TOC
Open TOC
ICS PA IOE
volatile 关键字
考虑这样一段程序:
使用 gcc -O2 demo.c,并 objdump -d a.out 可得:
若去掉 volatile 关键字,相同的操作会产生:
对 *p
的赋值操作就都被优化掉了。
简单来说,volatile 关键字告知编译器,代理(而不是变量所在的程序)可以改变该变量的值。我们可以认为这里指针 p 所指向的对象是一个屏幕大小的数据,屏幕控制器可以改变该数据的值。
进一步的解释可见 https://en.cppreference.com/w/c/language/volatile。
设备与 CPU
访问设备 = 读出数据 + 写入数据 + 控制状态
设备向 CPU 暴露设备寄存器的接口,把设备内部的复杂行为 (甚至一些模拟电路的特性) 进行抽象,CPU 只需要使用这一接口访问设备,就可以实现期望的功能。
对设备寄存器的编址方式(I/O 编址方式)有两种:
- 端口映射 I/O (port-mapped I/O),扩展性较差
IBM PC 兼容机对常见设备端口号的分配有专门的规定
- 内存映射 I/O (memory-mapped I/O, MMIO),将一部分物理内存的访问“重定向”到 I/O 地址空间中,CPU 尝试访问这部分物理内存的时候,实际上最终是访问了相应的 I/O 设备,CPU 却浑然不知
状态机视角下的输入输出
我们需要将设备分为两部分:
- 数字电路:如 CPU 可以从键盘控制器中读出按键信息
- 模拟电路:如键盘通过检查按键位置的电容变化来判断是否有按键被按下
- 执行普通指令时,状态机按照 TRM 的模型进行状态转移
- 执行设备输出相关的指令时,状态机除了更新 PC 之外,其它状态均保持不变,但设备的状态和物理世界则会发生相应的变化
- 执行设备输入相关的指令时,状态机的转移将会“分叉”:状态机不再像 TRM 那样有唯一的新状态了,状态机具体会转移到哪一个新状态,将取决于执行这条指令时设备的状态
S = <R, M>
,上文介绍的端口 I/O 和内存映射 I/O 都是通过寄存器 R
来进行数据交互的(从端口所对应的设备寄存器或 I/O 地址空间读写数据到 CPU 的寄存器)。
通过内存 M
来进行数据交互的输入输出方式叫 DMA。
映射和 I/O 方式
map.h
中为映射定义了一个结构体类型:
并声明了读写的统一接口 map_read
和 map_write
。
NEMU 中设备的行为是我们自定义的,与 REF 中的标准设备的行为不完全一样(例如 NEMU 中的串口总是就绪的,但 QEMU 中的串口也许并不是这样),这导致在 NEMU 中执行输入指令的结果会和 REF 有所不同,所以 map.h
还另外定义了 find_mapid_by_addr
函数,其中调用了 difftest_skip_ref
。
map.c
中实现了对映射的管理,包括 I/O 空间的分配及其映射,及 map_read
和 map_write
。
port-io.c
是对端口映射 I/O 的模拟。add_pio_map()
函数用于为设备的初始化注册一个端口映射 I/O 的映射关系。pio_read()
和 pio_write()
是面向 CPU 的端口 I/O 读写接口。
mmio.c
是对内存映射 I/O 的模拟。paddr.c
中的 paddr_read()
和 paddr_write()
会判断地址 addr
落在物理内存空间还是设备空间,若落在物理内存空间,就会通过 pmem_read()
和 pmem_write()
来访问真正的物理内存;否则就通过 map_read()
和 map_write()
来访问相应的设备。
设备
NEMU 实现了串口、时钟、键盘、VGA、声卡、磁盘、SD 卡七种设备。
为了开启设备模拟的功能,需要在 menuconfig 选中相关选项。
NEMU 使用 SDL 库来实现设备的模拟,nemu/src/device/device.c
含有和 SDL 库相关的代码。另外还有 init_device()
函数和 device_update()
函数。init_monitor()
调用了 init_device()
函数,cpu_exec()
在执行每条指令之后就会调用 device_update()
函数。
将输入输出抽象成 IOE
IOE 提供三个 API:
在 IOE 中,我们希望采用一种架构无关的“抽象寄存器”,这个 reg
其实是一个功能编号,我们约定在不同的架构中,同一个功能编号的含义也是相同的,这样就实现了设备寄存器的抽象。
abstract-machine/am/include/amdev.h
中定义了常见设备的“抽象寄存器”编号和相应的结构。
为了方便地对这些抽象寄存器进行访问,klib 中提供了 io_read()
和 io_write()
这两个宏,它们分别对 ioe_read()
和 ioe_write()
这两个 API 进行了进一步的封装。
特别地,NEMU 作为一个平台,设备的行为是与 ISA 无关的,因此我们只需要在 abstract-machine/am/src/platform/nemu/ioe/
目录下实现一份 IOE,来供 NEMU 平台的架构共享。其中,abstract-machine/am/src/platform/nemu/ioe/ioe.c
中实现了上述的三个 IOE API。
串口
实现
nemu/src/device/serial.c
模拟了串口的功能。
另外补充宏的信息:
NEMU 的框架代码默认为 MMIO。
我们来分析一下,串口初始化的时候会根据 I/O 编址方式注册端口或者分配空间。
可以看到输出的 Log 信息:
对应的回调函数则调用了 serial_putc,根据 CONFIG_TARGET_AM 调用 putch 或 putc。
CONFIG_TARGET_AM 究竟是什么?
目前均为 n
测试
在 am-kernels/kernels/hello/
目录下键入:
需要注意的是,这个 hello 程序和我们在程序设计课上写的第一个 hello 程序所处的抽象层次是不一样的:这个 hello 程序可以说是直接运行在裸机上,可以在 AM 的抽象之上直接输出到设备 (串口);而我们在程序设计课上写的 hello 程序位于操作系统之上,不能直接操作设备,只能通过操作系统提供的服务进行输出,输出的数据要经过很多层抽象才能到达设备层。
native + klib
依然是初始化问题。另外,hello.c 调用了 putstr 和 putch:
而在 native AM 下,putch 依赖于 putchar:
注意 AM 把 putch() 放在了 TRM 中,而不是 IOE 中。
riscv32-nemu + klib
依然会出现 __strcpy_avx2
修改了监视点中 head 指针的值,导致 ->
触发段错误。
调试如下:
为此我们再次忍气吞声的将监视点相关的代码注释掉,就可以运行了…
在 nemu AM 下,putch 依赖于 outb:
outb 是 ISA 相关的,riscv 的定义在 abstract-machine/am/src/riscv/riscv.h 中:
而 SERIAL_PORT 又定义在 abstract-machine/am/src/platform/nemu/include/nemu.h 中:
联系之前的回调函数,putch -> outb -> serial_io_handler -> serial_putc -> putc,实际上最终还是调用了库函数,设备只是其中的一层抽象。
在 native + glibc
的环境下当然也可以运行。
mainargs
在 AM 中,main()
函数允许带有一个字符串参数,这一参数通过 mainargs
指定,并由 AM 的运行时环境负责将它传给 main()
函数,供 AM 程序使用。具体的参数传递方式和架构相关。
对于 native 而言,参数传递是在 am-kernels/kernels/nemu/Makefile 中进行的:
而对于 $ISA-nemu,参数传递是 nemu/src/filelist.mk 中进行的:
配合 abstract-machine/am/src/platform/nemu/trm.c 中的 MAINARGS:
printf
有了 putch()
,我们就可以在 klib 中实现 printf()
了:
限制一次传输的字符数。
另外还可以在 klib-tests 中写个测试用例:
我们可以观察 putch 的反汇编结果:
可以看到串口的地址 0xa00003f8。
时钟
nemu/src/device/timer.c
模拟了 i8253 计时器的功能:
TODO: timer_intr 有什么用?
另外补充宏的信息:
这段 MMIO 空间会被映射到 RTC 寄存器。abstract-machine/am/include/amdev.h
中为时钟的功能定义了两个抽象寄存器:
AM_TIMER_RTC
,AM 实时时钟 (RTC, Real Time Clock),可读出当前的年月日时分秒。PA 中暂不使用。
AM_TIMER_UPTIME
,AM 系统启动时间,可读出系统启动后的微秒数。
我们手动展开 TIMER_UPTIME:
实现
下面我们需要在 abstract-machine/am/src/platform/nemu/ioe/timer.c
中实现 AM_TIMER_UPTIME
的功能,最终实现如下:
其中 inl
定义在 abstract-machine/am/src/riscv/riscv.h
:
而 RTC_ADDR
定义在 abstract-machine/am/src/platform/nemu/include/nemu.h
:
与宏 CONFIG_RTC_MMIO 相对应。
测试
测试用例在 am-kernel/tests/am-tests 中,我们键入:
即可看到程序每隔 1 秒往终端输出一行信息。
我们来看看测试用例是啥:
分析
关键在于 io_read(AM_TIMER_UPTIME)
,下面分析其执行过程:
可得函数体为:
宏的值也许就是结构体变量 __io_param
,这样才能访问其 us
成员。
参考 abstract-machine/am/src/platform/nemu/ioe/ioe.c
:
可得实际的函数调用为:
这便是我们要实现的函数。
另外注意到 nemu/src/device/timer.c
的回调函数中调用了 get_time()
,定义在 nemu/src/utils/timer.c
:
相关宏的信息为:
注意这里时间的获取取决于 CONFIG_TARGET_AM:
总体来说:
- 回调函数通过
get_time()
获取启动后的时间,并存放在地址 rtc_port_base 处。
__am_timer_uptime
的实现通过向 uptime->us 赋值的方式更新抽象寄存器中保存的时间。
io_read
读取抽象寄存器中保存的时间。
- 测试用例通过如下方式每隔一秒打印一条信息:
注意 sec 会不断增长,printf 也需要实现更多的输出格式。
姗姗来迟的忠告:不要在 native 链接到 klib 时运行 IOE 相关的测试
native
的 IOE 是基于 SDL 库实现的,它们假设常用库函数的行为会符合 glibc 标准,但我们自己实现的 klib 通常不能满足这一要求。因此 __NATIVE_USE_KLIB__
仅供测试 klib 实现的时候使用,我们不要求在定义 __NATIVE_USE_KLIB__
的情况下正确运行所有程序。
所以 native + klib
就是 XXX
跑分
跑分时请关闭 NEMU 的监视点,trace(注意给之前的 iringbuf 加上预编译)以及 DiffTest,以获得较为真实的跑分。
在 am-kernel/benchmarks/
目录下:
make ARCH=riscv32-nemu run
注意修改了测试程序中的 uptime_ms:
并在 Stop timer 处增加了:
可以观察输出:
很奇怪,若不修改 uptime_ms,由于 User_Time 会变得极小,跑分就会极高:
make ARCH=native run
make ARCH=riscv32-nemu run
同样需要修改 core_portme.c 中的 uptime_ms
make ARCH=native run
make ARCH=riscv32-nemu run
同样需要修改 uptime_ms
make ARCH=native run
可以通过 mainargs 指定参数,包括 test
, train
, ref
和 huge
。
microbench 中有一个叫 bf
的测试项目,它是 Brainf**k 语言的一个解释器。
跑分好差…
先完成,后完美 - 抑制住优化代码的冲动
运行红白机模拟器
malloc,之前搭 klib 测试的框架时已经实现了:
正确实现时钟后,你就可以在 NEMU 上运行一个字符版本的 FCEUX 了。修改 fceux-am/src/config.h
中的代码,把 HAS_GUI
宏注释掉,FCEUX 就会通过 putch()
来输出画面:
极具艺术感…
设备访问的踪迹 - dtrace
类似 mtrace,定义了宏后,只要修改 map.c 即可:
对于 hello,设备访问信息如下:
就是不断向地址 0xa00003f8 写入字符。
而对于时钟,设备访问信息如下:
先读取低四字节,再读取高四字节。读取的数字会飞速增长,读取的速度也很快(导致 Log 容量瞬间爆炸)。
当到达每一个整秒时,通过 serial 写入字符。
键盘
nemu/src/device/keyboard.c
模拟了 i8042 通用设备接口芯片的功能:
另外补充宏的信息:
abstract-machine/am/include/amdev.h
中为键盘的功能定义了一个抽象寄存器:
AM_INPUT_KEYBRD
,AM 键盘控制器,可读出按键信息。keydown
为 true
时表示按下按键,否则表示释放按键。keycode
为按键的断码,没有按键时,keycode
为 AM_KEY_NONE
。
当按下一个键的时候,键盘将会发送该键的通码 (make code)
当释放一个键的时候,键盘将会发送该键的断码 (break code)
实现
下面我们需要在 abstract-machine/am/src/platform/nemu/ioe/input.c
中实现 AM_INPUT_KEYBRD
的功能,最终实现如下:
KBD_ADDR
定义在 abstract-machine/am/src/platform/nemu/include/nemu.h
:
大概思路是,由于 KBD_ADDR 中实际同时编码了通码和断码:
- 若最高位为 1,则就是断码,keydown 置 1,且 keycode 去掉最高位的 1
- 若最高位为 0,则是通码,keydown 置 0,keycode 不变
我们可以通过 dtrace 观察:
测试程序打印的结果为 Got (kbd): Z (56) UP
。
测试程序打印的结果为 Got (kbd): C (58) DOWN
。
测试
测试用例在 am-kernel/tests/am-tests 中,我们键入:
程序输出相应的按键信息,包括按键名、键盘码以及按键状态。
观察测试程序:
其中两个寄存器的回调函数定义在 abstract-machine/am/src/platform/nemu/ioe/ioe.c
:
而宏 AM_KEYS 定义在 abstract-machine/am/include/amdev.h
。
多个键同时被按下
回到 nemu/src/device/keyboard.c
。
在非 AM 下,首先需要解读一下宏:
最终实际得到了一个映射表,如 keymap[SDL_SCANCODE_F1] = _KEY_F1
,值为枚举。
回调函数调用了 key_dequeue 函数,解析队列中第一个的按键:
另外,nemu/src/device/device.c
中的 device_update 调用了 send_key,将被按下的按键的信息放到队列末尾:
通常情况下,多个键被按下的间隔必定大于 device_update 中处理事件队列的时间间隔,所以,这些键都会被单独解析出来。
所以只要设定一个间隔值,将间隔小于该值的多个键视为同时被按下即可。
运行红白机模拟器
可以使用键盘操控字符版本的红白机模拟器了。
VGA
nemu/src/device/vga.c
模拟了 VGA 的功能:
另外补充宏的信息:
- 地址 0xa0000100 的前四个字节存放屏幕的长和宽,后四个字节用于存放是否刷新的信息。
- 地址 0xa1000000 存放了
400x300
的像素信息,一个像素占 32 个 bit 的存储空间,R(red), G(green), B(blue), A(alpha) 各占 8bit,其中 VGA 不使用 alpha 的信息
可以对应 abstract-machine/am/src/platform/nemu/include/nemu.h
:
abstract-machine/am/include/amdev.h
中为 GPU 定义了五个抽象寄存器,在 NEMU 中只会用到其中的两个:
AM_GPU_CONFIG
,AM 显示控制器信息,可读出屏幕大小信息 width
和 height
AM_GPU_FBDRAW
,AM 帧缓冲控制器,可写入绘图信息,向屏幕 (x, y)
坐标处绘制 w*h
的矩形图像。图像像素按行优先方式存储在 pixels
中,每个像素用 32 位整数以 00RRGGBB
的方式描述颜色。若 sync
为 true
,则马上将帧缓冲中的内容同步到屏幕上
GPU_STATUS 也许对应 SYNC_ADDR。
GPU_MEMCPY 暂时未使用,也许在 __am_gpu_fbdraw
中 copy 时会有用。
实现
abstract-machine/am/src/platform/nemu/ioe/gpu.c
屏幕的尺寸信息可以通过观察宏 CONFIG_VGA_SIZE_800x600
得到。
通过 inl 读取似乎会出错,于是采用硬编码的方式
copy 时小心越界。
另外 __am_gpu_status
似乎没有被调用过,因为没有 io_read(AM_GPU_STATUS).ready
。
nemu/src/device/vga.c
nemu/src/device/device.c
中的 device_update 调用了 vga_update_screen。
测试
测试用例在 am-kernel/tests/am-tests 中,我们键入:
观察测试程序:
update 是更新像素信息,关键在 redraw:
通过写 AM_GPU_FBDRAW,来更新像素信息。
我们可以通过 dtrace 观察:
大概就是不断的写 vmem,一直等到可以刷新后再显示在屏幕上。
FPS 应该是 1s 内 vga_update_screen 的调用次数。
还可以通过 ftrace 观察,最好固定缩进,便于观察:
TODO
读写对应
- RD (perm) - 写寄存器 (参数) - 读地址 (SYNC_ADDR) - io_read
如 AM_GPU_STATUS
- WR (perm) - 读寄存器 (参数) - 写地址 (FB_ADDR) - io_write
如 AM_GPU_FBDRAW
另外,只有定义了 CONFIG_TARGET_AM,才能够使用 io_read 和 io_write。
违背这种对应会发生什么,我们考虑 io_write(AM_GPU_STATUS, false)
,通过下面的信息展开:
即为:
再展开 ioe_write:
即为 __am_gpu_status(&__io_param)
:
可以发现语义恰好相反…
运行红白机模拟器
恢复 HAS_GUI
宏,就可以使用键盘操控图形版本的红白机模拟器了。
FPS 大概 20+
声卡
在 NEMU 中,我们根据 SDL 库的 API 来设计一个简单的声卡设备。
使用 SDL 库来播放音频的过程非常简单:
- 通过
SDL_OpenAudio()
来初始化音频子系统,需要提供频率、格式等参数,还需要注册一个用于将来填充音频数据的回调函数
- SDL 库会定期调用初始化时注册的回调函数,并提供一个缓冲区,请求回调函数往缓冲区中写入音频数据
- 回调函数返回后,SDL 库就会按照初始化时提供的参数来播放缓冲区中的音频数据
在 AM 中,abstract-machine/am/include/amdev.h
中为声卡定义了四个抽象寄存器:
AM_AUDIO_CONFIG
,AM 声卡控制器信息,可读出存在标志 present
以及流缓冲区的大小 bufsize
。另外 AM 假设系统在运行过程中,流缓冲区的大小不会发生变化
AM_AUDIO_CTRL
,AM 声卡控制寄存器,可根据写入的 freq
, channels
和 samples
对声卡进行初始化
AM_AUDIO_STATUS
,AM 声卡状态寄存器,可读出当前流缓冲区已经使用的大小 count
AM_AUDIO_PLAY
,AM 声卡播放寄存器,可将 [buf.start, buf.end)
区间的内容作为音频数据写入流缓冲区。若当前流缓冲区的空闲空间少于即将写入的音频数据,此次写入将会一直等待,直到有足够的空闲空间将音频数据完全写入流缓冲区才会返回
宏信息如下:
还有一个:
实现
nemu/src/device/audio.c
首先是注册相关的空间:
nemu/src/device/device.c 中的 init_device 调用了 init_audio。
注意到 audio 控制相关的寄存器有一个回调函数,目的是为了初始化音频子系统:
这里音频子系统的回调函数如下:
userdata 不使用。
SDL 库参考在下面
注意当回调函数需要的数据量大于当前流缓冲区中的数据量,需要把 SDL 提供的缓冲区剩余的部分清零。
读取流缓冲区的前 nread 字节后,将流缓冲区整体移动,并清零后面多余的部分。
abstract-machine/am/src/platform/nemu/ioe/audio.c
补充定义了一些宏:
首先两个 RD 的寄存器:
然后是两个 WR 的寄存器:
注意若当前流缓冲区的空闲空间少于即将写入的音频数据,此次写入将会一直等待,直到有足够的空闲空间将音频数据完全写入流缓冲区才会返回。
这里的实现是直接返回了,实际上应该用一个 while 语句。
- native 实现参考:软硬件结合,配合 Linux 系统函数
abstract-machine/am/src/native/ioe/audio.c
测试
测试用例在 am-kernel/tests/am-tests 中,我们键入:
观察测试程序:
一次最多写 4096 字节。
软件、硬件和 AM 程序对数据的读写
- 硬件通过内存空间直接读写数据
- 软件通过抽象寄存器间接读写数据
- 测试程序(AM 程序)通过 io_write 和 io_read 读写抽象寄存器,进而影响数据内容
读写冲突
因为两个读写相关的回调函数 __am_audio_play
和 audio_play
都依赖于当前流缓冲区已经使用的大小,但是回调函数的调用时机并不确定,所以 audio_base[reg_count]
的值可能并不能在硬件和软件之间实现同步。
我们在两个回调函数中添加了打印语句观察 audio_base[reg_count]
值的变化:
可以发现值并未实现同步。
后续:
- 添加同步寄存器进行显式同步……
- 发现 NEMU 的播放速度是 native 的两倍……
- ……
FFmpeg
https://zhuanlan.zhihu.com/p/113248454
使用 ffmpeg 查看 mp3 文件的一些信息,比如采样率、声道数等:
最重要的信息如下:
可以观察到 mp3 文件采样率是 44100Hz,双声道
使用 ffmpeg 转换时要用到上面的信息。
使用的参数如下(并不确定如何选取):
指定编码器
指定文件格式,是大端模式还是小端模式
指定通道数,2 代表双通道
指定采样率,这里是 44100Hz
得到 pcm 文件后,我们可以试听一下:
感觉不错…
然后,我们修改测试程序中的参数:
并修改 am-kernels/tests/am-tests/src/tests/audio/audio-data.S
:
就可以进行全损播放了…
应该还要改 format 为 AUDIO_S16SYS,与上述的文件格式对应
SDL
参考:
重点关注 SDL_AudioSpec 中的 freq / format / samples。
运行红白机模拟器
正确实现声卡后,就可以在 NEMU 上运行带音效的 FCEUX 了。
在 fceux-am/src/config.h
中提供了一些配置选项,其中音效有三种配置,分别是高质量 (SOUND_HQ),低质量 (SOUND_LQ) 以及无音效 (SOUND_NONE)。
NEMU 平台默认选择低质量,以节省 FCEUX 的音效解码时间。
然而似乎会卡在音频初始化的地方…
PA2 Report
冯 · 诺依曼计算机系统
- 幻灯片播放
- 打字小游戏
- 简单的红白机模拟器 LiteNES(性能比较低)
- 完整的红白机模拟器 FCEUX
这些游戏都可以抽象成一个死循环:
RTFSC 指南
另一个值得 RTFSC 的项目是 LiteNES,可以看成是 NEMU 和 AM 程序的融合。
参考:
在 NEMU 上运行 NEMU
在 am-kernels/kernels/nemu/
目录下:
它会进行如下的工作:
- 保存 NEMU 当前的配置选项
- 加载一个新的配置文件,将 NEMU 编译到 AM 上,并把
mainargs
指示 bin 文件作为这个 NEMU 的镜像文件
- 恢复第 1 步中保存的配置选项
- 重新编译 NEMU,并把第 2 步中的 NEMU 作为镜像文件来运行
第 2 步把 NEMU 编译到 AM 时,配置系统会定义宏 CONFIG_TARGET_AM
,此时 NEMU 的行为和之前相比有所变化:
- sdb, DiffTest 等调试功能不再开启,因为 AM 无法提供它所需要的库函数(如文件读写,动态链接,正则表达式等)
- 通过 AM IOE 来实现 NEMU 的设备
程序观察,在 (nemu) 中键入 c 后,会有如下的显示信息:
注意这些信息是程序本身的输出,对于外层的 nemu,也会有类似的日志信息。区别在于:
- 外层:使用 C 的 IO
- 内层:使用 AM 的 IO
由于 printf 尚未实现完全,所以有些格式化信息没有显示出来。
printf continue
实现详见代码。
功能参考:
TODO:
必答题
程序是个状态机
- 取指
- 译码
- 执行
- 更新 PC
经典再现:
union + struct + bit fields
RTFSC
fetch_decode 为 ISA 无关:
通过 isa_fetch_decode 得到指令执行的辅助函数:
instr_fetch 则从内存中读取指令编码,table_main 通过树状结构层层调用,对指令进行编码匹配和操作数解析,并最终返回执行辅助函数的下标:
macro yyds
程序如何运行
我们来看 main 函数:
1、ioe_init
位于 abstract-machine/am/src/platform/nemu/ioe/ioe.c
:
注册回调函数,并对一些设备进行初始化,实际上对于 nemu-riscv32
而言,这三个函数都是空的。
回顾回调函数是如何被调用的:
2、video_init
,对打字小游戏的图形界面进行初始化:
首先读取屏幕的长和宽,然后以一个 blank 为单位绘制整个屏幕。接着在 texture 中存储 26 个大写字母的三种可能的颜色像素:
- WHITE - NORMAL
- GREEN - HIT
- RED - MISS
font 为一个大小为 16×26 的 char 数组
一些定义如下:
3、核心是一个 while 循环:
首先读取当前时间对应的帧,如 1 second
对应了 30 frames
,然后对每一帧进行 game_logic_update
:
若当前帧为 6 的倍数(1 秒生成 CPS 个字母),则生成一个新的字母:
对于 character 结构体,ch 代表字母,x 和 y 代表坐标,初始在屏幕最上面,v 是速度,t 为miss 后在屏幕上停留的时间,初始为 0(代表还没有 miss)。
NCHAR 代表同屏最多容纳 128 个字母。
回到 game_logic_update,对这 128 个字母进行逻辑判断,主要是根据 t 来更改 y 使其下落或更改 ch 令其消失。
game_logic_update 之后还有一个循环:
在极短的时间内读取键盘输入(大多数情况应该都是未读取到输入),若键入 ESC,则 halt(0):
否则根据一张索引表 lut 来判断是否 hit:
与屏幕上现有的字符一一比对,若击中则字符以固定的速度反向。
最后的 render 重新绘制屏幕:
首先绘制背景,然后绘制字符。
对于在终端的输出,采用退格的方式使其表现为在一行更新结果。要注意此处 printf 的底层还是 putch 哦。
如果在内层 NEMU(即编译到 AM 的 NEMU)上运行打字游戏:
此时的打字游戏又是经历了怎么样的过程才能读取按键 / 刷新屏幕的呢?
观察得,无屏幕输出,有终端输出,有键盘输入,时钟正常。
我们来分析一些设备的访问情况:
putch (inner) -> outb -> serial_io_handler -> serial_putc -> putch (outer) -> outb -> serial_io_handler -> serial_putc -> putc
注意区分 CONFIG_TARGET_AM 和 native AM。
inner: rtc_io_handler -> get_time -> get_time_internal -> io_read
outer: rtc_io_handler -> get_time -> get_time_internal -> library functions
inner 和 outer 之间通过抽象寄存器进行通信。
类似时钟,通过抽象寄存器进行通信。inner 仅处理 dequeue(因为回调函数调用的是 key_dequeue),outer 会处理键盘事件队列:
总结一下设备目前的实现情况:
| 外层 NEMU | 内层 NEMU |
---|
串口 | ✔ | ✔ |
时钟 | ✔ | ✔ |
键盘 | ✔ | ✔ |
VGA | ✔ | ✗ |
声卡 | ✗ | ✗ |
编译与链接 ①
在 nemu/src/engine/interpreter/rtl-basic.h
中,你会看到由 static inline
开头定义的各种 RTL 指令函数。选择其中一个函数,分别尝试去掉 static
,去掉 inline
或去掉两者,然后重新进行编译,你可能会看到发生错误。请分别解释为什么这些错误会发生 / 不发生?你有办法证明你的想法吗?
首先是头文件包含关系:
defined but not used
,这是编译选项所致。
没有错误。我们来看一个例子:
注意 a.c
中的 extern inline void f();
,所有包含头文件 a.h
的源文件中需要有且仅有一个这样的声明。
若没有 extern inline void f();
,会报错 undefined reference to f
。
若多于一个 extern inline void f();
,会报错 multiple definition of f
。
若没有 extern inline void f();
,并在 a.h
中加上声明 void f();
,会报错 multiple definition of f
。
参考 https://stackoverflow.com/questions/6312597/is-inline-without-static-or-extern-ever-useful-in-c99
注意到 NEMU 中的编译选项中有 -O2
,我们开启 -O2
后,没有 extern inline void f();
也可以运行了!
换言之,只要编译器成功内联优化,就没啥问题了。
重定义。
编译与链接 ②
- 在
nemu/include/common.h
中添加一行 volatile static int dummy;
,然后重新编译 NEMU。请问重新编译后的 NEMU 含有多少个 dummy
变量的实体?你是如何得到这个结果的?
- 添加上题中的代码后,再在
nemu/include/debug.h
中添加一行 volatile static int dummy;
,然后重新编译 NEMU。请问此时的 NEMU 含有多少个 dummy
变量的实体?与上题中 dummy
变量实体数目进行比较,并解释本题的结果。
- 修改添加的代码,为两处
dummy
变量进行初始化:volatile static int dummy = 0;
,然后重新编译 NEMU。你发现了什么问题?为什么之前没有出现这样的问题?
1、查看 bss 段,未初始化的全局变量:
数了一下,共有 33 个,应该是所有直接或间接包含 common.h
头文件的数量。
变量名改成 dummy_foo
方便观察
2、还是 33 个……
可以使用 size
命令观察数据段大小:
3、重定义,因为 debug.h
包含了 common.h
:
因为初始化的全局变量是强符号,未初始化的全局变量是弱符号:
- 同名的强符号只能有一个,否则会报错
multiple definition
- 允许一个强符号和多个弱符号,但链接器会选择强符号的定义
- 当有多个弱符号相同时,链接器可以任选其中一个
TODO: 如何解释 Q2
可以参考这篇博客。
了解 Makefile
请描述你在 am-kernels/kernels/hello/
目录下敲入 make ARCH=$ISA-nemu
后,make
程序如何组织 .c 和 .h 文件,最终生成可执行文件 am-kernels/kernels/hello/build/hello-$ISA-nemu.bin
。
首先来看 am-kernels/kernels/hello/
目录下的 Makefile:
这里包含的 Makefile 在 abstract-machine 目录下。
执行来观察一下:
大概过程如下:
均使用交叉编译工具链 riscv64-linux-gnu-xxx
- 构建 hello 镜像,生成 .o 文件
- 构建 am 库,通过生成的 .o 文件生成 .a 文件
- 构建 klib 库,通过生成的 .o 文件生成 .a 文件
- 链接,通过之前生成的 hello 镜像和两个库,生成 elf 文件
- 反汇编 elf 文件得到 txt 文件
- 通过 objcopy 将 elf 文件转换为 bin 文件(NEMU 可执行的镜像文件)