はじめに
先日、「Z80のエミュレータ作ったよ」という記事を Qiita で公開してみました。
するとコメントで次のようなご指摘が...
Z80 Instruction Exerciserについては、恥ずかしながら知らなかったので早速確認してみたところ、どうやら Z80 の各種命令をテストするCP/M用のプログラムのようです。ただし、CP/Mでも限られた機能(標準出力)しか使っていないとのことで、Z80系のエミュレータ開発界隈では結構有名なツールらしいです。
そこで、Z80 Instruction Exerciserを使ってテストしているZ80エミュレータを色々見てみた所、以下のGO版Z80エミュレータがtinycpm.goというCP/Mのミニマム実装を含めている形で公開されていました。
という訳で、tinycpm.goの実装を参考にして、私のZ80エミュで動くミニマムCP/Mエミュレータを作ってテストをしてみたのですが、当初の結果は惨憺たるものでした...
参考までに、当初のテスト結果は次のような感じです。
% ./cpm zexall.cim
Z80all instruction exerciser
<adc,sbc> hl,<bc,de,hl,sp>.... OK
add hl,<bc,de,hl,sp>.......... OK
add ix,<bc,de,ix,sp>.......... OK
add iy,<bc,de,iy,sp>.......... OK
aluop a,nn.................... ERROR **** crc expected:51c19c2e found:7a69c5c1
aluop a,<b,c,d,e,h,l,(hl),a>.. ERROR **** crc expected:06c7aa8e found:81de5c5c
aluop a,<ixh,ixl,iyh,iyl>..... ERROR **** crc expected:a886cc44 found:d73060ff
aluop a,(<ix,iy>+1)........... ERROR **** crc expected:d3f2d74a found:40ad8060
bit n,(<ix,iy>+1)............. ERROR **** crc expected:83534ee1 found:2b526613
bit n,<b,c,d,e,h,l,(hl),a>.... OK
cpd<r>........................ ERROR **** crc expected:134b622d found:5b23bf78
cpi<r>........................ ERROR **** crc expected:2da42d19 found:d9b9f4b2
<daa,cpl,scf,ccf>............. ERROR **** crc expected:6d2dd213 found:102df6a4
<inc,dec> a................... OK
<inc,dec> b................... OK
<inc,dec> bc.................. OK
<inc,dec> c................... OK
<inc,dec> d................... OK
<inc,dec> de.................. OK
<inc,dec> e................... OK
<inc,dec> h................... OK
<inc,dec> hl.................. OK
<inc,dec> ix.................. OK
<inc,dec> iy.................. OK
<inc,dec> l................... OK
<inc,dec> (hl)................ OK
<inc,dec> sp.................. OK
<inc,dec> (<ix,iy>+1)......... OK
<inc,dec> ixh................. OK
<inc,dec> ixl................. OK
<inc,dec> iyh................. OK
<inc,dec> iyl................. OK
ld <bc,de>,(nnnn)............. OK
ld hl,(nnnn).................. OK
ld sp,(nnnn).................. OK
ld <ix,iy>,(nnnn)............. OK
ld (nnnn),<bc,de>............. OK
ld (nnnn),hl.................. OK
ld (nnnn),sp.................. OK
ld (nnnn),<ix,iy>............. OK
ld <bc,de,hl,sp>,nnnn......... OK
ld <ix,iy>,nnnn............... OK
ld a,<(bc),(de)>.............. OK
ld <b,c,d,e,h,l,(hl),a>,nn.... OK
ld (<ix,iy>+1),nn............. OK
ld <b,c,d,e>,(<ix,iy>+1)...... OK
ld <h,l>,(<ix,iy>+1).......... OK
ld a,(<ix,iy>+1).............. OK
ld <ixh,ixl,iyh,iyl>,nn....... OK
ld <bcdehla>,<bcdehla>........ OK
※未実装のundocumentedな命令があったためココまでしか実行できなかった
結構自信があったのですが、中々惨憺たる状況です。
これは燃える(?)
早速このPull Requestで全部のテストを通す修正を入れてみたので、詳しくはプルリクを参照してくださいと言いたいところですが...
こんな大作プルリク普通に見たく無いですよね。
なお、6k addition の大半はZ80 Instruction Exerciserのソースコードを組み込んだ影響です。
ですが、本体プログラム(z80.hpp)にもかなりの量の修正を入れてます。
実はバグの対策コード量自体はそんなに大きくありませんが 不具合の原因を特定するためにリファクリングを繰り返した結果 結構な量の修正が入りました。
不具合の原因特定アプローチ
当初、一部のテストがパスできなかった原因がさっぱり分かりませんでした。
一番解決するのに苦戦したテストケースが aluop
のテストケースです。
aluop a,nn.................... ERROR **** crc expected:51c19c2e found:7a69c5c1
aluop a,<b,c,d,e,h,l,(hl),a>.. ERROR **** crc expected:06c7aa8e found:81de5c5c
aluop a,<ixh,ixl,iyh,iyl>..... ERROR **** crc expected:a886cc44 found:d73060ff
aluop a,(<ix,iy>+1)........... ERROR **** crc expected:d3f2d74a found:40ad8060
aluop a,nn
については、割とすぐに原因が特定できました。(原因はx/yフラグという undocumented なフラグ設定方法の誤り)
幸い aluop a,nn
は「zexdocはパスしていたけどzexallがパスしていない」という分りやすい情報がありました。zexdocとzexdocのソースコードを確認したところ、非公開フラグ(x/y)をマスクするか否かという違いしか無かったので、原因がx/yフラグの誤りにあることが明白でした。
ですが、その後 aluop a,nn
はパスできるのに他の aluop
のテストケースがパスできないという謎な状態になってしまいました。この不具合については下記の issue で悶絶している内容の記録を残しています。
そもそも aluop
というテストケースの対象範囲が広すぎるんですよね... aluop
とは、具体的には ADD
ADC
SUB
SBC
AND
XOR
OR
CP
という 8つの命令群(8bit演算のみ)のセットです。
-
aluop a,nn
即値演算(命令数が少ない) -
aluop a,<b,c,d,e,h,l,(hl),a>
レジスタ演算(かなり命令数が多い) -
aluop a,<ixh,ixl,iyh,iyl>
8bitインデクスレジスタ演算(まぁまぁ命令数が多い) -
aluop a,(<ix,iy>+1)
8bitメモリ演算(そこそこ命令数が多い)
即値演算のテストは通るのに、レジスタ演算だと通らない...どゆこと?
という感じで混乱しました。
なお aluop
のテストが通らなかった原因は ADC
SBC
のキャリーフラグ計算ミスです。
本来 aluop a,nn
で失敗してもおかしくないバグですが、コンディション的に結構レアケースでしか顕在化したため、試行命令数が少ないそのテストケースでは偶然発生しなかったものと考えられます。
原因を特定するまでのアプローチには色々なやり方があると思いますが、個人的にオススメなのが ひたすらリファクタ です。
ひたすらリファクタ
実際、今回のPull Requestのcommitログを見てみると「refactor」という単語が結構目立ちます。
これなら、結果的にコードもキレイになるしバグも無くなるし一挙両得ですね...といいたい所ですが、仕事上のプログラムではデグレードが怖くて中々できないですねw
デグレードの防ぎ方
この自作Z80エミュは趣味プログラミングだから大丈夫...という風に考えている訳ではなく、zexdoc/zexallを採用する以前から動作上の変化をチェックアウトできる仕組みがあったので テストケースに漏れが無い限り デグることはないから、安心してリファクタができます。
動作上の変化をチェックアウトできる仕組みとは、ザックリ言うとエミュレータを動かした時のログ情報(日付を含まない)をテキスト形式で出力してリポジトリの管理対象としておき、動作に差異が生じたらPull Requestのdiffで差分を確認できるという感じのものです。(想定外のdiffが出たらデグったかも?ということで修正を見直す運用です)
今回、zexallとzexdocに対応したことで、そうそうテストケース漏れも起こらなくなったから、これからもどんどんとリファクタができます。やったね。
ただし、zexall と zexdoc はかなりCPU負荷が掛かり、私の貧弱な Mac Book Air (x86) だとテスト完了までに3分ぐらい掛かるのがネックですが...(安かったり無料のCIツールのVMだと性能が悪いからもっと時間が掛かるんだろうなぁ)
M2 の MacBook Pro とかならもっと早いのかなぁ...追記: 最新版で CircleCI に対応(※以前はTravis CIでしたが何か対応しないと動かない状態になっていたので Circle CI へ移行)して、commitをpushした都度、自動テストで zexall でエラーチェックできようにしてあります。
https://app.circleci.com/pipelines/github/suzukiplan/z80
実行時間を見るとだいたい2〜3分といったところのようです。(その2〜3分、シングルCPU 100%でぶん回すのでこんなCIをタダで使ってしまって良いのか少し不安になりますw)
なお、「デグレード防止のために Unit Test」というのが定石かもしれませんが、Unit Testはメンテナンス性が悪いので個人的にはあまり好きではありません。
今回のPull Requestのcommitログを見ていただくと分かるように、私は内部仕様はゴリゴリ変えていきたい武闘派です。そんな戦闘民族にとっては、外部仕様レベルのテストで網羅性のあるテストが自動的にできることが理想的です。なので、今回追加した zexall/zexdoc のテストケースは理想的なパターンかなと。(過去のCPUエミュレータを作る特殊な趣味のケースに限らず、実務のプログラムでもこんな感じにしたいところ)
要点
- 不具合は ひたすらリファクタ しながら特定しよう
- 外部仕様テスト自動化でデグレード防止しよう