Post

P5 · I/O 设备与 MMIO:guest 与 host 的第一条通路

P5 · I/O 设备与 MMIO:guest 与 host 的第一条通路

本章目标

读完本章你能回答:

  • CPU + 内存 = 完整计算机?差哪一块?
  • 为什么 RISC-V、ARM 都选 MMIO,而 x86 保留了 port I/O?
  • “设备”在模拟器里到底是什么?一个让很多人意外的答案
  • 30 行的 serial.c 为什么代表 guest 第一次能对 host 说话
  • timer 为什么要同时暴露一个 MMIO 地址一个 timer_mtime() C 函数——这不是重复吗?
  • 为什么 paddr_touched_mmio 这个 flag 一次要服务三个章节(P2 声明、P4 使用、P6a 激活)?

动手做到

  • 把 P2 的 paddr_read/write 从”只看 pmem 数组”推成”先看 pmem、再查 MMIO 表”
  • 写一个新设备(比如 debug 寄存器)挂到地址 0xa000_0100,靠它 Log 自己的执行
  • 跑通 prog.c 里的 test_serial_hello——guest 程序第一次把 hello\n 送到宿主 stdout
  • 跑通 test_timer_monotonic——验证连续两次读 mtime 单调递增

本章不涉及

  • DMA / 总线仲裁——真硬件关心,模拟器上 callback 同步执行就够了
  • 中断 delivery 路径——P6a 主场;本章只铺 timer 底座,不讲 MTIP 怎么转成 mip.MTIP
  • 用户态设备隔离 / 驱动框架——Stage 6b 谈完分页再说
  • 真·16550 UART 建模——QEMU 做这个,TEMU 只做单字节输出

1. 问题动机:CPU + 内存 ≠ 计算机

P2 给了 pmem,P3 装了 CPU,P4 用差分测试证明这台 CPU 对 RV32I spec 忠实。此时你跑:

1
2
3
$ ./build/temu -b hello.bin
[pmem.c:43 paddr_write] out of bound: addr=0xa00003f8 len=1
Abort trap: 6

等等。hello.bin 只是六个 sb 指令,把 'h' 'e' 'l' 'l' 'o' '\n' 写到 0xa00003f8。为什么 panic?

因为你的 CPU 是关起门的。pmem 范围是 [0x80000000, 0x88000000)——128MB。0xa00003f8 不在里面,所以 paddr_write 直接 panic(src/memory/pmem.c:59):

1
panic("paddr_write out of bound: addr=0x%08" PRIx32 " len=%d", addr, len);

这是对的。P2 的合约说过:”越界访问必须 panic,不能静默”。可这导致一个荒谬的现实——你手上这台 CPU 算得过来 fib(10),却打不出 hello world

⚠️ 常见误解:很多人以为”写模拟器 = 实现 ISA”。错。一台 CPU 不能打字、不能看时间、不能感知外界,它就只是个状态机玩具。I/O 是让计算机配得上”计算机”这个词的最后一块拼图

本章要做的事:把 [0x88000000, ∞) 这片”越界区”的一小段,重新定义成设备寄存器。写到这些地址,不再 panic,而是触发宿主那边的副作用——比如 putchar,比如读 wall clock。


2. 两种 I/O 范式:为什么 RISC-V 选 MMIO

硬件和 CPU 交换数据,历史上就两条路。

2.1 Port I/O(x86 血脉)

独立的 I/O 地址空间,16 位端口号,共 64K 个。专用指令:

in  al, 0x3f8     ; 从串口读一个字节
out 0x3f8, al     ; 往串口写一个字节

硬件实现:CPU 有一根独立的 M/IO# 信号线,高=访问内存,低=访问 I/O。地址译码器按这根线分流。

2.2 MMIO(RISC-V / ARM / 现代 MIPS 血脉)

设备寄存器被映射到主内存地址空间的某段。访问它们用 普通 load/store 指令

