想象你在开一辆配备了顶级自动驾驶辅助的汽车。Rust 的 safe 模式就是这样一辆车:编译器是副驾上的安全员,一直盯着你——不准超速、不准变道不打灯、每个借用的数据都有明确的车主。你几乎不可能因为驾驶习惯而撞车,因为安全员会在危险发生前就踩住刹车。 但自动驾驶有边界。你想驶入操作系统预留的越野区(调用系统 API)、想让车驶入另一个国家的公路(与 C 库互操作)、或者需要直接查看柏油路下的地基(操控裸指针)——自动驾驶辅助系统说:这段路我管不了。于是你按下“手动模式”按钮,这就是 unsafe。 注意,按下手动模式按钮,并不意味着安全员下车了。你依然要遵守交通法规、依然要系安全带,只是此时“不出事故”的责任,从系统转移到了你——司机——身上。这就是 unsafe 的灵魂契约:编译器把方向盘交给你,但所有的错误,都要你来承担。

岗位关联

学会在 Rust 中“安全地跳出安全区”,是系统级 AI 基础设施工程师的必修课:无论是绑定 C++ 推理引擎、对接 GPU 驱动,还是优化关键路径,你都将在 FFI 的边界上工作。理解了这一步,你就能在大模型推理、高性能向量计算等场景中游刃有余。

目录
  1. 可视化演示
  2. unsafe 初识
  3. 裸指针操作
  4. C 接口桥接
  5. 字符串飞地
  6. 安全封装层
  7. 回调消息站
  8. UB 探测器
  9. 工程纪律区
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示Unsafe Rust五种不安全操作、Rust与C通过FFI互调的数据流,以及安全封装模式将不安全代码隐藏在安全抽象层下。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Unsafe Rust五种不安全操作 DREF=解引用裸指针 *const, *mut T CALL=调用unsafe函数 STAT=访问可变static变量 unsafe { 5种操作 }

unsafe 初识

按下车内那个“手动模式”按钮之前,你得先弄清它会开启哪些新权限。Rust 的安全员(借用检查器)平日里管着五扇门,平时全是锁死的:第一扇门是解引用裸指针,第二扇是调用标了 unsafe 的函数,第三扇是读写可变静态变量,第四扇是实现 unsafe 的 trait(比如 Send 和 Sync),第五扇是访问联合体(union)的字段。手动模式一次性把这五扇门全打开了。

打开门不等于拆掉房子。许多初学者误以为进了 unsafe 块,借用规则就全部失效,可以随意造悬垂引用。实际上你的副驾安全员仍然坐在那里,普通的借用检查、生命周期检查照常工作。unsafe 只是解锁了五类“系统管不了”的操作,而不是把整辆车的安全系统都关了。

这种设计暗示了一个重要原则:最小化原则。越野路段能多短就多短,把 unsafe 块缩到最小,最好只有一行。每开一段手动模式,还要在代码旁写注释,告诉以后的司机:你为什么在这里跳出了安全区、你依赖了哪些不变量。

越野路段开多了,你会形成一种直觉:手动模式不是用来兜风的,而是用来解决最后几百米问题的。每次按下按钮都问自己一句——这段路我真的不能绕过去吗?

unsafe 额外能力={∗解引用裸指针⏟第一扇门, 调用 unsafe 函数⏟第二扇门, 访问可变静态⏟第三扇门, 实现 unsafe trait⏟第四扇门, 读写联合体⏟第五扇门}\displaystyle \text{unsafe 额外能力} = \{\underbrace{*\text{解引用裸指针}}_{第一扇门},\ \underbrace{\text{调用 unsafe 函数}}_{第二扇门},\ \underbrace{\text{访问可变静态}}_{第三扇门},\ \underbrace{\text{实现 unsafe trait}}_{第四扇门},\ \underbrace{\text{读写联合体}}_{第五扇门}\}
这条公式把 unsafe 的五种能力列成集合:不是“关闭检查”,而是精确地多给五把钥匙。
  1. 解引用裸指针对 *const T 或 *mut T 执行 *p 操作,编译器无法确认指针背后有没有有效数据。
    ∗p(p: ∗const T)\displaystyle *p\quad (\text{p: } *\text{const}\ T)
  2. 调用 unsafe 函数函数签名前带 unsafe 关键字的函数,调用时必须处于 unsafe 块中。
    unsafe fn f()→需在 unsafe 块内调用\displaystyle \text{unsafe fn}\ f() \to \text{需在 unsafe 块内调用}
  3. 访问可变静态变量static mut 类型的全局变量本身就有数据竞争风险,读写都需手动保证互斥。
    static mut COUNTER: u32→unsafe 读/写线程安全需自证\displaystyle \text{static mut COUNTER: u32} \xrightarrow{\text{unsafe 读/写}} \text{线程安全需自证}

