Post

P2 · 内存与寄存器:CPU 的状态长什么样

P2 · 内存与寄存器:CPU 的状态长什么样

本章目标

读完本章你能回答:

  • “CPU 的状态” 用 C 怎么存?为什么这个问题的答案就是一个 struct 加一个字节数组?
  • 为什么内存是 uint8_t[] 而不是 uint32_t[]
  • 为什么读内存要关心字节序对齐
  • ./temu image.bin 那一瞬间,磁盘上的字节到底经历了什么?

以及你能动手做

  • 写一个带边界检查、路由到 pmem 或 MMIO 的 paddr_read / paddr_write
  • 写一个能防溢出、带短读检查的 raw binary 加载器
  • 给调试器的 $sp $pc $fp 这些 ABI 别名真的查出数值

本章不涉及(defer 清单,避免读者徒劳期待):

  • 虚拟内存 / MMU / 页表——P6b 才做
  • Cache / TLB / 内存屏障——指令级模拟器不做
  • MMIO 设备的真实现(serial / timer)——P5
  • ELF 加载——本章只做 raw binary,ELF 留到 Hard 练习
  • x0 硬连线为 0 的 ISA 语义——那是指令写回路径的职责,P3 处理
  • CSR 详解mstatus / mepc …)——P6a

1. 问题动机:stub 是一种债

翻开 P1 末尾,调试器看起来相当好用:

1
2
3
4
5
6
(temu) p $pc
  = 0 (0x00000000)
(temu) p $sp + 4
  = 4 (0x00000004)
(temu) x 4 0x80000000
0x80000000: 0x00000000 0x00000000 0x00000000 0x00000000

看起来都对。但你仔细想一秒——为什么 $pc 是 0?

因为 P1 的 isa_reg_val stub 永远返回 0。$sp + 4 结果是 4 因为 $sp 也是 0。x 4 0x80000000 四个零不是 “这块内存真的全零”,而是 paddr_read stub 永远返回 0——调试器自己都没法区分 “真读到了全零” 和 “没读到任何东西”

这就是 P1 的”技术债”。调试器每次调用 stub 都是在利用一个巧合:”0 也是合法输出,所以 bug 被掩盖了”。

本章把债还清。三件事同时发生:

  1. 128 MB 物理内存被真正分配出来
  2. 32 个通用寄存器 + PC 被真正初始化
  3. ./temu image.bin 能把磁盘字节真正搬进来

一句口号贯穿全章

核心洞察:CPU 的状态 = 寄存器 + PC + 内存。就这三样。把这三样定义清楚,这台机器就有了”过去”和”未来”——可以被保存、恢复、打印、对比。后面所有的指令、异常、中断都只是在改这坨状态。


2. 寄存器文件:CPU_state 结构体

P0 的 toy CPU 有 3 个寄存器。真 RV32I 有 32 个,加上一个 PC。总计 33 个 word_t——比 toy CPU 多了 30 个,结构没变

1
2
3
4
5
6
7
8
// include/cpu.h
typedef struct {
    word_t  gpr[32];
    vaddr_t pc;
} CPU_state;

extern CPU_state cpu;      /* 全局单实例——整个 TEMU 只有一台 guest */
void cpu_init(void);

几个设计决策值得展开:

2.1 为什么 32 个?

RV32I spec 硬规定的。gpr 编号 0-31 各占指令编码的 5 bit 字段(rd / rs1 / rs2)。这个数字不是选的,是推的——5 bit = 32,多一个都装不下。为什么是 5 bit 而不是 6?见 §9 理论视角。

2.2 为什么 PC 和 gpr 分开?

RV32I 把 PC 从通用寄存器里分离出来。普通的 addsublw 不能直接读写 PC——只有 jal / jalr / 分支指令 + 陷入返回可以。

这是 ISA 设计选择。对比经典 32-bit ARM(A32):R15 = PC 是通用寄存器的一员——mov r0, r15 读 PC,mov r15, r0 跳转。灵活,但代价是:

  • 分支预测困难:任何一条写 R15 的指令都是潜在跳转
  • 流水线边界模糊:硬件要预测 PC 的下一个值,而 PC 是个普通寄存器
  • 读 R15 的”+8 陷阱”:由于早期 ARM 三级流水,mov r0, r15 读到的是 PC + 8(当前取指指针),不是当前执行指令的 PC——这个反直觉是 ARM 汇编的经典坑
  • 安全漏洞:ROP 攻击在经典 ARM 上比 RISC-V 更灵活

:ARMv8 AArch64 已经把 PC 独立——只有分支指令和陷入返回能改 PC,和 RISC-V 一致。RISC-V 的选择跟 MIPS 一脉相承(RISC 传统),不是”从 ARM 学的”,但结论一致:控制流应该显式化。

2.3 x0 的硬连线在哪实现?

RV32I spec 规定 x0 恒为 0:读永远返回 0,写静默丢弃。这是 ISA 语义,不是 C 的数据结构能天然表达的——cpu.gpr[0] = 42 在本章的代码里真的会写进去

读者第一反应是:”在 cpu_init 里怎么保证?” 答案:不在这儿保证。P3 写指令执行时,每条指令的写回路径会加一道 guard if (rd != 0) cpu.gpr[rd] = result——在指令层面实现,不在数据结构层面。

2.4 最小 cpu_init

1
2
3
4
5
6
7
8
9
10
11
// src/isa/riscv32/reg.c
#include "cpu.h"
#include "memory.h"

