Port expander MCP23017

About the post

In my post about the port expansion for the ESP-01 I had already briefly described the MCP23017, here I would like to go into detail about its many features. In the first part of the post I would like to show you how to use the MCP23017 (and the MCP23S17) with the help of a library. The second part is a deep dive for those who are interested in the internal details. I will refer to the numerous registers of the MCP23017.

MCP23017 – an overview

The MCP23017 is a 16-bit I/O port expander with convenient interrupt functions. The 16 I/O pins are organized in two ports (A and B), which are addressed separately (byte mode) or together (sequential mode). The supply voltage should be between 1.8 and 5.5 volts. The maximum current at the I/O pins is 25 mA in both directions. In total, the input current to VDD should not exceed 125 mA and not more than 150 mA should flow into VSS (GND). Communication takes place via I2C. A data sheet for the MCP23017 can be found here.

In terms of its flexibility, the MCP23017 is the Swiss army knife among the common port expanders, if you compare it with the 74HC595 shift register or the PCF8574, for example.<<<<<

Pinout of the MCP23017

Pinout of the MCP23017
Pinout of the MCP23017

The 16 I/O pins are named GPA0 to GPA7 or GPB0 to GPB7 according to the two ports. The power supply is via VDD and VSS. The connection of pins A0, A1 and A2 determines the I2C address according to the following scheme:

1 0 0 A2 A1 A0

For example, if A0 to A2 are LOW, then the address is 100000 (binary) = 32 (decimal) = 0x20 (hexadecimal). SDA and SCL are the two I2C pins. The reset pin is active-low. INTA and INTB are the interrupt pins for the two ports. You can set the polarity of the interrupt pins. You can also connect both interrupt pins (mirror function).

Controlling the MCP23017 with the library

I have developed a library that you can find and download here on Github. You can also install the library directly from the Arduino IDE library manager. The library is designed to address the two ports A and B in byte mode, i.e. separately. Maybe I’ll add sequential mode to the library at some point.

Simple input/output applications

The functionality of the I/O pins is comparable to the functionality of the Arduino I/O pins. Thus, the pins can be used as input or output, they are switched HIGH or LOW and they can act as both a (limited) power supplier and a power sink. If they are set as   input, the pins can serve as interrupt pins. But in the first example, we start easy and only 16 LEDs are controlled.

Circuit for controlling 16 LEDs
Circuit for controlling 16 LEDs

The address pins in my example are LOW, so the I2C address is 0x20. The reset pin is connected to the Arduino pin 5. The pins of port A and B each control eight LEDs of an LED bar. The I2C lines SDA and SCL should get pull-up resistors of 4.7 kOhm. Without the pull-ups, however, it also worked well for me.

In this example, the following functions are used (the MCP23017 object is called myMCP):

  • MCP23017 myMCP = MCP23017() creates the MCP23017 object. You have to pass the I2C address to the constructor. Moreover you can pass the reset pin (if you need the reset function) and / or a Wire object. The latter option enables you to use e.g. the two I2C busses of an ESP32.
  • myMCP.Init(); initializes the object with some presets
  • myMCP.setPinMode( pin,port,direction ); similar to the Arduino pinMode function, whereby the port is added as a parameter.
    • as direction, you can use: INPUT/OUTPUT/INPUT_PULLUP, OFF/ON or 0/1
    • “0”= INPUT, “1” =OUTPUT
  • myMCP.setPortMode( value,port ); set pinMode for an entire port; “value” is reasonably given as a binary number
  • myMCP.setPortMode( value,port,INPUT_PULLUP); with this variant all input pins are pulled up; no effect on output pins.
  • myMCP.setPin( pin,port,level ); similar to the digitalWrite function;
    •  permissible parameters for level are: 0/1, OFF/ON, LOW/HIGH
  • myMCP.setPort( value,port ); digitalWrite for an entire port; it makes sense to write “value” as a binary number
  • myMCP.togglePin( pin,port ); switches a pin from LOW to HIGH or from HIGH to LOW
  • myMCP.setPinX( pin,port,direction,level ); “extended version” of the setPin function or combination of setPinMode and setPin
  • myMCP.setPortModeX( direction,value,port ); “extended version” der setPort Funktion
  • myMCP.setAllPins( port,level ); sets all pins of a port to LOW or HIGH

