linux程序啓動過程

翻譯自:https://lwn.net/Articles/630727/

這個系列有兩篇文章,第一篇主要描述當一個用戶程序調用execve()系統調用的的時候發生了了什麼,內核是怎麼運行起來的,更加generic一些,裏面會覆蓋不同的可執行文件格式;而第二篇主要描述ELF格式可執行程序運行的過程,更加聚焦一些。
作者最近準備增加一個新的系統調用execveat(3.19版內核已經合併到mainline上了),這是一個execve()的近親,它允許調用者通過已經打開的文件描述符和路徑組合起來指定執行哪個可執行程序,execveat&execve的區別和openat&open的區別是非常類似的,這個系統調用的產生使得glibc庫提供的fexecve()實現不必再訪問於proc文件系統,這個對於沙箱環境(比如Capsicum)的構建是非常友好的。

用戶空間的視角

在更加深入內核之前,我們可以先從用戶空間視角來看一下程序的執行過程。截止到內核版本3.18之前,開始新的程序只有通過唯一的系統調用接口execve:

    int execve(const char *filename, char *const argv[], char *const envp[]);

filename參數指定了執行哪個程序,argvenvp參數指向一組字符串指針,最後以NULL結尾,分別表示傳遞到新程序上的命令行參數和環境變量。一個簡單的示例demo(do_execve.c)展示了我們可以如何填充他們:參數填充爲zero,one,two,命令行填充爲"ENVVAR1=1" "ENVVAR2=2",通過另外一個簡單的程序(show_info.c)來驗證和展示我們在do_execve.c中填充的結果。
通過下面的實驗我們看到,argv被正確的傳遞給被調用程序。通常二進制程序執行的時候argv[0]通常都是程序的名稱,但是這個只是慣例,它其實可以通過execve的調用者手動設置的。

    % ./do_execve ./show_info
    argv[0] = 'zero'
    argv[1] = 'one'
    argv[2] = 'two'
    ENVVAR1=1
    ENVVAR2=2

但是程序如果是腳本類的時候會有一些不一樣的地方,腳本程序show_info.sh中輸出環境變量,和上面一樣運行,結果有些不一樣:

    % ./do_execve ./show_info.sh
    $0 = './show_info.sh'
    $1 = 'one'
    $2 = 'two'
    ENVVAR1=1
    ENVVAR2=2
    PWD=/home/drysdale/src/lwn/exec

首先,環境變量自動追加了PWD來記錄當前工作目錄;其次,第一個參數是腳本的文件名稱而不是我們設置的zero
而下面的實驗不僅展示了/bin/sh腳本解釋器自動追加PWD環境變量而且表明內核自己修改它的參數:

    % cat ./wrapper
    #!./show_info
    
    % ./do_execve ./wrapper
    argv[0] = './show_info'
    argv[1] = './wrapper'
    argv[2] = 'one'
    argv[3] = 'two'
    ENVVAR1=1
    ENVVAR2=2

內核移除了第一個參數“zero”同時在頭部添加了兩個參數:腳本解釋器的名稱和傳遞給腳本解釋器的文件名稱。如果腳本的第一行包含了傳遞給腳本解釋器的額外參數,那麼傳遞的參數變爲三個了:腳本解釋器的名稱,腳本解釋器的參數和傳遞給腳本解釋器的文件名稱。

    % cat ./wrapper_args
    #!./show_info -a -b -c

    % ./do_execve ./wrapper_args
    argv[0] = './show_info'
    argv[1] = '-a -b -c'
    argv[2] = './wrapper_args'
    argv[3] = 'one'
    argv[4] = 'two'
    ENVVAR1=1
    ENVVAR2=2

簡單講,在執行腳本的時候傳遞參數的行爲就像下面這樣:在頭部新增腳本解釋器名稱,其他參數順序後移

    argv[0]:  'zero'=>'./wrapper4'=>'./wrapper3'=>'./wrapper2'=>'./wrapper' =>'./show_info'
    argv[1]:  'one'   './wrapper5'  './wrapper4'  './wrapper3'  './wrapper2'  './wrapper'
    argv[2]:  'two'   'one'         './wrapper5'  './wrapper4'  './wrapper3'  './wrapper2'
    argv[3]:          'two'         'one'         './wrapper5'  './wrapper4'  './wrapper3'
    argv[4]:                        'two'         'one'         './wrapper5'  './wrapper4'
    argv[5]:                                      'two'         'one'         './wrapper5'
    argv[6]:                                                    'two'         'one'
    argv[7]:                                                                  'two'