CPU_state cpu = { .gpr = {0}, .pc = 0 };

void cpu_init(void) {
    memset(cpu.gpr, 0, sizeof cpu.gpr);
    cpu.pc = RESET_VECTOR;        /* = 0x80000000 */
    /* P6a 会在这里加 csr_init(),现在不需要 */
}

这就是 CPU 的”开机序列”。三件事:清零 GPR、设 PC 到复位向量、(未来会有 CSR 初始化)。


3. 物理内存:uint8_t[128MB]

看起来再朴素不过的一行:

1
static uint8_t pmem[PMEM_SIZE];    /* PMEM_SIZE = 128 * 1024 * 1024 */

这一行代码藏了五个设计决策。

3.1 为什么是 uint8_t 不是 uint32_t

计算机的内存是按字节寻址——这是”计算机组成”的约定,不是 C 语言。guest 要能做 lb(读 1 字节)、lh(读 2 字节)、lw(读 4 字节),底层必须以字节为粒度组织,否则 1 字节读写没法实现。

换个角度:uint32_t[] 意味着”只能以 4 字节为最小单位”,这会让字节序、对齐检查都失去表达能力。

3.2 为什么 static + 全局,而不是 malloc

  • .bss 零代价零填充static 数组放在 .bss 段,OS 加载时一次性零填充映射进地址空间。零成本、零泄漏、立刻可用
  • 单实例:整个 TEMU 只有一台 guest CPU 一块物理内存。malloc 是为”不确定数量/生命周期”而生,这里两者都确定
  • 启动即可用:进程一起来 pmem 就存在,main() 的第一行就能读写,不需要在 main 里再 new 一下

3.3 PMEM_BASE 为什么是 0x80000000

1
2
3
4
// include/memory.h
#define PMEM_BASE    0x80000000u
#define PMEM_SIZE    (128 * 1024 * 1024)   /* 128 MB */
#define RESET_VECTOR PMEM_BASE

RISC-V 的 DRAM 起点约定就是 0x80000000。不是随便选的:

  • 低地址区间(0x0000_00000x1000_0000)留给 boot ROM / MMIO
  • 中间区间(0x1000_00000x8000_0000)留给各种设备 / 调试模块
  • 0x8000_0000 起才是 DRAM

对比:x86 的 DRAM 从 0x0000_0000 开始;ARM 各家不同(Cortex-M 默认 RAM 在 0x2000_0000)。地址空间布局是 ISA / 平台的约定。

3.4 guest 地址 ≠ host 指针

这是全章最重要的概念。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                host 进程地址空间
    ┌──────────────────────────────────┐
    │  .text  .data  .bss     ...      │
    │  │ │     │ │              │       │
    │  └─┼─────┴─┼──────────────┘       │
    │    │      ↓                       │
    │    │    static uint8_t pmem[]     │  ← host 视角:一个数组
    │    │    (lives at some host va,   │     地址是 host 的 va
    │    │     e.g. 0x7ff800000000)     │
    │    ↓                               │
    └──────────────────────────────────┘

                guest 地址空间
    ┌──────────────────────────────────┐
    │  0x00000000  ← MMIO / boot ROM   │  ← guest 视角:一个扁平 32 位空间
    │  0x80000000  ← DRAM starts here  │     地址是 guest paddr
    │  0x88000000  ← DRAM ends         │
    │  0xFFFFFFFF                       │
    └──────────────────────────────────┘

                转换桥梁(一行加法)
      host_ptr = pmem + (guest_paddr - PMEM_BASE)

把这个映射写成函数就是:

1
2
3
4
5
6
7
static inline uint8_t *guest_to_host(paddr_t addr) {
    return pmem + (addr - PMEM_BASE);
}

bool in_pmem(paddr_t addr) {
    return addr >= PMEM_BASE && addr < PMEM_BASE + PMEM_SIZE;
}

guest_to_host 是整个 TEMU 最关键的一行代码。它把 guest 的世界塞进 host 的数组。所有”guest 读/写内存” 的本质都是:算出 host 指针,再 memcpy

核心洞察:模拟器的”虚”就虚在这一行减法。guest 以为自己跑在 DRAM 上,host 知道真相——那只是 .bss 里的一块数组。但只要你的减法做对了,guest 永远看不穿。

3.5 理论视角:内存层次与”我们为什么只模拟一层”

真实 CPU 的内存系统不是一块,是一座金字塔

层级典型容量典型延迟实现
寄存器32 × 32 bit = 128 B~1 周期SRAM 小阵列
L1 cache32-64 KB~4 周期SRAM
L2 cache256-1024 KB~12 周期SRAM
L3 cache4-64 MB~40 周期SRAM
DRAM4-128 GB~200 周期DRAM(行+列结构)
SSD / diskTB 级~10 万-100 万周期NAND flash / 旋转磁介质

每一层的存在都是延迟 vs 容量的 tradeoff——越大越慢,越小越快。”好程序”利用局部性让热点数据尽量往金字塔顶上走。

TEMU 为什么只模拟 DRAM 这一层?

  1. 指令级模拟的定位(P0 讲过):cache miss/hit 在指令级上是不可观测的——它改变 执行速度,不改变 执行结果。guest 的 cpu.gpr[i] 该是什么就是什么,无论命中 L1 还是 miss 到 DRAM
  2. 没有正确性损失——cache 的存在只影响性能。模拟器本身慢,我们不介意再慢一点
  3. 加 cache = 写另一个项目——gem5 就是干这个的,6000+ 源文件

