内联汇编精通:在 C 里直接发硬件指令 (Inline Assembly Mastery)


章节概述

前几章介绍了许多”C 语言无法表达的指令”——in/outrdmsr/wrmsrcpuidlgdt/lidt。好消息是:你不必用纯汇编写整个程序来使用这些指令。GCC 的扩展内联汇编(extended asm)允许你在 C 代码中嵌入任意汇编指令,同时让编译器处理变量映射和优化。这就是本章的主题——从基础 asm("nop") 到复杂的多寄存器约束、asm goto、内存屏障和原子操作。如果你写过 C 驱动代码,你会在 C教程的 MMIO 章节 看到过简单的 asm volatile("":::"memory") —— 本章将其展开为完整的武器库。

核心理念:内联汇编是 C 与汇编之间的”接口”——你写的是汇编指令字符串,编译器负责将 C 变量映射到寄存器并插入到正确的执行流中。掌握约束语法(constraint)是精通内联汇编的关键——它决定了哪条指令用哪个寄存器、哪个变量被读写、哪些寄存器被破坏。


第一节:基础 asm vs 扩展 asm

1.1 两种内联汇编形式

// 形式一:基础 asm(Basic Asm)—— 极其简单,编译器"盲目"插入
asm("nop"); // 插入一条 nop 指令
asm("int $3"); // 插入断点(GDB 在此停下)
 
// 形式二:扩展 asm(Extended Asm)—— 带操作数、约束和 clobber
asm volatile (
 "movl %1, %%eax\n\t" // 模板中 %% 转义 %
 "addl %2, %%eax\n\t"
 "movl %%eax, %0"
 : "=r"(result) // 输出操作数列表
 : "r"(a), "r"(b) // 输入操作数列表
 : "eax", "cc" // clobber 列表(被破坏的寄存器)
);

1.2 为什么需要 volatile

asm volatile 告诉编译器”即使结果看起来没用也不要优化掉这条 asm”:

// 不带 volatile: 编译器可能认为没有副作用而删除它
asm("rdtsc"); // 危险!编译器可能删除
 
// 带 volatile: 编译器必须保留,即使看似"无输出"
asm volatile("rdtsc" : "=a"(low), "=d"(high));
// 告诉编译器: EAX 和 EDX 被修改了,它们在 "=a" 和 "=d" 约束中
 
// 内存屏障: 无输出也没问题,volatile 禁止优化
asm volatile("mfence" ::: "memory");

1.3 扩展 asm 的完整模板

asm [volatile] [goto] (
 "汇编指令模板\n\t" ← 字符串,可用多行(\n\t 换行缩进)
 : 输出操作数列表 ← [ [constraint]"变量名" ], ...
 : 输入操作数列表 ← 同上
 : clobber 列表 ← 被破坏的寄存器/条件码/内存
 : goto 标签列表 ← 仅 asm goto,指定可能跳转的目标标签
);

每个部分都可以为空。最简形式:asm volatile("cli" ::: "memory") —— 模板 + 只有 clobber 列表。


第二节:约束语法 —— 把 C 变量映射到汇编操作数

2.1 输出约束 (Output Constraints)

int result;
asm ("movl $42, %0" : "=r"(result));
// %0 = 第 0 个操作数 (result)
// "=r" = "写入" + "任意通用寄存器"
// 编译器选择哪个寄存器 → 用 %0 引用它
 
// 多个输出
int quotient, remainder;
asm ("divl %4"
 : "=a"(quotient), "=d"(remainder) // %0=quotient(eax), %1=remainder(edx)
 : "a"(dividend), "d"(0), "r"(divisor)); // %2=dividend(eax), %3=0(edx)
// 注意: 输入也参与编号。%0=quotient, %1=remainder, %2=dividend, %3=0, %4=divisor

关键输出修饰符:

修饰符含义
=只写输出(Write-only output)
+读写操作数(Read-Write)——既是输入也是输出
&Earlyclobber —— 在读取所有输入操作数之前就被写入。防止编译器将此操作数分配到输入操作数所在的寄存器
// Earlyclobber 示例
asm ("movl %1, %0\n\taddl $1, %0"
 : "=&r"(result) // & 防止 result 和 input 共享寄存器
 : "r"(input));

2.2 输入约束 (Input Constraints)

// "r" —— 任意通用寄存器
asm ("addl %1, %0" : "=r"(sum) : "r"(a), "0"(b));
// ^^^ "0" = 与第 0 个操作数使用同一寄存器
// 等价于: sum = b; sum += a;
 
// "i" —— 立即数整数(编译时常量)
asm ("int %0" :: "i"(0x80)); // 0x80 作为立即数嵌入指令
 
// "m" —— 内存操作数
int addr;
asm ("lgdt %0" :: "m"(gdt_desc)); // gdt_desc 必须是内存中的结构
 
// "g" —— 通用("rim"):寄存器/立即数/内存,编译器选择最方便的
asm ("movl %1, %0" : "=r"(x) : "g"(42));

2.3 特定寄存器约束

约束寄存器(x86)用途
aeax / rax累加器、系统调用号、in/out 的 AL
bebx / rbx基址寄存器
cecx / rcx计数器、rdmsr/wrmsr 的 MSR 索引
dedx / rdx数据、rdmsr/wrmsr 的高 32 位
Sesi / rsi源索引
Dedi / rdi目标索引
Aeax:edx / rax:rdx64 位值拆分到两个 32 位寄存器
q8 位寄存器子集 (al, bl, cl, dl)字节操作
Nd0~255 立即数(in/out 端口约束专用)in/out 的立即数端口号
// 用特定寄存器约束写 inb / outb
static inline uint8_t inb(uint16_t port) {
 uint8_t value;
 asm volatile ("inb %1, %0"
 : "=a"(value) // AL 寄存器
 : "Nd"(port)); // 端口号: 0-255 用立即数, 否则自动放入 DX
 return value;
}
 
static inline void outb(uint16_t port, uint8_t value) {
 asm volatile ("outb %0, %1"
 :: "a"(value), // AL = 要写的数据
 "Nd"(port)); // 端口号
}
 
// 用特定寄存器约束写 rdmsr
static inline uint64_t rdmsr(uint32_t msr) {
 uint32_t low, high;
 asm volatile ("rdmsr"
 : "=a"(low), "=d"(high) // EAX 和 EDX
 : "c"(msr)); // ECX = MSR 索引
 return ((uint64_t)high << 32) | low;
}
 
// 用特定寄存器约束写 wrmsr
static inline void wrmsr(uint32_t msr, uint64_t value) {
 uint32_t low = (uint32_t)(value);
 uint32_t high = (uint32_t)(value >> 32);
 asm volatile ("wrmsr"
 :: "a"(low), "d"(high), "c"(msr));
}

这些内联汇编封装就是 Linux 内核头文件 asm/msr.h 的核心实现——Linux 内核用 1000+ 内联汇编来桥接 C 和硬件。


第三节:Clobber 列表 —— 告诉编译器谁被改了

3.1 三类 Clobber

// 寄存器 clobber: 告诉编译器哪些寄存器被 asm 修改了
asm volatile ("cpuid"
 : "=a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx)
 : "a"(leaf)
 : ); // cpuid 修改了 EAX/EBX/ECX/EDX,但已在输出列表中声明
// 如果 asm 内部修改了不在输出列表中的寄存器,必须在 clobber 中声明:
asm volatile ("some_instruction"
 : : : "rax", "rcx", "rsi"); // 这三个寄存器被破坏
 
// "cc" clobber: 告诉编译器条件码寄存器被修改
asm volatile ("addl %1, %0" : "=r"(x) : "r"(a), "0"(x) : "cc");
// addl 修改了 EFLAGS 的 ZF/SF/CF/OF 等位 → 添加 "cc"
 
// "memory" clobber: 告诉编译器"内存的内容可能被修改了"
asm volatile ("movl $0, %0" : "=m"(status_register) :: "memory");
// 编译器会假设"任何可间接访问的内存"都可能被 asm 改变
// → 强制编译器在 asm 前后刷新所有寄存器中缓存的内存值

3.2 “memory” clobber 的内存屏障副作用

volatile int *mmio_register = (volatile int *)0xFED00000;
 
// 场景: 写命令寄存器后读状态寄存器
*mmio_register = 0x01; // 发送"开始 DMA"命令
// 问题: 编译器可能重排,把后面的读提前到这里之前!
asm volatile("" ::: "memory"); // 完整内存屏障
int status = *(mmio_register + 1); // 读取状态(保证在命令之后)
 
// 等价但更精确的做法:
asm volatile("sfence" ::: "memory");
// sfence 只屏障 store,不屏障 load

