运行时包管理器打包器测试运行器代码检查格式化指南参考博客反馈

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);