CSAPP笔记04链接(下)
CSAPP笔记04链接(下)
0x00 重定位
前面说到,链接的两个主要任务:解析符号和重定位。完成了符号解析后,代码中每个符号的引用都和一个符号定义(即某个输入模块中的符号表里的某个条目)关联起来了。
模块输入完并解析符号后,链接器就知道了它的输入模块中的代码节和数据节的确切大小。可以开始重定位步骤了,这个步骤会将输入模块合并为每个符号分配运行地址。重定位由两步组成:
①重定位节和符号定义。首先,所有的同类型的节会被合并,例如所有输入模块的.bss节会被合并到输出可执行文件的.bss节,此时每个节的地址都能确定了。然后,因为符号表里的符号定义的地址是相对于节的偏移,因此每个定义的符号的地址也可以确定了。因此这一步完成时,每条指令和全局符号(既包括全局变量又包括静态变量)都有唯一的运行时地址了。
②从定位节中的符号引用。对于每个符号的引用,并不是简单的将引用处的地址修改为重定位后的符号定义的地址。因为重定位后的符号定义地址是绝对的运行时地址,而对于符号的引用,有时候要填写的是引用的符号的相对地址。比如相对跳转。 要执行这一步,链接器依赖于可重定位目标模块中的重定位条目(relocation entry)。
重定位条目(relocation entry)
汇编器生成可重定位目标模块时,并不知道引用的数据和代码最终放在内存中的什么位置。它也不知道引用的外部模块中定义的函数或者全局变量的位置。
因此当汇编器遇到对最终位置位置的目标的引用,就会生成一个重定位条目,来告诉链接器在将目标文件合成可执行文件时如何修改该引用(直接修改机器代码里的,原来用0填充的地址)。
代码里当然有对符号的引用啦,比如调用函数,使用全局变量的值和地址,代码的重定位条目在.rel.text节里。
而数据区(也就是符号的值区,比如.data )里也有对符号的引用,比如某个变量的值是另一个变量的地址,数据的重定位条目在.rel.data节里(为什么没有.rel.bss呢?因为.bss里都是未初始化或者初始化为0的,不存在对其他符号的引用的初值,因此没有重定位条目,其他时候将.bss对应符号赋值为其他符号的地址,明显是发生在代码区的,也是.rel.data啊)。
ELF重定位条目的格式:
offset是需要被修改的地址相对于节的偏移,例如.rel.text里条目的offset为0x65,意味着相对于(该模块的text节的重定位后的)地址+0x65是要修改的引用/地址
symbol是被引用的符号,在之前的符号解析里已经关联到了一个符号定义(.symtab里的一个条目),因此这个字段的值被修该为.symtab里对应条目的索引。
type 告知应该如何填写引用的地址(主要是使用以绝对方式使用该引用的地址还是相对方式,前者直接将引用地址填入,后者则计算相对于pc的偏移后填入。)
ELF定义了32中不同的重定位类型,用宏定义常数表示,最常见的是以下两种:
R_X86_64_PC32和R_X86_64_32。前者表示重定位一个使用32位PC相对地址的引用,而后者则是重定位一个使用32位绝对地址的引用。这两种重定位类型是x86-64小型代码模型(samll code model),该模型只使用地址空间的低2GB,可以使用 -memodel=medium 和 -memodel=large编译选项使用中型代码模型和大型代码模型
addend是一个符号常数,主要是type为相对方式时的偏置,因为相对方式时,应该填写的是目标引用的重定位后的绝对地址-当前指令的下一条指令的地址,如果我们直接用 目标引用的重定位后的绝对地址-待修改的地址(也就是前面offset最终代表的地址),那么最终的相对地址会偏大,这个大小就是要引用的地址的长度,因此这个addend的值要么没有(绝对方式引用地址),要么是个负值(相对方式引用地址,绝对值和引用的地址长度相同)。
重定位pc相对引用
一个.c源代码编译汇编产生上图的可重定位模块,可以看到其调用了sum函数,也就是引用了sum符号,其汇编产生的重定位条目的type是PC相对的,对应的,可以看到使用了e8(call + immediate相对偏移操作码),其对应的条目如下:
r.offset=0x0f//e8后面开始是要修改的地址
r.symbol=sum//引用的符号名
r.type=R_X86_64_PC32//PC相对的引用地址
r.addend=-4//要修改的地址占4字节
我们假设sum符号和main符号都在同一个重定位目标文件的.text节,现在.text符号已经被重定位在0x4004d0,而sum符号在节内的偏移是0x18,因此被重定位0x4004e8,而待修改的地址相对偏移是0x0f,因此待修改的地址重定位后是0x4004df。那么,对该引用的重定位是 0x4004e8-0x4004df+(-4)=0x05,因此修改成 e8 05 00 00 00(小端字节序)
可以看到,之所以重定位相对PC引用要用一个addend字段,是因为要修改的地址和执行到该要修改的地址时PC的地址不同,因此加上该偏置。
重定位绝对引用
重定位绝对引用相当简单,将要引用处(就是被引用的符号的汇编地址处),直接修改为所在节重定位后的节地址+对应符号在符号表里记录的偏移
仍然是上面那张图,其中的bf 00 00 00 00指令下面有一个汇编器产生的重定位条目 a:R_X86_64_32 array 意思是 .text节里 0x0a偏移处有一个符号引用,需要链接器重定位, 符号是array,并且是绝对引用方式。而array是一个已经初始化的全局数组,那么会在.data节储存数组,符号表里其seciton/ndx是3(对应.data节),因此假定链接器已经确定了array的符号重定位为0x601018,那么将会直接将引用出的 00 00 00 00修改为 18 10 60 00(小端字节序)。
下图是通过readelf -a命令查看的可重定位目标模块的重定位条目信息:
0x01 可执行目标文件
现在,链接器已经将多个目标文件合并成一个可以执行的目标文件了,这个可执行的目标文件包含加载该文件到内存并与运行的所有信息,一个典型的ELF可执行文件中的结构如下:
可执行目标文件的格式类似于可重定位目标文件的格式。
elf头描述了文件的总体格式,还包括了程序的入口点(entry point):
可以看到入口点是0x1080,用objdump -d查看:
可以看到0x1080处确实是.text节的开头,但是却不是我们写的main函数,而是_start函数,这个函数是libc.a里的启动函数。再使用gdb给main下断点并用disas反汇编main:
可以发现main的运行时地址和objdump以及program entry里的地址远远不同,原因是,操作系统默认开启了ASLR(Adress Space Layout Randomization)机制,因此堆和栈空间的起始地址都不是0。
.text .rodata .data节与可重定位目标文件类似,但是可执行目标文件已经重定位了。
.init节定义了一个函数,”_init”,可以再objdump里看到,这个函数在程序被加载时会被os调用。
因为已经完全定位了,所以可执行目标文件没有rel节。
ELF可执行目标文件中有段/程序头部表(program header table),这个表记录了在加载可执行文件时,可执行文件如何被映射到连续的内存空间里。并且记载了读写属性。
前面的可执行的是代码段,从文件的偏移地址可以看出,包含elf头 .init节 .text节等,被加载到0x400000处;而后面的可读写的是数据段,包含.data和.bss节,被加载到0x600000处。
加载可执行目标文件
在shell中键入: ./main.out
shell会调用由操作系统提供的加载器(loader)来加载该程序。任何linux程序都可以通过linux提供的execve函数来调用加载器执行另一个程序。
#include <unistd.h>
#include <stdio.h>
int a=10;
int main(){
printf("hello\n");
execve("./main.out",NULL,NULL);
return a;
}//这个程序编译并执行会一直输出hello
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后根据ELF头里的program entry跳转到程序的入口点。这即是加载。
每个Linux程序都有一个运行时内存映像,如图:
在linux系统中代码段总是从0x400000处开始的(假定不开启ASLR) 数据段则是从0x600000处开始。运行时的堆在数据段之后,通过malloc库往上增长。堆后面的区域为共享模块预留的。用户栈总是从最大合法用户地址(2^48-1)开始,向下扩展。从2^48开始,就是为内核(kernel)中代码和数据段保留的,所谓内核是操作系统驻留在内存的部分。
动态链接共享库
静态库解决了如何让大量相关函数对应用程序可用的问题(主要是解决易用性和节省空间)。但是静态库仍然有两个问题:
①静态库更新后,程序员需要将程序重新与新版静态库链接。
②由于静态库里的被调用的模块会和目标程序合并成可执行文件并一起载如内存,对于每个程序都使用的例程比如printf、scanf会在每个进程都存在,对磁盘和内存是一种极大的浪费。
共享库(shared library)应运而生。共享库是一个目标模块,在运行或者加载时可以加载到内存空间的任意地址,并和一个在内存中的程序链接起来,这个过程称为动态链接(dynamic linking),是由动态链接器(dynamic linker)的程序来执行。
共享库也称共享目标(shared object),所以Linux系统上用.so后缀来表示。而windows系统则用.dll(dynamic linking library)动态链接库。
使用共享库,不需要往每个可执行文件里复制一份,且整个磁盘里可以就一份,同时物理内存里也只有一份共享库的.text节,并被不同进程通过虚拟内存的方式共享。
linux上生成.so库的方法和生成动态链接可执行文件的方法如下:
gcc -shared -fipc -o libvec.so addvec.c multvec.c
//生成.so库
gcc main.c ./libvec.so -o main.out
//同样的,默认会自动动态链接libc.so
动态链接共享库的基本思路是:静态执行一些链接,然后在程序加载时,动态完成链接过程。详细过程如下
一开始的静态链接时,并没有任何.so里的代码和数据真的被复制到生成的可执行文件里,而只是复制了一些.so模块里的符号表信息,使得可执行程序可以解析对.so模块中代码和数据的引用。
然后,当加载器和运行可执行文件时,加载部分链接的可执行文件,但是并不会将控制传递给程序,而是根据.interp节里的动态链接器路径,将控制先传递给动态链接器。然后动态链接器执行下面的重定位任务:
①重定位libc.so的代码和数据到内存的某个段(不增加物理内存的负担,因为之前就在物理空间,这时映射到代码的虚拟内存空间)
②重定位libvec.so的代码和数据到内存的某个段
③重定位main.out中所有对两个.so定义的符号的引用。
最后动态链接器再将控制传递给程序,从此共享库就在程序的虚拟内存空间中固定了,在执行的过程中不会变动。
应用运行时加载和链接共享库
通过上面的共享库的动态链接思路,我们发现,实际上这个方法也可以用于运行时(不仅是加载时)。这样做的好处是,应用程序可以随意地加载动态库,以实现程序的热更新.
web服务器就使用动态链接库来对http请求动态的处理,当http请求到达时,服务器动态地加载和链接适当的函数,然后调用它,而不是用fork和execve在子进程里.这样可以实现无需停止服务器即可更新存在的处理函数.
linux上,提供了动态链接器的系统调用,来让应用程序在运行时加载和链接共享库:
#include <dlfcn.h>
void *dlopen(const char *filename,int flag);
//const修饰变量在elf的.rodata节 只读
//如果没加载成功则返回NULL
filename是待链接的共享库的路径,而flag指定对于动态库如何进行符号解析.flag的参数有以下宏定义的常量:
RTLD_NOW 立即对可执行文件里的外部符号和共享库进行符号解析
RTLD_LAZY 当执行到包含共享库中的代码时才进行符号解析.
dlsym函数则是用来获取共享库里符号重定位后的地址:
#include <dlfcn.h>
void *dlsym(void *handle,char *symbol);
//成功则返回指向该符号的指针
其输入分别是前面dlopen返回的共享库的句柄(也就是一个结构的起始地址)和一个符号字符串
dlclose函数则是用于从该进程的虚拟内存空间中卸载共享库,不影响其他进程的使用,但是如果所有进程都卸载了该共享库,那么就会从物理空间中清除!
#include <dlfcn.h>
int dlclose(void *handle);
我们前面的main函数由加载时动态链接改成运行时动态链接,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
int x[2]={1,2};
int y[2]={3,4};
int z[2];
int main(){
void *handle;
void (*addvec)(int *,int *,int *,int);
//addvec是一个函数指针,
//指针,本身是一个变量,保存着一个地址
//不同类型的指针,对应着这个地址对应的内存空间里的数据
//是不同的类型的
//函数指针里,这个地址就是对应函数的首地址
handle=dlopen("./libvec.so",RTLD_LAZY);
if(!handle){
printf("error!\n");
exit(1);
}
addvec=dlsym(handle,"addvec");
addvec(x,y,z,2);
printf("z=[%d %d]\n",z[0],z[1]);
return 0;
}
上述代码使用gcc -rdynamic -ldl命令编译,并且可以看到,修改库后重新生成库,无需修改main.c里的代码并重新编译,即可让程序调用的函数不同.