Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-64ARM64 (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 约定)
栈指针rspspsp(x2)
零寄存器无(用 xor eax,eax)xzrx0 硬连线为 0
特权级ring 0–3EL0–EL3M/S/U
系统调用syscall(rax 号,rdi/rsi/rdx/r10/r8/r9 参数)svc #0(x8 号,x0–x5 参数)ecall(a7 号,a0–a5 参数)
典型风格CISC,内存操作数、变长RISC,Load/StoreRISC,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 SysVARM64 AAPCS64RISC-V psABI
整数参数rdi, rsi, rdx, rcx, r8, r9x0–x7a0–a7
浮点参数xmm0–xmm7v0–v7fa0–fa7
返回值rax(rdx:rax 128 位)x0(x1:x0)a0(a1:a0)
被调用者保存rbx, rbp, r12–r15x19–x29s0–s11
调用者保存其余x0–x18t0–t6, a0–a7
栈对齐16 字节16 字节16 字节
返回地址栈(call 压入)x30(LR)ra(x1)
红区128 字节(叶函数可用)无无
三个常见误解
  1. 「ARM64 没有返回地址寄存器」:有,x30 就是 LR;非叶函数通常把 x30 存栈,再 ret。
  2. 「RISC-V 的 x0 可以写」:x0 硬连线为 0,写入被丢弃;addi x0, x0, 1 仍然是 0,常被汇编器用作 nop。
  3. 「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
读汇编的顺序
  1. 先找参数寄存器(按 ABI 表),不要从第一条指令猜;
  2. 找返回值寄存器与 ret/jr ra;
  3. 找栈调整(sub rsp/stp x29,x30/addi sp,sp,-N),画出栈帧;
  4. 找内存访问(mov 带 []/()/lw/sw),确认基址与偏移;
  5. 最后才看算术与分支。

1.14.7 时钟与功耗:一条指令的时间下限

这一节回答:一条指令最快能多快执行完?为什么主频停在 5 GHz 附近而不是继续翻倍?

1.14.7 讲了 CMOS 反相器与 P ≈ αCV²f 的定性结论。真正决定频率的是一个环路的最长组合路径:

Tclk≥Tcq+Tlogic,max+Tsetup+Tskew+Tjitter. T_{\text{clk}} \ge T_{\text{cq}} + T_{\text{logic,max}} + T_{\text{setup}} + T_{\text{skew}} + T_{\text{jitter}}.

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,则

Tclk≥30+126+40+20+15+10=241 ps,fmax⁡≤4.15 GHz. T_{\text{clk}} \ge 30+126+40+20+15+10 = 241\ \text{ps},\qquad f_{\max} \le 4.15\ \text{GHz}.

想把频率抬到 5 GHz(200 ps),只能砍逻辑深度(加深流水线)或换更快的工艺;前者会增加分支误预测惩罚,后者会增加漏电。这就是 1.14.8 要算的账。

工艺节点FO4 反相器延迟典型 Vdd可持续 f_max漏电占动态功耗
28 nm20–25 ps0.9–1.0 V2.5–3.5 GHz10–20%
16 nm12–16 ps0.8–0.9 V3.5–4.5 GHz20–30%
7 nm8–10 ps0.75–0.85 V4.2–5.0 GHz25–40%
5 nm6–8 ps0.70–0.80 V4.8–5.5 GHz30–45%
3 nm5–7 ps0.65–0.75 V5.2–6.0 GHz35–50%

表里的数字是公开资料的数量级,具体库、电压点、温度都会让结果偏移 20% 以上。不要用工艺节点名直接比较频率:Intel 7、TSMC N7、Samsung 7LPP 的命名不是同一个物理量。

功耗分两块:

Ptotal=αCeqV2f⏟动态+IleakV⏟静态. P_{\text{total}} = \underbrace{\alpha C_{\text{eq}} V^2 f}_{\text{动态}} + \underbrace{I_{\text{leak}} V}_{\text{静态}}.

一颗 3 GHz、1.0 V、等效开关电容 200 nF、活动因子 0.1 的芯片,动态功耗是 0.1×200 nF×12×3 GHz=600.1 \times 200,\text{nF} \times 1^2 \times 3,\text{GHz} = 60 W;漏电再占 25%,总功耗 80 W。把电压降到 0.85 V,动态功耗按 V2V^2 降到 43 W,但 f_max 也会下降——降压与提频不能同时要。

实验 1.14.A · 测出真实频率与 IPC

固定一段依赖链长度为 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 只维持很短时间。

时钟与功耗的五个误解
  1. “主频越高越快”——只在同一微架构、同一指令流下成立。3.5 GHz 的 Golden Cove 在整数负载上可以打赢 4.5 GHz 的老架构;跨架构比 GHz 没有意义。
  2. “TDP 就是最大功耗”——TDP 是散热设计参考值。Intel 的 PL2/PL1、AMD 的 PPT 都允许短时间超过 TDP,持续时间由固件与温度决定。
  3. “工艺节点越小,频率一定越高”——节点主要提升密度与能效;互连电阻、漏电、散热可能让频率不升反降。
  4. “时钟树不耗电”——时钟树要驱动全芯片的触发器,通常占动态功耗的 20–40%;clock gating 关的是这部分。
  5. “降频一定省电”——降频若同时降压才显著省电(V2V^2);只降频不降压,静态功耗占比反而上升。

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 级流水、无停顿的理想周期数是 k+(n−1)k + (n-1)。5 条指令 9 个周期,1000 条指令 1004 个周期,CPI 是 1.004,相对非流水线的 5000 周期加速 4.98 倍,极限是 5。公式:

cycles=k+(n−1)+stalls,CPI=1+stallsn. \text{cycles} = k + (n-1) + \text{stalls},\qquad \text{CPI} = 1 + \frac{\text{stalls}}{n}.

停顿来自三类冒险:

冒险典型场景代价主要解法
结构取指与访存抢同一端口;除法器只有一个1–n 拍分离 I/D cache、多端口、增加执行单元
数据 RAWadd %rax,%rbx 后紧跟 mov %rbx,%rcx0(转发)或 1–2转发(forwarding)、暂停、编译器调度
数据 load-usemov (%rdi),%rax 后立刻用 %rax1 拍提前加载、重排、软件流水
控制条件分支结果未出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.0301.03
95%5%0.1501.15
90%10%0.3001.30
80%20%0.6001.60

这张表说明:分支密集的代码,预测准确率从 99% 掉到 90%,性能损失约 26%;而把误预测率从 5% 压到 1%,只能收回 12%。优化要挑「占比大、惩罚重」的那一项。

例 1.14.1 · 排序为什么能让循环变快

同一段「大于等于 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 倍。注意:排序本身要花 O(nlog⁡n)O(n\log n),只有循环被反复执行很多次时才划算。第 1.5 章会从编译器角度继续这个例子。

流水线与分支预测的五个坑
  1. “分支预测器看源码里的 if”——它看的是指令地址与历史。同一段代码换一份数据分布,命中率可以完全不同。
  2. “__builtin_expect 能显著提速”——它主要影响代码布局与内联,现代预测器会自己学习;在无 profiler 指导下乱标可能让热路径变冷。
  3. “误预测惩罚等于流水线级数”——还要加上前端重取、BTB 缺失、指令缓存 miss;实际惩罚可能比级数更大。
  4. “CPI 一定 ≥ 1”——多发射、SMT 下 CPI 可以小于 1;IPC 才是更直观的指标。
  5. “排序后循环变快,所以排序免费”——排序的成本必须计入;只跑一遍时排序通常更慢。

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/sub14
整数 imul31
浮点 add/mul42
FMA42
L1 加载4–52

若循环是 sum += a[i] 且 sum 是 float,每次加法要等上一次的 4 拍,吞吐被压到 0.25 条/拍。拆成 4 条独立累加链,4 条链可以同时在 4 个周期后各自出结果,平均 1 条/拍;拆成 8 条,达到 2 条/拍的发射上限。这就是「提高指令级并行」最朴素的手法:把一条长依赖链拆成几条短链。

用 llvm-mca 看瓶颈
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,说明执行单元被打满。

