《程序員的自我修養筆記之靜態連接》

若是想完全弄懂Android代碼保護的基本原理,《Unix環境高級編程》和《程序員的自我修養》是必讀書目。在此做讀書筆記git

第二章

程序源代碼到最終可執行文件的4個步驟:程序員

  • 預編譯

主要處理那些源代碼文件中以"#"開始的預編譯指令github

gcc -E hello.c -o hello.i
複製代碼
  • 編譯

對預編譯生成的文件進行詞法分析,語法分析,語義分析,中間語言生成,目標代碼生成及優化生成彙編代碼文件編程

gcc -S hello.i -o hello.s
複製代碼
  • 彙編

彙編器將彙編代碼轉換成可執行指令,輸出目標文件bash

as hello.s -o hello.o`或者`gcc -c hello.s -o hello.o`或者`gcc -c hello.c -o hello.o
複製代碼
  • 連接
ld -static crt1.o crti.o crtbeginT.o -start-gruoup -lgcc -lgcc_eh -lc-end-group crtend.o crtn.o
複製代碼

這裏省略了各文件的路徑函數

連接過程主要有以下步驟:工具

  • 地址和空間分配
  • 符號決議
  • 重定位

第三章

目標文件格式

  • 可重定位文件(Relocatable File)
  • 可執行文件(Executable File)
  • 共享目標文件(Shared Object File)
  • 核心轉儲文件(Core Dump File)

在Linux下可以使用file命令顯示文件格式優化

目標文件與程序之間的關係

SimpleSection.c代碼以下:ui

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;
}
複製代碼

1545926648771

  • 程序源代碼編譯後的機器指令被放在代碼段(.code或者.text)裏面
  • 全局變量和局部靜態變量放在數據段(.data)
  • 未初始化全局變量和未初始化局部靜態變量,或者有些編譯器也會將初始化爲0的變量也放置在.bss段

查看目標文件內部的結構可使用objdump工具,可看到對應的各個段大體結構(-h),各個段詳細內容(-x),代碼段內容(翻譯成了彙編語言)(-s -d)spa

其餘段內容

1545927294296

  • 將某個二進制文件做爲目標文件的一個段

    objcopy -I binary -o elf32-i386 -B i386 image.jpg image.o

  • 將某個變量放在特定段

    __attribute__((section("FOO"))) int global = 42

    __attribute__((section("BAR"))) void foo()

第四章靜態連接

空間與地址分配

  • 類似段合併

    • 空間與地址分配

      掃描全部輸入目標文件,並得到他們各個段的長度、屬性和位置,並將輸入目標文件中的符號表全部的符號定義和符號引用收集起來,放到全局符號表中。因而,連接器將獲取全部輸入目標文件的段長度,並將他們合併,計算輸出文件中各個段合併後的長度和位置,創建映射關係。

    • 符號解析與重定位

      利用上一步的信息進行段的數據,讀取段數據,重定位信息,進行符號解析與重定位、調整代碼中的地址等。

    以下圖:

    1546439014195

編寫程序:

/*a.c*/

extern int shared;

int main(){
	int a = 100;
	swap(&a, &shared);
	return 0;
}
/* b.c */
int shared = 1;

