← 返回模块
3.5.4.2beta 可读 · 未来付费校验通过内容版本 2026-05-24

FFI、bindgen 与 cxx

3.5.4 · Rust 互操作与生产化 · 编程

中信建投自营 IT 团队的同事把一份 C 的期权定价库 libquant_pricer.so 扔到你桌上。"我们这套是十几年前用 C 写的,封装了 Abramowitz-Stegun 7.1.26 标准正态 CDF 近似;你的 Rust 引擎要在中证 510300 期权上调它,先别想着重写。"——这就是 2026 年量化 Rust 开发者面对的现实:​​没有绿地项目​​。生产代码库继承了几十年的 C 和 C++。你必须能读懂并写出 extern "C" 块、用 bindgen 从 C 头文件生成 Rust 绑定、用 #[no_mangle] pub extern "C" + cbindgen 把 Rust 符号暴露给 C 调用方、用 cxx 桥接 C++ 双向互操作。工具链已经成熟稳定;真正难的是​​正确性​​——bindgen 生成的全部是 unsafe fn,把它们包装成安全 API 是你的工作,纪律完全继承自 L1。

C 侧:libquant_pricer 的源码

工作示例锚定 510300.SH (中证 500 ETF 不可期权;改用 510300.SH 沪深300 ETF) 的欧式看涨期权,参数 s=4.45 元k=4.50 元r=0.025 (一年期 SHIBOR)、sigma=0.22 (隐含波动率)、t=0.25 (3 个月到期)。C 侧使用 Abramowitz-Stegun 7.1.26 的标准正态 CDF 近似:

// pricer.h
#ifndef PRICER_H
#define PRICER_H

double price_european_call(double s, double k, double r, double sigma, double t);

#endif

// pricer.c
#include "pricer.h"
#include <math.h>

static double phi(double x) {
    // Abramowitz-Stegun 7.1.26 approximation of the standard normal CDF.
    double a1 = 0.254829592;
    double a2 = -0.284496736;
    double a3 = 1.421413741;
    double a4 = -1.453152027;
    double a5 = 1.061405429;
    double p  = 0.3275911;
    int sign = x < 0 ? -1 : 1;
    double ax = x < 0 ? -x : x;
    double t1 = 1.0 / (1.0 + p * ax * 0.70710678118654752440);
    double y = 1.0 - (((((a5 * t1 + a4) * t1) + a3) * t1 + a2) * t1 + a1) * t1 * exp(-0.5 * ax * ax * 0.5);
    return 0.5 * (1.0 + sign * y);
}

double price_european_call(double s, double k, double r, double sigma, double t) {
    double d1 = (log(s / k) + (r + 0.5 * sigma * sigma) * t) / (sigma * sqrt(t));
    double d2 = d1 - sigma * sqrt(t);
    return s * phi(d1) - k * exp(-r * t) * phi(d2);
}

extern "C"#[link]:最朴素的 FFI 入口

最薄的一层桥接是手写 extern "C" 声明,告诉 Rust 编译器"这里有一个 C ABI 的函数,链接器去找":

#[link(name = "quant_pricer")]
extern "C" {
    fn price_european_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64;
}

pub fn price_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
    // SAFETY: price_european_call 是纯函数, 无分配、无全局、无 IO;
    // 对任意有限 f64 输入返回 f64。
    debug_assert!(s.is_finite() && k.is_finite() && r.is_finite() && sigma.is_finite() && t > 0.0);
    unsafe { price_european_call(s, k, r, sigma, t) }
}

libc crate 给你提供了 POSIX 标准名 (malloc / free / pthread_create / read / write / mmap 等) 的规范 extern "C" 声明,你直接引就行,不必手写。

bindgen:从 C 头生成 Rust 绑定

手写 extern "C" 在小库上能凑合;面对一个上千行的 C 头文件 (TA-Lib、QuantLib 子集、自研定价库) 就不行了。bindgenbuild.rs 里解析 C 头并生成 unsafe fn 声明:

// build.rs
use std::env;
use std::path::PathBuf;

