⌨️

ATmega328pでキーボードをつくる

2022/08/28に公開

USBを喋れないATmega328pでも、V-USBのおかげでUSBデバイスになれる。パソコン側の制御はlibusbを使えば書けるのだが、ふつうUSBキーボードやUSBマウスはパソコンに専用の制御ソフトをインストールしなくても使える。これは、USBの仕様の一部に「キーボードやマウスのような人間向けのインタフェースとなるハードウェアのクラス」が規定されていて、パソコン側にそのホストとしての動作をするソフトウェアがたいていインストール済だからだ。USBでは、このハードウェアのクラスに対する仕様のことを「HID」と呼んでいるので、この文脈で「HID」と言ったら、この「USBにおけるHIDの仕様」のことを指す。

V-USBでもHIDデバイスが作れるようになっている。自作キーボード界隈でお馴染のファームウェア開発用ライブラリであるQMKを利用すると、このV-USBのHIDを裏でいい感じに使ってくれるので、ATmega328pを利用したキーボードのファームを作れる。しかし、ここではQMKに頼らずにV-USBを生で使ってHIDキーボードを作る。

どんなキーボードにするか

自作キーボードというと、いっぱいあるキーを限られたマイコンの端子で制御するための配線とロジック(いわゆるマトリックス)に目がいきがちだけど、ここでは「ATmega328pしか載ってない状態でパソコンにキーボードとして認識してもらえるやつ」を作ることが目的なので、キーは1つ、「a」のみとする。でもそれだとさすがにつまらないので、「Shift」キーは別に用意することにする。したがって、「a」と「A」が入力できるキーボードをつくる。

HIDのざっくりとしたノリ

なんでもないUSBとの大きな違いは、HIDでは「HIDレポートデスクリプタ」と呼ばれる配列をデバイスからパソコン(ホスト)に送信することで、「このデバイスではこういう形でデータを送るよ」という情報を伝える必要があること。ホストは、定期的にデバイスにポーリングしつつ、HIDレポートデスクリプタで申告されたとおりにデータがやってくるのを待つ。

これをV-USBのコーディングの観点で翻訳すると、こうなる。

  • EEPROM上に usbHidReportDescriptor というバイト配列としてHIDレポートデスクリプタを準備する
  • usbFunctionSetup の中で、ホストがポーリングに使うメッセージに応じた分岐処理を書く
  • main でデバイスとしての機能を書く

以降、これから作るキーボードの場合を例に順番に説明する。

HIDレポートデスクリプタの準備

これはバイトの配列なので人間が書くことは前提になっていないぽい。HID Descriptor Toolというツールが用意されているので、それを使えとのことなんだけど、ここではずるをして、V-USBのサイトで公開されているキーボード作例のコードから拝借する。ほかにも今回のコードは全体にこの作例を参考にしている。

const PROGMEM char usbHidReportDescriptor[35] = {   /* USB report descriptor */
  0x05, 0x01,                    // USAGE_PAGE (Generic Desktop)
  0x09, 0x06,                    // USAGE (Keyboard)
  0xa1, 0x01,                    // COLLECTION (Application)
  0x05, 0x07,                    //   USAGE_PAGE (Keyboard)
  0x19, 0xe0,                    //   USAGE_MINIMUM (Keyboard LeftControl)
  0x29, 0xe7,                    //   USAGE_MAXIMUM (Keyboard Right GUI)
  0x15, 0x00,                    //   LOGICAL_MINIMUM (0)
  0x25, 0x01,                    //   LOGICAL_MAXIMUM (1)
  0x75, 0x01,                    //   REPORT_SIZE (1)
  0x95, 0x08,                    //   REPORT_COUNT (8)
  0x81, 0x02,                    //   INPUT (Data,Var,Abs)
  0x95, 0x01,                    //   REPORT_COUNT (1)
  0x75, 0x08,                    //   REPORT_SIZE (8)
  0x25, 0x65,                    //   LOGICAL_MAXIMUM (101)
  0x19, 0x00,                    //   USAGE_MINIMUM (Reserved (no event indicated))
  0x29, 0x65,                    //   USAGE_MAXIMUM (Keyboard Application)
  0x81, 0x00,                    //   INPUT (Data,Ary,Abs)
  0xc0                           // END_COLLECTION
};

初見だとびっくりするけど、「これでこのデバイスからホストに送るキーについての情報のデータ構造が決まっているんだな」と理解しておけばよさそう。したがって、コード中でのレポートのデータ構造やその取り回しについても、同じ作例からそのまま使うことになる。具体的には、各キーを {modifier, key} という2要素の配列とし、ホストに送られるデータを作るときに先頭2バイトを読み出す、というやり方をしている。

