JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

使用者模式、核心模式,以及兩者之間的那條線

你的程式不被允許直接碰硬碟、網路,或另一個行程的記憶體——而這是刻意設計的。本篇要畫出整個系統中最重要的那一條線:你的程式碼與核心之間的界線,硬體為什麼要強制執行它,以及你想跨過去時,必須去敲的那道窄門。

你的程式是「保險栓扣著」在跑的

到現在,你已經能讀懂 CPU 實際在做什麼了:它走過一串指令,一次一小步地,在暫存器記憶體之間搬動位元組。但先前那幅圖悄悄藏起了一件事。當你的程式想讀一個檔案、把字印到螢幕上,或開一條網路連線時——你的二進位檔裡,沒有任何一條指令做得到這些。你的程式碼所跑的那顆 CPU,刻意地處在一種受限的模式中,在這模式裡,那些危險的指令——也就是會去跟硬體對話的那些——對你來說根本不會動。你的程式是「保險栓扣著」在跑的,而且它沒辦法靠自己把保險栓扳開。

這種受限的模式有個名字:使用者模式(user mode)。另外就只有一種模式,叫核心模式(kernel mode),在這模式裡每一條指令都被允許,程式碼可以碰任何硬體、任何記憶體、什麼都可以。這個區別不是某種你可以耍嘴皮繞過去的軟體慣例——它是 CPU 裡頭一個單一的狀態位元,由硬體本身設定,用來把關哪些指令是合法的。你的 shell、你的編輯器、你的遊戲,乃至此刻這支程式:它們全都跑在使用者模式裡。唯一跑在核心模式裡的軟體,是核心(kernel),也就是作業系統的核心部分。整場遊戲就是這樣,而本階接下來,講的都是這兩個世界之間的那道門。

為什麼要把權力交給一個裁判

誠實面對這道牆為什麼存在,是值得的,因為一開始它感覺就像作業系統只是在擋你的路。想像一台機器,上面每支程式都跑在核心模式裡。一支帶著野指標的程式,可以覆寫另一支程式的記憶體;一個有臭蟲的迴圈,可以霸佔住 CPU 永遠不還;任何一個行程,都能從另一個行程的位址空間裡直接讀出你的密碼;單一一次崩潰,就會把整台機器一起拖垮。那會沒有任何隔離、沒有任何公平,因為根本沒有任何人有權威去強制執行它們。這道牆之所以存在,是為了讓一支有臭蟲、或懷有惡意的程式,最多只能傷到它自己。

於是核心把自己立為唯一的裁判。正因為只有它跑在核心模式裡,也就只有它能決定:誰拿到 CPU 的哪一塊時間、一個行程可以看到實體記憶體的哪些分頁,以及這支特定的程式到底有沒有被允許去開那個特定的檔案。這就是核心扮演資源管理者的角色:機器裡每一樣稀有的、可共享的、或危險的東西——處理器、記憶體、硬碟、網路卡——都被圍在它後面,而你要碰到其中任何一樣,唯一的辦法就是去「請求」。代價是,請求並不免費,我們會在後面的某一篇正面面對這個代價;而它換來的好處,就是上面講的一切。

在大多數 CPU 上,使用者/核心的這道分界,是一套稍微更細緻的機制裡看得見的那一部分,這套機制叫權限環(privilege rings)——x86 把它們編號為 ring 0(權限最高)到 ring 3(最低),實務上核心住在 ring 0,你的程式住在 ring 3。中間那幾環確實存在,但主流系統很少用到,所以本階接下來的一切,你都可以維持那幅簡單的兩層圖像:一個有特權的「裡面」、一個沒特權的「外面」,中間夾著一個有人看守的關口。

那道門:系統呼叫

那麼,一支使用者模式的程式,到底是怎麼把事情做成的?答案是:透過一道窄窄的、受控的門,叫做系統呼叫(system call,常簡寫成 syscall)。系統呼叫是一種機制,讓你的程式能請求核心代替它去執行某個有特權的動作——從那個檔案讀出這些位元組、給我更多記憶體、啟動一個新的行程。關鍵在於,系統呼叫並不是一個普通的函式呼叫。當你呼叫自己寫的某個函式時,CPU 只是跳到另一個位址、然後繼續在使用者模式裡跑下去。系統呼叫做的事不一樣:它執行一條特殊的指令,把 CPU 翻轉進核心模式,並把控制權交給核心裡一個固定的進入點——它跨過了那條線。

這個區別是本階最重要的一個觀念,所以讓它沉澱下來。一個普通的函式呼叫和一個系統呼叫,在你的 C 原始碼裡看起來可以一模一樣——兩者都是 `name(args)`——但在底下,它們是天差地遠的兩種生物。函式呼叫一直待在牆的你這一側,代價是幾奈秒。系統呼叫則是一道有人看守的邊境關口:它陷入(trap)核心、切換權限、讓核心檢視你的請求並把事做完,然後帶著一個結果把你放回使用者模式。我們會在緊接著的幾篇裡,把這道跨越一條指令、一條指令地拆開來看;現在,先把它的「形狀」記住就好。

  USER MODE (ring 3)              |   KERNEL MODE (ring 0)
  -----------------------------   |   ---------------------------
  your code:                      |
      n = read(fd, buf, 100);     |
          |                       |
          | (special trap instr)  |
          +-------- crosses ----------->  kernel entry point
                                  |          checks fd, copies bytes,
                                  |          does the privileged work
          +-------- returns ------<----  flips back to user mode
          v                       |
      n == bytes read, or -1      |
一次 read() 呼叫:控制權透過陷入離開使用者模式,核心做完有特權的工作,控制權再帶著結果返回。虛線那道就是權限邊界;只有核心那一側可以碰硬體。

libc:你其實一直在用的那層友善包裝

如果一個系統呼叫需要一條特殊的陷入指令,那你又是怎麼一直把 read()、open()、write() 當成普通函式在呼叫的?因為它們幾乎就是普通函式。對幾乎每一個系統呼叫,C 標準函式庫——libc——都附上一個同名的、微小的、再普通不過的 C 函式,替你處理那些瑣碎的部分:它把你的引數放進核心所預期的那幾個確切暫存器裡、執行那條陷入指令,再把結果交回給你。這個薄薄的黏合函式,就是 libc 在扮演系統呼叫包裝器。當你寫下 `write(1, "hi\n", 3)` 時,你呼叫的是一個普通的 libc 函式,而那個函式,才是真正跨進核心的東西。

這就是為什麼這兩者很容易被糊在一起,也是為什麼在你腦中把它們分清楚會很有價值。一個像 printf() 或 malloc() 這樣的函式庫呼叫,完全跑在使用者模式裡,甚至可能從頭到尾都不去煩核心。一個系統呼叫,本身就是那道跨越核心的動作。讓人困惑的是,一個函式庫呼叫底下常常藏著好幾個系統呼叫(或一個都沒有):printf() 會先在使用者模式的記憶體裡把你的文字格式化好,然後——只有有時候——才會在底下呼叫 write() 把它沖出去。所以「這一行有沒有碰到核心?」是一個有真正答案的真問題——而在除錯那一階,你見過一個叫 strace 的工具,它整個任務就是把答案印出來:列出一支程式所做的每一個系統呼叫。

你請求的,與你拿回的

還有兩塊拼圖,才能把這道門補完整。第一塊是「到底有哪些呼叫存在」這個問題。核心對外擺出一份固定的系統呼叫菜單——open、read、write、close、fork,以及另外幾百個——而這份菜單被大家講定的形狀(每個呼叫的名字、以及各自承諾要做什麼),就是 POSIX APIPOSIX 是一套標準(首次發布於 1988 年),它把這個介面釘死下來,好讓同一份 C 原始碼可以在 Linux、macOS、各種 BSD,以及其他類 Unix 系統上被編譯並執行。這就是為什麼你正在學的這套檔案與行程的詞彙,是貨真價實可攜的知識,而不只是某一個作業系統的怪癖;對著 POSIX API 寫程式,你就等於對著一整個系統家族寫了程式。

第二塊拼圖,是「跨回來的是什麼」。核心沒辦法跨過邊界丟出一個 C++ 例外;它能交給你的,就只有一個回傳值。所以幾乎每一個 POSIX 呼叫都遵循一條嚴格的慣例:成功時,它回傳某個有用的東西(一個位元組數、一個檔案描述符、或 0);失敗時,它回傳一個哨兵值——通常是 -1——並把一個數字形式的原因碼,藏進一個叫 errno 的全域變數裡。這意味著你必須檢查每一個系統呼叫的回傳值,每一次都要。一個回傳 -1 的 read() 並沒有讀到任何東西;信任它會把你的程式弄壞。我們會用後面一整篇來講 errno 以及怎麼正確地做這件事,但規則從這裡就開始了:先檢查呼叫,然後只有在呼叫告訴你它失敗時,才去讀 errno。

  1. 做出呼叫,並接住它的回傳值:`ssize_t n = read(fd, buf, 100);`——絕不要呼叫 read() 卻把它回傳的東西丟掉。
  2. 先測試哨兵值:如果 `n == -1`,這個呼叫就是失敗了、什麼也沒讀到——在你拿 n 去信任任何東西之前,先處理這個情況。
  3. 只有在失敗之後,才去讀 errno 找原因(例如 EBADF,一個壞掉的描述符);在成功的呼叫之後讀 errno 是沒有意義的,而且可能讀到一個過時的舊值。
  4. 成功時,把 n 當成一個介於 0 到 100 之間的真實計數——並注意,它可能比你請求的 100 還小。那叫「短讀」(short read),是你很快會遇到的另一個觀念。

最後,有三道門在你的程式一啟動的那一刻就永遠是開著的,早在你自己開任何東西之前。它們就是標準串流:標準輸入、標準輸出,與標準錯誤,核心把它們當成檔案描述符 0、1、2 交給你。當你呼叫 printf() 時,它寫到描述符 1;perror() 寫到描述符 2。正因為這些只不過是檔案描述符,shell 可以悄悄地把它們指向一個檔案、一條管線,或另一支程式——這正是管線在做的事。標準串流就是為什麼一支程式可以讀它的輸入、寫它的輸出,卻不必知道、也不必在乎另一端到底是鍵盤、檔案,還是另一個行程;你寫到一個號碼,核心替你把它導去該去的地方。

站在那條線上

退一步,看看你現在握住的這幅圖。本階接下來的一切,都住在這同一道邊界上。〈什麼是系統呼叫?〉把鏡頭推近到那道門本身。〈系統呼叫如何陷入核心〉一路跟著單一一次跨越,下探到暫存器與陷入指令。〈errno 與回報錯誤〉講的是,當一次跨越失敗時,怎麼讀懂核心交回來的東西。而〈libc、POSIX 與標準串流〉則往外拉開,看整個介面——那層包裝、那套標準,以及那三道永遠開著的門。它們每一篇,都只是把你現在畫好的這條線,再湊近一點看而已。

而真正能帶走的,是一個習慣,而不只是一個事實。從這裡開始,當你讀 C 程式碼時,對每一個呼叫問一句:這會留在使用者模式,還是會跨過去?一段純運算、一個 strlen()、一個剛好還有餘裕記憶體的 malloc()——那些會留下。一個 open()、一個 read()、一個 fork()——那些會跨過去,它們會失敗,而且它們的代價遠高於一個普通呼叫。把這個問題隨身帶著,你就會像機器實際執行程式的方式那樣去讀系統程式:你的程式碼在一側、核心在另一側,中間夾著一小組有人看守、且可被檢查的門。