裸指针操作

在越野路面上,GPS 信号会断,地图不会提醒你前方是悬崖。裸指针就是这么个玩意儿:*const T 和 *mut T 没有生命周期约束,不保证指向有效内存,甚至可以悬垂或为空。如果说引用是副驾上的安全员盯着你“必须安全到达目的地”,那么裸指针就是把你扔在没有护栏的山路上,让你自己判断路况。

安全员平时会阻止你做的事,在这里全部交给你判断。比如引用 &T 要求非空、对齐、指向有效值;而裸指针可以指向任意内存地址。你从引用转换到裸指针很容易,因为编译器知道引用此刻有效;但从裸指针回到引用,编译器就完全无法看清了——这正是必须进 unsafe 的时刻。你相当于在说:“我亲眼确认过这段路面没塌,让我下车走两步。”

引用到裸指针是免费且安全的:let p = &x as *const i32,不涉及任何风险,因为借用检查器已经证明了 x 活着。反过来,let r = &*p 就需要 unsafe,因为没人知道 p 指向哪儿。

回到驾驶的比喻上:当你拿着裸指针时,你就是那个下车检查路面的人。你可以自由地绕开路面的坑洼(检查空指针)、确认地基是否坚固(验证生命周期),但你必须自己记得——刚才 GPS 显示的距离,现在全靠脚来量了。

&T⏟带护栏→as 转换安全 ∗const T⏟裸指针→解引用必须 unsafe &T⏟重建护栏\displaystyle \underbrace{\&T}_{\text{带护栏}} \xrightarrow[\text{as 转换}]{\text{安全}}\ \underbrace{*\text{const}\ T}_{\text{裸指针}} \xrightarrow[\text{解引用}]{\text{必须 unsafe}}\ \underbrace{\&T}_{\text{重建护栏}}
这是一个单向“安全感降级”过程:从引用到裸指针免费,从裸指针回到引用却要收费——收费的货币就是你的判断力。
  1. 引用转裸指针as 转型是语言内置的安全转换,引用有效,所以取出的裸指针也指向有效数据。
    let p:∗const T=&x as ∗const T;\displaystyle \text{let}\ p: *\text{const}\ T = \&x\ \text{as}\ *\text{const}\ T;
  2. 裸指针解引用*p 的读取可能访问非法内存,编译器不再背书,必须由程序员保证其有效性。
    ∗p ⇒必须位于 unsafe 块中\displaystyle *p\ \Rightarrow \text{必须位于 unsafe 块中}

C 接口桥接

FFI 全称是“外部函数接口”(Foreign Function Interface),你可以把它理解成一个国境口岸。Rust 的车要开到 C 语言的国家去,两边得先签订协议:你们承认什么样的驾照(调用约定)、什么样的车型(内存布局)。如果不提前约定好,你挂着 R 档往前开,对方还以为你在倒车,必然出事。

extern "C" 块就是这份协议。在块里声明你想调用的 C 函数,例如 extern "C" { fn strlen(s: *const c_char) -> usize; },意思是:“我要在 C 国调用这个叫 strlen 的驾照,它的规则全部按 C 标准来。”调用这种函数本身是 unsafe 的,因为你不知道 C 那头的实现是否靠谱——它可能返回一个悬垂指针,可能永远不会返回。

除了函数签名,数据结构也必须对齐暗号。Rust 结构体的字段顺序可能会被编译器私自重排以便优化,而 C 结构体则一定是声明顺序排列。所以跨边界传送结构体时,要加上 #[repr(C)] 这个魔法标记,强制 Rust 的结构体按照 C 的方式排兵布阵。

过了 FFI 这个口岸之后,你就正式踏上了异国公路:C 侧不会帮你做任何内存安全检查,它只负责按协议办事。你递过去的指针、你承诺的生命周期,都要一言九鼎。

