Part 4 · Professional Firmware36 min read
Communication Stacks — USB, CAN, Ethernet, BLE, and IoT
UART, SPI, and I2C connect chips on one board. Products talk to the outside world through richer links: USB to a PC, CAN to the rest of a vehicle or machine, Ethernet and Wi-Fi to a network, Bluetooth Low Energy to a phone. Each of these comes with a protocol stack far too large to write yourself on a deadline. This lesson explains what each link does on the wire, what the stack does for you, how to integrate the common open-source stacks on an STM32F407-class part, where the traps are, and how to choose.
What a stack is
Section titled “What a stack is”A protocol stack is a layered set of software that turns raw bits on a wire into application messages. Your UART shell from Part 2 was a two-layer stack: the UART driver moved bytes, the framing layer turned bytes into commands. USB, TCP/IP, and BLE stacks have the same idea with five to seven layers and tens of thousands of lines of code.
Integrating a stack always involves the same four jobs:
- The port layer. The stack calls functions you provide: “send this packet”, “give me a timestamp”, “lock this mutex”. You connect those to your drivers and RTOS (or bare metal loop).
- Configuration. A header full of
#defines that sizes buffers, enables features, and sets limits. These choices decide your RAM usage. - Threading model. Who calls the stack and when: from the main loop, from a dedicated task, from interrupts. Getting this wrong is the source of most stack bugs.
- Application callbacks. The stack calls you when data arrives or a connection changes.
Keep those four in mind for every section below.
USB device basics
Section titled “USB device basics”USB is a host-centric bus. The host (a PC, a phone) initiates every transaction; the device only ever answers. This is the single most important fact about USB firmware: you cannot “send” data whenever you want, you can only leave it in a buffer for the host to collect at its next poll.
Speeds and endpoints
Section titled “Speeds and endpoints”| Speed | Bit rate | Max bulk packet | STM32F407 support |
|---|---|---|---|
| Low speed | 1.5 Mbit/s | 8 bytes (no bulk) | OTG FS core |
| Full speed | 12 Mbit/s | 64 bytes | OTG FS core, PA11 (DM) / PA12 (DP) |
| High speed | 480 Mbit/s | 512 bytes | OTG HS core, needs an external ULPI PHY |
An endpoint is a numbered buffer in the device, with a direction (IN means device to host, OUT means host to device) and a transfer type:
| Type | Guarantees | Used for |
|---|---|---|
| Control | Delivered, acknowledged, structured as setup/data/status | Endpoint 0, enumeration, class requests |
| Bulk | Delivered and checked, no bandwidth guarantee | Serial data, mass storage |
| Interrupt | Polled at a guaranteed interval, small packets | Keyboards, mice, HID, status notifications |
| Isochronous | Guaranteed bandwidth, no retries | Audio, video |
Endpoint addresses carry the direction in bit 7: 0x81 is endpoint 1 IN, 0x02 is endpoint 2 OUT. Every device has endpoint 0 for control.
Descriptors and enumeration
Section titled “Descriptors and enumeration”A device describes itself with descriptors, byte structures defined by the USB specification. The device descriptor holds the vendor ID (VID), product ID (PID), and class. The configuration descriptor holds interfaces, each interface holds endpoints, and string descriptors hold human-readable names in UTF-16.
Enumeration is the conversation in which the host learns all of this:
Device Host | pull-up on D+ (full speed attach) | |-------------------------------------->| detects attach |<--------------------------- bus reset | |<----- GET_DESCRIPTOR(device, 64 bytes)| learn ep0 max packet size |---- device descriptor --------------->| |<----------------------- SET_ADDRESS(n)| device now answers at address n |<----------- GET_DESCRIPTOR(device) | |<----------- GET_DESCRIPTOR(config) | interfaces, endpoints |<----------- GET_DESCRIPTOR(strings) | |<------------------ SET_CONFIGURATION(1)| endpoints become active |<---- class requests (e.g. SET_LINE_CODING for CDC)The host imposes timeouts at each step (on the order of tens of milliseconds for some of them). A device that is busy erasing flash or sitting in a long ISR when a setup packet arrives fails enumeration, and the PC shows “device descriptor request failed”.
Classes
Section titled “Classes”A class is a standard protocol for a kind of device, so the operating system can drive it without a custom driver.
| Class | Appears as | Typical firmware use |
|---|---|---|
| CDC-ACM | Virtual COM port (/dev/ttyACM0, COM5) | Console, shell, data streaming |
| HID | Keyboard, mouse, or generic HID device | Driverless control panels, small data |
| MSC | USB drive | Exposing a log file system, drag-and-drop update |
| DFU | Firmware update device | Bootloader updates with dfu-util |
A composite device exposes several interfaces at once, for example CDC for a console plus DFU for updates. Composite devices that include CDC need an Interface Association Descriptor (IAD) so the host groups CDC’s two interfaces together. Getting the IAD wrong is a common reason a composite device works on Linux but not on Windows.
TinyUSB on the STM32F407
Section titled “TinyUSB on the STM32F407”TinyUSB is a small, portable, MIT-licensed USB stack supporting device and host mode on dozens of MCU families, including the STM32F4’s OTG cores. It uses no dynamic memory and defers all work out of interrupt context into a task function you call.
The configuration header selects the MCU, the role, and the classes:
#ifndef TUSB_CONFIG_H#define TUSB_CONFIG_H
#define CFG_TUSB_MCU OPT_MCU_STM32F4#define CFG_TUSB_OS OPT_OS_NONE#define CFG_TUSB_RHPORT0_MODE (OPT_MODE_DEVICE | OPT_MODE_FULL_SPEED)
#define CFG_TUD_ENDPOINT0_SIZE 64#define CFG_TUD_CDC 1#define CFG_TUD_CDC_RX_BUFSIZE 256#define CFG_TUD_CDC_TX_BUFSIZE 256
#endifThe descriptors are yours to supply, through callbacks. TinyUSB provides macros that expand to correctly laid-out byte arrays:
#include "tusb.h"
enum { ITF_NUM_CDC = 0, ITF_NUM_CDC_DATA, ITF_NUM_TOTAL };
#define EPNUM_CDC_NOTIF 0x81#define EPNUM_CDC_OUT 0x02#define EPNUM_CDC_IN 0x82#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_CDC_DESC_LEN)
static const tusb_desc_device_t desc_device = { .bLength = sizeof(tusb_desc_device_t), .bDescriptorType = TUSB_DESC_DEVICE, .bcdUSB = 0x0200, .bDeviceClass = TUSB_CLASS_MISC, /* required with IAD */ .bDeviceSubClass = MISC_SUBCLASS_COMMON, .bDeviceProtocol = MISC_PROTOCOL_IAD, .bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE, .idVendor = 0xCAFE, /* test VID only, never ship */ .idProduct = 0x4001, .bcdDevice = 0x0100, .iManufacturer = 1, .iProduct = 2, .iSerialNumber = 3, .bNumConfigurations = 1};
static const uint8_t desc_config[] = { TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100), TUD_CDC_DESCRIPTOR(ITF_NUM_CDC, 4, EPNUM_CDC_NOTIF, 8, EPNUM_CDC_OUT, EPNUM_CDC_IN, 64),};
const uint8_t *tud_descriptor_device_cb(void){ return (const uint8_t *)&desc_device;}
const uint8_t *tud_descriptor_configuration_cb(uint8_t index){ (void)index; return desc_config;}The device class triple TUSB_CLASS_MISC / MISC_SUBCLASS_COMMON / MISC_PROTOCOL_IAD tells the host to look for an IAD, which TUD_CDC_DESCRIPTOR emits. The CDC descriptor declares a notification endpoint (0x81, 8 bytes, interrupt) and a bulk pair for data (0x02 OUT, 0x82 IN, 64 bytes each, the full-speed maximum). The last two numbers in TUD_CONFIG_DESCRIPTOR are attributes and maximum current in milliamps. A string descriptor callback, tud_descriptor_string_cb(), is also required; it converts ASCII strings to UTF-16 and is copied almost verbatim from the TinyUSB examples. The 0xCAFE VID is a placeholder used in TinyUSB examples: a shipping product needs a VID you own or a PID allocated to you by someone who does.
The application is a loop that calls tud_task() and handles data:
#include <stdint.h>#include "tusb.h"#include "board.h"
static void cdc_echo_task(void){ if (!tud_cdc_available()) { return; } uint8_t buf[64]; uint32_t n = tud_cdc_read(buf, sizeof buf); tud_cdc_write(buf, n); tud_cdc_write_flush();}
void OTG_FS_IRQHandler(void){ tud_int_handler(0); /* root hub port 0 = OTG FS */}
int main(void){ board_init(); /* clocks (48 MHz USB), GPIO AF10, OTG clock */ tusb_init(); for (;;) { tud_task(); /* processes events queued by the ISR */ cdc_echo_task(); }}tud_int_handler() does the minimum in interrupt context: it reads the OTG core’s status and queues events. tud_task() runs the enumeration state machine and class drivers in thread context. If your main loop blocks for 50 ms, enumeration stalls for 50 ms. With an RTOS, put tud_task() in its own task at a fairly high priority. tud_cdc_write() copies into the TX FIFO and returns how many bytes fit; production code checks that return value instead of assuming everything was accepted.
board_init() must provide three things the USB core cannot work without. First, a 48 MHz clock for the OTG FS core: with an 8 MHz HSE and the PLL at M=8, N=336, the P divider of 2 gives 168 MHz for the system and a Q divider of 7 gives exactly 48 MHz. USB full speed needs that clock accurate to about 0.25%, which rules out running from the internal HSI. Second, PA11 and PA12 in alternate function 10. Third, the OTG FS peripheral clock enable on the AHB2 bus.
USB pitfalls
Section titled “USB pitfalls”- Descriptor errors. A wrong
wTotalLength, a mismatched interface count, or a missing IAD causes silent enumeration failure. Use a USB protocol analyzer or, cheaper, Wireshark with USBPcap (Windows) orusbmon(Linux) to see where the host gives up. - VBUS sensing. The OTG core can monitor the 5 V VBUS line to know when a cable is attached. If your board does not route VBUS to the sensing pin (PA9 on the F407), you must disable VBUS sensing in the core’s configuration, or the device never connects. The Discovery board does route it; many custom boards do not.
- Timing. Long critical sections, flash erases (which stall the bus on a single-bank F4, as Flash, EEPROM, and Non-Volatile Storage explains), or a debugger halt during enumeration all cause the host to give up. Halting at a breakpoint while connected usually makes the host reset the device.
- Self-powered versus bus-powered. A bus-powered device must draw no more than its declared current and must enter suspend (under 2.5 mA) when the host suspends the bus. Many hobby designs ignore this; certification does not.
Controller Area Network was designed for cars and is now everywhere in industrial machines, robots, e-bikes, and medical equipment. It is a multi-master bus on a twisted pair, robust against noise, with built-in arbitration and error handling.
The physical bus
Section titled “The physical bus”CAN uses two wires, CANH and CANL, driven differentially by a transceiver chip (for example a TJA1050-class part). The MCU’s CAN controller connects to the transceiver with plain TX and RX logic signals. The bus has two states: dominant (logic 0, the wires driven apart) and recessive (logic 1, the wires relaxed together). Any node driving dominant wins over any node leaving it recessive. The bus is terminated with 120 Ω at each end; a missing terminator is the first thing to check when a CAN bus does not work.
Arbitration by ID
Section titled “Arbitration by ID”Every frame starts with an identifier (11 bits standard, 29 bits extended). All nodes that want to transmit start together and send their ID bit by bit while listening. A node that sends recessive but reads dominant has lost and stops transmitting immediately, with no corruption to the winner’s frame. The result: the lowest ID always wins, and IDs are therefore priorities. Assign the lowest IDs to the most urgent messages (emergency stop, motor commutation) and higher IDs to diagnostics.
ID bits: 10 9 8 7 6 5 4 3 2 ...Node A (0x123): 0 0 1 0 0 1 0 0 0Node B (0x125): 0 0 1 0 0 1 0 0 1 <- B sends 1, reads 0 at bit 2, backs offBus: 0 0 1 0 0 1 0 0 0 (wired-AND: dominant wins)Frames
Section titled “Frames”A classic CAN data frame carries 0 to 8 data bytes. Its fields: start of frame, ID, RTR bit (data versus remote frame), IDE bit (standard versus extended), DLC (data length code), data, a 15-bit CRC, an ACK slot, and end of frame. A remote frame requests data from another node; it is rarely used in modern designs. Every receiving node that got a frame correctly drives the ACK slot dominant, so a transmitter alone on the bus never sees an ACK and keeps retrying.
Bit timing
Section titled “Bit timing”CAN has no clock wire. Every node resynchronizes on recessive-to-dominant edges and samples each bit at a configured sample point. A bit is divided into time quanta (tq):
|<----------------------- one bit (14 tq at 500 kbit/s) ----------------------->|| SYNC | PROP + PHASE1 (TS1 = 11 tq) | PHASE2 (TS2=2) || 1 tq | ^ sample point | (1 + 11) / 14 = 85.7%The bxCAN controller on the STM32F407 is clocked from APB1 (42 MHz at a 168 MHz system clock). For 500 kbit/s you need 42,000,000 / 500,000 = 84 clock cycles per bit. A prescaler of 6 gives 14 tq per bit, split as 1 sync + 11 (TS1) + 2 (TS2), for a sample point of 85.7%. Higher-layer standards specify the sample point (CANopen recommends 87.5%, many automotive networks use about 80%), and all nodes on a bus should use similar values.
The configuration register holds each field minus one. Using the bit-field names from ST’s CMSIS device header stm32f4xx.h:
#include <stdbool.h>#include <stdint.h>#include "stm32f4xx.h"
#define CAN_BRP 6u#define CAN_TS1 11u#define CAN_TS2 2u#define CAN_SJW 1u
void can1_init_500k(void){ RCC->APB1ENR |= RCC_APB1ENR_CAN1EN;
CAN1->MCR |= CAN_MCR_INRQ; /* request init mode */ while ((CAN1->MSR & CAN_MSR_INAK) == 0u) { } /* add a timeout in real code */
CAN1->MCR |= CAN_MCR_ABOM; /* automatic bus-off recovery */ CAN1->BTR = ((CAN_SJW - 1u) << 24) | ((CAN_TS2 - 1u) << 20) | ((CAN_TS1 - 1u) << 16) | (CAN_BRP - 1u);
/* Filter bank 0: 32-bit mask mode, mask 0 = accept everything into FIFO0 */ CAN1->FMR |= CAN_FMR_FINIT; CAN1->FA1R &= ~1u; CAN1->FS1R |= 1u; CAN1->FM1R &= ~1u; CAN1->sFilterRegister[0].FR1 = 0u; CAN1->sFilterRegister[0].FR2 = 0u; CAN1->FFA1R &= ~1u; CAN1->FA1R |= 1u; CAN1->FMR &= ~CAN_FMR_FINIT;
CAN1->MCR &= ~CAN_MCR_INRQ; /* leave init mode */ while ((CAN1->MSR & CAN_MSR_INAK) != 0u) { } /* waits for 11 recessive bits */}
bool can1_send_std(uint16_t id, const uint8_t *data, uint8_t len){ if ((CAN1->TSR & CAN_TSR_TME0) == 0u || len > 8u) { return false; /* mailbox 0 busy */ } uint32_t lo = 0, hi = 0; for (uint8_t i = 0; i < len; i++) { if (i < 4u) lo |= (uint32_t)data[i] << (8u * i); else hi |= (uint32_t)data[i] << (8u * (i - 4u)); } CAN1->sTxMailBox[0].TDLR = lo; CAN1->sTxMailBox[0].TDHR = hi; CAN1->sTxMailBox[0].TDTR = len; CAN1->sTxMailBox[0].TIR = ((uint32_t)(id & 0x7FFu) << 21) | CAN_TI0R_TXRQ; return true;}Three things deserve attention. Bit timing and filters can only be changed in initialization mode (INRQ), and leaving that mode waits until the controller sees 11 consecutive recessive bits on the bus; with no transceiver or a stuck bus, that loop never ends, which is why it needs a timeout. The filter bank decides which IDs the hardware accepts into a receive FIFO; this one accepts everything, but a real node filters to the IDs it cares about so the CPU is not interrupted for every frame on a busy bus. The transmit function writes data and length first and sets TXRQ last, in the same write as the ID, because setting TXRQ hands the mailbox to hardware. Note that CAN1 on the F407 shares filter banks with CAN2 and is routed to pins in AF9 (one option is PD0 RX and PD1 TX); check the datasheet’s alternate function table for your board.
Errors and bus-off
Section titled “Errors and bus-off”Every CAN node keeps a transmit error counter (TEC) and a receive error counter (REC). Errors (bad CRC, bit errors, stuff errors, missing ACK) increase them; successful frames decrease them. The counters drive three states:
| State | Condition | Behaviour |
|---|---|---|
| Error active | TEC and REC below 128 | Normal; signals errors with dominant error flags |
| Error passive | TEC or REC at least 128 | Still communicates, but signals errors with recessive flags and waits extra before retransmitting |
| Bus-off | TEC above 255 | Disconnected from the bus entirely |
This is how CAN contains a faulty node: a node with a broken transceiver drives itself off the bus instead of jamming it. Recovering from bus-off requires seeing 128 occurrences of 11 recessive bits. With ABOM set, the bxCAN does this automatically; without it, software must request it. Decide deliberately: automatic recovery is convenient but a node with a real fault will keep rejoining and disrupting the bus, so many designs recover in software with a back-off delay and log every bus-off event.
CAN FD and higher layers
Section titled “CAN FD and higher layers”CAN FD extends the payload to 64 bytes and allows a faster bit rate during the data phase (2 to 8 Mbit/s) while keeping arbitration at the classic rate. The STM32F407’s bxCAN does not support FD; newer parts (STM32G4, H7) have the FDCAN peripheral.
Raw CAN only gives you 8-byte frames with IDs. Higher-layer protocols assign meaning:
- CANopen (industrial automation): an object dictionary indexed by 16-bit index and 8-bit sub-index, PDOs for real-time data, SDOs for configuration, NMT for node state, heartbeat messages.
- J1939 (trucks, agriculture, heavy equipment): 29-bit IDs encoding priority, parameter group number (PGN), and source address; a large standardized catalogue of signals.
- UDS (ISO 14229, automotive diagnostics): request/response services such as read data by identifier, security access, and flash programming, usually carried over ISO-TP (ISO 15765-2), which segments messages longer than 8 bytes.
If your device must join an existing network, the network’s higher layer dictates your design. Use an established library rather than writing a CANopen stack from the specification.
Ethernet and TCP/IP
Section titled “Ethernet and TCP/IP”PHY, MAC, and RMII
Section titled “PHY, MAC, and RMII”Wired Ethernet on an MCU splits into two chips’ worth of function. The MAC (media access controller), built into the STM32F407, builds and checks frames and moves them to RAM with its own DMA engine and descriptor rings. The PHY, an external chip such as a LAN8720 or DP83848, handles the analog line signalling and talks to the MAC over RMII (reduced media-independent interface: 2 data bits each way at 50 MHz) or MII. The MAC configures the PHY over a two-wire management bus (MDIO/MDC), for example to read link status. Note that the STM32F4 Discovery board has no PHY or RJ45 jack; Ethernet experiments need a board with one, or an external PHY module.
lwIP (lightweight IP) is the standard open-source TCP/IP stack for MCUs: IPv4/IPv6, TCP, UDP, DHCP, DNS, and ICMP in tens of kilobytes of flash. It offers three APIs:
| API | Threading | Use |
|---|---|---|
| Raw (callback) API | Single thread, no RTOS required (NO_SYS 1) | Smallest, fastest, bare metal; callbacks run in stack context |
| Netconn API | Requires an RTOS; sequential blocking calls | Simpler code than raw, lwIP-specific |
| Socket API | Requires an RTOS; BSD-like socket/recv/send | Portable code, highest overhead |
lwIP’s packet buffer is the pbuf. A received packet may be a chain of pbufs (p->next), each with payload and len, and the chain’s total in p->tot_len. Some pbuf types point directly at DMA buffers so data is not copied (zero-copy); the cost is that those buffers stay owned by lwIP until you call pbuf_free(). lwIP allocates pbufs and control blocks from fixed-size memory pools sized in lwipopts.h (MEMP_NUM_TCP_PCB, PBUF_POOL_SIZE, MEM_SIZE), so exhaustion shows up as failed allocations, not heap fragmentation.
A UDP echo server with the raw API:
#include "lwip/udp.h"#include "lwip/pbuf.h"
static void echo_recv(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port){ (void)arg; if (p != NULL) { udp_sendto(pcb, p, addr, port); /* send the same pbuf back */ pbuf_free(p); /* we own p: always free it */ }}
void udp_echo_init(void){ struct udp_pcb *pcb = udp_new(); if (pcb == NULL) { return; } if (udp_bind(pcb, IP_ADDR_ANY, 7) != ERR_OK) { udp_remove(pcb); return; } udp_recv(pcb, echo_recv, NULL);}The receive callback owns the pbuf and must free it exactly once; forgetting is a leak that exhausts the pool after a few hundred packets, and freeing twice corrupts the pool. A TCP server follows the same shape with more callbacks:
#include "lwip/tcp.h"
static err_t on_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err){ (void)arg; if (p == NULL) { /* remote side closed */ tcp_close(tpcb); return ERR_OK; } if (err != ERR_OK) { pbuf_free(p); return err; } for (struct pbuf *q = p; q != NULL; q = q->next) { tcp_write(tpcb, q->payload, q->len, TCP_WRITE_FLAG_COPY); } tcp_output(tpcb); tcp_recved(tpcb, p->tot_len); /* reopen the receive window */ pbuf_free(p); return ERR_OK;}
static err_t on_accept(void *arg, struct tcp_pcb *newpcb, err_t err){ (void)arg; if (err != ERR_OK || newpcb == NULL) { return ERR_VAL; } tcp_recv(newpcb, on_recv); return ERR_OK;}
void tcp_server_init(uint16_t port){ struct tcp_pcb *pcb = tcp_new(); if (pcb == NULL || tcp_bind(pcb, IP_ADDR_ANY, port) != ERR_OK) { return; } pcb = tcp_listen(pcb); /* returns a new, smaller pcb */ tcp_accept(pcb, on_accept);}tcp_recved() tells lwIP the application has consumed data so it can advertise a larger receive window; forget it and the connection stalls after a few kilobytes. tcp_write() can fail with ERR_MEM when the send buffer is full; a real server checks it and resumes from the tcp_sent() callback. This is backpressure: the network is slower than you, and the stack tells you so.
With NO_SYS 1, the main loop drives everything: poll the Ethernet driver for received frames and hand them to netif->input, and call sys_check_timeouts() so TCP retransmissions, ARP, and DHCP timers run. Startup adds the interface with netif_add(), makes it default and up, and then either sets a static IP or calls dhcp_start(&netif). DHCP is right for consumer devices on unknown networks; a static IP is common in industrial installations where engineers assign addresses. Many products support both with DHCP as the default and a link-local fallback.
Anything that leaves a trusted LAN needs TLS. On an MCU, mbedTLS is the usual choice. Budget for it honestly: roughly 60 to 120 KB of flash for a typical configuration, 30 to 50 KB of RAM during a handshake (the TLS record buffers alone are up to 16 KB each way unless you negotiate smaller fragments with the maximum fragment length extension), and a handshake that takes from hundreds of milliseconds to several seconds on a Cortex-M4 depending on the cipher suite. Choosing ECDHE with P-256 and AES-GCM, and using a hardware crypto accelerator if the part has one, helps a lot. Key and certificate handling belong to Firmware Security.
Wi-Fi: module or integrated
Section titled “Wi-Fi: module or integrated”Wi-Fi on an MCU product takes one of two shapes:
- A co-processor module. An ESP32 or a dedicated Wi-Fi module runs the whole Wi-Fi and TCP/IP stack; your STM32 talks to it over UART or SPI. Classic modules use text AT commands (
AT+CWJAP="ssid","pass", then socket commands). This keeps the main MCU small and lets you reuse a pre-certified radio module, which saves months of regulatory work. The costs are a second firmware image to update, a text protocol that is awkward to parse robustly, and throughput limited by the serial link. - An integrated SoC. The ESP32 family and similar wireless MCUs run your application and the network stack on the same silicon. Cheaper and faster, but your application now shares CPU, RAM, and failure modes with a large vendor stack.
For an STM32-based product the module approach is the common answer; for a new connected product with modest real-time needs, an integrated SoC is often cheaper overall.
Bluetooth Low Energy
Section titled “Bluetooth Low Energy”BLE is designed for small, infrequent data exchanges at very low power. The STM32F407 has no radio; BLE products use parts such as the nRF52 family, ESP32, or STM32WB.
GAP: advertising and connections
Section titled “GAP: advertising and connections”GAP (Generic Access Profile) defines roles and how devices find each other. A peripheral (your sensor) broadcasts advertising packets of up to 31 bytes (legacy advertising) at an interval from 20 ms to over 10 s. A central (a phone) scans, sees the advertisement, and may connect. Advertising data usually carries the device name, service UUIDs, and sometimes sensor values directly, which lets a device broadcast data with no connection at all (the beacon pattern).
Once connected, the two sides wake up and exchange packets once per connection interval, from 7.5 ms to 4 s in 1.25 ms steps. Peripheral latency lets the peripheral skip a number of connection events when it has nothing to send, and the supervision timeout declares the link lost if nothing is heard for that long. These three numbers dominate power: a 15 ms interval gives low latency and a battery that lasts weeks; a 1 s interval with latency 4 can run for years on a coin cell. The central decides the final values; the peripheral can only request.
GATT: services and characteristics
Section titled “GATT: services and characteristics”GATT (Generic Attribute Profile) organizes data as a table of attributes. A service groups related characteristics; each characteristic has a UUID, a value, and properties (read, write, notify, indicate). Standard services use 16-bit UUIDs assigned by the Bluetooth SIG; custom ones use random 128-bit UUIDs.
Environmental Sensing service (0x181A) Temperature characteristic (0x2A6E) properties: read, notify value: sint16, units of 0.01 degC CCCD descriptor (0x2902) client writes 0x0001 to enable notifyNotify pushes a value to the central without an acknowledgement at the GATT layer; indicate requires one, so it is reliable but slower. The client enables either by writing the characteristic’s CCCD. The default ATT MTU is 23 bytes, which leaves 20 bytes of payload per notification. Both sides can negotiate a larger MTU (up to 247 bytes is common), and BLE 4.2+ data length extension lets one radio packet carry more, which raises throughput dramatically for bulk transfers such as firmware updates.
On Zephyr (one of the common stacks, alongside Nordic’s SoftDevice-based SDKs and vendor stacks), a temperature service looks roughly like this:
#include <zephyr/bluetooth/gatt.h>#include <zephyr/bluetooth/uuid.h>
static int16_t temp_centi; /* 0.01 degC units, matches 0x2A6E */static bool notify_enabled;
static ssize_t read_temp(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset){ return bt_gatt_attr_read(conn, attr, buf, len, offset, &temp_centi, sizeof temp_centi);}
static void ccc_changed(const struct bt_gatt_attr *attr, uint16_t value){ (void)attr; notify_enabled = (value == BT_GATT_CCC_NOTIFY);}
BT_GATT_SERVICE_DEFINE(env_svc, BT_GATT_PRIMARY_SERVICE(BT_UUID_ESS), BT_GATT_CHARACTERISTIC(BT_UUID_TEMPERATURE, BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY, BT_GATT_PERM_READ, read_temp, NULL, &temp_centi), BT_GATT_CCC(ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),);
void ble_temp_update(int16_t centi){ temp_centi = centi; if (notify_enabled) { bt_gatt_notify(NULL, &env_svc.attrs[1], &temp_centi, sizeof temp_centi); }}The macro builds the attribute table at compile time in flash: index 0 is the service declaration, index 1 the characteristic declaration, index 2 its value, index 3 the CCCD. The stack handles the protocol; you supply a read callback and call bt_gatt_notify() when data changes. BLE stacks run their own threads and timing-critical radio interrupts at the highest priority; your code must never mask interrupts for long or the link drops.
IoT application protocols
Section titled “IoT application protocols”Once a device has IP connectivity, it needs an application protocol to talk to a cloud or server.
MQTT is a publish/subscribe protocol over TCP. Devices connect to a broker and publish messages to topics (site/42/sensor/7/temperature); other clients subscribe to topics or wildcards (site/42/+/temperature, site/42/#). The device never needs to know who consumes its data.
| QoS | Guarantee | Cost |
|---|---|---|
| 0 | At most once: fire and forget | One packet |
| 1 | At least once: retried until acknowledged; duplicates possible | Two packets, message kept until PUBACK |
| 2 | Exactly once | Four-packet handshake, rarely worth it on MCUs |
Three features matter for devices. The keepalive interval (for example 60 s) makes the client send a ping if idle, so both sides detect dead TCP connections that NAT routers silently dropped. The last will is a message the broker publishes on the device’s behalf if it disappears without disconnecting cleanly, typically status = offline. Retained messages give new subscribers the latest value immediately. Because QoS 1 can deliver duplicates, design payloads to be idempotent: “temperature at time T is 21.5” is safe to receive twice; “increment counter” is not.
CoAP, HTTP, and LwM2M
Section titled “CoAP, HTTP, and LwM2M”CoAP is a compact REST-style protocol over UDP: GET/PUT/POST/DELETE on resources, 4-byte headers, confirmable messages with retransmission, and an observe option for subscriptions. It suits constrained networks such as Thread or NB-IoT where TCP is costly. HTTP (usually HTTPS) is heavier but universal; devices use it for occasional calls such as fetching a firmware image or calling a REST API. LwM2M is a device management standard on top of CoAP, with a standard object model for firmware update, connectivity monitoring, and device info, used mostly in cellular IoT.
Designing your own link protocol
Section titled “Designing your own link protocol”Sooner or later you design a protocol for a link you control: MCU to co-processor, device to host tool, board to board over RS-485. The rules that keep it maintainable:
- Framing. Frames must be findable in a byte stream after any corruption: a delimiter with escaping or COBS, or a sync word plus length. The UART lesson covered COBS and SLIP.
- Integrity. A CRC-16 or CRC-32 over every frame. A checksum that only sums bytes misses swapped bytes.
- Versioning. A version field in every frame header, and a rule that receivers ignore unknown message types rather than crashing.
- Acknowledgements and sequence numbers. If delivery matters, the receiver acknowledges by sequence number and the sender retries after a timeout.
- Idempotency. Retried commands must be safe to execute twice. Include the sequence number so the receiver can detect duplicates, or design commands as “set to X” rather than “change by X”.
- Backpressure. The receiver must be able to say “slow down”: a window of outstanding frames, or a busy response.
Serialize headers with shifts, never by casting a packed struct, so the code is independent of compiler padding and CPU endianness:
#include <stddef.h>#include <stdint.h>
#define LINK_VERSION 1u
typedef struct { uint8_t version; uint8_t type; uint16_t seq; uint16_t len;} link_hdr_t;
/* Writes a 6-byte little-endian header. Returns bytes written or 0. */size_t link_hdr_pack(const link_hdr_t *h, uint8_t *out, size_t cap){ if (cap < 6u) { return 0; } out[0] = h->version; out[1] = h->type; out[2] = (uint8_t)(h->seq & 0xFFu); out[3] = (uint8_t)(h->seq >> 8); out[4] = (uint8_t)(h->len & 0xFFu); out[5] = (uint8_t)(h->len >> 8); return 6u;}The receiver checks version first and rejects or adapts, checks len against its buffer before copying anything, and verifies the CRC before acting on the contents.
Choosing a link
Section titled “Choosing a link”| Link | Bandwidth | Distance | Power | Unit cost | Choose when |
|---|---|---|---|---|---|
| USB FS | 12 Mbit/s (about 1 MB/s real) | 5 m | Device can be bus-powered | Low (in MCU) | Connecting to a PC or phone, config tools, updates |
| CAN / CAN FD | 1 Mbit/s / up to 8 Mbit/s data | 40 m at 1 Mbit/s, km at low rates | Moderate | Low (controller + transceiver) | Multi-node, noisy, safety-relevant machines and vehicles |
| RS-485 | Up to 10 Mbit/s | Up to 1.2 km | Moderate | Very low | Long cable, simple master/slave, Modbus |
| Ethernet | 10/100 Mbit/s | 100 m per segment | High (PHY ~100+ mW) | Medium (PHY, magnetics, jack) | Fixed installations, high data, PoE |
| Wi-Fi | 1 to 50+ Mbit/s | Tens of metres | High peaks, needs sleep management | Medium (module) | Home and office devices with mains or large batteries |
| BLE | Up to about 1.4 Mbit/s app throughput at 2M PHY | 10 to 50 m typical | Very low | Low (SoC) | Phone as the user interface, coin-cell devices |
| Cellular (LTE-M, NB-IoT) | kbit/s to ~1 Mbit/s | Wide area | Moderate, PSM for sleep | High (module + subscription) | Devices with no local infrastructure |
The decision is rarely about bandwidth alone. Ask where the data has to end up, who installs the device, what certifications the market requires, and what the power budget is. Then pick the simplest link that meets all of those.
Common mistakes
Section titled “Common mistakes”- Calling the USB or network stack from both an ISR and the main loop. Fix: respect the stack’s threading model; ISRs only queue events, one context runs the stack.
- Running USB full speed from the HSI oscillator. Fix: derive 48 MHz from an HSE-based PLL (Q divider), within 0.25%.
- Custom board does not route VBUS but VBUS sensing is enabled, so USB never connects. Fix: disable VBUS sensing in the OTG core configuration or route the pin.
- CAN bus with one terminator or none. Fix: exactly two 120 Ω terminators, one at each physical end; measure 60 Ω between CANH and CANL with power off.
- Waiting forever for CAN init mode to exit with no transceiver connected. Fix: put a timeout on every hardware wait and report the error.
- lwIP pbuf leaked in a receive callback or freed twice. Fix: the callback owns
p; free it exactly once on every path. - Forgetting
tcp_recved(), so TCP stalls after the window fills. Fix: call it withp->tot_lenafter consuming data. - BLE connection interval left at the stack default, draining the battery. Fix: request an interval and latency that match the data rate.
- QoS 1 MQTT messages that are not idempotent. Fix: send state (“value is X at time T”), not deltas.
Exercises
Section titled “Exercises”- Draw the enumeration sequence for a CDC device and, for each step, write which TinyUSB callback or internal handler responds. Then capture a real enumeration with Wireshark and
usbmonor USBPcap (any USB device works) and find theSET_ADDRESSandSET_CONFIGURATIONrequests. - Compute bxCAN bit timing for 250 kbit/s and 1 Mbit/s from a 42 MHz APB1 clock with a sample point as close to 87.5% as possible. Show the prescaler, TS1, TS2, and the resulting
BTRvalue in hex. (For 250 kbit/s, a prescaler of 21 gives 8 tq per bit, and 1 + 6 + 1 hits 87.5% exactly; explain why more quanta per bit would still be preferable.) - On a Nucleo or other board with Ethernet, port lwIP with
NO_SYS 1and run the UDP echo server. Test withnc -u <board-ip> 7. Then deliberately removepbuf_free(p), send packets in a loop, and count how many succeed before replies stop. Relate the number toPBUF_POOL_SIZE. - Build the TinyUSB CDC echo on the Discovery board. Add a
tud_cdc_write()return value check and a counter of bytes that did not fit, then send a large file from the host and observe the counter. Read the map file and record how much flash and RAM TinyUSB uses. - Design a frame format for a link between an STM32 and an ESP32 co-processor: header fields, CRC, sequence numbers, acknowledgement rules, and how a receiver handles an unknown message type. Write
packandunpackfunctions and Unity tests for them, including a test that a frame with a corrupted CRC is rejected.
Check yourself
Section titled “Check yourself”Question 1:A USB CDC device's firmware calls a function that erases a 128 KB flash sector (about 1 to 2 seconds) right after power-up. What is the most likely symptom?
Enumeration is driven by firmware responding to control requests within host timeouts. A long blocking operation (and the bus stall from flash erase on a single-bank F4) makes the device miss them.
Question 2:Two CAN nodes start transmitting at the same instant, one with ID 0x100 and one with ID 0x0FF. What happens?
Arbitration is bitwise with dominant 0 winning, so the lower ID wins without corrupting its frame. The loser stops transmitting and retries when the bus is idle.
Question 3:An lwIP TCP server echoes data correctly for the first few kilobytes of every connection, then stops receiving. What is the most likely bug?
lwIP only reopens the TCP receive window when the application reports consumed data with tcp_recved(). Without it the peer stops sending once the window is full.
Question 4:A BLE temperature sensor sends a 2-byte value every 10 seconds but its coin cell lasts only three weeks. Which change most directly addresses this?
Power in a connected BLE link is dominated by how often the radio wakes for connection events. Longer intervals with latency, or connectionless advertising, cut radio-on time dramatically.
Question 5:A device publishes counter += 1 commands over MQTT with QoS 1. The server's counter is occasionally too high. Why?
QoS 1 retries until acknowledged, which can duplicate a message if the acknowledgement is lost. Sending absolute state instead of increments makes duplicates harmless.
Key takeaways
Section titled “Key takeaways”- Every stack integration has the same four parts: port layer, configuration, threading model, and application callbacks. The threading model causes most bugs.
- USB is host-driven: the device only answers, enumeration has deadlines, and full speed needs an accurate 48 MHz clock.
- CAN arbitrates by ID with the lowest ID winning, contains faulty nodes with error counters and bus-off, and needs exactly two 120 Ω terminators.
- lwIP’s raw API is callback-driven and pbuf-based; free every pbuf exactly once and call
tcp_recved()after consuming TCP data. - BLE power is dominated by advertising and connection intervals; GATT organizes data as services and characteristics with notify for push.
- MQTT QoS 1 can duplicate messages, so payloads should be idempotent state, not commands.
- Your own protocols need framing, CRC, a version field, sequence numbers, and backpressure from day one.
Next: Filesystems, Logging, and Data on Device, where the data you collect has to survive power loss and be found again.