2016年12月10日 星期六

[翻譯] 程式是如何啟動的(上)

  • 原文標題: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 Interface1的第 27 章中也有完善的描述)。在 Linux 3.18 以前,唯一用來調用新程式的系統呼叫是 execve() 2,其原型如下:


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

引數 filename 指定要被執行的程式,而引數 argvenvp 則是以 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;這讓核心能讀取檔案內容並決定如何處理該檔案。

  • 欄位 filenameinterp 都設定為存有程式本身的檔案的名稱;我們會在之後知道為什麼這裡有兩個不同的欄位。

  • 函式 bprm_mm_init() 配置設定相關的資料結構 struct mm_structstruct vm_area_struct,準備用來管理新程式的虛擬記憶體。尤其新程式虛擬記憶體的末端會是在該架構下最高的可能位址,它的堆疊(stack)也將從此處往下延伸。

  • 欄位 p 設定成指向新程式虛擬記憶體的末端,不過會留出空間給做為堆疊末端標記的 NULL 指標,p 的值會隨著更多資訊加進新程式的堆疊而更新(向下)。

  • 欄位 argcenvc 分別設定成命令列引數和環境變數的值的個數,以便在調用流程中,將這些資訊在晚一點的時候傳播給新程式。

  • 欄位 unsafe 被設置來存放一組位元遮罩(bitmask),含有可能使程式執行變得不安全(safe)的原因;舉例來說,假如行程被 ptrace() 追蹤,或是被設定了位元 PR_SET_NO_NEW_PRIVS,隨後,Linux 安全模組(Linux Security Module,LSM)可能會據此資訊拒絕當次執行程式的操作。

  • 欄位 cred 是一個另外配置且型別為 struct cred 的物件,存有關於新程式的憑證(credentials)4資訊,這些通常會從呼叫 execve() 的程式繼承,但也能被更新以容許 setuidsetgid 位元以及其他複雜情況。而由於setuidsetgid 位元會對安全性帶來負面影響,因此在它們被設立的情況會禁止一組相容性功能;欄位 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 的實作則將此行為串接到它們自己對該攔截點的實作中(例:SmackSELinux)。

  • 塗抹空間(scratch space)buf 填充了程式檔案中第一個區塊(chunk,128 位元組)的資料,這資料會在稍後用來偵測其二進位格式類型,才能對程式檔案做適當的處理。

在這個設定流程中,依賴於被執行的那個檔案的部分,是在函式 prepare_binprm() 的內部進行的,之後,假如真正跑的是不同檔案(例:命令稿直譯器),此函式可再次被呼叫以更新那些欄位。

最後,關於程式調用的資訊會利用區域工具函式(local utility function7copy_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 裡來做調校及解釋:

(加上其他兩種特定於架構的格式。)

在下一節,要探討它們中最重要的:直譯命令稿和為了支援隨機格式的 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 撰寫。

譯註:


  1. 《The Linux Programming Interface》正體中文版由基峯出版社於 2016 年 10 月出版
  2. 有在 Linux 上撰寫過 C/C++ 程式的人,或許會覺得要執行新程式明明就還有 execl()execv()exec() 函式家族的成員可以使用,這裡的描述似乎有問題。其實,這裡要區分函式庫函式系統呼叫的不同,execve() 是系統呼叫,是應用程式與作業系統核心溝通的最底層介面;而 execl() 等則是 glibc 函式庫中,為了便於使用,而產生的包裹函式。
  3. 在原文後續的討論中,ID 為 wahern 的一位訂閱者發表了一篇評論,並獲得了原文作者的認同,其中提到關於這裡所謂「所有的額外選項」可能導致的誤解,其實在核心處理到這部份時,並沒有考量多個引數的可能性,而是將它們整個當成「一個」引數來處理,正如更後面的正文所述:
    ……在確認了前兩個位元組後,其程式碼解析命令稿調用列(script-invocation line)的剩下部分,從其中分離出直譯器的名稱(從 #! 之後直到第一個空白前的所有東西),以及可能的引數(直到行尾的所有其他東西,並去除外部的空白)……
  4. 在 Linux 核心中,有關於「憑證」的設計可以分成幾個部分,此處的「struct cred」所實作的是行程憑證(task credentials),指的是當行程要進行各種行為,或者是成為任何操作的目標時,核心用以進行權限驗證所需的主要資料,詳細資訊可參考核心原始碼所附的文件 Documentation/security/credentials.txt
  5. 以 Linux 系統的術語來說,所謂的「personality」,是為了讓使用者在執行較舊版本 Linux 上的應用程式或是某些類 Unix 的平台上的程式時,提供相對應的執行環境(execution domain)所使用的,在 3.18 版本中,行程的 personality 記錄在結構 task_struct 之中的欄位 personality,其中有一部份的位元作為各種特殊相容特性的開關。

    但在遇到像是設立了 setuid 的工具程式時,由於各種考量(例如安全性),就會需要強制禁止某些特性的使用,此時就會將需要禁止使用的特性,記錄在欄位 linux_binprm.per_clear,並在適當的時機進行清除
  6. 值得一看的是,有一份 2.6.29 左右的 git commit,其主題雖然是描述當初自結構 linux_binprm 分離 cred 的開發紀錄,但其中一併提到了在修改之後,核心在準備執行新程式時,對於行程憑證的處理流程。
  7. local function 指的就是 C 的靜態函式(static function)。
  8. 其實在本文中「miscellaneous」可以有兩種意思,一種是其形容詞原意「其他的」;另一種則是指此處所說的這個特殊格式,用來處理「所有其餘的格式」。雖然原文可利用英文的語意結構辨識出正確含意,但在中文我卻不知道該怎麼翻譯才好,為了避免混淆,此後,第一種狀況照常翻譯,第二種情況則採原文 miscellaneous。
  9. 這個格式還有一個別稱叫「bFLT」,在 μClinux 上是常用的檔案格式。
  10. 這裡有一篇文章,講述實際上如何使用這個機制。

沒有留言 :

張貼留言