Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

1.17 Rust 系统编程:所有权、错误、FFI 与 C 对照

本章回答

Rust 的所有权、借用、生命周期在系统编程里到底约束了什么?Result/? 与 C 的 errno 怎么互转?什么时候必须 unsafe,边界怎么画?用 std::os::unix、libc、nix、rustix 写文件、线程、socket,与 C 版本相比少踩哪些坑、又多出哪些成本?Linux 内核的 Rust 模块又受哪些额外约束?

读完你会

写不泄漏、不 double-free 的 Rust 系统程序;用 Result 处理 EINTR/EAGAIN/短写;把 unsafe 收进一个带 // SAFETY: 的安全封装;与 C 库互调;判断何时用 Rust、何时仍用 C。

1.16 讲了 C/Rust 的工具链与 FFI:同一套 ELF、同一套 ABI、同一个链接器。但 Rust 真正改变系统编程的,不是语法,而是把资源生命周期和错误路径变成类型系统的一部分。这一章把所有权、Result、unsafe 放进文件、线程、socket 三个真实场景,再回看 Linux 内核里的 Rust 模块。

阅读方式

本章代码块带「架构 × 语言」切换器。纯 Rust 语义的部分标为 all;涉及系统调用入口、寄存器或 FFI 的代码分架构;汇编只在与 C/Rust 对照时出现。所有 Rust 代码按 rustc --edition 2021 或 cargo check 编写,C 代码按 -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror 编写。

1.17.1 所有权、借用与生命周期:三条硬约束

Rust 的所有权不是「内存管理风格」,而是一组在编译期检查的资源使用规则。在系统编程里,它主要约束三件事:

约束系统编程含义C 的对应风险
一个值只有一个所有者fd、缓冲区、锁守卫随作用域自动释放忘记 close/free、double-free
借用不能超过所有者生命周期&[u8] 指向的缓冲区不能被提前释放悬垂指针、use-after-free
可变借用独占同一时刻只有一个 &mut,编译期排除数据竞争两个线程写同一结构体
// ownership.rs — 所有权随作用域释放;rustc --edition 2021 ownership.rs
use std::fs::File;
use std::io::{self, Read};

fn read_all(path: &str) -> io::Result<Vec<u8>> {
    let mut f = File::open(path)?;      // 失败自动返回 Err
    let mut buf = Vec::new();
    f.read_to_end(&mut buf)?;
    Ok(buf)                              // f 在函数结束时自动 close
}

fn main() -> io::Result<()> {
    let data = read_all("/etc/hosts")?;
    println!("{} bytes", data.len());
    Ok(())
}
/* ownership.c — 同样的读取;每条错误路径都要自己 close
 * cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror ownership.c -o ownership
 */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int read_all(const char *path, char **out, size_t *out_len)
{
    int fd = open(path, O_RDONLY);
    if (fd < 0)
        return -1;
    size_t cap = 4096, len = 0;
    char *buf = malloc(cap);
    if (!buf) { close(fd); errno = ENOMEM; return -1; }
    for (;;) {
        if (len == cap) {
            char *nb = realloc(buf, cap * 2);
            if (!nb) { free(buf); close(fd); errno = ENOMEM; return -1; }
            buf = nb; cap *= 2;
        }
        ssize_t n = read(fd, buf + len, cap - len);
        if (n < 0) {
            if (errno == EINTR) continue;
            free(buf); close(fd); return -1;
        }
        if (n == 0) break;
        len += (size_t)n;
    }
    close(fd);
    *out = buf; *out_len = len;
    return 0;
}

int main(void)
{
    char *buf = NULL; size_t len = 0;
    if (read_all("/etc/hosts", &buf, &len) < 0) {
        perror("read_all"); return 1;
    }
    printf("%zu bytes\n", len);
    free(buf);
    return 0;
}

C 版本要处理 open、malloc、realloc、read 的每一条失败路径,并在每条路径上 close/free;漏一条就是 fd 泄漏或内存泄漏。Rust 的 ? 在返回 Err 时仍会运行 Drop,File 的 Drop 负责 close,Vec 的 Drop 负责释放缓冲区。这不是「Rust 更聪明」,而是析构顺序由类型系统保证。

借用与生命周期:把「谁活得久」写进类型

// borrow.rs — 借用检查器拒绝悬垂引用;以下函数若去掉注释会编译失败
fn first_word(s: &str) -> &str {
    s.split_whitespace().next().unwrap_or("")
}

// fn dangling() -> &str {
//     let s = String::from("hello");
//     &s[..]                 // error: `s` does not live long enough
// }

struct Parser<'a> {
    input: &'a str,
    pos: usize,
}

impl<'a> Parser<'a> {
    fn new(input: &'a str) -> Self {
        Self { input, pos: 0 }
    }
    fn rest(&self) -> &'a str { &self.input[self.pos..] }
}

fn main() {
    let text = String::from("hello world");
    println!("{}", first_word(&text));
    let p = Parser::new(&text);
    println!("{}", p.rest());
}

Parser<'a> 的生命周期参数表示:Parser 不能比它借用的 input 活得更久。C 里对应的是「文档约定 + 调用者自律」;Rust 把它变成编译错误。代价是类型签名变复杂,尤其是返回引用的函数、trait object、异步代码。经验法则是:先让编译器推断,只有在签名必须表达「返回值和某个参数同寿命」时才手写 'a。

