《程式設計師的自我修養筆記之靜態連結》

amlloc發表於2019-02-18

如果想徹底弄懂Android程式碼保護的基本原理,《Unix環境高階程式設計》和《程式設計師的自我修養》是必讀書目。在此作讀書筆記

第二章

程式原始碼到最終可執行檔案的4個步驟:

  • 預編譯

主要處理那些原始碼檔案中以"#"開始的預編譯指令

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

對預編譯生成的檔案進行詞法分析,語法分析,語義分析,中間語言生成,目的碼生成及優化生成彙編程式碼檔案

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

彙編器將彙編程式碼轉換成可執行指令,輸出目標檔案

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程式碼如下:

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)

其他段內容

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

相關文章