dynamic obj loader (2)
いくつかのバグフィックスや機能追加を経て、以前の記事で紹介した ispc による衝突計算モジュールもリロードも可能になりました。ispc も .obj を吐くのでリロード可能なわけです。
あと無駄に .lib にも対応しました (とりあえず static link 版のみ)。
以下、前回書ききれなかった自力リンクの実装の話。
.obj ファイルの構造は、この記事 (貴重な日本語記事!)、この記事にとても詳しく解説されています。.obj は自己説明的で分かりやすい構造になっているので、これらを読めばどう実装すればいいかは見当がつくと思います。
大雑把には .obj をメモリにマップして、relocation 情報を元にシンボルのアドレスを書き加えていく、という手順になります。詰まった時は dumpbin の結果を眺めるのが助けになるでしょう。
ちなみに .lib もこちらで詳しく解説されています。.obj ファイルが数珠つなぎになった構造で単純です。
で、以下は実装中自分が悩んだところです。
- RTTI が有効な場合、vtable の要素がずれる
RTTI が有効な場合、少なくとも .obj の時点では、vtable の最初の要素は RTTI 関連のデータへのポインタになっています。これが実行時に 1 つ前にずらされて vtable[-1] に移動するようです。いつどこでどのようにこのずらし処理が行われるのかは調べがついていませんが、CRT 初期化時にやってるんじゃないかと推測しています。
自力リンクの場合この vtable ずらしも再現する必要がありますが、正確な再現方法がわからなかったので、結局今回は RTTI 切ってくださいということにしました。制限として結構キツい部類なので対応したいところではありますが…。
興味があれば RTTI が働いているオブジェクトをウォッチウィンドウで (*( (void***)pHoge ))[-1] で見てみたり、dumpbin でその構造を観察したりしてみると面白いと思います。今回最も意表を突かれた部分でした。
- x64 で jmp の距離が 32bit に収まらなくなる
.obj <-> .exe 間の関数呼び出しで、32bit の相対アドレスを取る jmp 命令が使われます。
x86 でも x64 でも 32bit なため、x64 で 32bit に収まらない距離を飛ぼうとした場合、あらぬ場所に着地してクラッシュします。
そして、new や malloc() などの CRT 系 (==HeapAlloc() 系) のメモリ確保 API を使った場合、exe がマップされているメモリ領域から遠く離れた、32bit に収まらない距離の場所にメモリが確保されてしまいます。つまり、.obj を malloc() で確保した領域にマップしてその関数を実行した場合、jmp の後クラッシュすることがあります。
VirtualAlloc() はメモリアドレスを指定して確保することができるため、.exe がマップされている領域を調べてその近くに確保する、という手段で解決しました。
- .obj のデータセクションはアライメントを考慮した配置になっていない
SSE の __m128 の定数なども .obj のデータセクションに格納されますが、ここではアライメントが全く考慮されていません (たぶんファイルサイズ削減のため)。このため、マップした .obj のデータ領域をそのまま使うと、たまーに謎のクラッシュが起きるという厄介な事態が起こりえます。
何 byte align かという情報は section 単位で IMAGE_SECTION_HEADER::Characteristics に入っているので、これを元に自力でアライメントを考慮したデータの再配置をやる必要があります。
- シンボルの重複を強引に許可する
ロード時/アンロード時に自動的に実行されるハンドラ関数を実装する際に問題になった部分。
.obj それぞれに OnLoad/OnUnload みたいな名前の関数を持たせたいわけですが、そのままだと重複シンボルでリンクエラーになります。
該当関数を static にした場合、リンクエラーは出なくなりますが、最適化で消されてしまいます。
リンカオプション /FORCE:MULTIPLE を使うと重複シンボルのエラーを無視できますが、残念ながら特定のシンボルだけに適用することはできず、危険過ぎて使うわけにはいきません。
最終的に __declspec(selectany) なる拡張機能を見つけてこれで対処しました。これをつけた変数は重複が許されるようになります。変数にしか使えないため、関数を static にして __declspec(selectany) をつけた関数ポインタでそれを参照することで最適化で消えるのを抑止、というややこしい手順になりました。
また、.obj リンクは制限が厳しいため、dll についても再考しています。
前回の記事を書いた後で、exe のシンボルを dll から直接参照することができるのに気づきました。exe 側のソースに __declspec(dllexport) があると .exe と一緒に .lib も生成されるので、dll 側でそれをリンクするだけです。これなら Singleton が各モジュールで別個に作られる問題は解決できるし、exe から dll へ global オブジェクトを渡す関数を用意したりせずに済みそうです。(ちなみに Runtime Compiled C Plus Plus の実装ではがんばって手動で渡すようになっていました…)
モジュール跨いだメモリ解放でクラッシュする問題は、delete じゃなくて Release() で開放する方式にするとか、exe から提供されたアロケータを共有するとかで解決できるので大した問題ではありません。
あとは dll 用プロジェクトを用意するのが面倒問題ですが、特定フォルダ以下の .cpp は全て dll にする、みたいなルールを設け、スクリプトで .vcxproj からコンパイルオプションや依存ライブラリを得て cl.exe を実行、ダミーの DllMain() が入った .obj を食わせて dll に仕立て上げる、みたいにできれば .obj を読む方式とほぼ同等の利便性が実現できそうな気がします。
気が向いたらこちらもやってみたいところです。