GPIO, ADC, UART, I²C, SPI, USB, WiFi, BLE — every wire and protocol your MCU needs to talk to the physical world.
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.
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.
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.
| Interface | Wires | Speed | Range | Best For |
|---|---|---|---|---|
| GPIO | 1 | MHz | cm | LEDs, buttons, simple on/off |
| ADC | 1 | kSPS–MSPS | cm | Analog sensors |
| UART | 2 | 115.2 kbps | meters | Debug, GPS, modems |
| I²C | 2 | 400 kbps | cm | Sensors, EEPROMs, small displays |
| SPI | 4+ | 10–100 Mbps | cm | Fast displays, SD cards, flash |
| USB | 4 | 12–480 Mbps | 5m | PC connection, HID devices |
| WiFi | 0 (RF) | 54+ Mbps | 50m | Internet connectivity |
| BLE | 0 (RF) | 2 Mbps | 30m | Low-power sensor data |
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.
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.
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.
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.
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) }
Adjust the resistor value and supply voltage. The LED shows brightness based on current. Too much current = LED blows!
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.
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:
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.
The LM35 outputs 10mV per °C. At 25°C, it outputs 250mV. Let's trace the full conversion chain:
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.
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;
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.
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.
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.
Each byte is wrapped in a frame:
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.
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.
| Device | Baud Rate | Data Format |
|---|---|---|
| GPS (NEO-6M) | 9600 | NMEA ASCII sentences |
| ESP8266 AT mode | 115200 | AT command strings |
| Debug console | 115200 | printf() text |
| Bluetooth HC-05 | 38400 | Transparent bridge |
| SIM800 GSM | 9600 | AT 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); }
Type a character to see its UART transmission waveform. Each byte is framed with start and stop bits.
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.
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.
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).
| Device | Address (hex) | What It Does |
|---|---|---|
| BME280 | 0x76 or 0x77 | Temperature + humidity + pressure |
| SSD1306 OLED | 0x3C or 0x3D | 128×64 pixel display |
| 24C02 EEPROM | 0x50–0x57 | 256 bytes non-volatile storage |
| MPU6050 IMU | 0x68 or 0x69 | Accelerometer + gyroscope |
| ADS1115 ADC | 0x48–0x4B | 16-bit precision ADC |
Every transaction follows this sequence:
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.
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, ®, 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...
Watch a complete I²C transaction: START → Address → ACK → Data → STOP. The blue line is SCL (clock), orange line is SDA (data).
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.
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.
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).
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).
| Mode | CPOL | CPHA | Sample Edge | Common Devices |
|---|---|---|---|---|
| 0 | 0 | 0 | Rising | Most sensors, SD card |
| 1 | 0 | 1 | Falling | Some ADCs |
| 2 | 1 | 0 | Falling | Rare |
| 3 | 1 | 1 | Rising | W25Q flash, some displays |
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
Select CPOL/CPHA mode and see how clock edges align with data sampling. The blue arrows show when data is sampled.
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.
| Speed | Rate | Use Case |
|---|---|---|
| Low Speed | 1.5 Mbps | Keyboards, mice |
| Full Speed | 12 Mbps | Audio, CDC serial, HID |
| High Speed | 480 Mbps | Mass storage, video |
| Super Speed (USB 3.0) | 5 Gbps | External SSDs, cameras |
When you plug a USB device in, a choreographed dance happens in milliseconds:
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.
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(); } }
Watch the USB enumeration handshake step by step. Click "Plug In" to start the sequence.
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.
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 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:
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).
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
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.
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 (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 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 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 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.
| Technology | Frequency | Range | Throughput | Power | Topology |
|---|---|---|---|---|---|
| LoRa | Sub-GHz | 10+ km | 0.3–50 kbps | Very Low | Star |
| Zigbee | 2.4 GHz | 100m | 250 kbps | Low | Mesh |
| Thread | 2.4 GHz | 100m | 250 kbps | Low | Mesh (IPv6) |
| UWB | 3.1–10.6 GHz | 30m | 27 Mbps | Medium | Point-to-point |
| NB-IoT | Cellular | Km+ | 250 kbps | Medium | Star (cell tower) |
| WiFi | 2.4/5 GHz | 50m | 54+ Mbps | High | Star (AP) |
| BLE | 2.4 GHz | 30m | 2 Mbps | Very Low | Point-to-point/Mesh |
Range vs throughput scatter plot. Bubble size represents relative power consumption. Hover/tap a technology to see details.
Tap a bubble for details.
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.
| If you need... | Use this | Why |
|---|---|---|
| Simple on/off control | GPIO | One wire, instant, no protocol overhead |
| Read an analog sensor | ADC | Only way to convert continuous voltages |
| Debug output to PC | UART (USB CDC) | printf() just works, human-readable |
| Multiple slow sensors | I²C | 2 wires for many devices, simple |
| Fast display/storage | SPI | 10–80MHz, no protocol overhead per byte |
| PC connection (no driver) | USB CDC/HID | Universal, driverless, reliable |
| Internet connectivity | WiFi | Only option that connects to existing networks |
| Low-power sensor push | BLE | Years of battery life, phone-compatible |
| Long-range low-data | LoRa | Kilometers of range on coin cells |
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 an ESP32-based environmental monitor with:
| Peripheral | Interface | Pins | Notes |
|---|---|---|---|
| SSD1306 OLED (128×64) | I²C (0x3C) | GPIO21 (SDA), GPIO22 (SCL) | 4.7kΩ pull-ups, 400kHz |
| SD Card Module | SPI | GPIO23 (MOSI), 19 (MISO), 18 (SCK), 5 (CS) | Mode 0, start 400kHz then 25MHz |
| NEO-6M GPS | UART | GPIO16 (RX), GPIO17 (TX) | 9600 baud, NMEA output |
| BME280 Sensor | I²C (0x76) | Shared SDA/SCL bus | Same bus as OLED |
| BLE (built-in) | Internal | — | GATT 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.
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.
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.
| Topic | Lesson |
|---|---|
| Embedded systems intro | Embedded Systems From Zero |
| Real-time operating systems | RTOS & Concurrency |
| PCB design fundamentals | PCB Design |
| IoT cloud architecture | IoT Cloud Patterns |