TL; DR
- Keyball46はいいぞ
- ProMicroにはAVR版とRP2040版がある
- ピン配置の互換性はあれど、命令セットは完全に異なるので「ファームウエアを書けとささやくのよ、私のゴーストが」という方以外はAVR版を間違えずに買いましょう
- まあ最悪間違えても一週間くらいでなんとかなる(当社調べ)
- みんな自作キーボードをやろう!
はじまり
みなさんは、Keyball46という素晴らしい自作キーボードキットをご存知ですか?
そう、なんと、キーボードにトラックボールがついてるんです!最高ですよね!
ということで、秋葉原にある自作キーボードのお店こと遊舎工房さんから速攻でポチりました。ちょうど安いキーボードを一個水没させたところですし、自作キーボードも本格的にやりたかったし、年末休みで暇を持て余していますからね。
役者は揃った。あとは作るだけ…。(キーキャップが想像以上に美味しそうでニヤニヤしている。) pic.twitter.com/xZM8ZJaWyQ
— hikalium (@hikalium) 2021年12月24日
期待に胸を躍らせつつ、はじめての表面実装ダイオードのハンダづけを難なくこなし、
やっておる(はじめての表面実装) pic.twitter.com/1j29kwJ8JU
— hikalium (@hikalium) 2021年12月24日
さて、動作チェックとしゃれこみますか、と思ってファームウエアを焼こうとしたところ、自分の犯したミスに気づきます。
さて、ここに至ってやらかしに気づきました(ATmega32U4搭載のPro MicroではなくRP2040搭載のPro Microを買ってしまっていた(これはファームウエアを自分で書けという天啓か?)) https://t.co/YL3Un4njkm
— hikalium (@hikalium) 2021年12月24日
はい、パーツを間違えました。ゲームオーバーです。…本当に?
どこで道を踏み外したのか
さて、Keyball46の組み立て説明には、このように書かれていました。
(念の為、これは組み立て説明の落ち度ではなく、私の無知が原因です。私が失敗に至るまでの思考過程をお伝えするために引用しており、責任は私にあります。)
1-2.組立前にお客様自身で準備いただく部品
...
ProMicro 2個 安価なものやUSB Type-C対応のもの等選べます
...
…なるほど!Pro Microっていうマイコン基板を買えばいいのね! 遊舎工房で「Pro Micro」検索、っと…。
お、何種類かあるんだ。せっかくだし USB Type-C のがいいなあ、これを買うか!(SparkFun Qwiic Pro Micro - USB-C (ATmega32U4)の商品ページに飛んでカートに入れようとする)
(ここでも念を押しておきますが、すべての落ち度は買うものを間違えた私にあります。みなさんは適当にポチらずにきちんと調べてから買いましょう。)
あれ、これは売り切れかあ。半導体不足の影響もあるのかなあ。じゃあこっちはどうかな?(もう一個の似た見た目のやつをクリック)
わーい、買えそうだ!これで行こう!楽しみだなあ!!
…おわかりいただけただろうか。(ここが運命の分かれ道だった。)
Pro Microの種類
実はPro Microと名のつくボードには、コネクタの形状や互換品か否かという些細な点を除けば、大きく分けて少なくとも2種類が存在します。
見た目は非常に似ているのですが、何がちがうのかというと、載っているマイコンが根本的に違います。
前者はATmega32U4, つまりAVRマイコンです。 後者は、RP2040…これはRaspberry Piと同じ、ARMマイコンです。
さて、Keyball46が標準で提供しているファームウエアは…
# MCU name MCU = atmega32u4 ...
はい、ATmega32U4用ですね。おつかれさまでした。
「ファームウエアを書けとささやくのよ、私のゴーストが」
さて、困りましたね。再度ATmega32u4のPro Microを注文するのも時間がかかるだろうし、たとえそうしても、今手元にあるこのRP2040は無駄になってしまいます。それはマイコンがかわいそうですよね。
「…ファームウエアがないなら、書けばいいじゃない!」
たしかに!そうしよう!(白目)
方針を考える
とりあえず脳内で盛り上がってるマリーアントワネットと草薙素子は放置して、さっさと先に進みましょう。
最初はQMK firmwareをRP2040向けにビルドできないかとか少し調べたのですが、面倒なのでやめました(面倒とは!?)
あとベアメタルな環境でRustを書きたかったんです、はい。
幸い、ファームウエアのソースコードも回路図も公開されています。なんとかなりそうですね。(keyballの作者であるYowkeesさん、ありがとうございます!)
きっとRP2040のサンプルコードはインターネットにたくさん転がっているでしょうし、まずはそれを試してから、Rustでどう書くか考えることにしましょう。
まずは Hello, world だよね
そもそも、プログラムの書き込み方からわからないマイコンを相手にファームウエアを書き始めるのは無謀というものですから、まずは入門からやっていきましょう。
幸い、ネットの海を調べたところ、RaspberryPi公式でサンプルプログラムが公開されていました。
しかも、いくつかのプログラムはprebuiltのバイナリまで提供されています。便利!(上記リンク中のテーブルの Link to prebuilt UF2 というカラムから飛べます。)
とりあえずhello_usbのプログラムを書き込んでみましょう。
…え、どうやって書き込むの?
BOOTSEL mode, picotool
とりあえずネットの海を探ると、picotoolというCLIツールが見つかり、ボードをBOOTSELモードというのに持っていくと、picotoolを使ってプログラムを書き込める(もしくはストレージがUSBメモリのように認識されるので、そこにファイルをコピーする方法も可)ということがわかりました。
BOOTSELモードの入り方は、ボードの提供元であるSparkfunのページに画像付きで書かれています。
基本的に、ボード上にある2つのボタンはRESETボタンとBOOTSELボタンになっているので、BOOTSELを押しながらRESETを押せばBOOTSELモードでボードが起動してプログラムが書き込めます。
というわけで、いずれかの方法でhello_usbのバイナリ(hello_world.uf2
)を書き込むと、なんとPro MicroがUSB modemとして振る舞い始めます。
$ ls /dev/tty.usb* /dev/tty.usbmodem0000000000001
これの出力をscreenコマンドで見てみると、
$ screen /dev/tty.usbmodem0000000000001 Hello, world! Hello, world! Hello, world! ...
OK, RP2040の世界にこんにちはできました。
でもね、そのボタン、押せないの…
さて、じゃあこの調子でファームウエアを書いていきましょう…と言いたいところなのですが、一つ困ったことがわかりました。
この画像を見てください。(逆転裁判で証拠品を提示するときの効果音)
「この写真には、制作途中のKeyball46にPro Microが挿さっている様子が写っています。」
「いたって普通のプリント基板とPro Microですね。何か問題でも?」
「Pro Microの部分を見てください。何かに気づきませんか?」
「いえ、いたって普通のPro Microだと思いますけど。先ほどあなたがおっしゃっていた通り、RP2040版であるという点は異常ですが、それはファームウエアを書くという話で決着がついたはずですよね。さっさとファームウエアを書き始めたらどうなんですか?」
「ええ、私もそうしたいところです。ファームウエアが書き込めれば、の話ですが。」
「そんなのボタンを押せばいいってさっき…ああああああっ!!!」
「そう、ないんですよ。ボタンがね。」
…おわかりいただけただろうか?なんとこの基板、Pro Microを裏面に装着するように設計されているため、ボタンが押せないのです。
AVR版のPro Microだと、電源投入時にリセットボタンを長押しや連打することで書き込みモードに移行できるため、リセットボタンはタクトスイッチで基板上から操作できるように設計されているのですが、RP2040版の場合は、リセットボタンだけではなくBOOTSELボタンを押す必要があるのです。しかし、それは押すことができない!
ラズパイでラズパイにプログラムを書き込む - SWDって便利だな
ソフトウエア開発において、開発とデバッグの繰り返しを迅速に行うことは、開発効率の面からも非常に重要です。 最悪、Pro Microを毎回外して書き込み、そして基板上に戻す、ということも、コンスルーピンのおかげでできなくはないでしょうが、非常に面倒ですし、そもそもそう何度も抜き差ししていたら接触不良の原因になるでしょう。なにしろやる気がダダ落ちです。
では、どうするか…上の画像を見ると、Pro Microの下側、qwiicと書いてあるところの上に、DとCと書かれたランドが露出しているのが見えます。
これは一体なんでしょうか?
SWD Pins For advanced users, there are two pins (i.e. D for data/SWDIO and C for clock/SWCLK) on the back broken out for SWD programming on the back of the board. You'll need to solder wire to connect to these pins.
Pro Micro RP2040 Hookup Guide - learn.sparkfun.com
ええ、私たちはAdvanced usersなので、これを使いましょう(満面の笑顔)。
CoreSight Architecture | Serial Wire Debug – Arm Developer
ARM社のドキュメントによれば、SWD(Serial Wire Debug)を利用すると、JTAGと同じようなことができる、と書いてあります。
まあ簡潔にいえば、これをよしなにopenocd経由で操作してあげれば、レジスタ読み書きし放題、メモリ書き換え放題、要するになんでもやりたい放題できるということです。
まあファームウエアを開発するんだから、これくらいやってもよいでしょう。
まずはさくっとジャンパワイヤをSWDのランドにはんだ付けして、
もう一台どこのご家庭にも転がっているRaspberry Piを持ってきて、DをGPIO24, CをGPIO25に繋ぎます。(このピンアサインは、openocdに付属しているconfigファイルに定義されています。)
あとは、Raspberry Pi上で、Raspberry Piのフォーラムに書いてあったいい感じのopenocdのコマンドラインを参考に適当にopenocdさんを呼び出すと
openocd -f interface/raspberrypi-swd.cfg -f target/rp2040.cfg -c '"targets rp2040.core0; program ${PATH_TO_ELF} verify reset exit"
${PATH_TO_ELF}
に指定したプログラムが書き込まれてターゲットが再起動します。これは便利!(書き込まれたバイナリはちゃんと永続化されているので、次回以降の起動時もこれが使われます。)(先ほど紹介したprebuiltのUF2ファイルはこの方法では読めないので、サンプルを手元でビルドして、UF2に変換される前のELFファイルを指定してあげてください。)
よし、これでPro MicroをKeyball46の基板に挿しっぱなしでも大丈夫になりました。やっとファームウエアを書くステップに行けますね!
RustでRP2040をターゲットにしたプログラムを書く
先程紹介した raspberrypi/pico-examples は、残念ながらC言語で書かれています。
せっかく書くなら、もっと現代的な言語で書きたいですよね?ということで調べてみると、RP2040向けのHALを提供してくれるcrateが見つかりました。
フルスクラッチで書くのも面白そうですが、動くとわかってからでも遅くはないので、ひとまずはこれを使うことにしましょう。
先ほどと同様に、まずはサンプルを試してみます。
boards/sparkfun-pro-micro-rp2040/examples/sparkfun_pro_micro_rainbow.rs に、Pro Micro (RP2040) 用のサンプルがあるので、これをREADME.mdに書いてある通りにビルドします。
cargo build --release --example sparkfun_pro_micro_rainbow
そうすると、target/thumbv6m-none-eabi/release/examples/pro_micro_rainbow
にELFファイルが生成されるので、これを先程の方法で書き込んでみます。すると、LEDが虹色に光り始めます。やったね!(下の画像で緑色のLEDがそれです。)
USB HID として振る舞いたい
さて、私たちはもう終わったクリスマスの電飾を作りたかったわけではなくて、キーボードとマウスとして振る舞う何かを作りたかったのでした。
これを達成するためには、Pro Microさんに USB HID として振る舞ってもらう必要があるのですが、それをゼロからちょっと実装するのは大変です。
そこで、rp-rs/rp-hal内の他のboard向けのサンプルを眺めてみたところ、boards/rp-pico/examples/pico_usb_twitchy_mouse.rs という、まさにこれがほしかった!みたいなサンプルが見つかりました。なので、これをさくっとsparkfun_pro_micro向けに移植してあげればOKです。
実はpico向けにビルドしたバイナリを書き込むと普通に動いてしまうんですが(まあ同じSoCを使ってるからそれはそう)、せっかくHALがボードごとにあるので、ちょっと手直ししてあげるといいと思います。(適当にやった。)
はい、これで「PCに繋ぐとマウスが小刻みに上下するので画面ロックを回避できる邪悪デバイス」ができあがりました。意外と簡単だ!
キーボード機能を追加する
ではこの調子でBadUSBキーボード入力もできるようにしましょう。まずは、さっきのサンプルの実装を読んでみます。
// Create a USB device with a fake VID and PID let usb_dev = UsbDeviceBuilder::new(bus_ref, UsbVidPid(0x16c0, 0x27da)) .manufacturer("Fake company") .product("Twitchy Mousey") .serial_number("TEST") .device_class(0xEF) // misc .build(); unsafe { // Note (safety): This is safe as interrupts haven't been started yet USB_DEVICE = Some(usb_dev); }
ここでデバイスを作って、
// Move the cursor up and down every 200ms loop { delay.delay_ms(100); let rep_up = MouseReport { x: 0, y: 4, buttons: 0, wheel: 0, pan: 0, }; push_mouse_movement(rep_up).ok().unwrap_or(0); delay.delay_ms(100); let rep_down = MouseReport { x: 0, y: -4, buttons: 0, wheel: 0, pan: 0, }; push_mouse_movement(rep_down).ok().unwrap_or(0); }
あとはここでマウスの動きをpushしているようです。push_mouse_movementの実装は、以下のような感じです。
fn push_mouse_movement(report: MouseReport) -> Result<usize, usb_device::UsbError> { cortex_m::interrupt::free(|_| unsafe { // Now interrupts are disabled, grab the global variable and, if // available, send it a HID report USB_HID.as_mut().map(|hid| hid.push_input(&report)) }) .unwrap() }
ここで、自作OSでUSB HIDドライバを実装していた際の知識が役に立ってきます。
まず、USB HID では、マウスの移動やキーボードの押し下げなどの情報は、レポートというデータとしてデバイスから送られてきます。
そして、ここが重要なのですが、USBはプロトコル的に、常にホスト側からしかデータの転送を開始できない仕組みになっています。これはどういうことかというと、USBプロトコルのレイヤでは、割り込みという概念が存在せず、ポーリングしかできない、ということです。
ですが、キーボードやマウスが動くのは(CPUさんの時間軸から見たら)稀なので、ポーリングをするのは非効率的な気がしますよね?そこで、USBでは、USBコントローラがUSBデバイスに対して定期的にデータ転送を要求して(ポーリング)、データがあればUSBコントローラからCPUに割り込みを発生させる、という仕組みになっています。
というわけで、上のpush_mouse_movementが呼ばれても、実際にはまだホスト側にはその情報は伝わりません。その代わり、USBコントローラーが定期的に読んでくるとデバイス側で割り込みが発生するので、そのタイミングでデータを送りつけてあげることになります。
#[allow(non_snake_case)] #[interrupt] unsafe fn USBCTRL_IRQ() { // Handle USB request let usb_dev = USB_DEVICE.as_mut().unwrap(); let usb_hid = USB_HID.as_mut().unwrap(); usb_dev.poll(&mut [usb_hid]); }
これが、その「デバイス側の割り込みハンドラ」です。これが呼ばれるタイミングで、デバイスからホストへとデータ転送が走ることになります。
この割り込みの周期については、もっと上の方で
let usb_hid = HIDClass::new(bus_ref, MouseReport::desc(), 60);
とすることで、HIDClassを初期化する際に、60msに設定されています。詳細は、docs.rsにあるドキュメントを読むとわかりやすいと思います。
ここまでが、大まかなサンプルのデータの流れです。
では、これを改造して、キーボードの情報も送るにはどうすればよいでしょうか?
usbd-hidのドキュメントをさらに読むと、MouseReportの隣にKeyboardReportがあることがわかります。これを使えばなんとかいけそうですね。
見よう見まねで、こんな感じにしてみます。(振り返って書いているので雰囲気だけ掴んでください。)
static mut USB_HID_KEYBOARD: Option<HIDClass<hal::usb::UsbBus>> = None; fn main() -> ! { ... let usb_hid_keyboard = HIDClass::new(bus_ref, KeyboardReport::desc(), 10); unsafe { USB_HID_KEYBOARD = Some(usb_hid_keyboard); } ... loop { ... let rep = KeyboardReport { modifier: 0, reserved: 0, leds: 0, keycodes: [0x04 /* A */, 0, 0, 0, 0, 0], }; push_key_event(rep).ok().unwrap_or(0); ... } .... } pub fn push_key_event(report: KeyboardReport) -> Result<usize, usb_device::UsbError> { cortex_m::interrupt::free(|_| unsafe { USB_HID_KEYBOARD.as_mut().map(|hid| hid.push_input(&report)) }) .unwrap() } ... #[allow(non_snake_case)] #[interrupt] unsafe fn USBCTRL_IRQ() { // Handle USB request let usb_dev = USB_DEVICE.as_mut().unwrap(); let usb_hid_mouse = USB_HID.as_mut().unwrap(); let usb_hid_keyboard = USB_HID_KEYBOARD.as_mut().unwrap(); // new usb_dev.poll(&mut [usb_hid_mouse, usb_hid_keyboard /* new */]); } ...
KeyboardReportのkeycodesについては、HID Usage Tables FOR Universal Serial Bus (USB)の10: Keyboard/Keypad Page (0x07)
に定義があります。また、ここにpdfよりも軽い表があります。
これで、マウスの上下移動とともに、a
が入力され続けるはずです。
実際、動きました。…Linuxでは。
しかし、なぜかmacOSではマウスもキーボードも動かなくなってしまいました。一体なぜでしょうか?
Boot keyboard のフォーマットに切り替える
osdev.orgの記事によれば、USBキーボードのReportの形式は、
pub struct KeyboardReport2 { pub modifier: u8, pub reserved: u8, pub keycodes: [u8; 6], }
のような形式になっているようです。…あれっ、先ほど利用したusbd-hidのKeyboardReportには存在する、ledsのフィールドがないですね…。
これは完全に想像ですが、macOSではUSBキーボードのレポートディスクリプタをきちんと解釈せず、Boot protocolに沿っていると仮定しているのではないでしょうか。
レポートディスクリプタというのは、Reportがどのような形式になっているのかをUSBデバイスがUSBホスト側に伝えるためのデータで、
let usb_hid_keyboard = HIDClass::new(bus_ref, KeyboardReport::desc(), 10);
この行の KeyboardReport::desc()
がそれにあたります。このdescは、ソースを確認すると
#[gen_hid_descriptor( (collection = APPLICATION, usage_page = GENERIC_DESKTOP, usage = KEYBOARD) = { (usage_page = KEYBOARD, usage_min = 0xE0, usage_max = 0xE7) = { #[packed_bits 8] #[item_settings data,variable,absolute] modifier=input; }; (usage_min = 0x00, usage_max = 0xFF) = { #[item_settings constant,variable,absolute] reserved=input; }; (usage_page = LEDS, usage_min = 0x01, usage_max = 0x05) = { #[packed_bits 5] #[item_settings data,variable,absolute] leds=output; }; (usage_page = KEYBOARD, usage_min = 0x00, usage_max = 0xDD) = { #[item_settings data,array,absolute] keycodes=input; }; } )]
このようなマクロによって生成されています。
おそらく、Linuxはこれを解釈して正しくReportを読み取れるのですが、macOSは期待しているデータ構造と送られてきたReportが一致しないため、そこであきらめてしまっているのではないかと考えられます。
というわけで、osdev.orgやUSBのドキュメントに記載されている、Boot protocolの形式のほうを利用することにしましょう。これで、macOSでもLinuxでも動きます!
その他のハマりポイント
勢いで書いていたらめっちゃ記事が長くなってきたので、残りはさくっといきます。もし詳しく知りたい方がいらっしゃったら、Twitterなどでhikalium宛にリプライを飛ばすか、低レイヤーガールチャンネルの配信の際にでもコメントで聞いてください。
トラックボール基板がお返事してくれない…
Pro Micro にピンをつけた際、はんだが流れて NCSとMOSIがショートしてた
状況: トラックボール制御基板さんにSPI通信でお話ししても0xFFしか返ってこない。ロジアナで見たらNCSとMOSIが仲良く同じ波形を出してた。
解決策: 一回該当するピンのハンダを除去してやりなおし。
NCSがトランザクションが終わるよりも前にhighに戻っちゃう
状況: ショートを直してもまだお返事もらえない。波形を見てみると、NCSが一瞬しかlowになってなかった。
原因: rp-rs/rp-halのSpi::writeは、データレジスタに書き込むだけで、送信完了を待ってはくれないのであった。(涙)
解決策:
rp-halをforkしてSpi::deviceをとれるようにし、それを直接使ってステータスレジスタを読み、sendが完了するのをloopで待つようにした。
loop { let sspsr = spi.borrow_mut().device().sspsr.read(); if sspsr.tfe().bit_is_set() && sspsr.bsy().bit_is_clear() { break; } delay.borrow_mut().delay_us(10); }
まとめ
とりあえず動くようになった。
まだキーマップが練りきれていないのと、TRRSケーブルを介した接続には対応していないほか、私はこれまでErgo Dox EZを使っていたので、いかにこのキーの少なさで生きていくかなど課題は山積みですが、少しずつ実運用に投入していきたいと思います。(この記事の一部はこれで書きました。)
さて、普通にトラックボールも動くようになりましたが…(あとは左手側を組み上げるだけ) pic.twitter.com/BTYvj407YZ
— hikalium (@hikalium) 2021年12月30日
自作キーボード、たのしい!みんなもやろう!!
謝辞
Keyball46を設計されたYowkeesさん、自作キーボードに必要なパーツを販売している遊舎工房さん、そして数年前のCookpad自作キーボードインターン(?)で自作キーボードの基礎を教えてくれたKOBA789さん、ありがとうございました!