ELF与动态库进阶-链接真相与GOT表

ELF 与动态库进阶:链接的真相、GOT 表与动态链接器

库文件系列第四篇(2026-07-21)。上一篇把「节 → 段 → mm_struct」的主线打通了,这一篇往底层再钻两件事:

① 链接到底做了什么?为什么 .o 叫「可重定位文件」?

② 运行期的函数调用是怎么找到动态库代码的?------ .got、重定位与 ld-linux.so


一、从「代码变成二进制」说起

先回答一个朴素的问题:为什么我们需要编译?

每一条 C 语句编译后,实际是这样一条记录:

复制代码
[ 指令长度 | 操作码 | 数据 ]
  • 代码段里的指令都是单字节标识 的,所以对齐、类型都不影响,每条指令紧密相连
  • 只要 PC 拿到了起始虚拟地址,就可以顺着「读长度 → 解析操作码 → 取数据」的节奏,递归式地一路读下去

而 CPU 本质上"很傻":它出厂时就被刻录了固定的一套指令集,只会执行指令集里存在的东西。

二进制 ⇄ 指令集 是一一映射的。

所以:

  • 同样一份二进制,换台机器为什么跑不起来? 因为不同厂商 CPU 刻录的指令集不同;
  • 软件的可移植性:硬件层面取决于指令集,软件层面取决于编译器 + 操作系统。

二、链接的本质:一堆 ELF 合并成一个 ELF

2.1 两个反直觉的事实

  1. 程序的入口不是 main,而是 _start
    readelf -h xxx.exeEntry point address,指向的是运行时启动代码 _start,由它准备好环境后再调用 main
  2. .o 里的地址全是 0。
    反汇编一个 .o 你会发现 call 后面的地址尚未确定------因为还没链接,地址还没分配 。这就是「可重定位文件 」这个名字的由来:地址待重定位

2.2 链接时先做「符号表检查」

