Your microcontroller has plenty of processing power, but eight buttons, six status LEDs and two limit switches need more pins than it has. That is exactly the job of the MCP23017 GPIO expander: the Microchip part sits on the two I2C lines, SDA and SCL, and gives you 16 GPIO in return, each one configurable as an input or an output. Two wires in, sixteen pins out. With up to eight chips on one bus, the same two wires can carry 128 extra pins.
There are catches: every access goes over the bus, two of the 16 pins are output-only per the current datasheet, and the outputs are meant for logic signals, not loads. Reliability comes down to the address, pull-ups, interrupts and current budget.

What is an I2C IO expander?
An I2C IO expander is a chip that adds digital inputs and outputs through a serial bus. Your controller doesn’t toggle those pins directly; it writes and reads registers inside the expander: one byte sets which pins are inputs, another holds the current pin levels, and a third holds the output states you want.
The MCP23017 groups its 16 GPIO into two 8-bit ports, GPA0 to GPA7 and GPB0 to GPB7. Microchip specifies a supply range of 1.8 to 5.5 V, and the logic levels on the pins follow that supply. On the I2C bus the chip runs at 100 kHz and 400 kHz, plus 1.7 MHz when it is powered from 4.5 to 5.5 V. In standby it draws 1 µA at most.
The MCP23S17 is the SPI version with the same register set; libraries and wiring don’t carry over. The often-mentioned PCF8574 offers only eight quasi-bidirectional pins, with no true direction control and no switchable pull-ups. With the MCP23017, you set direction, pull-up, polarity and interrupt behavior separately for every pin.
When do you need a GPIO expander?
A GPIO expander pays off when you have many slow digital signals. Typical examples are push buttons, DIP switches, status LEDs, dry contacts on doors or limit switches, and the control inputs of relay modules. They change every few milliseconds at most, so the bus delay doesn’t matter.
For a sense of scale: a single read of a port register consists of an address byte, a register byte, a repeated address byte and a data byte. At 400 kHz that takes roughly 0.1 ms, and at 100 kHz about four times as long.
The MCP23017 is the wrong choice wherever a pin has to do more than high and low. It has no PWM, no analog-to-digital converter, no hardware counters and no timers. A servo, a dimmed LED or a fast rotary encoder belongs on a real controller pin.
Voltage matters as well. The pins tolerate at most the supply voltage plus 0.6 V. A 24 V signal from a PLC needs an optocoupler or level shifter first. If you are only two or three pins short, a smarter pin assignment is often simpler than another device on the bus.
How do you set the MCP23017 I2C address?
The MCP23017 I2C address is seven bits long. The upper four bits are fixed at 0100, and the lower three come from the A2, A1 and A0 pins. All three grounded gives 0x20, all three high gives 0x27, so up to eight chips can share one bus. Microchip requires the address pins to be biased externally; left floating, the address is undefined.
The notation trips people up. Some datasheets, logic analyzers and module descriptions show the full first byte, the seven address bits plus the read/write bit, so 0x20 becomes 0x40 for a write and 0x41 for a read. The RAK documentation for the RAK13003 also lists 0x40 as the 7-bit address, although it actually means the 8-bit byte. The Arduino Wire library and practically every common I2C library expect the 7-bit form, 0x20. Enter 0x40 and the expander appears dead.
Before writing code, run an I2C scanner sketch. If 0x20 shows up, wiring and power are fine. If nothing appears, check the supply, the pull-ups on SDA and SCL, and the reset pin, which is active low and must sit high for the chip to run.
Two chips with the same address wiring answer simultaneously and interfere without a clear error. If a module ships with its address pins grounded, change that wiring before adding a second chip to the bus.

