一次 malloc(16),到底是问谁要的内存
🎯 交互式可视化:点击这里体验 malloc 分配内存的全流程动画 你可以一步步推进指令,实时看指针怎么从 libc 的池子里拿到一块堆地址、数据怎么写进堆、free 之后这块地又怎么变回空闲。
我们写 int *p = malloc(16); 的时候,脑子里想的是"要 16 个字节"。但这句话背后藏着一连串没人告诉你的事:这 16 字节是从哪冒出来的?是 CPU 变出来的,还是内存条上某一格?是马上就给你,还是先打张白条?free 之后它又去哪了?
上一篇《一个 7 行的 C 函数,是怎么一路变成 CPU 上的电信号的》我们把"调用一个函数"拆到了字节:参数怎么进寄存器、栈帧怎么起落、call/ret 怎么一压一弹。那篇里所有变量都老老实实待在栈上,出了函数就没。这篇换一种内存:堆——你主动开口要、用完得自己还的那种。
主角就这么三行:
int *p = malloc(16); // 要一块 16 字节的内存,把地址记在 p 里
p[0] = 42; // 往这块内存写个 42
free(p); // 用完,还回去
放进一个能编译的最小程序:
#include <stdlib.h>
int main(void){
int *p = malloc(16);
p[0] = 42;
free(p);
return p[0];
}
老规矩,本文的汇编和机器码都是下面这条命令真编译出来的(和上一篇同一套:x86-64 / System V ABI,绝大多数 Linux 和教材里用的那套),复制即可复现:
clang -target x86_64-linux-gnu -O0 -fno-asynchronous-unwind-tables -S malloc.c
两点先说清,免得后面误会。第一,汇编和机器码是真编出来的,一个字没改。但
malloc内部怎么运转、它什么时候去找操作系统、堆地址具体是哪个0x555…——这些是我给的示意值。原因有二:一是malloc的实现藏在 libc 库里,不在我们这段代码里(汇编里你只会看到一条callq malloc,跳进去就是别人写好的几千行);二是真实地址受地址随机化影响,每次运行都不同。所以还是那句话:看关系,不看具体数字——返回的指针永远落在%rax、写堆永远是一次"解引用"、第一次写永远才真正惊动操作系统。第二,-O0关掉优化,编译器才会老老实实一行 C 对一段指令,不耍聪明。
一、malloc(16) 在汇编里,其实只有两行
先看 int *p = malloc(16); 被编译成了什么。下面这段是上面那条命令的真实输出(删掉了 .file、.size 这类给汇编器看的杂项,只留指令):
main:
pushq %rbp # ┐ 函数序言:和上一篇一模一样
movq %rsp, %rbp # ┘ 立基准线
subq $16, %rsp # 给局部变量挖 16 字节栈空间
movl $0, -4(%rbp) # 返回值槽填 0(编译器例行公事)
movl $16, %edi # ① 把参数 16 放进 %edi
callq malloc@PLT # ② 调用 malloc
movq %rax, -16(%rbp) # ③ 把返回的指针存到栈上的 p
...
int *p = malloc(16) 这句 C,核心就 ①②③ 三条。先认前两条——它俩你在上一篇见过同样的套路:
① movl $16, %edi:把 16 放进 %edi。还记得上一篇的"调用约定"吗?第一个整型参数走 %edi。malloc 只要一个参数(要多少字节),所以 16 就进了 %edi,等着被 malloc 取走。
② callq malloc@PLT:调用 malloc。和上一篇 callq add 是一码事——压返回地址当路标、%rip 跳进 malloc。唯一的不同是这个 @PLT 后缀,还有一件更值得停下来想的事:
malloc 这个函数,我们没写过。add 是我们自己定义的,源码就在隔壁;malloc 的源码不在我们这个文件里,它躺在系统的 C 标准库(glibc)里,早就编译好了。callq malloc@PLT 这一跳,跳进的是别人写好的几千行代码——@PLT 那点机关(过程链接表)是动态链接用来"找到库里那个 malloc 到底在哪"的中转站,细节按下不表,你只需要知道:这一跳,控制权离开了我们的代码,进了 libc 的地盘。
它在 libc 里折腾一圈,最后会带一个东西回来——一块 16 字节内存的地址。这就是第三条:
③ movq %rax, -16(%rbp):把 %rax 里的值存到栈上 p 的位置(-16(%rbp))。
%rax 是返回值信箱——这点和上一篇 add 把结果放进 %eax 一样,只不过这次返回的是个 64 位指针(一个内存地址),得用满整个 64 位的 %rax,而不是 32 位的 %eax。malloc 走之前,把"我给你划的那块地的门牌号"放进了 %rax;main 回来第一件事,就是把这个门牌号抄到自己栈上的 p 里存好。
所以 int *p = malloc(16) 翻成大白话是:把 16 报给 malloc → malloc 跑去搞到一块地、把地址塞进信箱 %rax → 我把地址从信箱抄进变量 p。 注意此刻 p 里存的只是一个地址,那块真正的 16 字节内存在别处——在哪?这就是下一节的事。
二、p[0] = 42 写的不是栈,是另一个世界
接着往下看 p[0] = 42; 编译成了什么:
movq -16(%rbp), %rax # ④ 把 p(地址)从栈读回 %rax
movl $42, (%rax) # ⑤ 把 42 写到「%rax 指向的那块内存」
④ movq -16(%rbp), %rax:把刚存进 p 的那个地址,再读回 %rax。没什么玄机,要用它了,先抓回手里。
⑤ movl $42, (%rax):关键就在这条。注意写法——上一篇我们见的全是 movl $0, -4(%rbp) 这种,目标是"%rbp 往下数几个字节",那是栈。这次目标是 (%rax),带括号——意思完全不同:不是写进 %rax 这个寄存器,而是写进"%rax 里那个地址所指向的内存"。
这个括号,就是 C 里的 *p(解引用)落到汇编的样子。%rax 里装的是 malloc 给的那个堆地址(假设是 0x5555_5556_02a0 这种),movl $42, (%rax) 就是跑到 0x5555_5556_02a0 那个格子,把 42 写进去。
为什么说这是"另一个世界"?对比一下两块内存的地址就懂了:
栈上的 p 这个变量 在 -16(%rbp),地址类似 0x7fff_ffff_e8f0 (高地址,栈区)
p 指向的那 16 字节 在 %rax 里的值,地址类似 0x5555_5556_02a0 (低得多,堆区)
p 这个变量本身(存门牌号的那张纸)在栈上,跟着 main 的栈帧走,main 一返回就没了。但 p 指向的那块内存在堆上,地址低得多,是另一片完全独立的区域。上一篇里栈变量"出了函数自动消失"那套规矩,对堆这块地完全不适用——main 就算返回了,这 16 字节也不会自动消失。
这就带出一个上一篇没有的问题:栈上的东西,函数一退、%rsp 一弹就自动让出来了,不用你管;可堆上这块地,谁来收?没人自动收。你得自己开口还——那就是第三行的 free(p),我们留到第六节。
先把这条机器码也拆开看一眼,对照上一篇会心一笑。movl $42, (%rax) 编译成的字节是 c7 00 2a 00 00 00:
c7:操作码,"把一个立即数写进内存"。00:寻址字节,编码了"目标地址就在%rax里(即(%rax))"。2a 00 00 00:要写的那个立即数,0x2a= 42(小端序,低字节在前)。
那个 2a 就是 42。上一篇我们在 03 45 f8 里找到过加法的真身 0x03,这次在 c7 00 2a... 里找到了"把 42 写进堆"的真身。CPU 不认识 p,不认识 [0],更不认识"堆"——它只是照着 c7 的指示,把 2a 这个字节,送到 %rax 报出的那个地址去。
到这里,数据流的表面我们看完了:16 进 malloc,吐出一个堆地址进 %rax、再存进栈上的 p;要用时把地址取回 %rax,42 顺着这个地址写进堆。但最大的谜团还没解:malloc 在那一跳里,到底是怎么"搞到一块地"的?它问谁要的?这才是这篇真正要下沉的地方。
三、malloc 第一反应不是找操作系统,是翻自己的"小金库"
很多人对 malloc 有个想当然的印象:调一次 malloc,操作系统就给你划一块内存。听起来顺理成章,其实大错特错——如果真这样,程序就慢得没法用了。
为什么慢?因为"找操作系统要内存"是一件昂贵的事。它需要从用户态陷入内核态(下一节细说这个"陷入"),内核要去改这个进程的内存映射表、做各种记账。一次这样的来回,比一次普通函数调用贵几十上百倍。而真实程序里 malloc/free 一秒钟可能调用成千上万次(每 new 一个对象、每拼一个字符串都在分配),要是每次都惊动内核,CPU 全耗在进出内核上了。
glibc 的 malloc 是这么解决的:它在进程里自己攥着一个内存池(行话叫 arena)。这个池子是它早先一次性向操作系统批发来的一大块地,然后自己当起了"二房东":
- 你调
malloc(16),它先翻自己的池子,看有没有现成的、够 16 字节的空闲块。有,就切一块给你,把地址放进%rax返回——全程在用户态,根本不碰内核,快得很。 - 你
free一块,它也不急着还给操作系统,而是收回池子里,标记成"空闲",等下次有人要再拿出来用。
所以绝大多数 malloc 调用,压根没惊动操作系统,就是 libc 在自己的池子里切切补补。这也是为什么第一节里 callq malloc 跳进去那"几千行代码"——大部分逻辑是在管理这个池子:怎么快速找到合适大小的空闲块、怎么把相邻的空闲块合并、怎么按大小归类……(glibc 内部把空闲块按大小分成 fastbin、smallbin 等好几类链表来加速查找,这里不展开,知道"有个按大小归类的空闲块仓库"就够了。)
顺带解开一个细节:malloc(16) 实际从池子里占掉的,不止 16 字节。每一块 malloc 出来的内存(行话叫 chunk),前面都带一个小小的"头",记着这块多大、前一块是不是空闲。这个头是 libc 管理池子要用的元数据。所以你要 16 字节,它实际划走的更多——这就是为什么频繁分配小块内存会有可观的额外开销。下次看到"小对象内存占用比想象的大",根源就在这个 chunk 头。
libc 的内存池(arena)—— malloc 的「小金库」
┌──────────────────────────────────────────────┐
│ [头|已用 32B] [头|空闲 48B] [头|你这块 16B] ... │
│ ▲ ▲ │
│ 别人占着 malloc(16) 切给你 │
└──────────────────────────────────────────────┘
每块前面那个 [头] 就是 chunk 元数据,记大小/状态
malloc 先在这里找够用的空闲块,找到就切,不碰内核
那问题来了:这个池子也是有限的。要是池子里翻遍了都没有够大的空闲块呢?这时候,libc 才不得不去敲操作系统的门。
四、池子空了,才向操作系统要地:brk 与 mmap
池子不够用时,libc 才会真正去找操作系统批发一块新地,补进池子。它有两种要法,按你要的大小分流:
小额扩容:brk / sbrk——把"堆顶"往上推一截。
每个进程的地址空间里,堆是一块从低地址往高地址生长的区域,它的上沿有个边界,叫 program break(堆顶)。brk/sbrk 这个系统调用干的事特别朴素:把这个边界往高地址推一截,推出来的那段空地就归你的进程了,libc 把它补进池子。
注意 libc 很鸡贼:它不会你要 16 字节就只推 16 字节,而是一次推一大块(比如 132KB)囤着。这样接下来你再 malloc 几百次小块,都能从这批囤货里切,不用反复敲内核的门。这正是第三节"小金库"的来历——金库就是这么一次次批发囤起来的。
高地址 ▲
┌──────────────────┐
│ 栈 │ ↓ 向下长(上一篇的主场)
├──────────────────┤
│ ...空... │
├──────────────────┤ ◀── program break(堆顶边界)
│ │ ▲
│ 堆 │ │ brk 把这条边界往上推,
│ (libc 的池子) │ │ 推出来的空地补进池子
├──────────────────┤ ↑ 向上长
│ .data / .bss │
│ .text 代码段 │
低地址 ▼
大额请求:mmap——单独圈一块地。
如果你一次就要一大块(glibc 的默认门槛是 128KB 起),libc 就不走堆了,改用 mmap 这个系统调用,让内核在地址空间里单独划一整块独立的映射区给你。为什么大块要特殊待遇?因为这种大块往往用完整块释放,单独 mmap 来的地,free 时能直接整块 munmap 还给操作系统、地址空间立刻收回,不会把堆搞得千疮百孔。
我们的 malloc(16) 是个小不点,走的是第一条路(从已有的池子里切,多半连 brk 都不用触发)。但不管哪条路,有一件反直觉的事都成立——这才是这篇最该记住的一点:操作系统答应给你地的那一刻,它其实还没真给你内存。
五、要到的还是"假地址"——第一次写,才真的落到内存条上
上一节最后那句话听着像绕口令:操作系统答应给地了,却还没真给内存?是的。这正是现代操作系统最精的一手——惰性分配(lazy allocation)。
回想一下 libc 通过 brk 或 mmap 拿到的是什么:是一段地址区间。比如内核说"好,0x5555_5556_0000 到 0x5555_5558_0000 这段地址归你了"。但内核此刻做的,仅仅是在这个进程的"账本"上记了一笔:这段虚拟地址你能用。它没有真的从内存条上划出对应的物理内存来对应这段地址。
这不是偷懒,是精明。程序常常一次要一大片地址,却只用到其中一小角(想想 malloc 那 132KB 囤货,可能你只写了开头几十字节)。要是答应的时候就配齐物理内存,绝大部分会白白浪费。所以内核的策略是:先开张空头支票,等你真往里写,再兑现。
那"兑现"是什么时候、怎么发生的?就是第二节那句 p[0] = 42——准确说,是那条 movl $42, (%rax)。
CPU 执行这条指令、拿着 %rax 里那个堆地址去写的时候,它(通过页表)一查,发现这个虚拟地址还没对应任何物理内存——内核之前只记了账,没配物理页。这一下查空了,CPU 当场停下当前指令,触发一个缺页中断(page fault),控制权陷入内核。内核一看:"哦,这是我之前开的空头支票,现在他真要用了。"于是从内存条上找一个空闲的物理页框,填进页表,让那个虚拟地址真正对应上一块物理内存。这一切办完,CPU 回到那条 movl 重新执行,42 这才真正落到了内存条上。
p[0] = 42 这一次写,背后发生的事:
movl $42,(%rax) ──→ CPU 拿 %rax 里的虚拟地址去写
│
▼ 查页表:这地址有物理内存吗?
┌─────────────┐
│ 没有!(空头支票) │ ──→ 缺页中断,陷入内核
└─────────────┘
│
▼ 内核:找一块空闲物理页框,填进页表
┌─────────────┐
│ 现在对应上了 │ ──→ 回到 movl 重新执行
└─────────────┘
│
▼
42 真正写进内存条
看出门道了吗:malloc 返回的那个地址,从来都是个"假地址"(虚拟地址)。 它什么时候变成内存条上真实的格子?不是 malloc 返回时,而是你第一次往里写时。这就是为什么有时候 malloc 一大块明明"成功"了,程序却在后面某次写入时才崩——地址早给了,物理内存却是用到才补的。
这一节其实是把这个系列的另外两篇接上了。"虚拟地址凭什么能假装成真内存""缺页中断后内核到底怎么找物理页、怎么填页表"——这两件事本身就是大题目,本仓库各有一篇专门拆:
- 假地址怎么变真内存:那套假地址,到底怎么变成真内存的
- 缺页中断内核怎么处理:缺页中断不是"出错",是内核最忙的一条正常路径
在这篇里,你只要记住这条链子的形状:malloc 给的是虚拟地址(假的)→ 第一次写触发缺页 → 内核才配上物理页(真的)。
六、free 不还给操作系统,还回"小金库"
最后一行 free(p),编译出来也是平平无奇的一次调用:
movq -16(%rbp), %rdi # 把 p(地址)放进 %rdi,当 free 的参数
callq free@PLT # 调用 free
把 p 里的地址放进 %rdi(第一个参数寄存器),调用 free,告诉它"这个地址那块地,我不用了"。
但 free 干的事,多半不是你以为的"还给操作系统"。还记得第三节那个"二房东"吗?free 在绝大多数情况下,只是把这块 chunk 在 libc 的池子里标记成空闲,收回池子备用,并不会把这段地址还给内核(不 munmap、也不把 brk 往回降)。地址区间还在你进程名下,物理页通常也还占着。
这解释了两个常被问起、看着矛盾的现象:
其一:free 之后,进程占的内存(RSS)经常不降。 因为内存还攥在 libc 池子里没还给系统呢——它留着给你下次 malloc 用。这不是内存泄漏,是故意囤着。(只有当初用 mmap 单独要的大块,free 时才会 munmap 真还回去、RSS 立刻降。)
其二:free 之后那个指针,往往还能"读到"旧值,但绝对不能再用。 既然那块物理内存还在、地址也没被收回,free 之后立刻 *p 去读,常常还能读到那个 42。但这是定时炸弹:那块地已经还回池子,libc 随时可能把它切给下一次 malloc,到时你手里这个旧指针指向的,就是别人的数据了。读写一个已经 free 的指针,就是臭名昭著的 use-after-free 漏洞——它危险的物理根源,正是这一节:地还在,只是主人随时会换。 (这和上一篇结尾提的"栈溢出"是一对:一个是栈上的地被踩,一个是堆上的地易主,都是"内存还在、归属变了"惹的祸。)
所以 free 的本质是:还给二房东 libc,不是还给房东操作系统。 真正还给操作系统,要么是大块的 munmap,要么是进程退出时一笔勾销。
七、把整条链子连起来看一遍
三行 C,我们一层层挖到了底。现在拉远镜头,把 malloc(16) 这一路"要地"的完整链条串成一条线:
① main 报需求 edi ← 16 (要 16 字节)
│
② callq malloc 控制权跳进 libc(我没写的代码)
│
▼
③ libc 翻池子 池里有够大的空闲块?
│ ├─ 有 ──────────────→ 切一块,纯用户态,不碰内核
│ └─ 没有 ─→ ④ 向内核批发
│
④ 向内核要地 小块: brk 推高堆顶 / 大块(≥128KB): mmap 单独映射
│ 内核只登记「这段虚拟地址归你」——开张空头支票,不给物理内存
│
▼
⑤ malloc 返回 rax ← 堆地址(一个虚拟地址,还是「假」的)
│
⑥ 存进变量 movq %rax,-16(%rbp) → p = 堆地址 (地址抄进栈上的 p)
│
⑦ p[0]=42 movl $42,(%rax) → 第一次往这地址写
│ 查页表→没物理页→缺页中断→内核配物理页框→兑现支票
│ 42 这才真正落到内存条上
│
⑧ free(p) callq free → 标记空闲,还回 libc 池子
(不还给操作系统:RSS 不降,地址还在但主人随时会换)
从头到尾,"分配 16 字节内存"这件听起来很实在的事,其实是三层惰性、能拖就拖的接力:
- libc 这层:能从自己池子里切,就绝不去找操作系统(一次系统调用太贵)。
- 内核这层:能只记账(给虚拟地址)就绝不真配物理内存(
brk/mmap只开支票)。 - 物理内存这层:能等到你第一次写再配页,就绝不提前配(缺页中断时才兑现)。
每一层都在偷懒,而且偷得有道理——因为程序"要的"远比"真用的"多,每一层都赌你大概率用不到,赌赢了就省一大笔。
回到开头那句 int *p = malloc(16)。现在你知道了,这一句在底层根本不是"操作系统给了我 16 字节",而是:libc 从它二房东的池子里切了一块地址给你(池子空了才向内核批发),这地址还是张空头支票,直到 p[0] = 42 你真往里写的那一刻,内核才手忙脚乱地配上一块真正的物理内存。free 之后,这块地又默默回到了二房东手里,等着被下一个 malloc 切走。
理解了这条链子,再看那些"为什么 free 了内存还不降""为什么这块 malloc 没崩、写的时候才崩""为什么小对象那么占内存"的问题,你看到的就不再是 malloc 这层的表象,而是底下 libc 池子、内核账本、物理页框这三层各自在偷的懒了。
这是"一条代码的冒险之旅"系列的第二篇。上一篇讲调用函数时栈帧怎么起落:《一个 7 行的 C 函数,是怎么一路变成 CPU 上的电信号的》。