汇编与指令集 入门
curl 把字节送上网络时调用 send(),这个调用编译下来是一小撮指令,末尾是 syscall —— 唯一一条把机器交给内核的操作码。这篇 primer 顺着这条路往下走:编译器吐出了什么、它为什么拼命把值留在寄存器里;哪两个决定让 x86 和 ARM 成了不同的编译目标;调用约定,以及它无声失败的 3 种方式;最后是那次跨界本身,约 250 ns。
编译器到底吐出了什么
源码是给人看的;CPU 解码的是字节。中间夹着一种和那些字节一一对应的文本形式,以及一个小到会用光的寄存器堆。
编译器把源码变成汇编 —— mov、call这种助记符 —— 汇编器再把它变成 CPU 解码的那些字节。看 int add(int a, int b) 开 -O2。现在还什么都没吐出来,图里只有那行 C 语句和两个空槽;按播放键、或者把步进条往右拖,每一步吐出一条汇编指令,并把它底下的机器码字节填上:
注意里头一点内存都没有。参数从寄存器进来,答案也从寄存器出去,所以整个函数就 4 个字节:lea 占 3 个,ret 占 1 个。加法是那条 lea 干的 —— 它算出一个地址并存下来,还不碰标志位。下面这张图先给的是 x86-64 的编码; 点图下面那个架构开关切到 ARM64 —— 同一份源码,形状一样,字节不一样 —— 再拖指令滑块,一次点亮一条指令和它的编码:
注意切换时的字节数。x86-64 在这儿花 4 字节,ARM64 花 8 字节 —— 同样两个操作,两条定长 4 字节指令。
寄存器是两个版本都这么小的原因:读一个不花钱。 x86-64 上有 16 个。拖动「活跃值」滑块:住在寄存器里的值是上面那排里填实的格子,过了 16 个,下面那排就开始接溢出来的:
因为过了 16 个编译器就得溢出:每个溢出值分到一个栈槽,读它变成一次 L1 load —— 5 个周期的 load-to-use 延迟,而一个寄存器要付的是 0。到就是在看寄存器分配失败,而这正是优化编译器大部分时间在躲的事。这笔账按每次读算、每轮迭代都付 —— 把「溢出读」滑块从 0 拖起来,看那排免费操作数一个个变成 5 周期的 load:
就是 40 个周期 —— 3 GHz 下约 13 ns —— 而这个循环体的寄存器操作数一分钱都不用付。这就是为什么寄存器压力是件实事,不是学术话题。寄存器也不是一条指令能叫出的全部:一个 x86 地址是基址加带比例的变址再加位移,全在一条指令里。把基址、变址、比例三个滑块都推一遍,地址就写在带子上:
看:把位移滑块调到 0,ARM64也是一条指令 —— ldr x0, [x1, x2, lsl #3]。位移放回去,ARM 就得先来一条 add: A64 没有「带比例变址再加立即数」这种形式。地址还带第二笔代价,两个 ISA 都管不着,因为内存按 64 字节的行取。沿着下面这两条 line 拖动那次 8 字节 load, 把它拖过 0x40:
传输的单位是行不是字节,所以把这次 load 拖过 0x40,1 条 line就变成2 条: 两次 tag 查找、两次可能未命中。源码里没有任何东西说明你会拿到哪一种。值留在寄存器里、地址留在一条指令里、load 留在一条 line 里 —— 这就是编译器在这一节的全部活儿,而汇编是你查它的地方。
x86 和 ARM 到底差在哪
1980 年代做的两个决定 —— 一条指令多长、有几个寄存器 —— 今天还在决定你的笔记本长什么样、你的云账单是多少。
一条 x86-64 指令 1 到 15 字节;每条 A64 指令正好 4 字节。几乎其他所有区别都从两本手册的这一行长出来。下面是一段真实的 7 条 x86 指令的基本块,首尾 18 字节:每条指令在下一条开始的地方结束,而译码器起步的那个字节在你手里。把标记拖离边界:
注意译码器在第 2 字节处吐出什么。它不出错:几乎每个字节都是合法操作码,于是它译出一条没人写过的指令, 再往后几个字节又和真实指令流对上。一个设错的跳转目标怎么变成「能跑但不对」的代码就是这么来的,用的 ROP gadget 是怎么在编译器吐出的指令内部被找到的。
只有一种指令长度的带子,不存在这样的位置 —— 把同一个标记沿着 A64 那条带子拖一遍,它装的是同样这 7 条指令、28 个字节:
只有一种长度,所以在 ARM64 上,不是 4 的倍数的偏移不是「错的指令」—— 它是 PC 对齐错误,核直接拒绝。这个保证的钱花在前端。
下面两条带子装的是同样这 7 条指令、同一把字节尺,而只有 A64 那一侧知道自己的指令从哪儿开始:推「周期」滑块,看 x86 的边界怎样从一堆还切不开的字节里,一个周期定一个、从左往右冒出来:
长度扫描是 ARM这边完全没有的部分,而且它是第一遍的代价,不是稳态代价:把图切到热,7 个边界在第 0 周期就全在了,因为预译码级已经把长度标记写进指令缓存,uop cache 更是干脆绕开长度问题。所以 x86 上的热循环并不会跑成 ARM 的七分之一 —— 而冷的分支目标会。
让热路径免费的那块硅,正是 ARM 根本不用造的东西。x86 拿它换来的是密度 —— 拖「指令数」滑块,把两条 bar 对着读:
注意这个比值:同样 7 条指令 —— 就是你刚刚驱动过的那两条带子 —— 18 字节对 28 字节,所以在这个基本块上 ARM64 大 56%。但它不是个常数。把条数推到,差距就变了,因为这个答案是对「这一段里恰好有多少条 1 字节ret 和 5 字节 call」求的平均:定长 4 字节赢 x86 的 call 的次数,和它输给 ret 的次数差不多。
寄存器堆是这笔交易反过来的地方 —— 拖活跃值数,看 ARM64 的 31 个和 x86-64的 16 个里,哪一边先溢出:
ARM64 有 31 个通用寄存器,x86-64 有 16 个,所以开始溢出的活跃值数大约翻倍。到 20: x86 已经在为 4 个栈槽付账,ARM 还一分没花。吃寄存器的代码 —— JIT 出来的 JavaScript、解释器循环 —— 直接感觉得到。
真正把程序搞坏的差别,不是这两条里的任何一条。下面,写 flag 和 写 data坐在「谁先变得可见」这条轴上;模型先留在 TSO, 然后拖那个想让 flag 抢跑的滑块:
注意 TSO 是怎么拒绝的:虚影标出这次写想去哪儿,箭头把它推回来,所以读者永远看到 42。切到 ARM 的弱序,拉—— 这次 flag 先变得可见,读者读到 0,而代码一个字没改。
这是跨架构移植最常见的 bug,而且它无声失败:本地笔记本上测试全过,Graviton 节点一上负载就返回垃圾。修法是 release store 加 acquire load —— 两个架构上都是。
调用约定
关于寄存器和栈布局的一份协议,让任何编译器、任何语言产出的代码都能互相调用。每一条栈回溯、每一个 FFI 绑定,都指望它成立。
Linux 和 macOS 的 x86-64 上,这份协议是 System V AMD64 ABI; Windows 有自己的一套,Apple 的 ARM64 是 AAPCS64 的变体。细节不同,形状一致:SysV 给前 6 个整数参数各一个寄存器, 顺序固定,再往后的全部放到栈上。把参数个数拖过 6:
注意第 7 个开始变了什么。到 6 为止这次调用一点内存都不碰:每个参数已经在被调方要看的地方。在调用前多一次 store、调用后多一次 load,再往后每多一个就多 8 字节栈 —— 这就是有 9 个形参的热函数打不过改传结构体指针的同一个函数的原因。而只要被调方需要局部变量,内存无论如何都会进场。
下面这张图一开始什么都没压,被调方一寸栈都还没有;按播放键、或者步进一格,看这个帧往下长出来 —— 每个槽有多高,就是它占多少字节:
call 压入返回地址; prologue 压 rbp 再减出一个栈帧; epilogue 把这两步撤销,ret 弹出并跳转。你栈上每一帧都长这样,这正是调试器能走它的原因。那个减法不是随便减 —— ABI 要求每个调用点上 rsp 都 16 字节对齐。把局部变量滑块拖离 16 的倍数,看填充怎样出现在帧的尾巴上:
注意填充永远不超过 15 字节、也永远不小于 0:到它就消失了,因为 96 本身就是 16 的倍数。那些字节,是让三层调用之外某个栈上向量的movaps 不出错的原因:手写汇编忘了对齐,崩溃会出现在一个它压根没调过的函数里。合同的另一半,是一次调用可以毁掉哪些寄存器 —— 拖滑块走一遍寄存器堆,把每个的承诺读一遍:被调方还原,还是调用方自己的事:
7 个寄存器能活过一次调用,因为被调方承诺还原它们;另外9 个可能被毁,调用方要跨调用用就得自己先存。这个二分存在,是因为两边各自知道对方不知道的事 —— 调用方知道自己还要哪些值,被调方知道自己会碰哪些寄存器。
叶子函数还有一条捷径,128 字节那么大 —— 拖动暂存空间的大小,把它拖过红区的边:
128 字节以内,红区让叶子函数完全不动rsp 就能用暂存空间 —— 每次调用省两条指令。拖过去,prologue 就得回来。信号处理函数绝对不能碰这块区域,这是信号安全代码和普通代码为数不多的差别之一。
以上都假设两边意见一致。下面,被调方正在读它被许诺的低位;把声明宽度调窄,再往高半边塞点东西:
ABI 把窄参数放低位、让高位未定义,所以 C 函数收 long 而你的绑定写成 int 时,被调方就会读到上一条指令在第 31 位以上留下的东西 —— 今天在你机器上是 0,明天一个不相干的改动之后是 0xdead。它不崩溃;它返回一个错的数。
约定坏掉还有另一个藏身处,同样无声 —— 拖栈深度,看回溯能走多远:
到,回溯已经撞上一个用 -fomit-frame-pointer 编出来的帧 (-O2 下这是默认),rbp 链就断了,栈的其余部分没了。DWARF 展开数据每帧一次表查找,但每一帧都描述得到。 Fedora 和 Ubuntu 在 2023–24 年把帧指针改回默认就是为此: 10 kHz 采样的 profiler 付不起那次查找。
唯一一条改变特权级的指令
几百个操作码里,正好只有一条把机器交给内核。程序对外部世界做的每一件事都从它过,而且它不便宜。
x86-64 上这条指令是 syscall;ARM64 上是 svc #0。其他每一条指令都跑在用户态, 硬件在那里只允许访问内核为这个进程映射过的东西。下面这张图一开始,每个寄存器都还拿着用户那边的值; 按播放键、或者把步进条推一格,看上面 5 行一起翻成内核那边的值 —— 那就是这条指令的全部,而「一次性」正是关键:不存在用户代码拿着内核特权的那一刻:
注意第一步里没有什么。syscall 写的是rcx、r11、rflags、段选择子和rip,别的一律不动:它退休的那一刻,CPU 已经在 ring 0, 却还跑在用户栈上、用着用户的页表。后面那 3 步是 entry_SYSCALL_64 里的普通指令,中间那条是 SWITCH_TO_KERNEL_CR3 —— Meltdown 之前这个宏展开成空,因为内核本来就映射在每个进程里,进入时页表根本不变。 KPTI 是它今天不再是空的原因,也是 2018 年这次跨界变贵的主要原因。
进去之后,内核还得知道这是哪一次调用 —— 拖滑块走过这张表,看 rax 里的号挑出一项:
rax 里的号索引sys_call_table —— 一个函数指针数组;参数从 rdi, rsi, rdx, r10, r8, r9 进来 —— 普通约定里用 rcx 的位置换成了 r10, 因为 syscall 自己把返回地址放进 rcx、把标志放进 r11 —— 就是你刚看它写下的那两笔。这些号是永久 ABI,所以 read 到今天还是 0。
你多久付一次跨界,是个设计决定 —— 把块大小往下拖,看切换怎样把这条 bar 从拷贝手里抢走:
到,1 MB 就是 1,048,576 次跨界、约 262 ms 的纯切换, 对上大约 105 µs 的真正拷贝 —— 开销占账单的 99.96%。到 64 KB 就是 16 次调用、4 µs,反过来由拷贝主导。带缓冲的 I/O 存在的全部理由就在这儿。
每次跨界的单价还取决于内核怎么启动:下面,一个核的一秒被切成 100 格,每秒 100 万次调用时,这些跨界已经吃掉了 25 格。推一下频率,再把隔离关掉:
隔离每次跨界约 250 ns、不开是 70 ns, 所以一个每秒 10 万次系统调用的服务,光在边界上就花掉一个核的 2.5%, 每秒 100 万次的花掉你眼前那 25 格。推到,这个核就什么别的都不干了。
关掉隔离不是解法 ——少跨界才是。下面,提交环是满的,一次 io_uring_enter 把 64 个全带走;把批量往下拖,看底下那排跨界怎么长回来:
每次 io_uring_enter 批 64 个操作,1,024 次跨界就变成 16 次;到,那一排就填满了,你又回到「每个元素一次系统调用」。切到 SQPOLL,跨界直接归零:一个内核线程轮询这个环,用户态只往共享内存里写。代价是有个核在空转,这笔交易高队列深度下值、低深度下不值。
还有一种一分不付的办法,靠的是数据坐在那条线的哪一边 —— 拖调用频率,看留在用户态的那条路和跨过去的那条:
因为 vDSO 那个页被映射进了每个进程,这次调用是横着走的,不是往下走:它根本不越过ring 边界,所以约 25 ns 而不是 250 ns。任何在循环里打时间戳的东西 —— tracer、指标库、每行取一次提交时间戳的数据库 —— 都靠着它活着,而且通常自己不知道;哪天它没了(内核不肯导出的 clocksource、一条 seccomp 规则、一个没有这个页的容器),吞吐会在代码一个字没改的情况下掉一个数量级。
速查
整条路径放进一张图,所有代价放进一根轴,再加 5 个值得在 review 里抓住的红旗。
send() 到网线之间,到底发生了什么?
6 步,其中只有 1 步改变特权级:libc 装好参数、往 eax 里放 44、执行一条指令,之后的一切都跑在内核里。一步步走下去:
注意用户代码占的份量有多小:6 条指令,没有一条值得量。跨界在内核拷第一个字节之前就花掉约 250 ns, 而后面那次错误检查,是每个 libc wrapper 都以之收尾的两行:
mov eax, 44 ; sendto syscall ; ring 3 -> 0 cmp rax, -4095 ; 出错了吗? jae __syscall_err ret ; rax = 发出的字节数
哪些寄存器带参数,系统调用又差在哪?
普通调用走 rdi, rsi, rdx, rcx, r8, r9,浮点走xmm0–7,返回值在 rax;被调方保存的是rbx, rbp, rsp, r12–r15 —— 7 个,rsp 也算一个:一个函数返回时把栈指针留在别处,那是毁了调用方,不只是毁了一个寄存器。系统调用用同样 6 个参数寄存器,但 r10 顶替 rcx, 因为 syscall 会毁掉它。拖滑块走一遍这一页点过名的所有代价,它们在同一根轴上:
注意 vDSO 和系统调用之间那道口子 —— 同一个答案差 10 倍,全看边界跨没跨。
读汇编什么时候能回本?
3 个。perf report 归到指令而非源码行;向量化只有吐出来的代码能回答;内存序 bug 在汇编之上隐形。
- 内联
asm不写 clobber。 只在分配器恰好这么选时发作,无声。 - lock-free 不写内存序。点名
memory_order。 - FFI 宽度写错。生成绑定,别手写。
- 一个元素一次系统调用。262 ms 对 105 µs。加缓冲。
- 单架构镜像。
buildx --platform linux/amd64,linux/arm64。