程序员的自我修养--链接、装载与库笔记:目标文件里有什么
編譯器編譯源代碼后生成的文件叫做目標文件。目標文件從結構上講,它是已經編譯后的可執行文件格式,只是還沒有經過鏈接的過程,其中可能有些符號或有些地址還沒有被調整。其實它本身就是按照可執行文件格式存儲的,只是跟真正的可執行文件在結構上稍有不同。可執行文件格式涵蓋了程序的編譯、鏈接、裝載和執行的各個方面。
1. 目標文件的格式
現在PC平臺流行的可執行文件格式(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkable Format),它們都是COFF(Common file format)格式的變種。目標文件就是源代碼編譯后但未進行鏈接的那些中間文件(Windows的.obj和Linux的.o),它跟可執行文件的內容與結構很相似,所以一般跟可執行文件格式一起采用一種格式存儲。從廣義上看,目標文件與可執行文件的格式其實幾乎是一樣的,所以我們可以廣義地將目標文件與可執行文件看成是一種類型的文件,在Windows下,我們可以統稱它們為PE-COFF文件格式。在Linux下,我們可以將它們統稱為ELF文件。
不光是可執行文件(Windows的.exe和Linux下的ELF可執行文件)按照可執行文件格式存儲。動態鏈接庫(DLL, Dynamic Linking Library)(Windows的.dll和Linux的.so)及靜態鏈接庫(Static Linking Library)(Windows的.lib和Linux的.a)文件都按照可執行文件格式存儲。它們在Windows下都按照PE-COFF格式存儲,Linux下按照ELF格式存儲。靜態鏈接庫稍有不同,它是把很多目標文件捆綁在一起形成一個文件,再加上一些索引,你可以簡單地把它理解為一個包含很多目標文件的文件包。ELF文件標準里面把系統中采用ELF格式的文件歸為以下4類,如下圖:
目標文件與可執行文件格式跟操作系統和編譯器密切相關,所以不同的系統平臺下會有不同的格式,但這些格式又大同小異。它們都是源于同一種可執行文件格式COFF。COFF的主要貢獻是在目標文件里面引入了”段”的機制,不同的目標文件可以擁有不同數量及不同類型的”段”。另外,它還定義了調試數據格式。
2. 目標文件是什么樣的
目標文件中的內容至少有編譯后的機器指令代碼、數據,還包括了鏈接時所須要的一些信息,比如符號表、調試信息、字符串等。一般目標文件將這些信息按不同的屬性,以”節”(Section)的形式存儲,有時候也叫”段”(Segment),在一般情況下,它們都表示一個一定長度的區域,基本上不加以區別,唯一的區別是在鏈接視圖和裝載視圖的時候。
程序源代碼編譯后的機器指令經常被放在代碼段(Code Section)里,代碼段常見的名字有”.code”或”.text”;全局變量和局部靜態變量數據經常放在數據段(Data Section),數據段的一般名字都叫”.data”,如下圖所示。
上圖的可執行文件(目標文件)的格式就是ELF,從圖中可以看到,ELF文件的開頭是一個”文件頭”,它描述了整個文件的文件屬性,包括文件是否可執行、是靜態鏈接還是動態鏈接及入口地址(如果是可執行文件)、目標硬件、目標操作系統等信息,文件頭還包括一個段表(Section Table),段表其實是一個描述文件中各個段的數組。段表描述了文件中各個段在文件中的偏移位置及段的屬性等,從段表里面可以得到每個段的所有信息。文件頭后面就是各個段的內容,比如代碼段保存的就是程序的指令,數據段保存的就是程序的靜態變量等。一般C語言的編譯后執行語句都編譯成機器代碼,保存在.text段;已初始化的全局變量和局部靜態變量都保存在.data段;未初始化的全局變量和局部靜態變量一般放在一個叫”.bss”的段里。未初始化的全局變量和局部靜態變量默認值都為0,本來它們也可以被放在.data段,但是因為它們都是0,所以為它們在.data段分配空間并且存放數據0是沒有必要的。程序運行的時候它們的確是要占內存空間的,并且可執行文件必須記錄所有未初始化的全局變量和局部靜態變量的大小總和,記為.bss段。所以.bss段只是為未初始化的全局變量和局部靜態變量預留位置而已,它并沒有內容,所以它在文件中也不占據空間。總體來說,程序源代碼被編譯以后主要分成兩種段:程序指令和程序數據。代碼段屬于程序指令,而數據段和.bss段屬于程序數據。
數據和指令分段的好處:
(1). 一方面是當程序被裝載后,數據和指令分別被映射到兩個虛存區域。由于數據區域對于程序來說是可讀寫的,而指令區域對于進程來說是只讀的,所以這兩個虛存區域的權限可以被分別設置成可讀寫和只讀。這樣可以防止程序的指令被有意或無意地改寫。
(2). 另外一方面是對于現代的CPU來說,它們有著極為強大的緩存(Cache)體系。由于緩存在現代的計算機中地位非常重要,所以程序必須盡量提高緩存的命中率。指令區和數據區的分離有利于提高程序的局部性。現代CPU的緩存一般都被設計成數據緩存和指令緩存分離,所以程序的指令和數據被分開存放對CPU的緩存命中率提高有好處。
(3). 第三個原因,其實也是最重要的原因,就是當系統中運行著多個程序的副本時,它們的指令都是一樣的,所以內存中只須要保存一份該程序的指令部分。對于指令這種只讀的區域來說是這樣,對于其它的只讀數據也一樣,比如很多程序里面帶有的圖標、圖片、文本等資源也是屬于可以共享的。當然每個副本進程的數據區域是不一樣的,它們是進程私有的。不要小看這個共享指令的概念,它在現代的操作系統里面占據了極為重要的地位,特別是在有動態鏈接的系統中,可以節省大量的內存。
3. 挖掘SimpleSection.o
SimpleSection.c的內容如下:
int printf(const char* format, ...);int global_init_var = 84;
int global_uninit_var;void func1(int i)
{printf("%d\n", i);
}int main(void)
{static int static_var = 85;static int static_var2;int a = 1;int b;func1(static_var + static_var2 + a + b);return a;
}
執行:$ gcc -c SimpleSection.c 會生成目標文件SimpleSection.o
執行:$ objdump -h SimpleSection.o ,使用binutils的工具objdump查看目標文件內部的結構,參數”-h”就是把ELF文件的各個段的基本信息打印出來,也可以使用”objdump -x”把更多的信息打印出來,但是”-x”輸出的這些信息又多又復雜,執行結果如下圖所示:
從執行結果得知,SimpleSection.o的段除了最基本的代碼段(.text)、數據段(.data)和BSS段(.bss)以外,還有只讀數據段(.rodata)、注釋信息段(.comment)、堆棧提示段(.note.GNU-stack)、.eh_frame段。Size為段的長度,File off為段所在的位置;”CONTENTS”、”ALLOC”等表示段的各種屬性;”CONTENTS”表示該段在文件中存在。BSS段沒有”CONTENTS”,表示它實際上在ELF文件中不存在內容。”.note.GNU-stack”段雖然有”CONTENTS”,但它的長度為0,是個很古怪的段,認為它在ELF文件中也不存在。那么ELF文件中實際存在的是”.text”、”.data”、”.rodata”、”.comment”、”eh_frame”段。
有一個專門的命令叫做”size”,它可以用來查看ELF文件的代碼段、數據段和BSS段的長度(dec表示3個段長度的和的十進制,hex表示長度和的十六進制),執行結果如下圖所示:
代碼段:挖掘各個段的內容,離不開objdump,objdump的”-s”參數可以將所有段的內容以十六機制的方式打印出來,”-d”參數可以將所有包含指令的段反匯編,執行結果如下圖所示:
“Contents of section .text”就是.text的數據以十六進制方式打印出來的內容,總共0x54字節,跟前面執行”-h”中”.text”段長度相符合,最左面一列是偏移量,中間4列是十六進制內容,最右面一列是.text段的ASCII碼形式。對照下面的反匯編結果,可以明顯地看到,.text段里面所包含的正是SimpleSection.c里兩個函數func1()和main()的指令。.text段的第一個字節”0x55”就是”func1()”函數的第一條”push %rbp”指令,而最后一個字節”0xc3”正是main()函數的最后一條指令”retq”。
數據段和只讀數據段:.data段保存的是那些已經初始化了的全局靜態變量和局部靜態變量。SimpleSection.c代碼里面一共有兩個這樣的變量,分別是global_init_var和static_var,這兩個變量每個4個字節,一共剛好8個字節,所以”.data”這個段的大小為8個字節。
SimpleSection.c里面在調用”printf”的時候,用到了一個字符串常量”%d\n”,它是一種只讀數據,所以它被放到了”.rodata”段,我們可以從輸出結果看到”.rodata”這個段的4個字節剛好是這個字符串常量的ASCII字節序,最后以\0結尾。
“.rodata”段存放的是只讀數據,一般是程序里面的只讀變量(如const修飾的變量)和字符串常量。單獨設立”.rodata”段有很多好處,不光在語義上支持了C++的const關鍵字,而且操作系統在加載的時候可以將”.rodata”段的屬性映射成只讀,這樣對于這個段的任何修改操作都會作為非法操作處理,保證了程序的安全性。另外在某些嵌入式平臺下,有些存儲區域是采用只讀存儲器的,如ROM,這樣將”.rodata”段放在該存儲區域中就可以保證程序訪問存儲器的正確性。
另外值得一提的是,有時候編譯器會把字符串常量放到”.data”段,而不會單獨放在”.rodata”段。
“.data”段里的前4個字節,從低到高分別為0x54, 0x00, 0x00, 0x00。這個值剛好是global_init_var,即十進制的84。global_init_var是個4字節長度的int類型,為什么存放的次序是0x54, 0x00, 0x00,, 0x00而不是0x00, 0x00, 0x00, 0x54?這涉及CPU的字節序(Byte Order)的問題,也就是所謂的大端(Big-endian)和小端(Little-endian)的問題。而最后4個字節剛好是static_var的值,即85.
BSS段:.bss段存放的是未初始化的全局變量和局部靜態變量。global_uninit_var和static_var2就是存放在.bss段,其實更準確的說法是.bss段為它們預留了空間。但是我們可以看到該段的大小只有4個字節,這與global_uninit_var和static_var2的大小的8個字節不符。其實我們可以通過符號表(Symbol Table)看到,只有static_var2被存放了.bss段,而global_uninit_var卻沒有被存放在任何段,只是一個未定義的”COMMON符號”。這其實是跟不同的語言與不同的編譯器實現有關,有些編譯器會將全局的未初始化變量存放在目標文件.bss段,有些則不存放,只是預留一個未定義的全局變量符號,等到最終鏈接成可執行文件的時候再在.bss段分配空間。
其它段:除了.text、.data、.bss這3個最常用的段之外,ELF文件也有可能包含其它的段,用來保存與程序相關的其它信息,如下圖所示:
這些段的名字都是由”.”作為前綴,表示這些表的名字是系統保留的,應用程序也可以使用一些非系統保留的名字作為段名。比如我們可以在ELF文件中插入一個”music”的段,里面存放了一首MP3音樂,當ELF文件運行起來以后可以讀取這個段播放這首MP3。但是應用程序自定義的段名不能使用”.”作為前綴,否則容易跟系統保留段名沖突。一個ELF文件也可以擁有幾個相同段名的段,比如一個ELF文件中可能有兩個或兩個以上叫做”.text”的段。還有一些保留的段名是因為ELF文件歷史遺留問題造成的,以前用過的一些名字如.sdata、.tdesc、.sbss、.lit4、.lit8、.reginfo、.gptab、.liblist、.conflict。可以不用理會這些段,它們已經被遺棄了。
自定義段:正常情況下,GCC編譯出來的目標文件中,代碼會被放到”.text”段,全局變量和靜態變量會被放到”.data”和”.bss”段。但是有時候你可能希望變量或某些部分代碼能夠放到你所指定的段中去,以實現某些特定的功能。比如為了滿足某些硬件的內存和I/O的地址布局,或者是像Linux操作系統內核中用來完成一些初始化和用戶空間復制時出現頁錯誤異常等。GCC提供了一個擴展機制,使得程序員可以指定變量所處的段:
__attribute__((section(“FOO”))) int global = 42;
__attribute__((section(“BAR”))) void foo() {}
我們在全局變量或函數之前加上”__attribute__((section(“name”)))”屬性就可以把相應的變量或函數放到以”name”作為段名的段中。
4. ELF文件結構描述
ELF目標文件格式的最前部是ELF文件頭(ELF Header),它包含了描述整個文件的基本屬性,比如ELF文件版本、目標機器型號、程序入口地址等。緊接著是ELF文件各個段。其中ELF文件中與段有關的重要結構就是段表(Section Header Table),該表描述了ELF文件包含的所有段的信息,比如每個段的段名、段的長度、在文件中的偏移、讀寫權限及段的其它屬性。
文件頭:可以用readelf命令來詳細查看ELF文件,如下圖所示:
從輸出結果可以看到,ELF的文件頭中定義了ELF魔數、文件機器字節長度、數據存儲方式、版本、運行平臺、ABI版本、ELF重定位類型、硬件平臺、硬件平臺版本、入口地址、程序頭入口和長度、段表的位置和長度及段的數量等。
ELF文件頭結構及相關常數被定義在”/usr/include/elf.h”里,因為ELF文件在各種平臺下通用,ELF文件有32位版本和64位版本。它的文件頭結構也有這兩種版本,分別叫做”Elf32_Ehdr”和”Elf64_Ehdr”。32位版本與64位版本的ELF文件的文件頭內容是一樣的,只不過有些成員的大小不一樣。為了對每個成員的大小做出明確的規定以便于在不同的編譯環境下都擁有相同的字段長度,”elf.h”使用typedef定義了一套自己的變量體系,如下圖所示:
64位版本的文件頭結構”Elf64_Ehdr”定義如下:
?
#define EI_NIDENT (16)typedef struct
{unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */Elf64_Half e_type; /* Object file type */Elf64_Half e_machine; /* Architecture */Elf64_Word e_version; /* Object file version */Elf64_Addr e_entry; /* Entry point virtual address */Elf64_Off e_phoff; /* Program header table file offset */Elf64_Off e_shoff; /* Section header table file offset */Elf64_Word e_flags; /* Processor-specific flags */Elf64_Half e_ehsize; /* ELF header size in bytes */Elf64_Half e_phentsize; /* Program header table entry size */Elf64_Half e_phnum; /* Program header table entry count */Elf64_Half e_shentsize; /* Section header table entry size */Elf64_Half e_shnum; /* Section header table entry count */Elf64_Half e_shstrndx; /* Section header string table index */
} Elf64_Ehdr;
ELF文件頭結構跟前面readelf輸出的ELF文件頭信息相比照,可以看到輸出的信息與ELF文件頭中的結構很多都一一對應。有點例外的是”Elf64_Ehdr”中的e_ident這個成員對應了readelf輸出結果中的”Class”、”Data”、”Version”、”OS/ABI”和”ABI Version”這5個參數。剩下的參數與”Elf64_Ehdr”中的成員基本一一對應。下圖中是ELF文件頭中各個成員的含義與readelf輸出結果的對照表。
ELF魔數:可以從前面readelf的輸出看到,最前面的”Magic”的16個字節剛好對應”Elf64_Ehdr”的e_ident這個成員。這16個字節被ELF標準規定用來標識ELF文件的平臺屬性,比如這個ELF字長(32位/64位)、字節序、ELF文件版本。最開始的4個字節是所有ELF文件都必須相同的標識碼,分別為0x7F、0x45、0x4c、0x46,第一個字節對應ASCII字符里面的DEL控制符,后面3個字節剛好是ELF這3個字母的ASCII碼。這4個字節又被稱為ELF文件的魔數,幾乎所有的可執行文件格式的最開始的幾個字節都是魔數。比如a.out格式最開始兩個字節為0x01、0x07;PE/COFF文件最開始兩個字節為0x4d、0x5a,即ASCII字符MZ。這種魔數用來確認文件的類型,操作系統在加載可執行文件的時候會確認魔數是否正確,如果不正確會拒絕加載。接下來的一個字節是用來標識ELF的文件類的,0x01表示是32位的,0x02表示是64位的;第6個字節是字節序,規定該ELF文件是大端的還是小端的。第7個字節規定ELF文件的主版本號,一般是1,因為ELF標準自1.2版以后就再也沒有更新了。后面的9個字節ELF標準沒有定義,一般填0,有些平臺會使用這9個字節作為擴展標志。
文件類型:e_type成員表示ELF文件類型,有3種ELF文件類型,每個文件類型對應一個常量。系統通過這個常量來判斷ELF的真正文件類型,而不是通過文件的擴展名。相關常量以”ET_”開頭,ET_REL:值為1,可重定位文件,一般為.o文件;ET_EXEC:值為2,可執行文件;ET_DYN:值為3,共享目標文件,一般為.so文件。
機器類型:ELF文件格式被設計成可以在多個平臺下使用。這并不表示同一個ELF文件可以在不同的平臺下使用,而是表示不同平臺下的ELF文件都遵循同一套ELF標準。e_machine成員就表示該ELF文件的平臺屬性,比如3表示該ELF文件只能在Intel x86機器下使用。
段表:ELF文件中有很多各種各樣的段,這個段表(Section Header Table)就是保存這些段的基本屬性的結構。段表是ELF文件中除了文件頭以外最重要的結構,它描述了ELF的各個段的信息,比如每個段的段名、段的長度、在文件中的偏移、讀寫權限及段的其它屬性。也就是說,ELF文件的段結構就是由段表決定的,編譯器、鏈接器和裝載器都是依靠段表來定位和訪問各個段的屬性的。段表在ELF文件中的位置由ELF文件頭的”e_shoff”成員決定。
“objdump -h”命令只是把ELF文件中關鍵的段顯示了出來,而省略了其它的輔助性的段,比如:符號表、字符串表、段名字符串表、重定位表等。可以使用readelf工具來查看ELF文件的段,它顯示出來的結果才是真正的段表結構,如下圖所示:
readelf輸出的結果就是ELF文件段表的內容。段表的結構比較簡單,它是一個以”Elf64_Shdr”結構體為元素的數組。數組元素的個數等于段的個數,每個”Elf64_Shdr”結構體對應一個段。”Elf64_Shdr”又被稱為段描述符(Section Descriptor)。對于SimpleSection.o來說,段表就是有13個元素的數組。ELF段表的這個數組的第一個元素是無效的段描述符,它的類型為”NULL”,除此之外每個段描述符都對應一個段。也就是說SimpleSection.o共有12個有效的段。
???????? ELF文件里面很多地方采用了這種與段表類似的數組方式保存。一般定義一個固定長度的結構,然后依次存放。這樣我們就可以使用下標來引用某個結構。Elf64_Shdr被定義在”/usr/include/elf.h”,代碼清單如下:
typedef struct
{Elf64_Word sh_name; /* Section name (string tbl index) */Elf64_Word sh_type; /* Section type */Elf64_Xword sh_flags; /* Section flags */Elf64_Addr sh_addr; /* Section virtual addr at execution */Elf64_Off sh_offset; /* Section file offset */Elf64_Xword sh_size; /* Section size in bytes */Elf64_Word sh_link; /* Link to another section */Elf64_Word sh_info; /* Additional section information */Elf64_Xword sh_addralign; /* Section alignment */Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;
結構體Elf64_Shdr的各個成員的含義如下圖所示:
注1:事實上段的名字對于編譯器、鏈接器來說是有意義的,但是對于操作系統來說并沒有實質的意義,對于操作系統來說,一個段該如何處理取決于它的屬性和權限,即由段的類型和段的標志位這兩個成員決定。
注2:關于這些字段,涉及一些映像文件的加載的概念。
對照Elf64_Shdr和”readelf -S”的輸出結果,可以很明顯看到,結構體的每一個成員對應于輸出結果中從第二列”Name”開始的每一列。
段的類型(sh_type):段的名字只是在鏈接和編譯過程中有意義,但它不能真正地表示段的類型。我們也可以將一個數據段命名為”.text”,對于編譯器和鏈接器來說,主要決定段的屬性的是段的類型(sh_type)和段的標志位(sh_flags)。段的類型相關常量以SHT_開頭,列舉如下圖所示:
段的標志位(sh_flag):表示該段在進程虛擬地址空間中的屬性,比如是否可寫,是否可執行等。相關常量以SHF_開頭,如下圖所示:
對于系統保留段,它們的屬性如下圖所示:
段的鏈接信息(sh_link、sh_info):如果段的類型是與鏈接相關的(不論是動態鏈接或靜態鏈接),比如重定位表、符號表等,那么sh_link和sh_info這兩個成員所包含的意義如下圖所示對于其它類型的段,這兩個成員沒有意義。
重定位表:SimpleSenction.o中有一個叫做”.rela.text”(注:書中為”.rel.text”)的段,它的類型(sh_type)為”SHT_RELA”(注:SHT_RELA: Relocation entries with addends; SHT_REL: Relocation entries, no addends),也就是說它是一個重定位表(Relocation Table)。鏈接器在處理目標文件時,須要對目標文件中某些部位進行重定位,即代碼段和數據段中那些對絕對地址的引用的位置。這些重定位的信息都記錄在ELF文件的重定位表里面,對于每個須要重定位的代碼段或數據段,都會有一個相應的重定位表。比如SimpleSection.o中的”.rela.text”就是針對”.text”段的重定位表,因為”.text”段中至少有一個絕對地址的引用,那就是對”printf”函數的調用;而”.data”段則沒有對絕對地址的引用,它只包含了幾個常量,所以SimpleSection.o中沒有針對”.data”段的重定位表”.rela.data”。一個重定位表同時也是ELF的一個段,那么這個段的類型(sh_type)就是”SHT_RELA”類型的,它的”sh_link”表示符號表的下標,它的”sh_info”表示它作用于哪個段。比如”.rela.text”作用于”.text”段,而”.text”段的下標為”1”,那么”.rela.text”的”sh_info”為”1”。
字符串表:ELF文件中用到了很多字符串,比如段名、變量名等。因為字符串的長度往往是不定的,所以用固定的結構來表示它比較困難。一種很常見的做法是把字符串集中起來存放到一個表,然后使用字符串在表中的偏移來引用字符串。通過這種方法,在ELF文件中引用字符串只需給出一個數字下標即可,不用考慮字符串長度的問題。一般字符串表在ELF文件中也以段的形式保存,常見的段名為”.strtab”或”.shstrtab”。這兩個字符串表分別為字符串表(String Table)和段表字符串(Section Header String Table)。顧名思義,字符串表用來保存普通的字符串,比如符號的名字;段表字符串表用來保存段表中用到的字符串,最常見的就是段名(sh_name)。
ELF文件中”e_shstrndx”是Elf64_Ehdr的最后一個成員,它是”Section header string table indiex”的縮寫。段表字符串表本身也是ELF文件中的一個普通的段,它的名字往往叫做”.shstrtab”。那么這個”e_shstrndx”就表示”.shstrtab”在段表中的下標,即段表字符串表在段表中的下標。
5. 鏈接的接口---符號
鏈接過程的本質就是要把多個不同的目標文件之間相互”粘”到一起,或者說像玩具積木一樣,可以拼裝形成一個整體。為了使不同目標文件之間能夠相互粘合,這些目標文件之間必須有固定的規則才行,就像積木模塊必須有凹凸部分才能夠拼合。在鏈接中,目標文件之間相互拼合實際上是目標文件之間對地址的引用,即對函數和變量的地址的引用。比如目標文件B要用到了目標文件A中的函數”foo”,那么我們就稱目標文件A定義(Define)了函數”foo”,稱目標文件B引用(Reference)了目標文件A中的函數“foo”。這兩個概念也同樣適用于變量。每個函數或變量都有自己獨特的名字,才能避免鏈接過程中不同變量和函數之間的混淆。在鏈接中,我們將函數和變量統稱為符號(Symbol),函數名或變量名就是符號名(Symbol Name)。
我們可以將符號看做是鏈接中的粘合劑,整個鏈接過程正是基于符號才能夠正確完成。鏈接過程中很關鍵的一部分就是符號的管理,每一個目標文件都會有一個相應的符號表(Symbol Table),這個表里面記錄了目標文件中所用到的所有符號。每個定義的符號有一個對應的值,叫做符號值(Symbol Value),對應變量和函數來說,符號值就是它們的地址。除了函數和變量之外,還存在其它幾種不常用到的符號。我們將符號表中所有的符號進行分類,它們有可能是下面這些類型中的一種:
(1). 定義在本目標文件的全局符號,可以被其它目標文件引用。比如SimpleSection.o里面的”func1”、”main”和”global_init_var”。
(2). 在本目標文件中引用的全局符號,卻沒有定義在本目標文件,這一般叫做外部符號(External Symbol),也就是我們前面所講的符號引用。比如SimpleSection.o里面的”printf”。
(3). 段名,這種符號往往由編譯器產生,它的值就是該段的起始地址。比如SimpleSection.o里面的”.text”、”.data”等。
(4). 局部符號,這類符號只在編譯單元內部可見。比如SimpleSection.o里面的”static_var”和”static_var2”。調試器可以使用這些符號來分析程序或崩潰時的核心轉儲文件。這些局部符號對于鏈接過程沒有作用,鏈接器往往也忽略它們。
(5). 行號信息,即目標文件指令與源代碼中代碼行的對應關系,它也是可選的。
對于我們來說,最值得關注的就是全局符號,即上面分類中的第一類和第二類。因為鏈接過程只關心全局符號的相互”粘合”,局部符號、段名、行號等都是次要的,它們對于其它目標文件來說是”不可見”的,在鏈接過程中也是無關緊要的。我們可以使用很多工具來查看ELF文件的符號表,比如readelf、objdump、nm等。
ELF符號表結構:ELF文件中的符號表往往是文件中的一個段,段名一般叫”.symtab”。符號表的結構很簡單,它是一個Elf64_Sym結構(64位ELF文件)的數組,每個Elf64_Sym結構對應一個符號。這個數組的第一個元素,也就是下標0的元素為無效的”未定義”符號。Elf64_Sym的結構定義如下:
typedef struct
{Elf64_Word st_name; /* Symbol name (string tbl index) */unsigned char st_info; /* Symbol type and binding */unsigned char st_other; /* Symbol visibility */Elf64_Section st_shndx; /* Section index */Elf64_Addr st_value; /* Symbol value */Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
這幾個成員的定義如下圖所示:
符號類型和綁定信息(st_info):該成員低4位表示符號的類型(Symbol Type),高28位表示符號綁定信息(Symbol Binding),如下圖所示:
符號所在段(st_shndx):如果符號定義在本目標文件中,那么這個成員表示符號所在的段在段表中的下標;但是如果符號不是定義在本目標文件中,或者對于有些特殊符號,sh_shndx的值有些特殊,如下圖所示:
符號值(st_value):每個符號都有一個對應的值,如果這個符號是一個函數或變量的定義,那么符號的值就是這個函數或變量的地址,更準確地講應該按下面這幾種情況區別對待:
(1). 在目標文件中,如果是符號的定義并且該符號不是”COMMON塊”類型的(即st_shndx不為SHN_COMMON),則st_value表示該符號在段中的偏移。即符號所對應的函數或變量位于由st_shndx指定的段,偏移st_value的位置。這也是目標文件中定義全局變量的符號的最常見情況,比如SimpleSection.o中的”func1”、”main”、”global_init_var”。
(2). 在目標文件中,如果符號是”COMMON塊”類型的(即st_shndx為SHN_COMMON),則st_value表示該符號的對齊屬性。比如SimpleSection.o中的”global_uninit_var”。
?(3). 在可執行文件中,st_value表示符號的虛擬地址。這個虛擬地址對于動態鏈接器來說十分有用。
SimpleSection.o中的符號如下圖所示:
readelf的輸出格式與上面描述的Elf64_Sym的各個成員幾乎一一對應,第一列Num表示符號表數組的下標,從0開始,共16個符號;第二列Value就是符號值,即st_value;第三列Size為符號大小,即st_size;第四列和第五列分別為符號類型和綁定信息,即對應st_info的地4位和高28位;第六列Vis目前在C/C++語言中未使用,我們可以暫時忽略它;第七列Ndx即st_shndx,表示該符號所屬的段;最后一列即符號名稱。從上面的輸出可以看到,第一個符號,即下標為0的符號,永遠是一個未定義的符號。對于另外幾個符號解釋如下:
(1). func1和main函數都是定義在SimpleSection.c里面的,它們所在的位置都為代碼段,所以Ndx為1,即SimpleSection.o里面”.text”段的下標為1。這一點可以通過readelf -a或objdump -x得到驗證。它們是函數,所以類型是STT_FUNC;它們是全局可見的,所以是STB_GLOBAL;Size表示函數指令所占的字節數;Value表示函數相對于代碼段起始位置的偏移量。
(2). printf這個符號,該符號在SimpleSection.o里面被引用,但是沒有被定義,所以它的Ndx是SHN_UNDEF。
(3). global_init_var是已初始化的全局變量,它被定義在.data段,即下標為3.
(4). global_uninit_var是未初始化的全局變量,它是一個SHN_COMMON類型的符號,它本身并沒有存在于BSS段。
(5). static_var.1752和static_var2.1753是兩個靜態變量,它們的綁定屬性是STB_LOCAL,即只是編譯單元內部可見。
(6). 對于那些STT_SECTION類型的符號,它們表示下標為Ndx的段的段名。它們的符號名沒有顯示,其實它們的符號名即它們的段名。比如2號符號的Ndx為1,那么它即表示.text段的段名,該符號的符號名應該就是”.text”。如果我們使用”objdump -t”就可以清楚地看到這些段名符號。
(7). “SimpleSection.o”這個符號表示編譯單元的源文件名。
特殊符號:當我們使用ld作為鏈接器來鏈接生產可執行文件時,它會為我們定義很多特殊的符號,這些符號并沒有在你的程序中定義,但是你可以直接聲明并且引用它,我們稱之為特殊符號。其實這些符號是被定義在ld鏈接器的鏈接腳本中的。鏈接器會在將程序最終鏈接成可執行文件的時候將其解析成正確的值,注意,只有使用ld鏈接產生最終可執行文件的時候這些符號才會存在。幾個很具有代表性的特殊符號如下:
(1). __executable_start:該符號為程序起始地址,注意,不是入口地址,是程序的最開始的地址。
(2). __etext或_etext或etext:該符號為代碼段結束地址,即代碼段最末尾的地址。
(3). _edata或edata:該符號為數據段結束地址,即數據段最末尾的地址。
(4). _end或end:該符號為程序結束地址。
(5). 以上地址都為程序被裝載時的虛擬地址。
我們可以在程序中直接使用這些符號,測試代碼如下:
#include <stdio.h>extern char __executable_start[];
extern char etext[], _etext[], __etext[];
extern char edata[], _edata[];
extern char end[], _end[];int main()
{printf("Executable Start %X\n", __executable_start);printf("Text End %X %X %X\n", etext, _etext, __etext);printf("Data End %X %X\n", edata, _edata);printf("Executable End %X %X\n", end, _end);return 0;
}
執行結果如下:
另外還有不少其它的特殊符號,它們跟ld的鏈接腳本有關。
符號修飾與函數簽名:約在20世紀70年代以前,編譯器編譯源代碼產生目標文件時,符號名與相應的變量和函數的名字是一樣的。比如一個匯編源代碼里面包含了一個函數foo,那么匯編器將它編譯成目標文件以后,foo在目標文件中的相對應的符號名也是foo。當后來UNIX平臺和C語言發明時,已經存在了相當多的使用匯編編寫的庫和目標文件。這樣就產生了一個問題,那就是如果一個C程序要使用這些庫的話,C語言中不可以使用這些庫中定義的函數和變量的名字作為符號名,否則將會跟現有的目標文件沖突。為了防止類似的符號名沖突,UNIX下的C語言就規定,C語言源代碼文件中的所有全局的變量和函數經過編譯以后,相對應的符號名前加上下劃線”_”。而Fortran語言的源代碼經過編譯以后,所有的符號名前加上”_”,后面也加上”_”。比如一個C語言函數”foo”,那么它編譯后的符號名就是”_foo”;如果是Fortran語言,就是”_foo_”。
這種簡單而原始的方法的確能夠暫時減少多種語言目標文件之間的符號沖突的概率,但還是沒有從根本上解決符號沖突的問題。比如同一種語言編寫的目標文件還有可能會產生符號沖突,當程序很大時,不同的模塊由多個部門(個人)開發,它們之間的命名規范如果不嚴格,則有可能導致沖突。于是像C++這樣的后來設計的語言增加了名稱空間(Namespace)的方法解決多模塊的符號沖突問題。
在現在的Linux下的GCC編譯器中,默認情況下已經去掉了在C語言符號前加”_”的這種方式;但是Windows平臺下的編譯器還保持的這樣的傳統,比如Visucal C++編譯器就會在C語言符號前加”_”,GCC在Windows平臺下的版本(Cygwin, mingw)也會加”_”。GCC編譯器也可以通過參數選項”-fleading-underscore”或”-fno-leading-underscrore”來打開和關閉是否在C語言符號前加上下劃線。
C++符號修飾:函數簽名(Function? Signature):包含了一個函數的信息,包括函數名、它的參數類型、它所在的類和名稱空間及其它信息。函數簽名用于識別不同的函數,函數的名字只是函數簽名的一部分。在編譯器及鏈接器處理符號時,它們使用某種名稱修飾的方法,使得每個函數簽名 對應一個修飾后名稱(Decorated Name)。編譯器在將C++源代碼編譯成目標文件時,會將函數和變量的名字進行修飾,形成符號名,也就是說,C++的源代碼編譯后的目標文件中所使用的符號名是相應的函數和變量的修飾后名稱。C++編譯器和鏈接器都使用符號來識別和處理函數和變量,所以對于不同函數簽名的函數,即使函數名相同,編譯器和鏈接器都認為它們是不同的函數。
GCC的基本C++名稱修飾方法如下:所有的符號都以”_Z”開頭,對于嵌套的名字(在名稱空間或在類里面的),后面緊跟”N”,然后是各個名稱空間和類的名字,每個名字前是名字字符串長度,再以”E”結尾。對于一個函數來說,它的參數列表緊跟在”E”后面,對于int類型來說,就是字母”i”。binutils里面提供了一個叫”c++filt”的工具可以用來解析被修飾過的名稱。
簽名和名稱修飾機制不光被使用到函數上,C++中的全局變量和靜態變量也有同樣的機制。對于全局變量來說,它跟函數一樣都是一個全局可見的名稱,它也遵循上面的名稱修飾機制,比如一個名稱空間foo中的全局變量bar,它修飾后的名字為:_ZN3foo3barE。值得注意的是,變量的類型并沒有被加入到修飾后名稱中,所以不論這個變量是整型還是浮點型甚至是一個全局對象,它的名稱都是一樣的。
名稱修飾機制也被用來防止靜態變量的名字沖突。比如main()函數里面有一個靜態變量叫foo,而func()函數里面也有一個靜態變量叫foo。為了區分這兩個變量,GCC會將它們的符號名分別修飾成兩個不同的名字_ZZ4mainE3foo和_ZZ4funcE3foo,這樣就區分了這兩個變量。
不同的編譯器廠商的名稱修飾方法可能不同,所以不同的編譯器對于同一個函數簽名可能對應不同的修飾后名稱。
猜測Visual C++的名稱修飾規則:修飾后名字由”?”開頭,接著是函數名由”@”符號結尾的函數名;后面跟著由”@”結尾的類名和名稱空間,再一個”@”表示函數的名稱空間結束;第一個”A”表示函數調用類型為”__cdecl”,接著是函數的參數類型及返回值,由”@”結束,最后由”Z”結尾。可以看到函數名、參數的類型和名稱空間都被加入了修飾后名稱,這樣編譯器和鏈接器就可以區別同名但不同參數類型或名字空間的函數,而不會導致link的時候函數多重定義。Visual C++的名稱修飾規則并沒有對外公開。Microsoft提供了一個UnDecorateSymbolName()的API,可以將修飾后名稱轉換成函數簽名。
由于不同的編譯器采用不同的名字修飾方法,必須會導致由不同編譯器編譯產生的目標文件無法正常相互鏈接,這是導致不同編譯器之間不能互操作的主要原因之一。
extern “C”:C++為了與C兼容,在符號的管理上,C++有一個用來聲明或定義一個C的符號的”extern “C””關鍵字用法。C++編譯器會將在extern “C”的大括號內部的代碼當作C語言代碼處理。C++的宏”__cpluplus”,C++編譯器會在編譯C++的程序時默認定義這個宏,我們可以使用條件宏來判斷當前編譯單元是不是C++代碼。
弱符號與強符號:對于C/C++語言來說,編譯器默認函數和初始化了的全局變量為強符號(Strong Symbol),未初始化的全局變量為弱符號(Weak Symbol)。我們也可以通過GCC的”__attribute__((weak))”來定義任何一個強符號為弱符號。注意:強符號和弱符號都是針對定義來說的,不是針對符號的引用。
針對強弱符號的概念,鏈接器就會按如下規則處理與選擇被多次定義的全局符號:
規則1:不允許強符號被多次定義(即不同的目標文件中不能有同名的強符號);如果有多個強符號定義,則鏈接器報符號重復定義錯誤。
規則2:如果一個符號在某個目標文件中是強符號,在其它文件中都是弱符號,那么選擇強符號。
規則3:如果一個符號在所有目標文件中都是弱符號,那么選擇其中占用空間最大的一個。比如目標文件A定義全局變量global為int型,占4個字節;目標文件B定義global為double型,占8個字節,那么目標文件A和B鏈接后,符號global占8個字節(盡量不用使用多個不同類型的弱符號,否則容易導致很難發現的程序錯誤)。
弱引用(Weak Reference)和強引用(Strong Reference):對外部目標文件的符號引用在目標文件被最終鏈接成可執行文件時,它們須要被正確決議,如果沒有找到該符號的定義,鏈接器就會報符號未定義錯誤,這種被稱為強引用。與之相對應還有一種弱引用,在處理弱引用時,如果該符號有定義,則鏈接器將該符號的引用決議;如果該符號未被定義,則鏈接器對于該引用不報錯。鏈接器處理強引用和弱引用的過程幾乎一樣,只是對于未定義的弱引用,鏈接器不認為它是一個錯誤。一般對于未定義的弱引用,鏈接器默認其為0,或者是一個特殊的值,以便于程序代碼能夠識別。弱引用和弱符號主要用于庫的鏈接過程。在GCC中,我們可以通過使用”__attribute__((weakref))”這個擴展關鍵字來聲明對一個外部函數的應用為弱引用。
這種弱符號和弱引用對于庫來說十分有用,比如庫中定義的弱符號可以被用戶定義的強符號所覆蓋,從而使得程序可以使用自定義版本的庫函數;或者程序可以對某些擴展功能模塊的引用定義為弱引用,當我們將擴展模塊與程序鏈接在一起時,功能模塊就可以正常使用;如果我們去掉了某些功能模塊,那么程序也可以正常鏈接,只是缺少了相應的功能,這使得程序的功能更加容易裁剪和組合。
6. 調試信息
目標文件里面還有可能保存的是調試信息。幾乎所有現代的編譯器都支持源代碼級別的調試,比如我們可以在函數里面設置斷點,可以監視變量變化,可以單步行進等,前提是編譯器必須提前將源代碼與目標代碼之間的關系等,比如目標代碼中的地址對應源代碼中的哪一行、函數和變量的類型、結構體的定義、字符串保存到目標文件里面。甚至有些高級的編譯器和調試器支持查看STL容器的內容,即程序員在調試過程中可以直接觀察STL容器中的成員的值。如果我們在GCC編譯時加上”-g”參數,編譯器就會在產生的目標文件里面加上調試信息。現在的ELF文件采用一個叫DWARF(Debug With Arbitrary Record Format)的標準的調試信息格式。Microsoft也有自己相應的調試信息格式標準,叫CodeView。但是值得一提的是,調試信息在目標文件和可執行文件中占用很多的空間,往往比程序的代碼和數據本身大好幾倍,所以當我們開發完程序并要將它發布的時候,須要把這些對于用戶沒有用的調試信息去掉,以節省大量的空間。在Linux下,我們可以使用”strip”命令來去掉ELF文件中的調試信息。
GitHub:?https://github.com/fengbingchun/Messy_Test?
總結
以上是生活随笔為你收集整理的程序员的自我修养--链接、装载与库笔记:目标文件里有什么的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 网络文件系统(NFS)简介
- 下一篇: TeamViewer介绍:远程控制计算机