Example sketch for simple input/output applications

Here is a sketch that plays with these functions and illustrates them:

#include <Wire.h>
#include <MCP23017.h>
#define MCP_ADDRESS 0x20 // (A2/A1/A0 = LOW)
#define RESET_PIN 5  

/* There are several ways to create your MCP23017 object:
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS)            -> uses Wire / no reset pin (if not needed)
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN)  -> uses Wire / RESET_PIN
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS)    -> uses the TwoWire object wire2 / no reset pin
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS, RESET_PIN) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN);

int wT = 1000; // wT = waiting time

void setup(){ 
  Serial.begin(9600);
  Wire.begin();
  if(!myMCP.Init()){
    Serial.println("Not connected!");
    while(1){} 
  }
  myMCP.setPortMode(0b11111101, A);  // Port A: all pins are OUTPUT except pin 1
  myMCP.setPortMode(0b11111111, B);  // Port B: all pins are OUTPUT
  delay(wT);
  myMCP.setAllPins(A, ON); // alle LEDs switched on except A1
  delay(wT);
  myMCP.setPinX(1, A, OUTPUT, HIGH); // A1 switched on 
  delay(wT); 
  myMCP.setPort(0b11110000, B); // B4 - B7 switched on
  delay(wT);
  myMCP.setPort(0b01011110, A); // A0,A5,A7 switched off
  delay(wT);
  myMCP.setPinX(0,B,OUTPUT,HIGH); // B0 switched on
  delay(wT);
  myMCP.setPinX(4,B,OUTPUT,LOW); // B4 switched off
  delay(wT);
  myMCP.setAllPins(A, HIGH); // A0 - A7 all on
  delay(wT);
  myMCP.setPin(3, A, LOW); // A3 switched off
  delay(wT);
  myMCP.setPortX(0b11110000, 0b01101111,B); // at port B only B5,B6 are switched on
  delay(wT);
  myMCP.setPinMode(0,B,OUTPUT); // B0 --> OUTPUT
  for(int i=0; i<5; i++){  // B0 blinking
    myMCP.togglePin(0,B); 
    delay(200);
    myMCP.togglePin(0,B);
    delay(200);
  }
  for(int i=0; i<5; i++){ // B7 blinking
    myMCP.togglePin(7,B);
    delay(200);
    myMCP.togglePin(7,B);
    delay(200);
  }
}

void loop(){ 
} 

In my example, the current (technical current direction Plus – Minus) flows > from the MCP23017 through the LEDs to GND. Instead, the MCP23017 could of course also be used as a current sink. Then an LED would light up if the corresponding pin is OUTPUT and LOW and voltage is supplied, comparable with how the Arduino works.

Read pin status

The following functions are used to query the pin status in the GPIO register:

  • myMCP.getPin( pin,port ); returns the level of a pin (as bool)
  • myMCP.getPort( port ); returns the status of an entire port (as byte), i.e. the contents of the GPIO register

You can use the same circuit as above and play around with these functions in conjunction with the following sketch. If you connect an external voltage (HIGH level) on a LOW switched output, you will see that there is a HIGH in the GPIO register. In the GPIO register, therefore, you find the actual logical level and not the set one.

#include <Wire.h>
#include <MCP23017.h>
#define MCP_ADDRESS 0x20 // (A2/A1/A0 = LOW)
#define RESET_PIN 5  

/* There are several ways to create your MCP23017 object:
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS)            -> uses Wire / no reset pin (if not needed)
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN)  -> uses Wire / RESET_PIN
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS)    -> uses the TwoWire object wire2 / no reset pin
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS, RESET_PIN) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN);

int wT = 1000; // wT = waiting time
byte portStatus;
bool pinStatus;

void setup(){ 
  Serial.begin(9600);
  Wire.begin();
  if(!myMCP.Init()){
    Serial.println("Not connected!");
    while(1){} 
  }  
  myMCP.setPortMode(0b11111111, A);  // Port A: all pins are OUTPUT
  myMCP.setPortMode(0b11111111, B);  // Port B: all pins are OUTPUT
  myMCP.setPort(0b10010011,A);  // 
}

void loop(){ 
  portStatus = myMCP.getPort(A);
  Serial.print("Status GPIO A: ");
  Serial.println(portStatus, BIN);
  pinStatus = myMCP.getPin(7, A);
  Serial.print("Status Port A, Pin 7: ");
  Serial.println(pinStatus, BIN);
  portStatus = myMCP.getPort(B);
  Serial.print("Status GPIO B: ");
  Serial.println(portStatus, BIN);
  pinStatus = myMCP.getPin(0, B);
  Serial.print("Status Port B, Pin 0: ");
  Serial.println(pinStatus, BIN);
  Serial.println("-------------------------------------");
  delay(5000);
} 

You can add an internal pull-up to I/Os configured as INPUT:

  • myMCP.setPinPullUp( pin,port,level ); sets a pull-up with a 100 kOhm resistor
  • myMCP.setPortPullUp( value,port ); is the equivalent for an entire port

You can add this to the sketch above and try it out.

Interrupt-on-Change

All 16 I/O pins can be configured as interrupt pins. There are two modes, the interrupt-on-change and the interrupt-on-defval-deviation. I start with the interrupt-on-change function, where every switch from LOW to HIGH or HIGH to LOW triggers an interrupt. The interrupt induces a polarity change at the respective interrupt output INTA or INTB. Alternatively, you can merge INTA and INTB. You can also set the polarity of the interrupt outputs.

Only pins set as INPUT can act as interrupt pins, but this setting is done in the background by the library.

I have implemented the following functions for interrupt-on-change:

  • myMCP.setInterruptOnChangePin( pin,port ); sets up a single pin as an interrupt-on-change pin
  • myMCP.setInterruptOnChangePort( value,port ); sets up multiple or all pins of a port as an interrupt-on-change pin
  • myMCP.setInterruptPinPol( level ); sets the level of the active interrupt output
    • level = HIGH — > active-high, level = LOW — > active-low (default)
    • I have implemented only one setting for both outputs, i.e. both active-high or both active-low
  • myMCP.setIntOdr( value ); value = 1 or ON — > interrupt output pins go into the open drain state, the interrupt pin polarity is overwritten; value = 0 or OFF — > active-low or active-high (both)
  • myMCP.deleteAllInterruptsOnPort( port ); undo the setup of the interrupt pins
  • myMCP.setIntMirror ( value ); value = 1 or ON — > INTA / INTB are mirrored, value = 0 or OFF — > INTA / INTB are responsible for their ports separately (default)
  • myMCP.getIntFlag( port ); returns the value of the interrupt flag register as byte. In the interrupt flag register the bit is set which represents the pin responsible for the last interrupt
  • myMCPgetIntCap( port ); returns the value of the interrupt capture register. It contains the value of the GPIO register at the time of the interrupt.

For testing, I have set up the following circuit:

Circuit for testing the Interrupt on Change function with the MCP23017
Circuit for testing the interrupt-on-pin-change function

The pins of port B are set up as interrupt pins and receive HIGH signals via the buttons. The LEDs connected to port A should indicate on which pin the interrupt occurred.

Example sketch for interrupt-on-change

#include <Wire.h>
#include <MCP23017.h>
#define MCP_ADDRESS 0x20 // (A2/A1/A0 = LOW)
#define RESET_PIN 5  
int interruptPin = 3;
volatile bool event; 
byte intCapReg; 

/* There are several ways to create your MCP23017 object:
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS)            -> uses Wire / no reset pin (if not needed)
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN)  -> uses Wire / RESET_PIN
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS)    -> uses the TwoWire object wire2 / no reset pin
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS, RESET_PIN) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN);

void setup(){ 
  pinMode(interruptPin, INPUT);
  attachInterrupt(digitalPinToInterrupt(interruptPin), eventHappened, RISING);
  Serial.begin(9600);
  Wire.begin();
  if(!myMCP.Init()){
    Serial.println("Not connected!");
    while(1){} 
  } 
  myMCP.setPortMode(0b11111111,A);
  myMCP.setPort(0b11111111, A); // just an LED test
  delay(1000); 
  myMCP.setAllPins(A, OFF);
  delay(1000);
  myMCP.setInterruptPinPol(HIGH); // set INTA and INTB active-high
  delay(10);
  myMCP.setInterruptOnChangePort(0b11111111, B); //set all B pins as interrrupt Pins
  event=false;
}  

void loop(){ 
  intCapReg = myMCP.getIntCap(B); // ensures that existing interrupts are cleared
  if(event){
    delay(200);
    byte intFlagReg, eventPin; 
    intFlagReg = myMCP.getIntFlag(B);
    eventPin = log(intFlagReg)/log(2);
    intCapReg = myMCP.getIntCap(B);
    Serial.println("Interrupt!");
    Serial.print("Interrupt Flag Register: ");
    Serial.println(intFlagReg,BIN); 
    Serial.print("Interrupt Capture Register: ");
    Serial.println(intCapReg,BIN); 
    Serial.print("Pin No.");
    Serial.print(eventPin);
    Serial.print(" went ");
    if((intFlagReg&intCapReg) == 0){  //LOW-HIGH or HIGH-LOW interrupt?
      Serial.println("LOW");
    }
    else{
      Serial.println("HIGH");
    }
    myMCP.setPort(intFlagReg, A);
    //delay(1000);
    event = false; 
  }
}

void eventHappened(){
  event = true;
}

Output of the Interrupt-on-Pin-Change sketch
Output of the Interrupt-on-Pin-Change sketch

Note 1: When the button is pressed quickly, the HIGH-LOW interrupt is ignored. 

Note 2: The interrupt remains active until a getIntCap or getPort query is made. When things go bad because you query at the wrong time or the next interrupt comes at the wrong time, the interrupt will remain unintentional active. That’s why I inserted an additional getIntCap query in line 28, which seems redundant at first glance. This ensures that the MCP23017 is ready for the next interrupt. The problem becomes particularly relevant for Interrupts-on-DefVal-Deviation. 

Interrupt-on-DefVal-Deviation

Here, the polarity of the interrupt pins is compared with the state defined in the so-called DEFVAL register. A deviation triggers an interrupt. If you read the interrupt capture or GPIO register, you delete the interrupt. However, if the interrupt condition is still met at this time, the next interrupt is triggered immediately.

For this interrupt method, I have implemented the following additional functions:

  • myMCP.setInterruptOnDefValDevPin( intpin,port,defvalstate ); intpin is the interruptpin, defvalstate is the default level and a deviation from it leads to the interrupt
  • myMCP.setInterruptOnDefValDevPort( intpins,Port,defvalstate );
    • intpins are the interruptpins, e.g. B10000001 would set the pins 0 and 7 as interrupt pins.
    • defvalstate is the value of the DEFVAL register; a deviation leads to the interrupt

As an example, I have set up the following circuit:

Circuit for testing the interrupt-on-DefVal-Deviation function of the MCP23017
Circuit for testing the Interrupt-on-DefVal-Deviation function

The port B pins are defined as interrupt pins. B0 to B3 are set HIGH with internal pull-ups, while B4 to B7 get pull-down resistors. The polarity on the respective pin is reversed by pushing the button. Port A is used to display the pin responsible for the interrupt, as in the last example.

Example sketch for interrupt-on-defval-deviation

#include <Wire.h>
#include <MCP23017.h>
#define MCP_ADDRESS 0x20 // (A2/A1/A0 = LOW)
#define RESET_PIN 5 
int interruptPin = 3;
volatile bool event = false;
byte intCapReg; 

/* There are several ways to create your MCP23017 object:
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS)            -> uses Wire / no reset pin (if not needed)
 * MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN)  -> uses Wire / RESET_PIN
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS)    -> uses the TwoWire object wire2 / no reset pin
 * MCP23017 myMCP = MCP23017(&wire2, MCP_ADDRESS, RESET_PIN) -> all together
 * Successfully tested with two I2C busses on an ESP32
 */
