简介:面向学习系统编程与操作系统基础的中文读者,这份 UIUC CS241 讲义译本将课程内容整理为可离线浏览、便于检索的文档,适合高校学生、自学者以及需要补齐进程、线程、同步、内存和文件系统等底层知识的开发者参考。压缩包共 137 个文件,约 3.5MB,其中 93 个 Markdown 讲义构成主体,另有 22 张 jpg 插图及 js、css、html 等站点资源,并附带 Dockerfile 等部署文件,既能按章节阅读,也方便本地搭建静态页面或进行二次整理。项目说明标注当前处于校对阶段,读者可据此判断译本状态;已有 196 人浏览学习。整体以课程笔记和概念梳理为主,中文表达有助于降低英文原课阅读门槛。读者可借助 Markdown 原文做批注摘录,或基于静态站点资源部署个人阅读页面,用于课堂复习、知识查漏和系统编程入门训练时对照参考。
1. 从一次 fork 翻车说起:这套 CS241 系统编程中文讲义能解决什么
第一次自己动手写 shell 的人,多半会在 fork 之后栽一次跟头:子进程 exec 失败没_exit,父进程和子进程一起回到命令循环里抢终端,屏幕上冒出两个提示符互相打架。这类细节在大部分教材里一句话带过,而 CS241 系统编程中文讲义把它拆成 fork、exec、waitpid、dup2 四条链路分别讲透。它是一份把某高校 CS241 课程英文讲义译成中文的开源笔记集合,典型的系统开源文档项目:按章节拆分 Markdown 文件,代码片段与系统调用说明混排。适合三类人——想补进程、并发、网络底子的后端开发,要做操作系统课设的同学,以及系统方向面试只会背八股、没真正跑过pipe的人。
2. 仓库骨架与阅读路线:讲义是怎么把系统编程切成十几条链路的
拿到一份讲义类仓库,先别急着从头读。我自己的习惯是先tree一遍、看目录怎么分层,再决定从哪章切进去。这份中文讲义同样适用——章节顺序其实对应了「C 与内存 → 进程 → 信号 → 管道与 shell → 线程 → 网络 → 分配器 → 文件系统」的依赖链,前一章是后一章的输入,跳着读会断。
2.1 一个讲义仓库通常长什么样
不同译者的目录命名会有些出入,但分层逻辑基本一致:顶层放 README 和构建配置,docs/下按主题一个目录,每章一个或多个 Markdown,配图单独收在images/里。常见做法大致是这样:
cs241-notes-zh/ ├── README.md # 阅读顺序、翻译进度、贡献说明 ├── mkdocs.yml # 站点配置:导航树、主题、中文分词 ├── docs/ │ ├── index.md # 首页,通常是章节索引表 │ ├── 01-c-and-memory/ # 指针、栈帧、内存模型 │ │ ├── pointers.md │ │ ├── stack-and-heap.md │ │ └── images/ │ ├── 02-processes/ # fork / exec / waitpid / 进程状态 │ ├── 03-signals/ # 信号语义、sigaction、可重入 │ ├── 04-pipes-shell/ # pipe / dup2 / 重定向 / 作业控制 │ ├── 05-threads/ # pthread、mutex、cond、sem │ ├── 06-networking/ # socket、TCP 生命周期、HTTP 服务器 │ ├── 07-malloc/ # 隐式空闲链表、分裂与合并 │ └── 08-filesystem/ # inode、目录项、ext2 只读解析 └── assets/ # 全局样式、字体、图示源文件这个结构不是硬性规定,但它解释了为什么讲义读起来顺:每一章都自带「概念 → 系统调用原型 → 代码片段 → 实验要求」四段式,读者不需要跨文件找上下文。
想把讲义在本地渲染成可跳转的网页,用 MkDocs 起一个本地服务就够了:
# 在仓库根目录执行,mkdocs.yml 里已经配好 docs_dir pip install mkdocs mkdocs-material mkdocs serve -a 127.0.0.1:8000 # 改 md 文件会热重载 mkdocs build -d site/ # 打成静态站点,方便离线看-a指定监听地址,-d指定输出目录。如果只想转成单个 PDF 带去通勤看,用 pandoc 一条命令即可,但要注意中文字体得显式指定,否则导出的 PDF 里汉字会变成方块——这是我第一次转文档时踩过的坑。
pandoc docs/**/*.md -o cs241-notes.pdf \ --pdf-engine=xelatex \ -V CJKmainfont="Noto Sans CJK SC" \ --toc --toc-depth=2--pdf-engine=xelatex是必须的,pdflatex处理中文要额外装宏包;-V CJKmainfont传字体名,用fc-list :lang=zh能看到系统里装了哪些。
2.2 三条阅读路线,按目标选而不是按页码选
讲义篇幅不小,全读一遍对多数人不现实。我一般按目标挑路线,三条就够覆盖大部分需求:
| 路线 | 入口章节 | 中途必须停的地方 | 适合谁 |
|---|---|---|---|
| 实验驱动 | 04 管道与 shell | dup2 重定向、waitpid 回收、作业控制 | 要做 shell 课设、想真正写出能跑的 mini shell |
| 系统调用驱动 | 02 进程 → 03 信号 | fork 返回值三分支、sigaction 与 EINTR | 后端开发,排查线上进程卡死、信号丢失 |
| 面试驱动 | 05 线程 → 06 网络 | 竞态、条件变量虚假唤醒、TCP 三次握手对应 API | 准备系统方向面试,需要能讲清「为什么」 |
实验驱动的路线最值得走,因为 shell 是唯一一个把进程、信号、管道、文件描述符四块知识全部串起来的载体。写完一个能跑ls | grep c和cmd &的 shell,前面几章的碎片自然就成体系了。
2.3 术语对照:为什么 system call 名要保留英文
中文讲义有个绕不开的问题:术语翻译不统一。同一个file descriptor,有的章节写「文件描述符」,有的写「文件句柄」;condition variable有时叫「条件变量」,有时叫「条件量」。我的建议是永远以英文系统调用名为锚点,中文只当注释看:
| 英文(锚点) | 常见中文译法 | 一句话作用 |
|---|---|---|
fork | 创建进程 / 派生 | 复制当前进程,返回两次 |
execve | 加载执行 | 用新程序替换当前进程映像 |
waitpid | 等待子进程 | 回收子进程,读取退出状态 |
pipe | 管道 | 创建一对读写文件描述符 |
dup2 | 复制描述符 | 把 fd 顶到指定编号上 |
sigaction | 信号处理注册 | 安装信号处理函数,可控制重启语义 |
pthread_create | 线程创建 | 起一个 POSIX 线程 |
按这张表搜原版英文讲义或 man page,几乎不会踩到翻译歧义。很多人在读讲义时卡住,不是概念难,是搜关键词搜偏了。
3. 核心章节拆解:fork、exec、dup2 与一个能跑的 mini shell
讲义里含金量最高、也最容易被读快了的,是进程与管道这一块。它把一个 shell 的执行流程拆成「创建 → 替换 → 重定向 → 回收」四步,每一步都对应一个必须处理好的失败分支。下面这几段代码是我从讲义里摘出来重新整理过的,可以直接编译。
3.1 fork 之后第一件事:想清楚 exec 失败怎么办
新手最常见的写法是 fork 完直接execlp,末尾什么都不写。看起来没问题,直到某天命令名打错,子进程从 exec 返回后一路往下走,把父进程的逻辑又执行了一遍。
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { pid_t pid = fork(); /* fork 返回两次:父进程得到子 pid,子进程得到 0 */ if (pid < 0) { /* 失败:通常是被 RLIMIT_NPROC 或内存限制挡住 */ perror("fork"); return 1; } if (pid == 0) { /* 子进程:exec 成功则永不返回;能走到下一行说明失败了 */ execlp("ls", "ls", "-l", (char *)NULL); perror("execlp"); /* 把失败原因写到 stderr,便于排查 */ _exit(127); /* 关键:用 _exit 而不是 return 或 exit */ } int status = 0; pid_t done = waitpid(pid, &status, 0); /* 阻塞等待指定子进程结束 */ if (done < 0) { perror("waitpid"); return 1; } if (WIFEXITED(status)) /* 正常退出? */ printf("child exit code = %d\n", WEXITSTATUS(status)); else if (WIFSIGNALED(status)) /* 被信号干掉? */ printf("child killed by signal %d\n", WTERMSIG(status)); return 0; }几个参数值得单独说。fork()的三个分支必须全处理:小于 0 是失败,等于 0 是子进程,大于 0 是父进程拿到的子进程 pid,顺手记下来后面waitpid要用。execlp的l表示参数逐个列出、以NULL结尾,p表示到PATH里找可执行文件;如果写execl就得给绝对路径。最关键的是_exit(127):exit()会刷 stdio 缓冲区、跑atexit注册的函数,在 fork 出来的子进程里这么干,父进程缓冲区里那些还没落盘的内容会被打印第二遍;_exit()直接走系统调用退出,不碰这些。
waitpid的第三个参数是选项位:传 0 表示阻塞等待指定 pid;传WNOHANG表示非阻塞,没结束就立刻返回 0;传WUNTRACED还能捕获被暂停的子进程。退出状态不能直接读,必须用WIFEXITED、WEXITSTATUS、WIFSIGNALED、WTERMSIG这几个宏去解,直接把status当返回值用是另一个高频错误。
3.2 pipe 加 dup2:从ls | grep到自己的重定向
管道这一节讲义讲得细,因为它是 shell 里唯一涉及「两个进程共享一个内核缓冲区」的地方。核心动作只有三步:建管道、把写端顶到 stdout(或读端顶到 stdin)、把所有原始 fd 关掉。
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(void) { int fds[2]; if (pipe(fds) < 0) { perror("pipe"); return 1; } /* fds[0] 读端,fds[1] 写端 */ pid_t left = fork(); if (left == 0) { /* 左半边:ls,写管道 */ dup2(fds[1], STDOUT_FILENO); /* 写端顶到 fd 1 */ close(fds[0]); /* 关掉不用的读端 */ close(fds[1]); /* 原来的写端也要关,fd 1 已是同一份 */ execlp("ls", "ls", (char *)NULL); _exit(127); } pid_t right = fork(); if (right == 0) { /* 右半边:grep c,读管道 */ dup2(fds[0], STDIN_FILENO); /* 读端顶到 fd 0 */ close(fds[0]); close(fds[1]); execlp("grep", "grep", "c", (char *)NULL); _exit(127); } close(fds[0]); /* 父进程两个都要关 */ close(fds[1]); waitpid(left, NULL, 0); waitpid(right, NULL, 0); return 0; }dup2(oldfd, newfd)的行为是「让 newfd 指向 oldfd 那张打开文件表项」,如果 newfd 已经打开会先被静默关闭——STDOUT_FILENO是常量 1,STDIN_FILENO是 0,别把顺序写反,反了就是往终端写、从终端读,程序会卡在那里等输入。
三个close里最容易漏的是父进程那两个。管道的读端只有在「所有写端都关闭」之后才会读到 EOF;父进程如果一直攥着fds[1]不放,右边那个grep的read永远不会返回 0,表现出来就是命令跑完了但 shell 不回到提示符——这个现象我在第一次写 shell 时盯了半小时才反应过来。
waitpid(left/right, NULL, 0)传NULL表示不关心退出状态,只做回收。两个子进程的顺序其实无所谓,谁先结束都行,但两个都得 wait,漏一个就留下僵尸。
3.3 信号与作业控制:Ctrl-C 为什么不该杀死 shell 自己
讲义到这一节才开始上强度。你在自己写的 shell 里按 Ctrl-C,终端驱动会把 SIGINT 发给整个前台进程组;如果 shell 和子进程在同一个组里,shell 自己也一起死。正确做法是把 shell 放进独立进程组,再把前台进程组的控制权交给子进程:
#include <signal.h> #include <unistd.h> #include <termios.h> static volatile sig_atomic_t got_int = 0; static void on_int(int sig) { (void)sig; got_int = 1; /* 处理函数里只置标志,不做任何重活 */ } void install_handlers(void) { struct sigaction sa; sa.sa_handler = on_int; sigemptyset(&sa.sa_mask); sa.sa_flags = 0; /* 不要 SA_RESTART,否则阻塞的 read 不会被打断 */ sigaction(SIGINT, &sa, NULL); }volatile sig_atomic_t是信号处理函数和主循环之间唯一安全的通信类型,编译器不会把它优化进寄存器,读写也不会被拆成两条指令。sa_mask是处理函数执行期间额外屏蔽的信号集,sigemptyset表示不额外屏蔽。sa_flags这里刻意不设SA_RESTART:一旦设了,被信号打断的read会自动重启,主循环就感知不到 Ctrl-C 了,只能等下一次输入。
子进程侧要在 fork 之后、exec 之前调用setpgid(0, 0)自建进程组,父进程侧用tcsetpgrp(STDIN_FILENO, child_pgid)把终端控制权交出去,子进程结束后再tcsetpgrp收回。这一段讲义讲得比较抽象,我建议对着代码跑一遍cat然后按 Ctrl-C,观察 shell 有没有活下来,比读十遍文字管用。
4. 并发与网络章节:pthread 三件套和一个小 HTTP 服务器
后半本讲义的重心从「进程之间」转到「线程之间」和「机器之间」。线程那章的所有例子最终都指向同一个结论:共享内存加锁不是为了性能,是为了让「检查和修改」变成一个不可分割的动作。网络那章则把 socket 的每个 API 对应到 TCP 状态机上,读完再看accept返回什么就不再是玄学。
4.1 mutex 加条件变量:一个不会虚假唤醒搞崩的队列
单独用 mutex 只能保证不撕数据,没法让消费者「等有数据了再醒来」。轮询浪费 CPU,睡固定时间又是撞大运。标准写法是 mutex 配 condition variable,且判断条件必须放在while里:
#include <pthread.h> #define CAP 8 static int buf[CAP]; static int head = 0, tail = 0, count = 0; static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; static pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER; static pthread_cond_t not_full = PTHREAD_COND_INITIALIZER; void push(int v) { pthread_mutex_lock(&mtx); while (count == CAP) /* 必须 while,不能 if */ pthread_cond_wait(¬_full, &mtx); /* 原子地释放锁并睡眠 */ buf[tail] = v; tail = (tail + 1) % CAP; count++; pthread_cond_signal(¬_empty); /* 唤醒一个消费者 */ pthread_mutex_unlock(&mtx); } int pop(void) { pthread_mutex_lock(&mtx); while (count == 0) pthread_cond_wait(¬_empty, &mtx); int v = buf[head]; head = (head + 1) % CAP; count--; pthread_cond_signal(¬_full); pthread_mutex_unlock(&mtx); return v; }pthread_cond_wait(&cond, &mtx)干了两件事:把mtx原子地释放掉、把线程挂到条件变量上睡;被唤醒时再原子地把锁拿回来。正因为唤醒和重新拿锁之间有一小段空窗,条件可能又被别的线程改掉了,所以必须回到while重新检查——这就是「虚假唤醒」的现实来源,用if写的人在压力测试下必翻车。PTHREAD_MUTEX_INITIALIZER是静态初始化,省掉pthread_mutex_init,适合全局锁。
编译时链接顺序也有讲究,-lpthread要放在源文件后面:
gcc -Wall -Wextra -g -o queue queue.c -lpthread-g保留调试符号,配合-fsanitize=thread能直接抓出数据竞争,比肉眼审代码快得多。
4.2 从阻塞 socket 到一个能返回 HTML 的服务
网络章从socket到accept一路讲下来,最后落到一个几十行的 HTTP 服务器。核心 API 顺序是固定的:socket建 fd、setsockopt开地址复用、bind绑端口、listen转监听态、accept取连接。
#include <arpa/inet.h> #include <netinet/in.h> #include <stdio.h> #include <string.h> #include <sys/socket.h> #include <unistd.h> int main(void) { int sfd = socket(AF_INET, SOCK_STREAM, 0); /* SOCK_STREAM 即 TCP */ int yes = 1; setsockopt(sfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes); /* 端口快速复用 */ struct sockaddr_in addr; memset(&addr, 0, sizeof addr); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 任何本地地址 */ addr.sin_port = htons(8080); /* 主机序转网络序,别漏 */ if (bind(sfd, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("bind"); return 1; } if (listen(sfd, 128) < 0) { perror("listen"); return 1; } /* 128 是 backlog */ for (;;) { int cfd = accept(sfd, NULL, NULL); /* 阻塞等下一个连接 */ if (cfd < 0) continue; char req[4096]; ssize_t n = read(cfd, req, sizeof req - 1); /* 简版:读一次就够 */ if (n > 0) { req[n] = '\0'; const char *body = "<h1>hello</h1>"; char resp[512]; int len = snprintf(resp, sizeof resp, "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n" "Content-Length: %zu\r\nConnection: close\r\n\r\n%s", strlen(body), body); write(cfd, resp, len); } close(cfd); /* 必须关,否则 fd 泄漏 */ } }htons(8080)那一步漏掉的人不少,表现是服务起来了但浏览器连不上,抓包能看到端口号是乱的——主机字节序和网络字节序在小端机器上正好相反。SO_REUSEADDR是为了避免服务重启时被TIME_WAIT卡住,本地开发必备。listen的 backlog 是内核维护的已完成连接队列长度,不是硬上限,但设太小在高并发下会丢连接。这个单线程版本一次只能服务一个客户端,讲义后面会让你改成accept之后 fork 一个子进程或起一个 pthread,那才是真正把前两章内容合起来用的地方。
4.3 分配器与文件系统:讲义里两段「读代码」训练
最后两章的风格和前六章完全不同,几乎不写新代码,全在让你读别人的实现。分配器那章给一个隐式空闲链表:每个块头部存 size 和 allocated 位,free时检查前后邻居能否合并,malloc时从头扫描找第一个够大的块并分裂。关键结构就一个:
typedef struct block { size_t size_and_flags; /* 低位当 allocated 标记,高位存块大小 */ struct block *next_free; /* 只在空闲块里有效 */ } block_t;为什么用低位存标志?因为块大小按 8 或 16 字节对齐,最低几位永远是 0,拿来存标记不浪费空间。这个技巧在后面讲malloc对齐要求时会反复出现。
文件系统那章是读 ext2:先解析超级块拿到块大小和 inode 表位置,再按 inode 号算出偏移读到 inode,最后顺着直接块指针和间接块指针找到目录项,目录项里name_len决定文件名长度。整章其实是在训练一件事——把磁盘上的一段字节按结构体解释出来之前,先确认字节序和对齐。这两章读起来最枯燥,但它们是从「会调 API」到「知道 API 底下发生了什么」的分水岭。
5. 避坑与排查:读讲义和跑实验时最容易翻车的几处
讲义是别人写的,环境是你自己的。下面这几条是我和周围人实打实踩过的,按「现象 → 原因 → 解决」记下来,遇到时能省不少时间。
5.1 输出莫名重复一遍
现象:fork 之后,父进程里那句printf("prompt> ")在终端打印了两次。原因:printf写的是 stdio 缓冲区,缓冲区内容在 fork 时被完整复制进子进程,子进程退出(调了exit)时又刷了一次。解决:fork 之前fflush(stdout),或者子进程一律用_exit退出。这个坑和 3.1 节讲的是同一个根因,只是从另一端冒出来。
5.2 管道命令跑完了但 shell 不返回
现象:自己写的 shell 执行ls | grep c,两边进程都结束了,但提示符再也不出现。原因:父进程 fork 完没关掉fds[0]和fds[1],读端始终有一个写端没关闭,read等不到 EOF。解决:父进程在 fork 完两个子进程之后立刻close(fds[0]); close(fds[1]);。判断这类问题有个通用手法:ls -l /proc/<pid>/fd看进程还开着哪些 fd。
5.3 进程表里堆了一排僵尸
现象:ps aux看到一堆状态是Z的进程,杀不掉。原因:子进程退出后父进程没wait,内核保留退出状态等着被读。解决:父进程要么阻塞waitpid,要么在 SIGCHLD 处理函数里循环while (waitpid(-1, NULL, WNOHANG) > 0);把已结束的都收掉。注意waitpid的第一个参数传 -1 表示等任意子进程,配WNOHANG才不会卡住。
5.4 抄讲义里的代码片段编译不过
现象:直接复制讲义里的函数体,gcc报一堆implicit declaration of function。原因:讲义为了聚焦主线,常常省略#include。解决:按报错里缺的函数名补头文件——fork/exec/read在<unistd.h>,waitpid在<sys/wait.h>,socket/bind在<sys/socket.h>,pthread_*在<pthread.h>且要加-lpthread。养成先编译再看逻辑的习惯,别在缺头文件的代码上推演行为。
5.5 翻到某一节发现术语对不上
现象:搜「条件量」搜不到任何内容,但明明记得讲义里讲过。原因:中文译名不统一,同一概念在不同章节用了不同译法。解决:回到 2.3 那张对照表,用英文系统调用名或英文术语去搜,或者在仓库里用grep -ri "condition variable" docs/全量搜一遍。翻译型文档项目这类问题无解,只能自己建立锚点词表。
6. 二次开发:把讲义改成自己的实验手册
讲义读第二遍的时候,价值就不在「看」而在「改」。我的做法是另建一个labs/目录,和讲义分开放,每章抽一个最小可编译例子,配一个 Makefile,让「读到的」变成「跑通的」。这样做的直接好处是:以后回查某个系统调用的行为,不用再翻讲义,直接看自己那份带注释的代码。
CC := gcc CFLAGS := -Wall -Wextra -g -std=gnu11 LDFLAGS := -lpthread SRCS := $(wildcard *.c) BINS := $(SRCS:.c=) all: $(BINS) %: %.c $(CC) $(CFLAGS) -o $@ $< $(LDFLAGS) clean: rm -f $(BINS) .PHONY: all clean这个 Makefile 靠wildcard自动发现当前目录的.c文件,新增一个例子不用改构建脚本。-std=gnu11是为了用上_GNU_SOURCE相关的扩展,比如pthread_setname_np;-g必须留着,后面用gdb或者-fsanitize=thread排查并发问题全靠它。
改造讲义内容时,我建议按「一个系统调用一个目录」来组织,而不是按章节:
labs/ ├── 01-fork-exec/ # 最小 fork + exec + waitpid ├── 02-pipe-dup2/ # 手写 ls | grep ├── 03-signal/ # Ctrl-C 与 EINTR 实验 ├── 04-pthread-cond/ # 生产者消费者,带压力测试 └── 05-socket-http/ # 单线程 HTTP,再改多线程每个目录里放三样东西:例子代码、一段NOTES.md记录这次跑出来的实际输出、一个run.sh把编译和验证串起来。验证脚本不用复杂,把预期输出和实际输出 diff 一下就行:
#!/usr/bin/env bash set -euo pipefail make -s out=$(./02-pipe-dup2) expected=$'a.c\nb.c' [[ "$out" == "$expected" ]] || { echo "FAIL: got [$out]"; exit 1; } echo "PASS"set -euo pipefail三件套别省,-u能抓出未定义变量,-o pipefail让管道里任一环失败都能被感知到,否则cmd | grep x里cmd崩了脚本照样往下走。
真正值得改的地方是那些讲义只给结论不给过程的段落。比如作业控制里的前台进程组切换,讲义写了「用tcsetpgrp交出去」,但没写交出去之后子进程被暂停(Ctrl-Z)再fg回来要怎么恢复。自己动手把这块补上,顺便在NOTES.md里记下SIGTSTP、SIGCONT的实际行为,这段内容才算真正变成你的。
最后提一个我自己的习惯:每改完一个例子,我都会刻意把它改坏一次——删掉那个close,或者把while换成if——然后跑一遍看现象对不对得上预期。因为能复现出讲义里描述的故障,才算真的理解了那个约束为什么存在。当年我第一次写 shell 时跳过了这一步,结果线上一个重定向丢了 fd,排查到凌晨才发现是父进程没关写端。从那以后我每次动文件描述符相关的代码,都强制走一遍「改坏 → 观察 → 改回」的流程,比任何单元测试都管用。希望帮到你。
本文还有配套的精品资源,点击获取