ゼッパチを、いまの机に蘇らせる — Z80 で C の printf と二分探索を、電源オンの 0x0000 から動かす

  • z80
  • sdcc
  • c言語
  • レトロコンピュータ
  • アセンブリ

寄り道に、ゼッパチの話を

これはシリーズの寄り道、気分転換の回です。テーマは Z80——私の世代でいう「ゼッパチ」。

大学生のころ、はじめて書いたアセンブリが Z80 でした。8 ビット、汎用レジスタはひとにぎり、命令も素直。スタックは下に伸びる、I/O は別空間、割り込みベクタはここ……そういう「機械の地理」を、紙のメモリマップを横に置いて、頭の中で歩いていた時代です。

あれから数十年。あの世界を、いまの机にどれくらい気軽に蘇らせられるのか。ふと試したくなりました。ただ「動いた」で終わらせず、電源オンの 0x0000 から C の printf までを一本の線でつなぐところまでやってみます。寄り道のわりに、中身は少し濃いめです。

道具をそろえる

Z80 の C コンパイラといえば SDCC(Small Device C Compiler)。これと、同梱の Z80 シミュレータ ucsim_z80 を入れる。やることはそれだけで、数十秒で揃います。あのころは、アセンブラを入れてリンカの設定を書いて、それだけで休日の午前が溶けていたことを思うと、この一瞬さがちょっと笑えてきます。

ただ「sdcc というコマンド一個」に見えて、中身は昔ながらの分業体制です。手元のバージョンはこうでした。

SDCC : ... z80 ... 4.5.24 #16530 (Linux)
ucsim_z80 0.9.9

sdcc ドライバの下にぶら下がっているのは、だいたいこの面々です。

