【读书笔记】【程序员的自我修养 -- 链接、装载与库(二)】进程虚拟地址空间、装载与动态链接、GOT、全局符号表、共享库的组织、DLL、C++与动态链接

前言

上接:【读书笔记】【链接、装载与库 part I 】程序员的自我修养 – 链接、装载与库;目标文件格式;静态链接;

介绍

  • PIC(positon-independent code,地址无关代码)

  • GOT (global offset table,全局偏移表)

  • PLT (procedure linkage table,过程链接表)- 实现延迟绑定

  • ABI(application binary interface,二进制接口)

  • FHS(file hierarchy stadndard,文件层次结构标准)

  • DLL(dynamic-link library,动态链接库)

  • RVA(relative virtual address,相对地址)

  • IAT(import address table,导入地址数组)

  • COM(component object model,组件对象模型)

  • 函数签名:函数的参数和返回值类型。

可执行文件的装载与进程

进程虚拟地址空间
  • 硬件决定了地址空间的最大理论上限,即硬件的寻址空间大小,即32位硬件平台决定了4GB的虚拟空间大小。
    • 通常情况下,指针的位数与虚拟空间地址位数一致。(当然存在历史中MSC 的C语言有长、短、近指针,为了适应当时畸形处理器而设立)
  • 进程的虚拟空间由OS 掌控,进程只能使用OS分配给进程的地址,访问越界会被OS捕获并结束进程。(segment fault就是进程访问了未经允许的地址)
    • Linux 32位下,默认3GB进程虚拟空间
    • windows 32位下,默认2GB进程虚拟空间,但是有个启动参数的修改可以使得windows 将OS占用虚拟地址空间减少至1GB,与linux同步。(修改Boot.ini)

linux 下4GB进程虚拟地址空间分配如下:

  • PAE(physical address extension):1995年 的pentium pro CPU采用了36位的物理地址,为了超过4GB的寻址空间,即将地址线扩展至36位。intel修改了页映射的方式,这个地址扩展方式为PAE。
    • 同时操作系统提供一个窗口映射的方式,将额外的内存映射到进程地址空间中
      • 如在进程空间中开辟一段空间(如1G)作为窗口,再将多出4G的物理空间中申请多个1G空间,编号后根据需要将窗口映射到不同的物理空间块上,以实现OS包装,让应用程序还是只有32位虚拟地址空间。
装载方式
  • 程序运行需要将指令与数据装载进内存,多数情况下程序所需内存大于实际物理内存
    • 由于程序运行具有局部性原理,可以采用动态装载的方式,即程序中常用部分驻留内存,不常用数据存放磁盘。
    • 动态装载的方法包括了覆盖装入(overlay)、页映射(paging)
      • 覆盖装入:速度较慢,用时间换空间的方案。需要程序员手工将代码按照模块区分,并将模块按照调用依赖关系组织成树结构,编写覆盖管理器将子模块替换,以节省内存的占用。
      • 页映射:通过将内存和磁盘中的数据与指令都按照页为单位划分成若干页。由OS中的存储管理器作为装载管理器,将使用到的指令与数据装载进内存,并在内存不足时将不用的指令与数据放弃,这时候有很多页面置换算法(FIFO、LRU等等 – 【C++】【缓存替换策略】【LRU】【LFU 】【FIFO】LRU算法C++实现,并测试;)。Windows 下对PE文件、Linux 下对ELF文件的装载都是这样完成的。
