在NodeJS中与C++代码通信

最近在项目中遇到需要在 NodeJS 中调用 C++代码的问题,在此略作总结。

主要方案

在 NodeJS 中,和其他语言编写的代码通信主要有两种方案:

  • 使用 AddOn 技术,使用 C++为 NodeJS 编写一个拓展,然后在代码中调用其他语言所编写的源码 or 动态库
  • 使用 FFI(Foreign Function Interface)技术,直接在 Node 中引入其他语言所编写的动态链接库

在对这两种方式进行比较后,发现这两种方式各有优劣。

首先,AddOn 技术比较通用,它可以使用 C++代码来拓展 Node 的行为,很多库都是使用这种方式来完成一些比较底层操作(比如和操作系统的一些通信)的。但是它写起来比较麻烦,要编写一个 C++项目,还要按照 NodeJS 的规范 export 相应的函数,而且每次安装的时候都需要进行编译(以适应本地 Node 的版本)。如果只是调用一个 DLL,那就还需要在项目里重新包装一遍 DLL 的接口。

如果使用 FFI 技术,限制就会比较多,首先,它只能调用其他动态库,如果你想使用 C/C++完成更多功能的话,还需要再封装一层 DLL,另外,它只支持_cdecl调用约定(也就是 DLL 在导出的时候一定要标记用_cdecl编译命令),不支持_stdcall或者_fastcall调用。但是调用起来就会很方便,可以直接在 JS 代码中声明 DLL 的接口就可以了。

综上比较,如果只调用第三方 DLL(而且恰好是_cdecl导出),使用 FFI 就再合适不过了(虽然性能可能会有一定的损失,而且调试起来会有困难)。

其实,从理论上来讲,FFI 也是基于 AddOn 技术的,只是它可以帮你把在 JS 中定义的接口直接转换成 C 语言的接口,并利用 NodeJS 的 Buffer 内存,将其同载入的 DLL 共享。当然由于 FFI 的这种通用性,也导致了一定的性能损失。

下面就以在 Windows 平台上使用 FFI 为例,简单聊一下如何使用 NodeJS 和 C++编译而成的 DLL 通信吧。

FFI 使用准备

安装 NodeJS

可能你的环境中已经有 NodeJS 了,但是,如果是最新版本,在安装 FFI 的时候会出现各种兼容性的问题(比如编译无法通过,虽然已经有人提供了 patch,但是还没有被 merge 进主分支,为了避免出现 bug,还是暂时不用为妙)。所以可以安装 LTS 版本代替。

另外,还需要注意要调用的 DLL 是 32 位还是 64 位的,Node 的版本需要和 DLL 的版本匹配。因为如果 64 位 Node 调用了 32 位的 DLL,是无法成功装载的,反之亦然。

安装 Windows 的 C++工具链

这里有两种方案:

  • 安装 Visual Studio,并安装相应的工具链。如果使用 VS 2019 版本的话,需要安装 C++桌面开发和 Windows SDK 相关的工具(Node v10 现在只支持 v141 版本的 MSVC),这种方式便于后续的调试工作(虽然也很艰难)
  • 在安装 Node 之后,使用管理员权限运行 Powershell,并全局安装 windows-build-tools,参考命令 npm install --global --production windows-build-tools

安装 node-gyp

node-gyp 是一个 Node 中基于 gyp 的跨平台的编译工具,用于编译其他库。

在安装的时候,需要使用 VC 的工具链,所以如果没有把工具链放在全局变量中,需要打开 VS 的Developer Powershell安装,该命令行一般在开始菜单的 Visual Studio 文件夹中。

参考命令:npm install -g node-gyp

安装 FFI 及 REF

下面的步骤依旧需要 VC 工具链,所以可能依旧需要在Developer Powershell中执行(建议常备该窗口,后面只要涉及到编译安装的命令都需要用到)。

安装 FFI 及相关工具的时候如果没有 VC 工具链,则会直接安装二进制代码,这样可能会出现包的 ABI 版本和 NodeJS 的 ABI 版本不符合的情况(在下面的 Tips 中会提到)。

现在,切换到项目的文件夹中,安装下面的包。其中,ffi 包是用以支持 FFI 功能的,ref 包是用以支持指针功能(原理是通过 Node 的 Buffer 内存,将 JS 的结构和 C 结构相互转换的)的,ref-*是用以支持高级结构的(比如数组和结构体)

1
2
3
4
npm install ffi -s
npm install ref -s
npm install ref-array -s
npm install ref-struct -s

除此之外,如果想支持 VC 中常见的 wchar 类型,还可以安装 ref-wchar 包。

安装 electron-rebuild 包

如果是 electron 项目,还推荐安装 electron-rebuild 包,该包可以遍历 node_modules 目录下的所有包,并将其重新编译。

