没有调不出的bug, 只有不理解的系统

PA3是整个PA的分水岭, 从PA3开始, 系统的复杂度开始逐渐上升, 越来越多的抽象层将会加入进来, 系统也开始逐渐完善, 应用也将更加真实.

这意味着, bug的传播链条会变得更复杂, 调试也越来越考验你对系统行为的理解. 如果你之前调bug都是抱大腿, 并没有在调试过程中去理解系统的细节, 你会发现在PA3中自己和大腿的差距正在迅速扩大: 大腿对整个系统的掌握越来越熟练, 而你则是越往前就越觉得寸步难行, 例如讲义和代码越来越看不懂, bug越来越神秘.

亡羊补牢的方法只有一个: 真的不能再偷懒了.

批处理系统

我们在PA2中已经实现了一个冯诺依曼计算机系统, 并且已经在AM上把打字游戏和FCEUX运行起来了. 有了IOE, 就几乎能把各种小游戏移植到AM上来运行了. 这些小游戏在计算机上的运行模式有一个特点, 它们会独占整个计算机系统: 我们可以在NEMU上一直玩打字游戏, 不想玩的时候, 我们就会退出NEMU, 然后重新运行FCEUX来玩.

事实上, 早期的计算机就是这样工作的: 系统管理员给计算机加载一个特定的程序(其实是上古时期的打孔卡片), 计算机就会一直执行这个程序, 直到程序结束或者是管理员手动终止, 然后再由管理员来手动加载下一个程序. 当年的程序也远远不如你玩的超级玛丽这么酷炫, 大多都是一些科学计算和物理建模的任务(比如弹道轨迹计算).

后来人们就想, 每次都要管理员来手动加载新的程序, 这太麻烦了. 回想起冯诺依曼计算机的工作方式, 它的其中一个特点就是: 当计算机执行完一条指令的时候, 就自动执行下一条指令. 类似的, 我们能不能让管理员事先准备好一组程序, 让计算机执行完一个程序之后, 就自动执行下一个程序呢?

这就是批处理系统的思想, 有了批处理系统之后, 就可以解放管理员的双手了. 而批处理系统的关键, 就是要有一个后台程序, 当一个前台程序执行结束的时候, 后台程序就会自动加载一个新的前台程序来执行.

这样的一个后台程序, 其实就是操作系统. 对, 你没有听错, 这个听上去好像什么都没做的后台程序, 真的是操作系统! 说起操作系统, 也许你会马上想到安装包都有几个GB的Windows. 但实际上, 历史上最早投入使用的操作系统GM-NAA I/O在1956年就诞生了, 而它的一个主要任务, 就是上文提到的"自动加载新程序".

什么是操作系统? (建议二周目思考)

这可是个大问题, 我们也鼓励你学习完操作系统课程之后再回来重新审视它.

最简单的操作系统

眼见为实, 我们先来展示一下操作系统究竟可以多简单. 在PA中使用的操作系统叫Nanos-lite, 它是南京大学操作系统Nanos的裁剪版. 是一个为PA量身订造的操作系统. 通过编写Nanos-lite的代码, 你将会认识到操作系统是如何使用硬件提供的机制(也就是ISA和AM), 来支撑程序的运行, 这也符合PA的终极目标. 至于完整版的Nanos, 你将会在下学期的操作系统课程上见识到它的庐山真面目.

我们为大家准备了Nanos-lite的框架代码, 通过执行以下命令获取:

cd ics2021
bash init.sh 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
├── README.md
├── resources
│   └── logo.txt    # Project-N logo文本
└── src
    ├── device.c    # 设备抽象
    ├── fs.c        # 文件系统
    ├── irq.c       # 中断异常处理
    ├── loader.c    # 加载器
    ├── main.c
    ├── mm.c        # 存储管理
    ├── proc.c      # 进程调度
    ├── ramdisk.c   # ramdisk驱动程序
    ├── resources.S # ramdisk内容和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目前的行为:

  1. 打印Project-N的logo, 并通过Log()输出hello信息和编译时间. 需要说明的是, Nanos-lite中定义的Log()宏并不是NEMU中定义的Log()宏. Nanos-lite和NEMU是两个独立的项目, 它们的代码不会相互影响, 你在阅读代码的时候需要注意这一点. 在Nanos-lite中, Log()宏通过你在klib中编写的printf()输出, 最终会调用TRM的putch().
  2. 调用init_device()对设备进行一些初始化操作. 目前init_device()会直接调用ioe_init().
  3. 初始化ramdisk. 一般来说, 程序应该存放在永久存储的介质中(比如磁盘). 但要在NEMU中对磁盘进行模拟是一个略显复杂工作, 因此先让Nanos-lite把其中的一段内存作为磁盘来使用. 这样的磁盘有一个专门的名字, 叫ramdisk.
  4. init_fs()init_proc(), 分别用于初始化文件系统和创建进程, 目前它们均未进行有意义的操作, 可以忽略它们.
  5. 调用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则有机器模式(M-mode), 监控者模式(S-mode)和用户模式(U-mode). 这些概念的思想都是类似的: 简单地说, 只有高特权级的程序才能去执行一些系统级别的操作, 如果一个特权级低的程序尝试执行它没有权限执行的操作, CPU将会抛出一个异常信号, 来阻止这一非法行为的发生. 一般来说, 最适合担任系统管理员的角色就是操作系统了, 它拥有最高的特权级, 可以执行所有操作; 而除非经过允许, 运行在操作系统上的用户程序一般都处于最低的特权级, 如果它试图破坏社会的和谐, 它将会被判"死刑".

