かずの不定期便ブログ

備忘録代わりに書きます

FPGAでOPMを鳴らしてみた(実機編)

目次

FPGAでOPMを鳴らしてみた(実機編)

 今回は実機編です。リアルにサウンドを鳴らす必要がありますのでSIMでは不要だったDACの準備, jt51をX68000と同じ4MHzで動作させないといけません。更にRAMのモデルはSIMモデルでしたが、PYNQに実装されているSDRAMをRAMとして使うことします。
#2020/8/24 コメント頂きまして記事の修正を行いました。サウンドが直りました!

レシピ

 実機で動かすためのモデルを以下のようにリストアップしました。青文字はSIMから流用可能なもの。緑色文字は修正が必要なもの。赤文字は新規に作成するものです。緑色文字であってもほぼゼロから作りなおした気がします。

  1. 68Kコア(TG68)
  2. YM2151(jt51)
  3. 割込みコントローラ
  4. 68K→YM2151バスブリッジ回路(クロック乗り換え必要)
  5. 68K→AXIバスブリッジ回路(クロック乗り換え必要)
  6. CLK生成モジュール(68Kコアクロックとjt51用クロック生成)
  7. DAC駆動用jt51出力→I2S変換
  8. DAC(I2S仕様)
  9. レジスタインターフェース
  10. Cプログラム

 こうしてリストアップするとほとんど新規ですね。チェック用にSIM時間含め、小一時間で作業可能なものから数日かかったものもあります。順に簡単に説明しますと、

クロック決め

 68Kコア(TG68)のクロックを決めなければいけません。X68Kでは10MHzなのですが、ここはTG68が合成可能な最大周波数で決めたいと思ってTG68単体をまず合成したところ33MHz程度いけましたので33MHzとします。(ただ、今思えばjt51へバス直結が可能かもしれない10MHzの方がよかったかもしれません)
 jt51はX68Kと同じ4MHzです。

DAC選定

 I2S I/Fで動作するDACならなんでもいいかと思いますが、ラズパイでも動く「UDA1334A搭載 I2S ステレオDACモジュール」を使いました(スイッチサイエンスで購入)。1000円程度です。DAC自体の動作確認がラズパイで行えますので安心です。なんでもいいとは書きましたが62.5KHzが受けられるDACが良いです。UDA1334Aのデータシートを見ると16KHz~100KHzが受けられます。Interpolation filterも内蔵されてていい感じにしてくれそうです。また62.5KHzを受けるためにはSYSCLK/PLL1端子を"H"入力としなければいけないところに注意が必要です。本端子はボード上でpull downされてますので、PYNQの汎用ポートをつなげて"H"駆動します。

68K→YM2151バスブリッジ回路(クロック乗り換え必要)

 ASXを33MHz→4MHzへ載せ替えた後、DTACKXを返します。jt51のbusy(dout[7]で監視できます)が"1"の時は"0"になるまでDTACKXを返さないような仕組みも付けました。しかしbusy観測は不要だと思います。少なくともhootで使われる音源ドライバはbusyが"0"になってるのを確認後、レジスタへのライト動作を行っているようです。割と面倒だったので、68Kコアは10MHzの方が良かったかもしれません。

68K→AXIバスブリッジ回路(クロック乗り換え必要)

 68K側からバスアクセスがあるたびにAXI4Liteのシングル転送を行います。PYNQに実装されているSDRAM512MBの内、後半256MBを利用するようにAxADDR[31:24]=8'h10とします。
 バス幅は32bitとしました。64bitにしても本使い方では16bitしか有効にならないので無意味と判断。
 X68KDRAMへのバスアクセスはnowaitらしいですが、PYNQでのAXIアクセスはLatencyが大きいので、1バスアクセスをAXI4-Liteアクセスに変換するだけだと、アクセスのたびに本Latencyが付いてしまい、とてつもなく遅くなることが予想されます。最終的にFPGAで鳴らした音を聞いてみたところ、レジスタの設定に間に合ってない風な音(あくまで予想です)が時々出るので、きっとこれが原因なのではないかと思っています。なんらか高速にアクセスが出来る様に対策をしないといけないようです。
