Electronics Software Development

Adding Digital I/O To Your Arduino: Part 3 – The MCP23017

Add More IO Green Graphic
Written by John Woolsey

Last Updated: March 29, 2021
Originally Published: March 18, 2021

Skill Level: Intermediate

Table Of Contents

Introduction

Sometimes, a project needs more digital I/O than what is available on your Arduino board. This often happens when you connect to components that require a lot of pins for their interface, e.g. some displays, or your project uses many discrete sensors and/or actuators.

This three-part tutorial teaches you how to add more digital inputs and outputs to your Arduino development board. Each part focuses on a specific integrated circuit (IC) chip.

Part 1 – The 74HC595 described how to add digital outputs using the 74HC595 8-bit serial-in parallel-out (SIPO) shift register IC.

Part 2 – The 74HC165 described how to add digital inputs using the 74HC165 8-bit parallel-in serial-out (PISO) shift register IC.

Part 3 – The MCP23017 (currently reading) describes how to add both digital inputs and outputs using the MCP23017 16-Bit I2C I/O Expander With Serial Interface IC.

A basic understanding of electronics and programming is expected along with some familiarity with the Arduino platform. If you are new to Arduino, or would just like to refresh your knowledge, please see our Blink: Making An LED Blink On An Arduino Uno tutorial before proceeding with this one. In addition, this tutorial will use a solderless breadboard to build a circuit from a schematic diagram. The All About Circuit’s Understanding Schematics, SparkFun’s How to Read a Schematic, Core Electronics’ How to Use Breadboards, and Science Buddies’ How to Use a Breadboard guides are good resources for learning how to translate a schematic to a breadboard.

The resources created for this tutorial are available on GitHub for your reference.

What Is Needed

Background Information

If you need to add both digital inputs and outputs to your project, the MCP23017 IC from Microchip is a good choice to incorporate into your design. It is a 16-bit I/O port expander that adds a total of 16 additional digital GPIO pins, in two ports, PORTA and PORTB with 8 pins each, that communicates with your Arduino over an I2C serial interface. 8-bit and SPI port expander versions are also available if you prefer.

Each MCP23017 IC can be set to one of eight I2C addresses (0x20-0x27) giving you a total of 128 (8 ICs x 16 pins) additional digital I/O pins that you can add to your project.

Each GPIO pin of the MCP23017 can be configured as either an input or an output. Additional options can also be enabled for each pin, i.e., an internal pull-up resistor, triggering an interrupt, and polarity inversion. Interrupts can even be configured to not only trigger on a change but also trigger on a comparison to a default value set by the user.

The MCP23017 is highly configurable. I will be covering many, but not all, of its capabilities. To learn more, check out the MCP23017 datasheet.

There are multiple ways to configure and use the MCP23017 within an Arduino sketch to access and control GPIO pins. Each has its pros and cons and can be heavily dependent on the nature of the additional inputs or outputs required. For instance, are the additional I/O highly disparate and need to be manipulated separately, or are they more homogeneous and can be referred to as a single block? I will present four different approaches in this tutorial, all producing the same basic functionally, so that you can easily compare among them and choose the best approach for your own design. While all of these approaches eventually update the MCP23017’s output pins (LEDs) with associated changes in the input pins (switches), each approach provides a different way to visualize or manipulate individual pins.

My development system consists of the Arduino Uno WiFi Rev2 development board connected to a macOS based computer running the desktop Arduino IDE. If you are using a different Arduino board or computer setup, the vast majority of this tutorial should still apply, however, some minor changes may be necessary.

If you need assistance with your particular setup, post a question in the comments section below and I, or someone else, can try to help you.

Building The Circuit

Before connecting any circuitry to your Arduino board, disconnect it from power and your computer. This avoids accidental damage during wiring.

Place the components and wire up the circuit on a breadboard according to the schematic diagram shown below.

Schematic Diagram Of An MCP23017 Digital I/O Circuit Connected To An Arduino Uno
Schematic Diagram Of An MCP23017 Digital I/O Circuit Connected To An Arduino Uno

