1. 先建立整体认知ELF不是“一个格式”而是一族格式1.1 ELF的四种“亚型”你可能天天都在碰如果你写过C/C迟早会在深夜被几个报错折磨链接阶段冒出来的undefined reference执行时出现的Exec format error还有动态库死活加载不上的cannot open shared object file。这些问题的根子全指向同一个东西——ELFExecutable and Linkable Format可执行与可链接格式。Linux下的可执行文件、动态链接库.so、编译产生的.o中间文件、甚至进程崩溃留下的core dump表面看是四种完全不同的东西底层其实是同一种格式。这一点常常被忽略很多人以为ELF就是“Linux的可执行文件”其实它远不止如此。Linux内核也好Android的Bionic链接器也好GNU工具链也好它们处理的所有二进制目标文件都是ELF。Windows用PEmacOS用Mach-O而Unix生态在System V时代定了ELF这个标准一用就用了三十多年。具体来说按文件头里e_type字段的取值ELF分成四个亚型e_type值名称常见载体用途1ET_REL.o、.a里的成员重定位文件链接器的输入2ET_EXEC早期可执行文件固定加载地址的可执行程序3ET_DYN.so、PIE可执行文件共享对象位置无关4ET_COREcore dump进程崩溃时的内存转储你随便在Linux上执行一下file /bin/ls输出大概率是ELF 64-bit LSB pie executable, x86-64。注意里面有个pie字样意思是这是一个ET_DYN类型的位置无关可执行文件采用了类似动态库的加载方式。这不是偶然现在主流发行版默认编译出来的可执行程序几乎都是PIE。所以“ELF”这个词光说“可执行文件格式”是不够准确的它更像一个容器规范装什么取决于你站在链接器、装载器还是调试器的角度。1.2 同一个文件两套视图节区用于链接段用于装载这是理解ELF最重要的一个世界观节区Section视图由Section Header Table描述是链接器、调试器和逆向工具喜欢看的。链接器按节区合并数据、解析符号、做重定位。段Segment视图由Program Header Table描述是内核和动态链接器实际使用的。内核把程序加载进内存时按段来mmap、设置读写执行权限。这两个视图看着像两套东西其实是同一块二进制数据的两种划分方式。一个段里面可能包含好几个节区比如可读可执行段通常把.text、.rodata、.init等一堆节区打包在一起而那些纯编译期信息比如.symtab符号表、.strtab字符串表、.debug_*调试信息标记为SHF_ALLOC之外的节区运行时根本不会被映射到内存里。所以你会看到一种有趣现象用readelf -S看节区能列出几十个条目用readelf -l看段通常就那么几个LOAD段。节区是“面子”段是“里子”。链接器对着节区干活内核对着段干活双方通过ELF头里两个表的位置和数量握手。1.3 谁最需要这份认知我的建议是凡是要和链接错误、动态库加载、程序崩溃、二进制分析打交道的人都值得把ELF吃透。具体来说写C/C遇到链接阶段各种诡异报错的开发者做Linux运维要看coredump的人搞逆向、CTF、恶意样本分析的安全从业者做Android开发发现so加载失败的同学。这篇文章不会只给一堆命令让你抄而是把整个链路——从文件头、节区、段、符号、重定位到动态链接——按我平时实际排查问题的顺序讲一遍。2. 打开文件第一眼ELF头那64个字节值得背下来2.1 e_ident前16个字节全是信息任何ELF文件都以7f 45 4c 46开头对应十六进制是0x7F和ASCII字符“ELF”。内核的binfmt_elf识别文件时先看这4个字节不匹配直接返回Exec format error。紧跟着的16字节e_ident里每一项都有具体含义偏移字段常见取值含义0-3EI_MAG7f 45 4c 46魔数4EI_CLASS1或21表示32位2表示64位5EI_DATA1或21表示小端2表示大端6EI_VERSION1ELF版本7EI_OSABI0或30是System V3是Linux8EI_ABIVERSION0ABI子版本9-15填充0保留这里有个小坑很多人以为EI_DATA就是“大小端”这么简单其实它还影响整个文件里所有多字节字段的解析顺序。x86和ARM默认都是小端1但ARM也有大端模式MIPS上两种都常见。如果你拿一个字节序不同的ELF文件硬解析头部字段会全部错乱readelf通常就会提示unable to read program header table之类的错误。2.2 核心字段逐一过e_type、e_machine、e_entry和两张“索引表”跳过e_ident从偏移16开始才是真正决定文件身份的字段。64位ELF头一共64字节我把它画成一张表偏移长度字段说明162e_type文件亚型前面说的REL/EXEC/DYN/CORE182e_machine目标架构204e_version通常为1248e_entry入口点的虚拟地址328e_phoffProgram Header Table在文件中的偏移408e_shoffSection Header Table在文件中的偏移484e_flags处理器相关标志522e_ehsizeELF头本身大小64位下就是64542e_phentsize每个程序头条目的字节数64位下为56562e_phnum程序头条目个数582e_shentsize每个节头条目的字节数64位下为64602e_shnum节头条目个数622e_shstrndx节名字符串表在第几个节头e_machine的取值也是查表识别的3是x86i386620x3E是x86-64400x28是ARM1830xB7是AArch64243是RISC-V。readelf -h显示Machine: x86-64读的就是这个字段。我习惯把e_phoff和e_shoff称为“两张索引表的指针”理解整个ELF就是从这两个偏移开始的从e_phoff跳过去看到的是装载段表从e_shoff跳过去看到的是链接节表。另外注意64位和32位头部尺寸不同64位e_ehsize是64、e_phentsize是56、e_shentsize是6432位对应52、32、40。所有解析工具都是靠这些字段来遍历的所以这些字段损坏的话后续全乱。2.3 亲手读一遍十六进制为了不纸上谈兵这里放一个手工构造的极简x86-64可执行文件的头部十六进制不是某个真实程序的完整输出就是头64字节的示意7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 02 00 3e 00 01 00 00 00 40 10 40 00 00 00 00 00 40 00 00 00 00 00 00 00 88 0c 01 00 00 00 00 00 00 00 00 00 40 00 38 00 01 00 40 00 03 00 02 00逐字节拆一下7f 45 4c 46魔数。0264位01小端01版本00OSABI是System V。02 00e_type2ET_EXEC固定地址的可执行文件。3e 00e_machine62x86-64。01 00 00 00e_version1。40 10 40 00 00 00 00 00e_entry0x401040程序入口。40 00 00 00 00 00 00 00e_phoff64程序头表从文件偏移64开始。00 00 00 00 40 00e_ehsize64e_phentsize56。01 00e_phnum140 00e_shentsize6403 00e_shnum302 00e_shstrndx2。注意所有多字节字段都是小端所以02 00解析为0x0002而不是0x0200。这个细节在手动排查损坏文件、写解析脚本的时候特别容易踩。3. 链接视角下的Section Header链接器的大脑3.1 Section Header的结构一个条目64字节Section Header Table是ELF的“节区目录”。每个节头64字节字段包括字段长度说明sh_name4节名在.shstrtab字符串表中的偏移sh_type4节类型sh_flags8属性如是否可写、是否分配内存、是否可执行sh_addr8节区在进程内存中的虚拟地址sh_offset8节区在文件中的偏移sh_size8节区大小sh_link4关联节区的索引sh_info4附加信息sh_addralign8对齐要求sh_entsize8如果节区是条目数组每个条目的大小sh_type常用的有SHT_PROGBITS1表示普通的程序数据SHT_SYMTAB2是符号表SHT_STRTAB3是字符串表SHT_RELA4是重定位表SHT_NOBITS8是“不占文件空间”的BSSSHT_DYNSYM11是动态符号表SHT_DYNAMIC6是动态段信息。sh_flags里最重要的是三个位SHF_WRITE0x1、SHF_ALLOC0x2、SHF_EXECINSTR0x4。SHF_ALLOC决定这个节区是否需要被加载进内存——没有这个标记的节区比如符号表、注释、调试信息文件里存在但进程运行时根本看不见。3.2 必须认识的节区.text、.data、.bss、.rodata对绝大多数程序来说下面这几个节区是核心.text可执行机器指令通常标记为AX可分配、可执行。程序所有代码都在这里。.rodata只读常量字符串字面量、全局const变量。标记为A但不可写。.data已初始化的全局变量和静态变量标记为WA可分配、可写。.bss零初始化的全局变量和静态变量标记为WA但sh_type是SHT_NOBITS文件里占用0字节。.symtab/.strtab符号表和字符串表链接器的“通讯录”。.shstrtab保存所有节名字符串的地方sh_name字段里的偏移就是指向这里。.rela.text、.rela.data重定位表链接器的“修补清单”。用readelf -S查看一个简单程序输出大致长这样我简化了Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0 0 0 0 [ 1] .text PROGBITS 0000000000401020 00001020 00000000000000c0 0 AX 0 0 16 [ 2] .rodata PROGBITS 00000000004010e0 000010e0 0000000000000028 0 A 0 0 4 [ 3] .data PROGBITS 0000000000402000 00002000 0000000000000010 0 WA 0 0 4 [ 4] .bss NOBITS 0000000000402010 00002010 0000000000000020 0 WA 0 0 4 [ 5] .symtab SYMTAB 0000000000000000 00002038 00000000000001f8 24 6 10 8注意.bss的Size是0x20但它在文件中的Offset只是“形式上的”大小为0因为SHT_NOBITS的意思是“这个节区在文件里没有字节装载时由内核按memsz清零分配”。3.3 .bss为什么能在文件里“不存在”很多新手第一次看到.bss会困惑变量定义了为什么不写进文件原因很简单零初始化数据没有任何信息量文件里存一堆0是浪费磁盘和加载时间。链接器只记录BSS的大小和地址内核在映射对应段的时候把memsz超过filesz的部分直接清零。这和段视图完全对应你会在readelf -l里看到某一个PT_LOAD段的FileSiz小于MemSiz差额就是.bss的大小。这是节区视图和段视图互相咬合最典型的例子——在链接视角BSS是NOBITS节区在装载视角它是一段“需要清零的内存”。3.4 符号表和字符串表链接器的“通讯录”符号表.symtab保存了目标文件里所有可见符号函数名、全局变量名、节区名、文件名。每个符号条目在64位ELF里是24字节关键字段是st_name索引到.strtab、st_info绑定和类型、st_value地址或偏移、st_size大小、st_shndx所属节区。readelf -s输出类似Symbol table .symtab contains 14 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 3: 0000000000000000 0 SECTION LOCAL DEFAULT 1 4: 0000000000000000 0 FILE LOCAL DEFAULT ABS foo.c 8: 0000000000000000 16 FUNC GLOBAL DEFAULT 1 foo 9: 0000000000000000 16 FUNC GLOBAL DEFAULT 1 main 10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf注意几个信息点Ndx为UND的符号比如printf表示“本文件引用了但没定义要等链接器去别处找”LOCAL符号是文件内部可见的GLOBAL是跨文件可见的.o里的函数Value通常是0或相对节区起点的偏移链接完成后才会变成绝对地址。4. 装载视角下的Program Header内核是怎么把程序“跑起来”的4.1 Program Header的结构一个条目56字节如果说Section Header是链接器看的Program Header就是内核和动态链接器看的。64位下每个条目56字节字段比节头简单字段长度说明p_type4段类型p_flags4权限R4W2X1p_offset8段内容在文件中的偏移p_vaddr8段的虚拟地址p_paddr8物理地址一般等于vaddrp_filesz8文件中的字节数p_memsz8内存中的字节数p_align8对齐要求通常是页大小0x1000p_type常用取值PT_LOAD1是真正要映射进内存的段PT_DYNAMIC2指向.dynamic节区PT_INTERP3记录动态链接器路径PT_NOTE4是构建ID等注释PT_PHDR6指向程序头表自己PT_GNU_STACK0x6474e551是可执行栈设定PT_GNU_RELRO0x6474e552表示部分只读加固区域。用readelf -l看一个典型的PIE可执行文件输出类似Elf file type is DYN (position-independent executable file) Entry point 0x1050 There are 13 program headers, starting at offset 64 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 0x001198 0x001198 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x00118d 0x00118d R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x0006e0 0x0006e0 R 0x1000 LOAD 0x002e00 0x0000000000003e00 0x0000000000003e00 0x000220 0x000500 RW 0x1000 DYNAMIC 0x002e10 0x0000000000003e10 0x0000000000003e10 0x000190 0x000190 RW 0x8 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 GNU_RELRO 0x002e00 0x0000000000003e00 0x0000000000003e00 0x000200 0x000200 R 0x1你会在中间看到几个PT_LOAD段分别标记为R、R E、R、RW。这四个段几乎可以按权限分类只读段、可执行段、只读数据段、可写数据段。注意最后一个LOAD段的FileSiz是0x220MemSiz是0x500差额0x2e0就是BSS在内存里的清零区域。4.2 内核装载流程mmap与页对齐当内核执行一个ELF可执行文件时核心逻辑并不复杂读取ELF头校验魔数、架构、类型。从e_phoff开始遍历Program Header。对每个PT_LOAD段以p_offset对应的文件位置、p_vaddr对齐后的地址、p_memsz对齐后的大小做mmap。设置段的读写执行权限也就是p_flags。根据PT_INTERP找到动态链接器通常是/lib64/ld-linux-x86-64.so.2先加载动态链接器再把控制权交给它由动态链接器完成后续依赖库加载和符号解析对静态链接程序则直接跳到e_entry。这里最反直觉的地方是“偏移和地址要对齐”。程序头里的p_vaddr通常是文件偏移对齐到页后的结果因为mmap只能按页操作。如果p_offset不是页对齐的内核照样能处理但会从文件页边界多映射一点内容然后靠权限位把多余的挡在外面。这也是为什么LOAD段p_align总是0x1000的原因——设计时就是按页对齐来的。我一般会强调“段视图就是一份mmap操作说明书”你在readelf -l里看到的每一行PT_LOAD都等价于一次mmap系统调用。这么想来ELF头之于内核就像一份“怎么把二进制变成进程”的施工图。4.3 PT_INTERP和PT_DYNAMIC动态链接的接线口PT_INTERP段只有几十字节内容就是一行路径比如/lib64/ld-linux-x86-64.so.2。它决定了程序由哪个动态链接器接管。PT_DYNAMIC段则是个数组条目结构是d_tag加一个值常见的有DT_NEEDED1依赖的共享库比如libc.so.6。DT_SYMTAB6动态符号表.dynsym的位置。DT_STRTAB5动态字符串表.dynstr的位置。DT_JMPREL23PLT重定位表.rela.plt的位置。DT_BIND_NOW24要求立即绑定不做懒绑定。readelf -d显示的就是这些内容。排查“动态库找不到”之类问题时你第一件事往往不是翻日志而是readelf -d看NEEDED列表。4.4 PIE与ASLR为什么现在可执行文件大多是ET_DYN以前编译的可执行文件是ET_EXEC加载地址写死在文件头里比如e_entry就是0x401040谁跑都是这个地址。这带来一个安全问题攻击者知道了固定地址就能精确地覆盖函数指针、ROP链目标。为了做地址空间随机化ASLR现代发行版默认把可执行文件也编成位置无关的ET_DYN加载时由内核或动态链接器随机选一个基址程序里所有地址都相对基址计算。这解释了为什么file输出里会出现pie executable它在二进制形态上更像一个.so。你在gdb里看到的入口地址和readelf -h显示的e_entry往往不一致就是因为ASLR加了一个随机偏移。顺带说一句Android上那些libxxx.so同样是ET_DYN的ELF只是加载器是Bionic linker排查思路和Linux大同小异。5. 符号与重定位所谓“链接”其实就是执行一份份“修补清单”5.1 符号表st_info里的绑定和类型符号表的核心是st_info这个字节低4位表示类型高4位表示绑定。类型上STT_FUNC2是函数STT_OBJECT1是数据对象STT_NOTYPE0是未定义类型绑定上STB_LOCAL0是文件私有STB_GLOBAL1是全局可见STB_WEAK2是弱符号——同名的多个强符号会覆盖弱符号这是GCC里__attribute__((weak))的底层机制。5.2 重定位条目链接器怎么知道往哪改、改成什么当链接器把多个.o合成可执行文件时每个目标文件里那些“先写着0、等最后填地址”的地方都对应一条重定位记录。64位ELF最常见的是RELA类型每个条目24字节r_offset要修补的位置在.o里是相对节区的偏移链接完成后变成绝对地址。r_info高32位是符号表下标低32位是重定位类型。r_addend加法修正值。对x86-64最常用的几个重定位类型如下类型值计算方式场景R_X86_64_641S A绝对地址64位直接写入R_X86_64_PC322S A - P32位PC相对地址R_X86_64_PLT324S A - P调用外部函数经PLT跳转R_X86_64_GLOB_DAT6S A填充GOT全局槽R_X86_64_JUMP_SLOT7S A填充GOT的PLT槽R_X86_64_RELATIVE8B A共享库里的基址相对修正这里S是符号最终地址A是加数P是“被修补位置”的地址B是共享库的加载基址。理解这套公式之后绝大多数和“relocation truncated”相关的报错就有了抓手。5.3 一个算到数字的重定位例子光讲公式不够我算一个具体的。假设链接后的可执行文件里有一段汇编长这样地址是我编的演示值0x4010f4: e8 00 00 00 00 call 0x4010f9 main0x5e8是call指令的操作码后面4字节是相对下一条指令的偏移。链接器拿到foo.o里的重定位记录明白这4个字节要填“跳到foo”的偏移量。此时符号foo的最终地址S 0x401120。被修补字段的地址P 0x4010f5也就是那条32位偏移所在的位置。汇编器写入的重定位加数A -4。为什么是-4因为call指令一共5字节运行时CPU把偏移相对于“下一条指令地址”P4的下一个字节即0x4010f9计算而字段的最后一个字节在P4所以要用-4把位置校正回下一条指令。于是链接器算出要写入的值S A - P 0x401120 - 4 - 0x4010f5 0x27运行到call时CPU执行0x4010f4这条5字节指令下一条指令地址是0x4010f9加上0x27正好跳到0x401120。链接器做的事本质上就是把这样一条条“这里要填多少”的算术题算完写进对应的指令字段。这也是“relocations in generic elf”这类热搜词对应的核心内容——重定位就是ELF链接机制的“心脏”。5.4 动态链接场景GOT和PLT的配合静态链接时所有符号地址在链接期就能算出来。但如果你调用的是libc里的printf主程序和libc.so.6是分开编译的printf的真实地址要等运行时才知道。这时候靠的是GOT全局偏移表和PLT过程链接表两套机制配合。链接器首先在可执行文件里为每个外部函数生成一段PLT桩子同时在.got.plt里分配一个槽位。第一次调用printf时跳到PLT桩子桩子里是jmp *GOT槽而这个槽位初始内容被链接器设置成指向桩子后面的“懒绑定引导代码”。引导代码会调用动态链接器的_dl_runtime_resolve把printf的真实地址查出来写回GOT槽。第二次再调用jmp *GOT槽就直接跳真实地址了。这个过程叫“懒绑定”。用readelf -r看可执行文件你会发现.rela.plt里的条目类型全是R_X86_64_JUMP_SLOT符号名正是那些外部函数。如果想关掉懒绑定可以在动态段里设置DT_BIND_NOW或者运行前设LD_BIND_NOW1代价是启动时把所有外部符号一次性解析完。6. 实际排查经验这些ELF相关报错你迟早会遇上6.1 “Exec format error” —— 架构不匹配cannot execute binary file: Exec format error这个报错最常见的解释有几种文件根本不是可执行格式文件是脚本但缺少#!/bin/sh最典型的是架构不匹配——比如在x86_64的Linux上运行一个ARM交叉编译的二进制或者在64位系统上执行一个32位但缺少32位运行库的程序。我排查的第一步永远是file命令file ./foo它会直接告诉你“ELF 32-bit LSB executable, ARM”之类的结论。如果架构和当前系统对不上那就得找正确的二进制或者装对应的模拟器。这是ELF里e_machine字段最直接的实战应用。6.2 “relocation truncated to fit” —— 链接器填不下了relocation truncated to fit: R_X86_64_PC32 against symbol xxx这个报错在编译大型程序时很常见。它的本质是链接器算出某个PC相对偏移需要32位但这个偏移超过了2GB的范围32位带符号数装不下。典型触发场景包括源码里有个超过2GB的.bss或.data大数组导致符号距离超过32位偏移上限或者某一个.o没加-fPIC就编进共享库某些绝对地址型重定位没法用于共享对象。解决方法按场景来编译共享库时所有源文件都加-fPIC。程序要用超大静态数据且不是共享库时可以试-mcmodellarge代价是生成代码更大更慢。如果问题是把非PIC对象链进共享库报错里通常还会写着“recompile with -fPIC”的提示。这类问题不要靠猜直接用readelf -r看看是哪个节、哪个符号的重定位出问题顺藤摸瓜比盲目加编译选项靠谱得多。6.3 “undefined reference” —— 符号表里找不到你链接阶段报undefined reference to foo意味着链接器在所有输入文件.o、.a、.so的符号表里都找不到一个GLOBAL或WEAK的foo定义。常见原因有四类函数声明了但没实现或者实现被static藏成了LOCAL符号。链接库的顺序不对。gcc的链接器是顺序搜索的如果你把-lfoo放在引用它的.o前面那么处理.o时foo还没被搜索到。这是新手最常踩的坑。C和C混编时没有加extern C导致C侧的名字被mangle成_Z3foov和C侧的foo对不上。动态库拿了但符号没导出nm -D libxxx.so看不到你要的符号。这里有个实用习惯遇到undefined reference先nm查一下目标文件或库里到底有什么符号、什么绑定类型比反复调整编译参数更有效率。6.4 动态库加载失败与“找不到某so符号”类日志动态加载相关的问题形态非常多。常见的是error while loading shared libraries: libxxx.so: cannot open shared object file还有dlopen失败后日志里出现cannot locate symbol、wrong ELF class、only ET_DYN files can be loaded等。我见过不少人在Android日志里看到类似“找不到xweb elf”这样的提示第一反应是去文件系统里找同名文件其实这类提示多半是动态链接器层面的问题某个.so缺少NEEDED依赖、ABI架构不匹配32位vs 64位、ARM vs x86、或者符号在加载时找不到。排查顺序建议是用file libxxx.so确认架构和位数。用readelf -d libxxx.so看它的NEEDED列表确认每个依赖都能找到且版本不冲突。用readelf -s libxxx.so看符号是否需要导出而没有导出。在Linux上可以用ldd检查可执行文件/共享库的依赖解析在Android上检查/system/bin/linker和linker64路径是否匹配。6.5 排错工具速查最后整理一张我平时最常用的ELF工具表算是压箱底的东西工具常用参数干什么用file无参数快速识别架构、类型、位数readelf-h / -S / -l / -s / -r / -d看头、节区、段、符号、重定位、动态信息objdump-d / -dr / -S反汇编看指令和重定位对应关系nm-n / -D / -C列符号表查定义、绑定、mangle名ldd无参数解析动态库依赖链addr2line-e / -f把地址转成函数名和行号配合coredump用strip无参数删符号表和调试信息缩小文件体积我在实际排查中养成的习惯是遇到任何二进制加载、链接、崩溃问题先跑file再跑readelf -l和readelf -d基本能解决80%的“找不到”“格式不对”“符号缺失”类问题。剩下的20%才需要真去读反汇编、看内存布局。ELF这套格式虽然老但它把所有信息都整整齐齐地放在文件里工具链又成熟只要愿意花半小时把文件头、节区、段、符号、重定位这五层结构过一遍后面排查任何二进制问题都会顺手很多。