ELF格式·链接与加载
目录
ELF格式
读取命令
链接与加载
链接
编址
逻辑地址&虚拟地址
小总结:
静态链接
加载
动态链接
进程如何看到动态库
动态库的加载
动态库的编址和库函数调用
全局偏移量表got(global offset table)
小知识
ELF格式
linux系统中,.o .c .so .exe等都是以ELF格式存在磁盘上。
链接.o就是将.o文件按ELF格式合并成更大的ELF文件,形成.exe
一个ELF文件由以下四部分组成
1.ELF头(ELF header):描述文件的主要特性,位于文件的开始位置。主要目的是定位文件的其他部分
2.程序头表(Program header table):列举了所有有效的段(segment)和它们的属性,表中记录每个段的开始位置和偏移量(offset)、长度。因为这些段都是连着放在二进制文件中,需要段表来明确不同段的位置。相当于有着section合成segment的方法。
3.节头表(Section header table):包含对节(section)的描述。
4.节(Section):ELF文件中的基本组成单位,包含了特定类型的数据。ELF文件的各种信息和数据都储存在不同的节中,如代码节储存可执行代码,数据节储存全局变量和静态数据等。
注:
编译后的section的大小不一定为4kb,但操作系统io的基本单位是4kb,所以在文件加载时操作系统要进行处理,多个section会以4kb合并成为数据段(segment),所以加载时加载的是segment
读取命令
readelf,专门读取可执行程序的命令
命令:readelf 选项 可执行程序名
-h 读取ELF Header
-S 读取Section Header Table的内容,也就是一个个Section的属性
-l 读取Program Header Table
链接与加载
链接
链接其实是将编译之后的所有目标文件连同用到的一些静态库的文件拼装成一个独立的可执行文件。
编址
形成可执行程序时,要对代码和数据进行编址。
现代计算机和操作系统对ELF文件编址时,采用“平坦模式”。
平坦模式:起始地址统一为零,只依靠偏移量来确定位置。
编译时,就会进行编址,对于该文件中定义的函数和数据,会直接分配地址,对于未定义的,会先置为0,在链接时进行地址重定位。
逻辑地址&虚拟地址
linux系统中,逻辑地址和虚拟地址实际算是同一个,只是在不同的位置叫法不同,在磁盘(文件系统)中,习惯叫逻辑地址,在内存中,习惯叫虚拟地址
小总结:
在形成可执行文件时,逻辑地址(虚拟地址)就已经有了,加载到内存中,就有了物理地址,然后页表就可以进行映射了。
静态链接
静态链接就是将.o进行合并,然后根据.o文件或者静态库中的重定位表进行地址重定位。
加载
加载前:对于mm_struct 和 vm_area_struct 来说,创建时的初始化数据是从ELF中来的,一个ELF程序,在未被加载到内存时,就已经有了逻辑地址(虚拟地址)了。
因为使用“平坦模式”编址,所以实际ELF的编址主要就是存储偏移量,起始地址都为0,所以就可以在文件系统中形成时就进行编址。
加载时:进程的PCB创建,其中的mm_struct(描述一个进程虚拟地址空间的结构体)和vm_area_struct(描述进程虚拟地址空间的每个区域的结构体)就会使用segment(段)来进行初始化。
动态链接
进程如何看到动态库
对于静态链接来说,链接时,静态库就会被加载到内存中,和.o进行合并成.exe,但对于动态库来说,动态库在运行时才会被加载到内存中,那么,进程是如何看到动态库的呢?
答案是,共享区。
在虚拟地址空间堆栈之间的共享区,用来存放该程序所依赖的动态库的虚拟地址。
所以,进程之间共享库也就是在进程的共享区中存放着相同的动态库的虚拟地址(一般虚拟地址不相同)
动态库的加载
首先,动态链接实际上将连接的过程推迟到了程序加载的时候。在一个程序加载时,操作系统会首先将程序的数据代码和它依赖的动态库加载到内存中,操作系统会根据当前程序虚拟地址空间的情况为其分配虚拟地址。(所以一般相同的库在不同进程地址空间中虚拟地址不同)
所以,动态库的加载是怎么实现的呢?
通过程序前加一个_start函数,当程序执行时,首先不会直接到main函数,而是_start(由C运行时库,或链接器提供的特殊函数)
_start函数会进行一系列初始化操作:
1.设置堆栈:为程序创建一个初始的堆栈环境。
2.初始化数据段:将程序的数据段(如全局变量和静态变量)从初始化数据段拷贝到相应的位置,并清零未初始化的数据段。
3.动态链接:_start函数会调用动态连接器的代码来解析和加载程序所依赖的动态库(共享库)。动态链接器会处理所有的符号解析和重定位,确保程序中的函数调用和变量访问能够正确的映射到动态库中的实际地址。
4.调用__libc_start_main(glibc提供的一个函数):它负责执行一些额外的初始化工作,如设置信号处理函数、初始化线程库(如果使用了线程)等。
5.调用main函数:__libc_start_main 接下来会调用main函数,执行main函数中代码。
6.处理main函数返回值:main函数返回时,__libc_start_main负责处理其返回值,并最后调用_exit函数终止程序。
动态库的编址和库函数调用
编址依旧是平坦模式+偏移量。
库函数的调用:
1.库已经被映射到了虚拟地址空间中,并且它的起始地址也是知道的。
2.库中的函数偏移量也是知道的。
3.所以,调用库函数时,是从代码区跳到共享区,调用完成再返回到代码区。
全局偏移量表got(global offset table)
综上,程序运行前,加载库并映射,然后对程序中的库函数的调用进行地址修改(地址重定位),但是,这里似乎修改的是代码区,但代码区是只读的。
所以,动态链接采用的方法是:在数据区中专门开一张表(got),在这个表中存全局变量和函数地址,而在代码区中存的是这张表的起始地址和这个函数在表中的偏移量。
这种方式实现的动态链接叫做PIC地址无关代码。
小知识
1.EIP(cpu中的)寄存器中会存pc指针,指向下一个要执行的指令地址,在ELF Header中,有一个entry point address(程序入口地址),当进程加载时,这个地址就会被放到EIP中,然后开始执行程序。
2.cpu读取虚拟地址到EIP,通过mmu(内存管理单元)来从页表中查找物理地址,进行虚拟地址到物理地址的转化。