前言在把 PA2.3 做完之后,顺手把 ysyx 的 npc 中的设备模仿 nemu 也实现了一下,分别是串口、时钟、vga,本文讨论一下这三种设备的区别。事实上,这三种设备循序渐进,虽然是三种设备,但其实在 CPU 眼里,它就是同一种东西,都是读写内存。
设备在 nemu 的模型中,程序访问设备的时候,会提供统一的接口,比如 io_read、io_write,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #include <amtest.h> void rtc_test () { AM_TIMER_RTC_T rtc; int sec = 1 ; while (1 ) { while (io_read(AM_TIMER_UPTIME).us / 1000000 < sec) ; rtc = io_read(AM_TIMER_RTC); printf ("%d-%d-%d %02d:%02d:%02d GMT (" , rtc.year, rtc.month, rtc.day, rtc.hour, rtc.minute, rtc.second); if (sec == 1 ) { printf ("%d second).\n" , sec); } else { printf ("%d seconds).\n" , sec); } sec++; } }
C
这里读取了 AM_TIMER_UPTIME、AM_TIMER_RTC 中的元素,这是抽象寄存器。io_read 是运行时环境提供的接口,事实上,它封装了 ioe:
1 2 3 4 5 6 7 8 #define io_read(reg) \ ({ reg##_T __io_param; \ ioe_read(reg, &__io_param); \ __io_param; }) #define io_write(reg, ...) \ ({ reg##_T __io_param = (reg##_T) { __VA_ARGS__ }; \ ioe_write(reg, &__io_param); })
C
ioe 是一种扩展的 io,它提供了一组架构无关性的 API:
1 2 3 bool ioe_init () ;void ioe_read (int reg, void *buf) ;void ioe_write (int reg, void *buf) ;
C
第一个 API 用于进行 IOE 相关的初始化操作。后两个 API 分别用于从编号为 reg 的寄存器中读出内容到缓冲区 buf 中, 以及往编号为reg寄存器中写入缓冲区buf中的内容。 在IOE中, 我们希望采用一种架构无关的”抽象寄存器”, 这个reg其实是一个功能编号。 我们约定在不同的架构中,同一个功能编号的含义也是相同的, 这样就实现了设备寄存器的抽象。
ioe_read() 和ioe_write() 都是通过抽象寄存器的编号索引到一个处理函数, 然后调用它. 处理函数的具体功能和寄存器编号相关。
串口串口怎么实现?我觉得还是注册一个地址,然后往这个地址中写入数据就行了。事实上,nemu 的串口就是这样实现的:
比如,我们要实现 putch 功能,我们的愿望是一旦应用程序调用了 putch,比如 putch(‘A’),那么运行时环境就会打印出来一个 ‘A’。
串口是一种设备,putch 会将’A’写在某个地址上,而 nemu 中有一种底层的机制:访问内存的 handler 检测到这个写入地址是设备而不是内存之后,就会调用一个函数,让它去处理,比如显示这个字符。简单吧
运行时环境的接口:
1 2 3 4 5 6 7 8 9 static inline void outb (uintptr_t addr, uint8_t data) { *(volatile uint8_t *)addr = data; }#define DEVICE_BASE 0xa0000000 #define MMIO_BASE 0xa0000000 #define SERIAL_PORT (DEVICE_BASE + 0x00003f8) void putch (char ch) { outb(SERIAL_PORT, ch); }
C
这里的核心就是 outb 的实现,它是一个访存操作,而且访问的地址是一个特殊的地址。这个访存操作会被编译器编译成sbu 指令:
1 2 3 4 80000088 <putch>: 80000088: a00007b7 lui a5,0xa0000 8000008c: 3ea78c23 sb a0,1016(a5) # a00003f8 <_end+0x1fff73f8> 80000090: 00008067 ret
ASM
之后呢?那当然是把二进制文件丢到 CPU 上执行了,当 cpu 执行到:sb a0,1016(a5)
的时候,这时候访存模块就被激活了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 extern "C" void memo_access_write (int waddr, int wdata, char wmask) { switch (wmask) { case 0x1 : paddr_write(waddr, 1 , wdata); break ; case 0x3 : paddr_write(waddr, 2 , wdata); break ; case 0xf : paddr_write(waddr, 4 , wdata); break ; default : paddr_write(waddr, 4 , wdata); } }
C
这是 verilog 中的 DPI-C 函数,它会调用 paddr_write(waddr, 1, wdata)
,之后进入:
1 2 3 4 5 6 7 8 9 void paddr_write (paddr_t addr, int len, word_t data) { IFDEF(CONFIG_MTRACE, display_memory_write(addr, len, data)); if (likely(in_pmem(addr))) { pmem_write(addr, len, data); return ; } IFDEF(CONFIG_DEVICE, mmio_write(addr, len, data); return ); out_of_bound(addr); }
C
这时候,就会检查地址,如果不是访存,那么就会进行到IFDEF(CONFIG_DEVICE, mmio_write(addr, len, data); return )
:
1 2 3 void mmio_write (paddr_t addr, int len, word_t data) { map_write(addr, len, data, fetch_mmio_map(addr)); }
C
这个函数会根据地址,获取相应的注册了的 mmio_map,mmio_map 是系统启动的时候注册的内存映射:
1 2 3 4 void init_serial () { serial_base = new_space(8 ); add_mmio_map("serial" , CONFIG_SERIAL_MMIO, serial_base, 8 , serial_io_handler); }
C
拿到 mmip_map 之后,就会进入:
1 2 3 4 5 6 7 8 void map_write (paddr_t addr, int len, word_t data, IOMap* map ) { assert(len >= 1 && len <= 8 ); check_bound(map , addr); paddr_t offset = addr - map ->low; host_write(map ->space + offset, len, data); invoke_callback(map ->callback, offset, len, true ); IFDEF(CONFIG_DTRACE, trace_dwrite(addr, len, data, map );); }
C
分别做一些检查,然后计算 offset,然后准确地将这个数据写入到目标地址(准备数据)。然后激活回调函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static void serial_putc (char ch) { MUXDEF(CONFIG_TARGET_AM, putch(ch), putc(ch, stderr )); }static void serial_io_handler (uint32_t offset, int len, bool is_write) { assert(len == 1 ); switch (offset) { case CH_OFFSET: if (is_write) serial_putc(serial_base[0 ]); else printf ("do not support read" ); break ; default : printf ("do not support offset = %d" , offset); } }
C
可以看到,回调函数其实是在实现功能。
以上就是串口的整个过程。
时钟事实上,时钟和串口很接近,但是也有一些不同。
首先是注册 mmio_map:
1 2 3 4 5 6 7 8 9 10 11 12 13 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 ; } }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); }
C
这里的回调函数是在将时间放到两个字,也就是准备数据。
当应用程序调用时间接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include <amtest.h> void rtc_test () { AM_TIMER_RTC_T rtc; int sec = 1 ; while (1 ) { while (io_read(AM_TIMER_UPTIME).us / 1000000 < sec) ; rtc = io_read(AM_TIMER_RTC); printf ("%d-%d-%d %02d:%02d:%02d GMT (" , rtc.year, rtc.month, rtc.day, rtc.hour, rtc.minute, rtc.second); if (sec == 1 ) { printf ("%d second).\n" , sec); } else { printf ("%d seconds).\n" , sec); } sec++; } }
C
实际上,在调用这个接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static inline uint32_t inl (uintptr_t addr) { return *(volatile uint32_t *)addr; }#define DEVICE_BASE 0xa0000000 #define RTC_ADDR (DEVICE_BASE + 0x0000048) void __am_timer_uptime(AM_TIMER_UPTIME_T* uptime) { uint32_t high = inl(RTC_ADDR + 4 ); uint32_t low = inl(RTC_ADDR); uptime->us = (uint64_t )low + (((uint64_t )high) << 32 ); }void __am_timer_rtc(AM_TIMER_RTC_T* rtc) { rtc->second = 0 ; rtc->minute = 0 ; rtc->hour = 0 ; rtc->day = 0 ; rtc->month = 0 ; rtc->year = 1900 ; }
C
可以看到,它和串口几乎一样,都是在读取数据。这个读取会被翻译成汇编指令:
1 2 3 4 5 6 7 800013b4 <__am_timer_uptime>: 800013b4: a00007b7 lui a5,0xa0000 800013b8: 04c7a703 lw a4,76(a5) # a000004c <_end+0x1ff6304c> 800013bc: 0487a783 lw a5,72(a5) 800013c0: 00e52223 sw a4,4(a0) 800013c4: 00f52023 sw a5,0(a0) 800013c8: 00008067 ret
ASM
之后,会被CPU执行,然后读取:
1 2 3 word_t mmio_read (paddr_t addr, int len) { return map_read(addr, len, fetch_mmio_map(addr)); }
C
同样,这里也会根据地址获取 mmio_map,之后就进入了 map_read:
1 2 3 4 5 6 7 8 9 10 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 ); word_t ret = host_read(map ->space + offset, len); IFDEF(CONFIG_DTRACE, trace_dread(addr, len, map )); return ret; }
C
同样就是安全检查,然后这里首先是激活回调函数:因为读取之前,首先得准备数据:
1 2 3 4 5 6 7 8 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 ; } }
C
这路需要注意的是,只有当 offset == 4的时候,才会更新寄存器。也就是当:
1 2 3 4 5 void __am_timer_uptime(AM_TIMER_UPTIME_T* uptime) { uint32_t high = inl(RTC_ADDR + 4 ); uint32_t low = inl(RTC_ADDR); uptime->us = (uint64_t )low + (((uint64_t )high) << 32 ); }
C
第一次获取 high 的时候 更新,获取 low 的时候,不会更新时间寄存器。
当时间准备好后,就可以读取以及返回了:
1 word_t ret = host_read(map ->space + offset, len);
C
这样就完成了时钟。
VGAVGA 前两个设备很不一样,因为它没有回调函数。这是为什么呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 ...static inline void update_screen () { SDL_UpdateTexture(texture, NULL , vmem, SCREEN_W * sizeof (uint32_t )); SDL_RenderClear(renderer); SDL_RenderCopy(renderer, texture, NULL , NULL ); SDL_RenderPresent(renderer); }void vga_update_screen () { uint32_t sync = vgactl_port_base[1 ]; if (sync) { update_screen(); vgactl_port_base[1 ] = 0 ; } }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())); }
C
因为,视频显示并不是说需要一个特定的事件(比如读写某些特定地址)去触发,而是需要每时每刻都将画面更新一下。那我们应该选用什么样子的事件呢?没错就是 CPU 每执行一个指令,我们就刷新:
1 2 3 4 5 6 7 void stepi () { step(); IFDEF(CONFIG_DIFFTEST, difftest_step(top->rootp->Top__DOT__core__DOT__pc_reg, 0 )); IFDEF(CONFIG_DEVICE, device_update()); }
C
device_update():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 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());#ifndef CONFIG_TARGET_AM SDL_Event event; while (SDL_PollEvent(&event)) { switch (event.type) { case SDL_QUIT: npc_state.state = NPC_QUIT; break ;#ifdef CONFIG_HAS_KEYBOARD case SDL_KEYDOWN: case SDL_KEYUP: { uint8_t k = event.key.keysym.scancode; bool is_keydown = (event.key.type == SDL_KEYDOWN); send_key(k, is_keydown); break ; }#endif default : break ; } }#endif }
C
至于访问设备,也就是写(没错,只有写):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void __am_gpu_fbdraw(AM_GPU_FBDRAW_T* ctl) { int x = ctl->x, y = ctl->y, w = ctl->w, h = ctl->h; if (!ctl->sync && (w == 0 || h == 0 )) return ; uint32_t * pixels = ctl->pixels; uint32_t * fb = (uint32_t *)(uintptr_t )FB_ADDR; uint32_t screen_w = inl(VGACTL_ADDR) >> 16 ; for (int i = y; i < y + h; i++) { for (int j = x; j < x + w; j++) { fb[screen_w * i + j] = pixels[w * (i - y) + (j - x)]; } } if (ctl->sync) { outl(SYNC_ADDR, 1 ); } }
C
其它的就没什么区别了:
1 2 3 4 5 6 7 8 void map_write (paddr_t addr, int len, word_t data, IOMap* map ) { assert(len >= 1 && len <= 8 ); check_bound(map , addr); paddr_t offset = addr - map ->low; host_write(map ->space + offset, len, data); invoke_callback(map ->callback, offset, len, true ); IFDEF(CONFIG_DTRACE, trace_dwrite(addr, len, data, map );); }
C
这里写完之后,就结束了。(激活回调函数不起作用)。
总结这篇文章主要讨论了在 NEMU 和 ysyx 项目中的三种设备(串口 、时钟 、VGA )的实现和它们的区别。文章以 PA2.3 为背景,模仿 NEMU 实现了这三种设备,文章按设备类型进行了详细的实现分析,并对每个设备的访问、触发机制进行了说明。
前言 :
在 NEMU 中,设备访问是通过统一的接口 io_read
和 io_write
实现的。文章讨论了通过这些接口实现的三种设备(串口、时钟、VGA),它们本质上在 CPU 眼里是读写内存的操作。
串口设备 :
串口的实现通过将字符写入特定的内存地址实现,NEMU 中通过 outb
操作将字符输出到设备地址。这个地址被访问时,会触发一系列的访存操作,最终通过回调函数将字符打印到终端。
通过 paddr_write
,内存访问会检测是否是设备访问,然后调用 serial_io_handler
处理写入并完成输出。
时钟设备 :
时钟和串口的工作原理类似,但时钟设备通过 io_read
读取当前时间。每次读取时会触发相应的回调函数 rtc_io_handler
,在读取前更新系统时间寄存器。
时钟的读取操作首先通过汇编指令执行访存操作,然后调用 mmio_read
来获取时间数据。
VGA 设备 :
VGA 与前两个设备有所不同,它没有依赖回调函数,而是通过周期性的屏幕刷新机制实现。
文章解释了 vga_update_screen
函数是如何通过定时器定期刷新屏幕,屏幕内容的更新通过写入帧缓冲区 (vmem
) 完成。
每当有新的图形数据写入时,VGA 控制器会将数据写入指定地址,并通过 SDL 进行屏幕刷新。
设备间的区别 :
串口 :依赖回调函数,写入地址时触发输出操作。
时钟 :在读取时依赖回调函数,读取前准备数据。
VGA :不依赖回调函数,而是周期性刷新屏幕。
结论:文章通过分析串口、时钟和 VGA 设备的实现,展示了不同设备在硬件模拟器中的处理机制。虽然这三种设备在 CPU 眼里都表现为内存读写,但它们在触发机制和使用场景上存在显著区别。串口和时钟依赖回调函数,而 VGA 则通过定期更新机制进行屏幕刷新。