乱序执行的五个坑
  1. “乱序执行会打乱程序结果”——退休按序,异常按序,架构状态与顺序执行一致;被重排的只是内部时序。
  2. “有乱序就不需要关心依赖”——RAW 依赖仍然串行;指针追逐、链表遍历、单累加器都是延迟受限。
  3. “volatile 能提高并行”——volatile 禁止编译器重排与合并访问,通常让代码更慢,不解决硬件依赖。
  4. “ROB 越大越好”——ROB 大能覆盖更多 cache miss,但功耗与验证成本上升;服务器与移动端的取舍不同。
  5. “llvm-mca 的数字等于真机”——它不模拟 cache miss、分支误预测、频率变化;只用它比较指令序列的相对优劣。

1.14.10 Cache 地址拆分与 AMAT:手算一个组相联缓存

这一节回答:给定缓存容量、块大小、相联度,一个地址落到哪个组?一次访存平均要多少拍?

设 L1 数据缓存 32 KiB、8 路组相联、块 64 B,虚拟地址 48 位:

sets=32 KiB8×64 B=64,offset=log⁡264=6,index=log⁡264=6,tag=48−6−6=36. \text{sets} = \frac{32,\text{KiB}}{8 \times 64,\text{B}} = 64,\qquad \text{offset} = \log_2 64 = 6,\qquad \text{index} = \log_2 64 = 6,\qquad \text{tag} = 48 - 6 - 6 = 36.

地址从高到低拆成 tag(36) | index(6) | offset(6)。取 0x0000_7fff_1234_5678:

字段计算值
offset0x678 & 0x3f0x38(56)
index(0x678 >> 6) & 0x3f25(0b011001)
tag0x7fff12345678 >> 120x7fff12345

同一组的地址只要求 index 相同,也就是低 12 位中的第 6–11 位相同。下面三个地址都映射到组 25:

地址低 12 位indextag与首个地址的关系
0x7fff123456780x678250x7fff12345基准
0x7fff123466780x678250x7fff12346+4 KiB,同组不同 tag
0x7fff123496780x678250x7fff12349+16 KiB,同组不同 tag

8 路意味着一个组只能放 8 个 tag。如果程序用 9 个相距 4 KiB 的数组同时顺序扫描,第 9 个流会把最早的那个 tag 挤出去,之后每次访问都 miss——这就是冲突缺失(conflict miss),容量明明够却用不上。把数组间距改成 4 KiB + 64 B,或者把 9 个流分块处理,就能避开。

平均访存时间(AMAT)按层展开:

AMAT=tL1+mL1(tL2+mL2(tL3+mL3 tDRAM)). \text{AMAT} = t_{L1} + m_{L1}\left(t_{L2} + m_{L2}\left(t_{L3} + m_{L3},t_{\text{DRAM}}\right)\right).

设 L1 命中 4 拍、局部缺失率 5%;L2 命中 12 拍、局部缺失率 20%;L3 命中 40 拍、局部缺失率 10%;DRAM 200 拍:

AMAT=4+0.05(12+0.2(40+0.1×200))=4+0.05(12+0.2×60)=5.2 拍. \text{AMAT} = 4 + 0.05\left(12 + 0.2\left(40 + 0.1 \times 200\right)\right) = 4 + 0.05(12 + 0.2 \times 60) = 5.2\ \text{拍}.

把 L1 缺失率从 5% 降到 2%,AMAT 变成 4+0.02×24=4.484 + 0.02 \times 24 = 4.48 拍,快 14%;把 L2 命中时间从 12 拍降到 8 拍,AMAT 只变成 4+0.05×20=5.04 + 0.05 \times 20 = 5.0 拍,快 4%。降低缺失率通常比降低命中时间更划算,因为缺失代价被放大了一次。

定义 1.14.1 · AMAT(average memory access time)

一次访存的平均延迟,等于第一层命中时间加上每一层「缺失率 × 下一层代价」的递归展开。式中的缺失率必须是局部缺失率(本层 miss 数 / 本层访问数),不是全局缺失率;混用会把 AMAT 算小。

