最近在开发中遇到了在NodeJS中调用C++代码的问题。这是一个简短的总结。主要解决方案在NodeJS中,与其他语言编写的代码进行通信主要有两种解决方案:使用AddOn技术,使用C++为NodeJS编写一个扩展,然后调用其他语言编写的源代码或动态库在代码使用FFI(ForeignFunctionInterface)技术,将其他语言编写的动态链接库直接引入Node.比较两种方法后发现,两种方法各有优缺点。首先,AddOn技术更通用。它可以使用C++代码来扩展Node的行为。很多库都是通过这种方式来完成一些比较底层的操作(比如一些与操作系统的通信)。但是写起来很麻烦。写C++项目,必须按照NodeJS规范导出相应的功能,每次安装都需要编译(适配本地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时会出现各种兼容性问题(比如编译不能通过,虽然有人提供了补丁,但是还是还没有合并到主分支,为了避免bug,最好暂时不用)。所以可以安装LTS版本。另外还需要注意调用的DLL是32位的还是64位的,Node的版本需要和DLL的版本相匹配。因为如果64位的Node调用32位的DLL,是无法加载成功的,反之亦然。安装适用于Windows的C++工具链有两个选项:安装VisualStudio和安装相应的工具链。如果使用VS2019版本,需要安装C++桌面开发和WindowsSDK相关工具(Nodev10现在只支持v141版本的MSVC),方便后续的调试工作(虽然难度也很大)。安装Node后,使用管理员权限运行Powershell,全局安装windows-build-tools。参考命令npminstall--global--productionwindows-build-tools安装node-gypnode-gyp是Node中基于gyp的跨平台编译工具。用于编译其他库。安装的时候需要用到VC工具链,所以如果不把工具链放在全局变量里,需要打开VS的DeveloperPowershell安装。命令行通常在开始菜单的VisualStudio文件夹中。参考命令:npminstall-gnode-gyp安装FFI和REF以下步骤仍然需要VC工具链,所以可能还是需要在DeveloperPowershell中执行(建议一直保持这个窗口,只要命令涉及到编译安装后面需要用到)。如果在安装FFI及相关工具时没有VC工具链,会直接安装二进制代码,所以包的ABI版本可能与NodeJS的ABI版本不匹配(在后面的Tips中提到)。现在,切换到项目文件夹并安装以下包。其中ffi包用于支持FFI功能,ref包用于支持指针功能(原理是通过Node的Buffer内存实现JS结构和C结构的转换),ref-*用于支持高级结构(如数组和结构)npminstallffi-snpminstallref-snpminstallref-array-snpminstallref-struct-s另外如果想支持VC中常见的wchar类型,也可以安装ref-wchar包。安装electron-rebuild包如果是electron项目,也建议安装electron-rebuild包,可以遍历node_modules目录下的所有包,重新编译。然后,建议在package.json中配置electron-rebuild命令:"scripts":{"rebuild":"./node_modules/.bin/electron-rebuild"}然后执行。当需要重新编译时,只需要执行npmrunrebuild即可。简单的使用概览,可以查看如下官方示例:varffi=require('ffi')varlibm=ffi.Library('libm',{ceil:['double',['double']]})libm.ceil(1.5)//2引入FFI后,使用FFI调用libm库(可能这个例子只能用于类Unix系统),一般扩展名为libm.so,系统会搜索这个动态库在系统目录中,并使用动态链接器将其加载到节点进程中。接下来程序在libm库中声明了一个方法ceil(向上取整),这个函数的返回值是double类型(第一个数组中的doule),这个函数的入参也是一个double类型的值(在第二个数组中加倍)。最后直接使用libm.ceil方法调用动态库中的函数并返回正确值。这只是FFI的一个简单用例。更复杂的用法(主要是异步调用和回调函数),请参考FFI示例页面https://github.com/node-ffi/node-ffi/wiki/Node-FFI-Tutorial。类型FFI的类型系统实际上记住了ref库的类型。ref库的类型系统是基于NodeJS的Buffer内存,可以根据Buffer中数据的类型来访问和修改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()方法。只要在对应类型的变量上使用这个方法,就可以得到指针指向内容的变量。比如一个JS变量a_pointer指向一个int*类型,如果我们想得到一个具体的整数值,可以使用a_pointer.deref()方法。相反,如果你想得到一个变量的地址,你需要对一个变量使用ref()方法。需要注意类型和变量值的区别。使用ref.types、ref.refType或者下面提到的ref_struct({...})获取的类型,如果要获取某个类型的变量,有两种方法,一种是从返回值中获取FFI函数,另外一个就是在Buffer中开辟一块空间来存放获取到的类型的变量,下面会详细介绍。如果需要在NodJSBuffer中开辟某种类型的空间,可以使用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库来实现。具体可以参考这两个库的文档。另外,对于WindowsAPI中比较常见的宽字符wchar类型,也有一个基于ref的库ref-wchar来支持。最后附上ref文档http://tootallnate.github.io/ref/,具体API可以查看这里。调用外部符号假设我们有以下C代码(并使其更复杂):/*main.c*/typedefstructt_s_t{inta;charb;}t_s;__declspec(dllexport)intadd_one(inta){returna+1;}__declspec(dllexport)voidstruct_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_one和struct_test。其中,函数前面的__declspec标志表示该函数声明为导出函数。VC在导出C函数时默认使用_cdecl调用约定。其中,add_one方法的作用很明显,就是给传入的参数加一并返回。struct_test函数的作用是先在堆上开辟一块大小为声明结构体大小的内存空间,然后将这块内存空间的指针赋值给传入的参数,对结构体进行赋值。(这里的代码其实不够严谨,没有进行内存回收,但这不是本文的重点,先不讨论)需要注意的是,如果是C++代码,需要导出带有extern“C”标记,否则会由于符号和调用约定问题而被修改,导致无法通过源代码中的符号找到该函数。我们可以使用VS的DeveloperPowershell编译上面的源码:cl/cmain.cLink/dllmain.obj编译后会生成main.dll,后面我们会用到这个动态库。对于上面的C函数,我们有如下JS代码,假设它与C代码在同一个文件夹中:/*index.js*/constffi=require('ffi')constref=require('ref')constref_struct=require('ref-struct')constt_s=ref_struct({a:ref.types.int,b:ref.types.char})constt_s_ref=ref.refType(t_s)consttest_ffi=ffi.Library(__dirname+'main',{add_one:['int',['int']],//又名'add_one':[ref.types.int,['int']],struct_test:['void',['pointer']]//又名'struct_test':['void',[t_s_ref]],})constresult=test_ffi.add_one(20)console.log(result)//21t_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语言函数及其参数符号的对象。上面已经简要介绍了该列表。对象的键是函数名,值是一个数组。数组的第一个元素是函数返回值的类型,第二个元素是另一个数组,里面包含了函数的入参类型。这些类型使用基于上一节介绍的ref包的类型系统。类型可以用字符串或代码表示。代码中可以参考aka的注释。接下来代码通过test_ffi.add_one调用C语言动态库中的add_one函数。可以看出调用方式和JS中的函数是一样的。但是需要注意的是参数类型一定不能错。特别需要注意的是,C语言中的字符串类型与JS中的字符串类型不同,必须按照上述方法进行转换。然后,代码使用ref.alloc方法为t_s_ref变量开辟一块内存空间(注意这里只是指针大小的空间,不是结构体大小),并将地址赋值给t_s_p变量,然后通过struct_test函数的变量。因为t_s_p是双指针,需要解引用两次才能得到结构的真正值。回调函数可以使用ffi.Callback()函数声明回调函数。这个函数第一个参数是返回值,第二个参数是入参列表,第三个参数是真正回调函数的闭包。例如定义一个回调函数如下,该函数会获取用户名和id,并返回动作是否执行成功:typedefint(*callback)(int,constchar*);然后,在ffi中,你可以使用以下方法声明回调函数:constcallback_function=ffi.Callback('int',['int','string'],(id,username)=>{//dosomethingreturn1})声明后,需要让回调函数为参数传入一个函数:test_ffi.set_a_callback(callback_function)//额外引用回调指针,避免GCprocess.on('exit',function(){callback_function})特别是在设置回调函数后,必须保证在JS中仍然有对该函数的引用(例如,对函数的引用放在NodeJS的退出事件中为上面提到过,也是比较经典的做法)。否则,该函数将被NodeJS的GC销毁。它的表现是:程序开始执行时一切正常,但过一会,调用回调函数时,程序就会异常退出。如果你用VS调试程序,你会发现程序可能访问了非法指针。这是因为回调函数的指针也存储在DLL代码中,但是JS中的指针所指向的地址是无法被JS中的代码识别的。Reference,所以被CG释放了,所以当DLL中的代码调用这个地址的函数时,就会访问到非法内存。一些TipsDLL调试方法可能会发现,在使用ffi的过程中最大的问题就是程序难以调试。尤其是面对一个DLL的时候,就像是针对一个黑盒操作。虽然它已经将其API用头文件的FFI翻译成JS代码,但仍然难以判断参数传递或返回值是否正确。在C++中,代码中的参数是否传入正确,传入后是否正确执行等等。这就需要一个可以调试的方法。更有用的方法是使用宇宙第一IDEVisualStudio的Attach(附加)方法到进程中进行调试。但是这种调试方式的前提是你有DLL的源代码或者PDB(符号)文件(如果没有,你只能看到异常代码附近的反汇编代码,通常这些异常都是内存错误引起的,实际上它周围的数据可能意义不大)。如果有源文件,首先打开项目,NodeJS加载DLL后,启动项目时可以选择“Attachtoprocess”,在对话框中选择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文件不在同一个文件夹中,可能会加载失败,并出现类似“动态链接错误:Win32错误126”的错误信息。这时候就需要把DLL文件夹的路径放在系统搜索动态链接库的PATH中,但是FFI没有提供这样的接口。不过好在WindowsAPI提供了SetDllDirectoryA接口来切换PATH寻找进程中的DLL。您可以使用以下代码来完成此操作:constkernel32_ffi=ffi.Library('kernel32',{SetDllDirectoryA:['bool',['string']]})kernel32_ffi.SetDllDirectoryA(your_custom_dll_directory)如上所述,如果动态库不在PATH中,会找不到动态库,会报“DynamicLinkingError:Win32error126”,其他时候只要找不到动态库,就会报这个错误.如果出现这个错误,需要检查动态库的名称是否正确,检查动态库的版本是否正确(比如32位的Node使用了64位的DLL)等。另外,有一个相对常见的“动态链接错误:Win32错误127”错误。这个错误意味着在DLL中没有找到相应的符号。可能需要检查ffi中声明的函数名是否正确,DLL版本是否有偏差。向上。在Electron中使用FFI由于每个Electron版本都是基于对应的Node和Chrome版本构建的,因此在使用FFI之前需要根据您所使用的Electron版本安装本地的NodeJS版本,否则FFI可能与Node版本不匹配。导致提示ABI版本不一致:xxwascompiledagainstadifferentNode.jsversionusingNODE_MODULE_VERSIONx。此版本的Node.js需要NODE_MODULE_VERSIONxx(NodeJS使用NODE_MODULE_VERSION来标识ABI版本)。这种情况下可以使用上面提到的electron-rebuild重新编译项目中的所有插件(注意本地NodeJS的ABI版本必须和Electron中NodeJS的ABI版本保持一致)。另外需要注意的是Electron5以上的版本使用了NodeJS12的ABI,但是目前的Ref库不支持这个ABI,会导致编译失败。不过已经有人提交pullrequest修复了,相信以后会有可用的版本。此外,还有FFI和Ref的NAPI版本,分别命名为ffi-napi和ref-napi,与ref相关的包,如array和structextensions,也有对应的NAPI版本,命名规则同上多于。使用NAPI的NodeC++扩展接口比较稳定,会是未来的趋势。最后,可以在https://electronjs.org/releases/stable查看Electron版本,而可以在https://nodejs.org/en/download/releases/查看NodeJS及其ABI版本。一些资源最后放几个最近踩坑时经常用到的资源:ref文档:http://tootallnate.github.io/ref/ffi文档:https://github.com/node-ffi/node-ffi/wiki/Node-FFI-Tutorial基于ffi的win32api:https://github.com/waitingsong/node-win32-apiv2ex在node-ffi食用指南(难吃):https://www.v2ex.com/amp/t/474611
