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 输出均为本机真实运行结果,非手工杜撰,可按命令自行复现。


目录

  1. [为什么要懂 ELF](#为什么要懂 ELF)
  2. [ELF 宏观布局:两种视图](#ELF 宏观布局:两种视图)
  3. [ELF Header:一切的起点](#ELF Header:一切的起点)
  4. Section:链接视图的主角
  5. 符号表与重定位:链接器的工作现场
  6. Segment:加载视图的主角
  7. [动态链接:GOT 与 PLT 的跳板戏法](#动态链接:GOT 与 PLT 的跳板戏法)
  8. [手写一个 ELF 解析器](#手写一个 ELF 解析器)
  9. [从零构造一个 132 字节的 ELF](#从零构造一个 132 字节的 ELF)
  10. 工具速查与常见问题
  11. 总结

一、为什么要懂 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 内核的加载流程

  1. 读 ELF Header,校验魔数;定位程序头表。
  2. 对每个 PT_LOAD 段:按 p_vaddr 页对齐后 mmap,把 p_offset..p_offset+p_filesz 的内容映射进去;p_memsz > p_filesz 的部分用匿名页补零(这就是 .bss)。
  3. 权限按 p_flags 设置(R-X / RW)。注意:段与页对齐可能造成文件末尾的"垃圾"也被映射,但权限挡得住。
  4. 若有 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 构造脚本见本文第八、九节,可直接保存运行。

相关推荐
GeW1 小时前
高端制造的三重底座:Linux、数据库与工业软件协同进化
linux
未济5 天前
linux 配置环境变量
linux
傲世仙尊5 天前
目录即文件-Ext文件系统收尾篇
linux·c语言
_艾伦 耶格尔.6 天前
进程间通信
linux
Liuqy-056 天前
Linux IO编程——静态库、动态库
linux
彧azz6 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
-梅6 天前
linux(8) 软硬链接
linux·运维·服务器
AIgorithmGEEK6 天前
[Linux]线程三部曲(上):一个执行流的诞生——从操作系统一路拆到 pthread_create
linux·线程·pid
琥珀色糖6 天前
SimlpeHttp
linux·服务器