- 目次ページに戻る / 前 / 次
- 前回からの差分をまとめて見る
さて、多段のサブルーチン呼び出しなんかやったりして、だんだん育ってきました。 それに連れてちょっとずつプログラムも長くなってきて、実行時の動きに不満が出てきましたので、 今回は本題の方をいったん脇に置いておいてそこを修正します。
何が不満かというと、1ステップごとに1秒スリープするというところです。 スリープ時間が長いとトライ&エラーを繰り返すときにかったるいし、 短くすると、結局最後まで終わってからゆっくり眺めることになって、 それだったらスリープなしでいいじゃんみたいになります。
そこで思いついたのがステップ実行方式への変更です。 何の気無しに思いついてやってみたところ思った以上に良いもので、 やってる本人としてはなかなかのブレイクスルー感がありました。
ステップ実行にすることによって
- 次にこれを実行します、と表示して止まる
- その表示を見て「それなら次はこうなるはずだ」と予測する。
たとえば
「
reg_a
の値が◯◯になるはずだ」「sp
の値が 1 減って戻り先アドレスがスタックに積まれるはずだ」 のように。 - 実際に1ステップ実行させる
- 予想どおりだったら「よしよし」といって次へ(くりかえす)
- そうでなければバグなので、デバッグする。 もしくは自分の予測が間違っているので、そっちを改める。
こういうサイクルが回るようになり、それによって脳内に動作モデルみたいなのが出来上がっていく感触がありました。
書籍などで解説を読むにしても、説明の文章や掲載されているコードをただ読んで頭の中だけで動作を想像するより、 実際に上記のように動かしてサイクルを回した方が圧倒的に理解しやすかったです。 たとえば x86アセンブリ言語での関数コール(vanya.jp.net) ではウェブページ上でステップ実行して試せるようになっているのですが、これがとても分かりやすくて参考になりました。これは紙の書籍ではできないことですね。
この後ベースポインタや引数やローカル変数が加わってスタックの扱いがさらに複雑になっていくのですが、 これがないと辛かっただろうな……と思います。
長々と書きましたが変更は数行です。
--- a/vgvm.rb +++ b/vgvm.rb @@ -28,6 +28,7 @@ class Vm def start dump() # 初期状態 + $stdin.gets loop do # operator @@ -78,14 +79,13 @@ class Vm raise "Unknown operator (#{op})" end - # 1命令実行するごとにダンプしてちょっと待つ dump() - sleep 1 + $stdin.gets end end def dump - puts "%- 10s | pc(%2d) | reg_a(%d) b(%d) c(%d) | zf(%d) | sp(%d,%d)" % [ + print "%- 10s | pc(%2d) | reg_a(%d) b(%d) c(%d) | zf(%d) | sp(%d,%d)" % [ @mem[@pc], @pc, @reg_a, @reg_b, @reg_c,
今回これで 1回使ってしまうのも何なので、ダンプ表示部分を全面的に書きなおして 一気に最終形に近いところまで修正してしまいます。 (実際には完成までの間にその都度細かく修正していましたが、ここでまとめて盛り込んでしまいます)
まずは前準備としてメモリを別クラスに分離します。 実際のコンピュータでも CPU とメモリは物理的に別ユニットだから、 という素朴な発想からですが、まあこれでやってみます。
まずは Memory
クラスを新たに作り、
インスタンスを Vm
のコンストラクタで渡すようにしました。
--- a/vgvm.rb +++ b/vgvm.rb @@ -2,8 +2,11 @@ require 'pp' require 'yaml' +class Memory +end + class Vm - def initialize + def initialize(mem) # program counter @pc = 0 @@ -134,7 +137,8 @@ end exe_file = ARGV[0] -vm = Vm.new +mem = Memory.new +vm = Vm.new(mem) vm.load_program(exe_file) vm.start
それから、 Vm#mem
だったところを Memory#main
に置き換えていきます。
--- a/vgvm.rb +++ b/vgvm.rb @@ -3,6 +3,11 @@ require 'pp' require 'yaml' class Memory + attr_accessor :main + + def initialize + @main = [] + end end class Vm @@ -18,7 +23,7 @@ class Vm # flag @zf = 0 - @mem = [] + @mem = mem # スタック領域 @stack = Array.new(4, 0) # スタックポインタ @@ -26,7 +31,7 @@ class Vm end def load_program(path) - @mem = YAML.load_file(path) + @mem.main = YAML.load_file(path) end def start @@ -35,21 +40,21 @@ class Vm loop do # operator - op = @mem[@pc] + op = @mem.main[@pc] case op (以下、同様に @mem を @mem.main に置き換え)
同じようにスタックも Memory
クラスに移動させます。
スタックのサイズは Memory.new
のときに渡すようにしました。
--- a/vgvm.rb +++ b/vgvm.rb @@ -3,10 +3,13 @@ require 'pp' require 'yaml' class Memory - attr_accessor :main + attr_accessor :main, :stack - def initialize + def initialize(stack_size) @main = [] + + # スタック領域 + @stack = Array.new(stack_size, 0) end end @@ -24,8 +27,6 @@ class Vm @zf = 0 @mem = mem - # スタック領域 - @stack = Array.new(4, 0) # スタックポインタ @sp = 3 end @@ -76,11 +77,11 @@ class Vm jump_eq(addr) when "call" @sp -= 1 # スタックポインタを1減らす - @stack[@sp] = @pc + 2 # 戻り先を記憶 + @mem.stack[@sp] = @pc + 2 # 戻り先を記憶 next_addr = @mem.main[@pc + 1] # ジャンプ先 @pc = next_addr when "ret" - ret_addr = @stack[@sp] # 戻り先アドレスを取得 + ret_addr = @mem.stack[@sp] # 戻り先アドレスを取得 @pc = ret_addr # 戻る @sp += 1 # スタックポインタを戻す else @@ -99,7 +100,7 @@ class Vm @pc, @reg_a, @reg_b, @reg_c, @zf, - @sp, @stack[@sp] + @sp, @mem.stack[@sp] ] end @@ -142,7 +143,7 @@ end exe_file = ARGV[0] -mem = Memory.new +mem = Memory.new(4) vm = Vm.new(mem) vm.load_program(exe_file)
ダンプ表示を改善します。
以下にコードをいろいろ貼ってますが、 修正後の動いている様子を先に見てもらった方が話が早いと思うので貼っておきます。
要するにこんな見た目+動きにしたい。
Vm
クラスに num_args_for()
というメソッドを追加。
_for
はなくても良かったかな。
VM 命令に対応する引数の数を教えてくれるというもの。 新しいダンプ表示で使います。
# class Vm def self.num_args_for(operator) case operator when "set_reg_a", "label", "call" 1 when "ret", "exit" 0 else raise "Invalid operator (#{operator})" end end
dump_main()
と dump_stack()
を Memory
クラスに追加。
ここでさっきの Vm.num_args_for()
を呼び出しています。
# class Memory def dump_main(pc) vmcmds = [] addr = 0 while addr < @main.size operator = @main[addr] num_args = Vm.num_args_for(operator) vmcmds << { addr: addr, xs: @main[addr .. addr + num_args] } addr += 1 + num_args end vmcmds.map{ |vmcmd| head = if vmcmd[:addr] == pc "pc =>" else " " end operator = vmcmd[:xs][0] "%s %02d %s" % [ head, vmcmd[:addr], vmcmd[:xs].inspect ] }.join("\n") end def dump_stack(sp) lines = [] @stack.each_with_index do |x, i| addr = i next if addr < sp - 8 head = case addr when sp "sp => " else " " end lines << head + "#{addr} #{x.inspect}" end lines.join("\n") end
Vm#dump_reg
と Vm#dump_v2
を追加して、
Vm#dump
の呼び出し箇所を書き換え。
--- a/vgvm.rb +++ b/vgvm.rb @@ -94,7 +94,7 @@ class Vm end def start - dump() # 初期状態 + dump_v2() # 初期状態 $stdin.gets loop do @@ -147,7 +147,7 @@ class Vm end # 1命令実行するごとにダンプしてちょっと待つ - dump() + dump_v2() $stdin.gets end end @@ -173,6 +173,25 @@ class Vm ] end + def dump_reg + [ + "reg_a(#{ @reg_a.inspect })", + "reg_b(#{ @reg_b.inspect })", + "reg_c(#{ @reg_c.inspect })" + ].join(" ") + end + + def dump_v2 + puts <<-EOB +================================ +#{ dump_reg() } zf(#{ @zf }) +---- memory (main) ---- +#{ @mem.dump_main(@pc) } +---- memory (stack) ---- +#{ @mem.dump_stack(@sp) } + EOB + end + def set_mem(addr, n) @mem.main[addr] = n end
古い方の Vm#dump
はもう使わないので消しておきます。
この段階ではこんな見た目。
================================ reg_a(2) reg_b(0) reg_c(0) zf(0) ---- memory (main) ---- 00 ["set_reg_a", 1] 02 ["call", 9] 04 ["set_reg_a", 5] 06 ["exit"] 07 ["label", "sub1"] 09 ["set_reg_a", 2] 11 ["call", 18] 13 ["set_reg_a", 4] 15 ["ret"] 16 ["label", "sub2"] pc => 18 ["set_reg_a", 3] 20 ["ret"] ---- memory (stack) ---- 0 0 sp => 1 13 2 4 3 0
ふむ……ラベルは、アセンブリのソースと同じようにインデントされているといいのでは? と考えてちょっと修正。
--- a/vgvm.rb +++ b/vgvm.rb @@ -35,9 +35,17 @@ class Memory operator = vmcmd[:xs][0] - "%s %02d %s" % [ + indent = + if operator == "label" + "" + else + " " + end + + "%s %02d %s%s" % [ head, vmcmd[:addr], + indent, vmcmd[:xs].inspect ] }.join("\n")
================================ reg_a(4) reg_b(0) reg_c(0) zf(0) ---- memory (main) ---- 00 ["set_reg_a", 1] 02 ["call", 9] pc => 04 ["set_reg_a", 5] 06 ["exit"] 07 ["label", "sub1"] 09 ["set_reg_a", 2] 11 ["call", 18] 13 ["set_reg_a", 4] 15 ["ret"] 16 ["label", "sub2"] 18 ["set_reg_a", 3] 20 ["ret"]
お、やっぱりちょっといいですね。
さらに、ジャンプ系の命令(jump
, jump_eq
, call
, ret
)と
exit
は他と区別ができると見やすいかなと思って色を付けました。
インデントと同じくこれも見た目の問題ですが、 色が付くと楽しくなってモチベーションが上がるのと、 見やすくなってデバッグ効率的にもよいはず。
--- a/vgvm.rb +++ b/vgvm.rb @@ -2,6 +2,11 @@ require 'pp' require 'yaml' +module TermColor + RESET = "\e[m" + RED = "\e[0;31m" +end + class Memory attr_accessor :main, :stack @@ -35,6 +40,14 @@ class Memory operator = vmcmd[:xs][0] + color = + case operator + when "exit", "call", "ret", "jump", "jump_eq" + TermColor::RED + else + "" + end + indent = if operator == "label" "" @@ -42,7 +55,7 @@ class Memory " " end - "%s %02d %s%s" % [ + "%s %02d #{color}%s%s#{TermColor::RESET}" % [ head, vmcmd[:addr], indent,
というわけで、このようになりました(上に貼ったのと同じ画像です)。 ステップごとに Enter キーを押してステップ実行しています。
そこはかとなくデバッガっぽい雰囲気のするダンプ表示になりましたね。
こんなふうに可視化(?)すると、 なんか機械がガチャガチャ動いてる感じがして楽しい!!!!
コードがそこそこ増えましたが、 それを上回る改善効果が得られたような気がします。 たった数十行でこれができるなら安い!!
追記 2019-12-25
case 式を使って Vm.num_args_for()
を書いていますが、
1行に複数の命令を並べているため、この後で命令の変更が発生したときの diff が見にくくなってしまいました。
今思えば、1命令が1行になるように、次のようにハッシュで書いておけばよかったですね……
(※ 実装としてどちらが良いかという話ではなく、diff と記事を見やすくするための都合です)
。
class Vm NUM_ARGS_MAP = { "ret" => 0, "exit" => 0, "set_reg_a" => 1, "label" => 1, "call" => 1 } end