在 C 语言中嵌入汇编代码
内联汇编(inline assembly)是在 C 源码中直接书写汇编指令,并让这些指令与周围的 C 变量、函数共享同一套编译流程的能力。它存在的理由很直接:C 是高级语言,但有些事情它天然表达不了——读取 CPU 时间戳计数器(rdtsc)、查询处理器特性(cpuid)、实现原子比较交换(cmpxchg),这些指令在 C 标准里根本没有对应的语法。操作系统内核、高性能库(如 glibc、Redis 的部分热路径)都会用内联汇编补上这块短板。

初学者容易把内联汇编想象成”在 C 里开一个洞直接钻到机器码”,这个直觉只对了一半:asm 语句确实会被原样交给汇编器,但它仍然要经过编译器的解析、优化和寄存器分配——你要向编译器”申报”自己用了什么、改了什么,否则编译器会按你没申报的假设去优化周围的 C 代码,然后程序在毫无警告的情况下出错。这篇文章就围绕这条主线展开:先看两种语法形态和它在编译流程中的位置,再逐段拆解 GCC 扩展 asm 的四段式语法,接着用 5 个可运行的实战示例练手,最后讲清楚常见陷阱和”什么时候根本不该用它”。
两种语法形态
先给结论:主流编译器上写内联汇编只有两条路——GCC/Clang 的 asm 语句(分基本与扩展两种形态),以及 MSVC 的 __asm 块。两者语法完全不兼容,写之前先确认你的编译器。
最简单的形态是 GCC 的基本内联汇编:一条 asm 加一个字符串,字符串里的内容会原样进入汇编输出。它适合那些”既不要输入也不要输出”的指令:
int main(void)
{
asm("nop"); /* 什么也不做,占一个指令周期 */
asm("cli"); /* x86:关中断(需要内核态权限) */
return 0;
}
基本形态的问题在于它和 C 代码”语言不通”:你无法把一个 C 变量喂给汇编指令,也无法把结果带回来,寄存器用坏了编译器也不知道。所以实际工程里 90% 的场景用的是扩展内联汇编——它通过”操作数约束”把 C 变量和汇编指令连接起来,这是第二节的主角。MSVC 则走另一条路:用 __asm { } 大括号块直接写汇编,块内可以直接引用 C 变量名。
| 编译器 | 基本 asm | 扩展 asm(操作数约束) | __asm {} 块 |
|---|---|---|---|
| GCC | 支持 | 支持(本文重点) | 不支持 |
| Clang | 支持 | 支持(兼容 GCC 语法) | 不支持 |
| MSVC(x86) | 不支持 | 不支持 | 支持 |
| MSVC(x64) | 不支持 | 不支持 | 不支持(需用 intrinsics) |
还有一个语法细节值得先记住:关键字写 asm 或 __asm__ 都可以,后者在未开启 GNU 扩展(如 -std=c99 -pedantic)时才有效;类似地,volatile 在这里写作 __volatile__。另外 GCC 默认生成 AT&T 语法(源操作数在前,如 movl %eax, %ebx),与 Intel 语法(目的在前)方向相反,本文所有 x86 示例均为 AT&T 语法。
在深入语法之前,值得花一张图弄清 asm 语句在整个编译流程中的位置——理解了它”被编译器包裹但不被编译器理解”,后面所有关于 clobber、volatile 的规则都会变得顺理成章:

扩展 asm 四段式语法
扩展内联汇编的全部语法浓缩在一行里:asm(模板 : 输出操作数 : 输入操作数 : clobber 列表)。除模板外其余三段均可省略,但冒号必须按顺序保留占位。下面逐段拆解。
模板:指令怎么写
模板就是汇编指令文本,但多了两个特殊符号。第一,%0、%1 这样的占位符会按顺序被替换成输出、输入操作数实际分配的位置(寄存器或内存地址);第二,模板里的寄存器要写成 %%eax(双百分号),因为单个 % 已被占位符占用。多条指令之间用 \n\t 分隔——换行让生成的汇编文件每条指令占一行,制表符对齐生成代码的缩进,这是纯粹的格式美观问题,但几乎所有内核代码都遵守这个惯例。
输出与输入操作数:C 变量怎么接入
每个操作数写成 "约束"(C 表达式) 的形式,输出在前、输入在后。输出操作数必须带 = 或 + 前缀:"=r" 表示”只写”(汇编执行前该变量的值无意义),"+r" 表示”读写”(先读旧值再写入新值)。输入操作数没有前缀。编译器读这些约束后自行决定每个操作数放在哪个寄存器(或内存),再把决定代入模板中的占位符——你写的是”我要一个通用寄存器”,而不是”必须用 eax”,这让编译器保留了一定的寄存器分配自由度。
一个最小但完整的例子:用 addl 实现两数相加。
static inline int add(int a, int b)
{
int result;
__asm__("addl %1, %0"
: "=r"(result) /* 输出:result 放进某个通用寄存器 */
: "r"(a), "0"(b)); /* 输入:a 用任意寄存器;b 复用第 0 个操作数的位置 */
return result;
}
注意 "0"(b) 这个写法:数字约束匹配约束,意思是”b 和第 0 个操作数(result)用同一个位置”。这条指令会被替换成类似 addl %eax, %eax 的形式(b 先被放进 result 的寄存器),语义正确且不多占用寄存器。如果不想用匹配约束,也可以直接指定具体寄存器:"a"(a), "b"(b) 分别把 a、b 绑定到 eax、ebx,此时模板里的寄存器要写全名,占位符照用不误。
约束字符速查
常用的约束字符和修饰符整理如下表,写代码时按需查表即可:
| 约束 | 含义 | 典型用途 |
|---|---|---|
r |
任意通用寄存器 | 最常用的默认选择 |
m |
内存操作数(变量地址) | 原子指令操作内存,如 lock xadd |
i |
编译期整型立即数 | 移位位数、掩码常量 |
a/b/c/d |
固定为 eax/ebx/ecx/edx | 指令本身规定了寄存器,如 rdtsc、cpuid |
D/S |
固定为 edi/esi | 字符串指令 movsb、rep 系列 |
A |
edx:eax 寄存器对 | 64 位结果拆两个 32 位寄存器的场景 |
=(前缀) |
该操作数只写 | 所有输出操作数的最低要求 |
+(前缀) |
该操作数读写 | 原地的加减、位翻转 |
&(前缀) |
早破坏输出(输入用完前可先写) | 多输出的指令防止寄存器复用出错 |
0~9(前缀) |
匹配某个已有操作数的位置 | 让输入与输出共用寄存器 |
clobber 列表:向编译器申报副作用
clobber 列表(也叫”破坏列表”)回答的问题是:这段汇编除了声明的输入输出,还动了哪些不该动的东西?指令可能临时借用一个没出现在操作数里的寄存器,可能修改标志寄存器,可能读写你根本没提到的内存。把这些如实申报在第四段,编译器就会避开这些位置、或在必要时重新加载数据。三种典型申报:
- 寄存器名:如
: "ecx", "edi"——模板里用了这些寄存器存中间结果,必须列出让编译器避让。 "cc"——指令改变了条件标志位(x86 上几乎所有的算术、逻辑指令都会,因此 x86 内核代码里它常被省略,但显式写上无害且更严谨)。"memory"——汇编读写了你没有通过指针操作数声明的内存。它既是”这些内存变了”的通知,也是一道编译器内存屏障:优化器不会把该 asm 前后的内存访问跨越它重排。
把四段合起来,就能读懂 Linux 内核里随处可见的那种完整写法了:扩展 asm 的本质是一份契约——你用模板告诉汇编器做什么,用约束和 clobber 告诉编译器它动了什么,两边的信任全靠申报的完整性。
五个实战示例
这一节的 5 个例子覆盖了内联汇编最高频的用途:读时钟、查 CPU 特性、原子操作、内存屏障,以及 MSVC 的对应写法。它们都短小到可以直接复制进你的工程。
示例 1:rdtsc 高精度计时
rdtsc 把 CPU 的时间戳计数器读进 edx:eax 两个寄存器,C 无法直接表达”一条指令写两个寄存器”。注意指令的寄存器是硬件固定的,所以操作数约束直接绑定 a 和 d,最后在 C 里拼成 64 位整数:
static inline unsigned long long rdtsc(void)
{
unsigned int lo, hi;
__asm__ __volatile__("rdtsc"
: "=a"(lo), "=d"(hi) /* eax→lo,edx→hi,无输入 */
:
: /* 无额外 clobber:rdtsc 只写 edx:eax */);
return ((unsigned long long)hi << 32) | lo;
}
这里的 __volatile__(下一节详谈)防止编译器看你”没用返回值”就把整条指令删掉——计时场景里指令本身就是副作用。
示例 2:cpuid 查询处理器特性
cpuid 按 eax 传入”功能号”,把结果写进 eax/ebx/ecx/edx 四个寄存器。输入输出都由指令的硬件格式决定,是最能体现”固定寄存器约束”价值的例子:
static inline void cpuid(unsigned int leaf, unsigned int regs[4])
{
__asm__ __volatile__("cpuid"
: "=a"(regs[0]), "=b"(regs[1]),
"=c"(regs[2]), "=d"(regs[3])
: "a"(leaf));
}
调用 cpuid(1, regs) 后,检查 regs[3] & (1 << 25) 即可判断 CPU 是否支持 SSE 指令集。
示例 3:lock xadd 原子加法
原子操作必须同时在寄存器和内存上做文章:+ 前缀表达”旧值换新值”,m 约束把指针指向的内存接入指令,lock 前缀保证总线级原子性,"memory" 申报内存被改写:
static inline int atomic_fetch_add(int *ptr, int value)
{
__asm__ __volatile__("lock xaddl %0, %1"
: "+r"(value), "+m"(*ptr) /* value 进来是加数,出去是旧值 */
:
: "memory");
return value; /* 返回加法前的旧值 */
}
示例 4:一行代码的内存屏障
这条可能是内核源码里出现频率最高的 asm:模板为空(不生成任何指令),纯粹用 "memory" clobber 让编译器”忘掉”所有缓存于寄存器中的内存值假设,禁止跨越屏障的访存重排:
#define barrier() __asm__ __volatile__("" ::: "memory")
顺带一个”小而美”的例子:bsfl(向前位扫描)找出最低位的 1 在第几位,比 C 循环逐位判断快得多,输入约束 "rm" 表示”寄存器或内存都行,编译器你看着办”:
static inline int first_set_bit(unsigned int x)
{
int pos;
__asm__("bsfl %1, %0"
: "=r"(pos)
: "rm"(x));
return pos; /* first_set_bit(0x8) == 3 */
}
示例 5:MSVC 的 __asm 块
MSVC(仅 32 位 x86)不需要学约束语法,直接在大括号里写 Intel 语法的汇编,块内可引用 C 变量名。同样实现 rdtsc:
unsigned long long rdtsc(void)
{
unsigned long long t;
__asm {
rdtsc
mov dword ptr [t], eax
mov dword ptr [t+4], edx
}
return t;
}
省心的代价是编译器对 __asm 块内的优化非常保守,而且 64 位 MSVC 直接移除了这一特性——x64 下微软只提供 intrinsics(如 __rdtsc())。这也是 MSVC 平台的现实:新代码几乎都应走 intrinsics 路线。
常见陷阱
先说最重要的一句:忘记申报副作用是内联汇编 bug 的头号来源,而且它不报错、不警告,只在你开高优化等级时随机发作。下面五个陷阱都源于”申报不完整”,按踩坑频率排序:
- 漏写寄存器 clobber:模板里用了
%%ecx却没在 clobber 列表写"ecx",编译器可能恰好把某个 C 变量缓存在 ecx 里,asm 执行完变量值就神秘变了。排查极难,因为低优化等级下寄存器压力小、常常恰好不冲突。 - 漏写
__volatile__:没有输出操作数(或输出未被使用)的 asm 语句,可能被优化器判定为”无效果”直接删除。rdtsc、内存屏障这类”指令即副作用”的场景必须加 volatile。 - 漏写
"memory"clobber:汇编改写了指针指向的内存,却没申报,编译器可能把屏障之前的旧值缓存到寄存器里继续用。示例 3 的原子加法若删掉"memory",高优化下读取计数器的代码就可能拿到过期值。 - 把 volatile 当万能药:volatile 只阻止”删除”和”重排 asm 语句本身”,并不生成任何硬件同步指令——它管编译器,不管 CPU 乱序执行。需要真正的内存序保证,x86 上要用
lock前缀指令或mfence。 - 忽视可移植性:asm 语句绑定了具体架构(x86 的例子在 ARM 上根本汇编不过)和具体编译器(GCC 语法 MSVC 不认)。跨平台项目要用宏把汇编隔离在单独的平台分支里,或干脆用下一节的替代方案。
替代方案与选型
内联汇编是最后手段而不是第一选择。现代编译器提供了两个更安全的层次:第一层是 intrinsics(内在函数)——编译器内置的、以普通函数形式封装的机器指令,如 <immintrin.h> 里的 _mm_add_epi32(SSE 向量加法)、__rdtsc()、_InterlockedIncrement()(MSVC 原子自增)。它们有类型检查、能被优化器理解、跨编译器版本稳定。第二层是编译器内置函数,如 GCC 的 __builtin_popcount(位计数)、__builtin_expect(分支预测提示),它们由编译器自动展开成当前架构上最优的指令序列。第三种选择是独立汇编文件:把汇编写成完整的函数放到单独的 .s 文件里,按调用约定与 C 链接,适合成段的核心算法。
独立汇编文件的形态长这样(x86-64 System V ABI,前两个整型参数经 rdi、rsi 传入,返回值放 eax):
# fast_add.S —— 独立汇编文件(AT&T 语法)
.globl fast_add
.text
fast_add:
leal (%rdi, %rsi), %eax # eax = rdi + rsi
ret
/* main.c —— C 侧只需一个 extern 声明 */
extern int fast_add(int a, int b);
int main(void)
{
return fast_add(3, 4); /* 返回 7 */
}
编译时把两个文件一起交给编译器即可:gcc main.c fast_add.S -o demo。三种方式的取舍汇总如下:
| 方式 | 开发难度 | 优化器友好度 | 可移植性 | 适用场景 |
|---|---|---|---|---|
| intrinsics / 内置函数 | 低 | 高(可参与调度优化) | 中(编译器间有差异) | SIMD、原子操作、特殊指令 |
| 扩展内联汇编 | 高 | 中(黑盒,但操作数可交互) | 低(绑定架构与编译器) | 零星指令、需要 C 变量直接交互 |
| 独立汇编文件 | 高 | 低(整函数黑盒) | 低(绑定架构) | 成段核心算法、启动代码 |
把选型逻辑画成一张决策图,遇到”要不要写汇编”的问题时按图索骥即可:

结论
回到起点:内联汇编解决的是”C 表达不了、编译器又没封装”的最后一公里问题。掌握它的路径很清晰——先记住四段式骨架 asm(模板 : 输出 : 输入 : clobber),再学会用约束字符表达”操作数放哪”,最后养成”每写一条指令就问自己动了哪些寄存器和内存”的申报习惯。
一句话总结:内联汇编是与编译器签订的契约——模板负责对汇编器说话,约束与 clobber 负责对编译器说实话,漏报的每一个副作用都是一颗延迟引爆的炸弹。
行动建议:从本文的 rdtsc 例子开始动手,在你的机器上分别用 -O0 和 -O2 编译并观察反汇编(gcc -S -O2 -masm=intel 输出最易读),体会 volatile 和 clobber 对生成代码的影响;之后再遇到需求,先查 intrinsics 头文件,查不到再回来写 asm。
参考文献 / 扩展阅读
- GNU Project, “Extended Asm — Using the GNU Compiler Collection (GCC)”, https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html
- Microsoft Learn, “__asm”, https://learn.microsoft.com/en-us/cpp/assembler/inline/asm
- Brennan “Basic” Underwood, “Brennan’s Guide to Inline Assembly”, http://www.delorie.com/djgpp/doc/brennan/brennan_att_inline_djgpp.html
- Intel Corporation, Intel® 64 and IA-32 Architectures Software Developer’s Manual, Volume 2: Instruction Set Reference.





