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

系統呼叫究竟是如何實作的

你呼叫 read(),位元組就出現了。但 read() 並不是讀取真正發生的地方——它只是一個門鈴。本篇跟著一個系統呼叫一路越過使用者/核心的邊界、再走回來,一個暫存器接一個暫存器地看清楚,並說明為什麼這趟跨越比一次普通的函式呼叫要昂貴。

read() 是門鈴,不是門

第二篇讓你握住了那套機制:一個陷阱(trap)刻意地把 CPU 經由中斷描述符表裡的一個項目丟進核心,並把它從使用者模式翻轉成核心模式。系統呼叫就是當一個程式選擇故意丟出那個陷阱、向核心請求某件它自己不被允許做的事時所得到的東西——開啟一個檔案、送出一個封包、分叉(fork)一個行程。本篇跟著這樣一個請求,從你寫下的那一行 C 一路往下走到真正做事的核心函式,再走回來。讀完之後,read() 在你眼中會變得很不一樣。

這是第一個驚喜,也是本篇最重要的觀念。你從 C 呼叫的那個 read()並不是系統呼叫。它是一個住在 C 函式庫裡的普通 C 函式——一層薄薄的包裝函式——而它幾乎只做一件事:把你的引數打包好,然後觸發陷阱。核心那一側、那段真正把位元組從磁碟搬出來的程式碼,做的是同一份工作,卻住在牆的另一邊。所以函式庫呼叫與系統呼叫是真正不同的兩種生物:在你的原始碼裡呼叫 read() 看起來和呼叫任何函式一模一樣,但在它內部的某一處,程式不再是一次普通的函式呼叫,而成了一個對另一個、權限更高的程式所發出的請求。

包裝函式內部:暫存器裡的一個數字

撬開 libc 的 read(),那份魔法平淡得幾乎令人失望。核心並不知道你的呼叫叫做「read」——它是靠一個數字來認得它的。每個系統呼叫在系統呼叫編號這套方案裡都有一個項目:在 64 位元的 Linux 上,read 是 0、write 是 1、open 是 2,依此類推。包裝函式的工作就是把那個數字載入一個固定的暫存器、把你的引數載入其他約定好的暫存器,然後執行一道特殊的指令。沒有比填一張欄位是 CPU 暫存器的表格更戲劇化的事了。

是哪些暫存器呢?這由一份嚴格的合約釘死——一套專供系統呼叫使用的小型 ABI,刻意等同於你在組合語言那一段遇過的普通 C 呼叫慣例。在 x86-64 的 Linux 上,呼叫編號放進 rax,而最多六個引數放進 rdi、rsi、rdx、r10、r8、r9。接著包裝函式執行 syscall 指令,那才是真正的陷阱:它把 CPU 翻進核心模式、跳到一個單一固定的核心進入點。當控制權回來時,核心已把它的答案留在 rax 裡——若不是一個非負的結果(讀到的位元組數),就是一個負的錯誤碼。

// what libc's read(fd, buf, n) becomes on x86-64 Linux:

  mov  rax, 0        ; syscall number for read
  mov  rdi, fd       ; arg1: file descriptor
  mov  rsi, buf      ; arg2: pointer to your buffer
  mov  rdx, n        ; arg3: how many bytes
  syscall            ; <-- THE TRAP: cross into the kernel
  ; on return, rax holds bytes read, or a negative error
  ; e.g. rax == -14  means -EFAULT (you passed a bad pointer)
整個包裝函式精簡到底:rax 裡一個數字、固定暫存器裡的引數、一道 syscall 指令。陷阱就是單獨那一行。

請留意這個「負值即結果」的慣例,因為它解釋了 C 程式設計師時時看見的一件事。核心無法設定你那個使用者空間的 errno——那是你的變數、在你的記憶體裡,核心不去碰它。所以核心回傳一個數來表示失敗,而 libc 包裝函式負責翻譯:若 rax 帶回一個小的負值,包裝函式就把它取負、存進 errno,並對你的 C 程式碼回傳 -1。這正是為什麼整個網站的每一個範例都要檢查 -1 的回傳值、然後讀取 errno——那個 -1 是包裝函式的訊號,errno 是被拆解出來的細節。

核心那一側:從一個進入點走到正確的處理常式

syscall 指令把 CPU 帶到核心裡恰好一個位址——read、write、open 以及每一個其他呼叫,用的都是同一個進入點。所以核心的第一個任務,是搞清楚你指的是哪一個呼叫。它讀取你留在 rax 裡的數字,並把它當成索引去查系統呼叫表:一個樸素的函式指標陣列,每個系統呼叫編號佔一格。第 0 格指向核心的 read 處理常式、第 1 格指向 write,依此類推。這是 libc 包裝函式在核心側的孿生兄弟——是你那個以數字表示的請求,在核心內部變成一次具體函式呼叫的那一刻。

