🪟

DenoでWindowsのDLLを利用する

2023/03/15に公開

OSにはダイナミックリンクライブラリというものが存在します。
共通の関数などをまとめておき、他のプログラムから読み込んで使えるようにしたものです。
WindowsではDLL(拡張子 .dll )が使えます。

Deno等の言語ではこれらのダイナミックリンクライブラリを取り込む方法があるので、これを使ってみたいと思います。

DLLの作成

Windows側のプロジェクトの作成

VisualStudio 2022を利用したプロジェクトの作成方法です。

まずは起動してください。

起動後に新しいプロジェクトを作成します。

プロジェクトの種類を選びます。適切なものがあるかもしれませんがとりあえず一旦まっさらな空の状態を選んでおきます。

次はプロジェクトの場所とプロジェクト名を適当につけます。後でプロジェクトを全て移動させればいいので場所は適当でもいいですが名前はしっかりつけておきましょう。無設定だとここの名前がDLLのファイル名になります。

次にプロジェクトにCもしくはC++のファイルを作成します。これを作らないと出てこないオプションもあるので今のうちに空で良いので作っておきます。

ソースファイルの上で 右クリック追加新しい項目 でファイルを作成することができます。

プロジェクトの設定を行います。プロジェクト名のうえで 右クリック → 一番下にある プロパティ を選択します。

プロジェクトの設定画面が出たら、まず左上の構成をDebugにします。開くとわかりますがReleaseという項目があり、同じような設定をもう一度行うことになります。

Debug選択後に左の全般をクリックするとこのプロジェクトの全体的な設定画面が右に表示されます。ここで構成の種類を アプリケーション(.exe) から ダイナミック ライブラリ(.dll) に変更してください。これでビルドの出力が単体実行可能なexeからdllに変更されます。

Debugの設定が終わったら左上のDebugをReleaseに切り替えて同じ設定を行ってください。

DLLにエクスポートする関数を追加

ではエクスポートする関数を実装してみます。まずは以下のような関数を実装します。

int count = 0;

extern "C" __declspec(dllexport) int CountUp() {
	return count++;
}

この状態でビルドしてみましょう。

エクスポートの確認

ちゃんとDLLがエクスポートされているか確認します。
まずはWindowsターミナルを起動します。そしてタブの右側にある v をクリックして新規で立ち上げるターミナルを選びます。
ここでは Developer Command Prompt for VS 2022 を選んでください。

その後DLLのあるフォルダまで cd コマンドで移動します。初期設定のままであればプロジェクトの中にある x68/Debug/ の中にDLLがあるはずです。

移動後に以下のコマンドを実行します。

dumpbin /exports libs.dll

libs.dll はDLLの名前なので自分の作ったものに変更してください。実行すると以下のように結果が返されます。

Microsoft (R) COFF/PE Dumper Version 14.32.31332.0
Copyright (C) Microsoft Corporation.  All rights reserved.


Dump of file libs.dll

File Type: DLL

  Section contains the following exports for libs.dll

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

    ordinal hint RVA      name

          1    0 000112D5 CountUp = @ILT+720(CountUp)

  Summary

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

CountUp という名前が確認できますね?ちゃんとエクスポートされてそうです。

Denoでの取り込みと実行

ではDenoで実際に取り込んでみましょう。

const libs = Deno.dlopen(
  './libs.dll',
  {
    CountUp: {
      parameters: [],
      result: 'i32',
    },
  } as const,
);

console.log(libs);
console.log(libs.symbols.CountUp());
console.log(libs.symbols.CountUp());

これを実行するには deno run --allow-ffi --unstable sample.ts のように --allow-ffi--unstable オプションが必要です。

結果は以下です。

DynamicLibrary { symbols: { CountUp: [Function] } }
0
1

Deno.dlopen() にDLLとそこから取り出す関数を指定すると、{ symbols: { エクスポートした関数群 } } のオブジェクトが手に入ります。
後はいつものJS関数のように呼び出しが可能です。

文字列を渡す

では一番しんどそうな文字列を渡してみます。JS側では文字列として扱いますがこのままではC言語側で扱えないので変換する必要があります。
今回のC言語側では単純な文字の配列を受け取って処理を行いますので、JSの文字列をC言語の文字の配列に変換して渡すこととします。

とりあえず今回は文字の配列と数値をもらい、文字の配列のn番目の値を返す関数を用意してみます。これでDeno側で文字列がちゃんと渡せているか判定しましょう。

extern "C" __declspec(dllexport) int CharCode(const unsigned char *str, int index) {
	return str[index];
}

Deno側では文字列の を渡してみたいと思います。

const libs = Deno.dlopen(
  './libs.dll',
  {
    CharCode: {
      parameters: ['buffer', 'i32'],
      result: 'i32',
    },
  } as const,
);

const str = (new TextEncoder()).encode('あ');

console.log(str);
console.log(libs.symbols.CharCode(str, 0));
console.log(libs.symbols.CharCode(str, 1));
console.log(libs.symbols.CharCode(str, 2));
console.log(libs.symbols.CharCode(str, 3));

最初に書いた通り単なる文字列は渡せないので、TextEncoder を使って単純な文字の配列にします。

結果は以下です。

Uint8Array(3) [ 227, 129, 130 ]
227
129
130
248

0-2バイトまでは正しい答えが帰ってきていますね。次にC言語で扱うところの文字の配列がNULL文字で終わる必要があるため、3バイト目は0にならないといけないです。でも結果がおかしいですね?

