aburi6800のブログ

コンピュータのプログラミング、ゲームに関するニッチな情報を書いていくブログです。

【MSX】Z80マシン語を勉強してみた(第1回)

はじめに

往年のマイコン少年なら誰しも憧れた、マシン語
当時のマイコン雑誌には何ページにもわたってダンプリスト(16進数の羅列)が載っていたりして、その入力すら厳しいものでしたが、そこから動くゲームはどれも本格的でBASICでは味わえないものばかりでした。
私も少年時代にマシン語のプログラムにチャレンジしましたが、当時は動くものが作れず、挫折してしたクチです。

そして時は流れ、現在。

改めて勉強しなおしてみると、なんということでしょう!
色々なことが理解できるようになり、ある程度動くプログラムが作れるようになりました。
せっかくですので、ここでは、その過程を記録していこうと思います。

まずは逆スクロールからやってみよう

入門書でよくあるのは、メモリの値を取ってきて計算して格納して…みたいなことが書かれていますが、正直つまらないですね。
やはり、画面で動くもので体感するのが、一番楽しく勉強できるというものです。

ということで、まず手始めに、昔挫折した逆スクロールの処理からやってみることにしました。
最初からすべてをマシン語で書くのは難易度が高いため、マシン語では逆スクロールする処理だけを作り、BASICで画面描画とマシン語の呼び出しをする構成としました。

まずは開発環境とか情報とか

WebブラウザMSXのBASICやアセンブリのエディタとエミュレータが統合された無料て利用できる開発環境である、MSXPenというサイトを使いました。

MSXPen

Z80アセンブリニーモニックは、こちらを参照しました。

Yamamoto's Laboratory 8ビット CPU Z80命令セット

また、VRAMへのアクセスはBIOSを使用していますが、BIOSコールについてはこちらを参照しました。

MSX Datapack wiki化計画 Appendix A.1 BIOS 一覧

BIOSって?

ここでBIOSとキーワードが出てきましたので、ちょっと余談を。
このBIOSというのは、MSX規格のマシンで用意された、便利なサブルーチン郡のことです。
これにより、MSX規格として各マシンのハードウェア構成の違いが吸収され、違う機種でも同じアプリケーションが修正なしで実行できるようになっていました。

当時のコンピュータでは、OSというものがなく(強いていえばBASICがOS代わりだった)、CPUからメモリ以外のハードウェア(画面やキーボード、音源など)へアクセスする際は、I/Oポートを直接叩いて行っていました。(このI/Oの叩き方にもお作法があったりして、プログラミングの難易度が上がる要因にもなっていました)

MSXではこのI/OポートやVDPをアクセスする処理が予めBIOSとして用意されていますので、アセンブリ言語でプログラムする敷居が非常に低くなっています。
また、そもそもハードウェアとしてゲームプログラムに使いやすいスプライトやジョイスティック等もサポートされているため、入門機としては非常にお勧めできるマイコンのひとつです。

私もこのBIOSのおかげで、煩わしいI/Oポートへのアクセスをすることなく、処理に集中してプログラムができました。

その代償として、またROMカートリッジのサポートもあり、メモリアクセスのスピードが他のZ80マイコンよりも遅く、若干見劣りすることになりますが… また、さすがにVRAMへのアクセスが遅いのは問題視していたようで、VDPへの直アクセスは公式に認められていました。

アセンブリで書いてみよう

では早速、逆スクロールする処理を考えてみましょう。
アセンブリマシン語)のプログラムはメモリやレジスタに対する操作を書く、ということを念頭に置き、ニーモニック表やBIOSの一覧とにらめっこして、プログラムの処理の手順を以下のように考えました。

  • 画面最終行から処理開始
  • 一行上の表示内容をコピー
  • 次に処理対象行を一行上にして、同じ処理を行う
  • これを画面最上行の一行前まで繰り返す
  • 最後に画面最上行を空白で埋める

ここから、実際に作ってみたアセンブリプログラムは、次のようなものになります。

  ORG 0xC000          ; 開始アドレス

START:
    LD HL,&1AF8         ; HL=&H1AF8(処理開始VRAMアドレス)
    LD BC,&17           ; BC=行数カウンタ &H17(23)回

; 外部ループ処理 開始
; 全行に対しての処理を行う
OUTLOOP:
    PUSH BC             ; BCをスタックに退避
    LD BC,&19           ; BC=桁数カウンタ &H19(25)回

; 内部ループ処理 開始
; 1行に対しての処理を行う
INLOOP:
                        ; ■1行上のVRAMアドレスのデータを読む
    PUSH BC             ; BCをスタックに退避
    PUSH HL             ; HLをスタックに退避
    LD BC,&20           ; BC=&20(32)
    SBC HL,BC           ; HL=HL-BC
    CALL &004A          ; BIOS RDVRM呼び出し
                        ; - HL : 読み取るアドレス
                        ; - A  : 読み取ったデータ
    POP HL              ; HLをスタックから復帰
    POP BC              ; BCをスタックから復帰

                        ; ■現在のVRAMアドレスにデータを書き込む
    CALL &004D          ; BIOS WRTVRM呼び出し
                        ; - HL : 書き込み先のVRAMアドレス
                        ; - A  : 書き込むデータ

                        ; ■ひとつ左のアドレスに移動
    DEC HL              ; HL=HL-1

                        ; ■桁数カウンタ減算
    DEC BC              ; BC=BC-1
    LD A,B              ; A=B
    OR C                ; A=A OR C
    JR NZ,INLOOP        ; NZ(ゼロでない)なら、INLOOPラベルにジャンプ
; 内部ループ処理 終了

                        ; ■1行上の処理開始VRAMアドレスに遷移させる
                        ; 画面右に7文字分の余白を設けるため、VRAMアドレスから7を減算する
    LD BC,&07           ; BC=&07(7)
    SBC HL,BC           ; HL=HL-BC

                        ; ■行数カウンタ減算
    POP BC              ; BCをスタックから復帰
    DEC BC              ; BC=BC-1
    LD A,B              ; A=B
    OR C                ; A=A OR C
    JR NZ,OUTLOOP       ; NZ(ゼロでない)なら、OUTLOOPラベルにジャンプ
; 外部ループ処理 終了

    LD HL,&1800         ; HL=&H1800(処理開始VRAMアドレス)
    LD BC,&19           ; BC=桁数カウンタ &H19(25)回

; 内部ループ処理 開始
; 1行目のクリア処理を行う
ENDLOOP:
                        ; ■現在のVRAMアドレスにデータを書き込む
    LD A,&20            ; A=&20(" ")
    CALL &004D          ; BIOS WRTVRM呼び出し
                        ; - HL : 書き込み先のVRAMアドレス
                        ; - A  : 書き込むデータ

                        ; ■ひとつ右のアドレスに移動
    INC HL              ; HL=HL+1

                        ; ■桁数カウンタ減算
    DEC BC              ; BC=BC-1
    LD A,B              ; A=B
    OR C                ; A=A OR C
    JR NZ,ENDLOOP       ; NZ(ゼロでない)なら、ENDLOOPラベルにジャンプ
    
    RET                 ; BASICに戻る

END START

ゲームで使うことを想定して、右側7桁はスコア表示などのためにスクロールさせずに固定するようにしました。
そのため、スクロールするのは、(0,0)~(24,23)までの範囲になります。

まずは、処理を開始するVRAMアドレスを求めます。
画面右下から左上に向かって処理するため、求めるのは画面右下のアドレスになります。
SCREEN 1の画面(パターンネームテーブル)の先頭アドレスは1800Hです。
32桁×24行で画面を初期化するので、32×24-1=767=2FFHを1800Hに加算した1AFFHが画面右下のアドレスになります。
ここから、固定表示させる7桁分を減らした1AF8Hが処理開始アドレスになります。
この値をHLレジスタに設定します。

次にOUTLOOPですが、これは行数分ループさせます(外ループ)。
そのため、事前にBCレジスタに行数である23(全体で24行だが、最後の1行は処理不要のため処理が必要なのは23行となる)を設定しています。

INLOOPは一行を桁数分ループする処理です(内ループ)。
BCレジスタをスタックに退避後、桁数である25を設定し、内ループに入ります。
このループでは、以下のような処理をしています。

  • HLレジスタの値をスタックに退避
  • HLレジスタの値から32を減算したアドレス=一行上の表示文字を取得(BASICのVPEEKと同じ)
  • HLレジスタの値をスタックから戻し、VRAMのそのアドレスに取得した表示文字を書き込み
  • HLレジスタの値から1を減算する=処理対象の位置を1文字左へ移動する
  • ループ回数(桁数)から1を減算し、ゼロになるまで繰り返し

内ループ終了後、外ループに戻りますが、そのときに画面右7桁を処理対象外とするために、HLレジスタから7減算します。
スタックに退避したBCレジスタを戻して1減算し、ゼロになるまで外ループを繰り返します。

外ループが終わったら、画面最上部をスペースで埋めて、BASICへ戻ります。

レジスタだけで処理しているものの、スタックを無駄に使っていたり、8ビット値の範囲での繰り返しなのにカウンタに16ビットレジスタを使っていたり、そもそもループをネストする必要がなかったりと、あまりいいお手本ではないですね。 まあ、覚えたてで書いたプログラムなので、大目に見てもらえればと。

レジスタって?

上の説明では、簡単に「HLレジスタに設定します」などと書きましたが、そもそもレジスタってなんでしょうか?
これは、CPUの中に予め用意された、変数のようなものです。
MSXに搭載されているZ80というCPUでは、A,B,C,D,E,H,Lの7種のレジスタが使えます。
これらに加えて、フラグ・レジスタ、インデックス・レジスタが用意されています。
また、各レジスタでは8ビットの値(0~255)しか格納できないのですが、2つのレジスタを組み合わせて16ビットの値を扱うこともできます。
この組み合わせとして、BC,DE,HLとインデックスレジスタがあります。

さて、これらのレジスタは、どれでも自由に使えるのですが、その用途は命令によって限られています。
例えは、計算をする場合やメモリへの値の出し入れは、Aレジスタしか使えません。
アドレスを示すレジスタとしてはHLレジスタが使われることが多いです。
また、ループ制御(DJNZ命令)を使う場合は、ループ数をBレジスタに設定します。
このように、処理で使う命令に合わせて、使えるレジスタが自然と決められていくことになりますが、プログラムを書いていくうちに自然と慣れていくと思います。

BASICから動かしてみよう

さて、せっかく作ったマシン語プログラムも、動かさないと意味がありません。
マシン語プログラムを呼び出すBASICプログラムを作ってみましょう。

100 SCREEN 1,2,0:WIDTH 32:KEY OFF:COLOR 15,4,7
110 CLEAR 200,&HBFFF:DEFUSR=&HC000
120 BLOAD "PROGRAM.BIN"
130 FORI=0TO24:LOCATE25,I:PRINT"|";:NEXT
140 LOCATE26,1:PRINT"SCROLL";:LOCATE26,2:PRINT" DEMO";  
150 FORI=0TO30:LOCATEINT(RND(1)*25),INT(RND(1)*25):PRINT"*";:NEXT
160 A=USR(0):LOCATEINT(RND(1)*25),0:PRINT"*";:GOTO160

110行目のCLEAR文では、BASICで使うメモリをBFFFHまでに設定し、DEFUSR文で呼び出すマシン語プログラムの先頭アドレス(C000H)を設定しています。

なお、DEFUSRのあとに0~9の番号を書くことで、10個までマシン語のプログラムの呼び出しアドレスを設定できます。

120行目でマシン語プログラムを読み込み、130~150行目で画面の初期表示をした後、160行目でスクロール処理と星の表示を行っています。

ちなみに、ロードするアドレスのC000Hはどうやって決定したかというと、特に意味はありません。
ユーザーエリアが8000HF37FHまでなので、この範囲内であればどのアドレスを指定しても良いのです。
ただし、あまり若いアドレスを設定すると、BASICプログラムが入りきらなくなりますので、その場合はE000Hとかにしましょう。

また、システムワークエリアがF380Hから存在するので、そこまでに納まるようにしましょう。

以上で、何とか動くものができました。
この処理をMSXPenで動作させるURLは、以下になります。

https://msxpen.com/codes/-Mht5HeZgQfTPNV9vx31

このURLにアクセスすると、自動的に実行されます。

