NJU计算机课程基础实验 PA2笔记(二)2022-08-13
C标准库函数的实现,设备读写下的超级玛丽
基础设施(2)
NEMU是一个用来执行其它程序的程序. 在可计算理论中, 这种程序有一个专门的名词, 叫通用程序(Universal Program),
NEMU和各种模拟器只不过是通用程序的实例化, 我们也可以毫不夸张地说, 计算机 就是一个通用程序的实体化. 通用程序的存在性为计算机的出现奠定了理论基础, 是可计算理论中一个极其重要的结论,
memset通过测试
推荐阅读 计算的极限
https://zhuanlan.zhihu.com/p/270155475
阅读相关Makefile, 尝试理解abstract-machine是如何生成native的可执行文件的
待补充
奇怪的错误码
为什么错误码是1
呢? 你知道make
程序是如何得到这个错误码的吗?
别高兴太早了, 框架代码编译到native
的时候默认链接到glibc, 我们需要把这些库函数的调用链接到我们编写的klib来进行测试. 我们可以通过在abstract-machine/klib/include/klib.h
中通过定义宏__NATIVE_USE_KLIB__
来把库函数链接到klib. 如果不定义这个宏, 库函数将会链接到glibc, 可以作为正确的参考实现来进行对比.
待补充
这是如何实现的?
为什么定义宏__NATIVE_USE_KLIB__
之后就可以把native
上的这些库函数链接到klib? 这具体是如何发生的? 尝试根据你在课堂上学习的链接相关的知识解释这一现象.
待补充
输入输出
设备与CPU
要向设备发送一些有意义的数字信号, 设备就会按照这些信号的含义来工作. 让一些信号来指导设备如何工作, 这不就像"程序的指令指导CPU如何工作"一样吗? 恰恰就是这样! 设备也有自己的状态寄存器(相当于CPU的寄存器), 也有自己的功能部件(相当于CPU的运算器). 当然不同的设备有不同的功能部件, 例如键盘有一个把按键的模拟信号转换成扫描码的部件, 而VGA则有一个把像素颜色信息转换成显示器模拟信号的部件. 控制设备工作的信号称为"命令字", 可以理解成【设备的指令】, 设备的工作就是负责接收命令字, 并进行译码和执行... 你已经知道CPU的工作方式, 这一切对你来说都太熟悉了.
所谓的访问设备, 说白了就是从设备获取数据(输入), 比如从键盘控制器获取按键扫描码, 或者是向设备发送数据(输出), 比如向显存写入图像的颜色信息. 但是, 如果万一用户没有敲键盘, 或者是用户想调整屏幕的分辨率, 怎么办呢? 这说明, 除了纯粹的数据读写之外, 我们还需要对设备进行控制: 比如需要获取键盘控制器的状态, 查看当前是否有按键被按下; 或者是需要有方式可以查询或设置VGA控制器的分辨率.
所以, 在程序看来,访问设备 = 读出数据 + 写入数据 + 控制状态.
既然设备也有寄存器, 一种最简单的方法就是把设备的寄存器作为接口, 让CPU来访问这些寄存器.
CPU要如何访问设备寄存器呢? 我们先来回顾一下CPU是如何访问CPU自己的寄存器的: 首先给这些寄存器编个号, 比如
eax
是0
,ecx
是1
... 然后在指令中引用这些编号, 电路上会有相应的选择器, 来选择相应的寄存器并进行读写. 对设备寄存器的访问也是类似的: 我们也可以给设备中允许CPU访问的寄存器逐一编号, 然后通过指令来引用这些编号. 设备中可能会有一些私有寄存器, 它们是由设备自己维护的, 它们没有这样的编号, CPU不能直接访问它们.这些编号也称为设备的地址. 常用的编址方式有两种:端口I/O、內存映射I/O
端口I/O
端口映射I/O(port-mapped I/O), CPU使用专门的I/O指令对设备进行访问, 并把设备的地址称作端口号. 有了端口号以后, 在I/O指令中给出端口号, 就知道要访问哪一个设备寄存器了. 市场上的计算机绝大多数都是IBM PC兼容机, IBM PC兼容 机对常见设备端口号的分配有专门的规定.
x86提供了
in
和out
指令用于访问设备, 其中in
指令用于将设备寄存器中的数据传输到CPU寄存器中,out
指令用于将CPU寄存器中的数据传送到设备寄存器中.movl $0x41, %al
movl $0x3f8, %edx
outb %al, (%dx)
上述代码把数据0x41传送到0x3f8号端口所对应的设备寄存器中. CPU执行上述代码后, 会将0x41这个数据传送到串口的一个寄存器中, 串口接收之后, 发现是要输出一个字符
A
; 但对CPU来说, 它并不关心设备会怎么处理0x41这个数据, 只会老老实实地把0x41传送到0x3f8号端口. 事实上, 设备的API及其行为都会在相应的文档里面有清晰的定义, 在PA中我们无需了解这些细节, 只需要知道, 驱动开发者可以通过RTFM, 来编写相应程序来访问设备即可.API, 行为, RTFM... 没错, 我们又再次看到了计算机系统设计的一个例子: 设备向CPU暴露设备寄存器的接口, 把设备内部的复杂行为(甚至一些模拟电路的特性)进行抽象, CPU只需要使用这一接口访问设备, 就可以实现期望的功能. 计算机系统处处蕴含抽象的思想, 只要理解其中的原理, 再加上RTFM的技能, 你就能掌握计算机系统的全部!
內存映射I/O
端口映射I/O把端口号作为I/O指令的一部分, 这种方法很简单, 但同时也是它最大的缺点. 指令集为了兼容已经开发的程序, 是只能添加但不能修改的.这意味着, 端口映射I/O所能访问的I/O地址空间的大小, 在设计I/O指令的那一刻就已经决定下来了. 所谓I/O地址空间, 其实就是所有能访问的设备的地址的集合. 随着设备越来越多, 功能也越来越复杂, I/O地址空间有限的端口映射I/O已经逐渐不能满足需求了. 有的设备需要让CPU访问一段较大的连续存储空间, 如VGA的显存, 24色加上Alpha通道的1024x768分辨率的显存就需要3MB的编址范围. 于是内存映射I/O(memory-mapped I/O, MMIO)应运而生.
编址方式将一部分物理内存的访问"重定向"到I/O地址空间中, CPU尝试访问这部分物理内存的时候, 实际上最终是访问了相应的I/O设备,
现代计算机都已经是64位计算机, 物理地址线都有48根,
(注意64位并不需要2的64次方,而是48就够了,因为过大空间导致了资源浪费。32位的时候寻址是32次方。每个地址总线可以对两个存储单元寻址,确定两种状态(0或1)1GB是2的30次方Byte,1TB是2的40次方B,再乘上256即2的8次方即是)
这意味着物理地址空间有256TB这么大, 从里面划出3MB的地址空间给显存, 根本就是不痛不痒. 正因为如此, 内存映射I/O成为了现代计算机主流的I/O编址方式: RISC架构只提供内存映射I/O的编址方式, 而PCI-e, 网卡, x86的APIC等主流设备, 都支持通过内存映射I/O来访问.
作为RISC架构, mips32和riscv32都是采用内存映射I/O的编址方式. 对x86来说, 内存映射I/O的一个例子是NEMU中的物理地址区间
[0xa1000000, 0xa1800000)
. 这段物理地址区间被映射到VGA内部的显存, 读写这段物理地址区间就相当于对读写VGA显存的数据. 例如memset((void *)0xa1000000, 0, SCR_SIZE);
会将显存中一个屏幕大小的数据清零, 即往整个屏幕写入黑色像素, 作用相当于清屏. 可以看到, 内存映射I/O的编程模型和普通的编程完全一样: 程序员可以直接把I/O设备当做内存来访问. 这一特性也是深受驱动开发者的喜爱.
状态机视角下的输入输出
我们可以把设备分成两部分, 一部分是数字电路. 我们刚才粗略地介绍了一些设备控制器的功能, 例如我们CPU可以从键盘控制器中读出按键信息. 既然是数字电路, 我们就可以把其中的时序逻辑电路看成是设备数字电路部分的状态D.
但D比较特殊, 计算机只能通过端口I/O指令或者内存映射I/O的访存指令来访问和修改.
有意思的是设备的另一部分: 模拟电路, 它也可以改变D. 例如键盘通过检查按键位置的电容变化来判断是否有按键被按下, 若有, 则会将按键信息写入到键盘控制器的寄存器中. 而按键位置的电容是否发生变化, 又是由物理世界中的用户是否按下按键决定的. 所以我们会说, 设备是连接计算机和物理世界的桥梁.
要对设备的状态和行为进行建模是一件很困难的事情, 除了设备本身的行为五花八门之外, 设备的状态还时时刻刻受到物理世界的影响. 于是, 我们在对状态机模型的行为进行扩展的时候, 并不考虑将D加入到S中, 而是仅仅对输入输出相关指令的行为进行建模:
通过内存进行数据交互的输入输出
我们知道
S = <R, M>
, 上文介绍的端口I/O和内存映射I/O都是通过寄存器R
来进行数据交互的. 很自然地, 我们可以考虑, 有没有通过内存来进行数据交互的输入输出方式呢?其实是有的, 这种方式叫DMA. 为了提高性能, 一些复杂的设备一般都会带有DMA的功能. 不过在NEMU中的设备都比较简单, 关于DMA的细节我们就不展开介绍了.
NEMU中的输入输出
终于进入紧张刺激的正式话题!
NEMU的框架代码已经在nemu/src/device/
目录下提供了设备相关的代码,
映射和I/O方式
这部分蛮重要的,多读读原文把,可以在之后回头读。
设备
NEMU使用SDL库来实现设备的模拟,
nemu/src/device/device.c
含有和SDL库相关的代码.init_device()
函数主要进行以下工作:
- 调用
init_map()
进行初始化.
cpu_exec()
在执行每条指令之后就会调用device_update()
函数, 这个函数首先会检查距离上次设备更新是否已经超过一定时间, 若是, 则会尝试刷新屏幕, 并进一步检查是否有按键按下/释放, 以及是否点击了窗口的按钮; 否则则直接返回, 避免检查过于频繁, 因为上述事件发生的频率是很低的.
将输入输出抽象成IOE
设备访问的具体实现是架构相关的, 比如NEMU的VGA显存位于物理地址区间[0xa1000000, 0xa1080000), 但对native的程序来说, 这是一个不可访问的非法区间, 因此native程序需要通过别的方式来实现类似的功能. 自然地, 设备访问这一架构相关的功能, 应该归入AM中. 与TRM不同, 设备访问是为计算机提供输入输出的功能, 因此我们把它们划入一类新的API, 名字叫IOE(I/O Extension).
访问设备其实想做什么: 访问设备 = 读出数据 + 写入数据 + 控制状态. 进一步的, 控制状态本质上也是读/写设备寄存器的操作, 所以访问设备 = 读/写操作.
bool ioe_init();
void ioe_read(int reg, void *buf);
void ioe_write(int reg, void *buf);
第一个API用于进行IOE相关的初始化操作. 后两个API分别用于从编号为
reg
的寄存器中读出内容到缓冲区buf
中, 以及往编号为reg
寄存器中写入缓冲区buf
中的内容.【这里的
reg
寄存器并不是上文讨论的设备寄存器, 因为设备寄存器的编号是架构相关的.】
abstract-machine/am/include/amdev.h
中定义了常见设备的"抽象寄存器"编号和相应的结构. 这些定义是架构无关的, 每个架构在实现各自的IOE API时, 都需要遵循这些定义(约定).NEMU作为一个平台, 设备的行为是与ISA无关的, 只需要在
abstract-machine/am/src/platform/nemu/ioe/
目录下实现一份IOE, 来供NEMU平台的架构共享. 其中,abstract-machine/am/src/platform/nemu/ioe/ioe.c
中实现了上述的三个IOE API,ioe_read()
和ioe_write()
都是通过抽象寄存器的编号索引到一个处理函数, 然后调用它. 处理函数的具体功能和寄存器编号相关, 下面我们来逐一介绍NEMU中每个设备的功能.
一些重要的文件、函数记录
代码框架可参考:https://ysyx.oscc.cc/forum/topic/16/pa2-3-nemu-am-am-kernels共同构建的虚拟世界概念图
随着代码复杂度越来越高,对框架的熟悉度要求指数上涨,必须开始记录每个框架的功能及其使用方式:
基础文件
-
指令集相关文件:
-
各类指令RTL调用函数实现部分:/home/physico/ics2021/nemu/src/isa/riscv32/instr/ *.h
-
译码函数部分:/home/physico/ics2021/nemu/src/isa/riscv32/instr/decode.c
-
函数名-宏展开需要部分:/home/physico/ics2021/nemu/src/isa/riscv32/include/isa-all-instr.h(旁边的isa-exec可以包括1提到的)
-
ISADecodeInfo:/home/physico/ics2021/nemu/src/isa/riscv32/include/isa-def.h
-
RTL实现相关:
-
/home/physico/ics2021/nemu/src/engine/interpreter/rtl-basic.h (def_rtl_compute_reg_imm)
-
/home/physico/ics2021/nemu/src/engine/interpreter/c_op.h(c_add(a, b) ((a) + (b)) 以及 interpret_relop类似RELOP_NE)
-
-
调试测试用指令与文件(我以自己的路径为准)
-
IOE(包括时间等):
地址:/home/physico/ics2021/am-kernels/tests/am-tests
测试方式:make ARCH=riscv32-nemu run mainargs=t (其他测试方式自行读代码)
-
benchmark测试:
地址:/home/physico/ics2021/am-kernels/benchmarks/microbench
测试方式:make ARCH=riscv32-nemu run (其他benchmark同样方法可测)
-
马里奥测试:
地址:home/physico/ics2021/fceux-am
测试方式: make ARCH=riscv32-nemu run mainargs=mario