天天看點

win32 PE format

Peering Inside the PE: A Tour of the Win32 Portable Executable File Format

Matt Pietrek March 1994

這篇文章來自 MicroSoft 系統期刊,1994 年 3 月。版權所有,? 1994 Miller Freeman,Inc.保留所有權利!未經 Miller Freeman同意,這篇文章的任何部分不得以任何形式抄襲(除了在論文中或評論中以摘要引用)。

一個作業系統的可執行檔案格式在很多方面是這個系統的一面鏡子。雖然學習一個可執行檔案格式通常不是一個程式員的首要任務,但是你可以從這其中學到大量的知識。在這篇文章中,我會給出 MicroSoft 的所有基于 win32系統(如winnt,win9x)的可移植可執行(PE)檔案格式的詳細介紹。在可預知的未來,包括 Windows2000 , PE 檔案格式在 MicroSoft 的作業系統中扮演一個重要的角色。如果你在使用 Win32 或 Winnt ,那麼你已經在使用 PE 檔案了。甚至你隻是在 Windows3.1 下使用 Visual C++ 程式設計,你使用的仍然是 PE 檔案(Visual C++ 的 32 位 MS-DOS 擴充元件用這個格式)。簡而言之,PE 格式已經普遍應用,并且在不短的将來仍是不可避免的。現在是時候找出這種新的可執行檔案格式為作業系統帶來的東西了。

我最後不會讓你盯住無窮無盡的十六進制Dump,也不會詳細讨論頁面的每一個單獨的位的重要性。代替的,我會向你介紹包含在 PE 檔案中的概念,并且将他們和你每天都遇到的東西聯系起來。比如,線程局部變量的概念,如下所述:

declspec(thread) int i;

我快要發瘋了,直到我發現它在可執行檔案中實作起來是如此的簡單并且優雅。既然你們中的許多人都有使用 16 Windows 的背景,我将把 Win32 PE 檔案的構造追溯到和它等價的16 位 NE 檔案。

除了一個不同的可執行檔案格式, MicroSoft 還引入了一個用它的編譯器和彙編器生成的新的目标子產品格式。這個新的 OBJ 檔案格式有許多和PE 檔案共同的東東。我做了許多無用功去查找這個新的 OBJ 檔案格式的文檔。是以我以自己的了解對它進行解析,并且,在這裡,除了 PE 檔案,我會描述它的一部分。

大家都知道,Windows NT 繼承了 VAX? VMS? 和 UNIX? 的傳統。許多 Windows NT 的創始人在進入微軟前都在這些平台上進行設計和編碼。當他們開始設計 Windows NT 時,很自然的,為了最小化項目啟動時間,他們會使用以前寫好的并且已經測試過的工具。用這些工具生成的并且工作的可執行和 OBJ 檔案格式叫做 COFF (Common Object File Format 的首字母縮寫)。COFF 的相對年齡可以用八進制的域來指定。COFF 本身是一個好的起點,但是需要擴充到一個現代作業系統如 Windows 95 和 Windows NT 的需要。這個更新的結果就是(PE格式)可移植可執行檔案格式。它被稱為"可移植的"是因為在所有平台(如x86,Alpha,MIPS等等)上實作的WindowsNT 都使用相同的可執行檔案格式。當然了,也有許多不同的東西如二進制代碼的CPU指令。重要的是作業系統的裝入器和程式設計工具不需要為任何一種CPU完全重寫就能達到目的。

MicroSoft 抛棄現存的32位工具和可執行檔案格式的事實證明了他們想讓 WindowsNT 更新并且運作的更快的決心。為16位Windows編寫的虛拟裝置驅動程式用一種不同的32位檔案布局--LE 檔案格式--WindowsNT出現很早以前就存在了。比這更重要的是對 OBJ 檔案的替換!在 WindowsNT 的 C 編譯器以前,所有的微軟編譯器都用 Intel 的 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_DEBUG]

.VirtualAddress;

為了有助于邏輯的了解這些winnt.h中的資訊,閱讀可移植可執行和公共對象檔案格式的規格說明,這些在MSDN既看CD光牒中是可用的,一直包括到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

file://--------------------/

// PROGRAM: PEDUMP

// FILE:    PEDUMP.C

// AUTHOR:  Matt Pietrek - 1993

file://--------------------/

#include <windows.h>

#include <stdio.h>

#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 Pietrek/n/n"

"Syntax: PEDUMP [switches] filename/n/n"

"  /A    include everything in dump/n"

"  /H    include hex dump of sections/n"

"  /L    include line number information/n"

"  /R    show base relocations/n"

"  /S    show symbol table/n";

// Open up a file, memory map it, and call the appropriate dumping 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 %s/n/n", 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 format/n");

    UnmapViewOfFile(lpFileBase);

    CloseHandle(hFileMapping);

    CloseHandle(hFile);

}

// process all the command line arguments and return a pointer to

// the filename argument.

PSTR ProcessCommandLine(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)、資源(RESOURCES),除了代碼和資料是你的程式直接使用的,一個子產品還可以由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中重要的是你可以對任何DLL調用GetModuleHandle()得到一個指針去通路它的元件(譯注)。

譯注:如果 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首部在記憶體中的指針,隻需要把這個值加到映像的基址上即可。

file://忽/略類型轉化和指針轉化 ...

pNTHeader = dosHeader + dosHeader->e_lfanew;

一旦你有了PE主首部的指針,遊戲就可以開始了!PE主首部是一個IMAGE_NT_HEADERS的結構,在WINNT.H中定義。這個結構由一個雙字(DWORD)和兩個子結構組成,布局如下:

DWORD Signature;

IMAGE_FILE_HEADER FileHeader;

IMAGE_OPTIONAL_HEADER OptionalHeader;

标志域用ASCII表示就是"PE/0/0"。如果在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函數的真實位址來初始化一個變量。比如,你要考慮這樣的情況:

  FARPROC 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資訊的PDB檔案的路徑。連接配接器從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...

  SHELL32.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 Internet

URL :http://msdn.microsoft.com/msdnmag/issues/02/03/PE2/PE2.asp

因為導入位址表在一個可寫的塊中,攔截一個EXE或DLL對另一個DLL的調用就相對容易。隻需要修改适當地導入位址條目去指向希望攔截的函數。不需要修改調用者或被調者的任何代碼。

注意微軟産生的PE檔案的導入表并不是完全被連接配接器同步的,這一點很有趣。所有對另一個DLL中的函數的調用的指令都在一個導入庫中。當你連接配接一個DLL時,庫管理器(LIB32.EXE或LIB.EXE)掃描将要被連接配接的OBJ檔案并且建立一個導入庫。這個導入庫完全不同于16位NE檔案連接配接器使用的導入庫。32位庫管理器産生的導入庫有一個.text塊和幾個.idata$塊。導入庫中的.text塊包含 JMP [XXXX] 的替換訓示,這個替換訓示在OBJ檔案的符号表中有一個名字來存儲它。這個符号名對将從DLL中導出的所有函數名都是唯一的(例如:[email protected])。導入庫中的一個.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 ,即動态連接配接庫,用來向其它執行體導出函數(或資料等)。