C数组的大小如何成为库二进制接口的一部分

大多数C编译器允许您访问具有未定义边界的extern数组,例如:

 extern int external_array[]; int array_get (long int index) { return external_array[index]; } 

external_array的定义可能在另一个翻译单元中,可能看起来像这样:

 int external_array[3] = { 1, 2, 3 }; 

问题是,如果这个单独的定义发生如下变化,将会发生什么:

 int external_array[4] = { 1, 2, 3, 4 }; 

大概:

 int external_array[2] = { 1, 2 }; 

二进制接口是否会保留(前提是存在一种允许应用程序在运行时确定数组大小的机制)?

奇怪的是,在许多体系结构上, 增加阵列的大小会违反二进制接口兼容性(ABI)。 减小阵列的大小也会导致兼容性问题。 在本文中,我们将仔细研究ABI兼容性并解释如何避免出现问题。

可执行文件的数据部分中的链接


为了了解数组的大小如何成为二进制接口的一部分,我们首先需要检查可执行文件的data部分中的链接。 当然,细节取决于特定的体系结构,这里我们将重点介绍x86-64体系结构。

x86-64体系结构支持相对于程序计数器的寻址,即访问全局数组变量(如先前显示的array_get函数中所示)可以编译为单个movl指令:

 array_get: movl external_array(,%rdi,4), %eax ret 

由此,汇编器将创建一个目标文件,其中的指令标记为R_X86_64_32S

 0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq 

此举告诉链接器( ld )在创建可执行文件时如何在链接期间填充external_array变量的相应位置。

这有两个重要的后果。

  • 由于变量的偏移量是在构建时确定的,因此在运行时没有开销来确定它。 唯一的代价就是访问内存本身。
  • 要确定偏移量,您需要知道所有变量数据的大小。 否则,将无法在布局期间计算数据部分的格式。

对于GNU / Linux中面向可执行和链接格式(ELF)的 C实现,对extern变量的引用不包含对象大小。 在array_get示例中array_get即使编译器也不知道对象array_get大小。 实际上,整个汇编程序文件如下所示(仅省略了-fno-asynchronous-unwind-tables的升级信息,这是psABI遵从性的技术要求):

  .file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits 

在该汇编器文件中没有external_array大小信息:唯一的字符引用在movl指令的行上,并且该指令中唯一的数字数据是数组元素的大小(乘以movl乘以4)。

如果ELF要求未定义变量的大小,那么甚至不可能编译array_get函数。

链接器如何获得实际字符大小? 他查看符号的定义,并使用在那里找到的尺寸信息。 这使编译器可以计算数据节的布局,并使用适当的偏移量填充数据移动。

常见的ELF对象


ELF的C实现不需要程序员添加源代码标记来表明函数或变量位于当前对象(可能是库或主要可执行文件)中还是位于另一个对象中。 链接器和动态加载程序将负责此工作。

同时,人们希望可执行文件不要通过更改编译模型来降低性能。 这意味着在编译主程序的源代码时(即不-fPIC ,在这种情况下不使用-fPIE ),在引入动态共享库之前,将array_get函数编译成完全相同的命令序列。 此外,在最基本的可执行文件中定义了external_array变量还是在运行时是否单独加载任何共享库都没有关系。 在两种情况下,编译器创建的指令都是相同的。

这怎么可能? 毕竟,常见的ELF对象与位置无关。 它们在运行时加载到不可预测的随机地址 。 但是,编译器会生成机器码序列,该序列要求这些变量位于链接过程中计算固定偏移量处 ,而这要早于程序启动。

事实是,只有一个加载的对象(主可执行文件)使用这些固定偏移量。 所有其他对象(动态加载器本身,C运行时库和程序使用的任何其他库)都被编译为完全独立于位置的对象(PIC)。 对于此类对象,编译器将从全局偏移量表(GOT)中加载每个变量的实际地址。 如果使用-fPIC编译array_get示例,则会看到此回旋处,这将导致此类汇编代码:

 array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret 

结果, external_array变量的地址不再硬编码,并且可以在运行时通过适当初始化GOT记录进行更改。 这意味着在运行时, external_array的定义可以在相同的共享库,另一个共享库或主程序中。 动态加载程序将根据ELF字符搜索规则找到适当的定义,并通过将GOT记录更新为其实际地址来将未定义的符号引用与其定义相关联。

我们回到原始示例,其中array_get函数位于主程序中,因此直接指定变量的地址。 链接器中实现的关键思想是, 即使实际上在运行时在公共对象中定义了主程序,主程序也将提供external_array变量定义。 动态加载程序将在可执行文件的数据部分中选择变量的副本,而不是在共享库中指定变量的初始定义。

这有两个重要的后果。 首先,回想一下external_array的定义如下:

 int external_array[3] = { 1, 2, 3 }; 

