19、函数与内联汇编:在C函数中嵌入汇编,AT&T语法与Intel语法,系统调用的实现

说实话,很多C语言开发者写了好几年代码,都没碰过内联汇编。我刚开始也觉得没必要——编译器不挺聪明的吗?直到有一次做嵌入式底层驱动,发现某个关键路径的延时函数死活优化不到目标值。折腾了两天,最后在函数里嵌了三行汇编,问题直接解决。

从那天起我就明白了:内联汇编不是炫技,是真正能救命的硬功夫。今天我们就把它彻底讲透。

19.1 为什么要在C函数里嵌入汇编?

你想想看,C语言再底层,终究是高级语言。有些操作它确实管不了:

  • 直接操作CPU特殊寄存器——比如ARM的CPSR、x86的CR0
  • 执行特殊指令——像内存屏障、关中断、开中断
  • 精确控制指令序列——某些时序敏感场景,编译器优化会打乱顺序
  • 实现系统调用——用户态切换到内核态的唯一途径

我个人习惯是:能用C解决的问题绝不用汇编。但遇到上面这几种情况,该出手时就出手。

核心原则:内联汇编是C语言的“逃生舱”,不是常规通道。只在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语法写进去,编译直接报错。查了半天才发现是语法问题。

我的建议:如果你主要在Linux/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上,系统调用的流程是这样的:

  1. 把系统调用号放入 eax
  2. 把参数依次放入 ebxecxedxesiediebp
  3. 执行 int $0x80syscall 指令
  4. 返回值在 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 指令,参数放在 rdirsirdx 等寄存器中。

避坑指南:我曾经在x86_64机器上编译x86的汇编代码,忘了加 -m32 选项,结果 int $0x80 执行失败。后来查资料才知道,x86_64内核虽然兼容 int $0x80,但参数传递方式不同。建议64位平台直接用 syscall 指令。

19.6 内联汇编的破坏描述

破坏描述(clobber list)是告诉编译器:这段汇编代码会破坏哪些寄存器和状态。编译器会帮你保存和恢复这些值。

常见的破坏描述:

  • "%eax""%ebx" 等——被修改的寄存器
  • "memory"——内存被修改,阻止编译器缓存内存值
  • "cc"——条件码寄存器被修改(标志位)

我见过很多新手写内联汇编时,破坏描述写不全。结果程序跑着跑着就出诡异bug——某个变量的值莫名其妙变了。其实就是编译器不知道某个寄存器被汇编代码改掉了。

血的教训:破坏描述宁可多写,不要少写。多写最多损失一点性能,少写可能导致数据损坏。我曾经因为漏写 "cc",导致后续的条件判断全部出错,查了整整两天。

19.7 知识体系总览

下面这张图总结了本章的核心知识结构:

函数与内联汇编知识体系 内联汇编 为什么需要? • 操作特殊寄存器 • 执行特殊指令 • 精确控制指令序列 • 实现系统调用 两种语法 AT&T语法(GCC默认) Intel语法(-masm=intel) 源/目的顺序相反 寄存器前缀不同 基本格式 asm [volatile] ( "指令\n\t" : 输出操作数 : 输入操作数 : 破坏描述 ); 操作数约束 r=寄存器 m=内存 i=立即数 g=通用 系统调用实现 eax=系统调用号 ebx/ecx/edx=参数 破坏描述 寄存器、memory、cc 宁可多写,不要少写

19.8 实战建议

最后,分享几条我这些年写内联汇编的心得:

  • 先写纯汇编调试——在C里嵌入之前,先用独立汇编文件把逻辑调通
  • 多用 volatile——防止编译器自作主张优化掉你的汇编
  • 破坏描述写全——这是内联汇编最容易出bug的地方
  • 能用C别用汇编——内联汇编是最后的手段,不是第一选择
  • 注意平台差异——x86、ARM、RISC-V的语法和系统调用号完全不同

内联汇编就像一把手术刀——用好了能精准解决问题,用不好可能伤到自己。多写、多调试、多踩坑,慢慢就熟练了。


公众号:蓝海资料掘金营,微信deep3321