一、動(dòng)態(tài)鏈接簡介
1.1 靜態(tài)鏈接缺點(diǎn)
在 現(xiàn)代操作系統(tǒng) 中,靜態(tài)鏈接 會(huì)存在以下 2個(gè) 問題:
- 多進(jìn)程 同時(shí)運(yùn)行,如果一個(gè) 函數(shù) 同時(shí)被 多個(gè)進(jìn)程 使用,此時(shí)使用 靜態(tài)鏈接 將極大地浪費(fèi) 內(nèi)存空間 。
- 靜態(tài)鏈接對程序的 更新、部署和發(fā)布 也會(huì)帶來麻煩。在 靜態(tài)鏈接 情況下,如果某個(gè)模塊更新了,那么整個(gè)程序都需要 重新鏈接 然后再進(jìn)行發(fā)布。如果因?yàn)橐恍┖苄〉母膭?dòng)而導(dǎo)致整個(gè)模塊需要重新鏈接,這樣對于程序來講是十分不利的。
要解決上面 2 個(gè)困難,最簡單的方法就是將各個(gè)模塊相互分割開行程 獨(dú)立 的文件,而不是將它們 靜態(tài)鏈接 在一起。 只有當(dāng)程序需要運(yùn)行的時(shí)候才進(jìn)行 鏈接,即將 鏈接過程 推遲到 運(yùn)行時(shí) 進(jìn)行,這就是 動(dòng)態(tài)連接 的基本思想。
當(dāng)我們有需要更新的模塊時(shí),我們只需要 替換 對應(yīng)的 共享模塊,而不需要將整個(gè)程序重新進(jìn)行鏈接。從而得到更新更加方便的效果。
1.2 動(dòng)態(tài)鏈接特性
動(dòng)態(tài)鏈接 需要 操作系統(tǒng) 的支持。因?yàn)?動(dòng)態(tài)鏈接的情況下,進(jìn)程 虛擬地址空間 比 靜態(tài)鏈接 復(fù)雜得多。同時(shí)還需要 存儲(chǔ)管理、內(nèi)存共享 和 進(jìn)程 等機(jī)制在 動(dòng)態(tài)鏈接 下都會(huì)有相應(yīng)的變化。Linux 支持動(dòng)態(tài)鏈接,其 ELF動(dòng)態(tài)鏈接文件 被稱為 動(dòng)態(tài)共享對象(Dynamic Shared Objects),即 共享對象象(.so文件),一般也稱為 動(dòng)態(tài)鏈接庫。
裝載程序時(shí),動(dòng)態(tài)鏈接器 會(huì)將需要的 所有動(dòng)態(tài)鏈接庫 裝載到進(jìn)程的地址空間,并且將程序所有 未決議符號 綁定的相應(yīng)的 動(dòng)態(tài)庫 中,并進(jìn)行 重定位工作。
1.2.1 動(dòng)態(tài)鏈接優(yōu)點(diǎn):
- 節(jié)省內(nèi)存
- 減少物理頁的換出換出
- 增加cache命中率(因?yàn)檫M(jìn)程間的 指令訪問 都是在同一個(gè) 共享模塊 上)。
- 提高程序的兼容性和擴(kuò)展性,同一個(gè)函數(shù)在不同的操作系統(tǒng)上可能有不同的實(shí)現(xiàn),通過動(dòng)態(tài)鏈接可以提高程序在不同操作系統(tǒng)間的兼容性。
1.2.2 動(dòng)態(tài)鏈接缺點(diǎn)
- 新模塊 與 舊模塊 的 接口不兼容問題 會(huì)導(dǎo)致程序 無法運(yùn)行。
二、動(dòng)態(tài)鏈接過程
在 編譯階段,鏈接器 需要確定符號的性質(zhì)是 靜態(tài)鏈接 還是 動(dòng)態(tài)鏈接。并根據(jù)對應(yīng)的規(guī)則進(jìn)行:
- 靜態(tài)鏈接:對符號的地址進(jìn)行 重定位
- 動(dòng)態(tài)鏈接:將符號標(biāo)記為 動(dòng)態(tài)鏈接符號,不進(jìn)行 重定位,而是裝載程序 時(shí)再進(jìn)行。
由于 鏈接器 需要知道這些信息,那么需要在 鏈接 時(shí)使用 動(dòng)態(tài)庫 來說明符號的性質(zhì)。
2.1 動(dòng)態(tài)鏈接的地址分布
我們先看下面的代碼例程:
/* Program1.c */
#include "Lib.h"
int main()
{
foobar(1);
return 0;
}
/* Program2.c */
#include "Lib.h"
int main()
{
foobar(2);
return 0;
}
/* Lib.h */
#ifndef LIB_H
#define LIB_H
void foobar(int i);
#endif
/* Lib.c */
#include <stdio.h>
void foobar(int i)
{
printf("Printing from Lib.so %d\n", i);
}
使用以下命令進(jìn)行編譯:
arm-linux-gnueabihf-gcc -fPIC -shared -o Lib.so Lib.c
arm-linux-gnueabihf-gcc -o Program1 Program1.c ./Lib.so
arm-linux-gnueabihf-gcc -o Program2 Program2.c ./Lib.so
使用以下命令查看 Lib.so 的 裝載屬性

