- 昔、X1turbo3というパソコンを持っていた
- 最近は、エミュレータという便利なものがある
- 確かマニュアルがあったはずだ、あった
- 文字を表示させよう
- キー入力を得よう
- 内部インタプリタを作ろう
- さすがにアセンブラを使いたい
- 内部インタプリタを動作させてみる
- 結局、自分で16進変換ルーチンを作ることになった
- テープヘッダ作成が面倒くさい
- ようやく本題に戻った。デバッグだ
- ワードを作ろう
- 10進数の表示はどうする?
- マシン語命令の置き換え
昔、X1turbo3というパソコンを持っていた
プログラミングゲームをやっていた。プログラミングもしたのだが、長続きせず。それだけが心残り。
最近は、エミュレータという便利なものがある
MZ-700 Emulator MZ700WIN For Windows
これを使えば、今までの心残りも解消されるかも。あれ、X1turbo3は?あれは色んな機能があるので使いきれない。という訳で、MZ-700でForthを作る。MZ-700のマニュアルには、回路図とモニタプログラムのソースコードがある。これさえあれば、プログラムを作れる。
確かマニュアルがあったはずだ、あった
MZ-2000のがorz。ヤフオクで落札したのだ。どうする?MZ-2000でForthを作るに変更するか?と思ったら、互換モニタROMに解説テキストがあった。これがあれば何とかなりそうだ。
文字を表示させよう
互換モニタのサブルーチン一覧を見ると、1文字表示は0012H番地。表示させる文字をどうやって指定するかは、書いてない。たぶん、Aレジスタかスタックだと思うが。仕方ない、逆アセンブルしよう。目玉逆アセンブル。
0012 C3 JP $0935 0013 35 0014 09 : 0935 FE CP $0D 0936 0D 0937 CA JP Z,$090E 0938 0E 0939 09 093A C5 PUSH BC 093B 4F LD C,A 093C 47 LD B,A 093D CD CALL $0196 093E 96 093F 01 0940 CD CALL $0946 0941 46 0942 09 0943 79 LD A,C 0944 C1 POP BC 0945 C9 RET
目玉逆アセンブルの結果、どうやらAレジスタに表示するコードを入れて呼び出すっぽい。というわけで、テストコードを書いてみた。もちろん目玉アセンブラを用いた。
3E LD A,$41 41 CD CALL $0012 12 00 C9 RET
このコードを何番地に置くか?MZ-700のメモリマップは、、たぶん、Wikipediaに書いてある、1000H番地で問題ないはず。1000H番地から置いてみる。モニタ画面でプログラムを入力し、GOTO$1000、Aが表示された。成功。文字列表示ルーチンも確認してみよう。
0015 C3 JP $0981 0016 81 0017 09 : 0981 F5 PUSH AF 0982 C5 PUSH BC 0983 D5 PUSH DE 0984 06 LD B,$05 0985 05 0986 CD CALL $0196 0987 96 0988 01 0989 1A LD A,(DE) 098A FE CP $0D 098B 0D 098C CA JP Z,$0FDF 098D DF 098E 0F 098F 4F LD C,A 0990 CD CALL $0946 0991 46 0992 09 0993 13 INC DE 0994 10 DJNZ $F3 0995 F3 0996 C3 JP $0984 0997 84 0998 09 0999 F5 PUSH AF 099A C5 PUSH BC 099B D5 PUSH DE 099C 06 LD B,$05 099D 05 099E CD CALL $0196 099F 96 09A0 01 09A1 1A LD A,(DE) 09A2 FE CP $0D 09A3 0D 09A4 CA JP Z,$0FDF 09A5 DF 09A6 0F 09A7 CD CALL $0BB9 09A8 B9 09A9 0B 09AA CD CALL $0970 09AB 70 09AC 09 09AD 13 INC DE 09AE 10 DJNZ $F1 09AF F1 09B0 C3 JP $099C 09B1 9C 09B2 09 09B3 C5 PUSH BC 09B4 D5 PUSH DE 09B5 E5 PUSH HL 09B6 CD CALL $0FB1 09B7 B1 09B8 0F 09B9 CD CAll $0DA6 09BA A6 09BB 0D 09BC 7E LD A,(HL) 09BD 32 LD ($118E),A 09BE 8E 09BF 11 09C0 22 LD ($118F),HL 09C1 8F 09C2 11 09C3 21 LD HL,$1192 09C4 92 09C5 11 09C6 CD CALL $01B8 09C7 B8 09C8 01 09C9 32 LD ($E000),A 09CA 00 09CB E0 09CC 32 LD ($1191),A 09CD 91 09CE 11 09CF 2F CPL 09D0 32 LD ($E000),A 09D1 00 09D2 E0 09D3 16 LD D,$14 09D4 14 09D5 CD CALL $09FF 09D6 FF 09D7 09 09D8 CD CALL $0A50 09D9 50 09DA 0A 09DB 78 LD A,B 09DC 07 RLC A 09DD DA JP C,$0BE6 09DE E6 09DF 0B 09E0 15 DEC D 09E1 C2 JP NZ,$09D5 09E2 D5 09E3 09 09E4 CD CALL $09FF 09E5 FF 09E6 09 09E7 CD CALL $08CA 09E8 CA 09E9 08 09EA FE CP $F0 09EB F0 09EC CA JP Z,$077A 09ED 7A 09EE 07 09EF F5 PUSH AF 09F0 CD CALL $0DA6 09F1 A6 09F2 0D 09F3 3A LD A,($118E) 09F4 8E 09F5 11 09F6 2A LD HL,($118F) 09F7 8F 09F8 11 09F9 77 LD (HL),A 09FA F1 POP AF 09FB E1 POP HL 09FC D1 POP DE 09FD C1 POP BC 09FE C9 RET
疲れた。ここまでやって、0996H番地でループしていることに気がついた。AF、BC、DEレジスタをおそらく作業用レジスタとして使うためにPUSHしているから、HLレジスタが怪しい。それから、1000H番地台は、作業エリアとして使っているっぽい。2000Hからプログラムを配置する。とりあえず、0を終端と仮定してプログラムを作った。
2000 21 LD HL,$2007 2001 07 2002 20 2003 CD CALL $0015 2004 15 2005 00 2006 C9 RET 2007 54 2008 45 2009 53 200A 54 200B 00
うまくいけば、"TEST"と表示するはず。GOTO$2000としたら、表示されたのは、2000。失敗だ。もしかしてBCレジスタに入れるのかも、と思い、
2000 01 LD BC,$2007 2001 07 2002 20
とやってみた。これもだめ。じゃあDEレジスタかも、と思い、
2000 11 LD DE,$2007 2001 07 2002 20
とやってみた。"TEST"と表示された。が、どんどんスクロールしていく。どうやら終端は0ではないらしい。試しに$0D(CR)を入れてみたら、TESTと表示し、モニタに戻った。成功。
2000 11 LD DE,$2007 2001 07 2002 20 2003 CD CALL $0015 2004 15 2005 00 2006 C9 RET 2007 54 (T) 2008 45 (E) 2009 53 (S) 200A 54 (T) 200B 0D (CR)
という訳で、文字列表示ルーチン0015Hは
- DEレジスタに文字列の先頭アドレスを入れる
- 文字列の終端は0D(キャリッジリターン)である
ことが分かった。互換モニタには、0018Hにもうひとつ文字列表示のルーチンがあるようなのだが、違いが分からず。とりあえず、1文字表示・文字列表示が出来たので良しとする。
キー入力を得よう
0003Hがキーボードからの1行入力らしい。これには、DEレジスタに入力バッファの番地を指定すると書いてある。親切だ。
早速プログラムを書いてみよう。
2010 11 20 20 LD DE,$2020 2013 CD 03 00 CALL $0003 2016 C9 RET
GOTO$2010で実行し、"ABCDE"を入力したら、モニタのプロンプト"*"が出て終了。2020Hを見てみると、"ABCDE"のキャラクタコードが表示されている。成功。それと、以降41文字目までは0DHで埋められていた。
内部インタプリタを作ろう
Forthの内部インタプリタというのは、C言語風に書くと、
goto word_pointer_entry[program[program_cnt]];
な感じなわけで。word_pointer_entry[]は、各ワードの処理番地が格納された配列、program[]は、Forthで書かれたプログラムの配列、program_cntが、現在処理している位置。びっくりするくらい簡単。
ただし、C言語だとこういうgotoは使えない。のだが、gccは実はこういうことができる機能拡張を行っている。今回はアセンブリ言語なので、そういうことは気にしないでよい。
さて、Z80のマシン語で上記を実現する。gotoは、JPでそのまま置き換えられる。以下のどれが良いだろう?
命令 | 動作 | バイト数 | ステート数 |
---|---|---|---|
JP (HL): | HLの示している番地にジャンプ | 1 | 4 |
JP (IX): | IXの示している番地にジャンプ | 2 | 8 |
JP (IY): | IYの示している番地にジャンプ | 2 | 8 |
JP nn: | 直接ジャンプ命令(ただしnnの部分を、実行時に直接書き換える) | 3 | 10 |
どう考えても、JP (HL)が良いと思う。バイト数も少ないし、ステート数も少ない。
次に、HLに与える番地の計算である。program_cnt、program、word_entry_pointerはある番地を表すとする。最初に、program[program_cnt]を計算する。まず、program_cntをレジスタにロードする。いい忘れたが、プログラムカウンタは2バイトである。
LD HL,PROG_CNT
そして、programは、program[]の先頭番地なので、2つを足し合わせ、その番地の内容を得れば目的を達成できる。
LD DE,PROG ADD HL,DE LD D, (HL)
こうしてDレジスタに入っている値が、program[program_cnt]である。この値をインデックスとして、各ワードへのポインタ配列にアクセスする。D = program[program_cnt]なので、word_pointer_entry[D]である。
LD HL,WORD_ENTRY ADD HL,D
あれ、8ビットと16ビットの足し算て出来ない、、Dの代わりにEレジスタを使って、Dには0を入れてみる。
;program_cntを得る LD HL,PROG_CNT ;program[HL]を得る LD DE,PROG ADD HL,DE LD E, (HL) ;word_pointer_entry[E]を得る LD HL,WORD_ENTRY LD D,0 ADD HL,DE ;ワード処理番地にジャンプする JP (HL)
これでいいだろうか?まだダメだった。ワード処理番地の配列は2バイト配列で、Eの値が1バイトである。Eの値は2の倍数でないと正しくアクセスできない。ところで、Z80のニーモニックには、"ADD DE,DE"なんて命令はない。どうするか。
- Aレジスタで8ビットの足し算(ADD)を行う
- HLレジスタにいれ、ADD HL,HLを実行する
後者を使うことにする。
このようにしてみた。
;word_pointer_entry[E]を得る LD H,0 LD L,E ADD HL,HL LD DE,WORD_ENTRY ADD HL,DE
それと、ワード処理番地へのジャンプだが、
JP (HL)
では、計算した配列の番地にジャンプしてしまっている。本当は、処理番地の配列に保存されている内容がジャンプ先である。そのため、次のように変更する。
;word_pointer_entry[E]の内容を得る LD D,(HL) INC HL LD E,(HL) LD H,D LD L,E ;ワード処理番地にジャンプする JP (HL)
ところで、LD DE,(HL)や、LD HL,DEという命令はない。昔、Z80の命令は直交性がない、と言われていたが、こうやってプログラミングしていると、なるほどと思う。まともにZ80のアセンブリ言語でプログラミングするのは、これがはじめて。
まとめてみた。
;program_cntを得る LD HL,PROG_CNT ;program[HL]を得る LD DE,PROG ADD HL,DE LD E, (HL) ;word_pointer_entry[E]を指している番地を得る LD H,0 LD L,E ADD HL,HL LD DE,WORD_ENTRY ADD HL,DE ;word_pointer_entry[E]の内容(ジャンプ先)を得る LD D,(HL) INC HL LD E,(HL) LD H,D LD L,E ;ワード処理番地にジャンプする JP (HL)
さすがにアセンブラを使いたい
上記コードをハンドアセンブルするのは、勘弁願いたい。一瞬、アセンブラも作っちゃおうか、と考えるが、やめておく。ということで、フリーソフトを漁ってみる。するとこんなのがあった。
早速アセンブルしてみる。Can't openと言っている。どうやら、長いファイル名がだめなようだ。ファイル名moiforth_mz700.aszをmfmz700.aszに変更した。
プログラムはこうだ。
; ; moiforth mz700 version ; ORG 2000h ;program_cntを得る LD HL,PROG_CNT ;program[HL]を得る LD DE,PROG ADD HL,DE LD E, (HL) ;word_pointer_entry[E]を指している番地を得る LD H,0 LD L,E ADD HL,HL LD DE,WORD_ENTRY ADD HL,DE ;word_pointer_entry[E]の内容(ジャンプ先)を得る LD D,(HL) INC HL LD E,(HL) LD H,D LD L,E ;ワード処理番地にジャンプする JP (HL) PROG_CNT: DS 2 PROG: DS 1000 WORD_ENTRY: DS 512 END
正常にアセンブルできたのだが、出力がHEX形式だ。
:10200000211620111820195E26006B2911002419B1 :0620100056235E626BE93D :00000001FF
困ったと思ったらCOM形式を作る-cオプションがあった。内部は機械語のバイナリデータそのものなので使いやすい。さらにこれをテープフォーマットに変換しなければならない。
MZ700WIN FAQに、テープのフォーマット形式が書かれていた。
それによると、ヘッダ部が128バイト、残りは実データ。
ヘッダの形式を上記サイトから引用すると、
オフセット | 内容 |
---|---|
00h | アトリビュート |
01h-11h | ファイル名。20hでパディングします。 |
12h-13h | ファイルサイズ |
14h-15h | 格納アドレス |
16h-17h | 実行アドレス |
アトリビュートがなんだか分からないが、0にでもしておこう。
内部インタプリタを動作させてみる
まだ、この内部インタプリタが動作するかわからないので、テストプログラムを作る。
; ; moiforth mz700 version ; PRNT EQU 0012H ;1文字表示 MSG EQU 0015H ;文字列表示 ORG 2000h ;プログラムカウンタをクリアする LD A,0 LD (PROG_CNT),A NEXT: ;program_cntを得る LD HL,PROG_CNT ;program[HL]を得る LD DE,PROG ADD HL,DE LD E, (HL) ;word_pointer_entry[E]を指している番地を得る LD H,0 LD L,E ADD HL,HL LD DE,WORD_ENTRY ADD HL,DE ;word_pointer_entry[E]の内容(ジャンプ先)を得る LD D,(HL) INC HL LD E,(HL) LD H,D LD L,E ;ワード処理番地にジャンプする JP (HL) PROG_CNT: DS 2 PROG: DB 1, 2, 3 ;DS 1000 WORD_ENTRY: DW TEST1, TEST2, TEST3 ;DS 512 EMBED_WORD: TEST1: ;'A'を表示する LD A,'A' CALL PRNT ;ジャンプする LD HL,(PROG_CNT) INC HL LD (PROG_CNT),HL JP NEXT TEST2: ;"BCDE"を表示する LD HL,STR CALL MSG INC HL LD (PROG_CNT),HL JP NEXT TEST3: ;終了 RET STR: DB 'B', 'C', 'D', 'E' END
これをアセンブルして、さらに、 テープのヘッダをつける。ヘッダは、バイナリエディタStirlingで作成し、ヘッダファイルとアセンブルしたバイナリファイルを
copy /b head+mfmz700.com mfmz700.mzt
で連結した。さて、実行してみた。大量にわけの分からない文字がでてリセットがかかった。失敗だ。さて、プリントデバッグをやってみよう。数値を表示するモニタルーチンを探さないと。
しかし、それがどれだかわからない。しかたない、地道に探す。逆アセンブラを探したら、hojaというフリーソフトがあった。
http://www.vector.co.jp/soft/win95/prog/se054738.html
これを使って、互換モニタを逆アセンブルする。
hoja.exe -s0 -o0 -u newmon7.rom > newmon7.asm
これでアセンブリ言語のソースになった。MZ-700のエミュレータのドキュメントmz700win_jp.txtには、「単体動作時にエミュレーションしている1Z-009Aのエントリ一覧」があり、その中に"HEX"というルーチンがある。早速HEXルーチンの番地、041F番地を見てみる。
Z0083: PUSH BC ;41F LD A,(DE) ;420 INC DE ;421 JP 06F1H ;422 Z0182: JR C,0434H ;425 RLCA ;427 RLCA ;428 RLCA ;429 RLCA ;42A LD C,A ;42B LD A,(DE) ;42C INC DE ;42D CALL 03F9H ;42E JR C,0434H ;431 OR C ;433 Z0113: POP BC ;434 RET ;435 Z0112: CP 02FH ;6F1 JR Z,06FBH ;6F3 CALL 03F9H ;6F5 JP 0425H ;6F8 Z0181: LD A,(DE) ;6FB INC DE ;6FC JP 0434H ;6FD Z0114: PUSH BC ;3F9 PUSH HL ;3FA LD BC,01000H ;3FB LD HL,03E9H ;3FE Z0110: CP (HL) ;401 JR NZ,0407H ;402 LD A,C ;404 JR 040DH ;405 Z0108: INC HL ;407 INC C ;408 DEC B ;409 JR NZ,0401H ;40A SCF ;40C Z0109: POP HL ;40D POP BC ;40E RET ;40F
ここまでだろうか。、、、追ってみたが、皆目見当がつかない。もし、自分がHEX変換ルーチンをつくるなら、
- Aレジスタに値を入れて呼び出す
- 下位4ビットの計算のために、スタックにAFをPUSHする
- 4ビット右シフトし、0FHでANDして、上位4ビットのみ残す
- 用意しておいた16進テーブル'0','1',,'F'の先頭番地に先ほどの値を足す
- そのアドレスの内容がHEX変換文字
- 下位アドレスも同様に変換
そういう風に考えているから、どうにも頭に入ってこない。だれかモニタルーチンの使い方教えて、、と言っても仕方ないので、自分で数値→文字列変換を作ることに方針変換した。
結局、自分で16進変換ルーチンを作ることになった
こう作った。
HEX: ;Aレジスタの内容を16進表記に変換し、 ;DEレジスタで示された番地に格納する PUSH HL PUSH DE PUSH AF ;上位4ビット AND 0F0H RRCA RRCA RRCA RRCA LD HL,HEXTBL ADD A,L LD A, (HL) LD (DE),A ;下位4ビット POP AF AND 0FH LD HL,HEXTBL ADD A,L LD A, (HL) INC DE LD (DE),A ;終端をつける INC DE LD A,0DH LD (DE),A POP DE POP HL RET HEXTBL: DB '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
これが間違っていたら悲しい。早速これを使って、デバッグしてみる。
テープヘッダ作成が面倒くさい
デバッグするといっても、アセンブルしただけではMZ-700エミュレータにロードできない。テープ形式に変換しなければいけない。ヘッダをつけるだけなのだが、バイナリエディタでちまちまやっていたら、効率が上がらない。そこで、ヘッダ追加プログラムを作る。だんだんForthから脱線し始めている、、、
プログラムはC言語で作る。一番慣れているので。やっつけで作った。これでテープ形式作成が楽になった。
cnvm7tape.exe MFMZ700.COM -s 2000 -n MFM7.MZT
で作ってくれる。テープ形式変換プログラムを作っている途中で、今までヘッダのファイル名の長さを間違えていることに気がついた。11Hは11ではなく、17だった。
チェックプログラムは、
ORG 2000h LD HL,0AB12H LD A,H LD DE,TEMP CALL HEX CALL MSG LD A,L LD DE,TEMP CALL HEX CALL MSG RET
これで画面にAB12と表示されるはず→表示は0000だった。自作の16進変換ルーチンがおかしいようだ。
とりあえず、デバッグする。紙の上に書き出してみる。わかった。ADD A,Lしたあと、LD L,Aしていないため、テーブルの番地を計算している部分が常に0になっている。修正して、AB12が表示された。めでたい。ようやく16進変換ルーチンが完成した。
HEX: ;Aレジスタの内容を16進表記に変換し、 ;DEレジスタで示された番地に格納する PUSH HL PUSH DE PUSH AF ;上位4ビット AND 0F0H RRCA RRCA RRCA RRCA LD HL,HEXTBL ADD A,L LD L,A ;追加した LD A, (HL) LD (DE),A ;下位4ビット POP AF AND 0FH LD HL,HEXTBL ADD A,L LD L,A ;追加した LD A, (HL) INC DE LD (DE),A ;終端をつける INC DE LD A,0DH LD (DE),A POP DE POP HL RET HEXTBL: DB '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'
ようやく本題に戻った。デバッグだ
そもそも16進変換ルーチンを作ったのは、内部インタプリタがわけの分からない文字を大量に出してリセットしたので、メモリやレジスタの値を確認したかったからだ。さっそく表示ルーチンを埋め込んで、確認する。その結果、最後のジャンプ先が正しくないことを確認した。
なぜ、ジャンプ先の計算がおかしいか?
;word_pointer_entry[E]の内容(ジャンプ先)を得る LD D,(HL) ;下位8ビットを上位8ビットのDに入れている INC HL LD E,(HL) ;上位8ビットを下位8ビットのEに入れている LD H,D LD L,E
Z80では、2バイト値をメモリに保存する際、リトルエンディアンで配置されている。そのため、今まで上位と下位を取り違えていた。さっそく修正。うまくいけば、ABCDと表示されるはず。実行した。画面が全部Aで埋まった。
Aの表示とBCDの表示は別のコードが担当しているから、Aの処理だけぐるぐる回っていることになる。今度は何がおかしいのだろう。
結局、色んなところがおかしかった。
- PROG_CNTの初期化が下位バイトのみだった
- ワードコードがずれていた
- BCDEの後に文字終端がなかった
- TEST2のカウンタのインクリメントの際、カウンタをロードしていなかった
最終的にこうなった。ようやく正常に'ABCDE'を表示した。
; ; moiforth mz700 version ; PRNT EQU 0012H ;1文字表示 MSG EQU 0015H ;文字列表示 LINP EQU 0003H ;1行入力 ORG 2000h LD HL,0 LD (PROG_CNT),HL NEXT: ;program_cntを得る LD HL,(PROG_CNT) ;program[HL]を得る LD DE,PROG ADD HL,DE LD E, (HL) ;word_pointer_entry[E]を指している番地を得る LD H,0 LD L,E ADD HL,HL LD DE,WORD_ENTRY ADD HL,DE ;word_pointer_entry[E]の内容(ジャンプ先)を得る LD E,(HL) INC HL LD D,(HL) LD H,D LD L,E ;ワード処理番地にジャンプする JP (HL) PROG_CNT: DS 2 PROG: DB 0, 1, 2 ;DS 1000 WORD_ENTRY: DW TEST1, TEST2, TEST3 ;DS 512 EMBED_WORD: TEST1: ;'A'を表示する LD A,'A' CALL PRNT ;ジャンプする LD HL,(PROG_CNT) INC HL LD (PROG_CNT),HL JP NEXT TEST2: ;"BCDE"を表示する LD DE,STR CALL MSG ;ジャンプする LD HL,(PROG_CNT) INC HL LD (PROG_CNT),HL JP NEXT TEST3: ;終了 RET STR: DB 'B', 'C', 'D', 'E', 0DH HEX: ;Aレジスタの内容を16進表記に変換し、 ;DEレジスタで示された番地に格納する PUSH HL PUSH DE PUSH AF ;上位4ビット AND 0F0H RRCA RRCA RRCA RRCA LD HL,HEXTBL ADD A,L LD L,A LD A, (HL) LD (DE),A ;下位4ビット POP AF AND 0FH LD HL,HEXTBL ADD A,L LD L,A LD A, (HL) INC DE LD (DE),A ;終端をつける INC DE LD A,0DH LD (DE),A POP DE POP HL RET HEXTBL: DB '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' TEMP: DS 3 DISPHL: ;HLの値を16進で画面に表示する LD A,H LD DE,TEMP CALL HEX CALL MSG LD A,L LD DE,TEMP CALL HEX CALL MSG RET KEYBUF: DS 41 END
ワードを作ろう
ところで、前節のデバッグ中、エミュレータを暴走させてから、最初の起動画面が'MZ-700'から'MZ 700'となり、カーソルが消えてしまった。どうにもならず、再インストールした。エミュレータが壊れるのは初めての経験だ。
話をもどさないといけない。内部インタプリタは作ったが、ワードを作っていない。なにから作ろうか?と思っていたが、、、そうだ、機械語命令をスタック用に作っていこう。
Z80の機械語命令は、命令の種類だけで158、レジスタの組み合わせを入れるとすごいことになるが、スタックマシン(≒Forth)なら、演算対象がスタック上のデータなので命令数は激減する。
8ビット足し算命令
足し算を行うには、
- スタックからデータを取り出し、Aレジスタに入れる
- スタックからデータを取り出し、Bレジスタに入れる
- ADD A,Bする
- 結果をスタックに入れる
作ってみる。そういえば、まだスタックも作ってなかった。さっそく作らないと。
STACK_PTR: DS 2 STACK: DS 1000 STACK_END:
完成。では足し算ワードを作る。
ADDB: LD HL,STACK_PTR ;スタックからデータを取り出し、Aレジスタに入れる LD A,(HL) ;pop INC HL ;スタックからデータを取り出し、Bレジスタに入れる LD B,(HL) ;pop INC HL ;足し算し、スタックに載せる ADD A,B DEC HL LD (HL),A ;push LD (STACK_PTR),HL ;ジャンプする LD HL,(PROG_CNT) INC HL LD (PROG_CNT),HL JP NEXT
こんな感じになった。
ところで、スタックへの保存方法は次の2通りあることに気がついた。
- ポインタが示しているところにはデータは入っておらず、そこにデータをPUSHした後、ポインタをずらす
- ポインタが示しているところにはデータが入っていて、ポインタをずらした後、PUSHする
どちらがいいか考えてみると、スタックに保存データは何バイトか分からないから、自分で必要なサイズを確保できる2が良いように思い、そのように作った。
8ビット即値ロード命令
次に、データをPUSHするワードを作る。即値というのは、数値そのまま、という意味である。英語ではimmediate。
PUSHB: ;program_cnt + 1を得る LD HL,(PROG_CNT) INC HL ;program[HL]を得る LD DE,PROG ADD HL,DE LD E, (HL) ;スタックに載せる LD HL,STACK_PTR DEC HL LD (HL),E ;push LD (STACK_PTR),HL ;ジャンプする LD HL,(PROG_CNT) INC HL INC HL LD (PROG_CNT),HL JP NEXT
とりあえず、この2つがあれば計算が出来るので、数値を表示するワードを作る。
10進数の表示はどうする?
足し算の結果は、MZ-700内では2進数で表現されている。これを10進文字列に変換して表示させたい。文字表示ルーチンはあるので、2進→10進変換が必要である。
以前2進→16進変換をやったが、これは簡単だった。これは、どちらも上位桁の数が下位桁の数を含まないため、その桁のみ考えればいいためである。
10進数はどうか。2進数の下位4ビットのみに注目すればいいかというと、違う。たとえば、2^8は256。200と50と6が入っている。2^16は65536。こんな具合なので、ビットシフトさせて終わりとはいかない。そこで、方法を考えてみる。
8ビットの数は、10進数で0から255の数を表現できる。ということは、100の桁がいくらか、10の桁がいくらか、1の桁がいくらかが分かればよい。100は2進数で1100100、10は1010、1は1である。これらの数で割っていき、それぞれの桁がいくらかを求める。
2進数の割り算は、10進数より大幅に簡単になる。桁がとる値は0か1しかないから。掛け算表は0*0=0、0*1=0、1*0=0、1*1=1の4つのみ。割り算は、除数より被除数が大きければ割れる。
ためしに、99(1100011)を10(1010)で割ってみる。
1001 --------- 1010 ) 1100011 1010 < 1100011 だから割れる。商に1 1010 -------- 100 1010 > 100 だから割れない。商に0 0 -------- 1001 1010 > 1001 だから割れない。商に0 0 -------- 10011 1010 < 10011 だから割れる。商に1。商は1001 = 9 1010 -------- 1001 余り9
これで、10の桁は9、1の桁も9と分かる。ここまでくれば、それぞれの桁の値にASCIIコードの0x30('0')を足せば表示用の文字データ"99"を得られる。
8ビットの2進値から10進文字列への変換ルーチンは、以下のようになる。
- 100で割り、100の桁の値と余りを得る
- 余りを10で割り、10の桁の値と余り(1の桁の値)を得る
- 3つの値それぞれに0x30を足し、指定番地に連続して保管する
作ってみよう。
まず割り算ルーチン。先ほどは大きければ割れると書いたが、実際には桁を合わせないといけない。人間は無意識にどこの桁から割れるかわかるが、CPUは動作をすべて指定しなければならない。そこでこうした。
- レジスタに被除数、除数を入れ、計算用レジスタ、商レジスタをクリアする
- 被除数を1ビット左シフトし、最上位ビットをキャリーフラグに入れる
- キャリーフラグが立っていたら、計算用レジスタの最下位ビットに1をセットする
- 計算用レジスタの値が除数より大きければ、除数で減算し、商レジスタの最下位ビットに1をセットする
- 商レジスタを1ビット左シフトする
- 計8回繰り返すまで、2.に戻る
ここまでで、除数で割った答えが商レジスタに、余りが計算用レジスタに得られる。余りは、下位の桁の値を求めるために引き続き利用できる。
では作ってみよう。まず、8ビットの割り算ルーチン。
DIV8: ;8ビット値の割り算を行う ;B: 被除数(計算後は余りが入る)、C: 除数 ;D: 商、A: 計算用、E: カウンタ PUSH AF PUSH DE LD A,0 LD D,0 LD E,0 DIV8_1: ;被除数をシフトする SLA B ;キャリーフラグが立っていたら、計算用レジスタの最下位ビットを立てる JP NC,DIV8_2 SLA A OR 01H DIV8_2: ;計算用レジスタ >= 除数 なら、除数で減算し、商の最下位ビットを立てる CP C JP NC,DIV8_3 JP NZ,DIV8_3 SUB C OR 01H DIV8_3: ;商を左シフトする SLA C ;8回繰り返したら、終了する INC E: PUSH AF LD A,E CP 8 POP AF JP NZ,DIV8_1 POP DE POP AF RET
DIV8を用いて、10進文字列変換関数を作る。
CONVDECI: ;数値を10進文字列に変換する ;A: 表示する値 ;HL: 文字列格納アドレス ;100の桁を得る LD C,100 CALL DIV8 LD A,C OR 30H LD (HL),A INC HL ;10の桁を得る LD C,10 CALL DIV8 LD A,C OR 30H LD (HL),A INC HL ;1の桁を得る LD A,B OR 30H LD (HL),A INC HL ;終端を付ける LD (HL),0DH RET
これを用いて数値を表示するワードを作る。
PRINT: LD HL,STACK_PTR ;スタックからデータを取り出し、Aレジスタに入れる LD A,(HL) ;pop INC HL LD (STACK_PTR),HL ;表示する LD HL,PRINTBUF CALL CONVDECI CALL MSG ;ジャンプする LD HL,(PROG_CNT) INC HL INC HL LD (PROG_CNT),HL JP NEXT
これで、データのPUSH、足し算、表示の各ワードができたので、ワードでプログラムを作ってみる。行う処理は、"2 3 + ."である。ただし、まだ構文解析できないので、ソースコードに直接内部コードを記述する。
PROG: DB 1, 2, 1, 3, 0, 2, 3 WORD_ENTRY: DW ADDB, PUSHB, PRINT, WORDRET, TEST1, TEST2, TEST3
1がPUSHB、続く2はPUSHする値、
、、、さて、
1年以上放置していた。久しぶりに現状を確認しようと、アセンブルし、エミュレータにかけると、暴走した。そうか、デバッグ中だったか。
ぼちぼち再開します。
ラベルPROGに"2 3 + ."を表す中間コードを書いてある。それの意味するところは、ラベルWORD_ENTRYのインデックスである。
1 .. PUSHB。続く数値(1バイト)をスタックに積む 2 .. 数値 1 .. PUSHB。続く数値をスタックに積む 3 .. 数値 0 .. ADDB。スタック上の2つの1バイト数値を加算し、スタックに積む 2 .. PRINT。スタック上の数値を取り出し、表示する 3 .. WORDRET。戻る
これのどこかで暴走している。どうやってデバッグしよう?HALT(CPU停止)命令で止めてみよう。
PUSHBルーチンがNEXTルーチンにジャンプする前にHALTを入れて、実行してみた。止まった。PUSHBが暴走している訳ではないらしい。
ADDBにHALTを入れてみた。止まった。
PRINTにHALTを入れてみた。いくらかゴミを表示して止まった。表示にバグが有るようだ。でも止まった。
WORDRETにHALTを入れてみた。£記号を画面上に表示し続けている。これが原因だ。しかし、WORDRETの実体はRET命令1つなので、実際はNEXTルーチンがおかしいのだ。
まず、PRINTのバグを取り除こう。
いま気がついたが、STACK_PTRの内容をSTACKのアドレスで初期化していない。バグとはこんなモノだ。早速初期化する。
;スタックポインタを初期化する LD HL,STACK_END LD (STACK_PTR), HL
表示が000になった。本当は005になるはずなのだが。また、モニタに制御が戻らず暴走している。
まず、モニタに制御がもどるようにしよう。ワードWORDRETだけをプログラムに記述し、アセンブル、実行してみる。
PROG: DB 3 ;DB 1, 2, 1, 3, 0, 2, 3
すると、モニタにちゃんと戻ってきた。
MZ-700 *G2000 *
ということは、この暴走はワードPRINTで引き起こされていることになる。そこで、どこにRET命令を入れたらモニタに制御がもどるか、試してみる。
PRINT: LD HL,STACK_PTR ;スタックからデータを取り出し、Aレジスタに入れる LD A,(HL) ;pop INC HL LD (STACK_PTR),HL <-- 3 ;表示する LD HL,PRINTBUF CALL CONVDECI LD DE, PRINTBUF CALL MSG <-- 2 ;ジャンプする LD HL,(PROG_CNT) INC HL INC HL LD (PROG_CNT),HL JP NEXT <-- 1
1は手前にJP命令があるので、戻らなかった。2にRET命令を入れてみると、モニタに戻った。ということは、NEXTルーチンに問題があるらしい。
と思っていたが、実際には"JP NEXT"の手前の"INC HL"が2つあるためだった。PUSHBは続くデータの分を含めてPROG_CNTを+2する必要があるが、PRINTは+1だけでよい。どうやらPUSHBからコピペしたためらしい。
暴走は止まった。現在の問題は、"2 3 +"の結果である"005"を表示しないことだ。
MZ-700 *G2000 000 *
この通り、"000"である。ワードPRINTは、表示をするのに
- CONVDECIでAレジスタの値を3桁文字列に変換し、バッファに保存する
- MSGでバッファを表示する
の処理を行なっている。MSGはバッファをそのまま表示しているだけなので、問題ない。問題はCONVDECIだろう。今までちゃんと動いているか確認されたことがないのだ。
その前に大きな勘違いをしていた。スタックだ。
スタックに積まれたデータにアクセスするには、
- STACK_PTRに保存された、スタックの現在位置をレジスタにロードする
- 現在位置に保存されたデータをロードする
としなければならないのに、最初のロードが
- STACK_PTRをロードする
になっている。
LD HL,STACK_PTR ↓ LD HL,(STACK_PTR)
に変更し、スタックポインタの動きを確認した。確認方法は、スタックポインタの値を変更した直後にHLレジスタの表示ルーチン"CALL DISPHL"を挿入する、デバッグプリント。
MZ-700 *G2000 24182417241724162416241724172418000
2418が初期位置で、PUSHBで-1、PUSHBで-1、ADDBで+1、PRINTで+1、初期位置に戻っている。OK。最後の000は、ワードPRINTの出力。
そして、ワードPRINTがスタックからAレジスタにロードしたデータを"CALL DISPA"でデバッグプリントした。
MZ-700 *G2000 05000
Aレジスタの値は5なので、"2 3 +"を正しく計算したことがわかる。しかし出力は"000"だ。そうすると、文字列変換を行うCONVDECIにバグがあるのかもしれない。
こういう時は、ステップ実行をしてみるとよい。しかし、デバッガがないので、紙の上で。
紙の上でデバッグしてみる。命令を一行ずつ見て、レジスタ値の変化を記録する。
その途中で、商をDレジスタで返す、としているのに、最後にDEレジスタをPOPしている。これではDレジスタの値が消えてしまう。それ以前に、Dレジスタに商を保存していない。DレジスタはPOPするので、Cレジスタに商を保存するように変更する。
MZ-700 *G2000 300
数値が出た。しかし、5ではない (2011.01.18)。
、、、いつの間にか3年半経過した。
が、再開する。どうやら、紙の上でデバッグしていたらしい。とりあえず、Z80のデバッガを探そう (2014.07.03)。
プリントデバッグでいいや、と思い直す。今実行しているプログラムは、こうだ。
PROG: DB 1, 2, 1, 3, 0, 2, 3
ワードのジャンプテーブルがこうである。
WORD_ENTRY: DW ADDB, PUSHB, PRINT, WORDRET, TEST1, TEST2, TEST3
なので、2をプッシュし、3をプッシュし、足して、画面に表示する、を行う。予想値は5だ。そして、今は、表示がうまくいかない。
そして今気づいたが、これ、表示したあと、止まらないのでは?(2014年7月5日)
と思ったが、最後の3はWORDRET、中身は「RET」命令なので止まるはず (2014年7月6日)。
表示したあと、表示が止まらないのではなく、3年半前にデバッグ出力のコードが残っていただけだった、、
現在、"005"を表示するはずが、"300"を表示している。少し原因がわかった。サブルーチン DIV8 は、8ビットの割り算のために8回ループしているはずが、256回ループしている。判定がおかしい。
ここのコードがおかしいようだ。
PUSH AF LD A,E CP 8 POP AF JP NZ,DIV8_1
ループカウンタ E を A にセットし、8と比較し、ゼロフラグが立っていなかったらループ先頭に戻る、が正しい動作。CP と JP NZ の間に POP が挟まっているのが原因?しかし、POPではフラグは変化しないはず (2014年7月7日)。
念のため、途中のPOPを除いて書いてみる。
PUSH AF LD A,E CP 8 JP Z,DIV8_5 DIV8_4: POP AF JP DIV8_1 DIV8_5: POP AF ;商を入れる LD C, A POP DE POP AF RET
すると、、ループが8回になった。
01020304050607080102030405060708300
DIV8 は2回呼び出されているので、"01...08" が2回、最後の "300" が実際の出力。結果は "300" のまま。
もう一つ気になったのが、この部分。JP NC と JP NZ を続けて実行している。
DIV8_2: ;計算用レジスタ >= 除数 なら、除数で減算し、商の最下位ビットを立てる CP C JP NC,DIV8_3 JP NZ,DIV8_3 SUB C OR 01H
JP命令ではフラグは変化しないはずなので、これでいいはずだが、途中に CP を入れてみる。、、変化せず (2014年7月8日)。
CONVDECI は次のようなコードである。Bレジスタに被除数をセットし、DIV8 が呼び出されるとBレジスタには余りだけが残る。それをさらに DIV8 で処理し、最後の1の桁はそのまま余りが入る。そこで、途中のBレジスタの値をデバッグ出力してみた。
CONVDECI: ;数値を10進文字列に変換する ;A: 表示する値 ;HL: 文字列格納アドレス ;被除数をセットする LD B, A call dispb ;deb ;100の桁を得る LD C,100 CALL DIV8 LD A, C OR 30H LD (HL),A INC HL call dispb ;deb ;10の桁を得る LD C,10 CALL DIV8 LD A, C OR 30H LD (HL),A INC HL call dispb ;deb ;1の桁を得る LD A,B OR 30H LD (HL),A INC HL ;終端を付ける LD (HL),0DH RET
結果は、、最初の DIV8 を呼び出す前が 5。当然である。処理したあとは、0、0。本当は最後まで5が残らなければならない。
となると怪しいのは DIV8。コードを良く見てみる。
DIV8: ;8ビット値の割り算を行う ;B: 被除数(計算後は余りが入る)、C: 除数(計算後は商が入る) ;A: 計算用、E: カウンタ PUSH AF PUSH DE LD A,0 LD D,0 LD E,0 DIV8_1: ;被除数をシフトする SLA B ;キャリーフラグが立っていたら、計算用レジスタの最下位ビットを立てる JP NC,DIV8_2 SLA A OR 01H DIV8_2: ;計算用レジスタ >= 除数 なら、除数で減算し、商の最下位ビットを立てる CP C JP NC,DIV8_3 CP C JP NZ,DIV8_3 SUB C OR 01H DIV8_3: ;商を左シフトする SLA C ;8回繰り返したら、終了する INC E: PUSH AF LD A,E CP 8 JP Z,DIV8_5 DIV8_4: POP AF JP DIV8_1 DIV8_5: POP AF ;商を入れる LD C, A POP DE POP AF RET
(2014年7月9日)
気がついた。8回ループしているので、Bレジスタを8回左シフトすることになる。つまり、Bレジスタは0になってしまう。本当は次の、10による割り算のために余りが入っていないといけない。まず1つ。
それと、CONVDECIの結果は"300"だった。これは、100の桁の数を調べるために5を100で割った結果が"3"ということである。これもおかしい。
DIV8_2がおかしい。商の最下位ビットを立てる、と書いてあるが、ビットを立てているのは計算用レジスタ = 最終的な余りである。
なんか、色々おかしい (2014年7月10日)。
まず、余りを本来のBレジスタに入れるように修正する。余りは計算用のAレジスタにあるので、最後に LD B, A すれば良い。
そして、商だが、今まで商を保存しておく場所がなかったようだ。除数であるCレジスタを、商と混同している。そこで、Dレジスタを商を保存するレジスタとする。
さらに気づいた。被除数をシフトしてビットを取り出す際、ビットがあるときしか計算用レジスタをシフトしていなかった。商レジスタも同じ間違いをしていた。
修正後、こうなった。
DIV8: ;8ビット値の割り算を行う ;B: 被除数(計算後は余りが入る)、C: 除数(計算後は商が入る) ;A: 計算用、E: カウンタ、D: 商保存用 PUSH AF PUSH DE LD A,0 LD D,0 LD E,0 DIV8_1: ;計算用レジスタ (A) をシフトしておく SLA A ;被除数をシフトする SLA B ;キャリーフラグが立っていたら、計算用レジスタの最下位ビットを立てる JP NC,DIV8_2 OR 01H DIV8_2: ;商 (D) を左シフトしておく SLA D ;計算用レジスタ (A) >= 除数 (C) なら、除数で減算し、商 (D) の最下位ビットを立てる CP C JP NC,DIV8_3 CP C JP NZ,DIV8_3 SUB C ;商 (D) の最下位ビットを立てる PUSH AF LD A, D OR 01H LD D, A POP AF DIV8_3: ;8回繰り返したら、終了する INC E: PUSH AF LD A,E CP 8 JP Z,DIV8_5 DIV8_4: POP AF JP DIV8_1 DIV8_5: POP AF ;余りを入れる LD B, A ;商を入れる LD C, D POP DE POP AF RET
実行結果は、、
MZ-700 *G2000 005 *
成功した。4年半かかった (2014年7月11日)。
十の桁と百の桁も確認してみる
PROG: DB 1, 7, 1, 8, 0, 2, 3
7と8を足すので、15が表示されるはず。
MZ-700 *G2000 00? *
、、、00? が表示された。
ADDB ルーチンの計算結果は合っていたので、やはり DIV8 ルーチンのバグだと思う。DIV8 ルーチンが呼び出されたあと、商と余りを確認する。
;100の桁を得る LD C,100 CALL DIV8 LD A, C OR 30H LD (HL),A INC HL call dispc ;deb call dispb ;deb ;10の桁を得る LD C,10 CALL DIV8 LD A, C OR 30H LD (HL),A INC HL call dispc ;deb call dispb ;deb
その結果、
- 100で割った結果: 商: 0, 余り: 15
- 10で割った結果: 商: 0, 余り: 15
10で割った結果がおかしい。ということは、DIV8_2 が間違っている。
DIV8_2: ;商 (D) を左シフトしておく SLA D ;計算用レジスタ (A) >= 除数 (C) なら、除数で減算し、商 (D) の最下位ビットを立てる CP C JP NC,DIV8_3 CP C JP NZ,DIV8_3 SUB C ;商 (D) の最下位ビットを立てる PUSH AF LD A, D OR 01H LD D, A POP AF
ここでは被除数 (== A) が除数以上であれば減算し、商のビットを立てる処理を行っている。そこで、比較を行う前にAレジスタの値を確認してみる。
DIV8_2: ;商 (D) を左シフトしておく SLA D call dispa ;deb ;計算用レジスタ (A) >= 除数 (C) なら、除数で減算し、商 (D) の最下位ビットを立てる
結果:
MZ-700 *G2000 001A1A1A1B1B1B1B 001A1A1A1B1B1B1B 00? *
Aレジスタには元の被除数 15 から、左シフトして取り出した値が入っていくため、01, 03, 07, 0F,, のように増えるはず。一体、どこで値が混入しているのか、あちこちに表示を挟んでみた。
その結果、表示をさせるルーチン HEX が、Aレジスタを破壊することが分かった、、、 (2014年7月13日)
気を取り直して、HEXルーチンを修正し、再度実行した。
MZ-700 *G2000 000000000103070F 000000000103070F 00? *
ここは想定したとおり。次はCレジスタにある除数を表示させる。
MZ-700 *G2000 6464646464646464 0A0A0A0A0A0A0A0A 00? *
64Hは100、0AHは10であるため問題ない。問題は、2段目の最後の 0FH は 0AH より大きいので、減算されるはずなのにされないことである。
減算部の処理をよく見てみる。
;計算用レジスタ (A) >= 除数 (C) なら、除数で減算し、商 (D) の最下位ビットを立てる CP C JP NC,DIV8_3 CP C JP NZ,DIV8_3 SUB C
- キャリーフラグが立っていなかったら (A > C)、ジャンプする → 減算しない
- ゼロフラグが立っていなかったら (A != C)、ジャンプする → 減算しない
、、、どうやら論理が逆である。本当は次のようでなくてはいけない。
- キャリーフラグが立っていたら (A > C)、ジャンプする → 減算しない
そこで、コードを修正する
;計算用レジスタ (A) >= 除数 (C) なら、除数で減算し、商 (D) の最下位ビットを立てる CP C JP C,DIV8_3 SUB C
実行してみると、結果は 00? ではなく、015 になった。これは、一回も減算していないことになる。そこで、確認のため、上記コードにデバッグプリントを挟んでみた。
;計算用レジスタ (A) >= 除数 (C) なら、除数で減算し、商 (D) の最下位ビットを立てる CP C JP C,DIV8_3 call dispa ;deb call dispc ;deb SUB C call dispa ;deb
正常なら、1回だけここを通るはず。結果は、、通っている。
MZ-700 *G2000 0F0A05 015 *
(2014年7月14日)
正しい余り 05H になっている。商が保存されているDレジスタも 01H なので、あっている。
、、、あれ?結果が 015 なら、合っている。100で割った結果が 0 で、10で割った結果が 1 で、余りが 5 だ。何か勘違いしていたみたいだ。結果が 015 になった時点で、バグは直ったんだ。
では次は、123を表示してみる。
PROG: DB 1, 100, 1, 23, 0, 2, 3
結果は、、
MZ-700 *G2000 123 *
8ビットで表現できる最大値、255を表示してみる。
PROG: DB 1, 199, 1, 56, 0, 2, 3
MZ-700 *G2000 255 *
OK、これでようやく10進数での表示が終わった。次は何やるんだっけ?4年半かかったので忘れている (2014年7月15日)。
マシン語命令の置き換え
4年半前に何をやっていたか?思い出した。マシン語の命令をワードにしようとしていた。
Z80のマシン語命令をForthのワードにするには、、
ロード・ストア命令
Z80は、レジスタへのロード、メモリへのストアの両方とも、LDで表現する。Forthの場合、レジスタが存在せずスタックのみなので、レジスタへのロードがなくなる。ただし、指定番地からスタックへのPUSH、スタックから指定番地へのPOPがそれぞれ、ロード・ストアに当たる。
PUSH・POP命令
(2014年7月16日)
Forthの場合、元からPUSH・POPしかないため、必須。
データ交換命令
レジスタ間、レジスタ-メモリ間のデータを交換する。スタック上のデータを入れ替えるワード SWAP が該当するか。
8ビット演算命令
すべてスタック上のデータ間の演算となる。
- 加算 (ADD)
- キャリー付き加算 (ADC)
- 減算 (SUB)
- キャリー付き減算 (SUB)
- 論理積 (AND)
- 論理和 (OR)
- 排他的論理和 (XOR)
- 比較 (CP)
、、、さて、これから数ヶ月更新を止める (2014年7月17日 つづく)