以前 グローバルキーフックでキーの捕捉と入力を行う なんて記事を書きましたが、.Net 6への移行を進めていると64bitで動作しないことが判明しました。
結論から言うとデータ構造アライメントのせいなのですが、その際の対応と何故起きたのかを考察してみようと思います。
検証環境
項目 | 詳細 |
---|---|
OS | Windows 10 Pro x64 |
CPU | Intel Core i7-10700K |
.Net | .Net 6 |
C++ | Visual C++ 2022 |
対応
問題点
まず、問題点ですがSendInput関数に渡すキーやマウスなどの入力情報を管理する構造体です。
Cでは以下のような構造体定義になっています。
typedef struct tagINPUT { DWORD type; union { MOUSEINPUT mi; KEYBDINPUT ki; HARDWAREINPUT hi; } DUMMYUNIONNAME; } INPUT, *PINPUT, *LPINPUT;
素直にC#側に実装するとこうなります。
前回のサンプルもKimamaKeyConverterでもこの実装にしています。
[StructLayout(LayoutKind.Explicit)] public struct INPUT { public int type; [FieldOffset(4)] public MOUSEINPUT no; [FieldOffset(4)] public KEYBDINPUT ki; [FieldOffset(4)] public HARDWAREINPUT hi; };
これを32bitビルドで動かすと上手く動作しますが、では64bitにするとどうでしょう。
動かないですね。
コレは後ほど述べますが、構造アライメントの都合で64bitではINPUT構造体内のMOUSEINPUTなどの構造体の位置が正しくないため動作しません。
対応策
理由はわかったと、ならどうするか。
一番簡単なのは32bitビルドだけにとどめておくことですが、この壁に悩んでるならそういうわけにもいかないでしょう。
ということでSequential構造体とExplicit構造体を別々に定義するとスマートに対応できそうです。
[StructLayout(LayoutKind.Sequential)] public struct INPUT { public int type; public UNION_INPUT input; }; [StructLayout(LayoutKind.Explicit)] public struct UNION_INPUT { [FieldOffset(0)] public MOUSEINPUT no; [FieldOffset(0)] public KEYBDINPUT ki; [FieldOffset(0)] public HARDWAREINPUT hi; }
この方法だとオフセットを相対的に指定することができるのでアライメントをプログラマ側で意識する必要がありません。
また、この方法では実行時のプラットフォームに応じてオフセットが調整されるのでAny CPUでも動作します。
原因
データ構造アライメント
構造体の各メンバ位置を調べる
最初に構造体の各メンバのバイト位置を調べる方法です。
stddef.hにoffsetofマクロが定義されているため、これを用いて以下の調査ではバイト位置を調べています。
//C #include <stdio.h> #include <stddef.h> struct A { char c; int i; } int main(void) { printf("A\n c = %zu\n", offsetof(A, c)); printf(" i = %zu\n", offsetof(A, i)); printf("size: %zu\n", sizeof(A)); }
簡単な例
では原因究明していきたいのですが、まず大前提なのがデータ構造アライメントです。
データ構造アライメントとはCPUに応じて構造体内部の各メンバ位置を調整する事を言います。
例えば、次のような構造体があるとしましょう。
// C struct A { char c; int i; short sh; int j; };
これがメモリに乗る時すべて数珠繋ぎに並ぶかというとそうではなく、各メンバ間に隙間が生まれます。
このような隙間のことをパディングと呼びます。
CPUではメモリアドレスを読み書きする際はワードサイズ単位で行われます。
32bitなら4バイト、64bitなら8バイト単位で読み込まれます。
この時数珠繋ぎなら以下のようになりますが、例えばA.iを読み込む際はワード単位なため32bitでは1-4バイト目と4-5バイト目の値を別々で読み込み、組み合わせる処理が必要なため、処理効率が悪くなる場合があります。
そこでワード単位に収まるようにパディングを埋めることで変数へのアクセスを最小限で済むように調整するのがデータ構造アライメントです。
これならA.iを読み込む時に1回の読み込みで済み、2回に分けて組み合わせる必要がありません。
アライメントが正しく行われていないと64bit型なんて最悪3回読み込みが発生する場合があるのでかなり効率が悪いですね。
また、CPUによっては分けて読み込むことができないものもあるため、アライメントが行われないとクラッシュする場合もあるそうです。
ちなみに、直接関係はありませんが、32bitビルドしたアプリケーションでは64bit型のアクセスは2回に分けて行われます。
INPUT構造体
ではデータ構造アライメントについてある程度わかったところでINPUT構造体のアライメントを調べてみましょう。
まずは32bitのレイアウトから。
32bitではすべてが4バイト中に収まるので調整は行われずに配置されます。
というより、調整が行われないような適切な宣言がなされているとも言えますね。
なのでC#のFieldOffsetは4バイトで問題なく動きます。
続いて64bitです。
64bitではMOUSEINPUT構造体のdwExtraInfoがポインタなので8バイト(64bit)になります。
その都合で4-8バイトと28-32バイトがパディングで調整されます。
また、KEYBDINPUT構造体のdwExtraInfoもポインタなのでここも20-24バイトがパディングで調整されます。
具体的なアルゴリズムが不明ですが、構造体内に4バイト/8バイトとサイズが変わるような型の変数があり、その構造体がunionで別の構造体に含まれる場合どうやらパディング(4-8バイトのような)が入るようです。
この例だとパディングなしでも8バイトに収まるように思えますが、恐らく先にメンバにもつ構造体(例えばMOUSEINPUT構造体)をパディング調整してから親の構造体(INPUT構造体)をパディング調整するため、このようなパディング配置になっているのだと思います。
このことから、64bitビルドにおけるC#のINPUT構造体のFieldOffsetは8バイトにする必要があります。
[StructLayout(LayoutKind.Explicit)] public struct INPUT { public int type; [FieldOffset(8))] public MOUSEINPUT no; [FieldOffset(8)] public KEYBDINPUT ki; [FieldOffset(8)] public HARDWAREINPUT hi; };
しかしながら、これではx86/x64シンボルを定義してさらに条件でオフセットを変えないといけないので結構手間ですし、Any CPUが使えません。
そこで、冒頭のようにExplicit用の構造体を定義し、その構造体をSequential構造体内に定義することでオフセットをCLRに調整してもらうようにします。
そうするとわざわざシンボルで条件分けする必要がなくなります。
[StructLayout(LayoutKind.Sequential)] public struct INPUT { public int type; public UNION_INPUT input; }; [StructLayout(LayoutKind.Explicit)] public struct UNION_INPUT { [FieldOffset(0)] public MOUSEINPUT no; [FieldOffset(0)] public KEYBDINPUT ki; [FieldOffset(0)] public HARDWAREINPUT hi; }
パッキング
今回はそこまで関係ありませんが、データ構造アライメントをプログラマが明示的に指定することが可能です。
パッキングを指定すると指定したバイト数にアライメントされるようになり、最低値でパディングが挟まらないようにすることもできます。
以下のような構造体があるとして、32bitでビルドするとします。
// C struct AD { char c; int i; short sh; long long l; }; #pragma pack(push, 1) struct A1 { char c; int i; short sh; long long l; }; #pragma pack(pop) #pragma pack(push, 2) struct A2 { char c; int i; short sh; long long l; }; #pragma pack(pop) #pragma pack(push, 4) struct A4 { char c; int i; short sh; long long l; }; #pragma pack(pop)
これらの構造体は次のようなレイアウトになります。
A1は1バイトパッキング指定でパディングが挟まれず、定義したとおり数珠つなぎに配置されます。
64bit型とか前述したとおり、3回読み込みが行われるのも見てわかるとおもいます。
A2は2バイトパッキング指定なため、2バイトに収まるか2の倍数バイト開始になるように調整されます。 そのため、cとiの隙間に1バイト分のパディングが挟まれます。
A4は4バイトパッキング指定で、4バイト単位になるように調整されます。
また、今回は出していませんが、8, 16バイトパッキングもあります。
32bit (x86, ARM, ARM64)ではVC++の既定値だと8バイトなのでADは8バイトパッキングと同等になります。
このようにパッキングを指定することで任意の単位でパディングを挟むようにできます。
パフォーマンスが犠牲になりますが、メモリが少ないような環境ではパディング分を利用できるので無駄がなくなるメリットがあります。
しかしながら、大体は構造体の定義の仕方でも調整ができるので使う場面ってそんなないような気がしますが。
あるとすればクロスプラットフォームで型のサイズがビルド環境によって変わる場合でしょうか。
なお、パッキング指定した構造体をC#で使用する場合はC#側でもパッキングの指定が必要になる場合があります。
[StructLayout(LayoutKind.Sequential, Pack = 2)] struct A2 { public byte c; public int i; public short sh; public long l; };
余談 IntPtrとint
本来はポインタの記事でも作って載せるべき話ですが、いい機会なのでついでに。
前回のサンプルではMOUSEINPUT構造体とKEYBOARDINPUT構造体のdwExtraInfoメンバをintで定義していました。
[StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { ... public int dwExtraInfo; }; [StructLayout(LayoutKind.Sequential)] public struct KEYBDINPUT { ... public int dwExtraInfo; };
本来ならポインタなのでIntPtrにするところですが、どうしてintで動いていたのでしょう。
ポインタはメモリアドレスを保持し、間接参照を行う事ができるもので特別なものと思えますが、その実態は値型と同じくただの数値です。
#include <stdio.h> // C int main(void) { int i = 1; int* p = &i; long long iaddr = (long long)&i; long long pValue = (long long)p; i = 2; *p = 3; printf("%p %p %lld %lld\n", &i, p, iaddr, pValue); }
00000027006FF9B4 00000027006FF9B4 167511062964 167511062964 // 00000027006FF9B4 = 167511062964
このコードでは変数iのアドレスとポインタpの保持するアドレス、そして変数iのアドレスを値型に入れたものとポインタの実値を値型に突っ込みました。
表示がHEXとDECで異なりますが、どちらも同じ数字となります。
つまり、ポインタと値型はどちらもメモリの実値をただただ読み込み、それをアドレスと見なすか値と見なすかの違いしかありません。
int i = 1; 00007FF74D691D8B mov dword ptr [i],1 int* p = &i; 00007FF74D691D92 lea rax,[i] 00007FF74D691D96 mov qword ptr [p],rax long long iaddr = (long long)&i; 00007FF74D691D9A lea rax,[i] 00007FF74D691D9E mov qword ptr [iaddr],rax long long pValue = (long long)p; 00007FF74D691DA5 mov rax,qword ptr [p] 00007FF74D691DA9 mov qword ptr [pValue],rax i = 2; 00007FF74D691DB0 mov dword ptr [i],2 *p = 3; 00007FF74D691DB7 mov rax,qword ptr [p] 00007FF74D691DBB mov dword ptr [rax],3
Visual Studio 2022の逆アセンブルですが、ポインタへの代入も変数への代入も同じ命令で処理されています。
唯一の違いはどの変数(レジスタ)かとサイズだけです。
このことから、dwExtraInfoは型のサイズさえ一致していれば動作します。
あとはそこに入れる値が値型なのか参照型なのかで変わってくるわけですね。
しかしながら、64bitではポインタサイズが64bitになってますから、intでは動作せず、longなどの64bit型にする必要があったりします。
...
なんてここまで書いてきましたが、intやlongなんて使わないでIntPtr(UIntPtr)にしておきましょう。
IntPtrにしておけば32bitでも64bitでもCLRが勝手にサイズ変えてくれるし、そもそもポインタだからそのままIntPtrにしておけばOK。
値として取り出すことも可能だし値型を使う意味が全く無いです。
じゃあなんであんなの書いてたかと言うと、当時の私がちゃんと理解してなかったからですね。
今見たらかなりひどい記事で我ながら笑ってしまう。
ってことで今更ながら訂正しておきます。