GPIO and Hardware Interfacing
GPIO (General Purpose Input/Output) pins are the physical interface between your microcontroller and the outside world. Understanding digital I/O, analog conversion, PWM, and serial protocols is fundamental to all IoT hardware work.
Digital I/O
Digital pins have only two states: HIGH (logic 1) and LOW (logic 0).
# MicroPython — digital input and output
from machine import Pin
import time
# Output: control an LED
led = Pin(2, Pin.OUT)
led.on() # HIGH (3.3 V)
led.off() # LOW (0 V)
led.toggle() # Switch state
led.value(1) # Same as on()
print(led.value()) # Read current output state
# Input: read a button (with internal pull-up)
button = Pin(0, Pin.IN, Pin.PULL_UP)
# PULL_UP: pin reads HIGH when button not pressed
# When button connects pin to GND → reads LOW (active-low logic)
while True:
if button.value() == 0: # Button pressed
print("Button pressed!")
led.on()
else:
led.off()
time.sleep_ms(50) # Debounce delayVoltage Levels
| Board | GPIO High | GPIO Low | Max Current Per Pin |
|---|---|---|---|
| ESP32 | 3.3 V | 0 V | 12 mA |
| Raspberry Pi 4 | 3.3 V | 0 V | 16 mA |
| Arduino Uno | 5 V | 0 V | 40 mA |
| Arduino Nano 33 IoT | 3.3 V | 0 V | 7 mA |
Warning: Never connect a 5 V signal directly to an ESP32 or Raspberry Pi GPIO — both are 3.3 V tolerant only. Use a voltage divider or logic level converter.
Interrupts — React Without Polling
Interrupts let the CPU respond to hardware events instantly without wasting cycles in a busy loop.
# MicroPython — external interrupt on GPIO
from machine import Pin
import time
led = Pin(2, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
press_count = 0
def button_pressed(pin):
global press_count
press_count += 1
led.toggle()
print(f"Interrupt! Press #{press_count}")
# Register ISR (Interrupt Service Routine)
# IRQ_FALLING triggers when voltage goes HIGH → LOW (button press)
button.irq(trigger=Pin.IRQ_FALLING, handler=button_pressed)
print("Waiting for button presses...")
while True:
time.sleep(1)
print(f"Main loop running... presses: {press_count}")Best Practice: Keep interrupt handlers short and fast. Set a flag inside the ISR and do the heavy work in the main loop.
Analog-to-Digital Conversion (ADC)
Most sensors output analog voltages (continuously variable). ADCs convert these to digital numbers your code can work with.
# ESP32 MicroPython — ADC reading
from machine import ADC, Pin
import time
# ESP32 ADC: 12-bit → values from 0 to 4095
# GPIO 34–39 are input-only, best for ADC
adc = ADC(Pin(34))
adc.atten(ADC.ATTN_11DB) # Input range: 0–3.3 V (full range)
adc.width(ADC.WIDTH_12BIT) # 12-bit resolution
# Attenuation options:
# ATTN_0DB → 0–1.0 V
# ATTN_2_5DB → 0–1.34 V
# ATTN_6DB → 0–2.0 V
# ATTN_11DB → 0–3.3 V (use for most sensors)
def read_voltage():
raw = adc.read()
voltage = raw * 3.3 / 4095
return raw, round(voltage, 3)
# LDR (light-dependent resistor) brightness mapping
def read_brightness():
raw, voltage = read_voltage()
# LDR in voltage divider: high voltage = dark, low voltage = bright
brightness = 100 - (voltage / 3.3 * 100)
return round(brightness, 1)
while True:
raw, v = read_voltage()
print(f"ADC raw: {raw:4d} | Voltage: {v:.3f} V | Brightness: {read_brightness():.1f}%")
time.sleep(0.5)ADC Resolution and Accuracy
| Resolution | Levels | Step Size (0–3.3V) |
|---|---|---|
| 8-bit | 256 | 12.9 mV |
| 10-bit | 1024 | 3.2 mV |
| 12-bit | 4096 | 0.8 mV |
| 16-bit | 65536 | 0.05 mV |
PWM — Analog-like Output
PWM (Pulse Width Modulation) rapidly switches a digital pin ON and OFF to simulate an analog output. The duty cycle controls the effective voltage.
# MicroPython — PWM for LED brightness control
from machine import Pin, PWM
import time
led = PWM(Pin(2), freq=1000) # 1 kHz frequency
# Duty cycle: 0 (off) to 1023 (full on) in MicroPython
# (Some versions use 0–65535)
def set_brightness(percent):
"""Set LED brightness from 0 to 100%."""
duty = int(percent / 100 * 1023)
led.duty(duty)
# Fade in and out
print("Fading LED...")
while True:
# Fade in
for brightness in range(0, 101, 2):
set_brightness(brightness)
time.sleep_ms(20)
# Fade out
for brightness in range(100, -1, -2):
set_brightness(brightness)
time.sleep_ms(20)PWM Applications
| Application | Typical Frequency | Notes |
|---|---|---|
| LED dimming | 1–10 kHz | Human eye cannot detect flicker |
| DC motor speed | 1–20 kHz | Higher = quieter, less heat |
| Servo control | 50 Hz | Position encoded in pulse width |
| Buzzer tone | 100 Hz – 10 kHz | Frequency = note pitch |
| Power supply | 50–500 kHz | Switching regulators |
I2C Protocol
I2C (Inter-Integrated Circuit) is a two-wire serial protocol allowing multiple devices on the same bus. Each device has a unique 7-bit address.
SDA ────┬───────┬───────┬──── (data)
│ │ │
MCU BME280 OLED
SCL ────┴───────┴───────┴──── (clock)# MicroPython — I2C bus scan and usage
from machine import I2C, Pin
# I2C0: SCL=GPIO22, SDA=GPIO21 (common ESP32 pins)
i2c = I2C(0, scl=Pin(22), sda=Pin(21), freq=400_000)
# Scan for connected devices
devices = i2c.scan()
print("I2C devices found:")
for addr in devices:
print(f" 0x{addr:02X}") # e.g. 0x76 (BME280), 0x3C (OLED)
# Read from a device (example: BH1750 light sensor at 0x23)
BH1750_ADDR = 0x23
POWER_ON = b'\x01'
CONT_HMODE = b'\x10' # Continuously measure at 1 lx resolution
i2c.writeto(BH1750_ADDR, POWER_ON)
i2c.writeto(BH1750_ADDR, CONT_HMODE)
import time
time.sleep_ms(200)
data = i2c.readfrom(BH1750_ADDR, 2)
lux = (data[0] << 8 | data[1]) / 1.2
print(f"Light: {lux:.1f} lx")Common I2C Addresses
| Device | Default Address |
|---|---|
| BME280 | 0x76 (0x77 alt) |
| BH1750 light | 0x23 (0x5C alt) |
| SSD1306 OLED | 0x3C (0x3D alt) |
| ADS1115 ADC | 0x48 |
| MPU-6050 IMU | 0x68 (0x69 alt) |
| DS3231 RTC | 0x57 |
SPI Protocol
SPI (Serial Peripheral Interface) is faster than I2C and used for high-speed devices like displays, SD cards, and ADCs. It requires 4 wires.
MOSI → Master Out Slave In (data from MCU to device)
MISO → Master In Slave Out (data from device to MCU)
SCK → Clock
CS → Chip Select (one per device, active LOW)# MicroPython — SPI with SD card
from machine import SPI, Pin
import sdcard, os
spi = SPI(1,
baudrate=1_000_000, # 1 MHz for init, up to 40 MHz after
polarity=0,
phase=0,
sck=Pin(18),
mosi=Pin(23),
miso=Pin(19))
cs = Pin(5, Pin.OUT, value=1) # CS idle HIGH
sd = sdcard.SDCard(spi, cs)
os.mount(sd, '/sd')
# Now use it like a normal file system
with open('/sd/data.csv', 'a') as f:
f.write("timestamp,temperature,humidity\n")
f.write("1704067200,24.5,61.2\n")
files = os.listdir('/sd')
print(f"Files on SD: {files}")UART / Serial Communication
UART is the simplest serial protocol — two wires (TX, RX), no clock, asynchronous.
# MicroPython — UART for GPS module (NEO-6M)
from machine import UART
import time
# GPS outputs NMEA sentences at 9600 baud by default
gps = UART(2, baudrate=9600, tx=17, rx=16)
def parse_gprmc(sentence):
"""Parse GPRMC sentence for lat/lon."""
parts = sentence.split(',')
if len(parts) < 7 or parts[2] != 'A':
return None # No fix
lat_raw = float(parts[3])
lat_dir = parts[4]
lon_raw = float(parts[5])
lon_dir = parts[6]
# Convert from DDMM.MMMMM to decimal degrees
lat = int(lat_raw / 100) + (lat_raw % 100) / 60
lon = int(lon_raw / 100) + (lon_raw % 100) / 60
if lat_dir == 'S': lat = -lat
if lon_dir == 'W': lon = -lon
return lat, lon
while True:
if gps.any():
line = gps.readline()
try:
sentence = line.decode('ascii').strip()
if sentence.startswith('$GPRMC'):
coords = parse_gprmc(sentence)
if coords:
print(f"GPS: {coords[0]:.6f}, {coords[1]:.6f}")
except:
passProtocol Comparison
| Protocol | Wires | Speed | Distance | Devices | Use Case |
|---|---|---|---|---|---|
| GPIO Digital | 1 | — | Short | 1:1 | Buttons, LEDs, relays |
| ADC | 1 | — | Short | 1:1 | Analog sensors |
| PWM | 1 | — | Short | 1:1 | LEDs, servos, motors |
| I2C | 2 (+ power) | 400 kHz–3.4 MHz | < 1 m | Up to 127 | Sensors, displays, ADC |
| SPI | 4 (+ 1 CS each) | 10–100 MHz | < 1 m | Limited | SD cards, displays, ADC |
| UART | 2 (+ power) | Up to 115,200 baud | Meters | 1:1 | GPS, GSM modules |
Summary
- GPIO digital pins are HIGH or LOW; always match voltage levels between devices
- Interrupts let you react to hardware events without blocking the main loop
- ADC converts analog voltages to 12-bit digital numbers (ESP32: 0–4095)
- PWM simulates analog output by varying the duty cycle
- I2C is a two-wire protocol supporting multiple devices via unique addresses
- SPI is faster than I2C, four-wire, used for displays and SD cards
- UART is simple two-wire asynchronous serial, used for GPS and GSM modules
Next, we'll look at power management — how to run IoT devices for months on a single battery.