Eight LEDs are connected to PORTA‘s GPA0GPA7 (21-28) pins of the MCP23017 IC via 330 Ω resistors constituting the 8 digital outputs being added to the system. Please note, the MCP23017 has a maximum sink/source current capability per I/O pin of 25 mA and a total chip current capability of 125 mA into the VDD pin or 150 mA out of the VSS pin. Our use of 8 LEDs, with expected currents of 10 mA or less each, easily falls within the proper current range, but additional design constraints may need to be considered when using more outputs or higher current devices.

An 8-position dip switch is connected to PORTB‘s GPB0GPB7 (1-8) pins of the MCP23017 constituting the 8 digital inputs being added to the system. Eight individual standard single-pole single-throw (SPST) switches may be used instead of the integrated 8-position dip switch if you prefer. Typically, we would also need to incorporate either pull-up or pull-down resistors in series with the 8 switches. However, we will be utilizing the internal pull-up resistors of the MCP23017 so that we can eliminate the need for those external resistors. Since pull-up resistors are being used, the other side of the switches are all tied to ground.

The MCP23017’s three address pins, A0A2 (15-17), set the lowest three bits of the I2C address range (0x20-0x27) that will be used to communicate to the IC over the I2C serial bus. Pins tied low (ground) correspond to a value of 0 and pins tied high (power) correspond to a value of 1. All address pins must be tied to either power or ground and can not be left floating. Since we are tying all address pins to ground (000), this correlates to an address of 0x20 for our MCP23017 IC. If you prefer to use a different I2C address or plan to use more than one MCP23017 IC in your design, make sure to tie the address pins for each IC appropriately.

The MCP23017’s RESET (18) pin is an active low input and must be externally biased so that it is not left floating. I tied it directly to power since we do not plan to reset the chip directly.

A 0.1 µF bypass capacitor was placed across the power pins of the MCP23017 IC in order to reduce any power supply noise that may be present.

The I2C clock, SCK (12), and data, SDA (13), pins of the MCP23017 IC are connected to the SCL and SDA pins respectively of the Arduino board to establish the I2C serial bus connection. Be aware that not all Arduino boards have the analog pins A4 and A5 connected to the I2C port. For this reason, I am using the SCL and SDA pins on the Digital row of the Arduino board.

INTA (20) and INTB (19) are the interrupt output pins for PORTA and PORTB respectively of the MCP23017. Later, we will be utilizing interrupts on PORTB to detect switch changes, so INTB is connected to D2 on the Arduino. We will not be using interrupts with PORTA so I left the INTA pin unconnected.

The circuit should look similar to the one shown below once completed.

Completed Arduino Uno And MCP23017 Digital I/O Circuit
Completed Arduino Uno And MCP23017 Digital I/O Circuit

Once the circuit is built, connect your Arduino to your computer with the USB cable.

Reading And Writing Single Inputs And Outputs Using Familiar digitalRead() And digitalWrite() Functionality

This first approach implements the same calling mechanisms as Arduino’s standard digitalRead() and digitalWrite() functions. It should be very familiar to long time Arduino users and the easiest to understand. However, when reading and writing multiple inputs or outputs, it will involve more I2C communication than other approaches since only one input or output at a time can be read or written respectively.

I researched various libraries available for use with the MCP23017 and chose the MCP23017 library by Bertrand Lemasle due to its thoroughness and ability to write to all MCP23017 registers if needed.

Open the Arduino IDE and install the MCP23017 library from within the Library Manager.

Next, create a sketch named PortExpander with the code shown below.

#include <MCP23017.h>

#define MCP23017_I2C_ADDRESS 0x20  // I2C address of the MCP23017 IC

