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 里。

栈(stack)· 编译器自动管理 func() 的栈帧 int *x 0x5a…2a0 int y 3 函数返回 → 整个栈帧自动消失 堆(heap)· 程序员显式管理 int malloc(sizeof(int)) 其他数据… 直到 free(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 要小心:

int *x = malloc(10 * sizeof(int)); x 40 字节(10 个 int) sizeof(x) → 8 只是"指针有多大"(64 位机上 8,32 位上 4) 不是你动态分配了多少 int x[10]; 40 字节的数组 sizeof(x) → 40 静态信息足够,编译器知道是 40 字节 字符串:用 malloc(strlen(s) + 1),"+1" 是给结尾的 '\0';这里用 sizeof 会出问题
sizeof 对指针和数组给出不同答案;字符串长度要用 strlen(s) + 1

malloc 返回 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() 返回的指针。你会注意到用户并没有传入大小——这意味着内存分配库必须自己记住每块有多大。

空闲空间由分配库管理 malloc(40) 头块 40 字节归你使用 ↓ 返回的指针 x free(x) 回到空闲空间可以再分配 库需要自己记住"这块有多大"——通常存在返回指针前面的头块里(第 17 章细讲)
malloc 返回的指针是 free 唯一需要的信息;大小由分配库自己记录

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);
"hello" 需要 6 字节,只分配了 5;第 6 个字节 '\0' 写进了紧挨着的另一块(q[0] 从 9 被改成 0)

奇怪的是,这种程序常常看起来运行正确:多写的 1 字节有时无害(覆盖了一个没人再用的变量),有时 malloc 本来就多给了一点空间;但有时它极其有害,许多安全漏洞正源于此;还有时程序直接崩溃。教训:跑对过一次,不代表它是对的。

程序编译通过、甚至运行多次都正确,并不意味着它正确。等某天环境变了它就崩了,学生常喊"可是它之前是好的!"然后怪编译器、OS、硬件,甚至教授。问题通常就在你以为的地方——你的代码里。

③ 忘记初始化分配的内存(forgetting to initialize allocated memory)

malloc 给你的内存里是什么都有可能;读一个从未写过的位置 = 未初始化读(uninitialized read)

运气好读到 0 之类让程序照常工作的值,运气不好就是随机而有害的东西。calloc() 在返回前会把内存清零,可以防止"以为内存是 0"这类错误。

④ 忘记释放内存(forgetting to free memory)= 内存泄漏(memory leak)

p 被改指向新块,旧块再也没有指针指向它:泄漏

在长期运行的程序或系统(比如 OS 本身)里这是大问题:内存慢慢漏光,只能重启。注意垃圾回收语言也救不了你:只要你还持有某块内存的引用,GC 就永远不会释放它。

系统里有两级内存管理:第一级由 OS 负责,进程运行时把内存交给它,进程退出(或被杀)时全部收回;第二级在每个进程内部,比如堆里的 malloc()/free()。即使你没 free(堆里泄漏了),进程结束时 OS 会回收它的全部页——代码、栈,以及这里关心的堆。所以对短命程序,泄漏通常不会造成实际问题(虽然是坏风格);但对永不退出的 Web 服务器、数据库,以及 OS 内核自己,泄漏终将导致崩溃。

第一级:操作系统 进程运行时分配内存(代码、栈、堆的页),进程退出时全部收回 进程 A 的地址空间 代码 堆:第二级(malloc/free) 栈 进程 A 退出 → OS 收回整个地址空间,堆里忘了 free 的也一并收回
两级内存管理:malloc/free 管进程内部的堆;OS 管进程整体

⑤ 用完之前就释放(freeing memory before you are done with it)= 悬挂指针(dangling pointer)

free(p) 后 p 仍然拿着旧地址;这块内存又被 malloc 给了 q,通过 p 读到的是 q 写的 42

后续使用可能让程序崩溃,也可能覆盖有效内存——比如你 free 之后又 malloc 了别的东西,分配器恰好回收利用了这块被错误释放的内存。

⑥ 重复释放(freeing memory repeatedly)= double free

同一块 free 两次:结果未定义,分配器内部结构可能被搞乱,常见结局是崩溃

⑦ 错误地调用 free(calling free() incorrectly)= invalid free

free 只接受 malloc 返回的那个指针;传入块中间的地址,分配器会在错误的位置找头块

忘记分配

未初始化指针 → 通常段错误

分配不足

缓冲区溢出 → 可能"正常",可能静默破坏,可能崩溃,可能被攻击

忘记初始化

未初始化读 → 读到不可预知的值

忘记释放

内存泄漏 → 长期运行的程序最终耗尽内存

用完之前释放

悬挂指针 → 崩溃或覆盖别人的数据

重复释放

double free → 未定义,分配器混乱,常崩溃

free 错误指针

invalid free → 危险,务必避免

