執行環境套件管理器打包器測試執行器程式碼檢查格式化指南參考部落格意見回饋

FFI

使用 Bun 的 FFI 模組高效地從 JavaScript 呼叫本地庫

使用內建的 bun:ffi 模組從 JavaScript 高效地呼叫本地庫。它適用於任何支援 C ABI 的語言,包括 Zig、Rust、C/C++、C#、Nim 和 Kotlin。


dlopen 用法(bun:ffi)

列印 sqlite3 的版本號:

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

// `suffix` 根據平臺可能是 "dylib"、"so" 或 "dll"
// 你不必一定要使用 "suffix",它只是為方便準備的
const path = `libsqlite3.${suffix}`;

const {
  symbols: {
    sqlite3_libversion, // 要呼叫的函式
  },
} = dlopen(
  path, // 庫名或檔案路徑
  {
    sqlite3_libversion: {
      // 無引數,傳回字串
      args: [],
      returns: FFIType.cstring,
    },
  },
);

console.log(`SQLite 3 版本: ${sqlite3_libversion()}`);

效能

根據我們的基準測試,bun:ffi 的速度大約是通過 Node-API 實現的 Node.js FFI 的 2-6 倍。

Bun 的 JavaScript 引擎(JavaScriptCore)原生實現了 dlopen、linkSymbols、CFunction 和 JSCallback。引數轉換、元數處理和結果裝箱都在引擎內部完成。熱點呼叫點會通過 DFG/FTL JIT 層編譯成直接的本地呼叫,不需要逐引數的 JavaScript 墊片。Bun 嵌入了 TinyCC,這是一款小巧快速的 C 編譯器,僅用於 cc(),它可以在執行環境編譯你提供的 C 原始碼。


用法

Zig

add.zig
pub export fn add(a: i32, b: i32) i32 {
  return a + b;
}

編譯命令:

terminal
zig build-lib add.zig -dynamic -OReleaseFast

傳入共享庫路徑和符號對映到 dlopen:

TypeScript
import { dlopen, FFIType, suffix } from "bun:ffi";
const { i32 } = FFIType;

const path = `libadd.${suffix}`;

const lib = dlopen(path, {
  add: {
    args: [i32, i32],
    returns: i32,
  },
});

console.log(lib.symbols.add(1, 2));

Rust

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

編譯命令:

bash
rustc --crate-type cdylib add.rs

C++

C
#include <cstdint>

extern "C" int32_t add(int32_t a, int32_t b) {
    return a + b;
}

編譯命令:

bash
# Linux
clang++ -shared -fPIC add.cpp -o libadd.so

# macOS
clang++ -dynamiclib add.cpp -o libadd.dylib

FFI 型別

支援以下 FFIType 值。

FFITypeC 型別別名
bufferchar*
cstringchar*
function(void*)(*)()fn、callback
ptrvoid*pointer、void*、char*
i8int8_tint8_t
i16int16_tint16_t
i32int32_tint32_t, int
i64int64_tint64_t, isize
i64_fastint64_t
u8uint8_tuint8_t
u16uint16_tuint16_t
u32uint32_tuint32_t
u64uint64_tuint64_t, usize
u64_fastuint64_t
f32floatfloat
f64doubledouble
boolbool
charchar
napi_envnapi_env僅限 cc()
napi_valuenapi_value僅限 cc()
buffer_lengthuint64_t / size_t僅限引擎原生(不適用於 cc())

buffer 引數必須是 TypedArray 或 DataView。

buffer_length 是 buffer 的配對長度引數:傳入你為 buffer 引數傳入的同一個 TypedArray/DataView,被呼叫函式會將該檢視的位元組長度作為無符號 64 位整數接收。引擎會在呼叫時從同一個物件讀取指標和長度,因此兩者始終一致。你無法通過自行傳入 view.byteLength 獲得這種原子快照,因為在 JavaScript 中提前讀取的長度可能會因可調整大小、可增長或已轉移的緩衝區而過時。buffer_length 僅可用作引數,並且與 napi 型別一樣,不能在 cc() 中使用。

