Tech Notes

Zephyrとラズパイpicoで独自USBデバイスを作る

最近、Zephyrというものを勉強してみている。RTOSと言われるものの1つで、様々なマイコン上で高度な処理を統一的に実装できる。対応チップは結構あるようだ。(参考)

出来ることは広いのだが、その中でUSB機能に注目したい。適当な32bitマイコンを買ってきて独自デバイス化できるのであれば、考えられるアプリケーションはかなり多い。

その一方で公式のサンプルには、マウスとかキーボードとかMIDIとかメモリとか既定のUSBデバイスクラスのものしかない。ベンダ独自デバイスを定義する方法のちゃんとした解説がどこにもない。というわけでこの記事を書いた。

実験にはRaspberry Pi picoを使用したが、Zephyrが対応するUSBマイコンであればどれでも使えるはずだ。

ソースコードの全体はこちら(リンク,zip形式)。

ZephyrのUSB関連APIについて

Zephyrの公式ドキュメントを見てみたところ、USBデバイス機能はページがごちゃついている。まず調べてみたところ、以下のような3つの混乱がある。

  1. APIが2層構造になっている。
    • 低レイヤな制御を「USB Device Controller(UDC) driver API」が担う。
    • より高次な制御を「USB Device Core API」が担う。
  2. 上層のUSB Device Core APIに新旧2つのバージョンがある。
    • 旧版のAPIはusb_で始まる。
    • 新版のAPIはusbd_で始まる。
  3. 下層のUDC driver APIにも新旧2つのバージョンがある。
    • 旧版のAPIはusb_dc_で始まる。
    • 新版のAPIはudc_で始まる。

下層のUDC driver APIを直接触って実装することもできるようだが、ここではよりまともなやり方として高レベルな方のUSB Device Core API、それも新版のusbd_の方を使って実装することを前提にする。古いAPIはdeprecatedになっている。

なお、キーボードやマウスなどのサンプルコードに使われているAPIはUSB Device Core APIをさらにラップして作られているものだ。Coreというのは様々なUSBデバイスを実装するためのCoreという意味だろう。図にするとこんな感じになる。

今回はこの独自デバイスを作ってみようという主旨である。

USBに関する基礎的な知識

USBデバイスの実装のために基本的な知識を説明する。

ホストとデバイス

マウスやキーボードなどのUSB機器を「USBデバイス」、USB機器が接続される側のパソコンのような機器を「USBホスト」と呼ぶ。以下では単に「ホスト」「デバイス」と呼ぶ。

USBの通信速度

USBの通信速度にはいくつかのバージョンがある。マイコンチップによって、これには対応しているがこれには対応していないなどがある。

  • Low Speed(1.5Mbps): 古い。USB1.0から。古すぎるのかZephyrでは使えない。
  • Full Speed(12Mbps): 標準的な速度。USB1.1から。
  • High Speed(480Mbps): 速い。USB2.0から。これが使えるマイコンは絞られる。
  • Super Speed(5Gbps~): くそ速い。USB3.0から。これが使えるマイコンは見たことがない。

USBデバイスクラス

USBデバイスというものはマウス、キーボード、メモリ、カメラ、Bluetoothドングル、シリアル変換、スピーカー、マイク、MIDI機器など非常に多岐に渡る。しかし、大抵の機器は何かしら既存の枠組みに当てはまるものだ。A社が作ったマウスだろうがB社が作ったマウスだろうがマウスはマウスである。そこで、こういう一般的な機器を表すためにUSBの規格ではデバイスクラスというものが定めてある。これは機器の分類であり、同時に各機器における標準的な通信仕様でもある。

マウスであればマウスのデバイスクラスを名乗り、USBマウスの通信プロトコルに従う。カメラであればカメラのデバイスクラスを名乗り、USBカメラの通信プロトコルに従う。各メーカーがこうしているおかげで、どのメーカーが作った機器でもOS標準の同じデバイスドライバで扱うことができる。メーカーが作ったドライバを別途インストールする必要がない。

USBデバイスクラスは

  • ベースクラス 1byte
  • サブクラス 1byte
  • プロトコル 1byte

の計3byteで表される。具体例を挙げれば、以下のようなベースデバイスクラスがある。

  • オーディオ機器: 0x01
    • イヤホン、マイクなど
  • HID(Human Interface Device): 0x03
    • マウス、キーボードなど
  • ストレージ: 0x08
    • いわゆるUSBメモリなど
  • 通信: 0x0A
    • USBシリアル通信
    • USBイーサネット通信
    • etc...

一覧はUSB.orgで誰でも見れる(リンク)。こうした標準に定義されたデバイスを名乗り、その通り振る舞うことで不自由なく通信できる。

なお、独自デバイスの場合は「ベンダ定義デバイス(=0xFF)」を名乗る。

VID,PID

あらゆるUSBデバイスは Vendor ID(VID)Product ID(PID) を持つ。それぞれ16bit。これはUSBデバイスを作る事業者を表すIDと、その事業者のデバイスを表すIDだ。重要なこととして、VIDはUSB-IFという管理団体に申請しないと取ることができない

VIDとPIDはデバイスを表す大切な識別番号のため、適当なIDを勝手に名乗って衝突してしまえばそれは問題になる。USB-IFもそれはイカンと言っている。一方で、何かしらのVID/PIDを名乗らなければUSBデバイスとして成立しない。ここが問題である。

開発した機器を頒布販売せず個人で実験する限りでは何の問題も起きないので、個人の範囲で留める分には適当なIDを勝手に名乗ってしまえば良いのではないだろうか。自分は実験の際、VIDとしてZephyr Projectに割り当てられている0x2FE3を使い、PIDは適当な数字を使って実験している。

デスクリプタ

USBデバイスが接続されたとき、ホストはデバイスに対し、その機器の様々な情報を求める。いわば「自己紹介」である。この自己紹介のデータをUSBデスクリプタと呼ぶ。

デスクリプタはいくつかの種類があり、階層構造になっている。この階層構造はUSBデバイスを表す意味構造そのものとなっている。

  • デバイスデスクリプタ
    • コンフィギュレーションデスクリプタ
      • インタフェースデスクリプタ
        • エンドポイントデスクリプタ
  • ストリングデスクリプタ
  • その他

まず物理的なデバイスがあり、それをどのような設定(コンフィギュレーション)で使うかというのがある。物理的なデバイスの中には論理的な機器=インタフェースがあり、インタフェースの中には通信の口(エンドポイント)がいくつかある。これらを表すのがデスクリプタだ。

デスクリプタのデータにはデバイス名、製造者名、必要な電力、先述したVID/PID、デバイスクラスなど様々な情報が含まれる。

ストリングデスクリプタは少し特殊で、設定の文字列テーブルを表すものだ。他のデスクリプタはC言語の構造体のように完全にデータ構造が決まっているので、可変長なデータを持つことができない。そこで文字列を表すために、別途定義された文字列テーブルへの参照を持つという仕組みになっている。その文字列テーブルを表すのがストリングデスクリプタだ。

また、上記の5種の他に特殊用途のデスクリプタや、USBデバイスクラスごとに定義されるデスクリプタも存在する。例として、HIDデバイスクラスで定義されるHIDレポートデスクリプタなどがある。

4つの転送方式

USB上で行われるデータ転送には4つの方式がある。全ての通信はこれのどれかになる。

  • バルク転送
    • データをガッと流す最も基本的な転送方式。
  • アイソクロナス転送
    • データの到着確認を行わない転送方式。
    • バルク転送をTCPとすればアイソクロナス転送はUDP。
  • インタラプト転送
    • 少量のデータを定期で流す転送方式。
    • 1通信につき64byteまでという制約がある。
  • コントロール転送
    • システムの状態を管理するための特殊な通信。

USBに関する基礎知識はこんなところだ。

ZephyrのUSBデバイス定義

Zephyrで何かを実装する際にはサンプルコードを参考にするのが良いのだが、独自USBデバイスの完全な実装はない。しかしtestusbサンプルが一定の参考になるので、それをベースにすると良い。以下ではtestusbサンプルに書かれていない内容を書く。

まずはUSBデバイスを表すオブジェクトを定義する必要がある。

USBD_DEVICE_DEFINEというマクロを使う。これはmain関数の中とかではなくグローバルに書く。

USBD_DEVICE_DEFINE(
    my_device, // オブジェクト名
    DEVICE_DT_GET(DT_NODELABEL(zephyr_udc0)), // デバイス
    0x2FE3, // VID
    0x1234  // PID
);

こうするとmy_deviceという名前のusbd_context型オブジェクトが定義される。

DEVICE_DT_GETというのがZephyrを触ったことの無い人には良く分からないかもしれないが、これはデバイスツリーという仕組みによるものである。ハードウェア的に存在するデバイスやペリフェラルなどを「デバイスツリー」として設定ファイルによって定義することができ、そこで定義したデバイスをプログラムからこのように呼び出せるのである。ここではマイコンに存在するUSBインタフェースを呼び出しているのだと思えば良い。筆者も詳しい原理については把握していない。

ここで定義したUSBコンテキストオブジェクトを使い、USBの挙動を定義していく。

デスクリプタ定義

デスクリプタを定義する方法。デスクリプタにはいくつか種類があることを説明したが、まず

  • デバイスデスクリプタ
  • コンフィギュレーションデスクリプタ
  • ストリングデスクリプタ

の3つの実装方法について説明する。

インタフェースデスクリプタ以下についてはUSBクラス(後述)というものによって実装する。

デバイスデスクリプタ定義

基本的にデバイスデスクリプタの大部分についてはUSBD_DEVICE_DEFINEで自動的に済んでいるのだが、いくつか文字列情報を追加する必要がある。

  • 言語情報
  • 製造者名
  • デバイス名
  • シリアルナンバー

これらはストリングデスクリプタによって記述される。従ってまずストリングデスクリプタで中身を定義し、それへの参照をデバイスデスクリプタに乗せることになる。

ストリングデスクリプタは専用のマクロで定義する。これもグローバルに書く。

// 言語情報定義
USBD_DESC_LANG_DEFINE(my_device_lang);
// 製造者名
USBD_DESC_STRING_DEFINE(my_device_manufacturer, "My Manufacturer", USBD_DUT_STRING_MANUFACTURER);
// デバイス名
USBD_DESC_STRING_DEFINE(my_device_product, "My USB Device", USBD_DUT_STRING_PRODUCT);
// シリアルナンバー
USBD_DESC_STRING_DEFINE(my_device_sn, "12345", USBD_DUT_STRING_SERIAL_NUMBER);

いずれも第1引数は定義したストリングデスクリプタを表す変数名である。被らないように適宜名付ける。

言語情報定義だけ少し特殊だ。第2引数以降がないが、これはZephyrに多言語対応機能の実装がないためらしい。USBの規格としてはデバイスがストリングデスクリプタに言語情報を乗せ、ホストは自分の求める言語の文字列データを選ぶといった挙動が決められているっぽいのだが、Zephyrはそれに対応していないので、固定データとして「英語」という言語情報を名乗る機能のみを持っている。そのデスクリプタデータを定義しているのがUSBD_DESC_LANG_DEFINEだ。

その他はUSBD_DESC_STRING_DEFINEで定義できる。第一引数が変数名、第二引数がデータ、第三引数が何のデータかという内容だ。

なお、シリアルナンバーについては以下のようにしてハードウェア情報から自動生成することも可能になっている。

USBD_DESC_SERIAL_NUMBER_DEFINE(my_device_sn);

上記のようにデータをグローバルに定義したあとは、main関数の中などで登録する。

usbd_add_descriptor(&my_device, &my_device_lang);
usbd_add_descriptor(&my_device, &my_device_manufacturer);
usbd_add_descriptor(&my_device, &my_device_product);
usbd_add_descriptor(&my_device, &my_device_sn);

それから、デバイスクラスは別途以下のように専用の関数で登録する。

usbd_device_set_code_triple(
    &my_device,
    USBD_SPEED_FS,
    0xFF, // BaseClass
    0x00, // SubClass
    0x00 // Protocol
);

コンフィギュレーションデスクリプタ定義

コンフィギュレーションデスクリプタは以下のように定義・登録する。

// コンフィギュレーション定義
USBD_DESC_CONFIG_DEFINE(fs_cfg_desc, "FS Configuration"); // コンフィギュレーションを説明する文字列
USBD_CONFIGURATION_DEFINE(
    my_device_fs_config,
    0, // 属性情報(bmAttributesで要検索)
    125, // 最大電力(2mA単位なのでこの場合250mA)
    &fs_cfg_desc
);
// コンフィギュレーション登録
usbd_add_configuration(
    &my_device_usbd,
    USBD_SPEED_FS, // 通信速度情報
    &my_device_fs_config
);

サンプルの内部コードを読むとFSの設定とHSの設定をそれぞれ登録したりしているようなのだが、HSに対応したマイコンを持ってないので動作検証は出来ていない。

USBクラス

ZephyrのUSB Device Core APIにおいては「USBクラス」という概念がある。これは基本的にはUSBデバイスクラスを念頭に置いた概念である。機器の種類でありプロトコルである。

「USBクラス」オブジェクトには具体的にどのようなUSBパケットを送受信するか、またどのようなUSBデスクリプタ(デバイスデスクリプタとコンフィギュレーションデスクリプタ以外の)を提示するかの実装が含まれており、USBクラスを定義することでUSBデバイスとして動作できるようになる。

いくつかのUSBデバイスクラスについては、Zephyrが対応したUSBクラスを整備している。マウスやキーボードなどを作る分にはそれを利用すればよい。一方で独自のUSBデバイス、またはZephyrで実装していないデバイスクラスのUSBデバイスを実装するためには、自分でUSBクラスを定義し実装する必要がある。

USBクラスの定義

usbd_class_apiという構造体があって、これにさまざまなコールバック関数へのポインタを入れる。これによってUSBデバイスとしてのふるまいを定義する。

static const struct usbd_class_api usbd_my_custom_class_api = {
    .init = my_custom_init,
    .get_desc = my_custom_get_desc,
};

全てのコールバックを定義しなければならない訳ではない。必須と言えるのはinitget_descあたりで、他はoptionalのようだ。

各コールバックの定義については後述する。

USBD_DEFINE_CLASSを用いるとUSBクラスを定義できる。

USBD_DEFINE_CLASS(my_custom_class, &usbd_my_custom_class_api, NULL, NULL);

第1引数は名前、第2引数はコールバック関数を収めた構造体だ。第3引数はデータの受け渡しに使うための任意のポインタで、これは後で使う。

第4引数は何かオプショナルな機能のようなのだが詳しく調べていないため、NULLとしている。

USBクラスの登録

USBクラスを定義したらそれをusbd_register_class関数で登録する。

usbd_register_class(&my_device_usbd, "my_custom_class", USBD_SPEED_FS, 1);

第2引数にはUSBD_DEFINE_CLASSの第一引数に渡した名前を指定する。

コールバック定義

とりあえず、必須のinitget_descについて説明する。

initは単に0を返すだけで良い。初期化処理の必要があればここで行う。

int my_custom_init(struct usbd_class_data *const c_data) {
    return 0;
}

get_descはデスクリプタ情報を返す。デバイスデスクリプタとコンフィギュレーションデスクリプタについてはZephyr側でなんとかしてくれるため、インタフェースデスクリプタエンドポイントデスクリプタをここで提示する。必要であれば他のデスクリプタもここで提示する。

こんな感じのものを書く。

// インタフェースデスクリプタ定義
const static struct usb_if_descriptor my_custom_if_desc = {
    .bLength = sizeof(struct usb_if_descriptor),
    .bDescriptorType = USB_DESC_INTERFACE,
    .bInterfaceNumber = 0,      // インタフェース番号
    .bAlternateSetting = 0,     // インタフェース代替設定番号
    .bNumEndpoints = 1,         // エンドポイント数(エンドポイント0を除く)
    .bInterfaceClass = 0xFF,    // インタフェースクラス(0xFFはVendor specific)
    .bInterfaceSubClass = 0x00, // インタフェースサブクラス
    .bInterfaceProtocol = 0x0,  // プロトコル番号
    .iInterface = 0x0,          // インタフェースを説明するストリングデスクリプタ番号(0は未定義)
};

// エンドポイントデスクリプタ定義
const static struct usb_ep_descriptor my_custom_ep_bulk_in_desc = {
    .bLength = sizeof(struct usb_ep_descriptor),
    .bDescriptorType = USB_DESC_ENDPOINT,
    .bEndpointAddress = 0x81, // エンドポイントアドレス(bit7:IN(1)/OUT(0))
    .bmAttributes = USB_EP_TYPE_BULK,
    .wMaxPacketSize = sys_cpu_to_le16(64),
    .bInterval = 0x00,
};

// デスクリプタ配列定義
const static struct usb_desc_header *my_custom_desc[] = {
    (const struct usb_desc_header *)&my_custom_if_desc,
    (const struct usb_desc_header *)&my_custom_ep_bulk_in_desc,
    NULL,
};

インタフェースデスクリプタはusb_if_descriptor、エンドポイントデスクリプタはusb_ep_descriptor構造体で表されるのでその中身を埋めているだけだ。そこについては詳細な解説を避ける。

こうして定義したデスクリプタを、usb_desc_header*型配列に収める。この配列はNULL終端であることに注意する。

このデスクリプタ配列をget_descコールバックから返せばよいのだが、以下のようにしてはならない。

void *my_custom_get_desc(struct usbd_class_data *const c_data, const enum usbd_speed speed) {
    return my_custom_desc;
}

どういう理屈か知らないが、このようにグローバル変数をただ返すのではだめなのだ。

ここでUSBD_DEFINE_CLASSの第3引数を使う。ここに指定した値はusbd_class_get_private関数で自由に取得できる。要はC言語あるあるのコンテキスト保持用ユーザデータだ。

// データ保持用構造体
struct my_custom_class_data_t {
    const struct usb_desc_header **const descs;
};

struct my_custom_class_data_t my_custom_class_data = {
    .descs = my_custom_desc,
};
// 第3引数でmy_custom_class_dataを渡す
USBD_DEFINE_CLASS(my_custom_class, &usbd_my_custom_class_api, &my_custom_class_data, NULL);
// my_custom_class_dataを取り出している
void *my_custom_get_desc(struct usbd_class_data *const c_data, const enum usbd_speed speed) {
    struct my_custom_class_data_t *data = usbd_class_get_private(c_data);
    return data->descs;
}

このように一度ユーザポインタを経由する必要がある。これでデスクリプタ提示用の処理は完成した。

USBの有効化

最後にUSB機能を有効化する。

ret = usbd_init(&sample_usbd);
if (ret) {
    LOG_ERR("Failed to initialize device support");
    return ret;
}

k_msleep(100);

ret = usbd_enable(&sample_usbd);
if (ret) {
    LOG_ERR("Failed to enable device support");
    return ret;
}

100msのスリープは、本来こんなの要らないと思うのだが、なんか入れないとエラーが出る場合があるようなので入れた。ここの理由はよく分かっていない。


ここまでを実装したらとりあえず認識されるUSBデバイスが作れると思う。

通信処理(デバイス→ホスト)

USBの通信にはエンドポイントという概念がある。エンドポイントというのは通信の口のようなもので、同じエンドポイントは同じ通信方法・同じ方向での通信を行う。例えばデバイス→ホスト方向のバルク通信とホスト→デバイス方向のバルク通信の両方があれば、それぞれ別のエンドポイントを定義する必要がある。

上で定義したデスクリプタを再掲する。

// インタフェースデスクリプタ定義
const static struct usb_if_descriptor my_custom_if_desc = {
    /* (中略) */
    .bNumEndpoints = 1,         // エンドポイント数(エンドポイント0を除く)
    /* (中略) */
};

// エンドポイントデスクリプタ定義
const static struct usb_ep_descriptor my_custom_ep_bulk_in_desc = {
    .bLength = sizeof(struct usb_ep_descriptor),
    .bDescriptorType = USB_DESC_ENDPOINT,
    .bEndpointAddress = 0x81, // エンドポイントアドレス(bit7:IN(1)/OUT(0))
    .bmAttributes = USB_EP_TYPE_BULK,
    .wMaxPacketSize = sys_cpu_to_le16(64),
    .bInterval = 0x00,
};

// デスクリプタ配列定義
const static struct usb_desc_header *my_custom_desc[] = {
    (const struct usb_desc_header *)&my_custom_if_desc,
    (const struct usb_desc_header *)&my_custom_ep_bulk_in_desc,
    NULL,
};

これはデバイス→ホスト方向のバルク転送を行うエンドポイントを1つ持っているというデスクリプタになる。各エンドポイントは1バイトのアドレスを持っているが、これはデバイス側が(いくつかの注意点に従えば)任意の数字を使ってよい。

注意点として、第7bitは通信の方向を指定する。デバイス→ホスト方向(IN)の場合は1、ホスト→デバイス方向(OUT)の場合は0を指定する。USB通信において、IN/OUTという言葉はホスト視点で使う。

また、アドレス0番は特殊な意味を持つので普通の通信には避ける。従って典型的には0x01/0x81などを使う。

デバイスからの送信処理

こんな感じの処理を書く。

uint8_t dev_to_host_buf[256];

void submit_data(struct usbd_class_data *c_data, uint8_t ep, void* data, size_t len) {
    // バッファの確保
    struct net_buf *buf = usbd_ep_buf_alloc(c_data, ep, sizeof(dev_to_host_buf));
    if (buf == NULL) {
        LOG_ERR("failed to alloc memory");
        return;
    }

    // バッファにデータを追加
    net_buf_add_mem(buf, data, len);

    // キューにバッファを追加
    int ret;
    ret = usbd_ep_enqueue(c_data, buf);
    if (ret) {
        LOG_ERR("failed to enqueue: %d", ret);
        net_buf_unref(buf);
    }
}

usbd_ep_buf_allocを使うと、あるエンドポイントで通信するためのバッファを確保できる。ここで返ってくるのがnet_bufという型のもので、これはZephyrで定義されている通信関連に使うバッファ専用の型だ。これはZephyrのAPIを使うと色々触れるのだが、ここではnet_buf_add_memで送信したいデータを追加している。

net_bufについて詳しく把握したい場合はドキュメント(リンク)を見るとよい。

これで確保したバッファをusbd_ep_enqueueに指定するとキューに追加され、次に通信する機会があったときに送信される。機会があったときに、というのは、前提としてUSB通信はホスト主導なため、デバイスはホストからの問い合わせがあるときにしか送信できないのである。ただし普通は高速にポーリングされる(~数ms間隔)のでユーザーがそれを意識することは少ない。

usbd_ep_enqueueはあくまでキューに追加するだけなので、何か特に「このタイミングでなければ呼んではいけない」といった制約はない。いつでも実行して良い。

バッファの後処理

上のような処理で送信できるのだが、これだけでは問題がある。allocだけあってfreeが無いのでは上手くいかない。usbd_ep_buf_freeをどこかで行う必要がある。

これはrequestコールバックで行うのが正しいようだ。以下のような感じでやる。この実装をしないと、2回目以降で失敗する。

int my_custom_request(struct usbd_class_data *const c_data, struct net_buf *buf, int err) {
    struct usbd_context *ctx = usbd_class_get_ctx(c_data);
    return usbd_ep_buf_free(ctx, buf);
}

static const struct usbd_class_api usbd_my_custom_class_api = {
    .init = my_custom_init,
    .get_desc = my_custom_get_desc,
    .request = my_custom_request,
};