然后,推荐在 package.json 中配置 electron-rebuild 的命令:

1
2
3
"scripts": {
"rebuild": "./node_modules/.bin/electron-rebuild"
}

之后执行在需要重新编译的时候只需要执行npm run rebuild即可。

使用方式

简单概览

可以查看如下官方示例:

1
2
3
4
5
6
var ffi = require('ffi')

var libm = ffi.Library('libm', {
ceil: ['double', ['double']]
})
libm.ceil(1.5) // 2

在引入 FFI 后,使用 FFI 调用了 libm 库(可能这个示例只能在类 Unix 系统中使用),一般拓展名为 libm.so,系统会在系统目录下搜索这个动态库,并将它使用动态链接器载入到 node 进程中。

接着,程序声明了 libm 库中的一个方法 ceil(向上取整),其中,该函数的返回值是 double 类型(第一重数组中的 doule),而该函数的入参也是一个 double 类型的值(第二重数组中的 double)。

最后,直接使用libm.ceil方法即可调用动态库中的函数,并返回正确的值。

这只是一个 FFI 的简单用例,更复杂的用法(主要是异步调用和回调函数)可以参考 FFI 的实例页https://github.com/node-ffi/node-ffi/wiki/Node-FFI-Tutorial

类型

FFI 的类型系统其实记住了 ref 库的类型,ref 库的类型系统基于 NodeJS 的 Buffer 内存,可以根据 Buffe 中数据的类型对 Buffer 内存中的数据进行访问和修改。

ref 自带的数据类型都是基本类型,比如 int 类型、bool 类型或者 string 类型。所有类型可以参考 ref 的wiki

ref 中的多数类型都有简写,比如ref.types.int可以简写为int

需要注意的是,char*可以写为string,对应的 ref 类型为ref.types.CString。值得注意的是,string 在 JS 中是基本类型,在 C 中却是引用类型。

对于指针类型,ref 提供了一个方法ref.refType()来得到,比如int*类型就可以使用ref.refType('int')得到。当然,为了省事,也可以直接用int*表示。

而对指针解引用,ref 库也提供了一个deref()方法。只要在对应类型的变量上使用该方法,就可以得到指针指向内容的变量。比如一个指向int*类型的 JS 变量 a_pointer,那么我们如果想得到具体的整数值,就可以使用a_pointer.deref()方法。

相反的,如果想获取某个变量的地址,就需要对某个变量使用ref()方法。

需要注意掌握类型与变量值的区别,使用ref.typesref.refType或者是下面会提到的ref_struct({...})获得的类型,而如果想获得某个类型的变量,有两个方法,一个是从 FFI 函数的返回值中获取,另一个是在 Buffer 中开辟一个空间,来存放类型为所获得类型的变量,下面会具体讲到。

如果需要在 NodJS 的 Buffer 中开辟长度为某个类型的空间,可以使用ref.alloc()函数,只要将类型名传入即可。比如,想开辟一个类型为 int 的内存,就可以使用ref.alloc('int')得到。

此外,还有以下几点需要注意:

  • 如果开辟类型为字符串的内存,推荐使用方法 ref.allocCString,其参数为一个 JS 的字符串。因为 C 语言的字符串在末尾有一个\0标识符,所以用这个方法可以更安全地得到 C 字符串。
  • 如果在 C 语言中值为 NULL,则在 JS 中对应的值为 ref.NULL。
  • 如果遇到指针类型,可以统一用'void'或者ref.types.void表示。
  • 如果要表示一个函数的指针,可以使用'pointer'表示。

对于复合类型,比如数组或者结构体,ref 库本身没有提供相应的支持,需要使用 ref-array 和 ref-struct 库来实现,具体可以参考这两个库的文档。

另外,对于 Windows API 中较为常见的宽字符 wchar 类型,也有一个基于 ref 的库 ref-wchar 进行支持。

最后,附上 ref 的文档http://tootallnate.github.io/ref/,具体的 API 都可以在这里进行查阅。

调用外部符号

假设我们有如下 C 代码(并把它写的复杂一些):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* main.c */
typedef struct t_s_t{
int a;
char b;
} t_s;

__declspec(dllexport) int add_one(int a) {
return a + 1;
}

__declspec(dllexport) void struct_test(t_s** t_s_p) {
*t_s_p = (t_s *)malloc(sizeof(t_s));
(*t_s_p)->a = 1;
(*t_s_p)->b = 'd';
}

上面的代码中,声明了一个结构体t_s,以及两个函数add_onestruct_test。其中,函数前面的__declspec标记表示声明该函数为导出函数。VC 默认导出 C 函数时是用_cdecl调用约定。

