D2: 程序的机器级表示

一、阶段目标

D2 阶段的核心目标是理解 C 程序如何被编译、链接并加载到裸金属环境中执行。完成后你将:

  1. 掌握从 C 源码 → 汇编 → 机器码 → 内存镜像的完整流程
  2. 理解 RISC-V 的 ABI (调用约定、数据类型、对齐)
  3. 理解链接脚本如何定义内存布局
  4. 理解裸金属程序的启动过程 (crt0/start.S)
  5. 理解 ELF 格式和 objcopy 二进制转换
  6. 掌握最小 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: 程序执行后没有任何输出就结束了

检查

  1. _start 是否在链接脚本的 entry 段中?
  2. _stack_pointer 是否正确定义?
  3. 尝试运行 dummy 测试,确认启动链路正常

Q3: printf 输出乱码或不工作

检查

  1. putch() 是否正确写入 SERIAL_PORT 地址?
  2. NEMU/NPC 是否正确处理了该 MMIO 地址的写操作?
  3. 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 最简单且不会出错
  • 程序执行完就停机了,不需要回收

十三、学习建议

  1. 用 objdump 分析你写的每个测试:看看 C 代码变成了什么汇编
  2. 手动跟踪 dummy 测试的执行:从 _start 开始,逐条指令理解
  3. 尝试修改链接脚本:改变入口地址或栈大小,观察效果
  4. 实现 klib 函数时先写测试:用 native 平台验证正确性
  5. 理解 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: 为什么要把指令执行分成 "取指" "译码" "执行" 三个阶段?

回答

  1. 概念清晰:对应硬件设计中的流水线阶段,便于理解
  2. ISA 无关性:取指对所有 ISA 都一样(读内存);译码和执行才是 ISA 相关的
  3. 可扩展性:将来实现流水线处理器时,这三个阶段自然对应流水级
  4. 调试友好:可以在每个阶段之间插入检查点 (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 只提供三样东西:

  1. heap — 一块可用的内存区域
  2. putch(ch) — 输出一个字符
  3. halt(code) — 停止执行

一个 C 程序最少需要

  • 一段可执行内存(存放代码)
  • 一个栈(start.S 设置 sp)
  • 一个结束方式(halt)

有了这三样,return 0; 这样简单的 main 就能运行了。


Q: 为什么要设计 AM 这样的抽象层?直接在 NEMU 上写程序不行吗?

回答

  1. 可移植性:一份 typing-game 代码,不改一行,可以跑在 NEMU/NPC/QEMU/Native 上。如果没有 AM,每换一个平台就要重写所有硬件相关代码。

  2. 分离关注点

    • 应用程序员只关心 io_read(AM_TIMER_UPTIME) 这样的接口
    • 平台实现者只关心如何将接口映射到具体硬件
    • 两者互不干扰
  3. 验证方便:同一个程序在 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

工作方式:

  1. DUT 和 REF 加载相同的程序
  2. 每执行一条指令后,对比两者的寄存器状态
  3. 如果不一致 → 说明 DUT 在这条指令上有 bug

为什么需要

  • 人工逐条验证几十条指令的实现太容易遗漏
  • DiffTest 能精确定位到哪条指令哪个寄存器上出了错
  • 是本项目中最强大的 debug 武器

Q: DiffTest 有什么局限性?

回答

  1. 需要参考模型:如果参考模型本身有 bug,DiffTest 也无法发现
  2. 只验证功能正确性:无法验证时序行为(在 RTL 中,两条指令之间的时钟周期数不同也不会被发现)
  3. 同步困难:对于中断、设备 I/O 等非确定性事件,DUT 和 REF 的行为可能天然不同
  4. 性能开销:每条指令后都要对比状态,模拟速度大幅下降(约 2-5 倍)

PA2.5 输入输出

Q: 什么是 MMIO?为什么要把设备映射到内存地址空间?

回答

MMIO (Memory-Mapped I/O) = 将设备的控制寄存器映射到物理地址空间中的某些地址。CPU 通过普通的 load/store 指令访问这些地址来与设备交互。

优点

  1. 无需专门的 I/O 指令:RISC-V 没有 x86 那样的 in/out 指令,所有设备访问都用 lw/sw 完成
  2. 统一编程模型:内存和设备用相同的指令访问,简化 ISA 设计
  3. 编译器友好:可以用 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 实现
posted @ 2026-06-08 16:54  mo686  阅读(2)  评论(0)    收藏  举报