1.14 计算机组成与三套 ISA:x86-64 / ARM64 / RISC-V 64
一条 C 语句从寄存器、ALU、内存到指令编码,中间到底发生了什么?x86-64、ARM64、RISC-V 64 的寄存器、指令格式、调用约定和系统调用入口有什么不同?同一段代码怎样在三种架构上编译、反汇编、单步?
画出 CPU 五级流水与寄存器文件;说清 RISC/CISC 的取舍;读三套汇编的常见指令;按 SysV AMD64、AAPCS64、RISC-V psABI 手算参数与栈;用 objdump/readelf/QEMU 验证。
本章的代码块带「架构 × 语言」切换器:右上角可选 x86-64 / ARM64 / RISC-V 64 与 C / Rust / 汇编。选择会记在浏览器里,翻到下一节仍生效。没有对应版本的组合会提示,而不是静默显示空白。
1.14.1 从门到指令:组成的最小模型
一台能跑 Linux 的机器,抽象到足够写系统程序,只需要五件东西:
CPU
├── 寄存器文件(16/31 个通用寄存器 + PC + 标志)
├── ALU / 乘法器 / 浮点单元
├── 控制单元(取指、译码、发射、退休)
└── MMU(页表基址、TLB、权限检查)
│
├── L1 I/D cache ── L2 ── L3 ── DRAM
└── 中断控制器(APIC/GIC/PLIC)── 定时器、设备
程序执行的最小循环是:
PC → 取指 → 译码 → 取操作数 → 执行 → 访存 → 写回 → PC+4/跳转
现代 CPU 把这条顺序流水拆成十几级,并允许乱序执行;但对程序员可见的语义仍然是「一条指令接着一条指令,寄存器与内存按序变化」。编译器和内核都依赖这个假象,只有内存屏障、原子指令和 volatile/asm! 才需要关心真实顺序。
1.14.2 ISA 是什么:寄存器、指令格式与特权级
ISA(instruction set architecture)是软件与硬件的契约,规定:
- 有哪些寄存器、各自位宽与用途;
- 指令如何编码(定长/变长)、有哪些寻址方式;
- 哪些指令只能在特权级运行(
syscall/svc/ecall、in/out、页表写); - 内存模型(强/弱序)、原子指令、异常/中断入口;
- 调用约定(ABI)虽然不属于 ISA,但由 ISA 的寄存器与栈规则派生。
三套 ISA 的骨架:
| 维度 | x86-64 | ARM64 (AArch64) | RISC-V 64 |
|---|---|---|---|
| 指令长度 | 变长 1–15 字节 | 定长 4 字节 | 定长 4 字节(可扩展 2/6) |
| 通用寄存器 | 16 个 64 位(rax…r15) | 31 个 64 位(x0…x30) | 31 个 64 位(x0…x31) |
| 链接寄存器 | 用栈保存返回地址 | x30(LR) | x1/ra(由 ABI 约定) |
| 栈指针 | rsp | sp | sp(x2) |
| 零寄存器 | 无(用 xor eax,eax) | xzr | x0 硬连线为 0 |
| 特权级 | ring 0–3 | EL0–EL3 | M/S/U |
| 系统调用 | syscall(rax 号,rdi/rsi/rdx/r10/r8/r9 参数) | svc #0(x8 号,x0–x5 参数) | ecall(a7 号,a0–a5 参数) |
| 典型风格 | CISC,内存操作数、变长 | RISC,Load/Store | RISC,Load/Store,极简 |
1.14.3 同一段 C,三种汇编
先看一个足够小、又能暴露调用约定差异的函数:
/* sum3.c — 三个 long 相加;编译:cc -O2 -S sum3.c */
long sum3(long a, long b, long c)
{
return a + b + c;
}
# x86-64 SysV:a=rdi, b=rsi, c=rdx, 返回=rax
sum3:
lea (%rdi,%rsi), %rax # rax = a + b
add %rdx, %rax # rax += c
ret
// ARM64 AAPCS64:a=x0, b=x1, c=x2, 返回=x0
sum3:
add x0, x0, x1 // x0 = a + b
add x0, x0, x2 // x0 += c
ret // 跳回 x30
# RISC-V psABI:a0/a1/a2,返回 a0
sum3:
add a0, a0, a1 # a0 = a + b
add a0, a0, a2 # a0 += c
ret # jalr x0, 0(ra)
#![allow(unused)]
fn main() {
// sum3.rs — 与 C 版本等价;rustc -O --emit asm sum3.rs
#[no_mangle]
pub extern "C" fn sum3(a: i64, b: i64, c: i64) -> i64 {
a + b + c
}
}
三份汇编的差别不在「加法怎么做」,而在参数放在哪、返回值放哪、返回地址放哪。这就是 ABI;ISA 只规定寄存器存在,ABI 规定谁负责保存。
1.14.4 调用约定:三张表
| 项 | x86-64 SysV | ARM64 AAPCS64 | RISC-V psABI |
|---|---|---|---|
| 整数参数 | rdi, rsi, rdx, rcx, r8, r9 | x0–x7 | a0–a7 |
| 浮点参数 | xmm0–xmm7 | v0–v7 | fa0–fa7 |
| 返回值 | rax(rdx:rax 128 位) | x0(x1:x0) | a0(a1:a0) |
| 被调用者保存 | rbx, rbp, r12–r15 | x19–x29 | s0–s11 |
| 调用者保存 | 其余 | x0–x18 | t0–t6, a0–a7 |
| 栈对齐 | 16 字节 | 16 字节 | 16 字节 |
| 返回地址 | 栈(call 压入) | x30(LR) | ra(x1) |
| 红区 | 128 字节(叶函数可用) | 无 | 无 |
- 「ARM64 没有返回地址寄存器」:有,
x30就是 LR;非叶函数通常把x30存栈,再ret。 - 「RISC-V 的
x0可以写」:x0硬连线为 0,写入被丢弃;addi x0, x0, 1仍然是 0,常被汇编器用作nop。 - 「x86-64 参数一定在寄存器」:超过 6 个整数参数、或者结构体太大时,参数会走栈;浮点/整数分别计数。
1.14.5 系统调用入口:三种指令
# write(1, msg, len) — x86-64 Linux
mov $1, %rax # SYS_write
mov $1, %rdi # fd = stdout
lea msg(%rip), %rsi # buf
mov $len, %rdx # count
syscall # 返回 rax;失败为 -errno
// write(1, msg, len) — ARM64 Linux
mov x8, #64 // SYS_write
mov x0, #1 // fd
adr x1, msg // buf
mov x2, #len // count
svc #0 // 返回 x0;失败为 -errno
# write(1, msg, len) — RISC-V 64 Linux
li a7, 64 # SYS_write
li a0, 1 # fd
la a1, msg # buf
li a2, len # count
ecall # 返回 a0;失败为 -errno
系统调用号是内核 ABI,不是 ISA 的一部分。同一台机器上,x86-64 的 write 是 1,ARM64 是 64,RISC-V 也是 64(遵循 asm-generic);但 openat 等编号在三种架构上可能不同。写跨架构代码时,不要硬编码编号,用 libc 或 syscall(SYS_write, ...)。
1.14.6 编译到三套架构:工具链与验证
# 本机架构
cc -O2 -S sum3.c -o sum3.s
# 交叉编译(需要对应 gcc/clang 与 sysroot)
aarch64-linux-gnu-gcc -O2 -S sum3.c -o sum3-arm64.s
riscv64-linux-gnu-gcc -O2 -S sum3.c -o sum3-riscv64.s
# Clang 直接指定 target(不需要完整 sysroot 也能看汇编)
clang --target=aarch64-linux-gnu -O2 -S sum3.c -o sum3-arm64.s
clang --target=riscv64-linux-gnu -O2 -S sum3.c -o sum3-riscv64.s
# 反汇编与 ELF 信息
objdump -d sum3
readelf -h sum3
没有硬件时,用 QEMU 用户态跑:
qemu-aarch64 -L /usr/aarch64-linux-gnu ./sum3
qemu-riscv64 -L /usr/riscv64-linux-gnu ./sum3
- 先找参数寄存器(按 ABI 表),不要从第一条指令猜;
- 找返回值寄存器与
ret/jr ra; - 找栈调整(
sub rsp/stp x29,x30/addi sp,sp,-N),画出栈帧; - 找内存访问(
mov带[]/()/lw/sw),确认基址与偏移; - 最后才看算术与分支。
1.14.7 时钟与功耗:一条指令的时间下限
这一节回答:一条指令最快能多快执行完?为什么主频停在 5 GHz 附近而不是继续翻倍?
1.14.7 讲了 CMOS 反相器与 P ≈ αCV²f 的定性结论。真正决定频率的是一个环路的最长组合路径:
T_cq 是触发器时钟到输出的延迟,T_logic,max 是关键路径上所有门与连线的延迟之和,T_setup 是触发器建立时间。后两项是物理世界的税:时钟树到达不同触发器的时间不一致(skew),电源噪声与温度让周期抖动(jitter)。设计者必须按最坏情况留余量,所以标称 5 GHz 的芯片,实际可持续频率往往只有 4.2–4.6 GHz。
手算一个 7 nm 处理器核:T_cq=30 ps、关键路径 14 级 FO4(每级 9 ps,共 126 ps)、连线 40 ps、T_setup=20 ps、skew 15 ps、jitter 10 ps,则
想把频率抬到 5 GHz(200 ps),只能砍逻辑深度(加深流水线)或换更快的工艺;前者会增加分支误预测惩罚,后者会增加漏电。这就是 1.14.8 要算的账。
| 工艺节点 | FO4 反相器延迟 | 典型 Vdd | 可持续 f_max | 漏电占动态功耗 |
|---|---|---|---|---|
| 28 nm | 20–25 ps | 0.9–1.0 V | 2.5–3.5 GHz | 10–20% |
| 16 nm | 12–16 ps | 0.8–0.9 V | 3.5–4.5 GHz | 20–30% |
| 7 nm | 8–10 ps | 0.75–0.85 V | 4.2–5.0 GHz | 25–40% |
| 5 nm | 6–8 ps | 0.70–0.80 V | 4.8–5.5 GHz | 30–45% |
| 3 nm | 5–7 ps | 0.65–0.75 V | 5.2–6.0 GHz | 35–50% |
表里的数字是公开资料的数量级,具体库、电压点、温度都会让结果偏移 20% 以上。不要用工艺节点名直接比较频率:Intel 7、TSMC N7、Samsung 7LPP 的命名不是同一个物理量。
功耗分两块:
一颗 3 GHz、1.0 V、等效开关电容 200 nF、活动因子 0.1 的芯片,动态功耗是 W;漏电再占 25%,总功耗 80 W。把电压降到 0.85 V,动态功耗按 降到 43 W,但 f_max 也会下降——降压与提频不能同时要。
固定一段依赖链长度为 1 的整数加法循环,跑 10 亿次,用 perf 读周期数:
cat > freq.c <<'EOF'
#include <stdio.h>
int main(void)
{
volatile long x = 1; /* volatile 防止整段被优化掉 */
for (long i = 0; i < 1000000000L; i++)
x = x + 1; /* 依赖链:每次加法等上一次结果 */
printf("%ld\n", x);
return 0;
}
EOF
cc -O2 freq.c -o freq
/usr/bin/time -f '%e s' ./freq
perf stat -e cycles,instructions ./freq
读法:cycles / 运行秒数 是有效频率。若 instructions / cycles 接近 1,说明循环没有被编译器改写;若远大于 1,编译器做了向量化或展开,测到的是吞吐而不是延迟。再对照 /proc/cpuinfo 的 cpu MHz 与 cpupower frequency-info 的 current policy,两者可能都不是持续频率——cpu MHz 是瞬时采样,turbo 只维持很短时间。
- “主频越高越快”——只在同一微架构、同一指令流下成立。3.5 GHz 的 Golden Cove 在整数负载上可以打赢 4.5 GHz 的老架构;跨架构比 GHz 没有意义。
- “TDP 就是最大功耗”——TDP 是散热设计参考值。Intel 的 PL2/PL1、AMD 的 PPT 都允许短时间超过 TDP,持续时间由固件与温度决定。
- “工艺节点越小,频率一定越高”——节点主要提升密度与能效;互连电阻、漏电、散热可能让频率不升反降。
- “时钟树不耗电”——时钟树要驱动全芯片的触发器,通常占动态功耗的 20–40%;clock gating 关的是这部分。
- “降频一定省电”——降频若同时降压才显著省电();只降频不降压,静态功耗占比反而上升。
1.14.8 五级流水线:理想 CPI 与真实惩罚
这一节回答:为什么 5 级流水线跑 5 条指令只要 9 个周期,而实际程序却常常 CPI 大于 1?
经典五级流水把一条指令拆成 IF、ID、EX、MEM、WB。相邻指令在不同级重叠:
周期 1 2 3 4 5 6 7 8 9
I1 IF ID EX MEM WB
I2 IF ID EX MEM WB
I3 IF ID EX MEM WB
I4 IF ID EX MEM WB
I5 IF ID EX MEM WB
n 条指令、k 级流水、无停顿的理想周期数是 。5 条指令 9 个周期,1000 条指令 1004 个周期,CPI 是 1.004,相对非流水线的 5000 周期加速 4.98 倍,极限是 5。公式:
停顿来自三类冒险:
| 冒险 | 典型场景 | 代价 | 主要解法 |
|---|---|---|---|
| 结构 | 取指与访存抢同一端口;除法器只有一个 | 1–n 拍 | 分离 I/D cache、多端口、增加执行单元 |
| 数据 RAW | add %rax,%rbx 后紧跟 mov %rbx,%rcx | 0(转发)或 1–2 | 转发(forwarding)、暂停、编译器调度 |
| 数据 load-use | mov (%rdi),%rax 后立刻用 %rax | 1 拍 | 提前加载、重排、软件流水 |
| 控制 | 条件分支结果未出 | 15–20 拍 | 分支预测、延迟槽、谓词执行 |
| WAW/WAR | 寄存器复用 | 0(重命名后消失) | 寄存器重命名(1.14.9) |
转发能把 EX 的结果直接送到下一条指令的 EX 输入,所以大多数 RAW 不需要停顿;但 load 的数据要到 MEM 末尾才可用,下一条若在 EX 就要用,只能停一拍。编译器把「加载」尽量提前,就是在填这一拍。
分支预测的基本单位是 2 位饱和计数器:四个状态(强不跳、弱不跳、弱跳、强跳)按实际方向每次移动一格,只有跨过中点才改变预测。两位状态让预测器在循环边界只错两次(进入与退出),而不是每次迭代都错。现代处理器用 TAGE/ITTAGE 一类的多表历史预测器,把全局分支历史哈希进多个表,准确率可以到 97–99%;返回地址栈(RSB)专门预测 ret。
把准确率代进 CPI:分支占 20%,误预测惩罚 15 拍,则
| 预测准确率 | 误预测率 | 额外 CPI | 总 CPI |
|---|---|---|---|
| 99% | 1% | 0.030 | 1.03 |
| 95% | 5% | 0.150 | 1.15 |
| 90% | 10% | 0.300 | 1.30 |
| 80% | 20% | 0.600 | 1.60 |
这张表说明:分支密集的代码,预测准确率从 99% 掉到 90%,性能损失约 26%;而把误预测率从 5% 压到 1%,只能收回 12%。优化要挑「占比大、惩罚重」的那一项。
同一段「大于等于 128 就累加」的循环,数据随机时分支方向近似随机,预测器只能猜一边,误预测率接近 50%;排序后前一半全不跳、后一半全跳,预测器几乎全中。
#include <stdio.h>
#include <stdlib.h>
static int cmp(const void *a, const void *b)
{
int x = *(const int *)a, y = *(const int *)b;
return (x > y) - (x < y);
}
int main(int argc, char **argv)
{
enum { N = 1 << 15 };
static int a[N];
unsigned seed = 1;
for (int i = 0; i < N; i++) {
seed = seed * 1103515245u + 12345u;
a[i] = (int)((seed >> 16) & 0xff);
}
if (argc > 1 && argv[1][0] == 's')
qsort(a, N, sizeof a[0], cmp);
long sum = 0;
for (int i = 0; i < N; i++)
if (a[i] >= 128)
sum += a[i];
printf("%ld\n", sum);
return 0;
}
cc -O2 -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror branch.c -o branch
perf stat -e branches,branch-misses ./branch # 随机
perf stat -e branches,branch-misses ./branch s # 排序
随机版本的 branch-misses 通常接近 branches 的一半,排序版本降到 1% 以下;运行时间差 2–4 倍。注意:排序本身要花 ,只有循环被反复执行很多次时才划算。第 1.5 章会从编译器角度继续这个例子。
- “分支预测器看源码里的
if”——它看的是指令地址与历史。同一段代码换一份数据分布,命中率可以完全不同。 - “
__builtin_expect能显著提速”——它主要影响代码布局与内联,现代预测器会自己学习;在无 profiler 指导下乱标可能让热路径变冷。 - “误预测惩罚等于流水线级数”——还要加上前端重取、BTB 缺失、指令缓存 miss;实际惩罚可能比级数更大。
- “CPI 一定 ≥ 1”——多发射、SMT 下 CPI 可以小于 1;IPC 才是更直观的指标。
- “排序后循环变快,所以排序免费”——排序的成本必须计入;只跑一遍时排序通常更慢。
1.14.9 乱序执行:窗口、重命名与依赖链
这一节回答:CPU 如何在等待一次 cache miss 的同时继续执行后面的指令?为什么「四条累加链」比「一条累加链」快四倍?
乱序执行把流水线分成前端与后端:
| 部件 | 作用 | 典型容量(Skylake 级) | 满了会怎样 |
|---|---|---|---|
| 指令缓存 / µop cache | 取指、译码 | 32 KiB / 1536 µops | 前端停顿 |
| 重排序缓冲区 ROB | 按程序顺序退休 | 224 条 | 停止发射 |
| 保留站 RS | 等操作数就绪后发射 | ~97 项 | 发射受限 |
| 物理寄存器堆 PRF | 寄存器重命名 | 168 整数 + 168 向量 | 重命名停顿 |
| 加载/存储缓冲 | 乱序访存 | 72 / 56 | 访存阻塞 |
前端按程序顺序取指、译码、重命名;后端在操作数就绪时乱序发射,但退休必须按程序顺序。这样异常和中断看到的仍是顺序语义,编译器不需要知道真实的执行顺序。
重命名消除了 WAW/WAR:mov %rax,%rbx 再 mov %rcx,%rax 写的是不同的物理寄存器,逻辑寄存器同名也不冲突。真正限制并行的是 RAW 依赖链:
| 指令 | 延迟(拍) | 吞吐(条/拍) |
|---|---|---|
| 整数 add/sub | 1 | 4 |
| 整数 imul | 3 | 1 |
| 浮点 add/mul | 4 | 2 |
| FMA | 4 | 2 |
| L1 加载 | 4–5 | 2 |
若循环是 sum += a[i] 且 sum 是 float,每次加法要等上一次的 4 拍,吞吐被压到 0.25 条/拍。拆成 4 条独立累加链,4 条链可以同时在 4 个周期后各自出结果,平均 1 条/拍;拆成 8 条,达到 2 条/拍的发射上限。这就是「提高指令级并行」最朴素的手法:把一条长依赖链拆成几条短链。
clang --target=x86_64-linux-gnu -O2 -S sum.c -o sum.s
llvm-mca -mtriple=x86_64-linux-gnu -mcpu=skylake sum.s
看三块:Block RThroughput(吞吐下限)、resource pressure(端口饱和)、Critical Path(依赖链)。吞吐下限远小于实测周期,瓶颈在依赖或前端;某一列压力接近 1.00,说明执行单元被打满。
- “乱序执行会打乱程序结果”——退休按序,异常按序,架构状态与顺序执行一致;被重排的只是内部时序。
- “有乱序就不需要关心依赖”——RAW 依赖仍然串行;指针追逐、链表遍历、单累加器都是延迟受限。
- “
volatile能提高并行”——volatile禁止编译器重排与合并访问,通常让代码更慢,不解决硬件依赖。 - “ROB 越大越好”——ROB 大能覆盖更多 cache miss,但功耗与验证成本上升;服务器与移动端的取舍不同。
- “llvm-mca 的数字等于真机”——它不模拟 cache miss、分支误预测、频率变化;只用它比较指令序列的相对优劣。
1.14.10 Cache 地址拆分与 AMAT:手算一个组相联缓存
这一节回答:给定缓存容量、块大小、相联度,一个地址落到哪个组?一次访存平均要多少拍?
设 L1 数据缓存 32 KiB、8 路组相联、块 64 B,虚拟地址 48 位:
地址从高到低拆成 tag(36) | index(6) | offset(6)。取 0x0000_7fff_1234_5678:
| 字段 | 计算 | 值 |
|---|---|---|
| offset | 0x678 & 0x3f | 0x38(56) |
| index | (0x678 >> 6) & 0x3f | 25(0b011001) |
| tag | 0x7fff12345678 >> 12 | 0x7fff12345 |
同一组的地址只要求 index 相同,也就是低 12 位中的第 6–11 位相同。下面三个地址都映射到组 25:
| 地址 | 低 12 位 | index | tag | 与首个地址的关系 |
|---|---|---|---|---|
0x7fff12345678 | 0x678 | 25 | 0x7fff12345 | 基准 |
0x7fff12346678 | 0x678 | 25 | 0x7fff12346 | +4 KiB,同组不同 tag |
0x7fff12349678 | 0x678 | 25 | 0x7fff12349 | +16 KiB,同组不同 tag |
8 路意味着一个组只能放 8 个 tag。如果程序用 9 个相距 4 KiB 的数组同时顺序扫描,第 9 个流会把最早的那个 tag 挤出去,之后每次访问都 miss——这就是冲突缺失(conflict miss),容量明明够却用不上。把数组间距改成 4 KiB + 64 B,或者把 9 个流分块处理,就能避开。
平均访存时间(AMAT)按层展开:
设 L1 命中 4 拍、局部缺失率 5%;L2 命中 12 拍、局部缺失率 20%;L3 命中 40 拍、局部缺失率 10%;DRAM 200 拍:
把 L1 缺失率从 5% 降到 2%,AMAT 变成 拍,快 14%;把 L2 命中时间从 12 拍降到 8 拍,AMAT 只变成 拍,快 4%。降低缺失率通常比降低命中时间更划算,因为缺失代价被放大了一次。
一次访存的平均延迟,等于第一层命中时间加上每一层「缺失率 × 下一层代价」的递归展开。式中的缺失率必须是局部缺失率(本层 miss 数 / 本层访问数),不是全局缺失率;混用会把 AMAT 算小。
getconf -a | grep -E 'LEVEL1_DCACHE_(SIZE|LINESIZE|ASSOC)'
cat > stride.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
static double now_ns(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec;
}
int main(int argc, char **argv)
{
size_t n = 1u << 26; /* 64 MiB,远超 L3 */
size_t stride = argc > 1 ? strtoul(argv[1], NULL, 0) : 1;
int *a = malloc(n * sizeof *a);
if (!a) return 1;
for (size_t i = 0; i < n; i++) a[i] = 1;
size_t steps = 0;
long sum = 0;
double t0 = now_ns();
for (size_t i = 0; i < n; i += stride) { sum += a[i]; steps++; }
double t1 = now_ns();
printf("stride=%zu steps=%zu %.2f ns/elem sum=%ld\n",
stride, steps, (t1 - t0) / (double)steps, sum);
free(a);
return 0;
}
EOF
cc -O2 -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror stride.c -o stride
for s in 1 16 1024 8192; do ./stride $s; done
perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-loads,LLC-load-misses ./stride 1
stride=1 触发硬件预取,带宽受限;stride=16(64 B)每次换一条 cache line,等价于顺序流;stride=1024(4 KiB)开始出现组冲突;stride=8192(32 KiB)在 8 路 32 KiB L1 上几乎每个访问都 miss。perf 的 L1-dcache-load-misses 与 LLC-load-misses 可以直接对照上面的拆分。
- “cache line 一定是 64 B”——x86 常见 64 B;部分 ARM64(如 Apple M1)是 128 B。用
getconf LEVEL1_DCACHE_LINESIZE或sysctl hw.cachelinesize确认。 - “index 位数固定”——index = log2(容量 / (块大小 × 相联度));容量或相联度一变,tag/index 的边界就移动。
- “用虚拟地址手算真实组号”——VIPT 缓存用虚拟地址的低位做 index,但受页大小与别名限制;真实索引最终由物理地址决定。
- “两个数组就会冲突”——8 路要 9 个同组流才发作;2 个流只是占两个 way。
- “L2 命中说明 L3 有副本”——L3 可能是非包含(non-inclusive)或排他(exclusive)的,L2 有数据不代表 L3 有。
- “
perf的cache-misses是统一的”——不同 PMU 上它可能指 L1、LLC 或架构定义的某事件;要显式写LLC-load-misses这类事件名。
1.14.11 三架构栈帧:第 7 个参数之后
这一节回答:参数超过 6 个、函数又有局部变量时,三套 ABI 的栈帧如何排布?为什么同一个第 7 参数在三种架构上的偏移不同?
1.14.11 给了六个参数以内的对照。把第 7、8 个参数和局部变量一起看,差异才完整:x86-64 的栈上第一个字是返回地址,ARM64/RISC-V 的第一个字就是第 7 个参数。
栈的形状可以先记一句话:x86-64 的 [rbp+8] 是返回地址,第 7 个参数从 [rbp+16] 开始;ARM64/RISC-V 没有栈上的返回地址,第 7 个参数就在入口的 [sp],建帧后移到 [sp+32]。
| 项 | x86-64 SysV | ARM64 AAPCS64 | RISC-V LP64D |
|---|---|---|---|
| 入口时第 7 参数 | [rsp+8] | [sp+0] | [sp+0] |
| 建帧后第 7 参数 | [rbp+16] | [sp+32] | [sp+32] |
| 建帧后第 8 参数 | [rbp+24] | [sp+40] | [sp+40] |
| 返回地址 | [rbp+8] | x30 | ra(x1) |
| 帧指针 | rbp | x29 | s0(x8) |
| 栈对齐 | 16 B | 16 B,任何时刻 | 16 B |
| 红区 | 128 B | 无 | 无 |
下面三个版本是同一函数:8 个 long 参数、两个局部变量、一次对 helper 的调用。注意第 7/8 参数的偏移,以及保存返回地址的位置。
# long f(long a,...,long h);a=rdi b=rsi c=rdx d=rcx e=r8 f=r9
# 入口:[rsp]=返回地址,[rsp+8]=g,[rsp+16]=h
.text
.globl f
.type f, @function
f:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp # 2 个局部变量 + 对齐
movq %rdi, -8(%rbp) # local x
movq %rsi, -16(%rbp) # local y
movq 16(%rbp), %rax # g
addq 24(%rbp), %rax # h
call helper
leave # mov rbp,rsp; pop rbp
ret
// 入口:sp 指向 g;[sp]=g,[sp+8]=h
.text
.globl f
.type f, %function
f:
stp x29, x30, [sp, #-32]! // 预索引:先减 sp,再存
mov x29, sp
str x0, [sp, #16] // local x
str x1, [sp, #24] // local y
ldr x9, [sp, #32] // g(sp 已减 32)
ldr x10, [sp, #40] // h
add x0, x9, x10
bl helper
ldp x29, x30, [sp], #32 // 后索引:先取,再加 sp
ret
# 入口:sp 指向 g;[sp]=g,[sp+8]=h
.text
.globl f
.type f, @function
f:
addi sp, sp, -32
sd ra, 24(sp) # 返回地址
sd s0, 16(sp) # 帧指针
sd a0, 0(sp) # local x
sd a1, 8(sp) # local y
ld t0, 32(sp) # g
ld t1, 40(sp) # h
add a0, t0, t1
call helper # auipc ra, ...; jalr ra
ld ra, 24(sp)
ld s0, 16(sp)
addi sp, sp, 32
ret
参数超过寄存器数量只是第一层。结构体按值传递时,三套 ABI 用不同的分类规则:
| 实参类型 | x86-64 SysV | ARM64 AAPCS64 | RISC-V LP64D |
|---|---|---|---|
| ≤16 B 的整数/指针结构体 | 拆成 1–2 个八字节,INTEGER 类 | 按 8 B 对齐放 x0–x7 | 按 XLEN 放 a0–a7 |
| >16 B 的结构体 | 内存,传隐式指针 | 内存,传隐式指针 | 内存(>2×XLEN) |
| HFA(1–4 个 float/double) | 按八字节用 SSE 寄存器 | v0–v3 | fa0–fa3 |
| 小结构体返回 | rax:rdx | x0:x1 | a0:a1 |
| 可变参数 | %al 记录向量寄存器个数 | 无特殊 | 无特殊 |
- “x86 第 7 个参数在
[rbp+8]”——[rbp+8]是返回地址,第 7 个在[rbp+16];差一格是call压栈造成的。 - “ARM64 的
stp会自动移动sp”——只有带!的预索引写法才写回sp;stp x29,x30,[sp,#-16]不移动,下一句会覆盖旧数据。 - “
sp可以随便当临时寄存器”——ARM64 要求sp在任何时刻 16 字节对齐;用ldr x0,[sp],#8这类单寄存器后索引会把对齐破坏。 - “RISC-V 的
ra是独立寄存器”——ra就是x1;call是伪指令,展开成auipc ra,...; jalr ra,会覆盖ra。 - “红区在所有架构上都有”——只有 x86-64 有 128 B 红区;ARM64/RISC-V 的叶函数不能往
sp以下写。 - “结构体一定走内存”——≤16 B 且字段类型合适时走寄存器;判断依据是分类规则,不是字节数一个数。
1.14.12 指令编码:手算三种格式
这一节回答:给定一条汇编,怎样不查汇编器就写出它的字节?三种 ISA 的字段布局差在哪?
x86-64 先看一条带 SIB 的访存指令:mov 0x10(%rdi,%rsi,4),%eax。它的字节是 8b 44 b7 10:
| 字节 | 字段 | 值 | 含义 |
|---|---|---|---|
8b | opcode | MOV r32, r/m32 | 目标寄存器在 ModR/M.reg |
44 | ModR/M | 01 000 100 | mod=01(disp8)、reg=000(eax)、r/m=100(有 SIB) |
b7 | SIB | 10 110 111 | scale=2(×4)、index=110(rsi)、base=111(rdi) |
10 | disp8 | 0x10 | 有符号 8 位位移 |
有效地址是 rdi + rsi*4 + 0x10。ModR/M 的 reg 字段不总是寄存器:某些 opcode 把它当扩展操作码(例如 FF /2 是 call),读手册时要看 opcode 的 /digit 记法。
ARM64 是定长 32 位,字段位置固定。add x0, x0, x1 的编码是 0x8B010000:
| 位 | 字段 | 值 |
|---|---|---|
| 31 | sf | 1(64 位) |
| 30 | op | 0(ADD) |
| 29 | S | 0(不设标志) |
| 28:24 | 固定 | 01011 |
| 23:22 | shift | 00 |
| 21 | 固定 | 0 |
| 20:16 | Rm | 1(x1) |
| 15:10 | imm6 | 0 |
| 9:5 | Rn | 0(x0) |
| 4:0 | Rd | 0(x0) |
拼起来:1000 1011 0000 0001 0000 0000 0000 0000。ldr x0,[x1,#8] 是 0xF9400420,字段为 size=11、111001、opc=01、imm12=1(8/8)、Rn=1、Rt=0。注意 add x0,x0,#1 是 0x91000400,和寄存器版只差几个位,但属于不同编码族——手算时先确定「立即数还是寄存器」。
RISC-V 有 6 种基本格式,立即数被拆开存放:
| 格式 | 位宽 | 立即数范围 | 典型指令 |
|---|---|---|---|
| R | 32 | 无 | add、sub、sll |
| I | 32 | 12 位有符号(±2048) | addi、ld、jalr |
| S | 32 | 12 位有符号 | sd、sw |
| B | 32 | 13 位有符号,2 的倍数(±4 KiB) | beq、bne |
| U | 32 | 20 位,左移 12 | lui、auipc |
| J | 32 | 21 位有符号,2 的倍数(±1 MiB) | jal |
add a0,a0,a1 是 R 型:funct7=0000000、rs2=01011、rs1=01010、funct3=000、rd=01010、opcode=0110011,拼出 0x00B50533。addi a0,a0,1 是 I 型:imm=1、rs1=01010、funct3=000、rd=01010、opcode=0010011,得到 0x00150513。sd ra,8(sp) 是 S 型:imm[11:5]=0、rs2=00001、rs1=00010、funct3=011、imm[4:0]=01000、opcode=0100011,得到 0x00113423。B 型的立即数按 imm[12|10:5|4:1|11] 打散,bne a0,a1,+8 是 0x00B51463。
printf '\x8b\x44\xb7\x10' > /tmp/m.bin
objdump -D -b binary -m i386:x86-64 /tmp/m.bin
echo 'add x0, x0, x1' | llvm-mc -triple=aarch64 -show-encoding
echo 'ldr x0, [x1, #8]' | llvm-mc -triple=aarch64 -show-encoding
echo 'add a0, a0, a1' | llvm-mc -triple=riscv64 -show-encoding
echo 'sd ra, 8(sp)' | llvm-mc -triple=riscv64 -show-encoding
clang --target=riscv64-linux-gnu -c enc.s -o enc.o && llvm-objdump -d enc.o
llvm-mc -show-encoding 输出的是小端字节序列;0x8B010000 在内存里是 00 00 01 8b。手算结果与它对照,错一位都能看出来。
- “ModR/M 的 reg 一定是寄存器”——不少 opcode 把它当扩展操作码;
/0–/7的数字就是 reg 字段。 - “x86 只有 8 个通用寄存器”——REX.R/X/B 扩展 reg/index/base 的最高位;没有 REX 前缀时只能编码前 8 个。
- “ARM64 的 ADD 只有一种编码”——寄存器版、立即数版、带进位版是不同的编码族,字段布局不一样。
- “RISC-V 的立即数是连续的”——B/J 型把立即数打散,手算必须按位段拼,不能直接左移。
- “字节序无所谓”——x86/ARM64/RISC-V 小端机器上,32 位字面值与内存字节序相反;写 shellcode 时最容易错。
- “RISC-V 指令都是 4 字节”——开启 C 扩展后,常用指令压缩成 2 字节;反汇编时不能按固定 4 字节切分。
1.14.13 RISC/CISC 与内存模型:litmus 实验
这一节回答:同样是「先写 X 再读 Y」,为什么 x86 上可以观察到 r1=r2=0,而直觉上的顺序执行不该出现这个结果?
RISC 与 CISC 的边界今天是模糊的:x86 前端把变长指令译成 µop,内部像 RISC;ARM64 也加入了复杂的寻址与向量指令。真正的差别在译码成本、代码密度与生态:x86 平均 3–4 字节一条指令、译码要预译码器;ARM64/RISC-V 定长 4 字节、字段固定,但 RISC-V 的 C 扩展又把常用指令压回 2 字节。
内存模型是比指令集更容易被忽略的契约。四类重排在三种架构上的允许情况:
| 重排 | x86-64(TSO) | ARM64 | RISC-V(RVWMO) |
|---|---|---|---|
| Load → Load | 禁止 | 允许 | 允许 |
| Load → Store | 禁止 | 允许 | 允许 |
| Store → Store | 禁止 | 允许 | 允许 |
| Store → Load | 允许 | 允许 | 允许 |
| 屏障指令 | mfence / lock 前缀 | dmb ish | fence rw,rw |
经典 Dekker litmus:
初始:X = 0, Y = 0
P0: X = 1; r1 = Y;
P1: Y = 1; r2 = X;
x86 的 TSO 只允许 Store→Load 重排,所以 r1=r2=0 是可能出现的;要排除它,必须在两次访问之间加 mfence(或把 store 换成 xchg)。ARM64/RISC-V 允许更多重排,需要 dmb ish/fence,或者使用带 acquire/release 语义的指令。
C11 的写法不依赖具体架构:
#include <stdatomic.h>
static atomic_int x, y, r1, r2;
void *p0(void *arg) /* p1 与 p0 对称:Y=1; fence; r2=X */
{
(void)arg;
atomic_store_explicit(&x, 1, memory_order_relaxed);
atomic_thread_fence(memory_order_seq_cst);
r1 = atomic_load_explicit(&y, memory_order_relaxed);
return 0;
}
把 p0/p1 各跑一百万轮、统计 r1==0 && r2==0 的次数,就能看到重排(x86 上需要很多轮才出现一次)。真正严谨的验证用 herd7 的架构模型(herd7 -model x86tso.cat dekker.litmus),而不是真机碰运气。
# 全屏障:mfence 之前的 load/store 不能越过它
mfence
// 全屏障;ish = inner shareable 域
dmb ish
# 读写屏障;fence rw,rw 约束所有 load/store
fence rw, rw
- “x86 是强序,所以不需要屏障”——TSO 仍允许 Store→Load 重排;Dekker/消息传递模式要
mfence或锁定指令。 - “
volatile是内存屏障”——volatile只约束编译器对该对象的访问,不生成 CPU 屏障,也不保证原子性。 - “
dmb ish和dmb ishst可以互换”——ishst只约束 store,不能替代ish去约束 load-store 顺序。 - “RISC-V 的
fence总是全屏障”——fence r,r只约束 load→load;fence.tso是另一种强度,按需选择。 - “默认
seq_cst太慢,全部改 relaxed”——放松顺序需要证明每个共享访问的同步关系;漏一个就是数据竞争。 - “QEMU 能验证弱内存模型”——QEMU 用户态不是架构内存模型的参考实现;用
herd7或真机。
1.14.14 三架构实验:从 C 到字节
这一节回答:怎样用一条可重复的流水线,把同一段 C 变成三套汇编、三份 ELF,并核对指令数、编码与系统调用?
实验 A:同一函数的三套代码。 仍用 sum3.c:
for t in x86_64 aarch64 riscv64; do
clang --target=$t-linux-gnu -O2 -S sum3.c -o sum3-$t.s
clang --target=$t-linux-gnu -O2 -c sum3.c -o sum3-$t.o
printf '%-9s ' "$t"
llvm-objdump -d sum3-$t.o | awk '/^[[:space:]]+[0-9a-f]+:/{n++; b++} END{print n" insn"}'
llvm-size -A sum3-$t.o | awk '/\.text/{print "text="$2}'
done
| ISA | 指令序列 | 条数 | 字节(未压缩) |
|---|---|---|---|
| x86-64 | lea、add、ret | 3 | 8 |
| ARM64 | add、add、ret | 3 | 12 |
| RISC-V | add、add、ret | 3 | 12(默认 rv64gc 实测 6) |
同一个函数,x86 用 8 字节,ARM64 用 12 字节;RISC-V 关闭压缩时 12 字节,默认 rv64gc 只 6 字节。只看「指令条数」,差异被完全掩盖:评估代码密度要看字节数,评估译码压力要看指令条数与格式。
实验 B:不依赖 libc 的 hello。 三个版本都只用到 write 与 exit:
.section .rodata
msg: .ascii "hello\n"
len = . - msg
.text
.globl _start
_start:
mov $1, %rax # SYS_write
mov $1, %rdi # stdout
lea msg(%rip), %rsi
mov $len, %rdx
syscall
mov $60, %rax # SYS_exit
xor %rdi, %rdi
syscall
.section .rodata
msg: .ascii "hello\n"
len = . - msg
.text
.globl _start
_start:
mov x8, #64 // SYS_write
mov x0, #1 // stdout
adr x1, msg
mov x2, #len
svc #0
mov x8, #93 // SYS_exit
mov x0, #0
svc #0
.section .rodata
msg: .ascii "hello\n"
len = . - msg
.text
.globl _start
_start:
li a7, 64 # SYS_write
li a0, 1 # stdout
la a1, msg
li a2, len
ecall
li a7, 93 # SYS_exit
li a0, 0
ecall
aarch64-linux-gnu-gcc -nostdlib -static hello-arm64.S -o hello-arm64
riscv64-linux-gnu-gcc -nostdlib -static hello-riscv64.S -o hello-riscv64
cc -nostdlib -static hello-x86_64.S -o hello-x86_64
./hello-x86_64
qemu-aarch64 -strace ./hello-arm64
qemu-riscv64 -strace ./hello-riscv64
-strace 的输出形如:
0 write(1,0x4000c8,6) = 6
0 exit(0)
系统调用号分别是 1/64/64,参数寄存器分别是 rdi,rsi,rdx / x0,x1,x2 / a0,a1,a2;三份 ELF 的 Machine 不同(62/183/243),但 write(1, "hello\n", 6) 的语义相同。
- “
clang --target能直接链接出可执行文件”——没有 sysroot 时只能-S/-c;链接会报找不到crt1.o、-lc。1.14.6 的命令需要 sysroot 或改用交叉 gcc。 - “
objdump什么架构都能反汇编”——GNU binutils 通常只编入本机架构;用llvm-objdump --triple=...或交叉 binutils。 - “静态二进制也要加
-L”——qemu-aarch64 -L是给动态链接器用的;静态程序加了反而可能加载错误的ld.so。 - “RISC-V 指令都是 4 字节”——默认
rv64gc含压缩指令,反汇编会出现 2 字节指令;按 4 字节切分会错位。 - “本机 gdb 能调所有架构”——本机 gdb 通常只支持 x86-64;跨架构要
gdb-multiarch加qemu -g。 - “
-static就是非 PIE”——-static-pie仍是 PIE,入口与重定位处理不同;用readelf -h的Type确认。
1.14.15 代码尺寸与性能:同一算法的三套指令账
同一段 C 编到三套 ISA,指令数、代码字节数、寄存器压力都不一样。不要凭感觉判断「谁更快」,先量三个数:
| 指标 | 怎么量 | 说明 |
|---|---|---|
| 代码字节数 | size a.out / llvm-size | 受 RVC、Thumb、x86 变长编码影响 |
| 指令条数 | `objdump -d | grep -c ‘^\s*[0-9a-f]’` |
| 周期/指令 | perf stat -e cycles,instructions | IPC = instructions/cycles |
| 资源压力 | llvm-mca -mcpu=... | 端口、发射宽度、依赖链 |
一个可复现的对照:
/* size_lab.c — 同一函数的三种写法 */
long sum_array(const long *a, int n) {
long s = 0;
for (int i = 0; i < n; i++) s += a[i];
return s;
}
long sum_unrolled(const long *a, int n) {
long s = 0; int i = 0;
for (; i + 4 <= n; i += 4) s += a[i] + a[i+1] + a[i+2] + a[i+3];
for (; i < n; i++) s += a[i];
return s;
}
for t in x86_64-linux-gnu aarch64-linux-gnu riscv64-linux-gnu; do
clang --target=$t -O2 -c size_lab.c -o size-$t.o
echo "$t $(llvm-size size-$t.o | awk 'NR==2{print $1}')"
llvm-objdump -d --triple=$t size-$t.o | grep -c '^ *[0-9a-f]*:'
done
预期量级:x86-64 的 sum_array 约 30–40 字节;ARM64 约 40–48 字节;RISC-V 开启 RVC 后约 24–32 字节。展开版代码更大,但循环开销更少;是否更快取决于 n、对齐、cache 与分支预测,必须实测。
size的 text 包含只读数据/跳表:不是纯指令字节;看objdump的地址范围更准。- RISC-V 的 RVC 是可选扩展:
-march=rv64gc才有压缩指令;rv64g的代码会大 20–30%。 - ARM64 的
-O2可能自动展开:不同 clang 版本策略不同,先看汇编再比较。 perf stat的 instructions 包含内核/动态链接器:用perf stat -e ... -- ./a.out并固定输入,或perf stat -a看整机。llvm-mca是静态模型:它不知道 cache miss/分支误预测,只能比较指令序列的吞吐下限。
怎么读结果:如果展开版 IPC 更高但墙钟没变,瓶颈在内存带宽;如果代码尺寸翻倍但 cache 命中率下降,可能更慢。三架构之间不要直接比绝对周期——频率、微架构、内存都不同;要比「同一架构下两种写法的 IPC/带宽」,或「同一算法在三套 ISA 上的指令数/代码尺寸」。
原子指令与屏障:三套 ISA 的同一件事
| 操作 | x86-64 | ARM64 | RISC-V |
|---|---|---|---|
| 原子加 | lock add | ldadd/ldxr+stxr | amoadd.w/d |
| CAS | lock cmpxchg | cas/ldaxr+stlxr | amoswap/lr+sc |
| 读-改-写 | lock xchg | swpal | amoswap |
| 全屏障 | mfence | dmb ish | fence rw,rw |
| 读屏障 | lfence | dmb ishld | fence r,r |
| 写屏障 | sfence | dmb ishst | fence w,w |
C11 的 atomic_compare_exchange_strong 在 x86 上落 lock cmpxchg,ARM64 落 ldaxr/stlxr 或 cas,RISC-V 落 lr/sc。memory_order_seq_cst 还要额外的全屏障;acquire/release 只需单向屏障。写可移植代码时用 <stdatomic.h>,不要手写 volatile 或假设 x86 的 TSO。
原子指令的代价不在指令本身,而在缓存行独占:多核同时 CAS 同一 cache line 会来回弹跳,吞吐随核数下降。分片计数器(每核一个计数,读时求和)、per-CPU 变量、RCU 都是绕开这个瓶颈的办法。测的时候用 perf c2c 看 HITM,而不是只看 lock 指令的延迟。没有 perf c2c 时,用「单线程 vs 多线程同地址 CAS」的吞吐对比也能看出弹跳:核数增加而吞吐不增,通常就是它。
1.14.16 本章出口检查
| 检查项 | 达标线 |
|---|---|
| 三套寄存器 | 能说出参数、返回值、栈指针、零寄存器 |
| 调用约定 | 能默写三张表的前三行 |
| 系统调用 | 能写出 write 在三架构上的指令与寄存器 |
| 读汇编 | 给一段 10 条指令的汇编,能画出栈帧 |
| 交叉编译 | 能用 clang --target 生成 ARM64/RISC-V 汇编 |
| 流水线与 CPI | 能算 5 级流水周期数、分支误预测对 CPI 的影响 |
| cache 与 AMAT | 能给地址拆分、算多层 AMAT |
| 内存模型 | 能说出三种架构允许的重排与屏障指令 |
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C CSAPP | 第 3 章 程序的机器级表示 | 1.14.1–1.14.6、1.14.11–1.14.12 | 扩展(三架构对照、栈帧与编码) |
| C CSAPP | 第 4 章 处理器体系结构(4.4–4.5) | 1.14.8–1.14.9 | 扩展:流水线、冒险、乱序 |
| C CSAPP | 第 6 章 存储器层次结构(6.4–6.6) | 1.14.10 | 扩展:组相联、AMAT |
| R 奔跑吧 | 第 1 章 处理器架构 | 1.14.2、1.14.5 | 背景(ARM64 视角) |
| rCore | 附录 D RISC-V 汇编 | 1.14.3–1.14.5 | 参考(独立改写) |
| —(本书扩展) | 无直接母本 | 1.14.7、1.14.13–1.14.14 | 时钟/功耗、内存模型、三架构实验 |
下一站:1.15 汇编与调用约定 把这三套汇编放进完整函数、栈帧与内联汇编里。
原子操作的另一个边界是「不能睡眠」:在原子上下文/中断里做 CAS 可以,但不能在持锁时调用可能睡眠的分配器;否则会触发 BUG: sleeping function called from invalid context。
本章的量化数字是量级,不是目标机器的实测值;换 CPU/编译器后,先用
perf stat和llvm-mca重跑,再下结论。