操作系统对可执行文件的装载
  • 程序如果直接对分页的物理内存进行操作,那么每次页错误后重新装入,都需要重定位。所以由MMU的的机器使用虚拟内存,有了地址转换和页映射机制,让OS动态加载可执行文件。

  • 进程的建立分为三部:

    1. 创建虚拟地址空间:实际时创建一个映射函数所需的数据结构,以实现虚拟空间映射只物理空间。实际是分配一个目录在发生页错误的时候,再进行页映射关系的设置。
    2. 读取可执行文件头、建立虚拟空间与可执行文件的映射关系:发生页错误时,OS要知道需要的页在可执行文件的什么位置,这就是虚拟空间与可执行文件的映射关系。
      • 通过读取文件头,得到每个段的起始位置与大小,将其映射到虚拟内存中。
      • 记录映射关系的是OS内部的一个数据结构,记录着进程空间中段的虚拟空间地址与ELF文件中偏移地址的映射关系。
      • 当发生段错误时,通过这个数据结构定位错误页在可执行文件中的位置
    3. 将CPU指令寄存器设置为可执行文件的入口并启动运行:这一步OS将CPU的指令寄存器控制权交给进程, 涉及了内核与用户堆栈的切换、CPU运行权限的切换等。程序入口地址也保存在ELF文件头中。
  • 页错误

    • 当发生页错误时,OS将接管CPU,同过映射关系找到空页所在VMA,计算出对应页面在可自行文件中的偏移,然后在物理内存分配一个物理页面,将进程中该虚拟页与分配的物理页之间建立映射关系,然后再将CPU控制权交给进程。
  • 这里有两个地方记录着映射关系:虚拟内存页面与可执行文件中的偏移之间的映射关系、物理页面与进程虚拟空间之间的映射关系。(由MMU映射) 如下图所示。

进程虚存空间分布
ELF文件的链接视图和执行视图
  • 链接的角度看,ELF 文件按照section 存储。从装载的角度看,ELF文件按照segment划分,segment将相同权限属性的段分配在同一空间。
  • readelf -S hello.elf 可以看到执行文件的section,readelf -l hello.elf 可以看到执行文件的segment。
  • ELF可执行文件有一个程序头表的数据结构,用于存放segment信息。因为ELF目标文件不需要装载,所以没有程序头表,而ELF可执行文件和共享库文件都有。
  • 和段表一致,程序头表也是一个结构体数组,保存了类型、文件中偏移、虚拟地址空间的起始位置、物理装在地址(一般和p_vaddr一致)、segment 在ELF文件中和虚拟空间中所占空间、权限以及对齐属性。
堆和栈
  • 进程中的segment除了代码VMA(virtual memory area)和数据VMA,还有堆VMA、栈VMA。

  • 32位环境下,实际能够申请的空间并不是linux 下3GB、windows 下2GB,因为收到系统版本、程序本身大小、用到的动态/共享库数量、程序栈数量等,使其并不足那么大的空间。还有可能随机地址空间分布引起的。

  • 装载时的映射,一般通过虚拟内存的页映射完成,因为段和页大小不一致,可能造成很大的内存碎片,一般存在段地址对齐的操作。即有些物理页面被映射多次再对齐段地址,实现比按段分更少的碎片。如下图所示。

Linux 内核装载ELF & windows 装载PE
  1. bash调用fork()创建新进程后,采用execve()系统调用执行指定ELF文件。
  2. 读取ELF文件头128字节,用于判断文件格式,以调用对应的可执行文件装载过程函数。
    1. 检查ELF文件有效性,如magic、程序头表中段的数量。
    2. 设置动态链接器路径。
    3. 根据ELF可执行文件的程序头表,对ELF文件进行映射。
    4. 初始化进程环境。
    5. 修改返回地址为可执行文件入口点。


  • PE并没有融合section,而是让segment起始对齐页,这样映射就会简单。
  1. 读文件的第一个页,包含了DOS头、PE文件头和段表
  2. 检查进程地址空间中的目标地址是否可用。(DLL装载相关)
  3. 根据段表将PE文件中的段映射到地址空间。
  4. 装载PE需要的DLL
  5. 解析PE中的导入符号
  6. 根据PE头指定参数,建立初始化堆、栈。
  7. 建立主线程,启动进程。

动态链接 —— 《《重点》》

why 动态链接
  • 静态链接的缺点: 空间浪费严重、更新麻烦。
  • 动态链接:将程序模块分割开来,运行时才进行链接。产品升级更加容易。
    • 运行时,将依赖目标文件都加载进内存后,满足了依赖关系,开始进行链接工作。(符号解析、地址重定位等)
    • 不但节省了内存(公共模块只在内存中存在一份),且提升了CPU缓存命中率。(访问集中在了同个共享模块上)
    • 插件实现扩展性:产品暴露规定的接口,按照这个接口要求的第三方开发的模块,在程序运行时动态的链接,实现了程序功能的扩展
    • 兼容性:可以用动态链接库充当程序与OS间的中间层,消除不同平台的差异性。(如多个OS都将printf包装到动态链接库,就可以让程序在不同的OS上同一套代码运行),即跨平台特性,当然实际更加复杂。
  • 动态链接的缺点:存在新旧模块的接口的兼容性问题、动态链接的性能损失。
    • 主要因为动态链接下对全局和静态数据的访问都要进行复杂的GOT定位,然后间接寻址。对于模块间的调用也先GOT,然后再间接跳转。
    • 且程序开始执行时,动态连接器都要进行一次链接工作。首先寻找和装载需要的共享对象,然后进行符号查找、重定位等工作。
