ARTICLE DETAIL

资讯详情

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

内存分段深入解析:从x86硬件到ELF加载的段机制

内存分段深入解析:从x86硬件到ELF加载的段机制 关于“内存分段”我一直觉得它是内存管理系列里最容易被人误解的概念。面试的时候我经常问候选人一个问题C语言里内存分段是怎么分的十有八九会回答“有代码段、数据段、堆、栈”。这个回答不算错但如果你接着问“那 x86 的分段机制和这个有什么关系”、“ELF 文件里的 segment 又是怎么回事”很多人就开始含糊了。其实这几个“段”完全处在不同层次但名字都一样导致大家越学越晕。这篇文章我就想把这个事彻底掰清楚从操作系统为什么需要分段到 x86 硬件上分段到底怎么走再到 C 程序编译链接后的段布局、Linux 下怎么实际查看最后聊聊 segment fault 那个名的来历。这篇文章适合这些读者写 C/C 时被段错误折磨过的人准备操作系统和计算机组成面试的人以及所有想真正理解进程地址空间、ELF 加载、虚拟内存的人。我会尽量用实际操作去验证理论而不是停留在教科书定义上。1. 先把“段”这个词掰开多种语境多种面孔1.1 四个不同层次的“段”我第一次把“段”这个概念彻底搞清楚是在一个非常狼狈的调试现场。程序运行到一半直接 SIGSEGV我用 GDB 看了半天发现是一个全局数组越界写入。当时同事补了一句“你写到别的段里去了。”我第一反应是“哪个段数据段还是堆”后来才意识到他说的其实是 ELF 加载到内存后的地址空间里某个区域。同一个“段”字在四个不同场景下有完全不同的含义第一种是 C/C 程序员常说的“代码段、数据段、BSS、堆、栈”。这实际上是运行时进程地址空间的粗略划分更准确的叫法是“内存区域”或“映射区”。第二种是操作系统教科书里的“分段式存储管理”。这是一种内存分配策略程序按逻辑模块拆成若干段每个段有独立基址和长度逻辑地址由“段号 段内偏移”组成。第三种是 x86 架构手册里的“段”。CPU 硬件用段选择子去查段描述符表得到段基址、段限长、权限然后把逻辑地址转换成线性地址。这是与操作系统无关的硬件机制。第四种是 ELF 可执行文件里的“segment”。通过 program header 描述内核加载程序时按 segment 做页映射。这四个层次不是互斥的它们一条线串起来操作系统用 ELF 里的 segment 把程序加载进内存加载之后进程地址空间呈现出代码/数据/堆/栈的分区运行期 CPU 再把程序给出的每一个逻辑地址交给分段单元处理。让人晕的就是这几层都叫“段”。1.2 为什么必须分清层次如果分不清就会出现一些非常诡异的混乱认知。比如有人问“分页和分段哪个好”有人答“Linux 用的是分页所以分段早就没用了”——这话只对了一半。x86 64 位长模式下段基址确实被强制为 0看起来像“平面模式”但段描述符表、段寄存器、特权级检查依然存在指令还是通过 CS/DS/SS 这些段寄存器来限定访问属性。也就是说分段硬件没有被删除而是被简化成了“每个段都是整个地址空间”的特殊配置。反过来如果你只站在 C 语言视角谈“段”又会漏掉最核心的东西为什么一个变量会跑到 .bss 而不是 .data为什么栈段向低地址增长而堆向高地址增长页面权限是谁在设置这些问题不深入到操作系统和编译器的层面单靠写代码是观察不完整的。这篇文章的写法是先讲为什么需要分段再看硬件怎么实现分段然后回到 C 程序和 ELF 文件看“段”具体长什么样最后用 Linux 工具当场验证。读完你至少不会再把这几个概念搅在一起。2. 分段机制为什么诞生连续分配的债2.1 连续分配模式的困境很多人学内存管理时觉得“分段”是凭空冒出来的算法实际上它是被逼出来的。最原始的内存管理方式是多道程序下的连续分配每个进程必须完整地、连续地放在一段物理内存里。比如内存 256MBA 进程占了 50MBB 进程占了 80MBC 进程想加载 100MB撑得下吗剩余 126MB撑得下但必须是一个连续的 100MB 空闲区。如果 A、B、C 的位置把内存切得稀碎剩下的空闲区都是二十几兆的小碎片C 就加载不了。这还不是最难受的。运行中的进程会产生动态需求栈要长、堆要长连续分配下这些扩展只能在一个固定区域里挪来挪去。如果整个进程只有一段连续空间堆和栈互相抢地盘抢不过就崩。更麻烦的是换出物理内存不够时要把某个进程整体换到磁盘等它再跑时又得整体搬回搬回去还得找一块同样大的连续空间。一个 100MB 的进程被换进换出几次内存碎片会迅速恶化。每次搬动都要重定位也就是修改所有地址相关的值代价极高。所以问题的根源在于“一整个程序 一段连续内存”这个等式太脆弱。它把程序的内部结构完全抹平了让操作系统只能在“给一整个程序找一块地方”这个粒度上去折腾。2.2 动态分区算法的碎片麻烦有人会想那我在加载进程时灵活一点找最合适的空闲区分配不就行了吗这就是动态分区分配。典型算法有三种首次适配first fit、最佳适配best fit、最坏适配worst fit。首次适配快但容易在内存前部积累小碎片最佳适配找最小够用的洞内存利用率看似高但留下的一丁点剩余空间几乎没用更容易产生大量不可用的小洞最坏适配故意找最大的洞去切想让剩余空间保持较大但最大的洞总是被优先切掉大程序来了反而找不到大洞。这些算法解决不了的核心问题是外部碎片每个进程内部没有碎片但进程之间有大量细小空闲区无法被利用。理论上可以通过紧凑compaction把所有进程挪到一起把碎片拼成大块但这要求进程在运行时能够被搬动也就是要有动态重定位能力。实现方式是每个进程配一个重定位寄存器MMU 在访问内存时自动加上基址。搬进程时只需要改寄存器值听起来很美但搬移几 GB 内存的代价是几毫秒甚至几十毫秒对今天的交互式系统完全不可接受。2.3 分段把程序拆成逻辑模块顺着“整段内存分配太粗”这个思路自然就会想到既然程序原本就是由主程序、子程序、数据、栈、堆这些逻辑模块组成的那我干脆不把整个程序看成一块而是把每个模块当成一个独立的分配单元这就是分段。分段的核心思想是程序的逻辑地址空间被划分为若干段segment每段是逻辑上完整的单元比如代码段、数据段、栈段、堆段。每个段有自己的长度和权限属性。进程在物理内存中占据的是一组段段内连续段与段之间不必相邻甚至可以不全部在内存里。用户程序给出的逻辑地址就不再是一个扁平的数字而是一个二维地址段号 S 段内偏移 W。比如段 3、偏移 0x1234。操作系统为每个进程维护一张段表表中记录每个段的段基址、段长、权限、是否在内存中等信息。MMU 访问时先用 S 查段表拿到段基址检查 W 是否超过段长如果没越界物理地址 段基址 W。这个过程极快几个周期就能完成而且越界检查是硬件自动做的。2.4 段表的使命段表项里最关键的是“段长”字段。分段机制一个很大的好处是天然支持保护。代码段可以标成只读、可执行数据段标成可读可写进程不允许越过自己的段界限去访问别人的段。CPU 在每次地址翻译时都做检查一旦 W 大于 limit就触发一个异常操作系统可以杀掉进程或者给出错误提示。“段错误”这个名字就是这么来的——某个地址翻译时违反了自己的段限制。分段还天然支持共享。两个进程如果运行同一份代码可以让它们的段表里对应代码段的表项都指向同一块物理区域权限为只读。这在连续分配下是很麻烦的因为你要确保两份代码在物理内存里完全一样而且都连续才能在节省空间的同时共享。分段下只要共享同一个段表项即可。分段也让堆和栈的动态增长变得可控。每个段是独立的分配单元栈段增长只需要检查它是否撞到邻近区域必要时分配一块更大的物理区间更新段表基址和长度即可。这比在连续分配模式下处理一个完整的“进程映像”要平滑得多。但记住分段本身并没有解决外部碎片问题。段长度可变频繁创建和撤销段会让物理内存里出现大小不一的空洞。真正把这个病根除掉的是分页。这个我在第 6 节再展开。3. x86 上的分段逻辑地址、段选择子、段描述符3.1 逻辑地址到线性地址的翻译管线操作系统教科书的“分段”是抽象的分配策略到了 x86 硬件上它就变成了非常具体的寄存器与数据结构。x86 的分段并不是操作系统可选的优化而是 CPU 指令集的一部分。在保护模式下CPU 从不直接拿程序员给出的偏移地址去访问内存它先要经过分段单元。x86 的逻辑地址由两部分组成16 位的段选择子segment selector 32/64 位的段内偏移。段选择子不是直接的段号它包含三部分13 位索引用于在描述符表中定位一个段描述符1 位 TI选择 GDT全局描述符表还是 LDT局部描述符表2 位 RPL请求特权级用于权限检查。CPU 拿到选择子后先根据 TI 找到 GDT 或 LDT再按索引取出 8 字节的段描述符。描述符里有段基址、段限长、粒度位、存在位、描述符特权级 DPL、类型等字段。硬件把段基址加上偏移同时做一系列权限和越界检查得到线性地址linear address。在线性地址之下如果开启了分页还要再经过分页单元转换成物理地址。所以在 x86 保护模式下一个虚拟地址实际要走两遍翻译逻辑地址 → 线性地址 → 物理地址。第一遍是分段第二遍是分页。3.2 段描述符里写的是什么段描述符是一个 64 位的结构里面每个字段都不是摆设。我建议你在看 Intel SDM 之前先建立一个直观认识段描述符就是在描述“一段内存从哪里开始、到哪里结束、谁能碰、怎么碰”。一个简化后的段描述符字段表大概长这样字段作用Base基址段的起始线性地址32 位体系下一般就是物理地址x86-64 长模式下大多数段基址固定为 0Limit段限长段的最大偏移配合 G 位决定单位是字节还是 4KBG粒度G0 时 limit 单位是字节G1 时 limit 单位是 4KB段最大变大S描述符类型0 表示系统段如门1 表示代码/数据段Type细分类别只读/读写、可执行/不可执行、一致性/非一致性等DPL描述符特权级段要求的特权级0 是内核3 是用户态P存在位段是否在内存中为 0 时访问会触发异常AVL软件可用的位硬件忽略举个例子Linux 内核在 32 位时代会设置一个内核代码段描述符和一个内核数据段描述符基址都是 0limit 都是 4GBDPL0用户代码段和数据段基址也是 0limit 也是 4GB但 DPL3。程序运行时 CS 指向用户代码段DS、SS 指向用户数据段。这种把所有段基址都设为 0 的模式就叫平坦模型。平坦模型下“逻辑地址”和“线性地址”数值上完全相等但这不代表分段单元没干活它依然在做权限检查和类型检查。3.3 平坦模型与 FS/GS 的幸存既然基址都是 0那分段还有什么存在意义答案是至少还有两个地方分段机制非常活跃地参与现代操作系统工作。第一个是特权级保护。CS 段描述符里的 DPL 决定了当前代码运行在 ring 0 还是 ring 3。CPU 在做任何特权指令比如修改 CR0、加载 GDT之前都会检查当前 CPL当前特权级来自 CS 的 RPL是否足够。虽然这不是完整的“分段保护”但它的确是从分段机制延续下来的检查逻辑。第二个是 FS 和 GS 段。Intel 在长模式下给 FS 和 GS 开了后门它们的基址不像 CS/DS/SS 那样被强制为 0而是可以通过模型专属寄存器Model Specific Register分别设置。操作系统把 FS/GS 的基址指向线程局部存储TLS区域比如 Windows 的 TEB、Linux 的 per-CPU 区。于是线程想要访问自己的线程局部变量时只需要用 FS:[偏移] 这类寻址方式CPU 会自动把 FS 基址加上偏移。这就是为什么编译器为每个线程生成 TLS 访问代码时用的是 FS 或 GS 段前缀。我印象很深的是有一次排查一个多线程程序gdb 里看到某一个线程的局部变量地址长得和另一个线程完全一样差点以为数据被共享了。后来用 info registers 看了 FS 的 base 才发现两个线程的 FS 基址不同虚拟地址相同但线性地址完全不同。这就是分段机制在现代系统中的真实作用。4. 编译器与链接器眼中的段ELF 的 segment 和 section4.1 section 是链接视图segment 是加载视图从程序员视角看“内存分段”最熟悉的其实是编译链接之后的那张地图。一个 ELF 文件内部既有 section节也有 segment段这两套东西经常被混着说但它们的服务对象完全不同。section 是给链接器和调试器用的。链接器在合并多个目标文件时需要知道哪些 .text 要拼在一起、哪些 .data 要归并符号表、重定位表都在 section 这一层。section header table 相当于 ELF 文件的“目录”每个 section 有自己的名字和属性比如 .text、.data、.bss、.rodata、.symtab、.strtab。segment 是给加载器用的也就是内核的 ELF loader 和动态链接器。可执行文件要加载进内存时并不需要一个 section 一个 section 地单独映射那样页表太碎浪费 TLB。更合理的做法是把具有相同权限、相似属性的多个 section 合并成一个更大的程序段以 segment 为单位做内存映射。program header table 描述的就是这些 segment。一个 PT_LOAD 类型的 segment 通常对应一个权限组合可读可执行的是代码段可读可写的是数据段。所以你会看到.text、.rodata 可能都在同一个可执行的 LOAD 段里.data 和 .bss 则在同一个可读写的 LOAD 段里。文件里 section 很多但在加载视图里段的数量很少而且每个段的页权限非常清晰。4.2 C 变量与段归属那么一个 C 程序里的变量到底会进哪个段这个我建议背下来因为它直接关系到程序体积和内存占用未初始化的全局变量和静态变量进 .bssBlock Started by Symbol它在文件中不占空间但加载时会分配内存并清零已初始化的非零全局变量和静态变量进 .data它的值在文件里是真实存在的只读常量、字符串字面量进 .rodata通常被链接到只读 segment函数体编译后的机器码进 .text局部变量进栈malloc/new 出来的内存进堆而栈和堆在运行时才建立不在 ELF 文件里但它们的区域由操作系统加载器和动态链接器预留。特别注意 .bss 和 .data 的区别。你定义一个char arr[1024*1024] {0};放在全局链接器会把它放进 .bss可执行文件的大小几乎不增加如果你定义char arr[1024*1024] {1};它就进 .data这一兆字节就得老老实实写进文件里。我见过有人把一个大数组初始化成非零数据导致可执行文件体积异常膨胀排查半天才意识到是这个原因。4.3 进程地址空间的最终布局一个 ELF 可执行文件被内核加载并运行时进程地址空间并不是只有 ELF 映射出来的那几段。经典布局从低地址到高地址大概是区域内容权限只读段.text / .rodata映射自 ELF 的 R-X LOAD 段r-x数据段.data / .bss映射自 ELF 的 RW LOAD 段rw-堆由 brk 扩展或由 mmap 映射大块空间rw-共享库区域依赖的 .so 每个都有各自的段混合栈线程调用栈向下增长rw-环境变量与命令行参数位于栈顶附近rw-内核映射区用户态不可访问地址最高---要注意 ASLR 开启后堆、栈、共享库的起始地址都会随机化每次运行可能都不一样。这给调试带来一点麻烦所以 GDB 经常会默认禁用 ASLR 以便复现。你只要知道这是安全机制不是玄学。5. 实地观察用 readelf、size、/proc/self/maps 把段看个清楚5.1 size 一秒钟看清 text/data/bss理论说了这么多不如跑点命令。写个最简单的 C 文件#include stdio.h int g_uninit[1000]; int g_init[1000] {1, 2, 3}; const char *msg hello, segment; int main(void) { printf(%s\n, msg); return 0; }编译后执行gcc -o segdemo segdemo.c size segdemo输出大概是这样text data bss dec hex filename 1574 1024 4000 6598 19c6 segdemotext是代码段大小data是含初始化数据的段大小bss是未初始化数据段大小。这里 data 是 1024因为g_init是 1000 个 int 占 4000 字节还有几个零碎的对象bss 是 4000对应g_uninit。msg指针本身在 .data它指向的字符串字面量 “hello, segment” 在 .rodata被合并进只读段不计入 data。如果你把g_init改成int g_init[1000] {0};它会变化吗试试就知道它会被链接器优化进 .bssdata 大幅缩小。5.2 readelf -l 看加载段size只是看文件层面的尺寸再深入一步用readelf -l查看 program headersreadelf -l segdemo输出里会有一组LOAD类型条目关键字段是Offset段在文件中的起始偏移VirtAddr加载到内存时的虚拟地址FileSiz段在文件里占用的字节数MemSiz段在内存中占据的字节数Flags段权限R、E 组合Align对齐要求。你会发现两个有趣的现象。第一第二组 LOAD 段通常是可读写的它的 FileSiz 远小于 MemSiz原因就是 .bss 不需要在文件里存数据但加载时操作系统必须为它分配内存并清零。第二VirtAddr 往往对齐到 0x10004KB的倍数因为最终这些段要被逐个页面映射页表要求地址对齐。5.3 /proc/self/maps 看运行时刻文件层面的 segment 看不完全运行时的地址空间才是真正的“分段”。Linux 下最直观的方式是读/proc/self/maps。写一个极小的程序#include stdio.h #include stdlib.h #include unistd.h int main(void) { char cmd[64]; sprintf(cmd, cat /proc/%d/maps, getpid()); system(cmd); return 0; }编译运行会看到一长串映射每一行格式如下地址范围 权限 偏移 设备 inode 路径 555555554000-555555555000 r-xp 00000000 fd:01 1234567 /home/user/segdemo权限位的四个字符分别是读、写、执行、私有/共享。p 表示 privates 表示 shared。为什么大多数映射是 p因为进程之间共享匿名页时通过写时复制COW保证隔离映射本身就是私有的。从r-xp那段你能看到可执行文件的只读代码段rw-p段里既有 .data 又有 .bss还有堆起始区再往上能找到映射进来的动态链接器和共享库最高的用户空间区域就是栈栈后面通常还有一组 vvar/vdso 之类的内核辅助映射。我第一次系统地看这个文件时最震撼的是发现一个进程里居然有几十个映射区域根本不是教科书上“代码段、数据段、堆、栈”四张大饼。共享库的每个段、线程栈、mmap 临时区域都会占用独立映射。所谓内存分段在现代 Linux 进程里就是这一大堆 VMA虚拟内存区。内核用红黑树管理这些 VMA每个 VMA 有自己的 vm_start、vm_end、vm_flags。5.4 两个小实验栈和堆的方向理解段布局后再验证两个经典事实。第一个是栈增长方向。写个函数打印局部变量地址#include stdio.h void func(int depth) { int local depth; printf(depth%d, local addr%p\n, depth, (void *)local); if (depth 3) func(depth 1); } int main(void) { func(0); return 0; }在我的 x86-64 Linux 上输出里地址逐渐变小比如depth0 的 local 在 0x7ffcaa123450depth1 的 local 在 0x7ffcaa123420差大约 48 字节其中有一部分是栈帧开销但方向很明确高地址向低地址增长。第二个是堆增长方向。连续 malloc 几次打印地址你会发现地址越来越大小分配走 brk主堆向上生长如果一次 malloc 几百 MB则可能走 mmap地址在另一片区域甚至离主堆很远。这也是“段”的动态部分堆和栈在进程整个生命周期里和文件映射段完全不同它们只是操作系统按需映射的匿名内存区。6. 为什么纯分段退居二线分页成为主角6.1 分段的先天毛病分段听起来逻辑合理又有保护又有共享为什么现代通用操作系统最终都选择了分页而不是纯分段因为分段有几个致命问题。首先是外部碎片问题依然存在。段长度可变进程创建、销毁、动态增长后物理内存会被切成很多大小不一的洞。换入换出以整个段为单位一个 1GB 的数据段可能一半在磁盘一半在内存吗纯分段体系下很难做到因为段表项里只有一个“存在位”要么整段在内存要么整段换出粒度太大。其次是段表项的管理复杂。操作系统要为每个进程维护一张段表段数量不固定段长度不固定权限组合繁多。现代程序往往有数万个内存映射区如果每个都要用“段描述符基址limit”去管理光查找和刷新缓存的成本就受不了。第三是编译、链接和动态加载的复杂性。分段意味着程序地址空间是一个个带长度的逻辑单元链接器要精确知道每个段的大小和重定位信息。而分页后逻辑地址是线性的链接器只需要关注一个平坦的虚拟地址空间。6.2 分页为什么更讨喜分页的核心思路是把物理内存切成固定大小的页帧比如 4KB虚拟地址也被切成同样大小的页。地址转换不再依赖“基址limit”这种二元的段检查而是通过多级页表查到一个页帧号再拼接偏移得到物理地址。固定大小带来一个巨大的好处外部碎片消失了。内存管理只需要维护页帧的空闲位图或链表分配一个页就是拿一个页帧回收同样简单。换入换出也按页进行一个进程可能只有少数页在磁盘上其余页还在内存这让内存利用率大大提高。分页还让权限检查和管理变得更细。每个页表项都有读写执行权限位、用户/内核位、脏位、访问位。现代操作系统把分段时代“粗粒度”的保护细化到了页级用户态只能访问当前页表映射给它的页映射很稀疏也不影响反正页表是分级的。分页的另一个杀手级能力是虚拟内存的延迟分配。操作系统可以先不给堆或栈真正分配物理页而是在首次访问时才通过缺页异常补上。这在分段体系下很难实现因为“段是否在内存”是段级的概念无法精细到某个偏移范围的页面。6.3 段思想在现代系统里还魂但分页取代分段并不代表分段的思想消失。很多系统的设计都能看到分段理念的影子。ELF 的 PT_LOAD segment 就是一种“逻辑段”思想加载时按权限和属性把多个 section 合并成若干大段。CPU 的 ring0/ring3 特权和 CS/DPL 检查本质上是段保护主义最后坚守的阵地。还有让你意想不到的带 GC 的语言运行时也像在“分段”。比如 Java 虚拟机把堆分为新生代和老年代年轻代再细分 Eden 和两个 Survivor 区每次垃圾回收只针对某一代进行这就是一种逻辑分段的策略。Julia 这类追求高性能的语言在设计内存分配时也会考虑对象代际和内存池julia 的 GC 把对象按代和线程局部分布本质上是在用“段/区”的视角管理内存而不是把整个堆当成一个难以拆分的整体。如果你去做 C/C 后端服务的性能优化你会发现 tcmalloc 或 jemalloc 把内存按对象大小分成了若干 size-class每个 size-class 管理一组独立的内存区。这其实也是“分段”变体按逻辑属性分类而不是按地址顺序硬切。所以正确的认知是现代系统用分页解决物理内存的资源管理问题用“逻辑分段”的智慧去管理程序结构、权限、代际和对象池。两者不是互相取代而是各管一段。7. segment fault 的真相段保护与分页保护的合谋7.1 名字里的历史包袱“Segmentation Fault段错误”大概是每个 C/C 程序员的启蒙导师但它那个“Segment”到底指什么很多人没想过。实际上它来自保护模式下的“segment violation”也就是访问内存时违反了段描述符里的限定偏移超过 limit、权限不足、段不存在等。CPU 会触发异常操作系统把这个事件翻译成 SIGSEGV 信号发给进程。在现代 x86-64 Linux 上大多数段描述符的 limit 都是整个地址空间所以严格意义上的“段违规”其实很少发生。你日常遇到的段错误绝大多数是分页单元的报告页表项不存在、页权限不足、用户态访问了内核页等。操作系统在缺页异常处理时发现内核无法修复这次访问就发出 SIGSEGV 通知进程。所以你可以在心里画一条时间线历史上段错误是 CPU 分段单元的越界/越权报警今天段错误是分段单元检查通过之后分页单元又抓了现行。两者共用了同一个信号名也共用了同一个“保护内存访问”的使命。7.2 现代段错误的真实来源现代 Linux 下段错误主要有这么几类空指针或野指针解引用访问 0 地址附近的页页表里根本没有映射栈溢出栈顶超过 VMA 边界访问到未映射区域写只读内存比如向字符串字面量写入或向加了mprotect(PROT_READ)的区域写入越界访问堆内存后踩到了未映射的页或用重叠的堆管理元数据释放后使用use-after-free堆管理器已经把对应页归还或重新映射为不可访问。排查手法其实就三板斧gdb 里跑看 backtrace编译加-g -fsanitizeaddress再去/var/log/kern.log或dmesg里看内核打印的页面错误信息。ASAN 会把越界、释放后使用、栈溢出都定位到源码行是效率最高的工具。7.3 一个实际排查案例我印象很深的一个问题是这样的程序偶发 SIGSEGV挂在memcpy里。gdb 看寄存器发现 dest 指针指向一个奇怪地址不是堆也不是栈而是 0x600000000000 附近某段映射的中间位置。后来用/proc/pid/maps对了一下发现 dest 落在了一个共享库的只读映射里。为什么 memcpy 会往那里写因为源数据本身是一个结构体数组某个长度字段被之前一个越界写损坏了导致循环拷贝的 length 变成一个巨大正数一路拷贝到只读区才触发页保护异常。当时我最深刻的感受是一个段错误背后的根因可能离触发点十万八千里。如果只看崩溃现场你可能觉得是 memcpy 的问题实际上责任在几十行外的数组下标边界判断。这种问题理论学得再好也还是要靠对进程地址空间的理解和工具逐步定位。“segment”这个词在这次排查里不是指 C 语言里的数据段而是一个活生生的、映射在进程地址空间里的只读区域恰好被越界写踩到了它的权限边界。我在实际排查中养成了一个习惯遇到 SIGSEGV第一件事不是猜测而是看/proc/pid/maps用gdb的info proc mappings也行把崩溃地址落到某个具体映射区域里先判断它是什么权限、属于哪个文件或匿名区。这一步能把问题范围瞬间缩小。这个思路对我帮助极大也推荐给你。内存分段这块内容到这里就算收尾了但整个内存管理系列才刚起步。分段解决的是“程序怎么按逻辑模块组织”分页解决的是“物理内存怎么按固定粒度分配”而真正把两者串联起来的虚拟内存管理才是现代系统最精彩的部分。下一篇我准备讲讲分页和多级页表的实现细节以及硬件 TLB 到底怎么让查页表这件事快到让人察觉不到。到时会用大量实测数据来说明页表层级对性能的影响想继续深挖的朋友可以留意。
返回列表