extern "C" { fn strlen⏟C 函数名(ptr: ∗const c_char⏟C 字符串指针)→size_t⏟无符号整数 ; }\displaystyle \text{extern "C"}\ \{\ \text{fn}\ \underbrace{\text{strlen}}_{\text{C 函数名}}(\underbrace{\text{ptr: } *\text{const}\ \text{c\_char}}_{\text{C 字符串指针}}) \to \underbrace{\text{size\_t}}_{\text{无符号整数}}\ ;\ \}
这个 extern 块像一本跨境驾驶手册:函数名、参数类型、返回类型全都按 C 国的交通规则写明,调用时你拿的是一张有效的国际驾照。
  1. extern "C" 块告诉编译器,块内函数使用 C 的 ABI(应用二进制接口),调用约定与 C 编译器一致。
    extern "C" { … }\displaystyle \text{extern "C"}\ \{\ \dots \ \}
  2. C 字符串指针参数C 函数通常接收 char*,在 Rust 侧对应 *const c_char,一个指向以 \0 结尾字节序列的裸指针。
    fn strlen(ptr: ∗const c_char)→size_t\displaystyle \text{fn }\text{strlen}(\text{ptr: } *\text{const}\ \text{c\_char}) \to \text{size\_t}

字符串飞地

C 国的路牌和 Rust 国的路牌长得完全不一样。Rust 的 &str 知道自己有多长——它在肚子里装了一个 GPS,记录着“我还剩几个字节”;而 C 字符串不知道自己有多长,它只能靠一个约定:看到 \0 就算走到头了。就像 C 国的路牌,写到“前方 5 公里”就不写了,你自己数到电线杆为止。

因此,跨过 FFI 边界传字符串,不能直接把 &str 扔过去。你需要 CString:它把 Rust 字符串复制进一块新的内存,并在末尾补上 \0,再把这块内存的所有权握在自己手里。CString::new("hello") 成功后,用 .as_ptr() 取出 *const c_char 交给 C 函数。

反过来,C 返回的 char*,你要用 CStr 来包一层借用:CStr 会从指针出发,沿着内存一直扫到 \0,然后在前面包上一层 &str 让 Rust 安全地读出内容。注意,CStr 只是借用,不拥有内存。谁负责释放那段内存?还是得靠 C 侧自己的规则——可能用 free(),也可能是静态内存,你只管读,不要乱释放。

在字符串这片飞地上,最常见的车祸就是忘记 \0,或释放了 CStr 正在借用的内存。记住两国路牌的不同写法,每次过境前检查自己的后备箱里有没有带够终止符。

&str⏟带长度标签→CString::newCString⏟末尾补 \0→as_ptr∗const c_char⏟C 侧裸指针→C 函数⏟读到 \0 停止\displaystyle \underbrace{\&\text{str}}_{\text{带长度标签}} \xrightarrow{\text{CString::new}} \underbrace{\text{CString}}_{\text{末尾补 \textbackslash0}} \xrightarrow{\text{as\_ptr}} \underbrace{*\text{const}\ \text{c\_char}}_{\text{C 侧裸指针}} \to \underbrace{\text{C 函数}}_{\text{读到 \textbackslash0 停止}}
这条链展示了“两国路牌”的翻译过程:Rust 长度制换成 C 终止符制,由 CString 负责这套翻译并管理底层内存。
  1. CString 构造CString::new 检查字符串内部是否含 \0,若含则报错;成功后追加一个 \0 终止符号。
    CString::new(s: &str)→Result<CString, NulError>\displaystyle \text{CString::new(s: \&str)} \to \text{Result<CString, NulError>}
  2. 暴露裸指针as_ptr() 返回指向内部缓冲区的 *const c_char,这个指针在 CString 存活期间有效。
    cstring.as_ptr()→∗const c_char\displaystyle \text{cstring.as\_ptr()} \to *\text{const}\ \text{c\_char}

安全封装层

越野驾驶总会让人提心吊胆,但你完全可以把它藏起来:在内部开一小段危险路段,然后把车交给乘客的时候,又切换回自动驾驶模式。这就是安全封装层的精髓——内部用 unsafe,对外暴露安全 API。unsafe 代码不过夜,更不能洒得到处都是。

比如你要封装一个 C 库的 create/use/destroy 三个函数,就可以设计一个 Rust 结构体:构造函数内部调用 create(unsafe),Drop 实现里调用 destroy(unsafe),而中间业务方法全部只用安全的 &self 就能完成。外面的调用者完全感受不到任何 unsafe 的存在,他拿到的是一辆带全景天窗的自动驾驶汽车——只管用,不用懂发动机。

这个模式背后是 RAII(资源获取即初始化):资源在构造函数里获取,在 Drop 里释放,编译器帮你保证释放必然执行。加上对 Option、Result 的映射,把 C 的“返回 -1 表示失败”翻译成 Rust 的 Result<T, Error>,封装层就让危险的异国驾驶变成了本地的舒适通勤。