对比:虚拟内存为什么要做?

虚拟内存(MMU / 页表)是 正确性相关 的——进程隔离、权限检查、缺页异常都依赖它。没有 MMU 你跑不了 OS。所以 P6b 会做 MMU。

一句话区分

工程原则:我们只模拟语义相关的内存层次,不模拟性能相关的层次。cache 不做(性能),MMU 要做(语义)。这条线在每一章都会重复画。


4. 字节序与对齐

0x11223344 这个 32 位数存进 4 个字节,怎么摆?

1
2
3
4
5
6
union {
    uint32_t w;
    uint8_t  b[4];
} u;
u.w = 0x11223344;
printf("%02x %02x %02x %02x\n", u.b[0], u.b[1], u.b[2], u.b[3]);

在 x86 或 Apple Silicon 上跑:

1
44 33 22 11

低字节在低地址——这是 little-endian(小端)。

4.1 字节序的两个阵营

 Little-EndianBig-Endian
低字节放哪低地址高地址
代表 ISA(默认 / 历史主用)x86, RISC-V, ARM(默认)SPARC, MIPS, PowerPC, 网络字节序
0x11223344 在内存里44 33 22 1111 22 33 44

:SPARC / MIPS / PowerPC 架构上其实都是 bi-endian(双端序),可以切换,但历史上默认和主流部署都是 BE。RISC-V 也是 bi-endian——spec 允许 LE/BE,实现上默认 LE 且几乎没人做 BE 版本。纯粹的”单端”现在几乎只剩 x86。

4.2 一个免费的巧合:host LE + memcpy = guest LE

既然大多数 host(x86、Apple Silicon、大多数 ARM)都是 LE,RISC-V 也是 LE——两边字节序一致。这意味着:

1
2
3
word_t ret = 0;
memcpy(&ret, guest_to_host(addr), len);
return ret;

memcpy 按字节顺序搬,和 LE 语义天然一致。我们不需要手写 (b[3]<<24)|(b[2]<<16)|(b[1]<<8)|b[0] 这种显式打包代码。

核心洞察:host-LE 的 memcpy 自动得到 guest-LE 语义——这不是巧合,是同族架构的免费午餐。如果 host 是 BE(比如古董 SPARC),就要在 memcpy 外面套一层 bswap。我们的代码在第一行假设了 “host LE”,不做这个假设它的简洁就消失了。

4.3 理论视角:字节序简史

字节序之争有个正式的名字叫 “byte sex wars”,1970s-80s 打得天翻地覆。

词源:1980 年 Danny Cohen 发表了著名的 On Holy Wars and a Plea for Peace,刊于 IEN-137(Internet Experiment Note——与 RFC 并列的 IETF 早期文档系列),1981 年正式版由 IEEE Computer 杂志重刊。文章从《格列佛游记》借”大端人 vs 小端人”的典故——原著里两个国家因为”吃蛋从哪头敲”开战,Cohen 用它讽刺 ISA 设计者之间的字节序争论。

阵营(注:SPARC / MIPS / PowerPC 架构上为 bi-endian,下表记录的是它们历史上的默认 / 主流部署):

年代Big-Endian(默认 / 主流)Little-Endian
1960s-70sIBM 360/370-
1970s-80sMotorola 68k, 网络字节序(RFC 791, 1981)Intel 8086, DEC VAX
1980s-90sSPARC, MIPS, PowerPC(均 bi-endian,默认 BE)x86
2000s-今网络协议、某些嵌入式几乎所有

为什么 LE 最后赢了

  1. Intel 垄断——x86 生态系统 lock-in,比任何技术优越性更决定性
  2. 下标一致性*(uint16_t*)&byte = byte 在 LE 下天然成立(低地址 = 低位),把 32 位数强转成 16 位/8 位不改数值。BE 下你会得到高位
  3. 增量读:流式解析时 LE 允许先读低位提前决定是否继续(实际用途不多,但 ISA 设计者认这个理由)

“网络字节序”的活化石:TCP/IP header 用 BE,是 1981 年定 spec 时 BE 还占优的遗产。今天你 x86 / ARM / RISC-V 机器发 TCP 包,每次都要 htons / htonl 做一次 byte swap——这是历史债。

现代妥协:RISC-V 特权规范允许 每个特权级独立 选 LE 或 BE——通过 mstatus.MBE / SBE / UBE 三个位(M/S/U 模式各自一位)。但所有主流实现都默认 LE、从不切 BE。

一句话收尾:

核心洞察:字节序没有”正确答案”,它是美学 + 生态锁定的产物。一旦选了就必须全系统一致——跨系统通信时必须显式转换。

4.4 对齐:硬件契约

RV32I 的 load/store 指令里,地址和访问宽度有个隐含约定:4 字节 lw 理应访问 4 字节对齐的地址。spec 允许实现处理 misaligned 访问,但也允许实现 trap 出异常——”想怎么搞看你”。

TEMU 的选择:我们在 paddr_read不检查对齐,但要求 len ∈ {1, 2, 4}

1
2
Assert(len == 1 || len == 2 || len == 4,
       "paddr_read: bad length %d", len);

为什么不检查对齐?

  • 未来的 memcpy 实现可能做 misaligned 访问:guest 上的 C runtime 有时故意用 misaligned 读快进
  • 某些指令允许 misaligned(如压缩指令扩展)

