vm2gol v2 製作メモ(10) ステップ実行 / ダンプ表示の改善



さて、多段のサブルーチン呼び出しなんかやったりして、だんだん育ってきました。 それに連れてちょっとずつプログラムも長くなってきて、実行時の動きに不満が出てきましたので、 今回は本題の方をいったん脇に置いておいてそこを修正します。

何が不満かというと、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)

ダンプ表示を改善します。

以下にコードをいろいろ貼ってますが、 修正後の動いている様子を先に見てもらった方が話が早いと思うので貼っておきます。

f:id:sonota88:20190521050134g:plain

要するにこんな見た目+動きにしたい。


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_regVm#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 キーを押してステップ実行しています。

f:id:sonota88:20190521050134g:plain

そこはかとなくデバッガっぽい雰囲気のするダンプ表示になりましたね。

こんなふうに可視化(?)すると、 なんか機械がガチャガチャ動いてる感じがして楽しい!!!!

コードがそこそこ増えましたが、 それを上回る改善効果が得られたような気がします。 たった数十行でこれができるなら安い!!

追記 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