简介:《UIUC CS241系统编程中文讲义》是 UIUC CS241 课程的开源中文翻译项目,面向对操作系统底层运行机制、系统级 I/O、并发与多进程编程感兴趣的开发者,也适合计算机专业学生对照课程开展自学。资源共 137 个文件,整体仅 3.5MB,主体为 93 个 Markdown 讲义,按课程知识模块逐篇整理;另有 22 张 JPG 示意图辅助理解抽象概念,配套的 HTML、JS、CSS 文件构成可浏览的网页版;还自带 Dockerfile 和部署脚本,一条命令即可启动本地阅读服务。目前已有 195 人下载学习。项目处于校对阶段,由社区多人维护,译稿注重术语统一与表述顺畅;除了讲义正文,包内还包含项目主页、样式资源、许可证和 Git 配置等完整开源结构,便于离线保存、二次查阅或继续参与翻译校对。与英文原版搭配使用,既能逐节对照学习,也能快速回顾关键概念,尤其适合备考、课程设计或面试前的体系化复习。
1. 系统编程知识地图:从 C 到并发与网络的一整条主线
学系统编程最容易犯的错,是把它当成“C 语言进阶课”来学。这套系统编程中文讲义给我的第一印象恰恰相反:它是一条从内存布局走到进程模型,再到并发同步,最后落到网络与 Shell 的完整主线。真正难的不是某个 API 用没用对,而是 fork 之后父子进程的内存关系、锁和条件变量的协作顺序、socket 缓冲区对读写语义的影响。这份讲义适合两类人:一类是正在复习进程线程、网络编程的备考者;另一类是想把 C 语言底子补扎实的开发者。它把系统编程里最零散的知识点串成一张地图,配合开源工具链,让查漏补缺有明确路径。下面按这张地图逐块展开。
2. C 语言与内存模型:指针、栈堆与未定义行为
2.1 指针与数组:等价不是相同
“数组名就是指针”这句话可以说对了一半。数组名在表达式中通常会退化成指向第一个元素的指针,但sizeof、取地址&以及对字符串字面量的处理都属于例外。很多内存报错看着莫名,其实都是从这个差异开始的。
int arr[4] = {1, 2, 3, 4}; int *p = arr; printf("sizeof arr = %zu\n", sizeof(arr)); // 16 printf("sizeof p = %zu\n", sizeof(p)); // 8这段代码在 64 位机器上分别输出 16 和 8。arr的类型是int[4],所以sizeof(arr)是整个数组占用的 16 字节;而p只是一个指针,保存的是arr的地址,sizeof(p)是 8 字节。也就是说,数组名和指针在“值”上可以互相代替,但在类型上完全不同。
最容易忽略的是&arr与&p的差别。&arr的类型是int (*)[4],对它执行(&arr) + 1会跳过整个数组,而不是跳过第一个元素。如果你把&arr强转成int *再做算术,步长会被强行改变,这类代码在边界判断上特别容易翻车。
另一个高频考点是函数参数里的数组。void f(int a[])其实等价于void f(int *a),也就是说数组在传参时已经退化成指针了。所以在函数内部不能用sizeof(a) / sizeof(a[0])来算长度,必须由调用方显式传入长度参数,或者用宏配合结构体把长度一起打包。
2.2 栈、堆与静态区:生命周期决定可见性
| 内存区域 | 分配方式 | 生命周期 | 最容易踩的错 |
|---|---|---|---|
| 栈 | 函数调用时自动分配 | 函数返回即失效 | 返回局部变量地址 |
| 堆 | malloc / free 手动管理 | 直到 free 或进程结束 | 泄漏、双重释放、use-after-free |
| 静态区 | 编译期确定 | 整个程序运行期 | 多线程无锁修改全局状态 |
表格是复习时的主线。栈上变量随函数调用而来,随函数返回而消失,所以返回栈上局部变量的地址是典型的未定义行为:
int *bad(void) { int x = 42; return &x; // 悬空指针 }这段代码不是每次运行都会崩溃,但它的行为不确定:栈帧被后续调用覆盖后,读到的值可能是别的数据。这就是系统编程里的第一个“黑匣子”——看起来能跑,实际上是运气。
堆内存的生命周期由开发者控制。malloc之后一定要配对free,但更隐蔽的是realloc。realloc可能把原有内存移动到新位置,并返回新的指针;你如果继续用旧指针,就会访问到已释放或未定义的内存。所以正确的写法是
int *tmp = realloc(p, new_size); if (tmp == NULL) { perror("realloc"); // p 仍然有效,需要处理失败 } else { p = tmp; }静态区存放全局变量和static变量,生命周期贯穿整个程序,但这也意味着并发场景下所有线程都在读写同一块内存。后面第 4 章的锁基本都在围绕这个区域展开。还有一个细节值得单独记:字符串字面量通常存在只读静态区,char *s = "abc"里的s指向的字符不能修改;而char s[] = "abc"会把内容复制到栈或全局数组里,之后可以正常修改。这组对比经常在 C 语言笔试题里出现。
2.3 内存错误的诊断与预防:编译期、运行期、事后分析
内存错误之所以让人头疼,是因为它常常不在出错现场暴露,而是等到另一个变量被覆盖才爆发。复习阶段最有效的做法是开三面防护:编译警告、运行时检测、事后分析。
gcc -g -Wall -Werror -fsanitize=address main.c -o main ./main-g让你在报错时能定位到行号;-Wall开启常见警告;-Werror把所有警告升级为错误,逼你处理;-fsanitize=address也就是 ASan,会在运行期检测越界、use-after-free、重复释放等。一旦命中,程序会直接输出类似heap-use-after-free和对应的调用栈,比你自己猜快了太多。
如果问题偏“内存泄漏”而不是越界,ASan 也能报,但更多时候我会用valgrind --leak-check=full ./main来做事后分析。它会在程序退出时列出泄漏的堆块、大小和分配位置。两者的侧重点不同:ASan 更像运行期哨兵,valgrind 更像事后审计。复习时可以把同一个测试程序分别跑一遍,不要只依赖一种工具。
梳理下来会发现,绝大多数内存错误不是“不会写”而是“没规划”:谁负责分配,谁负责释放,边界长度由谁保证。最容易补的一步是在每段代码里把分配和释放放在同一个函数层级,比如封装一个create_buffer / destroy_buffer,这样出了问题可以直接在两个点之间排查。
3. 进程与系统调用:fork、exec 与信号处理
3.1 fork 的语义:写时复制与不确定顺序
进程这一章开始,概念就不再是“编译器怎么解释代码”,而是“操作系统怎么看待程序”。复习进程前,先记住一句话:进程不等价于程序。程序是一段静态的代码和资源,进程是它运行时的一组完整状态。fork就是从这个状态中派生出一个几乎相同的副本。
pid_t pid = fork(); if (pid < 0) { perror("fork"); exit(1); } else if (pid == 0) { // 子进程从这里开始执行 printf("child\n"); } else { // 父进程得到子进程 pid printf("parent\n"); }fork的返回值是这道题的第一重点:父进程返回子进程的 PID,子进程返回 0,失败则返回 -1。所以if-else之后,同一个源代码会被两个进程各自执行一次,但进入的分支不同。注意“返回两次”的说法并不准确,准确说是“调用一次,在两个进程里各返回一次”。
现代系统实现的是写时复制技术。刚 fork 完的父子进程共享同一批物理页,只有当某一方要写入时,内核才会真正复制页面。这是性能优化,不是内存隔离。更要命的是文件描述符:fork 会复制文件描述符表,但表项指向的是内核里同一个“打开文件描述”,所以父子进程是共享同一个文件偏移量的。如果两边都read,读到的内容会互相影响,这一点在易错点章节还会再展开。
fork 之后两个进程的执行顺序完全由调度器决定,不能依赖printf的输出顺序。想强制同步,就必须用管道、信号或共享内存加锁。很多初学系统编程的人在这里 debug 了很久,最后发现只是顺序不确定,而不是代码逻辑错了。
3.2 exec 族与子进程回收:exit 到 waitpid 的全链路
fork负责“分身”,exec负责“变装”。exec 系列函数会用一个新的程序映像替换当前进程的地址空间,替换之后原来的代码和数据全部消失,如果成功了就不会返回;只有失败时才返回 -1 并且errno会被设置。
char *args[] = {"ls", "-l", NULL}; if (execvp("ls", args) < 0) { perror("execvp"); exit(1); } // 如果 exec 成功,永远到不了这里exec 族里最常用的是execvp,它会在 PATH 环境变量里搜索命令;参数数组必须以NULL结尾。这里最常见的翻车点是没有检查失败。因为 exec 成功不返回,很多人会忘了失败分支,结果命令不存在时程序直接崩溃。
子进程的回收是另一个重要考点。子进程退出并不会立刻消失,内核会保留它的进程表项直到父进程调用wait或waitpid。如果父进程一直不回收,这个进程就变成僵尸进程。僵尸进程不恶意,但它占着进程资源,积累多了会耗光 PID。
int status; pid_t done = waitpid(pid, &status, 0); if (done < 0 && errno == EINTR) { // 被信号打断,需要重试或统一处理 } if (WIFEXITED(status)) { printf("exit code %d\n", WEXITSTATUS(status)); }waitpid的第三个参数传0表示阻塞等待,传WNOHANG表示非阻塞轮询。这样设计是为了配合后面的信号处理:当子进程终止时,父进程会收到SIGCHLD,在 handler 里用waitpid(-1, &status, WNOHANG)循环收割所有已结束的子进程,避免阻塞主流程。
3.3 信号:异步通知与可重入函数
信号是进程之间的异步通知机制。它随时可能到来,既可能发生在语句之间,也可能发生在系统调用阻塞时。复习信号只需要把握三个点:哪些信号不可捕获、handler 里能做什么、被信号打断的系统调用怎么办。
SIGKILL和SIGSTOP不能被捕获或忽略,这是内核给“管理者”留下的后门。其他信号可以用signal或sigaction处理。sigaction更可控,但复习题里更常考的是 handler 的限制——handler 是被异步调用的,所以只能调用“异步信号安全”的函数,直接printf是不安全的,它内部的缓冲和锁可能已经把进程置入非一致状态。
volatile sig_atomic_t got_sig = 0; void on_sig(int sig) { got_sig = sig; // 只记录标志,不做 I/O } // 在主循环里再检查 got_sig,并在安全的地方输出handler 里推荐只写一个volatile sig_atomic_t标志,主程序轮询到标志后再处理真正的逻辑。这能避开绝大多数“信号一来就挂”的问题。
另一个高频坑是EINTR。信号到达后,阻塞中的read、accept、waitpid可能提前返回 -1 并设置errno = EINTR。容易踩坑的写法是在这些调用外面直接判断-1就报错,结果信号一来程序就误判为网络错误。正确的做法是遇到EINTR时根据场景重启调用,或者记录继续条件。这也是后面编写可靠网络循环的必备细节。
4. 并发编程三件套:线程、锁与条件变量
4.1 线程模型与共享状态:内存共享带来的第一个复杂度
线程和进程最大的区别是内存可见性:同一进程下的线程共享地址空间,堆、全局变量、文件描述符对每个线程都可见;只有栈和寄存器上下文是私有的。这个共享模型让线程比进程轻,但也让并发 bug 比单线程多出一个数量级。
void *worker(void *arg) { long n = (long)arg; printf("thread %ld\n", n); return NULL; } pthread_t t; pthread_create(&t, NULL, worker, (void *)42); pthread_join(t, NULL);pthread_create的关键参数有三个:线程句柄、入口函数、传给入口函数的参数。入口函数必须是void *(*)(void *),所以整数参数通常强转成void *传递,取回时要再转回原本类型。pthread_join的作用是等待线程结束并回收,类似于进程里的waitpid。
共享状态的第一个复杂度在于“检查再修改”不是原子操作。count++在底层是读、加、写三步,两个线程同时执行时可能互相覆盖。这场竞态走上不归路的原因,就是没有让三个步骤变成一个临界区。
4.2 互斥锁:Pthread 锁的正确姿势与死锁
互斥锁保护的不是“某个变量”,而是“对这个变量的访问序列”。一条原则贯穿始终:所有共享变量的读、写、检查加修改都要发生在同一把锁的保护下,否则锁就形同虚设。
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_lock(&mtx); count++; pthread_mutex_unlock(&mtx);锁的使用看起来简单,难的是锁的粒度。锁保护范围太大,线程之间的并行度被串行化,性能退化成单线程;锁保护范围太小,竞态仍然存在。复习时可以用“临界区最小化”来评估:只把真正操作共享状态的几条语句放进锁里,把 I/O 挪到锁外。
死锁的经典条件是四个同时满足:互斥、持有并等待、不可抢占、循环等待。在二把锁以上的场景里,最有效的预防策略是给所有锁定义一个全局加锁顺序。如果有两个线程分别先锁 A 再锁 B、先锁 B 再锁 A,就形成了循环等待。统一顺序后,任何线程都按 A→B 的方式拿锁,循环等待被打断,死锁自然消除。也可以用pthread_mutex_trylock拿不到锁就回滚已持有的锁,但这是补偿手段,不如顺序来得稳。
4.3 条件变量:从忙等通知到消息通知
如果只有互斥锁,消费者想等缓冲区有数据时,只能反复轮询;轮询既浪费 CPU,又让代码结构难看。条件变量是为解决“等一个条件变成真”而生的:它让调用者原子地释放锁、进入睡眠,直到另一个线程通知它醒来。
int count = 0; pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cv = PTHREAD_COND_INITIALIZER; void produce(int n) { pthread_mutex_lock(&mtx); count += n; pthread_cond_signal(&cv); pthread_mutex_unlock(&mtx); } void consume_until(int target) { pthread_mutex_lock(&mtx); while (count < target) { pthread_cond_wait(&cv, &mtx); } count -= target; pthread_mutex_unlock(&mtx); }这段代码有两个必须背下来的细节。第一,pthread_cond_wait不是单纯睡眠,它会把互斥锁释放,让生产者能进入临界区;被唤醒后它又会去重新获得锁。第二,外层条件判断一定要用while而不是if,因为存在“伪唤醒”,而且signal可能同时唤醒多个消费者,只有一个能继续消费,其余的要重新判断条件。
pthread_cond_signal与pthread_cond_broadcast的区别也要分清楚:signal至少唤醒一个等待线程,broadcast唤醒所有等待线程。当多个消费者等待不同条件时,signal可能唤醒一个假醒的线程,这时它必须回到while再睡下去,所以条件变量的使用场景里,while几乎是防止“玄学 bug”的生命线。
条件变量还有一个易漏点:它本身不保存状态。如果signal发出时没有任何线程在等待,这个唤醒就丢失了。因此必须额外维护一个谓词状态,也就是上面的count。判断条件和更新状态都要在锁内完成,这是条件变量能够可靠工作的大前提。
5. 易漏点与易错点:系统编程里最容易翻车的五个细节
5.1 内存与进程方向:三个低频但致命的坑
易漏点:用strlen的结果直接malloc,忘记给字符串终止符留空间。
为什么漏:strlen返回的是字符串中字符的数量,不包含末尾的'\0'。strcpy会把整个长度加一的字节写入目标,如果你分配的长度就是strlen(s),最后一个字节会写到缓冲区外。
怎么补:分配时显式malloc(strlen(s) + 1)。顺手可以养成检查strdup的习惯,它内部会做完整的复制,但依然要知道它会多分配一个字节。修复后用 ASan 跑一遍,它会立刻指出越界位置。
易漏点:fork 之前没有刷新stdout,结果同一份输出出现两次。
为什么漏:printf输出通常先进用户态缓冲区。fork复制进程地址空间时,缓冲区也被完整复制,于是子进程和父进程各自拿着同一段未刷新内容,在后续刷新时各输出一次。这是进程课里最经典的“复制了不该复制的东西”案例。
怎么补:在fork之前调用fflush(stdout)清空缓冲区;子进程中使用_exit(0)退出,而不是exit(0),因为exit会再次刷新缓冲区,导致重复输出。如果追求更稳的写法,干脆让每条日志在fork后立刻fflush,或者使用write这类不带用户态缓冲的输出。
易漏点:waitpid返回 -1 时只想到“没有子进程”,忽略了EINTR。
为什么漏:很多教程只写了“子进程不存在会返回 -1”,但信号同样会让等待中的waitpid提前返回,并且errno被设置为EINTR。没有处理EINTR的收割循环,会让正常的子进程退出事件被误判为失败。
怎么补:封装一个循环,遇到EINTR就重试,直到拿到实际的子进程状态:
while (waitpid(-1, &status, 0) < 0 && errno == EINTR) ;同样的问题会出现在accept、read等阻塞系统调用上,提前认识EINTR,后面的网络编程会少踩很多坑。
5.2 并发和网络方向:两个容易让调试变玄学的问题
易漏点:条件变量的等待条件用if而不是while。
为什么漏:看起来if也成立,反正信号来了就往下走。但条件变量的唤醒信号只代表“条件可能满足了”,不代表“条件一定满足”。伪唤醒和多个消费者竞争都会让唤醒后的线程和if判断之间出现窗口。
怎么补:统一写while (条件不满足) pthread_cond_wait(&cv, &mtx);。这不是为了“更严谨”,而是条件变量的标准用法。约定俗成:只要看到pthread_cond_wait,外层必然是while。
易漏点:TCP socket 上一次read或write没读完或没写完,就直接当字节流处理。
为什么漏:网络编程里,read返回的字节数通常比你想读的少;write也可能只写了一半缓冲区。这不是网络坏了,而是阻塞 socket 的默认语义。把一次读/写当成全量读写,是并发服务器首批会遇到的 bug。
怎么补:封装循环,把“读满 n 字节”和“写满 n 字节”当作接口,直到read返回 0(EOF)或出错才停止:
ssize_t recv_full(int fd, char *buf, size_t len) { size_t have = 0; while (have < len) { ssize_t n = read(fd, buf + have, len - have); if (n == 0) break; if (n < 0 && errno == EINTR) continue; if (n < 0) return -1; have += n; } return have; }server 端还经常漏掉SIGPIPE:当对端关闭连接后,写操作会让进程收到SIGPIPE而默认终止整个进程。处理办法是signal(SIGPIPE, SIG_IGN),然后靠write返回EPIPE来识别断开,而不是让进程静默退出。
6. 复习清单与查漏补缺:用 30 分钟把知识地图过一遍
6.1 按模块的自测清单
临考前不建议从头再翻一遍讲义,先按下面的清单跑一遍,卡住的地方就是你的薄弱模块:
| 模块 | 自测问题 | 卡住时回看 |
|---|---|---|
| 内存 | sizeof(arr)和sizeof(p)为什么不同 | 2.1 的类型差异 |
| 内存 | 返回局部变量地址会怎样 | 2.2 生命周期表 |
| 进程 | fork 返回两次每次返回什么 | 3.1 的代码与注释 |
| 进程 | 僵尸进程怎么出现、怎么回收 | 3.2 的 waitpid 片段 |
| 进程 | 信号 handler 里能调用 printf 吗 | 3.3 的 volatile 标志 |
| 并发 | count++ 为什么要加锁 | 4.1 的检查再修改 |
| 并发 | 条件变量为什么必须是 while | 4.3 的消费者循环 |
| 网络 | read/write 没读写完怎么办 | 5.2 的 recv_full 封装 |
清单里的每一行都可以对应到前面的代码或表格。能直接说出“为什么”而不是“我记得”,才算真的掌握了。
6.2 从报错信息反查知识点
查漏补缺最实用的一个技巧,是把报错信息当成考试题目来反查:看到stack smashing detected,回去复习栈和数组越界;看到heap-use-after-free,回去复习堆生命周期和悬空指针;看到Broken pipe,回去复习 SIGPIPE 和 TCP 半关闭;看到EINTR,回去复习信号中断的系统调用。这样做比按顺序翻讲义快得多,因为报错已经给出了“该补哪个模块”的答案。
我从这套讲义里学到的最重要的不是某个 API,而是“先建地图,再填细节”。从那以后,我每次复习系统编程都强制走一遍流程:先从报错反向圈出薄弱模块,再把模块对应的表格和代码默写一遍,最后挑一条易错点改造成自己的小例子。这个习惯看起来慢,实际上每一分钟都在补曾经踩过的坑。希望帮到你。
本文还有配套的精品资源,点击获取