FFI
使用 Bun 的 FFI 模組高效地從 JavaScript 呼叫本地庫
使用內建的 bun:ffi 模組從 JavaScript 高效地呼叫本地庫。它適用於任何支援 C ABI 的語言,包括 Zig、Rust、C/C++、C#、Nim 和 Kotlin。
dlopen 用法(bun:ffi)
列印 sqlite3 的版本號:
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
pub export fn add(a: i32, b: i32) i32 {
return a + b;
}
編譯命令:
zig build-lib add.zig -dynamic -OReleaseFast
傳入共享庫路徑和符號對映到 dlopen:
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
// add.rs
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
a + b
}
編譯命令:
rustc --crate-type cdylib add.rs
C++
#include <cstdint>
extern "C" int32_t add(int32_t a, int32_t b) {
return a + b;
}
編譯命令:
# Linux
clang++ -shared -fPIC add.cpp -o libadd.so
# macOS
clang++ -dynamiclib add.cpp -o libadd.dylib
FFI 型別
支援以下 FFIType 值。
FFIType | C 型別 | 別名 |
|---|---|---|
| buffer | char* | |
| cstring | char* | |
| function | (void*)(*)() | fn、callback |
| ptr | void* | pointer、void*、char* |
| i8 | int8_t | int8_t |
| i16 | int16_t | int16_t |
| i32 | int32_t | int32_t, int |
| i64 | int64_t | int64_t, isize |
| i64_fast | int64_t | |
| u8 | uint8_t | uint8_t |
| u16 | uint16_t | uint16_t |
| u32 | uint32_t | uint32_t |
| u64 | uint64_t | uint64_t, usize |
| u64_fast | uint64_t | |
| f32 | float | float |
| f64 | double | double |
| bool | bool | |
| char | char | |
| napi_env | napi_env | 僅限 cc() |
| napi_value | napi_value | 僅限 cc() |
| buffer_length | uint64_t / size_t | 僅限引擎原生(不適用於 cc()) |
buffer 引數必須是 TypedArray 或 DataView。
buffer_length 是 buffer 的配對長度引數:傳入你為 buffer 引數傳入的同一個 TypedArray/DataView,被呼叫函式會將該檢視的位元組長度作為無符號 64 位整數接收。引擎會在呼叫時從同一個物件讀取指標和長度,因此兩者始終一致。你無法通過自行傳入 view.byteLength 獲得這種原子快照,因為在 JavaScript 中提前讀取的長度可能會因可調整大小、可增長或已轉移的緩衝區而過時。buffer_length 僅可用作引數,並且與 napi 型別一樣,不能在 cc() 中使用。
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 字串:
CString(ptr: number, byteOffset?: number, byteLength?: number): string;
從以 null 結尾的字串指標轉換為 JavaScript 字串:
const myString = new CString(ptr);
從指定長度的指標轉換為 JavaScript 字串:
const myString = new CString(ptr, 0, byteLength);
new CString() 傳回普通字串(typeof myString === "string",myString === "hello" 可正常使用)。該字串是 C 字串的副本,因此即使 ptr 已釋放,你仍然可以安全地使用它。
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:
symbols.puts("Hello, world!"); // args: ["cstring"] — 直接傳入字串
cstring 傳回值的生命週期。 指標就是 C 函式傳回的內容:由原生端擁有的記憶體(靜態記憶體、它管理的緩衝區或它分配的堆記憶體)。傳回時引擎不會複製任何內容,JavaScript 字串會從該記憶體中克隆出來。唯一的別名情況是,C 函式傳回了一個_源自你以 JavaScript 字串形式傳入的 cstring 引數_的指標。引擎會將該引數轉碼到僅在本次呼叫期間有效的緩衝區,因此應將此類傳回指標視為僅在下一次 FFI 呼叫複用該緩衝區之前有效(這是傳回輸入內容的函式通常遵循的 C 規則)。請克隆它(通過傳回的字串或 new CString),不要持有原始地址。
函式指標
要從 JavaScript 呼叫函式指標,請使用 CFunction,例如使用從已載入的 Node-API(napi)模組中獲取的指標。
import { CFunction } from "bun:ffi";
let myNativeLibraryGetVersion = /* 你以某種方式獲得的指標 */
const getVersion = new CFunction({
returns: "cstring",
args: [],
ptr: myNativeLibraryGetVersion,
});
getVersion();
要一次定義多個函式指標,請使用 linkSymbols:
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。這對於非同步程式碼非常有用。
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,並忽略其傳回值。
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 轉換為指標:
import { ptr } from "bun:ffi";
let myTypedArray = new Uint8Array(32);
const myPtr = ptr(myTypedArray);
將指標轉換為 ArrayBuffer:
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:
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:
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。
FFIType | read 函式 |
|---|---|
| ptr | read.ptr |
| i8 | read.i8 |
| i16 | read.i16 |
| i32 | read.i32 |
| i64 | read.i64 |
| u8 | read.u8 |
| u16 | read.u16 |
| u32 | read.u32 |
| u64 | read.u64 |
| f32 | read.f32 |
| f64 | read.f64 |
記憶體管理
bun:ffi 不會替你管理記憶體。你必須在用完後釋放記憶體。
來自 JavaScript
要跟蹤 JavaScript 中不再使用的 TypedArray,請使用 FinalizationRegistry。
來自 C、Rust、Zig 等
要從 C 或 FFI 中跟蹤何時不再使用 TypedArray,請向 toArrayBuffer 或 toBuffer 傳遞一個回撥和一個可選的上下文指標。垃圾回收器釋放底層的 JavaScript ArrayBuffer 物件後,Bun 會呼叫該回調。該回調可能會在垃圾回收執行緒而非 JavaScript 執行緒上執行,因此不得回撥 JavaScript,也不得假設存在特定執行緒的狀態。
預期的簽名與 JavaScriptCore 的 C API 一致:
typedef void (*JSTypedArrayBytesDeallocator)(void *bytes, void *deallocatorContext);
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:
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 的指標:
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,
);
讀取指標
const out = encode_png(
// pixels 會作為指標傳入
pixels,
// 尺寸:
128,
128,
);
// 假設這是以 0 結尾,可以這樣讀取:
let png = new Uint8Array(toArrayBuffer(out));
// 寫入磁碟:
await Bun.write("out.png", png);