PC-8001 SD-DOS その2 ~割り込みでVGMを鳴らそうとして、結局ハマり倒した話~

PC-8001PC-8001 SD-DOS その2 ~割り込みでVGMを鳴らそうとして、結局ハマり倒した話~

前回、SD-DOSにストリーム読みを足して、VGM(ゲーム音楽のレジスタ書き込みログ)をロードしながら鳴らすところまでやりました。busy-loop方式のプレイヤー(VGMPLAY)で、ウェイト中にSDを先読みしてバッファに貯める作戦です。これはこれで最後まで鳴るようになったのですが、密な小節になると、SDの読み込みで演奏がプツッともたつくのが、ずっと気になっていました。

PC-8001のSDアクセスは8255のビットバンギングで、1バイト読むのに約0.4ミリ秒かかります。これを演奏の合間に同期でやると、その間だけ音が止まる。じゃあ、SDの読み込みを割り込み(Timer A)で「裏」に回して、CPUを止めずに鳴らせばいいのでは? という、いかにも筋の良さそうなアイデアに手を出したのが、今回の話です。

先にオチを言っておきます。結局、満足に安定はしませんでした。 道中はすごく勉強になったし、最後には音飛びも枯渇も潰したのですが、突き詰めると「割り込みにする旨味があまり無い」ことが分かってきて、根っこのSDが遅いのが全部の原因、という身も蓋もない結論に着地します。それでも、ハマりの中身がなかなか面白かったので、記録として残しておきます。

前提: 割り込み、どこから取る? ~232Cボードの「こんなこともあろうかと」~

そもそもPC-8001で、FMのTimer A割り込みをCPUに入れるには、割り込みの線とIM2のベクタを用意する仕組みがハード側に要ります。今回それを担ってくれたのが、以前自作したRS-232Cボード(PC8001ext232C)でした。

このボードは8251を2チャンネル積んだシリアルボードなのですが、作るときにロジックICでZ80のモード2割り込み(IM2)のベクタをデータバスに載せる回路を入れてあります。シリアルの2チャンネルぶん(Ch1→8008H、Ch2→800AH)に加えて、「こんなこともあろうかと」、空きの割り込み入力線を基板の外に引き出しておいたのです。将来、何かハード割り込みを使いたくなったとき用に。

その「いつか使うかも」が、今回でした。FMボードのTimer A割り込みの線を、この予備の割り込み入力(INT5)に繋ぐ。これでTimer Aがオーバーフローするとベクタ8004H経由で自前ISRが呼ばれる——という、今回の話の大前提が整います。逆に言うと、この予備線が無ければ、そもそも割り込み駆動なんて検討すらできなかったわけで。過去の自分の「こんなこともあろうかと」に、地味に助けられた格好です。……まあ、その配線でこの後さんざんハマるのですが、それは後の話。

232Cボードに「こんなこともあろうかと」引き出しておいた予備の割り込み線

設計はF-1 ~メインで演奏、割り込みでSD補充~

最初に方式を決めました。割り込みの使い方には何通りかありますが、採ったのは「メインスレッドはこれまで通りVGMを解析して音源に書き込む。Timer A割り込み(ISR)は、SDを1バイト読んでリングバッファに足すことだけに専念する」という形です(社内呼称F-1)。演奏のタイミングはメインのbusy-loopのまま、SDだけ裏で育てる、というイメージです。

