系统编程三大经典Bug图鉴:悬空指针、分配不足、缓冲区溢出,一个都别跳
【免费下载链接】coursebookOpen Source Introductory Systems Programming Textbook for the University of Illinois项目地址: https://gitcode.com/GitHub_Trending/co/coursebook
写 C 系统程序时,绝大多数令人抓狂的崩溃与安全漏洞,都可以追溯到同一类根源:程序对"内存到底有多大、这块内存还归不归我"缺乏清晰的认知。悬空指针让你写入一片已不属于自己的内存,分配不足让你以为拿到了足够空间实则越界,缓冲区溢出则让越界写入变成常态。这三类 Bug 贯穿了 UIUC CS 341 系统编程课程的开源教材 coursebook 的多个章节——从 C 常见错误、C 内存模型,到 malloc 分配器实现 和 GDB 调试。本文以这套教材的源码级素材为线索,拆解三个 Bug 从一行错误代码到内存布局被破坏的完整过程,并给出用 GDB 把"看不见的错误"变成可视化实验的具体方法。
一、三大 Bug 的触发机制:从代码到内存布局
1. 悬空指针:free 之后,指针还在
悬空指针(dangling pointer)的本质是:你持有一个指针,但它指向的内存已经被释放或失效。coursebook 在 introc/common_bugs.tex 里用一段极短的代码把两种错误同时演示了出来:
int *p = malloc(sizeof(int)); free(p); *p = 123; // Oops! - Dangling pointer! Writing to memory we don't own anymore free(p); // Oops! - Double free!free(p)之后,p指向的堆块已归还给分配器,但p本身仍然保存着那个地址。此时再写*p = 123,你写入的是一块"名义上已释放"的内存——分配器可能已经把它并入空闲链表,之后某个malloc又会把这块内存分给其他用途,你的写入就会悄无声息地污染别人的数据。更糟的是后续再次free(p),这就是 double free,分配器的元数据结构可能被破坏,导致崩溃或堆损坏。
教材给出的第一道防线非常朴素:释放后立即置 NULL。
p = NULL; // No dangling pointers这样任何后续误用都会变成对空指针的解引用,程序立刻崩溃而不是静默损坏——崩溃是友好的,因为它把错误暴露在了现场。
悬空指针还有另一个变体:返回指向自动变量的指针。栈上的自动变量只存活于函数调用期间,函数返回后继续使用这块内存同样是悬空访问:
int *f() { int result = 42; static int imok; return &imok; // OK - static variables are not on the stack return &result; // Not OK }2. 分配不足:malloc 的参数写错了
第二类 Bug 同样经典,而且更隐蔽——它不发生在指针用法上,而发生在malloc的参数上。教材里用结构体展示了这个坑:
struct User { char name[100]; }; typedef struct User user_t; user_t *user = (user_t *) malloc(sizeof(user)); // 错误!sizeof(user)是一个指针的大小(64 位机器上是 8 字节),而struct User需要 100+ 字节。你只申请了 8 个字节,随后strcpy(user->name, ...)这类操作就会把数据写进这 8 字节之外的堆内存。分配不足本质上就是一次必然发生的越界写,只不过它不像数组越界那样一眼能看出边界在哪。
正确写法是:
user_t * user = (user_t *) malloc(sizeof(user_t));同样典型的还有字符串拷贝的经典错误——"每个字符串都需要strlen(s)+1个字节"。教材用一个手写strdup的三步演进把这个知识点讲透了:
char *strdup(const char *input) { char *copy; copy = malloc(sizeof(char*)); /* nope! 这分配的是指针的大小,不是字符串的大小 */ copy = malloc(strlen(input)); /* 差一点……结尾的 NUL 终止符呢? */ copy = malloc(strlen(input) + 1); /* 这才对。 */ strcpy(copy, input); /* strcpy 会补上结尾的 NUL */ return copy; }C 字符串以 NUL(\0)字节结尾,"Hi" 实际要占 3 字节:[H] [i] [\0]。只按strlen分配,意味着strcpy写入的终止符必然落在申请的内存之外。有意思的是,这类 Bug 常常"有时工作、有时失败"——复习章节 review/review.tex 专门用一道题考察了这个现象:malloc(strlen(src))之后strcpy,如果堆上恰好有空闲且零化的内存,程序看起来一切正常;一旦那块区域被复用过,就随机崩溃。这种不确定性正是它难以排查的原因。
3. 缓冲区溢出:越界一字节,垮掉一整个程序
第三类 Bug 是安全领域的常客。教材在 introc/common_bugs.tex 里给出了最典型的循环越界:
#define N (10) int i = N, array[N]; for( ; i >= 0; i--) array[i] = i;循环第一次迭代写入array[10],而数组合法下标是 0 到 9。C 语言不检查指针和下标是否合法,这一字节就写进了数组之外的内存——那里很可能存放着其他变量甚至栈上的关键数据,内存被静默破坏。教材特别点出:溢出未必发生在你眼皮底下,它可能藏在某个库调用里,比如臭名昭著的gets:
gets(array); // Let's hope the input is shorter than my array!gets无法限制读取长度,输入稍长就直接写穿缓冲区。introc/common_c_functions.tex 中记载了它的结局:C99 已将其标记为废弃,C11 标准直接移除了它,取而代之的是fgets与getline。这背后正是缓冲区溢出的"恶意版本"——攻击者用精心构造的超长输入覆盖栈上的返回地址或局部变量,劫持程序控制流。
教材在 security/security.tex 中给出了一个可以亲手复现的完整例子:两个紧邻的栈数组out[10]和in[10],用无边界检查的fscanf(stdin, "%s", in)读取,输入超过 10 字节后,in的溢出数据直接改写相邻的out,原本应输出"a"的程序变成了"aoo"。把这段代码里的in换成保存密码哈希的数组,一次"无害"的输入过长就变成了凭据泄露的入口。
另一个被反复引用的现实案例是 Heartbleed:教材在 introc/c_memory_model.tex 中明确写道,Heartbleed 的根源部分就在于对 NUL 终止符的处理——memcpy拷贝进了一个容量不足的缓冲区。它提醒我们:忘记为 NUL 留一个字节,不是风格问题,是安全问题。
二、malloc 策略与首次适应分配:教材里埋的防坑知识点
理解了三大 Bug,自然会追问:free 之后的内存去哪了?为什么分配不足会导致越界写?答案藏在堆内存的管理机制里。malloc/malloc.tex 用整整一章带读者从零实现一个内存分配器,而所有 Bug 的"土壤"——堆的布局与分配策略——正是这一章的核心。
教材先讲清楚堆的来历:进程通过sbrk系统调用向内核申请堆内存,堆从地址空间一端向上增长,与向下增长的栈相向而行。最朴素的malloc实现就是每次请求都调一次sbrk:
void* malloc(size_t size) { // Ask the system for more bytes by extending the heap space. void *p = sbrk(size); if(p == (void *) -1) return NULL; // No space left return p; }但这个实现有两个致命缺点:系统调用慢,且从不复用已释放的内存——程序会不断把堆撑大直到耗尽内存。真正可用的分配器必须维护"哪些块空闲、哪些块已分配"的账本,这就是空闲链表与边界标签(boundary tag)的用武之地。
当程序请求一块内存时,分配器面临一个选择:用哪块空闲区?教材用一张 64K 堆的示意图展开(见 malloc/drawings/heap_empty.png):从低地址到高地址依次是 16KiB 空闲、10KiB 已分配、1KiB 空闲、1KiB 已分配、30KiB 空闲、4KiB 已分配、2KiB 空闲。假设此时来了一个malloc(2048)的 2KiB 请求:
- Best fit(最佳适应):遍历全部空闲块,挑最小的、恰好够用的那个——恰好是末尾那块 2KiB,零浪费,完美匹配;
- Worst fit(最差适应):挑最大的 30KiB 块,切出 2KiB,剩下 28KiB 空洞;
- First fit(首次适应):从头扫描,遇到第一个足够大的块就停下——即 16KiB 那块,切出 2KiB,剩下 14KiB 空洞,甚至不需要遍历整个堆。
首次适应的"找到第一个能用的就停"策略,恰好揭示了悬空指针 Bug 为什么那么危险:分配器会在堆上反复切开、合并空洞。当你free一块内存后,它立刻变成空闲链表中可被再次分配的一块;如果你还握着指向它的指针,下一次malloc就可能把它交给另一个数据结构,你的写入就开始"隔空打牛"。
教材也毫不避讳首次适应的代价——它切出的 14KiB 剩余空间如果不被复用,就是内部碎片(分配给了程序但程序用不上);而堆被切成碎片后,即使总空闲空间足够,也可能没有一块连续区域能满足大请求,这就是外部碎片。教材引用 1995 年的分配器综述研究给出了一个反直觉的结论:在模拟的随机工作负载下,best fit 与 first fit 表现相当;在带分割阈值与合并(coalescing)的实际场景中,地址有序的 first fit 与 best fit 也基本打平,而其中的原因至今没有完全定论。
这些策略细节对普通程序员意味着什么?一句话:malloc 返回的地址只是堆上一块"账本记录"的开头,你对这块内存的合法边界负有全部责任。分配器只保证"你申请了多少,这块就多大",它不会、也无法阻止你写越界。三大 Bug 的共同本质,都是在"分配器以为的边界"和"你实际写入的范围"之间产生了错位。
三、用 GDB 复现三个 Bug:把错误变成可视化实验
理论讲得再清楚,不如亲手把 Bug 从"玄学崩溃"变成"可见字节"。教材在 background/background.tex 中展示了 GDB 作为"内存显微镜"的用法,正好可以把三大 Bug 逐个可视化。
第一步:让崩溃点可控。GDB 可以中断在任意源码行,甚至在代码里用内联汇编埋断点:
int main() { int val = 1; val = 42; asm("int $3"); // set a breakpoint here val = 7; }$ gcc main.c -g -o main $ gdb --args ./main (gdb) r Program received signal SIGTRAP, Trace/breakpoint trap. main () at main.c:5 5 val = 7; (gdb) p val $1 = 42也可以在启动前用break main.c:4设定断点,运行后用p val检查变量。这为复现三大 Bug 提供了第一步:在写入发生之前停住,观察内存状态。
第二步:用x命令读取任意地址的字节。教材给的例子堪称缓冲区溢出的"慢镜头"——一个忘记 NUL 终止符的三字节字符串:
int main() { char bad_string[3] = {'C', 'a', 't'}; printf("%s", bad_string); }直接运行,printf会越过bad_string一直读到碰巧遇到的下一个 0 字节,输出Cat ZVQ?之类的乱码。用 GDB 停在printf那行,执行x/16xb bad_string:
(gdb) x/16xb bad_string 0x7fff5fbff9cd: 0x63 0x61 0x74 0xe0 0xf9 0xbf 0x5f 0xff 0x7fff5fbff9d5: 0x7f 0x00 0x00 0xfd 0xb5 0x23 0x89 0xff前三个字节0x63 0x61 0x74是Cat的 ASCII 码,从第四个字节开始全是栈上残留的随机数据——printf会把这些字节全部当作字符串内容读出去。这就是"缺少 NUL 终止符"在内存层面的真实模样:你没有越界写,但你的读越界了。
同样的手法可以推广到三大 Bug:
- 悬空指针:
free(p)后停在*p = 123之前,用x/4xb p查看指针指向的内存,再用info proc mappings或检查堆块元数据确认这块内存已被标记为空闲——你能亲眼看到自己正准备写入一块"无主之地"; - 分配不足:对
malloc(sizeof(user))的错误版本,用p sizeof(user)与p sizeof(user_t)对比,再在strcpy后x/120xb user,你会看到数据从第 8 字节开始越过了申请边界,写进了相邻堆块; - 缓冲区溢出:运行 security/security.tex 中的
out/in示例,输入超长字符串后在打印前用x/10cb out检查,溢出改写相邻栈变量的过程一览无余。
教材还提醒了一个环境细节:栈溢出示例在默认编译下可能"不生效",因为编译器默认开启栈保护(stack canary)——一个放在栈上、函数返回前必须保持不变的哨兵值,一旦被溢出改写,程序会立刻以 "stack smashing detected" 中止。复现实验时需要显式关闭:gcc main.c -fno-stack-protector。这本身就是现代编译器对抗缓冲区溢出的一道防线,与 ASLR(地址空间布局随机化)、DEP(数据执行保护)共同构成了教材 security/security.tex 中列出的系统安全机制清单。
结语
把三个 Bug 放在一起看,会发现它们共享同一张"内存地图":栈向下生长放局部变量,堆向上生长放动态分配,字符串靠 NUL 字节界定边界,分配器靠空闲链表记账。悬空指针是"错用已释放的堆块",分配不足是"低估了所需的堆块大小",缓冲区溢出是"无视边界读写"。coursebook 的价值在于它不满足于告诉你"别这么写",而是带着你从 introc/c_memory_model.tex 的内存模型出发,亲手在 malloc/malloc.tex 里实现分配器、用 background/background.tex 的 GDB 技巧观察字节——当你亲眼在调试器里看到溢出写穿了相邻变量、看到 free 后的内存被再次分配,这三个 Bug 就会从"经验之谈"变成"肌肉记忆"。下一次再写malloc(strlen(s))或for(i=N; i>=0; i--)时,停一秒钟,想想那块多出来的 NUL 字节和第 N+1 次循环。
【免费下载链接】coursebookOpen Source Introductory Systems Programming Textbook for the University of Illinois项目地址: https://gitcode.com/GitHub_Trending/co/coursebook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考