Embedded Systems

Hardware Interfacing

GPIO, ADC, UART, I²C, SPI, USB, WiFi, BLE — every wire and protocol your MCU needs to talk to the physical world.

Prerequisites: Basic electronics (voltage, current, resistance) + Binary numbers. That's it.
10
Chapters
10+
Simulations
0
Assumed Knowledge

Chapter 0: The Interfacing Challenge

You've got a microcontroller. It's a tiny computer — maybe an STM32 or an ESP32 — with 3.3V GPIO pins, a few ADC channels, and several serial peripherals. Now you need to connect it to the real world.

The temperature sensor outputs 0–5V analog. The motor needs 12V at 2A. The OLED display speaks SPI at 10MHz. The GPS module transmits UART at 9600 baud. The cloud requires WiFi. Every single connection is a different electrical and protocol challenge.

This is the interfacing problem: your MCU speaks one language (3.3V digital logic), but every peripheral speaks its own dialect with different voltages, currents, speeds, and protocols.

The core challenge: An MCU is useless in isolation. Its value comes from connecting it to sensors, actuators, displays, and networks. Each connection requires understanding the electrical requirements (voltage, current) AND the communication protocol (timing, data format). Get either wrong and nothing works — or worse, you release the magic smoke.

Let's map out the landscape. A typical embedded system might need to talk to 8+ different peripherals simultaneously. Each requires a different interface strategy.

MCU Interface Map

Click each peripheral to see its interface requirements. The central MCU must handle all of these simultaneously.

Click a peripheral to see its requirements.

Notice the variety: some peripherals need just one wire, others need four. Some run at 9600 bits per second, others at 80 million. Some draw microamps, others draw amps. A good embedded engineer knows which interface to use for each situation and how to wire it safely.

Why not just one protocol? Because physics makes trade-offs unavoidable. Fast protocols need more wires and shorter distances. Low-power protocols are slow. Simple protocols can't address multiple devices. Each interface exists because it optimizes for a specific constraint.
InterfaceWiresSpeedRangeBest For
GPIO1MHzcmLEDs, buttons, simple on/off
ADC1kSPS–MSPScmAnalog sensors
UART2115.2 kbpsmetersDebug, GPS, modems
I²C2400 kbpscmSensors, EEPROMs, small displays
SPI4+10–100 MbpscmFast displays, SD cards, flash
USB412–480 Mbps5mPC connection, HID devices
WiFi0 (RF)54+ Mbps50mInternet connectivity
BLE0 (RF)2 Mbps30mLow-power sensor data
Why can't you connect a 5V sensor directly to a 3.3V MCU input pin?

Chapter 1: GPIO — Digital I/O

Every interface starts here. GPIO (General Purpose Input/Output) is the most fundamental peripheral — a pin that can be either HIGH (3.3V) or LOW (0V). Simple, but surprisingly nuanced when you need to reliably drive loads or read noisy signals.

Output Modes: Push-Pull vs Open-Drain

When a GPIO pin is configured as an output, it can drive the line in two ways:

Push-pull actively drives the pin HIGH (connects to VCC through a P-channel MOSFET) or LOW (connects to GND through an N-channel MOSFET). The pin can source current (push) or sink current (pull). This is the default mode — it gives you a strong, fast signal.

Open-drain can only pull the line LOW (N-channel MOSFET to GND) or release it (high-impedance). To get a HIGH, you need an external pull-up resistor. Why would you want this? Two reasons: (1) it allows wire-OR/AND logic where multiple devices share one line, and (2) it enables level shifting — the pull-up can go to 5V even if the MCU is 3.3V.

Think of it this way: Push-pull is like a light switch — it actively pushes the light on or pulls it off. Open-drain is like a hand that can only press a spring-loaded button down. When the hand releases, the spring (pull-up resistor) brings it back up.

Input Modes: Pull-up, Pull-down, Floating

When reading a digital signal, the pin state must be defined at all times. A floating input (no pull-up or pull-down) picks up electrical noise and reads random values when nothing is connected. This is the number one beginner mistake.

