1一句话 + 关键问题
如何分配和管理内存?在 UNIX/C 程序里,理解怎样分配和管理内存是写出健壮、可靠软件的关键。常用的接口有哪些?哪些错误必须避免?
原文:In UNIX/C programs, understanding how to allocate and manage memory is critical in building robust and reliable software. What interfaces are commonly used? What mistakes should be avoided?
第 13 章从 OS 的角度定义了地址空间;这一章换到程序员的角度:在自己的地址空间里,怎样用 malloc() / free() 申请和归还内存,以及最常见的七种错误。原书说这一章"短而尖锐"。
它承上启下:你会看到 malloc 必须自己记住每块的大小(第 17 章会揭晓它把大小藏在头块(header)里),也会看到 malloc 不是系统调用,而是建立在 brk/sbrk/mmap 之上的库。
2核心概念速览
栈内存(stack memory)
由编译器隐式分配和回收,也叫自动内存(automatic memory)。函数返回,局部变量就没了。
堆内存(heap memory)
由程序员显式 malloc/free,生命周期由你决定。责任重大,也是大量 bug 的源头。
malloc(size)
传入字节数,成功返回指向新空间的指针(void *),失败返回 NULL。用 sizeof 算大小。
free(ptr)
只传 malloc 返回的指针,不传大小——大小由分配库自己记录。
七种常见错误
忘记分配、分配不足、忘记初始化、忘记释放、用完之前释放(悬挂指针)、重复释放、free 错误指针。
能编译 ≠ 能运行 ≠ 正确
这些错误大多"一声不吭"地通过编译,甚至运行正常。要靠 valgrind、AddressSanitizer 等工具抓出来。
brk / sbrk / mmap
malloc 是库函数,不是系统调用;它通过 brk/sbrk 移动堆顶(break),或用 mmap 向 OS 要匿名内存。
calloc / realloc
calloc 分配并清零;realloc 分配更大的新区域、拷贝旧数据、返回新指针。
3图解:两种内存与 malloc/free
3.1 栈 vs. 堆
运行 C 程序时有两种内存。栈内存的分配和释放由编译器替你完成:
void func() {
int x; // 在栈上声明一个整数
...
} // 返回时编译器自动回收 x
调用 func() 时编译器在栈上腾出空间,返回时收回。所以想让信息活得比这次调用更久,就不能放在栈上。这种对长寿内存的需求引出了堆内存,分配和释放完全由你显式负责:
void func() {
int *x = (int *) malloc(sizeof(int));
...
}
注意这一行里栈分配和堆分配同时发生:编译器看到 int *x 就在栈上给指针留位置;程序调用 malloc() 在堆上申请一个整数的空间,返回它的地址(失败则返回 NULL),这个地址再存到栈上的 x 里。
int *x = malloc(sizeof(int)):指针 x 本身在栈上,它指向的整数在堆上3.2 malloc():告诉它你要几个字节
#include <stdlib.h>
void *malloc(size_t size);
包含 stdlib.h 其实不是必需的(malloc 的代码就在所有 C 程序默认链接的 C 库里),它只是让编译器检查你调用得对不对。参数 size_t size 就是需要的字节数。手写数字(如 10)被认为是糟糕的风格,应该用 sizeof:
double *d = (double *) malloc(sizeof(double)); // sizeof(double) 在编译期就是 8
sizeof() 是编译期运算符,不是函数调用。但对变量用 sizeof 要小心:
sizeof 对指针和数组给出不同答案;字符串长度要用 strlen(s) + 1malloc 返回 void *——C 里就是这样把一个地址交还给你,由你决定怎么用。书里的 (double *) 这种强制类型转换(cast)对正确性毫无作用,只是告诉编译器和读代码的人"我知道我在做什么"。
拿不准就试一试(When in doubt, try it out)。读 man 手册有用,但它在实践中怎么表现才重要。作者自己也是写代码验证了关于 sizeof 的说法。
3.3 free():只给指针,不给大小
int *x = malloc(10 * sizeof(int));
...
free(x);
分配是容易的部分;何时、如何、甚至是否要释放才是难点。free() 只接受一个参数:之前 malloc() 返回的指针。你会注意到用户并没有传入大小——这意味着内存分配库必须自己记住每块有多大。
3.4 七种常见错误(每种一张图)
下面这些错误原书作者在本科 OS 课上"一遍又一遍"地见到。它们全部能编译通过、跑起来也可能毫无动静——编译通过是写出正确 C 程序的必要条件,但远远不够。正因为内存管理太容易出错,许多新语言提供了自动内存管理:你 new 对象,但从不需要释放,由垃圾回收器(garbage collector)找出不再被引用的内存替你释放。
下面每张图都是用第 4 节的"堆操作沙盒"跑出来的出错那一刻:上方是栈上的变量,下方是堆(每格代表一个单元),红框是出事的位置。
① 忘记分配内存(forgetting to allocate memory)
char *src = "hello";
char *dst; // oops! unallocated
strcpy(dst, src); // segfault and die
dst 从没被赋值,里面是垃圾;strcpy 往一个随机地址写 → 段错误(segmentation fault)段错误(segmentation fault)是"你在内存上干了蠢事,我很生气"的花哨说法(第 16 章会解释这个名字的来历)。正确写法是 char *dst = (char *) malloc(strlen(src) + 1);,或者直接用 strdup()。
② 分配不足(not allocating enough memory)= 缓冲区溢出(buffer overflow)
char *src = "hello";
char *dst = (char *) malloc(strlen(src)); // too small! 少了 '\0' 那 1 个字节
strcpy(dst, src);
奇怪的是,这种程序常常看起来运行正确:多写的 1 字节有时无害(覆盖了一个没人再用的变量),有时 malloc 本来就多给了一点空间;但有时它极其有害,许多安全漏洞正源于此;还有时程序直接崩溃。教训:跑对过一次,不代表它是对的。
程序编译通过、甚至运行多次都正确,并不意味着它正确。等某天环境变了它就崩了,学生常喊"可是它之前是好的!"然后怪编译器、OS、硬件,甚至教授。问题通常就在你以为的地方——你的代码里。
③ 忘记初始化分配的内存(forgetting to initialize allocated memory)
运气好读到 0 之类让程序照常工作的值,运气不好就是随机而有害的东西。calloc() 在返回前会把内存清零,可以防止"以为内存是 0"这类错误。
④ 忘记释放内存(forgetting to free memory)= 内存泄漏(memory leak)
在长期运行的程序或系统(比如 OS 本身)里这是大问题:内存慢慢漏光,只能重启。注意垃圾回收语言也救不了你:只要你还持有某块内存的引用,GC 就永远不会释放它。
系统里有两级内存管理:第一级由 OS 负责,进程运行时把内存交给它,进程退出(或被杀)时全部收回;第二级在每个进程内部,比如堆里的 malloc()/free()。即使你没 free(堆里泄漏了),进程结束时 OS 会回收它的全部页——代码、栈,以及这里关心的堆。所以对短命程序,泄漏通常不会造成实际问题(虽然是坏风格);但对永不退出的 Web 服务器、数据库,以及 OS 内核自己,泄漏终将导致崩溃。
⑤ 用完之前就释放(freeing memory before you are done with it)= 悬挂指针(dangling pointer)
后续使用可能让程序崩溃,也可能覆盖有效内存——比如你 free 之后又 malloc 了别的东西,分配器恰好回收利用了这块被错误释放的内存。
⑥ 重复释放(freeing memory repeatedly)= double free
⑦ 错误地调用 free(calling free() incorrectly)= invalid free
忘记分配
分配不足
忘记初始化
忘记释放
用完之前释放
重复释放
free 错误指针
内存错误如此常见,于是有了一整套工具生态:purify 和 valgrind 都能帮你定位内存问题的源头;本机实验用的是 gcc 自带的 AddressSanitizer(-fsanitize=address)。用惯了之后,你会奇怪以前没有它们是怎么活下来的。
3.5 底层 OS 支持:brk、sbrk、mmap
你可能注意到了:讲 malloc/free 时一直没提系统调用。原因很简单:它们不是系统调用,而是库调用。malloc 库在你的虚拟地址空间里管理空间,而它本身建立在几个向 OS 要内存/还内存的系统调用之上:
malloc(100e6) 落在高地址就是这个原因3.6 其他调用:calloc 和 realloc
calloc():分配内存并在返回前清零,避免"以为内存是零"却忘了初始化的错误。realloc():已经分配了一块(比如数组),需要更多空间时用:它分配一块更大的新区域、把旧内容拷过去、返回新指针。旧指针随之失效——继续用它就是悬挂指针。
4模拟器:堆操作沙盒
malloc / free / 读写:一步一步看堆的变化
✎ 编辑代码 / 自己写程序
int *p = malloc(n) calloc(n) free(p) p[i] = v *p = v int x = p[i] print(p[i]) strcpy(p, "..") q = p + k p = NULL char *d; char *s = "...";// 注释简化说明:堆共 32 格、从地址 1000 开始、首次适配(first fit),每格代表一个元素(sizeof(int) 按 1 计),不画头块。真实的 malloc 会有头块和对齐,但出错的道理一样。
5先预测,再验证
print(q[0]) 之前,先预测会打印什么?q 自己从没被越界访问过。看答案
'\0'(也就是 0),而不是之前写入的 9。dst 只分配了 5 格,strcpy 写 6 个字节,最后的 '\0' 落在紧挨着的 q[0] 上。q 的主人什么都没做错,数据却被别人悄悄改了——这类 bug 最难查,因为出错的地方和症状出现的地方离得很远。int x = p[0] 这一步,x 会得到 1(p 当初写的值)还是别的?看答案
dangling.c 里也是这样:data=0x5b597e93a2a0 other=0x5b597e93a2a0 data[5]=12345,两次 malloc 拿到了同一个地址。int *data = malloc(100 * sizeof(int)); data[100] = 0; 直接运行会怎样?用工具运行呢?程序正确吗?看答案
wrote data[100], program still running,退出码 0),因为 malloc 的块后面往往还有点余量。用 ASan 运行立刻报 heap-buffer-overflow … 0 bytes after 400-byte region。程序显然不正确。沙盒里的"数组越界"预设演示了同样的事。--leak-check=yes)或 ASan 呢?看答案
definitely lost)或 LeakSanitizer 能报告:本机 leak-asan 输出 Direct leak of 3072 byte(s) in 3 object(s)。沙盒里"忘记释放"预设在 p 被改写的那一刻就标记了泄漏。int *p = malloc(4); free(p); p = NULL; free(p);。第二个 free 会报 double free 吗?看答案
free(NULL) 是合法的空操作。这就是"free 之后立刻把指针置为 NULL"这个好习惯的价值:它把悬挂指针和重复释放都变成了无害(或立即可见的 NULL 解引用)。6动手实验:用工具抓出内存错误
实验目录 labs/14-memory-api/:9 个小程序,每个对应一类错误(含原书作业 1、4、5、6、7)。Makefile 为每个程序编译两份:普通版 xxx 和 AddressSanitizer 版 xxx-asan。本机没有安装 valgrind,所以用 gcc 自带的 -fsanitize=address(包含 LeakSanitizer);如果你装了 valgrind,也可以运行 valgrind --leak-check=yes ./xxx。
cd labs/14-memory-api && make
./overflow ; echo "exit=$?" # 普通版:看起来没事
./overflow-asan # ASan 版:当场抓住
make run # 依次运行全部 18 个版本
下表是本机(gcc 14,WSL2)的真实结果。示例输出,你的机器上地址会不同:
| 程序 | 错误类型 | 普通运行 | ASan 版 | gcc -Wall 编译期 |
|---|---|---|---|---|
null | 解引用 NULL | 段错误,退出码 139 | SEGV on unknown address 0x000000000000 | 无警告 |
forget_alloc | 忘记分配 | 段错误,退出码 139 | SEGV(WRITE memory access) | ‘dst’ is used uninitialized |
too_small | 分配不足 | 正常打印 dst = hello,退出码 0 | heap-buffer-overflow,WRITE of size 6 | 无警告 |
overflow | data[100] 越界 | 正常,退出码 0 | heap-buffer-overflow,0 bytes after 400-byte region | 无警告 |
uninit | 未初始化读 | 读到旧数据:a = -26023441 5 0 0 7 7 7 7 | 不报错(ASan 把新内存填成 0xbe) | may be used uninitialized |
leak | 忘记释放 | 正常,退出码 0 | LeakSanitizer:Direct leak of 3072 byte(s) | 无警告 |
dangling | 悬挂指针 | 正常,读到了别人写的 12345 | heap-use-after-free | pointer ‘data’ used after ‘free’ |
double_free | 重复释放 | glibc:free(): double free detected in tcache 2,退出码 134 | attempting double-free | pointer ‘x’ used after ‘free’ |
bad_free | free 错误指针 | glibc:free(): invalid pointer,退出码 134 | attempting free on address which was not malloc()-ed | ‘free’ called on pointer with nonzero offset 200 |
几段关键输出。分配不足(too_small.c):普通版一切正常,ASan 版:
$ ./too_small
dst = hello
$ ./too_small-asan
==345305==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000015 ...
WRITE of size 6 at 0x502000000015 thread T0
#0 0x74982fea2680 in strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:563
#1 0x5ad467a921f6 in main too_small.c:8
0x502000000015 is located 0 bytes after 5-byte region [0x502000000010,0x502000000015)
allocated by thread T0 here:
#1 0x5ad467a921df in main too_small.c:7
ASan 精确地告诉你:第 8 行的 strcpy 往一个 5 字节的块写了 6 字节,块是第 7 行分配的。悬挂指针(dangling.c):
$ ./dangling
data=0x5b597e93a2a0 other=0x5b597e93a2a0 data[5]=12345
$ ./dangling-asan
==345319==ERROR: AddressSanitizer: heap-use-after-free on address 0x514000000054 ...
READ of size 4 at 0x514000000054 thread T0
#0 0x563a582c02d0 in main dangling.c:10
0x514000000054 is located 20 bytes inside of 400-byte region [0x514000000040,0x5140000001d0)
freed by thread T0 here:
#1 0x563a582c023e in main dangling.c:7
previously allocated by thread T0 here:
#1 0x563a582c01ca in main dangling.c:5
普通版里 data 和 other 是同一个地址:free 掉的内存马上被下一次 malloc 回收,通过旧指针读到了新主人写的 12345。内存泄漏(leak.c):
$ ./leak-asan
exiting without free
=================================================================
==345315==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 3072 byte(s) in 3 object(s) allocated from:
#0 0x7b83eb8f4c57 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x6149649971b3 in main leak.c:7
SUMMARY: AddressSanitizer: 3072 byte(s) leaked in 3 allocation(s).
重复释放:glibc 自己就有检查,不用工具也会中止:
$ ./double_free
freed once, freeing again...
free(): double free detected in tcache 2
Aborted (退出码 134 = 128 + SIGABRT)
观察要点:(1) "不带工具运行正常"的有 4 个(too_small、overflow、leak、dangling),这正是原书反复强调的"能跑 ≠ 正确";(2) ASan 抓不到未初始化读,valgrind 的 memcheck 可以;(3) 现代 gcc 的 -Wall 已经能在编译期发现一部分(未初始化指针、use-after-free、free 非堆地址),所以永远打开 -Wall,并认真看警告。
7自测题
int *x = malloc(10 * sizeof(int)); printf("%zu", sizeof(x)); 在 64 位机器上打印什么?int x[10] 这种静态数组,sizeof 才会给出 40。free() 不需要传入大小?char *dst = malloc(strlen(src)); strcpy(dst, src); 最准确的描述是:malloc() 和 free() 是什么?8常见误区与小结
误区 1:"程序跑了好几次都对,所以没 bug。"分配不足、越界、泄漏、悬挂指针在普通运行时经常毫无症状。要用 valgrind / ASan 验证。
误区 2:"malloc 的内存是 0。"不保证。刚从 OS 拿来的新页通常是 0,但复用的块里是旧数据(本机 uninit 实验读到了 7 7 7 7)。需要 0 就用 calloc。
误区 3:"free 之后指针就变成 NULL 了。"不会。指针里仍是旧地址,需要自己写 p = NULL。
误区 4:"(int *) 这种强制转换让 malloc 更安全。"cast 对正确性没有作用,只是告诉读者"我知道我在做什么"。
误区 5:"sizeof(指针) 能告诉我分配了多少。"它只给出指针本身的大小。
本章小结
- 两种内存:栈(编译器自动管理,随函数返回消失)和堆(程序员用 malloc/free 显式管理)。
malloc(size)用sizeof计算字节数,字符串用strlen(s)+1;free(ptr)只接受 malloc 返回的指针,大小由库记录。- 七种常见错误:忘记分配、分配不足、忘记初始化、忘记释放、过早释放(悬挂指针)、重复释放、free 错误指针。它们都能通过编译,许多还能"正常运行"。
- malloc/free 是库调用,建立在
brk/sbrk/mmap之上;不要直接调用 brk/sbrk。还有calloc(清零)和realloc(扩容并拷贝)。 - 下一步:第 15 章回到 OS 视角,看硬件怎样把虚拟地址翻译成物理地址(基址 + 界限);第 17 章揭开 malloc 内部:空闲链表、头块、分割与合并。