购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

4.8 目标文件中的数据和代码对齐

我在《编程卓越之道(卷1)》中曾指出,将数据对齐到与其尺寸“自然匹配”的地址边界时可改善访问的性能。将过程代码开头或者循环的起始指令对齐到某个合适的边界也能取得同样效果。编译器的编写者对此心知肚明,经常在数据或代码流中插入填充字节(padding byte),以便将数据或代码序列对齐到适当的边界。然而要知道,链接器在链接两个目标文件以生成单一可执行文件时,可任意移动代码区域。

区域一般对齐于内存中的页边界。对于典型的应用程序,文本/代码区域在某个页边界位置开始,数据区域则从另一个页边界位置开始。如果有BSS区域的话,BSS区域也会开始于自己的页边界,依此类推。然而这并不是说在目标文件中,区域头涉及的每个区域都在内存里从自己的页开始。链接器会在可执行文件中将同名的多个区域合并成一个区域。因此,举例来说,如果两个不同的目标文件都含有.text段,链接器就会将其合并成一个.text区域,放置在最终的可执行文件中。通过合并同名的区域,链接器就不会把大量内存浪费到内部碎片上。

链接器如何满足其合并的各区域的对齐要求呢?答案当然与所用的具体目标文件格式和操作系统有关,但一般可以从目标文件格式本身找到。举例来说,Windows的PE/COFF文件中有IMAGE_OPTIONAL_HEADER32结构,其中有一个名为SectionAlignment的字段。链接器和操作系统在合并区域和将区域调入内存时,必须知道该字段说明的地址边界。在Windows下,PE/COFF文件可选头信息的SectionAlignment字段通常为32或4096字节。4KB值当然会在内存中将区域对齐于4KB页边界。选择对齐值32,也许因为它是一个合理的缓存线值,可参看《编程卓越之道(卷1)》中对缓存线的讨论。当然,其他值也不无可能—应用程序员可通过链接器或编译器的命令行参数,指定区域对齐的值。

4.8.1 选择区域对齐值

编译器、汇编器或其他代码生成工具能够保证在区域内的任何对齐都按区域对齐值的约数实现。例如,如果区域对齐值为32,则该区域内的数据可按1、2、4、8、16、32对齐。显然,再大的对齐值是不可能的。区域的对齐值若为32字节,就无法保证区域内数据对齐到64字节边界,因为操作系统或链接器只考虑区域的对齐尺寸,会将区域放在任何为32字节倍数的边界。而这些边界中一半不是64字节边界。

有一个事实或许不太显而易见,就是不能将区域内的数据对齐到区域对齐值的非约数位置。比如,具有32字节对齐值的区域不会允许对齐尺寸为5字节。不错,我们是能让数据在区域内的偏移量为5的倍数;但如果区域的内存起始地址并非5的倍数,那么想对齐的地址恐怕不会落在5字节的倍数上。唯一的办法就是将区域对齐值定为5的某个倍数。

由于内存地址是二进制值,因此语言转换器和链接器大都将对齐值限制为小于或等于2的某个最大整数次幂,通常也就是内存管理单元的页尺寸。许多语言的对齐值仅为2的较小次方,如32、64或256。

4.8.2 合并区域

在链接器合并两个区域时,必须考虑各区域相关的对齐值,因为应用程序可能要靠此对齐值才能正确工作。因此,链接器或其他对多个目标文件进行区域合并的程序在创建合并的区域时,一定不能仅仅对接两个区域的数据就了事。

当合并两个区域时,若两个区域中有一个或全部的长度并非区域对齐值的整数倍,链接器也许得在区域间加入填充字节。例如,如果两个区域的对齐值都是32,而一个区域为37字节长,另一个是50字节长,链接器会在第一个区域与第二个区域之间加入27字节的填充,或者在第二个区域与第一个区域之间添加14字节的填充—通常由链接器决定合并时的区域顺序。

倘若两个区域的对齐值不一样,情况会稍复杂一些。当链接器合并两个区域时,需要确保对齐值兼顾两个区域的要求。如果某区域的对齐值是另一区域的整数倍,链接器只要以较大的那个对齐值为准即可。比方说,假设对齐值都是2的整数次幂(多数链接器这么要求),那么链接器只需挑出较大的那个对齐值,作为合并后区域的对齐值。

如果某区域的对齐值并非另一个区域的整数倍,要保证对齐值同时满足两个区域的要求,唯一方法就是使用两个值的乘积,或最好使用两个值的最小公倍数。例如,在要合并的两个区域中,一个区域的对齐值为32字节,另一个区域对齐于5字节,则要求的对齐值是160字节(5×32)。合并这样的区域太复杂,所以多数链接器要求区域尺寸为2的较小整数次幂值,以确保较大的段对齐值总是较小对齐值的整数倍。

