从源码到进程:04.动态库的生成与使用,解密延迟绑定与重定位机制

动态库的生成与使用,解密延迟绑定与重定位机制

前言

在之前的文章中,我们讲解了实际静态库的生成与使用,说明了目标文件是怎么生成静态库的,并且说明了一个程序是怎么连接使用静态库的。

在实际的开发中,我们还会经常使用另一种文件 .so 文件,也就是动态库,本文就讲继续讲解动态库是生成与使用的,你将学会:

  • 为什么动态库解决静态库的痛点。

  • 如何生成、链接和运行时加载 .so。

  • GOT/PLT 和重定位表的“幕后英雄”作用。

通过了解动态库实现动态链接的方法,可以将这种想法应用到自己的需要的系统热更新功能

动态库的引出

有了静态库不够吗?我们为什么要使用动态库?

静态库确实能够做到程序复用,但是却存在几个问题

体积膨胀: 静态库在链接的时候,就将需要的符号链接进可执行文件中了,相当于包含了库的副版本。如果是多个团队协同开发程序,都使用了同一个库,比如sqlite,那么肯定是会造成程序的体积膨胀
更新困难: 静态库如果提供方更新了,每次使用者都需要重新生成可执行文件,重新连接库
编译成本高:对于很多稍微大型一点的项目,编译的成本巨大,重新编译的时间过长,相信自己编译过类似chromium等项目的同学深有体会。

因此,我们需要一种机制: 将链接过程延迟到程序运行时,让库能够被独立的加载,共享和替换

这样就能做到,当库更新的时候,我们不需要去重新编译整个可执行程序,只需要将动态库的文件进行更新就可以了

动态库实现的原理

动态库的设计思想

一个库文件,实际上对外提供的都是两种东西变量和函数,本质上都是我们之前提到的“符号(symbol)”。

对于一个库而言,实际上只要保证了在库变化的时候,只要库的符号是稳定的,或者说保证能继续提供之前保证提供的符号,那么库的更新实际上就可以做到无感的,让应用程序直接替换库。

动态库实现的核心思想就是:

不在链接的时候就将调用符号的地址写死,只是记录依赖,而在运行时真正生成这个符号的地址,也就是延迟绑定(lazy Binding)

简单来说就是两点:

  • 链接库的时候让可执行程序知道自己依赖什么库,依赖库中的什么符号
  • 在运行的时候,找到对应的库,从库中找到自己的依赖的符号,并且生成这个符号的真正地址

这个“重新计算地址”的动作,依靠两张重要的重定位表完成:

段名 作用
.rela.dyn 记录全局变量等静态数据的修正方式
.rela.plt 记录外部函数调用的修正方式(延迟绑定)

等到程序运行起来的时候,这两张表就为动态链接器提供了重新计算符号地址的关键信息,告诉它:

  • 哪个地址需要修正;
  • 修正哪个符号;
  • 修正的计算方式(加上偏移、取相对地址等)。

.dynsym 则是符号表的动态版本,告诉动态链接器“有哪些符号需要动态解析”。

动态库的生成

动态库的生成静态库的生成(.a)非常类似,都是在于利用目标文件 .o 区别在于编译与链接参数。

gcc -fPIC -shared -o libmylib.so a.o b.o c.o
  • -fPIC 生成 位置无关代码(Position Independent Code),让库能被加载到任意地址。
  • -shared 告诉链接器生成一个共享对象,而不是可执行文件。

位置无关性(PIC) 是动态库的关键:
因为动态库在不同进程中被加载到的地址往往不同,如果代码里直接写死绝对地址,会导致运行时错误。
因此,编译器会把所有外部符号的引用通过一层间接表(GOT)来访问。

链接时的约定

当主程序在链接阶段依赖一个动态库时(例如链接 -lmylib),链接器会:

  1. 在 ELF 文件中记录下程序依赖的库(放在 .dynamic 段中);
  2. 把那些属于动态库的符号标记为“需要运行时解析”的符号;
  3. 生成对应的 PLT / GOT 表结构
  4. 生成 .rela.dyn.rela.plt 段,保存符号修正规则。

这样,生成的可执行文件就知道自己依赖哪些动态库、哪些符号需要运行时解析。

运行时的动态加载

