Linux:8_库制作与原理
库制作与原理
一.什么是库
查看可执行程序依赖库:ldd(命令)
ldd的作用是查看一个可执行文件在运行时会依赖哪些动态库。 它不会运行程序,而是把可执行文件的共享库依赖关系打印出来。
- 基本用法
ldd <可执行文件>
例子:
$ ldd /bin/lslinux-vdso.so.1 (0x00007fff7f5d7000)libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f5b8a6b0000)libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5b8a2c0000)/lib64/ld-linux-x86-64.so.2 (0x00007f5b8a900000)
- libselinux.so.1、libc.so.6 就是这个程序需要的共享库。
=> /lib/x86_64-linux-gnu/...
表示库的实际路径。- 最后一行
/lib64/ld-linux-x86-64.so.2
是 动态链接器,所有 ELF 动态程序都会依赖它。
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始,因此库的存在意义非同寻常。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
- 静态库
.a[Linux]、.lib[windows]
- 动态库.
so[Linux]、.dll[windows]
- 动静态库不要包含main函数
- 只要用第三方库,不管在不在系统默认查找目录下,都要带上-l 库名
预备工作,准备好历史封装的libc代码,在任意新增"库文件"就是基础lO那节写的my_stdio项目(有些小差异,看这个就行)
二.静态库
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库。
- 所有的库(无论是动还是静),本质都是源文件对应的.o;
- 静态库本质,就是.o打了一个包
- .a静态库,本质是一种归档文件,不需要使用者解包,而用gcc/g++直接进行链接即可!
- 一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们的编译默认为动态链接库,只有在该库下找不到动态.so的时候才会采用同名静态库。我们也可以使用 gcc的
-static
强转设置链接静态库。
1. 静态库生成
- 静态库一般必须以lib作为开头,以.a作为结尾,中间部分是真正名字
# Makefile
libmystdio.a: my_stdio.o my_string.o@ar -rc $@ $^@echo "build $^ to $@ ... done"%.o: %.c@gcc -c $<@echo "compiling $< 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)
即:原始.a文件存在了,.o修改就替换.a中对应的.o,.o新建就在.a内部新建。
制作静态库:
czj@iv-ye46gvrx8gcva4hc07x0:my_stdio$ ar -rc 1ibmyc.a *.o #必须指定打包文件
t:
列出静态库中的文件v:verbose
详细信息
查看静态库的内容:
$ 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
2. 静态库使用
-
-L: 指定库路径
系统默认的搜索路径
-
头文件默认搜索路径
./ /usr/include/ /usr/local/include/
-
库文件默认搜索路径
/lib/ /lib64/ /usr/lib/ /usr/lib64/ /usr/local/lib/ /usr/local/lib64/
将头文件和库文件拷到系统默认路径下后,gcc只需要带上-i选项即可.
-
-
-I: 指定头文件搜索路径
-
-l: 指定库名
-
测试目标文件生成后,静态库删掉,程序照样可以运行
-
关于-static选项,稍后介绍
-
库文件名称和引入库的名称:去掉前缀lib ,去掉后缀.so , .a ,如: libc.so -> c
三.动态库
-
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
-
一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码
-
在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
-
**动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。**操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
1. 动态库生成
- 动态库一般必须以lib作为开头,以.so作为结尾(so后面可以带上版本),中间部分是真正名字
# Makefile
libmystdio.so: my_stdio.o my_string.o@gcc -o $@ $^ -shared@echo "build $^ to $@ ... done"%.o: %.c@gcc -fPIC -c $<@echo "compiling $< to $@ ... done".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
2. 动态库使用(使用和静态库一模一样)
- 结论
-
-static
gcc/g++默认使用动态库;
非得静态链接,只能-static,一旦-static就必须存在对应的静态库(否则报错);
如果只存在静态库,可执行程序,对于该库只能静态链接了(可以不加-static,只有动静态库都存在才需要). -
在linux系统下,默认情况安装的大部分库,默认都优先安装的是动态库!
-
库:应用程序=1:n;
-
vs不仅仅形成可执行程序,也能形成动静态库;
3. 库运行搜索路径
(1).问题
$ ldd a.outlinux-vdso.so.1 => (0x00007fff4d396000)libmystdio.so => not foundlibc.so.6 => /lib64/libc.so.6 (0x00007fa2aef30000)/lib64/ld-linux-x86-64.so.2 (0x00007fa2af2fe000)
去哪找动态库,只告诉了gcc!!!
系统!= gcc
(2).解决方案
①.拷贝.so
文件到系统共享库路径下, 一般指/usr/lib、/usr/local/lib、/lib64
或者开篇指明的库路径等
②.向系统共享库路径下建立同名软连接
③.更改环境变量: LD_LIBRARY_PATH
④.ldconfig方案:配置/ etc/ld.so.conf.d/
,ldconfig更新
ld:加载; so:动态库; conf:配置文件; d:目录
在/etc/ld.so.conf.d/
路径下创建一个任意名字以.con结尾的文件(sudo touch /etc/ld.so.conf.d/name.conf
),并将库所在的目录绝对路径记录到新建文件中,然后ldconfig刷新就可以找到动态库了。
[root@localhost linux]# cat /etc/ld.so.conf.d/bit.conf
/root/tools/linux
[root@localhost linux]# ldconfig // 要生效,这里要执行ldconfig,重新加载库搜索路径
四.使用外部库-课后尝试
我们现在没接触过太多的库,唯一接触过的就是C、C++标准库,这里我们可以推荐一个好玩的图形库:ncurses
// 安装
// Centos
$ sudo yum install -y ncurses-devel
// ubuntu
$ sudo apt install -y libncurses-dev
系统中其实有很多库,它们通常由一组互相关联的用来完成某项常见工作的函数构成。比如用来处理屏幕显示情况的函数(ncurses库)
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <ncurses.h>#define PROGRESS_BAR_WIDTH 30
#define BORDER_PADDING 2
// 窗口宽度 = 进度条宽 + 左右内边距*2 + 边框左右各1
#define WINDOW_WIDTH (PROGRESS_BAR_WIDTH + 2 * BORDER_PADDING + 2)
#define WINDOW_HEIGHT 5
#define PROGRESS_INCREMENT 3
#define DELAY 300000 // 微秒(300毫秒)int main(void) {initscr();cbreak();noecho();curs_set(FALSE);if (has_colors()) {start_color();// 已完成:绿字黑底init_pair(1, COLOR_GREEN, COLOR_BLACK);// 剩余:黑字红底(这样打印空格就会是红色背景)init_pair(2, COLOR_BLACK, COLOR_RED);}int max_y, max_x;getmaxyx(stdscr, max_y, max_x);int start_y = (max_y - WINDOW_HEIGHT) / 2;int start_x = (max_x - WINDOW_WIDTH) / 2;WINDOW *win = newwin(WINDOW_HEIGHT, WINDOW_WIDTH, start_y, start_x);box(win, 0, 0);wrefresh(win);int progress = 0;const int max_progress = PROGRESS_BAR_WIDTH;while (progress <= max_progress) {// 清屏并重画边框werase(win);box(win, 0, 0);int completed = progress;int remaining = max_progress - progress;int bar_x = BORDER_PADDING + 1; // 留出边框+内边距int bar_y = 2; // 竖直方向居中(窗口高5:边框上下各1,内容2~3行)// 已完成部分(绿色)if (has_colors()) wattron(win, COLOR_PAIR(1));for (int i = 0; i < completed; i++) {mvwaddch(win, bar_y, bar_x + i, '#');}if (has_colors()) wattroff(win, COLOR_PAIR(1));// 剩余部分(红色背景,用空格填充)if (has_colors()) wattron(win, COLOR_PAIR(2) | A_BOLD);for (int i = 0; i < remaining; i++) {mvwaddch(win, bar_y, bar_x + completed + i, ' ');}if (has_colors()) wattroff(win, COLOR_PAIR(2) | A_BOLD);// 百分比(底部居中)char percent_str[16];snprintf(percent_str, sizeof(percent_str), "%d%%", (progress * 100) / max_progress);int percent_x = (WINDOW_WIDTH - (int)strlen(percent_str)) / 2;mvwprintw(win, WINDOW_HEIGHT - 2, percent_x, "%s", percent_str);wrefresh(win);progress += PROGRESS_INCREMENT;usleep(DELAY);}delwin(win);endwin();return 0;
}
推荐一篇不错的使用指南:https://blog.csdn.net/bdn_nbd/article/details/134019142
五.目标文件
编译和链接这两个步骤,在Windows下被我们的IDE封装的很完美,我们一般都是一键构建非常方便,但一旦遇到错误的时候呢,尤其是链接相关的错误,很多人就束手无策了。在Linux下,我们之前也学习过如何通过gcc编译器来完成这一系列操作。
- o(linux)/.obj(windows)文件全称可重定向目标文件或可重定位目标文件
接下来我们深入探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。
先来回顾下什么是编译呢?编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器
代码。
比如:在一个源文件hello.c
里便简单输出"hello world!",并且调用一个run函数,而这个函数被定义在另一个原文件code.c
中。这里我们就可以调用gcc -c
来分别编译这两个原文件。
可以看到,在编译之后会生成两个扩展名为.o
的文件,它们被称作目标文件。要注意的是如果我们修改了一个原文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制的文件,文件的格式是ELF
,是对二进制代码的一种封装。
六.ELF文件
要理解编译链链接的细节,我们不得不了解一下ELF文件。
1. ELF文件分类
其实有以下四种文件其实都是ELF文件:
可重定位文件(Relocatable File)
:即 xxx.o 文件。包含适合于与其他目标文件链接来创建可执行文件或者共享目标文件的代码和数据。可执行文件(Executable File)
:即可执行程序。共享目标文件(Shared Object File)
:即 xxx.so文件。静态库文件也是ELF文件。内核转储(core dumps)
,存放当前进程的执行上下文,用于dump信号触发。
2. ELF文件组成
一个ELF文件由以下四部分组成:
ELF头(ELF header)
:描述文件的主要特性。其位于文件的开始位置,它的主要目的是定位文件的其他部分。- 程序头表(Program header table) :列举了所有有效的段(segments)和他们的属性。表里记着每个段的开始的位置和位移(offset)、长度,毕竟这些段,都是紧密的放在二进制文件中,需要段表的描述信息,才能把他们每个段分割开。
节头表(Section header table)
:包含对节(sections)的描述。节(Section )
:ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都存储在不同的节中,如代码节存储了可执行代码,数据节存储了全局变量和静态数据等。
最常见的节:
代码节(.text)
:用于保存机器指令,是程序的主要执行部分。数据节(.data)
:保存已初始化的全局变量和局部静态变量。
3. readelf
readelf的作用是分析 ELF 格式文件; 忠实地打印文件头部、段、节等底层结构。
- 基本语法
readelf [选项] <elf文件>
- 选项
选项 | 作用 |
---|---|
-h | 查看 ELF 头(ELF Header) |
-l | 查看程序头表 (Program Header Table) |
-S | 查看节头表 (Section Header Table) |
-s | 查看符号表 |
七.ELF从形成到加载轮廓
1. ELF形成可执行
- step-1:将多份C/C++ 源代码,翻译成为目标.o 文件
- step-2:将多份.o 文件section进行合并
注意:
- 实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库合并,此处不做过多追究,库也是.o文件一样.
2. ELF可执行文件加载
- 一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成segment
- 合并原则:相同属性,比如:可读,可写,可执行,需要加载时申请空间等.
- 这样,即便是不同的Section,在加载到内存中,可能会以segment的形式,加载到一起
- 很显然,这个合并工作也已经在形成ELF的时候,合并方式已经确定了,具体合并原则被记录在了ELF的程序头表(Program header table) 中
为什么要将section合并成为segment
- Section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果.text部分为4097字节,.init部分为512字节,那么它们将占用3个页面,而合并后,它们只需2个
页面。- 此外,操作系统在加载程序时,会将具有相同属性的section合并成一个大的segment,这样就可以实现不同的访问权限,从而优化内存管理和权限访问控制。
问: 为什么如果不合并4097字节多出来那一个自己占的1个页面剩下的空间不能给.init部分部分用
答: 操作系统按 Segment 粒度分配虚拟内存,页内空间不能跨 Segment 共用。
每个 Segment 会被mmap映射成一片连续的虚拟地址空间,并且要满足对齐要求(通常段的起始地址必须页对齐)。
换句话说:
- 内存分配单位是页
- 页只能属于某个 Segment
- 不同 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 符号表,就是源码里面那些函数名、变量名和代码的对应关系。.symtable是一个一个字符串表(即:字符串数组);有了它可执行程序就再也不用出现符号了,只需要记录起始偏移量.
如:char lable="helloworld\ofunc\olibc\Oa\0obj\o"
; -
.got.plt节
(全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节一起提供了对导入的共享库函数的访问入口,由动态链接器在运行时进行修改。对于GOT的理解,我们后面会说。- 使用 readelf 命令查看 .so 文件可以看到该节。
从执行视图
来看:
- 告诉操作系统哪些模块可以被加载进内存。
- 加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执行的。
我们可以在**ELF
头中找到文件的基本信息,以及可以看到ELF头是如何定位程序头表和节头表的。**例如我们查看下hello.o这个可重定位文件的主要信息:
对于ELF HEADER
这部分来说,我们只用知道其作用即可,它的主要目的是定位文件的其他部分。
八.理解连接与加载
1. 静态链接(链接时链接)
- 无论是自己的.o, 还是静态库中的.o,本质都是把.o文件进行链接的过程
- 所以:研究静态链接,本质就是研究.o是如何链接的
查看编译后的.o目标文件
objdump
objdump的作用是对文件进行反汇编
- 基本语法
objdump [选项] <文件>
- 选项
选项 作用 -d
反汇编 .text
节,显示机器码 → 汇编指令-S
反汇编时,显示源代码(如果带调试信息 -g
编译)
objdump -d
命令:将代码段(.text)进行反汇编查看hello.o
中的main
函数不认识printf
和run
函数
code.o
不认识printf
函数
我们可以看到这里的call指令,它们分别对应之前调用的printf和run函数,但是你会发现他们的跳转地址都被设成了0。那这是为什么呢?
其实就是在编译hello.c 的时候,编译器是完全不知道printf 和run 函数的存在的,比如他们位于内存的哪个区块,代码长什么样都是不知道的。因此,编辑器只能将这两个函数的跳转地址先暂时设为0。
这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码块(.data)中还存在一个重定位表(这个不用管),这张表将来在链接的时候,就会根据表里记录的地址将其修正。
- 注意:printf涉及到动态库,这里暂不做说明
整个过程
静态链接就是把库中的.o进行合并,和上述过程一样
所以链接其实就是将编译之后的所有目标文件连同用到的一些静态库运行时库组合,拼装成一个独立的可执行文件。其中就包括我们之前提到的地址修正,当所有模块组合在一起之后,链接器会根据我们的.o文件或者静态库中的重定位表找到那些需要被重定位的函数全局变量,从而修正它们的地址。这其实就是静态链接的过程。
所以,链接过程中会涉及到对.o中外部符号进行地址重定位。
2. ELF加载与进程地址空间
(1).虚拟地址/逻辑地址
问题:
- 一个ELF程序,在没有被加载到内存的时候,有没有地址呢?
- 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?
答案:
- 一个ELF程序,在没有被加载到内存的时候,本来就有地址,当代计算机工作的时候,都采用"平坦模式"进行工作。所以也要求ELF对自己的代码和数据进行统一编址,下面是objdump -S 反汇编之后的代码
最左侧的就是ELF的虚拟地址,其实,严格意义上应该叫做逻辑地址(起始地址+偏移量), 但是我们认为起始地址是0.也就是说,其实虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编址了. 在现代操作系统,虽然CPU还保留段寄存器,但它们的基址都被设置成0, 即:逻辑地址 = 0 + 偏移量(过去) = 偏移量(现在),这种平坦模式的编址我们称作线性地址
- 进程mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪里来的?从ELF各个segment来,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中的[start, end]等范围数据,另外在用详细地址,填充页表.虚拟地址空间不仅仅是进程看待内存的方式!磁盘上的可执行程序,代码和数据编址其实就是虚拟地址的统一编址!
所以:虚拟地址机制,不光光OS要支持,编译器也要支持.
(2).重新理解进程虚拟地址空间
ELF 在被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry字段中:
一张图说清楚
素材1
素材2
3. 动态链接与动态库加载(加载时链接)
(1).进程如何看到动态库
(2).进程间如何共享库的
(3).动态链接
①.概要
动态链接其实远比静态链接要常用得多。比如我们查看下hello
这个可执行程序依赖的动态库,会发现它就用到了一个c动态链接库:
这里的 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是直接加载的)。
- 这段话的意思是我们用到动态库的函数时,我们知道动态库加载到当前进程虚拟地址的位置,且知道原先动态库中的函数在动态库的绝对编址,即以0为起始地址的偏移量。
- 由此我们拿到了动态库在当前进程虚拟地址的动态库加载的起始地址和在动态库中该函数的偏移量,进而就可以使用该函数。
④.我们的程序,怎么和库具体映射起来的
- 动态库也是一个文件,要访问也是要被先加载,要加载也是要被打开的
- 让我们的进程找到动态库的本质:也是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中
一张图解释清楚
⑤.我们的程序,怎么进行库函数调用
-
库已经被我们映射到了当前进程的地址空间中
-
库的虚拟起始地址我们也已经知道了
-
库中每一个方法的偏移量地址我们也知道
-
所有:访问库中任意方法,只需要知道库的起始虚拟地址+方法偏移量即可定位库中的方法
-
而且:整个调用过程,是从代码区跳转到共享区,调用完毕在返回到代码区,整个过程完全在进程地址空间中进行的.
⑥.全局偏移量表GOT(global offset table)
注意:
- 也就是说,我们的程序运行之前,先把所有库加载并映射,所有库的起始虚拟地址都应该提前知道
- 然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置**(这个叫做加载地址重定位)**
- 等等,修改的是代码区?不是说代码区在进程中是只读的吗?怎么修改?能修改吗?
所以:动态链接采用的做法是在.data (可执行程序或者库自己)中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移表GOT,表中每一项都是本运行模块要引用的一个全局变量或函数的地址。
- 因为.data区域是可读写的,所以可以支持动态进行修改
- 由于代码段只读,我们不能直接修改代码段。但有了GOT表,代码便可以被所有进程共享。但在不同进程的地址空间中,各动态库的绝对地址、相对位置都不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
- 在单个.so下,由于GOT表与
.text
的相对位置是固定的,我们完全可以利用CPU的相对寻址来找到GOT表。 - 在调用函数的时候会首先查表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
- 这种方式实现的动态链接就被叫做
PIC 地址无关代码
。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这也是为什么之前我们给编译器指定-fPIC参数的原因,PIC=相对编址+GOT。
⑦.库间依赖(简单说明即可)
注意:
- 不仅仅有可执行程序调用库
- 库也会调用其他库!!库之间是有依赖的,如何做到库和库之间互相调用也是与地址无关的呢??
- 库中也有.GOT,和可执行一样!这也就是为什么大家为什么都是ELF的格式!
由于GOT表中的映射地址会在运行时去修改,我们可以通过gdb调试去观察GOT表的地址变化。在这里我们只用知道原理即可,有兴趣的同学可以参考:使用gdb调试GOT
- 由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫PLT(过程连接表(Procedure Linkage Table))。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被
使用到。
思路是:GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码/stup。在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
总而言之,动态链接实际上将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但绝对是物有所值的。因为动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。
- 解析依赖关系的时候,就是加载并完善互相之间的GOT表的过程.
(4).总结
- 静态链接的出现,提高了程序的模块化水平。对于一个大的项目,不同的人可以独立地测试和开发自己的模块。通过静态链接,生成最终的可执行文件。
- 我们知道静态链接会将编译产生的所有目标文件,和用到的各种库合并成一个独立的可执行文件,其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
- 而动态链接实际上将链接的整个过程推迟到了程序加载的时候。比如我们去运行一个程序,操作系统会首先将程序的数据代码连同它用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是无论加载到什么地方,都要映射到进程对应的地址空间,然后通过.GOT方式进行调用(运行重定位,也叫做动态地址重定位)。