内存错误如此常见,于是有了一整套工具生态:purify 和 valgrind 都能帮你定位内存问题的源头;本机实验用的是 gcc 自带的 AddressSanitizer(-fsanitize=address)。用惯了之后,你会奇怪以前没有它们是怎么活下来的。

3.5 底层 OS 支持:brk、sbrk、mmap

你可能注意到了:讲 malloc/free 时一直没提系统调用。原因很简单:它们不是系统调用,而是库调用。malloc 库在你的虚拟地址空间里管理空间,而它本身建立在几个向 OS 要内存/还内存的系统调用之上:

进程的虚拟地址空间(低 → 高) 代码 + 数据 堆(malloc 在这里切块) sbrk(+n) 后多出来的部分 program break(堆的末尾) 未使用 mmap 匿名区域 栈 brk(addr):把 break 设到新地址,堆随之变大或变小 sbrk(incr):传入增量,作用类似 mmap():创建一块匿名内存区域(与文件无关, 关联到交换空间),也能当堆来管理 不要自己直接调用 brk / sbrk! 它们是给内存分配库用的;乱调很可能把事情搞砸。 坚持使用 malloc() 和 free()。
malloc 库建立在 brk/sbrk(移动堆顶)和 mmap(匿名映射)之上。glibc 对大请求(默认 ≥128KB)直接用 mmap——第 13 章实验里 malloc(100e6) 落在高地址就是这个原因

3.6 其他调用:calloc 和 realloc

4模拟器:堆操作沙盒

模拟器

malloc / free / 读写:一步一步看堆的变化

选一个预设程序(或自己改代码),单步执行。上方是栈上的变量及它们指向哪里,下方是堆的每个格子。出现七类错误时会用红框标出位置,并告诉你真实程序会怎样、valgrind/ASan 会怎么说。
✎ 编辑代码 / 自己写程序
支持: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 = "...";// 注释
0
仍在使用的块
0
已 free 的块
0
泄漏的字节
0
检测到的错误

简化说明:堆共 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 当初写的值)还是别的?
看答案
得到 42。free(p) 后分配器把同一块地址又给了 q,q[0] = 42 覆盖了原值。在本机实验 dangling.c 里也是这样:data=0x5b597e93a2a0 other=0x5b597e93a2a0 data[5]=12345,两次 malloc 拿到了同一个地址。
(原书作业 5)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。程序显然不正确。沙盒里的"数组越界"预设演示了同样的事。
(原书作业 4)只 malloc 不 free 的程序,运行时会出什么问题?gdb 能帮你发现吗?valgrind(--leak-check=yes)或 ASan 呢?
看答案
运行时什么问题也没有(进程退出时 OS 回收全部内存)。gdb 也看不出问题——程序没有崩溃。只有 valgrind(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段错误,退出码 139SEGV on unknown address 0x000000000000无警告
forget_alloc忘记分配段错误,退出码 139SEGV(WRITE memory access)‘dst’ is used uninitialized
too_small分配不足正常打印 dst = hello,退出码 0heap-buffer-overflow,WRITE of size 6无警告
overflowdata[100] 越界正常,退出码 0heap-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忘记释放正常,退出码 0LeakSanitizer:Direct leak of 3072 byte(s)无警告
dangling悬挂指针正常,读到了别人写的 12345heap-use-after-freepointer ‘data’ used after ‘free’
double_free重复释放glibc:free(): double free detected in tcache 2,退出码 134attempting double-freepointer ‘x’ used after ‘free’
bad_freefree 错误指针glibc:free(): invalid pointer,退出码 134attempting 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 位机器上打印什么?
sizeof(x) 问的是"指向 int 的指针有多大",不是你动态分配了多少。只有 int x[10] 这种静态数组,sizeof 才会给出 40。
为什么 free() 不需要传入大小?
原书:大小不由用户传入,必须由内存分配库自己追踪。第 17 章会看到它通常藏在返回指针之前的头块里。
char *dst = malloc(strlen(src)); strcpy(dst, src); 最准确的描述是:
strlen 不含 '\0',应该是 strlen(src)+1。原书强调这类程序"often run seemingly correctly",本机实验也证实普通运行毫无异常。
关于短命程序不调用 free,下列哪项符合原书观点?
两级内存管理:OS 在进程退出时收回全部页(包括堆)。但作者建议养成好习惯,free 每一个显式分配的字节。
关于垃圾回收(GC)语言,哪项正确?
原书:"if you still have a reference to some chunk of memory, no garbage collector will ever free it"。
malloc() 和 free() 是什么?
原书 14.5:它们不是系统调用,而是库调用。
free(p) 之后又通过 p 读数据,最可能发生什么?
free 后内存可能被重新分配,旧指针读到的是新主人的数据(本机实验:12345)。gcc 14 的 -Wall 有时能在编译期警告 use-after-free,但不是所有情况都能发现。
下面哪个调用是合法且无害的?
C 标准规定 free(NULL) 什么也不做。A 是 double free,C 和 D 都是 invalid 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(指针) 能告诉我分配了多少。"它只给出指针本身的大小。

本章小结