
如果你手上有一套跑了好几年的C代码库又眼红Rust在内存安全和并发安全上让编译器兜底的体验那“C与Rust交互编程”几乎是你绕不开的一关。我最近在两个项目的迁移改造里都碰到了同样的局面业务模块想用Rust重写但底层几百万行C引擎动不得只能让两种语言共存。这轮实操走下来最大的感受是——FFI本身并不难难的是把类型映射、内存所有权、构建集成这些细节理清楚。这篇文章我打算掰开揉碎讲清楚三件事为什么业界普遍采用C ABI作为两种语言的“共同语言”、实际工程里C调Rust和Rust调C分别怎么落地、以及那些不跑一遍根本发现不了的坑。内容既有完整示例也有构建配置适合正在做技术选型对比的人也适合已经把Rust接进C工程但被链接错误和段错误折磨过的朋友。我尽量少讲废话多放能直接抄走的代码和配置。1. 为什么非要把C和Rust绑在一起用1.1 现实场景老代码与新需求的拉锯先说一个很多团队都遇到过的典型局面游戏引擎、音视频处理、工业控制、交易系统这些对性能极其敏感的行当底层几乎全是C。这些代码往往是十年以上累积出来的资产逻辑经过无数次线上打磨稳定性已经非常高任何“推倒重写”的提议在管理层那里都过不了审。但与此同时新需求又源源不断地冒出来网络协议解析、状态机管理、并发任务调度、配置文件解析……这些模块恰恰是Rust的强项。用Rust写编译器会帮你把数据竞争、空指针、悬垂引用这类问题提前拦住而且性能和C站在同一个量级。于是最现实的做法就是老模块继续跑C新模块用Rust写中间通过FFI把两者接起来。这种渐进式迁移策略在工业界已经有不少成功案例比如Firefox的Stylo CSS引擎、微软在Windows组件里引入Rust、以及各类嵌入式项目。它不是“谁取代谁”的问题而是让两种语言在同一个进程里各司其职。1.2 C和Rust各自的优势对比很多初学者会误以为Rust一定比C慢或者C一定比Rust灵活其实这两种语言在性能上几乎是同一梯队真正的差异在“安全保证”和“开发体验”上。我整理了一张对比表方便快速建立认知对比维度CRust内存安全靠约定和代码审查容易漏编译器强制检查默认安全并发安全需要手动加锁和小心设计所有权机制从源头防止数据竞争学习曲线语法复杂需要多年经验借用检查器概念独特学习成本不低编译时间大型工程也很慢增量编译较慢release构建更慢泛型能力模板功能强大但报错难懂泛型trait清晰报错信息友好生态成熟度极成熟几乎什么库都有生态发展极快但部分领域仍有空缺异步编程需要自己管理回调或依赖第三方库async/await一等公民组合更自然从这张表能看出来Rust的“安全”不是靠运行时垃圾回收换来的而是靠一套静态分析规则在编译期完成的所以做互操作时不会引入额外的性能损耗。这也是为什么FFI场景下两种语言边界上的性能损失可以做到几乎为零——只要数据处理得当跨一次调用的开销就相当于一次普通函数调用。1.3 什么时候不该做互操作聊完了好处也得泼点冷水。C和Rust互操作并不适合所有场景我见过一些团队强行上FFI最后维护成本比收益还大。第一高频热路径不适合频繁跨界访问。比如游戏每帧要调用几千次的逻辑每次都要跨FFI边界做参数转换和所有权交接即使单次开销很低累积起来也会很可观。这种情况下不如把热路径完整写在同一门语言里。第二复杂数据结构不适合直接跨边界传递。C的std::vectorstd::pairCustomType, std::string这种复杂类型没法直接映射到Rust必须序列化或者逐层转换成C兼容结构体转换代码本身就是一份沉重的维护负担。跨边界传数据时永远优先考虑基础类型扁平结构体。第三团队没有Rust经验时不要一上来就搞大集成。Rust的借用规则和所有权模型和C差异很大如果没人能看懂panic跨边界、生命周期标注这些概念出了问题排查会很痛苦。最好先用一个独立的小工具模块练手跑通了再进入核心链路。2. 交互的基石C ABI与类型映射2.1 为什么是C ABI而不是C ABI做C和Rust互操作第一课就是忘掉C和Rust各自的原生ABI一切以C ABI为“通用语”。原因很简单C的ABI在不同编译器MSVC、GCC、Clang、不同标准库实现、甚至不同版本之间都可能不一样。类成员函数的name mangling规则五花八门std::string的内部布局也随版本迭代一直在变。直接让Rust去调C类成员函数等于踩在一个随时可能塌的平台上。而C ABI是几十年来的稳定契约函数名不加修饰调用约定统一数据布局明确。Rust通过extern C声明使用C ABIC通过extern C块让编译器别做name mangling两边就能对上号。// C侧导出给Rust extern C { int my_add(int a, int b); }// Rust侧声明从C导入 extern C { fn my_add(a: i32, b: i32) - i32; }这里有个关键点必须提醒Rust侧声明extern C块里的函数默认是unsafe的调用时要包在unsafe块里。很多新手第一次写就栽在这里觉得怎么处处都要unsafe其实这就是FFI的本质——编译器不再帮你验证跨语言边界两侧的契约安全性需要你用人肉保证。2.2 基础类型映射与结构体布局跨语言传参最省心的做法是只用基础类型。原则是C和Rust都保证在大多数平台上能映射成相同位数、相同语义的类型。下面是我实际项目里最常用的一张映射表C类型C类型Rust类型int32_tint32_ti32uint64_tuint64_tu64doubledoublef64boolboolboolchar*/const char*const char**const c_charvoid*void**mut c_void枚举int#[repr(C)] enum结构体跨边界传递时必须保证两边的内存布局一致。C侧通常已经有#pragma pack或者编译器默认对齐规则Rust侧必须显式标注#[repr(C)]来匹配C语言的对齐方式否则Rust编译器可能会重新排列字段以优化内存布局两边数据就对不上了。#[repr(C)] pub struct Point { pub x: f64, pub y: f64, }struct Point { double x; double y; };C的std::string和std::vector这类STL容器绝对不能直接跨FFI传递它们的内部实现不在C ABI契约内。正确做法是退化成“指针长度”的组合Rust侧传一个*const u8和usizeC侧拿这两个参数构造std::string_view或std::span或者反过来。字符串本质上就是一种带长度的字节数组按这个思路处理就能绕开所有布局问题。2.3 所有权与内存释放谁分配谁负责跨语言边界传指针最危险的从来不是传参而是内存释放。C用new分配的内存如果交给Rust侧用Box::from_raw接管释放崩不崩全看运气反过来也一样。所以必须在设计接口时就把所有权规则写清楚。我习惯的约定是哪一侧分配的内存就由哪一侧提供释放函数。比如Rust侧创建的对象返回裸指针给C用同时导出一个destroy函数由C调用这个函数来释放内存。反过来C侧构造的数据传给Rust最终由C侧负责销毁。#[no_mangle] pub extern C fn create_point(x: f64, y: f64) - *mut Point { Box::into_raw(Box::new(Point { x, y })) } #[no_mangle] pub extern C fn destroy_point(p: *mut Point) { if !p.is_null() { unsafe { drop(Box::from_raw(p)) }; } }C侧使用时很清晰Point* p create_point(3.0, 4.0); // 使用p destroy_point(p);这个模式我建议一直沿用无论对象多简单。它从根本上防止了“A分配、B释放”造成的堆损坏。还有一个容易被忽略的点即使指针是nullptr释放函数也要做空指针检查我见过不少线上崩溃就是接口里偷懒没判空。3. Rust调用C两条路都能走通3.1 手动声明extern块小规模接入最直接如果项目中需要调用的C函数不多比如就三五个接口那完全没必要引入自动化工具。直接在Rust侧手写extern声明即可最直观也最可控。配合C侧头文件里的extern C导出// utility.h #ifdef __cplusplus extern C { #endif int calculate_fib(int n); const char* get_version(); #ifdef __cplusplus } #endifRust侧对应声明use std::ffi::CStr; use std::os::raw::c_char; extern C { fn calculate_fib(n: i32) - i32; fn get_version() - *const c_char; } fn fib(n: i32) - i32 { unsafe { calculate_fib(n) } } fn version() - String { unsafe { let ptr get_version(); CStr::from_ptr(ptr).to_string_lossy().into_owned() } }手动声明看起来很轻松但有个隐藏风险如果C侧的函数签名变了Rust侧声明却忘记同步编译期通常不会报错运行时会拿到垃圾数据甚至直接段错误。所以手动声明只适合接口量少、迭代慢的项目接口一多或者变化频繁就该上自动化工具。3.2 bindgen从C头文件自动生成Rust绑定接口数量一超过十个手写就很容易出错。这时候推荐用bindgen它读取C/C头文件自动生成对应的Rust FFI声明最大程度保证了签名一致性。安装很简单cargo install bindgen-cli对单个头文件一条命令就能生成绑定bindgen include/utility.h -o src/bindings.rs头文件里有复杂宏、平台相关定义时可以用--给clang传参数bindgen include/utility.h -o src/bindings.rs -- -Iinclude -stdc17不过这里必须给新手提个醒bindgen对纯C头文件支持非常成熟但遇到C类、模板、重载函数时它生成不了“能直接调用C类”的绑定。C类的成员函数本质上还是走C ABIbindgen没法预知编译器对类布局和符号修饰的处理。所以面对C类常规做法是给它包一层 “C接口薄封装”把类的构造、析构、成员调用全部暴露成自由函数。我一般会在C工程里加一个专门的ffi_bridge.cpp职责只有一个把类API转成C函数API。3.3 完整示例在Rust里调用C计算器类用个通俗的例子走通全流程。假设C侧有个Calculator类// calculator.h class Calculator { public: Calculator(); ~Calculator(); double add(double a, double b); const char* last_error() const; private: double result_; char error_buf_[256]; };为了让Rust能调它我写一个C接口桥// calculator_ffi.cpp #include calculator.h extern C { typedef struct Calculator Calculator; Calculator* calc_create() { return new Calculator(); } void calc_destroy(Calculator* calc) { delete calc; } double calc_add(Calculator* calc, double a, double b) { return calc-add(a, b); } const char* calc_last_error(Calculator* calc) { return calc-last_error(); } }Rust侧用bindgen或手写绑定都一样核心就是把不透明指针*mut Calculator传来传去#[repr(C)] pub struct Calculator { _private: [u8; 0], } extern C { pub fn calc_create() - *mut Calculator; pub fn calc_destroy(calc: *mut Calculator); pub fn calc_add(calc: *mut Calculator, a: f64, b: f64) - f64; pub fn calc_last_error(calc: *const Calculator) - *const c_char; } fn main() { unsafe { let calc calc_create(); let sum calc_add(calc, 2.0, 3.0); println!(sum {}, sum); calc_destroy(calc); } }这个桥接层看起来多写了一些代码但它把所有“C专有的东西”都关进了自己的编译单元里Rust侧只需要面对纯C接口。真实项目里我甚至会在桥接层做一层防御性检查比如入参判空、抛异常转换错误码这些动作让上层代码少踩很多坑。4. C调用Rust反过来的玩法4.1 Rust侧导出与cbindgen生成头文件反方向让C调用Rust核心同样是把Rust函数导出成C ABI。Rust侧每个要暴露的函数都要加#[no_mangle]和extern C。如果不加#[no_mangle]编译器会对函数名做修饰C侧根本找不到符号。最简单的例子#[no_mangle] pub extern C fn my_add(a: i32, b: i32) - i32 { a b }接口多了以后手写C头文件容易漏推荐用cbindgen从Rust代码自动生成头文件。安装和基本用法cargo install cbindgen cbindgen --lang c --crate my_rust_lib --output my_rust_lib.h在项目根目录放一个cbindgen.toml可以控制生成细节比如命名空间、include guard风格、C还是C模式我常用的配置language C namespace rust_bridge style bothcbindgen会自动为#[repr(C)]结构体、#[no_mangle]函数、枚举生成对应的C声明省去了手工同步的烦恼。但要注意没有#[repr(C)]的Rust结构体cbindgen默认不会生成或者生成时会告警因为布局未定义。4.2 编译成静态库并链接到C工程Rust代码写好导出函数后要编译成C能链接的库。用cargo直接产出静态库的方式cargo build --release然后在Cargo.toml里确保[lib] crate-type [staticlib, cdylib]staticlib会生成librust_lib.aLinux/macOS或rust_lib.libWindows直接喂给C链接器cdylib生成动态库适合插件化部署。实际项目我通常两个都开开发用动态库方便替换发布用静态库方便拷贝。C侧main函数调用#include my_rust_lib.h #include cstdio int main() { printf(result: %d\n, rust_bridge::my_add(1, 2)); return 0; }编译链接时把生成的静态库加上即可g main.cpp -L. -lrust_lib -o app这里有个常见坑Rust静态库内部可能依赖系统库比如pthread、dl链接顺序不对会报一堆undefined reference。Linux上我通常在C链接命令末尾补上-lpthread -ldlWindows上用MSVC时则需要确保链接了ws2_32和userenv。4.3 CMake与Cargo一体化构建配置实际项目里C工程大多用CMake管理手工敲g命令不现实。我推荐在CMake里直接调用cargo构建Rust库让整个工程一条命令编译。cmake_minimum_required(VERSION 3.16) project(cpp_rust_app LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_custom_command( OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/librust_lib.a COMMAND cargo build --release WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/rust_lib COMMENT Building Rust library with cargo ) add_library(rust_lib STATIC IMPORTED GLOBAL) add_dependencies(rust_lib ${CMAKE_CURRENT_BINARY_DIR}/librust_lib.a) set_target_properties(rust_lib PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_BINARY_DIR}/librust_lib.a ) add_executable(app main.cpp) target_link_libraries(app PRIVATE rust_lib) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/rust_lib/include) if(UNIX) target_link_libraries(app PRIVATE pthread dl) endif()这个配置的精髓在add_custom_command每次CMake触发构建时发现librust_lib.a不存在或者源码有更新就自动调用cargo重新编译Rust侧。两个语言的代码放同一个仓库、同一套构建体系对CI和团队协作都很友好。如果不想手写CMake逻辑还有个更省事的选择用cargo-c插件让cargo直接产出传统意义的库或者用cmake-cargo之类的第三方模块。我试过几种最后还是觉得上面这段手写CMake最透明出了问题容易排查。4.4 回调与异步交互跨语言的流程控制传递函数指针是C调用Rust时最常用的高级玩法。假设C侧有个事件循环想让Rust侧注册一个回调Rust侧可以暴露一个注册函数接收C传进来的函数指针extern C { fn on_event(data: i32); } #[no_mangle] pub extern C fn register_callback(cb: Optionextern C fn(i32)) { // 把cb存到全局或结构体里事件发生时调用 }C侧extern C void my_handler(int data) { // 处理事件 } rust_bridge::register_callback(my_handler);这里有个必须注意的点跨FFI传闭包很麻烦C的lambda如果不带捕获通常能隐式转换成函数指针但一旦捕获了外部变量就不能用普通函数指针传递了。Rust侧的Fn闭包同理。所以跨边界回调最好只传“函数指针用户数据指针”这个经典组合extern C void my_handler(void* user_data) { // user_data指向真实业务对象 }对于Rust的async/await与C异步事件循环对接思路也是一样不能直接把Future对象传过去。通用的做法是Rust侧把异步任务拆成“开始执行”和“任务完成回调”两部分通过channel或者回调把一个RustFuture的最终结果送回C事件循环。ferris这样跨语言异步协作本质就是任务分发和结果回传。5. 常见问题与陷阱实录5.1 panic跨边界从内存错误到未定义行为Rust的panic默认会进行栈展开unwind但如果panic发生在C ABI边界函数内部——也就是Rust函数被C直接调用时——栈展开行为就是未定义的。轻则直接abort崩溃重则内存错乱类似情况排查起来极其恶心。解决办法有两种。第一在导出函数入口直接包catch_unwind把panic抓回来转成错误码返回这是最稳妥的use std::panic::{catch_unwind, AssertUnwindSafe}; #[no_mangle] pub extern C fn do_work(input: i32) - i32 { match catch_unwind(AssertUnwindSafe(|| { // 内部逻辑 })) { Ok(v) v, Err(_) -1, } }第二在Cargo.toml里把panic策略改成abort让panic直接终止进程[profile.release] panic abort我自己的习惯是开发期保留unwind方便定位问题发布版本用abort配合catch_unwind把可控错误转成错误码真正不可恢复的错误才abort。5.2 C异常不能穿越C边界C函数如果向外抛出异常而这函数是被Rust通过FFI调用的异常会在Rust侧炸开。由于Rust没有C异常处理机制这种行为同样是未定义。因此在C桥接层里必须把异常全部catch住转成错误码或错误字符串返回extern C int cxx_do_work(int input, char* err_buf, size_t err_len) { try { // C业务逻辑 return 0; } catch (const std::exception ex) { strncpy(err_buf, ex.what(), err_len - 1); return -1; } catch (...) { strncpy(err_buf, unknown exception, err_len - 1); return -2; } }Rust侧调用时先把错误码值和err_buf读出来再决定是返回Result还是直接打日志。这套组合拳能把两种语言的异常/错误模型清晰隔离非常适合大型工程。5.3 结构体布局不一致对齐与字段顺序跨边界传递结构体时C和Rust只要有一边的布局规则不同数据就会错位。最典型的问题是C默认对齐、Rust如果忘了#[repr(C)]会自行重排字段另一个是C侧用了#pragma pack(1)Rust侧却按默认对齐。我在一个嵌入式项目里遇到过非常隐蔽的bugC结构体字段顺序是int8_t, int32_t, int16_tRust侧按同样顺序声明但没加#[repr(C)]结果Rust编译器把字段重排了所有字段读出来都是乱码。排查了一整天才定位到布局不一致。标准解法是Rust侧所有跨边界结构体一律加#[repr(C)]并明确字段类型和数量必要时在C侧用static_assert(sizeof(MyStruct) 0x18)之类的编译期断言来做护栏。5.4 生命周期与借用规则在FFI边界上的约束Rust的所有权和借用规则在FFI边界上不会消失这一点是很多从C转过来的开发者最不适应的。Rust引用一旦变成裸指针传出去编译器就失去了对它的保护反过来C传进Rust的裸指针被转成引用后Rust侧又如何保证指针在作用域内一定有效实际项目中最稳妥的做法是凡是指针跨越边界就按“野生指针”对待。Rust侧不要试图长期保存C传来的裸指针用之前拷贝成Rust所有权的数据用完就丢。任何从裸指针转换出来的引用*ptr都要包在unsafe里并且严格注释前置条件。对于impl Trait和生命周期参数相关的复杂签名比如rust forlifetime这类HRTB写法如果在FFI边界上直接使用会让接口变得难以理解。我的建议是FFI函数签名永远用具体类型不要引入高阶生命周期内部再封装一个安全的方法把生命周期约束管理起来。5.5 字符串与编码UTF-8和std::string的摩擦字符串绝对是FFI里发生问题频率最高的数据类型。C的std::string存的是一段任意字节而Rust的String强制要求UTF-8。如果在Rust侧用String::from_utf8_lossy把非UTF-8字节强转成String内容会被悄悄替换成替换字符数据就损坏了。安全模式是Rust侧接收C字符串时先按“字节切片”处理验证编码后再转换输出给C时保证字节序列合法UTF-8。extern C { fn set_message(data: *const u8, len: usize); } fn send_message(s: str) { unsafe { set_message(s.as_ptr(), s.len()); } }C侧void set_message(const uint8_t* data, size_t len) { std::string_view view(reinterpret_castconst char*(data), len); }只要记住“指针长度”这个组合基本能绕开99%的字符串跨语言问题。5.6 常见错误速查表错误现象可能原因解决办法链接时undefined referenceC符号被name mangling或Rust侧没加#[no_mangle]确认跨边界函数使用extern C检查符号导出链接时cannot find -lrust_lib库文件路径设置错误或未生成检查cargo是否成功生成.a/.lib文件核对CMake里的路径运行时直接段错误指针悬垂、结构体布局不一致、释放了错误分配者分配的内存检查使用前是否判空核对结构体repr(C)遵守“谁分配谁释放”乱码字符串UTF-8编码不匹配统一切换到“指针长度”传递字节序列在边界做编码转换Rust panic导致整个进程退出Rust panic跨FFI边界展开用catch_unwind包裹导出函数或设置panic abortWindows下报linker error缺少系统库Rust静态库依赖系统APIMSVC环境下补充链接ws2_32.lib、userenv.lib今天再翻这些错误记录最想分享的一条经验是FFI边界本质上是一道“信任边界”编译器的保护在这里会断掉。跨语言之前先把接口约定写清楚把所有权规则定死把异常策略统一后面所有的事故都会少一大半。我现在做C与Rust互操作第一步永远是打开头文件看接口注释里面有没有写清楚“谁分配谁释放”如果没写我就先补上再开始写代码。这种习惯帮我省下的排查时间比绑定生成工具省下的时间还要多。