- 1. 操作系统概述
- 2. 操作系统上的程序
- define call(…) ({ *(++top) = (Frame) { .pc = 0, VA_ARGS }; })
- define ret() ({ top—; })
- define goto(loc) ({ f->pc = (loc) - 1; })
- 3. 多处理器编程
- 4. 理解并发程序执行
- 5. 并发控制:互斥
- 6. 并发控制:同步
- 10. 状态机模型的应用
- 11. 操作系统上的进程
- 12. 进程的地址空间
- 13. 系统调用和 Shell
- GP, #PF 或 #DIV
- 总结
- 14. C 标准库的实现
- 15. Fork 的应用
- 16. 什么是可执行文件
- 17. 动态链接和加载
- 问题
- 参考
operating_systems_three_easy_pieces.pdf
1. 操作系统概述
2. 操作系统上的程序
什么是程序(源代码视角)
程序就是状态机
void hanoi(int n, char from, char to, char via) {
if (n == 1) printf("%c -> %c\n", from, to);
else {
hanoi(n - 1, from, via, to);
hanoi(1, from, to, via);
hanoi(n - 1, via, to, from);
}
return;
}
#include <stdio.h>
#include "hanoi-r.c"
int main() {
hanoi(3, 'A', 'B', 'C');
return 0;
}
C 程序的状态机模型 (语义,semantics)
- 状态 = 堆 + 栈
- 初始状态 = main 的第一条语句
- 迁移 = 执行一条简单语句
C 程序的状态机模型 (语义,semantics)
- 状态 = stack frame 的列表 (每个 frame 有 PC) + 全局变量
- 初始状态 = main(argc, argv), 全局变量初始化
- 迁移 = 执行 top stack frame PC 的语句; PC++
- 函数调用 = push frame (frame.PC = 入口)
- 函数返回 = pop frame
应用:将任何递归程序就地转为非递归
- 汉诺塔难不倒你 hanoi-nr.c
- A → B, B → A 的也难不倒你
- 还是一样的 call(),但放入不同的 Frame ```shell typedef struct { int pc, n; char from, to, via; } Frame;
define call(…) ({ *(++top) = (Frame) { .pc = 0, VA_ARGS }; })
define ret() ({ top—; })
define goto(loc) ({ f->pc = (loc) - 1; })
void hanoi(int n, char from, char to, char via) { Frame stk[64], top = stk - 1; call(n, from, to, via); for (Frame f; (f = top) >= stk; f->pc++) { switch (f->pc) { case 0: if (f->n == 1) { printf(“%c -> %c\n”, f->from, f->to); goto(4); } break; case 1: call(f->n - 1, f->from, f->via, f->to); break; case 2: call( 1, f->from, f->to, f->via); break; case 3: call(f->n - 1, f->via, f->to, f->from); break; case 4: ret(); break; default: assert(0); } } }
```shell
#include <stdio.h>
#include <assert.h>
#include "hanoi-nr.c"
int main() {
hanoi(3, 'A', 'B', 'C');
return 0;
}
什么是程序 (二进制代码视角)
还是状态机
- 状态 = 内存 M + 寄存器 R
- 初始状态 = (稍后回答)
迁移 = 执行一条指令
所有的指令都只能计算
把 (M,R) 完全交给操作系统,任其修改
- 一个有趣的问题:如果程序不打算完全信任操作系统?
- 实现与操作系统中的其他对象交互
- 读写文件/操作系统状态 (例如把文件内容写入 M)
- 改变进程 (运行中状态机) 的状态,例如创建进程/销毁自己
程序 = 计算 + syscall
3. 多处理器编程
底层原理
详见:http://jyywiki.cn/OS/2022/slides/3.slides#/
4. 理解并发程序执行
详见:http://jyywiki.cn/OS/2022/slides/4.slides#/
5. 并发控制:互斥
详见:http://jyywiki.cn/OS/2022/slides/5.slides#/
共享内存上的互斥
自旋锁(Spin Lock)
使用场景
- 临界区几乎不“拥堵”
- 持有自旋锁时禁止执行流切换
缺点
性能问题 (0)
-
性能问题 (1)
除了进入临界区的线程,其他处理器上的线程都在空转
-
性能问题 (2)
获得自旋锁的线程可能被操作系统切换出去
- 操作系统不 “感知” 线程在做什么
- (但为什么不能呢?)
-
互斥锁(Mutex Lock)
Futex = Spin + Mutex
自旋锁 (线程直接共享 locked)
更快的 fast path
- xchg 成功 → 立即进入临界区,开销很小
- 更慢的 slow path
- xchg 失败 → 浪费 CPU 自旋等待
睡眠锁 (通过系统调用访问 locked)
- 更快的 slow path
- 上锁失败线程不再占用 CPU
- 更慢的 fast path
- 即便上锁成功也需要进出内核 (syscall)
Futex: Fast Userspace muTexes
- Fast path: 一条原子指令,上锁成功立即返回
- Slow path: 上锁失败,执行系统调用睡眠
- 性能优化的最常见技巧
- 看 average (frequent) case 而不是 worst case
- 性能优化的最常见技巧
先在用户空间自旋
- 如果获得锁,直接进入
- 未能获得锁,系统调用
解锁以后也需要系统调用
- futex.py
- 更好的设计可以在 fast-path 不进行系统调用
总结
Take-away message
软件不够,硬件来凑 (自旋锁)
- 用户不够,内核来凑 (互斥锁)
- 找到你依赖的假设,并大胆地打破它
-
6. 并发控制:同步
详见:http://jyywiki.cn/OS/2022/slides/6.slides#/
条件变量:Conditional Variables
条件变量 API
wait(cv, mutex)
- 调用时必须保证已经获得 metex
- 释放 mutex,进入睡眠状态
- signal/notify(cv)
- 如果有线程正在等待 cv,则唤醒其中一个线程
broadcast/notifyAll(cv)
-
条件变量:实现并行计算
```c struct job { void (run)(void arg); void *arg; }
while (1) { struct job *job;
mutex_lock(&mutex); while (! (job = get_job()) ) { wait(&cv, &mutex); } mutex_unlock(&mutex);
job->run(job->arg); // 不需要持有锁 // 可以生成新的 job // 注意回收分配的资源 }
<a name="r7KHE"></a>
## 信号量:Semaphore
<a name="Oa8lX"></a>
## 哲学家吃饭问题
万能的方法:条件变量
<a name="izsUT"></a>
## 总结
- 实现同步的方法
- 条件变量、信号量
- Job queue 可以实现几乎任何并行算法
- 不要“自作聪明”设计算法,小心求证
<a name="PXkow"></a>
# 7. 真实世界的并发编程
详见:[http://jyywiki.cn/OS/2022/slides/7.slides#/](http://jyywiki.cn/OS/2022/slides/7.slides#/)
<a name="as2L0"></a>
## 高性能计算中的并发编程
<a name="OL2sC"></a>
### 高性能计算程序:特点
“A technology that harnesses the power of supercomputers or computer clusters to solve complex problems requiring massive computation.” (IBM)<br />以计算为中心
- 系统模拟:天气预报、能源、分子生物学
- 人工智能:神经网络训练
- 矿厂:纯粹的 hash 计算
- [HPC-China 100](http://www.hpc100.cn/top100/20/)
<a name="tYCXW"></a>
### 高性能计算:主要挑战
计算任务如何分解
- **计算图需要容易并行化**
- 机器-线程两级任务分解
- 生产者-消费者解决一切
- [MPI](https://hpc-tutorials.llnl.gov/mpi/) - “a specification for the developers and users of message passing libraries”, [OpenMP](https://www.openmp.org/) - “multi-platform shared-memory parallel programming in C/C++ and Fortran”
- [Parallel and Distributed Computation: Numerical Methods](https://web.mit.edu/dimitrib/www/pdc.html)
线程间如何通信
- 通信不仅发生在节点/线程之间,还发生在任何共享内存访问
<a name="aIW1a"></a>
## 数据中心里的并发编程
<a name="EBhoN"></a>
### 数据中心程序:特点
:::info
“A network of computing and storage resources that enable the delivery of shared applications and data.” (CISCO)
:::
**以数据 (存储) 为中心**
- 从互联网搜索 (Google)、社交网络 (Facebook/Twitter) 起家
- 支撑各类互联网应用:微信/QQ/支付宝/游戏/网盘/……
**算法/系统对 HPC 和数据中心的意义**
- 你有 1,000,000 台服务器
- 如果一个算法/实现能快 1%,就能省 10,000 台服务器
- 参考:对面一套房 ≈ 50 台服务器 (不计运维成本)
<a name="liJo6"></a>
### 数据中心:主要挑战
多副本情况下的高可靠、低延迟数据访问
- 在服务海量地理分布请求的前提下
- 数据要保持一致 (Consistency)
- 服务时刻保持可用 (Availability)
- 容忍机器离线 (Partition tolerance)
![](https://cdn.nlark.com/yuque/0/2022/png/1349795/1654337094430-45e39357-349c-44fa-b126-7bbe026fec19.png#clientId=uceb35cbb-4080-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u33c0c2ca&margin=%5Bobject%20Object%5D&originHeight=754&originWidth=1400&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=ua3ac4a77-8f79-4679-ae56-651d0d8f61d&title=)
<a name="fGHUv"></a>
### 如何用好一台计算机
如何用一台 (可靠的) 计算机尽可能多地服务并行的请求
- 关键指标:QPS, tail latency, ...
**我们有的工具**
- 线程 (threads)
thread(start = true) { println("${Thread.currentThread()} has run.") }
- 协程 (coroutines)
- 多个可以保存/恢复的执行流 ([M2 - libco](http://jyywiki.cn/OS/2022/labs/M2))
- 比线程更轻量 (完全没有系统调用,也就没有操作系统状态)
<a name="TGl7p"></a>
### 数据中心:协程和线程
**数据中心**
- 同一时间有数千/数万个请求到达服务器
- 计算部分
- 需要利用好多处理器
- 线程 → 这就是我擅长的 (Mandelbrot Set)
- 协程 → 一人出力,他人摸鱼
- I/O 部分
- 会在系统调用上 block (例如请求另一个服务或读磁盘)
- 协程 → 一人干等,他人围观
- 线程 → 每个线程都占用可观的操作系统资源
- (这个问题比你想象的复杂,例如虚拟机)
<a name="hg61o"></a>
### Go 和 Goroutine
:::info
Go: 小孩子才做选择,多处理器并行和轻量级并发我全都要!
:::
Goroutine: 概念上是线程,实际是线程和协程的混合体
- 每个 CPU 上有一个 Go Worker,自由调度 goroutines
- 执行到 blocking API 时 (例如 sleep, read)
- Go Worker 偷偷改成 non-blocking 的版本
- 成功 → 立即继续执行
- 失败 → 立即 yield 到另一个需要 CPU 的 goroutine
- 太巧妙了!CPU 和操作系统全部用到 100%
例子
- [fib.go](http://jyywiki.cn/pages/OS/2022/demos/fib.go); [The Go Programming Language(ch 9.8)](https://books.studygolang.com/gopl-zh/ch9/ch9-08.html)
<a name="DCCZV"></a>
### 现代编程语言上的系统编程
:::info
Do not communicate by sharing memory; instead, share memory by communicating. ——Effective Go
:::
**共享内存 = 万恶之源**
- 在奇怪调度下发生的各种并发 bugs
- 条件变量:broadcast 性能低,不 broadcast 容易错
- 信号量:在管理多种资源时就没那么好用了
既然生产者-消费者能解决绝大部分问题,提供一个 API 不就好了?
- [producer-consumer.go](http://jyywiki.cn/pages/OS/2022/demos/producer-consumer.go)
- 缓存为 0 的 channel 可以用来同步 (先到者等待)
<a name="pAdln"></a>
## 总结
- 并发编程的真实应用场景
- **高性能计算 (**注重任务分解): 生产者-消费者 (MPI/OpenMP)
- **数据中心** (注重系统调用): 线程-协程 (Goroutine)
- **人机交互** (注重易用性): 事件-流图 (Promise)
- 编程工具的发展突飞猛进
- 自 Web 2.0 以来,开源社区改变了计算机科学的学习方式
- 希望每个同学都有一个 “主力现代编程语言”
- Modern C++, Rust, Javascript, ...
<a name="ldjPC"></a>
# 8. 并发 Bug 和应对
<a name="bak5q"></a>
## 应对 Bug 的方法
<a name="Kkswk"></a>
### 防御性编程
<a name="Nx6AY"></a>
## 并发 Bug: 死锁(Deadlock)
<a name="ATYPG"></a>
### AA-Deadlock
假设你的 spinlock 不小心发生了中断
- 在不该打开中断的时候开了中断
- 在不该切换的时候执行了 yield()
```c
void os_run() {
spin_lock(&list_lock);
spin_lock(&xxx);
spin_unlock(&xxx); // ---------+
} // |
// |
void on_interrupt() { // |
spin_lock(&list_lock); // <--+
spin_unlock(&list_lock);
}
ABBA-Deadlock
void swap(int i, int j) {
spin_lock(&lock[i]);
spin_lock(&lock[j]);
arr[i] = NULL;
arr[j] = arr[i];
spin_unlock(&lock[j]);
spin_unlock(&lock[i]);
}
上锁的顺序很重要……
swap 本身看起来没有问题
- swap(1, 2); swap(2, 3), swap(3, 1) → 死锁
- philosopher.c
避免死锁
死锁产生的四个必要条件 (Edward G. Coffman, 1971):
互斥:一个资源每次只能被一个进程使用
- 请求与保持:一个进程请求资阻塞时,不释放已获得的资源
- 不剥夺:进程已获得的资源不能强行剥夺
循环等待:若干进程之间形成头尾相接的循环等待资源关系 :::info “理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。所以,在系统设计、进程调度等方面注意如何不让这四个必要条件成立,如何确定资源的合理分配算法,避免进程永久占据系统资源。此外,也要防止进程在处于等待状态的情况下占用资源。因此,对资源的分配要给予合理的规划。” ——Bullshit. ::: AA-Deadlock
AA 型的死锁容易检测,及早报告,及早修复
- spinlock-xv6.c 中的各种防御性编程
- if (holding(lk)) panic();
ABBA-Deadlock
- 任意时刻系统中的锁都是有限的
- 严格按照固定的顺序获得所有锁 (lock ordering; 消除 “循环等待”)
- 遇事不决可视化:lock-ordering.py
- 进而证明 T1:A→B→C;T2:B→C 是安全的
- “在任意时刻总是有获得 “最靠后” 锁的可以继续执行”
并发 Bug: 数据竞争(Data Race)
以下代码概括了你们遇到数据竞争的大部分情况// Case #1: 上错了锁
void thread1() { spin_lock(&lk1); sum++; spin_unlock(&lk1); }
void thread2() { spin_lock(&lk2); sum++; spin_unlock(&lk2); }
// Case #2: 忘记上锁
void thread1() { spin_lock(&lk1); sum++; spin_unlock(&lk1); }
void thread2() { sum++; }
- “在任意时刻总是有获得 “最靠后” 锁的可以继续执行”
回顾我们实现并发控制的工具
- 互斥锁 (lock/unlock) - 原子性
- 条件变量 (wait/signal) - 同步
忘记上锁——原子性违反 (Atomicity Violation, AV)
忘记同步——顺序违反 (Order Violation, OV)
Empirical study: 在 105 个并发 bug 中 (non-deadlock/deadlock)
- MySQL (14/9), Apache (13/4), Mozilla (41/16), OpenOffice (6/2)
-
应对并发 Bug 的方法
Lockdep: 运行时的死锁检查
ThreadSanitizer: 运行时的数据竞争检查
为所有事件建立 happens-before 关系图
Program-order + release-acquire
对于发生在不同线程且至少有一个是写的 x,y 检查x≺y∨y≺x
Lockdep: lock/unlock
- ThreadSanitizer: 内存访问 + lock/unlock
解析记录检查问题
- Lockdep: x⇝y∧y⇝x
- ThreadSanitizer: x⊀y∧y⊀x
付出的代价和权衡
- 程序执行变慢
-
动态分析工具:Sanitizers
AddressSanitizer (asan); (paper): 非法内存访问
- ThreadSanitizer (tsan): 数据竞争
- Demo: fish.c, sum.c, peterson-barrier.c; ktsan
- MemorySanitizer (msan): 未初始化的读取
UBSanitizer (ubsan): undefined behavior
统计当前的 spin count
- 如果超过某个明显不正常的数值 (1,000,000,000) 就报告
int spin_cnt = 0;
while (xchg(&locked, 1)) {
if (spin_cnt++ > SPIN_LIMIT) {
printf("Too many spin @ %s:%d\n", __FILE__, __LINE__);
}
}
- 如果超过某个明显不正常的数值 (1,000,000,000) 就报告
-
防御性编程:低配版 Sanitizer (L1)
内存分配要求:已分配内存 S=[ℓ0,r0)∪[ℓ1,r1)∪…
kalloc(s) 返回的 [ℓ,r) 必须满足 [ℓ,r)∩S=∅
- thread-local allocation + 并发的 free 还蛮容易弄错的 ```c // allocation for (int i = 0; (i + 1) sizeof(u32) <= size; i++) { panic_on(((u32 )ptr)[i] == MAGIC, “double-allocation”); arr[i] = MAGIC; }
// free for (int i = 0; (i + 1) sizeof(u32) <= alloc_size(ptr); i++) { panic_on(((u32 )ptr)[i] == 0, “double-free”); arr[i] = 0; }
<a name="BPt8A"></a>
## 总结
- 常见的并发 bug
- 死锁、数据竞争、原子性/顺序违反
- 不要盲目相信自己:检查、检查、检查
- 防御性编程:检查
- 动态分析:打印 + 检查
<a name="cS762"></a>
# 9. 操作系统的状态机模型
详见:[http://jyywiki.cn/OS/2022/slides/9.slides#/](http://jyywiki.cn/OS/2022/slides/9.slides#/)
<a name="STqRZ"></a>
## 硬件和软件的桥梁
<a name="FMZYM"></a>
### C 程序
我们已经知道如何写一个 “最小” 的 C 程序了:
- [minimal.S](http://jyywiki.cn/pages/OS/2022/demos/minimal.S)
- 不需要链接任何库,就能在操作系统上运行
“程序 = 状态机” 没问题
- 带来更多的疑问
- 但谁创建的这个状态机???
- 当然是操作系统了……呃……
- 这个程序可以在没有操作系统的硬件上运行吗?
- “启动” 状态机是由 “加载器” 完成的
- 加载器也是一段程序 (状态机)
- 这个程序由是由谁加载的?
<a name="cDDzC"></a>
### Bare-metal 与程序员的约定
为了让计算机能运行任何我们的程序,一定存在软件/硬件的约定
- CPU reset 后,处理器处于某个确定的状态
- PC 指针一般指向一段 memory-mapped ROM
- ROM 存储了厂商提供的 firmware (固件)
- 处理器的大部分特性处于关闭状态
- 缓存、虚拟存储、……
- Firmware (固件,厂商提供的代码)
- 将用户数据加载到内存
- 例如存储介质上的第二级 loader (加载器)
- 或者直接加载操作系统 (嵌入式系统)
<a name="F64U1"></a>
### CPU Reset 之后:发生了什么?
《计算机系统基础》:不仅是程序,整个计算机系统也是一个状态机
- 从 PC (CS:IP) 指针处取指令、译码、执行……
- 从 firmware 开始执行
- ffff0 通常是一条向 firmware 跳转的 jmp 指令
![](https://cdn.nlark.com/yuque/0/2022/png/1349795/1654413126644-9921ae62-810b-4d14-9737-1a50db6d0e0d.png#clientId=uceb35cbb-4080-4&crop=0&crop=0&crop=1&crop=1&from=paste&id=u7c344e90&margin=%5Bobject%20Object%5D&originHeight=400&originWidth=640&originalType=url&ratio=1&rotation=0&showTitle=false&status=done&style=none&taskId=u456dfb0d-ec27-4783-bcec-f5fc9e8c7f0&title=)<br />Firmware: [BIOS vs. UEFI](https://www.zhihu.com/question/21672895)
- 都是主板/主板上外插设备的软件抽象
- 支持系统管理程序运行
- Legacy BIOS (Basic I/O System)
- UEFI (Unified Extensible Firmware Interface)
<a name="QltB0"></a>
### Legacy BIOS: 约定
Firmware 必须提供机制,将用户数据载入内存
- Legacy BIOS 把第一个可引导设备的第一个扇区加载到物理内存的 7c00 位置
- 此时处理器处于 16-bit 模式
- 规定 CS:IP = 0x7c00, (R[CS] << 4) | R[IP] == 0x7c00
- 可能性1:CS = 0x07c0, IP = 0
- 可能性2:CS = 0, IP = 0x7c00
- 其他没有任何约束
计算机系统公理:你想到的就一定有人做到。
- 模拟方案:QEMU
- 传奇黑客、天才程序员 [Fabrice Bellard](https://www.zhihu.com/question/28388113) 的杰作
- [QEMU, A fast and portable dynamic translator](https://www.usenix.org/legacy/publications/library/proceedings/usenix05/tech/freenix/full_papers/bellard/bellard.pdf) (USENIX ATC'05)
- Android Virtual Device, VirtualBox, ... 背后都是 QEMU
- 真机方案:JTAG (Joint Test Action Group) debugger
- 一系列 (物理) 调试寄存器,可以实现 gdb 接口 (!!!)
<a name="gZllV"></a>
### 鸡和蛋的问题解决
有个原始的鸡:Firmware
- 代码直接存在于硬件里
- CPU Reset 后 Firmware 会执行
- 加载 512 字节到内存 (Legacy Boot)
- 然后功成身退
Firmware 的另一用处
- 放置一些 “绝对安全的代码”
- [BIOS 中断](http://jyywiki.cn/pages/OS/manuals/BIOS-interrupts.pdf) (Hello World 是如何被打印的)
- ARM Trusted Firmware
- Boot-Level 1, 2, 3.1, 3.2, 3.3
- [U-Boot](https://www.denx.de/wiki/U-Boot): the universal boot loader
<a name="hGNMY"></a>
### 今天的 Firmware: UEFI
IBM PC 所有设备/BIOS 中断是有 specification 的 (成就了 “兼容机”)
- 今天的 boot loader 面临麻烦得多的硬件:
- 指纹锁、不知名厂商生产网卡上的网络启动、USB 上的蓝牙转接器连接的蓝牙键盘、……
<a name="ydgGx"></a>
### UEFI 上的操作系统加载
标准化的加载流程
- 盘必须按 GPT (GUID Partition Table) 方式格式化
- 预留一个 FAT32 分区 (lsblk/fdisk 可以看到)
- Firmware 加载任意大小的 PE 可执行文件 .efi
- 没有 legacy boot 512 字节限制
- EFI 应用可以返回 firmware
更好的程序支持
- 设备驱动框架
- 更多的功能,例如 Secure Boot,只能启动 “信任” 的操作系统
<a name="RFRYp"></a>
## 操作系统的状态机模型
<a name="ruZot"></a>
### “操作系统” 的状态机已经启动
Firmware 和 boot loader 共同完成 “操作系统的加载”
- 初始化全局变量和栈;分配堆区 (heap)
- 为 main 函数传递参数
- 谁给操作系统传递了参数?
- 如何实现参数传递?
进入 C 代码之后
- 完全遵循 C 语言的形式语义
- 但有一些行为 “补充” —— AbstractMachine API
<a name="j3y5A"></a>
### 操作系统:是个 C 程序
一个迷你 “操作系统” [thread-os.c](http://jyywiki.cn/pages/OS/2022/demos/thread-os.c)
- make 会得到一个 “磁盘镜像”,好像魔法一样
- 就跟你们第一次用 IDE 的时候按一个键就可以编译运行一样
```c
int main() {
cte_init(on_interrupt);
for (int i = 0; i < LENGTH(tasks); i++) {
Task *task = &tasks[i];
Area stack = (Area) { &task->context + 1, task + 1 };
task->context = kcontext(stack, task->entry, (void *)task->name);
task->next = &tasks[(i + 1) % LENGTH(tasks)];
}
mpe_init(mp_entry);
}
总结
- 一切皆可调试 (包括 firmware)
- 理解操作系统是如何被启动的
- 学会使用 gdb (必备生存技能)
操作系统也是程序
- AbstractMachine 扩展了程序的语义,仅此而已
10. 状态机模型的应用
详见:http://jyywiki.cn/OS/2022/slides/10.slides#/查看状态机的执行
Trace 和调试器
程序执行 = 状态机执行
- AbstractMachine 扩展了程序的语义,仅此而已
我们能不能 “hack” 进这个状态机
record full - 开始记录
- record stop - 结束记录
- reverse-step/reverse-stepi - “时间旅行调试”
例子:调试 rdrand.c
Reverse execution 不是万能的
中断就可以做到
- 将状态 s→s′ “记账”
- 执行的语句
- 函数调用栈
- 服务的请求
- 得到统计意义的性能摘要
例子:Linux Kernel perf (支持硬件 PMU) - ilp-demo.c
perf list, perf stat (-e), perf record, perf report
实际中的性能优化
你们遇到的大部分情况
二八定律:80% 的时间消耗在非常集中的几处代码
- L1 (pmm): 小内存分配时的 lock contention
- profiler 直接帮你解决问题
工业界遇到的大部分情况
木桶效应:每个部分都已经 tune 到局部最优了
- 剩下的部分要么 profiler 信息不完整,要么就不好解决
- (工程师整天都对着 profiler 看得头都大了)
- The flame graph (CACM’16)
Model Checker/Verifier
一些真正的 model checkers
TLA+ by Leslie Lamport;
KLEE: Unassisted and automatic generation of high-coverage tests for complex systems programs (OSDI’08, Best Paper 🏅)
-
11. 操作系统上的进程
详见:http://jyywiki.cn/OS/2022/slides/11.slides#/
操作系统启动后到底做了什么
从系统启动到第一个进程
回顾 thread-os.c 的加载过程
CPU Reset → Firmware → Boot loader → Kernel _start()
操作系统会加载 “第一个程序”
- RTFSC (latest Linux Kernel)
- 如果没有指定启动选项 init=,按照 “默认列表” 尝试一遍
- 从此以后,Linux Kernel 就进入后台,成为 “中断/异常处理程序”
程序:状态机
- C 代码视角:语句
- 汇编/机器代码视角:指令
-
定制最小的 Linux
有存储设备,只有包含两个文件的 “initramfs”
-
$ tree .
.
├── bin
│ └── busybox (可以在我们的Linux里直接执行)
└── init
加上 vmlinuz (内核镜像) 就可以在 QEMU 里启动了
可以直接在文件系统中添加静态链接的二进制文件 -
应用程序视角的操作系统
Linux 操作系统启动流程
CPU Reset → Firmware → Loader → Kernel _start() → 第一个程序 /bin/init → 程序 (状态机) 执行 + 系统调用
操作系统为 (所有) 程序提供 API
- 进程 (状态机) 管理
- fork, execve, exit - 状态机的创建/改变/删除 ← 今天的主题
- 存储 (地址空间) 管理
- mmap - 虚拟地址空间管理
文件 (数据对象) 管理
初始状态:main(argc, argv)
- 程序可以直接在处理器上执行
虚拟化:操作系统在物理内存中保存多个状态机
int fork();
int execve(const char filename, char const argv, char * const envp);
- 执行名为 filename 的程序
允许对新状态机设置参数 argv (v) 和环境变量 envp (e)
- 刚好对应了 main() 的参数!
- execve-demo.c
_exit()
状态机管理:终止状态机
UNIX 的答案: _exit
立即摧毁状态机
void _exit(int status)
- 销毁当前状态机,并允许有一个返回值
-
结束程序执行的三种方法
exit 的几种写法 (它们是不同)
exit(0) - stdlib.h 中声明的 libc 函数
- 会调用 atexit
- _exit(0) - glibc 的 syscall wrapper
- 执行 “exit_group” 系统调用终止整个进程 (所有线程)
- 不会调用 atexit
- syscall(SYS_exit, 0)
- 执行 “exit” 系统调用终止当前线程
- 不会调用 atexit
结束当前进程执行的四种方式
- return, exit, _exit, syscall
对 “操作系统” 的完整理解
- CPU Reset → Firmware → Loader → Kernel _start() → 执行第一个程序 /bin/init → 中断/异常处理程序
- 一个最小的 Linux 系统的例子
- 进程管理 API
- fork, execve, exit: 状态机的复制、重置、销毁
- 理论上就可以实现 “各种功能” 了!
12. 进程的地址空间
详见:http://jyywiki.cn/OS/2022/slides/12.slides#/
进程的地址空间
查看进程的地址空间
地址空间的隔离
修改内存
我们可以改内存,也可以改代码!
The Light Side
- “软件热补丁” dsu.c (mprotect)
Ksplice: Automatic rebootless Kernel updates (EuroSys’09)
总结
问题:进程的地址空间是如何创建的,如何更改的
进程的地址空间
- 能文件关联的、带有访问权限的连续内存段
进程地址空间的管理API
- mmap
13. 系统调用和 Shell
详见:http://jyywiki.cn/OS/2022/slides/13.slides#/Shell
为用户封装操作系统 API
这就是 Shell (内核 Kernel 提供系统调用;Shell 提供用户接口)
- mmap
“与人类直接交互的第一个程序”
-
The UNIX Shell
“终端” 时代的伟大设计
“Command-line interface” (CLI) 的巅峰
Shell 是一门 “把用户指令翻译成系统调用” 的编程语言
终端和 Job Control
终端是 UNIX 操作系统中一类非常特别的设备!
-
Session, Process Group 和信号
SIGSEGV 和 SIGFPE
大家熟悉的 Segmentation Fault/Floating point exception (core dumped)
GP, #PF 或 #DIV
- UNIX 系统会给进程发送一个信号
- 此时可以生成一个 “core” 文件 (ELF 格式),能用 gdb 调试
UNIX (System V) 信号其实是有一些 dark corners 的
如果 SIGSEGV 里再次 SIGSEGV?
- POSIX.1 solved the portability mess by specifying sigaction(2), which provides explicit control of the semantics when a signal handler is invoked; use that interface instead of signal().
The PGID (process-group ID) is preserved across an execve(2) and inherited in fork(2)…
- Each process group is a member of a session
A session can have a controlling terminal.
- At any time, one (and only one) of the process groups in the session can be the foreground process group for the terminal; the remaining process groups are in the background.
- ./a.out & 创建新的进程组 (使用 setpgid)
- If a signal is generated from the terminal (e.g., typing the interrupt key to generate SIGINT), that signal is sent to the foreground process group.
- Ctrl-C 是终端 (设备) 发的信号,发给 foreground 进程组
- 所有 fork 出的进程 (默认同一个 PGID) 都会收到信号
- 可以修改 signal-handler.c 观察到这个行为
- At any time, one (and only one) of the process groups in the session can be the foreground process group for the terminal; the remaining process groups are in the background.
Only the foreground process group may read(2) from the terminal; if a background process group tries to read(2) from the terminal, then the group is sent a SIGTTIN signal, which suspends it.
- 这解释了 cat & 时你看到的 “suspended (tty input)”
- 同一个进程组的进程 read tty 会竞争
- signal-handler.c 同样可以观察到这个行为
The setpgid() and getpgrp() calls are used by programs such as bash(1) to create process groups in order to implement shell job control.
一个功能完整的 Shell 使用的操作系统对象和 API
- session, process group, controlling terminal
- 文件描述符:open, close, pipe, dup, read, write
- 状态机管理:fork, execve, exit, wait, signal, kill, setpgid, getpgid, …
14. C 标准库的实现
详见:http://jyywiki.cn/OS/2022/slides/14.slides#/libc
任何程序都用得上的定义
Freestanding 环境下也可以使用的定义
stddef.h - size_t
- stdint.h - int32_t, uint64_t
- stdbool.h - bool, true, false
- float.h
- limits.h
- stdarg.h
- syscall 就用到了 (但 syscall0, syscall1, … 更高效)
string.h: 字符串/数组操作
-
封装 (2): 文件描述符
stdio.h
FILE * 背后其实是一个文件描述符
-
封装 (3): 更多的进程/操作系统功能
err, error, perror
-
封装 (4): 地址空间
malloc 和 free
RTFM: The GNU C Library, RTFSC: Newlib
总结
- libc
- RTFM; RTFSC: 充满了宝藏
- 性能优化中的 fast/slow path
-
15. Fork 的应用
详见:https://jyywiki.cn/OS/2022/slides/15.slides#/
fork 补充
-
状态机、fork() 和魔法
帮助我们
理解物理世界 (Cellular Automata)
- 理解处理器、操作系统
- 调试 (record-replay)、profiler
- Model checker 和 program verifier
fork() 可以复制状态机?
- 感觉是一个非常 “强大” 的操作
-
搜索并行化
跳过初始化
备份和容错
状态机复制
Afork()in the road (HotOS’19)
fork: UNIX 时代的遗产
fork + execve
如果只有内存和文件描述符,这是十分优雅的方案
- 但贪婪的人类怎么可能满足?
在操作系统的演化过程中,为进程增加了更多的东西
- 信号 (信号处理程序,操作系统负责维护)
- 线程 (Linux 为线程提供了 clone 系统调用)
- 进程间通信对象
- ptrace (追踪/调试)
-
创建进程:POSIX Spawn
int posix_spawn(pid_t *pid, char *path,
posix_spawn_file_actions_t *file_actions,
posix_spawnattr_t *attrp,
char * argv[], char * envp[]);
参数
pid: 返回的进程号
- path: 程序 (重置的状态机)
- file_actions: open, close, dup
- attrp: 信号、进程组等信息
argv, envp: 同 execve
Fork is no longer simple
- Fork doesn’t compose - fork-printf.c
- Fork isn’t thread-safe
- Fork is insecure - 打破了 Address Space Layout Randomization
- Fork is slow - 的确……
- Fork doesn’t scale - 也是……
- Fork encourages memory overcommit - 呃……
但 fork() 是魔法啊:这引起了更多的思考
17. 动态链接和加载
问题
- go 是 C/C++ 实现的吗?最早是,后又实现了自举。
- rust 是 C/C++ 实现的吗?