lui  a0, 0xa0000     ; a0 = 0xa0000000
addi a0, a0, 0x3f8   ; a0 = 0xa00003f8
sb   t0, 0(a0)       ; *(uint8_t*)0xa00003f8 = t0  ← 输出字符

CPU 根本不知道 0xa00003f8 是”设备”。它发了一个 bus cycle 到那个地址,地址译码器决定这次访问路由给 DRAM 控制器还是 UART。

2.3 RISC-V 的选择

维度Port I/O (x86)MMIO (RISC-V / ARM)
ISA 新指令IN/OUT 一族0 条
驱动代码inb(0x3f8) / outb(0x3f8, c) 特殊 API*(uint8_t*)0xa00003f8 = c; 普通指针
编译器优化编译器不敢优化 I/O 指令volatile 告诉编译器别合并
硬件复杂度多一条 M/IO# 线 + 独立译码器统一译码,地址一视同仁
地址空间独立 64K port space吃主内存地址
设备数量上限65536 个 port受地址空间限制(RV32 下 4GB,绰绰有余)

核心洞察:MMIO 抛弃了 “I/O 是一种特殊操作” 的分类。硬件和软件都因此变简单——ISA 正交性的胜利。代价:内存地图要明确划出一块区域给设备用,写错地址会访问到设备寄存器而不是 DRAM。x86 兼容性包袱太重没法改,才保留了 port I/O。

RV32I 里根本找不到 IN/OUT 这类指令。这不是”RISC-V 忘了做 I/O”,是”I/O 根本不需要新指令”。


3. 核心洞察:设备 = 带副作用的地址段

回忆 P2 paddr_write(0x80001000, 1, 0x68) 的语义——把 0x68 写到 pmem 数组第 0x1000 字节。纯粹内存操作,没有副作用。

现在考虑 paddr_write(0xa00003f8, 1, 0x68)。语义是什么?

“把 0x68 送给宿主 stdout,让屏幕上出现一个 h。”

两条指令长得一模一样,行为天差地别。差别只在地址落在哪一段。

核心洞察设备在模拟器里就是挂在某段地址上的 C 回调函数。”访问这个地址 → 跑这段 C” —— 就这么简单。模拟器里没有什么”设备总线”、”PCI 配置空间”、”中断控制器”的神秘结构,它们最终都是一张 addr → callback 的表。

这不是模拟器的偷懒。这是硬件的真实模型抽象。真实的 UART 就是一块硅片,CPU 的地址线到它那里就触发了 TX 状态机。区别只是——那边是晶体管,这边是 C 函数。C 函数就是一个慢一万倍的 UART 芯片

有了这个模型,下面的实现就只是”怎么优雅地维护这张表”。


4. paddr_read/write 的双路径分派

看 P5 结束后 paddr_read 的完整形态(src/memory/pmem.c:30):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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);          /* 99% 命中 */
    }
    if (mmio_in_range(addr)) {
        word_t data = 0;
        mmio_access(addr, len, false, &data);
        paddr_touched_mmio = true;            /* P4 埋的伏笔,P5 点亮 */
        return data;
    }
    panic("paddr_read out of bound: addr=0x%08" PRIx32 " len=%d", addr, len);
}

4.1 三个设计要点

(1) pmem 优先,MMIO 次之

in_pmem 就是两次 <,比扫 MMIO 注册表快。让最常见路径(load/store DRAM)零开销通过。

(2) panic 仍然保留

越界访问不给默认值。如果未来你忘了注册某设备就访问它,panic 立刻告诉你”这个地址没人管”。相比 “读返回 0、写静默” 的宽松实现,这样 bug 暴露得早。

(3) paddr_touched_mmio 这个 flag

P2 里只声明(bool paddr_touched_mmio)没用。P4 在 difftest_step 里检查它(决定要不要 snapshot 而不是 lockstep 比对)。P5 在这里第一次把它置 true——”这条指令碰过 MMIO”。

洞察:一个 bool 服务三个章节。代码里这种 跨章节伏笔 是 TEMU 的设计特点之一:早声明、晚使用、一次到位,避免后期 retrofit。你也可以反过来看这是”分布式状态”的臭味——任何读它的模块都隐式依赖其他模块正确 set/clear 它。工程上是 trade-off,教学上值得拿出来讲。

