嵌入式 Linux下的動態庫原理和使用
1.編寫庫的標頭檔案和原始檔.
2.把所有涉及到的原始檔用如下方式編譯為目標檔案:
# g++/gcc -g -c -fPIC -o library1.o library1.cpp
# g++/gcc -g -c -fPIC -o library2.o library2.cpp
# ......
#
3.把所有的目標檔案連結為動態庫:
# g++/gcc -g -shared -Wl,-soname,libyourlibname.so-o libyourlibname.so.1.0.0library1.o library2.o...-lc
4.建立一個庫名連結
# ln -s libyourlibname.so.1.0.0 libyourlibname.so
5.如何使用動態連結庫
# 假如你的應用程式原始碼叫test.cpp
# 採用如下方式編譯:
# g++ -g -o test -L./ -lyourlibname test.cpp
6. 檢視一個程式連結了哪些庫用ldd
# ldd test
7.檢視一個程式有哪些符號用nm
#nm test
8. 去除一個程式的符號表用strip
#strip test
9. 從程式中找出一些資訊用 string
#strings test
關於Linux的動態共享庫的設定
可執行程式找不到要連結的動態共享庫,這是Linux上面編譯和執行程式很容易碰到的問題,通過上面的小例子,我們已經大致瞭解共享庫的一點基本原理,接下來我們要探討一下怎麼設定程式尋找動態共享庫的行為。
Linux作業系統上面的動態共享庫大致分為三類:
1.作業系統級別的共享庫和基礎的系統工具庫
比方說libc.so, libz.so,libpthread.so等等,這些系統庫會被放在/lib和/usr/lib目錄下面,如果是64位作業系統,還會有/lib64和/usr/lib64目錄。如果作業系統帶有圖形介面,那麼還會有/usr/X11R6/lib目錄,如果是64位作業系統,還有/usr/X11R6/lib64目錄。此外還可能有其他特定Linux版本的系統庫目錄。
這些系統庫檔案的完整和版本的正確,確保了Linux上面各種程式能夠正常的執行。
2、應用程式級別的系統共享庫
並非作業系統自帶,但是可能被很多應用程式所共享的庫,一般會被放在/usr/local/lib和/usr/local/lib64這兩個目錄下面。很多你自行編譯安裝的程式都會在編譯的時候自動把/usr/local/lib加入gcc的-L引數,而在執行的時候自動到/usr/local/lib下面去尋找共享庫。
以上兩類的動態共享庫,應用程式會自動尋找到他們,並不需要你額外的設定和擔心。這是為什麼呢?因為以上這些目錄預設就被加入到動態連結程式的搜尋路徑裡面了。Linux的系統共享庫搜尋路徑定義在/etc/ld.so.conf這個配置檔案裡面。這個檔案的內容格式大致如下:
- /usr/X11R6/lib64
- /usr/X11R6/lib
- /usr/local/lib
- /lib64
- /lib
- /usr/lib64
- /usr/lib
- /usr/local/lib64
- /usr/local/ImageMagick/lib
假設我們自己編譯安裝的ImageMagick圖形庫在/usr/local/ImageMagick目錄下面,並且希望其他應用程式都可以使用ImageMagick的動態共享庫,那麼我們只需要把/usr/local/ImageMagick/lib目錄加入/etc/ld.so.conf檔案裡面,然後執行:ldconfig命令即可。
ldcofig將搜尋以上所有的目錄,為共享庫建立一個快取檔案/etc/ld.so.cache。為了確認ldconfig已經搜尋到ImageMagick的庫,我們可以用上面介紹的strings命令從ld.so.cache裡面抽取文字資訊來檢查一下:
strings /etc/ld.so.cache | grep ImageMagick
輸出結果為:
- /usr/local/ImageMagick/lib/libWand.so.10
- /usr/local/ImageMagick/lib/libWand.so
- /usr/local/ImageMagick/lib/libMagick.so.10
- /usr/local/ImageMagick/lib/libMagick.so
- /usr/local/ImageMagick/lib/libMagick++.so.10
- /usr/local/ImageMagick/lib/libMagick++.so
已經成功了!
3、應用程式獨享的動態共享庫
有很多共享庫只被特定的應用程式使用,那麼就沒有必要加入系統庫路徑,以免應用程式的共享庫之間發生版本衝突。因此Linux還可以通過設定環境變數LD_LIBRARY_PATH來臨時指定應用程式的共享庫搜尋路徑,就像我們上面舉的那個例子一樣,我們可以在應用程式的啟動腳本里面預先設定LD_LIBRARY_PATH,指定本應用程式附加的共享庫搜尋路徑,從而讓應用程式找到它。
一個程式要想在記憶體中執行,除了編譯之外還要經過連結和裝入這兩個步驟。從程式設計師的角度來看,引入這兩個步驟帶來的好處就是可以直接在程式中使用printf和errno這種有意義的函式名和變數名,而不用明確指明printf和errno在標準C庫中的地址。當然,為了將程式設計師從早期直接使用地址程式設計的夢魘中解救出來,編譯器和彙編器在這當中做出了革命性的貢獻。編譯器和彙編器的出現使得程式設計師可以在程式中使用更具意義的符號來為函式和變數命名,這樣使得程式在正確性和可讀性等方面都得到了極大的提高。但是隨著C語言這種支援分別編譯的程式設計語言的流行,一個完整的程式往往被分割為若干個獨立的部分並行開發,而各個模組間通過函式介面或全域性變數進行通訊。這就帶來了一個問題,編譯器只能在一個模組內部完成符號名到地址的轉換工作,不同模組間的符號解析由誰來做呢?比如前面所舉的例子,呼叫printf的使用者程式和實現了printf的標準C庫顯然就是兩個不同的模組。實際上,這個工作是由連結器來完成的。
為了解決不同模組間的連結問題,連結器主要有兩個工作要做――符號解析和重定位:
符號解析:當一個模組使用了在該模組中沒有定義過的函式或全域性變數時,編譯器生成的符號表會標記出所有這樣的函式或全域性變數,而連結器的責任就是要到別的模組中去查詢它們的定義,如果沒有找到合適的定義或者找到的合適的定義不唯一,符號解析都無法正常完成。
重定位:編譯器在編譯生成目標檔案時,通常都使用從零開始的相對地址。然而,在連結過程中,連結器將從一個指定的地址開始,根據輸入的目標檔案的順序以段為單位將它們一個接一個的拼裝起來。除了目標檔案的拼裝之外,在重定位的過程中還完成了兩個任務:一是生成最終的符號表;二是對程式碼段中的某些位置進行修改,所有需要修改的位置都由編譯器生成的重定位表指出。
舉個簡單的例子,上面的概念對讀者來說就一目瞭然了。假如我們有一個程式由兩部分構成,m.c中的main函式呼叫f.c中實現的函式sum:
int i = 1;
int j = 2;
extern int sum();
void main()
{
int s;
s = sum(i, j);
int sum(int i, int j)
{
return i + j;
}
|
在Linux用gcc分別將兩段源程式編譯成目標檔案:
$ gcc -c m.c
$ gcc -c f.c
|
我們通過objdump來看看在編譯過程中生成的符號表和重定位表:
$ objdump -x m.o
……
SYMBOL TABLE:
……
00000000 g O .data 00000004 i
00000004 g O .data 00000004 j
00000000 g F .text 00000021 main
00000000 *UND* 00000000 sum
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
00000007 R_386_32 j
0000000d R_386_32 i
00000013 R_386_PC32 sum
|
首先,我們注意到符號表裡面的sum被標記為UND(undefined),也就是在m.o中沒有定義,所以將來要通過ld(Linux下的連結器)的符號解析功能到別的模組中去查詢是否存在函式sum的定義。另外,在重定位表中有三條記錄,指出了在重定位過程中程式碼段中三處需要修改的位置,分別位於7、d和13。下面以一種更加直觀的方式來看一下這三個位置:
$ objdump -dx m.o
Disassembly of section .text:
00000000 :
0: 55 push �p
1: 89 e5 mov %esp,�p
3: 83 ec 04 sub $0x4,%esp
6: a1 00 00 00 00 mov 0x0,�x
7: R_386_32 j
b: 50 push �x
c: a1 00 00 00 00 mov 0x0,�x
d: R_386_32 i
11: 50 push �x
12: e8 fc ff ff ff call 13
13: R_386_PC32 sum
17: 83 c4 08 add $0x8,%esp
1a: 89 c0 mov �x,�x
1c: 89 45 fc mov �x,0xfffffffc(�p)
1f: c9 leave
20: c3 ret
|
以sum為例,對函式sum的呼叫是通過call指令實現的,使用IP相對定址方式。可以看到,在目標檔案m.o中,call指令位於從零開始的相對地址12的位置,這裡存放的e8是call的操作碼,而從13開始的4個位元組存放著sum相對call的下一條指令add的偏移。顯然,在連結之前這個偏移量是不知道的,所以將來要來修改13這裡的程式碼。那現在這裡為什麼存放著0xfffffffc(注意Intel的CPU使用littleendian的編址方式)呢?這大概是出於安全的考慮,因為0xfffffffc正是-4的補碼錶示(讀者可以在gdb中使用p /x-4檢視),而call指令本身佔用了5個位元組,因此無論如何call指令中的偏移量不可能是-4。我們再看看重定位之後call指令中的這個偏移量被修改成了什麼:
$ gcc m.o f.o
$ objdump -dj .text a.out | less
Disassembly of section .text:
……
080482c4 :
……
80482d6: e8 0d 00 00 00 call 80482e8
80482db: 83 c4 08 add $0x8,%esp
……
080482e8 :
……
|
可以看到經過重定位之後,call指令中的偏移量修改成0x0000000d了,簡單的計算告訴我們:0x080482e8-0x80482db=0xd。這樣,經過重定位之後最終的可執行程式就生成了。
可執行程式生成後,下一步就是將其裝入記憶體執行。Linux下的編譯器(C語言)是cc1,彙編器是as,連結器是ld,但是並沒有一個實際的程式對應裝入器這個概念。實際上,將可執行程式裝入記憶體執行的功能是由execve(2)這一系統呼叫實現的。簡單來講,程式的裝入主要包含以下幾個步驟:
- 讀入可執行檔案的頭部資訊以確定其檔案格式及地址空間的大小;
- 以段的形式劃分地址空間;
- 將可執行程式讀入地址空間中的各個段,建立虛實地址間的對映關係;
- 將bbs段清零;
- 建立堆疊段;
- 建立程式引數、環境變數等程式執行過程中所需的資訊;
- 啟動執行。
一個程式要想裝入記憶體執行必然要先經過編譯、連結和裝入這三個階段,雖然是這樣一個大家聽起來耳熟能詳的概念,在作業系統發展的過程中卻已經經歷了多次重大變革。簡單來講,可以將其劃分為以下三個階段:
1. 靜態連結、靜態裝入
這種方法最早被採用,其特點是簡單,不需要作業系統提供任何額外的支援。像C這樣的程式語言從很早開始就已經支援分別編譯了,程式的不同模組可以並行開發,然後獨立編譯為相應的目標檔案。在得到了所有的目標檔案後,靜態連結、靜態裝入的做法是將所有目標檔案連結成一個可執行映象,隨後在建立程序時將該可執行映象一次全部裝入記憶體。舉個簡單的例子,假設我們開發了兩個程式Prog1和Prog2,Prog1由main1.c、utilities.c以及errhdl1.c三部分組成,分別對應程式的主框架、一些公用的輔助函式(其作用相當於庫)以及錯誤處理部分,這三部分程式碼編譯後分別得到各自對應的目標檔案main1.o、utilities.o以及errhdl1.o。同樣,Prog2由main2.c、utilities.c以及errhdl2.c三部分組成,三部分程式碼編譯後分別得到各自對應的目標檔案main2.o、utilities.o以及errhdl2.o。值得注意的是,這裡Prog1和Prog2使用了相同的公用輔助函式utilities.o。當我們採用靜態連結、靜態裝入的方法,同時執行這兩個程式時記憶體和硬碟的使用情況如圖1所示:
可以看到,首先就硬碟的使用來講,雖然兩個程式共享使用了utilities,但這並沒有在硬碟儲存的可執行程式映象上體現出來。相反,utilities.o被連結進了每一個用到它的程式的可執行映象。記憶體的使用也是如此,作業系統在建立程序時將程式的可執行映象一次全部裝入記憶體,之後程序才能開始執行。如前所述,採用這種方法使得作業系統的實現變得非常簡單,但其缺點也是顯而易見的。首先,既然兩個程式使用的是相同的utilities.o,那麼我們只要在硬碟上儲存utilities.o的一份拷貝應該就足夠了;另外,假如程式在執行過程中沒有出現任何錯誤,那麼錯誤處理部分的程式碼就不應該被裝入記憶體。因此靜態連結、靜態裝入的方法不但浪費了硬碟空間,同時也浪費了記憶體空間。由於早期系統的記憶體資源十分寶貴,所以後者對早期的系統來講更加致命。
2. 靜態連結、動態裝入
既然採用靜態連結、靜態裝入的方法弊大於利,我們來看看人們是如何解決這一問題的。由於記憶體緊張的問題在早期的系統中顯得更加突出,因此人們首先想到的是要解決記憶體使用效率不高這一問題,於是便提出了動態裝入的思想。其想法是非常簡單的,即一個函式只有當它被呼叫時,其所在的模組才會被裝入記憶體。所有的模組都以一種可重定位的裝入格式存放在磁碟上。首先,主程式被裝入記憶體並開始執行。當一個模組需要呼叫另一個模組中的函式時,首先要檢查含有被呼叫函式的模組是否已裝入記憶體。如果該模組尚未被裝入記憶體,那麼將由負責重定位的連結裝入器將該模組裝入記憶體,同時更新此程式的地址表以反應這一變化。之後,控制便轉移到了新裝入的模組中被呼叫的函式那裡。
動態裝入的優點在於永遠不會裝入一個使用不到的模組。如果程式中存在著大量像出錯處理函式這種用於處理小概率事件的程式碼,使用這種方法無疑是卓有成效的。在這種情況下,即使整個程式可能很大,但是實際用到(因此被裝入到記憶體中)的部分實際上可能非常小。
仍然以上面提到的兩個程式Prog1和Prog2為例,假如Prog1執行過程中出現了錯誤而Prog2在執行過程中沒有出現任何錯誤。當我們採用靜態連結、動態裝入的方法,同時執行這兩個程式時記憶體和硬碟的使用情況如圖2所示:
圖 2採用靜態連結、動態裝入方法,同時執行Prog1和Prog2時記憶體和硬碟的使用情況
可以看到,當程式中存在著大量像錯誤處理這樣使用概率很小的模組時,採用靜態連結、動態裝入的方法在記憶體的使用效率上就體現出了相當大的優勢。到此為止,人們已經向理想的目標邁進了一部,但是問題還沒有完全解決――記憶體的使用效率提高了,硬碟呢?
3. 動態連結、動態裝入
採用靜態連結、動態裝入的方法後看似只剩下硬碟空間使用效率不高的問題了,實際上記憶體使用效率不高的問題仍然沒有完全解決。圖2中,既然兩個程式用到的是相同的utilities.o,那麼理想的情況是系統中只儲存一份utilities.o的拷貝,無論是在記憶體中還是在硬碟上,於是人們想到了動態連結。
在使用動態連結時,需要在程式映象中每個呼叫庫函式的地方打一個樁(stub)。stub是一小段程式碼,用於定位已裝入記憶體的相應的庫;如果所需的庫還不在記憶體中,stub將指出如何將該函式所在的庫裝入記憶體。
當執行到這樣一個stub時,首先檢查所需的函式是否已位於記憶體中。如果所需函式尚不在記憶體中,則首先需要將其裝入。不論怎樣,stub最終將被呼叫函式的地址替換掉。這樣,在下次運行同一個程式碼段時,同樣的庫函式就能直接得以執行,從而省掉了動態連結的額外開銷。由此,用到同一個庫的所有程序在執行時使用的都是這個庫的同一份拷貝。
下面我們就來看看上面提到的兩個程式Prog1和Prog2在採用動態連結、動態裝入的方法,同時執行這兩個程式時記憶體和硬碟的使用情況(見圖3)。仍然假設Prog1執行過程中出現了錯誤而Prog2在執行過程中沒有出現任何錯誤。
圖 3採用動態連結、動態裝入方法,同時執行Prog1和Prog2時記憶體和硬碟的使用情況
圖中,無論是硬碟還是記憶體中都只存在一份utilities.o的拷貝。記憶體中,兩個程序通過將地址對映到相同的utilities.o實現對其的共享。動態連結的這一特性對於庫的升級(比如錯誤的修正)是至關重要的。當一個庫升級到一個新版本時,所有用到這個庫的程式將自動使用新的版本。如果不使用動態連結技術,那麼所有這些程式都需要被重新連結才能得以訪問新版的庫。為了避免程式意外使用到一些不相容的新版的庫,通常在程式和庫中都包含各自的版本資訊。記憶體中可能會同時存在著一個庫的幾個版本,但是每個程式可以通過版本資訊來決定它到底應該使用哪一個。如果對庫只做了微小的改動,庫的版本號將保持不變;如果改動較大,則相應遞增版本號。因此,如果新版庫中含有與早期不相容的改動,只有那些使用新版庫進行編譯的程式才會受到影響,而在新版庫安裝之前進行過連結的程式將繼續使用以前的庫。這樣的系統被稱作共享庫系統。
如今我們在Linux下程式設計用到的庫(像libc、QT等等)大多都同時提供了動態連結庫和靜態連結庫兩個版本的庫,而gcc在編譯連結時如果不加-static選項則預設使用系統中的動態連結庫。對於動態連結庫的原理大多數的書本上只是進行了泛泛的介紹,在此筆者將通過在實際系統中反彙編出的程式碼向讀者展示這一技術在Linux下的實現。
下面是個最簡單的C程式hello.c:
#include
int main()
{
printf("Hello, world\n");
return 0;
}
|
在Linux下我們可以使用gcc將其編譯成可執行檔案a.out:
$ gcc hello.c
|
程式裡用到了printf,它位於標準C庫中,如果在用gcc編譯時不加-static的話,預設是使用libc.so,也就是動態連結的標準C庫。在gdb中可以看到編譯後printf對應如下程式碼:
$ gdb -q a.out
(gdb) disassemble printf
Dump of assembler code for function printf:
0x8048310 : jmp *0x80495a4
0x8048316 : push $0x18
0x804831b : jmp 0x80482d0 <_init+48>
|
這也就是通常在書本上以及前面提到的打樁(stub)過程,顯然這並不是真正的printf函式。這段stub程式碼的作用在於到libc.so中去查詢真正的printf。
(gdb) x /w 0x80495a4
0x80495a4 <_GLOBAL_OFFSET_TABLE_+24>: 0x08048316
|
可以看到0x80495a4處存放的0x08048316正是pushl$0x18這條指令的地址,所以第一條jmp指令沒有起到任何作用,其作用就像空操作指令nop一樣。當然這是在我們第一次呼叫printf時,其真正的作用是在今後再次呼叫printf時體現出來的。第二條jmp指令的目的地址是plt,也就是procedurelinkage table,其內容可以通過objdump命令檢視,我們感興趣的就是下面這兩條對程式的控制流有影響的指令:
$ objdump -dx a.out
……
080482d0 >.plt>:
80482d0: ff 35 90 95 04 08 pushl 0x8049590
80482d6: ff 25 94 95 04 08 jmp *0x8049594
……
|
第一條push指令將got(global offsettable)中與printf相關的表項地址壓入堆疊,之後jmp到記憶體單元0x8049594中所存放的地址0x4000a960處。這裡需要注意的一點是,在檢視got之前必須先將程式a.out啟動執行,否則通過gdb中的x命令在0x8049594處看到的結果是不正確的。
(gdb) b main
Breakpoint 1 at 0x8048406
(gdb) r
Starting program: a.out
Breakpoint 1, 0x08048406 in main ()
(gdb) x /w 0x8049594
0x8049594 <_GLOBAL_OFFSET_TABLE_+8>: 0x4000a960
(gdb) disassemble 0x4000a960
Dump of assembler code for function _dl_runtime_resolve:
0x4000a960 <_dl_runtime_resolve>: pushl �x
0x4000a961 <_dl_runtime_resolve+1>: pushl �x
0x4000a962 <_dl_runtime_resolve+2>: pushl �x
0x4000a963 <_dl_runtime_resolve+3>: movl 0x10(%esp,1),�x
0x4000a967 <_dl_runtime_resolve+7>: movl 0xc(%esp,1),�x
0x4000a96b <_dl_runtime_resolve+11>: call 0x4000a740
0x4000a970 <_dl_runtime_resolve+16>: popl �x
0x4000a971 <_dl_runtime_resolve+17>: popl �x
0x4000a972 <_dl_runtime_resolve+18>: xchgl �x,(%esp,1)
0x4000a975 <_dl_runtime_resolve+21>: ret $0x8
0x4000a978 <_dl_runtime_resolve+24>: nop
0x4000a979 <_dl_runtime_resolve+25>: leal 0x0(%esi,1),%esi
End of assembler dump.
|
前面三條push指令執行之後堆疊裡面的內容如下:
下面將0x18存入edx,0x8049590存入eax,有了這兩個引數,fixup就可以找到printf在libc.so中的地址。當fixup返回時,該地址已經儲存在了eax中。xchg指令執行完之後堆疊中的內容如下:
最妙的要數接下來的ret指令的用法,這裡ret實際上被當成了call來使用。ret$0x8之後控制便轉移到了真正的printf函式那裡,並且清掉了堆疊上的0x18和0x8049584這兩個已經沒用的引數,這時堆疊便成了下面的樣子:
而這正是我們所期望的結果。應該說這裡ret的用法與Linux核心啟動後通過iret指令實現由核心態切換到使用者態的做法有著異曲同工之妙。很多人都聽說過中斷指令int可以實現使用者態到核心態這種優先順序由低到高的切換,在接受完系統服務後iret指令負責將優先順序重新降至使用者態的優先順序。然而系統啟動時首先是處於核心態高優先順序的,Inteli386並沒有單獨提供一條特殊的指令用於在系統啟動完成後降低優先順序以執行使用者程式。其實這個問題很簡單,只要反用iret就可以了,就像這裡將ret當作call使用一樣。另外,fixup函式執行完還有一個副作用,就是在got中與printf相關的表項(也就是地址為0x80495a4的記憶體單元)中填上查詢到的printf函式在動態連結庫中的地址。這樣當我們再次呼叫printf函式時,其地址就可以直接從got中得到,從而省去了通過fixup查詢的過程。也就是說got在這裡起到了cache的作用。
其實有很多東西只要勤于思考,還是能夠自己悟出一些道理的。國外有一些高手就是通過能夠大家都能見到的的一點點資料,自己摸索出來很多不為人知的祕密。像寫《UndocumentDos》和《Undocment Windows》的作者,他就為我們樹立了這樣的榜樣!
學習計算機很關鍵的一點在於一定要富於探索精神,要讓自己做到知其然並知其所以然。侯先生在《STL原始碼剖析》一書開篇題記中寫到"原始碼之前,了無祕密",當然這是在我們手中掌握著原始碼的情況下,如若不然,不要忘記Linux還為我們提供了大量的像gdb、objdump這樣的實用工具。有了這些得力的助手,即使沒有原始碼,我們一樣可以做到"了無祕密"。