Introduction: Adding Buttons to Your Project With an MCP23017 GPIO Expander and Using MCU Interrupts and Background Tasks to Process Button Events

Thinking of adding a few different buttons to your project and you already have used most of the free GPIO pins on your microcontroller? Are you using slow display drivers that make handling your button events unreliable? Interested in going past simple demo code and handling buttons professionally? If so, this Instructable is for you.

Adding multiple buttons and LEDs to your project will quickly consume your free GPIO pins, especially if a number of them are already used to hook up a TFT/OLED/LCD screen using SPI and you have other I2C or serial based sensors connected as well. Many of the development boards are short on free GPIO pins. The solution to that is to add a GPIO expander to your project. This is a dedicated board with 16 additional pins that will connect to your main microcontroller using the I2C protocol. You can even stack up to 4 of these modules, each with an additional 16 IO ports. I highly recommend the Adafruit MCP23017 I2C GPIO Expander Breakout with Qwiic connectors. If you have a microcontroller that has a qwiic connector on it, it is trivial to connect the board in using a simple qwiic cable. If you don't have qwiic yet, it can be hooked to your microcontroller using your I2C pins of SDA/SCL and power and ground. Once connected, the pins will be available to your project via communicating with the MCP23017 IC on the board. This is a little different in that the pins are not directly on your microcontroller and need to be accessed through the Adafruit library instead.

Most projects that use buttons are interested in the button press event and would like to use interrupts to reliably handle the button press events without losing them. This is particularly important when you have a slow display adapter in your project that can take a second or more to update the screen. You can't just rely on your main program loop() function to capture those events. Most articles on handling buttons recommend using interrupts and the code examples are assuming that your buttons are directly attached to the microcontroller pins, which is not the case if you are using a GPIO expansion board. The expansion board does have two interrupt pins that can be used for the GPIO expansion board to notify your main controller that an event has occurred, but that only notifies that something has happened and the controller has to query the MCP23017 chip for the event details over the I2C bus. That presents a challenge in that hardware interrupts are supposed to be lightning fast and communicating over the I2C bus is not supported and is the easiest way to crash your microcontroller and force it to restart. On the otherhand, not using interrupts and just polling the MCP23017 chip in your main loop will flood your I2C bus with unnecessary traffic and slow down your project and interfere with other communications over that bus. Adafruit does include an interrupt example in their library, but it only shows how to configure the interrupt pin on the MCP23017 chip and does not implement an interrupt service routine on the host microcontroller. So neither the standard Arduino examples nor the Adafruit examples are overly helpful. That is where this instructable article comes in...

The approach I am documenting here will use an interrupt service routine (ISR) on the host microcontroller unit (MCU), will have that ISR raise an event with a background task that will query the MCP23017 chip for the interrupt details. That background event handler will process the button press events and insert them into a FreeRTOS queue which can be monitored and picked up by your code running elsewhere, including in your loop(). The architecture here will ensure that you do not miss any button press events, even if your main loop is blocking on other code. It will also not put any unnecessary traffic on the I2C bus.

I have published the library to Github and am working on submitting it to both the Arduino and PlatformIO library manager systems so it will be easy for you to consume and use. Using the library I am providing is not crucial for your success and I will put a line-by-line code walkthrough here so you can implement it in your own project even if you don't want to use the provided library (or are waiting for me to complete the task of getting it into the standard libraries).

Please note that I have developed this technique for the ESP32 platform. It should work on other platforms too, but I have not tested it there. The key is to ensure that FreeRTOS is viable there. It is technically possible to run it on an ACR based Arduino Uno board, but the memory limitations are significant and to constrictive for this to be viable in any real project, so I recommend you find something with a little more memory.

Supplies

  1. Espressif ESP32 development board, such as the SparkFun Thing Plus ESP32-S2.
  2. Adafruit MCP23017 I2C GPIO Expander Breakout.
  3. Some tactile push button momentary switches. Get a package from Amazon appropriate for your project.
  4. Breadboard or protoboard and wires to set up your project.
  5. Source code from GitHub for shan-mcarthur/GpioExpanderLib library

I am demonstrating this on an ESP32 board. You can use either Arduino IDE or Visual Studio Code with PlatformIO extensions (recommended). Setting up your board development is out of scope of this instructable.

I always recommend having the datasheet handy for any module or sensor you are using. Here is the MSP23017 datasheet.

Step 1: Connect Your MCP23017 Expander to Your Microcontroller

