在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 ~ 127 |
signed char | -127 ~ 127 |
unsigned char | 0 ~ 256 |
在不宣告unsigned時 預設為signed型
ref
中unsigned
會縮寫成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轉碼後cstr
為Buffer
類,可直接作為當作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
,會修改Buffer
的prototype
,替換和注入一些方法,請參考文件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
這個錯誤有三種原因
- 通常是傳入的DLL路徑錯誤,找不到Dll檔案,推薦使用絕對路徑。
- 如果是在x64的
node
/electron
下引用32位的DLL,也會報這個錯,反之亦然。要確保DLL要求的CPU架構和你的執行環境相同。 - 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 github.com/node-ffi/no…
PVOID返回空,即記憶體地址FFFFFFFF
閃崩
winapi中,經常通過判斷返回的pvoid
指標是否存在來判斷是否成功,但是在node-ffi
中,對FFFFFFFF
的記憶體地址deref()
會造成程式閃崩。必須迂迴採用指標的指標型別進行特判
HDEVNOTIFY
WINAPI
RegisterDeviceNotificationA(
_In_ HANDLE hRecipient,
_In_ LPVOID NotificationFilter,
_In_ DWORD Flags);
HDEVNOTIFY hDevNotify = RegisterDeviceNotificationA(hwnd, ¬ifyFilter, 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)
}
複製程式碼