所有权的四个常见误解
  1. 「Rust 没有内存泄漏」:Box::leak、Rc 循环引用、mem::forget 都能泄漏;所有权防的是 use-after-free/double-free,不是所有资源泄漏。
  2. 「&mut 就是 C 的指针」:&mut T 还要求独占(no aliasing),编译器会据此做优化;把同一个 &mut 别名给两个线程是 UB。
  3. 「Drop 一定运行」:panic=abort、process::exit、mem::forget 都不会运行 Drop;系统编程里要显式处理这些路径。
  4. 「Arc<Mutex<T>> 就没有数据竞争」:它防的是 Rust 层面的数据竞争;死锁、优先级反转、锁 poisoning 仍在。

1.17.2 错误:Result、? 与 errno 的边界

Rust 把错误分成两类:可恢复错误用 Result<T, E>,不可恢复错误用 panic!。系统编程的绝大多数失败(EACCES、ENOENT、EINTR、EAGAIN)都是可恢复的,应该走 Result,而不是 unwrap()。

// errors.rs — 自定义错误、?、errno 映射;rustc --edition 2021 errors.rs
use std::error::Error;
use std::fmt;
use std::fs::File;
use std::io::{self, Read};

#[derive(Debug)]
enum LoadError {
    Io(io::Error),
    Empty,
}

impl fmt::Display for LoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            LoadError::Io(e) => write!(f, "io: {e}"),
            LoadError::Empty => write!(f, "file is empty"),
        }
    }
}

impl Error for LoadError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            LoadError::Io(e) => Some(e),
            LoadError::Empty => None,
        }
    }
}

impl From<io::Error> for LoadError {
    fn from(e: io::Error) -> Self { LoadError::Io(e) }
}

fn load(path: &str) -> Result<Vec<u8>, LoadError> {
    let mut f = File::open(path)?;       // io::Error -> LoadError
    let mut buf = Vec::new();
    f.read_to_end(&mut buf)?;
    if buf.is_empty() { return Err(LoadError::Empty); }
    Ok(buf)
}

fn main() {
    match load("/etc/hosts") {
        Ok(b) => println!("{} bytes", b.len()),
        Err(e) => {
            eprintln!("load failed: {e}");
            if let Some(src) = e.source() { eprintln!("caused by: {src}"); }
            std::process::exit(1);
        }
    }
}

C 的错误处理是「返回值 + errno」:open 返回 -1,errno 保存原因;read 返回 -1/0/正数;write 可能短写。Rust 的 std 把这些映射成 io::Error,但不会自动重试所有 EINTR,也不会把 EAGAIN 变成「等待」。

C 错误errnoRust ErrorKind正确处理
文件不存在ENOENTNotFound返回错误/创建文件
权限不足EACCES/EPERMPermissionDenied提示用户/提权
被信号打断EINTRInterrupted重试(裸 syscall 必须自己重试)
非阻塞暂无数据EAGAIN/EWOULDBLOCKWouldBlock交给 poll/epoll/async
管道读端关闭EPIPEBrokenPipe停止写,忽略 SIGPIPE
磁盘满ENOSPCStorageFull(较新)清理/报错
参数非法EINVALInvalidInput修正调用

io::Error::last_os_error() 读取当前线程的 errno;raw_os_error() 取原始整数;from_raw_os_error(n) 反向构造。必须在失败后立刻读,因为中间任何库调用都可能覆盖 errno:

// errno.rs — 读 errno 的正确与错误姿势
use std::fs::File;
use std::io;

fn main() {
    match File::open("/definitely/not/here") {
        Ok(_) => unreachable!(),
        Err(e) => {
            // 正确:e 已经保存了失败瞬间的 errno
            println!("kind={:?} raw={:?}", e.kind(), e.raw_os_error());
            // 错误:先做别的事再读 last_os_error,可能已被覆盖
            let _ = std::env::var("PATH");
            println!("last_os_error={:?}", io::Error::last_os_error().raw_os_error());
        }
    }
}
Rust 不会自动帮你处理 EINTR

File::write/File::read 在标准库里对 EINTR 做了重试,但直接调 libc::write/nix::unistd::write 不会;accept、connect、openat 等裸调用也要自己判断 ErrorKind::Interrupted。EAGAIN 不是错误,非阻塞 fd 要交给 poll/epoll 或 async runtime。

ABI 边界:寄存器与调用指令

项x86-64 SysVARM64 AAPCS64RISC-V 64 psABI
整数/指针参数rdi, rsi, rdx, rcx, r8, r9x0–x7a0–a7
返回值raxx0a0
调用/返回call/retbl/retjal/ret(call 伪指令)
被调用者保存rbx, rbp, r12–r15x19–x29s0–s11
栈对齐16 字节16 字节16 字节
系统调用syscallsvc #0ecall

Rust 的 extern "C" 按这张表传参;std::os::unix 只是在 libc 之上包了一层,不改变 ABI。FFI 结构体用 #[repr(C)],整数用固定宽度类型,才能让三套架构都读到相同偏移。

系统调用边界:三套指令(与 C 对照)

Rust 的 std 最终也要发系统调用。下面三段汇编是 write(1, msg, len) 的最小入口,用来对照 C 的 syscall() 与 Rust 的 asm!:

# sys_write_x86_64.s — clang --target=x86_64-linux-gnu -c
    .text
    .globl sys_write
    .type sys_write, @function
sys_write:
    movq    $1, %rax          # SYS_write
    syscall                   # rdi=fd, rsi=buf, rdx=len
    ret                       # 返回 rax;失败为 -errno
    .size sys_write, . - sys_write
// sys_write_arm64.s — clang --target=aarch64-linux-gnu -c
    .text
    .globl sys_write
    .type sys_write, %function
sys_write:
    mov     x8, #64           // SYS_write
    svc     #0                // x0=fd, x1=buf, x2=len
    ret                       // 返回 x0
    .size sys_write, . - sys_write
# sys_write_riscv64.s — clang --target=riscv64-linux-gnu -c
    .text
    .globl sys_write
    .type sys_write, @function