这里有一个初始化程序,应将其应用于主可执行文件中的定义。 为此,在主可执行文件中放置指向该符号的副本位置的链接。 readelf -rW显示为R_X86_64_COPY

 偏移量为0x408的重定位节'.rela.dyn'包含3个条目:
    偏移量信息类型符号的值符号的名称+加数
 0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
 0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
 0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 external_array + 0 

与其他移动一样,副本移动由动态加载程序处理。 它包括一个简单的按位复制操作。 复制目标由位移偏移量确定(本示例中为0000000000404020 )。 源是在运行时根据符号名称( external_array )及其值确定的。 创建副本时,动态加载程序还将查看字符的大小,以获取需要复制的字节数。 为了使所有这些成为可能, external_array符号将作为特定符号从可执行文件中自动导出,以便动态加载程序在运行时可见。 动态符号表( .dynsym )反映了这一点,如readelf -sW命令所示:

 符号表“ .dynsym”包含4个条目:
   编号:值大小类型绑定Vis Ndx名称
      0:0000000000000000 0 NOTYPE本地默认UND 
      1:0000000000000000 0 FUNC全局默认值UND __libc_start_main@GLIBC_2.2.5(2)
      2:0000000000000000 0 NOTYPE弱默认UND __gmon_start__
      3:0000000000404020 12对象全局默认值22 external_array 

有关对象大小的信息从何而来(在此示例中为12个字节)? 链接器打开所有公共对象,搜索其定义并获取有关大小的信息。 和以前一样,这允许链接器计算数据节的布局,以便可以使用固定偏移量。 同样,主可执行文件中定义的大小是固定的,无法在运行时更改。

动态链接器还会将共享库中的符号链接重定向到主可执行文件中的移动副本。 这样可以确保在整个程序中,只有C语言语义所要求的变量的一个副本;否则,如果变量在初始化后发生更改,则动态共享库将看不到主可执行文件的更新,反之亦然。

对二进制兼容性的影响


如果我们在不链接(或重新编译)主程序的情况下更改共享库中external_array的定义会怎样? 首先,考虑添加一个数组元素。

 int external_array[4] = { 1, 2, 3, 4 }; 

这将在运行时从动态加载器生成警告:

main-program: Symbol `external_array' has different size in shared object, consider re-linking

主程序仍然包含一个external_array定义,仅保留12个字节的空间。 这意味着复制不完整:仅复制数组的前三个元素。 结果, extern_array[3]对数组元素extern_array[3]访问。 这种方法不仅影响主程序,而且影响过程中的整个代码,因为对extern_array所有引用都重定向到了extern_array中的定义。 这包括提供extern_array定义的通用对象。 他可能还没有准备好面对自己定义中的数组元素消失的情况。

在相反的方向上删除元素怎么办?

 int external_array[2] = { 1, 2 }; 

如果程序避免访问数组元素extern_array[2] ,因为它以某种方式检测到数组长度的减小,那么它将起作用。 在阵列之后,有一些未使用的内存,但这不会破坏程序。

这意味着我们得到以下规则:

  • 将元素添加到全局数组变量违反了二进制兼容性。
  • 如果没有避免访问已删除项目的机制,则删除项目可能会破坏兼容性。

不幸的是,动态加载程序的警告看起来比实际的更无害,对于远程元素根本没有警告。

如何避免这种情况


使用libabigail之类的工具来检测ABI更改非常容易。

避免这种情况的最简单方法是实现一个返回数组地址的函数:

 static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; } 

如果由于数组在库中的使用方式而不能使数组的定义静态化,那么我们可以隐藏其可见性并防止其导出,因此避免了截断问题:

 int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 }; 

如果出于向后兼容的原因而导出数组变量,则一切都会更加复杂。 由于库中的数组被截断,因此具有较短数组定义的旧主程序如果与同一全局数组一起使用,将无法为新客户端代码提供对整个数组的访问。 取而代之的是,访问函数可以使用单独的(静态或隐藏的)数组,也可以在末尾使用单独的数组添加元素。 缺点是,如果导出数组变量以实现向后兼容,则不可能将所有内容存储在连续数组中。 辅助接口的设计应反映出这一点。

使用字符的版本控制,您可以导出具有不同大小的多个版本,而无需更改特定版本中的大小。 使用此模型,新的相关程序将始终使用最新版本,大概是最大的版本。 由于符号的版本和大小由链接编辑器同时确定,因此它们始终是一致的。 GNU C库对历史变量sys_errlistsys_siglist使用此方法。 但是,这仍然不提供单个连续数组。

考虑所有因素,访问器函数(例如,上面的get_external_array函数)是避免此ABI兼容性问题的最佳方法。

Source: https://habr.com/ru/post/zh-CN451182/


All Articles