为什么检查 len?

  • RV32I 的 load/store 只有 lb/lh/lw + 无符号变体——永远不会请求 len=3
  • Assert 抓到”谁传了 3”= 上层 ISA 代码有 bug,立刻停

理论视角(短)

工程原则:对齐要求不是 C 语言的规定,是 DRAM 物理结构 的投影。DRAM 按 “row + column” 组织,一次激活取一整行(64 字节);cache line 也是 64 字节。跨行/跨 line 的 load 等于两次硬件访问,延迟翻倍。x86 允许 misaligned 的代价是硅面积(要多一套 mux + align logic),RISC-V 把这个代价留给软件换来更小的核心。


5. paddr_read 的设计:路由表

5.1 朴素版(有缺陷)

初学者写 paddr_read 的第一直觉:

1
2
3
4
/* v1 朴素版 —— 不要照抄 */
word_t paddr_read_v1(paddr_t addr, int len) {
    return *(uint32_t *)(pmem + addr - PMEM_BASE);
}

这段代码在”大部分情况”下能跑,但藏了四个雷:

  1. 没有边界检查addr = 0x90000000(pmem 之外)会直接越界访问 host 内存——可能读到 TEMU 自己的代码段、可能 SIGSEGV、可能静默破坏相邻变量
  2. 强转 uint32_t* 在未对齐的 host 上 SIGBUS:老 ARM 核(Cortex-M3 之前)硬件不允许 misaligned word 访问
  3. len 参数被忽略:无论你请求 1 / 2 / 4 字节,它都读 4 字节
  4. 没有路由:未来 MMIO 地址(如 0xa0000048 的定时器)落到这里也会越界

5.2 正式版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/memory/pmem.c
static word_t pmem_read(paddr_t addr, int len) {
    word_t ret = 0;                          /* 高位清零:免费的 zero-extend */
    memcpy(&ret, guest_to_host(addr), (size_t)len);
    return ret;
}

word_t paddr_read(paddr_t addr, int len) {
    Assert(len == 1 || len == 2 || len == 4,
           "paddr_read: bad length %d", len);

    if (in_pmem(addr) && in_pmem(addr + (paddr_t)len - 1)) {
        return pmem_read(addr, len);
    }
    if (mmio_in_range(addr)) {              /* MMIO 路由:P5 才有真实现 */
        word_t data = 0;
        mmio_access(addr, len, false, &data);
        paddr_touched_mmio = true;
        return data;
    }
    panic("paddr_read out of bound: addr=0x%08" PRIx32 " len=%d", addr, len);
}

写版本对称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void paddr_write(paddr_t addr, int len, word_t data) {
    Assert(len == 1 || len == 2 || len == 4,
           "paddr_write: bad length %d", len);

    if (in_pmem(addr) && in_pmem(addr + (paddr_t)len - 1)) {
        pmem_write(addr, len, data);
        return;
    }
    if (mmio_in_range(addr)) {
        mmio_access(addr, len, true, &data);
        paddr_touched_mmio = true;
        return;
    }
    panic("paddr_write out of bound: addr=0x%08" PRIx32 " len=%d", addr, len);
}

五处细节需要讲

  1. word_t ret = 0;——读 1 字节时,memcpy 只写 ret 的低 8 位,高 24 位保持 0。这是免费的零扩展(matching lbu 无符号 load 的语义)
  2. in_pmem(addr + len - 1)-1——闭区间。合法访问的最后一个字节是 addr + len - 1,不是 addr + len。这个 off-by-one 是新手必翻的
  3. mmio_in_rangemmio_access——P5 才填真实现,现在是 stub。接口先占位是”有明确未来需求”而不是 YAGNI 的反例
  4. paddr_touched_mmio = true;——P4 的差分测试会用:MMIO 访问有副作用(比如 putchar),参考 CPU 要跳过这一步的对拍
  5. 越界走 panic——abort host 进程而不是 return 0。guest 访问非法地址 = guest bug = 模拟器应该立刻停下让用户看到

核心洞察paddr_read 是 TEMU 里第二稳定的 API(仅次于 P1 的 expr)。从今天写下起,它的签名和语义到 P6a 不动一个字符。未来 P6b 会在它上面加一层 vaddr_read 处理虚拟地址——但 paddr_ 本身不动。


6. 镜像加载:load_img

6.1 朴素版(危险)

1
2
3
4
5
6
7
/* v1 朴素版 —— 千万不要这样写 */
long load_img_v1(const char *path) {
    FILE *fp = fopen(path, "rb");
    fread(pmem, 1, PMEM_SIZE, fp);
    fclose(fp);
    return 0;
}

看起来能跑,实际上藏着全套灾难:

  • fopen 失败(文件不存在)→ fpNULLfread(NULL, ...) 段错误
  • fread 能读的字节数没看——返回值被扔掉,你不知道加载了多少
  • PMEM_SIZE 当上限——文件比 pmem 大,fread 只读满 pmem 然后悄无声息返回(丢失了一部分 image)
  • "rb" 如果忘 b,Windows 下 \r\n 会被自动转成 \n——二进制损坏

