- 原文標題:Load-time relocation of shared libraries
- 原文網址:http://eli.thegreenplace.net/2011/08/25/load-time-relocation-of-shared-libraries/
- 原文作者:Eli Bendersky
- 原文發表時間:2011 年 08 月 25 日
↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓
本文目標為解釋現代作業系統如何在有載入期重定位(load-time relocation)的情況下使用共享函式庫(shared library),雖然它聚焦於 32 位元 x86 上的 Linux 作業系統,但這些通用原則仍適用於其他作業系統與 CPU。
要注意共享函式庫有許多名稱——共享函式庫、共享物件(shared object)、動態共享物件(DSO)和動態連結函式庫(DLL——假如你來自 Windows 背景),為了一致性,我試著通篇只使用「共享函式庫」。
可執行檔的載入
Linux,與其他支援虛擬記憶體的作業系統類似,將可執行檔載入到固定的記憶體位址,假如分析隨便某個可執行檔的 ELF 標頭(ELF header),就可以看到入口點位址(Entry point address):
$ readelf -h /usr/bin/uptime
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
[...] some header fields
Entry point address: 0x8048470
[...] some header fields
這是由連結器(linker)放置,用以告訴作業系統要從哪裡開始執行可執行檔的程式碼〔1〕。事實上,假如我們之後用 GDB 載入可執行檔,並分析位址 0x8048470
,會看到可執行檔的節區(segment).text
的第一個指令落在那裡。
而這代表在進行可執行檔的連結時,連結器可以完全解析(resolve)所有(對函式和資料)的內部符號參考到固定以及最終的位置。連結器會自行完成某些重定位〔2〕,但最終它產生的輸出不含額外的重定位需求。
這就完了嗎?注意在前一段落中我強調的內部,只要可執行檔一直不需要共享函式庫〔3〕,那它就不需要重定位;但假如它真的使用了共享函式庫(如同大多數的 Linux 應用程式所做的),共享函式庫的載入方式,將導致取自他們的符號需要做重定位。
共享函式庫的載入
不像可執行檔,在建立共享函式庫的時候,連結器無法為程式碼假定一個已知的載入位址,原因很簡單,每個程式都可以使用任意數量的共享函式庫,而很明顯沒有方法,能預知任何給定的共享函式庫會被載入到行程(process)虛擬記憶體的哪裡。多年來為了這個問題,有許多解決方案被發明,但在本文中,我只專注在 Linux 目前所用的方案。
但首先,先來簡單分析一下問題,這裡有某個會被我編成共享函式庫的 C 程式碼例子〔4〕:
int myglob = 42;
int ml_func(int a, int b)
{
myglob += a;
return b + myglob;
}
注意 ml_func
如何對 myglob
做的幾次參考,在轉譯成 x86 組合語言時,將加入一個指令 mov
,用來把 myglob
的值從它在記憶體的位置取出到暫存器,但 mov
需要用絕對位址 1——那連結器怎麼知道要把什麼位址放進去呢?答案是,它不知道。如同之前提到的,共享函式庫沒有預先定義好的載入位址,而這將直到執行期才被決定。
在 Linux,動態載入器(dynamic loader)〔5〕是負責準備程式運行的一段程式碼,它的任務之一是在被執行的可執行檔有需求時,將共享函式庫從硬碟載入記憶體,當共享函式庫已經載入記憶體,它接著便針對新決定好的載入位置做調整。解決前個段落中所遇到的問題就是動態載入器的工作。
處理 Linux ELF 共享函式庫的這個問題有兩個主要的方式:
- 載入期重定位
- 位址無關程式碼(position independent code,PIC)
儘管 PIC 是更普遍且現今所推薦的方案,但在這篇文章中我會專注在執行期重定位上,之後,我計畫兩種方式都要涵蓋,再分開寫一篇 PIC 上的,我認為從載入期重定位開始,會讓 PIC 在以後做解釋更加容易。
連結需要載入期重定位的共享函式庫
為了創建必須在載入期做重定位的共享函式庫,我會不加旗標 -fPIC
來編譯它(否則會觸發 PIC 的生成):
gcc -g -c ml_main.c -o ml_mainreloc.o
gcc -shared -o libmlreloc.so ml_mainreloc.o
第一個感興趣想看的事是 libmlreloc.so
的入口點:
$ readelf -h libmlreloc.so
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
[...] some header fields
Entry point address: 0x3b0
[...] some header fields
因為知道載入器可能把共享物件搬到任何地方,為了簡單起見,連結器只是將共享物件以位址 0x0
做連結(區段 .text
起始於 0x3b0
);好好記住這件事——這在後續文章中會有用處。
現在來看看共享函式庫的反組譯,我們專注在 ml_func
上:
$ objdump -d -Mintel libmlreloc.so
libmlreloc.so: file format elf32-i386
[...] 略過一些東西
0000046c <ml_func>:
46c: 55 push ebp
46d: 89 e5 mov ebp,esp
46f: a1 00 00 00 00 mov eax,ds:0x0
474: 03 45 08 add eax,DWORD PTR [ebp+0x8]
477: a3 00 00 00 00 mov ds:0x0,eax
47c: a1 00 00 00 00 mov eax,ds:0x0
481: 03 45 0c add eax,DWORD PTR [ebp+0xc]
484: 5d pop ebp
485: c3 ret
[...] 略過一些東西
在前兩行 prologue2 部分的指令之後〔6〕,我們看到 myglob += a
編譯後的版本〔7〕,myglob
的值從記憶體拿到 eax
,增加 a
(位於 ebp+0x8
),然後再放回記憶體。
等等,mov
拿到了 myglob
?為什麼?看起來 mov
的運算元(operand)是 0x0
〔8〕,這是怎麼了?其實,這就是重定位的運作方式。連結器會放置某個臨時性的預定義值(在這個案例中是 0x0
)到指令串流中,然後創建一筆特殊的重定位項目指向這個地方。讓我們來分析一下用於這個共享函式庫的重定位項目:
$ readelf -r libmlreloc.so
Relocation section '.rel.dyn' at offset 0x2fc contains 7 entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008 R_386_RELATIVE
00000470 00000401 R_386_32 0000200C myglob
00000478 00000401 R_386_32 0000200C myglob
0000047d 00000401 R_386_32 0000200C myglob
[...] 略過一些東西
ELF 的區段 rel.dyn
被保留給動態(載入期)重定位(dynamic relocation),並由動態載入器來消化掉,在上面展示的區段裡面有三筆給 myglob
的重定位項目,正是因為在反組譯結果中有三次對 myglob
的參考。讓我們來解譯其中的第一筆。
它這麼說:到這個物件(共享函式庫)內偏移值 0x470
的地方,以符號 myglob
對其施以型別 R_386_32
的重定位,查閱 ELF 規格書得知,重定位型別 R_386_32
意味著:拿出位於項目中所指定的偏移值位置的值,加上符號的位址,再把它放回該偏移值位置。
在物件內偏移值 0x470
的地方有什麼呢?回想一下在 ml_func
反組譯結果中的這道指令:
46f: a1 00 00 00 00 mov eax,ds:0x0
a1
編碼成指令 mov
,所以它的運算元從下個位址開始,也就是 0x470
,這就是我們在反組譯結果中看到的 0x0
。回到重定位項目,現在我們明白它說的是:把 myglob
的位址加到那個指令 mov
的運算元;換句話說,它告訴動態載入器——一旦你進行實際位址的指派,把 myglob
的真正位址放到 0x470
,從而將 mov
的運算元取代成正確的符號實值。很巧妙,對吧?
再注意到重定位區段中「Sym. value」這一欄,有著 0x200C
對應 myglob
,這是 myglob
在共享函式庫的虛擬記憶體映像中的偏移值(回憶一下,連結器假定載入位址是 0x0
3),這個值也可以藉由查看函式庫的符號表(symbol table)來得知,舉例來說,利用 nm
:
$ nm libmlreloc.so
[...] 略過一些東西
0000200c D myglob
這裡的輸出也有提供 myglob
在函式庫內的偏移值,D
代表符號位在已初始化資料區段(.data
)。
載入期重定位的運作
為了觀察載入期重定位的運作,我會用一個簡單的驅動用可執行檔來使用我們的共享函式庫,在執行這個可執行檔的時候,作業系統就會載入該共享函式庫,並且適當地對它重定位。
然而出奇地,由於 Linux 啟用的 ASLR(address space layout randomization)功能,重定位會相對難以追蹤,因為我每次執行可執行檔的時候,共享函式庫 libmlreloc.so
都被放在不同的虛擬記憶體位址〔9〕。
不過,這只是個脆弱的阻礙,還是有方法把它全搞清楚。但首先,讓我們先來談談我們的共享函式庫裡包含的節區:
$ readelf --segments libmlreloc.so
Elf file type is DYN (Shared object file)
Entry point 0x3b0
There are 6 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x00000000 0x00000000 0x004e8 0x004e8 R E 0x1000
LOAD 0x000f04 0x00001f04 0x00001f04 0x0010c 0x00114 RW 0x1000
DYNAMIC 0x000f18 0x00001f18 0x00001f18 0x000d0 0x000d0 RW 0x4
NOTE 0x0000f4 0x000000f4 0x000000f4 0x00024 0x00024 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
GNU_RELRO 0x000f04 0x00001f04 0x00001f04 0x000fc 0x000fc R 0x1
Section to Segment mapping:
Segment Sections...
00 .note.gnu.build-id .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .eh_frame
01 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
02 .dynamic
03 .note.gnu.build-id
04
05 .ctors .dtors .jcr .dynamic .got
為了追蹤符號 myglob
,我們感興趣的是這裡列出的第二個節區,注意一下兩件事:
在下面「section to segment mapping(區段與節區的映射)」中,說了節區 01 包含了區段
.data
,也就是myglob
的所在處。VirtAddr
這欄指明第二個節區自0x1f04
開始,並且大小是0x10c
,代表它伸展到0x2010
,因而包含了位在0x200C
的myglob
。
現在,讓我們來解析載入期連結的過程,這要利用 Linux 給的一個好工具——函式 dl_iterate_phdr
,它讓應用程式能在執行期調查程式載入了哪些共享函式庫,更重要的——瞧一瞧那些函式庫的程式標頭(program header)。
因此,我在 driver.c
寫進下面的程式碼:
#define _GNU_SOURCE
#include <link.h>
#include <stdlib.h>
#include <stdio.h>
static int header_handler(struct dl_phdr_info* info, size_t size, void* data)
{
printf("name=%s (%d segments) address=%p\n",
info->dlpi_name, info->dlpi_phnum, (void*)info->dlpi_addr);
for (int j = 0; j < info->dlpi_phnum; j++) {
printf("\t\t header %2d: address=%10p\n", j,
(void*) (info->dlpi_addr + info->dlpi_phdr[j].p_vaddr));
printf("\t\t\t type=%u, flags=0x%X\n",
info->dlpi_phdr[j].p_type, info->dlpi_phdr[j].p_flags);
}
printf("\n");
return 0;
}
extern int ml_func(int, int);
int main(int argc, const char* argv[])
{
dl_iterate_phdr(header_handler, NULL);
int t = ml_func(argc, argc);
return t;
}
header_handler
實做了給 dl_iterate_phdr
的回呼(callback)函式,它會因每個函式庫而被呼叫,並報告它們的名字和載入位址,伴隨著它們所有的節區。我們也會調用來自共享函式庫 libmlreloc.so
的 ml_func
。
為了編譯驅動用程式並和我們的共享函式庫連結在一起,執行:
gcc -g -c driver.c -o driver.o
gcc -o driver driver.o -L. -lmlreloc
單獨運行這個驅動用程式,我們也能取得資訊,但每次執行都會有不同的位址,所以我會在 gdb
〔10〕之下來運行它,看一下它所說的,然後再使用 gdb
進一步查詢行程的記憶體空間:
$ gdb -q driver
Reading symbols from driver...done.
(gdb) b driver.c:31
Breakpoint 1 at 0x804869e: file driver.c, line 31.
(gdb) r
Starting program: driver
[...] 略過一些東西
name=./libmlreloc.so (6 segments) address=0x12e000
header 0: address= 0x12e000
type=1, flags=0x5
header 1: address= 0x12ff04
type=1, flags=0x6
header 2: address= 0x12ff18
type=2, flags=0x6
header 3: address= 0x12e0f4
type=4, flags=0x4
header 4: address= 0x12e000
type=1685382481, flags=0x6
header 5: address= 0x12ff04
type=1685382482, flags=0x4
[...] 略過一些東西
Breakpoint 1, main (argc=1, argv=0xbffff3d4) at driver.c:31
31 }
(gdb)
因為 driver
會回報它載入的所有函式庫(甚至包括暗中做的,像是 libc
或動態載入器本身),輸出結果很長,但我只會聚焦在關於 libmlreloc.so
的報告。這邊要說一下那六個節區跟之前 readelf
所回報的相同,只是這一次被搬到它們的最終記憶體位置。
讓我們來點數學計算,在輸出中說 libmlreloc.so
被放在虛擬位址 0x12e000
,我們在意的是第二個節區,就是在用 readelf
時看到偏移值 0x1f04
的那個;實際上,在這裡的輸出我們看到它載入在位址 0x12ff04
,而因為 myglob
在檔案中說偏移值是 0x200c
,我們估計它現在會在位址 0x13000c
。
所以,讓我們問問 GDB:
(gdb) p &myglob
$1 = (int *) 0x13000c
好極了!但是,ml_func
的程式碼又是怎麼參考到 myglob
的呢?讓我們再問一次 GDB:
(gdb) set disassembly-flavor intel
(gdb) disas ml_func
Dump of assembler code for function ml_func:
0x0012e46c <+0>: push ebp
0x0012e46d <+1>: mov ebp,esp
0x0012e46f <+3>: mov eax,ds:0x13000c
0x0012e474 <+8>: add eax,DWORD PTR [ebp+0x8]
0x0012e477 <+11>: mov ds:0x13000c,eax
0x0012e47c <+16>: mov eax,ds:0x13000c
0x0012e481 <+21>: add eax,DWORD PTR [ebp+0xc]
0x0012e484 <+24>: pop ebp
0x0012e485 <+25>: ret
End of assembler dump.
如同預期,myglob
真正的位址已經被放到所有參考到它的 mov
指令了,正如同重定位項目所指定得那樣。
函式呼叫的重定位
到目前為止,這篇文章驗證了資料參照(data reference)的重定位——以全域變數 myglob
做為例子;另一個需要重定位的是程式碼參照(code reference)——換句話說,函式呼叫。這一節就是這件事怎麼完成的簡略指引,這裡的進度會比較快,因為我現在可以假設讀者了解重定位到底是什麼了。
廢話不多說,讓我們立刻開始。我已經把共享函式庫的程式碼修改成下面這樣:
int myglob = 42;
int ml_util_func(int a)
{
return a + 1;
}
int ml_func(int a, int b)
{
int c = b + ml_util_func(a);
myglob += c;
return b + myglob;
}
ml_util_func
加了進來,而且會被 ml_func
使用;這裡有被連結的共享函式庫內 ml_func
的反組譯結果:
000004a7 <ml_func>:
4a7: 55 push ebp
4a8: 89 e5 mov ebp,esp
4aa: 83 ec 14 sub esp,0x14
4ad: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
4b0: 89 04 24 mov DWORD PTR [esp],eax
4b3: e8 fc ff ff ff call 4b4 <ml_func+0xd>
4b8: 03 45 0c add eax,DWORD PTR [ebp+0xc]
4bb: 89 45 fc mov DWORD PTR [ebp-0x4],eax
4be: a1 00 00 00 00 mov eax,ds:0x0
4c3: 03 45 fc add eax,DWORD PTR [ebp-0x4]
4c6: a3 00 00 00 00 mov ds:0x0,eax
4cb: a1 00 00 00 00 mov eax,ds:0x0
4d0: 03 45 0c add eax,DWORD PTR [ebp+0xc]
4d3: c9 leave
4d4: c3 ret
這裡令人感興趣的是在位址 0x4b3
的指令——它是對 ml_util_func
的呼叫,我們來剖析它一下:
e8
是 call
的指令碼(opcode),而 call
的引數則是對於下個指令的相對偏移值,在上面的反組譯中,這個參數是 0xfffffffc
,簡單點說則是 -4
。因此,call
目前指向它自己,這當然是不對的——但別忘了還有重定位。這裡有共享函式庫的重定位區段現在看起來的樣子:
$ readelf -r libmlreloc.so
Relocation section '.rel.dyn' at offset 0x324 contains 8 entries:
Offset Info Type Sym.Value Sym. Name
00002008 00000008 R_386_RELATIVE
000004b4 00000502 R_386_PC32 0000049c ml_util_func
000004bf 00000401 R_386_32 0000200c myglob
000004c7 00000401 R_386_32 0000200c myglob
000004cc 00000401 R_386_32 0000200c myglob
[...] 略過一些東西
假如比較先前 readelf -r
的調用,我們會注意到新加了一筆項目給 ml_util_func
,這筆項目指向位址 0x4b4
,也就是指令 call
的引數,而它的型態是 R_386_PC32
。這個重定位型態比 R_386_32
複雜了點,但不多。
它的意思如下所述:拿出該項目指定的偏移值中的值,加上該符號的位址,再減去偏移值本身的位址,然後把結果放回偏移值的內容 4。回想一下,這件重定位是在載入期完成的,那時符號與被做重定位的偏移本身的最終載入位址都已經知道了,這些最終位址會用來參與計算。
這在做什麼?基本上,這是相對式(relative)重定位,將它的位置納入考量,以符合相對定址指令的引數(e8
這個 call
指令就是這樣),我保證在帶入實際數值後就會很清楚了。
我現在要建立驅動用程式並再次在 GDB 下執行它,來觀察重定位的運作,這裡有個 GDB 會話(session)內容,後續是說明:
$ gdb -q driver
Reading symbols from driver...done.
(gdb) b driver.c:31
Breakpoint 1 at 0x804869e: file driver.c, line 31.
(gdb) r
Starting program: driver
[...] 略過一些輸出
name=./libmlreloc.so (6 segments) address=0x12e000
header 0: address= 0x12e000
type=1, flags=0x5
header 1: address= 0x12ff04
type=1, flags=0x6
header 2: address= 0x12ff18
type=2, flags=0x6
header 3: address= 0x12e0f4
type=4, flags=0x4
header 4: address= 0x12e000
type=1685382481, flags=0x6
header 5: address= 0x12ff04
type=1685382482, flags=0x4
[...] 略過一些輸出
Breakpoint 1, main (argc=1, argv=0xbffff3d4) at driver.c:31
31 }
(gdb) set disassembly-flavor intel
(gdb) disas ml_util_func
Dump of assembler code for function ml_util_func:
0x0012e49c <+0>: push ebp
0x0012e49d <+1>: mov ebp,esp
0x0012e49f <+3>: mov eax,DWORD PTR [ebp+0x8]
0x0012e4a2 <+6>: add eax,0x1
0x0012e4a5 <+9>: pop ebp
0x0012e4a6 <+10>: ret
End of assembler dump.
(gdb) disas /r ml_func
Dump of assembler code for function ml_func:
0x0012e4a7 <+0>: 55 push ebp
0x0012e4a8 <+1>: 89 e5 mov ebp,esp
0x0012e4aa <+3>: 83 ec 14 sub esp,0x14
0x0012e4ad <+6>: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
0x0012e4b0 <+9>: 89 04 24 mov DWORD PTR [esp],eax
0x0012e4b3 <+12>: e8 e4 ff ff ff call 0x12e49c <ml_util_func>
0x0012e4b8 <+17>: 03 45 0c add eax,DWORD PTR [ebp+0xc]
0x0012e4bb <+20>: 89 45 fc mov DWORD PTR [ebp-0x4],eax
0x0012e4be <+23>: a1 0c 00 13 00 mov eax,ds:0x13000c
0x0012e4c3 <+28>: 03 45 fc add eax,DWORD PTR [ebp-0x4]
0x0012e4c6 <+31>: a3 0c 00 13 00 mov ds:0x13000c,eax
0x0012e4cb <+36>: a1 0c 00 13 00 mov eax,ds:0x13000c
0x0012e4d0 <+41>: 03 45 0c add eax,DWORD PTR [ebp+0xc]
0x0012e4d3 <+44>: c9 leave
0x0012e4d4 <+45>: c3 ret
End of assembler dump.
(gdb)
這裡重要的是:
- 我們在從
driver
的輸出中,可以看到libmlreloc.so
第一個節區(程式碼節區)被映射在0x12e000
〔11〕。 ml_util_func
載入在0x0012e49c
。- 受重定位的偏移值的位址是
0x0012e4b4
。 - 在
ml_func
內對ml_util_func
的呼叫被修正,在引數內放了0xffffffe4
(我用了旗標/r
來反組譯ml_func
,才能在反組譯結果中額外顯示原始十六進位資料),能正確解譯為對ml_util_func
的偏移值。
很明顯,我們最感興趣的是 (4) 是怎麼達成的,數學計算的時間又到了,照著上面提及的 R_386_PC32
重定位項目所述,我們要:
拿出該項目指定的偏移值中的值(0xfffffffc
),加上該符號的位址(0x0012e49c
),再減去偏移值本身的位址(0x0012e4b4
),然後把結果放回偏移值的內容。當然,所有事都假定用 32 位元的二補數(2-s complement)計算,其結果是 0xffffffe4
,如同預期。
加分題:為什麼指令呼叫的重定位是需要的?
這是做為「紅利」一節,探討某些 Linux 上共享函式庫載入實作的特質,如果你只想了解重定位是怎麼做的,那你可以放心略過它。
當我試著理解 ml_util_func
的指令呼叫重定位的時候,我承認我抓破頭了好一陣子。回想一下,call
的引數是相對偏移值,在函式庫載入的時候,這個在 call
跟 ml_util_func
之間的偏移值本身當然是不變的——它們都在程式碼節區,會以一整塊的方式搬移。因此,為什麼指令呼叫的重定位是一定需要的?
這裡來試一個小實驗:回到共享函式庫的程式碼當中,在 ml_util_func
的宣告中加上 static
,重編後再看一次 readelf -r
的輸出結果。
好了沒?不管怎樣,我要公布結果了——那個重定位消失了!分析 ml_func
的反組譯結果——現在 call
的引數放的是正確的偏移值了——不需要重定位了。這是怎麼了?
在試著將全域符號參照到它們確實的定義時,關於先搜尋哪個共享函式庫的順序,動態載入器有著一些規則,使用者也能透過設定環境變數 LD_PRELOAD
來影響這個順序。
但這有太多的細節需要涵蓋,假如你真的很感興趣,你可以去看看 ELF 標準規格書、動態載入器的 man page 和 Google 一下 5。簡單地說,當 ml_util_func
是全域性的,它可能在可執行檔或其他共享函式庫遭到覆蓋(override)6,所以在連結我們的共享函式庫時,連結器不能直接假設偏移值是已知的,並因此把它寫死〔12〕。為了允許動態載入器決定如何解析全域符號,連結器把所有對它們的參照弄成可重新定位,這也是為什麼將函式宣告成 static
結果就會不一樣——因為它不再是全域或是被輸出(export)的,於是連結器就可以在程式內寫死它的偏移值。
加分題 #2:從可執行檔對共享函式庫的資料做參照
這是另一個做為紅利的一節,討論一個進階的議題,假如你已經對這些感到疲倦,你可以放心略過它。
上面的例子中,myglob
只在共享函式庫的內部使用,那假如我們從程式(driver.c
)裡對它做參考又會怎樣呢?畢竟,myglob
是全域變數所以是外部可見的。
讓我們把 driver.c
改成下面這樣(要說一下,我把對節區做迭代的程式碼拿掉了):
#include <stdio.h>
extern int ml_func(int, int);
extern int myglob;
int main(int argc, const char* argv[])
{
printf("addr myglob = %p\n", (void*)&myglob);
int t = ml_func(argc, argc);
return t;
}
現在它會印出 myglob
的位址,其輸出是:
addr myglob = 0x804a018
等等,這裡有點不對勁,myglob
不是落在共享函式庫的位址空間嗎?0x804xxxx
看起來像是落在程式本身的位址空間,這又是怎麼了?
回想一下,程式/可執行檔本身不是可以重定位的,所以它的資料位址必須在連結期做綁定(bind),因此連結器必須在程式的位址空間創建變數的副本,之後動態載入器就會用它當作重定位位址。這跟前一節討論的有點相似——在某種意義上,主程式中的 myglob
覆寫了共享函式庫中的那一個,然後根據全域符號的檢索規則,就會改為使用它。假如在 GDB 分析一下 ml_func
,就會看到對 myglob
所做的正確參照:
0x0012e48e <+23>: a1 18 a0 04 08 mov eax,ds:0x804a018
這能行得通的原因,是因為在 libmlreloc.so
裡面仍存在著對 myglob
的 R_386_32
重定位,而動態載入器會讓它指向 myglob
現在所處的正確位置。
這一切都很好,但還是少了些東西,myglob
是在共享函式庫裡被初始化的(成為 42),這個初始值是怎麼跑到程式本身的位址空間的呢?結果是由於有一筆特別的重定位項目,由連結器建立在程式裡面(到目前為止,我們只分析了共享函式庫內的重定位項目):
$ readelf -r driver
Relocation section '.rel.dyn' at offset 0x3c0 contains 2 entries:
Offset Info Type Sym.Value Sym. Name
08049ff0 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
0804a018 00000605 R_386_COPY 0804a018 myglob
[...] 略過一些東西
要注意給 myglob
的 R_386_COPY
重定位,它大概的意思是:從符號的位址複製值到這個偏移值,動態載入器會在載入共享函式庫時執行這個行為。那它怎麼知道要複製多少資料呢?符號表的區段含有每個符號的大小;舉裡來說,在 libmlreloc.so
的區段 .symtab
裡,myglob
的大小是 4。
我認為這是一個很棒的例子,展現了可執行檔的連結與載入流程是如何一起精心規劃的,連結器在輸出中塞一些特別指令給動態載入器消化並執行。
結語
載入期重定位是 Linux 在載入共享函式庫到記憶體的時候,用來解析它們的內部資料與程式碼參照的其中一個方法。但今時今日,位址無關程式碼(PIC)是更流行的觀點,而某些現代系統(例如 x86-64 7)也已不再支援載入期重定位。
而我仍然決定撰寫一篇講載入期重定位的文章有兩個理由,第一,載入期重定位在某些系統上相對於 PIC 是有些優勢的,特別是從效能的方面來說;第二,在沒有背景知識的情形下,恕我直言,載入期重定位是比較單純的,這會讓未來解釋 PIC 簡單一點。
無論動機如何,我希望協助闡明了在現代作業系統中,共享函式庫的連結與載入幕後的魔法。
- 〔1〕
- 更多關於入口點的資訊,請看這篇文章的〈Digression – process addresses and entry point〉一節。
- 〔2〕
- 連結期(link-time)重定位發生在合併多個目的檔(object file)成為一個可執行檔(或共享函式庫)的過程中,它牽涉了一堆的重定位來解析目的檔之間的符號參考。連結期重定位是個比載入期重定位更複雜的議題,我在這篇文章中不會涵蓋它。
- 〔3〕
- 只要將你所有的函式庫編譯成靜態函式庫(static library)(利用
ar
來合併目的檔而非gcc -shared
),並在連結成可執行檔時提供旗標-static
給gcc
——避免連結到libc
的共享版本,就可能辦到。 - 〔4〕
ml
取自「my library」,並且程式碼本身沒有任何意義,僅僅是為了演示所用。- 〔5〕
- 也被稱為「動態連結器(dynamic linker)」,它本身是一個共享物件(雖然也能如同可執行檔般運行),位於
/lib/ld-linux.so.2
(最後的數字是 SO 檔版本,也許會不同)。 - 〔6〕
- 假如你不熟悉 x86 是如何構造它的堆疊框架(stack frame)的,那麼,這是一個讀讀這篇文章的好時機。
- 〔7〕
- 你可以提供旗標
-l
給objdump
來把每行 C 原始碼加入到反組譯結果中,讓什麼編譯成什麼更清楚;但為了讓輸出更簡短,我在這裡就不這麼做了。
(譯註:這裡之所以能這麼做,是因為編譯時提供給 GCC 的旗標-g
,已經讓 GCC 將除錯資訊鑲嵌進可執行檔的緣故。) - 〔8〕
- 我說的是
objdump
的輸出的左手邊,原始記憶體位元組那塊,a1 00 00 00 00
表示mov
到eax
,並輔以運算元0x0
,也就是在反組譯中被譯為ds:0x0
的部分。 - 〔9〕
- 因此每次在那個可執行檔上調用
ldd
都會得到不同載入位址對應共享函式庫。 - 〔10〕
- 有經驗的讀者可能會說,我只要對 GDB 下
i shared
就可以得到共享函式庫的載入位址了;可是,i shared
只會給整個函式庫的載入位址(或更準確地說,是它的入口點),而我感興趣的則是那些節區。 - 〔11〕
- 什麼,又是
0x12e000
?我不是剛剛才在說載入位址隨機化嗎?這說明為了除錯,動態載入器可以被操作以關閉這個機制,也就是 GDB 所做的。
(譯註:GDB 的這個功能可以由set disable-randomization
來控制。) - 〔12〕
- 除非傳遞了旗標
-Bsymbolic
給它;在ld
的 man page 中有關於它的一切東西。
↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑
本文以 StackEdit 撰寫。
譯註:
- 嚴格說來,在 32 位元 x86 底下,由於有分節(segmentation)機制的存在(這是硬體機制,不要和本文討論 ELF 格式中的「節區」搞混了),這裡的絕對位址其實是對於節區暫存器的偏移值,但由於 Linux 模擬了平滑記憶體模型(flat memory model)來讓分節無效化,因而偏移植與真正的絕對位址相同;這也是為什麼在後面舉例時,還是會發現節區暫存器
DS
出現在反組譯的結果中。
↩ - 正式的說法應該叫 function prologue,也叫 prolog,是指在函式主體開始前進行準備工作的程式碼;與其相對的,是在函式主體之後做收尾的 function epilogue,也叫 epilog,這些在 wiki 有更詳細的說明。
↩ - 如果參考了之後
readelf --segments
的輸出,可能會注意到,即使從位址0x0
開始載入共享函式庫,myglob
在虛擬記憶體映像的偏移值也不會等於它對於檔案的偏移值(資料節區在檔案內的偏移值為0xf04
,而假定的記憶體位置卻是0x1f04
),這主要是由於 ELF 檔案的載入是以「節區」為處理單位,而節區載入的機制與硬體分頁(paging)有關,才導致對於檔案的偏移值通常會比較小。
↩ - 原作者這裡介紹的順序,是配合規格書中的順序,這個順序可能跟一些數值處理方面的考量有關,為了容易理解,我會重新帶入這裡的例子來解釋。
原本的內容大概是:「偏移值內容 = 偏移值原內容 + 符號位址 - 偏移值位址」,我們知道偏移值就是call
的引數,內容是 -4,符號就是在說被呼叫目標,偏移值位址就是call
引數的位址。帶入其中再調整一下順序就是:「call
的引數 = 被呼叫目標的位址 - (call
引數的位址+ 4)」,而 (call
引數的位址+ 4) 不就是call
的下一個指令位址嗎?!這樣就跟call
指令的定義相符了。
↩ - Ian Wienand 撰寫的的《Computer Science from the Bottom Up》中〈Working with libraries and the linker〉一節,對這部分的成因以及因應之道有許多參照 ELF 規格書的完善解釋。
↩ - 這樣的現象,叫做符號介入(symbol interposition),如果程式開發者確定不會有這種現象,在 GCC 5 以後,可以對 GCC 提供參數
-fno-semantic-interposition
,那 GCC 就會在最佳化時把這個前提納入考量,因而就可能直接把偏移值計算好。
而 Jay Conrod 的〈Tutorial: Function Interposition in Linux〉以開發者的角度說明函式介入(function interposition)在實務上有些怎樣的利用技巧。
↩ - 嚴格說來,其實在 64 位元 x86 的 Linux 系統上,並非是完全不支援,只是在使用上多了許多限制,可以參考原作者的這篇文章。
↩
沒有留言 :
張貼留言