动静态链接与加载
目录
静态链接
ELF加载与进程地址空间(静态链接)
动态链接与动态库加载
GOT表
静态链接
对于多个.o文件在没有链接之前互相是不知到对方存在的,也就是说这个.o文件中调用函数的的跳转地址都会被设定为0(当然这个函数是在其他.o文件中定义的)这个地址会在哪个时候被修正?链接的时候!为了让链接器将来在链接时能够正确定位到这些被修正 的地址,在代码块(.data)中还存在⼀个重定位表,这张表将来在链接的时候,就会根据表⾥记录的地址将其修正。这也就是为什么.o文件叫做可重定位文件。
ELF加载与进程地址空间(静态链接)
从上面的连接过程可以看到,在我们链接完成的之后形成的可执行程序中是有地址的,这个时候程序显然没有加载到内存中那这个地址就不可能是内存中的物理地址。事实上这个地址是一种逻辑地址,其思想与虚拟地址类似,也与虚拟地址对应,也就是说磁盘上的逻辑地址就是以后运行可执行程序时的虚拟地址。在当代计算机内部,这个逻辑地址采用平坦模式进行编址(也就是从0开始编址)。所以也要求ELF文件对自己的代码和数据进行统一编址。
简直巧妙,原来虚拟地址跟磁盘中可执行文件的逻辑地址是对应的。我们知道可执行程序的执行需要os创建子进程来执行,那么mm_struct、vm_area_struct在进程刚刚创建的时候,初始化数据从哪⾥来?就从逻辑地址来。从ELF各个segment来,每个segment有⾃⼰的起始地址和⾃⼰的⻓度,⽤来初始化内核结构中的[start, end] 等范围数据,另外再⽤详细地址,填充⻚表。
mm_struct
描述进程的整个虚拟地址空间,包含所有 vm_area_struct
的链表或红黑树。例如:
struct mm_struct {
struct vm_area_struct *mmap; // 虚拟内存区域链表
unsigned long start_code; // 代码段起始地址(ELF的 .text)
unsigned long end_code;
unsigned long start_data; // 数据段起始地址(ELF的 .data)
unsigned long end_data;
// ...
};
vm_area_struct
描述一个连续的虚拟内存区域(如一个ELF段),包括权限、文件映射信息等。
struct vm_area_struct {
unsigned long vm_start; // 起始虚拟地址(ELF的 p_vaddr)
unsigned long vm_end; // 结束虚拟地址
struct file *vm_file; // 关联的ELF文件
unsigned long vm_pgoff; // 文件中的偏移(对应ELF段在文件中的位置)
pgprot_t vm_page_prot; // 访问权限(如可读、可执行)
// ...
};
示例:ELF加载到虚拟地址空间
假设一个ELF文件有两个可加载段:
-
代码段:
.text
,p_vaddr = 0x400000
,p_memsz = 0x1000
-
数据段:
.data
,p_vaddr = 0x401000
,p_memsz = 0x2000
进程创建时,内核会:
-
创建两个
vm_area_struct
:-
代码段:
vm_start=0x400000
,vm_end=0x401000
, 权限为RX
(读+执行)。 -
数据段:
vm_start=0x401000
,vm_end=0x403000
, 权限为RW
(读+写)。
-
-
通过
mmap
将这两个段映射到虚拟地址空间,但物理内存尚未分配。 -
程序先加载到内存,用虚拟地址初始化了mm_struct,当进程首次执行
0x400000
处的指令时,触发缺页中断,内核将.text
段的内容从磁盘加载到物理内存,并更新页表。
问题是cpu怎么知道从哪里开始执行呢?ELF文件的LEF Header中有一个Entry point address 这个就是程序的入口地址。cpu中有一个寄存器EIP其中存放的是当前执行指令的下一条指令的地址,CR3寄存器执行页表。所以当程序开始执行的就时候就将Entry point address中的地址load到cpu中的EIP寄存器中,然后程序从入口开始执行。
动态链接与动态库加载
我们知道动态库跟我们编译链接好的可执行和程序之间是独立的存在于磁盘的。
我们的所有依赖于动态库的可执行文件都依赖于一个这个库:/lib64/ld-linux-x86-64.so.2,lib64/ld-linux-x86-64.so.2 是 Linux 系统中的一个动态链接器库文件,主要用于在程序运行时动态加载和链接共享库(.so 文件)
在我们要运行可执行程序时,我们先是跟静态库一样的过程,先通过Entry point address找到程序的入口,事实上程序的入口就是_start函数,这是一个由C运⾏时库(通常是glibc)或链接器(如ld)提供的特殊函数。在_start函数中会执行一下一系列操作:
1.设置堆栈:为程序设置一个初始的堆栈环境
2.初始化数据段:将程序的数据段(全局变量和静态变量)从初始化数据段复制到相应的内存位置,并清零未初始化的数据段。
3.动态链接:_start函数会调用动态链接器的代码来解析和加载程序运行所需要的动态库,动态连接器会处理所有的符号解析和重定位,确保程序中的调用函数和变量访问能够正确的映射到动态库中的实际地址。(动态链接实际上将链接的整个过程推迟到了程序加载的时候)
动态连接的优点:可以看到对于不同的进程如果需要同一个库中的函数,我们只需要在内存中加载一份动态库,分配一份物理地址即可,但是对于静态库来说,其可执行文件就是已经包含静态库中的函数的了,所以其磁盘空间和内存空间都是会产生浪费的。
动态链接器 动态链接器(如ld-linux.so)负责在程序运⾏时加载动态库。
但是我们的程序具体是怎么和库映射起来的?
首先可执行程序中存有依赖的动态库的路径,通过这个路径可以将动态库加载到物理内存。动态库也是采用了平坦模式进行编址,我们叫做库中方法的偏移量。然后通过创建新的mm_area_struct用库的大小开辟一段新的进程地址空间,就能得到库的虚拟地址,并建立页表映射关系。通过库的虚拟地址和库中的偏移量就能找到对应的方法。
所以库函数的调用机制如下: 库已经被我们映射到了当前进程的地址空间中 库的虚拟起始地址我们也已经知道了,库中每⼀个 ⽅法的偏移量地址我们也知道
所有:访问库中任意⽅法,只需要知道库的起始虚拟地址+⽅法偏移量即可定位库中的⽅法
GOT表
那GOT具体是怎么工作的呢? 比如,程序在编译时,对于外部函数比如printf,编译器并不知道它运行时的具体地址,所以会在GOT中生成一个条目。当程序第一次调用printf时,动态链接器(如ld-linux.so)会找到printf的实际地址并填入GOT中,之后的调用就直接使用这个地址了。这样可以实现延迟绑定,也就是PLT(Procedure Linkage Table)和GOT配合使用。PLT负责跳转到GOT中的地址,而GOT存储实际的地址。第一次调用时,GOT中的地址可能指向PLT中的解析代码,由动态链接器完成地址解析后,GOT中的条目会被更新为正确的地址。另外,GOT还可能用于全局变量的访问,因为动态库中的全局变量地址在加载时确定,也需要通过GOT来间接访问。