<< Click to Display Table of Contents >>

Navigation:  Bits and PCs Blog - 2025.11.20 >

MCP23xxx 8/16-Bit I2C/SPI GPIO Expander · 2025.06.23

Previous pageReturn to chapter overviewNext page

Updated: 2025.06.23 - new source code that works for I²C and SPI and both 8- and 16-bit chips, and a fast serial shift register class

 

So your microcontroller only has 10'000 inputs/outputs and you need 10'016? Well, here's an easy solution...

There are several types of I²C or SPI-interfaced "I/O Expander" chips and modules available which add 8 or 16 I/Os (per chip), supported by copious software libraries for Arduinos, ESP32s, Raspberries etc. Here are the details of my excursions into the mysterious realms of the ancient Microchip MCP23xxx series (runs on 3.3V or 5V), and some streamlined code in C++ and solutions to the chip's "idiosyncrasies". There is also source code for the 8-bit PCF8574 expander. And a much faster solution that uses a 74HC595 shift register.

Latest Microchip Data Sheet (2022), 16-bit versions
https://ww1.microchip.com/.../MCP23017-Data-Sheet-DS20001952.pdf
https://www.mouser.com/datasheet/2/268/MCP23018_Data_Sheet_DS20002103-3441615.pdf

8-bit versions
https://ww1.microchip.com/.../MCP23008-MCP23S08-Data-Sheet-20001919F.pdf
https://ww1.microchip.com/downloads/en/DeviceDoc/20002121C.pdf

 

Blurb

The MCP23xxx chips are almost 20 years old now, but they're still being manufactured (DIP, SOIC, TSSOP packages), and are still very useful. The chips communicate via I²C (MCP230xx models) or SPI (MCP23Sxx models), and have one or two fully-configurable 8-bit I/O ports. There are also versions with open drain outputs (MCP23x18 and MCP23x09).

Each port has 10 configuration or data registers, see the enum REGS definitions in the source code.

Each of the 8 or 16 I/O pins can be configured as either an input or an output. Inputs can be configured to have an internal 100K ohm 'pull-up' resistor, see configureGpios().

Each input can also be configured to generate an interrupt. Either 'interrupt-on-change', or it can be compared with a value in the DEFVAL register to generate an interrupt when the state does NOT match. See configureInterrupts(). But there are problems with this, see Continuous Interrupts! Not Usable?. It can't be programmed to interrupt on a rising or falling edge of an input, only on both edges (double the number of interrupts - or more if it's noisy).

The 8-bit version has one interrupt output. 16-bit versions have two interrupt outputs, INTA (interrupt from Port A input change) and INTB (interrupt from Port B input change). The interrupt outputs can be programmed as active low or active high, either driven or open-drain. They can also be 'mirrored' so only one INT pin needs to be used. See configureInterruptPins().

Input (and output) states are read with readGpios() or readBothGpios() which reads the states of all 8-bits of either Port A, Port B or both ports. Or you can use digitalRead() to read the state of just one pin at a time (less efficient).

Outputs are written with writeOutputs() and read with readOutputs(). These methods access the Output Latch register (OLAT). readGpios() and digitalRead() read the state of the outputs and inputs by reading the GPIO register.

 

mcp23017-testing

The test set-up used an enormous 28-pin DIP chip for I2C, and a smaller SIOC-28 chip for SPI. The code was developed on a CooCox Embedded Pi board sporting a 32-bit STM32F103 MCU, using Microsoft Visual Studio 2022 with the Visual Micro extension, and ST-LINK for loading and debugging. I had to make an SWD adapter board because the ST-LINK cables were lost somewhere in the mists of time (or the mists of my store room).

 

MCP23017 and MCP23018 Pinouts (I2C)

mcp23017-pinouts

* On the MCP23017 (2022), GPA7 and GPB7 can only be used as OUTPUTS
    See data sheet 2022 section 3.5.1 IODIR register, and 'GPA7 AND GPB7 ONLY WORK AS OUTPUTS ON THE MCP23017' below.

 

Block Diagram

Here's the block diagram for a single pin, so you can see how the OLAT, GPIO and IODIR register bits are handled...

mcp23017-ioport-diagram

 

Chip Addressing

The I²C chips have the standard three address pins A3..A0. But the 009 version has a single ADDR pin, which uses an interesting voltage divider to select the three address bits using a single pin.

