P0 · Computer is a State Machine
本章目标
读完本章你能回答:
- 模拟器(emulator)到底在模拟什么?
- 为什么”计算机 = 状态机”这句话不是口号,是真的可以写成 C 代码的?
- 为什么第一个 stage 不是写 CPU,而是写一个空壳 REPL?
- 一个”最小可维护”的 C 项目长什么样?
以及你能动手做:
- 从零搭一个可编译、可跑、可退出的 C 项目
- 用
getopt规范地解析命令行参数 - 用函数指针表实现一个可扩展的命令分派器
1. 为什么要”用 C 写一台计算机”
大多数 CS 学生的”计算机组成原理”课是这样结束的:
- 考试考了 CPU 流水线
- 背了五段式的名字
- 然后再也没碰过
问题是:你真的”懂” CPU 吗?懂和不懂的分界线很简单——能不能手搓一个?
写模拟器的意义不在于性能(宿主跑来 guest 慢几十倍),而在于你被迫把每一个硬件概念落到 C 代码里:
- “取指、译码、执行” 不再是 PPT 上的三个框,而是
for循环的三行 - “寄存器文件” 不再是抽象的存储,而是
uint32_t gpr[32] - “PC 自增” 不再是硬件黑盒,是
cpu.pc += 4
核心洞察:计算机是状态机。状态机可以用 C 写出来。
一切硬件概念最终都会归约到:一个 struct、一个 while、一次 switch。
2. 状态机视角:struct computer
先写一段代码感受一下。下面这个 50 行的 C 程序是一台完整的玩具 CPU——只有三个寄存器、两条指令,但它跑得动:
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
42
// toy-cpu.c — 30 行 CPU 从零开始
#include <stdio.h>
#include <stdint.h>
typedef struct {
uint32_t gpr[3]; // 三个通用寄存器 r0, r1, r2
uint32_t pc; // 程序计数器
} CPU_state;
// 指令编码:高 8 位是 opcode,低 24 位按指令类型解释
#define OP_ADDI 0x01 // r[dst] = r[src] + imm8
#define OP_HALT 0xFF
int main(void) {
CPU_state cpu = {0};
// 用 4 条指令构成一个"程序"
uint32_t mem[] = {
0x01010005, // ADDI r1, r0, 5 => r1 = 0 + 5 = 5
0x01020003, // ADDI r2, r0, 3 => r2 = 0 + 3 = 3
0x01020102, // ADDI r2, r1, 2 => r2 = r1 + 2 = 7 (示意:dst=2,src=1,imm=2)
0xFF000000, // HALT
};
while (1) {
uint32_t inst = mem[cpu.pc / 4];
uint8_t op = inst >> 24;
cpu.pc += 4;
if (op == OP_HALT) break;
if (op == OP_ADDI) {
uint8_t dst = (inst >> 16) & 0xff;
uint8_t src = (inst >> 8) & 0xff;
uint8_t imm = inst & 0xff;
cpu.gpr[dst] = cpu.gpr[src] + imm;
}
}
printf("r1=%u r2=%u\n", cpu.gpr[1], cpu.gpr[2]);
return 0;
}
编译跑一下:
1
2
$ cc toy-cpu.c -o toy && ./toy
r1=5 r2=7
这段代码里已经有了一个真 CPU 的全部骨架:
| 真硬件 | 这段 C 代码 |
|---|---|
| 寄存器文件 | cpu.gpr[3] |
| 程序计数器 | cpu.pc |
| 内存 | uint32_t mem[] |
| 取指 | mem[cpu.pc / 4] |
| 译码 | op = inst >> 24; dst = (inst >> 16) & 0xff; ... |
| 执行 | cpu.gpr[dst] = cpu.gpr[src] + imm |
| PC 自增 | cpu.pc += 4 |
| 主循环 | while (1) { ... } |
接下来几章要做的事,本质上只是把这段 30 行代码扩展成:
- 内存从固定 4 条指令扩到 128 MB(P2)
- 指令集从 1 条扩到真正的 RV32I 全集(P3)
- I/O加上(P5)
- 异常和中断加上(P6a)
所以——你已经会写模拟器了,剩下的只是工程。
2.1 理论视角:从图灵机到冯·诺依曼架构
刚才那个 while (1) { 取指; 译码; 执行 } 的循环不是偶然的形式——它是 1936 年图灵定义 a-machine(自动机,今天我们叫图灵机) 的直接具象化。
图灵机由三部分组成:
| 图灵机 | 冯·诺依曼计算机 | 我们的 C 代码 |
|---|---|---|
| 有限状态控制器(finite control) | CPU 控制逻辑 | while (1) { ... } + switch (op) |
| 无限纸带(tape) | 内存 | uint32_t mem[] |
| 读/写头(head)+ 状态 | 程序计数器 PC + 寄存器 | cpu.pc + cpu.gpr[] |
| 状态转移规则 | 指令集(ISA) | if (op == ...) { ... } |
两个关键等价:
- “状态机的一次迁移” = “CPU 的一条指令”。每执行一条指令,机器就从一个状态(所有 gpr + pc + mem 的组合)转移到下一个状态。这是停机问题等所有计算理论结论能应用到真实计算机的根本原因
- “纸带上的字符串” = “内存里的字节”。图灵机”纸带无限长”是理想化;真实 CPU 的内存有限,但只要内存够用,它就是图灵完备的——能计算任何可计算函数
核心洞察:CPU 不是魔法,它是一个被定义在 ISA 规范里的状态机。ISA 就是这个状态机的迁移规则表。写模拟器 = 按规范把迁移规则抄进 C 代码。没有神秘,没有”硬件魔法”——只有一张查不完的表和一个循环。
这也解释了冯·诺依曼架构那个看似奇怪的决策:”数据和指令放在同一块内存”。从图灵机视角看:纸带上既可以写”数据”也可以写”规则”——规则本身也是一串符号。这开启了”程序即数据”这条路:编译器、动态链接、JIT、病毒、自修改代码……所有这些都是这条路上的副产品。
与之对立的是哈佛架构(数据和指令分开两块内存),常见于嵌入式 / DSP 芯片——安全性和并行性更好,但丢掉了”程序即数据”的灵活性。你的手机里的 Cortex 核心实际是改良的冯·诺依曼 + 分离 L1 cache 的折衷。
3. 术语澄清:模拟器到底是什么
写之前先厘清几个经常混用的词:
| 术语 | 定义 | 例子 |
|---|---|---|
| Emulator(模拟器) | 在 host 上用软件模拟 guest 架构的指令级行为,不要求时序精确 | QEMU、NEMU、我们的 TEMU |
| Simulator(仿真器) | 通常更强调时序/周期精确,常用于硬件验证 | Verilator、gem5 |
| Virtual Machine | Guest 指令直接在 host CPU 上执行(通过硬件辅助),不翻译 | VMware、KVM |
| Interpreter | 模拟器的实现技术之一:一条一条解释 | 我们的 TEMU、早期 QEMU |
| JIT / Dynamic Translation | 把 guest 指令块翻译成 host 指令再执行,加速 | QEMU TCG、V8 |
宿主(host)和被模拟机(guest)的关系:
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────┐
│ host: macOS on x86_64 │ ← 你的电脑
│ │
│ ┌────────────────────────────┐ │
│ │ process: ./temu │ │
│ │ │ │
│ │ ┌──────────────────────┐ │ │
│ │ │ guest: RV32I machine │ │ │ ← 我们模拟的计算机
│ │ │ CPU_state cpu │ │ │
│ │ │ uint8_t pmem[128MB]│ │ │
│ │ └──────────────────────┘ │ │
│ └────────────────────────────┘ │
└──────────────────────────────────┘
本书的定位:写一个 interpreter 风格的 emulator——一次解释一条指令,不做任何翻译/加速。代码简单、行为透明、调试友好。做完它你才有资格去看 QEMU。
3.1 模拟的二维光谱
为什么有这么多工具?因为”模拟”是个二维选择:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
↑ 周期精确
│
gem5 ──┤
│ Verilator
│
周期级 │ ● Spike(指令级 + 解释,RISC-V 黄金参考)
│ /│
│ / │
指令级 │ TEMU │ QEMU(指令级 + 动态翻译)
│ ●──┤ ●
│ │
└──────┴─────────────────────────→
解释 动态翻译 JIT 原生
(interpret) (TCG / LLVM) (virtualization)
- 纵轴·抽象层次:你关心 CPU 内部的哪些细节?
- 指令级(instruction-accurate):每条指令执行完之后的状态(寄存器、内存)正确,但一条指令”花了多少时钟周期”不管——TEMU、QEMU、Spike 都在这一层
- 周期级(cycle-accurate):连流水线 stall、cache miss penalty 都精确——gem5、Verilator 在这层
- 门级 / RTL:每个触发器的信号变化——Verilator 的极端模式
- 横轴·实现技术:guest 指令怎么变成 host 行为?
- 解释(interpret):一条一条
switch分发,像我们的 toy CPU - 动态翻译 JIT:第一次遇到一段 guest 代码时翻译成 host 机器码缓存起来,再遇到直接跑翻译结果——QEMU TCG、V8 JavaScript
- 硬件虚拟化:guest 指令直接在 host CPU 上跑,遇到特权指令才陷入 hypervisor——KVM、VMware
- 解释(interpret):一条一条
TEMU 落在左下角(指令级 + 解释)是刻意的教学选择:
- 每一步都是 observable 的 C 代码——你能单步调试任何一条指令的执行过程
- 代码量可控——全部代码一两千行,一周能读完、一个月能复现
- 可改性——想加个自定义指令?加个
INSTPAT就行,不用管编译器
代价:慢得离谱。TEMU 跑 guest 每秒大概几十万条指令,QEMU 几亿条,真机几十亿条。但教学时慢正是优点——快意味着跳过了可见性。
工程原则:工具的选择由问题决定,不是由性能决定。”读懂 CPU 怎么工作” 和 “跑 Linux 启动” 是不同的问题,应该选不同的工具。
4. 为什么第一步不是写 CPU,而是写个空壳
直觉会告诉你:”计算机最重要的是 CPU,先把 CPU 写了呀。”
这是初学者的第一个大坑。原因:
- CPU 没法单独测试——它要从内存取指,内存没搭好测不了
- 就算能跑,你也不知道”跑对了没有”——没有调试器看不了寄存器
- 写出来 bug 一堆,你打印
printf调试到怀疑人生
正确的顺序是倒过来:先搭一个能”控制”未来 CPU 的外壳,再填 CPU。这个外壳就是 REPL(Read-Eval-Print Loop),类比:
| 工具 | 它的外壳是 | 外壳先于 |
|---|---|---|
| Python | python 交互 shell | 先有 REPL,后写复杂库 |
| GDB | (gdb) 提示符 | 先有命令框架,后接入调试器 |
| 我们的 TEMU | (temu) 提示符 | 先有命令分派,后接入 CPU |
本章做的就是第一步:让 ./temu 能启动、接受命令、干净地退出。
5. 最小可维护的 C 项目骨架
5.0 动手前:C 的编译模型(为什么需要 Makefile)
如果你用过 Python、JavaScript、Go,可能觉得”构建系统”很神秘。C 和它们不一样——C 没有模块,每个 .c 文件要独立编译,然后靠链接器缝起来。理解这点才能理解 Makefile 为什么长那样。
C 的编译四阶段(用 cc foo.c 时它都悄悄帮你做了):
1
2
3
4
5
6
7
foo.c ──┬── 预处理 (cpp) ──→ foo.i ← 展开 #include、#define
│
├── 编译 (cc1) ──→ foo.s ← 翻译成汇编
│
├── 汇编 (as) ──→ foo.o ← 汇编成机器码(object file)
│
多个 .o ──┴── 链接 (ld) ──→ foo ← 合并、解析符号、重定位
关键概念:翻译单元(translation unit)。一个 .c 文件加上它 include 的所有 header,在预处理后会被展开成一个单一的源代码文本——这就是一个翻译单元。编译器一次处理一个翻译单元,产出一个 .o 文件。
这就引出 C 的几个”奇怪”特性的来源:
头文件 include guard(
#ifndef X / #define X / #endif)如果
a.h和b.h都被foo.cinclude,且a.h内部也 include 了b.h——没有 guard 的话b.h会被展开两次,导致同一个struct被定义两次。guard 让重复 include 变成 no-op。为什么 header 里放
extern声明,.c里放定义多个翻译单元都可能 include 同一个 header。如果 header 里放定义(比如
int g_count = 0;),每个 translation unit 都产生一份g_count,链接时冲突。放extern int g_count;(仅声明,不分配),只在某一个.c里真正定义,链接时大家共享同一份。为什么需要 Makefile 做依赖追踪
假如你改了
common.h。哪些.c需要重编?C 不像 Rust 有mod系统告诉编译器依赖图——依赖藏在每个文件的#include里。Makefile 用-MMD让编译器顺便吐出依赖文件foo.d(内容形如foo.o: foo.c common.h),下次改common.h,Make 自动知道该 rebuild 哪些.o。为什么我们用
typedef uint32_t word_tuint32_t描述的是表示(32 位无符号整数),word_t描述的是语义(guest 架构的”字”)。将来 TEMU 若移植到 RV64,只改typedef一处就行,不用全局改uint32_t → uint64_t。用类型名表达意图,是系统编程成熟度的标志。
核心洞察:C 的”简单”是假象——它把编译模型、链接模型、内存模型都暴露给你管。这就是为什么 C 代码比 Python 跑得快,也是为什么 C 项目需要 Makefile 而 Python 不需要。你付出的每份工程复杂度,都换回来一份控制权。
5.1 项目目录
1
2
3
4
5
6
7
8
TEMU/
├── Makefile
├── include/
│ └── common.h ← 给所有模块共用的类型 / 宏
└── src/
├── main.c ← 入口、参数解析
└── monitor/
└── sdb.c ← REPL 主循环
5.2 common.h:所有人共用的基建
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
42
// include/common.h
#ifndef COMMON_H
#define COMMON_H
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdbool.h>
#include <string.h>
#include <assert.h>
/* word_t 是 guest 架构的"字"——RV32 下是 32 位无符号整数。
* 定义一个 typedef 比到处写 uint32_t 更有"建模意图"。 */
typedef uint32_t word_t;
typedef int32_t sword_t;
/* 颜色码:错误信息染红、警告染黄,在 terminal 里一眼看见。 */
#define ANSI_FG_RED "\33[1;31m"
#define ANSI_FG_YELLOW "\33[1;33m"
#define ANSI_NONE "\33[0m"
/* Log 宏:打到 stderr 不污染 stdout(未来程序的标准输出要跟模拟器日志分开) */
#define Log(fmt, ...) \
fprintf(stderr, ANSI_FG_YELLOW "[%s:%d %s] " fmt ANSI_NONE "\n", \
__FILE__, __LINE__, __func__, ##__VA_ARGS__)
/* Assert:条件不满足就红字打印 + abort。比 <assert.h> 的版本多了自定义消息。 */
#define Assert(cond, fmt, ...) \
do { \
if (!(cond)) { \
fflush(stdout); \
fprintf(stderr, ANSI_FG_RED "Assertion failed at %s:%d: " \
fmt ANSI_NONE "\n", \
__FILE__, __LINE__, ##__VA_ARGS__); \
assert(cond); \
} \
} while (0)
#define panic(fmt, ...) Assert(0, fmt, ##__VA_ARGS__)
#endif
设计要点:
#include警卫 用#ifndef / #define / #endif,避免同一份头文件被 include 两次导致重复定义##__VA_ARGS__是 GCC/Clang 扩展:当可变参数为空时把前面的逗号也吃掉。写纯标准 C11 的话要换成__VA_OPT__(,) __VA_ARGS__(C23 特性),工程里能用 GNU 扩展就用do { ... } while (0)是宏写多语句时的惯用法:保证if (x) Assert(...); else ...;能正确分支
5.3 main.c:入口和参数分发
先给一个反面教材——初学者常犯的写法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 反例:直接 for (i=1; i<argc; i++) 手动分发
int main(int argc, char *argv[]) {
int batch_mode = 0;
char *log_file = NULL;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-b") == 0) {
batch_mode = 1;
} else if (strcmp(argv[i], "-l") == 0) {
log_file = argv[++i]; // 忘了检查 i+1 会不会越界
}
// -lfoo 这种粘连写法支持不了
// --help 支持不了
// 顺序错了会乱
}
// ...
}
这个写法为什么坏?
argv[++i]万一用户写./temu -l(没跟参数),会读到argv[argc](NULL)然后传给fopen段错误- 不支持
-lfoo(和-l foo等价)这种 POSIX 惯例 - 加第三个选项时嵌套 if-else 会爆炸
正确写法——用 getopt:
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
// src/main.c
#include <getopt.h>
#include "common.h"
static int batch_mode = 0;
static const char *log_file = NULL;
static const char *image_file = NULL;
static void print_usage(const char *prog) {
printf("Usage: %s [OPTION]... [IMAGE]\n", prog);
printf(" -b run in batch mode (no REPL)\n");
printf(" -l FILE write log to FILE\n");
printf(" -h show this help and exit\n");
}
/* 返回值约定:
* 0 — 正常,继续执行
* 1 — 用户请求 -h 帮助后退出
* -1 — 参数错误,应以非零退出 */
static int parse_args(int argc, char *argv[]) {
int opt;
/* "hbl:" 里的 ':' 表示 -l 需要一个参数。 */
while ((opt = getopt(argc, argv, "hbl:")) != -1) {
switch (opt) {
case 'h': print_usage(argv[0]); return 1;
case 'b': batch_mode = 1; break;
case 'l': log_file = optarg; break;
default: print_usage(argv[0]); return -1;
}
}
/* getopt 处理完所有 option 后,optind 是第一个非-option 参数的位置。
* 我们约定这个位置是 image 文件路径。 */
if (optind < argc) image_file = argv[optind];
return 0;
}
getopt 的好处:
- 自动处理
-b -l foo、-bl foo、-lfoo等合法变体 - 缺参数时自动打印
option requires an argument optarg/optind是 libc 提供的全局变量,语义有标准文档
然后是 main 本身:
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
int main(int argc, char *argv[]) {
int r = parse_args(argc, argv);
if (r != 0) return r < 0 ? 1 : 0;
if (log_file != NULL) {
/* freopen: 把 stderr 重定向到文件,且后续 Log() 都会写进去。 */
if (freopen(log_file, "w", stderr) == NULL) {
fprintf(stderr, "cannot open log file '%s'\n", log_file);
return 1;
}
}
if (image_file != NULL) {
Log("would load image from '%s' here", image_file);
} else {
Log("no image provided; starting with empty state");
}
if (batch_mode) {
Log("batch mode: nothing to run yet (CPU comes in P3)");
return 0;
}
void sdb_mainloop(void); /* forward decl — 下一节实现 */
sdb_mainloop();
return 0;
}
关键细节:
log_file的重定向放在parse_args之后、业务代码之前,保证之后所有Log()都能写进文件image_file在optind < argc时取,空镜像也能启动——未来章节会接入真的加载
5.4 sdb.c:REPL 主循环
REPL 的本质:读一行 → 分派到 handler → 循环。让我们写一个 naive 版本,然后逐步修正。
版本 1 · naive(有 bug):
1
2
3
4
5
6
7
8
9
10
11
// 别照抄,有 bug
void sdb_mainloop_naive(void) {
char line[256];
while (1) {
printf("(temu) ");
fgets(line, sizeof line, stdin);
if (strcmp(line, "q") == 0) return;
printf("Unknown: %s", line);
}
}
问题:
- Bug 1 (EOF):Ctrl+D 时
fgets返回NULL,line内容未定义,后面strcmp读垃圾 - Bug 2 (换行符):
fgets会把\n也读进来,line实际是"q\n",strcmp("q\n", "q")不等 - Bug 3 (空行):用户直接回车,
line是"\n",当 unknown 打印 - Bug 4 (扩展性):加第二个命令怎么办?写
else if?三个、十个呢?
版本 2 · 修掉 bug 1-3:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void sdb_mainloop_v2(void) {
char line[256];
while (1) {
printf("(temu) ");
fflush(stdout); /* 防止 printf 缓冲导致提示符延迟显示 */
if (!fgets(line, sizeof line, stdin)) {
printf("\n"); /* Ctrl+D: 干净退出 */
return;
}
/* 去掉尾部的 \n */
size_t n = strlen(line);
if (n > 0 && line[n - 1] == '\n') line[n - 1] = '\0';
/* 跳过空行 */
if (line[0] == '\0') continue;
if (strcmp(line, "q") == 0) return;
printf("Unknown: %s\n", line);
}
}
版本 3 · 函数指针表(解决扩展性):
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// src/monitor/sdb.c
#include "common.h"
/* 命令 handler 约定:返回 -1 表示"退出 REPL",其他值继续 */
static int cmd_help(char *args);
static int cmd_q (char *args);
static struct {
const char *name;
const char *desc;
int (*handler)(char *args);
} cmd_table[] = {
{ "help", "Show command list", cmd_help },
{ "q", "Quit TEMU", cmd_q },
};
#define NR_CMD ((int)(sizeof(cmd_table) / sizeof(cmd_table[0])))
static int cmd_help(char *args) {
(void)args;
for (int i = 0; i < NR_CMD; i++) {
printf(" %-5s %s\n", cmd_table[i].name, cmd_table[i].desc);
}
return 0;
}
static int cmd_q(char *args) {
(void)args;
return -1;
}
void sdb_mainloop(void) {
char line[256];
printf("TEMU — TErminal Machine Emulator\n");
printf("Type 'help' for commands, 'q' to quit.\n");
while (1) {
printf("(temu) ");
fflush(stdout);
if (!fgets(line, sizeof line, stdin)) { printf("\n"); break; }
size_t n = strlen(line);
if (n > 0 && line[n - 1] == '\n') line[n - 1] = '\0';
/* strtok 原地切分:第一个 token 是命令名,剩下是参数字符串 */
char *cmd = strtok(line, " \t");
if (cmd == NULL) continue; /* 空行 */
char *args = strtok(NULL, ""); /* "" 意思是"余下全部" */
int found = 0;
for (int i = 0; i < NR_CMD; i++) {
if (strcmp(cmd, cmd_table[i].name) == 0) {
found = 1;
if (cmd_table[i].handler(args) < 0) return;
break;
}
}
if (!found) printf("Unknown command: %s (try 'help')\n", cmd);
}
}
现在加一个命令要改什么?
- 写个
static int cmd_foo(char *args) { ... } cmd_table里加一行
扩展成本从 O(n²) 的 if-else 变成 O(n) 的表驱动。这不是炫技,是每个成熟命令行程序(vim、gdb、redis-cli)都这么写的原因。
设计原则:能用数据(表)表达的事不要用代码(if-else)表达。加功能时改数据比改代码安全。
5.5 Makefile:让 make 魔法可见
手写 compile 命令能跑,但 10 个文件后会崩溃。看一个能自动处理依赖的 Makefile:
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
# Makefile
CC ?= clang
CFLAGS := -std=c11 -Wall -Wextra -Werror -g -O0 -Iinclude
LDFLAGS :=
SRC_DIR := src
BUILD_DIR := build
TARGET := $(BUILD_DIR)/temu
# shell 命令:把所有 .c 找出来
SRCS := $(shell find $(SRC_DIR) -name '*.c')
# 文本替换:src/foo/bar.c → build/foo/bar.o
OBJS := $(SRCS:$(SRC_DIR)/%.c=$(BUILD_DIR)/%.o)
DEPS := $(OBJS:.o=.d)
.PHONY: all clean run
all: $(TARGET)
$(TARGET): $(OBJS)
@mkdir -p $(dir $@)
$(CC) $(LDFLAGS) $^ -o $@
# Pattern rule: 任何 build/X.o 都由 src/X.c 生成
$(BUILD_DIR)/%.o: $(SRC_DIR)/%.c
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# 包含 .d 文件——GCC 自动生成的"每个 .o 依赖哪些 .h"
-include $(DEPS)
clean:
rm -rf $(BUILD_DIR)
run: $(TARGET)
./$(TARGET)
关键技巧:
| 魔法 | 作用 |
|---|---|
-MMD -MP | 让编译器在编译时生成依赖文件 foo.d,内容类似 foo.o: foo.c common.h cpu.h。下次改了 common.h,所有 include 它的 .c 自动 rebuild |
-include $(DEPS) | 前缀 - 表示”文件不存在时不报错”(第一次 build 还没有 .d 文件) |
$^ / $< / $@ | 自动变量:所有依赖 / 第一个依赖 / target 名 |
.PHONY | 声明 clean / run 这些不是真文件,就算有同名文件也强制执行 |
@mkdir -p $(dir $@) | @ 开头的命令不回显;$(dir ...) 取目录部分 |
验证能跑:
1
2
3
4
5
6
7
8
9
10
11
12
13
$ make
cc -std=c11 -Wall -Wextra -Werror -g -O0 -Iinclude -MMD -MP -c src/main.c -o build/main.o
cc -std=c11 -Wall -Wextra -Werror -g -O0 -Iinclude -MMD -MP -c src/monitor/sdb.c -o build/monitor/sdb.o
cc build/main.o build/monitor/sdb.o -o build/temu
$ ./build/temu
[src/main.c:xxx main] no image provided; starting with empty state
TEMU — TErminal Machine Emulator
Type 'help' for commands, 'q' to quit.
(temu) help
help Show command list
q Quit TEMU
(temu) q
6. 如何”测试”一个还没功能的程序?
Stage 0 几乎没有”功能”——它能做的就是启动、响应 help、q。怎么测?
最小端到端测试:用 shell 喂输入、检查输出或退出码。
1
2
3
4
5
6
7
8
9
10
# tests/stage0/smoke.sh
#!/bin/bash
set -e
OUT=$(printf 'help\nq\n' | ./build/temu 2>/dev/null)
echo "$OUT" | grep -q "Quit TEMU" || {
echo "FAIL: help didn't list Quit TEMU"; exit 1; }
echo "PASS"
把它挂到 Makefile:
1
2
3
.PHONY: test
test: $(TARGET)
@bash tests/stage0/smoke.sh
这测不了什么高级东西,但它保证:从今天起,make test 绿是 baseline。未来任何改动把 baseline 打破,make test 立刻告诉你。这种持续性的”小断言”比每次手动测快 100 倍。
工程原则:自动化测试的价值不是”发现新 bug”,是”回归检测”——保护已跑通的功能不被新代码搞坏。
7. 踩坑清单
新手在 stage 0 翻车最多的地方:
7.1 Makefile 的 tab 不是空格
1
2
target:
echo "hi" # 错!VS Code 默认转成空格了
Makefile 规则体必须是真 TAB 字符。用 cat -A Makefile 看,TAB 显示为 ^I。编辑器要把 .mk 和 Makefile 文件的”自动转 tab 为空格”关掉。
7.2 fgets 的尾巴换行
新手第一次写 REPL 必翻。记住这个惯用法:
1
2
size_t n = strlen(line);
if (n > 0 && line[n - 1] == '\n') line[n - 1] = '\0';
7.3 strtok 的副作用
strtok 会修改原字符串(在分隔符处插入 \0)。这意味着:
- 不能对字符串字面量调用(
strtok("abc"...)段错误) - 返回的指针指向原字符串内部,原字符串被 free/覆盖后立刻失效
- 它有全局状态,不能在多线程里用(要用
strtok_r)
7.4 printf 没刷新就 fgets 阻塞
1
2
printf("(temu) "); // 没 \n,行缓冲不刷
fgets(...); // 用户看不见提示符就让输入
解:fflush(stdout)。或者提示符末尾加 \n(丑)。
7.5 把”未来功能”提前引入
Stage 0 你可能会手痒写:
1
2
3
// "为后面 CPU 留个接口"
typedef void (*cpu_hook_t)(uint32_t pc);
static cpu_hook_t hooks[16];
YAGNI(You Aren’t Gonna Need It)。Stage 0 就是 stage 0,CPU 的事留给 P3。提前写的”通用抽象”十有八九会在真需求来时被推翻。
8. 动手练习
Easy 1. echo 命令
给 REPL 加一个 echo X Y Z 命令,把参数原样打印。args 就是 "X Y Z"(已经去掉了命令名)。
提示:在 cmd_table 加一行,写 cmd_echo 就完事。
Easy 2. 退出码
改造 main:如果用户用 q 退出,返回 0;如果是 Ctrl+D,返回 42。在 shell 里用 echo $? 验证。
学习点:进程间沟通最简单的方式是退出码。
Medium 1. -l 选项测试
写一个测试脚本:
1
2
3
./build/temu -l /tmp/temu.log <<< 'help
q'
grep "Quit TEMU" /tmp/temu.log # 断言日志里应该有
思考:为什么 help 的内容在 /tmp/temu.log 里出现,而不是在 terminal 里出现?(答案和 freopen(log_file, "w", stderr) 的语义有关)
Medium 2. 批量模式 -c
加一个 -c "cmd1; cmd2" 选项:不进入 REPL,直接按顺序执行分号分隔的命令。
1
2
3
4
5
6
$ ./build/temu -c 'help; echo hi'
help Show ...
q Quit ...
hi
$ echo $?
0
提示:strtok(arg, ";") 切命令,对每个 trim 前后空格后走一遍现有分派逻辑。
Hard 1. 命令历史
按上箭头能重现上一条命令。
提示:直接用 fgets 读行做不到(fgets 看到的是按回车后的整行),需要 readline 库或者手写 termios 原始模式 + escape sequence 解析。前者 5 行代码,后者 500 行。
学到:为什么 shell / gdb 都要链接 readline。
Hard 2. 自己写 getopt
不用 <getopt.h>,自己实现一个只支持短选项的版本。
1
2
3
int my_getopt(int argc, char *argv[], const char *optstring);
extern char *my_optarg;
extern int my_optind;
要能处理:
-bl foo→ 第一次返回b,第二次返回l且my_optarg = "foo"-lfoo→ 返回l,my_optarg = "foo"-l后面缺参数 → 返回?
学到:libc 里每个看起来”平平无奇”的函数背后都有一堆边界条件。
9. 本章小结
你应该能做到:
- 用一句话解释”计算机是状态机”,并能当场写 30 行 C 代码证明
- 区分 emulator / simulator / VM / JIT 四种概念
- 解释为什么 stage 0 不写 CPU
- 写一个带
-h/-b/-l的getopt参数解析器 - 用函数指针表实现可扩展的命令分派
- 写一个带
-MMD -MP自动依赖追踪的 Makefile - 说清楚
fgets+strtok的三个坑以及对应的规避写法
你应该理解:
- 为什么
printf后要fflush(stdout)(行缓冲) - 为什么
Log写 stderr 而不是 stdout(分离日志和”程序输出”) - 为什么 Makefile pattern rule 比手写每个 .o 规则好
- YAGNI 原则在系统编程里的具体体现
10. 延伸阅读
- K&R · The C Programming Language 第 1、5 章:C 入门和指针
- GNU Make Manual 第 10、11 章:pattern rules、automatic variables
man 3 getopt:每个 Unix 程序员都该读一遍- Brian Fox · The GNU Readline Library:命令历史/行编辑是怎么做的
- xv6-riscv 源码的
Makefile:一个真教学 OS 怎么组织 Make
与后续章节的连接
| 下一章想做什么 | 本章埋下的伏笔 |
|---|---|
| P1 调试器 | cmd_table 会从 2 个命令扩到 9 个(c、si、info、p、x、w、d、help、q) |
| P2 内存 | image_file 参数会真正被用来加载 raw binary |
| P3 CPU | batch_mode 会变成”加载镜像后直接跑到 EBREAK 为止” |
| P6a 特权机制 | -l 日志会成为 itrace / mtrace 调试输出的归宿 |
你现在手上这个 200 行的骨架会持续服役到 stage 6a,不需要重写。
下一站:P1——给这台还没有 CPU 的机器装上 GDB 级别的调试器。