If you are using a Microcontroller and expansion board that is equipped with a Qwiic connector, simply plug the expansion board in using a standard qwiic cable. If you don't have Qwiic, you can connect your expansion board to your MCU by wiring up the power, ground, SDA, and SCL pins for I2C. Please consult your specific MCU datasheet and connection guide for pin specifics. In addition to the I2C bus connection, you should also connect the IA pin on the expansion board to enable an interrupt signal to your MCU. The code mirrors both banks to a single interrupt so just hook up IA and don't worry about IB. This minimizes the number of new pins you have to dedicate to the project.

Step 2: Connect Your Buttons

The code in the project assumes that you will connect your buttons to a GPIO pin on the expansion board and pressing the button will ground that pin. The pin will be configured with pullup resistors. For the MCP23017 chip, the buttons are arranged in 2 banks of 8 buttons. The project will configure the IC so that pins on BANKA are pins numbered 0-7 and the ones on BANKB are pins 8-15. I recommend connecting all of your buttons in sequence starting on pin A0 and going up. Each button will have one pin connected to a shared ground and one to a dedicated GPIO pin on the expansion board. Most buttons have 4 connectors on them for physical stability, and you need to find the correct pair to connect. Test your button for continuity with it not pressed and select the two pins on the same side that do not have continuity. Validate that they do have continuity when you press the button.

Step 3: Create Your Project Using the Example Sketch

This instructables article is based on the code I published in the shan-mcarthur/GpioExpanderLib library in GitHub. I have provided an example sketch in the GitHub project under the examples folder. You can open the .INO file directly in the Arduino IDE, or you can copy/paste the contents to your /src/main.cpp file in a new PlatformIO project based on the Arduino project.

If you are using Visual Studio Code and PlatformIO, simply put these library dependencies in your platformio.ini file:

lib_deps = 
    adafruit/Adafruit MCP23017 Arduino Library@^2.3.0
    https://github.com/shan-mcarthur/GpioExpanderLib.git


The important parts of integrating this library into your project are as follows (code fragments from the example file).

Include the library header. You also need to include the Adafruit library:

#include <Adafruit_MCP23X17.h>
#include "GpioExpanderButtons.h"


It is typical to plan your pin assignments and use macros for the pin numbers, which will save on program memory requirements. This scenario will require the use of one GPIO pin for the use of an interrupt from the MCP23017 IC on the expansion board. In my project, I decided to use GPIO4 pin, but plan appropriately for your project and by consulting your development board's connection instructions and pin diagrams.

#define EXPANDER_INT_PIN 4      // microcontroller pin attached to INTA/B


Declare global variables to house the Adafruit and GpioExpanderButtons objects used:

// global variables for GPIO expander
Adafruit_MCP23X17 mcp;
GpioExpanderButtons expander;

Step 4: Configure Your Setup Method

In your setup() method, you need to initialize the MCP23017 chip. The chip can be connected via SPI or I2C and you might have it on a secondary I2C bus, so I left the initialization of the chip up to you.

  // initialize the MCP23x17 chip
  if (!mcp.begin_I2C())
  {
    Serial.println("Error connecting to MCP23017 module.  Stopping.");
    while (1);
  }


Initialize the GpioExpanderLib with the MCP23017 object you just initialized, the interrupt pin you have wired up in the previous steps, and the list of buttons that you are going to use. The pins are represented as bits in a single 16 bit integer. I provided some handy macros that will bit-shift the pin numbers to appropriate bit values and you can simply use a bitwise OR to combine them into a single number. You can also just calculate the number and provide a simple 16 bit integer without the macros if you desire. In my example, I have hooked up 4 buttons to BANKA (pins A0-A3 on the board).

  // initialize the button library with the set of pins to support as buttons
  uint16_t pins = GPIOEXPANDERBUTTONS_PIN(0)
                | GPIOEXPANDERBUTTONS_PIN(1)
                | GPIOEXPANDERBUTTONS_PIN(2)
                | GPIOEXPANDERBUTTONS_PIN(3);
  expander.Init(&mcp, EXPANDER_INT_PIN, pins);


The expander.Init() call will set up the interrupt line and handlers on the microcontroller host and configure the pins by talking to the MCP23017 chip over the I2C network. Everything should be fully active and responding to button clicks at this moment.

Step 5: Configure Your Loop Method

Your main loop should be designed to be highly performant and loops continuously. You should not have delays in your code and you should do everything that is possible to ensure high performance for everything that is in your main loop if you want your project to be responsive to events in a timely manner. To facilitate this concept, we are using a hardware interrupt on the microcontroller host that will process a button press and insert a queue into a FreeRTOS queue that you can monitor.