<2020/8/24修正&追記>
 コメント頂きましてjt51コアを最新に差し替えることでこのような音はしなくなり大変良いサウンドが鳴っております。不具合予想は外れました。

CLK生成モジュール(68Kコアクロックとjt51用クロック生成)

 PYNQというかZYNQに内蔵されているMMCMでクロックを作成します。
 外部クロック入力は100MHzなので、パズルを解くようにD値、Q(DIV)値を考えると33.16062MHzというクロックが作成できました。
 jt51用のクロックはQ(DIV)値の設定範囲内では分周が足りなくて8MHzまでしか生成できなかった為、MMCMの外で1/2を行い4MHzを作成しました。

DAC駆動用jt51出力→I2S変換

 UDA1334Aのデータシートを眺めてI2Sのインターフェース仕様を理解します。が、、、音声データフォーマットがなんなのか読み取れませんでした。とりあえずwavフォーマットと同じsigned 16bit形式で流し込んだら鳴りました。形式が間違ってたらイヤホンからピーキーな音がして壊れたりしないだろうかと心配でした。後、データシートにはシリアルデータの送信順がMSBからと読み取れますが、MSBの次にB2と記載されているのですが、どういう意味なのか分かりませんでした。2番目のビットって意味かなぁ。これについても何も考えずMSBから流し込んであげれば鳴りました。すべて結果オーライ的な感じです。
 jt51からはsample毎に音声データが生成されますが、cen_p1が"1"の区間でclk入力を数えると32clkに一回sampleが生成されています。16bit signedデータ2ch分をシリアルデータとして送信するには32clockかかるので、毎サイクル送信すれば、余りも飛び越しもなくピッタリです。
 UDA1334AのデータシートによるとLEFTが先なので、まずWS="0"として16cycかけてMSBファーストで左chデータを送ります。次にWS="1"にして16cycかけてMSBファーストで右chデータを送ります。送信クロックであるBCKはjt51への入力クロックとcen_p1を論理どりした信号で生成しています。以下がそのRTLです。YMCLKはjt51へ与えるクロックです。
githubに置いてある版
GitHub - jotego/jt51: YM2151 clone in verilog. FPGA proven.
と、opencoresのサイトに置いてあるもの
Overview :: JT51 - YM2151 compatible core :: OpenCores
で端子仕様が少し異なっていて、前者の方を利用しています。

jt51はgit hubの以下のものを利用させていただきました。
GitHub - jotego/jt51: YM2151 clone in verilog. FPGA proven.

#記事を書いた当時のRTLは少し古く2020/8/24現在の最新版はkey-onの問題が修正されているとの事で早速適用したところ音が完全に正常化しました!ホテゴ様ありがとうございます。自分の不具合予想は外れてました

<2020/8/24 ホテゴ様からのご指摘で記載を修正&適用RTLのRevUP>

// YMCLK はjt51へ与えるクロックです。
reg cen_p1, n_cen_p1 ;
always@(posedge YMCLK or posedge YMRST)
    if(YMRST)
        cen_p1 <= 1'b0;
    else
        cen_p1 <= #1 ~cen_p1;

always@(negedge YMCLK or posedge YMRST)
    if(YMRST)
        n_cen_p1 <= 1'b0;
    else
        n_cen_p1 <= cen_p1;

// negedge clkで作ったFFとclkと&しているのでヒゲは出ないです。
wire BCK = n_cen_p1 & YMCLK;

 

DAC(I2S仕様)

 DACはUDA1334Aを使ったわけですが、制御端子のレベルを下表に示します。またI2S I/Fとして利用したPYNQの端子も併せて記載します。冒頭にもかきましたが"SYSCLK/PLL1"端子はボードでpull downされており、使用したいレベルとは異なるのでPYNQボードから1駆動します。