MCP23017 myMCP = MCP23017(MCP_ADDRESS, RESET_PIN);

void setup(){ 
  pinMode(interruptPin, INPUT);
  attachInterrupt(digitalPinToInterrupt(interruptPin), eventHappened, RISING);
  Serial.begin(9600);
  Wire.begin();
  if(!myMCP.Init()){
    Serial.println("Not connected!");
    while(1){} 
  }
  myMCP.setPortMode(0b11111111,A);
  myMCP.setPort(0b11111111, A); // just an LED test 
  delay(1000); 
  myMCP.setAllPins(A, OFF);
  delay(1000);
  myMCP.setInterruptPinPol(HIGH);
  delay(10);
  myMCP.setInterruptOnDefValDevPort(0b11111111, B, 0b00001111); // interrupt pins, port, DEFVALB
  myMCP.setPortPullUp(0b00001111, B); // pull-up for B0-B3
  event=false;
}  

void loop(){ 
  intCapReg = myMCP.getIntCap(B);
  if(event){
    delay(200);
    byte intFlagReg, eventPin; 
    intFlagReg = myMCP.getIntFlag(B);
    eventPin = log(intFlagReg)/log(2);
    intCapReg = myMCP.getIntCap(B);
    Serial.println("Interrupt!");
    Serial.print("Interrupt Flag Register: ");
    Serial.println(intFlagReg,BIN); 
    Serial.print("Interrupt Capture Register: ");
    Serial.println(intCapReg,BIN); 
    Serial.print("Pin No.");
    Serial.print(eventPin);
    Serial.print(" went ");
    if((intFlagReg&intCapReg) == 0){ // HIGH/LOW or LOW/HIGH change?
      Serial.println("LOW");
    }
    else{
      Serial.println("HIGH");
    }
    myMCP.setPort(intFlagReg, A);
    delay(1000);
    event = false;
  }
}

