静态链接

链接就是指将多个目标文件链接在一起并最终形成一个可执行文件。

两个示例源码:

a.c

extern int shared;

int main() {
	int a=100;
	swap(&a,&shared);
}

 

b.c

int shared=1;

void swap(int *a,int *b) {
	int c;
	c=*a;
	*a=*b;
	*b=c;
}

1空间与地址分配

一般链接过程会将多个输入的目标文件相同的段合并成一个段,例如将所有输入文件的.text合并到输出文件的.text。

.bss段虽然不占用目标文件和可执行文件的空间,但在装载时会占用地址空间,因此也需要将它合并。这里的地址和空间有两个含义:一是在输出的可执行文件的空间,二是装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段如.text和.data,它们在文件中和虚拟地址中都要分配空间。实际上我们只应关注虚拟地址空间的分配,因为可执行文件的空间分配和链接过程关系并不是很大。

链接过程主要分两步:

(1)空间与地址分配:扫描所有的输入目标文件,并获得各个段的信息,将相似的段合并,并建立映射关系等。并将输入目标文件的符号表中的所有符号收集统一放在一个全局符号表中。

(2)符号解析与重定位:使用第一步收集到的所有信息,进行符号解析和重定位,调整代码中的地址等。

实际上第一步完成之后,各个符号在段内的相对位置是固定的,所以地址是确定的,只不过链接器要给每个符号加上一个偏移量。假设a.o中的main函数相对于a.o的.text段的偏移是X,经过链接后,a.o的.text段位于虚拟地址A,则main的地址就是A+X。

2符号解析和重定位

先观察在链接前,a.o目标文件是如何处理shared变量的地址和swap函数的地址等。

在偏移22的地方,其中be是指令的操作吗,后面的4个字节表示的是shared变量的地址,可以看到链接前还未知道shared变量的真正地址,因此以0替代。同理在偏移2f处,是调用swap的指令,可以看到除了操作码以外的部分都是0。值得注意的是,0xe8这个操作码代表这是一条近址相对位移调用指令,后面的4个字节就是被调用函数相对于调用指令的下一条指令的偏移量。继续往下看,下一条指令的地址是0x34,0x34+0=0x34。因此callq指令的实际调用地址是0x34。明显看到0x34并不是存放swap函数的地址,因此和shared一样,这只是一个临时的假地址。链接前,编译器并不知道swap函数的真正地址。

链接器在完成地址和空间分配之后就已经可以确定所有符号的地址了,因此可以根据符号地址对所有需要重定位的指令进行地址修正。使用objdump查看链接之后可执行文件的代码段,这里只附上部分代码段和数据段的截图:

在main中地址为400568的指令使用shared的地址,可以看到地址部分不再是0,而是0x601038(小端)。可以在数据段中看到0x601038的值是1,在b.c中确实把shared初始化成1,因此0x601038确实就是shared的地址。

而在main中地址为400575的指令调用swap函数,此时后面四个字节是0x1b。之前说过0x1b代表偏移量,下一条指令的地址加上这个偏移量才是真正要调用的地址,计算得到40057a+0x1b=400595。可以看到这正是swap函数的地址。

至于链接器怎么知道哪些指令需要调整,其实是重定位表保存了相关的信息。每一个需要重定位的段都有相应的重定位表。查看重定位表的内容:

RELOCATION RECORDS FOR [.text]表示这是.text的重定位表。每一个要被重定位的地方叫一个重定位入口。重定位入口的偏移表示该入口在要被重定位的段中的偏移。

关于指令修正方式,在32位x86平台下的ELF文件的重定位入口所修正的指令寻址方式只有两种:

(1)绝对近址32位寻址。

(2)相对近址32位寻址。

上图所示的R_X86_64_32表示绝对寻址修正S+A1,R_X86_64_PC32表示相对寻址修正S-P-A2。

A1=保存在修正位置的值。

A2=地址所占的字节数。

P=被修正的位置的虚拟地址。

S=符号的实际地址。

从上面的图可知,main函数的地址为0x400546,swap函数的地址为0x400595,shared变量的地址为0x601038。

那么对于shared,方式是绝对寻址修正。修正后的地址是S+A1,S为0x601038,由图可知A是0,所有修正后的地址为0x601038+0=0x601038。这正是shared变量的地址。

对于swap,方式是相对寻址修正,S为0x400595,A2为4,P为0x400576。所有修正后的地址S-A2-P为0x400595-4-0x400576=0x1b。下一条指令的地址加上修正后的0x1b偏移量恰好是swap函数的地址,即0x40057a+0x1b=0x400595。

总的来说,绝对寻址修正后的地址为该符号的实际地址,相对寻址修正后的地址为符号距离被修正位置的地址差。

