ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux虚拟地址空间:从进程地图到内存管理的宏观认知

Linux虚拟地址空间:从进程地图到内存管理的宏观认知 学Linux进程这一串概念的时候虚拟地址空间是个绕不开又特别容易糊过去的坎。我第一次接触到这个词是在一本操作系统的书里看到那张经典的进程地址空间布局图——从代码段一路画到栈顶——当时的感觉就是图看懂了但不知道它和我写C代码遇到的段错误、内存泄漏有什么关系。直到后来在Linux下做服务端开发排查各种诡异问题的时候才一点点把这张图立起来。这篇博客就是想把我建立宏观认知的完整过程梳理出来给正在啃进程概念、准备面试或者被虚拟内存搞晕的读者一个能落地的参考。虚拟地址空间不是一个可以忽略的定义它几乎贯穿了进程、内存管理、文件系统、多线程、甚至性能调优的所有环节。把这部分理解透了很多高深的问题会突然变得顺理成章。下面我按自己学习时的思路从到底是干什么的开始一步步说到如何在内核、命令和实际代码中印证它。1. 为什么每个进程都以为自己在独占整台机器我见过不少同学学到进程时有一个朴素的想法进程就是把可执行文件加载到内存里跑所以进程的地址就是物理内存地址。这个想法第一个绕不过去的坎就是物理内存才8GB你同时开着几十个浏览器标签、几个容器、一堆编译任务每个进程要是都直接操作物理地址早就乱成一锅粥了。操作系统为了不让大家抢地盘搞出了一个非常关键的抽象让每个进程都生活在自己独立的虚拟地址空间里。你开一个Python脚本它看到的地址空间可能从0x0000000000400000一直到0x7fffffffffff64位下用户空间通常有128TB可用从起始地址到自己独占的整个世界——尽管物理内存只有8GB但它完全感受不到有其他进程存在。这种感觉就像每个租户都拿到了一张整个仓库都是我的的地图但仓库其实是被隔成了无数小隔间操作系统在后面偷偷给你做映射确保你碰不到别人。要直观看到这个假象非常容易你只需在终端执行cat /proc/self/maps这是Linux系统给你当前shell进程展示的虚拟地址空间布局。里面每一行都是一个连续区间格式大概是560557f22000-560557f48000 r--p 00000000 08:01 132102 /usr/bin/bash 560557f48000-560557f7a000 r-xp 00026000 08:01 132102 /usr/bin/bash ... 7fff61b2c000-7fff61b4e000 rw-p 00000000 00:00 0 [stack]第一列是虚拟地址区间第二列是权限最后一列是它映射的东西。你会发现bash这个进程的地图里有可读可执行的程序代码段、有动态链接库、有堆、有栈层次分明。但如果你同时开两个终端分别执行这个命令你会看到两个进程的地址范围虽然不一样却都覆盖了从低地址到高地址的完整空间。这就是虚拟化的意义每个进程都拥有一个完整的地址空间幻觉互不干扰并且都觉得自己拥有整台机器。这个机制实际带来的好处宏观上可以归纳成三点进程隔离A进程地址空间里的0x1000和B进程地址空间里的0x1000映射到物理内存后完全是两个地方。A进程就算不小心越界写了野指针最多把自己搞成段错误不会把B进程的数据改坏。简化开发因为每个进程拿到的是连续、统一的地址空间编译器和链接器只需要按照约定好的布局生成代码不需要关心内存碎片、说不上哪儿就有个空洞这类物理问题。安全基础用户态进程即使猜到一个地址如果没有对应的页表映射也访问不了更不可能通过遍历地址去偷看别的进程甚至内核的数据。理解到这里你已经能解释程序跑起来以后到底是什么状态这个问题了程序是磁盘上的一堆文件而进程则是这幅虚构的内存世界地图加上CPU寄存器状态、文件描述符等一堆运行时信息。光看ps输出的那一堆数字是体会不到的打开/proc/pid/maps那一瞬间你才算真正看到了进程的相貌。2. 进程的地址空间布局从代码段到栈的那条完整地图既然每个进程都有独立地图那这张地图内部是怎么画的这是建立宏观认知的第二块拼图。你可以想象一个虚拟地址从低到高的数轴上依次排列着几个主要的区域。我把典型布局画成文本图高地址 --------------------------- | 内核空间用户态不可直接访问| --------------------------- | 栈向下增长 | --------------------------- | 命令行参数、环境变量 | --------------------------- | 内存映射区mmap、共享库 | --------------------------- | 堆向上增长 | --------------------------- | BSS段未初始化数据 | | 数据段.data | | 代码段.text | --------------------------- 低地址这个布局每个Linux从业者都应该闭着眼能画出来因为它是后面理解栈溢出、堆溢出、mmap文件、动态库加载的基础。**代码段.text**在最低地址附近存放的是编译后的机器指令权限通常是r-xp可读可执行p表示private。因为代码不需要在运行时被修改所以不给写权限。**数据段.data**放在代码段上方权限是rw-p存放你C代码里的全局初始化变量比如int global 1;。BSS段紧随其后存放未初始化的全局变量。艺术家般的细节在于BSS段在可执行文件里根本不占空间但进程一加载内核就在内存里帮你把这部分清零所以你在用ls看文件大小时那个文件并不大但运行起来虚拟内存里却有它。接下来是堆由malloc/new分配的内存都在这里它的起点是brk可以通过brk系统调用向上增长。然后是内存映射区动态链接库、mmap映射文件都放在这里。再往上就是栈局部变量、函数调用返回地址都在栈上它从高地址向下增长。栈和堆相向生长好处是它们在不碰撞的情况下都能尽量动态伸展。你可以用几个命令直观验证这个布局。编译一个最简单的程序#include stdio.h #include stdlib.h int global_init 100; int global_uninit; int main(void) { int local 5; int *p malloc(100); printf(main: %p\n, main); printf(global_init: %p\n, global_init); printf(global_uninit: %p\n, global_uninit); printf(local: %p\n, local); printf(heap: %p\n, p); getchar(); return 0; }编译后运行另开终端查出pid然后cat /proc/pid/maps再对照程序打印的五个地址。你会清楚地看到main的地址落在某个r-xp区间global_init落在rw-p区间local落在某个[stack]区间而malloc回来的指针落在堆附近的rw-p区间。这种动手对照的体验比背十遍图都深刻。还用得上两个命令size /bin/ls输出类似text data bss dec hex filename 103154 4568 740 108462 1a7ae /usr/bin/ls这里text就是代码段大小data是初始化数据段bss是未初始化数据段。另一个是readelf -l /bin/ls能直接看到ELF文件里LOAD段的虚拟地址和文件偏移这些LOAD段加载进内存后就会对应/proc/pid/maps里的VMA区域。有个细节很多人第一次会踩坑现代Linux默认开启了ASLR地址空间布局随机化所以你每次运行同一个程序栈地址、堆地址、动态库地址都会变这是为了防止攻击者猜固定地址实施利用。我们调试时可以用setarch -R关掉随机化或者看/proc/sys/kernel/randomize_va_space这个内核参数。初学时如果觉得地址一会儿一个样是出 bug 了不是你错了是保护机制在工作。3. 背后到底怎么翻译页表、MMU与TLB的分工虚拟地址是假的物理地址是真的那中间谁来翻译答案是MMU内存管理单元 页表 TLB的组合。现代Linux采用分页机制物理内存被切成固定大小的页框默认是4KB当然也有2MB、1GB的HugePage。虚拟地址也同样被切成页一个虚拟页对应一个物理页框这种对应关系记录在页表里。页表不是一张巨型数组而是多级结构。在x86_64下虚拟地址是48位用户空间通常只用低48位它被拆成四段索引加一个页内偏移9位PML4索引、9位页目录指针索引、9位页目录索引、9位页表索引最后12位是页内偏移。CPU访问内存时MMU硬件会利用这四段索引逐级查表最终拿到物理页框号和12位偏移拼出物理地址。为什么不直接用一张大页表以4KB页为例如果一张页表管整个48位空间你得有2^36个页表项也就是几十亿个项每个进程来一套内存直接爆炸。多级页表的好处是按需分配很多中间层的页表项是空的根本不用分配下一级表。就像公司组织架构如果所有员工都挂在总经理一个人名下协调不过来的分部门、分小组平时小组没人的部门干脆不建省事还省纸。那CPU每次访问内存都查这么多次表性能会不会很差所以现代CPU都有一个TLBTranslation Lookaside Buffer专门缓存最近用过的虚拟页到物理页的映射。TLB的命中率极高但有个痛点每次进程切换时TLB里的映射可能失效因为新进程的页表完全不同所以上下文切换成本不低。现代CPU用PCID进程上下文标识符缓解这个问题让不同进程的TLB项可以共存。我不建议你在宏观认知阶段就去硬背每级页表的名称但你要能手动做一个最简单的换算。比如32位下一个虚拟地址0x08048123页大小4KB0x1000那页号就是0x08048页内偏移是0x123。查页表得到物理页框号比如是0x20000真正的物理地址就是0x20000 0x123 0x200123。64位只是把表拆得更多层原理一脉相承。还有一个非常实用的排查脚本读/proc/pid/pagemap可以看某个虚拟页到底映射到哪个物理页框、是否驻留内存。比如你想检查自己进程某个指针地址对应页面是否存在可以用Python按位解析但要注意这个文件需要root权限格式也容易踩坑。更常用的还是看/proc/pid/smaps里面会显示每个VMA的Rss、Pss、Swap等信息用来判断内存是独占、共享还是被换出。页表机制带来的宏观结论很简单虚拟地址空间是逻辑设计物理内存只是临时映射的存储资源。正是因为有这一层翻译进程地址空间才能做到看似独立、看似连续、看似比物理内存还大的效果。4. 内核怎么维护这份地图mm_struct与VMA纸上谈兵的地址空间得靠内核在背后记账才有实际意义。每个进程在内核里对应一个task_struct其中有一个mm字段指向一个mm_struct结构体。你可以把mm_struct理解成进程地址空间的总账本。总账本里记着什么关键在于一堆地址字段start_code和end_code标记代码段范围start_data和end_data标记数据段范围start_brk和brk标记堆的起止start_stack记录栈的起始地址还有arg_start/arg_end、env_start/env_end记命令行参数和环境变量区域。这些字段在/proc/pid/statm和/proc/pid/maps里都能看到影子。当我第一次知道这些字段的存在时突然就理解了为什么老师总说进程不只是一个程序。你说的每一个段内核都在结构体里有对应的边界记录然后把这些边界拆成一个个VMA虚拟内存区域。VMA由struct vm_area_struct描述每个VMA记录了一段连续地址的起止、权限、是否文件映射、是私有还是共享以及对应的文件偏移。你在/proc/pid/maps里看到的每一行差不多就是一个VMA。你可以用一条命令快速感受mm_struct的记账效果sleep 1000 PID$! cat /proc/$PID/statm输出类似8013 422 215 3 0 8006 0这些数字以页为单位依次是虚拟内存总页数、驻留物理内存页数、共享页数、代码段页数、共享库页数、数据/栈页数。一个小sleep进程虚拟内存就有8000多页但真正驻留的只有400多页这直观体现了虚拟很大、物理很少。你可以再用pmap $PID查看更可读的映射明细也可以grep/proc/$PID/maps里的地址区间来印证。有一个比较特殊的细节内核线程没有mm_struct因为内核线程只是借用父进程的内核地址空间来执行不需要完整的用户空间地图。所以你在某些工具里看到内核线程的mm是NULL不用惊讶这是设计如此不是泄漏。当fork()创建一个子进程时Linux不会傻傻地把父进程所有物理页复制一遍而是复制mm_struct和VMA的元数据然后把物理页设置为只读共享——这就是下一节要讲的写时复制。如果直接复制1GB的进程fork会慢到让你怀疑人生有了写时复制fork往往只需要复制页表元数据开销小得多。这套账本也有出问题的时候。我们日常见到的段错误本质就是进程访问了一个没有VMA覆盖的地址或者超出了VMA的权限设置。你设一个空指针访问CPU翻译地址时发现页表里根本没有就会触发缺页异常内核查VMA后发现完全不在地图里直接给进程发SIGSEGV杀掉。排这类问题我自己的经验是先cat /proc/pid/maps看崩溃地址附近有没有合法的VMA再决定是不是空指针、越界还是栈溢出比盲目加日志快得多。5. 缺页、写时复制与按需加载虚拟地址空间如何变出内存虚拟地址空间只是一个假想的地图但物理内存是稀缺的。如果每个进程一启动就把整个地址空间都填上物理页那再大的内存也扛不住几十个进程。Linux解决这个问题的手法很聪明惰性加载。你只宣告了这块区域存在但没有实际准备物理页等到要用的那一瞬间再补上。程序启动时内核把可执行文件的LOAD段映射到地址空间但这只是建好了地图页面还没读入。CPU执行第一条指令访问到代码段某个页时MMU查页表发现这个虚拟页没有对应的物理页框——OK了触发缺页异常page fault。内核随后从文件里读取该页到物理内存建立映射CPU再重新执行那条指令。这就是按需加载demand paging。所以你会看到一个大程序启动时磁盘I/O往往集中在开头那几秒之后就是边跑边加载。缺页不只是进程启动时才有。运行时访问堆、栈、BSS的新页面也会触发缺页但这次不是从文件读而是分配一个零页并初始化。匿名页首次访问都会有这种建页动作。写一个循环不断malloc的C程序用vmstat 1观察pgfault数值你会看到缺页计数一直飙升——这些都是正常的内存成长过程。比按需加载更能体现虚拟内存哲学的是写时复制Copy-On-WriteCOW。fork创建子进程时父子进程共享同一份物理页但这些页被标记为只读。只要双方都不写大家共用一份很经济一旦有一方想写CPU跳出保护错误内核发现该页实际上是对某个已有物理页的COW映射就重新分配一页把原内容复制过去再解除只读标记。整个过程触发保护错误。内核从VMA里找到该区间。分配新物理页面复制旧页内容到新页。更新页表让写方指向新页。恢复可写。继续执行。这么一来fork的快和进程内存的隔离就同时成立了。面试时问到fork为什么快、父子进程怎么共享内存其实背后都是COW在上演。你可以写一个程序先去分配一个很大的数组再循环fork子进程最后看/proc/pid/status里的VmRSS会发现多出来的子进程并没有立刻复制完整的内存只有真正写时才会增长。这种预支承诺、事后兑现的思路和很多系统设计里的懒加载、延迟初始化异曲同工。与缺页绑在一起的还有swap换出。物理内存不够时内核可以把不常用的匿名页内容写到磁盘交换区然后在页表里标记为不在内存。当进程再次访问这个虚拟地址时又会触发一个主缺页异常major fault内核会去swap里把页面读回来。虚拟内存总量因此可以超过物理内存但代价是磁盘I/O的波动。用free -h看到的Swap一栏和vmstat里的si/so就是干这个用的。理解这套机制后你再回头想虚拟地址空间有什么用这个问题答案就立体了它不仅让进程彼此隔离、给予程序统一连续的内存模型更关键的是它支撑了按需加载、写时复制和交换这三板斧让有限的物理内存承载起了远超自身容量的进程世界。6. 把地图落到进程上从pmap到一个C程序的地址对照实战概念讲了一堆如果你没亲手拆过一个进程认知还是悬着的。这个部分我会带你完整跑一遍看一个真实进程的虚拟地址空间全貌。先起一个进程比如sleep 1000sleep 1000 PID$! pmap $PIDpmap输出会比/proc/pid/maps友好得多类似000055f3db600000 8K r-xp /usr/bin/sleep 000055f3db602000 4K r--p /usr/bin/sleep 000055f3db603000 4K rw-p /usr/bin/sleep 00007f53cd600000 1244K r-xp /usr/lib/x86_64-linux-gnu/libc.so.6 ... 00007fff12e41000 132K rw-p [stack] 00007fff12f00000 16K r--p [vvar] 00007fff12f04000 16K r-xp [vdso] total 3180K注意几件事。第一进程的虚拟内存并没有直接占用同等物理内存pmap展示的是映射关系不是立即占满。第二动态库libc的代码段很大但在多个进程间是可共享的所以物理内存里通常只保留一份。第三[stack]出现在很靠近高地址的位置而且只有132KB左右这是当前已经使用的栈区域不是栈上限栈上限通常由ulimit -s控制默认8MB。接着用我们前面那个C程序做对照。代码里打印了main、全局变量、局部变量、malloc堆地址。运行时切到另一个终端cat /proc/PID/smaps | grep -A5 734c1c4或者直接grep程序打印的地址前缀。你会发现main的地址落在可执行的匿名私有映射区权限是r-xp全局变量地址落在rw-p且不在任何文件映射里局部变量地址的区间名称是[stack]malloc返回的地址落在堆区或mmap区这取决于分配大小和glibc的arena策略。这一步几乎就是看地图的标准姿势以后排查内存问题都会用上。如果你把程序改为不断递归调用函数你会发现每次递归一层[stack]区间跟着增长一点。而疯狂malloc的话堆区或者mmap区会持续扩张。这种动态生长的过程正是虚拟地址空间作为运行时地图的生动演示。我强烈建议新手至少做一遍这个实验比任何理论都有说服力。生产环境里排查内存问题我一般按这个顺序ps -o pid,vsz,rss,comm快速看虚拟内存和驻留内存的巨大差距。pmap -x pid确认具体VMA的RSS分布。cat /proc/pid/smaps看每个区域是私有还是共享、是否换出。必要时用perf或gdb定位到具体函数栈和地址区间。有一点容易踩的坑VSZ虚拟内存大小非常大不等于真的占用那么多内存很多服务尤其是JVM虚拟地址空间会预留一大片看起来几十GB但RSS可能才几个GB。看到VSZ飚高不要慌先看RSS和smaps里的Pss把共享内存算清楚再下结论。反过来如果RSS一直上涨且Swap在增长那基本是真实的内存压力了。还有一个面试题经常问到栈和堆可以无限增长吗宏观答案是可以增长但有限制。栈受ulimit -s约束到顶会触发栈溢出段错误堆受物理内存和地址空间约束malloc到虚地址耗尽或者overcommit被拒就会返回NULL继续写就段错误。这些边界都是虚拟地址空间这门课的自然延伸理解了地图这些题就是送分题。7. 最容易踩的几个认知坑以及我现在的排查习惯我见过很多人在虚拟地址空间上的理解停在知道概念但一用就错这里集中说一下五个高频坑。第一个坑是把虚拟内存和物理内存直接划等号。上面说了这么多如果还是盯着top里的VIRT不放觉得进程占用几十GB那就跑了偏。内存分析永远是RSS、PSS、Swap这些实际占用指标优先。第二个坑是想当然地认为每一行maps都对应磁盘文件或物理页。实际上很多VMA是匿名的既没有文件也没有对应的磁盘偏移比如堆、栈、以及通过mmap(MAP_ANONYMOUS)分配的内存。它们只在虚拟地图里存在物理页是临时分配的。第三个坑是忽略权限位。我见过一个同学排查为什么访问全局变量会段错误结果发现它用mprotect把数据段设成只读了。maps里的r-xp、rw-p不只是摆设任何访问都要过权限检查这一关。一个只读文件映射到进程地址空间后你想写它就会触发保护错误而不是悄悄修改。第四个坑是忘记ASLR。你写脚本去解析某个固定地址可能脚本用着用着就失效了。排查问题时记得先确认randomize_va_space的值必要时用setarch $(uname -m) -R关闭随机化来做调试。第五个坑是低估页表/TLB对性能的影响。很多服务优化到最后瓶颈不在CPU计算而在TLB miss。这时候你会听到大页HugePages这个词。把2MB的大页给数据库、虚拟机用本质就是减少页表层级、增加TLB覆盖范围。虽然宏观认知阶段不需要你深入perf调优但至少要知道为什么会有人执着于echo 2048 /proc/sys/vm/nr_hugepages这种东西。我现在排查内存问题的习惯基本固化成了三招。第一招是遇到段错误先看dmesg最后几行内核通常会把崩溃时的指令地址和访问地址打出来然后去/proc/pid/maps如果进程还在或core文件对应的地址空间里找人。第二招是用gdb开core文件info proc mappings和bt联合看能直接定位是不是栈溢出或者堆越界。第三招是长期监控内存变化用pmap -x加时间戳快照对比看哪个VMA在悄悄长大哪个区域异常只增不减。这套方法论建立起来之后虚拟地址空间就不再是教科书里的死概念了它变成了一张你手里随时能翻的活地图。内核帮你记账MMU帮你翻译而你需要做的就是知道地图长什么样、去哪里查账、以及异常出现时该看哪一行。这大概就是建立宏观认知的完整意思——不是记住所有底层的页表格式而是心中有图排查不慌。
返回列表