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.
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.
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)
* 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...
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...)
// Examples for MCP23xxx I/O Expander
// muman.ch, 2025.06.19
// see https://muman.ch/muman/index.htm?muman-mcp23017.htm
#include <Wire.h>
#include <SPI.h>
#define DEBUG
// This is useful for debug output, but don't forget that you get
// full debugging features with ST-LINK/V2 SWD
#ifdef DEBUG
void LogError(char* msg, char* file, unsigned int line)
{
char buf[100];
char* fname = strrchr(file, '\\');
fname = fname ? fname + 1 : file;
sprintf(buf, "%lu %s %s %u", millis(), msg, fname, line);
Serial.println(buf);
Serial.flush();
}
#define LOGERROR(msg) LogError(msg, __FILE__, __LINE__)
#define ASSERT(b) if(!(b)) LOGERROR("Assert failed")
#else
#define LOGERROR(s)
#define ASSERT(b)
#endif
#include "MCP23Expander16bit.h"
MCP23Expander16bitSPI mcp;
// Last state of the inputs
byte oldgpios = 0;
// Interrupt from MCP23017
volatile bool mcpInterrupt = false;
void mcpInterruptHandler()
{
mcpInterrupt = true;
}
void setup()
{
Serial.begin(115200);
delay(2000);
Serial.println("\nStarted...");
SPI.begin();
bool ok = mcp.begin(&SPI, D2, 0);
if (!ok) {
Serial.println("fatal error");
while (1);
}
// interrupt handler (see code below)
pinMode(D3, INPUT);
attachInterrupt(D3, mcpInterruptHandler, FALLING);
// Port A bits 6..0 are inverted inputs with pull-ups
// (0=on) and generate interrupts, bit 7 is an output
// Port B is all outputs
mcp.configureGpios(mcp.PORTA, 0x7f, 0x7f, 0x7f);
mcp.configureGpios(mcp.PORTB, 0x00, 0x00, 0x00);
// we only need INTA since only Port A has interrupts
mcp.configureInterruptPins(false, false, true);
// interrupts on input change on Port A
mcp.configureInterrupts(mcp.PORTA, 0x7f, 0, 0);
// did it work?
mcp.dumpRegisters(mcp.PORTA);
mcp.dumpRegisters(mcp.PORTB);
Serial.println("");
}
void loop()
{
#if 1
// working version
loopGood();
#else
// not working version
loopBad();
#endif
}
// THIS VERSION WORKS WELL
void loopGood()
{
// did the 'input changed' interrupt occur?
noInterrupts();
bool hadInterrupt = mcpInterrupt;
mcpInterrupt = false;
interrupts();
// changed bits
byte chgbits = 0;
// bits that have been set
byte setbits = 0;
// bits that have been cleared
byte clrbits = 0;
// input changed interrupt occurred
// (this does not always mean the input really did change)
if (hadInterrupt) {
// read the current state of the GPIOs
// this also clears the interrupt
byte gpios;
mcp.readGpios(mcp.PORTA, &gpios);
// mask out the outputs
gpios &= mcp.iodirA;
// any input states changed?
// note the interrupt seems to happen many times without any changes!?
if (gpios != oldgpios) {
// changed bits
chgbits = gpios ^ oldgpios;
// bits that have been set
setbits = gpios & ~oldgpios;
// bits that have been cleared
clrbits = ~gpios & oldgpios;
oldgpios = gpios;
}
}
// show what happened, everything looks good (pins may need debouncing though)
if (chgbits) {
char buf[100];
sprintf(buf, "chgbits=%02X setbits=%02X clrbits=%02X ms=%lu",
chgbits, setbits, clrbits, millis());
Serial.println(buf);
}
}
// THIS VERSION DOES NOT WORK RELIABLY
// Are interrupts being missed?
void loopBad()
{
// did the 'input changed' interrupt occur?
noInterrupts();
bool hadInterrupt = mcpInterrupt;
mcpInterrupt = false;
interrupts();
// input changed interrupt occurred
if (hadInterrupt) {
// read the interrupt data to clear the interrupt
byte intfA, intcapA;
mcp.readInterrupt(mcp.PORTA, &intfA, &intcapA);
// read the current GPIO state
byte gpiosA;
mcp.readGpios(mcp.PORTA, &gpiosA);
// display the interrupt flag register INTF (shows which bits caused the interrupt)
// the interrupt capture register INTCAP (the gpio states when the interrupt occurred)
// and the current gpio states
char buf[100];
sprintf(buf, "intf=%02X intcap=%02X gpios=%02X ms=%lu",
intfA, intcapA, gpiosA, millis());
Serial.println(buf);
// the interrupt flag register should never be 0
// (that means that nothing caused the interrupt)
// but it is sometimes 0 - why?
if (intfA == 0)
Serial.println("error: intf=0");
// THE INTF & INTCAP REGISTER VALUES OFTEN MAKE NO SENSE
// INTF IS SOMETIMES 0
// INTCAP OFTEN SHOWS 0 WHEN THE INPUT WAS ACTIVE (ACTIVE CAUSED THE INTERRUPT)
// THIS LOOKS VERY WRONG TO ME
}
}
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 #defineDEBUG 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]
#pragma once
// MCP23x17/MCP23x18 16-Bit GPIO Expander with I2C Interface
// Copyright (C) MattLabs and muman.ch, 2025.06.23
// All rights reversed
/*
DATA SHEETS
MCP23x17
https://ww1.microchip.com/downloads/en/devicedoc/20001952c.pdf
MCP23x18
https://ww1.microchip.com/downloads/en/devicedoc/22103a.pdf
For details see the muman.ch blog post
https://muman.ch/muman/index.htm?muman-mcp23017.htm
"MCP23S17 Rev.A Silicon Errata" (document DS80311A)
https://ww1.microchip.com/downloads/aemDocuments/documents/OTH/ProductDocuments/Errata/80311a.pdf
*/
#include <SPI.h>
#include <Wire.h>
// Base class for 16-bit expander SPI and I2C classes
class MCP23Expander16bitBase
{
protected:
// For SPI IOCON:HAEN bit, set by enableHardwareAddressing()
static bool spiHardwareAddressingEnabled;
public:
// Input masks for each GPIO port, 1=the bit is an input, 0=output
byte iodirA = 0xff;
byte iodirB = 0xff;
// For the 'port' parameter, handled as 2 x 8-bit ports
enum PORT : byte
{
PORTA = 0,
PORTB = 1
};
// Port A register numbers for BANK mode 0, add 1 for the Port B register
enum REGS : byte
{
IODIR = 0x00, // I/O direction: 1=input, 0=output
IPOL = 0x02, // Input polarity: 1=inverted, 0=not inverted
GPINTEN = 0x04, // Interrupt-on-change enable: 1=enable interrupt, 0=no interrupt
DEFVAL = 0x06, // Default compare register for interrupt, interrupt occurs if bit does NOT match
INTCON = 0x08, // Interrupt-on-change control: 1=pin state compared with DEFVAL bit, 0=not compared
IOCON = 0x0A, // Chip configuration, see IOCON enum for bits, shared by both Ports
GPPU = 0x0C, // Pull-up resistor configuration: 1=pull-up enabled, 0=pull-up disabled
INTF = 0x0E, // Interrupt flag, which input caused the interrupt: 1=pin caused interrupt
INTCAP = 0x10, // Interrupt capture, port value at time of interrupt
GPIO = 0x12, // Port value, inputs and outputs
OLAT = 0x14 // Output latch, read/write output latches
};
// IOCON configuration register bits (the IOCON register is shared by both ports)
enum IOCON : byte
{
INTPOL = 0x02, // Polarity of INT pins: 0=active low, 1=active high
ODR = 0x04, // Open drain INT pins: 0=driven, 1=open drain (overrides INTPOL)
HAEN = 0x08, // Hardware address enable, SPI devices only
DISSLW = 0x10, // SDA slew rate control: 0=enabled, 1=disabled
SEQOP = 0x20, // Sequential address mode: 0=address increments, 1=address unchanged
MIRROR = 0x40, // Interrupt pins mirrored: 0=separate INTA and INTB, 1=connected
BANK = 0x80 // How registers are addressed: 0=same bank (sequential), 1=two banks
};
bool setDefaults();
bool configureInterruptPins(bool polarity, bool openDrain, bool mirrored);
bool configureInterrupts(PORT port, byte gpinten, byte defval, byte intcon);
bool configureGpios(PORT port, byte iodir, byte ipol, byte gppu);
bool readBothInterrupts(byte* intfA, byte* intcapA, byte* intfB, byte* intcapB);
bool readInterrupt(PORT port, byte* intf, byte* intcap);
void digitalWrite(PORT port, byte pin, bool value);
bool digitalRead(PORT port, byte pin);
bool readBothGpios(byte* gpioA, byte* gpioB);
bool readGpios(PORT port, byte* value);
bool readOutputs(PORT port, byte* value);
bool writeOutputs(PORT port, byte value);
bool readRegister(byte reg, byte* value);
virtual bool isConnected() = 0;
virtual bool readRegisters(byte reg, byte* value, int length) = 0;
virtual bool writeRegister(byte reg, byte value) = 0;
#ifdef DEBUG
bool verify(byte reg, byte value);
void dumpRegisters(PORT port);
void dumpRegister(byte reg, char* name, byte value);
#endif
};
// for SPI's IOCON:HAEN bit, see SPI's enableHardwareAddressing()
// this is shared by all instances of this class
bool MCP23Expander16bitBase::spiHardwareAddressingEnabled = false;
// Set registers as they would be after a power-on reset (POR)
// Except for the SPI IOCON:HAEN bit which is controlled by SPI's
// enableHardwareAddressing().
// Note that both IOCON registers are the same, shared by both ports.
// INT pin active low; INT pin driven; slew rate enabled; address increments;
// INTA/INTB separate; one-bank interleaved register addressing;
// HAEN according to enableHardwareAddressing.
// https://ww1.microchip.com/downloads/en/devicedoc/20001952c.pdf#page=21
bool MCP23Expander16bitBase::setDefaults()
{
// set IODIR to 0xff (all pins of both ports := inputs)
if (!writeRegister(IODIR, 0xff) || !writeRegister(IODIR + 1, 0xff))
return false;
iodirA = 0xff;
iodirB = 0xff;
// IOCON register
// for SPI, keep HAEN set if enableHardwareAddressing() was called
if (!writeRegister(IOCON, spiHardwareAddressingEnabled ? HAEN : 0))
return false;
// set all other writable registers to 0
for (byte reg = IPOL; reg <= OLAT + 1; ++reg) {
// skip read-only registers and IOCON
switch (reg) {
case INTF:
case INTF + 1:
case INTCAP:
case INTCAP + 1:
case IOCON:
case IOCON + 1:
continue;
}
if (!writeRegister(reg, 0))
return false;
}
return true;
}
// Configure the INTA and INTB pins
// polarity = polarity of INT output pins: 0=active low, 1=active high
// openDrain = open drain INT pins: 0=driven, 1=open drain (overrides polarity)
// mirrored = interrupt pins mirrored: 0=separate INTA and INTB, 1=connected
bool MCP23Expander16bitBase::configureInterruptPins(bool polarity, bool openDrain, bool mirrored)
{
// BANK=0 (one register bank)
// SEQOP=0 (sequential operation enabled)
// HAEN=1 (SPI hardware address enable A0..A3)
byte iocon = 0;
if (spiHardwareAddressingEnabled) // SPI only, see enableHardwareAddressing()
iocon |= HAEN;
if (polarity)
iocon |= INTPOL;
if (openDrain)
iocon |= ODR;
if (mirrored)
iocon |= MIRROR;
return writeRegister(IOCON, iocon);
}
// Configure interrupts
// gpinten = interrupt-on-change pin enable: 1=interrupt enabled, 0=interrupt disabled
// defval = default value register: different bit causes an interrupt, enabled by intcon bit
// intcon = interrupt control register: 1=bit compared with DEFVAL register, 0=not compared
bool MCP23Expander16bitBase::configureInterrupts(PORT port, byte gpinten, byte defval, byte intcon)
{
return writeRegister(GPINTEN + port, gpinten) &&
writeRegister(DEFVAL + port, defval) &&
writeRegister(INTCON + port, intcon);
}
// Configure a port's inputs/outputs
// iodir = direction: 1=input, 0=output
// ipol = input polarity: 1=inverted, 0=not inverted
// gppu = input pull-up: 1=pull-up enabled, 0=pull-up disabled
bool MCP23Expander16bitBase::configureGpios(PORT port, byte iodir, byte ipol, byte gppu)
{
// save input mask, 1=input
(port ? iodirB : iodirA) = iodir;
return writeRegister(IODIR + port, iodir) &&
writeRegister(IPOL + port, ipol) &&
writeRegister(GPPU + port, gppu);
}
// Read interrupt details for both ports
// intf = interrupt flag register, 1=pin caused interrupt
// intcap = interrupt captured register, GPIO port value at time of interrupt
// "The INTCAP register remains unchanged until the interrupt is cleared by
// a read of INTCAP or GPIO."
bool MCP23Expander16bitBase::readBothInterrupts(byte* intfA, byte* intcapA,
byte* intfB, byte* intcapB)
{
byte data[4];
if (!readRegisters(INTF, data, 4))
return false;
*intfA = data[0];
*intfB = data[1];
*intcapA = data[2];
*intcapB = data[3];
return true;
}
// Read interrupt details for one port, Port A or B
// "The INTCAP register remains unchanged until the interrupt is cleared by
// a read of INTCAP or GPIO."
bool MCP23Expander16bitBase::readInterrupt(PORT port, byte* intf, byte* intcap)
{
return readRegister(INTF + port, intf) &&
readRegister(INTCAP + port, intcap);
}
// Read both port's GPIO registers
// this clears both port's INTCAP registers
bool MCP23Expander16bitBase::readBothGpios(byte* gpiosA, byte* gpiosB)
{
byte buf[2];
if (!readRegisters(GPIO, buf, 2))
return false;
*gpiosA = buf[0];
*gpiosB = buf[1];
return true;
}
// Read all bits of GPIO register of Port A or B
// this clears the port's INTCAP register
bool MCP23Expander16bitBase::readGpios(PORT port, byte* value)
{
return readRegister(GPIO + port, value);
}
// Read/write the outputs (OLAT register) of Port A or B
bool MCP23Expander16bitBase::readOutputs(PORT port, byte* value)
{
return readRegister(OLAT + port, value);
}
bool MCP23Expander16bitBase::writeOutputs(PORT port, byte value)
{
// should not write to an input
ASSERT(((port ? iodirB : iodirA) & value) == 0);
return writeRegister(OLAT + port, value);
}
// Write to a single output (OLAT register) of Port A or B
void MCP23Expander16bitBase::digitalWrite(PORT port, byte pin, bool value)
{
ASSERT(pin < 8);
byte b;
readOutputs(port, &b);
byte mask = 1 << pin;
if (value)
b |= mask;
else
b &= ~mask;
writeOutputs(port, b);
}
// Read a single input (or output) from the GPIO register
bool MCP23Expander16bitBase::digitalRead(PORT port, byte pin)
{
ASSERT(pin < 8);
byte b;
readGpios(port, &b);
return (b & (1 << pin)) ? true : false;
}
// Read a single 8-bit register
bool MCP23Expander16bitBase::readRegister(byte reg, byte* value)
{
return readRegisters(reg, value, 1);
}
#ifdef DEBUG
// Register read-after-write verification
bool MCP23Expander16bitBase::verify(byte reg, byte value)
{
// read-after-write verification
// skip read-only and GPIO registers
switch (reg) {
case INTF:
case INTF + 1:
case INTCAP:
case INTCAP + 1:
case GPIO:
case GPIO + 1:
break;
default:
byte value1;
if (!readRegister(reg, &value1))
return false;
if (value != value1) {
LOGERROR("register compare error");
return false;
}
}
return true;
}
// Display all the register values of Port A or B
void MCP23Expander16bitBase::dumpRegister(byte reg, char* name, byte value)
{
char buf[32];
sprintf(buf, "%02X %-7s %02X", reg, name, value);
Serial.println(buf);
Serial.flush();
}
#define DUMP(reg, name) \
readRegister(reg + port, &value); \
dumpRegister(reg + port, name, value)
void MCP23Expander16bitBase::dumpRegisters(PORT port)
{
byte value;
Serial.println(port ? "\nPORT B" : "\nPORT A");
DUMP(IODIR, "IODIR");
DUMP(IPOL, "IPOL");
DUMP(GPINTEN, "GPINTEN");
DUMP(DEFVAL, "DEFVAL");
DUMP(INTCON, "INTCON");
DUMP(IOCON, "IOCON");
DUMP(GPPU, "GPPU");
DUMP(INTF, "INTF");
DUMP(INTCAP, "INTCAP");
DUMP(GPIO, "GPIO");
DUMP(OLAT, "OLAT");
}
#endif
////////////////////////////////////////
// SPI VERSION
// 16-bit I/O expander with SPI interface
class MCP23Expander16bitSPI : public MCP23Expander16bitBase
{
protected:
static SPISettings spiSettings;
SPIClass* spi = NULL;
byte csPin = 0xff;
byte slaveAdds = 0xff;
bool disableVerify = false;
void enableHardwareAddressing();
public:
bool begin(SPIClass* spiPort, byte chipSelectPin, byte slaveAddress = 0xff);
bool isConnected();
bool readRegisters(byte reg, byte* value, int length);
bool writeRegister(byte reg, byte value);
};
// Shared SPI settings for all instances
// change the 10MHz clock frequency as desired
SPISettings MCP23Expander16bitSPI::spiSettings = SPISettings(10000000, MSBFIRST, SPI_MODE0);
// Don't forget to initialize SPI in setup()
// SPI.begin();
// Call this for each expander chip, after calling SPI.begin().
// Use slaveAddress = 0..7 if you want to use SPI hardware addressing
// feature (or the default 0xff if not), see IOCON:HAEN bit
bool MCP23Expander16bitSPI::begin(SPIClass* spiPort, byte chipSelectPin,
byte slaveAddress /*=0xff*/)
{
spi = spiPort;
csPin = chipSelectPin;
pinMode(csPin, OUTPUT);
::digitalWrite(csPin, 1);
// the SPI device address feature allows up to 8 devices (0..7) to
// use the same chip select
// on first call, enable use of A3..A0 addresses with SPI if a
// slaveAddress has been supplied
if (!spiHardwareAddressingEnabled && slaveAddress <= 7) {
enableHardwareAddressing();
}
slaveAdds = (slaveAddress > 7) ? 0 : slaveAddress;
// set the default configuration in case the chip did not get a hardware reset
return setDefaults();
}
// This works only after a power-on-reset or /RESET signal.
// If using the A3..A0 address bit to allow up to 8 x SPI devices on the
// same SPI chip select line, then this is called to set the HAEN bit in
// all IOCON registers so the chips will use their addresses.
// If this is not called then you won't be able to talk to these chips.
// The message is sent to all devices on the bus, immediately after reset.
void MCP23Expander16bitSPI::enableHardwareAddressing()
{
// this cannot be called until the chip-select pin has been initialized
ASSERT(csPin != 0xff);
// only call it once
if (spiHardwareAddressingEnabled)
return;
spiHardwareAddressingEnabled = true;
disableVerify = true;
// write command with address 0b000
// this is seen by all devices, but only after a reset, when
// the HAEN bit is clear
slaveAdds = 0b000;
writeRegister(IOCON, HAEN);
// There is/was a silicon error in the MCP23S17 chip
// see "MCP23S17 Rev.A Silicon Errata" (document DS80311A)
// "When IOCON.HAEN = 0 (hardware addressing disabled) :
// If the A2 pin is high, then the device must be addressed as A2/A1/A0 =
// 1XX (i.e. OPCODE = 0b01001XXx).
// Work around - None"
// but they gave us the workaround, do the same thing with address 0b100...
slaveAdds = 0b100;
writeRegister(IOCON, HAEN);
disableVerify = false;
}
bool MCP23Expander16bitSPI::isConnected()
{
//TODO what's the best way to detect this?
return true;
}
// Read one or more consecutive 8-bit registers
// requires 'Sequential address mode' if reading more than one
bool MCP23Expander16bitSPI::readRegisters(byte reg, byte* values, int length)
{
ASSERT(reg + length <= OLAT + 2);
::digitalWrite(csPin, 0);
spi->beginTransaction(spiSettings);
spi->transfer(0b01000001 | (slaveAdds << 1)); // 0b0100aaa1
spi->transfer(reg);
for (int i = 0; i < length; ++i) {
values[i] = spi->transfer(0);
}
spi->endTransaction();
::digitalWrite(csPin, 1);
return true;
}
// Write a single 8-bit register
bool MCP23Expander16bitSPI::writeRegister(byte reg, byte value)
{
::digitalWrite(csPin, 0);
spi->beginTransaction(spiSettings);
spi->transfer(0b01000000 | (slaveAdds << 1)); // 0b0100aaa0
spi->transfer(reg);
spi->transfer(value);
spi->endTransaction();
::digitalWrite(csPin, 1);
#ifdef DEBUG
// read-after-write verification
if (!disableVerify)
return verify(reg, value);
#endif
return true;
}
////////////////////////////////////////
// I2C VERSION
// 16-bit I/O expander with I2C interface
class MCP23Expander16bitI2C : public MCP23Expander16bitBase
{
protected:
TwoWire* wire;
byte i2cAdds;
public:
bool begin(TwoWire* twoWire, byte i2cAddress);
bool isConnected();
bool readRegisters(byte reg, byte* value, int length);
bool writeRegister(byte reg, byte value);
};
// Initialize Wire before calling, for example...
// Wire.begin();
// Wire.setClock(1000000);
// Wire.setTimeout(100);
bool MCP23Expander16bitI2C::begin(TwoWire* twoWire, byte i2cAddress)
{
wire = twoWire;
i2cAdds = i2cAddress;
// set the default configuration in case the chip did not get a hardware reset
return setDefaults();
}
bool MCP23Expander16bitI2C::isConnected()
{
wire->beginTransmission(i2cAdds);
return wire->endTransmission() == 0;
}
// Read one or more consecutive 8-bit registers
// requires 'Sequential address mode' if reading more than one
bool MCP23Expander16bitI2C::readRegisters(byte reg, byte* values, int length)
{
ASSERT(reg + length <= OLAT + 2);
wire->beginTransmission(i2cAdds);
if (wire->write(reg) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
if (wire->requestFrom(i2cAdds, (size_t)length) != length) {
LOGERROR("requestFrom failed");
return false;
}
if (wire->readBytes(values, length) != length) {
LOGERROR("readBytes failed");
return false;
}
return true;
}
// Write a single 8-bit register
bool MCP23Expander16bitI2C::writeRegister(byte reg, byte value)
{
wire->beginTransmission(i2cAdds);
if (wire->write(reg) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->write(value) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
#ifdef DEBUG
// read-after-write verification
return verify(reg, value);
#else
return true;
#endif
}
⬛MCP23Expander8bit.h - updated version 2025.08.08 [Click to expand/collapse]
#pragma once
// MCP23x08/MCP23x09 8-Bit GPIO Expander with I2C or SPI Interface
// Copyright (C) MattLabs and muman.ch, 2025.08.08
// All rights reversed
/*
For more details see the blog:
https://muman.ch/muman/index.htm?muman-mcp23017.htm
USAGE
-----
#include <Wire.h>
#define MCP23EXPANDER8BIT_I2C // indicate it's the I2C version
#include "MCP23Expander8bit.h"
MCP23Expander8bitI2C mcp23008;
void setup()
{
Wire.begin();
Wire.setClock(1000000);
Wire.setTimeout(100);
mcp23008.begin(&Wire, 0x20);
// configure all gpios as outputs
mcp23008.configureGpios(0x00, 0x00, 0x00);
mcp23008.dumpRegisters();
}
...
DIFFERENCES BETWEEN THE 23x08 and 23x09 CHIPS
---------------------------------------------
23008 23S08 23009 23S09
Open drain outputs N N Y Y
I2C with ADDR pin addressing (1) N N Y N
I2C with A2..A0 addressing Y N N N
SPI with /CS pin addressing N Y N Y
SPI with A1..A0 addressing (2) N Y N N
Interrupt clearing control (3) N N Y Y
DISSLW slew rate control Y Y N N
(1) I2C address bits A2..A0 are selected by a voltage divider.
(2) The first byte of the SPI message contains the device address
bits A1..A0, allowing up to 4 x SPI devices with one /CS.
This is enabled by the HAEN bit, see enableHardwareAddressing().
The 23S009 has only one address, 0x20.
(3) An interrupt is cleared by reading either the GPIO or the
INTCAP register. On the 23x08, reading either register clears
the interrupt.
DATA SHEETS
-----------
https://ww1.microchip.com/downloads/en/DeviceDoc/MCP23008-MCP23S08-Data-Sheet-20001919F.pdf
https://ww1.microchip.com/downloads/en/DeviceDoc/20002121C.pdf
*/
// Define one of these for the I2C or SPI version
#if defined(MCP23EXPANDER8BIT_SPI)
#include <SPI.h>
#elif defined(MCP23EXPANDER8BIT_I2C)
#include <Wire.h>
#else
#error MCP23EXPANDER8BIT_SPI or MCP23EXPANDER8BIT_I2C not defined before #include "MCP23Expander8bit.h"
#endif
// Base class for 8-bit expander SPI and I2C classes
class MCP23Expander8bitBase
{
protected:
// For SPI 23S008 IOCON:HAEN bit, set by enableHardwareAddressing()
static bool spiHardwareAddressingEnabled;
public:
// Input mask for GPIO port, 1=the bit is an input, 0=output
byte iodir = 0xff;
// Register numbers
enum REGS : byte
{
IODIR = 0x00, // I/O direction: 1=input, 0=output
IPOL = 0x01, // Input polarity: 1=inverted, 0=not inverted
GPINTEN = 0x02, // Interrupt-on-change enable: 1=enable interrupt, 0=no interrupt
DEFVAL = 0x03, // Default compare register for interrupt, interrupt occurs if bit does NOT match
INTCON = 0x04, // Interrupt-on-change control: 1=pin state compared with DEFVAL bit, 0=not compared
IOCON = 0x05, // Chip configuration, see IOCON enum for bits, shared by both Ports
GPPU = 0x06, // Pull-up resistor configuration: 1=pull-up enabled, 0=pull-up disabled
INTF = 0x07, // Interrupt flag, which input caused the interrupt: 1=pin caused interrupt
INTCAP = 0x08, // Interrupt capture, port value at time of interrupt
GPIO = 0x09, // Port value, inputs and outputs
OLAT = 0x0A // Output latch, read/write output latches
};
// IOCON configuration register bits
enum IOCON : byte
{
INTCC = 0x01, // Interrupt clearing control, 23009 only
INTPOL = 0x02, // Polarity of INT pin: 0=active low, 1=active high
ODR = 0x04, // Open drain INT pin: 0=driven, 1=open drain (overrides INTPOL)
HAEN = 0x08, // Hardware address enable, 23S08 SPI devices only
DISSLW = 0x10, // SDA slew rate control, 23008 only: 0=enabled, 1=disabled
SEQOP = 0x20 // Sequential address mode: 0=address increments, 1=address unchanged
};
bool setDefaults();
bool configureInterruptPin(bool polarity, bool openDrain, bool intClear = false);
bool configureInterrupt(byte gpinten, byte defval, byte intcon);
bool configureGpios(byte iodir, byte ipol, byte gppu);
bool readInterrupt(byte* intf, byte* intcap);
void digitalWrite(byte pin, bool value);
bool digitalRead(byte pin);
bool readGpios(byte* value);
bool readOutputs(byte* value);
bool writeOutputs(byte value);
bool readRegister(byte reg, byte* value);
virtual bool isConnected() = 0;
virtual bool readRegisters(byte reg, byte* value, int length) = 0;
virtual bool writeRegister(byte reg, byte value) = 0;
#ifdef DEBUG
bool verify(byte reg, byte value);
void dumpRegisters();
void dumpRegister(byte reg, char* name, byte value);
#endif
};
// for SPI's IOCON:HAEN bit, see SPI's enableHardwareAddressing()
// this is shared by all instances of this class
bool MCP23Expander8bitBase::spiHardwareAddressingEnabled = false;
// Set registers as they would be after a power-on reset (POR)
// Except for the SPI IOCON:HAEN bit which is controlled by SPI's
// enableHardwareAddressing().
// INT pin active low; INT pin driven; slew rate enabled; address increments;
// INTA/INTB separate; one-bank interleaved register addressing;
// HAEN according to enableHardwareAddressing.
// https://ww1.microchip.com/downloads/en/devicedoc/20001952c.pdf#page=21
bool MCP23Expander8bitBase::setDefaults()
{
// set IODIR to 0xff (all pins of both ports := inputs)
if (!writeRegister(IODIR, 0xff))
return false;
iodir = 0xff;
// IOCON register
// for SPI 23S008, keep HAEN set if enableHardwareAddressing() was called
if (!writeRegister(IOCON, spiHardwareAddressingEnabled ? HAEN : 0))
return false;
// set all other writable registers to 0
for (byte reg = IPOL; reg <= OLAT; ++reg) {
// skip read-only registers and IOCON
switch (reg) {
case INTF:
case INTCAP:
case IOCON:
continue;
}
if (!writeRegister(reg, 0))
return false;
}
return true;
}
// Configure the INT pin
// polarity = polarity of INT output pin: 0=active low, 1=active high
// openDrain = open drain INT pin: 0=driven, 1=open drain (overrides polarity)
// intClear = 23x09 only, interrupt clearing control INTCC:
// 0=read GPIO register clears interrupt,
// 1=read INTCAP register clears, 23x09 ONLY
bool MCP23Expander8bitBase::configureInterruptPin(bool polarity, bool openDrain,
bool intClear/*=false*/)
{
byte iocon = 0;
// SPI 23S08 only, see enableHardwareAddressing()
// HAEN=1 (SPI hardware address enable A0..A3)
if (spiHardwareAddressingEnabled)
iocon |= HAEN;
if (polarity)
iocon |= INTPOL;
if (openDrain)
iocon |= ODR;
if (intClear)
iocon |= INTCC;
return writeRegister(IOCON, iocon);
}
// Configure interrupt
// gpinten = interrupt-on-change pin enable: 1=interrupt enabled, 0=interrupt disabled
// defval = default value register: different bit causes an interrupt, enabled by intcon bit
// intcon = interrupt control register: 1=bits compared with DEFVAL register, 0=not compared
bool MCP23Expander8bitBase::configureInterrupt(byte gpinten, byte defval, byte intcon)
{
return writeRegister(GPINTEN, gpinten) &&
writeRegister(DEFVAL, defval) &&
writeRegister(INTCON, intcon);
}
// Configure a port's inputs/outputs
// iodir = direction: 1=input, 0=output
// ipol = input polarity: 1=inverted, 0=not inverted
// gppu = input pull-up: 1=pull-up enabled, 0=pull-up disabled
bool MCP23Expander8bitBase::configureGpios(byte iodir, byte ipol, byte gppu)
{
// save input mask, 1=input
this->iodir = iodir;
return writeRegister(IODIR, iodir) &&
writeRegister(IPOL, ipol) &&
writeRegister(GPPU, gppu);
}
// Read interrupt details
// "The INTCAP register remains unchanged until the interrupt is cleared by
// a read of INTCAP or GPIO."
bool MCP23Expander8bitBase::readInterrupt(byte* intf, byte* intcap)
{
return readRegister(INTF, intf) &&
readRegister(INTCAP, intcap);
}
// Read all bits of the GPIO register
// this clears the port's INTCAP register
bool MCP23Expander8bitBase::readGpios(byte* value)
{
return readRegister(GPIO, value);
}
// Read/write the outputs (OLAT register)
bool MCP23Expander8bitBase::readOutputs(byte* value)
{
return readRegister(OLAT, value);
}
bool MCP23Expander8bitBase::writeOutputs(byte value)
{
// should not write to an input
ASSERT((iodir & value) == 0);
return writeRegister(OLAT, value);
}
// Write to a single output (OLAT register)
void MCP23Expander8bitBase::digitalWrite(byte pin, bool value)
{
ASSERT(pin < 8);
byte b;
readOutputs(&b);
byte mask = 1 << pin;
if (value)
b |= mask;
else
b &= ~mask;
writeOutputs(b);
}
// Read a single input (or output) from the GPIO register
bool MCP23Expander8bitBase::digitalRead(byte pin)
{
ASSERT(pin < 8);
byte b;
readGpios(&b);
return (b & (1 << pin)) ? true : false;
}
// Read a single 8-bit register
bool MCP23Expander8bitBase::readRegister(byte reg, byte* value)
{
return readRegisters(reg, value, 1);
}
#ifdef DEBUG
// Register read-after-write verification
bool MCP23Expander8bitBase::verify(byte reg, byte value)
{
// read-after-write verification
// skip read-only and GPIO registers
switch (reg) {
case INTF:
case INTCAP:
case GPIO:
break;
default:
byte value1;
if (!readRegister(reg, &value1))
return false;
if (value != value1) {
LOGERROR("register compare error");
return false;
}
}
return true;
}
// Display all the register values
void MCP23Expander8bitBase::dumpRegister(byte reg, char* name, byte value)
{
char buf[32];
sprintf(buf, "%02X %-7s %02X", reg, name, value);
Serial.println(buf);
Serial.flush();
}
#define DUMP(reg, name) \
readRegister(reg, &value); \
dumpRegister(reg, name, value)
void MCP23Expander8bitBase::dumpRegisters()
{
byte value;
Serial.println("\nMCP23xxx REGISTERS");
DUMP(IODIR, "IODIR");
DUMP(IPOL, "IPOL");
DUMP(GPINTEN, "GPINTEN");
DUMP(DEFVAL, "DEFVAL");
DUMP(INTCON, "INTCON");
DUMP(IOCON, "IOCON");
DUMP(GPPU, "GPPU");
DUMP(INTF, "INTF");
DUMP(INTCAP, "INTCAP");
DUMP(GPIO, "GPIO");
DUMP(OLAT, "OLAT");
}
#endif
#if defined(MCP23EXPANDER8BIT_SPI)
////////////////////////////////////////
// SPI VERSION
// 8-bit I/O expander with SPI interface
class MCP23Expander8bitSPI : public MCP23Expander8bitBase
{
protected:
static SPISettings spiSettings;
SPIClass* spi = NULL;
byte csPin = 0xff;
byte slaveAdds = 0xff;
bool disableVerify = false;
void enableHardwareAddressing();
public:
bool begin(SPIClass* spiPort, byte chipSelectPin, byte slaveAddress = 0xff);
bool isConnected();
bool readRegisters(byte reg, byte* value, int length);
bool writeRegister(byte reg, byte value);
};
// Shared SPI settings for all instances
// change the 10MHz clock frequency as desired
SPISettings MCP23Expander8bitSPI::spiSettings = SPISettings(10000000, MSBFIRST, SPI_MODE0);
// Don't forget to initialize SPI in setup()
// SPI.begin();
// Call this for each expander chip, after calling SPI.begin().
// Use slaveAddress = 0..7 if you want to use SPI hardware addressing
// feature (or the default 0xff if not), see IOCON:HAEN bit
bool MCP23Expander8bitSPI::begin(SPIClass* spiPort, byte chipSelectPin,
byte slaveAddress /*=0xff*/)
{
spi = spiPort;
csPin = chipSelectPin;
pinMode(csPin, OUTPUT);
::digitalWrite(csPin, 1);
// the SPI device address feature allows up to 8 devices (0..7) to
// use the same chip select
// on first call, enable use of A3..A0 addresses with SPI if a
// slaveAddress has been supplied
if (!spiHardwareAddressingEnabled && slaveAddress <= 7) {
enableHardwareAddressing();
}
slaveAdds = (slaveAddress > 7) ? 0 : slaveAddress;
// set the default configuration in case the chip did not get a hardware reset
return setDefaults();
}
// MCP23S08 only
// Works only for the first message after a power-on-reset or /RESET signal.
// If using the A2..A0 address bit to allow up to 4 x SPI devices on the
// same SPI chip select line, then this is called to set the HAEN bit in
// all IOCON registers so the chips will use their addresses.
// If this is not called then you won't be able to talk to these chips.
// The message is sent to all devices on the bus, immediately after reset.
void MCP23Expander8bitSPI::enableHardwareAddressing()
{
// this cannot be called until the chip-select pin has been initialized
ASSERT(csPin != 0xff);
// only call it once
if (spiHardwareAddressingEnabled)
return;
spiHardwareAddressingEnabled = true;
disableVerify = true;
// write command with address 0b000
// this is seen by all devices, but only after a reset, when
// the HAEN bit is clear
slaveAdds = 0b000;
writeRegister(IOCON, HAEN);
disableVerify = false;
}
bool MCP23Expander8bitSPI::isConnected()
{
//TODO what's the best way to detect this?
return true;
}
// Read one or more consecutive 8-bit registers
// requires 'Sequential address mode' if reading more than one
bool MCP23Expander8bitSPI::readRegisters(byte reg, byte* values, int length)
{
ASSERT(reg + length <= OLAT + 1);
::digitalWrite(csPin, 0);
spi->beginTransaction(spiSettings);
spi->transfer(0b01000001 | (slaveAdds << 1)); // 0b0100aaa1
spi->transfer(reg);
for (int i = 0; i < length; ++i) {
values[i] = spi->transfer(0);
}
spi->endTransaction();
::digitalWrite(csPin, 1);
return true;
}
// Write a single 8-bit register
bool MCP23Expander8bitSPI::writeRegister(byte reg, byte value)
{
::digitalWrite(csPin, 0);
spi->beginTransaction(spiSettings);
spi->transfer(0b01000000 | (slaveAdds << 1)); // 0b0100aaa0
spi->transfer(reg);
spi->transfer(value);
spi->endTransaction();
::digitalWrite(csPin, 1);
#ifdef DEBUG
// read-after-write verification
if (!disableVerify)
return verify(reg, value);
#endif
return true;
}
#elif defined(MCP23EXPANDER8BIT_I2C)
////////////////////////////////////////
// I2C VERSION
// 8-bit I/O expander with I2C interface
class MCP23Expander8bitI2C : public MCP23Expander8bitBase
{
protected:
TwoWire* wire;
byte i2cAdds;
public:
bool begin(TwoWire* twoWire, byte i2cAddress);
bool isConnected();
bool readRegisters(byte reg, byte* value, int length);
bool writeRegister(byte reg, byte value);
};
// Initialize Wire before calling, for example...
// Wire.begin();
// Wire.setClock(1000000);
// Wire.setTimeout(100);
// mcp23.begin(&Wire, 0x20);
bool MCP23Expander8bitI2C::begin(TwoWire* twoWire, byte i2cAddress)
{
wire = twoWire;
i2cAdds = i2cAddress;
// set the default configuration in case the chip did not get a hardware reset
return setDefaults();
}
bool MCP23Expander8bitI2C::isConnected()
{
wire->beginTransmission(i2cAdds);
return wire->endTransmission() == 0;
}
// Read one or more consecutive 8-bit registers
// requires 'Sequential address mode' if reading more than one
bool MCP23Expander8bitI2C::readRegisters(byte reg, byte* values, int length)
{
ASSERT(reg + length <= OLAT + 1);
wire->beginTransmission(i2cAdds);
if (wire->write(reg) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
if (wire->requestFrom(i2cAdds, (size_t)length) != length) {
LOGERROR("requestFrom failed");
return false;
}
if (wire->readBytes(values, length) != length) {
LOGERROR("readBytes failed");
return false;
}
return true;
}
// Write a single 8-bit register
bool MCP23Expander8bitI2C::writeRegister(byte reg, byte value)
{
wire->beginTransmission(i2cAdds);
if (wire->write(reg) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->write(value) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
#ifdef DEBUG
// read-after-write verification
return verify(reg, value);
#else
return true;
#endif
}
#endif
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.)".
"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."
"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.
// Example using a 74HC595 8-bit shift register as an Output Expander
// muman.ch, 2025.06.21
#if 0
// Fast Version
// This works only on the STM32F103xB MCU
// On a 72MHz STM32F103 the takes 9.2uS to write 8 bits
// The bit-bang clock runs at 1.18MHz
#include <stm32f103xb.h>
#include <stm32f1xx_hal_gpio.h>
class Encoder74HC595
{
protected:
#define SET_PIN(port, pin) ((port)->BSRR = (pin))
#define CLEAR_PIN(port, pin) ((port)->BSRR = (pin << 16))
#define READ_PIN(port, pin) ((port)->IDR & (pin))
//#define TOGGLE_PIN(port, pin) ((port)->ODR ^= (pin))
// data bit, 0 or 1
GPIO_TypeDef* portSER;
int maskSER;
// clock shift register to output latches on rising edge
GPIO_TypeDef* portRCLK;
int maskRCLK;
// data clock, data bit clocked into shift register on rising edge
GPIO_TypeDef* portSRCLK;
int maskSRCLK;
// clear shift register on falling edge
GPIO_TypeDef* portSRCLR;
int maskSRCLR;
// output enable, active low
byte pinOE;
// current state of the port
byte curData8 = 0;
public:
void begin(byte pinSER, byte pinRCLK, byte pinSRCLK,
byte pinSRCLR, byte pinOE = 255);
void enableOutputs(bool enable);
void clear();
void writeBit(byte bit, bool value);
void writeByte(byte data8);
bool readBit(byte bit) { return curData8 & (1 << bit); }
byte readByte() { return curData8; }
bool test(byte pinQH);
byte txRx(GPIO_TypeDef* portQH, int naskQH, byte dataOut);
};
void Encoder74HC595::begin(byte pinSER, byte pinRCLK,
byte pinSRCLK, byte pinSRCLR, byte pinOE)
{
// clear shift register, falling edge
portSRCLR = digitalPinToPort(pinSRCLR);
maskSRCLR = digitalPinToBitMask(pinSRCLR);
pinMode(pinSRCLR, OUTPUT);
CLEAR_PIN(portSRCLR, maskSRCLR);
SET_PIN(portSRCLR, maskSRCLR);
// clock shift register to output latches on rising edge
portRCLK = digitalPinToPort(pinRCLK);
maskRCLK = digitalPinToBitMask(pinRCLK);
pinMode(pinRCLK, OUTPUT);
CLEAR_PIN(portRCLK, maskRCLK);
SET_PIN(portRCLK, maskRCLK);
CLEAR_PIN(portRCLK, maskRCLK);
// data bit, 0 or 1
portSER = digitalPinToPort(pinSER);
maskSER = digitalPinToBitMask(pinSER);
pinMode(pinSER, OUTPUT);
CLEAR_PIN(portSER, maskSER);
// data clock, data bit clocked into shift register on rising edge
portSRCLK = digitalPinToPort(pinSRCLK);
maskSRCLK = digitalPinToBitMask(pinSRCLK);
pinMode(pinSRCLK, OUTPUT);
CLEAR_PIN(portSRCLK, maskSRCLK);
// optional output enable, active low
this->pinOE = pinOE;
if (pinOE < 255)
pinMode(pinOE, OUTPUT);
}
// Enable/disable outputs
void Encoder74HC595::enableOutputs(bool enable)
{
if (pinOE < 255)
digitalWrite(pinOE, !enable);
}
// Clear all outputs
void Encoder74HC595::clear()
{
// clear shift register on falling edge
CLEAR_PIN(portSRCLR, maskSRCLR);
SET_PIN(portSRCLR, maskSRCLR);
// transfer the shift register to the output latches on rising edge
SET_PIN(portRCLK, maskRCLK);
CLEAR_PIN(portRCLK, maskRCLK);
curData8 = 0;
}
// Write all 8 bits to the outputs
void Encoder74HC595::writeByte(byte data8)
{
curData8 = data8;
// clock 8 data bits into the shift register, ms bit first
// bit7 = QH .. bit 0=QA
for (int mask = 0x80; mask; mask >>= 1) {
// clock the next data bit into the shift register
if (data8 & mask)
SET_PIN(portSER, maskSER);
else
CLEAR_PIN(portSER, maskSER);
SET_PIN(portSRCLK, maskSRCLK);
CLEAR_PIN(portSRCLK, maskSRCLK);
}
// transfer the shift register to the output latches on rising edge
SET_PIN(portRCLK, maskRCLK);
CLEAR_PIN(portRCLK, maskRCLK);
}
// Write a single bit to one output
void Encoder74HC595::writeBit(byte bit, bool value)
{
if (bit > 7)
return;
int mask = 1 << bit;
byte curData;
if (value)
curData = curData8 | mask;
else
curData = curData8 & ~mask;
writeByte(curData);
}
// Shift Test
// Tests the byte shifted out of QH is the same as the byte
// that was previously shifted in.
// Output states are not changed.
// Requires an extra input connection to the chip's QH pin.
bool Encoder74HC595::test(byte pinQH)
{
GPIO_TypeDef* portQH = digitalPinToPort(pinQH);
int maskQH = digitalPinToBitMask(pinQH);
byte b1 = rand();
byte b2 = rand();
byte b3 = txRx(portQH, maskQH, b1);
byte b4 = txRx(portQH, maskQH, b2);
byte b5 = txRx(portQH, maskQH, b3);
return b4 == b1 && b5 == b2;
}
byte Encoder74HC595::txRx(GPIO_TypeDef* portQH, int maskQH, byte dataOut)
{
byte dataIn = 0;
for (int mask = 0x80; mask; mask >>= 1) {
// read the value shifted out of the QH bit
if (READ_PIN(portQH, maskQH))
dataIn |= mask;
// clock the next data bit into the shift register
if (dataOut & mask)
SET_PIN(portSER, maskSER);
else
CLEAR_PIN(portSER, maskSER);
SET_PIN(portSRCLK, maskSRCLK);
CLEAR_PIN(portSRCLK, maskSRCLK);
}
return dataIn;
}
Encoder74HC595 enc;
void setup()
{
Serial.begin(115200);
delay(3000);
Serial.println("\nMCU Started...");
enc.begin(D7, D6, D5, D4);
}
byte data = 0xa5;
void loop()
{
enc.writeByte(data);
data = ~data;
if (!enc.test(D3)) {
Serial.println("failed");
Serial.flush();
}
}
#else
// Conventional Slow Version
// This should work on all devices
// On a 72MHz STM32F103, this takes 46.2uS to write 8 bits
// The bit-bang clock runs at 197.3KHz
#define SER D7 // data bit, 0 or 1
#define RCLK D6 // clock shift register to output latches, rising edge
#define SRCLK D5 // data clock, data bit clocked into shift register on rising edge
#define SRCLR D4 // clear shift register, falling edge
void setup()
{
pinMode(RCLK, OUTPUT);
digitalWrite(RCLK, 0);
pinMode(SRCLK, OUTPUT);
digitalWrite(SRCLK, 0);
pinMode(SER, OUTPUT);
digitalWrite(SRCLK, 0);
pinMode(SRCLR, OUTPUT);
digitalWrite(SRCLR, 0);
digitalWrite(SRCLR, 1);
}
byte data = 0xa5;
void loop()
{
// clock 8 data bits into the shift register, ms bit first
// bit7 = QH .. bit 0=QA
for (int mask = 0x80; mask; mask >>= 1) {
// write data bit
digitalWrite(SER, data & mask ? 1 : 0);
// clock the data bit into the shift register
digitalWrite(SRCLK, 1);
digitalWrite(SRCLK, 0);
}
// transfer the shift register to the output latches
digitalWrite(RCLK, 1);
digitalWrite(RCLK, 0);
// invert the data and repeat
data = ~data;
}
#endif
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.
#pragma once
// PCF8574 Remote 8-Bit I/O Expander for I2C Bus
// muman.ch, 2025.06.01
//
// Data sheet
// https://www.ti.com/lit/ds/symlink/pcf8574.pdf
//
// 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
//
// WARNING! You MUST connect the chip's A1/A2/A3 pins to ground or VCC,
// else comms is NOT reliable.
// Inputs should have 100K pullups, according to the example ckt.
// The open drain /INT pin needs a 10K pullup.
// Not all input pins can have interrupts, see
// https://docs.arduino.cc/language-reference/en/functions/external-interrupts/attachInterrupt/
// The open-drain interrupt pin can be shared by other PCF8574 devices, but you cannot
// tell which device generated the interrupt, so using separate pins is better.
#include <Wire.h>
extern void LogError(char* msg, char* file, unsigned int line);
// Put this in the main '.ino' file or in utils.cpp
/*
#ifdef DEBUG
void LogError(char* msg, char* file, unsigned int line)
{
char buf[256];
char* fname = strrchr(file, '\\');
fname = fname ? fname + 1 : file;
sprintf(buf, "%lu %s %s %u", millis(), msg, fname, line);
Serial.println(buf);
}
#endif
*/
/// PCF8574 Remote 8-Bit I/O Expander for I2C Bus
class PCF8574ExpanderI2c
{
//>>>>>>>>>>>>>
//TODO define error logging or destruct sequence here
//NOTE can you define DEBUG from the Arduino IDE?
//with Visual Micro/Visual Studio it's no problem
#ifdef DEBUG
#define LOGERROR(msg) LogError(msg, __FILE__, __LINE__)
#define ASSERT(b) if(!(b)) LOGERROR("Assert failed")
#else
#define LOGERROR(s)
#define ASSERT(b)
#endif
//<<<<<<<<<<<<
protected:
TwoWire* wire;
byte i2cAdds;
static void interruptHandler();
static volatile bool hadInterrupt;
byte oldbits;
public:
byte inputMask;
bool begin(TwoWire* twoWire, byte i2cAddress, byte inputMask, int interruptPin);
bool isConnected();
bool inputStateChanged(byte* chgbits, byte* setbits, byte* clrbits);
bool writeOutputs(byte value);
bool readInputs(byte* data);
};
// On power-up, all ports are in input mode and the initial state is high.
// Call Wire.begin() only once, from setup().
// twoWire pointer (address of Wire) is needed if there's more than one I2C bus
// inputMask : 1=bit is an input, 0=bit is an output
// interruptPin : the MCO pin connected to the /INT output
bool PCF8574ExpanderI2c::begin(TwoWire* twoWire, byte i2cAddress, byte inputMask,
int interruptPin)
{
wire = twoWire;
i2cAdds = i2cAddress;
this->inputMask = inputMask; // (this->way you can use the same name)
hadInterrupt = false;
oldbits = 0;
// falling edge interrupt when input state changes
if (interruptPin) {
pinMode(interruptPin, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(interruptPin), interruptHandler, FALLING);
}
//TwoWire.begin() should be called once from setup()
//wire->begin();
// ensure the timeout is not 0 (infinite)
//wire->setWireTimeout(50000); // 50 milliseconds
wire->setTimeout(50);
// read the current state of the inputs, for change detection
byte data;
if (!readInputs(&data))
return false;
oldbits = data & inputMask;
return true;
}
bool PCF8574ExpanderI2c::isConnected()
{
wire->beginTransmission(i2cAdds);
return wire->endTransmission() == 0;
}
// Internal interrupt handler on change of an input's state.
// Sets 'hadInterrupt' which is polled by calling inputStateChanged()
// The interrupt will re-occur only after inputs have been read or
// the input state changed back.
// NOTE: This static handler may be shared by more than one device.
// This interrupt is a bit 'noisy'...
void PCF8574ExpanderI2c::interruptHandler()
{
hadInterrupt = true;
}
volatile bool PCF8574ExpanderI2c::hadInterrupt;
// Returns true if the 'input state changed' interrupt occurred and input
// bits have been changed, with details of which bits have changed.
// Note: It's possible that more than one bit has changed at the same time.
// chgbits : 1=bit changed, 0=bit unchanged
// setbits : 1=bit set, 0=bit not set
// clrbits : 1=bit cleared, 0=bit not cleared
bool PCF8574ExpanderI2c::inputStateChanged(byte* chgbits, byte* setbits, byte* clrbits)
{
noInterrupts();
bool changed = hadInterrupt;
hadInterrupt = false;
interrupts();
// no interrupt, assume nothing changed
if (!changed)
return false;
// read the new states
byte newbits;
if (!readInputs(&newbits))
return false;
// mask out the output bits
newbits &= inputMask;
// any input states changed?
// (the interrupt seems to happen many times without any changes!?)
if (newbits == oldbits)
return false;
// changed bits
*chgbits = newbits ^ oldbits;
// bits that have been set
*setbits = newbits & ~oldbits;
// bits that have been cleared
*clrbits = ~newbits & oldbits;
oldbits = newbits;
return true;
}
bool PCF8574ExpanderI2c::writeOutputs(byte value)
{
wire->beginTransmission(i2cAdds);
// keep all input pins high (1)!
if (wire->write((byte)(value | inputMask)) != 1) {
LOGERROR("write failed");
return false;
}
if (wire->endTransmission() != 0) {
LOGERROR("endtx failed");
return false;
}
return true;
}
bool PCF8574ExpanderI2c::readInputs(byte* data)
{
if (wire->requestFrom(i2cAdds, (size_t)1) != 1) {
LOGERROR("requestFrom failed");
return false;
}
if (wire->readBytes(data, 1) != 1) {
LOGERROR("readBytes failed");
return false;
}
return true;
}
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.
#pragma once
// Debouncer using a microsecond timer
// muman.ch, 2019.03.14
/*
Use this for debouncing a boolean value such as a switch or input state.
Unlike some other debouncers, this code registers activation (closing)
immediately. It is only the deactivation (opening) that is delayed by
the timer.
*/
class Debouncer2
{
private:
unsigned long usDelay;
bool firstCall;
bool wasActive;
unsigned long usTimer;
public:
Debouncer2() { }
Debouncer2(unsigned long usDelay);
void begin(unsigned long usDelay);
bool stateChanged(bool currentState, bool* debouncedState);
};
Debouncer2::Debouncer2(unsigned long usDelay)
{
begin(usDelay);
}
void Debouncer2::begin(unsigned long usDelay)
{
this->usDelay = usDelay;
firstCall = true;
}
// Returns 'true' if the debounced state has changed, with the state in
// 'debouncedState'. 'currentState' and 'debouncedState' are active high.
// Detects activation immediately, deactivation after the delay.
// Do not call this from a timer interrupt, it may not work.
bool Debouncer2::stateChanged(bool currentState, bool* debouncedState)
{
// register current state as changed on first call
if (firstCall) {
firstCall = false;
wasActive = !currentState;
}
// newly activated
if (currentState && !wasActive) {
// register as active immediately
*debouncedState = true;
wasActive = true;
return true;
}
// was active, still active or active again
if (currentState && wasActive) {
*debouncedState = true;
usTimer = 0; // restart deactivation timer
return false;
}
*debouncedState = wasActive;
// was active, now inactive
if (wasActive && !currentState) {
// start deactivation timer
if (usTimer == 0) {
usTimer = micros();
return false;
}
// or check for deactivation timeout
if (micros() - usTimer <= usDelay) {
// timed out, set inactive
wasActive = false;
*debouncedState = false;
usTimer = 0;
return true;
}
}
// unchanged
return false;
}
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