• 标 题:转载:用我们的32位吸脂工具为你的应用程序减肥[翻译:松鼠、老狐狸] (22千字)
  • 作 者:konit
  • 时 间:2001-11-30 16:15:40
  • 链 接:http://bbs.pediy.com

松鼠:http://squirrel.163.net/
老狐狸:http://kingfox.163.net/

用我们的32位吸脂工具为你的应用程序减肥

Matt Pietrek是《Windows
95系统编程奥秘》(1996,电子工业出版社,IDG)一书的作者。他在NuMega工作,EMAIL:71774.362@compuserve.com 。

你以为你已经做了一个很恰当、很紧凑的应用软件。你清楚程序中的每一行代码,而且自信没有多余的东西浪费空间、延缓速度。天啊,就像现在的即食食品,你的代码中可能隐藏了脂肪成份。也许你所写的代码不用负这个责任,而是你的编程工具和技巧为你的软件中添加多余的东西,并延缓运行速度。让我们复习一下可以用来为你的程序减肥的方法。我这里只谈到C和C++程序,但我所说的也部分的使用适用于其他编译语言,如Delphi等。

《为你肥硕的执行程序吸脂、减肥》(微软系统月刊,1993,7)是我早期的一篇文章,提出了许多EXE和DLL发胖的原因。那篇文章还提供了一个测试可执行文件并给出它们相对健康程度的程序:EXESIZE.EXE。那时主要是16位的WINDOWS程序。既然现在的系统是WINDOWS
NT 和WINDOWS 95,我已经收到很多请求,要求得到能为 WIN32的PE文件工作的新版本的EXESIZE。

因为32位的PE文件与16位的NET文件格式不同,我不能仅仅对EXESIZE作很小的改动就完事。现在我觉得有必要回头看看为什么提出上一篇文章。16位NE文件需要的一些单元在WIN32中没有应用,但16位与32位的可执行程序同样易于在同样的地方冗余,甚至,WIN32还增加了一些增大你EXE、DLL长度与装入时间的新方法。在这篇文章中,当我提到“可执行文件”,我的意思是所有的WIN32
PE文件,不管是EXE 文件,或DLL文件,或其他。

首先,让我们回顾我为16位程序提出的建议。当我在写这篇文章前我也这样做了,很高兴地想到相较16位WINDOWS的黑暗时代,WIN32的编程是多么的简单。另一点来说,一些陈年的问题依然存在,所以,我们用1993年的文章来看看现在的WIN32的编程世界。

在16位NE文件中正确设定对齐(Alignment)。每一段(segment)和资源都从文件的一个偏移开始。大的16位程序经常有大量的段。有几十个甚至上百个资源也很普遍。很多的废物就因为缺省连接队列长度为512字节而引进了。通过配置你的连接器(linker)(在Microsoft
linker中用"/ALIGN:XXX")你可以设定一个更合理的值(典型的是16字节),很显著的缩小你的文件。

在WIN32的PE文件中,段的等价物是片段(section)。片段依然需要界定(通常是512字节)。主要的不同是庞大的PE程序一般也不会超过10片段。而且,一个PE文件中所有的资源都组合到单个片段中,因此,资源的队列不是个问题。

在16位NE文件中不要生成无用的代码。当你引进一个函数,编译器(或汇编器)就在函数的开头和结尾生成一些特别的代码。这些代码设置数据段选择器,为外部函数的代码选择适当的段。这里的问题是许多程序所用的编译开关是;为每个远程函数都生成这些特别的代码,而不是仅为外部函数。很高兴,在WIN32中外部函数不再需要这些特别的代码。
现在是10点钟了,你知不知道你的调试信息在哪儿?这是很重要的,带着调试信息在可执行文件里的程序表明这是个很蠢的程序员或编程小组。后面我还将提到这个问题。

实时模式(Real Mode)已经不再使用,你为什么还支持它呢?在MICROSOFT
WINDOWS的早期,程序的段可能要在内存中移动,这在保护模式不是一个问题,因为INTEL的CPU能够通过它逻辑-物理地址转换能力来向你屏蔽;在实时模式,CPU无法做到这点。为了在实时模式下运行时避免出错,程序中经常包括了一些代码来隐藏段已经移动的事实。
这些代码只有在实时模式下才用的着。很多程序在WINDOWS
3.0下启动时都指定只能运行在保护模式下,然而他们还包含有从来不用的“实时废物”。幸运的是,在PE文件中,这个问题已经不复存在。

将多个段打包。前面我提到NE文件经常有很多段。因为一些原因(我不会提到这些),让连接程序把尽可能多的段合并到单个段中回很有好处。在PE文件中,不再有段,但它们的替代品(片段)也可以用一些连接程序来合并。合并片段的好处不同于16位程序中合并段,后面再详细解说。

将你的重定位信息连接起来。16位NE程序文档允许多个重定位表在可执行程序中象单个重定位表那样存在,而不是存在一大堆分离的重定位表。这个技术叫做链(chainning)。一些连接程序利用了这点,但在我原来那篇文章写作时Borland的TLINK没有。后来,TLINK已经更新了。

32位PE文件的重定位表完全不同于NE文件。当你不能链PE风格的重定位表时,你就不仅仅缩小可执行文件的长度。后面再说吧。

明智地使用运行库(Runtime Library).
如果你是用C++之类的编译语言,你应该链接一些外部的常规库。这些常规库一般叫做运行库(RTL)。如果你选择静态链接这些常规库(而不是使用DLL文件),你的代价是执行文件的长度。一般的,你可能为你所使用的每一个
增加了RTL的几个字节到几K字节的多余的代码和数据。有些简单的函数如strcpy 非常小,然而有些象printf 那样复杂的就会大得多。

在我原来那篇吸脂机的文章里,我要求人们使用通用的RTL函数的Windows版本,而不要链接静态版本。在Win32这一点同样适用。实际上,Win32的API包含了Windows
3.1中没有的C/C++类函数的一个扩展集,这使得减少你的可执行文件长度,用系统DLL里的代码来工作成为可能。

例如,当你要在代码中用sprintf时,你可以选用wsprintf代替。它能从你的程序中减少几千字节的RTL代码。同样,像strcpy那样的函数可以用lstrcpy代替,在程序中用malloc和free会增加几千字节代码和数据,应该考虑用HeapXXX(如HeapAlloc,HeapFree等)代替。可以参照我的关于这个问题的《在钩子下》(
Under the Hood)专栏。

用BSS段。在16位编译程序中,未初始化的数据(定义却没给初始值的变量)都放在称为BSS段的地方。(假设你们知道什么是BSS。)既然BSS段的数据不包含任何特别的初始值,16位链接程序一般把BSS段的数据连接入主数据段,这样不需要任何磁盘空间。16位连接程序可以设置段记录中磁盘空间的长度小于内存的长度。在Win32对未初始化的数据也可以同样运用,虽然是片断而不再是段。后面我还将说到这一点。

