PCF8574: Eight Extra Pins Over I2C — Buttons, LEDs and an Interrupt
The chip on the back of every LCD 1602 I2C backpack also works as a pin expander. We look at its quasi-bidirectional ports, why an LED must be switched on with LOW, and how the INT pin saves you from polling the bus all the time.
About the project
The PCF8574 adds 8 digital pins, P0–P7, to any board with I2C. One bus can take up to 8 of these chips (addresses 0x20–0x27) plus 8 PCF8574As (0x38–0x3F) — up to 128 pins on two wires. In this project four buttons toggle four LEDs, and the microcontroller learns about presses through an interrupt.
What you need
- ESP32 DevKit (or an Arduino)
- A PCF8574 module (the blue board with A0–A2 jumpers)
- 4 buttons, 4 LEDs, 4 × 330 Ω resistors
Wiring
| PCF8574 | ESP32 |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SDA | GPIO21 |
| SCL | GPIO22 |
| INT | GPIO4 |
| P0–P3 | LEDs: 3V3 → resistor → anode, cathode → P0…P3 |
| P4–P7 | buttons: P4…P7 → button → GND |
The A0–A2 jumpers set the three low address bits: all to GND gives 0x20.
A quasi-bidirectional port
The PCF8574 has no pinMode(). Each pin either pulls to GND with a strong transistor (up to 25 mA) or, when you write 1, barely pulls up to VCC with about 100 µA. Two rules follow:
- An input is a pin you wrote 1 to. A button to GND easily beats the weak pull-up, and
read()returns 0. - An LED lights on LOW. Wire it from VCC through a resistor to the pin: at
0the chip sinks the current. An LED from the pin to GND barely glows from those 100 µA. The simulator spots that wiring and tells you.
The chip handles up to 80 mA in total through GND, so eight LEDs at 10 mA each are fine.
Why INT
Polling I2C in every loop() is wasted traffic and delay. The INT output (open drain, active LOW) goes low as soon as any input changes and returns HIGH after the port is read. Connect it to a GPIO with a pull-up and read the PCF8574 only after an interrupt sets a flag.
Code
// 4 buttons and 4 LEDs on two I2C wires: a PCF8574 expander
#include <Wire.h>
#include <PCF8574.h>
PCF8574 pcf(0x20);
const int INT_PIN = 4; // the module's INT: open drain, active LOW
volatile bool changed = true; // true: read the state right after start-up
void IRAM_ATTR onChange() { changed = true; }
uint8_t leds = 0; // bit = LED on
uint8_t lastButtons = 0;
void setup() {
Serial.begin(115200);
Wire.begin();
pcf.begin(0xFF); // all pins at 1: inputs with pull-ups, LEDs off
if (!pcf.isConnected()) {
Serial.println("PCF8574 not found");
while (true) delay(1000);
}
pinMode(INT_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(INT_PIN), onChange, FALLING);
}
void loop() {
if (!changed) return; // nothing changed — leave the bus alone
changed = false;
uint8_t buttons = (~pcf.read8() >> 4) & 0x0F; // P4..P7, 1 = pressed
uint8_t pressedNow = buttons & ~lastButtons; // just pressed
lastButtons = buttons;
if (!pressedNow) return;
leds ^= pressedNow; // each button toggles its LED
pcf.write8(0xF0 | (~leds & 0x0F)); // P4..P7 stay inputs (1), an LED lights at 0
Serial.printf("LEDs: %d %d %d %d\n", leds & 1, (leds >> 1) & 1, (leds >> 2) & 1, (leds >> 3) & 1);
}
Note the write8(0xF0 | …): the high bits are always written as ones, otherwise the buttons stop being inputs.
Another library, another syntax
There is also Renzo Mischianti's library with the same PCF8574.h name and an Arduino-style API: pcf8574.pinMode(P0, OUTPUT); pcf8574.begin(); pcf8574.digitalWrite(P0, LOW);. Underneath it is the same quasi-bidirectional port, so "an LED lights on LOW" applies there too. The simulator supports both.
What next
- A 4×4 keypad on one PCF8574: rows as outputs, columns as inputs.
- Active-LOW relay modules fit a PCF8574 perfectly.
- An LCD 1602 I2C is the same PCF8574 soldered to a display. If an I2C scanner finds 0x27 or 0x3F, that is what it is.