sys_write:
    li      a7, 64            # SYS_write(asm-generic)
    ecall                     # a0=fd, a1=buf, a2=len
    ret                       # 返回 a0
    .size sys_write, . - sys_write

这三段不是「Rust 专属」:C 的 syscall(SYS_write, ...) 最终也走到同一条指令。差别在错误约定:裸系统调用返回 -errno,libc 包装返回 -1 并设置 errno。Rust 的 std::io 在 libc 之上做映射;如果你用 asm! 直接发系统调用,就要自己把负返回值转成 io::Error::from_raw_os_error(-ret)。

1.17.3 unsafe 的边界:小、可证明、封装

unsafe 不是「关掉检查」,而是「由你承担编译器无法验证的前置条件」。好的 unsafe 有三个特征:范围小、每个块有 // SAFETY: 说明、对外暴露安全 API。坏的做法是把整个模块包进 unsafe,让调用者替你还债。

// safe_fd.rs — 用 RAII 封装 raw fd;rustc --edition 2021 safe_fd.rs
use std::io;
use std::os::fd::{FromRawFd, RawFd};
use std::os::unix::io::AsRawFd;
use std::fs::File;

/// 拥有所有权的 fd;Drop 时关闭。
pub struct OwnedFd(File);

impl OwnedFd {
    /// # Safety
    /// `fd` 必须是有效的、由调用者转移所有权的文件描述符。
    pub unsafe fn from_raw(fd: RawFd) -> Self {
        // SAFETY: 调用者保证 fd 有效且所有权转移;File 会负责 close。
        OwnedFd(unsafe { File::from_raw_fd(fd) })
    }

    pub fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
        use std::io::Write;
        self.0.write_all(buf)
    }

    pub fn raw(&self) -> RawFd { self.0.as_raw_fd() }
}

fn main() -> io::Result<()> {
    let f = std::fs::OpenOptions::new().write(true).open("/dev/null")?;
    let fd = f.as_raw_fd();
    std::mem::forget(f);                       // 避免双重关闭
    let mut owned = unsafe { OwnedFd::from_raw(fd) };
    owned.write_all(b"x")?;
    Ok(())
}

这段代码故意展示了「所有权转移」的微妙处:File 已经拥有 fd,如果既保留 File 又构造 OwnedFd,就会 double-close。mem::forget(f) 放弃原 File 的所有权,再由 OwnedFd 接管。真实代码应优先用 File/OwnedFd 标准类型,不要手写这层。

unsafe 的检查清单

操作必须证明什么常见错误
*ptr 解引用指针非空、对齐、指向有效对象、生命周期内悬垂、越界、别名违反
slice::from_raw_parts非空(长度 0 也要求非空)、对齐、len 不越界空指针 + 0 长度
transmute大小相同、位模式合法、无 padding 歧义把 bool 传成 2
Box::from_raw指针来自 Box::into_raw,只接管一次重复释放
extern "C" 调用ABI、类型、所有权、panic 边界传 String、panic 跨边界
impl Send/Sync内部状态真的可跨线程手写 unsafe impl 掩盖 Rc

// SAFETY: 不是注释礼仪,而是审查线索:如果写不出「为什么这里安全」,就说明边界没画清。可以用 Miri 检查一部分 UB:

rustup component add miri
cargo +nightly miri test
# 或单文件:cargo +nightly miri run

Miri 抓不到所有问题(系统调用、FFI、并发时序),但能抓越界、悬垂、未初始化读、别名违规。对 unsafe 密集的 crate,Miri + ASan + fuzz 是组合拳。

1.17.4 文件:C 的每一条错误路径 vs Rust 的 ?

文件 I/O 是所有权和错误处理最好的试金石,也是本章的完整程序一:fd 需要关闭、缓冲区需要释放、read 可能短读、write 可能短写、open 可能被信号打断。C 版本把这些都摊在代码里;Rust 版本用 File/Vec 的 Drop 和 io::Result 把「正常路径」变短,但错误语义仍需你决定(重试、传播、还是转换)。

/* copy.c — 处理 EINTR、短读、短写;cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror copy.c -o copy */
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>

static int write_all(int fd, const char *p, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, p, n);
        if (w < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        if (w == 0) { errno = EIO; return -1; }   /* 不该发生,但不能死循环 */
        p += (size_t)w;
        n -= (size_t)w;
    }
    return 0;
}

static int copy_file(const char *src, const char *dst)
{
    int in = open(src, O_RDONLY | O_CLOEXEC);
    if (in < 0) return -1;
    int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
    if (out < 0) { int e = errno; close(in); errno = e; return -1; }

    char buf[65536];
    int rc = 0;
    for (;;) {
        ssize_t n = read(in, buf, sizeof buf);
        if (n < 0) {
            if (errno == EINTR) continue;
            rc = -1; break;
        }
        if (n == 0) break;
        if (write_all(out, buf, (size_t)n) < 0) { rc = -1; break; }
    }
    int saved = errno;
    if (close(in) < 0 && rc == 0) { rc = -1; saved = errno; }
    if (close(out) < 0 && rc == 0) { rc = -1; saved = errno; }
    errno = saved;
    return rc;
}

int main(int argc, char **argv)
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s SRC DST\n", argv[0]);
        return 2;
    }
    if (copy_file(argv[1], argv[2]) < 0) {
        perror("copy");
        return 1;
    }
    return 0;
}
// copy.rs — 标准库版本;rustc --edition 2021 copy.rs -o copy
use std::fs::{self, File};
use std::io::{self, Write};
use std::os::unix::fs::PermissionsExt;