随便说一句,如果你还觉得糊涂,BSS的意思是块存储空间(block storage space)。

缩小你的常驻名表(按顺序输出)。在编16位Windows程序时,当你调用或输出DLL的函数时,连接程序一般按顺序查找函数。“按顺序”也就是“按数字顺序”的另一个说法。也就是说,每个所调用的函数用一个WORD值来识别。除了按数字顺序输出外还可以按名字输出,函数的实际名字出现在调用的可执行文件和被调用的文件中。显然,按顺序的工作效率比按函数名工作要好。函数名字一般比较长,所以它们要用比按顺序更多的空间和工序。

为了按名字输入(很少在16位Windows使用),目标DLL必须按名字输出函数。输出的名字将是这两种表之一:常驻名表和非常驻名表。常驻名表的缺点是它占内存,而非常驻名表却不。不幸的是,很多16位连接程序在某些情况下缺省地把输出名字放到常驻名表里。
基于Win32的程序已经没有常驻名表和非常驻名表的概念,只有一个放置输出函数名的表。但输出函数名占据空间的问题依然存在。输出函数名和输入函数名都在内存中,除非你尽力地按顺序输出你的函数。你能够按顺序输入和输出你自己的可执行程序,但按顺序来输入系统DLL函数并不是一个好注意,因为交叉平台的函数顺序不一定相同。



回到现在。
16位、32位的比较就到这里。在回顾中,我重点提出了我的32位版本EXESIZE程序可以运用的领域。但在可以大踏步前进的地方我们为什么还要小心翼翼的踩水呢?有一个可以提高的地方是实时性能。记住这一点:我已经把我的新的吸脂工具的技巧分为两方面,空间、性能。

后面我将奉送一个32位的工具,让你看看如何缩小你的程序,并提高性能。但在品尝甜点之前,让我们先进餐。让我们考察一些空间和性能的要点,这样你能更好的理解吸脂工具的好处。

第一套技巧用来为你的程序节省空间,这通常是值得的。每一个规律总有例外,但我想你要找出这些技巧的例外可不容易。

在发行前关闭递增链接功能!
Visual C++中我很喜欢的一个特点就是递增链接(Incremental Linking)。减少链接程序的工作,能使Visual
C++在几秒中就完成链接。递增链接是调试时的缺省设置。

递增链接只重写从上次链接之后改动过的部分,因此获得这个令人眩目的性能。为了达到这个目的,Microsoft Link拷贝了一大堆INT
3指令到可执行文件的不同部分之间。因此当你在源代码加了几句后,多出来的代码就覆盖了INT 3所在的空间。文件的其他部分没有变动。

正如你想象的,递增链接的代价也很大,就是可执行文件的长度。一个使用递增链接的文件可以(平均的)用1/3的空间来存放INT
3。在大的执行文件中,这很可能给你增加几百KB甚至几M的INT 3!

怎样解决呢?在你编译发行版本时确认关掉递增链接功能。问题时,我已经见过很多不区分不同版本的程序员。他们把测试时的文件(也就是调试版本)发布出去了。我的32位吸脂工具可以在这些文件中挑出可执行部分和浪费的部分。如果你在一个可执行文件里看见了一堆INT
3,很可能这文件就是递增链接的。

如果所有的INT
3不够用,递增链接的文件还会有更多的浪费空间。在这种文件里,每个文件里的函数还多了一个JMP指令。当你调用递增链接文件里的函数时,CALL指令找到相应的JMP,方执行所需要的函数。这些JMP的好处是使链接程序可以在内存中任意移动函数而不用更新所有的使用这些函数的CALL指令。

总的说来,递增链接在开发时非常得心应手。只要你确认不要把递增链接的文件发行出去。一般,当你在工程里把调试开关转到发行版本时递增链接功能会自动关闭。如果你自己写MAKE文件的话,链接程序控制递增链接的方法是/INCREMENTAL:XX,XX可以是YES或NO。

去除调试信息
在程序里留下调试信息究竟会带给你多大的浪费空间,以及产生了什么样的调试信息,依不同编译器而定。让我们先从非Microsoft的编译器开始,因为他们比较容易描述。在非Microsoft的编译器中,调试信息一般都是可执行文件的一部份。在一个长度可观的工程里,调试信息可以达到长度的百分之五十,甚至更多。

Microsoft的编译器与调试信息的故事更为复杂。一般,生成调试信息的同时也用递增链接。如果使用了递增链接,Microsoft的链接器会把调试信息放在一个单独的以PDB为后缀名的文件里
(PDB的意思是程序数据库program database)
。这样做是为了递增链接时对可执行文件做最小的改动。PDB文件里是一些零碎的CodeView风格的信息。你还记得CodeView吧?

用PDB存储信息的可执行文件用一小块地方来存储相应PDB文件的名字。当使用递增链接和PDB文件时,可执行文件里因调试信息而浪费的空间是很小的,仅仅是PDB文件的全路径的长度。技术上,这一段会象CodeView信息那样列出来,但它确实只是CodeView信息的指针。

Microsoft调试信息常见的另一种类型是在可执行文件中实实在在的CodeView符号。你可以用/PDB:NONE来强迫链接器生成CodeView信息,但这样做就关闭了递增链接的功能。计算、读写CodeView符号与递增链接相冲突。如果你用CodeView调试信息,那么EXE文件的浪费空间也象前面我所说的非Microsoft编译器一样能达到50%。

用Microsoft编译器你还可以生成COFF风格的符号。COFF符号是早期Windows
NT制作队伍做它的编程工具时的格式,并流行了下来。链接器的开关可以是/DEBUGTYPE:COFF或/DEBUGTYPE:BOTH。COFF符号只有相对少的工具,而且大多数都在Win32
SDK。COFF调试信息象CodeView符号一样会占据可执行文件的很大空间,所以在发行前你应该去除。
Microsoft编译器还能产生另一种调试信息,叫做FPO(Frame Pointer
Omission)。FPO用来连接CodeView或PDB符号。它在编译器没有用EBP寄存器生成标准堆栈桢(a standard stack frame)
的地方帮助调试器查找函数的参数和本地变量。FPO信息也很大,同样要在发行前去除。

最后,在Microsoft编译器生成的可执行文件里你可以看见混杂的调试信息。这个区域通常是0x110字节,保存着链接器生成的可执行文件的名字。如果你改了可执行文件的名字,调试器依然能用混杂调试信息来判定文件的原始名字,然后算出对应的PDB文件的名字。在发行前非调试链接可执行文件就可以去除这混杂调试信息。

除了调试信息浪费的空间外,还有两个原因要求你关心你发行的文件里有没有调试信息。第一,存在调试信息就表明编译时编译器的优化开关没打开。优化开关可以很大程度地减小文件长度,下面我还会说到它。第二,调试信息让你无法防范别人反求你的程序。记住,调试信息表明了你的代码、数据、类型(例如类的定义)。有了调试信息,一个中等能力的程序员可以象打开核桃一样crack你程序的内部机制。