In your loop, you can quickly check if there are any pending events in the queue, and if there are none, you can continue on with your other code. If you detect an event in the queue, you can then extract the event from the queue, decode the pin number for the button press and invoke any processing that you need to handle that event.

  // process all pending butten events in the queue
  while (uxQueueMessagesWaiting(xGpioExpanderButtonEventQueue))
  {
    // retrieve the next button event from the queue and process it
    uint8_t pin;


    // retrieve the event from the queue and obtain the pin info
    xQueueReceive(xGpioExpanderButtonEventQueue, &pin, portMAX_DELAY);
   
    // process the pin
    Serial.print ("pin ");
    Serial.print (pin);
    Serial.println (" pressed");
  }


Just remember that to handle your button presses quickly, you must not have delay() methods or other slow code running in your main loop. Designing for high performance and optimizing your main loop is not the purpose of this article, but it is essential if you want to properly respond to button click events without frustrating your users.

At this point, you can compile and upload your sketch to your microcontroller and run it. Connect to the serial port and you should see messages about the pins as they are being pressed.

Step 6: Inspecting the Library Code

If you just want to use the library you are done, but if you want to learn about how it works, or want to adapt this technique within your custom code, I would encourage you to open the library source and inspect it. I want to highlight some of the important aspects of the code. The source code is documented so I won't go through things line-by-line, but I feel it would be helpful to walk through the code so you understand the key aspects.

Let's take a look at the code in the order of it being executed. Let;s start with the setup of the library. Locate the GpioExpanderButtons::Init() method. You will pass in the reference to the expander chip that you set up previously and your interrupt pin.

The first thing that library will do is to configure the MCP23017 chip interrupt system. Please reference the datasheet for the meaning of these, but in essence they are getting things ready to handle interrupts on both BANKA and BANKB using mirroring.

// set up the expander module for interrupts
    _expander->setupInterrupts(true, false, LOW);


Once the interrupt system is configured, the next step is to configure each of the pins. The code will take that bitmask of the pins that you provided and set each one that you indicate as an input pin with a pullup resistor and to participate in the MCP23017's interrupt system. Note that the interrupt is set to CHANGE. This is necessary to get a single interrupt on a button press, and a separate interrupt on a button release. If we just used LOW, then pressing and holding a button would continuously create an interrupt on that button. It might be a future extension to the library to allow for press-and-hold configuration, but for now, I wanted to capture the button presses reliably and that requires the use of CHANGE for the interrupt type.

    // configure each of the expander module pins
    for (int i=0; i<16; i++)
    {
        // check if this pin is a button
        if (buttonPins & (1 << i))
        {
            // set the button pin mode to input with a pullup resistor, and enable interrupts on state change
            _expander->pinMode(i, INPUT_PULLUP);
            _expander->setupInterruptPin(i, CHANGE);
        }
    }


Then it will clear any pending interrupts, just to make sure we are starting out with a clean slate:

    // clear any pending interrupts
    _expander->clearInterrupts();


It will then configure the interrupt pin on the microcontroller host as an input pin with pullup resistor and attach an interrupt handler on the pin.

    // configure MCU pin that will receive the interrupt from the GPIO expander
    pinMode(mcu_interrupt_pin, INPUT_PULLUP);
    attachInterrupt(digitalPinToInterrupt(mcu_interrupt_pin), GpioExpanderButtons::GpioExpanderInterrupt, FALLING);

The GpioExpanderInterrupt will be a background task that will run at a high priority and be triggered with a hardware interrupt. When the hardware interrupt is triggered, your regular (low priority) code will be suspended to handle the interrupt. Since this can be disruptive, there is a watchdog timer that will ensure that your code completes quickly. It will crash the microcontroller if it takes too long.

To offload the longer processing required to interrogate the MCP23017 about the interrupt it raised, and to clear that interrupt, I chose to use task notification as it is an incredibly efficient mechanism for one task to pass work to another task. To achieve this, we have to set up the task notification to trigger the method we are designating as the handler of that event.

// initiate background task to handle the button presses
    xTaskCreate(
              GpioExpanderButtons::GpioExpanderButtonServiceTask,  // Function to be called
              "Service Button Events",   // Name of task
              2048,         // Stack size (bytes in ESP32, words in FreeRTOS)
              (void *)this,         // Parameter to pass to function
              1,            // Task priority (0 to configMAX_PRIORITIES - 1)
              &xGpioExpanderButtonsTaskToNotify);         // Task handle



When the interrupt handler raises the event, the background task with the name of "Service Button Events" will be triggered. That is implemented by the static method GpioExpanderButtons::GpioExpanderButtonServiceTask(). It needs a stack size of about 2K of RAM, and the current class is passed in as a parameter so that it can access the extender and call its APIs. The task priority is normal. The variable xGpioExpanderButtonsTaskToNotify is used for this task notification system.

That takes care of both the short interrupt service routine and the background task that will service that event. The background task needs to notify your code of the button press events as they occur, and I have chosen to use a FreeRTOS queue to do that as it is an ordered FIFO queue that you can very easily listen to. To set up this queue, we simply have to create a queue and specify a size of the queue, of which I have arbitrarily chosen 50. Make sure your code is responsive and doesn't get too far behind!

    // initialize a queue to use for the button press events
    xGpioExpanderButtonEventQueue = xQueueCreate(50, sizeof(uint8_t));


Everything is set up now and the system should be fully operational. Let's switch to what happens when you press a button. When you press the physical button, it will short the pin to ground, and the MCP23017 will see that, capture the details of the event and the state of all pins in its internal registers and short its own interrupt pin to ground. The microcontroller will sense this and the interrupt service routine (ISR) set up here will now be triggered as a high priority task. As we mentioned, ISRs need to run very quickly and a watchdog timer will crash the microcontroller if it does not complete quickly. The ISR only has one job in this design - raise an event for the GpioExpanderButtonServiceTask() and pass over control to the lower priority task that is not subject to the watchdog timer limits and won't interfere with your other program tasks. This is a very useful feature of FreeRTOS and a good pattern to understand. Here is the entire code for that ISR and shows the task notification mechanism it uses to raise that event.

// Hardware Interrupt Service Routine (ISR) for handling button interrupts
void IRAM_ATTR GpioExpanderButtons::GpioExpanderInterrupt()
{
    // in ISR must be fast and cannot reach out to a sensor over the wire, so notify a lower priority task that an interrupt has occured
    // notify the button handler that a hardware interrupt has occured
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    vTaskNotifyGiveFromISR(xGpioExpanderButtonsTaskToNotify, &xHigherPriorityTaskWoken);


    // yield processor to other tasks
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}


Let's switch to the service task we set up to handle this event. Locate the GpioExpanderButtons::GpioExpanderButtonServiceTask(void *parameter) method. The method will be passed a single parameter that we have previously configured as the pointer to the current instance of the class. This class only gets instantiated once as it is a task that runs in the background waiting for new notifications.

This line will convert the void pointer to the right object type:

  // the inbound parameter is the pointer to the current expander class
GpioExpanderButtons *expander = (GpioExpanderButtons *)parameter;


Since this is a perpetual task that runs in the background, it has its own loop. It will enter a loop and the first thing it does every time is wait for that task notification event. It will receive that event and tests to know that it was successful before it processes it. If there is no event ready the task will be suspended until one is ready. It will not use CPU cycles while it is suspended waiting for a signal.

    // continuously process new task notifications from interrupt handler
    while(1)
    {
        // wait for a task notification raised from the interrupt handler
        thread_notification = xTaskNotifyWait(pdTRUE, ULONG_MAX, &ulNotifiedValue, portMAX_DELAY);

        if (thread_notification == pdPASS)
        {


Once it has a valid event, it will go to the MCP23017 module and determine the pin that raised the interrupt and the state of all other pins. Once it gets those details, it will clear the interrupt pin on the MCP23017, enabling it to be ready to process antoher event. It is important to clear off that interrupt at the earliest opportunity otherwise we could miss some events and we don't want to do that!

           // get the details from the GPIO expander based on the last interrupt
            uint8_t iPin = 255;
            iPin = expander->getLastInterruptPin();
            if (iPin != 255)
            {
                // retrieve all the pin states as of the time of the last interrupt
                allPins = expander->getCapturedInterrupt();

                // clear the interrupt, enabling the expander chip to raise a new interrupt
                expander->clearInterrupts();
            }


The final thing the task does is to check if the pin that was responsible for the interrupt was pressed. Remember that our interrupts are configured for CHANGE, and we will also get an interrupt for a button release. At this point the library is only interested in button presses, so we just test if the button is pressed and add that button info to the queue.

            // check if this is a button press (versus a release)
            if (iPin != 255 && GPIOEXPANDERBUTTONS_PIN_PRESSED(allPins, iPin))
            {
                // send a button press to the queue
                xQueueSend( xGpioExpanderButtonEventQueue, &iPin, portMAX_DELAY);
            }


That is all that is going on in the library, but we aren't done. That button was pressed, and a message is in the queue. Go back to your loop code and look at the uxQueueMessagesWaiting() and xQueueReceive() methods to see how you are testing the queue and receiving the messages from it.