こんにちは!開発の現場で、こんな壁にぶugぶつかったことはありませんか?
「自分でゼロから作った言語処理系(JITコンパイラ)が、メモリ上で生成した動的な機械語を実行した瞬間に、『Segmentation fault (core dumped)』 で沈黙した……」
「普通のC/C++プログラムならLLDBやGDBで一発なのに、動的に吐き出したコードだからソースコードの行番号もシンボル名も分からない。見えるのは無機質なメモリアドレスだけだ……」
LLVMをバックエンドに使って独自のプログラミング言語やDSL(ドメイン特化言語)を作っていると、必ずこの「ブラックボックスの悪夢」に直面します。静的なバイナリファイルならデバッガが勝手に教えてくれるデバッグ情報(DWARFなど)が、メモリ上にオンザフライで生成されるJIT(Just-In-Time)コードでは、OSやデバッガに伝わらないからです。
でも、安心してください。世界最高峰のデバッガである LLDB に「ここはこういうソースコードから生成された仮想的なファイルなんだよ」と正しく教え込む技術をマスターすれば、JITコードの内部であっても、まるで普通のC言語のようにソースコードレベルでステップ実行し、変数の値を手に取るように監視することができるようになります。
今回は、LLVMベースのJIT開発者が知るべき、涙が出るほど実務で役立つ「LLDBのJITデバッグ:動的コード生成時のセグフォ特定とソースレベルデバッグの極意」を、優しく丁寧にお伝えします。これをマスターすれば、あなたの言語処理系開発のスピードは文字通り何倍にも跳ね上がりますよ!
—
1. そもそも「JITデバッグ」とは何をしているのか?(基本概念)
私たちが普段使っているGDBやLLDBは、コンパイル済みのバイナリファイル(ELFやMach-O)と、そこに埋め込まれたデバッグ情報(DWARF)を読み込んでデバッグを行います。
しかし、JITコンパイラは違います。
1. ソースコードをパースし、
2. LLVMのIR(中間表現)を組み立て、
3. `LLVM JIT Engine (ORC JIT等)` を使って、メモリ上に直接「機械語(Machine Code)」を書き込みます。
4. そのメモリ領域にジャンプして実行します。
ここで問題が発生します。「メモリ上に直に置かれたコードには、ファイル名も行番号もない」のです。セグフォが起きたとき、OSが指し示すのは `0x7fff5fbff800` のようなメモリ番地だけ。これではどこがバグっているのか分かりませんよね。
解決アプローチの全体像
LLDBにJITコードをデバッグさせるためには、以下の2つのアプローチを組み合わせます。
- JITプロファイリング/デバッグリスナーの登録: LLVMの`JITEventListener`を有効化し、JITが機械語をメモリにロードした瞬間に、デバッガへ通知を送る仕組みを作ります。
- オブジェクトのダンプ(またはメモリマップの教え込み): LLVMが生成したオブジェクトファイルを一時ファイルとしてダンプし、それをLLDBに「このアドレス空間にロードされたものとしてシンボルをマッピングせよ」と指示します。
今回は、最も確実で現場で使える「JITオブジェクトのファイルダンプとシンボル手動マッピング(JITデバッグ用イメージの生成)」の手法をコードベースで見ていきましょう。
—
2. 環境構築と基礎セットアップ
まずは、現代のLLVM/Clang開発において必須となるツールチェーンを整えます。ここではmacOS(LLDBの母艦)またはLinux(Ubuntu等)を想定しています。
必要なツールのインストール(Ubuntuの場合の例)
LLVMの開発ライブラリと、モダンなLLDBがインストールされている必要があります。
LLVMおよびClangの開発パッケージとLLDBをインストール
sudo apt-get update
sudo apt-get install -y llvm-15-dev clang-15 lldb-15 liblldb-15-dev
コマンドのパスを通す(必要に応じてバージョンを合わせる)
export LLVM_DIR=/usr/lib/llvm-15
この環境がある前提で、C++(LLVM API)を使って「自作言語のJITコンパイラ」のモックを作成し、そこにLLDBをアタッチする仕組みを作っていきます。
—
3. 実践:JITコードのセグフォを暴く「JITデバッグ用イメージ」の生成コード
ここでは、LLVMのORC JIT(On-Request-Compilation JIT)を使い、動的に生成した関数(あえてポインタの不正アクセス=セグフォを起こすコード)を実行しつつ、LLDBでその中身を覗くための実装を行います。
以下のC++コード(`jit_debug_sample.cpp`)を見てください。これが今回の肝となります。
include “llvm/ExecutionEngine/Orc/LLJIT.h”
include “llvm/IR/IRBuilder.h”
include “llvm/IR/LLVMContext.h”
include “llvm/IR/Module.h”
include “llvm/Support/TargetSelect.h”
include “llvm/Support/raw_ostream.h”
include
include
// 1. LLVMのJIT環境を初期化し、意図的にセグフォを起こすコードを動的生成する関数
int main(int argc, char argv[]) {
// LLVMのネイティブターゲットを初期化(これがないと機械語が生成できない)
llvm::InitializeNativeTarget();
llvm::InitializeNativeTargetAsmPrinter();
// LLVMコンテキストとモジュール(ソースファイルの単位)を作成
llvm::LLVMContext Context;
auto Module = std::make_unique
llvm::IRBuilder<> Builder(Context);
// 2. IR(中間表現)で関数を構築する
// 相当する擬似コード: int crash_func() { int p = 0; return p; / セグフォ発生! / }
llvm::FunctionType FuncType = llvm::FunctionType::get(Builder.getInt32Ty(), false);
llvm::Function CrashFunc = llvm::Function::Create(
FuncType, llvm::Function::ExternalLinkage, “crash_func”, Module.get());
llvm::BasicBlock BB = llvm::BasicBlock::Create(Context, “entry”, CrashFunc);
Builder.SetInsertPoint(BB);
// ヌルポインタ(アドレス 0)からのロード命令を構築(これがセグフォの原因)
llvm::Value NullPtr = llvm::Constant::getNullValue(Builder.getInt32PtrTy());
llvm::Value LoadVal = Builder.CreateLoad(Builder.getInt32Ty(), NullPtr, “load_val”);
Builder.CreateRet(LoadVal);
// 3. ORC JITエンジンを構築
auto JIT = cantFail(llvm::orc::LLJITBuilder().create());
// 【重要】JITが生成したオブジェクトファイルをファイルにダンプする設定(LLDB用)
// 動的コードであっても、一度ディスクに `.o` として書き出すことでLLDBがシンボルを読めるようにする
JIT->getObjLinkingLayer().setObjectCache(nullptr); // カスタムキャッシュを設定可能
// モジュールをJITに追加
cantFail(JIT->addIRModule(std::move(llvm::orc::ThreadSafeModule(std::move(Module), std::make_unique
// 4. JITされた関数のアドレスをルックアップ
auto SymLook = JIT->lookup(“crash_func”);
if (!SymLook) {
llvm::errsStr() << "シンボルのルックアップに失敗しました\n";
return 1;
}
// 関数ポインタとしてキャスト
int (jit_func)() = SymLook->toPtr
std::cout << "JITコードの準備完了。関数アドレス: " << (void)jit_func << "\n"; std::cout << "これからセグフォを発生させます..." << std::endl; // 5. 実行(ここでSegmentation faultが発生する) int result = jit_func(); std::cout << "実行結果: " << result << "\n"; return 0; }
このコードのアーキテクチャ的なポイント
ここで重要なのは、「動的生成されたコードであっても、一度オブジェクトファイル(`.o`)としてメモリ上、あるいはディスク上にシリアライズ可能な状態を作る」という点です。完全なオンザフライ実行であっても、LLVMのリンクレイヤー(RuntimeDyldやJITLink)に対して「オブジェクトエミッター」を挟むことで、生成された機械語とDWARFデバッグ情報をファイルとして切り出すことができます。
—
4. LLDBを使った高度なデバッグ実践手順
では、上記のプログラムをビルドし、実際にLLDBを使ってJITコード内のセグフォを突き止めましょう。
ビルドコマンド
LLVMのライブラリをリンクしてビルドします(`llvm-config`を使用)。
g++ -g -O0 jit_debug_sample.cpp \
`llvm-config –cxxflags –ldflags –system-libs –libs core orcjit native` \
-o jit_debug_sample
(※ `-g` フラグをつけることで、C++ホスト側のデバッグ情報も保持されます)
ステップ1: LLDBでプログラムを起動する
まずはLLDBにプロセスをアタッチします。
lldb ./jit_debug_sample
LLDBが起動したら、プログラムを実行(`run`)します。予想通り、JIT領域でセグフォが発生してプログラムが停止するはずです。
(lldb) run
Process 12345 launched: ‘./jit_debug_sample’ (x86_64)
JITコードの準備完了。関数アドレス: 0x7f8841200000
これからセグフォを発生させます…
Process 12345 stopped
- thread #1, name = ‘jit_debug_sample’, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)
frame #0: 0x00007f8841200000
-> 0x7f8841200000: movl 0(%rax), %eax
0x7f8841200003: ret
おっ!見事に `EXC_BAD_ACCESS`(セグフォ)で止まりました。しかも、`0x00007f8841200000` というJITメモリ領域で `movl 0(%rax), %eax`(ヌルポインタデリreference)を実行して落ちていることが一目瞭然です。
ステップ2: LLDBにJITシンボルを教え込む(ここがプロの技)
現時点では、LLDBは `0x00007f8841200000` が何という関数なのか知りません(アドレスしか見えていません)。
もし、JITコンパイラ側で生成したオブジェクトファイルをダンプ(例: `dumped_jit_code.o`)している場合、LLDBの `target modules add` コマンドを使って、そのシンボルファイルを明的に読み込ませることができます。
LLDBのコンソールから、ダンプしたJITオブジェクトファイルを追加する
(lldb) target modules add dumped_jit_code.o
動的にロードされたメモリアドレスに、セクションをロード(再配置)させる
(実際のJITLinkのログやAPIから得られたロードベースアドレスを指定します)
(lldb) target modules load -f dumped_jit_code.o 0x7f8841200000
これにより、LLDBは「そのメモリ領域にある機械語は、元のソースコードのこの関数であり、この行番号に対応している」という対応関係(DWARF情報)を完全に理解します。
結果として、以下のようにJITで生成された関数の中でのスタックトレース(バックトレース)が美しく表示されるようになります。
(lldb) bt
- thread #1, name = ‘jit_debug_sample’, stop reason = EXC_BAD_ACCESS
- frame #0: 0x00007f8841200000 crash_func at
:1:12
frame #1: 0x000055555555b123 main + 150 at jit_debug_sample.cpp:45
frame #2: 0x00007ffff7a03bf7 __libc_start_main + 231
なんと、JITで生成したインメモリ関数である `crash_func` がバックトレース上にハッキリと現れました!これで、どの構文木ノードからこの不良コードが生成されたのかを逆算できるようになります。
—
5. 現場で役立つ!JITデバッグ効率を極限まで高めるための知見
最後に、言語処理系開発の現場で「これをやっておくと夜勤が消える」レベルの超実践的な知見をいくつかシェアします。
1. JITLinkのDumpObjects機能の活用
LLVMの新しいJITリンカである `JITLink` を使っている場合、環境変数 `LLVM_JITLINK_DUMP_OBJECTS=1` を設定してプログラムを実行するだけで、JITが生成したすべてのオブジェクトファイル(`.o`)を自動的にカレントディレクトリにダンプしてくれます。
これらをそのままLLDBにロードさせれば、複雑なコードを手動でダンプする仕組みを作らなくても、一発でソースレベルデバッグが可能になります。
2. ブレークポイントの事前仕込み(`__builtin_debugtrap()`の利用)
どうしてもLLDBでJIT関数の最初からステップインしたい場合は、LLVMのIR生成時に `llvm.debugtrap` イントロダクション(x86なら `int 3` 命令、ARMなら `brk` 命令)をコードの頭に挿入します。
これにより、JITコードの実行開始と同時にデバッガが自動的にトラップ(ブレーク)するため、アドレス迷子になることがなくなります。
—
まとめ
今回は、LLVMベースの言語処理系開発者が直面する「動的コード生成時のセグフォ」に対するLLDBのJITデバッグ手法を深く解説しました。
- 課題: JITコードはメモリ上に直接生成されるため、通常のデバッガではシンボルや行番号が分からない。
- 解決策: LLVMのJITオブジェクトダンプ機能や `JITLink` を用いてオブジェクトファイルを切り出し、LLDBの `target modules load` でメモリにシンボルをマッピングする。
- メリット: メモリ上の動的コードであっても、スタックトレースやソースレベルのデバッグが可能になり、バグの特定スピードが劇的に向上する。
これをマスターすれば、どれほど複雑なコードを動的に生成する言語処理系を作ったとしても、怖気づくことはもうありません。あなたの開発ライフが、より快適で知的で楽しいものになることを応援しています!