void eventHappened(){
  event = true;
}

The crucial difference to interrupt-on-change is that in this method the polarity change leads only in one direction to the interrupt. The output looks like this:

Output of Interrupt-on-DefVal-Dev sketch
Output of Interrupt-on-DefVal-Dev sketch

Der MCP23S17

The only difference between the MCP23S17 and the MCP23017 is the communication via SPI. Therefore the pinout is slightly different. All Functions are identical. In my library you find an example sketch and the wiring.

MCP23017 / MCP23S17 intern

Here, as announced, some additional detailed information about the MCP23017 for those who still want it.

The MCP23017 is only a representative of the larger MCP23XXX family, which differ from each other in the number of I/O pins, the control (I2C vs SPI) and the external connection. The MCP23017 is probably the most popular representative. A good overview of the MCP23XXX family can be found here.

The registers of the MCP23017

First of all, you have to decide how to address the registers. For this, you can set the BANK bit in the IOCON Register. If this is   set to 1, the port registers are in two separate banks. If, on the other hand, it is set to 0, the registers are in the same bank and the addresses are sequential. I chose the latter and did not implement the alternative as an option. The registers are thus defined as follows:

Register overview of the MCP23017 for IOCON.BANK = 0
Register overview of the MCP23017 if IOCON.BANK = 0

