跳到主要內容

中文試譯:Load-time relocation of shared libraries

作者:eliben
原文連結:http://eli.thegreenplace.net/2011/08/25/load-time-relocation-of-shared-libraries/

共享庫的載入時重定位
這篇文章的目的在解釋現代作業系統如何讓共享庫在載入時的連結動作發生。我們聚焦在32位元的x86 Linux上頭,但相同的原則在其他的作業系統與CPUs上一樣適用。
要注意的是,共享庫有許多不同的名字 - 共享庫(shared libraries),共享物件( shared objects),動態共享物件( dynamic shared objects (DSOs)),動態連結庫( dynamically linked libraries(如果你習慣的是Windows的環境,就知道這是所謂的DLL))。為了一致性,我在整篇文章中會使用"共享庫"這個字眼。

載入可執行檔
如同其他支援虛擬記憶體的作業系統,Linux將可執行檔載入到一個固定的記憶體位址。如果我們檢視任意一個可執行檔的ELF header,我們將可發現一個Entry point的位址:

$ 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的位址,我們將可看到.text segment的第一個被執行的指令。
這代表的是,當linker連結可執行檔時,會把全部的內部符號引用的位址都確定下來(function以及data),確定它們的固定的、最終的位址。Linker本身會對自己進行一些relocation[2],但最終的結果不會有任何的relocation。
講啥啊?注意,我在上段文中特別強調內部。只要可執行檔本身沒有用到任何共享庫[3],它就不需要進行relocation。但如果它使用了共享庫(大部分的Linux應用程式都是如此),因為共享庫的載入機制的關係,在共享庫中的符號必須進行relocation。

載入共享庫
不像可執行檔,當共享庫被建立時,linker無法為它的程式碼假定一個已知的載入位置。理由很簡單。每個程式可以使用任意多個共享庫,根本就沒有簡單的辦法可以事先知道一個共享庫會被載入到process的那個虛擬記憶體位址。多年來,不少的解決方案被創造出來,這篇文章我只會專注在Linux目前採用的作法。但一開始,讓我們簡單地思考一個問題。這邊是一點C的代碼[4],我把它編譯成共享庫的形式:
int myglob = 42;

int ml_func(int a, int b)
{
    myglob += a;
    return b + myglob;
}
注意ml_func中引用了myglob好幾次。當轉換成x86的assembly後,這會讓mov指令透過某個絕對的記憶體位址去獲取myglob,並放進register。那麼,linker是怎麼知道要放什麼位址進去呢?答案是 - 它不知道。如同我剛講過的,共享庫沒有事先被決定的位址,位址將會動態地決定。
在Linux中,dynamic loader[5]是一段負責準備讓程式運作的程式碼。它的任務之一是將執行中的程式所需要的共享庫從硬碟中載入到記憶體。當共享庫載入到記憶體中,它接著會依據載入位址被修正位址。前段文中所提的修正位址便是dynamic loader所要完成的工作。在Linux ELF共享庫中,有兩個主要途徑可以解決這個問題:
    1. 載入時relocation

    2. 位址無關程式碼(Position independent code(PIC))

雖然PIC是現在最常使用及推薦的的方法,在本文中我將會集中說明的是載入時relocation。我最終計畫將這兩個方式都解釋過,並另外寫一篇文章說明PIC,而且我認為從載入時relocation的說明開始,會讓PIC的解釋更加清楚。

連結在載入時relocation的共享庫 
要創建一個可以在載入時進行relocation的共享庫,可以在編譯時不要加進-fPIC的選項(這個選項會導致PIC的產生):

gcc -g -c ml_main.c -o ml_mainreloc.o
gcc -shared -o libmlreloc.so ml_mainreloc.o
第一件有趣的事是libmlreloc.so的entry point:
$ 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
為了簡單,linker僅僅將共享物件連結到位址0x0(.text section從0x3b0開始),因為它知道loader將會把它擺到不同位址。將這個事實記在心中 - 在稍後的篇幅中,這個概念會很有幫助。
現在,我們來看一下共享庫的disassembly,主要看ml_func的部份:
$ objdump -d -Mintel libmlreloc.so

libmlreloc.so:     file format elf32-i386

[...] skipping stuff

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

