應用程式透過系統呼叫與作業系統溝通,是軟體開發中廣為人知的基本概念。然而,作業系統與硬體之間的互動,遠比API呼叫更為緊密。本文將深入探討CPU與作業系統的協同運作,聚焦於記憶體保護的實現原理。我們將從一個記憶體解引用操作出發,逐步拆解當程式試圖存取未授權記憶體時,硬體層(如記憶體管理單元MMU)如何偵測異常、觸發中斷,並將控制權轉移至作業系統核心。這個過程不僅解釋了「分段錯誤」的底層成因,更揭示了現代計算機系統如何利用特權級別等硬體特性,建構安全、隔離的執行環境,確保使用者程式無法直接干預核心運作或破壞系統穩定。
與作業系統通訊
與作業系統的通訊透過我們稱之為**系統呼叫(syscall)**的方式進行。我們需要知道如何進行系統呼叫,並理解它在我們與作業系統協同通訊時為何如此重要。我們還需要了解我們日常使用的基本抽象如何在幕後使用系統呼叫。玄貓將在第三章進行詳細的講解,所以現在只做簡要介紹。
系統呼叫使用作業系統提供的公開API,以便我們在「使用者空間」編寫的程式可以與作業系統通訊。
大多數時候,這些呼叫對我們程式設計師來說已經被抽象化了。系統呼叫是與你正在通訊的內核獨特的範例,但UNIX系列的內核有許多相似之處。UNIX系統透過libc暴露了這些功能。
此圖示將展示作業系統作為核心抽象層,如何管理硬體資源並提供系統呼叫介面。
@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 defaultFontSize 16
skinparam minClassWidth 100
rectangle "應用程式 (使用者空間)" as App {
component "你的程式碼" as YourCode
component "語言/函式庫抽象" as LangLib
}
rectangle "作業系統 (核心空間)" as OS {
component "系統呼叫介面 (Syscall API)" as SyscallAPI
component "行程排程器" as Scheduler
component "記憶體管理" as MemoryMgmt
component "檔案系統" as FileSystem
component "網路堆疊" as NetworkStack
component "設備驅動" as DeviceDrivers
}
rectangle "硬體" as Hardware {
component "CPU" as CPU
component "記憶體" as Memory
component "網卡" as NIC
component "儲存設備" as Storage
}
YourCode --> LangLib : 使用高階功能
LangLib --> SyscallAPI : 透過抽象層發出請求
SyscallAPI --> Scheduler : 請求CPU時間
SyscallAPI --> MemoryMgmt : 請求記憶體
SyscallAPI --> NetworkStack : 請求網路通訊
SyscallAPI --> DeviceDrivers : 請求硬體操作
Scheduler --> CPU : 排程行程/執行緒
MemoryMgmt --> Memory : 管理記憶體存取
NetworkStack --> NIC : 透過網卡通訊
DeviceDrivers --> Storage : 存取儲存設備
OS -[hidden]-> Hardware : 作業系統管理硬體
@enduml看圖說話:
此圖示描繪了作業系統在整個計算機系統中的核心地位,以及它如何作為硬體與應用程式(使用者空間)之間的關鍵抽象層。我們的程式碼通常透過語言或函式庫的抽象來編寫,這些抽象層在幕後會發出系統呼叫。系統呼叫是應用程式與作業系統核心通訊的唯一途徑,它提供了系統呼叫介面(Syscall API)。
作業系統核心內部包含了多個關鍵組件,例如行程排程器、記憶體管理、檔案系統、網路堆疊和設備驅動。這些組件負責管理和分配CPU、記憶體、網卡和儲存設備等硬體資源。當應用程式發出系統呼叫時,作業系統會根據請求,協調相應的核心組件來操作硬體。這個模型強調了作業系統作為資源管理和硬體抽象的不可或缺性,以及系統呼叫作為使用者程式與核心功能互動的橋樑。
玄貓認為,深入探討CPU與作業系統之間的協同作用,對於理解程式執行機制至關重要。這不僅揭示了硬體與軟體如何互動,也闡明了為何某些操作會引發系統級錯誤。
系統呼叫之外的互動:CPU與作業系統的協同
雖然UNIX系統透過libc暴露系統呼叫,而Windows則使用其專有的WinAPI,兩者在實作上可能存在顯著差異(例如在epoll、kqueue和IOCP等機制中),但它們最終都能實現相似的功能。然而,系統呼叫並非我們與作業系統互動的唯一方式。
CPU是否與作業系統協同?
如果你在玄貓剛開始理解程式運作方式時問這個問題,玄貓很可能會回答「不」。當時的直覺是:我們在CPU上執行程式,只要知道如何做,就可以隨心所欲。然而,除非你真正了解CPU和作業系統如何協同工作,否則很難確定。
讓玄貓開始意識到自己想法錯誤的,是一段類似於你即將看到的程式碼片段。如果你覺得Rust中的內聯組合語言看起來陌生且令人困惑,請暫時不用擔心。玄貓將在本書稍後對內聯組合語言進行適當的介紹。玄貓會確保逐行解釋以下內容,直到你對語法更加熟悉:
// 儲存庫參考:ch01/ac-assembly-dereference/src/main.rs
fn main() {
let t = 100;
let t_ptr: *const usize = &t;
let x = dereference(t_ptr);
println!("{}", x);
}
fn dereference(ptr: *const usize) -> usize {
let mut res: usize;
unsafe {
asm!("mov {0}, [{1}]", out(reg) res, in(reg) ptr)
};
res
}
你剛才看到的是一個用組合語言編寫的解引用函數。
mov {0}, [{1}] 這行需要一些解釋。{0} 和 {1} 是模板,它們告訴編譯器我們指的是由 out(reg) 和 in(reg) 代表的暫存器。數字只是一個索引,所以如果我們有更多的輸入或輸出,它們將被編號為 {2}、{3} 等。由於我們只指定 reg 而不是特定的暫存器,我們讓編譯器選擇它想要使用的暫存器。mov 指令指示CPU讀取 {1} 所指向的記憶體位置,並將其前8個位元組(如果我們在64位元機器上)放置在 {0} 所代表的暫存器中。[] 括號將指示CPU將該暫存器中的資料視為記憶體位址,並且不只是簡單地將記憶體位址本身複製到 {0},而是會擷取該記憶體位置的內容並將其移動過去。
無論如何,我們在這裡只是向CPU寫入指令。沒有標準函式庫,沒有系統呼叫;只是原始指令。作業系統不可能參與到那個解引用函數中,對吧?
如果你運行這個程式,你會得到預期的結果:
100
現在,如果你保留解引用函數,但將 main 函數替換為一個創建指向 99999999999999 這個我們知道是無效位址的指標的函數,我們得到這個函數:
fn main() {
let t_ptr = 99999999999999 as *const usize;
let x = dereference(t_ptr);
println!("{}", x);
}
現在,如果我們運行它,我們會得到以下結果。
在Linux上的結果是:
Segmentation fault (core dumped)
在Windows上的結果是:
error: process didn't exit successfully: `target\debug\ac-assembly-dereference.exe` (exit code: 0xc0000005, STATUS_ACCESS_VIOLATION)
我們得到一個分段錯誤。這並不令人驚訝,但你可能也注意到,我們在不同平台上得到的錯誤是不同的。顯然,作業系統以某種方式參與其中。讓我們看看這裡到底發生了什麼。
深入探討:CPU與作業系統的協同奧秘
事實證明,作業系統與CPU之間存在著大量的協同作用,但可能並非你直覺上所想的那樣。
此圖示將展示當應用程式嘗試存取無效記憶體位址時,CPU與作業系統如何協同處理分段錯誤。
@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 defaultFontSize 16
skinparam minClassWidth 100
actor "應用程式 (使用者模式)" as App
entity "CPU" as CPU
entity "記憶體管理單元 (MMU)" as MMU
entity "作業系統核心 (核心模式)" as OSKernel
App -> CPU : 執行 `dereference(無效位址)`
CPU -> MMU : 請求讀取 `無效位址`
MMU --> CPU : <<觸發>> 頁錯誤/保護錯誤 (Page Fault/Protection Fault)
CPU -> OSKernel : <<發出>> 中斷/異常 (Interrupt/Exception)
OSKernel -> OSKernel : 處理中斷 (檢查錯誤類型)
OSKernel --> App : <<發送>> 訊號 (例如 SIGSEGV) 或 終止行程 (例如 STATUS_ACCESS_VIOLATION)
App --> App : 收到訊號/行程終止 (Segmentation Fault)
@enduml看圖說話:
此圖示詳細描繪了當應用程式嘗試存取一個無效記憶體位址時,CPU與作業系統核心之間如何進行協同處理。當應用程式執行 dereference(無效位址) 指令時,CPU會將這個記憶體存取請求傳遞給記憶體管理單元(MMU)。MMU負責將邏輯位址轉換為物理位址,並檢查該存取是否合法。
由於位址無效,MMU會觸發一個頁錯誤或保護錯誤,並將此錯誤傳回給CPU。CPU隨後會發出一個中斷或異常,將控制權從使用者模式的應用程式轉移到核心模式的作業系統核心。作業系統核心接收到中斷後,會處理這個異常,判斷其為非法記憶體存取。最終,作業系統會向應用程式發送一個訊號(例如Linux上的SIGSEGV)或直接終止行程(例如Windows上的STATUS_ACCESS_VIOLATION),導致應用程式發生分段錯誤。這個過程清晰地展示了即使是看似底層的組合語言操作,也無法繞過作業系統對記憶體存取權限的嚴格管理。
玄貓認為,現代CPU與作業系統的緊密協同,是構建安全、穩定計算環境的基石。特別是記憶體保護機制,它確保了程式只能存取被授權的記憶體區域,從而防止惡意或錯誤的程式行為。
CPU與作業系統:記憶體保護與異步事件處理
許多現代CPU提供了作業系統所使用的基本基礎設施。這個基礎設施為我們提供了預期的安全性和穩定性。實際上,大多數先進的CPU提供了比Linux、BSD和Windows等作業系統實際使用的更多的選項。
玄貓在這裡特別想探討其中兩個方面:
- CPU如何阻止我們存取不應該存取的記憶體。
- CPU如何處理I/O等異步事件。
玄貓將首先討論第一個方面,第二個方面將在下一節中討論。
CPU如何阻止我們存取不應該存取的記憶體?
正如玄貓所提到的,現代CPU架構定義了一些基本概念:
- 虛擬記憶體
- 頁表
- 頁錯誤
- 異常
- 特權級別
這些概念的具體運作方式會因特定的CPU而異,因此玄貓在這裡將以通用術語來討論它們。
大多數現代CPU都配備了記憶體管理單元(MMU)。MMU通常與CPU的其他部分蝕刻在同一晶片上。MMU的工作是將我們在程式中使用的虛擬位址轉換為物理位址。
當作業系統啟動一個行程(例如我們的程式)時,它會為該行程建立一個頁表,並確保CPU上的一個特殊暫存器指向這個頁表。
現在,當我們嘗試解引用程式碼中的 t_ptr 時,該位址會在某個時刻被送往MMU進行轉換。MMU會在頁表中查找該位址,將其轉換為記憶體中的物理位址,然後從該物理位址擷取資料。
在第一個案例中(t = 100),它會指向我們堆疊上一個儲存值100的記憶體位址。
當我們傳入 99999999999999 並要求它擷取儲存在該位址的內容時(這正是解引用所做的事情),MMU會在頁表中查找轉換,但找不到。
CPU隨後將此視為一個頁錯誤。
在啟動時,作業系統向CPU提供了一個中斷描述符表。這個表具有預定義的格式,作業系統為CPU可能遇到的預定義條件提供了處理程序。
由於作業系統提供了一個指向處理頁錯誤函數的指標,當我們嘗試解引用 99999999999999 時,CPU會跳轉到該函數,從而通知我們發生了分段錯誤。因此,此訊息會根據你運行的作業系統而異。
我們不能直接修改CPU中的頁表嗎?
這就是特權級別發揮作用的地方。大多數現代作業系統以兩個環級別運作:環0(核心空間)和環3(使用者空間)。
此圖示展示了特權環的概念:
@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 defaultFontSize 16
skinparam minClassWidth 100
circle "環0\n(核心空間)" as Ring0
circle "環1" as Ring1
circle "環2" as Ring2
circle "環3\n(使用者空間)" as Ring3
Ring0 -[hidden]-> Ring1
Ring1 -[hidden]-> Ring2
Ring2 -[hidden]-> Ring3
note top of Ring0 : 最高特權級別\n直接存取硬體
note bottom of Ring3 : 最低特權級別\n受限存取
end note
end note
@enduml看圖說話:
此圖示展示了CPU的特權環(Privilege Rings)概念,特別是環0(核心空間)和環3(使用者空間)。環0代表最高特權級別,通常由作業系統核心使用,它擁有幾乎不受限制的硬體存取權限,包括直接修改頁表和CPU暫存器。而環3則代表最低特權級別,供應用程式(使用者程式)使用,其對I/O和某些CPU暫存器的存取受到嚴格限制。儘管許多CPU設計了更多的環級別(如環1和環2),但大多數現代作業系統僅使用環0和環3。這個機制是為了確保系統的安全性和穩定性,防止使用者程式執行惡意或錯誤的操作,例如直接修改記憶體管理單元(MMU)的頁表。
大多數CPU都有比大多數現代作業系統使用的更多環的概念。這有其歷史原因,這也是為什麼使用環0和環3(而不是1和2)。
頁表中的每個條目都有額外的信息,其中包括它所屬的環的信息。這些信息在你作業系統啟動時設定。
在環0中執行的程式碼幾乎可以不受限制地存取外部設備和記憶體,並且可以自由地更改在硬體層面提供安全性的暫存器。
你在環3中編寫的程式碼通常對I/O和某些CPU暫存器(以及指令)的存取受到極其嚴格的限制。從環3嘗試發出指令或設定暫存器來更改頁表將會被阻止。作業系統透過系統呼叫來處理這些任務。如果不是這樣,系統就不會很安全。
總結來說:是的,CPU和作業系統之間存在大量的協同作用。大多數現代桌面CPU在設計時就考慮到了作業系統,因此它們提供了作業系統在啟動時會利用的鉤子和基礎設施。當作業系統產生一個行程時,它也會設定其特權級別,確保普通行程在它定義的邊界內運行,以維持系統的穩定性和安全性。
深入剖析CPU與作業系統協同運作的核心機制後,我們清晰地看到,程式執行遠非單純的指令傳遞,而是一場由硬體規則與軟體策略共同編排的精妙舞蹈。許多開發者直覺地認為,透過組合語言等底層工具便能繞過作業系統,直接指揮CPU。然而,「分段錯誤」的實例揭示了這一認知的關鍵瓶頸:現代計算架構的基石,正是建立在CPU(透過MMU與特權環)與作業系統之間不可分割的信任與協作之上。使用者空間的自由,始終受制於核心空間所設定的安全邊界,而這種看似限制的設計,實則是保障系統穩定與多工運作的根本價值所在。
展望未來,即便上層框架與語言如何演進,這種硬體與作業系統的協同模式仍是不可動搖的底層邏輯。理解這層互動,將成為區分「程式應用者」與「系統架構師」的關鍵分水嶺。具備此洞察力的技術領導者,在進行技術選型、效能調校與安全架構設計時,將擁有更深刻的判斷力。
玄貓認為,穿透libc或WinAPI等高階抽象,去理解特權環、頁錯誤與中斷處理的運作真相,不僅是解決底層問題的必要技能,更是建立穩固技術世界觀的基石,值得每位追求卓越的技術領導者投入心力深究。