ELF 文件格式

本页目录

ELF 同时服务于不同阶段:编译器生成可重定位目标文件,链接器组合代码与数据,内核及动态加载器建立进程映像。调试器还会读取符号与调试信息。它们访问的是同一个文件的不同索引,而不是必须按顺序执行的几层结构。

理解 ELF 时先回答三个问题:这个值是文件偏移还是虚拟地址;这个表供哪一阶段使用;这部分内容是否真的需要映射到进程中。

两种视图,不是一条包含链

同一个 ELF:section 组织内容,segment 描述加载需求 链接视图:section 加载视图:program header .text PT_LOAD:R + X .rodata、.dynsym、.dynstr PT_LOAD:R .data、.bss、.dynamic PT_LOAD:R + W 示意布局并非固定模板;PT_DYNAMIC 指向动态条目,条目再引用符号表等数据。

Section 按代码、数据、符号、重定位等用途组织内容。Program header 则描述映射及其他运行需求,其中 PT_LOAD 才表示可加载段。多个 section 可以被同一个 PT_LOAD 覆盖;PT_DYNAMIC、PT_INTERP 等还可能描述已位于 PT_LOAD 范围内的数据,不能把这些条目当作互不重叠的文件分区。

.dynsym 不“属于 PT_DYNAMIC”。PT_DYNAMIC 指向动态条目数组,其中 DT_SYMTAB 指向动态符号表。运行时定位不依赖 .dynsym 这个 section 名字。只读数据也不必和代码共用可执行段,实际权限布局由链接选项决定。ELF 动态链接结构

ELF header 是索引入口

文件开头的 e_ident 先说明后续结构应怎样解释:

字节/字段含义
0–30x7f、E、L、F 魔数
EI_CLASS,字节 4ELF32 或 ELF64,决定结构布局
EI_DATA,字节 5数据字节序
EI_VERSION,字节 6ELF 格式版本
EI_OSABI,字节 7操作系统/ABI 标识
EI_ABIVERSION,字节 8对应 ABI 的版本;不是填充字节
后续保留字节不应解释为另一组随意拼接的字段

剩余 header 中,e_machine 描述目标架构,e_entry 描述入口虚拟地址,e_phoff/e_shoff 是两张表的文件偏移。表项长度与数量分别由 e_*entsize、e_*num 描述;不能用宿主机器的指针大小推断目标文件布局。elf(5)

e_type常见用途不能直接推出的结论
ET_REL编译得到的 .o通常还不能直接执行
ET_EXEC通常是非 PIE 可执行映像不代表整个进程所有地址都固定
ET_DYN共享对象,也用于 PIE不保证已启用 ASLR,也不单独证明它是主程序
ET_CORE进程转储不保证包含所有内存与完整调试信息

PIE 是否默认开启取决于工具链和发行版配置。显式使用 -fPIE -pie 更便于教学实验;检查结果时结合 program header、动态标志和工具的解释,不要只看扩展名。

PT_LOAD:文件内容如何对应到内存

设加载偏移为 B,一个 PT_LOAD 的主要字段有:p_offset(文件起点)、p_vaddr(链接视角虚拟起点)、p_filesz(文件内容长度)、p_memsz(内存长度)、p_flags(读写执行权限)以及 p_align(对齐约束)。

对该段文件支持的区域,文件偏移 f 对应的运行时地址是:

runtime_address = B + p_vaddr + (f - p_offset)

这要求 f 位于该段的文件范围内。p_memsz 必须不小于 p_filesz,超出的内存区域初始化为零;p_align 大于 1 时须是相应的二次幂对齐值,p_vaddr 与 p_offset 满足同余要求,加载还需要满足页对齐规则。ELF Program Loading

例如一个可读写段的 p_offset=0x3000、p_vaddr=0x5000、p_filesz=0x600、p_memsz=0xa00:文件的 [0x3000, 0x3600) 对应内存 [B+0x5000, B+0x5600),后续 [B+0x5600, B+0x5a00) 为零。文件偏移 0x3120 对应 B+0x5120。

实际映射按页处理,文件尾所在页的清零与后续匿名页可能需要不同步骤。不能直接把 p_memsz 作为文件映射长度,就认为文件末尾之后自然都符合 ELF 零填充语义。

