程式的一生:從源程式到程序的辛苦歷程
一、前言
作為計算機專業的人,最遺憾的就是在學習編譯原理的那個學期被別的老師拉去幹活了,而對一個程式怎麼就從原始碼變成了一個在記憶體裡活靈活現的程序,一直也心懷好奇。這種好奇驅使我要找個機會深入瞭解一下,所以便有了本文,來督促自己深入研究程式的一生。不過,本文沒有深入研究編譯原理、作業系統原理,而是主要聚焦於程式的連結和載入。
學習的過程中主要參考了三本書、一個視訊、一個音訊(文末有列出),三本書裡,最主要的還是《程式設計師的自我修養 - 連結、裝載與庫》,裡面的程式碼放到了我的github上,並且配有shell指令碼和說明,執行後可以實操理解到更多內容。
南大袁春風老師的計算機原理講解對我幫助最大,視訊是最直接傳達知識的方式。另外,為了方便自己的實驗,製作了一個ubuntu的環境,並且內建了程式碼,方便實驗:阿里docker映象
docker pull registry.cn-hangzhou.aliyuncs.com/piginzoo/learn:1.0
二、概述
每天都有無數的程式被編譯、部署,不停地跑著,它們幹著千奇百怪的事情。如同這個光怪陸離的世界,是由每個人、每個個體組成的,如果我們剖析每個人,會發現他們其實都是一樣的結構,都是由細胞、組織組成,再深究便是基因了,DNA裡那一個個的“核苷酸基”決定了他們。
同樣,通過這個隱喻來認知計算機,我們可以知道,計算機的基因和本質就是馮諾依曼體系。啥是馮諾依曼體系呢?通俗地講,就是定義了整個硬體體系(CPU、外存、輸入輸出),以及執行的執行流程等等。可是,一個程式怎麼就與硬體親密無間地執行起來了呢?應該很多人都不瞭解,甚至包括許多計算機專業的同學們。
本質上來說,這個過程其實就是“從程式碼編譯,然後不同目標檔案連結,最終載入到記憶體中,被作業系統管理起來的一個程序,可能還會動態地再去連結其他的一些程式(如動態連結庫)的過程”。看起來似乎很簡單,但其實每個部分都隱藏著很多細節,好奇心很強的你一定想知道,到底計算機是怎麼做到的。
本文不打算討論硬體、程序、網路等如此龐大的體系,只聚焦於探索程式的連結和載入這兩個主題。
三、基礎
探索之前需要交代一些基礎知識,不然無法理解連結和載入。
3.1 硬體基礎
3.1.1 CPU
CPU由一大堆暫存器、算數邏輯單元(就是做運算的)、控制器組成。每次通過PC(程式計數器,存著指令地址)暫存器去記憶體裡定址可執行二進位制程式碼,然後載入到指令暫存器裡,如果涉及到地址的話,再去記憶體里加載資料,計算完後寫回到記憶體裡。每條指令都會放到指令暫存器(IR)中,等著CPU去取出來執行。
指令是從硬碟載入到記憶體裡,又從記憶體里加載到IR裡面的。指令執行過程中需要一些資料,這又要求從記憶體裡取出一些資料放到通用暫存器中,然後交給ALU去運算,結果出來後又會放到暫存器或者記憶體中,周而復始。
每一步都是一個時鐘週期,現在的CPU一秒鐘可以做1G次,是1000000000,幾十億次/秒。目前市場上的CPU主頻據說到4GHz就到極限了,限於工藝,上不去了,所以慢慢轉為多核,就是把幾個CPU封裝到一起共享內部快取。
3.1.2 主機板
如圖,我們經常聽說的“北橋、南橋”是什麼?
北橋其實就是一個計算機結構,準確地說是一個晶片,它連線的都是高速裝置,通過PCI匯流排,把cpu、記憶體、顯示卡串在一起;而南橋就要慢很多了,連線的都是滑鼠、鍵盤、硬碟等這些“窮慢”親戚,它們之間用ISA匯流排串在一起。
3.1.3 硬碟
硬碟硬體上是碟片、磁軌、扇區這樣的一個結構,太複雜了,所以從頭到尾給這些扇區編個號,就是所謂的“LBA(Logical Block Address)”邏輯扇區的概念,方便定址。
為了隔離,每個程序有一個自己的虛擬地址空間,然後想辦法給它對映到實體記憶體裡。如果記憶體不夠怎麼辦?就想到了再細分,就是分頁,分成4k的一個小頁,常用的在記憶體裡,不常用的交換到磁碟上。這就要經常用到地址對映計算(從虛擬地址到實體地址),這個工作就是MMU(Memory Management Unit),為了快都整合到CPU裡面了。
3.1.4 輸入輸出裝置
還有很多外設負責輸入輸出,一旦被外界輸入或要輸出東西,就得去告訴CPU:“我有東西了,來取吧”;“我要輸出啦,來幫我輸出吧”。這些工作就要靠一個叫“中斷”的機制,可以將“中斷”理解成一種訊息機制,用於通知CPU來幫我幹活。不是每個部分都可以直接騷擾CPU的,它們都要通過中斷控制器來集中騷擾CPU。
這些外設都有自己的buffer,這些buffer也得有地址,這個地址叫埠。
還得給每個裝置編個號,這樣系統才能識別誰是誰。每次中斷,CPU一看,噢,原來是05,05是鍵盤啊;06,06是滑鼠啊。這個號,叫中斷編號(IRQ)。
每次都必須要騷擾CPU嗎?直接把資料從外設的buffer(埠)灌到記憶體裡,不用CPU參與,多好啊!對,這個做法就是DMA。每個DMA裝置也得編個號,這個編號就是DMA通道,這些號可不能衝突哦。
3.2 彙編基礎
對於彙編,我其實也忘光了,所以得補補彙編知識了,起碼要能讀懂一些基礎的彙編指令。
3.2.1 彙編語法
彙編分門派呢!”AT&T語法” vs “Intel語法”:GUN GCC使用傳統的AT&T語法,它在Unix-like作業系統上使用,而不是dos和windows系統上通常使用的Intel語法。
最常見的AT&T語法的指令:movl、%esp、%ebp。movl是一個最常見的彙編指令的名稱,百分號表示esp和ebp是暫存器。在AT&T語法中,有兩個引數的時候,始終先給出源source
,然後再給出目標destination
。
AT&T語法:
<指令> [源] [目標]
3.2.2 暫存器
暫存器是存放各種給cpu計算用的地址、資料用的,可以認為是為CPU計算準備資料用的。一般分為8類:
種類 | 功能 | |
---|---|---|
累加暫存器 | 儲存執行運算的資料和運算後的資料。 | 就是放計算用的數,算之前,算完後的 |
標誌暫存器 | 儲存運算處理後的CPU的狀態。 | 一般溢位啊,或者JMP的時候看條件用的 |
程式計數器 | 儲存下一條指令所在記憶體的地址。 | 存著指令的地址,讀他才能找到程式碼在哪,程式碼定址用的 |
基址暫存器 | 儲存資料記憶體的起始地址。 | 讀記憶體用的,不過只放起始地址,定址用的 |
變址暫存器 | 儲存基址暫存器的相對地址。 | 讀記憶體用的,不過只放偏移地址,定址用的 |
通用暫存器 | 儲存任意資料。 | 這個是放任意資料用的,我怎麼覺得累加暫存器有點雞肋了,用它不就得了 |
指令暫存器 | 儲存指令。CPU內部使用,程式設計師無法通過程式對該暫存器進行讀寫操作。 | 存執行指令用的 |
棧暫存器 | 儲存棧區域的起始地址。 | 定址用的,永遠指著當前棧的棧頂地址(記憶體的) |
命名上,x86一般是指32位;x86-64一般是指64位。32位暫存器,一般都是e開頭,如eax、ebx;64位暫存器約定以r開頭,如rax、rbx。
1)32位暫存器
32位CPU一共有8個暫存器。
詳細的介紹:
2)64位暫存器有:32個
兩者的區別:
- 64位有16個暫存器,32位只有8個。但32位前8個都有不同的命名,分別是e _ ,而64位前8個使用了r代替e,也就是r 。e開頭的暫存器命名依然可以直接運用於相應暫存器的低32位。而剩下的暫存器名則是從r8 - r15,其低位分別用d,w,b指定長度。
- 32位暫存器使用棧幀作為傳遞引數的儲存位置,而64位暫存器分別用rdi、rsi、rdx、rcx、r8、r9作為第1-6個引數,rax作為返回值。
- 32位暫存器用ebp作為棧幀指標,64位暫存器取消了這個設定,沒有棧幀的指標,rbp作為通用暫存器使用。
- 64位暫存器支援一些形式以PC相關的定址,而32位只有在jmp的時候才會用到這種定址方式。
對了,暫存器可不是L1、L2 cache啊!Cache位於CPU與主記憶體間,分為一級Cache (L1Cache)和二級Cache (L2Cache),L1 Cache整合在CPU內部,L2 Cache早期在主機板上,現在也都整合在CPU內部了,常見的容量有256KB或512KB。暫存器很少的,拿64位的來說,也就是16個,64x16,也就是1024,1K。
總結:大致來說資料是通過記憶體-Cache-暫存器,Cache快取是為了彌補CPU與記憶體之間運算速度的差異而設定的部件。
3.2.3 定址方式
接下來說說定址,定址就是告訴CPU去哪裡取指令、資料。比如movl %rax %rbx
,這個涉及到定址,定址會尋“暫存器”、“記憶體”,可以是暴力的直接定址,也可以是委婉的間接定址。下面是各種定址方式:
你可能會看到這種指令movl,movw,mov
後面的l、w是什麼鬼?
就是一次搬運的資料數量。
3.2.4 常用的指令
最後說說指令本身,每個CPU型別都有自己的指令集,就是告訴CPU幹啥,比如加、減、移動、呼叫函式等。下面是一些非常常用的指令:
參考:願意自虐的同學,可以下載【Intel官方的指令集手冊】仔細研讀。
3.3 一些工具和玩法
本文還會涉及到一些工具:
- gcc:超級編譯工具,可以做預編譯、編譯成彙編程式碼、靜態連結、動態連結等,本質上是各種編譯過程工具的一個封裝器。
- gdb:太強了,命令列的除錯工具,簡直是上天入地的利器。
- readelf:可以把一個可執行檔案、目標檔案完全展示出來,讓你觀瞧。
- objdump:跟readelf功能差不多,不過貌似它依賴一個叫“bfd庫”的玩意兒,我也沒研究,另外,它有個readelf不具備的功能:反編譯。剩下的兩者都差不多了。
- ldd:這個小工具也很酷,可以讓你看一個動態連結庫檔案依賴於哪些其它的動態連結庫。
cat /proc/<PID>/maps
:這個命令很有趣,可以讓你看到程序的記憶體分佈。
還有各種利器,自己去探索吧。
3.4 其他
3.4.1 地址編碼
假如有個整形變數1234,16進位制是0x000004d2,佔4個位元組,起始地址是0x10000,終止地址是0x10003,那麼在外界看來,是它的地址是0x10000還是0x10003呢?答案是0x10000。
那麼問題來了,這4個位元組裡怎麼放這個數?高地址放高位,還是低地址放高位?答案是,都可以!
大端方式:高位在低地址,如 IBM360/370,MIPS
小端方式:高位在高地址,如 Intel 80x86
四、編譯
由於我沒學過編譯,對詞法分析、語法分析也不甚瞭解,找機會再深入吧,這裡只是把大致知識梳理一下。
詞法分析->語法分析->語義分析->中間程式碼生成->目的碼生成
4.1 詞法分析
通過FSM(有限狀態機)模型,就是按照語法定義好的樣子,挨個掃描原始碼,把其中的每個單詞和符號做個歸類,比如是關鍵字、識別符號、字串還是數字的值等,然後分門別類地放到各個表中(符號表、文字表)。如果不符合語法規則,在詞法分析過程中就會給出各類警告,咱們在編譯過程中看到的很多語法錯誤就是它乾的。有個開源的lex的程式,可以體會這個過程。
4.2 語法分析
由詞法分析的符號表,要形成一個抽象語法樹,方法是“上下文無關語法(CFG)”。這過程就是把程式表示成一棵樹,葉子節點就是符號和數字,自上而下組合成語句,也就是表示式,層層遞迴,從而形成整個程式的語法樹。同上面的詞法分析一樣,也有個開源專案可以幫你做這個樹的構建,就是yacc(Yet Another Compiler Compiler)。
4.3 語義分析
這個步驟,我理解要比語法分析工作量小一些,主要就是做一些型別匹配、型別轉換的工作,然後把這些資訊更新到語法樹上。
4.4. 中間語言生成
把抽象語法樹轉成一條條順序的中間程式碼,這種中間程式碼往往採用三地址碼或者P-Code的格式,形如x = y op z。長成這個樣子:
t1 = 2 + 6 array[index] = t1
不過這些程式碼是和硬體不相關的,還是“抽象”程式碼。
4.5 目的碼生成
目的碼生成就是把中間程式碼轉換成目標機器程式碼,這就需要和真正的硬體以及作業系統打交道了,要按照目標CPU和作業系統把中間程式碼翻譯成符合目標硬體和作業系統的彙編指令,而且,還要給變數們分配暫存器、規定長度,最後得到了一堆彙編指令。
對於整形、浮點、字串,都可以翻譯成把幾個bytes的資料初始化到某某暫存器中,但是對於陣列等其它的大的資料結構,就要涉及到為它們分配空間了,這樣才可以確定陣列中某個index的地址。不過,這事兒編譯不做,留給連結去做。
編譯不是本文重點,這裡就不過多討論了,感興趣的同學,可以讀讀這篇:《自己動手寫編譯器》。
五、連結
編譯一個c原始檔程式碼,就會對應得到一個目標檔案。一個專案中會有一堆的c原始碼,編譯後會得到一堆的目標檔案。這些目標檔案是二進位制的,就是一堆0、1的集合,到底這一堆0、1是如何排布的呢?接下來,我們得說一說,這些0、1組成的目標檔案了。
5.1 目標檔案
目標檔案是沒有連結的檔案(一個目標檔案可能會依賴其它目標檔案,把它們“串”起來的過程,就是連結)。這些目標檔案已經和這臺電腦的硬體及作業系統相關了,比如暫存器、資料長度,但是,對應的變數的地址沒有確定。
目標檔案裡有資料、機器指令程式碼、符號表(符號表就是原始碼裡那些函式名、變數名和程式碼的對應關係,後面會細講)和一些除錯資訊。
目的碼的結構依據COFF(Common File Format)規範。Windows和Linux的可執行檔案(PE和ELF)就是尊崇這種規範。大家用的都是COFF格式,動態連結庫也是。通過linux下的file命令可以參看目標檔案、elf可執行檔案、shell檔案等。
file /lib/x86_64-linux-gnu/libc-2.27.so /lib/x86_64-linux-gnu/libc-2.27.so: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /lib64/l, BuildID[sha1]=b417c0ba7cc5cf06d1d1bed6652cedb9253c60d0, for GNU/Linux 3.2.0, stripped file run.sh run.sh: Bourne-Again shell script, UTF-8 Unicode text executable file a.o a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped file ab ab: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped
如上可以看到不同檔案的區別。
5.2 目標檔案的結構
ELF是Executable LinkableFormat的縮寫,是Linux的連結、可執行、共享庫的格式標準,尊從COFF。
Linux下的目標ELF檔案(或可執行ELF檔案)的結構包括:
- ELF頭部
- .text
- .data
- .bss
- 其他段
- 段表
- 符號表
ELF檔案的結構包含ELF的頭部說明和各種“段”(section)。段是一個邏輯單元,包含各種各樣的資訊,比如程式碼(.text)、資料(.data)、符號等。
5.2.1 檔案頭(ELF Header)
先說說ELF檔案開頭部分的ELF頭,它是一個總的ELF的說明,裡面包含是否可執行、目標硬體、作業系統等資訊,還包含一個重要的東西:“段表”,就是用來記錄段(section)的資訊。
看個例子:
ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 816 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 64 (bytes) Number of section headers: 12 Section header string table index: 11
說明:
- 其中,”7f 45 4c 46”是ELF魔法數,就是DEL字元加上“ELF”3個字母,表明它是一個elf目標或者可執行檔案關於elf檔案頭格式。
- 還會說明諸如可執行程式碼起始的入口地址;段表的位置;程式表的位置;….多種資訊。細節就不贅述了。
關於更詳細的elf檔案頭的內容,可以參考:
- ELF 格式解析
- ELF檔案格式解析
- ELF檔案格式分析
- ELF檔案結構
5.2.2 段表(section table)
除了elf檔案頭,就屬段表重要了,各個段的資訊都在這裡。先看個例子:
命令readelf -S ab
可以幫助檢視ELF檔案的段表。
There are 9 section headers, starting at offset 0x1208: Section Headers: [Nr] Name Type Addr Off Size ES Flg Lk Inf Al [ 0] NULL 00000000 000000 000000 00 0 0 0 [ 1] .text PROGBITS 08048094 000094 000091 00 AX 0 0 1 [ 2] .eh_frame PROGBITS 08048128 000128 000080 00 A 0 0 4 [ 3] .got.plt PROGBITS 0804a000 001000 00000c 04 WA 0 0 4 [ 4] .data PROGBITS 0804a00c 00100c 000008 00 WA 0 0 4 [ 5] .comment PROGBITS 00000000 001014 00002b 01 MS 0 0 1 [ 6] .symtab SYMTAB 00000000 001040 000120 10 7 10 4 [ 7] .strtab STRTAB 00000000 001160 000063 00 0 0 1 [ 8] .shstrtab STRTAB 00000000 0011c3 000043 00 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), p (processor specific)
這個可執行檔案裡有9個段。常見的3個段:程式碼段、資料段、BSS段:
- 程式碼段:.code或.text;
- 資料段:.data,放全域性變數和區域性靜態變數;
- BSS段:.bss,為未初始化的全域性變數和區域性靜態變數預留位置,不佔空間。
還有其它段:
- .strtab : String Table 字串表,用於儲存 ELF 檔案中用到的各種字串;
- .symtab : Symbol Table 符號表,從這裡可以索引檔案中的各個符號;
- .shstrtab : 各個段的名稱表,實際上是由各個段的名字組成的一個字串陣列;
- .hash : 符號雜湊表;
- .line : 除錯時的行號表,即原始碼行號與編譯後指令的對應表;
- .dynamic : 動態連結資訊;
- .debug : 除錯資訊;
- .comment : 存放編譯器版本資訊,比如 “GCC:GNU4.2.0”;
- .plt 和 .got : 動態連結的跳轉表和全域性入口表;
- .init 和 .fini : 程式初始化和終結程式碼段;
- .rodata1 : Read Only Data,只讀資料段,存放字串常量,全域性 const 變數,該段和 .rodata 一樣。
段表裡記錄著每個段開始的位置和位移(offset)、長度,畢竟這些段都是緊密的放在二進位制檔案中,需要段表的描述資訊才能把它們每個段分割開。
有了段,我們其實就對可執行檔案瞭然於心了,其中.text程式碼段裡放著可以執行的機器指令;而.data資料段裡放著全域性變數的初始值;.symtab裡放著當初原始碼中的函式名、變數名的代表的資訊。
目標ELF檔案和可執行ELF檔案雖然規範是一致的,但還是有很多細微區別。
5.2.3 目標ELF檔案的重定位表
在段表中,你會發現這種段:.rel.xxx,這些段就是連結用的!因為你需要把某個目標中出現的函式、變數等的地址,換成其它目標檔案中的位置(也就是地址),這樣才能正確地引用、呼叫這些變數。至於連結細節,後面講連結的時候再說。
一般有text、data兩種重定位表:
- .rel.text:程式碼段重定位表,描述程式碼段中出現的函式、變數的引用地址資訊等;
- .rel.data: 資料段重定位表。
5.2.4 字串表
.strtab、.shstrtab
ELF中很多字串,比如函式名字、變數名字,都放到一個叫“字串”表的段中。
5.2.5 符號表
注意:字串表只是字串,符號表跟它不一樣,符號表更重要,它表示了各個函式、變數的名字對應的程式碼或者記憶體地址,在連結的時候,非常有用。因為連結就是要找各個變數和函式的位置,這樣才可以更新編譯階段空出來的函式、變數的引用地址。
每個目標檔案裡都有這麼一個符號表,用nm和readelf可以檢視:
1)a.o目標檔案的符號表
nm a.o
U _GLOBAL_OFFSET_TABLE_ U __stack_chk_fail 0000000000000000 T main U shared U swap
2)readelf -s a.o
目標檔案的符號表:
Symbol table '.symtab' contains 12 entries: Num: Value Size Type Bind Vis Ndx Name 0: 00000000 0 NOTYPE LOCAL DEFAULT UND 1: 00000000 0 FILE LOCAL DEFAULT ABS a.c 2: 00000000 0 SECTION LOCAL DEFAULT 1 3: 00000000 0 SECTION LOCAL DEFAULT 3 4: 00000000 0 SECTION LOCAL DEFAULT 4 5: 00000000 0 SECTION LOCAL DEFAULT 6 6: 00000000 0 SECTION LOCAL DEFAULT 7 7: 00000000 0 SECTION LOCAL DEFAULT 5 8: 00000000 85 FUNC GLOBAL DEFAULT 1 main 9: 00000000 0 NOTYPE GLOBAL DEFAULT UND shared 10: 00000000 0 NOTYPE GLOBAL DEFAULT UND swap 11: 00000000 0 NOTYPE GLOBAL DEFAULT UND __stack_chk_fail
從這個目標ELF檔案的符號表可以看到swap函式,Ndx是UND(Undefined的縮寫),表明不知道它到底在哪個段,需要被重定位,就是寫個1或3之類的數字表明段中的index;對於全域性變數shared也是同樣的定義。這些內容都會在靜態連結的時候,被連結器修改。
為了對比,我們來看可執行檔案ab的符號表的樣子,看看靜態連結後,這些符號的Ndx的變換。
3)可執行檔案ab的符號表
nm ab
0804a000 d _GLOBAL_OFFSET_TABLE_ 0804a014 D __bss_start 080480d7 T __x86.get_pc_thunk.ax 0804a014 D _edata 0804a014 D _end 080480db T main 0804a00c D shared 08048094 T swap 0804a010 D test
readelf -s ab
Symbol table '.symtab' contains 18 entries: Num: Value Size Type Bind Vis Ndx Name 0: 00000000 0 NOTYPE LOCAL DEFAULT UND 1: 08048094 0 SECTION LOCAL DEFAULT 1 2: 08048128 0 SECTION LOCAL DEFAULT 2 3: 0804a000 0 SECTION LOCAL DEFAULT 3 4: 0804a00c 0 SECTION LOCAL DEFAULT 4 5: 00000000 0 SECTION LOCAL DEFAULT 5 6: 00000000 0 FILE LOCAL DEFAULT ABS b.c 7: 00000000 0 FILE LOCAL DEFAULT ABS a.c 8: 00000000 0 FILE LOCAL DEFAULT ABS 9: 0804a000 0 OBJECT LOCAL DEFAULT 3 _GLOBAL_OFFSET_TABLE_ 10: 08048094 67 FUNC GLOBAL DEFAULT 1 swap 11: 080480d7 0 FUNC GLOBAL HIDDEN 1 __x86.get_pc_thunk.ax 12: 0804a010 4 OBJECT GLOBAL DEFAULT 4 test 13: 0804a00c 4 OBJECT GLOBAL DEFAULT 4 shared 14: 0804a014 0 NOTYPE GLOBAL DEFAULT 4 __bss_start 15: 080480db 74 FUNC GLOBAL DEFAULT 1 main 16: 0804a014 0 NOTYPE GLOBAL DEFAULT 4 _edata 17: 0804a014 0 NOTYPE GLOBAL DEFAULT 4 _end
可以看到,現在shared的Ndx是4,而swap的Ndx是1,對應的就是:4-資料段、1-程式碼段。
上面曾經顯示過的段的編號 。。。。 [ 1] .text PROGBITS 08048094 000094 000091 00 AX 0 0 1 [ 2] .eh_frame PROGBITS 08048128 000128 000080 00 A 0 0 4 [ 3] .got.plt PROGBITS 0804a000 001000 00000c 04 WA 0 0 4 [ 4] .data PROGBITS 0804a00c 00100c 000008 00 WA 0 0 4 [ 5] .comment PROGBITS 00000000 001014 00002b 01 MS 0 0 1 。。。
如上,對應的第一列的序號就標明瞭程式碼段是1,資料段是4。
另外,第二列Type也挺有用的:Object表示資料的符號,而Func是函式符號。
六、靜態連結
目標檔案介紹得差不多了,我們得到了一大堆零散的目標ELF檔案,是時候把它們“合體”了,這就需要連結過程了,就是要把這些目標檔案“湊”到一起,也就是把各個段合併到一起。
合併開始!讀每個目標檔案的檔案頭,獲得各個段的資訊,然後做符號重定位。
- 讀每個目標檔案,收集各個段的資訊,然後合併到一起,其實我理解就是壓縮到一起,你的程式碼段挨著我的程式碼段,合併成一個新的,因為每個ELF目標檔案都有檔案頭,是可以很嚴格合併到一起的;
- 符號重定位,簡單來說就是把之前呼叫某個函式的地址給重新調整一下,或者某個變數在data段中的地址重新調整一下。因為合併的時候,各個程式碼段都合併了,對應程式碼中的地址都變了,所以要調整。這是連結最核心的一步!
ld a.o b.o ab
詳細介紹a.o+b.o=> ab的變化,特別是虛擬地址的變化。
先看連結前的目標ELF檔案:a.o,b.o。
a.o的段屬性(objdump -h a.o) ------------------------------------------------------------------------ Idx Name Size VMA LMA File off Algn 0 .text 00000051 0000000000000000 0000000000000000 00000040 2**0 CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE 1 .data 00000000 0000000000000000 0000000000000000 00000091 2**0 CONTENTS, ALLOC, LOAD, DATA 2 .bss 00000000 0000000000000000 0000000000000000 00000091 2**0 ALLOC b.o的段屬性(objdump -h b.o) ------------------------------------------------------------------------ Idx Name Size VMA LMA File off Algn 0 .text 0000004b 0000000000000000 0000000000000000 00000040 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .data 00000008 0000000000000000 0000000000000000 0000008c 2**2 CONTENTS, ALLOC, LOAD, DATA 2 .bss 00000000 0000000000000000 0000000000000000 00000094 2**0 ALLOC
接下來是a.o + b.o,連結合體後的可執行ELF檔案:ab。
ab的段屬性(objdump -h ab) ------------------------------------------------------------------------ Idx Name Size VMA LMA File off Algn 0 .text 00000091 08048094 08048094 00000094 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .eh_frame 00000080 08048128 08048128 00000128 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 2 .got.plt 0000000c 0804a000 0804a000 00001000 2**2 CONTENTS, ALLOC, LOAD, DATA 3 .data 00000008 0804a00c 0804a00c 0000100c 2**2 CONTENTS, ALLOC, LOAD, DATA
我們來玩一玩“找不同”!可執行ELF檔案ab的VMA填充了。VMA是啥?為何需要調整?看來是時候說一說可執行ELF檔案了。
6.1 目標ELF檔案和可執行ELF檔案
上面一直刻意不區分目標ELF檔案和可執行ELF檔案,原因是想先介紹它們共同的ELF規範部分,但其實兩者是有區別的,這一小節忍不住想介紹一下,希望不會打斷看官的思路。
目標ELF檔案和可執行ELF檔案,其實是兩個目的、兩個視角:
- 目標檔案是為了進一步連結用的,我們可以用“連結視角”來看待它,它有各個sections,用段表section head table(SHT)來記錄、歸檔不同的內容,還有重要的重定位表,用於連結;
- 可執行檔案是為“程序視角”存在的,不需要重定位表,但它多了一個 “program header table(PHT)”,用來告訴作業系統如何把各個section加到程序空間的segment中。程序裡專門有個“segment”的概念,定義出“虛擬記憶體區域”(VMA,Virtual Memory Area),每個VMA就是一個segement。這些segment是作業系統為了裝載需要,專門又對sections們做了一次合併,定義出不同用途的VMA(如程式碼VMA、資料VMA、堆VMA、棧VMA)。
- 在目標檔案中,你會看到地址都是從0開始的,但是在可執行檔案中是0x8048000開始的,因為作業系統程序虛擬地址的開始地址就是這個數。關於虛擬地址空間,這裡不展開了,後面講裝載的部分再詳細討論。
雖然兩者有區別,但大體的規範是一樣的,都有ELF頭、段表(section table)、節(section)等基本的組成部分。
可以參考這篇文章《ELF可執行檔案的理解》,加深理解。
6.2 合體的ELF可執行檔案
回來看合體(連結)後的可執行ELF檔案ab。
ab的段屬性(objdump -h ab
):
Idx Name Size VMA LMA File off Algn 0 .text 00000091 08048094 08048094 00000094 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .eh_frame 00000080 08048128 08048128 00000128 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 2 .got.plt 0000000c 0804a000 0804a000 00001000 2**2 CONTENTS, ALLOC, LOAD, DATA 3 .data 00000008 0804a00c 0804a00c 0000100c 2**2 CONTENTS, ALLOC, LOAD, DATA
可以看到,ab的程式碼段.text是從0x8048094開始的,長度是0x91,也就是145個位元組長度的程式碼段。
段的開頭地址確定了,接下來段裡符號對應的地址就好找了(也就是.text段中的函式和.data段中的變數)。
回過頭去看幾個符號:swap函式、main函式、test變數、shared變數:
Num: Value Size Type Bind Vis Ndx Name 10: 08048094 67 FUNC GLOBAL DEFAULT 1 swap 12: 0804a010 4 OBJECT GLOBAL DEFAULT 4 test 13: 0804a00c 4 OBJECT GLOBAL DEFAULT 4 shared 15: 080480db 74 FUNC GLOBAL DEFAULT 1 main
- main函式:地址是080480db,Ndx=1,Type=FUNC,也就是說,main這個符號對應的是一個函式,在程式碼段.text,起始地址是080480db;
- test變數:地址是0804a010,Ndx=4,Type=OBJECT,也就是說,test這個符號對應的是一個變數,在資料段,起始地址是0804a010。
問題來了,這些地址是如何確定的呢?要知道目標ELF檔案a.o、b.o裡的地址還都是0作為基地址的,到合體後的可執行檔案ab怎麼就填充了這些東西呢?這就要引出“符號重定位”了。
6.3 符號重定位
既然連結是把大家的程式碼段、資料段都合併到一起,那就需要修改對應的呼叫的地址,比如a.o要呼叫b.o中的函式,合併到一起成為ab的時候,就需要修改之前a.o中的呼叫的地址為一個新的ab中的地址,也就是之前b.o中的那個函式swap的地址。
連結器通過“重定位 + 符號解析”完成上述工作。
最開始編譯完的目標檔案,變數地址、函式地址的基準地址都是0。一旦連結,就不能從0開始了,而要從作業系統和應用程序規定的虛擬起始地址開始作為基準地址,這個規定是0x08048094
。別問我為什麼,真心不知~
另外,還有這幾個目標檔案的各個段,它們的函式、變數等的地址原本都是基於0,現在合體了,都要開始逐一調整!之前每個函式、變數的地址都是相對於0的,也就是說,你知道它們的偏移offset,這樣的話,你只需要告訴它們新的基地址的調整值,就可以加上之前的offset算出新的地址,把所有涉及到被呼叫的地方都改一遍,就完成了這個重定位的過程。
具體怎麼做呢?通過重定位表來完成。
6.4 重定位表
就是一個表,記著之前每個object目標檔案中哪些函式、變數需要被重定位。這是一個單獨的段,命名還有規律呢!就是.rel.xxx,比如.rel.data、.rel.text。
看個栗子:
RELOCATION RECORDS FOR [.text]: OFFSET TYPE VALUE 0000000000000025 R_X86_64_PC32 shared-0x0000000000000004 0000000000000032 R_X86_64_PLT32 swap-0x0000000000000004
shared變數和swap函式都在a.o的重定位表中被記錄下來,說明它們的地址後期會被調整。offset中的25,就是shared變數對於資料段的起始位置的位移offset是25個位元組;同樣,swap函式相對於程式碼段開始的offset是32個位元組。另外,VALUE這列的“shared、swap”會對應到符號表裡面的shared、swap符號。
重定位表只記錄哪些符號需要重定位,而關於這個函式、變數更詳細的資訊都在符號表中。
接下來精彩的事情發生了,也就是連結中最關鍵的一步:修改連結完成的檔案中呼叫函式和變數引用的地址。
6.5 指令修改
修改函式和資料的應用地址有很多方法,這涉及到各個平臺的定址指令差異,比如R_X86_64_PC32。但本質來講就需要一種計算方法,計算出連結後的程式碼中對函式的呼叫地址、變數的應用地址、進行連結後的修改地址。
對於32位的程式來說,一共有10種重定位的型別。
舉個例子可能更容易理解:檔案a.c,b.c,連結成ab,我們來看連結過程中是如何做指令地址修改的。
先看看原始碼:
a.c
extern int shared; int main() { int a = 0; swap(&a, &shared); }
b.c
int shared = 1; int test = 3; void swap(int* a, int* b) { *a ^= *b ^= *a ^= *b; }
a.c的彙編檔案
00000000 <main>: .... 31: 89 c3 mov %eax,%ebx 33: e8 fc ff ff ff call 34 <main+0x34> <------------- 呼叫swap函式 38: 83 c4 10 add $0x10,%esp .... Relocation section '.rel.text' at offset 0x24c contains 4 entries: Offset Info Type Sym.Value Sym. Name .... 00000034 00000e04 R_386_PLT32 00000000 swap
可以看到目標檔案a.o中的彙編指令和重定位表中為R_386_PLT32
的重定位方式。然後,連結後得到ab的程式碼。
連結後的 ab ELF可執行檔案:
08048094 <swap>: 8048094: 55 push %ebp 8048095: 89 e5 mov %esp,%ebp .... 080480db <main>: .... 804810c: 89 c3 mov %eax,%ebx 804810e: e8 81 ff ff ff call 8048094 <swap> 8048113: 83 c4 10 add $0x10,%esp ....
分析
1)修正後的swap地址是:0x08048094
2)修正後的程式碼地址是: 0x804810e
3)原來的呼叫程式碼: 33: e8 fc ff ff ff call 34 <main+0x34>
,其實是0xfffffffc,補碼錶示的-4
4)先看修改完成的:ab中,804810e: e8 81 ff ff ff call 8048094 <swap>
。e8 fc ff ff ff 修改成了=> e8 81 ff ff ff,補碼錶示是-127
5)這個值是怎麼算的?
a.o的重定位表中的資訊是:00000034 00000e04 R_386_PLT32 00000000 swap
。
所謂R_386_PLT32,是:L+A-P
- L:重定項中VALUE成員所指符號@plt的記憶體地址 => 8048094,就是修正後的swap函式地址;
- A:被重定位處原值,表示”被重定位處”相對於”下一條指令”的偏移 => fcffffff,就是原始碼上的地址,固定的,補碼錶示的,實際值是-4;
- P:被重定位處的記憶體地址 => 804810e,就是修正後的main中呼叫swap的程式碼地址。
按照這個公式計算修正後的呼叫地址:
L+A-P:8048094 + −4 - 804810e = - 127 = -0x7f,補碼錶示是 ffffff81,由於是小端表示,所以最終替換完的指令為:
804810e: e8 81 ff ff ff call 8048094 <swap>
程式碼在執行的時候,會用當前地址的下一條指令的地址,加上偏移(-127),正好就是swap修正後的地址0x08048094。
6.6 靜態連結庫
我們自己寫的程式可以編譯成目的碼,然後等著連結。但是,我們可能會用到別的庫,它們也是一個個的xxx.o檔案麼?連結的時候需要挨個都把它們指定連結進來麼?
我們可能會用到c語言的核心庫、作業系統提供的各種api的庫,以及很多第三方的庫。比如c的核心庫,比較有名的是glibc,原始的glibc原始碼很多,可以完成各種功能,如輸入輸出、日期、檔案等等,它們其實就是一個個的xxx.o,如fread.o,time.o,printf.o,就是你想象的樣子。
可是,它們被壓縮到了一個大的zip檔案裡,叫libc.a:./usr/lib/x86_64-linux-gnu/libc.a
,就是個大zip包,把各種*.o都壓縮排去了,據說libc.a包含了1400多個目標檔案。
objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|more In archive ./usr/lib/x86_64-linux-gnu/libc.a: init-first.o: file format elf64-x86-64 SYMBOL TABLE: 0000000000000000 l d .text 0000000000000000 .text 0000000000000000 l d .data 0000000000000000 .data 0000000000000000 l d .bss 0000000000000000 .bss .......
我好奇地統計了一下,其實不止1400,我的這臺ubuntu18.04上,有1690個!
objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|grep 'file format'|wc -l 1690
如果以–verbose方式執行編譯命令,你能看到整個細節過程:
gcc -static --verbose -fno-builtin a.c b.c -o ab .... /usr/lib/gcc/x86_64-linux-gnu/7/cc1 -quiet -v -imultiarch x86_64-linux-gnu b.c -quiet -dumpbase b.c -mtune=generic -march=x86-64 -auxbase b -version -fno-builtin -fstack-protector-strong -Wformat -Wformat-security -o /tmp/cciXoNcB.s .... as -v --64 -o /tmp/ccMLSHnt.o /tmp/cciXoNcB.s ..... /usr/lib/gcc/x86_64-linux-gnu/7/collect2 -o ab /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginT.o ...
整個過程分為3步:
- cc1做編譯:編譯成臨時的彙編程式
/tmp/cciXoNcB.s
; - as彙編器:生成目標二進位制程式碼;
- collect2:實際上是一個ld的包裝器,完成最後的連結。
還會連結各類的靜態庫,其實它們都在libc.a這類靜態庫中。
七、裝載
終於把一個程式編譯、連結完,變成了一個可執行檔案,接下來就要聊聊如何把它載入到記憶體,這就是“裝載”的過程。
7.1 虛擬地址空間
在談載入到記憶體之前,先了解程序虛擬地址空間。
程序虛擬地址空間,在我看來是一個非常重要的概念,它的意義在於,讓每個程式,甚至後面的程序,都變得獨立起來,不需要考慮實體記憶體、硬碟、在檔案中的絕對位置等。它關心的只是自己在一個虛擬空間的地址位置。這樣連結器就好安排每個程式碼、資料的位置,裝載器也好安排指令、資料、棧、堆的位置,與硬體無關。
這個地址編碼也很簡單,就是你匯流排多大,我就能編碼多大。比如8位匯流排,地址就256個;到了32位,地址就可以是4G大小了;64位的話,地址就很大了...這麼大的一個地址空間都給一個程式和程序用了!可是,真實記憶體可能也就16G、32G,還有那麼多程序怎麼辦?怎麼裝載進來?別急,後面會介紹。
7.2 如何載入記憶體
一個可執行檔案地址空間碩大無比,怎麼把這頭大象裝入只有16G大小的“冰箱”—-記憶體?!答案是對映。
這樣就可以把可執行檔案中一塊一塊地裝進記憶體裡面了,前提是程序需要的塊,比如正在或馬上要執行的程式碼、資料等。那剩下的怎麼辦?如果記憶體滿了怎麼辦?這些不用擔心,作業系統負責排程,會判斷是否用到,用到的就會載入;如果滿了,就按照LRU演算法替換舊的。
7.3 程序視角
切換到程序視角,程序也要有一個虛擬空間,叫“程序虛擬空間(Process Virtual Space)”。注意:我們又提到了虛擬空間,前面聊起過這個話題,連結器需要、程序載入也需要,連結的時候要給每段程式碼、資料編個地址,現在程序也需要一個虛擬地址。我的學習認知告訴我這倆不是一回事,但應該差不了多少,都是匯流排位數編碼出來的空間大小,各個內容存放的位置也不會有太大變換。
但畢竟是不一樣的,所以它們之間也需要對映。有了這個對映,程序發現自己所需要的可執行程式碼缺了,才能知道到可執行檔案中的第幾行載入。這個對映關係就存在可執行ELF的PHT(程式對映表 - Program Header Table)中,前面介紹過,就是個對映表。
我們再將PHT對映表細化一下。
如果能直接把可執行檔案原封不動地對映到程序空間多好啊,這樣對映多簡單啊。事實不是這樣的。
為了空間佈局上的效率,連結器會把很多段(section)合併,規整成可執行的段(segment)、可讀寫的段、只讀段等,合併後,空間利用率就高了。否則,即便是很小的一段,未來實體記憶體頁浪費太大(實體記憶體頁分配一般都是整數倍一塊給你,比如4k)。所以連結器趁著連結就把小塊們都合併了,這個合併資訊就在可執行檔案頭的VMA資訊裡。
這裡有2個段:section和segment,中文都叫段,但有很大區別:section是目標檔案中的單元;而segement是可執行檔案中的概念,是一個section的組合或集合,是為了將來載入到程序空間裡用的。在我理解,segement和VMA是一個意思。
readelf -l ab
可以檢視程式對映表 - Program Header Table:
Elf file type is EXEC (Executable file) Entry point 0x80480db There are 3 program headers, starting at offset 52 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align LOAD 0x000000 0x08048000 0x08048000 0x001a8 0x001a8 R E 0x1000 LOAD 0x001000 0x0804a000 0x0804a000 0x00014 0x00014 RW 0x1000 GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x10 Section to Segment mapping: Segment Sections... 00 .text .eh_frame 01 .got.plt .data
“Segment Sections”就告訴你如何合併這些sections了。
上述示例有3個段(Segment),其中2個type是LOAD的Segment,一個是可執行的Segment,一個是隻讀的Segment。第一個可執行Segment到底合併哪些Section呢? 答案是:00 .text .eh_frame
。
這個資訊是存在可執行檔案的“程式頭表(Program Header Table - PHT)”裡面的,就是用readelf -f看到的內容,告訴你sections如何合併成segments。
總結:
- 目標檔案有自己的sections,可執行檔案也一樣;
- 只不過可執行檔案又創造了一個概念:segment,就是把sections做了一個合併;
- 真正裝載放到記憶體裡的時候,還要段地址對齊。
7.4 段(Segment)地址對齊
記憶體都是一個一個4k的小頁,便於分配,這涉及到記憶體管理,不展開詳述。
作業系統就給你一摞4k小頁,問題是即使將sections們壓縮成了segment,也不正好就4k大小,就算多一點點,作業系統也得額外再分配一頁,多浪費啊。
辦法來了:段地址對齊。
一個物理頁(4k)上不再是放一個segment,而是還放著別的,物理頁和程序中的頁是1:2的對映關係,浪費就浪費了,反正也是虛擬的。物理上就被“壓縮”到了一起,過去需要5個才能放下的內容,現在只需要3個物理頁了。
7.5 堆和棧
可執行檔案載入到程序空間裡之後,程序空間還有兩個特殊的VMA區域,分別是堆和棧。
通過檢視linux中的程序記憶體對映也可以看到這個資訊:cat /proc/555/maps
55bddb42d000-55bddb4f5000 rw-p 00000000 00:00 0 [heap] ... 7ffeb1c1a000-7ffeb1c3b000 rw-p 00000000 00:00 0 [stack]
參考:Anatomy of a Program in Memory Gcc 編譯的背後
八、動態連結
靜態連結大致清楚了,接下來介紹動態連結。
動態連結的好處很多:
- 程式碼段可以不用重複靜態連結到需要它的可執行檔案裡面去了,省了磁碟空間;
- 執行期還可以共享動態連結庫的程式碼段,也省了記憶體。
8.1 一個栗子
先舉個例子,看看動態連結庫怎麼寫。
lib.c,動態連結庫程式碼:
#include <stdio.h> void foobar(int i) { printf("Printing from lib.so --> %d\n", i); sleep(-1); }
為了讓其他程式引用它,需要為它編寫一個頭檔案:lib.h
#ifndef LIB_H_ #define LIB_H_ void foobar(int i); #endif // LIB_H_
最後是呼叫程式碼:program1.c
#include "lib.h" int main() { foobar(1); return 0; }
編譯這個動態連結庫:gcc -fPIC -shared -o lib.so lib.c
可以得到lib.so。然後編譯引用它的程式的program1.c: gcc -o program1 program1.c ./lib.so
,這樣就可以順利地引用這個動態連結庫了。
這背後到底發生了什麼?
編譯program1.c時,引用了函式foobar,可這個函式在哪裡呢?要在編譯,也就是連結的時候,告訴這個program1程式,所需要的那個foobar在lib.so裡面,也就是需要在編譯引數中加入./lib.so這個檔案的路徑。據說連結器要拷貝so的符號表資訊到可執行檔案中。
在過去靜態連結的時候,我們要在program1中對函式foobar的引用進行重定位,也就是修改program1中對函式foobar引用的地址。動態連結不需要做這件事,因為連結的時候,根本就沒有foobar這個函式的程式碼在程式碼段中。
那什麼時候再告訴program1 foobar的呼叫地址到底是多少呢?答案是執行的時候,也就是執行期,載入lib.so的時候,再告訴program1,你該去呼叫哪個地址上的lib.so中的函式。
我們可以通過/proc/$id/maps,檢視執行期program1的樣子:
cat /proc/690/maps 55d35c6f0000-55d35c6f1000 r-xp 00000000 08:01 3539248 /root/link/chapter7/program1 55d35c8f0000-55d35c8f1000 r--p 00000000 08:01 3539248 /root/link/chapter7/program1 55d35c8f1000-55d35c8f2000 rw-p 00001000 08:01 3539248 /root/link/chapter7/program1 55d35dc53000-55d35dc74000 rw-p 00000000 00:00 0 [heap] 7ff68e48e000-7ff68e675000 r-xp 00000000 08:01 3671326 /lib/x86_64-linux-gnu/libc-2.27.so 7ff68e675000-7ff68e875000 ---p 001e7000 08:01 3671326 /lib/x86_64-linux-gnu/libc-2.27.so 7ff68e875000-7ff68e879000 r--p 001e7000 08:01 3671326 /lib/x86_64-linux-gnu/libc-2.27.so 7ff68e879000-7ff68e87b000 rw-p 001eb000 08:01 3671326 /lib/x86_64-linux-gnu/libc-2.27.so 7ff68e87f000-7ff68e880000 r-xp 00000000 08:01 3539246 /root/link/chapter7/lib.so 7ff68ea81000-7ff68eaa8000 r-xp 00000000 08:01 3671308 /lib/x86_64-linux-gnu/ld-2.27.so 7ffc2a646000-7ffc2a667000 rw-p 00000000 00:00 0 [stack] 7ffc2a66c000-7ffc2a66e000 r--p 00000000 00:00 0 [vvar] 7ffc2a66e000-7ffc2a670000 r-xp 00000000 00:00 0 [vdso] ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]
如上可以看到“ld-2.27.so”,動態聯結器。系統開始的時候,它先接管控制權,載入完lib.so後,再把控制權返還給program1。凡是有動態連結庫的程式,都會把它動態連結到程式的程序中,由它首先載入動態連結庫。
8.2 GOT和PLT
GOT和PLT很複雜,細節很多,不太好理解,我也只是把大致的過程搞明白了,所以這裡只是說一說我的理解,如果感興趣可以看南大袁春風老師關於PLT的講解。
GOT放在資料段裡,而PLT在程式碼段裡,所以GOT是可以改的,放的跳轉用的函式地址;而PLT裡面放的是告訴怎麼呼叫動態連結庫裡函式的程式碼(不是函式的程式碼,是怎麼呼叫的程式碼)。
假如主程式需要呼叫動態連結庫lib.so裡的1個函式:ext,那麼在GOT表裡和PLT表裡都有1個條目,GOT表裡是未來這個函式載入後的地址;而PLT裡放的是如何呼叫這個函式的程式碼,這些程式碼是在連結期連結器生成的。
GOT裡還有3個特殊的條目,PLT裡還有1個特殊的條目。
GOT裡的3個特殊條目:
- GOT[0]: .dynamic section的首地址,裡面放著動態連結庫的符號表的資訊。
- GOT[1]: 動態連結器的標識資訊,link_map的資料結構,這個不是很明白,我理解就是連結庫的so檔案的資訊,用於載入。
- GOT[2]: 這個是呼叫動態庫延遲繫結的程式碼的入口地址,延遲繫結的程式碼是一個特殊程式的入口,實際是一個叫“_dl_runtime_resolve”的函式的地址。
PLT裡的特殊條目:
- PLT[0]: 就是去調動“_dl_runtime_resolve”函式的程式碼,是連結器自動生成的。
整個過程開始了:因為是延遲繫結,所以動態重定位這個過程就需要在第一次呼叫函式的時候觸發。什麼是動態重定位?就是要告訴程序載入程式,修改新載入的動態連結庫被呼叫處的地址,誰知道你把so檔案載入到程序空間的哪個位置了,你得把載入後的地址告訴我,我才能呼叫啊~這個過程就是動態重定位。
.text的主程式開始呼叫ext函式,ext函式的呼叫指令:
804845b: e8 ec fe ff ff call 804834c<ext>
804834c是誰?原來是PLT[1]的地址,就是ext函式對應的PLT表裡的代理函式,每個函式都會在PLT、GOT裡對應一個條目。
現在跳轉到這個函式(PLT[1])去。
PLT[1]:
804834c: ff 25 90 95 04 08 jmp *0x8049590 8048352: 68 00 00 00 00 pushl $0x0 8048357: e9 e0 ff ff ff jmp 804833c
這個函式首先跳到0x8049590裡寫的那個地址去了(jmp *xxx,不是跳到xxx,而是跳到xxx裡面寫的地址上去)。
這裡有2個細節:
- 0x8049590這個地址就是GOT[3],GOT[3]是ext函式對應的GOT條目;
- 0x8049590裡寫的那個地址就是PLT[1](ext對應的plt條目)的下一條。
what?PLT[1]程式碼繞這麼個圈子(用GOT[3]裡的地址跳)jmp,其實就是跳到了自己的下一條?是,這次是可笑,但未來這個值會改的,改成真正的動態庫的函式地址,直接去執行函式。
跳回來之後(PLT[1]),接下來是壓棧了一個0,0表示是第一個函式,也就是ext的索引。
繼續跳0x804833c,這是PLT[0],PLT[0]是去呼叫“_dl_runtime_resolve”函式。在呼叫之前還要幹一件事:push 0x8049588
,0x8049588是GOT[2]。GOT[2]裡放著so的資訊(我理解的不一定完全正確)。
至此,可以呼叫“_dl_runtime_resolve”函式去載入整個so了。
引數包括2個:一個是壓棧的那個0,就是ext函式的索引,後續通過這個索引可以找到GOT表的位置,把真正的函式的地址回填回去;第二個引數是壓棧的GOT[1],就是動態連結器的標識資訊,我理解就是告訴載入器so名字叫啥,它好去載入。
載入完成,立刻回撥安放到位置的so裡,索引為0的ext函式的地址,到GOT[3]中,也就是索引0。
下次再呼叫這個函式的時候,還是先呼叫PLT[1](ext的代理程式碼),但裡面的jmp \*0x8049590
(jmp *GOT[3])可以直接跳轉到真正的ext裡去了。
終於捋完了,必須總結一下。
- 動態連結庫,動態把so載入到虛擬地址空間,因為地址是不定的,所以跟靜態連結的思路一樣,需要做重定位,也就是要修改呼叫的程式碼地址。
- 因為是動態連結,都已經是執行期了,不能修改記憶體程式碼段(.text)(只讀),只能載入完之後,把載入的函式地址寫到GOT表裡。這就是在載入時修改GOT表的方法。
- 還有一種方法是:在主程式啟動時不載入so,等第一次呼叫某個動態連結庫的函式時再載入so,再更新GOT表。思路是:主程式呼叫某個動態連結庫函式時,其實是先呼叫了一個代理程式碼(PLT[x]),它會記錄自己的序號(確定是調哪個函式)和動態連結庫的檔名這2個引數,然後轉去呼叫“_dl_runtime_resolve”函式,這個函式負責把so載入到程序虛擬空間去,並回填載入後的函式地址到GOT表,以後再呼叫就可以直接去呼叫那個函數了。
8.3參考
這個是一篇很讚的文章講的PLT的內容,引用過來:
動態連結庫中的函式動態解析過程如下:
1)從呼叫該函式的指令跳轉到該函式對應的PLT處;
2)該函式對應的PLT第一條指令執行它對應的.GOT.PLT裡的指令。第一次呼叫時,該函式的.GOT.PLT裡儲存的是它對應的PLT裡第二條指令的地址;
3)繼續執行PLT第二條、第三條指令,其中第三條指令作用是跳轉到公共的PLT(.PLT[0]);
4)公共的PLT(.PLT[0])執行.GOT.PLT[2]指向的程式碼,也就是執行動態連結器的程式碼;
5)動態連結器裡的_dl_runtime_resolve_avx函式修改被調函式對應的.GOT.PLT裡儲存的地址,使之指向連結後的動態連結庫裡該函式的實際地址;
6)再次呼叫該函式對應的PLT第一條指令,跳轉到它對應的.GOT.PLT裡的指令(此時已經是該函式在動態連結庫中的真正地址),從而實現該函式的呼叫。
8.4 Linux的共享庫組織
Linux為了管理動態連結庫的各種版本,定義了一個so的版本共享方案。
libname.so.x.y.z
- x是主版本號:重大升級才會變,不向前相容,之前引用的程式都要重新編譯;
- y是次版本號:原有的不變,增加了一些東西而已,向前相容;
- z是釋出版本號:任何介面都沒變,只是修復了bug,改進了效能而已。
1)SO-NAME
Linux有個命名機制,用來管理so之間的關係,這個機制叫SO-NAME。任何一個so都對應一個SO-NAME,就是libname.so.x
。
一般系統的so,不管它的次版本號和釋出版本號是多少,都會給它建立一個SO-NAME的軟連結,例如 libfoo.so.2.6.1,系統就會給它建立一個叫libfoo.so.2的軟鏈。
這個軟連結會指向這個so的最新版本,比如我有2個libfoo,一個是libfoo.so.2.6.1,一個是libfoo.so.2.5.5,軟連結預設指向版本最新的libfoo.so.2.6.1。
在編譯的時候,我們往往需要引入依賴的連結庫,這時依賴的so使用軟連結的SO-NAME,而不使用詳細的版本號。
在編譯的ELF可執行檔案中會存在.dynamic段,用來儲存自己所依賴的so的SO-NAME。
編譯時有個更簡潔指定lib的方式,就是gcc -lxxx
,xxx是libname中的name,比如gcc -lfoo
是指連結的時候去連結一個叫libfoo.so的最新的庫,當然這個是動態連結。如果加上-static: gcc -static -lfoo
就會去預設靜態連結libfoo.a的靜態連結庫,規則是一樣的。
2)ldconfig
Linux提供了一個工具“ldconfig”,執行它,linux就會遍歷所有的共享庫目錄,然後更新所有的so的軟鏈,指向它們的最新版,所以一般安裝了新的so,都會執行一遍ldconfig。
8.5 系統的共享庫路徑
Linux尊崇FHS(File Hierarchy Standard)標準,來規定系統檔案是如何存放的。
- /lib:存放最關鍵的基礎共享庫,比如動態連結器、C語言執行庫、數學庫,都是/bin,/sbin裡系統程式用到的庫;
- /usr/lib: 一般都是一些開發用到的 devel庫;
- /usr/local/lib:一般都是一些第三方庫,GNU標準推薦第三方的庫安裝到這個目錄下。
另外/usr目錄不是user的意思,而是“unix system resources”的縮寫。
/usr:/usr 是系統核心所在,包含了所有的共享檔案。它是 unix 系統中最重要的目錄之一,涵蓋了二進位制檔案、各種文件、標頭檔案、庫檔案;還有諸多程式,例如 ftp,telnet 等等。
九、後記
研究這個話題,前前後後經歷了一個月,文章只是把過程中的體會記錄下來,同時在單位給同事們做了一次分享。雖然也只是浮光掠影,但終究是了結了多年的心願,對可執行檔案的格式、載入等基礎知識做了一次梳理,還是收穫滿滿的。這些知識對實際的工作有什麼幫助嗎?可能會有幫助,但可能也非常有限。“行無用之事,做時間的朋友”,做一些有意思的事情,過程本身就充滿了樂趣。
文章可能會有紕漏和錯誤,能看到這裡的同學,也請留言指出來,一起討論學習,共同進步!
參考
- 南京大學-袁春風老師-計算機系統基礎
- 深入淺出計算機組成原理-極客時間
- 《程式是怎樣跑起來的》
- 《程式設計師的自我修養》
- 《深