問題所在:你要怎麼安全地呼叫一段你無權跳進去的程式碼?
從本級稍早的導引,你手上已經握著兩個事實。第一,CPU 在兩種特權層級之一中執行:尋常的程式碼跑在使用者模式,那裡危險的指令與直接存取硬體一律被禁止,而核心則跑在核心模式,那裡什麼都被允許。第二,系統呼叫是使用者程式碼請核心代它去做那些被禁止之事的方式——開檔、寫位元組、要一個新的行程。這篇導引回答那兩個事實留下的機械性問題:既然你不准就這樣跳進核心的程式碼,控制權究竟怎麼跨過那條線?
請注意為什麼單純的跳躍行不通。如果使用者程式碼能直接 `jmp` 到某個核心位址就開始執行,整套保護就成了空話——任何程式都可以落在它喜歡的任何一條核心指令上,並以完整特權執行它。因此硬體拒絕這麼做:一次從使用者模式跳進核心記憶體的動作,不會成功,而是會出錯。所以這個跨越不能是你來操控的東西。它必須是一次受控的交接,由 CPU 本身、而非你的程式碼,來決定核心的執行究竟從哪裡開始。這個單一的念頭——你可以請求進入,但只有硬體決定你落在哪裡——就是整個設計。
陷入:一條改變你身分的指令
這個跨越由一條專用的單一指令完成。在現代 x86-64 的 Linux 上,它是 `syscall` 指令;較舊的系統則使用像 `int 0x80` 這樣的軟體中斷。執行它會觸發一次陷入——一次刻意的、同步的、切換進核心模式的動作。這和分頁錯誤或中斷屬於同一個事件家族,都涵蓋在陷入、錯誤與中斷之中,但有一個關鍵差別:錯誤是 CPU 抓到你在做錯事,而 `syscall` 造成的陷入則是你在禮貌地敲門。你是故意執行那條指令的,請求被放進去。
當 `syscall` 執行時,硬體在一個不可分割的步驟裡做了好幾件事,而最關鍵的是這些你一個都不能選。它把特權層級提升到核心模式。它保存你原本所在的位置——返回位址(你的 rip)和旗標——好讓核心稍後能把你送回去。然後它從一個核心在開機時設好的暫存器載入 rip,也就是系統呼叫進入點,使執行跳到恰好一個固定的核心位址,那是核心事先選定的。你無法改變這個方向。不論你發出的是哪一個系統呼叫,你永遠從同一扇門進入核心,而核心的守門程式碼最先執行。這就是完整的陷入與模式切換。
傳遞訊息:系統呼叫號碼與它的引數
如果每一個系統呼叫都從同一扇門進入,核心怎麼知道你要的是哪一個服務——open、還是 write、還是 fork?你用一個號碼告訴它。每個系統呼叫都有一個固定的系統呼叫號碼:在 x86-64 的 Linux 上,read 是 0、write 是 1、open 是 2,依此類推。在執行 `syscall` 之前,你把那個號碼放進一個特定的暫存器 rax,並把引數放進一串固定的暫存器:rdi、rsi、rdx、r10、r8、r9,最多放六個引數。這個暫存器配置是一份嚴格的契約——核心就只讀那幾個暫存器、別的一概不讀——它也是規範尋常呼叫的同一套 ABI 的一部分,只是為核心邊界另有一本專屬的規則手冊。
於是完整的交握讀起來像一個小小的協定:把系統呼叫號碼載入 rax、載入引數暫存器、執行 `syscall`,而當控制權返回時,到 rax 裡找結果。不需要像尋常函式那樣把引數放上堆疊傳遞,因為核心無法信任你的堆疊,而暫存器約定既更快、也更容易檢查。一個把單一位元組寫到標準輸出、手寫成組合語言的真實例子,長得就像下面這張草圖——而這正是 C 呼叫 `write(1, buf, 1)` 在底下、等 libc 做完它那一份工作後,所編譯成的東西。
; x86-64 Linux: write(1, msg, 13) mov rax, 1 ; syscall number 1 = write mov rdi, 1 ; arg1: fd = 1 (standard output) lea rsi, [msg] ; arg2: pointer to the bytes mov rdx, 13 ; arg3: how many bytes syscall ; <-- trap into the kernel here ; on return: rax = bytes written, or a negative error code register map: rax=number rdi rsi rdx r10 r8 r9 = args rax=result
進門之後,再走回來
一旦陷入把你帶到核心進入點,核心的工作就開始了,而它一刻也不信任你。它的守門程式碼先切換到一個核心堆疊——你的使用者堆疊可能已損毀或懷有惡意,所以核心絕不在它上面執行。接著它讀 rax,檢查這個號碼在它的表裡是不是一個真實存在的系統呼叫;超出範圍的號碼會被拒絕,而不是被照辦。然後,在依據你傳入的任何指標引數(像那個指著你位元組的 rsi)動作之前,它會驗證那個指標確實住在你的位址空間裡、而不在核心的位址空間裡,這樣程式就無法騙核心代它去讀寫核心記憶體。只有在這些檢查之後,它才分派到真正的處理常式——那個執行實際工作的函式。
當處理常式完成,返回的路徑就是進入的鏡像。核心把結果放進 rax,復原你被保存的暫存器與 rip,用一條專用的返回指令(`sysret` 或 `iret`)把特權層級降回使用者模式,於是執行在你那條 `syscall` 之後緊鄰的下一條指令上接續,彷彿它只是單獨的一步。有兩個約定讓結果可讀。成功會在 rax 裡返回一個非負值——對 write 而言,就是實際寫出的位元組數。失敗則返回一個數值不大的負值,其絕對值就是錯誤碼。那個「出錯時為負」的約定,正是 libc 稍後轉換成 errno 機制的東西,而下一篇導引整篇都在講它;在這裡,關鍵只是:結果是搭著 rax 回家的。
為什麼這個跨越是有代價的
把系統呼叫想成只是一次稍微花俏一點的函式呼叫很誘人,但那幅圖像藏起了一筆真實的代價,會引人寫出很慢的程式。一條單純的使用者模式 `call` 配 `ret`,是寥寥幾條便宜的指令。相對地,一次系統呼叫必須切換特權層級、切換堆疊、保存並復原額外的處理器狀態、跑核心的驗證、再切回來——而在現代硬體上,它還可能擾動那些會向前推測的 CPU 功能,要花額外的週期才能恢復。誠實的總結是:一次系統呼叫的代價大約是一次單純函式呼叫的數百到數千倍,而不是兩三倍。
這個單一事實解釋了一大堆真實的系統設計。它說明了為什麼用一百萬次各自獨立的 write() 系統呼叫去寫一百萬次每次一個位元組,會遠遠慢於把那些位元組在使用者空間裡緩衝起來、再用寥寥幾次大寫入一口氣沖出去——你是付一次過路費,而不是付一百萬次。它說明了為什麼高效能伺服器拚命想在每次系統呼叫裡做更多工作。它也是標準函式庫一開始就把無緩衝的核心 I/O 包進緩衝串流裡的原因,這條線由本級最後一篇導引直接接手。一旦你能把陷入看成一次有真實過路費的真實邊界跨越,批次處理就不再是個花招,而成了顯然該做的事。