ELF 文件格式从魔数到动态链接:读懂 Linux 可执行文件的每一字节
你在 Linux 上写的每一个
hello,运行的每一个 Python 进程,背后都有一种叫 ELF 的文件格式。它决定了内核如何把磁盘上的字节变成内存里的进程。这篇文章不讲玄学,全部用真实文件 说话:先认识 ELF 的宏观布局,再逐字节解剖 ELF Header、Section、Segment,讲透符号表、重定位、动态链接(GOT/PLT),最后手写一个 Python 解析器 把它读出来,再从零构造一个 132 字节的 ELF 并在虚拟机里真实运行。文末附工具速查表。
资料版本:ELF 规范依据 TIS ELF v1.2 与 gABI(Generic ABI);实测环境为 Ubuntu 24.04(plucky, kernel 6.17, aarch64)Linux 虚拟机 + x86-64 交叉编译链,工具为 GNU binutils(readelf / objdump)14.2、gcc 14.2、Python 3.13、clang 21。文中所有 readelf / objdump 输出均为本机真实运行结果,非手工杜撰,可按命令自行复现。
目录
- [为什么要懂 ELF](#为什么要懂 ELF)
- [ELF 宏观布局:两种视图](#ELF 宏观布局:两种视图)
- [ELF Header:一切的起点](#ELF Header:一切的起点)
- Section:链接视图的主角
- 符号表与重定位:链接器的工作现场
- Segment:加载视图的主角
- [动态链接:GOT 与 PLT 的跳板戏法](#动态链接:GOT 与 PLT 的跳板戏法)
- [手写一个 ELF 解析器](#手写一个 ELF 解析器)
- [从零构造一个 132 字节的 ELF](#从零构造一个 132 字节的 ELF)
- 工具速查与常见问题
- 总结
一、为什么要懂 ELF
ELF(Executable and Linkable Format,可执行与可链接格式) 是 Linux、Android、FreeBSD、Solaris 等系统上的统一二进制格式:可执行文件、目标文件(.o)、共享库(.so)、核心转储(core dump)全部用它。
它取代了更早的 a.out 和 COFF,解决了两大痛点:
| 痛点 | ELF 的解法 |
|---|---|
| a.out 只适合单段线性加载,难以表达复杂内存布局 | 用 Segment(程序头) 描述加载到内存的布局 |
| COFF 节表能力弱,不支持现代链接器所需的重定位、动态链接信息 | 用 Section(节表) 描述链接时的布局,并内置重定位、动态链接表 |
ELF 的精髓在于一句话:同一个文件,用两种视图去看------链接器看 Section,加载器(内核 + 动态链接器)看 Segment。
类比:Section 是"图纸上的设计图"(编译链接期),Segment 是"工地上的施工图"(运行期)。设计图比施工图详细得多,但施工只认施工图。
二、ELF 宏观布局:两种视图
一个 ELF 文件在磁盘上长这样(以 64 位为例):
graph LR subgraph 文件 = 头部 + 若干"表" H"ELF Header\
64 字节\
魔数/类型/架构/入口..." P"Program Header Table\
(可选,程序头/段)\
每项 56 字节" S"Sections...\
.text .data .rodata .bss .symtab\
.strtab .rela.text .debug_\* ..." T"Section Header Table\
(节表,每项 64 字节)\
可选,strip 后可删" end H --> P --> S --> T
关键点:
- ELF Header:文件最开头 64 字节(32 位是 52 字节),是"文件的自述",告诉解析者这是什么、其余表在哪。
- Program Header Table (程序头表):运行时唯一必需的表,描述如何把文件映射成内存中的段(Segment)。objdump -h 里看不到它。
- Section Header Table (节表):链接期必需。readelf -S 看到的就是它。运行时可有可无------所以 strip 能删掉它,程序照样跑。
- 两者的关系:多个 Section 会被合并进一个 Segment(例如 .text 和 .rodata 可能同属一个只读 Segment)。
记忆:Segment 管运行,Section 管链接。 程序头表在文件前部(方便内核加载时读取),节表在文件尾部(链接器用偏移随便找)。
三、ELF Header:一切的起点
我们交叉编译一个真实的 ELF 目标文件(clang -target x86_64-unknown-linux-gnu -c bare.c,源码含全局变量、函数、字符串),看它最开头的 96 字节:
text
$ xxd -l 96 bare.o
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............
00000010: 0100 3e00 0100 0000 0000 0000 0000 0000 ..>.............
00000020: 0000 0000 0000 0000 3009 0000 0000 0000 ........0.......
00000030: 0000 0000 4000 0000 0000 4000 1800 0100 ....@.....@.....
readelf -h 会把它"翻译"成人话:
text
$ readelf -h bare.o
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
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x0
Start of section headers: 2352 (bytes into file)
Number of section headers: 24
逐字节对照(64 位):
| 偏移 | 长度 | 字段 | 本例值 | 含义 |
|---|---|---|---|---|
| 0x00 | 16 | e_ident | 7f 45 4c 46 ... | 身份标识(见下表) |
| 0x10 | 2 | e_type | 0x0001 | 文件类型 |
| 0x12 | 2 | e_machine | 0x003e (62) | 目标架构 |
| 0x14 | 4 | e_version | 0x00000001 | 版本,恒为 1 |
| 0x18 | 8 | e_entry | 0x0000000000000000 | 入口地址(目标文件为 0) |
| 0x20 | 8 | e_phoff | 0x0000000000000000 | 程序头表偏移(目标文件没有) |
| 0x28 | 8 | e_shoff | 0x0000000000000930 | 节表偏移(0x930 = 2352) |
| 0x30 | 4 | e_flags | 0x00000000 | 架构相关标志 |
| 0x34 | 2 | e_ehsize | 0x0040 | ELF 头大小(64) |
| 0x36 | 2 | e_phentsize | 0x0000 | 每个程序头大小(56) |
| 0x38 | 2 | e_phnum | 0x0000 | 程序头数量 |
| 0x3a | 2 | e_shentsize | 0x0040 | 每个节头大小(64) |
| 0x3c | 2 | e_shnum | 0x0018 | 节头数量(24) |
| 0x3e | 2 | e_shstrndx | 0x0001 | 节名字符串表所在节下标 |
e_ident 前 16 字节是精华中的精华:
| 字节 | 名称 | 值 | 含义 |
|---|---|---|---|
| 0 | EI_MAG0 | 0x7f | 魔数第 1 字节。选 0x7f 是因为它不是可打印 ASCII,避免被 less、strings 等文本工具误判 |
| 1--3 | EI_MAG1..3 | 45 4c 46 | 'E' 'L' 'F',合起来 0x7f 'E' 'L' 'F' 是唯一合法的文件头 |
| 4 | EI_CLASS | 0x02 | 1=ELF32,2=ELF64(本例) |
| 5 | EI_DATA | 0x01 | 1=小端 LSB,2=大端 MSB(本例小端) |
| 6 | EI_VERSION | 0x01 | 版本,恒为 1 |
| 7 | EI_OSABI | 0x00 | ABI:0=System V,3=Linux(历史遗留,现代基本都填 0) |
| 8 | EI_ABIVERSION | 0x00 | ABI 版本 |
| 9--15 | EI_PAD | 全 0 | 保留填充 |
e_type 决定文件身份:
| 值 | 名字 | 是什么 | 典型后缀 |
|---|---|---|---|
| 1 | ET_REL | 可重定位文件(目标文件) | .o |
| 2 | ET_EXEC | 可执行文件(非 PIE,固定地址) | 无 |
| 3 | ET_DYN | 共享目标(PIE 可执行文件 / .so) | .so、PIE 程序 |
| 4 | ET_CORE | 核心转储 | core |
注意:现代发行版默认编译出的是 PIE(Position-Independent Executable),file 会显示 pie executable,e_type=3 (ET_DYN)------它像共享库一样地址随机化加载。这在后面 Segment 一节有真实对照。
e_machine 常见值:62=x86-64,183=AArch64,3=i386,40=ARM。
四、Section:链接视图的主角
链接器干活看的是节表。还是用上面的 bare.o,objdump -h(等价 readelf -S)展示它全部 24 个节:
text
$ objdump -h bare.o
Sections:
Idx Name Size VMA Type
0 00000000 0000000000000000
1 .strtab 00000103 0000000000000000
2 .text 0000004d 0000000000000000 TEXT
3 .rela.text 00000030 0000000000000000
4 .data 00000004 0000000000000000 DATA
5 .rodata 0000000c 0000000000000000 DATA
6 .bss 00000004 0000000000000000 BSS
7 .debug_abbrev 00000093 0000000000000000 DEBUG
8 .debug_info 00000097 0000000000000000 DEBUG
9 .rela.debug_info 00000060 0000000000000000
10 .debug_str_offsets 0000003c 0000000000000000 DEBUG
...
23 .symtab 00000150 0000000000000000
每个节头(Elf64_Shdr,64 字节)的字段:
| 字段 | 含义 |
|---|---|
| sh_name | 节名在 .shstrtab 中的偏移(所以第 0 节一定是 NULL,用于索引) |
| sh_type | 节类型(见下表) |
| sh_flags | 属性位:W(1)可写、A(2)加载时分配、X(4)可执行、T(0x400)TLS 等 |
| sh_addr | 节在内存中的虚拟地址(目标文件为 0,链接后才有值) |
| sh_offset / sh_size | 在文件中的偏移 / 大小 |
| sh_link / sh_info | 关联信息(如符号表指向的字符串表下标) |
| sh_addralign | 对齐要求 |
| sh_entsize | 每个表项的大小(如符号表每项 24 字节) |
常见节类型与节:
| 节名 | sh_type | 内容 | 是否进内存 |
|---|---|---|---|
| .text | PROGBITS | 机器指令 | ✅ 可执行 |
| .rodata | PROGBITS | 只读常量(字符串、switch 跳转表) | ✅ 只读 |
| .data | PROGBITS | 已初始化全局/静态变量 | ✅ 可写 |
| .bss | NOBITS | 未初始化变量(文件里不占空间,加载时全零) | ✅ 可写 |
| .symtab | SYMTAB | 符号表(静态,含局部符号) | ❌ |
| .strtab | STRTAB | 符号名字符串表 | ❌ |
| .shstrtab | STRTAB | 节名字符串表 | ❌ |
| .rela.text | RELA | 对 .text 的重定位项 | ❌ |
| .dynsym / .dynstr | DYNSYM / STRTAB | 动态符号表(只含导出/导入符号) | ✅ |
| .dynamic | DYNAMIC | 动态链接信息(readelf -d) | ✅ |
| .got / .plt | PROGBITS | 全局偏移表 / 过程链接表 | ✅ |
| .init_array / .fini_array | INIT_ARRAY / FINI_ARRAY | 构造/析构函数指针数组 | ✅ |
| .debug_* | PROGBITS | DWARF 调试信息(-g 生成) | ❌ |
| .interp | PROGBITS | 动态链接器路径字符串 | ✅ |
.bss 是 NOBITS:readelf -S 里它的 sh_size 是"应该分配多大",但文件里没有对应字节。这就是"未初始化变量不占磁盘空间"的真相------运行时内核为它保留内存并清零。
五、符号表与重定位:链接器的工作现场
5.1 符号表
符号就是"名字 → 地址"的映射。Elf64_Sym 每个 24 字节:
| 字段 | 含义 |
|---|---|
| st_name | 名字在 .strtab 中的偏移 |
| st_info | 高 4 位=绑定(0=LOCAL 1=GLOBAL 2=WEAK),低 4 位=类型(2=FUNC 1=OBJECT 3=SECTION 4=FILE...) |
| st_shndx | 所在节下标(0=未定义/外部符号) |
| st_value | 值(函数=偏移/地址,变量=地址,SECTION=节地址) |
| st_size | 符号大小 |
bare.o 的符号表(objdump -t):
text
0000000000000000 l df *ABS* 0000000000000000 bare.c # 源文件名
0000000000000000 l d .text 0000000000000000 .text
0000000000000000 g F .text 0000000000000012 add # 全局函数, 偏移0, 大小18
0000000000000020 g F .text 000000000000002d main # 全局函数, 偏移0x20, 大小45
0000000000000000 g O .rodata 000000000000000c msg # 全局对象, 大小12
0000000000000000 g O .data 0000000000000004 global_var
0000000000000000 g O .bss 0000000000000004 uninit_var
5.2 重定位:链接前的"待办清单"
目标文件里引用外部符号的指令,地址都是 0 占位。重定位表就是"链接器待办清单"。bare.o 的 .rela.text(objdump -r):
text
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
000000000000003a R_X86_64_PLT32 add-0x4 # 调用 add 的 call 指令
0000000000000041 R_X86_64_PC32 msg-0x4 # 引用 msg 的 lea 指令
Elf64_Rela 每个 24 字节:
| 字段 | 含义 |
|---|---|
| r_offset | 需要修补的位置(相对所在节的偏移) |
| r_info | 高 32 位=符号索引,低 32 位=重定位类型 |
| r_addend | 加数(RELA 才有,REL 存在被重定位位置里) |
x86-64 常用重定位类型(readelf -r 全名):
| 类型 | 公式 | 用途 |
|---|---|---|
| R_X86_64_64 | S + A | 64 位绝对地址 |
| R_X86_64_PC32 | S + A - P | 32 位 PC 相对地址(P=被修补位置) |
| R_X86_64_PLT32 | PLT + A - P | 调用外部函数(走 PLT) |
| R_X86_64_GLOB_DAT | S | 把符号地址写入 GOT |
| R_X86_64_JUMP_SLOT | S | 写入 GOT,PLT 跳转用 |
| R_X86_64_RELATIVE | B + A | 基址加加数(PIE/共享库) |
一句话:链接器拿着这张"待办清单",把每个占位的 0 改成真实地址。静态链接在链接期做完;动态链接部分留到程序启动时由动态链接器做。
六、Segment:加载视图的主角
6.1 程序头长什么样
程序运行时,内核只读 Program Header Table。每个 Elf64_Phdr 56 字节:
| 字段 | 含义 |
|---|---|
| p_type | 段类型(见下表) |
| p_flags | R(4) W(2) X(1) |
| p_offset | 段内容在文件中的起始偏移 |
| p_vaddr / p_paddr | 虚拟地址 / 物理地址(现代 OS 用同一个) |
| p_filesz | 文件里占多少字节 |
| p_memsz | 内存里占多少字节(≥ filesz,多出的部分是 bss,清零) |
| p_align | 对齐(通常 0x1000 = 页大小) |
段类型:
| p_type | 含义 |
|---|---|
| PT_LOAD | 要加载进内存的段(最重要,一个程序至少两个:R-X 的代码 + RW 的数据) |
| PT_DYNAMIC | 动态链接信息 |
| PT_INTERP | 动态链接器路径(.interp 节) |
| PT_PHDR | 程序头表自身 |
| PT_NOTE | 注释信息(build-id、ABI 标签) |
| PT_GNU_STACK | 栈可执行性标记(RW 表示 NX 栈) |
| PT_GNU_RELRO | 重定位后只读区域(安全加固) |
6.2 真实对照:PIE vs 非 PIE
同一份 C 代码,两种编译方式,readelf -l 的真实输出:
text
# gcc hello.c → PIE (ET_DYN),现代默认
$ readelf -l hello_x64_dyn | grep -A2 LOAD
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000650 0x0000000000000650 R 0x1000
LOAD 0x0000000000001000 0x0000000000001000 0x0000000000001000
0x00000000000001ad 0x00000000000001ad R E 0x1000
LOAD 0x0000000000002000 0x0000000000002000 0x0000000000002000
0x00000000000001bc 0x00000000000001bc R 0x1000
LOAD 0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
0x0000000000000268 0x0000000000000270 RW 0x1000
# gcc -no-pie hello.c → 传统 EXEC (ET_EXEC)
$ readelf -l hello_x64_exe | grep -A2 LOAD
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000000928 0x0000000000000928 R E 0x1000
LOAD 0x000000000000fde8 0x000000000041fde8 0x000000000041fde8
0x0000000000000258 0x0000000000000260 RW 0x1000
对比要点:
- PIE :虚拟地址从 0x0 开始,加载时由内核加一个随机基址;非 PIE:固定从 0x400000 开始。后者是 ASLR 攻击面,前者是防御。
- 最后那个 LOAD 段:filesz=0x268 但 memsz=0x270------差的 0x8 字节就是 .bss(uninit_var),加载时被内核清零补上。
- PIE 的 .interp 是 /lib64/ld-linux-x86-64.so.2,用 readelf -x .interp 能看到完整路径字符串。
6.3 内核的加载流程
- 读 ELF Header,校验魔数;定位程序头表。
- 对每个 PT_LOAD 段:按 p_vaddr 页对齐后 mmap,把 p_offset..p_offset+p_filesz 的内容映射进去;p_memsz > p_filesz 的部分用匿名页补零(这就是 .bss)。
- 权限按 p_flags 设置(R-X / RW)。注意:段与页对齐可能造成文件末尾的"垃圾"也被映射,但权限挡得住。
- 若有 PT_INTERP,加载动态链接器,把入口改成它;否则直接跳到 e_entry。
验证加载细节:readelf -l 的 Section to Segment mapping 会列出每个 Segment 包含哪些 Section------这就是"链接视图"和"加载视图"的交汇点。
七、动态链接:GOT 与 PLT 的跳板戏法
7.1 动态段:程序的"依赖清单"
readelf -d 输出 .dynamic 节(真实数据,x86-64 版):
text
$ readelf -d hello_x64_dyn | head -14
Dynamic section at offset 0x2dc8 contains 27 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x000000000000000c (INIT) 0x1000
0x0000000000000019 (INIT_ARRAY) 0x3db8
0x000000006ffffef5 (GNU_HASH) 0x3c0
0x0000000000000005 (STRTAB) 0x490
0x0000000000000006 (SYMTAB) 0x3e8
0x0000000000000003 (PLTGOT) 0x3fb8
0x0000000000000002 (PLTRELSZ) 24 (bytes)
0x0000000000000014 (PLTREL) RELA
0x0000000000000017 (JMPREL) 0x638
0x0000000000000007 (RELA) 0x560
0x000000000000001e (FLAGS) BIND_NOW
NEEDED libc.so.6 说明它依赖 libc;JMPREL 指向 .rela.plt(PLT 重定位表);BIND_NOW 表示立即绑定(关闭懒绑定,安全加固)。
.rela.plt 里能看到"谁被动态解析":
text
$ readelf -r hello_x64_dyn | grep -A2 "rela.plt"
Relocation section '.rela.plt' at offset 0x638 contains 1 entry:
Offset Info Type Sym. Value Sym. Name + Addend
000000003fd0 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
7.2 GOT/PLT:调用 printf 的完整路径
main 里调用 printf,反汇编(aarch64 版,原理相同):
text
$ objdump -d hello_dyn | sed -n '/<main>:/,/^$/p'
00000000000007c8 <main>:
...
7f4: 90000000 adrp x0, 0 <_init-0x5e8>
7f8: 91210000 add x0, x0, #0x840 # 加载格式串地址
7fc: 97ffff9d bl 670 <printf@plt> # 不直接调 libc, 而是跳 PLT!
printf@plt 的代码(.plt 段):
text
$ objdump -d hello_dyn | sed -n '/<.plt>:/,/nop/p'
0000000000000610 <.plt>:
610: a9bf7bf0 stp x16, x30, [sp, #-16]!
614: f00000f0 adrp x16, 1f000 # 定位 GOT
618: f947d211 ldr x17, [x16, #4000] # 从 GOT 取 printf 真实地址
61c: 913e8210 add x16, x16, #0xfa0
620: d61f0220 br x17 # 跳过去
流程一句话:
graph LR A"main 里 bl printf@plt" --> B"PLT 桩: 从 GOT 取地址" B --> C{"GOT 里是真实地址?<br/>(懒绑定: 首次为解析器入口)"} C -->|"首次"| D"动态链接器 _dl_runtime_resolve\
查符号表 → 找到 libc 里 printf" D --> E"把真实地址写回 GOT" C -->|"之后"| F"直接跳真实 printf"
这就是 GOT(全局偏移表)+ PLT(过程链接表):代码只依赖 PLT 桩,桩每次从 GOT 拿地址。懒绑定让"从不调用的函数"零开销;BIND_NOW 则在启动时一次性解析完。安全侧写(ROPgadget、pwntools 里天天见到 GOT/PLT)也由此而来。
八、手写一个 ELF 解析器
理解结构最好的方式是自己实现一遍。下面是一个约 60 行的 Python 解析器(仅 64 位小端),核心就是 struct.unpack_from 按上面的字段表拆字节:
python
#!/usr/bin/env python3
"""极简 ELF64 解析器: ELF头 / 节表 / 符号表 / 重定位 / 程序头"""
import struct, sys
ELF64_EHDR = "<16sHHIQQQIHHHHHH" # 64 字节
ELF64_SHDR = "<IIQQQQIIQQ" # 64 字节
ELF64_PHDR = "<IIQQQQQQ" # 56 字节
ELF64_SYM = "<IBBHQQ" # 24 字节
ELF64_RELA = "<QQq" # 24 字节
ET = {1:"REL",2:"EXEC",3:"DYN",4:"CORE"}
SH_TYPE = {1:"PROGBITS",2:"SYMTAB",3:"STRTAB",4:"RELA",6:"DYNAMIC",
7:"NOTE",8:"NOBITS",9:"REL",11:"DYNSYM",14:"INIT_ARRAY"}
STT = {0:"NOTYPE",1:"OBJECT",2:"FUNC",3:"SECTION",4:"FILE"}
STB = {0:"LOCAL",1:"GLOBAL",2:"WEAK"}
def cstr(buf, off):
end = buf.index(b"\x00", off)
return buf[off:end].decode(errors="replace")
def parse(path):
buf = open(path, "rb").read()
ident = buf[:16]
assert ident[:4] == b"\x7fELF", "不是 ELF!"
assert ident[4] == 2 and ident[5] == 1, "仅支持 64 位小端"
(_, e_type, e_machine, _, e_entry, e_phoff, e_shoff, _,
_, _, e_phnum, _, e_shnum, e_shstrndx) = struct.unpack_from(ELF64_EHDR, buf, 0)
print(f"ELF: type={ET.get(e_type,e_type)} machine={e_machine} entry=0x{e_entry:x}")
print(f" phoff=0x{e_phoff:x} shoff=0x{e_shoff:x} phnum={e_phnum} shnum={e_shnum}")
# 读节表 + 节名字符串表
shdrs = [struct.unpack_from(ELF64_SHDR, buf, e_shoff + i*64) for i in range(e_shnum)]
shstr = shdrs[e_shstrndx]
shstr_buf = buf[shstr[4] : shstr[4] + shstr[5]]
def sec_name(i): return cstr(shstr_buf, shdrs[i][0]) if i else ""
print(f"\n{'[Nr]':<6}{'Name':<20}{'Type':<12}{'Addr':<14}{'Off':<10}{'Size':<10}")
for i, sh in enumerate(shdrs):
name, typ, _, addr, off, size, _, _, _, _ = sh
print(f"[{i:<4}]{sec_name(i):<20}{SH_TYPE.get(typ,str(typ)):<12}"
f"0x{addr:<12x}0x{off:<8x}0x{size:<8x}")
# 符号表(SYMTAB/DYNSYM)
for i, sh in enumerate(shdrs):
if sh[1] in (2, 11):
str_buf = buf[shdrs[sh[6]][4] : shdrs[sh[6]][4] + shdrs[sh[6]][5]]
n = sh[5] // sh[9]
print(f"\n== Symbols in {sec_name(i)} ==")
for j in range(n):
s = struct.unpack_from(ELF64_SYM, buf, sh[4] + j*24)
name = cstr(str_buf, s[0]) if s[0] else ""
print(f" {name:<20} {STB[s[1]>>4]:<8}{STT[s[1]&0xf]:<9}"
f"ndx={s[3]:<3} value=0x{s[4]:x} size={s[5]}")
# 重定位
for i, sh in enumerate(shdrs):
if sh[1] == 4:
n = sh[5] // sh[9]
print(f"\n== Relocations in {sec_name(i)} ==")
for j in range(n):
off, info, addend = struct.unpack_from(ELF64_RELA, buf, sh[4] + j*24)
print(f" offset=0x{off:x} type={info & 0xffffffff} sym={info>>32} addend={addend}")
if __name__ == "__main__":
parse(sys.argv[1])
跑在真实 bare.o 上的输出(与 readelf 对照一致):
text
$ python3 parse_elf.py bare.o
ELF: type=REL machine=62 entry=0x0
phoff=0x0 shoff=0x930 phnum=0 shnum=24
[Nr] Name Type Addr Off Size
[0 ] NULL 0x0 0x0 0x0
[1 ] .strtab STRTAB 0x0 0x82a 0x103
[2 ] .text PROGBITS 0x0 0x40 0x4d
[3 ] .rela.text RELA 0x0 0x570 0x30
[4 ] .data PROGBITS 0x0 0x90 0x4
[5 ] .rodata PROGBITS 0x0 0x94 0xc
[6 ] .bss NOBITS 0x0 0xa0 0x4
...
[23 ] .symtab SYMTAB 0x0 0x420 0x150
== Symbols in .symtab ==
bare.c LOCAL FILE ndx=65521 value=0x0 size=0
add GLOBAL FUNC ndx=2 value=0x0 size=18
main GLOBAL FUNC ndx=2 value=0x20 size=45
msg GLOBAL OBJECT ndx=5 value=0x0 size=12
global_var GLOBAL OBJECT ndx=4 value=0x0 size=4
uninit_var GLOBAL OBJECT ndx=6 value=0x0 size=4
== Relocations in .rela.text ==
offset=0x3a type=4 sym=9 addend=-4 # R_X86_64_PLT32 → add
offset=0x41 type=2 sym=10 addend=-4 # R_X86_64_PC32 → msg
你能看到 .text 只有 0x4d 字节,但 .bss 的类型是 NOBITS、文件里没有内容------解析器在告诉你 .bss 是"虚拟的"。这就是"读懂每一字节"。
九、从零构造一个 132 字节的 ELF
反向操作更能检验理解:不写一行汇编,用 Python 拼出可运行的 ELF。目标:aarch64 上 exit(42)。
python
import struct
# 三句 AArch64 机器码: mov x8,#93(exit) / mov x0,#42 / svc #0
code = struct.pack("<III", 0xD2800BA8, 0xD2800540, 0xD4000001)
ehsize, phentsize, phnum = 64, 56, 1 # 1 个 PT_LOAD
code_off = ehsize + phentsize * phnum # = 120
vbase = 0x400000 # 非 PIE 基址
ehdr = struct.pack("<16sHHIQQQIHHHHHH",
b"\x7fELF" + bytes([2,1,1,0]) + bytes(8), # e_ident: ELF64/小端/SYSV
2, # e_type: ET_EXEC
183, # e_machine: EM_AARCH64
1, # e_version
vbase + code_off, # e_entry → 指向代码
ehsize, # e_phoff
0, # e_shoff: 没有节表!
0, # e_flags
ehsize, phentsize, phnum, # e_ehsize/phentsize/phnum
0, 0, 0) # 无节表相关字段
phdr = struct.pack("<IIQQQQQQ",
1, # p_type: PT_LOAD
5, # p_flags: R+X
0, # p_offset
vbase, vbase, # p_vaddr / p_paddr
code_off + len(code), # p_filesz
code_off + len(code), # p_memsz
0x1000) # p_align
open("mini_elf", "wb").write(ehdr + phdr + code) # 一共 132 字节
虚拟机里验证:
text
$ file mini_elf
mini_elf: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV),
statically linked, no section header
$ readelf -h mini_elf | grep -E "Type|Entry"
Type: EXEC (Executable file)
Entry point address: 0x400078
$ readelf -l mini_elf
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x0 0x400000 0x400000 0x84 0x84 R E 0x1000
$ ./mini_elf; echo "exit = $?"
exit = 42 # 真的跑起来了!
踩过的坑(重要) :我最初把第一条指令写成了 0xD2800B68,反汇编显示它确实是 mov x8, #91------但在 aarch64 上 91 是 capset 系统调用,不是 exit(93) 。svc 执行完"错误"的系统调用后,CPU 继续执行下一条指令------即文件末尾的零填充垃圾字节,于是 Illegal instruction (core dumped)。这个教训印证了两点:① 系统调用号因架构而异;② svc 返回后会顺序执行下一条指令,ELF 文件末尾的"剩余空间"会被映射但内容是垃圾。
这个最小 ELF 揭示了运行期的全部要素:魔数 → 内核识别;一个 PT_LOAD → 映射全部内容;e_entry → 跳转;代码末尾 svc → 陷入内核。Section 一个都没有,照样能跑------因为运行期只需要 Segment。
十、工具速查与常见问题
10.1 常用命令速查
| 命令 | 作用 |
|---|---|
| file a.out | 一句话识别类型(PIE/静态/架构/interpreter) |
| readelf -h a.out | ELF 头全字段 |
| readelf -S a.out | 节表(-SW 加宽显示) |
| readelf -l a.out | 程序头 + Section→Segment 映射 |
| readelf -s a.out | 符号表(-sW) |
| readelf -r a.out | 重定位表 |
| readelf -d a.out | 动态段 |
| readelf -x .interp a.out | 十六进制查看任意节内容 |
| objdump -d a.out | 反汇编(-M intel 用 Intel 语法) |
| objdump -t a.out | 符号表(简洁版) |
| objdump -h a.out | 节表(简洁版) |
| objdump -dr a.out | 反汇编 + 重定位注释 |
| size a.out | text/data/bss 占用统计 |
| nm a.out | 符号列表 |
| strip a.out | 删除符号表和调试信息 |
| xxd a.out | head | 看原始字节 |
10.2 常见问题
| 现象 | 原因 |
|---|---|
| file 显示 pie executable,readelf -h 是 DYN | 现代默认 PIE,不是 bug |
| .bss 在文件里找不到对应字节 | NOBITS 节本来就不占文件空间 |
| strip 后程序照常运行 | 运行期只需要 Segment 和动态段,节表可删 |
| 交叉编译 cannot find crt0.o | 缺目标平台的 C 运行库/头文件,需要 sysroot 或交叉工具链 |
| Illegal instruction(本文踩坑) | 系统调用号/架构不符,或执行到了未定义区域 |
| readelf -h 说 not an ELF file | 魔数不对------被文本工具改过、或被其他格式伪装 |
十一、总结
一张图收束全文:
graph TD F"ELF 文件" --> H"ELF Header\
魔数 0x7f'ELF' / 类型 / 架构 / 入口" F --> P"Program Header Table\
运行期: Segment" F --> S"Section Header Table\
链接期: Section" P -->|"PT_LOAD"| M"内核 mmap 映射\
R-X 代码段 + RW 数据段(bss 补零)" S -->|".symtab+.rela.*"| L"链接器: 符号解析 + 重定位修补" S -->|".dynsym+.dynamic+.got+.plt"| D"动态链接器: 懒绑定/立即绑定" M --> R"进程" D --> R
- ELF = 头部 + 段 + 节:头部自述身份,Segment 告诉内核怎么加载,Section 告诉链接器怎么链接。
- 运行时只需要 Segment:strip 删掉节表程序照跑,本文的 132 字节 ELF 一个 Section 都没有。
- e_entry + PT_LOAD 是运行的核心:跳进入口,陷入内核(svc),进程生命周期开始。
- GOT/PLT 是动态链接的精髓:代码与库解耦,地址跳板让"延迟绑定"成为可能。
读完这篇,你应该能:用 readelf 完整剖析任意 ELF、读懂反汇编里的 PLT 调用、写出自己的解析器、甚至从零拼一个能运行的程序。下回面试官问"ELF 的两种视图是什么",你可以从容地答:Segment 管加载,Section 管链接。
文中所有命令均可在标准 Linux 环境复现(gcc、readelf、objdump、xxd 属于 binutils/util-linux,均为发行版默认组件)。解析器与最小 ELF 构造脚本见本文第八、九节,可直接保存运行。