4.2 为什么不用一个统一的地址范围表?

朴素方案是把 pmem 和 MMIO 都挂进同一张 mmio_add_map 表:pmem 也注册成一段 callback。代价:每次访问 pmem 都要扫表——hot path 被拖成线性查找

方案pmem 访问MMIO 访问代码行数
现方案:pmem 硬编码 + MMIO 查表if 两次比较线性扫最多 8 项+5 行
统一查表线性扫 + 第 0 项是 pmem同样线性扫代码更短

选硬编码是 pragmatic choice——教学代码里清晰度 > 抽象对称性。等你真写工业级 VMM 再做 radix tree 查表也不迟。


5. MMIO 注册表:极简设计

src/device/mmio.c 全部不到 60 行。API:

1
2
3
4
5
typedef void (*mmio_cb_t)(paddr_t off, int len, bool is_write, word_t *data);

void mmio_add_map(paddr_t lo, paddr_t hi, mmio_cb_t cb, const char *name);
bool mmio_in_range(paddr_t addr);
void mmio_access  (paddr_t addr, int len, bool is_write, word_t *data);

几个设计决策 值得拆开看。

5.1 为什么不是动态数组?

1
2
3
#define MAX_MMIO 8
static mmio_map_t map[MAX_MMIO];
static int        nr_map = 0;

对比 Linux:动态注册、按需增长、数百个驱动。TEMU 写死 8 个槽位就够——serial、timer、mtimecmp,加上未来可能的键盘、VGA、磁盘,压根到不了 8。YAGNI 原则:不要为”可能用到”的抽象付代价

5.2 注册时验证重叠

1
2
3
4
for (int i = 0; i < nr_map; i++) {
    Assert(!(lo < map[i].hi && hi > map[i].lo),
           "MMIO region '%s' overlaps with '%s'", name, map[i].name);
}

两段 [lo1, hi1)[lo2, hi2) 不相交 ⇔ hi1 <= lo2 || hi2 <= lo1。取反加德摩根律 → lo1 < hi2 && hi1 > lo2。assert 即早发现。

为什么不检查和 pmem 重叠? 因为 pmem 在 [0x80000000, 0x88000000),MMIO 约定挂在 0xa000_0000 附近——两区域隔了 384MB 空白。assert 里不检查是”信任约定”的一种表达。未来若扩大 pmem 到 0x90000000 才要加这条检查。

5.3 callback 的”合一”签名

最朴素的设计会给每个设备写两个回调:read_cbwrite_cb。TEMU 把它们合成一个,用 is_write 标志分派。

方案好处坏处
合一(is_write 标志)读写共享状态天然在一起看得见callback 内部多一次 if
分离(两个函数指针)每个函数职责单一读写共享状态要放 file-static 跨函数

TEMU 选合一主要是为 timer 考虑——mtimecmp 的读写要共同维护那个 64-bit 变量,放一个函数里更紧凑(timer.c:62)。serial 里那个 if 显得多余,但统一的签名让所有设备长一个样,认知成本低。

5.4 偏移而不是绝对地址

1
map[i].cb(addr - map[i].lo, len, is_write, data);

callback 拿到的 off相对该段 base 的偏移,不是绝对地址。好处:设备代码里不用知道自己被挂在哪个 base——未来想把 serial 从 0xa00003f8 换到 0xa000_5000 只改 mmio_add_map 一行,serial_cb 一个字节不改。

经典 locality 原则:信息该在哪层知道,就只让那层知道。

5.5 长度检查

1
Assert(addr + (paddr_t)len <= map[i].hi, ...);

防止 lw 跨越两段 MMIO。比如你 lw a0, 0(0xa00003f7)——4 字节读 [0xa00003f7, 0xa00003fb)——前 1 字节是 serial,后 3 字节悬空。assert 让这种错误立刻暴露。


6. serial:第一个设备,只有 30 行

