注:本文为 “Linux 库” 相关文章合辑。
图片清晰度受引文原图所限。
略作重排,未整理去重。
如有内容异常,请看原文。
动态库(Dynamic Link Library,DLL)是一种在运行时加载的共享库,其中包含可被不同程序调用的代码和数据。与静态库不同,动态库在编译时不会被链接到可执行文件中,而是在程序运行时由操作系统或运行时环境加载并链接到内存中。这种加载方式带来了诸多优势,例如节省内存空间、共享代码段、支持动态更新、以及实现插件机制等。
动态库的优势
- 节省内存:多个进程可以共享同一份动态库的代码和数据,避免了代码冗余,从而节省了内存空间。
- 共享代码:动态库允许多个程序共享代码,提高代码的复用率,减少开发工作量。
- 动态更新:可以独立更新动态库,而无需重新编译和链接依赖于该动态库的程序。这简化了软件维护和升级过程。
- 插件机制:动态库可以作为插件使用,允许程序在运行时加载和卸载不同的功能模块,从而扩展程序的功能。
- 延迟加载:动态库可以按需加载,只有在程序真正需要使用库中的代码时才加载,提高了程序的启动速度。
动态库的加载过程
动态库的加载是一个复杂的过程,涉及多个步骤,主要包括以下几个阶段:
- 库的查找(Locating the Library)
当程序需要加载一个动态库时,它首先会在指定的路径或系统默认的库搜索路径中查找该库文件。库搜索路径的设置因操作系统而异。常见的搜索顺序包括:- 应用程序所在目录
- 系统目录 (Windows) 或标准库目录 (Linux)
- 环境变量(例如
PATH
在 Windows 上,LD_LIBRARY_PATH
在 Linux 上)中指定的路径 - 操作系统定义的其他特定路径
- 加载库(Loading the Library)
找到库文件后,操作系统或运行时环境会将动态库加载到进程的地址空间中。这意味着库的代码和数据段被映射到进程的虚拟内存空间,从而可以被进程访问。操作系统负责管理内存的分配和保护。 - 符号解析(Symbol Resolution)
动态库中的代码可能调用了其他动态库中的函数,或者需要访问全局变量等。因此,在加载动态库时,需要解析其中使用的符号(函数名、变量名等),将其与对应的内存地址关联起来。这个过程通常由动态链接器(Dynamic Linker)完成。- 延迟绑定(Lazy Binding):为了提高程序启动速度,符号解析可以采用延迟绑定的方式。也就是说,只有在程序第一次调用某个动态库中的函数时,才解析该函数的地址。
- 地址重定位(Address Relocation)
动态库被加载到进程的地址空间后,其代码和数据的虚拟地址可能与实际的物理地址不匹配。这是因为动态库的编译地址是相对于其自身的,而加载地址则取决于进程的地址空间分配。因此,需要进行地址重定位,将动态库中的绝对地址修改为相对于加载地址的偏移量,使其能够正确访问代码和数据。 - 初始化(Initialization)
当库被加载到进程中后,会执行一些初始化代码来设置全局变量、分配资源、注册回调函数等。这些初始化步骤确保库在使用之前处于可用状态。动态库通常会提供一个特殊的入口点(例如DllMain
在 Windows 上),操作系统会在加载库后自动调用该函数。 - 导出函数(Exporting Functions)
加载动态库后,其中的函数就可以被其他程序或者库调用了。这些函数通常由动态库提供给外部使用,并通过一定的接口进行调用。动态库需要明确声明哪些函数是导出的,以便其他程序可以访问它们。 - 卸载(Unloading the Library)
在程序运行过程中,如果动态库不再被使用,操作系统或者运行时环境可以将其从进程的地址空间中卸载,释放相应的资源和内存。卸载时,动态库通常会执行一些清理操作,例如释放资源、注销回调函数等。
补充说明
- 动态链接器 (Dynamic Linker):动态链接器是操作系统中负责加载和链接动态库的关键组件。在 Linux 系统中,动态链接器通常是
ld-linux.so
,在 Windows 系统中则是kernel32.dll
和ntdll.dll
。 - ABI 兼容性 (Application Binary Interface Compatibility):使用动态库时需要注意 ABI 兼容性问题。ABI 定义了数据类型、函数调用约定、内存布局等底层细节。如果动态库和使用它的程序之间的 ABI 不兼容,可能会导致程序崩溃或出现其他错误。
静态库加载原理
静态库(Static Library)是包含预编译代码的归档文件,它们在编译时被链接到可执行文件中,成为可执行文件的一部分。与动态库不同,静态库的代码在运行时不会被单独加载,而是已经包含在最终的可执行程序中。
静态库的特点
- 编译时链接:静态库的代码在编译器的链接阶段被复制到可执行文件中。
- 独立性:链接完成后,可执行文件不再依赖于静态库本身。 即使删除或修改静态库,也不会影响可执行文件的运行。
- 体积增大:因为静态库的代码被复制到每个使用它的可执行文件中,所以会导致可执行文件的体积增大。
- 更新不便:如果静态库的代码更新了,需要重新编译和链接所有使用它的可执行文件才能生效。
静态库的加载/链接过程
静态库并没有运行时加载的概念,所以严格来说"静态库的加载原理"其实是指 静态链接的过程。 这个过程发生在编译时,主要步骤如下:
- 编译阶段:源代码(.c, .cpp 等)经过编译器编译生成目标文件(Object File,.o 或 .obj)。目标文件包含机器码、数据、符号表等信息。
- 链接阶段:链接器(Linker)负责将多个目标文件和静态库文件链接成一个可执行文件。
- 链接器处理静态库:链接器在处理静态库时,会从静态库中提取被程序引用的目标文件(.o 或 .obj)。
- 符号解析和重定位
- 符号解析 (Symbol Resolution):链接器检查目标文件和静态库中的符号表,将程序中使用的符号引用(例如函数名、变量名)与它们在目标文件或静态库中的定义关联起来。
- 重定位 (Relocation):因为每个目标文件和静态库都是单独编译的,它们的代码和数据在内存中的地址都是相对的。链接器需要修改这些地址,将它们重定位到可执行文件中最终的地址。
- 生成可执行文件:链接器将所有目标文件和从静态库中提取的目标文件合并,并进行重定位,最终生成一个可执行文件。 这个可执行文件包含了程序的所有代码和数据,可以独立运行。
优点与缺点
- 优点
- 部署简单:生成的可执行文件包含了所有依赖的代码,无需额外的库文件支持,部署起来非常简单。
- 性能略好:由于所有代码都已链接到可执行文件中,运行时无需进行动态链接,理论上可以获得略微的性能提升。(但现代操作系统的动态链接器优化已经很好,实际差别不大。)
- 缺点
- 体积较大:每个可执行文件都包含一份静态库的代码,导致程序体积增大。
- 更新困难:静态库更新后,所有使用它的可执行文件都需要重新编译和链接。
- 内存占用:如果多个程序使用同一个静态库,那么每个程序都会在内存中拥有一份该静态库的代码,造成内存浪费。
总结
动态库的加载是一个涉及多个步骤的复杂过程。通过动态加载库,程序可以更加灵活地使用共享的代码和资源,提高了代码的复用性和系统的效率。理解动态库的加载原理对于编写高效、可维护的软件至关重要。
静态库在编译时链接到可执行文件中,具有部署简单、性能略好的优点,但也存在体积较大、更新困难的缺点。静态链接的过程包括编译、链接、符号解析、重定位和生成可执行文件等步骤。在选择使用静态库还是动态库时,需要根据项目的具体需求进行权衡。
Linux 下的静态链接与动态链接
posted @ 2021-07-20 23:01 Mr-xxx
一、什么是库
1. 概念
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:静态库(.a、.lib)和动态库(.so、.dll),所谓静态、动态是指链接。
2. 将一个程序编译成可执行程序的步骤
3. 静态链接方式和动态链接方式
4. 静态库
4.1 概念
之所以称为静态库,是因为在链接阶段,会将汇编生成的目标文件.o 与引用到的库一起链接打包到可执行文件 (.out) 中。因此对应的链接方式称为静态链接。试想一下,静态库与汇编生成的目标文件一起链接为可执行文件,那么静态库必定跟.o 文件格式相似。其实一个静态库可以简单看成是一组目标文件(.o 文件)的集合,即很多目标文件经过压缩打包后形成的一个文件。
静态库特点总结:
- 静态库对函数库的链接是放在编译时期完成的。
- 程序在运行时与函数库再无瓜葛,移植方便。
- 浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件
下面编写一些简单的四则运算 C++ 类,将其编译成静态库给他人用,头文件如下所示:
1 class StaticMath
2 {
3 public:
4 StaticMath (void);
5 ~StaticMath (void);
6
7 static double add (double a, double b);// 加
8 static double sub (double a, double b);// 减
9 static double mul (double a, double b);// 乘
10 static double div (double a, double b);// 除
11
12 };
Linux 下使用 ar 工具,将目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索。一般创建静态库的步骤如图所示:
4.2 Linux 下创建与使用静态库
4.2.1 Linux 静态库命名规则
Linux 静态库命名规范,必须 * 是”lib [your_library_name].a”:lib 为前缀,中间是静态库名,扩展名为 .a
创建静态库(.a)
通过上面的流程可以知道,Linux 创建静态库过程如下:
- (1)首先,将代码文件编译成目标文件.o(StaticMath.o)
1 // 这是 StaticMath.cpp 文件
2 #include<iostream>
3 #include"myhead.h"
4 using namespace std;
5 double StaticMath::add (double a,double b)
6 {
7 return a+b;
8 }
9 double StaticMath::sub (double a,double b)
10 {
11 return a-b;
12 }
13 double StaticMath::mul (double a,double b)
14 {
15 return a*b;
16 }
17 double StaticMath::div (double a,double b)
18 {
19 return a/b ;
20 }
1 g++ -c StaticMath.cpp
注意带参数 - c,将其编译为.o 文件,否则直接编译为可执行文件
- (2)然后,通过 ar 工具将目标文件打包成 .a 静态库文件,最好编写 makefile 文件(CMake 等等工程管理工具)来生成静态库,输入多个命令太麻烦了。格式为:ar rcs + 静态库的名字 (libMytest.a) + 生成的所有的.o
1 ar -jcv -f libstaticmath.a StaticMath.o
- (3) 使用静态库
编写使用上面创建的静态库的测试代码:
1 #include "StaticMath.h"
2 #include <iostream>
3 using namespace std;
4
5 int main (int argc, char* argv [])
6 {
7 double a = 10;
8 double b = 2;
9
10 cout << "a + b =" << StaticMath::add (a, b) << endl;
11 cout << "a - b =" << StaticMath::sub (a, b) << endl;
12 cout << "a * b =" << StaticMath::mul (a, b) << endl;
13 cout << "a /b =" << StaticMath::div (a, b) << endl;
14
15 return 0;
16 }
Linux 下使用静态库,只需要在编译的时候,指定静态库的搜索路径(-L 选项)、指定静态库名(不需要 lib 前缀和.a 后缀,-l 选项)。
1 g++ TestStaticLibrary.cpp -L../StaticLibrary -lstaticmath
-L:表示要连接的库所在目录
-l (小写 L):指定链接时需要的动态库,编译器查找动态连接库时有隐含的命名规则,即在给出的名字前面加上 lib,后面加上.a 或.so 来确定库的名称。
4.3 gcc 参数简介
这里只写了几个关键的,其它的可以在这里 GCC 参数详 解查找。
-E:只执行到预处理阶段,不生成任何文件
-S:将 C 代码转换为汇编代码 (.s 汇编文件)
-c:仅执行编译操作,不进行连接操作 (.o 机器码)
-o:指定生成的输出文件(.out 可执行文件)
-L:告诉 gcc 去哪里找库文件。 gcc 默认会在程序当前目录、/lib、/usr/lib 和 /usr/local/lib 下找对应的库
-l:用来指定具体的静态库、动态库是哪个
-I: 告诉 gcc 去哪里找头文件
5. 动态库
5.1 为什么还需要动态库?
为什么需要动态库,其实也是静态库的特点导致。
- 空间浪费是静态库的一个问题。
- 另一个问题是静态库对程序的更新、部署和发布页会带来麻烦。如果静态库 liba.lib 更新了,所以使用它的应用程序都需要重新编译、发布给用户(对于玩家来说,可能是一个很小的改动,却导致整个程序重新下载,全量更新)
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可。
5.2 动态库特点
- 动态库把对一些库函数的链接载入推迟到程序运行的时期。
- 可以实现进程之间的资源共享。(因此动态库也称为共享库)
- 将一些程序升级变得简单。甚至可以真正做到链接载入完全由程序员在程序代码中控制(显式调用)。
5.3 Linux 下创建与使用动态库
5.3.1 Linux 动态库的命名规则
动态链接库的名字形式为 libxxx.so,前缀是 lib,后缀名为 “.so”,ib + 名字 + .so
5.3.2 创建动态库(.so)
编写四则运算动态库代码
1 //myhead.h 文件
2 class DP
3 {
4 public:
5 static void print_111 ();
6 static void print_222 ();
7 static void print_333 ();
8 };
9
10 //print_333 文件(print_111 和 print_222 文件与 print_333 文件类似)
11 #include<iostream>
12 #include"myhead.h"
13 using namespace std;
14 void DP::print_333 ()
15 {
16 cout << "333333333333333" << endl ;
17 }
首先,生成目标文件,此时要加编译器选项 - fpic
g++ -fPIC -c print_*.cpp
-fPIC 创建与地址无关的编译程序(pic,position independent code),是为了能够在多个应用程序间共享。
然后,生成动态库,此时要加链接器选项 -share
1 g++ -shared -o libtest.so print_*.o
-shared 指定生成动态链接库。
其实上面两个步骤可以合并为一个命令:
1 g++ -fPIC -shared -o libtest.so print_*.cpp
生成 libtest.so 动态库
5.3.3 使用动态库
下面我会对提供的内容进行校对与格式化处理。
1. ldd 命令部分
ldd
命令用来查看可执行程序所依赖的 so 动态链接库文件。若显示 not found
提示,意味着未找到该库文件,程序运行时会报错,可手动添加该库文件来解决。
[root@localhost ld.so.conf.d]# ldd /usr/local/tengine/sbin/nginx
linux-vdso.so.1 => (0x00007ffc9fd66000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007ff1c5f56000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007ff1c5d52000)
libcrypt.so.1 => /lib64/libcrypt.so.1 (0x00007ff1c5b1a000)
libjemalloc.so.2 => /usr/jemalloc/lib/libjemalloc.so.2 (0x00007ff1c58cb000)
libc.so.6 => /lib64/libc.so.6 (0x00007ff1c550a000)
/lib64/ld-linux-x86-64.so.2 (0x00007ff1c6187000)
libfreebl3.so => /lib64/libfreebl3.so (0x00007ff1c5306000)
2. 使用动态库的测试代码部分
以下是 testDP.cpp
文件的代码,此代码调用了动态库中的函数:
// testDP.cpp 文件
#include "myhead.h"
#include <iostream>
using namespace std;
int main (void)
{
DP::print_111 ();
DP::print_222 ();
DP::print_333 ();
return 0;
}
需注意,在实际使用时,要确保 myhead.h
头文件存在,并且动态库已正确编译与链接。
引用动态库编译成可执行文件(跟静态库方式一样):
1 g++ testDP.cpp -L./ -ltest
然后运行:./a.out,报错如下:
这是由于程序运行时没有找到动态链接库造成的。程序编译时链接动态链接库和运行时使用动态链接库的概念是不同的,在运行时,系统能够知道其所依赖的库的名字,但是还需要知道绝对路径。有几种办法可以解决此种问题:
(1) 因为系统会按照 LD_LIBRARY_PATH 环境变量来查找除了默认路径之外( /lib, /usr/lib, /usr/local/lib)的共享库(动态链接库)的其他路径,就像 PATH 变量一样!所以我们可以修改该环境变量来解决这个问题。
1 export LD_LIBRARY_PATH=/home/hp/LinuxC:$LD_LIBRARY_PATH
也就是将我们发布的共动态库所在的路径加入系统的查找选项中。动态库的加载顺序为 LD_PRELOAD>LD_LIBRARY_PATH>/etc/ld.so.cache>/lib>/usr/lib。
(2)使用 LD_PRELOAD,如果使用了这个变量,系统会优先去这个路径下寻找,如果找到了就返回,不在往下找了
1 LD_PRELOAD=./libtest.so ./a.out
(3)将动态链接库赋值一份到默认路径
1 sudo cp libtest.so/usr/lib
(4)因为系统中的配置文件 /etc/ld.so.conf 是动态链接库的搜索路径配置文件,在程序运行时会去读取该文件,那么我们就将我们自己编写的库的路径写到该配置文件中去即可。
在最后一行加入 /home/liushengxi/C-/ 自建库 /test,运行 ldconfig ,该命令会重建 /etc/ld.so.cache 文件
二、可执行程序的链接、装载
《程序员的自我修养 - 链接装载与库》是一本值得推荐的书,主要介绍系统软件的运行机制和原理,涉及在 Windows 和 Linux 两个系统平台上,一个应用程序在编译、链接和运行时刻所发生的各种事项,包括:代码指令是如何保存的,库文件如何与应用程序代码静态链接,应用程序如何被装载到内存中并开始运行,动态链接如何实现,C/C++ 运行库的工作原理,以及操作系统提供的系统服务是如何被调用的。
2.1 基础知识
许多 IDE 和编译器将编译和链接的过程合并在一起,称为构建(Build),使用起来非常方便。但只有深入理解其中的机制,才能看清许多问题的本质,正确解决问题。
一般的编译过程可以分解为 4 个步骤,预处理,编译,汇编和链接:
- 预编译:处理源代码中的以”#” 开始的预编译指令,如”#include”、”#define” 等。
- 编译:把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后产生相应的汇编代码文件,是程序构建的核心部分,也是最复杂的部分之一。
- 汇编:将汇编代码根据指令对照表转变成机器可以执行的指令,一个汇编语句一般对应一条机器指令。
- 链接:将多个目标文件综合起来形成一个可执行文件。
而对于第 2 步,编译由编译器完成器,编译器是将高级语言翻译成机器语言的一个工具,其具体步骤包括:
- 词法分析:将源代码程序输入扫描器,将源代码字符序列分割成一系列记号(Token)。
- 语法分析:对产生的记号使用上下文无关语法进行语法分析,产生语法树。
- 语义分析:进行静态语义分析,通常包括声明和类型的匹配,类型的转换。
- 中间语言生成:使用源代码优化器将语法树转换成中间代码并进行源码级的优化。
- 目标代码生成:使用代码生成器将中间代码转成依赖于具体机器的目标机器代码。
- 目标代码优化:使用目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用位移替代乘法、删除多余指令等。
如果一个源代码文件中有变量或函数等符号定义在其他模块,那么编译后得到的目标代码中,该符号的地址并没有确定下来,因为编译器不知道到哪里去找这些符号,事实上这些变量和函数的最终地址要在链接的时候才能确定。现代的编译器只是将一个源代码编译成一个未链接的目标文件,最终由链接器将这些目标文件链接起来形成可执行文件。
先以 helloworld.c 程序为例,搞清楚可执行文件是如何生成的:
1 #include <stdio.h>
2 int main (void)
3 {
4 printf ("hello, world!\n");
5 return 0;
6 }
(1)预处理,处理代码中的宏定义和 include 文件,并做语法检查
1 gcc -E helloworld.c -o helloworld.cpp
(2)编译,生成汇编代码
1 gcc -S helloworld.cpp -o helloworld.s
(3)汇编,生成汇编代码
1 gcc -c helloworld.s -o helloworld.o
(4)链接,生成可执行文件
1 gcc helloworld.o -o helloworld
具体过程可以用下面的图片表示,各种文件格式之间的关系如下:
2.2 ELF 文件格式
编译器编译源代码后生成的文件称为目标文件,事实上,目标文件是按照可执行文件的格式存储的,二者结构只是稍有不同。Linux 下的目标文件和可执行文件可以看成一种类型的文件,统称为 ELF 文件,一般有以下几类:
- 可重定位文件,如:.o 文件,包含代码和数据,可以被链接成可执行文件或共享目标文件,静态链接库属于这类。
- 可执行文件,如:/bin/bash 文件,包含可直接执行的程序,没有扩展名。
- 共享目标文件,如:.so 文件,包含代码和数据,可以跟其他可重定位文件和共享目标文件链接产生新的目标文件,也可以跟可执行文件结合作为进程映像的一部分。
ELF 文件由 ELF header 和文件数据组成,文件数据包括:
- Program header table, 程序头:描述段信息
- .text, 代码段:保存编译后得到的指令数据
- .data, 数据段:保存已经初始化的全局静态变量和局部静态变量
- Section header table, 节头表:链接与重定位需要的数据
常见的 ELF 段及其描述:
除了 .text
, .data
, .bss
等基本段之外,ELF 文件可能包含许多其他段,用于存储各种信息。以下是一些常见的可选段及其用途:
.comment
:包含编译器版本等注释信息。.debug
:包含调试信息(如 DWARF 格式)。.dynamic
:包含动态链接所需的信息(用于可执行文件或共享库)。.hash
/.gnu.hash
:符号哈希表,用于动态链接时快速查找符号。.line
:行号表,将源代码行号映射到编译后的指令地址,用于调试。.note
:包含附加的注释或供应商特定信息。.strtab
:字符串表(String Table),存储符号名称和其他字符串。.symtab
:符号表(Symbol Table),包含程序中定义和引用的符号信息。.shstrtab
:段名称字符串表(Section Header String Table),存储各个段的名称。.plt
:过程链接表(Procedure Linkage Table),用于延迟绑定的动态函数调用。.got
:全局偏移表(Global Offset Table),存储全局变量和函数(尤其是在动态链接时)的地址。.init
:程序初始化代码段,在main
函数执行前运行。.fini
:程序终止代码段,在main
函数结束后运行。
一个 ELF 文件的段头表示例 (类似 readelf -S
的输出):
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .group GROUP 00000000 000034 000008 04 12 16 4
[ 2] .text PROGBITS 00000000 00003c 000078 00 AX 0 0 1
[ 3] .rel.text REL 00000000 000338 000048 08 I 12 2 4
[ 4] .data PROGBITS 00000000 0000b4 000008 00 WA 0 0 4
[ 5] .bss NOBITS 00000000 0000bc 000004 00 WA 0 0 4
[ 6] .rodata PROGBITS 00000000 0000bc 000004 00 A 0 0 1
[ 7] .text.__x86.get_p PROGBITS 00000000 0000c0 000004 00 AXG 0 0 1
[ 8] .comment PROGBITS 00000000 0000c4 000012 01 MS 0 0 1
[ 9] .note.GNU-stack PROGBITS 00000000 0000d6 000000 00 0 0 1
[10] .eh_frame PROGBITS 00000000 0000d8 00007c 00 A 0 0 4
[11] .rel.eh_frame REL 00000000 000380 000018 08 I 12 10 4
[12] .symtab SYMTAB 00000000 000154 000140 10 13 13 4
[13] .strtab STRTAB 00000000 000294 0000a2 00 0 0 1
[14] .shstrtab STRTAB 00000000 000398 000082 00 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),
p (processor specific)
ELF 文件组成字段分析:
ELF 文件头(ELF Header):保存描述整个文件的基本属性,如 ELF 魔数、文件机器字节长度、数据存储格式等。
段表(Section Header Table):保存各个段的基本属性,是除了文件头之最重要的结构。节选样例内容如下:
[Nr] | Name | Type | Addr | Off | Size | ES | Flg | Lk | Inf | Al |
---|---|---|---|---|---|---|---|---|---|---|
[1] | .text | PROGBITS | 00000000 | 000034 | 00005b | 00 | AX | 0 | 0 | 4 |
其表示的意义为,下标为 1 的段是.text 段,类型是程序段(PROGBITS 包括代码段和数据段),加载地址为 0,在文件中的偏移量是 0×34,长度为 0x5b,项的长度为 0(表示该段不包含固定大小的项),标志 AX 表示该段要分配空间及可以被执行,链接信息的两个 0 没有意义(不是与链接相关的段),最后的 4 表示段地址对齐为 2^4=16 字节。
重定位表:链接器在处理目标文件的时候,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些绝对地址的引用位置,这些重定位信息记录在重定位表里。每个需要重定位的代码段或数据段都会有一个相应的重定位表,如.rel.text 是针对”.text” 段的重定位表,”.rel.data” 是针对”.data” 段的重定位表。
字符串表:ELF 文件中用到很多字符串,如段名、变量名,因为字符串的长度不固定,用固定的结构来表示它比较困难,一般把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。一般字符串表在 ELF 中以段的形式保存,常见的有.strtab(字符串表,String Table)和.shstrtab(段表字符串表,Section Header String Table),前者保存如符号名字等普通字符串,后者保存如段名等段表中用到的字符串。
符号表:函数和变量统称为符号,其名称称为符号名。链接过程中关键的部分就是符号的管理,每一个目标文件都会有一个相应的符号表,记录了目标文件用到的所有符号,每个符号有一个对应的符号值,一般为符号的地址。一个样例如下:
Num | Value | Size | Type | Bind | Vis | Ndx | Name |
---|---|---|---|---|---|---|---|
13 | 0000001b | 64 | FUNC | GLOBAL | DEFAULT | 1 | main |
其意义如下:下标为 13 的符号的符号值为 0x1b,大小为 64 字节,类型为函数,绑定信息为全局符号,VIS 可以忽略,Ndx 表示其所在段的下标为 1(通过上一个样例可知,该段为.text 段),符号名称为 main。如果 Ndx 下标一项为 UND(undefine),则表示该符号在其他模块定义,以后需要重定位。
调试信息:目标文件里可能保存有调试信息,如在 GCC 编译时加上”-g” 参数,会生成许多以”.debug” 开头的段。
2.3 链接
链接,是收集和组织程序所需的不同代码和数据的过程,以便程序能被装入内存并被执行。一般分为两步:1. 空间与地址分配,2. 符号解析与重定位。一般有两种类型,一是静态链接,二是动态链接。
- 空间与地址分配
扫描所有的输入目标文件,获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,连接器将能获得所有输入如目标文件的 段长度,并且将它们合并,计算出输出文件中各个段合并后的长度和位置,并建立映射关系。
- 符号解析与重定位
使用上面一步中收集的所有信息,读取输入文件中的段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上,这一步是链接过程的核心,特别是重定位过程。
使用静态链接的好处是,依赖的动态链接库较少(这句话有点绕),对动态链接库的版本更新不会很敏感,具有较好的兼容性;不好地方主要是生成的程序比较大,占用资源多。使用动态链接的好处是生成的程序小,占用资源少。动态链接分为可执行程序装载时动态链接和运行时动态链接。
当用户启动一个应用程序时,它们就会调用一个可执行和链接格式映像。Linux 中 ELF 支持两种类型的库:静态库包含在编译时静态绑定到一个程序的函数。动态库则是在加载应用程序时被加载的,而且它与应用程序是在运行时绑定的。
2.4 代码分析
sys_execve 内部会解析可执行文件格式。代码在内核中 /linux-4.15.0/fs/exec.c 中。sys_execve 调用顺序:do_execve -> do_execve_common -> exec_binprm
do_execve 函数
int do_execve (struct filename *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
struct user_arg_ptr argv = { .ptr.native = __argv };
struct user_arg_ptr envp = { .ptr.native = __envp };
return do_execve_common (filename, argv, envp);
}
do_execve_common 函数
exec_binprm 函数
static int exec_binprm(struct linux_binprm *bprm)
{
pid_t old_pid, old_vpid;
int ret;
/* Need to fetch pid before load_binary changes it */
old_pid = current->pid;
rcu_read_lock();
old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent));
rcu_read_unlock();
ret = search_binary_handler(bprm);
if (ret >= 0) {
audit_bprm(bprm);
trace_sched_process_exec(current, old_pid, bprm);
ptrace_event(PTRACE_EVENT_EXEC, old_vpid);
proc_exec_connector(current);
}
return ret;
}
search_binary_handler 寻找符合文件格式对应的解析模板,如下:(对于给定的文件名,根据文件头部信息寻找对应的文件格式处理模块)
1 /*
2 * cycle the list of binary formats handler, until one recognizes the image
3 */
4 int search_binary_handler (struct linux_binprm *bprm)
5 {
6 bool need_retry = IS_ENABLED (CONFIG_MODULES);
7 struct linux_binfmt *fmt;
8 int retval;
9
10 /* This allows 4 levels of binfmt rewrites before failing hard. */
11 if (bprm->recursion_depth > 5)
12 return -ELOOP;
13
14 retval = security_bprm_check (bprm);
15 if (retval)
16 return retval;
17
18 retval = -ENOENT;
19 retry:
20 read_lock (&binfmt_lock);
21 list_for_each_entry (fmt, &formats, lh) {
22 if (!try_module_get (fmt->module))
23 continue;
24 read_unlock (&binfmt_lock);
25 bprm->recursion_depth++;
26 retval = fmt->load_binary (bprm);
27 read_lock (&binfmt_lock);
28 put_binfmt (fmt);
29 bprm->recursion_depth--;
30 if (retval < 0 && !bprm->mm) {
31 /* we got to flush_old_exec () and failed after it */
32 read_unlock (&binfmt_lock);
33 force_sigsegv (SIGSEGV, current);
34 return retval;
35 }
36 if (retval != -ENOEXEC || !bprm->file) {
37 read_unlock (&binfmt_lock);
38 return retval;
39 }
40 }
41 read_unlock (&binfmt_lock);
42
43 if (need_retry) {
44 if (printable (bprm->buf [0]) && printable (bprm->buf [1]) &&
45 printable (bprm->buf [2]) && printable (bprm->buf [3]))
46 return retval;
47 if (request_module ("binfmt-%04x", *(ushort *)(bprm->buf + 2)) < 0)
48 return retval;
49 need_retry = false;
50 goto retry;
51 }
52
53 return retval;
54 }
55 EXPORT_SYMBOL (search_binary_handler);
对于 ELF 格式的可执行文件 fmt->load_binary (bprm); 执行的应该是 load_elf_binary 其内部是和 ELF 文件格式解析的部分需要和 ELF 文件格式标准结合起来阅读。load_elf_binary 在 /linux-4.15.0/fs/binfmt_elf.c 文件中,代码太长,在此不贴出,这个函数主要作用就是在函数的最后根据链接种类启动一个起点为新的可执行程序的入口的进程。其中的关键代码如下:
ELF 文件格式结构体:
1 static struct linux_binfmt elf_format = {
2 .module = THIS_MODULE,
3 .load_binary = load_elf_binary,
4 .load_shlib = load_elf_library,
5 .core_dump = elf_core_dump,
6 .min_coredump = ELF_EXEC_PAGESIZE,
7 };
load_elf_binary 的最后调用 start_thread 函数。修改 int 0x80 压入内核堆栈的 EIP,当 load_elf_binary 执行完毕,返回至 do_execve 再返回至 sys_execve 时,系统调用的返回地址,即 EIP 寄存器,已经被改写成了被装载的 ELF 程序的入口地址了。
小结:
1、可执行程序的产生:
C 语言代码–> 编译器预处理–> 编译成汇编代码–> 汇编器编译成目标代码–> 链接成可执行文件,再由操作系统加载到内存中执行。
2、ELF 格式中主要有 3 种可执行文件:可重定位文件.o,可执行文件,共享目标文件。
3、ELF 可执行文件会被默认映射到 0x8048000 这个地址。
4、命令行参数和环境变量是如何进入新程序的堆栈的?
Shell 程序–>execve–>sys_execve,然后在初始化新程序堆栈时拷贝进去。
先函数调用参数传递,再系统调用参数传递。
5、当前程序执行到 execve 系统调用时陷入内核态,在内核中用 execve 加载可执行文件,把当前进程的可执行文件覆盖掉,execve 系统调用返回到新的可执行程序的起点。
6、动态链接库的装载过程是一个图的遍历过程,
ELF 格式中的.interp 和.dynamic 需要依赖动态链接器来解析,entry 返回到用户态时不是返回到可执行程序规定的起点,返回到动态链接器的程序入口。
2.5 静态链接
几个目标文件进行链接时,每个目标文件都有其自身的代码段、数据段等,链接器需要将它们各个段的合并到输出文件中,具体有两种合并方法:
- 按序叠加:将输入的目标文件按照次序叠加起来。
- 相似段合并:将相同性质的段合并到一起,比如将所有输入文件的”.text” 合并到输出文件的”.text” 段,接着是”.data” 段、”.bss” 段等。
第一种方法会产生很多零散的段,而且每个段有一定的地址和空间对齐要求,会造成内存空间大量的内部碎片。所以现在的链接器空间分配基本采用第二种方法,而且一般采用一种称为两步链接的方法:
- 空间与地址分配。扫描所有输入的目标文件,获得他们各个段的长度、属性和位置,收集它们符号表中所有的符号定义和符号引用,统一放到一个全局符号表中。此时,链接器可以获得所有输入目标文件的段长度,将他们合并,计算出输出文件中各个段合并后的长度与位置并建立映射关系。
- 符号解析与重定位。使用上面收集到的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。
经过第一步后,输入文件中的各个段在链接后的虚拟地址已经确定了,链接器开始计算各个符号的虚拟地址。各个符号在段内的相对地址是固定的,链接器只需要给他们加上一个偏移量,调整到正确的虚拟地址即可。
ELF 中每个需要重定位的段都有一个对应的重定位表,也称为重定位段。重定位表中每个需要重定位的地方叫一个重定位入口,包含:
- 重定位入口的偏移:对于可重定位文件来说,偏移指该重定位入口所要修正的位置的第一个字节相对于该段的起始偏移。
- 重定位入口的类型和符号:低 8 位表示重定位入口的类型,高 24 位表示重定位入口的符号在符号表的下标。
不同的处理器指令对于地址的格式和方式都不一样,对于每一个重定位入口,根据其重定位类型使用对应的指令修正方式修改其指令地址,完成重定位过程。
2.5.1 使用静态链接的可执行文件的装载分析
32 位硬件平台上进程的虚拟地址空间的地址为 0 到 2^32-1:0×00000000~0xFFFFFFFF,即通常说的 4GB 虚拟空间大小。在 Linux 操作系统下,4GB 被划分成两部分,操作系统本身占用了 0xC00000000 到 0xFFFFFFFF 共 1GB 的空间,剩下的从 0×00000000 到 0xBFFFFFFFF 共 3GB 的空间留给进程使用。
可执行文件只有被装载到内存以后才能运行,最简单的办法是把所有的指令和数据全部装入内存,但这可能需要大量的内存,为了更有效地利用内存,根据程序运行的局部性原理,我们可以把程序中最常用的部分驻留内存,将不太常用的数据放在磁盘中,即动态装入。
现在大部分操作系统采用的是页映射的方法进行程序装载。页映射并不是一下把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照” 页(Page)” 为单位划分成若干个页,以后所有的装载和操作的单位就是页。目前一般的页大小为 4K=4096 字节。装载管理器负责控制程序的装载问题,当运行到的某条指令不在内存的时候,会将该指令所在的页装载到内存中的一个地方,然后继续程序的运行。如果内存中已经没有位置,装载管理器会根据一定的算法放弃某个正在使用的页,并用新的页来替代,然后程序可以继续运行。
可执行文件中包含代码段、数据段、BSS 段等一系列的段,其中很多段都要映射进进程的虚拟地址空间。当段的数量增加时,会产生空间浪费问题。因为 ELF 文件被映射时是以系统的页长度为单位进行的,一个段映射的长度应为页长度的整数倍,如果不是,那么多余部分也将占用一个页,从而产生内存浪费。
实际上操作系统并不关心可执行文件各个段所包含的实际内容,它只关心一些跟装载有关的问题,最主要的是段的权限(可读、可写、可执行)。ELF 中,段的权限组合可以分成三类:
- 以代码段为代表的权限为可读可执行的段。
- 以数据段和 BSS 段为代表的权限为可读可写的段。
- 以只读数据段为代表的权限为只读的段。
于是,对于相同权限的段,可以把它们合并到一起当做一个段进行映射,这样可以把原先的多个段当做一个整体进行映射,明显地减少页面内部碎片,节省内存空间。这个称为”Segment”,表示一个或多个属性类似的”Section”,可以认为”Section” 是链接时的概念,”Segment” 是装载时的概念。链接器会把属性相似的”Section” 放在一起,然后系统会按照这些”Section” 组成的”Segment” 来映射并装载可执行文件。
进程的虚拟地址空间中除了被用来映射可执行文件的各个”Segment” 之外,还有包括栈(Stack)和堆(Heap)的空间,一个进程中的栈和堆在也是以虚拟内存区域(VMA, Virtual Memrory Area)的形式存在。操作系统通过给进程空间划分出一个个的 VMA 来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个 VMA,一个进程基本可以分为如下几种 VMA 区域:
- 代码 VMA,权限只读,可执行,有映像文件。
- 数据 VMA,权限可读写,可执行,有映像文件。
- 堆 VMA,权限可读写,可执行,无映像文件,匿名,可向上扩展。
- 栈 VMA,权限可读写,不可执行,无映像文件,匿名,可向下扩展。
2.6 动态链接
静态链接允许不同程序开发者相对独立地开发和测试自己的程序模块,促进程序开发的效率,但其也有相应的缺点:
- 浪费内存和磁盘空间。在多进程操作系统下,每个程序内部都保留了公用的库函数及其他数量可观的库函数及辅助数据结构,浪费大量空间。
- 程序开发和发布困难。一个程序如果使用了很多第三方的静态库,那么程序中一旦有任何库的更新,整个程序就要重新链接并重新发布给客户,非常不方便。
动态链接可以解决空间浪费和更新困难的问题,它不对那些组成程序的目标文件进行链接,而是等到程序运行时才进行链接。使用了动态链接之后,当我们运行一个程序时,系统会首先加载该程序依赖的其他的目标文件,如果其他目标文件还有依赖,系统会按照同样方法将它们全部加载到内存。当所需要的所有目标文件加载完毕之后,如果依赖关系满足,系统开始进行链接工作,包括符号解析及地址重定位等。完成之后,系统把控制权交回给原程序,程序开始运行。此时如果运行第二个程序,它依赖于一个已经加载过的目标文件,则系统不需要重新加载目标文件,而只要将它们连接起来即可。
动态链接可以解决共享的目标文件存在多个副本浪费磁盘和内存空间的问题,因为同一个目标文件在内存中只保存一份。另外,当一个程序所依赖的库升级之后,只需要将简单地用新的库将旧的覆盖掉,无需将所有的程序再重新链接一遍,当程序下次运行时,新版本的库会被自动加载到内存并链接起来,程序仍然可以正常运行,并且完成了升级过程。
对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身,还有它所依赖的共享目标文件,此时,它们都是被操作系统用同样的方法映射进进程的虚拟地址空间,只是它们占用的虚拟地址和长度不同。另外,动态链接器也和普通共享对象一样被映射到进程的地址空间。系统开始运行程序之前,会把控制权交给动态链接器,由它完成所有的动态链接工作,然后再把控制权交回给程序,程序就开始执行。
2.6.1 装载时重定位
动态链接的共享对象在被装载时,其在进程虚拟地址空间的位置是不确定的,为了使共享对象能够在任意地址装载,可以参考静态链接时的重定位(Link Time Relocation)思想,在链接时对所有的绝对地址的引用不做重定位,把这一步推迟到装载时再完成(静态库在链接的阶段就会进行重定位,动态库将其推迟到了程序装载的时候)。一旦模块装载完毕,其地址就确定了,即目标地址确定,系统就对程序中所有的绝对地址引用进行重定位。这种装载时重定位(Load Time Relocation)又称为基址重置(Rebasing)。
但是动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位之后对于每个进程来讲是不同的,但是实际上同一个可执行程序的代码段是公用的,也就是指令是一样的,这就存在这问题,可以使用地址无关代码技术(PIC)解决。当然,动态链接库中的可修改的数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。
2.6.2 地址无关代码(指令修改问题的解决方案)
装载时重定位导致指令部分无法在多个进程之间共享,失去了动态链接节省内存的一大优势。为了程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,可以把指令中那些需要改变的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变了,而数据部分可以在每个进程中拥有一个副本。这种方案称为地址无关代码(PIC, Position-independent Code)技术。
我们把共享对象模块中的地址引用按照是否跨模块分成模块内部引用和模块外部引用,按照不同的引用方式分成指令引用和数据引用,然后把得到的 4 种情况分别进行处理:
- 模块内部调用或跳转。因为被调用的函数和调用者处于同一个模块,相对位置固定,而现代的系统对于模块内部的跳转、函数调用可以采用相对地址调用或者给予寄存器的相对调用,所以这种指令不需要重定位,其是地址无关的。
- 模块内部数据访问。显然指令不能包含数据的绝对地址,那么只有进行相对寻址。因为一个模块前面一半是若干个页的代码,然后是若干个也的数据,这些页之间的相对位置是固定的,即任何一条指令与它所需要访问的模块颞部数据之间的相对位置是固定的,那么只需要相对当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对当前指令地址(PC)的寻址方式,ELF 中使用了巧妙的办法获取当前的 PC 值,然后再加上一个偏移量达到访问相应变量的目的。
- 模块间数据访问。模块间的数据访问目标地址要等到装载时才能确定,这些变量的地址跟模块的装载地址相关。ELF 在数据段里建立一个指向这些变量的指针数组,称为全局偏移表(GOT, Global Offset Table),当代码需要引用该全局变量时,可以通过 GOT 中相对应的项间接引用。当指令需要一个其他模块的变量时,程序会先找到 GOT,然后根据 GOT 中变量对应的项找到该变量的目标地址。每个变量对应一个 4 字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充 GOT 的各个项,以确保每个指针所指向的地址都正确。由于 GOT 本身放在数据段,它可以在被模块装载时修改,并且每个进程都可以有独立的副本,相互不受影响。
- 模块间调用、跳转。采用上述类似的方法,不同的是,GOT 中相应保存的是目标函数的地址,当模块需要调用目标函数时,可以通过 GOT 中的项进行间接跳转。调用一个函数时,先得到当前指令地址 PC,然后加上一个偏移得到函数地址在 GOT 中的偏移,然后进行间接调用。
于是,四种地址引用方式在理论上都实现了地址无关性
2.6.3 数据段地址无关性
以上的方法能够保证共享对象中代码部分地址无关,但数据部分并不是地址无关的,比如:
1 static int a;
2 static int* p = &a;
指针 p 的地址是绝对地址,指向变量 a,但 a 的地址会随着共享对象的装载地址改变而变。
数据段在每个进程都有一份独立的副本,并不担心被进程改变,于是可以选择装载时重定位的方法来解决数据段中绝对地址引用的问题。对于共享对象(代码段如果使用绝对地址,也会被放在数据段,地址无关代码技术)来说,如果数据段中有绝对地址的引用,那么编译器和链接器会产生一个重定位表,这个表中包含了”R_386_RELATIVE” 类型的重定位入口来解决上述问题。当动态链接器装载共享对象时,如果发现共享对象上有这样的重定位入口,就会对该共享对象进行重定位。
其实对代码段也可以使用装载时重定位而不是地址无关代码的方法,只是它存在着一定的缺陷,它有以下特点:
-
- 代码段不是地址无关,不能被多个进程共享,失去了节省内存的有点。
- 运行速度比地址无关代码的共享对象块,因为它省去了地址无关代码中每次访问全局数据和函数时都要做一次计算当前地址以及间接地址寻址的过程。
2.6.4 动态链接相关结构
动态链接下可执行文件的装载与静态链接下基本一样,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的”Program Header” 中读取每个”Segment” 的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。在静态链接情况下,操作系统接着就可以把控制权交给可执行文件的入口地址,然后程序开始执行。但在动态链接情况下,操作系统会先启动一个动态链接器,动态链接器得到控制权后,开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。
动态链接涉及到的段主要如下:
- “.interp” 段。在 Linux 中,操作系统在对可执行文件进行加载时,会寻找装载该可执行文件需要的相应的动态链接器,即”.interp” 段指定的路径的共享对象。
- “.dynamic” 段。动态链接 ELF 中最重要的结构,保存了动态链接器需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。”.dynamic” 段保存的信息类似于 ELF 文件头,只是 ELF 文件头保存的是静态链接相关的内容,这里换成动态链接所使用的相应信息。
- 动态符号表。ELF 中专门保存符号信息的段为”.dynsym”。类似于”.symtab”,但”.dynsym” 只保存与动态链接相关的符号,而”.symtab” 则保存了所有的符号,包括”.synsyms” 中的符号。同样地,动态符号表也需要一些辅助的表,如保存符号名的字符串表,静态链接时叫符号字符串表”.strtab”,在这里就是动态符号字符串表”.dynstr”(Dynamic String Table)。为了加快动态链接下程序符号查找的过程,往往还有扶着的符号哈希表”.hash”。动态链接符号表的结构与静态链接的符号表几乎一样,可以简单地将导入函数看做是对其他目标文件函数的引用,把导出函数看做是在本目标文件定义的函数即可。
- 动态链接重定位表。动态链接下,可执行文件一旦依赖于其他共享对象,它的代码或数据中就会有对于导入符号的引用,这些导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。如果共享对象不是以 PIC 编译的,那么它需要在装载是被重定位;如果它是 PIC 编译的,虽然代码段不需要重定位,但是数据段还包含了绝对地址的引用,其绝对地址被分离出来成了 GOT,而 GOT 是数据段的一部分,需要重定位。
装载时重定位跟静态链接中的目标文件重定位十分相似。静态链接中,目标文件里包含专门用于重定位信息的重定位表,如”.rel.txt” 表示代码段的重定位表,”.rel.data” 表示数据段的重定位表。类似地,动态链接中,重定位表分别为”.rel.dyn” 和”.rel.plt”,前者是对数据引用的修正,修正的位置位于”.got” 以及数据段,后者是对于函数引用的修正,修正的位置位于”.got.plt”。
2.6.5 动态链接的步骤
动态链接的步骤基本上分为 3 步:启动动态链接器本身,然后是装载所有需要的共享对象,最后是重定位和初始化。
- 动态链接器自举。普通共享对象文件的重定位工作由动态链接器完成,动态链接器本身本身不可以依赖于其他共享对象,其重定位工作由其自身完成,这需要动态链接器在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时不能用到全局和静态变量,甚至不能调用函数,这种具有一定限制的启动代码称为自举(Bootstrap)。
动态链接器获得控制权后,自举代码开始执行。自举代码首先找到自己的 GOT,而 GOT 的第一个入口即是”.dynamic” 段的偏移地址,由此找到了动态链接器本身的”.dynamic” 段。通过”.dynamic” 的信息,自举代码可以获得动态链接器本身的重定位表和符号表,从而得到动态链接器本身的重定位入口,先将他们全部重定位,然后动态链接器代码可以使用自己的全局变量和静态变量。 - 装载共享对象。自举完成后,动态链接器将可执行文件盒链接器本身的符号表合并到一个全局符号表中,然后开始寻找可执行文件依赖的共享对象。通过”.dynamic” 段中类型的入口是 DT_NEEDED 的项,链接器可以列出可执行文件所依赖的所有共享对象,将他们的名字放入一个装载集合中。然后从集合中取出一个共享对象的名字,找到相应的文件后打开,读取相应的 ELF 文件头”.dynamic” 段,然后将它相应的代码段和数据段映射到进程空间。如果这个 ELF 共享对象还依赖其他共享对象,则将所依赖的共享对象的名字放入装载集合中。如此循环把所有依赖对象都装载进内存为止。如果把依赖关系看做一个图的话,装载过程就是图的遍历过程,可以使用广度优先或深度优先搜索的顺序进行编译。
- 重定位和初始化。上述步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将他们的 GOT/PLT 中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程比较容易,和前面的地址重定位原理基本相同。
重定位完成后,如果共享对象有”.init” 段,那么动态链接器会执行”.init” 段的代码,用来实现共享对象特有的初始化过程,比如共享对象中 C++ 的全局 / 静态对象的构造。相应地,如果有”.finit” 段,当进程退出时会执行”.finit” 段中的代码,比如类似的 C++ 全局对象的析构。而进程的可执行文件本身的的”.init” 和”.finit” 段不是由动态链接器执行,而是有运行库的初始化部分代码负责执行。
2.6.6 显式运行时链接
动态链接还有一种更加灵活的模块加载方式,称为显式运行时链接(Explicit Run-time Linking),也叫运行时加载。就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。一般的共享对象不需要进行任何修改就可以进行运行时加载,称为动态装载库(Dynamic Loading Library)。动态库的装载通过以下一系列的动态链接器 API 完成:
- dlopen:打开一个动态库,加载到进程的地址空间,完成初始化过程。
- dlsym:通过指定的动态库句柄找到制定的符号的地址。
- dlerror:每次调用 dlopen ()、dlsym () 或 dlclose () 以后,可以调用 dlerror () 来判断上一次调用是否成功。
- dlclose:将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用 dlopen () 加载时,计数器加一;每次使用 dlclose () 卸载时,计数器减一。当计数器减到 0 时,模块才真正地卸载。
下面是一个简单的例子,这个程序将数学库模块用运行时加载的方法加载到进程中,然后获取 sin () 函数符号地址,调用 sin () 并且返回结果。
1 #include <stdio.h>
2 #include <dlfcn.h>
3 int mian ()
4 {
5 void *handle;
6 double (*func)(double);
7 char *error;
8
9 handle = dlopen (argv [1], RTLD_NOW);
10 if (handle == NULL)
11 {
12 printf ("Open library % s error: % s\n", argv [1], dlerror ());
13 return -1;
14 }
15
16 func = dlsym (handle, "sin");
17 if ( (error = dlerror ()) != NULL)
18 {
19 printf ("Symbol sin not found: % s\n", error);
20 goto exit_runso;
21 }
22
23 printf ("% f\n", func (3.1415926/2));
24
25 exit_runso:
26 dlclose (handle);
27 }
编译运行结果如下:
1 $gcc -o RunSoSimple RunSoSimple.c -ldl
2 $./RunSoSimple/lib/libm-2.6.1.so
3 1.000000
GOT 表
在 ELF(Executable and Linking Format)格式的共享库中,位置无关代码(PIC)技术使得代码和数据的引用与加载地址无关。程序可以被加载到地址空间的任意位置。PIC 在代码中的跳转和分支指令不使用绝对地址,而是在 ELF 可执行映像的数据段中建立一个全局偏移量表(GOT),用于存放所有全局变量的指针。
对于模块外部引用的全局变量和全局函数,通过 GOT 表的表项内容作为地址进行间接寻址。对于本模块内的静态变量和静态函数,以 GOT 表的首地址作为基准,通过相对于该基准的偏移量进行引用。由于模块内的静态变量和静态函数与 GOT 的相对距离是固定的,并且在链接阶段即可确定,因此 PIC 利用 GOT 来引用变量和函数的绝对地址,将位置无关的引用重定向到绝对位置。
在 PIC 代码中,代码段内不存在重定位项,实际的重定位项仅存在于数据段的 GOT 表内。共享目标文件中的重定位类型包括 R_386_RELATIVE
、R_386_GLOB_DAT
和 R_386_JMP_SLOT
。这些重定位类型用于在动态链接器加载映射共享库或模块运行时,对指针类型的静态数据、全局变量符号地址和全局函数符号地址进行重定位。
PLT 表
过程链接表(PLT)用于将位置无关的函数调用重定向至绝对位置。每个动态链接的程序与共享库均包含一个 PLT 表,表中每一项对应本运行模块引用的一个全局函数。程序对函数的访问均被调整为对 PLT 入口的访问。
在操作系统运行程序时,首先将解释器程序(即动态链接器 ld.so
)映射至合适地址并启动。ld.so
完成初始化后,依据可执行文件的动态库依赖表,查找并加载所需的库至内存。
Linux 使用全局的库映射信息结构 struct link_map
链表管理动态库的加载。动态库的加载过程是将库文件映射至内存,并将库映射信息结构添加至链表。struct link_map
描述共享目标文件的加载映射信息,是动态链接器运行时内部使用的结构,用于跟踪已装载的库及其符号。link_map
通过双向链接中间件 l_next
和 l_prev
链接进程中所有加载的共享库。动态链接器查找符号时,可遍历该链表,通过访问链表中的每个库搜索所需符号。link_map
链表的入口由可执行映像的全局偏移表(GOT)的第 2 个入口(GOT[1]
)指向。查找符号时,先从 GOT[1]
读取 link_map
节点地址,再沿节点进行搜索。
每个 PLT 入口项对应一个 GOT 项,执行函数时跳转至相应 GOT 项存储的地址。GOT 项初始值为 PLTn 项中的 push
指令地址(即 jmp
的下一条指令),因此第一次跳转无实际作用。符号解析完成后,GOT 项存储符号的实际地址。动态链接器在装载共享库时,在 GOT 中设置两个特殊值:GOT[1]
存储动态库映射信息数据结构 link_map
的地址;GOT[2]
存储动态链接器符号解析函数 _dl_runtime_resolve
的地址。
PLT 的第一个入口(PLT0)是访问动态链接器的特殊代码。程序首次访问 PLT 入口时,均跳转至 PLT0,最终跳入 GOT[2]
存储的地址执行符号解析函数。符号解析完成后,将符号的实际地址存入相应 GOT 项,后续调用函数时可直接跳转至实际地址,无需再次执行符号解析函数。
在 ELF 文件中,全局偏移表(GOT)用于将位置无关的地址定位至绝对地址,程序连接表则用于将位置无关的函数调用定向至绝对地址。连接编辑器无法解决程序从一个可执行文件或共享库目标到另一个的执行转移,因此仅将相关入口安排至程序连接表(PLT)。在 System V 体系中,程序连接表位于共享正文中,但使用私有全局偏移表中的地址。动态连接器(如 ld-2.2.2.so
)会确定目标的绝对地址并修改内存中全局偏移表的映像,从而重定向入口,而无需破坏程序正文的位置无关性和共享特性。可执行文件与共享目标文件各自拥有独立的程序连接表。
ELF 的动态连接库具有内存位置无关性,即库可加载至内存的任意位置而不影响功能,这种特性称为位置无关(position independent)。编译位置无关的动态连接库时,需使用 -fpic
选项,使编译器生成的目标文件为位置无关代码,并尽量减少对变量引用时使用绝对地址。位置无关代码的编译会带来一定开销,编译器需保留一个寄存器指向全局偏移量表(GOT),这在最坏情况下会导致性能降低 3%,但在其他情况下影响远小于 3%。
Linux 环境下的动态库生成与使用
峰上踏雪已于 2022-04-14 18:55:33 修改
一,必要的说明
1.准备测试程序
测试程序可以再我的上一篇博客中(Linux 环境下的静态库生成与使用)获取。当然测试程序比较简单,大家可以自行编写。
2.静态库与动态库的简要说明
这里我们只讲 Linux 环境下的静态库与动态库的生成与使用, Windows 直接用 VS 就能生成。
在项目中使用库一般有两个目的,一个是为了使程序更加简洁不需要在项目中维护太多的源文件,另一方面是为了源代码保密,毕竟不是所有人都想把自己编写的程序开源出来。
当我们拿到了库文件(动态库、静态库)之后要想使用还必须有这些库中提供的 API 函数的声明,也就是头文件,把这些都添加到项目中,就可以快乐的写代码了。
3.静态库与动态库区别
1.静态链接库:程序编译时被链接到目标代码。
2.动态链接库:程序运行时才会链接到目标代码。知道了静态库与动态库区别,它们的优缺点就不言而喻了。
二. 动态库
动态链接库是程序运行时加载的库,当动态链接库正确部署之后,
运行的多个程序可以使用同一个加载到内存中的动态库,因此在 Linux 中动态链接库也可称之为共享库。在 Linux 中动态库以 lib 作为前缀,以.so 作为后缀,中间是库的名字自己指定即可,即: libxxx.so
在 Windows 中动态库一般以 lib 作为前缀,以 dll 作为后缀,中间是库的名字需要自己指定,即: libxxx.dll
1.生成动态链接库
生成动态链接库是直接使用 gcc 命令并且需要添加 -fPIC(-fpic) 以及 -shared 参数。
-fPIC 或 -fpic 参数的作用是使得 gcc 生成的代码是与位置无关的,也就是使用相对位置。
-shared参数的作用是告诉编译器生成一个动态链接库。
下图是测试代码结构
# -I :重新指定要搜索的文件目录。
gcc -c -fpic add.c sub.c muli.c div.c -I ./include/
执行上面语句后会生成相对位置的 .o文件
执行下面的指令,生成动态链接库
gcc -shared *.o -o libCalc.so
2.如何使用动态链接库
- 动态库的使用
得到了一个可用的动态库之后,将其与相应的头文件放到一个目录中(就相当于发布了),
然后根据得到的头文件编写测试代码,对动态库中的函数进行调用。这里我把生成的动态库和相应的头文件放到 temp 文件夹中进行测试。
2.这里我们会用到gcc的两个参数
-l :在程序编译的时候,指定使用的库。(静态库的名字一定要掐头去尾。如:libCalc.so 变为 Calc)
-L :在程序编译的时候,指定使用的库的路径。
gcc main.c -o calc -L ./ -l Calc
如果各位码友都顺利的走到这一步,并且测试程序正常跑起来了,那么恭喜你完成了。
如果出现:./calc: error while loading shared libraries: libCalc.so: cannot open shared object file: No such file or directory的报错,不要慌,下面我们就来分析问题,并给出解决方案。
三、解决应用程序无法链接到动态库的问题
1. 动态链接库加载失败的原因
在可执行程序 calc
中,仅包含测试程序和头文件,而未包含动态链接库。这是因为动态链接库本身具有共享性,通常不会直接嵌入到可执行程序中。
2. 解决方案
将动态链接库路径添加到环境变量 LD_LIBRARY_PATH
中
需要明确的是,环境变量中不仅包含 PATH
,还有其他用于特定目的的变量。其中:
PATH
:用于设置可执行程序的查找路径。LD_LIBRARY_PATH
:用于设置动态库的查找路径。
3. 设置方法
设置环境变量分为两种情况:设置当前用户的环境变量和设置系统环境变量。前者仅对当前用户生效,后者对所有用户生效。
(1)设置当前用户的环境变量 LD_LIBRARY_PATH
(仅对当前用户生效)
通过编辑用户主目录下的配置文件来实现。可以使用以下命令:
vim ~/.profile
或
vim ~/.bashrc
在文件中添加以下内容:
export LD_LIBRARY_PATH=/home/shaofeng/桌面/c++/2.动态库/temp:$LD_LIBRARY_PATH
(2)设置系统环境变量 LD_LIBRARY_PATH
(对所有用户生效)
通过编辑系统级配置文件来实现。可以使用以下命令:
sudo vim /etc/profile
或
sudo vim /etc/environment
在文件中添加以下内容:
export LD_LIBRARY_PATH=/home/shaofeng/桌面/c++/2.动态库/temp:$LD_LIBRARY_PATH
4. 使设置生效
完成上述设置后,需要执行以下命令以使环境变量生效(否则需要重启系统):
-
当前用户生效:
source ~/.profile
或
source ~/.bashrc
-
系统生效:
source /etc/profile
或
source /etc/environment
详解 Linux 下静态库 / 动态库的生成和使用(含代码示例和操作流程)&& 动态库和静态库的区别
狱典司于 2021-09-25 18:26:50 发布
一、库的概念
- 库是一种组件技术。
- 库里封装了数据和函数,提供给用户程序调用。
- 库只执行到第三阶段编译,没有链接。
- 库的使用可以使程序模块化,提高程序的编译速度,实现代码复用。
Windows 的库
Windows 系统本身提供并使用了大量的库;
包括:静态库(.lib)和动态链接库(.dll)
Linux 的库
Linux 系统通常把库文件存放在
/usr/lib 或 /lib
目录下,
Linux 库文件名组成:前缀 lib + 库名 + 后缀
(3 部分组成)
动态库
:以.so
作为后缀
静态库
:通常以.a
或.la
作为后缀。
二、动态库和静态库的区别
1. 载入顺序不同
静态库的代码在编译时就拷贝到应用程序中,因此当多个应用程序同时引用一个静态库函数时,内存中将会调用函数的多个副本。其优点是节省编译时间。
动态库是在程序开始运行后且调用库函数时才被载入,被调函数在内存中只有一个副本,并且动态库可以在程序运行期间释放动态库所占用的内存。
2. 大小与共享的差异
静态链接库就是在程序编译的时候就被加载进来,这样的可执行文件会比较大一些,还不能共享 ;动态链接库是在程序执行的时候加载,可共享 。
3. 库函数调用的差异
静态链接是指把要调用的函数或者过程链接到可执行文件中,成为可执行文件的一部分。
动态链接所调用的函数代码并没有被拷贝到应用程序的可执行文件中去,而是仅仅在其中加入了所调用函数的描述信息(往往是一些重定位信息)。仅当应用程序被装入内存开始运行时,在 linux 的管理下,才在应用程序与相应的.so 之间建立链接关系。当要执行所调用动态库中的函数时,根据链接产生的重定位信息,才转去执行动态库中相应的函数代码。
三、静态库的创建和使用
1. 创建静态库的步骤:
- 在一个头文件中声明静态库所导出的函数
- 在一个源文件中实现静态库所导出的函数
- 编译源文件,生成可执行代码(没有连接)
- 将可执行代码所在目标文件加入到某个静态库中,并将静态库拷贝到系统默认的存放库目录下(/usr/lib 或 /lib)。
(一)ar 命令的使用方法
- 作用:创建和修改静态库
- 格式:ar [选项 ] 静态库名.a 目标文件 1.o 目标文件 2.o …
ar 命令可用选项:
选项 | 说明 |
---|---|
d | 从库中删除成员 |
p | 在终端上打印库中指定的成员 |
r | 在库中加入新成员文件,如果要加入的成员文件存在,则替换之;默认情况下,新的成员文件增加在库的结尾处 |
t | 显示库的成员文件清单 |
a | 在库的一个已经存在的成员后面增加一个新的成员文件 |
c | 创建一个库 |
i | 在库的一个已经存在的成员前面增加一个新的成员文件 |
s | 无论 ar 命令是否修改了库内容,都强制重新生成库符号表 |
x | 从库中提取一个成员文件。如果不指定要提取的成员文件则提取库中所有的文件 |
(二)生成静态库的实例
(1)头文件 mylib.h —— 声明静态库所导出的函数
#ifndef _mylib_H_ // 如果没有定义此标识符,编码以下程序
#define _mylib_H_ 1
void welcome ();
void outstring (const char*str);
#endif
(2) 对应于头文件的源文件 mylib.c —— 实现静态库所导出的函数
#include “mylib.h”
#include <stdio.h>
void welcome () {
printf (“welcome to libmylib\n”);
}
void outstring (const char*str) {
if (str !=NULL)
printf (“% s”,str);
}
(3)编译 mylib.c 生成目标文件:
# gcc –o mylib.o –c mylib.c
注: -c 是只编译而不生成可执行文件,-o 指定文件名
更多关于 gcc 的使用方法可以参考下方链接博客:
Linux 下详解 gcc 编译过程(含代码示例)&& gcc 使用教程
(4)将目标文件加入到静态库中,静态库为 libmylib.a
#ar rcs libmylib.a mylib.o
参数说明:
/*r — 在库中加入新成员文件,如果要加入的成员文件存在,则替换之默认情况下,新的成员文件增加在库的结尾处
/*c 创建一个库
/*s 无论 ar 命令是否修改了库内容,都强制重新生成库符号表
(5)将静态库拷贝到 linux 的库目录(/usr/lib 或 /lib)下
#cp libmylib.a/usr/lib/libmylib.a
到这里静态库就生成完了,下面我们尝试用一个程序链接它。
2. 调用静态库的测试程序
简而言之就是:用一个.c 源文件在编译时做静态链接
(一)调用静态库的测试程序:test.c
#include “mylib.h”
#include <stdio.h>
int main ()
{
printf (“create and use library:\n”);
welcome (); // 静态库中的函数原型
outstring (“It’s successful\n”); // 静态库中的函数原型
}
(二)编译使用了库函数的程序
#gcc –o test test.c –lmylib
参数说明:
(1)选项 l 为链接选项说明在链接时就将静态库链接至源程序中
(2)在 Linux 中约定所有库都以前缀 lib 开始,静态库以.a 结尾,动态库以.so 结尾,在编译时,无需带上前缀(lib)和后缀(.a)。
四、动态库的创建和使用
注:动态库没有归档,在编译过程中就要生成符号表
- 在 Linux 环境下,只要在编译函数库源程序时加上
-fPIC -shared
选项即可生成动态链接库。
1. 动态库的创建
# gcc –fPIC -o mylib.o -c mylib.c (生成符号表)
# gcc –shared -o libmylibs.so mylib.o(防止重复加载)
・或者可以直接使用一条命令
# gcc –fPIC –shared –o libmylibs.so mylib.c (可以省略 -c 和 .o 后缀文件名)
・将生成的静态库拷贝至 /usr/lib 或 /lib 目录中
# cp libmylibs.so/usr/lib/libmylibs.so
2. 动态库的使用
(1)通过 gcc 命令调用
# gcc –o test test.c -lmylibs
(2)通过调用系统函数来使用动态链接库
其中:
dlopen 函数的参数 flag 可取的值有:RTLD_LAZY、 RTLD_NOW、 RTLD_GLOBAL:
- RTLD_LAZY:在 dlopen () 返回前,对于动态库中存在的未定义的变量不执行解析,即不解析这个变量的地址
- RTLD_NOW:在 dlopen 返回前,解析出每个未定义变量的地址,如果解析不出来,dlopen 会返回 NULL, 错误为 “Undefined symbol:”
- RTLD_GLOBAL:使库中被解析出来的变量在随后的其它链接库中也可以使用,即全局有效
3. 通过调用系统函数来使用动态链接库的实例
创建 testso.c 文件:
#include <stdioh>
#include <dlfcn.h>
int main (void)
{
void*handle;
char*error;
void (*welcome)(); // 要变成指针函数来用(由符号表引出的指针)
if ((handle = dlopen (“/usr/lib/libmylibs.so”, RTLD_LAZY) == NULL)
{
printf (“dlopen error\n”);
exit (1);
}
welcome = dlsym (handle, “welcome”);
if ((error = dlerror ()) != NULL)
{
printf (“dlsym error \n”);
exit (1);
}
welcome ();
dlclose (handle);
exit (0);
}
编译: #gcc –o testso testso.c -ldl
其中:-ldl 指明 dlopen 等函数所在的库类型
Linux 下详解 gcc 编译过程(含代码示例)&& gcc 使用教程
狱典司于 2021-09-25 17:38:02 发布
准备: test.c
在正式开始之前,若是想要通过一个代码(.c 文件)来熟悉 gcc 的编译流程,可以在你的测试目录下使用如下代码:
// 文件名:test.c
1 #include <stdio.h>
2 #include <stdlib.h>
3
4 int main (int argc, char*argv [])
5 {
6 int arg;
7 for (arg = 0; arg < argc; arg++){
8 if (argv [arg][0] == ‘-’)
9 printf ("option:% s\n",argv [arg]+1);
10 else
11 printf ("argument % d:% s\n",arg,argv [arg]);
12 }
13 exit (0);
14 }
1. 编译过程
在使用 gcc 编译程序时,编译过程可以简要划分为 4 个阶段:
- 预处理
- 汇编
- 编译
- 链接
1.1 预处理 (Pre-Processing)
这个阶段主要处理源文件中的 #indef、#include 和 #define 预处理命令;
这里主要是把一些 include 的头文件和一些宏定义,放到源文件中。
- 输入的是 C 语言的源文件
- 输出 :生成一个中间 / 预加载文件 *.i(以 .i 结尾的文件)
- 这个阶段对应的 gcc 命令如下:
#gcc –E(预处理) test.c(源文件) -o test.i(将结果生成的文件)
- 说明:它通过对源文件 test.c 使用 E 选项来生成中间文件 test.i
1.2 汇编 (Assembling)
-
输入:中间文件 *.i
-
输出:编译后生成汇编编语言文件 *.s
-
这个阶段对应的 gcc 命令如下:
#gcc -S (汇编) test.i -o test.s(汇编编语言文件)
-
说明:它通过对源文件 test.c 使用 S 选项来生成汇编文件 test.s
1.3 编译 (Compiling)
- 输入文件:汇编文件 *.s
- 输出文件:二进制机器代码 *.o
- 这个阶段对应的 gcc 命令如下:
#gcc -c(编译) test.s -o test.o
*gcc -c
在功能上,预处理、编译、汇编是 3 个不同的阶段
但 gcc 在实际操作时可以把这 3 个步骤合并为一个步骤来执行,即使用 -c 选项:
- 输入文件:源码 *.c 文件
- 输出文件:二进制机器代码 *.o
- 这个阶段对应的 gcc 命令如下:
# gcc –c test.c -o test.o
//-o 指定了 output_filename
或 :
#gcc -c test.c
// 省略 - o output_filename
// 默认输出为 test.o
// 与源文件同名,后缀为.o
1.4 链接 (Linking)
- 输入文件:二进制机器码 *.o 文件
- 输出文件:与其它的机器代码和库文件汇集成一个可执行的二进制代码文件(无后缀)
- 这个阶段对应的 gcc 命令如下:
#gcc (没有选项符号) test.o example.o -o test
可用选项:
-L 指定库路径
-l (小写的 L)指定库名称
-I (大写的 i)指定头文件所在路径
-i 指定头文件名称
选项详解:
选项 | 用法 | 解释 |
---|---|---|
-l(大写的 i) | -Idirname (没有间隔) | 将名为 dirname 的目录加入到程序头文件目录列表中,它是在预处理阶段使用的选项,I 是指 Include |
-L | -Ldirname | 将名为 dirname 的目录加入到程序的库文件搜索目录列表中,它是在链接过程中使用的参数, L 是指 Link |
-l (小写的 L) | -lname | 指示编译器,在链接时,装载名为 libname.a 的函数库,该函数库位于系统预定义的目录或者由 - L 选项指定的目录下 |
・例:-lm 表示链接名为 libm.a 的数学函数库
2. 简化过程 —— gcc
- 上述过程可以简化为:
#gcc test.c (源文件) -o test(可执行文件)
注意:与链接的语句差别主要在指令中输入文件的后缀
注:
- 库是头文件的实现!
- 在编译过程中可以被装载 / 拷贝的库为静态库(.a 后缀)
3. gcc 常用选项总结
常用选项:
选项 | 功能 |
---|---|
-c | 只编译,不链接成可执行文件,编译器只是由输入的.c 等为后缀的源代码文件生成.o 为后缀的标文件,通常用于编译不包含主程序的子程序文件 |
-o | output_filename 确定输出文件的名称为 output_filename,同时这个名称不能和源文件名。如果不给出这个选项,gcc 就默认将输出的可执行文件命名为 a.out |
-g | 产生调试器 gdb 所必须的符号信息,要对源代码进行调试,就必须在编译程序时加入这个选项 |
-O | 对程序进行优化编译、链接,采用这个选项,整个源代码会在编译、链接过程中进行优化处理,这样产生的可执行文件的执行效率较高,但是,编译、链接的速度就相应地要慢一些。 |
-O2 | 比 - O 更好的优化编译、链接 |
-Wall | 输出所有警告信息 |
-w | 关闭所有警告信息 |
-Idirname (没有间隔,大写 i ) | 将名为 dirname 的目录加入到程序头文件目录列表中,它是在预处理阶段使用的选项,I 是指 Include |
-Ldirname | 将名为 dirname 的目录加入到程序的库文件搜索目录列表中,它是在链接过程中使用的参数。 |
・L 是指 Link | |
-lname(小写的 L) | 指示编译器,在链接时,装载名为 libname.a 的函数库,该函数库位于系统预定义的目录或者由 - L 选项指定的目录下 |
gcc 使用例子:
(1) gcc -Wall src.c –o dest -lpthread
/*输出所有警告信息地编译 src.c 生成 dest 可执行文件
/*并在链接时装入 libpthread.a 的函数库
(2)gcc -Wall -o sniffex sniffex.c -lpcap
/*输出所有警告信息地编译 sniffex.c 生成 sniffex 可执行文件
/*并在链接时装入 libpcap.a 的函数库
Linux:动静态库的概念与制作使用
海绵宝宝de派小星 已于 2024-01-23 22:08:52 修改
本篇要谈论的内容是关于动静态库的问题,具体的逻辑框架是建立在库的制作,库的使用,和库的原理来展开,基于上述的三个模块来对动静态库有一个较为清楚的认知
动静态库基础认知
在前面的学习中知道,在用户写完代码后,想要将写完的代码转换成可以执行的可执行程序过程是一个相当复杂的过程,那么在这段过程中要处理的过程基本有,例如预处理,编译,汇编,链接,可执行程序,而在学习编译工具gcc的时候又提到过,在代码进行链接的过程中,系统必须要提供对应的动静态库,因此就引入了动静态库的概念
所谓动态链接,就是让程序和库产生这种地址性的关联,而静态库,就是把目标的库文件直接拷贝到对应的可执行程序当中
动静态库基本概念
对于静态库,第一步要先引入的是静态库的原理,静态库就是在进行编译链接的过程中,把静态库中所包含的代码都拷贝到可执行程序中,之后在可执行程序的运转就不需要静态库了
对于动态库,原理是在程序运行的时候才会链接动态库的代码,多个程序会共享使用库的代码,而一个与动态库链接的可执行文件只有一个函数入口地址的表,而不是整个文件的内容,上述是关于动态库的基本原理,关于这些内容后面就进行一个一个的解析
静态库的制作
首先,创建出对应的文件,这里假设要实现一个计算器,那么实现对应的函数声明和实现过程:
再创建一个对应的测试函数
#include "Add.h"
#include "Sub.h"
#include "Mul.h"
#include "Div.h"
int main()
{
int a = 10;
int b = 20;
printf("%d + %d = %d\n", a, b, Add(a, b));
printf("%d - %d = %d\n", a, b, Sub(a, b));
printf("%d * %d = %d\n", a, b, Mul(a, b));
return 0;
}
那么现在准备工作就完成了,下面的问题是,我想要编译生成一个可执行程序,应该输入什么指令呢?
gcc -o test.exe test.c Add.c Sub.c Mul.c Div.c
这样就完成了编译,生成了一个可以被运行的可执行文件,那么下面对于上面的现象提出一些问题
1. 为什么在编译的时候不带头文件?
因为头文件所属的位置就在当前路径下,编译器是直接可以找到的,如果头文件对应的查找路径是在当前目录或者是指定的目录,是不需要写在编译选项中的
2. 上面的步骤是否每一步都需要?
这个问题问法很奇怪,怎么说是每一步都需要,假设现在要生成的是一个可执行程序,其实根本不太需要把源文件全部编译生成一个可执行文件,因为这样的过程需要经过预处理编译汇编链接等等,而实际上在编译这样的多文件项目的过程中,只需要把源文件都编译为.o后缀的文件,再将这些文件进行链接形成一个可执行文件,这是被倡议的一种链接方式
库的概念
这也就是在任何项目中,都会存在这样的文件的原因,在进行编译生成可执行文件的过程中,如果是直接将已经生成的这些.o后缀的文件进行一定的链接组合,就能生成可执行程序
基于上面的原因,我们重新进行一次编译,这次按照上述的过程生成对应的.o文件即可,为了方便后续进行其他的使用,写一个Makefile来自动进行编译比较好:
%.o:%.c
gcc -c $<
Test:Add.o Sub.o Mul.o Div.o test.o
gcc -o $@ $^
.PHONY:clean
clean:
rm -rf *.o Test
那么上面就是实现了Makefile,但是和前面写的不太一样,这个Makefile是直接将这些.o后缀的文件生成了一个可执行程序,所以现在就要先生成这样的.o后缀的文件,借助gcc编译工具就可以生成,之后就可以运行出结果了
这里补充一点上面写的这个语句,%.c的意义就是类似于一种通配符,因为后续生成test可执行文件是依赖于.o文件的,但是它们都不存在,那么此时就需要根据依赖关系来进行推导,而在推导的过程中,当Makefile在进行被编译的时候,就会把%.c全部展开,之后就会进行不断的推导,展开成四个gcc的编译方式语句,而这里的$<表示的是把文件依赖列表中的内容一个一个的传递到下面的命令中,最后连起来,就能解析成对应的内容了
那么现在的问题是,如果源文件已经不需要了,而是只需要这些.o为后缀的文件,也就是说,把这些文件进行打包,作为一个库,而把这个包交给使用者后,使用者就只需要写出自己的.c文件,再编译成.o文件,就能和我刚才打包好的包直接进行链接,就省去了前面的很多步骤,像这样的过程就是前面所述的核心观点,基于这样的原因,现在当前的主要任务就是生成一个库
那么就对Makefile进行改造,改造的核心思路就是基于上述的这一系列原理,将文件编译成.o文件,再将文件整合到一个固定的地方,这就是库的概念
包的概念
上面的思路原理存在,下面的一个问题是,直接把.o文件存到一个固定的位置,显然是不太合适的方式,如果此时有几百个文件呢?如果也是一个一个的进行转移,那么可能会有所遗漏,这都是不被建议和允许的,基于这样的原因,有了打包的概念
ar指令
ar -rc $@ $^
上述就是在Linux中打包的指令,ar命令就是把所有的源文件进行打包形成对应库文件的过程,其中这个rc表示的是replace和create的意思,表示的是如果不存在就创建,存在就替换,总之这样就可以形成一个完整的.a文件
下面要做的就是生成一个库文件,库文件的生成也能放到Makefile中来写,具体的书写过程如下
# 库的名字是mymath,是个静态库
static-lib=libmymath.a
# 生成库需要Add.o Sub.o Mul.o Div.o,实现方式是ar指令
$(static-lib):Add.o Sub.o Mul.o Div.o
ar -rc $@ $^
# 生成.o文件需要把.c文件按照下面的gcc编译选项一个一个生成($<)
%.o:%.c
gcc -c $<
# 建库
.PHONY:output
output:
mkdir -p mymath_lib/include
mkdir -p mymath_lib/lib
cp -f *.h mymath_lib/include
cp -f *.a mymath_lib/lib
# 清空内容
.PHONY:clean
clean:
rm -rf *.o *.a mymath_lib
上述的完整Makefile进行推导解析:第一行表示这是一个静态库,静态库的名字叫做mymath,前面的lib和后面的.a都是前缀和后缀,静态库真正的名字叫做mymath,而后面对于这个静态库的生成方式有了一个定义,静态库的生成依赖的是后面的四个.o文件,而具体的生成方式是ar指令,那么此时Makefile就会进行推导,Makefile现在需要.o文件,但是现在没有,所以在后面就提到了生成.o文件的过程,是利用.c文件来生成的,这样就完成了Makefile的过程,后面的两个操作就是建库和清除的过程
执行结果如下:
这样就完成了一个静态库,那么接下来要进入的问题是,库的使用问题
静态库的使用
进入这个话题,就意味着现在我们已经有了静态库,但是这个库怎么用呢?
朴素做法
现在已经有了库,别人给我提供了这些方法,我该如何使用?所以就用到了这些头文件提供的函数,但是现在如果直接编译会发现根本编译不过去,说明就现在而言,还是不可行的
原因在于什么呢?从报错信息来看,找不到这里对应的头文件,原因在于这些库文件都是被保护起来的,现在在本地编写之后的代码是无法找到对应的内容,说明现在还得想办法把这些库文件都让编译工具能够找见才可以,那么就把头文件都放到代码所在的目录中,再进行编译:
此时报错信息是,没有定义,说明现在已经找到对应的头文件了,但是没有找到定义头文件的地方,这个就叫做链接报错,那我写的这个库为什么用不了呢?
第三方库
对于我们自己写的库函数,都叫做第三方库,而gcc不认识第三方库,哪怕是就在当前路径下,也依旧不认识这个第三方库,因此就引出了要链接库的概念,所以就要引出一个选项,大I
-I 选项表示的意义是link,也就是链接指定的一个库,也就是说告诉编译器,你在进行编译的时候要使用这个库,所以执行下面的指令
gcc test.c -I mymath_lib/include/ -l mymath -L mymath_lib/lib
上面这一串是很长的指令,但是不急,一点一点的分析
首先是,要进行编译的对象是test.c,后面的这个选项表示的是新增头文件的搜索路径,后面紧跟着的就是头文件的搜索路径,而后面的小l表示的是指明链接的库名称,而大L表示的是新增库文件的搜索路径,基于上面这么一长串的选项,就能最终编译出来我们想要的结果,事实上也确实生成了,这说明我们的静态库已经使用成功了
之前我们使用的C标准库从来不需要指定,而此时为什么这里就需要指定了呢?因为这里我们自己实现的叫做第三方库,而gcc是专门用来处理C语言的编译工具,所以在进行编译的时候会直接到指定的路径下去寻找,gcc已经认识了C语言提供的官方库,而我们自己实现的第三方库它并不认识,即使看见了也不认识,需要我们主动的为gcc和自己写的库建立起合适的联系,才能让他们之间认识,编译器才能进行工作编译链接等等的后续操作,最终生成一个可执行程序
因此得出的结论是,未来我们把我们写的静态库提供给别人去使用,只需要把对应的.h头文件和对应的.a文件交给别人就够了,其中这个.a文件就是我们前面所说的.o文件的集合
那么下一个问题是,当使用ldd指令去查看依赖关系的时候,却发现一个问题
问题是,我们生成的这个a.out并不依赖我们写的库文件,这是因为在默认的情况下,可执行程序都是动态链接,因此ldd指令只能查询动态库,而静态库在编译期间就已经被拷贝到可执行程序当中了,因此也就查不到对应的信息,静态库是无法检查的
这里引出一个结论:gcc默认采取的是动态链接,但是对于个别库来说,如果你只提供.a的方式,那编译器也无能为力,只会把内容局部性的作为静态链接,而其他库则采取的是正常的的动态链接,如果带有 -static选项,那就必须要采取静态链接的方式了
所以说,在使用gcc进行编译的时候,如果这个程序依赖10个库,那么gcc就会尽量的把这10个库对应的.so文件都拿到,但是如果没有动态库也没关系,还可以去拿静态库
小结
加入现在需要某个库,我们从网上去下载,得到了库,下一步应该安装库,那如何安装库?实际上就是把对应的头文件和库文件都安装到系统当中,怎么安装到系统?本质上就是把对应的文件安装到usr路径下的include路径和lib路径下,所以说,安装的本质,就是把头文件和库文件分别拷贝到系统的指定路径下,只要拷贝到gcc的默认路径下,那么gcc在进行搜索的过程就不是问题
动态库的制作
关于动态库如何制作呢?其实也和静态库类似,从原理上将和静态库都相同,都是在源文件编译成.o文件后,给这些个文件进行打包,就形成了动态库,区别是,在形成对应的.o文件时,需要带上一个fPIC的选项,这个选项的意思是与位置无关码,至于这个是什么意思在后续会有讲解,这里只需要知道是这样的原理即可,具体原因主要是因为,动态库本身没有把内容拷贝到可执行程序当中去,因此动态库和可执行程序之间只是地址方面的关联,因此使用了动态链接后,只是告诉了可执行程序,你所需要的内容在哪里,在哪一个文件的什么位置,你需要的时候自己去找就可以,那么这个过程就叫做动态链接,所以在使用的时候只需要带上一个fPIC就可以了,之后再对生成的.o文件进行打包,使用的命令还是gcc命令,生成一个.so的文件,但是要带上-shared选项,表示的这个文件我想要生成的是一个共享库,也叫做是动态库
不管是在Linux中还是Windows中也好,动态库是比较重要的,形成动态库不需要额外的工具,只需要gcc就可以帮助我们完成这个过程,从这个角度也能看出,形成动态库的方法直接内置到了编辑中,但是静态库没有做出对应的内置,这也就说明动态库的重要性,那么下面对于Makefile进行对应的改造,生成我们所需要的动态库:
# 库的名字是mymath,并且是个动态库
dy-lib=libmymath.so
# 动态库的生成方式是用gcc,带上编译选项,直接编译就可以
$(dy-lib):Add.o Div.o Mul.o Sub.o
gcc -shared -o $@ $^
# 生成动态库所需要的.o文件需要依赖于.c文件生成,并且也需要带上特殊选项,表示的是与位置无关码
%.o:%.c
gcc -fPIC -c $<
# 整体将生成的内容进行打包
.PHONY:output
output:
mkdir -p mymath_lib_so/include
mkdir -p mymath_lib_so/lib
cp -f *.h mymath_lib_so/include
cp -f *.so mymath_lib_so/lib
# 对部分内容做出清理
.PHONY:clean
clean:
rm -rf *.o *.so mymath_lib_so
动态库的使用
如果想对于这个动态库把它安装到系统中,那么就需要放到指定的路径下,那么现在我们先不对于它做出任何操作,只是和静态库一样来尝试编译它,结果是:
事实上,用静态库的编译方式来对动态库进行编译,也成功了,说明到现在为止和静态库比起来没有任何区别,那么接下来接续:
在运行程序的过程中失败了,不过这也是可以预见的,因为静态库相当于直接把内容拷贝进去了,而动态库只是告诉你该去哪找,而在运行的时候,程序并不知道去哪找,所以找不见,这样的结果也是意料之内的
动态库和你的可执行程序是分离的,是两个文件,当执行程序的时候,程序需要被加载到内存,而库中的文件也要能够被系统找到,也是要加载到内存中,所以说,动态链接的程序,程序和库文件是分开的,在进程进行加载的过程中,程序库也要被找到并且加载,只有程序库被加载了,才能跑的起来,动态链接非常依赖动态库
那么如何能找到对应的内容呢?下面讨论的就是这个问题
动态库如何找到内容?
1. 直接安装到系统中
这个是简单粗暴的方法,当然也是简单可行的,看下面的操作
把头文件放到系统中
把库文件放到系统中
此时运行程序,就可以运行起来了,因为进程在运行的过程中可以在默认路径下找到我们所需要的内容,就能把这些内容加载到内存中供进程使用,调度等等
如果把对应的文件从对应的库中删除,那么就不能再使用了:
2. 软链接的方式
建立链接后,再进行编译运行,也能正常运行出结果
说明这也是可行的,同时查看ldd情况,发现确实是存在链接情况
现在解除链接,链接断开也就无法运行了
3. 通过环境变量的方式
这个方式也很好理解,默认的寻找方式是到lib64下去寻找,然而在这里可以通过修改环境变量,使得环境变量中新增一个配置变量,这样就能继续寻找了,具体操作过程如下:
但是环境变量的修改是临时的,这样的修改只在当次生效,当下次重新登陆就不存在了,这是因为环境变量在每次登陆时,都会由配置文件对其进行修饰,所以最根本的方法,其实是修改配置文件,这样就能每次都修改成功环境变量
4. 修改配置文件
在Linux中,动态库的配置文件的位置在 /etc/ld.so.conf.d/
那么修改的原理就是在这里新增一个文件,在文件中写入我们需要的地址就可以了!
具体操作展示如下:
所以往后你自己需要使用动态库,不管是用别人的还是你自己写的库文件,如果运行是找不到,那么这里就有四种做法
小结
如果同时同一组方法动静态库同时被提供,那么默认使用的是动态库,并且同一组库会提供动静态两种方式,gcc默认使用的是动态库,如果想使用静态库,就带上-static选项
Linux:动态库的加载原理和与进程的知识整合
海绵宝宝de派小星 于 2024-01-25 11:30:02 发布
本篇将要讨论的内容是,动态库的底层原理和与进程相结合构建出一份完整的调用网,进而对于进程和动态库有一个更加清楚的认知
动态库加载
基于前面的两个问题做出的一个原理剖析
静态库我们就不考虑加载了,主要原因是因为静态库本身就已经被加载到了程序的内部,所以没有过多的意义,那么动态库加载有什么意义呢?
在使用 gcc 进行编译的时候,使用的选项是 - c,然后就会形成.o 文件,将这样的文件打包就形成了静态库,动态库也是这样的原理,只是在这之前多了一个叫做 fPIC 的选项,这个东西叫做与位置无关码,那么什么是与位置无关码?该如何理解呢?
库和程序都要加载
在了解它之前,先要清楚的概念是,现如今形成的一般的可执行程序的格式叫做 elf 文件,也就是说给了一份源代码,经过编译后会形成一个 elf 格式,生成的这个二进制是有规则的,格式是 elf,而对于可执行程序中的 elf 中会存在很多很多的内容,比如包括有代码区,全局数据区,只读数据区等等,同时也会生成一张表,在这张表上会记录的是函数的具体位置,我在这里调用的这个函数所在的位置是某个.so 库里面的某个地址,这样就把可执行程序中用到的全部方法都列到了这张表上,最终达成的效果是,把每一个库里面所用到的方法的地址都填进来,使得最终可执行程序和库中的特定方法的地址产生了关联,这样的过程就叫做动态链接
在经过了动态链接后,就要进行加载,也就是把可执行程序加载到内存中,但是这不够,前面也说到了,库函数同样需要加载到内存中,现在的可执行程序中只有库函数的地址,但是却没有库函数的视线,所以想要让程序真正运行起来,需要的就是把程序所依赖的函数库也要加载进来,虽然可能并不是要立刻加载进来,但是当执行到这个代码语句的时候,这个库必须存在可以让进程调用这个函数
在调用的过程时,跳转到库内的对应位置,调用后再返回到原来的位置之后就可以继续执行了,因此我们说,动态链接要加载的不止是自己,与动态链接关联的库也要全部加载,如果我们要链接的库本身不存在,就会直接报错,在程序加载期间,加载器就会报错说不存在
可执行程序的地址问题
可执行程序在编译形成可执行文件后,但是还没有加载到内存中,对于这个单独的文件,它有地址吗?答案是有的,这是因为当程序被编译之后,所有的函数名变量名等等内容,都不再会存在,取而代之的是一个一个的地址,从汇编代码的角度来讲,每一步的跳转背后都是一个一个的地址,比如当前在函数的内部定义了一个临时变量,这个临时变量是在栈区存在的,但是此时可执行程序中可并没有栈,栈是在程序加载到内存中运行的时候才有栈区的概念,但是不影响,这个变量是在函数的内部形成的,这也就意味着在形成变量的时候,是通过寄存器为出发点,再加上偏移量,就可以进行访问,所以最终函数名变量名都是地址了
在 C/C++ 程序中,当调用取地址操作的时候,实际上是在打印的时候,地址数字直接把取地址变量名替换掉了,所以就能看到对应的地址了,包括函数也是这样,所有的函数都是没有函数名的,只有一个二进制的代码块,只要找到代码块的位置,就能从上往下进行执行了,最后执行到 return 语句就返回了
地址问题
上面这个模块引出的观点是,程序在编译好,没有被加载到内存中的这个独立的过程中,实际上在内部就已经有地址了,只是这个地址需要考虑到另外一个问题,就是在代码中是如何对于各个变量函数进行编址,也就是说,在编译的过程中是如何对这些内容编出对应的地址的呢?理论依据就前面所说的虚拟地址空间的概念
虚拟地址空间不仅仅是操作系统的一种映射技术,更重要的是,虚拟地址空间是一套标准,操作系统在内部要为进程创建地址,代码区,堆区栈区等等内容,而在编译可执行程序的时候,可以按照虚拟地址空间的方式来对可执行程序进行编译,所以说在形成的代码区域的对应位置就会有各种各样的区,这样就完成了编址的目的,未来在对这部分内容进行加载的时候,就相当于直接按照内存进行加载,内存是这样的,磁盘也是这样的,加载的时候就更容易进行模块化的对应过程,因此这个部分想要输出的观点是,虚拟地址空间,不仅仅是操作系统里面的概念,更是在编译器编译的时候,也要按照这样的规则来编译可执行程序,这样才能在加载的时候,进行从磁盘文件到内存的一种映射
因此基于上述的原理,在可执行程序进行编制的时候,函数对应的长度和起始地址都是已经确定好的,对应的区间起始地址和长度也都是确定好的,这些地址都是在加载到内存之前就已经全部确认好的,得出的结论是,我们所有的可执行程序在进行还没有加载到内存的时候,我们的代码和数据其实已经具备了虚拟地址空间这样的概念
逻辑地址和平坦模式
上述的可执行程序已经有了虚拟地址,那么对应在磁盘当中也应该有同样的一套地址,只不过在磁盘中不叫做虚拟地址,名字叫做逻辑地址,而逻辑地址其实可以理解为是相对地址,所谓相对地址就是基地址和偏移量组合起来的概念,那么在这里所说的代码和数据都可以借助这样的原理,在编译的时候都写好对应的位置,就能进行快速的跳转,把内容放置到磁盘中,虽然这个程序还没有被用户所执行,但是这个程序基本上应该是遵循什么样的逻辑思路已经被确定下来了
如何理解逻辑地址?其实最简单的一种方法,就是假定基地址是 0,那么对于 32 位的机器来说,它的偏移量的取值范围就是从 [0,FFFFFFFF],这种起始偏移量为零的可执行程序的编址方式,在 Linux 中就叫做平坦模式,所以这里我们就引出了平坦模式的概念
所以,程序是有代码区和数据区的,本质上就是规定代码区的起始地址是什么,偏移量是多少,未来代码区的起始地址就会放到寄存器中,数据区的起始地址是多少,偏移量是多少,每一个区域都会形成一个段,每一个数据段里面的起始地址和偏移量都会采用这样的方式来进行定位,最初的 Linux 使用了虚拟地址,所以就规定了基地址就是零,偏移量是多少是根据未来的不同情况来决定
不过对于现在来说,基本上逻辑地址和虚拟地址的概念没什么太大的区别,对于一个可执行程序,在磁盘中所使用的地址叫做逻辑地址,只不过这个逻辑地址采用的是起始地址为 0,偏移量是从 0 到全 F 的这样的一种方式进行编址,这个就叫做逻辑地址
绝对编址和相对编址
计算机在对程序进行编址的过程中,会有上面的两套编址方式,这两种编址方式其实就是参考点的选择问题,也很好理解,这里不再过多赘述,想得出的结论是,如果采用绝对编址的方式,当一个模块发生了变动,其他的所有模块都会发生变动,但是如果采取的是相对编址,不管相对于参考的内容如何进行改变,区域和区域之间的地址不会有任何变化,只需要在对应的位置加上所谓的偏移量就足够了,这就是想要输出的核心观点
与位置无关码
通过上述的这一系列过程,就引出了与位置无关码的概念,说白了就是与位置无关,采用的是函数在库中的起始偏移量是多少,未来这个库在内存中的什么位置加载到对应的位置,函数的地址都不会改变,这就是与位置无关
小结
上面输出的几个重要的观点总结如下
第一个是,在编译形成的 elf 格式的可执行程序,在 Linux 中如果采取的是动态链接的方式将程序和库里面的指定内容产生关联,那么在未来进行加载的时候,程序要加载,库也要加载
第二个是,如果程序没有被加载,里面会有地址吗?答案是有的,因为这样的地址实际上是在计算机编译好之后就已经存在的,根据虚拟地址空间的方式做好了一定的编址,编址结束后,代码后就不再会存在任何变量名符号等等内容,取而代之的是从 0 到全 F 的这样的编址形成的可执行程序,因此我们说,当程序编译好之后,对于函数的调用就转换成了对于地址的调用,因此在汇编语言中可以看到,实际上对于函数等等的调用都会转换成的是 call 一个地址来表示函数的调用
因此从上面的结论是,虚拟地址空间不仅仅是指导操作系统进行这样的设计,更重要的是,虚拟地址空间也是当代编译器必须支持的内容,如果没有提前建立好虚拟地址空间,那么在程序中如何对于这些空间建立映射,更怎么使用呢?因此,在进行编译的时候,就已经按照对应的地址空间的方式来进行编译了,这样的模式就叫做平坦模式,所谓平坦模式就是基础量是 0,偏移量是 0 到全 F,这样的模式在 Linux 下就叫做平坦模式
第三个是,绝对编址和相对编址,这个其实很好理解,这里不再多说,只是想提及的是,在对于一份代码进行编址的过程中,可以采取绝对编址,也可以采取相对编址,举两个最典型的特征是,例如在可执行程序中的编址就是绝对编址,也就是说在可执行程序中的地址都是已经提前确定好的,而在可执行程序调用库函数的过程中采取的就是相对编址,这样可以根据被调用函数在库中的相对偏移量来更快的确认地址来进行函数的调用,进而提高加载的效率来满足我们的各种各样的需求
动态库的加载与进程相结合
下面要提到的内容会和前面的知识向整合,我们上面这么大的篇幅谈的内容都是动态库的加载,那么加载的目的地是哪里?答案是操作系统,而加载的目的就是因为有进程的存在,进程的调用才会产生上面的这些内容,因此本篇总结的核心内容就在于此,总结的核心内容就是关于上面的这一系列过程是如何与进程相结合,如何供进程使用的
动态库的加载
首先,画出下面的逻辑图,从而引出要讨论的主题:
上图引出了我们要探讨的第一个问题,就是动态库是如何与虚拟以及物理内存,以及 PCB 建立关系的
先理解一下上图要表达的一个过程,顺便进行一些知识整合:当用户要加载一个进程时,操作系统就会为进程创建一个 task_struct,并且把程序加载到内存中,并且会创建对应的虚拟地址空间用来维护各个区域,最后再经过页表将数据进行对应,这是可以理解的,也是前面已经提及到的内容
那么现在新增的内容是,进程中会调用一些函数,这些函数会与多个动态库有联系,而我们知道,在可执行程序采用动态链接进行链接库的时候,会想办法让可执行程序与库建立联系,其实也就是存储了对应的地址,在动态库内有关于各个函数的偏移量,这样借助库的绝对地址以及函数相对于库的相对地址,就能找到对应的函数,而前面已经知道的结论是,在进行加载的过程中,不仅可执行程序需要加载,库也需要加载,但是库并不是立刻就被加载,而是在它需要被调用的时候才会被加载到内存中,加载之后,在寻址执行正文代码的过程中,不仅要找到正文的代码,也要找到库对应的代码,才能进行合适的调用,因此会把动态库加载到共享区中
动态库会被加载到堆和栈之间的这一块区域,也被叫做共享区,之后也会在页表中和物理地址建立对应的联系,库被加载后就可以被进程所用了,那共享库被使用的时候,一定会被加载到一个固定的位置吗?显然不是,因为在可执行程序进行加载的过程中可能会调用很多很多的库,并不一定会被映射到对应的位置,而可执行程序的源代码也会通过一些源码直接打开一些库把内容加载进来,然后进行运行库中的方法,这样的话就相当于通过编译链接本身就已经指明了要调用的一些库,所以想要加载到固定的位置是不太可能也没有必要的,更何况还可能在加载的过程中去移出一些不再会使用的库,因此让库加载到对应的地方这件事,做不到
所以最终的设计模式是,库被加载之后,被映射到了指定使用了该库的进程的地址空间中的共享区部分,最终的效果是让库在共享区中的任意位置,都可以正确运行,这样的理论前提就是页表的存在,不管动态库最终被加载到什么位置都不重要,只要有页表的存在,可以和物理地址之间建立起联系就足够了,当然前提是库加载到内存中之后位置不能发生改变
在前面的内容中对于地址空间有这样的理解,CPU 是最后执行代码的执行者,它在执行进程当中代码的过程时会通过地址空间找到代码的正文部分,再通过正文借助页表,找到可执行程序,把指令和地址等等都读到 CPU 当中,所以 CPU 访问的大多都是虚拟地址
库函数的加载
下面要讨论的内容是关于库中实现的函数是如何进行加载的,具体理解如下
要清楚的是,库函数在库中存储是以相对编址的形式存在的,那么可执行程序加载到内存中,其中一部分代码加载到内存中了,在代码区中此时也经过页表的映射建立好了联系,加载器要加载的不仅仅是可执行程序,它会进行判断,发现需要加载的内容还有部分库,也会一并加载进来,加载的地方就在共享区中,并且库的位置可以随意加载,但是加载后库的位置必须固定,所以此时库加载进来库的起始位置就确定了,于是在可执行程序中就会把库的符号都替换成对应的地址,在上图中也能看到,对应的地址会在库加载到内存后发生变换,此时的正文代码中存储的就是库的地址,而不是库的名称,而执行到这个代码的时候,会发现这个代码需要用到库中的某个方法,那么调用的根据就是相对编址
一种理解的方式是,在进行加载的时候,会先把库加载到内存中,此时库的位置就已经确定了,加载的过程会读取对应的符号表,于是就可以把库的名称全部替换成地址和对应的偏移量,而找对应库中的方法也可以借助这个相对偏移量来找,从这里也能理解为什么加载库的地址可以随意加载,只要固定就可以,因为进程不关注你在哪加载的,它只关注的是库的方法该从哪获取,只要这个库的位置固定了,那么借助库的位置找到函数的方法地址也是水到渠成的事,这也就是在进行编址的时候要带上 fPIC 选项的原因,这样的编址得出的结果是与位置无关的,因为存储的是相对位置
从上图中可以发现一点,函数或者其他内容进行的跳转,都是在地址空间当中进行的跳转,例如可能会在正文代码中存在很多很多的函数调用,会在自己的地址空间内不停地调用和返回,其次的一个结论是,当动态库映射到了地址空间中后,调用库函数的本质就是在地址空间内进行函数的跳转,和对应调用普通函数是没有区别的,顶多是调用距离的问题,这对于地址空间来说是没有区别的
其他程序加载问题
下面的问题是,当其他程序进行也进行使用的时候,这些库还需要加载吗?答案肯定是否定的,当后续启动一些程序的时候,这些库可能早就被加载到物理内存中去了,此时只需要把程序加载到内存中,构建 PCB,再对应构建出页表,就能把库直接映射到共享区,然后再继续其他的内容就可以了,这个就叫做动态链接
谈谈共享的含义
为什么叫做共享库呢?假设现在操作系统中存在几十个,几百个程序都和某个共享库有关系,但是在物理内存中只会存在一个动态库,所以叫做共享库,它强调的内容是,在程序进行加载的时候都是直接把库映射到自己的共享区就可以了,而静态库就失去了这个能力,它需要一份一份的拷贝到对应的函数调用处,所以也就会浪费空间,那么动态库的本质就是把整个系统中所有经常使用的代码都放到一起,这样不必使用很多份,有效的节省内存空间
实现轮转
下面就是最终结果,一个程序是如何转起来的呢?
上图展示的就是整个逻辑过程,CPU 当中存在一个指令寄存器,当它要执行指令的时候,其实就是把正文部分的代码直接读取到自己的指令寄存器中,然后由 CPU 去读取指令,分析指令,读取指令,而在前面关于虚拟地址空间的概念中提及到,程序在编译的时候虚拟地址就已经建立好了,也就是说在磁盘当中形成的这个可执行程序中就已经有了基本的地址,此时形成的地址就叫做逻辑地址,此时将这个可执行程序加载到内存中,内存中和磁盘中的文件形式和地址都是完全不变的
可执行程序的代码段里面加载内存的时候,就相当于把对应的地址加载到内存中,经过页表映射到对应的位置,未来 CPU 在执行的时候就可以找到对应的地址,因此,可执行程序加载到内存中是一定要占据物理内存的,有自己的物理地址,才能建立映射,而未来在使用时的任意的方法以及调用,使用的都是提前建立好的这一套地址,物理内存放的位置可以变更,但是实际的逻辑地址是不可以变更的,未来也只是在此基础上用页表进行的映射,仅此而已
那么也就是说,当可执行程序加载进来之后,页表的右侧是可以填的,因为已经有了对应的物理地址,物理地址已经确定了,更重要的一点是,形成的可执行程序的符号表会记录下来入口地址,也就是 entry,用来提供给操作系统读取,说白了就是告诉操作系统,你应该从这里开始这个程序了!那函数的入口地址有了,那么就可以在建立页表时候建立 main 函数与其所在区域的映射关系
这样,程序就能跑起来了,一旦加载就有了物理地址,而在刚开始运行的时候,对应整个程序的虚拟起始地址 main 函数的地址也就知道,那么最开始就能在页表中建立对应的映射关系,那么 CPU 在执行这个程序的时候,只需要把 main 函数的地址加载到指令寄存器中,指令寄存器拿到的就是虚拟地址,指令寄存器之后会找到对应的进程的地址空间,找到页表,就能进行对应的转换,转换后就能找到对应的物理地址,读取到对应的第一条指令,假设现在该读取到的是一个函数的指令,那么就要 call 对应的地址,但是也问题不大,起始地址都有了,剩下的虚拟地址都是连续的,一个一个的向下执行,将对应的地址加载到 CPU 中,CPU 就能对应的执行函数跳转,CPU 得到的数据从始至终都是虚拟地址,它想要获取物理地址也再简单不过,借助页表就可以完美找到
小结
结论就是,编译器形成的可执行程序的地址全部都是虚拟地址,加载到内存的时候编址不变,虚拟地址的入口地址也被记录下来,于是就可以在虚拟地址和物理地址之间构建映射关系,并且读取到 CPU 的寄存器中,CPU 在执行代码的过程中,直接从虚拟地址映射到逻辑地址,只有在物理地址才能找到代码的真实指令,之后会在虚拟地址的空间中逐条语句的向后进行,当需要 call 命令就进行对应的函数跳转,跳转到对应的位置进行读取对应的实现方法即可
当可执行程序被编译形成的时候,里面已经有了绝对编址形成的虚拟地址,加载的时候会把文件加载到物理内存中,在物理内存中找到对应的具体地址,这样就固定了,而一个程序加载到内存中就意味着它既有虚拟地址,又有物理地址,每一个代码语句和变量都是拥有的,虚拟地址是方便操作系统去寻找,而物理地址方便 CPU 去读取指令使用,当程序加载进来之后,页表从虚拟地址到物理地址就全部建立起来了
对于页表的建立,可以简单理解为,当可执行程序加载到内存中之后,页表的左边存储的是虚拟地址,页表的右边存储的是物理地址,而虚拟地址我们已经有了,那么就都填充到页表的左边,物理内存一旦加载了就有对应的物理地址,那么就存储在页表的右边,那么这样的映射关系就已经完美的建立完毕了,页表也正是填充完毕,可以供操作系统随时调度查找了
页表建立之后,会提供一个入口地址,也就是 main 函数的地址,通过虚拟地址转换物理地址就可以找到对应的起始地址,执行结束对应的雨具之后,读取下一条指令,读取的也是虚拟地址,那么再经过页表就能找到物理地址,读取指令,然后继续下一条指令…
总结
CPU 内执行可执行程序,会做的就是虚拟地址到物理地址的转换,为什么?
原因就在于此,CPU 在访问代码和内存的时候,会做虚拟到物理的地址转换,如果 CPU 在读取正文没有被修饰的时候,读取到的就是物理地址,但是现在读取到的是虚拟地址,所以就要存在从虚拟地址向物理地址的转换,也就有了页表的意义
那问题是,为什么 CPU 不直接读取物理地址呢?因为 CPU 读取虚拟地址很方便,体现在去执行语句很方便,更是因为借助页表映射物理地址更加方便,程序加载到内存的时候页表就都创建好了,直接去映射就可以了
动态库总结
- 动态库在内存中只有一份,不像静态库大家自己用自己的
- 那可以把动态库统一管理起来,每次加载的时候分配一个虚拟地址 x,其他进程也要使用这个动态库的话,它的虚拟地址也填 x,这样就容易管理
- 如果不这么做,每个程序对动态库也写死,就无法确保动态库虚拟地址的一致,管理就麻烦
- 因为动态库的虚拟地址是加载才分配的,为了方便后续找到区分对应函数,就引入了偏移量的概念
- 动态库的更新替换更加方便,只需要更改对应的偏移关系就可以,不用修改程序代码(虚拟地址是第一次加载才分配的)
- 动态库跨平台的兼容性就更好
via:
-
Linux下的静态链接与动态链接 - Mr-xxx - 博客园
https://www.cnblogs.com/MrLiuZF/p/15037507.html -
Linux 环境下的动态库生成与使用-CSDN博客
https://blog.csdn.net/qq_56673429/article/details/124176976 -
详解Linux下静态库/动态库的生成和使用(含代码示例和操作流程)&&动态库和静态库的区别_生成静态库的过程及作用-CSDN博客
https://blog.csdn.net/weixin_47826078/article/details/120474883 -
Linux:动静态库的概念与制作使用-CSDN博客
https://zhaobohan.blog.csdn.net/article/details/135732541 -
Linux:动态库的加载原理和与进程的知识整合_linux应用动态加载-CSDN博客
https://blog.csdn.net/qq_73899585/article/details/135793004