操作系统大都为可执行文件采用专门的格式。通常可执行文件格式与目标文件格式相似,其主要区别在于可执行文件中一般没有未解析的外部引用。
除了机器代码和二进制数据,可执行文件还含有其他元数据,包括调试信息、动态链接库的链接信息,以及定义操作系统应怎样将文件各区域调入内存的细节信息。因CPU和操作系统而异,可执行文件也可能包括重定位信息,以便操作系统在将文件调入内存时修正绝对地址。目标代码文件同样包含这些信息,因此许多操作系统所用的可执行文件格式类似于其目标文件格式,就不足为奇了。
Linux、QNX等类UNIX的操作系统使用ELF格式—“可执行及可链接的格式”(Executable and Linkable Format),这种格式是“目标模块格式”(object module format)和可执行格式的典型结合。的确,ELF的名称就指明了其文件格式的双重特性。微软的PE格式是从COFF格式直接修改而来的。这种可执行格式与其目标文件格式的相似性,允许操作系统的设计者在负责执行程序的加载器和链接器之间共享代码。既然如此,没有理由再专门探讨可执行文件里的数据结构。否则,只会大量重复前些节中的内容。
然而,这两种文件类型的布局有着实践上的明显区别。目标代码文件通常设计得尽量小;而可执行文件则往往设计成尽可能快地调入内存,即便这样会使文件不得不增大。大文件比小文件调入内存快,这种说法听起来似乎自相矛盾。然而假如操作系统支持虚拟内存的话,可能只将可执行文件的一部分调入内存。精心设计的可执行文件格式会利用这一点,在文件中合理布局数据和机器指令,从而减少虚拟内存的开销。
虚拟内存子系统和内存保护方案按页操作内存。典型处理器的一页通常介于1KB到64KB之间。不管一页有多大,页都作为最小内存单位,应用于诸如设定页内数据是只读的、可读/写的或可执行的等独特保护特性。特别地,只读/可执行代码与可读/写数据无法共存于同一页—必须位于内存中的不同页。以80x86 CPU家族为例,内存中的每页为4KB。因此,倘若我们有可读/写的数据,并希望将机器指令置于只读内存,那么要分配给进程的最少代码空间和最少数据空间就是8KB。实际上,大部分程序包含若干“段”(也就是我们在之前的目标文件里看到的“区域”),我们可以对其分别设立保护权限,每个区域都会请求在内存中获得专用的一到多个页,而不与其他区域共享。典型程序在内存中至少有4个区域:代码或文本、静态数据、未初始化数据和栈是最常见的区域,请参看图4-10。此外,许多编译器还生成堆(heap)段、链接段、只读数据段、常量数据段和应用程序具名数据段等。
由于操作系统将段映射为页,因此某个段所请求的字节数总为页尺寸的整数倍。举个例子,如果程序有一个段只含有1字节的数据,那么该段照样会在80x86上占据4096字节空间。类似地,倘若一个80x86应用程序包含6个不同的段/区域,那么它至少要占用24KB内存,不管程序有多少机器指令和数据字节,也不管可执行文件多大。
图4-10 内存中的典型段
ELF、PE/COFF等许多可执行文件格式在内存中提供了BSS区域选项。BSS区域是程序员存放未初始化静态变量的地方。既然其值没有初始化,就没必要在可执行文件中为这些变量胡乱填充一个值。因此,在有些可执行文件格式中,BSS区域只是一个小小的存根(桩),用于把BBS区域的大小告知操作系统的加载程序。这样一来,我们就可以向应用程序中添加新的未初始化静态变量,却不影响可执行文件的大小。增加BSS数据后,编译器只需调整一个值,告知加载程序应为未初始化变量保留多少字节即可。要是向初始化数据区域添加同样的这些变量,可执行文件的大小会随着所加入的每个数据字节而增长。能节省外存设备的空间显然是好事,故而利用BSS区域减小可执行文件尺寸是一个不错的优化措施。
不过,许多人容易忘记,BSS区域同样会在运行时期请求主存。可执行文件即使不大,我们在程序中声明的每一字节数据仍会在内存里被转换为对应数据。有些程序员错误地认为,可执行文件尺寸就是程序所占用内存的大小,这往往不符合实际情况,正如BSS例子所示的那样。某应用程序的可执行文件也许只有600字节,但如果程序用到4个不同区域,每个区域在内存中占有一个4KB页,那么在操作系统将其调入内存时程序将请求16384字节的空间。这是因为底层的内存保护硬件要求操作系统将内存一页页地分配给指定进程。
可执行文件可能比应用程序的执行内存区,即应用程序运行时占用的内存(即内存足迹)小的另一个原因就是,存在内部碎片(internal fragmentation)。即便只需内存块的很小一部分,一旦必须按固定尺寸的块分配内存,就会出现内部碎片(参看图4-11)。
图4-11 形成内部碎片的机理
请记住,即便区域数据量并非页尺寸的整数倍,内存中的每个区域仍要占据整数个页。区域内从最后一个数据/代码字节开始到页末的所有字节都浪费掉了,浪费的字节就是内部碎片。一些可执行文件格式允许打包每个区域,而无须将其加长为整数倍页大小。然而正如将要看到的,以此方式打包区域会付出性能的代价,所以有的可执行文件格式并不这么做。
最后不要忘了,可执行文件的大小还未计入运行时期动态分配的数据大小,例如堆中的数据和CPU栈中的值。不难看出,应用程序其实能够占据比可执行文件尺寸大得多的内存。
玩家们经常比赛,看谁能用顺手的语言写出最小的“Hello World”程序。汇编语言程序员特别津津乐道于自己用汇编语言写出的这个程序能够比用C等高级语言写的小得多。这是一场有趣的智力挑战赛。然而,不管程序可执行文件的大小是600B还是16KB,一旦操作系统为程序的不同区域分配了四五个页,那么程序在运行时期占据的内存大小很可能是半斤对八两。写出世上最短的“Hello World”程序会给某人以吹牛的资本,但现实世界由于存在内部碎片,这样的应用程序运行时几乎毫无节省可言。
对空间的优化并非没有价值。编程卓越的程序员会通盘考虑其应用程序用到的所有资源,而避免浪费这些资源。不过,试图将此过程推向极端是得不偿失的。假如区域尺寸低于4096字节,既然80x86等CPU的页尺寸为4KB,那么任何优化都将无功而返。不用说,如果给定的区域大于4096字节,就有可能将其缩减到这个“坎儿”以下,优化就值得一试了。记住,分配粒度(allocation granularity)即最小分配块的大小为4096字节。如果我们的区域是4097字节的数据,就会在运行时期占用8192字节的空间。应该设法将区域减少一个字节,从而在运行时期节省4096字节的空间。但是,倘若数据区域占据16380字节,要想将其减少4092字节,如此缩小文件将是相当困难的,除非开始时数据组织得太有水分。
我们注意到,多数操作系统分配磁盘空间以“簇”(cluster)或“块”(block)为单位,簇或块相当于甚至大于CPU内存管理的页尺寸。因此,如果为了少占磁盘空间而将可执行文件缩减到700字节(即便对于海量的现代磁盘驱动子系统,这么做最多算得上精神可嘉),节约效果并不像我们期望的那么好。例如,那个700字节的应用程序照样会占据磁盘表面上最小单位的一块。所有为应用程序代码或数据节省出来的空间,到头来只会更多地浪费磁盘文件空间—当然,这取决于区域/块的分配粒度。
对于较大的可执行文件,因为其体积比磁盘块大,内部碎片对空间浪费的影响要轻微一些。如果可执行文件的打包数据和代码区域,在区域间没有浪费任何空间,则内部碎片只会在文件末尾即最后一个磁盘块出现。假定文件体积是随机的,甚至文件散布于磁盘各处,那么内部碎片会平均对每个文件浪费一个磁盘块的一半。当磁盘块大小为4KB时,每个文件浪费2KB。对于非常小的小于4KB的文件,浪费掉的文件空间很可观。然而在应用程序较大时,浪费的空间就无关紧要了。既然如此,似乎只要将程序的所有区域顺序打包到可执行文件中,文件就会尽可能地小,但这么做能如愿以偿吗?
倘若所有因素平等地起作用,可执行文件较小自然不错。然而正如经常遇到的情况,各种因素并不平等,所以有时创建尽量小的可执行文件并非上策。为什么呢?请回想先前对操作系统虚拟内存子系统的讨论。当操作系统将应用程序调入内存准备执行时,并不实际读取整个文件,而是由操作系统的页管理系统只调入能够启动应用程序的那些页。这通常包括可执行代码的首页、存放栈数据的内存页,还可能有一些数据页。理论上,应用程序只要在内存中有两三个页,就能开始执行,其他代码和数据页可在需要时再调入(当应用程序请求这些页中的代码或数据时),这就是所谓的按需的页内存管理(demand-paged memory management)。在实践中,大部分操作系统出于效率方面的考量,实际上都会先调入多个页—在内存中维护一个“页的工作集”(working set)。总而言之,操作系统通常不将整个可执行文件调入内存,而是在应用程序请求时调入某些块。这样一来,从文件调入某页到内存的操作将显著影响程序的性能。于是有人可能会问,既然操作系统采取按需的页内存管理,有没有办法组织可执行文件的内容,以提高其性能?答案就是“有,如果把文件稍微变大的话”。
改进性能的诀窍在于将可执行文件的块与内存页的布局匹配起来。这意味着内存中的区域/段应当与可执行文件中按页划分的边界对齐,还意味着文件块大小应为磁盘扇区或块的整数倍。具备这些条件后,虚拟内存管理系统能将磁盘上的文件块快速拷贝到内存中的一页,更新任何必要的重定位值,继续程序的执行。另一方面,假如页数据横跨磁盘的两个块,而且没有与磁盘块的边界对齐,操作系统只好从磁盘上读取两个块—而非一个块—到内部缓冲区,再从缓冲区中将页数据拷贝到其所属的目标页中。这一额外操作很花时间,会对应用程序的性能造成负面影响。
由于这个原因,有的编译器实际上会将可执行文件撑大,确保可执行文件中的每个区域起始于块边界,以便虚拟内存管理子系统能将其直接映射到内存中的一页。这种编译器生成的可执行文件,往往比无此技术的编译器所产生的可执行文件大得多,在可执行文件中含有大量BBS(未初始化的)数据,打包文件格式显得非常紧凑时更是如此。
由于一些编译器生成的打包文件以牺牲执行时间换取少占用空间,而另一些编译器生成调入和运行速度更快的展开文件,因此靠比较所生成可执行文件的大小来衡量编译器质量的做法是危险的。确定编译器输出质量的最好途径是直接分析其输出,而不是使用诸如输出文件大小之类的牵强指标。
注意: 分析编译器输出是第5章的课题,所以要是你对此感兴趣,就继续读下去吧。