ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GCC内嵌汇编代码

GCC内嵌汇编代码

内嵌汇编代码基本用法

基础内嵌汇编代码

内嵌汇编,就是在 C 代码里直接写汇编指令,主要解决 3 个问题:

  • 极致性能优化:对时间敏感的代码(比如中断处理、原子操作),用汇编能实现编译器做不到的优化。
  • 访问特殊指令:像内存屏障、缓存维护、CPU 状态寄存器操作这类 C 语言无法直接调用的指令。
  • 硬件底层交互:直接操作寄存器、实现锁机制、原子操作等内核级功能。

它分为两种形式:

  • 基础内嵌汇编(basic asm):不带任何参数,格式最简单。
  • 扩展内嵌汇编(extended asm):支持输入 / 输出参数,是实际开发中最常用的形式。

基础内嵌汇编就是直接调用汇编指令,格式如下:

asm("汇编指令");

关键特点

  • asm是 GCC 扩展关键字,用来标记内嵌汇编代码块。
  • GCC 会把引号里的内容当成纯字符串,不会解析或优化里面的汇编指令。
  • 多条指令时,必须用\n换行,否则汇编器会报错。
  • 常见用法:调用特殊指令,比如缓存维护指令:
asm("icialluis"); // 调用高速缓存维护指令

扩展内嵌汇编代码

扩展内嵌汇编是带参数的完整形式,完整模板如下:

asm volatile ( "指令部分" // 汇编指令代码块 : 输出部分 // 描述哪些 C 变量会被汇编修改 : 输入部分 // 描述哪些 C 变量会被汇编读取 : 损坏部分 // 告诉编译器哪些寄存器/内存被修改 );

① 核心修饰符

修饰符作用
volatile关闭 GCC 对这段汇编的优化,防止编译器把汇编代码 “优化没了”,必须加!
inline让 GCC 把汇编编译成最短的机器码,减少函数调用开销
goto允许汇编指令跳转到 C 语言的标签(后面单独讲)

② 指令部分

写后面需要执行的汇编指令,多条指令用\n换行,比如:

"ldxr %0, [%2]\n" // 加载内存值到寄存器 "add %0, %0, %3\n" // 加法操作 "stxr %w1, %0, [%2]\n" // 存储结果回内存

这里的%0%1是占位符,对应后面输出 / 输入部分的参数,从 0 开始编号。

③ 输出部分:告诉编译器 “哪些变量被修改了”

格式::"约束条件"(变量名),多个参数用逗号分隔。

关键规则:

  • 必须用=或+开头:
    • =:表示变量只写(汇编只会修改它,不会读取初始值)。
    • +:表示变量可读可写(汇编既会读取初始值,也会修改它)。
    • "&"告诉编译器:输出寄存器不能和任何输入寄存器共用同一个物理寄存器
int x = 5, y = 10, z; __asm__ ( "mov %0, #0\n" // 先把结果清零 "add %0, %0, %1\n" // 加 x "add %0, %0, %2\n" // 加 y : "=r"(z) : "r"(x), "r"(y) ); // 如果编译器分配成: %0 = R0 (z) %1 = R0 (x) ← 和 %0 共用! %2 = R1 (y) // 实际执行: mov R0, #0 ; z = 0 add R0, R0, R0 ; 想加 x,但 x 也在 R0!实际:0+0=0(x=5 丢了) add R0, R0, R1 ; 加 y // 结果错误! 因为 x 的值在第一条指令就被覆盖了。 __asm__ ( "mov %0, #0\n" "add %0, %0, %1\n" "add %0, %0, %2\n" : "=&r"(z) // ← 加上 &,要求独立的寄存器 : "r"(x), "r"(y) );

常见约束符:

  • r:让 GCC 自动分配一个通用寄存器来存变量。
  • m:直接使用内存地址(不经过寄存器)。
  • Q:ARM64 原子操作专用,直接操作内存且隐含memory约束。
  • o:要求内存地址是基地址+偏移的形式
  • v:内存变量(不允许偏移,如[R0, #4]这种不行 )
  • p:操作数是一个指针/地址,而不是地址里的值。
  • i:立即数,编译时已知的常量(asm ("mov %0, %1" : "=r"(a) : "i"(123));)

示例::"=r"(mask)表示mask变量是 “只写” 的,用通用寄存器传递。

④ 输入部分:告诉编译器 “哪些变量被读取了”

格式:: "约束条件"(变量名),多个参数用逗号分隔。

关键规则:

  • 不能用=或+开头,只能用只读约束(比如rm)。
  • GCC 会假设输入变量在汇编执行前后值不变,不会主动修改它。

示例:: "r"(idx), "r"(sz)表示idx和sz是汇编要读取的输入参数。

⑤ 损坏部分:告诉编译器 “哪些资源被破坏了”

格式:: "资源名",多个用逗号分隔,作用是让编译器避开这些资源,避免冲突。

常见值:

  • "memory":告诉编译器汇编修改了内存,必须重新加载内存值,防止乱序优化(原子操作 / 内存屏障必须加)。
  • "cc":告诉编译器汇编修改了状态寄存器(比如 CMP、BNEZ 指令会改标志位)。
  • 寄存器名:比如x0x1,表示汇编直接修改了这些寄存器,编译器不能再用它们存其他值。

⑥ 关键示例拆解

示例 1:关闭本地 IRQ(ARM64)

static inline unsigned long arch_local_irq_save(void) { unsigned long flags; asm volatile ( "mrs %0, daif \n" // 读取PSTATE寄存器的DAIF字段(IRQ状态) "msr daifset, #2 \n" // 关闭本地IRQ : "=r"(flags) // 输出:flags变量(只写,通用寄存器) : // 输入:无 : "memory" // 损坏:修改了内存/寄存器状态 ); return flags; }
  • 输出部分:%0对应flags,保存修改前的 IRQ 状态。
  • 损坏部分:"memory"确保编译器不会乱序优化这段代码。

示例 2:原子加法(ARM64)

void my_atomic_add(unsigned long val, void *p) { unsigned long tmp; int result; asm volatile ( "1: ldxr %0, [%2]\n" // 独占加载内存值到tmp "add %0, %0, %3\n" // tmp += val "stxr %w1, %0, [%2]\n" // 尝试独占存储,result=0表示成功 "cbnz %w1, 1b\n" // 失败则重试 : "=r"(tmp), "=r"(result), "+Q"(*(unsigned long *)p) : "r"(val) : "cc", "memory" ); }
  • 输出部分的+Q(*p):+表示可读可写,Q是 ARM64 原子操作专用约束,直接操作内存。
  • 损坏部分的"cc":因为cbnz指令修改了状态寄存器。

使用汇编符号名

为了避免用%0%1这种难读的编号,可以给参数起 “符号名”,格式:[符号名] "约束"(变量)。 示例:加法函数

int add(int i, int j) { int res = 0; asm volatile ( "add %w[result], %w[input_i], %w[input_j]" : [result] "=r"(res) // 输出:result对应res变量 : [input_i] "r"(i), [input_j] "r"(j) // 输入:input_i/input_j对应i/j ); return res; }

这样代码可读性直接拉满,不用再对着参数顺序数%0%1。

使用goto修饰词

当汇编需要跳转到 C 语言的标签时,用asm goto模板:

asm goto ( "指令部分" : // 输出部分必须为空! : 输入部分 : 损坏部分 : C语言标签列表 // 允许跳转的标签 );

示例:判断参数是否为 1

static int test_asm_goto(int a) { asm goto ( "cmp %w0, 1\n" // 比较a和1 "b.eq %l[label]\n" // 相等则跳转到label : : "r"(a) : "memory" : label // 允许跳转到C语言的label标签 ); return 0; label: printf("a = %d\n", a); return 0; }
  • 关键限制:输出部分必须为空,跳转标签要在最后声明。

内嵌汇编函数与宏的结合

内嵌汇编和 C 宏结合,能写出通用的原子操作函数,比如 Linux 内核的ATOMIC_OP宏:

#define ATOMIC_OP(op, asm_op) \ static inline void atomic_##op(int i, atomic_t *v) { \ int res, tmp; \ asm volatile( \ "0: ldxr %w[tmp], [%[v]]\n" \ #asm_op " %w[tmp], %w[tmp], %w[i]\n" \ " stxr %w[res], %w[tmp], [%[v]]\n" \ " cbnz %w[res], 0b\n" \ : [res] "=&r"(res), [tmp] "=&r"(tmp) \ : [i] "r"(i), [v] "r"(v) \ : "memory", "cc" \ ); \ } ATOMIC_OP(add, add)
  • ##op用来拼接函数名(比如atomic_add),#asm_op把宏参数转成汇编指令字符串,一行宏就能生成多个原子操作函数。

  • #转字符串符,生成指令名。

    " #asm_op " "%[i], %[v]\n"

    展开过程:

    参数 asm_op = stadd

    #asm_op → "stadd"(字符串)

    最终汇编指令:stadd %w[i], %[v]\n

具体案例分析

① 问题代码

static void swap_data(unsigned char *src, unsigned char *dst, unsigned int size) { unsigned int len = 0; unsigned int tmp; asm volatile ( "1: ldrh w5, [%[src]], #2\n" "lsl w6, w5, #8\n" "orr %w[tmp], w6, w5, lsr #8\n" "strh %w[tmp], [%[dst]], #2\n" "add %[len], %[len], #2\n" "cmp %[len], %[size]\n" "bne 1b\n" : [dst] "+r" (dst), [len] "+r" (len), [tmp] "+r" (tmp) : [src] "r" (src), [size] "r" (size) : "memory" ); } int main(void) { int i; unsigned char *bufa = malloc(SIZE); if(!bufa) { return 0; } unsigned char *bufb = malloc(SIZE); if(!bufb) { free(bufa); return 0; } for(int i=0; i<SIZE; i++) { bufa[i] = i; } swap(bua, bufb, SIZE); free(bufa); free(bufb); return 0; }

这个程序要实现一个功能:

  • 把 bufa 里的每个 2 字节,交换高低位,然后存到bufb里。

  • 功能跑起来是正常的,但 free(bufa) 的时候报错:

    munmap_chunk(): invalid pointer

    最后程序 Aborted 崩溃。

问题核心:bufa这个指针,在调用swap_data之后,指向的地址变了!释放的时候用了一个错误的地址,所以内存管理直接报错。

② 错误代码的核心问题

static void swap_data(unsigned char *src, unsigned char *dst, unsigned int size) { unsigned int len = 0; unsigned int tmp; asm volatile ( "1: ldrh w5, [%[src]], #2\n" // ① 问题出在这里! "lsl w6, w5, #8\n" "orr %w[tmp], w6, w5, lsr #8\n" "strh %w[tmp], [%[dst]], #2\n" "add %[len], %[len], #2\n" "cmp %[len], %[size]\n" "bne 1b\n" : [dst] "+r" (dst), [len] "+r" (len), [tmp] "+r" (tmp) : [src] "r" (src), [size] "r" (size) // ② 这里是致命错误! : "memory" ); }

错误点 1:src指针被指令本身修改了

关键指令:

ldrh w5, [%[src]], #2
  • ldrh是 “加载半字” 指令
  • [%[src]], #2表示:先从src地址加载数据,然后src += 2
  • 也就是说,这条指令执行完,src的值会自动 +2!

这意味着:src指针会在循环里被不断修改,从bufa的起始地址,一步步往后走。

错误点 2:src被放在了「输入部分」

: [src] "r" (src) // 这里是输入部分

GCC 对输入部分的参数有一个强假设:

输入参数的值,在汇编执行前后不会改变编译器会认为 src 只是一个 “只读” 的值,会用它原本的寄存器直接参与后续操作。

  • 汇编里用ldrh指令修改了src寄存器的值(自动 + 2)
  • 而 GCC 完全不知道,还以为寄存器里还是原来的bufa地址
  • 加上-O2优化,GCC 直接把swap_data内联进了main函数
  • 结果:修改的不是src的副本,而是main里保存bufa地址的那个寄存器!
  • 最后free(bufa)时,寄存器里已经不是bufa的起始地址了,而是已经被修改过的指针,导致报错。

隐藏错误点3:寄存器w5/w6没声明在损坏列表里

ldrh w5, [%[src]], #2 lsl w6, w5, #8

汇编里直接用了w5和w6两个寄存器,但在损坏列表里没写:

: "memory" // 没写 "w5", "w6"

GCC 不知道这两个寄存器被修改了,可能会把它们分配给其他变量使用,导致值被意外覆盖,引发随机 bug。

③ 正确的修复方案

static void swap_data(unsigned char *src, unsigned char *dst, unsigned int size) { unsigned int len = 0; unsigned int tmp; asm volatile ( "1: ldrh w5, [%[src]], #2\n" "lsl w6, w5, #8\n" "orr %w[tmp], w6, w5, lsr #8\n" "strh %w[tmp], [%[dst]], #2\n" "add %[len], %[len], #2\n" "cmp %[len], %[size]\n" "bne 1b\n" : [dst] "+r" (dst), [len] "+r" (len), [tmp] "+r" (tmp), [src] "+r" (src) // ① 把 src 移到输出部分,声明为可读可写 : [size] "r" (size) : "memory", "w5", "w6" // ② 把 w5、w6 加到损坏列表里 ); }

修复点 1:src从输入部分 → 输出部分,属性改为+r

: [src] "+r" (src)
  • +r表示:这个参数既可以读,也可以被修改。
  • GCC 会为src分配一个独立的临时寄存器,汇编修改的是这个副本,不会影响main里的原始bufa地址。
  • 从反汇编里能看到:GCC 会额外分配一个寄存器(比如x3),用来保存src的值,汇编里只操作x3,不会动原来的x21。

修复点 2:损坏列表里加上"w5""w6"

: "memory", "w5", "w6"
  • 告诉 GCC:w5和w6被我用了,你别把其他变量放这两个寄存器里,避免冲突。

④ 从反汇编看前后对比

错误版本的反汇编

// 错误版本:直接用了保存 bufa 地址的 x21 寄存器 ldrh w5, [x21], #2 // ... bl free(x21) // 此时 x21 已经被修改过了!
  • 汇编直接操作了x21(保存bufa地址的寄存器),修改了它的值。
  • 最后free用的是修改后的地址,所以报错。

修复版本的反汇编

// 修复版本:GCC 额外分配了 x3 寄存器 mov x3, x21 // 把 bufa 地址复制到 x3 ldrh w5, [x3], #2 // 只修改 x3,不碰 x21 // ... bl free(x21) // x21 还是原来的 bufa 地址,一切正常
  • 汇编操作的是临时寄存器x3,x21里的原始地址没被修改。
  • free用的是正确的地址,程序正常退出。

⑤ 这个案例给我们的 4 条关键教训

  • 必须明确参数的读写属性:汇编指令如果会修改参数的值(比如ldrh带!或, #2自增),就不能放在输入部分,必须用+r放在输出部分。
  • 手动使用的寄存器,必须在损坏列表里声明:直接写w5x10这类寄存器,一定要在:后面列出来,否则 GCC 会误以为这些寄存器是空闲的。可能导致后面的代码使用这类寄存器,破坏数据
  • memory损坏列表的作用:汇编里读写了内存,一定要加"memory",防止编译器乱序优化,导致数据不一致。
  • -O2优化会暴露所有内嵌汇编的问题:不加优化时,函数不会被内联,问题可能被隐藏;加了-O2后,所有约束不严谨的问题都会直接暴露出来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进