[...] skipping stuff
在開頭的兩行指令後[6],我們看到編譯後的myglob += a的樣子[7]。myglob的值被移進eax,與a相加後(a位於ebp+0x8),然後被放回記憶體。
等等,mov抓取myglob?啥米?看起來mov實際上的運算元是0x0[8]。哪來的值啊?這就是relocation如何工作的方式。linker放置某個臨時、已定義的值在指令裡(此例中,臨時的值是0x0),然後建立一個特殊的relocation entry指到這個位址。讓我們檢視一下這個共享庫的relocation entries吧:
$ 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
[...] skipping stuff
ELF中的rel.dyn section被保留作為dynamic(載入時)relocation的用途,會由dynamic loader來使用。由於myglob在disassembly中被三個地方引用,在上圖中,myglob便有三個relocation entries。我們先來把第一個entry的意義解密出來吧。
它的意思是:到這個object file(共享庫)的offset 0x470的位置,然後以R_386_32的方式處理myglob這個符號。如果我們查閱ELF的spec,我們可以看到R_386_32這種relocation的方式的意思是:以位於指定的offset的數值,加上該符號的位址,然後將其放回該offset的地方。
我們在共享庫offset 0x470的位址有什麼東西呢?回想一下ml_func的disassembly:
46f:  a1 00 00 00 00          mov    eax,ds:0x0
a1是mov的機器指令,所以它的運算元是在下一個位址0x470開始。我們在disassembly中可以看到,這個值為0x0。現在回到relocation entry,我們現在了解了它的意思了:將myglob的位址加到mov的運算元,然後擺回0x470。換句話說,他告訴dynamic loader- 一旦你實際處理位址的給定時,請將myglob的位址擺到0x470,如此一來,mov的運算元就可被正確的值所替代了。酷吧?
再看一下在relocation section中"Sym.value"那欄,它指名了myglob有0x200C的值。這是myglob在共享庫的虛擬記憶體映像中的offset(原本linker假設是載入到0x0的位址)。這個值也可以透過檢視此程式庫的symbal table來確認,比如說,我們可以用nm:
$ nm libmlreloc.so
[...] skipping stuff
0000200c D myglob
這個輸出也提供了myglob在程式庫中的offset。D表示這個符號位於初始化的資料區段(.data)。


載入時的relocation
為了看到載入時relocation的動作,我將會透過一個簡單的程式去使用這個共享庫。當執行這個程式時,作業系統會載入共享庫並適當地relocate它。奇怪的是,因為Linux啟動了位址空間佈置的隨機化機制,重定位變得較難追蹤,因為每次我執行程式,libmlreloc.so都被放置到不同的virtual memory address[9]。
然而,這種機制是蠻弱的防駭機制。有辦法可以知道它到底在幹麼。但首先,先讓我們看看我們的共享庫的segment是如何組成的:
$ 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符號,我們對第二個segment特別有興趣。注意幾件事情:
    >> 從底部的section對應segment的訊息來看,segment 01包括了.data setion,myglob就是在這個section裡。
    >> 第二個segment的VirAddr欄位指明了它從0x1f04開始,並且長度為0x10c,也就是說這個segment會延伸到0x2010,而包含了位於0x200c的myglob。
現在,讓我們用Linux給予我們的一個美妙工具 - dl_iterate_phdr function,去觀察載入時的連結過程。這個工具允許應用程式在執行期去詢問哪些共享庫被載入並且還可以看看它們的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。它將會被呼叫來顯示所有的程式庫的名稱與載入位址以及相關的所有segments。這支程式同時也呼叫了在libmlreloc.so中的ml_func。編譯連結這個程式的方式:

gcc -g -c driver.c -o driver.o
gcc -o driver driver.o -L. -lmreloc

執行這個程式,我們獲得了一些資訊,但每次執行後,獲得的位址都不一樣。所以我接著要做的就是以gdb去執行它[10],方便進一步地觀察process的記憶體空間:

 $ 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
 [...] skipping output
 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

[...] skipping output
 Breakpoint 1, main (argc=1, argv=0xbffff3d4) at driver.c:31
 31    }
 (gdb)

driver顯示了所有它載入的程式庫(甚至一些隱含的載入,像libc和動態連結器本身)。輸出很長,所以我只會專注在libmlreloc.so相關的部份。注意,這六個segments之前以被readelf顯示過,但這次它們已被重定位後載入到最後的記憶體位址。
來作點數學。輸出結果顯示,libmlreloc.so被放置在虛擬位址0x12e000。我們感興趣的是第二個segment,它位於位移0x1fo4的地方。確實,我們看到它被載入到0x12ff04。既然myglob位於檔案位移0x200c之處,我們預期它現在會在0x13000c出現。讓我們問問GDB吧:
(gdb) p &myglob
$1 = (int *) 0x13000c
棒極了!那引用了myglob的ml_func又如何呢?再問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.
如我們預期,所有mov指令引用到的myglob的位址都換成了實際的位址,如同重定位項目所指明的一樣。


函式的重定位
到目前為止,這篇文章確認了資料的重定位 - 透過全域變數myglob作為例子。另一個需要被重定位的是程式碼的引用,就是函式呼叫。這個小節簡要說明這件事如何被完成。步調會較文章的其他部份快許多,畢竟我現在可以假設讀者已經瞭解重定位到底是什麼了。讓我們開始吧。我將共享庫的程式改成如下:


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
[...] skipping stuff
如果我們比對之前的readelf -r的結果,我們可以發現一個新的項目 - ml_util_func。這個項目指到0x4b4,這是call的參數的位址,而且他的型態為R_386_PC32。這個型態比R_386_32雖為複雜一點,但沒有複雜太多。 
它的意思是:拿這個項目所指到的位移的值,加上符號的位址,再減掉位移自身的位址,然後將結果放回位移所指到的那個word中。回想一下,這個步驟是在載入時完成,是在符號最後的載入位址和被重定位的位移本身都已經知道的那個時間點。這些最終的位址參與了這個計算。
是怎麼作到的勒?基本上,這是相對重定位,將位址納入考慮,然後適當的相對定址的指令參數被代入(這邊就是e8 call)。當我們看到實際的數字後,我保證這概念會變得清晰。現在要建立driver code然後在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
 [...] skipping output
 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

[...] skipping output
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的第一個segment(the code segment)被對應到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),然後放回該位移所指到的word中。當然,所有的動作都是在2的補數的算術下完成。結果就是如我們預期的0xffffffe4。

額外的紅利:為何需要函式呼叫的重定位?

這小節是額外的"紅利",討論Linux中的共享庫一些特別的實作。如果你只想瞭解重定位如何完成,你可以安全地略過這邊。
當試圖瞭解ml_util_func的call relocation,我必須承認我抓破腦袋好一陣子。想想看,call的參數是相對位移,而程式庫載入時,call指令與ml_util_func本身的位移並沒有改變 - 它們都在code segment,會是在同一個區塊裡頭。所以為什麼要作重定位呢?
這邊有一個小實驗可以嘗試:回到共享庫的程式碼裡頭,替ml_util_func的宣告加上static修飾。重新編譯,然後再看看readelf -r。好了嗎?無論如何,我還是會給出結果 - 該重定位項目不見嘍!括查ml_func的反組譯碼 - 那邊線在已經有給call正確的位移值 - 不需要重定位。到底是怎麼一回事啊?
當試圖引用全域符號的實際定義時,動態載入器對於共享庫的搜尋有一些規則存在。使用者也可以透過LD_PRELOAD環境變數來影響這個搜尋順序。
有許多的細節必須說明,所以如果你真的感興趣,你可以查閱ELF的規範、動態載入器的man page以及試著google。簡單來說,當ml_util_func是全域的符號時,它有可能可以被執行檔中另一個同名符號或者另一個位於其他共享庫的同名符號所取代,連結器不能假設位移是已知的就修改它[12]。它會讓所有的全域符號都是可重定位的,這樣才能讓動態載入器決定如何決議它們。這也是為什麼用static去宣告會造成不同結果 - 既然不再是全域或被輸出的符號,連結器就可以將位移寫死進去。

