操作系统内核的绝佳学习材料——JOS
前言:关于JOS和一些经验之谈
这一学期的操作系统课使用的是MIT用于教学的JOS操作系统,并且StonyBrook在其基础上做了大量改动,最重要的变化就是从32位移植到了64位。因为个人之前曾系统学习过Linux 0.11内核(《操作系统内核Hack:(四)内核雏形》,实现到时钟中断部分停下了),深知自己从零开始实现内核的工作量。即便是如我个人实现的MiniOS这种简单的不能再简单的,也是需要花费很多时间和精力的。虽然这些付出非常值得(在上这门课时给我带来了巨大的帮助),但是对于想直接上手的同学来说会很打击积极性。
既然强烈推荐JOS,那它有哪些好处呢?
- 环境搭建简单:JOS依赖的软件较少,基本就是GCC、Qemu、GDB这一套。以后如果有人能够做一个Docker镜像的话,那就更方便了。
- 引导性强:不管是Lab中的文字还是代码里的注释,JOS的作者真的很贴心,几乎所有习题都不会从零开做。尤其是代码中的注释,给出了详尽的指导,很多还都有Hint提示。这对自学的同学来说真的太重要的了。
- 自动打分:每个Lab都有一个Grade脚本,因为代码中编写了大量的测试断言代码(有的Lab感觉测试代码甚至都要超过功能代码了,非常的全面)。
- 挑战题目:如果你觉得太简单,每个Lab还有少量的挑战题目,有些可以一试,有些对于我是挺难的。这学期大概做了有十几个相对容易一些的Challenge题,感觉还不到总体数量的一半。从Challenge中真的能学到很多,推荐大家都试试,我后面会给出个人认为非常好的题目。
以下是个人的一点经验,感觉应该比较通用,严格遵守的话能节约很多时间:
- 注释:如果Lab文字不想仔细看的话,那么也行,碰到问题回头再翻。但是:一定要仔细看注释里的提示,差一句话可能就少实现了一种Case,就埋下了一颗“定时炸弹”!
- Bug:不要以为某个Lab满分就OK了,其中隐藏的Bug可能在后面的Lab爆发。这也是JOS实验有趣的地方,这一套代码完全是你自己的,从第一个Lab到最后,你都要完全负责。
- 调试:底层开发的调试工作绝对是考验功力的时刻。有时异常了,有时panic了,有时虚拟机triple error直接崩溃了。这时就需要凭借你的经验和强大的GDB像福尔摩斯一样去找线索,有时通过crash前的寄存器内容能预判,有时先大块定位后再逐行调试,有时凭感觉做出几种可能的猜测再去验证等等。
所以,个人以为JOS是个非常不错的选择,虽然它的确有的地方有些简陋,与真实的Linux有很大差别,但能系统地做完6个Lab的练习的话,对个人绝对是巨大的提升!本文不会“剧透”任何答案,只是总结一些经验心得和最重要的知识点,大家可以放心观看~
如果本文提到的题目、知识点在MIT的Lab中找不到的话,请搜索Stony Brook的Lab进行学习。
Lab 1: x86 Assembly and Bootloader
Lab 1的特点是阅读量大,但习题很少,毕竟刚开始还是让大家热身适应一下。不要看文字很多很烦,如果之前没有过内核或者嵌入式开发基础的话,其中一些预备知识还是很重要的,否则后面的Lab你会很痛苦。Lab 1涉及到的预备知识有:Git、Qemu、GDB、Inline汇编、C指针。
最主要的工作就是练习11开始实现backtrace函数的栈打印。之前觉着ebp(rbp)没什么用,真正控制着栈的是esp(esp),甚至像在《CSAPP缓冲区溢出攻击实验(下)》中ebp是错误的也没什么。而在这次实验的最终几道题目中,ebp却发挥了巨大作用,原来esp是在一个栈帧内随着压栈和出栈不断移动,而ebp就像是不同火车车厢(栈帧)之间连接的“钩子”。虽然函数返回时不断出栈,esp最终能够自己正确地返回到调用者。但当我们调试时,例如在GDB中执行bt命令时,是不能改变esp位置去追溯的,这时ebp就该出场了!
关于完成练习所需的代码其实JOS中的DWARF都给了,直接调用就能得到我们想要的东西。但要注意的就是:64位与32位汇编编程的不同!这也是JOS给我的“下马威”。总结一下我做这个最终练习时犯的错误,有些是旧知识忘记了,有些是新知识:
- 指针(地址)和值的转换:例如rbp要追溯到上一个栈帧的栈基址时可以这样:*((int *)(ebp))。
- 栈的方向弄反了:栈从高地址向低地址生长。ebp下的低地址是被调用函数的局部变量,ebp上的高地址是调用者的返回地址(原eip)、入参。
- ebp和eip配对错了:当前ebp和eip是一对,表示当前的数据位置和代码位置。而当前ebp所指位置里的值,与该位置紧挨着的高地址位置保存的返回地址,则是又一对。
- 64位的调用惯例:前面说到ebp上保存着返回地址和入参,这其实只对32位有效。64位的返回地址的确还是紧挨着ebp,但入参不是必须压栈的。因为64位机器的寄存器很多,从rdi、rsi、rdx、rcx、r8、r9可以保存6个入参,所以只有要调用的函数入参超过6个时才会压栈。
Lab 1只有一个Challenge,就是让输出到控制台的字体改变颜色。要求中介绍了一种通用的控制方法,就是ANSI的ESC序列(Escape sequence),语法是”ESC[Value;…;Valuem”。曾经在设法使MakeFile输出不同颜色字体时遇到过,例如echo -e ‘\033[31;1mHelloWorld!\033[0m’(先修改成红色,输出后立即复位)。所以整体思路是:内核接收到ESC序列字符串后,解析其内容并更改内部的输出模式,于是后续输出的字符就改变颜色。
Lab 2: Virtual memory
Lab 2开始难度陡增,个人感觉2和3可能是最难的两个Lab了。也可能是刚开始,一切还都不熟悉,所以感觉比较难。最烧脑的就是虚拟地址和物理地址的转换,以前从来没觉得这块知识很难,但在JOS里这绝对是最难的一块内容!
2.1 引导过程简介
因为之前在《操作系统内核Hack:(三)引导程序制作》花了几个月时间详细研究了系统启动过程,所以这一部分驾轻就熟,节约了不少时间。不太了解这部分背景知识的同学,可以看一下本人的《操作系统内核Hack》系列文章,写的应该还算比较清楚。要注意的一点区别就是:JOS采用AT&T汇编语法格式而不是比较流行的NASM,详细区别可以参考《Brennan’s Guide to Inline Assembly》,差别其实不大。
第一阶段引导:JOS的启动方式比较标准,boot文件夹下boot.S首先负责读取内存信息,以Multiboot格式存储在multiboot_info位置。这个信息是留给内核后续使用的,要详细了解Multiboot格式的话请参考Multiboot Specification。之后JOS开始加载GDT描述符,并进入保护模式。进入保护模式之后,迅速进入到C环境进行第二阶段引导。这一点比我之前学习的Linux内核要快很多,在Linux 0.11中这一部分的很多工作都是在汇编环境下完成的。也许是出于快点进入C编程环境,降低学生上手的难度吧,毕竟启动部分的确是非常复杂!
第二阶段引导:进入到boot/bootmain()后开始第二阶段引导。最主要的工作就是:从磁盘上读取内核文件,将控制转移到内核代码。关于如何读取磁盘找到内核并加载到内存,就不细说了,因为很枯燥啊,看看Orange’s读取FAT16的代码有多麻烦就知道了。关于找到内核入口的方式,JOS采取的策略类似于Orange’s,解析ELF文件头,找到内核的第一条指令。而Linux 0.11的方式则是将内核编译为纯二进制,并去掉没用的信息,所以内核的第一个字节就是第一条指令。
进入Kernel:因为前面说了,JOS“过快地”进入了C环境,而有些工作只能用汇编实现。“欠下的账”终究要还,所以内核的entry起始部分(kern/bootstrap.S)又再次进入汇编环境。把欠下的账补上后,才能完全进入C语言的世界。而kern/bootstrap.S要补上的最重要工作就是:设置页表,开启分页机制。下面进入本文的重点,页式管理。
2.2 内存分配的本质
谈到内存分配,第一反应就是C语言里的malloc(),以及高级语言C++/Java里的new关键字。可我们现在要写的是系统内核,还没有malloc()库函数(在《操作系统内核Hack》系列里曾讲过,开发内核时是不能随便引用标准库的),更没有高级语言里的new。就在迷茫的时刻,才会思考问题的本质。我们说内存分配时到底在说什么?其实对于内核来说,内存分配就是“随意”地返回一个地址给调用方使用,只要你保证这个地址不被其他人使用,那就是一次成功的内存分配了。所以,我们一般说的不管是JVM也好还是malloc也好,内存分配和释放的消耗其实都是内存管理器复杂管理的代价,如果我们用最最简单的Bump Allocator的话,内存分配的本质真的就像上面说的那样简单、原始!
2.3 虚拟地址 vs. 物理地址
现在就来看最为头疼的页式管理吧。这绝对是实验二的最大难点了!哪里是虚拟地址?哪里是物理地址?什么时候会发生转换?cr3以及每一级页表里存的是虚拟还是物理地址?GDB打印的又是什么地址?一个个问题搞得我晕头转向。现在终于有一些“清醒”了,就谈谈我对这些困惑问题的理解:
- 所有编译器“生成”的都是虚拟地址,例如:
int a = &p
,通过&p得到的地址就是虚拟地址。 - 所有内存访问都会发生地址转换,例如:
a[i]
、*p
,这两种形式的解引用都要求变量是虚拟地址。如果是自己手动赋的物理地址,就会导致MMU翻译时找不到对应的页表项而报错。 - 物理地址只能做二次转换获得,例如:
a = PADDR(&p)
。这种转换可行的前提是你知道当前页是如何映射的,即页表内容。这在内核中是可以办到的,也是我们在JOS实验二中做的事儿。但是未来你想要在某个用户进程中得到一个变量实际存在哪里了,这几乎是不可能的,因为操作系统已经对你屏蔽了这些东西。 - cr3里存的是物理地址,而且每一级页表pml4e、pdpe、pde、pte的表项里存的都是物理地址。否则就会出现“死循环”的效果了。假如pml4e[5]里存的是某一pdp表的虚拟地址,那么MMU又要拿着这个虚拟地址重新从pml4e来一遍…… 所以页表的内容类似于:pml4e[1]=0x1000(pdpe) => pdpe[0]=0x2000(pdp) => pde[11]=0x3000……
- 用GDB打印指针的地址是虚拟地址,但可以通过指定物理地址查看内存。例如,
p/x a
看到的就是虚拟地址,而x/10x 0x1000
查看的就是物理地址0x1000位置的内容。
如果大家修改到那几个walk()函数时可以会有疑问:为什么继续向下一级页表递归时要将页表地址通过KADDR()转为虚拟地址呢?因为每一级页表的walk()函数处理时都有pml4e[i]、pdpe[i]、pte[i]。不要忘记前面说过的,只要解引用就会发生地址转换!硬件逐级递归时是没有这个问题的,而我们的walk()函数是用软件模拟,所以一定要转为虚拟地址再递归!
Lab 3: Processes/environments
Lab 3的难度与Lab 2相当,最大难点就是中断(Interrupt)了。这两个Lab中涉及到的内存管理和中断可以说是最核心的知识,挺过这两个Lab后面就一马平川了!既然中断这么重要,那肯定不是三言两语可以说清楚的,强烈建议大家按照JOS的Instruction去实现,并且遇到问题一遍遍调试。这样整个中断的执行流程就会在你脑海里强化记忆,加深理解。
Lab 4: Multiprogramming and fork
经过了前面三个Lab的洗礼,Lab 4开始就是按部就班就可以了,个人感觉没有特别难的地方了。Lab 4首先让大家熟悉多核环境,我们要做的就是初始化好多核的运行环境,主要是多个内核栈和对应的TSS配置等。
4.1 OS中枢:调度器
OS的调度是由一个Timer发起的,引发中断进入内核态后,调用中断处理函数进行处理。因为JOS的中断过程是由BKL(Big Kernel Lock)保证的,所以中断处理函数运行时“整个世界都清净了”。不管有几个核心,此时世界静止,等待我们进行调度,在返回用户态的前一刻才会放开BKL。中断处理函数可做的事情很多,像简单的RR、复杂的类CFS、有趣的Lottery调度等等,大家尽可以根据自己兴趣去实验各种调度方式。像控制了OS的大脑中枢神经一般,控制一切的感觉还是很爽的!
4.2 COW Fork
本Lab的第二个核心就是实现拥有Copy-on-Write能力的Fork。要注意的是因为JOS采取的是Microkernel架构,所以Fork是在用户态配合几个系统调用完成的。因为要遍历进程地址空间决定是否要Copy还是Share,所以Instruction中给出了一种貌似很神奇的办法:顺序遍历一个数组。一直理解得不是很好,个中奥秘还是大家自己去探索吧!
4.3 IPC通信
因为JOS的Microkernel架构的缘故,在Lab 5和Lab 6中的FS和网络大量使用IPC通信,所以一定要对IPC有清晰的理解才能做好下两个Lab。其实并不难,IPC接收方调用receive()函数挂起自己,发送方调用send()进行数据通信后,内核唤醒接收方继续处理。
Lab 5: File System and Shell
Lab 5的核心就是一个:FS。JOS的FS对Linux的VFS进行了大量简化,最重要的改变就是去掉了inode,直接通过dentry管理文件和元数据。个人觉得,这个改动非常不好!一是因为inode真的太重要了,这个改动却导致了我们失去深入理解它的机会。二就是没了inode加上各种数据结构命名的不同,将JOS的FS部分与Linux的VFS对比学习时会产生很大困惑。强烈推荐大家看一下《Linux Kernel Architecture》之后,做一下实现inode的那个Challenge,具体请参见最后一部分。
Lab 6: Network Driver
这是我们的Final Lab,可以选网络驱动、虚拟化和自选题目。因为可能在MIT的原始材料里没有对应,所以就不详细说了。主要目的就是实现一个网络驱动,接收和发送网络包。难度不是很大,但因为是最终的Lab所以给出的Hint比较少,需要认真读Instruction和Intel的手册,总体上还是蛮有趣的!
福利:Challenges You Cannot Miss
本节给大家推荐一些我认为非常不错的Challenge,当然了,我没全做出来。因为有的真的工作量挺大的,也因为时间真的太紧了,而老师给Challenge的分数权重又比较低。但我觉得Challenge真的挺重要的,因为做正规题目时有很多Instruction和Hint,有时你做完后并不是理解的很透彻。而Challenge则完全只有挑战内容,没有太多的帮助。所以如果有时间多做一些的话,真能让你学的更扎实、更上一层楼!
- Lab 2 - Challenge 2! Homemade Debugger (强烈推荐):通过这个Challenge你也许会开始思考GDB,曾经理所当然的工具,它内部究竟是如何实现的呢?要想实现简单的断点和内存查看其实不太难,难的是像GDB一样根据编译器给出的调试信息进行反汇编。
- Lab 2 - Challenge 4! General Memory Allocator (Slab):题目要求实现一个能分配4K的整数倍的通用内存管理器,但个人觉得实现成Slab也许收获更大,但可能难度也更高。如果你真的实现了的话,后面很多Lab要给内核数据结构分配内存时就都可以用你自己的通用管理器了,真爽!
- Lab 3 - Challenge 3! Faster System Call (Sysenter/Sysexit):现在Linux中只要是环境允许的话,就会用这种所谓的快速系统调用。因为与传统的将所有上下文信息都压栈保存的方式有很大不同,所以要实现的话还是有些工作量的。
- Lab 4 - Challenge 1! Fine-grained Lock:JOS中为了简化,用的是BKL(Big Kernel Lock),只要进入内核态就一把大锁直接锁死,等回到用户态再放开。这就极大简化了多CPU时,内核数据结构的并行访问问题,当然也降低了并行性。BKL是Linux的技术债务,虽然并未完全移除,但在后续代码中是绝对不会使用它了。
- Lab 4 - Challenge 2! Scheduling Policy (强烈推荐):JOS只要求我们实现的是最简单的RoundRobin策略,这个Challenge让我们实现更有趣的调度,比如Lottery调度。利用随机数决定下一个运行的进程,允许进程具有不同的优先级的同时又无需做Bookkeeping,真是巧妙!具体可以参考《Three Easy Pieces》的精彩介绍!要注意的是:随机数生成器也要我们自己实现,因为内核里没有类似rand()这种函数,Wiki一下你会发现奇妙的东西。
- Lab 4 - Challenge 6! Share-Everything Fork (Threading!) (强烈推荐):JOS只要求实现了进程调度,通过这个Challenge你会Wow,原来这就是Thread啊!具体请参考Linux内核的clone()函数。
- Lab 5 - Challenge 1! Interrupt-driven IDE Driver:JOS目前用的是最简单的PIO(Programming I/O)方式与磁盘交互,本Challenge要实现中断式的方式,更贴近现实世界里的Linux。
- Lab 5 - Challenge 2! Page Cache Eviction:默认Page Cache是没有Evict功能的,这里我们可以实现一个简单的替换算法,例如Second-Chance。
- Lab 5 - Challenge 3! Journaling (强烈推荐):要为JOS的FS加上Journaling功能,工作量不是特别大(相比下一道Challenge),但需要对Ext3有比较清晰的了解,又是《Three Easy Pieces》,讲解的真的非常棒!
- Lab 5 - Challenge 4! inode Implementation (强烈推荐):这道题可谓是我做过的Challenge里工作量之最了,修改了将近15个文件,又新增了用户空间的测试程序,但收获也是巨大了!做完此题,你会对dentry、inode、file三大VFS核心对象有彻底清晰的认识,对in-memory和on-disk数据结构有全新的了解,对JOS的FS和Linux的VFS有充分的对比,此题真的不容错过!推荐《Linux Kernel Architecture》对应VFS几章精彩的讲解,这一部分的精彩程度真的完爆《Understanding the Linux Kernel》。
- Lab 5 - Challenge 5! Implement Unix-style exec:不借助内核帮助,在parent和child进程的用户空间里实现exec真是挺麻烦的,因为你已经进入child用户空间了,还要为child真正的ELF文件指定的main做准备工作再跳转,这些工作正常来说都是在内核态中完成的。至少我最后也没做出来……
- Lab 5 - Challenge 6! Implement mmap-style Memory-mapped Files:mmap的实现到了最后我也是云里雾里的,一定要抽时间好好理清一下。