The SPI version has an unusual feature - the address pins can also be used to select the SPI device, so you can connect up to 4 or 8 devices using the same chip select pin. The device address is part of the first byte of every SPI message. To enable this, the IOCON:HAEN bit must be set BEFORE anything else is done. See the enableHardwareAddressing() method.

 

Interrupt Handling

The chips have one or two interrupt outputs which can be connected to an MCU input to generate an interrupt whenever an input state changes. The 8-bit chips have just one interrupt output, INT. 16-bit chips have two interrupts, INTA and INTB, one for each port. These can be 'mirrored' so one interrupt covers both ports.

Because I2C and SPI communications cannot be used inside an interrupt handler, the interrupt handler should just set a flag which is polled from loop(). See the example sketch below.

To determine which pin caused the interrupt, the chip provides two registers, the Interrupt Flag Register (INTF) and the Interrupt Captured Register (INTCAP). A bit set in INTF indicates which pin(s) caused the interrupt. The INTCAP register captures the GPIO states at the time the interrupt occurred.

To clear the interrupt and determine which input interrupted, the data sheet says to read the port's INTF and INTCAP register(s). In the example code, loopBad() is [what I think is] the way the data sheet suggests to handle interrupts, reading the INTF and INTCAP registers to determine what caused the interrupt. BUT THIS DOES NOT WORK FOR ME...

 

Problems with INTF and INTCAP

Multiple interrupts can occur, with the INTF and INTCAP registers containing invalid data.

To test it, I was just quickly inserting and releasing a GND wire into the breadboard pin hole of an 'inverted input with pull-up' to turn it on/off, which is quite "noisy" (0 = on, 1 = off). If you do it slowly it seems to work. If you do it fast, it gets very confused and often says it is off when it should be on, and INTF is sometimes 0 (if not 0 then INTF seems to be correct - it's just INTCAP that's wrong). I have tried chips from two different batches, I²C and SPI, and they all have the same behaviour. loopBad() in the example sketch shows the problem.

I found a way which always works (for me) - reading the GPIO register when an interrupt occurs and using that to detect changes, see loopGood() in the example sketch.

The inputs may need debouncing (see 'Debouncing' below), as you will see from the multiple println() outputs.

See Can interrupts be missed, maybe this is what I'm seeing. Or maybe I'm doing something wrong. If anyone dares to experiment with this code, please let me know what you find, email info@muman.ch.

 

Example Sketch

This example uses a 16-bit SPI chip, the MCP23S17.

It illustrates how to use the classes and also shows the problems I had by providing two loops, loopGood() and loopBad(), see the details above.

  MCP23Expander.ino   [Click to expand/collapse]   (I can't be bothered with GitHub - life is too short - just copy/paste it from here...)

 

Source Code

The source code contains both SPI and I²C versions, with a base class for the common code. There are separate 8-bit and 16-bit classes (MCP23Expander8bit.h and MCP23Expander16bit.h), because to make one class that works for both would increase the code size, and you probably won't mix 8-bit and 16-bit chips in the same project. If you want to use the code in different files, you should separate the code into .h and .cpp files.

All methods return true/false for success/failure. Data is returned via pointer parameters. This is not the usual 'Arduino' style, - you can always ignore the return value, but you should always check the return value of begin(), so you know that the chip is actually connected :-)

In the 16-bit version, most methods have a PORT port parameter to select the 8-bit port to be accessed: PORTA or PORTB.

The IOCON register configures the internal behaviour of the chip. Do not change this register directly because it will invalidate the methods in this library. Instead, use configureInterruptPins() to modify the only changeable bits in this register.

The C++ code was tested with a 10MHz SPI clock on a STM32F103 32-bit MCU running at 72MHz, and also with a 1MHz I²C clock.

You'll need to #define DEBUG and LOGERROR etc, as shown in the Example Sketch. Debouncer code can be found in the Other I/O Expander Chips section.

  MCP23Expander16bit.h - original version, to be updated to match the 8-bit version below  [Click to expand/collapse]

  MCP23Expander8bit.h - updated version 2025.08.08  [Click to expand/collapse]

 

More Technical Notes

In begin(), the chip is initialized to use 'sequential addressing' - the register address increments automatically after each register read/write operation. This allows methods to read/write multiple registers without having to send the next address every time.

The register layout can be initialized to be 'one bank' (the registers are interleaved for both ports) or 'two banks' (each port has a separate set of consecutive registers). See the data sheet section 3.5 for details. This library uses the default 'one bank' interleaved format (IOCON.BANK = 0). Do not change this! (See Disclaimer.)