Pull-up resistor (typically 10k–100kΩ to VCC): the pin reads HIGH by default. A button press connects it to GND, pulling it LOW. This is the most common configuration for buttons — active-low logic.

Pull-down resistor (to GND): the pin reads LOW by default. An external signal pulls it HIGH. Less common but used when active-high makes the code more intuitive.

Debouncing Buttons

Mechanical buttons don't make clean transitions. When you press a button, the metal contacts bounce for 1–10ms, creating dozens of rapid transitions. Without debouncing, your MCU reads multiple "presses" from one physical press.

Hardware debounce: an RC filter (10kΩ + 100nF = τ = 1ms) smooths the bouncing. The capacitor charges/discharges slowly enough that the bounces are filtered out.

Software debounce: after detecting a transition, ignore further transitions for 20–50ms. Simpler, costs no components, but uses CPU time.

The LED math: An LED needs a current-limiting resistor. Without it, the LED draws unlimited current and burns out instantly. The formula: R = (Vcc − Vf) / I. For a typical red LED (Vf = 2.0V) at 20mA from 3.3V: R = (3.3 − 2.0) / 0.020 = 65Ω. The nearest standard value is 68Ω.

Driving Loads Beyond GPIO Limits

A GPIO pin can typically source/sink only 20mA. Need to drive a motor (500mA), a relay (80mA), or a high-power LED strip (2A)? You need a switch.

The solution: an N-channel MOSFET (e.g., IRLZ44N, 2N7000 for small loads). The GPIO drives the gate, the MOSFET switches the load. Gate HIGH = MOSFET conducts = load powered. Gate LOW = MOSFET off = load disconnected. Add a flyback diode across inductive loads (motors, relays) to catch voltage spikes when switching off.

c
// STM32 HAL: Configure GPIO as push-pull output
GPIO_InitTypeDef gpio = {0};
gpio.Pin = GPIO_PIN_5;
gpio.Mode = GPIO_MODE_OUTPUT_PP;    // Push-pull
gpio.Pull = GPIO_NOPULL;
gpio.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &gpio);

// Toggle LED
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);

// Read button with internal pull-up
gpio.Pin = GPIO_PIN_13;
gpio.Mode = GPIO_MODE_INPUT;
gpio.Pull = GPIO_PULLUP;            // Internal pull-up enabled
HAL_GPIO_Init(GPIOC, &gpio);

if (HAL_GPIO_ReadPin(GPIOC, GPIO_PIN_13) == GPIO_PIN_RESET) {
    // Button pressed (active-low)
}
LED Circuit Simulator

Adjust the resistor value and supply voltage. The LED shows brightness based on current. Too much current = LED blows!

Resistance (Ω) 68
Vcc (V) 3.3
You need to drive a 12V, 500mA DC motor from a 3.3V GPIO pin. What component do you add between the GPIO and the motor?

Chapter 2: ADC — Analog-to-Digital Conversion

The physical world is analog. Temperature, pressure, light intensity, battery voltage — these are continuous quantities. But your MCU thinks in discrete binary numbers. The ADC (Analog-to-Digital Converter) bridges this gap: it samples an analog voltage and converts it to a digital number.

Resolution and Quantization

A 12-bit ADC (common on STM32, ESP32) divides the reference voltage range into 212 = 4096 discrete levels. If the reference voltage Vref = 3.3V, each level represents:

LSB = Vref / 2n = 3.3V / 4096 = 0.000806V = 0.806mV

This LSB (Least Significant Bit) is the smallest voltage change the ADC can detect. A 10-bit ADC gives only 1024 levels (LSB = 3.22mV) — four times coarser. A 16-bit ADC gives 65,536 levels (LSB = 50.4μV) — much finer but slower and more expensive.

Quantization error: Because the ADC rounds to the nearest level, there's always an error of up to ±0.5 LSB. For a 12-bit ADC at 3.3V, that's ±0.4mV. For most applications (temperature, battery monitoring), this is negligible. For audio or precision measurement, you need 16+ bits.

Worked Example: Temperature Sensor (LM35)

The LM35 outputs 10mV per °C. At 25°C, it outputs 250mV. Let's trace the full conversion chain:

Physical
Temperature = 25°C
Sensor Output
V = 25 × 10mV = 250mV
ADC Conversion
Code = 250mV / 0.806mV = 310
Software
V = 310 × 0.806mV = 249.9mV → T = 25.0°C

Sampling Rate and Nyquist

The ADC doesn't read continuously — it takes snapshots at a fixed rate (the sampling rate, measured in SPS — samples per second). The Nyquist theorem says you must sample at least 2× the highest frequency in your signal. If your signal changes at 50Hz (mains hum), you need at least 100 SPS.

What happens if you violate Nyquist? Aliasing: high-frequency components fold back into lower frequencies, creating phantom signals that don't exist in the original. The cure: an analog low-pass filter (RC circuit) before the ADC input that removes frequencies above fsample/2.

fcutoff = 1 / (2πRC)

For a 1kSPS ADC: fNyquist = 500Hz. Set the anti-aliasing filter at ~400Hz: R = 10kΩ, C = 39nF gives fc = 408Hz.

c
// STM32 HAL: Read ADC and convert to temperature
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 100);
uint16_t raw = HAL_ADC_GetValue(&hadc1);

// Convert: 12-bit ADC, Vref = 3.3V, LM35 = 10mV/°C
float voltage = raw * 3.3f / 4096.0f;
float temp_c  = voltage / 0.01f;  // 10mV per degree

// For a thermistor (10k NTC), use Steinhart-Hart:
// R_therm = R_series * (4096 / raw - 1);
// T = 1/(A + B*ln(R) + C*ln(R)^3) - 273.15;
ADC Sampling & Aliasing

The teal wave is the true analog signal. Orange dots are ADC samples. Lower the sample rate to see aliasing — the reconstructed signal (dashed) misrepresents the original.

Signal Freq (Hz) 5
Sample Rate (SPS) 30
A 12-bit ADC with Vref = 3.3V reads a raw value of 2048. What voltage is at the input?

Chapter 3: UART — Serial Communication

You've got an MCU and a GPS module. The GPS wants to send you location data — strings of text like $GPGGA,123519,4807.038,N,01131.000,E.... It doesn't need to be fast, but it needs to be simple. Enter UART (Universal Asynchronous Receiver/Transmitter) — the workhorse serial protocol that's been around since the 1960s.

How UART Works

Asynchronous means there's no shared clock line. Both sides independently generate their own timing from an agreed-upon baud rate (bits per second). Common rates: 9600, 19200, 38400, 57600, 115200. Both devices MUST be set to the same baud rate or the data is garbage.

UART uses two wires: TX (transmit) and RX (receive). TX of device A connects to RX of device B, and vice versa. It's full-duplex — both sides can talk simultaneously.

The UART Frame

Each byte is wrapped in a frame:

Idle
Line HIGH (mark state)
Start bit
Line goes LOW for 1 bit period
Data bits
8 bits, LSB first (the actual byte)
Stop bit
Line goes HIGH for 1 bit period

That's 10 bits to send 8 bits of data (20% overhead). At 115200 baud: 115200 / 10 = 11,520 bytes per second maximum throughput. At 9600 baud: only 960 bytes/sec — fine for GPS NMEA sentences but too slow for high-speed data.

Throughput = baud_rate / bits_per_frame = 115200 / 10 = 11,520 bytes/sec
The timing constraint: If both sides disagree on baud rate by even 3%, the receiver samples at the wrong moment and misreads bits. A 9600-baud sender talking to a 9601-baud receiver is fine (0.01% error). A 9600-baud sender talking to a 19200-baud receiver reads garbage — every bit is half the expected width.

Voltage Levels

MCU-level UART uses TTL logic: HIGH = 3.3V (or 5V), LOW = 0V. The old RS-232 standard used ±12V (HIGH = −12V, LOW = +12V — inverted!). If you need to talk to a PC's RS-232 port, you need a level shifter like the MAX232 chip. Modern USB-to-serial adapters (FTDI, CH340, CP2102) handle this for you.

Common UART Devices

DeviceBaud RateData Format
GPS (NEO-6M)9600NMEA ASCII sentences
ESP8266 AT mode115200AT command strings
Debug console115200printf() text
Bluetooth HC-0538400Transparent bridge
SIM800 GSM9600AT commands
c
// STM32 HAL: Send and receive over UART
char msg[] = "Hello GPS\r\n";
HAL_UART_Transmit(&huart2, (uint8_t*)msg, strlen(msg), 100);

// Receive with DMA (non-blocking)
uint8_t rx_buf[256];
HAL_UART_Receive_DMA(&huart2, rx_buf, 256);

// In callback: parse NMEA
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // Parse: "$GPGGA,123519,4807.038,N,..."
    parse_nmea(rx_buf);
}
UART Waveform Generator

Type a character to see its UART transmission waveform. Each byte is framed with start and stop bits.

ASCII 65 = 0x41 = 01000001
At 9600 baud with 8N1 framing (8 data bits, no parity, 1 stop bit), how many bytes per second can you transmit at maximum?

Chapter 4: I²C — The Multi-Device Bus

You want to connect a temperature sensor, a pressure sensor, an EEPROM, and an OLED display — all with just two wires. I²C (Inter-Integrated Circuit, pronounced "I-squared-C") makes this possible by giving each device an address, so they share the same two lines without conflict.

The Two Wires

SCL (Serial Clock): driven by the master, provides timing for all data transfer. SDA (Serial Data): bidirectional — both master and slave drive this line at different times.

Both lines need pull-up resistors (typically 4.7kΩ to VCC). Why? Because I²C uses open-drain outputs — devices can only pull the line LOW. The pull-ups bring it HIGH when no device is pulling it down. This is what enables multiple devices to share the same wire without short circuits.

The pull-up trade-off: Lower resistance (2.2kΩ) = faster rise time = higher speed possible, but more current drawn. Higher resistance (10kΩ) = less current, but slower edges that limit bus speed. 4.7kΩ is the standard compromise for 100–400kHz operation.

Addressing

Each device has a 7-bit address (some use 10-bit, rare). This means up to 128 devices on one bus in theory. In practice, you get 10-20 before bus capacitance and address conflicts limit you. Addresses are fixed by the chip manufacturer (some chips have address pins for minor adjustment).

DeviceAddress (hex)What It Does
BME2800x76 or 0x77Temperature + humidity + pressure
SSD1306 OLED0x3C or 0x3D128×64 pixel display
24C02 EEPROM0x50–0x57256 bytes non-volatile storage
MPU6050 IMU0x68 or 0x69Accelerometer + gyroscope
ADS1115 ADC0x48–0x4B16-bit precision ADC

The I²C Protocol

Every transaction follows this sequence:

START
SDA goes LOW while SCL is HIGH
Address + R/W
7-bit address + 1-bit read(1)/write(0)
ACK
Slave pulls SDA LOW to acknowledge
Data byte(s)
8 bits each, MSB first, ACK after each
STOP
SDA goes HIGH while SCL is HIGH

The START condition is the unique signal that says "a transaction is beginning." No data pattern can accidentally mimic it because SDA transitions only happen when SCL is LOW during normal data transfer. A START is SDA falling while SCL is HIGH — that only happens deliberately.

Speed Grades

Standard mode: 100kHz. Fast mode: 400kHz. Fast-mode plus: 1MHz. The clock is always driven by the master. If a slave needs more time to process, it can hold SCL LOW (clock stretching), forcing the master to wait.

c
// STM32 HAL: Read temperature from BME280 (0x76)
#define BME280_ADDR  (0x76 << 1)  // HAL needs 8-bit address
#define BME280_TEMP_REG  0xFA

// Write register address, then read 3 bytes
uint8_t reg = BME280_TEMP_REG;
uint8_t data[3];

HAL_I2C_Master_Transmit(&hi2c1, BME280_ADDR, &reg, 1, 100);
HAL_I2C_Master_Receive(&hi2c1, BME280_ADDR, data, 3, 100);

// Combine 20-bit raw temperature
int32_t raw_t = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4);
// Apply compensation formula from BME280 datasheet...
I²C Bus Animation

Watch a complete I²C transaction: START → Address → ACK → Data → STOP. The blue line is SCL (clock), orange line is SDA (data).

Press to send address 0x3C + write + data 0xAE
Why do I²C lines need external pull-up resistors?

Chapter 5: SPI — The Speed Demon

I²C is elegant with its two wires, but sometimes you need raw speed. Streaming pixels to a display at 30fps, reading a flash chip at 80MHz, logging data to an SD card — this is SPI territory. SPI (Serial Peripheral Interface) trades more wires for dramatically higher throughput.

The Four Wires

MOSI (Master Out, Slave In): data from master to slave. MISO (Master In, Slave Out): data from slave to master. SCK (Serial Clock): clock generated by master. CS/SS (Chip Select): active-LOW signal that selects which slave to talk to.

Key difference from I²C: SPI is full-duplex. The master sends and receives simultaneously on every clock cycle. Even if you only want to read, you must clock out dummy bytes to receive data.

Speed comparison: I²C maxes out at 1MHz (fast-mode plus). SPI routinely runs at 10–80MHz — 10 to 80 times faster. An SD card in SPI mode at 25MHz transfers over 3 megabytes per second. The ILI9341 display at 40MHz can push full 320×240 frames at 30+ fps.

No Addressing — Just CS Pins

SPI has no address scheme. Instead, each slave gets its own CS (Chip Select) line. The master pulls CS LOW for the device it wants to talk to; all others ignore the bus. This means N devices need N+3 wires total (MOSI, MISO, SCK shared + one CS per device).

Clock Polarity and Phase (CPOL/CPHA)

SPI has 4 modes defined by two parameters:

CPOL (Clock Polarity): the idle state of the clock. CPOL=0 means clock idles LOW. CPOL=1 means clock idles HIGH.

CPHA (Clock Phase): when data is sampled. CPHA=0 means sample on the FIRST edge (leading). CPHA=1 means sample on the SECOND edge (trailing).

ModeCPOLCPHASample EdgeCommon Devices
000RisingMost sensors, SD card
101FallingSome ADCs
210FallingRare
311RisingW25Q flash, some displays
Getting the mode wrong: If you set Mode 0 but the device expects Mode 3, every bit will be sampled at the wrong moment. The data looks like random garbage. If your SPI device returns all 0xFF or all 0x00, check the clock mode first.
c
// STM32 HAL: Read ID from W25Q128 Flash (SPI Mode 0)
#define W25Q_CS_LOW()   HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET)
#define W25Q_CS_HIGH()  HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET)

uint8_t cmd = 0x9F;  // JEDEC ID command
uint8_t id[3];

W25Q_CS_LOW();
HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
HAL_SPI_Receive(&hspi1, id, 3, 100);
W25Q_CS_HIGH();
// id = {0xEF, 0x40, 0x18} for W25Q128

// SD card init (SPI Mode 0, start at 400kHz, then speed up)
// Send CMD0 (GO_IDLE) with CS low: 0x40,0x00,0x00,0x00,0x00,0x95
SPI Timing Diagram

Select CPOL/CPHA mode and see how clock edges align with data sampling. The blue arrows show when data is sampled.

CPOL 0
CPHA 0
You have 3 SPI devices. How many total wires connect from the MCU to these devices?

Chapter 6: USB — Universal Serial Bus

You plug in a keyboard. It works. You plug in a flash drive. Files appear. You plug in your MCU with a USB cable and a virtual serial port shows up. How does the computer know what each device is and how to talk to it? The answer is enumeration — USB's handshake protocol.

USB Speeds

SpeedRateUse Case
Low Speed1.5 MbpsKeyboards, mice
Full Speed12 MbpsAudio, CDC serial, HID
High Speed480 MbpsMass storage, video
Super Speed (USB 3.0)5 GbpsExternal SSDs, cameras

Enumeration: How the Host Learns About Your Device

When you plug a USB device in, a choreographed dance happens in milliseconds:

1. Attach
Device pulls D+ or D− high (signals speed)
2. Reset
Host drives both lines LOW for 10ms
3. Address
Host assigns unique address (1–127)
4. Descriptors
Host reads device/config/interface descriptors
5. Driver
Host matches class code → loads driver
6. Configured
Device is active and ready for data

Device Classes (No Custom Drivers Needed)

USB defines standard device classes so that OS drivers already know how to talk to your device:

CDC (Communications Device Class): your MCU appears as a virtual serial port (COM port on Windows, /dev/ttyACM0 on Linux). Most common for debug and data exchange. No driver installation on modern OS.

HID (Human Interface Device): keyboards, mice, game controllers. Fixed report format, very low latency, works without drivers everywhere.

MSC (Mass Storage Class): your MCU appears as a USB flash drive. The host can read/write files using a FAT filesystem on your MCU's flash or SD card.

For MCU developers: You don't write a USB stack from scratch. Use a library like TinyUSB (portable, supports STM32/ESP32/RP2040) or the vendor's USB middleware (STM32 USB Device Library). You define descriptors and callbacks; the library handles the protocol.

Descriptors: Your Device's Identity Card

c
// TinyUSB CDC descriptor (simplified)
tusb_desc_device_t const desc_device = {
    .bLength         = sizeof(tusb_desc_device_t),
    .bDescriptorType = TUSB_DESC_DEVICE,
    .bcdUSB          = 0x0200,        // USB 2.0
    .bDeviceClass    = TUSB_CLASS_CDC,
    .idVendor        = 0x1234,        // Your VID
    .idProduct       = 0x5678,        // Your PID
    .bNumConfigurations = 1,
};

// In your main loop:
while (1) {
    tud_task();  // TinyUSB device task (handles USB protocol)

    if (tud_cdc_available()) {
        char buf[64];
        uint32_t count = tud_cdc_read(buf, sizeof(buf));
        // Process received data...
        tud_cdc_write(buf, count);  // Echo back
        tud_cdc_write_flush();
    }
}
USB Enumeration Sequence

Watch the USB enumeration handshake step by step. Click "Plug In" to start the sequence.

Your STM32 project needs to send sensor data to a PC via USB. Which USB device class should you implement?

Chapter 7: WiFi & BLE — Wireless Connectivity

Wires end at the edge of your PCB. Beyond that, you need radio. The two dominant wireless protocols for embedded IoT are WiFi (high bandwidth, connects to existing networks) and BLE (Bluetooth Low Energy — ultra-low power, perfect for sensors). The ESP32 gives you both on one chip for under $4.

WiFi Modes

Station mode (STA): your device connects to an existing access point (your home router). It gets an IP address and can reach the internet. This is what you want for cloud-connected sensors, OTA updates, and web dashboards.

Access Point mode (AP): your device IS the access point. Phones/laptops connect to it directly. No internet required. Perfect for initial configuration ("connect to ESP32_Setup to enter your WiFi password") or local-only systems.

STA+AP: both simultaneously. The ESP32 can be connected to your router while also running its own AP for local configuration.

c
// ESP-IDF: Connect to WiFi in Station mode
wifi_config_t wifi_config = {
    .sta = {
        .ssid = "MyNetwork",
        .password = "MyPassword",
    },
};
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_config(WIFI_IF_STA, &wifi_config);
esp_wifi_start();
esp_wifi_connect();

// After connection: use HTTP, MQTT, WebSocket, etc.

BLE Architecture

BLE is fundamentally different from WiFi. It's not about throughput — it's about sending tiny packets with minimal energy. A BLE sensor can run for years on a coin cell battery.

GAP (Generic Access Profile): handles discovery and connection. A BLE device advertises its existence by broadcasting short packets every 20ms–10s. A central device (phone) scans for these advertisements and initiates a connection.

GATT (Generic Attribute Profile): defines how data is structured once connected. Data is organized into:

Service
A group of related data (e.g., "Heart Rate Service" UUID 0x180D)
Characteristic
A single data point (e.g., "Heart Rate Measurement" UUID 0x2A37)
Descriptor
Metadata about the characteristic (units, format, notifications config)

Operations: Read (pull data on demand), Write (send command to device), Notify (device pushes data when it changes — no polling needed), Indicate (like notify but with acknowledgment).

Notify vs Poll: Without notifications, the central must repeatedly ask "any new data?" which wastes energy on both sides. With notifications enabled, the peripheral stays silent until data changes, then pushes exactly one packet. This is why BLE sensors last years on batteries — the radio is off 99.9% of the time.
c
// ESP-IDF BLE GATT Server: Custom sensor service
#define SENSOR_SVC_UUID  0x00FF  // Custom service
#define TEMP_CHAR_UUID   0xFF01  // Temperature characteristic

// When temperature changes, notify all connected clients:
float temp = read_temperature();
uint8_t value[2];
value[0] = (uint16_t)(temp * 100) & 0xFF;
value[1] = (uint16_t)(temp * 100) >> 8;

esp_ble_gatts_send_indicate(gatts_if, conn_id,
    char_handle, sizeof(value), value, false);  // false = notify
BLE Device Simulator

A virtual BLE peripheral (sensor) and central (phone). Connect, read characteristics, subscribe to notifications, and trigger value changes to see BLE packets flow.

Click Scan to discover the peripheral.

A BLE temperature sensor sends readings every 5 seconds. Should you use Read or Notify?

Chapter 8: RF Options — Beyond WiFi and BLE

WiFi gives you internet but drains batteries in hours. BLE is power-efficient but limited to 30 meters. What if you need to send data 10 kilometers from a remote weather station? Or build a mesh network of 200 smart home devices? Or locate an asset within centimeters indoors? Different RF technologies optimize for different constraints.

LoRa / LoRaWAN

LoRa (Long Range) operates at sub-GHz frequencies (915MHz in US, 868MHz in EU). It trades bandwidth for incredible range: 10+ km line-of-sight, 2–5km in urban areas, at data rates of 0.3–50 kbps. A LoRa sensor can transmit a few bytes every 15 minutes and last 10+ years on 2 AA batteries.

Use case: agriculture sensors, smart city infrastructure, asset tracking across campus. Anything that sends small, infrequent packets over long distances.

Zigbee

Zigbee operates at 2.4GHz (same as WiFi/BLE) but forms mesh networks. Each device can relay messages for others, extending range organically. Up to 65,000 devices per network. 250kbps throughput. Used heavily in home automation (Philips Hue, SmartThings). Being replaced by Thread/Matter in new products.

Thread / Matter

Thread is the next-generation mesh protocol: IPv6-based (every device gets a real IP address), mesh topology, low-power. Matter (formerly Project CHIP) runs on top of Thread (or WiFi) and provides a unified application layer — Apple, Google, Amazon all agreed on this standard. New smart home devices are adopting Matter rapidly.

UWB (Ultra-Wideband)

UWB uses very short pulses across a wide frequency band (3.1–10.6 GHz). It's not for data transfer — it's for precise ranging. Time-of-flight measurement gives centimeter-level accuracy for indoor positioning. Apple's U1 chip, car digital keys (walk-up unlock), and warehouse asset tracking all use UWB.

The fundamental trade-off: Range × Bandwidth × Power = Constant. You can optimize for two, but the third suffers. LoRa picks range + power (sacrifices speed). WiFi picks speed + reasonable range (sacrifices power). BLE picks power + reasonable speed (sacrifices range). There is no free lunch in RF.
TechnologyFrequencyRangeThroughputPowerTopology
LoRaSub-GHz10+ km0.3–50 kbpsVery LowStar
Zigbee2.4 GHz100m250 kbpsLowMesh
Thread2.4 GHz100m250 kbpsLowMesh (IPv6)
UWB3.1–10.6 GHz30m27 MbpsMediumPoint-to-point
NB-IoTCellularKm+250 kbpsMediumStar (cell tower)
WiFi2.4/5 GHz50m54+ MbpsHighStar (AP)
BLE2.4 GHz30m2 MbpsVery LowPoint-to-point/Mesh
RF Technology Comparison

Range vs throughput scatter plot. Bubble size represents relative power consumption. Hover/tap a technology to see details.

Tap a bubble for details.

You're deploying soil moisture sensors across a 5km farm. Each sends 10 bytes every 30 minutes. Which RF technology fits best?

Chapter 9: Mastery — Putting It All Together

You now know eight interface types. The real skill is choosing the right one for each situation and wiring a complete system. Let's build that decision-making muscle.

Interface Selection Decision Tree

The decision process: (1) What's the data rate? (2) How far apart are the devices? (3) How many devices? (4) What's the power budget? (5) Does it need internet access? Answer these five questions and the protocol picks itself.
If you need...Use thisWhy
Simple on/off controlGPIOOne wire, instant, no protocol overhead
Read an analog sensorADCOnly way to convert continuous voltages
Debug output to PCUART (USB CDC)printf() just works, human-readable
Multiple slow sensorsI²C2 wires for many devices, simple
Fast display/storageSPI10–80MHz, no protocol overhead per byte
PC connection (no driver)USB CDC/HIDUniversal, driverless, reliable
Internet connectivityWiFiOnly option that connects to existing networks
Low-power sensor pushBLEYears of battery life, phone-compatible
Long-range low-dataLoRaKilometers of range on coin cells

Voltage Level Shifting

When connecting 3.3V and 5V devices, you need level shifting. Common techniques:

Resistor divider (5V→3.3V, input only): R1 = 1kΩ from signal, R2 = 2kΩ to GND. Output = 5V × 2k/(1k+2k) = 3.33V. Simple but not bidirectional.

MOSFET level shifter (bidirectional): A BSS138 N-channel MOSFET with pull-ups on both sides. Works for I²C, open-drain signals. The SparkFun BOB-12009 breakout does this.

Dedicated IC (TXB0108): 8-channel, auto-direction-sensing, works up to 100MHz. Best for SPI and parallel buses.

Design Challenge: Multi-Interface System

Design an ESP32-based environmental monitor with:

PeripheralInterfacePinsNotes
SSD1306 OLED (128×64)I²C (0x3C)GPIO21 (SDA), GPIO22 (SCL)4.7kΩ pull-ups, 400kHz
SD Card ModuleSPIGPIO23 (MOSI), 19 (MISO), 18 (SCK), 5 (CS)Mode 0, start 400kHz then 25MHz
NEO-6M GPSUARTGPIO16 (RX), GPIO17 (TX)9600 baud, NMEA output
BME280 SensorI²C (0x76)Shared SDA/SCL busSame bus as OLED
BLE (built-in)InternalGATT server, notify temp/humidity

Power budget: ESP32 active WiFi = 240mA. Deep sleep = 10μA. GPS = 45mA. OLED = 20mA. BME280 = 3.6μA sleep, 1mA measuring. SD card = 100mA writing. Total peak: ~400mA → use a 3.7V LiPo with LDO regulator.

Pin conflict check: ESP32 GPIO6-11 are connected to internal flash — never use them. GPIO34-39 are input-only (no pull-ups). GPIO12 affects boot if pulled HIGH. Always check the datasheet's GPIO function table before assigning pins.

Pull-Up Resistor Calculator

Rpull-up = Vcc / Isink(max)

For I²C at 400kHz with bus capacitance Cbus = 100pF: rise time tr ≤ 300ns. Rmax = tr / (0.8473 × Cbus) = 300ns / (0.8473 × 100pF) = 3.5kΩ. So 2.2kΩ–3.3kΩ for fast-mode, 4.7kΩ–10kΩ for standard-mode.

System Design Canvas

Complete system schematic with all interfaces. Click each connection to highlight the bus and see signal details.

Click a connection line to see bus details.

Connections

TopicLesson
Embedded systems introEmbedded Systems From Zero
Real-time operating systemsRTOS & Concurrency
PCB design fundamentalsPCB Design
IoT cloud architectureIoT Cloud Patterns
"The purpose of computing is insight, not numbers." — Richard Hamming. The same applies to hardware interfaces: the purpose of protocols is communication, not wires.
You need to connect a BME280 sensor AND an SSD1306 OLED to the same ESP32. Both are I²C. How many total wires do you need (excluding power)?