拓冰建站拓冰建站
首页 / 资讯中心 / 正文

KR第8章深度解析:C语言与UNIX系统接口核心

1. 为什么第8章是整本书最“硬核”的一章读《C程序设计语言第二版》读到最后很多人会在第8章“The UNIX System Interface”这里停下来。前面七章讲的都是语言本身类型、运算符、控制流、指针、结构体、预处理大部分内容离开操作系统也能理解但第8章一上来就切换到另一个视角——C语言和UNIX之间的共生关系。C语言解决的是如何写程序第8章解决的是程序如何跟操作系统打交道这一章读懂了你对“C语言为什么是C语言”会有完全不一样的理解。这本书的作者是肯·汤普森和丹尼斯·里奇UNIX和C语言的发明人所以第8章不是泛泛讲一个“操作系统API”而是在告诉你这些接口最初被设计出来的动机。当年在贝尔实验室他们是为了写UNIX本身才设计了这套系统调用接口后来C语言随着UNIX一起流行这套接口就成了所有类UNIX系统的共同底座。今天我们写的代码里最常见的read、write、open、close、fork、exec最早就是在这本书的第8章里被系统性地介绍出来的。这一章适合什么人来读我觉得有两类人收获最大。第一类是已经学完C语言基础、想搞清楚printf和scanf背后到底发生了什么的人第二类是准备或者正在做嵌入式开发、服务端开发、系统软件开发的人因为文件描述符、进程控制、内存管理这些东西不管你是写Linux服务端程序还是玩STM32早晚要碰。如果你是纯做上层业务开发这一章的内容可能用不太到但理解了它你在排查“为什么内存越界会导致程序莫名其妙崩溃”这类问题时会多一个底层视角。我当年第一次读这一章的时候其实有点痛苦因为它跟前面七章的风格不一样代码突然变成了一段一段的系统调用参数里还带文件描述符、权限模式位、错误返回值。但啃完之后我发现正是这一章把前面的指针、结构体、宏定义全部串了起来。可以说第8章是整本书的点睛之笔。1.1 这一章的阅读门槛和内容地图在开始之前先说清楚阅读门槛。第8章默认你已经掌握了指针和结构体另外需要有一点“程序如何跟内核打交道”的基本概念。如果前面第七章的结构体、联合体没读扎实建议先回去补一补因为第8章里大量出现struct、union以及指针类型转换比如后面讲存储分配器时的Header联合体如果不懂联合体和对齐alignment是怎么一回事读起来会非常吃力。整个章节的组织逻辑是从低到高递进的小节主题核心内容对读者来说意味着什么文件描述符与低层I/Oread/write/open/creat/close理解所有I/O操作的最底层入口系统调用与库函数getchar/fopen自己实现看清标准库只是为了好用而包的一层壳随机访问lseek和fseek理解“文件位置”这个概念目录与文件系统opendir/readdir/stat从文件内容上升到文件系统的元数据存储分配器malloc/free的内部实现理解堆内存到底是怎么回事进程控制fork/exec/wait程序如何创造出另一个程序信号处理signal基础程序之间和内核之间如何通信这样梳理下来你会发现第8章其实覆盖了一个操作系统接口的完整图谱文件、内存、进程、信号一个都不少。这也解释了为什么很多高校的操作系统课程会把这章列为必读参考资料。1.2 系统调用与库函数先把这个边界搞清楚读第8章之前脑子里一定要先立一个概念系统调用和库函数是两回事。系统调用是操作系统内核提供给用户程序的入口比如read、write、open申请内存的sbrk创建进程的fork它们直接触达内核态而库函数是标准库里封装好的函数比如printf、fread、fopen它们不一定直接和内核交互甚至可能根本不涉及内核。书里有一个非常经典的点标准I/O库中的绝大部分函数最终都会“降级”成某个系统调用。printf最终会调用writefopen最终要调用openmalloc的底层要调用sbrk。库函数存在的意义是提供缓冲、格式化、错误处理这些“方便层”但最底层的那层皮无论如何都绕不开系统调用。我经常跟朋友打一个比方系统调用是水电煤气公司接进小区的那根总管道而库函数是你家里装的各种净水器、热水器用了它们确实方便但水的源头还是那根总管道。明白了这个层级关系后面看书里的代码就不会晕——当书中直接用read替换getchar用open替换fopen时它其实是在卸掉库函数的包装让你看底层真正的机制。2. 文件描述符视角下的低层I/O第8章开篇就甩出了一组新的概念文件描述符。在标准库层面我们操作的是一个FILE *指针在系统调用层面文件是用一个整数来标识的这个整数就叫文件描述符file descriptor。UNIX系统约定俗成0是标准输入1是标准输出2是标准错误你打开的每一个新文件内核就会分配下一个可用的最小整数从3开始。这里有一个细节很多人容易忽略文件描述符不是“文件句柄”或“文件指针”这种抽象概念它是一个整型数字但这个数字背后在内核里对应着一张打开文件表的一个表项。也就是说文件描述符本质上是一个数组下标我把这个概念说透之后再理解dup、dup2这些函数就顺理成章了——它们不过是在复制表项引用而已。2.1 read/write/open/creat/close最原始的搬运工第8章的代码一上来就是那四个函数签名我直接抄在这里因为它们实在太重要了值得背下来int read(int fd, char *buf, int n); int write(int fd, char *buf, int n); int open(char *name, int flags, int perms); int creat(char *name, int perms); int close(int fd); int unlink(char *name);read和write的返回值是实际读写的字节数。read返回0表示读到文件结尾返回-1表示出错返回正数表示实际读到的字节数。write同理返回-1出错返回实际写入的字节数。这里有个初学者经常忽略的问题write并不保证一次把n个字节全写进去。在普通文件上这种情况很少发生但在管道、网络等场景下write返回的字节数很可能小于你请求的字节数所以代码里必须用循环来保证把剩余部分写完。书里那个cp程序的例子就处理了这一点但是只处理了一次严格来说应该在外部再加一层循环。open的第二个参数是访问模式常用的是O_RDONLY、O_WRONLY、O_RDWR三个宏定义在fcntl.h里。第三个参数是创建文件时的权限位只有当flags里带了O_CREAT或者用creat这个旧接口时才会生效而且这个权限位还会受进程的umask影响。我用过很多次creat它在老系统里是真实存在的独立系统调用后来被open加O_CREAT替代了这个信息在第8章末尾有提到但很多人读书不留神就划过去了。close几乎不会失败除了被打断这种极端情况。unlink是删除文件名的系统调用注意是删除文件名而不是“删除文件内容”一个文件如果被多个进程同时打开unlink之后其他进程仍然可以继续读写这个文件直到所有文件描述符关闭文件内容才会真正被释放。这个“引用计数”的思想贯穿了整个UNIX文件系统我在遇到“磁盘空间没释放”的问题时第一个排查方向就是有没有进程占着已删除的文件。2.2 动手实现一个cp第8章第一个完整代码第8章第一节末尾给了一个用低层I/O实现文件复制的完整程序也就是最经典的cp我结合书里代码整理了一段可验证的版本#include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #define PERMS 0666 #define BUFSIZE 4096 void error(char *fmt, ...) { // 简化处理直接退出 perror(cp); exit(1); } int main(int argc, char *argv[]) { int fd1, fd2, n; char buf[BUFSIZE]; if (argc ! 3) error(usage: cp from to); if ((fd1 open(argv[1], O_RDONLY, 0)) -1) error(cp: cant open %s, argv[1]); if ((fd2 creat(argv[2], PERMS)) -1) error(cp: cant create %s, mode %03o, argv[2], PERMS); while ((n read(fd1, buf, BUFSIZE)) 0) if (write(fd2, buf, n) ! n) error(cp: write error on file %s, argv[2]); close(fd1); close(fd2); return 0; }这段代码里有两个地方值得品。第一read循环只处理了write返回不是n的情况但如果写在磁盘满或者管道对端关闭时write会返回-1等于n是-1条件成立走到error里退出所以这个实现覆盖了“写失败”的路径。第二read返回0是正常退出循环的条件如果read返回-1循环条件为假也不会继续执行但程序不会报错这其实是个问题。更稳妥的写法是把read返回值单独存下来再判断这样能区分“正常读完”和“读出错”两种情况。我在实际工程里会用下面这种循环写法while ((n read(fd1, buf, BUFSIZE)) 0) { int off 0; while (off n) { int w write(fd2, buf off, n - off); if (w 0) { perror(write); exit(1); } off w; } } if (n 0) { perror(read); exit(1); }这里把write的短写问题也一并处理了虽然不是书中原样但这是我在实际项目中验证过的健壮写法。读书笔记最大的价值就是把书里“为了讲原理而简化”的代码补充成“可以直接拿到生产环境用”的版本。2.3 getchar的两种实现从低效到高效这一节书里做了一个很巧妙的演示——用read来重新实现getchar。第一版是“一次读一个字节”的朴素实现#include unistd.h int getchar(void) { char c; return (read(0, c, 1) 1) ? (unsigned char) c : EOF; }这个实现逻辑上完全正确但性能上很糟糕。每调用一次getchar就发生一次read系统调用也就是一次用户态和内核态的切换这可比从内存里拿一个字节贵得多。曾经有人统计过一次系统调用的开销大概在微秒量级如果程序频繁调用getchar来读一个大文件光是系统调用的时间就能让程序慢上百倍。于是书里紧接着给出了带缓冲区的改进版。这个改进版的思路是一次read读一大块数据到内存缓冲区然后getchar每次从缓冲区里取一个字节只有缓冲区全部读完时才再次调用read。这是“用户态缓冲”的核心思想也是标准库fopen/getc的高效来源。#include unistd.h #define BUFSIZE 4096 int getchar(void) { static char buf[BUFSIZE]; static char *bufp buf; static int n 0; if (n 0) { n read(0, buf, sizeof buf); bufp buf; } return (--n 0) ? (unsigned char) *bufp : EOF; }这个代码让我印象很深刻因为它把C语言的static局部变量用到了极致buf、bufp、n三个变量在整个程序生命周期内只初始化一次所有getchar调用共享同一块缓冲区和同一个指针位置。这里用到了前缀递减表达式先减再判断判断完了n还剩下实际未读的字节数。每次读到n变成-1时进入if分支重新填充缓冲区。注意read返回0或-1时n都会被赋成0或-1然后返回EOF逻辑是对的但实际工程里还得分清EOF和错误这里书里做了简化。标准I/O库的FILE结构体本质上就是干这个事的里面有一个文件描述符、一个缓冲区、一个位置指针、一组状态标志。第8章后面还会用一个简化的FILE结构体重现fopen和fgetc那一段如果你能独立读懂说明你已经完全理解用户态缓冲的原理了。3. 随机访问、文件系统与目录遍历第三部分我来聊聊lseek和目录操作。低层I/O那节讲的是顺序读写每次read/write都会移动文件的位置指针读完100字节位置就往后挪100字节。但实际应用中我们需要随机访问文件中的任意位置比如数据库文件、索引文件、图片解析都要先跳到某个偏移量再读。lseek就是干这个的。long lseek(int fd, long offset, int origin);第三个参数origin指定基准位置0表示从文件开头算1表示从当前位置算2表示从文件末尾算。lseek成功后返回新的位置偏移量从文件头算起的字节数失败返回-1。读这一节的时候我脑子里突然明白了好几个之前一直一知半解的东西fseek其实就是在FILE结构体里面处理一下缓冲区的情况然后调用lseekftell返回的是当前位置本质上就是lseek的返回值。标准库只是在系统调用外面套了一层缓冲管理而已核心动作还是lseek。3.1 lseek与fseek的关系以及一个坑书里在8.4节给出了fseek的一种实现思路大致是先把缓冲区里的未读数据丢弃把缓冲区的“当前写入位置”和“当前读取位置”都清空然后调用lseek。因为lseek操作的是内核里的文件偏移量而fseek操作的是用户态缓冲区的逻辑位置所以如果缓冲区里还有没写完的数据fseek之前必须先flush否则这些数据就丢了。这里有一个实际中很容易踩的坑当你同时使用标准库函数fread/fwrite/fseek和系统调用read/write/lseek操作同一个文件描述符时由于标准库有缓冲区两边的位置可能会对不上。比如你用fread读了100字节这时内核里的文件偏移量可能还在0因为数据还在用户态缓冲区里没读满。如果你直接在这时候调用lseek得到的偏移跟你预期的完全不一致。解决办法是要么全程只用标准库的fseek/ftell要么只用系统调用的lseek混用时务必先fflush或完全放弃缓冲。这个问题我在实际项目里踩过一次最后排查了很久才发现是缓冲区和系统调用混用导致的从那以后我对“同一文件不要混用两种I/O”这句话就记忆特别深刻。3.2 用opendir/readdir/stat遍历目录树第8章另一个让我眼前一亮的例子是对目录的读取。传统的目录操作其实也是个“文件”读取过程目录在UNIX里只是一个特殊文件内容是一系列目录项。第8章用最原始的方式演示了读取目录的过程——直接打开目录文件读出里面的目录项结构然后从每个目录项的inode号取stat信息。不过在实际项目里我们一般直接使用POSIX标准封装好的opendir/readdir/closedir它们内部做的事情就是第8章手工演示的那套流程。书中给的fsize程序用递归方式遍历目录输出每个文件的字节数和文件名。核心代码如下#include stdio.h #include string.h #include unistd.h #include fcntl.h #include sys/types.h #include sys/stat.h #include dirent.h void dirwalk(char *dir, void (*fcn)(char *)) { char name[PATH_MAX]; struct dirent *dp; DIR *dfd; if ((dfd opendir(dir)) NULL) { fprintf(stderr, dirwalk: cant open %s\n, dir); return; } while ((dp readdir(dfd)) ! NULL) { if (strcmp(dp-d_name, .) 0 || strcmp(dp-d_name, ..) 0) continue; if (strlen(dir) strlen(dp-d_name) 2 sizeof(name)) fprintf(stderr, dirwalk: name too long\n); else { sprintf(name, %s/%s, dir, dp-d_name); (*fcn)(name); } } closedir(dfd); } void fsize(char *name) { struct stat stbuf; if (stat(name, stbuf) -1) { fprintf(stderr, fsize: cant access %s\n, name); return; } if ((stbuf.st_mode S_IFMT) S_IFDIR) dirwalk(name, fsize); printf(%8ld %s\n, stbuf.st_size, name); }这个程序虽然简单但有一个函数指针的用法很值得品味dirwalk接收一个函数指针fcn这样同一个目录遍历函数可以搭配不同的处理逻辑想遍历目录树统计大小就用fsize想遍历目录树删除文件就换一个删除函数。这就是“策略模式”在C语言里的朴素表达没有面向对象那种多余的包装一个函数指针就搞定了。stat这个结构体里除了st_size文件大小还有st_mode文件类型和权限、st_mtime修改时间、st_uid属主等很多命令行工具的核心逻辑ls -l、du、find本质上就是围绕这些字段写的。读这一节的时候我建议你动手跑一下fsize再看看ls -lR的输出你会发现自己的理解维度会高一层——“哦原来ls的输出就是读stat结构体里的字段格式化出来的”。4. 自己实现一个malloc和free第8章里分量最重的一节我认为是8.7节的存储分配器。前面讲文件操作好歹有“打开一个文件”这种具象操作到了这一节纯内存纯数据结构纯指针游戏。Kernighan在这节带着读者从头实现了一个malloc和free其精妙程度直到今天我仍然觉得是学习C语言的巅峰体验之一。malloc要解决的问题是用户请求任意大小的内存块分配器要快速找到一块足够大的空闲内存切一块给用户剩下的仍然保持在空闲链表里。free要把用户归还的内存块重新接回空闲链表并且如果相邻块是空闲的要合并成大块防止内存碎片化。4.1 核心数据结构Header联合体与空闲链表分配器的核心是下面这个结构我第一次看到的时候觉得这个设计简直绝了——用联合体来保证内存对齐typedef long Align; // 用于对齐到长整型边界 union header { struct { union header *ptr; // 空闲链表中的下一个块 unsigned size; // 当前块的大小以Header为单位 } s; Align x; // 强制联合体按long对齐 }; typedef union header Header;这里的关键是每个空闲内存块的开头都放着一个HeaderHeader里面有指针和sizeHeader后面跟着真正可用的内存区域。当这个块是空闲的时候Header的s.ptr指向下一个空闲块当这个块被分配给用户的时候Header里的s.ptr字段其实没用了可以被覆盖但Header本身仍然占据块头的几个字节。因为用了Align这个长整型成员整个联合体的大小会被编译器对齐到long的边界所以每个Header本身就是对齐过的后面跟着的内存区域也是对齐的。把分配出去的内存块首部也保留Header这一点设计得非常聪明。因为有了Headerfree的时候才能知道这块内存有多大才能把块归还给链表。同时用户拿到的指针是Header之后的位置也就是(p1)而不是Header本身。这样设计的好处是用户那块内存区域可以完整地还给用户不需要在Header之外再单独记录“这是哪块内存对应的Header”。4.2 malloc和free的配对逻辑malloc的核心是遍历空闲链表寻找第一个大小大于等于请求量的块。找到之后有两种情况如果这个块刚好等于所需大小就直接把它从链表里摘下返回给用户如果块比请求大就把它拆成两块——剩余部分作为新的空闲块留在链表里后半部分或者前半部分取决于实现分配给用户。书中代码用的是从大块尾部切割的方式if (p-s.size nunits) { if (p-s.size nunits) { prevp-s.ptr p-s.ptr; } else { p-s.size - nunits; p p-s.size; p-s.size nunits; } freep prevp; return (void *)(p 1); }如果用p的头部作为用户内存那切割之后剩下的部分怎么表示书里采用的方式是把p的size减小然后让p向后移动nunits个单位新的p的size设为nunits。这样用户得到的是“原空闲块的尾部”链表中剩下的“原空闲块的头部”在上游等待下次分配。这个方案的好处是实现简单坏处是它会造成空闲块在地址空间里越来越靠后下次合并的时候需要多做一些处理。我见过的其他实现也有直接从头部分配的比如先让p的size减去nunits然后把返回指针设为p p-s.size效果差不多但代码顺序略有差异。free的逻辑要处理更麻烦的合并问题。用户释放内存时分配器拿到用户指针先回退一个Header拿到块信息然后把这个块插入空闲链表。插入时要检查它跟链表中前一个块和后一个块是否在内存地址上连续如果连续就合并成一个更大的块。书中free用了一个很精巧的循环来定位插入位置并检查后向合并void free(void *ap) { Header *bp, *p; bp (Header *)ap - 1; // 从用户指针回退到块头 for (p freep; !(bp p bp p-s.ptr); p p-s.ptr) if (p p-s.ptr (bp p || bp p-s.ptr)) break; // 释放的块在链表头之后或之前 if (bp bp-s.size p-s.ptr) { bp-s.size p-s.ptr-s.size; bp-s.ptr p-s.ptr-s.ptr; } else { bp-s.ptr p-s.ptr; } if (p p-s.size bp) { p-s.size bp-s.size; p-s.ptr bp-s.ptr; } else { p-s.ptr bp; } freep p; }这里面的链表遍历条件比较绕核心思想是空闲链表是一个循环链表最后一个节点的ptr指回链表头分配器要找到释放块在链表中的正确位置使得链表始终保持地址升序。因为空闲链表按地址升序排列合并后向检查p p-s.size bp就很容易判断“后一个空闲块的起始地址是否正好等于当前块加上当前块大小”如果相等说明它们是相邻的可以合并。一定要画图理解这段代码光看文字很容易懵。我当时在纸上画了几遍链表调整的图才算真正搞明白free里那两个if-else在干什么。4.3 morecore与系统调用sbrk当空闲链表中找不到足够大的块时malloc需要向操作系统要更多的堆内存。书里用的是sbrk系统调用。sbrk的作用是调整程序的“中断点”break也就是堆的边界。调用sbrk(0)只查询当前中断点调用sbrk(n)把堆向上增长n字节并返回增长前的旧中断点地址。这就是malloc向操作系统“进货”的入口。一个必须注意的细节是sbrk返回的内存是没有进行内存管理的如果用户直接使用sbrk并在释放时处理不当很容易造成堆内存碎片。malloc/free正是把sbrk搬来的大块内存用Header空闲链表的方式管理起来实现“用户层面”的内存复用。操作系统只负责提供一大块连续内存具体怎么切分、怎么回收都是用户态分配器的事。书里的morecore在调用sbrk时一次至少申请NALLOC个Header大小的内存为什么要一次性多申请因为如果每次malloc只向sbrk请求刚好的大小系统调用次数太多而且新来的内存块太小、太碎很快就会耗尽空闲链表。批量申请然后用free把整块接入空闲链表后续的小malloc就可以在空闲链表里零成本分配。这跟read多次读文件一个道理——减少系统调用次数是提升性能的核心手段。我记得自己当时试图在Linux上直接运行书里的malloc实现发现有些细节需要适配比如sbrk的类型、Header里unsigned size在64位系统上的宽度但整体思路完全可以在现代系统上验证。如果你手头有书强烈建议自己敲一遍这段代码再写一个测试程序反复malloc/free用printf打印一下每次分配返回的地址——你会亲眼看到地址的规律性和碎片化是如何出现的。5. 进程控制从 fork 到 exec再到最小 shell第8章最后这几节主题转到了进程。前面操作的是文件、内存这里操作的是“正在运行的程序”。UNIX创建进程的方式非常独特用fork把一个正在运行的进程完整复制一份然后其中一份用exec把自己替换成另一个程序。第一次接触fork的人会觉得这个设计很怪异为什么要先复制再替换而不是直接创建书里给的解释很简单这种方案使得fork之后的父子进程能共享文件描述符、环境变量等资源同时exec可以灵活选择执行哪个程序两者解耦之后shell可以在fork之后的子进程里先做一些重定向操作比如把标准输出重定向到文件再执行exec这就是shell里重定向功能的底层原理。这个设计到今天都没变因为它实在是太简洁了。5.1 fork的语义及返回值fork是本书里第一个返回值如此“分裂”的函数。fork调用一次但返回两次在父进程中返回子进程的PID大于0在子进程中返回0出错时返回-1。所以标准用法是int pid fork(); if (pid 0) { perror(fork failed); } else if (pid 0) { // 子进程执行子逻辑 execvp(cmd, argv); exit(1); } else { // 父进程wait子进程结束 wait(NULL); }理解这个流程的关键是fork之后父子进程的执行流是完全独立的从fork返回的那一行开始各自继续往下走。子进程拿到的是父进程内存空间的完整副本包括当前函数调用栈、全局变量、打开的文件描述符。但它们是不同的进程修改各自的变量互不影响。这就引出了很多初学者最容易犯的错误以为fork之后父子进程会“共享”某个变量所以在子进程里改了变量父进程里想读取修改后的值——不可能的。如果需要进程间通信得用文件、管道、共享内存等机制。书里没有详细展开IPC但理解子进程不共享内存这个前提能帮你避免一大半并发编程的bug。5.2 用fork和exec实现一个极简shell书里的经典例子是shell一个不断读入命令行、fork子进程、exec执行命令、然后wait等待结果的主循环。我把书里的思路稍微扩展一下给出一个可以直接编译运行的最小shell代码同时支持exit退出命令#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #define MAXARGS 100 int main(void) { char buf[512]; char *argv[MAXARGS]; while (1) { printf($ ); fflush(stdout); if (fgets(buf, sizeof buf, stdin) NULL) break; int nargs 0; char *p strtok(buf, \t\n); while (p ! NULL nargs MAXARGS - 1) { argv[nargs] p; p strtok(NULL, \t\n); } argv[nargs] NULL; if (nargs 0) continue; if (strcmp(argv[0], exit) 0) break; if (fork() 0) { execvp(argv[0], argv); fprintf(stderr, %s: command not found\n, argv[0]); exit(1); } wait(NULL); } return 0; }这是我读过第8章后自己动手扩展的一个版本加上了fflush和exit命令。最值得注意的是exec失败之后的处理说清楚execvp失败必须要exit(1)。因为如果exec失败而不退出子进程会继续执行fork后面的代码也就是父进程的wait逻辑导致子进程和父进程同时进入wait程序行为就完全错乱了。很多写简易shell的初学者在这个坑里栽过跟头如果你跑出来的shell行为怪异务必检查exec失败后是不是漏了exit。wait这个系统调用也要重点理解。它的作用不止是“等子进程结束”这么简单它还会收集子进程的退出状态。如果父进程不wait子进程结束后会变成一个“僵尸进程”虽然不占CPU了但进程表项还在内核会一直记录着它的退出状态等着父进程来取。如果父进程一直不取僵尸进程就会积攒并占据系统进程表的资源。这是UNIX父子进程机制里一个非常独特的点父进程对子进程有“收尸”的责任。5.3 一个冷门但重要的细节文件描述符在fork之后的行为书里在讲fork时没有刻意强调但这一点在实际写代码时非常重要fork之后父子进程共享所有打开的文件描述符。也就是说父进程打开的一个文件子进程里这个文件描述符仍然有效而且它和父进程共用同一个文件位置偏移。这个共享意味着如果父子进程同时对同一个文件进行write操作它们写的位置是互相接续的一个进程写完了另一个进程会接着写。这之所以能成立是因为文件描述符对应的内核文件表项里记录了文件位置而fork复制的是文件描述符表不是文件表项所以父子进程的同一个fd指向同一个文件表项。理解这个模型之后就能明白为什么通过管道pipe在父子进程间传递数据那么自然——管道也是一种文件两端各有一个fd父子进程通过继承拿到这对fd就能互相通信。书里没展开管道但文件描述符的继承机制就是管道能工作的基石。6. 信号机制与读完这章的实操沉淀第8章的最后讲到了信号。信号是UNIX系统里进程之间以及内核向进程发送通知的主要方式。最常见的信号包括CtrlC触发SIGINT中断信号程序非法访问内存触发SIGSEGV段错误信号 kill命令默认发送SIGTERM终止信号。书中用一个简单的signal调用展示了如何捕获信号并改变默认行为#include signal.h #include stdio.h #include unistd.h void handler(int signum) { printf(caught signal %d\n, signum); } int main(void) { signal(SIGINT, handler); pause(); // 等待信号 return 0; }这段代码很简单但引出了一个非常重要且危险的话题信号处理函数里不能随便调用非异步信号安全的函数。printf就不是异步信号安全的如果主程序正在执行printf的过程中收到信号又跳进handler里执行printf可能造成内存数据竞争或死锁。第8章写作时间较早对这块的严谨性要求不像今天这么高但我建议大家在实际项目中尽量保持信号处理函数极简——只设置一个标志变量让主循环去检查这个变量而不要在handler里做复杂操作。现代系统里signal函数还面临一个可移植性问题早期的UNIX系统signal在调用处理函数之后会把信号处置重置为默认值导致同一个信号第二次到来时行为不同。后来引入sigaction接口来规避这个问题POSIX也推荐使用sigaction而不是signal。如果你写的是新代码建议优先用sigaction除非你明确知道自己要兼容极老的系统。6.1 结合现代视角看第8章哪些仍然实用哪些已经演进读完第8章如果只是停留在“啊懂了”的状态有点可惜。我建议做一次对照把书中讲的内容和现代Linux系统上的实际行为做个对比你会发现绝大部分内容仍然成立只是有些接口名字或细节变了。比如open的flags从最早的O_RDONLY/O_WRONLY/O_RDWR演进到了O_CREAT、O_TRUNC、O_APPEND、O_NONBLOCK等一大堆标志位creat这个单独的接口渐渐被open替代文件的权限模式从0666这种原始写法变成了今天仍然一样的八进制表示目录操作从手工读inode变成了opendir/readdir封装。但底层原理——文件描述符、读位置、打开文件表项、inode、目录树——基本没变。malloc部分今天的glibc用的是ptmalloc2它是一个基于arena和chunk的复杂分配器远不止第8章里的空闲链表但第8章教你的是第一性原理内存分配就是管理链表的指针游戏所谓“高性能分配器”只是在链表基础上增加缓存、分桶、线程局部存储等优化而已。理解了书里的简化版malloc你看现代分配器的源码时会觉得亲切很多。进程控制部分fork/exec/wait到今天仍然是UNIX类系统创建进程的唯一正统方式。虽然现代Linux引入了clone、posix_spawn等更精细或更便捷的接口但它们的基础仍然是那套“复制当前进程、替换为新进程”的模型。只是现实中fork的开销被写时复制copy-on-write技术优化了不再真的把整个内存复制一遍。这个知识点第8章里没有但如果你理解了fork的“复制”语义再知道Linux大多数情况下不立即复制就能明白为什么fork在现代系统上仍然很快。6.2 读完第8章我总结的五条实操经验第一调试程序时多用strace。strace能跟踪一个进程调用了哪些系统调用、传了什么参数、返回了什么值。这一章讲的全程都是系统调用用strace去看你会发现printf后面跟着writefopen后面跟着openmalloc后面偶尔跟着brk——书里的抽象和机器执行的真实行为一下子就对上了。我强烈建议在跑任何涉及文件、内存、进程的实验时都开一个strace看看。第二写文件复制或者网络收发代码时务必处理write短写。也就是上一节提过的那个循环写法。很多人只判断了返回值是否为-1忽略了返回值小于请求长度的情况这在普通文件上不容易触发一换到管道、socket就出问题。第三把书里的代码亲手打字运行一遍不要复制粘贴。第8章的代码是“读起来容易、写起来容易错”的类型只有自己敲过一遍才会注意到比如p-s.size、bp-s.ptr这种指针链的写法有多绕。我当年花了一个晚上敲完整章示例第二天再看malloc那段时已经没有那种“云里雾里”的感觉了。第四操作系统课程或面试前第8章的malloc和fork是必须能徒手默写的代码。很多操作系统面试题会让你手写一个简单的内存分配器或者解释fork的底层流程KR里的这两个实现就是最经典的答案模板理解它们比背诵任何题库都有用。第五如果你将来写的是Linux系统编程建议把第8章和《UNIX环境高级编程》配合着读。KR讲原理APUE讲细节和踩坑两本配合起来整条知识线就非常完整了。比如文件描述符的继承、信号的安全函数列表、阻塞与非阻塞I/O这些KR没展开但APUE里都讲得很细致。6.3 第七章到第8章从语言到系统的一个跳跃读完第8章我有个很深的感触这一章其实是整本书的“毕业设计”。前面七章的C语言知识在这里全部被用上了而且用得极其自然。指针不再是用来玩链表和二叉树的而是直接操作内存块的地址结构体不只是用来组织数据的还承担着“在堆内存里布局元数据”的职责联合体的对齐作用也只有在这一章才真正体现出来。这也解释了为什么《C程序设计语言》到今天仍然是经典中的经典。它不只是一本“C语言语法书”它通过最后一章告诉你C语言设计的初衷是为了让程序员能够高效、简洁地编写操作系统级别的代码。你学的每一种语法都是为了让你能更自由地控制内存、文件、进程和信号。这个理念贯穿了整本书而在第8章达到了高潮。我个人最初读书时的体验是前七章顺风顺水到第8章栽了大概两个礼拜反复看、反复写、反复调到无数次崩溃。但突破之后再去写任何上层应用心里都有底了。因为不管框架怎么变、语言怎么换底层就是文件描述符、虚拟内存、进程表和那一套系统调用这些东西几十年来没有本质变化。你花时间在KR第8章上的每一分钟都是在为自己的编程理解打底子。如果你正在读这一章或者正被malloc那段绕得头疼我的建议很简单慢下来画图用手动跑一遍链表操作然后把代码抄下来编译运行。这个过程一旦扛过去后面会越来越顺。再往后如果你想深入可以接着读“The Design of the UNIX Operating System”Bach著或者直接翻Linux内核的有关源码你会发现KR铺的这条底层知识路径走得越远越值钱。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门