IODIR – I/O direction register

The IODIR register determines whether the pins are INPUT or OUTPUT pins. It is the only register with a start or reset value of 0b11111111. “1” means INPUT, “0” means OUTPUT, which sounds illogical to me, but maybe I am just used to the different definition in the world of Arduino. But because this irritates me, I have turned the values in my library accordingly. With myMCP.setPortMode() and myMCP.setPinMode() the IODIR register is addressed directly. For example, myMCP.setPortMode(B11111111, A) means that all pins of the port are OUTPUT pins.

IPOL – input polarity register

If you set the bits in this register, the inverted level of the pins is stored in the corresponding GPIO register. Because I could not imagine for what I could use it, I did not implement access to this register in my library.

GPINTEN – Interrupt-on-Change control register

This register controls which pins are used as interrupt pins. 0 = disable, 1 = enable. If you only want to implement interrupt-on-change, no further settings are necessary. However, in case you want to implement interrupt-on-defval-deviation, you have to make additional settings in the DEFVAL and INTCON registers. The GPINTEN register is accessed indirectly in my library via the setInterruptOnChangePin() and setInterruptOnDefValDevPin() functions or their counterparts for entire ports.

DEFVAL – default value register

This register is required to set up Interrupts-on-Defval-Deviation. If a value in the GPIO register differs from the DEFVAL register, an interrupt is triggered if the corresponding settings have been made in the GPINTEN and INTCON registers.

