Learning Objectives
- 說明 trap-and-emulate 怎麼讓客體作業系統以為自己掌控硬體,以及 Popek 與 Goldberg 的條件
- 比較 Type 1 與 Type 2 Hypervisor、虛擬機與容器
- 預測不同 namespace 下同一個行程看到什麼,說明容器為什麼共用核心
- 用 cgroup 的 memory.max、pids.max、cpu.max 判斷超過限制時會發生什麼
- 把整個作業系統課程串成一條線,知道接下來可以往哪裡學
作業系統也可以被騙
從第 1 課開始,作業系統都是「掌控一切」的角色:只有它能跑在核心模式,只有它能碰頁表和硬體。
現在有兩個需求:一台實體機器要同時跑 Windows 和 Linux;或是同一台機器上要跑一百個服務,彼此不能互相干擾、也不該互相看到。
第一個需求要讓作業系統以為自己獨佔硬體,這是虛擬機。第二個需求要讓行程以為自己獨佔一台機器,這是容器。兩者聽起來很像,做法與代價完全不同。
虛擬機為什麼可行:trap-and-emulate
Hypervisor(也叫 VMM,虛擬機監視器)是管理虛擬機的軟體。難處在於:客體作業系統(guest OS)會執行特權指令,例如關中斷、改頁表。真的讓它跑在核心模式,它就接管了整台機器。
解法用到第 1 課的機制:
- 把客體作業系統放在使用者模式跑,一般指令直接由 CPU 執行,速度不受影響。
- 它一執行特權指令,CPU 因為權限不足引發陷阱(trap),控制權回到 Hypervisor。
- Hypervisor 模擬這個指令的效果,但只改客體自己看到的那份狀態(例如「客體的中斷開關」),然後讓它繼續。
客體完全不知道這件事,這叫 trap-and-emulate。
Popek 與 Goldberg 在 1974 年的論文(Communications of the ACM)把這個想法寫成條件。他們把指令分成兩類:
- 特權指令(privileged):在使用者模式執行會引發陷阱。
- 敏感指令(sensitive):會讀取或改變機器的狀態,或行為依目前狀態而不同。
只要所有敏感指令都是特權指令,就能用 trap-and-emulate 做出有效的虛擬機。 反過來,有敏感指令不會引發陷阱的話,Hypervisor 就看不到客體做了這件事。
早期的 x86 正好不符合這個條件。例如 POPF:Intel 手冊寫明權限不足時不會產生例外,特權位元就是不改變。客體以為關了中斷,其實什麼都沒發生。下面用 Python 模擬這件事。
一個攔不到的指令
cli、sti 是關中斷與開中斷的特權指令。popf_off 代表用 POPF 關中斷。程式裡的指令名稱與流程是教學用的簡化,不是真正的 Hypervisor。注意 guest_if 和 wanted_if 什麼時候開始不一樣。
實作:這台機器能用 trap-and-emulate 嗎
補完 problems:回傳「敏感但不是特權」的指令(排序後的串列)。串列是空的,就符合 Popek 與 Goldberg 的條件。
機器 A、B 是假設的;第三組是 VT-x 之前的 x86 實際的幾個例子(課程講義記載的 popf、sgdt、sidt、sldt、smsw,不是完整清單)。
Hypervisor 的兩型與硬體輔助
x86 不符合條件,早期只能繞路:二進位轉譯(執行前把敏感指令改寫掉)、半虛擬化(修改客體作業系統,讓它主動呼叫 Hypervisor)。2005 年前後,Intel VT-x 與 AMD-V 加入硬體支援:CPU 多了一個給 Hypervisor 用的模式,客體可以跑在原本的特權層,敏感操作由硬體攔下來交給 Hypervisor。之後的 EPT、NPT 再讓硬體直接處理第 14 課的頁表轉換多的那一層,降低記憶體虛擬化的成本。
| 類型 | 位置 | 例子 | 適合 |
|---|---|---|---|
| Type 1(原生) | 直接跑在硬體上 | VMware ESXi、Xen、Microsoft Hyper-V | 資料中心 |
| Type 2(寄宿) | 跑在一般作業系統上 | VirtualBox、VMware Workstation | 開發者的個人電腦 |
Linux 的 KVM 是核心裡的一個模組,把 Linux 本身變成 Hypervisor,所以不太能明確歸到哪一型。
虛擬機的隔離很強,因為每台都有自己的核心;代價是每台都要帶一整份作業系統,開機要時間、記憶體也要先分出去。
容器不是虛擬機
容器沒有自己的核心。容器就是一般的行程,核心替它加了兩種限制:
- namespace:決定行程看得到什麼。
- cgroup:決定行程能用多少。
依 man7.org 的 namespaces(7),Linux 有這幾種 namespace:
| namespace | 隔離什麼 |
|---|---|
| PID | 行程編號 |
| Mount | 掛載點 |
| Network | 網路裝置、協定堆疊、連接埠 |
| UTS | 主機名稱 |
| IPC | System V IPC 與 POSIX 訊息佇列 |
| User | 使用者與群組編號 |
| Cgroup | cgroup 根目錄 |
| Time | 開機時鐘與單調時鐘 |
建立的方式是三個系統呼叫:clone(建立新行程時帶 CLONE_NEW* 旗標)、unshare(把自己搬到新的 namespace)、setns(加入既有的 namespace)。每個行程的 /proc/<pid>/ns/ 列出它屬於哪些 namespace,編號相同就是同一個。
下面這支程式印出自己的 cgroup 與 namespace,再用 unshare 建立新的 user 與 PID namespace(unshare 對 PID namespace 不會搬動自己,只影響之後的子行程),然後 fork。真實環境執行的結果:
kernel: 7.0.0-1014-aws
--- /proc/self/cgroup
0::/../../ce-sandbox/NSJAIL.13781
--- /proc/self/ns
pid:[4026532466]
mnt:[4026532461]
net:[4026532468]
uts:[4026532464]
user:[4026532460]
cgroup:[4026532467]
my pid (as I see it): 2
child in new PID namespace: getpid() = 1, getppid() = 0
parent sees child as: 3
在真正的 Linux 上實際執行的結果(Compiler Explorer 的 Linux x86-64 環境,gcc 13.3,2026-11-04)。
看兩件事。第一,同一個子行程:父行程看到的 PID 是 3,子行程自己看到的是 1,它的 getppid() 是 0,因為父行程在它的 namespace 之外。第二,這段輸出來自 Compiler Explorer,它本身就把每支程式關在 NSJAIL 沙箱裡,所以 cgroup 路徑寫著沙箱的名字、自己的 PID 也只有 2。你正在看容器技術跑在容器技術裡。
cgroup:能用多少
namespace 只管看得到什麼。一個容器看不到別人,還是可以把整台機器的記憶體吃光。cgroup(control group)負責限制與統計資源。
cgroup v2 是一個檔案系統形式的階層,每個群組是一個目錄,把 PID 寫進 cgroup.procs 就把行程放進這個群組;/proc/self/cgroup 顯示自己在哪裡,v2 的格式是 0::/路徑(上面的真實輸出就是)。
kernel.org 的 cgroup v2 文件規定的語意:
| 檔案 | 意思 | 超過時 |
|---|---|---|
memory.max |
記憶體硬性上限 | 回收不了就在這個 cgroup 裡觸發 OOM killer |
memory.high |
節流用的上限 | 行程被放慢並承受回收壓力,不會觸發 OOM killer |
pids.max |
行程數上限 | 建立新行程回傳 EAGAIN |
cpu.max |
格式 $MAX $PERIOD,每個週期最多用多少微秒,預設 max 100000 |
用完就等下一個週期(節流,throttle) |
io.max |
每個裝置的讀寫速率上限 | I/O 被延後 |
Docker 的選項就是在設定這些:docker run --memory、--cpus、--pids-limit。
隔離的代價,以及怎麼選
| 虛擬機 | 容器 | |
|---|---|---|
| 核心 | 每台自己一個 | 和主機共用 |
| 能跑什麼 | 任何作業系統 | 同一種核心上的程式 |
| 啟動與額外負擔 | 要開機、要一整份作業系統 | 只是開一個行程 |
| 隔離邊界 | 硬體虛擬化,攻擊面較小 | 系統呼叫介面,攻擊面是整個核心 |
| 資源分配 | 預先切分 | cgroup 動態限制 |
容器的隔離不只靠 namespace 和 cgroup,實務上還會降低權限:capabilities 拿掉不需要的 root 權力,seccomp 過濾能呼叫哪些系統呼叫。
怎麼選?
- 程式互相信任、要快速部署很多份:容器。
- 要跑不同的作業系統、要載入核心模組、或是跑不信任的別人的程式(公有雲的多租戶):虛擬機。
- 兩者常一起用:公有雲給你一台虛擬機,你在裡面跑容器。
第 1 課說系統呼叫是唯一的門。容器把這扇門當成邊界,虛擬機把邊界往下移到硬體指令。邊界越低,要騙的東西越多,但隔離也越強。
確認一下
在 trap-and-emulate 下,客體作業系統執行一般的加法指令,會發生什麼?
一台架構有一個指令:在使用者模式執行時讀到「目前是使用者模式」,不引發陷阱。這對 trap-and-emulate 有什麼影響?
容器裡的行程執行 ps,看到 PID 1 是自己的 web 程式。主機上的 ps 看到這個行程是 2301。下面哪個正確?
容器設了 memory.max,裡面的程式配置超過上限,而且記憶體回收不了。會發生什麼?
要在同一台 Linux 伺服器上讓客戶上傳並執行任意程式,而且客戶彼此不信任。比較合理的是?
全軌回顧:作業系統做了哪三件事
十八課走完,可以把它們收成三個問題。(這個分法來自 OSTEP 這本免費教科書:虛擬化、並行、持久性。)
1. 怎麼讓每個程式以為自己獨佔機器?(虛擬化)
| 課 | 內容 | 帶走一句話 |
|---|---|---|
| 1 | 作業系統與系統呼叫 | 程式碰不到硬體,系統呼叫是唯一的門 |
| 2 | 行程與狀態 | 行程是跑起來的程式,CPU 靠內容切換輪流用 |
| 3 | fork、exec、wait | Shell 就是這三個系統呼叫的迴圈 |
| 4 | 管線與訊號 | fd 是一層間接,所以能重新導向 |
| 5 | CPU 排程 | 周轉時間與回應時間互相拉扯 |
| 6 | MLFQ 與多核 | 不知道工作長度,就邊跑邊觀察 |
| 12 | 位址空間與 malloc | 每個行程看到的記憶體都是自己的 |
| 13 | 分頁與 TLB | 用查表換隔離,用快取補回速度 |
| 14 | 多層頁表與寫入時複製 | 需要時才配置,fork 才會便宜 |
| 15 | 分頁置換與顛簸 | 記憶體不夠時,區域性決定一切 |
| 18 | 虛擬化與容器 | 同一招再用一次,連作業系統都騙 |
2. 大家一起跑時,怎麼不出錯?(並行)
| 課 | 內容 | 帶走一句話 |
|---|---|---|
| 7 | 執行緒 | 共用位址空間,好用也危險 |
| 8 | 競爭條件 | 測試通過不能證明沒有競爭條件 |
| 9 | 鎖 | 鎖本身要靠硬體的原子指令 |
| 10 | 條件變數與號誌 | 條件一定要用 while 檢查 |
| 11 | 死結 | 實務上最有效的是固定鎖的順序 |
3. 斷電之後,資料還在嗎?(持久性)
| 課 | 內容 | 帶走一句話 |
|---|---|---|
| 16 | I/O 與磁碟排程 | 看裝置的成本結構決定策略 |
| 17 | 檔案系統與當機復原 | 讓最後一步成為唯一的決定點 |
貫穿全軌的是幾個反覆出現的想法:
- 多一層間接:頁表、fd 表、inode、FTL、namespace,都是在名稱和實體之間加一層,換到隔離與彈性。
- 用快取補回速度:TLB、頁快取、dentry cache。多一層間接會變慢,就靠區域性補回來。
- 把機制和策略分開:內容切換是機制,排程演算法是策略;頁表是機制,置換演算法是策略。
- 沒有免費的東西:每個設計都有代價,要問的是這個情況付得起哪一種。
往下學什麼
- 動手用容器:Docker 容器軌 把這一課的 namespace 與 cgroup 用起來。
- 管理真實系統:Linux 軌 練
ps、df、mount、權限與服務。 - 寫更多系統程式:C++ 軌 的指標、堆積與執行緒,可以看到這些概念在語言裡的樣子。
- 讀原文:OSTEP(Operating Systems: Three Easy Pieces)作者免費公開,涵蓋本軌的所有主題,附有實作作業。
- 讀真的核心:xv6 是 MIT 用來教學的簡單類 Unix 作業系統,程式碼短到可以讀完;另外
man 2 fork、man 7 namespaces這些 man page 就是最準確的文件。
要記得:這一軌的教學核心為了讓你看得見,做了很多簡化(沒有位址隨機化、記憶體模型順序一致、機器指令是簡化過的)。走到真實系統時,以 man page 與實際執行的結果為準。
重點整理
- 虛擬機靠 trap-and-emulate:一般指令直接跑,特權指令交給 Hypervisor 模擬。Popek 與 Goldberg 的條件是所有敏感指令都要是特權指令;早期 x86 不符合,後來靠硬體輔助解決。
- 容器是行程加上 namespace(看得到什麼)與 cgroup(能用多少),共用同一個核心。
- 同一個行程在不同 PID namespace 有不同的 PID;連接埠屬於 network namespace;掛載屬於 mount namespace。
- memory.max 超過時在 cgroup 內 OOM,pids.max 超過時 fork 失敗,cpu.max 用完就節流。
- 容器的邊界是系統呼叫,虛擬機的邊界是硬體指令。要跑不信任的程式,邊界要低。
作業系統軌到這裡結束。遇到問題時,回來問那三個問題,通常知道該看哪裡。