iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

day26_title

前言

在前面幾天,我們探索了 Bun 作為 JavaScript Runtime 的各種特性。
今天要來玩一個比較「硬派」的主題:如何用 bun:ffi 呼叫 Rust 寫的原生函式庫
如果你曾經覺得 JavaScript 在某些運算密集型任務(例如影像處理、加密演算法、數值計算)上力不從心,
過去的解法可能是寫 Node.js Native Addon(N-API),
但那通常需要處理複雜的 node-gyp 編譯流程
Bun 提供的 bun:ffi 讓這件事變得簡單許多——不需要編譯 binding 層,
直接載入動態函式庫(.so / .dylib / .dll)就能呼叫。

今天我們會完整走過:

  1. 建立一個 Rust cdylib 專案
  2. bun:ffidlopen 載入並呼叫
  3. 處理字串、指標與記憶體管理
  4. 常見陷阱與除錯技巧

什麼是 bun:ffi?

bun:ffi 是 Bun 內建的 Foreign Function Interface 模組,
讓 JavaScript / TypeScript 可以直接呼叫用 C ABI 導出的原生函式,
不需要額外的 binding 生成工具。它的核心 API 是 dlopen,用來動態載入共享函式庫並宣告函式簽章。

Rust 雖然不是 C,但只要用 extern "C" 標記函式、
搭配 #[no_mangle] 避免名稱修飾(name mangling),
編譯出來的函式庫就會有標準的 C ABI,因此可以完美搭配 bun:ffi 使用


Step 1:建立 Rust cdylib 專案

先建立一個新的 Rust 專案:

cargo new my_rust_lib --lib
cd my_rust_lib

修改 Cargo.toml,指定編譯型態為 cdylib

[package]
name = "my_rust_lib"
version = "0.1.0"
edition = "2021"

[lib]
name = "my_rust_lib"
crate-type = ["cdylib"]

[profile.release]
opt-level = 3
lto = true
  • crate-type = ["cdylib"]:告訴 Rust 編譯成 C 相容的動態函式庫。
  • lto = true:開啟連結時最佳化,讓效能更好(非必要,但建議在 release 模式開啟)。

Step 2:撰寫第一支導出函式

src/lib.rs 中:

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

#[no_mangle]
pub extern "C" fn multiply(a: f64, b: f64) -> f64 {
    a * b
}

重點語法:

  • #[no_mangle]:避免 Rust 編譯器對函式名稱做修飾,確保 FFI 呼叫時符號名稱是可預期的。
  • extern "C":指定使用 C 呼叫慣例(calling convention),這是 FFI 溝通的基礎。

編譯:

cargo build --release

編譯完成後,依平台會產出對應檔案:

平台 檔案
Linux target/release/libmy_rust_lib.so
macOS target/release/libmy_rust_lib.dylib
Windows target/release/my_rust_lib.dll

Step 3:用 bun:ffi 載入並呼叫

建立 index.ts

import { dlopen, FFIType, suffix } from "bun:ffi";

const path = `./target/release/libmy_rust_lib.${suffix}`;

const { symbols } = dlopen(path, {
  add: {
    args: [FFIType.i32, FFIType.i32],
    returns: FFIType.i32,
  },
  multiply: {
    args: [FFIType.f64, FFIType.f64],
    returns: FFIType.f64,
  },
});

console.log(symbols.add(3, 4));       // 7
console.log(symbols.multiply(2.5, 4)); // 10

執行:

bun run index.ts

suffix 是 Bun 提供的常數,會依平台自動回傳 so / dylib / dll,這樣就不用自己判斷作業系統。

型別對照上,FFIType 常用的有:

Rust 型別 FFIType
i32 FFIType.i32
i64 FFIType.i64
f32 FFIType.f32
f64 FFIType.f64
bool FFIType.bool
*const c_char FFIType.cstring
任意指標 FFIType.ptr
無回傳 FFIType.void

Step 4:處理字串

字串是 FFI 中最容易踩雷的部分,因為 Rust 的 String 是一個帶有長度資訊、堆積配置的複雜型別,不能直接跨語言邊界傳遞。標準作法是轉換成 C 風格的以 null 結尾字串(*const c_char)。

Rust 端:

use std::ffi::{CString, CStr};
use std::os::raw::c_char;

#[no_mangle]
pub extern "C" fn greet(name: *const c_char) -> *mut c_char {
    let c_str = unsafe { CStr::from_ptr(name) };
    let name_str = c_str.to_str().unwrap_or("unknown");
    let result = format!("Hello, {}! Welcome to Day 26.", name_str);

    CString::new(result).unwrap().into_raw()
}

// 重要:釋放由 Rust 配置的字串記憶體
#[no_mangle]
pub extern "C" fn free_string(s: *mut c_char) {
    unsafe {
        if s.is_null() {
            return;
        }
        drop(CString::from_raw(s));
    }
}

Bun 端:

import { dlopen, FFIType, CString, ptr } from "bun:ffi";

const { symbols } = dlopen(path, {
  greet: {
    args: [FFIType.cstring],
    returns: FFIType.ptr,
  },
  free_string: {
    args: [FFIType.ptr],
    returns: FFIType.void,
  },
});

const input = Buffer.from("Ironman\0", "utf8");
const resultPtr = symbols.greet(ptr(input));

console.log(new CString(resultPtr));
// Hello, Ironman! Welcome to Day 26.

// 用完務必釋放,否則會造成記憶體洩漏
symbols.free_string(resultPtr);

這裡有兩個關鍵細節:

  1. JS 傳字串給 Rust 時,要手動加上 \0 結尾,因為 C 字串是以 null byte 判斷結束位置。
  2. Rust 配置的記憶體(into_raw())必須提供對應的釋放函式讓 JS 呼叫,否則會產生記憶體洩漏。這是整合 Rust FFI 時最容易被忽略、也最容易造成問題的地方。

Step 5:處理結構化資料

如果要傳遞比較複雜的資料(例如物件、陣列),常見的兩種做法:

做法一:JSON 字串(簡單但有序列化開銷)

#[no_mangle]
pub extern "C" fn get_user_info() -> *mut c_char {
    let json = r#"{"name":"Alice","age":30}"#;
    CString::new(json).unwrap().into_raw()
}

JS 端拿到字串後用 JSON.parse 即可。這個做法實作簡單,適合資料量不大、呼叫頻率不高的情境。

做法二:#[repr(C)] struct + 指標(效能較好,但要小心記憶體佈局)

#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

#[no_mangle]
pub extern "C" fn make_point(x: f64, y: f64) -> Point {
    Point { x, y }
}

在 Bun 端,需要用 FFIType.struct 搭配明確定義的欄位型別對應,操作上會比字串複雜一些,適合效能敏感、資料結構固定的場景。


常見陷阱與除錯技巧

1. 找不到符號(Symbol not found)

如果出現 dlopen 錯誤或找不到函式,先確認:

  • Rust 函式是否有加上 #[no_mangle]extern "C"
  • Cargo.tomlcrate-type 是否為 cdylib
  • 動態函式庫路徑是否正確(用 suffix 動態組合,避免寫死平台副檔名)

可以用以下指令確認符號是否存在:

# macOS / Linux
nm -D target/release/libmy_rust_lib.so | grep add

2. Segmentation fault

多半是型別不匹配造成的,例如:

  • JS 傳入的參數型別與 Rust 函式簽章不一致(如把 i32i64 傳)
  • 字串忘記加 \0 結尾
  • 指標生命週期已結束卻還在使用(use-after-free)

3. 記憶體洩漏

只要 Rust 端用 into_raw()Box::into_raw() 等方式把記憶體所有權「丟」給 JS,就一定要提供對應的 free_xxx 函式,並且在 JS 端確實呼叫。這件事無法透過型別系統強制檢查,只能靠開發者自律與測試覆蓋。

4. 跨平台差異

.so.dylib.dll 三種平台的動態函式庫在載入行為上略有差異(例如 Windows 對路徑分隔符號、DLL 搜尋路徑的處理方式不同),建議:

  • suffix 常數而非手動判斷 process.platform
  • CI 環境中針對每個目標平台各自編譯與測試

為什麼選 bun:ffi + Rust,而不是 Node.js N-API?

比較項目 bun:ffi + Rust Node.js N-API
編譯流程 cargo build,無額外 binding 生成 需要 node-gypneon 等工具鏈
開發體驗 直接用 Bun 執行 TS,無需額外編譯步驟 需要重新編譯 Addon 才能測試
跨平台部署 需自行為各平台編譯 cdylib 同樣需要,但生態較成熟(如 prebuild)
適用場景 中小型效能需求、快速原型 大型專案、需要穩定生態系支援

bun:ffi 的優勢在於「輕量」與「開發速度快」,特別適合原型驗證或中小型專案;如果是需要長期維護、多平台自動化建置的大型專案,N-API 生態系(例如搭配 napi-rs)可能仍是更成熟的選擇。

參考資源

小結

今天我們完整走過用 bun:ffi 整合 Rust 的流程:
從建立 cdylib 專案、導出 C ABI 相容函式,到處理數字、字串與結構化資料的傳遞,並整理了幾個常見的除錯陷阱。

bun:ffi 讓 JavaScript 與 Rust 的整合變得非常直接,省去了傳統
Native Addon 繁瑣的編譯鏈設定。但也因為少了額外的安全網,
型別對應與記憶體管理都需要開發者自己把關——這也是使用 FFI 時最需要謹慎的地方。


上一篇
Bun 在 CI/CD 的應用:GitHub Actions 加速你的 Pipeline(下篇)
系列文
不只是快 —— Bun 30 天:從底層架構、全套工具鏈到生產部署26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言