6.2 正式版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
long load_img(const char *path) {
    Assert(path != NULL, "load_img: NULL path");

    FILE *fp = fopen(path, "rb");
    Assert(fp != NULL, "cannot open image '%s'", path);

    /* Step 1: 测文件长度 */
    Assert(fseek(fp, 0, SEEK_END) == 0, "fseek failed on '%s'", path);
    long size = ftell(fp);
    Assert(size >= 0, "ftell failed on '%s'", path);

    /* Step 2: 溢出检查 */
    Assert((size_t)size <= PMEM_SIZE,
           "image too large: %ld bytes > pmem %d bytes", size, PMEM_SIZE);

    /* Step 3: 倒回开头,fread */
    Assert(fseek(fp, 0, SEEK_SET) == 0, "fseek failed on '%s'", path);
    size_t got = fread(guest_to_host(RESET_VECTOR), 1, (size_t)size, fp);

    /* Step 4: 短读检查 */
    Assert(got == (size_t)size,
           "short read on '%s': got %zu of %ld", path, got, size);

    fclose(fp);
    Log("loaded %ld bytes from '%s' at 0x%08" PRIx32,
        size, path, (paddr_t)RESET_VECTOR);
    return size;
}

四步每步都有具体防御:

做什么防什么
1fseek SEEK_END + ftell没有 stat 的情况下测文件长度(跨平台)
2比较 size <= PMEM_SIZEimage 过大溢出 pmem 搞坏相邻变量
3freadguest_to_host(RESET_VECTOR)把字节精确放到 reset vector,不是 pmem 头部
4比较 got == sizeI/O error 或信号中断导致短读

guest_to_host(RESET_VECTOR)——注意这里用了 §3.4 的翻译函数。它算出的是 pmem + (0x80000000 - 0x80000000) = pmem——所以其实就是 pmem 头。但写法上要保持 “guest 地址优先”,未来 reset vector 换成其他值(Hard 练习会让你加 -L ADDR 选项)这行代码一字不改。

为什么 Assert 而不是 perror + return -1?因为 image 加载失败 = 开机失败。继续跑没意义。让用户看到红字立刻知道”这次启动没起来”比错误码返回更有信息量。


7. Reset Vector 与 cpu_init

7.1 隐式零初始化的陷阱

你可能想:”CPU_state cpu = {0} 不就把 PC 置零了吗?为什么还要 cpu_init?”

跑一下看看:

1
2
3
4
// 假想的"无 cpu_init"场景
CPU_state cpu = {0};        /* pc = 0 */
load_img("hello.bin");       /* image loaded at 0x80000000 */
exec_once();                 /* 要取指 mem[cpu.pc] */

首次取指:

1
2
3
4
5
6
paddr_read(0x00000000, 4)
  ↓
in_pmem(0) = false          (pmem 在 0x80000000)
mmio_in_range(0) = false
  ↓
panic: paddr_read out of bound: addr=0x00000000 len=4

PC 被隐式初始化为 0,但 0 不在 pmem 范围里。首次取指立刻越界 panic

7.2 真 CPU 的开机序列

真硬件的 reset 序列是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
① CPU 上电
      ↓
② 硬件把 PC 拉到 reset vector
   (x86: 0xFFFFFFF0, RISC-V 惯例: 0x80000000 或厂家自定)
      ↓
③ 这个地址上有 boot ROM (写死的 firmware)
      ↓
④ ROM 代码跳到 bootloader (从磁盘 / flash 加载)
      ↓
⑤ bootloader 加载 OS 内核
      ↓
⑥ OS 接管

TEMU 把这全部压缩成:

1
2
3
4
void cpu_init(void) {
    memset(cpu.gpr, 0, sizeof cpu.gpr);
    cpu.pc = RESET_VECTOR;
}

然后 load_img 把 image 塞到 RESET_VECTOR——我们没有 boot ROM,image 本身就是 boot ROM

7.3 main.c 里的调用顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int argc, char *argv[]) {
    parse_args(argc, argv);

    cpu_init();                  /* 1. 先把 CPU 状态清零 + PC 置 reset */
    init_devices();              /* 2. 初始化 MMIO 设备 */

    if (image_file) load_img(image_file);   /* 3. 把 image 搬进 pmem */

    if (batch_mode) {
        cpu_exec((uint64_t)-1);  /* 4. 开跑 */
        return exit_code();
    }
    sdb_mainloop();              /* 或进入调试器 REPL */
    return 0;
}

顺序不能颠倒:

  • cpu_initload_img:否则 init 可能清零你 load 进来的内容(虽然当前 cpu_init 没碰 pmem,但将来可能会)
  • init_devicesload_img:MMIO 区域要先注册,image 里如果有个 stray write 到 MMIO 地址才不会 panic

工程原则“隐式零初始化”是 C 的美学,”显式 init 函数”是工程的品味。凡是状态涉及多处的(CPU、设备、CSR、difftest),显式 init 函数是唯一靠得住的做法。


8. ABI 别名:从 $x2$sp

调试器要好用,用户就得能写 p $sp 而不是 p $x2。RV32I ABI 规定了 32 个寄存器的符号名:

1
2
3
4
5
6
7
8
9
10
11
12
// src/isa/riscv32/reg.c
static const char *reg_abi[32] = {
    "zero", "ra", "sp",  "gp",  "tp",  "t0", "t1", "t2",
    "s0",   "s1", "a0",  "a1",  "a2",  "a3", "a4", "a5",
    "a6",   "a7", "s2",  "s3",  "s4",  "s5", "s6", "s7",
    "s8",   "s9", "s10", "s11", "t3",  "t4", "t5", "t6",
};

