一台機器,兩個世界
在第一篇導覽中我們說過,作業系統同時是三樣東西:資源管理者、抽象層,以及控制程式。現在我們要看它如何扮演好「控制」這一面。訣竅在於:作業系統其實並不信任你的程式,而硬體也幫著它保持這份戒心。一切的核心處坐鎮著 核心(kernel)——作業系統中永遠常駐記憶體的那一部分,它就像握有每個房間萬能鑰匙的大樓管理員,而房客(也就是你的應用程式)手上只有自己房間的鑰匙。
為什麼要這份不信任?因為若不然,單單一支有臭蟲或惡意的程式,就可能覆寫另一支程式的記憶體、永久霸佔 CPU,或是直接在磁碟上亂寫而毀掉所有人的檔案。因此現代 CPU 在兩個截然不同的世界裡運作。在其中一個世界裡,程式可以對硬體為所欲為;在另一個世界裡,它若不先請示,幾乎做不了任何危險的事。這道區分稱為 雙模式運作,而它是整個計算領域中最重要的一條界線。
模式位元:一個改變一切的開關
CPU 怎麼知道自己身處哪個世界?靠的是硬體暫存器裡的一個旗標,叫做 模式位元。按慣例,0 代表 核心模式(也叫監督者模式或特權模式),1 代表 使用者模式。你的應用程式碼以模式位元設為使用者模式的狀態執行;核心則以它清為核心模式的狀態執行。就這麼一個位元——整道圍籬就靠它。
這個位元之所以重要,是因為某些機器指令被標記為 特權指令:讓 CPU 停機、修改模式位元本身、載入掌管記憶體映射的表格、直接對磁碟控制器下命令。若使用者模式的程式碼試圖執行其中任何一條,CPU 不會照辦——它會引發一個例外,把控制權直接交給核心,而核心通常會把這支冒犯規矩的程式殺掉。所以程式無法乾脆自己翻動位元來授予自己權力;翻動它這個動作本身就是特權。唯一合法的上升管道,就是我們接下來要遇見的那道門。
跨越界線:系統呼叫
如果使用者程式不能碰硬體,那它究竟要怎麼讀檔案、怎麼在螢幕上印字?它請核心代勞。這個請求就是 系統呼叫,它是從使用者世界進入核心世界唯一獲准的那道門。關鍵在於:系統呼叫並不像一般的函式呼叫那樣,你指到哪它就跳到哪——那樣你就能落腳在核心裡的任何位置。取而代之的是,程式執行一條特殊的 陷阱指令——一個刻意而受控的軟體中斷,CPU 處理它的方式是切換到核心模式,並跳到核心事先選定、預先登記好的那一個固定進入點。
把它想成銀行的櫃台窗口。你不能走進金庫,但你可以從玻璃底下遞進一張單子:「請從 42 號帳戶給我 100 元。」櫃員(核心)會檢查你是否有權限,安全地替你完成那個危險的部分,再把結果交還給你。以下就是像 read(fd, buf, n) 這樣一通呼叫所經歷的旅程,一步一步來。
- 程式把這通呼叫的身分與引數放到核心預期的位置——呼叫編號(例如代表 read 的那個)放進一個暫存器,而檔案描述符 fd、緩衝區位址 buf、以及數量 n 則放進其他暫存器。
- 它執行陷阱指令。CPU 把模式位元切換到核心模式,並跳到核心那個固定的進入點——程式無權挑選自己落在哪裡。
- 核心讀取呼叫編號,在一張表格中查找它,然後執行相對應的處理常式。它會先驗證一切:fd 真的是一個已開啟的檔案嗎?buf 指向的記憶體,真的是這支程式被允許寫入的範圍嗎?
- 它執行那項特權工作——與裝置溝通、把位元組複製進你的緩衝區——接著把回傳值放進一個暫存器,把模式位元翻回使用者模式,然後從陷阱指令之後的位置恢復你的程式。從程式的視角看,它不過是呼叫了一個函式並拿到了答案。
有一個值得趁早釐清的細微之處:你在程式碼裡寫的 read(),通常並不是系統呼叫本身。它是你的語言或 C 函式庫提供的一層薄薄的包裝,替你把暫存器安排好並執行陷阱。這個區分——親切的 API 與底層的系統呼叫之別——正是為什麼同一支程式往往能在不同系統上執行:即使底下的陷阱細節有所不同,API 仍可保持一致。
共享機器:眾多程式,一顆 CPU
一旦核心安全地掌握了硬體,它就能做一件美妙的事:讓好幾支程式共用一台電腦。第一步是 多元程式規劃——同時把好幾項工作載入記憶體,這樣只要某項工作停下來等待緩慢的磁碟讀取,CPU 就立刻切換到另一項工作,而不是閒坐空轉。昂貴的 CPU 始終忙碌,整體吞吐量隨之飆升。
分時系統——也就是我們今天說的多工——又把這推進了一步。核心不再被動等待工作自己暫停,而是動用一個硬體計時器,每隔幾毫秒就觸發一次中斷。每一次滴答,核心都能把 CPU 從正在執行的程式手中奪走,交給另一支程式。這些切換快到讓你覺得音樂、瀏覽器和編輯器全都同時在跑。不過要誠實面對它的本質:在只有單一 CPU 核心的情況下,這是 並行(在時間上交錯地推進),而不是平行(字面意義上的兩件事在同一瞬間發生)。真正的平行需要不只一顆核心。
把一支程式的狀態——它的暫存器、它的程式計數器——保存起來,再載入另一支程式的狀態,好讓後者能從剛才中斷的確切位置繼續執行,這個動作就是 情境切換。這是核心悄悄替你的進度夾上書籤。它並非免費;每一次切換都耗去一點時間,且純屬額外開銷——這正是為什麼計時器的時間片段不會被切得無限細小的原因之一。
核心是怎麼蓋起來的
我們一直把「核心」當成一樣東西在談,但在 核心要如何結構化 這件事上,其實有真實的選擇。最古老、至今仍最常見的設計是 單核心:幾乎整個作業系統——行程排程、記憶體管理、檔案系統、裝置驅動程式、網路堆疊——全擠在一個龐大的特權程式裡。一切都在核心模式下執行,因此各部分能彼此直接呼叫,速度很快。Linux 就是著名的例子。風險在於:任何一個驅動程式裡的臭蟲,都帶著完整的權力在跑,足以讓整台機器當掉。
光譜的另一端是 微核心。在這裡,特權核心被縮減到最精簡——僅夠處理模式切換、基本記憶體與訊息傳遞。檔案系統、驅動程式,甚至部分記憶體管理,都被移出去成為一般的使用者模式行程,彼此靠互傳訊息來溝通。如今一個崩潰的驅動程式只不過是一支使用者行程死掉而已;核心會重啟它,整個系統照樣存活。代價是這些訊息必須不斷地跨越使用者與核心的界線,而這種行程間通訊比起核心內部的直接呼叫要來得慢。
MONOLITHIC MICROKERNEL +----------------------+ +-------+ user processes | app app app | | app app app | +----------------------+ | FS driver net | (user mode) | scheduler memory | +----------------------+ | file sys drivers | | sched mem IPC | (kernel mode) | net stack ... | +----------------------+ +----------------------+ tiny core; rest talks by messages big core, all in kernel mode
大多數真實系統落在中間,成為 混合核心:以一個大致上單核心的核心換取速度,但把某些服務模組化或推出去,融合了兩種哲學。Windows 與 macOS 大致就坐落在這裡。請抵抗那個誘人的結論——以為微核心單純就是「比較好」:它是以原始速度換取穩健,唯有當隔離性比 IPC 成本更重要時,它才是對的抉擇。天下沒有白吃的午餐——只有一個關於你願意付出什麼代價的選擇。