程序的一生:從源程序到進程的辛苦歷程

閱讀前請點擊右上角“關注”,每天免費獲取Android知識解析及面試解答。Android架構解析,只做職場乾貨,完全免費分享!

一、前言

作為計算機專業的人,最遺憾的就是在學習編譯原理的那個學期被別的老師拉去幹活了,而對一個程序怎麼就從源代碼變成了一個在內存裡活靈活現的進程,一直也心懷好奇。這種好奇驅使我要找個機會深入瞭解一下,所以便有了本文,來督促自己深入研究程序的一生。不過,本文沒有深入研究編譯原理、操作系統原理,而是主要聚焦於程序的鏈接和加載。

學習的過程中主要參考了三本書、一個視頻、一個音頻(文末有列出),三本書裡,最主要的還是《程序員的自我修養 - 鏈接、裝載與庫》,裡面的代碼放到了我的github上,並且配有shell腳本和說明,運行後可以實操理解到更多內容。

南大袁春風老師的計算機原理講解對我幫助最大,視頻是最直接傳達知識的方式。另外,為了方便自己的實驗,製作了一個ubuntu的環境,並且內置了代碼,方便實驗:阿里docker鏡像

docker pull http://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//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

<code>      /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 a really cool domain parked on Park.io
a really cool domain parked on Park.io: Bourne-Again shell/>
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/<code>

如上可以看到不同文件的區別。

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)的信息。

看個例子:

<code>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
/<code>

說明:

  • 其中,”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文件的段表。

<code>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)/<code>

這個可執行文件裡有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文件的重定位表

在段表中,你會發現這種段:.http://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

<code>          U _GLOBAL_OFFSET_TABLE_
U __stack_chk_fail
0000000000000000 T main

U shared
U swap

/<code>

2)readelf -s a.o 目標文件的符號表:

<code>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/<code>

從這個目標ELF文件的符號表可以看到swap函數,Ndx是UND(Undefined的縮寫),表明不知道它到底在哪個段,需要被重定位,就是寫個1或3之類的數字表明段中的index;對於全局變量shared也是同樣的定義。這些內容都會在靜態鏈接的時候,被鏈接器修改。

為了對比,我們來看可執行文件ab的符號表的樣子,看看靜態鏈接後,這些符號的Ndx的變換。

3)可執行文件ab的符號表

nm ab

<code>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
T swap
0804a010 D test

/<code>

readelf -s ab

<code>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 /<code>

可以看到,現在shared的Ndx是4,而swap的Ndx是1,對應的就是:4-數據段、1-代碼段。

<code>上面曾經顯示過的段的編號
。。。。
[ 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
。。。/<code>

如上,對應的第一列的序號就標明瞭代碼段是1,數據段是4。

另外,第二列Type也挺有用的:Object表示數據的符號,而Func是函數符號。

六、靜態鏈接

目標文件介紹得差不多了,我們得到了一大堆零散的目標ELF文件,是時候把它們“合體”了,這就需要鏈接過程了,就是要把這些目標文件“湊”到一起,也就是把各個段合併到一起。

程序的一生:從源程序到進程的辛苦歷程

合併開始!讀每個目標文件的文件頭,獲得各個段的信息,然後做符號重定位。

  • 讀每個目標文件,收集各個段的信息,然後合併到一起,其實我理解就是壓縮到一起,你的代碼段挨著我的代碼段,合併成一個新的,因為每個ELF目標文件都有文件頭,是可以很嚴格合併到一起的;
  • 符號重定位,簡單來說就是把之前調用某個函數的地址給重新調整一下,或者某個變量在data段中的地址重新調整一下。因為合併的時候,各個代碼段都合併了,對應代碼中的地址都變了,所以要調整。這是鏈接最核心的一步!

ld a.o b.o ab

詳細介紹a.o+b.o=> ab的變化,特別是虛擬地址的變化。

先看鏈接前的目標ELF文件:a.o,b.o。

<code>a.o的段屬性(objdump -h a.o)
------------------------------------------------------------------------
Idx Name Size VMA LMA File off Algn
.text 00000051 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE

.data 00000000 0000000000000000 0000000000000000 00000091 2**0
CONTENTS, ALLOC, LOAD, DATA
.bss 00000000 0000000000000000 0000000000000000 00000091 2**0
ALLOC

b.o的段屬性(objdump -h b.o)
------------------------------------------------------------------------
Idx Name Size VMA LMA File off Algn
.text 0000004b 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
.data 00000008 0000000000000000 0000000000000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, DATA
.bss 00000000 0000000000000000 0000000000000000 00000094 2**0
ALLOC
/<code>

接下來是a.o + b.o,鏈接合體後的可執行ELF文件:ab。

<code>ab的段屬性(objdump -h ab)
------------------------------------------------------------------------
Idx Name Size VMA LMA File off Algn
.text 00000091 08048094 08048094 00000094 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
.eh_frame 00000080 08048128 08048128 00000128 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
.got.plt 0000000c 0804a000 0804a000 00001000 2**2
CONTENTS, ALLOC, LOAD, DATA
.data 00000008 0804a00c 0804a00c 0000100c 2**2
CONTENTS, ALLOC, LOAD, DATA/<code>

我們來玩一玩“找不同”!可執行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):

