この記事は、KLab Engineer Advent Calendar 2022 15日目の記事です。
Raspberry PiでUSBモデムエミュレータを作ってPS2のゲームのモデム対戦をTCP/IP上で行えるようにしました。
さて、対戦ゲームをプレイするには相手が必要です。
いろんな人に使ってもらうことを考えると、Raspberry Piはちょっと不便です。
具体的には、ゲーム機の周辺機器としては少々値段が高いことや、Linuxに慣れていないと使うのが難しいこと、そもそも品切れで入手困難であったりします。
そこで、Raspberry Pi Pico互換マイコンボードでEthernetポートを搭載している WIZnet W5500-EVB-Pico に移植してみました。
移植版のソースコードはこちら
移植にあたり、Arduino-Pico上で使えるUSBデバイスコントローラのドライバを書いたのでその話をします。
Raspberry Pi Pico (RP2040) で使えるUSBスタックの紹介
TinyUSBという組込みマイコン向けのUSBスタックがあります。
RP2040だけでなく様々なマイコンに対応しており、デバイスクラスの実装も多数含まれいるようです。
サンプルコードを読むと、非常に簡潔な記述でUSBデバイスを作れるようになっているように見えます。
また、Arduino向けのライブラリとしてAdafruit TinyUSB Arduinoが利用できるそうです。
ですが、今回はTinyUSBを使わずにUSBデバイスコントローラのドライバを自作しました。
なぜTinyUSBを使わなかったのか
端的に言えば、TinyUSBの使い方を調べるより、RP2040専用のドライバを自分で書いた方が早く作れると思ったためです。
今回の開発では、時間をかけずに楽に実装をしたいため、RP2040をArduino-Pico環境で利用します。
Arduino-picoにTinyUSB (or Adafruit TinyUSB Arduino) を組み合わせて使うことは非常に魅力のある手段だと思っていました。
しかし、TinyUSBについて調べていくうちに考えは変わりました。
初めて使うライブラリは、ドキュメントを読み、どのように使うのか、どんなAPIがあるのかを調べると思います。
TinyUSBは残念ながらドキュメントがほとんどありませんし、APIリファレンスも存在しません。
ドキュメントが無い以上、サンプルコードとライブラリ本体の実装を読むしか手段はありませんが、サンプルコードは標準のUSBデバイスクラスを利用するものばかりで、ベンダー独自クラスでエンドポイント上で直接パケットを送るような使い方のものは無いようです。
ドキュメントが無いライブラリを使うことは楽ではありません。
なので、今回の開発でTinyUSBを使うことは諦めることにしました。
USBデバイスコントローラドライバの自作
私の手には、RP2040マイコンの全てが書かれたドキュメントであるRP2040 Datasheetがあります。
そこで、データシートを元にRP2040マイコン向けのUSBデバイスコントローラドライバを自作することにしました。
自作といっても、データシートの 4.1.3.2. Standalone device example
の項にサンプルコードが記載されているので、ほぼその通りに作るだけです。
RP2040に内蔵されたUSBコントローラは、ホストとデバイスのどちらとしても動作できます。
今回はデバイスとして使うので、デバイス側の説明だけをします。
RP2040のUSBコントローラにはSerial RX Engine、Serial TX Engineというものが搭載されています。
この二つのエンジンは、ホストが開始したトランザクションの要求に対し、エンドポイントを判別しエンドポイント専用バッファとのデータ送受信を行ってくれます。
つまり、トランザクションに関する処理はほぼ全自動で行われるため、ドライバは単にバッファの読み書きをしてバッファの準備が整ったらレジスタを叩くだけで、各エンドポイントにおけるデータパケットのやり取りが可能です。
USBコントローラの初期化
実装: rp2040_usb_device::init
ここはほぼデータシート通りの順番でUSBコントローラのレジスタを設定するだけです。
データシートでは 4.1.3.2.1. Device controller initialisation
が対応します。
データシートからの差分としては、USBCTRL_IRQの割り込みハンドラを一回削除する処理を追加しました。
Arduino-pico環境で利用する際には、既存のUSBシリアル通信用のハンドラが登録されておりこれを削除しないと割り込みハンドラを登録できないためです。
auto old_handler = irq_get_exclusive_handler(USBCTRL_IRQ);
if (old_handler) {
irq_remove_handler(USBCTRL_IRQ, old_handler);
}
irq_set_exclusive_handler(USBCTRL_IRQ, _irq_handler_usbctrl);
初期化が完了するとUSBホストと通信ができるようになり、通信があると割り込みが発生します。
なので、次は割り込みを処理する割り込みハンドラの実装をします。
割り込みハンドラの作成
実装: rp2040_usb_device::irq_handler_usbctrl
割り込みのIRQ自体は1つだけですが、その下に複数の割り込み要因がカスケード接続されています。
なので、割り込みハンドラ内で割り込み要因をチェックして、必要な割り込み処理を実行する必要があります。
主な割り込み要因は以下の三つです。
- USB_INTS_BUS_RESET_BITS
- USB_INTS_SETUP_REQ_BITS
- USB_INTS_BUFF_STATUS_BITS
USB_INTS_BUS_RESET_BITS
実装: rp2040_usb_device::bus_reset
USBバスの状態がリセットされた際に発生する割り込み要因です。
デバイスアドレスがセットされていた際には、リセット時にアドレスを無効化する必要があります。
USB_INTS_SETUP_REQ_BITS
実装: rp2040_usb_device::handle_setup_packet
エンドポイント0でコントロール転送によりセットアップ要求が送られてきた際に発生する割り込み要因です。
具体的には、GET_DESCRIPTOR
や SET_CONFIGURATION
などのUSBデバイスの初期化リクエストが発行されると、この割り込みが発生します。
ドライバ内ではバッファからのデータの取り出しのみ行っており、ホストからの要求に対する処理はドライバ外にあるcontrol_packet_handlerにて行っています。
ただし、SET_ADDRESSに対する処理だけはドライバ内で処理します。
これはLinuxのUSB Raw GadgetのAPI実装に合わせましたが、とくに何か考えがあるわけではないです。
USB_INTS_BUFF_STATUS_BITS
実装: rp2040_usb_device::handle_buff_status
各エンドポイントの送受信バッファの状態が変化した際に発生する割り込み要因です。
具体的には、送信バッファの場合は送信完了時、受信バッファの場合は受信完了時に割り込みが生じます。
どのエンドポイントで割り込みが生じたか、というのはBUFF_STATUSレジスタにビット単位で記録されているので、1ビットずつ操作して確かめます。
受信完了時の割り込みの場合、受信バッファのデータを取り出して次のパケットに備える必要があるため、コールバック関数を呼び出して直ちに受信データを処理させます。
送受信処理
実装: rp2040_usb_device::transmit, rp2040_usb_device::receive
ホストからのセットアップ要求に答えるには、ホストとパケットの送受信を行う必要があります。
送受信を行うためには、エンドポイントごとに用意されたbuffer control registerを操作します。(4.1.2.5.4. Buffer control register
参照)
送信処理では、送信バッファにデータを格納した後、buffer control registerにデータ長とPID、USB_BUF_CTRL_FULLフラグを書き込みます。
PIDとはUSBにおけるPacket Identifierで、各エンドポイントでパケットを送信するごとに0と1を交互に切り替える必要があります。
FULLフラグが書き込まれると、Serial TX Engineはバッファは送信できる状態だと判断して、次のトランザクションでデータが送信されます。
受信処理では、buffer control registerにPIDを書き込み、USB_BUF_CTRL_FULLフラグを折ります。
receive関数では受信準備をするだけで、実際に受信したデータは受信完了割り込みが発生した際のコールバックで受け取ります。
USBエンドポイントの設定
実装: rp2040_usb_device::apply_endpoint_configuration
データシートでは4.1.3.2.2. Configuring the endpoint control registers for EP1 and EP2
に対応しています。
ここまで、USBコントローラの初期化と、割り込みハンドラの作成、送受信処理の実装を行いました。
これだけあればエンドポイント0におけるセットアップ要求に応答することができます。
セットアップを完了するには、SET_CONFIGURATIONの対応として追加のエンドポイントの初期化を行う必要があります。
USBコントローラへの設定としては、Endpoint control registerにエンドポイントの有効フラグ、バッファ構成、割り込み設定、エンドポイントタイプ、バッファのオフセットを指定する必要があります。(4.1.2.5.3. Endpoint control register
参照)
また、ドライバ側で利用するデータの初期化として、転送終了コールバック用の関数ポインタの格納と、PIDの初期化を行います。
以上でUSBデバイスコントローラドライバは概ね実装できました。
実装時の注意点
今回の実装でハマった、もしくはハマりそうだったポイントを紹介したいと思います。
USBのセットアップ要求におけるSET_ADDRESSで指定されたアドレスは、ステータスステージまで転送が完了してから反映する
USBデバイスは、USBバス内で自身の通信を区別するためのアドレスを持っています。
このアドレスは、セットアップ時にホストがデバイスへSET_ADDRESSリクエストを送り設定されます。
SET_ADDRESSリクエストはデータを伴わないコントロール転送が使われます。
この場合、ホストが要求を送る「セットアップステージ」のトランザクションの後に、デバイスから「ステータスステージ」のトランザクションを送り、要求が受け入れられたことをホストへ伝達します。1
SET_ADDRESSリクエストで受け取ったアドレスは、このステータスステージのトランザクションが完了した後から有効になります。
受け取ったアドレスを即時反映してしまうと、ステータスステージ送信時に有効になる前のアドレスによる通信が行われてしまい、通信に失敗します。
なので、アドレスを受け取った際は、アドレスを一時退避しておき、ステータスステージのトランザクションを送信してから、ステータスステージの転送完了コールバック内で退避しておいたアドレスを適用する必要があります。
割り込みハンドラ内でロックを取る場合はデッドロックが生じる可能性がある
割り込みハンドラは、割り込み要求が発生したタイミングで実行される非同期処理になります。
元々実行されていた処理は、割り込みハンドラが終了するまで再開されません。
なので、割り込みハンドラ内でロックを取ろうとした際に既にロックが取得されていた場合、
割り込みハンドラを終了しない限り元の処理でロックが解放されることは無いため、デッドロックが生じます。
対策として、ロック取得中は割り込みが発生しないように割り込み禁止設定を行う必要があります。
Raspberry Pi Pico SDKでは、ロックの取得と同時に割り込み禁止を設定するcritical_sectionというAPIが用意されているので、今回はこれを使っています。
終わりに
駆け足でしたが、RP2040 Datasheetを元に自作したRP2040のUSBデバイスコントローラドライバの実装について解説をしました。
RP2040のUSBコントローラは、トランザクションをハードウェア (Serial RX Engine/Serial TX Engine) が自動で処理をしてくれるので、ドライバ側ではバッファの読み書きだけ行えば簡単にUSBのデータパケットが送ることができます。
TinyUSBについて、ドキュメントが不足しているという問題はありますが、複数のマイコンに対応しており移植性の面でメリットがあるライブラリです。時間があるときにソースコードを読んで使ってみたいと思います。
また、今回使用したWIZnet W5500-EVB-PicoというRaspberry Pi Pico互換マイコンボードは、有線LANが使えるという点で面白いマイコンボードです。
最近は、ESP32や、これから日本でも発売されるであろうRaspberry Pi Pico Wなどの無線LANが使えるマイコンボードが流行っていますが、個人的には有線LAN推しなので有線LAN対応マイコンボードも流行ってほしいと思います。