const char *reg_name(int idx) {
    Assert(idx >= 0 && idx < 32, "register index %d out of range", idx);
    return reg_abi[idx];
}

ABI 别名的含义

别名索引用途
zerox0硬连线 0
rax1return address(函数返回地址)
spx2stack pointer
gpx3global pointer
tpx4thread pointer
t0-t6x5-7, x28-31临时寄存器(调用约定不保存)
s0-s11x8-9, x18-27saved 寄存器(调用约定要保存)
a0-a7x10-17函数参数 / 返回值
fp= s0 (x8)frame pointer(s0 的常用别名)

8.1 isa_reg_val 真实版

P1 stub:

1
word_t isa_reg_val(const char *name, bool *ok) { *ok = true; return 0; }

真实版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
word_t isa_reg_val(const char *name, bool *success) {
    /* 1. PC 特殊情况 */
    if (strcmp(name, "pc") == 0) {
        *success = true;
        return cpu.pc;
    }

    /* 2. 数字索引 xN (0 ≤ N < 32) */
    if (name[0] == 'x') {
        char *end;
        long n = strtol(name + 1, &end, 10);
        if (*end == '\0' && n >= 0 && n < 32) {
            *success = true;
            return cpu.gpr[n];
        }
    }

    /* 3. fp 是 s0 的别名 */
    if (strcmp(name, "fp") == 0) {
        *success = true;
        return cpu.gpr[8];
    }

    /* 4. ABI 别名表 */
    for (int i = 0; i < 32; i++) {
        if (strcmp(name, reg_abi[i]) == 0) {
            *success = true;
            return cpu.gpr[i];
        }
    }

    /* 5. CSR fallback (P6a 才填真实现) */
    word_t v;
    if (csr_lookup(name, &v)) {
        *success = true;
        return v;
    }

    *success = false;
    return 0;
}

五层查找:PC / xN / fp / ABI / CSR。一个接口接了 100+ 种合法名字——P1 调试器的 $pc$x3$sp$fp$mstatus 都走这一个函数。

现在回去跑 P1 的 REPL:

1
2
3
4
5
6
(temu) p $pc
  = 2147483648 (0x80000000)     ← 真值!不再是 stub 返回的 0
(temu) p $sp + 4
  = 4 (0x00000004)              ← cpu.gpr[2] = 0 + 4 = 4
(temu) x 4 $pc
0x80000000: 0x44332211 0x88776655 0xccbbaa99 0x00ffeedd  ← image 真的在这

P1 一行代码不改,今天所有命令从 “返回 stub 0” 变成 “返回真实值”。这就是 “先写调试器” 的红利——当 CPU 状态出生时,工具链已经等着它了。

8.2 理论视角:寄存器文件 = 内存层次的顶端

32 个寄存器 vs 128 MB DRAM——差了 400 万倍。为什么有这么大的层级分化?

延迟账:register file 是 SRAM 小阵列(32 × 32 bit = 128 字节),1 个周期就能读——因为面积小、译码器短、线延迟小。DRAM 行激活要上百周期。

面积账:SRAM 一个 bit 要 6 个晶体管(6T cell),DRAM 一个 bit 只要 1 晶体管 + 1 电容(1T1C cell)。相同硅面积下 DRAM 容量是 SRAM 的 ~40 倍。

为什么 32 个而不是 320?

  1. 指令编码:RV32I 指令是 32 bit,rd / rs1 / rs2 各占 5 bit,最多 32 个寄存器。改 64 个就要 6 bit × 3 = 18 bit 给寄存器索引,留给 opcode 的只剩 14 bit,ISA 装不下
  2. 访问延迟:register file 每加一行,读取的 multiplexer 就要多一级。320 个寄存器的 mux 延迟超过 1 周期,失去”1 周期读”的核心优势
  3. 编译器的寄存器分配:通过图着色算法做,寄存器再多帮助有限。OOO 超标量处理器真正用到的是重命名后的物理寄存器(128+ 个),不是 ISA 寄存器

RISC vs CISC 的寄存器数对比

ISAISA 寄存器数时代
x86(CISC)8 个1980s(32-bit)
x86-6416 个2000s
ARM16 个1980s-今
MIPS, SPARC, PowerPC, RV3232 个1980s-今(RISC 标准)

几乎所有 1980 年代后的 RISC ISA 都选 32——这是 register pressure 和 die area 的甜点

核心洞察:寄存器文件不是”内存的一种特例”,它是内存层次被压到极限的产物——用晶体管密度(6T vs 1T1C)换延迟(1 周期 vs 200 周期)。金字塔的顶端和底端本质是同一块硅上用不同方法造的存储


9. 测试:确信 pmem 真的 work

9.1 Smoke 测试:16 字节模式 + LE 读

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/bin/env bash
set -u
TEMU=./build/temu

TMPIMG=$(mktemp)
trap 'rm -f "$TMPIMG"' EXIT

# 16 个可区分字节
printf '\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00' > "$TMPIMG"

# LE 打包:
#   bytes [11 22 33 44] -> 0x44332211
#   bytes [55 66 77 88] -> 0x88776655
#   bytes [99 aa bb cc] -> 0xccbbaa99
#   bytes [dd ee ff 00] -> 0x00ffeedd
EXPECTED='0x80000000: 0x44332211 0x88776655 0xccbbaa99 0x00ffeedd'
OUTPUT=$(printf 'x 4 $pc\nq\n' | "$TEMU" "$TMPIMG" 2>/dev/null)