src/device/serial.c 全部:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define SERIAL_BASE 0xa00003f8u
#define SERIAL_SIZE 8

static void serial_cb(paddr_t off, int len, bool is_write, word_t *data) {
    (void)off;
    (void)len;
    if (is_write) {
        putchar((int)(*data & 0xff));
        fflush(stdout);
    } else {
        *data = 0;
    }
}

void init_serial(void) {
    mmio_add_map(SERIAL_BASE, SERIAL_BASE + SERIAL_SIZE, serial_cb, "serial");
}

真的就这么多。读完你就能写第二个设备。几个值得拆的细节:

6.1 0xa00003f8 这个地址哪来的

1981 年 IBM PC 把串口 UART 16550 的端口号定在 0x3f8(COM1)。之后整个 x86 世界都记得这个数字。TEMU 取 0xa000_0000 | 0x3f8 是致敬——给老程序员一点熟悉感,虽然 RISC-V 上完全没有”COM1”这个概念,是装饰性选择。

你也可以随便选别的,比如 0xa000_0000 整数。不影响任何程序。

6.2 为什么忽略 offlen

真 16550 有 8 个寄存器:THR(发送数据)、RBR(接收数据)、IER(中断使能)、LCR(线控)、… off 选哪一个。TEMU 的 serial 只有一个寄存器(输出),所以:

  • off=0..7 全部映射到同一个写入行为(”forgiving mode”——程序不管写到哪个偏移都被接受)
  • len=1/2/4 都只取 *data & 0xff ——sw 发四字节,只输出低字节

这是”玩具级 UART”。想做 16550 完整建模参考 QEMU 的 hw/char/serial.c——900+ 行。

6.3 fflush(stdout) 必须每次调

1
2
putchar((int)(*data & 0xff));
fflush(stdout);           // ← 看似多余,实则关键

stdout 默认是行缓冲(交互式)或全缓冲(pipe)。如果你的 guest 程序写了 "hello"ebreak 退出,没写 '\n'——libc 里缓冲区的 5 个字符永远不会刷出来

⚠️ 常见误解:”我明明写了 putchar('h'),为什么屏幕上没东西?” 答案:缓冲。解决:每次 flush(或测试时用 stdout = unbuffered)。

真硬件没这问题——串口线是物理的,电平一变就发出去了。模拟的”串口”借宿主 libc 的 stdout,就得懂 libc 的缓冲规则。

6.4 读返回 0 的语义

1
2
3
else {
    *data = 0;
}

真 16550 的 RBR 若有输入数据返回它,没数据时……行为取决于 LSR 状态位。TEMU 没建模输入路径,所以读永远返回 0。程序如果设计了”轮询到读出非零就当作 input”,配合 TEMU 会永远卡住——但目前 TEMU 上没有这种程序,够用。

想加 stdin 参考本章末练习 3。

6.5 验收

tests/programs/prog.c:240test_serial_hello

1
2
3
4
5
6
7
8
9
10
11
12
RUN_OUT("serial hello", 0, "hello",
    /* a0 = 0xa00003f8 */
    LUI(A0, 0xa0000000),
    ADDI(A0, A0, 0x3f8),
    /* h e l l o \n */
    ADDI(T0, ZERO, 'h'), SB(T0, A0, 0),
    ADDI(T0, ZERO, 'e'), SB(T0, A0, 0),
    ADDI(T0, ZERO, 'l'), SB(T0, A0, 0),
    ADDI(T0, ZERO, 'l'), SB(T0, A0, 0),
    ADDI(T0, ZERO, 'o'), SB(T0, A0, 0),
    ADDI(T0, ZERO, '\n'), SB(T0, A0, 0),
    ADDI(A0, ZERO, 0));

make test-prog——你看见 hello 从 stdout 出来。这是 guest 机器 第一次把信息送出它自己的地址空间

在此之前,所有状态都困在 cpu.gpr[]pmem[] 里——就算 fib(20) 算对了,你也得靠 halt 时的 a0 或手工 x 命令才能看到结果。现在它直接能对 host 说话

