UIAPduinoで名刺サイズのUSBテンキーを自作する

はじめに
Blenderライフ、楽しんでいますか。
Blenderを使っていると、視点移動のショートカットがテンキー前提であることに、遅かれ早かれ気づくことになります。テンキーの1で正面、3で右側面、7で真上。「テンキーを模倣」設定で凌ぐこともできますが、数字キーを潰すことになるので、1, 2, 3キーで頂点・辺・面を切り替えられなくなります。これはモデリング中に致命的に不便です。結局テンキーがあった方が圧倒的に快適なのです。
というのも、私はテンキーのないキーボードに乗り換えてから、この不便をずっと感じていました。
そこで、GMK26というキーパッドを買って有線で使っていたのですが——

大きすぎる。
マクロボタンとかロータリーエンコーダーとか、色々な機能がついているのですが、正直テンキーとしてしか使っていません。もっと言うと、ファンクションキーやDeleteキーも使っていません。必要なのは0〜9、四則演算、ピリオド、Enterだけです。テンキーに必要なものだけを残して、可能な限りコンパクトにしたい。ベゼルもいらない。

GMK26からテンキー部分のキーキャップだけ外してみると、「これくらいのサイズならかなりコンパクトにできるのでは?」という気持ちになりました。
既製品を探す
まず既製品を探してみましたが、「ベゼルレスで極限までコンパクトなテンキー」という商品は見つかりません。テンキーというのは利便性のための入力デバイスなので、多少大きくても困らないというのがメーカーの判断なのでしょう。
自作キーボード界隈を調べると、サリチル酸氏が設計したKeyFuda01というテンキー作成用基板を見つけました(遊舎工房で委託販売されています)。
とても良さそうに見えましたが、今回はもっともっと、できるだけ小さくしたいのでパスしました。キースイッチの間隔はそのままに、上下左右のベゼルを限界まで削り、筐体ごと自分で設計したい。そういった商品は探す限り見当たらなかったし、ちょうど自作キーボードに興味があったので、これを機に全部自分で作ってみることにしました。
設計
筐体のモデリング
まずはBlenderで筐体を設計しました。Blender用のテンキーをBlenderで設計するという、マッチポンプ的な状況です。

キーボードのスイッチには規格があって、Cherry MX互換スイッチの場合、取り付け穴は14mm×14mmの正方形、キーピッチ(隣のキーとの中央間距離)は19.05mmが標準です。この寸法でキースイッチがはまるような穴を並べた板をモデリングしました。
今回は3列×5行、ただし最下段の0キーは2Uサイズ(2キー分の幅)にしたので、合計14キーの配置です。標準的なテンキーのレイアウトそのままですね。

Bambu Lab A1 miniの3Dプリンターで出力するとこんな感じ。素材はPLAの白色マットです。

上部(スイッチプレート)と下部(底面)の2パーツ構成で、四角い直方体のブロックで接続する設計にしました。
ここで一つ大変だったのが、クリアランスの調整です。CADソフトではなくBlenderで設計しているので、寸法の管理がどうしてもCADほど厳密にはいきません。「0.1mm広くしたらスカスカ」「0.1mm狭くしたらハマらない」を何度も繰り返して、3Dプリンターで出力しては確認、出力しては確認を繰り返しました。

ただ、最終的にはどのパーツもカチッとはまって、力を加えないと外れないようになったので良かったです。3Dプリンターの精度に助けられました。
ホットスワップソケット
キースイッチの固定には、Kailhのホットスワップソケットを使いました。
これは基板にはんだ付けして使うのが本来の用途ですが、今回は3Dプリントした筐体に直接はめ込んでいます。ホットスワップにしておくと、いろんなキースイッチを気軽に差し替えて試せるのが自作キーボードの醍醐味ですよね。AliExpressで70個500円程度で購入できます。
仮組み
パーツが揃ったので仮に組み立ててみました。

マイコン(後述するUIAPduino)を底面に取り付けて——

底面と上部をサンドイッチにして——

試しにKailh Jellyfish軸をつけてみました。透明なハウジングがとても綺麗です。

キーキャップもつけてみました。キーキャップはGMK26のものを拝借しています。0キーは2Uサイズで、スタビライザーをつけていないのですが、意外とぐらつかないものですね。

完成予想図はこんな感じ。かなりコンパクトにまとまっています。

名刺(91mm × 55mm)を重ねてみたら、ほぼぴったり。名刺サイズのテンキーです。
マイコンの選定
UIAPduino Pro Micro CH32V003
さて、キーボードのファームウェアを動かすマイコンですが、自作キーボード界隈ではRP2040やATmega32U4(Pro Micro)が定番です。QMKやVIAといった高機能なファームウェアフレームワークが使えて、キーマップのカスタマイズもGUIでできる。
しかし、たかだか14キーのテンキーに、RP2040やATmega32U4を使うのはオーバースペックだと思いました。別にVIAでキーマップを書き換えたりしないし、RGBバックライトも不要です。テンキーの0〜9と四則演算とピリオドを送信するだけの、シンプルなファームウェアでいい。
そこで目をつけたのが、UIAPduino Pro Micro CH32V003です。
これは、WCH社のCH32V003というRISC-Vマイコンを搭載した、Pro Microフォームファクターのボードです。290円。RP2040のPro Micro互換ボードが1,000円前後することを考えると、破格です。
UIAPduinoの詳しい紹介はfabsceneさんのインタビュー記事に良くまとまっているので、そちらを参照してください。
このマイコンで特に面白いのは、ソフトウェアでUSBを実装しているということです。CH32V003にはUSBハードウェアペリフェラルが搭載されていません。にもかかわらず、GPIOのビットバンギングでUSB Low-Speedプロトコルを実現しています。rv003usbというライブラリがこれを可能にしていて、HIDデバイス(キーボード、マウスなど)として動作させることができます。
もう一つの利点は、ピン数が十分にあるということ。使用可能なGPIOピンが13本以上あるので、14キーのテンキー程度なら、キーボードでよく使われるマトリクス配線(行と列の格子状にキーを配線して、少ないピン数で多数のキーをスキャンする方式)のような複雑なことをしなくても、ほぼ全てのキーを1対1で接続できます。「ほぼ」と書いた理由は後述します。
配線
ピン配置の確認
UIAPduino Pro Microのピン配置を確認します。Pro Microフォームファクターなので、左右にピンが並んでいます。

ここでいくつかの罠があります。
TX (PD5) はUSBのプルアップ (DPU) に使われている。 したがってGPIOとして使うとUSB通信自体が壊れます。最初はここにキーを接続していたのですが、案の定まったく反応せず、原因を調べて初めてわかりました。
Pin 2 (PC0) は内蔵LEDと共有されている。 UIAPduinoには小さなオレンジ色のLEDがオンボードで搭載されていて、Pin 2 (PC0) に接続されています。LED回路の負荷がプルアップ入力を妨害して、スイッチの状態を正しく読めませんでした。ドキュメントにはCUT3というジャンプパッドをカットすればLEDを切り離せると書いてあったのでカットしたのですが、今度はPC0とヘッダーピンの間のトレースまで切れてしまい、ピン自体が使えなくなりました。ドキュメントには「2ピンがハイインピーダンス化」とも書いてあったのですが、正直カットする前はよくわかっていませんでした。
Pin 11 (PD1) はSWIOデバッグピンと共有されている。 こちらはファームウェア側でSWIOを無効化すれば普通のGPIOとして使えます。ただし、無効化するとデバッガー経由での書き込みができなくなります。再書き込みはUSBブートローダー経由(リセットボタンを押しながら接続→離す)で行います。
結局、使えるGPIOはおそらく13本。テンキー14キーに対して1本足りない。
1N4148で14番目のキーを追加する
13ピンに14キーを割り当てるにはどうするか。フルマトリクスは配線が面倒です。でも1キー分だけ足せればいいのだから、ダイオード1本の最小マトリクスで解決できます。
ここで登場するのが1N4148です。汎用小信号ダイオード、100個で500円(1個あたり5円)。こいつをNum 2のキーに使いました。

原理はこうです。Num 0 が接続されている A3 (PD2) と、Num 6 が接続されている A2 (PC4) の間に、ダイオードとNum 2のスイッチを直列に接続します。

ファームウェア側では、通常のスキャンで全ピンを入力モードにして直結キー13個を読み取った後、PD2を一瞬だけ出力 LOW に切り替えてPC4を読みます。Num 2が押されていれば、PD2 → スイッチ → ダイオード → PC4 という経路でPC4がLOWになる。ダイオードの向きのおかげで、Num 0 や Num 6 の信号とは干渉しません。
最終的なピン配置
紆余曲折を経て、最終的なピン配置はこうなりました。
| キー | ボードピン | GPIO | 接続方式 |
|---|---|---|---|
| Num 0 | A3 | PD2 | 直結→GND |
| Num 1 | A0 | PA2 | 直結→GND |
| Num 2 | A2↔A3間 | PC4↔PD2 | 1N4148マトリクス |
| Num 3 | Pin 3 | PC1 | 直結→GND |
| Num 4 | Pin 4 | PC2 | 直結→GND |
| Num 5 | Pin 5 | PC3 | 直結→GND |
| Num 6 | A2 | PC4 | 直結→GND |
| Num 7 | Pin 7 | PC5 | 直結→GND |
| Num 8 | Pin 8 | PC6 | 直結→GND |
| Num 9 | Pin 9 | PC7 | 直結→GND |
| Num ÷ | Pin 10 | PD0 | 直結→GND |
| Num × | Pin 11 | PD1 | 直結→GND |
| Num − | RX | PD6 | 直結→GND |
| Num . | A1 | PA1 | 直結→GND |
13個のキーは各ピンとGNDの間にスイッチを接続するだけ。内部プルアップ抵抗でHIGHに保持し、スイッチが押されるとLOWになる。14個目のNum 2だけがダイオードマトリクスです。
配線作業
さて、ハンダゴテを握るのは久しぶりです。
私はPCB設計ができないので、ポリウレタン銅線で空中配線することにしました。ポリウレタン銅線というのは、表面を絶縁被覆されたエナメル線の一種で、はんだ付けすると被覆が溶けて導通します。ブレッドボード上で試作するよりもコンパクトにまとまるので、こういう小型プロジェクトには便利です。
キースイッチの一方の端子はそれぞれ対応するGPIOピンに接続し、もう一方の端子はGND用の線を数珠つなぎにして、UIAPduino側のGNDに落としました。

ありえないカオスな配線ですが、自分用なので、動けば良いのです。これも自作キーボードの醍醐味ですね。配線がむき出しなので、後からガチャガチャいじりやすいのも利点です。

1N4148を追加してNum 2キーも認識するようになりました。A2とA3の間にダイオードとスイッチが挟まっているのが見えるでしょうか。
ファームウェアの開発
開発環境
UIAPduinoでUSB HIDデバイスを作るには、Arduino IDEではなくch32funという開発環境を使います。
Arduino IDEでもLチカなどの基本的なことはできるのですが、USB HID機能(キーボードやマウスとして振る舞う機能)を使おうとすると、Arduino向けの Keyboard.h ライブラリが使えません。CH32V003はArduinoのUSBスタックとは異なるソフトウェアUSB実装(rv003usb)を使っているためです。
ch32funはCH32V003向けの軽量な開発フレームワークで、rv003usbライブラリと組み合わせることで、USB HIDキーボードのファームウェアを書くことができます。
開発環境のセットアップは以下の通りです。
# rv003usbをクローン(ch32funがサブモジュールとして含まれる)
git clone --recursive https://github.com/cnlohr/rv003usb.git
# ビルドツールのインストール(Ubuntu / WSL)
sudo apt install build-essential libnewlib-dev gcc-riscv64-unknown-elf libusb-1.0-0-dev libudev-dev
私はWindows環境でWSLのUbuntuを使ってビルドしました。
プロジェクト構成
最終的なプロジェクトのファイル構成はこうなっています。
keypad/
├── keypad.c ← メインファームウェア
├── usb_config.h ← USBディスクリプタ定義
├── funconfig.h ← ch32fun設定
├── Makefile ← ビルド設定
└── rv003usb/ ← クローンしたリポジトリ
├── ch32fun/ ← ビルドシステム
└── rv003usb/ ← USBスタック
usb_config.h — USBディスクリプタ
USBデバイスとしてホストPCに認識してもらうためには、「自分は何者で、どんなデータを送るのか」をディスクリプタという形式で宣言する必要があります。
// UIAPduino のUSBピン配置(ハードウェア固定)
#define USB_PORT D // GPIO Port D
#define USB_PIN_DP 3 // PD3 = USB D+
#define USB_PIN_DM 4 // PD4 = USB D-
#define USB_PIN_DPU 5 // PD5 = USB D- pull-up (1.5k)
ここで定義されているPD3, PD4, PD5がUSB通信に占有されているために、これらのピンはGPIOとして使えないわけです。先述のTXピン問題の正体です。
HIDレポートディスクリプタは、標準的な8バイトキーボードレポート形式にしました。Byte 0がモディファイア(Ctrl, Shift等)、Byte 1がリザーブ、Byte 2〜7に最大6キー分のキーコードを格納できます。テンキーで6キー同時押しすることはまずありませんが、USB HID仕様に準拠しておくに越したことはありません。
keypad.c — メインファームウェア
ファームウェアのコアは非常にシンプルです。メインループで約2msごとにキーをスキャンして、押されているキーのHIDキーコードをレポートバッファに書き込む。USBのIN要求が来たら、そのバッファの内容を返す。それだけです。
int main(void)
{
SystemInit();
Delay_Ms(1);
keys_gpio_init();
usb_setup();
while (1) {
scan_keys();
Delay_Ms(2);
}
}
scan_keys() 関数の中身が少し面白いので、重要な部分を説明します。
デバウンス処理
メカニカルキースイッチは、押した瞬間に接点が数ミリ秒間バウンド(振動)します。金属の接点が物理的に弾んでいるので、電気的には「ON→OFF→ON→OFF→ON」のような高速な振動が発生します。これを何も対策せずに読むと、1回押しただけなのに複数回入力されたように見える——いわゆるチャタリングです。
対策として、25回連続(約50ms)同じ状態が続いたら確定するというカウンタベースのデバウンスを実装しています。
#define DEBOUNCE_COUNT 25
if (raw == key_raw_prev[i]) {
if (key_debounce[i] < DEBOUNCE_COUNT)
key_debounce[i]++;
if (key_debounce[i] >= DEBOUNCE_COUNT)
key_state[i] = raw;
} else {
key_debounce[i] = 0;
}
key_raw_prev[i] = raw;
50msというのは、人間が同じキーを最速で連打しても秒間10回(100ms間隔)程度なので、キー入力の取りこぼしは起きない範囲です。キースイッチのバウンスは長くても20ms程度なので、50ms待てば確実に安定します。
マトリクススキャン
通常のキースキャンが終わった後、PD2 (A3) を一瞬だけ出力LOWに切り替えて、PC4 (A2) を読みます。
// Drive PD2 (A3) LOW
pd2_drive_low();
// Brief delay for signal to propagate through diode
__asm__ volatile ("nop; nop; nop; nop; nop; nop; nop; nop;");
// Read PC4 (A2)
uint8_t matrix_raw = !(GPIOC->INDR & (1UL << 4));
// Restore PD2 to input with pull-up
pd2_input_pullup();
8個のNOP命令を入れているのは、PD2をLOWにした後、ダイオードを通じてPC4に信号が伝搬するまでの時間を確保するためです。48MHzのクロックだとNOP 1個が約20.8nsなので、8個で約167ns。1N4148のスイッチング時間は4ns程度なので、十分すぎるマージンです。
ゴースト防止のために、Num 6(PC4の直結キー)が押されている場合はマトリクスの読み取りを無視する処理も入れています。Num 6が押されていると、PC4は直結スイッチによってLOWになっているので、マトリクスの読み取り結果と区別できないからです。
コンボキー
14キーのテンキーに加えて、Enter と + も欲しい。でも物理的なキーを増やすスペースはない。そこでコンボキーで対処しました。
| 同時押し | 出力 |
|---|---|
| Num 0 + Num . | Enter |
| Num × + Num − | Num + |
これを実現するために、5状態のステートマシンを実装しています。
状態:
IDLE → 何も押されていない
WAIT → 片方押された、もう片方が来るか30ms待機
ACTIVE → 両方押された、コンボキーを送信
PASS → 30ms経過しても片方だけ → 通常キーとして送信
BLOCK → コンボ解除後、残りの指が離れるまでブロック
これは実装にかなり苦労しました。最初の実装では、×を押した瞬間に即座に×が送信されて、その直後に−を押すと+に切り替わるため、出力が「×+」になってしまう。WAITステートを追加して最初のキーを30ms抑制するようにしたら、今度はキーを長押ししたときに「0, 0, 0, 0, 0」のように断続的に入力される振動ループバグが発生。WAITタイマーが切れるとIDLEに戻り、次のスキャンで再びWAITに入る——これを延々と繰り返していたのです。
最終的に、PASS状態(一度判定したら指が離れるまで再判定しない)とBLOCK状態(コンボ解除後の漏れ防止)を追加して、安定した挙動になりました。
レースコンディション——最後のバグ
デバウンス、マトリクス、コンボキー、全部実装して「完成だ」と思った後に、最も厄介なバグに遭遇しました。
キーを1回だけ押しているのに、ごくまれに「33」や「11」のように2回入力されてしまうのです。頻度は低いけれど、確実に再現する。デバウンスのカウントを上げても直らない。
原因はUSBの割り込みとレポートバッファの書き込みが競合していたことでした。
もともとのコードは、hid_report バッファを直接操作していました。
// 危険なコード
memset(hid_report, 0, sizeof(hid_report)); // ← ここ!
// ... キー状態を書き込む ...
hid_report[report_idx++] = keycode;
memset でバッファをゼロクリアした直後、キー状態を書き込む前に、USBの割り込み要求が来てしまうことがあります。するとホストPCには「何も押されていない」というレポートが送信される。次の要求では正しいキーデータが送られるので、ホストPC側からは「キーが一瞬離されて、また押された」=二重入力に見えるわけです。
修正は単純で、一時バッファ temp_report に完全にレポートを組み立ててから、一気に hid_report にコピーするようにしました。
// 安全なコード
uint8_t temp_report[8] = {0};
// ... temp_reportにキー状態を書き込む ...
// 完成してから一気にコピー
for (int i = 0; i < 8; i++) {
hid_report[i] = temp_report[i];
}
これでUSBの割り込みがどのタイミングで来ても、常に完全に組み立てられたレポートが返されるようになりました。
ビルドと書き込み
ビルドは以下のコマンドで。
# WSL (Ubuntu) で実行
cd /mnt/c/Users/kame404/Desktop/keypad
make keypad.bin
ビルド結果:
FLASH: 3288 B / 16 KB (20.07%) 使用
RAM: 148 B / 2 KB (7.23%) 使用
FLASHの20%、RAMの7%しか使っていません。 RP2040のような高性能マイコンがいかに過剰かがわかります。テンキーにはこれで十分なのです。
書き込みは、リセットボタンを押しながらUSBケーブルを接続してすぐ離す(書き込み待機モード)→ minichlink で書き込みます。
# Windows PowerShell で実行
rv003usb\ch32fun\minichlink\minichlink.exe -c 0x1209b803 -w keypad.bin flash -b
Halting Boot Countdown
Interface Setup
Writing image
Image written.
Booting
Image written. と表示されれば成功です。
デバッグの話
ファームウェア開発の初期段階では、USB HIDの実装が正しく動くかどうかの確認が面倒でした。USB HIDキーボードとして動作するファームウェアを書き込んでも、キーが反応しなかった場合に「配線が悪いのか」「ファームウェアが悪いのか」「USB記述子が悪いのか」の切り分けができません。
そこで最初は、キーを押したらLEDが光るだけのシンプルなファームウェアでデバッグしました。配線が正しいことを確認してからUSB HIDの実装に進む——意外と空中配線でもなんとかなるものです。
完成
最終仕様
| 項目 | 仕様 |
|---|---|
| キー数 | 14キー(0〜9, ÷, ×, −, .) |
| コンボキー | 0+. → Enter, ×+− → + |
| マイコン | UIAPduino Pro Micro CH32V003 |
| インターフェース | USB 2.0 Low-Speed HID |
| 筐体 | PLA白色マット、3Dプリント |
| サイズ | 約91mm × 55mm(名刺サイズ) |
| FLASH使用 | 3,288 B / 16 KB (20.07%) |
| RAM使用 | 148 B / 2 KB (7.23%) |
| 同時押し | 最大6キー(USB HID仕様) |
| デバウンス | 25カウント(約50ms) |
完成した写真はこんな感じです。

配線やはんだ付けが非常に汚いのは自覚していますが、筐体に隠せば美しくまとまっているのではないでしょうか。それが筐体の力です。
最終的にキースイッチはOutemu Silent Lemon V3を採用しました。サイレント軸なので打鍵音が静かで、職場でも気兼ねなく使えます。仮組み時に使ったKailh Jellyfishは見た目が美しいのですが、打鍵感の好みでSilent Lemon V3に差し替えました。ホットスワップソケットのおかげで、こういうスイッチの試し比べが気軽にできるのが本当にありがたい。

チャタリングもないし、反応も非常に良い。コンボキーのEnterと+も安定して動作しています。名刺サイズにまとまって、とても良いテンキーができました。
費用
| 部品 | 数量 | 費用 |
|---|---|---|
| UIAPduino Pro Micro CH32V003 | 1個 | 290円 |
| Kailhホットスワップソケット | 14個(70個入り500円) | 約100円 |
| 1N4148 ダイオード | 1個(100個入り500円) | 約5円 |
| Outemu Silent Lemon V3 キースイッチ | 14個(70個入り1,100円) | 約220円 |
| 合計 | 約615円 |
※ PLA(3Dプリンター素材)、ポリウレタン銅線、はんだは手持ちのものを使ったので計上していません。キーキャップはGMK26から拝借です。
自作キーボードは高くつくイメージがありますが、テンキーに限って言えば、驚くほど安く作れます。
ハマりポイントと Tips
最後に、この制作で遭遇したトラブルをまとめておきます。
PD5 (TX) にキーを繋いではいけない。 USB DPUピンです。キーの入力どころか、USB通信自体が不安定になります。
Pin 2 (PC0) は内蔵LEDとの共有に注意。 CUT3をカットするとピンごとハイインピーダンスになり使えなくなります。もしPin 2を使いたい場合は、カットせずに外付けの330Ω〜470Ω抵抗でプルアップする方法が考えられますが、今回はダイオードマトリクスで回避しました。
Pin 11 (PD1) のSWIO無効化を忘れない。 AFIO->PCFR1 |= (1 << 24); でSWIOを無効化しないと、PD1はデバッグピンとして占有されたままキー入力を読めません。無効化するとデバッガーが使えなくなりますが、USBブートローダーで書き込めるので実用上問題ありません。
hid_report バッファのレースコンディション。 バッファを直接ゼロクリアしてから書き込むと、USBの割り込みタイミングによっては空のレポートが送信されて二重入力になります。一時バッファに組み立ててからコピーしてください。
おわりに
構想から完成まで2日でした。3Dプリンターは偉大です。
自作キーボードというと、基板設計、ファームウェア開発、筐体設計と、やることが盛沢山に思えますが、テンキーに限れば規模が小さいので入門に最適だと思います。14キーしかないのでマトリクス配線すら必要ない(1本だけ使ったけど)し、ファームウェアも数百行で済みます。
自作キーボードは初ですが、無事に名刺サイズの自作テンキーが完成しました。この記事が参考になりましたら幸いです。
使用したもの:
