裸指针与 FFI

12 分钟
本页目录

裸指针允许表达不能直接放进 Rust 引用模型的状态,例如可空外部句柄、容器内部的尚未初始化存储、底层分配与硬件接口。FFI 是重要用途,但不是唯一用途。

*const T 与 *mut T 本身不携带可被借用检查器完整追踪的生命周期。它们可以为空或不对齐;只有当你执行具体访问时,才必须满足该操作要求的全部条件。

取得指针来源分配与 provenance;数值地址并非全部信息1确认存储仍有效对象生命周期、访问范围、对齐与初始化2选择访问方式读、写、未对齐访问或构造引用有不同前提3维护别名与并发约束存在多个指针不等于允许任意同时访问4空或悬空指针值可以存在;能够保存它与能够解引用它是两回事。裸指针从地址走向一次合法访问,需要逐项补齐条件

指针存在与访问有效不同

fn main() {
    let value = 42_u32;
    let pointer = &raw const value;
    // SAFETY: value 仍存活且已初始化;pointer 对齐,读取没有竞争写入。
    let copied = unsafe { pointer.read() };
    assert_eq!(copied, 42);
    let null = std::ptr::null::<u32>();
    assert!(null.is_null()); // 保存和检查空指针不需要解引用
}

多个 *mut T 可以指向同一位置,但这不授权你制造同时有效且互相冲突的 &mut T,或进行非原子并发读写。指针的 const/mut 是允许通过该类型表达的访问方向,不会让原本只读或受共享引用保护的对象自动变得可写。指针模块

ptr.read 对非 Copy 类型会按位读出一个拥有值,调用者必须避免随后把同一资源当作两份独立所有权释放。ptr.write 又不会先析构原值,覆盖前是否需要释放已有资源也要单独处理。

裸指针转引用会增加承诺

&*ptr 或 &mut *ptr 不是“换个更方便的语法”。新引用要求非空、对齐、有效的目标值以及整个借用期间的相应访问约束,即使之后没有立即读写,也不能忽略这些要求。

同样,slice::from_raw_parts 的长度为零时通常仍要求非空且对齐的指针;C 接口允许 NULL+0 不代表可以直接构造 Rust 空切片。外部缓冲必须先核对各自接口的要求。

返回引用的安全封装还应把生命周期绑定到真正持有存储的对象,不能凭一个没有所有者信息的裸指针,给调用者任意长的借用。这一点比在函数体外加上 unsafe 标记更重要。

对 NULL + 0 单独分支,但别把 null 检查当完整验证

下面的函数按字节计算异或校验,允许空缓冲以 NULL + 0 表达。使用 unsafe fn,是因为非空指针对应的内存有效性无法仅靠整数检查证明。

/// # Safety
/// len > 0 且 data 非空时,调用者保证它指向同一分配内 len 个已初始化字节,
/// 整个调用期间可读、保持存活且没有冲突写入;长度不超过 isize::MAX。
unsafe fn xor_bytes(data: *const u8, len: usize) -> Result<u8, &'static str> {
    if len == 0 { return Ok(0); }
    if data.is_null() { return Err("非空长度对应空指针"); }
    if len > isize::MAX as usize { return Err("长度过大"); }
    // SAFETY: 上面排除空指针和过大长度;其余范围、初始化和访问前提由调用者保证。
    let bytes = unsafe { std::slice::from_raw_parts(data, len) };
    Ok(bytes.iter().fold(0, |result, byte| result ^ byte))
}
fn main() {
    let bytes = [1u8, 2, 3, 4];
    // SAFETY: 数组保持有效,指针和长度取自同一个完整数组,无并发写入。
    assert_eq!(unsafe { xor_bytes(bytes.as_ptr(), bytes.len()) }, Ok(4));
    // SAFETY: 这两种组合由接口明确处理,均不会访问空指针。
    assert_eq!(unsafe { xor_bytes(std::ptr::null(), 0) }, Ok(0));
    assert!(unsafe { xor_bytes(std::ptr::null(), 1) }.is_err());
}

检查 data 非空、len 没溢出后,仍不知道缓冲实际有多大,也不知道它是否已释放;因此不能把函数改为任意安全调用的指针验证器。from_raw_parts 的 len 按元素而非字节计,换成 u32 时还要考虑对齐与总字节尺寸。from_raw_parts

若上层本来就持有 &[u8],直接接收切片即可;只有真正跨越缺少 Rust 生命周期信息的边界时,才需要维护这里的额外前提。不要为了展示 unsafe 而把可靠的切片先拆成指针,再在另一处猜回长度。

provenance:不是整数地址相等就可互换

指针除了地址还带有其访问来源等语义。整数往返、越界指针运算和跨分配拼接都可能破坏操作前提。两个分配即使恰好相邻,也不能自动合成一个 Rust slice。

add/offset 等操作有范围和溢出要求;wrapping_add 可放宽计算阶段的条件,却不使最终解引用合法。需要调整地址时,了解 addr、with_addr 等保留来源的接口,比任意 as usize 再转回更易表达意图。Rust 的完整别名模型仍在发展,应依赖明确的标准库契约,而不是把某个解释器模型当作所有细节的最终规范。

重叠复制与非重叠复制是不同契约

fn main() {
    let mut bytes = [1u8, 2, 3, 4, 5];
    let pointer = bytes.as_mut_ptr();
    // SAFETY: 源 0..4 和目标 1..5 均在同一有效数组内;u8 对齐,允许重叠。
    unsafe { std::ptr::copy(pointer, pointer.add(1), 4); }
    assert_eq!(bytes, [1, 1, 2, 3, 4]);
    let mut safe = [1u8, 2, 3, 4, 5];
    safe.copy_within(0..4, 1);
    assert_eq!(safe, bytes);
}

ptr::copy 在语义上允许重叠,效果如先保存源区再写入目标;不能仅因为某次测试通过,就把这里替换成 copy_nonoverlapping。两者的 count 都是 T 的元素数量。普通切片数据优先使用 copy_within、copy_from_slice 等检查边界的接口。ptr::copy

按位复制也不是调用 Clone。对 String 等拥有资源的类型,复制指针/长度字段后让两份对象都析构会破坏所有权;容器内部若用这种操作移动元素,必须同步维护哪些槽位还被视为有效拥有值。u8 示例避开了这层析构责任,不代表泛化成任意 T 仍然同样简单。

复制方向不能替代非重叠契约

拖动目标范围,让两个区间从分离逐渐变成重叠。结果按 ptr::copy 的语义计算,始终使用复制前的源值。即使某种手写循环方向在当前例子看起来正确,也不能改变 copy_nonoverlapping 对调用者的前提要求。

正在呈现知识画面
复制方向不能替代非重叠契约

ptr::copy 允许重叠,copy_nonoverlapping 要求不重叠;u8 的复制不涉及重复析构。

packed:允许未对齐布局,不允许未对齐引用

#[repr(C, packed)]
struct Packet {
    kind: u8,
    length: u32,
}

fn main() {
    let packet = Packet { kind: 1, length: 42 };
    let pointer = &raw const packet.length;
    // SAFETY: 指向已初始化的 u32,存储在访问期间有效;使用未对齐读取。
    let length = unsafe { pointer.read_unaligned() };
    assert_eq!(length, 42);
}

不要先写 &packet.length 再把它转裸指针,那已经试图形成未对齐引用。repr(packed) 也不处理字节序,这种内存布局不等于已经设计好跨平台网络编码。对字节协议,常可使用 from_le_bytes/from_be_bytes 避免依赖结构体内存布局。

FFI 的三条边界

extern "C" 决定目标平台 C 调用约定,repr(C) 决定相应类型的布局规则,具体指针/长度/释放协议决定资源语义。x86-64 Unix 与 Windows 的 C ABI 不相同,不能把 System V 寄存器表推广到所有 x86-64。

Rust 2024 的外部函数声明使用 unsafe extern 块;导出固定名字使用 #[unsafe(no_mangle)],同时仍需正确的 extern "C" 函数类型。整数宽度用 core::ffi 等目标别名匹配,不把所有 C int/long 永久绑定到同一个 Rust 宽度。

repr(C) 结构体的实际偏移受目标 ABI 及字段自身布局影响,外层 repr(C) 不递归修正内层默认 Rust 类型。跨边界枚举和 bool 等有效值也必须匹配,不能只比 sizeof 一样就认定安全。类型布局

CString 拥有缓冲负责最后释放as_ptr()临时只读借用指针外部读取返回后不再保留指针into_raw():转出拥有责任原 CString 不再自动释放from_raw():按契约取回只取回一次,长度不可被改变跨边界先区分借用指针与转移所有权两条不同协议;as_ptr 的结果不能拿去 from_raw,也不能交给任意 free。

C 字符串:终止符、编码和拥有关系分别检查

use std::ffi::{CStr, CString};
fn main() {
    let name = CString::new("hello").unwrap();
    let pointer = name.as_ptr();
    // SAFETY: pointer 借自仍存活的 CString,NUL 在同一分配内,期间没有写入。
    let borrowed = unsafe { CStr::from_ptr(pointer) };
    assert_eq!(borrowed.to_str().unwrap(), "hello");
    assert!(CString::new(b"a\0b".to_vec()).is_err());
    let non_utf8 = CStr::from_bytes_with_nul(b"\xff\0").unwrap();
    assert!(non_utf8.to_str().is_err());
    assert_eq!(non_utf8.to_bytes(), &[0xff]);
}

CString 保证自身的 NUL 终止布局,不保证内容是 UTF-8;CStr 可以借用合法的非 UTF-8 字节串,所以转 &str 还要经过 to_str 校验。CStr::from_ptr 会寻找终止符,调用者必须保证它在有效存储范围内可读;若外部提供长度,优先利用有界缓冲接口而不是无界扫描。

不要写 CString::new(...).unwrap().as_ptr() 后把指针留到下一条语句使用:临时 CString 可能已经释放。即使把 CString 保存在变量中,如果外部库会在函数返回后继续保存这个指针,也必须延长真正拥有者的有效期,并协调最后一次外部访问。CString

转出所有权后,只能按原协议取回

use std::ffi::CString;
fn main() {
    let raw = CString::new("owned").unwrap().into_raw();
    // 此处可以按约定把 raw 交给外部代码;示例不修改其内容或长度。
    // SAFETY: raw 来自上面的 into_raw,尚未回收,终止符位置没有改变。
    let restored = unsafe { CString::from_raw(raw) };
    assert_eq!(restored.to_str().unwrap(), "owned");
}

from_raw 不是“把任何 C 字符串变成 Rust 所有者”。它只接回匹配的 into_raw 所转出的 CString;若外部改变终止符位置,重新计算出的长度可能与原分配不一致,违反回收契约。外部库分配的字符串应使用它提供的释放函数,必要时先复制成 Rust 拥有的数据。

来源常见正确释放责任不能直接替代
Box::into_raw按原类型与分配契约 Box::from_raw 一次libc free、重复 from_raw
CString::into_raw满足长度等要求后 CString::from_raw 一次任意 C 字符串指针
外部 create 返回句柄同库 destroy/close默认 Rust 分配器释放
仅借用的 as_ptr真正拥有者在借用结束后释放从借用指针制造第二个所有者

绑定生成之后还有什么工作

bindgen 从头文件生成声明,输出应放在 OUT_DIR,与 include! 路径一致。它不会自动发现 C 是否保存指针、回调线程是否并发,或某个返回值究竟由谁释放。

完整可运行的 C/Rust 联编与资源协议见 C ABI 与 bindgen。两篇的分工是:这里解释一次指针访问的有效条件,后者解释如何把外部库契约封装为安全接口。

回调与展开按具体 ABI 判断

Rust panic 越过普通非 unwind 的 C ABI 边界会导致进程终止;外部异常进入不允许展开的 Rust 边界则可能是未定义行为。C-unwind 为允许展开的场景提供不同约定,不能归因于“C 栈一定没有展开信息”。

回调内部用 catch_unwind 只处理可展开的 Rust panic,不会捕获 abort,也不能自动恢复被中断更新的数据结构。是否允许取消注册后继续回调、是否必须等待在途调用结束,同样属于 FFI 的安全条件。FFI 展开

为回调画出最后一次访问

注册函数接收 callback 和 user_data 时,至少要知道回调能否并发、在哪些线程运行、取消注册是否等待在途调用,以及失败时是否仍保留 user_data。只有外部库保证再无回调访问后,Rust 才能释放指向的对象。

一个常见安全封装由拥有者保存外部句柄,在 Drop 中取消注册并完成必要的在途调用协调;如果取消注册可能同步触发回调,还要避免持有回调需要的锁。若库的销毁协议要求异步完成,单靠同步 Drop 可能不足,需要显式关闭接口并定义未关闭就丢弃时的行为。

这些约束来自外部库的真实契约,不能从 extern "C" fn(*mut c_void) 的签名推导出来。类型声明解决调用方式,安全封装负责把资源时序补完整。