Output states are held in the Output Latch register (OLAT), and are read or written with readOutputs() and writeOutputs(). You can also read/write the output states via the port's GPIO register, but this library only provides a readGpio() method, so you should use writeOutputs() to write to the OLAT register directly - the GPIO register, read with readGpios(), will then reflect the state in OLAT.

If a bit is configured as an input, you can still write to the corresponding bit in the Output Latch OLAT register, but it does not show up at the GPIO output. If you were to reconfigure an input to an output then it would take its initial state from the OLAT register.

The 'slew rate' bit is used to "reduce ringing and crosstalk on signal lines when using open-drain outputs". More details would be nice. It doesn't seem to make any difference on my set-up, but it's probably best to keep the default setting. It is ON by default (0).

 

Important Hardware Tips

To prevent intermittent communications errors etc.

The /RESET input must NOT be left floating. It should be connected to VDD via a pull-up, to the MCU board's /RESET output, or to an MCU digital output for program-controlled reset.

The I2C address bits A0..A3 must NOT be left floating. They should be connected to GND or directly to VDD (no pull-ups needed), according to the required I²C or SPI address.

When using I2C, don't forget the pull-up resistors on SDA and SCK. They may be included on the module or the MCU board. If not, you must add them, usually 4.7K or 10K ohms. SPI does not need pull-ups.

Only use GPA7 and GPB7 as OUTPUTs, it seems there can be intermittent problems if they are used as inputs. See section below.

 

Reset

The power-on reset (POR) sets the I/Os to inputs (IODIR := 0xff), and sets all other registers to zeros. The setDefaults() method sets the registers to the values expected after a power-on.

The chip's /RESET input can be connected to the /RESET output of the MCU so it's reset whenever the MCU is reset. If not, it should be connected to VDD. /RESET could be connected to an MCU output if you want to be able to reset the chip if it's misbehaving - I have heard stories of the chip locking up, perhaps because of the GPA7/GPB7 problem described below, but I have not had this trouble.

After a reset, all GPIOs are set as inputs without pull-ups.

 

Initial Output States

After reset, all pins are configured as inputs so they are basically "floating". If used as an output, these will need a pull-up or pull-down resistor to ensure the correct start-up state, depending on what they are connected to. If driving a MOSFET, a 100k pull-down (or up) after the gate resistor will do. If it's a BJT, then something like a 10K pull-down/up is needed. If connecting an LED, use active low to turn it on (output LOW = LED ON):  OUTPUT ---|220Ω|---LED>|--- +3.3V

 

Debouncing

"Debouncing" and "Interrupt Handling" sound like something that happens in a nightclub with a bad reputation. Open-drains can cause a «très mauvaise odeur», and certain "debugging practices" could get you arrested. These annoying situations happen regularly in Electronic Circuit Design circles.

Inputs which are connected to mechanical switches may need to be 'debounced' to prevent multiple activations.

The 'Debouncer2' class will do it. Unlike some debouncers, on the first activation the state becomes 'on' immediately, which is good for emergency switches etc. It is only the de-activation which has the time delay. Once 'on', it must be 'off' for a certain amount of time before it is registered as actually being 'off' and can then be re-activated.

The debouncer source code and an example of using it with the PCF8574 can be found in the Other I/O Expander Chips section below.

The MCP23Expander class really needs some code like the PCF8574's inputStateChanged() method to determine which bits have changed, been set or been cleared (see 'Interrupts can be missed' and 'Continuous Interrupts' below). There's also an example of similar code in loopGood() in the 'MCP23Expander.ino' sketch example above.

 

Can interrupts be missed?

Some developers on Internet say they have problems with the chip's interrupts. I did too.

The INTF and INPCAP registers, returned by read[Both]Interrupts(), are often wrong!

They are not updated if multiple interrupts occur before an existing interrupt has been cleared. Here's what it says in the manual, "The value in INTCAP can be lost if GPIO is read before INTCAP while another IOC is pending. After reading GPIO, the interrupt will clear and then set due to the pending IOC, causing the INTCAP register to update." And, "The first interrupt event causes the port contents to be copied into the INTCAP register. Subsequent interrupt conditions on the port will not cause an interrupt to occur as long as the interrupt is not cleared by a read of INTCAP or GPIO."

Does this mean that interrupts may be lost and the INTCAP register may be wrong? I prefer to read the GPIOs when the interrupt occurs and ignore the INTF and INTCAP registers altogether, see the loopGood() code in 'MCP23Expander.ino' above.

 

Continuous Interrupts!

