4. 共享庫

4.1. 編譯、連結、運行

組成共享庫的目標檔案和一般的目標檔案有所不同,在編譯時要加-fPIC選項,例如:

$ gcc -c -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c

-f後面跟一些編譯選項,PIC是其中一種,表示生成位置無關代碼(Position Independent Code)。那麼用-fPIC生成的目標檔案和一般的目標檔案有什麼不同呢?下面分析這個問題。

我們知道一般的目標檔案稱為Relocatable,在連結時可以把目標檔案中各段的地址做重定位,重定位時需要修改指令。我們先不加-fPIC選項編譯生成目標檔案:

$ gcc -c -g stack/stack.c stack/push.c stack/pop.c stack/is_empty.c

由於接下來要用objdump -dS把反彙編指令和原始碼穿插起來分析,所以用-g選項加調試信息。注意,加調試信息必須在編譯每個目標檔案時用-g選項,而不能只在最後編譯生成執行檔時用-g選項。反彙編查看push.o

$ objdump -dS push.o 

push.o:     file format elf32-i386


Disassembly of section .text:

00000000 <push>:
/* push.c */
extern char stack[512];
extern int top;

void push(char c)
{
   0:	55                   	push   %ebp
   1:	89 e5                	mov    %esp,%ebp
   3:	83 ec 04             	sub    $0x4,%esp
   6:	8b 45 08             	mov    0x8(%ebp),%eax
   9:	88 45 fc             	mov    %al,-0x4(%ebp)
	stack[++top] = c;
   c:	a1 00 00 00 00       	mov    0x0,%eax
  11:	83 c0 01             	add    $0x1,%eax
  14:	a3 00 00 00 00       	mov    %eax,0x0
  19:	8b 15 00 00 00 00    	mov    0x0,%edx
  1f:	0f b6 45 fc          	movzbl -0x4(%ebp),%eax
  23:	88 82 00 00 00 00    	mov    %al,0x0(%edx)
}
  29:	c9                   	leave  
  2a:	c3                   	ret

指令中凡是用到stacktop的地址都用0x0表示,準備在重定位時修改。再看readelf輸出的.rel.text段的信息:

Relocation section '.rel.text' at offset 0x848 contains 4 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
0000000d  00001001 R_386_32          00000000   top
00000015  00001001 R_386_32          00000000   top
0000001b  00001001 R_386_32          00000000   top
00000025  00001101 R_386_32          00000000   stack

標出了指令中有四處需要在重定位時修改。下面編譯連結成執行檔之後再做反彙編分析:

$ gcc -g main.c stack.o push.o pop.o is_empty.o -Istack -o main
$ objdump -dS main
...
080483c0 <push>:
/* push.c */
extern char stack[512];
extern int top;

void push(char c)
{
 80483c0:       55                      push   %ebp
 80483c1:       89 e5                   mov    %esp,%ebp
 80483c3:       83 ec 04                sub    $0x4,%esp
 80483c6:       8b 45 08                mov    0x8(%ebp),%eax
 80483c9:       88 45 fc                mov    %al,-0x4(%ebp)
        stack[++top] = c;
 80483cc:       a1 10 a0 04 08          mov    0x804a010,%eax
 80483d1:       83 c0 01                add    $0x1,%eax
 80483d4:       a3 10 a0 04 08          mov    %eax,0x804a010
 80483d9:       8b 15 10 a0 04 08       mov    0x804a010,%edx
 80483df:       0f b6 45 fc             movzbl -0x4(%ebp),%eax
 80483e3:       88 82 40 a0 04 08       mov    %al,0x804a040(%edx)
}
 80483e9:       c9                      leave  
 80483ea:       c3                      ret    
 80483eb:       90                      nop    
...

原來指令中的0x0被修改成了0x804a010和0x804a040,這樣做了重定位之後,各段的加載地址就定死了,因為在指令中使用了絶對地址。

現在看用-fPIC編譯生成的目標檔案有什麼不同:

$ gcc -c -g -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c
$ objdump -dS push.o

push.o:     file format elf32-i386


Disassembly of section .text:

00000000 <push>:
/* push.c */
extern char stack[512];
extern int top;

void push(char c)
{
   0:	55                   	push   %ebp
   1:	89 e5                	mov    %esp,%ebp
   3:	53                   	push   %ebx
   4:	83 ec 04             	sub    $0x4,%esp
   7:	e8 fc ff ff ff       	call   8 <push+0x8>
   c:	81 c3 02 00 00 00    	add    $0x2,%ebx
  12:	8b 45 08             	mov    0x8(%ebp),%eax
  15:	88 45 f8             	mov    %al,-0x8(%ebp)
	stack[++top] = c;
  18:	8b 83 00 00 00 00    	mov    0x0(%ebx),%eax
  1e:	8b 00                	mov    (%eax),%eax
  20:	8d 50 01             	lea    0x1(%eax),%edx
  23:	8b 83 00 00 00 00    	mov    0x0(%ebx),%eax
  29:	89 10                	mov    %edx,(%eax)
  2b:	8b 83 00 00 00 00    	mov    0x0(%ebx),%eax
  31:	8b 08                	mov    (%eax),%ecx
  33:	8b 93 00 00 00 00    	mov    0x0(%ebx),%edx
  39:	0f b6 45 f8          	movzbl -0x8(%ebp),%eax
  3d:	88 04 0a             	mov    %al,(%edx,%ecx,1)
}
  40:	83 c4 04             	add    $0x4,%esp
  43:	5b                   	pop    %ebx
  44:	5d                   	pop    %ebp
  45:	c3                   	ret    

Disassembly of section .text.__i686.get_pc_thunk.bx:

00000000 <__i686.get_pc_thunk.bx>:
   0:	8b 1c 24             	mov    (%esp),%ebx
   3:	c3                   	ret

指令中用到的stacktop的地址不再以0x0表示,而是以0x0(%ebx)表示,但其中還是留有0x0準備做進一步修改。再看readelf輸出的.rel.text段:

Relocation section '.rel.text' at offset 0x94c contains 6 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
00000008  00001202 R_386_PC32        00000000   __i686.get_pc_thunk.bx
0000000e  0000130a R_386_GOTPC       00000000   _GLOBAL_OFFSET_TABLE_
0000001a  00001403 R_386_GOT32       00000000   top
00000025  00001403 R_386_GOT32       00000000   top
0000002d  00001403 R_386_GOT32       00000000   top
00000035  00001503 R_386_GOT32       00000000   stack

topstack對應的記錄類型不再是R_386_32了,而是R_386_GOT32,有什麼區別呢?我們先編譯生成共享庫再做反彙編分析:

$ gcc -shared -o libstack.so stack.o push.o pop.o is_empty.o
$ objdump -dS libstack.so
...
0000047c <push>:
/* push.c */
extern char stack[512];
extern int top;

void push(char c)
{
 47c:	55                   	push   %ebp
 47d:	89 e5                	mov    %esp,%ebp
 47f:	53                   	push   %ebx
 480:	83 ec 04             	sub    $0x4,%esp
 483:	e8 ef ff ff ff       	call   477 <__i686.get_pc_thunk.bx>
 488:	81 c3 6c 1b 00 00    	add    $0x1b6c,%ebx
 48e:	8b 45 08             	mov    0x8(%ebp),%eax
 491:	88 45 f8             	mov    %al,-0x8(%ebp)
	stack[++top] = c;
 494:	8b 83 f4 ff ff ff    	mov    -0xc(%ebx),%eax
 49a:	8b 00                	mov    (%eax),%eax
 49c:	8d 50 01             	lea    0x1(%eax),%edx
 49f:	8b 83 f4 ff ff ff    	mov    -0xc(%ebx),%eax
 4a5:	89 10                	mov    %edx,(%eax)
 4a7:	8b 83 f4 ff ff ff    	mov    -0xc(%ebx),%eax
 4ad:	8b 08                	mov    (%eax),%ecx
 4af:	8b 93 f8 ff ff ff    	mov    -0x8(%ebx),%edx
 4b5:	0f b6 45 f8          	movzbl -0x8(%ebp),%eax
 4b9:	88 04 0a             	mov    %al,(%edx,%ecx,1)
}
 4bc:	83 c4 04             	add    $0x4,%esp
 4bf:	5b                   	pop    %ebx
 4c0:	5d                   	pop    %ebp
 4c1:	c3                   	ret    
 4c2:	90                   	nop    
 4c3:	90                   	nop    
