【Linux】ELF与动静态库的“暗黑兵法”:程序是如何跑起来的?

目录

一、什么是库?

[1. C标准库(libc)](#1. C标准库(libc))

[2. C++标准库(libstdc++)](#2. C++标准库(libstdc++))

二、静态库

[1. 静态库的生成](#1. 静态库的生成)

[2. 静态库的使用](#2. 静态库的使用)

三、动态库

[1. 动态库的生成](#1. 动态库的生成)

[2. 动态库的使用](#2. 动态库的使用)

[3. 库运行的搜索路径。](#3. 库运行的搜索路径。)

(1)原因分析

(2)解决方案

[① 设置 LD_LIBRARY_PATH](#① 设置 LD_LIBRARY_PATH)

[② 将库复制到系统路径](#② 将库复制到系统路径)

[③ 复制.so文件到系统库目录](#③ 复制.so文件到系统库目录)

[④ 创建软链接到系统库文件](#④ 创建软链接到系统库文件)

四、外部库(补充)

五、目标文件(原理部分)

六、EIL文件

[1. ELF文件的四种格式](#1. ELF文件的四种格式)

[2. ELF文件的核心结构](#2. ELF文件的核心结构)

[(1)ELF Header(ELF头)](#(1)ELF Header(ELF头))

[(2)Program Header Table(程序头表)](#(2)Program Header Table(程序头表))

[(3)Section Header Table(节头表)](#(3)Section Header Table(节头表))

(4)Sections(节)

七、ELF从加载到轮廓

[1. ELF形成可执行文件](#1. ELF形成可执行文件)

[2. ELF可执行文件加载](#2. ELF可执行文件加载)

八、理解链接与加载

[1. 静态链接](#1. 静态链接)

(1)编译阶段

(2)重定位表

(3)静态链接阶段:地址修正

(4)总结

[2. ELF加载和进程地址空间](#2. ELF加载和进程地址空间)

[(1)逻辑地址 / 虚拟地址](#(1)逻辑地址 / 虚拟地址)

(2)重新理解进程虚拟地址空间

(3)静态链接库在内存加载

[3. 动态链接与动态库加载](#3. 动态链接与动态库加载)

(1)动态库加载

(2)进程间共享动态库

(3)动态链接

[① 动态链接概要](#① 动态链接概要)

[② 可执行程序被编译器动了手脚](#② 可执行程序被编译器动了手脚)

[③ 动态库中的相对地址](#③ 动态库中的相对地址)

[④ 程序与动态库映射](#④ 程序与动态库映射)

[⑤ 程序调用库函数](#⑤ 程序调用库函数)

[⑥ 全局偏移量表GOT](#⑥ 全局偏移量表GOT)

[⑦ 动态库间依赖](#⑦ 动态库间依赖)

[4. 总结](#4. 总结)


一、什么是库?

库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。

本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。

库有两种:静态库.a[Linux]、.lib[windows];动态库.so[Linux]、.dll[windows]

1. C标准库(libc)

系统 动态库(.so) 静态库(.a)
Ubuntu /lib/x86_64-linux-gnu/libc-2.31.so /lib/x86_64-linux-gnu/libc.a
-rwxr-xr-x 1 root root 2029592 May 1 02:20 -rw-r--r-- 1 root root 5747594 May 1 02:20
CentOS /lib64/libc-2.17.so /lib64/libc.a
-rwxr-xr-x 1 root root 2156592 Jun 4 23:05 -rw-r--r-- 1 root root 5105516 Jun 4 23

2. C++标准库(libstdc++)

系统 动态库(.so) 静态库(.a)
Ubuntu /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.so /usr/lib/gcc/x86_64-linux-gnu/9/libstdc++.a
符号链接→ ../../../x86_64-linux-gnu/libstdc++.so.6 -rw-r--r--(文件大小未显示)
CentOS /lib64/libstdc++.so.6 /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a
符号链接→ libstdc++.so.6.0.19 -rw-r--r-- 1 root root 2932366 Sep 30 2020

二、静态库

• 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。

• 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库。我们也可以使用 gcc 的 -static 强制设置链接静态库,一旦 -static 就必须存在对应的静态库。

1. 静态库的生成

mystdio.c mymakstring.c是我们自主实现的源文件。现将其编译为目标文件(.o),再使用ar打包为静态库(libmystdio.a)。

output:将头文件(.h)和静态库(.a)打包成 stdc.tgz。步骤:

(1)创建目录 stdc/include 和 stdc/lib。

(2)复制头文件到 include/,静态库到 lib/。

(3)用 tar -czf 打包成 stdc.tgz(gzip 压缩)。
ar 是归档工具,参数说明:

r:替换已存在的文件

c:创建库(如果不存在)

s:写入索引(加速链接)

t 选项:列出静态库中的文件

v 选项:详细信息(verbose)

cpp 复制代码
[Makefile]
libmystdio.a:mystdio.o mymakstring.o
	@ar -rc $@ $^
	@echo "build $^ to $@ ... done"
%.o:%.c
	@gcc -c $<
	@echo "compling $< to $@ ... done"
.PHONY:clean
clean:
	@rm -rf *.a *.o stdc*
	@echo "clean ... done"
.PHONY:output
output:
	@mkdir -p stdc/include
	@mkdir -p stdc/lib
	@cp -f *.h stdc/include
	@cp -f *.a stdc/lib
	@tar -czf stdc.tgz stdc
	@echo "output stdc ... done"

2. 静态库的使用

任意目录下新建Main.c,使用我们自己实现的库里的函数调用。

cpp 复制代码
// 再新建目录lib下的新文件Main.c
#include "mystdio.h"
#include "mystring.h"

int main()
{
    MYFILE *filep = MyFopen("log.txt", "a");
    if (!filep)
    {
        printf("MyFopen error!\n");
        return 1;
    }

    // const char *msg = "hello MyFwrite\n"; // 行刷新
    // MyFwrite(filep, msg, strlen(msg));

    int cnt = 5;
    while (cnt--)
    {
        const char *msg = "hello MyFwrite!"; // 没有'\n',不满足刷新条件,待在缓冲区
        MyFwrite(filep, msg, strlen(msg));
        // 强制刷新缓冲区
        MyFflush(filep);
        printf("buffer:%s\n", filep->outbuffer); // 打印缓冲区内容
        sleep(1);
    }

    MyFcolse(filep);
    const char *str = "hello!\n";
    printf("my_strlen: %d\n", my_strlen(str));
    return 0;
}

场景1:头文件和库文件安装到系统路径下
$ gcc Main.c -lmystdio

场景2:头文件和库文件和我们自己的源文件在同一个路径下
$ gcc Main.c -L. -lmystdio

场景3:头文件和库文件有自己的独立路径
$ gcc Main.c -I头文件路径 -L库文件路径 -lmystdio

-L:指定库路径。

-I:指定头文件搜索路径。

-l:指定库名。
• 测试目标文件生成后,静态库删掉,程序照样可以运行。

• 关于 -static 选项,稍后介绍。

库文件名称和引入库的名称:去掉前缀 lib,去掉后缀.so、.a,如:libc.so -> c

bash 复制代码
$ tree .
.
├── lib
│   └── Main.c
├── Makefile
├── mystdio.c
├── mystdio.h
├── mystring.c
├── mystring.h
└── usercode.c
2 directories, 10 files

$ make   # 制作并打包静态库
compling mystdio.c to mystdio.o ... done
compling mystring.c to mystring.o ... done
build mystdio.o mystring.o to libmystdio.a ... done

$ cd lib
$ gcc Main.c -I../ -L../ -lmystdio # 链接静态库
$ ./a.out
buffer:hello MyFwrite!
buffer:hello MyFwrite!
buffer:hello MyFwrite!
^C

$ tree ../
../
├── lib
│   ├── a.out
│   └── Main.c
├── libmystdio.a
├── Makefile
├── mystdio.c
├── mystdio.h
├── mystdio.o
├── mystring.c
├── mystring.h
├── mystring.o
└── usercode.c

2 directories, 11 files

三、动态库

• 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。

• 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。

在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)。

• 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。

1. 动态库的生成

(1)将 mystdio.o 和 mystring.o 链接成动态库 libmystdio.so

-shared:生成动态库/共享库格式(而不是可执行文件)。

库名规则:libxxx.so

(2)将 .c 文件编译为位置无关代码(PIC)的目标文件(.o)。

-fPIC:生成位置无关代码(Position-Independent Code),动态库必需。

$<:当前依赖的源文件(如 mystdio.c)。

(3)将头文件(.h)和动态库(.so)打包成 stdc.tgz。

cpp 复制代码
[Makefile]
libmystdio.so:mystdio.o mystring.o
	@gcc -o $@ $^ -shared
	@echo "build $^ to $@ ... done"
%.o:%.c
	@gcc -fPIC -c $< 
	@echo "compling $< to $@ ... done"
.PHONY:clean
clean:
	@rm -rf *.so *.o stdc*
	@echo "clean ... done"
.PHONY:output
output: # 把头文件和动态库打包压缩成 stdc.tgz
	@mkdir -p stdc/include
	@mkdir -p stdc/lib
	@cp -f *.h stdc/include
	@cp -f *.so stdc/lib
	@tar -czf stdc.tgz stdc
	@echo "output stdc ... done"

2. 动态库的使用

场景1:头文件和库文件安装到系统路径下

$ gcc main.c -lmystdio

场景2:头文件和库文件和我们自己的源文件在同一个路径下

$ gcc main.c -L. -lmymath // 从左到右搜索-L指定的目录

场景3:头文件和库文件有自己的独立路径

$ gcc main.c -I头文件路径 -L库文件路径 -lmymath

bash 复制代码
$ gcc usercode.c -I../stdc/include -L../stdc/lib -lmystdio
$ ll
total 28
drwxrwxr-x 2 zyt zyt  4096 May 14 18:47 ./
drwxrwxr-x 4 zyt zyt  4096 May 14 18:45 ../
-rwxrwxr-x 1 zyt zyt 16256 May 14 18:47 a.out*
-rw-rw-r-- 1 zyt zyt   742 May 14 18:29 usercode.c
# 当我们执行代码时,却显示动态库没有被加载
$ ./a.out
./a.out: error while loading shared libraries: libmystdio.so: cannot open shared object file: No such file or directory
# 用ldd查看库或可执行程序的依赖,发现libstdio.so找不到
$ ldd a.out
        linux-vdso.so.1 (0x00007ffe3f0f1000)
        libmystdio.so => not found
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000769ff5a00000)
        /lib64/ld-linux-x86-64.so.2 (0x0000769ff5cf4000)

我们按照方法执行后发现,动态库没有被加载。 这是为什么?

这个问题是因为系统在运行时找不到动态库 libmystdio.so 的位置。虽然我们在编译时通过 -L 指定了库的路径,但 -L 只对编译时的链接器有效,而运行时的动态链接器(ld.so)并不知道这个路径。

3. 库运行的搜索路径。

(1)原因分析

对于前面的问题:

bash 复制代码
$ ldd a.out
        linux-vdso.so.1 (0x00007ffe3f0f1000)
        libmystdio.so => not found
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000769ff5a00000)
        /lib64/ld-linux-x86-64.so.2 (0x0000769ff5cf4000)

• 编译时:-L../stdc/lib 告诉链接器在哪里找 libmystdio.so,因此编译能成功。

• 运行时:系统默认只在标准路径(如 /lib、/usr/lib)和 LD_LIBRARY_PATH(环境变量)中搜索动态库,而你的库在自定义路径 ../stdc/lib 中,导致加载失败。

(2)解决方案

① 设置 LD_LIBRARY_PATH

LD_LIBRARY_PATH是环境变量。作用是将库所在路径添加到动态链接器的搜索路径中。

缺点:仅在当前终端会话有效,重启后需重新设置。

bash 复制代码
$ echo $LD_LIBRARY_PATH # 初始是空

$ export LD_LIBRARY_PATH=${LD_LIBRARY_PATH}:/home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib
$ echo ${LD_LIBRARY_PATH}
:/home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib
$ ldd a.out
linux-vdso.so.1 (0x00007ffd45df0000)
libmystdio.so => /home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib/libmystdio.so (0x00007f8a1b234000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8a1ae00000)
/lib64/ld-linux-x86-64.so.2 (0x00007f8a1b434000)
② 将库复制到系统路径

ldconfig方案:配置/etc/ld.so.conf.d/ 。该目录包含自定义的库路径配置文件,系统启动时会加载这些路径到动态链接器的缓存中。

bash 复制代码
# /etc/ld.so.conf.d/下创建一个自定义的配置文件
$ sudo touch /etc/ld.so.conf.d/zyt.conf 
[sudo] password for zyt: 
$ ll /etc/ld.so.conf.d
total 36
drwxr-xr-x   2 root root  4096 May 14 19:10 ./
drwxr-xr-x 122 root root 12288 May  8 06:37 ../
-rw-r--r--   1 root root    38 Jan 22  2024 fakeroot-x86_64-linux-gnu.conf
-rw-r--r--   1 root root    44 Aug  2  2022 libc.conf
-rw-r--r--   1 root root   100 Mar 30  2024 x86_64-linux-gnu.conf
-rw-r--r--   1 root root     0 May 14 19:10 zyt.conf
-rw-r--r--   1 root root    56 Jan 29 01:07 zz_i386-biarch-compat.conf
-rw-r--r--   1 root root    58 Jan 29 01:07 zz_x32-biarch-compat.conf

# 填写动态库路径
$ sudo vim /etc/ld.so.conf.d/zyt.conf
$ cat /etc/ld.so.conf.d/zyt.conf
/home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib

# 更新库缓存,重新加载库搜索路径,使生效
$ sudo ldconfig
$ ldd a.out
        linux-vdso.so.1 (0x00007ffc84daa000)
        libmystdio.so => /home/zyt/linux-journey-log/code_25_5_14/dynamiclib/stdc/lib/libmystdio.so (0x000071e2461f0000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000071e245e00000)
        /lib64/ld-linux-x86-64.so.2 (0x000071e2461fc000)
③ 复制.so文件到系统库目录

拷贝.so文件到系统共享库路径下,一般是/usr/lib,/usr/local/lib,/lib64。(推荐)

bash 复制代码
# 1. 复制动态库到/usr/local/lib(需要sudo权限)
$ sudo cp /home/zyt/linux-journey-log/code_25_5_15/dynamiclib/stdc/lib/libmystdio.so /usr/local/lib/
# 2. 设置正确的文件权限
$ sudo chmod 755 /usr/local/lib/libmystdio.so
# 3. 更新动态链接器缓存
$ sudo ldconfig
# 4. 验证是否成功
$ ldconfig -p | grep libmystdio.so
        libmystdio.so (libc6,x86-64) => /usr/local/lib/libmystdio.so
$ ldd a.out
        linux-vdso.so.1 (0x00007ffede579000)
        libmystdio.so => /usr/local/lib/libmystdio.so (0x00007b98a6af5000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007b98a6800000)
        /lib64/ld-linux-x86-64.so.2 (0x00007b98a6b09000)
④ 创建软链接到系统库文件

适用于库文件需要经常更新,不想复制的场景。多个版本共存时,可以通过软链接切换。

bash 复制代码
# 1. 创建软链接(需要sudo权限)
$ sudo ln -s /home/zyt/linux-journey-log/code_25_5_15/dynamiclib/stdc/lib/libmystdio.so /usr/local/lib/libmystdio.so
[sudo] password for zyt: 
# 2. 更新动态链接器缓存
$ sudo ldconfig
# 3. 验证软链接
$ ls -l /usr/local/lib/libmystdio.so
lrwxrwxrwx 1 root root 74 May 15 12:06 /usr/local/lib/libmystdio.so -> /home/zyt/linux-journey-log/code_25_5_15/dynamiclib/stdc/lib/libmystdio.so
$ ldd a.out
        linux-vdso.so.1 (0x00007ffdfb1f9000)
        libmystdio.so => /usr/local/lib/libmystdio.so (0x0000701c1ab07000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x0000701c1a800000)
        /lib64/ld-linux-x86-64.so.2 (0x0000701c1ab1b000)

四、外部库(补充)

推荐一个好玩的图形库ncurses,使用指南:ncurse编程指南_ncurses教程-CSDN博客

bash 复制代码
// 安装
// Centos
$ sudo yum install -y ncurses-devel
// ubuntu
$ sudo apt install -y libncurses-dev

五、目标文件(原理部分)

编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们一般都是一键构建非常方便,但一旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这一系列操作。

接下来我们深入探讨一下编译和链接的整个过程,来更好地理解动静态库的使用原理。

先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。关键点:每个源文件独立编译,生成对应的目标文件。如果函数定义在其他文件中(如 hello.c 调用 code.c 中的 run()),编译器会暂时 标记未解析的符号(需链接阶段处理)。使用 -c 选项表示"只编译不链接"。

比如:在一个源文件hello.c里简单输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个原文件code.c中。这里我们就可以调用gcc -c来分别编译这两个原文件。

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

$ gcc -c hello.c
$ gcc -c code.c
$ ls
code.c  code.o  hello.c  hello.o

可以看到,在编译之后会生成两个扩展名为.o的文件,它们被称为目标文件。要注意的是如果修改了一个原文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件的格式是ELF,是对二进制代码的一种封装。

bash 复制代码
# file命令用于辨识文件类型
$ file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ file code.o
code.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

六、EIL文件

1. ELF文件的四种格式

类型 说明 示例
可重定位文件 包含代码和数据,需链接生成可执行文件或共享库 .o 文件(hello.o
可执行文件 可直接加载运行的程序,即可执行程序 ./a.out
共享目标文件 动态链接库,运行时加载 .so 文件(libc.so
核心转储文件 进程崩溃时的内存快照(如 segmentation fault 生成),存放当前进程上下文,用于dump信号触发。 core 文件

2. ELF文件的核心结构

ELF文件由以下四部分组成,可通过 readelf 工具查看。

(1)ELF Header(ELF头)

描述文件的主要特性。文件的类型(如可执行/共享库)、目标机器架构(如x86-64)、入口地址、节头表和程序头表的位置等。其位于文件的开始位置,它的主要目的是定位文件的其他部分。

bash 复制代码
$ readelf -h hello.o   # 查看目标文件的ELF头

关键字段:

Type: REL(可重定位文件)、EXEC(可执行文件)、DYN(共享库)。

Entry point address: 可执行文件的入口地址(如 _start)。

(2)Program Header Table(程序头表)

列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。作用:指导操作系统如何加载可执行文件或共享库(如哪些段需加载到内存、权限设置)。

注意:仅存在于可执行文件和共享库(可重定位文件如 .o 没有此表)。

cpp 复制代码
$ readelf -l a.out     # 查看可执行文件的程序头表(segment)

关键段(Segments):

LOAD: 需加载到内存的代码段(.text)、数据段(.data、.bss)。

INTERP: 动态链接器路径(如 /lib64/ld-linux-x86-64.so.2)。

(3)Section Header Table(节头表)

描述所有节(Sections)的信息(位置、大小、类型),供链接和调试使用。

bash 复制代码
$ readelf -S hello.o   # 查看目标文件的节头表

关键节(Sections):

.text : 机器指令(代码),是程序的主要执行部分。

.data : 已初始化的全局/静态变量。

.bss: 未初始化的全局/静态变量(在文件中不占空间,预留位置,加载时清零)。

.symtab: 符号表(函数/变量名及其地址的对应关系)。

.rel.text: 重定位信息(需链接器修正的代码地址)。

(4)Sections(节)

ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。

节与段的关系:

链接视角:使用节(如 .text、.data)。

执行视角:操作系统按段(如 LOAD)加载,一个段可能包含多个节(如将 .text 和 .rodata 合并到只读代码段)。稍后讲

七、ELF从加载到轮廓

1. ELF形成可执行文件

Step-1-编译:将 .c/.cpp 源代码文件编译成 可重定位目标文件。(预处理-编译-汇编)

Step-2-链接:将多个 .o 文件合并,生成 可执行文件(a.out) 或 共享库(.so)。

具体步骤

(1)符号解析(Symbol Resolution)

① 检查所有 .o 文件的 .symtab,确保每个符号(函数/变量)有且仅有一个定义。

② 如果某个符号未定义(如 printf),链接器会去 静态库(.a) 或 动态库(.so) 中查找。

(2)节(Section)合并

将多个 .o 文件的同名节合并:(也会涉及库的合并)

• 所有 .text → 合并到可执行文件的 .text

• 所有 .data → 合并到可执行文件的 .data

• 所有 .bss → 合并到可执行文件的 .bss

(3)重定位

① 修正代码和数据中的 地址引用(如 call printf 的真实地址)。

② 使用 .rel.text 和 .rel.data 表计算最终地址。

(4)生成可执行文件

最终生成 可执行 ELF 文件(a.out),包含:

• 程序头表(Program Header Table):告诉操作系统如何加载程序。

• 段(Segments):如 LOAD(代码段、数据段)、DYNAMIC(动态链接信息)。

2. ELF可执行文件加载

• 一个ELF会有多种不同的Section(节),在加载到内存的时候,也会进行Section合并,形成segment(段)。

• 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等。【某些 Section(如 .debug_info)仅用于调试,不参与运行,因此不会被映射到任何 Segment。】

权限 典型 Section 合并后的 Segment
R E(可读可执行) .text.plt.rodata LOAD 代码段
R W(可读可写) .data.bss.got LOAD 数据段
R(只读) .eh_frame.dynstr 可能合并到代码段

• 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起。

• 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了(链接器(ld)根据链接脚本的规则合并 Section 为 Segment。),具体合并原则被记录在了ELF的程序头表(Program header table)中。

bash 复制代码
$ readelf -S hello.o   # 查看可执行程序的section
There are 14 section headers, starting at offset 0x298:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000028  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  000001c0
       0000000000000048  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  00000068
       0000000000000000  0000000000000000  WA       0     0     1
  [ 4] .bss              NOBITS           0000000000000000  00000068
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  00000068
       000000000000000d  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  00000075
       000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000a1
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.pr[...] NOTE             0000000000000000  000000a8
       0000000000000020  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  000000c8
       0000000000000038  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  00000208
       0000000000000018  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000100
       00000000000000a8  0000000000000018          12     4     8
  [12] .strtab           STRTAB           0000000000000000  000001a8
       0000000000000017  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  00000220
       0000000000000074  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)

$ readelf -l a.out     # 查看section合并后的segment

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000628 0x0000000000000628  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x0000000000000199 0x0000000000000199  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x0000000000000124 0x0000000000000124  R      0x1000
  LOAD           0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
                 0x0000000000000258 0x0000000000000260  RW     0x1000
  DYNAMIC        0x0000000000002dc8 0x0000000000003dc8 0x0000000000003dc8
                 0x00000000000001f0 0x00000000000001f0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000030 0x0000000000000030  R      0x8
  NOTE           0x0000000000000368 0x0000000000000368 0x0000000000000368
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000030 0x0000000000000030  R      0x8
  GNU_EH_FRAME   0x000000000000201c 0x000000000000201c 0x000000000000201c
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002db8 0x0000000000003db8 0x0000000000003db8
                 0x0000000000000248 0x0000000000000248  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .interp 
   02     .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 
   03     .init .plt .plt.got .plt.sec .text .fini 
   04     .rodata .eh_frame_hdr .eh_frame 
   05     .init_array .fini_array .dynamic .got .data .bss 
   06     .dynamic 
   07     .note.gnu.property 
   08     .note.gnu.build-id .note.ABI-tag 
   09     .note.gnu.property 
   10     .eh_frame_hdr 
   11     
   12     .init_array .fini_array .dynamic .got 

为什么要将section合并成segment?

1. 减少内存碎片,提高页面对齐效率

内存按页(Page)管理:现代操作系统以固定大小的页(如 4KB)为单位管理内存。如果多个小 Section 分散加载,会导致内存浪费。

例子:

.text(代码段)占用 4097 字节 → 需要 2 页(4096 + 1)。

.rodata(只读数据)占用 512 字节 → 需要 1 页。

未合并时:共占用 3 页(实际使用 4097 + 512 = 4609 字节,利用率仅 37.5%)。

合并后:.text + .rodata 总大小 4609 字节 → 仅需 2 页(利用率提升至 56.3%)。

合并策略:将权限相同(如只读、可执行)的 Section 合并到一个 Segment,使它们在内存中连续存储,减少碎片。

2. 统一内存权限,简化操作系统加载

Section 的权限可能相同:

例如 :

.text(代码)、.rodata(只读数据)都是 R-X(可读、可执行)。

.data(全局数据)、.bss(未初始化数据)都是 RW-(可读、可写)。

操作系统按 Segment 设置权限:如果每个 Section 单独映射,操作系统需要为每个小段设置权限(频繁的系统调用,效率低)。

合并后:只需为整个 Segment 设置一次权限(例如一个 LOAD Segment 包含所有 R-X 的 Section)。

3. 提升程序加载速度

减少内存映射次数:

合并前:操作系统需为每个 Section 单独调用 mmap(或类似机制)。

合并后:只需为少数几个 Segment 调用 mmap,减少系统开销。

降低页表项(PTE)压力:

每个内存映射需要占用页表条目,合并后减少条目数量,节省内核资源。

4. 动态链接的优化

动态库(如 libc.so)的依赖项:动态链接器(如 ld-linux.so)需要快速定位程序中的 .dynamic、.got.plt 等关键 Section。

通过将这些 Section 合并到明确的 Segment(如 DYNAMIC),链接器能直接遍历 Program Header Table,而无需解析所有 Section。

对于程序头表和节头表又有什么用呢,其实ELF文件提供了2个不同的视图/视角来让我们理解这两个部分:

  • 链接视图(Linking view) - 对应节头表 Section header table

    • 文件结构的粒度更细,将文件按功能模块的差异进行划分。静态链接分析的时候一般关注的是链接视图,能够理解ELF文件中包含的各个部分的信息。

    • 为了空间布局上的效率,将来在链接目标文件时,链接器会把很多节(section)合并,规整成可执行的段(segment)、可读写的段、只读段等。合并了后,空间利用率就高了。否则,很小的一段,未来物理内存页浪费太大(物理内存页分配一般都是整数倍一块给你,比如4k)。所以,链接器趁着链接就把小块们都合并了。

  • 执行视图(Execution view) - 对应程序头表 Program header table

    • 告诉操作系统,如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有program header table。

说白了就是:一个在链接时作用,一个在运行加载时作用。

我们可以在ELF头中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。例如我们查看下hello.o这个可重定位文件的主要信息:

bash 复制代码
# 查看目标文件
$ readelf -h hello.o
ELF Header:
  Magic:    7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # ELF文件魔数标识
  Class:                             ELF64                    # 文件类型(64位)
  Data:                              2's complement, little endian  # 数据编码方式(小端序)
  Version:                           1 (current)              # ELF版本
  OS/ABI:                            UNIX - System V          # 操作系统ABI类型
  ABI Version:                       0                       # ABI版本
  Type:                              REL (Relocatable file)   # 文件类型(可重定位文件)
  Machine:                           Advanced Micro Devices X86-64  # 机器架构(x86-64)
  Version:                           0x1                     # 版本
  Entry point address:               0x0                     # 入口地址(目标文件为0)
  Start of program headers:          0 (bytes into file)      # 程序头表起始位置(目标文件无)
  Start of section headers:          728 (bytes into file)    # 节头表起始位置
  Flags:                             0x0                     # 处理器特定标志
  Size of this header:               64 (bytes)              # ELF头大小
  Size of program headers:           0 (bytes)               # 程序头表条目大小(目标文件无)
  Number of program headers:         0                       # 程序头表条目数(目标文件无)
  Size of section headers:           64 (bytes)              # 节头表条目大小
  Number of section headers:         13                      # 节头表条目数
  Section header string table index: 12                      # 节名称字符串表索引

# 查看可执行程序
$ gcc *.o
$ readelf -h a.out
ELF Header:
  Magic:    7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00  # ELF文件魔数标识
  Class:                             ELF64                    # 文件类型(64位)
  Data:                              2's complement, little endian  # 数据编码方式(小端序)
  Version:                           1 (current)              # ELF版本
  OS/ABI:                            UNIX - System V          # 操作系统ABI类型
  ABI Version:                       0                       # ABI版本
  Type:                              DYN (Shared object file) # 文件类型(动态共享对象)
  Machine:                           Advanced Micro Devices X86-64  # 机器架构(x86-64)
  Version:                           0x1                     # 版本
  Entry point address:               0x1060                  # 程序入口地址(_start)
  Start of program headers:          64 (bytes into file)     # 程序头表起始位置
  Start of section headers:          14768 (bytes into file)  # 节头表起始位置
  Flags:                             0x0                     # 处理器特定标志
  Size of this header:               64 (bytes)              # ELF头大小
  Size of program headers:           56 (bytes)              # 程序头表条目大小
  Number of program headers:         13                      # 程序头表条目数
  Size of section headers:           64 (bytes)              # 节头表条目大小
  Number of section headers:         31                      # 节头表条目数
  Section header string table index: 30                      # 节名称字符串表索引

八、理解链接与加载

1. 静态链接

• 无论是自己的.o还是静态库中的.o,本质都是把.o文件爱你进行链接的过程。

• 研究静态链接本质就是研究.o是如何链接的。

bash 复制代码
$ ls 
code.c  hello.c
$ gcc -c *.c
$ ls
code.c  code.o  hello.c  hello.o
$ gcc *.o -o main.exe
$ ll
total 40
drwxrwxr-x 2 zyt zyt  4096 May 17 15:57 ./
drwxrwxr-x 6 zyt zyt  4096 May 16 15:54 ../
-rw-rw-r-- 1 zyt zyt    62 May 16 15:55 code.c
-rw-rw-r-- 1 zyt zyt  1496 May 17 15:56 code.o
-rw-rw-r-- 1 zyt zyt   100 May 16 15:55 hello.c
-rw-rw-r-- 1 zyt zyt  1560 May 17 15:56 hello.o
-rwxrwxr-x 1 zyt zyt 16016 May 17 15:57 main.exe*

• objdump -d 命令:查看代码段(.text)的反汇编

我们发现call指令转跳的地址都被设置成了0,这是为什么?

其实在编译hello.c的时候,编译器是不知道printf和run函数的存在的,因此,编译器只能将这两个函数的转跳地址先暂时设为0。直到链接的时候,为了让连接器将来在链接时,能正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,将来链接时,就会根据表里记录的地址将其修正。细节如下:

(1)编译阶段

当编译器处理 hello.c 时,如果遇到外部函数(如 printf 或 run),它的处理流程如下:

• 编译器仅知道这些符号的名称(如 printf),但不知道它们的实际地址或代码内容。因为这些符号可能定义在其他文件(如 libc.so 或 code.o)中。

• 生成占位符:对于函数调用(如 call printf),编译器会生成一个临时地址 00 00 00 00;对于数据引用(如 lea 0x0(%rip),%rax),同样填充零偏移。

• 保留重定位信息:编译器在目标文件(.o)中生成 重定位表(Relocation Table),记录哪些指令需要后续修正。

(2)重定位表

目标文件中包含一个或多个重定位表(如 .rela.text),用于指导链接器如何修正占位符。

通过 readelf -r hello.o 可以查看:

条目介绍:

Offset:占位符在 .text 节中的位置(如 0x0f 对应 call 的操作码位置)。

Type:重定位类型(如 R_X86_64_PC32 表示 32 位相对地址调用)。

Sym. Value :显示了符号的值。

Sym. Name:需要解析的符号名(run)。

Addend:修正时的附加偏移(通常为 -4,因为 RIP 相对寻址会指向下一条指令)。

上图显示证明:在hello.o的.rela.text节中,puts(就是printf)和run的Sym. Value都是0000000000000000,这表示这些符号在目标文件中尚未解析,即它们的地址被初始化为0 。这表明这些符号在当前的目标文件中没有定义,需要在链接时从其他目标文件或库中解析,以确定它们的最终地址。

(3)静态链接阶段:地址修正

链接器(如 ld)在合并所有目标文件时,会完成以下操作:

• 符号解析(Symbol Resolution)

在全局符号表中查找 printf 和 run 的定义:printf 通常来自 libc.a(静态库)或 libc.so(动态库)。run 来自 code.o。若符号未定义,链接器报错(undefined reference)。

• 分配最终地址

合并所有 .text 节,并为函数分配运行时地址。

• 修正占位符

根据重定位表,修改代码中的零地址为实际地址。

查看最终程序的反汇编,就能显示函数运行时的地址了:

readelf -s main.exe 查看符号表

两个.o合并之后,在最终的程序中的符号表中,就找到了run;【0000000000001149】其实是地址,FUNC表示run符号类型是函数;【16】就是run函数所在的section被合并在最终的那一个section中了,16就是下标。

readelf -S main.exe :查看节区头表(Section Headers)

作用:显示文件的所有节区(Section)信息,描述各节区的布局和属性。

我们看到 code.o 和 hello.o 的 .text 合并后得到的是 main.exe 的第16个section。

(4)总结

所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这就是静态链接的过程。

链接过程中涉及到对.o中的外部符号进行地址重定位。

2. ELF加载和进程地址空间

(1)逻辑地址 / 虚拟地址

**•**一个ELF程序,在没有被加载到内存的时候,有没有地址呢?

有逻辑地址(虚拟地址布局),但无物理地址。当代计算机都采用"平坦模式":现代计算机采用虚拟内存机制,编译器在生成 ELF 文件时,会按虚拟地址空间对代码和数据预编址(如 .text 从 0x400000 开始)。下面是objdump -d main.exe 反汇编后的代码:

通过 objdump -d 或 readelf -S 看到的地址是逻辑地址 (起始地址+偏移量),表示该段代码/数据在进程虚拟空间中的预期位置。我们通常认为起始地址是0 。也就是说,其实**虚拟地址在程序还没有加载到内存的时候,就已经对可执行程序进行了统一编址。**这些地址在链接阶段由链接器分配,基于链接脚本(Linker Script)的规则。

**•**进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?

数据来源:ELF 文件的 Program Header Table(即 Segment 信息)。程序头表描述了ELF文件中各个段的属性,包括它们的类型、文件偏移、虚拟地址、物理地址、大小等信息。操作系统利用这些信息来初始化进程的内存布局,包括设置页表、分配内存区域等。这些结构确保了进程的虚拟地址空间能够正确映射到物理内存,从而允许进程执行。

• 磁盘上的可执行程序,代码和数据编址其实就是虚拟地址的统一编址!

① 磁盘上的ELF地址是虚拟地址:由链接器按进程虚拟地址空间统一分配,保存在文件中。

② OS加载时按此布局映射:将ELF中的虚拟地址映射到进程的虚拟内存,再通过页表转为物理地址。

③ 协作机制:

编译器:生成逻辑地址(目标文件)。

链接器:统一分配虚拟地址(ELF文件)。

操作系统:将虚拟地址映射到物理内存(运行时)。

(2)重新理解进程虚拟地址空间

ELF再被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中:

作用:入口地址是操作系统加载程序后,CPU开始执行的第一条指令的虚拟内存地址。该地址通常指向程序初始化代码(如_start或.text段的起始位置),由链接器在静态链接阶段确定。
代码加载时,内核读取ELF头的Entry point address,将程序加载到内存后,跳转到入口地址执行。
**•**页表映射时,内核将文件的.text段映射到物理内存,并建立页表项(虚拟→物理)。

操作系统加载程序时的行为:

当用户运行程序时(如 ./a.out),操作系统(Linux 内核/Windows Loader)会:

解析可执行文件头部:读取 Entry point address,确定代码的起始虚拟地址。

分配虚拟地址空间:为进程创建页表,映射代码段(.text)、数据段(.data)等。

③ 设置程序计数器(PC/IP):将 CPU 的指令指针寄存器(x86: RIP,ARM: PC)指向入口地址。

(3)静态链接库在内存加载

静态链接库在内存中的加载流程本质上是一个伪命题,因为其代码早已在编译期融入到了可执行文件之中。运行时,这类代码与其他自定义逻辑无异,均作为固定的部分参与程序的整体调度和执行。其具体特性如下:

静态链接库的特性

静态链接库的特点决定了它的加载行为不同于动态链接库。静态链接库在编译阶段即将库中的代码直接嵌入到目标文件中,这意味着最终生成的可执行文件本身已经包含了所有的库代码。

编译与链接阶段

在编译过程中,源代码被转换为目标文件(.o.obj),其中包含汇编指令和符号表。

**•**链接器负责解析未定义的符号,并将对应的实现从静态库中提取出来,将其实际代码复制到最终的可执行文件中。

此过程的关键在于,静态库中的代码并非以单独的形式存在于内存中,而是成为可执行文件的一部分。因此,静态链接库并没有传统意义上的"加载"概念,因为它已经在编译期间完成了集成。

运行时的行为

当程序启动时,操作系统会为可执行文件分配一段连续的虚拟地址空间。这段地址空间包括以下几个区域:

**•**代码区:存储程序的机器码,这部分内容来源于原始的源代码以及静态链接库中的代码片段。

**•**数据区:分为初始化的数据段(如全局变量)和未初始化的数据段(BSS 段)。

**•**堆栈区:用于动态分配内存和函数调用时的局部变量存储。

由于静态链接库的代码已经被完全嵌入到可执行文件中,因此在运行时不需要额外的操作系统介入来加载这些库代码。换句话说,静态链接库的代码已经是可执行文件的一部分,随同其他代码一起被映射到进程的地址空间。

内存占用分析

尽管静态链接库避免了运行时依赖问题,但它也带来了显著的空间开销。每当一个新的应用程序使用相同的静态库时,该库的全部代码会被再次复制到新的可执行文件中。这种机制可能导致多个程序在内存中有重复的库副本,增加了整体系统的内存消耗。

3. 动态链接与动态库加载

(1)动态库加载

① 虚拟内存映射:

**•**当进程A启动并需要加载动态库(如XXX.so)时,操作系统不会立即将整个库加载到物理内存

**•**而是通过mm_struct(内存描述符)在进程的虚拟地址空间中建立映射关系

② 共享区(Shared Area):

**•**动态库被映射到进程虚拟地址空间的"共享区"

**•**这个区域与进程的"数据区"和"代码区"是分开的

**•**多个进程可以共享同一个动态库的物理内存副本

③ 页表机制:

**•**操作系统通过页表将虚拟地址映射到物理内存

**•**对于动态库,这种映射是"按需"建立的 - 只有实际访问的部分才会被加载到物理内存

(2)进程间共享动态库

① 虚拟内存映射:

**•**进程A和进程B各自有独立的虚拟地址空间

**•**通过各自的mm_struct(内存描述符),两个进程都将XXX.so映射到自己的地址空间的"共享区"

**•**虽然虚拟地址可能不同,但最终指向相同的物理内存区域

② 物理内存共享:

**•**在物理内存中,XXX.so只有一份副本

**•**两个进程的页表条目都指向这同一块物理内存区域

**•**这是通过操作系统的内存管理实现的

(3)动态链接

① 动态链接概要

我们可以将需要共享的代码单独提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存,这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。

动态链接实际上是将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。

当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。

② 可执行程序被编译器动了手脚

在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到main函数。**实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。**在_start函数中,会执行一系列初始化操作,这些操作包括:

• 设置堆栈:为程序创建一个初始的堆栈环境。

• 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。

• 动态链接:这是关键的一步,_start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。动态链接器(如ld-linux.so)负责在程序运行时加载动态库。

动态连接器

**•**标红的 /lib64/ld-linux-x86-64.so.2 (0x00007f42c02b6000) 表示动态链接器(Dynamic Linker/Loader) 的路径和内存映射地址。

**•**内核首先加载动态链接器到内存(而非直接加载程序)。

**•**当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。

**•**Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置文件(如/etc/ld.so.conf及其子配置文件)来指定动态库的搜索路径。

**•**这些路径会被动态链接器在加载动态库时搜索。

**•**为了提高动态库的加载效率,Linux系统会维护一个名为/etc/ld.so.cache的缓存文件。

**•**该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件。

**•**库搜索顺序:

• 调用__libc_start_main:一旦动态链接完成,_start函数会调用__libc_start_main(这是glibc提供的一个函数)。__libc_start_main函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等。

• 调用main函数:最后,__libc_start_main函数会调用程序的main函数,此时程序的执行控制权才正式交给用户编写的代码。

• 处理main函数返回值:当main函数返回时,__libc_start_main会负责处理这个返回值,并最终调用_exit函数来终止程序。

③ 动态库中的相对地址

**•**动态库是采用相对编址(位置无关代码,PIC)的方案进行编址的,这种机制使得动态库可以被加载到进程地址空间的任意位置而无需重写代码。

**•**位置无关代码(PIC):是通过所有地址引用都使用相对偏移量,而非绝对地址实现的,使代码无论加载到内存哪个位置都能正确执行(而静态链接的程序使用固定绝对地址,加载位置固定)。

**•**平坦内存模式:采用统一编址,使整个地址空间是一个连续的线性空间。而在动态库视角,每个库都"认为"自己从地址0开始,实际通过偏移量计算真实地址。可执行程序(.exe)和动态库都要遵守"平坦模式",只不过.exe是直接加载的,动态库需要动态加载。

④ 程序与动态库映射

**•**动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的。

让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中。

⑤ 程序调用库函数

已知库的虚拟起始地址和函数偏移量的情况下,访问库中所有方法都需要:函数绝对地址 = 库虚拟起始地址 + 函数偏移量。并且,整个调用过程是从代码段转跳到共享区,调用完毕后返回代码区,整个过程都在进程地址空间中进行。

⑥ 全局偏移量表GOT

注意:也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道,然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位) , 等等,不是说代码区在进程中是只读的吗?怎么修改?能修改吗?

所以:动态链接采用的做法是在 .data(.data是可读写的,支持动态修改)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。代码段通过相对寻址访问GOT表项,动态链接器在加载时填充GOT表中的实际地址。

**•**GOT运行工作流程:

首次调用:

* 调用PLT跳转到动态链接器

* 解析符号得到真实地址并填充GOT表

* 跳转到真实函数地址

后续调用:

* 直接通过GOT表跳转(无解析开销)

GOT表不共享:由于GOT表项必须包含当前进程的绝对地址,并且不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。

**•**在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。

**•**这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。

备注:PLT是什么?

PLT(过程链接表)是动态链接的核心机制之一,主要用于延迟绑定 ,即在程序运行时按需解析动态库函数的真实地址,而不是在程序启动时就解析所有函数。
解决动态库函数调用问题 :动态库的函数地址在编译时是未知的(因为库可能被加载到任意地址),PLT 提供了一种间接跳转机制,使得程序可以先调用 PLT 条目,再由 PLT 负责跳转到正确的函数地址。优化后,采用延迟绑定,只有在函数第一次被调用时才会解析其真实地址,后续调用直接跳转,避免启动时解析所有符号的开销。

⑦ 动态库间依赖

• **库间依赖通过动态链接器递归加载:**动态库(.so)也可以依赖其他库,库间依赖通过动态链接器递归加载,保证所有库的 GOT 表被正确填充。

• **PIC + GOT/PLT 机制:**动态链接器递归加载,加载 libA.so 时,发现它依赖 libB.so,动态链接器会先加载 libB.so 并修正 libA.so 的 GOT 表。如果 libB.so 又依赖 libC.so,则继续递归加载。

• **ELF 格式统一:**所有库都是ELF格式,结构一致(都有 .got、.plt、.dynamic),确保动态链接器能统一处理。库的代码段(.text)仍然是位置无关(PIC),通过 %rip 相对寻址访问自己的 GOT 表。每个库的 GOT 表是独立的,动态链接器会分别填充。

解决依赖关系的时候,就是加载并完善互相之间的GOT表的过程。

总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效地利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。

4. 总结

• 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。

• 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。

• 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。

相关推荐
独行soc14 分钟前
2025年渗透测试面试题总结-百度面经(题目+回答)
运维·开发语言·经验分享·学习·面试·渗透测试·php
艾伦_耶格宇22 分钟前
【NGINX】 -10 keepalived + nginx + httpd 实现的双机热备+ 负载均衡
运维·nginx·负载均衡
海棠蚀omo24 分钟前
C++笔记-红黑树
开发语言·c++·笔记
选与握33 分钟前
ubuntu工控机固定设备usb串口号
linux·运维·ubuntu
Paraverse_徐志斌1 小时前
基于 Zookeeper 部署 Kafka 集群
ubuntu·zookeeper·kafka·消息队列
一个Potato1 小时前
C++笔试题(金山科技新未来训练营):
c++·科技
休息一下接着来1 小时前
C++ I/O多路复用
linux·开发语言·c++
龙湾开发1 小时前
计算机图形学编程(使用OpenGL和C++)(第2版)学习笔记 12.曲面细分
c++·笔记·学习·3d·图形渲染
舰长1151 小时前
ubuntu 安装mq
linux·运维·ubuntu
不是吧这都有重名1 小时前
利用systemd启动部署在服务器上的web应用
运维·服务器·前端