DAC端子 DACボード上での処理 PYNQ割り当て端子
SFOR[1:0] pull down -
SYSCLK/PLL1 pull down IO3:1を駆動する(レジスタ制御にする)
PLL0 pull down -
DEEM/CLKOUT pull down -
MUTE pull down -
I2SDAT - IO2
LRCK - IO1
BCLK - IO0

レジスタインターフェース

  1. SYSCLK/PLL1(初期値1)
  2. リセット制御(初期値0)
  3. STOPAXI(初期値0)

上から順にIO3への接続、回路初期化用、AXI部の初期化用
GPIO_0として作成します。

Cプログラム

 SDKから流しこむプログラムです。
 上記で作成したレジスタを使ってDACのSYSCLK/PLL1端子制御、回路のリセット解除を行います。
 リセット解除で68Kコアがブートし、音源ドライバを実行しjt51を動かします。リセット解除する前にSDRAMに音源ドライバやサウンドデータを書き込む必要があります。SDKGUIから行います。後ほど詳細に記載します。

vivado

 レシピにあげたモジュール類をsys_opmというmodule階層でまとめました。本回路のリセットをレジスタ経由で与えられるようにしました。リセットのレジスタ値をjt51のクロックで同期化したYMRST。その後TG68のクロックで同期化した信号RST68を作成するようにしました。このようにすることでリセット解除の順番がjt51→TG68となるのでTG68動作開始時に、まだjt51がリセット中であるという事態を避けることが出来ます。
 先ほど記載したcen_p1、n_cen_p1、BCLKの作成は本階層で行ってます。
 動作状態の目視用にLED[3:0]を使いました。

  • LED[0]:jt51の割り込み出力
  • LED[1]:jt51sample信号
  • LED[2]:AXIのRESP Errをラッチしたもの(正常なら点灯はしない)
  • LED[3]:レジスタ制御STOPAXIを繋いでいるだけ

 本階層での入出力ポートはAXIインターフェース以外は以下になります。

    // System Signals
    input wire        ACLK,
    input wire        ARESETN,

/* ~ AXIは省略  */
    /* GPIOに接続 */
    input       DACC,   // DACのSYSCLK/PLL1に繋ぐ
    input wire [1:0] SYSCTL,    // [1]:STOPAXI, [0]: SYSOPM(回路リセット負極性)

    output wire YM_IRQ,     // LED[0] 正論理へ変換
    output wire YM_SAMPLE,  // DAC sampleタイミング LED[1]
    output wire AXI_ERR,    // LED[2]
    output wire ext_STOPAXI,    //
    /* I2S IF */
    output wire BCLK,
    output wire LRCK,
    output wire I2SDAT,
    
    /* DAC制御用     */
    output wire SYS_PLL1
    );

assign SYS_PLL1 = DACC;  //レジスタ設定値をそのまま出力
wire STOPAXI,SYSOPM;
assign {STOPAXI,SYSOPM} = SYSCTL;
assign ext_STOPAXI = STOPAXI;  //レジスタ設定値をそのまま出力


vivado操作

 vivadoでnew projectを作成し、Add Sourcesでsys_opmモジュールを含む全モジュールを読み込みます。sys_opm階層がtopモジュールになっており過不足が無いことを確認後、
 IP IntegratorでCreate Block Design→design_1。「add IP」で「ZYNQ7 processing design」追加後,Run Block Automationとします。

f:id:spend-carefree:20200524215236p:plain
sys_opmソース読み込み
 次にAXIインターフェースを使うので、Diagram上のZYNQをダブルクリックして「PS-PL Configuration」を選択し、「HP Slave AXI Interface」 →「S AXI HP0 interface」をチェックし、「S AXI HP0 DATA WIDTH」は64bitとします。これでZYNQにAXIslave i/fが生えます。
 次にレジスタとしてGPIOを追加します。「add IP」から「AXI GPIO」を選択。ダブルクリック。「IP Configuration」タブで「Enable Dual Channel」にチェックを行い、「GPIO」、「GPIO 2」両方とも「All Outputs」にチェック。GPIO Widthは「GPIO」側は1bitとし「Default Output Value」=0x1。「GPIO 2」側はWidth=2bitでdefault valueは0x00000000のままにしてください。sys_opmモジュールのinput端子DACC、SYSCTL[1:0]に対応します。
 Diagramには、まだsys_opmモジュールは載ってないので、Diagram上で右クリック→Add Module..を選択。sys_opmを選択します。(sytem verilog形式の.svは出てこないようです。)
