前言
本篇博客我们来认识下库方面的知识
💓 个人主页:zkf
⏩ 文章专栏:Linux
若有问题 评论区见📝
🎉欢迎大家点赞👍收藏⭐文章


目录
[7.3.4全局偏移量表GOT(global offset table)](#7.3.4全局偏移量表GOT(global offset table))
1.什么是库
库是写好的现有的,成熟的,可以复⽤的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个⼈的代码都从零开始,因此库的存在意义⾮同寻常。
本质上来说库是⼀种可执⾏代码的⼆进制形式,可以被操作系统载⼊内存执⾏。库有两种:
静态库 .a[Linux]、.lib[windows]
动态库 .so[Linux]、.dll[windows]
2.静态库
静态库(.a):程序在编译链接的时候把库的代码链接到可执⾏⽂件中,程序运⾏的时候将不再
需要静态库。
⼀个可执⾏程序可能⽤到许多的库,这些库运⾏有的是静态库,有的是动态库,⽽我们的编译默
认为动态链接库,只有在该库下找不到动态.so的时候才会采⽤同名静态库。我们也可以使⽤ gcc
的 -static 强转设置链接静态库。
2.1静态库的生成
// Makefile
libmystdio.a:my_stdio.o my_string.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" ar 是 gnu 归档⼯具, rc 表⽰ (replace and create) ar -tv libmystdio.a
rw-rw-r-- 1000 / 1000 2848 Oct 29 14 : 35 2024 my_stdio.o
rw-rw-r-- 1000 / 1000 1272 Oct 29 14 : 35 2024 my_string.o
t: 列出静态库中的⽂件
v:verbose 详细信息
2.2静态库的使用
// 任意⽬录下,新建
// main.c ,引⼊库头⽂件include "my_stdio.h"
include "my_string.h"
include <stdio.h>
int main ()
{
const char *s = "abcdefg" ;
printf ( "%s: %d\n" , s, my_strlen(s));
mFILE *fp = mfopen( "./log.txt" , "a" );
if (fp == NULL ) return 1 ;
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfwrite(s, my_strlen(s), fp);
mfclose(fp);
return 0 ;
}
// 场景 1 :头⽂件和库⽂件安装到系统路径下
gcc main.c -lmystdio *//* *场景* *2* *:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下* gcc main.c -L. -lmymath
// 场景 3 :头⽂件和库⽂件有⾃⼰的独⽴路径
$ gcc main.c -I 头⽂件路径 -L 库⽂件路径 -lmymath
-L: 指定库路径
-I: 指定头⽂件搜索路径
-l: 指定库名
测试⽬标⽂件⽣成后,静态库删掉,程序照样可以运⾏
关于 -static 选项,稍后介绍
库⽂件名称和引⼊库的名称:去掉前缀 lib ,去掉后缀 .so , .a ,如: libc.so -> c
3.动态库
动态库(.so):程序在运⾏的时候才去链接动态库的代码,多个程序共享使⽤库的代码。
⼀个与动态库链接的可执⾏⽂件仅仅包含它⽤到的函数⼊⼝地址的⼀个表,⽽不是外部函数所在⽬
标⽂件的整个机器码
在可执⾏⽂件开始运⾏以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,
这个过程称为动态链接(dynamic linking)
动态库可以在多个程序间共享,所以动态链接使得可执⾏⽂件更⼩,节省了磁盘空间。操作系统采
⽤虚拟内存机制允许物理内存中的⼀份动态库被要⽤到该库的所有进程共⽤,节省了内存和磁盘空
间。
3.1动态库⽣成
// Makefile
libmystdio.so:my_stdio.o my_string.o
gcc -o @ ^ -shared
%.o:%.c
gcc -fPIC -c $<
.PHONY:clean
clean:
@rm -rf *.so *.o stdc*
@echo "clean ... done"
.PHONY:output
output:
@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"
shared: 表⽰⽣成共享库格式
fPIC:产⽣位置⽆关码(position independent code)
库名规则:libxxx.so
3.2动态库的使用
// 场景 1 :头⽂件和库⽂件安装到系统路径下
gcc main.c -lmystdio *//* *场景* *2* *:头⽂件和库⽂件和我们⾃⼰的源⽂件在同⼀个路径下* gcc main.c -L. -lmymath // 从左到右搜索 -L 指定的⽬录
// 场景 3 :头⽂件和库⽂件有⾃⼰的独⽴路径
gcc main.c -I 头⽂件路径 -L 库⽂件路径 -lmymath ldd libmystdio.so // 查看库或者可执⾏程序的依赖
linux-vdso.so .1 => ( 0x00007fffacbbf000 )
libc.so .6 => /lib64/libc.so .6 ( 0x00007f8917335000 )
/lib64/ld-linux-x86 -64. so .2 ( 0x00007f8917905000 )
3.3库运⾏搜索路径
问题
$ ldd a.out
linux-vdso.so .1 => ( 0x00007fff4d396000 )
libmystdio.so => not found
libc.so .6 => /lib64/libc.so .6 ( 0x00007fa2aef30000 )
/lib64/ld-linux-x86 -64. so .2 ( 0x00007fa2af2fe000 )
为什么此刻mystdio动态库找不到
解决方法
拷⻉ .so ⽂件到系统共享库路径下, ⼀般指 /usr/lib 、 /usr/local/lib 、 /lib64 或者开
篇指明的库路径等
向系统共享库路径下建⽴同名软连接
更改环境变量: LD_LIBRARY_PATH
4.目标文件
编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们⼀般都是⼀键构建⾮常⽅便, 但⼀旦遇到错误的时候呢,尤其是链接相关的错误,很多⼈就束⼿⽆策了。在Linux下,我们之前也了解过如何通过gcc编译器来完成这⼀系列操作。
接下来我们深⼊探讨⼀下编译和链接的整个过程,来更好的理解动静态库的使⽤原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运⾏的机器
代码。
⽐如:在⼀个源⽂件 hello.c ⾥便简单输出"hello world!",并且调⽤⼀个run函数,⽽这个函数被
定义在另⼀个原⽂件 code.c 中。这⾥我们就可以调⽤ gcc -c 来分别编译这两个原⽂件。
在编译之后会⽣成两个扩展名为 .o 的⽂件,它们被称作⽬标⽂件。要注意的是如果我们
修改了⼀个原⽂件,那么只需要单独编译它这⼀个,⽽不需要浪费时间重新编译整个⼯程。⽬标⽂件是⼀个⼆进制的⽂件,⽂件的格式是 ELF ,是对⼆进制代码的⼀种封装。
5.ELF文件
要理解编译链链接的细节,我们不得不了解⼀下ELF⽂件。其实有以下四种⽂件其实都是ELF⽂件:
可重定位⽂件( Relocatable File ) :即 xxx.o ⽂件。包含适合于与其他⽬标⽂件链接来创
建可执⾏⽂件或者共享⽬标⽂件的代码和数据。
可执⾏⽂件( Executable File ) :即可执⾏程序。
共享目标⽂件( Shared Object File ) :即 xxx.so⽂件。
内核转储 (core dumps) ,存放当前进程的执⾏上下⽂,⽤于dump信号触发。
⼀个ELF⽂件由以下四部分组成:
ELF 头 (ELF header) :描述⽂件的主要特性。其位于⽂件的开始位置,它的主要⽬的是定位⽂
件的其他部分。
程序头表 (Program header table) :列举了所有有效的段(segments)和他们的属性。表⾥
记着每个段的开始的位置和位移(offset)、⻓度,毕竟这些段,都是紧密的放在⼆进制⽂件中,
需要段表的描述信息,才能把他们每个段分割开。
节头表 (Section header table) :包含对节(sections)的描述。
节( Section ):ELF⽂件中的基本组成单位,包含了特定类型的数据。ELF⽂件的各种信息和
数据都存储在不同的节中,如代码节存储了可执⾏代码,数据节存储了全局变量和静态数据等。
最常⻅的节:
代码节(.text):⽤于保存机器指令,是程序的主要执⾏部分。
数据节(.data):保存已初始化的全局变量和局部静态变量。
6.ELF从形成到加载轮廓
6.1ELF形成可执行文件
step-1:将多份 C/C++ 源代码,翻译成为⽬标 .o ⽂件 + 动静态库(ELF)
step-2:将多份 .o ⽂件section进⾏合并
📌 注意:
实际合并是在链接时进⾏的,但是并不是这么简单的合并,也会涉及对库合并,此处不做
过多追究
6.2ELF可执⾏⽂件加载
⼀个ELF会有多种不同的Section,在加载到内存的时候,也会进⾏Section合并,形成segment
合并原则:相同属性,⽐如:可读,可写,可执⾏,需要加载时申请空间等.
这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到⼀起
很显然,这个合并⼯作也已经在形成 ELF 的时候,合并⽅式已经确定了,具体合并原则被记录在
了 ELF 的 程序头表 (Program header table) 中
📌 为什么要将section合并成为segment
Section合并的主要原因是为了减少⻚⾯碎⽚,提⾼内存使⽤效率。如果不进⾏合并,
假设⻚⾯⼤⼩为4096字节(内存块基本⼤⼩,加载,管理的基本单位),如果.text部分
为4097字节,.init部分为512字节,那么它们将占⽤3个⻚⾯,⽽合并后,它们只需2个
⻚⾯。
此外,操作系统在加载程序时,会将具有相同属性的section合并成⼀个⼤的
segment,这样就可以实现不同的访问权限,从⽽优化内存管理和权限访问控制。
对于 程序头表 和 节头表 ⼜有什么⽤呢,其实 ELF ⽂件提供 2 个不同的视图/视⻆来让我们理解这
两个部分:
链接视图 (Linking view) - 对应节头表 Section header table
⽂件结构的粒度更细,将⽂件按功能模块的差异进⾏划分,静态链接分析的时候⼀般关注的
是链接视图,能够理解 ELF ⽂件中包含的各个部分的信息。
为了空间布局上的效率,将来在链接⽬标⽂件时,链接器会把很多节(section)合并,规整
成可执⾏的段(segment)、可读写的段、只读段等。合并了后,空间利⽤率就⾼了,否
则,很⼩的很⼩的⼀段,未来物理内存⻚浪费太⼤(物理内存⻚分配⼀般都是整数倍⼀块给
你,⽐如4k),所以,链接器趁着链接就把⼩块们都合并了。
执⾏视图 (execution view) - 对应程序头表 Program header table
告诉操作系统,如何加载可执⾏⽂件,完成进程内存的初始化。⼀个可执⾏程序的格式中,
⼀定有 program header table 。
说⽩了就是:⼀个在链接时作⽤,⼀个在运⾏加载时作⽤。
从 链接视图 来看:
命令 readelf -S hello.o 可以帮助查看ELF⽂件的 节头表。
.text 节 :是保存了程序代码指令的代码节。
.data 节 :保存了初始化的全局变量和局部静态变量等数据。
.rodata 节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所
以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata
节。
.BSS 节 :为未初始化的全局变量和局部静态变量预留位置
.symtab 节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
.got.plt 节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供
了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。对于GOT的理解,我们后
⾯会说。
使⽤ readelf 命令查看 .so ⽂件可以看到该节。
从 执⾏视图 来看:
告诉操作系统哪些模块可以被加载进内存。
加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的
对于 ELF HEADER 这部分来说,我们只⽤知道其作⽤即可,它的主要⽬的是定位⽂件的其他部分。
7.理解连接与加载
7.1静态链接
⽆论是⾃⼰的 .o , 还是静态库中的 .o ,本质都是把.o⽂件进⾏连接的过程
所以:研究静态链接,本质就是研究 .o 是如何链接的
静态链接就是把库中的.o进⾏合并,和上述过程⼀样
所以链接其实就是将编译之后的所有⽬标⽂件连同⽤到的⼀些静态库运⾏时库组合,拼装成⼀个独⽴的可执⾏⽂件。其中就包括我们之前提到的地址修正,当所有模块组合在⼀起之后,链接器会根据我们的.o⽂件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从⽽修正它们的地址。这其实就是静态链接的过程。
所以,链接过程中会涉及到对.o中外部符号进⾏地址重定位。
7.2ELF加载与进程地址空间
7.2.1虚拟地址/逻辑地址
⼀个ELF程序,在没有被加载到内存的时候,有没有地址呢?
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?
答案:
⼀个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机⼯作的时候,都采⽤"平坦
模式"进⾏⼯作。所以也要求ELF对⾃⼰的代码和数据进⾏统⼀编址,下⾯是 objdump -S 反汇编
之后的代码
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们
认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执
⾏程序进⾏统⼀编址了.
进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来的?从ELF各个
segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end]
等范围数据,另外在⽤详细地址,填充⻚表.
所以:虚拟地址机制,不光光OS要⽀持,编译器也要⽀持.
7.2.2重新理解进程虚拟地址空间
ELF 在被编译好之后,会把⾃⼰未来程序的⼊⼝地址记录在ELF header的Entry字段中:
7.3动态链接与动态库加载
7.3.1进程如何看到动态库

7.3.2进程间如何共享库的

7.3.3动态链接
动态链接其实远⽐静态链接要常⽤得多。⽐如我们查看下 hello 这个可执⾏程序依赖的动态库,会发现它就⽤到了⼀个c动态链接库:
ldd main.exe
linux-vdso.so.1 => (0x00007ffefd43f000)
libc.so.6 => /lib64/libc.so.6 (0x00007f533380b000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5333bd9000)
这⾥的 libc.so 是C语⾔的运⾏时库,⾥⾯提供了常⽤的标准输⼊输出⽂件字符串处理等等这些功
能。
那为什么编译器默认不使⽤静态链接呢?静态链接会将编译产⽣的所有⽬标⽂件,连同⽤到的各种
库,合并形成⼀个独⽴的可执⾏⽂件,它不需要额外的依赖就可以运⾏。照理来说应该更加⽅便才对是吧?
静态链接最⼤的问题在于⽣成的⽂件体积⼤,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费⼤量的硬盘空间。
这个时候,动态链接的优势就体现出来了,我们可以将需要共享的代码单独提取出来,保存成⼀个独⽴的动态链接库,等到程序运⾏的时候再将它们加载到内存,这样不但可以节省空间,因为同⼀个模块在内存中只需要保留⼀份副本,可以被不同的进程所共享。
动态链接到底是如何⼯作的??
⾸先要交代⼀个结论,动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使⽤情况为它们动态分配⼀段内存。 当动态库被加载到内存以后,⼀旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
我们的可执⾏程序被编译器动了⼿脚
在C/C++程序中,当程序开始执⾏时,它⾸先并不会直接跳转到 main 函数。实际上,程序的⼊⼝点是 _start ,这是⼀个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。
在 _start 函数中,会执⾏⼀系列初始化操作,这些操作包括:
- 设置堆栈:为程序创建⼀个初始的堆栈环境。
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位
置,并清零未初始化的数据段。 - 动态链接:这是关键的⼀步, _start 函数会调⽤动态链接器的代码来解析和加载程序所依赖的
动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调
⽤和变量访问能够正确地映射到动态库中的实际地址。
动态链接器:
动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。
当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中。
环境变量和配置⽂件:
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 函数来终⽌程序。
上述过程描述了C/C++程序在 main 函数之前执⾏的⼀系列操作,但这些操作对于⼤多数程序员来说是透明的。程序员通常只需要关注 main 函数中的代码,⽽不需要关⼼底层的初始化过程。然⽽,了解这些底层细节有助于更好地理解程序的执⾏流程和调试问题
动态库为了随时进⾏加载,为了⽀持并映射到任意进程的任意位置,对动态库中的⽅法,统⼀编址, 采⽤相对编址的⽅案进⾏编制的(其实可执⾏程序也⼀样,都要遵守平坦模式,只不过exe是直接加载的)。
动态库也是⼀个⽂件,要访问也是要被先加载,要加载也是要被打开的
让我们的进程找到动态库的本质:也是⽂件操作,不过我们访问库函数,通过虚拟地址进
⾏跳转访问的,所以需要把动态库映射到进程的地址空间中