关于内存屏障在裸机/设备驱动中的使用场景,详见 O 与 MMIO 的内存屏障节


第四节:asm goto —— 内联汇编中的跳转

4.1 基础语法

asm goto 允许汇编代码跳转到 C 标签——这是实现 unlikely/likely 宏和硬件原子操作的基础:

// 基础语法
asm goto (
 "cmp %1, %0\n\t"
 "jne %l[label_true]\n\t" // %l[label] = 标签引用
 : // 无输出操作数(asm goto 不允许输出)
 : "r"(a), "r"(b)
 : "cc" // clobber
 : label_true // goto 标签列表
);
 
// 如果 a != b → 跳转到 label_true
printf("a == b\n");
return;
 
label_true:
printf("a != b\n");

4.2 实际应用:原子自旋锁

// Linux 内核风格的原子 test-and-set 自旋锁
static inline int arch_spin_trylock(volatile int *lock) {
 int oldval;
 asm volatile (
 "movl $1, %0\n\t"
 "xchgl %0, %1"
 : "=r"(oldval), "+m"(*lock)
 : // 输入为空
 : "memory"
 );
 return oldval == 0; // 0 = 成功获取锁
}
 
// 带退出的自旋(asm goto 版本)
static inline void arch_spin_lock(volatile int *lock) {
 asm goto (
 "1: lock; decl %0\n\t"
 "jns %l[acquired]\n\t" // 如果不为负 → 成功获取
 "2: pause\n\t" // 省电 + 减少总线争用
 "cmpl $0, %0\n\t"
 "jle 2b\n\t"
 "jmp 1b"
 : // 无输出(asm goto)
 : "m"(*lock)
 : "cc", "memory"
 : acquired
 );
acquired:
 ; // 锁已获取
}

第五节:实战武器库 —— 常用内联汇编封装

5.1 CPUID 完整封装

#include <stdint.h>
#include <cpuid.h> // GCC 提供的内置 cpuid —— 但你应理解其实现
 
// 手动实现 —— 理解底层
static inline void cpuid(uint32_t leaf, uint32_t *eax, uint32_t *ebx,
 uint32_t *ecx, uint32_t *edx) {
 asm volatile ("cpuid"
 : "=a"(*eax), "=b"(*ebx), "=c"(*ecx), "=d"(*edx)
 : "a"(leaf));
}
 
// 调用
uint32_t eax, ebx, ecx, edx;
cpuid(1, &eax, &ebx, &ecx, &edx);
// ECX bit 28 = AVX, EDX bit 25 = SSE, EDX bit 26 = SSE2

5.2 RDTSC —— 高精度时间戳

static inline uint64_t rdtsc(void) {
 uint32_t low, high;
 asm volatile ("rdtsc" : "=a"(low), "=d"(high));
 return ((uint64_t)high << 32) | low;
}
 
// 加内存屏障版本(防止指令重排)
static inline uint64_t rdtsc_ordered(void) {
 uint32_t low, high;
 asm volatile ("mfence\n\trdtsc" : "=a"(low), "=d"(high) :: "memory");
 return ((uint64_t)high << 32) | low;
}

5.3 停机和停中断

// 停机等待中断(仅在 Ring 0 可用)
static inline void hlt(void) {
 asm volatile ("hlt" ::: "memory");
}
 
// 关中断(仅在 Ring 0 可用)
#define cli() asm volatile("cli" ::: "memory")
 
// 开中断(仅在 Ring 0 可用)
#define sti() asm volatile("sti" ::: "memory")
 
// 保存 EFLAGS 并关中断(禁用中断一段时间后恢复)
static inline unsigned long native_save_and_cli(void) {
 unsigned long flags;
 asm volatile ("pushfq\n\tpopq %0\n\tcli"
 : "=r"(flags) :: "memory");
 return flags;
}
 
static inline void native_restore_fl(unsigned long flags) {
 asm volatile ("pushq %0\n\tpopfq" :: "r"(flags) : "cc", "memory");
}

5.4 原子操作

// 原子地交换值 (lock xchg)
static inline uint32_t atomic_xchg(volatile uint32_t *ptr, uint32_t new_val) {
 uint32_t old_val;
 asm volatile ("lock; xchgl %0, %1"
 : "=r"(old_val), "+m"(*ptr)
 : "0"(new_val));
 return old_val;
}
 