grep -qF "$EXPECTED" <<< "$OUTPUT" \
    && echo "pmem load test: OK" \
    || { echo "FAIL"; echo "$OUTPUT"; exit 1; }

# 越界测试
OOB=$(printf 'x 1 0x40000000\nq\n' | "$TEMU" 2>&1 || true)
grep -q 'out of bound' <<< "$OOB" \
    && echo "pmem OOB test: OK" \
    || { echo "FAIL"; exit 1; }

关键选择:为什么测试字节是 0x11 0x22 0x33 0x44 而不是全 0 / 全 FF?

  • 全 0:字节序怎么摆都是 0,看不出 LE vs BE
  • 全 FF:同样看不出
  • 每字节不同0x11 0x22 0x33 0x44 以 LE 解释是 0x44332211,以 BE 解释是 0x11223344——一眼区分

这也隐含断言了 §7 的 cpu_init——x 4 $pc 要求 $pc = RESET_VECTOR。如果 cpu_init 漏了,$pc = 0x 4 0 会越界 panic,测试挂。

9.2 为什么这个测试能覆盖这么多东西

这 20 行 shell 在一次执行里同时验证了:

  • 命令行参数解析(positional arg = image path)
  • fopen / ftell / fread 全链路
  • guest_to_host 的加法
  • paddr_read(len=4) 的 memcpy
  • 字节序(LE 打包)
  • cpu_init 把 PC 置到 RESET_VECTOR
  • 调试器 $pc 名称解析
  • x N EXPR 格式化输出

这就是”端到端”测试的威力:一个 smoke 脚本穿透整栈。Golden 测试不是为了”找出第一个 bug”,而是一旦跑过,就是整个栈的 baseline——以后任何 stage 如果改坏了哪一块,这个测试会立刻报警。


10. 踩坑清单

10.1 (uint32_t*)(pmem + off) 强转在部分 host 上 SIGBUS

老 ARM 核(Cortex-M3 之前、ARMv5 之前)硬件不允许 misaligned word 访问。memcpy 版本是 绝对安全 的跨平台解——编译器看到 memcpy(&uint32, p, 4) 通常优化成 ldr(对齐时)或字节加载(不对齐时)。

10.2 in_pmem(addr + len - 1) 的闭区间

in_pmem(addr + len) 会漏掉临界越界:假设 PMEM_SIZE = 100addr = PMEM_BASE + 99, len = 4——访问字节 99, 100, 101, 102,其中 100-102 已越界,但 addr + len = PMEM_BASE + 103,闭区间检查是对的;而 addr + len - 1 = PMEM_BASE + 102 越界,正确地判为非法

10.3 fread(pmem, ..., PMEM_SIZE, fp) 不等于”加载文件”

它是”能吃多少吃多少”——返回值是实际读的字节数。必须先测文件长度再分配读取大小,否则要么漏字节要么溢出。

10.4 fopen"b" 在 Windows 下损坏二进制

Windows 下 "r" mode 会把 \r\n 翻译成 \n——二进制 image 里 0x0D 0x0A 组合会被吃成一个字节。"rb" 开 raw 模式。Linux/macOS 下 "r""rb" 等价但还是要写 "b"——跨平台代码的黄金规则。

10.5 漏 cpu_init 导致 PC = 0

CPU_state cpu = {0} 后不调 cpu_initcpu.pc = 0,首次取指访问 paddr_read(0, 4) 立刻 panic。panic 日志里的 addr=0x00000000 是标志特征——以后见了直接想”有人没调 cpu_init”。

10.6 x0 硬连线不是 pmem / cpu_init 的职责

本章的 cpu.gpr[0] = 42 会真的写进去。P3 的指令写回路径才会守护 if (rd != 0) cpu.gpr[rd] = ...。这是本章的已知缺陷——不是 bug,是分层边界。

10.7 字节序假设

我们的 memcpy 风格依赖 host 是 LE。BE host(古董 SPARC、老 PowerPC)上要加 bswap——TEMU 不支持 BE host,写在第一行注释里。


11. 动手练习

Easy 1 · hist 命令统计字节分布

加一个 REPL 命令 hist:扫描 pmem 从 RESET_VECTORN 字节(N 是参数,默认等于 image 大小),输出 256 个桶的直方图(每字节值出现几次)。

提示guest_to_host(RESET_VECTOR) 拿 host 指针,for (i = 0; i < N; i++) hist[ptr[i]]++;,然后打印非零桶。

学到:区分 “image 加载的范围” 和 “pmem 总大小”——load_img 返回的 size 必须保存起来给 hist 用。

Easy 2 · px 命令以大端格式打印

px N EXPR,和 x 相同但每个字按 big-endian 解释(即每 4 字节反转后再打印)。

提示__builtin_bswap32(word) 或手写 ((w>>24)&0xff) | ((w>>8)&0xff00) | ((w<<8)&0xff0000) | (w<<24)

学到:显式 byteswap 的实现;一眼区分你默认 LE 看到的和 BE 看到的。

Medium 1 · 对齐严格模式

加命令行选项 --strict-align:开启后 paddr_read / paddr_writeaddr % len != 0 的访问 panic。默认关闭。

学到:ISA spec 的宽松默认 vs 严格模式;如何用命令行 flag 控制 assertion 等级。

