D5: 设备和输入输出
一、阶段目标
D5 阶段的核心目标是理解计算机如何与外部世界交互——实现 I/O 设备模拟,使客户程序能够输出字符、读取时间、响应键盘、显示图形。完成后你将:
- 理解 MMIO (Memory-Mapped I/O) 的工作原理
- 掌握 NEMU 中设备框架的注册和分发机制
- 实现串口、定时器、键盘、VGA 等设备
- 理解设备如何与 SDL 库配合实现真正的图形显示
- 能够在 NEMU 上运行带 I/O 的应用 (打字游戏、贪吃蛇等)
二、I/O 的两种方式
2.1 端口映射 I/O (Port-Mapped I/O, PMIO)
- 使用专用的 I/O 指令 (x86 的
in/out) - I/O 地址空间与内存地址空间独立
- RISC-V 不支持此方式
2.2 内存映射 I/O (Memory-Mapped I/O, MMIO)
- 将设备寄存器映射到物理地址空间的某些地址
- CPU 用普通的
lw/sw指令访问设备 - RISC-V 使用此方式
物理地址空间:
0x00000000 ┌──────────────┐
│ │
0x80000000 ├──────────────┤ ← 物理内存 (RAM)
│ pmem │
0x88000000 ├──────────────┤
│ │
0xa0000000 ├──────────────┤ ← MMIO 设备区域
│ 串口/定时器 │
│ 键盘/VGA │
0xa1000000 ├──────────────┤ ← 帧缓冲 (video memory)
│ Framebuffer │
└──────────────┘
三、NEMU 设备框架
3.1 整体架构
CPU 执行 lw/sw 指令
│
▼
paddr_read() / paddr_write() (nemu/src/memory/paddr.c)
│
├── in_pmem(addr)? → pmem_read/write() 普通内存
│
└── 否 → mmio_read/write() 设备 MMIO
│
▼
find_mapid_by_addr() 查找地址对应哪个设备
│
▼
map_read() / map_write() (nemu/src/device/io/map.c)
│
├── host_read/write() 读写设备的数据缓冲区
│
└── invoke_callback() 调用设备的回调函数
(serial_io_handler / rtc_io_handler / ...)
3.2 设备注册机制
每个设备在初始化时向框架注册自己:
// 注册 MMIO 设备的核心函数
void add_mmio_map(const char *name, paddr_t addr, void *space, uint32_t len, io_callback_t callback);
参数含义:
| 参数 | 含义 | 示例 |
|---|---|---|
name |
设备名称 (调试用) | "serial" |
addr |
MMIO 起始地址 | 0xa00003f8 |
space |
设备数据缓冲区 | 一块分配好的内存 |
len |
地址空间大小 | 8 |
callback |
读写回调函数 | serial_io_handler |
3.3 设备数据空间 (new_space)
// nemu/src/device/io/map.c
#define IO_SPACE_MAX (2 * 1024 * 1024)
static uint8_t *io_space = NULL;
uint8_t* new_space(int size) {
uint8_t *p = p_space;
size = (size + (PAGE_SIZE - 1)) & ~PAGE_MASK; // 页对齐
p_space += size;
return p;
}
所有设备共享一个 2MB 的 I/O 空间池。new_space() 相当于一个只增不减的分配器(和 klib 的 malloc 思路一样)。
3.4 读写分发 (map_read / map_write)
word_t map_read(paddr_t addr, int len, IOMap *map) {
paddr_t offset = addr - map->low; // 计算设备内偏移
invoke_callback(map->callback, offset, len, false); // 调用回调准备数据
return host_read(map->space + offset, len); // 读取数据
}
void map_write(paddr_t addr, int len, word_t data, IOMap *map) {
paddr_t offset = addr - map->low;
host_write(map->space + offset, len, data); // 先写入数据
invoke_callback(map->callback, offset, len, true); // 再调用回调处理
}
注意读写回调的调用时机不同:
- 读操作:先调用回调(让设备准备最新数据),再读取
- 写操作:先写入(让数据就位),再调用回调(设备处理)
四、串口设备 (Serial / UART)
4.1 功能
最简单的设备——程序写一个字节到串口地址,该字节就会显示在终端上。
4.2 实现
// nemu/src/device/serial.c
#define CH_OFFSET 0
static uint8_t *serial_base = NULL;
static void serial_io_handler(uint32_t offset, int len, bool is_write) {
assert(len == 1);
if (is_write && offset == CH_OFFSET) {
putc(serial_base[0], stderr); // 将字符输出到主机 stderr
}
}
void init_serial() {
serial_base = new_space(8);
add_mmio_map("serial", CONFIG_SERIAL_MMIO, serial_base, 8, serial_io_handler);
}
4.3 客户程序如何使用
// AM 中的 putch 实现
void putch(char ch) {
outb(SERIAL_PORT, ch); // 等效于 *(volatile uint8_t*)0xa00003f8 = ch;
}
完整链路:
putch('A')
→ outb(0xa00003f8, 'A')
→ CPU 执行 sb 指令到地址 0xa00003f8
→ paddr_write(0xa00003f8, 1, 'A')
→ 不在 pmem 范围内 → mmio_write()
→ 找到 "serial" 设备
→ map_write(): 写入 serial_base[0] = 'A'
→ serial_io_handler(): putc('A', stderr)
→ 终端显示字符 'A'
五、定时器设备 (Timer / RTC)
5.1 功能
提供一个 64-bit 微秒计数器,客户程序通过读取获得当前时间。
5.2 实现
// nemu/src/device/timer.c
static uint32_t *rtc_port_base = NULL;
static void rtc_io_handler(uint32_t offset, int len, bool is_write) {
assert(offset == 0 || offset == 4);
if (!is_write && offset == 0) {
uint64_t us = get_time(); // 获取主机真实时间 (微秒)
rtc_port_base[0] = (uint32_t)us; // 低 32 位
rtc_port_base[1] = us >> 32; // 高 32 位
}
}
void init_timer() {
rtc_port_base = (uint32_t *)new_space(8);
add_mmio_map("rtc", CONFIG_RTC_MMIO, rtc_port_base, 8, rtc_io_handler);
}
5.3 关键设计
- 读触发:只有在读偏移 0 时才更新时间值(高 32 位在读低 32 位时一起更新)
- 真实时间:
get_time()使用gettimeofday()获取主机真实时间 - 为什么是微秒:精度足够用于游戏帧率控制,且不会溢出太快
5.4 客户程序使用
// AM timer 实现
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;
}
六、键盘设备 (Keyboard / i8042)
6.1 功能
维护一个按键事件队列,客户程序读取时返回队首事件。
6.2 实现
// nemu/src/device/keyboard.c
#define KEYDOWN_MASK 0x8000
#define KEY_QUEUE_LEN 1024
static int key_queue[KEY_QUEUE_LEN] = {};
static int key_f = 0, key_r = 0; // 队列头/尾指针
// SDL 事件到 AM 键码的映射
static uint32_t keymap[256] = {};
// 按键入队 (由 device_update 中的 SDL 事件处理调用)
void send_key(uint8_t scancode, bool is_keydown) {
uint32_t am_scancode = keymap[scancode] | (is_keydown ? KEYDOWN_MASK : 0);
key_enqueue(am_scancode);
}
// 按键出队 (客户程序读设备寄存器时触发)
static uint32_t key_dequeue() {
if (key_f != key_r) {
uint32_t key = key_queue[key_f];
key_f = (key_f + 1) % KEY_QUEUE_LEN;
return key;
}
return _KEY_NONE; // 队列空
}
// I/O 回调
static void i8042_data_io_handler(uint32_t offset, int len, bool is_write) {
assert(!is_write);
i8042_data_port_base[0] = key_dequeue(); // 读时出队
}
6.3 键码编码格式
31 15 14 0
+-------------------+---+-----------+
| (unused) | D | keycode |
+-------------------+---+-----------+
│
└── bit15: 1=按下, 0=释放
6.4 SDL 事件循环
// nemu/src/device/device.c
void device_update() {
// 每 1/60 秒 更新一次设备状态
SDL_Event event;
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_KEYDOWN:
case SDL_KEYUP:
send_key(event.key.keysym.scancode,
event.key.type == SDL_KEYDOWN);
break;
case SDL_QUIT:
nemu_state.state = NEMU_QUIT;
break;
}
}
}
device_update() 在 NEMU 的指令执行循环中被定期调用:
// cpu-exec.c execute()
for (; n > 0; n--) {
exec_once(&s, cpu.pc);
// ...
IFDEF(CONFIG_DEVICE, device_update()); // ← 每条指令后检查设备
}
七、VGA 显示设备 (Framebuffer)
7.1 功能
提供一块与屏幕像素一一对应的内存区域,程序写入像素数据后通过同步寄存器触发屏幕刷新。
7.2 架构
客户程序
│
┌───────────┼───────────┐
│ outl(FB + offset, color) │ 写帧缓冲
│ outl(SYNC_ADDR, 1) │ 触发刷新
└───────────┼───────────┘
▼
NEMU 设备层
│
┌───────────┼───────────┐
│ vmem[offset] = color │ 数据写入缓冲
│ vga_update_screen() │ 同步时调用
└───────────┼───────────┘
▼
SDL2 图形库
│
┌───────────┼───────────┐
│ SDL_UpdateTexture() │ 更新纹理
│ SDL_RenderPresent() │ 显示到窗口
└───────────┼───────────┘
▼
操作系统窗口 → 显示器
7.3 实现
// nemu/src/device/vga.c
#define SCREEN_W 400
#define SCREEN_H 300
static void *vmem = NULL; // 帧缓冲 (400×300×4 bytes)
static uint32_t *vgactl_port_base = NULL; // 控制寄存器
void init_vga() {
// 控制寄存器: [31:16]=width, [15:0]=height, [32]=sync
vgactl_port_base = (uint32_t *)new_space(8);
vgactl_port_base[0] = (SCREEN_W << 16) | SCREEN_H;
add_mmio_map("vgactl", CONFIG_VGA_CTL_MMIO, vgactl_port_base, 8, NULL);
// 帧缓冲: 直接映射一块连续内存
vmem = new_space(SCREEN_W * SCREEN_H * 4);
add_mmio_map("vmem", CONFIG_FB_ADDR, vmem, SCREEN_W * SCREEN_H * 4, NULL);
init_screen(); // 初始化 SDL 窗口
}
void vga_update_screen() {
if (vgactl_port_base[1]) { // 同步寄存器非零?
update_screen(); // 用 SDL 将 vmem 刷到屏幕
vgactl_port_base[1] = 0; // 清除同步标志
}
}
7.4 帧缓冲地址计算
像素坐标 (x, y) 对应的 MMIO 地址:
addr = FB_ADDR + (y * SCREEN_W + x) * 4
像素格式: 0x00RRGGBB (32-bit, 忽略最高字节)
7.5 为什么帧缓冲不需要回调函数?
add_mmio_map("vmem", CONFIG_FB_ADDR, vmem, size, NULL); // callback = NULL
因为帧缓冲只是一块普通内存,写入的数据直接保存在 vmem 中。只有当客户程序写同步寄存器时,NEMU 才将 vmem 的内容刷新到 SDL 窗口。这避免了每写一个像素就触发回调的性能开销。
八、设备更新时机
8.1 问题:设备在什么时候被刷新?
// cpu-exec.c
void device_update() {
static uint64_t last = 0;
uint64_t now = get_time();
if (now - last < 1000000 / TIMER_HZ) return; // 未到刷新间隔
last = now;
IFDEF(CONFIG_HAS_VGA, vga_update_screen()); // 检查 VGA 同步
// ... SDL 事件处理 (键盘)
}
TIMER_HZ通常设为 60,即 1/60 秒 (~16ms) 检查一次- VGA 的实际刷新由同步寄存器控制(客户程序决定何时刷新)
- 键盘事件由 SDL 事件队列实时入队
九、设备 Kconfig 配置
# nemu/src/device/Kconfig
config HAS_SERIAL
bool "Enable serial"
default y
config HAS_TIMER
bool "Enable timer"
default y
config HAS_KEYBOARD
bool "Enable keyboard"
default y
config HAS_VGA
bool "Enable VGA"
default y
config HAS_AUDIO
bool "Enable audio"
default y
通过 make menuconfig 可以单独开关每个设备,开关后对应的 CONFIG_HAS_xxx 宏在编译时生效。
十、DTRACE (设备访问追踪)
在 Kconfig 中启用 DTRACE 后,每次设备 I/O 操作都会被记录:
DTRACE: Write serial @ offset=0, len=1, data='H'
DTRACE: Read rtc @ offset=0, len=4, data=0x001234ab
DTRACE: Read keyboard @ offset=0, len=4, data=0x00008041
这对于调试设备交互问题非常有用。
十一、NPC 中的设备实现 (对比)
NPC RTL 中的设备通过 AXI-Lite 外设控制器实现:
| 设备 | NEMU 实现方式 | NPC 实现方式 |
|---|---|---|
| 串口 | MMIO + C 回调 | axi_lite_uart.v (RTL) |
| 定时器 | MMIO + gettimeofday |
axi_lite_clint.v (RTL) |
| 键盘 | MMIO + SDL 事件队列 | DPI-C 回调 |
| VGA | MMIO + SDL 渲染 | DPI-C + 帧缓冲 |
NPC 仿真阶段,复杂设备通过 DPI-C 回调到 C++ 实现;SoC 集成阶段则用真正的硬件外设 (UART16550, SPI Flash 等)。
十二、常见问题与解决方案
Q1: 运行程序时没有任何输出
检查清单:
- 串口设备是否在 menuconfig 中启用?
CONFIG_SERIAL_MMIO地址是否与 AM 中SERIAL_PORT定义一致?paddr_write中是否正确将该地址分发到 mmio?
Q2: 定时器读取总是返回 0
原因:rtc_io_handler 只在 offset == 0 且 is_write == false 时更新数据。
检查:确保客户程序先读低 32 位 (offset=0) 再读高 32 位 (offset=4)。
Q3: 键盘按键无响应
检查:
- SDL 窗口是否获得焦点?
keymap[]是否正确初始化?device_update()是否被调用?(需要CONFIG_DEVICE宏启用)
Q4: VGA 屏幕不刷新
原因:客户程序写帧缓冲后必须写同步寄存器:
outl(VGACTL_ADDR + 4, 1); // 写 sync 寄存器触发刷新
如果只写了像素但没写 sync,vga_update_screen() 中的条件检查不通过。
Q5: 为什么设备回调不能太慢?
因为 device_update() 在指令执行循环中被调用。如果某个回调函数执行时间过长,会阻塞整个仿真。特别是 VGA 的 SDL 渲染——这就是为什么只在 sync 时才真正刷新屏幕。
十三、PA 讲义思考题
Q: 为什么选择 MMIO 而不是 PMIO?
回答:
- RISC-V 是 load/store 架构,没有设计专用的 I/O 指令
- MMIO 不需要 ISA 扩展,复用现有的 lw/sw 指令
- 简化硬件设计——总线上不用区分"这是内存访问还是设备访问"
- 地址解码器统一处理所有访问,只需在不同地址范围分发到不同模块
Q: 如果没有 volatile,设备访问会怎样?
回答:编译器可能做以下"优化":
// 原始代码
for (int i = 0; i < 100; i++)
*(uint32_t *)SERIAL_PORT = buf[i];
// 编译器可能优化为 (认为连续写同一地址没意义)
*(uint32_t *)SERIAL_PORT = buf[99]; // 只保留最后一次
加了 volatile 后,编译器被强制保留每一次读写操作,不允许合并、消除或重排。
Q: device_update() 为什么要限频?
回答:
如果每执行一条指令就检查 SDL 事件,开销巨大(SDL_PollEvent 涉及系统调用)。限频到 60Hz 意味着大约每执行几万条指令才检查一次,对仿真性能影响很小,同时 60Hz 的刷新率对人眼完全流畅。
Q: 客户程序如何知道屏幕尺寸?
回答:通过读 VGA 控制寄存器:
uint32_t val = inl(VGACTL_ADDR);
uint32_t width = val >> 16; // 高 16 位 = 宽度
uint32_t height = val & 0xffff; // 低 16 位 = 高度
这是 NEMU 在 init_vga() 中写入的:
vgactl_port_base[0] = (SCREEN_W << 16) | SCREEN_H;
Q: 按键事件队列为什么是环形的?
回答:
- 固定大小:无需动态内存分配(裸金属环境或性能敏感的模拟器中重要)
- FIFO 语义:先按先出,保证按键顺序正确
- 覆盖保护:队列满时 assert 失败,避免静默丢失事件
- O(1) 操作:入队/出队都是常数时间
十四、学习建议
- 从串口开始:最简单的设备,一个回调函数就能实现
- 然后实现定时器:稍复杂(需要获取主机时间),但逻辑简单
- 键盘需要 SDL:理解事件驱动模型和队列
- VGA 最复杂:涉及帧缓冲地址计算、SDL 纹理渲染、同步机制
- 用 DTRACE 调试:开启设备追踪可以看到每一次设备访问
- 运行 typing-game 作为综合测试:它同时使用定时器+键盘+VGA
参考资料:
- SDL2 官方文档: https://wiki.libsdl.org/
- RISC-V MMIO 约定
- PA2.5 "输入输出" 讲义
- 项目源码: nemu/src/device/, abstract-machine/am/src/platform/nemu/ioe/
十五、本阶段 Git 版本记录
| Commit | 说明 | 完成内容 |
|---|---|---|
807cfb6 |
PA2.3 w/o dtrace | NEMU 设备实现 (串口/定时器/键盘/VGA) |
3980d5e |
PA2.3 RTL | NPC 支持设备 I/O (DPI-C 串口/定时器) |
查看对应版本代码:
git show 807cfb6:nemu/src/device/serial.c # NEMU 串口
git show 807cfb6:nemu/src/device/timer.c # NEMU 定时器
git show 807cfb6:nemu/src/device/keyboard.c # NEMU 键盘
git show 807cfb6:nemu/src/device/vga.c # NEMU VGA
git show 3980d5e:npc/csrc/dpic.cpp # NPC DPI-C 设备回调

浙公网安备 33010602011771号