C/C++mikanOS書籍

mikanOSのデバッグの話


mikanosのデバッグの話

mikanOSのデバッグの話

この記事は 自作OS Advent Calendar 2021の「1桁の最大の素数」日目の記事として作成されました。

whoami

サイボウズ・ラボユース第11期生のtomiyです。
ラボユースの活動として内田公太さんご指導のもとmikanOSをいじっています。
その過程で得られたqemu上で動作する自作OSのカーネルやアプリをデバッグする方法等について僕がやらかした話も交えながら書きたいと思います。
この記事ではプロンプトは

# ターミナル
$ hoge
# gdbのシェル
(gdb) hoge
# qemuモニター
(qemu) hoge

のように表記します。

デバッグのための準備

最初にデバッグ対象の最適レベルをを-Ogにしましょう。 カーネルのデバッグであればmikanos/kernel/MakefileのCFLAGSとCXXFLAGSの-O2を -Ogにすればいいです。 もし最適レベルをを-Ogにすると問題が再現しなくなるならもとに戻すべきですが、 デバッグ時は最適化レベルを落としましょう。 最適化レベルが高いとソースコードとアセンブリ言語の対応関係がわかりにくかったり、 変数が見えなくなったりしてデバッグしづらくなります。
tmuxでターミナルを分割して左にqemu右にgdbというのが気に入っています。 これだとすぐにmikanOSを再起動できますし、bashのコマンドが使いたくなったら 右のペインを上下に分割してそこからbashのコマンドが使えます。 ウィンドウを何枚も開いてマウスでウィンドウ間を移動するのは面倒ですよね。
デバッガーはgdbを使用します。
mikanOSはClangでコンパイルされているのでlldbを使うべきかも知れませんが、
Clangをqemuに接続する方法がわからなかったのでgdbを使うことにしました。
gdbをqumuに接続する方法についてはこちらの記事を参照して下さい。


↑ではgdbを起動するたびにqemuに接続するコマンドを打って、毎回同じ箇所にブレークポイントを貼ったり、逆アセンブル結果の表示設定やfileコマンででのkernel.elfの読み込みを手動で行なってますが、面倒なため自動化します。
gdbには起動時に-xオプションでファイルからgdbのコマンドを読み込んで実行する機能があるのでこれを利用します。
mikanosディレクトリに適当な名前のファイル(僕はgdb_initにしました)を作成してそこにコマンドを書き込みます。
先頭が#で始まる行はコメントです。行の途中に#を入れても、それ以降がコメントと解釈されないので注意してください。

# qemuに接続
target remote localhost:1234
file ./kernel/kernel.elf
# 逆アセンブル結果をintel形式に
set disassembly-flavor intel
# ブレークするごとに逆アセンブル結果を表示
disp/3i $pc

gdbのpコマンドxコマンドについて

pコマンド: printの略 変数の値、数値や文字を表示
使用例: 数値を16進数に変換

(gdb) p/x 307200

使用例: 変数の表示

(gdb) p hoge

使用例: rspレジスタの値を表示

(gdb) p $rsp

レジスタ名の前には$をつける。
xコマンド: メモリダンプの表示
使用例: 0x10000から8Byte * 10だけ16進数としてダンプ

(gdb)x/10gx 0x10000

pコマンドの出力フォーマットはhttps://flex.phys.tohoku.ac.jp/texi/gdb-j/gdb-j_40.html を参照
xコマンドの出力フォーマットはhttps://flex.phys.tohoku.ac.jp/texi/gdb-j/gdb-j_41.html を参照
pコマンドやxコマンドの引数にはC言語でいう式が入る
つまり四則演算できる

(gdb) p/x 307200 + 0x10 
(gdb) x/3gx 0x8000000 + 0x1d8

間接参照演算子*について

gdbのコマンドではC言語のように間接参照演算子*がつかえる。たとえば

int hoge = 5;
int *phoge = &hoge;

があったとき

(gdb) p hoge
0x8000031
(gdb) p *hoge
5

のようになる。 また、

(gdb) p *0x8000031
5

のようにも書ける。