fn main() {
    // Compile the C source into a static library called "quant_pricer".
    cc::Build::new()
        .file("src/pricer.c")
        .compile("quant_pricer");

    // Generate bindings from wrapper.h into $OUT_DIR/bindings.rs.
    let bindings = bindgen::Builder::default()
        .header("src/wrapper.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .allowlist_function("price_european_call")
        .generate()
        .expect("Unable to generate bindings");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings");

    println!("cargo:rerun-if-changed=src/pricer.c");
    println!("cargo:rerun-if-changed=src/wrapper.h");
}

.allowlist_function("price_european_call") 是关键:不限制的话 bindgen 会把 <math.h> 里的全部声明也跟着拉进来,绑定面会爆炸到几千行——allowlist 收敛到你真正要用的几个符号。bindgen 需要 libclang.so 解析 C 头,Rocky Linux 9 上装 dnf install clang-devel,Ubuntu 22.04 上装 apt install libclang-dev

把生成的绑定 include!src/lib.rs,并在外面包一层安全 API:

// src/lib.rs
#[allow(non_camel_case_types, non_snake_case, non_upper_case_globals)]
mod ffi {
    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

/// Black-Scholes price of a European call on a non-dividend-paying underlying.
///
/// All parameters are required to be finite and non-negative; `t` must be > 0.
/// The C implementation is pure (no allocation, no I/O, no globals).
pub fn price_european_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 {
    // SAFETY: ffi::price_european_call is a pure C function (no allocation,
    // no global state, no I/O). It accepts any f64 inputs and returns an
    // f64. The Rust caller's parameter validation (debug_assert below in
    // debug builds) catches degenerate inputs early.
    debug_assert!(s.is_finite() && k.is_finite() && r.is_finite() && sigma.is_finite() && t > 0.0);
    unsafe { ffi::price_european_call(s, k, r, sigma, t) }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn price_atm_call_matches_published_table() {
        let p = price_european_call(100.0, 100.0, 0.05, 0.20, 0.25);
        assert!((p - 4.6151).abs() < 0.001, "got {p}");
    }
}

回归测试用 ATM 平值的标准 4.6151 (s=k=100, r=0.05, sigma=0.20, t=0.25) 与容差 0.001——任何对 C 端常数的篡改、对绑定调用的搞错都会立刻在测试里翻车。这条​​给每个 bindgen 函数写一层 Rust 安全包装 + 回归测试 + // SAFETY: 注释​​的纪律,沿用 L1。

#[repr(C)]#[repr(transparent)]:跨边界的结构体布局

Rust 的默认结构体布局会为了​​最小化 size​ 而重排字段顺序;C 侧不知道这件事。任何要跨 FFI 的 struct 必须加 #[repr(C)] 强制按声明顺序、按平台标准 padding 布局:

#[repr(C)]
pub struct CMdEvent {
    pub ts_ns: u64,
    pub price_ticks: i32,
    pub qty: u32,
    pub side: u8,
    _pad: [u8; 7],
}

// 单字段 newtype 用 #[repr(transparent)]: 与内部类型 ABI 一致
#[repr(transparent)]
pub struct PriceTicks(pub i32);

#[repr(transparent)] 用于单字段 newtype 包装,保留内部类型的 ABI——你写一个 pub struct PriceTicks(i32) 想跨 FFI 传 i32,加这个属性后它和 i32 完全等价,不会引入额外字节。

反向:把 Rust 暴露给 C 调用方

#[no_mangle] pub extern "C" fn 关闭名字混淆 (mangling),让 C 链接器能按名字找到你的 Rust 函数;cbindgen 自动生成对应的 C 头文件:

# cbindgen.toml
language = "C"
style = "both"
include_guard = "LIBQUANT_PRICER_RS_H"
header = "/* Auto-generated by cbindgen. Do not edit by hand. */"
autogen_warning = "/* DO NOT EDIT */"
no_includes = false

[parse]
parse_deps = false

[export]
prefix = "libquant_"

# Invocation
$ cargo install cbindgen
$ cbindgen --config cbindgen.toml --crate libquant_pricer_rs --output libquant_pricer_rs.h

[lib] crate-type = ["cdylib", "staticlib"] 让 cargo 同时产出动态库 (.so) 和静态库 (.a)。

cxx:C++ 双向互操作的工业级方案

C++ 的名字混淆、模板、RAII、std::shared_ptr / std::unique_ptr 让原生 extern "C" 无能为力——你不能直接用 extern "C" 调一个 std::string 返回值的 C++ 方法。David Tolnay 的 cxx crate 用​​桥接模块​​两侧声明类型与函数,自动生成两边的胶水代码:

// src/main.rs
#[cxx::bridge(namespace = "quant")]
mod ffi {
    unsafe extern "C++" {
        include!("libquant_pricer/include/pricer.h");
        type BlackScholesPricer;
        fn new_pricer() -> UniquePtr<BlackScholesPricer>;
        fn price_call(self: &BlackScholesPricer, s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64;
    }
    extern "Rust" {
        type RustVolatilitySurface;
        fn at(self: &RustVolatilitySurface, k: f64, t: f64) -> f64;
    }
}

pub struct RustVolatilitySurface { /* implementation-defined */ }

impl RustVolatilitySurface {
    fn at(&self, _k: f64, _t: f64) -> f64 { 0.22 }
}

fn main() {
    let pricer = ffi::new_pricer();
    let p = pricer.price_call(4.45, 4.50, 0.025, 0.22, 0.25);
    println!("510300.SH 期权价 = {p}");
}

桥的 C++ 侧由 cc::Build::new().file("src/pricer.cpp").compile("quant_pricer_cpp")build.rs 中编出来。UniquePtr<T> 自动调用 C++ 析构函数;CxxStringCxxVector<T>SharedPtr<T> 类似——cxx 让你不必手写任何 unsafe(unsafe extern "C++" 块的 unsafe 在桥的​​模块声明上​​,不是每次调用上)。

三大失败模式:字符串、生命周期、panic 安全

跨 FFI 边界最常翻车的三件事。​​(1) 字符串​​:Rust &str 是 UTF-8 字节切片 + 长度,C 是空字符结尾的 *const c_char。Rust → C 用 CString::new(s)?.into_raw() 转所有权;C → Rust 用 unsafe { CStr::from_ptr(p) }.to_str()? 验 UTF-8;文件路径用 OsString / OsStr。​​(2) 生命周期​​:C 库的 open / close 配对必须靠 Rust 这一侧的 Drop impl 兜底——pub struct CHandle(*mut c_void); impl Drop for CHandle { fn drop(&mut self) { unsafe { c_close(self.0); } } },栈退出自动释放。​​(3) panic 安全​​:Rust panic 跨 extern "C" 边界进入 C/C++ 调用方在 Rust 1.71 之前是 UB;生产纪律是在 FFI 边界包 std::panic::catch_unwind(|| { ... }),捕获 panic 转成错误码返回。Rust 1.71 引入 extern "C-unwind" ABI,允许 panic 故意传播为 C++ exception——只有桥两侧都接好 unwind 才能用。

use std::panic::{catch_unwind, AssertUnwindSafe};

#[no_mangle]
pub extern "C" fn libquant_price_european_call(s: f64, k: f64, r: f64, sigma: f64, t: f64, out: *mut f64) -> i32 {
    let result = catch_unwind(AssertUnwindSafe(|| {
        let p = /* pure-Rust BS impl */ 0.0_f64;
        // SAFETY: 调用方保证 out 指向合法且对齐的 f64 存储
        unsafe { *out = p; }
    }));
    match result { Ok(_) => 0, Err(_) => -1 }
}

行业实践:国内量化与 Rust 的 FFI 场景

TiKV 用 bindgen 大量包装 RocksDB 的 C API;蚂蚁链用 cxx 桥接 C++ 共识库;字节火山引擎在 InfoQ 中文站发表过多篇 FFI 实践博客。国内量化的 Rust + C/C++ 互操作主要场景是 (a) 包装现有 C 端的统计套利 / 风控 / 期权定价库 (TA-Lib、QuantLib 子集、自研定价库) 让 Rust 调用;(b) 包装 Rust 端的高性能数据结构让现有 C++ 交易系统调用 (渐进式 Rust 迁移);(c) PyO3 之外的 FFI 多见于 (a) 与 (b)。生产代码全部跟 L1 一致:bindgen 给的是 unsafe,你的工作是写安全包装 + 回归测试 + // SAFETY: 评审。

Exercise

(a) 实现 libquant_pricer.so 工作示例端到端:写 src/pricer.csrc/wrapper.h 实现 C 侧 Black-Scholes 看涨期权定价;写 build.rscc 编译 C 源、用 bindgen 生成绑定;写 src/lib.rs 内含 price_european_call 安全包装 + // SAFETY: 注释;用 ATM 平值回归测试验证 (4.6151\approx 4.6151,容差 0.0010.001)。在 bindgen::Builder::default() 链中加 .allowlist_function("price_european_call") 保持绑定面最小。 (b) 检查生成的绑定:cargo expand 或读 target/debug/build/<crate>-<hash>/out/bindings.rs;验证函数声明在 extern "C" 块内为 pub fn price_european_call(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64;,且没有其他多余声明。 (c) 实现反向方向:一个 libquant_pricer_rs Rust crate,#[lib] crate-type = ["cdylib", "staticlib"],导出 #[no_mangle] pub extern "C" fn price_european_call_rs(s: f64, k: f64, r: f64, sigma: f64, t: f64) -> f64 { /* 纯 Rust BS */ }(可复用 3.5.1 闭式代码,或实现 Abramowitz-Stegun 7.1.26);用 cbindgen --config cbindgen.toml --crate libquant_pricer_rs --output libquant_pricer_rs.h 生成 C 头;写一个小 C 程序 test_pricer.c include 头、链 .so、调 price_european_call_rs(100.0, 100.0, 0.05, 0.20, 0.25);验证输出 4.6151\approx 4.6151,容差 0.0010.001。 (d) 实现 cxx 桥接示例:一个 C++ 类 BlackScholesPricer 内含 price_call(s, k, r, sigma, t) 方法,内部调用静态库链入的纯 Rust 核心;#[cxx::bridge] 模块声明两侧;在 Rust main 中构造 UniquePtr<BlackScholesPricer> 并对一个 510300.SH 看涨期权定价。 (e) 各 3 句话,不实现:(i) 为什么 bindgen 生成的函数声明全是 unsafe、写安全包装是谁的责任(答:C 侧可能承载语义不变量——非空指针、合法 UTF-8 字符串、有限 float——Rust 类型系统无法表达;安全包装作者将这些不变量编码为 Rust 类型或 panic,沿用 L1 // SAFETY: 纪律);(ii) 为什么任何跨 FFI 的 struct 必须 #[repr(C)]、默认 Rust 布局会怎么样(答:为最小化 size 重排字段顺序,C 侧不知道这件事;#[repr(C)] 保留声明顺序与平台标准 padding);(iii) 为什么 C++ 互操作首选 cxx 而非 extern "C"、C++ 哪些特性击败原始 extern "C"(答:名字混淆、模板、跨边界对象的 RAII 析构;cxxUniquePtr<T> / SharedPtr<T> / CxxString / CxxVector<T> 生成尊重双方所有权礼仪的惯用包装)。

提示

先把 (a) 的纯 C + bindgen 端到端跑通: build.rs 第一次跑失败常常是 libclang 没装。Rocky Linux: dnf install clang-devel。Ubuntu: apt install libclang-dev

提示

(d) cxx 桥的 build.rs 用 cxx_build::bridge("src/main.rs").file("src/pricer.cpp").compile("quant_pricer_cpp");。注意 #[cxx::bridge] 标注的模块路径要与 cxx_build::bridge 的第一个参数一致。

深入主题:工程选型决策

何时手写 extern "C",何时上 bindgen,何时迁到 cxx——这是新接手一个 C / C++ 库时绕不开的工程问题。给出三条经验规则:

第一,​​符号数 ≤ 10 个、纯 C ABI、无复杂 struct​​——直接手写 extern "C" 块,所见即所得,审计成本最低。比如包装一个 POSIX 系统调用、一个简单数学库的单个入口、一个老式定价库的几个公开函数。手写的代价是若 C 头改变你不会自动发现,但函数数量少时这件事不重要。

第二,​​符号数 > 10 个、纯 C ABI、可能含 struct / enum / typedef​​——上 bindgen,放在 build.rs 里跑,绑定结果落到 OUT_DIR,用 include! 拉进 mod ffi。allowlist 收敛绑定面是关键纪律——任何不打算调的函数都不要让 bindgen 拉进来,否则未来 C 端改动会把你的 build 弄炸。本课工作示例属于这一类。

第三,​​目标是 C++ 而非 C、或者需要跨边界传递 RAII 对象​​——上 cxxcxx 不是 bindgen 的竞品,而是它的 C++ 互补品:bindgen 能解析 C++ 头但生成的是退化到 C 视角的绑定,会丢失 destructor、模板特化、std::string 等所有 C++ 特性。cxx 用桥接模块声明的方式让你明示 C++ 的"形状",自动生成两侧惯用的胶水代码。代价是桥接模块的写法要学,但学到之后再也不会回到原生 extern "C" 做 C++ 互操作。

错误处理跨边界的两种模式

C 侧通常用整数错误码 (errno、自定义 enum)、Rust 侧用 Result<T, E>——两者不能直接互通。两种映射模式:

​模式一,在 Rust 端把 C 错误码翻译成 Rust 错误​​。安全包装层调用 C 函数后检查返回值,若是错误码则构造 Rust 错误类型 (thiserror 派生的 enum 或自定义 struct)。Rust 调用方写 let p = libquant::price_call(...).map_err(MyErr::from)?; 一切按 Rust 礼仪。这条路最常见,适用于 C 侧 API 简单、错误码总数有限的场景。

​模式二,在 Rust 端把 panic 翻译成 C 错误码​​(反向)。用 std::panic::catch_unwind 在 FFI 边界捕获 Rust panic,转成 -1 / EINVAL 之类的 C 错误码返回。Rust 1.71 之后的 extern "C-unwind" ABI 让 panic 可以​​穿过​​边界、在 C++ 侧作为异常被 catch——这条路只在桥两侧都接好 unwind table 时才能用,生产中除非有明确需求(比如包装 bindgen 调出的 C++ 库)否则继续走 catch_unwind 模式。

行业落地:国内量化的 FFI 场景

国内 Rust + C/C++ 互操作主要场景是 (a) 包装现有 C 端的统计套利 / 风控 / 期权定价库 (TA-Lib、QuantLib 子集、自研定价库) 让 Rust 调用——这是渐进式 Rust 化最常见的第一步:把现有 C 库用 bindgen + 安全包装暴露给新写的 Rust 业务代码,避免一次性重写。(b) 包装 Rust 端的高性能数据结构让现有 C++ 交易系统调用 (渐进式 Rust 迁移)——典型是把 Rust 写的低延迟队列 / 行情解码器通过 cbindgencxx 暴露给 C++ 撮合主程,以"叶子节点"开始替换。(c) PyO3 之外的 FFI 大部分落在 (a) 与 (b)。

TiKV 用 bindgen 大量包装 RocksDB 的 C API,生产代码全部跟 L1 一致:​**bindgen 给的是 unsafe,你的工作是写安全包装 + 回归测试 + // SAFETY: 评审​**​。蚂蚁链使用 cxx 桥接 C++ 共识库;字节火山引擎在 InfoQ 中文站发表过多篇 FFI 实践博客。本课所有 Fenced 代码块的纪律——allowlist 收敛绑定面、#[repr(C)] 强制布局、安全包装加 // SAFETY: 注释、回归测试钉准 ATM 常数——是这些团队 CI 评审清单的最小子集。

下一课 (L3 「PyO3 与 Python 互操作」) 把本课的 FFI 纪律推到 Python 边界——研究侧用 pandas/Polars 跑研究,生产侧 Rust 跑热路径,PyO3 + numpy + maturin 是把两侧粘起来的工业标准方案。