库已经被我们映射到了当前进程的地址空间中
库的虚拟起始地址我们也已经知道了
库中每⼀个⽅法的偏移量地址我们也知道
所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅
法
⽽且:整个调⽤过程,是从代码区跳转到共享区,调⽤完毕在返回到代码区,整个过程完
全在进程地址空间中进⾏的.

7.3.4全局偏移量表GOT(global offset table)
注意:
也就是说,我们的程序运⾏之前,先把所有库加载并映射,所有库的起始虚拟地址都应该
提前知道
然后对我们加载到内存中的程序的库函数调⽤进⾏地址修改,在内存中⼆次完成地址设置
(这个叫做加载地址重定位)
等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?
所以:动态链接采⽤的做法是在 .data (可执⾏程序或者库⾃⼰)中专⻔预留⼀⽚区域⽤来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每⼀项都是本运⾏模块要引⽤的⼀个全局变量或函数的地址。
因为.data区域是可读写的,所以可以⽀持动态进⾏修改
- 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独⽴的GOT表,所以进程间不能共享GOT表。
- 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利⽤CPU的相对寻址来找到GOT表。
- 在调⽤函数的时候会⾸先查表,然后根据表中的地址来进⾏跳转,这些地址在动态库加载的时候会被修改为真正的地址。
- 这种⽅式实现的动态链接就被叫做 PIC 地址⽆关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运⾏,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
8.总结
静态链接的出现,提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发
⾃⼰的模块。通过静态链接,⽣成最终的可执⾏⽂件。
我们知道静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件,
其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
⽽动态链接实际上将链接的整个过程推迟到了程序加载的时候。⽐如我们去运⾏⼀个程序,操作系
统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址
都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进⾏调⽤(运⾏重定位,也叫做动态地址重定位)。
结束语
动静态库相关知识总结完毕
感谢观看!!