第 4 章 程序

本章講述什麼是程序, 以及 Linux 核心是如何創建, 管理和清除系統中的程序的.
在作業系統中, 程序是任務的執行者。 程式只是存貯在盤上的可執行映像裡面的機器
指令和資料的集合, 因此是被動的實體。 程序可以被看作正在運行的電腦程式。
程序是一個動態實體, 隨著處理器執行著機器指令而不斷變化。 除了程式中的指令和
資料之外, 程序中還包括了程式計數器, CPU 的所有暫存器, 堆疊(包含著像過程參數,
返回位址,保存的變數等臨時資料)。 目前正在執行的程式, 也就是程序, 含有微處理
器目前的所有活動。 Linux 是一個多重處理型的作業系統(multiprocessing, 或叫做多工)。
程序各司其職, 如果某個程序崩潰, 不會導致系統中別的程序崩潰。 每個程序在獨立
的虛擬位址空間中運行, 除非通過核心提供的安全的機制之外, 不能和別的程序相互作
用。
程序在其生命周期內要使用許多系統資源, 它要用 CPU 運行指令, 用實體記憶體存貯
指令和資料﹔ 它會打開並使用檔案系統中的檔案, 直接或間接使用實體設備。 Linux 必
須了解程序使用資源的情況以便合理地管理系統中的所有程序。 假如讓某個程序獨占大
部份系統實體記憶體或者 CPU, 對別的程序就不公平。
系統中最重要的資源是 CPU, 通常只有一個。 作為一個多重處理作業系統, Linux 的
目標是讓系統中的每個 CPU 上面始終有一個程序在執行, 以充份利用 CPU。 如果程序
數多於 CPU 數(通常總是這樣), 多余的程序必須等待有 CPU 空閒下來才能運行。
多重處理的想法很簡單: 讓程序一直執行直到它必須等待, 通常是等待使用一些系統
資源﹔ 當它可以使用這個資源時, 可以再讓它運行。 在一個單一處理的作業系統
(uniprocessing, 或叫做單工)中, 例如 DOS, CPU 在程序等待資源的時候將無所事事,
白白浪費時間。 在一個多重處理作業系統中, 記憶體中同時存在許多程序。 每當一個進
程必須等待, 作業系統就把 CPU 分配給別的需要運行的程序。 系統中專門有一個排程器
(scheduler)負責選出下一個要運行的程序。 Linux 使用很多排程策略來保証排程的公平。
Linux 支援很多不同的可執行的檔案格式, 比如 ELF , 還有 Java。 這些格式必須被透明
地管理。
4.1 Linux 程序
Linux 系統為了管理程序,用 task_struct 資料結構表示每個程序 (任務和程序 (task and
process) 在 Linux 中是可以互換使用的術語)。 任務向量(task vector)是一個指標陣列, 裡
面的指標指向系統中的每個 task_struct 資料結構。
這樣就意味著系統中的最大程序數受到任務向量的大小的限制﹔ 預設它有 512 個入口。
當創建新程序時, 新的 task_struct 從系統記憶體中被分配出來並被加入任務向量。 為了
便於尋找, 一個 current 指標指向目前的程序。
除了普通程序, Linux 還支援實時程序。 所謂實時是指這些程序必須能夠快速響應外
部的事件。 排程器會區別對待實時程序和普通程序。 盡管 task_struct 資料結構相當大,
而且很復雜, 但是其中能夠劃分出很多功能區域:
State (狀態)
程序執行時會根據不同的情形改變狀態。 Linux 程序有下列狀態:
1。Running 運行態
程序或者正在運行(它是系統的目前程序), 或者是準備運行的(它正在等待被分到系
統的 CPU 之一) 。
2。Waiting 等待態
程序正在等一個事件或一個資源。 Linux 中的等待態有性質不同的兩種類型:
interruptible (可中斷的)和 uninterruptible (不可中斷的)。 可中斷的等待程序能被信號打斷而
不可中斷的等待程序直接等待某種硬體條件, 在任何情形下都不能被中斷。
3。Stoped 停止態
程序被停止了, 通常是通過接受一個信號的方法。 被調試的程序能處於一個停止態。
4。Zombie 僵死態
某個已經終止的程序, 由於一些原因, 仍然在任務向量中占有一個 task_struct 資料
結構, 就處於僵死態。
Scheduling Information (排程信息)
排程程式需要這個信息以便相當決定系統中哪個程序最需要運行。
Identifiers (辨識器)
每個程序有一個程序辨識器。 程序辨識器不是任務向量的一個索引, 它就是一個數字
而已。 每個程序也有用戶和組辨識器, 它們是用來控制這個程序對系統中的檔案和設備
的存取的。
Inter-Process Communication (IPC, 程序間通訊)
Linux 支援 Unix 中古典的 IPC 機制, 如: signal (信號), pipe (管道) 和 semaphore (信
號燈),並且支援System V (Unix的一種較流行的標準版本)中的 share memory (共享記憶體
), semaphore (信號燈) 和 message queue (消息隊列)。 Linux 所支援的 IPC 機制在第 IPC
章中有詳細講述。
Links (連接)
Linux 系統沒有程序與別的程序完全無關。 除了初始化程序(init process)之外, 每個進
程都有一個父程序(parent process)。 新程序不是被憑空創造出來的, 它們是從已有的進
程拷貝得來, 或者是複製得來的。 代表程序的每個 task_struct 中都有指標指向它的父進
程, 兄弟程序(同一個父程序產生的程序之間是兄弟關系), 以及自己的子程序。 你能使
用 pstree 命令看到正在運行的程序的家庭關系, 下面是某次運行 pstree 命令得到的結
果:
init(1)-+-crond(98)
|-emacs(387)
|-gpm(146)
|-inetd(110)
|-kerneld(18)
|-kflushd(2)
|-klogd(87)
|-kswapd(3)
|-login(160)---bash(192)---emacs(225)
|-lpd(121)
|-mingetty(161)
|-mingetty(162)
|-mingetty(163)
|-mingetty(164)
|-login(403)---bash(404)---pstree(594)
|-sendmail(134)
|-syslogd(78)
`-update(166)
另外, 系統中有一個以初始化程序的 task_struct 資料結構為根的雙向鏈結串列, 把所有進
程都鏈接在裡面。 有了這樣的表, Linux 核心就可以方便地查看系統中的每個程序。 這
是為了支援 ps 和 kill 這樣的命令(分別是列出系統中的程序的命令和向程序發送信號的命
令(通常用於終止程序)).
Times and Timers (時鐘和定時器)
在程序的生命周期內, 核心記錄程序的創建時間並隨時記錄程序消耗的 CPU 時間。
每過一次時鐘滴答(tick)的時間, 核心就更新目前程序在系統模式和使用者模式所花的 CPU 時間
(以 jiffy 為單位)。 Linux 也支援程序特定的間隔定時器, 程序可以使用系統調用設置定時
器, 當定時器所設置的時間間隔已到, 核心就會給程序發送一個信號。 這些定時器可以
是一次性的或周期性地觸發。
File system (檔案系統)
程序可以打開和關閉檔案。 程序的 task_struct 中包含了指向打開的檔案的描述器
(descriptor)的指標, 還有兩個指向 VFS i節點(inode)的指標。 VFS i節點能夠唯一描述檔案
系統中的一個檔案或目錄, 它也是檔案系統所提供的統一的存取檔案的介面。 關於
Linux 系統中怎樣支援檔案系統, 請參看第章檔案系統。 第一個指標指向程序的根目錄
(程序的可執行映像檔案所在的目錄), 第二個指向程序的目前目錄或者叫 pwd 目錄(得名
於 Unix 中的 pwd 命令, 是 print working directory 之意。)。 VFS i節點中有一個域用來記
錄有多少個程序指向它們。 現在你明白為什麼當一個程序的 pwd 目錄是你想刪除的目錄
或者是這個目錄的一個子目錄的時候, 你就不能刪除它的原因了吧?
Virtual memory (虛擬記憶體)
大多數程序有一些虛擬記憶體(核心執行緒和精靈(daemon)除外), Linux 核心必須追蹤虛擬記憶體到系
統實體記憶體上的映射關系。
Processor Specific Context (處理器特定的上下文)
程序可以被看作是系統的目前的各種狀態的集合。 程序運行時要使用處理器的暫存器,
堆疊等等。 這就是所謂的程序上下文。 當程序被暫停時(暫時不再運行), 這個程序的
CPU 特定的上下文必須被保存到這個程序的 task_struct 中。 當程序被排程器重新啟動時,
它就從這裡恢復它的上下文。
4.2 Identifiers 辨識器
Linux 像所有的 Unix 一樣, 使用用戶(user)和組(group)辨識器在來檢查程序對系統中文
件或者映像的存取權限。 Linux 系統中的檔案都有所有權和許可權, 這些許可權描述了系
統中的用戶對那個檔案有什麼存取權限。 基本的許可權有讀(read), 寫(write)和執行
(execute), 它們被分派到3類用戶: 檔案的主人(owner), 屬於某個特定組的所有程序,
還有系統中的所有程序。 每一類用戶可以有不同的許可權, 例如: 一個檔案可以允許它
的主人讀寫, 允許檔案所在的組讀並且不允許系統中的其它程序存取。
Linux 系統中, 使用組就能夠把檔案的權限分配到一組用戶而不是簡單地到一個用戶或
到所有的程序。 例如, 你可以為一個軟體項目中的所有用戶創建一個組, 並且只允許這
個組中的用戶能夠讀寫該項目的原始程式。 程序能屬於若干組(預設最多能夠屬於32個組)。
每個程序的 task_struct 中有一個組向量(group_vector)來記錄這些組。 只要程序所屬的組
中有一個具有存取權限, 這個程序就有權存取那個檔案。
每個程序的 task_struct 中有4對用戶和組的辨識器:
1。 uid, gid
程序所代表的用戶(也就是啟動這個程序的用戶)的用戶辨識器和組辨識器。
2。 effective uid and gid (有效的 uid 和 gid)
有一些程式在執行的時候會把 uid 和 gid 改變為它們的自己的特定的某個 uid 和 gid
(這些程式的可執行映像檔案的 VFS i節點中有一個屬性規定了這樣的行為)。 這些程式被
稱為 "setuid" 程式。 它是限制系統服務(service)的權限一個方法, 尤其在實現為別的用戶
服務的網路精靈程式等類似的服務時很有用。 有效的 uid 和 gid 來自程式的映像檔案本身,
和啟動它的用戶無關。 核心在檢查權限的時候會使用有效的 uid 和 gid.
3。 file system uid and gid (檔案系統 uid 和 gid)
這兩個辨識器通常與有效的 uid 和 gid 一樣, 當檢查檔案系統存取權限時會用上。
這兩個辨識器是為了建立 NFS(Network File System, 網路檔案系統)而使用的, 因為用戶
模式的 NFS 伺服器需要像一個特別的程序一樣來存取檔案。 在這種情況下, 只有檔案
系統 uid 和 gid 被改變(有效的 uid 和 gid 不變)。 這樣可以防止惡意的用戶向 NFS 伺服器
發送 kill 信號。 Kill 信號會被以一個特別的有效 uid 和 gid 發送到程序。
4。 saved uid and gid (節省的 uid 和 gid)
這是 POSIX 標準中要求的兩個辨識器。 當然b序通過系統調用來改變 uid 和 gid 的時
候必須要用它們來保存真實的 uid 和 gid 。
4.3 Scheduling 排程
程序執行時總是一會兒在使用者模式下, 一會兒在系統模式下。 不同的硬體如何實現對這兩
種模式的支援不一定相同, 但是都有一種安全機制保証從使用者模式進入系統模式然後再回到
使用者模式。 使用者模式時程序的權限比較系統模式要小。 每當程序進行系統調用的時候就會從
使用者模式切換到系統模式, 然後繼續運行。 進入系統模式之後, 核心程式碼開始執行, 為這個
程序服務。 在 Linux 系統中, 程序不能從目前正在運行的程序那裡強占執行的權利。 當
執行的程序需要等待某個系統事件的時候, 它就讓出 CPU 。 例如, 程序可能等待從一
個檔案中讀出一個字元。 這個等待在系統調用內部, 處於系統模式﹔ 這時, 等待事件的
的程序將被核心暫停, 其它更著急的程序會被選中來運行。
程序總是要經常做系統調用所以就經常會這樣等待。 盡管如此, 如果程序願意, 它還
是可以長時間地不做系統調用從而不合理地占用 CPU 的處理時間。 因此, Linux 系統要
使用先佔式的排程。 在這種情況下, 每個程序被允許運行一小段時間, 比如 200ms,
如果時間到了, 核心就會暫停目前的程序, (不管它是不是願意), 選擇別的程序來運行。
這一小段時間就是所謂的 time slice (時間片)。
負責在系統中所有可以運行的程序中選擇最該運行的程序的核心部份是排程器。可以
運行的程序(runnable process)是指這個程序就在等待 CPU 來執行。
Linux 使用基於優先級的相當簡單的排程演算法在系統在目前的程序之間選擇。 當選擇了
新程序來運行時, 它保存目前的程序的狀態, 特定的處理器暫存器以及其它的上下文,
到這個程序的 task_struct 資料結構中。
然後它恢復新程序的狀態(這仍就是處理器相關的), 把系統的控制交給這個程序, 開始
運行它。 排程器為了能夠公平地分配 CPU 時間, 它在每個程序的 task_struct 中保存了下
列信息:
policy (策略)
在這個程序上使用的排程策略。 Linux 程序有兩種類型, 普通和實時。 實時程序比其
它所有的程序的優先級都要高。 如果有實時程序可以運行, 它將總是首先運行。 實時進
程有2種排程策略, Round Robin (輪轉式)和 First in First out (先入先出式)。在輪轉式排程
下, 每個 runnable 的實時程序輪流運行﹔ 在先入先出式排程下, 每個 runnable 的程序依
次運行, 次序就是它們進入運行隊列式的順序, 而且不會變化。
priority (優先級)
程序的優先級。它也是這個程序被允許運行的時間的總量(以 jiffy 為單位)。 通過系統調
用和 nice 命令能夠改變程序的優先級。
rt_priority (實時優先級)
實時程序的優先級高於其他類型的程序。 這個域允許排程器給每個實時程序以相對的
優先級。 實時的程序的優先級可以通過系統調用來改變。
counter (計數器)
這是該程序被允許運行的時間的總量(以 jiffy 為單位)。 程序第一次貽d始運行時, 這個
值就被設定為優先級的大小, 每次時鐘中斷一次, 這個數值就被減小。
核心內若干地方會運行排程器。 把目前的程序放入等待隊列後會運行排程器﹔ 在系統
調用結束, 即將返回到使用者模式的時候, 也可能會運行。 如果系統定時器把目前的程序的
counter 減小到了零, 它也需要運行。 排程器運行時, 需要做的事情是:
kernel work (核心工作)
排程器運行 bottom half handler (一種延遲處理任務的機制)並處理排程器的任務隊列。
關於 bottom half handler 以及這些輕量的核心執行緒在 第11章 核心機制 中有詳細講解。
process current process (處理目前程序)
在選擇其它程序運行之前,必須處理目前程序。 如果目前程序的排程策略是 Round
Robin (輪轉式), 它就被放到運行隊列的末尾,
如果任務是可以被中斷的(INTERRUPTIBLE), 並且自從最後一次排程它之後, 它收到
了一個信號, 那麼就設置它的狀態為 RUNNING(運行)。
如果目前的程序執行超時了,那麼它的狀態變為 RUNING。
如果目前的程序就是 RUNNING,它將保持這個狀態。
不處於 RUNNING 態並且也不是 INTERRUPTIBLE 的程序就被移出運行隊列。 這意味著
當排程器要尋找最需要運行的程序時, 不再會考慮它們。
Process selection (程序選擇)
排程器尋找整個運行隊列來選擇最需要運行的程序。 如果有實時程序(排程策略是實時
的那些), 它們就會獲得比普通程序更高的權重量。 正常的程序的權重是它的 counter (或
者優先級), 而實時程序是 counter 加 1000。 這說明如果系統中有處於 runnable 狀態的實
時程序, 它們就會比普通的 runnable 的程序先執行。 目前的程序, 因為已經執行了一段
時間, 經過了若干時間片, 它的 counter 就被減去了一些, 所以如果有同樣優先級的進
答7b的話, 它就要讓位了。 這正是需要的。 如果若干程序有同樣的優先級, 在隊列前
面的被先選中。 目前程序會被放到隊列的最後。 在有許多優先級相同的程序的平衡的系
統中, 它們會被輪流運行。 這就是稱為 Round Robin 的排程方案。然而, 程序會等待資
源, 它們的運行順序就會發生變化。
Swap processes (交換程序)
如果最需要運行的程序不是目前程序, 目前程序就必須被暫停, 新的程序將取而代之。
程序在運行時, 它在使用 CPU 的暫存器和系統的實體記憶體。 調用過程時, 它用暫存器
傳遞參數, 並且可能需要把返回位址放在堆疊中。 因此, 當排程器運行時它是在目前進
程的上下文(Context)中。 這時, CPU 處於特權態下, 也即核心模式, 但是正在運行的仍
然是目前程序。 如果要暫停它, 就必須把它的上下文保存進它的 task_struct 資料結構中。
然後, 新程序的機器狀態的必須被裝載。 這是和具體的系統相關的操作, 各種 CPU 的
做法很不相同, 但是通常有一些硬體輔助來做這件事。
程序上下文的切換在排程器運行結束時進行。 所切換的上下文是與被排程程序有關的
硬體環境在此時的一個快照。
如果剛才的程序或新的目前程序使用虛擬記憶體, 系統的頁表的項目可以需要更新。 同樣地,
這是和特定的機器體系結構相關的。 像 Alpha AXP 這樣的處理器, 使用 Look-aside
Tables (轉換對照表)或者 cached Page Table Entries (緩衝頁表項), 必須刷新那些屬於先
前程序的表項。
4.3.1 多處理機系統中的排程
多個 CPU 的系統在 Linux 世界中是相當稀罕的, 但是 Linux 系統中已經做了很多工作
使其成為一個 SMP(Symetric Multi Processing 對稱多處理) 作業系統。 那就是說, 有能力
在系統的 CPU 之間平衡工作。 在排程器中做這種工作是最合適的。
多處理器系統中, 理想的情況是所有處理器均忙於運行程序。 每當一個 CPU 的目前
的程序用盡它的時間片或必須等一個系統資源, 就會單獨運行排程程式。 關於一個
SMP 統要注意的第一事情是系統中不止存在一個空閒的程序。 在單處理器系統中空閒的
程序是在任務向量的第一任務, 在一個 SMP 系統中每個 CPU 都有一空閒的程序, 並且
你可能有不止一個空閒的 CPU 。另外每個 CPU 有一個目前程序, 因此 SMP 系統必須追
蹤每個處理器上的目前程序和空閒程序。
SMP 系統中每個程序的 task_struct 包含它目前正運行在上面的處理器的數字
(processor)以及上次運行在上面的處理器的數字(last_processor)。 雖然程序可以每次在不
同的 CPU 上面運行, Linux 可以使用 processor_mask 來限制程序可以使用的 CPU 。 如
果 processor_mask 的第 N 位被設置, 這個程序就能在處理器 N 上運行。 當排程器選擇
新程序, 它不會選擇 processor_mask 中和目前的處理器對應的位被清除的程序。 排程器
會略微照顧上次在這個處理器上面運行的程序, 因為把程序在不同的處理器之間移動通
常會帶來一定的性能損失。
4.4 Files (檔案)
圖 4.1 :程序的檔案
圖 4.1 表明系統中的每個程序有2個資料結構描述檔案系統相關的信息。
第一, fs_struct, 包含指標指向程序的 VFS i節點 和它的 umask 。 umask 是創建新檔案
時使用的預設模式, 可以用系統調用改變。
第二, files_struct, 包含程序目前正在使用的所有檔案的信息。 然b序從 standard input
(標準輸入) 讀並且寫到 standard output (標準輸出)。 任何錯誤消息應該輸出到 standard
error (標準錯誤)。 這些可以是檔案, 終端輸入/輸出或一台真實的設備, 但是程式都把它
們當作檔案。 每個檔案有它的自己的 descriptor (描述器), files_struct 中包含可以指向
256 個檔案資料結構的指標, 每個可以描述程序打開的一個檔案。f_mode 描述檔案是以
什麼模式被創建的:只讀, 讀寫 或者 只寫。 f_pos 記錄下一個讀或寫操作的位置。
f_inode 指向描述該檔案的 VFS i節點, 而 f_ops 是一個指向例程位址的向量的指標, 每
一個例程實現你希望在檔案上做的一個操作, 例如, 一個寫資料的例程。 這種對界面的
抽像非常有用, 允許 Linux 系統支援各種各樣的檔案類型。 我們以後就會看到, Linux 中
的 pipe (管道) 就是用這個機制實現的。
每打開一個檔案, 在 files_struct 的一個空閒的檔案指標被用來指向新檔案結構。 Linux
程序啟動的時候, 會有 3 個檔案描述器已經打開, 它們是標準輸入, 標準輸出和標準錯
誤, 通常都是從父程序中繼承來的。 所有的檔案存取都要使用系統調用, 它們使用或者
返回 file descriptor (檔案描述器)。 檔案描述器是到程序的 fd 向量的索引, 所以標準輸入,
標準輸出和標準錯誤的檔案描述器是 0 ,1 和 2 。 檔案的每次存取都要使用檔案資料結
構的檔案操作例程和 VFS i節點。
4.5 虛擬記憶體
程序的虛擬記憶體包含從許多來源來的可執行的程式碼和資料。
首先, 程式映像被裝載。 例如 像 ls 一樣的命令。 這個命令, 像所有的可執行的映像一
樣, 都由可執行程式碼和資料組成。 映像檔案包含裝載可執行的程式碼以及有關的程式資料
到程序的虛擬記憶體所需的全部信息。
第二, 程序運行時能分配(虛擬)記憶體, 比如說保留它正在讀的檔案的內容。 這最新
分配的, 虛擬的記憶體要被連接程序序的已有的虛擬記憶體才能使用。
第三, Linux 程序通常使用的公用程式碼函式庫, 例如處理檔案的例程。 每個程序有函式庫的自己
的拷貝, 這很不明智。 Linux 使用能同時被若干運行的程序使用的共享函式庫。 共享函式庫的
程式碼和資料必須被連接到共享這個函式庫的多個程序的虛擬位址空間。
在任何給定的時間段內, 程序不會使用在它的虛擬記憶體中包含的所有程式碼和全部資料。 它
可以包含僅僅在某些狀況下被使用的程式碼, 例如在初始化期間或一個特別的事件發生時。
它可能僅僅使用了從共享函式庫連接的一些例答7b。 裝載這些無用的東西進實體記憶體, 實
在是一種浪費。 考慮到系統中同時存在多個程序, 這將使系統很低效地運行。 為此,
Linux 使用 demand paging (請求換頁) 技術, 僅僅當程序試圖存取某頁時, 才把它裝入物
理記憶體。 因此, Linux 核心只要改變程序的頁表, 把虛擬的空間標明為存在但是不在內
存中就行了, 而不需要直接裝載程式碼和資料進實體記憶體。 當程序嘗試存取這裡的程式碼
或資料時, 系統硬體將產生 page fault (頁錯) 並且把控制傳遞給 Linux 核心來處理。 因此,
Linux 核心需要知道程序的虛擬位址空間的各個區域是從何處來的以及如何把它裝入記憶體,
這樣才能處理 page fault。
圖 4.2 :程序的虛擬記憶體
Linux 核心需要管理虛擬記憶體的所有這些區域。 程序的虛擬記憶體的內容在 mm_struct 資料結構中
描述, 程序的 task_struct 有指標指向這個結構。 程序的 mm_struct 資料結構也包含已裝
載的可執行的映像的信息, 還有到程序的頁表的指標。 程序的頁表包含一些指標, 指到
vm_area_struct 資料結構的一個表, 每個表示程序虛擬記憶體的一個區域。
這張鏈接的表是按虛擬記憶體位址升序鏈接的, 圖 4.2 顯示了一個簡單程序的虛擬記憶體的概觀以
及管理它的核心資料結構。 因為虛擬記憶體中的那些區域從若干來源, Linux 讓 vm_area_struct
指向一套處理虛擬記憶體的抽像介面的例程(經由vm_ops)。 這樣不管管理那記憶體的內在的服
務怎麼不同, 程序的所有虛擬記憶體都能用一致的方法處理。 例如有一個例程在程序試圖存取
記憶體並且它不存在時, 將被調用, 這就是用來處理 page fault 的。
程序的 vm_area_struct 資料結構的會被 Linux 核心很頻繁地調用。 這就使得尋找到
vm_area_struct 結構的時間對系統性能影響很大。為了加快存取, Linux 另外把
vm_area_struct 資料結構排列成一個 AVL (Adelson-Velskii 和 Landis)樹 (也稱平衡樹)。 這
棵樹上, 每個 vm_area_struct (或節點) 有一左一右兩個指標指到它的鄰近的
vm_area_struct 結構。 左指標指向的節點虛擬位址小於右指標指向的節點。尋找正確的節
點時, Linux 從樹根開始, 根據每個節點的左右指標指向的位址的大小關系決定向何處去
找, 直到找到為止。 當然, 沒有免費的午餐, 把一個新的
vm_area_struct 插入到這棵樹要花一些額外的處理時間。
當程序分配虛擬記憶體時, Linux 實際上不為程序保留實體記憶體。 相反, 它創建新的
vm_area_struct 資料結構描述虛擬記憶體, 再連接程序序的虛擬記憶體的表。 當程序試圖在那個新虛
存區域以內寫時,系統將發生 page fault (頁錯)。 處理器將試圖進行虛擬地冶d譯碼, 但
是因為這塊記憶體的沒有頁表入口, 它將失敗並引發 page fault 異常, 讓 Linux 核心來處
理。 Linux 檢查引用的虛擬的位址是否在目前的程序的虛擬的位址空間。 如果是, Linux
創造適當的 PTEs 並且為這個程序的分配實體記憶體的一頁。 程式碼或資料可能需要從文
件系統或從交換磁碟拷貝到那個實體頁。 程序然後在引起了 page fault 的指令處被重啟並
且, 這次因為記憶體實體上存在, 它可以繼續運行。
4.6 創建程序
當系統啟動時, 它在核心模式運行並且有,從某種意義上說, 僅僅一個程序, initial
process (初始程序)。 像所有的程序一樣, 初始程序的機器狀態由堆疊, 暫存器等等表示。
當系統的另外的程序被創建並運行時, 這些將在初始程序的 task_struct 資料結構被保存。
系統初始化結束時, 初始程序啟動一個核心執行緒(叫 init) 然後進入一個無事可做的空閒循
環。 當沒有別的事情做時, 排程器將運行這個空閒程序。 空閒程序的 task_struct 是唯一
一個不被動態地被分配的, 當構造核心的時候, 它就靜態地在核心裡面定義並且被叫做
init_task, 相當含糊。
Init 核心執行緒或程序的程序辨識器為1, 是系統的第一個真正的程序。 它做一些系統初
始化設置工作(例如打開系統控制台, 安裝根檔案系統)然後運行系統初始化程式。 這個
程式是 /etc/init, /bin/init 或者 /sbin/init, 與你的系統有關。 init 程式使用 /etc/inittab 作為腳本
檔案來創建系統中的新程序。 這些新進□
'7b可能還要再創建新程序。 例如, 當用戶試圖登錄時, getty 程序可能會創建 login 程序。
所有這些程序都是 init 核心執行緒的後代。
新程序通過複製舊程序,或複製目前程序來創建。 一個新任務通過系統調用(fork 或
clone)來創建。 複製在核心模式由核心來完成。 在系統調用結束時如果排程器選擇了新進
程, 新程序就可以運行了。 新的 task_struct 資料結構在系統實體記憶體中分配, 而且有一
頁或多頁實體記憶體頁被用來作為複製程序的堆疊(用戶堆疊和核心堆疊)。 新的程序辨識器
被創建, 它在系統內唯一。 但是有理由讓複製出來的程序記住它的父程序。 新的
task_struct 被加入 task vector (任務向量), 老程序的 task_struct 的內容被複製到複製的進
程的 task_struct.
當複製程序時, Linux 允許兩個程序共享資源而不是各自複製一份。 這包括程序的文
件, 信號處理程式, 以及虛擬記憶體。 當資源被共享時, 各自的計數域將被增加, 這樣當兩
個程序全部釋放資源的時候 Linux 才會回收它。
複製程序的虛擬記憶體比較困難。 新的 vm_area_struct 資料結構集合要被創建, 還有它們所
擁有的 mm_struct 資料結構, 以及被複製的程序的頁表。 這時還沒有程序的虛擬記憶體的內容
被複製。 這可能是個很困難的工作因為有的虛擬記憶體在實體記憶體, 有的在可執行映像裡, 有
的在交換檔裡。 為此, Linux Linux 使用稱為 "copy on write" (寫時複製) 的技術, 具體
做法是當其中一個程序試圖寫共享虛擬記憶體時才進行複製。 實現的方法是把可寫的記憶體區域
在頁表中標為 "read only" (只讀), 在 vm_area_struct 資料結構中標為 "copy on write "。 當
某個程序試圖寫時, 就會發生 page fault, 此時 Linux 就進行記憶體的複製, 並修改頁表和
虛擬記憶體的資料結構。
4.7 時間和定時器
在程序的生命周期內, 核心記錄進程的創建時間並隨時記錄程序消耗的 CPU 時間。
每過一次時鐘滴答(tick)的時間, 核心就更新目前程序在系統模式和使用者模式所花的
CPU 時間(以 jiffy 為單位)。 除了這些用於記賬的定時器之外, Linux 也支援程序特定的
間隔定時器, 當定時器所設置的時間間隔一到,核心就會給程序發送信號。有3種間隔定時器:
Real (實時)
定時器實時地走動。 當定時器到時, 程序會收到一個 SIGALRM 信號。
Virtual (虛擬)
當程序正在運行時定時器才走。 如果到時, 這個定時器會發送一個 SIGVTALRM 信號
給程序。
Profile (活動總計)
當程序正在運行時或者當系統代表程序在執行時, 這個定時器就走動。 它會發送
SIGPROF 信號。
Linux 系統把間隔定時器的信息存放在程序的 task_struct 資料結構中。通過系統調用能
夠添加定時器, 啟動, 停止以及讀取定時器的目前的時間。
每當系統的時鐘的一次滴答到來, 目前程序的所有間隔定時器的計數值就被減少, 如
果時間間隔已到, 就會發送相應的信號給程序。
實時間隔定時器有點特別。 Linux 在核心中使用了定時器機制來處理它。 每個程序有
自己的 timer_list 資料結構, 當實時間隔定時器運行時, 系統的 timer list (定時器列表)中把
它排入了隊列。 當定時器的時間間隔一到, 負責處理定時器事件的 bottom half handler 會
把它從隊列中刪除, 然後調用調用間隔定時器的處理器(並不是 CPU, 而是一段程式碼)。 這
個處理器這就產生了 SIGALRM 信號並且重啟間隔定時器, 又把它加入系統定時器隊列。
請參看 第11章 核心機制 中的具體講解。
4.8 Executing programs 執行程式
像 Unix 系統一樣, Linux 系統中的程式和命令通常是由一個命令解釋器來執行的。 一
個命令解釋器是一個用戶程序, 一般被稱為 shell , 因為它就像是系統的外殼, 被用戶
直接感受到。
Linux 系統中有許多命令解釋器, 最流行的一些是 sh, bash 和 tcsh 。 除了一些內部命
令之外, 例如 cd 和 pwd , 一個命令就是一個可執行的二進制的檔案。 對每個輸入的命
令, 命令解釋器在程序的搜索路徑中指定的目錄中尋找能夠匹配的可執行的映像檔案。
搜索路徑由 PATH 環境變數定義。 如果找到了匹配的檔案, 它就被裝載執行。
命令解釋器使用上面說的 fork 機制複製自己。 新的子程序用所找到的可執行的二進制
映像檔案的內容替換自己原先的內容, 也就是命令解釋器自身。 通常命令解釋器等待命
令完成, 也就是等待子程序退出。 你能讓命令處理器不要等待, 只要把子程序放到背景
運行就可以做到。 使用 control-Z 組合鍵, 它會導致一個 SIGSTOP 信號被送給子程序,
讓它暫停。 然後你可以用 shell 命令 bg 把它放到背景。 命令解釋器向它發送一個
SIGCONT 信號讓它恢復運行, 它將一直在哪兒, 直到運行結束或者它需要做終端輸入或
輸出。
一個可執行的檔案能有許多格式或甚至是一個腳本檔案。 腳本檔案必須被識別出來並
且用適當的解釋器來處理。 例如 /bin/sh 解釋 shell 腳本。 可執行的目標檔案中包含可執
行的程式碼和資料, 以及足夠的信息以便作業系統能夠裝載並運行。 Linux 系統中使用的最
多的目標檔案格式是 ELF (參見下面的小節)。 但是理論上, Linux 靈活到幾乎能處理任何
格式的目標檔案。
圖 4.3 :註冊的二進制格式
就像檔案系統, 格式由 Linux 支援了的二進制程式碼是在核被造了進核的任何一個造時
間或可得到作為模組被裝載。核堅持支援二進制的
格式的一張表 ( 參見圖 4.3 ) 並且當被嘗試執行一個檔案時,每二進制的格式接著被試用
直到一個人工作。
通常被支援了的 Linux 二進制程式碼格式是 a.out 和ELF 。可執行的檔案不必須完全被讀進
記憶體, 作為裝載的需求被知道的技術被使用。
當可執行的圖像的每部份被程序使用,它被使記憶體。圖像的閒置的部份可以從記憶體
被丟棄。
4.8.1 ELF
ELF (Executable and Linkable Format) 目標檔案格式, 由 Unix 系統實驗室所設計,是
Linux 系統中最常用的格式。 雖然同其它的目標檔案格式, 例如 ECOFF 和 a.out, 比較,
ELF 在性能上略有損失, 但 ELF 更靈活。 ELF 可執行檔案中包含可執行的程式碼(有時稱
為正文(text)), 還有資料。 除此之外, 還有表說明程式應該怎樣被放程序序的虛擬記憶體。 靜
態連接的映像可以用連接器(ld)構造, 或用連接編輯器, 結果成為一個包含運行時所需的
全部程式碼和資料的單個的映像。 映像中還說明了映像在記憶體中的概觀, 以及第一條指令
在映像中的位址。
圖 4.4 :ELF 可執行檔案檔案格式
圖 4.4 顯示了一個靜態連接的 ELF 可執行的映像的內部概觀。
這是一個簡單的 C 程式, 打印 "Hello, world!" 然後結束。 檔案頭說明它是一個 ELF 映
像, 在檔案頭起始的 52 個位元是 2 個實體的頭。 第一個實體頭中指示在映像中的可執
行的程式碼。 程式碼起始於虛擬位址 0x8048000 , 有 65532 個位元。 因為它是為 printf 包含
函式庫程式碼的所有的一幅靜態地被連介面的圖像,這是 () 輸出"你好世界"的呼叫。 映像的
入口點, 也就是程式的第一條指令, 不在映像的開始, 而是在虛擬位址 0x8048090 (
e_entry ) 處。 程式碼緊跟在第二實體的頭之後。 這個實體頭說明程式的資料, 要在裝在虛
位址 0x8059BB8 處。 資料是可讀可寫的。 你會注意到在檔案中的資料塊的大小是 2200
個位元( p_filesz ), 而在記憶體中所占的大小是 4248 個位元。 這是因為第一個 2200 個位元
包含預初始化的資料而隨後的 2048 個位元包含將由執行的程式碼來初始化的資料。
當 Linux 裝載 ELF 可執行檔案映像到程序的虛擬位址空間時, 它實際上沒有真的裝載
映像。
它設置虛擬記憶體資料結構, 程序的 vm_area_struct 樹和它的頁表。 當程式執行時, 頁差錯
(page fault)將導致程式的程式碼和資料被裝進實體記憶體。 程式中沒用到的部份的部份決不會
被裝載進記憶體。 當 ELF 二進制格式裝載器檢驗認為這個映像確實是一個 ELF 可執行映
像後, 它就從程序的虛擬記憶體中刷新目前的可執行映像。 因為這個程序是一個複製的映像(所
有的程序都這樣) 這舊映像就是父程序正在執行的程式。 刷新導致舊的虛擬記憶體資料結構被廢
棄, 程序的頁表被重新設置。 它也清除所有的信號 handler, 關閉已經打開的檔案。 刷
新過後, 程序就可以用新的可執行映像了。 不管可執行的映像是什麼格式的, 程序的
mm_struct 中需要設置同樣的信息。 有指向映像的程式碼和資料的開始和結束的指標。 這
些值在讀入 ELF 可執行映像的實體頭時被得到, 它們所說明的程式段被映射到程序的虛
擬位址空間。 此時, vm_area_struct 資料結構被設置, 程序的頁表也被修改。
mm_struct 資料結構中還包含指標指向傳遞給程式的參數以及程序的環境變數。
ELF 共享函式庫
反之, 一個動態連接的映像, 並沒有包含運行所必需的全部程式碼和資料。 部份程式碼和
資料在共享函式庫裡, 當映像執行的時候會被連接進來。 這時, ELF 共享函式庫的表也被連接進
了映像。 Linux 使用若干動態的連接器, ld.so.1 , libc.so.1 和 ld-linux.so.1 , 都存放在
/lib序目錄下。函式庫中包含公用的程式碼, 比如語言的幾程式。 如果沒有動態連接, 所有的程式需
要把函式庫中的這些程式碼各自複製一份, 這樣會需要多得多的磁碟空間和虛擬記憶體。 有了動
態連接, 每個被引用到的幾程式都在 ELF 映像的表中保存了信息, 動態連接器根據這個
信息知道怎樣找到函式庫中的程式碼並把它連接到程式的記憶體空間。
4.8.2 腳本檔案
腳本檔案是需要一個解釋器來運行的可執行檔案。 有各式各樣的解釋器可以在Linux中
使用, 例如 wish, perl 和命令處理程式比如 tcsh 。 Linux 使用標準的 Unix 習慣, 就是在
腳本檔案的第一行中包含解釋器的名字。 因此, 一個典型的腳本檔案將這樣開頭:
#! /usr/bin/wish
為了找到腳本指定的解釋器, 腳本二進制程式碼裝載器試圖打開在腳本檔案的第一行中
指名的可執行的檔案。 如果能打開它, 就讓這個檔案, 也就是一個解釋器, 來執行這
個腳本。 腳本檔案的名字成為參數零(第一參數)並且所有其它的參數向後移動一個位置
(原來第一參數成為新的第二參數, 依此類推)。 裝入解釋器的方法和 Linux 中裝入一個
可執行檔案的方法是一樣的。 Linux 試用每一種二進制格式直到某個格式能夠成功為止。
這樣, 從理論上, 你能夠安排若干個解釋器以及二進制格式, 使 Linux 的二進制格式處
理器變得非常靈活。