AI编程助手
AI免费问答

深入了解linux系统—— 库的链接和加载

雪夜   2025-08-15 14:06   943浏览 原创

一、目标文件

我们知道源文件经过编译链接形成可执行程序,在

Windows
下这两个步骤被
IDEA
封装的很完美,我们使用起来也非常方便;

Linux
中,我们可以通过
gcc
编译器来完成编译链接这一系列操作。

而源文件经过编译形成

.o
文件,而库文件是由
.o
文件形成的;那
.o
文件是什么呢?

.o
文件被称为目标文件,也被称为可重定位目标文件;

目标文件是一个二进制文件,其格式是

ELF

二、ELF文件

而我们还知道,我们源文件经过编译形成

.o
文件,然后再经过链接(将所有的
.o
文件合并,链接库文件)才能形成可执行文件。

那在链接的过程中做了什么呢?我们的

.o
文件为什么能够和库文件进行链接呢?

目标文件的文件格式是

ELF
、库文件的文件格式也是
ELF
、可执行文件的文件格式也是
ELF

深入了解linux系统—— 库的链接和加载

可以看到

.o
目标文件、共享目标文件、可执行文件的文件格式都是
ELF

ELF文件组成

.o
、库文件
.so
(静态库
.a
.o
的归档文件)以及可执行文件都是
ELF
格式文件,那
ELF
文件都包含什么呢?

一般

ELF
文件都包含以下几部分:

ELF
头(
ELF Header
)程序头表(
program Header Table
)节(
Section
)节头表(
Section Header Table
深入了解linux系统—— 库的链接和加载

那这些部分都包含哪些内容呢?

Linux
系统中,我们可以通过指令
readelf
来查看
ELF
格式文件的这几个部分的内容。

节(

Section

节是

ELF
文件中的基本组成单位,包含了特定类型的数据;
ELF
文件的各种信息和数据都存储在不同的节中。

例如

.text
.data
.bss
等。

节头表(

Section Header Table

ELF
文件中存在非常多的节,那如何区分这些节呢?

在节头表

Section Header Table
中,就记录了每一个节的描述信息。(比如
Name
Type
Address

我们可以使用

readelf -S
来查看一个ELF文件的节头表:

深入了解linux系统—— 库的链接和加载

这里内容比较多,只截取了一部分;

在这里面,我们可以看到存在

.text
代码、
.data
数据、
rodata
只读数据、
.bss
等。

程序头表

program Header Table

在程序头表中,记录了所有有效段和它们的属性。

在表中记录着每一段的开始位置和位移

offset
、长度;以及这些段如何去合并

我们可以使用

readelf -l
查看ELF文件的程序头表

深入了解linux系统—— 库的链接和加载

ELF Header

ELF Header
中,记录了文件的主要特性,程序的入口地址、节的个数,大小等等。

readelf -h
可以查看一个ELF文件的
ELF Header

深入了解linux系统—— 库的链接和加载
可执行程序的形成

了解了ELF文件的组成,感觉还是云里雾里的;(这里了解ELF文件中有哪几个部分组成,每一个部分大概内容即可)

.o
文件,库文件
.so
以及可执行文件都是ELF文件,那我们的可执行程序(文件)如何形成的呢?

这个就比较简单了,因为我们的

.o
文件的格式都是ELF,所以我们所有的
.o
文件形成可执行时,只需要将所有相同的数据节进行合并形成一个大的数据节;也就是形成一个大的ELF格式的文件。

深入了解linux系统—— 库的链接和加载

这里简单了解一下,在后续链接和加载内容中详细说明。

ELF可执行文件的加载

我们知道一个ELF文件中存在非常多的

Section
,在加载到内存时,也会进行
Section
的合并,形成
segment

合并规则:相同属性,可读/可写/可执行等等。

这样不同的

Section
在加载到内存时,就会合并成
segment

而合并方式在形成ELF时就已经确定了,在ELF的程序头表

Paogram Header Table
中我们可以查看。

深入了解linux系统—— 库的链接和加载

可以看到我们的

.text
代码段和
.rodata
只读数据段是被合并到一个
segment
的;

.got
.data
.bss
段这些可读可写的数据是合并到一个
segment
的。

而我们ELF文件中存在那么多节,如果我们不进行合并就发发现在内存中有非常多的块
4
KB空间中都存在浪费的空间,而合并节形成
segment
就是为了减小页面碎片,提供内存使用率。还用一点就是:将相同权限的节进行合并,这样具有相同属性的节就会形成一个大的
segment
;这样就可以更好的进行内存管理和权限访问控制。三、链接和加载静态链接

首先我们要知道,静态链接本质上就是将我们所有的

.o
文件和静态库文件进行合并,形成可执行;(静态库就是
.o
文件的归档,所以:静态链接本质上就和将所有的
.o
链接起来)

.o
文件是如何链接的呢?

现在有存在

code.c
fun.c
两份源文件,简单代码:

代码语言:javascript代码运行次数:0运行复制
<pre class="brush:php;toolbar:false">//code.c#include <stdio.h>void fun();int main(){    fun();printf("code: \n");return 0;}//fun.c#include <stdio.h>void fun(){printf("fun: \n");}

我们知道编译形成的

.o
以及最终形成的可执行程序都是二进制文件,我们可以使用
objdump
对这些二进制文件进行反汇编。

objdump -d
对代码部分进行反汇编。

深入了解linux系统—— 库的链接和加载

我们可以发现,在

code.c
fun.c
各自经过编译后形成的
.o
文件,经过反汇编,我们发现,在
code.s
中调用
fun
函数时,
cal
调用的函数地址是
0
,调用
printf
函数的地址也是
0
;在
fun.s
call
调用
printf
的地址也是
0

我们再来对可执行程序进行返汇编查看一下:

深入了解linux系统—— 库的链接和加载

可以看到,在可执行程序反汇编形成的文件中,

callq
调用函数时 就有了函数的地址。

readelf -S
可以查看ELF文件的符号表

深入了解linux系统—— 库的链接和加载

在链接时,对照符号表,根据表里记录的地址来修正函数的地址。

深入了解linux系统—— 库的链接和加载

所以,链接的本质上就是编译之后的所有目标文件连同用到的一些静态库运行时库组合,形成一个独立的可执行文件。

当所有模块组合在一起之后,链接器就会根据我们的

.o
文件或者静态库中的重定位表找到那些被重定位的函数,从而修改它们的地址。

我们链接的过程中就会涉及到对目标文件

.o
的地址修正(地址重定位);所以
.o
目标文件也被称为可重定位目标文件。

加载ELF和进程地址空间

这里有一个问题:进程地址空间

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

我们对

.o
文件、可执行文件进行反汇编可以发现,一个ELF文件,在还没有被加载到内存时,在其内部就存在地址;

深入了解linux系统—— 库的链接和加载

上图中最左侧就是ELF文件中的地址;严格意义上将,这种地址应该叫做逻辑地址:起始位置+偏移量。

而当我们把起始位置当做

0
,此时就成了虚拟地址;也就是说,在我们的程序还没加载到内存时,就已经把可执行程序进行统一编址了。

所以,我们进程在创建时,虚拟地址空间

mm_struct
vm_arae_sruct
的初始化数据从哪里来?就显而易见了;

从ELF中的

segment
中来,而每一个
segment
都有自己的起始位置和长度,就用来初始化内核数据结构
vm_area_struct
[start , end]
等数据,以及初始化页表。

所以虚拟地址,不仅操作系统要支持,而编译器也要支持。(因为程序在还没有加载到内存时,就已经进行统一编址了)

进程地址空间

所以,在程序运行时,该可执行程序要加载到内存;而进程的进程地址空间

mm_struct
中的虚拟地址从可执行程序中来,而可执行程序的代码和数据要加载到内存;操作系统就要为这些代码和数据开辟空间,然后填充页表。

这样进程才能被

CPU
调度,那
CPU
是如何知道进程的起始地址呢?

还记得在ELF格式的

ELF Header
中,存在一个
Entry point address
入口地址,所以在
CPU
调度进程时,CPU中的
EIP
寄存器,就会记录下一条要运行指令的地址;而在
CPU
在还存在
CR3
寄存器,它指向当前进程的页表。

所以在进程被调度时,就会把

Entry point address
入口地址拷贝到
CPU
中的
EIP
寄存器中,然后再修改CPU中其他信息(如
CR3
等);这样
CPU
就知道进程的起始地址;而且还知道当前进程的页表,根据进程地址空间中的虚拟地址,查找页表就可以找到当前进程代码和数据的物理地址。

深入了解linux系统—— 库的链接和加载
动态库

动态链接,简单来说就是将可执行程序和库产生关联,然后在程序运行时再加载动态库;

这也是我们在动态链接我们自己的库,生成可执行,在运行时还需要让系统找到我们的库的原因。

程序在运行时,才会加载动态库,那进程如何看到我们的动态库呢?

了解了一个进程如何找到我们的动态库;我们还知道动态库也被称为共享目标文件,也就是说我们的库可以被多个进程共享的。

所以在我们进程去找自己依赖的库时,如果当前库已经被加载到内存了,当前进程就可以根据库文件的

struct file
找到库文件的
struct path
,再根据
path
找到
struct dentry
然后就可以找到库文件的
inode
这样就可以找到库文件。

这样讲库映射到进程的地址空间中,这样多个进程就共享同一个库了;而在内存中库文件就只存在一份

深入了解linux系统—— 库的链接和加载
动态链接

我们知道静态链接就是将静态库合并到我们的可执行程序中,这样静态链接形成的可执行不依赖库,就可以执行;按理来说应该比的链接更加方便。

但是,当我们静态库文件特别大,我们如果使用静态链接,这样形成的可执行都包含一份静态库代码;而当程序运行起来时,在内存中就势必会存在多份源文件代码。

再看动态链接,只是将可执行文件和动态库文件产生关联,在程序运行时才进行链接,可执行文件中不存在库代码;而且在内存中,多个进程可以共享一个动态库,在内存中也不会出现多份库代码。

那动态链接如何实现的呢?

可执行程序被编译器处理过

C/C++
程序开始执行时,它并不是直接执行
main
函数;

实际是程序的入口不是

main
,而是
_start

深入了解linux系统—— 库的链接和加载

可以看到可执行程序的入口地址并不是

main
、而是
_start
Linux
下);
_start
函数是C运行时库(
glibc
)或者链接器(
id
)提供的特殊函数。

_start
函数它做了什么呢?

设置堆栈:为程序创建堆栈环境。初始化数据段:将程序的数据段(全局变量/静态变量)从初始化数据段拷贝到相应的内存位置,清零未初始化的数据段。动态链接:(关键)
_start
函数会调用动态链接器的代码来解析和加载程序所以来的动态库;动态链接器会处理所有符号解析和重定位,确保程序中的函数调用和变量访问能正确映射到动态库中的实际位置。调用
_libc_start_main
:链接完成之后,
_start
函数就会调用
libc_start_main
函数(
glibc
提供的);
lib_start_main
函数负责执行额外的初始化工作(例如设置信号处理函数,初始化线程库等);调用
main
函数:
lib_start_main
调用程序的
main
函数,此时程序的执行才到了用户编写的代码;处理
main
返回值:
main
返回时,
lib_start_main
就会处理这个返回值,然后调用
_exit
终止程序。

动态链接器:负责在程序运行时加载动态库

深入了解linux系统—— 库的链接和加载

可以看到这里程序都依赖

ld-linux-x86-64.so.2
这一个库。也就是动态链接器库

在程序启动时,动态链接器就会解析程序中的动态库依赖,并将这些库加载到内存中;
Linux
系统通过环境变量(
LD_LIBRARY_PATH
)/配置文件(
etc/ld.so.conf
)来指定动态库的搜索路径;这些路径会被动态链接器在加载动态库时进行搜索。

缓存文件:

为了提高动态库的加载效率,
Linux
系统会维护一个
/etc/ld.so.cache
的缓冲文件;该文件保存了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会优先搜索这个缓冲文件。库函数调用

说了这么多,那动态库该如何链接和加载的呢?我们的程序又是如何调用库函数呢?

那我们就清楚了,库映射到进程地址空间后,我们只需要知道库的起始虚拟地址和要调用方法的偏移量就可以找到库中的方法。

那也就是说,我们在动态链接的过程中,我们只需要记录下来调用库函数依赖的库名称,和该库函数中动态库中的偏移量地址即可。

深入了解linux系统—— 库的链接和加载
全局偏移量表
GOT
在动态链接的过程中,在程序中就下来了库函数所依赖的库名称,以及该函数在动态库中的偏移量地址。这样在程序运行时,要进行动态库的加载(如果库在内存中已经存在,就直接映射),这样在程序的地址空间中就存在了动态库的映射;那也就知道了库的起始虚拟地址;这样,我们再对加载到内存中的程序的库函数调用处,修改动态库的地址;在内存中完成二次地址重定位。

但是,我们知道代码区是只读的;那如何修改呢?代码区是不能修改的。

深入了解linux系统—— 库的链接和加载

所以,在动态链接的时候,在可执行程序中就存在了全局偏移量表

GOT
;在动态链接时在表中就存放了调用库函数的库名称和函数的 偏移量地址。

这样执行加载时,库加载映射到进程的进程地址空间中,然后修改

GOT
表中的库的虚拟地址即可。

深入了解linux系统—— 库的链接和加载
在一个
.so
动态库中,
GOT
表和
.text
的相对位置都是固定的,就可以使用
CPU
的相对寻址来查找
GOT
表;在调用库函数时,就会先查
GOT
表,根据表中的地址进行跳转,跳转到要调用函数的位置。(这里表中的地址在库加载就会被修改成真实的地址)。

这种方式实现动态链接被称为 地址无关码; 简单来说就是:我们动态库不需要做任何修改,被加载到任意内存地址处都能正常运行,且能够被所有进程共享。 这也就是在制作动态库,生成

.o
文件要带
-fPIC
选项的原因。

什么是
plc
深入了解linux系统—— 库的链接和加载

我们通过查看汇编代码可以发现,在进行库函数调用时,存在一个

plc
<puts@plc>
)。

这里

plc
指的是什么呢?

plc
简单来说就是延迟绑定

我们知道动态链接在程序加载时需要进程大量函数地址的重定位(修改大量的函数地址),显然是非常耗费时间的;并且有很多函数我们并没有调用。

为了进一步降低消耗,操作系统就会做优化:延迟绑定也就是

plc

最后在这里补充一点:库函数也会调用其他库函数,也就是库之间也存在依赖。

到这里本篇文件内容就大致结束了

简单总结:

ELF文件:
ELF Header
program Header Table
Section Header Table
Section
静态链接:将所有目标文件和静态库文件进行合并,进程地址重定位。进程地址空间:在ELF格式文件中存在逻辑地址(起始位置+偏移量),起始位置为
0
,偏移量地址也就是虚拟地址;在进程创建时进程地址空间
mm_struct
vm_area_struct
中初始化数据就来源于可执行文件中的地址。动态链接:链接时在可执行文件中记录库函数所依赖的库和偏移量地址(GOT表),在加载时根据动态库在进程地址空间的映射位置进行地址重定位;这样无论库加载到内存的什么地方,都要映射到进程地址空间中,这样在执行函数时通过查找GOT表的方式进行调用。
plc
延迟绑定:在第一次进行调用函数时,
GOT
表中指向辅助代码
/stup
,去查找函数真正的跳转地址,并更新
GOT
表;再次调用函数时,就直接跳转到函数的真正地址。
声明:本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn核实处理。