僕がよく使うgdbコマンド

バックトレースの表示

(gdb) bt

フレームの指定(フレーム番号は↑で確認)

(gdb) frame 2

現在のフレームのローカル変数を確認

(gdb) info locals

現在停止している周辺のソースコードを表示

(gdb) list

ブレークポイント一覧を表示

(gdb) info breakpoints

ブレークポイント削除(ブレークポイント番号は↑で確認)

(gdb) delete 3

qemuモニターのコマンドだけど、レジスタの値を確認

(qemu) info registers

16進数計算のテクニック?

自明なので証明は省略するがn進数の数においてn^mを{かける|割る}とき、 m桁だけ{左|右}にシフトすれば良い。
例えばn = 10, m = 5のとき42に10^5をかけると4200000となる。
これを使用すると計算が桁をシフトするだけですむ。
1ページ4KiB=4 * 2^10 Bのときページ数からアドレスの大きさ?に直したり、アドレスの大きさ?からページ数に直したりするとき、
16進数に4 * 2^10=4096を掛けたり割ったりする機会は多い。
4 * 2^10 = 2^12 = 16^3なので16進数に4096を{かける|割る}とき、
3桁だけ{左|右}にシフトすれば良い。
ついでに2進数に4096を{かける|割る}とき、12桁だけ{左|右}にシフトすれば良い。

逆アセンブル

$ objdump -C -M intel -d kernel.elf > kernel.dis

逆アセンブルを行うにはobjdumpコマンドを使用します。詳しい仕様については

$ man objdump

で調べて下さい。
ここでのオプションの意味は-Cがオブジェクト名をデマングル-M itelがアセンブラの形式をintel形式に-dが逆アセンブル(disasemmble)です。結果は標準出力に出力されるのでファイルにリダイレクトします。
結果をファイルに保存すると10MBを超えることがあるので注意して下さい。
LinuxのVSCodeではサイズがデカすぎて開けないのでVimで開きましょう。
Linuxコマンドに慣れている方はファイルに保存しなくても

$ objdump -C -M intel -d kernel.elf | less

で十分かも知れません。

CPU例外のデバッグ

ページフォルト(PF)や一般保護例外(GP)等のCPU例外のデバッグは例外ハンドラーにブレークポイントを設置するのが有効です。
しかし、gdbで

(gdb) b IntHandlerPF

のようにブレークポイントを設置するとgdbが気を使って例外ハンドラーの開始より少し後ろにブレークポイントが貼られてしまいます。関数呼び出し直後には

push rax
push rbp
mov rbp,rsp
push rax

のような処理を行うのでこの処理の後にブレークポイントが貼られます。
しかし、これでは困ります。

$ nm -C kernel.elf | grep IntHandlerPF

で例外ハンドラのアドレスを調べて

(gdb) b *0x134420

のようにしてもいいですが、カーネルに変更を加えるとこのアドレスがずれてしまうことがあります。
例外ハンドラにブレークポイントを貼っておくとデバッグに便利なので常にブレークポイントを貼っておきたいですが、
gdb_initにアドレスを書き込むとアドレスがずれてしまったときにブレークポイントが正しく貼れなくなってしまいます。
そこでシェルスクリプトを使ってgdb_initを動的に生成することで解決できました。

rm -rf ./gdb_init
echo “# qemuに接続
target remote localhost:1234
file ./kernel/kernel.elf
# 逆アセンブル結果をintel形式に
set disassembly-flavor intel
# ブレークするごとに逆アセンブル結果を表示
disp/3i $pc ”>> ./gdb_init
echo “# IntHandlerPF” >> ./gdb_init
nm -C ./kernel/kernel.elf | grep IntHandlerPF | awk ’{printf “b *0x%s”, $1}’ >> ./gdb_init 
echo “” >> ./gdb_init
echo “# IntHandlerGP” >> ./gdb_init
nm -C ./kernel/kernel.elf | grep IntHandlerGP | awk ’{printf “b *0x%s”, $1}’ >> ./gdb_init 
echo “” >> ./gdb_init
echo “# IntHandlerUD” >> ./gdb_init
nm -C ./kernel/kernel.elf | grep IntHandlerUD | awk ’{printf “b *0x%s”, $1}’ >> ./gdb_init 
echo “” >> ./gdb_init
echo “# IntHandlerBP” >> ./gdb_init
nm -C ./kernel/kernel.elf | grep IntHandlerBP | awk ’{printf “b *0x%s”, $1}’ >> ./gdb_init 
echo “” >> ./gdb_init
gdb -x gdb_init