以支持现代操作系统的RISC-V处理器为例, 它们存在M, S, U三个特权模式, 分别代表机器模式, 监管者模式和用户模式. M模式特权级最高, U模式特权级最低, 低特权级能访问的资源, 高特权级也能访问. 那CPU是怎么判断一个进程是否执行了无权限操作呢? 答案很简单, 只要在硬件上维护一个用于标识当前特权模式的寄存器(属于计算机状态的一部分), 然后在访问那些高特权级才能访问的资源时, 对当前特权模式进行检查. 例如RISC-V中有一条特权指令sfence.vma, 手册要求只有当处理器当前的特权模式不低于S模式才能执行, 因此我们可以在硬件上添加一些简单的逻辑来实现特权模式的检查:

is_sfence_vma_ok = (priv_mode == M_MODE) || (priv_mode == S_MODE);

可以看到, 特权模式的检查只不过时一些门电路而已. 如果检查不通过, 此次操作将会被判定为非法操作, CPU将会抛出异常信号, 并跳转到一个和操作系统约定好的内存位置, 交由操作系统进行后续处理.

通常来说, 操作系统运行在S模式, 因此有权限访问所有的代码和数据; 而一般的程序运行在U模式, 这就决定了它只能访问U模式的代码和数据. 这样, 只要操作系统将其私有代码和数据放S模式中, 恶意程序就永远没有办法访问到它们.

类似地, x86的操作系统运行在ring 0, 用户进程运行在ring 3; mips32的操作系统运行在内核模式, 用户进程运行在用户模式. 这些保护相关的概念和检查过程都是通过硬件实现的, 只要软件运行在硬件上面, 都无法逃出这一天网. 硬件保护机制使得恶意程序永远无法全身而退, 为构建计算机和谐社会作出了巨大的贡献.

这是多美妙的功能! 遗憾的是, 上面提到的很多概念其实只是一带而过, 真正的保护机制也还需要考虑更多的细节. ISA手册中一般都会专门有一章来描述保护机制, 这就已经看出来保护机制并不是简单说说而已. 根据KISS法则, 我们并不打算在NEMU中加入保护机制. 我们让所有用户进程都运行在最高特权级, 虽然所有用户进程都有权限执行所有指令, 不过由于PA中的用户程序都是我们自己编写的, 一切还是在我们的控制范围之内. 毕竟, 我们也已经从上面的故事中体会到保护机制的本质了: 在硬件中加入一些与特权级检查相关的门电路(例如比较器电路), 如果发现了非法操作, 就会抛出一个异常信号, 让CPU跳转到一个约定好的目标位置, 并进行后续处理.

分崩离析的秩序

特权级保护是现代计算机系统的一个核心机制, 但并不是有了这一等级森严的制度就高枕无忧了, 黑客们总是会绞尽脑汁去试探这一制度的边界. 最近席卷计算机领域的, 就要数2018年1月爆出的Meltdown和Spectre这两个大名鼎鼎的硬件漏洞了. 这两个史诗级别的漏洞之所以震惊全世界, 是因为它们打破了特权级的边界: 恶意程序在特定的条件下可以以极高的速率窃取操作系统的信息. Intel的芯片被指出均存在Meltdown漏洞, 而Spectre漏洞则是危害着所有架构的芯片, 无一幸免, 可谓目前为止体系结构历史上影响最大的两个漏洞了. 如果你执行cat /proc/cpuinfo, 你可能会在bugs信息中看到这两个漏洞的影子.

Meltdown和Spectre给过去那些一味追求性能的芯片设计师敲响了警钟: 没有安全, 芯片跑得再快, 也是徒然. 有趣的是, 直接为这场闹剧买单的, 竟然是各大云计算平台的工程师们: 漏洞被爆出的那一周时间, 阿里云和微软Azure的工程师连续通宵加班, 想尽办法给云平台打上安全补丁, 以避免客户的数据被恶意窃取.

不过作为教学实验, 安全这个话题离PA还是太遥远了, 甚至性能也不是PA的主要目标. 这个例子想说的是, 真实的计算机系统非常复杂, 远远没到完美的程度. 这些漏洞的出现从某种程度上也说明了, 人们已经没法一下子完全想明白每个模块之间的相互影响了; 但计算机背后的原理都是一脉相承的, 在一个小而精的教学系统中理解这些原理, 然后去理解, 去改进真实的系统, 这也是做PA的一种宝贵的收获.

results matching ""

    No results matching ""