链接装载与库:第四章——静态链接

整个静态链接会使用下面两个源代码例子来展开分析,使用gcc -c a.c b.c将源文件分别编译成目标文件a.ob.o

// a.c
extern int shared;
 
int main()
{
	int a = 100;
	swap(&a, &shared);
}
// b.c
int shared = 1;
 
void swap(int* a, int* b)
{
	*a ^= *b ^= *a ^= *b;
}

一、空间与地址分配

对于链接器来说,整个链接过程中,它就是将几个输入目标文件加工后合并成一个输出文件。在这里输入的目标文件是a.ob.o,输出文件是可执行文件ab。可执行文件中的代码段和数据段都是由输入的目标文件合并而来的。对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?

1.1 按序叠加

一个最简单的方案就是将输入的目标文件按照次序叠加起来,如下图所示:直接将各个目标文件依次合并。但是这样做会造成一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段。这种做法非常浪费空间,因为每个段都需要有一定的地址和空间对齐要求,比如对于x86的硬件来说,段的装载地址和空间的对齐单位是页,也就是4096字节,那么就是说如果一个段的长度只有1个字节,它也要在内存中占用4096字节。这样会造成内存空间大量的内部碎片
在这里插入图片描述

1.2 相似段合并

一个更实际的方法是将相同性质的段合并到一起,比如将所有输入文件的.text合并到输出文件的.text段,接着是.data段、.bss段等,如下图所示:.bss段在目标文件和可执行文件中并不占用文件的空间,但是它在装载时占用地址空间。所以链接器在合并各个段的同时,也将.bss合并,并且分配虚拟空间。
在这里插入图片描述

链接器为目标文件分配地址和空间这句话中的地址和空间其实有两个含义:第一个是在输出的可执行文件中的空间;第二个是在装载后的虚拟地址中的虚拟地址空间。对于有实际数据的段,比如.text.data来说,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在;而对于.bss这样的段来说,分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。

现在的链接器空间分配(只关注于虚拟地址空间的分配)的策略基本上都采用上述方法中的第二种(相似段合并),使用这种方法的链接器一般都采用一种叫两步链接(Two-pass Linking)的方法。也就是说整个链接过程分两步:

  • 第一步:空间与地址分配。扫描所有的输入目标文件,并且获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
  • 第二步:符号解析与重定位。使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。

使用ld链接器将a.o和b.o链接起来:$ ld a.o b.o -e main -o ab

-e main表示将main函数作为程序入口,ld链接器默认的程序入口为_start-o ab表示链接输出文件名为ab,默认为a.out。使用objdump来查看链接前后地址的分配情况,如下图所示:
在这里插入图片描述
VMA表示Virtual Memory Addredd,即虚拟地址,LMA表示Load Memory Address,即加载地址,正常情况下这两个值应该是一样的,但是有些嵌入式系统中,特别是在那些程序放在ROM的系统中时,LMA和VMA是不相同的。这里只关注VMA即可。

链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,即我们关心上面各个段中的VMA和Size,而忽略文件偏移(File off)。我们可以看到,在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配,所以他们默认都为0。等到链接之后,可执行文件ab中的各个段都被分配到了相应的虚拟地址。这里的输出程序ab中,.text段被分配到了地址0x08048094,大小为0x00000071字节;.data段从地址0x08049108开始,大小为0x00000004字节。可以看到,a.o和b.o的代码段被先后叠加起来,合并成ab的一个.text段,加起来的长度为0x00000071。所以ab的代码段里面肯定包含了main函数和swap函数的指令代码。

为什么链接器将可执行文件ab的.text分配到0x08048094、将.data分配到0x08049108?而不是虚拟地址空间的0开始呢?这涉及到操作系统进程的虚拟地址空间的分配规则,在Linux下,ELF可执行文件默认从地址0x08048094开始分配,详细分配方式会在可执行文件的装载与进程讲解。

1.3 符号地址的确定

在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后的虚拟地址就已经确定了,比如.text段起始地址为0x08048094.data段的起始地址为0x08049108