これに関してはあくまでバッファを渡しているためNULL文字が入っていません。このままではC側で文字列を扱う時に苦労もあるかと思うので、それを考慮してNULL文字を入れてみます。

const str = (new TextEncoder()).encode('あ\0');

console.log(str);
console.log(libs.symbols.CharCode(str, 0));
console.log(libs.symbols.CharCode(str, 1));
console.log(libs.symbols.CharCode(str, 2));
console.log(libs.symbols.CharCode(str, 3));

結果は以下のように無事C言語で言うところの文字の配列になりました。

Uint8Array(4) [ 227, 129, 130, 0 ]
227
129
130
0

また buffer ではなく pointer が使えるようになったようなのでそちらでも実装してみます。

const libs = Deno.dlopen(
  './libs.dll',
  {
    CharCode: {
      parameters: ['pointer', 'i32'],
      result: 'i32',
    },
  } as const,
);

const strPtr = Deno.UnsafePointer.of((new TextEncoder()).encode('あ\0'));
console.log(libs.symbols.CharCode(strPtr, 0));
console.log(libs.symbols.CharCode(strPtr, 1));
console.log(libs.symbols.CharCode(strPtr, 2));
console.log(libs.symbols.CharCode(strPtr, 3));

文字列を受け取る

逆にC側から文字の配列を返す処理もやってみましょう。

まずは文字の配列を返すコードを書いてみます。

extern "C" __declspec(dllexport) void* GetString() {
	return (void*)"Hello";
}

次に文字の配列を受け取るコードと、それをJSで使える文字列に変換するコードを書いてみます。

const pointer = libs.symbols.GetString();
const str = new Deno.UnsafePointerView(pointer);
console.log(pointer);
console.log(str);
console.log(new Uint8Array(str.getArrayBuffer(6)));
console.log(str.getCString());

ここでのポイントはポインターとして受け取った返り値をJSで使える値に変換する Deno.UnsafePointerView に渡し、そこから自分がほしい値を取り出す作業になります。

例えば文字の配列として解釈して文字列に変換するなら getCString() メソッドを呼び出してやればよいです。実際に結果として Hello が返ってきます。

140721471789184
UnsafePointerView { pointer: 140721471789184 }
Uint8Array(6) [ 72, 101, 108, 108, 111, 0 ]
Hello

次は日本語も返してみましょう!!!

extern "C" __declspec(dllexport) void* GetString() {
	return (void*)"あ";
}
const pointer = libs.symbols.GetString();
const str = new Deno.UnsafePointerView(pointer);
console.log(pointer);
console.log(str);
console.log(new Uint8Array(str.getArrayBuffer(3)));
console.log(str.getCString());

結果は以下です。

140721471789184
UnsafePointerView { pointer: 140721471789184 }
Uint8Array(3) [ 130, 160, 0 ]
error: Uncaught TypeError: Invalid CString pointer, not valid UTF-8
console.log(str.getCString());
                ^
    at UnsafePointerView.getCString (internal:ext/ffi/00_ffi.js:123:18)
    at file:///C:/Personal/GitHub/deno_winapi/sample/sample.ts:24:17

失敗してますね。文字の配列を見てみると 130160 ですがこれはいわゆるWindowsのShift_JIS (CP932)の ですね。
普通にプロジェクトを作ってしまうとこうなるので、これから以下のことをやります。

  • ソースファイルをUTF-8で保存
  • 出力オプションにUTF-8をつける

まずはソースファイルです。ファイル名前をつけて XXX を保存 をクリックします。この時ちゃんと対象のファイル名か確認しましょう。

ここで右下にある上書き保存をクリックして、エンコード付きで保存 を選択します。

その後次のようなダイアログが出るので Unicode(UTF-8 シグネチャなし) - コードページ 65001 を選択してください。
これでファイルがUTF-8で保存できるようになりました。

次にプロジェクトの設定です。プロジェクトの設定を開いた後 構成プロパティC/C++コマンドライン を選択してください。
その後右の一番下にある追加のオプションに /utf-8 を追加します。これで出力がUTF-8になります。

では実行してみましょう。

const pointer = libs.symbols.GetString();
const str = new Deno.UnsafePointerView(pointer);
console.log(pointer);
console.log(str);
console.log(new Uint8Array(str.getArrayBuffer(4)));
console.log(str.getCString());
140721471789184
UnsafePointerView { pointer: 140721471789184 }
Uint8Array(4) [ 227, 129, 130, 0 ]
あ

無事に getCString() で文字列を得ることができました!

まとめ

とりあえずWindowsでDLLを作り、Deno側からの呼び出しや値のやり取りを行いました。文字列が使えれば大体なんでもできるでしょう。

まずやってみて思ったのがやはりネイティブ周りは少し辛いのと、Windowsは標準がShiftJISなのでそこら辺の対応が少し面倒ですね。また型も大幅に違うのでやりくりは大変そうです。
個人的にはDeno側にC側の型(例えば int"i32" にするとか)やC側にDeno側の型(例えば void *pointer にするとか)である程度やっていけばなんとかなるかなと思います。というかunstableな機能なのでバージョンが0.1上がっただけで型の対応が変わったりしているので、型周りもここの記事は参考程度にして https://deno.land/manual/runtime/ffi_api をちゃんと見るようにした方が良いと思います。

何にせよ一度連携できる状態にまでもっていけば試行錯誤できるのでなんとかなると思います。

今後もう少し難しいAPIを使う予定なので、なにか知見があれば追記か別の記事で補足していこうかなと思います。

参考

Discussion