首先來看一下在 C語言程序是如何經過處理變成可執行程序的:
C代碼(.c) - 經過編譯器預處理,編譯成匯編代碼(.asm) - 匯編器,生成目標代碼(.o) - 鏈接器,鏈接成可執行文件(.out) - OS將可執行文件加載到內存里執行.
目標文件的格式ELF
EXECUTABLE AND LINKABLE FORMAT 可執行的和可鏈接的格式(是文件格式的標準)
.o文件 和 可執行文件,都是目標文件,一般使用相同的文件格式
ABI和目標文件格式是什么關系
目標文件也叫做ABI,應用程序二進制接口。實際上在目標文件里面,它已經是二進制兼容的格式。而什么叫二進制兼容呢?所謂的二進制兼容,就是指這個目標文件已經是適應某一種CPU體系結構上的二進制的指令,比如在32位x86環境下編譯出來的目標文件,鏈接成ARM上的可執行文件,那肯定是不可以的
ELF文件里面三種目標文件
一個可重定位(relocatable)文件保存著代碼和適當的數據,用來和其它的object文件一起來創建一個可執行文件或者是一個共享文件(主要是.o文件)
一個可執行(executable)文件保存著一個用來執行的程序,該文件指出了exec(BA_OS)如何來創建程序進程映象(操作系統怎么樣把可執行文件加載起來并且從哪里開始執行)
一個共享object文件保存著代碼和合適的數據,用來被下面兩個鏈接器鏈接:(主要是.so文件)第一個是鏈接編輯器(靜態鏈接)【請參看ld(SD_CMD)】,可以和其它的可重定位和共享object文件來創建其它的object第二個是動態鏈接器,聯合一個可執行文件和其它的共享object文件來創建一個進程映象
ELF的目標文件格式
Object文件參與程序的鏈接(創建一個程序)和程序的執行(運行一個程序)
一個ELF頭在文件的開始,保存了路線圖(road map),描述了該文件的組織情況程序頭表(Program header table)告訴系統如何來創建一個進程的內存映像Section頭表(Section header table)包含了描述文件Sections的信息。每個Section在這個表中有一個入口,每個入口給出了該Section的名字,大小等信息
鏈接視圖,有很多Section,執行視圖,有很多段(Segment)
大多數文件格式也都是這種模式,在頭記錄了一些元數據,用readelf命令來詳細查看ELF文件頭
可執行程序加載的主要工作
當創建或增加一個進程映象的時候,系統在理論上將拷貝一個文件的段到一個虛擬的內存段
可執行文件有一個頭部,里面有一些關鍵信息,Entry point Address,入口地址,即程序的起點,0x8048300,后面有一些代碼,數據
對于進程來講,進程有一個進程地址空間,而對于32位x86體系結構來講,進程有4G的進程地址空間(邏輯地址),3G以上的地址空間只能在內核態下訪問,在用戶態的時候,只能訪問0到3G的地址空間。
ELF可執行文件加載到內存的位置 與 ELF可執行文件加載到內存中開始執行的第一行代碼
ELF可執行文件默認加載到內存0x8048000這個位置,從這個位置開始加載。前面加載ELF可執行文件的頭部信息,但因不同文件大小不同,程序的實際入口為:0x8048x00,圖例為0x8048300,也就是說這個位置是程序的實際入口地址,即剛加載過可執行文件的進程(一個進程加載了新的可執行文件之后,開始執行的入口點),就是從這個地方開始執行
簡略地來看,圖例里的文件是ELF的靜態鏈接文件。靜態鏈接的時候,會將所有代碼放在一個代碼段,把所有的鏈接都鏈接好了,所以從0x8048300開始一行行代碼執行,壓棧出棧,把整個程序執行完而實際上如果需要用到共享庫,需要動態鏈接的話,會有多個代碼段,情況會更復雜(暫不研究)
裝載可執行程序之前的工作最主要的是兩大部分:
- 可執行程序的文件格式
- 可執行程序的執行環境
一般是通過shell程序啟動一個可執行程序,shell程序具體做了什么?而當啟動加載一個可執行程序的時候,也就是發起一個系統調用execve,shell環境準備了哪些執行的上下文環境(用戶態的執行環境)
再看看execve系統調用怎么樣把一個可執行文件在內核態里面裝載起來,裝載起來后又返回到用戶態(內核態的執行環境)
可執行程序的執行環境(Shell命令行、main函數的參數與execve的參數)
$ ls -l /usr/bin 列出/usr/bin下的目錄信息,
Shell本身不限制命令行參數的個數,命令行參數的個數受限于命令自身例如,
int main(int argc, char *argv[]) -- 愿意接收命令行參數又如,int main(int argc, char *argv[], char *envp[]) -- 愿意接收shell的環境變量.
前兩個參數由用戶輸入命令的時候設定-l /usr/bin,后一個是shell環境,shell程序自動加上
命令行參數和環境變量是如何保存和傳遞的?
先函數調用參數傳遞,再系統調用參數傳遞
Shell程序 -> execve -> sys_execve,然后在初始化新程序堆棧時拷貝進去
命令行參數和環境串都放在用戶態堆棧中
當fork一個子進程的時候,復制父進程,調用execve系統調用的時候,要加載的可執行程序把原來的進程的環境覆蓋掉了,覆蓋掉之后它的用戶態堆棧也被清空了,因為它是個新的程序要執行,那么argv和envp是如何進入新程序的用戶態堆棧的?即命令行參數和環境變量是如何進入新程序的堆棧的?
在創建一個新的用戶態堆棧的時候,實際上是把命令行參數的內容和環境變量的內容通過指針的方式傳遞到execve系統調用的內核處理函數,然后內核處理函數在創建可執行程序新的用戶態堆棧的時候,會把參數拷貝到用戶態堆棧里,初始化新的可執行程序的上下文環境。所以,新的程序能從main函數開始,把對應的參數接收過來,然后執行。但原先在調用execve時,參數只是壓在了shell程序當前進程的堆棧上,而這個堆棧在加載完新的可執行程序之后,已經被清空了,內核又創建了一個新進程的用戶態堆棧
如果僅僅只是加載一個靜態鏈接的可執行程序的話,只需要傳遞一些命令行參數,一些環境變量,可執行程序就可以正常地工作。但是對于絕大多數的可執行程序來講,還有一些對動態鏈接庫的依賴,這個比較復雜。
傳統的系統調用都是陷入到內核態,然后再返回到用戶態,繼續執行系統調用下面的指令
fork系統調用進入到內核態,兩次返回,在父進程中,返回到父進程原來的位置繼續向下執行,這個和傳統的系統調用是一樣的。在子進程中,構造了它的堆棧環境,子進程返回到特定的點,是從ret_from_fork開始執行然后返回到用戶態,對于子進程來講比較特殊
execve系統調用,當前的可執行程序在執行,執行到execve系統調用時候,陷入到內核態,在內核里面,用execve加載的可執行文件,把當前進程的可執行程序給覆蓋掉了,當execve系統調用返回的時候,已經不是返回到原來的可執行程序了,是新的可執行程序的執行起點,也就是main函數大致的位置,那么main函數的執行環境,也就需要我們來構建好加載的新的可執行程序的執行環境
sys_execve內核處理過程
當execve系統調用陷入到內核里的時候,system_call,調用了sys_execve(),sys_execve內部會解析可執行文件格式,后面的調用順序:do_execve -> do_execve_common -> exec_binprm
search_binary_handler根據文件頭部信息尋找對應的文件格式處理模塊,如下:
根據我們給出的文件名,加載了文件的頭部,判斷文件是什么格式,在列表中尋找能夠解釋ELF格式的內核模塊
對于ELF格式的可執行文件fmt->load_binary(bprm);執行的應該是load_elf_binary其內部是和ELF文件格式解析的部分需要和ELF文件格式標準結合起來閱讀
當ELF文件格式出現的時候,觀察者就能自動執行load_elf_binary,但實際上是在retval = fmt->load_binary(bprm)執行,這個地方實際上是一種多態的機制,本質上是一種觀察者模式
動態調試時中斷在 load_elf_binary,如下所示:
在start_thread函數中,將中斷返回后的 ip 和 sp 設置成了特定的新值,以便執行新的程序邏輯.
調試時也會中斷在該函數,并可以查看新的 ip 地址.
start_thread這個函數有一個pt_regs,一個new_ip,一個new_sp
pt_regs實際上就是內核堆棧的棧底的那部分,發生系統調用int 0x80的時候,把eflags、sp、ip都壓入到棧。那么新進程執行的時候,需要把它的起點位置給它替換掉
new_ip是怎么來的呢?
看一下load_elf_binary in /linux-3.18.6/fs/binfmt_elf.c#571
975 start_thread(regs, elf_entry, bprm->p);
elf_entry,對于一個靜態鏈接的可執行文件,就是可執行文件里的Entry point address,可執行文件頭部定義的起點
在一個新的可執行程序返回到用戶態之前,需要修改int 0x80壓入內核堆棧的EIP,用新的可執行程序的起點來修改,但是對于動態鏈接的過程又更復雜一些,先理解靜態鏈接的過程,大致是這樣
看一下do_execve_common
/linux-3.18.6/fs/exec.c#do_execve_common
打開要加載的可執行文件,然后加載文件頭部
1474 file = do_open_exec(filename);
創建結構體,bprm
1481 bprm->file = file;
1482 bprm->filename = bprm->interp = filename->name;
把環境變量和參數都copy到結構體里面
1505 retval = copy_strings(bprm->envc, envp, berm);
1509 retval = copy_strings(bprm->argc, argv, berm);
對可執行文件的處理過程
1513 retval = exec_binprm(berm);
看一下exec_binprm /linux-3.18.6/fs/exec.c#exec_binprm
尋找可執行文件的處理函數
1416 ret = search_binary_handler(bprm);
看一下search_binary_handler /linux-3.18.6/fs/exec.c#1352
尋找能夠解釋當前可執行文件的代碼模塊
如果該程序需要動態鏈接,則elf_interpreter指針不為空,并指向對應的 ld 文件.內核則加載此文件,由該文件進行動態鏈接,并最終跳入程序頭文件中制定的入口點.如下圖所示:
所以后面在start_thread的時候就會有兩種可能
975 start_thread(regs, elf_entry, bprm->p);
如果是一個靜態鏈接文件的話,elf_entry就是指向Entry point address的位置0x8048x00如果是一個需要依賴動態鏈接庫的話,需要ld鏈接器,elf_entry就是指向動態鏈接器的起點
淺析動態鏈接的可執行程序的裝載
對于一般的可執行程序來講,大多都是需要使用動態鏈接庫,動態鏈接庫最常見的就是libc,動態鏈接器ld它也是libc的一部分。那么在動態鏈接的過程,內核做了什么?
ELF格式里面要依賴其它的動態鏈接庫,動態鏈接庫某一個.so本身(它也是一個ELF格式的文件)還可能會依賴其它的動態鏈接庫,因此實際上動態鏈接庫的依賴關系會形成一個圖。在解釋每一個ELF格式文件的時候,看它依賴了哪些動態鏈接庫,這樣它就會加載
那么誰負責加載呢?
閱讀內核代碼的時候,可以看到,當這個文件需要用elf_interpreter的話,也就是說它需要依賴動態鏈接器來解釋這個ELF文件,那么它就需要加載load_elf_interp,實際上是加載動態連接器ld,那么這時候Entry point address,也就是說在返回到用戶態的時候,它返回的就不是這個可執行程序文件規定的起點,它返回的是動態連接器的程序入口,動態連接器負責解釋當前的可執行文件,看它里面依賴哪些動態鏈接庫,然后把那些動態鏈接庫一個一個加載進來,加載進來之后再解釋加載進來的動態鏈接庫,看它這個動態鏈接庫還依賴哪些文件,這樣就有一個叫廣度遍歷的方法(即動態鏈接庫的裝載過程是一個圖的遍歷),把所有的動態鏈接庫都裝載起來,裝載起來之后ld再負責把CPU的控制權移交給可執行程序頭部規定的起點位置
那么從以上分析看出,動態鏈接的過程不是由內核來完成的,主要是由動態鏈接器來完成的,動態鏈接器是libc的一部分,是在用戶態做的事情