同一段中,只有前半部分能换算成文件偏移 文件偏移 0x3000 ≤ f < 0x3600 0x600 字节:从文件取得 链接虚拟地址 0x5000 ≤ v < 0x5600 文件支持的初始化内容 0x5600 ≤ v < 0x5a00 0x400 字节:初始化为零 运行时地址 = B + v;右侧零填充区没有对应的文件字节。 区间右端不包含:0x5600 已进入零填充区,0x5a00 已离开此段。

反查地址时,先判断它属于哪一种区域

假设调试器给出运行时地址 r,且已知它属于这个 ELF 映像,先减去该映像的加载偏移 B,得到链接虚拟地址 v。然后查找覆盖 v 的 PT_LOAD,最后才尝试换算文件偏移。同一进程的主程序与不同共享库各有自己的加载偏移,不能拿主程序的 B 去解释 libc 中的地址。

下面只计算前述单段的地址分类,不读取 ELF,也不模拟页表。用半开区间可以直接检查两个最容易出错的边界。

OFFSET, VADDR = 0x3000, 0x5000
FILESZ, MEMSZ = 0x600, 0xa00

def locate(v):
    delta = v - VADDR
    if not 0 <= delta < MEMSZ:
        return ("outside", None)
    if delta >= FILESZ:
        return ("zero", None)
    return ("file", OFFSET + delta)

assert locate(0x4fff) == ("outside", None)
assert locate(0x5000) == ("file", 0x3000)
assert locate(0x55ff) == ("file", 0x35ff)
assert locate(0x5600) == ("zero", None)
assert locate(0x59ff) == ("zero", None)
assert locate(0x5a00) == ("outside", None)
bias = 0x70000000
runtime = bias + 0x5120
assert locate(runtime - bias) == ("file", 0x3120)

如果 v 是 0x5800,机械代公式会得到文件偏移 0x3800,但那个位置并不是此地址的初始数据来源;那里即使存在其他文件内容,也不能用来解释这块零初始化内存。反过来,file 分类只描述映像的初始来源:重定位或程序写入后,运行时字节未必仍与磁盘相同。

一个地址,三种坐标

关键不是先代公式,而是先检查 Δ 属于哪个区间。两条带共用段内位置标尺:上方文件带在 0x600 处结束,下方内存带继续到 0xa00。拖过这个边界,运行地址仍然存在,文件来源却已经消失;改变 B 则不改变来源分类。

正在呈现知识画面
一个地址,三种坐标

地址换算只适用于有文件来源的区域;零填充尾部没有对应的文件字节。加载偏移只平移运行地址。

常见 section 按职责速查

职责常见名称阅读重点
程序内容.text、.rodata、.data是否带 SHF_ALLOC,以及最终段权限
零初始化存储.bss,SHT_NOBITS有内存大小,文件中没有同样大小的有效载荷
符号.symtab、.dynsym普通符号表与运行时动态符号表分工不同
名字.strtab、.dynstr、.shstrtab字符串偏移必须对应正确字符串表
重定位.rel.、.rela.REL 与 RELA 的 addend 存放方式不同
动态信息.dynamic、.interp依赖、符号地址、解释器路径等
初始化.init_array、.fini_array通常存放函数指针,不等于 .init/.fini 的指令内容
调试.debug_*通常不加载,可能拆分到单独调试文件

sh_name 是 section 名字字符串表内的偏移,不是指针。sh_link、sh_info 的解释依 section 类型而定,例如重定位表需要关联符号表及目标 section,不能给它们套一个全局固定含义。

没有 section header table 的可执行 ELF 在满足加载要求时仍可能运行;这不代表删除任意字节都安全。strip 通常移除部分符号或调试信息,动态重定位仍需使用的内容必须保留。调试时也可依靠匹配的独立调试文件,不能简单说“strip 后 core 永远无法分析”。

用自己的程序把两张表对起来

#include <stdio.h>
int initialized = 7;
unsigned char zeros[8192];
int main(void)
{
    printf("%d %u\n", initialized, (unsigned)zeros[8191]);
    return 0;
}
cc -g -fPIE -pie elf-demo.c -o elf-demo
./elf-demo
readelf -hW elf-demo
readelf -SW elf-demo
readelf -lW elf-demo
readelf -dW elf-demo
readelf -rW elf-demo
readelf --dyn-syms -W elf-demo

输出应为 7 和 0。先在 -SW 输出找到 .data/.bss,再看 -lW 末尾的 Section to Segment mapping,最后比较对应 LOAD 的 FileSiz 与 MemSiz。不要预设恰好有两个 LOAD,也不要照抄另一台机器的地址。

