お詫び (2020/4/14)
どうも、何かやらかしてしまったようで、 https://github.com/tttk-prj/spresense/tree/master/examples/nes に記載したビルド方法だと、抜けがあったようです。ひとまず、暫定の方法を上記のREADME.mdに追記しました。
失礼しました。
#1. はじめに
喜ばしいことではないですが、新型コロナの影響で在宅ワークとなり、通勤時間分の時間的余裕が出来ました。
出来た時間を使って、放置していたSpresenseを使ってNESのエミュレータを作ってみたいと思い、オープンソースで見つけたNESエミュレータ「Cycloa」をSpresenseにポーティングしてみたのでこの記事を書いてみました。
(Qiitaだったり、Twitterだったり、日頃やってなかったものをやる良い機会と前向きに捉えて、自分なりのチャレンジです。)
目次
1.はじめに
2.Spresense
3.NES
4.NESエミュレータCycloa
5.Spresenseポーティング
6.ゲームコントローラ
7.ソースコードと動かし方
8.今後の予定
#2. Spresense
既に知っている人も多いのかもしれませんが、ソニーさんが出したマイコンボードです。
Spresense Overview
ARMのCoretex-M4が6つも乗っている、世にも珍しいボードです。最大動作周波数は156MHzとのことです。
IoT、エッジコンピューティングを歌っていますが、音も扱えるということで、「これならゲーム機も作れるよ」、ということなのか(?)、ソニーさんがデモでゲーム機を作って展示していたのを思い出しました。
で、今回、NESエミュレータも動かせるんじゃなかろうか、と思った次第です。
ちなみに、ソニーさんのゲームのデモ機はこれ。
https://plusstyle.jp/data/uploads/company/a_002.jpeg (Copyright © Plus Style Corp. All Right Reserved.)
このコントローラみたいな基板、販売してくれないかなぁ、なんて思っているのですが、無いものをねだっても仕方がないので、今回は以下のハードウェアを使いました。
Spresense Main Board | Spresense Extension Board | Arduino UNO LCD Connector board | ILI9341 2.2inch LCD | Game pad |
#3. NES
NESとは任天堂さんのファミリーコンピュータの海外での製品名だそうです。
そう! 今回、Spresenseというマイコンボードでファミコンのエミュレータを動かせないか、という試みです。
エミュレータを作るためには、何はともあれ、ターゲットとなるNESのハードウェアの仕様を概略でも理解しなければ、ということで調べ始めたところ、たくさんのサイトがあることが分かりました。その中でいくつか分かりやすいと思ったサイトを紹介します。
- NesDev ほぼ全ての情報があるのではないかというくらい色々なことが記載されています。英語ですが。。
- NES研究室 情報量はそれほど多くない(と言ったら失礼かな。。)ですが、日本語で記載されていてとても分かりやすいです。
- NES on FPGA FPGAを使ったNES互換機を作っているサイトです。日本語です。VerilogのRTLも公開されています。
これらのサイトを読めば詳しいことは全て書いてありますが、自分の頭の整理も兼ねて、以下に少しまとめます。
###3-1. NESハードウェアシステム構成
まず、NESのハードウェアの中は大きく、CUP、APU、Gamepad I/O、PPU、WorkRAM(2KB)、VideoRAM(2KB)、ROMカセットに別れるようです。
各ブロックの接続イメージを概略図にしてみました。
#####CPU
CPUには、MOS6502互換のRICOH製のRA2A03というICが使われているそうです。(日本などのNTSCの場合。PALの場合は異なる型番のICが使われているようです) CPUのバスには、音を作るAPU、ディスプレイ画像を作るPPU、GamepadのI/Oと2KBのWorkRAMが接続されています。 また、ROMカセット内のプログラムROMが外付けで接続されます。 上記の図ではAPUはCPUの外に書いていますが、実際にはRA2A03の中にインテグレートされているそうです。 動作周波数は21.477MHzのマスタークロックを12分の1にした1.7897MHzで動作するとのこと。SpresenseのCPUのクロックが156MHzなので、6つのCPUを駆使できれば、なんとかエミュレートさせられるのではないかと。
#####APU
APUはAudio Processing Unitの略だそうです。文字通り音の処理を行うハードウェア。音源としては、矩形波のチャネルが2つ、三角波のチャネルが1つ、ノイズチャネルが1つ、そして、DPCMのチャネルが1つの合計5つのチャネルがあり、これらをミキシングして、ゲームの音を作り出しているそうです。
#####PPU
PPUはPicture Processing Unitの略だそうです。カラーパレットを参照しながらバックグラウンドとスプライトのデータを重畳してディスプレイに出力するハードウェアです。PPUは独立したバスを持っていて、Video用の専用RAMが接続されています。また、ROMカセットの中のキャラクタROMに接続されます。PPUは内部にDMAを持っており、CPUバスからデータを高速にやりとりすることができます。ただし、DMAが動作している時は、CPUはストールするようです。
PPUはV-Syncの期間に入ると、CPUに割り込みを出してそのタイミングを伝えます。
#####Gamepad I/O
ゲームコントローラーのI/Oです。上下左右、スタート・ストップ、A・Bボタンの8つのボタンの入力のためのインターフェイスです。 銃型のコントローラなどもあるので、そのほかのインターフェイスもあるのかもしれません。
余談ですが、昔、銃型のコントローラの仕組みを調べたことがあります。昔のテレビはブラウン管で、テレビの中で電子ビームを照射して、電子ビームが当たったところが光る、というものでした。その電子ビームを画面の左上から右に向かって走査して、右端に行くと次の段の左端に行き、右下まで行ったらまた左上に戻る、ということで画面に映像を映し出していました。 したがって厳密には画面上で光っているのは1点のみ、であとは人間は残像で映像をみている、ということになります。銃のコントローラはこれを利用しています。 銃口は実は光のセンサーで、走査線が銃口を向けた位置を通ると、センサーが反応して、そのタイミングで、走査線が何処を走っていたかが分かり、結果として銃口が何処を向いているか、が分かるそうです。その他にも色々と手法はあったようですが、このブラウン管の仕組みを利用した方式に、調べた当時、なるほど!、と思ったので、紹介してみました。
#####ROMカセット
ゲームなどのソフトウェアが書き込まれているROMです。中には、プログラムROMとキャラクタROMの2種類の領域に別れており、それぞれ、CPUバスとPPUバスに接続されています。プログラムROMの最大サイズは32KB、キャラクタROMの最大サイズは8KBです。 ただ、ゲームが複雑に、面白く(?)なるにつれて、このサイズでは足りなくなってきたことから、カセット側に特別な回路を仕込むことで、ROMサイズを拡張する工夫がされています。これをマッパー:Mapperというそうです。
ROMのサイズだけでなく、音声再生の回路が入ったり、ゲームをセーブする機能が入ったり、カセットを使って拡張がされてきたそうです。
下記のサイトに写真付きで詳しく書かれています。
https://kohacraft.com/archives/201905121053.html
###3-2. メモリマップ
概略図にあるように、NESにはバスが、CPUバスとPPUバスの2系統あります。
CPUバスは16bitアドレスバスで、PPUバスは14bitアドレスバスだそうです。
それぞれのバスのメモリマップを、図にしてみました。
NESメモリマップ (参考:NES研究室 http://hp.vector.co.jp/authors/VA042397/nes/adrmap.html )
CPUバスとPPUバスはPPUを介して接続されているため、CPUから直接はアクセス出来ず、PPUのI/Oレジスタを介して行われます。
各レジスタの細かい仕様は、冒頭に紹介したサイトを参照してください。
#4. NESエミュレータCycloa
前置きが長くなりましたが、ここから、ソフトウェアの話になります。
エミュレータをイチから作る、ということも考えたのですが、調べていると世の中には、たくさんのオープンソースのNESエミューレータがあるようで、先人の知恵を借りるべきだと思い直しました。
問題はどのエミュレータを使うか、というところですが、以下の条件で探しました。
選択条件
- 標準C/C++で書かれているもの。
- OSなどのプラットフォームに依存したライブラリを利用していないもの(実装が別れているもの)。
- 構造がシンプルなもの。
条件1は、SpresenseのSDKがNuttXというPOSIXに準拠(?)したRTOSを使っており、標準のC/C++であれば、移植しやすいため。
条件2は、当然ですが、プラットフォームが異なるので、実装が別れていれば、やはり移植が楽なため。
条件3は、パフォーマンスを上げるためには、6つのARMコアを使う必要が出てきそうなので、改造がしやすいため。
で、この条件で選択したのが、「Cycloa」です。
このCycloaは、
- 標準のC++で実装されている
- 設計が非常にしっかりしており、プラットフォーム依存部分が「Fairy」というクラスで別れている。
- 構造も非常にシンプル。
と、検索条件を全て満たしていたのです。
これを見つけた時、「正にこの作業のために公開されているのでは!?」、と有りえない想像をしてしまうくらい、フィットしていました。
リポジトリのディレクトリ構成を見てもらえばわかりますが、
ソースコードもsrc/emulatorとsrc/fairyに別れており、emulatorがエミューレータ本体で、fairy側が画面の出力や音の出力、ゲームコントローラの入力といった、プラットフォームとの界面になります。
しかも、FairyはSDLライブラリを利用して実装されており、私が持つ、MAC/Linuxの環境ではサクッと動かすことが出来ました。
クラスの構造を簡単な図にしてみました。(UMLではありません。。)
エミュレータコア部分は、ハードウェアの構成要素通りの分割がされています。
で、プラットフォームとインターフェイスしなければならない、Audio/Video/Gamepadがそれぞれ、Fairyというクラスを持っており、
この部分を各プラットフォーム用に実装すれば良いようになっています。
つまり、Fairy部分をSpresenseに合わせて実装すれば動くはず!、ということです。
ROMはiNES形式のフォーマットのファイルを読み込んでCartridgeのインスタンスに格納されます。
図には記載していませんが、各マッパーに対応するために、Cartridgeクラスを親クラスとして、各種マッパーの実装クラスがsrc/emulator/mapperディレクトリの中に入っています。
iNESファイルフォーマット
エミュレータでROMカセットの内容はファイルとして提供されますが、プログラムROM、キャラクタROM、更にマッパーの内容などを分ける必要があり、そのために特殊なファイルフォーマットが必要になります。世の中にはいくつものフォーマットが存在するようですが、iNESというのが定番のようです。
詳しくは、以下に書かれています。
https://wiki.nesdev.com/w/index.php/INES
#5. Spresenseポーティング
それではいよいよポーティングです。
5-1. 環境構築
私の環境はmacを使っていますが、ソニーのSpresenseのサイトをみて、環境構築をしました。SpresenseのSDKのバージョンはv1.5.1です。
マニュアル通り行うことですんなり環境構築完了。
5-2. Step1:シングルCPUで動かす
まずはCycloaをシングルCPUで動かす、というところからスタートしました。
何はともあれ、CycloaをSpresenseのSDK上でビルド出来ないことには。。
CycloaをSpresenseで動かすために行ったことは、
- try/catchには対応していないようなので、まずはCycloaのException周りを削る。
- iostreamのようなC++の標準ヘッダが無いので依存する実装を削る。
- Spresense用のFairyの実装
- ビルドのためのMakefileの作成
です。
1は、CycloaのExceptionを見ると、Fatalなエラーケースと判断して良さそうなので、それが起きた時には全てをwhile(1)でハングさせてしまう、という割り切りにしました。
2は、外からライブラリを持ってくることは出来るようですが、メモリのフットプリントが勿体無いと感じたのと、Cycloaがstd::stringとstd::move程度しか利用していないことから、charポインタに置き換える、という対策にしました。
NESエミュレータCycloaで記載したように、Cycloaには、Fairyと呼ばれるポーティングレイヤがあります。
3は当然、といえば当然ですが、VideoFairy / AudioFairy / GamepadFairyの3種類のFairyをSpresense用に実装する必要があります。
Spresense Video Fairyの実装
オリジナルのCycloaでSDLを使って実装されているSDLVideoFairyを眺めると、
VideoFairyはdispatchRendering()をオーバライドして実装すれば良いということがわかりました。
引数に渡ってくる、nesBufferにはNESの画面サイズ:256x240のピクセルのデータが入っており、それぞれのピクセルデータはカラーパレットのインデックスに対応しており、そのパレットにしたがって実際の画面の色をピクセル単位で変換して絵を作っています。
for (int y = 0; y < screenHeight; y++) {
line = reinterpret_cast<uint32_t *>(line8);
for (int x = 0; x < screenWidth; x++) {
line[x] = VideoFairy::nesPaletteARGB[nesBuffer[y][x] & paletteMask];
}
line8 += pitch;
}
ここに出てくる、nesPalleteARGBは、固定値で、
const uint32_t VideoFairy::nesPaletteARGB[64] =
{
0x787878, 0x2000B0, 0x2800B8, 0x6010A0, 0x982078, 0xB01030, 0xA03000, 0x784000,
0x485800, 0x386800, 0x386C00, 0x306040, 0x305080, 0x000000, 0x000000, 0x000000,
.....
0xFFFFFF, 0x90D0FF, 0xA0B8FF, 0xC0B0FF, 0xE0B0FF, 0xFFB8E8, 0xFFC8B8, 0xFFD8A0,
0xFFF090, 0xC8F080, 0xA0F0A0, 0xA0FFC8, 0xA0FFF0, 0xA0A0A0, 0x000000, 0x000000
};
というテーブルになります。このビットパターンは、赤8bit、緑8bit、青8bitの24bitの値になります。
今回使うのは、冒頭に記載した、ILI9341のLCDボードです。このボードは、16bitカラーで、RGB565というフォーマットを使います。
赤5bit、緑6bit、青5bitの計16bitです。
そのため、このテーブルをILI9341に合わせた固定値を変更する必要がありますが、逐一ビットパターンを変換して記載するのは面倒なので、
マクロを作って対応しました。
#define ARGB2RGB565(x) (uint16_t)( \
((((uint32_t)x)&(0xF80000))>>8) | \
((((uint32_t)x)&(0x00FC00))>>5) | \
((((uint32_t)x)&(0x0000F8))>>3) )
これを使って、それぞれの値をこのマクロでくくることでRGB565用のカラーパレットを作っています。
const uint16_t nesPaletteRGB565[64] =
{
ARGB2RGB565(0x787878), ARGB2RGB565(0x2000B0), ARGB2RGB565(0x2800B8), ARGB2RGB565(0x6010A0),
ARGB2RGB565(0x982078), ARGB2RGB565(0xB01030), ARGB2RGB565(0xA03000), ARGB2RGB565(0x784000),
.....
ARGB2RGB565(0xFFF090), ARGB2RGB565(0xC8F080), ARGB2RGB565(0xA0F0A0), ARGB2RGB565(0xA0FFC8),
ARGB2RGB565(0xA0FFF0), ARGB2RGB565(0xA0A0A0), ARGB2RGB565(0x000000), ARGB2RGB565(0x000000)
};
残りは、dispatchRendering()の実装です。
とその前に、SpresenseとILI9342のボードを接続してLCDに画像を出力するための準備が必要です。
それらしいサンプルを探していたら、spresense/examples/cameraに同じLCDを使ったサンプルがあることがわかりました。
ただ、この実装だと、NuttXのnxというグラフィックライブラリが間に挟まっています。オーバーヘッドとフットプリントの観点から、出来るだけ余計なものは挟みたくないと思い、このコードを元にLCDのドライバを直接叩く方法を探りました。
#include <nuttx/board.h>
#include <nuttx/lcd/ili9340.h>
struct lcd_dev_s *lcddev;
struct lcd_planeinfo_s pinfo;
board_lcd_initialize(); // LCDドライバの初期化
lcddev = board_lcd_getdev(0); // LCDドライバインスタンスの取得
lcddev->getplaneinfo(lcddev, 0, &pinfo); // LCDドライバのメソッド(?)の取得
lcddev->setpower(lcddev, 1); // LCDをON
上記のコードでLCDが初期化され、pinfoに入っているメソッドを叩けば、描画がされます。
画面全部を描画するメソッドは無く、1ラインごとに描画するputrun()というメソッドがあったので、ひとまずこれを使います。
pinfo.putrun(y座標, x座標, 1ライン分のピクセル値の入ったバッファポインタ, ラインのピクセル数);
で、出来上がったdispatchRendering()がこれです。
void SpresenseVideoFairy::dispatchRendering(
const uint8_t (&nesBuffer)[screenHeight][screenWidth],
const uint8_t paletteMask)
{
uint16_t *pix = &lcdFrame[0];
uint16_t *line;
for (int y = 0; y < screenHeight; y++) {
line = pix;
for (int x = 0; x < screenWidth; x++) {
*pix++ = VideoFairy::nesPaletteRGB565[nesBuffer[y][x] & paletteMask];
}
pinfo.putrun(y, line_col, (uint8_t *)line, screenWidth);
}
}
これでStep1のVideoFairyの実装は終わりです。
これだけ。。これで良いのか、と思ってしまうくらい。。Cycloaの設計の良さが光ります。
Spresense Audio Fairyの実装
続いて、AudioFairyです。
オリジナルのCycloaのSDLAudioFairyを眺めると、
AudioFairyの親クラスに音データのローカルバッファを持っていて、エミューレータコアで作られた音をこのバッファにコピーする実装が入っています。
inline int pushAudio(int16_t *buff, int maxLength) {
...
}
SDLAudioFairyの実装では、定期的にSDLからのコールバックによって音データの要求がくるので、その要求に合わせて親クラスがコピーしたデータをpopAudio()で取り出し、SDLのオーディオのバッファにコピーすることで音を出しています。
void SDLAudioFairy::callback(void *data, Uint8 *stream, int len) {
....
const int copiedLength = me->popAudio(buffer, maxLen);
....
}
pushAudio()は、popAudio()によってデータが引き抜かれず、書き込むサイズ分の空きがローカルバッファに無い状態だと、エラーとしてfalseを返す実装になっていますが、それを呼び出しているAudioクラスでは、エラーのハンドリングをしていないため、無視されます。
今回のStep1では、まずは絵を出して動かす、という所に重点を置くため、Audioは後回しにすることにします(できます)。
空のSpresenseAudioFairyクラスを作ればまずは終了です。
class SpresenseAudioFairy : public AudioFairy {
public:
SpresenseAudioFairy(){};
~SpresenseAudioFairy(){};
};
Spresense Gamepad Fairyの実装
最後はGamepadです。
Gamepadはというと、上下左右、スタート・セレクト・A・Bの8つのボタンが押されている/押されていない、をEmulatorに伝える処理を行います。
SDLのGamepadFairyの実装を見ると、onVBlank()で、各ボタンの現在の状況を取得し、isKeyPressed()で指定されたボタンが押されているかどうか、を応答するというコードになっています。
この他に、onUpdate()というメソッドがありますが、これは空実装になっていました。 呼び出し元を辿れば目的が見えてくると思いますが、ひとまずはSDLの実装に習って、ここは考えないことに。。
Spresenseはマイコンボードなので、適当にGPIO端子を決めてInputとし、その端子にボタンを接続して、
ボタンが押された、押されていない、を端子のLow/Highで判断する、とすることで入力処理を行うことになります。
幸いにして、SpresenseのGPIOにはPullUp抵抗付きのInputというモードがあるようなので、端子に直接ボタンを接続して、ボタンの片方をGNDに接続すれば、事足りそうです。
回路のイメージは以下のようになります。
この場合、ボタンが押されていなければ、端子の入力はHighとなり、ボタンが押されるとLowになります。
後は、どの端子に何を接続するかを決めて、GPIO端子の設定を、PullUP付きのInput端子に設定して、端子の状態を取得すれば、OKです。
今回はLCDとのレイアウトを考えて、Spresenseのメインボード上の端子を使うことにしました。
# define BUTTON_SELECT PIN_I2S0_BCK
# define BUTTON_START PIN_I2S0_LRCK
# define BUTTON_A PIN_UART2_RTS
# define BUTTON_B PIN_UART2_CTS
# define BUTTON_DOWN PIN_EMMC_DATA2
# define BUTTON_LEFT PIN_I2S0_DATA_IN
# define BUTTON_UP PIN_I2S0_DATA_OUT
# define BUTTON_RIGHT PIN_EMMC_DATA3
後は、GPIOの設定と読み込み部分を実装すれば良いだけ!、ですが。。。
Spresense SDKのexamplesにGPIOのサンプルが、、無い!? そんな基本的なサンプルが無いなんて。。。
と色々と調べていたら、spresense/sdk/system/gpioというのがありました。
これを参考に、PullUP付きの入力端子に設定するコードが以下になります。
(基本機能についてのサンプルは入れてもらえたらなぁ。 次はPullRequestに挑戦してみようかな。)
#include <arch/board/board.h>
#include <arch/chip/pin.h>
// Aボタンの設定
board_gpio_write(BUTTON_A, -1);
board_gpio_config(BUTTON_A, 0, true, true, GPIO_PULL_STATE);
// Bボタンの設定
board_gpio_write(BUTTON_B, -1);
board_gpio_config(BUTTON_B, 0, true, true, GPIO_PULL_STATE);
....
// 左ボタンの設定
board_gpio_write(BUTTON_LEFT, -1);
board_gpio_config(BUTTON_LEFT, 0, true, true, GPIO_PULL_STATE);
// 右ボタンの設定
board_gpio_write(BUTTON_RIGHT, -1);
board_gpio_config(BUTTON_RIGHT, 0, true, true, GPIO_PULL_STATE);
後は、onVBlank()でGPIOの端子の値を読むコードを入れて、isPressed()で指定されたボタンの状態を返せばOK。
uint8_t SpresenseGamepadFairy::get_button(int pin)
{
return board_gpio_read(pin) == BUTTON_PORARITY_PUSH ? 1 : 0;
}
void SpresenseGamepadFairy::onVBlank()
{
state = 0;
state |= get_button(BUTTON_UP) ? GamepadFairy::MASK_UP : 0;
state |= get_button(BUTTON_DOWN) ? GamepadFairy::MASK_DOWN : 0;
state |= get_button(BUTTON_LEFT) ? GamepadFairy::MASK_LEFT : 0;
state |= get_button(BUTTON_RIGHT) ? GamepadFairy::MASK_RIGHT : 0;
state |= get_button(BUTTON_A) << GamepadFairy::A;
state |= get_button(BUTTON_B) << GamepadFairy::B;
state |= get_button(BUTTON_START) << GamepadFairy::START;
state |= get_button(BUTTON_SELECT) << GamepadFairy::SELECT;
}
bool SpresenseGamepadFairy::isPressed(uint8_t keyIdx)
{
return ((state >> keyIdx) & 1) == 1;
}
エントリー関数の作成
Spresense SDKは、NuttXというRTOSが使われています。 このNuttX、POSIXにできる限り準拠したインターフェイスをもつマイコン向けのOSとのことで、NuttShellというシェルも持っています。 Spresense SDKで自分のプログラムを作る場合、このシェルで実行できるコマンドを作る、ということになります。
まずは、ユーザアプリの追加方法:ツールを使用するを参考に、空のコマンドを追加します。
$ cd spresense/sdk
$ tools/mkcmd.sh nes "NES emulator by using Cycloa"
これでspresense/examplesの下にnesというディレクトリが出来、その下にnes_main.cというファイルが出来上がります。
今回、c++で実装しているので、エントリもC++にしたい、ということで、examples/helloxxを参考に、ファイル名をnes_main.cxxに変更して、Makefileのnes_main.cの箇所をnes_main.cxxにします。
nes_main.cxxの中はSDLの実装を参考に、Video/Audio/GamepadのそれぞれのFairyのインスタンスを作って、VirtualMachineのコンストラクタに渡し、HardReset()をvmに送った後はwhile(1)でひたすらvm.run()を実行するだけです。
ただし、ここで注意点! SDLのエントリーポイントの実装では、各インスタンスはmain関数の中、つまりスタック上に生成されていました。
Spresenseの場合、スタックサイズはデフォルトで2KBしかなく、インスタンスを置けません。 なので、グローバル変数としてインスタンスを作成します。
(こうしないと動かない。。。 これにハマって気がつくのに時間がかかってしまいました。。)
static SpresenseVideoFairy videoFairy;
static SpresenseAudioFairy audioFairy;
static SpresenseGamepadFairy player1;
static VirtualMachine vm(videoFairy, audioFairy, &player1, NULL);
extern "C" int nes_main(int argc, FAR char *argv[])
{
...
vm.loadCartridge(argv[1]);
vm.sendHardReset();
while(true) {
vm.run();
}
}
後は、Makefileにそのほかのソースコードを追加してビルド出来るようにします。
最後に、LinuxのKernelのように、Kconfigで必要なコンポーネントを有効にします。
まずはデフォルトに加えて、LCDドライバを有効にするだけで良いです。(Audioはこのステップでは使わないため)
あ、それと、追加したnesを有効にしなければなりません。これにも少しハマりました。
(追加したんだから、デフォルトで有効にしておいてくれればいいのに、どうしてデフォルト無効になるようにしたんだろう。。)
後はビルドして動かすだけです。
結果は、動きます!、たった上記の変更だけで動くなんて!! Cycloaおそるべし!、です。
NuttXも本当に移植性が高いと感じました。
そこまでは良かったのですが、、、。 遅い。。。
1フレーム描画するのに、約__110ms__もかかります。。。
やはり、他のCPUに処理を分散しないと。。
ということで、Step2で、VideoFairyの処理を別のCPUで動作させてみました。
5-3. Step2:別のCPUにVideoFairyの処理をオフロード
ということで、マルチコアの開発です。参考にしたのは、
- SDKのソースコードに入っている、examples/asmpにマルチコアのサンプル
- APSの記事 【マルチコア・デバッグ超入門】サブコアとメインコアをシームレスにデバッグ
- APSの記事 マルチコア・アプリケーション実践開発ガイド
- Spresenseドキュメント Spresense開発ガイド:ASMP
辺りです。
サブコアでは、ドライバを利用出来なそうなので、まずは、VideoFairyのLCDへの描画部分を抜いた部分を処理させます。
マルチコアではメモリのアクセス競合による性能劣化も考慮しなくてはなりません。
Spresenseは1.5MBのメモリが128KBごとに12個のタイル(?)に分割されていて、タイル毎であれば並行してメモリアクセスが出来るようです。
LCDへの出力バッファへの描画とLCDへの出力、Emulatorでの描画、が並行出来るようにするには、それぞれ用に別のタイルのバッファを確保する必要がありそうです。
NuttXはpthreadも対応していたので、以下のように設計しました。
VideoFairy::dispatchRendering()のマルチコア化
サンプルコードのasmpを参考にサブコアのプログラムを作成します。
worker側(SpresenseのサブコアのプログラムをSpresenseSDKではworkerと呼ぶようです)
static void do_frame_rendering(struct RenderRequestContainer *req)
{
....
for (pix_pos = 0; pix_pos < 240 /*screenHeight*/ * 256 /*screenWidth*/; pix_pos++) {
*vbuf++ = nesPaletteRGB565[frame[pix_pos] & mask];
}
}
int main(void)
{
....
while(1)
{
ret = mpmq_receive(&mq, &msgdata); // メインコアのメッセージQueueの受信待ち
struct RenderRequestContainer *req = (struct RenderRequestContainer *)msgdata;
do_frame_rendering(req); // レンダリング処理
ret = mpmq_send(&mq, MSG_ID_SAYHELLO, msgdata); // レンダリング結果をメインコアに戻す
}
}
メインコア側では、dispatchRendering()を修正し、別スレッドへのリクエストの送信と処理が終わったバッファの受け取りを行う実装にします。
// サブコアとの橋渡しとLCDドライバを利用して描画するthread
void *RenderEngine::render_thread(void *param)
{
....
while(1)
{
request = engine->popFromRequest(); // dispatchRendering()からのリクエスト待ち
send_render_request(request); // workerに描画リクエスト
recv_render_request(); // workerからの完了待ち
// LCDへの出力
ili9340_putall(0, 0, engine->vFairy->line_col, 240, 256, (uint8_t *)vbuf_v2p(request));
engine->pushToEmpty(request); // メインスレッドへバッファを送信
}
}
uint8_t * SpresenseVideoFairy::dispatchRendering(
const uint8_t (*nesBuffer)[screenWidth],
const uint8_t paletteMask)
{
// 別スレッドに描画要求 (dummyという引数は試行錯誤の結果で、削除予定です。。)
render_engine.requestRender(0/*dummy*/, NULL/*dummy*/, (uint8_t)paletteMask);
// 描画が完了しているバッファを取得
return render_engine.getRenderVRAM();
}
後は、CycloaのVideo.cxxでdispatchRendering()を呼び出している箇所を修正し、ダブルバッファに対応させます。
screenBuffer = (uint8_t (*)[Video::screenWidth])this->videoFairy.dispatchRendering(screenBuffer, this->paletteMask);
これで、VideoFairyのサブコア化は完了です。
実行してみると、、
平均フレームレート:75ms。。約1/3程度改善しました。 とはいえ、まだまだです。
NTSCはインターレースですが60fpsなので、75ms -> 16msまで改善しないといけない。。
次の施策は、、Video.cxxの処理をオフロードする、というところかな。
Cycloaにもう少し手を入れなければならないので、もう少し、どこでインターフェイスするかなど検討が必要なので、
この記事ではひとまずここまでで。対応方法が決まって改善したら、また記事を書きます。
(それと、今回のSpresenseのASMP、こちらも色々と面白いと感じたことがあったので、別の記事を書きたいと思っています。)
#6. ゲームコントローラ
今回購入したゲームコントローラはUSB接続のコントローラーです。
こちらは、ボタンの部分をSpresenseのGPIOに繋げるために、改造が必要です。
まずは、コントローラの後ろのネジを外して、開きます。
ここに写っている基盤を取り出し、ボタンに対応した部分の半田付けをして信号線を外に出します。
後は、組み上げ直して、自分で決めたGPIOに接続すれば、完成です。
#7. ソースコードと動かし方
今回の記事で動かしたソースコードは、以下のGitHubに上げておきました。
コード内容 | リポジトリURL |
---|---|
Cycloa修正分 | https://github.com/tttk-prj/Cycloa |
SpresenseSDK修正分 | https://github.com/tttk-prj/spresense |
ビルド方法及び書き込み方法、動かし方は、
https://github.com/tttk-prj/spresense/tree/master/examples/nes
の__README.md__に記載しておきました。
動かしたいiNESファイルをmicroSDカードに"game.nes"というファイル名でコピーして、そのカードを、書き込みが完了したSpresenseのExtensionボードのmicroSDカードスロットに差し込み、起動すればそのnesファイルが起動します。
で、romイメージファイルはというと、Cycloaのリポジトリには、フリーのromイメージがいくつか入っていますので、
この中から適当なものを"game.nes"としてSDカードに書き込めば、動きます。
#8. 今後の予定
今回のポーティングでは、フレームレートが75ms程度までしかでませんでした。
この先の改善には、CycloaのVideo.cxxの中の処理をサブコアにオフロードする必要がありそうです。
次はここを進めていき、また記事を書きたい、と考えています。
これからが大変だとは思いますが。。。
また、今回、Spresenseのコードを実装するのに、
Makefileの書き方だとか、Workerのビルド方法だとか、色々と苦労した点がありました。
こちらもノウハウとして、記事に書けたら、と思っています。
以上です。思った以上に長ったらしい記事になってしまいました。。
最後まで読んでくれた方、ありがとうございました。
説明が下手な部分もあると思いますが、
至らない部分はソースコードが全てを語ってくれる、と信じてます。(言い訳。。)