// 原子地比较并交换 (lock cmpxchg)
// 如果 *ptr == expected → *ptr = desired, 返回 expected
// 否则 返回 *ptr 的当前值
static inline uint32_t atomic_cmpxchg(volatile uint32_t *ptr,
 uint32_t expected, uint32_t desired) {
 uint32_t old;
 asm volatile ("lock; cmpxchgl %2, %1"
 : "=a"(old), "+m"(*ptr)
 : "r"(desired), "0"(expected));
 return old;
}
 
// 原子加法并返回旧值 (lock xadd)
static inline uint32_t atomic_fetch_add(volatile uint32_t *ptr, uint32_t val) {
 asm volatile ("lock; xaddl %0, %1"
 : "+r"(val), "+m"(*ptr)
 :: "memory");
 return val; // xadd 后 val 包含旧值
}

lock 前缀告诉 CPU”这块内存总线归我用了”——在 SMP 系统中确保多核/多处理器看到一致的原子操作。这些指令构成了所有高级同步原语(互斥锁、自旋锁、信号量)的底层基础。

5.5 读/写 MSR(需 Ring 0)

// rdmsr / wrmsr 完整封装(与 Linux 内核 msr.h 一致)
static inline uint64_t __rdmsr(uint32_t msr) {
 uint32_t low, high;
 asm volatile ("rdmsr" : "=a"(low), "=d"(high) : "c"(msr));
 return ((uint64_t)high << 32) | low;
}
 
static inline void __wrmsr(uint32_t msr, uint64_t value) {
 uint32_t low = (uint32_t)(value);
 uint32_t high = (uint32_t)(value >> 32);
 asm volatile ("wrmsr" :: "a"(low), "d"(high), "c"(msr));
}

第六节:NASM ↔ AT&T 转换速查

由于 GCC 内联汇编使用 AT&T 语法,而本教程的独立汇编程序使用 NASM(Intel)语法,这里提供快速转换表:

维度NASM (Intel)AT&T (GCC 内联 asm)
操作数顺序目标, 源 : mov rax, 1源, 目标 : mov $1, %rax
寄存器前缀无前缀 : rax, eax% : %rax, %eax
立即数前缀无前缀 : mov rax, 42$ : mov $42, %rax
内存引用[rax + 8]8(%rax)
内存引用 + 偏移[rbx + rcx*4 + 16]16(%rbx, %rcx, 4)
大小后缀mov dword [rax], 0movl / movw / movb
内联 asm 中 % 转义%% 在字符串中写 %
内联 asm 立即数端口in al, 0x60inb $0x60, %al / "inb %1, %0"
字符串换行\n\t 分隔多条指令
// 同一个操作,两种语法
 
// NASM (Intel):
// mov rax, [rbx + 8]
 
// 等价 AT&T (GCC 内联 asm):
asm ("movq 8(%%rbx), %%rax" : : : "rax");
// %0, %1 等引用操作数时不需要 %%,%% 用于字面寄存器名
 
// 或者用操作数绑定(推荐):
asm ("movq 8(%1), %0" : "=r"(result) : "r"(ptr));
// 让编译器选择寄存器,更安全

最佳实践:在内联 asm 中尽量让编译器通过约束选择寄存器,而非硬编码寄存器名。一方面可以避免 clobber 遗漏的 Bug,另一方面能充分利用编译器的寄存器分配优化。


第七节:内联汇编的陷阱和最佳实践

7.1 不要做的事

// 坏:修改寄存器但不在 clobber 中声明
asm volatile ("movl $42, %%ecx"); // 编译器不知道 ecx 被改 → 数据破坏
 
// 坏:修改内存但不用 "memory" clobber
int global_flag;
asm volatile ("movl $1, %0" :: "m"(global_flag));
// 后面 if (global_flag) ... 编译器可能用缓存的旧值
 
// 坏:假设变量在特定寄存器中
register int x asm("eax"); // register asm 声明不稳定
asm ("addl $1, %%eax" ::: "eax");
 
// 好:让约束系统分配
asm ("addl $1, %0" : "+r"(x)); // 编译器选寄存器,不需要指定 eax

7.2 必须做的事

// 对:volatile + "memory" 防止编译器优化掉或重排
asm volatile ("wbinvd" ::: "memory"); // 写回并失效缓存——不可省略
 
// 对:使用 earlyclobber (&) 防止输出/输入寄存器冲突
int result;
asm ("movl %1, %0\n\taddl $1, %0"
 : "=&r"(result) // & 关键: result 在读取 input 之前就被写入
 : "r"(input));
 