但是內核不支持無限循環嵌套,所以當腳本解釋器循環嵌套太多的時候它會返回ELOOP錯誤

    % ./do_execve ./wrapper6
    Failed to execute './wrapper6', Too many levels of symbolic links

內核對象linux_binprm

現在我們下探到內核空間來看一下execve()系統調用究竟是如何實現的,我們選擇fs/exec.c ->do_execve_common()作爲研究的入口點,它的主要工作是構建一個struct linux_binprm對象來描述當前程序調用的操作,下面先來看一下其中重要的成員,他們是如何能夠表達一個可執行程序的信息。
file指向一個即將被執行程序的文件對象,內核可以通過它來讀取文件內容來決策它是哪種文件格式從而可以選擇怎麼解釋執行它。
filenameinterp兩個都是指向程序的文件名稱,下面會有詳細解釋爲什麼需要兩個成員來描述這項信息。
bprm_mm_init()方法分配並且初始化相關的struct mm_structstruct vm_aread_struct數據對象,這兩個負責管理新程序的虛擬內存空間,棧通常是向下增長的,所以通常新程序的虛擬內存都會靠近arch允許的最高地址,除此之外內核爲了安全會有隨機化,所以真正的虛擬地址會在最高地址向下有一個隨機的偏移。
p指向新程序棧的最高地址,但是會留出來一個NULL指針作爲棧結束的標記地址,它的確切值是bprm->p = vma->vm_end - sizeof(void *);。如果有新的信息被添加到新程序的棧上時p的值將會被更新。
argcenvc表示參數和環境變量的值來傳遞給新程序。
unsafe是一個位圖來表示程序執行可能不安全的原因,例如一個程序被ptrace或者它自己通過pctrl設置了PR_SET_NO_NEW_PRIVS.LSM可能會在接下來需要使用到這裏設置的信息來決定是否拒絕程序執行。
cred是內核爲每個可執行程序分配的cred對象,它主要描述了用戶憑證信息。通常是繼承自execve()調用者,但是當可執行文件有setuid/setgid標誌時會來更新憑證信息,而setuid/setgid不僅修改用戶憑證而且會影響兼容性功能,他們都會影響系統的安全性。
per_clear記錄着進程隱私信息並且會在之後按照它的記錄信息來清除進程的個性化信息。
security主要是爲LSM提供支持的,允許存儲LSM特定的信息,LSM會通過security_bprm_set_creds()被通知到並且選擇設置該項內容。默認的實現中這個hook函數會更新新程序的capabilities屬性。

上面填充linux_bprm成員的過程主要是在prepare_binprm()中完成,如果一個腳本的文件被實際執行,通常第一行以#!開頭,這個對象會被再次更新。

最後,程序調用的信息會被拷貝到新程序棧的頂部。首先程序名稱會首先入棧,接下來是環境變量和參數,最後棧頂端佈局如下:

    ---------Memory limit---------
    NULL pointer
    program_filename string
    envp[envc-1] string
    ...
    envp[1] string
    envp[0] string
    argv[argc-1] string
    ...
    argv[1] string
    argv[0] string

程序如何被執行

當構建好linux_binprm對象後,程序真正的執行過程由exec_binprm()和search_binary_handler()來接管.它們會遍歷linux_binfmt對象的鏈表,每一個linux_binfmt都會提供一個handler來負責解釋程序。這些linux_binfmt可能會被編譯成模塊形式,所以需要通過try_module_get()來保證相關的module不會在使用他們的時候被卸載掉。

對於每個linux_binfmt handler對象,他們都提供了load_binary()回調實現,處理對象就是linux_binprm。如果handler支持這種文件格式就返回成功值(>=0),如果不支持就返回失敗碼(<0)並繼續遍歷迭代。
一個特殊的程序執行過程中可能還會依賴另一種程序的執行,最明顯的例子就是可執行腳本,它需要調用腳本解釋器,而腳本解釋器通常就是一個二進制程序。考慮到這種情況,search_binary_handler()可以被遞歸使用,不過他們都是使用同一個linux_binprm對象。但是這個遞歸深度不可能無限循環,超過最大深度就返回ELOOP
系統的LSM也會參與到這個過程中,在查找哪個linux_binfmt可以處理當前的linux_binprm之前,會觸發bprm_check_security hook,給LSM一個機會來決策是否允許執行,此時LSM可能會使用linux_binprm.security對象。

