摘要:一個程序的一輩子,從源程序到進程的辛苦歷程!本文不深刻研究編譯原理、操做系統原理,主要聚焦於程序的加載和連接。html
做爲計算機專業的人,最遺憾的就是在學習編譯原理的那個學期被別的老師拉去幹活了,而對一個程序怎麼就從源代碼變成了一個在內存裏活靈活現的進程,一直也心懷好奇。這種好奇驅使我要找個機會深刻了解一下,因此便有了本文,來督促本身深刻研究程序的一輩子。不過,本文沒有深刻研究編譯原理、操做系統原理,而是主要聚焦於程序的連接和加載。linux
學習的過程當中主要參考了三本書、一個視頻、一個音頻(文末有列出),三本書裏,最主要的仍是《程序員的自我修養 - 連接、裝載與庫》,裏面的代碼放到了個人github上,而且配有shell腳本和說明,運行後能夠實操理解到更多內容。git
南大袁春風老師的計算機原理講解對我幫助最大,視頻是最直接傳達知識的方式。另外,爲了方便本身的實驗,製做了一個ubuntu的環境,而且內置了代碼,方便實驗:阿里docker鏡像程序員
docker pull registry.cn-hangzhou.aliyuncs.com/piginzoo/learn:1.0
github
天天都有無數的程序被編譯、部署,不停地跑着,它們幹着千奇百怪的事情。如同這個光怪陸離的世界,是由每一個人、每一個個體組成的,若是咱們剖析每一個人,會發現他們其實都是同樣的結構,都是由細胞、組織組成,再深究即是基因了,DNA裏那一個個的「核苷酸基」決定了他們。算法
一樣,經過這個隱喻來認知計算機,咱們能夠知道,計算機的基因和本質就是馮諾依曼體系。啥是馮諾依曼體系呢?通俗地講,就是定義了整個硬件體系(CPU、外存、輸入輸出),以及執行的運行流程等等。但是,一個程序怎麼就與硬件親密無間地運行起來了呢?應該不少人都不瞭解,甚至包括許多計算機專業的同窗們。docker
本質上來講,這個過程其實就是「從代碼編譯,而後不一樣目標文件連接,最終加載到內存中,被操做系統管理起來的一個進程,可能還會動態地再去連接其餘的一些程序(如動態連接庫)的過程」。看起來彷佛很簡單,但其實每一個部分都隱藏着不少細節,好奇心很強的你必定想知道,到底計算機是怎麼作到的。shell
本文不打算討論硬件、進程、網絡等如此龐大的體系,只聚焦於探索程序的連接和加載這兩個主題。編程
探索以前須要交代一些基礎知識,否則沒法理解連接和加載。ubuntu
CPU由一大堆寄存器、算數邏輯單元(就是作運算的)、控制器組成。每次經過PC(程序計數器,存着指令地址)寄存器去內存裏尋址可執行二進制代碼,而後加載到指令寄存器裏,若是涉及到地址的話,再去內存里加載數據,計算完後寫回到內存裏。每條指令都會放到指令寄存器(IR)中,等着CPU去取出來運行。
指令是從硬盤加載到內存裏,又從內存里加載到IR裏面的。指令運行過程當中須要一些數據,這又要求從內存裏取出一些數據放到通用寄存器中,而後交給ALU去運算,結果出來後又會放到寄存器或者內存中,周而復始。
每一步都是一個時鐘週期,如今的CPU一秒鐘能夠作1G次,是1000000000,幾十億次/秒。目前市場上的CPU主頻聽說到4GHz就到極限了,限於工藝,上不去了,因此慢慢轉爲多核,就是把幾個CPU封裝到一塊兒共享內部緩存。
如圖,咱們常常據說的「北橋、南橋」是什麼?
北橋其實就是一個計算機結構,準確地說是一個芯片,它鏈接的都是高速設備,經過PCI總線,把cpu、內存、顯卡串在一塊兒;而南橋就要慢不少了,鏈接的都是鼠標、鍵盤、硬盤等這些「窮慢」親戚,它們之間用ISA總線串在一塊兒。
硬盤硬件上是盤片、磁道、扇區這樣的一個結構,太複雜了,因此從頭至尾給這些扇區編個號,就是所謂的「LBA(Logical Block Address)」邏輯扇區的概念,方便尋址。
爲了隔離,每一個進程有一個本身的虛擬地址空間,而後想辦法給它映射到物理內存裏。若是內存不夠怎麼辦?就想到了再細分,就是分頁,分紅4k的一個小頁,經常使用的在內存裏,不經常使用的交換到磁盤上。這就要常常用到地址映射計算(從虛擬地址到物理地址),這個工做就是MMU(Memory Management Unit),爲了快都集成到CPU裏面了。
還有不少外設負責輸入輸出,一旦被外界輸入或要輸出東西,就得去告訴CPU:「我有東西了,來取吧」;「我要輸出啦,來幫我輸出吧」。這些工做就要靠一個叫「中斷」的機制,能夠將「中斷」理解成一種消息機制,用於通知CPU來幫我幹活。不是每一個部分均可以直接騷擾CPU的,它們都要經過中斷控制器來集中騷擾CPU。
這些外設都有本身的buffer,這些buffer也得有地址,這個地址叫端口。
還得給每一個設備編個號,這樣系統才能識別誰是誰。每次中斷,CPU一看,噢,原來是05,05是鍵盤啊;06,06是鼠標啊。這個號,叫中斷編號(IRQ)。
每次都必需要騷擾CPU嗎?直接把數據從外設的buffer(端口)灌到內存裏,不用CPU參與,多好啊!對,這個作法就是DMA。每一個DMA設備也得編個號,這個編號就是DMA通道,這些號可不能衝突哦。
對於彙編,我其實也忘光了,因此得補補彙編知識了,起碼要能讀懂一些基礎的彙編指令。
彙編分門派呢!」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語法:
<指令> [源] [目標]
寄存器是存放各類給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個
二者的區別:
對了,寄存器可不是L一、L2 cache啊!Cache位於CPU與主內存間,分爲一級Cache (L1Cache)和二級Cache (L2Cache),L1 Cache集成在CPU內部,L2 Cache早期在主板上,如今也都集成在CPU內部了,常見的容量有256KB或512KB。寄存器不多的,拿64位的來講,也就是16個,64x16,也就是1024,1K。
總結:大體來講數據是經過內存-Cache-寄存器,Cache緩存是爲了彌補CPU與內存之間運算速度的差別而設置的部件。
接下來講說尋址,尋址就是告訴CPU去哪裏取指令、數據。好比movl %rax %rbx
,這個涉及到尋址,尋址會尋「寄存器」、「內存」,能夠是暴力的直接尋址,也能夠是委婉的間接尋址。下面是各類尋址方式:
你可能會看到這種指令movl,movw,mov
後面的l、w是什麼鬼?
就是一次搬運的數據數量。
最後說說指令自己,每一個CPU類型都有本身的指令集,就是告訴CPU幹啥,好比加、減、移動、調用函數等。下面是一些很是經常使用的指令:
參考:願意自虐的同窗,能夠下載【Intel官方的指令集手冊】仔細研讀。
本文還會涉及到一些工具:
cat /proc/<PID>/maps
:這個命令頗有趣,可讓你看到進程的內存分佈。還有各類利器,本身去探索吧。
假若有個整形變量1234,16進制是0x000004d2,佔4個字節,起始地址是0x10000,終止地址是0x10003,那麼在外界看來,是它的地址是0x10000仍是0x10003呢?答案是0x10000。
那麼問題來了,這4個字節裏怎麼放這個數?高地址放高位,仍是低地址放高位?答案是,均可以!
大端方式:高位在低地址,如 IBM360/370,MIPS
小端方式:高位在高地址,如 Intel 80x86
因爲我沒學過編譯,對詞法分析、語法分析也不甚瞭解,找機會再深刻吧,這裏只是把大體知識梳理一下。
詞法分析->語法分析->語義分析->中間代碼生成->目標代碼生成
經過FSM(有限狀態機)模型,就是按照語法定義好的樣子,挨個掃描源代碼,把其中的每一個單詞和符號作個歸類,好比是關鍵字、標識符、字符串仍是數字的值等,而後分門別類地放到各個表中(符號表、文字表)。若是不符合語法規則,在詞法分析過程當中就會給出各種警告,我們在編譯過程當中看到的不少語法錯誤就是它乾的。有個開源的lex的程序,能夠體會這個過程。
由詞法分析的符號表,要造成一個抽象語法樹,方法是「上下文無關語法(CFG)」。這過程就是把程序表示成一棵樹,葉子節點就是符號和數字,自上而下組合成語句,也就是表達式,層層遞歸,從而造成整個程序的語法樹。同上面的詞法分析同樣,也有個開源項目能夠幫你作這個樹的構建,就是yacc(Yet Another Compiler Compiler)。
這個步驟,我理解要比語法分析工做量小一些,主要就是作一些類型匹配、類型轉換的工做,而後把這些信息更新到語法樹上。
把抽象語法樹轉成一條條順序的中間代碼,這種中間代碼每每採用三地址碼或者P-Code的格式,形如x = y op z。長成這個樣子:
t1 = 2 + 6 array[index] = t1
不過這些代碼是和硬件不相關的,仍是「抽象」代碼。
目標代碼生成就是把中間代碼轉換成目標機器代碼,這就須要和真正的硬件以及操做系統打交道了,要按照目標CPU和操做系統把中間代碼翻譯成符合目標硬件和操做系統的彙編指令,並且,還要給變量們分配寄存器、規定長度,最後獲得了一堆彙編指令。
對於整形、浮點、字符串,均可以翻譯成把幾個bytes的數據初始化到某某寄存器中,可是對於數組等其它的大的數據結構,就要涉及到爲它們分配空間了,這樣才能夠肯定數組中某個index的地址。不過,這事兒編譯不作,留給連接去作。
編譯不是本文重點,這裏就不過多討論了,感興趣的同窗,能夠讀讀這篇:《本身動手寫編譯器》。
編譯一個c源文件代碼,就會對應獲得一個目標文件。一個項目中會有一堆的c源代碼,編譯後會獲得一堆的目標文件。這些目標文件是二進制的,就是一堆0、1的集合,到底這一堆0、1是如何排布的呢?接下來,咱們得說一說,這些0、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
如上能夠看到不一樣文件的區別。
ELF是Executable LinkableFormat的縮寫,是Linux的連接、可執行、共享庫的格式標準,尊從COFF。
Linux下的目標ELF文件(或可執行ELF文件)的結構包括:
ELF文件的結構包含ELF的頭部說明和各類「段」(section)。段是一個邏輯單元,包含各類各樣的信息,好比代碼(.text)、數據(.data)、符號等。
先說說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
說明:
關於更詳細的elf文件頭的內容,能夠參考:
除了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段:
還有其它段:
段表裏記錄着每一個段開始的位置和位移(offset)、長度,畢竟這些段都是緊密的放在二進制文件中,須要段表的描述信息才能把它們每一個段分割開。
有了段,咱們其實就對可執行文件瞭然於心了,其中.text代碼段裏放着能夠運行的機器指令;而.data數據段裏放着全局變量的初始值;.symtab裏放着當初源代碼中的函數名、變量名的表明的信息。
目標ELF文件和可執行ELF文件雖然規範是一致的,但仍是有不少細微區別。
在段表中,你會發現這種段:.rel.xxx,這些段就是連接用的!由於你須要把某個目標中出現的函數、變量等的地址,換成其它目標文件中的位置(也就是地址),這樣才能正確地引用、調用這些變量。至於連接細節,後面講連接的時候再說。
通常有text、data兩種重定位表:
.strtab、.shstrtab
ELF中不少字符串,好比函數名字、變量名字,都放到一個叫「字符串」表的段中。
注意:字符串表只是字符串,符號表跟它不同,符號表更重要,它表示了各個函數、變量的名字對應的代碼或者內存地址,在連接的時候,很是有用。由於連接就是要找各個變量和函數的位置,這樣才能夠更新編譯階段空出來的函數、變量的引用地址。
每一個目標文件裏都有這麼一個符號表,用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文件,是時候把它們「合體」了,這就須要連接過程了,就是要把這些目標文件「湊」到一塊兒,也就是把各個段合併到一塊兒。
合併開始!讀每一個目標文件的文件頭,得到各個段的信息,而後作符號重定位。
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文件了。
上面一直刻意不區分目標ELF文件和可執行ELF文件,緣由是想先介紹它們共同的ELF規範部分,但其實二者是有區別的,這一小節忍不住想介紹一下,但願不會打斷看官的思路。
目標ELF文件和可執行ELF文件,實際上是兩個目的、兩個視角:
雖然二者有區別,但大致的規範是同樣的,都有ELF頭、段表(section table)、節(section)等基本的組成部分。
能夠參考這篇文章《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
問題來了,這些地址是如何肯定的呢?要知道目標ELF文件a.o、b.o裏的地址還都是0做爲基地址的,到合體後的可執行文件ab怎麼就填充了這些東西呢?這就要引出「符號重定位」了。
既然連接是把你們的代碼段、數據段都合併到一塊兒,那就須要修改對應的調用的地址,好比a.o要調用b.o中的函數,合併到一塊兒成爲ab的時候,就須要修改以前a.o中的調用的地址爲一個新的ab中的地址,也就是以前b.o中的那個函數swap的地址。
連接器經過「重定位 + 符號解析」完成上述工做。
最開始編譯完的目標文件,變量地址、函數地址的基準地址都是0。一旦連接,就不能從0開始了,而要從操做系統和應用進程規定的虛擬起始地址開始做爲基準地址,這個規定是0x08048094
。別問我爲何,真心不知~
另外,還有這幾個目標文件的各個段,它們的函數、變量等的地址本來都是基於0,如今合體了,都要開始逐一調整!以前每一個函數、變量的地址都是相對於0的,也就是說,你知道它們的偏移offset,這樣的話,你只須要告訴它們新的基地址的調整值,就能夠加上以前的offset算出新的地址,把全部涉及到被調用的地方都改一遍,就完成了這個重定位的過程。
具體怎麼作呢?經過重定位表來完成。
就是一個表,記着以前每一個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符號。
重定位表只記錄哪些符號須要重定位,而關於這個函數、變量更詳細的信息都在符號表中。
接下來精彩的事情發生了,也就是連接中最關鍵的一步:修改連接完成的文件中調用函數和變量引用的地址。
修改函數和數據的應用地址有不少方法,這涉及到各個平臺的尋址指令差別,好比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+A-P:8048094 + −4 - 804810e = - 127 = -0x7f,補碼錶示是 ffffff81,因爲是小端表示,因此最終替換完的指令爲:
804810e: e8 81 ff ff ff call 8048094 <swap>
代碼在執行的時候,會用當前地址的下一條指令的地址,加上偏移(-127),正好就是swap修正後的地址0x08048094。
咱們本身寫的程序能夠編譯成目標代碼,而後等着連接。可是,咱們可能會用到別的庫,它們也是一個個的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步:
/tmp/cciXoNcB.s
;還會連接各種的靜態庫,其實它們都在libc.a這類靜態庫中。
終於把一個程序編譯、連接完,變成了一個可執行文件,接下來就要聊聊如何把它加載到內存,這就是「裝載」的過程。
在談加載到內存以前,先了解進程虛擬地址空間。
進程虛擬地址空間,在我看來是一個很是重要的概念,它的意義在於,讓每一個程序,甚至後面的進程,都變得獨立起來,不須要考慮物理內存、硬盤、在文件中的絕對位置等。它關心的只是本身在一個虛擬空間的地址位置。這樣連接器就好安排每一個代碼、數據的位置,裝載器也好安排指令、數據、棧、堆的位置,與硬件無關。
這個地址編碼也很簡單,就是你總線多大,我就能編碼多大。好比8位總線,地址就256個;到了32位,地址就能夠是4G大小了;64位的話,地址就很大了...這麼大的一個地址空間都給一個程序和進程用了!但是,真實內存可能也就16G、32G,還有那麼多進程怎麼辦?怎麼裝載進來?別急,後面會介紹。
一個可執行文件地址空間碩大無比,怎麼把這頭大象裝入只有16G大小的「冰箱」—-內存?!答案是映射。
這樣就能夠把可執行文件中一塊一塊地裝進內存裏面了,前提是進程須要的塊,好比正在或立刻要執行的代碼、數據等。那剩下的怎麼辦?若是內存滿了怎麼辦?這些不用擔憂,操做系統負責調度,會判斷是否用到,用到的就會加載;若是滿了,就按照LRU算法替換舊的。
切換到進程視角,進程也要有一個虛擬空間,叫「進程虛擬空間(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。
總結:
內存都是一個一個4k的小頁,便於分配,這涉及到內存管理,不展開詳述。
操做系統就給你一摞4k小頁,問題是即便將sections們壓縮成了segment,也不正好就4k大小,就算多一點點,操做系統也得額外再分配一頁,多浪費啊。
辦法來了:段地址對齊。
一個物理頁(4k)上再也不是放一個segment,而是還放着別的,物理頁和進程中的頁是1:2的映射關係,浪費就浪費了,反正也是虛擬的。物理上就被「壓縮」到了一塊兒,過去須要5個才能放下的內容,如今只須要3個物理頁了。
可執行文件加載到進程空間裏以後,進程空間還有兩個特殊的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 編譯的背後
靜態連接大體清楚了,接下來介紹動態連接。
動態連接的好處不少:
先舉個例子,看看動態連接庫怎麼寫。
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。凡有動態連接庫的程序,都會把它動態連接到程序的進程中,由它首先加載動態連接庫。
GOT和PLT很複雜,細節不少,不太好理解,我也只是把大體的過程搞明白了,因此這裏只是說一說個人理解,若是感興趣能夠看南大袁春風老師關於PLT的講解。
GOT放在數據段裏,而PLT在代碼段裏,因此GOT是能夠改的,放的跳轉用的函數地址;而PLT裏面放的是告訴怎麼調用動態連接庫裏函數的代碼(不是函數的代碼,是怎麼調用的代碼)。
假如主程序須要調用動態連接庫lib.so裏的1個函數:ext,那麼在GOT表裏和PLT表裏都有1個條目,GOT表裏是將來這個函數加載後的地址;而PLT裏放的是如何調用這個函數的代碼,這些代碼是在連接期連接器生成的。
GOT裏還有3個特殊的條目,PLT裏還有1個特殊的條目。
GOT裏的3個特殊條目:
PLT裏的特殊條目:
整個過程開始了:由於是延遲綁定,因此動態重定位這個過程就須要在第一次調用函數的時候觸發。什麼是動態重定位?就是要告訴進程加載程序,修改新載入的動態連接庫被調用處的地址,誰知道你把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個細節:
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裏去了。
終於捋完了,必須總結一下。
這個是一篇很讚的文章講的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裏的指令(此時已是該函數在動態連接庫中的真正地址),從而實現該函數的調用。
Linux爲了管理動態連接庫的各類版本,定義了一個so的版本共享方案。
libname.so.x.y.z
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。
Linux尊崇FHS(File Hierarchy Standard)標準,來規定系統文件是如何存放的。
另外/usr目錄不是user的意思,而是「unix system resources」的縮寫。
/usr:/usr 是系統核心所在,包含了全部的共享文件。它是 unix 系統中最重要的目錄之一,涵蓋了二進制文件、各類文檔、頭文件、庫文件;還有諸多程序,例如 ftp,telnet 等等。
研究這個話題,前先後後經歷了一個月,文章只是把過程當中的體會記錄下來,同時在單位給同事們作了一次分享。雖然也只是浮光掠影,但終究是告終了多年的心願,對可執行文件的格式、加載等基礎知識作了一次梳理,仍是收穫滿滿的。這些知識對實際的工做有什麼幫助嗎?可能會有幫助,但可能也很是有限。「行無用之事,作時間的朋友」,作一些有意思的事情,過程自己就充滿了樂趣。
文章可能會有紕漏和錯誤,能看到這裏的同窗,也請留言指出來,一塊兒討論學習,共同進步!
文章來源:宜信技術學院 & 宜信支付結算團隊技術分享第14期-支付結算機器學習技術團隊負責人 劉創 分享《程序的一輩子:從源程序到進程的辛苦歷程》
分享者:宜信支付結算機器學習技術團隊負責人 劉創
原文發佈於我的博客:動物園的豬(www.piginzoo.com)