ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Rust FFI 实战:extern “C“ 与 ABI 调用约定详解

Rust FFI 实战:extern “C“ 与 ABI 调用约定详解

写 Rust 的人迟早会撞上extern "C"。要么是项目里要调一个 C 库,要么是给其他语言写动态库,要么就是读代码时被这行字卡住了。我第一次接触它是在一个嵌入式项目里,需要 Rust 把采集到的数据传给 C 主程序,那时候对着extern块和#[no_mangle]折腾了好几天,后来踩的坑多了,才真正摸清楚这玩意儿背后的逻辑。这篇就把我这些年用extern "C"的经验做一个系统的梳理,从 ABI 到底是什么,到怎么调 C、怎么被 C 调,再到字符串、结构体这些高频踩坑点,一次性说透。

1. 先搞清楚:extern "C" 到底在解决什么问题

1.1 跨语言调用的地基:ABI

要理解extern "C",必须先明白一个概念:ABI(Application Binary Interface,应用二进制接口)。很多人把它和 API 混在一起,其实两者完全不是一个层面。API 是源代码层面的约定,你写printf("hello"),编译器知道要调用一个名叫 printf 的函数,这是编译期的事。ABI 是二进制层面的约定,它规定了函数参数怎么传递(放寄存器还是压栈)、返回值怎么传递、栈由谁来清理、结构体在内存里怎么排列、符号名怎么修饰。同一个函数,如果两个编译器对"参数放哪个寄存器、左移还是右移、参数从左往右 push 还是从右往左 push"的理解不一致,那编译出来的机器码互相之间就完全没法对接。

你可以把 ABI 想成两个陌生人约定在某个路口碰头。API 相当于约定了"明天下午三点在路口的咖啡店见",这没问题。但到了路口之后,走左边那条路还是右边那条路、进门先迈左脚还是右脚,这些细节没有约定好,两个人就可能擦肩而过。ABI 就是这些"走哪条路、迈哪只脚"的底层约定。

1.2 为什么偏偏是 C

任何一门带 FFI(Foreign Function Interface,外部函数接口)的语言,都需要选一个 ABI 作为对外沟通的"普通话"。理论上你可以自己设计一套 ABI,然后说服所有语言都来支持你,但现实是:C 是事实上的操作系统语言,几乎所有系统库都是 C 写的或用 C ABI 暴露接口。操作系统提供的系统调用、动态链接机制、C 标准库,全部建立在 C ABI 之上。

Rust 里extern后面可以跟不止"C",还可以写"stdcall"、"fastcall"、"system"等,这些是不同平台和编译器约定的调用约定。但"C"是默认值,也是最通用的选择。因为它遵循的是平台自身的 C ABI——在 Linux x86_64 上是 System V AMD64 调用约定,在 Windows x64 上是 Microsoft x64 调用约定。换句话说,extern "C"不是规定了一成不变的规则,而是告诉编译器:去跟这个平台上的 C 语言保持一致的二进制接口。

注意:extern "C"里的 C 是全大写,大小写敏感。写错了编译器会直接报错,因为"c"不是合法的 ABI 字符串。

2. 从 Rust 调 C:extern 块的正确打开方式

2.1 基础声明语法与 unsafe 边界

在 Rust 里声明外部 C 函数,用的是extern块:

extern "C" { fn strlen(s: *const c_char) -> usize; fn atoi(s: *const c_char) -> i32; }

这里有几个关键点。第一,extern块里只能写函数声明,不能写函数体。你只是告诉 Rust 编译器"有这么个函数,签名是这样的",至于它的实现,在链接时由外部库提供。第二,块内的函数默认是unsafe的,调用时必须在unsafe块里包起来。因为编译器无法验证一个来自 C 世界的函数行为是否正确,它会不会解引用空指针、会不会越界写内存、会不会直接崩掉,Rust 一律不保证。

到了 Rust 2024 版本,语法有了一点变化,extern 块本身需要用unsafe修饰:

unsafe extern "C" { fn strlen(s: *const c_char) -> usize; } unsafe { let len = strlen(c_str.as_ptr()); }

这意味着"这个 extern 块里所有声明都是可信的"这个判断,需要由写代码的人显式承诺。我个人觉得这个改动是合理的,它把"外部函数是否安全"的责任明确交到了开发者手里。