case of 动态链接 & 地址无关性
  • gcc -fPIC -shared -o Lib.so lib.c 将lib.c编译为.so动态链接库文件(-shared表示产生共享对象,-fPIC表明产生地址无关码*)

  • gcc -o program1 program1.c ./Lib.so 编译链接 program1.c 。

  • 静态链接时,会将program1.c 中外部函数重定位,

  • 动态链接时,会将program1.c 中动态共享对象中的函数的 引用标记为一个动态链接的符号,将重定位过程留在装载时进行。

    • 因为动态库Lib.so中保存了完整的符号信息,将Lib.so也作为链接文件之一,链接器在解析符号时可以知道program1.c 中的外部函数是动态库中的,就可以对其做特殊处理,使其成为一个对动态符号的引用。
  • 依靠动态链接的程序在运行时,进程虚拟空间中除了程序本身还有动态链接库、C语言运行库、动态链接器。启动程序时,控制权首先是交给动态链接器的,完成动态链接过程后才交给程序本身。

  • 动态链接库文件的装载属性除了名称,基本和普通程序一致,只是装载地址为0,由动态链接器在装载时确定。

    • 可执行文件基本可以确定自己在进程虚拟空间的起始位置,因为可执行文件是第一个被加载的文件,可以使用这个固定空闲的地址。 共享对象在编译时就不能假设自己在进程虚拟空间中的位置。
    • 动态链接模块的指令部分在多个进程间共享的,可修改数据部分对于不同进程有多个副本。所以数据部分可以使用装载时重定位解决。
    • 装载时重定位:装载时对程序的指令和数据中绝对地址的引用进行重定位
  • 地址无关代码(PIC,positon-independent code):使得程序模块中的共享指令部分,在装载时不需要因为装载地址的改变而改变。

    • 实现:将指令中需要被修改的部分分离出来,跟数据部分放在一起,使得指令部分可以保持不变,而数据部分在每个进程中拥有一个副本。
    • 这样访问时,模块内部的数据和指令采用相对地址或者相对跳转与调用,模块外部的数据与指令,采用间接访问或者间接跳转与调用(GOT,global offset table)。
    • GOT :在数据段中建立一个指向别的模块数据或指令的指针数组,成为全局偏移表(GOT,global offset table),表在数据段,在装载时被修改每个进程中拥有独立的副本,相互不受影响。在装在模块时,查找其他模块中需要的变量与指令的地址,然后填充在GOT的各个项中。
  • 共享模块中的全局变量

    • 共享模块的全局变量,主程序在extern引用了以后,会在bss段留下副本。如果一个变量同时存在多个位置中,着在程序实际运行过程中肯定有问题。
    • 所以所有使用这个全局变量的指令都指向位于可执行文件中的那个副本
    • 当共享模块装载时,某个全局变量在可执行文件中有副本,动态链接器会把GOT中的相应地址指向该副本。
    • 这个全局变量在程序主模块中没有副本,那么GOT中独赢地址就只想模块内部的该变量副本。
    • 如果变量在共享模块中初始化,那么动态链接库需要将初始化值复制到程序主模块的副本中。
      • lib.so 被多个进程加载,数据段会在每个进程都有独立的副本。lib.so 中的全局变量对于进程来说,会在数据段产生一个副本,和访问自身程序的全局变量一致。
  • 数据段地址无关性

    • 如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。
    • 装载时重定位的共享对象的运行速度比使用地址无关的代码更快,因为省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及简介地址寻址的过程
