首页 / 资讯中心 / 文章详情

深入理解进程虚拟地址空间:从内存布局到MMU页表翻译

深入理解进程虚拟地址空间:从内存布局到MMU页表翻译 ★ FEATURED ARTICLE
不少朋友学到进程这一章时都会卡在“虚拟地址空间”这个名字上。进程不是一块正在运行的程序吗哪来的“空间”你写C语言时打印出来的指针地址到底是不是内存条上的真实位置为什么父进程和子进程里同一个全局变量打印出来的地址是一样的它们却不互相干扰这些问题恰恰是理解进程模型最深的一道门槛。这篇文章就围绕进程的虚拟地址空间展开把概念、布局、地址翻译过程、实际观测方法一次讲明白适合刚学完进程基础、准备深入操作系统底层的读者也适合那些写了好几年代码却答不上来“malloc之后到底发生了什么”的朋友。1. 虚拟地址空间到底是个什么东西1.1 先从一个“诡异”的现象说起我在给某同学调一个C语言作业时遇到了一个很典型的问题。代码很简单fork了一个子进程父子进程各自打印同一个全局变量的地址。结果让我印象非常深刻两个进程打印出来的地址一模一样。#include stdio.h #include unistd.h int global_var 42; int main() { pid_t pid fork(); if (pid 0) { printf(child: global_var addr %p, value %d\n, global_var, global_var); } else { printf(parent: global_var addr %p, value %d\n, global_var, global_var); } return 0; }当时旁边有个刚入门的朋友问这俩进程岂不是抢同一块内存子进程改了值父进程是不是也会被改掉答案当然是否定的。那两个进程里global_var的值可以各自独立变化互不影响。这说明什么说明进程拿到的地址根本就不是物理内存上的真实地址而是一个“虚拟”的地址。进程以为自己独占了一整段连续的内存实际上它只是拿到了一张“地图”真实的内存可能分布在物理内存的各个角落甚至可能暂时还没分配。这就是虚拟地址空间这个概念最核心的第一课进程看到的世界不是真实世界而是一幅精心绘制的地图。1.2 虚拟地址与物理地址一张翻译表既然进程看到的地址是虚拟的那CPU真正去内存读写数据的时候总得知道真实地址吧这里就需要一个翻译机制通常由两部分配合完成MMUMemory Management Unit内存管理单元CPU内部负责地址翻译的硬件模块。页表Page Table记录虚拟页到物理页映射关系的数据结构是操作系统的“翻译表”。当进程访问一个虚拟地址时CPU并不会拿着这个地址直接去内存条上找数据而是先把虚拟地址交给MMUMMU查页表找到对应的物理地址然后才发起真正的内存访问。这个过程对进程本身完全透明进程压根不知道自己被“骗”了——它依然可以根据连续的地址做计算、跳转、读写数组完全感受不到物理内存其实是碎片化的。用生活里的话说这就像你在一家餐厅点菜。菜单上的编号是连续的你只需要照着编号点单后厨却可以根据食材库存自由调度菜谱编号和你最终吃到嘴里的那盘菜并不存在地址上的一一对应。菜单编号就是虚拟地址后厨的领料单就是页表。1.3 为什么必须有虚拟地址空间有人可能会问既然有了页表和MMU这么一套复杂机制让人头疼那干脆让进程直接用物理地址不行吗早期的系统确实有人这么干过但很快就撞上了四个绕不开的问题。第一是隔离。如果没有虚拟地址空间任何一个进程出现野指针都可能直接把另一个进程甚至操作系统的内存写坏整个系统直接崩溃。有了虚拟地址空间每个进程都活在自己的独立王国里你的进程崩溃是你自己的事不能顺手把别的进程拖下水。第二是安全。既然进程只能通过页表访问映射过的物理页那些没有映射的地址进程一碰就会触发异常操作系统就能立刻叫停阻止非法访问。这就是“段错误”和“非法访问”能够被拦截的底层原因。第三是地址空间的连续性。程序编译链接时链接器会假设代码和数据放在一个连续的地址范围内。如果直接使用物理内存这个假设基本不可能满足因为物理内存被多个进程挤来挤去分配到的片段注定是碎块。虚拟地址空间解决了这个矛盾物理内存碎片化无所谓只要虚拟地址连续链接器就开心了。第四是懒分配与共享的灵活性。有了映射这层中间代理操作系统就可以玩出很多花样。malloc申请大块内存时可以先只“记账”、不真给物理内存多个进程可以共享同一份物理代码页也可以让fork出来的子进程暂时与父进程共享物理页等真正写入时才复制也就是写时复制Copy-on-WriteCOW。这些机制都建立在虚拟地址之上下一节里你会反复看到它们。小结一句话虚拟地址空间是进程与物理内存之间的一层隔离层、翻译层更是一块让操作系统随心所欲调度资源的“画板”。2. 经典布局一张进程内存地图2.1 从低到高看一遍完整地图64位Linux下一个普通进程的用户态虚拟地址空间从低地址到高地址大致画出来是这样高地址 0x7fff... ┌─────────────────────┐ │ 内核空间用户不可直接访问│ ├─────────────────────┤ │ 栈区向下生长 │ ├─────────────────────┤ │ 空档 / 随机间隙 │ ├─────────────────────┤ │ 共享库映射区 / mmap区 │ ├─────────────────────┤ │ 空档 / 随机间隙 │ ├─────────────────────┤ │ 堆区向上生长 │ ├─────────────────────┤ │ BSS段未初始化全局变量 │ ├─────────────────────┤ │ 数据段已初始化全局变量 │ ├─────────────────────┤ │ 代码段text │ 低地址 0x4000... └─────────────────────┘这可不是我随手画的装饰图这是实实在在存在的内存地图。编译好的ELF可执行文件被加载进内存时加载器就按照这个布局把各段数据放到对应的虚拟地址区间。代码段放机器指令数据段放“已经写了初值的全局变量”BSS段放“没写初值、默认是零的全局变量”。顺带说一下你直接编译一个不开启PIEPosition Independent Executable位置无关可执行文件的C程序代码段通常从0x400000附近开始开启PIE或者编译成共享库地址就会落在更高、随机的位置。这也是为什么网上教程里大家贴出来的地址五花八门——除了体系结构差异还有编译选项和ASLR的影响。2.2 栈和堆两个方向相反的区域这张地图里最值得玩味的是栈区和堆区的位置关系栈从高地址往低地址生长堆从低地址往高地址生长。两个区域相向而行中间空着一大片区域谁也不能轻易越过边界。为什么设计成相反方向很简单为了让两个动态变化的区域可以更灵活地共享中间的空闲区域。如果栈和堆都从同一个方向长那它们迟早会互相“撞车”一个稍微多长一点另一个就没地方去了。现在方向相反相当于两个人从一条长走廊的两头往中间走整个走廊的空间都可以被弹性利用。栈区大小通常受限于ulimit -s这个参数常见默认值是8MB。注意这里的8MB是虚拟地址范围不是物理内存的固定占用。你写一个递归函数无限嵌套最终会突破栈区上限触发栈溢出段错误。堆区则没有这么明确的硬上限它更多受限于系统虚拟内存总量和物理内存的实际情况。堆的扩展靠brk系统调用改变“程序断点”大块内存分配则走mmap另辟一片区域。2.3 共享库与内存映射区进程运行起来后光有程序本身的代码和数据是不够的。你调用printf、malloc这些函数来自C标准库标准库的代码和全局数据放在共享库文件里。动态链接器会把共享库映射到进程地址空间的中间区域也就是共享库映射区。这个区域的地址往往在栈和堆之间的某个随机位置由mmap系统调用完成映射。mmap能干的不只是加载共享库你还可以主动用mmap做文件映射——把文件的内容映射到进程地址空间里读写文件就像读写内存数组一样。这个技术在很多高性能工具里都会用到。更关键的是共享库映射区还承担了匿名内存映射的职能。当你要分配超过一定阈值通常是128KB的堆内存时glibc的malloc不会走传统brk这条路而是直接调用mmap从系统“借”一块地址空间。好处是释放时可以真正还给操作系统不像brk堆区那样容易形成一个越涨越高的“高水位”。2.4 内核空间那一半64位系统下虚拟地址空间被分成两大部分用户空间和内核空间。以常见的x86-64 Linux为例地址的最顶端约一半留给了内核用户态进程无法直接访问。这个设计保证了内核的代码和数据不会轻易被用户程序破坏。不过用户进程的地址空间地图里为什么会显示内核空间因为从虚拟地址的角度看每个进程都有一份属于自己视角的完整地址空间虚拟地址高位部分对应的映射是内核页表的映射。进程通过系统调用陷入内核后CPU会切换特权级这时就能访问内核空间了。由于内核空间是所有进程共享的这也是为什么内核线程和用户态进程能在同一个系统里协作的原因之一。给个经验性的数字你在/proc/PID/maps里看到的地址高位超过0x7fffffffffff的部分基本就不再是用户空间了。很多教程会提到用户/内核分界线在64位Linux上并不存在一个固定的数值但用户空间的典型范围就是0x0000000000000000到0x7fffffffffff剩下的是内核的领地。3. 地址翻译的幕后黑手MMU、页表与TLB3.1 地址翻译的核心流程虚拟地址空间有了进程在里头畅游无阻那地址翻译具体怎么发生我尽量用不干巴巴的方式讲清楚。现代系统采用分页机制。物理内存被切成固定大小的页框典型大小是4KB虚拟地址空间也被切成同样大小的虚拟页。每个虚拟页与某个物理页框之间是否有映射、映射到哪都记录在一张页表里。一个虚拟地址会被拆成两部分高位段虚拟页号用来查页表。低位段页内偏移确定在页内的具体位置。假设页大小4KB那低12位就是页内偏移。地址翻译时MMU拿着虚拟页号去页表里找到对应的页表项取出物理页框号再拼接上页内偏移就得到了物理地址。整个过程如同查字典你给我一个词虚拟地址我先翻到对应的页码虚拟页号找到词在当前页的位置页内偏移最后定位到纸上的具体行。3.2 多级页表与TLB省内存和提速的艺术如果每个进程都有一张线性的大页表这日子没法过。64位系统下地址空间有2^48个可寻址的虚拟页每项页表记录8字节那光是页表就得上百GB比物理内存还多这显然不现实。解决方案是多级页表。不用一次把所有可能用到的映射全部建好而是按照实际使用情况逐级建立索引。内核只给进程实际用到的地址范围建立上级页表项下一级页表暂时缺着。这样一个只用到几十MB内存的小进程页表开销非常小。页表是解决了容量问题但每次访问内存都要查页表这会让变慢很多。于是CPU引入TLBTranslation Lookaside Buffer快表它是页表项的缓存专门缓存最近用过的地址翻译结果。TLB命中时地址翻译几乎零成本命中不了才被迫去内存里逐级查页表。这就是为什么很多性能调优文档里强调“局部性”——程序越保持访问集合集中TLB命中率越高跑得就越快。3.3 缺页异常与懒分配虚拟空间为什么能“超卖”现在可以回答开头那个问题了为什么malloc一块几百MB的大内存常常瞬间返回因为 malloc 在虚拟地址空间里“划地”只需要修改进程的堆映射并不需要真的找几百MB物理内存填进去。真正的物理页面是由第一次访问触发的缺页异常来逐个分配的。缺页异常Page Fault发生时CPU把它交给操作系统内核处理。内核检查这个虚拟地址是否合法。如果虚拟地址在进程的映射范围内内核分配物理页、填好页表、恢复进程执行如果地址压根不在映射范围内就判定非法访问发给进程一个段错误信号。这就是你访问野指针时崩溃的真正过程——不是“CPU不让你访问”而是“页表找不到对应的合法映射”。懒分配带来的好处非常实际很多程序malloc之后只用了其中一小部分如果一开始就把全部物理内存填满完全是浪费。基于同样的思路fork子进程时内核也不急着把父进程的物理内存全部复制一份而是让父子进程的页表先指向同一批物理页并把这些页标记为只读。任一方尝试写入时触发缺页异常内核才复制该页让两边各拿一份。这就是写时复制它让fork瞬间完成同时保持进程间内存隔离。4. 亲自扒开进程的虚拟地址空间4.1 /proc/[pid]/maps 逐行解读理论讲多了容易晕现在上手实操。Linux系统有一个特别实在的接口每个进程都对应一个/proc/[pid]/目录里面有个maps文件记录了当前进程虚拟地址空间的每一段映射。我随便挑一个进程来执行命令cat /proc/29818/maps输出可能长这样00400000-00401000 r-xp 00000000 08:01 786432 /usr/bin/cat 00401000-00402000 r--p 00000000 08:01 786432 /usr/bin/cat 00402000-00403000 rw-p 00001000 08:01 786432 /usr/bin/cat 7f8e37c67000-7f8e37e1a000 r-xp 00000000 08:01 123456 /lib/x86_64-linux-gnu/libc.so.6 7f8e37e1a000-7f8e3801a000 ---p 001b3000 08:01 123456 /lib/x86_64-linux-gnu/libc.so.6 7f8e3801a000-7f8e3801e000 r--p 001b3000 08:01 123456 /lib/x86_64-linux-gnu/libc.so.6 7f8e3801e000-7f8e38022000 rw-p 001b7000 08:01 123456 /lib/x86_64-linux-gnu/libc.so.6 7ffcf59a6000-7ffcf5bc7000 rw-p 00000000 00:00 0 [stack] 7ffcf5d9a000-7ffcf5d9f000 r--p 00000000 00:00 0 [vvar]每行六列从左到右地址范围这段虚拟地址的起止范围。权限r读、w写、x执行、p私有/s共享。偏移这段映射在文件中的偏移量。设备号与inode文件所在设备与inode号00:00和0表示匿名映射。路径名映射的来源可执行文件、共享库或[stack]、[heap]等特殊标注。你可以看到同一个可执行文件或共享库在映射表里往往出现多行这只是不同权限段被拆开映射了。编译链接时代码段是只读可执行r-xp数据段通常是只读写rw-p各自落在不同页上。4.2 /proc/[pid]/smaps 与 RSS/PSSmaps只给了概览想看每个区域更细的账目用smaps。这个文件比maps多了很多细节比如每段映射实际占用的物理内存量。cat /proc/29818/smaps | head -60典型的输出片段里能看到Size虚拟内存区域大小。Rss当前常驻物理内存大小。Pss按共享比例折算后的物理内存大小。Shared_Clean / Shared_Dirty共享页面多个进程共用其中又分干净未修改和脏已修改。Private_Clean / Private_Dirty私有页面只属于当前进程。为什么这些细节重要因为很多人用ps或top看内存占用时被“虚拟内存”这一列吓一跳——一个程序明明只用了100MBVirtual Size却显示几个GB这是什么情况答案很简单VSZ统计的是虚拟地址空间大小其中大量区域只是映射到了某个文件或者只是预留了空间根本没分配物理页面。真正要关注的是RSS常驻物理内存和PSS按比例平摊共享库后的内存这两个指标才接近“这个进程实际吃掉了多少内存”。4.3 写一个小例子验证栈、堆、全局变量的地址变化与其背地图不如自己打印地址验证一遍。下面这个程序打印出程序代码段的函数地址、全局变量地址、堆变量地址、栈变量地址#include stdio.h #include stdlib.h int global_init 100; int global_uninit; void func() { int stack_var 0; printf(func() address: %p\n, func); printf(stack_var address: %p\n, stack_var); } int main() { int local_var 0; int *heap_var malloc(1024); printf(global_init address: %p\n, global_init); printf(global_uninit address: %p\n, global_uninit); printf(local_var address: %p\n, local_var); printf(heap_var address: %p\n, heap_var); func(); free(heap_var); return 0; }在我的环境里编译运行后看到的地址分布符合预期函数地址和已初始化全局变量地址都落在可执行文件映射的较低区域未初始化全局变量稍微高一点堆区变量更高栈变量则在非常高的位置。你可以对比/proc/[pid]/maps来核对这些地址落在哪一段范围里。有一个细节要提醒现代Linux默认开启ASLR这意味着栈地址、堆起始地址、共享库加载地址每次运行都会随机变化。如果你连续运行两次程序malloc返回的地址大概率不一样。这是安全机制的体现不是系统抽风。写实验代码时如果不希望地址频繁变动可以用setarch -R或者临时关闭ASLR来固定地址分配方便观察布局。4.4 用pmap、top、ps快速查看内存画像命令工具里最直观的是pmappmap -x 29818它会列出进程所有映射、RSS和脏页大小末尾给出总计。适合用来快速确认“进程哪段内存占用异常”。ps看虚拟内存和常驻内存也很快ps -o pid,comm,vsz,rss,pmem -p 29818VSZ就是虚拟地址空间大小RSS是常驻物理内存大小。top进入界面后按f可以打开字段开关把VIRT、RES、CODE、DATA列显示出来。CODE是代码段占用的物理页数DATA是数据段堆的占用两者结合起来能快速判断某个进程是代码占得多还是数据占得多。排查内存问题时我的习惯是先用pmap看整体分布再对具体可疑区域用smaps看细节。比如怀疑某个进程段错误是栈溢出直接看maps里[stack]段的范围再对比崩溃日志里的访问地址就能判断是不是栈区域越界了。5. 常见的坑和排查方法5.1 “内存泄漏”到底指什么虚拟空间只涨不降内存泄漏这个词经常被滥用很多人在程序里malloc了不free就以为泄漏了。实际上进程退出后操作系统会把它的全部虚拟地址空间收回用户态泄漏的内存并不会永远留在系统里。真正的问题是长期运行的进程比如服务器反复分配而不释放它的地址空间像水位一样持续上涨最终把系统的物理内存和交换空间耗尽。排查内存泄漏标准做法是观察RSS和VSZ的变化趋势。写个脚本隔几秒记录一次进程的VSZ、RSS如果只涨不降那基本可以认定有泄漏点。再用工具抓堆分配现场比如valgrind的massif、gdb配合malloc钩子逐步定位到具体调用栈。经验之谈有些所谓的“泄漏”其实是RSS居高不下但虚拟空间不变那是glibc的堆碎片或未归还给系统的缓存块不一定真是你的分配没释放。5.2 段错误是访问了哪里段错误最常见的原因有三类解引用空指针、栈溢出、写入了只读区域。这三类在虚拟地址空间层面都能解释。空指针就是访问0地址附近。0地址在页表里通常没有映射一碰就触发缺页异常内核发现这个地址不在允许范围直接给进程发送段错误信号。栈溢出则是访问栈区之外的空间。栈区范围在maps里清清楚楚递归太深时栈指针一路向下生长突破了[stack]段的底部边界照样触发非法访问。写入只读区域呢比如你试图修改字符串字面量或代码段里的数据。mmap把代码段映射成r-xp只读可执行你硬要往那里写页表权限检查直接拒绝段错误立刻到来。理解了页表权限位你就知道这类问题的根源不是“地址不存在”而是“权限不够”。5.3 为什么malloc之后内存占用没变前面讲懒分配时提过这一点但实际操作中还是经常有人不理解。你在top里看到一个进程刚启动时RES很小后面随着访问越来越多RES不断上涨这恰恰是懒分配的典型现象虚拟地址空间早就划好了物理页面是访问到哪才分配到哪。有个细节值得留意如果程序分配了一大块内存写进去一部分然后不再碰其余部分那么RSS不会涨到VSZ那么大。这种情况是正常的操作系统不会无辜地把用不上的物理页分配给你。反过来如果你遍历并touch了一遍全部页面RSS就会迅速接近分配的虚拟大小。判断“有没有真的用上”这一点对优化内存占用非常有帮助。另一个相关现象是交换空间swap。当物理内存吃紧时内核可以把某些进程的物理页换出到磁盘页表里标记为不在内存。进程再次访问这些页面时缺页异常会负责换入。这个过程中进程的VSZ不变但RSS和物理内存占用会明显下降。线上服务如果遇到系统内存压力大多看看swap使用量和缺页异常数比单纯盯RSS更有参考价值。5.4 修改栈大小、切换ASLR等实用操作运维或开发时如果碰到某个程序的栈空间不够用可以用ulimit修改ulimit -s 16384 ./your_program这个命令把当前shell启动的程序的栈上限改成16MB。注意ulimit的修改只影响当前shell以及它启动的子进程不会全局生效。要永久修改需要改配置文件但大多数场景下临时设定就够了。ASLR级别的切换也很有用。Linux通过内核参数控制随机化的强度sysctl kernel.randomize_va_space默认值通常是2表示全面随机化。调试某些依赖固定地址的程序时可以临时设为0sysctl -w kernel.randomize_va_space0但强烈建议调试完立刻改回来因为关闭ASLR相当于削弱了系统的内存布局随机化保护别让测试机器长期处于这个状态。用gdb调试时也经常看到地址不稳定导致断点失效这时候可以靠set disable-randomization on在调试会话内部固定布局不必动全局参数。5.5 排查工具速查表工具/接口作用典型场景/proc/[pid]/maps查看虚拟地址区间和权限判断非法访问、栈溢出范围/proc/[pid]/smaps查看每段映射的RSS、PSS、私有/共享页精确定位内存占用大户pmap -x [pid]汇总进程映射与内存占用快速查看进程内存画像ps -o vsz,rss查看虚拟内存与常驻内存监控进程内存变化趋势top列出进程VIRT、RES、CODE、DATA字段系统级内存概览valgrind massif堆内存分配追踪定位内存泄漏点gdb动态调试检查地址与寄存器分析段错误的精确位置这个表格是我日常排查时的固定组合。先用pmap看轮廓再用smaps追细节最后gdb或者valgrind抓现场基本能覆盖大部分内存相关问题。6. 几个值得继续深挖的方向6.1 mm_struct与vm_area_struct虚拟地址空间的每一段映射在内核里并不是一个简单的列表而是由mm_struct描述的进程整体内存管理信息配合一组vm_area_struct来描述每一段连续的虚拟地址区域。每当你用mmap映射一段内存、扩展堆区、加载共享库内核都会创建一个新的vm_area_struct或者扩展已有的区域。理解这两个结构体之后再看maps文件会更通透每一行输出本质上就是遍历进程的vm_area_struct链表或红黑树得到的结果。当你需要写内核模块或者调试性能问题时这个视角比单纯看用户态地址分布要实用得多。6.2 execve如何搭建初始地址空间进程启动并不是直接从天而降获得一整块虚拟地址空间。execve执行新程序时内核会清空旧的地址空间映射然后根据ELF文件的程序头表把代码段、数据段映射到对应地址初始化栈、堆、共享库加载路径最后跳转到程序的入口点。整个过程环环相扣虚拟地址空间在这时被重新“铺”了一遍。如果你对ELF文件格式有一定了解可以试着对照readelf -l输出的段信息再看看程序运行后的maps会发现两者高度吻合。这种对照练习做一次你对可执行文件加载过程的记忆就会非常牢固。6.3 容器与虚拟地址空间容器技术比如常见的容器运行时依赖namespace和cgroup但进程的虚拟地址空间模型并没有因此改变。容器里的进程同样有自己的地址空间、自己的页表只是通过mount namespace等方式让它们看到不同的文件系统视图。理解虚拟地址空间对你后续看懂容器隔离机制也会有很大帮助。因为“隔离”的本质在内存层面早就由虚拟地址空间实现了。我个人的学习体会是虚拟地址空间这个知识点最奇妙的地方在于它是一个“看不见摸不着”的抽象却实实在在地决定了你写的每一行代码如何运行。学会从maps文件反推程序行为从页表理解内存开销从缺页异常理解程序性能这些能力一旦建立起来你再回去看那些曾经觉得神秘的系统问题会发现它们大多都在这个框架里找到了答案。最后再分享一个小习惯每拿到一个陌生程序先跑一遍pmap再启动它做一次典型操作观察虚拟地址空间的变化。这个操作能让你快速熟悉程序的运行模式也比裸看代码更容易理解它到底在干什么。虚拟地址空间就是这样一张百看不厌的藏宝图值得多花时间琢磨。
阅读完成 · 觉得有帮助?
咨询建站