这个瞬间比任何新指令都更接近”计算机”这个词的本意。


7. timer:第一个真正有状态的设备

serial 是无状态的——数据一出去就忘,下次进来和上次无关。timer 不同:它有一个随时间变化的值,而且这个变化不由 CPU 触发

7.1 两个子设备合一个文件

src/device/timer.c 里注册了两段 MMIO:

1
2
#define TIMER_BASE    0xa0000048u    /* mtime     低 4 + 高 4 = 8 字节 */
#define MTIMECMP_BASE 0xa0000050u    /* mtimecmp  低 4 + 高 4 = 8 字节 */
  • mtime(只读):自启动起的微秒数
  • mtimecmp(读写):软件设的”到这个时间点产生中断”

两者加起来就是 RISC-V CLINT(Core Local Interruptor)规范的最小子集。真 CLINT 还有 msip(软件中断)、按 hart 分组等,TEMU 单核不需要。

7.2 wall clock vs instruction count:时间到底怎么来?

实现 now_us() 有两种完全不同的哲学:

策略语义好处坏处
Wall clock(宿主真实时间)gettimeofday()sleep 1 秒的程序真的睡 1 秒;用户体验一致不可复现——两次运行 timer 读数不同;速率依赖模拟器有多快
Instruction count执行指令数 × 假想周期完全可复现;和 ISA 一样是确定性系统真实时钟和模拟时钟脱节;sleep 1s 变成忙等 10^9 条指令

TEMU 选 wall clock(timer.c:33):

1
2
3
4
5
6
7
8
9
static uint64_t now_us(void) {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return (uint64_t)tv.tv_sec * 1000000u + (uint64_t)tv.tv_usec;
}

uint64_t timer_mtime(void) {
    return now_us() - boot_time_us;
}

为什么?Stage 6a 的定时器中断 demo 想让”每秒触发一次”真的是每秒——不是每 10^9 条指令。对教学演示这是 pragmatic 选择,代价由差分测试吸收:

连 P4paddr_touched_mmio = true 导致 difftest_step 跳过比对、snapshot ref 状态。timer 的不可复现性被系统级吸收,不用在 timer 里自己处理。

7.3 mtimecmp 的 64-bit 写入 hazard

真 CLINT 和 TEMU 都把 64-bit mtimecmp 暴露成两个 32-bit MMIO 寄存器(RV32 ISA 没有 64-bit 存储指令,只能分两次 sw)。timer.c:62 的注释指出了 hazard:

1
2
3
writing the low word first can momentarily create a stale threshold
that trips the interrupt immediately — matches real CLINT behaviour,
which is why Linux writes the high word first when disarming.

场景:当前 mtimecmp = 0x0000_0001_ffff_ffff(已过期很久了),软件想改成 0x0000_0002_8000_0000

  • 步骤 1:写低 word 0x8000_0000 → 寄存器变成 0x0000_0001_8000_0000
  • 此时 mtime ≈ 0x0000_0002_1000_0000已经大于 mtimecmpmip.MTIP 被置起
  • 步骤 2:软件刚打算写高 word,但中断已经触发,trap 处理器被调用——比预期早了半辈子

Linux RISC-V timer 驱动(drivers/clocksource/timer-riscv.c)的写法是:

1
2
3
1. 先写高 word 为 UINT32_MAX —— 保证 mtimecmp 绝对在未来
2. 写低 word 为目标低 32
3. 再写高 word 为目标高 32

三步改一次值。丑,但对。

7.4 TEMU 为什么不修这个 hazard?

我们也能模仿 CLINT 加一个 “staging” 寄存器,软件必须两次写才 commit。但:

  • 现有测试(test_timer_monotonic)只读不写 mtimecmp
  • Stage 6a-4 的定时器中断 demo 只 arm 一次,永远不 re-arm
  • 未来 Stage 6b 若跑真 Linux,那时候 Linux 自己知道先写高 word

所以 TEMU 当前如实反映硬件行为,注释标注 hazard 给未来看。这是 “留 doc 不留 workaround” 的典型——