fn copy_file(src: &str, dst: &str) -> io::Result<u64> {
    let meta = fs::metadata(src)?;
    println!("src mode = {:o}", meta.permissions().mode());

    let mut inp = File::open(src)?;
    let mut out = File::create(dst)?;
    let n = io::copy(&mut inp, &mut out)?;   // 内部处理短读/短写/EINTR
    out.flush()?;                            // 显式 flush,错误不会被 Drop 吞掉
    Ok(n)
}

fn main() -> io::Result<()> {
    let args: Vec<String> = std::env::args().collect();
    if args.len() != 3 {
        eprintln!("usage: {} SRC DST", args[0]);
        std::process::exit(2);
    }
    let n = copy_file(&args[1], &args[2])?;
    println!("copied {n} bytes");
    Ok(())
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror copy.c -o copy
rustc --edition 2021 copy.rs -o copy-rs
./copy /etc/hosts /tmp/hosts.c && ./copy-rs /etc/hosts /tmp/hosts.rs
cmp /etc/hosts /tmp/hosts.c && cmp /etc/hosts /tmp/hosts.rs && echo same

C 版本里最容易漏的三处:

  1. read 的 EINTR:信号处理器打断 read 时返回 -1/EINTR,不是 EOF;不重试会把一次信号当成读失败。
  2. write 的短写:write(fd, p, 65536) 可能只写 4096 字节,尤其对 pipe/socket;必须循环推进指针和剩余长度。
  3. close 的错误:close 可能返回 EIO(尤其 NFS),但 fd 已经关闭;不要重试 close,也不要覆盖前一个错误。C 版本用 saved 保存第一个错误。

Rust 的 io::copy 处理了前两条;File 的 Drop 负责关闭,但 close 的错误会被丢弃(Rust 的 File::sync_all/flush 不会报告 close 错误)。对可靠性要求高的程序,要显式 sync_all(),并在需要时用 File::into_raw_fd + libc::close 检查返回值。

文件 API 的四个边界
  • O_TRUNC 会立刻截断目标文件;如果源文件和目标文件相同,先截断再读会得到空文件。cp 检测同一 inode,自己的 copy 也要检测(fstat 比较 st_dev/st_ino)。
  • open 的 mode 只在 O_CREAT 时生效,且会被 umask 掩掉;0644 实际可能是 0640。
  • 非阻塞 fd 的 read 返回 EAGAIN,不是 EOF;io::copy 在非阻塞 fd 上可能返回 WouldBlock。
  • File::create 等价于 O_WRONLY|O_CREAT|O_TRUNC,但不会设置 O_CLOEXEC?Rust 标准库在支持的系统上默认 O_CLOEXEC;C 侧要显式加,避免 exec 泄漏 fd。

1.17.5 线程:pthread 与 Arc<Mutex> / scoped threads

C 的线程安全靠约定;下面是本章的完整程序二(C 与 Rust 两个版本):共享数据、锁、生命周期都由程序员保证。Rust 用 Send/Sync 把「能不能跨线程」变成 trait bound,用 Arc 表达共享所有权,用 Mutex<T> 把数据放进锁里,而不是让锁和数据分离。

/* threads.c — 4 个线程各加 100000 次;cc ... -pthread threads.c -o threads */
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

struct counter {
    pthread_mutex_t mu;
    long value;
};

static void *worker(void *arg)
{
    struct counter *c = arg;
    for (int i = 0; i < 100000; i++) {
        if (pthread_mutex_lock(&c->mu) != 0) abort();
        c->value++;
        if (pthread_mutex_unlock(&c->mu) != 0) abort();
    }
    return NULL;
}

int main(void)
{
    struct counter c;
    pthread_t th[4];
    if (pthread_mutex_init(&c.mu, NULL) != 0) return 1;
    c.value = 0;
    for (int i = 0; i < 4; i++)
        if (pthread_create(&th[i], NULL, worker, &c) != 0) return 1;
    for (int i = 0; i < 4; i++)
        if (pthread_join(th[i], NULL) != 0) return 1;
    printf("value=%ld\n", c.value);
    pthread_mutex_destroy(&c.mu);
    return c.value == 400000 ? 0 : 1;
}
// threads.rs — scoped threads + Mutex;rustc --edition 2021 threads.rs -o threads
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0u64);
    thread::scope(|s| {
        for _ in 0..4 {
            s.spawn(|| {
                for _ in 0..100_000 {
                    let mut v = counter.lock().unwrap();
                    *v += 1;
                }
            });
        }
    });
    let v = counter.into_inner().unwrap();
    println!("value={v}");
    assert_eq!(v, 400_000);
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -pthread threads.c -o threads
rustc --edition 2021 threads.rs -o threads-rs
./threads && ./threads-rs

Rust 版本没有 Arc,因为 thread::scope 保证所有子线程在作用域结束前 join,借用可以指向栈上的 counter。如果线程要活过当前函数,就必须 Arc::clone 或 move 所有权;编译器会拒绝悬垂借用。

关注点C pthreadRust std::thread
创建/加入pthread_create/pthread_jointhread::spawn/JoinHandle::join
共享所有权裸指针 + 约定Arc<T>/&T(scoped)
互斥pthread_mutex_tMutex<T>(数据在锁内)
条件变量pthread_cond_tCondvar
线程局部__thread/pthread_key_tthread_local!
数据竞争TSan/约定Send/Sync 编译期检查
锁中毒无Mutex poisoning(可 into_inner 忽略)
并发不是「换个类型就安全」
  1. Arc<Mutex<T>> 仍会死锁:两把锁顺序不一致、或持锁调用回调;Rust 不检查锁顺序。
  2. Mutex poisoning 不是万能:panic 后锁被标记 poisoned;unwrap() 会再次 panic,into_inner() 可取回但可能处于不一致状态。
  3. thread::scope 不是「无限并发」:作用域结束会 join,长任务会阻塞退出;需要后台线程用 spawn + 生命周期管理。
  4. Rc/RefCell 不能跨线程:Rc 不是 Send,RefCell 不是 Sync;编译器会拦住,但 unsafe impl Send 可以绕过,后果自负。

1.17.6 Socket:C 的 accept 循环与 Rust 的 TcpListener

TCP echo 服务器把文件、线程之外的系统调用串起来,是本章的完整程序三:socket、bind、listen、accept、read、write、close。C 版本要自己处理 EINTR、短写、SIGPIPE;Rust 的 TcpStream 把 fd 包成类型,Read/Write 实现仍可能返回 Interrupted/WouldBlock,但资源释放由 Drop 保证。

/* echo.c — 单线程 TCP echo;cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror echo.c -o echo */
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

static int write_all(int fd, const char *p, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, p, n);
        if (w < 0) { if (errno == EINTR) continue; return -1; }
        if (w == 0) { errno = EIO; return -1; }
        p += (size_t)w; n -= (size_t)w;
    }
    return 0;
}

int main(int argc, char **argv)
{
    int port = argc > 1 ? atoi(argv[1]) : 7878;
    signal(SIGPIPE, SIG_IGN);              /* 对端关闭后 write 返回 EPIPE,而不是杀死进程 */

    int s = socket(AF_INET, SOCK_STREAM, 0);
    if (s < 0) { perror("socket"); return 1; }
    int one = 1;
    setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);

    struct sockaddr_in addr;
    memset(&addr, 0, sizeof addr);
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    addr.sin_port = htons((unsigned short)port);
    if (bind(s, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("bind"); return 1; }
    if (listen(s, 16) < 0) { perror("listen"); return 1; }
    fprintf(stderr, "listening on 127.0.0.1:%d\n", port);

    for (;;) {
        int c = accept(s, NULL, NULL);
        if (c < 0) { if (errno == EINTR) continue; perror("accept"); return 1; }
        char buf[4096];
        for (;;) {
            ssize_t n = read(c, buf, sizeof buf);
            if (n < 0) { if (errno == EINTR) continue; perror("read"); break; }
            if (n == 0) break;                 /* 对端关闭 */
            if (write_all(c, buf, (size_t)n) < 0) { perror("write"); break; }
        }
        close(c);
    }
}
// echo.rs — 单线程 TCP echo;rustc --edition 2021 echo.rs -o echo-rs
use std::io::{self, Read, Write};
use std::net::TcpListener;

fn main() -> io::Result<()> {
    let listener = TcpListener::bind(("127.0.0.1", 7878))?;
    eprintln!("listening on 127.0.0.1:7878");
    for conn in listener.incoming() {
        let mut stream = conn?;               // 连接错误:返回 Err,退出循环
        let mut buf = [0u8; 4096];
        loop {
            match stream.read(&mut buf) {
                Ok(0) => break,                // EOF
                Ok(n) => stream.write_all(&buf[..n])?,
                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
                Err(e) => return Err(e),
            }
        }
    }
    Ok(())
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror echo.c -o echo
rustc --edition 2021 echo.rs -o echo-rs
./echo 7878 &
printf 'hello\n' | nc 127.0.0.1 7878
./echo-rs &
printf 'hello\n' | nc 127.0.0.1 7878

两者都是单线程、一次处理一个连接:第二个客户端要等第一个关闭。要并发,C 侧用 fork/pthread/epoll,Rust 侧用 thread::spawn 或 async runtime。不要因为 Rust 版本短就以为它自动并发——for conn in incoming() 是串行的。

关注点CRust
socket 所有权int fd,手动 closeTcpStream,Drop 关闭
地址解析getaddrinfo + sockaddrToSocketAddrs/SocketAddr
错误-1 + errnoio::Result
短写自己循环write_all
EINTR自己重试read/write 多数自动重试,裸调用不
并发fork/pthread/epollthread/tokio/async-std
背压read 大小 + 阻塞同样;read 返回 WouldBlock 要处理
socket 编程的四个坑
  1. 不忽略 SIGPIPE:对端关闭后 write 触发 SIGPIPE,默认终止进程;C 要 signal(SIGPIPE, SIG_IGN) 或 send(..., MSG_NOSIGNAL),Rust 标准库内部处理,但裸 libc::write 不会。
  2. accept 的 EINTR:信号打断 accept 返回 -1/EINTR;不重试会误退出。
  3. TCP 没有消息边界:一次 read 可能读到半个请求或两个请求;协议要自带长度前缀/分隔符。
  4. SO_REUSEADDR 不是万能:它解决 TIME_WAIT 绑定问题,不解决端口被另一个进程占用;SO_REUSEPORT 才是多进程共享端口。

1.17.7 std::os::unix、libc、nix、rustix:四层选择

Rust 访问 Unix 接口有四层,越往下越接近 C,越需要 unsafe:

层代表优点代价
标准库跨平台std::fs、std::net、std::thread安全、可移植、维护成本低只覆盖常见操作,细节不可控
std::os::unixMetadataExt、PermissionsExt、AsRawFd暴露 Unix 细节,仍是安全 API仅 Unix;部分平台差异要 cfg
libc cratelibc::open、libc::epoll_wait与 C 一一对应,最灵活全 unsafe,错误码/结构体自己管
nix/rustixnix::unistd::read、rustix::fs::open对 libc 的安全封装,错误转 Errno/io::Error额外依赖;API 版本差异
// unix_ext.rs — 用 std::os::unix 读 inode、权限、fd;rustc --edition 2021 unix_ext.rs
use std::fs::File;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
use std::os::unix::io::AsRawFd;

fn main() -> std::io::Result<()> {
    let f = File::open("/etc/hosts")?;
    let m = f.metadata()?;
    println!("fd={} ino={} mode={:o} uid={} gid={} size={}",
             f.as_raw_fd(), m.ino(), m.permissions().mode(),
             m.uid(), m.gid(), m.size());
    Ok(())
}
/* stat_demo.c — 与上面等价的 C 版本
 * cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror stat_demo.c -o stat_demo
 */
#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main(void)
{
    int fd = open("/etc/hosts", O_RDONLY | O_CLOEXEC);
    if (fd < 0) { perror("open"); return 1; }
    struct stat st;
    if (fstat(fd, &st) < 0) { perror("fstat"); close(fd); return 1; }
    printf("fd=%d ino=%llu mode=%o uid=%u gid=%u size=%lld\n",
           fd, (unsigned long long)st.st_ino, st.st_mode & 07777,
           st.st_uid, st.st_gid, (long long)st.st_size);
    close(fd);
    return 0;
}

std::os::unix 还有几个高频扩展:

  • std::os::unix::ffi::OsStrExt/OsStringExt:路径是字节序列,不是 UTF-8;as_bytes()/from_bytes() 处理非 UTF-8 文件名。
  • std::os::unix::process::CommandExt:uid/gid/pre_exec/exec,用于降权或替换进程。
  • std::os::unix::net::UnixListener/UnixStream:Unix domain socket。
  • std::os::unix::fs::FileTypeExt:判断 socket/device/fifo。
  • std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd}:与 C/FFI 交换 fd 的边界;from_raw_fd/into_raw_fd 是 unsafe/所有权转移点。

直接调 libc:错误映射要自己写

// libc_pid.rs — 直接声明 libc 函数,不依赖 libc crate
// rustc --edition 2021 libc_pid.rs -o libc_pid
use std::ffi::CStr;
use std::os::raw::{c_char, c_int};

extern "C" {
    fn getpid() -> c_int;
    fn strerror(errnum: c_int) -> *const c_char;
}

fn main() {
    let pid = unsafe { getpid() };
    let msg = unsafe { CStr::from_ptr(strerror(2)) };  // ENOENT = 2
    println!("pid={pid} errno2={}", msg.to_string_lossy());
}

用 libc crate 时,libc::open 返回 -1 并设置 errno;Rust 侧要 io::Error::last_os_error()。用 nix 时,返回 Result<T, Errno>;用 rustix 时,返回 io::Result<T> 并尽量用 rustix::fs::openat 等避免 TOCTOU。三者不是互斥的:应用层用 std,需要 epoll/io_uring/seccomp 时下沉到 rustix 或 libc。

选择 FFI 层的四个判断
  1. 能用 std 就用 std:File/TcpStream/thread 已经处理了大部分 EINTR 和资源释放。
  2. 需要 O_DIRECT/epoll/io_uring/seccomp:std 不覆盖,选 rustix(安全、活跃)或 nix(成熟、API 稳定)。
  3. 要匹配内核头文件/ioctl 结构体:直接用 libc + bindgen,但把所有 unsafe 收进一个小模块。
  4. 跨平台库:cfg(target_os = "linux")/cfg(unix) 分层,不要把 Linux 专属常量暴露到公共 API。

1.17.8 Linux 内核 Rust 模块:用户态经验的边界

Linux 6.1 起 Rust 进入主线(CONFIG_RUST),但内核 Rust 与用户态 Rust 是两种环境:没有 std、没有 libc、没有 panic 展开、没有全局分配器(除非显式用 GFP_KERNEL 分配)、不能随便阻塞。内核提供自己的 kernel crate:Result、Arc、Mutex、SpinLock、FileOperations、MiscDevice 等。

下面是一个概念性的 misc 设备模块骨架,用来对照用户态的所有权/错误/FFI;它不能在用户态编译,必须在 Linux 内核树中构建:

// 需要 kernel tree:CONFIG_RUST=y,make LLVM=1
use kernel::prelude::*;
use kernel::miscdev;
use kernel::file::{File, Operations};
use kernel::sync::{Arc, Mutex};

module! {
    type: EchoModule,
    name: "rust_echo",
    author: "tutorial",
    description: "minimal Rust misc device",
    license: "GPL",
}

struct EchoModule {
    _misc: miscdev::Registration<Self>,
    state: Arc<Mutex<u64>>,
}

#[vtable]
impl Operations for EchoModule {
    fn open(_shared: &Self, _file: &File) -> Result<()> {
        Ok(())
    }

    fn write(_shared: &Self, _file: &File, data: &[u8], _offset: u64) -> Result<usize> {
        Ok(data.len())                 // 真实驱动要 copy_from_user / 处理 offset
    }
}

impl kernel::Module for EchoModule {
    fn init(_module: &'static ThisModule) -> Result<Self> {
        let state = Arc::new(Mutex::new(0), GFP_KERNEL)?;
        let misc = miscdev::Registration::new_pinned(
            c"rust_echo", state.clone())?;
        Ok(EchoModule { _misc: misc, state })
    }
}

这段代码的教学价值在于对照,而不是直接运行:

用户态 Rust内核 Rust差异
std::io::Resultkernel::Result错误码是内核 errno 的负数
Box::new/VecKBox/KVec + GFP_KERNEL分配可能睡眠/失败,需显式标志
std::sync::Mutexkernel::sync::Mutex自旋/睡眠锁语义不同,不能跨中断随便用
panic! 展开panic! 终止/BUG内核通常不允许 unwind
extern "C"#[vtable]/FileOperations通过内核 trait 表注册,不直接暴露 ABI
Drop 释放Drop + 生命周期约束对象可能被并发引用,需 Arc/pin

内核 Rust 的 unsafe 边界更硬:DMA 缓冲区、用户指针、锁顺序、中断上下文、GFP_ATOMIC 与睡眠规则,编译器都帮不了。写内核模块前先把第 3 篇的 C 内核路径读一遍;Rust 只是换语言,不会替你做内核语义的正确性证明。

不要把用户态 crate 直接搬进内核

std、libc、tokio、serde(默认)都不能直接用;内核 crate 需要 #![no_std]、内核分配器、内核同步原语。上游内核 Rust 仍快速演进,API 随版本变化;示例代码必须按目标内核版本核对,不能把 6.1 的 API 当成长期稳定 ABI。

1.17.9 实验一:用 strace 看 Rust/C 的真实系统调用

目标:确认「Rust 标准库替你做了什么」不是猜的。strace 在 Linux 上直接显示系统调用;Rust 与 C 的差异会体现在调用序列上,而不是语言层。

# 编译带调试信息的版本
cc -std=c17 -D_GNU_SOURCE -g -O0 -Wall -Wextra -Werror copy.c -o copy-dbg
rustc --edition 2021 -g copy.rs -o copy-rs-dbg

# 文件复制:openat/read/write/close 的序列
strace -f -e trace=openat,read,write,close ./copy-dbg /etc/hosts /tmp/a 2>&1 | tail -20
strace -f -e trace=openat,read,write,close ./copy-rs-dbg /etc/hosts /tmp/b 2>&1 | tail -20

# 线程:clone/futex;Rust scoped thread 也会走 clone
strace -f -e trace=clone,futex,exit_group ./threads 2>&1 | tail -20
strace -f -e trace=clone,futex,exit_group ./threads-rs 2>&1 | tail -20

# socket:socket/bind/listen/accept4/read/write
strace -f -e trace=socket,bind,listen,accept4,read,write ./echo 7878 2>&1 | tail -20

# Rust 的错误路径:ENOENT 时看 openat 返回 -1
strace -e trace=openat ./copy-rs-dbg /no/such/file /tmp/x 2>&1 | tail -5

读法:

  • openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC):Rust File::open 也走 openat,不是 open;O_CLOEXEC 是默认的(Rust 标准库在支持的系统上设置)。
  • 短写:write(3, "...", 4096) = 4096 一次写不完时,strace 会显示多次 write;Rust write_all 内部循环。
  • EINTR:read(...) = -1 EINTR 后紧跟一次重试;如果程序直接退出,说明没处理。
  • 线程:clone3/clone 的 flags 决定栈、TLS、CLONE_VM;futex 是 Mutex/Condvar 的底层等待。
  • accept4:Rust/glibc 可能用 accept4 带 SOCK_CLOEXEC,C 代码用 accept 则没有。
读 strace 的顺序
  1. 先看返回值:-1 后面的 errno 是根因;
  2. 再看 fd 生命周期:openat 的 fd 是否在 close 前泄漏;
  3. 再看短读/短写/EINTR;
  4. 最后看性能:同一系统调用重复次数是否异常。

1.17.10 实验二:cargo check/clippy/miri 与 FFI 错误映射

目标:把 Rust 的编译期检查和运行时证据串起来。cargo check 只做类型/借用检查,不生成代码;clippy 抓常见 API 误用;miri 抓 UB;strace/perf 抓系统调用和性能。

# 新建 crate(把本章示例放进去)
cargo new --bin sysdemo && cd sysdemo
# 复制 copy.rs/threads.rs 等,补 Cargo.toml
cargo check
cargo clippy -- -D warnings
cargo test

# Miri:需要 nightly 组件
rustup component add miri
cargo +nightly miri test

# 发布构建 + 体积/符号
cargo build --release
size target/release/sysdemo
nm -C target/release/sysdemo | head

# FFI 错误映射:C 函数返回 -1 并设 errno,Rust 侧读 raw_os_error
cat > c_errno.c <<'C'
#include <errno.h>
#include <fcntl.h>
int c_open_missing(void) { errno = ENOENT; return -1; }
C
cc -c c_errno.c -o c_errno.o
ar rcs libcerrno.a c_errno.o

cat > ffi_errno.rs <<'R'
#[link(name = "cerrno", kind = "static")]
extern "C" { fn c_open_missing() -> i32; }

fn main() {
    let rc = unsafe { c_open_missing() };
    if rc < 0 {
        let e = std::io::Error::last_os_error();
        println!("rc={rc} kind={:?} raw={:?}", e.kind(), e.raw_os_error());
    }
}
R
rustc --edition 2021 ffi_errno.rs -L . -l static=cerrno -o ffi_errno
./ffi_errno                         # rc=-1 kind=NotFound raw=Some(2)

cargo check 通过不代表链接通过,也不代表运行时正确;clippy -D warnings 会把 unwrap()、as 截断、未使用的 Result 等提示变成错误。miri 只能跑纯 Rust/部分 FFI,系统调用会被拦截或忽略;它是 UB 检查器,不是系统测试替代品。

Rust 错误处理的六个反模式
  1. 库代码到处 unwrap():调用者无法恢复;库应返回 Result,二进制入口再决定打印/退出。
  2. ? 丢失上下文:File::open 的 ENOENT 不知道是哪个路径;用 anyhow::Context/thiserror 加路径。
  3. as 截断:len as u32 在超过 4 GiB 时静默截断;用 u32::try_from(len)?。
  4. usize 跨平台:32 位目标上 usize 是 32 位;协议字段用固定宽度 u64/u32。
  5. Path 当 UTF-8:Unix 文件名可以是任意字节;用 OsStr/OsString,不要 to_str().unwrap()。
  6. Drop 当事务:Drop 不能返回错误,close/fsync 失败会被吞;关键落盘要显式 sync_all()。