实验 1.14.B · 用 stride 与 perf 验证组相联
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 与 AMAT 的六个坑
  1. “cache line 一定是 64 B”——x86 常见 64 B;部分 ARM64(如 Apple M1)是 128 B。用 getconf LEVEL1_DCACHE_LINESIZE 或 sysctl hw.cachelinesize 确认。
  2. “index 位数固定”——index = log2(容量 / (块大小 × 相联度));容量或相联度一变,tag/index 的边界就移动。
  3. “用虚拟地址手算真实组号”——VIPT 缓存用虚拟地址的低位做 index,但受页大小与别名限制;真实索引最终由物理地址决定。
  4. “两个数组就会冲突”——8 路要 9 个同组流才发作;2 个流只是占两个 way。
  5. “L2 命中说明 L3 有副本”——L3 可能是非包含(non-inclusive)或排他(exclusive)的,L2 有数据不代表 L3 有。
  6. “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 SysVARM64 AAPCS64RISC-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]x30ra(x1)
帧指针rbpx29s0(x8)
栈对齐16 B16 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 SysVARM64 AAPCS64RISC-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–v3fa0–fa3
小结构体返回rax:rdxx0:x1a0:a1
可变参数%al 记录向量寄存器个数无特殊无特殊
三架构栈帧的六个坑
  1. “x86 第 7 个参数在 [rbp+8]”——[rbp+8] 是返回地址,第 7 个在 [rbp+16];差一格是 call 压栈造成的。
  2. “ARM64 的 stp 会自动移动 sp”——只有带 ! 的预索引写法才写回 sp;stp x29,x30,[sp,#-16] 不移动,下一句会覆盖旧数据。
  3. “sp 可以随便当临时寄存器”——ARM64 要求 sp 在任何时刻 16 字节对齐;用 ldr x0,[sp],#8 这类单寄存器后索引会把对齐破坏。
  4. “RISC-V 的 ra 是独立寄存器”——ra 就是 x1;call 是伪指令,展开成 auipc ra,...; jalr ra,会覆盖 ra。
  5. “红区在所有架构上都有”——只有 x86-64 有 128 B 红区;ARM64/RISC-V 的叶函数不能往 sp 以下写。
  6. “结构体一定走内存”——≤16 B 且字段类型合适时走寄存器;判断依据是分类规则,不是字节数一个数。

1.14.12 指令编码:手算三种格式

这一节回答:给定一条汇编,怎样不查汇编器就写出它的字节?三种 ISA 的字段布局差在哪?

x86-64 先看一条带 SIB 的访存指令:mov 0x10(%rdi,%rsi,4),%eax。它的字节是 8b 44 b7 10:

字节字段值含义
8bopcodeMOV r32, r/m32目标寄存器在 ModR/M.reg
44ModR/M01 000 100mod=01(disp8)、reg=000(eax)、r/m=100(有 SIB)
b7SIB10 110 111scale=2(×4)、index=110(rsi)、base=111(rdi)
10disp80x10有符号 8 位位移

有效地址是 rdi + rsi*4 + 0x10。ModR/M 的 reg 字段不总是寄存器:某些 opcode 把它当扩展操作码(例如 FF /2 是 call),读手册时要看 opcode 的 /digit 记法。

ARM64 是定长 32 位,字段位置固定。add x0, x0, x1 的编码是 0x8B010000:

位字段值
31sf1(64 位)
30op0(ADD)
29S0(不设标志)
28:24固定01011
23:22shift00
21固定0
20:16Rm1(x1)
15:10imm60
9:5Rn0(x0)
4:0Rd0(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 种基本格式,立即数被拆开存放:

格式位宽立即数范围典型指令
R32无add、sub、sll
I3212 位有符号(±2048)addi、ld、jalr
S3212 位有符号sd、sw
B3213 位有符号,2 的倍数(±4 KiB)beq、bne
U3220 位,左移 12lui、auipc
J3221 位有符号,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。

实验 1.14.C · 让汇编器给你对答案
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。手算结果与它对照,错一位都能看出来。

指令编码的六个坑
  1. “ModR/M 的 reg 一定是寄存器”——不少 opcode 把它当扩展操作码;/0–/7 的数字就是 reg 字段。
  2. “x86 只有 8 个通用寄存器”——REX.R/X/B 扩展 reg/index/base 的最高位;没有 REX 前缀时只能编码前 8 个。
  3. “ARM64 的 ADD 只有一种编码”——寄存器版、立即数版、带进位版是不同的编码族,字段布局不一样。
  4. “RISC-V 的立即数是连续的”——B/J 型把立即数打散,手算必须按位段拼,不能直接左移。
  5. “字节序无所谓”——x86/ARM64/RISC-V 小端机器上,32 位字面值与内存字节序相反;写 shellcode 时最容易错。
  6. “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)ARM64RISC-V(RVWMO)
Load → Load禁止允许允许
Load → Store禁止允许允许
Store → Store禁止允许允许
Store → Load允许允许允许
屏障指令mfence / lock 前缀dmb ishfence 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
内存模型的六个坑
  1. “x86 是强序,所以不需要屏障”——TSO 仍允许 Store→Load 重排;Dekker/消息传递模式要 mfence 或锁定指令。
  2. “volatile 是内存屏障”——volatile 只约束编译器对该对象的访问,不生成 CPU 屏障,也不保证原子性。
  3. “dmb ish 和 dmb ishst 可以互换”——ishst 只约束 store,不能替代 ish 去约束 load-store 顺序。
  4. “RISC-V 的 fence 总是全屏障”——fence r,r 只约束 load→load;fence.tso 是另一种强度,按需选择。
  5. “默认 seq_cst 太慢,全部改 relaxed”——放松顺序需要证明每个共享访问的同步关系;漏一个就是数据竞争。
  6. “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-64lea、add、ret38
ARM64add、add、ret312
RISC-Vadd、add、ret312(默认 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) 的语义相同。

跨架构实验的六个坑
  1. “clang --target 能直接链接出可执行文件”——没有 sysroot 时只能 -S/-c;链接会报找不到 crt1.o、-lc。1.14.6 的命令需要 sysroot 或改用交叉 gcc。
  2. “objdump 什么架构都能反汇编”——GNU binutils 通常只编入本机架构;用 llvm-objdump --triple=... 或交叉 binutils。
  3. “静态二进制也要加 -L”——qemu-aarch64 -L 是给动态链接器用的;静态程序加了反而可能加载错误的 ld.so。
  4. “RISC-V 指令都是 4 字节”——默认 rv64gc 含压缩指令,反汇编会出现 2 字节指令;按 4 字节切分会错位。
  5. “本机 gdb 能调所有架构”——本机 gdb 通常只支持 x86-64;跨架构要 gdb-multiarch 加 qemu -g。
  6. “-static 就是非 PIE”——-static-pie 仍是 PIE,入口与重定位处理不同;用 readelf -h 的 Type 确认。

1.14.15 代码尺寸与性能:同一算法的三套指令账

同一段 C 编到三套 ISA,指令数、代码字节数、寄存器压力都不一样。不要凭感觉判断「谁更快」,先量三个数:

指标怎么量说明
代码字节数size a.out / llvm-size受 RVC、Thumb、x86 变长编码影响
指令条数`objdump -dgrep -c ‘^\s*[0-9a-f]’`
周期/指令perf stat -e cycles,instructionsIPC = 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 与分支预测,必须实测。

代码尺寸/性能对照的五个坑
  1. size 的 text 包含只读数据/跳表:不是纯指令字节;看 objdump 的地址范围更准。
  2. RISC-V 的 RVC 是可选扩展:-march=rv64gc 才有压缩指令;rv64g 的代码会大 20–30%。
  3. ARM64 的 -O2 可能自动展开:不同 clang 版本策略不同,先看汇编再比较。
  4. perf stat 的 instructions 包含内核/动态链接器:用 perf stat -e ... -- ./a.out 并固定输入,或 perf stat -a 看整机。
  5. llvm-mca 是静态模型:它不知道 cache miss/分支误预测,只能比较指令序列的吞吐下限。

怎么读结果:如果展开版 IPC 更高但墙钟没变,瓶颈在内存带宽;如果代码尺寸翻倍但 cache 命中率下降,可能更慢。三架构之间不要直接比绝对周期——频率、微架构、内存都不同;要比「同一架构下两种写法的 IPC/带宽」,或「同一算法在三套 ISA 上的指令数/代码尺寸」。

原子指令与屏障:三套 ISA 的同一件事

操作x86-64ARM64RISC-V
原子加lock addldadd/ldxr+stxramoadd.w/d
CASlock cmpxchgcas/ldaxr+stlxramoswap/lr+sc
读-改-写lock xchgswpalamoswap
全屏障mfencedmb ishfence rw,rw
读屏障lfencedmb ishldfence r,r
写屏障sfencedmb ishstfence 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 重跑,再下结论。