C言語のデバッグにLLDBを使ってみた

C言語

はじめに

10月は知見のない技術を学ぼう月間ということで、今日はLLDBを実際に使いながら、学習していこうと思います。なぜ、LLDBの使い方を学ぶかというと、私は趣味でコンパイラを自作しているからです。自作コンパイラは、C言語で実装しており、セグフォなどのエラーが起きた時に、LLDBで詳細にデバッグすることが可能になるからです。

↓こちらは今私が実装中のコンパイラになります。

GitHub – yu-corder/goemon-src
Contribute to yu-corder/goemon-src development by creating an account on GitHub.

筆者の環境がこちらになります。

mac 15.4.1(24E263)
プロセッサ 1.4 GHz クアッドコアIntel Core i5
メモリ 8GB

//lldb --version
lldb-1200.0.44.2

//コンパイラプログラム
gcc

(コマンド早見表)

//バージョン確認
lldb --version

//起動(実行ファイルを指定)
lldb ./main

//実行
run

//LLDBを終了
quit

//ブレークポイント設定(main関数の先頭で止める)
breakpoint set --name main

//ブレークポイント設定の短縮系。上記のブレークポイント設定と同義
b main

//ブレークポイント設定済みのリスト
breakpoint list

//次の行に進める(関数の中には入らない)
next

//次の行に進める(関数の中に入る)
step

//現在の関数を最後まで実行
finish

//次のブレークポイントまで実行
continue

//変数の値を見る(変数名を指定)
print p

//プリントコマンドの短縮系(ポインタをデリファレンスして値を見る)
p *p

//アドレスのメモリを確認
memory read p

//上記コマンドの表示するバイトを指定(ここではpが指すアドレスのメモリを表示)
memory read --format x --size 4 --count 1 p

//上記コマンドの短縮系
memory read -fx -s4 -c1 p

//現在のスタックフレームを確認
frame info

//呼び出し履歴を確認
bt

LLDBとは?

LLDBとは簡単にいうと、オープンソース・デバッガー(デバッグ用プログラム)です。主に、プログラムを実行しながらバグを見つけ、修正するために使用されます。

↓公式ドキュメントはこちらになります。

🐛 LLDB

では、LLDBで実際に何ができるのでしょうか?

  • プログラムの一時停止: バグが起きそうな場所(ブレークポイント)で実行を止められます。
  • 変数のチェック: 停止した時点での、変数の中身やメモリの状態を画面に表示できます。
  • 1行ずつの実行: コードを1行ずつ動かして(ステップ実行)、どこでエラーが起きるか追跡できます。
  • コードの実行: デバッグ中に、その場でコードを入力して評価・実行させることができます。

C言語での開発で変数の中身やメモリの状態を確認できるのは非常にありがたいですね。

起動

筆者はmacOSを使用しており、macOSではXcodeまたはXcode Command Line Toolsを導入するとLLDBが利用できるため、今回は追加インストールせず、インストール済みか確認してから使用します。

test@test goemon-src % lldb --version
lldb-1200.0.44.2
Apple Swift version 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28)
test@test goemon-src % which lldb
/usr/bin/lldb
test@test goemon-src % 

実際に起動してみます。

test@test goemon-src % lldb -- ./kama-c --ast --token --binary examples/study.goe examples/study.gb
(lldb) target create "./kama-c"
Current executable set to '/Users/test/Desktop/goemon-src/kama-c' (x86_64).
(lldb) settings set -- target.run-args  "--ast" "--token" "--binary" "examples/study.goe" "examples/study.gb"
(lldb) 

ここでは、私のプロジェクトにある、コンパイル済みの実行ファイルを指定して、lldbを起動しています。

(lldb) quit
test@test goemon-src %

quitで停止ができます。

ブレークポイントを設定する

実際にlldb を使っていきます。デバッグに使うサンプルプログラムになります。

#include <stdio.h>

int main() {
    int a = 100;
    int *p = &a;

    printf("a = %d\n", a);
    printf("p = %d\n", *p);

    return 0;
}
test@test lldb-tutorial % gcc -g main.c -o main
test@test lldb-tutorial % ./main
a = 100
p = 100

