かつかれーのメモ帳

実験ノートか勉強記録

複数のUSBファームウエアを自作コントローラーに乗せる

IIDX自作コントローラー記事のソフト編(その3)です。
以前の記事(その1, その2)で、2つの異なるUSB HIDゲームパッドの仕様を決めました。今回はこれらを1つのコントローラーに収め、切り替えながら動作させるための具体的な実装についてまとめます。当記事で一旦ソフト編は完結する予定です。

大雑把な基板の仕様

詳細は回路編に譲りますが、ファームウエア設計に関わる部分だけここで決めておきます。

  • ICとして、USBインターフェースの乗ったAVRを用いる。
  • ボタン・スクラッチ等の入力、LED等の出力も同じICで扱う。これにより、主要部をワンチップで完結させる*1
  • 同じ基板をIIDXだけでなくSDVXにも利用できる程度の汎用性を持たせる。

こんな感じ。ある程度多ピンのAVRを採用することになります。
要件を満たす物の中で入手性が良いのはATmega32U4ですかね。Arduinoとして実装済みの基板(Leonardo, Micro)が手に入る点もgoodです。しかし、値段がほぼ変わらないのに入出力ピンが多く取れて、遊ぶ余地があるAT90USB646の方が個人的には好みです*2
…こんな調子なので基板の仕様がきっちり固まりません。基板仕様のブレはファームウエア側で吸収するのが良さそうです。

ファームウエアの設計

まずは簡単な図から。
f:id:DSKK:20200603194916p:plain
ファームウエア仕様の概略。画像出典はこのブログとWikipedia
図からもわかる通り、コントローラーの構成要素は大きく分けて以下の二つです(左右に振り分けています)。後述のstate_structがこれらをつないでいます。

  • ハードウエア
    • 主に筐体+基板をハードウエアと呼んでいます。左側、IIDXの囲いの中の2つのコントローラー画像がそれぞれ異なるハードウエアに対応します。
    • 大雑把に機種ごとに括ることができます。
  • 動作モード
    • 画像の一番右の列です。PC等から見てどんな仕様のコントローラーに見えるのか、色々と異なっています。各動作モードは個別のHIDレポートディスクリプタを持つため、動作モードに応じて異なった形のHIDレポートを送出する必要があります。
    • 例えばPS3コントローラーのモードがIIDXとpop'nの家庭版に共通して用いられるように、機種をまたがって同じモードが使用される場合があります。

ハードウエア仕様は作るうちに変化する幅が広く(場合によっては採用するチップから変えてしまうこともあります)、どの程度の汎用性を持たせるべきかかなり判断しづらいです。そこで今回は、ハードウエア仕様1つにつき1つのファームウエアを作成することにします(画像にもあるように、個別のmain.cおよびmakefileを持つようにします)。コーディングのコストを最小限にするために、使うチップを変えない限りmakefileを使い回せるようにし、main.cも最低限の内容になるようにします。

EEPROMを用いた動作モード切替

明らかにこのままではハードウエアと動作モードの組み合わせが多すぎてしまい実装が大変です。まず、ハードウエア側は上述の通り、ファームウエアを個別に分けることにより個々の実装を単純にします。動作モード側は「起動後の動作モードの切り替えを考慮しなくてよい*3」ことに着目し、イニシャライズ時に一つの動作モードに確定してしまいます。すると上記の図はイニシャライズ後の時点で
f:id:DSKK:20200604102453p:plain
ここまで単純になります。また、破線を含めたループがメインループになります。

struct states_str_t states_str;
int main(void) {
    io_init(); //ピンアサインを決める。PORTx, DDRxの設定など
    usb_init(); //USB関連のイニシャライズ(後述)
    while (usb_configuration==0); // configuredになるまで待つ
    _delay_ms(1000);
    while (1) {
        io_task(); //=「キー入力を読んで更新」
        (*usb_task_ptr)(); //=「データを成形して出力」
    }
}

以上のコードに対応します。どんなハードウエアであってもmain.cの内容はこれ+io_init+io_taskになっていればOKです。io_initは入出力レジスタの設定と(必要なら)動作モード変更のためのEEPROMの書き換え、io_taskは入力ピンの読み込み・states_strの更新・出力ピンの設定を行います。詳細はgithubに今日(2020/06/04)上げたソースコードを参照してください。
次に、usb_init関数について解説します。実装は以下の通り。

