ホーム  ざれごと  ワシントン州  ツール  NT豆知識  Win32プログラミングノート  私的用語  ジョーク  いろいろ  ゲーム雑記  Favorites  掲示板   Mail

Visual C++の小技

Last modified: Sat Apr 05 04:42:12 2003 PDT

一つ上へ

コンパイラの小技

自分の書いたコードがどんな風にアセンブラに落ちているのか、確かめたくなることはありませんか? え、ない? ほんと? いや別にいいんですけど。

というわけで、コンパイル結果が気になるような人はたいていもう知っているはずなんですが、念のため。 一番簡単なのは、コンパイラにリスティングファイルを作らせることでしょう。 プロジェクトオプション、C/C++、リスティングファイルから指定できます。

出力結果は、中間オブジェクト出力先ディレクトリに a.cod とかで出てきます。 後は個人のお楽しみ。

インラインアセンブラでeipを取得

普通にやると eip はインラインアセンブラでは取れませんが、次の関数を呼び出すことで取得できます。

LPVOID __declspec(naked) GetEip()
{
    __asm mov eax, [esp];
    __asm ret;
}

↓の方で解説していますが、__declspec(naked) によって余分なプロローグ・エピローグコードが生成されないようにしておいて、スタックトップに積まれている eip を取ってくる、というわけです。 ちょっとだけ hacky ですが、__emit で直接コードを埋め込むよりはましかな、と。

ヘルプの小技

VC++のヘルプはhhw.exe、つまりHTMLヘルプなわけです。 中身は普通のHTMLになってまして、前のwinhlp32のヘルプよりは結構良いかな、と思うこともあります。 しかしながら、やはりヘルプを行ったり来たりが直線的にしか出来なくて、こっちとあっちとそっちを交互に見たい場合は、参照する順番に頭を使わなくてはなりません。

んが、所詮HTMLヘルプ、エンジンは IE と同じモノを使っているので、実は IE と同じく、リンク先を新しいウィンドウで開くことができるのです。

IE で、リンク先を新しいウィンドウで開くときはどうしていますか? アンカーを右クリックして、"新しいウィンドウで開く" メニューを選択してたりします? 残念ながら、HTMLヘルプでは、アンカーを右クリックしても "プロパティ" しかメニューには出てきません。

ではどうするかというと、実は IE にも用意されている、 Shift + 左クリック がその答えです。 アンカーを Shift + 左クリック してみると、新しい IE のウィンドウが開き、そこにはHTMLヘルプの内容がちゃーんと表示されます。 そこから先のリンクもきちんと動くので、必要なだけウィンドウを開いて調べまくることが出来る、というわけです。
意味があるかどうかはさておき、ファイルに保存も出来るし、Favorite メニューに入れることも出来まっせ。 パスを変えて再インストールしたら、思いっきり無効リンクになってしまいますが。

意外と知らない人が多いらしいので、書いてみました。

リンカの小技

リンクするライブラリをコードに埋め込む

例えば、winmm.libをリンクする必要があるとしたら、どうします?プロジェクトのオプションで指定するのが普通ですが、同じソースを使いまわすときなんかは、毎回指定するのが面倒ですね。 というわけで、リンカに対する指示をソースコードに埋め込む pragma で指定してもおっけー。

#pragma comment(lib, "winmm.lib")

ライブラリ(lib)に対するコメントには、リンカへのオプションや、リンクするライブラリを指定できるというわけです。

DLLの共用セクション

Win32では、DLLは各プロセス毎に全く別のインスタンスとしてロードされる(アドレスも変わるよ)なので、DLLを介してプロセス間でメモリを共有しようとすると、メモリマップドファイルを使うか、 共用セクションを使う わけです。
共有セクションは、コンパイラに対する #pragma 指示と、リンカに対するリンクオプションで作ることが出来ます。 リンクオプションは、コードに埋め込むことも出来ます。

#pragma comment(linker, "/section:shared,rws")
#pragma data_seg("shared")
namespace shared {
    UINT msgPrivate = NULL;     // notification message to the main app
    HWND hwndAppMain = NULL;    // main window of the main app
    HHOOK hhk = NULL;           // hook handler
};
#pragma data_seg()

気持ちnamespaceを使ってみたりして。

気をつけなくちゃいけないのは、共有する変数を明示的に初期化する必要があるということ。

初期化指示されていない外部変数はBSSへ入れられてしまいます。 BSSは、exe中に領域を確保されずローダがロード時に0で埋めるので、普通は初期化指示をしないほうがexeのサイズが小さくなって有利なのですが。

共用セクションに入れて欲しい変数の初期化を書いておかないと(初期値が0でもNULLでも)、その変数はBSSセクションへ入れられてしまって、プロセス間でシェアされない、ということになります。

また、プロセスをまたいだポインタには全く意味がないため、共用セクションにポインタを直接入れても無駄…というか、それはバグです。 共用するなら、ハンドルを介して行うか、プロセスごとに用意したベースアドレスからのオフセットにするべきでしょう。老婆心ながら。

セクションの4KBバウンダリ

VC++6.0では、リンカのデフォルトで、セクションが4KBバウンダリ(x86のページサイズ)に配置されるようになっています。 あらかじめCPUの自然なバウンダリである4KBにセクションがまとめられているため、ローダのパフォーマンス向上が望めます。 しかし、最終的なバイナリが小さい場合、4KBバウンダリのせいでサイズが予想外に大きくなることがあります。 好き好きですが、次のオプションを、プロジェクト設定のリンカタブ、"Project Options" の欄に直接書き込むと、セクションが4KBバウンダリにまとめられることがなくなります。

/OPT:NOWIN98

.rdataを.textに入れる

リンカオプションで、

/merge:.rdata=.text

とやっておくと、rdata(read-only)のデータが、コードセクションである.textにマージされます。 セクションの属性がちゃう、というリンカのwarningが出ちゃいますが…。

このリンカのwarningは、

/ignore:4078

で消せます。まぁ気分の問題か。

何が嬉しいかというと、.rdataが少ない場合、.textにマージすると、1ページだけメモリが稼げるかもしれない、ということです。 どちらもread-onlyではあるわけで。 しかし、使える局面は限られるでしょうね。 ↓みたいな、ごく小さなDLLを作る場合とか。

まとめ

以上まとめてコードに書くなら、こんな感じ。

#pragma comment(linker, "/opt:nowin98")
#pragma comment(linker, "/merge:.rdata=.text")
#pragma comment(linker, "/ignore:4078")

文字列リテラル

なお、VC6では、文字列リテラルは.dataに入れられるようになっています。 これを.rdataへ落とし込むには、コンパイラフラグ /GF が必要になります。 寡聞にして、コンパイラフラグを pragma で指定する方法を知らないので、ここばかりはプロジェクト設定で指定しています。 /GF は、プロジェクトオプションに直接書き込んでやればおっけー。

ディレイロード

#pragma comment(lib, "setupapi.lib")
#pragma comment(linker, "/delayload:setupapi.dll")

リンカへのオプション指定を使って、ディレイロードを行っています*。 注意点としては、ディレイロードに失敗すると例外が上がるので、呼び出し側できとんと捕捉してあげることが挙げられます。

* ただし、Visual Studio .NET 付属のコンパイラでは、これらのリンカ指定が使えなくなってしまったようです。 ちょっと頭が痛い…。

DLLの小技

スレッドの生成時にDllMainが呼ばれなくても構わない

…というときは、しょっぱなに

BOOL APIENTRY DllMain(HANDLE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved)
{
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH:
        DisableThreadLibraryCalls(hModule);
        break;

って感じで DisableThreadLibraryCalls() を呼んでおくと、多少パフォーマンスが稼げます。 ただし、CRTを(スタティックリンクで)使っているときは、スレッドに対する初期化が行われなくなるので、やってはいけません。 多分。

CRTをぜんぜん使わないDLL

フック用のDLLで、 大した処理もしていない割には、 なんだかサイズが小さくならない、というときは。

ちなみに、バイナリの大きさ=全てのセクションサイズの合計、というわけではありません。 上記のようにデフォルトで4KBバウンダリに切り上げられるし、それでなくとも、少なくとも16バイトバウンダリに切り上げらるはず。 実際のセクションのサイズを知りたい場合は、

link -dump -headers your_dll.dll

のようにして、PEヘッダを表示させるとおっけー。

使わないものはリンクする必要なし。 というわけで、CRTを全く使わないなら、リンクする必要はないわけです。 不要な初期化ルーチンやらの分だけ、DLLが小さくなります。

というわけで、プロジェクトのオプションで、デフォルトのライブラリを無視、にすると、CRTのライブラリはリンクされなくなります。
コードに埋め込むなら、こう。

#pragma comment(linker, "/nodefaultlib")

が、それだけでめでたし、とはなりません。 VC++のデフォルトでは _DllMainCRTStartup(HMODULE, DWORD, LPVOID) というCRT初期化用のダミー DllInit() がスタートアップルーチンになっています。ライブラリの中に入っているので、リンクエラーになってしまいま す。 ソースコードを見てみると、CRTの初期化以外に大したことはしていないので、プロジェクトのリンクオプションで、スタートアップを DllMain に書き換えてしまえばおっけー。
コードに埋め込むなら、こう。

#pragma comment(linker, "/entry:\"DllMain\"")

もうひとつ、デバッグ版では、関数の呼び出し方法に起因するスタックレベルの非整合のチェック用に(__stdcallか__cdeclか)、_chkesp() という関数が使われているようで、これまたリンクエラーになります。 VC++ のデバッグ支援機能の一つなんですが、ここは目をつぶって、空の関数を定義してやればおっけー。

#ifdef _DEBUG
extern "C" void _chkesp()
{
}
#endif

なお蛇足ながら、nodefaultlibは、オブジェクトに埋め込まれたライブラリを無視するという仕様なので、↑に挙げた、リンクするライブラリをコードに埋め込むという手は使えなくなります。

_chkesp

ちゃんとやるなら……。
VC++コンパイラの吐くコードは、 関数の呼び出し前にespをesiに保存し、呼出し後に esi と esp を比較してから _chkesp() を呼んでいるので、_chkesp() の中ではインラインアセンブラでゼロフラグをチェックするアセ ンブラコードを書いて、ブレークでもかければ良いでしょう。 Base APIの、DebugBreak()とか。

で、実際にやってみると、障害が二つあって意外とてこずりました。

最初に書いたコードは、こんな感じ。

extern "C" void _chkesp(
{
    __asm {
        jz ok;
    }
    OutputDebugString(TEXT("Stack level doesn't match!\n"));
    DebugBreak();
ok:
    ;
}

すると、こ〜んなコードが生成されたのでした。

__chkesp PROC NEAR                                      ; COMDAT

; 108  : {

  00000 55               push    ebp
  00001 8b ec            mov     ebp, esp
  00003 83 ec 40         sub     esp, 64                        ; 00000040H
  00006 53               push    ebx
  00007 56               push    esi
  00008 57               push    edi
  00009 8d 7d c0         lea     edi, DWORD PTR [ebp-64]
  0000c b9 10 00 00 00   mov     ecx, 16                        ; 00000010H
  00011 b8 cc cc cc cc   mov     eax, -858993460                ; ccccccccH
  00016 f3 ab            rep stosd

; 109  :     __asm {
; 110  :         jz ok;

  00018 74 23            je      SHORT $ok$16732

; 111  :     }
; 112  :     OutputDebugString(TEXT("Stack level doesn't match!\n"));

  0001a 8b f4            mov     esi, esp
  0001c 68 00 00 00 00   push    OFFSET FLAT:??_C@_1DI@MEOM@?$AAS?$AAt?$AAa?$AAc?$AAk?$AA?5?$AAl?$AAe?$AAv?$AAe?$AAl?$AA?5?$AAd?$AAo?$AAe?$AAs?$AAn?$AA?8?$AAt?$AA?5@ ; `string'
  00021 ff 15 00 00 00
        00               call    DWORD PTR __imp__OutputDebugStringW@4
  00027 3b f4            cmp     esi, esp
  00029 e8 00 00 00 00   call    __chkesp

; 113  :     DebugBreak();

  0002e 8b f4            mov     esi, esp
  00030 ff 15 00 00 00
        00               call    DWORD PTR __imp__DebugBreak@0
  00036 3b f4            cmp     esi, esp
  00038 e8 00 00 00 00   call    __chkesp
$ok$16732:

; 114  : ok:
; 115  :     ;
; 116  : }

  0003d 5f               pop     edi
  0003e 5e               pop     esi
  0003f 5b               pop     ebx
  00040 83 c4 40         add     esp, 64                        ; 00000040H
  00043 3b ec            cmp     ebp, esp
  00045 e8 00 00 00 00   call    __chkesp
  0004a 8b e5            mov     esp, ebp
  0004c 5d               pop     ebp
  0004d c3               ret     0
__chkesp ENDP

こりゃたまりません。 64バイトのローカルエリアをスタック上に作って、0xccで埋めてしまうコードまで入ったりして…。
というわけで、最初の問題に対しては、いろいろコンパイルオプションを探してみたのですがうまくいきません。
2番目の問題に対しては、C/C++で関数呼び出しを書くと、スタックレベルチェックのコードが生成されてしまうようです。 途中で、逃げのオプションを探す気力が尽きてしまいました。 アセンブラで書けばOKだろうと。

結局たどり着いたのは次のようなコードです。

const TCHAR _stack_level_unmatch[] = TEXT("Stack level doesn't match!\n");
extern "C"
void __declspec(naked) _chkesp()
{
    __asm {
        jz ok;
        push offset _stack_level_unmatch;
        call dword ptr OutputDebugString;
        call dword ptr DebugBreak;
ok:
        ret;
    };
}

__declspec(naked) がポイントでした。 これはコーリングコンベンションではなく、関数の実体を修飾するもので、プロローグ・エピローグのコードを全く生成しないようコンパイラに指示します。 アセンブラで"ret"を書かないと、関数からのリターンもしないという徹底した仕様のようで(笑)。

_chkespの再帰的呼び出しを避けるため、デバッグ用APIの呼び出しも、アセンブラで行いました。 なお、アプリから見たAPIのエントリは、インポートテーブルのアドレスになるので、間接ジャンプを使う必要があります。
これで、おおむねOKそうです。 codファイルの結果を見ても、自分の書いたアセンブラコード以外には、余計なものは一切付いていません。 偉いぞ > __declspace(naked)。

__chkesp PROC NEAR                                      ; COMDAT

; 136  :        __asm {
; 137  :                jz ok;

  00000 74 11            je      SHORT $ok$16741

; 138  :                push offset _stack_level_unmatch;

  00002 68 00 00 00 00   push    OFFSET FLAT:__stack_level_unmatch

; 139  :                call dword ptr OutputDebugString;

  00007 ff 15 00 00 00
        00               call    DWORD PTR __imp__OutputDebugStringW@4

; 140  :                call dword ptr DebugBreak;

  0000d ff 15 00 00 00
        00               call    DWORD PTR __imp__DebugBreak@0
$ok$16741:

; 141  : ok:
; 142  :                ret;

  00013 c3               ret     0
__chkesp ENDP
_TEXT   ENDS
END

……とさんざん格闘した後で、このリンクを見つけました。
[
Q191669 - PRB: LNK2001: Unresolved External Symbol __chkesp ]
そっか、コンパイルオプションで逃げる手もあったのか (/GZ をコンパイルオプションから外す)。 まぁいいや。ちゃんとスタックのチェックが出来るし…(^_^;

ベースアドレス

DLLの場合、ベースアドレスのデフォルトは 0x10000000 になっていますが、フック用 DLL などの場合は、ちょーっとずらしてやると、他の DLL とアドレスがバッティングする確率が小さくなります。 詳しい説明は省きますが、アドレスがバッティングしないとみんなちょっとだけ幸せになります。 プロジェクトオプションの、リンク、アウトプット、ベースアドレスで適当な値を指定します。 4KBバウンダリならおっけーのはず。それとも64KBバウンダリだったかな。忘れた。
コードに埋め込むなら、例えばこう。

#pragma comment(linker, "/base:\"0x18000000\"")

バイナリの出力先

ExeファイルのサブプロジェクトとしてDLLを作っているとき、 コンパイル結果のlib、exp、dllなどをexeと同じディレクトリに入れると何かと便利です。 が、中間ファイルの出力先までexeと同じにしてしまうと、ちょっとハマります。 コンパイラ、リンカが使うファイルの一部に名前が固定のものがあって、バッティングするようです。

というわけで、サブプロジェクトのDLLでは、最終バイナリの出力先は親exeと同じにして、中間ファイルはDLL固有のものにするのが良さそうです。

デバッグ出力

MFCを使っていればTRACEx()等のマクロでデバッグメッセージを、デバッガへ出力することが出来て便利ですね。 でも、CRTもMFCも使わないDLLでは、デバッグ用メッセージの出力はどうすりゃいいんでしょう。

実は、 OutputDebugString() というAPIがあり、結局TRACEx()なども最終的にこいつを呼んでいます。 ということで、直接このAPIを呼べばおっけー。

アサート代わりには、 DebugBreak() を呼んでブレークポイント例外を起こせばおっけーでしょう。

OutputDebugString() は文字列を一つ引数に取るだけで、そのままでは TRACEx() と同じには使えません。 普通は vsprintf() なんぞを使うわけですが、CRTを使わない場合は当然使えません。 というわけで、Win32 API版の wvsprintf() を使います。 浮動小数点が扱えない以外は、vsprintf() の代替に大体なります :-)。

こんな感じ。

#include <stdarg.h>

#if defined(_DEBUG) || defined(DEBUG)

namespace hirofumi {
    inline void VOutputDebugString(LPCTSTR str, ...)
    {
        TCHAR buf[256];
        va_list args;
        va_start(args, str);
        wvsprintf(buf, str, args);
        OutputDebugString(buf);
        va_end(args);
    }
};

#define ASSERT(x) \
    if (!(x)) { \
        hirofumi::VOutputDebugString(TEXT("Assertion failed! in %s (%d) %s\n"), \
            TEXT(__FILE__), __LINE__, TEXT(#x)); \
        DebugBreak(); \
    } else

#define VERIFY(x)   ASSERT(x)

#define TRACE0(x)   OutputDebugString(TEXT(x))


#define TRACE1(x, a)            hirofumi::VOutputDebugString(TEXT(x), a);
#define TRACE2(x, a, b)         hirofumi::VOutputDebugString(TEXT(x), a, b);
#define TRACE3(x, a, b, c)      hirofumi::VOutputDebugString(TEXT(x), a, b, c)
#define TRACE4(x, a, b, c, d)   hirofumi::VOutputDebugString(TEXT(x), a, b, c, d)


#else
// Release version

#define ASSERT(x)

#define VERIFY(x)   x

#define TRACE0(x)
#define TRACE1(x, a)
#define TRACE2(x, a, b)
#define TRACE3(x, a, b, c)
#define TRACE4(x, a, b, c, d)

#endif

他のプロセスにインジェクトされたDLLの出力を見る

グローバルフックの入っているDLLの出力は、残念ながらVC++のIDEのデバッグ出力には全部は表示されません。 他のプロセスにインジェクトされたDLLの分が出ないのです。 ブレークポイントも効きません(当然だけど)。 というわけで、どうしましょう。

私はNTしか知らないのでNT限定ということになってしまいます。9xの人はごめん。
もしマシンが2台あれば、カーネルデバッガを設定しましょう。 全てのデバッグ出力は、カーネルデバッガで見ることが出来ます。

カーネルデバッガなんて大げさな。という向きには、スタンドアロンで解決する方法もあります。 ntsdというユーザモード用のデバッガをプロセスにアタッチしてやると、目的のプロセスにインジェクトされたDLLの出力を見ることが出来るようになります。 ntsdはWindows 2000のCDのSUPPORTというディレクトリの下に入っているので、パスの通っている場所にコピーしておいてください。

まずは、tlistというリソースキットに入っているコマンドなど使うか、タスクマネージャを使ってプロセスIDを取得します。 プロセスIDが分かったら、次のようにしてntsdを起動します。

ntsd -p pid -g

-p オプションがpidを指定してプロセスにデバッガをアタッチするオプションで、-g はイニシャルブレークなしに実行を継続するオプションです。 これで、目的のプロセスにインジェクトされたDLLのデバッグ出力を見ることが出来ます。

あるいは、最初からntsdの下でプロセスを起動することも出来ます。

ntsd -g app.exe

ntsdはデバッガなので、当然デバッグも出来ます。 しかし、IDEのようなソースコードデバッガではなく、アセンブリレベルのデバッガなので、x86アセンブラに関する知識が必要となります。 ただし、ntsdはVC++の吐いたシンボルファイル|プログラムデータベース(*.pdb)が扱えるので、シンボリックデバッグまでは可能です。

-g オプションをつけないで起動するか、プロセスにフォーカスをあわせて F12 を押すと、デバッガのプロンプトへ落ちます。 そこで !sympath というコマンドを使って、シンボルのあるパスを指定します。 Windows 2000のシンボル(CDに入っています)と同時に扱いたい場合は、パスをセミコロンで区切って複数指定します。

!sympath を設定して !reload yourdll.dll とタイプすると、yourdll.pdb というシンボルファイルを探しに行きます。 x yourdll!* で、yourdll.dll の中にあるシンボルの一覧が表示されます。

これ以上はこのページにそぐいませんが、少しだけ書いておくと、

bp アドレス または シンボル

ブレークポイントの設定

bl

ブレークポイントのリスト

be ブレークポイントの番号

ブレークポイントを有効にします。引数は、bl で表示されるbpの番号。
"*" を指定すると、全てのブレークポイントを有効にします。

bd ブレークポイントの番号

ブレークポイントを一時的に無効にします。

bc ブレークポイントの番号

ブレークポイントをクリアします。

d

ダンプ。ddでDWORD、dwでWORD、dbでBYTE。dcでBYTE兼キャラクタ。

u [アドレス]

ディスアセンブル

r [レジスタ名]

レジスタダンプ

kb

スタックトレースの表示。ただの k、kv などバリエーションがある。

q

終了(デバッグ対象のプロセスも終了しちゃうので注意)

?

ヘルプ

DOSの頃を知っている人には、symdebのNT版と言えば通りが早いかも。 もちろん、symdebよりはずっと強力になっています。

Since 1996

一つ上へ

ホーム  ざれごと  ワシントン州  ツール  NT豆知識  Win32プログラミングノート  私的用語  ジョーク  いろいろ  ゲーム雑記  Favorites  掲示板   Mail