Linux入门:动静态库的理解与加载
目录
一.什么是库
二.静态库
一).静态库生成
二).静态库使用
三.动态库
一).动态库生成
二).动态库使用
三).库运行搜索路径
四.目标文件
五.ELF文件
一).初步理解
二).ELF形成可执行
三).ELF可执行文件加载
六.理解连接与加载
一).静态链接
二).ELF加载与进程地址空间
1.虚拟地址/逻辑地址
2.重新理解进程虚拟地址空间
三).动态链接与动态库加载
1.进程如何看待动态库
2.动态链接
3.编译器的小动作
4.动态库中的相对地址
5.程序怎么和库具体映射起来的
6.程序怎么进行库函数调用
7.全局偏移量表GOT(global offset table)
8.库间依赖
四).总结
一.什么是库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
- 静态库 .a[Linux]、.lib[windows]
- 动态库 .so[Linux]、.dll[windows]
二.静态库
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
- 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库。我们也可以使用gcc的 -static 强转设置链接静态库。
1.动静态库中,要不要包含main函数呢?
你敢带main()函数吗?如果你带了,将来用户用库函数时,用户会写main()函数的!一旦编译就出现了main()函数冲突,所以不要带main()函数。
一).静态库生成
所有的库(无论是动还是静),本质都是源文件对应的.o
静态库的本质就是.o打了一个包。
.a静态库,本质是一种归档文件,不需要使用者解包,用gcc/g++直接链接即可。
- ar 是 gnu 归档工具, rc 表示(replace and create)
- t: 列出静态库中的文件
- v:verbose 详细信息
二).静态库使用
// 任意目录下,新建// main.c,引⼊库头⽂件#include "hello.h"
#include "test.h"
#include <stdio.h>int main()
{int n=10,m=20;test();int sum=fun(n,m);printf("%d",sum);return 0;
}
为什么呢?因为gcc不知道在哪里。
那么我们就需要告诉gcc
- -L: 指定库路径
- -I: 指定头文件搜索路径
- -l: 指定库名
- 测试目标文件生成后,静态库删掉,程序照样可以运行
- 库文件名称和引入库的名称:去掉前缀 lib ,去掉后缀 .so , .a ,如: libc.so -> c
如果我们要连接任何非C/C++标准库(包括其他外部库或者我们自己写的)都需要指明-L -l
三.动态库
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
- 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
- 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接
- 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采勇虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间
一).动态库生成
- -shared: 表示生成共享库格式
- -fPIC:产生位置无关码(position independent code)
- 库名规则:libxxx.so
二).动态库使用
这里gcc高版本加了-fPIC默认选项
为什么会出现这个问题?系统不知道在哪里!!
三).库运行搜索路径
我告诉了谁,只告诉了gcc。系统不知道在哪里!gcc!=系统
解决方法:
方法1:拷贝 .so 文件到系统共享库路径下, 以般指 /usr/lib、/usr/local/lib、/lib64的库路径等。(不推荐)
方法2:向系统共享库路径下建立同名软连接
方法3:更改环境变量: LD_LIBRARY_PATH
OS运行程序,要查找动态库,也会在该环境变量下查找动态库(LD_LIBRARY_PATH),经常是空的
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:《your_lib_path》
方法4:ldconfig方案:配置 /etc/ld.so.conf.d/ *.cof ,ldconfig更新
系统默认的库文件路径下扫描对应的库之外,还去该路径/etc/ld.so.conf.d/下一个一个去读取配置文件内容,来找到对应的动态库。所以可以在这个目录下创建自己的配置文件。
四.目标文件
接下来我们深入探讨以下编译和链接的整个过程,来更好的理解动静态库的使用原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。
比如:在以个源文件 hello.c 里便简单输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个原文件 code.c 中。这里我们就可以调用 gcc -c 来分别编译这两个原文件。
// 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 ,是对二进制代码的一种封装。
五.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 ):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
- 节头表(Section header table) :包含对节(sections)的描述。
最常见的节:
- 代码节(.text):用于保存机器指令,是程序的主要执行部分。
- 数据节(.data):保存已初始化的全局变量和局部静态变量
二).ELF形成可执行
- step-1:将多份 C/C++ 源代码,翻译成为目标 .o 文件
- step-2:将多份 .o 文件 section进行合并
注意:实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究
三).ELF可执行文件加载
- 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
- 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等.
- 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起
- 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的 程序头表(Program header table) 中
查看可执行程序的section:readelf -S main
可以看到有31个section,并且每一个section的信息以类似于数组的方式存储起来。
查看section合并的segment:readelf -l main
可以看到程序里面有13个segement,在05下标对应的segment中,将 .data 和 .bss节合并到一个segment中,.data存储的是数据,如全局变量和局部静态变量,.bss中存储的是全局未初始化变量,都是属于同一个属性,所以合并到了一个segment中。上述每一个segment后面都有相关RWX的标志位,表示该segment的权限属性。
为什么要将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节⼀起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改
从 执行视图 来看:
- 告诉操作系统哪些模块可以被加载进内存。
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的
对于 ELF Header 这部分来说,我们只用知道其作用即可,它的主要目的是定位文件的其他部分
六.理解连接与加载
一).静态链接
- 无论是自己的.o, 还是静态库中的.o,本质都是把.o文件进行连接的过程
- 所以:研究静态链接,本质就是研究.o是如何链接的
通过反汇编查看编译后的.o目标文件
objdump -d 命令:将代码段(.text)进行反汇编查看
hello.o 中的 main 函数不认识 printf和run 函数
code.o 不认识 printf 函数
这里的e8表示函数调用的机器码,后面的0表示函数地址,这个函数调用对应着代码中printf的调用。
我们可以看到这里的call指令,它们分别对应之前调用的printf和run函数,但是你会发现他们的跳转地址都被设成了0,那这是为什么呢?
其实就是在编译 hello.c 的时候,编译器是完全不知道 printf 和 run 函数的存在的,比如他们位于内存的哪个区块,代码张什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表,这张表将来在链接的时候,就会根据表里记录的地址将其修正。
读取hello.o符号表
- puts:就是printf的实现, run就是我们自己的方法在hello.o中未定义(因为在code.o中)
- UND就是:undefine,表示未定义说白了就是本.o文件找不到
读取code.o符合表
- puts:就是printf的实现
- UND就是:undefine,表示未定义说白了就是本.o文件找不到
将这两个.o文件通过链接,链接成可执行文件main
读取hello.o符号表:readelf -s main
- 这里run前面的16表示在exe程序的第16节中,使用 readelf -S main 可以看到这个节是 .text节。这表示了它属于代码节,就是run函数所在的section被合并最终的那一个section中了,16就是下标。
- 0000000000001149:其实是地址
- FUNC:表示run符号类型是个函数
怎么证明上面的说法?
hello.o或者code.o call后面的00 00 00 00有没有被修改成为具体的最终函数地址呢?
objdump -d main,main中可以看到printf和run的地址被进行了修改
- 两个.o的代码段合并到了一起,并进行了统一的编址。
- 链接的时候,会修改.o中没有确定的函数地址,在合并完成之后,进行相关call地址,完成代码调用。
静态链接就是把库中的.o进行合并,链接其实就是将编译之后的所有目标文件连同用到的⼀些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
二).ELF加载与进程地址空间
1.虚拟地址/逻辑地址
问题:
- 一个ELF程序,在没有被加载到内存的时候,有没有地址呢?
答案:
- 一个ELF程序,在没有被加载到内存的时候,就有地址,当代计算机工作的时候,都采用"平坦模式"进行工作。所以也要求ELF对自己的代码和数据进行统一编址。
平坦模式:就是可执行程序中的所以segment都是从0开始依次递增的统一编制
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了。
问题:
- 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?
答案:
- 从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外在用详细地址,填充页表。
所以:虚拟地址机制,不光光OS要支持,编译器也要支持。
2.重新理解进程虚拟地址空间
ELF被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry point address字段当中。
所以一个可执行程序在Linux系统中的加载过程如下:
步骤1: 在 bash 进程中,通过命令行的形式输入可执行程序的文件名,识别到是一个可执行程序之后,在 bash 进程中打开了可执行程序的文件,并且创建可执行程序的进程。
步骤2:在这个进程中通过 dentry 结构体找到文件的 inode,找到 inode 之后,加载文件的属性,并且通过 inode 和 data blocks 的映射关系,找到数据和代码,将其放入物理内存中。
步骤3:然后初始化 mm_struct 和 vm_area_struct 结构体的[start, end],并且填充页表。
步骤4:将程序的入口地址加载到 cpu当中,此时 cpu 进行调度然后运行该进程。cpu中有个EIP字段,里面存放的是当前指令的下一条指令的地址,这样就可以通过入口地址依次运行整个程序了。
注意:在计算机中,磁盘上的逻辑地址就是进程中的虚拟地址,cpu使用的也是虚拟地址
三).动态链接与动态库加载
1.进程如何看待动态库
如果一个可执行程序的进程要调用动态库,首先需要先将动态库的代码和数据加载到物理内存当中,其次通过页表将动态库的物理地址映射到进程的虚拟地址空间的共享区上,然后在通过代码区的代码调用动态库中函数的地址,进行地址跳转调用动态库中的库函数。
那进程间如何共享库的。
如果两个进程同时使用一个动态库,就是将一份动态库的物理地址通过两个页表分别映射到两个进程的虚拟地址空间的共享区中。所以在多个进程使用同一个动态库的时候,动态库只需要加载一份在内存中,减少了内存的浪费。
2.动态链接
ldd命令打印程序或者库文件所依赖的共享库列表
这里的 libc.so是C语言的运行时库,里面提供了常用的标准输入输出文件字符串处理等等这些功能。
那为什么编译器默认不使用静态链接呢?静态链接会将编译产生的所有目标文件,连同用到的各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以运行。照理来说应该更加方便才对是吧?
静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件就有可能都包含了相同的功能和代码,显然会浪费大量的硬盘空间。
动态链接到底是如何工作的??
动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。
当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了。
3.编译器的小动作
在C/C++程序中,当程序开始执行时,它首先并不会直接跳转到 main 函数。实际上,程序的入口点是 _start ,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数
在 _start 函数中,会执行一系列初始化操作,这些操作包括:
1.设置堆栈:为程序创建一个初始的堆栈环境
2.初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段
3.动态链接:这是关键的一步, _start 函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址
动态链接器:
- 动态链接器(如ld-linux.so)负责在程序运行时加载动态库
- 当程序启动时,动态链接器会解析程序中的动态库依赖,并加载这些库到内存中
环境变量和配置⽂件:
- Linux系统通过环境变量(如LD_LIBRARY_PATH)和配置⽂件(如/etc/ld.so.conf及其⼦配置文件)来指定动态库的搜索路径
- 这些路径会被动态链接器在加载动态库时搜索
缓存文件:
- 为了提高动态库的加载效率,Linux系统会维护⼀个名为/etc/ld.so.cache的缓存文件
- 该文件包含了系统中所有已知动态库的路径和相关信息,动态链接器在加载动态库时会首先搜索这个缓存文件
4.调用 _libc_start_main :一旦动态链接完成, _start 函数会调用_libc_start_main (这是glibc提供的⼀个函数)。 _libc_start_main 函数负责执行一些额外的初始化工作,比如设置信号处理函数、初始化线程库(如果使用了线程)等
5.⽤ main 函数:最后, _libc_start_main 函数会调用程序的 main 函数,此时程序的执行控制权才正式交给用户编写的代码
6.处理 main 函数的返回值:当 main 函数返回时, _libc_start_main 会负责处理这个返回值,并最终调用 _exit 函数来终止程序
程序员通常只需要关注 main 函数中的代码,而不需要关心底层的初始化过程。然而,了解这些底层细节有助于更好地理解程序的执行流程和调试问题。
4.动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意位置,对动态库中的方法,统一编址,采用相对编址的方案进行编制的(其实可执行程序也一样,都要遵守平坦模式,只不过exe是直接加载的)。
在动态库中的编址还是起始地址(0)+ 偏移量,由于起始地址为0,所以偏移量就是动态库的编址。映射到进程的虚拟地址空间时,在进程的虚拟地址空间中,动态库的库函数地址就是动态库在进程虚拟地址空间中的起始地址,加上动态库的库函数的偏移量,这被称为相对编址
5.程序怎么和库具体映射起来的
注意:
- 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
- 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中
首先进程打开动态库,通过动态库 struct_file 结构体找到 dentry 然后找到动态库的 inode,将动态库的属性,数据和代码加载到内存中,然后为动态库开辟一个 vm_area_struct 并初始化,最后再将动态库的物理地址通过页表映射到进程的虚拟地址空间中
6.程序怎么进行库函数调用
注意:
- 库已经被我们映射到了当前进程的地址空间中
- 库的虚拟起始地址我们也已经知道了
- 库中每一个方法的偏移量地址我们也知道
- 所以:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
- 整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的.
7.全局偏移量表GOT(global offset table)
注意:
- 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
- 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(这个叫做加载地址重定位)
动态链接采用的做法是在 .data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。因为.data区域是可读写的,所以可以支持动态进行修改。
- 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
- 在单个.so下,由于GOT表与 .text 的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。
- 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
- 这种方式实现的动态链接就被叫做 PIC 地址无关代码 。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT
8.库间依赖
注意:
- 不仅仅有可执行程序调用库
- 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??
- 库中也有.GOT,和可执行一样!这也就是为什么大家为什么都是ELF的格式!
由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化
- 由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。
解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程.
四).总结
- 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
- 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成以个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
- 动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)