<code>Idx Name          Size      VMA       LMA       File off  Algn
.text 00000091 08048094 08048094 00000094 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
.eh_frame 00000080 08048128 08048128 00000128 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
.got.plt 0000000c 0804a000 0804a000 00001000 2**2
CONTENTS, ALLOC, LOAD, DATA
.data 00000008 0804a00c 0804a00c 0000100c 2**2
CONTENTS, ALLOC, LOAD, DATA/<code>

可以看到,ab的代碼段.text是從0x8048094開始的,長度是0x91,也就是145個字節長度的代碼段。

段的開頭地址確定了,接下來段裡符號對應的地址就好找了(也就是.text段中的函數和.data段中的變量)。

回過頭去看幾個符號:swap函數、main函數、test變量、shared變量:

<code>  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
/<code>
  • 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目標文件中哪些函數、變量需要被重定位。這是一個單獨的段,命名還有規律呢!就是.http://rel.xxx,比如.rel.data、.rel.text。

看個栗子:

<code>   RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000025 R_X86_64_PC32 shared-0x0000000000000004
0000000000000032 R_X86_64_PLT32 swap-0x0000000000000004
/<code>

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

<code>extern int shared;

int main()
{
int a = 0;
swap(&a, &shared);
}/<code>

b.c

<code>int shared = 1;
int test = 3;

void swap(int* a, int* b) {
*a ^= *b ^= *a ^= *b;
}/<code>

a.c的彙編文件

<code><main>:
....
31: 89 c3 mov %eax,%ebx
33: e8 fc ff ff ff call 34 <main> 38: 83 c4 10 add $0x10,%esp
....
Relocation section '.rel.text' at offset 0x24c contains 4 entries:
Offset Info Type Sym.Value Sym. Name
....
00000e04 R_386_PLT32 00000000 swap/<main>/<main>/<code>

可以看到目標文件a.o中的彙編指令和重定位表中為R_386_PLT32的重定位方式。然後,鏈接後得到ab的代碼。

鏈接後的 ab ELF可執行文件:

<code><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
..../<swap>/<main>/<swap>/<code>

分析

1)修正後的swap地址是:0x08048094

2)修正後的代碼地址是: 0x804810e

3)原來的調用代碼: 33: e8 fc ff ff ff call 34 <main>,其實是0xfffffffc,補碼錶示的-4/<main>

4)先看修改完成的:ab中,804810e: e8 81 ff ff ff call 8048094 <swap>。e8 fc ff ff ff 修改成了=> e8 81 ff ff ff,補碼錶示是-127/<swap>

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,由於是小端表示,所以最終替換完的指令為:

<code>804810e: e8 81 ff ff ff call 8048094 <swap>/<code>

代碼在執行的時候,會用當前地址的下一條指令的地址,加上偏移(-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多個目標文件。

<code>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:
l d .text 0000000000000000 .text
l d .data 0000000000000000 .data
l d .bss 0000000000000000 .bss
......./<code>

我好奇地統計了一下,其實不止1400,我的這臺ubuntu18.04上,有1690個!

<code>      objdump -t ./usr/lib/x86_64-linux-gnu/libc.a|grep 'file format'|wc -l    
1690
/<code>

如果以–verbose方式運行編譯命令,你能看到整個細節過程:

<code>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 .../<code>

整個過程分為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:

<code>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...
.text .eh_frame
.got.plt .data/<code>

“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

<code>     55bddb42d000-55bddb4f5000 rw-p 00000000 00:00 0                          [heap]
...
7ffeb1c1a000-7ffeb1c3b000 rw-p 00000000 00:00 0 [stack]
/<code>

參考:Anatomy of a Program in Memory Gcc 編譯的背後

八、動態鏈接

靜態鏈接大致清楚了,接下來介紹動態鏈接。

動態鏈接的好處很多:

  • 代碼段可以不用重複靜態鏈接到需要它的可執行文件裡面去了,省了磁盤空間;
  • 運行期還可以共享動態鏈接庫的代碼段,也省了內存。

8.1 一個栗子

先舉個例子,看看動態鏈接庫怎麼寫。

lib.c,動態鏈接庫代碼:

<code>#include <stdio.h>
void foobar(int i) {
printf("Printing from lib.so --> %d\\n", i);
sleep(-1);
}/<stdio.h>/<code>

為了讓其他程序引用它,需要為它編寫一個頭文件:lib.h

<code>  #ifndef LIB_H_
#define LIB_H_
void foobar(int i);
#endif // LIB_H_
/<code>

最後是調用代碼:program1.c

<code>#include "lib.h"
int main() {
foobar(1);
return 0;
}
/<code>

編譯這個動態鏈接庫: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的樣子:

<code>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]/<code>

如上可以看到“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函數的調用指令:

<code>804845b: e8 ec fe ff ff call 804834c/<code>

804834c是誰?原來是PLT[1]的地址,就是ext函數對應的PLT表裡的代理函數,每個函數都會在PLT、GOT裡對應一個條目。

現在跳轉到這個函數(PLT[1])去。

<code>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
/<code>

這個函數首先跳到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期-支付結算機器學習技術團隊負責人 劉創 分享《程序的一生:從源程序到進程的辛苦歷程》
分享者:宜信支付結算機器學習技術團隊負責人 劉創
原文發佈於個人博客:動物園的豬(http://www.piginzoo.com)


分享到:


相關文章: