首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >30 行 build.rs,我把整个 SQLite 塞进了 Rust

30 行 build.rs,我把整个 SQLite 塞进了 Rust

作者头像
不吃草的牛德
发布2026-07-29 20:18:27
发布2026-07-29 20:18:27
60
举报
文章被收录于专栏:RustRust

《Rust 下沉三部曲》第 1 篇 · FFI 上篇 · bindgen


先给你看结果。

代码语言:javascript
复制
$ cargo run
opened db: test.db
inserted 3 rows
query result:
(1, alice)
(2, bob)
(3, carol)

上面这段程序里,我没有写一行 SQL 引擎的代码。数据库读写、B 树、事务、WAL——全是那个用了二十多年、被无数生产系统验证过的 C 库 SQLite 干的。我只是用 Rust 把它"接"了过来。

接过来用了多少代码?核心就一个 build.rs,去掉注释后有效代码 30 行左右

这就是 FFI(Foreign Function Interface,外部函数接口)的威力:你不必重写世界,只需要学会站在世界的肩膀上。

准确说,我们不是把 SQLite 源码"塞进" Rust,而是通过 FFI 去链接系统里的 libsqlite3 动态库,直接调用它的 C 接口。下面那句 query result: 下面每一行,都是 SQLite 每读到一个结果就回调我们的 Rust 函数、由它打印出来的——这正好能让你直观看到 FFI「双向互调」长什么样。


一、为什么你迟早要面对 FFI

Rust 生态很年轻。年轻意味着——很多轮子还没有。

音视频编解码有 FFmpeg,加密有 OpenSSL,数据库有 SQLite,机器学习有一堆 C++ 写的推理引擎。这些库经过几十年打磨,你用 Rust 从头重写?不现实,也没必要。

聪明的做法是:用 Rust 写你真正关心的业务逻辑,把成熟的底层能力通过 FFI 借过来。

问题是,手写 FFI 绑定是件极其痛苦的事。一个稍大的 C 库有几百个函数、几十个结构体,你要一个一个照着头文件抄成 Rust 的 extern "C" 声明——抄错一个类型,程序就静默崩溃,还查不出来。

所以我们需要 bindgen自动读 C 头文件,生成 Rust 绑定。


二、先搞懂:FFI 边界上到底发生了什么

在动 bindgen 之前,你必须理解三个关键词,否则出了 bug 你连方向都摸不着。

extern "C" —— 告诉编译器"这个函数用 C 的调用约定"。Rust 自己的函数调用约定是不稳定的(编译器想怎么改就怎么改),而 C ABI 是几十年不变的行业标准。跨语言,就得说这门"普通话"。

#[repr(C)] —— 告诉编译器"这个结构体按 C 的方式排列内存"。Rust 默认会重排字段来优化内存,但 C 不会。如果你把一个默认布局的 Rust struct 传给 C,字段位置对不上,C 读到的就是乱码——而且不报错。

#[no_mangle] —— 关掉 Rust 的名称修饰。Rust 会把函数名编译成 _ZN4main7hello17h... 这种鬼样子,加上它,函数才以原名 hello 暴露给 C。

记住一张类型映射速查表,90% 的场景够用了:

C 类型

Rust 类型

int / long

c_int / c_long

char*(字符串)

*const c_char(配 CStr 转换)

void*

*mut c_void

T*(数组)

*const T + 一个长度参数

struct Foo*(不透明)

opaque type / *mut Foo


三、动手:30 行 build.rs 接入 SQLite

环境准备(跑之前确认一下):① bindgen 依赖 libclang 解析头文件——macOS 自带 clang,Linux 用 apt install clang;② 系统要能找到 sqlite3.hlibsqlite3——macOS 自带或 brew install sqlite,Linux 用 apt install libsqlite3-devbuild.rs 里写死了 Homebrew 在 Apple Silicon / Intel Mac 的两个 include 路径,其他平台按需改 -I 参数即可。

新建项目,Cargo.toml 里加依赖:

代码语言:javascript
复制
[dependencies]
libc = "0.2.189"

[build-dependencies]
bindgen = "0.72.1"

关键是 build.rs——编译前自动跑,生成绑定:

代码语言:javascript
复制
// =============================================================================
// build.rs —— 构建脚本(Cargo 在编译 crate 之前自动执行)
// -----------------------------------------------------------------------------
// 作用:调用 bindgen 读取 wrapper.h(内含 sqlite3.h),自动生成 Rust FFI 绑定
//   代码,并写入 $OUT_DIR/bindings.rs。随后 src/main.rs 通过 include! 宏把它
//   嵌入进当前 crate,从而可以在 Rust 中直接调用 SQLite 的 C 接口。
// =============================================================================
use std::env;
use std::path::PathBuf;

/// build.rs 的入口函数。
///
/// 功能:配置并运行 bindgen,生成 SQLite 的 Rust FFI 绑定文件。
///
/// 参数:无。
///
/// 返回值:无(返回 `()`)。一旦绑定生成或写入失败会直接 panic,让构建中断。
///
/// 异常/错误处理:
///   - `bindgen::Builder::generate()` 失败时通过 `expect` 触发 panic;
///   - `env::var("OUT_DIR")` 取不到时通过 `unwrap` 触发 panic(Cargo 保证提供)。
fn main() {
    // 告诉链接器链接系统的 sqlite3 动态库(libsqlite3)。
    // 等价于 rustc 的 -l sqlite3 参数。
    println!("cargo:rustc-link-lib=sqlite3");

    // 当 wrapper.h 变化时重新运行本构建脚本,保证绑定及时更新。
    println!("cargo:rerun-if-changed=wrapper.h");

    // 构造并配置 bindgen 生成器。
    // Builder 采用链式调用,每一项配置的含义见行内注释。
    let bindings = bindgen::Builder::default()
        // 指定根头文件,bindgen 从这里开始递归解析 C 声明。
        .header("wrapper.h")
        // 只生成匹配该正则的函数绑定,避免把 sqlite3.h 里几百个函数全拉进来。
        // 覆盖:open/close/exec/prepare_v2/step/finalize/column_*/bind_*/err*/errmsg/last_insert_rowid
        .allowlist_function("sqlite3_(open|close|exec|prepare_v2|step|finalize|column_|bind_|err|errmsg|last_insert_rowid)")
        // 只生成以 sqlite3 开头的类型(如 sqlite3、sqlite3_stmt 等)。
        .allowlist_type("sqlite3.*")
        // 只生成以 SQLITE_ 开头的常量(如 SQLITE_OK、SQLITE_ROW 等)。
        .allowlist_var("SQLITE_.*")

        // 下面两个 -I 指定头文件搜索路径,适配 Homebrew 的两个常见安装位置:
        //   /opt/homebrew/include  —— Apple Silicon 的 Homebrew
        //   /usr/local/include     —— Intel Mac 的 Homebrew
        .clang_arg("-I/opt/homebrew/include")
        .clang_arg("-I/usr/local/include")
        // 让 clang 把符号默认可见性设为 default,确保 FFI 符号能被正确链接。
        .clang_arg("-fvisibility=default")

        // bindgen 0.72 起 RustTarget::stable 变为函数 stable(minor, patch) -> Result,
        // 需显式传入 Rust 版本号(1.minor.patch)并解包;此处以 1.82.0 为目标。
        // 指定目标 Rust 版本可控制生成代码使用的语法特性(如 unsafe extern blocks)。
        .rust_target(bindgen::RustTarget::stable(82, 0).unwrap())
        // 生成不依赖 std 的绑定(use_core),便于在 no_std 环境也能使用。
        .use_core()
        // 注册 CargoCallbacks:当 wrapper.h 或其依赖头文件变化时自动让 Cargo 重编译。
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        // 真正执行生成;失败则 panic 中断构建。
        .generate()
        .expect("Unable to generate bindings");

    // OUT_DIR 是 Cargo 提供的输出目录,专门存放构建产物。
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());

    // 把生成的绑定写入 $OUT_DIR/bindings.rs,供 src/main.rs 用 include! 引入。
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