使用优化开关
虽然编译器的优化开关被抨击产生BUG代码,我认为没有比放弃它更糟的事了。依我经验,高度优化时产生的问题都是由于优化代码速度而不是代码长度引起的。说实在话,在我让编译器为代码长度优化时我从来没有见过优化开关产生的BUG。

优化开关的好处不是它产生好的代码,一个优秀的汇编语言程序员能做到甚至比优化开关做得更好。优化开关能把你从编译器可能产生的效率不高的代码中ZHENJIU出来。下面我用Visual
C++来做个例子,你能看见不同的编译器产生的类似的结果。

int foo( int i )
{
    return i * 2;
}

int main()
{
    if ( foo(7) )
        return 1;
    else
        return 0;
}


缺省情况下,非优化的Visual C++ 4.1用CL FOO.C语句会产生表1的指令,这两个函数共有0x48字节。
Figure 1 Assembler from Nonoptimized Code

foo proc
401000: PUSH    EBP
401001: MOV    EBP,ESP
401003: PUSH    EBX
401004: PUSH    ESI
401005: PUSH    EDI
401006: MOV    EAX,DWORD PTR [EBP+08]
401009: ADD    EAX,EAX
40100B: JMP    00401010

401010: POP    EDI
401011: POP    ESI
401012: POP    EBX
401013: LEAVE
401014: RET
foo endp

main proc
401015: PUSH    EBP
401016: MOV    EBP,ESP
401018: PUSH    EBX
401019: PUSH    ESI
40101A: PUSH    EDI
40101B: PUSH    07
40101D: CALL    00401000
401022: ADD    ESP,04
401025: TEST    EAX,EAX
401027: JE      0040103C

40102D: MOV    EAX,00000001
401032: JMP    00401043

401037: JMP    00401043

40103C: XOR    EAX,EAX
40103E: JMP    00401043

401043: POP    EDI
401044: POP    ESI
401045: POP    EBX
401046: LEAVE
401047: RET
main endp
现在,我们打开长度的优化开关(CL /O1 FOO.C):

foo proc
401000: MOV    EAX,DWORD PTR [ESP+04]
401004: ADD    EAX,EAX
401006: RET
foo endp

main proc
401007: PUSH    07
401009: CALL    00401000
40100E: ADD    ESP,04
401011: CMP    EAX,01
401014: SBB    EAX,EAX
401016: INC    EAX
401017: RET
main endp

噢!打开长度优化开关使生成的代码少了0x18字节,是非优化的33%。如果你比较一下这两段代码,你能看见优化开关减少不必要代码的几个途径。

第一,两个函数(FOO和MAIN)都不要堆栈(PUSH EBP,MOVE
EBP,ESP和LEAVE指令)。第二,注册的变量寄存器(EBX,ESI,EDI)都没有用上,所以优化版本不用PUSH和POP保存它们。第三,在非优化版本里,两个函数都经常用JMP来跳到下一条指令上。这样做不仅无用、占5字节,还打断了CPU的流水线(pipeline),这是应该避免的。第四,main函数里的if语句在优化版本里很聪明地翻译成CMP,SBB,用EAX来返回值;然而,你简直找不到比非优化版本更差的办法了。

在这儿,FCC规则要求我告诉你,你不能时刻期待优化器能有如此成功的效果,上面这个小例子无疑是造作的结果。同时,记住我打开的长度开关是长度的开关,而不是速度。这里的要点是优化开关给了你聪明的代码。

当你比较长度优化和速度优化时,你会发现这两者几乎是同一回事。主要的差别在于,优化速度时编译器会打开内联的函数,例如strcpy等,这样生成的代码就不用调用外部函数。这时速度会加快,但内联函数会增大代码。在最坏情况下,它们会加入一个4KB的页,从而可能引起附加页面错。一个页面错的严重性应该说比你从内联函数得到的好处要大,所以你必须小心权衡你的优化的决定。这个可以作为参考:Microsoft的操作系统编写队伍从长度优化而不从速度优化。



看看你的队列!
除了长度优化给你的更好的代码之外,还有一个很好的理由让你使用Visual
C++的长度优化开关。从不同的OBJ和LIB文件里集成片断(section)时,编译器把每个OBJ文件里的代码和数据从一个偏移量开始放置。对于COFF的OBJ文件(由Visual
C++生成),偏移量是WINNT.H中预定义的IMAGE_SCN_ALIGN_XBYTES,可以是1, 2, 4, 8, 16, 32, 或64字节
Visual C++ 4.1的缺省偏移值是16字节,也就是说,OBJ文件里的每个片断都从第16字节开始放置,在上一个OBJ与下一个OBJ中的空间填满INT
3。最糟情况下,链接后的文件里每个段落之间你会得到15字节INT 3。如果Visual
C++能让你选择偏移的量会好一些,但现在并没有提供这个功能。现在,在OBJ之间你必然有16字节的偏移。

如果说这些OBJ之间的填充不足以称为“空间杀手”,一个看起来无害的Visual C++编译选项如果你使用不恰当会。想一下Visual
C++文件是怎么介绍/Gy开关的:“本选项按照COMDAT格式生成函数包以实现函数级链接”。在英语里,这意味着链接器将从一个OBJ里取出所需要的函数,而不是把整个OBJ链接进来。

/Gy 开关不正确使用时的问题是使得链接器为每个函数都分配一个偏移。我已经见到很多有几千个函数而且使用/Gy
的可执行文件。既然他们使用缺省的16字节偏移量,里面就到处分布有共8KB的INT
3。等一下,还不止这些!你不会想到即使你不打开/Gy开关,如果你用/O1(优化长度)或/O2(优化速度)开关,它也会隐含地打开。

嗨,这看起来有点混乱。我一边既叫你优化长度,另一边又告诉你优化长度会打开这个浪费空间的/Gy。想想长度,又想想速度:你该怎么办呢?

如果你不想强迫编译器使用一个特别的偏移量,至少你还可以用一个开关来解决:/Os。这个开关的意思是“长度重于速度”,它将迫使编译器使用1字节的偏移量,而不是缺省的16字节。猜一猜会发生什么事:当你用优化长度的/O1时,/Os开关自动打开,于是虽然/O1也打开了/Gy,/Os会设定偏移为1,于是去除量/Gy通常会带来的多余的长度。相反,如果你用优化速度的/O2,/Os不打开,于是你就有了垃圾。所以:用/O1而不用/O2,至少在微软提供更好的办法之前如此。

另一个问题是在可执行文件里面的片段的对准。缺省情况下,Borland和Microsoft的链接器在可执行文件中的每一个片段都是以512字节作为边界的。如果你的程序中有好几个小的片段(就是说小于0x200字节的片段),理论上设置一个小一些的边界值——比如16字节——对你会更有用。Microsoft的Linker有一个/ALIGN开关可以实现这个功能。如果你是Borland
C++用户,可以用/Afnnnn开关设置文件边界。