ツール 役割
sdcc ドライバ。C なら compile→assemble→link→.ihx までを一括で面倒みる
sdasz80 Z80 アセンブラ(.asm.rel
sdldz80 リンカ(.rel を結合し配置を決める)
crt0 / z80.lib C ランタイム起動コードと標準ライブラリ(share/sdcc/lib/z80/
ucsim_z80 リファレンス命令セットシミュレータ。デバッガ兼用
makebin / packihx .ihx(Intel HEX)を生バイナリや整形 HEX に変換

昔は sdasz80sdldz80 を手で順番に叩き、リンク時のアドレス指定で何度もつまずいたものですが、C のソースなら sdcc 一発でそこを全部畳んでくれる。隔世の感、というのはこういうことを言うのだと思います。

Z80 のメモリ空間を、もう一度

C を載せる前に、足場を確認します。Z80 のアドレス空間は 64 KB のフラットなメモリ(0x0000–0xFFFF)と、それとは完全に別の I/O 空間(IN/OUT 命令で触る 256 個のポート)の二本立てです。MMU はありません。メモリ保護もありません。番地は全部、地続きの平野です。

電源が入ると Z80 は PC=0x0000 から実行を始める。ここがリセット開始番地です(Z80 では実質 RST 0。「ベクタ」は本来、割り込み側の用語なので、ここでは使いません)。SDCC が吐いたバイナリの 0x0000 を実際に覗くと、こうなっていました。

0x0000: c3 00 01      → JP 0x0100

C3JP。つまり「リセットしたら 0x0100 へ飛べ」。その 0x0100 から先が crt0——C の世界が始まる前に走る、お膳立て役のコードです。逆アセンブルすると(実機ビルドの実値)、やっていることは素直でした。

crt0 の逆アセンブル(実機ビルドの実値)
0x0100  31 00 00   LD   SP,#0x0000   ; スタックポインタを立てる
0x0103  cd b4 0d   CALL 0x0DB4       ; .data/.bss 初期化へ(gsinit 前段, _HOME)
0x0106  b7         OR   A
0x0107  cc b6 0d   CALL Z,0x0DB6     ; gsinit 本体(グローバル変数の初期化)
0x010A  cd 0a 02   CALL 0x020A       ; ← ここで main() を呼ぶ
0x010D  c3 04 02   JP   0x0204       ; main から戻ったら _exit へ

C プログラマが main() の一行目を書くずっと手前で、誰かが「スタックを用意し、初期値付きの変数(.data)を配り、ゼロ初期化領域(.bss)を均し」してくれている。その「誰か」が crt0 です。普段は意識しませんが、Z80 の上ではこの数十バイトが C の前提条件そのものになります。

配置はリンカが決めます。今回の hello の地図(リンカが出力する .map)を読むと、こう並んでいました。

区画 先頭 中身
リセット開始番地 0x0000 JP 0x0100(3 バイト)
_CODE 0x0200 コード本体+文字列リテラル(約 3 KB)
_DATA / _BSS 0x8000 変数領域・ゼロ初期化領域

main の実体は 0x020A_putchar0x0238printf0x0275"Hello, Z80!" という文字列リテラルは、データではなくコード区画 _CODE の末尾(0x0220 付近)に置かれていました。読み出し専用の定数は、コードと一緒に焼かれる——ROM 前提の世界の作法が、こんなところに残っています。

C が、Z80 で、printf まで動いた

足場が見えたので、定番の hello world を。ただしメモリに文字列を書いて目視で確認、ではなく、ちゃんと printf を通します。

ここで素の Z80 の現実にぶつかります。この機械にはコンソールが無い。stdout という出口が、そもそも存在しない。SDCC の printf は最終的に putchar() を 1 文字ずつ呼ぶだけで、その先をどこへ出すかはターゲット側の責任です。だから標準ライブラリに putchar の実体は入っていません。移植の最小単位は、この 1 関数なのです。

出口には ucsim_z80 の「シミュレータ・インターフェース(simif)」を使います。これは、決めた番地(またはポート)にコマンドを書くとホスト側が反応してくれる擬似デバイスで、0x70'p')を書いてから 1 バイト書くと、その文字がホストの標準出力に出ます。putchar はこれだけです。なお __sfr(この変数は I/O ポート)と __at(この番地へ固定配置)は SDCC 独自の拡張記法で、他コンパイラには無いもの——移植するなら、まずここを書き換える前提だと思っておけば十分です。

console.c
__sfr __at (0x80) SIMIF;          /* I/O ポート 0x80 = OUT (0x80),A */

int putchar(int c) {
    SIMIF = 0x70;                 /* 'p' : これから 1 文字出す */
    SIMIF = (unsigned char)c;     /* その 1 文字              */
    return c;
}

たった 1 関数を与えるだけで、その上の階層——書式変換、%d の十進化、文字列の流し込み——は標準ライブラリがそのまま動きます。hello.c 側は、もう普通の C です。

hello.c
#include <stdio.h>
#include "console.h"

void main(void) {
    printf("Hello, Z80!\n");
    printf("7 * 6 = %d\n", 7 * 6);
    sim_exit();
}

sdcc -mz80 でビルドし、ucsim_z80 で走らせた実出力がこれです。

Hello, Z80!
7 * 6 = 42

Stop at 0x000245: (110) Program stopped itself

ほぼ半世紀前(Z80 は 1976 年)の命令セットの上で、printf%d42 に化けて出てきました。main から戻る代わりに sim_exit()(simif の停止コマンド 's')でシミュレーションを畳んでいるので、HALT で宙ぶらりんにならず "Program stopped itself" と綺麗に終わります。

現場の罠:コンソールを 0xFFF0 に置いたら、壊れた

ここは正直に書きます。最初、私はこの simif を I/O ポートではなく、メモリの 0xFFF0 番地に置いていました。hello は動いた。ところが、再帰や、引数の多い printf を入れたとたん、出力がゴミ化して無限ループに落ちる。1〜2 引数は通るのに、3 引数で必ず壊れる。最初は「SDCC の printf が非力なのか」と疑いました。違いました。

犯人は、さっき逆アセンブルで見た crt0 の一行目です。

0x0100  LD SP,#0x0000

SP=0x0000。Z80 のスタックは下方向に伸びるので、最初の PUSH0xFFFF に書き、0xFFFE0xFFFD……と降りてくる。つまりスタックの天井はメモリ最上部。一方、私はコンソールを 0xFFF0 に置いていた。呼び出しが少し深くなった瞬間、スタックが 0xFFF0 を上書きし、書き込んだ戻り番地のバイトが simif の「コマンド」として解釈され、's'(停止)や 'x'(hex 出力)が偶発的に走る——出力がゴミ化していた正体はこれでした。

抜け方は、ひねりませんでした。実機の UART がそうであるように、コンソールを I/O 空間(ポート)へ逃がす。メモリは番地が地続きで、スタックという「見えない住人」がいつでも上から降りてくる。I/O 空間は完全に別の平面なので、スタックは絶対に届かない。-I if=memory[0xFFF0]-I if=outputs[0x80] に変え、putcharOUT (0x80),A に書き換えた。それだけで、3 引数どころか 5 引数の printf も再帰も、すべて素通りで通るようになりました。

20 年やってきて、これは何度味わっても同じ感想になります。メモリマップは平面の地図に見えて、実はスタックという住人が住んでいる。 その住人の足元に、うっかりデバイスを置いてはいけない。教科書には「スタックは高位から下に伸びる」と一行で書いてある、その一行が、現場では誰かのバグレポートの背骨になっている。

「動いた」と「正しく動いた」は別物

最後に、もう少し計算らしいものを。二分探索を C で書き、結果を printf で期待値と突き合わせて PASS/FAIL まで出します。画面に数字が出た、では足りない。正しい数字が出たかを、機械に判定させます。

bsearch.c
static const unsigned char a[8] = {2, 5, 8, 12, 20, 33, 41, 57};

static void check(unsigned char key, unsigned char expect) {
    unsigned char got = bsearch8(key);
    printf("bsearch(%u) -> %u (expect %u) : %s\n",
           (unsigned)key, (unsigned)got, (unsigned)expect,
           (got == expect) ? "PASS" : "FAIL");
}

実出力:

bsearch(33) -> 5 (expect 5) : PASS
bsearch(2) -> 0 (expect 0) : PASS
bsearch(57) -> 7 (expect 7) : PASS
bsearch(7) -> 255 (expect 255) : PASS

a[5]==33 なので添字 5、先頭・末尾も理屈どおり、配列に無い 7 は「見つからない=0xFF=255」。C で書いたロジックが、ほぼ半世紀前の命令セットの上で、期待値どおりに動いた。「動いた」と「正しく動いた」を分けて確認するのは、前シリーズと同じ流儀です。ここだけは、寄り道でも崩しません。

ちなみに (unsigned) の明示キャストについて、当初は「可変長引数で上位バイトにゴミが乗りうるから」と書いていましたが、これは私の誤りでした。C の既定実引数昇格で unsigned charint に昇格して渡るため、値はそのまま保たれます(コメントでのご指摘に感謝します)。それでもキャストを残しているのは、%u と渡す型を目で見て一致させておきたい——「昇格は当然こうなるはず」を処理系任せにせず明示する、という習慣の側の理由です。「とりあえず動いた」を疑う癖は、8 ビットでも 32 ビットでも変わりません。

結び、そして次回

机の上に、ゼッパチの開発環境があっさり戻ってきました。電源オンの 0x0000 から crt0mainprintf、そして putchar の出口まで——細い線ですが、端から端まで自分の目で追える。この「全部見える」感じは、64 KB という小ささのご褒美だと思います。

次回(記事 2)は、ここに 自分で書いた、もう少し大きいプログラムを載せて走らせます。何を動かすと「おお」となるか——それは別途、じっくり決めます。本稿で動かしたコードは lab-z80(MIT)で公開しています。

https://github.com/logicia32/lab-z80https://github.com/logicia32/lab-z80

この記事は Zenn に初出したものを加筆・補足したものです ── Zenn の元記事を見る