const uint8_t LED0 = 0;      // GPA0 (21) of the MCP23017
const uint8_t LED1 = 1;      // GPA1 (22) of the MCP23017
const uint8_t LED2 = 2;      // GPA2 (23) of the MCP23017
const uint8_t LED3 = 3;      // GPA3 (24) of the MCP23017
const uint8_t LED4 = 4;      // GPA4 (25) of the MCP23017
const uint8_t LED5 = 5;      // GPA5 (26) of the MCP23017
const uint8_t LED6 = 6;      // GPA6 (27) of the MCP23017
const uint8_t LED7 = 7;      // GPA7 (28) of the MCP23017
const uint8_t Switch0 = 8;   // GPB0 (1) of the MCP23017
const uint8_t Switch1 = 9;   // GPB1 (2) of the MCP23017
const uint8_t Switch2 = 10;  // GPB2 (3) of the MCP23017
const uint8_t Switch3 = 11;  // GPB3 (4) of the MCP23017
const uint8_t Switch4 = 12;  // GPB4 (5) of the MCP23017
const uint8_t Switch5 = 13;  // GPB5 (6) of the MCP23017
const uint8_t Switch6 = 14;  // GPB6 (7) of the MCP23017
const uint8_t Switch7 = 15;  // GPB7 (8) of the MCP23017

MCP23017 mcp23017 = MCP23017(MCP23017_I2C_ADDRESS);  // instance of the connected MCP23017 IC

void setup() {
   Wire.begin();     // initialize I2C serial bus
   mcp23017.init();  // initialize MCP23017 IC

   // Configure MCP23017 I/O pins
   configurePinsWithPinMode();  // familiar pinMode() style

   // Reset MCP23017 ports
   mcp23017.writeRegister(MCP23017Register::GPIO_A, 0x00);
   mcp23017.writeRegister(MCP23017Register::GPIO_B, 0x00);
}

void loop() {
   readAndWriteWithDigitalReadAndDigitalWrite();
}

void configurePinsWithPinMode() {
   // Configure output pins
   mcp23017.pinMode(LED0, OUTPUT);
   mcp23017.pinMode(LED1, OUTPUT);
   mcp23017.pinMode(LED2, OUTPUT);
   mcp23017.pinMode(LED3, OUTPUT);
   mcp23017.pinMode(LED4, OUTPUT);
   mcp23017.pinMode(LED5, OUTPUT);
   mcp23017.pinMode(LED6, OUTPUT);
   mcp23017.pinMode(LED7, OUTPUT);

   // Configure input pins with internal 100K pull-up resistors
   // Third argument inverts the polarity of the input value when read
   mcp23017.pinMode(Switch0, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch1, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch2, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch3, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch4, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch5, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch6, INPUT_PULLUP, true);
   mcp23017.pinMode(Switch7, INPUT_PULLUP, true);
}

void readAndWriteWithDigitalReadAndDigitalWrite() {
   // Read and write individual inputs and outputs
   mcp23017.digitalWrite(LED0, mcp23017.digitalRead(Switch0));
   mcp23017.digitalWrite(LED1, mcp23017.digitalRead(Switch1));
   mcp23017.digitalWrite(LED2, mcp23017.digitalRead(Switch2));
   mcp23017.digitalWrite(LED3, mcp23017.digitalRead(Switch3));
   mcp23017.digitalWrite(LED4, mcp23017.digitalRead(Switch4));
   mcp23017.digitalWrite(LED5, mcp23017.digitalRead(Switch5));
   mcp23017.digitalWrite(LED6, mcp23017.digitalRead(Switch6));
   mcp23017.digitalWrite(LED7, mcp23017.digitalRead(Switch7));
}

Line 3 sets the address we will use to communicate with the MCP23017 IC over the I2C bus. This address needs to match the address specified by the hardwired A0A2 pins discussed in the Building The Circuit section.

Lines 5-20 define the named references for our individual LEDs and switches. The integer values are how the MCP23017 library refers to the individual pins of PORTA (07) and PORTB (815). I have also included the specific MCP23017 port pins in the comments for correlation to the hardware.

Line 22 defines our instance, mcp23017, of the MCP23017 chip.

In the setup() routine, lines 25 and 26 initialize the I2C bus and the mcp23017 instance.

Lines 32-33 reset the ports by writing zeros directly to the MCP23017’s GPIO registers. This helps the sketch and the MCP23017 IC remain synced and is especially helpful between uploads.

Since we will be covering multiple configuration and operational approaches, I separated them into their own distinct functions.

