运行客户程序dummy
查看反汇编结果,实现相关指令
实现jal指令的匹配
学习riscv32i中的jal指令
- RISC-V32I中唯一一条J型指令;
- 立即数只有imm[20:1],默认imm[0] = 0,因此是2字节对其的;
- 需要将imm进行符号扩展;
- 操作过程:R(rd) = pc + 4; pc = pc + SEXT(imm);
实现sw指令
为调试器p命令提供十进制输出功能
为调试器w命令提供十进制输出功能
异常问题解决
发现执行了不属于上述汇编文件中的指令:
经过调试,发现jal指令出现问题,并没有进行减法,而是进行了加法,跳转到了一个奇怪的地方;
经过一通gdb调试后无果
给gcc添加-g3调试信息;
实现jalr命令
实现更多的指令
基本通过全部测试样例,只有mul-longlong有问题,返回值a0不是0,不知为何
修复mul-longlong退出返回值不为0的bug
根据ebreak指令的地址,往回追踪a0寄存器的值,发现在跳转到ebreak之前,存在一条指令:
将a0寄存器修改为1,猜测应该是程序本义,并不是bug,因此查看程序源码,确实有很多check()调用,用于在产生0条件时调用halt(1);
通过全部测试样例;
阅读am的makefile文件,更改gcc的参数宏定义,默认批处理模式运行nemu
看了j将近一天的makefile文件,没有看明白,自学的感觉真的太难受
卡了两三天,看不懂makefile,找不到run目标在哪里,今天仔细看了一下am的makefile的include指令,发现漏看了其中nemu.mk中的两条include指令,原来是眼神不好,并不是笨。
成功实现了一个简易版的sprintf函数.
实现指令环形缓冲区
成功实现mtrace的开闭,尝试实现ftrace
补充:观察源码后成功实现cpu-test批处理启动nemu
2024.2.06
成功实现的原因是我在源码中发现了这个:

然后我在am的makefile中添加了如下内容:

然后就成功实现了,确实有点简单了,由于我没仔细看源码,先前把问题想复杂了,若智。
基础设施实现——ftrace
熟悉elf文件的结构并从中读入symtab和strtab到程序中
建立ftrace缓冲区
在这一步骤中,我们需要做的是利用函数调用时的跳转地址,遍历符号表并与其st_value成员逐个比对,并判断其st_info是否为函数类,如果相同,则再利用字符串表将字符串读出,并替换跳转地址,作为一条ftrace记录存入缓冲区中,这一步还是比较好实现的。
当然我们还需要进行的工作是,正确的识别跳转指令和返回指令,并正确得到其跳转地址。
分析recursion.c的汇编代码
函数f1
源码:

汇编:

需要注意的地方:
- 对全局变量lvl和rec的访问都是通过pc相对寻址来实现的,即auipc+addi指令;
- 传入参数所在的寄存器为a0和a1;
- 在向func[0]跳转的时候使用的是jr这条伪指令,这条指令的下一条地址保存寄存器为x0,因此无效,直接跳转到func,并没有保存下指令地址。也就是说不用返回?
- 返回值保存在a0寄存器当中;
重点在于第三点,jal和jalr指令为什么要保存下一条指令的地址呢?是因为在被调用函数执行完毕之后,还需要返回到调用函数中继续执行剩余命令,因此需要将下指令地址进行暂时性的保存。
显然,f1这个函数是不需要保存着返回地址的,因为f1的返回值就是func[0]的返回值(n > 0),因此也无需开辟新的栈帧,也就是众所周知的尾递归。
函数f2
源码

汇编

显然f2的汇编就与f1有很大的不同了,因为f2中对func[1]的调用并不是该函数的最后一步操作,因此该栈帧的任务并没有完成,需要开辟新的栈帧来调用func[1]。
如何准确识别出函数调用指令和ret指令
函数调用和返回都是通过JAL和JALR命令来实现的,同时返回指令ret一定是JALR命令,因为其返回地址保存在ra(x1)寄存器当中,即其rs1==1。
当然并不是所有的jal和jalr都是与函数调用有关系的,因此我们需要在符号表中进行查找,如果找到了,我们就认为它是调用或者返回指令,否则不进行log。
简单实现,一气呵成,不多做赘述

关于recursion.c的问题补充
观察recursion.c的ftrace文件,发现出现如下问题:

为什么会出现这个问题呢?因为f0和f1会进行所谓的尾部递归调用,这就导致了f1或f0在递归调用前已经完成了全部操作,就不会开辟新的栈帧给被调用函数,而是直接使用已经用完的老栈帧就可以了,因此被调用函数返回地址是调用函数的调用函数,就会发生这种调用和返回不匹配的现象。
如果我们修改源码,使得f0和f1不进行尾部递归,则效果如下:

这样就不会进行尾递归,也就不会出现调用和返回不匹配的现象了。
实现基本的输入输出
IO设备的工作原理与CPU非常相似:设备有自己的状态寄存器(相当于CPU的寄存器),也有自己的功能部件(相当于CPU的运算器),控制设备工作的信号称为“命令字”,相当于设备的指令。
所谓对设备的访问,就是要么从设备获取数据,要么设备发送数据出去。而这些一般都是CPU来完成的,因此CPU在与设备打交道时,需要能够控制设备的状态,但是实现的接口是什么呢?就是设备的寄存器,即CPU可以从设备的数据寄存器读出数据,也可以从其中读入数据;可以从设备的状态寄存器汇总读出设备的状态,判断设备是否处于忙碌状态;可以往设备的命令寄存器中写入命令字,借此来修改设备的状态。
既然CPU与设备之间交互的接口是设备寄存器,那么CPU是如何访问设备寄存器的呢?
当然了,我们可以对设备寄存器像CPU寄存器那样进行编号,并将这个编号放入特别的IO指令当中,比如x86架构下的in
和out
指令。这种IO编址方式称为端口映射IO。当时这种方法有个很大的缺点,由于IO地址空间的大小在IO指令被设计之初就已经确定下来了,因此能够访问的IO地址非常有限。
因此,内存映射IO应运而生。这种编制方式将一部分的物理内存的访问“重定向”到I/O地址空间当中,CPU尝试访问这些物理内存时,实际上最终是访问了相应的IO设备。这样CPU就可以通过普通的访存来访问设备了。
我们可以将设备当中的数字电路中的时序逻辑电路看作是设备数字电路部分的状态D
,计算机只能通过端口IO指令或者内存映射IO的访存指令来访问和修改D
。设备还有另外一部分:模拟电路。模拟电路与物理世界相连接,能够改变D
。
内存IO映射的实现
在nemu代码中,init_map会向堆区申请一块内存p_space用于IO设备的寄存器,每加入一块新设备,都从p_sapce中分出一部分给它,来作为真实的设备寄存器的存储空间。而相对应的未经过映射的内存空间则是根据配置指定的,例如CONFIG_SERIAL_MMIO是串口在物理内存中的地址空间。
实现了内存IO映射之后,所有的IO设备已经扩展进了整个计算机系统当中,我们也就可以对其寄存器进行读取和写入了。
IOE扩展的实现
要实现IOE扩展,就是要实现对设备的读取和写入功能,即对某一个寄存器的读取和写入。如果要保证IOE扩展的设备无关性,就需要定义一套规定,即对寄存器的编号进行规定,得到一组抽象寄存器编号,并让所有的设备和平台都要遵循这样的约定。
例如,trm.c中的putch函数是使用outb函数实现的,而outb在每一种架构下都有各自的实现,但名字相同。
nemu并不是一个处理器,而是一个平台,整个平台包括了处理器以及软件在该处理器上运行的运行时环境。
实现串口
运行alu-tests测试

实现时钟
运行am-tests/rtc测试,在查看rtc.c时,发现printf使用了%02d格式化输出,但是我的printf并没有实现对位宽字符的识别,因此更改代码为%d,日后再对printf进行完善。
在这一过程中还修改了printf使用的int_to_string函数,修补了对0的无效转换bug。

理解串口输出的工作方式
- printf函数调用了putch函数,来将内容输出到电脑屏幕上;
- am中定义了putch()函,作为软件利用硬件进行输入输出的接口;
- nemu平台对putch的实现中有这样一行调用
outb(SERIAL_PORT, ch)
即通过调用outb,来实现对抽象串口寄存器的输出;
- outb则是平台nemu对架构的另一层抽象,在riscv32的am代码中,对outb的实现如下:
static inline void outb(uintptr_t addr, uint8_t data) { *(volatile uint8_t *)addr = data; }
- 如果调用putch,那么outb就实现了对SERIAL_PORT内存地址的写入,而这一内存地址又会被映射到一段设备物理寄存器的地址上去,这里的映射则属于硬件代码(nemu)中的内容;
- 一点调用了outb,就产生了一条访存指令(store),就会调用物理paddr_write进而调用mmio_write,mmio_write会找到该地址对应的map,进而调用map_write将内容写入设备寄存器中,随后调用设备回调函数:
- 可见,最后是通过putc函数将内容输出到stderr中的;
理解nemu当前的内存分配机制以及图灵机运行时环境
查看nemu配置:

可知,nemu的物理地址大小为0x8000000,即2^27B,128M的大小,对这128M的内存单元从0x80000000开始编址。
但是目前好像并没有牵扯到栈与堆区的划分,因此我们需要查看程序的运行时环境。最终还是发现了蹊跷之处:在trm.c中,有如下代码:

搜索_heap_start变量,发现在链接脚本linker.ld中:

即_heap_start = ALIGN(0x1000);
查询一下ALIGN是什么意思:问了一下文心一言,了解到这条语句的意思是_heap_start的起始地址是0x1000对齐的,并没有给出堆区的确切起始地址,猜测它是由gcc自动分配的,然后会利用这个变量的地址作为堆区的开始地址,PMEM_END作为堆区的结束地址,构造一个堆区结构体:
Area heap = (Area) { .start = &_heap_start, .end = PMEM_END };
搞清楚了这些,我们就可以开始实现一个简单的malloc函数了。
PA2尾声
坎坎坷坷的做完了PA2,在这过程中学到了很多、经历了很多,也思考了很多。在这个过程中最大的收获就是胆量,勇于学习、用于解决未知问题的胆量。一旦知道了问题的可能的解决方案,不要犹豫,勇敢去做,因为不管解决好坏,收获终究是少不了的。
用对打字小游戏的理解进行收尾
main函数总体结构

main函数首先调用的是ioe_init()函数,印象中这是am运行时环境中的一个系统调用,查阅相关源码:

我们可以根据函数所在文件的目录进行理解:am(抽象机器)中定义了一组平台(架构的具体实现)无关的接口,用于实现软件输入输出功能与硬件设备之间的对接。ioe_init()、ioe_read()、ioe_write()等函数就是这样的一组系统调用。其中ioe_init()函数所实现的是对每个IO寄存器端口所对应的系统调用的注册,将每个系统调用的指针存入
lut
查找表中,便于后续的调用。我们可以关心以下lut查找表的构建:
第一行是定义了一个函数指针,并用
typedef
关键字将函数指针类型规定为handler_t,随后声明一个void *
数组,用于存储各个系统调用。其实我对这里构建lut使用的语法还是比较纳闷的,接下来分析一下。首先,没记错的话AM_TIMER_CONFIG等字符串是am定义的一组架构无关IO寄存器端口,用于避免不同架构对各种IO寄存器所在的地址的规定的不同而导致的软件的差异。它们的本质就是一系列互不相同的整数,所以代码中的
[]
可能就是对lut
进行索引,并最终构建了一个哈希表,可以通过该寄存器的端口号来找到对应的系统调用,实现对硬件的访问。接下来就大体看一下各个端口对应的系统调用:键盘

计时器

GPU(图形渲染设备)

仔细分析一下,这几种系统调用具有共同点:它们都调用了
outl/inl
。我们查看一下它俩的代码:
发现这几个函数当中只有对一个地址的解引用访存,而且该函数是一个内联函数。因此我们可以断定,这几个函数所发挥的功能其实就是输入输出指令的功能,这几个函数所产生的的指令可能就是一条对IO设备进行访问访存指令:只要给我准确的硬件地址,其编译产生的指令就能够实现对硬件寄存器的访存。
有点扯远了,我们重新看typing-game中的main函数:

146行则是一个video_init()函数,查看其代码:

该函数首先调用io_read(其实就是ioe_read)访问AM_GPU_CONFIG寄存器端口,获取了屏幕的宽度和高度。重新回顾一下AM_GPU_CONFIG端口的系统调用:

该系统调用读入VGACTL_ADDR寄存器当中的内容,这是一个32位的VGA寄存器,其低16位用于存储VGA设备的高度,高16位存储VGA设备的宽度。
重新回到video_init()函数中,其次,它声明了一个外部变量char front[];查看其c源码:

泰裤辣!先不管它。继续往下看,发现用到了一些宏定义:

猜测CHAR_W和CHAR_H可能是代表字符的宽度和高度,COL_WHITE等可能颜色值。
紧接着下一步就是对blank数组的每一个元素进行初始化,每个元素都设置为紫色;看一下blank数组的定义:

这里有两个uint32_t数组,一个是CHAR_W * CHAR_H大小的blank数组,另一个则先不管,后面说。
然后是一段双层for循环,对屏幕以字符高度和宽度大小进行遍历:

对每个位置,都访问一次AM_GPU_FBDRAW端口,看一下讲义,回忆一下该端口的作用:

再看一下该端口缓冲区结构体的定义:

可知,io_write的作用就是在屏幕的(x, y)对应的缓冲区中的位置写入blank中的像素值,这里sync的值是false,并不是true,通过回顾该端口对应的系统调用知,这里的缓冲区指的是VGA设备从内存中注册的一段video memory(显存),该调用会将pixels(blank)中的内容写入该缓冲区,讲义描述如下:


下一步就是这段代码了:

猜测font数组存了26个英文字母的大写字体信息,每个字体的信息长度为16个字节,正好是字符高度的大小,因此每个字节(8bit)就代表blank数组一行中的信息(如果该位上是1,那么这个地方就显示白色)。紧接着,我们也可以猜测一下texture数组的含义,其第一维只有3的长度,因此所代表的的是WHITE、GREEN、RED三种颜色,第二位则是26的长度,所代表的则是26个英文字母,第三位则是一个字符大小的长度,里面存储了字符显示的字体信息。因此texture数组中存储了26个英文字母分别在白、绿、红颜色下的显示信息。这里的三重循环就是在生成这样一段信息。
总结来说video_init()函数的作用就是先将显存全部设置为紫色像素值,并产生texture数组,供后续显示访问。
让我们回到main函数,随之而来的就是游戏逻辑了:

在while循环内,首先获取计时器寄存器中的当前计时时间,并将其除以(1000000/FPS)。随后不断++curent,并每次循环都调用game_logic_update函数。查看一下game_logic_update函数:

FPS是帧率,即每秒的帧数,CPS是点击率,即每秒的点击数量,FPS/CPS就是平均每次点击有多少帧。。。。。有点不懂这个if语句,直接理解为满足一定时间条件就调用new_char():

该函数遍历整个chars数组,对每一个chars中的元素c,如果c中的ch不是0,则随机生成一个大写字母给之,并设定一个x轴随机坐标,y设为0(屏幕顶部),在设置一个速度,然后退出该函数。
这里速度v的表示方式很特别,首先screen_h - CHAR_H + 1是字符的总路程,那么其分母应该就是时间,但是这个时间是使用FPS来进行衡量的,其单位是:像素/帧
回到game_logic_update函数,在生成新字符之后我们会遍历整个chars数组,对每个chars中的元素,执行如下逻辑:
- 如果该元素的ch不是0,那么:
- 如果其t大于0,那么令t减一,并判断其是否为0,若是则将ch置位0;
- 如果t不大于0,那么在其y上加上一个v,并判断y是否小于0,若是,则将ch置0,再判断y+CHAR_H是否大于screen_h,若是,则miss,并令v为0,y为screen_h - CHAR_H,令t为FPS;
- 否则什么也不做,直接跳过。
回到main函数,一旦current达到frames的大小,就进入另一个while循环:

首先访问AM_INPUT_KEYBRD这个端口,获取键盘输入:
- 如果没有输入,则跳出循环;
- 如果按下esc,则终止;
- 如果按下某一已经注册过的按键,则调用check_hit函数。
来看一下check_hit函数:

首先遍历整个chars数组找出当前屏幕中所有正在运动的且位于最下面的等于ch的字符,将其速度反向。
回到main,随后判断current和rendered的大小,没猜错的话,这里的render是渲染的意思。即如果current如果大于rendered整个值,就会调用render()函数进行渲染,整个render函数才是整个程序的核心:

首先,x[NCHAR] y[NCHAR] n是三个局部静态变量,在其初始化之后始终只有一个实体,x y两者的作用是存储上一次渲染时的字符信息,n表示信息的个数,用于在之后的for循环中擦除上一次的渲染结果,以便新的渲染顺利进行。
在渲染完成之后,87行会做一次同步操作,将显存中的信息同步到屏幕上。
重新看一下这句话,发现frames其实是在求一个当前计时下应该跑过的帧数,而current就是代表每一帧。在随后的for循环中,我们每一帧都要更新游戏逻辑,并监听键盘,更新游戏逻辑,随后进行渲染——绘制新的屏幕。

最后,我们再来回顾一下硬件代码,以便更好地理解软硬件的协同工作原理,这里以键盘为例。我们从软件开始。
首先软件编译之后生成设备访存指令,并在译码后的执行阶段进行处理:

通过调用mmio_read/mmio_write函数:

然后调用map_read/map_write函数:

在该函数中,会对相应设备的寄存器空间进行访存,并调用其对应的设备回调:

硬件设备寄存器中的内容是通过device_updata进行更新的:

而这个函数是调用了SDL库。
至此,整个代码的运行逻辑基本完全被我摸透了,PA2圆满结束咧!