简介:这份UIUC CS241系统编程中文讲义面向希望系统学习系统编程、操作系统底层机制与C语言工程实践的中文读者,着力降低英文课程材料的阅读门槛,适合高校学生、自学者及准备相关课程复习的人群使用。资源包共137个文件,约3.5MB,以93个Markdown讲义为主体,配有22张插图及少量网页样式、脚本和静态页面文件,目录结构清晰,便于按章节检索、离线阅读和对照原文复习。已有196人浏览学习。译文项目处于校对阶段,保留开源协作与贡献说明,读者可据此跟进修订、反馈问题;讲义正文与配图结合,适合梳理系统编程核心概念,也可作为课程笔记补充和日常查阅的参考资料。
1. 系统编程的知识地图:先把 fork 和 pthread 串成一条线
系统编程的中文讲义不多,这份译注版把「课上跟着敲一遍、复习时再翻一遍」的需求接住了。很多人学完一学期,脑子里剩一堆零散的 API:fork、pipe、dup2、sigaction、pthread_create,单独问都能答,串起来就说不清谁先调用谁。其实主线只有三条——进程如何被创建、替换、回收;文件描述符如何成为所有 I/O 的统一入口;多个控制流如何安全共享同一块内存。讲义顺着这条线铺开,从最小 shell 一路推到线程同步和 malloc 实现。它适合已经写过 C、做过一两个课设、正在准备考试或面试复盘的人;完全零基础会卡在指针和链接那一步,建议先把 C 补到能读懂函数指针再进来。
2. 进程与程序执行:fork、exec、wait 三件套的复习重点
进程这条线是整个方向的地基。考试爱考细节,工程里也最容易翻车:fork 的返回值判断写反、exec 失败后子进程继续跑父进程的代码、父进程不 wait 导致僵尸进程堆积。讲义把这三个调用放在一起讲是有道理的,它们本来就构成一个闭环——fork 负责分叉,exec 负责换脑,wait 负责收尸,缺一个流程就断。
2.1 fork 复制了什么,又没复制什么
教科书说 fork 复制父进程的地址空间,这句话对但不精确。现代内核用的是写时复制:fork 时只复制页表,物理页共享并标记只读,任何一方写入才触发缺页、真正复制那一页。所以 fork 一个占了几百 MB 的进程并不慢,慢的是之后父子双方都大量写内存。这也是为什么「fork 完立刻 exec」的模式在小程序里没问题,在大服务里会被换成 posix_spawn 之类更轻的接口——fork 期间复制的页表本身就是开销。
真正要背牢的是返回值,三种情况不能混:
| 所在进程 | fork 返回值 | 含义 |
|---|---|---|
| 父进程 | 大于 0 | 新建子进程的 pid |
| 子进程 | 0 | 用来区分「我是新进程」 |
| 调用失败 | -1 | 不创建子进程,errno 有效 |
还有一个常被忽略的点:文件描述符表被复制了,但表项指向的是同一个打开文件描述。父子进程对同一个 fd 做 read,会推进同一个文件偏移量。想让两边各读各的,得先各自 open 一次,或者用 dup 配合 lseek 重新定位。
pid_t pid = fork(); if (pid < 0) { perror("fork"); /* 失败:errno 会告诉我们原因,比如 EAGAIN */ return 1; } if (pid == 0) { /* 子进程分支:这里 pid 一定是 0 */ execlp("ls", "ls", "-l", (char *)NULL); _exit(127); /* 只有 exec 失败才会走到这里 */ } /* 父进程分支:pid 是子进程号,可以直接拿去 wait */ int status; waitpid(pid, &status, 0);这段代码的关键在第 5 行的_exit(127)。exec 成功时不会返回,一旦返回就说明失败了,此时子进程必须立刻退出,绝对不能往下执行父进程的逻辑——这是新手最经典的翻车点,表现是输出莫名其妙重复了一遍。
2.2 exec 家族:后缀决定参数怎么传
六个 exec 函数看着吓人,其实后缀就是两个维度的组合:l表示参数以列表形式逐个给出,v表示参数装在数组里;p表示按 PATH 环境变量搜索可执行文件,e表示自己传环境变量数组。记住这张表,考场上现推都来得及:
| 函数 | 参数形式 | 是否查 PATH | 环境变量 |
|---|---|---|---|
| execl | 列表 | 否 | 继承 |
| execlp | 列表 | 是 | 继承 |
| execle | 列表 | 否 | 显式传入 |
| execv | 数组 | 否 | 继承 |
| execvp | 数组 | 是 | 继承 |
| execve | 数组 | 否 | 显式传入 |
几个必须记住的行为:exec 成功后进程号不变,但代码段、数据段、堆栈全部被新程序覆盖;文件描述符表默认保留,除非该 fd 设了 FD_CLOEXEC 标志;被捕获的信号处理函数会重置为默认动作,被忽略的信号则保持忽略。参数列表必须以 NULL 结尾,漏掉这个 NULL 是最常见的段错误来源之一,编译器不会拦你。
2.3 wait 与 waitpid:僵尸进程是怎么来的
子进程退出后,内核要保留它的退出状态等父进程来取,这个中间态就是僵尸。僵尸不占内存,但占着 pid 和进程表项,长期运行的服务如果不管,迟早把 pid 耗尽。wait 和 waitpid 就是回收手段,区别在于 wait 只能等任意一个子进程,waitpid 可以指定 pid、可以非阻塞。
int status; pid_t ret; /* -1 表示等任意子进程;被信号打断时返回 -1 且 errno 为 EINTR,需要重试 */ while ((ret = waitpid(-1, &status, 0)) == -1 && errno == EINTR) { continue; } if (ret > 0) { if (WIFEXITED(status)) { printf("正常退出,返回码 %d\n", WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf("被信号 %d 杀死\n", WTERMSIG(status)); } }status不能直接当整数读,必须用 WIFEXITED、WEXITSTATUS、WIFSIGNALED 这些宏去解,因为退出码和终止信号被打包进了同一个 int 的不同位段。想要不阻塞地清理,就把第三个参数换成 WNOHANG,返回 0 表示有子进程但都还没退出。工程里更常见的做法是给 SIGCHLD 装处理函数,在函数里循环调用非阻塞 waitpid 把已退出的子进程一次收干净。
2.4 一个最小 shell 的执行流程
把上面三件套串起来的最小闭环就是 shell。步骤拆开看很清楚:打印提示符、读一行、按空白切分成 argv 数组、判断是不是内建命令(cd 必须由父进程自己执行,因为子进程 chdir 不影响父进程)、fork、子进程处理重定向后 execvp、父进程 waitpid。重定向必须发生在 fork 之后、exec 之前,因为 dup2 改的是当前进程的 fd 表,改早了会连 shell 自己的输出一起改掉。这个顺序在讲义的 shell 部分反复强调,写错的话现象很诡异:第一次重定向生效,之后 shell 的所有输出都不见了。
3. 文件描述符与 IPC:管道、重定向和共享内存
文件描述符这层抽象值得单独一章,因为它把磁盘文件、终端、管道、socket 全统一成了「一个可以 read/write 的整数」。理解透这一层,重定向、管道、socket 编程就都不需要死记,全是同一套操作换个 fd 而已。
3.1 文件描述符本质是进程私有表的下标
每个进程有一张 fd 表,下标就是那个整数,表项指向系统级的打开文件描述,里面存着偏移量、状态标志和引用计数。这就解释了一串现象:0、1、2 默认被占着(标准输入输出错误),新打开的文件从最小的空闲下标开始分配;fork 之后父子共享打开文件描述所以偏移量联动;close 只是把表项清空、引用计数减一,真正释放要等所有引用都关掉。
理解「最小空闲下标」这条规则,dup2 的用法就不用背了。想重定向标准输出,就是想办法让下标 1 指向目标文件,而 dup2 正是干这个的——它把第一个参数复制到第二个参数指定的下标上,必要时先关掉原来那个。
3.2 dup2 与重定向的顺序陷阱
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { perror("open"); return 1; } /* 把 fd 复制到 1 号位,此后 printf 写的就是这个文件 */ if (dup2(fd, STDOUT_FILENO) < 0) { perror("dup2"); return 1; } close(fd); /* 原来那个下标没用了,关掉,否则 fd 泄漏 */ printf("这行会写进 out.txt\n");O_TRUNC不能省,否则文件比新内容长的时候会留下旧尾巴。close(fd)也不能省,dup2 只是复制了表项,原来那个下标还占着,长跑进程里就是实打实的泄漏。还有一个隐蔽的坑:如果 open 返回的恰好就是 1(前面的标准输出被关过),那 dup2 相当于自己复制自己,什么也没做,而 close(fd) 反而把输出关了。严谨写法是在 open 之后判断fd == STDOUT_FILENO,是的话先 dup 一份再用。
3.3 管道:EOF 什么时候到来
管道是内核里的一块缓冲区,两个 fd 分别对应读端和写端。它的语义里最重要的不是「能传数据」,而是「什么时候 read 返回 0」——所有写端都被关闭时,读端才会读到 EOF。这条规则直接决定了两个经典 bug:忘了关不用的那一端,read 永远不返回;父子进程都持有写端,父进程等子进程、子进程等 EOF,互相卡死。
int fds[2]; if (pipe(fds) < 0) { perror("pipe"); return 1; } if (fork() == 0) { close(fds[0]); /* 子进程只写,读端必须关 */ write(fds[1], "hello", 5); close(fds[1]); /* 关掉写端,父进程才会读到 EOF */ _exit(0); } close(fds[1]); /* 父进程只读,写端必须关 */ char buf[16]; ssize_t n; while ((n = read(fds[0], buf, sizeof buf)) > 0) { write(STDOUT_FILENO, buf, (size_t)n); } close(fds[0]); wait(NULL);缓冲区大小有限(Linux 上通常是 64KB 量级,可用 fcntl 的 F_GETPIPE_SZ 查询),写满之后 write 会阻塞,所以全双工场景下父子互相写、都不读,就是标准死锁。要双向通信,老老实实开两根管道。
3.4 共享内存、mmap 与 IPC 选型
管道适合流式字节、有亲缘关系的进程;跨进程传大块数据就得看共享内存。mmap 把文件或匿名内存映射进地址空间,双方拿到同一块物理页,读写不经过内核拷贝,速度最快,代价是同步要自己做——内核不提供任何互斥保证,必须配信号量或互斥锁。讲义里那句提醒很到位:共享内存只解决「怎么看见」,不解决「谁先谁后」。
| 机制 | 通信范围 | 有无内核缓冲 | 消息边界 | 典型场景 |
|---|---|---|---|---|
| 匿名管道 | 有亲缘关系的进程 | 有 | 无(字节流) | shell 命令串联 |
| 命名管道 FIFO | 任意本机进程 | 有 | 无 | 简单单向通知 |
| 共享内存 | 任意本机进程 | 无(直接映射) | 无 | 大块数据、低延迟 |
| 消息队列 | 任意本机进程 | 有 | 有 | 结构化小消息 |
| 本地 socket | 任意本机进程 | 有 | 无(可自行封装) | 全双工、复杂协议 |
选型时先问三个问题:数据量多大、要不要双向、能不能忍受一次内核拷贝。答案基本就指向表里某一行了。命名管道和消息队列还要注意清理,进程异常退出后残留的对象需要手动删除,这在调试时经常被误认为「程序有鬼」。
4. 信号与并发:从 sigaction 到条件变量
这一章是整门课的分水岭。前面都是单控制流的顺序执行,从信号开始,程序里出现了「随时可能插进来」的第二种执行流,线程则是显式地并行。两者共享同一套麻烦:竞态、可见性、顺序不确定。学的时候分开学,用的时候必须一起想。
4.1 信号是异步投递,不是函数调用
信号由内核在某个时刻投递给进程,进程跳进处理函数执行完再返回原处,看起来像函数调用,但本质完全不同:它可能打断任意一条指令,包括正在执行的 malloc 内部。默认动作有终止、忽略、终止并 core dump、暂停几类,SIGKILL 和 SIGSTOP 不能被捕获或忽略,这条常考。
static volatile sig_atomic_t stop = 0; static void on_sigint(int signo) { (void)signo; stop = 1; /* 处理函数里只做这一件事 */ } int main(void) { struct sigaction sa; memset(&sa, 0, sizeof sa); sa.sa_handler = on_sigint; sigemptyset(&sa.sa_mask); /* 执行期间不额外屏蔽别的信号 */ sa.sa_flags = 0; /* 不加 SA_RESTART,让阻塞调用返回 EINTR */ if (sigaction(SIGINT, &sa, NULL) < 0) { perror("sigaction"); return 1; } while (!stop) { /* 主循环干活 */ } return 0; }sig_atomic_t保证在信号上下文里读写是原子的,volatile阻止编译器把它优化进寄存器。SA_RESTART是个双刃剑:加上之后 read、write 这类慢调用被信号打断会自动重启,代码看起来省事,但while (!stop) { read(...) }这种结构就永远检查不到 stop,程序变成必须杀进程才能退出。不加则 read 返回 -1 且 errno 为 EINTR,循环里必须显式处理。
4.2 异步信号安全:处理函数里能做什么
答案是能做的极少。POSIX 定义了一份 async-signal-safe 函数清单,write、_exit、sigaction 在列,printf、malloc、free、pthread_mutex_lock 全都不在。原因是信号可能打断这些函数内部的临界区,重入之后数据结构就烂了。血泪经验是:处理函数里调用 printf 在测试时往往看不出问题,压测时偶发卡死,排查成本极高。
稳妥写法是「处理函数只置标志位或写一字节到自管道」,真正的逻辑放回主循环做。自管道技巧在讲义里专门讲过:创建一个管道,处理函数往写端 write 一个字节(write 是安全的),主循环用 select/poll 监听读端,等于把异步事件转成了同步事件,后面所有处理都在正常上下文里进行,不用提心吊胆。
4.3 pthread 的创建、回收与 detach
void *worker(void *arg) { int id = *(int *)arg; /* 注意:传指针要保证生命周期覆盖线程执行期 */ printf("worker %d\n", id); return NULL; } pthread_t t; int id = 1; if (pthread_create(&t, NULL, worker, &id) != 0) { /* pthread 系列返回错误码而不是设置 errno,别用 perror */ } pthread_join(t, NULL); /* 不 join 也不 detach 就是资源泄漏 */两个细节:pthread 函数返回 0 表示成功,失败返回错误码本身,所以不能靠 errno 判断;线程必须被 join 或 detach,否则它的栈和描述符不会释放,长跑服务里这是慢性泄漏。参数传递别传栈上局部变量的地址,除非你确保 join 发生在变量销毁之前——传一个栈变量的指针然后立刻返回,线程读到的就是垃圾值。
4.4 条件变量为什么必须配 while
pthread_mutex_lock(&m); while (!ready) { /* 必须 while,不能 if */ pthread_cond_wait(&cv, &m); } consume(item); pthread_mutex_unlock(&m);用 if 的版本在单生产者单消费者、时序温和的测试里能跑通,一上多线程就出问题。原因有三:wait 可能被虚假唤醒;被唤醒到重新拿到锁之间有时间窗,条件可能已被别的线程改回去;广播唤醒多个等待者时,只有一个能真正消费,其余必须回去重新等。这三条里任何一条都足以让 if 版本读到还没准备好的数据。
与之配套的规则是:修改条件必须在持锁状态下进行,signal/broadcast 也建议持锁调用。不持锁改条件,等待方可能在判断条件和调用 wait 之间错过通知,然后永远睡下去——这种 bug 不会崩,只会挂起,最难查。
4.5 死锁与竞态:四个条件和排查顺序
死锁的四个必要条件——互斥、占有并等待、不可抢占、循环等待——背起来容易,用起来难。工程上的排查顺序是:先看有没有嵌套加锁(两个线程以相反顺序拿两把锁,最典型),再看有没有在持锁时做阻塞操作(等待 I/O、等待另一个线程),最后看条件变量的唤醒逻辑有没有丢通知。
避免手段也就那么几条:全局规定加锁顺序,所有代码一律先 A 后 B;用pthread_mutex_trylock失败就释放已有锁重试;把锁的粒度缩小到只保护真正共享的字段。竞态则更隐蔽,典型长相是「加了锁但还是错」,八成是某个共享变量的访问漏在锁外面,或者用了非原子操作做计数器。这类问题靠读代码很难发现,上工具比人眼快得多。
5. 易漏点:五个一跑就现形的细节
复习到这一章,知识点基本齐了,剩下的差距往往在这些边角上。下面五条是梳理下来最容易漏、又最容易在实验和面试里被问到的。
5.1 短读短写:read 返回的字节数不是你要的字节数
易漏点:默认 read/write 一次就能搬完整个缓冲区。为什么漏:本地文件和小数据量下几乎总是全成功,测试环境掩盖了问题。怎么补:任何 read 都必须写在循环里,累计到目标长度或遇到 EOF/错误为止;write 同理,返回值小于请求长度就要接着写剩下的部分。管道、socket、终端这类流式 fd 上,短读短写是常态。再叠加 EINTR 重试,就成了一个标准模板:
ssize_t read_full(int fd, void *buf, size_t len) { size_t done = 0; char *p = buf; while (done < len) { ssize_t n = read(fd, p + done, len - done); if (n < 0) { if (errno == EINTR) continue; /* 被信号打断,重试 */ return -1; /* 真错误 */ } if (n == 0) break; /* EOF */ done += (size_t)n; } return (ssize_t)done; }5.2 管道忘记关写端,read 永远等不到 EOF
易漏点:fork 之后只关自己不用的一端,忘了另一端在对方那里还有副本。为什么漏:代码逻辑上「我只读,为什么要管写端」。怎么补:fork 后立刻在每个进程里关掉不用的那一端,形成习惯动作;调试时如果程序卡在 read 不返回,第一反应就是数一数写端还有几个持有者。父进程自己留着写端是最常见的版本,因为 fork 前创建的管道两端都被复制了一遍。
5.3 fork 之后子进程继承了锁的状态
易漏点:多线程程序里 fork,子进程只保留调用 fork 的那个线程,但所有互斥锁的状态被原样复制。如果 fork 时某把锁正被别的线程持有,子进程里这把锁就是永久锁死状态,一 lock 就挂。为什么漏:单线程程序里根本不会遇到。怎么补:fork 之后、exec 之前,子进程只调用 async-signal-safe 函数,别碰锁、别 malloc;更稳的做法是在 fork 前用 pthread_atfork 注册准备/父/子三个钩子,在准备阶段把所有锁拿到手、在子进程钩子里重新初始化互斥量。
5.4 条件变量用 if 判断,以及丢了通知
易漏点:把 wait 外面的 while 写成 if。为什么漏:测试用例里等待方总是先睡、通知方总是后到,时序刚好正确。怎么补:无条件写 while;同时检查所有修改条件的地方是否都在锁内,signal 是否也在锁内。如果条件是一个计数器而不是布尔值,注意 broadcast 和 signal 的选择——多个等待者等待不同条件时用 broadcast,只等同一条件且只需唤醒一个时用 signal。
5.5 信号处理函数里调用非安全函数
易漏点:在 handler 里 printf、malloc、加锁。为什么漏:平时跑得好好的,压力一大就偶发死锁或崩溃,归因困难。怎么补:handler 里只做两件事——置volatile sig_atomic_t标志、往自管道写一个字节;把日志和业务逻辑挪回主循环。另外记得处理 SIGPIPE:往已关闭的管道或 socket 写数据会收到这个信号,默认动作是终止进程,服务器程序基本都要忽略它,改为检查 write 返回的 EPIPE。
6. 复习清单:六道自测题和一周边界
知识点铺完,最后落到怎么验证自己真的会了。给一份可以照着做的自测清单:不看讲义,白纸写出每个模块的核心流程,写完再对照。写不出来或者写出来有犹豫的地方,就是下一轮要重点补的。
| 自测题 | 检验的能力 | 卡住说明什么 |
|---|---|---|
| 手写 fork + exec + waitpid 完整流程,含错误分支 | 进程三件套 | 返回值语义没记牢 |
用 dup2 实现cmd > file 2>&1,说清顺序 | 文件描述符与重定向 | 没理解 fd 是表下标 |
| 写一个父子管道通信,保证不卡死 | 管道语义与 EOF | 关端规则不熟 |
| 装一个 SIGINT 处理函数,让主循环优雅退出 | 信号与 EINTR | 没考虑阻塞调用被打断 |
| 用互斥锁加条件变量实现生产者消费者 | 同步原语 | while 和锁范围没吃透 |
| 讲清一次死锁的成因并给出两种避免方案 | 并发调试 | 只会背四个条件不会用 |
搭配的复习节奏是:第一天进程与 fd,第二天管道与 IPC,第三天信号,第四天线程与锁,第五天把 shell 和生产者消费者各手写一遍,第六天只看易漏点那一章和错题,第七天合上讲义重画一次知识地图,三条主线加各自的分支,画得出来就算过关。查漏补缺时优先补「写不出来」的,而不是「看着眼熟」的——后者最容易骗自己。
提醒一句:讲义里的示例代码一定要自己敲一遍、故意改错几处再跑。把 waitpid 的循环去掉看看僵尸怎么堆起来,把管道的 close 注释掉看看程序怎么卡住,把条件变量的 while 改成 if 看看多线程下怎么读到脏数据。这些错误自己制造过一次,考场上和代码评审里就不会再犯。
我自己的教训是早期学系统编程只背 API 签名,fork 的返回值写在笔记上很工整,真写代码时还是把父子分支搞反,调试了两小时才发现是if (pid = 0)。从那以后我每次碰进程相关代码,都强制走一遍「返回值三态、exec 失败兜底、wait 收尸」这三项检查,哪怕赶时间也不跳。希望这份梳理能帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取