nptclのブログ

Common Lisp処理系nptの開発メモです。https://github.com/nptcl/npt

nptでDLLファイルを読み込む

1. はじめに

nptのWindows版で、DLLを読み込む機能を作りました。
FFIでは無いので注意。
npt用にDLLを作成すると読み込めるというものです。
【追記】soも作りました! nptでsoファイルを読み込む - nptclのブログ

npt for Windowsは、なんとなくうまく動いているように見えます。
さらにDLLを呼び出せるようにできれば、 Windowsで簡単な仕事をさせることができるのではと思いました。

もうちょっとウィンドウの操作をちゃんと作ればいいような気がしています。
履歴とかコピーとか、あとプロンプトの操作もアップデートして行きたいです。

2. DLLの作成

まずはDLL作成から。
下記のファイルを用意してください。

  • npt/develop/dlfile/
    • lispdl.c
    • lispdl.h

これらのファイルを用いて、DLLを作成していきます。
Visual Studioでもgccでもなんでも良いので、 DllMainを作成できる環境を用意して下さい。

注意点を先に示しますが、「呼出規約」をnpt本体と合わせる必要があります。
Windows上では、だいたいcdeclstdcallの二種類の呼出規約が混ざって存在しています。
呼出規約ってなんなのって話ですが、 関数を呼ぶときと呼ばれるときにどういう動きにするかを CPUの動作レベルで決めたインターフェイスの事です。

ちょっと詳しく説明します。
Microsoftが提供しているCコンパイラCL.EXEだと思うのですが、 このコンパイラで何も指定をしなかった場合はcdeclが使用されます。
バイナリで配布しているnpt32.exenpt64.execdeclです。
nptがcdeclならDLLで公開する関数もcdeclにしてください。

ネットを色々見ると、DLLの関数はみんなstdcallか あるいはCALLBACKみたいなマクロを用いています。
そうする理由は、Windowsが標準で提供してるAPIが全部stdcallだからです。
CALLBACKとかWINAPIなどのマクロも確か全部stdcallのはず。
だからDLLはstdcallしか作れないのかとばかり思ってました。
どうもそんなことはないようです。
cdeclで公開してください。
それが嫌ならば、npt本体をstdcallで作成し直してください。
(もしかしたらnptをstdcallで作るの無理かも)

cdeclstdcallは一見して似通っているので 適当に設定しても動くことがあります。
でも、これがとんでもないバグというか、問題を引き起こします。
絶対に合わせましょう。

ついでに言っておきますが、ビット数も合わせてください。
nptが64bitなら、DLLも64bitでコンパイルする必要があります。

それでは、簡単な例を示します。

#include "lispdl.h"
#include <Windows.h>

BOOL WINAPI DllMain(HINSTANCE hInst, DWORD fdwReason, LPVOID lpReserved)
{
    switch (fdwReason)    {
    case DLL_PROCESS_ATTACH:
    case DLL_PROCESS_DETACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
        return TRUE;
    }
}

__declspec(dllexport) int __cdecl lisp_dlfile_main(lisp_dlfile_array ptr)
{
    return lisp_dlfile_update(ptr);
}

DllMain関数は何もしてないので説明は不要かと思います。

lisp_dlfile_main関数は、nptが真っ先に呼び出す初期化用の関数です。
呼出規約を__cdeclで指定しています。
先ほど説明した通り、npt本体がstdcallの場合は、__stdcallを指定してください。
この関数でやることは、lisp_dlfile_update関数を呼び出すだけです。
lisp_dlfile_update関数は、呼び出し元のnpt環境から色んな機能をDLL内部に持ってくる命令です。
後で説明しますが、nptはDLLを読み込むと まずはlisp_dlfile_main関数を呼び出そうとします。

【参考】以前はlisp_dlfile_mainではなく、lisp_dllmainという名前でした。
【参考】いちおうlisp_dllmainでも、今のところ動きます。

これでDLLを作成する準備ができました。
なにか作ってみましょう。

#includeしているlispdl.hは、 amalgamationが提供するlisp.hのDLLバージョンなので、 nptの説明書に記載されている方法で開発できます。
単に"Hello"という文字列を返却する関数hello_を作ってみます。
公開する関数は、必ず脱出関数にしてください。

__declspec(dllexport) int __cdecl hello_(addr rest)
{
    addr control, x;

    lisp_push_control(&control);
    x = Lisp_hold();
    lisp_string8_(x, "Hello");
    lisp_set_result_control(x);

    return lisp_pop_control_(control);
}

