下面是我再看一遍链接过程的与 AI 的对话,对我帮助挺大的,贴上来吧
在动态链接过程中,程序通过以下机制实现外部符号的调用和重定位,涉及 .dynamic
段、PLT/GOT 表和符号解析机制:
一、外部符号调用的初始状态
1. 调用点的原始代码
当程序调用共享库中的函数(如 printf
)时,编译器会生成跳转到 PLT(Procedure Linkage Table) 的指令:
; x86_64示例
call printf@plt
对应的 PLT 条目初始内容为:
printf@plt:
jmp *GOT[1] ; 初始时跳转到动态链接器的解析逻辑
push index ; 符号在重定位表中的索引
jmp resolve ; 跳转到动态链接器符号解析代码
2. GOT(Global Offset Table)的初始状态
- GOT[0]:动态链接器内部数据(
link_map
结构地址)。 - GOT[1]:动态链接器的符号解析函数(
_dl_runtime_resolve
地址)。 - GOT[2..n]:外部函数的地址槽(初始指向 PLT 解析逻辑)。
二、动态链接器的重定位流程
1. 加载时重定位
动态链接器(ld.so
)在程序启动时处理以下步骤:
-
解析
.dynamic
段: 获取动态链接所需的关键信息:DT_NEEDED
:依赖的共享库列表。DT_SYMTAB
:动态符号表(.dynsym
)地址。DT_RELA
/DT_REL
:重定位表(.rela.plt
)地址。DT_JMPREL
:PLT 重定位表(通常与.rela.plt
相同)。DT_HASH
/DT_GNU_HASH
:符号哈希表地址。
-
加载依赖库: 递归加载所有依赖库到内存,构建
link_map
链表。 -
符号解析与重定位: 遍历重定位表(如
.rela.plt
),根据符号名和哈希表查找目标地址,填充 GOT 条目。
2. 重定位表示例(.rela.plt
)
typedef struct {
Elf64_Addr r_offset; // GOT条目地址(需修改的位置)
Elf64_Xword r_info; // 符号索引和重定位类型(如R_X86_64_JUMP_SLOT)
Elf64_Sxword r_addend;
} Elf64_Rela;
- r_offset:指向 GOT 中需要修改的地址槽。
- r_info:高 32 位为符号在
.dynsym
中的索引,低 32 位为重定位类型(如R_X86_64_JUMP_SLOT
)。
3. 符号解析过程
动态链接器通过以下步骤解析符号:
-
哈希查找: 使用
.hash
或.gnu.hash
计算符号名的哈希值,快速定位符号在.dynsym
中的位置。// 伪代码(glibc/dlcache.c) Sym* find_symbol(const char* name) { uint32_t hash = elf_gnu_hash(name); uint32_t bucket = hash % hashtable->nbuckets; for (int i = hashtable->buckets[bucket]; i != 0; i = hashtable->chains[i]) { if (strcmp(name, &dynstr[shdr->dynsym[i].st_name]) == 0) return &dynsym[i]; } return NULL; }
-
符号表(
.dynsym
)匹配: 遍历哈希链,匹配符号名和版本信息,最终获取符号的虚拟地址。 -
填充 GOT: 将符号地址写入重定位表指定的 GOT 条目:
*(Elf64_Addr*)r_offset = sym->st_value; // 修改GOT条目为实际地址
三、运行时延迟绑定(Lazy Binding)
1. 首次调用触发解析
当程序首次调用外部函数时,PLT 的初始跳转会进入动态链接器的解析逻辑:
_dl_runtime_resolve
: 根据 PLT 压入的符号索引,找到重定位条目,解析符号地址。- 更新 GOT: 将解析后的函数地址写入 GOT,后续调用直接跳转到目标函数。
2. 性能优化
- 哈希加速:
.gnu.hash
比传统.hash
更快,减少哈希冲突。 - PLT 延迟绑定:避免启动时解析所有符号,按需解析提升加载速度。
四、关键数据结构与工具验证
1. 查看动态段信息
readelf -d libexample.so
输出示例:
Dynamic section at offset 0x2e40 contains 24 entries:
Tag Type Name/Value
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x0000000000000005 (STRTAB) 0x7d0
0x0000000000000006 (SYMTAB) 0x3d0
0x0000000000000017 (JMPREL) 0x1a20
0x0000000000000007 (RELA) 0x1000
2. 查看重定位表
readelf -r libexample.so
输出示例:
Relocation section '.rela.plt' at offset 0x1a20 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000200018 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf@GLIBC_2.2.5 + 0
3. 调试符号解析
使用 LD_DEBUG
环境变量观察动态链接过程:
LD_DEBUG=symbols,bindings ./program
输出示例:
symbol=printf; lookup in file=./libexample.so [0]
symbol=printf; lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
binding file ./libexample.so [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol 'printf' [GLIBC_2.2.5]
五、总结
- 调用点原始代码:PLT 跳转到未初始化的 GOT 条目,触发动态解析。
- 重定位时机:加载时由
ld.so
处理,或运行时首次调用时延迟绑定。 - 符号定位依据:通过
.dynsym
、.hash
/.gnu.hash
和重定位表协作完成。 - 动态链接器依赖:
.dynamic
、.dynsym
、.rela.plt
和.hash
是ld.so
解析的核心结构。
通过这一机制,程序能够高效地调用外部符号,同时支持动态库的灵活加载和更新。
我还是不明白。Elf64_Xword r_info ; 符号索引是什么时候建立的。整个过程我还是挺混乱的,有没有什么清晰一点的讲解思路
动态链接过程中的符号解析和重定位机制确实容易令人困惑,下面用分阶段、模块化的方式重新梳理整个过程,确保逻辑清晰。
一、符号重定位的核心问题
程序在编译时无法确定外部函数(如 printf
)的地址,因此需要在运行时动态解析。这需要 两个关键步骤:
- 记录哪些位置需要修正(重定位条目)。
- 告诉动态链接器如何修正这些位置(符号名称和规则)。
二、分阶段流程解析
阶段 1:编译源代码(生成目标文件 .o
)
-
外部符号标记 编译器发现未定义的符号(如
printf
),生成一个 未解决的重定位条目(Relocation Entry)。 -
示例: 假设在
main.c
中调用printf
,编译器生成:; main.o 的反汇编片段 call 0x0 ; 占位符地址(未解析的printf)
对应的重定位条目:
// .rela.text 中的条目 { r_offset = 0x10, // call指令中地址的偏移(在.text中) r_info = (index << 32) | R_X86_64_PLT32, // 符号索引 + 重定位类型 r_addend = -4 }
阶段 2:静态链接(生成可执行文件或 .so
)
-
合并重定位条目 静态链接器(如
ld
)将所有.o
文件的重定位条目合并到.rela.plt
(PLT 重定位表)中。 -
生成符号索引 静态链接器在
.dynsym
(动态符号表)中为每个外部符号分配索引,并在重定位条目中记录该索引。// 符号表.dynsym中的条目(假设printf是第3个符号) dynsym[3] = { st_name = 0x20, // 在.dynstr中的偏移,指向字符串"printf" st_value = 0x0, // 初始地址(运行时由动态链接器填充) st_info = (STB_GLOBAL << 4) | STT_FUNC };
// .rela.plt中的重定位条目 { r_offset = 0x2000, // GOT中的槽位地址 r_info = (3 << 32) | R_X86_64_JUMP_SLOT, // 符号索引=3 r_addend = 0 }
阶段 3:动态链接器(ld.so
)加载程序
- 加载依赖库
ld.so
读取.dynamic
段,加载所有依赖的共享库(如libc.so.6
)。 - 构建符号全局视图
通过
.dynsym
、.hash
/.gnu.hash
和依赖库的符号表,建立所有符号的全局哈希表。
阶段 4:符号解析与重定位
-
遍历重定位表(
.rela.plt
) 对于每个条目(如r_info
中符号索引=3):-
查找符号名:通过
.dynsym[3].st_name
找到.dynstr
中的字符串"printf"
。 -
哈希查找符号:用
.gnu.hash
快速定位libc.so.6
中的printf
函数地址。 -
修正 GOT:将地址写入
r_offset
指定的位置(如GOT[2]
)。*(uint64_t*)0x2000 = 0x7f8a5d400520; // printf在libc中的地址
-
-
延迟绑定(Lazy Binding) 若启用延迟绑定,首次调用
printf@plt
时触发解析:printf@plt: jmp *GOT[2] ; 第一次跳转到解析逻辑 push 3 ; 符号索引=3 jmp _dl_runtime_resolve
三、关键数据结构与工具验证
1. 查看符号索引
readelf -s libexample.so | grep printf
输出示例:
Num: Value Size Type Bind Vis Ndx Name
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5
- 符号索引:此处
printf
的索引为 3。
2. 查看重定位表
readelf -r libexample.so
输出示例:
Relocation section '.rela.plt' at offset 0x1a20:
Offset Info Type Sym. Value Sym. Name + Addend
000000200018 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0
- Info 字段:
000300000007
→ 符号索引=3(高 32 位),类型=7(R_X86_64_JUMP_SLOT
)。
3. 调试符号解析
LD_DEBUG=symbols,bindings ./program
输出示例:
symbol=printf; lookup in file=./libexample.so [0]
symbol=printf; lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0]
binding file ./libexample.so [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol 'printf'
四、总结:符号索引与重定位流程
阶段 | 操作 | 生成的关键数据 |
---|---|---|
编译 | 生成未解析的重定位条目,标记外部符号的调用位置。 | .rela.text 中的条目(符号索引占位) |
静态链接 | 合并重定位条目到 .rela.plt ,分配符号索引。 | .dynsym 、.rela.plt 、.gnu.hash |
动态链接 | 根据符号索引查找符号名,解析实际地址,填充 GOT。 | 修正后的 GOT 条目(指向真实函数地址) |
- 符号索引的建立:静态链接时确定,存储在
.rela.plt
的r_info
字段。 - 动态链接器的角色:利用
.dynsym
、.hash
等 Section 解析符号,修正地址。
通过这一流程,程序在运行时能动态绑定外部符号,无需在编译时硬编码地址。
编译阶段我理解了,就是把 call 一个未定义函数的位置记下来,类型什么的都记下来。但是这里函数的“符号”放在哪里?我想是有一个符号表,前面那个结构里存了符号表的偏移,这样就解释的通了。对,这个符号表是
.dynsym
,同时搞定了那种外部定义,自己声明的情况;也可以表示对外定义的情况。然后是静态链接阶段。 把前面的结构统一放在.rela.plt 中这个可以。然后符号表也统一放在一起,偏移什么的都容易调整。
然后是动态链接阶段。这里的意思是如果采用延迟绑定的策略,就在静态链接的地方先把 call 的地方都换成 call 这个 printf@plt: 之类的东西是吧,然后在第一次有调用外部链接的函数的时候开始整个符号解析过程。因为 call 已经有一个地址了,这里说是 printf@plt 第一个指令转到 GOT[2] 里的位置,好,GOT[2] 指向的是 PLT 解析逻辑,所以这里就开始遍历整个重定位表是吧?哦我懂了,这里不去修改.text 里的东西,而是去修改 printf@plt 里面指向的 GOT[2] 里的东西,所以 call 的东西在静态链接的时候替换了之后就不变了是吧。(其实也是文件偏移,不过刚好偏移到 got 表里了而已)遍历整个重定位表,重定位表里有 got 的槽位,符号在符号表里的索引,符号表里的存的又是一个符号的字符串,真正的函数地址还不知道在哪。这个时候怎么建立从字符串到函数地址这条路呢?前面漏掉了导出函数的工作吧。应该有一个从字符串哈希到一个数,从数映射到一个地址的工作,然后动态链接的时候把这些集合起来?是涉及到哈希表的合并了吗?并没有,动态链接器为每个库维护独立的哈希表,查找时按依赖顺序遍历所有库的哈希表,直到找到符号。
分步详解动态链接过程
一、符号存储与符号表(.dynsym
)
1. 符号的存储位置
-
符号表
.dynsym
: 动态链接所需的符号(如外部函数名printf
)存储在.dynsym
(动态符号表)中。每个条目包含符号名在.dynstr
中的偏移、类型、绑定属性等。typedef struct { Elf64_Word st_name; // 符号名在.dynstr中的偏移 unsigned char st_info; // 符号类型(如函数)和绑定属性(如全局) // ... 其他字段(如大小、地址等) } Elf64_Sym;
-
.dynstr
: 字符串表,存储符号名的实际字符串(如"printf"
)。
2. 符号类型与绑定属性
-
st_info
字段: 由 符号绑定类型(高 4 位)和 符号类型(低 4 位)组成:- 绑定类型:
STB_GLOBAL
(全局符号)、STB_LOCAL
(局部符号)、STB_WEAK
(弱符号)。 - 符号类型:
STT_FUNC
(函数)、STT_OBJECT
(全局变量)、STT_NOTYPE
(未定义类型)。
示例:
(STB_GLOBAL << 4) | STT_FUNC
表示一个全局函数符号。 - 绑定类型:
3. 示例验证
查看符号表:
readelf -s libexample.so
输出示例:
Symbol table '.dynsym' contains 5 entries:
Num: Value Size Type Bind Vis Ndx Name
3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5
printf
是.dynsym
中的第 3 个符号,类型为FUNC
,绑定属性为GLOBAL
。
二、静态链接阶段:重定位表(.rela.plt
)与 GOT/PLT 机制
1. 重定位表的真实作用
.rela.plt
的作用: 记录 GOT 条目需要被修正的位置,而非直接修正call
指令的地址。r_offset
:指向 GOT 中的槽位(而非call
指令的地址)。- 原因:直接修改
call
指令需要写代码段(r-x
权限),而修改 GOT 只需写数据段(rw-
权限),更安全高效。
2. GOT 与 PLT 的分工
- GOT(Global Offset Table): 存储外部函数的 实际地址(动态链接后填充)。
- PLT(Procedure Linkage Table): 提供跳转到 GOT 的 跳板代码,首次调用时触发动态解析。
3. 示例验证
查看重定位表和 GOT:
readelf -r libexample.so
输出示例:
Relocation section '.rela.plt' at offset 0x1a20 contains 3 entries:
Offset Info Type Sym. Value Sym. Name + Addend
000000200018 000300000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0
r_offset = 0x200018
:GOT 中printf
的槽位地址。r_info
:符号索引=3,类型=R_X86_64_JUMP_SLOT
(PLT 重定位)。
三、动态链接阶段:符号解析与延迟绑定
1. 延迟绑定(Lazy Binding)
-
首次调用触发解析: 程序首次调用外部函数时,PLT 代码跳转到动态链接器的解析逻辑。
printf@plt: jmp *GOT[2] ; 第一次跳转指向解析逻辑 push 3 ; 符号索引=3 jmp _dl_runtime_resolve
-
解析后填充 GOT: 动态链接器解析符号地址后,将结果写入 GOT。后续调用直接跳转到目标函数。
2. 符号解析的核心步骤
- 符号名 → 符号索引:
通过
.rela.plt
中的条目获取符号索引(如 3)。 - 符号索引 → 符号名:
通过
.dynsym[3].st_name
找到.dynstr
中的字符串"printf"
。 - 符号名 → 地址:
动态链接器通过 哈希表(
.gnu.hash
) 在所有依赖库中查找"printf"
的地址。
3. 哈希表的合并与查找
-
哈希表的作用: 避免遍历所有符号,通过哈希值快速定位符号所在的哈希桶(Bucket)。
-
.gnu.hash
结构:struct gnu_hash { uint32_t nbuckets; // 哈希桶数量 uint32_t symoffset; // 符号表起始索引 uint32_t bloom_size; // 布隆过滤器大小 uint32_t bloom_shift; // 布隆过滤器位移 // 布隆过滤器数组、哈希桶数组、哈希链数组 };
-
查找步骤:
- 计算符号名的哈希值。
- 使用布隆过滤器快速排除不存在的符号。
- 遍历哈希桶中的链,匹配符号名。
4. 示例验证
查看哈希表:
readelf -S libexample.so | grep .gnu.hash
输出示例:
[ 5] .gnu.hash GNU_HASH 00000000000001b8
四、总结与补充
1. 完整流程
- 编译阶段: 记录外部符号的调用位置,生成未解决的重定位条目。
- 静态链接阶段:
合并重定位条目到
.rela.plt
,设置符号索引,生成 PLT/GOT。 - 动态链接阶段:
- 加载依赖库,构建全局符号视图。
- 按需(首次调用)或立即解析符号,修正 GOT 条目。
2. 关键设计思想
- PLT/GOT 解耦: 将代码段(不可写)和数据段(可写)分离,保证安全性和灵活性。
- 哈希加速:
通过
.gnu.hash
和布隆过滤器大幅提升符号查找效率。
3. 工具验证命令
# 查看符号表
readelf -s libexample.so
# 查看重定位表
readelf -r libexample.so
# 查看哈希表
readelf -S libexample.so | grep .gnu.hash
# 调试动态链接过程
LD_DEBUG=symbols,bindings ./program
五、答疑
-
为什么
r_offset
指向 GOT 而非call
指令? 直接修改代码段需要r-x
→rwx
权限切换,存在安全风险且效率低。修改 GOT 只需操作数据段,更安全高效。 -
哈希表如何合并多个库的符号? 动态链接器为每个库维护独立的哈希表,查找时按依赖顺序遍历所有库的哈希表,直到找到符号。
-
延迟绑定的代价是什么? 首次调用会有解析开销,但避免了启动时解析所有符号,适合大型程序。可通过
LD_BIND_NOW=1
强制立即绑定。