TypeScript
const {
  symbols: { write_all },
} = dlopen(path, {
  // C:size_t write_all(int fd, const void *buf, size_t len)
  write_all: { args: ["i32", "buffer", "buffer_length"], returns: "u64" },
});
const chunk = new TextEncoder().encode("hello");
write_all(1, chunk, chunk); // buf 和 len 都來自 `chunk`

napi_env 和 napi_value 僅在 cc() 原始碼中有效。在那裡,編譯後的跳板函式會用模組的環境填充 napi_env 引數;在該位置傳入的 JavaScript 引數只是佔位符,會被忽略。napi_value 會原樣傳遞 JavaScript 值。在 dlopen、linkSymbols、JSCallback 或 CFunction 描述符中使用任一型別都會丟擲 TypeError。


字串

JavaScript 字串和 C 樣式字串不同,這使得在本地庫中使用字串變得複雜。

JavaScript 字串和 C 字串有什麼不同?

JavaScript 字串:

  • UTF16(每個字元 2 位元組)或者可能是 latin1,具體取決於 JavaScript 引擎和使用的字元
  • length 單獨儲存
  • 不可變

C 字串:

  • 通常是 UTF8(每個字母 1 位元組)
  • 不儲存長度。相反,字串以 null 結尾:其長度是第一個 \0 的索引
  • 可變

為了解決這個問題,bun:ffi 匯出了 CString,它會讀取指標處的 UTF-8 C 字串,並傳回一個普通的 JavaScript 字串:

TypeScript
CString(ptr: number, byteOffset?: number, byteLength?: number): string;

從以 null 結尾的字串指標轉換為 JavaScript 字串:

TypeScript
const myString = new CString(ptr);

從指定長度的指標轉換為 JavaScript 字串:

TypeScript
const myString = new CString(ptr, 0, byteLength);

new CString() 傳回普通字串(typeof myString === "string",myString === "hello" 可正常使用)。該字串是 C 字串的副本,因此即使 ptr 已釋放,你仍然可以安全地使用它。

TypeScript
const myString = new CString(ptr);
my_library_free(ptr);

// 這很安全,因為 myString 是克隆的
console.log(myString);

在 returns 中使用時,FFIType.cstring 會將指標強制轉換為 JavaScript string。在 args 中使用時,FFIType.cstring 接受 ptr 所接受的一切,並且還接受直接傳入的 JavaScript 字串。引擎會將字串轉碼為以 null 結尾的 UTF-8 緩衝區,該緩衝區在呼叫期間有效,因此你無需自行將其編碼為 Buffer:

TypeScript
symbols.puts("Hello, world!"); // args: ["cstring"] — 直接傳入字串

cstring 傳回值的生命週期。 指標就是 C 函式傳回的內容:由原生端擁有的記憶體(靜態記憶體、它管理的緩衝區或它分配的堆記憶體)。傳回時引擎不會複製任何內容,JavaScript 字串會從該記憶體中克隆出來。唯一的別名情況是,C 函式傳回了一個_源自你以 JavaScript 字串形式傳入的 cstring 引數_的指標。引擎會將該引數轉碼到僅在本次呼叫期間有效的緩衝區,因此應將此類傳回指標視為僅在下一次 FFI 呼叫複用該緩衝區之前有效(這是傳回輸入內容的函式通常遵循的 C 規則)。請克隆它(通過傳回的字串或 new CString),不要持有原始地址。


函式指標

要從 JavaScript 呼叫函式指標,請使用 CFunction,例如使用從已載入的 Node-API(napi)模組中獲取的指標。

TypeScript
import { CFunction } from "bun:ffi";

let myNativeLibraryGetVersion = /* 你以某種方式獲得的指標 */

const getVersion = new CFunction({
  returns: "cstring",
  args: [],
  ptr: myNativeLibraryGetVersion,
});
getVersion();

要一次定義多個函式指標,請使用 linkSymbols:

TypeScript
import { linkSymbols } from "bun:ffi";

// getVersionPtrs 在其他地方定義
const [majorPtr, minorPtr, patchPtr] = getVersionPtrs();

const lib = linkSymbols({
  // 和 dlopen() 不同,這裡的名稱可以隨意
  getMajor: {
    returns: "cstring",
    args: [],

    // 由於不用 dlsym(),必須提供一個有效的指標
    // 該指標可以是 number 或 bigint
    // 無效指標會導致程式崩潰。
    ptr: majorPtr,
  },
  getMinor: {
    returns: "cstring",
    args: [],
    ptr: minorPtr,
  },
  getPatch: {
    returns: "cstring",
    args: [],
    ptr: patchPtr,
  },
});

