批处理系统
我们在PA2中已经实现了一个冯诺依曼计算机系统, 并且已经在AM上把打字游戏运行起来了. 有了IOE, 几乎能把各种小游戏移植到AM上来运行了. 这些小游戏在计算机上的运行模式有一个特点, 它们会独占整个计算机系统: 我们可以在NEMU上一直玩打字游戏, 不想玩的时候, 我们就会退出NEMU, 然后重新运行超级玛丽来玩.
事实上, 早期的计算机就是这样工作的: 系统管理员给计算机加载一个特定的程序(其实是上古时期的打孔卡片), 计算机就会一直执行这个程序, 直到程序结束或者是管理员手动终止, 然后再由管理员来手动加载下一个程序. 当年的程序也远远不如你玩的超级玛丽这么酷炫, 大多都是一些科学计算和物理建模的任务(比如弹道轨迹计算).
后来人们就想, 每次都要管理员来手动加载新的程序, 这太麻烦了. 能不能让管理员事先准备好一组程序, 让计算机执行完一个程序之后, 就自动执行下一个程序呢? 这就是批处理系统的思想, 有了批处理系统之后, 就可以解放管理员的双手了. 而批处理系统的关键, 就是要有一个后台程序, 当一个前台程序执行结束的时候, 后台程序就会自动加载一个新的前台程序来执行.
这样的一个后台程序, 其实就是操作系统. 对, 你没有听错, 这个听上去好像什么都没做的后台程序, 就是操作系统! 说起操作系统, 也许你会马上想到安装包都有几个GB的Windows. 但实际上, 历史上最早投入使用的操作系统GM-NAA I/O在1956年就诞生了, 而它的一个主要任务, 就是上文提到的"自动加载新程序".
什么是操作系统? (建议二周目思考)
这可是个大问题, 我们也鼓励你学习完操作系统课程之后再回来重新审视它.
最简单的操作系统
那么, 我们也来介绍一下在PA中使用的最简单的操作系统吧, 它的名字叫Nanos-lite. Nanos-lite是南京大学操作系统Nanos的裁剪版, 是一个为PA量身订造的操作系统. 通过编写Nanos-lite的代码, 你将会认识到操作系统是如何使用机器提供的接口, 来支撑程序的运行的. 这也符合PA的终极目标.
框架代码中已经为大家准备好了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 # 加载器
├── main.c
├── mm.c # 存储管理
├── proc.c # 进程调度
├── ramdisk.c # ramdisk驱动程序
└── syscall.c # 系统调用处理
需要提醒的是, Nanos-lite是运行在AM之上, AM的API在Nanos-lite中都是可用的.
虽然操作系统对我们来说是一个特殊的概念, 但在AM看来,
它只是一个使用AM API的普通C程序而已, 和超级玛丽没什么区别.
同时, 你会再次体会到AM的好处: Nanos-lite的实现可以是机器无关的,
这意味着, 你可以像开发klib那样, 在native
上调试你编写的Nanos-lite.
另外, 虽然不会引起明显的误解, 但在引入Nanos-lite之后, 我们还是会在某些地方使用"用户进程"的概念, 而不是"用户程序". 如果你现在不能理解什么是进程, 你只需要把进程作为"正在运行的程序"来理解就可以了. 还感觉不出这两者的区别? 举一个简单的例子吧, 如果你打开了记事本3次, 计算机上就会有3个记事本进程在运行, 但磁盘中的记事本程序只有一个. 进程是操作系统中一个重要的概念, 有关进程的详细知识会在操作系统课上进行介绍.
一开始, 在nanos-lite/include/common.h
中所有与实验进度相关的宏都没有定义,
此时Nanos-lite的功能十分简单.
我们来简单梳理一下Nanos-lite目前的行为:
- 通过
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的运行.
更新Makefile
我们在2018/11/03 16:00:00对框架代码的nanos-lite/Makefile
进行了更新.
如果你在此时间之前获得框架代码, 请根据这里手动更新该文件;
如果你在此时间之后获得框架代码, 你不需要进行额外的操作.
由于Nanos-lite本质上也是一个AM程序, 我们可以采用相同的方式来编译/运行Nanos-lite.
在nanos-lite/
目录下执行
make ARCH=x86-nemu update
make ARCH=x86-nemu run
即可. 另外如前文所说, 你也可以将Nanos-lite编译到native
上并运行, 来帮助你进行调试.
框架代码提供的这个操作系统还真的什么都没做! 回顾历史, 要实现一个最简单的操作系统, 就要实现以下两点功能:
- 用户程序执行结束之后, 可以跳转到操作系统的代码继续执行
- 操作系统可以加载一个新的用户程序来执行
来自操作系统的新需求
仔细思考, 我们就会发现, 上述两点功能中其实蕴含着一个新的需求: 程序之间的执行流切换.
我们知道函数调用一般是在一个程序内部发生的(动态链接库除外),
属于程序内部的执行流切换, 使用call指令即可实现.
而上述两点需求需要在操作系统和用户程序之间进行执行流的切换.
不过, 执行流切换的本质, 也只是把%eip
从一个值修改成另一个值而已(黑客眼中就是这么理解的).
那么, 我们能否也使用call指令来实现程序之间的执行流切换呢?
也许在GM-NAA I/O诞生的那个年代, 大家说不定还真是这样做的:
操作系统就是一个库函数, 用户程序退出的时候,
调用一下这个特殊的库函数就可以了, 就像我们在AM的程序中调用_halt()
一样.
不过, 后来人们逐渐认识到, 操作系统和其它用户程序还是不太一样的:
一个用户程序出错了, 操作系统可以运行下一个用户程序;
但如果操作系统崩溃了, 整个计算机系统都将无法工作.
所以, 我们还是希望能把操作系统保护起来, 尽量保证它可以正确工作.
在这个需求面前, 用call指令来进行操作系统和用户进程之间的切换就显得太随意了. 操作系统的本质也是一个程序, 也是由函数构成的, 但无论用户程序是无意还是有心, 我们都不希望它可以把执行流切换到操作系统中的任意函数. 我们所希望的, 是一种可以限制入口的执行流切换方式, 显然, 这种方式是无法通过程序代码来实现的.
等级森严的制度
为了阻止程序将执行流切换到操作系统的任意位置, 硬件中逐渐出现保护机制相关的功能, 比如i386中引入了保护模式(protected mode)和特权级(privilege level)的概念. 简单地说, 只有高特权级的程序才能去执行一些系统级别的操作, 如果一个特权级低的程序尝试执行它没有权限执行的操作, 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 | |
| | | | +-----+-----+ | | | |
| | | | | | | | |
| | | +-----------+-----------+ | | |
| | | | | | |
| | +-----------------+-----------------+ | |
| | | | |
| +-----------------------+-----------------------+ |
+------------------------+ +------------------------+
虽然80386提供了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中, 恶意程序就永远没有办法访问到它们. 这些保护相关的概念和检查过程都是通过硬件实现的, 只要软件运行在硬件上面, 都无法逃出这一天网. 硬件保护机制使得恶意程序永远无法全身而退, 为构建计算机和谐社会作出了巨大的贡献.
这是多美妙的功能! 遗憾的是, 上面提到的很多概念其实只是一带而过, 真正的保护机制也还需要考虑更多的细节. i386手册中专门有一章来描述保护机制, 就已经看出来这并不是简单说说而已. 根据KISS法则, 我们并不打算在NEMU中加入保护机制. 我们让所有用户进程都运行在ring 0, 虽然所有用户进程都有权限执行所有指令, 不过由于PA中的用户程序都是我们自己编写的, 一切还是在我们的控制范围之内. 毕竟, 我们也已经从上面的故事中体会到保护机制的本质了: 在硬件中加入一些与特权级检查相关的门电路(例如比较器电路), 如果发现了非法操作, 就会抛出一个异常信号, 让CPU跳转到一个约定好的目标位置, 并进行后续处理.
分崩离析的秩序
特权级保护是现代计算机系统的一个核心机制, 但并不是有了这一等级森严的制度就在高枕无忧了,
黑客们总是会绞尽脑汁去试探这一制度的边界.
最近席卷计算机领域的, 就要数2018年1月爆出的Meltdown和Spectre这两个大名鼎鼎的硬件漏洞了.
这两个史诗级别的漏洞之所以震惊全世界, 是因为它们打破了特权级的边界:
恶意程序在特定的条件下可以以极高的效率窃取操作系统的信息.
Intel的芯片被爆都有Meltdown漏洞, 而Spectre漏洞则是危害着所有架构的芯片,
无一幸免, 可谓目前为止体系结构历史上影响最大的两个漏洞了.
如果你执行cat /proc/cpuinfo
, 你应该会在bugs
信息中看到这两个漏洞的影子.
Meltdown和Spectre给过去那些一味追求性能的芯片设计师敲响了警钟: 没有安全, 芯片跑得再快, 也是徒然. 有趣的是, 直接为这场闹剧买单的, 竟然是各大云计算平台的工程师们: 漏洞被爆出的那一周时间, 阿里云和微软Azure的工程师连续通宵加班, 想尽办法给云平台打上安全补丁, 以避免客户的数据被恶意窃取.
不过作为教学实验, 安全这个话题离PA还是太遥远了, 甚至性能也不是PA的主要目标. 这个例子想说的是, 真实的计算机系统非常复杂, 远远没到完美的程度, 这些漏洞的出现从某种程度上也说明了, 复杂程度已经到了人们没法一下子想明白每个模块之间的相互影响了; 但计算机背后的原理都是一脉相承的, 在一个小而精的教学系统中理解这些原理, 然后去理解, 去改进真实的系统, 这也是做PA的一种宝贵的收获.