Medium 2 · 可配置的加载地址 -L ADDR

-L 0xNNNNNNNN 选项:image 加载到 ADDR 而非 RESET_VECTOR;相应调整 cpu.pc。测试 ./temu -L 0x80001000 image.bin 然后 p $pc 得到 0x80001000

学到:reset vector 和 load address 的解耦。真硬件 ROM 加载到低地址,但跳去 RAM(高地址)执行——这两个概念应该分离。

Hard 1 · tiny_memcpy 取代 libc memcpy

不用标准库 memcpy,自己写:

1
2
3
4
5
static void tiny_memcpy(void *dst, const void *src, size_t n) {
    uint8_t *d = dst;
    const uint8_t *s = src;
    while (n--) *d++ = *s++;
}

pmem_read / pmem_write / load_img 全部用它。验证字节序和对齐还是对的。

学到memcpy 不是魔法。”字节按序搬” 就是 little-endian 读的本质——你现在亲手证明了这个对应关系。顺便理解为什么 memcpy 在 guest-LE + host-LE 下”自动”正确。

Hard 2 · ELF 加载器

实现 load_elf(const char *path):解析 ELF header → 读 program headers → 对每个 PT_LOAD 段按 p_paddr 放进 pmem。

提示Elf32_Ehdr / Elf32_Phdr 结构在 <elf.h>。从 e_phoff 偏移读 e_phnumElf32_Phdr,过滤 p_type == PT_LOADfreadguest_to_host(p_paddr)。Entry point 从 e_entry 拿,设到 cpu.pc

学到:ELF 比 raw binary 强在哪——多段、非连续地址、显式 entry point、符号信息(后续练习可以扩展为加载符号表给调试器用)。这是从 “dd 出一个 image” 升级到 “能加载真正的 gcc 产物”。


12. 本章小结

你应该能做到

  • 用一句话解释 “CPU 的状态 = 寄存器 + PC + 内存”,并能画出三者的 C 数据结构
  • 画出 guest paddr 和 host va 之间的 guest_to_host 加法映射
  • 解释为什么 pmem 是 uint8_t[] 而不是 uint32_t[]
  • paddr_read 的三件事:长度 Assert / 范围检查 / 路由
  • 解释 memcpy 为什么在 LE host 上自动得到 LE 语义
  • load_img 的四步:open / ftell / 溢出 / 短读检查
  • 解释 cpu_init 为什么必须在任何 exec 前调用
  • 给 ABI 别名(sp / a0 / fp)查真值

你应该能解释

  • 为什么 RV32I 寄存器是 32 个而不是 64 个(指令编码 5 bit 限制 + 面积延迟权衡)
  • 为什么 PC 要从 gpr 里拆出来(ISA 清晰性 vs ARM R15 的遗憾)
  • 为什么我们模拟 DRAM 但不模拟 cache(语义相关 vs 性能相关)
  • 为什么 host LE + guest LE 让 memcpy “自动正确”
  • 为什么对齐要求是硬件契约而不是 C 的规定
  • 为什么 x0 硬连线应该在指令执行路径实现而不是数据结构
  • 为什么 image 加载失败应该 Assert 而不是 return -1

13. 延伸阅读

  • Patterson & Hennessy · Computer Organization and Design: RISC-V Edition 第 5 章:内存层次从 SRAM 到磁盘的完整理论
  • Danny Cohen · IEN-137 (1980)On Holy Wars and a Plea for Peace——字节序之争的原始文档,短且精彩(IEN 是 RFC 的姊妹系列,不是 RFC)
  • RISC-V Unprivileged ISA Spec
    • 第 1.4 节 Memory Model
    • 第 2.6 节 Load/Store Instructions(读起来就知道为什么 len ∈ {1,2,4}
  • man 5 elf + readelf -a some.elf:ELF 格式速览
  • John L. Hennessy & David A. Patterson · Computer Architecture: A Quantitative Approach 第 2 章:为什么寄存器是 32 个、cache 为什么分 L1/L2/L3 的量化分析
  • xv6-riscv 源码 kernel/memlayout.h:一个真教学 OS 怎么切 DRAM 和 MMIO 的地址区间

与后续章节的连接

下一章做什么本章埋下的伏笔
P3 RV32I CPUpaddr_read 每次取指都会调;cpu.gpr[] 被指令真实写入;cpu_init 成为批量模式的起跑点;x0 硬连线在指令写回路径真正实现
P4 差分测试paddr_touched_mmio flag 开始被读和清;Spike 侧用相同 load_img 接口同步 image
P5 I/O 设备paddr_read 的 MMIO 分支终于有实现——本章预留的 mmio_access stub 填实;paddr_touched_mmio 在差分测试里发挥作用
P6a 异常isa_reg_val 的 CSR 分支被填——P1 调试器写 p $mstatus 一直等这天;reset vector 观念扩展到 trap vector mtvec
P6b 虚拟内存paddr_read 上方会加一层 vaddr_read 做翻译——本章的 paddr_ 前缀正是为了给这层抽象留位置

本章定义了 TEMU 最稳定的两个事实:内存是什么寄存器是什么。从今天起所有的故事都围绕着这两件事展开——指令是”按规则改这两样”,异常是”暂停改这两样”,中断是”被打断后改这两样”……

下一站:P3——让这个有状态的机器真正开始执行 RV32I 指令。

This post is licensed under CC BY 4.0 by the author.