ELF加载器的原理与实现
ELF加載器
為什么需要ELF
隔離應用服務和內核服務
有了ELF,我們的應用程序都可以通過編譯成ELF的方式從外部加載,系統內核部分只提供關鍵的內核服務(內存管理/中斷/調度/IPC)和系統服務(文件/網絡),用戶服務程序都可以通過ELF的方式進行加載。同時庫的同步更新可以在不更新內核的情況進行同步更新。
提供統一編程接口
我們通過提供統一的POSIX標準庫,來為用戶提供標準的編程接口,方便了應用程序開發人員進行標準開發
應用程序可以動態加載和卸載
通過ELF,用戶可以動態的加載和卸載相關服務,增強了應用程序加載的靈活性
ELF加載方式
靜態庫-獨立exec
我們把所有相關源代碼進行編譯,鏈接,最后生成可執行文件,這個文件不依賴于其他模塊,是一個完整的可執行單元。
操作系統處理這類文件的流程是直接將elf的所有段拷貝到內存中,然后將PC指針指向entry就可以運行了,什么場景下會這樣使用呢?
- 簡單的應用程序,不依賴于其他模塊
- 依賴于其他模塊,其他模塊以靜態庫的方式鏈接到應用程序
優點:程序是一個完整的可執行單元,不需要操作系統去進行重定向操作
缺點:每一個依賴于某個靜態庫的應用程序都會包含完整的靜態庫,這樣每一個應用程序都會占用磁盤空間和內存空間,如果這樣的應用程序有成千上萬,那資源浪費很大
動態庫-非獨立exec
非獨立exec我們這里只講依賴于動態庫的應用程序,一般情況我們在編寫應用程序的時候,會用到很多庫,這些庫是其他工程師已經寫好了,我們直接用就可以了,最典型的就是C和C++庫,我們在寫應用程序的時候只需要關注我們自己的業務功能就可以了。
外部庫的存在方式以靜態和動態2種,靜態庫在上面已經分析了,接下來我們著重分析動態庫
如果我們的C庫已經編譯成了動態庫,那么我們把他鏈接到我們的應用程序的時候,編譯器只會對所需C庫的符號進行分析,并把他記錄到rel.dyn里,并不會真正的把動態庫中的具體內容進行拷貝,所以這樣編譯出的應用程序就不包括動態庫的內容,等到將應用程序真正加載到操作系統去運行的時候,我們操作系統中會有一個動態鏈接器dl去完成外部符號的鏈接與重定位
優點:
應用程序依賴的動態庫在內存中只存在一份,當然數據段是每個應用程序私有的,這樣會節約磁盤空間和內存空間
當我們要更新動態庫時,只需要更新動態庫文件即可,所依賴的應用程序不需要單獨更新
缺點:
應用程序的啟動會犧牲一部分啟動時間
動態庫更新需要考慮兼容性問題
靜態庫
編譯方式
靜態庫將庫源文件打包成.a,然后main鏈接到靜態庫,形成可執行文件exe,這里的鏈接實際上就是把.a的代碼和數據聯合main打包到exe里。
編譯流程
編譯腳本
$(CC) -MD -c $(CFLAGS) %.c -o %.c.o $(AR) cr libxxx.a %.c.o動態庫
編譯方式
動態庫以.so的方式呈現,然后main鏈接到動態庫,形成可執行文件exe,注意這里的鏈接并非真正的鏈接,并沒有把.so的代碼和數據拷貝到exe里,只是標記了引用的外部符號表,所以exe的大小并沒有變大。
編譯流程
編譯腳本
$(CC) -MD -c $(CFLAGS) %.c -o %.c.o $(CC) -shared -fPIC -nostartfiles -o libxxx.so %.c.oELF格式分析
一個可執行文件我們可以從兩個視角去剖析:
- 鏈接視圖:section - 節
- 執行試圖:segment - 段
讀取elf信息
readelf -a xxx > xxx.dumpsection
一個elf文件,由多個section組成,這個可以linker.lds里指定節的名稱和地址,一般來說一個elf是由多個section組成的,如下圖:
從上圖可以看出,此elf由11個段組成,其中最中要的段有:
- text
- rodata
- data
- bss
從section(節)的角度上,我們可以看出一個elf的基本組成,以及每個節的名稱,類型,運行地址,大小,屬性等。從上面的例子中我們可以得到:
- text:代碼節大小為0x55e0,運行地址為0x41000000,在文件中的偏移為0x10000(64K對齊)。text包含elf的所有指令,有些鏈接腳本把rodata也合并到此節
- data:數據節大小為0x470,運行地址為0x410055e8,在文件中的偏移為0x155e8。data包含已經初始化的數據,這些數據必須像指令一樣保存在文件中
- bss:bss節大小為0x238,運行地址為0x41005a58,在文件中的偏移為0x15a58。因為bss是段是未初始化和初始化為0的節,所以在文件中實在是沒有必要存儲一堆0,只需要保存好bss大小,在elf加載的時候再手動清零即可,這樣可以減少elf文件的空間
解析header
上面的section是我們通過readelf命令讀取得到的,在elf里專門有區域來存儲所有section的分布信息,我們通過header信息就可以輕松得到:
首先來看一下header結構:
重要的數據成員:
- e_entry:程序的入口地址,一般為main函數的地址
- e_phoff:程序頭在文件中的偏移,用于解析segment信息
- e_shoff:節頭在文件中的偏移,用于解析section信息
- e_phnum:程序頭個數
- e_shnum:節頭個數
- e_phentsize:一個程序頭的大小,32位下默認固定為32字節
- e_shentsize:一個節頭的大小,32位下默認固定位40字節
程序頭
節頭
剛剛上面的section信息,我們就可以讀取ELF文件的前128字節來解析ELF頭部,根據section的偏移,再解析就可以得到section信息了,同樣segment信息也是如此
segment
segment就是我們熟悉的段,我們一般在談論一個程序加載的過程,我們希望用segment來描述,一個segment可以包含多個section,還是之前的那個elf文件,我們來分析一下他在段的視角下是如何組成的。
這個elf由2個段組成,我們只關注LOAD段,LOAD段表示此段是需要加載到內存運行的,我們可以看到LOAD段的大小分為了兩部分,FileSiz和MemSiz,我們先放一下,我們先關注FileSiz,也就是LOAD段實際在文件中占用了多大空間,0x5a58,我們從section視圖可以看到0x5a58剛好就是data節的結束地址。由此我們可以得到FileSiz,是text+rodata+data,我們再看MemSiz,MemSiz表示此段在內存中所占用的空間,前面我們說到bss段是不需要占用文件空間的,但是他需要占用內存空間,而且需要手動進行清0操作,所以我們可以看到MemSiz的大小為FileSiz+bss_size。再看此段的屬性,為RWE說明此段可讀可寫可執行,包含了代碼數據加BSS。
如何加載ELF
靜態庫加載
這里說的靜態庫加載指的是程序鏈接到靜態庫生成的exe文件的加載,靜態庫的加載相對動態庫要簡單很多,因為全部工作已經由編譯器的連接器ld給我們完成了,我們要做的只是把ELF文件map到相應運行地址空間,然后把PC指向entry即可。
靜態庫加載流程圖
動態庫加載
這里說的動態庫與加載指的是程序鏈接到動態庫生成的exe文件的加載,動態庫的加載比靜態庫要復雜很多,程序在鏈接階段,只是給我們初步進行了一個假鏈接,我們需要自己在程序加載的時候再去鏈接動態庫,簡單來說,我們需要自己實現一個鏈接器linker,把主模塊和動態庫鏈接起來。
我們對比一下靜態庫和動態庫在進程地址空間中的分布:
靜態庫很簡單,直接把數據映射到text開始的地方就可以了,動態庫的映射缺分成了2部分,動態庫除了節省磁盤空間之外還具有共享屬性,這個屬性可以節約內存空間,尤其是在多個ELF加載同一個共享可以的同時,我們只需要映射一次即可。動態庫的映射除了本身主模塊的映射之外,還需要把共享庫可以映射到進程的共享庫中去,如上圖,主模塊被映射到0x41000000開始,共享庫被映射到0x5D000000,我們要做的就是建立主模塊和共享庫之間的連接(主模塊需要引用動態庫中的函數和數據),也就是重定位工作,這也是我們實現鏈接器的關鍵。
共享庫的關鍵:共享庫的代碼段是被所有ELF共享的,數據段對于每個ELF來說是相互獨立的,linux下通過cow(寫時拷貝)技術實現這一機制,從根本上來說,是通過缺頁異常來實現。
實現自己的鏈接器
鏈接器最重要的工作就是重定位,主要包含主模塊重定位和共享庫重定位兩項工作,后面我們都稱為main_relocate和libary_relocate。這兩者有何區別?
main relocate
main relocate主要完成的工作是把引用的外部函數和變量的地址更新到got.plt表中去,這樣才能完成函數跳轉工作
library relocate
library relocate主要處理的是共享庫內部的函數跳轉和變量引用,共享庫最后被編譯成位置無關,各個源文件之間的函數跳轉(非static函數)和變量引用(非static變量)都需要重定位后才能進行加載運行,重定位的類型和CPU相關,ARM下的重定位類型主要有如下:
鏈接流程
我們先來梳理一下鏈接器的流程:
判斷ELF是否需要鏈接器
我們讀取一個包含共享庫的elf文件:
在程序頭中,我們發現了INTERP,證明這個elf是需要鏈接器去進行重新鏈接共享庫。同時,一般包含了INTERP,同時也會有DYNAMIC段,從INTERP可以看出,ELF需要執行/usr/lib/ld.so.1來進行鏈接,在我們的系統中,我們的鏈接器(解釋器)是被編譯到系統服務中去的,所以就不用執行鏈接器本身的自舉操作了,在Linux里,鏈接器本身還要完成自舉操作,要復雜一些。
段類型:
查看ELF依賴哪些動態庫
經過上面的鏈接器檢查,如果檢查出ELF確實依賴了一些庫,那究竟依賴于哪些庫呢?這個信息從哪里可以解析出來呢?我們的dynamic段該上場了,dynamic段里存儲了相關信息,我們先截取看一下:
首先我們可以看到,在程序頭中,我們可以看到新增加了DYNAMIC段,并標記了DYNAMIC的屬性,大小,偏移等。
我們首先來看看dynamic段的數據結構:
我們再看看有哪些類型的動態節:
類型確實是有點太多了,先大概掃一眼
我們要解析出ELF依賴于哪些庫,需要借助 NEEDED 和 STRTAB,STRSZ 來解析,下面流程圖表示了解析依賴庫的流程:
之前的解析圖中我們可以看出此ELF依賴于libdl.so和libmath.so,這個依賴信息是如何生成的呢?回想一下我們在之前提到的主模塊在鏈接動態庫的時候,我們說是個假鏈接,意思就是說只是給生成的exe文件打包上了相關依賴庫和重定向信息,所以我稱為假鏈接,我們看一下ld信息:
LINK: elf_test arm-none-eabi-ld --warn-common --gc-sections -e main -T./linker.lds -L./libs/libdl -L./libs/libmath -Map elf_test.map -o elf_test --start-group elf_loader_test.c.o -ldl -lmath --end-group其中的 -ldl -lmath,就會生成依賴信息
主模塊調用共享庫里的函數接口
主模塊程序如下:
/******************elf_test.c****************/ #include <dl.h> int main() {int sum = dl_sum(1, 2);return 0; }共享庫程序如下:
/******************dl.c****************/ #include <dl.h> int dl_sum(int a, int b) {return a+b; }把dl.c編譯成動態庫libdl.so:
$(CC) -MD -c $(CFLAGS) dl.c -o dl.c.o $(CC) -shared -fPIC -nostartfiles -o libld.so dl.c.o在main中鏈接libdl.so:
$(LD) $(LDLAGS) -e main -T./linker.lds -L./libs/libdl -o elf_test elf_loader_test.c.o -ldllinker.lds如下:
ENTRY(main);SECTIONS{. = 0x41000000;.text :{*(.text .text.*)*(.rodata .rodata.*)}.data :{*(.data .data.*)}.bss :{*(.bss .bss.*)} }我們反匯編生成的exe文件:
$(OBJDUMP) -x -D -S elf_test > elf_test.dump我們定位到我們調用dl_sum處:
因為dl_sum是動態庫里的,代碼自然也在動態庫里,那么在exe里,他跳轉到了0x4100014c這個地址處:
這個是plt表,最后他會跳轉到一個新的地址上,那這個新地址是多少呢?
這些外部函數究竟該跳轉到哪里?
main里調用的所有外部動態庫函數,都存放在rel.dyn節里,我們來看一下:
我們來分析一下rel.dyn的數據結構:
r_info里保存著外部變量和函數的信息:
#define ELF32_R_SYM(i) ((i)>>8) #define ELF32_R_TYPE(i) ((unsigned char)(i)) #define ELF32_R_INFO(s,t) (((s)<<8)+(unsigned char)(t)) // index為外部符號在動態符號表中的索引 index = ELF32_R_SYM(rel->r_info); // type為外部符號表的類型:函數/變量/...... type = ELF32_R_TYPE(rel->r_info);找到所需外部符號表的信息流程如下:
總結一下:根據rel.dyn節找到所有依賴外部動態庫的符號,根據符號名搜索外部庫找到符號真正的地址,然后進行重定向,重定向實際上就是,修改main中的.got.plt節把他的地址指向真正的地址就可以了。這樣函數在跳轉流程如下:
main->plt->rel.dyn->got.plt
從外部動態庫搜索符號
main 中 用到了dl_sum符號,那么如何從外部動態庫中得到該符號的真正地址呢?
搜索方式有2種:字符串匹配 和 hash匹配,在符號量大的情況下建議使用hash匹配,搜索效率會比較高,下面簡要介紹一下字符串匹配的流程:
我們看一下dl_sum的在libdl.so的地址是多少:
注意:這里的地址是0x790,只是一個相對偏移地址,重定向的時候需要加上一個共享庫的加載地址進行重定向。
未完成的工作
前面我們完成了main的重定向,我們還沒有對動態庫做任何處理,顯然還有一步沒有完成,就是把動態庫的內容映射到進程的地址空間里,不然我們重定向后,還是無法正常跳轉到dl_sum
我們同樣readelf來看一下動態庫的基本信息:
動態庫也是一種類型的elf,他的type為DYN,其他信息的含義和exe并無區別
在這個動態庫里包含了很多動態段,我們先放一下,如果你的動態庫,沒有涉及到數據段和函數間的引用的話,你只需要把代碼段映射到進程的共享地址空間即可,如果你的動態庫里涉及到了數據段和函數調用就需要再次對庫進行重定位。
如何對共享庫進行重定位
static函數和變量
靜態函數和變量不需要進行重定位
非靜態全局變量
全局變量在庫中的訪問,是通過間接訪問得到:
源代碼:
反匯編:
000006e0 <get_dl_data>:6e0: e52db004 push {fp} ; (str fp, [sp, #-4]!)6e4: e28db000 add fp, sp, #06e8: e59f201c ldr r2, [pc, #28] ; 70c <get_dl_data+0x2c>6ec: e08f2002 add r2, pc, r26f0: e59f3018 ldr r3, [pc, #24] ; 710 <get_dl_data+0x30>6f4: e7923003 ldr r3, [r2, r3]6f8: e5933000 ldr r3, [r3]6fc: e1a00003 mov r0, r3700: e28bd000 add sp, fp, #0704: e49db004 pop {fp} ; (ldr fp, [sp], #4)708: e12fff1e bx lr70c: 0001029c muleq r1, ip, r2710: 00000014 andeq r0, r0, r4, lsl r0從反匯編中,我們可以看到最后從0x109a4的地址中取出全部變量的地址,然后再ldr r3, [r3],得到全局變量的值。我們找到0x109a4的地址,發現其值為0,說明此地址是需要我們進行重定位的,我們找到dl_data真正的地址為0x109c8:
那么重定位就是把0x109a4地址處的值修改為0x109c8,這樣就能正確訪問全局變量了。
我們發現0x109a4是位于got節中的,所以我們得到,got節中應該保存著所有需要進行重定位的全局變量的地址。
在rel.dyn里記錄下了所有變量所需重定向的信息:
非靜態bss
非靜態bss段的重定位和非靜態的全部變量一樣
全局函數
全局函數在庫中的訪問是通過延遲綁定來實現的:
// dl.c int dl_test() {return get_dl1_data(); } // dl1.c int dl1_data = 0x35; int get_dl1_data() {return dl1_data; }
同樣,最終會跳轉到got表中去,我們需要對got表中的函數進行重定位:
再rel.plt節里記錄了所有延遲綁定的函數:
Offset表示重定向的源地址,Sym.Value表示真實的函數地址
共享庫重定位
R_ARM_ABS32,R_ARM_GLOB_DAT,R_ARM_JUMP_SLOT,都直接把offset修改為sym.value即可,R_ARM_RELATIVE比較特殊,需要把offset地址處的值修改為load_addr + [offset]
共享庫重定位流程
總結
以上是生活随笔為你收集整理的ELF加载器的原理与实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 话费接口API优惠充值源码分享
- 下一篇: 华奥安心延保对代码的敬畏之心