void usb_init(void) {
    uint8_t eep_val = eeprom_read_byte(EEP_ADDR); // read EEPROM
    eeprom_busy_wait();
    switch(eep_val){
    case 1: //PS3
        ep_list = (const uint8_t *) ep_list_PS3;
        desc_list = (const uint8_t *) desc_list_PS3;
        ep_list_len = 2;
        desc_list_len = 7;
        is_ps3 = 1;
        usb_task_ptr=&usb_task_PS3;
        break;
    case 2: //INFINITAS
        ep_list = (const uint8_t *) ep_list_INFINITAS;
        desc_list = (const uint8_t *) desc_list_INFINITAS;
        ep_list_len = 2;
        desc_list_len = 7;
        is_ps3 = 0;
        usb_task_ptr=&usb_task_INFINITAS;
        break;
    default:
        for(;;);
    }
    //ここから下はレジスタの設定
    HW_CONFIG();  // UHWCON = 0x81 USB device mode && enable the USB pad regulator
    USB_FREEZE(); // enable the USB controller && disable the clock inputs
    PLL_CONFIG(); // (Clock division factor)=8 (External XTAL)=16MHz enable PLL
    while (!(PLLCSR & (1<<PLOCK))); // wait for PLL lock (it takes about 100 ms)
    USB_CONFIG(); // start USB clock
    UDCON = 0; // clear bit0 - DETACH (Set to physically detach
               // the device by disconnecting internal pull-up on D+ or D-).
    usb_configuration = 0; // zero when we are not configured, non-zero when enumerated
    UDIEN = (1<<EORSTE)|(1<<SOFE); //enable the EORSTI interrupt and SOFI interrput
    // EORSTI - End Of Reset Interrupt flag.
    // Set by hardware when an “End Of Reset” has been detected by the USB controller.
    // SOFI - Start Of Frame Interrupt flag.
    // Set by hardware when an USB “Start Of Frame” PID (SOF) has been detected (every 1ms).
    sei();
}

まず、eep_valにEEPROMの内容を読み込んできます。読み込むアドレス=EEP_ADDRの値はとりあえず0にしてあります。EEPROMが腐って読み書きできなくなったら別のアドレスに変えればいいと思います。
読み込んだeep_valの値に応じて各動作モードのイニシャライズをswitch文により行っています(原理上最大256種の動作モードを切り替えられます)。セットするのは以下の変数です。

const uint8_t* ep_list;
const uint8_t* desc_list;
uint8_t ep_list_len;
uint8_t desc_list_len;
uint8_t is_ps3;

特にdesc_listがわかりやすいのですが、これはUSBの各種リクエストに応じて返すべきディスクリプタ情報を構造体の配列として持ったものです。例えばPS3の場合

const struct desc_list_struct desc_list_PS3[] PROGMEM = {
    {0x0100, 0x0000, (const uint8_t*)device_desc_PS3, DEVICE_DESC_SIZE_PS3},
    {0x0200, 0x0000, (const uint8_t*)config_desc_PS3, CONFIG_DESC_SIZE_PS3},
    {0x2100, 0x0000, (const uint8_t*)config_desc_PS3+DEVICE_DESC_SIZE_PS3, 9},
    {0x2200, 0x0000, (const uint8_t*)report_desc_PS3, REPORT_DESC_SIZE_PS3},
    {0x0300, 0x0000, (const uint8_t*)&str_desc_langID, 4},
    {0x0301, 0x0409, (const uint8_t*)&str_desc_manufacturer, sizeof(STR_MANUFACTURER)},
    {0x0302, 0x0409, (const uint8_t*)&str_desc_product_PS3, sizeof(STR_PRODUCT_PS3)}
};

です。usb.cの実装を見ていただけるとわかるのですが、この中からリクエストに合うものを探して返すような動作になっています。ここで一つポイントなのが、各動作モードのディスクリプタをすべてconst PROGMEMキーワード付きでハードコードしている点と、動作モード固有のサフィックス付きで命名している点です。ディスクリプタは結構大きい構造体ですが、これによりRAMを圧迫せずに済みます。またこれらが別々の名前を持っていることから、上記desc_listなどで配列の先頭アドレスが必要になったときにも(const uint8_t*)report_desc_PS3のように問題なく記述できます。
ep_list, desc_listのような抽象化のおかげで、usb.cはハードウエア仕様によらずスタンドアロンで動作します。

各動作モード固有の実装

動作モードごとに

