使用Nodejs的addon调用dll的问题记录

用Napi编写nodejs Addon通过LoadLibraryA调用的dll的记录

背景:我负责的一个公司内部项目,使用electron+Vue3+cpp的方式实现一个对底层具有操作的软件开发。然后第三方提供底层硬件设备的dll与sdk,然后我们就想通过nodejs提供的addon+Napi方式去实现对这个dll进行封装与调用。不选择lib的原因也很简单,是因为在写这个项目的时候我没有意识到.lib .dll include三者的关系。因此采用了LoadLibraryA动态加载dll方式去做。

在这里特别说明一下三者的关系,如果上网查大部分都会说lib是静态链接库dll是动态链接库,因此lib是编译阶段的dll是运行阶段的。说的对,但是不完全对,我一直理解是dll和lib是可以互相代替的,本来我以为只要在编译阶段通过了lib,那么生成的程序就是可以用的,我以为源代码应该是存在lib中,因此编译后的产物是可以直接运行的。后来发现lib只是让编译通过,真正运行必须得有dll。也就是说通俗一点,程序运行调用库有两种方式

  • 1、【显式dll调用】手动构造dll的函数模板,然后调用LoadLibrary/LoadLibraryA去动态加载dll。
    • 好处就是不需要将其他项目的include头文件、lib文件、dll文件全拷过来;换句话说就是对方只要给你dll文件就够了,既可以保护源代码,也很简单方便
    • 缺点就是你必须很熟悉这个dll导出的函数什么样子,参数类型是什么,具有什么样的数据结构,还得自己手动去构建导出函数。
  • 2、【隐式dll调用】通过将对方的include文件夹拷到自己的include内,然后将对方编译的.lib包含到自己项目的lib库目录,然后将dll拷到根目录或者可以访问的地方
    • 好处就是直接通过对方的include,直接调用对方写好的函数以及数据类型,省略很多项目搭建上的问题,就像开发自己的代码一样。
    • 坏处就是,像我这种从Java转js然后转c++的人来说,如果不是有完整的知识体系,不了解什么是编译过程链接过程什么是cmake,很难理解为什么还要lib为什么要include为什么要dll,甚至都没法把项目搭建起来。

