- 原文標題:How programs get run
- 原文網址:https://lwn.net/Articles/630727/
- 原文作者:David Drysdale
- 原文發表時間:2015 年 01 月 28 日
- 譯註:
- 根據原文使用的參考資料連結,原文應該是使用 3.18 版核心做為依據。
↓↓↓↓↓↓ 正文開始 ↓↓↓↓↓↓
本文是一份系列文章中的上篇,旨在說明核心如何讓程式開始運行:當一個使用者程式調用系統呼叫 execve()
,幕後到底發生了什麼?我最近致力於一個新的系統呼叫 execveat()
的實作,這是一個 execve()
的相近變體,但它允許呼叫者藉由檔案描述符(file descriptor)與檔案路徑的組合,來指定要調用的程式,就像其他的 *at()
系統呼叫一樣。(因此使得在不依靠存取檔案系統 /proc
的條件下,實作函式庫函式 fexecve()
成為可能,這對於沙箱式的環境(sandboxed environment),例如 Capsicum,非常重要。)
過程中,我探索了現有 execve()
的實作方式,並據此在這系列文章呈現其功能性的細節。在本篇中,我們專注在核心用於程式調用中的通用機制,適用於不同的程式檔格式;而於下篇中,將專注於運行 ELF 二進位檔的細節。
從使用者空間(user space)的觀點
在深入核心之前,我們要先從使用者空間的程式執行行為開始探索(在《The Linux Programming Interface 》1的第 27 章中也有完善的描述)。在 Linux 3.18 以前,唯一用來調用新程式的系統呼叫是 execve()
2,其原型如下:
int execve(const char *filename, char *const argv[], char *const envp[]);
引數 filename
指定要被執行的程式,而引數 argv
與 envp
則是以 NULL 作為尾項的列表,分別用於指定提供給新程式的命令列引數( command-line argument)和環境變數( environment variable)。藉由提供「zero
」、「one
」、「two
」做為命令列引數,「ENVVAR1=1
」、「ENVVAR2=2
」做為環境變數,一個單純只有空殼的引動程式(do_execve.c
)就能讓我們探測它如何運作;為了看到受調用程式內的效果,我們利用另一個簡單程式(show_info.c
),它只會輸出它的命令列引數(argv
)和環境變數(environ
)。
將這些湊到一起,就能獲得想要的效果——把指定的引數和環境傳給被調用的程式。注意,儘管給被調用程式的 argv[0]
就是由 execve()
的呼叫者指定的,在 argv[0]
中存放程式名稱並非是 execve()
本身所需要或必須遵循的約定(convention),至少就二進位檔來說是如此。
% ./do_execve ./show_info
argv[0] = 'zero'
argv[1] = 'one'
argv[2] = 'two'
ENVVAR1=1
ENVVAR2=2
然而,當被調用的程式是一份命令稿(script)而非二進位檔案時,結果會有些微的改變;為了探索這種情況,我們使用一個與先前用來輸出環境的程式相等價的 shell 命令稿(show_info.sh
),將它跟調用 execve()
的原始程式湊在一起,會看到兩個相異之處:
% ./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
」值。一個進一步的試驗則透露了,是命令稿直譯器(script interpreter)/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
),並以兩個命令列引數取代——命令稿直譯器程式的名字(取自命令稿第一行)和被調用程式(存有命令稿內容)的名字;萬一命令稿第一行也包含了要提供給直譯器的命令列引數(例如 awk
需要 -f
選項來將輸入視為檔名而非命令稿內容),第三個額外的命令列引數就會被插入,存有所有的額外選項 3:
% 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
在一定的限度內,我們可以讓引數重複「彈出一個、推入兩個」這樣的變更,只要不斷調用包裹(wrap)住命令稿的命令稿,並以此類推即可;在每次的變更中,實際上都會推入包裹器命令稿自己的名字到 argv[1]
:
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'
然而,這不會永遠持續下去——一旦有太多層的包裹器,行程(process)會以 ELOOP
失敗:
% ./do_execve ./wrapper6
Failed to execute './wrapper6', Too many levels of symbolic links
進入核心:struct linux_binprm
現在,讓我們移到核心空間(kernel space),並開始鑽研實作系統呼叫 execve()
的程式碼,有篇之前的文章已經探索了通用的系統呼叫機制(以及 execve()
需要的特殊處理),所以讓我們從 fs/exec.c
裡的函式 do_execve_common()
接著看下去;這個函式內的主要目標是建立一個 struct linux_binprm
的新實體(instance),用以描述目前的程式調用操作,在這個結構中:
欄位
file
被設定為對應於被調用程式而剛剛開啟的struct file
;這讓核心能讀取檔案內容並決定如何處理該檔案。欄位
filename
及interp
都設定為存有程式本身的檔案的名稱;我們會在之後知道為什麼這裡有兩個不同的欄位。函式
bprm_mm_init()
配置並設定相關的資料結構struct mm_struct
與struct vm_area_struct
,準備用來管理新程式的虛擬記憶體。尤其新程式虛擬記憶體的末端會是在該架構下最高的可能位址,它的堆疊(stack)也將從此處往下延伸。欄位
p
設定成指向新程式虛擬記憶體的末端,不過會留出空間給做為堆疊末端標記的 NULL 指標,p
的值會隨著更多資訊加進新程式的堆疊而更新(向下)。欄位
argc
與envc
分別設定成命令列引數和環境變數的值的個數,以便在調用流程中,將這些資訊在晚一點的時候傳播給新程式。欄位
unsafe
被設置來存放一組位元遮罩(bitmask),含有可能使程式執行變得不安全(safe)的原因;舉例來說,假如行程被ptrace()
追蹤,或是被設定了位元PR_SET_NO_NEW_PRIVS
,隨後,Linux 安全模組(Linux Security Module,LSM)可能會據此資訊拒絕當次執行程式的操作。欄位
cred
是一個另外配置且型別為struct cred
的物件,存有關於新程式的憑證(credentials)4資訊,這些通常會從呼叫execve()
的程式繼承,但也能被更新以容許setuid
/setgid
位元以及其他複雜情況。而由於setuid
/setgid
位元會對安全性帶來負面影響,因此在它們被設立的情況會禁止一組相容性功能;欄位per_clear
記錄了之後在行程的個性(personality)中將要清除的位元 5 6。欄位
security
讓 LSM 能在linux_binprm
儲存 LSM 的特有資訊;LSM 會在呼叫security_bprm_set_creds()
以及 LSM 攔截點(LSM hook)bprm_set_creds
時收到通知,該攔截點的預設實作會更新新程式的 Linux capability,用以為該程式檔案的檔案 capability 給予批准;其他 LSM 的實作則將此行為串接到它們自己對該攔截點的實作中(例:Smack、SELinux)。塗抹空間(scratch space)
buf
填充了程式檔案中第一個區塊(chunk,128 位元組)的資料,這資料會在稍後用來偵測其二進位格式類型,才能對程式檔案做適當的處理。
在這個設定流程中,依賴於被執行的那個檔案的部分,是在函式 prepare_binprm()
的內部進行的,之後,假如真正跑的是不同檔案(例:命令稿直譯器),此函式可再次被呼叫以更新那些欄位。
最後,關於程式調用的資訊會利用區域工具函式(local utility function7)copy_strings()
和 copy_strings_kernel()
,複製到新程式堆疊的頂端;首先,程式檔名會推入堆疊(而其位置被存放在 linux_bprm
實例的 exec
欄位),並緊接著所有的環境變數,再然後是所有的引數。在這個流程結束後,堆疊看起來就像:
---------記憶體空間極限---------
NULL 指標
程式檔名 字串
envp[envc-1] 字串
...
envp[1] 字串
envp[0] 字串
argv[argc-1] 字串
...
argv[1] 字串
argv[0] 字串
二進位格式處置器的疊代(iteration):struct linux_binfmt
有了完整的 struct linux_binprm
在手,程式執行的真正工作是在 exec_binprm()
及(更重要的)search_binary_handler()
進行,其程式碼在一串 struct linux_binfmt
物件的串列上疊代,每個物件都為某種特定的二進位程式格式提供了一個處置器(handler);但由於一個二進位處置器可能定義於某個核心模組(kernel module),所以該程式碼對每個格式呼叫 try_module_get()
,以確保在此處使用時,相關的程式碼不會被其他任務(task)卸載。
對每個 struct linux_binfmt
處置器物件,函式指標 load_binary()
會被呼叫,並傳入 linux_binprm
物件。假如處置器的程式碼能支援該二進位格式,它會執行任何為了該程式執行所需的準備,然後回傳成功的訊息(≥ 0);否則,處置器回傳失敗的代號(< 0),而疊代則向下一個處置器繼續。
一個特定程式的執行可能依賴於另一個不同程式的執行,最明顯的案例就是可執行命令稿(executable script),它會需要調用命令稿直譯器;為了解決這種情況,search_binary_handler()
的程式碼設計成可以遞迴地調用,並對 struct linux_binprm
的物件做再利用,可是,為了避免無窮遞迴,遞迴的深度是有限的,它會像先前看到的,給出 ELOOP
錯誤這樣的行為。
系統的 LSM 也會在這項操作中插個手;在對於二進位格式的疊代開始前,LSM 攔截點 bprm_check_security
會觸發,讓 LSM 能決定是否允許這項操作,為此,它也許會利用先前它儲存於欄位 linux_binprm.security
的狀態。
在疊代結束時,如果找不到可以處理該程式的格式(而且至少根據程式前四個位元組來看,呈現應該是二進位檔案而非文字檔的樣子),那程式碼會試圖組入名為「binfmt-XXXX
」的模組,其中 XXXX
是程式檔案中,第三和第四個位元組構成的十六進位值,這是一個老舊的機制(於 1996 年加入 Linux 1.3.57),用一種比較動態的方法,來達成二進位檔案處置器和格式之間的關聯;更近代的 binfmt_misc
機制(在下面說明)則允許了一種更有彈性的方法來完成類似功能。
二進位格式
所以,在一個標準核心中什麼二進位格式是可用的呢?搜尋一下用來註冊 struct linux_binfmt
實例(經由 register_binfmt()
與 insert_binfmt()
)的程式碼,我們得到了一組可能的格式,
它們全都在檔案 fs/Kconfig.binfmts
裡來做調校及解釋:
binfmt_script.c
:支援直譯命令稿,以#!
的一行起始。binfmt_misc.c
:根據執行期的調校,支援其他(miscellaneous)8的二進位格式。binfmt_elf.c
:支援 ELF 格式的二進位檔。binfmt_aout.c
:支援傳統 a.out 格式的二進位檔。binfmt_flat.c
:支援 flat 格式的二進位檔 9。binfmt_em86.c
:支援在 Alpha 機器上的 Intel ELF 二進位檔。binfmt_elf_fdpic.c
:支援 ELF FDPIC 二進位檔。binfmt_som.c
:支援 SOM 格式的二進位檔(一種 HP/UX PA-RISC 格式)。
(加上其他兩種特定於架構的格式。)
在下一節,要探討它們中最重要的:直譯命令稿和為了支援隨機格式的 miscellaneous 機制;而在下篇中,則會探討 ELF 二進位格式——在所有程式執行的流程裡,典型的結尾之處。
命令稿的調用:binfmt_script.c
以字元序列 #!
為開頭(並且設立了可執行位元)的檔案會被視為命令稿,並由 fs/binfmt_script.c
的處置器進行處理,在確認了前兩個位元組後,其程式碼解析命令稿調用列(script-invocation line)的剩下部分,從其中分離出直譯器的名稱(從 #!
之後直到第一個空白前的所有東西),以及可能的引數(直到行尾的所有其他東西,並去除外部的空白)。
(一個值得注意的細節:回到 struct linux_binprm
物件建立的時候,只有程式的前 128 位元組是被擷取的,這表示假如直譯器名稱和引數比這還長,就會遭到截斷。)
這些訊息到手後,程式碼會從新程式的堆疊頂端(也就是最低位址)移除 argv[0]
,然後原地推入下列東西,並在過程中調整 linux_binprm
物件中 argc
的值:
合起來看,這就解釋了本文一開始在使用者空間觀察到的行為,我們的新程式的堆疊被改得像這樣:
---------記憶體空間極限---------
NULL 指標
程式檔名 字串
envp[envc-1] 字串
...
envp[1] 字串
envp[0] 字串
argv[argc-1] 字串
...
argv[1] 字串
程式檔名 字串
( 直譯器引數 )
直譯器檔名 字串
該程式碼也變更了 linux_binprm
結構內 interp
的值,讓它指向直譯器的檔名,而不是命令稿的檔名,這解釋了為何結構 linux_binprm
需要參照到兩個字串:一個(interp
)是我們目前想要執行的程式,另一個則是一開始在 execve()
呼叫中所調用的名字(filename
)。順著相似流程,linux_binprm
內的欄位 file
也會被更新為參照新的直譯器程式,而其前 128 位元組的內容也會讀入塗抹空間 buf
。
接著,命令稿處置器會遞迴進 search_binary_handler()
,為命令稿直譯器的程式重複整個流程,如果直譯器本身還是一份命令稿,則 interp
的值會再一次被更動,而 filename
會維持不變。
其他直譯器偵測:binfmt_misc.c
前面我們知道,早期的 Linux 核心在對於新增格式方面的支援,提供了一種大致可用的方法,是藉由搜索在名稱中,包含二進位檔案開端幾個位元組的核心模組(kernel module)來達成;這種方法並不是特別便利——只搜尋一對位元組是有很大限制的(相較於指令 file
所使用的涵蓋大範圍的偵測用特徵碼(signature)),同時也需要增加核心模組的入門門檻。
miscellaneous 格式的處置器允許一種更彈性、動態的方法來處理新的格式,是藉由執行期的調校設定(經由一個掛載在 /proc/sys/fs/binfmt_misc
之下的特殊檔案系統)來指定以下資訊 10:
如何辨識要支援的格式;看是基於附檔名,還是位於特定位移的魔術值(magic value)。(與解析命令稿直譯器時相同,魔術值必須落在程式檔的前 128 位元組。)
辨識成功時,需要調用的直譯器程式為何;程式檔案的名稱會以
argv[1]
的方式傳遞給它(如同命令稿的調用)。
一個 miscellaneous 格式處置器被使用的好例子是用於 Java 檔案:偵測 .class
檔(基於它們的 0xCAFEBABE
前綴)或 .jar
檔(基於副檔名 .jar
),並且自動在它們之上調用 JVM 執行檔;這會需要一個包裹命令稿(wrapper script)來提供相關的命令列參數,因為 miscellaneous 調教設定並不允許引數的指定——這表示 miscellaneous 處置器將調用命令稿直譯器,由它再為 JVM 執行檔調用 ELF 處置器。(而 JVM 執行檔則可能再轉而調用動態連結器(dynamic linker)ld.so
,然而,那又是另一個故事了。)
從內在看來,對於這個格式,核心的實作與先前描述對於命令稿的處置器是相似的,除了在一開始會有一個對於符合調校設定之項目的搜尋,而該調校設定則用來指定某些選擇性的細節(例如移除 argv[0]
)。
命令稿和 miscellaneous 格式的處置器都會進行遞迴,試圖為特定格式調用所需的直譯器程式,而這樣的遞迴必然要在某個點上結束;在現代的 Linux 系統上,這個點幾乎都會是一個 ELF 二進位程式——也就是下篇的主題——請持續關注。
↑↑↑↑↑↑ 正文結束 ↑↑↑↑↑↑
本文以 StackEdit 撰寫。
譯註:
- 《The Linux Programming Interface》正體中文版由基峯出版社於 2016 年 10 月出版。
↩ - 有在 Linux 上撰寫過 C/C++ 程式的人,或許會覺得要執行新程式明明就還有
execl()
、execv()
等exec()
函式家族的成員可以使用,這裡的描述似乎有問題。其實,這裡要區分函式庫函式和系統呼叫的不同,execve()
是系統呼叫,是應用程式與作業系統核心溝通的最底層介面;而execl()
等則是 glibc 函式庫中,為了便於使用,而產生的包裹函式。
↩ - 在原文後續的討論中,ID 為 wahern 的一位訂閱者發表了一篇評論,並獲得了原文作者的認同,其中提到關於這裡所謂「所有的額外選項」可能導致的誤解,其實在核心處理到這部份時,並沒有考量多個引數的可能性,而是將它們整個當成「一個」引數來處理,正如更後面的正文所述:
……在確認了前兩個位元組後,其程式碼解析命令稿調用列(script-invocation line)的剩下部分,從其中分離出直譯器的名稱(從
↩#!
之後直到第一個空白前的所有東西),以及可能的引數(直到行尾的所有其他東西,並去除外部的空白)…… - 在 Linux 核心中,有關於「憑證」的設計可以分成幾個部分,此處的「
struct cred
」所實作的是行程憑證(task credentials),指的是當行程要進行各種行為,或者是成為任何操作的目標時,核心用以進行權限驗證所需的主要資料,詳細資訊可參考核心原始碼所附的文件Documentation/security/credentials.txt
。
↩ - 以 Linux 系統的術語來說,所謂的「personality」,是為了讓使用者在執行較舊版本 Linux 上的應用程式或是某些類 Unix 的平台上的程式時,提供相對應的執行環境(execution domain)所使用的,在 3.18 版本中,行程的 personality 記錄在結構
task_struct
之中的欄位personality
,其中有一部份的位元作為各種特殊相容特性的開關。
但在遇到像是設立了setuid
的工具程式時,由於各種考量(例如安全性),就會需要強制禁止某些特性的使用,此時就會將需要禁止使用的特性,記錄在欄位linux_binprm.per_clear
,並在適當的時機進行清除。
↩ - 值得一看的是,有一份 2.6.29 左右的 git commit,其主題雖然是描述當初自結構
linux_binprm
分離cred
的開發紀錄,但其中一併提到了在修改之後,核心在準備執行新程式時,對於行程憑證的處理流程。
↩ - local function 指的就是 C 的靜態函式(static function)。
↩ - 其實在本文中「miscellaneous」可以有兩種意思,一種是其形容詞原意「其他的」;另一種則是指此處所說的這個特殊格式,用來處理「所有其餘的格式」。雖然原文可利用英文的語意結構辨識出正確含意,但在中文我卻不知道該怎麼翻譯才好,為了避免混淆,此後,第一種狀況照常翻譯,第二種情況則採原文 miscellaneous。
↩ - 這個格式還有一個別稱叫「bFLT」,在 μClinux 上是常用的檔案格式。
↩ - 這裡有一篇文章,講述實際上如何使用這個機制。
↩
沒有留言 :
張貼留言