不幸的是,对于Microsoft用户来说,如果你使用/Align选项,它会把内存里的片段和磁盘文件上的片段设置成同样的数值(比如16字节)。在Windows
NT中,按照16字节对准的可执行程序可以运行,但是处于某些低级技术原因,你可能不会想这么做。唉,Windows
95不会运行片段边界不是0x1000字节的倍数的可执行程序。

Borland的TLINK32可以明确地使得你单独设置文件的边界。不过,在Borland C++
5.x中,把文件的片段边界设置为任何小于512字节的数值会导致生成无效的可执行代码。结果是:有时候,使用更小的边界尺寸也许是压缩PE可执行文件大小的一种方法。不过,除非链接器能够提供必要的灵活性并且工作正常,否则,此路不通。

典型情况下,当你的程序包含未初始化数据的时候,编译器会把有关信息放到一个称为.bss的OBJ片段里面。在可执行文件里面,.bss不会占用任何空间。如果可执行文件里面包含.bss片段,操作系统就必须为其提供物理内存。即使你仅仅使用了4字节的未初始化的DWORD变量,操作系统也必须为其建立4KB的物理内存,因为是以4KB页面为单位分配的。

比使用一个单独的.bss片段更好的方式是把它合并到一个初始化数据片段中。在这种方式下,如果你的程序的未初始化数据不太多,则这些数据会被合并到已经被初始化数据使用的4KB内存中。幸运的是,大多数现今的链接器(包括Visual
C++ 4.x和Borland C++
5.x)可以自动把未初始化数据合并到初始化数据片段中。如果你在一个可执行文件里看到.bss片段,这个文件很可能使用老版本的链接器生成的。如果这是你自己编写的程序,你有理由将它升级。

至今为止我给出的所有提示更是用于那些非固有的问题(All of the tips I've given you so far are pretty much
no-brainers with nothing inherently questionable about
them.)还有几条其他的路——需要动一点脑筋——可以消减你的程序的空间消耗。换言之,我后面的建议可能是你想或者不想做的。你必须确定它们是否适用于你的特殊程序。

删除重定位信息
Win32
PE可执行文件通过映射各个片断(比如代码片段和数据片段)到内存中的制定地址来完成加载。对于每个被加载的可执行模块(EXE和DLL),Win32加载器都会从模块中提取基地址。然后模块中的所有片段被加载到相对于基地址的一个偏移地址中。顺便提一句,这个基地址与可执行文件的模块句柄(HMODULE)完全相同。

当链接器在生成PE文件的时候,会给出一个首选的加载地址。换句话说,链接器优化了文件,这样,如果Win32加载器能够加载文见到首选地址的话,只需要做很少一点工作就可以了。另一方面,如果Win32加载器无法加载模块到首选地址,那么加载器就必须自己做很多工作。加载器必须重新链接模块,这样,所有对代码和数据项的内部引用都被正确地指向新的加载地址。

Win32加载器用来在内存中重定位模块的信息被认为是基本重定位。这些数据存放于可执行文件的一个通常称为.reloc的片段中。简单情况下,基本重定位信息是一系列内存模块中的偏移,加载器必须在此处添加实际加载地址和首选加载地址之间的位移。无疑,你可以想象,基本重定位信息越多,加载器在内存中重定位模块时要做的工作就越多。后面我会对此做更多的探讨。

理想情况下,模块被加载到首选地址,Win32加载器无需关心.reloc片段的基本重定位信息。如果你想要冒险使得模块总是被加载到其首选地址的话,你可以去掉重定位信息。我无法确保你这种冒险的稳定性。如果你删除了重定位信息,而加载器又无法加载模块到首选地址的话,加载器就会拒绝加载该模块。Game
over!另一方面,打的程序可以由几百KB的重定位信息,所以,这才是需要考虑的目标。

如何决定是否该删除重定位信息?下面是一些通用的指导方针。记住要用你最佳的判断历来从头到为地思考形式。由于每个进程只有一个EXE文件,你通常可以忽略EXE文件中的重定位信息。习惯上,EXE文件被加在到进程地址空间的线性地址0x400000(4MB)处,并且首先获得选择加载地址的机会(在DLL之前)。由于很少会出现无法加在EXE到首选地址的情况,所以通常忽略或者删除重定位信息是安全的。

与EXE相反的是,每个进程通常有多个DLL成用进程的地址空间。幸好它们都被加载到不同的地址,而且无需重定位。尽管如此,保留DLL重的重定位信息还是一个廉价的保险措施,这就使得DLL可以被加载到任何需要的地方。这在你无法控制所有进程所需的DLL情况下就尤其重要。

尽管尝试和归纳上述论点并且认为“重定位信息应该从EXE文件中剔除,但是保留在DLL中”的想法挺诱人的,可是还是有反例的。比如,Program1想从另一个EXE文件(Program2)中读取资源,如果Program2的重定位信息丢失了的话,Program1就不能把Program2映射到内存中来访问其资源。以你的程序为例,你的程序有一个主窗口的图标。为了显示你的程序的图标,Explorer或者Progman需要加载你的程序以访问图标资源。

在什么场合下会对从DLL中删除重定位信息敏感呢?也许你的应用程序只需要很少的磁盘空间和内存就可以运行,你对应用程序和操作系统拥有完全的控制权,那么假设你的DLL无论如何也不会被加载到你制定的位置之外的地方是相当安全的。你可以精确地决定正在加载的程序的特性,并且可以得知只要可执行文件和操作系统不改变,这些特性就不会改变。

如果你决定删除重定位信息,那么可以有三种方法。最简单的是在链接器命令行指定/FIXED开关。还有一种方法是,你可以对你的执行程序运行加-f参数的REBASE程序。Win32
SDK附带了REBASE。第三种删除重定位信息的方法是新的NT 4.0
IMAGEHLP.DLL内部的RemoveRelocations函数。下面的例子代码显示了如何使用RemoveRelocations。



合并片段
在可执行文件里,由程序的代码、数据、资源、导入信息、导出信息等等拼凑起来的原始数据被存放与不同的片段中。通常,程序中有一个代码片段(对于Microsoft的编译器叫做.text,对于Borland
C++编译器叫做CODE),一个可写的数据片段,一个资源片段(.rsrc),一个导入片段(.idata),一个导出片段(.edata)。对于公用片段做一个完全的罗列是没意思的。

除了这些“标准”片段之外,你还可以用编译器程序或者汇编器的SEGMENT指令建立其他的片段。比如,你可能有一些能够被所有使用你的DLL的进程共享的数据。为了实现数据共享,你最好建立一个新片段,并且告诉链接器链接文件的时候给予这个片段SHARED属性。

在某些情况下,“片段”是段的Win32的等效。在我的16-bit吸脂文章里,我描述了每个段是如何使用系统资源(比如,LDT选择符)的,这对保持你的代码的段的数量尽可能少是个好办法。这个思路对Win32同样有效。PE文件里的每个“片段”从内部操作系统表中使用内存。每个附加片段缺省情况下会给可执行文件增加512字节。

更重要的是,每个在内存中被访问的可执行文件片段都会使用至少4KB的物理内存。因而,即使你在片段里只使用了2字节的数据,你还是必须为此开销4KB的物理内存。如果你有三个片段,每个实际只使用了10字节内存,实际开销仍旧是12KB物理RAM。

只要可能,你就应该把属性相同或兼容的片段合并起来。我在这里有意模糊了一下,因为我没有找到一个硬性的、快速的法则可以给你。如果你没有显式地建立你自己的片段,而你又在使用本文所提及的工具,你可以放心地忽略这一点。这些工具可以非常恰当地合并逻辑上可以合并的片断。比如,Visual
C++ 4.1可以把好几个不同的OBJ文件里的.CRT、.bss和.data段合并成可执行文件的单一段。

如果你真的想优化你的应用程序,你会发现还是有一些段是链接器无法自动合并的。但是你可以强迫链接器合并它们。比如,如果你在Windows NT
4或者更高版本下测试可执行文件,你会发现大多数情况下.idata和.edata段已经被合并到.rdata(只读数据)段。

那么,你该如何合并片段呢?微软的链接器提供了一个开关称为/MERGE,精确的语法是:
/MERGE:<source_section>=<destination_section>

链接器会把源片段的内容添加到目的片段,从而使得它们看上去就像是磁盘上的一个单个的段。

对于有些段你可以把它放在一边,尽管看上去把它们合并起来更好些。一个是.rsrc段,Win32的UpdateResource函数假设资源总是在自己的独立的段里面。另一个要避免被合并的是.tls段,这是线程的局部变量呆的地方。线程局部变量是那些你用__declspec(thread)声明的变量。最后,你可能已经分割了你的代码,因此可能会有一个只是在启动的时候才用到的段。你不会想把这个段合并到其他段中,因为当来自附近其他逻辑段的代码和数据被访问的时候,那个段的代码会最有可能保存在内存页面中。(原文:You
wouldn't want to merge that with other sections, as that section's code would
most likely remain paged into memory when nearby code and data from other
logical sections are accessed.)

使用系统运行库代码:

如果你使用Visual C++,你也许能从你的可执行文件中除掉所有的运行库代码。到此为止,每个Win32平台都与至少一个Microsoft运行库(RTL)
for C/C++的拷贝想关联。理想情况下,你可以仅仅以来基本的C/C++ RTL DLL作为基本操作系统的起始部分。比如CRTDLL.DLL从Windows
NT 3.1开始就伴随着每个Win32平台。唉,微软再也不提供CRTDLL.DLL的引入库了。

自从CRTDLL.DLL不再成为一个选择,如果有标准的Visual C++ RTL DLL存在就好了。不幸的是,Windows
95使用了MSVCRT20.DLL,而Windows NT 3.51却不是这样。在Windows NT
4.0,存在MSVCRT40.DLL和MSVCRT.DLL,可就是没有MSVCRT20.DLL。

综上所述,在目前,我无法找到一个合适的方法来生成一个使用系统提供的C/C++ RTL
DLL并且可以使用所有当前Win32平台的DLL的程序。看样子,MSVCRT.DLL是朝着这个方向迈出了一步,可是,却没有相关的引入库。

说到这儿,如果你有一个用于特定Win32平台的程序,你可以使用系统附带的相关的MSVCRTxx.DLL。当然,如果你不在意额外的关联一个运行库DLL的工作,那么你当然应该考虑使用运行库DLL,而不是静态链接的RTL。如果你的产品有许多可执行文件组成的话,这就特别值得了。


避免地址空间冲突

到目前为止,我已经讨论了减小可执行文件大小的途径。现在让我们来看看一些通用的加快程序加载速度的途径。就想我前面提到的压缩尺寸那样,我的升级的吸脂程序可以识别某些你可以用来改进性能的东西。

早些时候,当我叙述除掉重定位信息的时候,我提到Win32加载器可能无法加载一个可执行模块到它的首选加载地址。载这种情况下,加载器不得不移动内存中别处的模块。重定位信息是一种允许加载器修改内存中模块以使之运行在与其首选加载地址不同的地址空间的信息。你可以想象使用重定位信息来在内存中移动模块是一件花时间的事情。重定位信息越多,时间开销越大。

那么,为什么加载器无法加载模块到它的首选地址空间呢?首要的原因是已经有某些东西占用了部分或所有的目的地址范围。“某些东西”是指什么?它可能是一个企图加载到已经用于线程栈的区域的模块,或者可能希望的加载地址与某个程序的堆区域冲突。最可能的情况是,模块企图加载到已经被其他模块占用的内存空间。不管这些冲突会造成什么后果,加载器都不得不找到一个不同的、未用的线性内存区域,并且处理所有的重定位信息来把模块移到那里。

加载冲突的典型例子跟DLL有关。大多数链接器使用0x10000000(或256MB)作为缺省的首选加载地址。如果你的Project由一个EXE和五个DLL组成,而你没对指定加载地址做任何处理,你会因为一个DLL可以加载到首选地址而另外四个DLL则需要加载器能找到能加载它们的地方而死机。很明显,必须采取措施来避免这一情形。

那么,该做些什么来避免这种情况的发生呢?首先,加载器允许你制订一个首选加载地址。对于Microsoft链接器,命令行选项是/BASE:xxxx,这里xxxx是一个十六进制格式的地址。对于Borland的TLINK32,等效开关是-B:xxxx。

知道了可以载链接时刻指定首选加载地址之后,你就可以试者把地址设定得足够远,使得任何两个DLLs都不会重叠。但是这种工作很罗嗦而且容易出错。比如,如果你把基地址挑得靠在一起,如果你下次修改了模块的代码,使得其内存范围扩张到模块的空间,就很容易造成冲突。

更简单的设置首选加载地址的方法是使用Win32
SDK的REBASE程序。REBASE的主要用途是改变一个已存在的可执行文件的首选加载地址。REBASE的实际能力是可以处理一组文件。REBASE建立一个进程将要加载的EXE和DLL的列表,并且为每个可执行模块计算加载地址,使得列表中的可执行模块不会和其他模块冲突。计算之后,REBASE就可以遍历和修改每个可执行模块的首选加载地址。

典型情况下,REBASE被用做项目的系统的一部分。所有的部件都链接好之后,一个包含了所有可执行模块名字的文件传送给REBASE,REBASE依此修改每一个可执行模块。比如,假设你的项目包含A.EXE,B.DLL,C.DLL和D.DLL,只要建立一个如下所示的文件:BASE_IT.TXT:

A.EXE
B.DLL
C.DLL
D.DLL

然后把这个文件连同起始首选地址一起传送给REBASE。比如:

REBASE -b 600000 -R C:\MYDIR -G BASE_IT.TXT