延迟绑定(PLT)
  • 延迟绑定:正常的动态链接过程,程序启动时,链接器会去解决模块间的函数引用的符号查找以及重定位。但是很多共享模块中的函数并没被用到,所以采用延迟绑定的做法:函数第一次被用到的时候才进行绑定。
    • ELF 使用PLT(procedure linkage table,过程链接表)来实现延迟绑定。
      • PLT让函数调用中加了一层间接跳转。调用函数不通过GOT跳转,而是通过PLT项进行跳转,每个外部函数在PLT中都有一个相应的项。
动态链接下相关结构
  • 可执行文件的装载:

    1. 可执行文件的装载
      1. os 读可执行文件头部。
      2. 从program header 中读取每个segment 的虚拟地址、文件地址及属性,将其映射到进程虚拟空间的相应位置。
    2. 加载动态链接器,转动态链接器的入口地址开始执行。(动态链接器也是个共享对象,也有入口地址,也被加载到进程地址空间中)
      1. 动态链接器开始自身初始化。
      2. 对可指向文件进行动态链接。
  • ELF可执行文件决定了使用的动态链接器位置。

    • .interp 段存了可执行文件需要的动态链接器的路径
    • .dynamic 段存了动态链接器所需要的基本信息。(依赖的共享对象、动态链接符号表位置、动态链接重定位表位置、共享对象初始化代码的地址等,有点像静态链接的ELF文件头。)
    • ldd program1 可以查看程序主模块或共享库 依赖哪些共享库
  • 动态符号表: 动态链接的关键部分在于所依赖的符号、相关文件信息。

    • 静态链接中有个段作为符号表 .symtab(symbol table),保存了该目标文件的符号定义与引用
    • ELF还有一个动态符号表的段 .dynsym(dynamic symbol ),其只保存了动态链接相关的符号。
    • 动态链接的模块同时拥 .dynsym.symtab.symtab保存了所有符号,包括了 .dynsym中的符号。
    • 静态链接有.strtab(符号字符串表)用于保存符号名的字符串表。 动态链接有对应的 .dynstr(动态符号字符串表)
    • 动态链接有.hash (符号哈希表),用于在程序运行时加快符号的查找过程
  • 动态链接重定位表:

    • 采用绝对地址寻址的位置需要重定位
    • 静态链接时,对外部符号的引用在最终链接时被修正。
    • 动态链接时,导入符号地址在运行时才能确定,需要在运行时将这些导入符号的引用修正,即需要重定位
    • 静态链接时,有用于表示重定位信息的重定位表,比如.rel.text.rel.data ,分别为代码段、数据段的重定位表。
    • 动态链接中也有对应的重定位表.rel.dyn.rel.plt,前者用于对数据引用的修正,修正的位置位于.got以及数据段,后者用于对函数引用的修正,修正的位置位于 .got.plt
    • 链接重定位过程:
      1. 动态链接需要重定位时,查找对应函数的地址。
      2. 查找到的函数对应的.so,在全局符号表中找到该函数的地址X。
      3. 将地址X填入.got.plt中偏移位置Y中去。以实现地址重定位。(Y表示了该函数在.got.plt中的偏移)
  • 动态链接的进程堆栈中,保存了OS传递给动态链接器的信息,用于让动态链接器了解可执行文件的段信息、属性、程序入口地址等。同时还保存了动态链接器所需要的一些辅助信息数组。

动态链接的步骤和实现
  • 主要分为:启动动态链接器、装载所有需要的共享对象、重定位与初始化。

  • 动态链接器自举:

    • 普通共享对象的重定位工作由动态链接器完成,也可以依赖于其他的共享对象,依赖的对象又由动态链接器负载链接和装载
    • 动态链接器本身也是共享对象,但是本身不可以依赖其他共享对象,同时本身所需的全局、静态变量的重定位也需要自身来完成(自举)。
    • 自举代码入口就是动态链接器的入口地址,OS将进程控制权交给动态链接器时,自举代码开始执行。
      • 自举代码首先找到自己的 GOT ,GOT的第一个保存的是.dynamic 段的偏移。
      • 根据.dynamic 段中信息,自举代码可以获得自身的重定位表和符号表
      • 根据动态链接器的重定位表,将他们全部重定位后,动态链接器才可以使用自己的全局、静态变量。
      • 自举代码中同样不能调用函数,因为PIC模式编译的共享对象,对于模块内外调用一致,使用GOT/PLT方式,所以在没有重定位GOT/PLT之前,自举代码不能调用函数。
  • 装载共享对象:

    • 自举后,动态链接器将自身和可执行文件的符号表合并,变成了全局符号表
    • 链接器开始寻找可执行文件的共享对象, .dynamic 段中有一种类型指出可执行文件所依赖的共享对象,将所有的依赖对象名字放入一个装载集合中。
    • 找到对应共享对象文件后,读取对应ELF文件头和.dynamic 段,将这个共享对象的代码段、数据段映射到进程空间中,然后将这个对象所依赖的共享对象同样装载进装载集合中。
    • 装载过程就是一个有向图的遍历过程,一般使用广度优先,也有链接器使用深度优先。一直到所有依赖的共享对象被装载进来为止。
    • 每个共享对象被装载进来时,其符号表被合并到全局符号表中,当装载结束,全局符号表中包含了进程中所有动态链接所需的符号
    • 符号优先级
      • 多个模块定义了同一个符号,装载后,进程空间中这些模块都被装载了,但是存在共享对象全局符号介入(global symbol interpose,当一个符号需要加入全局符号表时,如果存在同名符号,则后加入的符号被忽略)。这样使得引用多个模块中的这个符号,却只能得到第一个被引入的符号的效果。(执行A、B模块中的a函数,都实现的是A模块中的a函数)
  • 重定位与初始化:

    • 链接器开始遍历可执行文件和共享对象的重定位表,将其GOT/PLT 中每个需要重定位的位置进行修正。
    • 此时已经有了全局符号表,再进行重定位就容易多了。
    • 重定位完后,共享对象中有.init 段的,动态链接器将执行 .init 段中代码,以实现共享对象的初始化。(如共享对象中C++ 的全局、静态对象的构造),同时还有.finit 段在进程退出时被执行。
    • 可执行文件的.init 段不会由动态链接器去执行,而是由程序初始化部分代码执行。
  • Linux 动态链接器实现:

    • linux 程序装载时,通过execve() 系统调用被装载到进程的地址空间。

    • 动态链接器是个特殊的共享对象,他不仅是个共享对象,还是个可执行文程序

    • 因为execve() 并不关心ELF文件是否可执行,只是对可执行文件的装载后,对其程序头表中的描述进行装载,分析其ELF入口地址后将控制权转交给入口地址。(有.interp 就是动态链接的e_entry,没有就是ELF文件的e_entry)。也就是有动态链接地址段,说明需要被动态链接,就把动态链接器映射金进程地址空间,然后把控制权交给动态链接器。

    • 也就是说共享库和可执行文件除了文件头的标志位和扩展名不同,其他都一样。

    • 动态链接器本身是静态链接的,不能依赖于其他共享对象。

    • 动态链接器的装在地址和共享对象一样都是无效的0地址,内核在装载时选择一个合适的装载地址。