当前面一步完成之后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对位置是固定的,所以这时候其实mainsharedswap的地址也已经是确定的了,只不过链接器须要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。比如我们假设a.o中的main函数相对于a.o的.text段偏移是X,但是经过链接合并以后,a.o的.text段位于虚拟地址0x08048094,那么main的地址应该是0x08048094+X 从前面objdump的输出看到,main位于a.o的.text段的最开始,也就是偏移为0,所以main这个符号在最终的输出文件中的地址应该是 0x08048094+0 ,即0x08048094。我们也可以通过完全一样的计算方法得知所有符号的地址,在这个例子里面,只有三个全局符号,所以链接器在更新全局符号表的符号地址以后,各个符号的最终地址如下图所示:
在这里插入图片描述

二、符号解析与重定位

2.1 重定位

重定位就是重新定位符号的地址。在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤

a.o的代码段反汇编结果如下图所示:在程序的代码里面使用的都是虚拟地址,在这里也可以看到main的起始地址为0x0000000000000000,这是因为在未进行前面提到过的空间分配之前,目标文件代码段中的起始地址以0x0000000000000000开始,等到空间分配完成以后,各个函数才会确定自己在虚拟地址空间中的位置。从反汇编结果中,可以看到a.o共定义了一个函数main。这个函数占用0x33个字节,共17条指令;最左边那列是每条指令的偏移量,每一行代表一条指令(x86是变长指令,有些指令的长度很长,RISC指令是定长,32位)。图中用加粗体标出了两个引用sharedswap的位置,对于shared的引用是一条mov指指令。另外一个是偏移为0x20的指令的一条调用指令,它其实就表示对swap函数的调用。
在这里插入图片描述
链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么链接器就可以根据符号的地址对每个需要重定位的指令进行地址修正。用objdump来反汇编输出程序ab的代码段,可以看到main函数的两个重定位入口都已经被修正到正确的位置,如下图所示:经过修正以后,sharedswap的地址分别为0x080491080x00000009(小端序列)。为什么swap的地址为0x00000009呢?这是因为call指令是一条近址相对位移调用指令,它后面跟的是调用指令的下一条指令的偏移量,下一条指令是add指令,所以swap的真实地址为0x080480bf + 0x00000009
在这里插入图片描述

2.2 重定位表

专门用来保存与重定位相关的信息,它在ELF文件中往往是一个或多个段。对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表,而一个重定位表往往就是ELF文件中的一个段,所以其实重定位表也可以叫重定位段。如代码段.text如有要被重定位的地方,那么会有一个相对应叫.rel.text的段保存了代码段的重定位表;如果数据段.data有要被重定位的地方,就会有一个相对应叫.rel.data的段保存了数据段的重定位表。可以使用objdump来查看文件的重定位表,如下图所示:objdump -r a.o命令可以用来查看a.o里面要重定位的地方,即a.o所有引用到外部符号的地址。
在这里插入图片描述
每个要被重定位的地方叫一个重定位入口(Relocation Entry),可以看到a.o里面有两个重定位入口。重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置,RELOCATION RECORDS FOR [.text]表示这个重定位是代码段的重定位表,所以偏移表示代码段中需要被调整的位置。对照前面的反汇编结果可以知道,这里的0x1c0x27分别就是代码段中的mov指令和callq指令的地址部分。

2.3 符号解析

如果直接使用ld来链接a.o,而不将b.o作为输入,链接器就会发现sharedswap两个符号没有被定义,没有办法完成链接工作,如下图所示:就是链接时符号未定义。导致这个问题的原因很多,最常见的一般都是链接时缺少了某个库,或者输入目标文件路径不正确或符号的声明与定义不一样。
在这里插入图片描述
其实重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其它目标文件的符号。重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器须要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这时候链接器就会去查找由所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。比如查看a.o的符号表,如下图所示:
在这里插入图片描述
GLOBAL类型的符号,除了main函数是定义在代码段之外,其它两个sharedswap都是UND,即undefined未定义类型,这种未定义的符号都是因为该目标文件中有关于它们的重定位项。所以在链接器扫描完所有的输入目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误。

2.4 指令修正方式

不同的处理器指令对于地址的格式和方式都不一样。寻址方式有如下区别:近址寻址或远址寻址;绝对寻址或相对寻址;寻址长度为8位、16位、32位或64位。绝对寻址修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;相对寻址修正后的地址为符号距离被修正位置的地址差。