指定了BASE_IT.TXT列出的内容的映像应该是同一组的,而且起始地址是0x600000,并且列表中的文件名是位于C:\MYDIR目录。REBASE还有其他的一些选项,这里我并不打算描述。详情参见SDK文档。尽管REBASE并非一个界面友好的工具,但是如果你正在制作一个不平凡的商业软件的话,学习它的用法还是非常有价值的。



绑定

尽管避免地址空间冲突是一件很有价值的事情,但是还有很多方法可以用来降低Win32加载器的工作负载,以此改善可执行文件的加载时间。除了映像PE文件的片段到内存中之外,Win32加载器还要负责解决引入函数的引用。比如,假设你的程序调用了GetFocus函数。当你加载可执行模块时,Win32加载器不得不载内存中定位GetFocus,然后补足你的内存中模块的GetFocus的地址。

查找GetFocus的地址的活动很像GetProcAddress所做的工作。也就是,根据给出的模块的句柄和函数名字,搜索指定模块的导出表来查找被导出函数的地址。实际上,Win32加载器和GetProcAddress的动作相同并非偶然,在操作系统的内部,加载器和GetProcAddress使用了同样的内部例程。

既然知道了Win32加载器和GetProcAddress共享了许多代码,你就可以发现对于你引入的每个函数,操作系统都要像你对每一个引入函数调用GetProcAddress那样做出大致相同的工作。如果你对你的可执行文件运行一个像DUMPBIN或者TDUMP这样的程序,并且发现它引入了400个函数,想想看,每当你加载你的程序的时候,Windows要做400次GetProcAddress调用。够吓人的,是不是?

那么,如何改善这种情况呢?每次你运行程序的时候,被导入函数的地址也许不会改变。其实,当调用诸如GetFocus这样的系统函数的时候,引入函数的地址是不会改变的,除非用户安装了操作系统的升级版或者补丁。

由于入口函数的地址不会改变很多(开发阶段可能会有例外),如果有一种方法能够一次获得引入函数的地址,然后把它保存在你的可执行文件中,这岂非很伟大?现在,这种方法已经有了。这个过程称为“绑定”。当Win32加载器遇到一个正确绑定的可执行文件的时候,它就可以避免这些耗时的函数搜索工作。作为一个参考,Windows
NT附带的所有可执行文件都已经经过了“绑定”。

绑定可以用两种方法之一来完成。首先,你可以运行Win32 SDK的BIND程序。比如:
      BIND –u FOOBAR.EXE
使得BIND遍历FOOBAR的引入函数列表,计算这些函数的地址,把这些地址写回FOOBAR.EXE。-u参数告诉BIND前进,并且把地址写入可执行文件。如果没有-u参数,BIND遍历查找引入函数的地址,但是不改写可执行文件。

绑定可执行文件的另一种方法是通过使用IMAGEHLP.DLL中的BindImage和BindImageEx函数。SDK的BIND工具仅仅是封装了BindImageEx函数。我的样本吸脂程序演示了如何使用BindImage。你可能会考虑用到BindImage和BindImageEx的一个地方是你的安装过程。

关于绑定有两个共同问题。首先,如果你绑定你的硬项,而后来导入的DLLs发生了改变,这将会发生什么事情呢?没事儿。当IMAGEHLP绑定可执行文件的时候,它也把一个表示导入的DLL的时间的时间标记写入到这个可执行文件中。当Win32加载器处理可执行文件中的导入函数的时候,会把你的文件中的时间标记同导入函数的DLL的时间标记做比较。如果时间匹配,加载器结束工作。如果时间标记不同,你也不用担心,加载器会象没有经过绑定那样处理可执行文件。换言之,如果你绑定了一系列DLLs,而其中的一个或多个发生了改变,并不会降低你的程序的性能。

另一个有关绑定的问题是,当你无法确切知道你的程序将要运行的Win32平台和版本(Windows 9x?Windows NT
3.51?4.0?哪一个Service
Pack?)的时候,会发生什么事情。比如你的程序调用了USER32.DLL的GetFocus,而且你对这个程序作了绑定。唯一能够感觉到你的绑定工作带来的速度的提高效果的人是那些拥有与你的USER32.DLL版本完全相同的人。因此,绑定值得一做吗?

即使你还没有意识到把你的程序映像绑定到系统API函数有任何好处,你还是会从把EXE和DLLs绑定的做法中得到好处。同样,如果你有好几个互相调用的DLLs,把这些DLLs绑定起来似的你导入的函数运行的更快的做法还是非常有价值的。当然,成功的绑定首先建立在你正确地REBASEd你的可执行文件的基础上。把你的可执行文件绑定到家载器会移到别处的DLL上不会给你带来任何好处。理想情况下,你的安装程序将会把你的可执行文件和DLL绑定起来作为安装程序的一部分。




Liposuction32
理论已经足够了!对我来说,本文最好的部分就是编写一个样本程序。有一个例外事,我的吸脂程序提供了我上面所述的每一个提示。实际上,只有一个核心程序,但是它既可以构造成一个命令行程序,(LIPO32.EXE)也可以构造为GUI程序(Liposuction32.EXE)。

如图2所示的GUI版本对交互地分析可执行文件是很有用的。你既可以在窗口顶部的编辑框中输入文件名,也可以用拖-放的方式把任何地方的文件拖到这个窗口。一旦给Liposuction32.EXE一个工作文件名,它就会在对话框的相关区域填写与这个程序有关的信息。底部的三个按钮提供了命令行程序LIPO32未实现的功能。这些按钮可以去除文件中的调试信息,剔除重定位信息,以及执行绑定工作。然后,这个程序会被重新分析以提供新的改进的版本。

图2 Liposuction32

图3所示的命令行版本LIPO32.EXE不想那么时髦Liposuction32.EXE,但是在其他方面更胜一筹。由于这是一个命令行工具,你可以在你的BUILD过程终于它协同工作。同样,你可以把它的所有输出重定向到一个文件中。如果你想分析大量文件的话,这可是一个非常方便的手段。比如,我使用命令行版本分析\WINNT\SYSTEM32目录下的每一个文件。如果你喜欢这样工作,可以求助于SHELL的FOR命令,比如,命令行

for %a in (*.exe *.dll) do lipo32.exe %a >> LIPO_OUTPUT

为当前目录下的每个EXE和DLL文件建立了一个LIPO32报告,并且把输出放到一个名为LIPO_OUTPUT的文件中。

Figure 3 LIPO32


对结果的解释

我们先来讨论GUI版本,看看这两个程序给了你什么信息。我之所以选择GUI版本是因为你总是可以看到某些表示这个程序的用途的信息被显示出来。如果命令行版本未发现什么错误,它并不输出任何特别的细节。

回到图2,顶部的编辑框包含了即将被分析的程序文件名。你可以输入程序名字,然后按下ENTER键来改变Liposuction32
的“显微镜”下的文件。文件名的左下方是显示重定位标的大小和映像是否绑定的区域。

标题为Incremental Linking的列表框告诉你在每个可执行文件的代码片段里找到了多少个INT 3。如前所述,当链接器执行增量链接的时候,会插入INT
3s。我是怎样得知文件中有多少个INT
3的呢?我使用了一种“残忍”的强制措施,这种措施在我所做的所有测试中看起来工作的很正常。在每个代码片段里,我反复扫描三个值为0xCC的连续字节。单个的0xCC是INT
3指令的操作码。我选择三个连续的0xCC作为触发条件是因为其他包含三个0xCC的指令出现的机会非常小。一旦我在一行里面发现三个INT 3,我继续扫描其他的INT
3s直到代码结束或者找到了不同的字节。

有一点很重要:在你的程序里面出现几百个INT 3s并不意味着曾经使用过增量链接。我说过链接器在缺省情况下如何按照16字节边界对齐OBJ文件和函数并用INT
3s填充其中的空隙。这些对齐用的INT 3s经常会显示出来。我并未试图把这种INT 3s同增量链接生成的INT 3s区分开来。毕竟,所有的INT
3都只不过是INT 3,而且它们都无谓地占用了空间。如果你需要区分这两种类型的INT
3,你可以修改我的代码来查找不超过15字节(16字节OBJ文件对齐情况下插入的最大填充代码)的INT 3s。

解释报告的INT 3s的数量的意思有点棘手。如果你又不超过1K的INT 3s,你可能看到的是Microsoft Linker的16字节填充物。如果有大量的INT
3s(比如,占到了全部代码的25%),可能你看到的是增量链接的产物。如果你看到的INT
3s在1~10KB之间,这既可能是增量链接的产物,也可能是链接器把每个函数对齐到16字节边界的产物。我早些时候描述Visual
C++的不带/O1开关的/Gy开关的时候曾经提到过这一点。我这里用的是粗略的数值。如果你的程序特别小或者出奇大,就需要调整这些选项。

标记为Debug
Info的列表框可执行文件中找到的调试信息的大小和类型。由Borland编译器生成的程序的调试信息标记为BORLAND,对于Microsoft的调试信息,你会看到诸如CODEVIEW、COFF、FPO以及MISC等调试信息的组合。其他编译器生成的调试信息可能会显示为CODEVIEW信息。

杂项的调试信息会仅仅占据0x110字节,而且可以被很安全地忽略掉。如果你看到文件中的Borland、CodeView或者COFF信息,就需要考虑一下了。文件中可能包含一些你不希望其他人看到的符号信息。FPO调试信息仅仅同其他调试信息一起使用,尽管它会占据大量的空间。关键问题是当你看到这些调试信息的时候,可能这个程序是debug板,因此并未经过优化。应该把调试信息的出现当作更深层次问题来对待。当然,你总是可以发现例外。比如,大多数的Windows
NT系统DLLs包含FPO信息以辅助调试,但是它们在编译的时候是经过优化处理的。

名为Unoptimized
Code的列表框仅显示不使用优化器是造成的空间浪费。一个建议是如果你发现未优化代码的迹象,你可以知道优化器未能工作。吸脂程序找到的未优化代码是跳转到后面的代码的JMP指令。这些JMPs指令浪费了5个字节,却什么也做不了。吸脂程序合计了它发现的这种序列的总数并且报告结果。记住,这些JMPs仅仅是未优化代码中的一类。还有许多其他的代码序列也会浪费空间,而我的代码是不会查找这类信息的。

如果你发现大量的“愚蠢的JMPs”,你最好看看是否选择了debug build方式(这时通常优化功能会关闭),如果你使用Visual
C++,你也可能会发现大量的INT 3s。矫正方法:用release方式重新编译。

Uninitialized
Data列表框包含可执行文件中所有包含未初始化数据的片段的列表。这个列表框应该是空的,因为链接器能够把这些片段组合到其他初始化数据段中以减少片段的数量。如果你在这个列表框中看到了内容,你可能再用一个过时的链接器。

Combinable
Sections列表框列出了可执行文件中可能被组合的片段。理论上,这些片段都可以被组合在一起(比如使用/MERGE开关)以减小片段数量,还可能节省磁盘空间。在每个可组合片段系列的末尾,列出了组合前和组合后会需要多少内存页面。可是,记住,你并不想盲从程序的提示。正如我前面所述,可能会有更好的理由而不去组合某些片段。

编写Combinable
Sections列表框的逻辑非常简单,所以我不会指出每一种简单的可能性,简而言之,就是查找具有相同属性的片段的代码。尽管如此,在比较片段属性之前,算法不考虑.reloc和.rsrc段。一个严重的缺陷是,这个算法逻辑不会提出可以被合并的片段,即使它们有不同的属性。比如,.idata和.edata段理论上可以合并到.rdata段中,尽管他们的属性不同。在我的测试中,这个列表框经常建议.idata段应该合并到.data段中。一个更好的选择是把.idata放到.rdata中。

Load
Conflicts列表框报告了导致加载冲突的可执行模块的文件名和内存范围。既然Liposuction32一次只检查一个可执行文件,它又是如何得知有加载冲突的呢?我的代码仅仅是简单地做了加载器所做的工作,那就是:它观察导入表,并且提取每一个导入的DLL的名字,然后,程序试图定位导入模块。如果找到了导入模块,Liposuction32提取其最佳导入地址。

当这些工作处理可执行文件的直接导入的所有模块的时候,如果一个导入模块导入了其他DLLs该怎么办?在定位和存储每一个导入模块到一个表中之后,Liposuction32对这个表执行排序,并且查找那些如果按照首选加载地址加载就会在线性地址空间相互重叠的模块。如果你发现这个列表框中有内容,恐怕就需要使用我前面介绍的REBASE程序了。

Liposuction32底部的三个按钮使得你可以修改一个已经存在的可执行文件。Strip Debug
Info按钮仅仅在可执行文件包含调试代码时可用。当我在Windows NT 4.0 Beta
2中编写这段代码的时候,我发现IMAGEHLP若作用与Borland的可执行文件,就会留下一个零长度文件。这一问题应该会在Windows
NT的后续版本里面得到解决。如果这个按钮可用,而你选择了“剔除调试代码”,则程序会调用IMAGEHLP的SplitSymbols函数。我注意到SplitSymbols还是会在文件中留下杂项调试信息。由于这些信息很小,你可以选择忽略之。如果你真的想完全剔除它,重新链接这个可执行文件而不要允许任何调试标记选项。

Remove
Relocations按钮在EXE文件中存在重定位信息的时候可用。对于DLLs来说,我故意禁止这个按钮以防止无意剔除重定位信息。如果你真的想删除DLLs中的重定位信息,你可以用-f选项运行REBASE。在程序内部,Remove
Relocations按钮使用IMAGEHLP的RemoveLocations函数。在Windows NT beta
2中,我发想这个函数的一个问题会导致可执行文件再也无法运行。这在以后的版本中应该会解决。

Bind按钮总是可用的,即使可执行文件的映像已经经过了绑定。为什么要这么做?我们假设经过检查的可执行文件已经经过了绑定,可能这个程序绑定的一个或者多个DLLs发生了改变。虽然我可以编写一段用于从可执行文件的绑定导入表读取信息和检查时间标记的代码,不过我想把这个工作作为那些有进取心的读者的练习。




Liposuction32和Lipo32的代码

在结束之前,我们来复习一下Liposuction32和Lipo32的代码的重要部分。我将以一步一步的详细分析来与你分享这段代码因为这里有大量的代码。而且,这里有一些值得了解的有用的提示,尤其是如果你将自己扩充这个程序的话。

两个程序的核心代码都是LIPO32.CPP(如图4所示)。AnalyzeFile函数就是分析一个可执行文件的地方。它既可以通过GUI代码(LIPOGUI.CPP)调用,也可以通过命令行代码(CMDLINEUI.CPP)调用。LIPO32.CPP文件自己并不输出任何信息。它检查指定的文件,然后调用一系列输出函数输出结果。输出函数的原型在LIPO32OUTPUT.H中。

图4 LIPO32.CPP

LIPOGUI.CPP和CMDLINEUI.CPP都实现了LIPO32OUTPUT.H中的函数。这是在GUI和命令行版本之间共享核心代码的关键。MAKEFILE中的定义HARDCORE=1告诉NMAKE应该链接命令行源代码而不是GUI版本。

现在我们回到LIPO32.CPP中的AnalyzeFile函数,该函数所做的第一件事情是用传入的文件名建立一个PE_EXE2对象。我会简短描述一下PE_EXE2对象。在建立PE_EXE2对象之后,AnalyzeFile函数使用这个对象来验证指定的文件是否是一个真正的PE文件并且是一个80386系列二进制代码。如果两个条件都不成立,AnalyzeFile报告一个错误并且返回。

AnalyzeFile函数的其他工作很简单。使用PE_EXE2对象调用函数LookForIncrementalLinking,
LookForUnoptimizedCode, LookForDebugInfo, LookForRelocations,
LookForCombinableSections, LookForBSSSections, LookForLoadAddressConflicts, and
LookForBoundImage。我这里略去了杂七杂八的函数细节。如果你感兴趣,可以参见LIPO32.CPP源代码,在那里我做了很多注释。

LIPO32.CPP核心代码严重依赖于PE_EXE2类。PE_EXE2是一个我用于封装PE文件细节的类。PE_EXE2其实是第三代派生类。可以参见图5的类层次图。



图5PE_EXE2类层次图。(原文这里缺少图5)

MEMORY_MAPPED_FILE类提供了用于获取文件名并使之在内存中可用的的功能。构造函数和析构函数维护打开和关闭文件、内存映像和其他功能。如果你还没有内存映像类,这个类或许对你有价值。

EXE_FILE类位于MEMORY_MAPPED_FILE之上。它的作用仅限于告知内存映像文件是否是可执行文件。如果文件是可执行文件,EXE_FILE类会告诉你它是什么类型(MS-DOS?16-bit
Windows、OS/2 2.x、VxD还是PE)。

PE_EXE类是让人感兴趣的类。这各类对WINNT.H中定义的PE文件数据结构做了很少的一点包装。它的功能几乎仅限于读出PE头信息。此外,PE_EXE类也可以返回信息和用PE文件数据目录——诸如导入表达小或指向调试信息的指针——计算出来的指针。如果你的程序操作PE文件,你可能会考虑把PE_EXE加入到你的工具箱中。

记住重要的一点:PE_EXE类的设计是与WINNT.H中的信息一同工作的而不是替代之。比如,GetCharacteristics方法返回一个包含可执行文件信息的DWORD标志字。这些标志对应着WINNT.H中的有关定义。我并不在意重新生成WINNT.H中定义的所有#define和数据结构。如果你想使用PE_EXE类,你应该考虑把WINNT.H中的PE文件结构和定义作为类的一部分。

最后,我来介绍一下PE_EXE2类,这是从PE_EXE类派生的。PE_EXE2提供两类PE_EXE未提供的功能。首先,PE_EXE2提供了对可执行文件中的片段的访问。你可以用名字、从1开始的下标或相对虚拟地址(RVA)来搜索一个片段。每个片段相关的方法都返回一个PESECTION.H中定义的PE_SECTION对象,PE_SECTION对象只是对WINNT.H中定义的IMAGE_SECTION_HEADER结构的简单封装。

PE_EXE2拥有而PE_EXE没有的其他额外特性是PE_EXE2允许轻松访问文件中的调试信息。解释调试信息有一点困难,因为,Borland和Microsoft在如何解释PE格式的关键字段方面历来就不一样。我建立了PE_EXE2类,因此我可以在其他无需调试信息和片段信息的项目里使用基类PE_EXE。

除了PE_EXE2类以外,吸脂程序代码以来的其他类是MODULE_DEPENDENCY_LIST类。这个类对于找到在进程地址空间相互重叠的可执行文件时非常重要的。给它一个可执行文件的名字,它就可以构造一个完整的该可执行文件直接导入或通过其他模块间接导入的模块的列表。在构造了这个列表之后,该类可以用来通过名字查询某个特殊的模块。该类还可以报告列表中有多少模块,以及枚举列表中的每一个模块。为了简化MODULE_DEPENDENCY_LIST类的代码,我使用了前面描述的PE_EXE类。

MODULE_DEPENDENCY_LIST类的构造函数获取可执行文件的名字,并且用递归的方法查找每一个导入的DLL。为了重复Win32加载器的行为,构造函数临时把当前工作目录改变为原始可执行模块所在的目录。模块列表建立之后,构造函数恢复了原始的当前工作目录。我说明这一点只是由于这么做会导致该类在多线程映用中不安全,因为当前工作目录提供给整个进程而不是某个线程。


总结

我已经论述了可能导致可执行文件效率低下的两个方面:大小和性能。在大小方面,如果你还没做过什么努力,那么就确保你的程序不是Debug版本。在速度方面,要想办法用诸如REBASE这样的工具避免加载地址冲突。我也论述了其他可以优化的部分,但是,即使你仅仅做了上面两件事情,你也可以很好地改进你那拙劣的代码了。

这些天来,诸如GB(giga-byte)和200MHz之类的词汇到处泛滥。看上去调整程序的性能使之尽可能高效率的做法已经是过时的艺术了。正如我在此处所言,成为一个编程团体的友好公民并不困难。如果我们一起来努力使得我们的代码干净而快速,我们就可以打破仅仅为了使工作能够进行而不得不需要越来越大和越来越快的计算机这样的恶性循环。

本文由Miller Freeman公司发表在《微软系统月刊》1995年第一期。版权保留。未经Miller
Freeman公司同意,本文的任何部分不得以任何方式被复制(除了评论性文章)。

要想同Miller Freeman公司联系定购,在美国可用(800) 666-1084,其他国家用(303) 447-9330。其他调查,请用(415)
358-9500

[译者注]本文由松鼠和老狐狸翻译。