程序启动时,操作系统在加载elf文件的时候,将扫描依赖的动态库,此时会进入内核态,系统的 动态链接器(ld-linux.so 会介入整个加载过程。

1. 全局变量的重定位:.rela.dyn

动态链接器首先扫描 .rela.dyn

  • 读取每条重定位项;
  • 找到对应的符号;
  • 将其真实地址填入 GOT 表或数据段;
  • 这样程序访问全局变量时,地址已经正确。

这部分修正是 在程序启动阶段就完成的

2. 函数的延迟绑定:.rela.plt

对于函数调用,情况稍微复杂:

  • 程序调用外部函数时,会先跳转到 PLT(Procedure Linkage Table)
  • PLT 再通过 GOT 查找函数地址。
  • 如果是第一次调用,GOT 里存的是动态链接器的入口地址,程序会“陷入”动态链接器。
  • 动态链接器查找 .rela.plt 中的符号修正规则,解析出真实的函数地址,并把它写回 GOT。
  • 之后再调用同一个函数时,PLT 直接通过 GOT 跳到真实地址,不再触发解析。

这就是 延迟绑定(Lazy Binding) 的原理。

用一句话总结整个动态链接过程:

编译阶段记录符号,链接阶段生成重定位表,运行时动态链接器根据 .rela.dyn.rela.plt 修正 GOT,程序通过 GOT 访问变量和函数的真实地址。

下面就是实际一个动态库加载与使用过程的流程图:

so_load

动态库加载与符号解析过程演示

下面我们通过一个完整实验,直观展示 动态库函数调用从“延迟绑定”到“真实地址解析”的全过程

实验源码

文件结构:

.
├── add.cpp
├── add.h
├── main.cpp

add.h

#pragma once
int add(int a, int b);

add.cpp

#include "add.h"
int add(int a, int b)
{
    return a + b;
}

main.cpp

#include <iostream>
#include "add.h"

int main()
{
    std::cout << "3 + 4 = " << add(3,4) << std::endl;
    std::cout << "3 + 4 = " << add(3,4) << std::endl;
    return 0;
}

构建可执行文件与动态库

执行的时候注意,因为现代编译器为了安全默认关闭了延迟绑定,直接生成了函数的地址,但是间接寻址的方式没有变化,所以必须严格按照脚本生成可执行文件

# 生成动态库 libmath.so
g++ -fPIC -shared -o libmath.so add.cpp

# 生成可执行文件
g++ main.cpp -L. -lmath -no-pie -Wl,-z,lazy -g -o main

验证 ELF 依赖关系

readelf -d main | grep NEEDED

输出类似:

0x0000000000000001 (NEEDED)             Shared library: [libmath.so]

说明:程序运行时依赖 libmath.so


查看 PLT / GOT / 重定位表结构

readelf -r main

可以看到类似:

000000403ff8  000100000007 R_X86_64_JUMP_SLOT  add@Base + 0

这条记录就说明:

“add 函数在运行时需要被修正,它的真实地址会被写入 GOT 表的对应槽位。”


用 GDB 验证延迟绑定

现在我们来观察 第一次调用 add() 时 GOT/PLT 的变化过程

gdb ./main

在 GDB 中执行以下命令:

(gdb) start
Temporary breakpoint 1, main () at main.cpp:5
5	    std::cout << "3 + 4 = " << add(3,4) << std::endl;

(gdb) info functions add
All functions matching regular expression "add":
.....
0x0000000000401070  add(int, int)@plt
0x00007ffff7fb70f9  add(int, int)

你可以看到:

  • add@plt 是主程序中的“跳板”;
  • add 是动态库中真实函数的地址。

反汇编查看 PLT 跳转

(gdb) disassemble 0x0000000000401070
Dump of assembler code for function _Z3addii@plt:
   0x0000000000401070 <+0>:	endbr64
   0x0000000000401074 <+4>:	bnd jmp *0x2f9d(%rip)        # 0x404018 <_Z3addii@got.plt>
   0x000000000040107b <+11>:	nopl   0x0(%rax,%rax,1)
End of assembler dump.

可以看到:

调用 add(int, int)@plt 实际上是先 jmp.got.plt 中对应的槽位。

打印 GOT 槽位的值(第一次调用前)

(gdb) x/gx 0x404018
0x404018 <_Z3addii@got.plt>:	0x0000000000401030

此时 GOT 中的地址指向动态链接器(ld-linux.so),意味着还没绑定真实地址。

运行第一次调用

(gdb) n
3 + 4 = 7

此时触发了 lazy binding,动态链接器解析出 add 的真实地址并写回 GOT。

再次查看 GOT 槽位

(gdb) x/gx 0x404018
0x404018 <_Z3addii@got.plt>:	0x00007ffff7fb70f9

可以看到地址已经变了,对应于动态库中 add 的实现地址!

再次调用时

第二次执行:

(gdb) n
3 + 4 = 7

这时不再进入链接器,直接通过 GOT 中保存的真实地址跳转,完成调用。

验证总结图

阶段 GOT 内容 说明
程序启动 指向动态链接器 _dl_runtime_resolve 还未绑定
第一次调用 add() 调用 PLT → 链接器解析符号并修正 GOT 完成重定位
第二次调用 add() GOT 中是 add 的真实地址 直接跳转执行

总结性说明

通过这次实验你验证了动态链接中的关键事实:

机制 表现
延迟绑定 函数第一次调用才解析符号地址
PLT 表 作为中间跳板,负责函数跳转
GOT 表 保存最终解析的函数真实地址
动态链接器 运行时修改 GOT,实现符号解析

总结

动态库的出现,解决了静态库的三个根本问题:

  1. 可独立更新:接口不变即可替换库文件。
  2. 共享与复用:多个进程共用同一个 .so 文件,节省内存。
  3. 启动与执行高效:延迟绑定减少启动开销。

而在机制层面上,它的核心就是以下三张表的配合:

表 / 段名 作用
GOT(Global Offset Table) 存储符号的运行时真实地址
PLT(Procedure Linkage Table) 函数调用跳板,实现延迟绑定
.rela.dyn / .rela.plt 动态链接器的“施工图”,记录如何修正符号地址

整个动态链接过程,就是在利用动态链接器去重新生成符号地址的过程:

程序只负责声明“我需要这些符号”,而动态链接器根据 .rela.* 的“图纸”,把符号的真实地址在内存中重新接好,完成加载与执行。

就是在 "库的实现发生了变化"中找到了“符号不应该变化”,通过引入一层间接访问机制(PLT / GOT),让程序可以在运行时灵活地解析、更新与共享符号,实现了真正意义上的“可替换模块化”。
这正应了计算机科学中那句著名的论断:

“All problems in computer science can be solved by another level of indirection.”
——David Wheeler

posted @ 2025-10-16 06:59  ToBrightmoon  阅读(28)  评论(0)    收藏  举报

© ToBrightmoon. All Rights Reserved.

Powered by Cnblogs & Designed with ❤️ by Gemini.

湘ICP备XXXXXXXX号-X