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


vm2gol v2 製作メモ(9) 2段以上のサブルーチン呼び出し / スタック領域とスタックポインタ



前回簡単なサブルーチンをやりましたが、 今回はサブルーチンからまた別のサブルーチンを呼ぶ、 というのをやってみたいです。 これも普通にできてほしい。

今の状態ではこれはできません。 なぜかというと、 ret で戻るときの戻り先アドレスを reg_c に入れているからです。 このまま2段目のサブルーチン呼び出しをやってしまうと、 call のときに reg_c が上書きされてしまい、2段目からは戻れても 1段目からは正しく戻れなくなってしまいます。

そこで、スタック領域というものを用意します。

メインメモリの末尾の方をスタック領域として使うのが一般的っぽいのですが、 ここではメインメモリとは別にスタック領域を用意します。 なぜならその方が簡単そうだから(どっちでもあんまり変わらない?)。 我々(?)は初心者なので難しそうなことをやってはいけません。

まあ、いったんこれで作っておいて後から変えることもできなくはなさそうです。


はい。ではやります。

まずスタック領域を用意します。 メインメモリと同じくただの配列です。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -16,6 +16,8 @@ class Vm
     @zf = 0
 
     @mem = []
+    # スタック領域
+    @stack = Array.new(4, 0)
   end
 
   def load_program(path)

スタック領域のサイズはとりあえず 4 としました。かわいいスタックです。

それから、スタック領域の先頭がどこなのかを記憶しておく必要があるので、 その位置を保存しておくレジスタ、スタックポインタを追加します。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -18,6 +18,8 @@ class Vm
     @mem = []
     # スタック領域
     @stack = Array.new(4, 0)
+    # スタックポインタ
+    @sp = 3
   end
 
   def load_program(path)

スタック領域のサイズを 4 としたので、 スタックポインタの初期値は、スタックの「底」である 3 を指すようにしておきます。 次の図のようなイメージです。

f:id:sonota88:20190519121326p:plain
図 9-1 スタック領域の初期状態


それから、 callret を書き換えて reg_c ではなくスタック領域を使うようにします。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -66,11 +66,14 @@ class Vm
         addr = @mem[@pc + 1]
         jump_eq(addr)
       when "call"
-        @reg_c = @pc + 2 # 戻り先を記憶(call ではなく、その次の命令になるように)
+        @sp -= 1 # スタックポインタを1減らす
+        @stack[@sp] = @pc + 2 # 戻り先を記憶
         next_addr = @mem[@pc + 1] # ジャンプ先
         @pc = next_addr
       when "ret"
-        @pc = @reg_c
+        ret_addr = @stack[@sp] # 戻り先アドレスを取得
+        @pc = ret_addr # 戻る
+        @sp += 1 # スタックポインタを戻す
       else
         raise "Unknown operator (#{op})"
       end
  • sp から 1 を引いて戻り先アドレスをセット
  • 戻り先アドレスを取り出して sp に 1 を足す

が対になっているのがミソですね。


あわせてダンプ表示をいじります。 追加で @mem[@pc], @sp, @stack[@sp] も表示します。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -85,10 +85,12 @@ class Vm
   end
 
   def dump
-    puts "pc(%2d) | reg_a(%d) b(%d) c(%d) | zf(%d)" % [
+    puts "%- 10s | pc(%2d) | reg_a(%d) b(%d) c(%d) | zf(%d) | sp(%d,%d)" % [
+      @mem[@pc],
       @pc,
       @reg_a, @reg_b, @reg_c,
-      @zf
+      @zf,
+      @sp, @stack[@sp]
     ]
   end
 

機械語コードを書いて……

# 09_nested_call.vge.yaml

[
  "set_reg_a", 1,
  "call", 9, # sub1
  # 4 sub1 からここに戻って来るはず
  "set_reg_a", 5,
  "exit",

  # 7
  "label", "sub1",
  # 9
  "set_reg_a", 2,
  "call", 18, # sub2
  # 13 sub2 からここに戻って来るはず
  "set_reg_a", 4,
  "ret",

  # 16
  "label", "sub2",
  # 18
  "set_reg_a", 3,
  "ret"
]

動かしてみます。

$ ruby vgvm.rb 09_nested_call.vge.yaml
set_reg_a  | pc( 0) | reg_a(0) b(0) c(0) | zf(0) | sp(3,0)
call       | pc( 2) | reg_a(1) b(0) c(0) | zf(0) | sp(3,0)
set_reg_a  | pc( 9) | reg_a(1) b(0) c(0) | zf(0) | sp(2,4)  … call されて @pc が変更され、
                                                               スタック領域に sub1 からの戻り先アドレスがセットされた
call       | pc(11) | reg_a(2) b(0) c(0) | zf(0) | sp(2,4)
set_reg_a  | pc(18) | reg_a(2) b(0) c(0) | zf(0) | sp(1,13) … call されて @pc が変更され、
                                                               スタック領域に sub2 からの戻りアドレスがセットされた
ret        | pc(20) | reg_a(3) b(0) c(0) | zf(0) | sp(1,13)
set_reg_a  | pc(13) | reg_a(3) b(0) c(0) | zf(0) | sp(2,4)  … ret により sub2 から戻った
ret        | pc(15) | reg_a(4) b(0) c(0) | zf(0) | sp(2,4)
set_reg_a  | pc( 4) | reg_a(4) b(0) c(0) | zf(0) | sp(3,0)  … ret により sub1 から戻った
exit       | pc( 6) | reg_a(5) b(0) c(0) | zf(0) | sp(3,0)
exit

いいですねー。 reg_a の値も 1, 2, 3, 4, 5 と予想どおりに変わっています。

ではアセンブリコードからやってみましょう。 今回はジャンプまわりの変更がないのでアセンブラの修正は不要でそのまま動くはず。

# 09_nested_call.vga.txt

  set_reg_a 1
  call sub1   # sub1 を呼び出し
  set_reg_a 5 # sub1 からここに戻って来るはず
  exit

label sub1
  set_reg_a 2
  call sub2   # sub2 を呼び出し
  set_reg_a 4 # sub2 からここに戻って来るはず
  ret

label sub2
  set_reg_a 3
  ret
$ ./run.sh 09_nested_call.vga.txt
set_reg_a  | pc( 0) | reg_a(0) b(0) c(0) | zf(0) | sp(3,0)
call       | pc( 2) | reg_a(1) b(0) c(0) | zf(0) | sp(3,0)
set_reg_a  | pc( 9) | reg_a(1) b(0) c(0) | zf(0) | sp(2,4)
call       | pc(11) | reg_a(2) b(0) c(0) | zf(0) | sp(2,4)
set_reg_a  | pc(18) | reg_a(2) b(0) c(0) | zf(0) | sp(1,13)
ret        | pc(20) | reg_a(3) b(0) c(0) | zf(0) | sp(1,13)
set_reg_a  | pc(13) | reg_a(3) b(0) c(0) | zf(0) | sp(2,4)
ret        | pc(15) | reg_a(4) b(0) c(0) | zf(0) | sp(2,4)
set_reg_a  | pc( 4) | reg_a(4) b(0) c(0) | zf(0) | sp(3,0)
exit       | pc( 6) | reg_a(5) b(0) c(0) | zf(0) | sp(3,0)
exit

さっきと同じ結果になっています。OK!

いやー、スタック領域を導入するだけで入れ子の呼び出しができてしまいました。 意外と簡単ですね?



vm2gol v2 製作メモ(8) サブルーチンの呼び出し(call, ret)



どんどんプログラムっぽくしていきましょう。

プログラムといえばサブルーチン! サブルーチンやりましょう!

最初は簡単なのがいいので、 reg_a に値をセットするだけの サブルーチンをやってみます。

callret という命令を追加します。

# 08_call_ret.vga.txt

  set_reg_b 1
  call sub
  set_reg_b 3
  exit

label sub
  set_reg_a 2
  ret

call でサブルーチン sub にジャンプし、 サブルーチンの処理が終わったら ret で元の場所に戻ります。

順番としては、

set_reg_b 1
set_reg_a 2
set_reg_b 3

のように動いてほしい。

call にはジャンプ先のラベル名が与えられていて、 普通のジャンプと同じようにすれば良さそうです。

問題は ret です。 サブルーチンから戻るには「どこに戻るか」が分からないと戻れません。

ひとまず、 reg_c が空いているので reg_c に戻り先のアドレスをセットするようにしましょうか。 いつセットするかというと、 call するときですね。 戻り先アドレスを覚えておくというところが、単純な jump と異なるところです。

まずは VM に命令を追加して…

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -61,6 +61,12 @@ class Vm
       when "jump_eq"
         addr = @mem[@pc + 1]
         jump_eq(addr)
+      when "call"
+        @reg_c = @pc + 2 # 戻り先を記憶(call ではなく、その次の命令になるように)
+        next_addr = @mem[@pc + 1] # ジャンプ先
+        @pc = next_addr
+      when "ret"
+        @pc = @reg_c
       else
         raise "Unknown operator (#{op})"
       end

アセンブラも修正しないといけないんですが、いっぺんにやると分からなくなりそうなので まずは機械語コードを自分で書きます。 せっかくアセンブラ作ったのにまだ手で機械語コード書くのか……という気もしますが、 一歩ずつということで。

# 08_call_ret.vge.yaml

[
  "set_reg_b", 1, # サブルーチン呼び出し前
  "call", 9,      # サブルーチン呼び出し
  # 4
  "set_reg_b", 3, # サブルーチン呼び出しから戻った後
  "exit",

  # 7
  # ここからサブルーチン
  "label", "sub",
  # 9
  "set_reg_a", 2,
  "ret"
]
$ ruby vgvm.rb 08_call_ret.vge.yaml
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(1) c(0) | zf(0) … b=1 がセットされた
pc( 9) | reg_a(0) b(1) c(4) | zf(0) … reg_c に戻り先アドレスがセットされ、
                                       サブルーチンにジャンプした
pc(11) | reg_a(2) b(1) c(4) | zf(0) … a=2 がセットされた
pc( 4) | reg_a(2) b(1) c(4) | zf(0) … 呼び出し元(の次の命令の位置)に戻った
pc( 6) | reg_a(2) b(3) c(4) | zf(0) … b=3 がセットされた
exit

いいですね!

良さそうなので、アセンブラを修正してさっき手で書いた 機械語コードと同じものが出力されるようにします。 といってもこれだけ。

--- a/vgasm.rb
+++ b/vgasm.rb
@@ -50,7 +50,7 @@ alines.each do |aline|
   case head
   when "label"
     words << rest[0]
-  when "jump", "jump_eq"
+  when "jump", "jump_eq", "call"
     label_name = rest[0]
     words << label_addr_map[label_name] + 2
   else

まずはアセンブルだけやってみて出力を確認します。

アセンブラが出力した機械語コードはアドレスの情報がなくて自分で数えるのが面倒なので、 確認用の簡単なスクリプトを書きました。

# dump_exe.rb

lineno = -2

while line = $stdin.gets do
  lineno += 1
  puts "% 4d %s" % [lineno, line]
end

これを使って見てみると…

$ ruby vgasm.rb 08_call_ret.vga.txt | ruby dump_exe.rb
  -1 ---
   0 - set_reg_b
   1 - 1
   2 - call
   3 - 9
   4 - set_reg_b
   5 - 3
   6 - exit
   7 - label
   8 - sub
   9 - set_reg_a
  10 - 2
  11 - ret

call subcall 9 に期待したとおりに置き換えられていて、問題なさそうです。


サッと書いてしまったものの、番号を表示するだけならnl コマンドでもよかったですね……。

$ ruby vgasm.rb 08_call_ret.vga.txt | nl -v -1
    -1  ---
     0  - set_reg_b
     1  - 1
     2  - call
     3  - 9
     4  - set_reg_b
     5  - 3
     6  - exit
     7  - label
     8  - sub
     9  - set_reg_a
    10  - 2
    11  - ret

良さそうなので run.sh でまとめて実行しましょう。

$ ./run.sh 08_call_ret.vga.txt
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(1) c(0) | zf(0)
pc( 9) | reg_a(0) b(1) c(4) | zf(0)
pc(11) | reg_a(2) b(1) c(4) | zf(0)
pc( 4) | reg_a(2) b(1) c(4) | zf(0)
pc( 6) | reg_a(2) b(3) c(4) | zf(0)
exit

いいですね!

初めて機械語アセンブリ言語でサブルーチン呼び出しを書きました(オレオレの機械語アセンブリ言語ですが……)! 新鮮!

EDSAC

(2024-01-06 追記)

EDSAC ではジャンプ命令のオペランドを直接書き換えて戻り先アドレスを記憶させていたそうです。

当時、プログラムの命令とデータはまったく同じようにメモリに記録されていました。 そのため変数に値を代入するような気軽さで、プログラム自身を書き換えることができました。 関数を呼んだあと元の位置に戻ってくることを、ジャンプ命令のジャンプ先を書き換えて実現していました。

コーディングを支える技術――成り立ちから学ぶプログラミング作法』 p46



vm2gol v2 製作メモ(7) ラベルとアセンブラ



さて、機械語コードをいくつか書いてきて、 ジャンプ先のアドレスを調べて修正するのに慣れてきた一方で、だんだん面倒にもなってきました。 プログラムを修正するたびに調べるのも面倒だし、書きなおすのも面倒。 こんなの人間のやる仕事じゃねえ!

で、なんか自動化できそうなのでやってみます。

そのためにラベルという命令?   命令というか目印みたいなのを導入します。

ラベルを使って、プログラムをこんな感じで書けないでしょうか?

[
  "set_reg_a", 0,
  "set_reg_b", 0,
  "compare",
  "jump_eq", "then", # ラベルを指定してジャンプ
  "set_reg_c", 3,
  "jump", "endif",   # ラベルを指定してジャンプ
  "label", "then",   # ラベル
  "set_reg_c", 2,
  "label", "endif",  # ラベル
  "set_reg_a", 4,
  "exit"
]

これを変換して、 "then" とか "endif" の部分を数字(アドレス)にすれば、 これまで手で書いてきた機械語コードと同じ位置付けのものになる (VM に渡せるようになる)、という寸法です。

この「変換前のプログラム」のフォーマットも紆余曲折(JSON にしたり)があり、 vm2gol-v1 のときはこんな感じの YAML ファイルに落ち着きました。

- set_reg_a 0
- set_reg_b 0
- compare
- jump_eq then
- set_reg_c 3
- jump endif
- label then
- set_reg_c 2
- label endif
- set_reg_a 4
- exit

ですが、ラベルがインデントされているとやはり見やすいということで、 ちょっと工夫して、たとえばこんなフォーマットを考えてみます。

-   set_reg_a 0
-   set_reg_b 0
-   compare
-   jump_eq then
-   set_reg_c 3
-   jump endif
- label then
-   set_reg_c 2
- label endif
-   set_reg_a 4
-   exit

これを YAML としてパースすると、先頭の余分な空白は無視されるので、 内容的には上のインデントなしと同じになります。

あ、でもこれだと、 このファイルをプログラムで自動生成するときに困りそうかな……。

というわけで、 vm2gol-v2 では YAML はやめてオレオレフォーマットにします。

  set_reg_a 0
  set_reg_b 0 # ここを書き換えて動作確認する
  compare
  jump_eq then
  set_reg_c 3
  jump endif

label then
  set_reg_c 2

label endif
  set_reg_a 4
  exit

これを機械語コードに変換するプログラムを作りました。これです。

# vgasm.rb

# coding: utf-8
require 'pp'
require 'yaml'

def parse(src)
  alines = []
  src.each_line do |line|
    words = line.sub(/#.*/, "").strip.split(/ +/)
    unless words.empty?
      alines << words
    end
  end
  alines
end

def create_label_addr_map(alines)
  map = {}

  addr = 0
  alines.each do |aline|
    head, *rest = aline

    case head
    when "label"
      name = rest[0]
      map[name] = addr
      addr += 2
    else
      addr += 1
      addr += rest.size
    end
  end

  map
end

src = File.read(ARGV[0])
alines = parse(src)

# key: ラベル名、 value: アドレス のマッピングを作る
label_addr_map = create_label_addr_map(alines)
# pp label_addr_map

words = []
alines.each do |aline|
  head, *rest = aline

  words << head

  case head
  when "label"
    words << rest[0]
  when "jump", "jump_eq"
    label_name = rest[0]
    words << label_addr_map[label_name]
  else
    words += rest.map{ |arg|
        (/^\-?\d+$/ =~ arg) ? arg.to_i : arg
      }
  end
end

puts YAML.dump(words)

2パス(2段階)の処理に分かれていて、 まず最初のパスでラベル名とアドレスのマッピングを作り、 2パス目で jumpjump_eq で指定されているラベルをアドレスに置き換えます。

あれっなんかアセンブラができてしまいましたね(わざとらしく)。 そしてこの「変換前のプログラム」はアセンブリのコードですね(わざとらしく)?

拡張子は .vga.txt にします。アセンブリ(Assembly)の a です。

さっきの「変換前のコード」を 07_if.vga.txt に保存して、 実行します!

$ cat 07_if.vga.txt 
  set_reg_a 0
  set_reg_b 0 # ここを書き換えて動作確認する
  compare
  jump_eq then
  set_reg_c 3
  jump endif

label then
  set_reg_c 2

label endif
  set_reg_a 4
  exit

$ ruby vgasm.rb 07_if.vga.txt > 07_if.vge.yaml

$ cat 07_if.vge.yaml
---
- set_reg_a
- 0
- set_reg_b
- 0
- compare
- jump_eq
- 11
- set_reg_c
- 3
- jump
- 15
- label
- then
- set_reg_c
- 2
- label
- endif
- set_reg_a
- 4
- exit

jump, jump_eq の引数がそれぞれ 11, 15 となっていて、 うまく変換できてますね。

これを VM に与えると…

$ ruby vgvm.rb 07_if.vge.yaml
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(0) c(0) | zf(0)
pc( 4) | reg_a(0) b(0) c(0) | zf(0)
pc( 5) | reg_a(0) b(0) c(0) | zf(1)
pc(11) | reg_a(0) b(0) c(0) | zf(1)
vgvm.rb:63:in `block in start': Unknown operator (label) (RuntimeError)
        from vm.rb:28:in `loop'
        from vm.rb:28:in `start'
        from vm.rb:135:in `<main>'

おっと VM の修正を忘れていました。 label の場合は何もする必要がなくて、単に次に進むだけとします。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -53,6 +53,8 @@ class Vm
       when "compare"
         compare()
         @pc += 1
+      when "label"
+        @pc += 2
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr

では再度実行。

  $ ruby vgvm.rb 07_if.vge.yaml
  pc( 0) | reg_a(0) b(0) c(0) | zf(0)
  pc( 2) | reg_a(0) b(0) c(0) | zf(0)
  pc( 4) | reg_a(0) b(0) c(0) | zf(0)
  pc( 5) | reg_a(0) b(0) c(0) | zf(1) … compare を実行した
  pc(11) | reg_a(0) b(0) c(0) | zf(1) … ラベル "then" の位置にジャンプした
  pc(13) | reg_a(0) b(0) c(0) | zf(1) … label なので何もせず進んだ
  pc(15) | reg_a(0) b(0) c(2) | zf(1) … then句を実行した
  pc(17) | reg_a(0) b(0) c(2) | zf(1) … endif にジャンプした
  pc(19) | reg_a(4) b(0) c(2) | zf(1)
  exit

良さそうですね。

……いや、ちょっと待ってください。 正しい動きではあるんですが、 ラベルそのものがあるアドレスにジャンプしても label の時は何もせず進むだけなので、無駄な感じがしますね。

なので、ジャンプするときはラベルの次の位置にジャンプさせると良いのでは?

やってみましょうか。

@@ -41,7 +41,7 @@ alines.each{ |line|
     words << rest[0]
   when "jump", "jump_eq"
     label_name = rest[0]
-    words << label_addr_map[label_name]
+    words << label_addr_map[label_name] + 2
   else
     words.concat(
       rest.map{ |arg|
$ ruby vgasm.rb 07_if.vga.txt > 07_if.vge.yaml

$ cat 07_if.vge.yaml 
---
- set_reg_a
- 0
- set_reg_b
- 0
- compare
- jump_eq
- 13
- set_reg_c
- 3
- jump
- 17
- label
- then
- set_reg_c
- 2
- label
- endif
- set_reg_a
- 4
- exit

$ ruby vgvm.rb 07_if.vge.yaml
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(0) c(0) | zf(0)
pc( 4) | reg_a(0) b(0) c(0) | zf(0)
pc( 5) | reg_a(0) b(0) c(0) | zf(1)
pc(13) | reg_a(0) b(0) c(0) | zf(1) … ラベルの次の位置にジャンプした
pc(15) | reg_a(0) b(0) c(2) | zf(1)
pc(17) | reg_a(0) b(0) c(2) | zf(1)
pc(19) | reg_a(4) b(0) c(2) | zf(1)
exit

特に問題なさそう。

(こうすると VM に追加した label は用済みな気が…… まあ残したままにしておきましょうか)


なんかあっさりとアセンブラができてしまいました。

といっても命令も値も機械語と同じで、 仕事らしい仕事といえばラベルの変換くらいなので、 アセンブラと言ってしまってよいものかという感じはしますが。

※ ちなみに、この後もアセンブラ部分はほとんど変わりません。これでほぼ完成形。


あ、そうだ、今回のでアセンブルVM での実行の 2段階に分かれて、 実行するのがちょっと面倒になってきたので、 いっぺんに実行させるための簡単な Bash スクリプトを用意しておきます。

#!/bin/bash

set -o errexit

file="$1"
bname=$(basename $file .vga.txt)
exefile=tmp/${bname}.vge.yaml

ruby vgasm.rb $file > $exefile
ruby vgvm.rb $exefile

アセンブラによって生成された .vge.yaml ファイルは tmp/ というディレクトリに入るようにしました。

vm2gol-v1 のときは生成されたファイルも全部 git のリポジトリに入れていましたが、 今回は tmp/ ディレクトリごと無視します。

--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+tmp/

run.sh という名前で保存して chmod で実行権限を付けて、 こいつにアセンブリのファイルを渡すと、 VM での実行までやってくれます。

$ chmod u+x run.sh

$ ./run.sh 07_if.vga.txt 
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(0) c(0) | zf(0)
pc( 4) | reg_a(0) b(0) c(0) | zf(0)
(略)


vm2gol v2 製作メモ(6) if文っぽい条件分岐



前回 comparejump_eq を使っていきなりカウンタを作ってしまいましたが、 この2つがあれば、

a = 0
b = 0
if a == b
  c = 2
else
  c = 3
end
a = 4

みたいな普通の if 文っぽいことができそうです。ちょっとやってみましょう。

BASIC風に書くとこう。

10 set_reg_a 0
20 set_reg_b 0
30 compare
40 jump_eq 70

# else
50 set_reg_c 3
60 jump 80

# then
70 set_reg_c 2

# endif
80 set_reg_a 4

a と b が等しい場合は c に 2 をセットし、 そうでない場合は c に 3 をセットし、 どちらの場合でもその後に a に 4 をセットする。

else 節の後に then 節、という順番が慣れない感じですが、 なんかできそうですね。機械語にしてみます。

# 06_if.vge.yaml

[
  # 0
  "set_reg_a", 0,
  # 2
  "set_reg_b", 0, # ここを書き換えて動作確認する
  # 4
  "compare",
  # 5
  "jump_eq", 11,
  # 7
  "set_reg_c", 3,
  # 9
  "jump", 13,
  # 11
  "set_reg_c", 2,
  # 13
  "set_reg_a", 4,
  # 15
  "exit"
]

まずはこれをそのまま実行します。

$ ruby vgvm.rb 06_if.vge.yaml
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(0) c(0) | zf(0)
pc( 4) | reg_a(0) b(0) c(0) | zf(0)
pc( 5) | reg_a(0) b(0) c(0) | zf(1) … a == b なので zf == 1 になった
pc(11) | reg_a(0) b(0) c(0) | zf(1) … then 節にジャンプした
pc(13) | reg_a(0) b(0) c(2) | zf(1) … then 節を実行した
pc(15) | reg_a(4) b(0) c(2) | zf(1) … 最後に reg_a に 4 をセットした
exit

いいですね。では、プログラムを書き換えて a == b とならない場合を試してみます。

"set_reg_b", 0,

"set_reg_b", 1,

に書き換えて実行。

$ ruby vgvm.rb 06_if.vge.yaml
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(0) c(0) | zf(0)
pc( 4) | reg_a(0) b(1) c(0) | zf(0)
pc( 5) | reg_a(0) b(1) c(0) | zf(0) … a != b なので zf == 0 のまま
pc( 7) | reg_a(0) b(1) c(0) | zf(0) … ジャンプせずに次の命令へ進んだ
pc( 9) | reg_a(0) b(1) c(3) | zf(0) … else 節を実行した
pc(13) | reg_a(0) b(1) c(3) | zf(0) … then 節を飛び越えて最後にジャンプした
pc(15) | reg_a(4) b(1) c(3) | zf(0) … 最後に reg_a に 4 をセットした
exit

いいですね!



vm2gol v2 製作メモ(5) 条件分岐(compare, jump_eq)



チンタラやっててなかなか進みませんが一歩ずつやっていきます。

ループができるようになったら、次は条件分岐ですよ! プログラムといえば条件分岐!

条件分岐はどうやるかというと…… comparejump_eq という命令を導入します。 こいつを使って前回のカウンタを改造して、 値が 3 になったら 0 に戻ってまた 1 ずつ増えるというカウンタを作りましょう。

BASIC 風に書くとこうでしょうか:

10 set_reg_c 1  # これは最初に1回だけ実行される
20 set_reg_b 3  # これは最初に1回だけ実行される
30 set_reg_a 0
40 add_ac       # a + c の結果を a にセット
50 compare      # reg_a と reg_b を比較してフラグをセットする
60 jump_eq 30   # reg_a と reg_b が等しかったら、30番地に戻って reg_a の値を 0 に戻す
                # そうでなければ何もせず次に進む
70 jump 40      # reg_a と reg_b が等しくない場合

機械語コードに翻訳します。

# 05_compare_jump_eq.vge.yaml

[
  # 0
  "set_reg_c", 1,
  # 2
  "set_reg_b", 3,
  # 4
  "set_reg_a", 0,
  # 6
  "add_ac",
  "compare",
  "jump_eq", 4,
  "jump", 6
]

まず、レジスタreg_a, reg_b の2つだけだとめんどくさくなりそうな気がしたので、 reg_c を使うようにしてみました。

reg_a に足すべき値である 1 を reg_c にセットしておき、 add_acreg_c の値を reg_a に足します。

set_reg_cadd_ac はまだないので追加します。 set_reg_aadd_ab とほとんど同じです。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -35,6 +35,10 @@ class Vm
         n = @mem[@pc + 1]
         @reg_b = n
         @pc += 2
+      when "set_reg_c"
+        n = @mem[@pc + 1]
+        @reg_c = n
+        @pc += 2
       when "add_ab"
         add_ab()
         @pc += 1
--- a/vgvm.rb
+++ b/vgvm.rb
@@ -42,6 +42,9 @@ class Vm
       when "add_ab"
         add_ab()
         @pc += 1
+      when "add_ac"
+        add_ac()
+        @pc += 1
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr
@@ -74,6 +77,10 @@ class Vm
   def add_ab
     @reg_a = @reg_a + @reg_b
   end
+
+  def add_ac
+    @reg_a = @reg_a + @reg_c
+  end
 end
 
 exe_file = ARGV[0]

add_abadd_ac も 1行しかないのでメソッドにしなくてよかったかもしれませんね…… まあ、いちいち直していくのも煩雑なのでこのまま進めます)


準備ができたところで今回の目玉である comparejump_eq の登場です!

compare の動作はこう:

reg_a と reg_b の値を比較し、
  等しければ:     ゼロフラグに 1 をセット
  等しくなければ: ゼロフラグに 0 をセット

jump_eq の動作はこう:

ゼロフラグの値が
  0 だったら: 何もせず次の命令に進む(プログラムカウンタを素直に進める)
  1 だったら: 引数で指定されたアドレスにジャンプする

比較とジャンプの 2ステップに別れるんですね。ふむふむー。

で、この 2つのステップを仲介しているのがゼロフラグ。 ゼロフラグもレジスタの一種で、これも今回追加します。


まずゼロフラグ。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -12,6 +12,9 @@ class Vm
     @reg_b = 0
     @reg_c = 0
 
+    # flag
+    @zf = 0
+
     @mem = []
   end

comparejump_eq を追加。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -48,9 +48,15 @@ class Vm
       when "add_ac"
         add_ac()
         @pc += 1
+      when "compare"
+        compare()
+        @pc += 1
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr
+      when "jump_eq"
+        addr = @mem[@pc + 1]
+        jump_eq(addr)
       else
         raise "Unknown operator (#{op})"
       end
@@ -84,6 +90,18 @@ class Vm
   def add_ac
     @reg_a = @reg_a + @reg_c
   end
+
+  def compare
+    @zf = (@reg_a == @reg_b) ? 1 : 0
+  end
+
+  def jump_eq(addr)
+    if @zf == 1
+      @pc = addr
+    else
+      @pc += 2
+    end
+  end
 end
 
 exe_file = ARGV[0]

ステップ数が増えて、今のままだとダンプ出力が長くなってしまいます。 ここに長々としたのを貼るのもなんなので、 dump というメソッドを作ってダンプ出力を見やすくしました。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -23,6 +23,8 @@ class Vm
   end
 
   def start
+    dump() # 初期状態
+
     loop do
       # operator
       op = @mem[@pc]
@@ -62,11 +64,19 @@ class Vm
       end
 
       # 1命令実行するごとにダンプしてちょっと待つ
-      pp self
+      dump()
       sleep 1
     end
   end
 
+  def dump
+    puts "pc(%2d) | reg_a(%d) b(%d) c(%d) | zf(%d)" % [
+      @pc,
+      @reg_a, @reg_b, @reg_c,
+      @zf
+    ]
+  end
+
   def set_mem(addr, n)
     @mem[addr] = n
   end
@@ -108,6 +118,5 @@ exe_file = ARGV[0]
 
 vm = Vm.new
 vm.load_program(exe_file)
-pp vm # 初期状態
 
 vm.start

結果です! いい感じですね!

$ ruby vgvm.rb 05_compare_jump_eq.vge.yaml 
pc( 0) | reg_a(0) b(0) c(0) | zf(0)
pc( 2) | reg_a(0) b(0) c(1) | zf(0)
pc( 4) | reg_a(0) b(3) c(1) | zf(0)
pc( 6) | reg_a(0) b(3) c(1) | zf(0)
pc( 7) | reg_a(1) b(3) c(1) | zf(0)
pc( 8) | reg_a(1) b(3) c(1) | zf(0)
pc(10) | reg_a(1) b(3) c(1) | zf(0)
pc( 6) | reg_a(1) b(3) c(1) | zf(0)
pc( 7) | reg_a(2) b(3) c(1) | zf(0)
pc( 8) | reg_a(2) b(3) c(1) | zf(0)
pc(10) | reg_a(2) b(3) c(1) | zf(0)
pc( 6) | reg_a(2) b(3) c(1) | zf(0)
pc( 7) | reg_a(3) b(3) c(1) | zf(0)
pc( 8) | reg_a(3) b(3) c(1) | zf(1) … ゼロフラグが立った!
pc( 4) | reg_a(3) b(3) c(1) | zf(1) … 4番地にジャンプしている!
pc( 6) | reg_a(0) b(3) c(1) | zf(1) … reg_a が 0 にリセットされた!
pc( 7) | reg_a(1) b(3) c(1) | zf(1) … ↓ 以下くりかえしている!
pc( 8) | reg_a(1) b(3) c(1) | zf(0)
pc(10) | reg_a(1) b(3) c(1) | zf(0)
pc( 6) | reg_a(1) b(3) c(1) | zf(0)
pc( 7) | reg_a(2) b(3) c(1) | zf(0)
pc( 8) | reg_a(2) b(3) c(1) | zf(0)
pc(10) | reg_a(2) b(3) c(1) | zf(0)
pc( 6) | reg_a(2) b(3) c(1) | zf(0)
pc( 7) | reg_a(3) b(3) c(1) | zf(0)
pc( 8) | reg_a(3) b(3) c(1) | zf(1)
pc( 4) | reg_a(3) b(3) c(1) | zf(1)
pc( 6) | reg_a(0) b(3) c(1) | zf(1)
pc( 7) | reg_a(1) b(3) c(1) | zf(1)
(略)

同じ項目が縦に並んで、変化が見やすくなりました。



vm2gol v2 製作メモ(4) カウンタ / プログラムをファイルに



コンピュータのようなものを作っているので、 何か compute させようということで、足し算をさせて、 regチカのときのループと組み合わせてカウンタを作ってみます。 reg_a の値を 1, 2, 3, ... と増やしていくというものです。

add_ab() というメソッドがすでにあり、 足し算した結果を reg_c に入れるようにしてましたが、 ちょっと書き換えて、結果を reg_a に入れることにします。

(こんな感じで、まず適当に作って、 後で調べたり考えなおしたりして適当に修正していきます。)

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -62,7 +62,7 @@ class Vm
   end
 
   def add_ab
-    @reg_c = @reg_a + @reg_b
+    @reg_a = @reg_a + @reg_b
   end
 end

VM のメインループから呼び出す部分がなかったので書いておきます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -32,6 +32,9 @@ class Vm
         n = @mem[@pc + 1]
         @reg_a = n
         @pc += 2
+      when "add_ab"
+        add_ab()
+        @pc += 1
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr

ループは前回の regチカと同じですね。 jump を使うだけです。

BASIC 風に書くとこんな感じ。

10 set_reg_a 1
20 set_reg_b 1
30 add_ab
40 jump 30

このようにプログラムを書き換えましょう。 簡単なので BASIC 風に書くまでもなかったかも。

    @mem = [
      # 0
      "set_reg_a", 1,
      # 2
      "set_reg_b", 1,
      # 4
      "add_ab",
      # 5
      "jump", 4
    ]

動かしてみると set_reg_b なんて命令はないぞ、 と怒られるので適当に set_reg_b 命令を追加します。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -35,6 +35,10 @@ class Vm
         n = @mem[@pc + 1]
         @reg_a = n
         @pc += 2
+      when "set_reg_b"
+        n = @mem[@pc + 1]
+        @reg_b = n
+        @pc += 2
       when "jump"
         addr = @mem[@pc + 1]
         @pc = addr

あらためて実行すると……

$ ruby vgvm.rb 
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=1,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=2,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=2,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=3,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=3,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=4,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=4,
 @reg_a=4,
 @reg_b=1,
 @reg_c=0>
#<Vm:0x0056153e64c090
 @mem=["set_reg_a", 1, "set_reg_b", 1, "add_ab", "jump", 4],
 @pc=5,
 @reg_a=5,
 @reg_b=1,
 @reg_c=0>
(略)

いいですね! プログラムカウンタが 4 と 5 を行ったり来たりして、reg_a の値が1ずつ増えています。

(ずっと動き続けるので、飽きたら Ctrl-C で止めましょう)

プログラムを外部に切り出す

さて、ここまではプログラムをこのようにメモリに直書きしていましたが、

    @mem = [
      # 0
      "set_reg_a", 1,
      # 2
      "set_reg_b", 1,
      # ...
    ]

そうするとプログラムの切り替えが面倒なので ファイルに切り出すことにします。

最初は Ruby のソースとしてそのままファイルに出して eval を使ってこうしていたのですが、

# プログラムファイル
[
  # 0
  "set_reg_a", 1,
  # 2
  "set_reg_b", 1,
  # ...
]

# プログラムファイルの読み込み
@mem = eval(File.read("program.rb"))

いろいろあって YAML にしたのでここではいきなりそのようにします (eval 方式は、簡単だし pppシリアライズできるのでこれはこれで悪くはなかったのですが)。

プログラムのファイルの内容はこう。 vm2gol 用の実行可能(executable)ファイルということで、拡張子を .vge.yaml としました。

# 04_counter.vge.yaml
[
  # 0
  "set_reg_a", 1,
  # 2
  "set_reg_b", 1,
  # 4
  "add_ab",
  # 5
  "jump", 4
]

ファイルからロードするように VM を修正します。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -1,5 +1,6 @@
 # coding: utf-8
 require 'pp'
+require 'yaml'
 
 class Vm
   def initialize
@@ -11,16 +12,11 @@ class Vm
     @reg_b = 0
     @reg_c = 0
 
-    @mem = [
-      # 0
-      "set_reg_a", 1,
-      # 2
-      "set_reg_b", 1,
-      # 4
-      "add_ab",
-      # 5
-      "jump", 4
-    ]
+    @mem = []
+  end
+
+  def load_program(path)
+    @mem = YAML.load_file(path)
   end
 
   def start
@@ -76,7 +72,10 @@ class Vm
   end
 end
 
+exe_file = ARGV[0]
+
 vm = Vm.new
+vm.load_program(exe_file)
 pp vm # 初期状態
 
 vm.start

実行するときは、プログラムファイルを引数で渡してこうします。

ruby vm.rb 04_counter.vge.yaml

引数でのファイルの指定を変えるだけで実行するプログラムが切り替えられるようになりました。 進歩!



vm2gol v2 製作メモ(3) regチカ(jump)



プログラムといえばループ! ですね(※1)。 ループをやりましょうということで、 jump 命令を追加して regチカをやりましょう。

(※1 ……と書いてはいますが、実際は「次に簡単そうなのは何かな?」と考えて 何をやるのかを決めていました。 スモールステップでやっていきます。 )

regチカ(れぐちか)というのは今勝手に作った言葉で、 電子工作の Lチカのように、 レジスタの値を 0 にしたり 1 にしたりして チカチカするのを愛でるというものです。

BASIC風に書くとこんな感じでしょうか。

10 reg_a に 1 をセット
20 reg_a に 0 をセット
30 GOTO 10

これならすぐできそう。VM の case 式に jump を追加して、

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -30,6 +30,9 @@ class Vm
         n = @mem[@pc + 1]
         @reg_a = n
         @pc += 2
+      when "jump"
+        addr = @mem[@pc + 1]
+        @pc = addr
       else
         raise "Unknown operator (#{op})"
       end

プログラム(@mem の中身)を書き換えます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -12,8 +12,10 @@ class Vm
     @reg_c = 0
 
     @mem = [
+      # ★1
       "set_reg_a", 1,
       "set_reg_a", 0,
+      "jump", 0, # ★1 に戻る
       "exit"
     ]
   end

一番最初の set_reg_a のアドレスが 0 なので、 0 にジャンプします。

これを動かすと……

$ ruby vgvm.rb 
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=4,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=4,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x005609a6b609d0
 @mem=["set_reg_a", 1, "set_reg_a", 0, "jump", 0, "exit"],
 @pc=2,
 @reg_a=1,
 @reg_b=0,
 @reg_c=0>
(続く)

@reg_a=1 になったり @reg_a=0 になったりしてますね! jump 命令で1ステップ使うので3拍子になっていますが。

ものすごく原始的なプログラムでものすごく原始的なループが動きました。 すばらしい……(しばしターミナルを眺めて愛でる)。



vm2gol v2 製作メモ(2) プログラムカウンタとVMの骨組み



第一回はさすがにあっさりすぎたのでどんどんやっていきます。

今の状態では vm = Vm.new した後に vm に対して外部から指示してあれこれやらせていて、 あんまり VM というかコンピュータっぽくない(最初に起動した後は勝手に動いてほしい)ので、それっぽくしていきます。

まずは命令をメモリに置いて、それを順次処理していくようにします。 そのためにプログラムカウンタも導入します。


まずはプログラムをメモリに直に書くようにして

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -8,7 +8,11 @@ class Vm
     @reg_b = 0
     @reg_c = 0
 
-    @mem = Array.new(8, 0) # サイズ8。0で初期化。
+    @mem = [
+      "set_reg_a", 1,
+      "set_reg_a", 0
+    ]
+  end
   end
 
   def set_mem(addr, n)
@@ -35,14 +39,3 @@ end
 vm = Vm.new
 pp vm # 初期状態
 
-vm.set_mem(0, 1)
-vm.set_mem(1, 2)
-pp vm
-
-vm.copy_mem_to_reg_a(0)
-vm.copy_mem_to_reg_b(1)
-pp vm
-
-vm.add_ab
-vm.copy_reg_c_to_mem(2)
-pp vm

@pcVm#start を追加:

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -3,6 +3,9 @@ require 'pp'
 
 class Vm
   def initialize
+    # program counter
+    @pc = 0
+
     # register
     @reg_a = 0
     @reg_b = 0
@@ -13,6 +16,24 @@ class Vm
       "set_reg_a", 0
     ]
   end
+
+  def start
+    loop do
+      # operator
+      op = @mem[@pc]
+      case op
+      when "set_reg_a"
+        n = @mem[@pc + 1]
+        @reg_a = n
+        @pc += 2
+      else
+        raise "Unknown operator (#{op})"
+      end
+
+      # 1命令実行するごとにダンプしてちょっと待つ
+      pp self
+      sleep 1
+    end
   end
 
   def set_mem(addr, n)
@@ -39,3 +60,4 @@ end
 vm = Vm.new
 pp vm # 初期状態
 
+vm.start

メモリ上では「"set_reg_a"」と「1」の2つなので、 set_reg_a した後はプログラムカウンタを2つ進めます。

これを動かすと……

$ ruby vgvm.rb 
#<Vm:0x0056111dbfd248
 @mem=["set_reg_a", 1, "set_reg_a", 0],
 @pc=0,
 @reg_a=0,
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056111dbfd248
 @mem=["set_reg_a", 1, "set_reg_a", 0],
 @pc=2,
 @reg_a=1, … ★1
 @reg_b=0,
 @reg_c=0>
#<Vm:0x0056111dbfd248
 @mem=["set_reg_a", 1, "set_reg_a", 0],
 @pc=4,
 @reg_a=0, … ★2
 @reg_b=0,
 @reg_c=0>
vgvm.rb:30:in `block in start': Unknown operator () (RuntimeError)
        from vgvm.rb:21:in `loop'
        from vgvm.rb:21:in `start'
        from vgvm.rb:67:in `<main>'

reg_a が ★1 で 1 に, ★2 で 0 になっているのが確認できますね!  プログラムカウンタも2つずつ進んでいます。


これで、

  • プログラムカウンタが指している場所から命令と引数を取ってくる
  • 命令を実行する
  • プログラムカウンタを進める
  • (くりかえし)

という VM の骨組みができました。 実装としては case による条件分岐をループで回す、という形になっていて、後はこれに肉付けしていくことになります。


最後に例外が発生して終了するのはプログラム通りの動きではありますが、 exit という命令を追加して、例外を発生させずに静かに終了するようにしておきます。

--- a/vgvm.rb
+++ b/vgvm.rb
@@ -13,7 +13,8 @@ class Vm
 
     @mem = [
       "set_reg_a", 1,
-      "set_reg_a", 0
+      "set_reg_a", 0,
+      "exit"
     ]
   end
 
@@ -22,6 +23,9 @@ class Vm
       # operator
       op = @mem[@pc]
       case op
+      when "exit"
+        $stderr.puts "exit"
+        exit
       when "set_reg_a"
         n = @mem[@pc + 1]
         set_reg_a(n)


vm2gol v2 製作メモ(1) 最初のVM



とにかく始める

とにかくやるのだぁ

(『30日でできる! OS自作入門』 p14)


とりあえずアレですよね? レジスタとメモリがあるんですよね? などといって適当に書き始めます。 慣れていてサッと始められるので Ruby です。 サッと始められるの大事です。

# coding: utf-8
require "pp"

class Vm
  def initialize
    # register
    @reg_a = 0
    @reg_b = 0
    @reg_c = 0

    @mem = Array.new(8, 0) # サイズ8、0で初期化
  end

  def set_mem(addr, n)
    @mem[addr] = n
  end

  def copy_mem_to_reg_a(addr)
    @reg_a = @mem[addr]
  end

  def copy_mem_to_reg_b(addr)
    @reg_b = @mem[addr]
  end

  def copy_reg_c_to_mem(addr)
    @mem[addr] = @reg_c
  end

  def add_ab
    @reg_c = @reg_a + @reg_b
  end
end

vm = Vm.new
pp vm # 初期状態

vm.set_mem(0, 1)
vm.set_mem(1, 2)
pp vm # ★1

vm.copy_mem_to_reg_a(0)
vm.copy_mem_to_reg_b(1)
pp vm # ★2

vm.add_ab
vm.copy_reg_c_to_mem(2)
pp vm # ★3

★1 なにはともあれメモリに値をセットする。 0番地に 1 が、1番地に 2 が入っていればOK。

★2 メモリの値をレジスタにコピーしてみる。 reg_a が 1 に、reg_b が 2 になっていればOK。

★3 足し算して結果を reg_c に入れて、それをメモリにコピーする、というのをやってみる。 reg_c とメモリの 2番地に 3 が入っていればOK。

$ ruby vgvm.rb
#<Vm:0x005650ed9eaa40
     @mem=[0, 0, 0, 0, 0, 0, 0, 0],
     @reg_a=0,
     @reg_b=0,
     @reg_c=0> ... 初期状態
#<Vm:0x005650ed9eaa40
     @mem=[1, 2, 0, 0, 0, 0, 0, 0],
     @reg_a=0,
     @reg_b=0,
     @reg_c=0> ... ★1
#<Vm:0x005650ed9eaa40
     @mem=[1, 2, 0, 0, 0, 0, 0, 0],
     @reg_a=1,
     @reg_b=2,
     @reg_c=0> ... ★2
#<Vm:0x005650ed9eaa40
     @mem=[1, 2, 3, 0, 0, 0, 0, 0],
     @reg_a=1,
     @reg_b=2,
     @reg_c=3> ... ★3

はい。

ちょっとあっさりしすぎですがこんな感じで始めました。 最初なのでこんなもんで。

余談

足し算のところは、下記のようにして桁あふれのエミュレーションもやってましたが 最終的に使わなくなったので消しました。

sum = @reg_a + @reg_b
if sum >= 16
  @reg_c = sum - 16
  @of = 1 # オーバーフローフラグ
else
  @reg_c = sum
end

雑に作る

(2024-01-13 追記)

雑に作る』という本に大変すばらしいことが書かれていたので引用します。

「雑の極意」

一、気軽に作り始めること
一、完成度は低くてもまずは完成させること
一、見た目にこだわらないこと
一、1つの傑作より10の駄作を作ること
一、広く深く学ぶより、いま必要なことを学ぶこと
一、1つの技術で10作品作ること
一、「雑」をよいことととらえること

人はついつい、作品を作る前に「傑作を作るぞ!」と意気込んでしまいがちだ。そして実際に作ってみると想像していたようにいかず、途中で飽きて投げ出してしまう。工作あるあるのなかでもかなり上位にランクインするあるあるだろう。

そこで「雑に作る」の精神だ。長い期間かけて立派な作品を作るのではなく、3日で雑な小品を1つ作る。気軽に始めて、飽きる前に終わらせるのだ。そうすればあなたの作品集に1つ作品が増える。そしてそんな雑な作品をどんどん作り続けるうちに、いつのまにかあなたはいろんな電子部品の使い方を覚え、複雑な機構も組めるようになり、大作を作り上げるだけの実力を手に入れていることだろう!

まさにこれです。この精神でやっていきます。