C言語で作るLC-3仮想マシン 第11回: 小さいLC-3プログラムをデバッグする | UNIX Cafe

* 当サイトでは、コンテンツの一部に広告を掲載しています。

System Note $ cat /proc/ai-disclosure

本記事の構成および論理分析には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プログラムと、その実行に使うサンプルファイルです。

Static Badge Static Badge Static Badge Static Badge

最初に作業ファイルをそろえる

まず、リポジトリを取得して第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やメモリダンプには、52601263E004F022のような4桁の16進数が出てきます。これはLC-3が実行する機械語の命令コードです。アセンブリで書いたAND R1, R1, #0ADD R1, R1, #3は、実行前にこの16bitの値へ変換されています。

LC-3の命令コードでは、先頭の16進数1桁、つまり上位4bitが命令の種類を表します。この部分をopcodeと呼びます。たとえば5260は先頭が5なのでAND1263127Fは先頭が1なのでADDE004は先頭がEなのでLEAF022F025は先頭が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から配置するという意味です。LOOPMSGはラベルで、命令や文字列の番地に名前を付けています。.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, #3

traceでは、PC=3000AND R1, R1, #0PC=3001ADD 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
        PUTS

traceでは、PC=3002LEA R0, MSGPC=3003PUTSに対応します。

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 LOOP

traceでは、PC=3004ADD R1, R1, #-1PC=3005BRp 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なので、実行後のPCx3002へ戻ります。

0になったらループを抜ける

同じ流れを進めていくと、3回目の表示のあと、最後の減算でR1が0になります。ここで見ているのは、loop.asmの次の部分です。

        ADD R1, R1, #-1
        BRp LOOP
        HALT

traceでは、PC=3004が最後の減算、PC=3005BRp LOOPPC=3006HALTに対応します。

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の実行後のPCx3006です。BRpは正のときだけ分岐する命令なので、COND=Zのときは分岐しません。そのままx3006HALTへ進み、停止します。

メモリダンプで配置を見る

表示する範囲を指定する

次に、--dump-memoryx3000付近のメモリを見ます。

./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: 5260AND R1, R1, #0 です
  • x3001: 1263ADD R1, R1, #3 です
  • x3002: E004LEA R0, MSG です
  • x3003: F022PUTS です
  • x3004: 127FADD R1, R1, #-1 です
  • x3005: 03FCBRp LOOP です
  • x3006: F025HALT です

文字列が置かれている範囲を見る

x3007以降には、文字列Hi\nが入っています。LC-3の.STRINGZは、1文字を1 wordとして並べ、最後に0を置きます。

  • 0048H です
  • 0069i です
  • 000A は改行です
  • 0000 は文字列の終端です

メモリダンプを見ると、命令と文字列が同じLC-3メモリ上に並んでいることが分かります。LEABRのような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=3002LEA R0, MSGを実行したあとにR0=3007になっていました。

PC=3002 INSTR=E004 OP=LEA
R0=3007 R1=0003 ... PC=3003 COND=P

LEAは、現在のPCから相対的にアドレスを計算し、そのアドレスをレジスタへ入れる命令です。ここでは、MSGのアドレスをR0へ入れています。

PC相対として読む

PC相対というのは、命令wordの中に目的地の絶対番地をそのまま入れるのではなく、「いまのPCからいくつ離れているか」を入れる方式です。近くのラベルを指す命令では、この形をよく使います。

LEA命令そのものはx3002にあります。ただし、VMでは命令をfetchした時点でPCが次の番地へ進みます。そのため、LEAを実行するときの基準になるPCx3003です。

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を足します。

このため、PUTSR0に入ったx3007から文字列を読み、Hiを表示できます。

BRpがなぜループするのか

戻り先のラベルを確認する

次に、BRp LOOPを見ます。ここで見ているのは、loop.asmの次の部分です。

LOOP    LEA R0, MSG
        PUTS
        ADD R1, R1, #-1
        BRp LOOP
        HALT

BRp LOOPx3005にあります。戻り先のLOOPは、LEA R0, MSGがあるx3002です。メモリダンプでは、x300503FCが入っていました。

offsetは4つ戻る

x3005: 03FC

