分类: C/C++
2008-08-07 17:38:27
在
October
1996 column 我讨论过一个有关可执行文件大小的问题。那个时候,一个简单的
hello world 程序大约有32KB。在
Visual C ® 编译器更新了两个版本后,文件尺寸问题稍微得到了改善,同样的程序使用
Visual C ® 6.0 编译器现在只有28KB。在那时的专栏里,我使用了一个小的运行库来创建极小的可执行程序。虽然有不少局限,但是对决大多数的程序来说,它们运转得很好。这些局限已经存在相当长的一段时间了,我决心修正它们。同时也提供一个学习如何进一步减少程序的尺寸的鲜为人知的知识。
DLL 和 EXE 的尺寸
在替换运行库之前,我们得先花点时间看看为什么EXE和DLL的大小比你想象的要大。考虑下面标准的
Hello World 程序:
#include
使用下列命令编译并产生一个map文件(译者著:如果 CL
不能正确执行,在控制台下先执行\VC98\Bin\VCVARS32.BAT)
Cl /O1 Hello.CPP /link /MAP
首先,查看 .MAP 文件(Figure 1展示了一个裁减过的版本),从
main (0001:00000000) 和 printf (0001:0000000C)
的地址来看,可以推断主函数只有 oxC
字节的长度。再看这个文件的最后一行 __chkstk( 0001:00003B10),可以估计可执行代码至少有
0x3B10 字节,其中将近14KB的代码将 Hello World 送到屏幕。
现在,再看看.MAP 文件中的其它行。有些项是有用的,比如,__initstdio
函数支持 printf 将输出写到文件,所以这类支持 stdio
的库函数是有意义的。再如 strlen,它会被printf调用,所以它包含在 .MAP
中就不足为奇了。
不过,我们再看看其它函数,比如
__sbh_heap_init。它是运行库的堆初始化程序, 而Win32的操作系统也提供了一些类似
HeapAlloc 的函数来实现堆分配。虽然C 运行库选择Win32的堆函数可能带来性能上的提高,但是
Visual C
并没有这样做。所以最后在可执行文件上增加了许多不必要的代码。
有些人并不介意运行库实现自己的堆,但这里有更缺乏说服力的例子。看看
map 文件底部的 __crtMessageBoxA
函数,这个函数使得可执行文件可以通过运行库而非 USER32.dll
来调用 MessageBox API。但对于一个简单的hello world 程序来说,是否要调用
MessageBox 是很难预见的。
再看一个例子:函数 __crtLCMapStringA
将字符串转做区域转换。区域支持是微软的责任,但是对大多数程序并不需要。所以就没有必要为区域转换花费开销。
我还可以继续列举那些不必要的东西,不过我已经证明了自己的观点:一个典型的小程序包含了很多不会被使用的代码,对于各个代码片段而言,并没有增加了多少代码的尺寸,但是将它们全部加起来将是相当可观的。
关于C 动态连接的运行库
留心的读者可能会说,“Matt! 为什么不使用C 动态链接的运行库?”。在过去我不会这样用,因为在
Windows 95, Windows 98, Windows NT 3.51, Windows NT 4.0 这几个平台中C 动态链接的运行库命名不统一。不过幸运的是,现在情况改变了,大多数情况下你绝对可以信赖你机器上的
MSVCRT.DLL。
重新编译你的MSVCRT
(译者注:cl /O1 /MD Hood.c /link /map), 很不错,可执行文件只有16KB。重要的是你把一些不需要的代码移到了
MSVCRT.DLL。只不过在程序启动的时候你要多引导一个DLL。此DLL还包含了类似区域转换的支持。如果MSVCRT能够满足你的需要,那么就尽量使用它。只不过,我还是相信使用一个裁减的,静态运行库是个不错的东西。
我不知道我该不该这样做,不过通过和读者的邮件交流,我坚信我并不孤独,许多朋友都跟我一样希望代码尽可能的小。现代一般用的可写光盘和快速的网络连接,是不需要担心代码尺寸。但是我在家中最快的网络连接也只有24Kbps.我讨厌浪费时间去下载一个臃肿的页面。
作为我的一个原则,我希望代码尽可能的小。我不愿意装载任何我不需要的DLLs。甚至我可能要用到一个DLLs,我都会延迟加载。我在过去的专栏里面讨论过延迟加载,如果你想熟悉它。可以先看看
Under
the Hood in the December 1998 issue of MSJ for starters
深度挖掘
我们已经赶跑了程序中不必要的代码,现在我们看看可执行体本身。使用 DUMPBIN /HEADERS Hood.exe, 可以看到下面的输出:
1000 section alignment
1000 file alignment
第二行很有意思。它说明代码的边界是4KB(0x1000)对齐。由于段的存储是连续的,所以很难发现段与段之间那些可能存在的4KB浪费。
如果使用Visual C 6.0之前版本的连接器,你有可能看到不同的结果:
1000 section alignment
200 file alignment
不同的关键在于段边界以512(0x200)字节对齐。这样空间浪费就要少得多。Visual C 6.0
将边界对齐适应内存的对齐,这样可以使得 windows9x
的程序装载速度提高,不过文件更大。
幸运的是,Visual C 连接器提供返回到过去参数的方法。使用开关
/OPT:NOWIN98,重新编译,如果你使用静态连接(译者注:cl /O1 Hood.c /link /OPT:NOWIN98),那么可执行文件的大小为21KB,减少了7KB;如果使用MSVCRT.DLL动态连接(译者注:cl /O1
/MD Hood.c /link /OPT:NOWIN98),可执行文件只有2560字节。
LIBCTINY: 最小的运行库
现在你明白为什么一个简单的 EXEs 和 DLLs
有如此大了,也是时候介绍我的运行库了。在
October
1996 column,我建立了一个静态的 .LIB 文件代替微软的 LIBC.LIB
和 LIBCMT.LIB 。我称之为 LIBCTINY.LIB,它是从微软运行库分离出来的一个微缩版。
LIBCTINY.LIB臆在支持不需要大运行库的小应用程序,但是,它不适于用在
MFC 以及其它复杂的 Visual C 扩展运行库。理想的 LIBCTINY.LIB
使用者是一个只调用 Win32 API 的 DLLs 或 EXEs 来输出信息。
LIBCTINY.LIB
有两个指导性准则。第一,它将标准的 Visual C 启动例程替换成非常简单的代码。这段代码不涉及任何复杂的运行库函数,如
__crtLCMapStringA。如你呆会儿要看到的,LIBCTINY.LIB 在启动 WinMain,
main 或 DllMain之前只执行一些很小的任务。第二,
LIBCTINY.LIB 将复杂的函数实现如 malloc 或 printf 尽量替换为已有的Win32系统调用。所以不仅启动代码短小,大部分其他
LIBCTINY.LIB 的函数实现如 malloc, free, new, delete, printf, strupr,
strlwr 等等都是非常简单的,查看一下 printf 在 printf.cpp (Figure 2)实现就会明白我所说的了。
老版本的 LIBCTINY.LIB
中的约束令我很是苦恼。首先,原始版本不支持 DLLs。你只能创建控制台或者
GUI 程序,而不能创建一个小的DLL。其次,原始的 LIBCTINY 不支持 C 的构造和析构。当然,我说的是在全局范围内申明的构造器和析构器。在新版本中,我添加了对这些的支持。同时也了解到编译器和运行库为了让构造器和析构器运转是多么的复杂的一件事。
构造器内幕
编译器处理一个含有构造器的代码文件的时候,它会做两件事,首先是一小段类似
$E2
用来调用构造器的代码。第二件事就是产生一个指向这段代码的指针。指针被写到 .OBJ 文件的 .CRT$XCU 节中。
为什么使用如此搞笑的命名?哈,有点复杂。先看一段代码来增加理解。如果你查看
Visual C 运行库原代码(比如,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)。注意,节的命名和 .CRT$XCU 非常相似。
这里,我们需要一点链接器方面的知识。当创建一个最终的PE文件的时候,链接器将所有名字相同的节合并。所以,如果 A.OBJ 有一个叫做
.data 的节,而 B.OBJ 也有个 .data 的节的话,那么 A.OBJ 和 B.OBJ
中所有 .data 里面的数据将被连续的写到PE文件唯一的 .data
节中去。
$的作用是一个名字的分隔符。当链接器遇到一个有$的名字的时候,会将前半部分看作是节名。所以
.CRT$XCA 和 .CRT$XCU 以及 CRT$XCZ 在最后的PE文件中都被合并成
.CRT 节。
那么$的后半部分是什么意思呢?链接器在合并这种类型的节的时候,根据后半部分的字母顺序排序。所以
.CRT$XCA 里面的数据放在最前面,接下来是 .CRT$XCU,最后是
.CRT$XCZ 里面的数据。这些就是需要理解的关键点。
接下来,运行库并不知道 EXE 或 DLL 有多少个静态构造器,也就是不知道在
.CRT$XCU
节中有多少个构造器代码的指针。但是当链接器合并 .CRT$XCU
节的时候,通过定义 .CRT$XCA 和 .CRT$XCZ 节的 __xc_a 和 __xc_z
符号来产生一个函数指针数组,运行库就通过函数指针数组的开始和结束来定位函数。
如你所期望的,访问静态构造器是一件简单的事情,只要通过媒举函数指针数组就可以实现。其操作函数是
_initterm (Figure 3),这段函数和 Visual C 运行库的代码是一致 的。
从上看来,让静态构造器工作是相对容易的,只要正确的定义数据段(.CRT$XCA and .CRT$XCZ)然后调用在启动代码处调用
_initterm 就行了。而静态析构器的工作更加富有技巧性。
和编译器同连接器协同为静态构造器创建函数指针数组不同的是,静态析构器是在运行时被创建的。为了创建此列表,编译器先产生一个对Visual C 运行库 atexit 的调用。atexit函数将析构器函数的指针加到一个先入后出的队列。当 EXE
或 DLL 卸载的时候,运行库将循环调用队列中的函数。
LIBCTINY 中的 atexit 函数相对于 Visual C
运行库中的要简单得多。我们在 initterm.cpp
中用了三个函数和若干静态变量来实现 atexit,_atexit_init
简单的分配32位函数指针空间并保存在静态变量到 pf_atexitlist 中。
atexit
函数检查数组是否有足够的空间,如果有,将指针添加到列表中(一个更加健壮的版本将在必要的时候重新分配数组空间)。最后
_DoExit
函数媒举所有数组中的函数指针且分别调用。更加完美的做法是,_DoExit 反向媒举数组,这样更加符合
Visual C 运行库的行为。只不过,LIBCTINY
的目的是变得更加简单和小巧,而不是为了去兼容。
LIBCTINY''s 的启动流程
现在看看 LIBCTINY 如何支持 DLLs 和 EXEs,一个窍门是消灭不必要的代码,让DLL 载入代码仅可能的小。Figure 4 展现了一个极小的
DLL 启动代码。
当你 DLL 装载的时候,_DllMainCRTStartup 是你DLL最开始执行的地方而不是 DllMain。LIBCTINY 首先检查是不是 DLL_PROCESS_ATTACH,如果是,就调用
_atexit_init,_initterm 呼叫所有的静态构造器。 而函数的核心是调用
DllMain, 它是你的 DLL 代码的一部分。
DllMainCRTStartup最后要做的是检查 DLL
是不是要DLL_PROCESS_DETACH。如果是,调用 _DoExit。如前所叙,它将调用静态析构器。如果你对控制台
和 GUI 模式的启动代码感到好奇,可以看看 CRT0TCON.CPP 和 CRT0TWIN.CPP(这些代码都有相应的下载,可以在文章的开始处找到)。
另外一个要做的事就是找到 DLLCRTO.CPP (Figure 4) 中下面一行:
#pragma comment(linker, "/OPT:NOWIN98")
上面一行直接告诉连接器使用 /OPT:NOWIN98
开关。它的好处是你不要手工的添加到 makefile 文件或者 工程文件,我要说明的是,如果你使用
LIBCTINY, 请务必打开 /OPT:NOWIN98 开关。
使用 LIBCTINY.LIB
使用 LIBCTINY.LIB 是很简单的。你所要做的就是将LIBCTINY.LIB 加入到你的连接库列表当中。如果你使用
Visual Studio IDE,那就加到 Projects | Settings | Link tab,你编译的二进制类型无所谓 (console EXE, GUI
EXE 或 DLL),只要 LIBCTINY.LIB 有正确的入口就可以了。
看看 Figure 5
中的 TEST.CPP。
程序代码中只使用了 LIBCTINY.LIB 所实现函数的很少一部分,并且包含了一个静态的构造器和析构器调用。当我用Visual C
6.0 的普通编译参数时:
CL /O1 TEST.CPP
结果可执行文件有 32768 个字节。现在把 LIBCTINY.LIB
链接上,
CL /O1 TEST.CPP LIBCTINY.LIB
最终的可执行文件只有 3072 个字节。
你可能会担心这个LIBCTINY 是不完整的,举个例子,在TEST.CPP 中,有个 strtchr
的调用。 但是这些都没有问题,因为函数可以在 LIBC.LIB
或 LIBCMT.LIB 等 Visual C 提供的库中找到。 LIBCTINY.LIB 和 LIBC.LIB 都实现了一系列的函数,但是
LIBCTINY 显然要小得多。
最后,需要重申的是 LIBCTINY
并不适应所有的情况,比如,你使用了多线程,且使用了运行库的线程私有数据(译者著:指的是TLS-线程本地存储,比如为每个线程保留一个errno变量)的支持,LIBCTINY
就不合适,我一般是先试试,如果能够运转,那就太好了!如果不行,我就使用一般的运行库。
文章修正
在2000年十月的MSDN杂志上有篇我的文章
"Avoiding
DLL Hell: Introducing Application Metadata in the Microsoft .NET Framework"。我写到:使用Visual C 6.0 #import 会使得编译器会读一个COM类型的库,并为所有库里面的接口产生一个 ATL
的头文件。同时指出 #import 产生了头文件,而不是 ATL。
Richard Grimes -- <
我确实应该在我写之前多研究一下。我在 ATL方面的经验就仅限于 Visual C 的向导和自动生成的代码。我很少用#import ,没有足够的理由断言和 ATL 没有联系。感谢Richard
,指出我的错误,也会激励我以后做每件事情三思而后行。
下载本文示例代码
作者简介
Matt Pietrek 为NuMega计算机软件实验室做高级研究,是多本著作的作者。他的站点是
, 上面有一个 FAQ页面有一些他以前发表的专栏和文章的信息。