これで常に例外ハンドラーにブレークポイントを貼れるようになりました。 CPU例外のデバッグ準備完了です。
qemuを起動した状態で

$ ./gdb.sh

でgdbをqemuに接続してカーネルの読み込み、ブレークポイントの設置まで自動でやってくれます。
PFの具体的なデバッグ方法を紹介します。
例外ハンドラは特殊な関数なのでバックトレースが使えません。
例外ハンドラでブレークしたらまずすることはスタックの確認です。
スタックを確認するには

(gdb) x/6gx $rsp

です。
rspレジスタの指すアドレスから6領域をジャイアント(8byte)単位*6で16進数でダンプしています。
これが何を意味しているのかはIntel SDM Vol.3 6.14 のFigure 6-9右側をみればわかります。
Intel SDMはOS自作erのバイブルなので必ずダウンロードしておきましょう!
実際の事例を紹介します。 アプリケーション実行中にページフォルトが起きてIntHandlerPFでブレークしました。
そこでスタックを確認すると

(gdb) x/6gx $rsp
0x8fd0: 0x0000000000000005 0x000000000004b765
0x8fe0: 0x0000000000000023 0x0000000000000246
0x8ff0: 0xffffffffffffeed8 0x000000000000001b

となりました。
Intel SDM Vol.3 6.14 のFigure 6-9右側をみると
Error Code: 0x5
RIP: 0x4b765
CS: 0x23
RFLAGS: 0x246
RSP: 0xffffffffffffeed8
SS: 0x1bとなります。
ここでエラーコードの意味を調べてみましょう。
PFのエラーコードの意味はIntel SDM Vol.3 6.15 のFigure 6-11にあります。
0x5を2進数になおすとb101なので
エラーコード: 0x5
bit0:1 ページは存在してる
bit1:0 読み込みでPFが発生
bit2:1 ユーザーモードで発生
と解釈できます。アプリケーション実行中にPFが発生したので 確かにユーザーモードで発生したのであっていそうです。
これだけでは原因がわからないのでつぎは スタックにあるRIPを確認します。
あれ?アプリが0x4b765なんてアドレスに配置されてるわけないのにおかしいな。
とりあえず、0x4b765をダンプ

(gdb) x/6gx 0x4b765
0x4b765: 0x0000000000000000 0x0000000000000000
0x4b775: 0x0000000000000000 0x0000000000000000
0x4b785: 0x0000000000000000 0x0000000000000000

おかしい。
これならRIPが0x4b765になった時点でPFじゃなくてUDがおきるはず!
つまり、例外ハンドラーが呼ばれたときにメモリが壊れた。
ここから推測できるのは、
アプリ側で本来飛ぶべきじゃない場所に飛んでしまってる(RIPがありえない値だから)
その飛んだ先がたまたま機械語命令として解釈できた(UDじゃないから)
その機械語命令がカーネルモードじゃないと読めない領域を読もうとしてPF
とりあえず、アプリ呼び出し直前あたりでブレークして0x4b765をダンプしました。
記録が残ってなかったので以下は似た状況を再現したものです。

(gdb) x/6gx 0x4b765
0x4b765: 0x0000000000012005 0x0000000000013005
0x4b775: 0x0000000000014005 0x0000000000015005
0x4b785: 0x0000000000016005 0x0000000000017005