How do you use the MCP23017 with Arduino?
On Arduino, the usual starting point is the “Adafruit MCP23017” library. It provides the Adafruit_MCP23X17 class, which RAKwireless also uses in its RAK13003 examples, with version 2.1.0 or later. After including Wire.h and Adafruit_MCP23X17.h, you create an object and call begin_I2C(). Without an argument it uses 0x20; otherwise you pass the 7-bit address.
From there the expander feels like regular Arduino pins with the object in front: mcp.pinMode(), mcp.digitalWrite() and mcp.digitalRead(). The pins are numbered 0 to 15. Pins 0 to 7 map to GPA0 to GPA7, and 8 to 15 map to GPB0 to GPB7.
Under the hood, the library reads and writes registers, and you will meet them in the datasheet and whenever you debug. The table shows the default layout after power-up (IOCON.BANK = 0), where Port A and Port B registers alternate.
| Register | Address Port A / B | Purpose |
|---|---|---|
| IODIR | 0x00 / 0x01 | Direction: 1 = input, 0 = output |
| IPOL | 0x02 / 0x03 | Invert the polarity of an input |
| GPINTEN | 0x04 / 0x05 | Enable interrupt-on-change |
| DEFVAL | 0x06 / 0x07 | Compare value for interrupts |
| INTCON | 0x08 / 0x09 | Compare against previous value or DEFVAL |
| IOCON | 0x0A / 0x0B | Shared configuration, e.g. interrupt outputs |
| GPPU | 0x0C / 0x0D | Enable internal pull-ups |
| INTF | 0x0E / 0x0F | Shows which pin triggered the interrupt |
| INTCAP | 0x10 / 0x11 | Port state at the moment of the interrupt |
| GPIO | 0x12 / 0x13 | Read pin levels; writing sets the output latch |
| OLAT | 0x14 / 0x15 | Read and write the output latch directly |
The most important value isn’t in the table: after power-up and after every reset, IODIR holds 0xFF and every other register holds 0x00. All 16 pins start out as inputs without pull-ups. So write the idle level into the output latch first, then switch the pin to output. With the Adafruit library, you do that by calling digitalWrite() before pinMode(OUTPUT), so a connected relay driver doesn’t click on briefly at startup. Since a reset wipes the configuration, keep setup in a function you can call again after a communication error.
How do MCP23017 pull-ups work on inputs?
An open input with no defined level floats and reads high or low depending on cable length and nearby noise. Every input therefore needs a resistor that pulls it to a known level at rest.
The MCP23017 has internal pull-ups that you enable per pin in the GPPU register, or on Arduino with mcp.pinMode(pin, INPUT_PULLUP). Microchip specifies them as a current rather than a resistance: 40 to 115 µA at a 5 V supply with the pin pulled to ground, 75 µA typical. That works out to roughly 45 to 125 kΩ, which is a weak pull-up. The chip has no pull-downs.
That leads to the standard wiring: the button or contact switches the pin to ground. Open reads high and closed reads low, so the logic is active low. If you would rather see a 1 for “pressed” in software, the IPOL register flips the polarity per pin, and the GPIO register then returns the logical state directly.
For short traces on the board, the internal pull-up is enough. On longer cables it is too weak against induced noise, so add an external pull-up of about 4.7 to 10 kΩ to the expander’s own supply, not to 5 V when the chip runs at 3.3 V.
Two quirks are worth knowing. First, since revision D of the datasheet in 2022, Microchip lists GPA7 and GPB7 on the MCP23017 as output-only. Plan inputs on the other 14 pins. Second, mechanical contacts bounce: a single press produces several transitions between high and low over a few milliseconds. You can debounce in software by accepting a new state only after it has stayed stable for roughly 20 to 50 ms, or in hardware with an RC filter in front of the pin.
How do MCP23017 interrupts work?
Constant polling keeps the controller awake and ties up the bus; interrupts are cleaner. The MCP23017 has two interrupt outputs, INTA for Port A and INTB for Port B. When an enabled input changes, the matching output switches.
Three registers control the behavior. GPINTEN sets which pins are allowed to trigger an interrupt at all. INTCON sets the comparison: with a bit at 0, any change from the previous level triggers it. With a bit at 1, the chip compares against the matching bit in DEFVAL, and the interrupt stays active as long as the pin differs from that default. In IOCON you decide whether INTA and INTB work separately or are linked through the MIRROR bit, and whether they drive push-pull or open-drain.
After an interrupt, INTF tells you which pin caused it, and INTCAP holds the port state at the moment of the event. The interrupt clears when you read GPIO or INTCAP. Otherwise the line stays active and no new interrupt arrives. On Arduino you set this up with setupInterrupts() and setupInterruptPin(); getLastInterruptPin() returns the source, and clearInterrupts() resets it.
I2C transactions don’t belong in your controller’s interrupt service routine. Set a flag there and read the expander in the main loop. An interrupt doesn’t replace debouncing either: a bouncing button fires several interrupts in quick succession. And because INTCAP holds only the first event’s state until you clear it, fast changes get lost, so don’t use the expander to count fast pulses.
How much current can an MCP23017 output supply?
Forums usually quote 25 mA per pin. That is an absolute maximum rating, not an operating point. The table compares the absolute limits with the figures at which Microchip actually guarantees output levels.
| Parameter | Value | Type |
|---|---|---|
| Current per output, sunk or sourced | 25 mA max. | Absolute maximum |
| Total current out of the VSS ground pin | 150 mA max. | Absolute maximum |
| Total current into the VDD supply pin | 125 mA max. | Absolute maximum |
| Output low at 8 mA sunk (VDD 4.5 V) | 0.6 V max. | Guaranteed operating value |
| Output high at 3 mA sourced (VDD 4.5 V) | VDD − 0.7 V min. | Guaranteed operating value |
The practical rule follows from that: the MCP23017 sinks current much better than it sources it. Wire an LED between the supply and the pin so it lights when the pin goes low. The guaranteed figures apply at 4.5 V, and at a 3.3 V supply the drivers are weaker. Plan on a few milliamps per pin there; a modern LED is clearly visible at 2 to 5 mA. For a red LED with a forward voltage of about 2 V on 3.3 V, 3 mA works out to a series resistor of about 390 Ω.
The total counts too. Sixteen outputs at 10 mA each make 160 mA, above the 150 mA ground-pin limit.
Anything that needs more goes through a driver stage: relay coils, solenoid valves, motors, LED strips or loud buzzers. A logic-level MOSFET or an NPN transistor with a base resistor turns the logic signal into a switch for the load, and coils need a flyback diode. Ready-made relay modules often include it; check that their input switches reliably at 3.3 V.
What does the RAK13003 add to WisBlock?
The RAK13003 packages the MCP23017 as a WisBlock module. It plugs into an IO slot on a WisBlock Base board, measures 25 × 35 mm, and brings all 16 GPIO out to a pin header labeled PA0 to PA7 and PB0 to PB7. Configuration runs over I2C, and RAKwireless specifies standard and fast mode, meaning 100 and 400 kHz.
The module runs on a nominal 3.3 V from the Base board’s switched 3V3_S rail. That puts the logic levels on the header at 3.3 V as well, so 5 V signals don’t belong there directly. RAK specifies a supply current of 1 mA maximum in operation and 1 µA maximum in standby, which suits battery-powered builds. Note that switching off 3V3_S to save power wipes the expander’s configuration, and the outputs don’t hold their state.
The INTA and INTB interrupt outputs are routed to the slot pins IO5 and IO6, and the reset pin goes to the slot’s RST line. According to the RAK documentation, the A2 to A0 address pins are tied to ground by default, so the module answers at 0x20. No switch to change it is documented. The connector also carries SPI lines, but the RAK13003 doesn’t support SPI.
In the official Arduino example, RAK first sets WB_IO2 high, which turns on the 3V3_S rail on WisBlock, and then pulses WB_IO4 low briefly as a reset before calling begin_I2C(). Which controller pin maps to IO5, IO6 and reset on your Base board depends on the slot. RAK’s WisBlock Pin Mapper reveals double use of IO5 and IO6 with other modules.
Where can you buy an MCP23017 module?
Before you pick a module, check four things. First, voltage: the logic levels have to match your controller and the contacts you connect, and on WisBlock that means 3.3 V. Second, the address: find out whether it is fixed and whether it collides with other devices on the bus. Third, the interrupt lines: you can only skip constant polling if INTA or INTB is connected to a controller pin. Fourth, the number of inputs: count on 14 usable inputs, since GPA7 and GPB7 are output-only, and plan driver stages for anything that needs more than a logic signal.
A module that plugs straight into a matching slot in your system saves you loose wires on the I2C bus, which removes a common source of faults.
If you are looking for an MCP23017 GPIO expander on a ready-made module, you will find our expansion modules in the I/O modules category.