正規表現で経路依存のブレークポイントを作る

これは何?

プログラムのデバッグをしていて、あるいは既存のプログラムの挙動を調べていて、ブレークポイントがある経路を通ってきたときのみ停止してほしいということが結構ある気がしたので、そういうブレークポイントが作れないかをやってみました。その経路の指定のために、各所に「ラベルつきのブレークポイント」が置けるようにして、そのラベルたちの吐いたログが、予め設定しておいた正規表現にマッチするときのみ停止するという風にしてみたらいい感じだったので書いてみます。

Motivating Examples

ケース1: ループのなかの処理の最初のほうは無視して最後のほうだけブレークポイントを止めたい

例えばゲームのメインループや最適化法の反復処理で、ループの最後のほうに値や挙動がどうなっているか調べたいのに普通にブレークポイントを置くとそこに到達するまでcontinueし続けなければいけないということがあります。
このぐらい簡単な例であればiに関する条件でブレークポイントを仕掛けることもできるでしょうが、例えばループのなかから別の関数を呼び出してそこにブレークポイントを置いたとすると、その関数から直接ループカウンタiにアクセスすることはできないので「最後のほうだけみる」のが難しかったりします。そういったときに、先頭何回かのブレークポイントを飛ばして、最後のほうだけ停止する、ということができればコードを汚染せずにできると嬉しかったりします。

#include <cstdio>

using namespace std;

int main(){
    for(int i=0; i < 100; i++){
        printf("hoge %d\n", i);  // ここにブレークポイント
    }
    return 0;
}

例えば上のようなコードで、先頭95回は飛ばして残りのところをみたい、というような場合です。

ケース2: ある関数がいろいろな関数から呼ばれるが、特定の関数から呼ばれているようなときだけブレークポイントを止めたい

デバッグに限らず、何か既存のプログラムの挙動を調べるためにブレークポイントを置いて、適当な入力についてプログラムを回してみる、ということがあります。例えば、以下の例について、関数hの挙動が気になるのでここにブレークポイントを置いてみたとします。

#include <cstdio>

using namespace std;

void h(){  // fからもgからもよばれる
    printf("h is called!\n");  // gからよばれたときだけブレークポイントで止めたい
}

void f(){
    printf("f is called!\n");
    h();
}

void g(){
    printf("g is called!\n");
    h();
}

int main(){
    for(int i=0; i < 100; i++){
        if(i != 71){
            f();
        }else{
            g();
        }
        printf("i = %d\n", i);
    }
    return 0;
}

この例だと、hはfから沢山よばれるのですが、gからは滅多に呼ばれません。なので、gからよばれたときのhの挙動が気になりますが、普通にhに置いてしまうと滅多に起こらないことのためにcontinueをカチカチする必要があります。一応gに置いてそこからステップインする方法もありますが、それを毎回やるのも面倒です。

ケース3: ある関数がいろいろな関数から呼ばれるが、特定の関数から呼ばれているようなときだけブレークポイントを無視し、他の場合だけ止めたい

ケース2の丁度裏側です。関数hがfやgなどいろいろな関数から呼ばれるとします。うち、fからよばれるのはよくあることですが、今の入力の実行について他のどこから呼ばれるかという挙動を観察したいとします。このとき、hにブレークポイントを置くが、fから呼ばれているときにはブレークポイントを止めないようにできれば観察がはかどりそうです。

このような問題をどう解決する?

以上のような問題をまとめて解決するために、次のようなブレークポイントがあればよいのではないかと考えました。

実装

gdbPython拡張としてこれを実装してみました。実装は以下のとおりです。

import gdb
import re


bplog = ""
pat = None

class MyBreakPoint(gdb.Breakpoint):
    def __init__(self, label, spec):
        self.label = label
        super().__init__(spec)
    def stop(self):
        global bplog
        bplog += self.label
        # print("log:" + bplog)
        if re.match(pat, bplog):
            return True
        else:
            return False

class RegisterNamedBreakPoint(gdb.Command):
    def __init__(self):
        super().__init__("register-labelled-break-point", gdb.COMMAND_USER)
    def invoke (self, arg, from_tty):
        splitted = arg.split(" ")
        label = splitted[0]
        rest = " ".join(splitted[1:])
        # argをもとにブレークポイントをつくる    
        print(f"Registered label {label}")
        MyBreakPoint(label, rest)


class RegisterCondition(gdb.Command):
    def __init__(self):
        super().__init__("register-regular-expression", gdb.COMMAND_USER)
    def invoke(self, arg, from_tty):
        global pat
        pat = re.compile(arg)

class ShowLogLabels(gdb.Command):
    def __init__(self):
        super().__init__("show-log-labels", gdb.COMMAND_USER)
    def invoke(self, arg, from_tty):
        global bplog
        print(bplog)

RegisterNamedBreakPoint()
RegisterCondition()
ShowLogLabels()

使い方は簡単で、上のPythonファイルをbreakpoint-re.pyという名前で保存し、gdbの実行時オプションで「-x breakpoint-re.py」とするか、あるいは起動後に「source breakpoint-re.py」とします*1。この拡張を有効にすることによって、gdbに以下の3つのコマンドが追加されます。

上2つのコマンドを使って、ラベルつきブレークポイントを設置し、パターンを設定した上であとは普通に実行すれば、パターンに一致するところでのみ停止します。show-log-labelsは、上でお話した「ブレークポイント用ログ」を出力するものです。様子をみるために使ってください。

使用例

ケース1について

以下の使用例では、7行目に「a」というラベルのブレークポイントを置き、正規表現パターンとして「a{95}」を指定しています。これによって、aが95回印字されたところで始めて停止するようになり、最初のほうはブレークポイントがスキップされてただ「hoge 5」のように出力されています。

ashiato45@DESKTOP-648N1H1:~/gdbtest$ gdb -x breakpoint-re.py ./a.out
(中略)
Reading symbols from ./a.out...
(gdb) register-labelled-break-point a skip_first.cpp:7
Registered label a
Breakpoint 1 at 0x1162: file skip_first.cpp, line 7.
(gdb) register-regular-expression a{95}
(gdb) run
Starting program: /home/ashiato45/gdbtest/a.out
hoge 0
hoge 1
hoge 2
(中略)
hoge 92
hoge 93

Breakpoint 1, main () at skip_first.cpp:7
7               printf("hoge %d\n", i);
(gdb) show-log-labels
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
(gdb) c
Continuing.
hoge 94
(中略)
Breakpoint 1, main () at skip_first.cpp:7
7               printf("hoge %d\n", i);
(gdb) c
Continuing.
hoge 99
[Inferior 1 (process 19886) exited normally]
(gdb) show-log-labels
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
(gdb)
ケース2について

以下の使用例では、6行目に「h」というラベルのブレークポイントを、15行目に「g」というラベルのブレークポイントを置き、正規表現パターンとして「.*gh$」を指定しています。これによって、gからhがよばれたところで停止するようになり、かつ他のところではラベルの末尾が「hh」になるはずなので停止せずスキップされます。

ashiato45@DESKTOP-648N1H1:~/gdbtest$ gdb -x breakpoint-re.py ./a.out
(中略)
Reading symbols from ./a.out...
(gdb) register-labelled-break-point h from_one_function.cpp:6
Registered label h
Breakpoint 1 at 0x1171: file from_one_function.cpp, line 6.
(gdb) register-labelled-break-point g from_one_function.cpp:15
Registered label g
Breakpoint 2 at 0x11a4: file from_one_function.cpp, line 15.
(gdb) register-regular-expression .*gh$
(gdb) run
Starting program: /home/ashiato45/gdbtest/a.out
f is called!
h is called!
i = 0
f is called!
h is called!
(中略)
i = 69
f is called!
h is called!
i = 70
g is called!

Breakpoint 1, h () at from_one_function.cpp:6
6           printf("h is called!\n");  // gからよばれたときだけブレークポイントで止めたい
(gdb) c
Continuing.
h is called!
i = 71
f is called!
h is called!
(中略)
i = 98
f is called!
h is called!
i = 99
[Inferior 1 (process 24883) exited normally]
(gdb)

改善の余地

  • 今回はログを全部記録し、毎ブレークポイント正規表現のマッチをしていてそれなりの計算量がかかっているが、最初に正規表現パターンに対応するオートマトンを構成してしまい、ラベルごとにオートマトンを1ステップするようにすればO(1)で計算がすむし、ログの記録はそもそもいらなくなる。
  • 本当はVisual StudioVSCodeでもやりたい。
  • エラートラップ全然してない
  • ていうか1970年代にAhoとかがやってそう

さんくす

竹(@takemioIO)さん: デバッガをいじりたいと言ったときにgdbPython拡張を提案してくださいました。ありがとう!

*1:gdbPython拡張を有効にした状態でビルドされている必要があります。