// 对:"cc" clobber 在修改了 EFLAGS 时
asm ("addl %1, %0" : "+r"(x) : "r"(y) : "cc");
// addl 修改了 ZF/SF/CF/OF 等条件码 → 必须声明 "cc"

7.3 性能提示

内联汇编会禁用部分编译器优化——编译器无法分析 asm 字符串的内容,因此将 asm 视为”黑盒”:

  1. 编译器不会通过 asm 重排指令(除非带 volatile 的 asm 之间有依赖)
  2. 编译器不在 asm 前/后缓存内存值(使用 "memory" clobber 时)
  3. 编译器不能将 asm 内的操作与其他指令融合

原则:只有真正需要用汇编才能做到的才用内联汇编(端口 I/O、MSR、控制寄存器、特殊指令)。能用 C 标准函数或编译器 intrinsic(__builtin_*)实现的就不要手写内联汇编。


小节练习


章节测试

一、判断题

判断题 1

asm volatile 中的 volatile 关键字与 C 变量的 volatile 完全相同——都表示”不要优化掉”。 ( )

  • 正确

  • 错误

判断题 2

在内联汇编中使用 rdmsr 指令时,MSR 索引必须放在 ECX 寄存器中——可以通过 "c" 约束保证这一点。 ( )

  • 正确

  • 错误

判断题 3

asm goto 可以有输出操作数,与普通扩展 asm 一样。 ( )

  • 正确

  • 错误

判断题 4

在内联汇编中写 "=r"(result),编译器会将 result 变量放到某个通用寄存器中,但程序员不知道具体是哪个寄存器——只能通过 %0 引用。 ( )

  • 正确

  • 错误

判断题 5

使用 "memory" clobber 后,编译器的性能没有损失——因为在读取内存时总是从缓存中获取。 ( )

  • 正确

  • 错误


动手练习题

练习题 1:用内联 asm 实现 CPUID 检测

难度: 简单

编写一个 C 程序,用 GCC 内联 asm 实现 cpuid 查询并打印以下信息:

  • CPU 厂商字符串(“GenuineIntel” 或 “AuthenticAMD”)
  • CPU 型号、家族和步进
  • 是否支持 MMX、SSE、SSE2、SSE3、SSSE3、SSE4.1、SSE4.2、AVX、AVX2

要求:每条 cpuid 调用都写成独立的内联 asm 函数(如 cpuid_leaf1()),不得使用 __builtin_cpu_supportscpuid.h

练习题 2:内联 asm rdtsc 计时器

难度: 简单

用内联 asm 封装 rdtsc 指令,测量一个纯 C for 循环(循环 1000 万次空操作)的 CPU 周期数。对比以下三种情况:

  1. 不加任何内存屏障
  2. 在 rdtsc 前加 mfence
  3. 在 rdtsc 前加 lfence(Intel 推荐的方式)

观察三种情况的时间差异——理解指令重排和内存屏障的真实影响。

练习题 3:Linux 用户态端口 I/O

难度: 简单

编写一个需要 root 权限运行的 C 程序,使用 ioperm() 获取端口 0x80(POST 调试端口)的访问权限,然后用内联 asm 的 outb 向该端口写入值。验证:

  • ioperm() 调用成功后内联 asm 的 outb 能正常执行
  • ioperm() 未调用时直接执行同样的内联 asm → 段错误(SIGSEGV)

这题让你体验”用户态通过内联汇编执行特权指令”的边界限制——ioperm() 是内核授予的临时权限。

练习题 4:原子计数器

难度: 简单

lock xaddl 内联 asm 实现一个无锁的原子计数器(atomic_inc),并用它协调两个线程各自加 1000 万次。对比:

  1. 使用内联 asm lock xaddl 的原子版本
  2. 使用普通 ++ 的非原子版本

观察非原子版本的结果是否正确(在多核系统上通常不正确)。输出两个版本的结果差异——理解硬件的 lock 前缀为什么是不可替代的。

练习

以下题目从汇编/底层视角训练相关能力:

题号题目链接涉及知识点
371两整数之和https://leetcode.cn/problems/sum-of-two-integers/位运算(add指令原理)
201数字范围按位与https://leetcode.cn/problems/bitwise-and-of-numbers-range/位移与位操作
190颠倒二进制位https://leetcode.cn/problems/reverse-bits/位操作、嵌入式模式