如何减小EXE和DLL的文件长度&init&crt&startup

59 篇文章 0 订阅
41 篇文章 0 订阅
作者Matt Pietrek
翻译lostall
原文Under The Hood: Reduce EXE and DLL Size with LIBCTINY.LIB, January 2001
声明严格地讲,这不是一篇翻译,我更愿意把它叫作“意译”。即,我尽力保证文章内容与原文完全一致,但我不保证与原文表达完全一致。特别地,对常用术语、我拿不准的句子,或者我懒得写汉字的话,我会直接引用英文。如果可能,读者还是尽量阅读原文为最佳。

         [摘要]     这篇文章讲述了如何减小EXE和DLL的文件长度。第一个小技巧是使用/OPT:NOWIN98链接选项,可以让Section按512字节对齐,而不是默认的4K字节。这只是小case,这篇文章最动人之处在于它模拟实现了一个简单的Run time Library: libctiny.lib。麻雀虽小五脏俱全。
(1) VC的运行时库会创建一个自己的heap,而libctiny.lib里的malloc等函数只是简单地调用GetProcessHeap()。
(2) libctiny.lib实现了简单的Startup函数,关键之处在于开始运行时要调用静态C++成员变量的构造函数,结束时调用静态C++成员变量的析构函数。
(3) 本文最精彩的地方是介绍了VC里是怎么处理静态C++成员的构造和析构的,不过文中对于如何执行析构函数并没有讲全,没有说明atexit是何时调用的。我补充以下测试数据:
$E4:
00401117 push ebp
00401118 mov ebp,esp
0040111A call $E1 (0040112d) // function for executing constructor
0040111F call $E3 (00401143) // function for executing 'atexit'
00401124 cmp ebp,esp
00401126 call __chkesp (00401727)
0040112B pop ebp
0040112C ret

$E1:
0040112D push ebp
0040112E mov ebp,esp
00401130 mov ecx,offset g_TestClassInstance (00405758) // this指针
00401135 call @ILT+10(TestClass::TestClass) (0040100f)
0040113A cmp ebp,esp
0040113C call __chkesp (00401727)
00401141 pop ebp
00401142 ret

$E3:
00401143 push ebp
00401144 mov ebp,esp
00401146 push offset $E2 (0040115c) // function for executing destructor
0040114B call atexit (00401379) // function for adding the function pointer of '$E2' into the list of static destructors
00401150 add esp,4
00401153 cmp ebp,esp
00401155 call __chkesp (00401727)
0040115A pop ebp
0040115B ret

$E2:
0040115C push ebp
0040115D mov ebp,esp
0040115F mov ecx,offset g_TestClassInstance (00405758)
00401164 call @ILT+0(TestClass::~TestClass) (00401005)
00401169 cmp ebp,esp
0040116B call __chkesp (00401727)
00401170 pop ebp
00401171 ret

         在执行_initterm时会调用$E4,而$E4会首先调用$E1执行构造函数,然后紧接着调用$E3把析构函数的地址$E2加入到list of static destructors中。

         我早在MSJ 1996年10月份的专栏里,就提出了一个关于可执行文件大小的问题。那个时侯一个简单的Hello World的程序编译后有32KB那么大。对于此后的两个编译器版本,问题也只是稍微好了一点而已。现在用VC6.0编译同样的程序也达到了28K。
         在那个专栏里,我提供了一个运行时库的替代品,可以让你创建非常小的可执行程序。虽然在适用性上有一些限制,但在我自己的很多程序里都用得非常好。经历了这么长时间,我决定是时侯对这些限制做一些修正了。同时这也是一个非常好的机会可以给大家描述一个很少有人知道的链接器选项,它可以更进一步的减小程序的大小。

EXE and DLL Size

         在开始进入运行时库替代品的代码之前,有必要花点时间复习一下为什么简单的EXEs和DLLs会比你想象中的大。考虑下面这个典型的Hello World程序:

#include <stdio.h>

void main()
{
    printf ("Hello World!/n" );
}

         让我们以大小优先的方式编译程序(译注:即编译器选项/O1,最小化大小),并生成一个map文件。在VC的命令行下,使用如下的语法:

Cl /O1 Hello.CPP /link /MAP

         首先看一看.MAP文件,Figure 1中是一个减化的版本。注意观察main函数的地址(0001:00000000)和printf函数的地址(0001:0000000C),你可以推断main函数的大小只有0xC个字节。再看看文件的最后一行,__chkstk函数的地址是0001:00003B10,你可以推断出执行程序中的代码至少有0x3B10个字节。即往屏幕上写个Hello World需要超出14KB的代码。
         现在浏览一遍.MAP文件中的其它部分。有些函数还是有意义的,比如__initstdio函数。毕竟printf是把输出写到一个文件中,所以一些底层的为stdio提供支持的运行时库函数还是有必要的。同样的,也可以估计到printf的代码可能会调用strlen,所以包含strlen也不会让人感到奇怪。
         然而,看一看其他的一些函数,比如__sbh_heap_init。这是用于初始化运行时库的small block heap的函数。Win32系统通过HeapAlloc家族的函数提供它们自己的堆。因为使用自己的堆具有潜在的性能上的好处,所以尽管VC库可以选择使用Win32的堆函数,但是它没这么做。结果就是,你的执行程序里包含了比需要的更多的代码。
         尽管有些人可能不关心运行时库实现了自己的heap,但另外有些情况你不能漠然视之。考虑map文件中靠近最下面的__crtMessageBoxA函数。这个函数允许运行时库调用MessageBox函数,而不用强迫执行程序链接到USER32.DLL。对于一个简单的Hello World程序,你很难预料到它还要调用MessageBox。
         再看看另一个例子:__crtLCMapStringA函数,它实现字符串的locale-dependent transformations。尽管Microsof是有一些责任提供本地化支持,但很多程序并不真的需要它。为什么要让不使用locales的程序付出使用locales的代价呢?
         尽管我还可以继续列举一些包含了不需要的代码的例子,但现在这些已经足够证明我的论点。一些典型的小程序里包含了大量没被用到的代码。对于他们各自而言,产生的影响并不大,但是把所有情况累加起来,你就会发现,代码变得太大了!

What About the C++ Runtime Library DLL?

         思维敏捷的读者可能会问,"Hey Matt!为什么你不用运行时库的DLL版本?"要是在过去,我会辩解说没有在Windows 95,98,NT 3.51,NT 4.0等操作系统上命名一致的C++运行时库的DLL版本。幸运的是,现在已经今非昔比,在大多数情况下你可以依赖MSVCRT.DLL,它在你机器上总是存在的。
         打开这个开关(译注:即选择运行时库的DLL版本,比如/MD)然后重新编译Hello.cpp,生成的可执行程序只有16KB了。结果并不坏,但你还可以做得更好。更重要的是,你只不过是把所有不需要的代码移到了别的地方而已(即:MSVCRT.DLL)。另外,当你的程序启动时,另一个DLL必须被装载并且初始化,这个初始化包括你可能并不关心的本地化支持等等。如果MSVCRT.DLL满足你的需要,当然可以继续用它。但是,我相信一个精简的静态链接地运行时库仍然有它的存在价值。
         我可能是在和风车作战(译注:取自唐吉诃的风车大战,应该解释为理想主义),但我和读者之间的e-mail交流显示我并不孤单,总是有人想要最小的代码。在现在这个遍布可写CD,DVD,高速Internet连接的年代里,人们很少担心代码的大小。但是在我家里最快的Internet连接也只有24Kbps,我憎恨在浏览网页时要浪费时间下载臃肿的控件。
         作为原则,我希望我的代码尽可能的小巧。我不想load任何我没有真正用到的DLLs。就算我可能需要一个DLL,我也要尽可能地delayload它,这样我才不会为装载它而付出代价,直到我真正用到了它。Delayloading是我在以前的专栏里讨论过的一个主题,我强烈推荐你去熟悉它。初学者可以从
Under the Hood in the December 1998 issue of MSJ开始。(译注:这篇文章的译文在此)

Digging Deeper

         既然已经解决了程序中未使用代码的问题,我们再转向可执行文件本身。如果你对我的Hello.exe运行DUMPBIN /HEADERS的话,会发现输出以下两行:(译注:section alignment是内存中节对齐的大小,file alignment是文件中节对齐的大小,详细内容参见IMAGE_OPTIONAL_HEADER)

1000 section alignment
1000 file alignment

         第二行很有趣,它是说在执行文件中每一个code和data段都是按4KB(0x1000)字节对齐的。因为sections在文件中是连续存储的,不难发觉在上一个section的结尾和下一个section的起始之间浪费的空间最多可达到4KB。
         如果我用一个比VC6.0更早的linker链接这个程序的话,会发现有一些不同,如下所示:

1000 section alignment
200 file alignment

         关键的差异是section是按512(0x200)字节对齐的,这减少了浪费的空间。VC6.0里,链接器缺省的是让section在文件中的对齐方式等于在内存中的对齐方式。这种做法在Windows 9x上对启动速度有轻微的提高,但是使执行程序变大了。
         幸运的是,VC linker有一种方法可以回到以前的做法,这个开关就是/OPT:NOWIN98。Rebuild Hello.cpp,增加的这个开关可以使执行文件的大小减少到21KB,节省了7KB。如果链接到MSVCRT.DLL并且同时使用/OPT:NOWIN98,执行文件的大小一下子降到了2560个字节!

