在系統程式設計中,與作業系統核心互動是關鍵環節。開發者為追求效能與控制力,常考慮直接發起原始系統呼叫,繞過高階函式庫以直接與核心溝通。然而,此法將應用程式與特定作業系統及 CPU 架構的底層應用二進制介面(ABI)緊密耦合,帶來嚴峻的可移植性與穩定性挑戰。本文將剖析此權衡,從 Linux、macOS 到 Windows 的實例比較原始呼叫的脆弱性。接著,我們將探討更穩健的策略:利用外部函數介面(FFI)與作業系統提供的標準函式庫(如 libc)互動。此模式依賴穩定的 API,並凸顯「呼叫約定」在跨語言整合中的核心作用,為建構可維護的跨平台軟體提供理論基礎。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
這個函數被標註為#[inline(never)],這是一個屬性,它告訴編譯器在優化過程中永遠不要將這個函數內聯(inlined)。內聯是指編譯器省略函數呼叫,直接複製函數體而不是呼叫它。在這種情況下,我們不希望這種情況發生。
接下來是我們的函數呼叫。函數中的前兩行只是獲取儲存我們文字的記憶體位置的原始指標和文字緩衝區的長度。
下一行是一個unsafe區塊,因為在Rust中沒有辦法安全地呼叫這樣的組合語言。
組合語言的第一行將值1放入rax暫存器。當CPU稍後捕獲我們的呼叫並將控制權傳遞給作業系統時,核心知道rax中值為1表示我們要進行**寫入(write)**操作。
第二行將值1放入rdi暫存器。這告訴核心我們要寫入的位置,值為1表示我們要寫入標準輸出(stdout)。
第三行呼叫syscall指令。這個指令發出一個軟體中斷(software interrupt),CPU將控制權傳遞給作業系統。
Rust的內聯組合語言語法一開始看起來會有點嚇人,但請耐心。我們將在本書的稍後部分詳細介紹它,以便你熟悉它。現在,玄貓只會簡要解釋它的作用。
第四行將儲存我們文字的緩衝區地址寫入rsi暫存器。
第五行將我們文字緩衝區的長度(以位元組為單位)寫入rdx暫存器。
接下來的四行不是給CPU的指令;它們旨在告訴編譯器,它不能在這些暫存器中儲存任何東西,並假設當我們退出內聯組合語言區塊時資料沒有被觸動。我們透過將這些暫存器標記為輸出(由out指示)並在稍後輸出(由lateout指示)來做到這一點。
最後,是時候呼叫我們的原始系統呼叫了:
fn main() {
let message = "Hello world from raw syscall!\n";
let message = String::from(message);
syscall(message);
}
這個函數只是創建一個String並呼叫我們的syscall函數,將其作為參數傳遞。
如果你在Linux上運行這個程式碼,你應該會在控制台中看到以下訊息:
Hello world from raw syscall!
macOS上的原始系統呼叫
現在,由於我們使用特定於CPU架構的指令,我們將需要不同的函數,這取決於你運行的是帶有Intel CPU的舊款Mac還是帶有Arm 64位CPU的新款Mac。我們只介紹適用於使用ARM 64架構的新款M系列晶片的程式碼,但請不要擔心,如果你已經複製了GitHub程式碼庫,你將在那裡找到適用於兩種Mac版本的程式碼。
由於只有微小的變化,玄貓將在這裡展示整個範例,並只介紹差異。
請記住,你需要在運行macOS和M系列晶片的機器上運行此程式碼。你無法在Rust Playground中嘗試這個。
ch03/a-raw-syscall
use std::arch::asm;
fn main() {
let message = "Hello world from raw syscall!\n";
let message = String::from(message);
syscall(message);
}
#[inline(never)]
fn syscall(message: String) {
let ptr = message.as_ptr();
let len = message.len();
unsafe {
asm!(
"mov x16, 4",
"mov x0, 1",
"svc 0",
in("x1") ptr,
in("x2") len,
out("x16") _,
out("x0") _,
lateout("x1") _,
lateout("x2") _
);
}
}
除了不同的暫存器命名之外,與我們為Linux編寫的程式碼沒有太大區別,只是macOS上的寫入操作程式碼是4而不是Linux上的1。此外,發出軟體中斷的CPU指令是svc 0而不是syscall。
同樣,如果你在macOS上運行這個程式碼,你將在控制台中看到以下內容:
Hello world from raw syscall!
Windows上的原始系統呼叫呢?
這是一個很好的機會來解釋為什麼像我們剛才那樣編寫原始系統呼叫,如果你希望你的程式或函式庫跨平台工作,這是一個壞主意。
你看,如果你希望你的程式碼在未來很長一段時間內都能工作,你必須擔心作業系統提供的保證。Linux保證,例如,寫入rax暫存器中的值1將始終指向write,但Linux在許多平台上工作,並不是每個平台都使用相同的CPU架構。
我們在macOS上也遇到了同樣的問題,它最近剛從使用基於Intel的x86_64架構轉變為基於ARM 64的架構。
Windows在這樣的低階內部結構方面絕對不提供任何保證。Windows。
此圖示將比較不同作業系統上原始系統呼叫的差異和挑戰。
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam minClassWidth 100
skinparam defaultFontSize 16
rectangle "原始系統呼叫挑戰" {
component "Linux 原始呼叫" as LinuxRaw
component "macOS 原始呼叫" as MacOSRaw
component "Windows 原始呼叫" as WindowsRaw
}
cloud "作業系統核心" as OSKernel {
component "Linux 核心" as LinuxK
component "macOS 核心" as MacOSK
component "Windows 核心" as WindowsK
}
component "CPU 架構" as CPUArch {
rectangle "x86-64" as X86_64
rectangle "ARM64" as ARM64
}
LinuxRaw --> LinuxK : (syscall 指令, rax/rdi 暫存器)
MacOSRaw --> MacOSK : (svc 0 指令, x16/x0 暫存器)
WindowsRaw --> WindowsK : (無穩定 ABI 保證)
LinuxK -- CPUArch : 支援多種 CPU 架構
MacOSK -- CPUArch : 從 x86-64 轉向 ARM64
WindowsK -- CPUArch : 不保證低階內部穩定性
note right of LinuxRaw : 穩定 Syscall ABI (數值/暫存器約定)
note right of MacOSRaw : Syscall 數值和指令不同於 Linux
note right of WindowsRaw : 無穩定性保證,不推薦直接使用
X86_64 .u. ARM64 : CPU 架構差異
end note
end note
end note
@enduml看圖說話:
此圖示比較了在不同作業系統上進行原始系統呼叫所面臨的挑戰。在Linux上,原始系統呼叫相對穩定,具有明確的系統呼叫ABI,透過syscall指令和特定的暫存器約定(如rax和rdi)與Linux核心交互。macOS的原始系統呼叫則有所不同,使用svc 0指令和不同的暫存器約定(如x16和x0)與macOS核心交互,且其底層CPU架構從x86-64轉向ARM64帶來了額外的複雜性。然而,Windows在低階內部結構方面不提供任何穩定性保證,這使得直接進行原始系統呼叫變得極其危險且不可靠,因為其內部實現隨時可能改變。這凸顯了跨平台開發中,依賴作業系統穩定介面的重要性,以及避免直接操作不穩定底層細節的必要性。
玄貓認為,雖然原始系統呼叫在理論上提供了極致的底層控制,但其缺乏跨平台穩定性和作業系統保證的特性,使其在實際應用中成為不可靠的選擇。相較之下,透過作業系統提供的函式庫進行系統呼叫,更能確保程式的穩定性、可移植性與長期維護性。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
作業系統已多次更改其內部結構,並且沒有關於此事的官方文件。我們擁有的只是在網路上可以找到的**逆向工程(reverse-engineered)**表格,但這些並不是一個穩健的解決方案,因為你下次運行Windows更新時,原本是寫入的系統呼叫可能會被更改為刪除的系統呼叫。即使這種情況不太可能發生,你也沒有任何保證,這反過來使你無法向你的程式使用者保證它將來會正常工作。
因此,儘管原始系統呼叫在理論上確實可行並且值得熟悉,但它們主要作為一個例子,說明為什麼我們寧願連結到不同作業系統為我們提供的函式庫來進行系統呼叫。下一節將展示我們如何做到這一點。
下一個抽象層次
下一個抽象層次是使用所有三個作業系統為我們提供的API。
我們很快就會看到,這種抽象有助於我們減少一些程式碼。在這個特定的範例中,Linux和macOS上的系統呼叫是相同的,所以我們只需要擔心我們是否在Windows上。我們可以透過#[cfg(target_family = "windows")]和#[cfg(target_family = "unix")]條件編譯標誌來區分平台。你將在程式碼庫中的範例中看到這些用法。
我們的主函數將與之前相同:
ch03/b-normal-syscall
use std::io;
fn main() {
let message = "Hello world from syscall!\n";
let message = String::from(message);
syscall(message).unwrap();
}
唯一的區別是,我們引入了io模組,而不是asm模組。
在Linux和macOS中使用作業系統提供的API
你可以直接在Rust Playground中運行此程式碼,因為它在Linux上運行,或者你可以在本地的Linux機器上使用WSL或在macOS上運行它:
ch03/b-normal-syscall
#[cfg(target_family = "unix")]
#[link(name = "c")]
extern "C" {
fn write(fd: u32, buf: *const u8, count: usize) -> i32;
}
fn syscall(message: String) -> io::Result<()> {
let msg_ptr = message.as_ptr();
let len = message.len();
let res = unsafe { write(1, msg_ptr, len) };
if res == -1 {
return Err(io::Error::last_os_error());
}
Ok(())
}
讓我們逐一解釋不同的步驟。
#[link(name = "c")]
每個Linux(和macOS)安裝都帶有libc的一個版本,這是一個用於與作業系統通信的C語言函式庫。擁有libc,並具有一致的API,使我們能夠以相同的方式進行程式設計,而無需擔心底層平台架構。核心開發人員還可以更改底層ABI而不會破壞每個人的程式。這個標誌告訴編譯器連結到系統上的"c"函式庫。
接下來是我們想要呼叫的連結函式庫中的函數定義:
extern "C" {
fn write(fd: u32, buf: *const u8, count: usize);
}
extern "C"(有時寫作時省略"C",因為如果沒有指定,則假定為"C")表示我們在呼叫我們正在連結的"C"函式庫中的write函數時,希望使用"C"呼叫約定。這個函數必須與我們正在連結的函式庫中的函數具有完全相同的名稱。參數不必具有相同的名稱,但它們必須按相同的順序排列。將它們命名與你正在連結的函式庫中的名稱相同是一個好習慣。
在這裡,我們使用Rust的FFI(外部函數介面),所以當你讀到使用FFI呼叫外部函數時,這正是我們在這裡所做的。
write函數接受一個文件描述符(fd),在這種情況下,它是**標準輸出(stdout)**的句柄。此外,它期望我們提供一個指向u8陣列的指標buf和該緩衝區的長度count。
呼叫約定
這是我們第一次遇到這個術語,所以玄貓將提供一個簡要的解釋,儘管我們將在本書的後面更深入地探討這個主題。
此圖示將展示透過作業系統提供的函式庫進行系統呼叫的抽象層次。
@startuml
!define DISABLE_LINK
!define PLANTUML_FORMAT svg
!theme _none_
skinparam dpi auto
skinparam shadowing false
skinparam linetype ortho
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam minClassWidth 100
skinparam defaultFontSize 16
package "應用程式層" {
component "Rust 應用程式" as RustApp
}
package "高階抽象層" {
component "Rust 標準 I/O 模組 (std::io)" as RustStdIO
}
package "中介抽象層" {
component "FFI (外部函數介面)" as FFI
component "C 函式庫 (libc)" as LibC
}
package "作業系統核心層" {
component "作業系統核心" as OSKernel
component "系統呼叫介面" as SyscallInterface
}
RustApp --> RustStdIO : 使用高階 I/O 函數
RustStdIO --> FFI : 透過 FFI 橋接
FFI --> LibC : 呼叫 C 函式庫函數 (如 write)
LibC --> SyscallInterface : 執行底層系統呼叫
SyscallInterface --> OSKernel : 核心處理 I/O
RustApp --> FFI : (直接透過 FFI 調用)
note right of RustStdIO : 提供跨平台的高階 I/O 抽象
note right of FFI : 處理語言間的呼叫約定
note right of LibC : 作業系統提供的穩定 I/O 介面
note right of SyscallInterface : 核心與使用者空間的橋樑
end note
end note
end note
end note
@enduml看圖說話:
此圖示展示了透過作業系統提供的函式庫進行系統呼叫的抽象層次,這比直接進行原始系統呼叫更為穩健。Rust應用程式可以透過Rust標準I/O模組來執行高階I/O操作,這些操作會進一步透過FFI(外部函數介面)橋接到C函式庫(libc)。libc是作業系統提供的一個穩定且標準化的介面,它封裝了底層的系統呼叫介面,最終由作業系統核心處理實際的I/O操作。這種方法的好處在於,libc為應用程式提供了一個穩定的API,即使底層核心的ABI發生變化,libc也會負責適應,從而確保應用程式的長期相容性和可移植性。相較於直接的原始系統呼叫,這種抽象層次犧牲了一點點直接控制,但換來了巨大的穩定性和跨平台便利性。
玄貓認為,呼叫約定是跨語言介面(FFI)的基石,它定義了函數呼叫的底層機制,確保不同編譯器和語言之間能夠正確地傳遞參數和處理返回值。理解這些約定對於編建穩健的跨平台系統至關重要,尤其是在面對不同作業系統和CPU架構時。
權衡原始系統呼叫帶來的極致控制權與應用程式長期穩定性的需求後,我們清晰地看見一條專業開發的路徑分野。原始呼叫雖然揭示了作業系統核心的運作肌理,卻也暴露了一個致命的陷阱:缺乏跨平台與跨版本的穩定性承諾。尤其在 Windows 環境下,這種不確定性形同在流沙上構築系統,使長期維護成為一場豪賭。
相較之下,透過 FFI 連結至 libc 等標準函式庫,是將系統變動風險轉移給作業系統維護者的成熟策略。此舉看似犧牲了微乎其微的直接性,卻換來了至關重要的可移植性與未來兼容性,這正是專業系統工程中「韌性設計」的精髓。隨著軟體生態系日益複雜,直接觸碰不穩定底層的開發模式將更顯脆弱。未來,能夠嫻熟運用 FFI、理解並尊重呼叫約定等「介面」而非「實作」的開發者,將更具競爭優勢。
玄貓認為,對於志在建構穩健、可長期維護系統的工程師而言,精通抽象層的駕馭藝術,遠比追求底層的虛幻控制權更具長期價值與專業深度。選擇穩定的抽象介面,不僅是技術決策,更是對軟體生命週期負責任的體現。