核心洞察:工程上有两种防错策略:“让错误不可能发生”(加状态机)和 “让错误一发生立刻暴露”(assert/panic)。再加一种“行为忠实于硬件,错误在上层解决”(留 doc)。TEMU 作为硬件模型,第三种最贴合它的定位——它本就该像硬件一样可以被用错。

7.5 双接口:为什么 timer 既是 MMIO 又是 C 函数?

device.h:32 里除了 init_devices 还有:

1
2
uint64_t timer_mtime(void);
uint64_t timer_mtimecmp(void);

guest 通过 MMIO 访问 timer——lw t0, 0(0xa0000048)。那为什么还要暴露 C 函数?

因为 Stage 6a 的 cpu_exec 每执行完一条指令都要判断

1
2
3
if (timer_mtime() >= timer_mtimecmp()) {
    cpu.csr.mip |= MIP_MTIP;
}

如果走 MMIO 路径(paddr_read(0xa0000048, 4)),每条指令都:

  1. 触发 mmio_access
  2. 触发 paddr_touched_mmio = true
  3. 导致 difftest_step 把每条指令都当”碰过 MMIO”处理
  4. Snapshot 整个寄存器文件——hot path 被完全废掉

所以我们开两条路:

访问源路径副作用
Guest 程序MMIO: lwpaddr_readmmio_accesstimer_cb触发 paddr_touched_mmio,走 difftest snapshot
Host cpu_exec直接 timer_mtime() C 函数无副作用

核心洞察同一个底层状态,对不同请求源暴露不同接口。Guest 看到的是 MMIO 时序、有副作用、慢;Host 内部看到的是 C 函数、零副作用、快。这不是洁癖违反,是工程必须

硬件里有类似的东西:CPU 有两条路径访问物理寄存器——”正常流水线” 和 “JTAG 调试端口”。两条路径读同一位数据,但时延、可见性、副作用都不同。TEMU 的”MMIO + C 函数”是它的软件版本。


8. difftest × device:P4 伏笔终于兑现

P4 §6 讲过 paddr_touched_mmio 在差分测试里被检查。到 P5 它终于有”真东西”可指向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
         guest: sw t0, 0(a0)       (a0 = SERIAL_BASE)
                    │
                    ▼
         paddr_write(0xa00003f8, 4, 0x68)
                    │
                    ▼
         mmio_access → serial_cb → putchar('h'), fflush
                    │
                    ▼
         paddr_touched_mmio = true
                    │
       (this instruction is done, cpu_exec returns)
                    │
                    ▼
         difftest_step
             if (paddr_touched_mmio) {
                 paddr_touched_mmio = false;
                 ref_regcpy_from_main();      ← 不让 ref 再跑一次
                 return;                       ← 不比对
             }
             ref_exec_one();
             compare(ref_cpu, cpu);            ← 否则走正常 lockstep

8.1 为什么不让 ref 再跑一次?

因为 ref 再跑一次 = 再打印一个 ‘h’。你的 stdout 会看到 hh,程序输出”hhehllllo\n”——副作用翻倍。

更一般地:

副作用类型在 ref 里重放会怎样
putchar字符出现两次
mtime两次返回值不同(gettimeofday 变了)
未来的 write syscall文件被写两次
网络包发送包发两次

差分测试的前提是两套实现都是纯函数。MMIO 一旦把副作用泄到宿主,这个前提就破了。解决:把这条指令整个从比对中豁免,直接复制状态。

8.2 豁免语义的代价

这样 MMIO 相关指令就失去了差分测试保护。如果你写错了 serial_cb(比如把 *data & 0xff 写成 *data & 0xf),difftest 不会告诉你——输出 x% 乱码你得靠肉眼发现。

核心洞察:差分测试最优雅的边界不是”怎么比”而是”不比什么”。覆盖率的代价 = 你必须准确划出那些”不比”的范围。划大了漏 bug,划小了真值比对里全是噪声。

