
终端里第一次蹦出Segmentation fault (core dumped)的时候绝大多数人的第一反应是把代码从头到尾再读一遍然后什么都没看出来——这条路我走过浪费时间且不解决问题。实验做到第十二个性质已经变了前十一个实验里你敲的是ls、chmod、useradd是在用别人写好的工具从 Linux 编程技术应用这个实验开始你要自己写出工具。编译工具链、文件系统调用、进程与并发、Shell 自动化这四件事凑在一起才构成能写程序和会用命令之间的那道分水岭。这篇内容面向正在做这个实验、但对 Linux 系统编程还没有完整概念的人也面向那些代码能跑、但说不清为什么能跑的人。我会把每一步为什么这么做讲透包括那些实验指导书上不会写、但真正卡住人的细节。1. 实验十二真正在考什么从敲命令到写程序的那道坎1.1 实验序列里的位置决定了它的难度曲线Linux 操作系统这门课的实验安排通常是递进的前期围绕文件与目录操作、权限管理、用户与组、磁盘与挂载、软件包管理中期进入 Shell 基础和进程查看到了第十二个实验才开始要求你写代码。这个位置很关键它意味着老师默认你已经具备两个前提一是能熟练在命令行里定位文件、查看权限、读懂报错二是理解进程、文件描述符这些抽象概念至少停留在名词层面。难点恰恰在这里。前面十一个实验的错误反馈是直白的chmod权限不够它就告诉你 Permission denied路径写错它就告诉你 No such file or directory你改一下就好。而编程实验的错误反馈是间接的编译器只告诉你某个符号没定义但没告诉你为什么程序跑起来结果是错的但退出码是 0进程卡住了终端就那么安静地等着不给你任何提示。这种反馈延迟是很多人第一次做编程实验最不适应的地方。所以做这个实验之前先在心里换个预期你的主要工作不是写代码而是建立一条从报错到根因的推理链路。代码本身可能只有三十行但把它编译对、跑对、解释清楚需要的是另一套能力。1.2 验收时老师真正在看哪四件事把实验目标拆开看Linux 编程技术应用这个实验通常覆盖四个能力面虽然不同学校的题目细节有差异但这四块基本跑不掉编译与构建能不能把一个多文件的 C 程序用gcc编出来能不能写一个能用的 Makefile理解预处理、编译、汇编、链接这四个阶段分别在干什么。文件与系统调用能不能用open、read、write、stat这类接口自己实现一个简化版的cp或ls理解系统调用和库函数的区别。进程与通信能不能用fork、exec、wait、pipe、信号把一个父子进程协作的流程跑通知道僵尸进程、孤儿进程是怎么来的。脚本自动化能不能把反复敲的命令序列固化成一个 Shell 脚本带参数、带错误检查、带日志输出。这四块不是孤立的。你在写简化版cp时踩到的缓冲区问题会直接影响你写 Shell 脚本时对管道的理解你在fork上栽的跟头会决定你后面看任何并发代码时的直觉是否准确。1.3 开工前的环境自检清单不要急着写代码先花三分钟确认工具链是齐的。很多编译不过的问题根因是环境缺组件而不是代码有问题。组件用途自检命令期望结果gccC 编译器gcc --version输出版本号make构建工具make --version输出版本号gdb调试器gdb --version输出版本号glibc-devel / libc6-dev头文件与静态库ls /usr/include/stdio.h文件存在man-pages手册页man 2 open打开系统调用手册vim 或 nano编辑器vim --version输出版本号其中man-pages最容易被忽略但它是这个实验里最值钱的资源。man 2 open看的是系统调用手册第 2 节man 3 fopen看的是库函数手册第 3 节这两个数字的区别本身就是知识点第 2 节讲的是内核提供的接口第 3 节讲的是 C 库包装出来的接口。判断一个函数属于哪一类看它的手册页码就够了。提示如果man 2 open提示 No manual entry说明手册页没装全先补上再开始实验。在报告里写一句通过手册页确认接口的参数语义比写一句查了资料要有说服力得多。2. GCC 与 Makefile先让代码真的能被编出来2.1 一条 gcc 命令背后其实发生了四件事大多数人写的是gcc hello.c -o hello一条命令结束。但这条命令内部拆开是四个阶段理解这四个阶段你才能看懂 90% 的编译报错。预处理处理#include、#define、条件编译把头文件内容原地展开产出.i文件。对应gcc -E hello.c -o hello.i。编译把预处理后的 C 代码翻译成汇编代码产出.s文件。对应gcc -S hello.i -o hello.s。汇编把汇编代码翻译成机器码目标文件产出.o文件。对应gcc -c hello.s -o hello.o。链接把多个.o文件和系统库拼成可执行文件。对应gcc hello.o -o hello。为什么要搞清楚这个因为报错信息里出现的阶段名是有含义的。undefined reference tofoo 是链接阶段的问题说明代码语法没问题只是找不到实现stdio.h: No such file 是预处理阶段的问题说明头文件路径不对。知道错误发生在哪个阶段排查方向就完全不一样了。实测一下你会有更直观的感觉先写一个最简单的程序然后依次执行上面四条命令看每一步产出的文件长什么样。打开hello.i你会发现前面多出上千行那些都是stdio.h展开进来的内容。这个瞬间比看十页教材都管用。2.2 常见编译报错与真实原因对照下面这张表是我在带人做这个实验时反复见到的报错按出现频率排序。注意报错原文和真实原因往往不是一回事。报错关键字真实原因处理方向undefined reference to xxx链接时找不到符号实现漏了源文件或漏了-l库参数implicit declaration of function没包含对应头文件补#include别靠隐式声明蒙混No such file or directory头文件头文件不在默认搜索路径用-I指定目录multiple definition of xxx头文件里写了变量或函数定义头文件只放声明定义放.cerror: for loop initial declarations...编译器默认标准过旧加-stdc99或-stdgnu11Permission denied输出文件不可写换路径检查目录权限collect2: error: ld returned 1 exit status这是链接失败的汇总信息往上翻真正的错误在它前面最后一条要特别说。很多人只看到最后这行就懵了因为这句话本身什么都没说。真正的错误信息永远在它上面几行collect2只是最后那个收尾的人。学会往上翻三行这个动作能省下大量时间。2.3 Makefile 的依赖规则以及为什么不直接用 Shell 脚本多文件项目里每次改一行代码就重新敲一遍长长的gcc命令这种做法撑不过一天。Makefile 解决的问题是只重新编译那些真的变了的文件。CC gcc CFLAGS -Wall -g -O2 -stdgnu11 TARGET mycp OBJS main.o fileops.o $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $ $^ %.o: %.c $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET) .PHONY: clean这份 Makefile 里有两个必须记住的细节。第一命令行前面必须是 Tab不是空格。这是 make 的历史包袱用空格会报missing separator而且报错信息完全不提示你你用了空格。第二$(TARGET): $(OBJS)这一行描述的是依赖关系不是执行顺序。make 拿到依赖图后自己决定先编哪个这就是它比脚本聪明的地方。那为什么不用 Shell 脚本代替脚本只能线性执行每次都全量重编make 靠时间戳判断哪些目标过期了增量编译。对一个只有两个文件的项目差别不明显对一个二十个文件的项目差别是十秒和三十秒的差别而且是每次编译都差。另外 make 支持-j并行编译脚本想做到这点得自己写进程管理实在不划算。2.4 编译参数怎么选-Wall、-g、-O2 各自在换什么-Wall打开常用警告。它不是可选项是必选项。很多在运行期崩溃的代码编译期其实有警告只是你没开。比如把int当指针用、函数没写返回值、变量没用就声明这些都会在-Wall下暴露。-g把调试符号打进可执行文件行号和变量名都保留下来。没有它gdb 里看到的栈回溯只有一串地址等于没调试。-O2做优化。这里有个坑优化会打乱代码和汇编的对应关系变量可能被优化进寄存器gdb 里print变量会显示 optimized out。所以调试阶段用-O0默认就是出成绩或者测性能时再换-O2。我的习惯是在 Makefile 里把CFLAGS拆成两块调试目标和发布目标分开CFLAGS_DEBUG -Wall -g -O0 -stdgnu11 CFLAGS_REL -Wall -O2 -stdgnu11注意-Wall后面还可以加-Wextra会多报一批警告比如函数参数未使用、比较时有符号和无符号混用。开启之后第一次编译通常一片红但改完这些警告代码质量会有一个明显的台阶。3. 文件与系统调用把 cp 和 ls 自己实现一遍3.1 系统调用和库函数不是一回事这是这个实验里最重要的一个概念区分。open、read、write、close、stat属于系统调用是内核直接暴露的接口参数和返回值的语义都非常底层fopen、fread、fwrite、fprintf属于 C 标准库函数是在系统调用之上包了一层用户态缓冲区。为什么要包这一层因为系统调用要陷入内核开销大。如果你一个字节一个字节地调用write程序的大部分时间花在用户态和内核态之间来回切换上。库函数在用户态攒够一块数据再统一调一次write效率就上来了。那为什么实验还要求用系统调用写因为实验考的是让你理解底层机制而且系统调用能做的事情更多open可以指定O_NONBLOCK、O_APPENDfcntl可以做文件锁这些在库函数层面是拿不到的。这就像开车平时开自动挡没问题但你要懂变速箱里在发生什么。3.2 一个能用的简化版 cp缓冲区大小值得实测#include stdio.h #include fcntl.h #include unistd.h #define BUFSZ 4096 int main(int argc, char *argv[]) { if (argc ! 3) { fprintf(stderr, 用法: %s 源文件 目标文件\n, argv[0]); return 1; } int in open(argv[1], O_RDONLY); if (in 0) { perror(打开源文件失败); return 1; } int out open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644); if (out 0) { perror(打开目标文件失败); close(in); return 1; } char buf[BUFSZ]; ssize_t n; while ((n read(in, buf, BUFSZ)) 0) { ssize_t off 0; while (off n) { ssize_t w write(out, buf off, n - off); if (w 0) { perror(写入失败); close(in); close(out); return 1; } off w; } } if (n 0) perror(读取失败); close(in); close(out); return 0; }这段代码里有三处是实验报告里的加分点很多人不会写。第一处是内层的while (off n)循环。write的返回值可能小于你要求写的字节数尤其是写管道、写网络套接字、或者磁盘快满的时候。初学者通常写成write(out, buf, n)就完事本地拷贝小文件确实不出问题但这属于侥幸。第二处是O_TRUNC标志。不加它目标文件比源文件长的话会残留后面一段旧数据拷出来的文件尾部是脏的。第三处是权限0644的写法它是八进制前面那个 0 不能省写成644就是十进制了结果完全不对。缓冲区大小值得自己做个实验。把BUFSZ依次改成 1、512、4096、65536拿一个几百兆的文件测一下time你会看到从 1 字节到 4096 字节的提升非常明显而从 4096 到 65536 提升就很小了。原因不复杂4096 是常见的内存页大小一次读一整页正好和内核的页缓存对齐再往上加收益递减。这个实测数据写进报告比抄一段缓冲区越大越好的结论有价值得多。3.3 目录遍历readdir 拿到的信息可能不够用实现一个简化版ls核心是这三步opendir打开目录readdir逐个读取目录项closedir关闭。#include stdio.h #include dirent.h #include sys/stat.h int main(int argc, char *argv[]) { const char *path (argc 1) ? argv[1] : .; DIR *d opendir(path); if (!d) { perror(opendir); return 1; } struct dirent *e; while ((e readdir(d)) ! NULL) { struct stat st; char full[4096]; snprintf(full, sizeof(full), %s/%s, path, e-d_name); if (lstat(full, st) 0) { perror(lstat); continue; } printf(%10ld %s\n, (long)st.st_size, e-d_name); } closedir(d); return 0; }这里有个坑struct dirent里确实有个d_type字段看起来能直接告诉你这是文件还是目录。但它在某些文件系统上返回DT_UNKNOWN尤其是某些网络文件系统或较老的文件系统。指望d_type做判断的程序换一台机器、换一个挂载点就可能行为异常。稳妥做法是拿到文件名之后自己调stat或lstat去问。lstat和stat的区别也在考纲内遇到符号链接时stat会跟进到目标文件lstat返回链接本身的信息。你写的是ls那就得用lstat你写的是这个链接指向的文件有多大那就用stat。这两个调用混用是最常见的翻车点之一。3.4 errno、perror 和一套值得养成的排查习惯系统调用失败时返回 -1具体原因放在全局变量errno里。你当然可以自己写if (errno ENOENT)逐个判断但在实验阶段更省事的做法是直接用perror它会把errno翻译成一句人类能读的话前面加上你给的前缀。int fd open(nofile.txt, O_RDONLY); if (fd 0) { perror(打开 nofile.txt); // 输出: 打开 nofile.txt: No such file or directory }我建议从做这个实验开始就养成三个习惯它们会在后面的所有编程任务里持续回报你。第一每个可能失败的调用都要检查返回值。malloc会返回 NULLopen会返回 -1read会返回 -1 或者 0。不检查就等于把问题往后推推到某个完全不相关的地方再爆炸。第二错误信息一律写 stderr不写 stdout。用fprintf(stderr, ...)或者perror这样程序输出可以被正常重定向错误信息还能留在终端上。这个小习惯在写管道的时候尤其重要因为 stdout 被重定向之后如果你把错误也写进去下游程序会收到一堆垃圾数据。第三不要在错误分支里忘记释放资源。上面open目标文件失败时我写了close(in)这就是在补这个洞。进程退出时内核会回收文件描述符所以短期看好像没事但如果这段代码是在循环里被调用文件描述符会很快耗尽报EMFILE: Too many open files。这个报错第一次见的时候非常让人困惑因为它和打开文件这个动作看起来毫无关系。4. 进程与通信fork、exec、管道这套组合怎么落地4.1 fork 之后为什么你的 printf 打了两遍先看一段代码这是新手最容易困惑的现象#include stdio.h #include unistd.h int main(void) { printf(开始); // 注意这里故意没写换行 fork(); printf(pid%d\n, getpid()); return 0; }运行结果里开始会打印两次。很多人第一反应是fork把 printf 也复制了一份执行了——方向对了一半。真正的原因是printf的内容先进入用户态的行缓冲区终端设备下遇到换行才真正write出去。printf(开始)没带换行所以它只是待在缓冲区里没落到屏幕上。此时fork发生整个用户态内存被复制包括这个还没刷出去的缓冲区。父进程和子进程各自在退出时刷新缓冲区于是同一段内容被写了两遍。验证方法很简单把printf(开始)改成printf(开始\n)或者在fork之前加一句fflush(stdout)两个开始就变成一个了。把这行代码改成写文件重定向到文件再试一次你会发现结果又不一样——因为文件是全缓冲行为跟终端不同。这个观察能帮你把缓冲区刷新策略这个抽象概念彻底钉死。注意实验报告里如果写了这个现象一定要把fflush的验证过程一起写进去。只描述现象不给出验证手段看起来就像从别处看来的。4.2 wait 和僵尸进程一个可以现场复现的问题fork之后父进程如果不调用wait或waitpid回收子进程的退出状态子进程结束后会变成僵尸进程状态是Z。亲眼看一下这个状态比背定义有用得多。#include stdio.h #include unistd.h int main(void) { pid_t pid fork(); if (pid 0) { printf(子进程 %d 退出\n, getpid()); return 0; } printf(父进程 %d 先睡 30 秒\n, getpid()); sleep(30); // 这 30 秒内另开一个终端执行 ps能看到 Z 状态 return 0; }编译运行之后立刻在另一个终端执行ps -o pid,ppid,stat,cmd -C 你的程序名或者ps aux | grep 程序名你会看到一个状态栏写着Z的进程。这 30 秒就是这个僵尸进程的寿命。把sleep(30)换成wait(NULL)僵尸就没了。理解僵尸进程的关键是子进程的退出信息需要有个地方存着直到父进程来取。内核不能直接扔掉否则父进程就永远拿不到退出码了。所以子进程的进程表项保留着但它已经不再占用 CPU 和内存只是挂在那里等父进程来收。这也是为什么僵尸进程杀不掉——它本来就已经死了kill一个已经不存在的进程当然没用能解决它的只有父进程调用wait。4.3 用管道把两个进程连起来管道是这个实验里最实用的东西因为它正是 Shell 里那个|的底层实现。用系统调用手写一遍你对ls | wc -l的理解会完全不一样。#include stdio.h #include unistd.h #include sys/wait.h int main(void) { int fd[2]; if (pipe(fd) 0) { perror(pipe); return 1; } pid_t pid fork(); if (pid 0) { /* 子进程把标准输出接到管道写端执行 ls */ close(fd[0]); dup2(fd[1], STDOUT_FILENO); close(fd[1]); execlp(ls, ls, -l, (char *)NULL); perror(execlp); // 只有 execlp 失败才会走到这里 return 1; } /* 父进程从管道读端收数据 */ close(fd[1]); char buf[4096]; ssize_t n; while ((n read(fd[0], buf, sizeof(buf))) 0) { fwrite(buf, 1, n, stdout); } close(fd[0]); wait(NULL); return 0; }这段代码里有两个必须注意的点。第一管道两端用完要及时关闭。子进程里close(fd[0])是必须的否则父进程的read永远等不到 EOF会一直阻塞——因为管道的读端还有别的进程持有内核认为还可能有人往里写。这个卡死现象非常典型第一次遇到的人通常会怀疑是read的用法错了其实问题出在没关的那个 fd 上。第二execlp之后的perror是必需的因为execlp成功时不会返回一旦它返回了就说明失败了这时候必须报错不然程序会带着错误的状态继续往下走。如果实验题目没要求手写管道popen是更省事的选择它把fork、exec、pipe三件事打包成了一个函数。但建议至少手写一遍因为popen有个限制只能单向通信而且没法控制子进程的环境。手写过一遍之后你看到popen就知道它在内部替你做了什么。4.4 信号处理里最容易犯的三个错信号是异步的所以它的坑全在异步这两个字上。第一个错在信号处理函数里调用printf。printf不是异步信号安全的函数它内部有锁和缓冲区状态。如果在主程序正操作 stdout 的时候信号来了处理函数里又去调printf就可能死锁或者输出错乱。正确做法是用write#include signal.h #include unistd.h volatile sig_atomic_t g_stop 0; void on_sigint(int sig) { (void)sig; const char msg[] 收到中断信号准备退出\n; write(STDOUT_FILENO, msg, sizeof(msg) - 1); g_stop 1; }第二个错用普通变量在信号处理函数和主循环之间传递状态。编译器可能把这个变量优化到寄存器里导致主循环永远看不到更新。所以要用volatile sig_atomic_t这个类型保证了读写是原子且不会被优化掉。第三个错忘了signal或sigaction的注册时机。注册必须放在可能触发信号的操作之前而且要检查返回值。用sigaction比signal更可靠因为signal在不同系统上的语义有细微差异比如是否自动重置处理函数。这个实验里用signal通常也够但知道有sigaction这个更规范的选择是加分项。5. Shell 脚本自动化把重复劳动固化成一条命令5.1 脚本头和变量引号两个必须写对的地方脚本第一行#!/bin/bash不是注释是告诉内核用哪个解释器来执行这个文件。少了它脚本可能被当成 sh 执行而 sh 和 bash 在数组、[[ ]]、$RANDOM这些特性上并不一致会出现我在终端里能跑写成脚本就不行的诡异情况。变量的引号是第二个重灾区。看这几种写法的区别namehello world echo $name # 输出 hello world但 word splitting 会把它拆成两个参数 echo $name # 输出 hello world作为一个整体 echo ${name} # 最明确边界清楚推荐 rm $file # 如果 file 是空的这行等价于 rm 后面什么都没有可能报错 rm $file # 如果 file 是空的这行是 rm 会明确报错更安全结论很直接变量引用一律加双引号。这不是风格偏好是防 bug。文件名里带一个空格不加引号的脚本就会崩而且崩得莫名其妙。5.2 条件判断test、[ ] 和三目运算符的坑Shell 里的[其实是一个命令不是语法结构所以[后面必须有空格]前面也必须有空格。写成[$x -eq 1]会报command not found因为 shell 把[$x当成一个命令名去找了。if [ $n -gt 10 ]; then echo 大于 10 elif [ $n -eq 10 ]; then echo 等于 10 else echo 小于 10 fi数字比较用-gt、-lt、-eq、-ne字符串比较用、!、-z空串、-n非空。这两套运算符不能混用把字符串拿去做-gt比较会报integer expression expected。[[ ]]是 bash 的扩展比[ ]更安全它不做单词分割支持、||、正则匹配~。既然脚本头已经写明用 bash用[[ ]]是更好的选择。判断文件是否存在、是不是目录这类操作-f、-d、-e、-r、-x这些测试运算符要记住它们在自动化脚本里出现频率极高。5.3 参数处理和退出码让脚本像个正经工具#!/bin/bash set -euo pipefail SRC${1:-hello.c} OUT${2:-hello} if [[ ! -f $SRC ]]; then echo 错误: 源文件 $SRC 不存在 2 exit 2 fi echo [构建] $SRC - $OUT gcc -Wall -g -O0 -o $OUT $SRC echo [运行] ./$OUT ./$OUTset -euo pipefail这一行值得单独说。-e表示任何命令返回非零就立刻退出避免错误被忽略后继续往下跑出一堆连锁问题-u表示引用未定义变量就报错能提前发现拼错的变量名-o pipefail表示管道中任何一个环节失败都算失败不加这个的话cmd1 | cmd2的退出码只由cmd2决定前面的失败会被吞掉。这三个选项加上去之后脚本会变得脆一些但脆是好事它会在问题发生的第一时间停下来。${1:-hello.c}这种写法的意思是如果第一个参数存在且非空就用它否则用hello.c。这是给脚本设默认值最简单的方式。$#是参数个数$是所有参数加引号时会保持每个参数独立$*是所有参数拼成一个字符串。这几个变量在批量处理文件时会反复用到。5.4 把编译、运行、清理串成一条流水线脚本真正发挥价值的地方是把一个完整流程收敛成一条命令。下面这个脚本比单纯编译多一点东西带清理选项、带日志、带失败提示。#!/bin/bash set -euo pipefail LOGbuild.log TARGETlab12 usage() { echo 用法: $0 [-c] [-h] echo -c 编译前清理旧产物 echo -h 显示帮助 } clean0 while getopts ch opt; do case $opt in c) clean1 ;; h) usage; exit 0 ;; *) usage; exit 1 ;; esac done [[ $clean -eq 1 ]] { echo [清理]; make clean || true; } echo [构建] 开始日志写入 $LOG if make $LOG 21; then echo [构建] 成功 else echo [构建] 失败最后 20 行日志 tail -n 20 $LOG 2 exit 1 fi echo [运行] ./$TARGET ./$TARGET这份脚本里有几个细节值得抄走。日志重定向用了 $LOG 21把标准输出和标准错误都收进同一个文件顺序不能写反写成21 $LOG的话 stderr 还是指向终端。失败时tail -n 20只打印最后二十行因为编译日志动辄几百行全打印出来反而找不到重点。make clean || true是为了让清理失败不至于中断流程因为第一次构建时没有产物可清make clean返回非零很正常。6. 调试与验收gdb、段错误定位和报告怎么写6.1 gdb 里真正高频的八条命令编译时带上-g然后gdb ./程序名接下来用到的命令其实没几个。命令作用使用场景break 函数名/break 文件:行号设置断点定位到可疑位置run [参数]启动程序带参数运行next单步不进入函数快速跳过已知正确的代码step单步进入函数需要看函数内部时finish执行到当前函数返回跳出一个很深的调用print 变量打印变量值检查状态backtrace打印调用栈崩溃后第一件事info locals打印当前所有局部变量懒得一个个 print崩溃之后的第一反应应该是backtrace简写bt它会告诉你程序死在哪个函数的哪一行以及是谁调用的。如果bt显示的栈里有??这种地址说明对应的库没有调试符号这很正常往下看你自己写的代码那一帧就行。6.2 段错误的完整定位链路段错误是最常见的崩溃定位它有一套固定流程。第一步确认有 core dump 可看。执行ulimit -c如果是 0说明 core 文件被禁用了用ulimit -c unlimited打开。如果系统配置里把 core 路径改了用cat /proc/sys/kernel/core_pattern看它落到哪去了。第二步用 gdb 打开 core 文件gdb ./程序名 core。进去之后立刻bt看崩溃那一帧。第三步如果连 core 都没有就用 gdb 直接跑让它在崩溃点停下来gdb ./程序然后run崩溃时 gdb 会接管此时bt、print那些出问题的变量。段错误的原因归纳起来就三类。指针没初始化或者已经是 NULL 就去解引用这是最常见的数组越界写到了不属于自己的内存释放后继续使用也就是 use-after-free这类问题的表现往往不稳定有时候崩有时候不崩换个编译选项结果就变了。有一个非常好用的技巧用-fsanitizeaddress重新编译一遍。gcc -Wall -g -O0 -fsanitizeaddress -o myprog myprog.c ./myprogAddressSanitizer 会把越界读写、释放后使用、内存泄漏这些问题在发生的那一刻就报出来附带完整的调用栈比对着 core 文件猜快得多。它唯一的代价是程序变慢、内存占用变高调试阶段完全可以接受。6.3 内存问题自查valgrind 与常见误报如果题目里涉及动态内存分配valgrind值得跑一遍。valgrind --leak-checkfull --show-leak-kindsall ./myprog它会输出几类问题definitely lost是确定泄漏分配的指针彻底丢了indirectly lost是间接泄漏通常是因为持有它的结构体本身泄漏了still reachable是程序结束时还有指针指着严格说不算泄漏但值得看一眼。先修definitely lost因为它往往连带解决掉后面几个。跑 valgrind 有个前提程序里最好用-g编译不然栈回溯里只有函数名没有行号排查效率会低很多。另外 valgrind 对某些库的兼容性一般如果它在你的程序还没跑起来就报错可以先用-fsanitizeaddress顶上两个工具解决的是同一类问题。6.4 实验报告怎么写出我真的做过的质感最后说一下报告。这个实验的报告最容易写成两种极端一种是只贴代码没有任何分析另一种是长篇大论地抄教材概念。两种都拿不到高分因为老师要看的是你的思考痕迹。我的建议是每个核心功能都按这个四段式写需求是什么 → 我的实现思路是什么 → 遇到了什么问题 → 怎么定位和解决的。第三段和第四段是含金量最高的部分。比如你写了最初缓冲区用了 1 字节测一个 200MB 的文件耗时约 X 秒改成 4096 字节后降到 Y 秒这比使用了合适的缓冲区大小强一百倍因为它有数据、有对比、有结论。再比如fork那个缓冲区复制的现象如果你把没加换行时打印两次、加了换行打印一次、重定向到文件又是另一种行为这三组观察都写下来并给出fflush的验证这一段就是整份报告里最有分量的内容。它证明的不是你记住了某个知识点而是你有能力设计实验去验证一个猜想。还有一个小细节把失败的尝试也留下来。你调write没做部分写处理导致大文件拷贝损坏你fork之后忘了关管道的一端导致读操作永久阻塞这些都是真实的过程。报告里写清楚这里卡了多久、怎么发现的读起来才像一个人的实验记录而不是一份标准答案。我个人做这类实验的习惯是边做边记开一个文本文件每解决一个问题就写两行现象是什么原因是什么。等到写报告的时候这份流水账就是最好的素材而且不用担心回忆有偏差。这个习惯我从做课程实验一直带到现在做项目排障时同样在用收益远超预期。