TestOS移植K210开发板

概述

本文介绍前六个部分在移植K210开发板遇到的问题,第七章比较麻烦,就放弃了。这里选用K210开发板的原因是rCore教程提供了K210的使用教程,同时RustSBI也提供了相应的适配,使得我能够在几乎不改变内核代码的情况下进行移植,所以还是感谢rCore教程的作者、RustSBI的作者洛佳大佬以及借我开发板的ccc大佬。

内容

第一部分

第一部分没什么需要修改的,主要是学习开发板的使用。首先将开发板使用Type-C的数据线连上电脑。由于我编译和烧录使用的是虚拟机,所以需要映射USB端口,VirtualBox启动系统后窗口右下角有个USB的图标,右键点击,选择那个中文拼音的端口打上勾,就映射成功了。然后下载烧录工具,把项目里的kflash.py下载下来。为了获得K210的输出信息,需要安装串口通信的Python库:

pip3 install pyserial

修改makefile:

rustsbi-k210_size := 131072
k210_serialport := /dev/ttyUSB0
k210:
	riscv64-unknown-elf-gcc os.c printf.c entry.S -T linker_k210.ld -ffreestanding -nostdlib -g -o os_k210 -mcmodel=medany
	riscv64-unknown-elf-objcopy os_k210 --strip-all -O binary os_k210.bin
	@cp rustsbi-k210.bin rustsbi-k210.copy
	@dd if=os_k210.bin of=rustsbi-k210.copy bs=$(rustsbi-k210_size) seek=1
	@mv rustsbi-k210.copy os_k210.bin
	@sudo chmod 777 $(k210_serialport)
	python3 kflash.py -p $(k210_serialport) -b 1500000 os_k210.bin
	python3 -m serial.tools.miniterm --eol LF --dtr 0 --rts 0 --filter direct $(k210_serialport) 115200

除了编译之外,需要建立一个二进制映像文件,包含rustsbi-k210.bin和编译出的os_k210.bin。具体操作是先将rustsbi-k210.bin复制一份,然后使用dd命令将其和编译出的os_k210.bin组合成二进制映像文件,注意os_k210.bin必须放在新文件里偏移量为rustsbi-k210_size的位置,rustsbi-k210_size的值是将rustsbi-k210.bin的文件大小按4096字节向上对齐的结果。K210版的链接脚本也有区别,需要修改一下内核的起始地址,因为rustsbi-k210强制指定了从0x80020000这里启动内核。

接着通过kflash.py把映像烧录到端口设备文件,在我的系统上这个设备文件是/dev/ttyUSB0。在烧录的时候终端会出现一个[INFO] Trying to Enter the ISP Mode...的提示,这个时候需要按住K210开发板上的Boot按钮,才能识别到开发板。识别过后就会开始烧录,烧录完就可以运行了。我在识别和烧录的过程中都有可能发生连接失败的错误,不知道是数据线、接口的问题还是开发板都会这样。

运行的过程中可以按K210开发板上的Reset按钮重启内核,终端上的输出有时候会混乱或缺失,我猜测应该是串口通信的刷新率问题,毕竟这里不像qemu那样是直接输出,K210的输出传到终端还是需要一定时间的,运行太快而丢失输出信息也不是不可能。内核运行结束后如果正常调用RustSBI的关机功能会输出:

[rustsbi] reset triggered! todo: shutdown all harts on k210; program halt. Type: 0, reason: 0

关机以后也可以按Reset按钮重启内核,关机以后串口通信程序仍在运行,需要手动退出。退出的快捷键是Ctrl+](一开始没看到程序启动时的提示,以为只能用kill命令强制退出😅)。

第二部分

第二部分开始有用户程序了,不过用户程序的链接脚本不需要修改,反正这个从哪执行都是自己指定的。值得注意的是用于导入用户程序的汇编代码link_app.S,之前是参考的rCore教程,前三行为:

.align 3
.section .data
.global _num_app

但是上了板之后会报内存读取不对齐的错误:需改成:

.section .data
.global _num_app
.align 3

按照rCore教程的解释,K210比qemu多的一个限制是读取多少字节类型的数据就要求这个数据的地址按多少字节对齐。比如读取int就要求这个int按4字节对齐。这段汇编代码下面就声明了一个.quad(64位,8字节)类型的_num_app变量,所以C程序在读取这个变量的时候要求其地址按8字节对齐。但令我疑惑的是,rCore里不管是qemu和K210用的都是上面的版本,两者都可以正常运行;但是我如果使用上面的版本,K210上就会报错。使用objdump查看符号表发现gcc编译前一个版本_num_app的地址是不按8字节对齐的,而rustc编译前一个版本则有对齐,感觉这是两个编译器的行为差别:在汇编代码中把变量放在段声明的正下方,rustc就直接把变量放在段的最前面了,而gcc在变量前面仍有可能插入其他的变量,所以必须在需要声明的变量前指定对齐才保险。

第三部分

第三部分需要修改timer.c里的时间频率常数:

#ifdef K210
#define CLOCK_FREQ (403000000 / 62)
#else
#define CLOCK_FREQ 12500000
#endif

第四部分

第四部分也没有什么特别的,就是原程序里task.c最后没有初始化current_task这个变量,这会导致内核在K210板上无法运行。这就是虚拟机和硬件的区别了,虚拟机可能会将未初始化的内存置为0,而硬件不会,未初始化的内存的值是随机的。如果要保险的话,还是应该像教程那样在程序的一开始就手动将bss段全部置为0,保证所有未初始化的全局变量值都为0。

第五部分

第五部分是坑了我最久的,因为这个bug实在是太难发现了,虽然实际原因可能对许多熟悉嵌入式开发的人来说是基本常识,但我这样的新手却完全不了解,这里总结一下调试的经过:

第一阶段:运行以后报panic,但是有时候是内核态的panic,有时候是机器态的panic,每次panic的原因和错误代码地址还不一样,时而是读缺页,时而是写缺页,时而是未知的系统调用,时而是非法代码。而错误代码地址对应的代码有时候甚至还和错误原因对不上,比如明明是store指令却报个读异常。经过观察,我发现发现异常的程序总是在主程序调用两次exec后出现,而且在内核添加打印函数还会改变错误类型。根据以往的经验,打印函数能够改变错误的基本只有两种情况:初始化和数据竞争。但是把bss段全部置为0和关闭时钟中断后仍未解决问题,关键是在qemu下从未报错,K210上没法用gdb(开发板单步调试需要专门的硬件,我没有),看错误的代码反而让我更摸不着头脑。

第二阶段:决定使用笨方法,就是对正常运行的第四部分一步步往第五部分修改,直至复现该错误。这一阶段也是改变了调试的思路,从错误结果反推原因转变为从代码反推原因,因此不再关注于错误的类型,而是只关注“是否出错”这一结果。在加入exec之后,错误又出现了,说明这个bug和fork什么都没有关系,仅和exec有关。我又尝试在执行第一个用户程序前先执行两次user_init函数(exec调用的处理用户任务块的函数),发现不会报错,说明必须进入用户态,然后我又令程序在第一次exec进入内核态后调用两次user_init函数,又出错了,只调用一次就不出错。说明错误发生的条件一个是需要进入一次用户态,一个是需要调用两次user_init,那么bug就只能在第二次user_init之中。