...

和先前的結果不同,指令中的0x0(%ebx)被修改成-0xc(%ebx)-0x8(%ebx),而不是修改成絶對地址。所以共享庫各段的加載地址並沒有定死,可以加載到任意位置,因為指令中沒有使用絶對地址,因此稱為位置無關代碼。另外,注意這幾條指令:

 494:	8b 83 f4 ff ff ff    	mov    -0xc(%ebx),%eax
 49a:	8b 00                	mov    (%eax),%eax
 49c:	8d 50 01             	lea    0x1(%eax),%edx

和先前的指令對比一下:

 80483cc:       a1 10 a0 04 08          mov    0x804a010,%eax
 80483d1:       83 c0 01                add    $0x1,%eax

可以發現,-0xc(%ebx)這個地址並不是變數top的地址,這個地址的內存單元中又保存了另外一個地址,這另外一個地址才是變數top的地址,所以mov -0xc(%ebx),%eax是把變數top的地址傳給eax,而mov (%eax),%eax才是從top的地址中取出top的值傳給eaxlea 0x1(%eax),%edx是把top的值加1存到edx中,如下圖所示:

圖 20.3. 間接定址

間接定址

topstack的絶對地址保存在一個地址表中,而指令通過地址表做間接定址,因此避免了將絶對地址寫死在指令中,這也是一種避免硬編碼的策略。

現在把main.c和共享庫編譯連結在一起,然後運行:

$ gcc main.c -g -L. -lstack -Istack -o main
$ ./main 
./main: error while loading shared libraries: libstack.so: cannot open shared object file: No such file or directory

結果出乎意料,編譯的時候沒問題,由於指定了-L.選項,編譯器可以在當前目錄下找到libstack.so,而運行時卻說找不到libstack.so。那麼運行時在哪些路徑下找共享庫呢?我們先用ldd命令查看執行檔依賴于哪些共享庫:

$ ldd main
	linux-gate.so.1 =>  (0xb7f5c000)
	libstack.so => not found
	libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7dcf000)
	/lib/ld-linux.so.2 (0xb7f42000)

ldd模擬運行一遍main,在運行過程中做動態連結,從而得知這個執行檔依賴于哪些共享庫,每個共享庫都在什麼路徑下,加載到進程地址空間的什麼地址。/lib/ld-linux.so.2是動態連結器,它的路徑是在編譯連結時指定的,我們在第 2 節 “main函數和啟動常式”講過gcc在做連結時用-dynamic-linker指定動態連結器的路徑,它也像其它共享庫一樣加載到進程的地址空間中。libc.so.6的路徑/lib/tls/i686/cmov/libc.so.6是由動態連結器ld-linux.so.2在做動態連結時搜索到的,而libstack.so的路徑沒有找到。linux-gate.so.1這個共享庫其實並不存在於檔案系統中,它是由內核虛擬出來的共享庫,所以它沒有對應的路徑,它負責處理系統調用。總之,共享庫的搜索路徑由動態連結器決定,從ld.so(8)的Man Page可以查到共享庫路徑的搜索順序:

  1. 首先在環境變數LD_LIBRARY_PATH所記錄的路徑中查找。

  2. 然後從緩存檔案/etc/ld.so.cache中查找。這個緩存檔案由ldconfig命令讀取配置檔案/etc/ld.so.conf之後生成,稍後詳細解釋。

  3. 如果上述步驟都找不到,則到預設的系統路徑中查找,先是/usr/lib然後是/lib。

