2024 NJU PA2.3
I/O设备
常见的I/O设备有键盘、鼠标等。CPU与I/O设备的交互,包括数据交互、CPU读取I/O设备的状态等。主要通过以下两种方式实现:
- 端口I/O (Port I/O, PIO):计算机为常见设备分配端口号,例如0x0020~0x0021被分配给中断控制器,用户通过特殊指令访问这些端口号。这种方式非常死板,不能方便地添加设备。特别地,随着新设备的出现,原先划分的I/O空间根本无法满足需求。
 - 内存映射I/O(Memory-Map I/O, 即MMIO):内存映射将内存访问“重定向”到I/O地址空间,用户看上去是在读写内存,但实际上是在访问I/O设备。这种方式很受欢迎,但它是如何实现的?
 
MMIO
请记住这个缩写,本节设备都是用MMIO实现的。
NEMU中的I/O设备
开始前,运行make menuconfig启用设备:

我们以时钟为例,观察NEMU是如何模拟I/O设备的。启用设备后,init_monitor($NEMU_HOME/src/monitor/monitor.c)会调用init_device:
IFDEF(CONFIG_DEVICE, init_device());  // 初始化设备
接下来进入init_device($NEMU_HOME/src/device/device.c):
void init_device() {
  IFDEF(CONFIG_TARGET_AM, ioe_init());
  init_map();
  IFDEF(CONFIG_HAS_TIMER, init_timer());  // 初始化时钟
  ......
}
在初始化时钟(init_timer)前,调用函数ioe_init和init_map. init_map用于申请一块足够大的内存,我们把这块内存称为【设备专用超级无敌大内存】。
接下来,函数init_timer用于初始化时钟:
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);
  IFNDEF(CONFIG_TARGET_AM, add_alarm_handle(timer_intr));
}
new_space(8)在【设备专用超级无敌大内存】中寻找一块大小为8字节的内存,并返回其首地址。接下来,调用add_mmio_map($NEMU_HOME/src/device/io/mmio.c)创建内存映射I/O设备:
#define NR_MAP 16
static IOMap maps[NR_MAP] = {};  // 保存所有的设备
static int nr_map = 0;
void add_mmio_map(const char *name, paddr_t addr, void *space, uint32_t len, io_callback_t callback) {
  ......
      
  maps[nr_map] = (IOMap){ .name = name, .low = addr, .high = addr + len - 1,
    .space = space, .callback = callback };
  
  .....
  nr_map ++;
}
函数add_mmio_map负责创建IOMap结构体(nemu/include/device/map.h),此结构体定义如下:
typedef struct {
    const char *name;  // 设备名
    paddr_t low;
    paddr_t high;  // 映射的起始和终止地址
    void *space;  // 映射的目标空间
    io_callback_t callback;  // 回调函数
} IOMap;
调用init_timer后,创建了如下IOMap:
{
    name = "rtc";
    low = CONFIG_RTC_MMIO;
    high = CONFIG_RTC_MMIO + 7;
    space = rtc_port_base;
    serial_io_handler = rtc_io_handler;
}
这里的CONFIG_RTC_MMIO定义为#define CONFIG_RTC_MMIO 0xa0000048. 为什么是这么奇怪的数字?我猜是历史原因。
提示
rtc是real-time clock的缩写,理解为时钟就好。
端口映射I/O设备通过函数mmio_read和mmio_write进行读写。以下是mmio_read($NEMU_HOME/src/device/io/mmio.c)的实现:
word_t mmio_read(paddr_t addr, int len) {
  return map_read(addr, len, fetch_mmio_map(addr));
}
fetch_mmio_map(addr)在maps数组中寻找包含addr(low <= addr <= high)的IOMap结构体。找到此结构体后,调用map_read($NEMU_HOME/src/device/io/map.c):
word_t map_read(paddr_t addr, int len, IOMap *map) {
  assert(len >= 1 && len <= 8);
  check_bound(map, addr);
  paddr_t offset = addr - map->low;
  invoke_callback(map->callback, offset, len, false); // prepare data to read
  word_t ret = host_read(map->space + offset, len);
  return ret;
}
也就是说,地址addr被映射为map->space + (addr - map->low). 还有印象吗?map->space在那块【设备专用超级无敌大内存】上。
在【设备专用超级无敌大内存】上读取数据前,还调用了map->callback, 时钟的callback被设置为rtc_io_handler,此函数的定义如下:
static void rtc_io_handler(uint32_t offset, int len, bool is_write) {
  assert(offset == 0 || offset == 4);
  if (!is_write && offset == 4) {
    uint64_t us = get_time();
    rtc_port_base[0] = (uint32_t)us;
    rtc_port_base[1] = us >> 32;
  }
}
现在一切都清楚了!NEMU使用get_time()获取64位的当前时间,将低32位和高32位写入rtc_port_base开始的8个字节处。rtc_port_base是初始化时钟时,通过new_space(8)在【设备专用超级无敌大内存】上申请的内存。
NEMU在每次读取时钟寄存器前,通过调用rtc_io_handler将get_time获取到的时间写入rtc_port_base处,这样就更新了时钟寄存器的内容。最后,程序通过host_read读取时钟寄存器,这就完成了对时钟设备的模拟。
很容易得出,mmio_read(CONFIG_RTC_MMIO, 4)和mmio_read(CONFIG_RTC_MMIO+4, 4)可以读取到时钟寄存器的低32位和高32位,将它们拼在一起就是当前时间。
PA 2.3
串口
串口的实现不需要添加任何代码,在am-kernels/kernels/hello下运行make ARCH=riscv32-nemu run, 输出如下:

接下来需要借助sprintf实现printf, 这两个函数大部分代码是重复的,但printf无法调用sprintf. 如何复用代码?答案是实现函数vsprintf, 然后在sprintf和printf中调用它。
提示
在实现
vsprintf时,建议实现格式符%c.
有了vsprintf,实现printf就是小菜一碟:
int printf(const char *fmt, ...) {
    #define PRINT_BUF_SIZE 1024
    char buffer[PRINT_BUF_SIZE];
    va_list ap;
    va_start(ap, fmt);
    int ret = vsprintf(buffer, fmt, ap);  // 调用vsprintf
    va_end(ap);
    if (ret > PRINT_BUF_SIZE) {
        ret = PRINT_BUF_SIZE;
    }
    for (int i = 0; i < ret; i++) {
        putch(buffer[i]);
    }
    return ret;
}
进入am-kernels/tests/alu-tests,运行make ARCH=riscv32-nemu run, 几十秒后看到HIT GOOD TRAP, 非常好!
时钟
让我们看看客户程序是怎么访问时钟的。以下是一个使用时钟设备的例子:
while(io_read(AM_TIMER_UPTIME).us / 1000000 < sec) ;
用户通过io_read获取当前时间(AM_TIMER_UPTIME). AM_TIMER_UPTIME表示获取系统自启动后运行的时间,定义于$AM_HOME/am/include/amdev.h:
AM_DEVREG( 6, TIMER_UPTIME, RD, uint64_t us);
展开这个宏后,如下所示:
enum { AM_TIMER_UPTIME = (6) }; 
typedef struct { 
    uint64_t us; 
} AM_TIMER_UPTIME_T;
除了定义AM_TIME_UPTIME时,还定义了结构体AM_TIMER_UPTIME_T,它包含成员us, 用于保存获取到的时间。
io_read也是一个宏,展开它得到:
{ 
    AM_TIMER_UPTIME_T __io_param; 
    ioe_read(AM_TIMER_UPTIME, &__io_param); 
    __io_param; 
}
也就是说,访问设备是通过ioe_read实现的。ioe_read会根据AM_TIMER_UPTIME,从查找表中(look-up table, lut)找到对应的函数并调用它,这里调用的是__am_timer_uptime($AM_HOME/am/src/platform/nemu/ioe/timer.c).
还记得MMIO的目标吗?像访问内存那样访问设备。在$AM_HOME/am/src/platform/nemu/include/nemu.h中定义了时钟寄存器的地址RTC_ADDR:
#define DEVICE_BASE     0xa0000000
#define RTC_ADDR        (DEVICE_BASE + 0x0000048)
这和前面提到的CONFIG_RTC_MMIO是同一个值。我们使用$AM_HOME/am/src/riscv/riscv.h提供的函数inl读取RTC_ADDR处的值。函数inl会被编译器翻译为lw指令。在NEMU中,lw指令会依次调用vaddr_read → paddr_read → mmio_read.
word_t paddr_read(paddr_t addr, int len) {
  if (likely(in_pmem(addr))) return pmem_read(addr, len);
  IFDEF(CONFIG_DEVICE, return mmio_read(addr, len));
  out_of_bound(addr);
  return 0;
}
可以看出,paddr_read会根据传入的地址,选择调用pmem_read或是mmio_read.
最后,在$AM_HOME/am/src/platform/nemu/ioe/timer.c实现__am_timer_uptime:
void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) {
  uint64_t low = inl(RTC_ADDR);
  uint64_t high = inl(RTC_ADDR + 4);
  up_time->us = (high << 32) | low;
}
在am-kernels/tests/am-tests下运行make ARCH=riscv32-nemu run mainargs=t,输出如下:

提示
mainargs的参数如何指定?请查看am-kernels/tests/am-tests/src/main.c.
运行演示程序
注释掉am-kernels/kernels/demo/include/io.h中的#define HAS_GUI. 实现$AM_HOME/klib/src/stdlib.c中的malloc:
void *malloc(size_t size) {
  // On native, malloc() will be called during initializaion of C runtime.
  // Therefore do not call panic() here, else it will yield a dead recursion:
  //   panic() -> putchar() -> (glibc) -> malloc() -> panic()
#if !(defined(__ISA_NATIVE__) && defined(__NATIVE_USE_KLIB__))
  static size_t offset = 0;
  if (size % 4 != 0) {  // 地址对齐
    size += 4 - size % 4;
  }
  void* tmp = (void*)((size_t)heap.start + offset);
  offset += size;
  return tmp;
#endif
  return NULL;
}
之前$AM_HOME/klib/src/string.c中未实现的几个库函数,例如memmove,现在也需要全部实现。完成后,在am-kernels/kernels/demo下运行测试用例,部分如下所示:
高尔顿钉板:

汉诺塔:

旋转甜甜圈:

提示
如果出现很多的
%c,那是因为你没有实现%c格式符,在vsprintf中实现它吧!
Bad Apple(这视频还挺好看的):

提示
如果很卡,请在menuconfig中关闭所有trace、diff选项。我这边播放很流畅。
dtrace
这个实在不想写了,快要写吐了,我把输出部分写在函数fetch_mmio_map里。
键盘
读取键盘只需3行代码,原理和时钟是一样的($AM_HOME/am/src/platform/nemu/ioe/input.c):
void __am_input_keybrd(AM_INPUT_KEYBRD_T *kbd) {
  uint32_t key = inl(KBD_ADDR);
  kbd->keydown = key & KEYDOWN_MASK ? true : false;
  kbd->keycode = key & ~KEYDOWN_MASK;
}
在am-kernels/tests/am-tests下运行make ARCH=riscv32-nemu run mainargs=k, 按下键盘后,能看到如下输出:

VGA
VGA的屏幕大小在init_vga($NEMU_HOME/src/device/vga.c)中已经设置好了:
void init_vga() {
  vgactl_port_base = (uint32_t *)new_space(8);
  vgactl_port_base[0] = (screen_width() << 16) | screen_height();
  add_mmio_map("vgactl", CONFIG_VGA_CTL_MMIO, vgactl_port_base, 8, NULL);
  vmem = new_space(screen_size());
  add_mmio_map("vmem", CONFIG_FB_ADDR, vmem, screen_size(), NULL);
  IFDEF(CONFIG_VGA_SHOW_SCREEN, init_screen());
  IFDEF(CONFIG_VGA_SHOW_SCREEN, memset(vmem, 0, screen_size()));
}
从寄存器读取屏幕的宽和高即可:高16位是屏幕宽度,低16是屏幕高度。在$AM_HOME/am/src/platform/nemu/ioe/gpu.c中添加获取屏幕大小的逻辑:
void __am_gpu_init() {
  int i;
  uint32_t size = inl(VGACTL_ADDR);
  int w = size >> 16;  // 高16位是屏幕宽度
  int h = size & 0xffff;  // 低16位是屏幕高度
  uint32_t *fb = (uint32_t*)(uintptr_t)FB_ADDR;
  for (i = 0; i < w * h; i++) fb[i] = i;
  outl(SYNC_ADDR, 1);
}
void __am_gpu_config(AM_GPU_CONFIG_T *cfg) {
  uint32_t size = inl(VGACTL_ADDR);
  int height = size & 0xffff;
  int width = size >> 16;
  *cfg = (AM_GPU_CONFIG_T) {
    .present = true, .has_accel = false,
    .width = width, 
    .height = height,
    .vmemsz = width * height * sizeof(uint32_t)  // 屏幕大小
  };
}
另外,需要实现fbdraw功能:绘制以(x, y)为左上角、大小为(w, h)的矩形。这一功能实现于函数__am_gpu_fbdraw:
void __am_gpu_fbdraw(AM_GPU_FBDRAW_T *ctl) {
  uint32_t size = inl(VGACTL_ADDR);
  int vga_w = size >> 16;
  int vga_h = size & 0xffff;
  int x = ctl->x, y = ctl->y;
  int w = ctl->w, h = ctl->h;
  uint32_t* pixels = ctl->pixels;
  uint32_t *fb = (uint32_t*)(uintptr_t)FB_ADDR;
  for (int i = 0; i < h; i++) {
    int row = y + i;
    if (row >= vga_h) {
      break;
    }
    for (int j = 0; j < w; j++) {
      int col = x + j;
      if (col >= vga_w) {
        break;
      }
      fb[row * vga_w + col] = pixels[i*w + j];
    }
  }
  
  if (ctl->sync) {
    outl(SYNC_ADDR, 1);
  }
}
完成后,删除__am_gpu_init中的代码,最终效果如下(带有旋转的动画效果):

每执行完一条指令,device_update($NEMU_HOME/src/device/device.c)就会被调用,它进一步调用vga_update_screen($NEMU_HOME/src/device/vga.c). 这个函数目前是空的,我们需要在sync==1时更新屏幕。
用户程序通过调用outl(SYNC_ADDR, 1)设置sync,查看SYNC_ADDR,发现它被定义为VGACTL_ADDR+4, 即vgactl_port_base[1]:
void vga_update_screen() {
  // TODO: call `update_screen()` when the sync register is non-zero,
  // then zero out the sync register
  int sync = vgactl_port_base[1];  // 获取sync的值
  if (sync == 1) {
    update_screen();
    vgactl_port_base[1] = 0;
  }
}
完成后,在am-tests下运行make ARCH=riscv32-nemu run mainargs=v,输出如下:

取消注释am-kernels/kernels/demo/include/io.h中的#define HAS_GUI,运行之前的测试用例,部分如下所示:
高尔顿钉板:

生命游戏:

超级玛丽(可能是哪里没做好,FPS只有5,卡成狗屎,根本没办法玩):

贪吃蛇:

PA 2.3到此结束!
                    
                
                
            
        
浙公网安备 33010602011771号