デバッグの手法
プログラムをデバッグするとき、基本的な手段として、ブレークポイントやステップ実行を使うことが多いと思います。これらは「プログラムを好きな場所で一時停止させて、スコープの状態を確認する」ことができる強力な手段です。
ただし、ステップ実行とは相性の悪いケースもあります。
- 止めずに追跡したい:UIやデバイスなどリアルタイム系の処理
- 変化点から遡りたい:ある値が異常化した瞬間までに、どの分岐を通ってきたか
- ループの統計を取りたい:大量に通るループでの実行パターンを把握する
こういうときに、プログラムの実行経路をまとめて観測できる手段があれば、デバッグが捗る気がしませんか?
これを叶えることのできる技術に、動的バイナリ計装というものがあります。
ここでは、動的バイナリ計装を行うツールの一つである DynamoRIO を使って、アプリケーションの実行経路を収集し、Siv3Dで可視化するプログラムを作ってみました。
このプログラムは、実行しているアプリケーションが通ったソースコードの行をハイライト表示で可視化するものです。横軸は時系列で、ここではループ回数と対応しています。gifを見ると「11行目の"FizzBuzz"を出力する行は、15回に1回の頻度で通っている」ということが確認できます。
この記事では、DynamoRIO の使い方と、デバッグ情報を活用してプログラムの実行フローを解析する方法について解説します。
動的バイナリ計装
動的バイナリ計装(dynamic binary instrumentation: DBI)とは、実行中のアプリケーションに対して、ソースコードを変更することなく、外部から解析用コードを挿入し、アプリの挙動を監視・操作する技術のことです。
主要な DBI ツールには、DynamoRIO / Intel Pin / Frida などのソフトウェアが挙げられます。
プログラムを書き換える仕組み
DBI ツールはどうやって解析対象とするアプリケーション(.exe)の命令を書き換えているのでしょうか?
DynamoRIO の実行の仕組みについて、以下でサンプルコードを例に説明します。
まずプログラム全体の実行フローは、下図のようになっています。この中で、ユーザーが実装をするのは、左下部分のクライアントツール(DLL)です。これが実行エンジン(drrun.exe)から呼び出されるので、この中に追加したい処理を実装していきます。
DynamoRIO は最初に、読み込んだバイナリを基本ブロック1の単位でデコードし、編集可能な内部表現(命令リスト)を作ります。
次にそれぞれの基本ブロックに対して、基本ブロックイベント(BBイベント)を発行します。このイベントをトリガーとして、ユーザーは命令の挿入・置換・削除を行うことができます。
クライアントツールに実装するコードはこんな感じです。
#include "dr_api.h"
#include "drmgr.h"
// 基本ブロックイベントのコールバック処理
static dr_emit_flags_t event_bb_insert(void* drcontext, void* tag, instrlist_t* bb,
instr_t* inst, bool for_trace, bool translating, void* user_data)
{
// bb: 基本ブロック全体の命令リスト
// inst: 現在処理中の1命令
// ここでbbを編集して命令列を書き換える
}
// アプリケーション終了イベントのコールバック処理
static void on_exit()
{
drmgr_exit();
}
// dr_client_main: クライアントツールの起動時に呼ばれる
DR_EXPORT void dr_client_main(client_id_t, int argc, const char* argv[])
{
drmgr_init();
// コールバックの登録
drmgr_register_bb_instrumentation_event(nullptr, event_bb_insert, nullptr);
drmgr_register_exit_event(on_exit);
}
DynamoRIO から提供される命令列は、デコードされた内部表現(アセンブリのようなもの)であり、直接編集するのはなかなか大変です。
そこで、アプリに処理を追加するときは、呼んでほしい関数をクライアントツール側で定義しておいて、その関数をアプリ側から呼び出す命令をbbに追加する(dr_insert_clean_call)ことによって、簡単に追加処理を実装することができます。
// clean call から呼ばれる関数
static void print_pc(app_pc pc)
{
dr_printf("pc = %p\n", pc);
}
// 基本ブロックイベントのコールバック処理
static dr_emit_flags_t event_bb_insert(void* drcontext, void* tag, instrlist_t* bb,
instr_t* inst, bool for_trace, bool translating, void* user_data)
{
// イベントではDynamoRIO側が挿入したメタ命令が来ることもあるので、アプリ命令だけを対象にする
if (!instr_is_app(inst))
return DR_EMIT_DEFAULT;
// inst(=現在の命令)のアプリ側アドレス値を取得
app_pc pc = instr_get_app_pc(inst);
if (pc == nullptr)
return DR_EMIT_DEFAULT;
// inst の直前に clean call を挿入して pc を渡す
dr_insert_clean_call(drcontext, bb, inst,
(void*)print_pc,
false,
1, // 渡す引数の個数
OPND_CREATE_INTPTR(pc)); // DynamoRIOの内部表現型にキャストして渡す
return DR_EMIT_DEFAULT;
}
イベント処理による命令の書き換えが完了したら、DynamoRIO は編集後の命令列を機械語に再エンコードして、コードキャッシュに配置します。CPU が実際に実行するのは、このコードキャッシュ上の命令列です。
DynamoRIOの実行サンプル
サンプルとして、アプリケーションで実行された基本ブロックの数をカウントするコードを紹介します。
DynamoRIOの導入方法は、以下のreleasesからWindows版をダウンロードします。
クライアントツールの実装
クライアントツールのビルドは CMake を使って行います。
cmake_minimum_required(VERSION 3.20)
project(trace_client LANGUAGES C CXX)
find_package(DynamoRIO REQUIRED)
add_library(bb_count SHARED bb_count.cpp)
configure_DynamoRIO_client(bb_count)
use_DynamoRIO_extension(bb_count drmgr)
set_target_properties(bb_count PROPERTIES PREFIX "" SUFFIX ".dll")
target_compile_features(bb_count PRIVATE cxx_std_20)
// 実行された基本ブロック(BB)の回数を数える
#include "dr_api.h"
#include <stdint.h>
static uint64_t g_bb_count = 0; // BB実行回数
// clean call から呼ばれる関数
static void on_bb_exec()
{
g_bb_count++;
}
// 基本ブロックイベントのコールバック処理
static dr_emit_flags_t event_bb(void *drcontext, void *tag, instrlist_t *bb, bool for_trace, bool translating)
{
// BB先頭のアプリ命令を取得(メタ命令はスキップ)
instr_t *where = instrlist_first_app(bb);
if (where != NULL)
{
// BB先頭に clean call を挿入(引数なし)
dr_insert_clean_call(drcontext, bb, where, (void *)on_bb_exec, false, /*num_args=*/0);
}
return DR_EMIT_DEFAULT;
}
// 終了時に結果を出力
static void event_exit(void)
{
dr_fprintf(STDERR, "[bb_count] BB executed = %llu\n", (unsigned long long)g_bb_count);
dr_mutex_destroy(g_lock);
}
DR_EXPORT void dr_client_main(client_id_t id, int argc, const char *argv[])
{
dr_set_client_name("bb_count", "");
g_lock = dr_mutex_create();
// コールバック登録
dr_register_exit_event(event_exit);
dr_register_bb_event(event_bb);
}
ビルド方法
ダウンロードした DynamoRIO のフォルダパスをDynamoRIO_ROOTに指定して CMake を実行します。
cd bb_count/build
cmake -G "Visual Studio 17 2022" -A x64 -DDynamoRIO_ROOT="ここにDynamoRIOのフォルダパス" ..
cmake --build . --config Release
実行
ビルドすると./Release/bb_count.dllが出力されます。
実行するには、ダウンロードしたDynamoRIO/bin64/drrun.exeに実装した DLL とアプリのパスを渡して起動します。
./DynamoRIO/bin64/drrun.exe -c ./Release/bb_count.dll -- 解析対象のアプリのパス
実行結果:
...
[bb_count] BB executed = 641574
デバッグ情報を読み取る
Visual Studio でアプリケーションをビルドすると、実行ファイル(.exe)と一緒にデバッグ情報(.pdb)が出力されます。
PDB ファイルには、ビルドに関するさまざまな情報が記録されています。
- 実行ファイルのファイルパス
- シンボル情報(名前とアドレス値の対応)
- 型情報
- ソースのファイルパス・行番号 など
PDB を参照することで、実行している命令のアドレス値とソースコードの行数やシンボル情報を対応付けることができます。
ビルド設定によっては、必要なデバッグ情報が出力されません。以下の項目に気を付けておくと良いでしょう。
- C/C++ > 全般 > デバッグ情報の形式(
/Z7or/Zior/ZI) - C/C++ > 最適化 > 最適化:できれば無効(
/Od) - リンカー > デバッグ > デバッグ情報の生成(
/DEBUGor/DEBUG:FASTLINKor/DEBUG:FULL)
PDBファイルを読む方法
Visual Studio に含まれている Debug Interface Access (DIA) SDK を使うことで、PDBファイルの中身にアクセスすることができます。
DIA SDK は COM API を使用して、PDB の情報を検索・列挙するためのライブラリです。
DIA SDKの使い方
Visual Studio のビルド設定には、以下のパスを追加しておきます。
- インクルード ディレクトリ:
$(VSInstallDir)DIA SDK\include - ライブラリ ディレクトリ:
$(VSInstallDir)DIA SDK\lib
(1) DiaDataSourceを作る
DiaDataSourceは「デバッグ情報をどこから読み込むか」を表すオブジェクトで、PDB を開くためのローダの役割をします。
また、DIA SDK を使用するには、事前にmsdia140.dllを読み込んでおく必要があります。
static HRESULT CreateDiaDataSource(const wchar_t* msdiaDllPath, IDiaDataSource** outSrc)
{
*outSrc = nullptr;
// msdia140.dll を読み込む
HMODULE h = LoadLibraryW(msdiaDllPath);
if (!h) return HRESULT_FROM_WIN32(GetLastError());
using DllGetClassObjectFn = HRESULT(WINAPI*)(REFCLSID, REFIID, LPVOID*);
auto getClassObject = reinterpret_cast<DllGetClassObjectFn>(
GetProcAddress(h, "DllGetClassObject"));
if (!getClassObject) { FreeLibrary(h); return E_NOINTERFACE; }
// COM インターフェースを用いて DiaDataSource のインスタンスを作る
CComPtr<IClassFactory> factory;
HRESULT hr = getClassObject(__uuidof(DiaSource), IID_PPV_ARGS(&factory));
if (FAILED(hr)) { FreeLibrary(h); return hr; }
return factory->CreateInstance(nullptr, __uuidof(IDiaDataSource), (void**)outSrc);
}
msdia140.dllでPC内を検索すると、サイズの異なるものがたくさんヒットしてどれを使えばいいのか迷います。自分は以下のパスにあるものを使用しました。
C:\Program Files\Microsoft Visual Studio\2022\Community\DIA SDK\bin\amd64\msdia140.dll
この DLL は改変なしで再頒布可能なので2、プログラムにそのまま同梱してしまうのが簡単だと思います。
(2) EXE から Session を開く
次に、作成したDiaDataSourceを用いてDiaSessionを初期化します。
このDiaSessionが、デバッグ情報にアクセスするためのインターフェースとなります。
static bool OpenDiaForExe(
const wchar_t* exePath,
const CComPtr<IDiaDataSource>& src,
CComPtr<IDiaSession>& outSes)
{
// loadDataForExe を呼ぶと EXE に紐づけられた PDB が自動的に読み込まれる
if (FAILED(src->loadDataForExe(exePath, nullptr, nullptr))) return false;
if (FAILED(src->openSession(&outSes))) return false;
return true;
}
(3) VA(仮想アドレス) から 行情報を得る
プログラム命令のアドレス値を findLinesByVA 関数に渡すことで、ソースコード行の情報を取得します。
struct SrcPos
{
std::wstring file;
DWORD line = 0;
};
static bool VaToLine(IDiaSession* ses, ULONGLONG va, SrcPos& out)
{
out = {};
// findLinesByVA: アドレスvaに対応する行情報のエントリを列挙する
CComPtr<IDiaEnumLineNumbers> e;
if (FAILED(ses->findLinesByVA(va, 1, &e))) return false;
// エントリの最初の要素を取得
CComPtr<IDiaLineNumber> ln;
ULONG fetched = 0;
if (e->Next(1, &ln, &fetched) != S_OK || fetched == 0) return false;
// 行情報からソースファイル情報を取得
CComPtr<IDiaSourceFile> sf;
if (FAILED(ln->get_sourceFile(&sf))) return false;
// ソースファイルのパスを取得
BSTR b = nullptr;
if (FAILED(sf->get_fileName(&b))) return false;
out.file = b ? b : L"";
SysFreeString(b);
// 行番号を取得
DWORD line = 0;
if (FAILED(ln->get_lineNumber(&line))) return false;
out.line = line;
return true;
}
多くの場合、実行中のプログラムの仮想アドレスをそのまま findLinesByVA に渡すだけだと、正しいデバッグ情報を得ることができません。
これは、ASLR (address space layout randomization) という仕組みによって、読み込んだ EXE や DLL が実際に配置されるメモリの開始アドレスがランダム化されるためです。
ASLR への対応をするには、DIA SDK ではfindLinesByVA を呼ぶ前に、EXE モジュールのベースアドレス値を put_loadAddress に渡しておくことが推奨されます。これにより、内部で仮想アドレスをベースからの相対アドレスに変換してくれるようになるので、結果として正しい情報を得られます3。
また、Visual Studio では ASLR はデフォルト有効で、ビルド設定「リンカー > 詳細設定 > ランダム化されたベースアドレス(/DYNAMICBASE)」で変更できます4。
DIA SDK のサンプルコード全体
EXE ファイルのパスをコマンドライン引数で渡すと、バイナリの先頭から探索して最初に VaToLine がヒットした行数をプリントするプログラムです。大体ソースファイルの先頭に定義した関数の行数が取れると思います。
#include <iostream>
#include <dia2.h>
#include <atlbase.h>
#pragma comment(lib, "diaguids.lib")
// (1) `DiaDataSource`を作る
static HRESULT CreateDiaDataSource(const wchar_t* msdiaDllPath, IDiaDataSource** outSrc)
{
*outSrc = nullptr;
// msdia140.dll を読み込む
HMODULE h = LoadLibraryW(msdiaDllPath);
if (!h) return HRESULT_FROM_WIN32(GetLastError());
using DllGetClassObjectFn = HRESULT(WINAPI*)(REFCLSID, REFIID, LPVOID*);
auto getClassObject = reinterpret_cast<DllGetClassObjectFn>(
GetProcAddress(h, "DllGetClassObject"));
if (!getClassObject) { FreeLibrary(h); return E_NOINTERFACE; }
// COM インターフェースを用いて DiaDataSource のインスタンスを作る
CComPtr<IClassFactory> factory;
HRESULT hr = getClassObject(__uuidof(DiaSource), IID_PPV_ARGS(&factory));
if (FAILED(hr)) { FreeLibrary(h); return hr; }
return factory->CreateInstance(nullptr, __uuidof(IDiaDataSource), (void**)outSrc);
}
// (2) EXE から Session を開く
static bool OpenDiaForExe(
const wchar_t* exePath,
const CComPtr<IDiaDataSource>& src,
CComPtr<IDiaSession>& outSes)
{
// loadDataForExe を呼ぶと EXE に紐づけられた PDB が自動的に読み込まれる
if (FAILED(src->loadDataForExe(exePath, nullptr, nullptr))) return false;
if (FAILED(src->openSession(&outSes))) return false;
return true;
}
struct SrcPos
{
std::wstring file;
DWORD line = 0;
};
// (3) VA(仮想アドレス) から 行情報を得る
static bool VaToLine(IDiaSession* ses, ULONGLONG va, SrcPos& out)
{
out = {};
// findLinesByVA: アドレスvaに対応する行情報のエントリを列挙する
CComPtr<IDiaEnumLineNumbers> e;
if (FAILED(ses->findLinesByVA(va, 1, &e))) return false;
// エントリの最初の要素を取得
CComPtr<IDiaLineNumber> ln;
ULONG fetched = 0;
if (e->Next(1, &ln, &fetched) != S_OK || fetched == 0) return false;
// 行情報からソースファイル情報を取得
CComPtr<IDiaSourceFile> sf;
if (FAILED(ln->get_sourceFile(&sf))) return false;
// ソースファイルのパスを取得
BSTR b = nullptr;
if (FAILED(sf->get_fileName(&b))) return false;
out.file = b ? b : L"";
SysFreeString(b);
// 行番号を取得
DWORD line = 0;
if (FAILED(ln->get_lineNumber(&line))) return false;
out.line = line;
return true;
}
int wmain(int argc, wchar_t** argv)
{
if (argc < 2)
{
std::wcerr << L"Usage: " << argv[0] << L" <exePath>\n";
return 1;
}
const wchar_t* exePath = argv[1];
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
{
std::wcerr << L"CoInitializeEx failed\n";
return 1;
}
CComPtr<IDiaDataSource> src;
const wchar_t* msdiaPath = LR"(C:\Program Files\Microsoft Visual Studio\2022\Community\DIA SDK\bin\amd64\msdia140.dll)";
if (FAILED(CreateDiaDataSource(msdiaPath, &src)))
{
CoUninitialize();
std::wcerr << L"CreateDiaDataSource failed: " << GetLastError();
return 1;
}
CComPtr<IDiaSession> ses;
if (!OpenDiaForExe(exePath, src, ses))
{
CoUninitialize();
std::wcerr << L"OpenDiaForExe failed: " << GetLastError();
return 1;
}
SrcPos pos;
for (ULONGLONG i = 0;; ++i)
{
// 4byteずつ探索する
const ULONGLONG va = 4 * i;
if (VaToLine(ses, va, pos))
{
std::wcout << L"Address: 0x" << std::hex << va << L" -> " << pos.file << L"(" << pos.line << L")\n";
break;
}
}
CoUninitialize();
return 0;
}
C++実行トレーサーの構成
ここからは Siv3D を使って作ったツールの説明になります。まず、作成したツールは全体としてこのような構成です。
DynamoRIO は基本ブロックごとにクリーンコールを追加します。その中で現在のプログラムカウンタ5の値をビューワーに送信します。
ビューワーは受け取ったプログラムカウンタを PDB と照合することで、現在実行しているソースコードのファイルパスと行数を得る、という仕組みです。
プロセス間の情報の受け渡し
DynamoRIO とビューワーはプロセスが異なるので、変数を直接渡すことはできません。プロセス同士で情報をやり取りする手段として、memory-mapped file を使った共有メモリを作りました。
DynamoRIO はクライアントツールに任意のコマンドライン引数を渡すことができるので、最初にビューワー側で共有メモリの名前を決めておき、DynamoRIO の起動時に渡します。
// 起動ごとに一意な名前を作成
const auto uuidStr = CreateUUID();
wchar_t shmName[128];
swprintf_s(shmName, L"Local\\bbtrace_shm_%ls", uuidStr.c_str());
// (途中省略)
CreateProcessW(
drrunPath.c_str(),
cmdlineBuf.data(), // "drrun.exe -c clientPath --channel shmName -- appPath"
nullptr, nullptr,
FALSE,
CREATE_UNICODE_ENVIRONMENT,
nullptr,
nullptr,
&si, &pi
);
クライアントツールでは、受け取った名前で memory-mapped file を作成します。
g_hMap = CreateFileMappingW(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, (DWORD)sizeof(ShmLayout), channelName);
void* p = MapViewOfFile(g_hMap, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(ShmLayout));
g_shm = static_cast<ShmLayout*>(p);
そして、DynamoRIO のイベント処理の中で、この共有メモリに書き込み、ビューワー側で定期的に読み出す処理を実装しました。
DynamoRIOからビューワーに送る情報
DynamoRIO の基本ブロックイベントとモジュール読み込みイベントを使用して、それぞれ以下の情報をビューワーに対して送ります。
-
Event_BB:基本ブロックイベント- プログラムカウンタ
- スレッドID
- タイムスタンプ
-
Event_Mod:モジュール読み込みイベント- ベースアドレス
- イメージサイズ
- ファイルパス
static void on_module_load(void* drcontext, const module_data_t* info, bool loaded)
{
uint64_t moduleBase = (uint64_t)info->start;
uint64_t moduleSize = (uint64_t)((byte*)info->end - (byte*)info->start);
std::string modulePath(info->full_path);
// moduleBase, moduleSize, modulePath を送信
}
実行パフォーマンスについて
DynamoRIO を通してアプリケーションを実行するだけで、何も処理を追加せずとも、コードキャッシュから命令を実行するためのコストが常にかかります。何もしない状態での DynamoRIO のオーバーヘッドは、平均で 11%程度6 になるそうです。これに加えて、追加した命令の処理時間がかかります。
また、今回のようにデバッグ情報と対応させるには最適化を無効にしないと情報を取れない可能性があります。ゲームなど重いアプリケーションに対して適用するのは難しいかもしれません。
おまけ:最適化オプションによる違い
最適化を有効にしたとき、結果がどの程度壊れるのか実験してみました。
/Od:最適化無効
冒頭のgifアニメと同じく最適化を無効にした場合です。通った場所を正確に捕捉できています。