void swap(int *a, int *b){
	*a ^= *b  ^= *a ^=*b;
}
複製代碼
  • objdump -h a.o

    1546439221303

  • objdump -h b.o

    1546439234732

  • 連接兩個文件ld a.o b.o -e main -o ab -lc

    在個人機器上面須要加上-lc參數才能連接成功,否則報a.c:(.text+0x4b): undefined reference to `__stack_chk_fail錯誤,具體緣由不明,

    -c: 從指定的命令文件讀取命令

    -l: 把指定的存檔文件添加到要鏈接的文件清單

    獲得的可執行文件不僅是簡單的連接過程,跟書中的內容有差別,有大神知道麻煩賜教

  • objdump -h ab

    1546439338068

    能夠看出,合併後獲得的ab文件的.text段和.data段的長度分別是9c和4,正好等於兩個.o文件相應段的長度之和。

符號解析與重定位

重定位

重定位是靜態連接的核心內容,首先看a.o裏面是如何訪問調用外部符號(shared變量和swap函數)

  • 使用objdump -d 命令查看a.o反編譯代碼

    1548947287398

  • objdump -d ab

    1548951798049

其中main起始地址爲0x0,共佔用0x50個字節,最左邊那列表明偏移量偏移量爲22和31的地方即是分別引用sharead變量和swap函數的位置。

  • a.o中引用shared代碼爲lea 0x0(%rip),%rsi,是將rip寄存器的值+0直接傳遞給rsi寄存器,這是由於還沒法查找符號shared的位置,使用0x0代替,後面連接完成以後,ab文件就將0x0替換爲0x200d47,加上rsi寄存器的值,計算後也就是shared的地址0x0601020,可以使用objdump -s abdata段內看到該變量的值。

  • 引用swap函數的代碼爲callq 36 <main+0x36>,既下一條指令的地址,ab文件則會直接將swap地址0x400301填入,變成callq 400301 <swap>

  • 可是第二次試驗,是在公司電腦,我獲得的是以下結果

    1549882203163

    1549882230042

    也就是說,沒有相對尋址了,這讓我有點納悶,swap函數地址也是,不一樣於家裏電腦生成的指令。

總之,就是當文件並無連接以前,遇到了不認得的符號時,編譯器把地址0x0和下一條指令的地址做爲代替,等連接完成地址和空間的分配後,就已經能夠肯定全部符號的虛擬地址了,此時連接器再將全部須要重定位的指令進行地址修復。

書中的環境及解釋是這樣子的:

1549942799298

1549942845177

  • 絕對尋址修正

    a.o第一個重定位入口,即偏移爲18的mov指令修正,修正方式是R_386_32,即絕對地址修正。這個重定位入口,修正後應該是S+A

    • S是符號shared的實際地址,即0x3000
    • A是被修正位置的值,即0x00000000

    因此重定位入口修正後地址爲:0x3000+0x00000000=0x3000,指令修正後應該是:

    1549942583409

  • 相對尋址修正

    a.o的第二個重定位入口,即偏移爲0x26這條call指令的修正,修正方式爲R_386_PC32,也就是相對地址修正。這個重定位入口,修正後結果應爲S+A-P

    • S是swap的實際地址,即0x2000
    • A是被修正的未知的值,即e8 fc ff ff ff中操做數0xfc ff ff ff(小端:-4)
    • P爲被修正的未知,當連接成可執行文件時,這個值應該是被修正位置的虛擬地址,也就是0x1000+0x27

最後重定位入口修正後地址爲0x2000 + (-4) - (0x1000 + 0x27) = 0xFD5,即:

1549943316258

重定位表

重定位表專門用於保存與重定位相關的信息,它在ELF文件中每每是一個或者多個段。對於可重定位ELF文件來講,一個重定位表每每就是ELF文件中的一個段,因此重定位表也能夠叫作重定位段。好比,代碼段「.text」若是有要被重定位的地方,那麼就會有一個相對應的「.rel.text」的段保存代碼段的重定位表,可以使用objdump來查看目標文件的重定位表:

objdump -r a.o
複製代碼

1549001595525

  • 每一個要被重定位的地方叫作重定位入口,咱們能夠看到」a.o「有兩個重定位入口(Relocation Entry)
  • 偏移:表示該入口在段中的位置
  • RELOCATION RECORDS FOR [.text]表示這個重定位表是代碼段的重定位表

重定位表的結構是一個Elf64_Rel or Elf32_Rel結構,以下

struct Elf32_Rel
{
  Elf32_Addr  r_offset;  /* Address */
  Elf32_Word  r_info;    /* Relocation type and symbol index */
};
struct Elf64_Rel
{
  Elf64_Addr  r_offset;  /* Address */
  Elf64_Xword r_info;    /* Relocation type and symbol index */
};
複製代碼

1549002681392

符號解析

連接是由於咱們的目標文件中用到的符號被定義在其餘目標文件當中,若是咱們直接使用ld來連接「a.o」,而不將「b.o」做爲輸入,則會出現sharedswap兩個符號未定義的狀況:

1549878517301

在開發過程當中,發生這種狀況的緣由有不少,最多見的狀況通常都是連接時缺乏某個庫文件或者輸入目標文件路徑不正確或者符號的聲明和定義不同。所以,從普通程序員的角度看,符號的解析佔據了連接過程的主要內容

其實,重定位過程也伴隨着符號的解析過程,每個目標文件均可能定義一些符號,也可能引用到定義在其餘目標文件的符號。重定位過程當中,每一個重定位的入口都是對一個符號的引用,當鏈接器須要對某個符號的引用進行重定位時,就要肯定這個符號的目標地址。此時,連接器就會去查找全部輸入目標文件的符號表組成的全局符號表,找到對應的符號後進行重定位。

好比查看「a.o」的符號表

1549879012254

其中UND表示undefined未定義類型。這種未定義的符號是由於該目標文件中有關於他們的重定位項。因此連接器掃描完全部輸入目標文件以後,這些未定義的符號都應該能夠在全局符號表中找到,不然就會報符號未定義錯誤。

指令修正方式

不一樣的處理器指令對與地址的格式和方式都不同。但總的來講尋址方式有以下幾個方面:

  • 近址尋址或遠址尋址

  • 絕對尋址或相對尋址

  • 尋址長度爲8位、16位、32位或64位

    可是對於32位x86平臺下ELF文件重定位入口所修正的指令尋址方式只有兩種:

  • 絕對近址32位尋址

  • 相對近址32位尋址

書中的尋址方式是這個:

1549880971583

可是在公司,我機器是64位的,尋址方式是這個:

1549881132972

也就是R_X86_64_32R_X86_64_PC32,網上也沒找到對應的資料,哪位大佬若是知道懇請指導,或者在git上面提issue。

不過我研究了一下,R_X86_64_32的尋址方式,彷佛是直接將地址直接寫入修復便可:

1549934038967

家裏機器確定會不同的結果,所以這裏留一個坑待填。

COMMON塊(涉及弱符號的理解)

在C語言中,函數和初始化的全局變量(包括顯示初始化爲0)是強符號,未初始化的全局變量是弱符號。咱們也能夠經過GCC的"__attribute__((weak))"來定義任何一個強符號爲弱符號。

對於它們,下列三條規則使用:

① 同名的強符號只能有一個,不然編譯器報"重複定義"錯誤。

② 容許一個強符號和多個弱符號,但定義會選擇強符號的。

③ 當有多個弱符號相同時,連接器選擇佔用內存空間最大的那個。

若是一個弱符號定義在多個目標文件中,它們的類型又不一樣,而連接器自己有不支持符號類型,即變量類型對於連接器來講是透明的,此時若是類型不一致應該如何處理呢?主要分如下集中狀況:

  • 兩個或者兩個以上強符號類型不一致
  • 一個強符號,其餘都是弱符號,出現類型不一致
  • 兩個或者兩個以上弱符號不一致

第一種狀況是無需額外處理的,多強符號定義自己便是非法,連接器將報多重定義錯誤,連接器要處理後兩種狀況。

此時,COMMOM塊機制出現。以SimpleSection.c爲例子,符號表以下:

1549936124323

這裏能夠看到符號global_uninit_varGLOBAL數據對象,大小爲4,類型爲COM,而實際上該變量爲弱類型 int 類型變量

另外編寫一個Common.c文件內容以下:

double global_uninit_var = 24;
複製代碼
  • gcc -c Common.c SimpleSection.c
  • gcc -o Common Common.o SimpleSection.o
  • readelf -s Common

獲得以下結果:

1549937921207

能夠看到,size變成了8

但若是將SimpleSample.c裏面的global_uninit_var改成double類型,把Common.c裏面的global_uninit_var改成int類型,再執行可得以下警告:

1549938109105

這是由於弱符號大小大於強符號大小所致,此時結果以下,大小是4:

1549938231992

若是Common.c裏面的global_uninit_var也改成弱符號,則獲得的符號大小爲8

1549938461555

最後.bbs段大小爲8,即最終爲初始化全局變量仍是放在了bbs段。

1549938857040

這個時候咱們能夠得出以下結論:

  • 當強符號與弱符號同時存在時,最後獲得的符號大小取決於強符號
  • 多個弱符號時,大小取決於比較大的那個
  • 最後讀取完全部輸入目標文件之後,弱符號最終仍是放在了BBS段

咱們能夠想到,當編譯器將一個編譯單元編譯成目標文件的時候,若是該編譯單元包含了弱符號(未初始化的全局變量就是典型的弱符號),那麼該弱符號最終所佔空間的大小此時是未知的,由於有可能其餘編譯單元中同符號名稱的弱符號所佔的空間比本編譯單元該符號所佔的空間要大。因此編譯器此時沒法爲該弱符號在BSS段分配空間,由於所須要的空間大小此時是未知的。可是連接器在連接過程當中能夠肯定弱符號的大小,由於當連接器讀取全部輸入目標文件後,任何一個弱符號的最終大小均可以肯定了,因此它能夠在最終的輸出文件的BSS段爲其分配空間。因此整體來看,未初始化的全局變量仍是被放在BSS段

GCC的-fno-common選項容許咱們把全部爲初始化的全局變量不以COMMON塊的形式處理,或者使用__attribute__擴展:int global ____attribute__((nocommon));

C++相關問題

主要有兩個:

  • 重複代碼消除
  • 全局構造與析構
重複代碼消除

C++編譯器會產生重複代碼,如模板(Templates),外部內聯函數(ExternInline Function)和虛函數表(Virtual Function Table)均可能在不一樣的編譯單元中生成相同代碼。

有效作法是將每一個模板實例單獨放在一個段裏面,每一個段包含一個模板實例。好比add<T>(),某個編譯單元以int類型和float類型實例化該模板函數,那麼目標文件就包含了該模板實例的段,如.tmp.add<int>.tmp.add<float>,當其餘編譯單元也須要相同的方式實例化該模板函數後,也會使用相同的名字,這樣在連接器最終連接的時候能夠區分這些相同的模板實例段,而後將它們併入最後的代碼段。

GCC把相似最終連接時合併的段叫作Link Once,將這種類型的段命名爲.gnu.linkonce.name,其中name是該模板函數實例的修飾後名稱。

而VISUAL C++則將該類型的段叫作「COMDAT」,連接器會根據這個標記,在鏈接時將重複的段丟棄。

可是,當相同名稱的段可能有不一樣的內容,這多是不一樣編譯單元使用的編譯器版本或編譯優化選項不一樣。這時連接器頗有可能隨意選擇其中一個副本做爲連接的輸入,而後提供一個警告信息,一般狀況下,這種信息是不能隨意忽略的。

函數級別連接

這是VISUAL C++提供的編譯選項「函數級別連接」,可將函數象上述方式那樣把函數放在單獨的段中,能夠作到沒有用到的函數則將它拋棄。

GCC 也提供相似的機制

  • -ffunction-sections:將函數分別保持到獨立的段中
  • -fdata-sections:將變量分別保持到獨立的段中
全局構造和析構

Linux系統下通常程序入口爲_start,這個函數是Linux系統庫(Glibc)的一部分。當程序和Glibc庫連接到一塊兒造成最終可執行文件之後,這個函數就是程序的初始化部分入口。

ELF文件定義以下兩個特殊段

  • .init

    該段保存可執行指令,構成進程的初始化代碼,main函數被調用前,Glibc的初始化部分安排執行這個段中的代碼。

  • .fini

    該段保存着進程終止代碼指令。當main函數正常退出時,Glibc會安排執行這個段中的代碼。

利用這個特性,C++全局構造和析構函數便由此實現。

靜態連接庫

靜態連接庫實際上能夠當作是一組目標文件的集合。使用ar壓縮程序可將這些目標文件壓縮在一塊兒,並對其進行編號和索引,以便於查找和檢索。

  • ar -t libc.a

    查看文件包含哪些目標文件

  • objdump -t libc.a grep xxx

    查找某個函數所在目標文件

  • ar -x libc.a

    解壓出目標文件

連接過程能夠十分複雜,以printf函數爲例:

1549941968960
相關文章
相關標籤/搜索