本記事の構成および論理分析にはAI(人工知能)を使用しています。情報の正確性は、システム管理者(UNIXユーザー)による手動検証済みです。
C言語で作るLC-3仮想マシン 第11回: 小さいLC-3プログラムをデバッグする | UNIX Cafe

小さいLC-3プログラムをデバッグする
前回は、VMに--dump-registersと--dump-memoryを追加し、プログラム終了後のレジスタとメモリを確認できるようにしました。
第6回以降では、VMの中を観察するための機能を少しずつ増やしてきました。命令の流れを見る--trace、命令数を数える--profile、1命令ずつ進める--step、指定番地で止める--break、最後の状態を見る--dump-registersと--dump-memoryです。
今回は、VM本体に新しい機能は追加しません。第10回までに作った道具を使って、小さいLC-3プログラムの動きを追います。
GitHubではlc3-vm-cという名前で公開しています。今回は本文の途中でファイルを1つずつ作るのではなく、最初に第11回用の作業ファイルをそろえてから進めます。
この記事で使うデバッグ用のコードは、GitHubのlessons/11-debug-exampleに置いています。VM本体のlc3.cは第10回の完成版と同じです。今回追加しているのは、デバッグ対象として使う小さいLC-3プログラムと、その実行に使うサンプルファイルです。
最初に作業ファイルをそろえる
まず、リポジトリを取得して第11回のディレクトリへ移動します。すでにリポジトリを手元に置いている場合は、最新の状態に更新してから同じディレクトリへ移動してください。
git clone https://github.com/k1117n-cmyk/lc3-vm-c.git
cd lc3-vm-c/lessons/11-debug-exampleこのディレクトリには、VM本体、ビルド用のMakefile、サンプルプログラム、実行用のオブジェクトファイルが入っています。
lessons/11-debug-example/
lc3.c
Makefile
examples/
hello.asm
hello.obj
loop.asm
loop.objこの記事では、これらのファイルが手元にある前提で進めます。以降のコマンドは、lessons/11-debug-exampleディレクトリで実行してください。
loop.asmは人間が読むためのアセンブリソースです。VMが直接実行するのは、アセンブル後のloop.objです。この記事では、loop.asmを見てプログラムの意味を確認し、実行コマンドではloop.objを指定します。
今回はVM本体を変更しない
これまでの回では、毎回lc3.cに機能を追加してきました。第11回は少し違います。
今回は、lc3.cに新しい処理を足しません。使うVMは第10回の完成版です。新しく見るのは、デバッグ対象になるLC-3プログラムです。
第10回まで:
VMに観察機能を追加する
第11回:
その観察機能を使ってLC-3プログラムを読むデバッグという言葉は、バグを直す作業だけを指すとは限りません。ここでは、プログラムがどの命令をどの順番で実行し、レジスタや条件フラグがどう変わるかを確認する作業として扱います。
読む前に見るものを整理する
今回はコードを追加する回ではなく、実行結果を読む回です。先に、この記事で何を見るのかを整理しておきます。
PC: 次に実行する命令の番地ですR0からR7: LC-3プログラムが使うレジスタですCOND: 直前の計算結果が正、ゼロ、負のどれだったかを表す条件フラグですINSTR: メモリから読んだ16bitの命令wordですOP: 命令wordの上位4bitから分かる命令の種類です
LC-3のメモリは、1番地に16bitの値を1つ置きます。この16bitの値をここではwordと呼びます。命令も文字も、メモリダンプでは同じ16bitの値として表示されます。
CONDは、次の分岐命令が見るための状態です。たとえばADDの結果が0ならCOND=Zになり、その直後のBRpは「正のときだけ分岐」なので分岐しません。
命令コードの読み方
traceやメモリダンプには、5260、1263、E004、F022のような4桁の16進数が出てきます。これはLC-3が実行する機械語の命令コードです。アセンブリで書いたAND R1, R1, #0やADD R1, R1, #3は、実行前にこの16bitの値へ変換されています。
LC-3の命令コードでは、先頭の16進数1桁、つまり上位4bitが命令の種類を表します。この部分をopcodeと呼びます。たとえば5260は先頭が5なのでAND、1263や127Fは先頭が1なのでADD、E004は先頭がEなのでLEA、F022やF025は先頭がFなのでTRAPです。
5260 -> 先頭が 5 -> AND
1263 -> 先頭が 1 -> ADD
127F -> 先頭が 1 -> ADD
E004 -> 先頭が E -> LEA
F022 -> 先頭が F -> TRAP PUTS
F025 -> 先頭が F -> TRAP HALT残りの3桁には、どのレジスタを使うか、即値を使うか、PCからどれだけ離れた場所を指すか、TRAPのどの機能を呼ぶか、といった細かい情報が入っています。同じADDでも1263は「R1に3を足す」、127Fは「R1から1を引く」という意味になります。
この記事では、命令コードをビット単位で完全に分解するところまでは進みません。まずは「先頭の1桁で命令の種類が分かり、残りの桁で詳しい動作が決まる」と見ておけば、traceやメモリダンプの出力を追いやすくなります。
デバッグ対象のプログラム
今回使うLC-3プログラムは、Hiを3回表示して停止する小さいループです。Hello Worldより少しだけ複雑で、カウンタ、条件フラグ、分岐を確認できます。
確認するファイル: examples/loop.asm
.ORIG x3000
AND R1, R1, #0
ADD R1, R1, #3
LOOP LEA R0, MSG
PUTS
ADD R1, R1, #-1
BRp LOOP
HALT
MSG .STRINGZ "Hi\n"
.END.ORIG x3000は、このプログラムをLC-3メモリのx3000から配置するという意味です。LOOPとMSGはラベルで、命令や文字列の番地に名前を付けています。.STRINGZは、文字列をメモリに並べ、最後に終端の0を置く疑似命令です。
このプログラムでは、R1をカウンタとして使います。最初にR1を0にし、次に3を足します。その後、LOOPから始まる部分でHiを表示し、R1を1ずつ減らします。
BRp LOOPは、直前の計算結果が正のときだけLOOPへ戻る命令です。R1が2、1の間は戻ります。R1が0になると条件フラグがZになるので、分岐せずにHALTへ進みます。
まず普通に実行する
最初はデバッグ用のオプションを付けずに実行します。まず、プログラムが期待どおり動くことだけを確認します。
make
./lc3 examples/loop.obj出力は次のようになります。
Hi
Hi
Hi
HALTここでは、Hiが3回表示されることを確認できれば十分です。ただ、この出力だけでは、なぜ3回で止まったのか、R1がどう変化したのか、BRpがいつ分岐したのかは分かりません。
そこで、ここから観察用のオプションを使って中を見ていきます。
traceで命令の流れを見る
まず--traceを使います。--traceは、1命令を実行するたびに、実行した番地、命令word、opcode、レジスタ状態を表示します。
traceの出力では、最初の行のPC=...が「いま実行した命令の番地」です。次の行に出てくるレジスタ一覧は、その命令を実行したあとの状態です。同じ行の最後にあるPC=...は、次に実行する番地を表します。
./lc3 --trace examples/loop.objカウンタを3にする
最初の2命令は、カウンタR1の初期化です。ここで見ているのは、loop.asmの次の部分です。
AND R1, R1, #0
ADD R1, R1, #3traceでは、PC=3000がAND R1, R1, #0、PC=3001がADD R1, R1, #3に対応します。
PC=3000 INSTR=5260 OP=AND
R0=0000 R1=0000 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=0000 PC=3001 COND=Z
PC=3001 INSTR=1263 OP=ADD
R0=0000 R1=0003 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=0000 PC=3002 COND=PまずPC=3000の行を見ます。これはAND R1, R1, #0を実行した結果です。R1が0になります。そのため、条件フラグはCOND=Zになります。
条件フラグは、直前の計算結果によって変わります。結果が正ならP、0ならZ、負ならNです。
次にPC=3001の行を見ます。これはADD R1, R1, #3を実行した結果です。R1に3が入り、3は正の値なので、条件フラグはCOND=Pになります。
文字列の番地を入れて表示する
続いて、ループ本体に入ります。ここで見ているのは、loop.asmの次の部分です。
LOOP LEA R0, MSG
PUTStraceでは、PC=3002がLEA R0, MSG、PC=3003がPUTSに対応します。
PC=3002 INSTR=E004 OP=LEA
R0=3007 R1=0003 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=0000 PC=3003 COND=P
PC=3003 INSTR=F022 OP=TRAP PUTS
Hi
R0=3007 R1=0003 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3004 COND=PまずPC=3002の行を見ます。これはLEA R0, MSGを実行した結果です。LEA R0, MSGで、R0に文字列MSGの先頭アドレスが入ります。実行後のレジスタを見ると、R0=3007になっています。
ここでR0=3007になっているのは、文字列そのものがR0に入ったという意味ではありません。R0には、文字列が置かれているメモリ番地x3007が入っています。
次にPC=3003の行を見ます。これはPUTSを実行した結果です。PUTSは、R0が指す文字列を表示するTRAPです。そのため、ここでHiが表示されます。
カウンタを減らして戻る
表示した後は、カウンタを1つ減らして分岐します。ここで見ているのは、loop.asmの次の部分です。
ADD R1, R1, #-1
BRp LOOPtraceでは、PC=3004がADD R1, R1, #-1、PC=3005がBRp LOOPに対応します。
PC=3004 INSTR=127F OP=ADD
R0=3007 R1=0002 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3005 COND=P
PC=3005 INSTR=03FC OP=BR
R0=3007 R1=0002 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3002 COND=PまずPC=3004の行を見ます。これはADD R1, R1, #-1を実行した結果です。R1が3から2になります。まだ正なのでCOND=Pです。
次にPC=3005の行を見ます。これはBRp LOOPを実行した結果です。BRp LOOPは、条件フラグがPのときだけ分岐します。この時点ではCOND=Pなので、実行後のPCはx3002へ戻ります。
0になったらループを抜ける
同じ流れを進めていくと、3回目の表示のあと、最後の減算でR1が0になります。ここで見ているのは、loop.asmの次の部分です。
ADD R1, R1, #-1
BRp LOOP
HALTtraceでは、PC=3004が最後の減算、PC=3005がBRp LOOP、PC=3006がHALTに対応します。
PC=3004 INSTR=127F OP=ADD
R0=3007 R1=0000 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3005 COND=Z
PC=3005 INSTR=03FC OP=BR
R0=3007 R1=0000 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3006 COND=Z
PC=3006 INSTR=F025 OP=TRAP HALT
HALT
R0=3007 R1=0000 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3007 PC=3007 COND=ZまずPC=3004の行を見ます。R1が0になると、条件フラグはCOND=Zになります。次にPC=3005の行を見ると、BRp LOOPの実行後のPCはx3006です。BRpは正のときだけ分岐する命令なので、COND=Zのときは分岐しません。そのままx3006のHALTへ進み、停止します。
メモリダンプで配置を見る
表示する範囲を指定する
次に、--dump-memoryでx3000付近のメモリを見ます。
./lc3 --dump-memory x3000 12 examples/loop.objこのコマンドのx3000は表示を始める番地です。12は表示する個数で、バイト数ではなくLC-3メモリのword数です。つまり、x3000から12 word分を表示します。
出力は次のようになります。
Hi
Hi
Hi
HALT
memory x3000..x300B:
x3000: 5260 1263 E004 F022 127F 03FC F025 0048
x3008: 0069 000A 0000 0000命令が置かれている範囲を見る
x3000からx3006までは命令です。x3007からはMSGの文字列です。
x3000: 5260はAND R1, R1, #0ですx3001: 1263はADD R1, R1, #3ですx3002: E004はLEA R0, MSGですx3003: F022はPUTSですx3004: 127FはADD R1, R1, #-1ですx3005: 03FCはBRp LOOPですx3006: F025はHALTです
文字列が置かれている範囲を見る
x3007以降には、文字列Hi\nが入っています。LC-3の.STRINGZは、1文字を1 wordとして並べ、最後に0を置きます。
0048はHです0069はiです000Aは改行です0000は文字列の終端です
メモリダンプを見ると、命令と文字列が同じLC-3メモリ上に並んでいることが分かります。LEAやBRのようなPC相対命令を読むときは、この配置が重要になります。
LEAがなぜx3007を指すのか
対象のasmを確認する
ここでは、次のLEA R0, MSGがなぜ文字列MSGを指せるのかを見ます。
LOOP LEA R0, MSG
PUTS
ADD R1, R1, #-1
BRp LOOP
HALT
MSG .STRINGZ "Hi\n"traceでは、PC=3002のLEA R0, MSGを実行したあとにR0=3007になっていました。
PC=3002 INSTR=E004 OP=LEA
R0=3007 R1=0003 ... PC=3003 COND=PLEAは、現在のPCから相対的にアドレスを計算し、そのアドレスをレジスタへ入れる命令です。ここでは、MSGのアドレスをR0へ入れています。
PC相対として読む
PC相対というのは、命令wordの中に目的地の絶対番地をそのまま入れるのではなく、「いまのPCからいくつ離れているか」を入れる方式です。近くのラベルを指す命令では、この形をよく使います。
LEA命令そのものはx3002にあります。ただし、VMでは命令をfetchした時点でPCが次の番地へ進みます。そのため、LEAを実行するときの基準になるPCはx3003です。
offsetを足してMSGへ届く
LEA命令の番地: x3002
fetch後のPC: x3003
MSGの番地: x3007
x3003 + 4 = x3007命令wordのE004に含まれているoffsetは+4です。fetch後のPCであるx3003に4を足すと、MSGの先頭であるx3007になります。
ここで使う基準がx3002ではなくx3003である点が重要です。LC-3 VMでは、命令を読んだあとにPCが次の番地へ進みます。その進んだ後のPCを基準にoffsetを足します。
このため、PUTSはR0に入ったx3007から文字列を読み、Hiを表示できます。
BRpがなぜループするのか
戻り先のラベルを確認する
次に、BRp LOOPを見ます。ここで見ているのは、loop.asmの次の部分です。
LOOP LEA R0, MSG
PUTS
ADD R1, R1, #-1
BRp LOOP
HALTBRp LOOPはx3005にあります。戻り先のLOOPは、LEA R0, MSGがあるx3002です。メモリダンプでは、x3005に03FCが入っていました。
offsetは4つ戻る
x3005: 03FCBRpもPC相対の命令です。BRp命令そのものはx3005にありますが、fetch後のPCはx3006です。
戻り先のLOOPはx3002です。つまり、x3006からx3002へ戻るには、offsetが-4になります。
03FCの中では、この-4が9bitのPCoffsetとして入っています。負のoffsetなので、VMの中では符号拡張して16bitの値として扱います。ここでは細かいbit列よりも、「fetch後のPCから4つ戻る」と読めれば十分です。
BRp命令の番地: x3005
fetch後のPC: x3006
LOOPの番地: x3002
x3006 - 4 = x3002分岐するかはCONDで決まる
ただし、BRpは毎回必ず分岐するわけではありません。条件フラグがPのときだけ分岐します。
ADD R1, R1, #-1の結果が2や1なら、条件フラグはPです。そのため、BRpはx3002へ戻ります。結果が0になると条件フラグはZです。このときBRpは分岐せず、次のx3006へ進みます。
profileで実行回数を見る
traceとの違いを確認する
--profileを使うと、プログラム全体で何命令実行したか、opcodeごとに何回実行したかを確認できます。
--traceは実行順を1命令ずつ表示します。--profileは順番ではなく、プログラムが終わったあとに合計回数を表示します。細かい流れを見るときはtrace、全体の回数を見るときはprofile、という使い分けです。
./lc3 --profile examples/loop.objprofileの出力を見る
出力は次のようになります。
Hi
Hi
Hi
HALT
profile:
total instructions: 15
BR 3
ADD 4
AND 1
LEA 3
TRAP 4合計は15命令です。内訳を見ると、ループの回数も確認できます。
opcode単位で内訳を読む
この一覧はopcode単位です。PUTSとHALTは表示上は別のTRAP処理に見えますが、命令の種類としてはどちらもTRAPです。そのため、profileではTRAPにまとめて数えられます。
ANDは、R1を0にする初期化なので1回ですADDは、R1=3にする1回と、カウンタを減らす3回で合計4回ですLEAは、ループ内で文字列アドレスを入れるので3回ですBRは、ループ末尾で3回実行されますTRAPは、PUTS3回とHALT1回で合計4回です
--profileは、1命令ずつの細かい順番を見るものではありません。プログラム全体として、どの種類の命令が何回動いたかをざっくり確認するための道具です。
今回のような短いループでは、LEA、BR、PUTSが3回ずつ実行されたことから、ループが3回回ったことを確認できます。
breakでループ末尾に止める
止める行を決める
次に、--breakを使って、ループ末尾のBRp LOOPで止めます。ここで止めたいのは、loop.asmの次の行です。
BRp LOOPこの行はx3005にあります。そのため、ブレークポイントにはx3005を指定します。
./lc3 --break x3005 examples/loop.objここで止めると、分岐する直前のR1とCONDを確認できます。
ブレークポイントは、指定した番地の命令を実行する前に止まります。ここではx3005で止まった時点では、まだBRp LOOPは実行されていません。そのため、表示されているR1とCONDを見て、このあと分岐するかどうかを判断できます。
1回目はR1が2で止まる
1回目に止まったときは、R1が2です。
Hi
breakpoint hit at x3005
PC=3005 INSTR=03FC OP=BR
R0=3007 R1=0002 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3005 COND=P
break>R1=0002で、条件フラグはCOND=Pです。このあとEnterで続行すると、BRpはx3002へ分岐します。
break>が表示されたら、Enterを押すと続行します。この連載のブレークポイントは、停止中にコマンドを入力する対話式デバッガではなく、指定番地でいったん止めるための小さい機能です。
2回目はR1が1で止まる
2回目に止まったときは、R1が1です。
Hi
breakpoint hit at x3005
PC=3005 INSTR=03FC OP=BR
R0=3007 R1=0001 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3005 COND=P
break>まだCOND=Pなので、もう一度x3002へ戻ります。
3回目はR1が0で止まる
3回目に止まったときは、R1が0です。
Hi
breakpoint hit at x3005
PC=3005 INSTR=03FC OP=BR
R0=3007 R1=0000 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3004 PC=3005 COND=Z
break>今度はCOND=Zです。BRpは正のときだけ分岐するので、このあとはx3006のHALTへ進みます。
ブレークポイントは、バグを直すためだけのものではありません。今回のように、同じ番地で何度も止めて、レジスタや条件フラグがどう変わるかを見るためにも使えます。
dump-registersで最後の状態を見る
最後のレジスタを表示する
最後に、--dump-registersでプログラム終了後のレジスタを確認します。
./lc3 --dump-registers examples/loop.obj出力は次のようになります。
Hi
Hi
Hi
HALT
registers:
R0=3007 R1=0000 R2=0000 R3=0000 R4=0000 R5=0000 R6=0000 R7=3007 PC=3007 COND=ZR0とR1を読む
R0=3007は、最後に表示した文字列MSGの先頭アドレスです。PUTSはR0を使って文字列を表示するので、最後までこの値が残っています。
R1=0000は、カウンタが0まで減ったことを表します。この0が、ループ終了の理由です。
CONDとPCを読む
COND=Zは、最後のADD R1, R1, #-1の結果が0だったためです。BRpはCOND=Pのときだけ分岐するので、COND=Zになったことでループを抜けました。
PC=3007は、HALT実行後のPCです。HALT命令はx3006にありました。VMは命令をfetchするときにPCを進めるので、停止後のPCは次の番地であるx3007を指しています。
デバッグ機能を組み合わせる
組み合わせ例を見る
ここまで、各オプションを個別に使ってきました。実際には、これらのオプションは組み合わせて使えます。
第8回で作った--stepも、1命令ずつ止まりながら見るときに使えます。ただ、今回は出力例を追いやすくするため、主に--trace、--profile、--break、--dump-registers、--dump-memoryを使います。
./lc3 --trace --dump-registers examples/loop.obj
./lc3 --profile --dump-memory x3000 12 examples/loop.obj
./lc3 --break x3005 --dump-registers examples/loop.obj--trace --dump-registersは、命令の流れを見たあと、最後のレジスタ状態もまとめて確認したいときに使えます。
--profile --dump-memoryは、実行回数とメモリ配置を一緒に見たいときに使えます。今回のような短いプログラムでは、命令配置と実行回数の対応が分かりやすくなります。
--break x3005 --dump-registersは、ループ末尾で何度か止めながら状態を見て、最後に終了後の状態も確認したいときに使えます。
見たいものに合わせて選ぶ
--trace: 命令の順番を見たいときに使います--profile: どの命令が何回動いたか見たいときに使います--break: 特定の番地で止めたいときに使います--dump-registers: 最後のレジスタ状態を見たいときに使います--dump-memory: 命令や文字列の配置を見たいときに使います
1つの機能だけで全部を理解しようとする必要はありません。命令の順番はtrace、全体の回数はprofile、特定の場所の状態はbreak、最後の状態はdumpというように、見たいものに合わせて使い分けます。
今回のまとめ
第11回では、VM本体に新しい機能を追加せず、第10回までに作った観察機能を使いました。
短いループプログラムでも、PC、レジスタ、条件フラグ、メモリ配置を見ると、プログラムが動いている理由をかなり細かく追うことができます。
Hiが3回表示されるという結果だけでなく、R1が3から0まで減り、CONDがPからZになり、BRpが最後だけ分岐しないことを確認できました。
trace、profile、break、dumpを組み合わせると、LC-3プログラムがなぜその順番で動くのかを確認できます。
これは、VMを作るだけでなく、VMの中で動くプログラムを読むための道具になります。
次回: GitHubで試しやすい形に整える
第12回では、VM本体のコードには触れず、README、Makefile、サンプル、記事ごとのコードを整理し、GitHubから手元で試しやすい形に整えます。