Sadly, the chip does not support rising or falling edge only interrupts for an input.

If using the interrupt mode where the GPIO input pin state is compared to the bit in the Default Value Register DEFVAL, then you will get CONTINUOUS INTERRUPTS all the while the states do not match!

The interrupt is cleared every time you read the INTCAP or GPIO registers, then it immediately occurs again. This makes no sense to me.

It is better to use the 'interrupt-on-change' method - keeping the INTCON register as 0x00 and just enabling interrupt-on-change with bits in GPINTEN. You get an interrupt on both the rising and falling edges of the input, but that's OK. I also got interrupts where it says nothing has changed (INTF = 0), that is not OK.

 

GPA7 and GPB7 only work as Outputs on the I²C MCP230xx ???

The old version of the data sheet does not mention this "idiosyncrasy", but it's in the 2022 data sheet (see link in "References" below). In section 3.5.1, for the IODIR Register it says, "Note: For MCP23017, IO7 must be set via I2C interface to 0 (output)." But after some research, I found this is a problem with all the MPC230xx I2C chips. The SPI chips are ok.

See the discussion here, https://forums.adafruit.com/viewtopic.php?t=203060, where it says, "Turns out this is not some horrible new bug due to revised silicon - it is a horrible old bug that has been in the silicon for many years, possibly from the start. Transitions on either of those pins, used as an input, can foul up I2C transactions somehow. (The SPI version of the chip is not affected.)".

See also, https://github.com/adafruit/Adafruit-MCP23017-Arduino-Library/issues/96

This is what causes the problem, from https://forum.microchip.com/s/topic/a5C3l000000MFCdEAO/t288499?comment=P-2287899

"For the MCP23008 specifically
The serialization circuit in this device apparently is not digitally buffered between the GPIO pin and the I2C SDA line. That is, when it's time to transmit the logic level of a specific IO pin on the SDA line, it does NOT sample and hold (at least not until after the rising edge of the clock, from what I can tell). It seems that they effectively just open a gate between the GPIO input and the SDA pin.

As a result, if a transition occurs on your GPIO pin WHILE that pin level is being serialized to the I2C SDA pin, then the transition ALSO happens on the SDA line. Well, that's bad, because I2C forbids SDA to transition while the clock is transitioning or high during data transmission. If a transition DOES occur, then my theory is that it looks like a START or STOP bit, and that the PIC MCU I2C peripheral (even in Master mode) doesn't like receiving a START or STOP bit in the middle of a transaction and likely doesn't handle it well. I wouldn't be surprised if receiving START or STOP unexpectedly could throw off the I2C peripheral state machine and accidentally lock it in READ mode while dribbling out an endless sequence of clock cycles."

And another good description of the problem, from https://www.reddit.com/r/AskElectronics/comments/1h0hv5x/for_anyone_using_the_mcp23017_ioexpander/

"flyingsaxophone : I submitted the bug / errata on the 8-bit version of this product, and assume they found the same issues on the 2x8 product. Notice that it's only the I2C variant with this change / restriction. This issue is that the input values aren't latched/held as they get shifted out the I2C bus. If your input happens to change as that bit value is being transmitted on the bus, that transition appears immediately on the bus.

This isn't really an issue for most situations, except that if an input transition occurs on bit 7 as it's being shifted out (being the first bit shifted out after the I2C start sequence), it can screw up the I2C start sequence and can crash the bus.

If you can find a way to guarantee that bit 7 won't transition during the I2C transaction, then you can avoid that issue. Otherwise, there's no guarantee that the I2C transaction completed correctly, hence the change to those inputs being output only.

If you're currently using this product and experiencing bus crashes, chances are this is the issue."

The workaround is: When using I2C, do not use bit 7 of either port as an input!

 

Problems configuring register addressing (BANK=0 and BANK=1)

Registers can be mapped either as a single bank of registers (BANK=0) with the resisters alternating between Port A and Port B, or as two banks of registers with a separate 10-register banks for each port (BANK=1). See data sheet section 3.5.

But there is another problem, which you may encounter while developing. The address of the IOCON register that is used to set the BANK mode will change if the BANK mode is changed. If BANK=0 it is register 0x0A, and if BANK=1 it is register 0x05. So if it's changed, how do you know which register to use to change it back?

If the chip got a real hardware reset then it should always be 0x0A (BANK=0), but if not, it could be either address. That's why it's a good idea to connect the chip's /RESET pin to the MCU's /RESET output.

