D5: 设备和输入输出

一、阶段目标

D5 阶段的核心目标是理解计算机如何与外部世界交互——实现 I/O 设备模拟,使客户程序能够输出字符、读取时间、响应键盘、显示图形。完成后你将:

  1. 理解 MMIO (Memory-Mapped I/O) 的工作原理
  2. 掌握 NEMU 中设备框架的注册和分发机制
  3. 实现串口、定时器、键盘、VGA 等设备
  4. 理解设备如何与 SDL 库配合实现真正的图形显示
  5. 能够在 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: 运行程序时没有任何输出

检查清单

  1. 串口设备是否在 menuconfig 中启用?
  2. CONFIG_SERIAL_MMIO 地址是否与 AM 中 SERIAL_PORT 定义一致?
  3. paddr_write 中是否正确将该地址分发到 mmio?

Q2: 定时器读取总是返回 0

原因rtc_io_handler 只在 offset == 0is_write == false 时更新数据。
检查:确保客户程序先读低 32 位 (offset=0) 再读高 32 位 (offset=4)。

Q3: 键盘按键无响应

检查

  1. SDL 窗口是否获得焦点?
  2. keymap[] 是否正确初始化?
  3. 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?

回答

  1. RISC-V 是 load/store 架构,没有设计专用的 I/O 指令
  2. MMIO 不需要 ISA 扩展,复用现有的 lw/sw 指令
  3. 简化硬件设计——总线上不用区分"这是内存访问还是设备访问"
  4. 地址解码器统一处理所有访问,只需在不同地址范围分发到不同模块

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: 按键事件队列为什么是环形的?

回答

  1. 固定大小:无需动态内存分配(裸金属环境或性能敏感的模拟器中重要)
  2. FIFO 语义:先按先出,保证按键顺序正确
  3. 覆盖保护:队列满时 assert 失败,避免静默丢失事件
  4. O(1) 操作:入队/出队都是常数时间

十四、学习建议

  1. 从串口开始:最简单的设备,一个回调函数就能实现
  2. 然后实现定时器:稍复杂(需要获取主机时间),但逻辑简单
  3. 键盘需要 SDL:理解事件驱动模型和队列
  4. VGA 最复杂:涉及帧缓冲地址计算、SDL 纹理渲染、同步机制
  5. 用 DTRACE 调试:开启设备追踪可以看到每一次设备访问
  6. 运行 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 设备回调
posted @ 2026-06-09 16:45  mo686  阅读(18)  评论(0)    收藏  举报