其中,add_one方法的作用显而易见,是将传入参数加一再返回。而struct_test函数的作用是先在堆上开辟一个大小为生面声明结构体的内存空间,然后将该内存空间的指针赋给传入的参数,并将该结构体赋值。(这里的代码其实不够严谨,没有进行内存回收,但这不是本文的重点,所以先不做讨论)

需要注意的是,如果是 C++代码,需要使用extern "C"标记导出,否则会因为符号修饰和调用约定的问题导致无法通过源代码中的符号找到该函数。

我们可以使用 VS 的Developer Powershell对上述源码进行编译:

1
2
cl /c main.c
Link /dll main.obj

编译后将生成 main.dll,我们在后面会用到这个动态库。

针对上述 C 函数,我们有如下 JS 代码,并假设和 C 代码在同一文件夹下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* index.js */
const ffi = require('ffi')
const ref = require('ref')
const ref_struct = require('ref-struct')

const t_s = ref_struct({
a: ref.types.int,
b: ref.types.char
})

const t_s_ref = ref.refType(t_s)

const test_ffi = ffi.Library(__dirname + '\\main', {
add_one: ['int', ['int']],
// aka 'add_one': [ref.types.int, ['int']],
struct_test: ['void', ['pointer']]
// aka 'struct_test': ['void', [t_s_ref]],
})
const result = test_ffi.add_one(20)
console.log(result) //21

t_s_p = ref.alloc(t_s_ref)
test_ffi.struct_test(t_s_p)
console.log(t_s_p)
console.log(t_s_p.deref())
console.log(t_s_p.deref().deref()) //a->1, b->'d'

首先,在代码中声明了一个结构体t_s,和 C 语言中的t_s*类型相对应。然后,我们还得到了结构体t_s的一个引用t_s_ref,和 C 语言中的t_s**类型对应。为什么在 C 语言中都多了一层指针呢?其原因和前面的字符串一样。

然后,声明了 test_ffi 变量,它调用了 ffi.Library 方法,该方法返回了 JS 中 DLL 的句柄和函数的声明,通过该变量可以进行 DLL 的调用。该方法有两个参数,一个是动态库的名称(可以略去拓展名 dll),另一个就是描述 C 语言函数的符号及其参数的对象。

该列表在上面已经简单介绍过了,对象的 key 是函数名,value 是一个数组,数组中第一个元素为函数的返回值的类型,第二个元素为另一个数组,它里面包含了函数的入参的类型。这些类型就用到了上一节中介绍的基于 ref 包的类型系统,类型可以用字符串表示,也可以用代码表示,可以参考代码中 aka 的注释。

接下来,代码通过test_ffi.add_one调用了 C 语言动态库中的add_one函数,可以看出,调用的方式和 JS 中的函数并无二致。但是需要注意参数类型千万不能传错,特别要注意 C 语言的 string 类型和 JS 中的 string 类型不同,要按照前文提到的方法进行转换。

然后,代码使用 ref.alloc 方法为 t_s_ref 变量开辟了一个内存空间(注意这只是一个指针大小的空间,并非结构体大小),并将该地址赋给 t_s_p 变量,然后将该变量传递给 struct_test 函数。因为 t_s_p 是一个二重指针,所以需要解两次引用,才能得到结构体真实的值。

回调函数

回调函数可以使用ffi.Callback()函数声明,该函数的第一个参数为返回值,第二个参数为入参列表,第三个参数为真实回调函数的闭包。

比如一个回调函数的定义如下,该函数会得到用户名和 id,并返回动作是否执行成功:

1
typedef int(*callback)(int, const char*);

那么,在 ffi 中,就可以使用如下方式声明该回调函数:

1
2
3
4
const callback_function = ffi.Callback('int', ['int', 'string'], (id, username) => {
// do something
return 1
})

在声明之后,需要将该回调函数做为参数传入某个函数:

1
2
3
4
5
6
test_ffi.set_a_callback(callback_function)

// Make an extra reference to the callback pointer to avoid GC
process.on('exit', function() {
callback_function
})

特别需要注意的是,设置完回调函数以后一定要保证该函数在 JS 中还存在一个引用(比如上面讲该函数的一个引用放在了 NodeJS 的 exit 事件中,这也是比较经典的做法)。否则,该函数将会被 NodeJS 的 GC 析构。其表现是:在程序刚开始执行的时候一切正常,但是执行了一会儿之后在调用这个回调函数,程序就会异常退出。如果用 VS 去对程序 Debug,就会发现该程序可能访问了非法指针,这是因为 DLL 代码中也存放了该回调函数的指针,但是在 JS 中该指针指向的地址因为没有被 JS 中的代码引用,所以被 CG 被释放,这样 DLL 中代码调用该地址的函数的时候,就会访问到非法的内存。