額外的紅利 #2:從可執行檔引用共享庫的資料
同樣的,這是一個紅利小節,討論的是較進階的主題。如果你很累了,那就安心地略過這些東西吧。
在上面的例子中,myglob只在共享庫內部所用。如果我們從程式(driver.c)去引用它會發生什麼事呢?畢竟,myglob是全域變數,可以被外界所看到。
讓我們修改driver.c如下(注意,我移除了巡訪segment的程式碼):
#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看起來像是執行檔的位址空間。又怎麼了啊?
回想一下,可執行檔並不是可重定位的,所以它的data的位址必須在連結時決定。因此,連結器必須將變數複製一份到執行檔的位址空間,然後動態載入器會使用這份複製作為重定位的位址。這有點類似之前小節的討論 - 有點像是在主程式的myglob取代了共享庫中的符號,然後根據全域符號的查找規則被使用。如果我們用GDB去觀察ml_func,我們可以看到myglob的引用被正確地指明:
0x0012e48e <+23>:      a1 18 a0 04 08 mov    eax,ds:0x804a018
這行得通,因為myglob是R_386_32類型的重定位,依然存在libmlreloc.so中,然後動態載入器會讓它指向正確的地方。
這運作的很好,但還有一些遺漏。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
[...] skipping stuff
注意myglob的R_386_COPY重定位。它只是說:從這個符號的位置將其值複製進指明的位移中。當載入共享庫時,動態連結器就會照著這麼作。連結器怎麼知道要複製多少呢?符號表的section會包涵每個符號的長度資訊。舉例來說,myglob在.symtab中的長度為4。
我認為這是一個很酷的例子去說明執行檔的連接過程以及載入是如何交互作用。連結器放入了特殊的指令給動態載入器去處理並執行。

總結
載入時的重定位是Linux中所採用的方法之一(當然,還有其他作業系統也採用),是為了解決當載入共享庫時,對共享庫的資料與程式的引用問題。現在,與位址無關(PIC)是更受歡迎的途徑,有一些現代的系統(像x86-64)已不再支援載入時重定位。
我依然決定寫這篇文章說明載入時重定位有兩個理由。第一,相較於PIC,載入時重定位有一些優點,尤其是效能的部份。第二,載入時重定位較簡單理解,不需先備知識,也使得PIC在未來可以較容易被解釋。
不論動機為何,我希望這篇文章對於揭示現代作業系統的連結與載入機制的底層魔術機密有所幫助。

http://eli.thegreenplace.net/wp-content/uploads/hline.jpg
[1]想知道更多關於入口點的資訊,可參閱這篇文章的"Digression – process addresses and entry point"小節。
[2]在合併多個物件檔為單一可執行檔(或共享庫)的過程時,會發生連結時重定位。這牽涉到相當多的重定位才能完成物件檔間的符號決議。相對於載入時重定位,連結時重定位是較複雜的主題,我不會在這篇文章中討論到。
[3]當以靜態方式編譯程式庫時,這可以被作到(用ar去包物件檔,不要用gcc -shared),當連結時可執行檔時,提供-static旗標給gcc - 可避免連結到libc的共享版本。
[4]ml是簡單的"my library"的意思。同樣的,程式碼本身沒啥意義,只是拿來驗證使用。
[5]也稱為"動態連結器"。它本身也是共享物件(雖然它也可以被拿來執行),位於/lib/ld-linux.so.2(最後的數字表示SO的版本,有可能會不同)
[6]如果你對x86是如何組織它的stacj frame不是很熟,或許是閱讀這篇文章的好時機。
[7]你可以餵-l選項給objdump去增加C的原始碼倒反組譯碼中,讓它清楚的顯示編譯如何完成。為了讓輸出短一點,我省略了這個步驟。
[8]看看objdump的左邊輸出,那邊的記憶體顯示a1 00 00 00 00,表示mov將0x0移進eax,反組譯器解釋為ds:0x0
[9]所以透過ldd叫起可執行檔,每次都會回報出不同的共享庫載入位址。
[10]有經驗的讀者會注意到我可以要求GDB用i shared去獲得共享庫的載入位址。然而,i shared只指明了整個程式庫的位址(更精確的說,它的進入點),而我感興趣的是segments。
[11]什麼?又是0x12e000?我不是說過有載入時位址隨機化機制嗎?這是因為為了除錯方便,動態載入器可以被關掉這項機制。這也就是GDB幹的好事。
[12]除非傳進-Bsymbolic旗標。讀一下ld的man page吧。



留言

張貼留言

這個網誌中的熱門文章

淺讀Linux root file system初始化流程