4.8.3 区域对齐的控制

人们一般使用链接器选项来控制程序中的区域对齐。以微软的 link.exe 程序为例,命令行参数“/ALIGN:value”告诉链接器,将输出文件中的所有区域对齐至指定边界,value须为2的整数次幂。GNU的链接器 ld 则通过在链接脚本文件中使用“BLOCK(value)”选项来指定边界对齐值。macOS的 ld 链接器提供命令行选项“-segalign value”,用以指定区域对齐值。具体命令与可能的值都与链接器有关,不过现代链接器几乎全都允许指定区域对齐属性。可查看所用链接器的文档来了解具体细节。

在设置区域对齐值时要注意:链接器往往要求给定文件的所有区域应对齐到相同边界,即2的整数次幂值。因此,如果我们对各区域有不同的对齐需求,应对目标文件内的所有区域选取最大的对齐值。

4.8.4 库模块内的区域对齐

倘若我们用到一大堆短的库例程,区域内的对齐会对可执行文件的尺寸影响甚大。例如,我们已经为库中的目标文件所关联的区域指定对齐尺寸为16字节。链接器处理的每个库函数将被放到16字节边界。如果函数很小,少于16字节,那么在链接器创建的最终可执行文件中,函数间的空间就会闲置下来。这是另一种形式的内部碎片。

要理解将区域中的代码或数据对齐到给定边界的原因,可以想想缓存线的工作原理(参看《编程卓越之道(卷1)》)。通过将函数开头对齐到缓存线上,执行时就能减少错失次数,从而使函数的执行速度有所提高。正是由于这个原因,许多程序员喜欢将其全部函数都对齐到缓存线的开始处。缓存线大小因CPU而异,不过典型的缓存线为16到64字节长,所以许多编译器、汇编器和链接器都试图将代码、数据对齐到其中的某个边界。在80x86处理器上,按16字节对齐还有其他一些好处,因而不少基于80x86的工具对目标文件默认按16字节对齐。

举个例子,请看如下简短的HLA程序,它调用两个小的库例程,后面将用Microsoft工具进行处理:

下面是库模块 bits.cnt 的源代码:

下面是bits.reverse32()库函数的源代码。注意该源文件还包括bits.reverse16()和bits.reverse8()函数,为了节省篇幅,这里没有给出其函数体。这些函数的操作与我们的讨论不相干,但请注意它们将值的高位(high-order,HO)和低位(low-order,LO)进行了互换。因为这3个函数位于同一个源文件,故而用到其中一个函数的程序也会自动包含其他两个函数—编译器、汇编器和链接器的工作方式使然。

微软的 dumpbin.exe 工具可供用户检查 .obj .exe 文件中的各个字段。如果对用以生成HLA标准库的 bitcnt.obj reverse.obj 文件运行带命令行选项“/headers”的dumpbin,我们就会知道每个区域都对齐至16字节的边界。因此,当链接器把 bitcnt.obj reverse.obj 数据同先前给出的程序合并时,它会将 bitcnt.obj 文件中的bits.cnt()函数对齐到16字节边界,并将 reverse.obj 文件中的3个函数也对齐到某个16字节边界。请注意,它不会把文件中的每个函数都对齐到16字节边界。即便希望对齐,也该由创建目标文件的工具来做。通过对可执行文件使用带命令行选项“/disasm”的 dumpbin.exe ,就能看到链接器已经按这些对齐要求做了—注意到对齐于16字节边界的地址,其十六进制数的最低位为0:

该程序的确切操作并不重要(何况它其实并未做出有用的事情)。真正需要操心的是,链接器怎样额外在源文件的函数前插入一些字节$cc(即int 3指令),以确保其对齐于指定边界。

在这一特例中,函数bits.cnt()实际长为64字节,链接器只在其前面插入了3字节,就将其对齐到16字节边界。浪费的百分比—即填充字节数与函数字节数之比相当低。然而,如果有一大堆小函数,比如本例中只有两个指令的默认异常处理函数,那么空间的浪费是很可观的。如果创建自己的库模块,我们就需要权衡通过对齐代码获得的少量性能提升,是否值得自己去浪费空间放置填充字节。

当我们分析目标码和可执行文件,以便确定诸如区域尺寸、对齐值等特性时, dumpbin.exe 之类的目标码转储实用工具会很有用。Linux等多数类UNIX的系统提供与之差不多的objdump工具。第5章我们将讨论这些程序的用法,它们是分析编译器输出的有力工具。 /6nSKI1uvCgWp9JVX/K4OuVwA0cLW1ZmFEtd3/OSy/SuUtI56gLBh0c0ciF9Cg4u

点击中间区域
呼出菜单
上一章
目录
下一章
×