win32 PE 檔案格式 (轉)
桂林電子工業學院
專業文獻翻譯(譯文)
????系??計算機及應用??專業
文??章Peering Ins the PE:
A Tour of the Portable
Executable File Format
班??級???98031414 ?????
學生姓名??? 雷? 鵬?????
二〇〇二年 六 月 十五 日
Peering Inside the PE: A Tour of the Win32 Portable Executable File Format
Matt Pietrek March 1994
這篇文章來自 期刊,1994 年 3 月。版權所有,? 1994 Miller Freeman,Inc.保留所有權利!未經 Miller Freeman同意,這篇文章的任何部分不得以任何形式抄襲(除了在論文中或評論中以摘要引用)。
一個的可格式在很多方面是這個系統的一面鏡子。雖然學習一個可執行檔案格式通常不是一個員的首要任務,但是你可以從這其中學到大量的知識。在這篇文章中,我會給出 MicroSoft 的所有基於 win32系統(如winnt,)的可移植可執行(PE)檔案格式的詳細介紹。在可預知的未來,包括 2000 , PE 檔案格式在 MicroSoft 的作業系統中扮演一個重要的角色。如果你在使用 Win32 或 Winnt ,那麼你已經在使用 PE 檔案了。甚至你只是在 Windows3.1 下使用 Visual C++ ,你使用的仍然是 PE 檔案(Visual C++ 的 32 位 MS-D擴充套件用這個格式)。簡而言之,PE 格式已經普遍應用,並且在不短的將來仍是不可避免的。現在是時候找出這種新的可執行檔案格式為作業系統帶來的東西了。
我最後不會讓你盯住無窮無盡的十六進位制Dump,也不會詳細討論頁面的每一個單獨的位的重要性。代替的,我會向你介紹包含在 PE 檔案中的概念,並且將他們和你每天都遇到的東西聯絡起來。比如,執行緒區域性變數的概念,如下所述:
declspec(thread) int i;
我快要發瘋了,直到我發現它在可執行檔案中實現起來是如此的簡單並且優雅。既然你們中的許多人都有使用 16 Windows 的背景,我將把 Win32 PE 檔案的構造追溯到和它等價的16 位 NE 檔案。
除了一個不同的可執行檔案格式, MicroSoft 還引入了一個用它的和器生成的新的目標模組格式。這個新的 OBJ 檔案格式有許多和PE 檔案共同的東東。我做了許多無用功去查詢這個新的 OBJ 檔案格式的文件。所以我以自己的理解對它進行解析,並且,在這裡,除了 PE 檔案,我會描述它的一部分。
大家都知道, 繼承了 VAX? VMS? 和 ? 的傳統。許多 Windows NT 的創始人在進入前都在這些平臺上進行設計和編碼。當他們開始設計 Windows NT 時,很自然的,為了最小化專案啟動時間,他們會使用以前寫好的並且已經測試過的工具。用這些工具生成的並且工作的可執行和 OBJ 檔案格式叫做 COFF (Common File Format 的首字母縮寫)。COFF 的相對年齡可以用八進位制的域來指定。COFF 本身是一個好的起點,但是需要擴充套件到一個現代作業系統如 Windows 95 和 Windows NT 的需要。這個的結果就是(PE格式)可移植可執行檔案格式。它被稱為"可移植的"是因為在所有平臺(如x86,Alpha,MIPS等等)上實現的WindowsNT 都使用相同的可執行檔案格式。當然了,也有許多不同的東西如二進位制程式碼的指令。重要的是作業系統的裝入器和工具不需要為任何一種CPU完全重寫就能達到目的。
MicroSoft 拋棄現存的32位工具和可執行檔案格式的事實證實了他們想讓 WindowsNT 升級並且執行的更快的決心。為16位Windows編寫的虛擬裝置程式用一種不同的32位檔案佈局--LE 檔案格式--WindowsNT出現很早以前就存在了。比這更重要的是對 OBJ 檔案的替換!在 WindowsNT 的 C 編譯器以前,所有的微軟編譯器都用 的 OMF ( Object Module Format ) 規範。就像前面提到的,MicroSoft 的 Win32 編譯器生成 COFF 格式的 OBJ 檔案。一些微軟的競爭者,如 Borland 和 Symentec ,選擇放棄了 COFF 格式並堅持 Intel 的 OMF 檔案格式。這樣的結果是製作 OBJ 和 LIB 的公司為了使用多個不同的編譯器,不得不為每個不同的編譯器分發這些庫的不同版本(如果他們不這麼做)。
PE 檔案格式在 winnt.h 標頭檔案中文件化了(用最不精確的語言)!大約在 winnt.h 的中間部分標題為"Image Format"的一個快。在把 MS-DOS 的 MZ 檔案頭和 NE 檔案頭移入新的PE檔案頭之前,這個塊就開始於一個小欄。WINNT.H提供PE檔案用到的生鮮資料結構的定義,但只有很少有助於理解這些資料結構和標誌變數的註釋。不管誰為PE檔案格式寫出這樣的標頭檔案都肯定是一個信徒無疑(突然持續地冒出Michael J. O'Leary的名字來)。描述名字,連同深嵌的結構體和宏。當你配套winnt.h進行編碼時,類似下面這樣的並不鮮見:
pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DE]
.VirtualAddress;
為了有助於邏輯的理解這些winnt.h中的資訊,閱讀可移植可執行和公共檔案格式的規格說明,這些在MSDN既看光碟中是可用的,一直包括到2001年8月。
現在讓我們轉換到COFF格式的OBJ檔案的主體上來,WINNT.H包括COFF OBJ和LIB的結構化定義和型別定義。不幸的是,我還沒有找到上面提到的可執行檔案格式的類似文件。既然PE檔案和COFF OBJ檔案是如此的相似,我決定是時間把這些檔案帶到重點上來,並且把它們也文件化。僅僅讀過了關於PE檔案的組成,你自己也想Dump一些PE檔案來看這些概念。如果你用微軟基於32位WINDOWS的開發工具,DUMPBIN 程式可以將PE檔案和COFF OBJ/LIB檔案轉化為可讀的形式。在所有的PEDump器中,DUMPBIN是最容易理解的。它恰好有一些很好的選項來反彙編它正解析的檔案的程式碼塊,Borland可以使用tdump來瀏覽PE檔案,但tdump不能解析 COFF OBJ/LIB 檔案。這不是一個重要的東西因為Borland的編譯器首先就不生成 COFF 格式的OBJ檔案。
我寫了一個PE和COFF OBJ 檔案的Dump程式--PEDUMP(見表1),我想提供一些比DUMPBIN更加可理解的輸出。雖然它沒有反彙編器以及和LIB庫檔案一起工作,它在其他方面和DUMPBIN是一樣的,並且加入了一些新的特性來使它值得被認同。它的在任何一個MSJ電子公報版上都可以找到,所有我不打算在這裡把他全部列出。作為代替,我展示一些從PEDUMP得到的示例輸出來闡明我為它們描述的概念。
譯註:--說實話,我從這這份程式碼中幾乎唯一學到的東西就是"如何處理命令列",其它的都沒學到。
表 1 PEDUMP.C
// PROGRAM: PEDUMP
// FILE: PEDUMP.C
// AUTHOR: Matt Pietrek - 1993
#include
#include
#include "objdump.h"
#include "exedump.h"
#include "extrnvar.h"
// Global variables set here, and used in EXEDUMP.C and OBJDUMP.C
BOOL fShowRelocations = FALSE;
BOOL fShowRawSectionData = FALSE;
BOOL fShowSymbolTable = FALSE;
BOOL fShowLineNumbers = FALSE;
char HelpText[] =
"PEDUMP - Win32/COFF .EXE/.OBJ file dumper - 1993 Matt Pietreknn"
"Syntax: PEDUMP [switches] filenamenn"
" /A include everything in dumpn"
" /H include hex dump of sectionsn"
" /L include line number informationn"
" /R show base relocationsn"
" /S show symbol tablen";
// Open up a file, memory map it, and call the appropriate dum routine
void DumpFile(LPSTR filename)
{
HANDLE hFile;
HANDLE hFileMapping;
LPVOID lpFileBase;
PIMAGE_DOS_HEADER dosHeader;
hFile = CreateFile(filename, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
if ( hFile = = INVALID_HANDLE_VALUE )
{ printf("Couldn't open file with CreateFile()n");
return; }
hFileMapping = CreateFileMapping(hFile, NULL,
PAGE_READONLY, 0, 0, NULL);
if ( hFileMapping = = 0 )
{
CloseHandle(hFile);
printf("Couldn't open file mapping with CreateFileMapping()n");
return;
}
lpFileBase = MapViewOfFile(hFileMapping, FILE_MAP_READ, 0, 0, 0);
if ( lpFileBase = = 0 )
{
CloseHandle(hFileMapping);
CloseHandle(hFile);
printf("Couldn't map view of file with MapViewOfFile()n");
return;
}
printf("Dump of file %snn", filename);
dosHeader = (PIMAGE_DOS_HEADER)lpFileBase;
if ( dosHeader->e_magic = = IMAGE_DOS_SIGNATURE )
{ DumpExeFile( dosHeader ); }
else if ( (dosHeader->e_magic = = 0x014C) // Does it look like a i386
&& (dosHeader->e_sp = = 0) ) // COFF OBJ file???
{
// The two tests above aren't what they look like. They're
// really checking for IMAGE_FILE_HEADER.Machine = = i386 (0x14C)
// and IMAGE_FILE_HEADER.SizeOfOptionalHeader = = 0;
DumpObjFile( (PIMAGE_FILE_HEADER)lpFileBase );
}
else
printf("unrecognized file formatn");
UnmapViewOfFile(lpFileBase);
CloseHandle(hFileMapping);
CloseHandle(hFile);
}
// process all the command line arguments and return a pointer to
// the filename argument.
PSTR ProcesmmandLine(int argc, char *argv[])
{
int i;
for ( i=1; i < argc; i++ )
{
strupr(argv[i]);
// Is it a switch character?
if ( (argv[i][0] = = '-') || (argv[i][0] = = '/') )
{
if ( argv[i][1] = = 'A' )
{ fShowRelocations = TRUE;
fShowRawSectionData = TRUE;
fShowSymbolTable = TRUE;
fShowLineNumbers = TRUE; }
else if ( argv[i][1] = = 'H' )
fShowRawSectionData = TRUE;
else if ( argv[i][1] = = 'L' )
fShowLineNumbers = TRUE;
else if ( argv[i][1] = = 'R' )
fShowRelocations = TRUE;
else if ( argv[i][1] = = 'S' )
fShowSymbolTable = TRUE;
}
else // Not a switch character. Must be the filename
{ return argv[i]; }
}
}
int main(int argc, char *argv[])
{
PSTR filename;
if ( argc = = 1 )
{ printf( HelpText );
return 1; }
filename = ProcessCommandLine(argc, argv);
if ( filename )
DumpFile( filename );
return 0;
}
1 WIN32 與 PE 基本概念
讓我們複習一下幾個透過PE檔案的設計瞭解到的基本概念(見圖1)。我用術語"MODULE"來表示一個可執行檔案或一個DLL載入的程式碼(CODE)、資料(DATA)、資源(RES),除了程式碼和資料是你的程式直接使用的,一個模組還可以由WINDOWS用來確定資料和程式碼載入的位置的支撐資料結構組成。在16位WINDOWS中,這些支撐資料結構在模組(用一個HMODULE來指示的段)中。在WIN32裡面,這些資料結構在PE檔案頭中,這些我將會簡要地解釋一下。
圖1 PE檔案略圖
關於PE檔案最重要的是,上的可執行檔案和它被WINDOWS調入記憶體之後是非常相像的。WINDOWS載入器不必為從磁碟上載入一個檔案而辛辛苦苦建立一個程式。載入器使用記憶體對映檔案機制來把檔案中相似的塊對映到虛擬空間中。用一個構造式的分析模型,一個PE檔案類似一個預製的屋子。它本質上開始於這樣一個空間,這個空間後面有幾個把它連到其餘空間的機件(就是說,把它聯絡到它的DLL上,等等)。這對PE格式的DLL是一樣容易應用的。一旦這個模組被載入,Windows 就可以有效的把它和其它記憶體對映檔案同等對待。
和16位Windows不同的是。16位NE檔案的載入器讀取檔案的一部分並且建立完全不同的資料結構在記憶體中表示模組。當資料段或者程式碼段需要載入時,載入器必須從全域性堆中新申請一個段,從可執行檔案中找出生鮮資料,轉到這個位置,讀入這些生鮮資料,並且要進行適當的修正。除此而外,每個16位模組都有責任記住當前它使用的所有段選擇器,而不管這個段是否被丟棄了,如此等等。
對Win32來講,模組所使用的所有程式碼,資料,資源,匯入表,和其它需要的模組資料結構都在一個連續的記憶體塊中。在這種形勢下,你只需要知道載入器把可執行檔案對映到了什麼地方。透過作為映像的一部分的指標,你可以很容易的找到這個模組所有不同的塊。
另一個你需要知道的概念是相對虛擬地址(RVA)。PE檔案中的許多域都用術語RVA來指定。一個RVA只是一些專案相對於檔案對映到記憶體的偏移。比如說,載入器把一個檔案對映到虛擬地址0x10000開始的記憶體塊。如果一個映像中的實際的表的首址是0x10464,那麼它的RVA就是0x464。
(虛擬地址 0x10464)-(基地址 0x10000)=RVA 0x00464
為了把一個RVA轉化成一個有用的指標,只需要把RVA值加到模組的基地址上即可。基地址是記憶體對映EXE和DLL檔案的首址,在Win32中這是一個很重要的概念。為了方便起見,WindowsNT 和 Windows9x用模組的基地址作為這個模組的例項控制程式碼(HINSTANCE)。在Win32中,把模組的基地址叫做HINSTANCE可能導致混淆,因為術語"例項控制程式碼"來自16位Windows。一個程式在16位Windows中的每個複製得到它自己分開的資料段(和一個聯絡起來的全域性控制程式碼)來把它和這個程式其它的複製分別開來,就形成了術語"例項控制程式碼"。在Win32中,每個程式不必和其它程式區別開來,因為他們不共享相同的地址空間。術語INSTANCE仍然保持16位windows和32位Windows之間的連續性。在Win32中重要的是你可以對任何DLLGetModuleHandle()得到一個指標去訪問它的元件(譯註)。
譯註:如果 dllname 為 NULL,則得到執行體自己的模組控制程式碼。這是非常有用的,如通常編譯器產生的啟動程式碼將取得這個控制程式碼並將它作為一個引數hInstance傳給WinMain !
你最終需要理解的PE檔案的概念是"塊(Section)"。PE檔案中的一個塊和NE檔案中的一個段或者資源等價。塊可以包含程式碼或者資料。和段不同的是,塊是記憶體中連續的空間,而沒有尺寸限制。當你的聯結器和庫為你建立,並且包含對作業系統非常重要的資訊的其它的資料塊時,這些塊包含你的程式直接宣告和使用的程式碼或資料。在一些PE格式的描述中,塊也叫做物件。術語物件有如此多的涵義,以至於只能把程式碼和資料叫做"塊"。
2 PE首部
和其它可執行檔案格式一樣,PE檔案在眾所周知的地方有一些定義檔案其餘部分面貌的域。首部就包含這樣象程式碼和資料的位置和尺寸的地方,作業系統要對它進行干預,比如初始堆疊大小,和其它重要的塊的資訊,我將要簡短的介紹一下。和微軟其它可執行格式相比,主要的首部不是在檔案的最開始。典型的PE檔案最開始的數百個位元組被DOS殘留部分佔用。這個殘留部分是一個可以列印如"這個程式不能在DOS下執行!"這類資訊的小程式。所以,你在一個不支援Win32的系統中執行這個程式,便可以得到這類錯誤資訊。當載入器把一個Win32程式對映到記憶體,這個對映檔案的第一個位元組對應於DOS殘留部分的第一個位元組。那是無疑的。和你啟動的任一個基於Win32 的程式一起,都有一個基於DOS的程式連帶被載入。
和微軟的其它可執行格式一樣,你可以透過查詢它的起始偏移來得到真實首部,這個偏移放在DOS殘留首部中。WINNT.H標頭檔案包含了DOS殘留程式的資料結構定義,使得很容易找到PE首部的起始位置。e_lfanew 域是PE真實首部的偏移。為了得到PE首部在記憶體中的指標,只需要把這個值加到映像的基址上即可。
略型別轉化和指標轉化 ...
pNTHeader = dosHeader + dosHeader->e_lfanew;
一旦你有了PE主首部的指標,遊戲就可以開始了!PE主首部是一個IMAGE_NT_HEADERS的結構,在WINNT.H中定義。這個結構由一個雙字(D)和兩個子結構組成,佈局如下:
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER OptionalHeader;
標誌域用ASCII表示就是"PE"。如果在DOS首部中用了e_lfanew域,你得到一個NE標誌而不是PE,那麼這是16位NE檔案。同樣的,在標誌域中的LE表示這是一個Windows3.x 的虛擬裝置驅動程式(VxD)。LX表示這個檔案是OS/2 2.0檔案。
PE DWORD標誌後的是結構 IMAGE_FILE_HEADER 。這個域只包含這個檔案最基本的資訊。這個結構表現為並未從它的原始COFF實現更改過。除了是PE首部的一部分,它還表現在微軟Win32編譯器生成的COFF OBJ 檔案的最開始部分。IMAGE_FILE_HEADER的這個域顯示在下面:
表2 IMAGE_FILE_HEADER Fields
WORD Machine
表示CPU的型別,下面定義了一些CPU的ID
0x14d Intel i860
0x14c Intel I386 (same ID used for 486 and 586)
0x162 MIPS R3000
0x166 MIPS R4000
0x183 DEC Alpha AXP
WORD NumberOfSections
這個檔案中的塊數目。
DWORD TimeDateStamp
聯結器產生這個檔案的日期(對OBJ檔案是編譯器),這個域儲存的數是從1969年12月下午4:00開始到現在經過的秒數。
DWORD PointerToSymbolTable
COFF符號表的檔案偏移量。這個域只用於有COFF資訊的OBJ檔案和PE檔案,PE檔案支援多種除錯資訊格式,所以偵錯程式應該指向資料目錄的IMAGE_DIRECTORY_ENTRY_DEBUG條目。
DWORD NumberOfSymbols
COFF符號表的符號數目。見上面。
WORD SizeOfOptionalHeader
這個結構後面的可選首部的尺寸。在OBJ檔案中,這個域是0。在可執行檔案中,這是跟在這個結構後的IMAGE_OPTIONAL_HEADER結構的尺寸。
WORD Characteristics
關於這個檔案資訊的標誌。一些重要的域如下:
0x0001 這個檔案中沒有重定位資訊
0x0002 可執行檔案映像(不是OBJ或LIB檔案)
0x2000 檔案是動態連線庫,而非程式
其它域定義在WINNT.H中。
PE首部的第三個組成部分是一個IMAGE_OPTIONAL_HEADER型的結構。對PE檔案,這一部分當然不是"可選的"。COFF格式允許單獨實現來定義一個超出標準IMAGE_FILE_HEADER附加資訊的結構。IMAGE_OPTIONAL_HEADER裡面的域是PE的實現者感到超出IMAGE_FILE_HEADER基本資訊以外非常關鍵的資訊。
並非 IMAGE_OPTIONAL_HEADER 的所有域都是重要的(見圖4)。比較重要,需要知道的是ImageBase 和 SubSystem 域。你可以忽略其它域的描述。
表3 IMAGE_FILE_HEADER 的域:
WORD Magic
表現為一些類別的標誌字,通常是0X010B 。
BYTE MajorLinkerVersion
BYTE MinorLinkerVersion
生成這個檔案的聯結器的版本。這個數字以十進位制顯示比用十六進位制好。一個典型的聯結器版本是2.23。
DWORD SizeOfCode
所有程式碼塊的進位尺寸。通常大多數檔案只有一個程式碼塊,所以這個域和 .TEXT 塊匹配。
DWORD SizeOfInitializedData
已初始化的資料組成的塊的大小(不包括程式碼段)。然而,和它在檔案中的表現形式並不一致。
DWORD SizeOfUninitializedData
載入器在虛擬記憶體中申請空間,但在磁碟上的檔案中並不佔用空間的塊的尺寸。這些塊在程式啟動時不需要指定初值,因此術語名就是"未初始化的資料"。未初始化的資料通常在一個名叫 .bss 的塊中。
DWORD AddressOfEntryPoint
載入器開始執行這個程式的地址,即這個PE檔案的入口地址。這是一個RVA,通常在 .text 塊中。
DWORD BaseOfCode
程式碼塊起始地址的RVA 。在記憶體中,程式碼塊通常在PE首部之後,資料塊之前。在微軟的聯結器產生的EXE檔案中,這個值通常是0x1000 。Borland 的聯結器 TLINK32 也一樣,把映像第一個程式碼塊的RVA和映像基址相加,填入這個域。
譯註:這個域好像一直沒有什麼用
DWORD BaseOfData
資料塊起始地址的RVA 。在記憶體中,資料塊經常在最後,在PE首部和程式碼塊之後。
譯註:這個域好像也一直沒有什麼用
DWORD ImageBase
聯結器建立一個可執行檔案時,它假定這個檔案被對映到記憶體中的一個指定的地方,這個地址就存在這個域中,假定一個載入地址可以使聯結器以便節省空間。如果載入器真的把這個檔案對映到了這個地方,在執行之前程式碼不需要任何改變。在為WindowsNT 建立的可執行檔案中,預設的ImageBase 是0x10000。對DLL,預設是0x40000。在Window95中,地址0x10000不能用來載入32位EXE檔案,因為這個區域在一個被所有程式共享的線性地址空間中。因此,微軟把Win32可執行檔案的預設基址改為0x40000,假定基址為0x10000 的老程式坐在Windows95 中需要更長的載入時間,這是因為載入器需要重定位基址。
譯註:這個域即"Prefered Load Address",如果沒有什麼意外,這就是該PE檔案載入記憶體後的地址。
DWORD SectionAlignment
對映到記憶體中時,每個塊都必須保證開始於這個值的整數倍。為了分頁的目的,預設的SectionAlignment 是 0x1000。
DWORD FileAlignment
在PE檔案中,組成每個塊的生鮮資料必須保證開始於這個值的整數倍。預設值是0x200 位元組,也許是為了保證塊都開始於一個磁碟扇區(一個扇區通常是 512 位元組)。這個域和NE檔案中的段/資源對齊(segment/resource alignment)尺寸是等價的。和NE檔案不同的是,PE檔案通常沒有數百個的塊,所以,為了對齊而浪費的通常空間很少。
WORD MajorOperatingSystemVersion
WORD MinorOperatingSystemVersion
這個程式執行需要的作業系統的最小版本號。這個域有點含糊,因為Subsystem 域(後面將會說到)可以提供類似的功能。這個域在到目前為止的Win32中預設是1.0。
WORD MajorImageVersion
WORD MinorImageVersion
一個可由使用者定義的域。這允許你有不同的EXE和DLL版本。你可以透過連結器的 /version 選項設定這個域的值。例如:"link /version:2.0 myobj.obj"。
WORD MajorSubsystemVersion
WORD MinorSubsystemVersion
這個程式執行需要的最小子系統版本號。這個域的一個典型值是3.10 (表示WindowsNT 3.1)。
DWORD Reserved1
通常是 0 。
DWORD SizeOfImage
載入器必須關心的這個映像所有部分的大小總和。是從映像的開始到最後一個塊結尾這段區域的大小。最後一個塊結尾按SectionAlignment進位。
譯註:這個很重要,可以大,但不可以小!
DWORD SizeOfHeaders
PE首部和塊表的大小。塊的實際資料緊跟在所有首部元件之後。
DWORD CheckSum
這個檔案的CRC校驗和。在微軟可執行格式中,這個域被忽略並且置為0 。這個規則的一個例外情況是信任服務,這類EXE檔案必須有一個合法的校驗和。
WORD Subsystem
可執行檔案的使用者介面使用的子系統型別。WINNT.H 定義了下面這些值:
NATIVE 1 不需要子系統(比如裝置驅動)
WINDOWS_GUI 2 在Windows圖形使用者介面子系統下執行
WINDOWS_CUI 3 在Windows字元子系統下執行(控制檯程式)
OS2_CUI 5 在OS/2字元子系統下執行(僅對OS/2 1.x)
POSIX_CUI 7 在 Posix 字元子系統下執行
WORD DllCharacteristics
指定在何種環境下一個DLL的初始化(比如DllMain)將被呼叫的標誌變數。這個值經常被置為0 。但是作業系統在下面四種情況下仍然呼叫DLL的初始化函式。
下面的值定義為:
1 DLL第一次載入到程式中的地址空間中時呼叫
2 一個執行緒結束時呼叫
4 一個執行緒開始時呼叫
8 退出DLL時呼叫
DWORD SizeOfStackReserve
為初始執行緒保留的虛擬記憶體總數。然而並不是所有這些記憶體都被提交(見下一個域)。這個域的預設值是0x100000(1Mbytes)。如果你在CreateThread 中把堆疊尺寸指定為 0 ,結果將是用這個相同的值(0x10000)。
DWORD SizeOfStackCommit
開始提交的初始執行緒堆疊總數。對微軟的聯結器,這個域預設是0x1000位元組(一頁),TLINK32 是兩頁。
DWORD SizeOfHeapReserve
為初始程式的堆保留的虛擬記憶體總數。這個堆的控制程式碼可以用GetPocessHeap 得到。並不是所有這些記憶體都被提交(見下一個域)。
DWORD SizeOfHeapCommit
開始為程式堆提交的記憶體總數。預設是一頁。
DWORD LoaderFlags
從WINNT.H中可以看到,這些標誌是和除錯支援相聯絡的。我從沒有見到過在哪個可執行檔案中這些位都置位了,清除它讓聯結器來設定它。下面的值定義為:
1. 在開始程式前呼叫一個端點指令
2. 程式被載入時呼叫一個偵錯程式
DWORD NumberOfRvaAndSizes
資料目錄陣列中的的條目數目(見下面)。當前的工具通常把這個值設為16。
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]
一個IMAGE_DATA_DIRECTORY 結構陣列。初始陣列元素包含可執行檔案的重要部分的起始RVA和大小。這個陣列最末的一些元素現在沒有使用。這個陣列的第一個元素經常時匯出函式表的地址和尺寸。第二個陣列條目是匯入函式表的地址和尺寸,等等。對一個完整的、已定義的陣列條目,見IMAGE_DIRECTORY_ENTRY_XXX 在WINNT.H中的定義。這個陣列允許載入器迅速查詢這個映像的一個指定的塊(例如,匯入函式表),而不需要遍歷映像的每個塊,透過比較名字來確定。大部分陣列條目描述一整塊資料。然而,IMAGE_DIRECTORY_ENTRY_DEBUG項只包括 .rdata 塊的一小部分位元組。
3 塊表
在PE首部和映像塊之間的是塊表。塊表本質上是包含映像中每個塊資訊的電話本。映像中的塊以他們的起始地址(RVA)排列,而不是按字母排列。
現在,我進一步澄清什麼是一個塊。在NE檔案中,你的程式程式碼和資料在相互區別開來的段中。NE首部的一部分是一個結構陣列,每個對應你的程式用到的一個段。陣列中的每個結構包含一個段的資訊。這些資訊儲存了段的型別(程式碼或資料)、大小、和它在檔案中的位置。在PE檔案中,塊表和NE檔案中的段表類似。和NE檔案的段表不同,PE塊表項不儲存一個程式碼和資料塊的選擇子。代替的,每個塊表項儲存檔案的生鮮資料對映到記憶體中以後的地址。於是塊就和32位段類似,但他們實際上不是單獨的段。它們實際上是程式虛擬空間的一個記憶體範圍。
另一個PE檔案和NE檔案的不同之處是它怎樣管理你的程式不用,但作業系統要用的支援資料;例如可執行檔案使用的DLL列表或修正表的位置。在NE檔案中,資源不被當作段。甚至分配給他們的選擇子,資源的相關資訊並未儲存在NE檔案首部的段表中。代替的,提交給一個分隔表的資源朝向PE首部的結尾。關於匯入和匯出函式的資訊也沒有授權給它自己的段;它交織在NE首部中。
PE檔案的故事就不一樣了。任何可能被認為是關鍵的程式碼或資料都存在一個完備的塊中。於是,匯入函式表的資訊就存在它自己的塊中,匯出表也一樣。對重定位資料也是一樣的。程式或作業系統可能需要的任何程式碼或資料都可以得到它們自己的塊。
在我討論特定塊之前,我需要先描述操作這些塊的資料。在記憶體中緊跟在PE首部的是一個IMAGE_SECTION_HEADER陣列。陣列的元素個數在PE首部中給定(IMAGE_NT_HEADER.FileHeader.NumberOfSections域)。我用PEDUMP來輸出塊表和塊的所有的域及其屬性。表5 描述了用PEDUMP輸出的一個典型EXE檔案的塊表,表6 給出了 Obj 檔案的塊表。
表 4 一個典型EXE檔案的塊表
01 .text VirtSize: 00005AFA VirtAddr: 00001000
raw data offs: 00000400 raw data size: 00005C00
relocation offs: 00000000 relocations: 00000000
line # offs: 00009220 line #'s: 0000020C
characteristics: 60000020
CODE MEM_EXECUTE MEM_READ
02 .bss VirtSize: 00001438 VirtAddr: 00007000
raw data offs: 00000000 raw data size: 00001600
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: C0000080
UNINITIALIZED_DATA MEM_READ MEM_WRITE
03 .rdata VirtSize: 0000015C VirtAddr: 00009000
raw data offs: 00006000 raw data size: 00000200
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 40000040
INITIALIZED_DATA MEM_READ
04 .data VirtSize: 0000239C VirtAddr: 0000A000
raw data offs: 00006200 raw data size: 00002400
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: C0000040
INITIALIZED_DATA MEM_READ MEM_WRITE
05 .idata VirtSize: 0000033E VirtAddr: 0000D000
raw data offs: 00008600 raw data size: 00000400
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: C0000040
INITIALIZED_DATA MEM_READ MEM_WRITE
06 .reloc VirtSize: 000006CE VirtAddr: 0000E000
raw data offs: 00008A00 raw data size: 00000800
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 42000040
INITIALIZED_DATA MEM_DISCARDABLE MEM_READ
表 5 一個典型OBJ檔案的塊表
01 .drectve PhysAddr: 00000000 VirtAddr: 00000000
raw data offs: 000000DC raw data size: 00000026
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 00100A00
LNK_INFO LNK_REMOVE
02 .debug$S PhysAddr: 00000026 VirtAddr: 00000000
raw data offs: 00000102 raw data size: 000016D0
relocation offs: 000017D2 relocations: 00000032
line # offs: 00000000 line #'s: 00000000
characteristics: 42100048
INITIALIZED_DATA MEM_DISCARDABLE MEM_READ
03 .data PhysAddr: 000016F6 VirtAddr: 00000000
raw data offs: 000019C6 raw data size: 00000D87
relocation offs: 0000274D relocations: 00000045
line # offs: 00000000 line #'s: 00000000
characteristics: C0400040
INITIALIZED_DATA MEM_READ MEM_WRITE
04 .text PhysAddr: 0000247D VirtAddr: 00000000
raw data offs: 000029FF raw data size: 000010DA
relocation offs: 00003AD9 relocations: 000000E9
line # offs: 000043F3 line #'s: 000000D9
characteristics: 60500020
CODE MEM_EXECUTE MEM_READ
05 .debug$T PhysAddr: 00003557 VirtAddr: 00000000
raw data offs: 00004909 raw data size: 00000030
relocation offs: 00000000 relocations: 00000000
line # offs: 00000000 line #'s: 00000000
characteristics: 42100048
INITIALIZED_DATA MEM_DISCARDABLE MEM_READ
每個IAMGE_SECTION_HEADER都有一個如圖7 描述的格式。注意每個塊中儲存的資訊缺失了什麼是很有趣的。首先,注意沒有指明任何預載入的屬性。NE檔案格式允許你指定應該和模組一起載入的預載入段的屬性。OS/2? 2.0 LX 格式有點類似,允許你指定預載入八頁(記憶體頁:譯註,下同) 。PE格式就沒有任何類似的東西。微軟必須確保Win32 需求頁面的載入。
表 6 IMAGE_SECTION_HEADER 的格式
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]
這是一個為塊命名的8位元組ANSI名字(不UNICODE)。大部分塊名開始於一個 ". "(比如".text"),但這並非必須的,就像你可能相信的一些PE文件一樣。你可以在組合語言中用任何一個段指示你自己的塊。或者在微軟C/C++編譯器中用"#pragma data_seg"來指示。需要注意的是如果塊名佔滿8個位元組,就沒有NULL結束位元組了。如果你熱衷於 printf ,你可以用 %8s來避免把這個名字複製到一個緩衝區中,然後又在結尾加上一個NULL位元組。
union {
DWORD PhysicalAddress
DWORD VirtualSize
} Misc;
在EXE和OBJ中,這個域的意義不同。在EXE中,它儲存程式碼或者資料的實際尺寸。這個尺寸是未經過校準檔案對齊尺寸並進位的。後面要講到的這個結構的SizeOfRawData 域(這個詞有點不確切)儲存了校準檔案對齊尺寸並進位後的尺寸。Borland 的聯結器調換了這兩個域的意思,於是看上去就是正確的了。對OBJ檔案,這個域指示塊的物理尺寸。第一個塊開始於地址0 。為找到OBJ 檔案中的下一個塊,把SizeOfRawData加到當前塊基址上即可。
DWORD VirtualAddress
在EXE中,這個域儲存決定載入器把這個塊對映到記憶體中哪個位置的RVA 。為計算一個給定的塊在記憶體中的實際起始地址,把這個映像的基址加上儲存在這個域的VirtualAddress即可。用微軟的工具,第一個塊的預設RVA是0x1000 。在OBJ檔案中,這個域沒有意義,被置為0 。
DWORD SizeOfRawData
在EXE中,這個域包含這個塊按檔案對齊尺寸進位後的尺寸。比如說,假定一個檔案的對齊尺寸是0x200 。如果這個塊的VirtualAddress域(前面那個域)的是0x35a ,那麼這個域就是0x400 。在OBJ檔案中,這個域包含由編譯器或彙編器提供的塊的精確尺寸。換句話說,對OBJ ,它等價於EXE中的VirtualSize域。
DWORD PointerToRawData
這是一個基於檔案的偏移,透過這個偏移,可以找到由編譯器或彙編器產生的生鮮資料。如果你的程式自己要把一個PE或COFF檔案對映到記憶體(而不是讓作業系統來載入),那麼這個域比VirtualAddress更重要。在這種情況下你有一個完全線性的檔案對映,所以你會在這個偏移處找到塊的資料,而不是在VirtualAddress域指定的RVA 處找到。
DWORD PointerToRelocations
在OBJ中,這是指向塊的重定位資訊的基於檔案的偏移值。每個OBJ塊的重定位資訊緊跟在這個塊的生鮮資料之後。在EXE中,這個域(和後面的)是沒有意義的,被置為0 。聯結器產生EXE時,它解決了大部分的這種修正值,只剩下基址的重定位和匯入函式,將在載入時解決。關於基本重定位資訊和匯入函式保留在他們自己的塊中,所以對一個EXE ,沒有必要在每個塊的生鮮資料之後都緊跟它的重定位資訊。
DWORD PointerToLinenumbers
這是行號表基於檔案的偏移量。行號表把原始檔的一行和(編譯器)為這一行產生的(機器)程式碼的首址聯絡起來。在如CodeView格式的現代除錯格式中,行號資訊儲存為除錯資訊的一部分。然而,在COFF除錯格式中,行號資訊和符號名/型資訊的儲存是分開的。通常只有程式碼塊(如 .text )有行號資訊。在EXE檔案中,行號資訊在塊的生鮮資料之後,朝著檔案的結尾方向收集。在OBJ檔案中,一個塊的行號資訊跟在生鮮塊資料和這個塊的重定位表之後。
WORD NumberOfRelocations
塊的重定位表中的重定位項的數目(參考上面的PointerToRelocations域)。這個域似乎只和OBJ檔案有關。
WORD NumberOfLinenumbers
塊的行號表中的行號項的數目(參考上面的PointerToLinenumbers域)。
DWORD Characteristics
大部分程式設計師的稱之為標誌,COFF/PE格式稱之為特徵。這個域是指示塊屬性的標誌集(如程式碼/資料,可讀,可寫)。一個對所有可能的塊屬性的完整的列表,見WINNT.H中的IMAGE_SCN_XXX_XXX的定義。如下是比較重要的一些標誌:
0x00000020 這個塊包含程式碼。通常和可執行標誌(0x80000000)一起置位。
0x00000040 這個塊包含已初始化的資料。除了可執行塊和 .bss 塊之外幾乎所有的塊的這個標誌都置位。
0x00000080 這個塊包含未初始化的資料(如 .bss 塊)
0x00000200 這個塊包含註釋或其它的資訊。這個塊的一個典型用法是編譯器產生的 .drectve 塊,包含連結器命令。
0x00000800 這個塊的內容不應放進最終的EXE檔案中。這些塊是編譯器或彙編器用來給聯結器傳遞資訊的。0x02000000 這個塊可以被丟棄,因為一旦它被載入,其程式就不需要它了。最通常的可丟棄塊是基本重定位塊( .reloc )。
0x10000000 這個塊是可共享的。和DLL一起使用時,這個塊的資料可以在使用這個DLL的程式之間共享。預設時資料塊是非共享的,這意味著使用這個DLL的各個程式都有自己對這個塊的資料的副本。在更專業的術語中,共享塊告訴記憶體管理器把使用這個DLL的所有程式把的這個塊的頁面對映到記憶體中相同的物理頁面。為使一個塊可共享,在連線時用SHARE屬性。如:
LINK /SECTION:MYDATA,RWS ...
告訴聯結器叫做"MYDATA"的塊是可讀的,可寫的,共享的。
0x20000000 這個塊是可執行的。這個標誌通常在"包含程式碼"標誌(0x00000020)被置位時置位。
0x40000000 這個塊是可讀的。在EXE檔案中,這個域幾乎總被置位。
0x80000000 這個塊是可寫的。如果在一個EXE塊中這個塊未被置位,載入器會把這塊的記憶體對映頁面標為只讀或"只執行"。有此屬性的典型的塊是 .data 和 .bss 。有趣的是,.idata 塊也有這個屬性。
PE格式中還缺少"頁表"的概念。在LX格式中,OS/2的IMAGE_SECTION_TABLE等價物不直接指向檔案中的程式碼或資料塊。代替的,它指向一個指示塊中特定範圍的屬性和位置的頁查詢表。PE格式分配所有的,並且確保所有的塊中的資料將連續的儲存在檔案中。比較這兩種格式:LX可以允許更大的靈活性,但PE風格更簡單,更容易協同工作。我已經寫了這兩種檔案的Dumper 。
PE格式另一個值得歡迎的改變是所有專案的位置都儲存為簡單的雙字(DWORD)偏移。在NE格式中,幾乎所有東西的位置都儲存為它們的扇區值。為了得到實際的偏移,你第一步需要查詢NE首部的對齊單元尺寸並把它轉化為扇區尺寸(典型的是 16 和512 位元組)。然後你需要把扇區尺寸乘以指定的扇區偏移才得到實際的檔案偏移。如果NE檔案的某些東西偶然儲存為一個扇區偏移,這可能是相對於NE首部的。因為NE首部並不在檔案的開始,你需要在自己的程式碼中調整這個檔案的NE首部。總之,PE格式比NE,LX,或LE格式更容易協同工作(假定你能使用記憶體映像檔案)。
4 通用塊
已經看到了大體上塊是什麼和它們位於何處,讓我們看一下你將會在EXE和OBJ檔案中找到的通用塊。這個列表決不是完整的,但包含了你每天都碰到的塊(甚至你沒有意識到的)。
.text 塊是編譯器或彙編器結束時產生的通用程式碼塊。因為PE檔案執行在32位下,並且沒有16位段的限制,沒有理由根據分開的原始檔把程式碼分為分開的塊。代替的,聯結器把從不同的OBJ檔案得來的 .text 塊連線起來放到EXE檔案中的一個大 .text 塊中。如果你用 Borland C++ ,編譯器把產生的程式碼放到名為 CODE 的塊中。Borland C++ 生成的PE檔案有一個名為 CODE 的塊而不是名為 .text 。我將會簡短的解釋一下。
Figure 2. Calling a function in another module
對我來說,除了我用編譯器建立的或從執行時庫中得到的程式碼外,在 .text 塊中找到附加的程式碼是比較有趣的。在一個PE檔案中,當你在另一模組中呼叫一個函式時(比如在USER32.DLL中的GetMessage ),編譯器產生的CALL 指令並不把控制直接轉移到在DLL中的這個函式(見圖8)。代替的,CALL 指令把把控制轉移到一個也在 .text 中的
JMP DWORD PTR [XXXXXXXX]
指令處。這個 JMP 指令(譯註1)透過一個在 .idata 中的DWORD變數間接的轉移控制。 .idata 塊的DWORD包含作業系統函式入口的實際地址。在對這進行一會兒回想之後,我開始理解為什麼DLL呼叫用這種方式來實現。透過一個位置傳送所有的對一個給定的DLL函式的呼叫,載入器不需要改變每個呼叫DLL的指令。所有的PE載入器必須做的是把目標函式的正確地址放到 .idata 的一個 DWORD 中。不需要改變任何call指令。在NE檔案中就不同了,每個段都包含一個需要應用到這個段上的一個修正表。如果這個段把一個給定的DLL函式呼叫了20次,載入器必須把這個函式的地址寫入到這個段的每個呼叫指令中。PE方法的缺點是你不能用一個DLL函式的真實地址來初始化一個變數。比如,你要考慮這樣的情況:
FROC pfnGetMessage = GetMessage;
將把GetMessage的地址存到變數 pfnGetMessage 中。在16位Windows中,這可以工作,但在Win32中不能。在Win32中,變數pfnGetMessage最終儲存的是我前面提到的JMP DWORD PTR [XXXXXXXX] 替換指示(譯註2)。如果你想透過函式指標呼叫一個函式,事情也會如你所預料的一樣。但是,如果你想讀取 GetMessage 開始的位元組,你將不能如願(除非你自己做跟在 .idata 指標後的工作)。後面我將會返回到這個話題上--在匯入表的討論中。
譯註1:英文 thunk,正統的計算機專業術語為"形實轉換程式",類似宏(macro)替換,故我將它譯為"替換指示",指在具體指令中xxxxxxxx 被替換,後面出現的替換指示同。
譯註2:現在的編譯器如VC6以上等等,產生的匯入函式呼叫程式碼不再是先來一個相對Call指令到 jmp [xxxx] 處,然後再到 xxxx 處(真正的匯入函式入口),而是用了一種更高,也更容易讓人理解的方式:call [xxxx] 。以前用那種間接的方式多是為相容編譯器。但是現在仍有一些編譯器,如MASM,直到版本7.0,還是用前面那種間接的方式,從這裡也可以看出微軟對ASM的態度了。
雖然 Borland 可以讓編譯器輸出的程式碼塊名為 .text ,但它是選擇 NAME 作為預設的段名。為了確定PE檔案中的塊名,Borland 的聯結器(TLINK32.EXE)從OBJ檔案中取出段名並把它截斷為8字元(如果有必要)。
當塊名的不同只是一個小問題時,Borland PE 檔案怎樣連結到其它模組就是一個重要的不同。就像我在 .text 的描述中提到的,所有到OBJ的呼叫透過一個JMP DWORD PTR [XXXXXXXX]替換指示。在微軟系統下,這條指令透過一個匯入庫到達 .text 塊。因為庫管理器(LIB32)當你連結外部DLL時才建立匯入庫(和這個替換指示),聯結器自己不需要"知道"怎樣生成這這個替換指示。匯入庫實際上只不過是連結到這個PE檔案的一些更多的程式碼和資料。
Borland 處理匯入函式的系統只是一個簡單的16位NE檔案方式擴充套件。Borland 聯結器使用的匯入庫實際上只不過是一個函式名連同它所在的DLL名的列表。於是TLINK32就有責任確定外部DLL的修正,並生為它成一個適當的JMP DWORD PTR [XXXXXXXX] 替換指示 。TLINK32把這個替換指示儲存在它建立的名為 .icode 塊中。正像 .text 是預設的程式碼塊,.data 塊是已初始化資料的歸宿。這些資料包含編譯時初始化的全域性和靜態區域性變數。它還包括文字字串。聯結器把從OBJ/LIB檔案得來的所有 .data 塊組合到EXE檔案的一個 .data 塊中。區域性變數載入到一個執行緒的堆疊中,在 .data 或 .bss 中不佔空間。
.bss 塊是儲存未初始化的全域性和靜態區域性變數的地方。聯結器把 OBJ/LIB 檔案中的所有 .bss 塊連結到EXE檔案的一個 .bss 塊中。在塊表中,.bss 塊的RawDataOffset 域置為0 ,表示這個塊在檔案中不佔用任何空間。TLINK 不產生這個塊。代替的,它擴充套件 DATA 塊的虛擬尺寸(virtual size)。
.CRT 塊是微軟 C/C++ 執行時庫利用的另一個已初始化資料的塊(從名字)。我不能理解為什麼這些資料不放在 .data 中。(譯註)
譯註:從CRT的字面意思看,應該是"C Run Time",即C執行時庫。
.rsrc 塊這個模組的所有資源。在Windows NT的早期,16位RC.EXE輸出的RES檔案是微軟的PE聯結器不能識別的格式。CVTRES 程式把這種格式的RES檔案轉換成COFF格式的OBJ檔案,把資源資料放在 OBJ 的 .rsrc 塊中。聯結器就可以把這個資源OBJ當作另一個OBJ來連結了,允許聯結器"知道"關於資源的特殊東西。微軟最近釋出的更多聯結器可以直接處理RES檔案。
.idata 塊包含關於這個模組從其它DLL匯入的函式(和資料)的資訊(譯註)。這個塊和NE檔案的模組引用表是等價的。一個關鍵的不同是PE檔案匯入的每個函式都明確的列在這個塊中。為找到NE檔案中的等價資訊,你必須去挖掘這個段生鮮資料的結尾的重定位資訊。
譯註:現在許多編譯器產生的EXE檔案都沒有這個塊,然而ImportTable並不是沒有了,代替的,ImportTable僅由DataDirectory[1]指示,一般指向.text塊或.data塊中。
.edata 塊是這個PE檔案匯出到其它模組的函式和資料的列表。它的NE檔案等價物是條目表的聯合,駐留名錶,和非駐留名錶,和16位Windows不一樣,很少有理由從一個EXE檔案匯出一些東西,所以你通常只在DLL中看到 .edata 塊。當使用微軟的工具時,.edata 塊中的資料透過EXP檔案來到PE檔案中。換種方法,聯結器不為它自己生成這個資訊。代替的,它依賴庫管理器(LIB32)來掃描OBJ檔案,並建立EXP檔案,聯結器要把它要連結的模組的列表加入其中。是的,好!這些麻煩的EXP檔案實際上只是副檔名不同的OBJ檔案而已。
.reloc 塊保持一個基本重定位表。基本重定位是一個對一條指令或已初始化的變數值的調整,如果載入器不能把這個檔案載入到聯結器假定的位置,這就是很重要的了。如果載入器能把這個映像載入到聯結器建議(prefer)的基地址,載入器就完全忽略這個塊的重定位資訊。如果你願意冒險,並且希望載入器可以始終把這個映像載入到假定的基址,你可以透過 /FIXED 選項告訴連結器去除這個資訊。這樣可以在可執行檔案中節省空間,但會導致這個可執行檔案在其它的Win32實現中不能工作。比如,假定你為Windows NT建立了一個EXE檔案,並且把基址設為 0x10000 。如果你讓聯結器去除重定位資訊,這個EXE檔案在Windows95下將不能執行,因為在這裡地址0x10000已被系統使用了。
注意編譯器生成的JMP和CALL指令是很重要的,首選它使用相對偏移量的版本,而非32位平坦段中的真實偏移量版本。如果映像需要被載入非聯結器假定的基址處,這些指令不需要改變,因為它使用的是相對定址。結果就是,並不需要你想象的那麼多的重定位。重定位通常只需要使用指向一些資料的32位偏移。舉個例子,讓我們看一下,你有如下的全域性變數宣告:
int i;
int *ptr = &i;
如果聯結器假定一個0x10000的映像基址,變數i的地址將最終是一個特定值如0x12004 。在用來存放指標"ptr"的記憶體中,聯結器將寫進0x12004 ,因為這是變數 i 的地址。如果載入器由於某種原因決定把這個檔案載入基址0x70000處,變數i的地址將是0x72004 。.reloc 塊是映像中的一些記憶體位置的列表,這些記憶體位置在連線時聯結器假定的載入地址和實際需要的載入地址是不同的,這個因素需要考慮。
當你使用編譯器指令 __declspec(thread) 時,你定義的資料不在 .data 和 .bss 塊種。它最終在 .tls 塊中,這個塊指示"執行緒區域性儲存",並且和Win32的TlsAlloc函式族相聯絡。處理 .tls 塊時,記憶體管理器設定頁表以便程式在任何時刻切換執行緒時,都有一個新的實體記憶體頁集對映到 .tls 塊的地址空間。這就允許執行緒內的全域性變數。在大部分情況下,利用這種機制,比基於執行緒分配記憶體並把其指標存在一個 "TlsAlloc 過的"(注:原文TlsAlloc'ed)槽(注:原文Slot)中要容易的多。
不幸的是,有一點需要注意--必須深入研究.tls 塊和 __declspec(thread) 的變數。在WindowsNT 和Windows95 中,如果DLL是被載入庫動態載入的,這種執行緒區域性儲存機制將不能在這個DLL中工作。然而在EXE中或一個隱含載入的DLL中,一切都工作正常。如果你不隱含連結到這個DLL ,但需要按執行緒的資料,你必須會到過去並使用 TlsAlloc 和 TlsGetValue 這種原始方式來設定執行緒動態記憶體分配。
雖然 .rdata 塊通常在 .data 和 .bss 塊之間,你的程式一般看不見並使用這些塊中的資料。.rdata 塊至少在兩種東西中使用。第一,在微軟聯結器生成的EXE中,.rdata 塊存放除錯目錄,這隻在EXE檔案中出現。(在 TLINK32 的 EXE 中,除錯目錄在名為 ".DEBUG"的塊中)。除錯目錄是一個IMAGE_DEBUG_DIRECTORY結構陣列。這些結構保持儲存在檔案中的變數的型別,尺寸,和位置的除錯資訊。三種主要的除錯資訊型別顯示如下:CodeView?, COFF,和 FPO,表9顯示了PEDUMP輸出的一個典型的除錯目錄。
表 7 一個典型的除錯目錄
Type Size Address FilePtr Charactr TimeDate Version
COFF 000065C5 00000000 00009200 00000000 2CF8CF3D 0.00
??? 00000114 00000000 0000F7C8 00000000 2CF8CF3D 0.00
FPO 000004B0 00000000 0000F8DC 00000000 2CF8CF3D 0.00
CODEVIEW 0000B0B4 00000000 0000FD8C 00000000 2CF8CF3D 0.00
除錯目錄不必在 .rdata 塊的開始找到。為找到除錯目錄表的開始,使用資料目錄的第七個條目(IMAGE_DIRECTORY_ENTRY_DEBUG)的RVA。資料目錄在檔案的PE首部結尾部分。為確定微軟聯結器生成的除錯目錄的條目數,用除錯目錄的尺寸(在資料目錄條目的尺寸域)除以一個IMAGE_DEBUG_DIRECTORY結構的尺寸即可。TLINK32產生一個簡單的數目,通常是1 。PEDUMP示例程式描述了這一點。
.rdata 域的另一個有用的部分是"描述串"。如果你在程式的DEF檔案中指定一個DESCRIPTION條目,這個指定的描述串就出現在 .rdata 塊中。在NE格式中,描述串總是非駐留名錶的第一個條目。描述串是用來保持一個描述這個檔案的有用的文字串的。不幸的是,我還沒找到一條便捷的途徑來得到它。我看到有些描述串在PE檔案的除錯目錄之前,在另一些檔案中它在除錯目錄之後。我找不到得到這個描述串的一致的方法(或甚至這種方法根本就不存在)。
.debug$S 和 .debug$T 塊只出現在 OBJ 中。他們儲存 CodeView 除錯符號和型別資訊。這些塊名是從以前16位編譯器($$SYMBOLS 和 $$TYPE)使用的段名繼承來的。.debug$T 塊的唯一用途是保持包含工程中所有OBJ的CodeView資訊的P檔案的路徑。聯結器從PDB中讀取並且使用它來建立CodeView資訊的組成部分,這些CodeView資訊放置在PE檔案的結尾。
.drectve 塊只出現在OBJ檔案中。它包含用文字表示的聯結器命令。比如,在我用微軟編譯器編譯的任一OBJ中,下面的字串都出現在 .drectve 塊中:
-defaultlib:LIBC -defaultlib:OLDNAMES
當你在程式中用 __declspec(export) 時,編譯器簡單的把等價的命令列輸出到 .drectve 塊中(例如:"-exprot:MyFunction")。
在玩弄 PEDUMP 的過程中,我不時的遇到其它塊。例如,在Window95的KERNEL32.DLL中,有LOCKCODE和LOCKDATA塊。大概這是一種特殊的頁處理方法,是為了避免缺頁(譯註)。
譯註:缺頁,在頁式記憶體管理中,一條指令訪問的虛擬記憶體未對映到實體記憶體中,此時將發生缺頁中斷,關於缺頁中斷,請參閱作業系統相關書籍。
從這裡學到兩個教訓。第一:不要以為有而只使用編譯器或彙編器提供的標準塊。如果由於某種原因你需要一個分開的塊,不要猶豫,自己去建立!在C/C++編譯器中,使用 #pragma code_seg 和 #pragma data_seg 。在組合語言中,只不過是建立一個名字和和標準塊不同的32位的段(將成為一個塊)。如果使用TLINK32 ,你必須使用一個不同的類,或者關掉程式碼段包裝(packing)。其它要記住的東西是使用非標準塊名你將會更透徹的理解特殊PE檔案的意圖和實現。
5 PE檔案的匯入表
前面,我描述了函式呼叫怎樣到一個外部DLL中而不直接呼叫這個DLL 。代替的,在執行體中的 .text 塊中(如果你用Borland C++ 就是 .icode 塊),CALL指令到達一條
JMP DWORD PTR [XXXXXXXX]
指令處。JMP指令尋找的地址把控制轉移到實際的目標地址。PE檔案的 .idata 會包含一些必要的資訊,這些資訊是載入器用來確定目標函式的地址以及在執行體映像中去修正他們的。
.idata 塊(或稱匯入表,我更喜歡這樣叫)開始於一個IMAGE_IMPORT_DESCRIPTOR陣列。每個DLL都有一個PE檔案隱含連結上的IMAGE_IMPORT_DESCRIPTOR。沒有指定這個陣列中結構的數目的域。代替的,這個陣列的最後一個元素是一個全NULL的IMAGE_IMPORT_DESCRIPTOR 。IMAGE_IMPORT_DESCRIPTOR的格式顯示在表8 。
表 8 IMAGE_IMPORT_DESCRIPTOR Format
DWORD Characteristics
在一個時刻,這可能已是一個標誌集。然而,微軟改變了它的涵義並不再糊塗地升級WINNT.H 。這個月實際上是一個指向指標陣列的偏移(RVA)。其中每個指標都指向一個IMAGE_IMPORT_BY_NAME結構。
DWORD TimeDateStamp
指示這個檔案的建立時間。
DWORD ForwarderChain
這個域聯絡到前向鏈。前向鏈包括一個DLL函式向另一個DLL轉送引用。比如,在WindowsNT中,NTDLL.DLL就出現了的一些前向的它向KERNEL32.DLL匯出的函式。應用程式可能以為它呼叫的是NTDLL.DLL中的函式,但它最終呼叫的是KERNEL32.DLL中的函式。這個域還包含一個FirstThunk陣列的(即刻描述)。用這個域索引得函式會前向引用到另一個DLL 。不幸的是,函式怎樣前向引用的格式沒有文件,並且前向函式的例子也很難找。
DWORD Name
這是匯入DLL的名字,指向以NULL結尾的ASCII字串。通用例子是KERNEL32.DLL和USER32.DLL 。
PIMAGE_THUNK_DATA FirstThunk
這個域是指向IMAGE_THUNK_DATA聯合的偏移(RVA)。幾乎在任何情況下,這個域都解釋為一個指向的IMAGE_IMPORT_BY_NAME結構的指標。如果這個域不是這些指標中的一個,那它就被當作一個將從這個被匯入的DLL的匯出序數值。如果你實際上可以從序數匯入一個函式而不是從名字匯入,從文件看,這是不清楚的。
IMAGE_IMPORT_DESCRIPTOR 的一個重要部分是匯入的DLL的名自和兩個IMAGE_IMPORT_BY_NAME指標陣列。在EXE檔案中,這兩個陣列(由Characteristics域和FirstThunk域指向)是相互平行的,都是以NULL指標作為陣列的最後一個元素。兩個陣列中的指標都指向 IMAGE_IMPORT_BY_NAME 結構。表3以圖形顯示了這種佈局。表12顯示了PEDUMP對一個匯入表的輸出。
圖 3. 兩個平行的指標陣列
表 9. 一個EXE檔案的匯入表
GDI32.dll
Hint/Name Table: 00013064
TimeDateStamp: 2C51B75B
ForwarderChain: FFFFFFFF
First thunk RVA: 00013214
Ordn Name
48 CreatePen
57 CreateSolidBrush
62 DeleteObject
160 GetDeviceCaps
// Rest of table omitted...
KERNEL32.dll
Hint/Name Table: 0001309C
TimeDateStamp: 2C4865A0
ForwarderChain: 00000014
First thunk RVA: 0001324C
Ordn Name
83 ExitProcess
137 GetCommandLineA
179 GetEnvironmentStrings
202 GetModuleHandleA
// Rest of table omitted...
32.dll
Hint/Name Table: 00013138
TimeDateStamp: 2C41A383
ForwarderChain: FFFFFFFF
First thunk RVA: 000132E8
Ordn Name
46 ShellAboutA
USER32.dll
Hint/Name Table: 00013140
TimeDateStamp: 2C474EDF
ForwarderChain: FFFFFFFF
First thunk RVA: 000132F0
Ordn Name
10 BeginPaint
35 CharUpperA
39 CheckDlgButton
40 CheckMenuItem
// Rest of table omitted...
PE檔案的匯入表的每一個函式有一個 IMAGE_IMPORT_BY_NAME 結構。IMAGE_IMPORT_BY_NAME結構非常簡單,看上去是這樣:
WORD Hint;
BYTE Name[?];
第一個域是匯入函式的匯出序數的最佳猜測。和NE檔案不同,這個值不是必須正確的。於是,載入器指示把它當作一個進行二分查詢的建議開始值。下一個是匯入函式的名字的ASCIIZ字串。
為什麼有兩個平行的指標陣列指向結構IMAGE_IMPORT_BY_NAME ?第一個陣列(由Characteristics域指向的)單獨的留下來,並不被修改。經常被稱作提名錶。第二個陣列(由FirstThunk域指向的)將被PE載入器覆蓋。載入器在這個陣列中迭代每個指標,並查詢每個IMAGE_IMPORT_BY_NAME結構指向的函式的地址。載入器然後用找到的函式地址覆蓋這個指向IMAGE_IMPORT_BY_NAME結構的指標。JMP DWORD PTR [XXXXXXXX] 替換指示中的 [XXXXXXXX] 表示 FirstThunk 陣列的一個條目。因為由載入器覆蓋的這個指標陣列實際上保持所有匯入函式的地址,叫做"匯入地址表"。
對Borland使用者,上面的描述有點彆扭。由TLINK32產生的PE檔案缺少其中一個陣列。在這樣一個執行體中,IMAGE_IMPORT_DESCRIPTOR(提名陣列)中Characteristics域的是0 。於是,僅有的由FirstThunk域(匯入地址表)指向的陣列在PE檔案中就是必須的了。故事到這裡應該結束了,除非在我寫PEDUMP時深入一個有趣的問題中。在最佳化上無止境的探索,微軟在WindowsNT中"最佳化"了系統DLL(KERNEL32.DLL等等)的thunk陣列。在這個最佳化中,這個陣列中的指標不再指向IMAGE_IMPORT_BY_NAME結構,它們已經包含了匯入函式的地址。換句話說,載入器不需要去查詢函式的地址並用匯入函式的地址覆蓋thunk陣列(譯註)。對希望這個陣列包含指向IMAGE_IMPORT_BY_NAME結構的指標的PEDump程式,這導致了一個問題。你可能正在思考,"但是,Matt ,為什麼呢不順便使用提名錶陣列?"這可能是一個完美的解決方案,除非提名錶陣列在Borland檔案中不存在。PEDUMP處理所有這些情況,但是程式碼理所當然的就有些雜亂。
譯註: 這就是 Bound Import,關於Bound Import,請參閱:
Matt Pietrek "Inside Windows An In-Depth Look into the Win32 Portable Executable File Format, Part 2 " From MSDN Magazine March 2002 on Inte
URL :
因為匯入地址表在一個可寫的塊中,攔截一個EXE或DLL對另一個DLL的呼叫就相對容易。只需要修改適當地匯入地址條目去指向希望攔截的函式。不需要修改呼叫者或被調者的任何程式碼。
注意微軟產生的PE檔案的匯入表並不是完全被聯結器同步的,這一點很有趣。所有對另一個DLL中的函式的呼叫的指令都在一個匯入庫中。當你連線一個DLL時,庫管理器(LIB32.EXE或LIB.EXE)掃描將要被連線的OBJ檔案並且建立一個匯入庫。這個匯入庫完全不同於16位NE檔案聯結器使用的匯入庫。32位庫管理器產生的匯入庫有一個.text塊和幾個.idata$塊。匯入庫中的.text塊包含 JMP [XXXX] 的替換指示,這個替換指示在OBJ檔案的符號表中有一個名字來儲存它。這個符號名對將從DLL中匯出的所有函式名都是唯一的(例如:_Dispatch_Message@4)。匯入庫中的一個.idata$塊包含一個從其中引用的替換指示(譯註:即JMP [XXXX]中的XXXX)。另一個.idata$塊有一個匯入函式名之前的提示序號(hint ordinal)的空間。這兩個域就組成了IMAGE_IMPORT_BY_NAME結構。當你晚連線一個使用匯入庫的PE檔案時,匯入庫的塊被加到聯結器需要處理的在OBJ檔案中的你的塊的列表中。一旦匯入庫中的這個替換指示的名字和和要匯入的函式名相同,聯結器就假定這個替換指示就是這個匯入函式,並修正對這個匯入函式,使其指向這個替換指示。匯入庫中的這個替換指示在本質上就被當作這個匯入函式本身了。
除了提供一個匯入函式替換指示的程式碼部分,匯入庫還提供PE檔案的.idata塊(或稱匯入表)的片斷。這些片斷來自於庫管理器放入匯入庫中的不同的.idata$塊。簡而言之,聯結器實際上不知道出現在不同的OBJ檔案中的匯入函式和普通函式之間的不同。聯結器只是按照它的邊框調整規則去建立並結合塊,於是,所有的事情就自然順理成章了。
6 術語
生鮮資料:原文"RawData",意指未加工過的資料,即原原本本從磁碟上讀入而未經過任何改動的資料。
替換指示:原文"thunk",本質上是一條指令,這條指令中有浮動的地址域。如文中的 jmp [xxxx],其中xxxx是一個浮動地址(floating address),或稱可重定位地址(relocatable address)。
OBJ檔案:Object檔案,即編譯器編譯產生的目標檔案,這種檔案只有在(和LIB)連線之後,才能形成可執行檔案。
LIB檔案:庫檔案,這種檔案中包含一些二進位制的程式碼(資料)及其符號,一般情況下,用到LIB中的哪個符號,聯結器連線時,關於那個符號的二進位制程式碼(資料)才會放入最終的執行體中。
RES檔案:Widows資原始檔,由RC.EXE編譯。
EXE檔案:不用多說Windows下的可執行檔案,這類檔案一般有匯入表(Import Table)。有少數這類檔案有匯出表(Export Table)。
DLL檔案:Dinamic Link Library ,即動態連線庫,用來向其它執行體匯出函式(或資料等)。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/10752043/viewspace-993931/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- PE檔案格式
- PE檔案格式的RVA概念
- PE檔案格式詳細解析(一)
- 初步瞭解PE檔案格式(上)
- PE檔案格式詳細解析(二)--IAT
- Billy Belceb 病毒編寫教程for Win32 ----PE檔案頭Win32
- [轉載]淺析.NET Framework對PE檔案格式的擴充套件Framework套件
- 第二部分 PE檔案格式
- [轉]根據PE檔案格式獲取LoadLibraryA()/GetProcAddress()地址
- Photoshop檔案格式(轉)
- 深入剖析PE檔案
- PE檔案結構(二) 區塊,檔案偏移與RVA轉換
- MIDI 檔案格式 (轉)
- COFF檔案的格式 (轉)
- PE檔案格式詳細解析(六)-- 基址重定位表(Base Relocation Table)
- PE 檔案結構圖
- 手工構造一個超微型的 PE 檔案 (轉)
- plist檔案格式轉換器
- Linux轉換檔案格式Linux
- 讀取DXF格式檔案 (轉)
- PE教程2: 檢驗PE檔案的有效性
- PE檔案結構複習
- PE檔案結構解析3
- PE檔案結構解析1
- PE檔案結構解析2
- 如何將檔案PDF格式轉換成Word格式
- chm檔案怎麼轉換成TXT格式?chm檔案快速轉化成TXT格式的方法
- PDF檔案如何轉成markdown格式
- 使用sratoolkit轉換SRA檔案格式
- ofd檔案如何轉換成pdf格式 電腦上ofd檔案如何轉換成pdf格式
- windows載入PE檔案的流程Windows
- PE檔案格式詳細解析(五)-- 除錯UPX壓縮的notepad程式除錯
- ofd檔案如何轉換成pdf格式 電腦ofd檔案如何免費轉換為pdf格式
- Unix/ELF檔案格式及病毒分析(轉)
- Unix/ELF檔案格式及病毒分析 (轉)
- JAVA中GBK格式檔案和UTF-8格式檔案互相轉換Java
- PE檔案格式詳細解析(四)-- 執行時壓縮及UPX壓縮除錯除錯
- 惡意軟體PE檔案重建指南