这节 Tutorial 其实没讲多少新的 OS 理论。前半段是把 Lecture 里面的 Process、System Call、Signal 这些东西重新串了一遍,后半段大半时间都在确认一件事:我们至少有足够的 Linux 和 C 语言基础,不至于后面写 OS 代码的时候先被 Pointer 和 Segmentation Fault 送走。
所以真正值得留下来的东西也就三块:几个很容易混的 OS 概念、Linux 下的基本工作流,以及写 Systems Programming 需要的 C 语言基础。
Mode Switch 和 Context Switch
先补一个 Lecture 里面已经有概念、但还没正式起名字的东西:Context Switch(上下文切换)。
我们之前说过,一个 CPU Core 需要在 A、B、C 等不同 Process 之间飞快切换。但 CPU 当然不能只说一句“A 你先下去,B 你上来”就完事了——它里面还有 Program Counter、各种 Registers、Stack Pointer 等等一堆属于 A 当前执行状态的东西。所以真正切的时候,流程大概是这样:
1 | Suspend A |
这就是 Context Switch:把 CPU 目前代表的“运行现场”,从一个 Process 换成另一个 Process。
这也解释了为什么之前 PCB 里面一定要保存 Program Counter 和 Register Context。A 被踹下 CPU 的时候先把现场存进 PCB,下一次 Scheduler 把 A 叫回来,再把这些东西恢复回 CPU。于是虽然底下大家抢的是同一个 CPU,每一个 Process 看起来却都像:
我有一颗属于自己的 CPU,并且每次回来还能从上次停的地方继续。
这就是 CPU 虚拟化的一部分,下一个 Lecture 会讲。
不过要注意,Mode Switch ≠ Context Switch,这个非常容易混。假设 Process A 执行了一句 read(...),它通过 System Call 进入 Kernel,整个过程是:
1 | Process A · User Mode |
从头到尾还是 A,变的只是 User Mode 和 Kernel Mode,所以这叫 Mode Switch。而如果发生的是下面这种,才是真的从 A 换成了 B:
1 | Process A |
一句话概括:Mode Switch 关心的是“CPU 现在是什么权限?”,Context Switch 关心的是“CPU 现在在帮哪个 Process 干活?”。这两个东西甚至可以同时发生。例如 A 调用 read(),先发生一次 Mode Switch 进入 Kernel,结果 Kernel 一看你要的数据硬盘还没读完,先旁边等着,于是:
1 | A → Blocked |
这时候又发生了一次 Context Switch。所以 System Call 一定意味着进入 Kernel 的 Mode Switch,但不一定意味着 Context Switch。
Interrupt 和 Signal 也不是一回事
第二组特别容易混的概念是 Interrupt ≠ Signal。
Interrupt 面对的是 CPU / Kernel。当 Hardware 或者 CPU 上发生某个需要 OS 处理的事件时,它会通过 Interrupt 把 CPU 的控制权带回 Kernel:
1 | Hardware / CPU Event |
比如 Timer 到时间了、Disk I/O 完成了、Keyboard 来了输入、CPU 发生 Exception,走的都是这条路。这其实也回答了第一章留下的那个问题:一个 Process 写了 while(true) 死活不肯把 CPU 交回来,OS 怎么办?答案是 Timer Interrupt 到点之后,CPU 照样会进入 Kernel,Process 配不配合并不重要。
而 Signal 面对的是 Process。它是 Kernel 在通知某个 Process:兄弟,出事了。常见的几个是:
1 | SIGINT |
所以有时候一个完整流程甚至是:
1 | Hardware Event |
例如你按下 Ctrl-C,底层先由一个 Hardware Event 让 Kernel 得到控制,随后 Kernel 再向 Foreground Process 发送 SIGINT。一句话:Interrupt 把 Kernel 叫过来,Signal 把 Process 叫过来。
Linux 环境
一些真的会用到的 Linux 命令
文件操作无非是 pwd、ls -l、cd、mkdir、cp、mv、rm、cat 这几个,Process 方面则是 ps、pstree、top、kill。它们和 Lecture 里学的东西直接对应:
1 | ps / pstree |
另外 man 一定要会,尤其是:
1 | man 2 open |
这里的数字不是版本号,Section 2 是 System Calls,Section 3 是 Library Functions。所以 man 2 open 查的是 Kernel 提供的 System Call 接口,man 3 printf 查的是 C Library 里面的 printf()。这也再次说明:Library Function 和 System Call 不是一层东西。
Pipe、Redirection 和 Background Process
Linux Shell 里面有三个以后经常会碰到的东西。
首先是 Pipe:
1 | ps -ef | grep ssh |
| 表示把前一个 Command 的 Output 作为后一个 Command 的 Input。所以上面这句就是先 ps -ef 列出 Processes,再交给 grep ssh 只筛选包含 ssh 的内容。
Redirection 则是:
1 | ./program > output.txt |
表示不要把 Standard Output 打印到 Terminal,而是写入 output.txt。
如果在命令最后加一个 &:
1 | ./program & |
就表示把程序放到 Background 执行。正常情况下 Shell 启动一个程序以后要等它结束:
1 | Shell |
但放后台之后:
1 | Shell 启动 Program |
于是你还能继续输入别的 Command。几个东西甚至可以叠起来:
1 | ./program > log.txt 2>&1 & |
意思是 stdout 进 log.txt,stderr 和 stdout 去同一个地方,整条命令放后台。也就是:后台运行这个程序,并且把正常输出和错误输出全部扔进 log.txt。
C for Operating Systems
这部分大多数都是 C 语言基础。但 OS 代码和普通 Algorithm 题不太一样:你会大量直接碰 Memory、Pointer、Buffer、Struct 和 System API。所以这里真正需要建立的是一个意识——**这块 Memory 在哪?谁拥有它?这个 Pointer 现在指向什么?这块 Memory 还能活多久?**只要这几个问题开始含糊,Segmentation Fault 一般就在来的路上。
编译 C
最简单的方式是:
1 | gcc hello.c -o hello |
这门课更建议:
1 | gcc -Wall -Wextra -g program.c -o program |
其中 -Wall 打开常见 Warning,-Wextra 打开更多 Warning,-g 加上 Debug Information,-o 指定 Output Filename。
C 里面一个很重要的 Mindset 是 编译通过 ≠ 正确。有三种事情不要混:Compiler Error 是根本编不过,Compiler Warning 是编得过、但 Compiler 觉得你八成有点问题,Runtime Error 则是编译完全成功、跑起来才炸。尤其是 C,后两种多得很。
Make 不是 Compiler
项目稍微大一点之后,你可能会有 main.c、parser.c、util.c 一堆文件,每次都手动 gcc main.c parser.c util.c -o program 很烦,所以有了 Makefile。例如:
1 | program: main.o parser.o util.o |
它描述的是 program 依赖 main.o、parser.o 和 util.o。然后你执行 make,make 会自己判断哪些 Compilation Command 需要重新运行。所以不要搞混:GCC 负责真正的编译工作,Make 负责管理 Build Dependency,并决定该跑哪些 Command。
Type 和 sizeof()
Systems Programming 里面经常看到 char、int、long、size_t、ssize_t、pid_t 这些类型,重点不是现在把每一种背下来,而是不要自己猜 Type 到底几个 Byte。
比如不要写:
1 | int *a = malloc(10 * 4); |
这等于在硬编码“int 一定是 4 Bytes”。更好的写法是:
1 | int *a = malloc(10 * sizeof(*a)); |
意思是给我 10 个 a 指向类型的空间,到底多大让 Compiler 自己算。
Pointer:本质就是 Address
1 | int x = 42; |
这里 x 是一个 int,&x 是 x 的地址,p 是保存 x 地址的变量,*p 则是去 p 保存的地址,把里面那个 int 拿出来。假如 x 存在 0x1000,那么 p = 0x1000,*p = 42。
所以 *p = 100; 不是把 p 改成 100,而是顺着 p 找到它指向的 Memory,把那里的内容改成 100,于是最后 x == 100。
Pointer 存在,不代表它指向的东西存在
这个非常重要。int *p; 只代表我声明了一个能够保存 int 地址的 Pointer,它没有顺便给你 Allocate 一个 int,此时 p 甚至可能只是某个 Garbage Address。如果直接 *p = 10;,相当于随便抽一个不知道哪来的地址往里面写 10,那整个晋西北马上乱成一锅粥。
相比之下,int *p = NULL; 至少明确告诉我们 p 目前没有指向一个 Valid Object,所以可以:
1 | if (p != NULL) { |
但你当然不能 *p = 10;,因为 NULL 仍然不是一个可以 Dereference 的地址。一句话:Declaring a pointer does not allocate memory.
Array 和 Pointer
1 | int a[4] = {10, 20, 30, 40}; |
Array 里面的元素在 Memory 中是连续存放的,所以 a[2] 和 *(a + 2) 拿到的都是 30,也就是 a[i] == *(a + i)。但是不要因此直接把 “Array = Pointer” 写进脑子,它们关系非常近,但不是同一个概念。
Pointer Arithmetic
假设有一个 int *p,那么 p + 1 并不是地址数字单纯加 1 Byte,而是移动到“下一个 int”的地址。所以如果 sizeof(int) = 4、p = 0x1000,那么 p + 1 = 0x1004,p + 2 = 0x1008。Pointer Arithmetic 按照它指向的 Type 作为移动单位。
C 永远 Pass by Value
这也是很经典的坑。
1 | void swap(int x, int y) |
调用 swap(a, b) 之后 a 和 b 不会真的 Swap,因为函数只是修改了 x 和 y 这两个 Copies。但如果把地址传进去:
1 | void swap(int *x, int *y) |
再调用 swap(&a, &b),这次 x 指向 a 的地址,y 指向 b 的地址,修改 *x 实际上就是通过地址直接修改 Caller 的 a。这里依旧没有什么真正的 “Pass by Reference”,C 还是在 Pass by Value,只不过这一次 Copy 过去的 Value 恰好是一个 Address。
为什么 OS API 一堆 Pointer?
看看 POSIX 的 read():
1 | ssize_t read(int fd, void *buf, size_t count); |
为什么 buf 非得是个 Pointer?因为 read() 需要把从文件或者设备读出来的数据,写回 Caller 拥有的一块 Memory:
1 | char buffer[128]; |
buffer 把自己的地址交给 read(),随后 read() 才能往里面塞数据。这就是为什么 Systems Programming 到处都是 Pointer——函数经常需要访问,甚至修改 Caller 拥有的 Memory。
C String 就是 Character Array
1 | char s[] = "hello"; |
Memory 里面真正放的是:
1 | 'h' 'e' 'l' 'l' 'o' '\0' |
最后这个 '\0' 叫做 NULL Terminator。所以 strlen("hello") 返回 5,但实际 Storage 需要 6 Bytes,因为 \0 自己也占位置。
很多 C String Function 根本不知道你的 Array 有多长,它们的逻辑差不多就是一直往后读,什么时候看到 '\0' 就认为 String 结束了。所以如果你把 Terminator 搞没了,它就有机会一路读到天涯海角。
常见的 String Operation 有 strlen()、strcmp()、strcpy()、strcat(),而 memcpy()、memset() 更偏 Raw Memory Operation。简单来说,String Function 把 Memory 理解成 '\0' 结尾的字符串,Memory Function 眼里就是一坨 Raw Bytes。
char [] 和 char *
这两个也很容易看起来一样:
1 | char a[] = "hello"; |
这里得到的是一个 Writable Array,所以 a[0] = 'H'; 没问题。但是:
1 | char *b = "hello"; |
这里的 b 指向 String Literal,就不要依赖 b[0] = 'H'; 这种东西能正常工作。所以如果你明确需要一个能够被修改的 String Buffer,那就 Allocate 一个 Array 或者其他 Writable Memory。
argc 和 argv
如果 main 的签名是:
1 | int main(int argc, char *argv[]) |
然后 Shell 执行 ./program input.txt 10,那么:
1 | argc = 3 |
注意最后的 "10" 仍然是 String,它不是 Integer 10。也就是说 Command-Line Argument 刚进程序的时候全部都是字符串,需要数字的话还得自己 Convert。
struct:把相关 State 绑在一起
OS 代码里这个东西出现得非常多,例如:
1 | struct process { |
1 | struct process p; |
如果拿的是 Pointer:
1 | struct process *ptr = &p; |
其中 ptr->pid 等价于 (*ptr).pid。
为什么 OS 这么喜欢 Struct?因为 Process、File、Socket、Memory Region、Page Table Entry 这些东西都有一大坨相关 State 需要绑在一起。我们之前讲的 PCB,在真实 OS 里面当然不可能只是课堂上的一个框框,例如 Linux 会用类似 task_struct 这样的巨大 Struct 去描述 Process / Task。所以以后看到 struct xxx *p;,先别把它看成 C 语言语法题,真正应该问的是:这个 Struct 在模拟什么 OS Entity?里面保存了什么 State?
Dynamic Memory
最常见的:
1 | int *a = malloc(10 * sizeof(*a)); |
在 Heap 上申请 10 个 int,然后把起始地址塞进 a。用完:
1 | free(a); |
这里有一个比 malloc() 语法本身重要得多的问题:谁拥有这块 Memory?它应该活多久?最后谁负责 Free? 只要一个 Allocation 的 Ownership 和 Lifetime 说不清楚,Memory Bug 一般也不远了。
四个基本 API 分别是:malloc() 负责 Allocate,初始内容不保证是什么;calloc() 是 Allocate + Zero Initialize;realloc() 用来 Resize,注意地址可能会变;free() 则负责 Release。特别是 realloc() 并不保证原地扩容,它完全可能把整块 Memory 搬到一个新的 Address。
C 的 Memory 死亡四件套
Uninitialized Pointer
1 | int *p; |
Pointer 里面是未知地址,然后你还 Dereference。
Memory Leak
1 | p = malloc(100); |
这块 Memory 已经没有用了,但是你一直没还。
Use After Free
1 | free(p); |
p 这个变量可能还留着原来的地址数字,但是那个 Memory Object 已经死了,这个地址已经不再属于你。
Out of Bounds
1 | int a[10]; |
合法 Index 只有 0 到 9,10 已经出去了。
这四种错误看起来不同,本质上其实都是一件事情:程序脑子里对 Memory 的理解,和这块 Memory 真实的有效范围 / Lifetime 对不上了。Uninitialized Pointer 是从来就不知道地址是否合法,Use After Free 是以前合法、现在死了,Out of Bounds 则是起点合法、但算出去的地址越界了。
C 不会一路牵着你的手告诉你“亲,这里是不是有点危险?”,很多时候它直接让你写,然后某个不知道什么时候冒出:
1 | Segmentation Fault |
Preprocessor 和 Macro
在真正 Compiler 开始编译之前,C 还有一层:Preprocessor。
1 |
|
可以:
1 | gcc -DDEBUG program.c -o program |
定义 DEBUG,于是 Debug 版本和正常版本可以选择性 Compile 不同代码。
Macro 有一个很经典的坑:
1 |
假如写 SQUARE(1 + 2),Preprocessor 大概只是老老实实替换成 1 + 2 * 1 + 2,结果当然不是 9。所以至少应该:
1 |
这也说明 Macro 更接近 Source Code 层面的文本展开,而不是一个正常的 Function Call。
Debugging
C 程序炸掉的时候,最垃圾的 Debug 方法就是“我觉得可能是这里,改一下试试”“还不行?那我再加个 if”。不要这样。
比较正常的思路是:
1 | Reproduce |
而不是哪一行最后 Crash 就强行把哪一行按住。
编译的时候留 Debug Information:
1 | gcc -Wall -Wextra -g program.c -o program |
然后可以:
1 | gdb ./program |
常见操作是 break、run、next、step、continue、print、backtrace。VS Code 里面的 Breakpoint、Step Over、Step Into、Variables、Call Stack,本质上干的是同一套事情。
看到 Segmentation Fault 怎么办?
首先,Segmentation Fault 只是症状,不要看到这句话就立刻开始随机修改代码。先想几个最高频的问题:是不是 Dereference 了 NULL?是不是用了 Uninitialized Pointer?Array 是不是越界了?String Buffer 是不是写爆了?是不是 Use After Free?是不是传错 Pointer 了?
然后:
1 | gdb ./program |
先看 Backtrace,找到真正 Crash 的 Frame 以后:
1 | frame 0 |
去检查相关 Pointer 和 State。最后真正要找的是哪个 Invalid Memory Access 发生了,而且 Root Cause 不一定就在 Crash 那一行。例如你可能很早以前 free(p);,几十行以后才 *p = 10;,最后在这里 Crash。所以 Crash Location ≠ Root Cause Location,这个意识以后写 C 非常重要。
Tutorial 1 Takeaway
这节 Tutorial 虽然有 41 页,但真正值得带走的东西其实没有看起来那么恐怖。
OS 这边主要补了三个辨析:
1 | System Call |
Linux 这边重点是:
1 | 最终一定在 Grading Environment 测试 |
而 C 语言这边,真正贯穿后面所有东西的一句话是:永远搞清楚 Address、Ownership、Lifetime 和 Boundary。
Pointer 指到哪?这块 Memory 是谁 Allocate 的?现在还活着吗?谁负责 Free?这个地址还在合法范围内吗?这些问题如果始终能答上来,绝大多数 C 语言 Memory Bug 其实都不会显得特别神秘。