c++定义一个动态全局变量_静态链接与动态链接的宏观概述及微观详解
靜態鏈接與動態鏈接的宏觀概述及微觀詳解
第一部分 宏觀概述
1. 靜態鏈接
靜態鏈接就是在程序運行前,鏈接器通過對象文件中包含的重定位表,完成所有重定位操作,并最終形成一個在運行時不需要再次進行依賴庫的加載和重定位操作(因為所有的依賴庫在運行前都被鏈接到程序中了)。
2. 動態鏈接
動態鏈接指的是主程序對動態共享庫或對象中符號的引用,是等到程序運行后再加載并進行重定位操作。程序的主體部分也稱為主程序還是靜態鏈接的,這部分鏈接是不會將依賴的動態共享庫或對象鏈接進主程序的。
2.1 裝載時重定位
裝載時重定位:就是程序在運行的過程中,裝載依賴的動態共享庫或對象并在裝載的過程中完成以下兩種操作:
(1)修正主程序中對動態共享庫或對象中定義的符號的引用,因為這時動態共享庫中所有全局符號的虛擬地址都已確定了。
(2)修正動態共享庫或對象中指令和數據對絕對虛擬地址的引用,因為動態共享庫的虛擬基地址一般都是從0x00開始的,所以當動態共享庫或對象被加載到進程的虛擬地址空間的某地址處(假設加載地址為:virt_so_base),那么它的指令或數據中對絕對虛擬地址的引用就都要加上virt_so_base,這樣就完成動態共享庫或對象中指令和數據的重定位了。
經過以上的操作,大家會發現裝載時重定位雖然解決了動態共享庫或對象可以被動態加載到進程的不同地址空間這一困擾靜態共享庫的難題,但是它還是沒有解決“共享”這個核心問題,也就是同一個動態共享庫或對象不能被多個進程共享這一問題。
例如進程A將DSO加載到自己的地址空間0x1000處,那么DSO中需要重定位的指令和數據就要加上0x1000,而進程B要想將DSO加載到自己的地址空間0x4000處時,就不能共享內存中已有的DSO,因為進程A已經將其重定位了,進程B無法共享。
2.2 PIC機制—解決DSO模塊中指令部分無法共享的問題
關于PIC機制的詳解,我的另一篇文章關于Linux KASLR機制的里面有詳細的關于PIC的介紹。
解決思路:DSO模塊中,指令對本模塊內定義的靜態數據和過程的引用,全都編譯成相對尋址,對全局符號(函數和變量)的訪問,不管是內部定義的還是外部模塊定義的,都通過存儲在RW數據段中的GOT表間接訪問。
(1)如何通過GOT表間接訪問
例如DSO1中定義了call func1這條指令,調用的func1過程是定義在DSO2中的,
這時在編譯DSO1的時候會在DSO1的GOT表中分配一個空表項,并將該空表項相對于
Call func1這條指令的offset存儲在DSO1中call指令的operand位置(相對尋址),該
指令最終會被編譯成call *(offset)這種間接尋址方式,在裝載DSO2后,會修正這個GOT
表項,使其指向DSO2模塊中func1被加載到進程地址空間中實際的虛擬地址,實現代
碼的PIC機制,這樣DSO1的指令部分就能被映射到不同的進程的不同地址空間了,因為它是PIC Code。
(2)數據段中存在的絕對地址引用問題
指針變量是數據段中常見的對某變量的絕對虛擬地址的引用,因此其引用的虛擬地址,會隨著數據段被加載到不同進程的不同地址空間而改變,再加上數據段中的數據值是可變的,其本身就不能被多個進程共享,所以通過裝載時重定位的方式可以解決該問題。
例如:static int a = 3; static int a_p = &a; a_p中存儲的絕對虛擬地址是隨著DSO裝載地址的改變而不停變化的,而且這種數據段每個進程都會有自己的副本的,所以可以用裝載時重定位的方法解決。
(3)DSO內部定義的全局變量和全局函數
DSO內部代碼對其內部自定義的全局變量和全局函數的訪問,統一按照extern方式處理,也就是統一通過GOT表間接訪問,即使它們是定義在同一個DSO內部。
3. 動態鏈接與靜態鏈接的本質區別
動態鏈接是在程序運行過程中,可以根據需要(使用了PLT延遲加載技術),由動態LD按需對依賴的外部函數進行綁定的過程。
靜態鏈接是在程序LD成可執行文件的時候就已經將所有需要重定位的地址修正好了,這也意味著所有的依賴庫都要打包成一個可執行文件,這樣就起不到庫文件在不同進程間共享的作用了。
動態鏈接的本質是:通過GOT表將指令中對(內外)全局符號的引用,轉換成對存儲在RW數據區的GOT表項的間接引用,從而實現指令段的PIC,如果要支持延遲加載技術,那么還需要PLT表輔助,動態LD先通過PLT表,更新相應的GOT表項,然后再通過GOT表項間接訪問外部符號。但是這里有個例外,那就是可執行程序也被稱為主程序,對其內部定義的全局符號的訪問是靜態鏈接的(不作為外部符號處理),對DSO中定義的全局函數的訪問都是通過GOT訪問的,而對DSO中定義的全局變量會根據自身是否是PIC code做出不同的處理方式。
3.1 可執行程序不是PIC code
如果訪問了DSO中定義的全局變量,那么會在可執行程序的.bss段中分配一個同名變量,指令對DSO中該變量的訪問將被更改為對.bss段中新分配的變量的訪問;當DSO被加載后,會將其定義的同名變量的初始值復制到主程序的同名變量(.bss段中新分配的),如果DSO中有對該變量訪問的指令的話,還要將其對應的GOT表項重定位指向主程序的同名變量,這樣該變量最終在程序中就只有一個副本可用。后面會詳解這一過程。
3.2 可執行程序是 PIC code
主程序會在GOT表中建立一個訪問該變量的表項,主程序通過GOT表間接訪問DSO中的該變量。
PIC技術是通過相對尋址和相對間接尋址技術,從而實現指令部分的位置無關,對內部靜態數據和過程的訪問完全可以通過offset相對尋址完成,對(內外)全局數據和過程的訪問可以通過相對offset到GOT再間接尋址;因此無論將它加載到任意虛擬地址空間都能正確訪問對應的數據和方法,但是數據里的絕對地址引用是編譯的時候固定的(例如static int a=10;static int * b=&a,這時a存儲的是b的絕對虛擬地址),所以當加載到不同的虛擬地址空間后,一定要重定位的。
總之一旦指令部分PIC后,其操作數地址(offset)是不變的,變的是數據區。
注意:虛擬地址空間肯定是連續的,但是物理空間不一定連續,這點一定要牢記。
第二部分 ELF文件格式
這里將ELF格式拿出來單獨講,是因為下面第三部分要詳細介紹靜態鏈接和動態鏈接詳細過程了,里面有大量各類表的詳細分析,第一部分中的靜態鏈接關系圖和動態鏈接關系圖詳細描述了靜態鏈接和動態鏈接中各種表之間的關聯關系,至于它們之間如何關聯的以及表中各個字段的含義這就要看ELF的具體協議了。
俞甲子的《程序員的自我修養》這本書關于編譯和鏈接講的非常好,對于ELF文件格式講的也很好,本文就不講ELF文件格式這塊了,有興趣的話可以看這本書并參考下面的鏈接看會更好;因為這本書寫的是32位的編譯與鏈接,64位的有些字段含義是有變化的,例如重定位表中的r_info字段包含兩部分信息;32位:低8位表示重定位類型,高24位表示符號在符號表中的索引,而64位:低32位表示重定位類型,高32位表示符號在符號表中的索引。
https://docs.oracle.com/cd/E23824_01/html/819-0690/chapter6-54839.html#chapter7-2
這個鏈接可以當作工具書,里面對ELF的講解非常詳細,值得收藏。
ELF headr 包含(program header table) 和 (section header table )
Program header table: 包含每個program segment and relative info (execute view).
Section header table: 包含每個section header and relative info (link view) .
第三部分 靜態鏈接微觀詳解
首先明確一點:源碼先要編譯成目標文件.o,然后再鏈接成可執行文件或共享對象。
編譯階段生成目標文件,目標文件由各個段(section:鏈接視圖概念)構成,段內對函數或變量的引用分為如下兩種類型處理:
(1)全局函數或變量(文件內部或外部定義的全局符號)
統一作為外部符號處理,因此在重定位表中都有相應的描述項
將全局變量統一作為外部符號處理好理解,因為LD合并同類項之后全局變量在數據段(segment:執行視圖)內的offset就變了,編譯的時候不能用相對尋址,更別說絕對尋址了,LD時還要再次進行重定位,所以編譯時統一當作外部符號,由LD統一進行重定位。
將全局函數統一視為外部函數這點和動態共享庫處理內部定義的全局變量的思想是一樣,因為編譯的時候是不知道引用的函數是定義在文件內部的還是文件外部的,例如被引用的函數定義在引用函數的后面的話,這種情況是先編譯引用函數的,這時還是不知道被引用函數是否是定義在本文件,所以在編譯的時候將對函數的引用統一看作是對外部函數的引用來處理,由LD統一進行重定位。
(2)靜態函數或變量(文件內部定義的靜態符號)
分為兩類:靜態變量和靜態函數
靜態變量:靜態變量也會被當作外部符號處理,需要重定位,想想看為什么,還是因為文件中定義的靜態變量(包括其他文件)最終會被合并成可執行文件的一個segment,所以相對地址會變化的,因此編譯的時候就不能相對尋址了(因為LD合并同類項的時候會變的),故直接將其放到重定位表中,讓LD最后進行重定位。
靜態函數是編譯的時候就可以相對尋址的,不需要重定位,想想看為什么,因為靜態函數肯定是定義在同文件中的,因此編譯的時候可以確定函數不是外部函數,它們之間的相對地址是固定的,即使后面的LD過程合并同類項也不會有變化,所以在編譯的時候就可以確定下來了。
所以在編譯的時候對于全局函數,全局變量和靜態變量的訪問都是當作對外部符號訪問來處理的,唯一的例外就是對靜態函數的處理,在編譯的時候就完成地址綁定了。
下面通過舉例詳細介紹
如圖1所示,main.c中定義了全局變量和靜態變量,以及通過全局指針變量和靜態指針變量分別引用全局變量和靜態變量。
下面通過圖2重定位表描述編譯階段是如何處理這些變量和引用的。
圖2 .rela.text重定位表存儲的是text段中哪些地方需要被重定位的相關信息,其中類型1~6是對不同類型變量的引用,其重定位的基地址是有所不同,下面詳細闡述。
3.1 圖2中類型1重定位記錄分析
Offset=0x1a:表明被重定位的位置在.text段中offset=0x1a處,如下圖5中的R1位置。Info=0x001200000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x12)表明重定位入口的符號在符號表中的索引是index=18。
下圖3符號表最左邊一列就是符號在符號表中的索引:
如上圖3所示:num=18就是符號在符號表中的索引,value=0說明該符號在ndx=5(data.rel.local段)中的offset=0,size=8表明該符號占用的空間為8bytes,type=OBJECT表明該符號是一個對象變量,bind=GLOBAL表明該符號綁定的對象是全局的,ndx=5表明該符號所在的段在段表中的索引等于5,參考下圖4的段表可知, ndx=5就是.data.rel.local段。
由上圖2的sym.name+addend=stat_var_gp-4可得addend=-4,根據ELF linkage協議中定義的relocation_type可知,R_X86_64_PC32類型(相對尋址)的重定位公式是:S+A-P. 具體參見下圖6.
S:符號鏈接后最終的虛擬地址
A: 加數(addend,也稱修正值)
P: 需要被重定位處在鏈接后最終的虛擬地址
S+A-P=S-(P-A)=S-(P+4)
P+4:由圖5 重定位R1處可知就是被重定位處下一條指令的虛擬地址
所以,S-(P+4)就是被重定位處下一條指令到目的地址之間的offset,完成重定位后就是相對尋址了。
由此可知推出:全局符號是基于自身符號地址尋址的,且其重定位的加數(addend)就是被重定位處的長度的負數(因為相對尋址是基于下一條指令的地址到目的地址之間的offset)。
3.2 圖2中類型2重定位記錄分析
Offset=0x2b:表明被重定位的位置在.text段中offset=0x2b處,如下圖5中的R2位置。Info=0x000700000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x07)表明重定位入口的符號在符號表中的索引是index=7。
如上圖3所示:num=7就是符號在符號表中的索引,value=0說明該符號在ndx=5(data.rel.local段)中的offset=0,size=0表明該符號占用的空間為0bytes,type=SECTION表明該符號標識一個段,bind=LOCAL表明該段是文件內部定義的,ndx=5表明該符號所在的段在段表中的索引等于5,參考上圖4的段表可知, ndx=5表示.data.rel.local段。
看到沒有,代碼段對分配在.data.rel.local段中的stat_var_gp和stat_var_sp這兩個指針進行訪問,但是尋址的方式卻不一樣,對全局變量stat_var_gp是基于自身的符號地址進行訪問,而對靜態變量stat_var_sp則基于.data.rel.local段基址進行訪問,想想看這是為什么呢?后面會詳述。
根據圖2中的Sym.name+addend=.data.rel.local+4可得:addend=4.
下面很有必要解釋下這個addend=4是怎么計算得到的。
指令相對尋址的offset是指當前執行指令的下一條指令的起始地址到目的地址之間的差值,
而重定位表中每條記錄的r_offset項(參見下面靜態鏈接關系圖)中只記錄了被重定位處在段中的offset,并沒有記錄其下一條指令的地址或重定位處的長度(因為通過重定位處長度和r_offset可以計算出下一條指令的地址),這樣一來在鏈接的時候就無法計算出它們之間相對offset,而addend就是用來彌補這個鴻溝的。
(1)全局符號
因為全局符號都是基于自己符號地址尋址的,所以addend中存儲的就是被重定位處長度的負數,上面類型1已經解釋過了。
(2)靜態符號(靜態變量和靜態指針變量)
因為靜態符號都是基于自己所在段的基地址尋址的,所以addend中存儲的就是:
被引用靜態符號在其所在段中的offset - 被重定位處的長度。
這里作如下定義,進行隨后的推導過程:
sym_section_offset : 被引用符號在其所在段中的offset
rela_position_len : 被重定位處的長度
假設符號.data.rel.local,也就是該段在可執行文件中最終的虛擬地址是S,要被修正的位置在可執行文件中的虛擬地址是P, 那么根據relocation_type=R_X86_64_PC32其修正的公式是:S+A-P。
A = sym_section_offset - rela_position_len
S + A - P = S + sym_section_offset - (P + rela_position_len)
S + sym_section_offset:就是符號stat_var_sp鏈接后的實際虛擬地址
P + rela_position_len: 就是需要被重定位處的下一條指令的虛擬地址
這樣(S + sym_section_offset) - (P + rela_position_len)就是LD合并同類項后它們之間實際的offset,將這個值更新到P位置上就完成了地址重定位操作。
因此,由圖3符號表可知stat_var_sp在.data.rel.local段中的offset=8,因此addend=8-4=4。
3.3 圖2中類型3重定位記錄的分析
Offset=0x22:表明被重定位的位置在.text段中offset=0x22處,如下圖5中的R3位置。Info=0x000300000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x03)表明重定位入口的符號在符號表中的索引是index=3。
如上圖3所示:num=3就是符號在符號表中的索引,value=0說明該符號在ndx=3(data段)中的offset=0,size=0表明該符號占用的空間為0bytes,type=SECTION表明該符號標識一個段,bind=LOCAL表明該段是文件內部定義的,ndx=3表明該符號所在的段在段表中的索引等于3,參考上圖4的段表可知, ndx=3表示.data段。
因此可知,靜態變量stat_var不是以自己的符號尋址的,而是以其所在的data作為基地址尋址的,關于這一點類型2中已經得出結論,這里是驗證。
根據圖2中Sym.name + addend = .data - 4,可得addend = -4。
根據類型2中給出的計算addend的公式:A = sym_section_offset - rela_position_len
計算過程如下:
由圖3可得符號stat_var在.data段中的sym_section_offset =0
由圖5重定位處R3可得rela_position_len=4
所以,addend = 0 - 4 = -4
3.4 圖2中類型4重定位記錄的分析
Offset=0x36:表明被重定位的位置在.text段中offset=0x36處,如下圖5中的R4位置。Info=0x001400000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x14)表明重定位入口的符號在符號表中的索引是index=20。
如上圖3所示:num=20就是符號在符號表中的索引,value=0x10說明該符號在ndx=5(data.rel.local段)中的offset=0x10,size=8表明該符號占用的空間為8bytes,type=OBJECT表明該符號標識一個對象變量,bind=GLOBAL表明該對象是全局的,ndx=5表明該符號所在的段在段表中的索引等于5,參考上圖4的段表可知, ndx=5表示.data.rel.local段。
根據圖2中Sym.name + addend = .abc_gp -4,可得addend=-4。
因為類型4和類型1都是全局指針,基于自己符號地址尋址。
所以根據類型1的結論可以推出:addend = - rela_position_len = -4
3.5 圖2中類型5重定位記錄的分析
Offset=0x41:表明被重定位的位置在.text段中offset=0x41處,如下圖5中的R5位置。Info=0x000700000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x07)表明重定位入口的符號在符號表中的索引是index=7。
如上圖3所示:num=7就是符號在符號表中的索引,value=0x00說明該符號在ndx=5(data.rel.local段)中的offset=0x00,size=0表明該符號占用的空間為0bytes,type=SECTION表明該符號標識一個段,bind=LOCAL表明該段是文件本地定義的,ndx=5表明該符號所在的段在段表中的索引等于5,參考上圖4的段表可知, ndx=5表示.data.rel.local段。
因此可知,靜態指針abc_sp不是以自己的符號地址尋址的,而是以其所在的.data.rel.local段作為基地址尋址的,關于這一點類型2中已經得出結論,這里是驗證。
根據圖2中Sym.name + addend = .data.rel.local + 14,可得addend=0x14。
根據類型2中給出的計算addend的公式:A = sym_section_offset - rela_position_len,計算過程如下:
由圖3可得符號abc_sp在.data.rel.local段中的sym_section_offset =0x18
由圖5重定位處R5可得rela_position_len=4
所以,addend = 0x18 - 4 = 0x14
3.6 圖2中類型6重定位記錄的分析
Offset=0x4c:表明被重定位的位置在.text段中offset=0x4c處,如下圖5中的R6位置。Info=0x001500000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x15)表明重定位入口的符號在符號表中的索引是index=21。
如上圖3所示:num=21就是符號在符號表中的索引,value=0x00說明該符號在ndx=7(data.rel段)中的offset=0x00,size=8表明該符號占用的空間為8bytes,type=OBJECT表明該符號標識一個對象變量,bind=GLOBAL表明該對象是全局的,ndx=7表明該符號所在的段在段表中的索引等于7,參考上圖4的段表可知, ndx=7表示.data.rel段。
根據圖2中Sym.name + addend = global_var_gp - 4,可得addend = -4。
因為類型6和類型1都是全局指針,基于自己符號地址尋址。
所以根據類型1的結論可以推出:addend = - rela_position_len = -4
3.7 圖2中類型7重定位記錄的分析
Offset=0x57:表明被重定位的位置在.text段中offset=0x57處,如下圖5中的R7位置。Info=0x000a00000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x0a)表明重定位入口的符號在符號表中的索引是index=10。
如上圖3所示:num=10就是符號在符號表中的索引,value=0x00說明該符號在ndx=7(data.rel段)中的offset=0x00,size=0表明該符號占用的空間為0bytes,type=SECTION表明該符號標識一個段,bind=LOCAL表明該段是文件本地定義的,ndx=7表明該符號所在的段在段表中的索引等于7,參考上圖4的段表可知, ndx=7表示.data.rel段。
因此可知,靜態指針global_var_sp不是以自己的符號地址尋址的,而是以其所在的.data.rel段作為基地址尋址的,關于這一點類型2中已經得出結論,這里是驗證。
根據圖2中Sym.name + addend = .data.rel + 4,可得addend=0x04。
根據類型2中給出的計算addend的公式:A = sym_section_offset - rela_position_len,計算過程如下:
由圖3可得符號global_var_sp在.data.rel段中的sym_section_offset = 0x08
由圖5重定位處R7可得rela_position_len = 4
所以,addend = 0x08 - 4 = 0x04
3.8 圖2中類型8重定位記錄的分析
Offset=0x98:表明被重定位的位置在.text段中offset=0x98處,如下圖5中的R8位置。Info=0x001600000002:由兩個部分構成(32:32),低32位(0x02)表明重定位入口的類型是R_X86_64_PC32,高32位(0x16)表明重定位入口的符號在符號表中的索引是index=22。
如上圖3所示:num=22就是符號在符號表中的索引,value=0x00且ndx=UND(undefined)說明該符號是外部符號,文件內部沒有定義,size=0且type=NOTYPE說明符號的最終類型目前還無法確定,因為外部同一個全局符號可以被定義成多個弱類型或多個弱類型+一個強類型且數據類型可以不同,所以此時無法確定(關于強弱符號的解釋請看”程序員的自我修養”一書,里面有詳解),bind=GLOBAL表明該對象是全局的,ndx=UND表明該符號是外部符號,文件內部沒有定義該符號。
根據圖2中Sym.name + addend = global_var - 4,可得addend = -4。
因為類型8是全局符號,基于自己符號地址尋址。
所以根據類型1的結論可以推出:addend = - rela_position_len = -4
3.9 圖2中類型9重定位記錄的分析
Offset=0x7c:表明被重定位的位置在.text段中offset=0x7c處,如下圖5中的R9位置。Info=0x001900000004:由兩個部分構成(32:32),低32位(0x04)表明重定位入口的類型是R_X86_64_PLT32,高32位(0x19)表明重定位入口的符號在符號表中的索引是index=25。
如上圖3所示:num=25就是符號在符號表中的索引,value=0x00且ndx=UND(undefined)說明該符號是外部符號,文件內部沒有定義,size=0且type=NOTYPE說明符號的最終類型目前還無法確定,因為外部同一個全局符號可以被定義成多個弱類型或多個弱類型+一個強類型且數據類型可以不同,所以此時無法確定(關于強弱符號的解釋請看”程序員的自我修養”一書,里面有詳解),bind=GLOBAL表明該對象是全局的,ndx=UND表明該符號是外部符號,文件內部沒有定義該符號。
根據圖6所示:relocation_type=R_X86_64_PLT32=4重定位計算公式為:L+A-P。
L: The section offset or address of the procedure linkage table entry for a symbol
LD對外部函數符號的處理分兩種情況
編譯器在處理該外部函數符號的時候是不知道這個符號是定義在普通對象還是共享對象里的,所以在rel表中統一將其定義為R_X86_64_PLT32類型,在隨后的鏈接過程會根據函數符號是定義在普通對象還是共享對象進行不同的處理。
(1)該函數符號定義在普通對象中
LD將其當做普通的R_X86_64_PC32類型進行處理,這時L+A-P = S+A-P
(2)該函數符號定義在共享對象中
LD將其作為R_X86_64_PLT32進行處理,LD會為其create一個“函數名@plt”過程和在.got.plt表中創建一個表項(用于存儲函數被加載后的實際虛擬地址),并將代碼中對該函數的訪問改為對該過程的訪問,這些操作都要在靜態鏈接的時候完成的,這個過程(函數名@plt)的地址就是L,所以relocate計算公式變為:L+A-P。
最后動態鏈接的時候會將函數的實際虛擬地址更新到.got.plt表項中,這樣該過程通過.got.plt表項就可以間接跳轉到實際要訪問的函數了。
根據圖2中Sym.name + addend = multiple - 4,可得addend = -4。
因為類型9是全局符號,基于自己符號地址尋址。
所以根據類型1的結論可以推出:addend = - rela_position_len = -4
通過對以上9種重定位類型的分析我們可以總結出如下結論:
1. 全局符號(包括全局指針)是以自己的符號地址進行重定位的
2. 靜態符號(靜態變量和靜態指針)是以自己所在的段為基地址進行重定位的
3. 指向非外部符號的指針(全局和靜態)都被分配在.data.rel.local段中
4. 指向外部符號的指針(全局和靜態)都被分配在.data.rel段中
首先要搞清楚.data.rel.local段的含義:.data表明它是一個數據段,.rel表明這個數據段中的數據是對其他符號的引用(例如int* var_p = &var),是需要被重定位的,.local表明這個被引用的符號是在本文件定義的(例如文件內部定義了int var=3),而不是外部符號(extern int var)。根據以上的解釋:.data.rel段的含義大家應該都明白了,是變量引用了外部符號,需要被重定位這里不闡述了。
注意:不管是.data.rel.local段還是.data.rel段,其內部定義的數據變量既可以是全局變量,
也可以是僅內部可見的靜態變量,這點一定要搞清楚,下面舉例說明。
例如: int a = 3; int* a_p = &a; static int* s_a_p = &a;
全局指針變量a_p和靜態指針變量s_a_p都會放在.data.rel.local段中,
但對它們的尋址是不同的:全局指針變量a_p = a_p + addend; 而靜態指針變量s_a_p = .data.rel.local + addend。
如果將int a = 3改為extern int a的話,全局指針變量a_p和靜態指針變量s_a_p都會放在.data.rel段中,對它們的尋址也會變為:全局指針變量a_p = a_p + addend; 而靜態指針變量s_a_p = .data.rel + addend。
看到這里可能會問為什么靜態符號都是以所在段的基地址為base尋址呢?
因為靜態變量(包括靜態指針變量)是文件內部定義的僅內部可見符號,所以當LD在合并同類項,建立全局符號表時是不會記錄這些僅文件內部可見的符號的,但會通過它們所在的段基地址+addend來定位它們。
例如:static int a=3; 是通過.data + addend來定位靜態變量a
static Int* a_p = &a; 是通過.data.rel.local + addend來定位靜態變量指針a_p
extern int b; static int* b_p = &b; 是通過.data.rel + addend來定位靜態變量指針b_p
通過以上的解釋大家有沒有發現一個現象,僅內部可見的符號都是以所在的段基地址為base進行尋址的,這樣做的好處就是全局符號表中只需要記錄全局符號和段符號就行了,大量的僅內部可見符號就不用記錄了。
下面列舉重定位表rela.data.rel.local中的兩條記錄來分析如何對數據區內的指針變量進行重定位。
首相要明白指針是對一個符號的絕對地址引用,所以relocation_type=R_X86_64_64(絕對地址引用)。
3.10 圖2中類型10重定位記錄的分析
Offset=0x08:表明被重定位的位置在.data.rel.local段中offset=0x08處,如下圖5中的R10位置。Info=0x000300000001:由兩個部分構成(32:32),低32位(0x01)表明重定位入口的類型是R_X86_64_64,高32位(0x03)表明重定位入口的符號在符號表中的索引是index=0x03=3。
如上圖3所示:num=3就是符號在符號表中的索引,value=0x00說明該符號在ndx=3(data段)中的offset=0x00,size=0表明該符號占用的空間為0bytes,type=SECTION表明該符號標識一個段,bind=LOCAL表明該段是文件本地定義的,ndx=3表明該符號所在的段在段表中的索引等于3,參考上圖4的段表可知, ndx=3表示.data段。
因此可知,靜態變量stat_var_bk不是以自己的符號地址尋址的,而是以其所在的.data段作為基地址尋址的。
根據圖2中Sym.name + addend = .data + 4,可得addend=0x04。
由于relocation_type=R_X86_64_64所以根據圖6得知其計算公式為:S+A
S:是符號鏈接后的虛擬地址
A:加數(修正值)
stat_var_bk是以.data段作為基地址尋址的,由下圖5可知stat_var_bk在.data段內的offset=4
所以,符號stat_var_bk的虛擬地址=.data連接后的虛擬地址+offset=S+offset。
所以可以推出:addend=符號在所在段的offset.
3.11 圖2中類型11重定位記錄的分析
Offset=0x10:表明被重定位的位置在.data.rel.local段中offset=0x10處,如下圖5中的R11位置。Info=0x001300000001:由兩個部分構成(32:32),低32位(0x01)表明重定位入口的類型是R_X86_64_64,高32位(0x13)表明重定位入口的符號在符號表中的索引是index=19。
如上圖3所示:num=19就是符號在符號表中的索引,value=0x08說明該符號在ndx=3(data段)中的offset=0x08,size=4表明該符號占用的空間為4bytes,type=OBJECT表明該符號標識一個對象,bind=GLOBAL表明綁定的是全局對象,ndx=3表明該符號所在的段在段表中的索引等于3,參考上圖4的段表可知, ndx=3表示.data段。
因此可知,全局變量abc是以自己的符號地址尋址的。
根據圖2中Sym.name + addend = .data + 0,可得addend=0x00。
由于relocation_type=R_X86_64_64所以根據圖6得知其計算公式為:S+A。
S就是全局變量abc的虛擬地址,所以可以推出:addend = 0x00。
由類型10和11可以得出如下結論:
指針的重定位操作分為兩種類型:對靜態變量引用和對全局變量引用
(1)對靜態變量引用
公式S+A中的S是段基址,那么A就是符號在該段中的offset
(2)對全局變量的引用
公式S+A中的S就是實際符號地址,那么A值永遠為0。
下面看一下main.c依賴的外部模塊funcs.c的對象文件及相關的重定位表。
從圖1中funcs.c的定義可以看出,函數set_multiple_index和函數multiple是定義在同一個文件中的,但是從下面圖7 中funcs.o的重定位表可以發現multiple對set_multiple_index函數的調用是需要重定位的,將set_multiple_index當作外部符號處理了。
重定位類型是:R_X86_64_PLT32,參考上面的main.c中對multiple調用的處理,這里就不多做解釋了。
編譯時對調用靜態方法的處理與對全局方法的處理是不同的
由圖5中對divide方法的調用可以看出,在編譯時就通過相對尋址設置好了跳轉地址,在圖2的重定位表中也沒發現要對divide函數調用進行重定位,所以靜態方法調用是不需要重定位的,因為首先它肯定是在文件內有定義的,其次調用它的代碼塊肯定與靜態方法編譯在同一個.text段中的,因此它們之間的offset就固定下來了,即使LD合并很多.text段在一起形成一個segment,它們之間的offset也不會改變,因此可以在編譯時就設定好。
下圖11是main.c鏈接成可執行文件后main的匯編代碼,從中可以看出對divide調用的offset值與圖5 main.o中的offset是一樣的。
第四部分 動態鏈接微觀詳解
4.1 動態鏈接的主要有2大優點
1. 共享DSO,節省內存
2. DSO版本更新后,程序不需要重新編譯
要實現以上兩點,DSO必須是PIC代碼。
4.2 動態鏈接有1個缺點
在運行時要一次性鏈接整個程序依賴的包(DSO如果已經存在于內存的話,僅需要relocate and remapping),這樣程序運行的速度肯定會慢的(所以比靜態共享庫要慢,后面會講靜態共享庫的優缺點)。尤其是程序運行的過程中,有可能會走不同的分支,從而運行不到有些依賴包,這時也把這些包鏈接和加載進內存會大大消耗時間和內存。
為了克服這個缺點,就有了PLT技術,按需加載,用到了才加載并鏈接。
但有一點一定要注意:如果對DSO包定義的全局變量有引用的話,那么不管該變量的引用是否會被執行到,該DSO包都會被加載到內存并對該全局變量的引用重定位,因為PLT是對函數調用的延遲加載技術,而全局變量訪問是沒有這項技術的,所以函數可以是RTLD_LAZY,但變量一定要RTLD_NOW。
4.3 動態鏈接庫/對象為什么要是PIC code
(1)共享對象(dso)要想被不同進程共享必須是PIC code
想想看如果dso包不是PIC code的情形
如果sample.dso包被加載到進程A的0x3000~0x4000虛擬地址空間,那么該包中所有對絕對虛擬地址的引用(指令部分)都要以0x3000作為virt_base進行更新;當進程B要把該包加載進自己0x7000~0x8000虛擬地址空間,那么就無法共享進程A加載的sample.dso包的指令部分了。
(2)DSO包的更新與發布必須要PIC code
下面以靜態共享庫為例說明。
靜態共享庫的虛擬基地址是固定的,所以每個進程要想引用靜態共享庫,那么在每個進程的虛擬地址空間中,必須預留固定的虛擬地址區間用于該靜態共享庫。
例如sample.sso是靜態共享庫且其虛擬基地址是0x4000,占用一頁大小(0x1000),這樣每個要用到它的進程都要在自己的進程地址空間中預留0x4000~0x5000這個虛擬地址區間給sample.sso。
程序在靜態鏈接的時候就可以完成所有對靜態共享庫的函數和全局變量引用的重定位工作(靜態共享庫在進程中的虛擬地址是固定的),但是不會在此時把靜態共享庫鏈接到應用程序中,而是等到運行時才加載到內存并映射到程序固定的虛擬地址區間,通過這樣的方式實現庫文件在不同進程之間的共享,這樣只保留一份庫文件在內存中,從而實現節省內存和減少程序本身大小的問題,而且運行時僅需加載一次但不需要重定位了,所以速度比DSO要快。
但這樣的方式實現庫共享會導致以下兩個大問題:
(1)進程虛擬地址的利用很不靈活很容易造成地址沖突
(2)靜態庫文件如果版本升級后其中的函數或全局變量的地址一旦發生改變,原來的應用程序必須重新鏈接。
所以PIC code就是為了解決問題1,裝載時重定位就是為了解決問題2,這樣靜態共享庫就編程動態共享庫了。
4.4 編譯和鏈接的總特點
1.4.1 編譯器在編譯的時候(注意不是鏈接),對象內對所有全局變量(包括內外定義或聲明的)的引用,不管是聲明的還是定義的,默認都是當作外部符號處理的,都會放到重定位表中去。
1.4.2 連接器在鏈接可執行文件的時候,會遍歷程序所有的符號表(包括依賴的共享庫),并做如下的操作。
(1)首先檢查主程序定義的全局強符號與所有共享庫的全局強符號是否有重定義沖突。
(2)檢查主程序及共享庫中所有以extern聲明的全局符號,在其他的模塊和共享庫中是否有定義,如果沒有定義就會報符號未定義的鏈接錯誤。
(3)如果主程序中聲明的extern全局符號在其它的模塊中定義了,那么在靜態鏈接的時候就直接重定位了(鏈接時已經知道其虛擬地址了)。
(4)如果主程序中聲明的extern全局符號在其它的共享庫中定義了,那么此時還無法決定該符號的實際虛擬地址是多少,那么就根據主程序是否是PIC code進行不同的處理,后面會詳解。
(5)在鏈接共享庫自身的時候,是不會檢查共享庫中用到的但聲明為extern 的全局符號是否有定義,但當將共享庫鏈接到主程序的時候,連接器會檢查共享庫用到的但聲明為extern 的全局符號在全局符號表中是否有定義。
例如在sample.dso共享庫中聲明了一個extern int ext_var; 并且被其定義的funcA使用了;而Test.c主程序依賴該sample.dso庫,并且調用了其定義的方法funcB(沒有使用ext_var),即使這樣在鏈接Test.c為可執行程序的時候也會報undefined reference to ext_var這樣的錯誤,如果在Test.c中或其他依賴的庫文件中定義了該變量就不報錯了。
4.5 對extern變量和弱類型變量的處理是動態鏈接中比較復雜的部分。
下面從編譯和鏈接兩個階段分別講解
4.5.1編譯階段
1. extern 變量被當做外部符號(ndx=UND)處理,如果代碼和數據有對其引用,重定位表中有R_X86_64_PC32類型的重定位記錄。
2. 弱類型變量(例如這樣: int a;僅聲明未定義的變量)會被當做COMMON類型的符號(ndx=COM)來處理,如果代碼和數據有對其引用,重定位表中有R_X86_64_PC32類型的重定位記錄。
COM類型在編譯的時候其實也可以被看作是UND類型,因為它不屬于當前文件的任何一個段,之所以將其定義為COM類型,那是因為后面LD的時候有用。
4.5.2 鏈接階段
根據ndx=UND和ndx=COM的區別,分開講解
4.5.2.1 ndx=UND
說明該變量是extern聲明的外部變量,在本文件中沒有定義,所以不屬
于當前文件的任何一個段,對它的處理分兩種情況。
1. 共享對象中聲明的extern變量
因為共享對象中,對(內外)全局符號(函數和變量)的訪問,都被當做外部符號來處理,所以extern聲明的變量當然也就按照外部符號處理了,最終鏈接成的共享對象的.rela.dyn段中,會有一條R_X86_64_GLOB_DAT類型的重定位記錄。
2. 可執行文件中聲明的extern變量
這里又分成兩種情況
(1)該extern變量是定義在主程序的其它模塊文件中,那么就按照靜態鏈接來處理,在鏈接成主程序的過程中,就重定位好了,相對尋址即可。
(2)該extern變量是定義在DSO對象中的話,那么又可以分成兩種情況來處理。
【1】如果可執行文件不是PIC code的話
那么必須要在.bss段中分配一個該變量的副本,然后重定位到該處即可,在加載DSO的過程中,會將DSO中的該變量的初始值COPY到可執行程序.bss段中的這個變量的副本上,然后會重定位DSO中該變量對應的.got表項,使其指向主程序.bss段中的這個副本。
可能有人會問,為什么不能在主程序的.got中分配一個表項,然后重定位到共享對象中定義的該變量,這樣多簡單方便,因為主程序對共享對象中定義的函數引用就是這么干的(通過.got.plt表項);稍后會做詳細解讀,這里可能大多數人都沒搞懂,而且俞甲子的那本書說的也不對,主要原因是:指令對數據訪問地址無關性的處理和對函數調用地址無關性的處理機制不一樣。
【2】如果可執行文件是PIC code的話
通過在.got表中分配一個表項,用于存儲DSO中定義的該變量加載后的實際虛擬地址,實現重定位。
4.5.2.2 ndx=COM
鏈接階段對ndx=COM的處理也分為兩種情況。
1. DSO中聲明的弱類型變量
鏈接的時候會在最終的DSO對象的.bss段中為其分配空間,然后.rala.dyn段中會有一條R_X86_64_GLOB_DAT類型的重定位記錄。
2. 主程序中聲明的弱類型變量
有3種情況需要考慮
(1)主程序所依賴的DSO中沒有該變量的聲明或定義。
會在主程序的.bss段中為其分配空間,然后根據重定位記錄重定位。
(2)主程序所依賴的DSO中也僅有該變量的聲明(弱類型)。
會在主程序的.bss段中為其分配空間,然后根據重定位記錄重定位。
(3)主程序所依賴的DSO中有該變量的定義(強類型,以它為準)。
這又得分成兩種情況來看
【1】主程序不是PIC code
這時就相當于對DSO中該變量的引用了,所以會在.bss中分配一個副本,且在.rela.dyn段中會新增有一條R_X86_64_COPY類型的重定位記錄,后面會詳解。
【2】主程序是PIC code
這時就相當于對DSO中該變量的引用了,但不會在.bss中分配一個副本,而是會在.got表中增加一個指向該變量的表項,并在.rela.dyn段中會新增有一條R_X86_64_GLOB_DAT類型的重定位記錄,后面會詳解。
4.6 舉例詳解以上列舉的動態鏈接過程中的各類重定位類型
下面是主程序和兩個DSO的源碼
4.6.1 分析funcs.c的編譯和鏈接
因為maths.c是一個純粹的函數庫,沒有需要重定位的引用,所以這里先分析funcs.c的編譯和鏈接,看看DSO庫或對象是如何生成的。
如圖15所示:通過gcc -fPIC -c funcs.c -o funcs.o命令生成PIC目標對象。
由上圖15所示:
所有對全局變量的訪問(不管是弱類型還是強類型,或是extern類型)都會有相應的R_X86_64_REX_GOTP類型重定位記錄,該類型告訴鏈接器要創建.got表并且將所有對全局變量的訪問都修改為通過.got中的表項間接訪問,從而實現指令訪問全局變量的地址無關性。
所有對全局函數的訪問都會有相應的R_X86_64_PLT32類型重定位記錄,該類型告訴鏈接器要建立.got.plt表并且將所有對全局函數的訪問都修改為通過.got.plt中的表項間接訪問,從而實現指令訪問全局函數的地址無關性。
圖15中重定位表.rela.text中的各個字段的含義這里就不一一解釋了,因為上一章的靜態鏈接詳解中已經詳細介紹過了,這里僅對weak類型變量的Sym.value值解釋一下;因為weak變量的編譯后的ndx=COM不在任何段中,所以其Sym.value值表示該變量的長度,而不再是符號在段中的offset了,這點一定要清楚,這是ELF協議規定的。
下圖17 funcs.o的符號表中,高亮部分顯示了weak_var1和weak_var2的ndx=COM,weak_var3在本文件中確實有定義,所以其ndx=3(.data段);set_multiple_index是在本文件定義的,所以其ndx=1(.text段),而math_add不是本文件定義的,所以其ndx=UND。
下面看看執行完鏈接指令ld -fPIC -shared -o funcs.dso funcs.o 后得到的動態共享對象,其重定位表有哪些變化。
下面通過funcs.dso的符號表,段表,數據段表和代碼段表詳細闡述DSO對象在加載的時候是如何被重定位的。
如圖19所示,weak_var1,weak_var2和weak_var3的ndx分別為14,14和13;再看看段表圖20可知ndx=14是.bss段,ndx=13是.data段。
所以對于弱類型在鏈接DSO的過程中,最終會在.bss段為其分配內存空間的。
首先得詳細介紹下如何利用PLT技術實現延遲或按需綁定外部函數。
如果不使用PLT技術的話,程序啟動后,動態鏈接器會將程序中所有要訪問的外部符號的實際虛擬地址更新到對應的.got表項中(當然先要加載對應的DSO),程序的指令部分是通過.got表項間接尋址這些外部符號的,從而實現了指令的PIC。
而PLT(procedure linkage table)相當于在指令間接尋址和.got表之間又加了一層間接尋址,它是一段代碼,這點一定要搞清楚。
因為PLT是專門用于對外部函數引用的延遲綁定,所以將原先的一個.got表分成了兩個表:
.got表: 專門用于存儲外部全局變量的實際虛擬地址
.got.plt表:專門用于存儲外部全局函數的實際虛擬地址
.got.plt表的結構稍微有點特殊,它的前三項分別被用于存儲:.dynamic段地址,本模塊ID和dl_runtime_resolve的地址;之后的表項才被用于存儲每個外部函數的實際虛擬地址。
.plt段:是個代碼段,專門用于將被調用到的外部全局函數的實際虛擬地址綁定到對應的.got.plt表中。
它的結構圖22的高亮注釋對其進行了詳細解釋,下面通過對math_add的綁定再次進行詳細解讀。
math_add是funcs.dso的外部函數,因此在funcs.dso中有一個math_add@plt項,圖22中的0x108d處對math_add的調用就變成先跳轉到math_add@plt(相對跳轉),而不是直接通過.got表項間接跳轉到外部函數。
下面看看math_add@plt項干了什么。
如代碼圖22中0x1020處所示:
第一條指令間接跳轉到.got.plt表項0x4020處存儲的地址,由圖21可得0x4020處存儲的地址是0x1026,而0x1026就是math_add@plt的下一條指令地址,該指令將math_add符號引用在重定位表.rela.plt中的下標入棧,第三條指令是跳轉到0x1000處。
0x1000處的.plt項是每個plt函數項的都要執行的部分,因此將其獨立出來單獨成項。
.plt項第一條指令是將本模塊的moduleID入棧,然后跳轉到dl_runtime_resolve函數執行math_add的綁定工作,dl_runtime_resolve函數會根據外部函數引用在.rela.plt重定位表中的索引(入棧的參數),解析處需要的重定位信息,查找全局重定位表,找到math_add符號的實際虛擬地址,并根據重定位類型R_X86_64_JUMP_SLO = S(符號實際虛擬地址),將math_add的實際虛擬地址直接更新到.got.plt表項0x4020處,最后再返回繼續執行call math_add@plt,就跳轉到maths.dso的math_add執行了。
以上詳述了如何通過PLT技術延遲綁定外部函數引用,下面將講一下如何綁定外部變量的引用。
由圖18可知,動態鏈接的重定位表被分為兩類:.rela.dyn和.rela.plt。
.rela.dyn : 用于對外部變量引用的重定位
.rela.plt : 用于對外部函數的重定位
我們看一下圖18中.rela.dyn表的第一條記錄是怎么重定位的。
Offset Info Type Sym. Value Sym. Name + Addend
000000003fe0 000400000006 R_X86_64_GLOB_DAT 0000000000004034 multiple_index + 0
Offset=0x3fe0是.got表的起始位置,說明此處用于存儲multiple_index的實際虛擬地址。
Info.relocation_type=0x06=R_X86_64_GLOB_DAT = S表明就是將multiple_index的實際虛擬地址更新到0x3fe0即可。
Info.symbol_index=0x04說明該符號在.dynsym動態符號表中的索引是4,看一下圖18可知multiple_index是分配在.data段中的全局對象。
此時的Sys.value是鏈接DSO對象時分配的虛擬地址0x4034(以0x00為基地址),圖21中顯示此處屬于.data段且存儲的值為0x03,在動態鏈接的時候會將funcs.dso被加載到進程的虛擬基地址+Sys.value形成最終的實際虛擬地址并更新到全局符號表中,隨后會遍歷.rela.dyn重定位表并檢索全局符號表中multiple_index的實際虛擬地址,最后更新到對應的.got表項,到此就完成了對外部變量引用的重定位了。
這里可能大家會有個疑問,為什么對外部變量引用不應用PLT技術進行延遲綁定呢?
這里解釋一下,一方面是因為DSO對象之間相互引用全局變量這種情況本身就是要盡量避免的,因為這會增加共享模塊之間的耦合度,不利于功能擴展,所以需要被重定位的量相對于函數來說比較少,沒必要延遲綁定;另一方面就是從技術實現上來說在鏈接階段也不可能,除非在編譯階段就做好了這方面的考慮,后面會通過解釋主程序(非PIC)引用共享對象中的變量時會在自己的.bss段中分配一個COPY對象的原因來回答這個問題。
4.6.2 下面詳細分析主程序被編譯和鏈接成PIC和非PIC code時,對共享對象中函數和變量的引用是如何處理的。
4.6.2.1 主程序是PIC code
用命令:gcc -fPIC -c -main.c -o main.pic.o將main.c編譯成PIC code模式,重定位表如下圖23所示:
這里和圖15 funcs.o的重定位表的重定位類型一樣,分為兩類:
(1)所有對全局變量的訪問(不管是弱類型還是強類型,或是extern類型)都會有相應的R_X86_64_REX_GOTP類型重定位記錄,該類型告訴鏈接器要創建.got表并且所有對全局變量(內外定義或聲明的)的訪問都修改為通過.got中的表項間接訪問,從而實現指令訪問全局變量的地址無關性。
(2)所有對全局函數的訪問都會有相應的R_X86_64_PLT32類型重定位記錄,該類型告訴鏈接器要建立.got.plt表并且將所有對全局函數的訪問都修改為通過.got.plt中的表項間接訪問,從而實現指令訪問全局函數的地址無關性。
下圖24是對main.pic.o的部分反匯編,這里主要關注如何實現訪問全局變量和全局函數的地址無關性。
圖24中注釋,詳細分析了如何實現對全局函數inner_add和全局變量dso_var引用的PIC。
對inner_add引用的分析
地址0x22處的指令,操作碼E8表明它是call指令相對跳轉,后面的4個字節是offset(需要被重定位),由圖23 重定位表可知,此處是對inner_add引用的重定位。
對該處offset的重定位分兩種情況:
1. 如果inner_add是主程序內部自定義的,那么靜態鏈接的時候就可以計算出inner_add相對于調用指令之間的offset了,因此靜態鏈接的時候就實現重定位了。
2. 如果inner_add是外部DSO中定義的,那么靜態鏈接的時候無法知道其虛擬地址,所以就得通過.got表項實現運行時動態鏈接重定位。
注意:可執行程序也是通過在外部函數引用與.got表之間增加一層間跳轉(函數名@plt過程),實現外部函數引用的PIC,但是不分.got和.got.plt兩個表了,統一都放在.got表中,.got的前三項分別用來存儲: .dynamic段地址,本某塊moduleID和dl_runtime_resolve地址;而且主程序中對外部函數的引用是立即重定位(盡管指令也是通過call 函數名@plt調用,但不起作用)和外部變量引用的處理是一樣,下面會debug驗證這一點。
下面看看main.pic.o被鏈接成可執行文件后的重定位表和反匯編代碼是什么樣
用gcc -o main.pic main.pic.o ./funcs.dso ./maths.dso生成main.pic可執行文件
由上圖25可知,只有對外部變量dso_var,內部聲明weak_var3和外部函數multiple的引用需要重定位,而之前main.pic.o的重定位表(圖23)列出的對inner_add,weak_var1和weak_var2引用的是需要重定位的,這里沒有了。
下面結合main.pic的反匯編代碼段和數據段詳細分析為什么這三個符號不需要重定位了。
PIC主程序中對自定義的全局函數inner_add的處理
圖26中0x1167地址處是對inner_add引用的重定位,是直接修正為相對跳轉到inner_add(0x1145),從中可以看出兩點:
1. 沒有在.plt段中創建一個inner_add@plt過程用于間接訪問和重定位.got中其對應的表項。
這和鏈接器處理共享對象中自定義的函數之間引用不一樣。
從圖15(目標對象funcs.o)和圖18(目標對象main.pic.o)中可以看出,編譯階段它們對內部自定義的set_multiple_index和inner_add的處理,用的都是一樣的重定位類型:R_X86_64_PLT32。
但鏈接階段不一樣,共享對象中即使函數都是定義在同一個DSO中,但是它們之間的調用還是通過.got.plt表間接跳轉的。
可以看一下funcs.dso中的重定位表(圖18).rela.plt,可以看出multiple和set_multiple_index這兩個全局函數雖然都是定義在funcs.dso中的,但是multiple調用set_multiple_index函數還是要通過.got.plt間接調用。
2. 沒有在.got表中創建一個表項,用于存儲inner_add實際虛擬地址。
這個就好明白了,指令(e8)是相對跳轉指令且沒有在.plt段中創建間接跳轉項(inner_add@plt),是在靜態鏈接的時候就修正好了,所以不會在.got表中創建表項了。
PIC主程序對自己聲明的弱類型全局變量的處理
這里主要分為2種情況
情況1: 主程序和依賴共享對象中都聲明了同名的弱類型
weak_var1和weak_var2在main.c和funcs.c中都聲明為弱類型
那么會在主程序的.bss段為weak_var1和weak_var2分配空間,這里不管是int還是long數據類型都分配了8bytes,如圖26 main.pic的數據部分所示。
我們知道目標對象funcs.o的重定位表(圖15)顯示,weak_var1和weak_var2的重定位類型都是R_X86_64_REX_GOTP,這是指示LD在鏈接的時候在.got表中創建相應的表項并通過.got表項間接訪問weak_var1和weak_var2。
共享對象funcs.dso的重定位表(圖18)顯示,weak_var1和weak_var2的重定位類型都是R_X86_64_GLOB_DAT,表明LD在鏈接的時候確實為它們在.got中創建表項了并通過got表項間接訪問。雖然已經在(圖21)funcs.dso的.bss段中為它們分配了空間了嗎,但是此時還不知道其他的模塊(例如主程序)或DSO中是否定義了同名的強類型,如果有定義了強類型這里就要重定位到強類型的地址了,如果沒有就重定位到自己的.bss段中分配的變量。
目標對象main.pic.o中的重定位表(圖23)顯示,weak_var1和weak_var2的重定位類型都是R_X86_64_REX_GOTP,這也是指示LD在鏈接的時候在.got表中創建相應的表項并通過.got表項間接訪問weak_var1和weak_var2;但因為main.pic.o是主程序,所以在鏈接為可執行程序的時候,會檢索自己及其依賴的所有對象的符號表,從而能夠確認這兩個符號沒有請類型定義,所以就會將分配在自己.bss段中的weak_var1和weak_var2在符號表中設定為強類型符號,然后對這兩個變量的引用也不經過.got表了,其依賴對象funcs.dso中對這兩個變量的訪問也都會重定位到main的.bss段中來(因為main中的被改為強類型了)。
這里要著重解釋一下這兩個變量的重定位方式是如何轉變的。
我們知道目標對象main.pic.o中已經將對weak_var1和weak_var2的訪問分成兩步來完成了,如圖24(main.pic.o代碼部分)的0x62和0x69兩處地址所示:
第一步:48 8b 05 00 00 00 00 mov 0x0(%rip),%rax
寄存器相對尋址從.got表項中取出weak_var1的實際虛擬地址存入%rax。
第二步:8b 10 mov (%rax),%edx
寄存器尋址讀取weak_var1的值
現在主程序中可以通過寄存器相對尋址(也就是第一步)一步就訪問到存儲在main.pic的.bss段中的weak_var1了,但現在這里已經被編譯成分兩步訪問了,怎么辦?
有兩個辦法:
【1】不用修改第一步指令
LD的時候還是為weak_var1在.got中創建表項,并在rela.dyn中創建該變量重定位記錄,這樣在運行main主程序的時候動態鏈接器會將main運行時bss段中weak_var1的實際虛擬地址更新到其對應的.got表項中了,從而完成重定位。這也就和DSO對象的處理方式一樣了。
【2】修改第一步指令
通過lea指令和(%rip)相對尋址相結合的方式,實現運行時動態獲取bss段中weak_var1的實際虛擬地址;但這種方式有個前提:不能改變指令的長度。
目前LD就是采用了這種方式將第一步改為:
圖27中0x1127處:48 8d 05 6a 2e 00 00 lea 0x2e6a(%rip),%rax
指令長度都為7bytes,沒有變。
為什么指令長度不能變呢,如果能變的話就簡單了,直接將第二部去掉不就行了嗎,通過寄存器相對尋址一步就搞定了,想想看為什么?
因為代碼段中如果存在大量相對跳轉指令的話,一旦你增加或減少了指令的長度,很可能會導致這些跳轉目的地址是錯誤的了,想想看如果有大量判斷語句以weak_var1為判斷條件進行跳轉,尤其是通過標號的跳轉,這些跳轉都是相對尋址的,別指望LD會幫你對這些進行修改,不可能的,LD這時壓根不知道上下文的聯系和邏輯。
這才是非PIC主程序如果引用DSO中的變量的時候,靜態LD要在自己的bss段中分配一個該變量的COPY的根本原因。
因為在用非PIC方式編譯主程序時,對全局變量的訪問就是用一條寄存器相對尋址的(也就是上面的第一步)搞定,在鏈接的時候其實是知道這個變量是被主模塊定義的還是被DSO對象定義的,但是代碼的長度已經固定了,不能改了。
如果是主模塊自己定義的話,那么靜態LD的時候直接重定位就好了。
如果是DSO對象中定義的話,那么LD的時候絕不能改為在該寄存器相對尋址指令(上面的第一步)的后面再加一條寄存器尋址指令,在GOT表中增加一項,最終通過got表間接尋址的方式尋址。
所以LD最終采用了這個方式:在主模塊的bss段中為該變量分配一個副本,然后直接重定位到這個副本。
情況2: 主程序中定義了同名弱類型,而依賴對象中定義了同名強類型
因為主程序是使用PIC方式編譯的,所以對所有全局變量(內外)的訪問在編譯時就確定通過如上所述的兩步訪問模式尋址的。
weak_var3是DSO中定義的全局變量,在鏈接器鏈接主程序的時候,會檢索自己包括依賴對象的符號表的,從而可以確定是對funcs.dso中定義的該變量的引用,所以是不會在自己的bss段再為其分配空間了,接著會在.got中為其分配一個表項,將第一步寄存器相對尋址重定位到該表項,這樣在程序運行并通過動態鏈接器加載funcs.dso并且將weak_var3的實際虛擬地址更新到對應的got后,如上所述的第一和第二步就可以通過got表間接訪問weak_var3了。
4.6.2.2 主程序不是PIC code
下面通過分析非PIC主程序的重定位表,反匯編代碼來看看與其PIC code的區別。
通過gcc -c -o main.o main.c 編譯出非PIC main.o
如圖28所示,在非PIC模式編譯時,對全局變量和方法(內外)都是當做外部符號處理的,都是需要重定位的,不過重定位類型和PIC模式編譯的不一樣。
(1)方法的重定位類型都是:R_X86_64_PLT32。
這里著重講一下,為什么方法在PIC和非PIC模式的編譯下都可以是R_X86_64_PLT32呢?
通過上面的講解我們知道,在編譯時,對全局變量的訪問會根據PIC和非PIC模式生成的指令個數是不一樣的,PIC模式會生成兩條指令,非PIC模式會生成一條指令,所以LD的時候考慮的情況就比較多。
而方法的引用在兩種編譯模式下就是一條相對跳轉指令搞定,這是因為如下原因:
1. 如果引用的方法是在主模塊中定義的,那么在靜態鏈接的時候就知道它們之間的offset了,所以靜態LD時通過計算出來的offset,直接修改該指令的操作數就完成重定位了。
2. 如果引用的方式DSO中定義的,那么在靜態鏈接的時候還不知道方法的實際虛擬地址,所以在靜態鏈接的時候,會先創建.plt段及函數名@plt表項并且重定位call指令使其相對跳轉到該表項,然后再在.got表中分配一個該方法的表項并且表項中存儲函數名@plt過程的第二條指令的地址,從而實現通過.got.plt表對方法引用的延遲綁定。
(2)全局變量的重定位類型都是R_X86_64_PC32
這種類型的重定位公式:S+A-P,就不解釋了,靜態鏈接那章已經詳細解釋過了,總是相對尋址形式。
下面看一下鏈接后可執行程序的重定位表有什么變化。
通過 gcc -o main main.o ./funcs.dso ./maths.dso 生成main可執行文件。
下面結合圖29著重分析R_X86_64_COPY類型的作用。
.rela.dyn重定位表中顯示:對weak_var3和dso_var的引用的重定位類型變成R_X86_64_COPY類型了,上面已經講過非PIC程序對全局變量的訪問一律都是用一條寄存器相對尋址指令實現的,而此時外部定義的變量weak_var3和dso_var的實際虛擬地址還不知道呢,所以就在主程序的.bss段中分配一個weak_var3_copy和dso_var_copy,并將指令修正到指向這些副本,當主程序運行時,會處理重定位表.rela.dyn中的所有重定位記錄(變量的重定位是沒有延遲綁定的),首先會加載funcs.dso,這時內存中就有兩個weak_var3和兩個dso_var了,此時
動態鏈接器會將funcs.dso中這連個變量的值copy到副本中,實現數據的一致性。
當主程序執行到multiple方法時,動態鏈接器就會根據funcs.dso中的重定位記錄,將funcs.dso中的.got表中用于存儲weak_var3和dso_var實際虛擬地址的表項,重置為存儲主程序.bss中分配的 weak_var3_copy和dso_var_copy的實際虛擬地址,至此分別完成了主程序中副本數據的初始化和funcs.dso中對這兩個變量引用的重定位,而funcs.dso中自定義的這兩個全局變量就廢棄了。
可能有人會問,可以對寄存器相對尋址這一條訪存指令,建立一個重定位記錄,這樣當把funcs.dso加載到進程的地址空間后,就知道weak_var3或dso_var的實際虛擬地址了,然后計算該訪存指令到weak_var3或dso_var的實際虛擬地址之間的offset,然后重定位該指令的操作數,這樣不就可以了嗎?
的確是可以這樣,但相對尋址要修正的操作數的長度是4bytes,也就是最大4G的尋址空間,而對于64位處理器來說,當funcs.dso映射到進程的虛擬地址基地址到調用指令之間的距離一旦大于4G,那么就無法訪問了,所以這種方式有缺陷。
下面非PIC主程序的數據段和代碼段很好的印證了以上的分析
好了羅里吧嗦這么多終于解釋完了動態鏈接的各種細節。下面就通過debug PIC模式的main程序來驗證一下是否如上所說。
因為加入了debug信息,所以有些地方的offset就不一樣了,所以要重新對main.pic和funcs.dso進行反匯編。
結果如下圖所示:
驗證點1: 主程序中的PLT延遲加載是否有效
根據以上說的,只有調用了funcs.dso中的multiple方法才會綁定,我們看看是不是這樣。
因為inner_add方法是在multiple方法之前就調用了,所在調用inner_add處打斷點,
看看此時有沒有綁定multiple。
圖34如上圖34可知,multiple@plt過程的實際虛擬地址是0x555555555030,根據圖32 PIC-main-debug的反匯編代碼片段,如下所示:
0000000000001030 <multiple@plt>:
1030: ff 25 82 2f 00 00 jmpq *0x2f82(%rip) # 3fb8 <multiple>
1036: 68 00 00 00 00 pushq $0x0
103b: e9 e0 ff ff ff jmpq 1020 <.plt>
0x555555555030 + 0x6 + 0x2f82 = 0x555555557FB8,該值就是.got表(主程序只有一個.got表,沒有.got.plt表)中的一個表項的虛擬地址,該表項中存儲multiple函數實際虛擬地址,這里用“函數名@got.item”這種形式來表示該函數引用對應的got表項的虛擬地址,既multiple@got.item = 0x555555557FB8。
由上圖34可知:
1. 通過“x /2wx 0x555555557FB8”指令打印出該處內存存儲的值為0x7ffff7fc8190,這就是multiple函數的實際虛擬地址,即*(multiple@got.item) = 0x7ffff7fc8190。
2. 通過p multiple指令打印出來的該函數的虛擬地址也是0x7ffff7fc8190。
3. 通過“x /2wx 0x555555557FA0”指令打印出.got表起始處存儲的.dynamic的地址為0x3d90,由圖32可知.dynamic段的地址的確是0x3d90,.got段的起始處存儲的也是0x3d90。
因為,此時還沒訪問mulitple函數呢,這1,2的值竟然是一樣的,所以對于主程序來說,PLT壓根就沒起作用。
驗證點2:funcs.dso調用math_add的時候,PLT延遲綁定有沒有起作用。
由funcs.c的源碼可以知道,math_add是被set_multiple_index調用的,所在調用set_multiple_index處打斷點,按道理是不應該會觸發對math_add的綁定的。
圖35如上圖35可知,math_add@plt過程的實際虛擬地址是0x7ffff7fc8040,根據圖33 funcs.dso-debug的反匯編代碼片段,如下所示:
0000000000001040 <math_add@plt>:
1040: ff 25 da 2f 00 00 jmpq *0x2fda(%rip) # 4020 <math_add>
1046: 68 01 00 00 00 pushq $0x1
104b: e9 d0 ff ff ff jmpq 1020 <.plt>
0x7ffff7fc8040 + 0x6 + 0x2fda = 0x7FFFF7FCB020, 該值就是.got.plt表中的一個表項的虛擬地址,該表項中存儲multiple函數的實際虛擬地址,這里用“函數名@got.plt.item”這種形式來表示該函數引用對應的got表項的虛擬地址,既math_add@got.plt.item = 0x555555557FB8。
由上圖35可知:
1. 通過“x /2wx 0x7FFFF7FCB020”指令打印出該處內存存儲的值是0x7ffff7fc8046,也就是函數math_add的實際虛擬地址,即*(math_add@got.plt.item) = 0x7ffff7fc8046。
2. 通過p multiple指令打印出來的該函數的虛擬地址也是0x7ffff7fc30f5。
3. 通過“x /2wx 0x7FFFF7FCB000”指令打印出.got.plt表起始處存儲的.dynamic的地址為0x3e40,從圖33可知,.dynamic段的地址的確是0x3e40,.got.plt的起始項存儲的也是0x3e40。
因為,此時還沒訪問mulitple函數,1,2這兩處的值也不一樣,所以funcs.dso對maths.dso中math_add方法的引用是延遲綁定的。
之前還講過,在沒有重定位之前,.got.plt中每個表項(不包括前三項)存儲的默認值是其對應的“函數名@plt”過程的第二條指令的地址,從圖33可知,0x4020處存儲的地址是0x1046,正是math_add@plt的第二條指令的地址(68 01 00 00 00 pushq $0x1);打印0x7FFFF7FC8046處地址存儲的值為:0x00000168 0x00,由此可得math_add@got.plt.item表項中存儲的值由0x1046變為0x0x7FFFF7FC8046。
所以將funcs.dso加載到進程的0x7FFFF7FC7000虛擬基地址處時,首先會更新.got.plt表中所有表項(不含前三項)存儲的值:*(math_add@got.plt.item) += 0x7FFFF7FC7000,這樣才能保證后面對math_add進行綁定時正確跳轉到math_add@plt的第二條指令,進而跳轉到.plt中調用dl_runtime_resolve函數執行真正的綁定操作。
看到沒有,實際的操作和之前講的還是稍微有點出入的。
驗證點3:主程序所依賴的直接或間接DSO對象是何時被加載映射到進程地址空間的。
上面講過,對DSO中全局變量的引用是無法PLT處理的,只要程序中有引用,不管執行的時候是否會被執行到,都要無條件的重定位;從這個例子可以推出:funcs.dso只對maths.dso中的方法有調用,所以在沒訪問maths.dso之前不應該加載maths.dso到進程地址空間的。
下面我們來驗證一下。
由上圖34,35的native process 58576可知,這個進程的ID=58576。
當前還沒有運行到math_add@plt過程,math_add.dso應該還沒加載。
通過cat /proc/58576/maps 指令可以查看進程的地址空間分布情況。
圖36由圖36可知,此時已經將maths.dso加載映射到進程的地址空間了。
通過過以上三點可以得出結論:
1. 主程序直接或間接依賴的DSO對象,在主程序開始運行時會一次性加載映射到進程的地址空間。
2. 在主程序開始運行時,完成所有外部全局變量的重定位以及.rela.plt所有表項(不含前三項)的*(math_add@got.plt.item) += DSO_VIRT_BASE操作,上一步操作完后就知道每個DSO的虛擬基地址DSO_VIRT_BASE了。
3. 主程序開始運行時,其直接依賴的外部函數不是延遲綁定的,和對外部變量的處理一樣。
4. 共享對象中對外部函數的引用是使用PLT處理的,PLT機制有效。
在家窩了這些天,終于寫完了,也該告一段落了。
總結
以上是生活随笔為你收集整理的c++定义一个动态全局变量_静态链接与动态链接的宏观概述及微观详解的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python并发编程之concurren
- 下一篇: 大喇叭疫情防控广播解决方案