The MCP23017 is a 16-bit I²C GPIO expander from Microchip — two independent 8-bit ports (PORTA/PORTB) that add 16 extra digital I/O pins to any microcontroller over a two-wire bus, for boards where I²C is available but spare GPIO pins aren't. It runs directly on a 1.8–5.5 V rail with no level translation needed for 3.3 V or 5 V logic, and up to eight devices can share one bus via three hardware address pins, giving a 7-bit address range of 0x20–0x27. Every input pin supports interrupt-on-change, with INTA/INTB outputs configurable active-high, active-low, or open-drain, so a host can react to a button press or sensor trip without polling. It's the standard part for driving LEDs/relays and reading switches behind I²C instead of spending scarce MCU pins directly on them.
The myth: "It's a dumb GPIO expander — just wire it to the bus and go"
That assumption causes most MCP23017 failures. The part is not dumb, and its defaults are what bite you: every pin powers up as an input, sequential mode is on, the register map is "paired" rather than banked, and the 1.7 MHz mode everyone quotes only works at 5 V. None of this is a bug — it's documented behavior you have to design around. Here's the full picture.
What the MCP23017 actually is
It's a 16-bit general-purpose parallel I/O expansion over I²C — two independent 8-bit ports (PORTA/PORTB) behind a serial bus, for when your MCU has spare I²C but few pins. Key facts:
- Supply range 1.8 V to 5.5 V, so it sits directly on a 3.3 V or 5 V rail with no level translation.
- Three hardware address pins A2:A1:A0 give up to eight devices per bus — 128 GPIO max. The 7-bit address range is 0x20–0x27 (control byte
0100+ A2:A1:A0 + R/W). - GPA7 and GPB7 are output-only on this part — don't route a switch to them.
- Interrupt-on-Change (IOC) on any input pin, with INTA/INTB outputs configurable active-high, active-low, or open-drain, independently or OR'd via IOCON.MIRROR.
Integration: how to wire and configure it
Power and straps. Tie A2:A1:A0 solidly to VDD or VSS — the datasheet explicitly requires external biasing; a floating strap is the classic "no ACK" cause. Same for RESET if you use it: drive it with a defined source or pull-up, don't float it. The datasheet specifies a maximum VDD rise time for POR, so a slow-ramping rail can prevent clean reset release.
I²C pull-ups. All AC timing is characterized at RPU = 1 kΩ with CL = 135 pF — that's the condition the guarantees are anchored to, so start there. Bus capacitance is capped at 400 pF at 100/400 kHz. The SDA/SCL lines have an internal 50 ns spike-suppression filter at ≤400 kHz, so no extra series resistors needed for glitch rejection at normal speeds. For marginal rising-edge timing or long/many-device buses, IOCON.DISSLW enables slew-rate control on the SDA pin (applied when driving high to low) — a part-specific tuning knob worth knowing about.
First writes. On power-up, IODIRA/B = 0xFF — all pins are inputs. Write IODIR before expecting any output to drive. For inputs, set GPPU bits to enable the internal 100 kΩ weak pull-ups (there is no internal pull-down — add your own for a low default). Use IPOL to invert active-low switch readings in hardware instead of software. Leave IOCON.BANK = 0 (the default) if you want the ports addressed as one contiguous 16-bit block — BANK=0 pairs the A and B registers back-to-back at consecutive addresses; only set BANK=1 if you specifically want the ports split into separate A/B register banks.
Interrupts. Configure GPINTEN per pin; interrupts fire on pin change or deviation from a preconfigured DEFVAL. The port value is latched into INTCAP at the moment of change, and the interrupt clears only when GPIO or INTCAP is read. With MIRROR = 1 both INT pins OR together — read the associated port or the line stays asserted. Note the ODR/INTPOL interaction: ODR = 1 (open-drain) overrides INTPOL; active-high INT requires ODR = 0 and INTPOL = 1 — with the default INTPOL = 0, ODR = 0 alone still yields an active-low INT.
Gotchas to design around
-
1.7 MHz is 4.5–5.5 V only. At 3.3 V, treat 400 kHz as the ceiling. Fast+ mode also drops bus capacitance to 100 pF (vs 400 pF) and the spike filter to effectively off (10 ns vs 50 ns).
-
THD:DAT is long at low supply — up to 3.45 µs at 1.8–5.5 V (0.9 µs at 2.7 V+, 0.15 µs at 4.5 V+). A host that releases SDA early after SCL falls can corrupt bytes. Similarly, TAA (output valid) is up to 3.45 µs at 1.8–5.5 V — don't read back too fast at low VDD.
-
Sequential mode is on by default (SEQOP = 0) — the pointer auto-increments after every ACK and rolls over to 0x00 after the last register. Critically: if the host issues Stop or Restart mid-transfer, the in-flight data is not written. Don't abort a block write expecting partial writes to stick. If you want to poll one register, disable auto-increment with SEQOP.
-
BANK=0 is the default map (IODIRA=0x00, IODIRB=0x01, whole map 0x00–0x15). Changing BANK re-partitions the map the instant the byte clocks in — the pointer can land on an invalid address, so the datasheet advises byte-writes only when changing BANK.
-
HAEN does nothing here. It's marked "MCP23S17 only" — don't waste time toggling it expecting an address change on the I²C part.
-
Drive limits. Per-pin drive is capped at 25 mA sink or source, with 700 mW total package power and 125 mA into VDD / 150 mA out of VSS package limits. Clamp current on any pin driven outside 0–VDD is ±20 mA, and absolute pin voltage is −0.6 V to VDD+0.6 V — series resistance on exposed I/O is your protection. ESD rating is 4 kV HBM / 400 V MM, so connector-exposed lines need external protection.
Known limitation: these are absolute-max stress limits, not recommended DC drive levels — our verification pass couldn't fully read the DC Characteristics table, so the VOH/VOL and recommended supply-current numbers are deliberately omitted here. Confirm datasheet §1.1 directly before sizing LED/relay loads.
-
GPIO glitch filter is 150 ns — shorter pulses get suppressed. And an I/O change propagates to INT within ~600 ns (register valid in up to 450 ns), so don't read GPIO instantly from your ISR.
Testing: a bring-up sequence
- Before I²C: verify A0/A1/A2 and RESET are biased, VDD/VSS correct, exposed pad tied to VSS or left open.
- Link check: scope SCL/SDA at 100 kHz, confirm the device ACKs its address. Verify SDA is held valid past the SCL falling edge per THD:DAT.
- POR defaults: read IODIRA/B — expect 0xFF at 0x00/0x01. GPINTEN, IPOL, GPPU, GPIO, OLAT should all read 0x00. That's a fast "is it alive and did it reset" check.
- Round-trip: write a pattern to OLAT, read back via GPIO. Remember sequential mode auto-increments — a Stop mid-block drops the in-flight byte.
- Direction: write IODIR to make pins outputs, drive OLAT, measure. Confirm GPA7/GPB7 aren't assigned as inputs. Enable GPPU on an input and confirm it reads high.
- Interrupts: enable GPINTEN, toggle the pin, watch INTA. Confirm INT clears only after reading GPIO/INTCAP — if it never re-arms, the read-back step was missed. With MIRROR=1, read the associated port.
- Speed sweep: step up through 100 kHz (THIGH 4.0 µs min), 400 kHz (0.6 µs min at 2.7 V+), and only to 1.7 MHz (0.12 µs min) if you're at 4.5–5.5 V with ≤100 pF bus. A fast-mode failure at 3.3 V is an invalid test, not a bug.
Troubleshooting: order of attack
Strap bias → ACK/address → sequential-pointer behavior → IODIR defaults → OLAT vs GPIO → interrupt clear. Most "dead chip" reports trace to one of these:
- No ACK: floating A2:A1:A0. Tie them to rails.
- Writes don't stick: a Stop/Restart aborted a sequential transfer mid-byte.
- Pointer lands wrong: sequential auto-increment is on; disable via SEQOP or rewrite the pointer each access.
- Outputs don't drive: IODIR still 0xFF — pins default to input.
- Readback differs from what you wrote: GPIO reads the pin, OLAT reads the latch. Drive outputs through OLAT; read GPIO to see reality.
- INT never clears: you must read GPIO/INTCAP, and with MIRROR=1 you must read the correct port (A vs B).
- Corrupt bytes at speed: check bus capacitance against 400 pF (≤400 kHz) / 100 pF (1.7 MHz), verify pull-ups against the 1 kΩ/135 pF test condition, and check THD:DAT at your supply voltage — and don't forget the host-side TSU:DAT setup time (up to 250 ns at 1.8–5.5 V): driving SDA too late before the SCL rising edge is another common low-supply corruption cause.
Fix the documented behaviors first; only then suspect the silicon.
Part page: MCP23017.