您的位置:首页 > 其它

使用 LIBCTINY.LIB 为 EXE 和 DLL 文件 减肥

2008-08-06 09:27 218 查看
使用 LIBCTINY.LIB 为 EXE 和 DLL 文件
减肥



原著:Matt Pietrek

翻译:crazyd

原文出处:MSDN Magazine January
2001 Reduce EXE and DLL Size with LIBCTINY.LIB

原代码下载:Hood0101.exe
(45KB)

  在
October
1996 column
我讨论过一个有关可执行文件大小的问题。那个时候,一个简单的 hello world 程序大约有32KB。在 Visual C++®
编译器更新了两个版本后,文件尺寸问题稍微得到了改善,同样的程序使用 Visual C++® 6.0
编译器现在只有28KB。在那时的专栏里,我使用了一个小的运行库来创建极小的可执行程序。虽然有不少局限,但是对决大多数的程序来说,它们运转得很好。
这些局限已经存在相当长的一段时间了,我决心修正它们。同时也提供一个学习如何进一步减少程序的尺寸的鲜为人知的知识。




DLL 和 EXE 的尺寸


  在替换运行库之前,我们得先花点时间看看为什么EXE和DLL的大小比你想象的要大。考虑下面标准的
Hello World 程序:

#include <stdio.h>

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

使用下列命令编译并产生一个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 -- <<Professional ATL COM Programming (Wrox Press, 1998)>>的作者,友好地指出
#import 产生头文件为链接器支持的COM类,实际上是由 COMDEF.H
产生的。Richard 还说到,”链接器支持的COM类和ATL中支持的COM类有很多的不同。最大不同点就是ATL不使用C++异常。实际上,ATL类比链接器支持的COM类更加轻量级,所以我会很高兴用它来生成 ATL 代码。

  我确实应该在我写之前多研究一下。我在 ATL方面的经验就仅限于 Visual C++ 的向导和自动生成的代码。我很少用#import ,没有足够的理由断言和 ATL 没有联系。感谢Richard
,指出我的错误,也会激励我以后做每件事情三思而后行。



作者简介

  Matt Pietrek 为NuMega计算机软件实验室做高级研究,是多本著作的作者。他的站点是
http://www.wheaty.net/, 上面有一个 FAQ页面有一些他以前发表的专栏和文章的信息。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: