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。
- 「Rust 没有内存泄漏」:
Box::leak、Rc循环引用、mem::forget都能泄漏;所有权防的是 use-after-free/double-free,不是所有资源泄漏。 - 「
&mut就是 C 的指针」:&mut T还要求独占(no aliasing),编译器会据此做优化;把同一个&mut别名给两个线程是 UB。 - 「
Drop一定运行」:panic=abort、process::exit、mem::forget都不会运行Drop;系统编程里要显式处理这些路径。 - 「
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 错误 | errno | Rust ErrorKind | 正确处理 |
|---|---|---|---|
| 文件不存在 | ENOENT | NotFound | 返回错误/创建文件 |
| 权限不足 | EACCES/EPERM | PermissionDenied | 提示用户/提权 |
| 被信号打断 | EINTR | Interrupted | 重试(裸 syscall 必须自己重试) |
| 非阻塞暂无数据 | EAGAIN/EWOULDBLOCK | WouldBlock | 交给 poll/epoll/async |
| 管道读端关闭 | EPIPE | BrokenPipe | 停止写,忽略 SIGPIPE |
| 磁盘满 | ENOSPC | StorageFull(较新) | 清理/报错 |
| 参数非法 | EINVAL | InvalidInput | 修正调用 |
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());
}
}
}
File::write/File::read 在标准库里对 EINTR 做了重试,但直接调 libc::write/nix::unistd::write 不会;accept、connect、openat 等裸调用也要自己判断 ErrorKind::Interrupted。EAGAIN 不是错误,非阻塞 fd 要交给 poll/epoll 或 async runtime。
ABI 边界:寄存器与调用指令
| 项 | x86-64 SysV | ARM64 AAPCS64 | RISC-V 64 psABI |
|---|---|---|---|
| 整数/指针参数 | rdi, rsi, rdx, rcx, r8, r9 | x0–x7 | a0–a7 |
| 返回值 | rax | x0 | a0 |
| 调用/返回 | call/ret | bl/ret | jal/ret(call 伪指令) |
| 被调用者保存 | rbx, rbp, r12–r15 | x19–x29 | s0–s11 |
| 栈对齐 | 16 字节 | 16 字节 | 16 字节 |
| 系统调用 | syscall | svc #0 | ecall |
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 版本里最容易漏的三处:
read的EINTR:信号处理器打断read时返回-1/EINTR,不是 EOF;不重试会把一次信号当成读失败。write的短写:write(fd, p, 65536)可能只写 4096 字节,尤其对 pipe/socket;必须循环推进指针和剩余长度。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 检查返回值。
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 pthread | Rust std::thread |
|---|---|---|
| 创建/加入 | pthread_create/pthread_join | thread::spawn/JoinHandle::join |
| 共享所有权 | 裸指针 + 约定 | Arc<T>/&T(scoped) |
| 互斥 | pthread_mutex_t | Mutex<T>(数据在锁内) |
| 条件变量 | pthread_cond_t | Condvar |
| 线程局部 | __thread/pthread_key_t | thread_local! |
| 数据竞争 | TSan/约定 | Send/Sync 编译期检查 |
| 锁中毒 | 无 | Mutex poisoning(可 into_inner 忽略) |
Arc<Mutex<T>>仍会死锁:两把锁顺序不一致、或持锁调用回调;Rust 不检查锁顺序。Mutexpoisoning 不是万能:panic 后锁被标记 poisoned;unwrap()会再次 panic,into_inner()可取回但可能处于不一致状态。thread::scope不是「无限并发」:作用域结束会 join,长任务会阻塞退出;需要后台线程用spawn+ 生命周期管理。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() 是串行的。
| 关注点 | C | Rust |
|---|---|---|
| socket 所有权 | int fd,手动 close | TcpStream,Drop 关闭 |
| 地址解析 | getaddrinfo + sockaddr | ToSocketAddrs/SocketAddr |
| 错误 | -1 + errno | io::Result |
| 短写 | 自己循环 | write_all |
EINTR | 自己重试 | read/write 多数自动重试,裸调用不 |
| 并发 | fork/pthread/epoll | thread/tokio/async-std |
| 背压 | read 大小 + 阻塞 | 同样;read 返回 WouldBlock 要处理 |
- 不忽略
SIGPIPE:对端关闭后write触发SIGPIPE,默认终止进程;C 要signal(SIGPIPE, SIG_IGN)或send(..., MSG_NOSIGNAL),Rust 标准库内部处理,但裸libc::write不会。 accept的EINTR:信号打断accept返回-1/EINTR;不重试会误退出。- TCP 没有消息边界:一次
read可能读到半个请求或两个请求;协议要自带长度前缀/分隔符。 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::unix | MetadataExt、PermissionsExt、AsRawFd | 暴露 Unix 细节,仍是安全 API | 仅 Unix;部分平台差异要 cfg |
libc crate | libc::open、libc::epoll_wait | 与 C 一一对应,最灵活 | 全 unsafe,错误码/结构体自己管 |
nix/rustix | nix::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。
- 能用
std就用std:File/TcpStream/thread已经处理了大部分EINTR和资源释放。 - 需要
O_DIRECT/epoll/io_uring/seccomp:std不覆盖,选rustix(安全、活跃)或nix(成熟、API 稳定)。 - 要匹配内核头文件/ioctl 结构体:直接用
libc+bindgen,但把所有unsafe收进一个小模块。 - 跨平台库:
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::Result | kernel::Result | 错误码是内核 errno 的负数 |
Box::new/Vec | KBox/KVec + GFP_KERNEL | 分配可能睡眠/失败,需显式标志 |
std::sync::Mutex | kernel::sync::Mutex | 自旋/睡眠锁语义不同,不能跨中断随便用 |
panic! 展开 | panic! 终止/BUG | 内核通常不允许 unwind |
extern "C" | #[vtable]/FileOperations | 通过内核 trait 表注册,不直接暴露 ABI |
Drop 释放 | Drop + 生命周期约束 | 对象可能被并发引用,需 Arc/pin |
内核 Rust 的 unsafe 边界更硬:DMA 缓冲区、用户指针、锁顺序、中断上下文、GFP_ATOMIC 与睡眠规则,编译器都帮不了。写内核模块前先把第 3 篇的 C 内核路径读一遍;Rust 只是换语言,不会替你做内核语义的正确性证明。
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):RustFile::open也走openat,不是open;O_CLOEXEC是默认的(Rust 标准库在支持的系统上设置)。- 短写:
write(3, "...", 4096) = 4096一次写不完时,strace 会显示多次write;Rustwrite_all内部循环。 EINTR:read(...) = -1 EINTR后紧跟一次重试;如果程序直接退出,说明没处理。- 线程:
clone3/clone的 flags 决定栈、TLS、CLONE_VM;futex是Mutex/Condvar的底层等待。 accept4:Rust/glibc 可能用accept4带SOCK_CLOEXEC,C 代码用accept则没有。
- 先看返回值:
-1后面的errno是根因; - 再看 fd 生命周期:
openat的 fd 是否在close前泄漏; - 再看短读/短写/EINTR;
- 最后看性能:同一系统调用重复次数是否异常。
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 检查器,不是系统测试替代品。
- 库代码到处
unwrap():调用者无法恢复;库应返回Result,二进制入口再决定打印/退出。 ?丢失上下文:File::open的ENOENT不知道是哪个路径;用anyhow::Context/thiserror加路径。as截断:len as u32在超过 4 GiB 时静默截断;用u32::try_from(len)?。usize跨平台:32 位目标上usize是 32 位;协议字段用固定宽度u64/u32。Path当 UTF-8:Unix 文件名可以是任意字节;用OsStr/OsString,不要to_str().unwrap()。Drop当事务:Drop不能返回错误,close/fsync失败会被吞;关键落盘要显式sync_all()。
1.17.11 十二条 Rust 系统编程陷阱
unwrap()在信号/中断路径 panic:panic 展开可能重入或 abort。修复:错误向上返回;FFI/信号处理器内不 panic。Arc<Mutex<T>>跨.await持锁:async 任务被挂起时锁未释放,其他任务饿死。修复:缩小锁作用域,或改用tokio::sync::Mutex。Rc跨线程编译失败:Rc不是Send。修复:用Arc;不要unsafe impl Send掩盖。RefCell运行时 panic:同时借用borrow()和borrow_mut()。修复:缩小借用作用域,或改用Mutex/RwLock。'static约束报错:thread::spawn要求'static。修复:move所有权,或用thread::scope借用。- fd 双重关闭:
File和OwnedFd同时拥有同一个 fd。修复:只保留一个所有者;into_raw_fd后不要再让原对象 Drop。 - FFI 字符串没有 NUL:C 期望
char*以 0 结尾,Rust&str不是。修复:CString::new(s),并检查内部 NUL。 - panic 跨
extern "C":UB/abort。修复:边界内catch_unwind,或panic=abort,或用extern "C-unwind"。 errno被后续调用覆盖:last_os_error()必须在失败后立刻读。修复:先保存raw_os_error(),再格式化/打印。read/write短读短写:Rustread不保证填满缓冲区,write不保证写完。修复:read_exact/write_all,或自己循环。SIGPIPE杀进程:裸libc::write到已关闭 socket。修复:忽略SIGPIPE或用MSG_NOSIGNAL。- 内核 crate 直接搬用户态依赖:
std/libc/tokio不可用。修复:用内核kernelcrate 的KBox/KVec/Mutex,按目标内核版本核对 API。
1.17.12 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 所有权 | 能解释 fd/锁/缓冲区为何自动释放,以及 Drop 不运行的场景 | 1.17.1 |
| 错误 | 能写处理 EINTR/EAGAIN/短写的函数,并映射 errno | 1.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 |
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 章 FFI | 1.17.3、1.17.7 | 扩展:安全封装与 C 互调 |
| Rustonomicon | 别名、repr(C)、unwind、FFI | 1.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 裸机、附录 D | 1.17.8 | 参考:只借 no_std/内核组织方式 |
下一站:1.18 RISC-V 裸机 把 Rust 的
no_std、所有权和 FFI 放进没有操作系统的环境:SBI、三特权级、trap 与页表。用户态的Result和Drop在那里要让位于链接脚本、栈指针和unsafe的硬件约束。