BRpもPC相対の命令です。BRp命令そのものはx3005にありますが、fetch後のPCx3006です。

戻り先のLOOPx3002です。つまり、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です。そのため、BRpx3002へ戻ります。結果が0になると条件フラグはZです。このときBRpは分岐せず、次のx3006へ進みます。

profileで実行回数を見る

traceとの違いを確認する

--profileを使うと、プログラム全体で何命令実行したか、opcodeごとに何回実行したかを確認できます。

--traceは実行順を1命令ずつ表示します。--profileは順番ではなく、プログラムが終わったあとに合計回数を表示します。細かい流れを見るときはtrace、全体の回数を見るときはprofile、という使い分けです。

./lc3 --profile examples/loop.obj

profileの出力を見る

出力は次のようになります。

Hi
Hi
Hi
HALT

profile:
total instructions: 15

BR              3
ADD             4
AND             1
LEA             3
TRAP            4

合計は15命令です。内訳を見ると、ループの回数も確認できます。

opcode単位で内訳を読む

この一覧はopcode単位です。PUTSHALTは表示上は別のTRAP処理に見えますが、命令の種類としてはどちらもTRAPです。そのため、profileではTRAPにまとめて数えられます。

  • ANDは、R1を0にする初期化なので1回です
  • ADDは、R1=3にする1回と、カウンタを減らす3回で合計4回です
  • LEAは、ループ内で文字列アドレスを入れるので3回です
  • BRは、ループ末尾で3回実行されます
  • TRAPは、PUTS3回とHALT1回で合計4回です

--profileは、1命令ずつの細かい順番を見るものではありません。プログラム全体として、どの種類の命令が何回動いたかをざっくり確認するための道具です。

今回のような短いループでは、LEABRPUTSが3回ずつ実行されたことから、ループが3回回ったことを確認できます。

breakでループ末尾に止める

止める行を決める

次に、--breakを使って、ループ末尾のBRp LOOPで止めます。ここで止めたいのは、loop.asmの次の行です。

        BRp LOOP

この行はx3005にあります。そのため、ブレークポイントにはx3005を指定します。

./lc3 --break x3005 examples/loop.obj

ここで止めると、分岐する直前のR1CONDを確認できます。

ブレークポイントは、指定した番地の命令を実行する前に止まります。ここではx3005で止まった時点では、まだBRp LOOPは実行されていません。そのため、表示されているR1CONDを見て、このあと分岐するかどうかを判断できます。

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で続行すると、BRpx3002へ分岐します。

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は正のときだけ分岐するので、このあとはx3006HALTへ進みます。

ブレークポイントは、バグを直すためだけのものではありません。今回のように、同じ番地で何度も止めて、レジスタや条件フラグがどう変わるかを見るためにも使えます。

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=Z

R0とR1を読む

R0=3007は、最後に表示した文字列MSGの先頭アドレスです。PUTSR0を使って文字列を表示するので、最後までこの値が残っています。

R1=0000は、カウンタが0まで減ったことを表します。この0が、ループ終了の理由です。

CONDとPCを読む

COND=Zは、最後のADD R1, R1, #-1の結果が0だったためです。BRpCOND=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まで減り、CONDPからZになり、BRpが最後だけ分岐しないことを確認できました。

traceprofilebreakdumpを組み合わせると、LC-3プログラムがなぜその順番で動くのかを確認できます。

これは、VMを作るだけでなく、VMの中で動くプログラムを読むための道具になります。

次回: GitHubで試しやすい形に整える

第12回では、VM本体のコードには触れず、README、Makefile、サンプル、記事ごとのコードを整理し、GitHubから手元で試しやすい形に整えます。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

のいのアバター のい UNIX Cafe マスター

Macintosh Color Classicから始まった旅は、長いWindows時代を経て、Windows10のサポート終了をきっかけにUNIXの世界へ戻ってきました。UNIX Cafeでは、UNIX・Linux・そしてMacな世界を、むずかしい言葉を使わず、物語のように書いています。プログラミングは、アイデアをコンピューターに伝えるための言葉です。簡単な単語と文法を覚えれば、誰でもコマンドを使えます。ぜひ一度、やさしいプログラミングの世界をのぞいてみてください。

目次