第三阶段:第二次user_init包括解映射第一次user_init映射的用户物理内存,再重映射新的用户物理内存。按照xv6实验的经验,出错往往在删除再更新的时候,于是我设置解映射的时候不释放物理内存,程序不报错了。那么为什么不能释放内存呢?我发现不释放内存时新申请的物理页号是连续的,于是猜想用户内存对应的物理页号必须连续。因此我修改了一下物理页帧的分配代码,改成在释放页帧的时候,如果被释放的页号和当前使用过的最高页号相邻,则不将该页号放入分配池,而是直接令最高页号自减一。分配页帧时也不经过分配池,而是直接分配最高页号,这样就可以保证多次分配的页帧是连续的。经此一改,程序能够正常运行了,bug貌似得到了解决,但是用户物理页号必须相邻,这个要求怎么想都很奇怪……

第四阶段:我又尝试分配页号的时候每分配一个就跳过一个页号(即只分配偶数页号或奇数页号),程序却没有报错,前面得出的结论不攻自破了,真正的凶手另有其人。我决定再细化错误条件,用来测试的用户程序内存占三个页,我就看看哪个页释放会报错,最后锁定了一个物理页号,当用户程序的第一个虚拟页映射到该页号时就会出错,而其他虚拟页映射到这里则不会。仔细观察这个物理页号的分配历史,发现它第一次被分配时刚好被映射到了第一个执行的用户程序的第一个虚拟页。设第一个执行的用户程序为A,经过两次user_init重置后的用户程序为B,经过实验又发现,当A和B的其他虚拟页映射到该物理页号不会出错,而A和B的第一个虚拟页映射到其他相同的物理页号亦会出错。这样,问题就被锁定在了两个用户程序的第一个虚拟页了,发现都是代码段所在的页。结合虚拟机上不出错,硬件上才会出错的现象,bug终于露出了它的真面目:缓存未刷新

在exec函数末尾加上一行代码:

asm volatile ("fence.i");

问题解决,fence.i指令用于刷新指令缓存。现在来解释为什么bug会引发上面的这些情况:

  • 虚拟机由于所有的易失性存储器件都是由内存模拟的,所以通常没有实现缓存机制。qemu我没看过源码,但我看过spike(riscv-isa-sim)的源码,确实没有缓存机制,想必qemu也是如此,自然没有缓存刷新问题,而硬件不一样,因此虚拟机上能正常运行,K210上就会报错。
  • 指令缓存和数据缓存是独立的,而重映射过程中对数据的读写只改变了数据缓存。同时指令缓存是通过指令的物理地址进行寻址的,所以如果前一个程序代码段的物理页帧被映射给了另一个程序代码段,且这两个程序的代码段实际内容不一致,指令缓存里的数据就会和实际内存内容不一致。
  • 我物理页号分配池用的是栈,假设第一个程序接受的物理页号是1、2、3,重映射回收后进栈顺序是1、2、3,出栈顺序就变成3、2、1了,所以第一次user_init映射的顺序是3、2、1,之后重映射回收后进栈顺序是3、2、1,第二次user_init的顺序又变成1、2、3了,满足了出错条件,这就是上面需要经过两次user_init才会出错的原因。
  • 因为是缓存,必须先写数据才会发生脏数据问题,所以必须进入一次用户态。
  • 指令缓存没有刷新,那么CPU读取到的指令就完全是不可预测的,当然什么异常都有可能发生,也因此会出现错误和代码对不上的问题,毕竟我看的代码和缓存里的代码不一样。值得注意的是,我在第一阶段有尝试把代码段的内容以十六进制形式输出,没发现问题,这说明输出过程走的也是数据缓存,指令缓存只有CPU执行的时候才知道里面的值是多少,这也是这次调试过程中最大的难点。

第六部分

没有什么新的问题。

总结

这几个部分的移植给我的启发还是挺大的。首先是开发板的使用,这个应该和其他开发板是共通的,然后是硬件要注意的两个问题:初始化和缓存刷新(数据缓存基本不用怎么处理,tlb在切换页表时刷新过了,主要就这个指令缓存)。虽然是很简单的几个东西,但是长达数天的调试过程还是有很多值得复盘思考的地方。

posted @ 2021-06-17 23:25  YuanZiming  阅读(659)  评论(0编辑  收藏  举报