The first configuration approach is the configurePinsWithPinMode() function that is called in setup(). It uses the familiar pinMode() functionality to configure the individual inputs and outputs of the MCP23017. Note the extra third argument, true, used when setting the inputs. This optional argument inverts the polarity of the value read from the input pin. In other words, if the real value is LOW, it will give you HIGH when read, and vice versa. This ability comes in quite handy since we had to tie the common sides of the dip switches to ground in order to take advantage of the internal pull-up resistors of the MCP23017 IC. With that being the case, the switches become LOW when turned on, but it would be nice to show them as HIGH instead. Enabling the polarity inversion option of the MCP23017 does just that.

Likewise, the readAndWriteWithDigitalReadAndDigitalWrite() function is the first operational approach and is called in the loop() routine. It simply reads the current value of an input (switch) and writes that value to the associated output (LED). Since this routine is run continuously, any flipped switches are immediately reflected in the LEDs.

Save, compile, and upload the sketch to give it a try. Turn on and off a few of the switches and watch the matching LEDs turn on and off.

If there is something that needs further explanation, please let me know in the comment section and I will try to answer your question.

Reading And Writing All Inputs Or Outputs Of A Port

This second approach takes advantage of the fact that we designed PORTA to be all outputs and PORTB to be all inputs giving us the ability to not only easily manage all pins of a port, but spend less time in I2C bus communication. It produces the most concise code, but does not indicate the meaning of each of the inputs or outputs.

Add the following functions to the end of your sketch.

void configurePinsWithPortMode() {
   // Configure PORTA (LEDs)
   mcp23017.portMode(MCP23017Port::A,
      0b00000000);  // direction (IODIRA) - set direction of all pins as outputs

   // Configure PORTB (switches)
   mcp23017.portMode(MCP23017Port::B,
      0b11111111,   // direction (IODIRB) - set direction of all pins as inputs
      0b11111111,   // pull-up (GPPUB) - enable 100K pull-up resistors on all inputs
      0b11111111);  // polarity (IPOLB) - invert logic polarity for all inputs
}

void portCopy() {
   // Copy values from PORTB (switches) to PORTA (LEDs)
   mcp23017.writePort(MCP23017Port::A, mcp23017.readPort(MCP23017Port::B));
}

Then replace the pins configuration line within the setup() function from

configurePinsWithPinMode();  // familiar pinMode() style

to

// configurePinsWithPinMode();  // familiar pinMode() style
configurePinsWithPortMode();  // concise portMode() style

thereby commenting out the old pins configuration routine and adding the new one underneath the original.

Likewise, replace the body of the loop() function with the following.

// readAndWriteWithDigitalReadAndDigitalWrite();
portCopy();

Notice that this approach drastically reduces the number of lines needed to configure the I/O pins thanks to the MCP23017 library’s portMode() method. All pin directions for an entire port are written as a single byte with 0 representing an output and 1 an input for each pin. The optional third and fourth arguments of portMode(), used for PORTB, enable the pull-up resistor and polarity inversion for each pin. All bits in each byte of this configuration are all of the same value, but that does not have to be the case. You can mix and match inputs, outputs, and enabled options for the various pins as you see fit. I deliberately designed the circuit and configured the ports in this manner so that I could take advantage of the fact that all pins of a given port are all of the same type. Thus, the portCopy() function consists only of a single statement that simply reads all the pins of PORTB and writes their values to the pins of PORTA.

Save your work and upload the updated sketch to test the code. It should function in exactly the same way as before, just with less code.

Port based reading and writing are good choices to use when all the pins of a port are the same type (inputs or outputs) and can be referred to as a single block, like a bank of LEDs or switches in our case.

As you can probably tell from our work so far, the digitalRead(), digitalWrite(), readPort(), and writePort() methods of the MCP23017 library are analogous to the isrDigitalRead(), osrDigitalWrite(), isrReadRegister(), and osrWriteRegister() functions we created and used in Part 1 – The 74HC595 and Part 2 – The 74HC165 of this series. Hence, all of the approaches and examples we covered in Part 1 – The 74HC595:

  • changeOutputsWithDigitalWrite(),
  • changeOutputsWithBinaryValues(),
  • changeOutputsWithDefinedNames(),
  • changeOutputsWithBitOperations(), and
  • cycleLEDs()

