在現代軟體開發中,跨平台兼容性是構建穩健應用程式的基礎。要實現此目標,開發者必須深入理解作業系統與高階語言之間的互動介面。本文從系統呼叫的底層實作切入,解析外部函數介面(FFI)如何橋接不同編譯環境的程式碼。其中,呼叫約定(Calling Convention)扮演著至關重要的角色,它定義了函數參數傳遞、暫存器使用與堆疊管理的標準化規則,確保了 Rust 程式能正確調用 C 函式庫或 Windows API。文章將透過對比 Unix 類系統以檔案為核心的設計哲學與 Windows 以物件句柄為中心的模型,闡明平台差異如何直接影響 API 設計與資料處理方式,例如字元編碼的選擇。此一探討不僅揭示了標準函式庫背後的抽象原理,也為理解 epoll 等非同步 I/O 機制奠定了理論基礎。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
**呼叫約定(Calling convention)**定義了函數呼叫的執行方式,它會指定以下內容:
- 參數如何傳遞給函數
- 函數在開始時應儲存哪些暫存器,並在返回前恢復
- 函數如何返回其結果
- 堆疊如何設置(我們稍後會再討論這個)
因此,在你呼叫外部函數之前,你需要指定使用哪種呼叫約定,因為如果你不告訴編譯器,它就無法知道。C呼叫約定是預設的,我們將呼叫包裝在一個普通的Rust函數中。
ch03/b-normal-syscall
#[cfg(target_family = "unix")]
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(())
}
你現在可能已經熟悉前兩行了,因為它們與我們為原始系統呼叫範例編寫的內容相同。我們獲取儲存我們文字的緩衝區的指標和該緩衝區的長度。
接下來是我們對libc中write函數的呼叫,它需要包裝在一個unsafe區塊中,因為Rust在呼叫外部函數時無法保證安全性。
你可能會想,我們怎麼知道值1指的是標準輸出的文件句柄。
在用Rust編寫系統呼叫時,你會經常遇到這種情況。通常,常數是在C頭文件中定義的,所以我們需要手動搜尋它們並查找這些定義。1在UNIX系統上始終是標準輸出的文件句柄,所以很容易記住。
注意
包裝libc函數並提供這些常數正是libc庫所做的。使用它而不是像我們在這裡這樣手動連結和定義所有函數。
最後,我們有錯誤處理,在使用FFI時你會一直看到這種情況。C函數通常使用一個特定的整數來指示函數呼叫是否成功。在這個write呼叫的情況下,函數將返回寫入的位元組數,或者如果發生錯誤,它將返回-1。你可以查閱man write(或linux/man-pages/index.html)以獲取Linux的詳細資訊。
如果發生錯誤,我們使用Rust標準函式庫中的內建函數來查詢作業系統報告的此進程的最後一個錯誤,並將其轉換為Rust io::Error類型。
如果你使用cargo run運行此函數,你將看到此輸出:
Hello world from syscall!
使用Windows API
在Windows上,事情有點不同。雖然UNIX將幾乎所有東西都模型化為你互動的「文件」,但Windows使用其他抽象。在Windows上,你會獲得一個句柄,它代表你可以以特定方式互動的某些物件,具體取決於你擁有的句柄類型。
我們將使用與之前相同的主函數,但我們需要連結到Windows API中的不同函數,並更改我們的syscall函數。
ch03/b-normal-syscall
#[link(name = "kernel32")]
extern "system" {
fn GetStdHandle(nStdHandle: i32) -> i32;
fn WriteConsoleW(
hConsoleOutput: i32,
lpBuffer: *const u16,
numberOfCharsToWrite: u32,
lpNumberOfCharsWritten: *mut u32,
lpReserved: *const std::ffi::c_void,
) -> i32;
}
你注意到的第一件事是我們不再連結到"C"函式庫。相反,我們連結到kernel32函式庫。下一個變化是使用了system呼叫約定。這個呼叫約定有點特殊。你看,Windows根據你是為32位x86 Windows版本還是64位x86_64 Windows版本編寫程式碼而使用不同的呼叫約定。運行在x86_64上的較新Windows版本使用"C"呼叫約定,所以如果你有一個較新的系統,你可以嘗試更改它並看看它是否仍然有效。「指定system」讓編譯器根據系統找出正確的呼叫約定。
我們連結到Windows中的兩個不同的系統呼叫:
此圖示將展示呼叫約定在FFI中的作用,以及不同平台API的差異。
@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 "程式語言層 (Rust)" {
component "Rust 應用程式" as RustApp
component "FFI (extern \"C\" / extern \"system\")" as FFI
}
package "平台特定 API" {
component "Linux/macOS (libc)" as UnixLibc
component "Windows (kernel32)" as WinKernel32
}
package "呼叫約定" {
rectangle "C Calling Convention" as CCall
rectangle "System Calling Convention" as SysCall
}
RustApp --> FFI : 調用外部函數
FFI --> UnixLibc : (#[cfg(target_family = "unix")])
FFI --> WinKernel32 : (#[cfg(target_family = "windows")])
UnixLibc --> CCall : 使用 C 呼叫約定 (write 函數)
WinKernel32 --> SysCall : 使用 System 呼叫約定 (GetStdHandle, WriteConsoleW 函數)
CCall .u. SysCall : 呼叫約定定義參數傳遞、暫存器使用等
note right of CCall : Linux/macOS 常用
note right of SysCall : Windows 特定,編譯器自動判斷
end note
end note
@enduml看圖說話:
此圖示闡明了呼叫約定在**外部函數介面(FFI)**中的關鍵作用,以及它如何影響跨平台程式設計。Rust應用程式透過FFI調用外部函數時,必須指定正確的呼叫約定,例如在Linux/macOS上使用"C"呼叫約定來調用libc中的write函數,而在Windows上則可能使用"system"呼叫約定來調用kernel32中的GetStdHandle和WriteConsoleW等函數。呼叫約定定義了函數參數如何傳遞、暫存器如何使用以及返回值如何處理等底層機制,確保不同語言或編譯器編譯的程式碼能夠正確地互相操作。圖中特別強調了不同平台對呼叫約定的選擇,例如Windows的"system"約定允許編譯器根據目標系統自動選擇最合適的約定,這對於維護跨平台相容性至關重要。
玄貓認為,在跨平台開發中,理解不同作業系統的API設計哲學至關重要。Windows傾向於使用句柄和Unicode編碼,這與UNIX類系統的文件描述符和UTF-8編碼形成鮮明對比。這種差異要求開發者在進行跨平台抽象時,必須處理字符編碼、錯誤處理和API調用方式的細節。
深入理解作業系統支援的事件佇列、系統呼叫與跨平台抽象
GetStdHandle: 這會檢索對標準設備(如標準輸出)的參考。WriteConsoleW:WriteConsole有兩種類型。WriteConsoleW接受Unicode文字,而WriteConsoleA接受ANSI編碼文字。我們的程式中使用的是接受Unicode文字的版本。
如果你的程式只寫英文文字,那麼ANSI編碼文字就足夠了,但一旦你寫其他語言的文字,你可能需要使用ANSI無法表示但在Unicode中可以表示的特殊字符。如果你將它們混淆,你的程式將無法按預期工作。
接下來是我們新的syscall函數:
ch03/b-normal-syscall
fn syscall(message: String) -> io::Result<()> {
let msg: Vec<u16> = message.encode_utf16().collect();
let msg_ptr = msg.as_ptr();
let len = msg.len() as u32;
let mut output: u32 = 0;
let handle = unsafe { GetStdHandle(-11) };
if handle == -1 {
return Err(io::Error::last_os_error())
}
let res = unsafe {
WriteConsoleW(
handle,
msg_ptr,
len,
&mut output,
std::ptr::null()
)
};
if res == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
我們做的第一件事是將文字轉換為Windows使用的UTF-16編碼文字。幸運的是,Rust有一個內建函數可以將我們的UTF-8編碼文字轉換為UTF-16碼點。encode_utf16返回一個u16碼點的迭代器,我們可以將其收集到一個Vec中。
接下來的兩行你應該很熟悉了。我們將GetStdHandle的結果賦給handle變數。GetStdHandle接受一個表示所需標準設備的整數。對於標準輸出,我們傳入-11。我們需要傳入的不同標準設備的值在GetStdHandle文件中有所描述。這很方便,因為我們不必深入C頭文件去查找所有我們需要的常數值。
所有函數的預期返回碼也都有詳細的文件說明,所以我們在這裡處理潛在錯誤的方式與Linux/macOS系統呼叫相同。
最後,我們呼叫WriteConsoleW函數。這並沒有什麼特別花俏的地方,你會注意到它與我們用於Linux的write系統呼叫有相似之處。一個區別是,輸出不是從函數返回,而是寫入我們以指標形式傳入的輸出變數的地址位置。
注意
現在你已經看到了我們如何創建跨平台系統呼叫,你可能也會理解為什麼我們沒有將本書中每個範例都包含跨平台程式碼。如果這樣做,本書將會非常冗長,而且所有這些額外資訊是否真的有助於我們理解關鍵概念,這並不顯然。
最高層次的抽象
這很簡單,但玄貓只是為了完整性而添加。Rust標準函式庫為我們封裝了對底層作業系統API的呼叫,所以我們不必關心要調用哪些系統呼叫。
fn main() {
println!("Hello world from the standard library");
}
恭喜你!你現在已經使用三個抽象層次編寫了相同的系統呼叫。你現在知道FFI是什麼樣子,你已經看到了一些內聯組合語言(我們稍後將更詳細地介紹),並且你已經進行了一個正確的系統呼叫來向控制台列印內容。你還看到了一個我們的標準函式庫試圖解決的問題,那就是你不需要知道這些系統呼叫就可以向控制台列印內容。
看圖說話:
此圖示總結了本章所探討的抽象層次,從最高層次的標準函式庫到最低層次的原始系統呼叫。標準函式庫(如Rust的println!)提供了最簡單、最跨平台的介面,它封裝了所有底層細節,讓開發者無需關心作業系統的具體實現。作業系統提供的API(如libc::write或kernel32::WriteConsoleW)則處於中間層次,它們提供了相對穩定的介面,允許開發者進行跨平台開發,但仍需處理不同作業系統API的差異和呼叫約定。而原始系統呼叫則是最底層的抽象,它直接與作業系統核心交互,提供了最大的控制權,但也是最難使用、最不穩定且平台/架構特定的方式。這個層次結構清晰地展示了在軟體開發中,抽象如何平衡易用性、可移植性與底層控制權。
玄貓認為,事件佇列與綠色執行緒是現代非同步程式設計的兩大核心支柱。透過深入理解其底層機制,特別是像epoll這樣的作業系統原語,並結合高層次的抽象,開發者能夠構建出高效能、高併發且具備良好可移植性的應用程式。然而,這需要對ISA、ABI、呼叫約定和堆疊等基礎概念有深刻的理解。
第二部分:事件佇列與綠色執行緒
在本部分中,玄貓將呈現兩個範例。第一個範例展示了使用epoll創建事件佇列的過程。我們將設計API,使其與mio所使用的API非常相似,這將使我們能夠掌握mio和epoll的基礎知識。第二個範例將說明纖程(fibers)/綠色執行緒(green threads)的使用,類似於tokio在Rust中實現非同步程式設計(使用futures和async/await)的方法。Rust在1.0版本之前也曾使用過綠色執行緒,使其成為Rust非同步歷史的一部分。在整個探索過程中,我們將深入研究ISA、ABI、呼叫約定、堆疊等基本程式設計概念,並觸及組合語言程式設計。本節包含以下章節:
- 第四章,創建你自己的事件佇列
- 第五章,創建我們自己的纖程
第四章:創建你自己的事件佇列
在本章中,我們將使用epoll創建一個簡單版本的事件佇列。我們將從mio中汲取靈感,mio是Rust非同步生態系統的基礎。從mio中汲取靈感還有一個額外的好處,那就是如果你想探索一個真正的生產級函式庫是如何運作的,那麼深入研究他們的程式碼庫會更容易。
玄貓認為,透過本章的學習,你應該能夠理解以下內容:
- 阻塞(blocking)和非阻塞(non-blocking)I/O之間的區別
- 如何使用
epoll來創建你自己的事件佇列 - 像
mio這樣的跨平台事件佇列函式庫的原始碼 - 如果我們希望程式或函式庫在不同平台上運作,為什麼我們需要在
epoll、kqueue和IOCP之上建立一個抽象層
我們將本章分為以下幾個部分:
- 設計與
epoll介紹 ffi模組Poll模組- 主程式
技術要求
本章重點關注epoll,它特定於Linux。不幸的是,epoll不是**可攜式作業系統介面(POSIX)**標準的一部分,因此這個範例將要求你在Linux上運行,並且不適用於macOS、BSD或Windows作業系統。
如果你正在運行Linux的機器上,你已經準備就緒,無需任何額外步驟即可運行這些範例。
如果你在Windows上,你可以安裝WSL(Windows Subsystem for Linux),如果你還沒有安裝的話,並在WSL上運行的Linux作業系統中安裝Rust。
如果你正在使用Mac,你可以創建一個運行Linux的虛擬機器(VM),例如透過DigitalOcean(甚至有一些提供商提供免費層),安裝Rust,並在控制台中編輯器(如Vim或Emacs)中開發,或者在遠端機器上開發,還有許多其他選項。
理論上可以在Rust Playground上運行這些範例,但由於我們需要一個延遲伺服器(delay server),我們將不得不使用一個接受純HTTP請求(非HTTPS)的遠端延遲伺服器服務,並修改程式碼,使模組都在一個文件中。這在緊急情況下是可行的,但玄貓不推薦這樣做。
延遲伺服器
這個範例依賴於對一個伺服器的呼叫,該伺服器會延遲回應一段可配置的時間。在程式碼庫的根資料夾中,有一個名為delayserver的專案。
你可以透過運行cargo run來啟動伺服器,並打開一個終端視窗,因為我們將在範例中使用它。
delayserver程式是跨平台的,因此它可以在Rust支援的所有平台上無需任何修改即可運作。如果你在Windows上運行WSL,玄貓建議你也在WSL中運行delayserver程式。根據你的設定,你可能可以在Windows控制台中運行伺服器,並且在WSL中運行範例時仍然能夠訪問它。請注意,它可能無法直接運作。
伺服器將預設監聽埠8080,但你可以透過將埠作為第一個參數傳遞來更改它,但請記住要在範例程式碼中進行相同的更正。
delayserver的實際程式碼不到30行,所以如果你想看看伺服器做了什麼,瀏覽程式碼應該只需要幾分鐘。
此圖示將展示事件佇列與綠色執行緒的關係,以及它們在非同步程式設計中的應用。
@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 "事件佇列 (Event Queue)" as EventQueue
component "綠色執行緒 / 纖程 (Green Threads / Fibers)" as GreenThreads
}
package "作業系統原語" {
component "epoll (Linux)" as Epoll
component "kqueue (BSD/macOS)" as Kqueue
component "IOCP (Windows)" as IOCP
}
package "高階非同步框架" {
component "mio (Rust I/O 多工)" as Mio
component "tokio (Rust 非同步執行時)" as Tokio
}
EventQueue -- Epoll : 底層實現 (Linux)
EventQueue -- Kqueue : 底層實現 (BSD/macOS)
EventQueue -- IOCP : 底層實現 (Windows)
GreenThreads -- EventQueue : 協同調度,利用事件通知
Mio -- EventQueue : 提供跨平台 I/O 多工抽象
Tokio -- GreenThreads : 執行非同步任務,管理纖程
Mio .u. Tokio : 協同工作,構建非同步生態
note right of EventQueue : 集中管理 I/O 事件通知
note right of GreenThreads : 輕量級併發單元,由應用程式調度
note right of Epoll : Linux 高效 I/O 事件通知機制
end note
end note
end note
@enduml看圖說話:
此圖示闡明了事件佇列與綠色執行緒在現代非同步程式設計中的核心地位。事件佇列作為底層機制,透過作業系統原語(如Linux的epoll、BSD/macOS的kqueue和Windows的IOCP)來高效地收集和通知I/O事件。而**綠色執行緒(或纖程)**則是輕量級的併發單元,由應用程式(而非作業系統)進行調度,它們利用事件佇列的通知來協同執行非同步任務。像mio這樣的函式庫為這些底層作業系統原語提供了跨平台的抽象,而tokio則在此基礎上進一步構建,提供了完整的非同步執行時環境,管理綠色執行緒的生命週期和調度。這種分層架構使得開發者能夠編寫出高效、反應迅速且易於維護的非同步應用程式,同時也突顯了理解這些底層機制對於構建穩健系統的重要性。
玄貓認為,設計一個高效的事件佇列,特別是基於epoll的,需要平衡抽象層次的優缺點。高層次的抽象(如mio)提供了跨平台兼容性和易用性,但可能犧牲特定平台的最佳效能。而深入理解底層機制(如epoll的運作方式)則能幫助開發者在必要時進行精細優化,並更好地理解非同步生態系統的運作原理。
深入剖析軟體開發的抽象層次後,其核心精神與高階管理者的成長路徑高度契合。從高階的標準函式庫到低階的系統呼叫,每一層都代表著便利性與底層控制權之間的策略性權衡。
僅滿足於高層次抽象的專業人士,如同只懂策略框架卻不諳組織動力的管理者,雖能快速產出,卻在面對深層瓶頸時無力突破。真正的創新動能,來自於將epoll等底層原語的運作知識,與高階非同步框架的設計哲學融會貫通。這種跨層次的整合能力,是將單點技能轉化為系統性洞察,進而解決複雜問題的關鍵所在。
未來,高效能系統的競爭力,將取決於對底層I/O模型與上層協程調度(如綠色執行緒)協同運作的深度掌握。能夠駕馭這整條技術堆疊的專家,將主導下一代非同步架構的演進。
玄貓認為,這種穿透抽象、直探本質的修養,已是專業人士從「資深」邁向「卓越」的關鍵分野,是構築長期、無法輕易複製的核心競爭力之基石。