const [major, minor, patch] = [lib.symbols.getMajor(), lib.symbols.getMinor(), lib.symbols.getPatch()];

回撥

使用 JSCallback 建立 JavaScript 回撥函式,以便將其傳遞給 C/FFI 函式,讓原生程式碼能夠回撥 JavaScript 或 TypeScript。這對於非同步程式碼非常有用。

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

const {
  symbols: { search },
  close,
} = dlopen("libmylib", {
  search: {
    returns: "usize",
    args: ["cstring", "callback"],
  },
});

const searchIterator = new JSCallback((ptr, length) => /hello/.test(new CString(ptr, 0, Number(length))), {
  returns: "bool",
  args: ["ptr", "usize"],
});

const str = Buffer.from("wwutwutwutwutwutwutwutwutwutwutut\0", "utf8");
if (search(ptr(str), searchIterator)) {
  // 找到匹配!
}

// 過一會兒:
setTimeout(() => {
  searchIterator.close();
  close();
}, 5000);

使用完 JSCallback 後,呼叫 close() 釋放記憶體。

實驗性執行緒安全回撥

JSCallback 實驗性支援執行緒安全回撥。如果要將回調函式傳遞到建立它的執行緒之外的其他執行緒,則需要使用此功能。通過可選的 threadsafe 引數啟用它。

執行緒安全回撥可從任何執行緒呼叫,包括由你的本地庫生成、Bun 不會以其他方式獲知的執行緒。引擎會線上程安全回撥所在的呼叫執行緒上覆制 C 引數,並將呼叫轉發到 JavaScript 執行緒。在那裡,引擎會像處理普通回撥一樣轉換引數(i64/u64/usize 引數會以 BigInt 形式傳入;指標會以 number 形式傳入),並執行你的函式。由於從 C 的角度來看,呼叫是非同步的,因此傳回給 C 呼叫方的值未指定。你可以宣告非 void 的 returns(下例使用 "bool"),但 C 端必須將執行緒安全回撥視為傳回 void,並忽略其傳回值。

TypeScript
const searchIterator = new JSCallback((ptr, length) => /hello/.test(new CString(ptr, 0, Number(length))), {
  returns: "bool",
  args: ["ptr", "usize"],
  threadsafe: true, // 可選,預設為 `false`
});

指標

Bun 在 JavaScript 中將指標表示為 number 型別。

64 位指標如何存放在 JavaScript 數字中?

64 位處理器最多支援 52 位可定址空間。JavaScript 數字支援 53 位可用空間,因此還剩下 1 位額外空間。

為什麼不用 BigInt? BigInt 更慢。JavaScript 引擎會單獨分配 BigInt,因此它無法裝入常規 JavaScript 值。如果將 BigInt 傳遞給函式,Bun 會將其轉換為 number。

Windows 注意事項:Windows API 型別 HANDLE 不表示虛擬地址,將 ptr 用於 HANDLE 不會 按預期工作。請使用 u64 來安全地表示 HANDLE 值。

將 TypedArray 轉換為指標:

TypeScript
import { ptr } from "bun:ffi";
let myTypedArray = new Uint8Array(32);
const myPtr = ptr(myTypedArray);

將指標轉換為 ArrayBuffer:

TypeScript
import { ptr, toArrayBuffer } from "bun:ffi";
let myTypedArray = new Uint8Array(32);
const myPtr = ptr(myTypedArray);

// toArrayBuffer 接受 `byteOffset` 和 `byteLength`
// 如果不傳 `byteLength`,預設為 null 結尾指標
myTypedArray = new Uint8Array(toArrayBuffer(myPtr, 0, 32), 0, 32);

讀取指標資料有兩種選擇。對於長時效指標,使用 DataView:

TypeScript
import { toArrayBuffer } from "bun:ffi";
let myDataView = new DataView(toArrayBuffer(myPtr, 0, 32));