重定位指令修正方法:

  • 绝对寻址(S+A)修正和相对寻址修正的区别就是绝对寻址修正后的地址为该符号的实际地址;
  • 相对寻址(S+A-P)修正后的地址为符号距离被修正位置的地址差。
  • A = 保存在被修正位置的值
    P = 被修正的位置(相对于段开始的偏移量或者虚拟地址),注意,该值可通过r_offset计算得到
    S = 符号的实际地址

在这里插入图片描述

三、COMMON 块

弱符号机制允许同一个符号的定义存在于多个文件中,所以可能会导致一个问题:如果有个符号的定义存在于多个文件,而它们的类型又不相同。变量类型对于链接器来说是透明的,链接器本身不支持符号的类型。所以就有了COMMON块机制。

现代的链接机制在处理弱符号的时候,采用的就是与COMMON块一样的机制,当不同的目标文件需要的COMMON块空间大小不一致时,以最大的那块为准。COMMON类型的链接规则是针对符号都是弱符号的情况,如果其中有一个符号为强符号,那么最终输出结果中的符号所占空间与强符号相同。如果链接过程中有弱符号大小大于强符号,那么ld链接器会报警告。未初始化的全局变量就是典型的弱符号。GCC的-fno-common也允许我们把所有未初始化的全局变量不以COMMON块的形式处理,或者使用__attribute__扩展,即int global __attribute__((nocommon)); 一旦一个未初始化的全局变量不是以COMMON块的形式存在,那么它就相当于一个强符号,如果其它目标文件中还有同一个变量的强符号定义,链接时就会发生符号重复定义错误。

四、C++相关问题

C++的一些语言特性使之必须由编译器和链接器共同支持才能完成工作。最主要的有两个方面,一个是C++的重复代码消除,还有一个就是全局构造与析构。另外由于C++语言的各种特性,比如虚函数、函数重载、继承、异常等,使得它背后的数据结构异常复杂,这些数据结构往往在不同的编译器和链接器之间相互不能通用,使得C++程序的二进制兼容性成了一个很大的问题。

4.1 重复代码消除

C++编译器在很多时候会产生重复的代码,比如模板(Templates)、外部内联函数(Extern Inline Function)和虚函数表(Virtual Function Table)都有可能在不同的编译单元里生成相同的代码。如模板,从本质上来讲很像宏,当模板在一个编译单元里被实例化时,它并不知道自己是否在别的编译单元也被实例化了。所以当一个模板在多个编译单元同时实例化成相同的类型的时候,必然会生成重复的代码。当然,最简单的方案就是不管这些,将这些重复的代码都保留下来,不过这样做的主要问题有以下几个方面:空间浪费、地址容易出错、指令运行效率较低。

一个比较有效的做法就是将每个模板的实例代码都单独地存放在一个段里,每个段只包含一个模板实例。这样链接器在最终链接的时候可以区分这些相同的模板实例段,然后将它们合并入最后的代码段。这种做法被主流的编译器所采用,GNU GCC编译器和VISUAL C++编译器都采用了类似的方法。GCC把这种类似的需要在最终链接时合并的段叫Link Once,它的做法是将这种类型的段命名为.gnu.linkonce.name,其中name是该模板函数实例的修饰后名称。VISUAL C++编译器做法稍有不同,它把这种类型的段叫做COMDAT,这种COMDAT段的属性字段(PE文件的段表结构里面的IMAGE_SECTION_HEADER的Characteristics成员)都有IMAGE_SCN_LNK_COMDAT这个标记,在链接器看到这个标记后,它就认为该段是COMDAT类型的,在链接时会将重复的段丢弃。

这种重复代码消除对于模板来说是这样的,对于外部内联函数和虚函数表的做法也类似。比如对于一个有虚函数的类来说,有一个与之相对应的虚函数表(Virtual Function Table,一般简称vtbl),编译器会在用到该类的多个编译单元生成虚函数表,造成代码重复;外部内联函数、默认构造函数、默认拷贝构造函数和赋值操作符也有类似的问题。它们的解决方式基本跟模板的重复代码消除类似。

这种方法虽然能够基本上解决代码重复的问题,但还是存在一些问题。比如相同名称的段可能拥有不同的内容,这可能由于不同的编译单元使用了不同的编译器版本或者编译优化选项,导致同一个函数编译出来的实际代码有所不同。那么这种情况下链接器可能会做出一个选择,那就是随意选择其中任何一个副本作为链接的输入,然后同时提供一个警告信息。

函数级别链接

由于现在的程序和库通常来讲都非常庞大,一个目标文件可能包含成千上百个函数或变量。当我们需要用到某个目标文件中的任意一个函数或变量时,就需要把它整个地链接起来,也就是说那些没有用到的函数也被一起链接了起来。这样的后果是链接输出文件会变得很大,所有用到的没用到的变量和函数都一起塞到了输出文件中。

VISUAL C++编译器提供了一个编译选项叫函数级别链接(Functional-Level Linking, /Gy),这个选项的作用就是让所有的函数都像前面模板函数一样,单独保存到一个段里面。当链接器需要用到某个函数时,它就将它合并到输出文件中,对于那些没有用的函数则将它们抛弃。这种做法可以很大程度上减少输出文件的长度,减少空间浪费。但是这个优化选项会减慢编译和链接过程,因为链接器需要计算各个函数之间的依赖关系,并且所有函数都保存到独立的段中,目标函数的段的数量大大增加,重定位过程也会因为段的数目的增加而变得复杂,目标文件随着段数目的增加也会变得相对较大。

GCC编译器也提供了类似的机制,它有两个选择分别是-ffunction-sections-fdata-sections,这两个选项的作用就是将每个函数或变量分别保持到独立的段中。

4.2 全局构造与析构

一般的一个C/C++程序时从main开始执行的,随着main函数的结束而结束。然而,其实在main函数被调用之前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配初始化(malloc, free)、线程子系统等。C++的全局对象构造函数也是在这一时期被执行的,C++的全局对象的构造函数在main之前被执行,C++全局对象的析构函数在main之后被执行。

Linux系统下一般程序的入口是_start,这个函数是Linux系统库(Glibc)的一部分。当我们的程序与Glibc库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序的主体。在main函数执行完成以后,返回到初始化部分,它进行一些清理工作,然后结束进程。

ELF文件还定义了两种特殊的段:

(1). .init:该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数被调用之前,Glibc的初始化部分安排执行这个段的中的代码。

(2). .fini:该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。

这两个段.init.fini的存在有着特别的目的,如果一个函数放到.init段,在main函数执行前系统就会执行它。同理,假如一个函数放到.fini段,在main函数返回后该函数就会被执行。利用这两个特性,C++的全局构造和析构函数就由此实现。

4.3 C++与 ABI

如果要使两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等。其中我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为ABI(Application Binary Interface)。API往往是指源代码级别的接口;而ABI是指二进制层面的接口。影响ABI的因素非常多,硬件、编程语言、编译器、链接器、操作系统等都会影响ABI。对于C语言的目标代码来说,以下几个方面会决定目标文件之间是否二进制兼容:

  • 内置类型(如int、float、char等)的大小和在存储器中的放置方式(大端、小端、对齐方式等)。
  • 组合类型(如struct、union、数组等)的存储方式和内存分布。
  • 外部符号(external-linkage)与用户定义的符号之间的命名方式和解析方式,如函数名func在C语言的目标文件中是否被解析成外部符号_func。
  • 函数调用方式,比如参数入栈顺序、返回值如何保持等。
  • 堆栈的分布方式,比如参数和局部变量在堆栈里的位置,参数传递方法等。
  • 寄存器使用约定,函数调用时哪些寄存器可以修改,哪些需要保存,等等。

这只是一部分因素。到了C++的时代,语言层面对ABI的影响又增加了很多额外的内容,正是这些内容使C++要做到二进制兼容比C来得更为不易:

  • 继承类体系的内存分布,如基类,虚基类在继承类中的位置等。
  • 指向成员函数的指针(pointer-to-member)的内存分别,如何通过指向成员函数的指针来调用成员函数,如何传递this指针。
  • 如何调用虚函数,vtable的内容和分布形式,vtable指针在object中的位置等。
  • template如何实例化。
  • 外部符号的修饰。
  • 全局对象的构造和析构。
  • 异常的产生和捕获机制。
  • 标准库的细节问题,RTTI如何实现等。
  • 内嵌函数访问细节。

C++一直为人诟病的一大原因是它的二进制兼容性不好,或者说比起C语言来更为不易。不仅不同的编译器编译的二进制代码之间无法相互兼容,有时候连同一个编译器的不同版本之间兼容性也不好。