在那一切之前,進入點的程式碼還有些雜務要做,而這正解釋了成本花在哪裡。它必須把你的使用者空間暫存器存到某個安全的地方(核心馬上就要把它們蓋掉)、從你的使用者堆疊切換到一個每行程專屬的核心堆疊,然後才透過表格分派出去。當處理常式回傳時,這一切會反向收尾:還原你的暫存器、切回使用者堆疊、降下權限等級,並在 syscall 之後的那道指令處讓你的程式碼繼續執行。這些記帳雖小卻是實打實的,正是我們在最後量到的那個成本的種子。

為什麼核心不能直接對你的指標解參考

現在我們來到讓每個學核心程式碼的人都絆倒的部分。你的 read() 遞給核心一個指標——buf,那些位元組該去的位址。核心正在核心模式裡執行,握有觸碰機器中任何記憶體的權力。那它為什麼不能直接 buf = byte; 把資料一口氣抄進去呢?兩個原因,而且都關乎安全。第一,buf 是一個使用者空間位址,它只有在你那個*行程的分頁表裡才有意義;核心必須把它當成使用者記憶體去讀寫,而不能假設它像核心位址那樣被對映。第二,而且更尖銳地:你可能說了謊。

假設你的 buf 指向的不是你自己的緩衝區,而是一個核心位址、或一塊你並不擁有的記憶體。一個天真地直接對它解參考的核心,會樂呵呵地讀取或覆寫某個珍貴的東西——一個教科書等級的權限提升。所以核心從不直接碰使用者指標。它把每一個位元組都漏斗般地導過受守護的輔助函式,那組copy-to/from-user常式:用 copy_from_user() 把你的引數拉進來、用 copy_to_user() 把結果推出去。這些函式會檢查位址是否真的落在使用者空間裡,而且它們被寫成:若指標是壞的,存取會乾淨地觸發頁錯誤,系統呼叫回傳 -EFAULT,而不是讓核心崩潰。那個 -EFAULT 就是我們稍早看見回到 rax 裡的 -14。

這就是核心/使用者邊界被具體化的樣子,值得停下來看,因為它推翻了初學者的心智模型。這道牆不只關乎權限——核心模式對比使用者模式——它也關乎對位址的信任。即使握有觸碰所有記憶體的權力,核心仍刻意把每一個來自使用者空間的指標都當成可疑之物,只透過這些經過檢查、能容忍頁錯誤的複製動作伸手越過邊界。在真實的核心程式碼裡把這件事弄錯,你就寫出了一個安全漏洞;這是系統程式設計裡真正困難、容易出錯的部分之一,而不是一道形式手續。

這趟跨越的代價——以及快速路徑

把這趟來回加總起來,你就能感覺到時間花在哪:陷阱與模式切換本身、存與還原暫存器、堆疊的交換、查表分派、那些經過檢查的複製,然後整段順序再反向收尾。每一項都不龐大,但每一項也都不免費。誠實的標題是:一個系統呼叫的代價遠高於一次普通的函式呼叫——典型上是數百個 CPU 週期,相對於一次純函式呼叫的寥寥幾個。函式呼叫是在你自己的權限等級內跳走又回來;而一次系統呼叫會改變模式、碰觸核心的機械、再回來。

這個代價正是為什麼許多系統智慧講的是避免系統呼叫、而不只是發出它們。這就是為什麼 stdio 會把你的寫入緩衝起來、成大批地刷出,而不是每個字元一次系統呼叫;為什麼一個繁忙的伺服器會動用批次處理的介面,在單獨一次跨越裡處理許多事件。「每次陷阱多做一點」是你往上爬時會一再遇見的主題——這道邊界是一座收費站,訣竅是滿載而過,而不是空車通過。

硬體與核心一直在反擊,這是最後一塊拼圖。老式的 x86 是透過一個軟體中斷(int 0x80 指令)來丟出陷阱的,那真的很慢;現代晶片加入了專用的 syscall 指令,正是為了讓模式切換更便宜。而核心保有一條快速路徑:對於常見、簡單、不需要笨重記帳的呼叫,進入點的程式碼會走一條精簡的路線,只存它非存不可的暫存器、略過它能證明為不必要的工作,然後對任何棘手的情況才退回那條緩慢的通用路徑。有些呼叫走得更遠——少數只讀取核心資料的呼叫,例如取得時間,可以完全不必觸發陷阱就被服務,靠的是核心對映進每個行程的一個共享唯讀分頁。

退一步,把整段旅程握在手裡。你的 read() 是一個 libc 包裝函式,它把一個數字載入 rax、把引數放進固定暫存器、執行一道陷阱指令。CPU 切換了模式、落在核心那單一的進入點,它存下你的狀態、交換堆疊、透過系統呼叫表分派到真正的處理常式。那個處理常式從不信任你的指標,把每一個位元組都經由受檢查的使用者複製輔助函式搬運,然後回傳一個數字,再由包裝函式把它變回一個結果與 errno。這趟跨越正是讓核心安全的東西——也是讓它有代價的東西。第四篇跟著看:當核心在系統呼叫進行到一半時,決定改去執行另一個行程,你就會遇見排程器。