Appearance
目标文件与 ELF 格式
概念
上一篇的第三步产出 .o,第四步产出可执行文件——两者都是"二进制",但:
它们到底是不是"同一类东西"?
答案是"是同一个容器格式的三种装法"——这个容器叫 ELF(Executable and Linkable Format):
三种类型(e_type 字段):
e_type | 值 | 名字 | 谁产出 | 特征 |
|---|---|---|---|---|
ET_REL | 1 | 可重定位目标文件 | 汇编器(-c) | 地址未定,有重定位表;没有入口点 |
ET_EXEC | 2 | 可执行文件 | 链接器 | 地址已定死在文件里(非 PIE 程序) |
ET_DYN | 3 | 共享目标文件(.so)/ PIE 可执行文件 | 链接器(-shared / -pie) | 要加载时才定址(lang/23 细讲) |
如果你只在 Linux 上见过 .o 和 a.out,最大的收获是这两句:
| 认知 | 说明 |
|---|---|
| ① 同一个格式装了三种东西 | 链接器吃 ET_REL,吐出 ET_EXEC/ET_DYN |
| ② 有两个"表"服务两拨人 | 节头表服务链接器(链接视角);程序头表服务操作系统加载器(执行视角) |
本篇在主线上的位置:
lang/20讲了"四步流水线",本篇讲第三步的产物.o的形状——读懂它,才能读懂下一篇"链接器到底在做什么"。
原理
一、两个视角:节(section)与段(segment)
这是 ELF 最容易被搞混的地方。 一句话区分:
| 节(section) | 段(segment) | |
|---|---|---|
| 谁用 | 链接器(ld) | 操作系统加载器 |
| 描述表 | 节头表(section header table) | 程序头表(program header table) |
| 粒度 | 细(.text / .data / .symtab / 每个调试节…) | 粗(通常 2~5 个,如 LOAD/RO 段、LOAD/RW 段) |
| 对应命令 | readelf -S | readelf -l |
在 .o 里有吗 | 有 | 没有(.o 的 e_phoff = 0) |
为什么链接器要"细"、加载器要"粗"?
- 链接器要"按内容合并":所有
.o的.text并成一个.text,所有.data并成一个.data——所以必须分得细; - 加载器只关心"哪些页可读可执行、哪些页可写"——所以把多个同权限的节"打包"成一个段(这正是"段"的由来,也是 ELF 里
p_flags有 R/W/X 的原因)。
一张图看清:
┌──────────── 链接视角(节头表,readelf -S)────────────┐
│ .text .rodata .data .bss .symtab .rel.text ... │
└────────────────────────┬─────────────────────────────┘
│ 多个节按"权限相同"合并
▼
┌──────────── 执行视角(程序头表,readelf -l)──────────┐
│ LOAD 段 #1(R-X):.text + .rodata │
│ LOAD 段 #2(RW-):.data + .bss │
│ PT_INTERP、PT_DYNAMIC、PT_GNU_STACK … │
└──────────────────────────────────────────────────────┘记住这个"合并"动作:
.data和.bss会落进同一个可写段——这正是".bss不占文件空间、却要占虚拟内存"在布局上的表现(见例 3)。
二、ELF 的整体布局
文件偏移 0
┌───────────────────────────────────────────┐
│ ① ELF 头(64 字节,64 位) │
├───────────────────────────────────────────┤
│ ② 程序头表(program header table) │ ← 加载器用;.o 里没有
├───────────────────────────────────────────┤
│ ③ 各节的内容(.text / .data / .symtab …) │
├───────────────────────────────────────────┤
│ ④ 节头表(section header table) │ ← 链接器用;通常在文件末尾
└───────────────────────────────────────────┘注意两件事:
| 细节 | 原因 |
|---|---|
| ELF 头在最前(固定 64 字节,可用它定位一切) | 文件系统只需要读前 64 字节就能判断"这是不是 ELF" |
| 节头表常常在最后 | 节的内容写完才知道每节多大,所以"目录"放最后更自然 |
三、ELF 头:14 个字段,64 字节
64 位 ELF 头是固定 64 字节,字段顺序与长度是标准规定的:
| 字段 | 偏移 | 长度 | 含义 |
|---|---|---|---|
e_ident[16] | 0 | 16 | 魔数 7f 45 4c 46(\x7f E L F)+ 类别 + 字节序 + ABI |
e_type | 16 | 2 | 1=ET_REL,2=ET_EXEC,3=ET_DYN |
e_machine | 18 | 2 | 62 = x86-64,8 = MIPS,183 = AArch64 |
e_version | 20 | 4 | 当前为 1 |
e_entry | 24 | 8 | 入口点虚拟地址(.o 恒为 0) |
e_phoff | 32 | 8 | 程序头表偏移(.o 为 0) |
e_shoff | 40 | 8 | 节头表偏移 |
e_flags | 48 | 4 | 处理器相关标志(MIPS 用它存 ABI 信息) |
e_ehsize | 52 | 2 | ELF 头大小(64) |
e_phentsize | 54 | 2 | 每个程序头的大小(56) |
e_phnum | 56 | 2 | 程序头个数 |
e_shentsize | 58 | 2 | 每个节头的大小(64) |
e_shnum | 60 | 2 | 节头个数 |
e_shstrndx | 62 | 2 | "节名字符串表"是第几个节 |
e_ident[16] 里最要紧的三个字节:
| 字节 | 名字 | 值 → 含义 |
|---|---|---|
| [0..3] | 魔数 | 恒为 7f 45 4c 46——file 命令就是靠它认出 ELF |
| [4] | EI_CLASS | 1 = 32 位,2 = 64 位 |
| [5] | EI_DATA | 1 = 小端,2 = 大端 |
| [6] | EI_VERSION | 1 |
| [7] | EI_OSABI | 0 = System V(Linux);3 = Linux 常见值 |
注意
EI_CLASS会改变一切:32 位 ELF 头只有 52 字节,每一项的宽度也不同——所以"读 ELF"必须"先读 16 字节e_ident,再决定用什么结构去解"。
四、常见的节(section)
| 节名 | 类型 | 内容 | 可写 |
|---|---|---|---|
.text | PROGBITS | 机器码 | 否 |
.rodata | PROGBITS | 只读数据:字符串字面量、const 全局量、跳转表 | 否 |
.data | PROGBITS | 已初始化的全局/静态变量 | 是 |
.bss | NOBITS | 未初始化(或初始化为 0)的全局/静态变量 | 是 |
.comment | PROGBITS | 编译器版本字符串(gcc (GCC) 13.2.0) | 否 |
.note.* | NOTE | ABI、构建 ID 等元信息 | 否 |
.symtab | SYMTAB | 符号表 | —— |
.strtab | STRTAB | 符号名字符串表(符号表里存的是"名字偏移",不是名字) | —— |
.rel.text / .rela.text | REL/RELA | 重定位表(".text 里哪些位置要填") | —— |
.debug_* / .line | PROGBITS | 调试信息(-g 才有) | —— |
.bss 是唯一"不占文件空间"的节——它的类型是 SHT_NOBITS("没有内容"):
为什么可以这么省? 因为"未初始化的全局变量全是 0"是约定——没必要在文件里存 4 万个 0 字节。
| 节 | 文件里 | 加载后(内存里) |
|---|---|---|
.data | 存真数据 | 照抄 |
.bss | 0 字节数据 | 分配大小,清零 |
.bss 这个名字有个段子:早期汇编里的伪指令叫 block started by symbol,后来又被调侃成 better save space——名字解释了它的全部用途。
五、符号表:.symtab 与"名字放在别处"
符号表由一串固定 24 字节(64 位)的表项组成:
| 字段 | 长度 | 含义 |
|---|---|---|
st_name | 4 | 名字在 .strtab 里的偏移(不是名字本身!) |
st_info | 1 | 高 4 位 = 绑定(bind),低 4 位 = 类型(type) |
st_other | 1 | 可见性(默认 0) |
st_shndx | 2 | 所在节的索引(特殊值见表下) |
st_value | 8 | 值:在 .o 里是"节内偏移";在可执行文件里是"虚拟地址" |
st_size | 8 | 对象大小(函数是多少字节、变量占多少字节) |
st_info 的两种编码:
| 绑定(bind,高 4 位) | 值 | 含义 |
|---|---|---|
STB_LOCAL | 0 | 只在本文件可见(static 函数/变量) |
STB_GLOBAL | 1 | 全局(跨文件可用,链接器要处理它) |
STB_WEAK | 2 | 弱符号(下一篇讲:它可以被强符号覆盖) |
| 类型(type,低 4 位) | 值 | 含义 |
|---|---|---|
STT_NOTYPE | 0 | 未指定 |
STT_OBJECT | 1 | 数据对象(变量、数组) |
STT_FUNC | 2 | 函数 |
STT_SECTION | 3 | 节(重定位时可以指向"某个节的起点") |
STT_FILE | 4 | 源文件名 |
st_shndx 的三个特殊值(高频考点):
| 值 | 名字 | 含义 |
|---|---|---|
| 0 | SHN_UNDEF | "未定义" = 这是个外部引用(别的文件里才有) |
0xFFF1 | SHN_ABS | 绝对值,不参与重定位(地址在链接后也不变) |
0xFFF2 | SHN_COMMON | 公共块(未初始化的全局变量在某些 ABI 下用它) |
readelf -s里看到UND就是SHN_UNDEF——所以"一个符号是 UND"= "它在本文件里只有引用、没有定义"。 这正是链接器要拿.symtab做的事:把 UND 配上实体。
为什么名字要"存偏移"? 因为同一个名字可能出现多次(多个符号引用同一个名字),放字符串表能共享;而且字符串表本身也可被多处复用(.strtab / .shstrtab 分离)。
六、重定位表:.rel 与 .rela
"哪些位置要等链接时填",就记在重定位表里。 64 位 x86 用 RELA(带显式加数):
| 字段 | 长度 | 含义 |
|---|---|---|
r_offset | 8 | 要填的位置(.o 里是节内偏移) |
r_info | 8 | 高 32 位 = 符号索引,低 32 位 = 重定位类型 |
r_addend | 8 | 加数(RELA 才有;REL 把它藏在被改的地方) |
r_info 的打包方式决定了"解包"方法:
c
sym = r_info >> 32; /* 高 32 位:用第几个符号 */
type = r_info & 0xffffffff; /* 低 32 位:哪种重定位 */几种常见类型:
| 类型 | 值 | 计算式 | 用途 |
|---|---|---|---|
R_X86_64_64 | 1 | S + A | 64 位绝对地址(指针表、long 常量) |
R_X86_64_PC32 | 2 | S + A - P | PC 相对(本文件内的相邻代码/数据引用) |
R_X86_64_PLT32 | 4 | S + A - P(经 PLT) | 调用外部函数 |
R_X86_64_32 | 10 | S + A | 32 位绝对地址 |
R_X86_64_32S | 11 | S + A(符号扩展) | 32 位绝对(有符号) |
其中
S= 符号最终地址、A= addend、P= 要填位置本身的地址。lang/22-link.md会把S + A - P一步步算出来。
示例
例 1:手工造一个 ELF 头,再逐字段解回来
任务:用 Python 把 64 位 ELF 头"打包成字节"再"解析回字段",看清 14 个字段的偏移怎么累加出 64。
python
import struct, unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# ① e_ident[16]:魔数 + 类别 + 字节序 + 版本 + ABI,余下补 0
e_ident = bytes([0x7F, 0x45, 0x4C, 0x46, 2, 1, 1, 0] + [0] * 8)
# ② 按标准顺序打包("<" 表示小端、无填充)
hdr = struct.pack("<16sHHIQQQIHHHHHH",
e_ident, # e_ident[16]
1, # e_type = ET_REL(.o)
62, # e_machine = x86-64
1, # e_version
0, # e_entry = 0(.o 无入口点)
0, # e_phoff = 0(.o 无程序头表)
512, # e_shoff = 节头表在偏移 512
0, # e_flags
64, # e_ehsize = 64
0, # e_phentsize
0, # e_phnum
64, # e_shentsize = 64
9, # e_shnum = 9 个节
8) # e_shstrndx = 第 8 个节是名字表
print("=== ① 前 16 字节(e_ident)长什么样 ===")
print(" " + " ".join("%02x" % b for b in hdr[:16]))
print(" 前 4 字节 7f 45 4c 46 就是魔数(file 命令靠它认出 ELF)")
print(" 第 5 字节 02 = EI_CLASS(64 位);第 6 字节 01 = EI_DATA(小端)")
print(" 实际长度 = %d 字节" % len(hdr))
print()
print("=== ② 逐字段解析 ===")
V = struct.unpack("<16sHHIQQQIHHHHHH", hdr)
ROWS = [
("e_ident", 0, 16, "7f45 4c46 ...", "魔数 + 类别 + 字节序 + ABI"),
("e_type", 16, 2, V[1], "1 = ET_REL(可重定位目标文件)"),
("e_machine", 18, 2, V[2], "62 = EM_X86_64"),
("e_version", 20, 4, V[3], "1"),
("e_entry", 24, 8, V[4], ".o 没有入口点 → 0"),
("e_phoff", 32, 8, V[5], ".o 没有程序头表 → 0"),
("e_shoff", 40, 8, V[6], "节头表偏移 = 512"),
("e_flags", 48, 4, V[7], "0"),
("e_ehsize", 52, 2, V[8], "ELF 头大小 = 64"),
("e_phentsize", 54, 2, V[9], "每程序头 0 字节(没有)"),
("e_phnum", 56, 2, V[10], "程序头 0 个"),
("e_shentsize", 58, 2, V[11], "每节头 64 字节"),
("e_shnum", 60, 2, V[12], "节 9 个"),
("e_shstrndx", 62, 2, V[13], "名字表为第 8 节"),
]
print(" " + pad("字段", 13) + pad("偏移", 6) + pad("长度", 6) + pad("值", 16) + "含义")
for name, off, size, val, note in ROWS:
print(" " + pad(name, 13) + pad(str(off), 6) + pad(str(size), 6)
+ pad(str(val), 16) + note)
print()
SIZES = [16, 2, 2, 4, 8, 8, 8, 4, 2, 2, 2, 2, 2, 2]
print("=== ③ 偏移校验(手算,应该正好 64) ===")
print(" 字段长度序列 = " + " + ".join(str(x) for x in SIZES))
print(" 合计 = %d 字节,实际打包长度 = %d 字节 → %s"
% (sum(SIZES), len(hdr), "一致" if sum(SIZES) == len(hdr) else "不一致"))
print(" 节头表:偏移 %d,%d 个 × %d 字节 = %d 字节" % (512, 9, 64, 9 * 64))
print(" 节头表结束于 %d,所以这个文件至少 %d 字节" % (512 + 9 * 64, 512 + 9 * 64))
print(" → 这只是“目录”,还没算 9 个节的内容")预期输出:
=== ① 前 16 字节(e_ident)长什么样 ===
7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
前 4 字节 7f 45 4c 46 就是魔数(file 命令靠它认出 ELF)
第 5 字节 02 = EI_CLASS(64 位);第 6 字节 01 = EI_DATA(小端)
实际长度 = 64 字节
=== ② 逐字段解析 ===
字段 偏移 长度 值 含义
e_ident 0 16 7f45 4c46 ... 魔数 + 类别 + 字节序 + ABI
e_type 16 2 1 1 = ET_REL(可重定位目标文件)
e_machine 18 2 62 62 = EM_X86_64
e_version 20 4 1 1
e_entry 24 8 0 .o 没有入口点 → 0
e_phoff 32 8 0 .o 没有程序头表 → 0
e_shoff 40 8 512 节头表偏移 = 512
e_flags 48 4 0 0
e_ehsize 52 2 64 ELF 头大小 = 64
e_phentsize 54 2 0 每程序头 0 字节(没有)
e_phnum 56 2 0 程序头 0 个
e_shentsize 58 2 64 每节头 64 字节
e_shnum 60 2 9 节 9 个
e_shstrndx 62 2 8 名字表为第 8 节
=== ③ 偏移校验(手算,应该正好 64) ===
字段长度序列 = 16 + 2 + 2 + 4 + 8 + 8 + 8 + 4 + 2 + 2 + 2 + 2 + 2 + 2
合计 = 64 字节,实际打包长度 = 64 字节 → 一致
节头表:偏移 512,9 个 × 64 字节 = 576 字节
节头表结束于 1088,所以这个文件至少 1088 字节
→ 这只是“目录”,还没算 9 个节的内容三条结论:
| 结论 | 说明 |
|---|---|
| ELF 头是"可自解析"的 | 前 16 字节告诉你位数与字节序,之后一切都能算出来 |
| 字段偏移是"累加"出来的 | 16+2+2+4+8+8+8+4+2+2+2+2+2+2 = 64——没有隐藏填充 |
.o 的两个 0 是"身份标记" | e_entry=0 且 e_phoff=0 ⇒ 不可执行(只有链接后才能跑) |
例 2:读符号表——st_info 怎么同时装下"绑定"和"类型"
任务:把 24 字节的符号表项造出来再解回来,看清 bind << 4 | type 的位运算,并认出 UND/ABS 两个特殊节索引。
python
import struct, unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
BIND = {0: "LOCAL", 1: "GLOBAL", 2: "WEAK"}
TYPE = {0: "NOTYPE", 1: "OBJECT", 2: "FUNC", 3: "SECTION", 4: "FILE"}
SEC = {0: "UND", 1: ".text", 2: ".rodata", 3: ".data", 4: ".bss",
0xFFF1: "ABS", 0xFFF2: "COMMON"}
# st_name 指向 .strtab 里的偏移(真实 ELF 就是这样,名字不内联)
STRTAB = {0: "", 1: "foo", 9: "g_init", 16: "g_buf", 22: "printf",
29: "N", 38: "__gmon_start__"}
# st_info = bind<<4 | type
SYMS = [
(0, (0 << 4) | 0, 0, 0x0, 0),
(1, (1 << 4) | 2, 1, 0x0, 24),
(9, (1 << 4) | 1, 3, 0x0, 4),
(16, (1 << 4) | 1, 4, 0x4008, 40000),
(22, (1 << 4) | 2, 0, 0x0, 0),
(38, (2 << 4) | 2, 0, 0x0, 0),
(29, (1 << 4) | 1, 0xFFF1, 0x1000, 0),
]
print("=== 每个符号表项固定 24 字节 ===")
print(" st_name(4) + st_info(1) + st_other(1) + st_shndx(2) + st_value(8) + st_size(8) = 24")
blobs = [struct.pack("<IBBHQQ", s[0], s[1], 0, s[2], s[3], s[4]) for s in SYMS]
print(" 实际生成 %d 项 → 共 %d 字节" % (len(blobs), len(blobs) * 24))
print()
print("=== readelf -s 风格的解读表 ===")
print(" " + pad("名称", 17) + pad("绑定", 8) + pad("类型", 7) + pad("所在节", 8)
+ pad("st_value", 10) + "st_size")
for blob in blobs:
st_name, st_info, st_other, st_shndx, st_value, st_size = struct.unpack("<IBBHQQ", blob)
bind = st_info >> 4
typ = st_info & 0x0F
nm = STRTAB.get(st_name, "?")
print(" " + pad(nm, 17) + pad(BIND.get(bind, "?"), 8) + pad(TYPE.get(typ, "?"), 7)
+ pad(SEC.get(st_shndx, str(st_shndx)), 8)
+ pad("0x%x" % st_value, 10) + str(st_size))
print()
print("=== 位运算:st_info 一个字节怎么装两件事 ===")
for st_info in (0x00, 0x12, 0x11, 0x22):
print(" 0x%02x → 高 4 位 bind=%d(%s),低 4 位 type=%d(%s)"
% (st_info, st_info >> 4, BIND.get(st_info >> 4, "?"),
st_info & 0x0F, TYPE.get(st_info & 0x0F, "?")))
print()
print("=== 三个特殊节索引 ===")
print(" " + pad("st_shndx", 10) + pad("名字", 10) + "含义")
SP = [
("0x0", "UND", "未定义 → 这是外部引用,等链接器去别的文件找"),
("0x1~..", "节索引", "普通:在某个节里;st_value 是该节内的偏移"),
("0xfff1", "ABS", "绝对值,链接后地址不变(常量/固定地址)"),
("0xfff2", "COMMON", "公共块(未初始化全局变量,某些 ABI 用)"),
]
for a, b, c in SP:
print(" " + pad(a, 10) + pad(b, 10) + c)
print()
print(" ★ st_value 的含义会变:.o 里是“节内偏移”,可执行文件里是“虚拟地址”")预期输出:
=== 每个符号表项固定 24 字节 ===
st_name(4) + st_info(1) + st_other(1) + st_shndx(2) + st_value(8) + st_size(8) = 24
实际生成 7 项 → 共 168 字节
=== readelf -s 风格的解读表 ===
名称 绑定 类型 所在节 st_value st_size
LOCAL NOTYPE UND 0x0 0
foo GLOBAL FUNC .text 0x0 24
g_init GLOBAL OBJECT .data 0x0 4
g_buf GLOBAL OBJECT .bss 0x4008 40000
printf GLOBAL FUNC UND 0x0 0
__gmon_start__ WEAK FUNC UND 0x0 0
N GLOBAL OBJECT ABS 0x1000 0
=== 位运算:st_info 一个字节怎么装两件事 ===
0x00 → 高 4 位 bind=0(LOCAL),低 4 位 type=0(NOTYPE)
0x12 → 高 4 位 bind=1(GLOBAL),低 4 位 type=2(FUNC)
0x11 → 高 4 位 bind=1(GLOBAL),低 4 位 type=1(OBJECT)
0x22 → 高 4 位 bind=2(WEAK),低 4 位 type=2(FUNC)
=== 三个特殊节索引 ===
st_shndx 名字 含义
0x0 UND 未定义 → 这是外部引用,等链接器去别的文件找
0x1~.. 节索引 普通:在某个节里;st_value 是该节内的偏移
0xfff1 ABS 绝对值,链接后地址不变(常量/固定地址)
0xfff2 COMMON 公共块(未初始化全局变量,某些 ABI 用)
★ st_value 的含义会变:.o 里是“节内偏移”,可执行文件里是“虚拟地址”三条结论:
| 结论 | 说明 |
|---|---|
| 符号表项 24 字节,字段长度固定 | 其中 st_info 一个字节被"拆成两半"用(高 4 位 bind、低 4 位 type) |
名字不在符号表里,而在 .strtab | st_name 只是个偏移——所以符号表可被多处共享,也便于 strip 时整节删掉 |
UND 与 ABS 是两个语义极端 | UND = "地址不在本文件"(要重定位);ABS = "地址就是它,永不改" |
例 3:.bss 为什么不占文件空间
任务:给一份 .o 的节表,算"文件占用"与"内存占用"的差。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# (节名, 类型, 文件占用, 内存占用)
SECS = [
("NULL", "NULL", 0, 0),
(".text", "PROGBITS", 1200, 1200),
(".rodata", "PROGBITS", 400, 400),
(".data", "PROGBITS", 8, 8),
(".bss", "NOBITS", 0, 40000),
(".comment", "PROGBITS", 27, 27),
(".symtab", "SYMTAB", 288, 288),
(".strtab", "STRTAB", 97, 97),
(".shstrtab", "STRTAB", 60, 60),
]
print("=== readelf -S 风格(只看大小这一列) ===")
print(" " + pad("节名", 12) + pad("类型", 11) + pad("文件占用", 10) + pad("内存占用", 10) + "差")
tf = tm = 0
for name, kind, f, m in SECS:
tf += f
tm += m
print(" " + pad(name, 12) + pad(kind, 11) + pad(str(f), 10) + pad(str(m), 10)
+ ("0" if f == m else "-%d" % (m - f)))
print(" " + pad("合计", 12) + pad("", 11) + pad(str(tf), 10) + pad(str(tm), 10)
+ "-%d" % (tm - tf))
print()
print("=== 差值来源只有一个:.bss ===")
bss = [m for n, k, f, m in SECS if n == ".bss"][0]
print(" 内存占用 - 文件占用 = %d - %d = %d 字节" % (tm, tf, tm - tf))
print(" 而 .bss 的内存占用正好 = %d 字节 → 它的文件占用是 0" % bss)
print(" 为什么敢不存?因为“未初始化全局变量全是 0”是语言约定:")
print(" 存 40000 个 0 到文件里 = 白白 40000 字节;加载时清零成本几乎为零")
print()
print("=== 假如 .bss 也占文件,会怎样 ===")
print(" 文件会从 %d 字节涨到 %d 字节,是原来的 %.1f 倍" % (tf, tf + bss, (tf + bss) / tf))
print(" 一个项目里未初始化数组很常见(缓冲区、日志区),省下的量随程序增大而放大")
print()
print("=== 加载时还要按页对齐(页 = 4096 字节) ===")
PAGE = 4096
need = (bss + PAGE - 1) // PAGE * PAGE
print(" .bss 请求 %d 字节 → 虚拟内存按页取整 = %d 字节(%d 页)"
% (bss, need, need // PAGE))
print(" 文件侧增量 = 0 字节,虚拟内存侧增量 = %d 字节" % need)
print(" → “不占文件空间”≠“不占内存”,它只是把成本从磁盘移到了内存(且是惰性分配)")预期输出:
=== readelf -S 风格(只看大小这一列) ===
节名 类型 文件占用 内存占用 差
NULL NULL 0 0 0
.text PROGBITS 1200 1200 0
.rodata PROGBITS 400 400 0
.data PROGBITS 8 8 0
.bss NOBITS 0 40000 -40000
.comment PROGBITS 27 27 0
.symtab SYMTAB 288 288 0
.strtab STRTAB 97 97 0
.shstrtab STRTAB 60 60 0
合计 2080 42080 -40000
=== 差值来源只有一个:.bss ===
内存占用 - 文件占用 = 42080 - 2080 = 40000 字节
而 .bss 的内存占用正好 = 40000 字节 → 它的文件占用是 0
为什么敢不存?因为“未初始化全局变量全是 0”是语言约定:
存 40000 个 0 到文件里 = 白白 40000 字节;加载时清零成本几乎为零
=== 假如 .bss 也占文件,会怎样 ===
文件会从 2080 字节涨到 42080 字节,是原来的 20.2 倍
一个项目里未初始化数组很常见(缓冲区、日志区),省下的量随程序增大而放大
=== 加载时还要按页对齐(页 = 4096 字节) ===
.bss 请求 40000 字节 → 虚拟内存按页取整 = 40960 字节(10 页)
文件侧增量 = 0 字节,虚拟内存侧增量 = 40960 字节
→ “不占文件空间”≠“不占内存”,它只是把成本从磁盘移到了内存(且是惰性分配)三条结论:
| 结论 | 说明 |
|---|---|
.bss 的文件占用是 0,内存占用照算 | 节类型是 SHT_NOBITS("没有内容")——节头表里只有一条目录 |
| "全是 0"是可以推导出来的,不必存 | 省下的是磁盘 I/O 与文件体积,代价只是加载时清零 |
| 文件省了、内存没省,而且还要页对齐 | 40000 字节会占 10 页(40960 字节)——这是 .data/.bss 合并成同一个可写段的直接后果 |
例 4:解一条重定位表项(给下一篇铺路)
任务:给定 r_offset / r_info / r_addend,解出"改哪里、按哪个符号、用哪种规则、加多少"。
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
RTYPE = {
1: ("R_X86_64_64", "S + A", "64 位绝对地址"),
2: ("R_X86_64_PC32", "S + A - P", "PC 相对 32 位(同文件内引用)"),
4: ("R_X86_64_PLT32", "S + A - P", "经 PLT 调用外部函数"),
10: ("R_X86_64_32", "S + A", "32 位绝对地址"),
}
# (r_offset, r_info, r_addend)
RELS = [
(0x12, (2 << 32) | 2, -4),
(0x20, (4 << 32) | 4, -4),
(0x38, (6 << 32) | 2, 0),
]
print("=== 一条 RELA 项 24 字节 = r_offset(8) + r_info(8) + r_addend(8) ===")
print(" " + pad("r_offset", 12) + pad("符号索引", 10) + pad("类型值", 8)
+ pad("类型名", 18) + pad("公式", 12) + "addend")
for off, info, add in RELS:
sym = info >> 32
typ = info & 0xFFFFFFFF
name, formula, _ = RTYPE[typ]
print(" " + pad("0x%x" % off, 12) + pad(str(sym), 10) + pad(str(typ), 8)
+ pad(name, 18) + pad(formula, 12) + str(add))
print()
print("=== r_info 的打包/解包(这是“一个 64 位数装两件事”) ===")
info = (2 << 32) | 2
print(" 符号索引 2、类型 2 → r_info = (2 << 32) | 2 = 0x%016x" % info)
print(" 解包:sym = r_info >> 32 = %d ;type = r_info & 0xffffffff = %d"
% (info >> 32, info & 0xFFFFFFFF))
print(" ★ 为什么能这么塞?高 32 位放符号索引(符号数不会超 40 亿),低 32 位放类型")
print()
print("=== 为什么 PC32 的 addend 常常是 -4 ===")
print(" x86-64 的 call/jmp 用 PC 相对偏移,而 CPU 计算时 PC 已经指向下一条指令;")
print(" 指令长度 4 字节 → 链接器在 addend 里预扣掉 4(-4),把“相对下一条”修正成“相对本条”")
print(" 公式 S + A - P 中的 P 就是“被填位置本身的地址”,A = -4 与之配合")预期输出:
=== 一条 RELA 项 24 字节 = r_offset(8) + r_info(8) + r_addend(8) ===
r_offset 符号索引 类型值 类型名 公式 addend
0x12 2 2 R_X86_64_PC32 S + A - P -4
0x20 4 4 R_X86_64_PLT32 S + A - P -4
0x38 6 2 R_X86_64_PC32 S + A - P 0
=== r_info 的打包/解包(这是“一个 64 位数装两件事”) ===
符号索引 2、类型 2 → r_info = (2 << 32) | 2 = 0x0000000200000002
解包:sym = r_info >> 32 = 2 ;type = r_info & 0xffffffff = 2
★ 为什么能这么塞?高 32 位放符号索引(符号数不会超 40 亿),低 32 位放类型
=== 为什么 PC32 的 addend 常常是 -4 ===
x86-64 的 call/jmp 用 PC 相对偏移,而 CPU 计算时 PC 已经指向下一条指令;
指令长度 4 字节 → 链接器在 addend 里预扣掉 4(-4),把“相对下一条”修正成“相对本条”
公式 S + A - P 中的 P 就是“被填位置本身的地址”,A = -4 与之配合三条结论:
| 结论 | 说明 |
|---|---|
| 重定位项的职责是"描述一件事" | 改哪里(r_offset)、对谁(符号索引)、怎么算(类型)、加多少(addend) |
| 一个 64 位数装了两件事 | r_info = (符号索引 << 32) | 类型——和 st_info 的套路一样 |
-4 不是魔法数 | 它来自"x86 的 PC 已经指向下一条指令"这一硬件事实(下一篇会把算式走完) |
考点
考点
1. ELF 的三种类型(必背)
e_type | 值 | 产物 | 能不能直接运行 |
|---|---|---|---|
ET_REL | 1 | .o(由 -c 产出) | 不能(地址未定、无入口点) |
ET_EXEC | 2 | 可执行文件 | 能 |
ET_DYN | 3 | .so 共享库 / PIE 可执行文件 | 能(但要加载时重定位) |
2. 节 vs 段(最容易混)
- 节(section):给链接器看的,用节头表描述,
readelf -S查看;粒度细; - 段(segment):给加载器看的,用程序头表描述,
readelf -l查看;粒度粗(按权限把节合并); .o里没有程序头表(e_phoff = 0)——这就是".o不能被加载执行"的结构性原因;.data与.bss合并进同一个可写段,.text与.rodata合并进同一个只读可执行段。
3. ELF 头 64 字节的字段(记关键的六个)
e_ident(0~15):魔数7f 45 4c 46+EI_CLASS(1=32/2=64)+EI_DATA(1=小端/2=大端);e_type(16):ET_REL / ET_EXEC / ET_DYN;e_entry(24):入口点虚拟地址,.o恒为 0;e_shoff(40):节头表偏移;e_shentsize(58)/e_shnum(60):每项 64 字节、共几项;e_shstrndx(62):节名字符串表是第几节(节名也存在字符串表里!);- 判据:要读 ELF,必须先读
e_ident定下"位数与字节序",否则后面全是乱码。
4. .bss 为什么省空间(高频)
- 类型是
SHT_NOBITS(NOBITS):文件里不存数据,只在节头表里留一条目录(64 字节); - 理由:未初始化的全局/静态变量按语言规定初值全为 0——存 0 是浪费;
- 代价:加载时由内核按大小分配并清零(所以"不占文件"≠"不占内存");
- 注意:显式初始化为 0 的全局变量也常常被放进
.bss(因为效果等价,编译器会优化)。
5. 符号表项的 24 字节与两个编码
- 字段:
st_name(4) /st_info(1) /st_other(1) /st_shndx(2) /st_value(8) /st_size(8); st_info=bind << 4 | type:bind = LOCAL(0)/GLOBAL(1)/WEAK(2);type = NOTYPE(0)/OBJECT(1)/FUNC(2)/SECTION(3)/FILE(4);st_name是.strtab里的偏移,不是名字本身;- 第 0 个符号表项固定全 0(约定:这样
st_shndx = SHN_UNDEF且名字为空,表示"无符号")。
6. st_shndx 的三个特殊值
| 值 | 名字 | 含义 |
|---|---|---|
| 0 | SHN_UNDEF(readelf 显示 UND) | 外部引用,地址不在本文件 |
0xFFF1 | SHN_ABS | 绝对地址,不需重定位 |
0xFFF2 | SHN_COMMON | 公共块(未初始化全局变量) |
7. 重定位表项(RELA 24 字节)
r_offset(8) +r_info(8) +r_addend(8);r_info=(符号索引 << 32) | 类型;R_X86_64_PC32的公式是S + A - P(S符号地址、A加数、P被填位置地址);RELA有显式addend,REL没有(x86-64 用 RELA,i386 用 REL);- 常见 PC 相对重定位的
addend = -4,原因是 x86 的 PC 已指向下一条指令(指令长 4 字节)。
8. 高频易错点
st_value的含义随"文件类型"改变:.o里是"节内偏移",可执行文件里是"虚拟地址"——同一个字段两种意思,这是最容易答错的点;- 节头表的第 0 项(索引 0)固定是全 0 的
NULL节——所以"9 个节"里有一个是空的; readelf与objdump的分工:readelf看结构(头、节、段、符号、重定位);objdump -d看反汇编;strip删掉的是.symtab/.debug_*,不是.dynsym——动态链接要用的动态符号表必须留着;.o里.text的地址从 0 开始——所以".o里的地址"只是"节内偏移",不能当虚拟地址用;- ELF 不只是 Linux 的格式——
e_machine让同一格式承载 x86、ARM、MIPS、RISC-V(这也是"可移植的容器"的含义)。
小结
- ELF 是统一容器:可重定位文件(
ET_REL)、可执行文件(ET_EXEC)、共享库 / PIE(ET_DYN)共用一套格式。 - 两个视角:节头表给链接器(细)、程序头表给加载器(粗);
.o没有程序头表,所以不能运行。 - ELF 头 64 字节:魔数
7f 45 4c 46+ 类别 + 字节序 +e_type+e_entry+e_shoff+e_shnum…。 - 常见节:
.text机器码 /.rodata只读数据 /.data已初始化 /.bss未初始化(不占文件)/.symtab符号 /.rela.text重定位。 - 符号表项 24 字节:
st_info一字节装bind<<4|type;st_name是字符串表偏移;st_shndx里UND=外部引用、ABS=绝对值。 st_value一词两义:.o里"节内偏移",成品里"虚拟地址"。- 重定位项 24 字节:
r_offset+r_info(符号索引 << 32 | 类型)+r_addend;PC32的公式S + A - P。 - 一句话概括
.o:它是"内容齐全、地址待定"的 ELF——符号表和重定位表就是"待办清单"。
回到主线:L2 讲的是"指令如何被执行",L3 讲的是"人怎么写指令"。本篇把 L3 的第三步产物 .o 拆开看——它回答了"源码翻译成机器码之后,为什么还不能跑"这个中间态问题:因为跨文件的名字还没接上、地址还没定。
接下来的问题很自然:链接器拿着这些符号表和重定位表,具体怎么"接名字、填地址"?如果两个文件定义了同名符号怎么办?一个未初始化的全局变量在多个文件里声明,为什么不算重复定义?——
22-link.md就是回答这些的。
下一篇:符号解析与重定位
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。