V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
aswe6587
V2EX  ›  Node.js

node-ffi 食用指南(难吃)

  •  
  •   aswe6587 · 2018-07-27 11:22:18 +08:00 · 9159 次点击
    这是一个创建于 2345 天前的主题,其中的信息可能已经有所发展或是发生改变。

    node 调 c++撞了一头包,最后含泪写出了这指南。 ...好像不支持 markdown 的 table 语法,凑合着看吧。

    人生苦短,别调 dll。

    node-ffi 使用指南

    nodejs/electron中,可以通过node-ffi,通过Foreign Function Interface调用动态链接库,俗称调 DLL,实现调用 C/C++代码,从而实现许多 node 不好实现的功能,或复用诸多已实现的函数功能。

    node-ffi 是一个用于使用纯 JavaScript 加载和调用动态库的 Node.js 插件。它可以用来在不编写任何 C ++代码的情况下创建与本地 DLL 库的绑定。同时它负责处理跨 JavaScript 和 C 的类型转换。

    Node.js Addons相比,此方法有如下优点:

    1. 不需要源代码。
    2. 不需要每次重编译`node`,`Node.js Addons`引用的`.node`会有文件锁,会对`electron 应用热更新造成麻烦。
    3. 不要求开发者编写 C 代码,但是仍要求开发者具有一定 C 的知识。
    

    缺点是:

    1. 性能有折损
    2. 类似其他语言的 FFI 调试,此方法近似黑盒调用,差错比较困难。
    

    安装

    node-ffi通过Buffer类,在 C 代码和 JS 代码之间实现了内存共享,类型转换则是通过refref-arrayref-struct实现。由于node-ffi/ref包含 C 原生代码,所以安装需要配置 Node 原生插件编译环境。

    // 管理员运行 bash/cmd/powershell,否则会提示权限不足
    npm install --global --production windows-build-tools
    npm install -g node-gyp
    

    根据需要安装对应的库

    npm install ffi
    npm install ref
    npm install ref-array
    npm install ref-struct
    

    如果是electron项目,则项目可以安装 electron-rebuild 插件,能够方便遍历node-modules中所有需要rebuild的库进行重编译。

    npm install electron-rebuild
    

    在 package.json 中配置快捷方式

    package.json
        "scripts": {
        "rebuild": "cd ./node_modules/.bin && electron-rebuild --force --module-dir=../../"
    }
    

    之后执行npm run rebuild 操作即可完成electron的重编译。

    简单范例

    extern "C" int __declspec(dllexport)My_Test(char *a, int b, int c);
    extern "C" void __declspec(dllexport)My_Hello(char *a, int b, int c);
    
    import ffi from 'ffi'
    // `ffi.Library`用于注册函数,第一个入参为 DLL 路径,最好为文件绝对路径
    const dll = ffi.Library( './test.dll', {
        // My_Test 是 dll 中定义的函数,两者名称需要一致
        // [a, [b,c....]] a 是函数出参类型,[b,c]是 dll 函数的入参类型
        My_Test: ['int', ['string', 'int', 'int']], // 可以用文本表示类型
        My_Hello: [ref.types.void, ['string', ref.types.int, ref.types.int]] // 更推荐用`ref.types.xx`表示类型,方便类型检查,`char*`的特殊缩写下文会说明
    })
    
    //同步调用
    const result = dll.My_Test('hello', 3, 2)
    
    //异步调用
    dll.My_Test.async('hello', 3, 2, (err, result) => {
        if(err) {
            //todo
        }
        return result
    })
    

    变量类型

    C 语言中有 4 种基础数据类型----整型 浮点型 指针 聚合类型

    基础

    整型、字符型都有分有符号和无符号两种。

    类型 | 最小范围 -------- | --- char | 0 ~ 127 signed char | -127 ~ 127 unsigned char | 0 ~ 256

    在不声明 unsigned 时 默认为 signed 型

    refunsigned会缩写成u, 如 uchar 对应 usigned char

    浮点型中有 float double long double

    ref库中已经帮我们准备好了基础类型的对应关系。

    C++类型 | ref 对应类型 | -------- | --- void | ref.types.void int8 | ref.types.int8 uint8 | ref.types.uint8 int16 | ref.types.int16 uint16 | ref.types.uint16 float | ref.types.float double | ref.types.double bool | ref.types.bool char | ref.types.char uchar | ref.types.uchar short | ref.types.short ushort | ref.types.ushort int | ref.types.int uint | ref.types.uint long | ref.types.long ulong | ref.types.ulong DWORD | ref.types.ulong

    DWORD 为winapi类型,下文会详细说明

    更多拓展可以去ref doc

    ffi.Library中,既可以通过 ref.types.xxx 的方式申明类型,也可以通过文本(如uint16)进行申明。

    字符型

    字符型由char构成,在GBK编码中一个汉字占 2 个字节,在 UTF-8 中占用 3~4 个字节。一个ref.types.char默认一字节。根据所需字符长度创建足够长的内存空间。这时候需要使用ref-array库。

    const ref = require('ref')
    const refArray = require('ref-array')
    
    const CharArray100 = refArray(ref.types.char, 100) // 申明 char[100]类型 CharArray100
    const bufferValue = Buffer.from('Hello World') // Hello World 转换 Buffer
    // 通过 Buffer 循环复制, 比较啰嗦
    const value1 = new CharArray100()
    for (let i = 0, l = bufferValue.length; i < l; i++) {
        value1[i] = bufferValue[i]
    }
    // 使用 ref.alloc 初始化类型
    const strArray = [...bufferValue] //需要将`Buffer`转换成`Array`
    const value2 = ref.alloc(CharArray100, strArray)
    

    在传递中文字符型时,必须预先得知DLL库的编码方式。node 默认使用 UTF-8 编码。若 DLL 不为 UTF-8 编码则需要转码,推荐使用iconv-lite

    npm install iconv-lite
    
    const iconv = require('iconv-lite')
    const cstr = iconv.encode(str, 'gbk')
    

    注意!使用 encode 转码后cstrBuffer类,可直接作为当作uchar类型

    iconv.encode(str.'gbk')中 gbk 默认使用的是unsigned char | 0 ~ 256储存。假如 C 代码需要的是signed char | -127 ~ 127,则需要将 buffer 中的数据使用 int8 类型转换。

    const Cstring100 = refArray(ref.types.char, 100)
    const cString = new Cstring100()
    const uCstr = iconv.encode('农企药丸', 'gbk')
    for (let i = 0; i < uCstr.length; i++) {
        cString[i] = uCstr.readInt8(i)
    }
    

    C 代码为字符数组char[]/char *设置的返回值,通常返回的文本并不是定长,不会完全使用预分配的空间,末尾则会是无用的值。如果是预初始化的值,一般末尾是一大串的0x00,需要手动做trimEnd,如果不是预初始化的值,则末尾不定值,需要 C 代码明确返回字符串数组的长度returnValueLength

    内置简写

    ffi 中内置了一些简写

    ref.types.int => 'int'
    ref.refType('int') => 'int*'
    char* => 'string'
    

    只建议使用'string'。

    字符串虽然在 js 中被认为是基本类型,但在 C 语言中是以对象的形式来表示的,所以被认为是引用类型。所以string其实是char* 而不是char

    聚合类型

    多维数组

    遇到定义为多维数组的基本类型 则需要使用 ref-array 进行创建

        char cName[50][100] // 创建一个 cName 变量储存级 50 个最大长度为 100 的名字
    
        const ref = require('ref')
        const refArray = require('ref-array')
    
        const CName = refArray(refArray(ref.types.char, 100), 50)
        const cName = new CName()
    

    结构体

    结构体是 C 中常用的类型,需要用到ref-struct进行创建

    typedef struct {
        char cTMycher[100];
        int iAge[50];
        char cName[50][100];
        int iNo;
    } Class;
    
    typedef struct {
        Class class[4];
    } Grade;
    
    const ref = require('ref')
    const Struct = require('ref-struct')
    const refArray = require('ref-array')
    
    const Class = Struct({  // 注意返回的`Class`是一个类型
        cTMycher: RefArray(ref.types.char, 100),
        iAge: RefArray(ref.types.int, 50),
        cName: RefArray(RefArray(ref.types.char, 100), 50)
    })
    const Grade = Struct({ // 注意返回的`Grade`是一个类型
        class: RefArray(Class, 4)
    })
    const grade3 = new Grade() // 新建实例
    

    指针

    指针是一个变量,其值为实际变量的地址,即内存位置的直接地址,有些类似于 JS 中的引用对象。

    C 语言中使用*来代表指针

    例如 int a* 则就是 整数型 a 变量的指针 , &用于表示取地址

    int a=10,
    int *p; // 定义一个指向整数型的指针`p`
    p=&a // 将变量`a`的地址赋予`p`,即`p`指向`a`
    

    node-ffi实现指针的原理是借助ref,使用Buffer类在 C 代码和 JS 代码之间实现了内存共享,让Buffer成为了 C 语言当中的指针。注意,一旦引用ref,会修改Bufferprototype,替换和注入一些方法,请参考文档ref 文档

    const buf = new Buffer(4) // 初始化一个无类型的指针
    buf.writeInt32LE(12345, 0) // 写入值 12345
    
    console.log(buf.hexAddress()) // 获取地址 hexAddress
    
    buf.type = ref.types.int // 设置 buf 对应的 C 类型,可以通过修改`type`来实现 C 的强制类型转换
    console.log(buf.deref()) // deref()获取值 12345
    
    const pointer = buf.ref() // 获取指针的指针,类型为`int **`
    
    console.log(pointer.deref().deref())  // deref()两次获取值 12345
    

    要明确一下两个概念 一个是结构类型,一个是指针类型,通过代码来说明。

    // 申明一个类的实例
    const grade3 = new Grade() // Grade 是结构类型
    // 结构类型对应的指针类型
    const GradePointer = ref.refType(Grade) // 结构类型`Grade`对应的指针的类型,即指向 Grade
    // 获取指向 grade3 的指针实例
    const grade3Pointer = grade3.ref()
    // deref()获取指针实例对应的值
    console.log(grade3 === grade3Pointer.deref())  // 在 JS 层并不是同一个对象
    console.log(grade3['ref.buffer'].hexAddress() === grade3Pointer.deref()['ref.buffer'].hexAddress()) //但是实际上指向的是同一个内存地址,即所引用值是相同的
    

    可以通过ref.alloc(Object|String type, ? value) → Buffer直接得到一个引用对象

    const iAgePointer = ref.alloc(ref.types.int, 18) // 初始化一个指向`int`类的指针,值为 18
    const grade3Pointer = ref.alloc(Grade) // 初始化一个指向`Grade`类的指针
    

    回调函数

    C 的回调函数一般是用作入参传入。

    const ref = require('ref')
    const ffi = require('ffi')
    
    const testDLL = ffi.Library('./testDLL', {
        setCallback: ['int', [
            ffi.Function(ref.types.void,  // ffi.Function 申明类型, 用`'pointer'`申明类型也可以
            [ref.types.int, ref.types.CString])]]
    })
    
    
    const uiInfocallback = ffi.Callback(ref.types.void, // ffi.callback 返回函数实例
        [ref.types.int, ref.types.CString],
        (resultCount, resultText) => {
            console.log(resultCount)
            console.log(resultText)
        },
    )
    
    const result = testDLL.uiInfocallback(uiInfocallback)
    

    注意!如果你的 CallBack 是在 setTimeout 中调用,可能存在被 GC 的 BUG

    process.on('exit', () => {
        /* eslint-disable-next-line */
        uiInfocallback // keep reference avoid gc
    })
    

    代码实例

    举个完整引用例子

    // 头文件
    #pragma  once
    
    //#include "../include/MacroDef.h"
    #define	CertMaxNumber 10
    typedef struct {
    	int length[CertMaxNumber];
    	char CertGroundId[CertMaxNumber][2];
    	char CertDate[CertMaxNumber][2048];
    }  CertGroud;
    
    #define DLL_SAMPLE_API  __declspec(dllexport)
    
    extern "C"{
    
    //读取证书
    DLL_SAMPLE_API  int My_ReadCert(char *pwd, CertGroud *data,int *iCertNumber);
    }
    
    const CertGroud = Struct({
        certLen: RefArray(ref.types.int, 10),
        certId: RefArray(RefArray(ref.types.char, 2), 10),
        certData: RefArray(RefArray(ref.types.char, 2048), 10),
        curCrtID: RefArray(RefArray(ref.types.char, 12), 10),
    })
    
    const dll = ffi.Library(path.join(staticPath, '/key.dll'), {
        My_ReadCert: ['int', ['string', ref.refType(CertGroud), ref.refType(ref.types.int)]],
    })
    
    async function readCert({ ukeyPassword, certNum }) {
        return new Promise(async (resolve) => {
            // ukeyPassword 为 string 类型, c 中指代 char*
            ukeyPassword = ukeyPassword.toString()
            // 根据结构体类型 开辟一个新的内存空间
            const certInfo = new CertGroud()
            // 开辟一个 int 4 字节内存空间
            const _certNum = ref.alloc(ref.types.int)
            // certInfo.ref()作为 certInfo 的指针传入
            dll.My_ucRMydCert.async(ukeyPassword, certInfo.ref(), _certNum, () => {
                // 清除无效空字段
                let cert = bufferTrim.trimEnd(new Buffer(certInfo.certData[certNum]))
                cert = cert.toString('binary')
                resolve(cert)
            })
        })
    }
    

    常见错误

    • Dynamic Linking Error: Win32 error 126

    这个错误有三种原因

    1. 通常是传入的 DLL 路径错误,找不到 Dll 文件,推荐使用绝对路径。
    2. 如果是在 x64 的node/electron下引用 32 位的 DLL,也会报这个错,反之亦然。要确保 DLL 要求的 CPU 架构和你的运行环境相同。
    3. DLL 还有引用其他 DLL 文件,但是找不到引用的 DLL 文件,可能是 VC 依赖库或者多个 DLL 之间存在依赖关系。
    • Dynamic Linking Error: Win32 error 127:DLL 中没有找到对应名称的函数,需要检查头文件定义的函数名是否与 DLL 调用时写的函数名是否相同。

    Path 设置

    如果你的 DLL 是多个而且存在相互调用问题,会出现Dynamic Linking Error: Win32 error 126错误 3。这是由于默认的进程Path是二进制文件所在目录,即node.exe/electron.exe目录而不是 DLL 所在目录,导致找不到 DLL 同目录下的其他引用。可以通过如下方法解决:

    //方法一, 调用 winapi SetDllDirectoryA 设置目录
    const ffi = require('ffi')
    
    const kernel32 = ffi.Library("kernel32", {
    'SetDllDirectoryA': ["bool", ["string"]]
    })
    kernel32.SetDllDirectoryA("pathToAdd")
    
    //方法二(推荐),设置 Path 环境环境
    process.env.PATH = `${process.env.PATH}${path.delimiter}${pathToAdd}`
    

    DLL 分析工具

    可以查看 DLL 链接库的所有信息、以及 DLL 依赖关系的工具,但是很遗憾不支持WIN10。如果你不是WIN10用户,那么你只需要这一个工具即可,下面工具可以跳过。

    可以查看进程执行时候的各种操作,如 IO、注册表访问等。这里用它来监听node/electron进程的 IO 操作,用于排查Dynamic Linking Error: Win32 error错误原因 3,可以查看ffi.Libary时的所有 IO 请求和对应结果,查看缺少了什么DLL

    dumpbin.exe 为 Microsoft COFF 二进制文件转换器,它显示有关通用对象文件格式(COFF)二进制文件的信息。可用使用 dumpbin 检查 COFF 对象文件、标准 COFF 对象库、可执行文件和动态链接库等。 通过开始菜单 -> Visual Studio 20XX -> Visual Studio Tools -> VS20XX x86 Native Command Prompt 启动。

    dumpbin /headers [dll 路径] // 返回 DLL 头部信息,会说明是 32 bit word Machine/64 bit word Machine
    dumpbin /exports [dll 路径] // 返回 DLL 导出信息,name 列表为导出的函数名
    

    闪崩问题

    实际node-ffi调试的时候,很容易出现内存错误闪崩,甚至会出现断点导致崩溃的情况。这个是往往是因为非法内存访问造成,可以通过Windows日志看到错误信息,但是相信我,那并没有什么用。C 的内存差错是不是一件简单的事情。

    附录

    自动转换工具

    tjfontaine 大神提供了一个node-ffi-generate,可以根据头文件,自动生成node-ffi函数申明,注意这个需要Linux环境,简单用 KOA 包了一层改成了在线模式ffi-online,可以丢到 VPS 中运行。

    WINAPI

    轮子

    winapi 存在大量的自定义的变量类型,waitingsong 大侠的轮子 node-win32-api中完整翻译了全套windef.h中的类型,而且这个项目采用 TS 来规定 FFI 的返回 Interface,很值得借鉴。

    注意!里面的类型不一定都是对的,相信作者也没有完整的测试过所有变量,实际使用中也遇到过里面类型错误的坑。

    GetLastError

    简单说node-ffi通过 winapi 来调用 DLL,这导致GetLastError永远返回 0。最简单方法就是自己写个C++ addon来绕开这个问题。

    参考 Issue GetLastError() always 0 when using Win32 API 参考 PR https://github.com/node-ffi/node-ffi/pull/275

    PVOID 返回空,即内存地址FFFFFFFF闪崩

    winapi 中,经常通过判断返回的pvoid指针是否存在来判断是否成功,但是在node-ffi中,对FFFFFFFF的内存地址deref()会造成程序闪崩。必须迂回采用指针的指针类型进行特判

    HDEVNOTIFY
    WINAPI
    RegisterDeviceNotificationA(
        _In_ HANDLE hRecipient,
        _In_ LPVOID NotificationFilter,
        _In_ DWORD Flags);
    
    HDEVNOTIFY hDevNotify = RegisterDeviceNotificationA(hwnd, &notifyFilter, DEVICE_NOTIFY_WINDOW_HANDLE);
    if (!hDevNotify) {
    	DWORD le = GetLastError();
    	printf("RegisterDeviceNotificationA() failed [Error: %x]\r\n", le);
    	return 1;
    }
    
    const apiDef = SetupDiGetClassDevsW: [W.PVOID_REF, [W.PVOID, W.PCTSTR, W.HWND, W.DWORD]] // 注意返回类型`W.PVOID_REF`必须设置成 pointer,就是不设置 type,则 node-ffi 不会尝试`deref()`
    const hDEVINFOPTR = this.setupapi.SetupDiGetClassDevsW(null, typeBuffer, null,
        setupapiConst.DIGCF_PRESENT | setupapiConst.DIGCF_ALLCLASSES
    )
    const hDEVINFO = winapi.utils.getPtrValue(hDEVINFOPTR, W.PVOID) // getPtrValue 特判,如果地址为全`FF`则返回空
    if (!hDEVINFO) {
        throw new ErrorWithCode(ErrorType.DEVICE_LIST_ERROR, ErrorCode.GET_HDEVINFO_FAIL)
    }
    
    2 条回复    2024-01-07 17:59:08 +08:00
    kimown
        1
    kimown  
       2018-07-31 07:45:38 +08:00
    不是有 n-api 吗?
    zhangyuang
        2
    zhangyuang  
       355 天前
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   946 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 20:49 · PVG 04:49 · LAX 12:49 · JFK 15:49
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.