一、前言
作為計算機專業的人,最遺憾的就是在學習編譯原理的那個學期被別的老師拉去干活了,而對一個程序怎么就從源代碼變成了一個在內存里活靈活現的進程,一直也心懷好奇。這種好奇驅使我要找個機會深入了解一下,所以便有了本文,來督促自己深入研究程序的一生。不過,本文沒有深入研究編譯原理、操作系統原理,而是主要聚焦於程序的鏈接和加載。
學習的過程中主要參考了三本書、一個視頻、一個音頻(文末有列出),三本書里,最主要的還是《程序員的自我修養 - 鏈接、裝載與庫》,里面的代碼放到了我的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文件頭的內容,可以參考:
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 等等。
九、后記
研究這個話題,前前后后經歷了一個月,文章只是把過程中的體會記錄下來,同時在單位給同事們做了一次分享。雖然也只是浮光掠影,但終究是了結了多年的心願,對可執行文件的格式、加載等基礎知識做了一次梳理,還是收獲滿滿的。這些知識對實際的工作有什么幫助嗎?可能會有幫助,但可能也非常有限。“行無用之事,做時間的朋友”,做一些有意思的事情,過程本身就充滿了樂趣。
文章可能會有紕漏和錯誤,能看到這里的同學,也請留言指出來,一起討論學習,共同進步!
參考
- 南京大學-袁春風老師-計算機系統基礎
- 深入淺出計算機組成原理-極客時間
- 《程序是怎樣跑起來的》
- 《程序員的自我修養》
- 《深入理解計算機系統》
- readlf、nm、ld、objdump、ldconfig、gcc命令
文章來源:宜信技術學院 & 宜信支付結算團隊技術分享第14期-支付結算機器學習技術團隊負責人 劉創 分享《程序的一生:從源程序到進程的辛苦歷程》
分享者:宜信支付結算機器學習技術團隊負責人 劉創
原文發布於個人博客:動物園的豬(www.piginzoo.com)