从“程序是什么”到 NEMU:用代码读懂 PA1《开天辟地的篇章》

本文基于 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 的连续状态转移

关键词

riscv32NEMUAbstractMachinePAysyx计算机系统模拟器状态机


一、讲义的核心:程序如何在计算机上运行

讲义先提出一个问题:

为了执行哪怕最简单的程序,最简单的计算机应该长什么样?

答案是:

复制代码
存储器 + 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 中,指令处理分为两步:

  1. 取指令;
  2. 译码并执行。

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 正在模拟的"计算机世界"。

相关推荐
Wireless_Link1 天前
一颗芯片如何从想法变成可出货的硅片,全流程解析
芯片设计·芯片生产·芯片封测
gwf2161 天前
AI训练RDMA性能Profiling:瓶颈定位与调优方法论
芯片设计·rdma·nccl·拥塞控制·ai集群·rnic·gpudirect
gwf2169 天前
800G RNIC芯片设计挑战:PCIe Gen6与DMA引擎架构 —— 面向AI超大规模集群的硬件实现深度解析
芯片设计·rdma·rnic·gpudirect·pciegen6·dma引擎·mrc协议
gwf21611 天前
AI多租户集群的RDMA隔离:PD/MR/ACL硬件实现 —— 面向大模型训练/推理的硅级安全架构
芯片设计·rdma·nccl·ai集群·gpudirect·多租户隔离·sr-iov
xyz_CDragon1 个月前
从 RTL 到 GDSII:人与 AI 协作,用开源 EDA 工具链完成 SHA-256 芯片的 SoC 集成与签核全流程(附完整流程+代码)
人工智能·开源·芯片设计·开源 eda·caravel
search72 个月前
芯片设计中的ECO
芯片设计
xyz_CDragon3 个月前
OpenAI发布首款自研芯片Jalapeño:9个月流片,AI设计芯片的时代来了
人工智能·单片机·深度学习·神经网络·芯片设计
泛联新安4 个月前
VHawk-Lint——军工FPGA/ASIC设计质量自主可控的基石
fpga·芯片设计·eda
硅农深芯4 个月前
芯片设计后端工作流程详解
后端·芯片设计