一些 Tips

DLL 的调试方法

在使用 ffi 的过程中,可能发现最大的问题就是程序难以调试。特别在面对 DLL 的时候,就像针对一个黑盒操作一样,虽然已经对着头文件将他的 API 使用 FFI 翻译为 JS 的代码,但还是难以确定传参或返回值是否正确,在 C++的代码中该参数是否正确传入,传入后是否正确执行等等。这就需要一个能够调试的方法。

一个比较好用的方式是使用宇宙第一 IDE Visual Studio 的 Attach(附加)到进程的方式进行调试。但这种调试方法的前提是手中有 DLL 的源码或 PDB(符号)文件(如果没有的话就只能看到出现异常的代码附近的反汇编的代码了,而通常这些异常都是内存错误引起的,其实它附近的数据可能没有多大的意义)。

如果手上有源文件,那么首先打开工程,然后在 NodeJS 载入 DLL 之后,就可以在启动工程的时候选择“附加到进程”,在对话框中选择 NodeJS 进程即可进入调试界面。在调试界面里,可以插入断点,也可以看到断点附近的内存。

如果手上没有源文件,但是有 PDB 文件(或者少量的源码),可以使用 VS 打开一个空工程,然后在调试的设置中添加符号文件的位置,这样也可以进行断点调试,在调试的过程中可以查看代码有没有命中断点,在命中断点时,会引导你载入项目文件,如果有的话可以选择,否则可以查看断点附近的反汇编代码。

具体的调试方法可以参考 MSDN 文档:https://docs.microsoft.com/en-us/visualstudio/debugger/attach-to-running-processes-with-the-visual-studio-debugger?view=vs-2019,在这里就不过多叙述了。

如何载入在其他文件夹中的 DLL

如果 JS 文件和 DLL 文件不在同一文件夹中,可能会出现载入失败,会出现类似于“Dynamic Linking Error: Win32 error 126”的错误提示。

这时,就需要将 DLL 文件夹的路径放在系统寻找动态链接库的 PATH 中,但是 FFI 并没有提供此类接口。不过,好在 Windows API 提供了 SetDllDirectoryA 这个接口用以切换该进程中寻找 DLL 的 PATH,可以使用如下代码完成这个操作:

1
2
3
4
const kernel32_ffi = ffi.Library('kernel32', {
SetDllDirectoryA: ['bool', ['string']]
})
kernel32_ffi.SetDllDirectoryA(your_custom_dll_directory)

一些链接时候的错误提示

上面已经提到,如果动态库不在 PATH 中的话,会出现无法找到动态库的情况,这时候会报“Dynamic Linking Error: Win32 error 126”的错误,在另外一些时候,只要没有找到动态库,都会报该错误。如果出现该错误,就需要检查动态库的名称是否正确,检查动态库的版本是否正确(比如 32 位 Node 使用了 64 位的 DLL)等。

另外,还有一个“Dynamic Linking Error: Win32 error 127”错误比较常见,该错误指没有在 DLL 中找到对应的符号,这可能就需要检查在 ffi 中声明的函数名是否正确以及是否 DLL 版本有偏差了。

在 Electron 中使用 FFI

由于每一个 Electron 的版本是基于相应的 Node 和 Chrome 版本构建的,所以在使用 FFI 之前需要根据所使用的 Electron 版本安装本地的 NodeJS 版本,否则 FFI 可能会和 Node 版本不匹配,导致提示 ABI 版本不一致:xx was compiled against a different Node.js version using NODE_MODULE_VERSION x. This version of Node.js requires NODE_MODULE_VERSION xx(NodeJS 使用 NODE_MODULE_VERSION 来辨别 ABI 版本)。

这种情况下,可以使用前文提到的electron-rebuild对项目中的所有插件进行重新编译(需要注意本地 NodeJS 的 ABI 版本一定要和 Electron 中 NodeJS 的 ABI 版本一致)。

另外,需要注意的是,Electron 5 以上的版本使用了 NodeJS 12 的 ABI,但是当前的 Ref 库并不支持该 ABI,会导致编译失败。不过已经有人提交了 pull request 进行修复,相信之后会有一个可用的版本出来。

另外,也有了 NAPI 版本的 FFI 和 Ref,分别名为ffi-napiref-napi,和 ref 相关的包,比如 array 和 struct 拓展,也有了相应的 NAPI 版本,命名规则同上。使用 NAPI 的 Node C++ 拓展接口相对稳定,是今后的趋势。

最后,Electron 版本可以在https://electronjs.org/releases/stable中查看,而 NodeJS 及其 ABI 的版本可以在https://nodejs.org/en/download/releases/中查看。

一些资源

最后,在这里放上最近踩坑时候经常使用到的一些资源吧: