【Linux系统】17 库制作与原理(下)

目录

前言

一、编译链接整体流程

[二、ELF 文件格式](#二、ELF 文件格式)

两个核心视图(面试高频)

[ELF 头部关键字段](#ELF 头部关键字段)

三、静态链接原理

四、动态链接原理

[4.1 为什么需要位置无关码 -fPIC](#4.1 为什么需要位置无关码 -fPIC)

[4.2 GOT 和 PLT,延迟绑定 Lazy Binding](#4.2 GOT 和 PLT,延迟绑定 Lazy Binding)

[4.3 动态链接完整宏观流程](#4.3 动态链接完整宏观流程)

[五、C 程序启动流程:main 不是真正入口](#五、C 程序启动流程:main 不是真正入口)

[六、静态链接 VS 动态链接完整对比表](#六、静态链接 VS 动态链接完整对比表)

[七、常用 ELF 调试命令汇总](#七、常用 ELF 调试命令汇总)

下篇小结


前言

我们上篇学会了怎么制作静态库、动态库,会写 Makefile,解决动态库找不到的问题。但是有几个核心疑问:

  1. .c源文件经过 gcc,如何变成.o目标文件?目标文件如何变成可执行程序?
  2. ELF 是什么?section(节)和 segment(段)有什么区别?链接视图和执行视图?
  3. 静态链接的时候,链接器做了哪些工作?什么叫重定位?
  4. 动态库为什么需要-fPIC位置无关代码?GOT、PLT 是什么?延迟绑定原理?
  5. C 程序不是从 main 函数开始执行?_start、__libc_start_main 做了什么?

一、编译链接整体流程

cpp 复制代码
add.c sub.c xxx.c 源代码
     ↓ 编译器 gcc -c
add.o sub.o xxx.o 目标文件(可重定位ELF文件) + 库文件
     ↓ 链接器 ld
a.out 可执行程序(ELF可执行文件)

示例代码 hello.c

cpp 复制代码
#include<stdio.h>
void run();
int main() {
    printf("hello world!\n");
    run();
    return 0;
}

code.c

cpp 复制代码
#include<stdio.h>
void run() {
    printf("running...\n");
}

只编译不链接,生成.o目标文件:

cpp 复制代码
gcc -c hello.c
gcc -c code.c
ls *.o

.o是可重定位 ELF 目标文件,二进制,不能直接运行。

观察反汇编

cpp 复制代码
objdump -d hello.o

你会发现,调用printf、run的 call 指令,跳转地址写的全部是00 00 00 00。

编译器编译单个.o的时候,不知道外部函数printf、run到底在内存哪个地址。编译器把未知外部调用地址先填 0,留下重定位条目,交给链接阶段修正。

使用readelf -s hello.o查看符号表.symtab

  • UND(undefined):未定义符号,本目标文件找不到这个符号,需要外部链接提供。

把多个.o 链接成可执行程序

cpp 复制代码
gcc hello.o code.o -o main.exe

链接器的两大工作:

  1. 段合并 :把多个.o里面同名 section 合并,多个.text合并为可执行文件的.text,.data合并,.bss合并。
  2. 符号解析 + 重定位:找到 UND 未定义符号对应的真实地址,修正 call 指令里面的 0 占位地址。

静态库本质就是一堆.o的归档包;链接静态库,就是链接器从归档包中把需要的.o拿出来参与链接,和直接把.o合并是同一个逻辑。

二、ELF 文件格式

Linux 下面 4 种文件都是 ELF 格式:

  1. 可重定位文件(Relocatable File):xxx.o目标文件;编译产出,给链接器使用。
  2. 可执行文件(Executable File):a.out,我们运行的程序。
  3. 共享目标文件(Shared Object File):xxx.so动态库。
  4. 内核转储 core dumps:程序崩溃生成 core 文件。

ELF 文件四大组成部分

  1. ELF Header ELF 头部:整个文件元信息,魔数、文件类型、机器架构、程序入口地址;记录程序头表、节头表在文件的偏移位置。
  2. Program Header Table 程序头表 (执行视图):告诉操作系统加载器,如何把文件加载映射到进程内存;描述 Segment 段。可重定位.o 目标文件没有程序头表。
  3. Section Header Table 节头表(链接视图):描述各个 section 节,链接器使用。
  4. Sections(各个节):真正存放代码、数据、符号、重定位信息。

两个核心视图(面试高频)

  1. 链接视图 ------ Section(节),节头表 Section Header Table

给链接器使用,编译链接阶段起作用,粒度细。各个独立 section:.text代码节、.data初始化全局数据、.rodata只读常量、.bss未初始化全局变量、.symtab符号表、.rel.*重定位节。

  1. 执行视图 ------ Segment(段),程序头表 Program Header Table

给操作系统加载器使用,程序运行加载阶段。链接器会把多个属性相同的 section 合并为一个 Segment 段。 例如多个只读可执行 section 合并为一个LOAD类型 segment(R-E 权限);多个读写 section 合并为读写 segment(RW 权限)。
为什么要合并 section 成 segment? 内存按页(4KB)分配。大量细碎小 section,如果每个 section 单独占一页,会造成大量内存页碎片浪费。合并相同权限的 section 到同一个 segment,减少内存页数量,提升内存利用率。
工具命令

cpp 复制代码
readelf -h main.exe    # 查看ELF头部
readelf -S main.exe    # 查看所有section节(链接视图)
readelf -l main.exe    # 查看program header segment段(执行视图)
objdump -d main.exe    # 反汇编代码段.text

关键节说明

表格

节名 作用
.text 存放编译后的机器指令代码
.data 存放已经初始化的全局、静态变量
.rodata 只读数据:字符串常量等,只读
.bss 未初始化全局 / 静态变量,文件中不占磁盘空间,运行时内存清零
.symtab 符号表,保存函数名、变量名符号信息
.got / .got.plt 全局偏移表,动态链接使用,数据段,可写
.plt 过程链接表,动态链接使用,代码段

ELF 头部关键字段

  • e_ident魔数:7f 45 4c 46,识别 ELF 文件;
  • e_entry:程序入口虚拟地址;注意入口不是 main,是_start;
  • e_phoff:程序头表在文件内偏移;
  • e_shoff:节头表在文件内偏移。

查看目标文件 hello.o:readelf -h hello.o,目标文件 Type 是 REL(可重定位),Number of program headers = 0,没有程序头表,不能直接加载运行。

三、静态链接原理

静态链接 = 段合并 + 符号解析 + 重定位。

  1. 段合并:把各个输入.o同名 section 合并。
  2. 符号解析:链接器遍历各个目标文件符号表,把 UND 未定义符号找到对应的定义。
  3. 重定位:根据重定位表,修正代码 / 数据中所有外部符号占位地址,把 0 替换成真实虚拟地址。

静态链接重定位发生在编译链接阶段,程序运行的时候地址全部已经确定。

静态库就是 ar 打包一堆.o,链接静态库等价于取出库内.o 参与链接。链接之后库文件就不再需要。

四、动态链接原理

4.1 为什么需要位置无关码 -fPIC

动态库.so要加载到进程虚拟地址空间,但是每次加载的虚拟基地址不固定,操作系统根据内存使用情况分配。

如果代码写死绝对虚拟地址,换一个加载基地址全部就会失效。 PIC 位置无关代码全部使用相对寻址,不写死绝对地址;不管 so 被映射到哪一块虚拟地址,代码都可以正常执行。

PIC 核心依靠:GOT 全局偏移表 Global Offset Table

问题:代码段.text是只读权限,运行时不能修改! 如果动态链接需要回填外部函数地址,不能直接修改只读代码段。解决方案:把所有外部符号的地址,存放到可写的数据段的 GOT 表。代码段只使用相对偏移访问 GOT 表项,真正的地址保存在 GOT 里面,运行时修改 GOT。

4.2 GOT 和 PLT,延迟绑定 Lazy Binding

  1. GOT(Global Offset Table 全局偏移表) 保存在可写数据段,每一个表项存储外部函数 / 变量的虚拟地址。每个进程有自己独立 GOT 副本,所以 GOT 不能多进程共享。
  2. PLT(Procedure Linkage Table 过程链接表) 存放在代码段,是一小段跳转桩代码,配合 GOT 实现延迟绑定。

延迟绑定(Lazy Binding) :Linux 默认策略。 不过程序一启动就解析所有动态库函数;函数第一次被调用的时候,才解析符号,回填 GOT 表。没有被调用过的函数完全不做解析,加快程序启动速度。

工作流程:

  1. 第一次调用动态库函数:执行 PLT 桩代码,GOT 表项存的是跳转到动态链接器的占位地址;
  2. 触发动态链接器ld-linux.so,查找这个函数真实虚拟地址;
  3. 把真实函数地址回填 GOT 对应表项;
  4. 跳转到真正库函数执行;
  5. 第二次调用同一个函数:直接查 GOT 拿到已经填好的真实地址,不再调用动态链接器。

4.3 动态链接完整宏观流程

运行./a.out,execve 系统调用加载 ELF 可执行文件:

  1. 内核读取 ELF 程序头表 Program Header Table;
  2. 创建进程task_struct、mm_struct,建立虚拟地址空间;
  3. 识别.interp段,找到动态链接器/lib64/ld-linux-x86-64.so.2;
  4. 把动态链接器加载映射进进程虚拟地址空间;
  5. 动态链接器读取可执行程序的依赖列表,逐个加载所需要的.so动态库,映射到进程虚拟地址;
  6. 动态链接器处理符号,配合 PLT/GOT,实现延迟绑定;
  7. 执行控制权交给可执行程序的入口_start。

物理内存中,动态库的代码段.text是只读,多进程可以共享;但是数据段、GOT 表每个进程拥有自己独立副本,不能共享。

五、C 程序启动流程:main 不是真正入口

内核执行完 execve,跳转到程序入口_start,不是 main 函数! _start来自 crt1.o(C 运行时库 crt 启动文件)。

_start函数执行一系列工作:

  1. 设置进程初始栈,处理argc、argv;
  2. 处理动态链接相关初始化;
  3. 调用__libc_start_main(glibc 提供);

__libc_start_main完成:

  1. 全局变量、构造函数初始化;
  2. 注册退出清理函数;
  3. 调用用户写的 main 函数;
  4. main 返回之后,处理返回值,调用_exit结束进程。

所以我们写的 main 函数,仅仅是被库函数调用的普通函数。

六、静态链接 VS 动态链接完整对比表

表格

对比项 静态链接(-static) 动态链接(gcc 默认)
链接时机 编译链接期完成全部符号解析、重定位 链接期仅记录依赖;重定位推迟到程序运行时
库代码处理 把静态库.o机器码拷贝复制进可执行程序 不拷贝代码;运行时操作系统加载 so
运行依赖 运行不需要库文件 运行必须存在对应的.so动态库
可执行文件体积 体积大,包含库完整代码 体积小,只存引用信息
内存占用 每个进程独立一份库代码,内存浪费 物理内存一份 so 代码段,多进程共享,节省内存
库升级 库更新,所有程序必须重新编译链接 直接替换 so,程序不需要重新编译
重定位实现 编译期静态重定位 运行时 GOT 表动态重定位,PLT 延迟绑定
启动速度 启动快,运行期没有链接开销 启动有动态链接解析开销

七、常用 ELF 调试命令汇总

cpp 复制代码
# readelf
readelf -h xxx        # ELF头部
readelf -S xxx        # 全部section节
readelf -l xxx        # program header segment段
readelf -s xxx        # 符号表

# objdump
objdump -d xxx        # 反汇编.text代码段
objdump -S xxx        # 混合源码反汇编

ldd xxx               # 查看可执行/so依赖哪些动态库
file xxx              # 识别文件类型
nm xxx                # 查看符号

下篇小结

  1. ELF 分链接视图 section(节) 和执行视图 segment(段) ;.o目标文件只有节没有段;运行加载看 Program Header。
  2. 静态链接核心:段合并、符号解析、编译期重定位;静态库就是归档的.o集合。
  3. 动态库必须-fPIC位置无关码;依靠 GOT(可写,存放外部符号地址)、PLT 实现延迟绑定,减少程序启动开销。
  4. C 程序入口是_start,由 crt 启动文件提供,_start调用__libc_start_main,再调用我们写的 main。
  5. 动态链接器ld-linux.so.2,程序运行时负责加载 so、符号解析、回填 GOT 表。

拓展思考题(面试常考)

  1. 为什么动态库不能修改.text 代码段完成重定位?
  2. GOT 表为什么每个进程不能共享?
  3. 延迟绑定有什么优缺点?
相关推荐
weixin_450907282 小时前
[ZYNQ Linux] VCU使用
linux·zynq
acd120092 小时前
Nginx 反向代理的缓冲区坑:后端响应大时接口偶发 502
java·运维·nginx
程序员清风2 小时前
AI 编程助手的权限边界:文件、命令、密钥与人工确认
运维·服务器·网络
wacsii2 小时前
【Linux进程(二)】深入 Linux 内核:进程状态切换与底层数据结构的秘密
linux·服务器
R&L_201810012 小时前
LINUX CPU 热点分析
linux
CodeYp20193 小时前
第6讲:Ext 系列文件系统
linux·文件系统·ext
爱吃香菜的初学者3 小时前
十九.Linux——TCP协议(1)
linux·网络·tcp/ip
Rambo.Fan3 小时前
02 · 重建与修复手册(新服务器 playbook)
运维·服务器·django
Zevalin爱灰灰3 小时前
Linux入门第二部分(ARM裸机篇) 第三章 汇编LED驱动实验
linux·汇编·arm开发