内联汇编(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 字符串指令 movsbrep 系列
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。

参考文献 / 扩展阅读

0