1. はじめに
私が開発したゲーム機 VGS-Zero は C言語(SDCC)を用いてゲームを開発することができる 8bit コンピュータですが、Z80 アセンブリ言語でプログラムを開発することも可能です。
そこで、Z80 を用いたプログラミング方法を note で簡単に解説してみたところ、(私の note にしては)そこそこ好評でした。
上記 note では Z80 アセンブリ言語自体の解説は大胆に端折り、既存の教本で学習することを推奨しています。ただし、私自身が Z80 の参考書を購入して読んでみた限り、「学校の先生や研究者ではなく 現役プログラマ目線の実戦向きな Z80 の入門書 があっても良いかも?」と思われたので筆を執ってみることにしました。
私が購入した参考書は大学の先生が書かれたものだったので、とても丁寧で分かりやすく「学習用の参考書」としては有用だと思われるので、引き続き「基礎の習得」にはちゃんとした参考書を読むことを推奨します。
なお、私の学位はノーランクの(大学とか行ったことない)、実戦経験しかないソルジャーです。
更に、私は「歴戦の Z80 プログラマ」という訳でもなく、Z80 マシン語プログラミングの唯一の経験は、Battle Marine という私が Steam で販売中の VGS-Zero 用のゲームソフト(※C言語で開発)をゲームギアへ移植したことのみです。
※上記製品に関するお問い合わせはギア太郎さんの方へお願いします。
他には C++11 で扱えるシングルヘッダの Z80 エミュレータも開発しています。
つまり、Z80マシン語プログラミングに関しては「ちょっとできる初心者レベル」といえます。
私が最初に触ったパソコンは、中学校に置いてあった PC-9801(80286 搭載)で、その当時から既に Z80 は「過去の石」になりつつあったので触る機会がありませんでした。
学位が無ければ経験も無い。
そんなガチの「Z80初心者プログラマ」が書いたZ80入門書ではありますが、初心者だからこそ初心者目線で分かりやすい解説ができるのではないかと期待したいところです。
2. コンピュータ基礎
コンピュータ(Computer)とは、ラテン語の Com Putare (共に木を剪定する)を語源とする Computare (計算する, 英: Compute)に er を付与した名詞 「計算をするもの」
です。(参考)
日本語で言えば「計算機」ですね。
以下、コンピュータの基礎について VGS-Zero を軸に解説します。
2-1. 装置構成
コンピュータ全般(パソコン、スマホ、ゲーム機、電卓など)は、入力装置、処理装置(演算装置+制御装置)、記憶装置(主記憶装置、補助記憶装置)、出力装置から構成されている複合装置(システム)です。
装置名 | 用途 | VGS-Zeroの該当装置 |
---|---|---|
入力装置 | システムの操作 | ジョイパッド |
処理装置 | 演算+各装置の制御 | CPU(Z80), VDP, APU, DMA, HAGe |
主記憶装置 | 装置内蔵の情報記憶 | RAM, VRAM, 各装置のレジスタ |
補助記憶装置 | 外部の情報記憶 | ROM, セーブデータ |
出力装置 | 処理結果の出力 | テレビ |
2-2. コンピュータができること
コンピュータができることは究極的には 特定番地のメモリの値を読み込んだり(入力)、演算したり(処理)、特定番地のメモリへ任意の値を書き込んだり(出力) することだけで、「マリオをジャンプさせる」といった複雑な命令は存在しませんが、連続的な入力、処理、出力の組み合わせにより「Aボタンが押された時にマリオをジャンプさせるような映像信号をテレビへ出力する」といった複雑な処理をすることができます。
これは最新の iPhone でも TK-80 でも同じ コンピュータ(ノイマン型)にとって普遍的な事 です。
2-3. メモリ構成
Z80 ではアドレス 0x0000〜0xFFFF 番地の 65536 バイト(64KB)のメモリをプログラムから参照・更新することができます。
VGS-Zero ではこの 64KB の領域を 8KB 毎の 8 ページに論理分割しており、ページ毎に各種装置のアクセス用途を 可能な限り固定的に 割り当てています。
フレキシブルな設計にした方が高い拡張性を得ることができますが、それを扱うユーザープログラムに多くの手間が発生して複雑になってしまうため、固定的な役割を直感しやすいレイアウトにしたいという意図(設計思想)でデザインしました。
Page | Address | R/W | B/S | アクセス用途 |
---|---|---|---|---|
0 |
0x0000 〜 0x1FFF
|
R | ✓ | ROM bank0 |
1 |
0x2000 〜 0x3FFF
|
R | ✓ | ROM bank1 |
2 |
0x4000 〜 0x5FFF
|
R | ✓ | ROM bank2 |
3 |
0x6000 〜 0x7FFF
|
R | ✓ | ROM bank3 |
4 |
0x8000 〜 0x9FFF
|
RW | - | VRAM |
5 |
0xA000 〜 0xBFFF
|
RW | ✓ | VRAM (Character) or 拡張 RAM |
6 |
0xC000 〜 0xDFFF
|
RW | - | RAM0 |
7 |
0xE000 〜 0xFFFF
|
RW | - | RAM1 |
- R/W = Read-only (R) or Writable (RW)
- B/S = Bank Switchable(バンク切り替え可否)
VGS-Zero の Z80 では、バンク切り替えを用いることで最大 4120KB (4MB + 24KB) のメモリへアクセスできます。
2-4. ブート処理(電源投入時の動作)
VGS-Zero の電源を投入すると 0x0000 番地から Z80 のプログラムが動き始めます。
0x0000〜0x1FFF 番地 (ページ0) は、ROM カートリッジ (game.pkgのgame.rom) のバンク0(先頭8KB)にマップされているので、バンク0が VGS-Zero のブートプログラム(起動時最初に実行されるプログラム)となります。
昨今のゲーム機では OS が搭載されていて、ブートプログラムは OS 本体のプログラム(カーネル)をロードして実行するプログラムとなっている(パソコンやスマホと同じ構成になっている)ことが多いですが、ROM カセットを用いていた頃の古いゲーム機ではゲーム自体がブートプログラムとなっているものが多く、古典的な8ビットゲーム機のテクノロジーで創られた VGS-Zero もそれらに倣い ゲーム自体をブートプログラム として見えるようにしています。
厳密には、VGS-Zero の場合は各種デバイスを全て仮想化(エミュレーション)していて、ハードウェア抽象化レイヤーを担ったカーネル等がゲームモジュール(game.pkg)をロードしています。
なお、「ROM カセットを用いていた頃の古いゲーム機」であっても必ずしもゲーム自体がブートプログラムという訳ではありません。
機種 | ブートプログラムの動き(ざっくり) |
---|---|
GameBoy | 電源を投入すると Nintendo のロゴを表示するブートプログラムが実行され、ブートプログラムがカードリッジのプログラムをロードしてゲームが起動される。(GB, GBC, GBA の全てで仕組み上は同じ) |
GameGear | 前期モデルはブートプログラム = ゲーム ROM だったが、後期モデルはゲームボーイと同様に固定のブートプログラムがカードリッジのプログラムをロードしてゲームが起動される。 |
MSX | BIOSと呼ばれるブートプログラムが起動後、各種デバイスの初期化を行い、ゲームカートリッジがスロットに挿入されている場合、カートリッジヘッダに設定されている初期アドレスからプログラムが実行される。 |
PC-88等 | BIOSと呼ばれるブートプログラムが起動後、各種デバイスの初期化を行い、フロッピーディスクが挿入されている場合、フロッピーディスクのブートセクタがメモリに読み込まれて実行される。 |
3. プログラム基礎
Z80 は、次の流れを繰り返し実行することで 命令の逐次実行 を行います。
- プログラムカウンタ(レジスタ)が指すメモリからデータを読み込み(フェッチ)
- フェッチしたデータを解析(命令デコード)
- 実行
- 結果反映(ライトバック)
プログラムカウンタは上記流れの中で次の命令読み込み位置に更新されます。
そして、逐次実行される命令群 のことを プログラム と呼びます。
テレビの番組表のこともプログラムと呼びますが、だいたい同じものです。
テレビの番組表は、時系列に配信する番組が羅列されています。
- 08:00 ニュース
- 09:00 今日のわんこ
- 10:00 今日のにゃんこ
コンピュータのプログラムも同様に、時系列(実行アドレス)毎に実行される命令が羅列されています。
- PC:0x0000 メモリからレジスタへ値を読み込む(ロード)
- PC:0x0003 レジスタと数値の演算を実行
- PC:0x0005 演算結果(レジスタ)をメモリへ書き込む(ストア)
※PC = Program Counter
Z80 の場合、電源投入(リセット)時のプログラムカウンタ初期値が 0x0000 になるため、基本プログラム(ブートプログラム)は 0x0000 番地から 動き始めます。
3-1. レジスタ
レジスタとは、各種処理装置に搭載されるメモリ(主記憶装置)で、汎用的な RAM と比較して少容量(必要最小限)&高速な特徴があります。
Z80 には次のレジスタがあります。
レジスタ | サイズ | 役割 |
---|---|---|
PC (プログラムカウンタ) | 16bit | プログラム実行アドレス |
SP(スタックポインタ) | 16bit | スタックの格納アドレス |
A(アキュームレータ) | 8bit | 汎用レジスタ (主に演算を実行) |
B | 8bit | 汎用レジスタ |
C | 8bit | 汎用レジスタ |
D | 8bit | 汎用レジスタ |
E | 8bit | 汎用レジスタ |
F(フラグ) | 8bit | 演算結果により変化する特殊レジスタ |
H | 8bit | 汎用レジスタ |
L | 8bit | 汎用レジスタ |
IX | 16bit | インデックス・レジスタ |
IY | 16bit | インデックス・レジスタ |
厳密にはこの他にも色々なレジスタ(I、R、IFF等)が存在しますが、ゲーム・プログラミング時によく使うものに絞りました。(裏レジスタについては後述しますが VGS-Zero では利用をあまり推奨していません)
R(リフレッシュカウンタ)については乱数代わりに利用しているものが市販ソフトにも幾つか存在しますが、乱数としては偏りが大きく品質が悪いため R の乱数利用は基本的に推奨できません。(VGS-Zeroなら HAGe で高品質な収束保証型乱数を利用できます)
AF、BC、DE、HL は組み合わせることで 16bit とすることができる「ペアレジスタ」と呼ばれます。
なお、B, C, D, E, H, L を単に「汎用レジスタ」と表記していますが、ペアレジスタ HL にはメモリアクセス時の高速なインデックス・レジスタの役割や、16bit 演算のアキュームレータとしての役割などがあります。
IX と IY は 8bit レジスタ IXH, IXL, IYH, IYL のペアレジスタですが、IXH, IXL, IYH, IYL へアクセスする命令は(内部的には存在するものの)公式仕様としては公開されていません。
3-2. 命令
Z80 のプログラムで実行できる命令は、大まかに以下の 8 種類に分類できます。
- 割り込み
- レジスタ ⇔ メモリ(store, load)
- レジスタ ⇔ レジスタ(transfer)
- レジスタ ⇔ スタック領域(push, pop)
- 演算
- 分岐
- I/O
- その他
Z80 の命令について上記の分類ごとに解説します。
ファミコンのCPU(6502のリコーカスタム)と Z80 の命令の分類上の違いは「I/O命令の有無」です。6502 では I/O 機器が全てメモリ上にマップされているため、ロード・ストア命令でCPU外部の装置の入出力(I/O)を行います。
ゲームボーイのCPU(Z80のシャープカスタム)でもI/O命令が削除されていて、I/Oはメモリ上にマップされています。
(1) 割り込み
VGS-Zero の場合は割り込みを使う必要が無い(一応使えるが使う必要が無い)ので DI
(Disable Interrupt) の状態にしておくことを推奨します。
Z80 はリセット時に DI
(Disable Interrupt) の状態になるため、VGS-Zero(ゲーム ROM がブートプログラムのゲーム機)の場合は DI
命令を実行する必要がありません。
なお、VGS-Zero の SDL2 版エミュレータの場合、DI されている時に HALT
命令(割り込み待機)を実行すると EXIT (プロセス終了) します。
その他の割り込み関連の詳細な解説は省略します。
(2) レジスタ ⇔ メモリ(store, load)
Z80 の場合、LD 命令で任意のメモリ番地への書き込みや読み込みができますが、利用できるレジスタや即値などが全パターン網羅されている訳ではない点を注意する必要があります。
また、6502 ではロードとストアが別ニーモニック(LDA, STA)ですが Z80 ではロードとストアは同じ LD 命令となっています。
メモリに書き込む命令群 (store)
; A書き込み
LD (nn), A ; メモリ番地 nn に A の値を書き込む
LD (BC), A ; メモリ番地 BC に A の値を書き込む
LD (DE), A ; メモリ番地 DE に A の値を書き込む
; 汎用レジスタ(Aを含む)書き込み
LD (HL), r ; メモリ番地 HL に 汎用レジスタ値 (A,B,C,D,E,H,L) を書き込む
LD (IX+d), r ; メモリ番地 IX+(-128〜127) に 汎用レジスタ値 (A,B,C,D,E,H,L) を書き込む
LD (IY+d), r ; メモリ番地 IY+(-128〜127) に 汎用レジスタ値 (A,B,C,D,E,H,L) を書き込む
; 16ビットレジスタ値書き込み
LD (nn), rr ; メモリ番地 nn に BC,DE,HL,IX,IY,SP を書き込む (※AF,PCは書き込めない)
; 即値書き込み
LD (HL), n ; メモリ番地 HL に 即値 n を書き込む
LD (IX+d), n ; メモリ番地 IX+(-128〜127) に 即値 n を書き込む
LD (IY+d), n ; メモリ番地 IY+(-128〜127) に 即値 n を書き込む
; 繰り返し書き込み (※フラグの P/V, N, H が変化する点を注意)
LDI ; (DE) <- (HL), HL++, DE++, BC--
LDIR ; LDI を BC が 0 になるまで繰り返す
LDD ; (DE) <- (HL), HL--, DE--, BC--
LDDR ; LDD を BC が 0 になるまで繰り返す
LDIR はとても便利な命令ですが VGS-Zero では C言語 の memset
と memcpy
に相当する高速なハードウェア機能(DMA)があるため、使う必要がありません。
; LDIR の使用例
LD DE, DESTINATION_ADDR
LD HL, SOUCE_ADDR
LD BC, COPY_SIZE
LDIR
; 上記と同等の処理 (DMA)
LD BC, DESTINATION_ADDR
LD DE, SOURCE_ADDR
LD HL, COPY_SIZE
OUT ($C3), A
メモリから読み込む命令群 (load)
; Aへ読み込む
LD A, (nn) ; メモリ番地 nn から A へ値を読み込む
LD A, (BC) ; メモリ番地 BC から A へ値を読み込む
LD A, (DE) ; メモリ番地 DE から A へ値を読み込む
; 汎用レジスタ(Aを含む)へ読み込む
LD r, (HL) ; メモリ番地 HL から 汎用レジスタ値 (A,B,C,D,E,H,L) へ値を読み込む
LD r, (IX+d) ; メモリ番地 IX+(-128〜127) から 汎用レジスタ値 (A,B,C,D,E,H,L) へ値を読み込む
LD r, (IY+d) ; メモリ番地 IX+(-128〜127) から 汎用レジスタ値 (A,B,C,D,E,H,L) へ値を読み込む
; 16ビットレジスタへの読み込み(※メモリレイアウトはリトルエンディアン)
LD rr, (nn) ; メモリ番地 nn から BC,DE,HL,IX,IY,SP へ読み込む (※AF,PCには読み込めない)
(3) レジスタ ⇔ レジスタ(transfer)
レジスタに別のレジスタの値を代入したり入れ替える命令について解説します。
8bit transfer
LD r1, r2 ; レジスタ r1 (A,B,C,D,E,H,L) に r2 の値を代入
この命令は使用頻度がかなり高いです。
16bit transfer
16bit レジスタは SP ← HL, IX, IY 間での LD 命令があります。
LD SP, HL
LD SP, IX
LD SP, IY
これはあまり使いどころがない印象です。
なお、z88dk の z80asm では LD HL, BC
と記述することができます。
org $0000
.main
LD HL, BC
これをアセンブルすると次のような自動展開が行われます。
$ z80asm -b test.asm
$ z88dk-dis test.bin
ld h,b ;[0000] 60
ld l,c ;[0001] 69
z88dkでは、この他にも z80asm だと存在しない命令も良い感じに展開してくれて便利ですが、スタックを使うものも存在するのでシビアな性能を求めるケースでは注意が必要かもしれません。
例えば ADD HL, $1234
(Z80に存在しない HL への即値加算) は次のように展開されます。
push de ;[0000] d5
ld de,$1234 ;[0001] 11 34 12
add hl,de ;[0004] 19
pop de ;[0005] d1
16bit transfer via stack
IX と IY にペアレジスタ (BC, DE, HL) の値を代入することはできません。
そこで、16bit レジスタから別の 16bit レジスタへ値を代入したい時はスタックを経由する方式が有効だと考えられます。
; 例: LD IX, BC 相当の処理
PUSH BC ; BC をスタックへストア
POP IX ; スタックから IX へロード
IX, IY は遅いからゲームでは使わなれない傾向が多いと思われます。(実際、GG版Battle Marineではシビアな性能が要求されたため IX, IY をほぼ使っていません。※やんごとなき事情があり一箇所だけ使っている処理がありますが頓智をきかせれば回避できたかも...)
しかし、IX, IY はメモリアクセス時 +d(相対指定)アクセスが構造体のアクセスをする時などにかなり便利なので、高速な Z80 (16MHz) を搭載している VGS-Zero では 積極的に IX, IY を用いて可読性の高いコードを記述 することが望ましいです。
IXH, IXL, IYH, IYL へのアクセスが可能なアセンブラを使用している場合は以下のように実装することもできます。
; 例: LD IX, BC 相当の処理
LD IXH, B
LD IXL, C
処理性能的にも PUSH BC (10Hz) + POP IX (14Hz) = 24Hz に対して LD IXH, B (8Hz) + LD IXL, C (8Hz) = 16Hz なので、こちらの方が高速です。
AILZ80というアセンブラでは IXH, IXL, IYH, IYL へのアクセスができるのでとても便利です。
ex, exx ※VGS-Zeroでは非推奨
EX DE, HL ; DE と HL の値を入れ替える
EX AF, AF' ; AF を 裏レジスタ と入れ替える
EXX ; BC, DE, HL を 裏レジスタ と入れ替える
レジスタをまとめて 退避・復帰 したい場合に PUSH・POP を使うよりも高速にできるメリットがあります。
ただし、若干クセがあり使いにくい(というより使いどころが限られる)ので私はほぼ使いません。使いどころのルールをカッチリ決めないとバグの温床になりがちです。(例えば EXX を用いるサブルーチンがネストすると意図しない形でレジスタが壊されることになるため、EXX をするサブルーチンから EXX を呼び出すサブルーチンのコールを避ける必要があります)
RAM 領域に十分な余裕があり高速な VGS-Zero では、裏レジスタは使わずスタック(PUSH/POP)で代用した方が再利用性の高いコードを記述できるものと思われます。
スタックなら RAM に余裕がある限り無限にネストできるのでコードの再利用性が高くなると考えられます。
また、厳密には「レジスタ ⇔ レジスタ(transfer)」の分類ではないですが、一部ペアレジスタとスタック領域(メモリ)の入れ替えもできます。
EX (SP), HL ; スタック先頭の値と HL を入れ替える
EX (SP), IX ; スタック先頭の値と IX を入れ替える
EX (SP), IY ; スタック先頭の値と IY を入れ替える
これらもあまり使いどころは無いかもしれません。
私見ですが「PUSH or CALL以外がスタックの中を書き換える」という行為が ものすごく気持ち悪い ので極力利用を避けています。
それが必要なパターンのロジックにエンカウントしたら「ロジックそのものが良くないかも?」と考えて構造から見直します。
スタックはPUSH or CALL以外で更新すべきでなく(同様にSPも初期化時と PUSH, POP, CALL, RET 以外で書き換えるべきではなく)それが必要な処理=アンチパターン と私は考えています。(それで困ったことは今の所ありません)
(4) レジスタ ⇔ スタック領域(push, pop)
レジスタペア(AF, BC, DE, HL, IX, IY)の単位でスタック領域へ PUSH、スタック領域から POP をすることができます。
PUSH AF ; AF をスタック領域へ PUSH
PUSH BC ; BC をスタック領域へ PUSH
PUSH DE ; DE をスタック領域へ PUSH
PUSH HL ; HL をスタック領域へ PUSH
PUSH IX ; IX をスタック領域へ PUSH
PUSH IY ; IY をスタック領域へ PUSH
POP IY ; スタック領域から IY へ POP
POP IX ; スタック領域から IX へ POP
POP HL ; スタック領域から HL へ POP
POP DE ; スタック領域から DE へ POP
POP BC ; スタック領域から BC へ POP
POP AF ; スタック領域から AF へ POP
Z80 のスタックポインタ(SP)は、PUSH時はプリデクリメント、POP時はポストインクリメントです。
例えば、PUSH HL を実行した時の流れは次の通りです。
- SP をデクリメント
- SP が指すメモリに H をストア
- SP をデクリメント
- SP が指すメモリに L をストア
16bit 値はメモリ上ではリトルエンディアンでレイアウト
そして、POP HL を実行した時の流れは次の通りです。
- SP が指すメモリの値を L へロード
- SP をインクリメント
- SP が指すメモリの値を H へロード
- SP をインクリメント
Z80 をリセットした時の SP 初期値は不定値なので、スタックを使う命令(PUSH, POP, CALL, RET)を呼び出す前に必ず SP を初期化する必要があります。
VGS-Zero では、RAM1 の 0xFFFF 番地(ページ7末尾)から順番に小さい方のアドレスに向かってスタック領域として使用することができるため、SP は 0x0000 に初期化する必要があります。
LD SP, 0
変数(グローバル変数)は RAM0 の 0xC000 番地(ページ6先頭)から大きい方のアドレスに向かって使用していくため、変数領域とスタック領域が重ならないように注意してプログラミングする必要があります。
VGS-Zero が使用している Z80 エミュレータでは、SP の初期値は 0xFFFF なので、SP の初期化をしなくてもほぼ問題なく動作しますが、その状態ではメモリの 0xFFFE から PUSH される形になるため RAM が 1byte 無駄になってしまいます。
SP の初期値に設定すべき値は機種により異なります。例えば、MSX の場合は 0xF380(参考)、ゲームギアの場合は 0xDFF0(※開発者向けのハードウェアマニュアルに記載)に SP を初期化する必要があります。
(5) 演算
演算命令は基本的に A (アキュームレータ) に対して行われ、演算結果により F (フラグ) が更新されます。
フラグ(F)
F はビット毎に次のような意味があります。
Bit-7 | Bit-6 | Bit-5 | Bit-4 | Bit-3 | Bit-2 | Bit-1 | Bit-0 |
---|---|---|---|---|---|---|---|
S | Z | - | H | - | P/V | N | C |
- S: Signed flag
- 1: 演算結果が負数
- 0: 演算結果がゼロまたは正数
- Z: Zero flag
- 1: 演算結果がゼロ
- 0: 演算結果がゼロではない
- H: Half Carry flag
- 1: 下位4ビットのキャリー発生
- 0: 下位4ビットのキャリーが発生しなかった
- P/V: Overflow flag (or Parity flag)
- 1: オーバーフロー発生(or 後述
E:1
の解説を参照) - 0: オーバーフローが発生しなかった(or 後述
E:1
の解説を参照)
- 1: オーバーフロー発生(or 後述
- N: Negative flag
- 1: 減算(比較)を実行
- 0: 加算を実行
- C: Carry flag
- 1: キャリーが発生
- 0: キャリーが発生しなかった
8bit 演算
ADD (加算), ADC (キャリー加算), SUB (減算), SBC (キャリー減算), AND (論理積), OR (論理和), XOR (排他的論理和), CP (比較), INC (インクリメント), DEC (デクリメント), CPL (ビット反転), NEG (0-A), CCF (キャリー反転), SCF (キャリーセット), BIT (ビットテスト), SET (ビットセット), RES (ビットリセット) の命令があります。
CCF, SCF を 8bit 演算とするのは若干違和感があるかもしれませんが F に対する演算ということで...
-
E:1
演算結果のビットがセットされている数が偶数なら 1(奇数なら 0) -
$xx
演算結果が xx なら 1(不一致なら 0) -
b:0
指定ビットが 0 なら 1(1 なら 0)
SET と RES は A レジスタに対しては不要
A レジスタの場合、SET
と RES
よりも OR
と AND
を用いたビットセット、ビットリセットの方が高速&複数ビットの同時セット・リセットを行うことができ便利です。
比較の考え方
比較は内部的には SUB 命令とほぼ同等(演算結果の反映のみがスキップ)で、比較した結果の判定を Z, C, S, P/V で行うことができます。
そして、大小比較は C フラグで行います。
CP $80 ; TEST: A - 0x80
CALL C, A_IS_LOWER_THAN_0x80 ; A < 0x80
CALL NC, A_EQUALS_OR_HIGHER_THAN_0x80 ; A >= 0x80
CP を「比較命令」として覚えると C
と NC
のどちらが <
で、どちらが >=
だったのかを忘れがちになるかもしれません。少なくとも私はアセンブリ言語に慣れていない頃は忘れがちでした。しかし、「比較は内部的には引き算をしている」と覚えておけば、「Aと比較する値の方が大きければキャリーが立つからCがセットされる」と覚えることができます。
連続比較 (CPI, CPD, CPIR, CPDR)
Z80 には CPI, CPD, CPIR, CPDR という連続的なメモリ領域を比較できる命令が存在します。ただし、私はそれらの命令の存在意義をいまひとつ理解できていないので解説を省略します。
なお、これらの命令は全く使われていない訳ではありません。私が Z80 エミュレータを作った当初、これらの命令に期待通りに動作しない致命的なバグがあったのですが、それでも大半の市販ゲームは問題なく動きました。しかし、この命令を使っているゲームが意図した通りに動かなかったことで初めてバグの存在に気づき対策しましたが、この原因究明のための調査には大分苦労しました。(要するに市販ゲームでも使用頻度が低いと言えるので「使いどころがあまり無いのではないか?」と推察しています)
論理シフト (SLA, SRL)
論理シフトは単純なシフト命令です。左シフト時は LSB、右シフト時は MSB に 0 がセットされます。
; 左論理シフト
SLA r ; r = |b6|b5|b4|b3|b2|b1|b0|"0"| ca = b7
SLA (HL) ; (HL) = 〃
SLA (IX+d) ; (IX+d) = 〃
SLA (IY+d) ; (IY+d) = 〃
; 右論理シフト
SRL r ; r = |"0"|b7|b6|b5|b4|b3|b2|b1| ca = b0
SRL (HL) ; (HL) = 〃
SRL (IX+d) ; (IX+d) = 〃
SRL (IY+d) ; (IY+d) = 〃
キャリーフラグ以外には N と H がリセットされます。
SLA は Shift Left Arthmetic (左算術シフト) の略ですが、私はこれを「論理シフトである」と解釈することにしています。
現代では、左算術シフトは「符号ビット(MSB)を動かさない左シフト」と解説するテキストが多くあります。しかし、「符号ビット(MSB)も動かす左シフト」も算術シフトと解釈することが可能です。(参考)
MSBを動かす左算術シフトの方が高速に処理可能な利点があり、一方、MSBを固定する左算術シフトの方が仕様の論理性(対称性)が保たれている利点があるものと考えられ、当時のZ80設計者は論理性より効率性を重視したものと考えられます。
昨今の CPU には除算や乗算命令が搭載されており、そもそも算術シフトがほぼ不要になった と考えられるため、仕様の論理性(対称性)を重視して「左算術シフトはMSBを固定する」という考え方へ主流が変化したのかもしれません。(実際、左算術シフトはMSBを固定するものとして解説しているテキストが多い)
現代のプログラマにとっては、SLA=左算術シフトだと話がややこしくなるので、以後「SLA=左論理シフト」の前提で解説します。
算術シフト (SRA) ※VGS-Zeroでは非推奨
算術シフトとは、シフト後の MSB (bit-7) がシフト前の MSB と同値になるシフト命令ですが、VGS-Zero の場合 HAGe の乗算、除算、剰余残を用いることで Z80 の算術シフト命令は不要 なので解説を省略します。
左算術シフトに対応する命令は Z80 には存在しません。
SLL という隠し命令が存在しますが、この命令は SLA の結果の LSB に 1 をセットする「算術シフトでなければ論理シフトでもないシフト命令」です。
ローテート (R)
; 左ローテート (bit-0がキャリー)
RLA ; A = |b6|b5|b4|b3|b2|b1|b0|ca| ca = b7
RL r ; r = 〃
RL (HL) ; (HL) = 〃
RL (IX+d) ; (IX+d) = 〃
RL (IY+d) ; (IY+d) = 〃
; 左ローテート (bit-0がbit-7)
RLCA ; A = |b6|b5|b4|b3|b2|b1|b0|b7| ca = b7
RLC r ; r = 〃
RLC (HL) ; (HL) = 〃
RLC (IX+d) ; (IX+d) = 〃
RLC (IY+d) ; (IY+d) = 〃
; 右ローテート (bit-7がキャリー)
RRA ; A = |ca|b7|b6|b5|b4|b3|b2|b1| ca = b0
RR r ; r = 〃
RR (HL) ; (HL) = 〃
RR (IX+d) ; (IX+d) = 〃
RR (IY+d) ; (IY+d) = 〃
; 右ローテート (bit-7がbit-0)
RRCA ; A = |b7|b7|b6|b5|b4|b3|b2|b1| ca = b0
RRC r ; r = 〃
RRC (HL) ; (HL) = 〃
RRC (IX+d) ; (IX+d) = 〃
RRC (IY+d) ; (IY+d) = 〃
キャリーフラグ以外には N と H がリセットされます。
論理シフト+ローテートを用いることで 16bit 値以上の論理シフトを簡単に実現できます。
; 以下を実行すると BCDE (32bit値) が左論理シフトされる
SLA E ; E を左論理シフト
RL D ; 上記キャリー (Eのbit-7) を用いて D をローテート
RL C ; 上記キャリー (Dのbit-7) を用いて C をローテート
RL B ; 上記キャリー (Cのbit-7) を用いて B をローテート
; 以下を実行すると BCDE (32bit値) が右論理シフトされる
SRL B ; B を右論理シフト
RR C ; 上記キャリー (Bのbit-0) を用いて C をローテート
RR D ; 上記キャリー (Cのbit-0) を用いて D をローテート
RR E ; 上記キャリー (Dのbit-0) を用いて E をローテート
RLC,RRC,RLCA,RRCA の C は Carry ではない
Circular (円形) の C とのことです...「キャリーを含めたローテーションか」という点が異なる命令ニーモニックに C を付与すれば、「あぁ、Cがついている方がキャリーか」と判断する人の方が多いと思うのですが、何故そんな「わざわざプログラマのミスリードを誘発するネーミング」にしたのか疑問です。
4bit ローテート(RLD/RRD)
4bit 同時にローテーションする RLD, RRD という命令が存在し、RLD, RRD は A と (HL) の値を 4 ビット左(RLD)または右(RRD)にローテーションします。この命令を用いることで 高速にメモリ上の BCD 値を十進論理シフト(10倍 or 1/10) することができます。
少し分かり難いので、具体例で解説します。
次のように 0xC000〜0xC003 (RAM) に 8桁(4バイト)の BCD の 0x12345678
が格納されているものとします。
Dump from 0xC000 (16 bytes)
ADDR +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F ASCII
C000: 12 34 56 78 00 00 00 00 - 00 00 00 00 00 00 00 00 .4Vx............
上記を RLD で 10倍、RRD で 1/10 にする例をそれぞれ示します。
RLD (BCD を 10倍) の具体例
LD HL, $C003 ; HL = 0xC003
XOR A ; A = 0
LD B, 4 ; 4回実行
LOOP: ; ループ起点
RLD ; A & (HL) を 4bit 右ローテート
DEC HL ; HL--
DJNZ LOOP ; B-- して 0 でなければ LOOP へジャンプ
上記を実行後のメモリダンプは次の通りです。
Dump from 0xC000 (16 bytes)
ADDR +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F ASCII
C000: 23 45 67 80 00 00 00 00 - 00 00 00 00 00 00 00 00 #Eg.............
RRD (BCD を 1/10) の例
LD HL, $C000 ; HL = 0xC000
XOR A ; A = 0
LD B, 4 ; 4回実行
LOOP: ; ループ起点
RRD ; A & (HL) を 4bit 右ローテート
INC HL ; HL++
DJNZ LOOP ; B-- して 0 でなければ LOOP へジャンプ
上記を実行後のメモリダンプは次の通りです。
Dump from 0xC000 (16 bytes)
ADDR +0 +1 +2 +3 +4 +5 +6 +7 +8 +9 +A +B +C +D +E +F ASCII
C000: 01 23 45 67 00 00 00 00 - 00 00 00 00 00 00 00 00 .#Eg............
BCD
数値演算を実行した後 DAA
を実行することで演算結果が BCD として正しい形になります。
例えば、次のようになります。
LD A, $12 ; A = BCD の "12"
ADD 8 ; A += 8 (A: $1A)
DAA ; BCD 変換 (A: $20)
16bit 演算
ADD HL, rr ; HL += rr (BC,DE,HL,SP)
ADC HL, rr ; HL += carry + rr (BC,DE,HL,SP)
SBC HL, rr ; HL -= carry + rr (BC,DE,HL,SP)
ADD IX, rx ; IX += rx (BC,DE,IX,SP)
ADD IY, ry ; IY += ry (BC,DE,IY,SP)
INC ra ; ra++ (BC,DE,HL,SP,IX,IY)
DEC ra ; ra-- (BC,DE,HL,SP,IX,IY)
任意ペアレジスタに A を加算したい時は次のように実装します。
; BC += A
ADD A, C ; A += C
LD C, A ; C = A
ADC A, B ; A += B + carry
SUB C ; A -= C
LD B, A ; B = A
ADD HL, rr
は 11Hz で実行できるのに対して、上記は 20Hz (4+4+4+4+4) を要するので 16bit 演算で実装可能なケースでは極力 16bit 演算で実装することが望ましいです。しかし、汎用レジスタでは計算される値が HL に限定される縛りがあるため、トータルコストでは上記のようなキャリー演算を含む実装の方が結果的に高速になることもままあります。
なお、上記の処理は分岐を用いて以下のようにすることもできます。
; BC += A
ADD A, C ; A += C
LD C, A ; C = A
JR NC, ASMPC+3 ; carryがセットされてなければB++しない
INC B ; B++
この場合、桁上がり(B++)の有無により速度が変わりますが平均的には 20Hz より若干早く演算できる可能性があります。
- 桁上がり(B++)あり = 4+4+7+4 = 19Hz
- 桁上がり(B++)なし = 4+4+12 = 20Hz
※相対ラベル指定が可能なアセンブラを用いればラベルを書かなくても済みます。
(6) 分岐(branch, jump)
分岐命令は「無条件分岐」「条件分岐」「DJNZ」に分類することができます。
無条件分岐
JP nn ; 絶対番地 nn へ分岐
JP HL ; 絶対番地 HL へ分岐
JP IX ; 絶対番地 IX へ分岐
JP IY ; 絶対番地 IY へ分岐
JR +n ; 相対番地 +n (-128~127) へ分岐
CALL nn ; 戻りアドレスをスタックへ PUSH してから絶対番地 nn へ分岐
RET ; スタックから POP した絶対番地へ分岐
なお、JP HL
, JP IX
, JP IY
についてはニーモニック表記としては JP (HL)
, JP (IX)
, JP (IY)
という書き方が正しいですが、その正しいニーモニック表記だと HL が指すメモリに格納されているアドレス値への分岐 とミスリードしやすいので気を付けましょう。
また、ペアレジスタ値へ分岐したい時は PUSH と RET を組み合わせます。
PUSH BC ; BC をスタックへ PUSH
RET ; スタックから POP した絶対番地 (=BC) へ分岐
条件分岐
フラグ (F) の状態により 分岐したりしなかったり することができます。
JP Z, nn ; ゼロフラグがセット(1)の場合は、絶対番地 nn へ分岐
JP NZ, nn ; ゼロフラグがリセット(0)の場合は、絶対番地 nn へ分岐
JP C, nn ; キャリーフラグがセット(1)の場合は、絶対番地 nn へ分岐
JP NC, nn ; キャリーフラグがリセット(0)の場合は、絶対番地 nn へ分岐
JP M, nn ; Sフラグがセット(1: マイナス)の場合は、絶対番地 nn へ分岐
JP P, nn ; Sフラグがリセット(0: プラス or 0)の場合は、絶対番地 nn へ分岐
JP PE, nn ; P/Vフラグがセット(1)の場合は、絶対番地 nn へ分岐
JP PO, nn ; P/Vフラグがリセット(0)の場合は、絶対番地 nn へ分岐
条件分岐は JP nn
だけでなく JR
、CALL
、RET
でも使用することができます。
ただし、レジスタ指定ジャンプ(JP {HL|IX|IY}
)では使用できません。
レジスタ指定ジャンプで条件分岐をしたい時に PUSH -> RET cnd
としたくなるかもしれませんが、これは条件不一致で分岐しなかった時にスタックポインタが壊れる(ズレる)ので NG です。
DJNZ
DJNZ +n ; B をデクリメントして 0 でなければ 相対番地 +n (-128~127) へ分岐
割とよく使われる命令です。
LD B, 3 ; 3回だよ3回
LOOP: ; ループラベル
CALL WOOF ; わん!
DJNZ LOOP ; B-- をして B が 0 でなければ LOOP へジャンプ
上記処理を DJNZ を使わずに実装すると次のようになります。
LD B, 3 ; 3回だよ3回
LOOP: ; ループラベル
CALL WOOF ; わん!
DEC B ; B--
JR NZ, LOOP ; B が 0 でなければ LOOP へジャンプ
上記のどちらの処理でも同じ結果(サブルーチン WOOF が 3回コールされる形)になります。ただし、コードサイズを 1byte 削ることができ、処理性能も DJNZ の方が(ループ回数×3Hz程度)良いので、特別な事情(8086とバイナリコンパチしたい場合とか?)が無い限り DJNZ で実装すべき です。
(7) I/O
; 入力命令
IN A, (n) ; ポート n + (B*256) から A へ入力
IN r, (C) ; ポート BC から {A|B|C|D|E|H|L} へ入力
INI ; ポート BC から (HL) へ入力後 B--, HL++ (BがゼロになったらZ=1)
INIR ; INI を B が 0 になるまで繰り返す
IND ; ポート BC から (HL) へ入力後 B--, HL-- (BがゼロになったらZ=1)
INDR ; IND を B が 0 になるまで繰り返す
; 出力命令
OUT (n), A ; ポート n + (B*256) へ A を出力
OUT (C), r ; ポート BC へ {A|B|C|D|E|H|L} へ入力
OUTI ; ポート BC へ (HL) を出力後 B--, HL++ (BがゼロになったらZ=1)
OTIR ; OUTI を B が 0 になるまで繰り返す
OUTD ; ポート BC へ (HL) を出力後 B--, HL-- (BがゼロになったらZ=1)
OTDR ; OUTD を B が 0 になるまで繰り返す
VGS-Zero ではリピート命令(INIR, INDR, OTIR, OTDR)が必要な I/O は存在しないため利用シーンが想定されません。
VGS-Zero を含む大半の機器ではポート数の上限は最大256個です(ポート番号としての B は無視されます)が、一部 16bit のポート数を備えた機器も存在します。例えば、Sharp X1 では VRAM へのアクセスに 16bit I/O を用います。
(8) その他
Z80 には何も実行しない NOP
命令があります。
VGS-Zero の SDL2 版エミュレータでは、NOP 命令をブレイクポイントとして利用できるデバッグ機能があります。
プログラムの実行結果が期待通りにレジスタやメモリに反映されているか確認したい時に NOP 命令を仕込むことで、ピンポイントにプログラムを中断することで効率的なデバッグを行うことが可能です。
4. アウトプット学習
VGS-Zero の examples は HELLO, WORLD!
を除き全て「短くて簡単なC言語」で実装されています。
そこで、example の内容を C言語 から Z80 アセンブリ言語へ移植する アウトプット学習 をすることで、効果的に Z80 の知識を吸収できるものと思われます。
もちろん、それが面倒くさければゲーム開発でも全然 OK です。
プログラムを覚えるコツは「ひたすらプログラムを組む」ことです。
例えば、画面上に16x16のスプライトを表示してジョイパッドで動かすプログラムを作り、そこから「Bボタンでショットを撃ってみる」などのゲームっぽい機能追加をしていくことで多くのノウハウを吸収できる筈です。
最初から超大作を作ろうとすると手を動かすことができなくなりがちなので、最終的には超大作を創る場合であっても 簡単なエクササイズをひとつづつ潰していく要領 で作り進めた方が「完成する可能性」が高くなるかもしれません。
特にマシン語プログラミングの場合は。