资讯动态

ELF与地址空间:从程序头表到进程内存映射的完全解读

发布时间:2026/10/3 21:15:25 来源:尧图企业网站定制
我最早被ELF和地址空间这两个词整懵是在刚接触Linux下程序链接和加载的时候。拿一个编译好的二进制用readelf打开里面一会儿是Section节一会儿是Segment段链接时看的是节运行时用的却是段搞不清谁说了算。后来又遇到一个更实际的问题同一个二进制在A机器上跑得好好的拷到B机器上直接报cannot execute或者启动秒崩查到最后问题出在入口点、加载地址和动态链接器这三件事上。这三件事恰恰全都被ELF文件里的结构决定了而程序运行时的所有状态又都发生在进程的地址空间里。所以“ELF与地址空间”不是两个独立的知识点它们是一件事的两张脸一张是文件在磁盘上的静态编码一张是运行时内存里的动态布局。这篇文章我就从实操角度把这两张脸之间的映射关系完全拆开来讲适合正在学系统编程、排查加载崩溃问题、或者想真正看懂readelf和objdump输出的人。不需要你有多深的底层基础但至少自己动手编译运行过C程序接下来我会一步步把关键字段、映射规则和常见坑都亮出来。1. 程序能跑起来靠的不是节头表而是程序头表1.1 文件里其实有“两张地图”接触过ELF格式的人大概率都听过两个结构Section Header Table节头表和Program Header Table程序头表。它们同时存在于同一个文件里描述的东西却完全不是一回事。打个比方节头表是仓库的“物资清单”按用途把数据分类存放哪些是代码.text、哪些是只读数据.rodata、哪些是全局变量.data以及它们的符号名、调试信息程序头表则是一份“运输路线图”只关心哪些东西需要被装车运到“内存”这个目的地以及到了之后应该放在哪个地址、需要什么权限、占据多大空间。链接阶段用的是节头表把多个目标文件的节合并、重定位、生成最终布局但到了加载阶段内核根本不看节头表。内核只根据程序头表里的每个条目把相应的字节映射到虚拟地址空间。这也是很多新手第一次用strip删掉节头表后发现程序还能正常跑的原因——节头表在运行时不是必需品程序头表才是。如果你把程序头表损坏了那这个文件基本就废了内核会在加载阶段直接拒绝它。1.2 从readelf -h看ELF头的三个关键字段随便拿一个编译好的二进制执行readelf -h你会看到类似下面的输出ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1060 Start of program headers: 64 (bytes into file) Start of section headers: 13960 (bytes into file) Size of program headers: 56 (bytes) Number of program headers: 13ELF头一共64字节从Magic开始。7f 45 4c 46是固定魔数对应ASCII就是DEL E L F第5个字节02表示64位01表示小端序。这些基础信息之后真正需要盯住的字段有三个Type当前文件的类型常见值是ET_REL可重定位的目标文件、ET_EXEC固定地址的可执行文件、ET_DYN共享对象或PIE可执行文件。到了现代发行版默认编出来的可执行文件几乎都是ET_DYN原因稍后专门说。Entry point address入口点的虚拟地址。CPU从内核态返回用户态后跳转的就是这个地址。Start of program headers和Number of program headers程序头表的位置和大小。加载器解析程序头表就是从这个偏移开始读取一串固定长度的结构体。1.3 ET_EXEC与ET_DYN的地址观差异同样是可执行文件ET_EXEC和ET_DYN在“地址观”上有根本区别。ET_EXEC在链接期就把虚拟地址写死了。典型32位时代的可执行文件入口点常常是0x8048340加载器必须严格按照文件里写的地址把它们放进内存没有商量的余地。而ET_DYN生成的是一个位置无关的代码体文件里记录的地址只是一个“相对基址”实际加载到哪个地址由内核或动态链接器在运行时决定。这也是为什么你会在64位机器上看到PIE程序的入口点显示成0x1060这种小数字——它不是最终运行地址最终地址是某个随机基址加上这个偏移。提示判断一个二进制到底是不是PIE最快的办法就是看Type。如果显示DYN它大概率是PIE显示EXEC则说明编译时用了-no-pie。排查段错误时这个信息经常能帮你快速判断是不是地址写死导致的问题。我自己排查过不少崩溃问题第一件事永远是用readelf -h照一下入口点和类型再用readelf -l看程序头表。前者告诉我“文件想从哪开始跑”后者告诉我“文件想怎么被铺进内存”。这两个问题搞清楚了加载类的问题基本就解决了一半。2. 从文件到内存PT_LOAD是怎么把二进制“铺”进地址空间的2.1 一次典型编译后readelf -l输出怎么看现在动手看真实输出。我准备了一个很普通的C程序只做一件事打印一个全局变量的地址。编译命令是gcc -o demo demo.c然后执行readelf -l demo那里有一节Program Headers值得逐行看Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8 INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0005d8 0x0005d8 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x0001e1 0x0001e1 R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x0001a0 0x0001a0 R 0x1000 LOAD 0x002da0 0x0000000000002da0 0x0000000000002da0 0x000150 0x000248 RW 0x1000 DYNAMIC 0x002db8 0x0000000000002db8 0x0000000000002db8 0x0001a0 0x0001a0 RW 0x8 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 GNU_RELRO 0x002da0 0x0000000000002da0 0x0000000000002da0 0x000150 0x000150 R 0x1这部分输出是理解“加载”的关键。LOAD类型的段正是真正会通过mmap映射到进程地址空间的东西。其他段PHDR、INTERP、DYNAMIC等服务于动态链接和加载过程本身它们通常落在某个LOAD段的范围内对应同一块文件字节的多种语义。这个输出里有4个LOAD段从权限上分就是三类第一个是只读的R包含ELF头、程序头表、只读数据第二个是可读可执行R E也就是代码段.text就在这里第三个是只读R通常存放只读数据第四个是读写RW对应.data和.bss。你看最后一个LOAD段的FileSiz是0x150但MemSiz是0x248这个差值里藏着一个重要的知识点下一小节说。2.2 地址、偏移和页对齐为什么必须满足那个不等式我们以这一段为例LOAD 0x002da0 0x0000000000002da0 0x0000000000002da0 0x000150 0x000248 RW 0x1000Offset是文件偏移0x2da0VirtAddr是虚拟地址0x2da0看起来两者相等但因为Align是0x1000实际映射时不能简单认为文件偏移等于虚拟地址。加载器做的是把文件从0x2da0处开始、长度为0x150的字节映射到虚拟地址0x2da0开始的区域。同时满足一个关键的不等式VirtAddr % Align Offset % Align也就是说虚拟地址与文件偏移在对齐粒度上必须是同余的。这保证了一个LOAD段在跨越页边界时文件内容与虚拟内存的对应关系不会被“搓得错位”。如果这个条件不满足加载器会直接报错程序无法启动。在满足同余的前提下映射操作实际是以页为单位的。mmap的粒度是页x86-64下通常是4KB也就是0x1000所以即使文件中只有一小段需要映射内核也会映射整个页。多余的部分自动补零。这里就出现了一个非常常见的现象多个相邻的LOAD段明明文件里是连续的信息但映射到内存后页边界处常常会有“重叠”或“交叠映射”。很多人在/proc/self/maps里看到两个相邻区域起始地址差一个页大小会疑惑文件偏移明明只有几十个字节的差为什么要浪费一个页。这是加载器的正常行为不是内存泄露。2.3 BSS不占文件却占内存的特殊区段回到刚才那个FileSiz0x150而MemSiz0x248的段。文件里只有0x150字节的真实数据但加载到内存后这个段要占0x248字节的空间。多出来的0x248 - 0x150 0xf8个字节就是.bss的内容。.bss段存放的是未初始化或初始化为零的全局变量和静态变量。C语言规范里说未显式初始化的全局变量会被自动清零。为了实现这一点链接器不需要在文件里为这0xf8字节存一堆零那样太浪费磁盘。它只需要在程序头表里把MemSiz做得比FileSiz大加载器在映射完文件内容后会把多出来的那部分内存清零。注意这部分内存是匿名映射不依赖文件。所以你在文件系统里看这个二进制大小和它在内存里实际占用的虚拟内存大小从来就不是一回事。一个只有几KB的静态链接程序运行时虚拟内存可能是几十KB就是这个原因。这一点对排查“为什么程序启动后内存占用比二进制文件大很多”的问题特别有帮助。以后别再怀疑是内存泄露了先看看是不是.bss段在作祟。可以用readelf -S查看节表通常能看到[XX] .bss NOBITS 0000000000004040 003040 000008 00 WA 0 0 8NOBITS这个类型说明它在文件里不占空间。任何用size命令看到的bss数值都代表运行时才产生的内存需求。3. 地址空间的整体格局0x400000与0x555555…都是从哪来的3.1 进程虚拟内存的“标准户型图”我曾经被一个问题困扰很久为什么有的程序入口地址是0x400000有的是0x555555554000还有的是0x8048000后来明白这些数字背后对应的是不同的可执行文件类型和编译选项。非PIE的64位可执行文件默认加载基址是0x400000。也就是说第一个LOAD段从虚拟地址0x400000开始放入口点算出来通常是0x400xxx附近。而PIE程序由于需要地址随机化加载基址不是固定的常见值是0x555555554000附近但每次运行都会变。这个0x555555554000本身并不特殊它只是ASLR在x86-64上选出来的典型基址之一。从整个进程的角度看虚拟地址空间的标准布局大致是这样的从低到高排低地址区域PIE程序基址、或者非PIE程序的固定基址再往上一点共享库映射区ld-linux和libc.so通常在这里中间偏下堆通过brk或mmap扩展很高地址线程栈、环境变量、参数向量最高区域内核空间用户态不可访问把这个布局对应到代码运行时的感受就是全局变量在一个相对固定的低地址区局部变量在高地址的栈上动态分配的堆在两者之间。这也是为什么栈向下增长、堆向上增长两者在极端情况下会在中间相遇。3.2 ASLR下的三块随机基址ASLR地址空间布局随机化是现代系统的标配但它在不同场景下随机化的对象不一样。对于PIE程序二进制本身的加载基址是随机的对于动态链接器它的映射基址是随机的对于栈、堆、mmap基址同样也是随机的。这三块随机彼此独立。有趣的是非PIE程序的加载基址不受ASLR影响因为它必须加载到一个固定地址。所以为了提高安全性现代发行版默认把系统里几乎所有可执行文件都编成PIE。你可以在自己的机器上验证gcc -no-pie -o demo_nopie demo.c gcc -o demo_pie demo.c连续运行多次分别观察它们的启动地址。demo_nopie的入口地址固定不变demo_pie的加载基址每次都不同但相对偏移始终一致。这就是PIE的本质地址本身随机结构相对固定。3.3 用/proc/self/maps把理论对回现实理论讲再多不如直接看一眼。在C程序里写几行代码运行时读取/proc/self/maps然后打印出来。典型输出是这样的555555554000-555555555000 r--p 00000000 08:01 123456 /path/to/demo 555555555000-555555556000 r-xp 00001000 08:01 123456 /path/to/demo 555555556000-555555557000 r--p 00002000 08:01 123456 /path/to/demo 555555557000-555555558000 rw-p 00002000 08:01 123456 /path/to/demo 7ffff7a00000-7ffff7bc6000 r--p 00000000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7bc6000-7ffff7c46000 r-xp 001c6000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7c46000-7ffff7d4c000 r--p 00246000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7d4c000-7ffff7d50000 rw-p 0034c000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7d50000-7ffff7d54000 rw-p 00000000 00:00 0 7ffff7d86000-7ffff7da9000 rw-p 00000000 00:00 0 7ffff7da9000-7ffff7dad000 r--p 00000000 00:00 0 7ffff7dad000-7ffff7db0000 r-xp 00000000 00:00 0 7ffff7db0000-7ffff7db7000 r--p 00001000 00:00 0 7ffff7db7000-7ffff7dbd000 rw-p 00002000 00:00 0 7ffff7dbd000-7ffff7dc0000 r--p 00003000 00:00 0 7ffff7dc0000-7ffff7dc1000 r-xp 00000000 00:00 0 7ffff7dc1000-7ffff7dc5000 r--p 00001000 00:00 0 7ffff7dc5000-7ffff7dc9000 rw-p 00002000 00:00 0 7ffff7dc9000-7ffff7dcb000 r--p 00003000 00:00 0用这个输出去对前面的理论一切都对上了。同一个可执行文件在 maps 里有多个区域权限分别是r--p、r-xp、r--p、rw-p而且每两个区域之间地址差正好是0x1000。它们来自同一个文件的不同LOAD段文件偏移从0递增。读到这些你就能直观理解“一个程序是由多个映射区域拼起来的”而不是一个单一的连续块。这里有个容易忽略的细节普通maps输出很容易分辨主程序、libc和匿名映射。但我实际调试时更喜欢看/proc/self/smaps因为它还包含每个区域的RSS、PSS和KernelPageSize信息对排查某个特定段是不是真的占了物理内存非常有用。比如说一个巨大的.bss段在 maps 里可能显示为rw-p匿名区域但smaps里的RSS很小说明它还没有真正被触碰过。这也是Linux内存管理“按需分配”的直接表现。4. 动态链接与重定位PIE时代加载时到底谁在改地址4.1 从PT_INTERP说起ld.so怎么接管启动回到程序头表输出里的INTERP段。它只有一行INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]这个INTERP段指定了动态链接器的路径。当你执行一个动态链接的可执行文件时内核加载完程序头表里的LOAD段后发现存在INTERP段于是先把动态链接器本身映射进内存再把控制权交给它。真正的程序入口点并不是0x1060而是动态链接器内部的某个入口。动态链接器要完成的事情包括读取程序头表、加载所有依赖的共享库、处理重定位、初始化各种依赖关系最后才跳转到真正的入口点。这也是为什么在GDB里启动一个动态链接程序你会在_start之前看到一堆ld-linux的调用。很多只学过静态链接原理的人第一次看到这种场景会困惑怎么程序还没到main就已经跑了那么多代码那些代码全是动态链接器在干活。4.2 GOT/PLT和重定位表的角色动态链接的核心问题是一个共享库被加载到随机地址后代码里所有引用外部函数和全局变量的地方怎么找到真实地址答案就是重定位和GOT/PLT这两个机制的组合。GOT全局偏移表是一张数据表存放外部符号的真实地址PLT过程链接表是一段段跳转指令。以调用printf为例动态链接器加载libc.so后会把printf的真实地址写入GOT中对应的槽位。程序里调用printf的指令不直接包含printf的地址而是跳转到PLT中某个桩再通过GOT间接跳转。这种设计让共享库里的代码不需要因为加载地址不同而逐字节修补指令。重定位信息在ELF里存储为.rela.dyn和.rela.plt两个节。readelf -r可以看到具体内容Relocation section .rela.dyn at offset 0x4d8 contains 8 entries: Offset Info Type Sym. Value Symbols Name 000000003dc8 000000000008 R_X86_64_RELATIVE 0 000000003dd0 000000000008 R_X86_64_RELATIVE 0 000000004028 000000000004 R_X86_64_JUMP_SLOT 0000000000000000 printfGLIBC_2.2.5每一行都表示加载器需要修改某个地址处的值。R_X86_64_RELATIVE是PIE下最常见的一种类型它告诉动态链接器把一个基于加载基址计算出来的绝对地址写进去。R_X86_64_JUMP_SLOT对应GOT中函数地址的填充也就是printf的跳转槽。理解这些条目的意义后当你用objdump -d看到程序里充满jmp *got(%rip)之类的指令时就不会觉得奇怪了。提示静态链接的程序没有INTERP段也没有.rela.plt。如果你怀疑某个二进制应该是静态的但readelf -l里出现了INTERP那它实际上是动态的只是因为链接方式的原因把依赖库“嵌”进了启动流程之外的地方。4.3 动态库加载失败的一般排查依赖、命名空间与ELF类型动态链接是运行时才发生的事情所以它的错误也都在运行时才暴露。最常见的报错有几种cannot open shared object file: No such file or directory找不到依赖库。用ldd看依赖列表往往能发现某个.so路径指向一个不存在的文件。relocation error: symbol not found库里引用的符号在已加载的依赖里找不到。这通常是版本不匹配比如用老版本头文件编译的新程序链接了旧版库。加载后秒崩可能是.rela.dyn里的重定位项被错误处理或者库本身加载地址冲突。我实际排查过一种场景宿主程序启动后通过dlopen动态加载一个业务插件结果一直报“找不到文件”但文件明明就在指定目录下。后来发现插件依赖的另一个版本库被宿主程序提前加载了和插件需要的版本冲突。这种问题在ELF层面看就是重定位时符号解析到了“错误”的库上。用LD_DEBUGlibs,files可以清楚看到动态链接器的解析过程比瞎猜有效得多。另外还有一个和ELF类型相关的坑如果你想加载的.so文件实际上不是共享对象而是静态归档库.a或者它的e_type写的是ET_REL那dlopen同样会失败。确认一个文件到底是哪种类型永远用readelf -h看Type不要只看扩展名。5. 手写解析器与实测把ELF头读到字节级别5.1 用Python脚本解析ELF头与程序头表理解了概念之后我习惯再写一点“玩具代码”把结构体变成看得见的数据这比任何文档都直观。下面是一个极简的Python脚本只解析ELF头和程序头表import struct import sys def parse_elf(path): with open(path, rb) as f: data f.read(64) magic data[:4] if magic ! b\x7fELF: raise ValueError(not an ELF file) ei_class data[4] # 132bit, 264bit ei_data data[5] # 1little endian, 2big endian fmt if ei_data 1 else if ei_class 2: # Elf64_Ehdr e_type, e_machine, e_version struct.unpack(fmt HHI, data[16:24]) e_entry, e_phoff, e_shoff struct.unpack(fmt QQQ, data[24:48]) e_phentsize, e_phnum struct.unpack(fmt HH, data[54:58]) else: # Elf32_Ehdr e_type, e_machine, e_version struct.unpack(fmt HHI, data[16:24]) e_entry, e_phoff, e_shoff struct.unpack(fmt III, data[24:36]) e_phentsize, e_phnum struct.unpack(fmt HH, data[42:46]) print(fType0x{e_type:04x} Machine0x{e_machine:04x} Entry0x{e_entry:x}) print(fphoff{e_phoff} phentsize{e_phentsize} phnum{e_phnum}) if e_phoff 0 or e_phnum 0: print(no program header) return with open(path, rb) as f: f.seek(e_phoff) phdrs f.read(e_phentsize * e_phnum) for i in range(e_phnum): off i * e_phentsize if ei_class 2: p_type, p_flags struct.unpack_from(fmt II, phdrs, off) p_offset, p_vaddr, p_paddr struct.unpack_from(fmt QQQ, phdrs, off 8) p_filesz, p_memsz, p_align struct.unpack_from(fmt QQQ, phdrs, off 32) else: p_type, p_offset, p_vaddr, p_paddr, p_filesz, p_memsz, p_flags, p_align ( struct.unpack_from(fmt IIIIIIII, phdrs, off) ) print(fseg{i}: type0x{p_type:x} flags{p_flags} off0x{p_offset:x} fvaddr0x{p_vaddr:x} filesz0x{p_filesz:x} memsz0x{p_memsz:x} align0x{p_align:x}) if __name__ __main__: parse_elf(sys.argv[1])用这个脚本去解析任意ELF文件你会发现它与readelf -h和readelf -l的字段完全对得上。这个脚本很短但已经把“文件里的ELF头到底是什么样”这个问题彻底落地了。你可以改一改输出增加一个过滤条件例如只打印PT_LOAD段或者判断文件是不是PIE把它变成自己常用的诊断工具。5.2 同一份源码三种编译方式的对比为了把前面讲的ET_EXEC、ET_DYN和共享库三者的区别刻进脑子里我经常用同一份源码做对比实验。以Hello World为例#include stdio.h int global_a 1; int global_b; int main() { printf(addr of global_a: %p\n, global_a); printf(addr of main: %p\n, main); return 0; }分别执行三次编译gcc -no-pie -o demo_nopie demo.c gcc -fPIE -pie -o demo_pie demo.c gcc -fPIC -shared -fPIE -o libdemo.so demo.c然后逐一查看readelf -h demo_nopie | grep -E Type|Entry readelf -h demo_pie | grep -E Type|Entry file demo_nopie demo_pie libdemo.so可以看到demo_nopie的Type是EXEC入口点是一个很大的绝对地址运行多次地址不变demo_pie的Type是DYN入口点是一个相对偏移运行多次基址随机变化libdemo.so的Type也是DYN但它多了-shared编译产生的共享对象特征没有入口点概念也无法作为可执行文件直接运行。还有一个细节值得观察demo_nopie的readelf -l里第一个LOAD段的VirtAddr通常从0x400000开始而demo_pie的VirtAddr从0x0开始。这个差别直接对应加载器处理方式的不同。非PIE程序每个load段的虚拟地址是绝对地址加载器必须精确映射到那个位置PIE程序的虚拟地址只是一个相对偏移加载器会在运行时选一个基址然后做“加基址”处理。5.3 踩过的坑与排错经验最后我想分享几个实际踩过的坑它们都和“ELF与地址空间”直接相关。第一个坑用strip删过头之后用gdb调试符号和源码全丢了但程序还能跑。这个我在前面解释过因为节头表不是运行必需的。但如果你还需要调试器给你展示源码行号和变量名千万别在调试版上跑strip --strip-all至少留一个.symtab和.debug_*节。第二个坑在32位系统上把一个编译成ET_DYN的共享库硬塞到低地址区域结果加载失败。原因与mmap的MAP_FIXED有关。当动态链接器想把某个库映射到一个固定地址时如果那个地址已经被占用它会报错而不是自动换地方。这在嵌入式交叉编译场景里很常见解决方式一般是用-Wl,-Ttext-segmentxxx重新链接或者在linker script里定义好布局。第三个坑使用dlopen时被加载的库内部依赖了一个没有被导出的符号。这不是ELF格式的错而是链接可见性问题。用objdump -T查看动态符号表确认需要的符号是否在.dynsym中导出。如果目标库编译时用了-fvisibilityhidden或-Wl,--exclude-libs,ALL外部就看不到这些符号dlopen后调用必然失败。提示排查动态符号相关问题时nm -D和objdump -T是你的左膀右臂。nm -D只看动态符号不显示静态符号。如果一个库在nm -D里找不到你需要的函数却能在nm静态符号里找到多半是export列表被故意收紧了。第四个坑也是最容易让人懵的同一个“二进制”在机器A上运行正常机器B上报段错误而且地址每次都变。这种情况八成和ASLR或库版本有关。我会先在B机器上执行readelf -l对比两个机器上同一个文件的程序头表确认是不是文件传输损坏如果文件一致再关闭ASLRsetarch -R重跑一次看崩溃地址是否固定。固定了说明是地址相关的问题不固定说明是代码运行时的逻辑问题跟加载器无关。这套排查链路我用了很多年从readelf -h到readelf -l再到readelf -d看动态段再到/proc/self/maps和gdb配合整套流程下来ELF和地址空间之间的关系会变得非常清楚。到了这个程度再回看那些0x400000、0x555555554000和入口点的数字你就不会觉得它们是玄学而是能自己算出来、自己定位问题的“已知量”了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