这次做起来还是很有收获的,虽然最后阶段基本在写库函数,和ICS已经没啥关系了
思考题
AM究竟给程序提供了多大的栈空间呢?
观察$AM_HOME/scripts/linker.ld
这个链接脚本可以发现,其中定义了一个符号_stack_pointer
而根据AM启动客户程序的流程可知,在am/src/riscv/nemu/start.S
中的_start:
中将会执行la sp, _stack_pointer
,以此初始化栈指针。又注意到_stack_top
符号的地址与之相差0x8000
,因此可以回答AM中程序的栈空间大小为0x8000
字节
对比异常处理与函数调用
在函数调用中,调用点只需要保存caller saved的寄存器
而在异常处理中,需要保存所有的通用寄存器、CSR寄存器
区别是由规约决定的。函数调用约定决定了我们只需要保存caller saved寄存器即可满足被调用函数对寄存器使用的需求,而异常处理结束后,我们仍然需要恢复到异常发生前的“状态”(具体而言就是所有的通用寄存器、CSR寄存器....等等),因此需要提前把它们保存起来
堆和栈在哪里?
首先要知道栈和堆的用途
只需要注意到栈的使用只发生在函数调用过程中,堆的使用只发生在malloc/free
函数调用之后,因此它们都只在动态时有意义,这是为什么它们不需要出现在可执行文件中
AM中的栈从哪里来已经在上面回答过了,而堆的来头则可以在$AM_HOME/am/src/platform/nemu/trm.c
中找到,是一个Area
类型的结构体变量heap
。可以发现它们都是在程序执行后(_start
),main
执行前被初始化的。
如何识别不同格式的可执行文件?
在ELF Header中可以看到OS/ABI一项,这里可以区分操作系统;还有大/小端机的信息、数据储存方式的信息(二进制补码格式等等)
冗余的属性?
FileSiz
是ELF文件中的大小,MemSiz
是加载进内存后的大小。对于.bss
段中的数据无需保存在ELF文件中(默认被初始化为0),但是加载之后应当在内存中占用空间、有它的地址。因此就会出现FileSiz<=MemSiz
的情况
为什么要清零?
因为初始化为0的变量会被储存在.bss
段中,数据被初始化为0这一步就是在加载过程中完成的
当然这么说还有一个前提,就是内存的初值是不确定的(至少不是全0的),这点可以从NEMU的menuconfig中看到,默认NEMU会初始化内存为随机内容
RISC-V系统调用号的传递
这题实在猜不出设计者的心意,说一下我的想法
需要注意到的是,进行系统调用的时候,系统调用的调用号和它的参数是一并被传给系统调用识别的处理函数的。而a0
在calling convention中已经作为储存函数调用参数的第一个寄存器了。假如在识别出正确的事件后需要进一步调用函数进行处理,就需要逐个移动寄存器的内容后再call。而把系统调用号作为最后一个参数传入,则在必要时只需要覆盖就能进一步处理,这就可以更快处理各类系统调用(当然也更方便了)。
文件偏移量和用户程序
把偏移量放在文件记录表中维护有一个前提:无论文件被打开多少次,都共享同一个偏移量。这也是我在实现fs.c
的时候产生的疑问:如果一个文件被打开多次,正确的读写行为应该是怎样的?
这个问题可以通过audio.c
中的pipe来理解。一个pipe
是一个文件(队列),它支持同时从队头读取、从队尾写入。读取和写入两个操作可以由两个独立的进程分别完成,这就要求它们的读写操作必须彼此独立(至少偏移量是互相独立的)。
比较fixedpt
和float
取:
- 可以不需要FPU也能实现小数运算
- 可以简单地保持序关系和实现简单的运算
- 在数字较大时仍然有比较好的精度(总共32bit的有效位)
舍:
- 在结果很小时答案的精度,浮点数能表示的最小数字远小于定点数的最小数,这使得浮点数在多次除法后仍然保持着相对准确的结果,而定点数很容易变成0
- 能表示数字的值域(最大值)。浮点数能表示的最大数字远大于定点数的最大数,这使得定点数在多次乘之后很容易符号溢出
神奇的fixedpt_rconst
rconst
的最外面有一个强制类型转换为fixedpt
类型(也就是int
),因此整个表达式最终会成为一个整数(这是编译期间进行的),所以最终不会出现浮点指令。
RTFSC???
处理不是那么好玩,可以玩一玩怎么逆处理!
不妨记处理后的程序为P',原本的程序为P
首先注意到这种修改不能改变程序语义,并且有如下性质(性质1):在P'中相同的符号,在P中一定也相同
而且还有一个很特殊的地方:硬编码的数据(包括AM的接口调用)是没法被处理的(不然就改变了程序的语义),因此这一部分也可以提供一些信息
同样以16122学长的跳一跳程序为例,可以明显发现硬编码了三种颜色(红黄紫),那么就可以根据这个得到绘制不同部位的代码;硬编码了欢迎和得分信息,因此可以确定一部分函数的功能;同时能发现几处AM接口调用,因此可以直接得到对应函数的用途。
做完这些还能更进一步:注意到for循环内的变量也是携带有语义的(如行优先枚举),并且根据性质1可以借此获得一些全局的语义信息(例如说小人的高度、小人的坐标等等)
到这里其实就差不多了,剩下的基本自己写也能写出来,于是就得到了一个重命名后的可读跳一跳实现。
感想&反馈
主要想说说loader的问题。在PA3之前我从没有想过为什么.bss段的数据不需要特殊处理,直接就能加载到NEMU模拟执行,到了后面有同学问出这个问题我才意识到,这步loader的工作实际上是提前被做掉了(实际上如果能早一点问出“为什么我们同时需要一个.bin
和一个.elf
?”就能更快发现了,这个问题确实是一个看起来简单但是不那么容易回答明白的问题)
还想说说遇到的一个bug,仍然与gettimeofday()
有关。阅读sys/time.h
可以发现struct timeval
的实现用的是long int
,也就是一个架构相关的类型。通过简单的测试可以发现在timer-test/main.c
中输出sizeof(struct timeval)
的结果是8,而在nanos-lite/src/syscall.c
中的结果则是16。这是因为
最初在实现gettimeofday
系统调用的时候,我直接把struct timeval *
指针作为参数传给了nanos-lite/src/syscall.c
,这内存布局就导致了问题:用户程序中timeval
的两个成员分别组成了syscall
中timeval
的第一个成员的高32位和低32位。这直接使得我的用户程序永远读不到usec
的信息(恒为0),因此在用户程序那里最小时间间隔就是整1秒了,这也是为什么最初我的仙剑只能跑到1帧(他已经尽力了)
事实上这个问题并不罕见,这正是ICS理论课本上的例子:链接的双方用不同的类型解释了相同的内存区域,使得接口的语义发生了偏差。我的解决方法也简单粗暴:在系统调用中直接传递两个成员的指针,并规定它们都是uint32_t
类型,这样就(暂时地)解决了问题
于是我的疑问在于,为什么一个大家经常使用的库函数要使用平台相关的类型?
upd:
实际上是libc更新了,新版的库函数规定struct timeval
中的成员应该不管什么架构都使用64位长度的整型,而navy中带有的libc还没有修改,所以导致了这个问题。只需要在makefile中修改就可以正确实现了
在说到“一切皆文件”的哲学时,我还没有什么体会,直到看到通过更换具体文件读写的实现来达到“用统一的方法处理不同的事物”的目的,才理解什么叫一切皆文件了——就是抽象出统一的接口供程序操作,来达到简化设计的目的。在这一层面上,所有可以操作的东西看起来都是文件(可读可写)
说一点反馈吧。感觉PA3.3的部分内容实在太多了,后期库函数的编写也很费时间。我能明白这门课一直在强调的抽象层观点,但是用SDL实现一个抽象设备1,再给抽象设备1抽象出AM接口,然后给抽象的接口封装出一个NDL、miniSDL,最后用miniSDL实现libam中的抽象设备2,这样来来回回地编写功能相同、逻辑类似,而仅仅只是在抽象层之间传递信息的模块,是不是有点码农的感觉.....