19、函数与内联汇编:在C函数中嵌入汇编,AT&T语法与Intel语法,系统调用的实现
说实话,很多C语言开发者写了好几年代码,都没碰过内联汇编。我刚开始也觉得没必要——编译器不挺聪明的吗?直到有一次做嵌入式底层驱动,发现某个关键路径的延时函数死活优化不到目标值。折腾了两天,最后在函数里嵌了三行汇编,问题直接解决。
从那天起我就明白了:内联汇编不是炫技,是真正能救命的硬功夫。今天我们就把它彻底讲透。
19.1 为什么要在C函数里嵌入汇编?
你想想看,C语言再底层,终究是高级语言。有些操作它确实管不了:
- 直接操作CPU特殊寄存器——比如ARM的CPSR、x86的CR0
- 执行特殊指令——像内存屏障、关中断、开中断
- 精确控制指令序列——某些时序敏感场景,编译器优化会打乱顺序
- 实现系统调用——用户态切换到内核态的唯一途径
我个人习惯是:能用C解决的问题绝不用汇编。但遇到上面这几种情况,该出手时就出手。
19.2 两种主流语法:AT&T vs Intel
x86世界里,汇编语法分两大流派。我在项目中两种都写过,说实话各有千秋。
| 对比项 | AT&T语法 | Intel语法 |
|---|---|---|
| 源操作数、目的操作数顺序 | movl %eax, %ebx(源在前,目的在后) |
mov ebx, eax(目的在前,源在后) |
| 寄存器前缀 | 需要 % 前缀 |
不需要 |
| 立即数前缀 | 需要 $ 前缀 |
不需要 |
| 内存操作数 | disp(base, index, scale) |
[base + index*scale + disp] |
| 指令后缀 | 有(如 l 表示long) |
无 |
| GCC默认 | 是 | 否(需加 -masm=intel) |
嗯,这里要注意:GCC默认使用AT&T语法。我当年第一次写内联汇编时,按Intel语法写进去,编译直接报错。查了半天才发现是语法问题。
-masm=intel 选项,但要注意这会影响整个文件的汇编风格。
19.3 GCC内联汇编的基本格式
GCC的内联汇编用 asm() 或 __asm__() 关键字。完整格式长这样:
asm [volatile] (
"汇编指令\n\t"
: 输出操作数列表 /* 可选 */
: 输入操作数列表 /* 可选 */
: 破坏描述列表 /* 可选 */
);
我第一次看到这个格式时,说实话有点懵。四个部分用冒号隔开,每个部分都可以为空。但用熟了就会发现,这个设计其实很巧妙。
举个最简单的例子:
#include <stdio.h>
int main() {
int a = 10, b = 20, result;
__asm__ volatile (
"movl %1, %%eax\n\t"
"addl %2, %%eax\n\t"
"movl %%eax, %0"
: "=r" (result) /* 输出:result = eax */
: "r" (a), "r" (b) /* 输入:a → %1, b → %2 */
: "%eax" /* 破坏:eax被修改 */
);
printf("result = %d\n", result); // 输出30
return 0;
}
这里有几个关键点:
%0、%1、%2是占位符,按顺序对应输出和输入操作数- 在AT&T语法中,寄存器前面要加两个
%(如%%eax),因为单个%被占位符用了 "=r"表示输出操作数,=表示只写,r表示任意寄存器volatile告诉编译器不要优化掉这段汇编——我建议每次都加上
volatile,结果编译器觉得这段汇编“没有副作用”,直接给优化掉了。调试了一整天,最后发现汇编代码根本没执行。从那以后,我写内联汇编必加 volatile。
19.4 操作数约束:让编译器帮你分配寄存器
你可能会问:为什么不直接写死用哪个寄存器?
原因很简单——编译器比你更懂寄存器分配。你写死用 eax,万一 eax 里正存着重要数据呢?
操作数约束就是告诉编译器:我需要什么样的寄存器或内存位置,你来帮我安排。
| 约束符 | 含义 | 示例 |
|---|---|---|
r |
任意通用寄存器 | "r" (var) |
m |
内存地址 | "m" (var) |
i |
立即数(编译时常量) | "i" (100) |
g |
通用(寄存器、内存、立即数均可) | "g" (var) |
= |
只写(用于输出操作数) | "=r" (out) |
+ |
读写(用于输入输出操作数) | "+r" (var) |
& |
早期破坏(不与输入共用寄存器) | "=&r" (out) |
举个实际点的例子——实现一个原子加操作:
static inline int atomic_add(int *ptr, int val) {
int old_val;
__asm__ volatile (
"lock xaddl %0, %1\n\t"
: "=r" (old_val), "+m" (*ptr)
: "0" (val)
: "memory", "cc"
);
return old_val + val;
}
这里用了 "0" 约束,意思是输入操作数0(val)要和输出操作数0(old_val)放在同一个寄存器。为什么?因为 xadd 指令要求源操作数和目的操作数都在寄存器中。
19.5 系统调用的实现:从用户态到内核态
系统调用是内联汇编最经典的应用场景之一。用户程序想访问硬件、创建进程、读写文件,都得通过系统调用进入内核态。
在x86 Linux上,系统调用的流程是这样的:
- 把系统调用号放入
eax - 把参数依次放入
ebx、ecx、edx、esi、edi、ebp - 执行
int $0x80或syscall指令 - 返回值在
eax中
下面是一个用内联汇编实现 write 系统调用的例子:
#include <unistd.h>
ssize_t my_write(int fd, const void *buf, size_t count) {
ssize_t ret;
__asm__ volatile (
"movl %1, %%ebx\n\t" /* fd → ebx */
"movl %2, %%ecx\n\t" /* buf → ecx */
"movl %3, %%edx\n\t" /* count → edx */
"movl $4, %%eax\n\t" /* 系统调用号4 = write */
"int $0x80\n\t" /* 触发系统调用 */
"movl %%eax, %0" /* 返回值 → ret */
: "=r" (ret)
: "r" (fd), "r" (buf), "r" (count)
: "%eax", "%ebx", "%ecx", "%edx"
);
return ret;
}
嗯,这里要注意:系统调用号在不同架构上不一样。x86的 write 是4,x86_64上就是1了。而且x86_64用 syscall 指令,参数放在 rdi、rsi、rdx 等寄存器中。
-m32 选项,结果 int $0x80 执行失败。后来查资料才知道,x86_64内核虽然兼容 int $0x80,但参数传递方式不同。建议64位平台直接用 syscall 指令。
19.6 内联汇编的破坏描述
破坏描述(clobber list)是告诉编译器:这段汇编代码会破坏哪些寄存器和状态。编译器会帮你保存和恢复这些值。
常见的破坏描述:
"%eax"、"%ebx"等——被修改的寄存器"memory"——内存被修改,阻止编译器缓存内存值"cc"——条件码寄存器被修改(标志位)
我见过很多新手写内联汇编时,破坏描述写不全。结果程序跑着跑着就出诡异bug——某个变量的值莫名其妙变了。其实就是编译器不知道某个寄存器被汇编代码改掉了。
"cc",导致后续的条件判断全部出错,查了整整两天。
19.7 知识体系总览
下面这张图总结了本章的核心知识结构:
19.8 实战建议
最后,分享几条我这些年写内联汇编的心得:
- 先写纯汇编调试——在C里嵌入之前,先用独立汇编文件把逻辑调通
- 多用
volatile——防止编译器自作主张优化掉你的汇编 - 破坏描述写全——这是内联汇编最容易出bug的地方
- 能用C别用汇编——内联汇编是最后的手段,不是第一选择
- 注意平台差异——x86、ARM、RISC-V的语法和系统调用号完全不同
内联汇编就像一把手术刀——用好了能精准解决问题,用不好可能伤到自己。多写、多调试、多踩坑,慢慢就熟练了。
公众号:蓝海资料掘金营,微信deep3321