To solve this problem, you can first write 0x00 to register 0x05, then write 0x00 to register 0x0A. After that, you can assume BANK=0.

 

Using a 74HC595 Serial Shift Register chip as an I/O expander

If you only need fast outputs, then a cheap bit-banged Serial Shift Register chip could be a solution. Tests show that this is considerably faster than using the I2C device at 1MHz, even when using the slow digitalWrite() method to do the bit-banging.

For example, you can get a tri-state-output 74HC595N chip for 17 cents (reichelt.com), and I've seen them for 5 cents. This has clock (SRCLK) and data (SER) inputs, so you can clock in 8 bits (using bit-banging) then clock these 8 bits into the output register (RCLK). It also has output enable (/OE) and clear (/SRCLR) pins. You can daisy-chain them together too, to provide super-fast 16, 24, 32...-bit output packages. It needs 4 outputs to control the entire chain, plus an optional 'output enable'.
https://www.ti.com/lit/ds/symlink/sn74hc595.pdf

When Output Enable /OE is high, the outputs are floating, so you'll probably need pull-ups or pull-downs to ensure the correct state. If /OE is tied to GND, then the output states may be undefined until the program starts running. This could be nasty!

Here's an example sketch which contains two versions. The conventional version uses digitalWrite() and takes 46.7uS to write 8 bits. In comparison, I2C at 1MHz takes 180uS! The fast version, specifically for the STM32 (class Encoder74HC595), writes directly to the MCU's GPIO registers and takes just 9.2uS to write 8 bits! The fast version also contains a fancy test() method that verifies what's been clocked into the shift register by reading the QH output.

  ShiftRegister74HC595.ino

 

Other I/O Expander Chips

There are many. These are the most common...

PCF8574, 8-bit I²C, 3.3V..5V

This is an 8-bit expander with 'quasi-bidirectional I/O'. It has no configuration registers - if an 'output' is set high (floating) then it can be read as an input. It can also generate an interrupt on the change of a input's state.

The PCF8574 is often used for interfacing LCD modules via an I2C connection, see LCD Character Display Using PCF8574.

Data Sheet
https://www.ti.com/lit/ds/symlink/pcf8574.pdf

Here's my code for the PCF8574:

  PCF8574ExpanderI2c.h   [Click to expand/collapse]  

I/O expanders can produce many interrupts, even if the state has not actually changed (?). To handle this you need to use some debouncer code, as in this example. This logs the interrupts and the debounced state changes.

  Debouncer2.h

  PCF8574 Debouncer Example

MCP23008 and MCP23S08 - the 8-bit versions of MCP23017/MCP23S17

MCP23008-MCP23S08-Data-Sheet-20001919F.pdf

TCA9534 and PCA9538, 8-bit I²C/SMBus

https://www.ti.com/lit/ds/symlink/tca9534.pdf
https://www.ti.com/lit/ds/symlink/tca9554.pdf

https://www.nxp.com/docs/en/data-sheet/PCA9538.pdf

  PCA9538Expander8bitI2c.h   [Click to expand/collapse]  

PCA9502, 8-bit, I²C and SPI

https://www.nxp.com/docs/en/data-sheet/PCA9502.pdf

CY8C9520A/40A/60A, 20, 40 or 60-bits, plus up to 27KB EEPROM, I²C, 3.3V..5V

Infineon-CY8C9520A-EN.pdf

References

Latest Microchip Data Sheet (2022), 16-bit versions
https://ww1.microchip.com/.../MCP23017-Data-Sheet-DS20001952.pdf
https://www.mouser.com/datasheet/2/268/MCP23018_Data_Sheet_DS20002103-3441615.pdf

8-bit versions
https://ww1.microchip.com/.../MCP23008-MCP23S08-Data-Sheet-20001919F.pdf
https://ww1.microchip.com/downloads/en/DeviceDoc/20002121C.pdf

Microchip Application Note AN1043, 2006
"Unique Features of the MCP23X08/17 GPIO Expander"
ApplicationNotes/01043a.pdf

Visual Micro extension for Microsoft Visual Studio - I use this for all my Arduino-style projects
"Compile and upload any Arduino project to any board, using the same Arduino platform and libraries, with all the advantages of the advanced Microsoft Visual Studio IDE"
https://www.visualmicro.com/

TI Application Report SCPA032, 2001
"Improving System Interrupt Management Using the PCF8574 and PCF8574A I/O Expanders for the I2C Bus"
https://www.ti.com/lit/an/scpa032/scpa032.pdf