hello~ 很高兴见到大家! 这次带来的是Linux系统中关于库制作与原理这部分的一些知识点,如果对你有所帮助的话,可否留下你宝贵的三连呢?
个 人 主 页 : 默|笙

文章目录
- 一、预备知识
-
- [1.1 指令集](#1.1 指令集)
- [1.2 _start函数](#1.2 _start函数)
- 二、静态链接的链接过程
- 三、动态链接的链接过程
-
- [3.1 动态库的加载和映射](#3.1 动态库的加载和映射)
- [3.2 如何进行库函数调用](#3.2 如何进行库函数调用)
- [3.3 全局偏移量表GOT](#3.3 全局偏移量表GOT)
一、预备知识
1.1 指令集
- CPU 具备专属的指令集。CPU 执行计算和操作的本质,是逐条解析并执行机器指令;早期程序员直接用二进制机器指令编程,过程极其繁琐且易出错,因此人们设计了汇编语言 ------ 为每一条机器指令匹配对应的助记符(如 mov、add),大幅降低编程难度;而 C 语言等高级语言,则是在汇编语言的基础上进一步抽象封装,让程序员能以更接近自然语言的方式编写代码,最终再通过编译器 / 汇编器将高级语言代码转换为 CPU 可执行的机器指令。不同的 CPU 具备的指令集也不同,所以安卓和苹果不兼容,本质是 CPU 不同。


- 可以使用 objdump -d 命令对可执行程序进行反汇编,查看其对应的汇编指令与机器码。CPU 执行的机器指令本质由指令长度、操作码(Opcode)和操作数(数据 / 地址)组成;在指令执行过程中,程序计数器(PC 指针)会自动更新 ------ 本条指令执行完毕后,PC指针会加上当前指令的长度,从而精准跳转到下一条指令的内存地址,保证指令流按顺序执行。
1.2 _start函数

- 对用户而言,编写程序时约定俗成的入口是 main 函数;但对 Linux 系统的编译器 / 内核来说,程序真正的启动入口是名为_start 的函数 ------ 内核加载可执行文件后,会先执行_start,再由_start 完成运行时环境初始化(如栈、参数、全局变量初始化),最终调用程序员编写的 main 函数。
_start函数作用:
- 设置堆栈,为程序创造一个初始的堆栈环境。
- 初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
- 动态链接:_start函数会调用动态链接器的代码来解析和加载程序所依赖的动态库(shared libraries)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确地映射到动态库中的实际地址。
- 动态库和静态库里面都是没有main函数的,也不能够有main函数。
二、静态链接的链接过程
- 静态链接的本质就是.o文件的连接过程,无论是静态库中的.o文件还是自己创建的.o文件,都是这样。那么静态链接究竟要干些什么呢?
- 这里我通过创建文件来模拟静态库链接的完整过程:准备 main.c 和 run.c 两个源文件,先将它们分别编译为 .o 目标文件,再对目标文件进行反汇编得到 .s 文件。可以把 run.o 直接看作一个 "人造静态库"------ 因为静态库本质就是多个 .o 目标文件的打包集合。
cpp
1 #include<stdio.h>
2 void run()
3 {
4 printf("running...\n");
5 }

- 这是 run.c 和 run.s 文件:run.c 中调用了标准库函数 printf,因此在对应的汇编代码(run.s)或反汇编结果中,会出现 callq 指令(用来调用函数),这行指令本应指向 printf 函数的执行地址,但实际显示的地址却全为 0。
cpp
1 #include<stdio.h>
2 void run();
3 int main()
4 {
5 printf("hello world!\n");
6 run();
7 return 0;
8 }

- 是 main.c 及其对应的汇编文件 main.s:能清晰看到,代码中两个函数调用指令(callq)后的目标地址均为0, 这是因为编译阶段编译器仅为外部函数预留了地址占位符,无法确定函数的最终内存地址,毕竟 .o 文件的生成时不需要其他文件,所以它当然不知道自己所调用的函数的地址。


- main.o 和 run.o 这两个 ELF 文件被合并成为了一个文件,查阅符号表(命令 readelf -s(小S) ELF文件名)可以看到两个函数被放在了同一个段13也就是代码段 text 里面。

- 对比反汇编结果可清晰看到:在 .o 目标文件的反汇编内容中,因外部函数符号未解析而地址尚不明确的函数调用(表现为 0 地址 / 占位地址),在链接生成可执行文件 target 后,其对应的反汇编文件 target.s 中,这些函数调用指令已指向最终的虚拟内存地址。这就是静态链接所要干的事情。

- 总结,静态链接其实就是干了两件事情:一是将所有用到的函数指令都拷贝到可执行文件的代码段(.text 段)中以及段合并,二是为这些函数重新分配唯一的虚拟内存地址(进行统一编址),最后把原本目标函数调用处的 0 占位地址,替换成重新分配好的最终地址。这也是为什么.o 文件被称作可重定位目标文件,因为在静态链接过程中,它内部的函数 / 数据地址需要经过链接器处理后重新分配,才能确定最终的虚拟内存地址。
- 静态库没有加载的过程,也就是不用加载进入内存,因为它在链接的时候代码就已经被加载进入可执行文件里面了。
三、动态链接的链接过程
3.1 动态库的加载和映射
- 进程创建的时候,是会先创建PCB然后再加载程序;那么动态链接的程序,是先加载程序还是先加载库呢?先加载库 。



- 动态库加载时会被映射到共享区,也就是栈区和堆区中间的那个区。动态库在物理内存里面只会存在一份,一份可以映射到多个进程虚拟地址空间的共享区,这也是为什么它叫做共享区的核心原因。

- 但是OS它怎么知道这些库已经被加载到内存里面了?而且有这么多的库被加载到内存里面,OS要不要管理这些被加载进来的库呢?当然是要的。老规矩,先描述再组织,它也有对应的结构体对库进行管理。由于过于复杂且偏离主题,这里就不细讲这个结构体了。
3.2 如何进行库函数调用
- 动态库为了支持被随时加载并映射到任意进程的任意内存位置,会对库内的函数 / 数据进行统一编址,也就是以 0 为基址向后编址,确保动态库加载到任意内存地址时都能正常运行。
- 这样只要知道了库文件在内存里面的起始位置,再根据文件内部各个函数 / 数据的偏移量就能够找到对应的函数进行调用。也就是:库的起始地址 + 方法偏移量 = 函数在内存中的实际调用地址。所有库的起始地址OS通过管理库的结构体就都能知道,而动态库的偏移量就储存在库的符号表中。
- 而且整个调用过程,是从代码区跳到共享区,调用完毕再回到代码区,整个过程都是在进程虚拟地址空间进行的。
- 静态链接会在链接阶段就完成函数调用地址的重定位,而动态链接则是将这一地址重定位过程推迟到程序加载进入内存后(运行时)才执行。
- 但此时新的问题出现了:程序的代码段会被操作系统映射到内存的只读代码区,代码区不允许修改,那么我们该如何动态修改函数调用对应的目标地址(比如动态库函数的真实地址)呢?这就要谈到全局偏移量表GOT了。
3.3 全局偏移量表GOT

- 动态链接在数据段.data(可读写)专门预留了一块空间,用来存储函数的跳转地址,它也被叫做全局偏移量表GOT,表中每一项都存放的是要用到的全局变量或动态库函数的最终内存地址。
- 所以call后面跟着的地址是 got 初始地址 + got 表中的偏移地址,说明调用的时候会首先查 got 表找到对应函数,而 got 表里面存储的就是对应函数的最终内存地址,它由库的起始地址 + 方法偏移量 = 函数在内存中的实际调用地址而来,然后根据这个最终地址进行跳转,到共享区完成对函数的调用,之后再回到代码区。
- 这种方式实现的动态链接对应的就是 PIC(位置无关代码):借助相对编址 + GOT 表的组合机制,动态库无需做任何修改,即可被加载到任意内存地址并正常运行,同时还能被多个进程共享。这也是我们编译动态库时,需要给编译器指定 -fPIC 参数的核心原因 ------PIC 的实现本质就是 "相对编址(库内偏移)+ GOT 表(动态绑定地址)"。
今天的分享就到此结束啦,如果对读者朋友们有所帮助的话,可否留下宝贵的三连呢~~
让我们共同努力, 一起走下去!