and Part 2 – The 74HC165:

  • readInputsWithDigitalRead(),
  • readInputsWithBinaryValues(),
  • readInputsWithDefinedNamesAndBitOperations(), and
  • readAndPrintInputsOnChange()

also apply to the MCP23017 when replacing the relevant operational functions of the shift registers with their MCP23017 library counterparts. So instead of reiterating those approaches, we are focusing on the differences and capabilities of the MCP23017 IC in general and the MCP23017 library in particular in this last part of the series.

Utilizing Interrupts For Greater Efficiency

The previous approaches continuously looped through reading and writing the I/O pins. This is not a very efficient use of the microcontroller’s time. We could use polling to read the current inputs and compare them with their last known values. That would be better, but still not that efficient. Fortunately, the MCP23017 chip is capable of triggering interrupts on input pin changes. Its interrupt system is quite extensive and we will cover many of its capabilities within these next two sections.

The approach covered in this section uses interrupts to alert us when an input has changed, thereby freeing up the microcontroller to perform other tasks as necessary until an interrupt is triggered. This approach can be quite useful when the inputs are not expected to change very often.

Add the following lines to the PortExpander sketch just below the I/O Pin Definitions and above the creation of the mcp23017 instance towards the top of the sketch.

const int MCP23017_INTB = 2;  // connected to MCP23017 INTB (19) pin

volatile bool switchDidChange = false;  // change status of MCP23017 input pins

INTB is the interrupt output pin that will be driven LOW by the MCP23017 when it detects a change in the PORTB input pins. The switchDidChange flag will be set by our interrupt service routine (ISR). The volatile keyword is used since the variable can be changed at any time by the ISR.

Next, add the following functions to the end of the PortExpander sketch.

void configureInterrupts() {
   // Configure MCP23017 interrupts
   mcp23017.interruptMode(MCP23017InterruptMode::Separated);  // INTA and INTB act independently
   mcp23017.interrupt(MCP23017Port::B, CHANGE);  // trigger an interrupt when an input pin CHANGE is detected on PORTB

   // Set up interrupt connection and attach interrupt service routine
   mcp23017.clearInterrupts();  // reset interrupt system
   pinMode(MCP23017_INTB, INPUT_PULLUP);  // utilize microprocessor's internal pull-up resistor
   attachInterrupt(digitalPinToInterrupt(MCP23017_INTB), mcp23017ChangeDetectedOnPortB, FALLING);  // INTB is active LOW
}

This function configures the MCP23017 interrupt system and attaches an interrupt service routine.

The interrupt mode is set to Separated to allow the INTA and INTB interrupt pins to act independently. An Or mode option is available that internally ors the the pins together so that an interrupt for either PORTA or PORTB will be reflected on both pins simultaneously.

The interrupt() method enables interrupts for all pins of PORTB, detecting any CHANGE that occurs. Other available options are FALLING and RISING.

The clearInterrupts() method resets the interrupt system by clearing any previous interrupts and initiates watching for further pin changes.

The mcp23017ChangeDetectedOnPortB() ISR is then attached for execution when the Arduino detects that the MCP23017_INTB pin is FALLING.

void mcp23017ChangeDetectedOnPortB() {
   switchDidChange = true;
}

This is the interrupt service routine that simply sets the switchDidChange flag when executed.

void readAndWritePortOnInputChange() {
   if (switchDidChange) {
      delay(100);  // allow time for MCP23017 to set interrupt registers
      portCopy();  // copy values from PORTB (switches) to PORTA (LEDs)
      mcp23017.clearInterrupts();  // clear interrupt
      switchDidChange = false;
   }
}

This function checks if the switchDidChange flag was set by the ISR, and if so, copies the values from PORTB (inputs) to PORTA (outputs). A small delay of 100 ms is used to ensure the MCP23017 has had enough time to update its internal interrupt registers. Once the copy is complete, the interrupts and flag are cleared.

Finally, we need to update the setup() and loop() functions to add the use of these new functions. Add the following line to the setup() function between configuring the MCP23017’s I/O pins and resetting of its ports.

configureInterrupts();

Comment out the portCopy() statement in the loop() function and add the readAndWritePortOnInputChange() to be executed instead. This latter function should now be the only thing being called within the loop() function.

Save your work and upload the updated sketch to test the code. It should function in exactly the same manner as before, but with much greater efficiency and far less I2C communication.

Extending Interrupts With More Granularity

This last approach utilizes more of the MCP23017 interrupt system’s capabilities to provide greater detail of specific pin states at the time an interrupt occurred.

Add the following function to the end of the sketch

void readAndWritePinOnInputChange() {
   uint8_t flagA, flagB;  // MCP23017 INTFA/B registers
   uint8_t capA, capB;    // MCP23017 INTCAPA/B registers

   if (switchDidChange) {
      delay(100);  // allow time for MCP23017 to set interrupt registers
      mcp23017.interruptedBy(flagA, flagB);  // retrieve pin causing interrupt
      mcp23017.clearInterrupts(capA, capB);  // clear interrupt and capture pin states at time of interrupt
      uint8_t pin = 0;  // input pin causing interrupt
      for (pin = 0; pin < 8; pin++) {
         if (bitRead(flagB, pin)) break;
      }
      uint8_t value = bitRead(capB, pin);  // new value of input pin causing interrupt
      mcp23017.digitalWrite(pin, value);  // set appropriate LED with new value
      switchDidChange = false;
   }
}

and then update the loop() function to run this function instead.

The MCP23017’s INTFA and INTFB registers flag the pins that caused the interrupt for PORTA and PORTB respectively. The INTCAPA and INTCAPB registers capture all the pin values at the time the interrupt was triggered. The flagA/flagB and capA/capB variables hold these register values that are retrieved with the library’s interruptedBy() method along with an overloaded version of the clearInterrupts() method. These register values are then used to retrieve the specific pin that was changed, along with its new value, and then update only the associated output pin with digitalWrite(). The PORTA interrupt registers are ignored since we are only interested in PORTB state changes at this time.

Save your work and upload the updated sketch to test the code. Again, it should function in exactly the same manner as before, but this time with more pin state granularity.

Before we end, I wanted to mention another very nice feature of the MCP23017 library is the ability to read from and write to any of the MCP23017 IC’s registers directly with the library’s readRegister() and writeRegister() methods. We saw an example of this in the setup() function for resetting the GPIO ports. This capability provides maximum flexibility in configuring and accessing individual GPIO pin data independent of the library’s default functionality. Check out the library’s header file for a summary of the methods and general capabilities available with the MCP23017 library.

Now would be a good time to upload the BareMinimum sketch (Main Menu > File > Examples > 01.Basics > BareMinimum) to reset all pins back to their default states. This ensures no outputs are being driven when plugging in your board for your next project.

Summary

In this tutorial, we learned how to add both digital inputs and outputs to your Arduino board using the MCP23017 16-Bit I2C I/O Expander With Serial Interface IC. We covered multiple approaches for how to represent and control the MCP23017 IC’s GPIO in your Arduino code so that you can compare and choose the right implementation in your own designs. They range from a simplistic approach that copies all values of one port to another to using the familiar digitalRead() and digitalWrite() mechanisms. We even covered how to use interrupts to allow for greater efficiency of the microcontroller’s time.

The final source code and schematic used for this tutorial are available on GitHub. The GitHub version of the code is fully commented to include additional information, such as the program’s description, circuit connections, code clarifications, and other details. The comments are also Doxygen compatible in case you want to generate the code documentation.

Thank you for joining me along this journey and I hope you enjoyed the experience. Please feel free to share your thoughts or questions in the comments section below.

About the author

John Woolsey

John is an electrical engineer who loves science, math, and technology and teaching it to others even more.
 
He knew he wanted to work with electronics from an early age, building his first robot when he was in 8th grade. His first computer was a Timex/Sinclair 2068 followed by the Tandy 1000 TL (aka really old stuff).
 
He put himself through college (The University of Texas at Austin) by working at Motorola where he worked for many years afterward in the Semiconductor Products Sector in Research and Development.
 
John started developing mobile app software in 2010 for himself and for other companies. He has also taught programming to kids for summer school and enjoyed years of judging kids science projects at the Austin Energy Regional Science Festival.
 
Electronics, software, and teaching all culminate in his new venture to learn, make, and teach others via the Woolsey Workshop website.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.