1. 程式人生 > >讀書筆記--《程式設計師的自我修養》第4章:靜態連結(1)

讀書筆記--《程式設計師的自我修養》第4章:靜態連結(1)

本章以 如何將a.c檔案與b.c檔案連結成一個可執行檔案 來探討如何進行靜態連結

其中a.c和b.c檔案如下:
a.c檔案

extern int shared;
int main()
{
    int a = 100;
    swap(&a,&shared);
    }

b.c檔案

int shared = 1;
void swap(int* a, int* b)
{
    *a ^= *b ^= *a ^= *b;
}

首先將他們編譯成目標檔案“a.o”和“b.o”
從程式碼中可以看到,b.c中定義了兩個全域性符號:變數shared和函式swap();a.c中定義了一個全域性符號main;a.c引入了b.c中的shared和swap。接下來把a.o和b.o兩個目標檔案連結成一個可執行檔案ab.

4.1 空間地址分配

對於多個輸入目標檔案,連結器如何將它們的各個段合併到一個輸出檔案?

方法一:按序疊加
缺點:由於地址和空間對齊的關係,會造成記憶體空間大量的碎片

方法二:相似段合併(現在連結器基本都採用這個方法)
”連結器為目標檔案分配地址和空間“:這裡既指在輸出的可執行檔案中的空間、也指裝載後的虛擬地址的空間。
但對.bss段來說只限於後者。

一般分兩步:
(1)空間與地址分配
掃描所有輸入檔案,並獲得它們各個段的長度、屬性和位置;將它們符號表中所有的符號定義和符號引用收集起來,統一放到一個全域性符號表中。
(2)符號解析與重定位
利用上一步獲得的資訊,讀取輸入檔案中段的資料、重定位資訊,並進行符號解析和重定位。

使用ld連結器,將目標檔案連結起來:
$ ld a.o b.o -e main -o ab

  1. -e main 表示將main函式作為程式的入口;ld連結器預設程式入口是_start。
  2. -o ab 表示連結輸出檔名是ab。

    objdump -h a.o 引數-h表示顯示處a.o的段頭資訊
    這裡寫圖片描述
    這裡寫圖片描述
    可以看出,連結後.text的長度=兩個目標檔案中.text的長度之和;
    連結前所有的VMA都是0,因為他們還未被分配虛擬地址空間,預設為0.

    問題:為什麼連結器要將可執行檔案ab的.text分配到0x4000e8,將.data分配到0x6001b8,而不是從虛擬空間的0地址開始分配呢?答案:不知道!

4.1.3 符號地址的確定

連結器首先根據前面的空間分配方法,對各個段的虛擬地址進行確定。如.text段的起始位置是:0x4000e8,.data段的起始位置是:0x6001b8。然後根據各個符號在段內的偏移,確定其真正的虛擬地址。只需對段的基址加上偏移量即可。
用gdb打印出符號的位置:
這裡寫圖片描述
說明main函式在.text段的偏移為0,shared在.data的偏移也為0.

4.2 符號解析與重定位

用objdump -d a.o 檢視a.o檔案的反彙編結果:
這裡寫圖片描述

(1)a.o檔案中對shared的引用為偏移為0x13的位置:be 00 00 00 00
它的作用是將shared的地址賦值到esi暫存器中,該指令一共5個位元組,第1個位元組是指令碼,後4個位元組是shared的地址。
由於原始碼在編譯時,編譯器不知道shared的地址,因此把0作為它們的地址。

(2)a.o檔案中對swap的引用為偏移為0x20的位置:e8 00 00 00 00
該指令一共5個位元組,前面的0xe8是操作碼。根據Intel的IA-32體系軟體開發者手冊,這是一條近址相對位移呼叫指令。
後面4個位元組是被呼叫的函式相對呼叫指令的下一條指令的偏移量。

用objdump -d ab命令檢視ab的反彙編程式碼:
這裡寫圖片描述
發現確實,shared的地址變為0x6001b8,而swap函式的地址為0x40010d+0x7 = 0x400114

4.2.2 重定位表

重定位表用來儲存與重定位相關的資訊。每個要被重定位的ELF段都有一個重定位表。一個重定位表往往是ELF檔案中的一個段。如程式碼段有要被重定位的地方,那麼就有’.ref.text’的段儲存了程式碼段的重定位表。

objdump -r a.o 檢視a.o檔案的重定位表
這裡寫圖片描述

每個要被重定位的地方叫一個重定位入口。重定位入口的偏移表示該入口在要被重定位段中的位置。根據前面的反彙編結果,這裡的0x14和0x21分別是程式碼段中shared和swap的地址所在的位置。

對於32位的Intel x86系列處理器來說,重定位表是一個Elf32_Rel結構的陣列,每個陣列元素對應一個重定位入口。
typedef struct {
Elf32_Addr r_offset; //重定位入口的偏移。
Elf32_Word r_info; //重定位入口的型別和符號。
}Elf32_Rel;

r_offset:對可重定位檔案來說,是該重定位入口所要修正的位置的第一個位元組相對於段起始地偏移。
r_info:低8位表示重定位入口的型別;高24位表示重定位入口的符號在符號表中的下標。

4.2.3 符號解析
輸入命令:$ ld a.o
這裡寫圖片描述
發現找不到shared和swap的定義。這是因為我們沒有連結b.o檔案,當然找不到嘍

輸入命令:$readelf -s a.o
這裡寫圖片描述
“GLOBAL”型別的符號,出來main函式定義了之外,其他shared和swap都是“UND”,未定義型別。這種未定義的符號是因為該目標檔案中有關於它們的重定向項。所以連結器掃描完所有的輸入目標檔案後,所有這些未定義的符號都應該能在全域性符號表中找到,否則聯結器就報符號未定義錯誤。

4.2.4 指令修正方式

不同處理器指令對地址的格式和方式都不一樣。定址方式有多種,如:
近址定址或遠址定址;絕對定址或相對定址;定址長度有8、16、32、64位等區別。

但對於32位x86平臺的ELF檔案的重定位入口所修正的指令定址方式,只有兩種;
絕對近址32位定址和相對近址32位定址。

重定位入口的r_info成員低8位表示重定位入口型別,如表所示:
這裡寫圖片描述
其中,A=儲存在被修正位置的值;P=被修正的位置(相對於段開始的偏移量或虛擬地址);S=符號的實際地址(r_info的高24位)

對照前面的a.o的重定位資訊,第一個重定位入口是對swap符號的引用,它是一條相對位移呼叫指令;而shared是R_386_32型別的,它修正的是一條傳輸指令的源,該傳輸指令的源是一個立即數,即shared的絕對地址。

假設將a.o和b.o連結成可執行檔案後,main函式的虛擬地址是0x1000,swap是0x2000;shared的是0x3000。那麼如何修正重定位入口呢?

(1)對於shared變數。是絕對地址修正。結果應該是S+A.
S是符號shared的實際地址,即0x3000,A是被修正位置的值,是0x0。因此修正後的地址是
0x3000+0x0 = 0x3000

(2)對於swap函式。是相對地址修正。結果應該是S+A-P.
S是符號swap的實際地址,即0x2000,A是被修正位置的值,是0x0。P為被修正的位置,當連結成可執行檔案時,這個值應該是被修正位置的虛擬地址,即0x1000+0x21
因此這個重定位入口修正後地址為:0x2000+0-(0x1000+0x21)=0xfdf
**

這一步沒法驗證,因為不知道怎麼打印出重定位過程資訊。。。具體看書

**

4.3 COMMON塊

由於弱符號機制允許同一個符號的定義存在多個檔案中。但是如果一個弱符號定義在多個目標檔案中,它們的型別又不同。而聯結器本身並不支援符號的型別,那麼連結器該如何處理呢?

考慮到三種情況:
(1)兩個或兩個以上強符號不一致;
(2)有一個強符號,其他都是弱符號,出現型別不一致;
(3)兩個或兩個以上弱符號型別不一致;

對於第(1)中情況,無須額外處理,連結器會報符號多重定義錯誤;連結器處理的是後兩種情況。

事實上,現在的編譯器和連結器支援一種叫做COMMON塊的機制。當不同的目標檔案需要的COMMON塊空間大小不一致時,以最大的那塊為準。

COMMON型別的連結規則是針對符號都是弱符號的情況,如果其中有一個符號是強符號,則輸出結果中符號所佔空間與強符號相同。如果連結過程又弱符號大小大於強符號,那麼ld連結器會報錯。

問題:為什麼編譯器不直接把未初始化的全域性變數也當作未初始化的區域性靜態變數一樣處理,在.bss段為其分配空間,而是將其標記為一個COMMON型別的變數?
回答:當編譯器將一個編譯單元編譯成目標檔案時,如果包含弱符號,則其所佔空間是未知的,因為有可能在連結到其他檔案中也定義了該弱符號,而弱符號以佔用空間大的為準,因此無法確定在.bss段為其分配多少空間。而連結時,則大小就能確定了,所以它可以在最終輸出檔案的.bss段為其分配空間。所以總體來看,未初始化全域性變數還是放在.bss段的。

GCC的“-fno-common”允許我們把未初始化的全域性變數不以COMMON塊的形式處理,或者使用“attribute”擴充套件:

int global __attribute__((nocommon));

一旦一個未初始化的全域性變數不以COMMON塊的形式存在,它就相當於一個強符號。如果其他目標檔案中還有一個變數的強符號定義,連結時就會發生符號重複定義錯誤。