再把同一源码停在三个不同阶段

在支持这些选项的 Linux GCC/Clang 工具链中,继续构建以下产物。这里显式指定 PIE 与非 PIE,避免发行版默认值影响对比。

cc -g -fPIE -c elf-demo.c -o elf-demo.o
cc -g -fno-pie -no-pie elf-demo.c -o elf-demo-exec
./elf-demo-exec
readelf -hW elf-demo.o
readelf -lW elf-demo.o
readelf -sW elf-demo.o
readelf -hW elf-demo-exec
readelf -sW elf-demo
观察点elf-demo.oelf-demo(PIE)elf-demo-exec
e_typeRELDYNEXEC
program header此普通编译产物没有描述可加载映像描述可加载映像
printf 符号未定义,等待链接处理通常仍由动态库提供通常仍由动态库提供
initialized 的普通已定义符号值相对所属 section 的偏移链接视角虚拟地址链接视角虚拟地址
是否能直接运行不能作为普通程序启动能,输出 7 0能,输出 7 0

ET_EXEC 也可以动态链接,因此“非 PIE”与“静态链接”是两个问题。比较 initialized 时,连同 Ndx 一起读:ET_REL 中的 Value=0 可以只是该变量位于 .data 开头;未定义 printf 的 Value=0 则不表示函数就位于地址零。TLS 符号又有专门的偏移含义,不能把所有 st_value 都直接加 B。ELF 符号值

BSS 的大小为什么没有原样增加到文件里

zeros 有 8192 字节,但它的初始内容可以通过“大小与清零规则”表达,不需要在文件中存下 8192 个零。用 readelf -SW 查 .bss 的 NOBITS 类型与 Size,再用 readelf -lW 查看可写 LOAD 的 MemSiz 与 FileSiz。差值还可能包括对齐和其他零初始化对象,所以不要求它恰好等于 8192。

文件总大小也包含 ELF 表、调试信息、符号及填充,不能用所有 LOAD 的 FileSiz 简单相加替代文件长度;段范围本身也不是保证互斥的分区。同样,MemSiz 表达映像的虚拟内存需求,不直接给出进程当前驻留的物理页数量。理解这些量后,才能分清“磁盘文件大”“地址空间大”和“当前物理内存占用大”。

写解析器时,先守住结构边界

将文件缓冲直接强转成 Elf64_Ehdr* 会忽略字节序、对齐、截断和 ELF32。可靠解析应先验证 e_ident,按目标编码读取字段,再检查偏移与长度是否都落在文件范围内;检查乘法与加法溢出应发生在访问之前。

数量字段还存在扩展编码:e_phnum 的 PN_XNUM、e_shnum 的扩展值和 SHN_XINDEX 需要结合 section 0 解码。字符串表也应验证偏移及终止边界。教学中“读取 64 字节并打印几个字段”只能算格式识别,不能算完整 ELF 验证器。

字符串偏移也需要在自己的表内解释

例如字符串表是 b"\0.text\0.data\0",偏移 1 对应 .text,偏移 7 对应 .data。偏移还允许指向已有字符串中间,偏移 2 对应 text;不能只接受此前扫描出的字符串开头。读取必须在这一张表的边界内找到 NUL,不能一路扫描进后面的 section。ELF String Table

def read_name(table, offset):
    if not 0 <= offset < len(table):
        raise ValueError("string offset outside table")
    end = table.find(b"\0", offset)
    if end == -1:
        raise ValueError("unterminated name")
    return table[offset:end]  # 保留原始字节,不假定名字一定是 UTF-8

table = b"\0.text\0.data\0"
assert read_name(table, 0) == b""
assert read_name(table, 1) == b".text"
assert read_name(table, 2) == b"text"
assert read_name(table, 7) == b".data"
for data, offset in [(table, -1), (table, len(table)), (b"\0bad", 1)]:
    try:
        read_name(data, offset)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid name accepted")

此函数只负责单次名字读取;文件级解析仍须验证整个字符串表的位置、长度与结构规则。section 名用 e_shstrndx 指定的表,符号名则用相应符号表关联的字符串表。偏移数值相同不意味着名字相同。

接下来可用 动态链接 理解符号如何变成地址,再用 加载与启动 追踪这些地址何时被使用。