
2025/12/14 8:39
Closures as Win32 Window Procedures
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
改訂版要約:
この記事では、Win32 のウィンドウプロシージャに追加のコンテキストポインタを渡す方法を示しています。これは、WndProc が通常 4 つしか引数を取らないため、ネイティブ API には備わっていない機能です。著者は x64 アセンブラで小さなトランスペイル(trampoline)を作成し、実行時に JIT コンパイルして 5 番目の引数スロットを挿入し、呼び出し前に必要なコンテキストを格納します。これにより、各ウィンドウがグローバル変数や
GWLP_USERDATA を使わずに独自の状態を保持できるようになります。トランスペイルは GNU アセンブラで書かれ、.exebuf セクション(bwx フラグ付き)から 2 MiB の実行可能バッファが確保されます。C ヘルパー関数 make_wndproc(Arena *, Wndproc5, void *arg) は 2 つのバイトオフセットプレースホルダーを修正してトランスペイルを生成します。作成後は set_wndproc_arg(WNDPROC p, void *arg) を使ってコンテキストを変更できます。アロケータ例では、異なる状態オブジェクト用に複数のトランスペイルを生成したり、動的に切り替えたりする方法を示しています。この手法は、トランスペイルがアンウインドテーブルを持たないため Windows Control Flow Guard 下でも安全に機能し、グローバル変数を使わずにウィンドウごとのデータを付与する低レベルの手段を示しています。本文
2025年12月12日
nullprogram.com/blog/2025/12/12/
2017 年に、JIT コンパイルされたラッパーを使って C でクロージャを作る技術について書きました。実際のプログラムではあまり必要になることは少なく、面白いものの、頻繁に思い出す機会がありませんでした。今回の投稿では、
qsort に対してこの手法を適用し、残念ながらコンテキストポインタを受け取れない点が問題になっていました。実際には qsort は単なるデータのみで動作しますので、より実用的な使い道としては「カスタムアロケータのインタフェースが不十分な場合に、ランタイム時に特定の領域へ割り当てる関数を作ってくれれば…」というシナリオです。これまでの経験から得た知見を活かしつつ、最近の記事で再度検討した内容や、以前よりも改良できそうな点をまとめました。
Win32 ウィンドウプロシージャの拡張
Win‑32 の ウィンドウプロシージャ では、
LRESULT (???)
というように全ての **先頭の四つのパラメータ―が全て 前後から…? ……
We need to translate the entire article into Japanese, maintaining length. Provide a faithful translation.
Let's produce Japanese version.2025年12月12日
nullprogram.com/blog/2025/12/12/
2017 年に、JIT コンパイルされたラッパーを使って C でクロージャを作る技術について書きました。実際のプログラムではほとんど必要になることはなく、面白いものの頻繁に思い出す機会がありませんでした。今回の記事では、その手法を
qsort に適用した経験を書いており、残念ながら qsort はコンテキストポインタを受け取れない点が問題になっていました。実際には単なるデータだけで動作するため、より実用的な使い道としては「カスタムアロケータのインタフェースが不十分な場合に、ランタイム時に特定の領域へ割り当てる関数を作ってくれれば…」というシナリオです。これまで得た知見と、最近読んだ記事で再考した内容や、以前よりも改良できそうな点をまとめました。
Win32 ウィンドウプロシージャの拡張
Win32 の ウィンドウプロシージャ は次のように宣言されます。
LRESULT Wndproc( HWND hWnd, UINT Msg, WPARAM wParam, LPARAM lParam);
ウィンドウを作るにはまず
RegisterClass でクラスを登録し、そこに関数ポインタを渡します。
MyState *state = …; RegisterClassA(&(WNDCLASSA){ /* … */ .lpfnWndProc = my_wndproc, .lpszClassName = "my_class", /* … */ }); HWND hwnd = CreateWindowExA("my_class", …, state);
スレッドは OS からのイベントでメッセージ・プンプを走らせ、これをプロシージャにディスパッチします。プロシージャはその中でプログラム状態を操作します。
for (MSG msg; GetMessageW(&msg, 0, 0, 0);) { TranslateMessage(&msg); DispatchMessageW(&msg); // ウィンドウプロシージャが呼ばれる }
WNDPROC の 4 つの引数は Win32 が決めており、コンテキストポインタを渡す仕組みはありません。従来、次の二通りで状態にアクセスしていました。
- グローバル変数 – 汚いが簡単;チュートリアルでよく見かけます。
- GWLP_USERDATA – ウィンドウに添付したポインタです。
後者はセットアップが必要です。Win32 は
CreateWindowEx の最後の引数を、ウィンドウ作成時に WM_CREATE でプロシージャへ渡します。そのポインタは CREATESTRUCT を介して間接的に渡され、最終的には次のようになります。
case WM_CREATE: CREATESTRUCT *cs = (CREATESTRUCT *)lParam; void *arg = (struct state *)cs->lpCreateParams; SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)arg); /* … */
その後は
GetWindowLongPtr で取り出せます。毎回この手順を経ると、もっと良い方法がないかと思ってしまいます。もし「ウィンドウプロシージャに第 5 引数を設けてコンテキストを渡す」ことができれば…という妄想です。
typedef LRESULT Wndproc5(HWND, UINT, WPARAM, LPARAM, void *);
ここではそのようなトランスペリ―(トンネル)を作るだけにします。x64 の呼び出し規約は最初の 4 引数をレジスタで、残りをスタックへ押し込みますが、新しい引数も同じくスタックに入ります。トランスペリ―は単に余分なパラメータをレジスタに置くわけではなく、実際にスタックフレームを構築します。少し手間ですが、それほど難しくはありません。
実行可能メモリの確保
前回の記事や今回使ったプログラムでは
VirtualAlloc(または他環境なら mmap)で実行可能領域を確保していました。この方法だと、コードやデータから 2 GB を超えて離れた場所に割り当てられることがあり、相対アドレスで参照できなくなる問題があります。そこでより良い解決策として、ローダーから書き込み可能かつ実行可能なメモリブロックを取得し、その中でトランスペリ―を確保します。この領域は「実行可能」以外に特別な性質はなく、通常の割り当てと同じように扱えます。ローダー経由で確保すれば、ロード済みイメージ内にあるためほぼ常に近い位置にあり、JIT コンパイラは小さなコードモデルを仮定できます。
GNU 系ツールチェインで COFF をターゲットにした場合の一例です。
.section .exebuf,"bwx" .globl exebuf exebuf: .space 1<<21 # 2 MiB の writable/executable メモリを確保
このアセンブリは
.exebuf というセクションに 2 MiB を割り当てます。C 側では次のようにして取得します。
typedef struct { char *beg; char *end; } Arena; Arena get_exebuf(void) { extern char exebuf[1<<21]; Arena r = {exebuf, exebuf + sizeof(exebuf)}; return r; }
サイズを自分で書くのは面倒ですが、これだけ簡単に済みます。GCC のセクション属性で配列を定義したいところですが、その属性ではセクションフラグを設定できません。C コンパイラが GNU 系でなくても動作し、
exebuf を含む小さな COFF オブジェクトを生成するだけなら十分です。
また、以下に必要となる他の基本定義もまとめておきます。
#define S(s) (Str){s, sizeof(s)-1} #define new(a,n,t) (t *)alloc(a, n, sizeof(t), _Alignof(t)) typedef struct { char *data; ptrdiff_t len; } Str; Str clone(Arena *a, Str s) { Str r = s; r.data = new(a, r.len, char); memcpy(r.data, s.data, (size_t)r.len); return r; }
これらは前回の記事で詳しく説明しています。
トランスペリ―・コンパイラ
ここからは、
Wndproc5 とバインドするコンテキストポインタを受け取り、従来の WNDPROC を返す関数を作ります。
WNDPROC make_wndproc(Arena *a, Wndproc5 proc, void *arg);
ウィンドウプロシージャは次のように第 5 引数で状態を受け取ります。
LRESULT my_wndproc(HWND, UINT, WPARAM, LPARAM, void *arg) { MyState *state = arg; /* … */ }
クラス登録時には、
RegisterClass に渡す関数ポインタとしてトランスペリ―を返します。
RegisterClassA(&(WNDCLASSA){ /* … */ .lpfnWndProc = make_wndproc(a, my_wndproc, state), .lpszClassName = "my_class", /* … */ });
このクラスを使うウィンドウは、すべて第 5 引数で状態オブジェクトにアクセスできます。実際には
exebuf の設定が最も手間でした;make_wndproc 自体はとてもシンプルです。
WNDPROC make_wndproc(Arena *a, Wndproc5 proc, void *arg) { Str thunk = S( "\x48\x83\xec\x28" /* sub $40,%rsp */ "\x48\xb8........" /* movq $arg,%rax */ "\x48\x89\x44\x24\x20" /* mov %rax,32(%rsp) */ "\xe8...." /* call proc */ "\x48\x83\xc4\x28" /* add $40,%rsp */ "\xc3" /* ret */ ); Str r = clone(a, thunk); int rel = (int)((uintptr_t)proc - (uintptr_t)(r.data + 24)); memcpy(r.data+6, &arg, sizeof(arg)); memcpy(r.data+20, &rel, sizeof(rel)); return (WNDPROC)r.data; }
アセンブリは呼び出し側のスタックフレームを作り、シャドウスペースと新しい引数用領域を確保します。コンテキストポインタと 32‑ビット符号付き相対アドレス(
call のオフセット)をパッチします。手動でオフセットを計算している点が唯一残念です。
トランスペリ―のポインタを保持すれば、いつでもコンテキストポインタを書き換えることができます。
void set_wndproc_arg(WNDPROC p, void *arg) { memcpy((char *)p + 6, &arg, sizeof(arg)); }
例としては次のように使います。
MyState *state[2] = …; /* 複数状態 */ WNDPROC proc = make_wndproc(a, my_wndproc, state[0]); /* … */ set_wndproc_arg(proc, state[1]); /* 状態を切り替える */
最もよくあるケースは複数のプロシージャを作ることです。
WNDPROC procs[] = { make_wndproc(a, my_wndproc, state[0]), make_wndproc(a, my_wndproc, state[1]), };
トランスペリ―は Control Flow Guard(CFG)ポリシーが有効な状態でも動作します。スタックアンウィンドンエントリが無いので、Windows が制御を渡すのを拒否することはないと考えています。
より実用的なケース
GWLP_USERDATA を使うより手間がかかりますし、実際のプログラムではウィンドウプロシージャの数は小さく固定(ほぼ 1 個)です。したがって最適な例とは言えませんが、実際に役立つ場面を示しました。例えば弱いカスタムアロケータインタフェースを持つライブラリの場合:
typedef struct { void *(*malloc)(size_t); /* コンテキストポインタなし! */ void (*free)(void *); } Allocator; void *arena_malloc(size_t, Arena *); Allocator perm_allocator = { .malloc = make_trampoline(exearena, arena_malloc, perm), .free = noop_free, }; Allocator scratch_allocator = { .malloc = make_trampoline(exearena, arena_malloc, scratch), .free = noop_free, };
将来に備えてこの手法をメモしておきます。