7.os kernel 实现
我们已经了解并发编程实践需要的几乎全部内容——线程、内存模型、互斥、同步和并发 bugs 的应对。但我们一直是在简化的线程模型 (thread.h) 上讲解的。但我们还没有回答一个关键问题:线程到底在计算机硬件上是如何实现的?即便系统中只有一个处理器,我们依然可以创建很多并发执行的线程。
讲并发,操作系统课,并不是编程课,为什么花这么多时间讲并发编程?
我们前面都是在简化的 api 上学习的,这部分我们考虑线程库如何实现?即便系统中只有一个处理器,我们依然可以创建很多并发执行的线程。
中断的机制奠定了现代操作系统的基础。
多处理器与中断
50行实现嵌入式操作系统
线程库是怎么实现的?操作系统内核里如何实现线程。
多处理器模型
做实验的时候,有个 AbstractMachine 的框架
AbstractMachine 的实验框架,使用 am.h 里的 api 可以直接使用 C 控制计算机硬件去控制一个操作系统内核。lab 的名字是 kernel,去实现一个小的 在 bare metal 上的 操作系统。
这一切的基础,Turing Machine,图灵机模型。一个有限状态机+一条纸带

读取当前的状态,然后根据状态找到下一个状态,还可以写出去。这个效果是在无限长的纸带写出 010101 就好像编了一个程序。
计算机科学领域里有个非常重要的定理:图灵机的表达能力非常强,强到我们可以写一个图灵机的程序去模拟另外一个图灵机。
我们可以做一台图灵机,可以解释执行写在纸上的 C 程序,这些都是可以证明的。
Turing Machine: 一个处理器,一个地址空间
“无情的执行指令的机器” (rvemu) 的抽象
可用的内存是静态变量和 heap
只能执行计算指令和 putch, halt
(模拟器,指令的模拟执行)
risc-v 的模拟器,无情的执行指令的机器。从手册 reset 规定好的地方开始,不断执行。有限状态机上有个状态是停机,有个有意思的问题:halting problem,即能否写一个程序,这个程序可以输入另外一个程序,会不会在有限步内终止。这是个不可判定问题,即无法用图灵机解决。
这个简单的 risc-v 的指令模拟器,绝大多数指令都是计算指令
多处理器就不一样了,多个处理器的 mpe 功能,AbstractMachine 每个处理器类似一个线程,多个处理器共享一个地址空间,状态机模型也发生了一点变化。
状态:共享内存和每个处理器的内部状态
多处理器上,机器是不确定的执行指令的机器
状态迁移:处理器 t 分隔出一个 “单处理器计算机”
任一时刻,我们可以让其中一个处理器带着内存往前走一步。这就让状态机变得非常复杂,也变得不符合直觉了。
简易的多处理器内核(L1 的模型),和多线程程序一致,同步通过原子指令实现
代码模拟的简易的多处理器系统模型容易理解,但同时也有缺陷:它上似乎没办法运行任何 “正经” 的操作系统:如果任何处理器上的应用程序死循环,那么这个处理器就彻底 “卡死” 了。而使真正得我们的线程可以 “逃出” 死循环的核心机制,就是操作系统管理的硬件中断。
中断机制
如果模拟的这个简易处理器,一旦某个 CPU 运行了死循环,直接卡死,这个 CPU 就和消失一样。
这和我们的直觉不同,为什么我们写 C 程序,while(1) 为什么不会卡死 CPU 呢?
为此,我们需要引入 CPU 的一个重要机制:中断。
8086 里有中断,我们如何理解中断这个行为呢。
中断在硬件上就是一根信号线。边沿触发或者低电平触发