显式运行时链接
  • 显式运行时链接 :程序运行时,加载或卸载模块。常用于实现一些插件、驱动等。常见于web服务器端。

    • 能够减少程序启动时间和内存使用,同时可以使得程序本身不用重启即可实现模块的增加、删除、更新等。
    • 共享库由动态链接器在程序启动之前负责装载和链接。
    • 动态库的装载通过动态链接器提供的API,让程序通过这些API对动态库进行操作。(打开动态库、查找符号、错误处理、关闭动态库)
      • dlopen()打开动态库 :返回被加载模块的句柄,有参数表明时PLT机制还是直接绑定。
      • dlsym()查找符号 :返回函数、变量的地址,如果是常量,就返回常量。(常量也是符号)
      • dlerror()错误处理:其他的操作后,都可以检查错误。
      • dlclose()关闭动态库:卸载已经加载的模块,先执行.finit段代码,在从符号表中移除自己的符号,取消进程空间映射关系,然后关闭模块。(有引用计数的机制,及每次卸载减少一个计数,直到计数减为0才真正卸载。
  • windows下的rundll,将dll文件加载进来运行,就是利用了运行时加载的原理。

Linux共享库的组织

共享库版本
  • 动态链接使得程序和共享库可以独立开发和更新,更具灵活性。其中共享库的更新被分为了兼容更新和不兼容更新。
    • 这里的兼容接口,时指的二进制接口ABI。ABI对于不同语言来说,主要包括一些函数调用的堆栈、符号命名、参数规则、数据结构的内存分布等规则。
    • 不同版本的编译器、 操作系统、硬件平台等,都可能导致ABI不兼容。
    • C++由于模板、虚函数等使得ABI兼容更复杂,不推荐使用C++作为共享库的接口,可以使用C做接口。
  • 共享库版本:使用共享库版本是解决共享库兼容性的有效方法之一。
    • 通过主版本号不兼容,次版本号、发布版本号向下兼容的特性。(主版本号保证接口兼容、次版本号向下兼容,发布本版号完全兼容)
  • SO-NAME: 共享库的文件名去掉次版本号和发布版本号。
    • 程序所依赖一个共享库,必须包含被依赖共享库的名字和主版本号。
    • Linux 下,系统为每个共享库所在目录创建一个跟 SO-NAME 相同且指向它的软连接
      • 软链接指向目录中:主版本号相同,次版本号和发布版本号最新的共享库。 保证了以SO-NAME为名的软链接都指向系统中最新版本的共享库。
      • 这样可以在编译输出ELF文件时,将被依赖的共享库的SO-NAME保存在.dynamic中,在动态链接时会根据共享库目录中的SO-NAME 软链接定向到最新版本的共享库。
    • ldconfig :安装或者更新一个共享库时,使用其更新软链接,使其指向最新版共享库。如果安装新的共享库,使用其会为新共享库创建相应软链接。
符号版本
  • 符号版本机制: 当程序依赖较高此版本号的共享库,运行于较低次版本号的共享库系统中,可能产生缺少某些符号的错误(新次版本号的共享库可能添加了旧版没有的符号)。这种次版本号交会的问题没有因为SO-NAME 而改善。需要用符号版本机制
  • 基于符号的版本机制:保持SO-NAME的同时,在新次版本号中新添加的全局符号都打上一个标记。如VERS_1.3
    • 这种符号版本的方法,是对SO-NAME 机制保证共享库主版本号一致的一种补充。
共享库系统路径 & 共享库查找过程
  • Linux 和诸多开源OS 都遵守一个FHS(file hierarchy stadndard),规定OS中的系统文件该如何存放。
  • 共享库查找过程
    • 动态链接时,程序所依赖的共享对象全部由动态链接器负责装载和初始化。
    • 一个模块依赖的模块路径保存在.dynamic 段里。
    • 为了程序可移植性和兼容性,共享库路径往往是相对的。
    • 动态链接器在每次查找共享库时遍历整个目录会很费时。
      • 一般由 ldconfig 程序将共享库目录下的共享库创建、删除、更新相应的 S0-NAME(对应的符号链接),使得每个 SO-NAME 能够指向正确的共享库文件。
      • ldconfig 还会将这些 SO-NAME 收集起来,集中存放,并建立一个 SO-NAME 缓存,一共链接器快速查找。
      • 链接器如果在cache 中没找到,就会去目录中找,再找不到会宣告失败。
环境变量
  • LD_LIBRARY_PATH:改变共享库查找路径。
    • 为某个进程设置了LD_LIBRARY_PATH 进程启动时,链接器首先查找LD_LIBRARY_PATH指定的目录。
  • LD_PRELOAD :指定预先装载的一些共享库或目标文件。除了调试与测试,正常情况下避免使用,尤其是发行版本。
  • LD_DEBUG :动态链接器会在运行时,打印出各种有用的信息。
共享库的创建和安装
  • 共享库和共享对象的创建基本一致
    • gcc -shared -fPIC -W1, -soname,libfoo.so.1 -o -libfoo.so.1.0.0 libfoo1.c libfoo2.c -libar1 -libar2
      • -shared 表示输出的时共享库类型,-fPIC 表示使用地址无关代码,-W1 表示将指定参数传递给链接器。最后跟的是库名称、源文件、依赖库文件。
  • 默认情况下可执行文件中带有符号信息和调试信息,在调试时十分有用,但是在发布版本来说用处不大。
    • 可以使用strip 工具清楚共享库或可执行文件的所有符号和调试信息。
    • 或者使用编译时加不产生符号信息和调试信息的参数。
  • 共享库的构造与析构: GCC提供了一种声明,用于共享库的构造与析构。
    • __attribute((constructor)) 声明构造函数 __attribute((destructor))声明析构函数。
    • 同时__attribute((constructor(5)))__attribute((destructor(5))) 表示多个构造函数和析构函数,数字为优先级,析构函数的优先级相反,用于和构造函数相匹配。

Windows 下的动态链接

DLL
  • DLL(dynamic-link library,动态链接库) :相当于Linux下的共享对象。

    • windows下的DLL和EXE实际上是一个概念,都是有PE格式的二进制文件。DLL的扩展名除了.dll以外,还有.ocx(OCX控件)或是.CPL(控制面板程序)
    • Linux下的ELF运行时加载,在Windows下更加广泛,著名的ActiveX 技术,就是基于这种运行时加载机制实现的。
  • 进程地址空间和内存管理:

    • windows早期版本所有引用程序在一个地址空间,DLL也是,所有程序共享DLL且随意访问,以此实现进程间通信。但是容易DLL中数据的损坏。
    • 32位windows开始支持进程独立地址空间,DLL在不同进程中拥有不同的私有数据副本,但是与ELF不同的是:DLL的代码并不是地址无关的,只在某些情况下被多个进程共享。
    • win32下windows提供了一种DLL来实现进程间通信的方法。通过允许DLL数据段设置为共享,使任何进程都可以共享该数据段。也可以单独分离出来一个数据段以供共享。
  • 基地址与RVA(relative virtual address,相对地址):

    • PE文件被装载时,进程空间中的起始地址就是基地址,每个PE文件都有一个优先装载的基地址,这个值为PE头文件的 image base
    • EXE和DLL的 image base不一致,当DLL装载地址冲突时,PE装载器会选用其他空闲地址。
  • DLL的全局函数和变量的导入导出需要显式声明,默认都不导出。

    • __declspec(dllexport) 声明符号需要从该DLL导出, __declspec(dllimport) 声明符号是从别的DLL导入的。
    • C++中,符号前添加exter "C" 保证导入或导出的符号符合C语言符号修饰规范,以防止C++编译器进行符号修饰
  • DLL创建:

    • 使用MSVC 编译器c1编译产生debug 或release 版本的DLLc1 /LDd Math.cc1 /LD Math.c
    • 产生的Math.DLL就是需要的DLL文件,还会产生Math.obj 编译的目标文件、Math.expMath.lib。可以使用dumpbin 查看DLL导出的符号。
  • DLL使用: 就是引用DLL中的导出函数和符号的过程,即导入过程。

    • 使用 c1 /c TestMath.c 编译,使用 link TestMath.obj Math.lib 链接。
      • 静态链接时 .lib文件是一组目标文件的集合 ,动态链接也是,但是Math.lib用于描述Math.dll的导出符号,包含了TestMath.obj 链接Math.lib时所需要的导入符号和一些桩代码(“胶水”代码)用于将程序与DLL粘在一起。
      • Math.lib这样的文件又被称为导入库。
  • 使用模块定义文件(.def)::

    • 声明DLL中某个函数为导出函数除了使用 __declspec(dllexport) ,还可以采用模块定义(.def)文件声明,类似连接脚本的作用,用于控制链接过程。c1 Math.c /LD /DEF Math.def
    • 优点在于可以控制导出符号的符号名。
      • 很多时候,编译器会对源代码符号进行修饰,除了C++,C语言符号也有可能被修饰,比如不同的调用规范下修饰不同。使用.def文件就可以将导出函数重命名, 便于维护和使用。
      • .def文件还可以控制输出文件的默认堆大小、输出文件名、段属性、默认栈大小、版本号等。
  • DLL显式运行时链接::

    • 与ELF类似, DLL也支持运行时链接(运行时加载)。
      • windows 提供了装载DLL、查询符号地址、卸载已加载模块,这三个API。(LoadLibraryGetProcAddressFreeLibrary)
符号导出导入表
  • 导出表: 一个PE需要将一些函数、变量提供给其他PE文件使用时,叫做符号导出

    • ELF的动态链接时,ELF将导出符号保存再.dynsym 段中,让动态链接器查找和使用。
    • windows PE中,导出的符号被存放再导出表中。
      • PE文件头有个 DataDirectory 结构数组,第一个元素就是导出表的结构和长度。
        • 导出表中的最后三个成员指向三个数组,分别为导出地址表(EAT,export address table)、符号名表、名字序号对应表
      • 现在的DLL导出方式基本使用符号名,实际上为了向后兼容,序号导出方式仍被保留。
    • 对于链接器来说,链接DLL时,要知道哪些函数和变量要被导出,因为PE默认不导出全局函数和变量,通过__declspec(dllexport) 或者链接参数来指定导出符号。
  • EXP文件:

    • DLL 创建过程会有个EXP临时文件产生。
    • 链接器会遍历两次DLL,第一次将所有目标文件的导出符号收集,并创建DLL导出表,并将这个表放入临时产生的EXP文件的.edata 段中。
    • 第二次扫描时,链接器将这个EXP文件当普通目标文件一样,和其他目标文件一起链接输出DLL。
  • 导出重定向: 将某个导出符号重定向到另一个DLL中。

    • 将A库中的a函数重定向到B库的b函数上,调用a就相当于调用b。
      • 重定向函数,使用模块定义文件.DEF
  • 导入表:

    • 在程序中使用到了DLL的函数或者变量,即符号导入。
      • ELF 中, .rel.dyn.rel.plt 分别保存了该模块需要导入的变量、函数,以及符号所在的模块等。而got.got.plt保存着这些变量和函数的真正地址。
      • Windows下对应为导入表。当PE被加载时,windows加载器将所有需要导入的函数地址确定,并将导入表中的元素调整正确地址。
    • dumpbin /IMPORTS Math.dll 来查看模块依赖于哪些DLL。一般会有很多构建Windows DLL时,链接的支持DLL运行的基本运行库。
    • PE中,导入表是一个IMAGE_IMPORT_DESCRIPTOR 结构体数组,每个结构对应一个被导入DLL。
      • 结构体中的FirstThunk 指向一个IAT(import address table,导入地址数组),IAT中每个元素指向一个被导入的符号,其值再连接结束后,由符号名或序号被修改成真正的符号地址。(类似于ELF中的GOT)
  • 导入函数的调用:

    • PE 没有地址无关性,装载时模块在进程空间中的地址冲突由重定基地址解决(rabasing)
DLL优化
  • DLL 代码段和数据段并不是地址无关,默认装载在ImageBase 目标地址上,如果冲突,需要装载在其他地址,会引起整个DLL的Rebase,影响性能。

  • 重定基地址(rebasing): PE采用装载时重定位

    • 即目标地址冲突时,DLL装载在新的地址, 每个绝对地址引用都需要重定位。
    • 但是这个重定位比较特殊,只需要重定位处加上偏移,也就是重定基地址。(正常的重定位要找地址,再修正)
  • 系统DLL:

    • windows自带了很多系统DLL,是windows引用程序运行时都需要的用到的。
    • windwos系统在进程空间0x70000000~0x80000000分配一块区域专门映射给这些DLL,在windows安装时就将这些地址分配给这些DLL,使其相互不冲突,不需要在装载时重定基址了。
  • 序号:

    • DLL中每个导出函数都有一个对应序号,可以没有函数名。
    • 从DLL导入函数时,可以使用函数名或序号。
    • 序号标示着被导出函数在DLL 导出表中的位置
C++与动态链接
  • C++编写共享库存在的问题:共享库会更新,导致一系列问题。

    • C++标准只规定了语言层面,没有对二进制级别的规定。
  • 为了解决兼容性问题,提高程序的重用性。微软很早开始了COM(component object model,组件对象模型) 的开发。

    • 《COM本质论》 描述了COM实现机制。
      • 在这里插入图片描述
  • DLL HELL:DLL版本控制机制的问题导致DLL噩梦(dll hell)

    • 常发生在旧版本DLL覆盖新版本DLL、向下兼容性问题、安装引起的bug。

总结

  • C++标准只规定了语言层面,没有对二进制级别的规定。
  • C++的符号修饰更加复杂,使得无法二进制兼容。ABI
  • 使用C++实现DLL,也可能DLL HELL
  • windows 本身基于动态链接,Windows 的API以DLL形式提供给开发者,Linux等则以系统调用作为OS 的最终入口。

参考

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值