が必要です。これらをcommon/modes/*.cに実装します。ただしストリングディスクリプタの一部は共通なのでcommon_desc.cに実装してあります。
もし動作モードを追加したくなったら以下の手順を踏めば良いです。states_structについては説明がまだですが、後述します。また、ファイルの配置は最後の方に説明があります。

  1. 新たにcommon/modes/内にhoge.cを作る。既存のfuga.cを複製→サフィックスを置換→内容を適宜修正などでOK。
  2. common/modes/modes.hの末尾をコピペで増やしてサフィックスを置換。
  3. common/usb.cのusb_init関数内のswitch文にcaseを追加。
  4. states_struct.cにレポートを送る関数を実装、states_struct.hに関数のプロトタイプ宣言を追加。

簡単ですね。各種ディスクリプタが用意できていれば単純作業で動作モードを増やせるのが良いです。
この実装はハードウエア仕様には依存しないようになっていて、外からは「HIDレポートの送受信用関数」を呼べば良いです。この関数はいくつかのuint8_t型変数*4を受け取り、HIDレポートディスクリプタの要求に沿うように送信用レジスタにセットします。

states_struct

ハードウエア固有のパートはmain.cに押し込めておくことを先に述べました。そして、動作モード固有のパートはcommon/modes/*.cに押し込めてあります。これらを繋ぐための内容がstates_struct.cに実装されています。
states_structはマイコンレジスタよりは高級な、ボタン等の入力状態を保持する構造体です。この構造体は機種ごとに1種類定義されます。IIDXの実装例は以下の通りです。

struct states_str_t{
    uint16_t  button_state; //pressed=1 else=0
    uint8_t   button_is_locked[16];
    int32_t   button_locked_time[16];
    uint8_t   scratch_state; //neutral=0 up=1 down=2
    uint16_t  scratch_position;
    uint8_t   scratch_is_locked;
    int32_t   scratch_locked_time;
    int32_t   scratch_kept_time;
};

16bitのbutton_state(bit0がボタン0番、bit15がボタン15番に対応)、scratch_state(無操作=0, 左回り=1, 右回り=2)など。チャタリング対策のため、前の操作タイミングを控えておく変数などもあります*5
この構造体のインスタンスはexternキーワード付きで1つ作成され、main.cのio_task関数で内容が随時更新されます。そしてstates_struct.cに実装されたusb_task_PS3関数やusb_task_INFINITAS関数などによって読まれ、成形ののち送信されます(これらのusb_task_*関数は前段落で言うところの「HIDレポートの送受信用関数」を内部で呼んでいます)。
usb_task_hogeの形をした関数のうちどれを呼ぶべきかは動作モードを切り替えるたびに変わります。そのため、main.cでは関数ポインタusb_task_ptrの指す関数を呼ぶということにしてあり(上記コード参照)、その指す先はusb_init関数内のswitch文でセットしています。これもちょっとした工夫ポイントですね。

フォルダ構成

これまでの動作の依存関係を踏まえて、以下の通りファイルを配置するときれいにまとまります。

┌─common
│  │  common.h
│  │  usb.h
│  │  usb.c
│  │
│  └─modes
│          modes.h
│          common_desc.c
│          INFINITAS.c
│          PS3.c
│
└─IIDX
    │  states_struct.c
    │  states_struct.h
    │
    └─dao_red_usb_replacement
            main.c
            Makefile

[機種名]/[基板名]/main.cが多数存在していて、それらが1階層上で定義される機種固有のstates_structを更新。states_structはcommon/modes/[該当する動作モード].c内のHIDレポート送信用関数を呼ぶといった流れです。
ハードウエア仕様に依存しない部分はcommonフォルダに分離してあり、特にusb周りはusb.cとusb.hに分けてあります。usb_init関数もここ。

おわりに

以上のソースはこちら。
github.com
ブログを書きながら設計直したら最後のコミットで†Restructured everything†してしまった。gitヘタクソか?
ソフトの実装編はこの辺で完結ですかね。設計こだわったおかげでSDVX版もすぐ出来そう。
自分用の備忘録的な書き方にどうしてもなってしまったので、何か気になる点があったらコメントか
しありす (@cialis438) | Twitter
へのリプをお気軽にどうぞ。感想やコントローラー自作に関する雑談・質問なども歓迎です。
下のリンクからシェアやツイートなどしてもらえると大変執筆モチベが上がりありがたいです。どうぞよろしく。

*1:AVR以外にはトランジスタアレイ(LEDドライブ用)を用いる程度で留めることを想定。

*2:makefileを書き換えてやれば、AT90USBシリーズにもATmega32Uシリーズと同じソースコードが使えます

*3:例えば、繋げる相手がPS3からPCに変わるなら一度ケーブルを抜くことになります。したがってコントローラーの起動時、すなわちUSBケーブルを差し込むタイミングで動作モードを選択できれば十分です。

*4:もちろん、関数の実装によってはこれに限りませんが。

*5:構造体内に定義するのが適切か?と言われると結構微妙。グローバル変数でもよかったかも。