f:id:spend-carefree:20200524221444p:plain
Diagram
 「Run Connection Automation」をクリックしてaxi_gpio_0→S_AXIをチェックして、GPIOの結線をS_AXI経由で、またprocessing_system7_0→S_AXI_HP0をチェックしてAXIの結線も行います。
 axi_gpio_0のGPIOとGPIO2を展開して、sys_opm_0の端子、DACCとSYSCTL[1:0]へ繋ぎます。
 LED[3:0]は{ext_STOPAXI,AXI_ERR,YM_SAMPLE,YM_IRQ}としたいので、concatモジュールを「add IP」してバンドル化します。またdout[3:0]をつかんで右クリック→Make externalします。dout[3:0]をLED[3:0]へrenameします。
 DACへ繋げる端子、BCLK, LRCK, I2SDAT, SYS_PLL1も同様にMake externalします。
 Valid Designします。
f:id:spend-carefree:20200524223538p:plain
diagram完成
 Sourcesペインでdesign_1を選択し、右クリック→Create HDL Wrapperを行いdesign_1_wrapperを作成します。更に合成対象とするために右クリック→Set as Topを選択します。これでDesign Sources配下にはdesign_1_wrapperのみとなるはずです。
 次にxdcファイルを読み込みます。以下の様に作成しました。

## LEDs
set_property -dict {PACKAGE_PIN R14 IOSTANDARD LVCMOS33} [get_ports {LED[0]}]
set_property -dict {PACKAGE_PIN P14 IOSTANDARD LVCMOS33} [get_ports {LED[1]}]
set_property -dict {PACKAGE_PIN N16 IOSTANDARD LVCMOS33} [get_ports {LED[2]}]
set_property -dict {PACKAGE_PIN M14 IOSTANDARD LVCMOS33 } [get_ports { LED[3] }]
# I2S
#CK_IO0
set_property -dict {PACKAGE_PIN T14 IOSTANDARD LVCMOS33} [get_ports BCLK]
#CK_IO1
set_property -dict {PACKAGE_PIN U12 IOSTANDARD LVCMOS33} [get_ports LRCK]
#CK_IO2
set_property -dict {PACKAGE_PIN U13 IOSTANDARD LVCMOS33} [get_ports I2SDAT]
#CK_IO3 SYSCLK/PLL1 -- DAC ctrl
set_property -dict {PACKAGE_PIN V13 IOSTANDARD LVCMOS33} [get_ports SYS_PLL1]
create_generated_clock -name ym_clk    -source [get_pins design_1_i/sys_opm_0/inst/clkgen/MMCME2_BASE_inst/CLKOUT1] -divide_by 2 [get_pins design*/sys_opm*/inst/clkgen/iYMCLK_reg/Q]
set_clock_groups -asynchronous -group i68KCLK -group clk_fpga_0 -group ym_clk

 最後から2行目の"create_generated_clock"はjt51用の4MHzクロックです。TG68用クロックはMMCMで生成させている為、自動で設定されるようでxdcファイルに記載しなくても大丈夫なようです。
 最後の行はFPGAへ入力されるクロック(clk_fpga_0 )、TG68のクロック(i68KCLK )、jt51のクロック(ym_clk)は非同期関係であることを合成ツールに教えてあげます。これらの設定は最初から分かっていたわけではなく、合成後(bit stream生成後)のSynthesis のレポートを見ると、クロック定義もれや、解析不要なパスがレポートされたので、後から追加したものです。
 後は「Generate Bitstream」を行い「File」→「Export」→「Export Hardware」を行います。Exportする前に合成がmetしているかSynthesis→「Open Synthesized Design」→「Report Timing summary」で確認します。実はTG68で約-48psのsetupのタイミング割れがあります(こっちは真正ですが小さいので無視することにしました)。JT51の非同期リセットを同期化しているFFのリセット端子、Hold側で一か所-1ns程度割れていますがこちらはfalseパスっぽいため無視しました。
 「File」→「Launch SDK」でSDKを起動します。