老司机的经验告诉我们:所有 unsafe 都应该有一个“防弹玻璃”。当你能把危险路段浓缩到一个结构体的内部实现里,剩下的代码就回到了安全员的怀抱,你的项目才能长时间平稳运行而不出大事故。

安全API(x)=验证前置条件⏟安全区代码+unsafe 核心操作⏟手动区一段路+错误翻译⏟Result 包装\displaystyle \text{安全API}(x) = \underbrace{\text{验证前置条件}}_{\text{安全区代码}} + \underbrace{\text{unsafe 核心操作}}_{\text{手动区一段路}} + \underbrace{\text{错误翻译}}_{\text{Result 包装}}
安全封装像一个双层保温杯:外层是安全的接口,内层是不安全的内胆;你喝水时触碰的永远只有外层。
  1. 验证前置条件进入 unsafe 前,先检查指针是否为空、下标是否越界,保证后面手动操作的前提成立。
    if ptr.is_null() { return Err(...); }\displaystyle \text{if}\ \text{ptr.is\_null()}\ \{\ \text{return Err(...);}\ \}
  2. unsafe 核心操作前置条件满足后,调用外部 C 函数并解引用返回的指针。
    unsafe { c_lib_create(config) }\displaystyle \text{unsafe}\ \{\ \text{c\_lib\_create(config)}\ \}
  3. 错误翻译把 C 的返回码(0 成功、-1 失败)映射到 Rust 的 Result 类型,调用方可以放心地用 ? 传播错误。
    Err=if ret<0 { Error::Last } else { Ok(handle) }\displaystyle \text{Err} = \text{if}\ \text{ret} < 0\ \{\ \text{Error::Last}\ \}\ \text{else}\ \{\ \text{Ok(handle)}\ \}

回调消息站

有些时候,C 函数办完事会主动打给你——这叫回调(callback)。你走到 C 国办事大厅,留下一张名片:“如果处理完,请打这个电话通知我。”这个名片就是函数指针。

在 Rust 里,要传给 C 的回调函数必须声明为 extern "C" fn,保证调用约定和 C 一致。同时它不能是闭包,因为闭包可能捕获环境,而 C 不知道什么叫“环境”。那怎么把状态带进回调里呢?业界标准做法是“用户数据指针”:你传给 C 一个 *mut c_void(或 *mut T),C 原封不动地在回调时递回来,你在回调里再转回 &mut T。

这套“递名片 + 暗号验证”的流程也有陷阱:如果回调可能在线程池里被调用,你就要保证传给 C 的数据是 Send 的,且生命周期覆盖整个回调窗口。更危险的是,C 可能持有你的函数指针不放,等你 Rust 侧都 Drop 了之后才拨打电话——那就成了打给死人的电话。

回调像是你和 C 库之间的对讲机。用得好,双方配合默契;用不好,就可能出现 C 侧在深夜拨号给你已经注销的手机号。确保对讲机的持有期限、确保暗号不失效,是你作为司机的责任。

extern "C" fn⏟名片格式(user_data: ∗mut c_void⏟随名片夹带的暗号, event: u8⏟事件编号)\displaystyle \underbrace{\text{extern "C" fn}}_{\text{名片格式}}(\underbrace{\text{user\_data: } *\text{mut}\ \text{c\_void}}_{\text{随名片夹带的暗号}},\ \underbrace{\text{event: u8}}_{\text{事件编号}})
回调签名永远带着一个用户数据指针:这是 C 世界“带话”的唯一方式,Rust 侧靠它在回调里找回自己的状态。
  1. extern "C" fn 声明确保函数使用 C 的调用约定,C 侧才能正确压栈、弹栈。
    extern "C" fn on_event(data: ∗mut c_void, code: u8)\displaystyle \text{extern "C" fn}\ \text{on\_event}(\text{data: } *\text{mut}\ \text{c\_void},\ \text{code: u8})
  2. 暗号还原回调内部把 *mut c_void 转回 &mut T,这个转换必须 unsafe,且要求 C 递回来的确实是当初传出去的指针。
    let state=&mut∗(data as ∗mut State);\displaystyle \text{let state} = \&mut *(data\ \text{as}\ *\text{mut}\ State);

UB 探测器

手动模式下最怕什么?不是明显的报错,而是未定义行为(Undefined Behavior,简称 UB)。UB 像路上的暗冰:程序看起来正常行驶,但整个行为已不在任何规范的保护范围内。悬垂指针解引用、对未对齐地址取值、整数溢出、数据竞争,都是暗冰。它们不会立刻让你翻车,但可能在随机时刻炸毁整个程序。

怎么在真正上路之前发现暗冰?这时候就要请出 Miri 这位“模拟驾驶教练”。Miri 是一个 Rust 解释器,它不真跑编译后的机器码,而是一行行地解释执行,同时监控每一次内存访问:指针是否悬垂、对齐是否满足、引用是否被违反。运行 cargo miri test,它会在你的测试集里模拟所有路径,一旦踩到 UB 就立刻勒令停车。

有了 Miri,你可以把 unsafe 的越野路段先放到模拟驾驶舱里开几百遍。不过它只能检测“能不能被实际跑出来的 UB”,有些 UB(比如数据竞争在特定硬件上才触发)它也只能靠模型去猜。所以 Miri 不是万能的,但它是每个 unsafe 司机最称心的副驾。

Miri 就像驾校里的模拟器,让你在安全环境里把一切危险都经历一遍。在你真正驾驶上生产环境的公路之前,先在模拟器里把所有暗冰都碾一遍吧。

UB 诱因={悬垂指针⏟指空, 越界访问⏟超速, 未对齐⏟路面坑洼, 数据竞争⏟抢道, 空指针⏟断路}\displaystyle \text{UB 诱因} = \{\underbrace{\text{悬垂指针}}_{指空},\ \underbrace{\text{越界访问}}_{超速},\ \underbrace{\text{未对齐}}_{路面坑洼},\ \underbrace{\text{数据竞争}}_{抢道},\ \underbrace{\text{空指针}}_{断路}\}
这条公式把常见的未定义行为列成清单:Miri 会在模拟阶段逐个扫描,遇到任一项就自动踩刹车并报出“事故地点”。
  1. 悬垂指针指针指向的内存已被释放,解引用等于走进了一栋已经拆迁的楼。
    dangling=∗ptr(ptr→已释放内存)\displaystyle \text{dangling} = *\text{ptr} \quad (\text{ptr} \to \text{已释放内存})
  2. 数据竞争两个线程同时读写同一块内存且至少一方在写,读写次序未同步。
    race(thread1, thread2): read∩write≠∅\displaystyle \text{race(thread1, thread2):}\ \text{read} \cap \text{write} \neq \varnothing

工程纪律区

最后一个区域不写代码,而是立规矩。一个成熟的 unsafe 司机,出发前会做完整的检查清单。首先是代码审查:每一行 unsafe 都要经过两个人的眼睛,因为一个人的盲区,可能正好覆盖了悬垂指针坠落的位置。

其次是注释不变量。每次写 unsafe,都要回答三个问题:为什么这里是安全的、什么条件保证了它安全、如果条件被破坏会怎样。这些注释是给未来司机的路标,让他们维护时不至于重新踩进同一个坑。Rust 社区甚至建议把不变量写进文档测试,让 cargo test 自动验证一部分。

最后是模糊测试(Fuzzing)。给 FFI 边界喂随机输入,让 C 函数面对各种奇怪的字符串、奇葩的长度、边界值。模拟驾驶只测你想到的场景,而模糊测试会测你没想到的场景——这相当于夜间无人路段的高速急刹测试,逼出那些只在极限边缘才露头的 UB。

工程纪律的本质是敬畏:手动模式不是更自由,而是更自律。所有成熟的 Rust 项目,表面看是一套安全的 API,内里都有一套严苛的纪律在守护着少数几行 unsafe——就像最有经验的越野司机,恰恰是那个出发前检查轮胎最仔细的人。

安全总分=最小化 unsafe 行数⏟少开手动×完整不变量注释⏟路标清晰×测试与模糊覆盖⏟多跑几遍\displaystyle \text{安全总分} = \underbrace{\text{最小化 unsafe 行数}}_{\text{少开手动}} \times \underbrace{\text{完整不变量注释}}_{\text{路标清晰}} \times \underbrace{\text{测试与模糊覆盖}}_{\text{多跑几遍}}
三个工程纪律像乘法因子:任何一项趋近于零,整体安全性就归零。少写一行 unsafe、多写一条注释、多跑一次模糊测试,三管齐下才谈得上安心。
  1. 最小化 unsafe把 unsafe 块收缩到只剩必要操作,周围全用安全代码包裹。
    min⁡∣unsafe 行数∣\displaystyle \min \left|\text{unsafe 行数}\right|
  2. 不变量注释用 // SAFETY: 开头写清楚安全前提,让审阅者能够核对。
    // SAFETY: 调用前已验证 ptr 非空且指向有效对象\displaystyle \text{// SAFETY: 调用前已验证 ptr 非空且指向有效对象}
  3. 模糊测试使用 cargo-fuzz 对 FFI 函数做随机输入轰炸,让隐藏 UB 现形。
    fuzz(fn, random_inputs)→找 UB\displaystyle \text{fuzz}(\text{fn},\ \text{random\_inputs}) \to \text{找 UB}

