node-ffi使用指南

陳三百發表於2018-07-25

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程式碼之間實現了記憶體共享,型別轉換則是通過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 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, &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)
}
複製程式碼