nodejs/elctron中,可以通過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代碼之間實現了內存共享,類型轉換則是通過ref、ref-array、ref-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 ~ 127signed char | -127 ~ 127unsigned char | 0 ~ 256

在不聲明unsigned時 默認為signed型

refunsigned會縮寫成u, 如 uchar 對應 usigned char

浮點型中有 float double long double

ref庫中已經幫我們準備好了基礎類型的對應關係。

C++類型 | ref對應類型 |

---------- | ------------void | ref.types.voidint8 | ref.types.int8uint8 | ref.types.uint8int16 | ref.types.int16uint16 | ref.types.uint16

float | ref.types.float

double | ref.types.doublebool | ref.types.boolchar | ref.types.charuchar | ref.types.ucharshort | ref.types.shortushort | ref.types.ushortint | ref.types.intuint | ref.types.uintlong | 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之間存在依賴關係。
  4. 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分析工具

Dependency Walker (depends.exe) Home Page?

www.dependencywalker.com圖標

可以查看DLL鏈接庫的所有信息、以及DLL依賴關係的工具,但是很遺憾不支持WIN10。如果你不是WIN10用戶,那麼你只需要這一個工具即可,下面工具可以跳過。

Process Monitor - Windows Sysinternals?

docs.microsoft.com
圖標

可以查看進程執行時候的各種操作,如IO、註冊表訪問等。這裡用它來監聽node/electron進程的IO操作,用於排查Dynamic Linking Error: Win32 error錯誤原因3,可以查看ffi.Libary時的所有IO請求和對應結果,查看缺少了什麼DLL

DUMPBIN 選項?

msdn.microsoft.com

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來繞開這個問題。

參考IssueGetLastError() always 0 when using Win32 API參考PRgithub.com/node-ffi/nod

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]
", 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)
}

推薦閱讀:

相關文章