D2: 程序的机器级表示
一、阶段目标
D2 阶段的核心目标是理解 C 程序如何被编译、链接并加载到裸金属环境中执行。完成后你将:
- 掌握从 C 源码 → 汇编 → 机器码 → 内存镜像的完整流程
- 理解 RISC-V 的 ABI (调用约定、数据类型、对齐)
- 理解链接脚本如何定义内存布局
- 理解裸金属程序的启动过程 (crt0/start.S)
- 理解 ELF 格式和 objcopy 二进制转换
- 掌握最小 C 运行时库 (klib) 的实现
二、编译工具链
2.1 问题:C 程序如何变成 RISC-V 机器码?
完整流程:
hello.c hello.bin
│ ▲
│ riscv64-linux-gnu-gcc │ objcopy
▼ │
hello.o ──── riscv64-linux-gnu-ld ────► hello.elf ──────┘
(目标文件) (链接器) (可执行文件) (纯二进制)
▲
│
linker.ld (链接脚本)
am.a + klib.a (库文件)
2.2 交叉编译器
项目使用 riscv64-linux-gnu-gcc 作为交叉编译器(虽然名字里有 64,但可以通过 -march 指定生成 32 位代码)。
# abstract-machine/scripts/isa/riscv.mk
CROSS_COMPILE := riscv64-linux-gnu-
COMMON_CFLAGS := -fno-pic -march=rv64g -mcmodel=medany -mstrict-align
但在具体的目标平台中会覆盖这些默认值:
# abstract-machine/scripts/riscv32e-npc.mk
COMMON_CFLAGS += -march=rv32e_zicsr -mabi=ilp32e # 生成 RV32E 代码
LDFLAGS += -melf32lriscv # 32位小端 ELF
2.3 关键编译选项
# abstract-machine/Makefile
CFLAGS += -O2 # 优化等级
-MMD # 生成依赖文件 (.d)
-Wall -Werror # 全部警告 + 警告当错误
-fno-asynchronous-unwind-tables # 不生成异常展开表
-fno-builtin # 禁止编译器内置函数替换
-fno-stack-protector # 禁止栈保护 (裸金属无 OS 支持)
-Wno-main # 允许 main 返回值非标准
-U_FORTIFY_SOURCE # 禁用安全检查宏
知识点:
| 选项 | 含义 | 为什么需要 |
|---|---|---|
-march=rv32e_zicsr |
目标 ISA: RV32E + Zicsr 扩展 | 只用 16 个寄存器 + CSR 指令 |
-mabi=ilp32e |
ABI: int/long/pointer 都是 32 位, 嵌入式 | 调用约定 |
-fno-pic |
不生成位置无关代码 | 裸金属固定地址执行 |
-fno-builtin |
禁止替换 memcpy/printf 为内联 | 我们要用自己的 klib 实现 |
-fno-stack-protector |
不插入栈金丝雀 | 裸金属没有 __stack_chk_fail |
-static |
静态链接 | 裸金属没有动态链接器 |
三、链接脚本 (Linker Script)
3.1 问题:程序各段 (.text, .data, .bss) 如何在内存中布局?
解决方案:链接脚本 linker.ld 精确定义每个段的起始地址和排列顺序。
/* abstract-machine/scripts/linker.ld */
ENTRY(_start) /* 入口符号 */
PHDRS { text PT_LOAD; data PT_LOAD; } /* ELF Program Header */
SECTIONS {
/* _pmem_start = 0x80000000 (由 LDFLAGS --defsym 传入) */
/* _entry_offset = 0x0 */
. = _pmem_start + _entry_offset; /* 起始地址 = 0x80000000 */
.text : {
*(entry) /* 最先放置 entry 段 (start.S) */
*(.text*) /* 所有代码段 */
} : text
etext = .;
_etext = .;
.rodata : {
*(.rodata*) /* 只读数据 (字符串常量等) */
}
.data : {
*(.data) /* 已初始化的全局变量 */
} : data
edata = .;
_data = .;
.bss : {
_bss_start = .;
*(.bss*) /* 未初始化的全局变量 (清零) */
*(.sbss*)
*(.scommon)
}
_stack_top = ALIGN(0x1000); /* 栈顶 (4KB 对齐) */
. = _stack_top + 0x8000; /* 栈大小 = 32KB */
_stack_pointer = .; /* 栈指针初始值 */
end = .;
_end = .;
_heap_start = ALIGN(0x1000); /* 堆起始 (4KB 对齐) */
}
3.2 内存布局图
地址空间 (从低到高):
0x80000000 ┌─────────────────┐ ← _pmem_start, _start (PC 初始值)
│ .text (代码段) │ entry 段最先放置
│ start.S │ 然后是所有 .text
│ main() │
│ ... │
├─────────────────┤ ← _etext
│ .rodata (只读) │ 字符串常量, const 数组
├─────────────────┤
│ .data (数据段) │ 已初始化全局变量
├─────────────────┤ ← _bss_start
│ .bss (BSS段) │ 未初始化全局变量 (全零)
├─────────────────┤ ← _stack_top (4KB 对齐)
│ │
│ 栈 (32KB) │ 向高地址增长
│ │
├─────────────────┤ ← _stack_pointer (SP 初始值)
│ │
│ 堆 (heap) │ malloc 分配区域
│ 向高地址增长 │
│ │
└─────────────────┘ ← PMEM_END (0x80000000 + 128MB)
3.3 LDFLAGS 中的关键定义
# abstract-machine/scripts/platform/npc.mk
LDFLAGS += -T $(AM_HOME)/scripts/linker.ld # 使用链接脚本
LDFLAGS += --defsym=_pmem_start=0x80000000 # 内存起始地址
LDFLAGS += --defsym=_entry_offset=0x0 # 入口偏移
LDFLAGS += --gc-sections # 移除未使用的段
LDFLAGS += -e _start # 入口点符号
四、程序启动过程 (start.S)
4.1 问题:CPU 复位后,第一条执行的指令是什么?做了什么?
解决方案:start.S 是程序的真正入口,负责最基本的初始化后跳转到 C 代码。
# abstract-machine/am/src/riscv/npc/start.S
.section entry, "ax" # 放入名为 "entry" 的段 (链接脚本确保它排在最前)
.globl _start # 全局符号,链接器的入口点
.type _start, @function
_start:
mv s0, zero # 清除帧指针 (调试用)
la sp, _stack_pointer # 加载栈指针 (从链接脚本获得地址)
jal _trm_init # 跳转到 C 语言的初始化函数
仅三条指令! 因为裸金属环境中:
- 没有操作系统需要设置
- 不需要初始化 C 运行时 (没有 libc 的 crt0)
- 只需要:① 清除帧指针 ② 设置栈指针 ③ 跳转到 C
4.2 _trm_init() — C 语言的初始化入口
// abstract-machine/am/src/riscv/npc/trm.c
extern char _heap_start;
int main(const char *args);
Area heap = RANGE(&_heap_start, PMEM_END); // 定义堆区域
void _trm_init() {
int ret = main(mainargs); // 调用用户程序的 main()
halt(ret); // main 返回后,停机
}
4.3 程序结束 — halt() 的实现
// NPC 平台
#define npc_ebreak(code) asm volatile("mv a0, %0; ebreak" : :"r"(code))
void halt(int code) {
npc_ebreak(code); // 将退出码放入 a0,触发 ebreak
while (1); // 理论上不应到达这里
}
// NEMU 平台
#define nemu_trap(code) asm volatile("mv a0, %0; ebreak" : :"r"(code))
void halt(int code) {
nemu_trap(code);
while (1);
}
核心机制:
mv a0, %0— 将返回码放入a0寄存器ebreak— 触发断点异常- NEMU/NPC 拦截
ebreak,检查a0的值:0 = 正常退出, 非 0 = 错误
4.4 完整启动链路
CPU 复位
│ PC = 0x80000000
▼
_start (start.S)
│ mv s0, zero
│ la sp, _stack_pointer
│ jal _trm_init
▼
_trm_init() (trm.c)
│ int ret = main(mainargs);
▼
main() (用户程序, e.g. add.c)
│ ... 执行用户逻辑 ...
│ return 0;
▼
halt(0) (trm.c)
│ mv a0, 0
│ ebreak
▼
NEMU/NPC 拦截 ebreak
│ a0 == 0 → HIT GOOD TRAP
│ a0 != 0 → HIT BAD TRAP
▼
模拟器停止,报告结果
五、ELF 到二进制镜像的转换
5.1 问题:为什么不能直接将 ELF 文件加载到模拟器?
ELF (Executable and Linkable Format) 文件包含大量元数据(段表、符号表、重定位信息),而裸金属环境只需要纯粹的机器码和数据。
5.2 转换过程
# abstract-machine/scripts/platform/npc.mk
image: $(IMAGE).elf
@$(OBJDUMP) -d $(IMAGE).elf > $(IMAGE).txt # 反汇编 (调试用)
@$(OBJCOPY) -S --set-section-flags .bss=alloc,contents \
-O binary $(IMAGE).elf $(IMAGE).bin # ELF → 纯二进制
| 步骤 | 工具 | 输入 | 输出 | 作用 |
|---|---|---|---|---|
| 编译 | gcc | .c / .S | .o | 源码→目标文件 |
| 链接 | ld | .o + .a | .elf | 目标文件→可执行文件 |
| 反汇编 | objdump -d | .elf | .txt | 生成可读的汇编 (调试) |
| 转换 | objcopy -O binary | .elf | .bin | 剥离元数据,只留代码+数据 |
5.3 objcopy 参数解释
-S— 去除所有符号和重定位信息--set-section-flags .bss=alloc,contents— 将 BSS 段包含在输出中 (初始化为 0)-O binary— 输出格式为原始二进制
5.4 NEMU 如何加载镜像
// nemu/src/monitor/monitor.c
static long load_img() {
FILE *fp = fopen(img_file, "rb");
fseek(fp, 0, SEEK_END);
long size = ftell(fp); // 获取文件大小
fseek(fp, 0, SEEK_SET);
fread(guest_to_host(RESET_VECTOR), size, 1, fp); // 直接读入内存
fclose(fp);
return size;
}
RESET_VECTOR = 0x80000000,即 .bin 文件的第一个字节对应内存地址 0x80000000,正好是 _start 的位置。
六、RISC-V ABI (调用约定)
6.1 问题:函数调用时,参数怎么传?返回值放哪?谁负责保存寄存器?
6.2 寄存器约定 (ilp32e ABI)
| 寄存器 | ABI 名 | 用途 | 调用者/被调用者保存 |
|---|---|---|---|
| x0 | zero | 常量 0 | — |
| x1 | ra | 返回地址 | 调用者 |
| x2 | sp | 栈指针 | 被调用者 |
| x3 | gp | 全局指针 | — |
| x4 | tp | 线程指针 | — |
| x5-x7 | t0-t2 | 临时寄存器 | 调用者 |
| x8 | s0/fp | 保存寄存器/帧指针 | 被调用者 |
| x9 | s1 | 保存寄存器 | 被调用者 |
| x10-x11 | a0-a1 | 参数/返回值 | 调用者 |
| x12-x17 | a2-a7 | 参数 | 调用者 |
| x18-x27 | s2-s11 | 保存寄存器 | 被调用者 |
| x28-x31 | t3-t6 | 临时寄存器 | 调用者 |
RV32E 注意:只有 x0-x15 (16 个寄存器),因此 a2-a5、s2-s11、t3-t6 不可用!
6.3 函数调用示例
int add(int a, int b) { return a + b; }
int main() { return add(3, 4); }
编译为 RISC-V 汇编:
main:
addi sp, sp, -16 # 分配栈帧
sw ra, 12(sp) # 保存返回地址
li a0, 3 # 第一个参数 → a0
li a1, 4 # 第二个参数 → a1
jal ra, add # 调用 add, 返回地址存入 ra
lw ra, 12(sp) # 恢复返回地址
addi sp, sp, 16 # 释放栈帧
ret # jalr x0, ra, 0
add:
add a0, a0, a1 # a0 = a + b (返回值在 a0)
ret
6.4 栈帧结构
高地址
┌──────────────┐
│ 上一帧的数据 │
├──────────────┤ ← 调用前的 sp
│ 保存的 ra │ sp + 12
│ 保存的 s0 │ sp + 8
│ 局部变量 1 │ sp + 4
│ 局部变量 0 │ sp + 0
├──────────────┤ ← 当前 sp
低地址
七、最小 C 运行时库 (klib)
7.1 问题:裸金属环境没有 libc,printf/memcpy 从哪来?
解决方案:Abstract-Machine 提供 klib (Kernel Library),手动实现必需的 C 标准库函数子集。
7.2 string.c — 字符串/内存操作
// abstract-machine/klib/src/string.c
size_t strlen(const char *s) {
size_t len = 0;
while (*s++) len++;
return len;
}
void *memcpy(void *out, const void *in, size_t n) {
for (int i = 0; i < n; i++)
*((char *)out + i) = *((char *)in + i);
return out;
}
void *memset(void *s, int c, size_t n) {
char *tmp = s;
while (n--) *tmp++ = c;
return s;
}
int strcmp(const char *s1, const char *s2) {
while (*s1) {
if (*s1 != *s2) break;
s1++; s2++;
}
return *(const unsigned char *)s1 - *(const unsigned char *)s2;
}
7.3 stdlib.c — 堆管理
// abstract-machine/klib/src/stdlib.c
static void* addr = NULL;
void* malloc(size_t size) {
if (addr == NULL) addr = heap.start; // 第一次调用时初始化
void* ret = addr;
addr += size; // 简单的 bump allocator
return ret;
}
void free(void* ptr) {
// 不回收!裸金属环境下最简单的实现
}
知识点:
heap是在trm.c中定义的全局变量:Area heap = RANGE(&_heap_start, PMEM_END);_heap_start来自链接脚本中定义的符号- 这是一个只增不减的"bumper allocator"—— 永远不释放内存
7.4 stdio.c — printf 实现
klib 中实现了一个完整的 printf/sprintf/snprintf,支持 %d, %x, %s, %c, %u, %p 等格式化输出。
putch() 是底层的字符输出函数:
// NPC 平台
void putch(char ch) {
outb(SERIAL_PORT, ch); // 写入 MMIO 串口地址
}
outb 实际上是一个内存写操作:
// abstract-machine/am/src/riscv/riscv.h
static inline void outb(uintptr_t addr, uint8_t data) {
*(volatile uint8_t *)addr = data;
}
八、测试程序结构 (cpu-tests)
8.1 问题:如何验证编译出来的程序能正确执行?
8.2 测试框架 (trap.h)
// am-kernels/tests/cpu-tests/include/trap.h
#include <am.h>
#include <klib.h>
__attribute__((noinline))
void check(bool cond) {
if (!cond) halt(1); // 条件不满足 → BAD TRAP
}
8.3 典型测试 (add.c)
// am-kernels/tests/cpu-tests/tests/add.c
#include "trap.h"
int add(int a, int b) { return a + b; }
int test_data[] = {0, 1, 2, 0x7fffffff, 0x80000000, ...};
int ans[] = {0, 0x1, 0x2, ...}; // 预计算的正确答案
int main() {
int i, j, ans_idx = 0;
for (i = 0; i < NR_DATA; i++) {
for (j = 0; j < NR_DATA; j++) {
check(add(test_data[i], test_data[j]) == ans[ans_idx++]);
}
}
return 0; // 所有测试通过 → GOOD TRAP
}
8.4 最简测试 (dummy.c)
// am-kernels/tests/cpu-tests/tests/dummy.c
int main() {
return 0; // 什么都不做,只测试启动链路是否正常
}
8.5 构建和运行
# am-kernels/tests/cpu-tests/Makefile
ALL = $(basename $(notdir $(shell find tests/. -name "*.c")))
Makefile.%: tests/%.c latest
@echo "NAME = $*\nSRCS = $<\ninclude $${AM_HOME}/Makefile" > $@
@make -s -f $@ ARCH=$(ARCH) $(MAKECMDGOALS)
运行命令:
# 运行所有 CPU 测试
make ARCH=riscv32e-npc run
# 运行单个测试
make ARCH=riscv32e-npc ALL=add run
每个测试的构建过程:
tests/add.c → add.o
↓ (链接 am.a + klib.a)
add-riscv32e-npc.elf
↓ (objcopy)
add-riscv32e-npc.bin
↓ (加载到 NPC/NEMU)
执行 → GOOD TRAP / BAD TRAP
九、I/O 端口访问 (MMIO)
9.1 问题:裸金属程序如何进行输入输出?
在没有操作系统的情况下,I/O 通过内存映射 (Memory-Mapped I/O) 实现:特定的物理地址对应硬件寄存器。
9.2 设备地址定义
// abstract-machine/am/src/riscv/npc/include/npc.h
#define DEVICE_BASE 0xa0000000
#define SERIAL_PORT (DEVICE_BASE + 0x0000000) // 串口
#define KBD_ADDR (DEVICE_BASE + 0x0001000) // 键盘
#define RTC_ADDR (DEVICE_BASE + 0x0002000) // 实时时钟
#define VGACTL_ADDR (DEVICE_BASE + 0x0003000) // 显示控制
9.3 I/O 操作函数
// abstract-machine/am/src/riscv/riscv.h
static inline void outb(uintptr_t addr, uint8_t data) {
*(volatile uint8_t *)addr = data; // 写一个字节到设备寄存器
}
static inline uint8_t inb(uintptr_t addr) {
return *(volatile uint8_t *)addr; // 从设备寄存器读一个字节
}
static inline uint32_t inl(uintptr_t addr) {
return *(volatile uint32_t *)addr; // 读 4 字节
}
volatile 的重要性:告诉编译器不要优化这些访问 —— 每次读写都必须实际执行,因为硬件寄存器的值可能随时改变。
十、GCC 内联汇编
10.1 问题:C 语言如何嵌入特定的机器指令?
halt() 中使用了 GCC 内联汇编 (inline assembly):
#define npc_ebreak(code) asm volatile("mv a0, %0; ebreak" : :"r"(code))
语法解析:
asm volatile(
"mv a0, %0; ebreak" // 汇编模板: %0 是第一个操作数的占位符
: // 输出操作数列表 (空)
: "r"(code) // 输入操作数: code 放入任意寄存器 ("r")
)
volatile— 禁止编译器优化掉或重排这段汇编"r"(code)— 将 C 变量code的值加载到一个通用寄存器中%0— 引用第一个操作数 (即 code 所在的寄存器)
实际生成的汇编可能是:
mv a0, a5 # 假设 code 在 a5 中
ebreak # 触发断点
十一、objdump 反汇编分析
11.1 如何查看编译出来的机器码?
riscv64-linux-gnu-objdump -d build/add-riscv32e-npc.elf > build/add-riscv32e-npc.txt
输出示例:
80000000 <_start>:
80000000: 00000413 mv s0, zero
80000004: 00008117 auipc sp, 0x8
80000008: ffc10113 addi sp, sp, -4
8000000c: 008000ef jal ra, 80000014 <_trm_init>
80000014 <_trm_init>:
80000014: ff010113 addi sp, sp, -16
80000018: 00112623 sw ra, 12(sp)
8000001c: 00000517 auipc a0, 0x0
80000020: 00050513 mv a0, a0
80000024: 010000ef jal ra, 80000034 <main>
80000028: 00050513 mv a0, a0
8000002c: 008000ef jal ra, 80000034 <halt>
11.2 readelf 查看 ELF 结构
riscv64-linux-gnu-readelf -h build/add-riscv32e-npc.elf # 文件头
riscv64-linux-gnu-readelf -l build/add-riscv32e-npc.elf # 程序头 (段加载信息)
riscv64-linux-gnu-readelf -S build/add-riscv32e-npc.elf # 段表
十二、常见问题与解决方案
Q1: 编译时报 "undefined reference to __muldi3"
原因:RV32E 没有硬件乘法器 (或编译器生成了 64-bit 乘法辅助函数)
解决方案:项目提供了 libgcc 补丁:
AM_SRCS += riscv/npc/libgcc/div.S \
riscv/npc/libgcc/muldi3.S \
riscv/npc/libgcc/multi3.c
Q2: 程序执行后没有任何输出就结束了
检查:
_start是否在链接脚本的entry段中?_stack_pointer是否正确定义?- 尝试运行
dummy测试,确认启动链路正常
Q3: printf 输出乱码或不工作
检查:
putch()是否正确写入SERIAL_PORT地址?- NEMU/NPC 是否正确处理了该 MMIO 地址的写操作?
- klib 中的
printf实现是否正确处理了格式化字符串?
Q4: 为什么 .bss 段需要 --set-section-flags .bss=alloc,contents?
默认情况下,.bss 段不占用 ELF 文件空间 (只记录大小)。但 objcopy -O binary 需要实际输出这些零字节,否则加载后 BSS 区域的值可能是随机的。
Q5: la sp, _stack_pointer 实际生成了什么指令?
la (load address) 是伪指令,展开为:
auipc sp, %hi(_stack_pointer) # sp = PC + 高20位偏移
addi sp, sp, %lo(_stack_pointer) # sp += 低12位偏移
这实现了 PC 相对寻址,但由于裸金属程序地址固定,也可以理解为加载一个绝对地址。
Q6: 为什么 free() 是空函数?
在裸金属环境中:
- 内存不会归还给 OS (没有 OS)
- bump allocator 最简单且不会出错
- 程序执行完就停机了,不需要回收
十三、学习建议
- 用 objdump 分析你写的每个测试:看看 C 代码变成了什么汇编
- 手动跟踪 dummy 测试的执行:从
_start开始,逐条指令理解 - 尝试修改链接脚本:改变入口地址或栈大小,观察效果
- 实现 klib 函数时先写测试:用 native 平台验证正确性
- 理解 RISC-V 调用约定:这是后续所有阶段的基础
参考资料:
- RISC-V Calling Convention (ABIs)
- GNU ld Linker Scripts 手册
- ELF Format Specification
- GCC Inline Assembly HOWTO
- 项目源码: abstract-machine/, am-kernels/tests/cpu-tests/
十四、PA 讲义思考题回答 (PA2 程序/运行时环境/AM 部分)
以下问题来自 PA2.1~2.3 讲义中与"程序的机器级表示"相关的核心思考题。
PA2.1 不停计算的机器
Q: YEMU 的工作过程是什么?(概念模型)
回答:讲义中用一个极简的模拟器 YEMU 解释 CPU 的本质——一个不断做如下事情的状态机:
while (1) {
从 PC 指示的内存位置取出指令;
执行该指令;
更新 PC;
}
这就是冯诺依曼体系结构的核心循环。在 NEMU 中对应 cpu_exec() → execute() → exec_once()。
Q: 程序是什么?
回答:从计算机的视角看,程序就是内存中的一串字节。CPU 不关心这些字节的"含义",它只是按照 ISA 手册的规则去解释和执行它们。不同的程序不过是不同的字节序列。
从更高层面看:程序 = 指令序列 + 数据,它描述了一个计算过程。编译器的工作就是将人类可读的 C 代码翻译为机器可执行的字节序列。
Q: "计算机是个状态机" 是什么意思?
回答:
- 状态 = 所有寄存器的值 + 整个内存的内容
- 状态转移 = 执行一条指令
计算机从初始状态(PC = RESET_VECTOR,内存中加载了程序)出发,每执行一条指令就发生一次状态转移,直到遇到 halt(ebreak)停止。
整个程序的执行就是一条从初始状态到终止状态的有限状态序列。
PA2.2 RTFSC(2) — 指令执行
Q: 一条指令在 NEMU 中是如何执行的?整理执行流程。
回答:以 addi a0, a1, 5 为例:
1. exec_once(s, cpu.pc)
│
├─ s->pc = cpu.pc // 记录当前 PC
├─ s->snpc = cpu.pc // 初始化静态 next PC
│
├─ isa_exec_once(s)
│ ├─ inst_fetch(&s->snpc, 4) // 从 pmem[pc - 0x80000000] 读取 4 字节
│ │ └─ s->snpc += 4 // snpc 更新为 pc+4
│ │
│ └─ decode_exec(s)
│ ├─ s->dnpc = s->snpc // 默认 dnpc = snpc (顺序执行)
│ ├─ INSTPAT 匹配 // 找到匹配的指令模式
│ ├─ decode_operand(TYPE_I) // 提取 rs1=11, rd=10, imm=5
│ ├─ R(rd) = src1 + imm // gpr[10] = gpr[11] + 5
│ └─ R(0) = 0 // 强制 x0 = 0
│
└─ cpu.pc = s->dnpc // 更新 PC (此处 = pc+4)
Q: 为什么要把指令执行分成 "取指" "译码" "执行" 三个阶段?
回答:
- 概念清晰:对应硬件设计中的流水线阶段,便于理解
- ISA 无关性:取指对所有 ISA 都一样(读内存);译码和执行才是 ISA 相关的
- 可扩展性:将来实现流水线处理器时,这三个阶段自然对应流水级
- 调试友好:可以在每个阶段之间插入检查点 (ITRACE 在取指后记录, DiffTest 在执行后检查)
Q: inst_fetch 做了什么?
回答:
static inline uint32_t inst_fetch(vaddr_t *pc, int len) {
uint32_t inst = vaddr_ifetch(*pc, len); // 从内存读取 len 字节
(*pc) += len; // 更新 snpc
return inst;
}
对于 RISC-V,len 固定为 4(32-bit 指令),所以每次取指后 snpc += 4。这等效于硬件中的 PC + 4 逻辑。
Q: 立即数为什么要符号扩展 (SEXT)?
回答:RISC-V 指令中的立即数字段有限(如 I 型只有 12 位),但运算时需要 32 位操作数。符号扩展保证了负数的正确表示:
imm = 0xFFF(12-bit) → 符号扩展后0xFFFFFFFF= -1 (32-bit)imm = 0x005(12-bit) → 符号扩展后0x00000005= 5 (32-bit)
如果用零扩展,0xFFF 就会变成 4095 而不是 -1,这不符合大多数指令的语义。
PA2.3 程序, 运行时环境与 AM
Q: 什么是运行时环境?为什么需要它?
回答:
运行时环境 = 程序执行所需的、超越其自身代码的所有支撑。包括:
- 栈的初始化(sp 寄存器的设置)
- 堆内存管理(malloc)
- 标准库函数(printf, memcpy)
- 程序入口/退出机制(_start → main → halt)
- I/O 设施
为什么需要:一个 C 程序不能直接在裸硬件上运行——它假设"有人"已经设置好了栈、提供了 printf 等函数、并在 main 返回后做了清理。在 OS 上由 crt0 + libc + 内核提供;在裸金属上由 AM 提供。
Q: AM 的 TRM 提供了什么?一个 C 程序最少需要什么才能运行?
回答:TRM 只提供三样东西:
heap— 一块可用的内存区域putch(ch)— 输出一个字符halt(code)— 停止执行
一个 C 程序最少需要:
- 一段可执行内存(存放代码)
- 一个栈(start.S 设置 sp)
- 一个结束方式(halt)
有了这三样,return 0; 这样简单的 main 就能运行了。
Q: 为什么要设计 AM 这样的抽象层?直接在 NEMU 上写程序不行吗?
回答:
-
可移植性:一份 typing-game 代码,不改一行,可以跑在 NEMU/NPC/QEMU/Native 上。如果没有 AM,每换一个平台就要重写所有硬件相关代码。
-
分离关注点:
- 应用程序员只关心
io_read(AM_TIMER_UPTIME)这样的接口 - 平台实现者只关心如何将接口映射到具体硬件
- 两者互不干扰
- 应用程序员只关心
-
验证方便:同一个程序在 NEMU 上通过测试后,可以直接部署到 NPC RTL 上验证硬件正确性。
Q: ARCH 变量如何实现"一份代码多平台"?
回答:构建系统通过 ARCH=riscv32e-npc 变量确定:
ARCH_SPLIT = $(subst -, ,$(ARCH)) # ["riscv32e", "npc"]
ISA = $(word 1,$(ARCH_SPLIT)) # riscv32e
PLATFORM = $(word 2,$(ARCH_SPLIT)) # npc
然后 include 对应的配置文件:
-include $(AM_HOME)/scripts/$(ARCH).mk # scripts/riscv32e-npc.mk
这个 .mk 文件里定义了:
- 交叉编译器、march/mabi 选项
- 平台相关的源文件列表 (
AM_SRCS) - 链接脚本
run规则(如何启动模拟器/运行镜像)
同一份应用代码 + 不同的 ARCH = 不同的二进制镜像 → 运行在不同的平台上。
PA2.4 基础设施(2) — DiffTest
Q: 什么是差分测试 (DiffTest)?为什么需要它?
回答:
差分测试 = 用一个已知正确的参考实现(reference)来验证待测实现(DUT)的正确性。
在本项目中:
- DUT = 你的 NEMU / NPC
- REF = Spike (RISC-V 官方模拟器) 或另一个已验证的 NEMU
工作方式:
- DUT 和 REF 加载相同的程序
- 每执行一条指令后,对比两者的寄存器状态
- 如果不一致 → 说明 DUT 在这条指令上有 bug
为什么需要:
- 人工逐条验证几十条指令的实现太容易遗漏
- DiffTest 能精确定位到哪条指令在哪个寄存器上出了错
- 是本项目中最强大的 debug 武器
Q: DiffTest 有什么局限性?
回答:
- 需要参考模型:如果参考模型本身有 bug,DiffTest 也无法发现
- 只验证功能正确性:无法验证时序行为(在 RTL 中,两条指令之间的时钟周期数不同也不会被发现)
- 同步困难:对于中断、设备 I/O 等非确定性事件,DUT 和 REF 的行为可能天然不同
- 性能开销:每条指令后都要对比状态,模拟速度大幅下降(约 2-5 倍)
PA2.5 输入输出
Q: 什么是 MMIO?为什么要把设备映射到内存地址空间?
回答:
MMIO (Memory-Mapped I/O) = 将设备的控制寄存器映射到物理地址空间中的某些地址。CPU 通过普通的 load/store 指令访问这些地址来与设备交互。
优点:
- 无需专门的 I/O 指令:RISC-V 没有 x86 那样的
in/out指令,所有设备访问都用lw/sw完成 - 统一编程模型:内存和设备用相同的指令访问,简化 ISA 设计
- 编译器友好:可以用 C 语言的指针解引用来访问设备
volatile 的关键作用:
*(volatile uint32_t *)0xa0000048 = data; // 必须用 volatile
- 没有 volatile:编译器可能将多次写合并、消除"无效"读取
- 有 volatile:每次都必须真正执行内存访问,因为设备寄存器的值可能随时被硬件改变
Q: 程序在模拟器上运行时如何知道"时间过了多久"?
回答:
通过读取定时器设备的 MMIO 寄存器:
uint64_t get_time() {
uint32_t lo = inl(RTC_ADDR); // 低 32 位
uint32_t hi = inl(RTC_ADDR + 4); // 高 32 位
return ((uint64_t)hi << 32) + lo;
}
模拟器端(NEMU/NPC)在收到对 RTC_ADDR 的读请求时,会调用主机的 gettimeofday() 或 clock_gettime() 获取真实时间并返回。所以即使模拟器执行指令很慢,程序读到的"时间"仍然是真实的墙钟时间。
Q: 为什么串口输出只需要一条 sb (store byte) 指令就能完成?
回答:
void putch(char ch) {
outb(SERIAL_PORT, ch); // *(volatile uint8_t *)SERIAL_PORT = ch;
}
模拟器(NEMU)对 SERIAL_PORT 地址的写操作做了特殊处理:
- NEMU 拦截到对该地址的写入
- 取出写入的字节
- 调用主机的
putchar()输出到终端
真实硬件中,UART 串口控制器也是类似的机制:CPU 将字符写入 UART 的数据寄存器(THR),UART 硬件自动将字符通过串行线发送出去。所以一条 sb 指令就完成了"输出一个字符"的全部工作。
注:以上回答基于 NJU ICS PA 2024 讲义的核心思考题,结合本项目源码分析。
PA 讲义来源:https://nju-projectn.github.io/ics-pa-gitbook/ics2024/
十五、本阶段 Git 版本记录
| Commit | 说明 | 完成内容 |
|---|---|---|
fa79548 |
NJU-ProjectN/abstract-machine ics2023 initialized | 引入 AM 框架 (链接脚本、start.S、klib) |
1c8f0fb |
PA2.1 | 编译工具链配置、交叉编译、ELF→bin 流程 |
查看对应版本代码:
git show fa79548:abstract-machine/scripts/linker.ld # 链接脚本
git show fa79548:abstract-machine/am/src/riscv/npc/start.S # 启动代码
git show fa79548:abstract-machine/klib/src/string.c # klib 实现

浙公网安备 33010602011771号