どうやらページテーブルっぽいです。
このとき僕はこの場所に飛んでしまう原因を調べるためにアプリをステップ実行しました。
この方法ではとんでもなく時間がかかりますが、このとき動かしていたアプリはhelloで、PFが起きるのがhelloが表示される前だったため
アプリ起動直後に異常がある可能性が高いと考えていました。なのでこの方法でもそんなに手間はかからないと考えてこの方法を使いました。
そしてこの場所にcll or jmpするす場所を特定できました。
これが使えない場合であれば、後で説明するint3命令をアプリに何箇所か入れてBP例外(ブレークポイント例外)を発生させ問題箇所を大まかに特定してからステップ実行したと思います。
0x4b765をcallしていた箇所は

call QWORD PTR [rbx+0x10]

でrbx+0x10は0x80001d8でした。
0x80001d8はelfファイルから読み込んだものが置かれていた領域です。
ダンプしてみると

(gdb) x/6gx 0x80001d8
0x80001d8: 0x000000000004b765 0x000000000004c0c5
0x80001e8: 0x0000000000000025 0x000000000004d775
0x80001f8: 0x000000000004e0cd 0x0000000000000025

でした。
アプリを逆アセンブルしてみると、0x80001d8は本来0x8078760が書かれているはずだとわかりました。
0x4b765,0x4c0c5,0x4d775という増え方はページテーブルのエントリーっぽいです。
つまりPFの原因はelfファイルが読み込まれた領域にOSがページテーブルのエントリーを書き込んでしまってメモリ破壊が起きたことだと推測されます。
ここで0x80001d8に0x4b765を書き込んだ犯人を特定してこれが正しいのか検証してみましょう。
これにはwatchポイントを使用します。

watchポイント

watchポイントは変数や指定したメモリ領域に読み込み/書き込みがあったときにブレークする機能です。 メモリ破壊が起こっている可能性があるときにメモリを破壊している犯人を探すのに役に立ちます。
最初にelfファイルをメモリに読み込んだ直後でブレークしてその後0x80001d8 にwatchポイントを仕込みます。

(gdb) watch *0x80001d8

すると

Old value = 134711136
New value = 309093
PageMapEntry::SetPointer (this=0x80001d8, p=0x4b000) at ./paging.hpp:89
89 }
1: x/3i $pc
=> 0x136564 :       pop    rbp
   0x136565 :       ret   
   0x136566:    int3
(gdb) p/x 134711136
$1 = 0x8078760
(gdb) p/x 309093
$2 = 4b765

pop rbpはメモリに値を書き込んでいないのでおかしい?
watchポイントはメモリ値を書き込んだ次の命令でブレークするからlistコマンドで 周辺のソースコードを表示すればok
これで0x80001d8に0x4b765を書き込んだ犯人が特定できました。
たしかにページングのページエントリーを書き込む命令でした。
ちなみに、なぜelfファイルを読み込んだ領域にページングのページエントリーを書き込まれるのかは原因がわかっていないです。
現在調査中です。

asm(“int3”)

ブレークポイント例外を発生させるCPU命令がint3です。 C言語やC++からインラインアセンブラで呼び出せます。

while(1) asm(“hlt”)

例外ハンドラの設定前でasm(“int3”)が使えない、コンソールの描画も終わってなくてputString()も使えない状況でデバッグに使えるのがwhile(1) asm(“hlt”)です。
hlt命令はカーネルモードでしか使えないので、アプリの動作中にシステムコールでカーネルモードに移っているときか、カーネル内でしか使えません。
hlt命令はCPUの動作を停止させるCPU命令ですが、割り込み等によって停止状態が解除されてしまいます。
そのため、割り込みが入ってもそのタスクが停止し続けるためには無限ループの中に入れる必要があります。

階層ページング構造を辿る

ページの設定を確認したい場合や仮想アドレスが どの物理アドレスに割り当てられているか調べたい場合等に階層ページング構造を確認したい場面があります。
4階層ページングについてはみかん本19.4を参照してください。
階層ページング構造を調べる方法
1. qemu monitorでinfo tlb

(qemu) info tlb

2. CR3からたどる

(qemu) info registers
CR3=hoge
(qemu) x/4gx hoge
…

1. はqemu上だからlessコマンドがつかえなくて辛く
件数が多くなりすぎて目的のアドレスを探し出すのが困難なので2.を使います。

