WinDbg: 条件付きブレークポイントで「ある関数を経由してきたか」をチェックする方法

条件付きブレークポイント (Conditional Breakpoint) では、よく「引数や変数がある値になっているか」をチェックしますが、「ある関数を経由してきたか」はどうやってチェックすればいいのでしょうか。

動作確認環境

  • Windows 11 Home 23H2
  • Visual Studio Community 2022 (Visual C++)
  • WinDbg 10.0

「ある関数を経由してきたか」とは

下図のように、関数 common に至るルートが 4 通りあったとします。

単純に関数 common にブレークポイントを仕掛けると、① から ④ のどのルートを通ってきてもブレークポイントにヒットし一時停止します。
そうではなく、関数 B を経由して関数 common に到達した場合にのみ一時停止させたい、つまり ③ または ④ のルートを通ってきた場合のにみ一時停止させたい、これがやりたいことです。

実現方法 1

条件付きブレークポイントの書き方には、次の 2 種類があります。前者が新しいスタイルで、後者が昔ながらのスタイルです。

  • bp /w “<条件式>” <停止アドレス>
  • bp <停止アドレス> “<コマンド>”

前者のスタイルで「ある関数を経由してきたか」をチェックできないか試行錯誤したところ、以下の方法で実現できました。「<関数名>」の後ろに 1 文字の半角空白がある点に注意してください。

bp /w "@$curstack.Frames.Any(p=>p.ToDisplayString().StartsWith(\"<モジュール名>!<関数名> \"))" <停止アドレス>

長いので折り返します。実際には 1 行で書く必要があります。

bp /w "@$curstack.Frames.Any(p=>
        p.ToDisplayString().StartsWith(
            \"<モジュール名>!<関数名> \"))" 
    <停止アドレス>

日本語に翻訳 (?) すると、「ブレークポイント (bp) を <停止アドレス> に仕掛ける。ただし、コールスタック (@$curstack) の各フレーム (.Frames) の情報 (p) を文字列化 (.ToDisplayString()) し、”<モジュール名>!<関数名> ” で前方一致検索 (.StartsWith()) した結果、マッチする行がひとつでも (.Any) 存在した場合 (/w) にのみ有効とする。」となります。(※ 場合によっては .StartsWith() ではなく .Contains() を使うとよい。)

たとえば、「test モジュールの関数 B」を経由して「test モジュールの関数 common」に到達した場合にのみ停止させるには、次のように書きます。

bp /w "@$curstack.Frames.Any(p=>
        p.ToDisplayString().StartsWith(
            \"test!B \"))" 
    test!common

実際に試してみましょう。
サンプルプログラム「c:\tmp\test.c」です。上に図示したルート ①, ②, ③, ④ を順次実行しています。

#include <windows.h>
#include <stdio.h>

void common()
{
    printf("common\n");
}

void C()
{
    printf("C -> ");
    common();
}

void B(BOOL callC)
{
    printf("B -> ");
    callC ? C() : common();
}

void A()
{
    printf("A -> ");
    common();
}

int main()
{
    printf("①  main -> ");
    common();

    printf("②  main -> ");
    A();

    printf("③  main -> ");
    B(FALSE);

    printf("④  main -> ");
    B(TRUE);

    return 0;
}

Visual C++ のコマンドプロンプトを起動し、

ビルドします。「/Od」は最適化抑止(デフォルト)、「/Zi」はデバッグ情報を生成、の意味です。

C:\tmp> cl /Od /Zi test.c

「test.exe」が生成されました。

「test.exe」を普通に実行すると、各ルートが表示されます。

C:\tmp> test.exe
①  main -> common
②  main -> A -> common
③  main -> B -> common
④  main -> B -> C -> common

次に「test.exe」をデバッガ上で実行します。
WinDbg を起動し、メニューの [File]-[Open Executable] から「test.exe」を開きます。

「test モジュールの関数 B」を経由して「test モジュールの関数 common」に到達した場合にのみ停止するよう、WinDbg の [Command] ウィンドウからブレークポイントを仕掛けます。

0:000> bp /w "@$curstack.Frames.Any(p=>p.ToDisplayString().StartsWith(\"test!B \"))" test!common

実行を継続します。

0:000> g

ブレークポイントにヒットしました。

Breakpoint 0 hit
test!common:
00007ff6`448d7190 4883ec28        sub     rsp,28h

プログラムの実行画面には次のように表示されています。

①  main -> common
②  main -> A -> common
③  main -> B ->

画面キャプチャーです。

ルート ① とルート ② では止まらず、ルート ③ の common に入ったところで止まっています

dx コマンドでコールスタックを見ると、test!B から test!common が呼び出されていることが確認できます。

0:000> dx @$curstack.Frames
@$curstack.Frames                
    [0x0]    : test!common [Switch To]
    [0x1]    : test!B + 0x2f [Switch To]
    [0x2]    : test!main + 0x39 [Switch To]
    [0x3]    : test!invoke_main + 0x22 [Switch To]
    [0x4]    : test!__scrt_common_main_seh + 0x10c [Switch To]
    [0x5]    : KERNEL32!BaseThreadInitThunk + 0x1d [Switch To]
    [0x6]    : ntdll!RtlUserThreadStart + 0x28 [Switch To]

実行を継続します。

0:000> g

再びブレークポイントにヒットしました。

Breakpoint 0 hit
test!common:
00007ff6`448d7190 4883ec28        sub     rsp,28h

プログラムの実行画面には次のように表示されています。

①  main -> common
②  main -> A -> common
③  main -> B -> common
④  main -> B -> C ->

ルート ④ の common に入ったところで止まっています。
dx コマンドでコールスタックを見ると、test!B を経由して test!common に到達していることが確認できます。

0:000> dx @$curstack.Frames
@$curstack.Frames                
    [0x0]    : test!common [Switch To]
    [0x1]    : test!C + 0x15 [Switch To]
    [0x2]    : test!B + 0x20 [Switch To]
    [0x3]    : test!main + 0x4f [Switch To]
    [0x4]    : test!invoke_main + 0x22 [Switch To]
    [0x5]    : test!__scrt_common_main_seh + 0x10c [Switch To]
    [0x6]    : KERNEL32!BaseThreadInitThunk + 0x1d [Switch To]
    [0x7]    : ntdll!RtlUserThreadStart + 0x28 [Switch To]

以上のように、想定通りの条件で停止しました。

なお、「dx @$curstack.Frames」コマンドでコールスタックを見ると各フレームに [0x2] などのインデックスや [Switch To] といった DML が表示されますが、文字列化 (ToDisplayString()) すると消えます。次のコマンドで分かります。

0:000> dx @$curstack.Frames[2].ToDisplayString()
@$curstack.Frames[2].ToDisplayString() : test!B + 0x20
    Length           : 0xd

実現方法 2

昔ながらのスタイルで実現する方法についても考えてみました。
その結果です。

bp <停止アドレス> "r $t1=0; .foreach /pS4 /ps1 (v {kc}) { .if $scmp(\"${v}\", \"<モジュール名>!<関数名>\")=0 { r $t1=1 }}; .if ($t1=0) { gc }"

長いので折り返します。実際には 1 行で書く必要があります。

bp <停止アドレス> 
    "r $t1=0; 
    .foreach /pS4 /ps1 (v {kc}) { 
        .if $scmp(\"${v}\", \"<モジュール名>!<関数名>\")=0 { 
            r $t1=1 
        }
    }; 
    .if ($t1=0) { gc }"

日本語に翻訳 (?) すると、「ブレークポイント (bp) を <停止アドレス> に仕掛ける。ただし停止時、以下のコマンドを実行する。(1) 疑似レジスタ $t1 に、0 を代入する。(2) コールスタックの情報を出力 (kc) し、最初の 4 語を除いた (/pS4) 後、1 語おき (/ps1) に “<モジュール名>!<関数名>” という文字列を探し ($scmp()=0)、見つかったら疑似レジスタ $t1 に 1 を代入する。(3) 疑似レジスタ $t1 の値が 0 だったら実行を再開し、そうでなかったら停止したままとする。」となります。(※ $scmp()=0 ではなく $spat() を使うのもよい。)

たとえば、「test モジュールの関数 B」を経由して「test モジュールの関数 common」に到達した場合にのみ停止させるには、次のように書きます。

bp test!common 
    "r $t1=0; 
    .foreach /pS4 /ps1 (v {kc}) { 
        .if $scmp(\"${v}\", \"test!B\")=0 { 
            r $t1=1 
        }
    }; 
    .if ($t1=0) { gc }"

先のプログラムで実際に試してみましょう。

WinDbg を起動し、メニューの [File]-[Open Executable] から「test.exe」を開きます。
そして、[Command] ウィンドウから目的のブレークポイントを仕掛けます。

0:000> bp test!common "r $t1=0; .foreach /pS4 /ps1 (v {kc}) { .if $scmp(\"${v}\", \"test!B\")=0 { r $t1=1 }}; .if ($t1=0) { gc }"

実行を継続します。

0:000> g

ブレークポイントにヒットしました。

test!common:
00007ff6`67db7190 4883ec28        sub     rsp,28h

プログラムの実行画面には次のように表示されています。

①  main -> common
②  main -> A -> common
③  main -> B ->

ルート ① とルート ② では止まらず、ルート ③ の common に入ったところで止まっています。
kc コマンドでコールスタックを見ると、test!B から test!common が呼び出されていることが確認できます。

0:000> kc
 # Call Site
00 test!common
01 test!B
02 test!main
03 test!invoke_main
04 test!__scrt_common_main_seh
05 KERNEL32!BaseThreadInitThunk
06 ntdll!RtlUserThreadStart

実行を継続します。

0:000> g

再びブレークポイントにヒットしました。

test!common:
00007ff6`67db7190 4883ec28        sub     rsp,28h

プログラムの実行画面には次のように表示されています。

①  main -> common
②  main -> A -> common
③  main -> B -> common
④  main -> B -> C ->

ルート ④ の common に入ったところで止まっています。
kc コマンドでコールスタックを見ると、test!B を経由して test!common に到達していることが確認できます。

0:000> kc
 # Call Site
00 test!common
01 test!C
02 test!B
03 test!main
04 test!invoke_main
05 test!__scrt_common_main_seh
06 KERNEL32!BaseThreadInitThunk
07 ntdll!RtlUserThreadStart

以上のように、想定通りの条件で停止しました。

なお、kc コマンドの出力から最初の 4 語を除いた (/pS4) のは、「#」「Call」「Site」「00」を検索対象外とするためです。その後、1 語おき (/ps1) に、「test!common」「test!C」… と検索していきます。

おわりに

条件付きブレークポイントで「ある関数を経由してきたか」をチェックする方法について見てきました。あらゆるパターンに対応可能なのか、この方式がベストなのかは分かりませんが、試した範囲ではうまく動作しています。デバッグ時の助けになればと思います。

参考文献

[1] マイクロソフト「デバッガー オブジェクトでの LINQ の使用」
https://learn.microsoft.com/ja-jp/windows-hardware/drivers/debugger/using-linq-with-the-debugger-objects