console.log(
  myDataView.getUint8(0, true),
  myDataView.getUint8(1, true),
  myDataView.getUint8(2, true),
  myDataView.getUint8(3, true),
);

對於短時效指標,使用 read:

TypeScript
import { read } from "bun:ffi";

console.log(
  // ptr, byteOffset
  read.u8(myPtr, 0),
  read.u8(myPtr, 1),
  read.u8(myPtr, 2),
  read.u8(myPtr, 3),
);

read 函式行為類似於 DataView,但通常更快,因為不需建立 DataView 或 ArrayBuffer。

FFITyperead 函式
ptrread.ptr
i8read.i8
i16read.i16
i32read.i32
i64read.i64
u8read.u8
u16read.u16
u32read.u32
u64read.u64
f32read.f32
f64read.f64

記憶體管理

bun:ffi 不會替你管理記憶體。你必須在用完後釋放記憶體。

來自 JavaScript

要跟蹤 JavaScript 中不再使用的 TypedArray,請使用 FinalizationRegistry。

來自 C、Rust、Zig 等

要從 C 或 FFI 中跟蹤何時不再使用 TypedArray,請向 toArrayBuffer 或 toBuffer 傳遞一個回撥和一個可選的上下文指標。垃圾回收器釋放底層的 JavaScript ArrayBuffer 物件後,Bun 會呼叫該回調。該回調可能會在垃圾回收執行緒而非 JavaScript 執行緒上執行,因此不得回撥 JavaScript,也不得假設存在特定執行緒的狀態。

預期的簽名與 JavaScriptCore 的 C API 一致:

C
typedef void (*JSTypedArrayBytesDeallocator)(void *bytes, void *deallocatorContext);
TypeScript
import { toArrayBuffer } from "bun:ffi";

// 傳遞 deallocatorContext:
toArrayBuffer(
  bytes,
  byteOffset,

  byteLength,

  // 這是一個指向上下文的可選指標
  deallocatorContext,

  // 這是一個指向函式的指標
  jsTypedArrayBytesDeallocator,
);

// 不傳 deallocatorContext:
toArrayBuffer(
  bytes,
  byteOffset,

  byteLength,

  // 這是一個指向函式的指標
  jsTypedArrayBytesDeallocator,
);

記憶體安全

不要在 FFI 之外使用原始指標。Bun 的未來版本可能會新增一個 CLI 標誌來停用 bun:ffi。

指標對齊

如果介面期望的是與 char 或 u8 不同大小的指標,確保 TypedArray 也是對應的大小。u64* 和 [8]u8* 並不完全等價,因為對齊要求不同。

傳遞指標

當 FFI 函式期望指標時,傳入對應大小的 TypedArray:

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

const {
  symbols: { encode_png },
} = dlopen(myLibraryPath, {
  encode_png: {
    // FFIType 也可以用字串形式指定
    args: ["ptr", "u32", "u32"],
    returns: FFIType.ptr,
  },
});

const pixels = new Uint8ClampedArray(128 * 128 * 4);
pixels.fill(254);
pixels.subarray(0, 32 * 32 * 2).fill(0);

const out = encode_png(
  // pixels 會作為指標傳入
  pixels,

  128,
  128,
);

自動生成的包裝器會將 TypedArray 轉換為指標。

高難度用法

如果不希望進行自動轉換,或者希望獲取 TypedArray 內特定位元組偏移處的指標,請直接獲取指向 TypedArray 的指標:

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

const {
  symbols: { encode_png },
} = dlopen(myLibraryPath, {
  encode_png: {
    // FFIType 也可以用字串形式指定
    args: ["ptr", "u32", "u32"],
    returns: FFIType.ptr,
  },
});

const pixels = new Uint8ClampedArray(128 * 128 * 4);
pixels.fill(254);

// 這裡傳回的是數字!不是 BigInt!
const myPtr = ptr(pixels);

const out = encode_png(
  myPtr,

  // 尺寸:
  128,
  128,
);

讀取指標

TypeScript
const out = encode_png(
  // pixels 會作為指標傳入
  pixels,

  // 尺寸:
  128,
  128,
);

// 假設這是以 0 結尾,可以這樣讀取:
let png = new Uint8Array(toArrayBuffer(out));

// 寫入磁碟:
await Bun.write("out.png", png);