五、静态库链接

在一般的情况下,一种语言的开发环境往往会附带有语言库(Language Library),这些库就是对操作系统的API(Application Programming Interface, 应用程序编程接口)的包装。比如C语言版Hello World程序,它使用C语言标准库的printf函数来输出一个字符串,printf函数对字符串进行一些必要的处理后,最后会调用操作系统提供的API。各个操作系统下,往终端输出字符串的API都不一样,在Linux下,它是一个write的系统调用,而在Windows下它是WriteConsole系统API。

其实一个静态库可以简单地看成一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件。比如我们在Linux中最常用的C语言静态库libc位于/usr/lib/x86_64-linux-gnu/libc.a,它属于glibc项目的一部分;像Windows这样的平台上,最常使用的C语言库是由集成开发环境所附带的运行库,这些库一般由编译器厂商提供,比如Visual C++附带了多个版本的C/C++运行库。

在一个C语言的运行库中,包含了很多跟系统功能相关的代码,比如输入输出、文件操作、时间日期、内存管理等。glibc本身是用C语言开发的,它由成百上千个C语言源代码文件组成,也就是说,编译完成以后有相同数量的目标文件,比如输入输出有printf.oscanf.o;文件操作有fread.o, fwrite.o;时间日期有date.o, time.o;内存管理有malloc.o等。把这些零散的目标文件直接提供给库的使用者,很大程度上会造成文件传输、管理和组织方面的不便,于是通常人们使用ar压缩程序将这些目标文件压缩到一起,并且对其进行编号和索引,以便于查找和检索,就形成了libc.a这个静态库文件。我们也可以使用ar工具来查看这个文件包含了哪些目标文件,如下图所示,仅显示一小部分:

#ar -t /usr/lib/loongarch64-linux-gnu/libc.a
...
getrpcbynumber_r.o
clnt_unix.o
svc_unix.o
create_xid.o
auth_des.o
auth_unix.o
clnt_gen.o
clnt_perr.o
clnt_tcp.o
clnt_udp.o
get_myaddr.o
...

libc.a里面包含了很多个(1500多)目标文件,我们如何在这么多目标文件中找到printf函数所在的目标文件呢?是使用objdump或readelf加上文本查找工具如grep,如$objdump -t /usr/lib/loongarch64-linux-gnu/libc.a | grep printf,就可以看到printf函数被定义在了printf.o这个目标文件中。或通过命令$ar -x /usr/lib/x86_64-linux-gnu/libc.a,会将libc.a中的所有目标文件解压至当前目录,也可以找到printf.o

如果说printf.o没有依赖其他其他目标文件,我们就可以写一个hello world程序hello.c,将printf.o从libc.o中解压出来,然后将编译成目标文件的hello world程序hello.oprintf.o链接成一个可执行文件。但是很遗憾,printf.o依赖了其他文件。

可以通过GCC的--verbose参数将整个编译链接过程的中间步骤打印出来,默认情况下,GCC会自作聪明地将Hello World程序中只使用了一个字符串参数的printf替换成puts函数,以提高运行速度,要使用-fno-builtin参数关闭这个内置函数优化选项,gcc -static --verbose -fno-builtin main.c命令执行结果如下图所示:
在这里插入图片描述

关键的三个步骤在图中已经用红线框起来了,第一步是调用cc1程序,这个程序实际上就是GCC的C语言编译器,它将main.c编译成一个临时的汇编文件/tmp/ccrzlbGv.s;然后调用as程序,as程序是GNU的汇编器,它将/tmp/ccrzlbGv.s汇编成临时目标文件/tmp/ccSyqK4F.o,这个/tmp/ccSyqK4F.o实际上就是main.o;接着最关键的步骤是最后一步,GCC调用collect2程序来完成最后的链接。实际上collect2可以看做是ld链接器的一个包装,它会调用ld链接器来完成对目标文件的链接,然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化的结构。可以看到最后一步中,有几个库和目标文件被链接入了最终可执行文件。

为什么静态运行库里面一个目标文件只包含一个函数?比如libc.a里面pritnf.o只有printf()函数。

链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库中的printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那么没有被用到的目标文件(函数)就不要链接到最终的输出文件中。

六、链接过程控制