/O1:最大最適化 (サイズを優先)
/O1を付けた場合、最初のif文までは正しく追えていますが、それ以降は全滅でした。

/O2:最大最適化 (速度を優先)
/O2を付けた場合は、意外にも通った箇所は/Odと同じく正確に取ることができました。ただ、アドレス位置から取得する行番号は多少前後にずれているようです。

/Ox:最適化 (速度を優先)
複雑なプログラムになるとまた違うかもしれません。FizzBuzzに関しては、/O2でも解析はできてそうで、/O1はダメダメという結果になりました。
-
基本ブロックとは、分岐やループなどのジャンプ命令で区切られた、連続して実行される命令区間のことです。 ↩
-
https://learn.microsoft.com/ja-jp/visualstudio/releases/2022/redistribution#dia-sdk ↩
-
https://learn.microsoft.com/ja-jp/visualstudio/debugger/debug-interface-access/idiasession?view=visualstudio#remarks ↩
-
https://learn.microsoft.com/ja-jp/cpp/build/reference/dynamicbase-use-address-space-layout-randomization?view=msvc-170 ↩
-
ここでいうプログラムカウンタとは、クリーンコール上で直接見たレジスタ値(=コードキャッシュのアドレス値)ではなく、DynamoRIOのAPI(
instr_get_app_pc)を通して元のバイナリを実行したときの値を取得したものなので、pdbのデバッグ情報と照合することができます。 ↩ -
https://static.googleusercontent.com/media/research.google.com/ja//pubs/archive/38225.pdf ↩




