内存管理基础 —— 进程内存布局与 malloc 全家桶
C 程序员吃过最多的亏都是内存:栈溢出、堆泄漏、free 后再用、double free、悬空指针。这篇按"进程内存布局 → 栈 vs 堆 → malloc/free 怎么工作 → 常见内存错误 → 内存分配器选型"五步讲清楚,读完后再写 C,对每一个变量 / 每一次 malloc 心里有数。
0. 引言:这个 bug 在哪?
char*get_name(void){charbuf[64];strcpy(buf,"alice");returnbuf;// ⛔ 返回栈上指针}intmain(void){char*name=get_name();printf("%s\n",name);// 可能打印对,可能崩,看运气return0;}类似的还有:
char*p=malloc(100);free(p);p[0]='X';// ⛔ use-after-freechar*q=malloc(100);free(q);free(q);// ⛔ double free这些坑都和"内存到底在哪"有关。下面把 Linux 进程内存布局拆开看。
1. 进程内存布局
一个 C 程序运行时,进程地址空间从低到高大致:
各区对应(结合 19 篇 ELF):
| 区 | 对应 ELF 节 | 谁分配 | 谁释放 |
|---|---|---|---|
| .text | .text | OS 加载 | 进程退出 |
| .rodata | .rodata | OS 加载 | 进程退出 |
| .data | .data | OS 加载并初始化 | 进程退出 |
| .bss | .bss | OS 加载并清零 | 进程退出 |
| Heap | — | malloc / brk / mmap | free / munmap |
| Stack | — | 编译器自动 | 函数返回自动 |
| 共享库 mmap | 各 .so 段 | ld.so | 进程退出 |
2. 栈(Stack)
2.1 行为
| 特性 | 说明 |
|---|---|
| 每个线程一个栈 | pthread 默认 8MB(详见第 1 篇) |
| 函数调用压栈帧 | 帧内含参数、返回地址、局部变量 |
| 函数返回弹栈帧 | 自动恢复 SP,局部变量随之失效 |
| 栈向下生长 | 高地址 → 低地址 |
2.2 局部变量在栈上
voidf(intn){intlocal_a=10;// 栈charbuf[1024];// 栈structpointp={1,2};// 栈}// f 返回后,local_a / buf / p 全部失效2.3 栈的优势
| 优势 | 说明 |
|---|---|
| 零开销分配 | 编译期算好偏移,函数入口一次性SP -= N完成 |
| 自动释放 | 函数返回SP += N,无需手动free |
| 缓存友好 | 连续地址,CPU cache 命中率高 |
2.4 栈的局限
| 局限 | 说明 |
|---|---|
| 生命周期受限 | 函数返回就没了 |
| 大小有限 | 默认 8MB,超了SIGSEGV(栈溢出) |
| 递归危险 | 每次调用一帧,深递归爆栈 |
2.5 栈大小调整
# 查看ulimit-s# 8192 (KB)# 临时改ulimit-s16384# 16MB# 永久改 /etc/security/limits.conf线程级也能在 pthread_create 前设置:
pthread_attr_tattr;pthread_attr_init(&attr);pthread_attr_setstacksize(&attr,16*1024*1024);// 16MBpthread_create(&tid,&attr,fn,NULL);2.6 栈溢出表现
voidrecur(intn){charbig[1024];recur(n+1);}intmain(void){recur(0);}// Segmentation fault (core dumped)或者大局部数组:
voidf(void){charbuf[10*1024*1024];// 10MB > 8MB → 栈溢出}经验法则:单函数局部变量总和 ≤ 几 KB;大数据用 malloc 走堆。
3. 堆(Heap)
3.1 malloc / free / realloc / calloc
#include<stdlib.h>void*malloc(size_tsize);// 分配,未初始化void*calloc(size_tnmemb,size_tsize);// 分配 + 清零void*realloc(void*ptr,size_tsize);// 重新分配voidfree(void*ptr);// 释放int*arr=malloc(100*sizeof(int));if(!arr){/* 失败处理 */}arr[0]=1;free(arr);// 清零版(适合数组初始化)int*zarr=calloc(100,sizeof(int));// 全 0// 扩容arr=realloc(arr,200*sizeof(int));// 可能换新地址3.2 必背规则
| 规则 | 说明 |
|---|---|
malloc必检查返回值 | 内存不足返回 NULL |
free(NULL)安全 | 标准保证什么都不做 |
同一指针只free一次 | 重复 free → double free 崩 |
free后立即ptr = NULL | 防 use-after-free |
realloc(p, 0)行为依实现 | 可能返回 NULL,可能等于 free |
realloc失败原指针仍有效 | 旧块不丢 |
3.3 malloc 内部 —— brk / mmap 双轨
malloc不直接调系统调用——每次malloc(8)都不会去内核要内存,有内存池。底层实际用:
| 块大小 | 分配方式 | 说明 |
|---|---|---|
| 小块(默认 ≤ 128KB) | brk()调整堆顶 | heap 区从.bss之上往上长 |
| 大块(> 128KB) | 直接mmap一段匿名内存 | 独立映射,不进堆 |
用户层free把内存还给 malloc 池,不一定立即归还给 OS。所以top显示进程 RSS 居高不下,但 valgrind 又没找到泄漏 ——碎片+不归还给 OS。
3.4 看 malloc 行为
#include<malloc.h>malloc_stats();// 把统计信息打到 stderr或者mallinfo2()拿到结构体编程使用。
3.5 阈值控制
#include<malloc.h>mallopt(M_MMAP_THRESHOLD,64*1024);// 改成 ≥64KB 走 mmapmallopt(M_TRIM_THRESHOLD,128*1024);// free 后超过这值才 trim 还 OS4. 常见内存错误
4.1 内存泄漏(Memory Leak)
void*p=malloc(100);// 忘记 freereturn;少量泄漏看不出来,长跑服务 RSS 持续上涨 → OOM。
抓泄漏:valgrind / AddressSanitizer / jemalloc heap profile。详见 29 篇。
4.2 use-after-free
char*p=malloc(100);free(p);strcpy(p,"hi");// ⛔ p 已经无效最阴险的 bug:有时正常有时崩,因为内存可能被 malloc 重用。
防御:
free(p);p=NULL;// 立即置空// 之后 p[0] 访问 → SIGSEGV,立刻发现4.3 double free
char*p=malloc(100);free(p);free(p);// ⛔ 崩或破坏堆// glibc 报: free(): double free detected4.4 悬空指针(Dangling Pointer)
char*create(void){charbuf[100];// 栈strcpy(buf,"hi");returnbuf;// ⛔ 返回时 buf 已失效}或者:
char*p=malloc(100);char*alias=p;free(p);strcpy(alias,"hi");// ⛔ alias 也是悬空4.5 堆缓冲区溢出
char*p=malloc(10);strcpy(p,"this is a long string");// ⛔ 溢出破坏堆元数据,下次 malloc 可能崩。
4.6 栈缓冲区溢出
voidf(constchar*input){charbuf[10];strcpy(buf,input);// ⛔ input 太长 → 栈溢出,可能改返回地址}经典安全漏洞。Linux 现代防御:栈金丝雀(canary)+ NX bit + ASLR。
4.7 未初始化读取
charbuf[100];printf("%s\n",buf);// ⛔ buf 是栈上垃圾值或:
char*p=malloc(100);// malloc 不清零printf("%s\n",p);// ⛔ p 内容是垃圾要清零用calloc或memset(p, 0, 100)。
5. malloc 的安全 wrapper
工程实践常包一层:
void*xmalloc(size_tsize){void*p=malloc(size);if(!p){fprintf(stderr,"out of memory\n");abort();}returnp;}void*xcalloc(size_tn,size_ts){void*p=calloc(n,s);if(!p)abort();returnp;}void*xrealloc(void*p,size_tsize){void*q=realloc(p,size);if(!q&&size)abort();returnq;}理由:
| 出发点 | 论据 |
|---|---|
| 简化代码 | 把"出错检查"统一做掉,主流程代码更干净 |
| 场景合理 | 大多数程序内存不足就该退出,没法优雅恢复 |
| 行业先例 | vim、git、tmux 都有自己的xmalloc |
6. 内存分配器选型
glibc 默认是 ptmalloc2,多线程场景碎片严重。生产服务常换:
| 分配器 | 特点 | 用法 |
|---|---|---|
| ptmalloc2 | glibc 默认,通用 | 默认 |
| jemalloc | Facebook 出品,多线程友好 | LD_PRELOAD=/usr/lib/libjemalloc.so ./prog |
| tcmalloc | Google 出品,速度快 | LD_PRELOAD=/usr/lib/libtcmalloc.so ./prog |
| mimalloc | 微软新秀,性能极佳 | LD_PRELOAD=/usr/lib/libmimalloc.so ./prog |
切换很简单:
# 安装 jemallocsudoaptinstalllibjemalloc-dev# 用 LD_PRELOAD 替换LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./myapp或者编译时链接:
gcc main.c-omyapp-ljemalloc实测:高频 malloc/free 的多线程服务,换 jemalloc 性能常提升 10-30%,内存碎片显著降低。Redis、Nginx、Rust runtime 都支持选择分配器。
7. 自己写对象池 / 内存池
频繁 malloc 同尺寸对象时,对象池比 malloc 快 10-100 倍:
typedefstructchunk{structchunk*next;chardata[64];}chunk_t;staticchunk_t*freelist=NULL;chunk_t*pool_alloc(void){if(freelist){chunk_t*c=freelist;freelist=c->next;returnc;}returnmalloc(sizeof(chunk_t));}voidpool_free(chunk_t*c){c->next=freelist;freelist=c;}进阶:arena 分配器(一次性分配大块,像栈一样递增分配):
typedefstruct{char*base;size_tsize,used;}arena_t;void*arena_alloc(arena_t*a,size_tn){if(a->used+n>a->size)returnNULL;void*p=a->base+a->used;a->used+=n;returnp;}// 不能单独 free,整个 arena 一锅释放适合"批量分配 + 一次性释放"场景:HTTP 请求处理、编译器 AST 等。Postgres、Apache、nginx 都内置这类 arena。
8. 内存对齐
C 结构体不是简单"字段大小相加":
structs{chara;// 1intb;// 4,要 4 字节对齐charc;// 1};// sizeof(struct s) = 12,不是 6布局:
| 项 | 内容 |
|---|---|
| 偏移 0 | a (1) |
| 偏移 1-3 | padding |
| 偏移 4 | b (4) |
| 偏移 8 | c (1) |
| 偏移 9-11 | tail padding (对齐 sizeof 到 4) |
调整字段顺序节省内存:
structs2{intb;// 4chara;// 1charc;// 1};// sizeof(struct s2) = 8强制不对齐(极少用):
structs__attribute__((packed)){...};// sizeof = 6注意:packed 结构访问慢(CPU 不喜欢未对齐),跨平台未必兼容。
详见 20 篇 C 语言常见坑。
9. valgrind 抓内存错误
终极武器:valgrind --tool=memcheck --leak-check=full ./myapp
10. 总结
进程内存布局速记:
API 速记:
| API | 用途 |
|---|---|
malloc(n) | 分配 n 字节,未初始化 |
calloc(n, sz) | 分配 + 清零 |
realloc(p, n) | 改大小 |
free(p) | 释放,free(NULL)安全 |
避坑口诀:
- malloc必检查返回值
- free 后立即置 NULL
- 同一指针只 free 一次
- 局部 char 数组别返回
- 大对象走堆,不要堆栈
- malloc + memset 就是 calloc
- 怀疑内存问题valgrind / ASan
金句:内存是 C 程序的命门,搞懂"在哪 / 谁分配 / 谁释放"三件事,半数 bug 不会出现。