本文基于
ysyx-workbench/nemu的实际代码,梳理 PA1《开天辟地的篇章》中的核心思想:计算机由存储器、CPU、寄存器、PC 和指令组成;程序运行的本质,是"取指、执行、更新 PC"的不断循环,也是一个宏大的状态机。
摘要
PA1《开天辟地的篇章》从一个最简单的计算机模型出发,介绍了:
- 存储器;
- CPU;
- 运算器;
- 寄存器;
- 指令;
- 程序计数器 PC;
- 取指、执行、更新 PC 的循环;
- 程序的状态机视角。
NEMU 正是这个模型的工程化实现。本文将会把这些概念逐一对应用户代码中的:
nemu/src/memory/paddr.c
nemu/src/cpu/cpu-exec.c
nemu/src/isa/riscv32/init.c
nemu/src/isa/riscv32/inst.c
nemu/src/isa/riscv32/include/isa-def.h
nemu/src/monitor/sdb/sdb.c
最终你会看到:
存储器 -> pmem
CPU -> CPU_state cpu
寄存器 -> cpu.gpr[32]
PC -> cpu.pc
指令 -> inst_fetch() + decode_exec()
运算器 -> inst.c 中各指令的 C 表达式
执行循环 -> exec_once() + execute()
状态机 -> cpu + pmem + PC 的连续状态转移
关键词
riscv32、NEMU、AbstractMachine、PA、ysyx、计算机系统、模拟器、状态机
一、讲义的核心:程序如何在计算机上运行
讲义先提出一个问题:
为了执行哪怕最简单的程序,最简单的计算机应该长什么样?
答案是:
存储器 + CPU + 寄存器 + PC + 指令
计算机只需要反复做一件事:
while (1) {
从 PC 指示的存储器位置取出指令;
执行指令;
更新 PC;
}
NEMU 中对应的代码就是:
static void exec_once(Decode *s, vaddr_t pc) {
s->pc = pc;
s->snpc = pc;
isa_exec_once(s);
cpu.pc = s->dnpc;
}
这四行代码基本就是讲义核心思想的直接翻译。
二、存储器:对应 NEMU 中的 pmem
讲义说:
为了执行程序,首先要解决程序放在哪里的问题。
这个能够放下程序的部件就是存储器。
在 NEMU 中,存储器位于:
nemu/src/memory/paddr.c
核心定义:
#if defined(CONFIG_PMEM_MALLOC)
static uint8_t *pmem = NULL;
#else
static uint8_t pmem[CONFIG_MSIZE] PG_ALIGN = {};
#endif
当前配置:
CONFIG_MBASE=0x80000000
CONFIG_MSIZE=0x8000000
因此 NEMU 模拟的客户机物理内存是:
[0x80000000, 0x87ffffff]
也就是 128 MiB。
客户机地址到主机数组的转换:
uint8_t* guest_to_host(paddr_t paddr) {
return pmem + paddr - CONFIG_MBASE;
}
例如:
客户机地址 0x80000000 -> pmem[0]
客户机地址 0x80000004 -> pmem[4]
AM 程序最终生成的 .bin 会被加载到:
fread(guest_to_host(RESET_VECTOR), size, 1, fp);
也就是从:
0x80000000
开始放入存储器。
因此:
讲义中的"存储器" = NEMU 中的 pmem
三、CPU:对应 NEMU 中的 CPU_state cpu
讲义说:
CPU 是负责处理数据的核心电路单元,程序的执行全靠它。
NEMU 中的 CPU 状态定义在:
nemu/src/isa/riscv32/include/isa-def.h
代码:
typedef struct {
word_t gpr[32];
vaddr_t pc;
word_t mstatus;
word_t mtvec;
word_t mepc;
word_t mcause;
} riscv32_CPU_state;
在 NEMU 中有一个全局 CPU:
CPU_state cpu = {};
这个 cpu 可以理解成:
当前这台虚拟 riscv32 计算机的全部 CPU 状态
其中包括:
gpr[32] 32 个通用寄存器
pc 程序计数器
mstatus CPU 状态控制寄存器
mtvec trap 入口地址
mepc 异常返回地址
mcause 异常原因
也就是说:
讲义中的"CPU" = NEMU 中的 CPU_state cpu
四、寄存器:对应 cpu.gpr[32]
讲义说:
CPU 把正在处理的数据暂时放在寄存器中。
NEMU 中:
nemu/src/isa/riscv32/local-include/reg.h
定义:
#define gpr(idx) (cpu.gpr[check_reg_idx(idx)])
在 inst.c 中进一步定义:
#define R(i) gpr(i)
于是:
R(1)
表示访问寄存器 x1;
R(10)
表示访问寄存器 a0。
RISC-V 的 32 个通用寄存器包括:
x0 = zero
x1 = ra
x2 = sp
x3 = gp
x4 = tp
x5 = t0
...
x10 = a0
...
x31 = t6
其中 x0 必须恒为 0,所以 inst.c 最后有:
R(0) = 0;
对应关系是:
讲义中的"寄存器" = cpu.gpr[32]
五、PC:对应 cpu.pc
讲义说:
CPU 要知道当前执行到哪一条指令,因此需要程序计数器 PC。
NEMU 中:
cpu.pc
就是 PC。
初始化时:
static void restart() {
cpu.pc = RESET_VECTOR;
cpu.gpr[0] = 0;
}
void init_isa() {
memcpy(guest_to_host(RESET_VECTOR), img, sizeof(img));
restart();
}
因此一开始:
cpu.pc = 0x80000000
随后 CPU 会:
从 cpu.pc 取指令
执行指令
更新 cpu.pc
对应关系是:
讲义中的"PC" = cpu.pc
六、指令:从 inst_fetch() 到 decode_exec()
讲义说:
指令用来告诉 CPU 对数据进行什么处理。
在 NEMU 中,指令处理分为两步:
- 取指令;
- 译码并执行。
6.1 取指令
代码:
int isa_exec_once(Decode *s) {
s->isa.inst = inst_fetch(&s->snpc, 4);
return decode_exec(s);
}
inst_fetch() 从 PC 指向的存储器位置读取 4 字节:
cpu.pc = 0x80000000
-> 读取 0x80000000 ~ 0x80000003
-> 得到一条 32 位 riscv32 指令
例如 ebreak 的机器码是:
0x00100073
它在内存中的字节顺序是:
73 00 10 00
6.2 译码
译码核心在:
nemu/include/cpu/decode.h
代码:
#define INSTPAT(pattern, ...) do { \
uint64_t key, mask, shift; \
pattern_decode(pattern, STRLEN(pattern), &key, &mask, &shift); \
if ((((uint64_t)INSTPAT_INST(s) >> shift) & mask) == key) { \
INSTPAT_MATCH(s, ##__VA_ARGS__); \
goto *(__instpat_end); \
} \
} while (0)
它的思想是:
指令二进制位
& mask
== key
则匹配成功。
例如 addi 的模式:
INSTPAT("??????? ????? ????? 000 ????? 00100 11",
addi, I, R(rd) = src1 + imm);
例如 add:
INSTPAT("0000000 ????? ????? 000 ????? 01100 11",
add, R, R(rd) = src1 + src2);
例如 sub:
INSTPAT("0100000 ????? ????? 000 ????? 01100 11",
sub, R, R(rd) = src1 - src2);
所以:
讲义中的"指令" = inst_fetch() + decode_exec() + INSTPAT
七、运算器:对应 inst.c 中的 C 表达式
讲义说:
CPU 需要运算器,才能对数据进行处理。
NEMU 是软件模拟器,没有独立的硬件 ALU。
每条指令的运算逻辑直接写在 inst.c 中。
例如:
加法
R(rd) = src1 + src2;
立即数加法
R(rd) = src1 + imm;
减法
R(rd) = src1 - src2;
逻辑与
R(rd) = src1 & src2;
逻辑左移
R(rd) = src1 << (src2 & 0x1f);
所以:
讲义中的"运算器" = inst.c 中各指令的 C 表达式
八、取指、执行、更新 PC:exec_once()
讲义中的循环:
while (1) {
从 PC 指示的存储器位置取出指令;
执行指令;
更新 PC;
}
在 NEMU 中对应:
static void exec_once(Decode *s, vaddr_t pc) {
s->pc = pc;
s->snpc = pc;
isa_exec_once(s);
cpu.pc = s->dnpc;
}
逐行解释:
| 代码 | 含义 |
|---|---|
s->pc = pc; |
记录当前指令地址 |
s->snpc = pc; |
记录顺序下一条指令地址 |
isa_exec_once(s); |
取指令并执行 |
cpu.pc = s->dnpc; |
更新 PC |
其中 s->dnpc 是:
dynamic next PC
下一条动态执行的指令地址
普通指令:
s->dnpc = s->pc + 4
分支指令:
s->dnpc = 分支目标地址
因此:
讲义中的"取指 -> 执行 -> 更新 PC" = exec_once()
九、程序是状态机:execute() 循环
讲义认为:
程序运行就是计算机状态不断发生确定性的转移。
NEMU 中:
static void execute(uint64_t n) {
Decode s;
for (; n > 0; n--) {
if (nemu_state.state != NEMU_RUNNING) {
break;
}
exec_once(&s, cpu.pc);
g_nr_guest_inst++;
trace_and_difftest(&s, cpu.pc);
IFDEF(CONFIG_DEVICE, device_update());
}
}
一次 exec_once() 就是一次状态转移:
旧状态:
cpu.pc
cpu.gpr[32]
pmem
执行一条指令
新状态:
cpu.pc
cpu.gpr[32]
pmem
整个程序就是:
S0 -> S1 -> S2 -> S3 -> ... -> Sn
其中每个状态 Si 都由:
(寄存器, PC, 内存)
组成。
所以:
讲义中的"程序是状态机" = execute() + CPU + PMEM
十、1+2+...+100 例子在 riscv32 中如何对应
讲义给出:
mov r1, 0
mov r2, 0
addi r2, r2, 1
add r1, r1, r2
blt r2, 100, 2
jmp 5
在 riscv32 中可以写成:
li t0, 0
li t1, 0
loop:
addi t1, t1, 1
add t0, t0, t1
blt t1, 100, loop
j .
对应你的 inst.c:
li
INSTPAT("??????? ????? ????? 000 ????? 00100 11",
addi, I, R(rd) = src1 + imm);
add
INSTPAT("0000000 ????? ????? 000 ????? 01100 11",
add, R, R(rd) = src1 + src2);
blt
INSTPAT("??????? ????? ????? 100 ????? 11000 11",
blt, B,
if (!(src1 < src2)) s->dnpc = s->pc + 4);
j
通常由 jal 实现:
INSTPAT("??????? ????? ????? ??? ????? 11011 11",
jal, J, R(rd) = s->pc + 4);
执行过程中不断修改:
cpu.gpr[t0]
cpu.gpr[t1]
cpu.pc
这正是讲义中所说的:
程序通过指令不断改变计算机状态
十一、用 SDB 观察状态机
你的 SDB 支持:
si
info r
x
p
w
b
它们和讲义中的状态机视角完全对应。
si:单步执行
执行一条指令,观察一次状态转移。
info r:查看寄存器
void isa_reg_display() {
int i;
for (i = 0; i < 32; i++) {
printf("%s\t0x%08x\t%u\n",
regs[i], cpu.gpr[i], cpu.gpr[i]);
}
printf("pc\t0x%08x\t%u\n", cpu.pc, cpu.pc);
}
x:查看内存
word_t data = vaddr_read(addr + i * 4, 4);
printf("0x%08x: 0x%08x\n", addr + i * 4, data);
p:计算表达式
word_t res = expr(args, &succ);
if (succ) {
printf("%s = 0x%08x (%u)\n", args, res, res);
}
b:设置断点
word_t addr = expr(args, &succ);
if (succ) {
new_bp(addr);
}
因此你可以用 SDB 观察:
当前 PC
当前寄存器
当前内存
当前表达式
这就是讲义所说的:
从状态机视角理解程序行为
十二、概念对照总表
| 讲义概念 | NEMU 代码 |
|---|---|
| 存储器 | pmem in paddr.c |
| 物理内存起点 | CONFIG_MBASE=0x80000000 |
| CPU | CPU_state cpu |
| 寄存器 | cpu.gpr[32] |
| PC | cpu.pc |
| 取指令 | inst_fetch() |
| 译码 | decode_exec() + INSTPAT |
| 运算器 | inst.c 中的 C 表达式 |
| 执行一条指令 | exec_once() |
| 更新 PC | cpu.pc = s->dnpc |
| 取指执行循环 | execute() |
| 状态机 | CPU + PMEM + PC 的连续状态 |
| 调试观察 | SDB:si/info r/x/p/w/b |
十三、结合你的代码,两个需要特别注意的点
1. g_print_step 不要强制打开
你当前代码里有:
g_print_step = true;
这会让 c 和批处理模式都逐条打印指令,破坏 SDB 和批处理的预期行为。
更合理的写法是:
g_print_step = (n < MAX_INST_TO_PRINT);
这样:
si 3打印 3 条;c不逐条打印;-b批处理模式不会输出大量日志。
2. ebreak 要和 nemu_trap 配套
AM 的 halt():
void halt(int code) {
nemu_trap(code);
while (1);
}
nemu_trap() 在 riscv32 中展开为:
mv a0, code
ebreak
NEMU 必须在 inst.c 中正确识别 ebreak,并让 nemu_state.state 变成:
NEMU_END
否则程序无法正常结束。
十四、总结
《开天辟地的篇章》讲的是:
最简计算机 = 存储器 + CPU + 寄存器 + PC + 指令
你的 NEMU 代码实现的是:
pmem
= 存储器
cpu.gpr[32]
= 寄存器
cpu.pc
= PC
inst_fetch()
= 从存储器取指令
decode_exec() + INSTPAT
= 译码并执行
R(rd) = ...
= 运算器修改寄存器
cpu.pc = s->dnpc
= 更新 PC
execute()
= 取指、执行、更新 PC 的无限循环
SDB
= 观察状态机的工具
最终,程序运行的本质就是:
初始状态
-> 取指
-> 执行
-> 更新 PC
-> 新状态
-> 取指
-> 执行
-> 更新 PC
-> ...
这就是从静态代码到动态状态机的过程,也是 NEMU 正在模拟的"计算机世界"。