先試試第一種方法,在運行main時通過環境變數LD_LIBRARY_PATH把當前目錄添加到共享庫的搜索路徑:

$ LD_LIBRARY_PATH=. ./main

這種方法只適合在開發中臨時用一下,通常LD_LIBRARY_PATH是不推薦使用的,儘量不要設置這個環境變數,理由可以參考Why LD_LIBRARY_PATH is bad(http://www.visi.com/~barr/ldpath.html)。

再試試第二種方法,這是最常用的方法。把libstack.so所在目錄的絶對路徑(比如/home/akaedu/somedir)添加到/etc/ld.so.conf中(該檔案中每個路徑占一行),然後運行ldconfig

$ sudo ldconfig -v
...
/home/akaedu/somedir:
        libstack.so -> libstack.so
/lib:
        libe2p.so.2 -> libe2p.so.2.3
        libncursesw.so.5 -> libncursesw.so.5.6
...
/usr/lib:
        libkdeinit_klauncher.so -> libkdeinit_klauncher.so
        libv4l2.so.0 -> libv4l2.so.0
...
/usr/lib64:
/lib/tls: (hwcap: 0x8000000000000000)
/usr/lib/sse2: (hwcap: 0x0000000004000000)
...
/usr/lib/tls: (hwcap: 0x8000000000000000)
...
/usr/lib/i686: (hwcap: 0x0008000000000000)
/usr/lib/i586: (hwcap: 0x0004000000000000)
...
/usr/lib/i486: (hwcap: 0x0002000000000000)
...
/lib/tls/i686: (hwcap: 0x8008000000000000)
/usr/lib/i686/cmov: (hwcap: 0x0008000000008000)
...
/lib/tls/i686/cmov: (hwcap: 0x8008000000008000)

ldconfig命令除了處理/etc/ld.so.conf中配置的目錄之外,還處理一些預設目錄,如/lib/usr/lib等,處理之後生成/etc/ld.so.cache緩存檔案,動態連結器就從這個緩存中搜索共享庫。hwcap是x86平台的Linux特有的一種機制,系統檢測到當前平台是i686而不是i586i486,所以在運行程序時使用i686的庫,這樣可以更好地發揮平台的性能,也可以利用一些新的指令,所以上面ldd命令的輸出結果顯示動態連結器搜索到的libc/lib/tls/i686/cmov/libc.so.6,而不是/lib/libc.so.6。現在再用ldd命令查看,libstack.so就能找到了:

$ ldd main
	linux-gate.so.1 =>  (0xb809c000)
	libstack.so => /home/akaedu/somedir/libstack.so (0xb806a000)
	libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7f0c000)
	/lib/ld-linux.so.2 (0xb8082000)

第三種方法就是把libstack.so拷到/usr/lib/lib目錄,這樣可以確保動態連結器能找到這個共享庫。

其實還有第四種方法,在編譯執行檔main的時候就把libstack.so的路徑寫死在執行檔中:

$ gcc main.c -g -L. -lstack -Istack -o main -Wl,-rpath,/home/akaedu/somedir

-Wl,-rpath,/home/akaedu/somedir表示-rpath /home/akaedu/somedir是由gcc傳遞給連結器的選項。可以看到readelf的結果多了一條rpath記錄:

$ readelf -a main
...
Dynamic section at offset 0xf10 contains 23 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libstack.so]
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000f (RPATH)                      Library rpath: [/home/akaedu/somedir]
...

還可以看出,執行檔運行時需要哪些共享庫也都記錄在.dynamic段中。當然rpath這種辦法也是不推薦的,把共享庫的路徑定死了,失去了靈活性。

4.2. 動態連結的過程

本節研究一下在main.c中調用共享庫的函數push是如何實現的。首先反彙編看一下main的指令:

$ objdump -dS main
...
Disassembly of section .plt:

080483a8 <__gmon_start__@plt-0x10>:
 80483a8:	ff 35 f8 9f 04 08    	pushl  0x8049ff8
 80483ae:	ff 25 fc 9f 04 08    	jmp    *0x8049ffc
 80483b4:	00 00                	add    %al,(%eax)
...
080483d8 <push@plt>:
 80483d8:	ff 25 08 a0 04 08    	jmp    *0x804a008
 80483de:	68 10 00 00 00       	push   $0x10
 80483e3:	e9 c0 ff ff ff       	jmp    80483a8 <_init+0x30>

Disassembly of section .text:
...
080484a4 <main>:
/* main.c */
#include <stdio.h>
#include "stack.h"

int main(void)
{
 80484a4:	8d 4c 24 04          	lea    0x4(%esp),%ecx
 80484a8:	83 e4 f0             	and    $0xfffffff0,%esp
 80484ab:	ff 71 fc             	pushl  -0x4(%ecx)
 80484ae:	55                   	push   %ebp
 80484af:	89 e5                	mov    %esp,%ebp
 80484b1:	51                   	push   %ecx
 80484b2:	83 ec 04             	sub    $0x4,%esp
	push('a');
 80484b5:	c7 04 24 61 00 00 00 	movl   $0x61,(%esp)
 80484bc:	e8 17 ff ff ff       	call   80483d8 <push@plt>
...

第 3 節 “靜態庫”連結靜態庫不同,push函數沒有連結到執行檔中。而且call 80483d8 <push@plt>這條指令調用的也不是push函數的地址。共享庫是位置無關代碼,在運行時可以加載到任意地址,其加載地址只有在動態連結時才能確定,所以在main函數中不可能直接通過絶對地址調用push函數,也是通過間接定址來找push函數的。對照着上面的指令,我們用gdb跟蹤一下:

$ gdb main
...
(gdb) start
Breakpoint 1 at 0x80484b5: file main.c, line 7.
Starting program: /home/akaedu/somedir/main 
main () at main.c:7
7		push('a');
(gdb) si
0x080484bc	7		push('a');
(gdb) si
0x080483d8 in push@plt ()
Current language:  auto; currently asm

跳轉到.plt段中,現在將要執行一條jmp *0x804a008指令,我們看看0x804a008這個地址裡存的是什麼:

(gdb) x 0x804a008
0x804a008 <_GLOBAL_OFFSET_TABLE_+20>:	0x080483de

原來就是下一條指令push $0x10的地址。繼續跟蹤下去:

(gdb) si
0x080483de in push@plt ()
(gdb) si
0x080483e3 in push@plt ()
(gdb) si
0x080483a8 in ?? ()
(gdb) si
0x080483ae in ?? ()
(gdb) si
0xb806a080 in ?? () from /lib/ld-linux.so.2

最終進入了動態連結器/lib/ld-linux.so.2,在其中完成動態連結的過程並調用push函數,我們不深入這些細節了,直接用finish命令返回到main函數:

(gdb) finish
Run till exit from #0  0xb806a080 in ?? () from /lib/ld-linux.so.2
main () at main.c:8
8		return 0;
Current language:  auto; currently c

這時再看看0x804a008這個地址裡存的是什麼:

(gdb) x 0x804a008
0x804a008 <_GLOBAL_OFFSET_TABLE_+20>:	0xb803f47c
(gdb) x 0xb803f47c
0xb803f47c <push>:	0x53e58955

動態連結器已經把push函數的地址存在這裡了,所以下次再調用push函數就可以直接從jmp *0x804a008指令跳到它的地址,而不必再進入/lib/ld-linux.so.2做動態連結了。

4.3. 共享庫的命名慣例

你可能已經注意到了,系統的共享庫通常帶有符號連結,例如:

$ ls -l  /lib
...
-rwxr-xr-x  1 root root 1315024 2009-01-09 22:10 libc-2.8.90.so
lrwxrwxrwx  1 root root      14 2008-07-04 05:58 libcap.so.1 -> libcap.so.1.10
-rw-r--r--  1 root root   10316 2007-08-01 03:20 libcap.so.1.10
lrwxrwxrwx  1 root root      14 2008-11-01 08:55 libcap.so.2 -> libcap.so.2.10
-rw-r--r--  1 root root   13792 2008-06-12 21:39 libcap.so.2.10
...
lrwxrwxrwx  1 root root      14 2009-01-13 09:28 libc.so.6 -> libc-2.8.90.so
...
$ ls -l /usr/lib/libc.so
-rw-r--r-- 1 root root 238 2009-01-09 21:59 /usr/lib/libc.so

按照共享庫的命名慣例,每個共享庫有三個檔案名:real name、soname和linker name。真正的庫檔案(而不是符號連結)的名字是real name,包含完整的共享庫版本號。例如上面的libcap.so.1.10libc-2.8.90.so等。

soname是一個符號連結的名字,只包含共享庫的主版本號,主版本號一致即可保證庫函數的介面一致,因此應用程序的.dynamic段只記錄共享庫的soname,只要soname一致,這個共享庫就可以用。例如上面的libcap.so.1libcap.so.2是兩個主版本號不同的libcap,有些應用程序依賴于libcap.so.1,有些應用程序依賴于libcap.so.2,但對於依賴libcap.so.1的應用程序來說,真正的庫檔案不管是libcap.so.1.10還是libcap.so.1.11都可以用,所以使用共享庫可以很方便地升級庫檔案而不需要重新編譯應用程序,這是靜態庫所沒有的優點。注意libc的版本編號有一點特殊,libc-2.8.90.so的主版本號是6而不是2或2.8。

linker name僅在編譯連結時使用,gcc-L選項應該指定linker name所在的目錄。有的linker name是庫檔案的一個符號連結,有的linker name是一段連結腳本。例如上面的libc.so就是一個linker name,它是一段連結腳本:

$ cat /usr/lib/libc.so
/* GNU ld script
   Use the shared library, but some functions are only in
   the static library, so try that secondarily.  */
OUTPUT_FORMAT(elf32-i386)
GROUP ( /lib/libc.so.6 /usr/lib/libc_nonshared.a  AS_NEEDED ( /lib/ld-linux.so.2 ) )

下面重新編譯我們的libstack,指定它的soname:

$ gcc -shared -Wl,-soname,libstack.so.1 -o libstack.so.1.0 stack.o push.o pop.o is_empty.o

這樣編譯生成的庫檔案是libstack.so.1.0,是real name,但這個庫檔案中記錄了它的soname是libstack.so.1

$ readelf -a libstack.so.1.0
...
Dynamic section at offset 0xf10 contains 22 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000e (SONAME)                     Library soname: [libstack.so.1]
...

如果把libstack.so.1.0所在的目錄加入/etc/ld.so.conf中,然後運行ldconfig命令,ldconfig會自動創建一個soname的符號連結:

$ sudo ldconfig
$ ls -l libstack*
lrwxrwxrwx 1 root    root       15 2009-01-21 17:52 libstack.so.1 -> libstack.so.1.0
-rwxr-xr-x 1 akaedu  akaedu  10142 2009-01-21 17:49 libstack.so.1.0

但這樣編譯連結main.c卻會報錯:

$ gcc main.c -L. -lstack -Istack -o main
/usr/bin/ld: cannot find -lstack
collect2: ld returned 1 exit status

注意,要做這個實驗,你得把先前編譯的libstack共享庫、靜態庫都刪掉,如果先前拷到/lib或者/usr/lib下了也刪掉,只留下libstack.so.1.0libstack.so.1,這樣你會發現編譯器不認這兩個名字,因為編譯器只認linker name。可以先創建一個linker name的符號連結,然後再編譯就沒問題了:

$ ln -s libstack.so.1.0 libstack.so
$ gcc main.c -L. -lstack -Istack -o main