INTCON – interrupt control register

This register determines the conditions under which interrupts are triggered:

  • “0”: Comparison with previous pin status (interrupt-on-change)
  • “1”: Comparison with DEFVAL (Interrupt-on-DefVal-Deviation)

However, the settings are only effective on the pins for which the corresponding bits have been set in GPINTEN.

IOCON – I/O expander configuration register

In this register, some special settings can be made for the MCP23017.

  • BANK – Addressing mode for the registers
    • “1”: registers are in separate banks
    • “0”: registers are in the same bank
    • I did not implement an option to change that in my library
  • MIRROR – is adjustable in my library via setIntMirror()
    • “1”: INTA and INTB are connected (mirrored)
    • “0”: INTA and INTB are separately responsible for port A and B respectively
  • SEQOP – sequential addressing
    • “1”: disabled – the address pointer is not incremented
    • “0”: enabled – the address pointer is automatically incremented
    • I did not implement the option to change that in my library
  • DISSLW – Adjustment of the slope of the SDA output
    • “1”: disabled
    • “0”: enabled
    • not implemented in my library
  • HAEN – not relevant for the MCP23017
  • ODR – open drain for the interrupt pins INTA and INTB
    • “1”: open drain is active – overrides the INTPOL setting
    • “0”: disabled – polarity of the interrupt signal is determined by INTPOL
    • setting is done via setIntODR(); only one common setting for both pins is implemented in my library (either both 0 or both 1)
  • INTPOL – polarity of interrupt pins
    • “1”: active-high
    • “0”: active-low
    • in my library only one common setting of both pins is implemented
  • GPPU – GPIO pull-up register
    • “1”: pull-up with 100 kOhm resistor
    • “0”: no pull-up
    • implemented in my library by setPinPullUp() or setPortPullUp()
    • impacts only pins configured as input

INTF – interrupt flag register (read-only)

This register records which pin caused the last interrupt. The set bit reveals the “guilty” one. In my library, getIntFlag() queries the register content.

INTCAP – interrupt capture value register (read-only)

This register records the contents of the GPIO register at the time of the last interrupt. I implemented the query through the getIntCap() function.

GPIO – general purpose I/O port register

Contains the pin status (HIGH/LOW). Changes to the register also change the OLAT (Output Latch) register. In my library, write access is only implemented indirectly via other functions. Read access in my library is provided by getPin() or getPort().

OLAT – output latch register

Reading returns the state of the register, not the port’s state (this would be done through the GPIO Read). This means, for example, if a pin is set as LOW, but an external HIGH is attached, then you will read it as LOW here, whereas in the GPIO register you would read it as HIGH.

2 thoughts on “Port expander MCP23017

  1. Detailed info on MCP23017. I used it recently without understanding the inner workings of it and this helped me alot as a software developer who has little background in electronics

Leave a Reply

Your email address will not be published.