TEMU 的划法是”走过 MMIO 的整条指令”。更精细的划法(比如只豁免命中 MMIO 那个字节对应的 ref 状态)理论上更好,但工程代价远超教学收益。


9. init_devices():main 里的一行

src/device/mmio.c:53

1
2
3
4
void init_devices(void) {
    init_serial();
    init_timer();
}

main.c:135 调用它:

1
2
cpu_init();
init_devices();       /* ← 必须在 cpu_init 之后、cpu_exec 之前 */

顺序:

  1. cpu_initcpu.gpr 清零,cpu.pc = RESET_VECTOR
  2. init_devices 注册所有 MMIO map,记录 boot_time_us = now_us()
  3. load_img 把镜像拷到 pmem
  4. cpu_exec 开跑

为什么 init_devices 要 idempotent?

看 mmio_add_map 的 assert:

1
Assert(nr_map < MAX_MMIO, "too many MMIO mappings (%d)", MAX_MMIO);

重复注册同名 map 会再占一个槽、然后因重叠触发 panic。所以 init_devices 假设全程只调一次——这是对调用方的隐式要求。未来若要支持 “reset 后重新 init”,得先加 mmio_clear_all()。目前没这需求,YAGNI。


10. 陷阱清单

  • ⚠️ 不要在 pmem_read/write 之外的地方调 paddr_read/write。它们做合法性检查 + MMIO 分发 + flag 置位——这套逻辑只应走一遍。在 isa 执行层里用 paddr_*,别绕过去直接读 pmem 数组。
  • ⚠️ 不要让 cpu_exec hot path 走 MMIOtimer_mtime() 直接 gettimeofday,不走 paddr_read(0xa0000048, 4)。否则 difftest snapshot 每条指令。
  • ⚠️ 不要在 callback 里 printf 调试。它会和 serial 输出交叉、污染 stdout——program test 会把 "[timer.c:50] reading mtime" 当成 guest 输出去匹配。用 Log() 写 stderr。
  • ⚠️ paddr_touched_mmio 由谁清零?difftest_step 开头(P4)。如果你写了自己的非 difftest 消费者,记得设计清零时机——或者改造成”每次 cpu_exec 循环入口清零”。
  • ⚠️ MMIO 区域不要和 pmem 重叠。现在靠”约定不重叠”保证(pmem 在 0x80000000..0x88000000,MMIO 在 0xa000_0000+)。若扩大 pmem 要同步加 assert。
  • ⚠️ fflush(stdout) 不能少。全缓冲下 5 字符 “hello” 可能永远不出来。

11. 练习

Easy 1 · 给 serial 加一个 ready 状态位

off=0 继续是数据寄存器,off=4 变成 “TX ready” bit:

  • off=4 永远返回 1(我们的模型瞬间完成 TX,永远 ready)
  • off=4 被忽略(或 panic)

prog.c 里加一个轮询 ready 再写的程序。学到:真实 UART 的 “status + data” 寄存器簇模型。

Easy 2 · timer 的单调性保护

NTP 同步会把宿主系统时间往回调(极罕见,但有)。此时 gettimeofday 可能返回比上次更小的值,timer_mtime 就会非单调递减。加一个 max 保护:

1
2
3
4
5
static uint64_t last_reported = 0;
uint64_t t = now_us() - boot_time_us;
if (t < last_reported) t = last_reported;
last_reported = t;
return t;

学到:wall clock 建模的陷阱;guest 程序依赖 “时间只会往前走”,你必须替它守住。

Medium · 键盘输入(UART RX)

serial_cb 的 read 路径从宿主 stdin 拿字符。挑战:

  1. 非阻塞读getchar 阻塞会冻结 guest;查 select / fcntl O_NONBLOCK
  2. EOF 语义:stdin 关闭时返回 -1,guest 看到什么?常见做法是返回 0 + LSR bit 0 = 0(”no data”)
  3. terminal raw mode:正常 tty 会等回车才把字符给进程,你得 tcsetattr 关掉 canonical mode

写一个 guest 程序 echo 退出条件 'q':轮询 RX,读到 qebreak