ここでは、-g オプションをつけています。-g オプションを付けることで、コンパイル時に実行ファイルにデバッグ情報を埋め込みます。これによりLLDBから、

  • ソースコードの行番号
  • 変数名
  • 関数名
  • スタックフレーム

などを確認できるようになります。※最適化オプションなどによって、変数の追跡が難しくなるケースもあります。

test@test lldb-tutorial % lldb ./main
(lldb) target create "./main"
Current executable set to '/Users/test/Desktop/lldb-tutorial/main' (x86_64).
(lldb) run
Process 60563 launched: '/Users/test/Desktop/lldb-tutorial/main' (x86_64)
a = 100
p = 100
Process 60563 exited with status = 0 (0x00000000) 
(lldb) 

run コマンドで実行することができます。Process 60563 exited with status = 0 (0x00000000) でstatus が0ですが、これは正常終了を意味しています。

次にブレークポイントを設定します。

(lldb) breakpoint set --name main
Breakpoint 1: where = main`main + 15 at main.c:4:9, address = 0x0000000100003f3f
(lldb) run
Process 66455 launched: '/Users/test/Desktop/lldb-tutorial/main' (x86_64)
Process 66455 stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: 0x0000000100003f3f main`main at main.c:4:9
   1    #include <stdio.h>
   2   
   3    int main() {
-> 4        int a = 100;
   5        int *p = &a;
   6   
   7        printf("a = %d\n", a);
Target 0: (main) stopped.
(lldb) 

ブレークポイントを設定することで、main関数の先頭で止めることができました。

ステップ実行

ブレークポイントを設定したので、ステップ実行をします。

(lldb) next
Process 66455 stopped
* thread #1, stop reason = step over
    frame #0: 0x0000000100003f46 main`main at main.c:5:10
   2   
   3    int main() {
   4        int a = 100;
-> 5        int *p = &a;
   6   
   7        printf("a = %d\n", a);
   8        printf("p = %d\n", *p);
Target 0: (main) stopped.
(lldb)

LLDBには複数のステップ実行方法があります。まずは next を使って、1行ずつプログラムを実行してみます。

(lldb) next
Process 66455 stopped
* thread #1, stop reason = step over
    frame #0: 0x0000000100003f4e main`main at main.c:7:24
   4        int a = 100;
   5        int *p = &a;
   6   
-> 7        printf("a = %d\n", a);
   8        printf("p = %d\n", *p);
   9   
   10       return 0;
Target 0: (main) stopped.
(lldb) next
a = 100
Process 66455 stopped
* thread #1, stop reason = step over
    frame #0: 0x0000000100003f5f main`main at main.c:8:25
   5        int *p = &a;
   6   
   7        printf("a = %d\n", a);
-> 8        printf("p = %d\n", *p);
   9   
   10       return 0;
   11   }
Target 0: (main) stopped.
(lldb) 

next は関数呼び出しがあっても、その関数の内部には入らず、現在のフレームで次のソース行まで実行します。ブレークポイントに設定した箇所から、関数の中もデバッグする場合は、step コマンドを使います。プログラムをちょっと修正します。

#include <stdio.h>


static void hello() {
    printf("Hello World!\n");
}

int main() {
    int a = 100;
    int *p = &a;

    printf("a = %d\n", a);
    printf("p = %d\n", *p);

    return 0;
}
(lldb) step
Process 86667 stopped
* thread #1, stop reason = step in
    frame #0: 0x0000000100003f1e main`main at main.c:12:5
   9        int a = 100;
   10       int *p = &a;
   11  
-> 12       hello();
   13  
   14       printf("a = %d\n", a);
   15       printf("p = %d\n", *p);
Target 0: (main) stopped.
(lldb) step
Process 86667 stopped
* thread #1, stop reason = step in
    frame #0: 0x0000000100003f64 main`hello at main.c:5:5
   2   
   3   
   4    static void hello() {
-> 5        printf("Hello World!\n");
   6    }
   7   
   8    int main() {
Target 0: (main) stopped.
(lldb) 

step は関数呼び出しを含む行では、まずその行へ進み、次の step で呼び出された関数の内部へ入ります。次にfinish コマンドです。

(lldb) run
There is a running process, kill it and restart?: [Y/n] Y
Process 86667 exited with status = 9 (0x00000009) 
Process 88450 launched: '/Users/test/Desktop/lldb-tutorial/main' (x86_64)
Process 88450 stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: 0x0000000100003f0f main`main at main.c:9:9
   6    }
   7   
   8    int main() {
-> 9        int a = 100;
   10       int *p = &a;
   11  
   12       hello();
Target 0: (main) stopped.
(lldb) finish
Hello World!
a = 100
p = 100
Process 88450 stopped
* thread #1, stop reason = step out
Return value: (int) $0 = 0

    frame #0: 0x00007ff803b53530
->  0x7ff803b53530: movl   %eax, %ebx
    0x7ff803b53532: movq   -0xd8(%rbp), %rax
    0x7ff803b53539: movq   0x8(%rax), %rdi
    0x7ff803b5353d: addq   $0x60, %rdi
Target 0: (main) stopped.
(lldb) 

finish を実行すると現在の関数から抜けて呼び出し元に戻ります。今回の例では、呼び出し元側のコードにデバッグ情報がないため、アセンブリ表示で停止しています。最後にcontinue コマンドです。

(lldb) run
There is a running process, kill it and restart?: [Y/n] Y
Process 93840 exited with status = 9 (0x00000009) 
Process 96987 launched: '/Users/test/Desktop/lldb-tutorial/main' (x86_64)
Process 96987 stopped
* thread #1, stop reason = breakpoint 1.1
    frame #0: 0x0000000100003ecf main`main at main.c:12:9
   9    }
   10  
   11   int main() {
-> 12       int a = 100;
   13       int *p = &a;
   14  
   15       hello();
Target 0: (main) stopped.
(lldb) continue
Process 96987 resuming
Process 96987 stopped
* thread #1, stop reason = breakpoint 2.1
    frame #0: 0x0000000100003f28 main`hello at main.c:5:5
   2   
   3   
   4    static void hello() {
-> 5        printf("Hello World!\n");
   6        printf("TEST\n");
   7        printf("TEST\n");
   8        printf("TEST\n");
Target 0: (main) stopped.
(lldb) 

continue コマンドは、次のブレークポイントなどで停止するまでプログラムを実行します。

コマンド意味
next次のソースコード行まで実行。関数呼び出しがあっても中には入らない
step次の行へ進む。関数呼び出しがあれば中に入る
finish現在の関数を最後まで実行して、呼び出し元に戻る
continue次のブレークポイントまで実行

変数を見る

変数の中身を見るコマンドは print になります。

(lldb) next
Process 2903 stopped
* thread #1, stop reason = step over
    frame #0: 0x0000000100003ed6 main`main at main.c:13:10
   10  
   11   int main() {
   12       int a = 100;
-> 13       int *p = &a;
   14  
   15       hello();
   16  
Target 0: (main) stopped.
(lldb) print a
(int) $2 = 100
(lldb) 

ポインタも見ることができます。また、ポインタをデリファレンスして、値も見ることができます。

(lldb) next
Process 2903 stopped
* thread #1, stop reason = step over
    frame #0: 0x0000000100003ede main`main at main.c:15:5
   12       int a = 100;
   13       int *p = &a;
   14  
-> 15       hello();
   16  
   17       printf("a = %d\n", a);
   18       printf("p = %d\n", *p);
Target 0: (main) stopped.
(lldb) print p
(int *) $3 = 0x00007ff7bfefeab8
(lldb) print *p
(int) $4 = 100
(lldb) 

(C言語のprintf関数を使って同じようなことは可能ですね。)

printf("%d\n", a);
printf("%p\n", p);
printf("%d\n", *p);

lldb のprint コマンドですが、p に省略可能です。

(lldb) p p
(int *) $5 = 0x00007ff7bfefeab8
(lldb) p *p
(int) $6 = 100
(lldb)

ポインタ・メモリの中身を見る

先ほどはポインタのアドレスを確認しましたが、実際にそのアドレスにあるメモリを見てみます。

memory read コマンドを使います。

(lldb) memory read p
0x7ff7bfefeab8: 64 00 00 00 00 00 00 00 30 f1 ef bf f7 7f 00 00  d.......0���...
0x7ff7bfefeac8: 30 35 b5 03 f8 7f 00 00 00 60 06 02 f8 7f 00 00  05�.�....`..�...

(lldb) print &a
(int *) $10 = 0x00007ff7bfefeab8
(lldb) memory read &a
0x7ff7bfefeab8: 64 00 00 00 00 00 00 00 30 f1 ef bf f7 7f 00 00  d.......0���...
0x7ff7bfefeac8: 30 35 b5 03 f8 7f 00 00 00 60 06 02 f8 7f 00 00  05�.�....`..�...
(lldb) 

pはaのアドレスを保持しているため、memory read pとmemory read &aは同じアドレスからメモリを読み取ることになります。

64 00 00 00 が a の値 100 です。100 を16進数にすると、100 = 0x64 となり、今回の環境ではintが4バイトなので、下記のようになります。

64 00 00 00

memory read だけだと分かりづらい場合は、読み取るメモリの形式や1要素あたりのサイズ、要素数をオプションで指定することも可能です。

(lldb) memory read --format x --size 4 --count 1 p
0x7ff7bfefeab8: 0x00000064
部分意味
memory read指定したメモリアドレスの内容を読み取る
--format x16進数(hexadecimal)で表示する
--size 41要素を4バイトとして扱う
--count 11要素だけ表示する
p読み取るアドレス。ここではポインタ p の指す先

スタックフレームを見る

LLDBでは、現在どの関数で実行が停止しているのか、その関数がどの関数から呼び出されたのかを確認できます。frame関連のコマンドで確認することができます。下記のサンプルプログラムで確認します。

#include <stdio.h>

void print_value(int value)
{
    printf("value = %d\n", value);
}

void process(int value)
{
    print_value(value);
}

int main()
{
    int a = 100;

    process(a);

    return 0;
}

frame info コマンドを実行します。

(lldb) step
Process 40177 stopped
* thread #1, stop reason = step in
    frame #0: 0x0000000100003f4b main`process(value=100) at main.c:10:17
   7   
   8    void process(int value)
   9    {
-> 10       print_value(value);
   11   }
   12  
   13   int main()
Target 0: (main) stopped.
(lldb) frame info
frame #0: 0x0000000100003f4b main`process(value=100) at main.c:10:17
(lldb) 

現在のスタックフレームの情報が出力されました。続いて、関数の呼び出し履歴を表示します。

(lldb) bt
* thread #1, stop reason = step in
  * frame #0: 0x0000000100003f1b main`print_value(value=100) at main.c:5:28
    frame #1: 0x0000000100003f53 main`process(value=100) at main.c:10:5
    frame #2: 0x0000000100003f7e main`main at main.c:17:5
    frame #3: 0x00007ff803b53530
(lldb)

bt コマンドで呼び出し履歴を表示することができます。* frame #0 が現在のスタックフレームになります。frame #1 がその呼び出し元、frame #2 がさらにその呼び出し元です。

フレームを移動することも可能です。frame select 1 で process() に移動できます。

(lldb) frame info
frame #0: 0x0000000100003f1b main`print_value(value=100) at main.c:5:28
(lldb) print value
(int) $0 = 100
(lldb) frame select 1
frame #1: 0x0000000100003f53 main`process(value=100) at main.c:10:5
   7   
   8    void process(int value)
   9    {
-> 10       print_value(value);
   11   }
   12  
   13   int main()
(lldb) frame info
frame #1: 0x0000000100003f53 main`process(value=100) at main.c:10:5
(lldb) print value
(int) $1 = 100
(lldb) 

おまけ: 自作コンパイラでlldbを使ってみる

最後に自作コンパイラでlldbを使ってみます。↓自作コンパイラはこちらになります。

GitHub – yu-corder/goemon-src
Contribute to yu-corder/goemon-src development by creating an account on GitHub.

言語名は五右衛門です。今回動作に使用する五右衛門プログラムはこちらになります。

function int fib(int n) { 
    if (n <= 1) { 
        return n; 
    } 
    return fib(n - 2) + fib(n - 1); 
}
 
for (int i = 0; i < 20; i++) { 
    print fib(i); 
}

こちらの五右衛門プログラムをコンパイラで、バイトコード化したものをVMで実行します。VMで実行する時の処理をlldb でデバッグします。

test@test goemon-src % lldb -- ./kama-e examples/study.gb
(lldb) target create "./kama-e"
Current executable set to '/Users/test/Desktop/goemon-src/kama-e' (x86_64).
(lldb) settings set -- target.run-args  "examples/study.gb"
(lldb) run
Process 87030 launched: '/Users/test/Desktop/goemon-src/kama-e' (x86_64)
VM Output: 0
VM Output: 1
VM Output: 1
VM Output: 2
VM Output: 3
VM Output: 5
VM Output: 8
VM Output: 13
VM Output: 21
VM Output: 34
VM Output: 55
VM Output: 89
VM Output: 144
VM Output: 233
VM Output: 377
VM Output: 610
VM Output: 987
VM Output: 1597
VM Output: 2584
VM Output: 4181
Process 87030 exited with status = 0 (0x00000000) 
(lldb) 

実際にブレークポイントを設定して、VM内部の動きをデバッグします。

(lldb) breakpoint set --name run
...省略
(lldb) next
Process 97453 stopped
* thread #1, stop reason = step over
    frame #0: 0x0000000100003285 kama-e`run(program=0x00007fd2bda04080) at kama_execute.c:25:9 [opt]
   22       while (true) {
   23           int instruction = program[pc++];
   24  
-> 25           switch (instruction) {
   26               case OP_PUSH:
   27                   stack[++sp] = program[pc++];
   28                   if (sp >= 255) {
Target 0: (kama-e) stopped.
(lldb) step
Process 97453 stopped
* thread #1, stop reason = step in
    frame #0: 0x0000000100003290 kama-e`run(program=0x00007fd2bda04080) at kama_execute.c:27:41 [opt]
   24  
   25           switch (instruction) {
   26               case OP_PUSH:
-> 27                   stack[++sp] = program[pc++];
   28                   if (sp >= 255) {
   29                       printf("限界突破!スタックが溢れましたぞ! (pc=%d)\n", pc);
   30                       return;
Target 0: (kama-e) stopped.
(lldb) step
Process 97453 stopped
* thread #1, stop reason = step in
    frame #0: 0x00000001000032ab kama-e`run(program=0x00007fd2bda04080) at kama_execute.c:28:24 [opt]
   25           switch (instruction) {
   26               case OP_PUSH:
   27                   stack[++sp] = program[pc++];
-> 28                   if (sp >= 255) {
   29                       printf("限界突破!スタックが溢れましたぞ! (pc=%d)\n", pc);
   30                       return;
   31                   }
Target 0: (kama-e) stopped.
(lldb) print stack[0]
(int) $1 = 0
(lldb) print pc
(int) $3 = 38
(lldb) print sp
(int) $4 = 0
(lldb) 

ここでは、スタックに0が積まれたことが確認できました。spが0なので、現在有効なスタックの先頭はstack[0]です。そしてstack[0]には0が入っています。

趣味のプロジェクトになるので、今回の記事では五右衛門のデバッグはここまでとします。

最後に

今回はlldbの起動からデバッグの方法を学習しました。これから五右衛門の実装を進めて行く中で、lldb を使いながら進めていきます。コンパイラ内やVM内で使用している配列を動的配列にするリファクタリングやガベージコレクションを実装する際は、lldb が役に立つと思うので、もう少し使い方を勉強したいと思います。最後まで見ていただきありがとうございました。次回はWASM(WebAssembly) の記事になるかと思います。次の記事も見ていただけると嬉しいです。

コメント

タイトルとURLをコピーしました