例題21-3:ctypesでコールバック

Note

  • 2014/09/12
    • この例題および例題21-1で取り上げたAPI-USBPライブラリのDIO、AIO部分をPythonから利用するためのパッケージpyAPIUSBPを公開しました( http://pyapiusbp.sourceforge.net/ )。 例題21-4 もご覧ください。

A: ええと、タイトルの通りです。今回は作者がいろいろと追いつめられているので余計な脱線は抜きです。新年度が始まるまでの間に更新するつもりだったそうなんですが、まあいろいろとアレだったようで。

B: ははあ、アレですか。読者の皆さんもぜひ気をつけてほしいですね。

A: まあアレに気をつけろと言われても皆さんが困るだけだと思いますが、ご自愛くださいませ。ホント。

B: というわけでさっそくお題ですが…。

A: おっと、そうそう。ctypesでコールバック。今日の助手は優秀じゃなきゃ困るんで、 さあ今から優秀になるんだ 。そーら、だんだんコールバックのことがわかるよーになーる…

B: なんだかぼーっとしてきました…

A: コールバック…コールバック…ハイッ!

B: はっ、いったい何が?

A: さて、本題である。 例題21-1 でctypesを使ってCONTEC社のUSB接続のIOユニットをpythonから使用したわけだが、この手の機械をぱっと使えるようにしておけばいろいろと便利なので、思い立ってPythonのパッケージを書くことにした。

B: あ、ああ、はいはい。なんでこんなにぼーっとしてるんだろ。

A: で、DIO-0808TY-USBなどのDIOユニットのAPIに対応するモジュールは簡単に出来たんだ。ただDIOだけでは不便なんで、アナログ入出力(Analog Input/Output: AIO)があるAIO-120802LN-USBというのを調達してきてAIOのAPIもサポートしようと思ったんだな。AIO-120802LN-USBはUSB接続で12bitのアナログ入力8チャンネル、アナログ出力2チャンネルに加えて16点のDIOにカウンタまであって、これひとつでいろんなことが出来そうな優れもの。だがこれが思えば災厄であった。

../_images/21-3-01.jpg

B: 災厄?

A: いや、機能が豊富なんだから当たり前なんだけど、APIの関数がやたらと多くてPythonに移植するのが大変なんよ。買ってきて開発キットをインストールしてドキュメントを見たら軽く絶望した。けど薄いPython用のラッパーを作るだけなら頭を使わないんで、本業で煮詰まった時の気分転換にぽちぽち書いていたわけよ。そうしたら出てきたのが…

B: さっきからぶつぶつ言ってるコールバックですか。

A: そう。さすが作者が切羽詰まっていると察しがいいな。まあコールバックの前に少し話しておくことがあるので順番に行くと、こういう関数があるんよ。

ret = AioSetAiEvent (short Id, HWND hWnd, long AiEvent)アナログ入力に関するWindowメッセージ通知のイベント要因を設定します。

  • Id: AioInit関数で取得したIDを指定します。
  • hWnd: Windowハンドルを指定します。
  • AiEvent: イベント要因を以下の範囲からマクロもしくは数値で指定します。

B: …これがなにか?

A: ウィンドウハンドル…CreateWindow…

B: (焦点があわない目で)…ああ…、ウィンドウハンドルですね。ウィンドウハンドルはどうやって取得すればいいんでしょう? 一般的なWindowsアプリケーションならCreateWindows関数でウィンドウを作成した時に戻り値にウィンドウハンドルが得られますが、Pythonのスクリプトの場合はどうなるんでしょう?

A: うむ。あ、ご存じない読者の方向けに補足しておきますと、ウィンドウハンドルというのはWindowsが現在開いているウィンドウを管理するために、個々のウィンドウに付けている番号だと思ってください。便宜上「開いている」と言いましたが、最小化されていたりするウィンドウなどにもすべてハンドルがあります。このハンドルを通じてウィンドウを描画しなおしたり、ドラッグ&ドロップでウィンドウにファイルを渡したりとかすることが出来るんですね。

B: …(まだぼーっとしている)

A: で、このウィンドウハンドルですが、当然ウィンドウを開くアプリケーションにしかありません。Pythonのスクリプトは別にウィンドウを開きませんので困ったわけです。

B: …ああ、なるほど…。

A: それでこの関数を移植するのはとりあえず保留することにしたんですが、このウィンドウハンドルの問題についていくつか触れておきたいと思います。まずはTkinter。Tkinterを使えばPythonでもウィンドウを開くことが出来るわけですが、こいつでウィンドウハンドルを得るにはwm_frame()というメソッドを使います。Tkinter window handleで検索するとwinfo_id()というメソッドがひっかかることがあってややこしいのですが、Windows上でTkinterを使ってウィンドウを開いた場合、一番外側のウィンドウのハンドルを得るにはwm_frame()でなければいけません。最初これがなかなかわからなくてハマりました。

B: ふむふむ。

A: 他にはctypesを通じてWin32 APIを使う方法があります。Win32 APIのFindWindow()を使うと、ウィンドウ名やウィンドウクラスからウィンドウハンドルを得ることが出来ます。

ret = FindWindow (LPCTSTR lpClassName, LPCTSTR lpWindowName)指定された文字列と一致するクラス名とウィンドウ名を持つトップレベルウィンドウ( 親を持たないウィンドウ)のハンドルを返します。詳しくはMSDNの解説ページ参照( http://msdn.microsoft.com/ja-jp/library/cc364634.aspx )のこと。

  • lpClassName:ウィンドウクラス名またはクラスアトム。
  • lpWindowName: ウィンドウ名。

B: どうやって使うんですかね。

A: 簡単なサンプルを書いてみた。Tkinterで作成したウィンドウには初期状態でtkという名前が付いているので、FindWindowを使ってtkという名前のウィンドウを探しています。

1
2
3
4
5
6
7
8
9
import Tkinter
import ctypes

root = Tkinter.Tk()
root.update()

print 'winfo_id:', root.winfo_id()
print 'wm_frame:', int(root.wm_frame(),16)
print 'FindWindowA:', ctypes.windll.user32.FindWindowA(None,'tk')

B: 実行してみました。なんか数字が出てきましたが。

winfo_id: 1967606
wm_frame: 1770772
FindWindowA: 1770772

A: うむ。ここに出てきた数値がウィンドウハンドル、Windowsが各ウィンドウを管理するために付けている番号だ。4行目で空っぽのTkinterのウィンドウを作成し、5行目でupdate()して表示している。続いて7行目、8行目でTkinterのウィンドウのwinfo_id()とwm_frame()を実行してウィンドウハンドルを取得している。両者の値が異なることに注目してほしい。そして、 wm_frameとFindWindowAの結果が一致している点に注目 。つまり、Win32 APIで使用するウィンドウハンドルはwm_frameから得られる。

B: 確かにwinfo_idとwm_frameは全然違う値ですね。ところで7行目にint()関数を使っているのは?

A: wm_frame()はウィンドウハンドルを16進数表記の文字列として返すので、int()で第2引数に16を指定して10進数に変換している。

B: へえ、じゃあint(hoge, 8)とかにしたら8進数を10進数に変換してくれるんですか?

A: もちろん。

B: そりゃ便利ですね、覚えておかなければ。メモメモ…

A: そして9行目はctypesでFindWindowを呼び出している。Win32APIはWindowsユーザーにとっては非常によく使うものなので、いちいちLoadLibraryとかしなくてもwindllの名前空間に読み込まれている。FindWindowはUser32.DLLというDLLに収められているので、呼び出すにはctypes.windll.user32.FindWindowAと書く。どのDLLに収められているのか知っていないといけないのでWin32APIでのプログラミング経験がない人は最初苦労するかもね。

B: 経験がない人はそもそもFindWindow自体を知らないんじゃ…

A: おっと、これはB君に一本取られたな。

B: ところでさっきからFindWindowAの最後のAが気になっているんですが、これはなんですか?

A: あー。これはもうドツボの世界でな。WindowsのシステムがUnicodeに対応した時に、従来の文字コードを使用するWindowsプログラムに対応するために、二種類の関数が用意されているのだ。FindWindow W という具合に最後にWがついたらUnicode版、FindWindow A という具合にAがついたら従来版の関数が呼び出される。VisualStudioなどでプログラムを作成する時には、単にFindWindowと書けばプロジェクトの設定に応じてコンパイラが自動的に適切な関数をリンクしてくれるので意識する必要がないんだが、今回のようにPythonから直接DLLの中に手を突っ込んで利用する場合にはどちらの関数を使うのか明示しなければいけない。

B: Unicode版はなんでUじゃなくてWなんでしょう。それに従来版も。

A: Wは「ワイド文字列」のWだな。従来版は「マルチバイト文字列」というんだが、これがAなのはASCIIコードのAに由来してるのかなあ。知らん!

B: はあ。まあ別にどうでもいいですが。

A: どうでもいいなら聞くな。さて、これでTkinterでウィンドウハンドルを得る方法を説明したわけだが、正直このハンドルを使ってCONTECのAIOユニットをコントロールできるかどうか試すのが 面倒くさい 。まあやってみると案外あっさり動くかもしれんが、動かなかった時のショックが大きすぎるのでとりあえず保留。

B: あー、出たよ。いつものパターンですな。

A: なんでさっさと断念したかというと、AIOユニットのAPIにはウィンドウハンドルを使わなくてもイベントを処理する関数が用意されていたからなんですな。ああ、なんて 素敵 なんだ!

B: (あきれ顔) …で、どういう関数なんですかそれは。

A: うむ。それが今回のメインテーマ、コールバックなのだよ!

B: はあ、コールバックですか。あれ? コールバックってなんだ? どこかで聞いたような… あれ?

A: コールバック…割込み…非同期処理…

B: (また焦点が合わない目で)…ああ…、割込みですね…。非同期的にデータ入力処理をするときの。イベントがあれば割込みが生じて…コールバック関数が…

A: そーそー。いやー、素晴らしいねB君。話が早い。キーボードからの入力を待つ時のように、いつ「キーが押された」というイベントが生じるかわからない場合、メインの処理を行いながら、定期的にキーが押されたか確認する処理を行わなければいけない。下の図の「割り込みを使わない場合」の「メインの処理」の矢印がまだらになっているが、灰色の部分が実際にメインの処理を行っている部分、赤い部分がキーが押されたか確認している部分。キーの確認にかなりの時間をとられているのがわかると思う。

../_images/21-3-02.png

B: こりゃひどいですね。半分くらいはキー押しの確認じゃないですか。もっと確認の間隔をあけたらいいんじゃないですか?

A: そうすると今度はキーが押されてからプログラム上で検出されるまでの遅れが大きくなってしまうので操作性が悪くなってしまう。ちなみにこれは 例題11などで繰り返し触れてきた反応時間計測の遅延の問題と本質的に同じ であることに注意してほしい。

B: へへん。そのくらい察しがつきましたよん。

A: そりゃ何より。でも大事なので強調しておいた。一方、「割り込み」というのは、キーが押されたときに割込み信号というのが発生して、メインの処理が中断されてあらかじめ登録されていた処理が呼び出される。この呼び出される処理をひとつの関数として記述したものをコールバック関数と呼ぶ。コールバック関数が終了したら自動的にメイン処理に戻る。割り込みを使わない場合と比べて非常に効率がいい。

B: なるほど。出前のピザが早く届かないかなあと思って何度も部屋と玄関を往復するとなかなか作業が進まないのと同じことですね。割り込みは配達員が鳴らすチャイムで、チャイムがなるまでは集中して作業すれば効率がよい、と。なるほどなるほど。今度からなるべくじっくりとチャイムを待つことにしよう。

A: …いつもは部屋と玄関を往復してるんか。やれやれだな。

B: 放っといてください。

A: で、この割り込みを登録するのがAioSetAiCallBackProcという関数。

ret = AioSetAiCallBackProc (short Id, long* CallBackProc, long AiEvent, void* Param)アナログ入力に関するイベントが発生した際に呼び出されるコールバック関数を登録します。

  • Id: AioInit関数で取得したIDを指定します。
  • CallBackProc: コールバック関数のアドレスを指定します。
  • AiEvent: イベント要因を以下の範囲からマクロもしくは数値で指定します。
  • Param: コールバックに渡すパラメータのアドレスを指定します。

A: で、続いてコールバック関数の定義。

ret = CallBackProc (short Id, short Message, WPARAM wParam, WPARAM lParam, void* Param)

  • Id: AioInit関数で取得したIDが渡されます。
  • Message: コールバック関数呼び出しの原因となるメッセージ番号が渡されます。
  • wParam: 現在は使用されません。
  • lParam: イベントに固有のパラメータが渡されます。
  • Param: コールバックに渡すパラメータのアドレスを指定します。

B: …あまりにもわからなさすぎて、どこから質問したらいいかすらわかりません。wParamは「現在は使用されません」って、何それ?

A: 将来的な拡張を見据えて予備として確保しているとか、過去のバージョンとの互換性を保つためとか、そういう理由で使われない引数があるってのはこの手のライブラリには良くあることなので気にするな。

B: はあ。そんなもんですか…。

A: さて、例題21-1や例題9-1、9-2を見て「ctypes案外簡単じゃん」と思った人でも、この関数をどうPythonに移植したらいいかは戸惑うかも知れないな。C++でコールバック関数を書いたことがあればイメージしやすいんだけど。

B: …(気を失いつつある)

A: B君もこの辺が限界か。じゃあここからは一人で頑張るとするか。まず、イベントが生じたときに行う処理を決めなければならない。ここでは単に「呼び出しの原因となるメッセージ番号」を16進数で表示することにしよう。コールバック関数の第2引数Messageの値を表示するだけだね。コールバック関数の引数に注意しながらPythonの関数を書く。とりあえず引数の個数が一致していることと、関数内で引数を参照する時に、どの引数にどのような値が格納されているかに気を付けて。関数名は自由につけても良いし、引数名も自由につけて構わない。もっとも、リファレンスに書いてある名前と一致させておいた方が混乱しなくていいと思う。

def showMessageId(deviceId, message, wparam, lparam, param):
    print hex(message)
    return 0

A: ctypesを使ってこのshowMessageIdをコールバック関数として登録したいんだけど、ctypesは当然それぞれの引数や戻り値の型がなんであるのかを知らない。そこで、ctypesにこのコールバック関数はこんな引数と戻り値を持つんだよと教えてやらなければいけない。この関数の引数と戻り値の定義を プロトタイプ というんだが、プロトタイプの定義にはctypes.WINFUNCTYPEを使う。正確に言うと、stdcall呼び出し規約に基づく場合はctypes.WINFUNCTYPEを使って、cdecl呼び出し規約に基づく場合はctypes.CFUNCTYPEを使うんだが、今回の関数はstdcallだ。WINFUNCTYPEの第一引数は戻り値の型で、第二引数以降はコールバック関数の引数の型を順番に記述する。今回の場合、コールバック関数として登録したいshowMessageIdは5つの引数を持つのだから、戻り値と合せて6つの引数を持つ。

callbackPrototype = ctypes.WINFUNCTYPE(ctypes.c_long,
                          ctypes.c_short,
                          ctypes.c_short,
                          ctypes.c_int,
                          ctypes.c_int,
                          ctypes.c_void_p)

A: ここでちょっとズルをしているのはwparamとlparamの型。リファレンスにはこれらの型はWPARAMであると書いてあるんだけど、C#用のリファレンスを見るとintになっていたのでintで代用した。とりあえずのテストでは一応動作したけど、もしかするとちゃんとWPARAM型にする必要があるかも知れない。ちなみにctypesにはwintypeというモジュールがあり、そこにWPARAMやHWND、RECTといったWin32APIを使う上で必須の型がctypesように定義されている。Windows7(x64)で確認したところ、ctypes.wintypes.WPARAMはctypes.c_ulongの別名になっている。

A: さて、コールバック関数のプロトタイプが出来たら、これを使ってただのPythonの関数に過ぎないshowMessageIdを、ctypesで扱えるコールバック関数に変換する。作業は簡単で、単にプロトタイプの引数にPythonの関数を与えればよい。

callback = callbackPrototype(showMessageId)

A: ここまでくればもう一息。AioSetAiCallBackProcを実行する準備をしよう。まずはAIO用のDLLであるcaio.dllを例題21-1と同様の手順で読み込む。そしてデバイス名(ここではAIO000)を指定してAioInit()を実行。デバイスのADを参照渡しで受け取る。これで準備OK。

caio = ctypes.windll.LoadLibrary('caio.dll')
deviceID = ctypes.c_short()
caio.AioInit('AIO000', ctypes.byref(deviceID)

A: そしていよいよAioSetAiCallBackProcを使ってコールバック関数を登録だ。第1引数は先ほど取得したデバイスID,第2引数にcallback。第3引数の0x0020はAPIに付属のリファレンスに掲載されている定数。第4引数のNoneは特に何も渡すパラメータがないのでNone。以上、おしまい!

caio.AioSetAiCallBackProc(deviceID, callback, 0x0020, None)

B: ぅうーん。あれ、Aさんどうしたんですか、すがすがしい顔して。

A: ああ。今回の解説が終わったところだ。さ、もう新年度になったことだし仕事が山積みだ。さっさと次の仕事に移るか。

B: へっ? もう終わったんですか。って、あれ…、今日は何の話をしたんでしたっけ。あれ、どこまで聞いたかな…。

A: ふふっ、疲れてるんじゃないかね。今日は柏屋の薄皮まんじゅうを持ってきておいたからそれでも食べて休みたまえ。じゃあねー。

B: (饅頭の箱を開けながら) うーん、Aさんの話が全然思い出せないぞ、なんで今日はこんなにぼーっとしてるんだ…?