指针与 Rust:当 C 遇上所有权
说实话,我第一次接触 Rust 的时候,心里是有点抵触的。做了十几年嵌入式,C 语言的指针早就玩得滚瓜烂熟,突然让我去学一套新的内存管理规则,总觉得多此一举。但真正深入之后,我发现 Rust 的那套所有权和借用机制,其实解决了很多 C 语言里让我头疼的问题。
今天我们就来聊聊,C 语言的指针和 Rust 的引用到底有什么区别。以及,在 Rust 里怎么用 unsafe 代码操作原始指针,还有 FFI 调用时怎么传递指针。
核心观点:Rust 的所有权系统,本质上是对 C 语言指针操作的一种「安全约束」。它把运行时才能发现的内存错误,提前到了编译阶段。
所有权与借用:Rust 的「安全指针」
先说说所有权。这个概念其实不复杂——每个值在 Rust 里都有一个唯一的「所有者」。当所有者离开作用域,值就被自动释放。这跟 C 语言里 malloc/free 是一个道理,只不过 Rust 帮你自动插入了 free 的代码。
我刚开始写 Rust 时,最不习惯的就是所有权转移。在 C 里,我可以随意拷贝指针,多个地方同时指向同一块内存。但在 Rust 里,一个值只能有一个所有者。你想让别的变量也访问这块内存?要么转移所有权,要么借用。
// C 语言:多个指针指向同一块内存
int *p1 = malloc(sizeof(int));
int *p2 = p1; // 没问题,但谁负责 free?
// Rust:所有权转移
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权转移给了 s2,s1 不再有效
// println!("{}", s1); // 编译错误!
借用机制就更巧妙了。你可以暂时「借」用某个值,用完之后还回去。借用的规则很简单:要么同时有多个不可变引用(&T),要么只有一个可变引用(&mut T)。
我在项目中遇到过一个问题:一个嵌入式设备的数据采集模块,需要同时读取和修改缓冲区。用 C 写的时候,经常因为指针混用导致数据错乱。后来用 Rust 重写,借用规则直接在编译期就帮我排除了这些 race condition。
个人经验:如果你从 C 转 Rust,可以把所有权想象成「唯一拥有者」,把借用想象成「临时访问权限」。这样理解起来会顺很多。
引用与指针:看似相似,实则不同
Rust 的引用(&T 和 &mut T)和 C 的指针,底层实现其实是一样的——都是内存地址。但语义上差别很大。
| 特性 | C 指针 | Rust 引用 |
|---|---|---|
| 空值 | 可以 NULL | 不可能为空(Option 处理) |
| 算术运算 | 支持指针加减 | 不支持 |
| 生命周期 | 程序员自己管理 | 编译器检查 |
| 别名规则 | 无限制 | 严格的借用规则 |
说白了,Rust 引用就是「加了安全锁的指针」。它保留了指针的高效性,但通过编译期检查,杜绝了悬垂指针、空指针解引用、缓冲区溢出这些经典问题。
你想想看,在 C 里写个链表,每次插入删除都要小心翼翼,生怕指针指歪了。Rust 里用引用配合所有权,这些事编译器就帮你盯着了。
unsafe Rust:原始指针的「法外之地」
但 Rust 也不是万能的。有些场景下,你必须绕过借用规则,直接操作内存。比如:
- 与硬件寄存器交互
- 实现自定义的内存分配器
- 调用 C 语言的 FFI 接口
- 性能关键路径上的优化
这时候就需要用到 unsafe Rust 里的原始指针(*const T 和 *mut T)。它们跟 C 指针几乎一模一样:可以为空、支持算术运算、没有生命周期检查。
// unsafe Rust 中的原始指针
let mut x = 42;
let raw_ptr: *mut i32 = &mut x as *mut i32;
unsafe {
*raw_ptr = 100; // 通过原始指针修改值
println!("{}", *raw_ptr);
}
警告:unsafe 代码块并不意味着「不安全」,而是「编译器无法保证安全,需要程序员自己负责」。我曾经在一个项目中,因为 unsafe 块里的指针越界,导致整个系统崩溃。调试了两天才找到问题。
我个人习惯是:能用安全 Rust 解决的问题,绝不用 unsafe。只有在明确知道自己在做什么,并且性能或功能上确实需要时,才使用原始指针。
FFI 中的指针传递:C 与 Rust 的桥梁
嵌入式开发中,经常需要调用 C 语言写的底层库。这时候 FFI(外部函数接口)就派上用场了。指针传递是 FFI 中最常见的操作。
Rust 通过 extern "C" 块声明外部函数,用 #[no_mangle] 导出函数给 C 调用。指针类型用 *const T 或 *mut T 表示。
// Rust 端:调用 C 函数
extern "C" {
fn c_function(ptr: *mut i32, len: i32);
}
fn call_c() {
let mut data = [1, 2, 3, 4, 5];
unsafe {
c_function(data.as_mut_ptr(), data.len() as i32);
}
}
// Rust 端:导出函数给 C 调用
#[no_mangle]
pub extern "C" fn rust_function(ptr: *mut i32, len: i32) {
let slice = unsafe {
std::slice::from_raw_parts_mut(ptr, len as usize)
};
// 现在可以安全地操作 slice 了
for item in slice.iter_mut() {
*item *= 2;
}
}
这里有个坑:Rust 的引用和 C 的指针在 ABI 层面是兼容的,但语义不同。C 那边可能随时释放内存,而 Rust 这边还持有引用。我曾经在做一个传感器驱动时,C 库在回调里释放了缓冲区,但 Rust 端还在用那个指针,结果就是段错误。
避坑指南:FFI 传递指针时,一定要明确「谁负责分配内存」「谁负责释放内存」「生命周期有多长」。最好在接口文档里写清楚,或者用 Rust 的 std::mem::ManuallyDrop 来管理。
知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以看到,从 C 指针到 Rust 引用,再到 unsafe 原始指针和 FFI,其实是一条「安全与灵活」的权衡线。
从图中可以看出,C 指针在最左边,代表「完全自由但危险」;Rust 引用在中间,代表「安全受控」;unsafe 原始指针和 FFI 则是「在安全框架下打开一扇通往底层的门」。最右边的安全抽象层,是 Rust 推荐的做法——用 Box、Arc、Slice 等封装好的类型,而不是直接操作原始指针。
嗯,说到这,我想起一个项目。当时需要把一个 C 写的网络协议栈移植到 Rust 上。最头疼的就是那些回调函数里的指针传递。C 那边传过来一个 void* 上下文,Rust 这边得小心翼翼地转成正确的类型。我最后用了一个全局的 HashMap 来管理这些指针,虽然性能有点损失,但安全性大大提升。
其实 Rust 的设计哲学很简单:在 99% 的场景下,用安全的方式解决问题。剩下的 1%,用 unsafe 明确标记出来,让代码审查的人一眼就能看到「这里可能有风险」。
总结一下:
- 所有权和借用是 Rust 内存安全的核心,相当于给 C 指针加上了「编译期安全带」
- 引用和指针底层一样,但语义上引用更安全、更受约束
- unsafe 里的原始指针是必要的「逃生舱」,但要用得谨慎
- FFI 传递指针时,生命周期和所有权必须明确约定
下一章我们会深入 Rust 的所有权系统,看看它到底是怎么在编译期保证内存安全的。到时候我会拿一个实际项目中的环形缓冲区来举例,你就能更直观地感受到 Rust 的威力了。