tl;dr
- MCP23017は、I2C IOエキスパンダーで、IOが足りないない所をI2Cプロトコルで拡張できるICだよ。16bitのIOが増やせるよ。ただし、プルダウン機能はないよ。
- Sparrow62v2では左手側にRaspberry Pi Picoを積み、右手側にMCP23017を積み、その間をI2Cで接続したキーボードだよ。今まで、KMK Firmwareしか対応してなかったよ。
- MCP23017で利用したプロトコルは、INPUT/OUTPUT方向を決めるIODIRA, IODIRBと、値のゲットセットを行うGPIOA、GPIOBさえ使えば良いよ。
- QMK Firmwareで、
scan_custom_matrix()
を実装することで、MCP23017でのマトリックススキャンを実装したよ。
Sparrow62v2
Sparrow62v2は、私の作っている左右分割型自作キーボードキットです。かつては游舎工房でも委託販売しておりましたが、現在は在庫切れとなっております。そろそろv3を作りたいと思っており、再販予定は今のところありません。
左右分割で左右でレイアウトが異なっているのと、トッププレート、ボトムプレート、メインボードも厚さがそれぞれ異なるPCBを利用しているため、結構生産にコストがかかります。1年半で20程度販売できたのですが、再生産コストをかけるならばv3を設計したい気持ちがあり、v2の再生産を予定していません。
一方、利用いただいてる方はいらっしゃり、QMK Firmware対応のリクエストを受けました。MCP23017を利用しており、QMK Firmwareの基本機能に習熟していないこともあり、販売当初からKMK Firmwareのみをサポートさせてもらっていました。SparrowDialを通して、QMK Firmwareについて理解を深め、I2Cデバイスを使うための機能が整備されていることを理解したため、対応をやってみることにしました。
IOエキスパンダーMCP23017を組み込む
IOエキスパンダーMCP23017は、I2Cのシリアル通信で、GPIO 16bit分を操作できるICです。秋月でも190円で購入することができます。半導体品不足の時には、在庫切れを起こしていましたが、現状在庫豊富なようです。
Sparrow62v2ではスペースを節約したり、MCUを2個用意させるコストに疑問を感じていたため、IOエキスパンダーを利用するようにしました。
ただ、注意点としては、MCP23017には入力に対してプルアップ(フローティング時でHighとする)する機能はあるのですが、プルダウン(フローティング時にLowとする)する機能はありません。COLからROWに電流を流すスイッチマトリックス構成の場合、入力側のROWにプルダウンが必要となります。そのため、プルダウン抵抗を回路に追加しました。
Sparrow62v2の構成
Sparrow62v2の内部構成はざっくり以下のような構成です。
左手メインボードではRP2040(Raspberry Pi Pico)上でQMK Firmwareが動作しており、TRRSソケットを通して右手メインボードのMCP23017のI2Cで通信を行っています。
左右のメインボードでそれぞれスイッチマトリックスが組まれており、COLからROWに電流が流れるようになっています。スイッチマトリックスがRP2040、MCP23017に接続された形になっています。
MCP23017のプロトコル
I2Cではマスター(MCU、RP2040)側から、スレイブ(MCP23017)に対して、「特定アドレスのレジスタを読み書きする」ようにプロトコルを組むことが多いです。このレジスタがアドレスごとに機能が異なっており、特定のアドレスで、GPIOの役割をセットできたり、GPIOのアウトプットを設定したり、インプットの値を取得したりします。
MCP23017には、アドレスの振り方に、16ビットモード(IOCON.BANK=0)と、8ビットモード(IOCON.BANK=1)があります。初期状態が16ビットモードになっていて、16ビットモードでもアドレスが違うだけなので、私は16ビットモードで利用しています。本稿も16ビットモードで解説します。
主に使うのは以下の4つレジスタだけです。
名称 | レジスタアドレス | 機能 |
---|---|---|
IODIRA | 0x00 | GPIOAの各IO0~8のインプット・アウトプットの方向を8bit(0:アプトプット、1: インプット)で設定する |
IODIRB | 0x01 | GPIOBの各IO0~8のインプット・アウトプットの方向を8bit(0:アプトプット、1: インプット)で設定する |
GPIOA | 0x12 | GPIOAの各IO0~8のインプット・アウトプットの値を8bit(0: Low、1:High)で取得、設定する |
GPIOB | 0x13 | GPIOBの各IO0~8のインプット・アウトプットの値を8bit(0: Low、1:High)で取得、設定する |
IODIRAをROWのインプット、IODIRBをCOLのアウトプットとして使うため、以下を設定すれば良いとわかります。
- GPIOAを全てインプットにするため、IODIRAに
0xff
をセット - GPIOBを全てアウトプットにするため、IODIRBに
0x00
をセット
そしてCOLからROWのマトリックススキャンをするため、以下を行います。
- 読み取りたいCOLのGPIOのみをHighにするため、GPIOBに該当bitのみをHigh(例: 0b00000010)をセット
- COL設定後に、ROWのGPIOを読み取る(対象COLにおいて、スイッチが押されたキーのROWのみがHighになる)ため、GPIOAを読み取り
キーマトリックスの制御については、Google検索すると記事が見つかるため、そちらをご覧ください。
MCP23017については詳しくは、日本語のデータシートがあるので、こちらを参照ください。
MCP23017データシート
https://ww1.microchip.com/downloads/jp/DeviceDoc/20001952C_JP.pdf
QMK Firmwareでのカスタムキーマトリックスをスキャンのインターフェイス
QMK Firmwareにおいては、カスタムのキーマトリックスを実装するための機能が用意されています。このうち、CUSTOM_MATRIX=liteの機能があり、こちらを利用することにしました。
liteでは以下の関数のみ実装します。
- void matrix_init_custom(void)
- キーマトリックの初期化を行う関数
- MCP23017や、RP2040のGPIOの設定を行う。
- bool matrix_scan_custom(matrix_row_t current_matrix)
- キーマトリックスのスキャンを行う関数
- 引数current_matrix
- 戻り値に、current_matrix[]に変更がある場合、trueを返す。
一点注意点としては、通常はkeyboard.jsonに"matrix_pins"を設定すると思いますが、カスタムマトリックスliteを有効にすると、この値は無視されます。そのため、MCP23017を使わないRP2040側で行うマトリックススキャンも実装する必要があります。
カスタムマトリックスliteと、I2C通信の有効化
カスタムマトリックスlite
カスタムマトリックスを有効にするには、keyboards/{キーボード名}/rules.mkに以下を記述します。
CUSTOM_MATRIX = lite SRC += matrix.c
keyboard.jsonの"matrix_pins"で通常は生成される、MATRIX_ROWS、MATRIX_COLSの値をkeyboards/{キーボード名}/config.hに設定します。
#pragma once #define MATRIX_ROWS 5 #define MATRIX_COLS 14
最後に、スキャンの実装を行うkeyboars/{キーボード名}/matrix.cを追加します。
void matrix_init_custom(void) { } bool matrix_scan_custom(matrix_row_t current_matrix[]) { }
I2C通信の有効化
QMK FirmwareにはI2C通信のための機能があり、こちらを有効化します。
くわしくはQMK Firmwareのドキュメントを参照ください。
keyboard/{キーボード名}/config.hに以下を追加します。RP2040では、I2C0とI2C1の2つがあります。I2C_DRIVERでどちらを利用するのか指定します(下記はI2C0の例)。さらに、I2Cで利用するピンを設定します。I2C0であったとしてもI2C1_SDA/SCL_PINに設定します。F_SCLはI2Cの通信速度ですが、電子工作レベルでは100kHzくらいが使われます。うまく通信ができない場合、この値を落としてみたり、スキャン時の速度が遅い場合は上げてみるのも手です。ただ、MCUによって使える速度の種類に制限があります。
#define I2C1_SCL_PIN GP13 #define I2C1_SDA_PIN GP12 #define I2C_DRIVER I2CD0 #define F_SCL 100000
keyboard/{キーボード名}/halconf.hを作成し、以下を追加します。
#define HAL_USE_I2C TRUE
keyboard/{キーボード名}/mcuconf.hを作成し、以下を追加します。
#pragma once #include_next <mcuconf.h> #undef RP_I2C_USE_I2C0 #define RP_I2C_USE_I2C0 TRUE #undef RP_I2C_USE_I2C1 #define RP_I2C_USE_I2C1 FALSE
デバッグコンソールを有効にする
QMK Firmwareのロジック実装時は、プリント文によるデバッグをできるようにしておきます。
まず、ログを出力するコンソールを有効化するには、keyboard.jsonのfeaturesにconsoleをtrueに設定します。
{ // 略 "features": { "bootmagic": true, "command": false, "console": true, // これ! "extrakey": true, "mousekey": true, "nkro": false }, // 略 }
これで、各コードで#include <print.h>
をして、printf()
を呼ぶとコンソールに出力されるようになります。
デバッグ用のdprintf()
も用意されており、デバッグ時はこちらを利用しておけば、デバッグ終了後もprintf()を削除する必要がなくなります。
dprintf()
では、keymap.cにて、以下のフックを実装し、デバッグを有効化しておきます。
void keyboard_post_init_user(void) { // Customise these values to desired behaviour debug_enable = true; debug_matrix = false; //debug_keyboard=true; //debug_mouse=true; }
このコンソールに接続して見るには、qmkコマンドを使います。
qmk console
MCP23017の初期化を実装する
matrix.cの実装に入っていきます。
ポイントとして以下の部分です。
- i2cの初期化として、
i2c_init();
を呼ぶ。 - i2c_write_register()で、IODIRA、IODIRBで、各GPIOのインプット・アウトぷっとの方向を設定する
#include "timer.h" #include "matrix.h" #include "debug.h" #include "wait.h" #include <print.h> #include "platforms/chibios/gpio.h" #include "i2c_master.h" #define MCP23017_I2C_ADDRESS 0x20 #define MCP23017_IODIR_A 0x00 #define MCP23017_IODIR_B 0x01 #ifndef MCP23017_I2C_TIMEOUT # define MCP23017_I2C_TIMEOUT 100 #endif void matrix_init_custom(void) { i2c_init(); wait_ms(10); // MCU側のキーマトリックスの方向設定 // 略 // GPIOAをInputに設定 uint8_t iodir_a = 0xff; status = i2c_write_register(MCP23017_I2C_ADDRESS << 1, MCP23017_IODIR_A, &iodir_a, 1, MCP23017_I2C_TIMEOUT); dprintf("set I2C IODIR_A i2c_status_t:%d\n", status); // GPIOBをOutputに設定 uint8_t iodir_b = 0x00; status = i2c_write_register(MCP23017_I2C_ADDRESS << 1, MCP23017_IODIR_B, &iodir_b, 1, MCP23017_I2C_TIMEOUT); dprintf("set I2C IODIR_B i2c_status_t:%d\n", status); }
i2c_write_registerが、レジスタに書き込む関数になります。
MCP23017のI2Cアドレス(同じI2Cバスに繋がった複数のI2Cスレーブデバイスを、マスター側から指示しわけるためのアドレス)が0x20となっています。ただし、i2c_write_registerにおいては1bitシフトして設定する必要があります。
MCP23017からキーマトリックスのスキャンを実装する
COL2ROWのキーマトリックスのスキャンでは以下を行う必要があります。
- COL0をHighにし、それ以外のCOLをLowにする
- ROWを読み取る
- 1〜2を各COLで繰り返す
各COL/ROWを設定するためのbit値を示す配列として、RIGHT_COL_BITS、RIGHT_ROW_BITSを指定しました。COL0のときはRIGHT_COL_BITS[0]を使う形になります。
uint8_t RIGHT_COL_BITS[] = {1, 1 << 1, 1 << 2, 1 << 3, 1 << 4, 1 << 5, 1 << 6, 1 << 7}; uint8_t RIGHT_ROW_BITS[] = {1, 1 << 1, 1 << 2, 1 << 3, 1 << 4};
なお、私のキーボードではRP2040側のキーマトリックスのGPIOの定義もしています。
uint8_t LEFT_COLS[] = {GP5, GP6, GP7, GP8, GP9, GP10, GP11}; uint8_t LEFT_ROWS[] = {GP0, GP1, GP2, GP3, GP4};
わかりにくいのですが、QMK Firmwareにとっては以下となるように私のキーボードでは実装することになります。なので、キーボードにとってのCOLと、MCP23017として処理するCOLがずれることに注意してください。
スキャンは以下のように実装しました。RP2040側のキーマトリックスの処理についてはブログ中では省略します。コードは公開していますので、そちらを参照ください。
#define MCP23017_I2C_ADDRESS 0x20 #define MCP23017_GPIO_A 0x12 #define MCP23017_GPIO_B 0x13 bool matrix_scan_custom(matrix_row_t current_matrix[]) { // スキャン後のマトリックスの状態を入れておく matrix_row_t scaned_matrix[MATRIX_ROWS]; memset(scaned_matrix, 0, sizeof(scaned_matrix)); for (int col = 0; col < MATRIX_COLS; col++) { if (col < sizeof(LEFT_COLS)) { // MCU側のキーマトリックススキャン // 略 } else { // MCP23017側のMatrix // COL側、特定のCOLだけHIGHにして、他はLOWにする uint8_t write_buf = RIGHT_COL_BITS[col - sizeof(LEFT_COLS)]; i2c_status_t status = i2c_write_register(MCP23017_I2C_ADDRESS << 1, MCP23017_GPIO_B, &write_buf, 1, MCP23017_I2C_TIMEOUT); dprintf("write I2C GPIOB i2c_status_t:%d value:0x%02X col:%d\n", status, write_buf, col); if (status != I2C_STATUS_SUCCESS) { return 0; } // ROW側、読み取り uint8_t read_buf; status = i2c_read_register(MCP23017_I2C_ADDRESS << 1, MCP23017_GPIO_A, &read_buf, 1, MCP23017_I2C_TIMEOUT); dprintf("read I2C GPIOA i2c_status_t:%d value:0x%02X col:%d\n", status, read_buf, col); if (status != I2C_STATUS_SUCCESS) { return 0; } for (int row = 0; row < sizeof(RIGHT_ROW_BITS); row++) { // ROWごとに1(ボタンが押された行)があるか確認 if (read_buf & RIGHT_ROW_BITS[row]) { // このループのCOLのbitを立てる。 scaned_matrix[row] |= 1 << col; } } } } // 更新があったかチェックして、QMK Firmwareにスキャン結果を戻す配列current_matrixを更新する bool updated = false; for (int row = 0; row < MATRIX_ROWS; row++) { if (current_matrix[row] != scaned_matrix[row]) { current_matrix[row] = scaned_matrix[row]; updated = true; } } return updated; }
ポイントは以下です。
1 COLごとにループする。 2 COLごとのループで、対象のCOLのみHigh、それ以外はLowとなるように、レジスタGPIOBに書き込む。 3. 2.のあとにROWの各値を読み込むように、レジスタGPIOAを読み込む。読み込んでHighだった場合、スキャン結果の変数scaned_matrix[row]に書き込む。 4. 最後にscanned_matrixと引数current_matrixを比較して、引数current_matrixを更新する。また、更新された場合、戻り値にtrueを返す。
キーマトリクスの処理自体は非常に簡単なため、理屈がわかればそれほど実装量も多くありません。
このコードは以下で公開しています。
デアッグのポイント
上記コードにもdpritfがたくさん含まれるとおり、プリントデバッグを繰り返しました。
キーマトリックススキャンの度にログを出していると、毎秒100回近く動いているのもあり、結構ログの流量が多くなり、目で追えません。
そのため、私はデバッグログの出力を500msに1度しか行わないように工夫しました。
# ログの出力インターバル #ifndef CUSTOM_MATRIX_DEBUG_INTERVAL # define CUSTOM_MATRIX_DEBUG_INTERVAL 500 #endif static uint16_t d_timer = 0; bool matrix_scan_custom(matrix_row_t current_matrix[]) { bool debug = false; if (timer_elapsed(d_timer) > CUSTOM_MATRIX_DEBUG_INTERVAL) { debug = true; d_timer = timer_read(); } if (debug) { dprintf("-- matrix_scan_custom start --\n"); } // 略 }
ちゃんと動くか?
デバッグを繰り返し、ちゃんと動くようになりました!! 最近はSparrowDialなど、QMK Firmwareで作り込みをしていたため、その資産がそのまま使えるようになり、久しぶりに分割キーボードを楽しんでいます。
ただ、ファームウェア更新のためにRP2040側にリセットをかけたとき、I2C通信がそれ以降成功しない(ログ上もi2c_status=-1が返っている)ことが3回に1回くらい発生しました。MCP23017側が悪いのか、RP2040側が悪いのかまでは判別できていません。ただ、USBを再接続すると直ります。
RP2040から、I2CのVBUSを瞬断してMCP23017を再起動できるように、MOSFETを追加しても良かったなと思っています。
終わりに
ここのところはSparrow60C、SparrowDialと割れてないキーボードを使っていたのですが、やはり分割キーボードの良さとはまた別のものです。久しぶりに分割キーボードをQMK Firmwareで楽しめています。
Sparrow62v2ではMCP23017を利用していましたが、最近はSparrowTVでもIOエキスパンダとして、CH32V003F4P6にそのような実装をして利用しています。今後は、Sparrow62v3を作る際にはCH32V003F4P6を左右分割機能の要として活用したいと思っています。