###導入###
| メモリ アドレスにブレークポイントを設定するのは便利ですが、最も使いやすいツールとは言えません。コードと同じ抽象化レベルでデバッグできるように、ソース コード行と関数エントリ アドレスにブレークポイントを設定できるようにしたいと考えています。
|
この記事では、ソースレベルのブレークポイントをデバッガーに追加します。すでにサポートされているすべての機能を使用すると、これは最初に思ったよりもはるかに簡単です。また、シンボルのタイプとアドレスを取得するコマンドも追加します。これは、コードやデータを見つけたり、リンクの概念を理解したりするのに役立ちます。
シリーズインデックス
今後の記事が公開されると、これらのリンクは徐々に有効になります。
- 環境の準備
- ブレークポイント
- レジスタとメモリ
- エルフとドワーフ
- ソースコードとシグナル
- ソースコードレベルのステップバイステップ実行
- ソースレベルのブレークポイント
- コールスタック
- 変数の読み取り
###次のステップ###
-
ブレークポイント
ドワーフ
エルフとドワーフ この記事では、DWARF デバッグ情報がどのように機能するか、またそれを使用してマシン コードを高レベル ソース コードにマッピングする方法について説明します。 DWARF には、関数のアドレス範囲と、抽象化レイヤー間でコードの位置を変換できる行テーブルが含まれていることを思い出してください。これらの関数を使用してブレークポイントを実装します。
関数エントリ
関数名にブレークポイントを設定するのは、オーバーロードやメンバー関数などを考慮すると少し複雑になる可能性がありますが、すべてのコンパイル単位を調べて、探している名前に一致する関数を検索します。 DWARF 情報は次のようになります:
リーリー
DW_AT_name を一致させ、DW_AT_low_pc (関数の開始アドレス) を使用してブレークポイントを設定したいと考えています。
リーリー
このコードで少し奇妙に見える唯一の点は、エントリです。問題は、関数の DW_AT_low_pc が関数のユーザー コードの開始アドレスを指しておらず、プロローグの先頭を指していることです。コンパイラは通常、スタックの保存と復元、スタック ポインタの操作などを実行するために使用される関数のプロローグとエピローグを出力します。これはあまり役に立たないので、エントリ行を 1 つ増やして、プロローグの代わりにユーザー コードの最初の行を取得します。 DWARF 行テーブルには実際には、関数プロローグの後の最初の行としてエントリをマークする機能がいくつかありますが、すべてのコンパイラがそれを出力するわけではないため、私は元のアプローチを採用しました。
ソースコード行
高レベルのソース コード行にブレークポイントを設定するには、行番号を DWARF のアドレスに変換する必要があります。コンパイル単位を反復処理して、指定されたファイルと名前が一致するコンパイル単位を探し、その後、指定された行に対応するエントリを探します。
DWARF は次のようになります:
リーリー
したがって、ab.cpp の 5 行目にブレークポイントを設定したい場合は、行 (0x004004e3) に関連付けられたエントリを探してブレークポイントを設定します。
リーリー
ここで is_suffix ハックを行ったので、a/b/c.cpp に対して c.cpp と入力できるようになります。もちろん、実際には大文字と小文字を区別するパス処理ライブラリか何かを使用する必要がありますが、私は怠け者です。 entry.is_stmt は、行テーブル エントリがステートメントの先頭としてマークされているかどうかをチェックします。ステートメントの先頭は、ブレークポイントの最適なターゲットであると考えられるアドレスに基づいてコンパイラによって設定されます。
シンボル検索
オブジェクト ファイル レベルでは、シンボルが重要です。関数にはシンボルが付けられ、グローバル変数にはシンボルが付けられ、あなたもシンボルを取得し、私たちもシンボルを取得し、誰もがシンボルを取得します。特定のオブジェクト ファイルでは、一部のシンボルが他のオブジェクト ファイルまたは共有ライブラリを参照する場合があり、リンカはシンボル参照から実行可能プログラムを作成します。
シンボルは、バイナリ ファイルの ELF セクションに保存されている適切な名前のシンボル テーブルで検索できます。幸いなことに、libelfin にはこれを行うための優れたインターフェイスがあるため、ELF に関するすべての処理を自分たちで行う必要はありません。私たちが何を扱っているかを理解していただくために、readelf によって生成されたバイナリの .symtab 部分のダンプを次に示します。
リーリー
オブジェクト ファイルには環境を設定するために使用される多くのシンボルが表示され、最後にメインのシンボルが表示されます。
シンボルのタイプ、名前、値 (アドレス) に興味があります。その型のsymbol_type列挙型があり、名前としてstd::string、アドレスとしてstd::uintptr_tを使用します。
リーリー
依存関係によってこのインターフェイスが破壊されることを望まないため、libelfin から取得したシンボル タイプを列挙型にマップする必要があります。幸いなことに、すべてに同じ名前を選択したので、簡単でした:
リーリー
最後にシンボルを見つけなければなりません。説明のために、シンボル テーブルの ELF 部分をループし、そこで見つかったシンボルを std::vector に収集します。より賢い実装では、名前からシンボルへのマッピングを確立できるため、データを一度確認するだけで済みます。
std::vector debugger::lookup_symbol(const std::string& name) {
std::vector syms;
for (auto &sec : m_elf.sections()) {
if (sec.get_hdr().type != elf::sht::symtab && sec.get_hdr().type != elf::sht::dynsym)
continue;
for (auto sym : sec.as_symtab()) {
if (sym.get_name() == name) {
auto &d = sym.get_data();
syms.push_back(symbol{to_symbol_type(d.type()), sym.get_name(), d.value});
}
}
}
return syms;
}
ログイン後にコピー
添加命令
一如往常,我们需要添加一些更多的命令来向用户暴露功能。对于断点,我使用 GDB 风格的接口,其中断点类型是通过你传递的参数推断的,而不用要求显式切换:
- 0x -> 断点地址
- : -> 断点行号
- -> 断点函数名
else if(is_prefix(command, "break")) {
if (args[1][0] == '0' && args[1][1] == 'x') {
std::string addr {args[1], 2};
set_breakpoint_at_address(std::stol(addr, 0, 16));
}
else if (args[1].find(':') != std::string::npos) {
auto file_and_line = split(args[1], ':');
set_breakpoint_at_source_line(file_and_line[0], std::stoi(file_and_line[1]));
}
else {
set_breakpoint_at_function(args[1]);
}
}
ログイン後にコピー
对于符号,我们将查找符号并打印出我们发现的任何匹配项:
else if(is_prefix(command, "symbol")) {
auto syms = lookup_symbol(args[1]);
for (auto&& s : syms) {
std::cout << s.name << ' ' << to_string(s.type) << " 0x" << std::hex << s.addr << std::endl;
}
}
ログイン後にコピー
测试一下
在一个简单的二进制文件上启动调试器,并设置源代码级别的断点。在一些 foo 函数上设置一个断点,看到我的调试器停在它上面是我这个项目最有价值的时刻之一。
符号查找可以通过在程序中添加一些函数或全局变量并查找它们的名称来进行测试。请注意,如果你正在编译 C++ 代码,你还需要考虑名称重整。
本文就这些了。下一次我将展示如何向调试器添加堆栈展开支持。
你可以在这里找到这篇文章的代码。