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