另一个高频操作是给库加链接属性。如果你的 C 函数来自一个名为mylib的静态库或动态库,需要这样写:

#[link(name = "mylib")] extern "C" { fn my_c_function(x: i32) -> i32; }

链接库名字不需要带lib前缀和.a/.so后缀,链接器会自动补全。比如#[link(name = "m")]对应的是libm.so或libm.a。这一块的坑在于:实际的链接行为还受构建环境影响。你可以在build.rs(Cargo 构建脚本)里通过cargo:rustc-link-lib指令动态指定,也可以用#![link(name = "...")]这种 crate 级属性,但日常开发中最常用的还是在extern块上直接标注#[link]。

2.2 动态库与静态库的链接方式

链接方式的选择会影响部署和开发体验。我个人的经验是:开发阶段优先用动态库,编译快、迭代方便;发布阶段如果目标环境可控,静态库更省心,不用管目标机器上有没有装对应版本的动态库。

在build.rs里可以控制链接方式:

// build.rs fn main() { println!("cargo:rustc-link-lib=static=mylib"); println!("cargo:rustc-link-search=native=path/to/lib"); }

cargo:rustc-link-lib的格式是kind=name,kind可以是static、dylib、framework(macOS)等。cargo:rustc-link-search指定链接器搜索库文件的路径。

这里有个容易忽略的点:如果用动态库,运行时系统要能找到它。在 Linux 上通常需要把库路径加进LD_LIBRARY_PATH,或者把.so文件放到系统默认搜索路径里(比如/usr/lib)。在 macOS 上用的是DYLD_LIBRARY_PATH,Windows 上动态库要么和 exe 放同一目录,要么加进PATH。我见过太多人编译过了但运行时error while loading shared libraries报错,最后发现就是路径问题。

提示:在使用第三方 C 库时,建议先确认库是 C 兼容的。C++ 库需要额外的extern "C"包裹,否则符号名会被 C++ 编译器修饰,Rust 这边extern块里声明的函数名就和链接器实际要找的符号对不上,报 undefined reference 错误。

3. 从 C 调 Rust:导出函数的完整姿势

3.1 no_mangle 与导出符号

调用方向反过来时,要做的事情稍微多一点。你写了一个 Rust 函数,希望 C 代码能调用它,核心需要两件事:保证使用 C 的调用约定,以及防止符号名被修饰。

第一件事通过extern "C"声明函数来实现:

#[no_mangle] pub extern "C" fn add_two_numbers(a: i32, b: i32) -> i32 { a + b }

第二件事通过#[no_mangle]属性实现。这里需要解释一下:Rust 编译器默认会对符号做名称修饰(name mangling),这是编译器为了支持重载、命名空间等特性而做的符号编码。修饰之后,函数在二进制里的真实符号名长这样:_ZN3foo14add_two_numbers17h8e3f9c29a1b2c3d4E。C 代码是没有办法用这种名字去调用的。#[no_mangle]就是告诉编译器:别修饰,直接把这个函数名作为符号名导出。此时 C 代码里就是一个普通的函数声明:

// C 侧代码 extern int add_two_numbers(int a, int b); int main() { return add_two_numbers(1, 2); }

如果add_two_numbers所在的 Rust crate 被编译成一个静态库(crate-type = ["staticlib"]),C 程序可以直接链接这个.a文件。如果想做成动态库,用crate-type = ["cdylib"],会得到一个 C 语言可以dlopen/LoadLibrary加载的.so/.dylib/.dll。

重点:#[no_mangle]和pub extern "C"通常成对出现。只写no_mangle不写extern "C",函数还是 Rust 默认的调用约定,C 侧如果按 C ABI 去调用,参数传递对不上就是未定义行为。只写extern "C"不写no_mangle,符号名被修饰,C 侧链接时找不到符号。两个缺一不可。

3.2 回调函数与函数指针

C 代码调用 Rust 函数最常见的场景之一是把 Rust 函数作为回调传给 C 库。比如某个 C 库提供注册回调的接口:

typedef void (*event_handler_t)(int event_id, void* user_data); void register_handler(event_handler_t handler, void* user_data);

Rust 这边对应的声明:

type EventHandler = unsafe extern "C" fn(event_id: i32, user_data: *mut c_void); #[link(name = "eventlib")] extern "C" { fn register_handler(handler: EventHandler, user_data: *mut c_void); }

