汇编与指令集 入门
curl 终于要把字节送到网络上时,它发起一个 send() 调用。 这个调用编译下来是一小撮汇编指令,最后是一条 syscall —— 把控制权交给内核、把这一篇 primer 通向 Layer 2 的那条唯一特殊指令。 5 个 section 把全貌搭出来:编译器到底吐出了什么+ C → 汇编 的并排对照;x86 vs ARM —— 到 2025 年突然变重要的 CISC vs RISC 取舍;让函数之间用寄存器传参数的调用约定;syscall 指令+ 用户 / 内核态切换走读;最后是 速查表, 值得能冷讲清楚的那几个问题。
编译器到底吐出了什么
源码是给人看的。汇编才是真正在跑的。一旦你能读中间这一层,每个性能问题都从迷信变成具体。
C、C++、Rust、Go 编译器把源码变成 汇编(assembly)(像 mov、add、call 这种文本助记符), 再由汇编器把汇编变成 机器码(machine code)(CPU 真正解码的那些字节)。 汇编是机器码的 1:1 人类可读视图,带标签、注释、和少量像 .data、.text这种没机器码对应的伪操作。
指令集架构(ISA)是公开的合同:合法操作码的列表、 它们用哪些寄存器、它们接受哪些寻址模式、各自有什么副作用。 ISA 是 CPU 厂商实现的东西,软件以它为目标。 同样的 C 源码,针对 x86-64 和 ARM64 编译,产出完全不同的机器码,做同一件事 —— 因为两个 ISA 操作码不同。
最朴素的例子
看那个经典入门函数:
int add(int a, int b) {
return a + b;
}用 gcc -O2 针对 x86-64(Linux/Mac 用 SysV ABI)编译,吐出来的汇编是:
add:
lea eax, [rdi + rsi] ; 把两个参数寄存器加起来
ret ; 弹出返回地址、跳过去两条指令。按调用约定,参数到达 rdi 和 rsi 寄存器 (下一节解释怎么定的);返回值留在 eax(rax 的低 32 位)。 没碰内存 —— 小叶子函数全在寄存器里跑完,这是现代代码这么快的主要原因之一。lea(load effective address)指令被滥用做加法:lea eax, [rdi + rsi] 一条指令算出 rdi + rsi 并存好, 不碰进位标志 —— 编译器的常用把戏。
同一个函数在 ARM64 上
add:
add w0, w0, w1 ; 参数 0 = 参数 0 + 参数 1
ret ; 跳到链接寄存器两条指令,结构上同样的工作,助记符和寄存器名不同。 ARM 的 SysV 风格 ABI 把前 8 个整数参数走 x0–x7(或 32 位 int 走 w0–w7),返回值放 x0/w0。 ARM 上 ret 跳到链接寄存器(lr = x30), 里面装着调用者 bl(branch with link)指令设置的返回地址。
为啥要读汇编
3 个汇编素养能回本的具体时刻:
- Profiler 归因。
perf report显示热指令是用汇编展示的。 知道每个助记符代价多少 ——lea1 周期、div20+、 内存mov命中 1 周期未命中 ~100 周期 —— 就把「某源码行上的百分比」变成可执行的假设。 - 编译器的解释。编译器把那个循环向量化了吗?消除了那个数组边界检查吗?
std::move避免了拷贝吗?汇编是真相,源码是一厢情愿。 Compiler Explorer(godbolt.org) 把源码和汇编并排放,是教这个最好的工具。 - 微体系结构 debug。分支误测、cache miss、lock-free 代码里的内存序 bug、 原子操作行为不如预期 —— 这些都在汇编层级解释,不在源码层级。 强内存模型的语言(Java、Rust)让这个少一些,但 C/C++ 里到 senior 级别是必须的。
你不用写汇编就能受益。你要能读到一个 20 行函数在 perf report里不再像天书的程度。
要点。「源码编译成汇编、汇编编译成机器码;汇编只是 CPU 实际执行的东西的 人类可读视图。ISA 是硬件和软件之间的合同。读汇编是把『编译器做了点啥』从信念变成知识的方法 —— Compiler Explorer 是最简单的入手方式。」
x86 vs ARM —— CISC vs RISC,实际意义
两大家族主导世界。它们在 1980 年代对指令长度、寄存器数量、寻址复杂度做了不同选择。 这些选择被冻在硬件里 40 年,决定了为啥你的笔记本现在是 ARM。
x86-64(Intel 和 AMD)是 CISC 设计。它的指令是变长 —— 每条 1 到 15 字节。有 16 个通用 64 位寄存器(32 位 x86 原本 8 个,REX 前缀扩展到 16)。 几百个不同操作码覆盖复杂寻址模式:mov rax, [rbx + rcx*8 + 16]是一条指令,从「一个基寄存器 + 一个按 8 缩放的索引寄存器 + 一个立即偏移」算出的地址读内存。 CPU 前端必须花真力气才能分清一条指令在哪结束、下一条从哪开始。
ARM64(AArch64)是 RISC 设计。指令是定长 —— 每条恰好 4 字节。 有 31 个通用 64 位寄存器。操作码集合更小、更规整。没有复杂寻址模式: 从 [rbx + rcx*8 + 16] 加载在 ARM 上要 2–3 条更简单的指令。 解码硬件相应更简单、更小,这直接换成每条指令更低的功耗。
为啥取舍在当时重要
- 代码密度。CISC 每字节装更多含义,所以二进制更小。 1980 年代内存贵,这非常重要。ARM 的定长 4 字节指令磁盘上可以比等价 x86 大 30%, 部分被 ARM 的 Thumb 模式(热代码用 16 位指令)抵消。
- 解码器复杂度。x86 解码器必须串行扫字节找指令边界 (最长指令前缀帮不上忙 —— 不部分解码你不知道长度)。 ARM 的 4 字节对齐让解码器可以平凡地并行取并解码 N 条指令。 这是 ARM 核每瓦解码宽度更高的部分原因。
- 寄存器堆大小。更多寄存器意味着更多活值不用 spill 到内存, spill 是编译器能做的最贵的事。ARM 的 31 个 GPR 比 x86 的 16 个, 对编译器是真实的人机工程胜利,尤其对 JIT 编译的 JavaScript 这种寄存器密集代码。
为啥取舍现在重要
2020 年代翻转了历史格局。3 件事推动:
- Apple Silicon。M1(2020)展示了一颗投入够 R&D 的 ARM 核 能以显著更低的功耗打过 Intel 最好的 —— 持续每核 5–10W vs Intel 25W+。 4 年内每一台出货的 Mac 都是 ARM。
- AWS Graviton。基于 ARM 的服务器芯片每美元性能高 20–30%, 驱动了一次主要的云迁移。到 2024 年,AWS 新 EC2 实例有一半是 Graviton。 每个主要 web 服务现在都必须支持多架构部署。
- 手机。ARM 一直是手机 ISA,但 2011 年的 ARMv8 让它跟桌面核竞争。 今天你口袋里的手机单线程 IPC 比 2015 年的服务器 CPU 还高。
对软件意味着什么
对应用工程师来说,差异主要在 3 处出现:
- Profiler 里的汇编长得不一样。同样算法,但助记符、寄存器名、指令数都不同。 学读两种需要一些练习,但不难。
- 原子内存序意外。x86 是 TSO(Total Store Order)—— 大多数操作本来就免费提供顺序一致性。ARM 是弱序的 —— 没有显式 barrier 的、在 x86 上能跑的朴素 lock-free 代码,在 ARM 上悄无声息地坏掉。 这是最常见的跨架构移植 bug。
- 容器镜像和二进制分发。Docker 镜像必须声明并构建
amd64和arm64两个;docker buildx和 manifest list 处理这件事。GitHub releases 上的预编译二进制要带两种架构。 多架构构建的成本真实但小。
要点。「x86 是 CISC(变长指令、16 寄存器、复杂寻址); ARM64 是 RISC(4 字节定长、31 寄存器、简单寻址)。 ARM 更简单的解码器和更大的寄存器堆换成更好的能效, 这就是为啥 Apple Silicon、AWS Graviton、所有手机都跑 ARM。 最大的软件意外是内存序:x86 是 TSO(宽容)、ARM 是弱序(lock-free 代码必须显式 barrier)。」
调用约定 —— 函数之间怎么对话
一个函数没法调用另一个函数,除非它们同意:参数放哪、返回值从哪里来、哪些寄存器必须保留。 这套同意就是调用约定 —— 系统上每个二进制之间的合同。
调用约定是让 GCC 编的代码能调用 Clang 编的代码、C 库、内核,都透明的那条规则。 每个 OS+架构有一个正典 ABI(Application Binary Interface),所有东西都瞄准它。 Linux/x86-64 上是 System V AMD64 ABI。 Windows/x86-64 上是 Microsoft x64 调用约定。Apple/ARM64 上是 AAPCS64 的定制变种。 它们在细节上不同,在结构上一致。
SysV AMD64 一屏看完
参数寄存器(整数): rdi, rsi, rdx, rcx, r8, r9 (6 个参数)
参数寄存器(浮点): xmm0..xmm7 (8 个参数)
返回值(整数): rax (rdx 用于 128 位 int)
返回值(浮点): xmm0
caller-saved 寄存器: rax, rcx, rdx, rsi, rdi, r8..r11
(caller 跨调用需要保留时自己存)
callee-saved 寄存器: rbx, rbp, r12..r15
(callee 返回前必须恢复)
栈: 调用点 16 字节对齐;向下生长;rsp 下面 128 字节 red zone。对大多数函数调用,前 6 个整数参数和前 8 个浮点参数全装在寄存器里, 调用本身根本不碰内存。超过这个数的参数压栈。 返回值走 rax 或 xmm0。栈对齐 16 字节 —— 要求 SSE 指令能在栈分配的向量上操作而不 trap。
caller-saved vs callee-saved
这个分裂存在是因为 caller 和 callee 保存东西的代价不同。 caller-saved 寄存器(也叫「scratch」或「volatile」): 调用方知道跨调用它 care 哪些值、只存那些。 如果你不 care 大多数,就便宜。callee-saved 寄存器: 被调函数知道它实际用哪些、只存那些。 如果函数不用大多数 callee-saved 寄存器就便宜。
实际上,编译器为每个变量决定该存到哪种寄存器。 循环计数器和内层循环临时值通常进 caller-saved 寄存器(循环里便宜、干活时热)。 跨函数调用存活更久的值进 callee-saved 寄存器 (函数入口存一次、返回恢复一次 —— 比每次调用都重存便宜得多)。
栈帧
高地址 ┌──────────────────────┐ │ caller 的参数 #7..#n│ (整数参数超过 6 个时) ├──────────────────────┤ │ 返回地址 │ ← 'call' 压入 ├──────────────────────┤ ← caller 的 rsp 入口时 │ 保存的 rbp(可选) │ ├──────────────────────┤ ← callee 在这里设 rbp │ 局部变量 │ │ 溢出的寄存器 │ ├──────────────────────┤ ← callee 当前的 rsp │ red zone(128 B) │ ← 不用调 rsp 就能用 ├──────────────────────┤ 低地址
调用一个函数把返回地址压栈再跳。callee prologue 通常设栈帧指针 (push rbp; mov rbp, rsp)并为局部变量分配空间(sub rsp, N)。 callee epilogue 反过来(add rsp, N; pop rbp; ret)。 叶子函数常省略栈帧指针来省指令。
rsp 下面 128 字节的 red zone留给叶子函数当临时空间, 不用显式调 rsp。信号处理器不能碰它。 这就是信号安全代码跟普通代码不同的那种小细节之一。
为啥这对 debug 和 FFI 重要
- 栈跟踪解码。每个栈帧都按约定来,所以 backtrace 可以靠跟
rbp(有栈帧指针时)或读 DWARF unwind info(没有时)走帧。约定被破坏时 (比如 inline assembly clobber callee-saved 寄存器没声明), backtrace 就坏、crash 就变不透明。 - FFI(Foreign Function Interface)。从 Python、Rust、Go、Java 等调 C 永远走 C ABI 当中介。每种语言的 FFI 层 把参数 marshal 成约定期望的寄存器和栈槽。不匹配(类型宽度错、对齐缺失) 是常见的内存破坏 bug 来源。
- 定制调用约定。JIT(V8、LuaJIT、Java 的 JIT)经常发明自己内部的约定, 在寄存器里传更多参数、或者跳过 prologue/epilogue 工作。 跨过去到 C 代码时它们必须切到 ABI 约定。
要点。「调用约定是让系统上所有二进制能互相调用的合同。 Linux/x86-64(SysV AMD64)下:前 6 个整数参数走 rdi/rsi/rdx/rcx/r8/r9、 返回值在 rax、callee-saved 是 rbx/rbp/r12-r15、 调用点栈 16 字节对齐。FFI、栈跟踪、JIT 设计全都依赖懂这套。」
Syscall —— 开门的那条特殊指令
任一 ISA 里几百条指令中,恰好有一条把 CPU 从用户态切到内核态。 你程序里每一次 read、write、open、fork、mmap,都流过它。本节讲那几纳秒里到底发生了什么。
x86-64 或 ARM64 几百条指令里,恰好有一条特殊:把 CPU 从用户态(x86 上特权 ring 3、ARM 上 EL0)切到内核态(ring 0 / EL1)的那一条。 x86-64 上是 syscall 指令;ARM64 上是 svc #0。 你程序跑的其他每一条指令 —— 每次 load、store、add、branch —— 都在用户态,CPU 强制程序只能碰内核为它映射的内存和设备。
原子地变什么
syscall 指令上,硬件原子地改变好几样状态。原子性就是让边界安全的东西: 没有中间时刻能让「用户代码带着内核特权运行」或者「内核代码带着用户页表运行」。
- 特权 ring 3 → 0。CPU 状态寄存器里的一个标志变了。 从这一刻起,程序可以执行特权指令、访问已映射的内核内存。
- 页表基址切换。x86 上 CR3 寄存器装载内核的页表基址。 Meltdown(2018)把 KPTI(Kernel Page Table Isolation)变成强制后, 用户和内核有各自独立的页表 —— 切换是真实的代价(TLB 刷掉)。
- 栈指针切换。用户态的
rsp被保存, 每线程的内核栈(放在 CPU 的 MSR 里)被装入。 - 指令指针跳到内核的 syscall 入口, 这地址记录在 x86-64 的 LSTAR model-specific 寄存器里。
内核这边
内核从一个指定寄存器(Linux/x86-64 上是 rax, Linux/ARM64 上是 x8)读一个号码,识别请求的是哪个 syscall。 它把这个号当索引去查 sys_call_table —— 每个 syscall 一个函数指针的表 —— 然后调用对应的 handler。参数寄存器(Linux/x86-64 上 syscall 用rdi/rsi/rdx/r10/r8/r9;注意是 r10 而不是 rcx, 因为 syscall 指令本身会 clobber rcx)带着参数。
syscall 上原子地变 3 件事:特权环(3 → 0)、 页表基址(用户 → 内核,Spectre / Meltdown 后强制要求)、 以及栈指针(用户栈 → 每线程内核栈)。内核从 rax 读 syscall 号、 从固定表分发、跑 handler、把返回值写回 rax,sysret 把切换反过来。你程序里每一次 I/O、每一次内存分配、每一次 fork, 都走这同一序列。代价
一次 syscall 本身在现代硬件上要 100–300 ns(后 Meltdown KPTI 包括在内)。 这还没算实际工作 —— 从 socket buffer read 是 syscall 代价 + 一次内存拷贝; 从磁盘 read 是 syscall 代价 + I/O 延迟。 syscall 开销是下限:任何涉及内核切换的事情都不可能比这快。
这就是为啥高性能服务器下大力气避免 syscall。epoll 把 N 个 socket 就绪查询凝成一次 syscall 而不是 N 次。io_uring 走得更远 —— 用户态和内核共享在飞操作的 ring buffer, 所以提交和收割 I/O 完全不用 syscall。sendfile 和 splice让内核在文件描述符之间拷字节,不绕回用户态。
vDSO —— 看起来是 syscall、实际不是
一些高频「syscall」—— gettimeofday、clock_gettime、getcpu —— 实现在一个叫 vDSO(virtual Dynamic Shared Object) 的共享用户态库里,内核把它映射进每个进程。实现从一个只读页读内核维护的数据,立即返回, 不发生特权切换。这把延迟从 ~150 ns 降到 ~15 ns —— 对时间敏感的代码(日志、profiler、tracing 工具)很重要,它们一直在调这些。
要点。「ISA 里几百条指令中恰好一条切换特权层、把用户代码桥到内核 —— x86-64 上是 syscall、ARM64 上是 svc #0。 切换是原子的:ring、页表、栈一起翻。代价(~100–300 ns)就是高吞吐服务器用epoll、io_uring、sendfile、vDSO 来彻底避免 syscall 的原因。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。 先把问题刻进脑子;答题骨架就跟在后面。
汇编、机器码、ISA 的区别?
机器码是 CPU 解码的那些原始字节。汇编是这些字节的 1:1 人类可读视图 —— 同样的指令,只是助记符和标签。ISA(Instruction Set Architecture)是 公开的合同,规定存在哪些操作码、每个做什么 —— CPU 厂商实现的是它。
为啥 ARM 在可比性能下比 x86 更省电?
两个结构性原因:定长 4 字节指令让解码器能平凡地并行 N 路 (相比 x86 串行扫字节找边界);31 个寄存器 vs 16 个,降低了 spill 到内存的次数, spill 是最耗电的操作。加上 Apple/Qualcomm/ARM 近年在 ARM 上的 R&D 预算比 Intel 在 x86 上多。综合下来在可比负载上 perf/watt 高 2–3×。
SysV AMD64 里哪些寄存器带函数参数?
前 6 个整数参数走 rdi, rsi, rdx, rcx, r8, r9; 前 8 个浮点参数走 xmm0..xmm7;再多的参数走栈。 返回值:rax(128 位整数用 rax+rdx,浮点用 xmm0)。 syscall 时,第 4 个参数寄存器是 r10 而不是 rcx, 因为 syscall 指令本身会 clobber rcx。
走一下 syscall 指令上发生什么。
原子地:特权 ring 3 → 0;页表基址从用户切到内核 (KPTI,Meltdown 2018 后强制);栈指针从用户栈切到每线程内核栈; 指令指针跳到内核的 syscall 入口(x86-64 上记在 LSTAR)。 然后内核读 rax 拿 syscall 号、查 sys_call_table、调 handler、 把返回值写回 rax、sysret 原子地反过来。
为啥说 syscall 贵、有什么技术降低这个代价?
一次 syscall 在现代硬件上光是切换就 ~100–300 ns(包含 KPTI),还没算实际工作。 降代价的技术:epoll 把 N 个就绪查询凝成 1 次 syscall;io_uring 跟内核共享 ring buffer,提交 / 完成完全不用 syscall;sendfile 让内核在 fd 之间搬字节而不绕到用户态; vDSO 把常见读(clock_gettime)实现在内核映射的用户态内存里,完全避免切换。
caller-saved 和 callee-saved 寄存器的区别?
两个都是约定。caller-saved(「volatile」)寄存器可能被任何函数调用覆盖; 调用方需要的话自己保存。callee-saved(「non-volatile」)寄存器跨调用保留 —— 被调函数如果用,必须入口存、出口恢复。这个分裂存在是因为 caller 和 callee 代价不同; 编译器按生命周期挑哪个用。
Code review 红旗
- 没有正确 clobber 声明的 inline 汇编。用
asm volatile的函数必须告诉编译器它改了哪些寄存器和内存。clobber 漏了导致无声破坏, 只在编译器决定跨 asm 块把某个值留在被 clobber 的寄存器里时才显形。 - 打算在 ARM 上跑的、没有显式内存 barrier 的 lock-free 代码。在 x86(TSO)上能跑的代码,在 ARM(弱序)上悄无声息地坏。 修法是每个原子操作上用显式
atomic_thread_fence或合适的memory_order。 - 参数宽度不匹配的 FFI 声明。从 Python 或 Rust 用错的类型宽度调 C 函数, 破坏下一个调用点期望找到的约定值的寄存器 / 栈。
cffi、bindgen、 Rust 的libccrate 就是来生成正确绑定的。 - 每次迭代发多次 syscall 的紧凑循环。从 socket 每次
read是一次 syscall;一次 1 字节读 1 MB 是 10 万次 syscall。 用户态 buffer 起来、每千字节调一次(或用readv)来摊销。 - 2025 年还跑的代码用单架构 Docker 镜像。
FROM alpine没指定或不支持arm64在 Graviton、M 系列 Mac、 任何现代开发环境上都失败。用docker buildx加--platform linux/amd64,linux/arm64。