SDK

 「File」→「New」→「Application Project」で新規プロジェクトを作成し、適当なプログラムを作成します。

  • X68Kのメモリ領域としてPYNQのSDRAM空間0x10000000以降を使います。
  • hootドライバの代わりに0xE00000番地に0x1を書きこみます。
  • 本回路のリセット解除

これらを関数化したものです。一部だけ載せます。main関数ではGpioレジスタの初期化とか書き込み専用の設定をしてますが、FPGAプログラミング大全に載ってるので省略します。

#define SDRAM ((volatile unsigned char *) 0x10000000)

void play(unsigned char music_no) {
    *(SDRAM+0xE00001)=music_no;
    *(SDRAM+0xE00000)=0x01;
    Xil_DCacheFlush();
}

void init_buffer(void) {
    //*(SDRAM+0xE00001)=music_no;
    int i;
    for(i=0; i<4*1024; i++)
    	*(SDRAM+0xE00000+i)=0x00;
    Xil_DCacheFlush();
}
void stop(void) {
    *(SDRAM+0xE00001)=0x00;
    *(SDRAM+0xE00000)=0x01;
    // 制御コード初期化
    //*(SDRAM+0xE00004)=0x00; // ctrl
    //*(SDRAM+0xE00005)=0x00; // music no
    Xil_DCacheFlush();
}
void reset_opm(void) {
    int j;
    XGpio_DiscreteWrite(&GpioAddrOn, SYSOPM, 0);    // reset
    for ( j=0; j<100000; j++); /* 遅延を作成 */
    //XGpio_DiscreteWrite(&GpioAddrOn, SYSOPM, 1);    // reset解除
    
}
void reset_release_opm(void) {
    //int j;
    XGpio_DiscreteWrite(&GpioAddrOn, SYSOPM, 1);    // reset解除
    
}

 これらの関数を呼び出してmain関数を作ります。
play(musicno);
stop();
それぞれにブレイクを張ります。play関数の引数はmusicnoです。
stop();行にブレイクを張る理由は、この行で停止している最中も音源ドライバは動作しています。ここで、メモリウィンドウから0x10E00000、0x10E00001を書き換え事で再生ミュージックNoを変更できるためです。

作業

 まずはいつものように「Program FPGA」でビットストリームをFPGAへ書き込んでから、「Run」→「Debug Configuration..」でC/C++ application(System Debugger)でデバッグを開始します。main関数の最初で止まるので、このタイミングで、PYNQのSDRAMへ音源ドライバとサウンドデータを書き込みます。
 SDKGUIから行います。なんどもやると面倒になってくるので、スクリプト化させたいですが、やり方が分かりませんでした。GUIでの手順は以下です。
 上部メニューの「Xilinx Tools」→「Dump/Restore Data File」。Processor:Select→「Name=...」→「APU→ARM Cortex-A9 MPCore #0」。File Location:にはSDRAMへ読み込む(Restore)するファイル名を指定。Restore Memoryのラジオボタンにチェック。Start Addressはhootのxmlファイルに記述されていますが、ドラゴンスピリットであれば、"dempa.xml"の行が"[X68000] DRAGON SPIRIT"と記述されているところを検索し、romlistから読み込みファイル名を見ます。3ファイル読み込んでいるようで"dsopmdrv.bin"はoffsetの指定がないので、offset=0になり、PYNQで使う先頭アドレス0x10000000を打ち込みます。Size(in bytes)はサイズなので、そのファイルのサイズを打ち込みます。バイナリエディタでそのファイルを開けることをお勧めします。16進数でサイズを打ち込むなら0x始まりで入れます。OKボタンを押します。SDK Logのwindowを見て「INFO : Restored Contents to Memory Successfully from File xxxx」と出ていれば成功です。続けてサウンドデータ、PCMデータを読み込みます。PCMデータを鳴らす曲でなくてもhootのxmlに記載あるものは読み込みます。ドライバ以外はオフセットが記載してあるので、xmlに記載のoffset+0x10000000を忘れないようにしてSDKでファイルを読み込んでください。
 本当にSDRAMへ書き込めているか確認の為、メモリウインドウを開けます。SDKの右下のMemoryのところで+ボタンを押します。「Enter address or expression to monitor」と出るので、まず0x10000000を打ち込みます。すると0x10000000を先頭とするメモリウィンドウが開きます。バイナリエディタで開いているファイルの内容と比較します。SDKで見えてるのはlittle endianなので注意です。同様に他のファイルチェック用にメモリウィンドウを作成します。ドラゴンスピリットなら他に0x10010000,0x10020000になると思います。
 更にhootのwork領域、0x10e00000を先頭とするモニタウインドウも作成します。
 FPGAの電源を切らない限り、FPGAが暴走しない限り、SDRAMの内容は破壊されないので、最初からプログラムを実行させても、FPGAを焼きなおしても本作業は不要です。(が、うまくいくときと行かないときがあります。一度再生できない状態になると、PYNQのリセット、SDRAMへの書き込みを再度行わないとうまくいきませんでした)