AI 应用场景

绑定 C++ 推理引擎(llama.cpp)

阶段:模型部署与推理加速

解决的问题:大语言模型的推理引擎(如 llama.cpp)是 C/C++ 写的,直接用原生代码调用容易内存泄漏或崩溃;Rust 的 AI 服务端需要用安全的接口接住这些 C 函数。

具体案例:某 AI 公司要在 Rust 编写的推理网关中集成一个 GGUF 格式的量化模型库。Rust 工程师用 extern "C" 声明 C API,写了一个 LlamaSession 结构体:构造函数创建上下文,Drop 中释放内存,generate(prompt) 方法走安全封装层,内部才触碰裸指针。结果上层 AI 业务代码完全无感,所有 C 侧资源都由 Rust 结构体 RAII 管理。

为 GPU 计算库做安全封装

阶段:高性能数值计算

解决的问题:CUDA 驱动和 cuBLAS 都是 C API,直接调用需要管理大量裸指针和设备内存;普通 AI 研发人员不可能记住每个指针的生命周期。

具体案例:向量检索项目要调用 cuBLAS 做矩阵乘法,Rust 团队通过 unsafe 封装了 Tensor 类型:内部持有设备指针,外部提供 matmul(&self, rhs: &Tensor) -> Tensor 安全方法。每个 Tensor 的 Drop 自动调用 cudaFree,半精度计算时的错误码也翻译成 Result。AI 算法工程师因此能用安全 Rust 写训练循环,完全避开 C 层陷阱。

用 Miri 守护 AI 安全关键路径

阶段:测试与质量保障

解决的问题:AI 服务中长期运行的任务一旦踩到 UB,可能在模型峰值请求时崩溃,且极难复现。

具体案例:一家做实时语音识别的公司,发现服务偶发段错误(Segfault)。团队为所有涉及 FFI 的模块补充 #[cfg(miri)] 测试,在 CI 流水线里运行 cargo miri test,成功捕获一处回调函数里悬垂指针的解引用——那正是高峰期请求并发导致 C 侧提前释放缓冲区引发的。修复后,崩溃率降为零。

Python 演示

python37 行
import ctypesimport platform  class SafeWrapper:    """模仿 Rust 的安全封装:内部藏住 C 指针,外部只给安全方法。"""     def __init__(self, text):        # 模拟 CString:结尾补 \0,持有底层的 C 缓冲区        self._buf = text.encode("utf-8") + b"\x00"        self._c_buffer = ctypes.create_string_buffer(self._buf)     def as_c_ptr(self):        # 模拟 as_ptr():把内部缓冲区暴露给 C 侧        return ctypes.cast(self._c_buffer, ctypes.c_char_p)  def demo_ffi():    # 1. 加载系统 libc —— 相当于 extern "C" { fn strlen(...) }    lib = (        ctypes.CDLL(None)        if platform.system() != "Windows"        else ctypes.CDLL("msvcrt")    )    c_strlen = lib.strlen    c_strlen.argtypes = [ctypes.c_char_p]    c_strlen.restype = ctypes.c_size_t     # 2. 安全封装下使用裸 C 函数    msg = SafeWrapper("hello from ffi land")    length = c_strlen(msg.as_c_ptr())    print("C strlen =", length)    print("Python len =", len("hello from ffi land"))  if __name__ == "__main__":    demo_ffi()
目的:用 Python 的 ctypes 还原了 Rust 中 CString 封装 + extern "C" 调用的完整链路,跑通一次从安全代码进入 C 函数再返回的“跨境之旅”。
  1. class SafeWrapper对应 Rust 的安全封装层,内部持有 C 缓冲区的所有权,外部只暴露安全方法。
  2. self._buf = text.encode("utf-8") + b"\x00"模拟 CString::new 的补 \0 行为,让 C 侧能正确判断字符串终点。
  3. self._c_buffer = ctypes.create_string_buffer(self._buf)在内存中创建一块可供 C 代码读取的连续缓冲区。
  4. as_c_ptr对应 Rust 的 .as_ptr(),把内部地址以 c_char_p 形式传给外部 C 函数。
  5. lib = ctypes.CDLL(None)加载系统标准 C 库,类似 Rust 里 extern "C" 声明并链接外部库。
  6. c_strlen.argtypes = [ctypes.c_char_p]声明 C 函数的参数类型,相当于 FFI 签名里的 *const c_char。
  7. length = c_strlen(msg.as_c_ptr())在安全 API 内部完成一次不安全的 C 调用,调用方并不接触裸指针。

