ELF 文件格式从魔数到动态链接:读懂 Linux 可执行文件的每一字节
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 Header:一切的起点
- Section:链接视图的主角
- 符号表与重定位:链接器的工作现场
- Segment:加载视图的主角
- 动态链接:GOT 与 PLT 的跳板戏法
- 手写一个 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 位为例):
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 字节:
$ 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 会把它"翻译"成人话:
$ 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 个节:
$ 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):
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):
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 的真实输出:
# 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 版):
$ 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 里能看到"谁被动态解析":
$ 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 版,原理相同):
$ objdump -d hello_dyn | sed -n '/:/,/^$/p' 00000000000007c8: ... 7f4: 90000000 adrp x0, 0 <_init-0x5e8> 7f8: 91210000 add x0, x0, #0x840 # 加载格式串地址 7fc: 97ffff9d bl 670# 不直接调 libc, 而是跳 PLT!
printf@plt 的代码(.plt 段):
$ 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 # 跳过去
流程一句话:
(懒绑定: 首次为解析器入口)"} 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 按上面的字段表拆字节:
#!/usr/bin/env python3
"""极简 ELF64 解析器: ELF头 / 节表 / 符号表 / 重定位 / 程序头"""
import struct, sys
ELF64_EHDR = "<16sHHIQQQIHHHHHH" # 64 字节
ELF64_SHDR = ">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 对照一致):
$ 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)。
import struct
# 三句 AArch64 机器码: mov x8,#93(exit) / mov x0,#42 / svc #0
code = struct.pack("
虚拟机里验证:
$ 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 构造脚本见本文第八、九节,可直接保存运行。
"原文地址: http://www.cveoy.top/t/topic/qHB3 著作权归作者所有。请勿转载和采集!