在 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 文件(Figure1展示了一個裁減過的版本),從 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
第二行很有意思。它說明代碼的邊界是4KB(0x1000)對齊。由於段的存儲是連續的,所以很難發現段與段之間那些可能存在的4KB浪費。
1000 file alignment
如果使用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 (Figure2)實現就會明白我所說的了。
老版本的 LIBCTINY.LIB 中的約束令我很是苦惱。首先,原始版本不支持 DLLs。你只能創建控制台或者 GUI 程序,而不能創建一個小的DLL。其次,原始的 LIBCTINY 不支持 C++ 的構造和析構。當然,我說的是在全局范圍內申明的構造器和析構器。在新版本中,我添加了對這些的支持。同時也了解到編譯器和運行庫為了讓構造器和析構器運轉是多麼的復雜的一件事。
構造器內幕
編譯器處理一個含有構造器的代碼文件的時候,它會做兩件事,首先是一小段類似 $E2 用來調用構造器的代碼。第二件事就是產生一個指向這段代碼的指針。指針被寫到 .OBJ 文件的 .CRT$XCU 節中。
為什麼使用如此搞笑的命名?哈,有點復雜。先看一段代碼來增加理解。如果你查看 Visual C++ 運行庫原代碼(比如,CINITEXE.C), 你可以看到下列:#pragma data_seg(".CRT$XCA")
上面的代碼創建兩個節, .CRT$XCA 和 .CRT$XCZ。 每個節都有一個變量(分別是 __xc_a 和 __xc_z)。注意,節的命名和 .CRT$XCU 非常相似。
_PVFV __xc_a[] = { NULL };
#pragma data_seg(".CRT$XCZ")
_PVFV __xc_z[] = { NULL };
這裡,我們需要一點鏈接器方面的知識。當創建一個最終的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 (Figure3),這段函數和 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 載入代碼僅可能的小。Figure4 展現了一個極小的 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 (Figure4) 中下面一行:#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 有正確的入口就可以了。 看看 Figure5 中的 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 ,指出我的錯誤,也會激勵我以後做每件事情三思而後行。
本文配套源碼