requestコールバックはホストからのポーリングに従って呼ばれる。送信処理が成功したらバッファはもう不要なので解放するというイメージだ。

PC側での受信

注意点として、PC側で何らかのソフトを使って受信しないとキューからの送信は行われないらしい。

何か専用のソフトを作ってデバイスをオープンしてデータを待ち受けなければ、デバイス側のキューは満杯になってしまう。行儀よくやるのであれば、デバイスが開かれているかどうかを確認してデータ送信を行うべきなのだと思う。そこの実装はちゃんと調べていない。

PC側の受信ソフトは本格的なデバイスドライバを書く手もあるのだが、libusbというライブラリや、WindowsであればWinUSBを使うとそこそこ手軽に作ることができる。この作り方については別の記事にしたい。

通信処理(ホスト→デバイス)

すでに説明したように、ホスト→デバイスとデバイス→ホストでは必ず別のエンドポイントを設ける必要がある。

こんな感じでデスクリプタを増やす。

// インタフェースデスクリプタ定義
const static struct usb_if_descriptor my_custom_if_desc = {
    /* (中略) */
    .bNumEndpoints = 2,         // エンドポイント数(エンドポイント0を除く)
    /* (中略) */
};

// エンドポイントデスクリプタ定義
const static struct usb_ep_descriptor my_custom_ep_bulk_in_desc = {
    .bLength = sizeof(struct usb_ep_descriptor),
    .bDescriptorType = USB_DESC_ENDPOINT,
    .bEndpointAddress = 0x81, // エンドポイントアドレス(bit7:IN(1)/OUT(0))
    .bmAttributes = USB_EP_TYPE_BULK,
    .wMaxPacketSize = sys_cpu_to_le16(64),
    .bInterval = 0x00,
};

const static struct usb_ep_descriptor my_custom_ep_bulk_out_desc = {
    .bLength = sizeof(struct usb_ep_descriptor),
    .bDescriptorType = USB_DESC_ENDPOINT,
    .bEndpointAddress = 0x01, // エンドポイントアドレス(bit7:IN(1)/OUT(0))
    .bmAttributes = USB_EP_TYPE_BULK,
    .wMaxPacketSize = sys_cpu_to_le16(64),
    .bInterval = 0x00,
};

// デスクリプタ配列定義
const static struct usb_desc_header *my_custom_desc[] = {
    (const struct usb_desc_header *)&my_custom_if_desc,
    (const struct usb_desc_header *)&my_custom_ep_bulk_in_desc,
    (const struct usb_desc_header *)&my_custom_ep_bulk_out_desc,
    NULL,
};

バッファ設定

こんな感じでデータ受信の準備をする。

void recv_data(struct usbd_class_data *c_data, uint8_t ep) {
    // バッファの確保
    struct net_buf *buf = usbd_ep_buf_alloc(c_data, ep, sizeof(host_to_dev_buf));
    if (buf == NULL) {
        LOG_ERR("failed to alloc memory");
        return;
    }

    // キューにバッファを追加
    int ret;
    ret = usbd_ep_enqueue(c_data, buf);
    if (ret) {
        LOG_ERR("failed to enqueue: %d", ret);
        net_buf_unref(buf);
    }
}

エンドポイントとしてOUTエンドポイントを指定すること以外はほぼ送信の方と変わらない。これをすることでデータ受信の準備が整う。

初回これをするのはenableコールバックあたりが妥当と思われる。enableコールバックはUSBデバイスのコンフィギュレーションが完了したときのコールバックで、このタイミング以降であればUSB通信の準備が整っている。

void my_custom_enable(struct usbd_class_data *const c_data) {
    recv_data(c_data, 0x01);
}

static const struct usbd_class_api usbd_my_custom_class_api = {
    .init = my_custom_init,
    .get_desc = my_custom_get_desc,
    .request = my_custom_request,
    .enable = my_custom_enable,
};

受信処理