链接器怎么知道某个函数有没有定义?答案是 ELF 里有一个节专门存放符号表(.symtab ,而符号表在文件中的字节偏移量记录在 ELF Header 里。

链接的第一件事就是遍历所有 .o 的符号表

  • 遍历完所有 .o 都找不到定义 → 链接失败(undefined reference)
  • 同一个符号被定义了多次 → 重定义错误(multiple definition)

检查通过后,再谈合并。

2.3 合并规则

内容 合并方式
代码(节) .o 的顺序直接拼接
数据(节) 按先后顺序 + 类型 + 对齐要求排布

于是三件事一次完成:① 各节的偏移量 ② 整体文件的偏移量 ③ 静态链接所需的偏移量。有了偏移量就有了虚拟地址,映射关系随之可以建立。

所以静态链接的结论很硬核:静态库不需要在运行期加载到内存------因为整个库的代码早已被复制进可执行文件,在磁盘上就完成了全部链接工作。

补充一个容易误解的点:.a 本身不是标准 ELF 文件,它只是 ar 归档包;但包里面每一个 .o 都是标准 ELF。


三、动态链接:链接的是「链接」,不是「运行」

3.1 静态链接的代价

静态链接把节全部合并、重新以新磁盘偏移构建虚拟地址,一切 OK......除了体积

全部重载会让可执行文件体积膨胀近 100 倍

内存再优秀也救不了磁盘空间问题。而且内存就那么大,代码和数据必须进内存 CPU 才能用------可执行文件太肥,就是灾难。

3.2 动态链接的路线

复制代码
① 找到库:库路径 → 库文件的 inode number → 读 inode → 拿块号
② 把 .so 映射到进程地址空间的【共享区】(mm_struct 里属于全局共享的那段编号)
③ 运行期调用库函数 = 对共享区虚拟地址的调用

共享库(Shared Object)的本质

  • 一份 .so 被映射到共享区后,代码和数据只需在物理内存中存在一份,可以被多个进程共同映射使用;
  • exec 创建子进程时,内存里已经载入的库并不会销毁,只是虚拟地址映射消失,子进程再自行按需建立映射即可(常驻共享);
  • 问题来了:内存里有些库被 1 个进程映射、有些被 10 个映射、有些已销毁......操作系统必须用一个数据结构来专门管理这些「已载入内存的 .so」 。这就是动态库管理的由来(内核里对应 struct file/共享映射的引用计数那一套)。

3.3 地址空间布局的「对偶」

复制代码
低地址  →  0 号起始(与磁盘块组相似):.text / 数据段  →  堆  →  【共享区:.so 库】  →  栈  ← 高地址

有意思的对称:.so 落在磁盘的高地址侧,而进程地址空间里共享区也位于上部------这套布局与我们学过的磁盘分区、块组划分是同一种「先分区再放东西」的思路。


四、.o / .exe / .so:谁有 Program Header Table?

这是本篇最值得记住的一张表:

文件 是否标准 ELF 有 Section Header Table 有 Program Header Table
.o 可重定位目标文件 没有
.exe 可执行文件
.so 共享库

为什么 .o 没有 Program Header Table? 因为 Program Header Table 是链接器 ld 的产物

  1. ld 遍历所有节 ,按类型 + 权限把相关的节归拢、排好相对位置;
  2. 属性相近的节(比如都是已初始化数据)块号紧密排在一起,遇到数据截断就知道要排到下一段;
  3. 排完后写入 Program Header Table------记录每一段的相对字节位置

关键理解:

Program Header Table 不是「全局虚拟地址表」,而是「段的架构蓝图」 :它提供的是段的相对布局。

最终虚拟地址 = 这份蓝图(ld 的 exe Program Header)+ 系统按段类型分配的基地址。

于是完整的地址落地链条是:

复制代码
虚拟地址 → ① 依据段类型确定它属于哪个段
        → ② 拿 Program Header 里的偏移量(虚拟地址本身就是 4KB 倍块号 + 段内偏移)
        → ③ 加上系统分配的基地址 = 最终虚拟地址
        → ④ 缺页时:用最终虚拟地址查页表(hash,O(1))→ 找到对应的磁盘 4KB 块
        → ⑤ 在内存里找空闲页,把这一块整体载入 → 反向登记映射

这条链之所以能成立,全靠一个巧合(其实是设计):虚拟地址是 4KB×n,物理内存页是 4KB×n,磁盘上的数据存储也是 4KB×n ------三者完美对齐,于是「映射」退化成一块 4KB 整体挪移


五、重定位与 .got:动态库函数是怎么被调用的

5.1 难题:call 后面的地址是段里的,改不了

动态库在链接期还不知道自己会被加载到哪个虚拟地址(位置无关 )。可是 call 指令的目标地址写在代码段 里,而代码段是只读的,运行期无法修改

解法很巧妙:

.data(数据段)里专门划出一块区域,用来存放「函数跳转地址」------这就是 .got(Global Offset Table,全局偏移表)。

5.2 一趟完整的解析过程

复制代码
【编译/链接期】
ld 遍历用到的动态库的符号表,在 .got 里为每个库函数留一个条目
   → 调用 100 个库函数,.got 至少 100 项
   → 此时条目里通常是「符号名 + 占位」(如 libc.c@0x4044)
   代码里写的是「间接调用」:call .got[n]

【运行期 · 动态库加载时】
① ld-linux.so.2(动态链接器)把 .so 映射进共享区,建立映射
② 走一遍重定位:修改 .data 里的 .got 条目,写入真实的最终虚拟地址(基地址 + 偏移)
③ 此后 CPU 执行到 call .got[n],硬件自动查表拿到目标地址并跳转

重点区分两个"链接器":

静态链接器 动态链接器
程序名 ld ld-linux.so.2
工作时机 链接期(编译时) 运行期(程序启动 / 库载入时)
干什么 合并节、生成 Program Header Table、填 .got 占位 载入库、建映射、重定位修改 .got 条目

顺带回答「为什么静态链接也存在动态链接的身影」:即使采用动态库,程序里仍然有大量静态完成的结构(节、段布局、.got 框架),只是具体库函数地址的填写被推迟到了运行期。这就是「链接的链接,不是运行的链接」这句话的真正含义。

-fPIC(位置无关代码)之所以必要,也正是因为动态库的虚拟基地址未知 :只有全部代码采用相对寻址 + 通过 .got 间接跳转,库才能被加载到任意位置而不出错。


六、小结

复制代码
源码 ──编译──▶ .o(ELF,地址全 0,可重定位,无 PHT)
.o 们 ──ld──▶ 合并节 + 符号检查 + 生成 PHT ──▶ 可执行 ELF(有 PHT)
                                              ├─ 静态:库代码已复制进来,运行期零依赖
                                              └─ 动态:只记库名,运行期 ld-linux.so.2 载入库 → 重定位 .got

五条最值钱的结论:

  1. 入口是 _start 不是 main.o 的地址全 0,所以叫"可重定位";
  2. 链接先查符号表(未定义/重定义都过不了),再按「代码拼接 + 数据对齐」合并;
  3. 节是磁盘的、段是内存的 ;只有被 ld 处理过的文件(.exe/.so)才有 Program Header Table;
  4. 最终虚拟地址 = 段蓝图(PHT)+ 系统分配的基地址;三端都是 4KB 对齐,所以映射等于整块 4KB 搬运;
  5. 代码段只读不可改 → 用 .got 间接跳转.got 的填写是运行期由动态链接器完成的重定位工作。

本文基于 2026-07-21《ELF 动态库》笔记整理。后续可继续深入:动态库为什么天然共享(7-28),以及 mm_struct 视角下的动态库加载细节。

相关推荐
M78佐菲1 小时前
ARM学习笔记(五)
linux·arm开发·笔记·嵌入式硬件·学习
H_oRIZoN_2 小时前
Linux入门DAY45 IMX6ULL 裸机开发笔记:汇编点灯、C 语言点灯、GIC 中断梳理
linux·单片机·嵌入式硬件
临期冰淇淋3 小时前
Unisoc 展锐平台Camera摄像头分辨率适配
linux·c语言·驱动开发
流浪0013 小时前
Linux系统篇36——线程(一) 线程的概念、本质和Linux的实现方式
linux·面试·操作系统·线程
从零开始的嵌入式之旅4 小时前
day46
linux·arm开发·笔记·嵌入式硬件
虚无的纽扣4 小时前
【Linux】将进程知识与基础IO融会贯通——简单自定义Shell的实现
linux·ubuntu
傲世仙尊4 小时前
预处理详解-define宏井号条件编译一篇过
linux·c语言
忆挽篱笙歌4 小时前
linux基本指令
linux
小小de风呀5 小时前
de风——【从零开始学习Linux】(五):gcc编译器的基本使用
linux·运维·服务器