面包板上搭 74 处理器的电路,手作的乐趣,是写 verilog 感受不到的。
中断告诉 CPU 有事情来了,不管你在干什么,都停下来。停下来以后的行为,由处理器确定。
实际的处理器并不是 “无情地执行指令”
无情的执行指令
同时有情地响应外部的打断
每个指令周期的结束都会检测中断信号。
中断有两个脚, 和 不可屏蔽中断。
中断发生了,应该干什么,即中断的响应
首先检查处理器配置是否允许中断(相关寄存器的配置)
如果处理器关闭中断,则忽略(IF=1)
x86 Family
询问中断控制器获得中断号 n
保存 CS, RIP, RFLAGS, SS, RSP 到堆栈
跳转到 IDT[n] 指定的地址,并设置处理器状态 (例如关闭中断)
RISC-V (M-Mode)
检查 mie 是否屏蔽此次中断
跳转 PC = (mtvec & ~0xf)
更新 mcause.Interrupt = 1
跳转这个行为以为着不正常。中断响应 x86 做的多一点,有点像强行插入了一个函数跳转,这个地址是我们可以设置的,即此函数可以任意实现。RISC-V 流程基本上也相差不大。
教科书到此为止了。如果想更深入,需要去写能执行的代码,即实现一个模拟器。
这个 CPU 有 32 个 32 bit 寄存器,一个 pc 寄存器,以及一段其它寄存器,以及一个死循环,模拟一步指令执行,以及最主要的,中断。这个简单模拟器里关于中断的代码就十几行,有助于对中断的理解。
中断是奠定操作系统“霸主地位”的机制,操作系统具有中断的完整控制权,随意开关,然而应用程序做不到,这就意味着即使是死循环也可以被中断打断,然后操作系统的代码就开始执行了。
如果想强行在用户程序里关中断会怎么样呢?
会报一个段错误。x86 在保护模式下,应用程序执行了非法操作,也会触发一个中断告知操作系统,然后报错。
框架代码里的中断 api,
检查是否开了中断,然后提供了一个设置中断函数的 api。这使得我们使用框架代码学习操作系统的时候不需要写汇编语言了。
每当中断发生的时候,就跳转到了框架代码执行,在 cte_init 传入了事件原因,然后还保存了上下文。操作系统最早的并发性就来自于并发,如中断处理中访问了共享数据,所有有些教科书上会说关闭中断就能实现互斥。单处理器系统上,关了中断永远也不会和别人并发,就可以安全的把一个数据结构做完操作,然后打开中断。这也是 MCU 上跑的 RTOS 实现互斥的方式。
最早操作系统的并发性就是来自于中断 (而不是多处理器)
关闭中断就能实现互斥
系统中只有一个处理器,永远不会被打断
关中断实现了 “stop the world”
一个真正的计算机系统模型,不仅仅有多处理器,还有中断,每个 CPU 都可以中断。数据竞争到处都有可能发生。
理解了中断,结合前面,就有了一个有趣的挑战,如何实现一个中断安全的自旋锁?
这个事情没那么简单,中断和多处理器是两个并发来源。
AA 型的死锁,哪怕我们在 lock 的时候关中断,unlock 的时候开中断,也有可能会有问题。
50 行代码实现操作系统内核
中断还能来做什么呢?框架代码里的 API
一个参数是 Event ev 告诉是什么类型的中断,还有个 Context *ctx ,来研究这个参数。
一个程序执行流,往前走,突然来了个中断,不管我们执行了什么代码,中断程序是可以任意修改寄存器的,即原来代码里放在寄存器里的东西会被破坏。
所以框架代码需要考虑这个事情并妥善处理,所以框架代码干了更多的事情。
如何让被中断的代码回到原来的状态去执行?即要保存原来的状态,中断发生的时候保存,返回的时候,handler 就返回了需要恢复的寄存器的数据所在的地址。
这是一个非常小心的操作,每恢复一个寄存器,这个寄存器就不能再用了,所以这部分代码是操作系统里很有趣,也很精巧的代码,即上下文切换的代码。
x86-64 下的 Context
一些比较特殊的寄存器 rip,
程序的状态,一个程序要可以运行起来,关键的地方 rip 此程序的代码,rsp 指向此程序的栈,这些都是程序独有的,实打实存在的内存,如何在只有一个 CPU 的情况下,放两个程序执行?
只要把两个程序的代码,数据、堆栈,都放到内存里。当然对于线程来说代码和数据是共享的,堆栈独享。
只要把两个线程的寄存器都放在内存里,中断的时候,把需要的数据般上去。
所以在中断处理程序开始执行时,所有可以被执行的线程,所有的内存、寄存器都在内存里,所有的线程看起来一样,随便把那个线程的寄存器往 CPU 上一贴,这个线程就开始执行了。
这就是现代操作系统实现分时的方法。
这就是上下文切换,状态机在执行……
发生了中断
操作系统代码开始执行
状态机被 “封存”
我们可以用代码 “切换” 到另一个状态机
中断返回另一个状态机的状态
“封存” 的状态还在
下次可以被恢复到处理器上执行
看代码,我们如何表示一个线程?线程就是一个数据结构,这个数据结构的定义
定义了名字、下一个线程、入口、以及私有的一个栈,整个数据结构 8kb,正好栈还是往数据头的方向在增长。
当然这个地方是个难点。在做了 “状态机” 和 “并发” 足够的铺垫后,我们终于回到了机器指令 (状态机) 和操作系统内核了。本次课回答了为什么 while (1) 不会把操作系统 “卡死”:
在操作系统代码切换到应用程序执行时,操作系统内核会打开中断
“不可信任” 的应用程序定期会收到时钟中断被打断,且应用程序无权配置中断
操作系统接管中断后,可以切换到另一个程序执行
要更深入的理解,还是得去读代码,这个过程必不可少,毕竟学习理论最终也是为了能更容易理解代码在干什么。
某种意义上讲,CPU 是一个状态机的容器,并不在意执行那段代码,操作系统把状态机放在 CPU 这个容器上,状态机就开始往前走了。
最后更新于
这有帮助吗?