2.1 系統呼叫與 libc crate:std::process、Command 與 fork/exec#
使用者與核心的邊界#
1974 年 Ritchie 與 Thompson 在 CACM 發表的 Unix 論文確立了核心/使用者分離:應用程式不能直接碰硬體,必須透過系統呼叫(syscall)陷入核心。x86-64 上這是 syscall 指令(AMD 於 2000 年代初在 K8 引入,取代老舊的 int 0x80):使用者程式把系統呼叫號放進 rax、參數放進 rdi, rsi, rdx, r10, r8, r9,然後陷入核心。
我們用組語直接驗證「系統呼叫其實就是一條指令」:
# exit(42) 的純組語(x86-64 Linux, NASM 語法)
section .text
global _start
_start:
mov rax, 60 # sys_exit 的呼叫號
mov rdi, 42 # 第一個參數:結束碼
syscall
在 Rust 裡做同樣的事:
unsafe { libc::syscall(libc::SYS_exit, 42); }
libc crate 就是 C header 的機器翻譯:man 2 fork 對應 libc::fork,man 2 pipe 對應 libc::pipe。本書所有「到底發生了什麼」的段落,最終都會落到這一層。
fork/exec:1970 年代至今的程序模型#
Unix 建立新程序的設計獨樹一格:不是「spawn 一個新程式」,而是拆成兩步:
fork():核心複製目前程序(寫時複製,copy-on-write),回傳兩次——父程序得到子 pid,子程序得到 0。execve():把目前程序的位址空間整個換成新程式。
這個 1970 年代由 Ken Thompson 設計的 API 至今未變,因為 fork 之後、exec 之前的縫隙是無價的:你可以在此時重導 fd(dup2)、設定訊號、關檔案——shell 的 ls | grep foo 就靠這條縫。
C 寫法(教科書經典):
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
execlp("ls", "ls", "-l", (char*)NULL);
_exit(127); // exec 失敗才會到這
}
int status;
waitpid(pid, &status, 0);
return 0;
}
Rust 用 std::process::Command 達成同樣的事,但把 fork/exec 的縫隙變成建構者 API:
use std::process::Command;
fn main() -> std::io::Result<()> {
let out = Command::new("ls")
.arg("-l")
.output()?; // fork + exec + 收 stdout/stderr
println!("{}", String::from_utf8_lossy(&out.stdout));
Ok(())
}
Command 內部做的事正是 posix_spawn/fork+execvp+waitpid,而它的型別設計保證了你無法在 exec 之後「忘記」收屍——Child 物件持有子程序的 pid,Child::wait() 消耗自己並回傳 ExitStatus。若你 drop 了 Child 而不 wait,得到的是殭屍程序(zombie)——這是 Unix 的老問題,Rust 只能提醒(會印警告)而不能完全禁止,因為這是核心的行為,不是語言的。
手寫 fork/exec:看見縫隙#
為了教學,我們用 libc 直接重現 shell 的核心邏輯(本書第 5 章的容器執行器會再回來用到這個模式):
use std::ffi::CString;
unsafe fn run_ls() -> i32 {
let pid = libc::fork();
if pid < 0 { panic!("fork failed"); }
if pid == 0 {
// 子程序:這裡就是「縫隙」,可以先做 fd 操作
let prog = CString::new("/bin/ls").unwrap();
let arg0 = CString::new("ls").unwrap();
let arg1 = CString::new("-l").unwrap();
let argv = [arg0.as_ptr(), arg1.as_ptr(), std::ptr::null()];
let envp = [std::ptr::null()];
libc::execve(prog.as_ptr(), argv.as_ptr(), envp.as_ptr());
libc::_exit(127); // 注意:不可用 std::process::exit(可能跑 atexit)
}
let mut status = 0;
libc::waitpid(pid, &mut status, 0);
(status >> 8) & 0xff // WEXITSTATUS
}
fn main() {
println!("exit = {}", unsafe { run_ls() });
}
兩個所有權相關的細節值得停在原地:
- fork 之後的多執行緒問題:POSIX 規定 fork 只複製呼叫執行緒,若程序中其他執行緒正握著 malloc 的鎖,子程序就死鎖。這是 Unix 五十年的經典地雷;Rust 的對策是讓
Command走posix_spawn(現代 glibc 在無複雜操作時用它,避免真 fork),並且 std 保證 exec 前的子程序裡「只有 async-signal-safe 的程式碼在跑」。 - exec 失敗的清理:範例中用
libc::_exit而非std::process::exit,因為後者會執行 atexit handler,可能操作一個在 fork 時狀態已不一致的 std。
環境變數與工作目錄#
Command 的建構者介面把「縫隙操作」變成明確參數,這是 2010 年代 API 設計(宣告式取代命令式)的示範:
Command::new("sh").arg("-c").arg("echo $HOME")
.env("HOME", "/root") // exec 前設定環境
.current_dir("/tmp") // exec 前 chdir
.stdin(Stdio::null())
.status()?;
第 5 章的 mini-docker 中,我們會用同樣的介面加上 pre_exec(在 fork 之後、exec 之前執行 closure,內部用 unsafe 包 setns、chroot、setsid)來建立容器——你可以把容器執行器理解為「把 fork/exec 縫隙裡塞滿隔離操作的 shell」。
strace:觀察一切#
$ strace -f cargo run 2>&1 | grep -E 'execve|clone|wait4'
execve("/usr/bin/ls", ["ls", "-l"], ...) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|SIGCHLD) = 12345
wait4(12345, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 12345
注意 clone 取代了老式 fork——glibc 的 fork() 內部就是 clone,第 5.2 節我們會直接用 clone 的 flags 建立容器。
習題#
- 用 strace 觀察
Command::new("true").status(),數出至少 5 個系統呼叫並解釋。 - 解釋為什麼「fork 之後立刻 exec」的模型可以活 50 年,而 Windows 的
CreateProcess(一次做完、參數十個結構體)相比之下靈活性差在哪。 - 寫一個 Rust 程式:fork 三個子程序依序執行
date、uname -a、df -h,父程序收集所有 exit code。