static const uchar keyReport[KEYNUM+1][2] PROGMEM = {
  {0, 0}, // no key pressed
  {0, 4}, // A 
  {0, 5}, // B
  ...

static void buildReport(uchar key) {
  *(int *)reportBuffer = pgm_read_word(keyReport[key]);
}

ちなみに、{modifier, key} のそれぞれに何の値を指定するかはHIDの仕様として定められていて、HID Usage Tablesというのを見ることになっている。

ここで注意が必要なのは、このHIDレポートデスクリプタのサイズを usbdrv/usbconfig.h という設定ファイルで下記のように明示してあげないといけないこと。

#define USB_CFG_HID_REPORT_DESCRIPTOR_LENGTH    35

これを正しく設定しないと、デバイスをパソコンに接続して dmesg したときに "unknown main item tag 0x0" のようなエラーメッセージが出て頭を抱えることになる。 usbdrv/usbconfig.h は、このほかにもちょくちょく手を入れることになるので、存在を忘れないようにしよう。

usbFunctionSetup 内でホストのポーリングに答える処理

このへんは定型なので、自分でわかりやすいコードでかけばよさそう。

usbMsgLen_t usbFunctionSetup(uchar data[8]) {
  usbRequest_t *rq = (void *)data;

  usbMsgPtr = reportBuffer;
  if((rq->bmRequestType & USBRQ_TYPE_MASK) == USBRQ_TYPE_CLASS) {
    switch(rq->bRequest) {
    case USBRQ_HID_GET_REPORT:
      buildReport(keyPressed(0));
      return sizeof(reportBuffer);
    case USBRQ_HID_GET_IDLE:
      usbMsgPtr = &idleRate;
      return 1;
    case USBRQ_HID_SET_IDLE:
      idleRate = rq->wValue.bytes[1];
    }
  }
  return 0; 
}

キーボードの機能を書く

キーが一個しかないのでシンプルに「キーを接続したポートをプルアップしておいて、キーが押されて電圧が下ったのを検知したら、ホストがポーリングしてるバッファにデータを用意する」という処理を書く。ただ、バッファに書き込むだけでなく、usbInterruptIsReady()usbSetInterrupt() というのを使う必要があるらしい。押されたキーから keyReport のエントリを取り出す関数 keyPressed があるとしたら、こんな感じ書くことになる。

state = STATE_SEND_KEY;
if (usbInterruptIsReady()) {
  switch(state) {
  case STATE_SEND_KEY:
    buildReport(keyPressed());
    state = STATE_RELEASE_KEY;
    break;
  case STATE_RELEASE_KEY:
    buildReport(0);
    state = STATE_SEND_KEY;
    break;
  default:
    state = STATE_SEND_KEY;
  }
  usbSetInterrupt(reportBuffer, sizeof(reportBuffer));
}

ここで、usbInterruptIsReady()usbSetInterrupt()を使うには usbdrv/usbconfig.h で以下に0以外の値を設定する必要がある(デフォルトは0なので注意)。

#define USB_CFG_HAVE_INTRIN_ENDPOINT    1

あとは keyPressed を定義すればいい。キーをB1に接続するならこんな感じになる。

static uchar keyPressed(void) {
  if (!(PINB & 1<<PB1)) {
    return 1;
  }
  return 0;
}

動作確認

以上で動くはずだなと思ってコンパイルしてフラッシュして接続しても、これだけだとHIDデバイスとして認識されない。実はまだ usbdrv/usbconfig.h に設定が必要な値がある。

#define USB_CFG_INTERFACE_CLASS     0x03   //HID
#define USB_CFG_INTERFACE_SUBCLASS  0
#define USB_CFG_INTERFACE_PROTOCOL  0

このキーボードでは一番上の値だけを設定すればいいけれど、なんかもっと凝ったことをするときは下二つも必要になるっぽい。よくわかっていない。

ここまでの成果に、さらにShiftキーの機能を付けると、「A」と「a」だけが入力できるキーボードになる。最終的なコードはここ

QMKとPro Microなら簡単

なんだかんだいって本格的なキーボードにするにはマトリックスをまじめにやらないといけないし、レイヤーもほしい。そうなると、素直にQMKを使えばいいことになる。さらにQMKなら、原理的にはI2C接続で分離型も実現できるはず。

実は、SU120でキースイッチを配線して、QMKとATmega328pで左右分離キーボードを作ってみたりはした。しかし、現実は厳しくて、動きはするけど反応が悪くてぜんぜん使い物にならない。その過程でV-USBとHIDについていろいろ調べたので簡単にメモを残しておくことにしたのが本記事です。

Discussion