データが来たときはrequestコールバックが呼ばれる。つまりそこでバッファに入ってきたデータを取り出せばよいのだが、しかしこれではデバイスからホストにデータを送信し終わったときと区別が付かない。どちらもrequestコールバックだからだ。

そこでどうするかというと、ここだけ部分的に下層のUDC APIを使うことでエンドポイントを取得できる。レイヤの違うAPIを叩くのはちょっと気持ち悪いが、これが定番のようだ。

// これをインクルード
#include <zephyr/drivers/usb/udc.h>
int my_custom_request(struct usbd_class_data *const c_data, struct net_buf *buf, int err) {
    // バッファからデータ取得
    struct udc_buf_info *info = udc_get_buf_info(buf);

    // この形でエンドポイント番号を取得できる
    info->ep;

エンドポイント番号次第で分岐させる。

    if (info->ep == 0x01) { // OUTエンドポイント
        // このようにデータにアクセスできる
        buf->data;  // データバッファ
        buf->len;   // データのバイト数
        return usbd_ep_buf_free(ctx, buf);
    }
    if (info->ep == 0x81) { // INエンドポイント
        return usbd_ep_buf_free(ctx, buf);
    }
    return 0;
}

これでデータを受け取れる。

バッファの再設定

実際には上のコードだと最初の1回しか成功しない。1回受信したらバッファを再設定しなければならない。

ということでこうする。

    if (info->ep == 0x01) {
        // データの受信処理...
        usbd_ep_buf_free(ctx, buf); // バッファの後処理
        recv_data(c_data, 0x01);    // バッファの再設定
        return 0;
    }
    if (info->ep == 0x81) {
        return usbd_ep_buf_free(ctx, buf);
    }
    return 0;
}

このように1回データを受信するたびバッファを再設定することで繰り返し受信が可能となる。


これで送信と受信の両方が実装できた。様々なデータ通信をするUSBデバイスが実装できるはずだ。

その他、調査中の事柄

初期化時にprotocol errorが出る

USB APIのログレベルをINFにするとUSBの初期化時に以下のようなエラーが出る。無視しても問題が起きて無さそうなので放置している。

<inf> usbd_ch9: protocol error:
<inf> usbd_ch9: setup:
                80 06 00 06 00 00 0a 00                          |........
<inf> usbd_ch9: not supported

PC側でデバイスを開かないとキューが詰まる

上にも書いた通り、PC側でデバイスをオープンしないとデータ送信キューが詰まってしまう。本来はその状態を検出した方が良いのだと思われる。

考えられる対処としてはカウンタを設けて、データ送信時に+1、requestで-1などするようにして、値が1を超過したら問題があると判断して送信しないようにするなどがある。

しかし実験した感じでは、必ずしも真面目に対処しなくてもそう大きな問題は起きないようだった。

バルク転送以外の通信

USB通信にはバルク転送以外にもアイソクロナス転送、インタラプト転送、コントロール転送がある。それらについての調査はここでは行っていない。また機会があったら別途調べたいと思う。

おわりに

電子工作をしていると「これPCと接続してみたいなあ...」と思うことは多い。かつてはDsub9ピンのシリアルポートが多くのPCに搭載されていたが、今どきのPCの外部ポートはUSBかHDMIか、さもなくばWiFiやBluetoothなどの無線くらいというのが実情だ。

この中で最も難度がマシなのはやはりUSBということになるだろう。

代表的なやり口はUSB-シリアル変換器を通したシリアル通信だが、シリアル通信はストリームなので通信の切れ目の扱いに問題を抱える。さらに一番重要な点として、オレオレデバイスを作ったという満足感に欠ける。また一定の知見があれば独自のレポートを発行するUSB HIDデバイスも選択肢の1つだが、これはインタラプト転送なので大容量の通信に問題を抱える。

その点、独自USBデバイスの自作は通信の速度、自由度、自作デバイスという特別感が揃っており、技術的難度を考えなければ非常に魅力的だ。ラズピコ1台あればトライできるので、ぜひZephyrを使った独自USBデバイスの開発を試してみてはいかがだろうか。

コメント