仮想アドレスから各階層の配列の添字を調べるにはosdev.jpのツールが便利です。
https://osdev.jp/tools/x86_64_vaddr_composer.html
ここでは0x8000000がどの物理アドレスに割り当てられているかと、ページの設定を確認してみます。
0x8000000を分解すると
PML4 0x000=0
PDP 0x000=0
PD 0x040=64
PT 0x000=0
Offset 0x000=0
となります。
CR3を調べてPML4の先頭アドレスを調べます。

(qemu) info registers
CR3=000000000009d000

これと0x000ffffffffff000でand演算を行ったものがPML4の先頭を指すアドレスです
0x000ffffffffff000でand演算を行うと下3桁と14,15,16 桁目が0になります
つまり 0x9d000 & 0x000ffffffffff000 = 0x9d000
PML4の先頭: 0x9d000

PML4[0]を調べます。
配列の1要素(ページング構造のエントリ)の大きさは64bit = 8byteです。

(gdb) x/1gx 0x9d000
0x9d000: 0x00000000002fc027

PML4[0] = 0x2fc027
0x2fc027 & 0x000ffffffffff000 = 0x2fc000
PDPの先頭: 0x2fc000

PDP[0]を調べる

(gdb) x/1gx 0x2fc000 
0x2fc000: 0x00000000002fe027

PDP[0] = 0x2fe027
0x2feo27 & 0x000ffffffffff000 = 0x2fe000
PDの先頭: 0x2fe000

PD[64]を調べる

(gdb) x/65gx 0x2fe000
0x2fe000: 0x00000000000000e3 0x00000000002000e3
~省略~
0x2fe200: 0x00000000080000e7

PD[64] = 0x80000e7
0x80000e7 & & 0x000ffffffffff000 = 0x8000000
PTの先頭: 0x8000000
あれ?諸事情でアプリを0x8000000〜に配置しているはずなんだが、、、

PT[0]を調べる。

(gdb) x/1gx 0x8000000
0x8000000: 0x00010102464c457f

あれ?なんか見たことある。

$ hexdump -C hello | head -n 1
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF…………|

0x8000000がどの物理アドレスに割り当てられているかも、ページの設定もわかりませんでしかが、
なにかがおかしいことがわかりました。
mikanOSの階層ページング構造を生成する部分にバグ?
現在原因を調査中です。
でもページング構造の調べ方は理解してもらえたと思います。
ちなみにページング構造のエントリのどのbitがなにに対応しているかはみかん本p. 444リスト19.2にありますが、
「作って理解するOS」p. 250に詳細な解説があったのでこちらを参考にしました。
“””
present: このエントリが有効なときに1をセット
writable: 書き込み可能なときに1をセット
user: 特権レベル3(ユーザーモード)のプログラムがアクセス可能なときに
accessed: 対応するページへのアクセスがあったときにCPUが1をセット
dirty: 対応するページに書き込みがあったときにCPUが1をセット
“””
エントリの下位12bitが0x0e3=0b000011100011のときを例にとると
present: 1
writable: 1
user: 0
accessed: 1
dirty: 1
このページは存在して、書き込み可能で、すでにアクセスと書き込みがあったとなります。

Makefileのデバッグ

makeでMakefileに書いたのと違うコマンドが実行されてしまうことがありました。
そこで役に立ったのが-rと-nの2つのオプションです。
make -r デフォルトルールを無効化
make -n コマンドを実行せずに、makeされたときに実行されるコマンドを表示
makeで思い通りのコマンドが実行されなかったのは、僕がMakefileの書き方を間違えていたからでした。
そのせいでデフォルトルールが実行されていました。
make -n で思い通りのコマンドが表示されない場合、
make -n -pでデフォルトルールを無効化してどうなるか確かめてみましょう。

ラボユースの宣伝

https://labs.cybozu.co.jp/youth/requirements.html
素晴らしい制度です。
興味のある方は申し込んでみましょう。
内田さんの「OS開発ゼミ」は募集終了しているので
申し込みたい人は来年の4月以降になります。

コメント

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