ISRの中身はだいたいこんな感じです。SDの読み出しはレジスタを盛大に使うので、表のレジスタは裏レジスタ(EX AF,AF' + EXX)に退避してから呼びます。

`;-------------------------------------------------
; Timer A 割り込みハンドラ
;  ・タイマを再武装して、SDを1バイト補充するだけ
;-------------------------------------------------
ISR_FMTA:
        EX      AF,AF'
        EXX                     ; 表のレジスタを裏へ退避
        CALL    TA_REARM        ; Timer Aを再起動(次の割り込みを再武装)
        CALL    RB_TRYFILL1     ; 満タン/EOFでなければSDを1バイト補充
        EXX
        EX      AF,AF'
        EI
        RETI`

PC-8001はI=80HのままN-BASICのIM2ベクタテーブル($8000台)を使っているので、空いている割り込み1本ぶんのベクタエントリ2バイトだけを自前ISRに差し替えて、終了時に元へ戻す、という作法にしました。232C拡張ボードのシリアル受信割り込みとは別の線を使うので、共存できる…はずでした。

F-1の構成 ― メインで演奏、ISRでSD補充

Macにシミュレータを作って机上検証

いきなり実機で割り込みデバッグをやると、固まったときに何も分かりません。そこで前回も使った自作の小さなZ80エミュレータ(Python)を拡張して、サイクル数を数える機能割り込み(IM2)・YM2203 Timer Aのモデルを足しました。これで、VGMプレイヤをMac上で1命令ずつ動かして、各waitの理論時間とのズレ、リングバッファ残量、ISRがちゃんと発火しているか、を全部見られるようにしました。

実機のSD 1バイト受信が約678サイクル=339μsという実測値とも一致して、「よし、これで机上で詰められる」と意気込んでいました。…が、これが後で盛大に裏目に出ます。

シミュレータでは完璧、実機では無音

シミュレータ上では、F-1プレイヤは綺麗に最後まで鳴ります。テンポも妥当。意気揚々と実機に持っていって G9000無音。 メニューは出るし曲も選べるのに、選んだ瞬間に固まって、何も鳴りません。ここから長い長いデバッグが始まります。

まずは「どこまで進んでいるか」を画面に出す

固まる場所を切り分けるために、プレイヤに進行状況マーカーを仕込みました。N-BASICの1文字出力(RST 18H)で、O(OPEN)→H(ヘッダ)→P(プリフィル)→I(割り込み設定)と1文字ずつ表示し、SD補充待ちのタイムアウトで U、Timer Aのtickが来なければ X を出して、固まらずに先へ進むようにしました。

実機の画面に出たのは OHPIUK0OHPIまで行っているので割り込み設定までは到達。その後 U(バッファ枯渇)、そして K0 =「ISRが一度も発火していない」。つまりTimer A割り込みが一度も来ていない、と分かりました。

進行状況マーカーで固まる場所を切り分ける(OHPIUK0)

迷宮その1: VGMが勝手にタイマを止めていた

途中で立てた有力仮説の一つが、これでした。VGMデータ自身が、YM2203のレジスタ27H(Timer A制御レジスタ)を書いている。 27HはCH3モードとタイマ制御を兼ねていて、多くのVGMが曲頭やフレームごとに27Hを書きます。そこで LoadA=0 になると、裏で回している背景タイマが止まってしまう、という筋書きです。

これ、シミュレータで再現できました。 ただし、それまでのテストデータは27Hを書いていなかったのです。テストVGMに27H書き込みを足したら、シミュレータ上でもピタリと OHPIUK0 が再現。これが**「シミュレータが通って実機で死ぬ」の正体その1**です。シミュレータは「自分が想定した入力」しか試しておらず、自分の盲点をそのまま見逃していました。waitの先頭でタイマを再起動するように直して再現テストも通った——が、実機はまだ無音(K0)

迷宮その2: そもそも割り込みが来ていない

ここでようやく根本を疑い、VGMもSDも全部排除した最小テストで「割り込みは本当にCPUに届いているのか」を確かめることにしました。Timer Aを回して、ISRが入ったら数えるだけのプログラム(INTTEST)です。肝は、busy-loop中とHALT中の両方で待って、それぞれ何回入るかを比べる点です。

`; フェーズA: EIしてbusy-loop(数十ms)。その間にISRが入った回数を表示
; フェーズB: EIしてHALTを20回。     その間にISRが入った回数を表示
;
; 画面に "A=nn B=nn" と出る。読み方:
;   A>0          → busy-loop中でも割り込みは入る(別のバグ)
;   A=0 かつ B>0 → 割り込みはHALT中しか入らない(実機固有の制約)
;   A=0 かつ B=0 → 割り込み自体が来ていない(ベクタ番号違い等)`

ISRは再武装してカウンタを増やすだけ。実機で走らせた結果は…… A=000 B=000割り込みが、一度も、来ていない。

真犯人は配線でした

A=000 B=000 =「割り込み自体が来ない」。残るは「ベクタ番号(=どのINT線に繋がっているか)が、コードの想定と食い違っている」しかありません。ハードを見直したところ、FMボードの割り込み線の配線が間違っていました。 正しくは INT5 = ベクタエントリ 8004H。配線を直したら、INTTESTは A=024(busy-loop中でも割り込みが入る)になり、F-1プレイヤもちゃんと鳴り出しました。

ついでに勘違いも一つ晴れました。ベクタ番号の既定値は 04H で、ずっと「04H=INT4」だと思い込んでいたのですが、232Cボードの割り込みは74LS148(プライオリティエンコーダ)の反転で降順に並んでいて、ベクタ下位 = 2×(7-n) なのでした。基板の割り当てはこうです。

`8004H = INT5  ← 予備線。今回ここにFM Timer Aを繋いだ(コード既定 04H)
8006H = INT4  ← 予備線(空き)
8008H = INT3  ← 232C Ch1(8251)
800AH = INT2  ← 232C Ch2(8251)`

04H は「INT4」ではなく「テーブルのオフセット04H = INT5のスロット」だったわけです。配線をINT5に合わせたら、既定値 04H はそのままで正解でした。今回の長い無音の真因は、ソフトのどんな解析でもシミュレータでも絶対に見つけられない、ハードの配線でした。 それを暴いたのが、VGMもSDも捨てた最小の割り込み単独テスト。教訓は、シミュレータは「自分の思い込みを実装したもの」なので同じ思い込みのバグは構造的に見つけられないこと、デバイス側の挙動は最小単独テストか実機準拠エミュレータで確かめること、の2点に尽きます。

PC-8001のIM2ベクタは降順 ― INT5が8004H

動いた! が、今度は音符が飛ぶ

配線が直って鳴り出したものの、ここからが第二ラウンドでした。最初は「テンポが激遅」。これはISRがSDを読んでいる時間を、waitから精算するのを忘れていたのが原因で、読んだぶんだけウェイトを短くするようにしたらテンポは戻りました。

ところが今度は、密な小節でときどき音符が飛ぶ。 精算量を下げると飛びは減るがテンポが変になる、というトレードオフにハマりました。原因は精算の**「ラグ」。ISRが長い待ちの間に読んだぶんの精算を「次のwaitの先頭でまとめて適用」していたため、長い待ちで貯まった大量の精算が直後の短い音符に一気に乗っかって、長さゼロに潰していた**のでした。VGMPLAYでこれが起きないのは、同じwaitの中で同期に読んでその場で精算しているから(ラグが無い)です。

直し方は**精算をその場でやる(局所精算)**こと。ISRはSDを1バイト読むごとにカウンタを増やすだけにして、メインのbusy-loopが「今の待ちの中で」その増分を見つけ次第サンプルを即スキップし、次の待ちには持ち越さない。これで短い音符が潰れなくなり、正しいテンポと飛びゼロが両立しました。

精算のラグ ― 長い待ちの精算が直後の短い音符を潰す

長い曲で、後半が枯れる

短い曲はいい感じになったのですが、長い曲だと「最初はいいのに、だんだんSDに引っ張られてもったりしてくる」症状が。後半のバッファ枯渇です。SDの読みを長い待ちのときだけに限定したので、「1tickあたりの補充バイト数」を増やしても短い音符を邪魔しなくなり、長い待ちでまとめ読みしてバッファを満タンに保てるようになりました。リングバッファもN-BASIC高位ワークの手前ギリギリまで広げて20KBに増量。これで、よほど密でなければ最後まで枯れずに走り切ります。

そして気づく、ちょっと悲しい真実

ここまで来て冷静に振り返ると、厳しい現実が見えてきました。そもそもSDの1バイト読み(約0.4ms)を短い音符の最中にやると、その間ぶん演奏が止まって音が飛ぶ。だから「読んでいいのは長い待ちの間だけ」になります。ところが長い待ちの間はメインはどうせ暇でbusy-loopを回しているだけなので、そこで同期にSDを読む(VGMPLAYのやり方)のと割り込みで裏読みするのとで、結果がほとんど変わらないのです。

密な区間でこそ裏読みが効いてほしいのに、密な区間では読むと邪魔になるから読めない。長い待ちでは読めるけど、そこは同期読みでも困らない。割り込みにした旨味が、思ったほど無かった。 しかもタイマ管理のオーバーヘッドがあるぶん、むしろ不利ですらある。結局、密な区間の引っかかりの根っこはビットバンギングSDの速度そのもの(実効1.84MHzで毎秒2KB台が上限)で、VGMPLAYもVGMIRQも同じ壁の前で同じように引っかかる。壁の高さは変えられませんでした。

オチ: ビットバンギングSDの壁、次は40ピンPICでドライブ?

というわけで、割り込み駆動のVGMプレイヤは「動くし、音飛びも枯渇も潰せたけれど、VGMPLAYを明確に超えはしなかった」という、なんとも締まらない着地になりました。でも、シミュレータが思い込みを共有していると検証にならない教訓、最小テストがソフトでは見つからない配線ミスを一発で暴いた話、PC-8001のIM2ベクタが降順だった小ネタ、精算のラグで短い音符が潰れる嫌なタイミングバグ、と、ハマりの中身はとても勉強になりました。割り込みを実機で生かす作法(ベクタ差し替え、レジスタ退避、HALTとbusy-loopの違いなど)も一通り押さえられたので、無駄ではなかったと思うことにします。

そして何度も顔を出すこの「根っこのSDが遅い」問題。これはもう、下回りをマイコンで作り直して、ビットバンギングを卒業するしかなさそうです。当初はPico(RP2040)を考えていたのですが、それより40ピンのPICあたりが良さそうかもです。EMUZ80——1個の40ピンPIC(PIC18F47Q43)がZ80の周辺(メモリもI/Oも)を丸ごと面倒みる、というあの構成——を見ていると、SDカードも同じ発想で「速くて賢いPICにまるごと任せて、PC-8001側はシンプルに叩くだけ」にできそうです。下回りが速くて確実になれば、そもそも演奏中にSDを読むことの大半が悩みでなくなる。割り込みで裏読みなんて小細工も要らなくなるかもしれません。実効1.84MHzでこれだけ苦労したので、その下が速くなると、また景色がガラッと変わりそうです。その話は、また次回に。

関連リンク

PC-8001