uchan; MikanOSにUSB CDCドライバを追加してシリアル通信 - uchan-nos/os-from-zero GitHub Wiki
CDC は Communication Device Class の略で、通信を行うことが主目的となる USB 機器向けのクラスです。 サブクラス CDC ACM は、RS-232C 通信をエミュレートする USB 機器でよく採用されます。
MikanOS に CDC ACM クラスドライバを搭載して、自作の CDC ACM 機器を制御する方法を探求します。
サンプルとなる CDC ACM 機器の挙動を観察する
最終的には自作の CDC ACM 機器を制御することを目標としますが、最初から自作の USB 機器を用いるとデバッグが大変です。ということで、最初は 秋月のPIC18F14K50使用USB対応超小型マイコンボード に、Microchip 提供の CDC サンプルをそのまま書き込んだものを対象に、USB クラスドライバの作成を目指します。
この実験機器の通信を観測し、CDC ACM の仕様を勉強した過程を USB CDC機器のパケットキャプチャを解析 に記録してあります。
CDC ドライバの設計
調査結果をまとめると、PIC18F14K50 で自作した実験機器は 2 つのインタフェース(Communications と Data)を持ち、文字データは Data インタフェースが持つバルクエンドポイントでやり取りされることが分かりました。Communications インタフェースが持つインタラプトエンドポイントは、今回の実験では活躍しませんでしたが、仕様では DSR と DCD の状態をホストへ通知するのに使われることになっています。
ここからは、いよいよこの機器の振る舞いに対応した CDC ドライバを作成していきます。
MikanOS の USB ドライバは「ゼロからの OS 自作入門」でほとんど触れていないので、多少詳しめに解説していきます。まず、変更点を考える起点として、クラスドライバを生成する関数 NewClassDriver()
を見てみます。
usb::ClassDriver* NewClassDriver(usb::Device* dev, const usb::InterfaceDescriptor& if_desc) {
if (if_desc.interface_class == 3 &&
if_desc.interface_sub_class == 1) { // HID boot interface
if (if_desc.interface_protocol == 1) { // keyboard
auto keyboard_driver = new usb::HIDKeyboardDriver{dev, if_desc.interface_number};
if (usb::HIDKeyboardDriver::default_observer) {
keyboard_driver->SubscribeKeyPush(usb::HIDKeyboardDriver::default_observer);
}
return keyboard_driver;
} else if (if_desc.interface_protocol == 2) { // mouse
auto mouse_driver = new usb::HIDMouseDriver{dev, if_desc.interface_number};
if (usb::HIDMouseDriver::default_observer) {
mouse_driver->SubscribeMouseMove(usb::HIDMouseDriver::default_observer);
}
return mouse_driver;
}
}
return nullptr;
}
この関数は、デバイスから得たインタフェースディスクリプタの情報を使って、適切なクラスドライバを生成します。この関数を改造して、CDC 機器だったら CDC クラス用のドライバを生成する、というのが基本的な方向性ですね。
この関数を作った当時は、筆者は USB HID クラスしか詳しく知りませんでした。HID クラスでは、デバイス全体のクラスコード(bDeviceClass)は 0 になっていて、各インタフェースでクラスを指定する仕様になっています。この理由を推測すると、HID というのは他の種類の機器に付随するクラスであり得るからだと思います。例えば、USB スピーカーは全体としてはオーディオの機器と考えられますが、音量つまみは HID の機能です。ですので、デバイス全体として HID としてしまうのではなく、インタフェース単位でクラスを指定する仕様になっているんだと思います。ということで、NewClassDriver()
はインタフェースディスクリプタだけを受け取るようになっています。
一方、CDC クラスはデバイス全体のクラスコードが 2 となる仕様です。デバイスの一部の機能として CDC を含められないことを意味します。なぜこの仕様になっているのかは分かりませんが、もしかしたら、1 つの CDC デバイスは 2 つの異なるインタフェース Communications Class(bInterfaceClass = 2)と Data Class(bInterfaceClass = 10)を含むからかもしれません。2 種類のインタフェースが組み合わさって 1 つの CDC という機能を提供するため、インタフェース単位では全体的なクラスを決められません。
さて、そう考えると NewClassDriver()
は CDC クラスに関しては使えません。CDC のクラスドライバは、デバイス全体に対して 1 つのクラスドライバを作る必要があるからです。ですので、設計方針は次のようにしようと思います。
- デバイスクラス(bDeviceClass)が 0 の場合、今までどおりにインタフェース毎に
NewClassDriver()
を呼び出す。 - デバイスクラスが非 0 の場合、デバイス全体を見てクラスドライバを生成する関数(新しく作る)を呼び出す。
QEMU に自作 CDC 機器をつなぐ
設計と実装は並行に進める方がやりやすいので、ここからは実験機器を対象にしてドライバを実装しつつ、設計も進めていきます。
そのために QEMU で動く MikanOS に対し、ホストマシンに接続した実験機器を認識させる必要があります。普通、QEMU などのエミュレータは、ゲスト OS に対して仮想のハードウェアを提供します。仮想 CPU、仮想ディスク、仮想 NIC などなど。しかし、今回は実際のハードウェアである実験機器をゲストから扱いたいわけです。仮想マシンに直接 USB ケーブルを指すことはできません。どうしたらいいのでしょうか?
これは一般に「パススルー(pass through)」と呼ばれる技術で解決できます。パススルーとは、ホストマシンに接続されたデバイスを直接ゲストに見せることです。ホスト OS を通過させることから「パススルー」と呼ぶのだと思います。
調べると、QEMU で USB デバイスをパススルーさせることができるようです。QEMU のドキュメントの USB emulation によれば、-device usb-host,hostbus=bus,hostaddr=addr
というオプションを使えば、バス番号とデバイス番号で指定した USB デバイスをパススルーできるようです。lsusb コマンドで番号を確認したところ、実験機器はバス 1、アドレス 28 だったので、MikanOS を起動するコマンドラインは次の通りです。
$ QEMU_OPTS="-device usb-host,hostbus=1,hostaddr=28" ./build.sh run
ただし、初期設定のままでは USB パススルーが権限不足でエラーになります。一番簡単な回避方法は build.sh を root として実行する(上記のコマンドラインに sudo を追加する)ことです。そうすると QEMU も root で実行することになるため、権限エラーが出なくなります。しかし、コンパイル処理も root になり、生成される .o ファイルが root 所有になってしまって面倒なことになります(一般ユーザとしてビルドするときに権限不足で .o を変更できないなど)。
そこで、筆者は特定の USB デバイスを一般ユーザで開けるように設定しました。Ubuntu は udev でデバイスを管理しているため、次の 1 行を /etc/udev/rules.d/10-qemu-hw-users.rules というファイルに書けば良いです(この設定は Run qemu without root for usb passthrough? を参考にしました)。
SUBSYSTEM=="usb", ATTRS{idVendor}=="04d8", ATTRS{idProduct}=="000a", TAG+="uaccess"
これをファイルに書いたら、次の 2 つのコマンドを実行します。
$ sudo udevadm control -R
$ sudo udevadm trigger
以上で、一般ユーザが実験機器を USB パススルーできるようになりました。他の USB 機器をパススルーしたくなったら、設定を追加すれば良いですね。
MikanOS からパススルーした機器を認識できることを確認するために、デバイスクラスが 0 でなかったら番号を印字するよう、USB ドライバを修正してみました。該当のコミットはこちらです。 https://github.com/uchan-nos/mikanos/commit/d968e54ac719a3c56200e9e2c3e1508d9cea6b4e
このコミットを適用した MikanOS を、USB パススルーを設定した QEMU で動作させると、次のような表示になりました。CDC のデバイスクラス番号は 2 なので、正しく認識できているみたいですね。
images/uchan/mikanos-screenshot-d968e54ac719a3c56200e9e2c3e1508d9cea6b4e.png
lsusb コマンドを追加
筆者が自分で作った USB ドライバですが、複雑な構造のため思い出す作業を兼ねて簡単なところから改造しようと思います。 ということで、手始めに、接続されている USB デバイスを一覧するコマンドを作ります。Linux の lsusb コマンドの超簡易版です。
images/uchan/mikanos-screenshot-lsusb-command.png
コミットはこちらです: https://github.com/uchan-nos/mikanos/commit/e21b58981c9f08647a1e52407178582520bc156d QEMU に USB パススルーで接続したデバイス(ベンダ ID 04d8)がきちんと表示されていることが分かります。成功です。
仕組みは xHC に接続されているデバイスを、スロット番号の順に取得し、ベンダ ID などを表示する、というものです。 スロット番号は 1 から 255 まで有効なので、for 文で繰り返します。
デバイスを取得できたらベンダ ID やクラスコードなどを取得します。 それらの情報は USB デバイスから取得できるデバイスディスクリプタに登録されているので、デバイスディスクリプタを取得しなければなりません。
幸い、初期化の過程で取得したデバイスディスクリプタは usb::Device
の device_desc_
に記録されているので、それを参照するだけで大丈夫です。
device_desc_
を取得するメソッドがなかったので増やしました( DeviceDesc()
)。
デバイス側プログラムの解析
USB ドライバからデバイスへのデータ送信の方が、データ受信より作りやすいと思います。そのため、次の目標は実験機器に向けてデータを送信することとします。それを達成するためには、まず実験機器のデータ受信部の処理を学ぶのが近道だと考え、機器側のドライバの動作を解析してみました。USB CDC 機器側のドライバ動作解析
バルク送信試験コマンドの作成
バルク通信を用いて、実験機器へ指定した 1 バイトを送るコマンド usbtest を作ります。機器側では 1 を受信したら LED を点灯、2 を受信したら消灯させるようにして、通信成功を確認することができます。このコマンドは USB CDC 機器側のドライバ動作解析 で試行錯誤する際に作ったものですが、基本的なデータ送信を説明する良いサンプルなので、ここで説明します。
まず、追加した usbtest コマンドの実装を見てみます。
} else if (strcmp(command, "usbtest") == 0) {
if (!usb::cdc::driver) {
PrintToFD(*files_[2], "CDC device not exist\n");
} else if (first_arg && first_arg[0]) {
usb::cdc::driver->SendSerial(first_arg, 1);
} else {
usb::cdc::driver->SendSerial("a", 1);
}
} else if (command[0] != 0) {
グローバル変数として新たに作成した usb::cdc::driver
という変数を使って、実験機器にデータを送信します。グローバル変数として CDC ドライバを表す変数を作る方法は、あまりきれいな方法ではありません。本来なら、配列などで複数に対応できるようにしたうえで、送信対象の機器を選べるようなインタフェースにすべきでしょう。例えば usbtest x /dev/usb0
のようにパスで指定するとか。ただ、今は USB CDC の仕様を勉強しながらドライバを書く段階なので、最もソースコードの変更が少ない方法で済ませることにしました。
次に、このグローバル変数へ値を書いている部分を見ます。それは新しく作った NewClassDriver()
の中です。この関数は USB 機器が発見されるたびに呼び出され、適切なクラスドライバを生成します。
device_desc.device_class == 2
であれば CDC クラス用のドライバを生成します。CDC クラスは必ずコミュニケーションクラスのインタフェースを持ち、オプションとしてデータクラスのインタフェースを持ちます。if_comm
と if_data
はそれらのインタフェースを指すポインタです。
while 文 でそれら 2 つのインタフェースを探します。CDC クラスの仕様から、インタフェースクラス値 2 がコミュニケーションクラス、10 がデータクラスに対応することが分かりますから、その通り探します。
それぞれのインタフェースには 1 つ以上のエンドポイントが対応しますので、for 文 でエンドポイント記述子を取得します。エンドポイント記述子から EndpointConfig
構造体を作り、ep_configs
に保存しておきましょう。ep_configs
は、後に xHCI ドライバがエンドポイントを設定する際に利用する配列です。
今は実験機器さえ動けばいいや、という試験的な実装なので、CDC クラスに特有の記述子(HeaderDescriptor
や ACMDescriptor
など)はすべて無視しています。幅広い CDC デバイスに対応するには、これらを適切に記録しておき、後で利用する必要があります。
CDC クラスドライバの実装
グローバル変数 usb::cdc::driver
を生成する処理を説明しました。次は CDC ドライバの中身を見ていきます。特に重要な SendSerial()
に焦点を当てます。
この関数がやっているのは大きく 2 つ、Bulk Out 転送と Bulk In 転送です。まず関数に渡されたバッファを Bulk Out により送信します。すると、実験機器はデータを受信して、1/2 であれば LED4 の On/Off を制御します。次に Bulk In 転送により実験機器からデータを受信します。Bulk In で読み取ったデータは不要なのですが、実験機器が次のデータを受け取る態勢になるためには受信操作が必要です。簡単のために new uint8_t[8]
として生成した一時的なバッファを指定していますが、これは明らかにメモリリークします。本来なら new
で生成したバッファを、データ転送完了時に delete
しなければなりません。あるいは、動的メモリ管理が不要な静的変数などを用いてデータの送受信をします。今回はデータ送信を単純な方法で試してみたかったので、メモリリークには目をつぶっています。(MikanOS の master ブランチにマージするまでに修正する予定です。)
NormalOut()
はホスト→デバイス方向の転送を行う関数です。Interrupt Out と Bulk Out のどちらにも対応しています。というより、Interrupt と Bulk はエンドポイントに紐付いた属性であり、NormalOut()
に渡したエンドポイント番号(ep_bulk_out_
)から一意に決まるため、関数を分ける必要はありません。ということに気付いて、既にあった InterruptOut()
と新しく作った BulkOut()
を統合したのです。同様に NormalIn()
はデバイス→ホスト方向の転送を行う関数です。
実際は、NormalOut()
と NormalIn()
は転送の予約を xHC のキューに積むだけで、データ転送自体は xHC により非同期に行われます。データ転送が完了すると Device::OnInterruptCompleted()
が呼ばれ、その中で CDCDriver::OnInterruptCompleted()
が呼ばれます。CDCDriver::OnInterruptCompleted()
の中では単にログを出すだけで、その他の処理はしていません。この中で delete
をするようにすればメモリリークは防げるでしょう。
今後の展望
この記事はここでおしまいにする予定です。ですが、今回実装したソースコードの改良は続けて、master にマージする予定です。その頃には関数の構造などが変わっているかもしれませんが、USB 通信のエッセンスは変わらないので、この記事は依然として新しい USB クラスドライバを付け足すときの参考にはなると思います。