1. 为什么首先要理解ELF格式
我在日常排查问题和做安全分析时,手里拿到的第一个对象往往就是一个ELF文件。不管它是刚编译出来的可执行程序,还是某个安了恶意软件特征的样本,只要先用file命令确认了是"ELF 64-bit LSB executable",后面所有逆向、调试、性能分析的动作都需要围绕这套格式展开。可以说,ELF就是Linux世界里最底层的通行语言,不懂它,很多深层的工具调用就只是表面功夫。
ELF全称是Executable and Linkable Format,直译就是“可执行与可链接格式”。它最初由系统V的设计者在1980年代推出,后来被Linux、各种BSD、以及绝大多数类Unix系统继承并沿用至今。Windows下的PE格式、macOS下的Mach-O格式,它们解决的是同一类问题,只是布局和细节不同。所以一旦吃透了ELF,再去看PE或者Mach-O,很多概念都是相通的,只是字段名和偏移位置需要重新记忆。
理解ELF能解决什么问题呢?往大里说,它能让你明白一个C程序从源码变成内存里运行的进程,中间到底经历了什么;往小里说,它能直接帮你定位“为什么我的程序一运行就段错误”“为什么静态链接的程序体积这么大”“为什么某个函数被编译器优化掉了”。对开发者、运维、安全研究人员来说,这都是躲不开的基础。这篇内容适合刚接触Linux底层机制的开发者,也适合做了几年应用开发但一直没系统理清文件格式的工程师。我尽量用实际例子带着大家走一遍,保证你能拿着命令亲手验证。
2. ELF文件在磁盘上的整体布局
2.1 从文件头开始认识四大组成部分
一个标准的ELF文件,解剖开之后大致由四个部分组成:文件头(ELF Header)、程序头表(Program Header Table)、节头表(Section Header Table)和它们各自指向的数据区。
文件头位于文件的最开始,固定占52字节(32位ELF是52字节,64位是64字节)。它是整个文件的“主心骨”,告诉你这个文件是什么类型、在哪种架构上运行、程序入口在哪、两张表(程序头表和节头表)分别在第几个字节、各有多少项。没有文件头,后面的一切都无从谈起。
程序头表描述的是运行视角。内核和动态链接器加载文件时只看这张表,它告诉你哪些数据需要映射到内存、映射到哪个虚拟地址、权限是只读还是可读写可执行、对齐是多少。这张表只在可执行文件和共享库中存在,纯目标文件(.o)里是没有的,因为目标文件还不参与加载。
节头表描述的是链接视角。编译器、链接器、调试器主要看这张表。每个节(Section)都有自己的名字和用途,比如.text是代码、.data是已初始化数据、.symtab是符号表。节头表是链接和调试的索引目录。
数据区就是实际内容了,代码、全局变量、字符串、调试信息全都堆在这里,分别被上面的两张表“间接引用”。
2.2 文件头里的关键字段逐一拆解
我习惯用readelf -h查看文件头,这是最直观的方式。看一个典型64位ELF的输出:
$ readelf -h /bin/ls ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x5db0 Start of program headers: 64 (bytes into file) Start of section headers: 133416 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 13 Size of section headers: 64 (bytes) Number of section headers: 30最有价值的几个字段,我逐个说一下。
Magic的前四个字节是固定的7f 45 4c 46,也就是\x7fELF。任何解析器识别ELF,第一件事就是检查这四个字节,不匹配直接报错。第五个字节是02,表示64位;如果是01就是32位。第六个字节是01,表示小端字节序。TCP/IP协议是网络字节序,ELF可执行文件在自己本地跑,所以通常沿用CPU本身的字节序,x86和ARM基本都是小端。
Type字段标识文件类型。常见的有REL(目标文件)、EXEC(可执行文件)、DYN(共享库或者启用PIE的可执行文件)、CORE(核心转储文件)。这里/bin/ls显示是DYN而不是EXEC,因为现代Linux发行版都默认开启PIE(位置无关可执行),它的基本地址不固定,加载时由内核随机分配。这个机制是缓解地址随机化(ASLR)攻击的重要手段。
Entry point address是程序第一条指令的虚拟地址。对于PIE程序,这个值表示为相对于加载基址的偏移;对于普通的EXEC文件,这就是绝对地址。Start of program headers和Start of section headers告诉你两张索引表的起始偏移。Number of program headers和Number of section headers分别是表项数量,乘以表项大小就能算出表的结束位置。
2.3 用命令亲手验证文件头
光看输出还不够,我会建议你用xxd直接看原始字节,把文件头的几个关键数字亲手对一下。拿一个简单程序举例:
$ cat hello.c #include <stdio.h> int main() { puts("hello"); } $ gcc -o hello hello.c $ xxd -l 64 hello 00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............ 00000010: 0300 3e00 0100 0000 4012 0000 0000 0000 ..>.....@....... 00000020: 4000 0000 0000 0000 3802 0000 0000 0000 @.......8....... 00000030: 1c00 1900 0800 0000 0000 0000 0000 0000 ................从第16字节开始是03 00 3e 00,低位在前,即Type=3(DYN),Machine=0x3e(即62,x86-64)。第24字节是入口地址0x1240,这个地址相对基址的偏移很小,因为PIE把启动代码放在很靠前的位置。第32字节40 00 00 00是程序头表偏移64字节,正好紧跟在文件头后面。第40字节38 02 00 00是节头表偏移0x238,也就是568字节处。
养成“先看readelf输出,再用xxd验证原始字节”的习惯,你会对格式的理解扎实很多,至少以后再遇到任何异常文件,能自己动手排查。这一步没有捷径。
提示:
readelf的版本和你系统上binutils的版本有关。新版本会多显示一些字段,比如GNU_PROPERTY相关的属性节,但核心结构始终不变。
3. 节(Section)与段(Segment):两个视角的映射关系
3.1 节:编译器眼中的文件区块
节是ELF文件内部按功能划分的数据块。编译器每生成一个目标文件,就会产生一系列节:.text放机器码,.data放已初始化的全局变量,.bss放未初始化的全局变量,.rodata放只读常量,.symtab放符号表,.strtab放符号名称字符串,.shstrtab放节名称字符串。
用readelf -S看节表,你能直观体会到这种划分:
$ readelf -S hello There are 30 section headers, starting at offset 0x238: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .interp PROGBITS 0000000000000318 00000318 000000000000001c 0000000000000000 A 0 0 1 [ 2] .note.gnu.property NOTE 0000000000000338 00000338 0000000000000020 0000000000000000 A 0 0 8 ...每一节都有名称、类型、地址、文件偏移、大小、标志等属性。Flags里的字母含义很重要:A表示Alloc,加载时需要分配内存;W表示Writable;X表示Executable;I表示Info(系统保留)。编译器在生成目标文件时,会根据代码属性的不同把内容分类放进不同节,这样链接器才有得编排。
其中.bss节比较特殊,它在文件里不占用任何实际字节(Size显示有值,但Offset往往紧跟在数据区末尾,实际文件里没存内容)。之所以可以这样,是因为未初始化的全局变量在加载时全填零,没必要在磁盘上存一堆零字节。这是ELF的经典空间优化。
3.2 段:内核加载视角下的内存布局
程序头表描述的段(Segment)则是内核真正关心的。每个段都是一块连续的、有相同权限属性的数据。内核加载ELF时,并不是把每个节单独映射进内存,那样太浪费页表项了,而是把相邻的、权限相同的节合并到一起作为一段,一次性mmap进内存。节到段的合并规则由链接器的脚本(linker script)控制。
默认情况下,链接器会把.text、.rodata、.note等只读节合并成一个只读段(对应readelf -l里的LOAD类型,一般标注R E权限),把.data、.bss等可写节合并成一个可写段(R W权限)。这样映射时,一个段映射一次,权限统一设置,既省页表项又符合安全原则——不该写的代码块永远不给你写权限。
用readelf -l查看程序头表:
$ readelf -l /bin/ls Elf file type is DYN (Position-Independent Executable file) Entry point 0x5db0 There are 13 program headers, beginning at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040 0x00000000000002d8 0x00000000000002d8 R 0x8 INTERP 0x0000000000000318 0x0000000000000318 0x0000000000000318 0x000000000000001c 0x000000000000001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000003f70 0x0000000000003f70 R E 0x1000 LOAD 0x0000000000003f70 0x0000000000003f70 0x0000000000003f70 0x0000000000001130 0x0000000000002c80 R W 0x1000 DYNAMIC 0x0000000000003f98 0x0000000000003f98 0x0000000000003f98 0x00000000000001b0 0x00000000000001b0 R W 0x8 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x10 ...注意看两个LOAD类型的段。第一个从文件偏移0开始,大小0x3f70,Flags是R E,这是包含代码和只读数据的那一段。第二个从偏移0x3f70开始,大小0x2c80,Flags是R W,这是包含.data和.bss的那一段。MemSiz比FileSiz大的部分,就是.bss在内存里后续铺开的零区域。
为什么会这样设计?因为操作系统加载程序的时候,做的是mmap映射,而mmap的最小单位是页,通常4096字节。为了不浪费页,链接器必须把所有节的偏移和大小对齐到页边界,把权限相同的节前后拼接在一起。这就是为什么你写了一个1KB的代码,文件可能被填充到3KB左右,而不是精确的1KB。
3.3 从节到段的映射:链接脚本在背后拉线
如果你好奇具体哪些节归属于哪个段,可以用readelf -S和readelf -l对照着看。通常链接器使用默认脚本,但嵌入式开发或者有特殊内存布局需求时,会提供自定义链接脚本。查看默认链接脚本可以用ld --verbose,输出里能看到类似这样的片段:
. = SIZEOF_HEADERS; .text : { *(.text) *(.text.*) } .rodata : { *(.rodata) *(.rodata.*) } .data : { *(.data) *(.data.*) } .bss : { *(.bss) *(.bss.*) }每个输出节按顺序排放,所有同名输入节都被聚合到对应的输出节中。靠这个机制,编译器只管生成内容,链接器集中控制布局。在实际项目里,我要调整某个特殊全局变量放在特定地址,就是在链接脚本里给它专门开辟一个节,然后定义起始符号和结束符号。
4. 符号表与重定位:链接过程的核心
4.1 符号表:名字到底怎么和地址绑定
符号表是ELF里最像“字典”的数据结构。每个符号记录一个名字和它对应的值(通常是地址或偏移量),还有一些属性位,描述这个符号是函数还是变量、是全局的还是局部的、是否已定义。
用readelf -s查看符号表:
$ readelf -s hello Symbol table '.symtab' contains 65 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS crtstuff.c ... 23: 0000000000004010 0 OBJECT LOCAL DEFAULT 23 completed.0 ... 59: 0000000000001139 16 FUNC GLOBAL DEFAULT 13 main 60: 0000000000000000 0 FUNC GLOBAL DEFAULT UND puts@GLIBC_2.2.5这里最需要区分的就是LOCAL和GLOBAL。LOCAL符号(如静态函数、静态变量)只在目标文件内部可见,不会参与到跨文件链接。GLOBAL符号则可以被其他目标文件引用,比如这里的main。UND表示该符号在当前文件中未定义,需要从别的目标文件或共享库里找,比如puts。
符号值对于不同的符号含义不同。在目标文件里,它通常是对某个节的偏移;在可执行文件里,它往往是虚拟地址。所以解析符号表前,必须根据文件类型来理解Value字段的含义。
4.2 重定位:链接器怎么把“引用”改成“具体地址”
写C代码时我们用函数名调用函数,但CPU最终执行的指令里没有函数名,只有地址。把函数名“翻译”成地址的过程分为两步:编译时,编译器生成目标文件,遇到外部函数或全局变量,先留一个空位,并产生一条重定位记录;链接时,链接器根据符号表找到目标地址,回填到这个空位里。
重定位记录用readelf -r查看。看一个简单目标文件:
$ cat foo.c extern int global_val; void func() { global_val = 42; } $ gcc -c -o foo.o foo.c $ readelf -r foo.o Relocation section '.rela.text' at offset 0x238 contains 1 entry: Offset Info Type Sym. Value Sym. Name + Addend 0000000000000004 0000000200000002 R_X86_64_PC32 0000000000000000 global_val - 4这条记录的意思是:在.text节的偏移4处,需要放一个32位的PC相对跳转值,目标是global_val符号,附加修正值是-4。链接器做计算时,公式大致是:S + A - P,其中S是符号实际地址,A是Addend,P是正在重定位的位置。PC相对寻址要求计算目标与当前位置的差值,所以要减去P。这些公式虽然枯燥,但你在排查一些“调用外部函数崩溃”的问题时,脑子里有这条公式,就能判断是不是链接时没解析到符号,或者重定位类型选错了。
重定位的类型非常多,R_X86_64_PC32、R_X86_64_PLT32、R_X86_64_GOTPCREL只是其中一小部分。你不需要全部背下来,但至少要知道每种类型决定了“要不要加基址”“是绝对寻址还是相对寻址”,这是理解动态链接跳转结构的基础。
4.3 静态链接的过程:一步步看着地址被填上
我建议你自己动手做一次实验:编译两个小源文件,然后把.o文件的符号表、重定位表都打出来,再用gcc -static做一次完整静态链接,对比链接前后符号表的变化。你会看到链接前puts在符号表里是UND,链接后它被解析到libc.a里某个具体地址;重定位记录全部清零,因为地址已经回填完成。这个过程对理解“链接器就是地址整理器”更有帮助。
5. 动态链接与加载过程拆解
5.1 动态段与.dynamic节存了什么
现代Linux上的可执行文件几乎都是动态链接的。动态链接的骨架在PT_DYNAMIC段里,对应.dynamic节。用readelf -d查看:
$ readelf -d /bin/ls | head -30 Dynamic section at offset 0x3f98 contains 29 entries: Tag Type Name/Value 0x0000000000000001 (NEEDED) Shared library: [libselinux.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 0x000000000000000c (SYMBOLIC) 0 0x000000000000000d (INIT) 0x4d30 0x0000000000000019 (INIT_ARRAY) 0xbf40 0x000000000000001b (INIT_ARRAYSZ) 8 (bytes) 0x0000000000000005 (STRTAB) 0x4960 0x0000000000000006 (SYMTAB) 0x43e0 0x000000000000000a (SYMENT) 24 (bytes) 0x0000000000000011 (RELRO) 0xde20 0x0000000000000012 (RELA) 0x3700 0x0000000000000013 (RELASZ) 1008 (bytes) 0x0000000000000014 (RELAENT) 24 (bytes) 0x0000000000000017 (JMPREL) 0x3af0 0x0000000000000018 (BIND_NOW) 0 0x0000000000000006 (SYMTAB) 0x43e0NEEDED条目列的是这个程序依赖的共享库清单,加载器会依次加载这些库,并解析出其中定义的符号供主程序使用。STRTAB、SYMTAB分别是动态符号表和它对应的字符串表,它们的结构和静态符号表类似,但只保留动态链接需要的入口。INIT_ARRAY是构造函数数组,C++全局对象构造、.init_array里注册的初始化函数都会在这些地址上被依次调用。
5.2 GOT与PLT:两个表撑起延迟绑定
动态链接有一个关键问题:如果程序调用了100个库函数,而其中某些函数在运行过程中根本没被调用到,那在启动阶段全都解析一遍就很浪费。为此,ELF设计了延迟绑定机制,核心就是GOT(全局偏移表)和PLT(过程链接表)。
大致的流程是:调用一个外部函数时,代码里先跳到对应的PLT条目,PLT条目再跳到GOT里存放的函数地址。第一次调用时,GOT里存的不是真实函数地址,而是回到动态链接器的一段解析代码;解析器查完符号表,把真实地址写回GOT,然后跳转到目标函数。之后再调用这个函数,跳转到GOT时拿到的就是真实地址,不再需要解析。这个“先占位、后填充、只填一次”的机制,就是延迟绑定。
你可以用objdump -d看一个调用了puts的反汇编片段:
0000000000001139 <main>: 1139: f3 0f 1e fa endbr64 113d: 55 push %rbp 113e: 48 89 e5 mov %rsp,%rbp 1141: 48 8d 05 fc 0e 00 00 lea 0xefc(%rip),%rax 1148: 48 89 c7 mov %rax,%rdi 114b: e8 f0 fe ff ff call 1040 <puts@plt>这里的call 1040 <puts@plt>就是跳到PLT表项。在1040处你会看到一条间接跳转,跳到GOT里存的地址。第一次执行后,这个GOT项就被修改为真实地址。
5.3 加载器的工作流程
整个加载过程可以参照内核和动态链接器的配合来理解。内核首先把可执行文件的代码段和数据段映射到虚拟地址空间里,然后检查PT_INTERP段,找到动态链接器的路径(通常就是/lib64/ld-linux-x86-64.so.2),把这个链接器本身也映射进内存,并将控制权交给它。动态链接器随后读取.dynamic里的NEEDED条目,递归加载所有依赖库,解析重定位项,调用INIT_ARRAY里的构造函数,最后才跳转到程序入口。
我把这个顺序记住,排查“为什么程序启动时崩溃”就有一个清晰的排查线索:二进制本身是不是合法、依赖库有没有找到、构造函数段有没有问题、入口点是否合理,一条条顺着查下去。
6. 实战工具与调试技巧
6.1 readelf:解剖文件的第一工具
readelf是我用得最多的命令,它不依赖系统中的动态链接符号表,直接解析文件本身,因此在遭受攻击、链接器异常等情况下依然可以输出真实内容。常用选项我整理一下:
readelf -h <file> # 文件头 readelf -l <file> # 程序头表(段) readelf -S <file> # 节头表 readelf -s <file> # 符号表 readelf -r <file> # 重定位信息 readelf -d <file> # 动态段信息 readelf -n <file> # 注记段(note segment)排查“为什么程序跑不起来”时,先用readelf -l确认LOAD段权限是否正确,再用readelf -d确认依赖库路径是否能找到。大部分动态库缺失问题都能靠这两步定位。
6.2 objdump和nm:反汇编与符号浏览
objdump既能把二进制翻译成汇编语言,也能查看各种头信息。常用的是:
objdump -d <file> # 反汇编可执行节 objdump -t <file> # 符号表 objdump -R <file> # 动态重定位 objdump -p <file> # 文件头与段信息配合nm,可以快速扫描目标文件里的符号。nm的功能和readelf -s类似,但输出更简洁,适合快速过滤全局函数和变量。我在分析二进制体积时,会用nm --size-sort把所有符号按占用空间排序,一眼就能看出哪些函数或数据结构异常膨胀。
6.3 hexdump:在字节层面观察ELF数据
有些时候,文件头被篡改、魔数错误、字段被破坏,readelf会直接报错。这时候就要回到最原始的十六进制字节。我之前处理过一个被加密混淆的样例,readelf根本认不出来,但用hexdump -C看前64字节,发现魔数被替换成了其他值。把魔数改回7f 45 4c 46,文件才被工具链重新识别。虽然这种手工修文件的方式不常用,但遇到“反调试”或“反分析”手段时非常有效。
hexdump -C <file> | head -20对于大文件,hexdump输出太多,可以根据readelf给出的偏移直接定位到具体区域。比如想查看节名表,先看readelf -S输出中.shstrtab的Offset字段,再照这个偏移去hexdump里查看,不会浪费时间一屏一屏地翻看。
7. 常见问题排查与避坑记录
7.1 几类常见错误速查
我把自己这些年遇到过的典型问题整理成了一张速查表,可以直接照着排查。
| 现象 | 常见原因 | 排查命令 |
|---|---|---|
cannot open shared object file | 依赖的共享库不在系统搜索路径 | ldd <file>查看缺失依赖 |
file format not recognized | 魔数被破坏或文件被截断 | `xxd |
| 程序一运行就段错误 | 入口地址错误、栈不可执行/堆栈被破坏 | readelf -h看Entry point;readelf -l看GNU_STACK权限 |
| 跳转表错乱、代码跑飞 | PLT/GOT重定位未正确完成 | objdump -R <file>查看动态重定位条目 |
| 静态链接体积异常大 | 可能引入了不必要的依赖或调试节 | nm --size-sort <file>查看大符号 |
7.2 我踩过的两个坑
第一个坑是误以为Size of section headers占了文件很大比例。事实上,节头表本身只是索引,并不会因为节的数量增加而让文件暴涨。真正让二进制变大的是.debug系列调试节和符号表。想查看每一节实际占用排名,可以用readelf -S配合awk暴力排个序。发布前如果想去掉调试信息,strip命令要慎用:它对可执行文件效果很好,但对有些场景需要保留.dynamic和.dynsym,否则动态链接无法工作。我自己用的是strip --strip-unneeded,它只去掉模块静态符号和调试信息,保留动态链接必需的符号。
第二个坑是修改LD_LIBRARY_PATH带来的连锁问题。设置了错误的环境变量后,动态链接器会优先搜索指定路径,结果系统自带的libc.so.6被一个不兼容版本拦截,几乎所有命令都启动不了。排查时连ls都用不了,最后只能靠绝对路径调用/lib64/ld-linux-x86-64.so.2手动加载程序,才把环境变量清理干净。从那以后,我调试动态库时都尽量用LD_DEBUG=libs输出详细加载路径,而不是直接改环境变量全局生效,这样既能定位问题,又不污染系统环境。
7.3 调试动态链接的隐藏利器
LD_DEBUG环境变量是排查动态链接问题的隐藏利器,平时文档里不常讲。设置LD_DEBUG=libs会打印每个库的搜索路径和加载结果;设置LD_DEBUG=symbols会打印每次符号查找的结果;设置LD_DEBUG=reloc会打印每次重定位的详细过程。
$ LD_DEBUG=libs /bin/ls 2>&1 | grep "trying file" | head -20这个命令几乎可以回答“为什么这个动态库没被找到”的全部疑问。不过要注意,它会输出海量内容,最好搭配grep将结果收窄。我在做兼容性分析时,经常用它来对比不同机器上同一动态库的搜索路径差异,排查结果往往一眼见分晓。
7.4 学会使用工具链附带的小工具
除了标准的问题排查,还有几个命令对理解ELF也很有用。size命令能直接显示各节占用情况,ldd能列出动态库依赖树,objdump --private-headers能查看某些私有节。我最常推荐给新手的组合是:file先确认基本格式,readelf -h看头和入口,readelf -l看段权限,readelf -d看依赖和动态信息。四个命令依次下来,一个可执行文件的基本健康状况就完全在掌握之中了。
我个人在实际项目里最大的体会是:对ELF格式的熟悉不能只停留在概念层面,一定要配合命令和原始字节去反复验证。读十遍文档,不如亲手修改一个节的名字、观察readelf输出的变化来得直观。当然,改动文件一定要在副本上操作,否则一个不经意的字节覆盖,就可能让一个原本完好的二进制彻底报废。理解了这些机制,平时遇到任何文件加载、链接、符号相关的疑难杂症,排查效率都会比只靠搜索答案高出一大截。