(后来补充:这篇文章是我第一次研究addon调用dll,因此写的比较混乱,后来略微深入一点,推荐直接看另一篇说明:
用Napi编写nodejs Addon并调用dll

上面都是题外话,是我后面学习到lib文件与编译关系之后补充的。所以一开始项目采用的是最简单的动态导入dll实现,因此下面是用来记录动态导入dll时遇到的一些问题

.

.

一、部分代码展示

代码由项目裁剪而来,直接复制可能会有报错或者缺少其他东西,仅供参考

  • 测试用生成DLL部分代码
//函数声明的头文件<pch.h>
#ifndef PCH_H
#define PCH_H

// 添加要在此处预编译的标头
#include <iostream>
#include "framework.h"
using namespace std;
//定义导入导出标识
#ifdef DETECTOR_EXPORTS
#define DETECTOR_API __declspec(dllexport)
#else
#define DETECTOR_API __declspec(dllimport)
#endif
//定义自定义结构体
struct MyStruct{
	const char* name;
	int age;
	bool marry;
};
//定义自定义class
class MyClass {
private:
	const char* name;
	int age;
	bool marry;
public:
	MyClass(const char* name,int age,bool marry) {
		this->name = name;
		this->age = age;
		this->marry = marry;
	}
	~MyClass() {
		cout<<"~MyClass"<< endl;
	}
	void show() {
		cout << this->name << " " << this->age << " " << this->marry << endl;
	}
};
//导出函数声明
extern "C" {
	DETECTOR_API void hello();
	DETECTOR_API const char* helloWithStr();
	DETECTOR_API void setHelloWithStr(const char* str);
	DETECTOR_API MyStruct helloWithStruct();
	DETECTOR_API void setHelloWithStruct(MyStruct ms);
	DETECTOR_API MyClass helloWithClass();
	DETECTOR_API void setHelloWithClass(MyClass mc);
}
#endif //PCH_H

// 函数实现cpp源文件<pch.cpp>
#include <iostream>
#include <string>
#include "pch.h"

using namespace std;

//各个函数实现

void  hello() {
    cout << "Hello" << endl;
}
const char*  helloWithStr() {
    cout << "->[Hello2]" << endl;
    return "Hello2";
}
void  setHelloWithStr(const char* str) {
    cout << str << endl;
}
MyStruct  helloWithStruct() {
    MyStruct ms = {"king",25,false};
    return ms;
}
void  setHelloWithStruct(MyStruct ms) {
    cout << ms.name << " " << ms.age << " " << ms.marry << endl;
}
MyClass  helloWithClass() {
    MyClass mc("KINGMC",27,false);
    return mc;
}
void  setHelloWithClass(MyClass mc) {
    mc.show();
}
  • nodejs Addon部分代码
//test.h
#ifndef TEST_H
#define TEST_H
//必须与dll代码中结构与class的一样(换句话说,如果项目得体,完全可以直接拷贝dll中的头文件拿来用)

class MyClass {
private:
	const char* name;
	int age;
	bool marry;
public:
	MyClass(const char* name,int age,bool marry) {
		this->name = name;
		this->age = age;
		this->marry = marry;
	}
	~MyClass() {
		cout<<"~MyClass"<< endl;
	}
	void show() {
		cout << this->name << " " << this->age << " " << this->marry << endl;
	}
};
struct MyStruct{
	const char* name;
	int age;
	bool marry;
};
void loadIRayDetectorDll();
void test();
#endif
//test.cpp
#include <iostream>
#include <string>
#include <atlstr.h>
#include "test.h"

using namespace std;

//函数签名模板
typedef void(*HelloCall)();
typedef const char*(*HelloWithStrCall)();
typedef void(*SetHelloWithStrCall)(const char*);
typedef const MyStruct(*HelloWithStructCall)();
typedef void(*SetHelloWithStructCall)(MyStruct);
typedef const MyClass(*HelloWithClassCall)();
typedef void(*SetHelloWithClassCall)(MyClass);

//函数指针
HelloCall helloCall = nullptr;
HelloWithStrCall helloWithStrCall = nullptr;
SetHelloWithStrCall setHelloWithStrCall = nullptr;
HelloWithStructCall helloWithStructCall = nullptr;
SetHelloWithStructCall setHelloWithStructCall = nullptr;
HelloWithClassCall helloWithClassCall = nullptr;
SetHelloWithClassCall setHelloWithClassCall = nullptr;

//加载dll与导入函数
int loadIRayDetectorDll(){
    HINSTANCE dllLoaded = LoadLibraryA(("DllTest.dll"));
    if(dllLoaded == NULL){
        return 1;
    }else{
        //函数载入
		helloCall = (HelloCall)GetProcAddress(dllLoaded2,"hello");
		helloWithStrCall = (HelloWithStrCall)GetProcAddress(dllLoaded2,"helloWithStr");
		setHelloWithStrCall = (SetHelloWithStrCall)GetProcAddress(dllLoaded2,"setHelloWithStr");
		helloWithStructCall = (HelloWithStructCall)GetProcAddress(dllLoaded2,"helloWithStruct");
		setHelloWithStructCall = (SetHelloWithStructCall)GetProcAddress(dllLoaded2,"setHelloWithStruct");
		helloWithClassCall = (HelloWithClassCall)GetProcAddress(dllLoaded2,"helloWithClass");
		setHelloWithClassCall = (SetHelloWithClassCall)GetProcAddress(dllLoaded2,"setHelloWithClass");
		// helloCall = (HelloCall)GetProcAddress(dllLoaded2,"?hello@@YGXXZ");
		// helloWithStrCall = (HelloWithStrCall)GetProcAddress(dllLoaded2,"?helloWithStr@@YGPBDXZ");
		// setHelloWithStrCall = (SetHelloWithStrCall)GetProcAddress(dllLoaded2,"?setHelloWithStr@@YGXPBD@Z");
		// helloWithStructCall = (HelloWithStructCall)GetProcAddress(dllLoaded2,"?helloWithStruct@@YG?AUMyStruct@@XZ");
		// setHelloWithStructCall = (SetHelloWithStructCall)GetProcAddress(dllLoaded2,"?setHelloWithStruct@@YGXUMyStruct@@@Z");
		// helloWithClassCall = (HelloWithClassCall)GetProcAddress(dllLoaded2,"?helloWithClass@@YG?AVMyClass@@XZ");
		// setHelloWithClassCall = (SetHelloWithClassCall)GetProcAddress(dllLoaded2,"?setHelloWithClass@@YGXVMyClass@@@Z");
        if(helloCall == NULL ||helloWithStrCall == NULL ||setHelloWithStrCall == NULL ||helloWithStructCall == NULL ||setHelloWithStructCall == NULL ||helloWithClassCall == NULL ||setHelloWithClassCall == NULL){
            return 2;
        }
    }
    return 0;
}

//测试入口
void test(){
    helloCall();
    cout<<"str: ["<<helloWithStrCall()<<"]"<<endl;
    setHelloWithStrCall("wowNB");
    
    const MyStruct ms =  helloWithStructCall();
    cout<<"->["<<ms.name<<" "<<ms.age<<" "<<ms.marry<<"]"<<endl;
    MyStruct ms2={"KING2",26,false};
    setHelloWithStructCall(ms2);
    
    MyClass mc = helloWithClassCall();
    mc.show();
    MyClass mc2("KINGClass2",30,false);
    setHelloWithClassCall(mc2);
}
#include <napi.h>
#include <iostream>
#include "test.h"

using namespace std;
//向js抛出异常
void throwJsError(Napi::Env env, string msg){
    env.RunScript(Napi::String::New(env, "throw new Error('" + msg + "')"));
}
//导出给js使用
Napi::Boolean dll_test(const Napi::CallbackInfo &info){
    test()
    return Napi::Boolean::New(env,true);
}

//addon初始化入口
Napi::Object Initialize(Napi::Env env, Napi::Object exports){
    // 加载IRayDetector的dll,载入导出函数
    int loadRes = loadIRayDetectorDll();
    if (loadRes == 1){
        throwJsError(env, "Cannot load DLL");
    }
    else if (loadRes == 2){
        throwJsError(env, "Cannot load function from DLL");
    }
    exports.Set(Napi::String::New(env, "dll_test"), Napi::Function::New(env, dll_test));
    return exports;
}

NODE_API_MODULE(NODE_GYP_MODULE_NAME, Initialize)
//ts
import * as dlltest from "DllTestAddon.node";
dlltest.dll_test();

.

.

.

二、遇到的问题

1、无法通过LoadLibraryA加载Dll

a、32位与64位的问题

​ 如果使用nodejs为32位,那么addon导入64位dll时是不会成功的,并且不会报错(这个可以用visual studio工程进行调试得出)。需要特别说明,如果你切换到64位nodejs,切记删掉项目中旧的node_modules,重新执行一边"npm install",保证你的库都变成了64位。(对于nodejs版本管理,这里不得不提nvm神器,具体网上查)。

b、dll与addon的路径问题

​ 如果说dll加载的路径不存在或者无法访问,是会加载失败的,这个时候需要逐步排查。通常情况下相对目录查不到使用绝对目录试试,如果绝对目录查不到,就需要思考是不是32位64位原因,或者访问权限问题。

.

2、导入dll后无法调用导出函数

a、导出函数符号化问题

​ 对于getProcAddress(dll:dllHandle,methodStr:string)这个函数来说,methodStr指的是符号,而不是函数名称。可能通过查阅资料你会发现很多教程或者博客他们传入的都是函数名称,那是因为他们导出的函数符号恰好是函数名称,而如果不是专门设置过,那么大概率导出的函数名称都是符号化的。

​ 所谓函数名称符号化,就是需要使用cmd命令行内的dumpbin /exports DllTest.dll进行查询。如果输入dumpbin显示不存在,那是因为没有把VS的xxx\VisualStudio\2022\VC\Tools\MSVC\14.36.32532\bin\Hostx64\x64\的路径配置到环境变量(这个不是必须,只是这样配置方便个人使用)。dumpbin样例如下

PS xxx\project\YTHPT\electron_typescript_vue3_win32\iRayDetectorTest> dumpbin /exports .\DllTest.dll
Microsoft (R) COFF/PE Dumper Version 14.36.32532.0
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file .\DllTest.dll

File Type: DLL

  Section contains the following exports for DllTest.dll

    00000000 characteristics
    FFFFFFFF time date stamp
        0.00 version
           1 ordinal base
           7 number of functions
           7 number of names

    ordinal hint RVA      name

          1    0 00011285 ?hello@@YGXXZ = @ILT+640(?hello@@YGXXZ)
          2    1 0001115E ?helloWithClass@@YG?AVMyClass@@XZ = @ILT+345(?helloWithClass@@YG?AVMyClass@@XZ)
          3    2 00011069 ?helloWithStr@@YGPBDXZ = @ILT+100(?helloWithStr@@YGPBDXZ)
          4    3 00011005 ?helloWithStruct@@YG?AUMyStruct@@XZ = @ILT+0(?helloWithStruct@@YG?AUMyStruct@@XZ)
          5    4 000112E4 ?setHelloWithClass@@YGXVMyClass@@@Z = @ILT+735(?setHelloWithClass@@YGXVMyClass@@@Z)
          6    5 00011389 ?setHelloWithStr@@YGXPBD@Z = @ILT+900(?setHelloWithStr@@YGXPBD@Z)
          7    6 0001108C ?setHelloWithStruct@@YGXUMyStruct@@@Z = @ILT+135(?setHelloWithStruct@@YGXUMyStruct@@@Z)

  Summary

        1000 .00cfg
        1000 .data
        1000 .idata
        1000 .msvcjmc
        3000 .rdata
        1000 .reloc
        1000 .rsrc
        8000 .text
       10000 .textbss

​ 会发现在中间部分类似于?hello@@YGXXZ = @ILT+640(?hello@@YGXXZ)的就是dll导出函数列表,也是函数的符号索引(函数名称符号化)。至于如何让导出函数依旧是原版函数名称,在文章底部"其他问题"会有说明。

b、__stdcall、__cdecl的问题

​ 如果dll使用的导入约定是__stdcall,但是addon使用了__cdecl约定或者其他约定(其他类似于还有__fastcall等),总之两者的约定不一样,就会导致调用失败。也就是说dll导入约定两者必须一致,如果dll没有使用,那么addon也不需要。导入约定在底部“其他问题”有说明。

.

3、dll导出函数返回值与参数数据类型问题

​ dll导出函数的返回值为string类型没法被调用,这个问题不仅仅出现在返回值类型,如果函数的参数为string也会导致一样的问题。实际上不仅仅是string,严格来说,如果不是纯C的数据类型都会出现这个问题。

  • 1、在dll导出函数时,极力不推荐使用C++的数据类型,因为对于dll或者exe来说,不同版本甚至不同设备以及编译器,导入dll对于解析C++的内存空间都是不一样的(网上查的),虽然说nodejs addon导入dll,实际上是nodejs.exe进程的js实例导入的dll,由于能力有限,我不知道是因为nodejs本身不支持C++的导出规范还是说必须和编译nodejs.exe的编译器以及编译配置一样情况下才能导入C++规范dll(毕竟通过我测试,导出为string的函数我在纯vs创建的cpp项目中导入使用完全没问题,但是换到nodejs addon就出现这个问题)。因此如果需要dll具有通用性,最好使用纯C数据类型。例如string就可以使用const char*代替。

  • 复杂类型返回时可以用纯C结构体进行返回,例如

    • struct MyStruct{
      	const char* name;
      	int age;
      	bool marry;
      };
      
  • 对于dll导出class也是可以的,但是同理,class的成员属性以及函数也必须准守导出的数据是纯C数据类型。例如

    • class MyClass {
      private:
      	const char* name;
      	int age;
      	bool marry;
      public:
      	MyClass(const char* name,int age,bool marry) {
      		this->name = name;
      		this->age = age;
      		this->marry = marry;
      	}
      	~MyClass() {
      		cout<<"~MyClass"<< endl;
      	}
      	void show() {
      		cout << this->name << " " << this->age << " " << this->marry << endl;
      	}
      };
      
  • dll是支持用指针当做参数或者返回值类型的,但是必须准守原则谁申请,谁释放,申请多少释放多少。也就是说如果dll在内部申请了指针并开辟空间,然后返回指针给addon调用,就不能在这个addon内释放这个指针,因为dll的堆空间和当前进程堆空间是不一致的。必须再由dll提供一个释放指针的接口,例如

    • void freeMemory(void* ptr){
      	free(ptr);
      }
      

      通过在addon内调用这个函数去释放dll传递过来的指针。

.

4、使用__cdecl的函数退出时异常

​ 根本原因就是因为__cdecl的导入规约明确了在函数调用内部必须手动堆栈,如果没有手动清除就会导致ndoejs.exe的js实例直接挂掉(没有任何异常,需要拿vs才能调试出来)。

.

5、函数调用一次之后异常结束

​ dll导入与函数调用位置导致的这个问题,使用try-catch之后还会有如下信息

C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe[27820]: c:\ws\src\js_native_api_v8.h:96: Assertion '(open_callback_scopes) == (open_callback_scopes_before)' failed.

​ 原因是在导出给js使用的函数dll_test函数中不能将 导入dll、函数载入调用函数 同时进行。也就说,在同一个给js导出的函数中不能同时导入dll、载入函数之后调用函数。例如我的源码中将dll导入与函数载入放在了初始化中,然后调用放在了dll_test中。有可能有一些开发者想通过js传入dll路径实现动态载入dll,这样设计是没有问题的,问题在于有时候会认为导入dll并且载入函数之后应该可以调用载入的函数呀,实际上是不行的(我也不知道什么原因,可能是nodejs或者其他原因)。

.

.

.

三、其他问题

a、如何在cpp中定义dll的导出函数

通过如下方式给函数声明加上导入导出标识,这个DETECTOR_API名字随便取

#ifdef DETECTOR_EXPORTS
#define DETECTOR_API __declspec(dllexport)
#else
#define DETECTOR_API __declspec(dllimport)
#endif

b、如何构造导入函数签名模板

  • 什么是函数签名模板

    • 函数签名模板就是你导入的函数是长什么样的,这个模板必须描述这个函数的返回值类型,参数类型。
  • 例如

    • //函数签名模板(这里使用__stdcall,但是实际上我的dll导出并没有遵循__stdcall,这里仅仅只是展示)
      typedef void(__stdcall *HelloCall)();
      typedef const char*(__stdcall *HelloWithStrCall)();
      typedef void(__stdcall *SetHelloWithStrCall)(const char*);
      typedef const MyStruct(__stdcall *HelloWithStructCall)();
      typedef void(__stdcall *SetHelloWithStructCall)(MyStruct);
      typedef const MyClass(__stdcall *HelloWithClassCall)();
      //返回值是void类型,参数只有一个,是MyClass类型
      typedef void(__stdcall *SetHelloWithClassCall)(MyClass);
      
      //函数指针
      HelloCall helloCall = nullptr;
      HelloWithStrCall helloWithStrCall = nullptr;
      SetHelloWithStrCall setHelloWithStrCall = nullptr;
      HelloWithStructCall helloWithStructCall = nullptr;
      SetHelloWithStructCall setHelloWithStructCall = nullptr;
      HelloWithClassCall helloWithClassCall = nullptr;
      SetHelloWithClassCall setHelloWithClassCall = nullptr;
      

c、如何查看dll导出函数与符号索引

dumpbin /exports dll文件名称.dll

d、什么是导入规约(导入约定)

​ 所谓的导入规约是过去编程的遗留产物,也就是当初在C导出dll时是针对了很多规约的,例如对C++导出是一套规约,对Pascal导出又是另一套规约,目的就是为了导入方知道导出的函数构成,所谓的函数名称符号化其实就是对函数的描述,函数符号化的含义就是描述了这个函数的返回值类型与参数类型,以及字节数等(具体查询c/cpp函数符号)

e、__stdcall与__cdecl以及extern “C”作用

1、__stdcall与__cdecl区别
  • 通常来说,dll导出函数使用__stccall或者不使用,这样是最好的(我建议是不使用)。__cdecl全称“C Declaration”,__stdcall应该不需要多说吧,看名字就知道“标准调用”嘛。

  • 共同点:参数入栈方式都是右到左

    • extern "C" DETECTOR_API void __stdcall test();
      
      void test(int a,int b){
          cout<<a<<" "<<b<<endl;
      }
      
      int a=1;
      test(a,a++);
      //打印 2 1
      //原因是先走的 a++
      //这种问题深究没意义,取决于编译器,但可以肯定的是入栈方式是右往左
      
  • 不同点:(他们的不同点很多,具体网上查,这里只是说我认为重要的点)

    • __stdcall是自动清空堆栈空间,不需要调用者手动释放
    • __cdecl必须由调用者手动释放堆栈空间
  • 场景

    • __stdcall之所以不需要手动释放空间,是因为他约定了参数必须严格相同,也就是dll导出函数签名是什么,调用者就必须严格一致,包括类型,以及数量一致
    • __cdecl导出函数可以实现可变参数,也就是调用者传入参数数量可以是可变的,这就导致一个释放问题,毕竟dll也不知道你传入了多少参数,因此手动释放必须由调用者进行释放。
2、__stdcall与__cdecl对于函数符号的影响
  • __stdcall遵循的是标准的C++规约,导出的函数一般情况下都是类似于?hello@@YGXXZ
  • __cdecl遵循C标准规范,导出的符号一般情况下类似于_hello@0
3、extern "C"作用

​ 这个的作用主要是强制导出使用C默认规范,导出的符号是原版样式。但是extern "C"的意义是,extern 表示当前声明处于外部文件或者外部库中,需要在编译阶段再去寻找,"C"表示这是一个严格的C规范。他和__cdecl区别是,__cdecl是导入规约,针对导入者;extern "C"是连接关键字,针对cpp工程。针对的层次不一样。一般情况下extern "C"不是用在这个场景,他用在多文件调用的情况,只不过它严格约定了编译后的函数符号,因此在这里借用而已。

f、如何防止函数名称符号化

​ 给dll导出的函数使用extern "C"。特别说明:使用了extern "C"之后切记不要使用__stdcall,否则会导致函数依旧会符号化

.

.

.

四、总结

  • nodejs的addon不支持C++数据类型的dll,只能用C数据类型

  • nodejs的addon对dll有严格的32位64位要求,但是不会报错

  • dll函数导出如果不是有严格要求,用extern "C"就够了,例如

    • //导出函数声明
      extern "C" {
      	DETECTOR_API void hello();
      	DETECTOR_API const char* helloWithStr();
      	DETECTOR_API void setHelloWithStr(const char* str);
      	DETECTOR_API MyStruct helloWithStruct();
      	DETECTOR_API void setHelloWithStruct(MyStruct ms);
      	DETECTOR_API MyClass helloWithClass();
      	DETECTOR_API void setHelloWithClass(MyClass mc);
      }
      
    • 这样导出的函数就是原版函数名称

  • 加载dll,载入函数不能与函数调用处于同一个函数中被js调用(哪怕是Napi::Object Initialize(Napi::Env env, Napi::Object exports)初始化函数也不行)。一般情况下导入dll与载入函数放到addon初始化的入口,调用则导出给js使用。

  • getProcAddress载入函数使用的是函数符号

posted @ 2023-09-06 15:46  麦块程序猿  阅读(549)  评论(0编辑  收藏  举报