メモリにあるマシン語プログラムを見てみよう

さて、アセンブリソースから作ったマシン語プログラムと、BASICプログラムの2つを使って、画面を逆スクロールすることができました。
しかし、マシン語プログラムは実際どのようにメモリに格納されているのでしょうか?

BASICプログラムに以下の行を追加して再度実行してみましょう。

200 SCREEN0:A=&HC000:FORI=0TO63STEP8:PRINTRIGHT$("0000"+HEX$(A+I),4);":";
210 FORJ=0TO7:PRINTRIGHT$("00"+HEX$(PEEK(A+I+J)),2);" ";:NEXTJ:PRINT:NEXTI

そして、プログラムをストップして、MSXの画面で

RUN 200

と打ってEnterキーを押してみてください。
すると・・・

16進数がたくさん表示されました。
これがアセンブリソースをコンパイルして作られた、マシン語プログラムです。
最後のC9がBASICに戻るRET命令ですので、ここまでで全部で60バイトになるようです。

C9以降が00ではないことがあると思いますが、無視してください

なお、この16進数の値をBASICプログラムのDATA文として書いて、POKE文でメモリに書き込めば、BASICプログラムだけで逆スクロールさせることができます。
参考として、以下にBASICだけで書いたプログラムを載せておきます。

100 SCREEN 1,2,0:WIDTH 32:KEY OFF:COLOR 15,4,7
110 CLEAR 200,&HBFFF:DEFUSR=&HC000
120 GOSUB200
130 FORI=0TO24:LOCATE25,I:PRINT"|";:NEXT
140 LOCATE26,1:PRINT"SCROLL";:LOCATE26,2:PRINT" DEMO";  
150 FORI=0TO30:LOCATEINT(RND(1)*25),INT(RND(1)*25):PRINT"*";:NEXT
160 A=USR(0):LOCATEINT(RND(1)*25),0:PRINT"*";:GOTO160
200 FORI=0TO59:READA$:POKE&HC000+I,VAL("&H"+A$):NEXT:RETURN
210 DATA 21,F8,1A,01,17,00,C5,01
220 DATA 19,00,C5,E5,01,20,00,ED
230 DATA 42,CD,4A,00,E1,C1,CD,4D
240 DATA 00,2B,0B,78,B1,20,EB,01
250 DATA 07,00,ED,42,C1,0B,78,B1
260 DATA 20,DC,21,00,18,01,19,00
270 DATA 3E,20,CD,4D,00,23,0B,78
280 DATA B1,20,F5,C9,00,00,00,00

MSXPenで作ったプログラムを保存する

少し話が脱線しますが、MSXPen上で作成したプログラムを保存する方法を紹介します。

一つ目は、右上の「Share」というボタンを押して、表示されたURLをメモしておく方法です。
このURLにプログラムコードも含まれているので、アクセスすると全て復元されます。
この記事のように、ブログなどにも貼り付けることが出来るので便利です。

二つ目は、エディタに書いたソースをコピーして、他のテキストエディタなどに貼り付けて保存する方法です。
PC上で再利用したい場合に有効です。
なお、MSXPenではPasmoというアセンブラが使われており、PCにインストールすることでコンパイルすることができます。
詳細は以下を参照ください。

Pasmo, ensamblador cruzado Z80 portable / portable Z80 cross assembler.

最後は、実行時に作成されたディスクイメージをエクスポートする方法です。
MSXPenでは、アセンブリソースは自動的にコンパイルされてPROGRAM.BINというファイル名で、BASICプログラムもAUTOEXEC.BASというファイル名でディスクイメージに保存されます。
そして、起動時にこのディスクイメージを読み込んで実行する仕組みになっています。
ですので、このディスクイメージを保存することで、配布や他のエミュレータなどでの実行が容易になります。

さいごに

ここまでで、BASICプログラムからマシン語の処理を呼び出して動かすことができました。
例にした逆スクロールもですが、フィールドマップ表示やフォント加工など、BASICでは時間がかかる処理を一部マシン語にすることで、それまでと動きが変わると思います。

最初からすべてをマシン語で書くのは大変ですので、このような一部の処理からマシン語で書いてみては如何でしょうか。