从“虚拟地址空间”读到“可执行文件的装载与进程”的下半部分最大的感受就是装载这件事和我们平时嘴上说的“读文件”完全不是一回事。第六章上还在讲进程的地址空间长什么样、历史上有哪些装载方式到了下真正开始回答那个最核心的问题——双击一个几百 MB 的二进制操作系统到底做了什么让 main 跑起来为什么启动可以快到像没读文件一样读完这一篇之前很多停留在“大概懂”层面的知识点终于能串起来了。这篇笔记我会按自己读下来的逻辑线来写先从“装载是映射而不是搬运”这个根本认知说起再去看缺页异常如何把纸面映射变成真实内存接着是动态链接器在 execve 之后那段时间干的三件事然后是延迟绑定和地址无关代码这两个绕不开的机制最后用 /proc/pid/maps 和 readelf 联合观察一个真实进程的装载结果。每一节都会带上我当时实测过的输出和分析希望能给同样在读这本书的人一些参考。1. 装载的实质内核只建映射不负责搬运如果只看字面意思“装载”很容易被理解成“把文件从磁盘搬到内存”。但读完第六章下半部分你会发现这个直觉错得离谱。内核在 execve 之后做的事情更像是给你一张“借阅清单”而不是真的把整本书搬到你书桌上。1.1 从 ELF 到进程地址空间PT_LOAD 说了算ELF 文件里面有 program header里面最重要的一类段就是 PT_LOAD。内核启动程序时不是把整个文件按顺序读进来而是只遍历 program header 里所有的 PT_LOAD 段在进程的虚拟地址空间里找一块合适的区域然后把这块区域和文件里对应的部分建立映射关系。拿实际的程序试一下最直观。比如对一个简单的 C 程序编译后执行readelf -l ./a.out输出里会看到类似这样的内容LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000598 0x000598 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x00018d 0x00018d R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x000138 0x000138 R 0x1000 LOAD 0x002e00 0x0000000000003e00 0x0000000000003e00 0x000120 0x000128 RW 0x1000每一行对应一个 LOAD 段其中 offset 表示段在文件里的偏移vaddr 表示段要被映射到的虚拟地址filesz 是段在文件里占的大小memsz 是段加载到内存中应该占的大小。注意最后一行的 memsz 比 filesz 大 8 个字节多出来的就是 .bss 段——它不占文件空间却在进程的地址空间里占地方。映射时那部分会被视为全零的匿名页不需要回磁盘读内容。1.2 按页映射一个掩藏得很深的细节这里有个细节很多人会忽略内核建立映射的粒度是页通常是 4KB。但 LOAD 段的起始地址和大小不一定正好是页的整数倍。内核的实际做法是把每个段映射到它所在页的页首映射长度也对齐到页边界。也就是说一个文件段只要占了一个页的一部分这个页就会被整个或者按页表项映射进地址空间。这样做带来一个有意思的后果——地址空间里看到的段范围通常比 ELF 里写的 LOAD 段范围要大一点。段末尾那个不足一页的部分会被内核自动清零。所以从安全角度说进程永远不会在段之间的缝隙里读到别的进程的残留数据你看到的每个页都是独立的。把这个过程和图书馆预约书类比就很好懂了装载不是把书内容抄一遍放在书桌上而是录入“这本书第 10 页到第 30 页放在我书架的这个格子里”。内核给你画的格子不对应实体内容只对应“将来要从哪取、取多少”的规则。1.3 映射建立之后物理内存还是空的这一步理解很关键因为映射完成之后进程的地址空间里虽然能看到这些区域但绝大多数页表项还没有指向真实的物理页。此时 RSS物理内存占用基本接近于零VSZ虚拟内存大小却长得吓人。如果你在程序刚启动、还没执行多少代码时去看ps输出会发现 VSZ 是几百 MB而 RSS 才几 MB——这不是内存泄漏恰恰是典型的“懒装载”特征。我当时读完这节立刻做了一件事写了一个两万行的数组初始化程序先编译运行再在启动瞬间用/usr/bin/time -v看缺页次数。它让我真正意识到进程从“被 exec”到“main 开始执行”之间根本没人把整个 ELF 读进内存绝大部分内容都停留在“纸面映射”状态。2. 缺页异常真正发生“读文件”的时刻既然映射只是纸面工作那程序开始跑的时候CPU 去取指令、访问数据必然要碰那些还没有物理页对应的地址。一旦碰到就有了缺页异常。这一节我花了不少时间才彻底理顺处理器、内核和映射三者的关系。2.1 缺页异常处理流程的全貌当 CPU 访问一个虚拟地址MMU 查页表发现这一项无效或权限不匹配就会触发缺页异常。此时 CPU 会保存现场跳到内核的缺页异常处理入口。内核拿到出错地址后先判断这个地址在不在当前进程的某个 VMA 里如果不在直接给进程发 SIGSEGV如果在了再检查访问权限和 VMA 要求的权限是否匹配不匹配也是段错误。匹配之后内核再去判断这个 VMA 对应的是什么如果是文件映射就去页缓存里找对应的文件页如果是匿名映射就直接分配一个新的物理页全部清零。物理页就绪后内核把物理页的地址填进进程页表更新 TLB然后返回用户态重新执行刚才触发缺页的那条指令。这条指令这次执行时页表已经命中一切都像没发生过一样继续。2.2 minor fault 和 major fault 的区别书里把缺页分成两类minor fault轻微缺页和 major fault严重缺页。我第一次看的时候觉得名字不直观后来发现这其实是性能分析里极有用的区分。minor fault目标页已经在物理内存里只是当前进程的页表里没有映射。比如第一次访问一个共享库的代码页而另一个进程已经把这一页读进来了此时只要把自己的页表项指过去就行不涉及磁盘 IO。major fault目标页不在物理内存需要从磁盘读或者从 swap 分区换入。这是真正要等磁盘的缺页也是程序卡顿的主要嫌疑。用/usr/bin/time -v很容易看到两者的差别/usr/bin/time -v ./my_program输出里会有类似Minor (reclaiming a frame) page faults: 4382和Major (requiring I/O) page faults: 12这样的行。冷启动一个大型二进制程序major fault 通常会比较高连续启动第二次文件页很可能已经留在页缓存里这时 major fault 会显著下降minor fault 会成为绝对主力。这个现象也印证了“装载”为什么快大部分页其实在第一次触碰时才从页缓存里挂映射而不是启动时并发读。2.3 为什么说“缺页不一定是性能问题”以前我遇到程序慢第一反应就是去看是不是频繁缺页。学了 demand paging 之后才知道要分情况。minor fault 是在操作系统正常运行机制里的高频事件一次进程启动产生几千个 minor fault 非常正常完全不代表有问题。major fault 才是需要警惕的因为它背后是真实的磁盘 IO。如果想让某个程序启动更快合理的方向是“让发生 major fault 的页尽量早被读到或者干脆启动期的关键页提前预读”而不是“消除缺页”。后来我做过一个小优化一个数据服务每次冷启动都要读取近 500MB 的配置快照原本代码在 main 里用 read 全部读进来导致启动时 major fault 巨大。读这章之后我改成 mmap启动阶段只建立映射然后对热路径上最常用的几十个页调用 madvise 提前触发一次按需预读。启动时间的数字变化不大但“启动到服务就绪”的时间却少了三分之一因为大部分数据页不用真正填充了。这就是拿书里的机制直接换收益的例子。3. execve 之后的“暗时间”动态链接器在背后干的三件事很多人以为 execve 之后直接跳到可执行文件的入口就开始了实际上中间还有一段由动态链接器控制的时间。可执行文件里通常有一个 PT_INTERP 段指向动态链接器本身。内核装载好可执行文件后并不会直接进入可执行文件的入口而是先跳到链接器的入口。这也是第六章下半部分自己感觉信息密度最高的一段。3.1 链接器接下了一个烂摊子加载依赖、符号解析、初始化动态链接器接管之后要处理三件事加载所有依赖的共享库并且是递归地加载。一个库依赖另一个库链接器就会继续往深里加载直到没有新的依赖为止。构建符号解析和重定位的环境。简单说就是让每个编译单元里那些“还不知道地址”的外部符号逐步落地成真正的内存地址。运行动态库的初始化代码。ELF 文件里的.init和.init_array段就是干这个的很多全局构造函数、C 静态对象的构造逻辑都在这里。书里反复强调链接器的执行顺序不是随意的必须先加载所有依赖的库再执行初始化。因为初始化代码里很可能访问别的库的全局变量如果依赖库还没有加载那个变量地址自然解析不了。3.2 用 LD_DEBUG 看加载过程链接器的大部分细节是黑盒但 glibc 的链接器提供了一个调试开关LD_DEBUG可以直接看到它在做什么LD_DEBUGlibs ./my_program输出会打印出每个共享库的搜索路径、最终找到的文件以及实际加载的地址。比如find librarylibc.so.6 [0]; searching ... trying file/lib/x86_64-linux-gnu/libc.so.6如果把libs换成bindings还能看到每个符号是在什么时候完成绑定的。这里有个实际排查经验之前遇到一个程序启动时报无法解析某个函数所有同事一开始都在看代码后来我用LD_DEBUGlibs跑了一遍发现它加载了一个非常老的已经被废弃的库而那个库恰好也导出了同名函数把新函数的符号给“顶掉”了。这种跨库同名符号覆盖的问题是动态链接的经典坑LD_DEBUG是定位它最快的方法。3.3 库搜索路径的优先级别只记结论书里讲了链接器搜索共享库的路径顺序但我觉得真正值得记录的是这个顺序会让很多诡异问题有迹可循。通常是可执行文件 DT_RPATH、环境变量 LD_LIBRARY_PATH、缓存 /etc/ld.so.cache、默认路径 /lib、/usr/lib。实际开发中最容易踩的坑是把某个库放到 LD_LIBRARY_PATH 里又忘了这个环境变量对系统里所有程序都生效导致其他程序也加载到这个同名库。排查方法很土但有效先跑一下ldd ./my_program看每个库实际解析到哪个路径再决定是不是环境变量干扰。3.4 静态链接和动态链接的启动差异同样是这本书前半部分埋下的线静态链接的可执行文件没有 PT_INTERP 段内核装载完之后直接跳到可执行文件的入口根本不需要动态链接器。动态链接的程序启动时先跑链接器再做上面三件事然后才回到可执行文件自己的入口。所以“动态程序启动一定比静态慢”这个说法在大体上没错但因为延迟绑定下一节的存在动态程序并不是把每个符号都在启动时解析完实际总开销通常没有想象中那么高。这个知识点在面试里经常被问到但读完整章后你会发现面试题背后其实就是这一段暗时间干活的流程。4. 延迟绑定、GOT/PLT 与地址无关代码动态链接的隐蔽工程如果说前面几节讲的是“容器层面”的装载那这一节就是在看装载完成之后动态库是如何被组织到一起的。共享库有个天然难题它不知道自己会被加载到哪个地址所以编译时不能把绝对地址写死在指令里。解决这个问题的关键在于两个机制地址无关代码PIC和 PLT/GOT 表。4.1 为什么共享库必须“地址无关”一个非 PIE 的可执行文件编译时就知道自己最终会链接在固定地址比如 0x400000。但共享库不同它可能被加载到任意地址。如果指令里有“跳转到 0x12345678”之类的绝对地址加载到别的地址就得改写指令改写过后的代码页就没法在多个进程间共享了。这违背了共享库的初衷。PIC 的核心思想是把对全局数据和函数的访问改成通过“当前指令地址 偏移量”来计算。这样不管库被映射到哪个虚拟地址相对距离不变指令内容完全一样。代码页可以只保留一份物理页被几十个进程共享。这里可以类比一个场景一群人都住在一个小区大家说“我家阳台正对着小区中心花园”每个人的相对位置都不一样但描述方式都是相对的。PIC 就是让每一段代码都用这种相对语言而不是写死“我家是 3 号楼 502”。4.2 PLT/GOT 和延迟绑定为什么第一次调用慢之后快有了 PIC函数调用也不能直接写绝对地址而是改成对 GOT全局偏移表的访问。GOT 里存的是目标函数真正的地址第一次调用时目标地址还没解析所以要经由 PLT过程链接表跳板进入链接器的解析函数解析完成后把目标地址回填到 GOT。后续再调用直接从 GOT 里取地址不再走解析逻辑。这个过程用 GDB 或 objdump 看非常直观。写个简单的 C 代码调用 printf编译后objdump -d ./my_program | grep printf会看到类似这样的片段call 401030 printfplt而printfplt内部是一小段跳到 GOT 项的指令。第一遍执行到这条 call 时会把控制权交给链接器解析完成后GOT 对应表项存下 printf 的真实地址。如果你在第二次进入同一函数时再断点观察发现 GOT 里的值已经变了就说明延迟绑定生效了。延迟绑定最大的优势在于一个程序启动时不需要把所有依赖库的每个外部符号都解析完。启动路径上没调到的函数永远不解析。这也是动态程序启动比想象中快的重要理由。4.3 PIE 与 ASLR可执行文件自己也“地址无关”了书上还点到了 PIE位置无关可执行文件。PIE 和 PIC 的机制几乎一样只不过 PIC 用于共享库PIE 用于可执行文件本身。现代发行版默认把可执行文件也编成 PIE这样内核加载时可以把程序基址随机化配合 ASLR 让攻击者更难预测地址布局。检查一个程序是不是 PIE用readelf -hreadelf -h ./my_program正常情况下如果输出里Type:是DYN (Position-Independent Executable file)就是 PIE老式静态可执行文件通常是EXEC。这个字段之前看的时候没在意读完这章后再看就能体会到“DYN”三个字母背后是一整套映射机制。4.4 延迟绑定的代价延迟绑定不是没有代价。最典型的问题出现在符号解析阶段如果程序刚启动时马上要调用某个函数而这个函数所在库又没加载好就会被迫提前触发整个依赖链的解析启动反而更慢。另一个经典坑是如果库里某个构造函数里调用了自己依赖的外部函数而这个外部函数本身还没有绑定就可能在初始化阶段出现“看起来是初始化顺序错误实际是符号未绑定”的报错。遇到这类问题用LD_BIND_NOW1强制启动时全量绑定往往能让报错提前暴露方便定位到底是哪个符号出了问题。5. 一张地图看懂进程空间/proc/pid/maps 实操指南读完整章之后我做的第一件事就是把书里的图和实际进程的地址空间对照起来。/proc/pid/maps是内核暴露的一个文本文件里面每一行描述进程里的一段 VMA。学装载不及时看它基本等于白读。5.1 maps 的六列分别是什么拿一个正在跑的 shell 来看cat /proc/self/maps每一行大概是这样的结构55f3c1d00000-55f3c1e00000 r--p 00000000 08:01 1234567 /usr/bin/cat 55f3c1e00000-55f3c1f00000 r-xp 00001000 08:01 1234567 /usr/bin/cat 55f3c1f00000-55f3c2000000 r--p 00002000 08:01 1234567 /usr/bin/cat 55f3c2000000-55f3c2010000 r--p 00003000 08:01 1234567 /usr/bin/cat 55f3c2010000-55f3c2020000 rw-p 00004000 08:01 1234567 /usr/bin/cat从左到右分别是地址范围、权限r/w/x/p、偏移相对于被映射文件的偏移、设备号、inode、文件名。把前面 readelf -l 的 LOAD 段输出拿出来一对就能发现 mapps 中的偏移和 ELF 中 LOAD 段的 offset 是对应的权限也有对应关系只读代码段是r--p可执行代码段是r-xp数据段是rw-p。如果一段既不是堆也不是栈却有写权限多半是数据段或堆。5.2 堆、栈和匿名映射的识别maps 里带路径的行是文件映射路径显示为[heap]、[stack]或[vdso]的则是匿名或其他特殊映射。堆通常紧跟在程序的数据段后面地址从小向大增长栈则在高地址方向地址从大向小增长。这正好印证了书里进程地址空间布局图栈在高位堆在低位中间是文件映射和堆之间的空隙。很多时候程序崩溃打开 coredump 前的 maps 就能直接看到是哪一段区域访问越界。比如SIGSEGV发生在地址0x55f3c1e5a000在 maps 里一查发现落在/usr/bin/my_program的r-xp段说明是代码段内部地址错误通常意味着函数指针跳转到了奇怪的地方如果落在rw-p段可能是堆缓冲区溢出后往数据段写那就是另一类问题。5.3 用 readelf 推 maps建立段与文件的双向映射想彻底理解一个可执行文件的装载“readelf 看静态布局maps 看动态落点”是两个互补的动作。比如 readelf -l 里某个 LOAD 段的 offset 是 0x2e00那么在 maps 里找到对应文件且偏移 0x2e00 的那一行就知道这一整段被映射到进程地址空间的具体位置了。对于 PIE 程序每次启动的基址都不一样但段内相对位置完全一致所以只要用 “maps 中的起始地址减去起始 LOAD 段的 vaddr”就能算出这程序的加载基址。这个技巧排错时很好用。有次看一个 JIT 类运行时崩溃栈回溯里符号全是乱的我靠 maps 找出运行时的映射基址再用手动方式算出出错地址相对基址的偏移和符号表一对比问题立刻浮出水面。这套方法不管程序被 ASLR 折腾到什么地址都能把运行时问题还原回编译时符号层。6. 这章看完后几个我信了很久的错误直觉被纠正了读技术书最有价值的部分往往不是学会新东西而是发现自己以前的理解是错的。这部分记录几个我亲自踩过的“直觉偏差”希望能帮你少走弯路。6.1 “程序启动时把整个文件读进内存”——这个说法可以退役了以前调启动性能总觉得瓶颈在“加载文件”上于是想尽办法压缩 ELF 体积、减少段数量。学了映射机制之后才知道装载过程根本不读整个文件只有程序运行中真正触碰的页才会被按需读入。文件体积大不等于启动慢至少要看你启动路径上触及哪些页。把启动路径上不用的段想办法排除在映射之外才是更高效的优化方向。6.2 “内存占用高 坏事”——得先分清是否共享用 ps 看 RSS 时动态库的代码页是共享物理页的一百个进程用同一个 libc代码页只有一份。如果单单看一个进程的 RSS 就觉得“这进程很吃内存”往往会误判。真正需要关心的是 USS唯一内存或 PSS按共享比例分摊后的内存。这层认识也是建立在理解页表共享的基础上的。6.3 “动态链接一定比静态链接慢很多”——延迟绑定让差距没那么大静态链接的启动流程简单直接但代价是可执行文件巨大、每个程序都要复制一份同样的库代码。动态链接虽然有链接器介入但延迟绑定让大量解析推迟到实际调用时所以通常动态程序启动的额外开销没有想象中吓人。更需要注意的是如果启动路径恰好集中在少数几个外部符号绑定的成本会被均摊掉根本感知不到。6.4 用 mmap 替代一次性 read 的启发第六章下读完后的直接行动就是把之前文件加载的代码用 mmap 重写了一次。mmap 的启动开销是建立映射几乎为零read 的开销则是把所有数据都填充到内存用多长时间取决于文件大小。改成 mmap 后程序“启动快”但数据页碰到时会有一次缺页如果关键数据确实全都需要那用madvise(MADV_WILLNEED)提前预热热点页就是一套很完整的方案。这比单纯在代码里纠结“用 read 快还是 mmap 快”要有意义得多。整章读下来我最想留下的一个习惯是遇到任何“为什么这个二进制跑起来长这样”的疑问先读 readelf再看 maps配合 strace 和 LD_DEBUG 把过程拉开。装载的本质是映射映射的本质是延迟延迟的背后是缺页——这三个词基本能把第六章下所有的内容串起来。后面如果再碰到程序启动慢、内存占用高、偶尔段错误这类问题我会先想想现在是哪一层的哪个环节在工作是不是有某个不缺页的页被强行读了或者有一个该延迟绑定的符号被提前解析了。答案往往就在这几个问题里。 SEO 优化官网定制响应式建站教育培训建站