與 普通程序不同的是,動(dòng)態(tài)鏈接模塊 都是從 0x00000000 地址開始,也就是說 共享對象的最終裝載地址在編譯時(shí)并不確定。
2.2 地址無關(guān)碼
在 靜態(tài)鏈接 時(shí),鏈接器 直接將各個(gè) 目標(biāo)文件 進(jìn)行重定位,因而沒有地址沖突問題。
在 動(dòng)態(tài)鏈接 中,由于 鏈接 是在 裝載 時(shí)進(jìn)行,所以對于 共享對象文件 的 地址分配 成了首要問題。由于 編譯 時(shí),鏈接器 并沒有對 共享對象 進(jìn)行重定位,所以 共享對象 并不清楚自己在 虛擬地址空間 中的 位置。與 共享對象文件 不同的是,可執(zhí)行文件 則是第一個(gè)被裝載的文件,并且可以確定自己在 虛擬地址空間 中的位置。
要解決上面提到的問題,首先想到的就是 如何讓共享獨(dú)享文件可以裝載在任意地址
2.2.1 裝載時(shí)重定位
裝載時(shí)重定位 又被稱為 基址重置,其含義如下:
在沒有 虛擬內(nèi)存 的多進(jìn)程狀態(tài)下,程序直接被操作系統(tǒng)當(dāng)成一個(gè)整體裝載進(jìn)物理內(nèi)存,此時(shí) 程序 的 指令 和 數(shù)據(jù) 的 相對位置 不會(huì)改變。比如 程序 在 編譯 時(shí)預(yù)設(shè)的 裝載地址 為 0x1000,如果裝載時(shí)發(fā)現(xiàn)該地址已經(jīng)被占用,且 0x4000 這個(gè)地址有空間可以容納程序。程序就被裝載到 0x4000,其 代碼 和 數(shù)據(jù) 的 絕對引用(因?yàn)槭窃谖锢韮?nèi)存) 加上 0x3000 偏移即可。
裝載時(shí)重定位可以分下面 2 點(diǎn)來討論:
- 不適用 指令部分,因?yàn)?共享模塊 的 指令部分 是在多個(gè)進(jìn)程之間共享的,如果進(jìn)行 裝載時(shí)重定位,則指令對于每一個(gè)進(jìn)程來講都是不同的。
- 對于 可修改數(shù)據(jù)部分,由于每個(gè)進(jìn)程都擁有副本,因而可以使用 裝載時(shí)重定位 來解決 地址分配 問題。
裝載時(shí)重定位 是解決 動(dòng)態(tài)模塊 中 絕對地址引用 的辦法之一,使用 -shared 選項(xiàng)就是指定使用 裝載時(shí)重定位。
2.2.2 代碼地址無關(guān)
一般來說,為了實(shí)現(xiàn)程序 動(dòng)態(tài)模塊 中的 指令部分 在裝載時(shí)不需要因?yàn)檠b載地址的改變而改變,所以得將 指令 中需要 修改 的部分分離出來,跟 數(shù)據(jù)部分 放在一起。這樣 指令部分 可以保持不變,而 數(shù)據(jù)部分 則在每個(gè) 進(jìn)程 中都有一個(gè)副本,此方法稱為 地址無關(guān)碼。
共享模塊 中的 地址引用 可以按照 是否跨模塊 和 引用方式 分為以下幾類:
- 模塊 內(nèi)部 的 函數(shù)調(diào)用、跳轉(zhuǎn)
- 模塊 內(nèi)部 的 數(shù)據(jù)訪問
- 模塊 外部 的 函數(shù)調(diào)用、跳轉(zhuǎn)
- 模塊 外部 的 數(shù)據(jù)訪問
下面的代碼例程解釋了 4 種情況的體現(xiàn):
/* pic.c */
static int a;
extern int b;
extern void ext();
void bar()
{
a = 1;//類型2:模塊內(nèi)部的數(shù)據(jù)訪問
b = 2;//類型4:模塊外部的數(shù)據(jù)訪問
}
void foo()
{
bar();//類型1:模塊內(nèi)部的函數(shù)調(diào)用
ext();//類型3:模塊外部的函數(shù)調(diào)用
}
使用下面命令進(jìn)行編譯和查看反匯編:
arm-linux-gnueabihf-gcc pic.c -shared -fPIC -o pic.so
arm-linux-gnueabihf-objdump -D pic.so > pic.S
2.2.2.1 模塊內(nèi)部的指令引用
由于 被調(diào)用函數(shù) 和 調(diào)用函數(shù) 在同一個(gè)模塊內(nèi),所以他們的相對位置是固定,可以通過 相對地址調(diào)用 實(shí)現(xiàn)訪問,從而不需要進(jìn)行 重定位。
我們查看代碼的 反匯編,可以得到下面的片段:
00000488 <bar@plt>:
add ip, pc, #0, 12
add ip, ip, #16, 20 ; 0x10000
ldr pc, [ip, #2944]! ; 0xb80
000005a8 <foo>:
push {r7, lr}
add r7, sp, #0
blx 488 <bar@plt>//跳轉(zhuǎn)到 bar@plt
...
可以看到 foo函數(shù) 使用 相對地址 的訪問方式跳轉(zhuǎn)到了另一個(gè)函數(shù),一般來說 相對地址訪問 使用的是 分支指令 來實(shí)現(xiàn)。按照筆者理解,ARM 下的 分支指令 使用的是相對于 PC寄存器 的 偏移量,偏移量是不能大于 32M 。
2.2.2.2 模塊內(nèi)部的數(shù)據(jù)訪問
一個(gè)模塊前面是若干頁的 代碼,后面是若干頁的 數(shù)據(jù),這些 頁 之間的 相對位置 是固定的。所以 模塊內(nèi)任一條指令 與 模塊內(nèi)任一數(shù)據(jù) 之間的 相對位置 也是 固定 的。
與 模塊內(nèi)部的指令引用 一樣,我們只要通過在 代碼 中加入 偏移量 即可訪問到 模塊內(nèi)的任一數(shù)據(jù)
我們看看 ARM匯編 的數(shù)據(jù)訪問:
00000574 <bar>:
......
57c: 4b08 ldr r3, [pc, #32] ; (5a0 <bar+0x2c>)//將 0x10ab6 裝入r3寄存器
/* pc指針指向下一條指令的地址,即0x580 */
57e: 447b add r3, pc//將r3中的0x10ab6加上0x580即得到0x11038,即變量a的地址
580: 4619 mov r1, r3//將變量a的地址賦給r1寄存器
582: 2301 movs r3, #1//將立即數(shù)1賦給r3
584: 600b str r3, [r1, #0]//將r3中的值即 1 賦給變量a
......
59c: 00010a82 andeq r0, r1, r2, lsl #21
5a0: 00010ab6 ; <UNDEFINED> instruction: 0x00010ab6
5a4: 00000024 andeq r0, r0, r4, lsr #32
Disassembly of section .bss:
00011034 <__bss_start>:
11034: 00000000 andeq r0, r0, r0
00011038 <a>://0x11038為變量a的地址
11038: 00000000 andeq r0, r0, r0
從上面可以看出,是通過 pc寄存器 加上一個(gè)事先在 編譯階段 計(jì)算好的 偏移 來得到變量的地址
2.2.2.3 模塊外部的數(shù)據(jù)訪問
模塊外部的數(shù)據(jù)訪問比較麻煩,因?yàn)槟K間的 數(shù)據(jù)地址 需要等到 裝載時(shí) 才能確定,所以我們要將這種數(shù)據(jù)變成 代碼地址無關(guān) 。前面提到過,做到 代碼地址無關(guān),基本思想就是把地址相關(guān)的部分放到 數(shù)據(jù)段。
ELF 在 數(shù)據(jù)段 中建立了 全局偏移表(Global Offset Table,GOT) ,它是一個(gè) 指向模塊變量的指針數(shù)組,每一項(xiàng)為 4個(gè)字節(jié),在程序中表現(xiàn)為一個(gè) section 。當(dāng)代碼需要訪問 全局變量 時(shí),可以通過 GOT 中對應(yīng)的項(xiàng)間接引用。
鏈接器 在裝載模式的時(shí)候會(huì)查找每個(gè) 變量 所在的地址,然后填充到 GOT 中,確保每個(gè) 指針 所指向的地址正確。GOT 是存放在 數(shù)據(jù)段 中,每個(gè)進(jìn)程擁有 獨(dú)立的副本,所以在裝載時(shí)修改互不影響。
與 模塊內(nèi)部的數(shù)據(jù)訪問 類似,在 編譯 時(shí)可以確定 GOT 與 當(dāng)前指令 的偏移。通過 pc寄存器 訪問到 GOT,再通過 變量地址 在 GOT 中的 偏移 即可得到 變量地址。GOT 中的地址與變量之間的對應(yīng)關(guān)系由 編譯器 決定。
通過下面指令查看 GOT 的地址:
arm-linux-gnueabihf-objdump -h pic.so

可以看到,GOT 在文件內(nèi)的偏移為 0x11000。
再通過下面命令查看 變量重定位信息
arm-linux-gnueabihf-objdump -R pic.so

可以看到 變量b 的偏移為 0x11024,我們可以計(jì)算到它在 GOT 中的 偏移 為:
0x11024 - 0x11000 = 0x24
0x24 / 0x4 = 0x9
故相當(dāng)于在 GOT 中的 第9項(xiàng)。
2.2.2.4 模塊外部的指令引用
模塊外部的指令引用 與 模塊外部的數(shù)據(jù)訪問 類似,GOT 中也保存了 目標(biāo)函數(shù)地址。當(dāng)需要調(diào)用 模塊外函數(shù) 時(shí),可以通過查找 GOT 獲取到對應(yīng)的 函數(shù)地址 進(jìn)行跳轉(zhuǎn)。
2.3 共享模塊的全局變量問題
先看下面這段代碼:
/* module.c */
extern int global
int foo()
{
global = 1;
}
這種情況是 可執(zhí)行文件中的一個(gè)模塊引用了定義在共享對象中的全局變量。
這里有將導(dǎo)出一個(gè)問題,即 編譯器編譯目標(biāo)文件時(shí),無法根據(jù)上下文判斷變量是定義在同一個(gè)模塊的其他目標(biāo)文件還是定義在另一個(gè)共享對象中,所以無法判斷是否為跨模塊間的調(diào)用
為了解決這種情況,下面需要假設(shè) 2 種情況:
-
假設(shè)module.c是可執(zhí)行文件的一部分:
- 由于 程序主模塊 的代碼并不是 地址無關(guān)碼,當(dāng) 程序主模塊 引用 全局變量 時(shí)的方式跟訪問普通數(shù)據(jù)的方式一致。
- 由于 可執(zhí)行文件 在運(yùn)行時(shí)并不進(jìn)行 代碼重定位,所以共享模塊全局變量 的地址必須在 鏈接時(shí) 確定。為了能夠使得 鏈接過程 正常進(jìn)行,鏈接器 會(huì)在創(chuàng)建 可執(zhí)行文件 時(shí)在 .bss段 創(chuàng)建一個(gè) 共享模塊全局變量副本。
- 共享模塊全局變量 定義在原本的 共享對象 中,而可執(zhí)行文件 的 .bss段 又一個(gè)副本。此時(shí)關(guān)于該變量的訪問會(huì) 發(fā)生歧義。為了解決該問題,所有使用該 共享模塊全局變量 的指令都指向 .bss段 中的副本
- 如果 共享模塊全局變量 在 .bss段 中擁有副本,則 動(dòng)態(tài)鏈接器 會(huì)將 GOT 中的對應(yīng)地址指向該副本以避免發(fā)生歧義
- 如果 共享模塊全局變量 在 共享模塊 中被 初始化,則動(dòng)態(tài)鏈接器 會(huì)將 初始化值 賦值到 副本。
-
假設(shè)module.c是 共享對象的一部分:
- 編譯器 無法確定 共享模塊全局變量 是 跨模塊訪問 還是 模塊內(nèi)的其他目標(biāo)訪問。
- GCC編譯器 會(huì)對 共享模塊全局變量 按照 跨模塊訪問模式 來產(chǎn)生代碼。
2.4 數(shù)據(jù)段地址無關(guān)性
由于 共享對象 中 代碼部分 可以做到 地址無關(guān),即 使用相對地址來訪問代碼。而 數(shù)據(jù)部分 有可能出現(xiàn) 絕對地址,那么此時(shí) 數(shù)據(jù)部分 如何做到 地址無關(guān) 呢?
先看下面這段代碼:
static int a;
static int* p = &a;
在 共享對象 的代碼中,指針p 中存放的是一個(gè) 絕對地址,該地址指向 變量a。而 變量a 的地址會(huì)隨著 共享對象 的 裝載地址 改變而改變,那么如何解決該問題?
解決該問題可以從 2 個(gè)角度入手:
- 數(shù)據(jù)段:從上面可以知道,共享模塊 的 數(shù)據(jù)部分 在 程序 中都有一個(gè) 副本,且在程序之間,該數(shù)據(jù)段 獨(dú)立存在,所以 變量地址 不擔(dān)心被修改。由此可以選擇 裝載時(shí)重定位 的方法來解決 數(shù)據(jù)段的絕對引用 問題。按照筆者的理解,就是在裝載時(shí)選擇一塊空閑內(nèi)存存放 數(shù)據(jù)部分,然后修改 指針p 指向的地址。
- 共享對象:在 共享對象 中存在 數(shù)據(jù)的絕對引用 時(shí),編譯器 和 鏈接器 產(chǎn)生 重定位表,并使用 RELATIVE 類型的重定位入口來描述該 地址引用。 動(dòng)態(tài)鏈接器 裝載 共享對象 時(shí),如果發(fā)現(xiàn) 重定位表 存在該類型的 重定位入口,則對其進(jìn)行 重定位。
2.5 延遲綁定
動(dòng)態(tài)鏈接 比 靜態(tài)鏈接 要靈活得多,但與此同時(shí)也犧牲了一部分性能。其缺點(diǎn)體現(xiàn)在以下 2 個(gè)方面:
- 動(dòng)態(tài)鏈接 對于 全局 和 靜態(tài) 的數(shù)據(jù)訪問要進(jìn)行復(fù)雜的 GOT定位,再進(jìn)行 間接尋址
- 動(dòng)態(tài)鏈接 是在 裝載 時(shí)完成 鏈接工作,這將減慢程序的 啟動(dòng)速度。
在程序運(yùn)行時(shí),有很多函數(shù)在程序執(zhí)行時(shí)不會(huì)被用到,比如錯(cuò)誤處理或者 用戶比較少用的功能模塊等,所以不需要所有函數(shù)在一開始就鏈接好。
延遲綁定(Lazy Binding) 的基本思想是 函數(shù)第一次被調(diào)用時(shí)才進(jìn)行綁定(符號查找、重定位等),如果沒有則不進(jìn)行綁定。要實(shí)現(xiàn) 延遲綁定 需要使用到名為 PLT(Procedure Linkage Table) 的方法。
我們可以先從 動(dòng)態(tài)鏈接器 的角度來看,如果 liba.so 需要調(diào)用 libc.so 中的 bar函數(shù),當(dāng) liba.so 第一次調(diào)用 bar 時(shí),需要調(diào)用 動(dòng)態(tài)鏈接器 來完成 地址綁定。
下面使用一個(gè)例子來進(jìn)行說明:
/* pic.h */
#ifndef PIC_H
#define PIC_H
void bar(void);
#endif
/* pic.c */
static int a;
void bar(void)
{
a = 1;//類型2:模塊內(nèi)部的數(shù)據(jù)訪問
}
/* ptl.c */
#include "pic.h"
int main()
{
bar();
bar();
}
使用下面指令編譯,并將程序和動(dòng)態(tài)庫拷貝到開發(fā)板上運(yùn)行:
arm-linux-gnueabihf-gcc pic.c -shared -fPIC -o pic.so
arm-linux-gnueabihf-gcc plt.c pic.so -o plt
接下來我們使用 gdb 來查看 延遲綁定 的過程:
#gdb ./plt #使用gdb運(yùn)行程序
#(gdb) disassemble main #查看main函數(shù)的反匯編
Dump of assembler code for function main:
0x00010544 <+0>: push {r7, lr}
0x00010546 <+2>: add r7, sp, #0
0x00010548 <+4>: blx 0x10454 <bar@plt> #可以調(diào)用 bar 時(shí)是調(diào)轉(zhuǎn)到 bar@plt
0x0001054c <+8>: blx 0x10454 <bar@plt>
0x00010550 <+12>: movs r3, #0
0x00010552 <+14>: mov r0, r3
0x00010554 <+16>: pop {r7, pc}
End of assembler dump.
#(gdb) b *0x10454 #在 <bar@plt> 處設(shè)置斷點(diǎn)
Breakpoint 1 at 0x10454
#(gdb) r//執(zhí)行程序
Starting program: /mnt/plt
Breakpoint 1, 0x00010454 in bar@plt ()
#(gdb) x/4i $pc #查看pc寄存器附近的匯編
=> 0x10454 <bar@plt>: add r12, pc, #0, 12
0x10458 <bar@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x1045c <bar@plt+8>: ldr pc, [r12, #2992]! ; 0xbb0
0x10460 <__libc_start_main@plt>: add r12, pc, #0, 12
在 bar@plt 中進(jìn)行了幾次計(jì)算,并將計(jì)算結(jié)果賦值給 pc寄存器,以讓程序得以在某處運(yùn)行,下面嘗試計(jì)算結(jié)果:
- 0x10454:pc寄存器 為 0x1045c,并將該值存放到 r12寄存器,此時(shí) r12=0x1045c
- 0x10458:將 r12寄存器 中的值加上 0x10000,此時(shí) r12=0x2045c
- 0x1045c:將 r12寄存器的值 加上 2992(十進(jìn)制),再將 該值指向的地址 中的 內(nèi)容 賦值給 pc,此時(shí) r12=0x2100c
使用 gdb 查看 0x2100c 中的值:
#(gdb) x/xr 0x2100c
0x2100c: 0x00010440//此時(shí) pc寄存器的值為0x00010440
我們可以查看 plt 的反匯編來看看這個(gè)地址的內(nèi)容:
00010440 <.plt>:
10440: e52de004 push {lr} ; (str lr, [sp, #-4]!)
10444: e59fe004 ldr lr, [pc, #4] ; 10450 <.plt+0x10>
10448: e08fe00e add lr, pc, lr
1044c: e5bef008 ldr pc, [lr, #8]!
10450: 00010bb0 ; <UNDEFINED> instruction: 0x00010bb0
將程序運(yùn)行幾個(gè)指令,進(jìn)入該函數(shù):
#(gdb) si
0x00010458 in bar@plt ()
......
#(gdb) si
0x00010440 in ?? ()
#(gdb) x/4i $pc #可以看到函數(shù)已經(jīng)進(jìn)入 .plt
=> 0x10440: push {lr} ; (str lr, [sp, #-4]!)
0x10444: ldr lr, [pc, #4] ; 0x10450
0x10448: add lr, pc, lr
0x1044c: ldr pc, [lr, #8]!
#(gdb) si #執(zhí)行下一步的指令
0x00010444 in ?? ()
......
#(gdb) x/8i $pc
=> 0x10448: add lr, pc, lr
0x1044c: ldr pc, [lr, #8]!
0x10450: ; <UNDEFINED> instruction: 0x00010bb0
0x10454 <bar@plt>: add r12, pc, #0, 12
0x10458 <bar@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x1045c <bar@plt+8>: ldr pc, [r12, #2992]! ; 0xbb0
0x10460 <__libc_start_main@plt>: add r12, pc, #0, 12
0x10464 <__libc_start_main@plt+4>: add r12, r12, #16, 20 ; 0x10000
#(gdb) info registers
r0 0x1 1
r1 0xbe9feda4 3198152100
r2 0xbe9fedac 3198152108
r3 0x10545 66885
r4 0xbe9fec68 3198151784
r5 0x0 0
r6 0x0 0
r7 0xbe9fec50 3198151760
r8 0x0 0
r9 0x0 0
r10 0xb6f27000 3069341696
r11 0x0 0
r12 0x2100c 135180
sp 0xbe9fec4c 0xbe9fec4c
lr 0x10bb0 68528
pc 0x10448 0x10448
cpsr 0x40070010 1074200592
此時(shí) lr 為 0x10bb0,而 pc 為 0x10448+8,那么下一步 lr=0x21000
注意:這里不細(xì)講pc寄存器的特性,感興趣的讀者請查看參考鏈接
#(gdb) si
0x0001044c in ?? ()
#(gdb) x/8i $pc
=> 0x1044c: ldr pc, [lr, #8]!
0x10450: ; <UNDEFINED> instruction: 0x00010bb0
0x10454 <bar@plt>: add r12, pc, #0, 12
0x10458 <bar@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x1045c <bar@plt+8>: ldr pc, [r12, #2992]! ; 0xbb0
0x10460 <__libc_start_main@plt>: add r12, pc, #0, 12
0x10464 <__libc_start_main@plt+4>: add r12, r12, #16, 20 ; 0x10000
0x10468 <__libc_start_main@plt+8>: ldr pc, [r12, #2984]! ; 0xba8
#(gdb) info registers
r0 0x1 1
r1 0xbe9feda4 3198152100
r2 0xbe9fedac 3198152108
r3 0x10545 66885
r4 0xbe9fec68 3198151784
r5 0x0 0
r6 0x0 0
r7 0xbe9fec50 3198151760
r8 0x0 0
r9 0x0 0
r10 0xb6f27000 3069341696
r11 0x0 0
r12 0x2100c 135180
sp 0xbe9fec4c 0xbe9fec4c
lr 0x21000 135168
pc 0x1044c 0x1044c
cpsr 0x40070010 1074200592
該指令會(huì)將 lr加上8 所指向的地址中的內(nèi)容復(fù)制給 pc寄存器。lr=0x21000,則訪問的就是地址 0x21008,使用 gdb 查看該地址中的內(nèi)容
#(gdb) x/xr 0x21008
0x21008: 0xb6f0e518
(gdb) x/8i 0xb6f0e518
=> 0xb6f0e518 <_dl_runtime_resolve>: push {r0, r1, r2, r3, r4}
0xb6f0e51c <_dl_runtime_resolve+4>: ldr r0, [lr, #-4]
0xb6f0e520 <_dl_runtime_resolve+8>: sub r1, r12, lr
0xb6f0e524 <_dl_runtime_resolve+12>: sub r1, r1, #4
0xb6f0e528 <_dl_runtime_resolve+16>: add r1, r1, r1
0xb6f0e52c <_dl_runtime_resolve+20>: blx 0xb6f09de8 <_dl_fixup>
0xb6f0e530 <_dl_runtime_resolve+24>: mov r12, r0
0xb6f0e534 <_dl_runtime_resolve+28>: pop {r0, r1, r2, r3, r4, lr}
發(fā)現(xiàn) 0x21008 中的地址指向了 _dl_runtime_resolve 函數(shù),而 0x21008 就在程序的 got段 中。
#(gdb) si #進(jìn)入 _dl_runtime_resolve 函數(shù),其地址為 0x21008
0xb6f15518 in _dl_runtime_resolve () from /lib/ld-linux-armhf.so.3
我們前面說到調(diào)用 動(dòng)態(tài)鏈接器 進(jìn)行 綁定,而_dl_runtime_resolve函數(shù) 就是進(jìn)行 動(dòng)態(tài)鏈接器進(jìn)行 綁定。
#(gdb) s #直接跳過 _dl_runtime_resolve 函數(shù)
Single stepping until exit from function _dl_runtime_resolve,
#(gdb) x/xr 0x2100c #查看 0x2100c的內(nèi)容
0x2100c: 0xb6f3a4c1
#(gdb) disassemble 0xb6f3a4c1 #查看 0xb6f3a4c1 所在的匯編,對于反匯編文件,會(huì)發(fā)現(xiàn)這就是bar函數(shù)
Dump of assembler code for function bar:
=> 0xb6f3a4c0 <+0>: push {r7}
0xb6f3a4c2 <+2>: add r7, sp, #0
0xb6f3a4c4 <+4>: ldr r3, [pc, #16] ; (0xb6f3a4d8 <bar+24>)
0xb6f3a4c6 <+6>: add r3, pc
0xb6f3a4c8 <+8>: mov r2, r3
0xb6f3a4ca <+10>: movs r3, #1
0xb6f3a4cc <+12>: str r3, [r2, #0]
0xb6f3a4ce <+14>: nop
0xb6f3a4d0 <+16>: mov sp, r7
0xb6f3a4d2 <+18>: ldr.w r7, [sp], #4
0xb6f3a4d6 <+22>: bx lr
0xb6f3a4d8 <+24>: andeq r0, r1, r2, ror #22
經(jīng)過上面的步驟,就完成了延遲綁定,下一次調(diào)用 bar函數(shù) 時(shí)將不再進(jìn)行 .plt函數(shù),而是直接跳轉(zhuǎn)到 bar函數(shù)。
到了這里,大概可以了解了 延遲綁定 的過程,總結(jié)如下:
- 跳轉(zhuǎn)到 綁定函數(shù)的.plt函數(shù)
- 跳轉(zhuǎn)到 .plt函數(shù)
- 調(diào)用 _dl_runtime_resolve函數(shù)
- 完成綁定
在整個(gè)過程中,出現(xiàn)了地址 0x2100c 和 0x10444 ,我們使用下面指令查看 section段表
arm-linux-gnueabihf-objdump -h plt

發(fā)現(xiàn)地址 0x2100c 就在 got段,再使用下面指令查看 重定位表:
arm-linux-gnueabihf-objdump -R plt
可以從上圖知道 0x21000 是 got段 的開始,got段 的前 3 項(xiàng)比較特殊,如下:
- 第一項(xiàng):保存了 .dynamic段 的地址,在例子中的地址為 0x21000。
- 第二項(xiàng):保存 本模塊ID 的所在地址,在例子中的地址為 0x21004。由 動(dòng)態(tài)鏈接器 在裝載時(shí)初始化
- 第三項(xiàng):保存 _dl_runtime_resolve函數(shù) 的地址,在例子中的地址為 0x21008,也符合例子中的過程論證。由 動(dòng)態(tài)鏈接器 在裝載時(shí)初始化。

發(fā)現(xiàn) 符號bar 的便宜就是在 0x2100c。按照筆者理解就是修改地址 0x2100c 中的地址指向,在沒有綁定的時(shí)候是指向 .plt函數(shù),當(dāng)綁定完了以后就指向 函數(shù)所在地址。由此可以優(yōu)化 動(dòng)態(tài)鏈接 在裝載時(shí)需要 綁定所有符號 的缺點(diǎn)。
2.6 動(dòng)態(tài)鏈接的相關(guān)結(jié)構(gòu)
2.6.1 .interp段
動(dòng)態(tài)鏈接器 存放在 文件系統(tǒng) 中,而程序需要知道其 存放路徑 才能調(diào)用,所以就需要在程序中添加 動(dòng)態(tài)鏈接器 的路徑,而 .interp段 就是存放 動(dòng)態(tài)鏈接器 的路徑。
查看程序的反匯編可以看到如下代碼:
Disassembly of section .interp:
00010154 <.interp>:
10154: 62696c2f rsbvs r6, r9, #12032 ; 0x2f00
10158: 2d646c2f stclcs 12, cr6, [r4, #-188]! ; 0xffffff44
1015c: 756e696c strbvc r6, [lr, #-2412]! ; 0xfffff694
10160: 72612d78 rsbvc r2, r1, #120, 26 ; 0x1e00
10164: 2e66686d cdpcs 8, 6, cr6, cr6, cr13, {3}
10168: 332e6f73 ; <UNDEFINED> instruction: 0x332e6f73
在 gdb 中查看其內(nèi)容:
#(gdb) x/as 0x10154
0x10154: "/lib/ld-linux-armhf.so.3"
可以發(fā)現(xiàn) 動(dòng)態(tài)鏈接器 的路徑為 /lib/ld-linux-armhf.so.3
同理,也可以使用下面指令查看 動(dòng)態(tài)鏈接器:
arm-linux-gnueabihf-readelf -l plt

2.6.2 .dynamic段
.dynamic段 保存了 動(dòng)態(tài)鏈接器 所需要的基本信息,比如 依賴的共享對象、動(dòng)態(tài)鏈接符號表的位置、動(dòng)態(tài)鏈接重定位表的位置、共享對象的初始化代碼地址 等。
其代碼表現(xiàn)為 Elf32_Dyc結(jié)構(gòu)體數(shù)組 ,代碼如下:
typedef struct
{
Elf32_Sword d_tag; /* Dynamic entry type */
union
{
Elf32_Word d_val; /* Integer value */
Elf32_Addr d_ptr; /* Address value */
} d_un;
} Elf32_Dyn;
下表列出了常用的 類型 和 值。
| d_tag | d_un |
|---|---|
| DT_SYMTAB | 動(dòng)態(tài)鏈接符號表地址,d_ptr表示 .dynsym 地址 |
| DT_STRTAB | 動(dòng)態(tài)鏈接字符串表地址,d_ptr表示 .dynstr 地址 |
| DT_STRSZ | 動(dòng)態(tài)鏈接字符串表大小,d_val表示大小 |
| DT_HASH | 動(dòng)態(tài)鏈接哈希表地址,d+ptr表示 .hash 地址 |
| DT_SONAME | 共享對象的 SO-NAME |
| DT_RPATH | 動(dòng)態(tài)鏈接共享對象的搜索路徑 |
| DT_INIT | 初始化代碼段地址 |
| DT_FINIT | 結(jié)束代碼段地址 |
| DT_NEED | 依賴的共享對象文件,d_ptr表示所依賴的共享對象文件名 |
|
DT_REL DT_RELA |
動(dòng)態(tài)鏈接重定位表地址 |
|
DT_RELENT DT_RELAENT |
動(dòng)態(tài)鏈接重定位入口數(shù)量 |
2.6.3 動(dòng)態(tài)符號表
- 導(dǎo)入函數(shù):主程序 引用 動(dòng)態(tài)共享對象 中的函數(shù),該函數(shù)稱為 導(dǎo)入函數(shù)
- 導(dǎo)出函數(shù):動(dòng)態(tài)共享對象 中的函數(shù)提供給 主程序 使用,該函數(shù)稱為 導(dǎo)出函數(shù)
ELF 使用了 動(dòng)態(tài)符號表(Dynamic Symbol Table) 來保存 動(dòng)態(tài)鏈接 符號的 導(dǎo)入導(dǎo)出關(guān)系,段名為 .dynsym(Dynamic Symbol)。該段僅保存 動(dòng)態(tài)鏈接符號,對于模塊內(nèi)部的符號則不保存。
與 .symtal(靜態(tài)符號表) 類似,動(dòng)態(tài)符號表 使用了一些輔助的表來進(jìn)行表示。包括:
- .dynstr:保存 動(dòng)態(tài)符號名 的 動(dòng)態(tài)符號字符串表(Dynamic String Table)。
- .hash:加速 符號查找 過程 的 字符哈希表。
動(dòng)態(tài)鏈接符號表 幾乎與 靜態(tài)鏈接符號表 一樣,對于其結(jié)構(gòu)本章節(jié)不做贅述。
2.6.4 動(dòng)態(tài)鏈接重定位表
由于 導(dǎo)入符號 的存在,共享對象 也需要進(jìn)行重定位。因?yàn)樵?編譯時(shí) 無法知道 導(dǎo)入符號 的 地址,這些地址需要在 裝載時(shí) 才能確定和修正,也就是 重定位。
對于使用 PIC技術(shù) 的 可執(zhí)行文件或共享對象 來說,代碼段 是不需要重定位的。但 數(shù)據(jù)段 是需要重定位的,原因有下面 2個(gè):
- 數(shù)據(jù)段 有可能會(huì)引用 絕對地址。
- 代碼段 中引用 絕對地址 的部分被分離出來成為 GOT表,但 GOT 存放于 數(shù)據(jù)段。
在 動(dòng)態(tài)鏈接 中,重定位表 所在的段分別如下:
- .rel.dyn:該段用于修正 數(shù)據(jù)引用,即 .got段 和 數(shù)據(jù)段。對應(yīng) 靜態(tài)鏈接 的重定位表 .rel.data。
- .rel.plt:該段用戶修正 函數(shù)引用,即 .got.plt段 。對應(yīng) 靜態(tài)鏈接 的重定位表 .rel.text
使用下面命令查看 動(dòng)態(tài)重定位表:
arm-linux-gnueabihf-readelf -r pic.so

動(dòng)態(tài)重定位表 的數(shù)據(jù)結(jié)構(gòu)與 靜態(tài)重定位表 一樣,這里不做贅述。
待補(bǔ)充 ARM架構(gòu) 下的指令修正
2.6.5 動(dòng)態(tài)鏈接輔助數(shù)據(jù)
當(dāng) 動(dòng)態(tài)鏈接器 開始進(jìn)行鏈接時(shí),需要一些信息,比如 可執(zhí)行文件segment數(shù)量、segment屬性、程序入口 等。此類信息都需要由 操作系統(tǒng) 存放在 進(jìn)程堆棧,由此傳遞給 動(dòng)態(tài)鏈接器。這些信息成為 輔助信息數(shù)據(jù)(Auxiliary Vector)。
代碼結(jié)構(gòu)體如下:
typedef struct
{
uint32_t a_type; /* Entry type */
union
{
uint32_t a_val; /* Integer value */
/* We use to have pointer elements added here. We cannot do that,
though, since it does not work when using 32-bit definitions
on 64-bit platforms and vice versa. */
} a_un;
} Elf32_auxv_t;
Elf32_auxv_t 和 Elf32_Dyn 非常相似,下面簡單講解常用個(gè)的值
| a_type定義 | a_type值 | a_val意義 |
|---|---|---|
| AT_NULL | 0 | 表示 輔助信息數(shù)組 的 結(jié)束 |
| AT_EXEFD | 2 | 表示 可執(zhí)行文件 的 文件句柄。操作系統(tǒng)打開 可執(zhí)行文件 后獲得的 文件句柄,再將該 句柄 傳遞給 動(dòng)態(tài)鏈接器 |
| AT_PHDR | 3 | 程序頭表(Program Header)的 地址 |
| AT_PHENT | 4 | 程序頭表(Program Header) 中每一個(gè) 入口(Entry) 的大小 |
| AT_PHNUM | 5 | 程序頭表(Program Header) 中 入口(Entry) 的數(shù)量 |
| AT_BASE | 7 | 表示 動(dòng)態(tài)鏈接器 本身的裝載地址 |
| AT_ENTRY | 9 | 可執(zhí)行文件 的 啟動(dòng)地址 |
在 程序 中,我們是可以獲取到這些 輔助信息,這些信息處于 環(huán)境變量指針 后面。
三、 動(dòng)態(tài)鏈接步驟
動(dòng)態(tài)鏈接 步驟概括如下:
1. 動(dòng)態(tài)鏈接器自舉
2. 裝載共享對象
3. 重定位和初始化
3.1 動(dòng)態(tài)鏈接器自舉
動(dòng)態(tài)鏈接器 本身是一個(gè) 共享對象。那么在啟動(dòng)時(shí)就需要進(jìn)行 共享對象 裝載的操作:
- 重定位
- 加載依賴庫
針對上面 2 個(gè)操作,動(dòng)態(tài)鏈接器 的實(shí)現(xiàn)如下:
- 動(dòng)態(tài)鏈接器 本身不依賴于 任何共享對象
-
動(dòng)態(tài)鏈接器 通過 自舉 完成對自身的 重定位。
- 動(dòng)態(tài)鏈接器 的入口地址就是 自舉代碼 的入口地址,自舉代碼 找到自身的 GOT
- 通過 GOT 找到 .dynamic段
- 通過 .dynamic段 獲取本身的 重定位表 和 符號表,最終完成 重定位
- 完成 重定位 后,動(dòng)態(tài)鏈接器 可以訪問 全局變量 、 靜態(tài)變量 和 函數(shù)。
3.2 裝載共享對象
- 動(dòng)態(tài)鏈接器 將 可執(zhí)行文件 和 鏈接器 的 符號表 合并為 全局符號表。
- 鏈接器從中取出所需要的 共享對象名,并找到對應(yīng)的共享對象。
- 將找到的 共享對象 的 代碼段 和 數(shù)據(jù)段 映射到進(jìn)程空間
- 將 共享對象 的 符號表 合并到 全局符號表
- 重復(fù)以上操作直到將所有的 依賴共享對象 裝載到進(jìn)程空間
搜索 共享對象 的過程與 圖的遍歷 過程類似,鏈接器 一般采用 廣度優(yōu)先搜索 來裝載 共享對象。
在裝載 共享對象 時(shí),一個(gè) 共享對象 的 全局符號 被另一個(gè) 共享對象 的 同名全局符號 覆蓋的現(xiàn)象稱為 全局符號介入。
為了解決該問題,Linux動(dòng)態(tài)鏈接器 定義了一個(gè)規(guī)則:當(dāng)一個(gè)符號需要被加入全局符號表時(shí),如果相同的符號名已經(jīng)存在,則最后的符號被忽略。
所以 共享對象 的重名符號存在 忽略現(xiàn)象,當(dāng)程序大量使用 共享對象 時(shí)應(yīng)該非常小心 符號重名問題。
3.2.1 全局符號介入與地址無關(guān)碼
前面說過 模塊內(nèi)部的函數(shù)調(diào)用 問題,如果將函數(shù)都使用這種方法進(jìn)行裝載,則有可能出現(xiàn) 全局符號接入問題。而為了解決 全局符號介入,又需要對 代碼 進(jìn)行 重定位,這又與 地址無關(guān) 的特性想違背。因此 編譯器 一般采用 模塊外部的函數(shù)調(diào)用 進(jìn)行處理,將函數(shù)使用 .got.plt 進(jìn)行 重定位,從而解決了 需要重定位代碼段 的問題。
書中給出了一個(gè)技巧:為了提高模塊內(nèi)部函數(shù)的調(diào)用效率,可以使用static定義函數(shù)。編譯器就可以確定函數(shù)不被其他模塊覆蓋,從而使用模塊內(nèi)部的函數(shù)調(diào)用方式進(jìn)行加載,可以加快函數(shù)的調(diào)用速度
3.3 重定位和初始化
鏈接器 開始重新遍歷 可執(zhí)行文件 和 共享對象 的 重定位表,將它們的 GOT/PLT表 中每個(gè) 重定位入口 進(jìn)行修正,接著嘗試執(zhí)行每個(gè) 共享對象 的 .init段,用以實(shí)現(xiàn) 共享對象 特有的初始化
四、 動(dòng)態(tài)鏈接操作
4.1 環(huán)境變量
LD_PRELOAD:該變量指定的 文件 會(huì)在 動(dòng)態(tài)鏈接器 按照固定的規(guī)則搜索 共享庫 之前裝載。
由于 全局符號介入 的存在,該宏的存在可以使得程序員可以改寫 標(biāo)準(zhǔn)C庫 中的而不影響其余函數(shù),對于程序的 調(diào)試 比較有用。由于帶有 __attribute__((constructor)) 屬性的函數(shù)在裝載時(shí)會(huì)被執(zhí)行,也有使用 LD_PRELOAD 進(jìn)行 代碼注入 的破壞行為,但這種行為比較低端和容易識別。LD_DEUBG:該變量可以打開 動(dòng)態(tài)鏈接器 的調(diào)試功能。動(dòng)態(tài)鏈接器 會(huì)在運(yùn)行時(shí)打印出該環(huán)境變量指定的文件的信息,便于調(diào)試 共享庫。
4.2 構(gòu)造/析構(gòu)函數(shù)
- __attribute__((constructor)) 屬性讓 函數(shù) 被編譯進(jìn) .init_array段 ,這些函數(shù)在 動(dòng)態(tài)庫 裝載時(shí)會(huì)被運(yùn)行以進(jìn)行 初始化操作。這些函數(shù)稱為 構(gòu)造函數(shù)。
- __attribute__((destructor)) 屬性讓 函數(shù) 被編譯進(jìn) .finit_array段 ,這些函數(shù)在 動(dòng)態(tài)庫 卸載時(shí)會(huì)被運(yùn)行以進(jìn)行 去初始化操作。這些函數(shù)稱為 析構(gòu)函數(shù)。
使用 析構(gòu)/構(gòu)造函數(shù) 時(shí),不可以使用編譯選項(xiàng) -nostartfiles 或 -nostdlib。因?yàn)?構(gòu)造/析構(gòu)函數(shù) 需要在系統(tǒng)默認(rèn)的 標(biāo)準(zhǔn)運(yùn)行庫 上運(yùn)行,如果沒有這些庫,則 析構(gòu)/構(gòu)造函數(shù) 無法運(yùn)行。
如果有多個(gè) 析構(gòu)/構(gòu)造函,則可以使用下面的宏進(jìn)行定義:
- __attribute__((constructor(n))):構(gòu)造函數(shù) 的 n越小,函數(shù)越先被執(zhí)行
- __attribute__((destructor(n))):構(gòu)造函數(shù) 的 n越小,函數(shù)越慢被執(zhí)行
五、 參考鏈接
ARM匯編中PC寄存器詳解
延遲綁定(PLT)
Android C語言_init函數(shù)和constructor屬性及.init/.init_array節(jié)探索