LIBCTINY: A Minimal Runtime Library

         既然你已经理解了为什么简单的EXEs和DLLs会那么大,那么是时侯介绍我的新的改进了的运行时库的替代品了。在October 1996的专栏里(前面提过),我创建了一个小的静态.LIB文件,用来替换或者补充Microsoft的LIBC.LIB和LIBCMT.LIB。我把它叫做LIBCTINY.LIB,因为它是Microsoft自己的运行时库源程序的一个非常stripped-down的版本。
         LIBCTINY.LIB是给那些简单的不需要很多运行时库支持的应用程序使用的。因此,它不适用于MFC程序,或者其它需要大量使用C++运行时库的复杂情况。LIBCTINY.LIB的理想对象是调用了一些Win32 API函数并且可能会显示一些简单输出的小程序或DLLs。
         LIBCTINY.LIB背后有两个指导原则。首先,它用非常简单的代码替换了Visual C++的标准启动函数。这个简化的代码不引用任何更深奥的运行时库函数,比如__crtLCMapStringA。正因如此,极大地减少了链接到你的执行文件中的无关代码。正如我稍后将说明的那样,LIBCTINY的函数在调用你的WinMain, main或DllMain之前仅仅执行了最少的任务。
         LIBCTINY.LIB的第二个指导原则是用已经存在于Win32系统DLLs的代码实现相当规模的函数,比如malloc,free, new, delete, printf, strupr, strlwr等等。看一眼printf.cpp(
Figure 2)中printf的实现,体会一下我说的意思。
         在LIBCTINY.LIB的原始版本里有两个限制一直困扰着我。第一,原始的版本里不支持DLLs。你可以创建小的Console和GUI执行程序,但不幸的是你不能创建一个小的DLL。
         第二,原始的版本里不支持静态C++构造和析构。我这里指的是在全局范围内声明的构造和析构。在新版本里我已经加上了提供这种支持的基本代码。在这个过程中,我也学到了很多关于编译器和运行时库为实现静态构造和析构玩的一个复杂游戏的知识。

The Dark Underbelly of Constructors

         当编译器处理一个有静态Constructor的源文件时,它生成两个东西。首先是一个名字类似于$E2的一小段代码,负责调用Constructor。其次是一个指向这段代码的指针。这个指针被写到.OBJ文件中一个叫做.CRT$XCU的特殊节中
         为什么叫这么有趣的名字?原因有点复杂。让我提供一点别的数据帮助解释。如果你检查VC运行时库源代码(比如,CINITEXE.C),你会发现下面这段代码:

#pragma data_seg(".CRT$XCA")
_PVFV __xc_a[] = { NULL };

#pragma data_seg(".CRT$XCZ")
_PVFV __xc_z[] = { NULL };

         上面的代码创建了两个数据段:.CRT$XCA和.CRT$XCZ。在每个段中放了一个变量(分别是__xc_a和__xc_z)。注意,段的名字非常类似于编译器把Constructor代码指针放到的.CRT$XCU段的名字。
         这里需要了解一点Linker的理论。当处理所有的段以产生最终的PE文件时,链接器合并所有相同名字的段的数据。所以,如果A.OBJ有一个段叫.data,B.OBJ也有一个段叫.data,那么A.OBJ里.data段的数据和B.OBJ里.data段的数据会被顺序写到PE文件中一个单独的.data段中。
         段名里使用的$符号有特别的作用。当遇到段名里有$符号的时侯,链接器把$之前的名字做为最终的段名。这样,.CRT$XCA, .CRT$XCU, 和 .CRT$XCZ段被合并在一起,成为最终的执行文件中的.CRT段。
         那么段名里$之后的部分是干什么用的呢?当合并这种类型的段时,链接器根据$之后的字符串的字典顺序进行处理。所以,.CRT$XCA段内的数据先处理,紧跟着的是.CRT$XCU段内的数据,最后是.CRT$XCZ。这是理解的关键点。
         运行时库代码不知道一个指定的EXE或DLL里有多少个静态的构造函数需要被调用。但是,它知道在.CRT$XCu段内的指向constructor code块的指针。当链接器合并所有的.CRT$XCU段时,它产生的实际效果是创建了一个函数指针数组。通过定义在.CRT$XCA和.CRT$XCZ段内的__xc_a和__xc_z变量,运行时库能够可靠地定位函数指针数组的起点和终点。
         正如你可能料到的,调用模块内所有的静态构造函数,只是简单的遍历函数指针数组,依次调用每个指针。做这件事的函数是_initterm(
Figure 3),它与Visual C++运行时库里的源代码是一样的。
         考虑完所有的事以后,LIBCTINY让static constructors运行起来相对就简单了。主要就是定义正确的数据段(特别是.CRT$XCA和.CRT$XCZ),然后从启动代码的正确地方调用_initterm。但是处理静态析构就要有点技巧了。
         不同于编译器和链接器合谋为静态constructor创建的函数指针数组,静态destructor列表是在运行时创建的。为了建立这个列表,编译器产生对atexit函数的调用,它是Visual C++运行时库里的一个函数。atexit接受一个函数指针,然后把这个指针添加到一个FILO(先进后出)列表(译注:其实就是stack)中。当EXE或DLL卸载时,运行时库遍历这个列表,并且调用每个函数指针。
         LIBCTINY对atexit的实现比VC运行时库里的实现简单了很多。有三个函数和几个静态变量用于实现它,也在initterm.cpp中。_atexit_init简单地分配一个容纳32个函数指针的数组,并把数组指针存到pf_atexitlist静态变量中。
         atexit函数检查数组中是否还有空间,如果有,就把函数指针加到数组的末尾。这段代码的一个更强健的做法是当需要时reallocate数组。最后,_DoExit函数使用你的朋友,_initterm,去遍历数组,调用每个函数指针。理想情况下,_DoExit应该模仿VC运行时库的实现,以相反的方向遍历数组。但是LIBCTINY的目的只是为了简单小巧,没有必要追求完美的兼容性。(译注:C++标准规定Destructor的顺序应该与Constructor的顺序相反。LIBCTINY没有遵循这个规定,但也没有什么影响。应用程序也应该尽量避免使用构造或析构顺序有依赖关系的全局变量。)

LIBCTINY's Minimal Startup Routines

         现在让我们看看LIBCTINY对小DLL的新支持。像EXE一样,技巧在于使DLL的入口点代码尽可能的小,并且去除可能引起大量其他代码的无用程序。Figure 4展示了最小的DLL启动代码。当你的DLL被装载时,是这段代码,而不是你的DllMain首先被执行。
         _DllMainCRTStartup函数是你的DLL里执行的起始点。在LIBCTINY的实现中,它首先检查是否DLL处在DLL_PROCESS_ATTACH调用中。如果是,就调用_atexit_init(前面讲过),然后通过_initterm调用静态Constructor。函数的核心是调用DllMain,它是你在Dll里自己提供的。这个DllMain的调用对四种通知类型都适用(process attach/detach, and thread attach/detach)。
         DllMainCRTStartup要做的最后一件事是检查Dll是否处在DLL_PROCESS_DETACH调用中。如果是,就调用_DoExit。与前面讲的一样,这会导致所有的静态析构函数被调用。如果你对Console和GUI类型的EXE的启动代码感到好奇的话,一定要看看CRT0TCON.CPP和CRT0TWIN.CPP。
         另一个值得查看的是在DLLCRTO.CPP(看
Figure 4)的靠近最上面的一行代码:

#pragma comment(linker, "/OPT:NOWIN98")

         它把一个链接器命令放到DLLCRTO.OBJ文件中,其作用是通知链接器使用/OPT:NOWIN98开关。好处是你不用再手工地把/OPT:NOWIN98添加到你的make文件或工程文件中。我考虑到如果你使用LIBCTINY,你应该也会想使用/OPT:NOWIN98。

Using LIBCTINY.LIB

         使用LIBCTINY非常简单。你需要做的所有事就是把LIBCTINY.LIB加到链接器的.LIB列表中。如果你正在使用Visual Studio's IDE,应该在Projects | Settings | Link下面。哪种类型的执行文件都可以(console EXE, GUI EXE, or DLL),因为LIBCTINY.LIB为每种类型都提供了适当的入口点。
         看一看
Figure 5中的TEST.CPP。这个程序简单地使用了几个LIBCTINY.LIB实现的函数,并且包含了一个静态constructor和destructor的调用。当我用Visual C++以正常方式编译它时,

CL /O1 TEST.CPP

         生成的执行文件是32768字节。简单地在命令行上加上LIBCTINY.LIB:

CL /O1 TEST.CPP LIBCTINY.LIB

         生成的执行文件缩减到了3072字节。
         你可能很想知道哪些运行时库函数LIBCTINY没有实现。例如,在TEST.CPP中,有一个对strrchr的调用。这里没有问题,因为Visual C++提供的LIBC.LIB和LIBCMT.LIB包含了这个函数。LIBCTINY.LIB和LIBC.LIB都实现了各种各样的函数。LIBCTINY提供的明显比LIBC.LIB的少。重要的是要让链接器在解析函数调用时首先找到LIBCTINY的函数,这样LIBCTINY的函数才会被使用。如果有些函数LIBCTINY没有实现,链接器会在LIBC.LIB中找到它。
         最后有必要重复一下,LIBCTINY不适用所有的情况。比如,如果你的代码使用了多线程,并且依赖于运行时库提供的per-thread data支持,那么LIBCTINY就不适合你。我的做法是对一个估计可以的程序使用LIBCTINY,如果可以,great!如果不可以,我就简单的使用正常的运行时库。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值