绝大部分情况下,我们使用链接器提供的默认链接规则对目标文件进行链接。这在一般情况下是没有问题的,但对于一些特殊要求的程序,比如操作系统内核、BIOS(Basic Input Output Syste)或一些在没有操作系统的情况下运行的程序(如引导程序Boot Loader或者嵌入式系统的程序,或者有一些脱离操作系统的硬盘分区软件PQMagic等),以及另外的一些需要特殊的链接过程的程序,如一些内核驱动程序等,它们往往受限于一些特殊的条件,如需要指定输出文件的各个段虚拟地址、段的名称、段存放的顺序等,因为这些特殊的环境,特别是某些硬件条件的限制,往往对程序的各个段的地址有着特殊的要求。

由于整个链接过程有很多内容需要确定:使用哪些目标文件?使用哪些库文件?是否在最终可执行文件中保留调试信息、输出文件格式(可执行文件还是动态链接库)?还要考虑是否要导出某些符号以供调试器或程序本身或其它程序使用等。

操作系统内核:从本质上来讲,它本身也是一个程序。比如Windows的内核ntoskrnl.exe就是一个我们平常看到的PE文件,它的位置位于C:\Windows\System32\ntoskrnl.exe。很多人误以为Windows操作系统的内核很庞大,由很多文件组成。这是一个误解,其实真正的Windows内核就是这个文件。

6.1 链接控制脚本

链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有如下三种方法:

  • 使用命令行来给链接器指定参数,如前面所使用的ld的-o, -e参数就属于这类。
  • 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。方法也比较常见,比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段以用来传递参数。
  • 使用链接控制脚本,也是最为灵活、最为强大的链接控制方法。

由于各个链接器平台的链接控制过程各不相同。ld链接器的链接脚本功能非常强大。VISUAL C++也允许使用脚本来控制整个链接过程,VISUAL C++把这种控制脚本叫做模块定义文件(Module-Definition File),它们的扩展名一般为.def。

ld在用户没有指定链接脚本的时候会使用默认链接脚本,我们可以使用$ ld -verboase命令来查看ld默认的链接脚本。默认的ld链接脚本存放在/usr/lib/ldscripts/下,不同的机器平台、输出文件格式都有相应的链接脚本。ld会根据命令行要求使用相应的链接脚本文件来控制链接过程。当然,为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本,比如可以使用-T参数,执行命令$ ld -T link.script

6.2 最小的程序

最小的程序:为了演示链接的控制过程,我们接着要做一个最小的程序:这个程序的功能是在终端上输出Hello world!。这个小程序能够脱离C语言运行库,使用nomain作为整个程序的入口,将小程序的所有段都合并到一个叫tinytext的段,这个段是我们任意命名的,是由链接脚本控制链接过程生成的。

char* str = "Hello world!\n";
 
void print()
{
	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"(str):"edx", "ecx", "ebx");
}
 
void exit()
{
	asm("movl $42, %ebx \n\t"
	"movl $1, %eax \n\t"
	"int $0x80 \n\t");
}
 
void nomain()
{
	print();
	exit();
}

依次执行如下命令:
在这里插入图片描述

从源代码可以看到,程序入口为nomain()函数,然后该函数调用print()函数,打印Hello World,接着调用exit()函数,结束进程。这里的print函数使用了Linux的WRITE系统调用,exit()函数使用了EXIT系统调用。这里我们使用了GCC内嵌汇编。

GCC和ld的参数意义如下:

  • -fno-builtin:GCC编译器提供了很多内置函数(Built-in Function),它会把一些常用的C库函数替换成编译器的内置函数,以达到优化的功能。比如GCC会将只有字符串参数的printf函数替换成puts,以节省格式解析的时间。exit()函数也是GCC的内置参数之一,所以要使用-fno-builtin参数来关闭GCC内置函数功能。
  • -static:这个参数表示ld将使用静态链接的方式来链接程序,而不是使用默认的动态链接的方式。
  • -e nomain:表示该程序的入口函数为nomain,这个参数就是将ELF文件头的e_entry成员赋值成nomain函数的地址。
  • -o TinyHelloWorld:表示指定输出可执行文件名为TinyHelloWorld。

6.3 使用ld链接脚本

6.4 ld链接脚本语法简介

七、BFD 库

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yelvens

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值