順に説明します。
__declspec(dllexport)は、DLLで公開するための修飾。
intは、脱出関数なので返却値がint
__cdeclは呼出規約。
hello_は関数名であり、何でもよいです。
(addr rest)の引数は、addrを一つにしてください。

引数はいくつかの形から選ぶことができるのですが、 ここではあんまり説明しません。
作成したhello_関数の内容は、だいたい次のような感じになります。

(defun hello_ (&rest rest)
  "Hello")

あと今更ですが__declspec(dllexport)ではなく defファイルを用意する方法もあるらしいです。
詳しくは知りません。

以上でDLLファイルが作成できるはずです。
ここではaaa.dllという名前で作成したことにして話を進めます。

3. nptで実行

npt for Windowsを起動してください。
たぶんコマンドプロンプトのnptでも問題ないです。
あと、同じディレクトリにaaa.dllをコピーしてください。

まずは操作が面倒なのでnpt-systemパッケージをuseします。

* (use-package 'npt)

最近、npt-systemnptというnicknameを付けました。
けっこう便利です。

DLLを読み込むには、dlfile関数を使用します。
dlfileの第一引数にopenを指定します。

* (dlfile 'open "aaa.dll")    ;; ★注意
#<PAPER 0 ...>

dlfileopenは、指定したDLLファイルを読み込みます。
DLLファイルと認識できたら、最初にlisp_dlfile_main関数を探して呼び出します。
もしlisp_dlfile_main関数が存在しなかった場合は例外が発生します。
またlisp_dlfile_main関数を実行した結果、0以外が返却された場合も例外です。
全てが成功した場合は、PAPERオブジェクトが返却されます。

この返却されたオブジェクトがないと何もできませんので、 何かの変数に代入してください。

* (setq x (dlfile 'open "aaa.dll"))
#<PAPER 0 ...>

DLLから関数を呼び出すためには、まずは関数を探します。
次のように実行してください。

* (setq y (dlfile 'call x "hello_"))
#<PAPER 0 ...>

DLL内にhello_という関数が存在しなかった場合は例外が発生します。
成功した場合は、PAPERオブジェクトが返却されます。
openの時と同様、何かの変数に格納してください。

注意してほしいのは、返却値は関数ではないということです。
呼出しを行う場合は、dlcall関数を使用します。

* (dlcall y)
"Hello"

DLLで作成したコードが実行されたことが分かります。

DLLを開放する処理もあります。
dlfilecloseを指定してください。

* (dlfile 'close x)

これでDLLは解放されます。
このときdlcallyを呼び出すと例外が発生します。

dlfiledlcallによって返却されたオブジェクトは、 そのプロセス限りの使い捨てだということを覚えておいてください。
返却値はPAPERオブジェクトなのでcoreファイルやfaslファイルで保存できます。
しかし保存されたものを使用しても正しく動作はしませんし、 おそらくはプロセスが壊れます。

3. 引数の形

後で仕様を変更するかもしれないので暫定ですが、引数の形を指定できます。
標準ではextend-restという型であり、 dlcallに渡された引数がすべてhello_の第一引数に指定されます。

例えば、

* (dlcall y 10 20 30)

として呼び出した場合は、DLL関数の

__declspec(dllexport) int __cdecl hello_(addr rest)
{
    ...
}

引数restに、(10 20 30)のコピーが渡されます。
本当はコピーではなく、内部のデータをそのまま渡したかったのですが、 lisp.hの関数がdynamic-extentのデータに対応してなかったので、 仕方なくコピーしました。
暫定と言っているのはこういう部分です。

変更した例を示します。
まずはDLL関数から。

__declspec(dllexport) int __cdecl arg2_(addr x, addr y)
{
    if (lisp_stdout8_("Value1: ~S~%", x, NULL))
        return 1;
    if (lisp_stdout8_("Value2: ~S~%", y, NULL))
        return 1;
    lisp_set_result_control(Lisp_nil());
    return 0;
}

この関数を取得するときに、extend-var2引数を指定します。

* (setq x (dlfile 'open "aaa.dll"))
* (setq y (dlfile 'call x "arg2_" 'extend-var2))

実行は次の通り。

* (dlcall y 10 20)
Value1: 10
Value2: 20
NIL

extend-var2は、引数が2つでないとエラーが発生します。
型にどのような種類があるのかは、process_calltype.cに書かれていますが、 extend-restという名前も不格好であり後で変えるかもしれません。