注意回调指针的类型是unsafe extern "C" fn。Rust 里的函数指针类型也带 ABI 标注,默认的fn类型是 Rust ABI,extern "C" fn才是 C ABI。做 FFI 时,函数指针类型一定要和实际函数的 ABI 保持一致。

回调里最棘手的部分是生命周期和状态传递。C 库不会认识你的 Rust 引用和所有权,所以通常通过user_data这个void*参数传递上下文。在 Rust 侧,这意味着你要把一个裸指针传给 C,然后回调时再把它转回 Rust 引用:

struct Context { count: i32, } unsafe extern "C" fn on_event(event_id: i32, user_data: *mut c_void) { let ctx = unsafe { &mut *(user_data as *mut Context) }; ctx.count += event_id; } fn main() { let mut ctx = Context { count: 0 }; unsafe { register_handler(on_event, &mut ctx as *mut Context as *mut c_void); } // 确保 ctx 在回调被注销之前一直存活 }

这里最核心的经验是:Context的生命周期必须覆盖到 C 库最后一次调用回调为止。如果ctx在 main 结束前就被 drop 了,而 C 侧还持有那个指针,下一次回调就是悬垂指针访问,轻则数据错乱,重则段错误。而且这种 bug 通常不是每次都能复现,非常阴间。

4. 字符串和指针:跨语言数据交换的核心难点

4.1 CString/CStr 的正确使用

跨语言交换数据时,字符串是最基础也最容易出错的数据类型。C 里的字符串约定是以\0结尾的连续字节序列,也就是 NUL 结尾字符串。Rust 的String和&str则是长度前缀的,内部可以包含任意字节、不一定以\0结尾,而且本身就存储了长度信息。

Rust 标准库为此提供了两个类型:

  • CString:从 Rust 所有权字符串创建,保证以\0结尾,适合传给 C。
  • CStr:借用 C 侧传来的、以\0结尾的字节序列,适合从 C 接收。

从 Rust 往 C 传:

use std::ffi::CString; fn send_to_c(s: &str) { // 这一步会复制字符串并追加 NUL let c_str = CString::new(s).expect("字符串内不能含有 NUL 字节"); unsafe { my_c_function(c_str.as_ptr()); } }

CString::new返回Result,因为如果字符串内部含有\0,构造出来的 C 字符串会提前截断,C 函数拿到的内容就不完整,所以库在这里显式返回错误。

从 C 往 Rust 收:

use std::ffi::CStr; // 假设 c_ptr 来自 C 侧,指向一个 NUL 结尾的字符串 let c_str = unsafe { CStr::from_ptr(c_ptr) }; let s = c_str.to_str().unwrap_or("invalid utf-8");

CStr::from_ptr会从指针位置开始扫描,直到遇到\0,然后把这段字节序列包装成一个&CStr。这里有三个隐藏的坑:第一,指针必须是合法且确实指向 NUL 结尾的字符串,否则扫描可能越界;第二,to_str()会做 UTF-8 校验,C 侧的字符串不一定符合 UTF-8 编码,失败时需要处理;第三,CStr只是借用,C 侧释放这块内存之后,你这边持有引用就悬垂了。

经验之谈:跨语言字符串传递有一个原则——谁分配,谁释放。C 分配的字符串,别想着在 Rust 里用CString::from_raw去释放,除非你非常确定这块内存是 Rust 的分配器分配的。最常见的错误是你把一个CString传给了 C,C 函数内部调用了free()把它释放了,然后 Rust 侧CStringdrop 时再次释放同一块内存,造成 double free。这类 bug 排查起来非常痛苦。

4.2 内存所有权与生命周期

字符串只是指针的一种形态,更普遍的问题是:跨 FFI 边界传指针时,所有权怎么划分。Rust 的所有权模型在 C 面前完全不适用,C 不认 move、不认 borrow、不认 drop。所以做 FFI 时,你需要人肉维护一套"谁负责释放"的协议。

这是我常用的几个原则:

  1. 传指针给 C 时,明确约定 C 侧是否接管所有权。如果 C 函数"借用"数据(比如只是读一下字符串),传as_ptr()就行,所有权还在 Rust 手里,等函数返回后 Rust 侧正常释放。如果 C 函数"接管"了数据(比如它内部会缓存这个字符串供后续使用),那就得把所有权转移出去,这时要配合CString::into_raw()使用——这个方法返回裸指针,且不再跟踪释放,C 侧负责最终释放。

  2. 从 C 拿指针时,明确这个指针对应的内存归谁。如果 C 文档说内存由调用方释放,拿到*mut c_void后可以用Box::from_raw重新封装成 Rust 所有权类型,让 RAII 帮忙释放。如果 C 说无需释放(比如指向静态内存或库内部缓冲区),那就只借用,不要碰所有权。

// 把一个 Box 传给 C,所有权转移 let boxed = Box::new(42i32); let raw = Box::into_raw(boxed); // 得到 *mut i32 unsafe { consume_pointer(raw as *mut c_void); } // C 侧回调里把它收回来释放 fn release_box(raw: *mut c_void) { unsafe { drop(Box::from_raw(raw as *mut i32)); } }
  1. 结构体和枚举的布局一定要显式注解。Rust 的结构体字段顺序在默认情况下是未定义的,编译器可能为了对齐和优化重新排列字段。跨语言传结构体时,必须用#[repr(C)]让布局和 C 编译器一致:
#[repr(C)] pub struct SensorData { pub timestamp: u64, pub value: f32, pub valid: bool, }

C 侧对应:

struct SensorData { uint64_t timestamp; float value; int valid; // C 的 bool 实际是 int,这里用 int 对齐 };

这里有个容易翻车的点:Rust 的bool在repr(C)下是 1 字节,C 的_Bool也是 1 字节,但很多 C 代码用的是int来模拟布尔值。两边定义不一致,结构体偏移全乱。更稳妥的做法是约定用u8表示布尔(0 或 1),两边都显式用整数类型。

枚举的坑类似。Rust 默认的枚举布局不是 C 兼容的,跨 FFI 传枚举要用#[repr(C)]甚至#[repr(u8)]/#[repr(i32)]明确底层类型:

#[repr(u8)] pub enum ErrorCode { Ok = 0, Timeout = 1, Unknown = 2, }

C 侧对应enum ErrorCode如果编译器的enum底层类型默认是int,而 Rust 侧是u8,那也是不对齐的。最好 C 侧也用固定宽度的整数类型配合enum或直接用宏定义常量,或者两边约定 Rust 用#[repr(i32)]来匹配 C 的默认枚举宽度。

5. 实战踩坑记录:我遇到的几个经典翻车现场

5.1 Panic 跨越 FFI 边界

这个问题在 Rust 社区里已经被强调过很多次了,但我还是要再说一遍,因为我第一次真正被它坑到是在一个线上服务里。Rust 函数通过extern "C"导出给 C 主程序调用,函数内部因为某种原因 panic 了——索引越界、unwrap 失败、算术溢出——然后 panic 直接穿过了 FFI 边界。Rust 的 panic 默认是栈展开(unwinding),当它越过一个extern "C"函数帧时,行为是未定义的。在 Linux 上可能表现为 abort,在其他平台上可能是更随机的事情。

解决方案有两种:

第一种是给导出函数包上一层catch_unwind:

use std::panic::{catch_unwind, AssertUnwindSafe}; #[no_mangle] pub extern "C" fn safe_entry_point(x: i32) -> i32 { let result = catch_unwind(AssertUnwindSafe(|| dangerous_operation(x))); match result { Ok(v) => v, Err(_) => -1, // 返回错误码,C 侧自行处理 } }

第二种是设一个 panic hook,在跨边界前做兜底。我个人的做法是:导出的函数每个都做防御性包装,凡是逻辑比较复杂、可能触发 panic 的内部函数,统一catch_unwind包住。同时设置std::panic::set_hook,记录日志之后再继续。

另一个相关实践:包含了可能 panic 的代码的 crate,编译时建议加上panic = "abort"配置。这样 panic 直接终止进程,不会尝试跨越 FFI 边界做栈展开,行为至少是可预期的。缺点是你无法在 Rust 侧捕获 panic 恢复执行了,对于库来说,通常还是catch_unwind更灵活。

重要:CString::new(...).expect(...)这样的代码在导出函数里非常危险。外部输入触发内部含\0的字符串时,expect直接 panic,如果这个函数是 C 导出的,就中招了。导出的函数边界上,所有expect都要换成unwrap_or_else返回错误码,或者干脆用 match 处理。

5.2 符号冲突与链接顺序

另一个我踩过的坑是符号冲突。项目里同时链了好几个 C 库,这些库内部同样定义了一个同名辅助函数(这在老 C 代码里不罕见),链接时产生符号冲突。表现为链接器报 multiple definition 错误,或者更阴险的是,链接器选择了错误的那份实现,程序行为完全不符合预期。

排查这种问题的工具是nm:

nm -C libfoo.a | grep symbol_name nm -C libbar.a | grep symbol_name

如果发现两个库都有同名符号,需要确认符号的可见性以及谁先被链接。在 Rust 侧可以用#[link(name = "...")]顺序调整,或者在build.rs里控制链接顺序。更稳妥的方案是给 C 库打个补丁把符号改名,或者用objcopy --redefine-sym重新命名,避免直接依赖"链接顺序恰好正确"这种脆弱状态。

还有一个符号问题:写了#[no_mangle]的导出函数,最好保证名字不在常见的全局符号列表里。比如你这个库要和一个 C 库一起链接,C 库里恰好也有个叫init的函数,两边都no_mangle导出init,那就是冲突。给导出函数加项目前缀是个实用习惯,比如myproj_init、myproj_process_data,降低冲突概率。

5.3 结构体对齐与打包问题

结构体跨语言传输出错,最常见的原因是对齐(alignment)规则不一致。以#[repr(C)]为例,Rust 遵循目标平台的 C 布局规则,理论上应该和 C 一致。但实际项目中,C 侧代码经常带编译选项,比如 MSVC 的/Zp或 GCC 的__attribute__((packed)),改变默认对齐,这时候两边结构体尺寸就不同了。

一个具体例子:C 侧结构体用#pragma pack(push, 1)定义了紧凑布局,Rust 侧如果只用#[repr(C)]不管对齐,字段偏移就会错位,传过去的数据全乱。Rust 侧要匹配紧凑布局,需要加#[repr(C, packed)]。

// C 侧: #pragma pack(push, 1) // struct Packet { uint8_t flag; uint32_t value; }; // sizeof = 5,而不是默认对齐下的 8 #[repr(C, packed)] pub struct Packet { pub flag: u8, pub value: u32, }

另一条经验是:定义跨 FFI 的结构体之后,写一个断言把结构体大小固定住:

const _: () = assert!(std::mem::size_of::<Packet>() == 5);

这样如果哪一天布局变了,编译期就能发现,而不是运行时拿到一堆错乱数据。

5.4 生命周期穿越 FFI 的悬垂引用

Rust 的所有权模型保证了同语言内的安全,但一旦穿过 FFI 边界,编译器就管不到了。最常见的长生命周期 bug 是:一个识别指针被传给了 C 库,C 库把它存起来异步使用,而 Rust 侧以为函数调用结束了就可以释放。

unsafe { let data = CString::new("important").unwrap(); c_lib_register_data(data.as_ptr()); // C 库内部缓存了这个指针 // 此时 data 在函数结束时 drop,C 库之后再用就是悬垂指针 }

这个问题的本质是生命周期协议没对齐。正确做法:如果 C 库要缓存指针,Rust 侧必须用into_raw把所有权转出去,并实现对应的释放函数让 C 在合适时机调用,或者在 Rust 侧用一个静态/长期存活的结构管理数据,确保生命周期覆盖 C 库的使用期。

每次做这类设计时,我习惯先画一个"谁持有、谁释放"的表格,把自己和 C 侧的职责写清楚,再落到代码。这种"人肉 RAII"约定虽然烦琐,但比事后调试悬垂指针高效得多。

6. 构建集成与跨平台注意点

6.1 build.rs 与链接搜索路径

前面提到过build.rs里用cargo:rustc-link-lib和cargo:rustc-link-search控制链接。这里补充一个细节:构建脚本里还可以通过cargo:rerun-if-changed指令避免不必要的重新构建。

// build.rs fn main() { println!("cargo:rerun-if-changed=wrapper.h"); println!("cargo:rustc-link-search=native=./libs/linux"); println!("cargo:rustc-link-lib=static=my_native_lib"); }

rerun-if-changed=wrapper.h的意思是:只有wrapper.h变化时才重新跑构建脚本,而不是每次编译都重新执行。如果你的构建脚本要做一些耗时操作(比如生成绑定代码),这个指令能明显加快迭代速度。

6.2 跨平台调用约定的差异

extern "C"在 Linux、macOS 上对应 System V ABI,在 Windows 上对应 Microsoft x64 调用约定。绝大多数情况下,你写extern "C"就够了,因为这两种调用约定在函数参数传寄存器上虽然有差异,但只要你声明了extern "C",Rust 编译器会自动用目标平台的正确约定。

但 Windows 上有一个特殊情况:extern "system"。它在 32 位 Windows 上等同于stdcall,在 64 位 Windows 上等同于 C 调用约定。如果你在 Windows 上和 Win32 API 打交道,建议用extern "system"而不是extern "C",因为 Win32 API 的函数在 32 位下是stdcall约定,用extern "C"会不匹配。

#[cfg(windows)] extern "system" { fn MessageBoxA(hwnd: *mut c_void, text: *const c_char, caption: *const c_char, kind: u32) -> i32; }

这里的cfg(windows)是另一个要点:跨平台代码里,FFI 声明经常需要根据平台裁剪不同函数或库名。用#[cfg(target_os = "windows")]、#[cfg(unix)]等属性做条件编译。

6.3 用 bindgen 自动生成绑定

手写 C 函数声明容易出错,尤其是大型 C 库有几十个头文件时。实践中我推荐用bindgen自动从 C 头文件生成 Rust FFI 绑定。它在构建时解析头文件,生成对应的extern块和结构体定义,能省掉大量手写工作,也减少签名写错的概率。

# build-dependencies [build-dependencies] bindgen = "0.66"
// build.rs fn main() { println!("cargo:rerun-if-changed=wrapper.h"); let bindings = bindgen::Builder::default() .header("wrapper.h") .parse_callbacks(Box::new(bindgen::CargoCallbacks::new())) .generate() .expect("绑定生成失败"); let out_path = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); bindings .write_to_file(out_path.join("bindings.rs")) .expect("写入绑定文件失败"); }

然后在代码里include!(concat!(env!("OUT_DIR"), "/bindings.rs"))引入生成的绑定。用 bindgen 的时候,生成的代码默认是unsafe extern "C"块,内部还带一堆注释,不要手动修改生成的文件,要改就改 C 头文件或生成配置。

7. 一些个人经验总结

写了这些年 FFI 代码,我的体会是:extern "C"的语法本身不复杂,难的是心态转变。Rust 的编译器保证的是"在 Rust 的世界里安全",一旦开一个口子通向外部世界,安全责任就回到了你手上。你写的每个unsafe都是对这个责任的确认,而不是对安全规则的破坏。

有几个习惯我一直坚持,收益很大:

第一,所有 FFI 边界上的函数,无论导入还是导出,都写清楚注释,标明参数的传递方向(in/out/inout)、所有权归属、是否有别名、调用后谁负责清理。哪怕是自己一个人维护的项目也要写,因为三个月后的你看这段代码时的记忆是全新的。

第二,跨语言传结构体时,直接在代码里加断言锁死尺寸和偏移量。用std::mem::size_of和offset_of!宏(Rust 1.77 之后的稳定宏)在编译期做检查,一旦布局变了立刻编译失败,而不是运行时出现诡异的错乱。

#[repr(C)] struct Header { version: u16, flags: u8, length: u32, } const _: () = { assert!(std::mem::size_of::<Header>() == 8); assert!(std::mem::offset_of!(Header, length) == 4); };

第三,所有导出给 C 的函数,入口处先做参数合法性校验,把unwrap/expect/ 数组索引这些可能 panic 的写法全部替换为显式返回错误码。C 侧往往不会关心你的 Rust 代码内部为什么会失败,它只想拿一个状态码做下一步决策。

最后一个小技巧:调试 FFI 问题时,不要只盯 Rust 侧代码,用系统工具辅助确认。nm看符号、readelf看动态段、objdump看汇编、gdb断点猜调用约定。很多时候问题不在 Rust 代码逻辑,而在二进制层面的细节——符号名拼错了、结构体少加了一个字段、字节对齐差了两个字节,这些靠"读代码"是看不出来的,只有贴近二进制才能发现。

extern "C"不是什么高深魔法,它就是 Rust 世界里的一扇窗,打开窗户的语言叫 C ABI。学会正确开关这扇窗,你就打通了 Rust 与整个 C 生态的任督二脉——从系统调用到图形库再到嵌入式固件,几乎都能用 Rust 搭上手。希望这篇分享能帮你少走一些我走过的弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进