常见误区

误区我以为 `unsafe` 块里可以随便造悬垂引用、改任意内存,编译器全都不管了,就像打开了游戏里的无敌模式。

正解unsafe 并不停用借用检查器,它只是额外开放五类操作——解引用裸指针、调用 unsafe 函数、访问可变静态变量、实现 unsafe trait、读写联合体。其它所有安全规则照常生效。如果在一个 unsafe 块里写出两个互相冲突的可变借用,编译器依然会毫不留情地报错。它更像是给你五把备用钥匙,而不是拆掉整栋楼的安保系统。

为何常见:“unsafe(不安全)”这个名字太容易让人误解为“关闭安全机制”,初学者看到关键字就以为整段代码都脱离了编译器的掌控。

误区我整理依赖时,只要发现某个 crate 里出现 `unsafe`,就立刻判定它不靠谱,想换掉。

正解标准库和无数高质量 crate 内部都大量使用 unsafe,只是它们把不安全的实现藏在安全 API 后面。真正危险的不是“用了 unsafe”,而是“用了 unsafe 却没有安全封装和不变量注释”。判断一个库是否安全,要看它是否做到最小化 unsafe、是否有清晰的 SAFETY 注释、是否有测试覆盖。这就好比你看一家餐厅是否卫生,不能只看后厨有没有用刀,要看菜刀有没有戴上刀套。

为何常见:不了解 unsafe 的封装模式时,很容易把“出现关键字”等同于“存在风险”,忽略了工程纪律才是安全的核心。

误区我把 `CString::new(...).as_ptr()` 得到的裸指针存到一个全局变量里,过了一段时间再去用,结果程序崩溃了。

正解as_ptr() 返回的指针只在 CString 存活期间有效。一旦 CString 被 Drop,它管理的缓冲区就被释放,保存下来的指针变成悬垂指针。正确做法是:让 CString 的生命周期覆盖 C 函数调用的全过程,或者把 C 需要的数据复制到 C 自己管理的内存中。指针像一张门禁卡,卡的主人(CString)都离职了,你还能指望门禁还认这张卡吗?

为何常见:很多初学者把 FFI 里的裸指针当成普通整数看待,忽略了它背后还有一套所有权和生命周期的逻辑。

误区C 函数返回了一个 `*mut Foo`,我心想“既然都返回了,那肯定是有效的”,直接在 safe 代码里解引用。

正解裸指针不像引用那样自带“有效性证明”。C 函数可能返回空指针、指向栈上临时变量的指针、或需要额外调用释放函数的堆指针。正确处理方式是:通过 std::ptr::NonNull 去除空值可能性、确认生命周期、必要时用 Box::from_raw 接管所有权,并且所有这些检查/转换都必须在 unsafe 块中完成。这就像收到陌生人的名片,你不能直接按上面的地址开车冲过去,先得核实对方身份。

为何常见:C 函数返回的指针在大多数情况下确实是有效的,这种侥幸心理掩盖了少数失败路径,直到某一天在极端输入下爆出崩溃才追悔莫及。

面试题

Q1. 在 Rust 中,unsafe 块为开发者额外开放的能力不包括哪一项?

单选题 · easy

  • A.解引用裸指针
  • B.调用 unsafe 函数
  • C.绕过借用检查器← 正确答案
  • D.访问可变静态变量

答案:C。unsafe 开放的是五类特定能力,但借用检查器仍然全程在工作。它不会让你绕过借用规则——你在 unsafe 块里写两个冲突的可变借用依然编译失败。选项 C 是常见的误解,“关闭安全员”并不在列表里。

Q2. 裸指针 *const T 与引用 &T 的关键区别是什么?

单选题 · easy

  • A.裸指针不能为空
  • B.裸指针没有生命周期← 正确答案
  • C.裸指针指向只读内存
  • D.裸指针自动管理内存

答案:B。&T 由借用检查器保证生命周期内有效,必须非空且对齐;*const T 则没有任何这类保证,它可能为空、悬垂或指向任意地址,编译器不会替你把关。所以裸指针“下车自己看路”,引用则是“安全员全程护送”。

