Basic operation

The left hand side of the keyboard uses a Microchip MCP23018 IC, which is an I/O expander with open drain outputs and it can be configured through I2C or SPI. In this case we are going to focus on the I2C protocol only since it is the one used by the main MCU. The MCU is a Teensy 2.0 and it is placed on the right hand side of the Ergodox. It is used for monitoring all the switch status, converting the pressed switches into its corresponding character and sending it through USB to interact with the computer as a usual keyboard.

The MCP23018 has two different GPIO ports, GPIOA and GPIOB, each with 8 I/O pads. In the original Ergodox PCB, port A is used to interact with the columns and port B with the rows.
GPIO pads on port B (rows) are configured as inputs and the internal pull-up resistor is enabled on every port B pad. Regarding GPIO pads on port A, they are configured as outputs. According to the datasheet, output pads on the MCP23018 are open-drain, which basically means that the pad value can be internally driven to ground or left floating (high impedance). Since the rows and columns are connected through the mechanical switch and the diode, GPIO’s on port B can determine if the switch is closed (pressed) or open (released) depending on the voltage found at it. A diagram of this scheme is shown below.

The way the Ergodox firmware determines the state of every switch (closed/pressed or open/released) is by sequentially setting one column to ground while leaving the rest floating. Then, the state of the rows is read. Depending on the state of the switch, the GPIOB reading will be:

  • If the switch is released, the voltage at GPIOB will be VDD due to its pull-up resistor. The pad value will be read as 1.
  • If the switch is closed, the voltage at GPIOB will be set to 0.0 V. The pad value will be read as 0.

Since the rest of columns are left floating, they won’t interfere on the reading. Once the state of a particular row is read, its value is stored and the operation is repeated until all the available rows are covered. The process of reading the state of all the switches is called a «scan».

Debugging MCP23018 with Arduino

When I assembled my Ergodox, I loaded the QMK firmware expecting the keyboard to just work. However, there seems to be a hardware issue since the comunication between the LHS and the RHS is not successful. Therefore, I decided to replicated the QMK firmware on an Arduino to have a better understanding on how the MCP and the Teensy interact together and also figure out if there’s really any hardware issue on the PCB’s.

QMK firmware is highly customizable and therefore it can be a little bit intricate when trying to read its code. I tried to distil the most basic functionality regarding the writing and reading done from the Teensy to the Mcp23018 through I2C.

The code below will perform a scan every 1 second and will print through the Serial interface the keyboard matrix result.


#include <Wire.h>
#include "mcp23018.h"
#include "i2c_wrapper.h"
#include "utils.h"

#define KB_ROWS     6
#define KB_COLUMNS  7  // 14 in total, 7 in left hand side

int data;
bool matrix[KB_ROWS][KB_COLUMNS];

void setup() {

  while (!Serial); // Leonardo: wait for Serial Monitor
  Serial.println("\nMCP23018 debugger");


void loop() {

  while(true) {
    // Clear matrix values

    for (uint8_t col = 0; col < KB_COLUMNS; col++) {
      // Drive column to ground
      // Read GPIO's on port B
      data = i2c_rd(I2C_ADDR, GPIOB);
      for (uint8_t row=0; row < KB_ROWS; row++) {
        // Store read values into the matrix
        matrix[row][col] = !( data & (1<<(5-row)) );

    // Leave column floating again
    // Wait 1 second


void drive_column(uint8_t col) {
   i2c_wr(I2C_ADDR, GPIOA, 0xFF & ~(1 << col));

void drive_column_hiz() {
  i2c_wr(I2C_ADDR, GPIOA, 0xFF);

void clear_matrix() {
  for (uint8_t row = 0; row < KB_ROWS; row++) {
    for (uint8_t col = 0; col < KB_COLUMNS; col++) {
       matrix[row][col] = 0;

void print_matrix() {
   for (uint8_t col = 0; col < KB_COLUMNS; col++) {
      if (col == 0) {
        Serial.println("[ ");
      for (uint8_t row = 0; row < KB_ROWS; row++) {
         Serial.print(matrix[row][col] ? 1 : 0);
         Serial.print("  ");
      if (col == 6) {


MCP23018 debugger
0  0  0  0  0  0  
0  0  0  0  1  0  
0  0  0  0  0  0  
0  0  0  0  0  0  
0  0  0  0  0  0  
0  0  0  0  0  0  
0  0  0  0  0  0  

where 1 means the switch is pressed and 0 released.

Auxiliary files


#include <Wire.h>

int i2c_rd(int i2c_addr, int reg_addr) {
   int rd_data;
   // Start condition + 7 bit I2C device address
   // Write bit + next byte
   // Stop condition
   // Start condition + address + read bit
   Wire.requestFrom(i2c_addr, 1); // request 1 byte from slave device
   if(Wire.available()) {
    rd_data =;
   return rd_data;

int i2c_wr(int i2c_addr, int reg_addr, uint8_t wr_data) {
   // Start condition + 7 bit I2C device address
   // Byte with address to be written
   // Data to be written
   // Stop condition
   return Wire.endTransmission();


#define I2C_ADDR        0b0100000
// i/o direction register
#define IODIRA          0x00
#define IODIRB          0x01
// GPIO pull-up resistor register
#define GPPUA           0x0C
#define GPPUB           0x0D
// general purpose i/o port register (write modifies OLAT)
#define GPIOA           0x12
#define GPIOB           0x13


#ifndef _UINT8_T
#define _UINT8_T
typedef unsigned char uint8_t;
#endif /* _UINT8_T */

int print_addr_val(int addr, int val) {
    Serial.print("Addr ");
    Serial.print(addr, HEX);
    Serial.print(": ");
    Serial.println(val, BIN);