简介:《Linux/UNIX系统编程手册》课后习题代码是一份面向系统编程学习者的实践代码包,适合正在攻读本书、希望巩固文件I/O、进程控制、信号、线程、网络套接字及I/O复用等核心API的开发者。资源共561个文件,以359个C源文件为主,辅以60个头文件、45个Makefile构建脚本、16个Shell脚本及若干SConstruct/说明文档,覆盖习题解答、示例程序与跨平台构建配置,压缩包仅1016KB,轻量且便于下载与阅读。已有720人学习下载。代码中包含fork顺序统计、内存分配验证、文件锁、线程克隆、网络套接字封装等典型习题实现,并附带Makefile与SConstruct,可帮助读者对照手册逐章演练,理解系统调用的底层细节,建立扎实的系统编程功底。
1. 为什么《Linux/UNIX系统编程手册》课后习题代码比读书更能检验能力
《Linux/UNIX系统编程手册》(TLPI,Michael Kerrisk 著)是系统编程领域公认的参考书,但它最容易被忽略的价值恰恰是每章末尾的课后习题。这些习题不是填空式问答,而是需要你重新实现、修改或论证系统调用行为的小项目;只有把 read/write/fork/signal 在真实进程里跑起来,才能把“见过系统调用”变成“会用系统调用”。在这套 TLPI 课后习题代码的实践路线上,我会先说明怎么搭建与书配套的库环境,再按章节把习题归类,给出可直接照抄的代码模式,最后用 strace、边界输入等方式验证结果。读者只要有 Linux 基础和一个能编译 C 的发行版,就能照步骤把练习完整做下去;有多年系统编程经验的工程师也能从验证手段里找到新启发。
2. 搭建课后习题代码环境:TLPI lib 目录、编译参数和 Makefile 模板
开始动手前,先把练习环境固定下来。常见做法是在自己的练习工程里建一个 lib 目录,从 TLPI 配套资料中复制出错误处理与数字解析相关文件;之后每一道习题的 .c 文件都放在这个工程下,靠相对路径 include 和链接。这样做能避免在几十个练习文件里反复粘贴同一段 errExit 实现,也能保证习题代码的写法与书中的演示程序保持一致。
2.1 从 TLPI 配套资料复制 lib 目录,先理解 tlpi_hdr.h 的依赖关系
TLPI 源码包解压后,lib/ 目录里有几个反复使用的文件:tlpi_hdr.h 是总头文件,它集合了系统头文件并声明了错误处理函数;error_functions.c 提供 errExit、fatal、usageErr 等实现,这些函数会把错误信息连同 errno 一起打印到标准错误;get_num.c 则提供 getInt、getLong 这类命令行参数解析辅助,习题里经常要读入数字参数。
tlpi_hdr.h 的核心内容大致如下,可以先读一遍再往下编译:
#ifndef TLPI_HDR_H #define TLPI_HDR_H #include <sys/types.h> #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <errno.h> #include <string.h> #include "get_num.h" #include "error_functions.h" typedef enum { FALSE, TRUE } Boolean; #define min(m, n) ((m) < (n) ? (m) : (n)) #define max(m, n) ((m) > (n) ? (m) : (n)) #endif这段头文件的作用是把 stdio、stdlib、unistd、errno、string 等常用头文件一次性引入,同时把 Boolean、min、max 和错误处理函数原型带进来。练习代码只要#include "tlpi_hdr.h",就不必在每个文件里重复写一长串#include。需要留意的是,tlpi_hdr.h 依赖同目录下的 get_num.h 和 error_functions.h,复制时不能只拿一个头文件,而要把整个 lib 目录都保留下来,否则编译器会报找不到头文件。
2.2 习题代码的编译选项与 Makefile 布局
直接gcc mycp.c通常会报链接错误,因为 errExit 等函数定义在 error_functions.c 里,编译单文件时这些符号没有参与链接。我一般把工程固定成下面这种布局,每个练习目录里放一个 Makefile 和一个或多个 .c 文件,lib 目录放在所有练习目录的共同上级。
CC := gcc CFLAGS := -Wall -Werror -g -std=c99 -I../lib LDLIBS := LIBDIR := ../lib LIBSRCS := $(LIBDIR)/error_functions.c $(LIBDIR)/get_num.c TARGETS := mycp tee signal_pause mutex_counter all: $(TARGETS) # 大多数习题是单个 .c 文件对应一个可执行文件 mycp: mycp.c $(LIBSRCS) $(CC) $(CFLAGS) -o $@ $^ clean: rm -f $(TARGETS)这里的-I../lib让预处理器在上级目录的 lib 中查找 tlpi_hdr.h;$(LIBSRCS)把 error_functions.c 和 get_num.c 一起编译链接,避免 undefined reference。-g保留调试符号,-Wall打开大多数警告,-Werror把警告当作错误。做练习时如果被某些历史代码的警告卡住,可以先去掉-Werror,看清警告内容再决定是否修改,而不是盲目关闭警告。
部分练习题还依赖特性宏。下表是不同练习类型常用的编译配置:
| 练习题类型 | 常见特性宏 | 额外链接参数 | 典型验证工具 |
|---|---|---|---|
| 文件 I/O(lseek、目录遍历) | -D_FILE_OFFSET_BITS=64 | 无 | stat、strace |
| 进程创建与结束 | -D_XOPEN_SOURCE=700 | 无 | ps、wait |
| 信号处理 | 无特殊要求 | 无 | kill、gdb |
| POSIX 线程 | -D_POSIX_C_SOURCE=200809L | -pthread | strace、gdb |
| System V IPC | -D_XOPEN_SOURCE=700 | 老系统可能需要-lrt | ipcs、ipcrm |
_FILE_OFFSET_BITS=64在 32 位平台上影响 off_t 的宽度,现代 64 位发行版上一般不需要,但加上不会出错。线程练习必须使用-pthread,它除了链接 pthread 库,还会定义_REENTRANT等宏,影响部分 glibc 头文件的声明方式。
2.3 新版 glibc 和内核带来的练习题编译问题
TLPI 部分内容基于较老的内核和 glibc 写成,直接照抄到新版 Ubuntu、Fedora 上会遇到两类差异。第一类是系统调用号,比如SYS_gettid在 x86_64 和 ARM64 上编号不同,习题代码不应手写调用号,而是调用syscall(SYS_gettid)这类封装;第二类是某些函数在新 glibc 里已是线程安全版本,老书里为了说明“非线程安全”而设计的练习,运行结果可能不再复现,这时要主动用-D_XOPEN_SOURCE=700开启显式声明,并去 man 手册确认当前 glibc 的行为。
3. 按章节拆解课后习题:文件 I/O、进程信号、线程同步的代码思路
TLPI 的练习题没有官方标准答案,因此不同人的习题代码风格差异很大。直接在网上搜TLPI exercise能找到不少个人仓库,但有价值的不是拿来即用的答案,而是题目里要求的“对比验证”和“边界处理”。下面把练习题按最常见的三类分组,分别说清每一类需要掌握的系统调用和代码套路。
3.1 文件 I/O 练习题:验证原子性与文件空洞
文件 I/O 类习题集中在第 4、5 章,常见操作是模拟 cp、tee、ls,或者观察 O_APPEND 与 lseek 的关系。写这类习题代码时,最容易出问题的地方是磁盘文件写入只循环一次,没有处理部分写入;read 调用也没有处理返回值小于缓冲区大小的情况。
int fd1 = open("a.log", O_CREAT | O_WRONLY | O_APPEND, 0644); int fd2 = open("b.log", O_CREAT | O_WRONLY, 0644); lseek(fd2, 0, SEEK_END); write(fd1, "hello", 5); write(fd2, "hello", 5);这段对比代码本身不会编译失败,但结果分析才是关键。O_APPEND 确保每次 write 前内核都会把文件偏移量移到末尾,即使多个进程同时写,也不会互相覆盖;而 lseek+write 在单进程单次写入时看不出区别,一旦两个进程往复执行 lseek 和 write,就可能出现一个进程覆盖另一个进程数据的情况。练习题要你写代码验证这个“竞争窗口”,办法是让两个进程分别打开同一个普通文件,一个用 O_APPEND,另一个先 lseek 再 write,循环多次后比较文件内容。
文件空洞则是 lseek 越过文件末尾再写的直接后果。阻塞文件的空洞部分读出来是零字节,但不会占用磁盘块。一个典型的练习代码是用 lseek 把偏移量移到第 100 个字节后写一个字符,再用ls -l观察文件大小和du -h观察实际占用块数,两者差异就是空洞的证据。
3.2 进程与信号练习题:在竞争条件中理解 EINTR 与可重入
进程类和信号类习题通常要求你写出能稳定复现某种行为的程序。比如验证 fork 之后父子进程的缓冲区行为,或者验证信号处理器执行期间发生阻塞系统调用时的 EINTR 错误码。这类代码的常见目标是让“错误分支”真正执行一次,而不仅仅是阅读手册。
struct sigaction sa; sigemptyset(&sa.sa_mask); sa.sa_handler = handler; sa.sa_flags = 0; // 不设置 SA_RESTART sigaction(SIGINT, &sa, NULL); ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); if (n == -1 && errno == EINTR) printf("read interrupted by signal\n");sa_flags = 0意味着系统调用被信号中断后不会自动重启;read 如果阻塞在管道或终端读入上,收到 SIGINT 就会返回 -1,errno 置为 EINTR。如果给 sa_flags 加上 SA_RESTART,内核会自动重启 read,调用者根本看不到 EINTR。练习里经常要求分别编译两种版本,体会“要不要自动重启”对程序设计的影响。
验证这类代码时,必须让 read 阻塞在管道或终端上,因为普通文件的 read 通常不属于慢系统调用,即使有信号到达也会直接返回已读数据。常见的做法是先用pipe建一个管道,写端不写入数据,让读端进程阻塞在 read 上,再从另一个进程 kill 过去。
3.3 线程同步练习题:先复现竞争,再加互斥量
线程章节的典型习题是计数器累加、生产者消费者、线程安全函数改造。最容易上手的起步题是让多个线程各自重复增加同一个全局变量,先不加锁观察结果,再加互斥量对比。下表是这类习题代码经常出现的结果演变:
| 阶段 | 代码状态 | 典型结果 |
|---|---|---|
| 未加锁 | 多个线程同时counter++ | 结果小于期望值且每次不同 |
| 加互斥量 | 每次自增前后 pthread_mutex_lock/unlock | 结果固定为期望值 |
| 改用原子操作 | 用__atomic_add_fetch | 结果正确且性能比互斥量高 |
线程题的坑通常在编译方面,而不是运行逻辑。忘记加-pthread会直接链接失败;没有加-D_POSIX_C_SOURCE时,部分与线程相关的声明可能被隐藏。另外,pthread 互斥量的初始化方式也有讲究:静态分配的互斥量可以用PTHREAD_MUTEX_INITIALIZER初始化,动态分配的则必须调用 pthread_mutex_init。
4. 三个易移植的练习题实现:tee、SA_RESTART 与互斥计数器
选择三个有代表性的练习题,给出可直接编译运行的完整代码,并说明参数改动会如何影响行为。这些代码都按 TLPI 常见风格编写,直接放进上一章描述的 Makefile 工程即可。
4.1 实现 tee 命令并加入 -a 参数:完整代码
TLPI 第 4 章有一道练习是让读者实现 tee 命令:从标准输入读取数据,同时写到标准输出和指定文件。下面的实现增加 -a 参数控制是否追加写入,代码里也加入了 write 失败时的错误处理。
#define _XOPEN_SOURCE 700 #include "tlpi_hdr.h" #include <fcntl.h> #include <getopt.h> #ifndef BUF_SIZE #define BUF_SIZE 1024 #endif int main(int argc, char *argv[]) { int opt; int saveFd; int append = 0; ssize_t numRead; char buf[BUF_SIZE]; while ((opt = getopt(argc, argv, "a")) != -1) { switch (opt) { case 'a': append = 1; break; default: usageErr("usage: %s [-a] file\n", argv[0]); } } if (optind >= argc) usageErr("usage: %s [-a] file\n", argv[0]); int flags = O_CREAT | O_WRONLY; if (append) flags |= O_APPEND; saveFd = open(argv[optind], flags, 0644); if (saveFd == -1) errExit("open"); while ((numRead = read(STDIN_FILENO, buf, BUF_SIZE)) > 0) { if (write(STDOUT_FILENO, buf, numRead) != numRead) fatal("write to stdout"); if (write(saveFd, buf, numRead) != numRead) fatal("write to file"); } if (numRead == -1) errExit("read"); close(saveFd); exit(EXIT_SUCCESS); }getopt在这里解析命令行选项,optind指向第一个非选项参数,也就是保存文件的名字。缓冲区大小用#ifndef包起来,是为了方便在编译时通过-DBUF_SIZE=4096覆盖。open 的 flags 在普通模式下是覆盖写,加上 -a 后追加写;0644 是新建文件时的默认权限,受 umask 影响。read 返回 0 代表到达标准输入末尾,循环正常结束;返回 -1 则用 errExit 打印具体错误。
命令行参数与 open 行为的关系整理如下:
| 命令行 | open flags 组合 | 文件已存在时的行为 |
|---|---|---|
./tee out.txt | O_CREAT | O_WRONLY | 覆盖原有内容 |
./tee -a out.txt | O_CREAT | O_WRONLY | O_APPEND | 每次写入前偏移移到末尾 |
./tee -a -a out.txt | 同一选项重复出现,无副作用 | 与单次 -a 一致 |
这里要强调一下 O_APPEND 的实现保障:它保证每次 write 前内核都会重新取当前文件偏移并移动到末尾,即使多个 tee 进程同时写同一个文件,也不会互相覆盖。这个机制是文件 I/O 章节的重点,也是面试常问的点。
4.2 信号练习中的 sigaction 配置:复现并处理 EINTR
信号练习题常用 sigaction 替代早期的 signal 接口,原因是 sigaction 的行为在不同 UNIX 变体上更一致。下面代码先以sa_flags = 0安装处理器,然后再用 SA_RESTART 重新安装一次,通过读管道来观察 read 的返回行为。
#define _XOPEN_SOURCE 700 #include "tlpi_hdr.h" #include <signal.h> static volatile sig_atomic_t gotSig = 0; static void handler(int sig) { gotSig = 1; } static void test_read_with_flags(int useRestart) { int fds[2]; char buf[16]; struct sigaction sa; sigemptyset(&sa.sa_mask); sa.sa_handler = handler; sa.sa_flags = useRestart ? SA_RESTART : 0; if (sigaction(SIGUSR1, &sa, NULL) == -1) errExit("sigaction"); if (pipe(fds) == -1) errExit("pipe"); kill(getpid(), SIGUSR1); /* 在 read 之前发送信号 */ ssize_t n = read(fds[0], buf, sizeof(buf)); if (n == -1) printf("read returned EINTR, useRestart=%d\n", errno == EINTR); else printf("read completed, got %zd bytes\n", n); }这里特意在 read 之前发送 SIGUSR1,保证信号到达时进程还阻塞在管道读上。若没有 SA_RESTART,read 返回 -1 且 errno 为 EINTR;设置 SA_RESTART 后,内核会在信号处理完成后重新发起 read,此时管道没有数据,read 继续阻塞,程序无法按顺序继续打印。实际运行时要配合另一个线程或进程向管道写入数据来观察“恢复后读到数据”的完整路径。
隐藏的问题是 sig_atomic_t 类型的用法。信号处理器中只给volatile sig_atomic_t变量赋值是安全的,如果改成volatile int counter并在处理器里自增,在多线程环境下可能读到不一致的值。这是从信号到线程同步之间的过渡知识点。
4.3 用互斥量修复多线程计数器:线程执行顺序观察
计数据题是所有线程章节的起点。最初版本不设互斥量,直接让 4 个线程各自执行一百万个counter++,再用 pthread_join 等待全部完成后打印结果。由于counter++包含读、加、写三步,并发执行时多个线程可能同时读到同一个旧值,最后写回的结果会覆盖其他线程的进度。
#define _GNU_SOURCE #include "tlpi_hdr.h" #include <pthread.h> #define THREAD_NUM 4 #define LOOP_NUM 1000000 static long counter = 0; static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; static void * threadFunc(void *arg) { for (int i = 0; i < LOOP_NUM; i++) { pthread_mutex_lock(&mtx); counter++; pthread_mutex_unlock(&mtx); } return NULL; } int main(int argc, char *argv[]) { pthread_t t[THREAD_NUM]; int s; for (int i = 0; i < THREAD_NUM; i++) { s = pthread_create(&t[i], NULL, threadFunc, NULL); if (s != 0) errExitEN(s, "pthread_create"); } for (int i = 0; i < THREAD_NUM; i++) { s = pthread_join(t[i], NULL); if (s != 0) errExitEN(s, "pthread_join"); } printf("counter = %ld\n", counter); exit(EXIT_SUCCESS); }注意 errExitEN 是 TLPI 库中为 pthread 系列函数准备的错误输出函数,它把线程函数返回的错误码转换成字符串。pthread_mutex_lock和unlock包裹的临界区只有一行,却足以保证 counter 的更新不被打断。锁粒度越大,结果越可靠,但性能越低;这里把循环体整个锁住,运行时间会比无锁版本慢几个数量级,属于正常的实验现象。
| 实现方式 | 期望结果 | 运行表现 |
|---|---|---|
| 不加锁 | 任意小于 4000000 的数 | 每次运行结果都不太一样 |
| 加互斥量 | 总是 4000000 | 结果固定但耗时显著增加 |
原子自增__atomic_add_fetch(&counter, 1, __ATOMIC_RELAXED) | 总是 4000000 | 结果正确且耗时接近无锁 |
从无锁到互斥量的变化,能清楚看到“临界区保护”的本质:不是让代码执行变慢,而是让多个线程对共享状态的修改形成一种可预期的顺序。后续生产者消费者、读写锁、条件变量都是在这个基础上扩展出来的。
5. 验证课后习题代码的三个技巧:strace 跟踪、边界注入与回归脚本
练习代码写出来能编译运行,只完成了一半。另一半是确认它确实调用了预期的系统调用,并且错误分支被真正执行。下面三个技巧是我做这套题目时最常用的验证手段,都能在命令行直接完成。
5.1 用 strace 观察系统调用参数与返回值
strace 能打印进程发起的每一次系统调用及返回值,是验证习题代码最直接的窗口。跟踪 tee 风格程序时,用-e trace=read,write过滤出关心的调用,加上-o把输出保存到文件,避免与程序自身的输出混杂。
strace -f -e trace=open,read,write -o /tmp/trace.log ./tee -a out.txt < input.txt grep -E 'open|read|write' /tmp/trace.log | head -20输出里能看到write(1, "data...", 1024) = 1024这样的行,表示向标准输出写入了 1024 字节,返回值等于第三项参数;如果返回值小于请求字节数,说明发生了部分写入,需要检查代码里是否写够了多次循环。-f选项让 strace 跟随 fork 出来的子进程,在多进程练习里必须加。
5.2 用受限文件和 ulimit 做边界注入
把目标文件指向 /dev/full,可以人为制造写入失败。/dev/full 是 Linux 提供的虚拟设备,任何 write 都会返回 ENOSPC,用来模拟磁盘满的情况。运行./tee /dev/full并输入几行内容,正常实现的代码会调用 fatal 打印错误后退出,而忽略 write 返回值的代码会安静地失败,这正是练习代码要修的隐藏问题。
ulimit -n 4 ./mycp /etc/hosts /tmp/hosts_copyulimit -n 4把文件描述符上限压到 4,此时标准输入、输出、错误已经占用 0、1、2,open 返回的可用描述符只有 3。如果再打开一个文件,下一次 open 就会失败。用这种方式可以验证代码是否妥善处理了open返回 -1 的问题。
5.3 写一个极简回归脚本固化验证结果
练习代码会反复修改,每改一次都手动敲命令会漏掉细节。我习惯在工程根目录放一个 shell 脚本,把核心验证步骤串起来,改成强迫自己提交前运行一遍。
#!/bin/bash set -euo pipefail # 基础功能:拷贝文件并比较内容 ./mycp /etc/hosts /tmp/hosts_copy cmp /etc/hosts /tmp/hosts_copy # 错误路径:目标目录不存在,open 必须失败 if ./mycp /etc/hosts /no_such_dir/out; then echo "expected open failure" exit 1 fi # 跟踪写入调用,确认 write 被执行 strace -e trace=write -o /tmp/trace.log ./mycp /etc/hosts /tmp/hosts_copy grep -q 'write(' /tmp/trace.log echo "checks passed"set -euo pipefail让脚本在第一条失败命令处退出,set -u能暴露未初始化变量。写入不存在的目录会让 open 返回 -1,如果程序调用 errExit,进程退出码非零,脚本就能捕获到这个预期失败。strace 那一步断言了 write 系统调用确实出现,从侧面证明数据不是只在用户态缓冲区转了一圈。这套脚本配合 make 使用,就能把 TLPI 课后习题代码的验证频率提高到每一次修改。
本文还有配套的精品资源,点击获取