这节 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
2
3
4
5
6
7
8
9
Suspend A
↓
Save A's execution context
↓
Scheduler 选择 B
↓
Restore B's execution context
↓
Continue B

这就是 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
2
3
4
5
6
7
8
9
Process A · User Mode
↓
System Call
↓
Process A · Kernel Mode
↓
Return
↓
Process A · User Mode

从头到尾还是 A,变的只是 User Mode 和 Kernel Mode,所以这叫 Mode Switch。而如果发生的是下面这种,才是真的从 A 换成了 B:

1
2
3
4
5
6
7
8
9
Process A
↓
Kernel 保存 A 的 Context
↓
Scheduler 选择 B
↓
恢复 B 的 Context
↓
Process B

一句话概括:Mode Switch 关心的是“CPU 现在是什么权限?”,Context Switch 关心的是“CPU 现在在帮哪个 Process 干活?”。这两个东西甚至可以同时发生。例如 A 调用 read(),先发生一次 Mode Switch 进入 Kernel,结果 Kernel 一看你要的数据硬盘还没读完,先旁边等着,于是:

1
2
3
4
5
A → Blocked
↓
Scheduler 选择 B
↓
B Running

这时候又发生了一次 Context Switch。所以 System Call 一定意味着进入 Kernel 的 Mode Switch,但不一定意味着 Context Switch。


Interrupt 和 Signal 也不是一回事

第二组特别容易混的概念是 Interrupt ≠ Signal。

Interrupt 面对的是 CPU / Kernel。当 Hardware 或者 CPU 上发生某个需要 OS 处理的事件时,它会通过 Interrupt 把 CPU 的控制权带回 Kernel:

1
2
3
4
5
Hardware / CPU Event
↓
Interrupt
↓
Kernel

比如 Timer 到时间了、Disk I/O 完成了、Keyboard 来了输入、CPU 发生 Exception,走的都是这条路。这其实也回答了第一章留下的那个问题:一个 Process 写了 while(true) 死活不肯把 CPU 交回来,OS 怎么办?答案是 Timer Interrupt 到点之后,CPU 照样会进入 Kernel,Process 配不配合并不重要。


而 Signal 面对的是 Process。它是 Kernel 在通知某个 Process:兄弟,出事了。常见的几个是:

1
2
3
4
5
6
7
8
SIGINT
→ Ctrl-C

SIGSEGV
→ Invalid Memory Access

SIGCHLD
→ Child Process 状态发生变化

所以有时候一个完整流程甚至是:

1
2
3
4
5
6
7
8
9
Hardware Event
↓
Interrupt
↓
Kernel
↓
Signal
↓
Process

例如你按下 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
2
3
4
5
6
7
8
ps / pstree
→ 看 Process

top
→ 看正在运行的 Process 和资源使用

kill
→ 给 Process 发 Signal

另外 man 一定要会,尤其是:

1
2
man 2 open
man 3 printf

这里的数字不是版本号,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
2
3
4
5
6
7
Shell
↓
Program Running
↓
Program Finished
↓
Shell 继续接受输入

但放后台之后:

1
2
3
Shell 启动 Program
├─ Program 自己在后台继续跑
└─ Shell 马上回来

于是你还能继续输入别的 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
2
gcc hello.c -o hello
./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
2
program: main.o parser.o util.o
gcc main.o parser.o util.o -o program

它描述的是 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
2
int x = 42;
int *p = &x;

这里 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
2
3
if (p != NULL) {
printf("%d\n", *p);
}

但你当然不能 *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
2
3
4
5
6
void swap(int x, int y)
{
int tmp = x;
x = y;
y = tmp;
}

调用 swap(a, b) 之后 a 和 b 不会真的 Swap,因为函数只是修改了 x 和 y 这两个 Copies。但如果把地址传进去:

1
2
3
4
5
6
void swap(int *x, int *y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}

再调用 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
2
3
char buffer[128];

ssize_t n = read(fd, buffer, sizeof(buffer));

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
2
3
4
5
argc = 3

argv[0] = "./program"
argv[1] = "input.txt"
argv[2] = "10"

注意最后的 "10" 仍然是 String,它不是 Integer 10。也就是说 Command-Line Argument 刚进程序的时候全部都是字符串,需要数字的话还得自己 Convert。


struct:把相关 State 绑在一起

OS 代码里这个东西出现得非常多,例如:

1
2
3
4
5
struct process {
int pid;
int state;
char name[32];
};
1
2
struct process p;
p.pid = 123;

如果拿的是 Pointer:

1
2
struct process *ptr = &p;
ptr->pid = 456;

其中 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
2
free(a);
a = NULL;

这里有一个比 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
2
int *p;
*p = 10;

Pointer 里面是未知地址,然后你还 Dereference。


Memory Leak

1
2
p = malloc(100);
// 忘记 free(p)

这块 Memory 已经没有用了,但是你一直没还。


Use After Free

1
2
free(p);
*p = 10;

p 这个变量可能还留着原来的地址数字,但是那个 Memory Object 已经死了,这个地址已经不再属于你。


Out of Bounds

1
2
int a[10];
a[10] = 5;

合法 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
2
3
4
5
6
7
#include <stdio.h>

#define BUFFER_SIZE 1024

#ifdef DEBUG
printf("x = %d\n", x);
#endif

可以:

1
gcc -DDEBUG program.c -o program

定义 DEBUG,于是 Debug 版本和正常版本可以选择性 Compile 不同代码。

Macro 有一个很经典的坑:

1
#define SQUARE(x) x * x

假如写 SQUARE(1 + 2),Preprocessor 大概只是老老实实替换成 1 + 2 * 1 + 2,结果当然不是 9。所以至少应该:

1
#define SQUARE(x) ((x) * (x))

这也说明 Macro 更接近 Source Code 层面的文本展开,而不是一个正常的 Function Call。


Debugging

C 程序炸掉的时候,最垃圾的 Debug 方法就是“我觉得可能是这里,改一下试试”“还不行?那我再加个 if”。不要这样。

比较正常的思路是:

1
2
3
4
5
6
7
8
9
Reproduce
↓
找到第一次出现错误 State 的位置
↓
Inspect
↓
理解为什么 State 会错
↓
Fix Root Cause

而不是哪一行最后 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
2
3
gdb ./program
run
bt

先看 Backtrace,找到真正 Crash 的 Frame 以后:

1
2
frame 0
print variable

去检查相关 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
2
3
4
5
6
7
8
9
10
11
12
13
14
System Call
→ Application 请求 Kernel Service

Mode Switch
→ User / Kernel 权限发生变化

Context Switch
→ CPU 正在执行的 Process 发生变化

Interrupt
→ Event 把 CPU / Kernel 叫过来

Signal
→ Kernel 通知 Process

Linux 这边重点是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
最终一定在 Grading Environment 测试

man 2
→ System Call

man 3
→ Library Function

|
→ Pipe

>
→ Output Redirection

&
→ Background Process

而 C 语言这边,真正贯穿后面所有东西的一句话是:永远搞清楚 Address、Ownership、Lifetime 和 Boundary。

Pointer 指到哪?这块 Memory 是谁 Allocate 的?现在还活着吗?谁负责 Free?这个地址还在合法范围内吗?这些问题如果始终能答上来,绝大多数 C 语言 Memory Bug 其实都不会显得特别神秘。