Hello Worldが動かなくなった
これは言語実装 - Qiita Advent Calendar 2025 - Qiitaの16日目の記事です。
私はShiikaというRuby風文法の静的型付け言語を作っているのですが、今年はずっと非同期処理の対応をしていて大きな話題(新機能とか)がないため、日々のデバッグの様子を紹介しようと思います。
現象
開発中のブランチで、いつのまにかputs "Hello"が動かなくなっていました。
$ ./a.out
thread '<unnamed>' (43953217) panicked at lib/shiika_ffi/src/core_class/string.rs:43:17:
null pointer dereference in SkString::value() - value field is null
どうやら出力すべきデータがnullになっているようです。
ところで上記ではコマンド名がa.outになってますよね?これだけで実はやっかいなバグだとわかります。なぜか。
バグの種類
Shiikaのバグはたいていの場合、「コンパイラが落ちる」という形で現れます。ShiikaコンパイラはRustで書いているので、この場合デバッグはdbg!マクロとかで普通にやればいいだけです。
ところが、たまに「実行ファイルの生成までは成功するが、それを実行すると落ちる」というパターンがあります。こうなると途端に取れる手段が限られてきます。
ライブラリ部分にdbgを入れる
まず簡単にできるのは、ライブラリ関数にデバッグプリントを入れることです。Shiikaの組み込みメソッドの一部はRustで実装しているので、その部分に限りdbg!が使えます。試しにやってみましょう。
--- a/lib/shiika_ffi/src/core_class/string.rs
+++ b/lib/shiika_ffi/src/core_class/string.rs
@@ -50,4 +50,13 @@ impl SkString {
pub fn as_str(&self) -> &str {
std::str::from_utf8(self.value()).unwrap()
}
+
+ pub fn inspect(&self) {
+ unsafe {
+ dbg!(self.0);
+ dbg!((*self.0).vtable);
+ dbg!((*self.0).class_obj);
+ dbg!((*self.0).value);
+ }
+ }
}
以下の出力を得ました。
[packages/core/ext/src/core_class/string.rs:7:9] n_bytes = 9
[lib/shiika_ffi/src/core_class/string.rs:56:13] self.0 = 0x00007fb0af2e7640
[lib/shiika_ffi/src/core_class/string.rs:57:13] (*self.0).vtable = 0x00005571152d3440
[lib/shiika_ffi/src/core_class/string.rs:58:13] (*self.0).class_obj = 0x00007fb0af2e78e0
[lib/shiika_ffi/src/core_class/string.rs:59:13] (*self.0).value = 0x00007fb0af2e7620
[packages/core/ext/src/core_class/string.rs:7:9] n_bytes = 5
[lib/shiika_ffi/src/core_class/string.rs:56:13] self.0 = 0x00007fb0af2e75c0
[lib/shiika_ffi/src/core_class/string.rs:57:13] (*self.0).vtable = 0x00005571152d3440
[lib/shiika_ffi/src/core_class/string.rs:58:13] (*self.0).class_obj = 0x00007fb0af2e78e0
[lib/shiika_ffi/src/core_class/string.rs:59:13] (*self.0).value = 0x00007fb0af2e75a0
[packages/core/ext/src/core_class/object.rs:20:5] "puts" = "puts"
[lib/shiika_ffi/src/core_class/string.rs:56:13] self.0 = 0x00007fb0af2e7f60
[lib/shiika_ffi/src/core_class/string.rs:57:13] (*self.0).vtable = 0x00005571152d36f0
[lib/shiika_ffi/src/core_class/string.rs:58:13] (*self.0).class_obj = 0x0000000000000000
[lib/shiika_ffi/src/core_class/string.rs:59:13] (*self.0).value = 0x0000000000000000
文字列の初期化時(String#initialize)と出力時(Object#puts)に上記のログを出したのですが、putsの方は確かに.valueがnull (0x00)になっていることがわかります。.vtableも他のと違うので、Stringじゃないものをputsに渡してそうな予感がします。
LLDBを使う
ライブラリのデバッグプリントでは解決しない場合は、LLDBの登場です。(いや、正確にはその前に「.llをClaude Codeに見せてバグの原因を推測してもらう」というフェーズがあるのですが、今回はめぼしいヒントが得られませんでした。)
LLDBはLLVMプロジェクトによるデバッガで、実行ファイルをステップ実行したり、メモリの中身を見たりいろいろできます。
(※gdbとはどう違うの?という話ですが、私はあまり分かっていません…。たぶん今回の範囲だとgdbでも同じことができると思います。単に、LLVMを使ってるからLLDB使おうかなあ、くらいでやっています)
LLDB起動
まずlldb a.outのようにしてlldbを起動します。
今回nullエラーが起きているのは以下の関数の中だとわかっているので、breakpoint _chiika_main_2でブレークポイントを設定してからrunで実行を開始します。するとこの関数の冒頭で実行が止まります。
define ptr @_chiika_main_2(ptr %"$env", i64 %"$async_result") {
%result = tail call i64 @chiika_env_ref(ptr %"$env", i64 1, i64 6)
%recover_i64_to_ptr = inttoptr i64 %result to ptr
%addr_vtable = getelementptr inbounds %Object, ptr %recover_i64_to_ptr, i32 0, i32 0
%load_vtable = load ptr, ptr %addr_vtable, align 8
%vtable = load [3 x ptr], ptr %load_vtable, align 8
%func_raw = extractvalue [3 x ptr] %vtable, 2
%result1 = tail call i64 @chiika_env_ref(ptr %"$env", i64 1, i64 6)
%recover_i64_to_ptr2 = inttoptr i64 %result1 to ptr
%const_value = load ptr, ptr @shiika_const_String, align 8
%string_new_result = tail call ptr @Meta_String_new(ptr %const_value, ptr @shiika_str0, i64 5)
%result3 = tail call ptr %func_raw(ptr %"$env", ptr %recover_i64_to_ptr2, ptr %string_new_result, ptr @_chiika_main_3)
ret ptr %result3
}
次に、Meta_String_newの呼び出しまでは問題なく実行できてそうなので、そこまでを終わらせます。breakpoint Meta_String_new→continueで関数の中に入り、finishで関数から出ます。
ここで、関数の返り値が$raxに入っているので、その中身(=$raxをポインタと見なしたときの指している箇所のメモリ)を見てみます。
(lldb) memory read --size 8 --format x --count 4 $rax
0x7ffff7c3b5c0: 0x00005555558e4440 0x00007ffff7c3b8e0
0x7ffff7c3b5d0: 0x00007ffff7c3b5a0 0x0000000000000000
この内容は直近のデバッグプリント(以下)と一致しており、String.new内でメモリが壊れているわけではないことがわかります。
[packages/core/ext/src/core_class/string.rs:7:9] n_bytes = 5
[lib/shiika_ffi/src/core_class/string.rs:56:13] self.0 = 0x00007ffff7c3b5c0
[lib/shiika_ffi/src/core_class/string.rs:57:13] (*self.0).vtable = 0x00005555558e4440
[lib/shiika_ffi/src/core_class/string.rs:58:13] (*self.0).class_obj = 0x00007ffff7c3b8e0
[lib/shiika_ffi/src/core_class/string.rs:59:13] (*self.0).value = 0x00007ffff7c3b5a0
うーん、となると、そのあとに壊れている?stepとmemory readを組み合わせてもいいですが、もっと便利な命令があります。
(lldb) watchpoint set expression -- (void*)0x00007ffff7c3b5c0
(lldb) continue
とすると、特定のメモリアドレスに書き込みがあった際に自動で停止してくれます。
…と思ったけど、止まらずに最後まで実行されちゃいました。
ていうかさっき、変な値が渡されてた気がする…変な値?
回答
ここでようやく気づいたのですが、原因はライブラリ関数の引数の間違いでした。ShiikaのメソッドをRustで定義する場合、第一引数にレシーバが来るのですが、putsの場合はそれを使わないのでうっかり忘れていました。
気づくのに時間がかかった理由の一つに、これがvtable経由の呼び出しだったというのがあります。vtableはOOPの多態を実装するのに使われるテクニックですが、実装上は以下のように「テーブルに入っている関数が正しい引数を受け取ると信じて、実行する(ヨシ!」という形になるため、今回のように間違った関数をテーブルに入れてもそのまま実行されてしまいます。
%vtable = load [3 x ptr], ptr %load_vtable, align 8
%func_raw = extractvalue [3 x ptr] %vtable, 2
...
%result3 = tail call ptr %func_raw(ptr %"$env", ptr %recover_i64_to_ptr2, ptr %string_new_result, ptr @_chiika_main_3)
まああれなんですよね…言語のユーザはこういうことが変な目に合わないように守られているわけですが、その裏では処理系の作者がいわば身代わりとなってダメージを受けているわけです。クリスマスでいえば、プレゼントをもらう側と渡す側の違いといえるでしょう(?)。メリークリスマス!