没有调不出的bug, 只有不理解的系统
PA3是整个PA的分水岭, 从PA3开始, 系统的复杂度开始逐渐上升, 越来越多的抽象层将会加入进来, 系统也开始逐渐完善, 应用也将更加真实.
这意味着, bug的传播链条会变得更复杂, 调试也越来越考验你对系统行为的理解. 如果你之前调bug都是抱大腿, 并没有在调试过程中去理解系统的细节, 你会发现在PA3中自己和大腿的差距正在迅速扩大: 大腿对整个系统的掌握越来越熟练, 而你则是越往前就越觉得寸步难行, 例如讲义和代码越来越看不懂, bug越来越神秘.
亡羊补牢的方法只有一个: 真的不能再偷懒了.
批处理系统
我们在PA2中已经实现了一个冯诺依曼计算机系统, 并且已经在AM上把打字游戏运行起来了. 有了IOE, 就几乎能把各种小游戏移植到AM上来运行了. 这些小游戏在计算机上的运行模式有一个特点, 它们会独占整个计算机系统: 我们可以在NEMU上一直玩打字游戏, 不想玩的时候, 我们就会退出NEMU, 然后重新运行超级玛丽来玩.
事实上, 早期的计算机就是这样工作的: 系统管理员给计算机加载一个特定的程序(其实是上古时期的打孔卡片), 计算机就会一直执行这个程序, 直到程序结束或者是管理员手动终止, 然后再由管理员来手动加载下一个程序. 当年的程序也远远不如你玩的超级玛丽这么酷炫, 大多都是一些科学计算和物理建模的任务(比如弹道轨迹计算).
后来人们就想, 每次都要管理员来手动加载新的程序, 这太麻烦了. 回想起冯诺依曼计算机的工作方式, 它的其中一个特点就是: 当计算机执行完一条指令的时候, 就自动执行下一条指令. 类似的, 我们能不能让管理员事先准备好一组程序, 让计算机执行完一个程序之后, 就自动执行下一个程序呢?
这就是批处理系统的思想, 有了批处理系统之后, 就可以解放管理员的双手了. 而批处理系统的关键, 就是要有一个后台程序, 当一个前台程序执行结束的时候, 后台程序就会自动加载一个新的前台程序来执行.
这样的一个后台程序, 其实就是操作系统. 对, 你没有听错, 这个听上去好像什么都没做的后台程序, 真的是操作系统! 说起操作系统, 也许你会马上想到安装包都有几个GB的Windows. 但实际上, 历史上最早投入使用的操作系统GM-NAA I/O在1956年就诞生了, 而它的一个主要任务, 就是上文提到的"自动加载新程序".
什么是操作系统? (建议二周目思考)
这可是个大问题, 我们也鼓励你学习完操作系统课程之后再回来重新审视它.
最简单的操作系统
眼见为实, 我们先来展示一下操作系统究竟可以多简单. 在PA中使用的操作系统叫Nanos-lite, 它是南京大学操作系统Nanos的裁剪版. 是一个为PA量身订造的操作系统. 通过编写Nanos-lite的代码, 你将会认识到操作系统是如何使用硬件提供的机制(也就是ISA和AM), 来支撑程序的运行, 这也符合PA的终极目标. 至于完整版的Nanos, 你将会在下学期的操作系统课程上见识到它的庐山真面目.
框架代码已经为大家准备好了Nanos-lite的代码.
Nanos-lite已经包含了后续PA用到的所有模块,
由于硬件(NEMU)的功能是逐渐添加的, Nanos-lite也要配合这个过程,
你会通过nanos-lite/include/common.h
中的一些与实验进度相关的宏来控制Nanos-lite的功能.
随着实验进度的推进, 我们会逐渐讲解所有的模块, Nanos-lite做的工作也会越来越多.
因此在阅读Nanos-lite的代码时, 你只需要关心和当前进度相关的模块就可以了, 不要纠缠于和当前进度无关的代码.
nanos-lite
├── include
│ ├── common.h
│ ├── debug.h
│ ├── fs.h
│ ├── memory.h
│ └── proc.h
├── Makefile
└── src
├── device.c # 设备抽象
├── fs.c # 文件系统
├── initrd.S # ramdisk内容
├── irq.c # 中断异常处理
├── loader.c # 加载器
├── logo.txt # Project-N logo文本
├── main.c
├── mm.c # 存储管理
├── proc.c # 进程调度
├── ramdisk.c # ramdisk驱动程序
├── raw_logo.S # Project-N logo的字符串
└── syscall.c # 系统调用处理
需要提醒的是, Nanos-lite是运行在AM之上, AM的API在Nanos-lite中都是可用的.
虽然操作系统对我们来说是一个特殊的概念, 但在AM看来,
它只是一个调用AM API的普通C程序而已, 和超级玛丽没什么区别.
同时, 你会再次体会到AM的好处: Nanos-lite的实现可以是架构无关的,
这意味着, 无论你之前选择的是哪一款ISA, 都可以很容易地运行Nanos-lite,
甚至你可以像开发klib那样, 在native
上调试你编写的Nanos-lite.
另外, 虽然不会引起明显的误解, 但在引入Nanos-lite之后, 我们还是会在某些地方使用"用户进程"的概念, 而不是"用户程序". 如果你现在不能理解什么是进程, 你只需要把进程作为"正在运行的程序"来理解就可以了. 还感觉不出这两者的区别? 举一个简单的例子吧, 如果你打开了记事本3次, 计算机上就会有3个记事本进程在运行, 但磁盘中的记事本程序只有一个. 进程是操作系统中一个重要的概念, 有关进程的详细知识会在操作系统课上进行介绍.
一开始, 在nanos-lite/include/common.h
中所有与实验进度相关的宏都没有定义,
此时Nanos-lite的功能十分简单.
我们来简单梳理一下Nanos-lite目前的行为:
- 打印Project-N的logo, 并通过
Log()
输出hello信息和编译时间. 需要说明的是, Nanos-lite中定义的Log()
宏并不是NEMU中定义的Log()
宏. Nanos-lite和NEMU是两个独立的项目, 它们的代码不会相互影响, 你在阅读代码的时候需要注意这一点. 在Nanos-lite中,Log()
宏通过你在klib
中编写的printf()
输出, 最终会调用TRM的_putc()
. - 初始化ramdisk. 一般来说, 程序应该存放在永久存储的介质中(比如磁盘). 但要在NEMU中对磁盘进行模拟是一个略显复杂工作, 因此先让Nanos-lite把其中的一段内存作为磁盘来使用. 这样的磁盘有一个专门的名字, 叫ramdisk.
- 调用
init_device()
对设备进行一些初始化操作. 目前init_device()
会直接调用_ioe_init()
. init_fs()
和init_proc()
, 分别用于初始化文件系统和创建进程, 目前它们均未进行有意义的操作, 可以忽略它们.- 调用
panic()
结束Nanos-lite的运行.
由于Nanos-lite本质上也是一个AM程序, 我们可以采用相同的方式来编译/运行Nanos-lite.
在nanos-lite/
目录下执行
make ARCH=$ISA-nemu run
即可. 另外如前文所说, 你也可以将Nanos-lite编译到native
上并运行, 来帮助你进行调试.
操作系统是个C程序
你也许有点不敢相信, 但框架代码的.c和.h文件无疑蕴含着这铁一般的事实, 甚至连编译方式也看不出什么特殊的地方. GNU/Linux也是这样: 如果你阅读它的源代码, 就会发现GNU/Linux只不过是个巨大的C程序而已.
那么和一般的C程序相比, 操作系统究竟有何特殊之处呢? 完成PA3之后, 相信你就会略知一二了.
框架代码提供的这个操作系统还真的什么都没做! 回顾历史, 要实现一个最简单的操作系统, 就要实现以下两点功能:
- 用户程序执行结束之后, 可以跳转到操作系统的代码继续执行
- 操作系统可以加载一个新的用户程序来执行
来自操作系统的新需求
仔细思考, 我们就会发现, 上述两点功能中其实蕴含着一个新的需求: 程序之间的执行流切换. 我们知道函数调用一般是在一个程序内部发生的(动态链接库除外), 属于程序内部的执行流切换, 使用call/jal指令即可实现. 而上述两点需求需要在操作系统和用户程序之间进行执行流的切换. 不过, 执行流切换的本质, 也只是把PC从一个值修改成另一个值而已(黑客眼中就是这么理解的). 那么, 我们能否也使用call/jal指令来实现程序之间的执行流切换呢?
也许在GM-NAA I/O诞生的那个年代, 大家说不定还真是这样做的:
操作系统就是一个库函数, 用户程序退出的时候,
调用一下这个特殊的库函数就可以了, 就像我们在AM的程序中调用_halt()
一样.
不过, 后来人们逐渐认识到, 操作系统和其它用户程序还是不太一样的:
一个用户程序出错了, 操作系统可以运行下一个用户程序;
但如果操作系统崩溃了, 整个计算机系统都将无法工作.
所以, 人们还是希望能把操作系统保护起来, 尽量保证它可以正确工作.
在这个需求面前, 用call/jal指令来进行操作系统和用户进程之间的切换就显得太随意了. 操作系统的本质也是一个程序, 也是由函数构成的, 但无论用户程序是无意还是有心, 我们都不希望它可以把执行流切换到操作系统中的任意函数. 我们所希望的, 是一种可以限制入口的执行流切换方式, 显然, 这种方式是无法通过程序代码来实现的.
等级森严的制度
为了阻止程序将执行流切换到操作系统的任意位置, 硬件中逐渐出现保护机制相关的功能, 比如i386中引入了保护模式(protected mode)和特权级(privilege level)的概念, 而mips32处理器可以运行在内核模式和用户模式, riscv32则有机器模式, 监控者模式和用户模式. 这些概念的思想都是类似的: 简单地说, 只有高特权级的程序才能去执行一些系统级别的操作, 如果一个特权级低的程序尝试执行它没有权限执行的操作, CPU将会抛出一个异常信号, 来阻止这一非法行为的发生. 一般来说, 最适合担任系统管理员的角色就是操作系统了, 它拥有最高的特权级, 可以执行所有操作; 而除非经过允许, 运行在操作系统上的用户程序一般都处于最低的特权级, 如果它试图破坏社会的和谐, 它将会被判"死刑".
以i386为例, 存在0, 1, 2, 3四个特权级, 0特权级最高, 3特权级最低. 特权级n所能访问的资源, 在特权级0~n也能访问. 不同特权级之间的关系就形成了一个环: 内环可以访问外环的资源, 但外环不能进入内环的区域, 因此也有"ring n"的说法来描述一个进程所在的特权级.
+---------------------------------------------------+
| +-----------------------------------------------+ |
| | APPLICATIONS | |
| | +-----------------------------------+ | |
| | | CUSTOM EXTENSIONS | | |
| | | +-----------------------+ | | |
| | | | SYSTEM SERVICES | | | |
| | | | +-----------+ | | | |
| | | | | OS | | | | |
|-|-----+-----+-----+-----+-----+-----+-----+-----|-|
| | | | | |LEVEL|LEVEL|LEVEL|LEVEL| |
| | | | | | 0 | 1 | 2 | 3 | |
| | | | +-----+-----+ | | | |
| | | | | | | | |
| | | +-----------+-----------+ | | |
| | | | | | |
| | +-----------------+-----------------+ | |
| | | | |
| +-----------------------+-----------------------+ |
+------------------------+ +------------------------+
虽然i386提供了4个特权级, 但大多数通用的操作系统只会使用0级和3级: 操作系统处在ring 0, 一般的程序处在ring 3, 这就已经起到保护的作用了. 那CPU是怎么判断一个进程是否执行了无权限操作呢? 在这之前, 我们还要简单地了解一下i386中引入的与特权级相关的概念:
- DPL(Descriptor Privilege Level)属性描述了一段数据所在的特权级
- RPL(Requestor's Privilege Level)属性描述了请求者所在的特权级
- CPL(Current Privilege Level)属性描述了当前进程的特权级,
一次数据的访问操作是合法的, 当且仅当
data.DPL >= requestor.RPL # <1>
data.DPL >= current_process.CPL # <2>
两式同时成立, 注意这里的>=
是数值上的(numerically greater).
<1>式表示请求者有权限访问目标数据, <2>式表示当前进程也有权限访问目标数据. 如果违反了上述其中一式, 此次操作将会被判定为非法操作, CPU将会抛出异常信号, 并跳转到一个和操作系统约定好的内存位置, 交由操作系统进行后续处理.2>1>
对RPL的补充
你可能会觉得RPL十分令人费解, 我们先举一个生活上的例子.
- 假设你到银行找工作人员办理取款业务, 这时你就相当于requestor,
你的账户相当于data, 工作人员相当于current_process.
业务办理成功是因为
- 你有权限访问自己的账户(
data.DPL >= requestor.RPL
) - 工作人员也有权限对你的账户进行操作(
data.DPL >= current_process.CPL
)
- 你有权限访问自己的账户(
- 如果你想从别人的账户中取钱, 虽然工作人员有权限访问别人的账户(
data.DPL >= current_process.CPL
), 但是你却没有权限访问(data.DPL < requestor.RPL
), 因此业务办理失败 - 如果你打算亲自操作银行系统来取款, 虽然账户是你的(
data.DPL >= requestor.RPL
), 但是你却没有权限直接对你的账户金额进行操作(data.DPL < current_process.CPL
), 因此你很有可能会被抓起来
在计算机中也存在类似的情况: 用户进程(requestor)想对它自己拥有的数据(data)进行一些它没有权限的操作, 它就要请求有权限的进程(current_process, 通常是操作系统)来帮它完成这个操作, 于是就会出现"操作系统代表用户进程进行操作"的场景. 但在真正进行操作之前, 也要检查这些数据是不是真的是用户进程有权使用的数据.
通常情况下, 操作系统运行在ring 0, CPL为0, 因此有权限访问所有的代码和数据; 而用户进程运行在ring 3, CPL为3, 这就决定了它只能访问同样处在ring3的代码和数据. 这样, 只要操作系统将其私有代码和数据放在ring 0中, 恶意程序就永远没有办法访问到它们.
类似地, mips32的操作系统运行在内核模式, 用户进程运行在用户模式; 而对于riscv32, 操作系统运行在监控者模式, 用户进程运行在用户模式. 这些保护相关的概念和检查过程都是通过硬件实现的, 只要软件运行在硬件上面, 都无法逃出这一天网. 硬件保护机制使得恶意程序永远无法全身而退, 为构建计算机和谐社会作出了巨大的贡献.
这是多美妙的功能! 遗憾的是, 上面提到的很多概念其实只是一带而过, 真正的保护机制也还需要考虑更多的细节. ISA手册中一般都会专门有一章来描述保护机制, 这就已经看出来保护机制并不是简单说说而已. 根据KISS法则, 我们并不打算在NEMU中加入保护机制. 我们让所有用户进程都运行在最高特权级, 虽然所有用户进程都有权限执行所有指令, 不过由于PA中的用户程序都是我们自己编写的, 一切还是在我们的控制范围之内. 毕竟, 我们也已经从上面的故事中体会到保护机制的本质了: 在硬件中加入一些与特权级检查相关的门电路(例如比较器电路), 如果发现了非法操作, 就会抛出一个异常信号, 让CPU跳转到一个约定好的目标位置, 并进行后续处理.
分崩离析的秩序
特权级保护是现代计算机系统的一个核心机制, 但并不是有了这一等级森严的制度就在高枕无忧了,
黑客们总是会绞尽脑汁去试探这一制度的边界.
最近席卷计算机领域的, 就要数2018年1月爆出的Meltdown和Spectre这两个大名鼎鼎的硬件漏洞了.
这两个史诗级别的漏洞之所以震惊全世界, 是因为它们打破了特权级的边界:
恶意程序在特定的条件下可以以极高的速率窃取操作系统的信息.
Intel的芯片被爆都有Meltdown漏洞, 而Spectre漏洞则是危害着所有架构的芯片,
无一幸免, 可谓目前为止体系结构历史上影响最大的两个漏洞了.
如果你执行cat /proc/cpuinfo
, 你可能会在bugs
信息中看到这两个漏洞的影子.
Meltdown和Spectre给过去那些一味追求性能的芯片设计师敲响了警钟: 没有安全, 芯片跑得再快, 也是徒然. 有趣的是, 直接为这场闹剧买单的, 竟然是各大云计算平台的工程师们: 漏洞被爆出的那一周时间, 阿里云和微软Azure的工程师连续通宵加班, 想尽办法给云平台打上安全补丁, 以避免客户的数据被恶意窃取.
不过作为教学实验, 安全这个话题离PA还是太遥远了, 甚至性能也不是PA的主要目标. 这个例子想说的是, 真实的计算机系统非常复杂, 远远没到完美的程度. 这些漏洞的出现从某种程度上也说明了, 人们已经没法一下子完全想明白每个模块之间的相互影响了; 但计算机背后的原理都是一脉相承的, 在一个小而精的教学系统中理解这些原理, 然后去理解, 去改进真实的系统, 这也是做PA的一种宝贵的收获.