2017年1月8日 星期日

[翻譯] 共享函式庫的載入期重定位

↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓

本文目標為解釋現代作業系統如何在有載入期重定位(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 共享函式庫的這個問題有兩個主要的方式:

  1. 載入期重定位
  2. 位址無關程式碼(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,因而包含了位在 0x200Cmyglob

現在,讓我們來解析載入期連結的過程,這要利用 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.soml_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 的呼叫,我們來剖析它一下:

e8call 的指令碼(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)

這裡重要的是:

  1. 我們在從 driver 的輸出中,可以看到 libmlreloc.so 第一個節區(程式碼節區)被映射在 0x12e000〔11〕
  2. ml_util_func 載入在 0x0012e49c
  3. 受重定位的偏移值的位址是 0x0012e4b4
  4. 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 的引數是相對偏移值,在函式庫載入的時候,這個在 callml_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 裡面仍存在著對 myglobR_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
[...] 略過一些東西

要注意給 myglobR_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),並在連結成可執行檔時提供旗標 -staticgcc——避免連結到 libc 的共享版本,就可能辦到。
〔4〕
ml 取自「my library」,並且程式碼本身沒有任何意義,僅僅是為了演示所用。
〔5〕
也被稱為「動態連結器(dynamic linker)」,它本身是一個共享物件(雖然也能如同可執行檔般運行),位於 /lib/ld-linux.so.2(最後的數字是 SO 檔版本,也許會不同)。
〔6〕
假如你不熟悉 x86 是如何構造它的堆疊框架(stack frame)的,那麼,這是一個讀讀這篇文章的好時機。
〔7〕
你可以提供旗標 -lobjdump 來把每行 C 原始碼加入到反組譯結果中,讓什麼編譯成什麼更清楚;但為了讓輸出更簡短,我在這裡就不這麼做了。

(譯註:這裡之所以能這麼做,是因為編譯時提供給 GCC 的旗標 -g,已經讓 GCC 將除錯資訊鑲嵌進可執行檔的緣故。)
〔8〕
我說的是 objdump 的輸出的左手邊,原始記憶體位元組那塊,a1 00 00 00 00 表示 moveax,並輔以運算元 0x0,也就是在反組譯中被譯為 ds:0x0 的部分。
〔9〕
因此每次在那個可執行檔上調用 ldd 都會得到不同載入位址對應共享函式庫。
〔10〕
有經驗的讀者可能會說,我只要對 GDB 下 i shared 就可以得到共享函式庫的載入位址了;可是,i shared 只會給整個函式庫的載入位址(或更準確地說,是它的入口點),而我感興趣的則是那些節區。
〔11〕
什麼,又是 0x12e000?我不是剛剛才在說載入位址隨機化嗎?這說明為了除錯,動態載入器可以被操作以關閉這個機制,也就是 GDB 所做的。

(譯註:GDB 的這個功能可以由 set disable-randomization控制。)
〔12〕
除非傳遞了旗標 -Bsymbolic 給它;在 ld 的 man page 中有關於它的一切東西。

↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑

本文以 StackEdit 撰寫。

譯註:


  1. 嚴格說來,在 32 位元 x86 底下,由於有分節(segmentation)機制的存在(這是硬體機制,不要和本文討論 ELF 格式中的「節區」搞混了),這裡的絕對位址其實是對於節區暫存器的偏移值,但由於 Linux 模擬了平滑記憶體模型(flat memory model)來讓分節無效化,因而偏移植與真正的絕對位址相同;這也是為什麼在後面舉例時,還是會發現節區暫存器 DS 出現在反組譯的結果中。
  2. 正式的說法應該叫 function prologue,也叫 prolog,是指在函式主體開始前進行準備工作的程式碼;與其相對的,是在函式主體之後做收尾的 function epilogue,也叫 epilog,這些在 wiki 有更詳細的說明
  3. 如果參考了之後 readelf --segments 的輸出,可能會注意到,即使從位址 0x0 開始載入共享函式庫,myglob 在虛擬記憶體映像的偏移值也不會等於它對於檔案的偏移值(資料節區在檔案內的偏移值為 0xf04,而假定的記憶體位置卻是 0x1f04),這主要是由於 ELF 檔案的載入是以「節區」為處理單位,而節區載入的機制與硬體分頁(paging)有關,才導致對於檔案的偏移值通常會比較小。
  4. 原作者這裡介紹的順序,是配合規格書中的順序,這個順序可能跟一些數值處理方面的考量有關,為了容易理解,我會重新帶入這裡的例子來解釋。

    原本的內容大概是:「偏移值內容 = 偏移值原內容 + 符號位址 - 偏移值位址」,我們知道偏移值就是 call 的引數,內容是 -4,符號就是在說被呼叫目標,偏移值位址就是 call 引數的位址。帶入其中再調整一下順序就是:「call 的引數 = 被呼叫目標的位址 - (call 引數的位址+ 4)」,而 (call 引數的位址+ 4) 不就是 call 的下一個指令位址嗎?!這樣就跟 call 指令的定義相符了。
  5. Ian Wienand 撰寫的的《Computer Science from the Bottom Up》中〈Working with libraries and the linker〉一節,對這部分的成因以及因應之道有許多參照 ELF 規格書的完善解釋。
  6. 這樣的現象,叫做符號介入(symbol interposition),如果程式開發者確定不會有這種現象,在 GCC 5 以後,可以對 GCC 提供參數 -fno-semantic-interposition,那 GCC 就會在最佳化時把這個前提納入考量,因而就可能直接把偏移值計算好。

    而 Jay Conrod 的〈Tutorial: Function Interposition in Linux〉以開發者的角度說明函式介入(function interposition)在實務上有些怎樣的利用技巧。
  7. 嚴格說來,其實在 64 位元 x86 的 Linux 系統上,並非是完全不支援,只是在使用上多了許多限制,可以參考原作者的這篇文章

沒有留言 :

張貼留言