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号