写进程地址空间第一篇文章的时候我把虚拟内存的整体框架拆开讲了一遍从代码段到栈从堆到内存映射段把一张内存布局图硬生生画了半小时。文章发出后有同学私信问我既然地址空间只是个“虚拟”的概念那程序里的指针拿到地址后CPU到底怎么找到真正的物理内存为什么64位系统下地址空间那么大进程却依然会OOM还有同学问堆和栈都往中间长万一撞上了怎么办这些问题问得非常好说明第一篇文章的框架已经建立起来了但还缺一层“血肉”。这篇作为系列第二篇我准备把地址空间从“一张静态的地图”变成“一套动态运转的系统”来讲。会重点做几件事用代码实测堆和栈的增长方向与碰撞风险把虚拟地址到物理地址的转换链路拆开来看再手把手带大家用系统自带的工具观察一个真实进程的内存布局。内容仍然面向写过一些代码、但没系统学过操作系统的开发者读完你可以做到看到一份进程内存报告能快速判断哪里可疑程序出现段错误或OOM能顺着地址空间的机制去定位原因。1. 先把地址空间的整体框架再过一遍系列第一篇里画过一张经典的内存布局图但那是基于32位系统的简化版。今天开篇必须先把这个框架升级到64位因为现在绝大多数开发环境已经是64位了而64位地址空间的行为和32位有本质差别。如果不先把这张新地图立起来后面谈堆栈碰撞、谈页表结构都会失去参照物。1.1 从32位到64位地址空间不是变大了四倍这么简单32位系统下进程地址空间是4GB其中内核通常占用高地址的1GB用户态只有3GB可用。当年程序员开玩笑说“4G内存是硬伤”其实更准确地说是32位地址空间限制了单个进程能直接操作的内存范围。64位系统理论上地址空间是2的64次方也就是16EBExabyte这个数字大到失去直觉。但实际没有任何处理器实现完整的64位地址线x86-64架构目前只实现48位虚拟地址也就是256TB的用户空间。即便如此这个范围也远超物理内存的容量。关键点在于64位下地址空间虽然大但并不是“一整块可以随便用的空地”而是被操作系统划分成几个固定区域每个区域有不同的用途和权限。从低地址到高地址大致是这样区域起始地址常见分布主要用途权限代码段0x400000附近可执行指令读执行数据段代码段之上全局变量、静态变量读写堆数据段之后向上增长动态分配内存读写内存映射段堆上方向下放置共享库等共享库、mmap文件读写执行视情况栈用户空间顶部向下增长函数调用帧、局部变量读写内核区最高地址区域内核代码和数据用户不可访问相比32位64位下最明显的变化是栈的起始位置不再固定而是带随机化共享库的加载地址也随机化堆的起始地址虽然由内核决定但在启用位置无关可执行文件PIE后整个程序基址都随机化了。这就是ASLR地址空间布局随机化的功劳——为了安全系统故意让每次运行程序的地址布局都不一样。注意ASLR是安全机制不是bug。它让攻击者难以预测目标地址。调试时如果发现每次打印的指针地址都不同不要以为是程序出错了。1.2 这张“地图”到底是谁画的内核视角很多同学误以为地址空间是编译器生成的或者说是链接器决定的。实际上编译器确实参与了一部分——它生成的可执行文件里有代码段、数据段的各种节section这些决定了相对布局。但最终把虚拟地址映射到物理内存的是内核。进程启动时内核做的事情可以这么理解读取可执行文件的头部信息了解代码段有多大、数据段要占多少空间。在虚拟地址空间中从合适的基址开始为每个段分配连续的虚拟地址范围。建立页表把这些虚拟地址范围标记为“已映射”并关联到对应的物理内存页或文件页。设置好栈的初始位置把环境变量、命令行参数压到栈顶。跳转到程序入口点开始执行。这个过程对程序员是透明的但理解它有个很重要的实际意义你在程序里看到的地址并不是物理内存地址而是一个虚拟地址。每次访问这个地址CPU都要通过页表把它翻译成物理地址。这个翻译过程在后面第3章会详细讲。1.3 为什么地址空间“看上去很大”却不代表内存够用这是第二个关键认知。地址空间大只代表虚拟地址的编号范围大不代表物理内存多。比如你的机器只有16GB物理内存但每个进程都能看到几百TB的虚拟地址空间这之间的差距靠什么补靠页表和按需分配。按需分配demand paging是操作系统最经典的优化手段之一。当你malloc一块内存内核并不会立刻在物理内存里划一块对应大小的区域给你。它只是在页表里把这段虚拟地址标记为“可访问”但对应的物理页实际上还不存在。直到你真正读写这块内存的某个页时CPU触发缺页异常page fault内核才去物理内存分配一个页框填好页表项再让程序继续执行。所以malloc成功不代表你真的占用了物理内存。我用一个简单实验验证过一个进程malloc了1GB内存但只逐个字节写一遍用top看RES常驻物理内存会从很小慢慢涨到接近1GB。这就是按需分配在起作用。这个机制也直接解释了为什么“虚拟内存占用”看着吓人但系统还扛得住——因为大部分虚拟页可能从未被真正触碰过。2. 实测堆和栈一个向上一个向下到底会不会撞上前面提到堆向上增长栈向下增长这两个区域都在用户空间的中间区域“相向而行”。很多新手会问那堆栈撞上了怎么办这就需要用实测和原理一起来回答。2.1 写个程序看堆和栈的地址变化先看一段简单的C代码分别打印栈上局部变量的地址和堆上malloc出来的地址#include stdio.h #include stdlib.h void func(int depth) { int local depth; printf(栈上变量地址 (第%02d层): %p\n, depth, (void*)local); if (depth 0) { func(depth - 1); } } int main() { int stack_var 0; int *heap_1 (int*)malloc(sizeof(int)); int *heap_2 (int*)malloc(sizeof(int)); char *heap_big (char*)malloc(1024 * 1024); // 1MB printf(main函数内局部变量: %p\n, (void*)stack_var); printf(第一次malloc: %p\n, (void*)heap_1); printf(第二次malloc: %p\n, (void*)heap_2); printf(1MB malloc: %p\n, (void*)heap_big); printf(---\n); func(3); free(heap_1); free(heap_2); free(heap_big); return 0; }编译运行关闭ASLR便于观察但现代系统默认开启这里用setarch x86_64 -R临时关闭gcc -o addr addr.c setarch x86_64 -R ./addr我自己机器上的一次输出如下不同系统会有差异但规律一致main函数内局部变量: 0x7ffd2f3a5c1c 第一次malloc: 0x55d8b2f63260 第二次malloc: 0x55d8b2f63280 1MB malloc: 0x55d8b2f64260 --- 栈上变量地址 (第00层): 0x7ffd2f3a5bec 栈上变量地址 (第01层): 0x7ffd2f3a5bbc 栈上变量地址 (第02层): 0x7ffd2f3a5b8c 栈上变量地址 (第03层): 0x7ffd2f3a5b5c看到规律没有每次递归调用栈变量的地址都在变小——说明栈确实从高地址往低地址方向增长。而两次malloc出来的地址依次增大——堆确实往上走。两者一个向下一个向上方向相反。2.2 堆栈之间为什么不会轻易相撞既然方向相反理论上就存在碰撞的可能。但现实中极少发生原因有三层第一它们之间还夹着内存映射段。共享库、mmap文件都放在堆和栈之间而且通常会占据一定空间。这个区域本身就在堆和栈之间充当缓冲带。第二栈的大小有限制。Linux下可以用ulimit -s查看常见默认值是8MB。换句话说栈不是无限向下长的超过这个限制就会触发栈溢出stack overflow程序直接崩溃。既然栈有上限它和堆之间的“安全距离”就是确定的。第三堆虽然理论上可以向上长但受当前映射段的位置限制。当malloc申请的内存过大堆顶超过某个阈值时内核会选择用mmap分配一大块独立区域而不是继续扩展堆。这也是glibc的设计大块分配通常超过128KB走mmap小块分配走堆brk。所以那个看似让人担心的“堆栈相撞”实际被操作系统用多重机制防得死死的。真正需要担心的反而是两类问题栈溢出因为递归过深或局部变量过大和堆溢出写入越过堆块边界这两个才是开发中会遇到的实际问题。2.3 用地图工具验证/proc/self/maps的完整解读Linux系统有个宝藏文件/proc/self/maps它会把当前进程的完整内存映射一栏一栏列出来。如果程序里主动读这个文件就能看到自己的地址空间真实布局。我在上面的程序基础上加一行system(cat /proc/self/maps | head -20);输出像这样行数很多截取头部00400000-00401000 r-xp 00000000 fd:01 1234567 /path/to/addr 00600000-00601000 r--p 00000000 fd:01 1234567 /path/to/addr 00601000-00602000 rw-p 00001000 fd:01 1234567 /path/to/addr 7f2b1d5b0000-7f2b1d5b1000 r-xp 00000000 fd:01 1234567 /lib/x86_64-linux-gnu/ld-musl-x86_64.so.1 ... 7ffe8f5be000-7ffe8f5df000 rw-p 00000000 00:00 0 [stack] 7ffe8f5df000-7ffe8f5e2000 r--p 00000000 00:00 0 [vvar] 7ffe8f5e2000-7ffe8f5e4000 r-xp 00000000 00:00 0 [vdso]每行格式是地址范围、权限、偏移、设备号、inode、映射对象。看懂这六列你就可以当一个“内存侦探”了第一列虚拟地址起止范围十六进制。第二列权限r读w写x执行p私有s共享。注意没有权限的用-表示。第三列偏移量表示该映射在文件中的偏移匿名映射通常为0。第四列设备号表示文件所在设备。第五列inode号文件的inode匿名映射为0。第六列映射对象可能是可执行文件路径、共享库路径或者是[stack]、[heap]、[vdso]等特殊标识。这个文件的妙处在于它是内核直接导出的进程内存映射表比任何调试工具都权威。排查内存问题时第一步看这里基本不会错。3. 地址转换链路虚拟地址是怎么一步步找到物理内存的前面说了这么多虚拟地址、物理地址的概念现在必须正面回答CPU执行指令时怎么把虚拟地址翻译成物理地址搞懂这条链路你就会明白为什么地址空间可以比物理内存大那么多也会理解为什么程序崩溃时看到的地址能直接定位到代码行。3.1 分页把内存切成等大的小块操作系统管理内存不是按字节来管的而是按“页”page来管的。x86-64架构下标准页大小是4KB。虚拟地址空间被切成一个个4KB的虚拟页物理内存被切成一个个4KB的物理页框page frame。虚拟页到物理页框的映射关系记录在页表page table里。每个进程都有自己的页表所以不同进程里的同一个虚拟地址可以映射到完全不同的物理页框——这就是进程地址空间隔离的本质。如果只有一张简单的页表那4GB地址空间就需要大约100万个页表项4GB / 4KB 1M每个页表项8字节的话光页表就要8MB。到了64位256TB的地址空间这个数字直接爆炸到几十亿项。所以现代CPU都不会用单层页表而是用多级页表。3.2 多级页表和TLB两个关键设计x86-64用的是四级页表PGDPage Global Directory、PUDPage Upper Directory、PMDPage Middle Directory、PTEPage Table Entry。虚拟地址在这套机制下会被拆成几段63 48 47 39 38 30 29 21 20 12 11 0 ----------------------------------------------------------------------- | 符号扩展 | PGD索引 | PUD索引 | PMD索引 | PTE索引 | 页内偏移 | -----------------------------------------------------------------------实际翻译流程是CPU拿着虚拟地址先根据PGD索引查PGD表得到下一级页表PUD的物理地址再查PUD得到PMD再查PMD得到PTE最后在PTE里找到物理页框号加上页内偏移得到最终的物理地址。这个过程叫页表遍历page walk。四级页表听起来很绕为什么不直接用一个大数组道理很简单大多数进程实际上用不到完整的256TB地址空间。多级页表的好处是未使用的地址空间根本不需要分配对应的页表项。比如一个进程只用了很少的内存那么PGD的某些项直接就是空的下一级页表也不用存在。这种“用到哪里建到哪里”的设计让页表本身的开销大大降低。但四级遍历是有代价的。如果每次内存访问都要走四层表性能会非常难看。所以CPU里有一个专门的高速缓存叫TLBTranslation Lookaside Buffer用来缓存最近用过的虚拟地址到物理地址的映射关系。TLB命中时地址转换不需要走页表一次搞定未命中时才走完整的多级查询。这就是为什么程序局部性越好性能越高的底层原因之一。3.3 一个内存访问的完整生命周期用一个简单的例子串起来程序要读取int变量x的地址0x7ffd2f3a5c1c。CPU拿到这个虚拟地址先查TLB。如果命中直接得到物理地址跳到第5步。TLB未命中MMU内存管理单元开始查进程的页表。按照虚拟地址里的索引位逐级找到PTE发现该虚拟页尚未分配物理页框这就是缺页。内核介入分配一个物理页框把页表项填好返回用户态重新执行指令。现在物理地址拿到了CPU发起对物理内存的访问数据被载入寄存器。第3、4步看起来简单但其实是按需分配的核心。第一次访问某个堆内存时往往都会走一遍缺页流程。后续再访问同一个页TLB和页表都准备好了速度就快很多。理解这条链路后你会明白为什么“第一次读写内存”往往比后续慢——那是缺页带来的固有成本。注意TLB并不是越大越好。太大反而会增大命中延迟而且TLB的容量和页表粒度也有关。现代CPU用“大页”HugePage比如2MB或1GB来减少页表项数量从而提高TLB命中率。数据库和高性能服务常配置HugePage原理就在这。4. 动手观察从进程内部看自己的地址空间理论说再多不如动手看一次。这一章我带你做几个能直接上手的实验用系统工具观察地址空间的真实行为。这些命令和思路之后排查问题时可以直接复用。4.1 用pmap看进程的内存画像pmap是一个非常直观的工具它会把某个进程的所有内存映射按地址顺序列出来并汇总各类内存的占用情况。# 先启动我们的测试程序让它睡眠方便观察 ./addr PID$! pmap -x $PID输出的核心信息包括地址、大小、RSS驻留物理内存大小、模式、映射对象。其中RSS这个列很有用它告诉你这段虚拟地址实际占了多少物理内存。如果一个进程虚拟地址空间特别大但RSS很小说明大部分虚拟页都没有被实际使用。用pmap和/proc/self/maps配合基本上能回答“这个进程的内存到底用在哪”这类问题。线上排查内存泄漏时看pmap的输出往往能快速定位到是堆在膨胀、还是mmap区域在膨胀、还是共享库异常增多。4.2 观察按需分配写不写内存的区别让我们做一个对比实验。写一个程序先malloc一块256MB的内存然后分成两种情况情况Amalloc后什么都不做直接sleep。 情况Bmalloc后逐页写入触碰每一页。分别用top或ps看进程的RES常驻内存#include stdio.h #include stdlib.h #include string.h #include unistd.h int main(int argc, char *argv[]) { size_t size 256 * 1024 * 1024; char *p (char*)malloc(size); if (argc 1 strcmp(argv[1], touch) 0) { // 逐页写入强制触发缺页 for (size_t i 0; i size; i 4096) { p[i] 1; } } printf(分配完成PID%dresident check after sleep...\n, getpid()); sleep(30); return 0; }分别运行./memtest和./memtest touch。你会看到不写入时进程的RES几乎不涨逐页写入后RES逐渐接近256MB。这个实验直观证明了按需分配虚拟地址空间只是“画饼”真正吃物理内存的是“触碰”。4.3 ASLR的随机化到底改变了什么前面提到ASLR现在做个简单的验证。写一段代码打印main函数的地址和栈地址连跑几次#include stdio.h int main() { int x 0; printf(main%p, stack%p\n, (void*)main, (void*)x); return 0; }正常情况下开启ASLR每次运行的输出都不一样比如main0x55d8b2f631a9, stack0x7ffd2f3a5c1c main0x55f0f4e7c1a9, stack0x7ffc2b9d3c1c如果临时关闭ASLR则每次都一样setarch x86_64 -R ./aslr_test这个差异在调试时很有影响如果你在GDB里设置了断点然后每次运行时地址都在变就需要使用set disable-randomization on来关闭随机化让调试体验稳定。但要注意这只影响被调试的进程不会影响系统整体安全性。5. 与地址空间相关的经典问题段错误、OOM、内存泄漏了解地址空间的机制不是纯粹的学术兴趣而是为了解决实际问题。这一章我挑三个最常见的线上问题从地址空间的角度来分析它们的根因和排查思路。5.1 段错误到底是谁访问了不该访问的地址段错误Segmentation Fault本质上是进程访问了“当前地址空间中不存在或无权访问”的虚拟页。触发时机有两种一是地址不存在于页表中。比如空指针解引用地址0通常在任何用户地址空间里都没有映射访问它立刻产生缺页内核发现是非法访问直接发SIGSEGV。二是地址虽然映射了但权限不对。比如往只读的代码段写入数据或者执行了数据段里的内容。这就好比你拿着钥匙去开一扇门门存在但钥匙不对照样进不去。排查段错误时GDB背面的信息很有用gdb ./program core (gdb) run (gdb) bt # 查看调用栈从地址空间的视角看核心思路是段错误信息里通常会给出触发异常的地址以及访问类型。你对照/proc/self/maps或pmap的输出就能判断这个地址落在哪个区域从而推断出是空指针、野指针、还是越界写。5.2 OOM为什么虚拟空间很大还会内存不足OOMOut Of Memory的触发逻辑其实并不是“虚拟地址空间用完了”而是“物理内存交换空间不够用了”。因为地址空间几乎不可能用完256TB你分配不完但物理内存是有限的。一个进程占用的物理内存可以用RSS来衡量。当所有进程的RSS总和超过物理内存而且内核判定需要回收内存但无法满足时就会触发OOM Killer选择一个进程杀掉。想理解这个机制核心概念是overcommit——内核允许进程申请远大于物理内存的虚拟内存赌的是你“申请了但未必全用”。这个赌局在物理内存耗尽时就崩了。排查OOM的常用手段dmesg | grep -i oom查看内核日志里面有OOM Killer选中的进程、当时的RSS值、触发原因等。5.3 内存泄漏为什么进程的RSS只涨不降内存泄漏从地址空间的视角看就是“虚拟地址空间被不断映射但不再映射回物理页时RSS自然回落。反之持续泄漏的进程它的堆或mmap区域会持续增长对应的RSS也不断上升。”排查内存泄漏我常用的三板斧pmap观察连续截取几次pmap -x输出看哪段区域的RSS在持续增长。配合smaps看细节/proc/PID/smaps里每个映射段都有RSS、PSS等细致字段可以精确到是哪个地址范围在膨胀。用Valgrind或AddressSanitizer查调用栈定位是哪个函数分配的内存没释放。注意RSS不下降有时候不一定是泄漏。glibc的堆管理器不会把释放的内存立刻归还操作系统而是留在进程的堆里复用。所以一个分配过大量内存又释放的进程RSS可能居高不下。判断是否泄漏更合理的指标是看“活跃分配量”是否持续增长而不是RSS是否下降。6. 工具与技巧日常开发中必须掌握的地址空间相关操作最后一章我想分享一些我日常调试内存问题时常用的命令和习惯。这些内容不复杂但很实用能让你少走很多弯路。6.1 GDB、/proc、pmap的配合使用调试内存问题时我会同时开三样东西GDB、pmap、/proc/PID/smaps。流程是这样的先让程序崩溃生成core文件。用GDB看崩在哪一行确认异常地址。启动一个同等环境的正常进程用pmap看它的地址空间分布用来对照。从/proc/PID/smaps里的细节字段确认异常区域对应的映射对象。这三样配合下来绝大多数内存问题都能定位到具体函数甚至具体代码行。6.2 ulimit、/proc/sys/vm与调优参数地址空间的一些行为其实是可以通过内核参数调节的。几个常用的参数/命令作用常用场景ulimit -s查看/设置栈大小防止栈溢出崩溃ulimit -v限制虚拟内存用量模拟低内存环境/proc/sys/vm/overcommit_memory控制内存超额分配策略服务端内存调优/proc/sys/vm/max_map_count限制进程最大映射数高并发服务需要调大比如某些服务会频繁mmap导致进程的映射数量超过默认上限通常65530然后报mmap: Cannot allocate memory。这时候调大max_map_count就能解决问题。6.3 给新手的建议先学会看地图再学调参数作为系列第二篇我最后想强调一个观点地址空间的机制不是孤立的操作系统知识点它和程序的崩溃、性能、安全都直接相关。新手上路不要急着背各种内核参数先把本文这些“看地图”的工具用熟——能在自己的进程里认出每一块地址是什么、在干什么再往上钻研调优才会事半功倍。我个人调试的习惯是碰到内存相关的诡异现象第一反应永远是看/proc/PID/maps和pmap而不是猜。因为地址空间是一张极其精确的地图所有问题都会在图上留下痕迹。读图能力是这个领域最值得花时间练的基本功。