4.程序员的自我修养---静态链接

xiaoxiao2022-06-03  5

1.空间和地址的分配 可执行文件中的代码段和数据段都是由输入的目标文件中合并而来的。对于多个输入的目标文件,链接器如何将它们的各个段合并到输出文件的? 1.按序叠加 这样做会造成一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段。这样做非常浪费地址空间,因为每个段都要求有一定的地址 和空间对齐要求,比如对于 x86 的硬件来说,段的装载地址和空间的对齐单位是页,也就是 4096 字节。那么就是说,如果一个段的长度只有1字节, 它也要在内存中占有 4096 字节。这样会造成内存空间大量的内存碎片。 2.相似段的合并 将相同性质的段合并到一起。 .bss 段在目标文件和可执行文件中并不占用空间,但它在装载时占用地址空间。所以链接器在合并各个段的同时,也将 ".bss" 段合并,并且分配虚拟空间。 "链接器为目标文件分配地址和空间" 这句话中的 "地址和空间" 其实有2个含义: 1.是在输出的可执行文件中的空间 2.是在装载后虚拟地址中的虚拟地址空间 对于有实际数据的段,比如 ".text" 和 ".data" 来说,它们在文件中和虚拟地址中都要分配空间,因为它们在这两者中都存在。而对于 ".bss" 这样的段来说, 分配空间的意义只局限于虚拟地址空间,因为它在文件中并没有内容。事实上,我们在这里谈到的空间分配只关注与虚拟地址空间的分配,因为这个关系到链接器后面的 关于地址计算的步骤,而可执行文件本身的空间与链接过程关系不是很大。 现在的链接器空间分配的策略基本上都采用第二种方法,使用这种方法的链接器一般采用一种叫做两步链接的方法,也就是说整个链接过程分两步。 第一步:空间与地址分配 扫描所有的输入目标文件,并且获得他们各个段的长度,属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表, 这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。 第二步:符号解析与重定位 使用上面第一步中收集到的所有信息,读取输入文件中段的数据,重定位信息,并且进行符号解析与重定位,调整代码中的地址等。事实上第二步是链接过程的核心,特别是 重定位过程。 ld a.o b.o -e main -o ab // -e main,表示将 main 作为程序入口, ld 链接器默认程序入口为 _start; -o 表示链接器输出文件名为 ab,默认为 a.out VMA (Virtual memory address), 虚拟内存地址,LMA(load memory address) 即加载地址。 链接前后的程序中所使用的地址已经是程序在进程中的虚拟地址,即我们关心上面各个段中的 VMA 和 Size ,而忽略文件的偏移(File off)。我们可以看到,在链接之前,目标文件中 的所有段的 VMA 都是0,因为虚拟空间还没有被分配,所以默认为 0.等到链接之后,可执行文件 ab 中的各个段都被分配到了相应的虚拟地址。 为什么链接器要将可执行文件 ab 的 ".text" 分配到 0x08048094, 将 ".data" 分配 0x080049108 而不是从虚拟空间的 0 开始分配呢。这涉及到操作系统的进程虚拟地址空间分配 规则,在 Linux 下,ELF 可执行文件默认从地址 0x08048000 开始分配。 在第一步的扫描和空间分配阶段,链接器按照前面介绍的空间分配方法进行分配,这时候输入文件中的各个段在链接后地址就已经确定了,比如 '.text' 段起始地址为 0x08048094, '.data' 段的起始地址为 0x080049108 。 当前面一步完成后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对地址是固定的,这时候起始 "main", 'shared' 和 "swap" 的地址已经是确定的了。只不过链接器必须 要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。比如,我们假设 "a.o" 中的 'main' 函数相对于 'a.o' 的 '.text' 段的偏移是 X, 但是经过链接合并后,"a.0" 的'.text' 段位于虚拟地址 0x080048940, 那么 'main' 的地址应该是 0x080048940 + X 2.符号解析与重定位 近址相对位移调用指令 调用指令的吓一跳指令的偏移量 重定位表: 对于可重定位的 ELF 文件来说,它必须包含重定位表,用来描述如何修改相应的段里的内容。对于每个要被重定位的ELF段都有一个对应的重定位表, 而一个重定位表往往就是 ELF 文件中的一个段,所以其实重定位表也可以叫做重定位段。比如代码段 ".text" 如果要被重定位的地方,那么就会有一个 相应的叫 '.rel.text' 的段保存了代码段的重定位表。 符号解析: 在重定位的过程中,每个重定位的入口都是对一个符号的引用,那么当链接器需要对某个符号的引用进行重定位时,它就要确定这个符号的目标地址。这个时候 链接器就会去查找所有输入目标文件的符号表组成的全局符号表,找到相应的符号后,进行重定位。 寻址方式: 1.近址寻址或远址寻址 2.绝对寻址或相对寻址 3.寻址长度为8位,16位,32位或64位 3.COMMON 块 现在的编译器和链接器都支持一种叫 COMMON 块的机制,这种机制最早来自Fortran, 早起的Fortran没有动态分配空间的机制,程序员 必须事先声明它所需要的临时使用的空间的大小。Fortran 把这种空间叫做 COMMON 块,当不同的目标文件的 COMMON 块空间不一致时,以 最大的那块为准。 现代的链接机制在处理弱符号的时候,采用的就是与 COMMON 块一样的机制。 当然,COMMON 类型的链接规则是针对符号都是弱符号的情况,如果其中有一个符号为强符号,那么最终输出结果中的符号所占的空间与强符号相同。 值得注意的是,如果链接过程中有弱符号大小大于强符号,那么 ld 链接器会报如下警告。 ld:warning : alignment 4 of symbol `global` in a.o is smaller than 8 in b.o 直接导致需要 COMMON 机制的原因是编译器和链接器允许不同类型的弱符号的存在,但最本质的原因还是链接器不支持符号类型,即链接器无法判断各个 符号的类型是否一致。 在目标文件中,编译器为什么不直接把未初始化的全局变量也当作未初始化的局部变量一样处理,为它在 .bss 段分配空间,而是将其标记为一个 COMMON 类型的变量? 通过连接链接器处理多个弱符号的过程,我们可以想到,当编译器将一个编译单元编译成目标文件的时候,如果该编译单元包含了弱符号(未初始化的全局变量就是典型的弱符号), 那么该符号最终所占空间的大小在此刻是未知的,因为有可能其他编译单元中该符号所占空间比编译单元该符号所占的空间要大。所以编译器此时无法为弱符号在 .bss 段分配空间, 因为所需要的空间大小未知。但链接器在链接过程中可以确定弱符号的大小,因为当链接器读取所有输入目标文件以后,任何一个弱符号的最终大小都可以确定了,所以它可以在最终输出文件 的 .bss 段为其分配空间。所以总体来看,未初始化全局变量最终还是被放在 .bss 段的。 gcc 的 "-fno-common" 也允许我们把所有未初始化的全局变量不以 COMMON 块的形式处理,或者用 "__attribute__" 扩展: int global __attribte__((nocommon)); 一旦一个未初始化的全局变量不是以 COMMON 块的形式存在,那么它就相当于一个强符号,如果其他目标文件中还有一个变量的强符号,链接时就会发生符号重复定义的错误。 4.C++ 相关问题 1.消除重复代码 c++编译器在很多时候会产生重复代码,比如模板,外部内联函数和虚函数表都有可能在不同的编译单元里生成相同代码。 最简单的拿模板来说,模板从本质上来讲很像宏,当模板在一个编译单元被实例化时,它并不知道自己是否在别的编译单元也被实例化了。 所以当一个模板在多个编译单元同时实例化成相同类型的时候,必然会产生重复代码。主要问题: 1.浪费空间。 2.地址较易出错,有可能2个指向同一个函数的指针会不相等 3.指令运行效率较低。因为现代的cpu都会对指令和数据进行缓存,如果同一份指令有多副本,那么指令cache的命中率就会降低 一个有效的做法就是将每个模板的实例代码都单独放在一个段里,每个段只包含一个模板实例。比如有个模板函数是 add<T>(),某个 编译单元以 int 类型和 float 类型实例化了该模板函数。那么该编译单元的目标文件中就包含了2个该模板实例的段。如,.temp.add<int> 和 .temp.add<float>。这样,当别的编译器单元也以 int 或 float 类型实例化该模板函数后,也会生成同样的名字,这样链接器在最终连接 的时候可以区分这些相同的模板实力段,然后将它们合并入最后的代码段。 这种做法目前被主流的编译器所采用,GNU GCC 编译器和 VISUAL C++ 编译器都采用了类似的方法。GCC 把这种类似的需要在最终链接时合并的 段叫 "Link Once",它的做法是将这种类型的段命名为 ".gnu.linkonce.name",其中 “name” 是该模板函数实例的修饰后名称。VISUAL C++ 编译器 做法稍微不同,它把这种类型的段叫做 "COMDAT",这种段的属性字段都有 IMAGE_SCN_LNK_COMDAT 这个标记,在链接器看到这个标记后,它就认为该段是 COMDAT 类型的,连接时会将重复的段丢弃。 这种重复代码消除对模板来说是这样的,对于外部内联函数和虚函数表的做法也是类似。比如对于一个虚函数的类来说,有一个与之对应的虚函数表,编译器 会在用到该类的多个编译单元生成虚函数表,造成代码重复。外部内联函数,默认构造函数,默认拷贝构造函数和复制操作符也有类似的问题。 这种方法虽然能够基本解决代码重复问题,但还是存在一些问题。比如相同名称的段可能拥有不同的内容,这可能由于不同的编译单元使用了不同的编译器版本或者 编译优化选项,导致同一个函数编译出来的实际代码有所不同。那么这种情况下链接器可能会做出一个选择,那就是随意选择其中一个副本作为链接的输入,然后同时选择 提供一个警告信息。 2.函数级别的链接 由于现在的程序和库通常非常大,一个目标文件可能包含成千上百个函数或者变量。当我们必须要用到某个目标文件的任意一个函数或者变量时,就必须把它整个链接起来, 也就是说,那么没有用到的函数也被一起链接了进来。这样的后果是链接输出文件会变得很大,所有用到的没用到的变量和函数都一起塞到了输出文件中。 VISUAL C++ 编译器提供了一个编译选项叫 函数级别链接,这个选项的作用是让所有的函数都像前面模板函数一样,单独保存到一个段里面。当链接器需要用到某个函数时, 它就将它合并输出到文件中,对于那些没有用的函数则将它们抛弃。这种做法可以很大程度上减小输出文件的长度,减小空间浪费。但是这个优化选项会减慢编译和链接过程,因为 链接器要计算各个函数之间的依赖关系,并且所有函数都保持到独立的段中,目标函数的段的数量大大增加,重定位过程也会因为段的数目的增加而变得复杂,目标文件随着段数目 的增加也变得相对较大。 gcc 编译器也提供了类似的机制,它有两个选择分别是 "-ffunction-sections" 和 "-fdata-sections", 这2个选项的作用就是讲每个函数或者变量分别保存到独立的段中。 3.全局构造与析构 一般 c\c++程序是从 main 开始执行的,随着 main 函数的结束而结束。然后,其实在 main 函数被调用之前,为了程序能够顺利执行,要先初始化进程执行环境,比如堆分配 初始化(malloc,free),线程子系统等。c++ 的全局对象构造函数也是在这一时期被执行的,我们知道 c++ 的全局对象的构造函数在 main 之前被执行, c++ 全局对象的析构函数 在 main 之后被执行。 Linux 系统下一般程序的入口是 "_start" ,这个函数是 Linux 系统库(glibc)的一部分.当我们的程序与 glibc 库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化 部分的入口,程序初始化部分完成一些列初始化过程之后,会调用 main 函数来执行程序的主体。在 main 函数执行完成以后,返回到初始化部分,它进行一些清理工作,然后结束进程。对于 有些场合,程序的一些特定的操作必须在 main 函数之前被执行,还有一些操作必须在 main 函数之后执行,其中很具代表性的就是 c++ 的全局对象的构造和析构函数。因此 ELF 文件还定义了 2个特殊的段。 .init : 该段里保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在 main 函数被调用之前, glibc 的初始化部分安排执行这个段中的代码。 .fini : 该段保存着进程的终止代码指令。因此,当一个程序的 main 函数正常退出时,glibc 会安排执行这个段中的代码。 这2个段 .init 和 .fini 的存在有着特别的目的,如果一个函数放到 .init 段,在 main 函数执行前系统就会执行它。同理,假如一个函数放到 .fini 段,在 main 函数返回之后该函数 就会被执行到。利用这2个特性,c++的全局构造和析构函数就由此实现。 4.C++ 与 ABI 如果要使2个编译器编译出来的目标文件能够互相链接,那么这2个目标文件必须满足这些条件:采用同样的目标文件格式,拥有统一的符号修饰标准,变量的内存分布方式相同,函数的调用方式相同 等等。其中我们把符号修饰标准,变量内存布局,函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为 ABI(application binary interface)。 API 与 ABI : 实际上它们都是应用程序接口,只是它们所描述的接口所在的层面不一样。 API 往往是指源代码级别的接口,比如我们可以说 Posix 是一个 API 标准;而 ABI 是指二进制层面的接口,ABI 的 兼容性比 API 更为严格, 比如我们可以说 C++ 的对象内存分布是 C++ ABI 的一部分。API 更关注源代码层面的,比如 Posix 的 printf() 这个函数的原型,它能保证这个函数定义在所有遵循 Posix 标准的系统之间都是一样的,但是它不保证 printf 在实际的每个系统执行时,是否按照从右到左将参数压入堆栈,参数在堆栈中如何分布等这些实际运行时的二进制级别的问题。比如,有2台 机器,一台是 Intel x86, 另外一台是 MIPS 的,它们都装了 Linux系统,由于 Linux 支持 Posix 标准,所以它们的 C 运行库应该都有 printf 函数。但实际上 printf 在被调用过程中, 这些关于参数和堆栈分布的细节在不同的机器上肯定是不一样的,升值调用 printf 的指令也不一样。这就是说,API 相同并不代表 ABI 相同。 由于 ABI 互相不兼容,各个目标文件之间无法相互链接,而金辉兼容性更加无从谈起。 影响 ABI 的因素非常多,硬件,编程语言,编译器,链接器,操作系统等都会影响 ABI。 我们可以从 C 语言的角度来看一个编程语言是如何影响 ABI 的。对于 C 语言的目标代码来说,以下几个 方面会决定目标文件之间是否二进制兼容: 1.内置类型(如int, float, char 等)的大小和在存储器中的放置方式(大端,小端,对齐方式等) 2.组合方式(如 struct, union,数组等)的存储方式和内存分布 3.外部符号与用户定义的符号之间的命名方式和解析方式,如函数名 func 在 C 语言的目标文件中是否被解析成外部符号 _func 。 4.函数调用方式,比如参数入栈顺序,返回值如何保存等 5.堆栈的分布方式,比如参数和局部变量在堆栈里的位置,参数传递方法等 6.寄存器使用约定,函数调用时哪些寄存器可以修改,哪些需要保存等。 7.继承类体系的内存分布,如基类,虚基类在基础类中的位置等 8.指向成员函数的指针的内存分布,如何通过指向成员函数的指针来调用成员函数,如何传递 this 指针 9.如何调用虚函数,vtable 的内容和分布形式,vtalbe 指针在 object 中的位置等 10.template 如何实例化 11.外部符号的修饰 12.全局对象的构造和析构 13.异常的产生和捕获机制 14.标准库的细节问题,RTTI如何实现等 15.内嵌函数访问细节 c++ 一直为人诟病的一大原因是它的二进制兼容性不好。不仅不同的编译器编译的二进制代码之间无法互相兼容,有时候连同同一个编译器的不同版本之间的兼容性也不好。很多时候库厂商往往不希望用户 看到源码,所以一般以二进制的方式提供给用户。这样,当用户的编译器型号与版本与编译库所用的编译器型号和版本不同时,就可能产生不兼容。 5.静态库链接 程序之所以有用,因为它会有输入输出,这些输入输出对象可以是数据,也可以是人,也可以是另外一个程序,还可以是另外一台计算机。 但是一个程序如何做到输入输出呢?最简单的办法是使用操作系统提供的应用程序编程接口(API)。当然,操作系统也是一个程序。 程序如何使用操作系统提供的 API ? 一般情况下,一种语言的开发环境往往会附带语言库,这些库就是对操作系统 API 的包装。库里面还 有一些很常用的函数,这部分函数不调用操作系统 API。 静态库可以简单的看成一组目标文件的集合,即很多目标文件打包后形成的一个文件。使用 ar 压缩程序将这些目标文件压缩到一起,并且对其 进行编号和索引,以便于查找和检索。 gcc -v hello.c 过程: 1.调用 cc1 程序,这个程序实际上就是 gcc 的 c 语言编译器,它将 'hello.c' 编译成一个临时汇编文件 '/tmp/ccX1xvlu.s' 2.然后调用 as 程序, as 程序是 GNU 的汇编器,它将 '/tmp/ccX1xvlu.s' 汇编成临时目标文件 'hello.o' 3.最关键步骤,gcc 调用 collect2 程序完成最后的链接. collect2 可以看做是 ld 的一个包装,它会调用 ld 链接器来完成对目标文件的链接。 然后再对链接结果进行一些处理,主要是收集所有与程序初始化相关的信息并且构造初始化结构。 为什么静态库里面的一个目标文件只包含一个函数?比如 libc.a 里面的 printf.o 只有 printf() 函数 ? A: 我们知道,链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库中的 printf() 函数,那么链接器就会把库中包含 printf() 函数的 那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。 6.链接过程控制 绝大部分情况下,我们使用链接器提供的默认规则对目标文件进行链接。一般情况下没有问题,但对于一些特殊程序,比如操作系统内核,BIOS或在一些没有操作系统的情况 下运行的程序(如引导程序 Boot Loader或者嵌入式系统的程序),以及另外的一些需要特殊的链接过程的程序,如一些内核驱动程序,它们往往受限于一些特殊的条件,如需要 指定输出文件的各个段虚拟地址,段的名称,段的存放顺序等,因为这些特殊的环境,特别是某些硬件的限制,往往对程序的各个段的地址有特殊的要求。 由于整个链接过程有很多内容需要确定:使用哪些目标文件,使用哪些库文件,是否在最终可执行文件中保存调试信息,输出文件格式(可执行文件还是动态链接库)?还要考虑是否 要导出某些符号以供调试器或程序本身或其他程序使用等。 1.链接控制脚本 链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所需要的文件。一般链接器有如下3种方法: 1.使用命令行来给链接器指定参数,如 ld 使用的 -o, -e 2.将链接器指令存放在目标文件里面,编译器进程会通过这种方法向链接器传递指令。 3.使用链接控制脚本,也是最为灵活,最为强大的链接控制方法 Visual C++ 也允许使用脚本来控制整个链接过程,它把这种控制脚本叫做模块定义文件,它们的扩展名一般为 .def 2.最小程序 char *str = "Hello,world\n"; void print() { asm("mov1 $13, %
转载请注明原文地址: https://www.6miu.com/read-4915137.html

最新回复(0)