跳轉到

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 一個新程式」,而是拆成兩步:

  1. fork():核心複製目前程序(寫時複製,copy-on-write),回傳兩次——父程序得到子 pid,子程序得到 0。
  2. 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 建立容器。

習題#

  1. 用 strace 觀察 Command::new("true").status(),數出至少 5 個系統呼叫並解釋。
  2. 解釋為什麼「fork 之後立刻 exec」的模型可以活 50 年,而 Windows 的 CreateProcess(一次做完、參數十個結構體)相比之下靈活性差在哪。
  3. 寫一個 Rust 程式:fork 三個子程序依序執行 date、uname -a、df -h,父程序收集所有 exit code。