在Unix的世界中,file system佔據一個極重要的抽象化地位。其中,/ 所代表的rootfs更是所有後續新增file system所必須依賴前提條件。以Linux為例,黑客 Jserv 就曾經詳細說明過 initramfs的背後設計考量 。本篇文章不再重複背景知識,主要將追蹤rootfs初始化的流程作點整理,免得自己日後忘記。 :-) file system與特定CPU架構無關,所以我觀察的起點從init/main.c的start_kernel()開始,這是Linux作完基本CPU初始化後首先跳進的C function(我閱讀的版本為 3.12 )。跟root file system有關的流程羅列如下: start_kernel()         -> vfs_caches_init_early()         -> vfs_caches_init()                 -> mnt_init()                         -> init_rootfs()                         -> init_mount_tree()         -> rest_init()                 -> kernel_thread(kernel_init,...) 其中比較重要的是mnt_int()中的init_rootfs()與init_mout_tree()。init_rootfs()實作如下: int __init init_rootfs(void) {         int err = register_filesystem(&rootfs_fs_type);         if (err)                 return err;         if (IS_ENABLED(CONFIG_TMPFS) && !saved_root_name[0] &&                 (!root_fs_names || strstr(root_fs_names, "tmpfs"))) {          

誰在呼叫我?不同的backtrace實作說明好文章

今天下班前一個同事問到:如何在Linux kernel的function中主動印出backtrace以方便除錯? 寫過kernel module的人都知道,基本上就是用dump_stack()之類的function就可以作到了。但是dump_stack()的功能是如何作到的呢?概念上其實並不難,慣用手法就是先觀察stack在function call時的變化(一般OS或計組教科書都有很好的說明,如果不想翻書,可以參考 這篇 ),然後將對應的return address一層一層找出來後,再將對應的function名稱印出即可(透過執行檔中的section去讀取函式名稱即可,所以要將KALLSYM選項打開)。在userspace的實作可參考Jserv介紹過的 whocallme 或對岸好手實作過的 backtrace() ,都是針對x86架構的很好說明文章。 不過從前面兩篇文章可以知道,只要知道編譯器的calling convention,就可以實作出backtrace,所以是否GCC有提供現成的機制呢?Yes, that is what __builtin_return_address() for!! 可以參考這篇 文章 。該篇文章還提到了其他可以拿來實作功能更齊全的backtrace的 程式庫 ,在了解了運作原理後,用那些東西還蠻方便的。 OK,那Linux kernel是怎麼做的呢?就是用頭兩篇文章的方式啦~ 每個不同的CPU架構各自手工實作一份dump_stack()。 為啥不用GCC的機制?畢竟...嗯,我猜想,除了backtrace以外,開發者還會想看其他register的值,還有一些有的沒的,所以光是GCC提供的介面是很難印出全部所要的資訊,與其用半套GCC的機制,不如全都自己來~ arm的實作 大致上長這樣,可以看到基本上就只是透過迭代fp, lr, pc來完成: 352 void unwind_backtrace (struct pt_regs * regs , struct task_struct *tsk) 353 { 354 struct stackframe frame ; 355 register unsigned long current_sp asm ( "

kernel panic之後怎麼辦?

今天同事在處理一個陌生的模組時遇到kernel panic,Linux印出了backtrace,同事大致上可以知道是在哪個function中,但該function的長度頗長,短時間無法定位在哪個位置,在這種情況下,要如何收斂除錯範圍呢?更糟的是,由於加入printk會改變模組行為,所以printk基本上無法拿來檢查參數的值是否正常。 一般這樣的問題會backtrace的資訊來著手。從這個資訊我們可以知道在function中的多少offset發生錯誤,以x86為例(從 LDD3 借來的例子): Unable to handle kernel NULL pointer dereference at virtual address 00000000 printing eip: d083a064 Oops: 0002 [#1] SMP CPU:    0 EIP:    0060:[<d083a064>]    Not tainted EFLAGS: 00010246   (2.6.6) EIP is at faulty_write+0x4/0x10 [faulty] eax: 00000000   ebx: 00000000   ecx: 00000000   edx: 00000000 esi: cf8b2460   edi: cf8b2480   ebp: 00000005   esp: c31c5f74 ds: 007b   es: 007b   ss: 0068 Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0) Stack: c0150558 cf8b2460 080e9408 00000005 cf8b2480 00000000 cf8b2460 cf8b2460        fffffff7 080e9408 c31c4000 c0150682 cf8b2460 080e9408 00000005 cf8b2480        00000000 00000001 00000005 c0103f8f 00000001 080e9408 00000005 00000005 Call Trace:  [<c0150558>] vfs