Hardware Fundamentals

GPIO and Hardware Interfacing

Master digital I/O, analog reading, PWM, and serial protocols — I2C, SPI, and UART — for connecting sensors and peripherals to microcontrollers.

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).

python
# 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 delay

Voltage Levels

BoardGPIO HighGPIO LowMax Current Per Pin
ESP323.3 V0 V12 mA
Raspberry Pi 43.3 V0 V16 mA
Arduino Uno5 V0 V40 mA
Arduino Nano 33 IoT3.3 V0 V7 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.

python
# 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.

python
# 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

ResolutionLevelsStep Size (0–3.3V)
8-bit25612.9 mV
10-bit10243.2 mV
12-bit40960.8 mV
16-bit655360.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.

python
# 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

ApplicationTypical FrequencyNotes
LED dimming1–10 kHzHuman eye cannot detect flicker
DC motor speed1–20 kHzHigher = quieter, less heat
Servo control50 HzPosition encoded in pulse width
Buzzer tone100 Hz – 10 kHzFrequency = note pitch
Power supply50–500 kHzSwitching 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)
python
# 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

DeviceDefault Address
BME2800x76 (0x77 alt)
BH1750 light0x23 (0x5C alt)
SSD1306 OLED0x3C (0x3D alt)
ADS1115 ADC0x48
MPU-6050 IMU0x68 (0x69 alt)
DS3231 RTC0x57

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)
python
# 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.

python
# 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:
            pass

Protocol Comparison

ProtocolWiresSpeedDistanceDevicesUse Case
GPIO Digital1—Short1:1Buttons, LEDs, relays
ADC1—Short1:1Analog sensors
PWM1—Short1:1LEDs, servos, motors
I2C2 (+ power)400 kHz–3.4 MHz< 1 mUp to 127Sensors, displays, ADC
SPI4 (+ 1 CS each)10–100 MHz< 1 mLimitedSD cards, displays, ADC
UART2 (+ power)Up to 115,200 baudMeters1:1GPS, 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.