学到:从 “只有输出” 到 “全双工” 的难度跳跃——I/O 一半以上的复杂度在输入侧。

Hard · VGA framebuffer

map [0xa100_0000, 0xa100_0000 + 160*120) 当成 160×120 字符缓冲区(每字节一个 ASCII 字符)。修改 cpu_exec:每执行 N 条指令,把整个 framebuffer 用 ANSI 转义码刷到终端。

挑战:

  1. 帧率节流:每条指令都刷新会把终端打爆。60 Hz 刷一次合适
  2. 设备寄存器 vs 帧缓冲:framebuffer 是没有 callback 的 MMIO——就是挂在那里的一段内存,硬件异步读出来。你要么给它一个真 pmem 区域、让 CPU 直接访问字节,要么在 mmio_cb 里维护一个 shadow buffer
  3. dirty tracking:上一帧和这一帧只差几个字节时,没必要全屏重绘

学到:从 CPU 角度理解 “显卡是一块被 CPU 和显示器共同读写的内存”。这是现代 GPU 和共享内存硬件加速的思想原型。


12. 本章小结

你应该能做到

  • 解释为什么 RISC-V 一条 I/O 指令都没有还能做 I/O
  • 画出 paddr_read 分派流程图(pmem → MMIO → panic)
  • src/device/mmio.c 写第二个设备(debug 寄存器、假硬币随机源……)
  • 解释 serial 为什么必须 fflush(stdout)
  • 解释 timer 为什么同时暴露 MMIO 和 C 函数接口
  • 跑通 make test-prog 看到 serial hellotimer monotonic 两条绿

你应该能解释

  • Port I/O 和 MMIO 的 trade-off
  • paddr_touched_mmio 这个 flag 如何串起 P2 / P4 / P5 三章
  • Wall clock 和 instruction count 对”时间”建模的哲学差异
  • mtimecmp 64-bit 写入 hazard 及 Linux 的规避手法
  • Difftest + MMIO 为什么必须走 snapshot 而不是 lockstep
  • 同一底层状态为什么对 guest 和 host 暴露不同接口

13. 延伸阅读

  • SiFive Interrupt Cookbook(v1.2,2020) —— CLINT/PLIC 官方规范,mtime/mtimecmp 的定义来自这里
  • Linux drivers/clocksource/timer-riscv.c —— 真实 kernel 怎么操作 mtimecmp,三步写入的活化石
  • QEMU hw/char/serial.c —— 900 行完整建模 16550,对照 TEMU 30 行理解”玩具”和”工业”的鸿沟
  • Patterson & Hennessy, RISC-V Edition, Ch. 5.2 —— I/O 范式对比的教科书章节
  • Intel® 64 IA-32 Software Developer Manual Vol. 1 § 16 —— port I/O 的血统,x86 为什么保留它的历史原因
  • Bunnie Huang, Hacking the Xbox, Ch. 2 —— 从逆向角度看真硬件的 MMIO 地图

与后续章节的连接

下一章做什么本章埋下的伏笔
P6a Trap 机制ebreak / ecall 走 trap 后,CSR(mepc/mcause/mtvec)开始介入
P6a 中断 deliverytimer_mtime() >= timer_mtimecmp() 这行代码变成 mip.MTIP = 1 的触发条件
P6a CSR 差分CSR_CMP 从 P4 的”预留”变”激活”——MMIO flag 和 CSR 豁免共用同一个 snapshot 机制
P6b 虚拟内存设备地址段必须绕过页表(不能被 mmap 到用户空间),这是”物理地址”概念第一次和”虚拟地址”分野
全书剩余P5 之后 TEMU 是一台能说话的计算机——输出 hello world、能读时钟、能被差分测试守住。是时候让它学会被打断

P5 给 TEMU 装上了嘴巴和钟表。P6a 要给它装上神经系统——让它能被外部事件打断,而不是一味地顺流而下。

下一站:P6a——异常与中断,让 CPU 第一次学会”放下手头的事”。

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