Play!

 この状態で続きを実行します。「Resume」ボタンを押します。初回ではなぜかならないです。ならない場合は再度、虫アイコンをクリックして最初からプログラムを実行します。うまくいく場合は0x10e00000番地へplay時に"0x1"を書き込みますが68Kにより"0x0"へすぐに書き換えられます(勝手に書き換わるので文字が赤くなります)。ここがSDK(もしくはCA9が実行するプログラム)から書いた値0x1のままだと演奏出来ないようです。いまいち原因が分かりません。不安定です。
 さて、この状態で非常にクリアなサウンドがなるのですが、完全じゃないんです。微妙とかそういうのじゃなく、明らかにレジスタの設定がミスっている(遅れている、間に合ってない)というのが分かるサウンドなんです。とてもクリアな良い音だが、間違っているという感じです(伝わるかな?)。ZYNQに備わっているロジックアナライザを使って解析を試みたいのですが、なんだか難しそうで、やれてないです。音がおかしくなる手前からjt51バスの観測を開始し、割込み発生くらいまでを取得すれば見えてくる気がしますが、音がおかしくなるというトリガをどう作成すればいいかで悩んでます。それよりもさっさとAXIアクセスを改善したほうが早いかなと考えてます。
 不完全だけど、

 音はいい感じです。エミュレータと変わらないと思いますが。なんだかよい音バイアスがかかるのでしょうか?
#2020/8/24 追記
#git hubにあるjt51コアを最新版に置き換えたら直りました。jt51へのレジスタアクセスは間に合ってたらしい。。。不具合予想外れ。現在、CPU⇔AXIの間にデータキャッシュを実装してバストランザクション毎にWAITが入ってしまうのを修正してjt51へのレジスタアクセス期間を増やそうとしてたですが、ココが問題じゃなかったようです。

 後、jt51の出力値に乗算する回路を付加してVolume制御を付けた方がいいです。何もしないと結構大きな音です。うるさくはないですが長時間聞くのは辛いと思います。

original : jt51の出力
out : I2Sへ流すデータ
 out = (reg値(4bit)==0xf) ? original  : ( (original * reg値(4bit))>>>4);

こんな感じの回路を付加して16段階で調整できるようにするといいかもです。
 
おまけ。DACとPYNQを繋げてます。

f:id:spend-carefree:20200528224450j:plain
PYNQ with DAC

次回

 解決編を書きたいですが、現在まだ解決してないです。
#2020/8/24 追記
#git hubにあるjt51コアを最新版に置き換えたら直ってしまったので解決編はないです。

以上です。

前回は
spend-carefree.hatenablog.com