1.17.11 十二条 Rust 系统编程陷阱

现象 → 原因 → 修复
  1. unwrap() 在信号/中断路径 panic:panic 展开可能重入或 abort。修复:错误向上返回;FFI/信号处理器内不 panic。
  2. Arc<Mutex<T>> 跨 .await 持锁:async 任务被挂起时锁未释放,其他任务饿死。修复:缩小锁作用域,或改用 tokio::sync::Mutex。
  3. Rc 跨线程编译失败:Rc 不是 Send。修复:用 Arc;不要 unsafe impl Send 掩盖。
  4. RefCell 运行时 panic:同时借用 borrow() 和 borrow_mut()。修复:缩小借用作用域,或改用 Mutex/RwLock。
  5. 'static 约束报错:thread::spawn 要求 'static。修复:move 所有权,或用 thread::scope 借用。
  6. fd 双重关闭:File 和 OwnedFd 同时拥有同一个 fd。修复:只保留一个所有者;into_raw_fd 后不要再让原对象 Drop。
  7. FFI 字符串没有 NUL:C 期望 char* 以 0 结尾,Rust &str 不是。修复:CString::new(s),并检查内部 NUL。
  8. panic 跨 extern "C":UB/abort。修复:边界内 catch_unwind,或 panic=abort,或用 extern "C-unwind"。
  9. errno 被后续调用覆盖:last_os_error() 必须在失败后立刻读。修复:先保存 raw_os_error(),再格式化/打印。
  10. read/write 短读短写:Rust read 不保证填满缓冲区,write 不保证写完。修复:read_exact/write_all,或自己循环。
  11. SIGPIPE 杀进程:裸 libc::write 到已关闭 socket。修复:忽略 SIGPIPE 或用 MSG_NOSIGNAL。
  12. 内核 crate 直接搬用户态依赖:std/libc/tokio 不可用。修复:用内核 kernel crate 的 KBox/KVec/Mutex,按目标内核版本核对 API。

1.17.12 本章出口检查

检查项达标线没达标去哪
所有权能解释 fd/锁/缓冲区为何自动释放,以及 Drop 不运行的场景1.17.1
错误能写处理 EINTR/EAGAIN/短写的函数,并映射 errno1.17.2
unsafe能写一个带 // SAFETY: 的安全封装,说明前置条件1.17.3
文件/线程能写 C/Rust 对照版本并解释资源所有权1.17.4–1.17.5
socket能写 echo server,解释 SIGPIPE/EINTR/消息边界1.17.6
FFI 层能选 std/std::os::unix/libc/nix/rustix 并说明代价1.17.7
内核 Rust能说出用户态与内核 Rust 的五个差异1.17.8
实验能用 strace/clippy/miri 找到证据1.17.9–1.17.10
即时自测 · 1.17

Q1 为什么 thread::spawn 的闭包要求 'static,而 thread::scope 不要求?

Q2 File::open 失败后,io::Error::last_os_error() 和返回的 Err(e) 有什么区别?什么时候必须用前者?

Q3 一个 Rust 函数把 &mut [u8] 传给 C,C 保存指针稍后异步写。这个 API 为什么不安全?怎么改?

Q4 no_std 内核模块为什么不能用 std::sync::Mutex?内核的 Mutex 与用户态有什么语义差异?

答案

A1 spawn 的线程可能活过当前栈帧,闭包捕获的引用必须 'static;scope 在作用域结束前 join 所有线程,借用不会逃逸,所以可以用栈上的 &T/&Mutex<T>。

A2 返回的 Err(e) 保存了失败瞬间的错误;last_os_error() 读的是当前线程的 errno,中间任何调用都可能覆盖。只有在你拿到的是裸返回值(如 libc::write 的 -1)时才用 last_os_error(),且要立刻读。

A3 C 保存指针后,Rust 的借用检查无法保证缓冲区在异步写期间不被移动/释放;C 也可能在另一个线程写,违反别名规则。修复:把缓冲区 Box::leak/Arc 共享并明确生命周期,或让 C 在调用返回前完成写(同步 API),并提供 unsafe 文档说明。

A4 std::sync::Mutex 依赖用户态线程/调度器、可能使用 futex 和 poisoning;内核 Mutex 是内核同步原语,可能在原子上下文/中断中不可用,睡眠规则不同,且没有用户态 poisoning 语义。内核代码必须用 kernel::sync::Mutex 并遵守锁顺序/上下文约束。

母本对应

母本章节本书落点程度
Rust Book第 4 章所有权、第 9 章错误、第 16 章无畏并发1.17.1–1.17.5完整:所有权/借用/Result/线程
Rust Book第 19 章 unsafe、第 20 章 FFI1.17.3、1.17.7扩展:安全封装与 C 互调
Rustonomicon别名、repr(C)、unwind、FFI1.17.3、1.17.7参考:按系统编程重写
K Kerrisk第 4–5、29–33、56–61 章1.17.4–1.17.7对照:C 语义与错误码
R 奔跑吧 Linux 内核第 1–4 章(内核模块/并发)1.17.8背景:内核上下文与锁规则
rCore第 1–4 章 Rust 裸机、附录 D1.17.8参考:只借 no_std/内核组织方式

下一站:1.18 RISC-V 裸机 把 Rust 的 no_std、所有权和 FFI 放进没有操作系统的环境:SBI、三特权级、trap 与页表。用户态的 Result 和 Drop 在那里要让位于链接脚本、栈指针和 unsafe 的硬件约束。