在遍歷結束後,如果當前程序還不能處理,系統可能會嘗試來加載binfmt-XXXX這種名稱的驅動,XXXX是程序的第三和第四個字節的十六進制表示。這種機制是在內核1.3.57版本(1996年)上添加的,主要是爲了能夠以一種更加靈活的方式來關聯可執行程序和它的驅動。不過最近的binfmt_misc機制提供了一種相似的但是更加靈活便捷的方式。

二進制格式

內核支持一些可執行二進制的格式,我們可以通過在代碼中搜索關鍵詞register_binfmt & insert_binfmt()來找到都有哪些struct linux_binfmt實例,更加簡潔的方式就是ls fs/binfmt_*,內核的命名非常規範的,我們可以找到所有支持的格式。下面是所有可以配置的格式,註釋摘抄自fs/Kconfig.binfmts文件,我就不再當搬運工了

binfmt_script.c: Support for interpreted scripts, starting with a #! line.
binfmt_misc.c: Support miscellaneous binary formats, according to runtime configuration.
binfmt_elf.c: Support for ELF format binaries.
binfmt_aout.c: Support for traditional a.out format binaries.
binfmt_flat.c: Support for flat format binaries.
binfmt_em86.c: Support for Intel ELF binaries running on Alpha machines.
binfmt_elf_fdpic.c: Support for ELF FDPIC binaries.
binfmt_som.c: Support for SOM format binaries (an HP/UX PA-RISC format).
(plus a couple of other architecture-specific formats).

下面的章節主要解釋:解釋腳本和“misc”機制,後面一篇文章專門講述ELF格式,ELF基本上是所有可執行程序最終都要調用的。

script

如果一個可執行程序以#!開頭那麼我們就認爲他是一個腳本程序,並通過fs/binfmt_script.c中的handler來執行。在檢查前兩個字節後,會繼續解析這一行的內容,從#!之後到第一個空格之間的內容就是解釋器的內容,空格之後直到換行的內容是解釋器的參數。
內核在解析可執行文件時,linux_binprm.buf只有128字節,所以如果解釋器的長度大於128字節,它會被截斷。
接下來,會從新程序的棧上移除argv[0],然後追加下列參數並且調整argc的值:

the program name
(optionally) the collected interpreter arguments
the name of the interpreter program

與上面用戶空間程序觀察到的結果結合,新程序的棧排布就像這樣:

    ---------Memory limit---------
    NULL pointer
    program_filename string
    envp[envc-1] string
    ...
    envp[1] string
    envp[0] string
    argv[argc-1] string
    ...
    argv[1] string
    program_filename string
    ( interpreter_args )
    interpreter_filename string

腳本handler處理過程中會更改linux_binprminterp的值,它最後是解釋器的文件名稱而不是腳本文件名稱,這就解釋了爲什麼linux_binprm中爲什麼有兩個字符串:interp是我們當前要執行的程序,而filenameexecve()中調用的文件。同時file也會指向新的解釋器程序文件,buf中保存的是解釋器的前128字節。
之後如果解釋器還是個腳本,腳本handler會遞歸調用search_binary_handler(),再次重複當前過程,這個過程中interp會一直指向最新的解釋器,但是filename一直是最初的腳本文件名稱。如果解釋器是ELF格式,那麼就會移交到ELF handler中

misc

早期的內核版本支持一種動態添加可執行文件格式支持的方式,它通過文件的第三和第四個字節的內容來搜索內核模塊,但是它並不是非常的完美,兩個字節最多支持256種格式,非常可能會被用光,所以需要一種新的內核模塊來處理。misc二進制格式提供了一種更加便捷並且支持動態添加的方式,只需要掛載proc並且通過/proc/sys/fs/binfmt_misc接口就能實現運行時配置。

它可以通過文件名的後綴或者在文件特殊的偏移上magic匹配,就像腳本解釋器一些樣,不過magic也必須在文件的前128字節內,之後解釋器程序被調用並且原始程序文件名稱放在argv[1]上傳遞給它。

java文件就是非常好的例子:如果文件是以.jar/.class結尾或者有0xCAFEBASE magic,就會自動調用JVM程序來加載這個文件。因爲misc配置並不支持指定參數,所以需要一個封裝的腳本來提供相關命令行參數支持,misc來調用這個封裝的腳本,最終調用JVM的ELF可執行格式文件

它的內部實現和腳本解釋器比較像,只不過它會再次在它註冊配置裏面搜索匹配格式處理,匹配的格式處理可能有些可選項,例如移除argv[0].

script和misc格式都會調用解釋器程序,不過最終都會以調用ELF二進制程序爲終點,這個內容見下篇內容。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章