wrapper.h 就一行:

代码语言:javascript
复制
// =============================================================================
// wrapper.h —— bindgen 的 C 头文件入口
// -----------------------------------------------------------------------------
// 作用:作为 bindgen 在 build.rs 中解析的「根头文件」。
//   bindgen 不会直接扫描系统头文件,而是从这个 wrapper.h 出发,递归地把它
//   #include 进来的所有声明翻译成对应的 Rust 类型/函数/常量。
//   这样做的好处是:把「要暴露给 Rust 的 C API」集中在一处管理,避免污染。
// =============================================================================
#include <sqlite3.h>

那句 allowlist_function 非常关键。不加它,bindgen 会把头文件里递归 include 的所有东西(几千行)都生成出来,编译又慢体积又大。用 allowlist 精确裁剪,只留你要的。(正则里多列的 prepare_v2/step/column_*/bind_* 是给后续进阶查询预留的扩展位,本篇 demo 实际只用 open/close/exec。)

然后在代码里 include 生成的绑定,跑通"开库 → 建表 → 插入 → 查询(走回调)→ 关库"的完整流程:

代码语言:javascript
复制
// =============================================================================
// src/main.rs —— SQLite FFI 演示主程序
// -----------------------------------------------------------------------------
// 作用:通过 bindgen 自动生成的 Rust 绑定,直接调用 SQLite 的 C 接口,
//   演示「打开数据库 → 建表 → 插入 → 查询 → 关闭」的完整流程。
//
// 工作原理:
//   1. build.rs 在编译前生成 $OUT_DIR/bindings.rs,里面包含 sqlite3_* 系列
//      函数的外部声明(extern "C")以及相关类型/常量;
//   2. 本文件用 include! 宏把 bindings.rs 嵌入进来,于是可以直接调用这些
//      不安全(unsafe)的 C 函数;
//   3. 所有 C 调用都包在 unsafe 块里,由开发者保证内存安全。
// =============================================================================

// 下面四个 allow 用于抑制 bindgen 生成代码里的命名风格告警。
// 因为 C 的命名习惯(全大写常量、下划线类型、小驼峰函数)与 Rust 规范不一致,
// 而生成代码无法手动修改,所以统一放行这些 lint。
#![allow(non_upper_case_globals)] // 允许常量不是全大写(bindgen 生成的常量名沿用 C 风格)
#![allow(non_camel_case_types)]   // 允许类型名不是大驼峰(如 sqlite3_stmt)
#![allow(non_snake_case)]         // 允许函数名不是蛇形(如 sqlite3_open)
#![allow(dead_code)]              // 允许生成但未使用的声明存在

// 把 build.rs 生成的 bindings.rs 嵌入当前 crate。
// env!("OUT_DIR") 在编译期由 Cargo 注入,指向本 crate 的构建输出目录。
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

use std::ffi::CString;
use std::ptr;

/// 程序入口。
///
/// 功能:演示用 FFI 调用 SQLite C API,完成一次完整的数据库操作:
///   打开 test.db → 建表 users → 插入 3 条记录 → 查询并打印 → 关闭数据库。
///
/// 参数:无。
///
/// 返回值:无(返回 `()`)。
///
/// 异常/错误处理:
///   - 数据库打开失败:通过 panic 中断;
///   - CString 构造失败(理论上字符串里出现 NUL 字节才会失败):unwrap 触发 panic;
///   - 其余 SQL 执行错误在本示例中未做检查,直接忽略返回码。
fn main() {
    // 所有 SQLite C 调用都是 unsafe 的,统一放在一个 unsafe 块中管理。
    unsafe {
        // ---- 1. 打开数据库 ----
        // db 是指向 sqlite3 句柄的裸指针,初始为空;sqlite3_open 会为其分配内存。
        let mut db: *mut sqlite3 = ptr::null_mut();

        // C 接口需要以 NUL 结尾的 C 字符串,CString 把 Rust 的 &str 转成这种格式。
        // 这里指定数据库文件名为 "test.db"(若不存在,sqlite3_open 会创建它)。
        let db_path = CString::new("test.db").unwrap();

        // sqlite3_open: 打开数据库文件。
        //   参数1: 数据库路径(C 字符串指针)
        //   参数2: 输出参数,接收分配到的 sqlite3* 句柄
        //   返回: SQLITE_OK(0) 表示成功
        if sqlite3_open(db_path.as_ptr(), &mut db) != SQLITE_OK as i32 {
            panic!("无法打开数据库");
        }
        println!("opened db: test.db");

        // ---- 2. 建表 ----
        // 使用 IF NOT EXISTS 避免重复运行时报「表已存在」错误。
        let create_sql =
            CString::new("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);")
                .unwrap();

        // sqlite3_exec: 执行一条不需要返回结果的 SQL(建表/插入/删除等)。
        //   参数1: 数据库句柄
        //   参数2: SQL 语句(C 字符串)
        //   参数3: 回调函数(无需回调时传 None)
        //   参数4: 传给回调的第一个参数(这里不用,传 null)
        //   参数5: 错误信息输出指针(这里不用,传 null)
        sqlite3_exec(
            db,
            create_sql.as_ptr(),
            None,
            ptr::null_mut(),
            ptr::null_mut(),
        );

        // ---- 3. 插入数据 ----
        // 一次插入 3 行,展示批量 INSERT 的写法。
        let insert_sql =
            CString::new("INSERT INTO users (name) VALUES ('alice'), ('bob'), ('carol');").unwrap();

        sqlite3_exec(
            db,
            insert_sql.as_ptr(),
            None,
            ptr::null_mut(),
            ptr::null_mut(),
        );

        println!("inserted 3 rows");

        // ---- 4. 查询数据 ----
        // SELECT 语句需要通过回调函数逐行接收结果。
        println!("query result:");

        let select_sql = CString::new("SELECT id, name FROM users ORDER BY id;").unwrap();

        // SQLite 在执行 sqlite3_exec 时,每读到一行就调用一次 callback。
        // 这是一个 extern "C" 函数,签名必须严格匹配 sqlite3_exec 的回调约定。
        //
        // 参数:
        //   _data    : 调用 sqlite3_exec 时传入的第 4 个参数(此处为 null,故忽略)
        //   argc     : 本行的列数
        //   argv     : 指向「字符串指针数组」的指针,每个元素是一列的值(C 字符串)
        //   _col_name: 指向「列名指针数组」的指针(此处不用,故忽略)
        //
        // 返回值:非 0 表示让 SQLite 中止查询,0 表示继续。
        extern "C" fn callback(
            _data: *mut libc::c_void,
            argc: i32,
            argv: *mut *mut libc::c_char,
            _col_name: *mut *mut libc::c_char,
        ) -> i32 {
            // 把一行的各列值收集成 Vec<String>,方便用 join 拼接打印。
            let row: Vec<String> = (0..argc)
                .map(|i| {
                    // argv 是「指向指针的指针」,先取第 i 列的指针。
                    let ptr = unsafe { *argv.offset(i as isize) };
                    if ptr.is_null() {
                        // 列值为 NULL(C 的 NULL),显示为字符串 "NULL"。
                        "NULL".to_string()
                    } else {
                        // 把 C 字符串转成 Rust 的 String。
                        // to_string_lossy 会把无效 UTF-8 字节替换为 U+FFFD,避免 panic。
                        unsafe { std::ffi::CStr::from_ptr(ptr) }
                            .to_string_lossy()
                            .into_owned()
                    }
                })
                .collect();

            // 用逗号拼接打印一行,形如:(1, alice)
            println!("({})", row.join(", "));
            // 返回 0 表示继续处理下一行。
            0
        }

        // 执行查询,把 callback 作为回调传入。
        // 注意 Some(callback):把 extern "C" 函数转成 Option<fn> 传给 C 侧。
        sqlite3_exec(
            db,
            select_sql.as_ptr(),
            Some(callback),
            ptr::null_mut(),
            ptr::null_mut(),
        );

        // ---- 5. 关闭数据库 ----
        // sqlite3_close 释放 sqlite3_open 分配的句柄内存,避免内存泄漏。
        sqlite3_close(db);
    }
}

cargo run,跑通。你刚刚用 Rust 驱动了一个 C 数据库引擎。

⚠️ 教学简化提醒:上面这个 callback 是被 C(SQLite)反向调用的 Rust 函数,但没有用 catch_unwind 兜底(见下文铁律二)。它内部只做了 CStr::from_ptr + to_string_lossy,理论上不会 panic,可一旦收到非法指针导致 unwinding 跨越 FFI 边界,就是未定义行为。这里为了不打断主流程先省了,正式代码务必补上——写法就在铁律二里。


四、三条铁律:踩过才知道多疼

FFI 好用,但它是在 Rust 的安全区外面走钢丝。这三条铁律,违反任何一条都是灾难:

铁律一:ABI 边界不能有 Rust 特有的类型假设。 别把 Rust 的 enum、泛型、StringVec 直接扔给 C。C 不认识它们的内存布局。要传,就转成 #[repr(C)] 的结构或裸指针 + 长度。

铁律二:panic 绝对不能跨越 FFI 边界。 这是新手最容易踩的雷。如果一个被 C 调用的 Rust 函数内部 panic 了,栈展开(unwinding)跨过 C 的栈帧——这是未定义行为(UB),程序可能崩溃、可能静默出错、可能哪天在生产环境突然爆炸。

回到上面 main.rscallback——它就是个典型的「被 C 调用、却没兜底」的 Rust 函数。解决办法:在暴露给 C 的函数里用 catch_unwind 兜底:

代码语言:javascript
复制
#[no_mangle]
pub extern "C" fn my_callback(x: i32) -> i32 {
    std::panic::catch_unwind(|| {
        // 你的逻辑,哪怕这里 panic 也不会越界
        risky_work(x)
    }).unwrap_or(-1)  // panic 了就返回错误码
}

铁律三:所有权归属必须写进 API 契约。 一块内存,谁分配的谁释放。C 里 malloc 的东西,你不能用 Rust 的方式 drop;Rust 里 Box::into_raw 出去的指针,得有个配套函数收回来 Box::from_raw。谁负责释放,必须在文档里写死,否则不是内存泄漏就是双重释放。


小结与预告

这一篇你掌握了:

  • • FFI 三兄弟 extern "C" / #[repr(C)] / #[no_mangle] 到底改了什么;
  • • 用 bindgen + build.rs 自动接入一个真实 C 库(并看清了 callback 这种「C 调 Rust」的双向互调);
  • • 三条能救命的铁律。

一句话记住:panic 跨越 FFI 边界 = 未定义行为,这是新手最容易踩的雷。

但 bindgen 有个软肋——它生成的一切都是 unsafe 的,安全全靠你自己保证。有没有一种方式,让编译器帮你把互调的安全性检查了

有。下一篇:《bindgen 不安全?cxx 让 Rust 和 C++ 互调稳如老狗》,我们聊聊类型安全的双向桥。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust火箭工坊 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、为什么你迟早要面对 FFI
  • 二、先搞懂:FFI 边界上到底发生了什么
  • 三、动手:30 行 build.rs 接入 SQLite
  • 四、三条铁律:踩过才知道多疼
  • 小结与预告
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档