3COMMON块

现在的编译器和链接器都支持一种叫COMMON块的机制。这种机制最早源于Fortran,早期的Fortran没有动态分配空间的机制,程序员必须事先声明它所需要的临时使用空间的大小。Fortran把这种空间叫COMMON块,当不同的目标文件所需要的COMMON块空间大小不一致时,以最大的块为准。

现代的链接机制在处理弱符号时,采用的就是与COMMON块一样的机制。编译器将未初始化的全局变量定义作为弱符号处理。假设目标文件a.o存在一个弱符号tt占4个字节,目标文件b.o存在一个同名的弱符号tt占8个字节。根据COMMON类型的链接规则,最后的输出文件中tt将会占8个字节。当然有强符号时首选强符号,无强符号时才按大小选择。

这也就能解释为什么编译器不能在编译时候为未初始化的全局变量在.bss段中分配空间,而是把它标记为COMMON类型的变量。因为在编译的时候,未初始化的全局变量所占的空间是未知的,因为可能其他编译单元中的同名弱符号所占的空间更大。对于链接器来说,它是知道的,所以它可以为该符号在.bss段分配空间。

GCC的“-fno-common“允许我们把所有未初始化的全局变量不以COMMON块的形式处理,或者使用”__attribute__“扩展:

int global __attribute__((nocommon));

一旦一个未初始化的全局变量不以COMMON块的形式存在,那么它就相当于一个强符号。

4C++相关问题

4.1重复代码的消除

C++新增的特性如模板,内联函数和虚函数表等都有可能在不同的编译单元生成相同的代码,自然而然要想办法处理重复代码的消除。

以模板为例,常用做法是将每个模板的实例代码都单独地放在一个段里,每个段只包含一个模板实例。比如模板函数是add<T>(),某个编译单元以int和float实例化了该模板函数,那么该编译单元的目标文件中包含了两个该模板实例的段。简单起见假设这两个段的段名是.temp.add<int>和.temp.add<float>。当别的编译单元也以int或float实例化该模板函数后,也会生成相同名字的段。链接器会区分这些相同的段,然后将它们合并到最后的代码段中。对于内联函数和虚函数的做法也类似。

当我们需要用到某个目标文件中的一个函数或变量时,需要把它整个链接进来,也就是说很多没用到的函数也链接进来了。VISUAL C++编译器提供了一个编译选项叫函数级别链接。这个选项的作用是将所有的函数单独保存在一个段里面,当链接器需要用到某个函数时,就将它合并到输出文件中,对于没有用到的则将抛弃。但会减慢编译和链接的过程,因为要计算各个函数之间的依赖等。GCC也提供类似的机制,”-ffunction-sections“和”-fdata-sections“这两个选项的作用是将每个函数或变量分别保存到独立的段中。

4.2全局构造和析构

实际上在main函数执行前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配初始化,线程子系统等。全局对象的构造也是在main之前执行的,而析构则是在main之后执行。Linux系统下一般程序的入口是”_start“,这个函数是Linux系统库(Glibc)的一部分。当我们的程序与Glibc库链接形成可执行文件之后,这个函数就是程序的初始化部分的入口,初始化完之后会调用main。main执行完后,返回到初始化部分进行一些清理工作,然后结束进程。因此ELF文件还定义了两个特殊的段:

(1).init:构成了进程的初始化代码,Glibc的初始化部分安排执行这个段中的代码。

(2).fini:保存着进程终止代码指令。

如果一个函数放到.init,系统会在main之前执行它,放到.fini则会在main之后执行它。C++全局构造和析构就由此实现。

4.3C++与ABI

两个不同的编译器编译出来的目标文件能够相互链接,必须要满足:采用相同的目标文件的格式,拥有同样的符号修饰标准,变量的内存分布方式相同,函数的调用方式相同等等。而其中符号修饰标准,变量内存布局,函数调用方式等这些和可执行代码二进制兼容性相关的内容称为ABI(Appication Binary Interface)。

5静态库链接

对于程序如何使用操作系统提供的API,一般情况下,一种语言的开发环境往往会带有语言库(Language Library),这些库就是对操作系统的API的包装。比如printf对字符串进行必要的处理之后,最后会调用操作系统提供的API。各个操作系统下,往终端输出字符串的API都不一样。在Linux下是write,在Windows下是WriteConsole。

静态库可以看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。

6链接过程控制

绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接。这在一般情况下是没有问题的,但对于一些特殊要求的程序,比如操作系统内核,BIOS或一些需要特殊链接过程的程序,如一些内核驱动程序等,它们往往受限于一些特殊的条件,如需要指定输出文件的各个段虚拟地址,段的名称,段存放的顺序等。

链接器控制整个链接过程的方法一般有三种:

(1)使用命令行来给链接器指定参数。

(2)将链接指令存放在目标文件里,编译器经常会通过这种方法向链接器传递命令。

(3)使用链接控制脚本。VISUAL C++也允许使用脚本控制整个链接过程,VISUAL C++把这种控制脚本叫做模块定义文件,扩展名一般为.def。

在没有指定链接脚本的时候ld链接器会使用默认链接脚本,ld -verbose 可以查看ld默认的链接脚本。使用-T参数指定链接脚本,如:ld -T link.script。

以下写一个最小的程序输出Hello World。不使用c语言库,因此需要内嵌汇编。

char *str="Hello World!\n";

void print() {
	int add=(void *)str;
	//这句作用是把8字节的地址赋值给int,即用4字节int来记录字符串的地址
	//因为下面的movl指令只能使用4字节的变量
	
        //以下使用了WRITE调用,调用号是4
	asm("movl $13, %%edx \n\t"
	"movl %0, %%ecx \n\t"
	"movl $0, %%ebx \n\t"
	"movl $4, %%eax \n\t"
	"int $0x80 \n\t"
	::"r"(add):"edx","ecx","ebx");
}

void exit() {
        //以下使用了EXIT调用,调用号是1
	asm("movl $42, %ebx \n\t"
	"movl $1, %eax \n\t"
	"int $0x80 \n\t");
}

void nomain() {
	print();
	exit();
}

编译链接:

-fno-builtin表示关闭内置函数优化选项,-static表示ld将使用静态链接的方式而不是默认的动态链接,-e nomain表示程序的入口函数为nomain。

用objdump观察可执行文件hello的段,发现有.text,.rodata,.data和.comment等。

.text是只读和.rodata是只读的,.data里面保存的是str全局变量,我们没有改变它,所以也可以看成是只读的,因此我们可以把这三个段合并成一个段里,并设置只读,其次.comment段其实是可以丢弃的。要做到这些,我们需要使用ld链接脚本来控制链接过程。

先给出简单的链接脚本文件,扩展名为.lds(ld script):

ENTRY(nomain)

SECTIONS
{
	. = 0x08048000 + SIZEOF_HEADERS;
	tinytext : { *(.text) *(.data) *(.rodata) }
	/DISCARD/ : { *(.comment) }
}

ENTRY(nomain)指定了程序的入口为nomain函数,后面的SECTIONS命令一般是链接脚本的主题,这个命令指定了各个输入段到输出段的变换。其中第一条是赋值语句,后面两条是转换规则。

第一条赋值语句的意思是将当前虚拟地址设置成0x08048000+SIZEOF_HEADERS。SIZEOF_HEADERS为输出文件的文件头大小。”.“表示当前虚拟地址,因为这条语句后面紧跟着输出段”tinytext“,所以tinytext段的起始虚拟地址即为0x08048000+SIZEOF_HEADERS。

第二条表示将所有输入文件的.text,.data,.rodata段依次合并到输出文件的tinytext段。

第三条表示将所有输入文件的.comment段丢弃。

使用该链接脚本来控制链接过程,发现达到了要求,并且执行该文件,可以看到正确的输出。

链接脚本语法

链接脚本由一系列语句组成,分为命令语句和赋值语句。风格与c语言相似,有如下几点:

(1)语句之间使用”;“作为分隔符。对于命令语句,可以使用换行来分隔。

(2)可以使用常见的表达式与运算符。

(3)注释和字符引用。/* */,用到的文件名,格式名等用双引号引用。

hello.lds由两个命令语句组成,ENTRY和SECTIONS,以下是一些常用的命令语句。

SECTIONS命令是最为复杂的,SECTIONS命令语句最基本的格式:

SECTIONS
{
    ...
    secname : { contents }
    ...
}

secname表示输出段的段名,后面必须有空格。contents描述了一套规则和条件,它表示符合这种条件的输入段将合并到这个输出段中。输出段名的命名方法必须符合输出文件格式的要求。有一个特殊的段名/DISCARD/,表示符合contents条件的段将被丢弃。

contents中可以包含若干个条件,每个条件用空格隔开。只要符合这些条件中任何一个,就表示符合contents规则。

条件的写法:filename(sections)。

(1)file1.o(.data)表示file1.o的.data符合条件。

(2)file1.o(.data .rodata)或file1.o(.data, .rodata)表示file1.o中的.data和.rodata符合条件。

(3)file1.o表示file1.o的所有段都符合条件。

(4)*(.data)表示所有输入文件中的.data段都符合条件。

(5)[a-z]*(.text*[A-Z])表示所有输入文件中以小写字母开头的文件中段名以.text开头并且以大写字母结尾的段符合条件。

  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值