前言的前面
DragonOS是一个从0开始研发内核及用户态环境的,独立自主的,面向服务器领域的开源操作系统,提供Linux兼容性。
官网:https://DragonOS.org
代码仓库:https://github.com/fslongjin/DragonOS
前言
在写DragonOS的时候,我总是遇到一些神奇的BUG,包括但不限于:
- 加一行printk(“”);,代码就能正常运行
- 读写几个无关的变量,代码就能跑了
- 加一层函数调用,把某个函数wrap一下,代码就能运行
- 10月份的时候,我和同学调试IDR的源代码,有个单元测试用例就是无法通过。并且,出错的位置总是不相同。将测试用例的数据规模减小之后,就不会报错。
- XHCI驱动程序在初始化的时候,随机性报错,系统重启后即有概率正常初始化。
上面这些bug,每次碰到,都摸不着头脑,觉得真的是个玄学问题,一直不知道怎么解决他们,根本找不到方向。直到最近,在使用Rust重构CFS调度器的时候,突然间意识到了,上面这些现象,都是来自于进程切换的代码,产生了错误。
先说结论,BUG的产生来自两个方面:
- 未定义行为的内联汇编代码
- 切换进程前,存在未完全保存执行现场的调用路径。(也就是说,有时候保存了,有时候没有保存)
我是怎么发现这个bug的?
首先,我使用Rust重构了CFS调度器,这个逻辑不复杂,很快就实现了。
由于原先的C语言版本的代码,调用了这两个宏来进行进程切换:switch_mm()和switch_proc(),分别用来切换页表以及进程上下文。
参见DragonOS-0.1.2的cfs.c的第84、86行:
http://opengrok.ringotek.cn/xref/DragonOS-0.1.2/kernel/src/sched/cfs.c#84
这两个宏主要是汇编代码,长下面这样:
http://opengrok.ringotek.cn/xref/DragonOS-0.1.2/kernel/src/process/process.h?fi=process_switch_mm#process_switch_mm
http://opengrok.ringotek.cn/xref/DragonOS-0.1.2/kernel/src/process/process.h?fi=switch_proc#switch_proc
简单介绍一下这两个宏的作用:
- process_switch_mm这个宏,主要作用是,将下一个进程的基地址加载到页表基址寄存器CR3中。
- switch_proc这个宏,首先保存了rbp寄存器(当前栈帧基址)和rsp寄存器(当前栈指针),把他们保存到当前进程的线程结构体中。然后切换到下一个进程的内核栈,同时获取为当前进程的设置一个返回地址(就是switch_proc_ret_addr所在的地址),存到当前进程的线程结构体内的rip成员变量中。并且,往下一个进程的内核栈内,压入下一个进程的返回地址(next->thread->rip),接着,跳转到__switch_to这个函数(注意不是call,而是jmp,因此这里是不会压栈的),进行其他的工作,当__switch_to函数返回时,处理器将会弹出63行压入的“下一个进程的RIP”,这样就完成了进程切换。
后面的实验证明,错误具有两处,其中一处正是发生在switch_proc宏的内联汇编代码之中。
回到重构CFS的话题,我想在Rust代码中,实现切换进程的动作。由于内联汇编的编写有点麻烦,那么最简单、最直接的办法,自然是在C里面加一个函数,把switch_proc和switch_mm这两个宏封装一下,接着直接在Rust里面调用这个C函数即可。
因此,我把这两个宏封装了一下,封装成这样:
http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/sched/core.c?r=d4f3de93#9
注意,我为了避免歧义,在这里把原本的switch_proc()宏,改名为switch_to().在下文中,将用switch_to来代指前文的switch_proc宏。
然后,再在Rust的代码之中,调用了这个函数。本来我以为这样就万事大吉了,但是一运行,处理器就在进程调度的时候,产生了General Protection异常,并且出错的地方,是位于__switch_to函数的ret指令处。(switch to函数里面切换了fs、gs寄存器)
指向这个异常产生的原因有很多,查询Intel开发手册的Volume3A的第6.15章节中,关于General Protection的产生的原因的描述后,大概是这样:
由于文档中,大量的描述是关于那几个段选寄存器的,并且__switch_to函数里面切换了fs、gs寄存器,因此我对进程切换前后,cs、ds、es、fs、gs、ss几个段选寄存器的值,以及将要被换入的值,进行了详细的检查。发现他们的值都是正确的,权限也都是正确的。
Debug陷入了僵局。
解决BUG
我反复思考:为什么这两个宏单独使用就可以运行,独立成函数就不行了呢?是不是因为由于编译器指令重排序优化问题,或者是处理器乱序执行问题导致的?我加了内存屏障,依然无法解决。
BUG的原因之一:未完全保存指执行现场的上下文
在这个时候,我检查发现:在中断结束时调用的sched(),由于进入中断的时候,保存了上下文。除了这种情况以外,其他时候,直接调用sched(),我们并没有对进程当前的执行现场作保存!在这个时候,我联想到之前那些奇怪的BUG,就是文章开头所说的那些。我把他们结合起来思考,突然顿悟:那些玄学的bug的产生,正是因为发生进程调度,而执行现场没有被保存,在进程被重新调度时,由于执行现场的数据缺失,导致其报错!随机性出错的现象,正是因为调度时机不确定导致的!
因此,我对这个问题提出了解决方案:调度器必须在中断上下文中运行,以保证执行现场被完整保存。为了支持那些需要立即调度的场景(与时钟中断触发的调度相对应),我为DragonOS新增了一个系统调用:sys_sched().而原先的sched()函数,功能则改为“发起一个SYS_SCHED系统调用”。这个系统调用就是利用了进入系统调用之前,会由中断处理机制先把执行现场保存了的特点,从而解决了进程的执行现场没有被保存的问题。
具体的代码如图所示:
http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/arch/x86_64/sched.rs?r=d4f3de93#6
http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/sched/core.rs?r=d4f3de93#78
经过上面的修改,所有的能够运行进程调度器,切换进程的路径,都保存了进程的上下文。我想着,这样问题应该就解决了吧?结果一运行,仍然是报错的,还是那个熟悉的General Protection异常。
这个时候,我重新审视了一下上面的代码,经过一个小时的思考,我确认我上面找的确实就是一个BUG,仍然报错肯定是因为还有未发现的bug。
BUG的原因之二:switch_to宏的内联汇编,是未定义行为的代码
我重新思考了很久,我坚信问题一定存在于switch_to和__switch_to这两个地方。但是,在进入这两个地方的前后,寄存器值,以及即将换入的值,我都没有发现异常。我盯着switch_to()宏的代码看了很久,发现它就是有点不对劲!
http://opengrok.ringotek.cn/xref/DragonOS-0.1.2/kernel/src/process/process.h?fi=switch_proc#switch_proc
在这串汇编里面,我修改了rax寄存器的值,并且rax不存在于内联汇编的输入、输出部分,也没有在损坏部分声明。GCC编译器并不知道我在这串汇编里面改了rax寄存器!那么,这段代码的行为就是未定义行为,因为编译器可能会利用rax来存一些临时数据,而我这样就破坏了它。因此,直接在损坏部分(下图第70行)加上”rax”寄存器,再运行,bug就解决了!
http://opengrok.ringotek.cn/xref/DragonOS/kernel/src/process/process.h?r=d4f3de93#54
后续测试
为了验证是否像我想的那样,IDR中的大数据测试用例无法通过,且随机性assert failed的现象,是由于进程切换时的BUG导致的,我重新运行了IDR的所有测试用例,都直接通过了。
小结
这个BUG前前后后花了我5天时间去调试,如果算上之前调试实时调度器、IDR、XHCI以及其他模块的时候,由于玄学问题花费的时间,那么总耗时可能达到了将近一个月。真的是,未定义行为的代码,以及未保存上下文这个bug,浪费了我、小伙伴的很多时间。
这个bug,经过了codeQL、cppcheck、ControlFlag、腾讯云的代码检查服务的检测,都没法查出来,真的藏的够深的。或许是因为,那些工具都是为检查应用软件而研发的吧。