操作系统有三条主线: 软件(应用), 硬件(计算机), 操作系统(软件直接访问硬件带来麻烦太多而引入的中间件). 若要理解操作系统, 对操作系统的服务对象(应用程序)有精确的理解是必不可少的.
应用视角下的操作系统
先从硬件(CPU)的视角开始, 对于一个普通的c程序而言, 只不过是由一堆字节序列组成, CPU不断的取指, 译码, 执行. CPU是一个无情的执行机器而已.
个人认为, 从操作系统的视角上看, 软件(程序)是由一组操作系统提供的API加上计算指令组成的. 程序只能允许计算, 在需要外部请求时需要让出控制权让操作系统来帮忙处理.
解决异常退出
有办法让程序”停下来”吗?
- 纯”计算”的状态机: 不行
- 没有”停机”的指令
解决办法: 用一条特殊的指令请操作系统帮忙
1 | movq $SYS_exit, %rax # exit( |
- 把”系统调用”的参数放到寄存器中
- 执行syscall, 操作系统接管程序
- 程序把控制权完全交给操作系统
- 操作系统可以改变程序状态甚至终止程序
汇编代码的状态机模型
Everything is a state machine: 计算机 = 数字电路 = 状态机
- 状态 = 内存M + 寄存器R
- 初始状态 = ABI规定(例如由一个合法的%rsp)
- 状态迁移 = 执行一条指令
操作系统上的程序
- 所有的指令都只能计算
- deterministic: mov, add, sub, call
- non-deterministic: rdrand,…
- syscall把(M, R)完全交给操作系统
操作系统使得应用程序有”独占整个计算机, 逐条指令执行”的假象, 如果把程序类比成我们自己的意识, 感知到的时间似乎是连续不断的. 操作系统上的应用程序通过系统调用指令与操作系统交互, 此时程序执行被完全暂停, 但操作系统仍然在工作—就像麻醉后醒来, 周围的环境发生了变化, 但我们完全没有感受到时间的流逝.
因此, 我们对操作系统上程序的一个很重要的理解是程序是计算和系统调用组成的状态机; 大部分计算指令都是确定性(deterministic, 在相同环境下执行的结果总是相同)的, 少部分指令(如rdrand返回随机数)则有非确定的结果. 系统调用指令是不确定性的, 操作系统可能会将计算机运行环境中的信息, 例如来自设备的输入传递到程序中.
简单C程序的状态机模型(语义)
对C程序作出简化
- 简化: 改写成每条语句至多一次运算/函数调用的形式
状态机定义
- 状态 = 堆 + 栈
- 初始状态 = main的第一条语句
- 状态迁移 = 执行一条语句中的一小步
对于下来则是
状态
- Stack frame的列表 + 全局变量
初始状态
- 仅有一个frame: main(argc, argv); 全局变量为初始值
状态迁移
- 执行frames.top处的简单语句
- 函数调用 = push.frame(frame.PC = 入口)
- 函数返回 = popframe
理解编译器
我们有两种状态机
- 高级语言代码.c
- 状态: 栈, 全局变量, 状态迁移: 语句执行
- 汇编指令序列.s
- 状态: (M, R); 状态迁移: 指令执行
- 编译器是两者之间的桥梁
- .s = compile(.c)
那到底什么是编译器?
- 不同的优化级别产生不同的指令序列
- 凭什么说一个.s = compile(.c)是”对的”还是”错的”
.s = compile(.c): 编译正确性
.c 执行中的所有外部观测者可见的行为, 必须在.s中保持一致
- External function calls(编译时确定)
- 如何调用由Application Binary Interface(ABI)规定
- 可能包含系统调用, 因此不可更改, 不可交换
- 编译器提供的”不可优化”标注
- volatile[load|store|inline assembly]
- Termination
- .c终止当且仅当.s终止
在此前提下, 任何翻译都是合法的(例如我们期望更快或更短的代码)
- 编译优化的实际实现: (context-sensitive) rewriting rules
- 代码示例: 观测编译器优化行为和compiler barrier
操作系统中”任何程序”的一生
任何程序 = 调用syscall的状态机
- 被操作系统加载
- 通过另一个进程执行execve设置为初始状态
- 状态机执行
- 进程管理: fork, execve, exit
- 文件/设备管理: open, close, read, write
- 存储管理: mmap, brk
- 调用_exit(exit_group)
观测程序与操作系统的联系
stace是一个非常重要的命令行工具, 帮助我们”观测”应用程序和操作系统的边界. 实际上, 任何程序的执行就是状态机在计算机上的运行, 因此”用合适的方式观测状态机执行”就是我们理解程序的根本方法. 调试器, trace, profiler提供了不同侧面的理解手段, 这三个工具将会在课程中反复出现.
Take-away Messages
无论是汇编代码还是高级语言程序, 它们都可以表示成状态机:
- 高级语言代码.c
- 状态: 栈, 全局变量; 状态迁移: 语句执行
- 汇编指令序列.s
- 状态: (M, R); 状态迁移: 指令执行
- 编译器实现了两种状态机之间的翻译
应用程序与操作系统沟通的唯一桥梁是系统调用指令(例如x86-64的syscall). 计算机系统不存在玄学; 一切都建立在确定的机制上
硬件视角的操作系统
计算机硬件的状态机模型
不仅仅是程序, 整个计算机系统也是一个状态机
- 状态: 内存和寄存器数值
- 初始状态: 手册规定(CPU Reset)
- 状态迁移
- 任意选择一个处理器CPU
- 响应处理外部中断
- 从cpu.PC取指令执行
Bare-metal与程序员的约定(计算机系统状态机的初始状态)
CPU Reset后的状态(寄存器值)
- 厂商自由处理这个地址上的值
- Memory-mapped I/O
厂商为操作系统开发者提供Firmware
- 管理硬件和系统配置
- 把存储设备上的代码加载到内存
- 例如存储介质上的第二级loader(加载器)
- 或者直接加载操作系统(嵌入式系统)
X86 CPU Reset之后: 到底执行了什么?
状态机(初始状态)开始执行:
- 从PC取指令, 译码, 执行…
- 开始执行厂商”安排好”的Firmware代码
- x86 Reset Vector 是一条向Firmware跳转的jmp指令
Legacy BIOS: 约定:
BIOS提供机制, 将程序员的代码载入内存
- Legacy BIOS把第一个可引导设备的第一个512字节加载到物理内存的0x7c00位置
- 此时处理器处于16-bit模式
- 规定CS:IP = 0x7c00, (R[CS] << 4) | P[IP] == 0x7c00
- 可能性1: CS = 0X07C0, IP = 0
- 可能性2: CS = 0, IP = 0x7c00
- 其他没有任何约束
虽然最多只有446个字节代码(64B分区表+2B标识)
- 但控制权已经回到程序员手中
UEFI上的操作系统加载
标准化的加载流程
- 磁盘必须按GPT(GUID Partion Table)方式格式化
- 预留一个FAT32分区
- Firmware能够加载任意大小的PE可执行文件.efi
- 没有legacy boot 512字节限制
- EFI应用可以返回firmware
更好的程序支持
- 驱动设备框架
- 更多的功能, 例如Secure Boot, 只能启动”信任”的操作系统
我们已经获得的能力
为硬件直接编程
- 可以让机器运行任意不超过510字节的指令序列
- 编写任何指令序列(状态机)
- 只要能问出问题, 就是可以RTFM/STRFW/ChatGPT找到答案
- 如何在汇编里生成n个字节的0
- 如何在x86 16bit mode 打印字符
- 只要能问出问题, 就是可以RTFM/STRFW/ChatGPT找到答案
操作系统: 就一个C程序
- 用510字节的指令完成磁盘 -> 内存的加载
- 初始化C程序的执行环境
- 操作系统就开始运行了
大学的真正意义
- 迅速消化数十年来建立起的学科体系
- 将已有的思想和办法重新组织, 为大家建立好台阶
- 破除”写操作系统很难”, “写操作系统很牛”类似的错误认识
- 操作系统真的就是个C程序
- 你只是需要”被正确告知”一些额外的知识
- 然后写代码, 吃苦头
- 从而建立正确的”专业世界观
Take-away Messages
计算机系统是严格的数学对象: 没有魔法; 计算机系统的一切行为都是可预测, 可理解的.
- 处理器是无情的执行指令的机器
- 厂商配置好处理器Reset后的行为: 先运行Firmware, 再加载操作系统
- 厂商逐渐形成了达成共识的Firmware Specification(IMB PC “兼容机”, UEFI, …)
- 操作系统真的就是个C程序, 只是能直接访问计算机硬件