Q3. 在 Rust 中声明外部 C 库函数时,通常使用哪个语法块?

单选题 · easy

  • A.extern "C"← 正确答案
  • B.pub extern
  • C.static extern
  • D.unsafe extern

答案:A。正确答案是 extern "C" 块,它声明了使用 C 调用约定的外部函数。extern 是关键字,"C" 指定 ABI 为 C 约定。其它几个都是干扰项,Rust 中没有 pub extern 或 unsafe extern 这种写法。

Q4. Rust 与 C 互操作时,为什么结构体要加 #[repr(C)]?

单选题 · mid

  • A.加快编译优化速度
  • B.使用 C 语言的布局规则← 正确答案
  • C.开启自动垃圾回收
  • D.允许结构体参与继承

答案:B。Rust 默认允许编译器重排结构体字段以优化内存布局,而 C 结构体严格按声明顺序排列。#[repr(C)] 强制 Rust 一侧采用与 C 一致的布局规则,两边才能按同一张图纸对接内存。这个选择与优化速度、垃圾回收、继承无关。

Q5. CString::new("abc") 生成的 CString 与普通 &str 最大的区别是什么?

单选题 · mid

  • A.CString 末尾追加了终止符← 正确答案
  • B.CString 使用 UTF-16 编码
  • C.CString 的长度一定更短
  • D.CString 支持随机访问修改

答案:A。CString 在内部缓冲区的末尾追加了一个 \0 字节,因为 C 字符串靠它来判定终点;&str 则自己记录长度。CString 仍然是 UTF-8 编码,长度不一定更短,也不提供随机访问修改能力。

Q6. 安全封装模式的核心思路是哪一项?

单选题 · mid

  • A.在 pub fn 里直接写 unsafe
  • B.把裸指针返回给所有调用方
  • C.内部 unsafe 外部安全 API← 正确答案
  • D.在整个 crate 加 forbid(unsafe)

答案:C。安全封装模式把 unsafe 集中在类型内部实现(构造、析构、核心操作),对外暴露的 API 完全不出现 unsafe 关键字。调用方拿到的是一层“防弹玻璃”,既用上了底层 C 库能力,又不用亲自开手动模式。

Q7. FFI 回调函数在 Rust 侧应声明为哪种类型?

单选题 · mid

  • A.extern "C" fn← 正确答案
  • B.async fn
  • C.const fn
  • D.inline fn

答案:A。回调跨越 Rust 与 C 的边界,必须使用 extern "C" fn 保证调用约定一致。async fn 是异步协程,const fn 是编译期计算,inline fn 不是合法的 Rust 项声明,三者都无法作为 C 可调用的函数指针。

Q8. 下列哪些情况属于 Rust 中的未定义行为(UB)?

多选题 · hard

  • A.解引用悬垂裸指针← 正确答案
  • B.构造未对齐的引用← 正确答案
  • C.使用 as 做类型宽窄转换
  • D.在 unsafe 块中调用函数

答案:AB。解引用悬垂指针(A)和构造未对齐引用(B)都是典型 UB,Miri 能直接捕获。as 类型转换(C)本身是明确定义的语法操作,不属于 UB;在 unsafe 块中调用 unsafe 函数(D)正是其正常用途。UB 意味着“任何行为都可能发生”,包括崩溃和数据损坏。

Q9. 使用 Miri 可以检测出哪些问题?

多选题 · mid

  • A.悬垂指针的解引用← 正确答案
  • B.结构体字段的对齐错误← 正确答案
  • C.多线程的数据竞争← 正确答案
  • D.编译过程的速度瓶颈

答案:ABC。Miri 是 Rust 的解释器,在逐行执行时监控内存访问,能发现悬垂指针(A)、对齐错误(B)和数据竞争(C)。它做的是语义层面的动态检测,不关注编译性能(D)。想发现编译速度瓶颈应该用 cargo build --timings 或性能分析工具。

Q10. 在 unsafe 代码中,内存安全最终由谁负责保证?

单选题 · extreme

  • A.借用检查器的自动分析
  • B.编译器的运行时插桩
  • C.程序员手动证明不变量← 正确答案
  • D.操作系统的内存保护

答案:C。unsafe 把内存安全的举证责任从编译器移交给了程序员。借用检查器对裸指针的合法性不做判断,编译器也不插桩;操作系统只提供基础页面保护,不能识别逻辑层面的悬垂引用。只有程序员在代码中验证前置条件、保证生命周期、注释不变量,才能守住这条边界。这正是“手动模式”契约的核心意义。