Post

P0 · Computer is a State Machine

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 行代码扩展成:

  1. 内存从固定 4 条指令扩到 128 MB(P2)
  2. 指令集从 1 条扩到真正的 RV32I 全集(P3)
  3. I/O加上(P5)
  4. 异常和中断加上(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 == ...) { ... }

两个关键等价

  1. “状态机的一次迁移” = “CPU 的一条指令”。每执行一条指令,机器就从一个状态(所有 gpr + pc + mem 的组合)转移到下一个状态。这是停机问题等所有计算理论结论能应用到真实计算机的根本原因
  2. “纸带上的字符串” = “内存里的字节”。图灵机”纸带无限长”是理想化;真实 CPU 的内存有限,但只要内存够用,它就是图灵完备的——能计算任何可计算函数

核心洞察:CPU 不是魔法,它是一个被定义在 ISA 规范里的状态机。ISA 就是这个状态机的迁移规则表。写模拟器 = 按规范把迁移规则抄进 C 代码。没有神秘,没有”硬件魔法”——只有一张查不完的表和一个循环。

这也解释了冯·诺依曼架构那个看似奇怪的决策:”数据和指令放在同一块内存”。从图灵机视角看:纸带上既可以写”数据”也可以写”规则”——规则本身也是一串符号。这开启了”程序即数据”这条路:编译器、动态链接、JIT、病毒、自修改代码……所有这些都是这条路上的副产品。

与之对立的是哈佛架构(数据和指令分开两块内存),常见于嵌入式 / DSP 芯片——安全性和并行性更好,但丢掉了”程序即数据”的灵活性。你的手机里的 Cortex 核心实际是改良的冯·诺依曼 + 分离 L1 cache 的折衷。


3. 术语澄清:模拟器到底是什么

写之前先厘清几个经常混用的词:

术语定义例子
Emulator(模拟器)在 host 上用软件模拟 guest 架构的指令级行为,不要求时序精确QEMU、NEMU、我们的 TEMU
Simulator(仿真器)通常更强调时序/周期精确,常用于硬件验证Verilator、gem5
Virtual MachineGuest 指令直接在 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

TEMU 落在左下角(指令级 + 解释)是刻意的教学选择

  1. 每一步都是 observable 的 C 代码——你能单步调试任何一条指令的执行过程
  2. 代码量可控——全部代码一两千行,一周能读完、一个月能复现
  3. 可改性——想加个自定义指令?加个 INSTPAT 就行,不用管编译器

代价:慢得离谱。TEMU 跑 guest 每秒大概几十万条指令,QEMU 几亿条,真机几十亿条。但教学时慢正是优点——快意味着跳过了可见性。

工程原则:工具的选择由问题决定,不是由性能决定。”读懂 CPU 怎么工作” 和 “跑 Linux 启动” 是不同的问题,应该选不同的工具。


4. 为什么第一步不是写 CPU,而是写个空壳

直觉会告诉你:”计算机最重要的是 CPU,先把 CPU 写了呀。”

这是初学者的第一个大坑。原因:

  • CPU 没法单独测试——它要从内存取指,内存没搭好测不了
  • 就算能跑,你也不知道”跑对了没有”——没有调试器看不了寄存器
  • 写出来 bug 一堆,你打印 printf 调试到怀疑人生

正确的顺序是倒过来:先搭一个能”控制”未来 CPU 的外壳,再填 CPU。这个外壳就是 REPL(Read-Eval-Print Loop),类比:

工具它的外壳是外壳先于
Pythonpython 交互 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 的几个”奇怪”特性的来源:

  1. 头文件 include guard#ifndef X / #define X / #endif

    如果 a.hb.h 都被 foo.c include,且 a.h 内部也 include 了 b.h——没有 guard 的话 b.h 会被展开两次,导致同一个 struct 被定义两次。guard 让重复 include 变成 no-op。

  2. 为什么 header 里放 extern 声明,.c 里放定义

    多个翻译单元都可能 include 同一个 header。如果 header 里放定义(比如 int g_count = 0;),每个 translation unit 都产生一份 g_count,链接时冲突。放 extern int g_count;(仅声明,不分配),只在某一个 .c 里真正定义,链接时大家共享同一份。

  3. 为什么需要 Makefile 做依赖追踪

    假如你改了 common.h。哪些 .c 需要重编?C 不像 Rust 有 mod 系统告诉编译器依赖图——依赖藏在每个文件的 #include。Makefile 用 -MMD 让编译器顺便吐出依赖文件 foo.d(内容形如 foo.o: foo.c common.h),下次改 common.h,Make 自动知道该 rebuild 哪些 .o

  4. 为什么我们用 typedef uint32_t word_t

    uint32_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

设计要点

  1. #include 警卫#ifndef / #define / #endif,避免同一份头文件被 include 两次导致重复定义
  2. ##__VA_ARGS__ 是 GCC/Clang 扩展:当可变参数为空时把前面的逗号也吃掉。写纯标准 C11 的话要换成 __VA_OPT__(,) __VA_ARGS__(C23 特性),工程里能用 GNU 扩展就用
  3. 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_fileoptind < 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 返回 NULLline 内容未定义,后面 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);
    }
}

现在加一个命令要改什么?

  1. 写个 static int cmd_foo(char *args) { ... }
  2. 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 几乎没有”功能”——它能做的就是启动、响应 helpq。怎么测?

最小端到端测试:用 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。编辑器要把 .mkMakefile 文件的”自动转 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,第二次返回 lmy_optarg = "foo"
  • -lfoo → 返回 lmy_optarg = "foo"
  • -l 后面缺参数 → 返回 ?

学到:libc 里每个看起来”平平无奇”的函数背后都有一堆边界条件。


9. 本章小结

你应该能做到:

  • 用一句话解释”计算机是状态机”,并能当场写 30 行 C 代码证明
  • 区分 emulator / simulator / VM / JIT 四种概念
  • 解释为什么 stage 0 不写 CPU
  • 写一个带 -h/-b/-lgetopt 参数解析器
  • 函数指针表实现可扩展的命令分派
  • 写一个带 -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 个(csiinfopxwdhelpq
P2 内存image_file 参数会真正被用来加载 raw binary
P3 CPUbatch_mode 会变成”加载镜像后直接跑到 EBREAK 为止”
P6a 特权机制-l 日志会成为 itrace / mtrace 调试输出的归宿

你现在手上这个 200 行的骨架会持续服役到 stage 6a,不需要重写。

下一站:P1——给这台还没有 CPU 的机器装上 GDB 级别的调试器。

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