目录
7.3.3.6 全局偏移量表GOT(global offset table)
1. 什么是库
库是写好的现有的,成熟的,可以复用的代码。现实中每个程序都要依赖很多基础的底层库,不可能每个人的代码都从零开始。
本质上来说库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。库有两种:
静态库:.a文件 [Linux],.lib文件 [windows]
动态库:.so文件 [Linux],.dll文件 [windows]
2. 静态库
静态库(.a):本质就是对源文件对应的 .o 文件进行一个打包,打包成一个 .a 文件。程序在编译链接的时候把 .a 文件链接到可执行文件中,程序运行的时候将不再需要静态库。
静态库的命名:规定上以 lib 开头,以 .a 结尾,中间部分就是库的名字。
一个可执行程序可能用到许多的库,这些库运行有的是静态库,有的是动态库,而我们编译的时候默认为动态链接库,只有在该库找不到动态.so的时候才会采用同名静态库。我们也可以使用 gcc 的 -static 强转设置链接静态库。
2.1 静态库的制作
下面做一个实验,演示静态库的制作过程,先给出预备的使用的代码:
//mystdio.h
#pragma once
//定义C标准库缓冲区大小
#define MAX 1024
// 三种缓冲类型标志位
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2
//使用mFILE结构体模拟C标准库中的FILE结构体
typedef struct IO_FILE
{
int flag;
int fileno;
char outbuffer[MAX];
int size;
}mFILE;
// mfopen == fopen, mfwrite == fwrite, mfflush == fflush, mfclose == fclose
mFILE *mfopen(const char * filename, const char * mode);
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);
//mystring.h
#pragma once
int my_strlen(const char *s);
//mystdio.c
#include "mystdio.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
mFILE *mfopen(const char *filename, const char *mode)
{
int fd = -1;
if (strcmp(mode, "r") == 0)
{
fd = open(filename, O_RDONLY);
}
else if (strcmp(mode, "w") == 0)
{
fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
}
else if (strcmp(mode, "a") == 0)
{
fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
}
if (fd < 0) return NULL;
mFILE *mf = (mFILE*)malloc(sizeof(mFILE));
if (mf == NULL)
{
close(fd);
}
mf->fileno = fd;
mf->flag = FLUSH_LINE;
mf->size = 0;
return mf;
}
int mfwrite(const void *ptr, int num, mFILE *stream)
{
//1. 拷贝到用户级缓冲区中
memcpy(stream->outbuffer + stream->size, ptr, num);
stream->size += num;
//2. 判断是否为行缓冲
if (stream->flag == FLUSH_LINE && stream->outbuffer[stream->size - 1] == '\n')
{
mfflush(stream);
}
return num;
}
void mfflush(mFILE *stream)
{
//写到文件内核级缓冲区中
if (stream->size > 0)
{
write(stream->fileno, stream->outbuffer, stream->size);
}
fsync(stream->fileno);
stream->size = 0;
}
void mfclose(mFILE *stream)
{
if (stream->size > 0)
{
mfflush(stream);
}
close(stream->fileno);
free(stream);
}
//mystring.c
#include "mystring.h"
#include <stdio.h>
int my_strlen(const char *s)
{
const char *start = s;
while(*s)
{
s++;
}
return s - start;
}
步骤1:形成库需要的 .o 文件
步骤2:使用命令 ar -rc [静态库.a] [*.o] 进行静态库的生成。 ar是gnu归档工具。
2.2 静态库的使用
任意目录下新建 main.c ,内容如下
// main.c
#include "mystdio.h"
#include "mystring.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
mFILE *fp = mfopen("log.txt", "w");
if (fp == NULL)
{
return 1;
}
int cnt = 10;
while(cnt--)
{
printf("write %d\n", cnt);
char buffer[64];
snprintf(buffer, sizeof(buffer), "hello xiaoc, cnt is : %d\n", cnt);
mfwrite(buffer, strlen(buffer), fp);
// mfflush(fp);
sleep(1);
}
mfclose(fp);
return 0;
}
使用命令将 main.c 文件编译为 main.o 文件,并将 mystdio.h,mystring.h,libmyc.a 文件复制到该目录下。
使用命令 gcc -o main main.o -L. -lmyc 将main.o 和 静态库 libmyc.a链接起来形成可执行文件 main。
执行文件 ./main,下列内容表示程序可正常执行,说明自己写的静态库是可以使用的。
-L:指定库路径,上述 . 表示指定当前路径。
-l:指定库名称,库名称是静态库文件去掉开头的 lib 和结尾的 .a 的中间部分。 这个l是小写的“L”。
-I:指定头文件搜索路径,这里头文件在当前目录中,所以不用指定。这个I是大写的“i”。
测试目标文件生成后,静态库删除,程序照样可以运行。如果要链接任何非C/C++标准库,都需要指明 -L -l。
C/C++标准库链接的时候不用指定头文件路径和库路径,是因为在安装gcc/g++的时候,就已经把标准库的头文件和库文件复制到了系统中指定的路径下了,所以在使用的时候不需要指定头文件路径和库文件路径。C/C++标准库编译的时候也不需要指定库名称,因为gcc/g++是C语言和C++的编译器,它们认识C/C++标准库。
所以在链接第三方库的时候不想指定头文件和库文件的路径时,也可以把第三方库的头文件和库文件拷贝到Linux系统中的指定文件中,但是gcc/g++不认识第三方库,所以就算这样链接的时候也需要指定库的名称。
这里给出一个makefile脚本,创建一个lib目录,lib目录中用include目录和mylib目录,分别存放静态库的头文件和库文件,并打包成压缩包。
libmyc.a:mystdio.o mystring.o
ar -rc $@ $^
mystdio.o:mystdio.c
gcc -c $<
mystring.o:mystring.c
gcc -c $<
.PHONY:output
output:
mkdir -p lib/include
mkdir -p lib/mylib
cp -f *.h lib/include
cp -f *.a lib/mylib
tar czf lib.tgz lib
.PHONY:clean
clean:
rm -rf *.o libmyc.a lib lib.tgz
3. 动态库
动态库(.so):本质也是对源文件对应的 .o 文件进行一个打包,打包成一个可执行的 .a 文件。程序运行的时候才去链接动态库,多个程序共享使用库的代码。
一个与动态库链接的可执行文件仅仅包含它使用到的函数的入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
在可执行文件开始运行之前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程为动态链接(dynamic linking)。
动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
3.1 动态库的制作
步骤1:将 .c 源文件编译成 .o 文件,编译的使用需要带 -fPIC选项。
步骤2:使用 gcc -shared -o libmyc.so *.o 命令,形成动态库 .so 文件。
shared:表示生成共享库格式
fPIC:产生位置无关码(position independent code)
库名规则:libxxx.so
可以看到该文件是一个动态链接文件
3.2 动态库的使用
动态库的使用和静态库的使用相同,需要指定头文件路径,库文件路径以及链接的库名称。但是直接运行可执行程序的时候却,报错找不到动态库。
解决方案1:将头文件和库文件安装到系统路径下。
解决方案2:在系统存储库文件的路径下,建立与第三方库的软链接。
解决方案3:给环境变量 LD_LIBRARY_PATH 添加上第三方库文件的路径,程序运行时会在系统路径中查找动态库,也会在该环境变量中的路径中查找动态库。
将上述环境变量写入相关配置文件中,就可以使其永久可以找到第三方库了。
解决方案4:在 /etc/ld.so.conf.d/ 目录中任意创建一个以.conf结尾的配置文件。然后将第三方库的路径复制到该文件中,使用 ldconfig 刷新一下,这样就能找到动态库了。
知识点1:
gcc/g++默认使用动态库链接。当动态库和静态库都存在的时候,非要静态链接,就在链接的时候加选项 -static。只存在动态库的时候,不能进行静态链接。只存在静态库的时候,链接的时候都只能静态链接。
知识点2:
在Linux系统下,默认情况安装的大部分库,都优先安装的是动态库。
4. 目标文件
编译和链接这两个步骤在Windows下已经被IDE封装的很完美了,一般都是一键构建非常方便,但一旦遇到错误的时候,尤其是链接相关的错误,很多人就束手无措了。在Linux下,之前也学习过如何通过gcc编译器来完成这一系列的操作。
下面深入探讨一下编译和链接的整个过程,来更好的理解动静态库的使用原理。
编译的过程其实就是将我们程序的源代码翻译成CPU能够直接运行的机器代码。在编译之后会生成拓展名为 .o 的文件,它们被称作目标文件。 要注意的是如果修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程。目标文件是一个二进制文件,文件格式是ELF,是对二进制代码的一种封装。
这里给出两份代码hello.c 和code.c,通过编译,编译出hello.o 和 code.o。
//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");
}
使用 file [文件名] 命令查看文件,可以看到目标文件是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.1 ELF形成可执行
步骤1:将多份C/C++源代码,翻译成目标.o文件。
步骤2:将多份 .o 文件section进行合并。
实际合并是在链接时进行的,但是并不是这么简单的合并,也会涉及对库的合并。
7.2 ELF可执行文件加载
一个ELF会有多种不同的Section,在加载到内存的时候,也会进行Section合并,形成Segment。
合并原则:根据相同属性,比如:可读,可行,可执行,需要加载时申请空间等将section合并为segment。
即使是不同的Section,在加载到内存中,可能会以segment形式,加载到一起。
很显然,这个合并工作也已经在形成ELF的时候,合并方式就已经确定了,具体合并原则被记录在了ELF程序头表中。
使用命名 readelf -S [ELF文件名] 查看文件的section信息:
可以看到ls命令可执行程序,有31个section,并且每一个section的信息以类似于数组的方式存储起来。
使用命令 readelf -l [ELF文件名] 查看文件的segment信息:
可以看到 ls 程序里面有13个segement,在05下标对应的segment中,将 .data 和 .bss节合并到一个segment中,.data存储的是数据,如全局变量和局部静态变量,.bss中存储的是全局未初始化变量,都是属于数据,所以合并到了一个segment中。上述每一个segment后面都有相关RWX的标志位,表示该segment的权限属性。
对于 程序头表 和 节头表,其实ELF文件提供2个不同的视图/视角来让我们理解这两个部分:
链接视图(Linking View)-- 对应节头表(Section Header Table)
文件结构的粒度更细,将文件按功能模块的差异进行划分,静态链接分析的时候一般关注的是链接视图,能够理解ELF文件中包含的各个部分的信息。
为了空间布局上的效率,在链接目标文件时,链接器会把很多节合并,规整成可执行的段、可读写的段、只读段等。合并后,空间利用率就高了,否则,对于物理内存页浪费太大(物理内存页分配一般都是整数倍一块一块分配的,比如4K)。
执行视图(Execution View)-- 对应程序头表(Program Header Table)
告诉操作系统如何加载可执行文件,完成进程内存的初始化。一个可执行程序的格式中,一定有 program header table。
section header table 在链接时作用,program header table 在加载时作用。
上图右边的第一个图,对应的是分段的图,通过RWX属性将每个section分为几个段。第二个图就是每个section的分布图。可以看到上图中.init和.text分到了一个段中,.data自己分为一个段,.rodata和.got分到一个段。
从链接视图来看:
.text节:是保存了程序代码指令的代码节。
.data节 :保存了初始化的全局变量和局部静态变量等数据。
.rodata节 :保存了只读的数据,如⼀⾏C语⾔代码中的字符串。由于.rodata节是只读的,所以只能存在于⼀个可执⾏⽂件的只读段中。因此,只能是在text段(不是data段)中找到.rodata 节。
.BSS节 :为未初始化的全局变量和局部静态变量预留位置。
.symtab节 : Symbol Table 符号表,就是源码⾥⾯那些函数名、变量名和代码的对应关系。
.got.plt节 (全局偏移表-过程链接表):.got节保存了全局偏移表。.got节和.plt节⼀起提供了对导⼊的共享库函数的访问⼊⼝,由动态链接器在运⾏时进⾏修改。
从执行视图来看:
告诉操作系统哪些模块可以被加载进内存。加载进内存之后哪些分段是可读可写,哪些分段是只读,哪些分段是可执⾏的。
可以在ELF头中找到文件的基本信息,以及看到ELF头如何定位程序头表和节头表的。使用命令 readelf -h [ELF文件名] 查看ELF头的信息:
对于ELF header部分,只用知道其作用即可,它的主要目的是定位文件的其他部分。
知识点1:
为什么要将section合并成segment?
section合并的主要原因是为了减少页面碎片,提高内存使用效率。如果不进行合并,假设页面大小为4096字节(内存块基本大小,加载,管理的基本单位),如果 .text 部分为4097字节,.init 部分为512字节,那么将占用3个页面。而合并后,加载是将一个segment同时加载,这两个部分只需要2个页面。
此外,操作系统在加载程序时,会将具有相同属性的 section 合并成一个大的 segment ,这样就可以实现不同的访问权限,从此优化优化内存管理和权限访问控制。
7. 理解链接和加载
7.1 静态链接
无论是自己的.o文件,还是静态库中的.o文件,本质都是把.o文件进行链接的过程,所以静态链接本质就是研究.o文件是如何链接的。
这里将上述的编译过的hello.o和code.o进行反汇编查看,使用 objdump -d [目标文件名] 对目标文件进行反汇编:
上图是code.s的内容,这里的e8表示函数调用的机器码,后面的0表示函数地址。 这个函数调用对应着代码中printf的调用。
上图是hello.s的内容,这里的函数地址也是0。 这里的函数调用对应着代码中printf和run的调用。
可以从上看出,光是编译的情况下,hello.o中是不认识code.o中的函数的,也不认识C标准库中的函数。因此,编译器只能将函数的跳转地址先暂时设为0。
这个地址会在链接的时候进行修正。为了让链接器将来在链接时能够正确定位到这些被修正的地址,在代码节(.data)中还存在一个重定位表,在链接的时候就会根据表里记录的地址将其修正。
使用readelf -s code.o,读取code.o的符号合集,这里可以看到printf函数是未被定义的(UND表示未被定义,puts就是printf的实现)。
使用readelf -s hello.o,可以看到run和printf都是未被定义的。
将这两个.o文件通过链接,链接成可执行文件exe,这是再使用 readelf -s exe 进行查看,就可以看到run函数有了对应的地址。printf是动态链接的,这里不做说明。而这里run前面的16表示在exe程序的第16节中,使用 readelf -S exe 可以看到这个节是 .text节。这表示了它属于代码节。
使用 objdump -d exe > exe.s,在exe.s中的main中可以看到printf和run的地址被进行了修改。
静态链接:1.将所有.o和静态库中的.o文件的section合并成segment。2. 将函数的地址进行修正。
7.2 ELF加载与进程地址空间
7.2.1 虚拟地址与逻辑地址
一个ELF可执行程序,在没有被加载到内存的时候,本身就有地址,当代计算机工作的时候,都采用“平坦模式”进行工作。所以也要求ELF对自己的代码和数据进行统一编制。下面是对exe.s中的部分内容。
平坦模式:就是可执行程序中的所以segment都是从0开始依次递增的统一编制。
最左侧的就是ELF的虚拟地址,其实严格意义上应该叫做逻辑地址(起始地址+偏移量),但是平坦模式下,起始地址为0,所以起始虚拟地址在我们的程序还没有加载到内存的时候,就已经把可执行程序进行统一编制了。
进程的mm_struct,vm_area_struct在进程刚刚创建的时候,初始化的数据就是从ELF各个segment来的,每个segment有自己的起始地址和自己的长度,用来初始化内核结构中vm_area_struct的[start, end] 等范围数据,另外在将虚拟地址和物理地址的映射填入页表当中。
所以虚拟地址机制不仅仅操作系统要支持,编译器也要支持。
7.2.2 重新理解进程虚拟地址空间
ELF被编译好之后,会把自己未来程序的入口地址记录在ELF header的Entry point address字段当中。
所以一个可执行程序在Linux系统中的加载过程如下:
步骤1: 在 bash 进程中,通过命令行的形式输入可执行程序的文件名,识别到是一个可执行程序之后,在 bash 进程中打开了可执行程序的文件,并且创建可执行程序的进程。
步骤2:在这个进程中通过 dentry 结构体找到文件的 inode,找到 inode 之后,加载文件的属性,并且通过 inode 和 data blocks 的映射关系,找到数据和代码,将其放入物理内存中。
步骤3:然后初始化 mm_struc t和 vm_area_struct 结构体的[start, end],并且填充页表。
步骤4:将程序的入口地址加载到 cpu当中,此时 cpu 进行调度然后运行该进程。cpu中有个EIP字段,里面存放的是当前指令的下一条指令的地址,这样就可以通过入口地址依次运行整个程序了。
知识点1:
在计算机中,磁盘上的逻辑地址就是进程中的虚拟地址,cpu使用的也是虚拟地址。
7.3 动态链接与动态库加载
7.3.1 进程如何使用动态库
下面对上图进行说明:
如果一个可执行程序的进程要调用动态库,首先需要先将动态库的代码和数据加载到物理内存当中,其次通过页表将动态库的物理地址映射到进程的虚拟地址空间的共享区上,然后在通过代码区的代码调用动态库中函数的地址,进行地址跳转调用动态库中的库函数。
7.3.2 进程间如何共享库
如果两个进程同时使用一个动态库,就是将一份动态库的物理地址通过两个页表分别映射到两个进程的虚拟地址空间的共享区中。
所以在多个进程使用同一个动态库的时候,动态库只需要加载一份在内存中,减少了内存的浪费。
7.3.3 动态链接
7.3.3.1 概要
动态链接远比静态链接要常用得多。使用 ldd [可执行程序文件名] 就可以查看该可执行程序链接的动态库。
编译器默认不使用静态链接,是因为静态链接最大的问题在于生成的文件体积大,并且相当耗费内存资源。
动态链接的优势就是,我们可以将需要共享的代码单独提取出来,保存成一个独立的动态库,等到程序加载的时候再将它们加载到内存,这样不但可以节省磁盘和内存的空间,而且可以被不同的进程所共享。
首先,动态链接实际上是将链接的整个过程推迟到了程序加载的时候。比如运行一个程序,操作系统会首先将程序的数据和代码连同使用到的一系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况为它们动态分配一段内存。当动态库被加载到内存之后,一旦它的内存地址被确定,就可以去修正动态库中库函数在进程虚拟地址空间中的函数跳转地址了。
7.3.3.2 可执行程序被编译器动了手脚
在C/C++程序中,当程序开始执行时,首先并不会直接跳转到main函数。实际上,程序的入口点是_start,这是一个由C运行时库(通常是glibc)或链接器(如ld)提供的特殊函数。上图中就是Linux中的一个链接器。
在_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 函数来终⽌程序。
7.3.3.3 动态库中的相对地址
动态库为了随时进行加载,为了支持并映射到任意进程的任意为止,对动态库中的方法的统一编址,在进程虚拟地址空间中采用相对编址的方案进行编址的(动态库的编址和可执行程序一样,都要遵守平坦模式)。
在动态库中的编址还是起始地址(0)+ 偏移量,由于起始地址为0,所以偏移量就是动态库的编址。映射到进程的虚拟地址空间时,在进程的虚拟地址空间中,动态库的库函数地址就是动态库在虚拟地址空间中的地址,加上动态库的库函数的偏移量,这被称为相对编址。
7.3.3.4 程序怎么和库具体映射起来
动态库也是一个文件,要被访问也是要先被加载,要被加载就是要被打开的,让进程找到动态库的本质:就是文件操作,不过我们访问库函数,通过虚拟地址进行跳转访问的,所以需要把动态库映射到进程的地址空间中。
首先进程打开动态库,通过动态库struct_file结构体找到dentry然后找到动态库的inode,将动态库的属性,数据和代码加载到内存中,然后为动态库开辟一个vm_area_struct并初始化,最后再将动态库的物理地址通过页表映射到进程的虚拟地址空间中。
7.3.3.5 程序怎么进行库函数调用
库已经被映射到了当前进程的虚拟地址空间中,库的虚拟其实地址也知道,库中每一个方法的偏移量也知道,所以访问库中的任意方法,只需要知道 库的起始虚拟地址 + 库方法偏移量 就可定位库中的方法。
而且,整个调用过程是从代码区跳转到共享区,调用完毕再返回到代码区,整个过程完全在进程虚拟地址空间中进行的。
7.3.3.6 全局偏移量表GOT(global offset table)
程序运行之前先把所有动态库加载并映射,所有库的起始虚拟地址都提前知道,然后对我们加载到内存中的程序的库函数调用进行地址修改,在内存中二次完成地址设置(加载地址重定位)。但是修改地址不是修改代码区吗?代码区不是只读的吗?怎么进行修改?
给出的解决方法是,动态链接采用的做法是在 .data 中专门预留一片区域用来存放函数的跳转地址,它也被叫做全局偏移量表GOT,表中每一项都是本运动模块要引用的一个全局变量或函数的地址(偏移量)。因为 .data 区域是可读写的,所以可以支持动态进行修改。
1. 有了GOT表,代码便可以被所以进程共享。但在不同进程的虚拟地址空间中,各个库的起始虚拟地址不同。反映到GOT表上,就是每个进程的每个动态库都有独立的GOT表,所以进程间不能共享GOT表。
2. 在单个.so下,由于GOT表与.text的相对位置是固定的,所以完全可以利用相对寻址来找到GOT表。
3. 在调用函数的时候会首先查GOT表,然后根据表中的地址来进行跳转,这些地址在动态库加载的时候会被修改为真正的地址。
4.这种方式实现的动态链接被叫做PIC地址无关代码。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享,这就是为什么之前给编译器指定 -fPIC 参数的原因,PIC = 相对编址+GOT。
7.3.3.7 库间依赖
不仅仅可执行程序调用库,库也会调用其他库。库之间是有依赖的,库中也有.got。
由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,操作系统还做了一些其他的优化,比如延迟绑定,或者也叫做PLT(过程链接表)。与其在程序一开始就对所有函数进行地址重定位,不如将这个过程推迟到函数第一次被调用的时候,因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
动态链接实际上将链接的整个过程,比如符号查询,地址的重定位从编译时推迟到程序运行时,虽然牺牲了一定的性能和程序加载时间,但动态链接能够更有效的利用磁盘空间和内存资源,以极大方便了代码的更新和维护,更关键的是,它实现了二进制级别的代码复用。
知识点1:
解析依赖关系的时候,就是加载并完善相互之间的GOT表的过程。
7.3.4 总结
1. 静态链接提⾼了程序的模块化⽔平。对于⼀个⼤的项⽬,不同的⼈可以独⽴地测试和开发⾃⼰的模块。通过静态链接,将自己的demo代码和静态库链接起来⽣成可执⾏⽂件,进行自己模块的开发测试。
2. 静态链接会将编译产⽣的所有⽬标⽂件,和⽤到的各种库合并成⼀个独⽴的可执⾏⽂件,
其中我们会去修正模块间函数的跳转地址,也被叫做编译重定位(也叫做静态重定位)。
3.动态链接是将链接的整个过程推迟到了程序加载的时候。运⾏⼀个程序时,操作系统会⾸先将程序的数据代码连同它⽤到的⼀系列动态库先加载到内存,其中每个动态库的加载地址都是不固定的,但是⽆论加载到什么地⽅,都要映射到进程对应的地址空间,然后通过.GOT⽅式进⾏调⽤(运⾏重定位,也叫做动态地址重定位)。