概要
USBキーボードやマウスを認識し、HID入力レポートをシリアルデータとして出力してくれる CH9350Lを使ってUSBキーボードをArduinoボードに接続しキーボード入力を利用できるようにする。シリアルポート(UART)を備えたArduinoボードならば接続やプログラミングはシンプルなので、おそらくMAX3421E を使ったUSB Host Shield よりも簡単に使える。

この投稿では、CH9350Lの評価用ボードとSparkFun Pro Micro(5V / 16MHz)同等品をつないで使い方を調べてみた話をまとめた。そしてCH9350LとSeeduino XIAO-m0 を組み合せて、ふつうの日本語キーボードをほぼNICOLAキーボード(親指シフトキーボード)として使えるようにするhoboNicolaアダプターを作成した話も続く予定。
CH9350Lの概略
CH9350Lは、USBシリアル変換デバイスのCH340シリーズやMCS51(8051)互換のCH559Lなどで知られている南京沁恒微电子股份有限公司 (英語名 : Nanjing Qinheng Microelectronics) の製品で、他の製品と同じく国内の通販サイト(秋月電子など)でお安く入手できる。
このデバイスは、キーボードやマウス(およびディスプレイ)などの操作側と操作対象のコンピュータ間の距離を通信技術を使って延長する、KVMスイッチあるいはKVMエクステンダと呼ばれる製品での利用を主目的として作られたデバイスのようで、このデバイスが入出力するシリアル通信部分をビデオ信号に重畳するとかRS485規格を使うとかして延長することが想定されているようだ。
CH9350Lのおもな特長は以下のとおり。
- 接続対象に応じた2種類の動作モード(上位側、下位側)と、使い方に応じた5種類の動作ステート(動作ステート0~4)をもつ。今回は、下位側動作モード、動作ステート0を利用する。
- 基本的には、上位側モードと下位側モードで動作する2つのCH9350Lをシリアル通信で接続して使うことが想定されている。
- USB2.0規格と互換性があり12Mbpsと1.5MBpsの通信速度をサポートする。
- USBキーボードやマウスの出力データ(HID入力レポート)をシリアルデータに変換して出力できる。
- 日本語キーボードで拡張されたキー(変換、無変換、ひらがななど)についても、所定のHID Usage IDが出力される。
- 上位側からのシリアルデータにより、USBキーボードのLEDを点灯/消灯できる(出力レポートのサポート)。
- UARTの通信速度は115200/57600/38400bpsをサポート(デフォルトは115200bpsで、特殊用途では300000bpsも利用できる)。データ形式は、8ビット、1ストップ、パリティ無し。
- 水晶発振器とパワーオンリセット回路を内蔵しているので、周辺回路がとてもシンプル。
- 外部電源電圧は+5Vおよび+3.3Vをサポート。+5Vは内蔵+3.3V出力電圧レギュレータの入力となっており、内部動作電圧は+3.3V。入出力ロジックレベルは基本的に+3.3V。
外部との接続
CH9350Lは2系統のUSB2.0ポートとUART(TXD/RXD) ポートによって外部の機器(またはもう一つのCH9350L)と接続することができる。

2つの動作モードは、USBポートの用途を規定している。
- 上位側モード(Upper computer mode) では、PCなどのUSBホストへの接続に対応する。ポート1(DP/DMピン)のみが使える。
- 下位側モード(Lower computer mode) では、キーボードやマウスといったUSBデバイスへの接続をサポートする。ポート1(DP/DMピン)とポート2(HP/HMピン)の2系統が使える。
CH9350L自体は、上位側モードではUSBデバイスとして動作し、下位側モードでは(2台のUSBデバイスを接続できる)USBホストとして動作することになる。2つのCH9350Lを組合せたKVM用途などでは以下のような構成になる。

今回は、CH9350Lを下位側モードとして動作させる。そして、上位側のCH9350Lの代わりにArduinoボードをシリアル通信で接続する。
データシート
CH9350Lの詳細についてはメーカーのデータシートを参照のこと。中文版のデータシートなどはこちらのページから、英語版のデータシートなどはこちらからダウンロードできる。これを書いている時点では、中文版が2.6版で英語版は2.3版だった。なお、秋月電子のCH9350L販売ページ内にリンクされているデータシートは中文の2.3版なのでちょっと古めだった。
CH9350Lの評価に使ったボード
Aliexpressから以下のような評価用ボードを購入した。価格は送料込みで1000円ほどだったが円安が進むと高くなってしまう。

右側にUSB Type-Aレセプタクルが2つ、左側にType-Bレセプタクルが実装されている。今回は下位側モードで利用するので、2つのType-Aにキーボードとマウスをつないで動作を見た。Type-B側の配線は、TypeAのUSB ポート1と同じピン(DP/DM)に接続されている。
黄色いピンヘッダに、電源(+5VとGND)、UART信号(TXDとRXD)が引き出されている。

Aliexpressの中のショップの販売ページに添付の回路図では、CH9350LのTXDとRXDは、それぞれピンヘッダのRXDとTXDに引き出されているはずだが、実際にはTXDはTXDに、RXDはRXDに接続されていた。シルクと実際が違うのはよくあること。
このボードには以下のように5回路のDIPスイッチが載っている。

DIPスイッチによって、UARTの通信速度、動作モードを規定するSELピンのレベル、動作ステート決めるS1およびS0ピンのレベルを設定することができるが、写真のよう全OFF(HIGHレベル)にしておくと、115200bps、SEL=1(下位側モード)、S1|S0=11(動作ステート0または1) ということになる。今回はこの状態で利用した。なお、これらのピンはデバイス内部でプルアップされているので、外部配線無しですべてHIGHである。
電源を与えた状態でCH9350LのUSBポート1(DP/DM)にキーボードをつなぐと、ポート1側の青いLEDが点灯する(USBポートはホットプラグ対応)。このLEDはLED1ピン(#1)に接続されていて、LED1ピンがLOWレベルのとき点灯するようになっている。LED1ピンは、接続されたデバイスの認識が完了した時点でLOWとなり、何かのキー入力を検出すると260msec間のHIGHのあとLOWに戻る。つまり、キー入力のたびにチカチカする。これにより、CH9350Lがキー入力を検出していることが視覚的に分かる。
(hoboNicolaアダプター用の回路図は次の投稿で掲載の予定)。
Pro Micro(+5V/16MHz)との接続
CH9350LのTXDとRXDをPro MicroのRX(PD2)とTX(PD3)に接続し、+5VとGNDを接続するだけなので特に配線図はなし。Pro Microで動かすスケッチではハードウェアUARTのSerial1によってCH9350Lと入出力することになる。
+5Vロジックとの接続について
CH9350Lは基本的に+3.3V動作なので、 入出力レベルが+3.3Vのマイコンと接続するのが間違いのないところだが、以下のような理由で+5Vロジックの Pro Micro (+5V/16MHz)に直結しても大丈夫だろうと判断した。データシートに明示されていない事項なので、壊れてもしかたがないという前提。
CH9350LのUARTの出力側(TXD)のHIGHレベルは+3.3Vなので+5VロジックのマイコンのRXDにそのまま接続しても問題ない。入力側(RXD)については、CH559Lが大丈夫なのだからCH9350Lもオーケーだろうと考えた。
同じ会社が作っているMCS51互換のCH559では、多くのGPIO入力について+5Vレベルの入力が許容されている (CH559 データシートの 2.Features に次のように明記されている。GPIO: support up to 45 GPIO,3.3V voltage output, support 5V voltage input except pins:P1.0~P1.7, XI, XO, RST)。
QFPのCH559LとCH9350Lでは、使い方が一致しているピンが多くCH559の#27ピンはRXD1/P2.6でありCH9350LのRXDピンも#27ピン。CH559Lに専用のファームウェアを焼いたものがCH9350Lではなかろうか。確証のない話ではあるが、少なくともうちでは +5V動作のPro Microと接続して壊れずに動いている。心配な方は、+3.3Vのマイコンで試してください。
動作確認スケッチ
Pro MicroのUSBはPCに接続して仮想COMポートとし、シリアルモニタでデータが見えるようにした。
とりあえずデータを見る
例えば以下のようなスケッチを書くと、CH9350Lから送られてくるデータをシリアルモニタで見ることが出来る。
static const char hex[] = "0123456789ABCDEF"; void dump_byte(uint8_t c) { char tmp[3]; tmp[0] = hex[(c >> 4) & 0x0f]; tmp[1] = hex[c & 0x0f]; tmp[2] = 0; Serial.print(tmp); Serial.print(" "); } void setup() { Serial1.begin(115200); Serial.begin(115200); } void loop() { while(Serial1.available()) { uint8_t c = Serial1.read(); dump_byte(c); } }
このスケッチを書き込むと、シリアルモニタには 57 AB 82 A3 57 AB 82 A3 57 AB 82 A3 57 AB 82 A3…. という、CH9350Lからの4バイトのデータが連続的に表示される。データシートによると、0x57 0xAB 0x82 0xA3 というフレームは、ステータスリクエストフレームと呼ばれるもので、66msecごとに出力されてくる。最後の0xA3の下位4ビットは、IO1~IO4ピンの状態(ロジックレベル)を表しているとのことだが、特に確認していない。
動作ステート0
頻繁に送信されるステータスリクエストフレームを停止させるためには、上記の4バイトを受信したときに以下のデータフレームを送信しCH9350Lを動作ステート0に設定する。
0x57 0xAB 0x12 0x00 0x00 0x00 0x00 0xFF 0x80 0x00 0x20
動作ステート0は、今回のようにキーボードやマウスからのデータを単純にシリアルデータとして取り出したいときに用いる動作ステートで、動作ステートを変更するかリセットするまでステータスリクエストフレームの送信は止まる。
なお、次回に掲載予定のhoboNicolaアダプター用のスケッチでは、キーボードLEDの点灯を制御するために動作ステート1を使う。
CH9350_test01.ino
フレーム単位でデータを表示するため、以下のようなスケッチを書いた。
/** * CH9350_test01.ino Copyright (c) 2022 Takeshi Higasa * This software is released under the MIT License. * http://opensource.org/licenses/mit-license.php */ #include "arduino.h" #if defined(USE_TINYUSB) #include "Adafruit_TinyUSB.h" #endif void setup() { Serial1.begin(115200); Serial.begin(115200); } static const char hex[] = "0123456789ABCDEF"; void dump_byte(uint8_t c) { char tmp[3]; tmp[0] = hex[(c >> 4) & 0x0f]; tmp[1] = hex[c & 0x0f]; tmp[2] = 0; Serial.print(tmp); Serial.print(" "); } static const uint8_t set_state[] = { 0x57,0xAB,0x12,0x00,0x00,0x00,0x00,0xFF,0x80,0x00,0x20 }; uint8_t opcode = 0; uint8_t data_length = 0; uint8_t rx_length = 0; uint8_t data_frame[100]; static const uint8_t frame_size = sizeof(data_frame); static const uint8_t none = 0; static const uint8_t has_57 = 1; static const uint8_t has_57ab = 2; static const uint8_t has_op = 3; static const uint8_t wait_data = 4; uint8_t rx_state = none; void loop() { while(Serial1.available()) { uint8_t c = Serial1.read(); if (c == 0x57 && rx_state == none) { rx_state = has_57; Serial.println(); } else if (rx_state == has_57) { if (c != 0xab) rx_state = none; else rx_state = has_57ab; } else if (rx_state == has_57ab) { if (c == 0x84 || c == 0x86 || c == 0x87) { rx_state = none; } else { rx_state = has_op; opcode = c; } } else if (rx_state == has_op) { switch(opcode) { case 0x83: // data frame. case 0x88: data_length = c; rx_length = 0; rx_state = wait_data; break; case 0x82: // sync status. Serial1.write(set_state, sizeof(set_state)); // through default: rx_state = none; break; } opcode = 0; } else if (rx_state == wait_data) { data_frame[rx_length++] = c; if (rx_length >= data_length || rx_length >= frame_size) { rx_state = none; // data_frame[]の中身を処理する。 } } dump_byte(c); } }
loop() の中身はシリアルデータを受け取る際に滯りがないよう、簡単なステートマシンとした。いくつかキーを叩いてみると、シリアルモニタには以下のように表示された。
57 AB 82 A3 57 AB 87 57 AB 88 0B 10 01 00 00 00 00 00 00 00 08 09 // 左Ctrlキー オン 57 AB 88 0B 10 01 00 2C 00 00 00 00 00 09 36 // 左Ctrl + Space オン 57 AB 88 0B 10 01 00 00 00 00 00 00 00 0A 0B // Space オフ 57 AB 88 0B 10 00 00 00 00 00 00 00 00 0B 0B // Ctrl オフ ...
“57 AB 88” から始まるのがステート0でのデータフレームで、以下のような構成になっている。
データフレームの構成
フィールド名称 | バイト数 | 内容/備考 |
フレームヘッダ | 2 | 常に0x57 0xAB |
opcode | 1 | 0x88 (ステート1では0x83) |
データ長 data_length |
1 | 以降のバイト数 |
接続情報 device_kind |
1 | 接続機器と接続されているポートを表す。 データシートでは以下のビット構成になっている。 MSB 7 6 5 4 3 2 1 0 -------------------- 0 0 S S 0 T T P SS = 00:other, 01:keyboard, 10:mouse, 11: media TT = 00:Unknown,01:HID,10:BIOS, 11:Reserved. P = 0: Port1、1:Port 2 |
データ report |
可変 | HID 入力レポート |
シリアル 番号 |
1 | リセット時0で始まるシリアル番号。送信都度インクリメントされる。 |
チェックサム | 1 | データとシリアル番号の総和。 |
Ctrl + Spaceを押下したときのデータ 57 AB 88 0B 10 01 00 2C 00 00 00 00 00 09 36 のうち、0x0Bは 0x10以降に11バイトあることを、0x10 は Unknownなキーボードがポート1に接続されていることを表している。そして以降の8バイトが入力レポート本体で、その後にシリアル番号とチェックサムが続いている。
また、ポート2にUSBマウスをつないでみると、以下のようなデータが得られる。
57 AB 88 07 21 01 03 FE 00 FE 00 57 AB 88 07 21 01 03 FE 00 FF 01 57 AB 88 07 21 01 03 FE 00 00 02
接続情報が0x21なので、Unknownなマウスがポート2に接続されていることになる。データ部分は4バイト構成で以下のようなフォーマットだった。
0 1 2 3 ------------------------- Btns Xmove Ymove Wheel Btns: bit0: Left, bit1:Right, bit2:Center
動作ステート0の場合、接続中のキーボードがBIOS (Bootプロトコル)なのかHID(レポートプロトコル)なのかが判別できないようだった。また、Fnキーなどとの併用でメディア制御やシステム制御用コードを出力するコンポジットデバイス構成のキーボードかどうかの区別もできない(動作ステート1でも同様)。
CH9350Lにつながっているキーボードやマウスのレポートディスクリプタをプログラム的に見ることができないので、あらゆるUSBキーボードのデータをシリアル変換する仕組みの実装は難しいだろう。ただ、キーボードに合わせてスケッチを直せばなんとかなりそうなので、個人の趣味用としては問題ないのだが。
キー入力データの抽出 (CH9350_test02.ino)
先のスケッチでは、opcode = 0x88 のときのデータ(接続情報フィールド以降)が data_frame 配列 に格納されている。このスケッチでは、接続機器がキーボードのとき、data_frame 内のHID入力レポートを直前に受信した内容と比較し、オンになったキーとオフになったキーを検出してシリアルモニタに表示するようにした。以下のようなスケッチとした。
/** * CH9350_test02.ino Copyright (c) 2022 Takeshi Higasa * This software is released under the MIT License. * http://opensource.org/licenses/mit-license.php */ #include "arduino.h" #if defined(USE_TINYUSB) #include "Adafruit_TinyUSB.h" #endif void dump_byte(uint8_t c) { static const char hex[] = "0123456789ABCDEF"; char tmp[3]; tmp[0] = hex[(c >> 4) & 0x0f]; tmp[1] = hex[c & 0x0f]; tmp[2] = 0; Serial.print(tmp); Serial.print(" "); } void key_event(uint8_t hid_code, bool on) { dump_byte(hid_code); Serial.println(on ? "ON" : "OFF"); } static const uint8_t keys_count = 10; // key_array size. static const uint8_t report_size = keys_count + 2; // key_array and modifiers and reserved. static const uint8_t frame_size = report_size + 3; // (dev_kind, report, serial, sum) uint8_t prev_rep[report_size] = {0}; void parse_report(uint8_t* data, uint8_t datalen) { if (!data || datalen < 4) return; // illegal size. #if 1 for(uint8_t i = 0; i < datalen; i++) dump_byte(data[i]); Serial.print(" : "); #endif uint8_t* rep = &data[1]; if (rep[2] == 0x01) // roll-over error return; // modifiers. uint8_t change = rep[0] ^ prev_rep[0]; static const uint8_t modifier_base = 0xe0; if (change != 0) { uint8_t mask = 1; for(uint8_t i = 0; i < 8; i++, mask <<= 1) { if (change & mask) key_event(modifier_base + i, rep[0] & mask); } } // keys. uint8_t rep_size = datalen -= 3; for(uint8_t i = 2; i < rep_size; i++) { // search pressed. bool cur_match = false; bool prev_match = false; for(uint8_t n = 2; n < rep_size; n++) { if (rep[i] == prev_rep[n]) cur_match = true; if (prev_rep[i] == rep[n]) prev_match = true; } if (!cur_match) key_event(rep[i], true); // detect ON key. if (!prev_match) key_event(prev_rep[i], false); // detect OFF key. } memcpy(prev_rep, rep, report_size); } static const uint8_t none = 0; static const uint8_t has_57 = 1; static const uint8_t has_57ab = 2; static const uint8_t has_op = 3; static const uint8_t wait_data = 4; static const uint8_t set_state[] = { 0x57,0xAB,0x12,0x00,0x00,0x00,0x00,0xFF,0x80,0x00,0x20 }; void ch9350_loop() { static uint8_t rx_state = 0; static uint8_t opcode = 0; static uint8_t data_length = 0; static uint8_t rx_length = 0; static uint8_t data_frame[frame_size] = {0}; while(Serial1.available()) { uint8_t c = Serial1.read(); if (c == 0x57 && rx_state == none) { rx_state = has_57; } else if (rx_state == has_57) { if (c != 0xab) rx_state = none; else rx_state = has_57ab; } else if (rx_state == has_57ab) { if (c == 0x84 || c == 0x86 || c == 0x87) { rx_state = none; } else { rx_state = has_op; opcode = c; } } else if (rx_state == has_op) { switch(opcode) { case 0x83: // data frame. case 0x88: data_length = c; rx_length = 0; rx_state = wait_data; break; case 0x82: // sync status. Serial1.write(set_state, sizeof(set_state)); // through default: rx_state = none; break; } opcode = 0; } else if (rx_state == wait_data) { data_frame[rx_length++] = c; if (rx_length >= data_length || rx_length >= frame_size) { rx_state = none; if ((data_frame[0] & 0x30) == 0x10) // keyboard. parse_report(data_frame, rx_length); } } } } void setup() { Serial1.begin(115200); Serial.begin(115200); } void loop() { ch9350_loop(); }
このスケッチでは、CH9350Lから受信を行う部分を、ch9350_loop() として独立させ、HID入力レポートの内容から新しいキーイベントを抽出する関数 parse_report() を追加した。新しくオンまたはオフになったキーのHIDキーコード(Usage ID) とオンオフ情報は key_event() でシリアルモニタに表示するようにした。このスケッチの応用としては、key_event() に手を加えたり別の関数にコードを渡したりすることを想定している。
実行するとシリアルモニタにdata_frameの内容と、オンになったキー、オフになったキーのHIDキーコードを表示する。
10 01 00 00 00 00 00 00 00 51 52 : E0 ON (左Ctrl オン) 10 01 00 04 00 00 00 00 00 52 57 : 04 ON (A オン) 10 01 00 04 16 00 00 00 00 53 6E : 16 ON (S オン) 10 01 00 04 16 07 00 00 00 54 76 : 07 ON (D オン) 10 01 00 04 16 07 09 00 00 55 80 : 09 ON (F オン) 10 01 00 16 07 09 00 00 00 56 7D : 04 OFF (A オフ) 10 01 00 07 09 00 00 00 00 57 68 : 16 OFF (S オフ) 10 01 00 09 00 00 00 00 00 58 62 : 07 OFF (D オフ) 10 01 00 00 00 00 00 00 00 59 5A : 09 OFF (F オフ) 10 00 00 00 00 00 00 00 00 5A 5A : E0 OFF (左Ctrl オフ)
(スケッチはカッコ内の表示をしませんので)。
CtrlやShiftなどの修飾キーについても他のキーと同様に扱うため、0xE0~0xE7というコードを与えている(コードとキーの関係は以下の modifiers を参照)。こうすることで、キー入力を利用する側では修飾キーの各ビットを意識する必要がなくなるし、たとえばキーコードの入れ替えなどが容易になる。
HIDキーコードと文字との関係は、usb.org の HID Usage Tables 1.3 を参照。第10章の Keyboard/Keypad Page (0x07) に対応表が掲載されている。
HID入力レポートについて
キーボードからの典型的な入力レポートは以下のような構造になっている。
typedef struct { uint8_t modifiers; // 8つの修飾キーの状態 uint8_t reserved; uint8_t keys[REPORT_COUNT]; // 最大REPORT_COUNTキーの状態 } report_t;
REPORT_COUNT == 6 となっているキーボードが多いが、レポートディスクリプタの内容によってはさらに多い場合もある。今回のスケッチでは10キーまでとした。さらに大きくする必要があるならば、データ長に応じてスケッチを変更すればよいだろう。
詳細については、USB Device Class Definition for Human Interface Devices (HID) Firmware Specification—6/27/01 Version 1.11 を参照のこと。
modifiers
入力レポートの modifiers には、オンになっている修飾キーの情報がビット単位に格納されている。
(MSB) bit7 6 5 4 3 2 1 0 (LSB) 右GUI 右Alt 右Shift 右Ctrl 左GUI 左Alt 左Shift 左Ctrl
前回受信時のmodifiersとXORをとることで変化のあったキーを検出する。そしてその変化が、0から1 (オン)なのか、1から0 (オフ)なのかを判定している。
スケッチの parse_report() では、各修飾キーのビット位置(0 .. 7 )を0xe0に加えたコードを出力するようにしている。
keys[]
キーボードで現在押されているキーのHIDキーコードが格納される。対応可能なキー数を超えると、keys[] の全要素に 0x01 が格納されることになっている。
上記のシリアルモニタ出力では、押した順に配列の前からHIDキーコードが入り、キーを離したときも押下中のキーのコードは前詰めされている。HID規格ではコードの格納の順序や格納位置は規定されていない (上記HID仕様書の Appendix C. Keyboard Implementation を参照)。他のUSBホストコントローラでは、おそらく違った動作になるだろう。
USBハブへの対応について
USBハブを内蔵したキーボード(PFU HHKB Lite2)をCH9350Lのポート1に接続した場合、他のキーボードと同様にデータフレームを受け取ることができた。ただ、HHKBの内蔵ダウンストリームポートに、マウスやキーボードを接続してリセットをかけても、それらのデバイスからの入力は受け取れなかった。
CH9350Lのポート1にふつうのUSBハブを接続し、ハブのポートにキーボードやマウスを接続した場合も有効になるのは1つだけだった。まとめると以下のようになる。
- USBハブは認識するがCH9350Lの各ポートが認識できるUSBデバイスは1つ。
- キーボードとマウスをつないだUSBハブを接続しても、使えるのはいずれか一方だけ。
- 2台使うならば、CH9350Lの2つのポートにおのおの接続して使う。
- 消費電流的に厳しいデバイスをつなぐような場合、セルフパワーハブを経由するような手はある。
きょうのまとめ
- hoboNicolaアダプターで使ってきた USB ホストコントローラー MAX3421Eを載せた mini USB Host Shield 2.0 がほぼ入手不能状態になってしまったので、代替品としてCH9350Lが使えないものかと思って試してみたら、わりとあっさり動いた。
- 今回のスケッチは、Pro Micro(5V/16MHz)とSeeeduino XIAO-m0 で動作することを確認した。Serial1が使えれば、他のArduinoボードでも大丈夫じゃなかろうか。
- 次回は、今回のスケッチを発展させてhoboNicolaライブラリで利用可能にする話と、CH9350LとSeeeduino XIAO-m0 を使ったhoboNicolaアダプターを作る話になる予定です。