Series Overview: This is Part 17 of our 18-part STM32 Unleashed series. In this penultimate article we graduate from peripheral-level communication to full network connectivity — Ethernet MAC, PHY chips, LwIP, DHCP, UDP/TCP, HTTP, and MQTT.
1
Architecture & CubeMX Setup
STM32 family, clock tree, HAL vs LL, CubeMX workflow, first project
Completed
2
GPIO & Button Debounce
GPIO modes, pull-up/down, EXTI, software debounce, HAL_GPIO_ReadPin
Completed
3
UART Communication
Polling, interrupt, DMA modes, printf retargeting, ring buffers
Completed
4
Timers, PWM & Input Capture
TIM basics, PWM generation, input capture, encoder mode
Completed
5
ADC & DAC
Single/continuous conversion, DMA, injected channels, DAC waveforms
Completed
6
SPI Protocol
SPI master/slave, full-duplex, DMA transfers, sensor drivers
Completed
7
I2C Protocol
I2C master, 7/10-bit addressing, DMA, multi-master, error handling
Completed
8
DMA & Memory Efficiency
DMA streams, circular mode, memory-to-memory, zero-copy patterns
Completed
9
Interrupt Management & NVIC
Priority grouping, preemption, ISR design, HAL callbacks, latency
Completed
10
Low-Power Modes
Sleep, Stop, Standby modes, RTC wakeup, LP UART, power profiling
Completed
11
RTC & Calendar
RTC configuration, alarms, backup registers, calendar subseconds
Completed
12
CAN Bus
FDCAN/bxCAN, filters, message frames, error handling, automotive use
Completed
13
USB CDC Virtual COM Port
USB FS/HS, CDC class, virtual serial, control transfers, descriptors
Completed
14
FreeRTOS Integration
Tasks, queues, semaphores, mutexes, CMSIS-RTOS2 wrapper, stack sizing
Completed
15
Bootloader Development
Custom IAP bootloader, UART/USB DFU, flash programming, jump-to-app
Completed
16
External Storage: SD & QSPI Flash
FATFS on SD card, QSPI NOR flash, memory-mapped execution, wear levelling
Completed
17
Ethernet & TCP/IP Stack
LwIP integration, DHCP, TCP server, HTTP, MQTT, Ethernet DMA descriptors
You Are Here
18
Production Readiness
Watchdog, HardFault handler, flash option bytes, code signing, CI/CD
STM32 Ethernet Architecture
The STM32F4/F7/H7 microcontrollers include an on-chip Ethernet MAC (Media Access Controller) compliant with IEEE 802.3 at 10/100 Mbit/s. The MAC handles frame assembly, CRC generation, collision detection (in half-duplex), and flow control — but it does not include an analogue front-end. An external PHY (Physical Layer) chip is required to convert the digital MAC signals to the analogue currents on the RJ-45 connector.
The interface between the STM32 MAC and the PHY operates in one of two modes:
- MII (Media Independent Interface): 4-bit data path, requires 16 pins (TXD[3:0], RXD[3:0], TX_EN, RX_DV, TX_CLK, RX_CLK, COL, CRS, MDIO, MDC). Data clocked at 25 MHz for 100 Mbit/s.
- RMII (Reduced MII): 2-bit data path, requires only 7 signal pins (TXD[1:0], RXD[1:0], TX_EN, CRX_DV, REF_CLK) plus MDIO/MDC. REF_CLK must be a 50 MHz clock provided by the PHY or an external oscillator. RMII is the standard choice for STM32 Ethernet.
Popular PHY Chips
- LAN8720A (Microchip): RMII, 10/100 Mbit/s, integrated 50 MHz oscillator output, I2C-like MDIO management bus. Used on the Nucleo-F429ZI, Nucleo-F746ZG, and many custom boards. PHY address 0x00 or 0x01 depending on pin strapping.
- DP83848 (Texas Instruments): MII or RMII, 10/100 Mbit/s, 3.3 V operation, LED outputs for link/activity. Older design but very well documented.
- KSZ8081 (Microchip): RMII, 10/100 Mbit/s, very small 32-SQFN package, suitable for space-constrained designs.
- TLK110 (Texas Instruments): RMII, 10/100 Mbit/s, industrial temperature range (-40 to 85°C), ideal for harsh environments.
DMA Descriptor Rings
The Ethernet MAC transfers data to/from the CPU's SRAM using DMA2 and a pair of descriptor rings:
- TX descriptor ring: The CPU writes frame pointers and length fields into TX descriptors. The DMA reads each descriptor, fetches the frame payload from the pointed-to buffer, and transmits it. After transmission, the MAC clears the OWN bit so the CPU can reuse the buffer.
- RX descriptor ring: The CPU populates RX descriptors with buffer pointers. When a frame arrives, the DMA writes the payload into the next available buffer and sets the OWN bit. The CPU polls or waits for an interrupt to process incoming frames.
| Ethernet Feature |
STM32F4 |
STM32F7 |
STM32H7 |
Notes |
| MAC Standard |
IEEE 802.3 |
IEEE 802.3 |
IEEE 802.3 |
10/100 Mbit/s on all |
| PHY Interface |
MII / RMII |
MII / RMII |
RMII only |
H7 drops MII |
| DMA Engine |
DMA2 |
DMA2 |
Internal ETH-DMA |
H7 has dedicated MAC-DMA |
| TX Descriptors (typical) |
4–8 |
4–8 |
4–8 (enhanced) |
Each = 1 Ethernet frame |
| RX Descriptors (typical) |
4–8 |
4–8 |
4–8 (enhanced) |
Buffer size ≥ 1536 bytes |
| Max Frame Size |
1522 bytes |
1522 bytes |
9018 bytes (jumbo) |
H7 supports jumbo frames |
| IEEE 1588 PTP |
Yes |
Yes |
Yes |
Hardware timestamping |
| D-cache coherency |
N/A |
N/A |
Required (MPU) |
H7: descriptors must be non-cached |
/* ================================================================
* ETH Handle Init for RMII + LAN8720A at 100 Mbit/s
* CubeMX-generated MX_ETH_Init() — placed in ethernetif.c
* DMA descriptors allocated in non-cached SRAM region on H7
* ================================================================ */
#include "stm32f4xx_hal.h"
#include "stm32f4xx_hal_eth.h"
ETH_HandleTypeDef heth;
/* Descriptor buffers — must be in DMA-accessible SRAM */
ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB]
__attribute__((section(".RxDecripSection")));
ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB]
__attribute__((section(".TxDecripSection")));
uint8_t Rx_Buff[ETH_RXBUFNB][ETH_RX_BUF_SIZE]
__attribute__((section(".RxarraySection")));
uint8_t Tx_Buff[ETH_TXBUFNB][ETH_TX_BUF_SIZE]
__attribute__((section(".TxarraySection")));
void MX_ETH_Init(void)
{
static uint8_t MACAddr[] = {0x00, 0x80, 0xE1, 0x00, 0x00, 0x01};
heth.Instance = ETH;
heth.Init.AutoNegotiation = ETH_AUTONEGOTIATION_ENABLE;
heth.Init.Speed = ETH_SPEED_100M;
heth.Init.DuplexMode = ETH_MODE_FULLDUPLEX;
heth.Init.PhyAddress = LAN8720A_PHY_ADDRESS; /* 0x00 */
heth.Init.MACAddr = MACAddr;
heth.Init.RxMode = ETH_RXINTERRUPT_MODE;
heth.Init.ChecksumMode = ETH_CHECKSUM_BY_HARDWARE;
heth.Init.MediaInterface = ETH_MEDIA_INTERFACE_RMII;
HAL_ETH_Init(&heth);
/* Initialise DMA descriptor rings */
HAL_ETH_DMARxDescListInit(&heth, DMARxDscrTab,
&Rx_Buff[0][0], ETH_RXBUFNB);
HAL_ETH_DMATxDescListInit(&heth, DMATxDscrTab,
&Tx_Buff[0][0], ETH_TXBUFNB);
}
LwIP Integration
LwIP (Lightweight IP) is an open-source TCP/IP stack written by Adam Dunkels at SICS. It is the de-facto standard for Cortex-M networking — used in millions of industrial controllers, medical devices, and IoT nodes. STM32Cube includes LwIP as a middleware package, and CubeMX generates all the glue code needed to connect it to the Ethernet HAL.
LwIP provides three progressively higher-level APIs:
- Raw (callback) API: No RTOS required. Your application registers callback functions that LwIP calls when data arrives or a connection state changes. Maximum performance, minimum RAM, but callback-driven design is complex for multi-protocol applications.
- Netconn API: Requires an RTOS. Provides a sequential, blocking-style API similar to BSD sockets but with some differences. Suitable for most embedded applications.
- BSD Socket API: Requires an RTOS. Provides a fully POSIX-compatible socket API (
socket(), bind(), connect(), send(), recv()). Easiest to port existing PC networking code to embedded, at the cost of slightly higher overhead.
Key lwipopts.h Settings
The lwipopts.h file controls LwIP's compile-time configuration. Critical settings for STM32:
MEM_SIZE: Total heap for LwIP dynamic allocations. Start with 10240 (10 KB), increase if you see ERR_MEM errors. Place in a dedicated RAM section.
MEMP_NUM_PBUF: Number of memp pbuf structures — set to at least 8. Each pbuf chains to a network packet.
MEMP_NUM_TCP_SEG: Number of TCP segments available simultaneously. Increase for higher throughput (16 is a good starting point).
TCP_MSS: Maximum Segment Size, must be ≤ 1460 (Ethernet MTU 1500 − IP header 20 − TCP header 20).
TCP_SND_BUF: TCP send buffer per connection. Set to 2 × TCP_MSS for good throughput without excessive RAM use.
LWIP_DHCP 1: Enable DHCP client.
NO_SYS 1: For bare-metal (no RTOS) operation. Set to 0 for FreeRTOS-based netconn/socket API.
/* ================================================================
* LwIP bare-metal init (NO_SYS=1) with periodic polling
* Call ethernetif_input() and sys_check_timeouts() from main loop
* ================================================================ */
#include "lwip/init.h"
#include "lwip/netif.h"
#include "lwip/timeouts.h"
#include "lwip/dhcp.h"
#include "ethernetif.h"
#include <string.h>
struct netif gnetif; /* Global network interface */
void lwip_stack_init(void)
{
ip_addr_t ipaddr, netmask, gw;
/* Zero IP config — DHCP will fill these in */
IP_ADDR4(&ipaddr, 0, 0, 0, 0);
IP_ADDR4(&netmask, 0, 0, 0, 0);
IP_ADDR4(&gw, 0, 0, 0, 0);
/* Initialise LwIP core */
lwip_init();
/* Add the Ethernet network interface */
netif_add(&gnetif, &ipaddr, &netmask, &gw,
NULL, ðernetif_init, ðernet_input);
/* Set as default interface */
netif_set_default(&gnetif);
/* Bring interface up (link not yet confirmed) */
if (netif_is_link_up(&gnetif)) {
netif_set_up(&gnetif);
}
/* Start DHCP client */
dhcp_start(&gnetif);
printf("LwIP init done — waiting for DHCP...\r\n");
}
/* Call this function from the superloop at high frequency */
void lwip_poll(void)
{
/* Process received Ethernet frames */
ethernetif_input(&gnetif);
/* Handle LwIP internal timers (DHCP, TCP retransmit, etc.) */
sys_check_timeouts();
}
STM32H7 Cache Coherency: The Ethernet DMA descriptors and frame buffers must reside in non-cacheable SRAM on H7. Configure the MPU to mark the ETH descriptor region (typically SRAM1 0x20020000 or a dedicated section) as Device or Non-Cacheable Normal memory. Failure to do this causes the Ethernet DMA to read stale descriptor values and the stack will never receive any packets.
DHCP Client
DHCP (Dynamic Host Configuration Protocol, RFC 2131) allows the STM32 to automatically acquire an IP address, subnet mask, default gateway, and DNS server from the network's DHCP server — typically a router or enterprise DHCP appliance. This eliminates the need to hard-code IP addresses in firmware, which is critical for field-deployable devices.
DHCP State Machine
The LwIP DHCP client implements the full RFC 2131 state machine:
- INIT: Client starts with no IP address. Sends a DHCPDISCOVER broadcast on 255.255.255.255.
- SELECTING: Client collects DHCPOFFER messages from one or more servers and picks the best offer.
- REQUESTING: Client sends a DHCPREQUEST broadcast to accept the chosen offer and decline others.
- BOUND: Server responds with DHCPACK. Client configures the interface with the assigned IP, mask, gateway, and DNS.
- RENEWING: At T1 (50% of lease time), client unicasts a DHCPREQUEST to the original server to renew the lease.
- REBINDING: At T2 (87.5% of lease time), client broadcasts a DHCPREQUEST if renewal failed, trying any available server.
/* ================================================================
* Wait for DHCP binding with 10-second timeout
* Prints assigned IP address over UART on success
* Assumes lwip_stack_init() has already been called
* ================================================================ */
#include "lwip/dhcp.h"
#include "lwip/netif.h"
#include <stdio.h>
#define DHCP_TIMEOUT_MS 10000U
HAL_StatusTypeDef wait_for_dhcp(void)
{
uint32_t tick_start = HAL_GetTick();
struct dhcp *dhcp_handle;
printf("Waiting for DHCP...\r\n");
while (HAL_GetTick() - tick_start < DHCP_TIMEOUT_MS)
{
/* Process incoming frames and update timers */
lwip_poll();
/* Check if DHCP has bound */
dhcp_handle = netif_dhcp_data(&gnetif);
if (dhcp_handle != NULL &&
dhcp_handle->state == DHCP_STATE_BOUND)
{
/* DHCP bound — print assigned address */
char ip_str[16], gw_str[16], nm_str[16];
ipaddr_ntoa_r(&gnetif.ip_addr, ip_str, sizeof(ip_str));
ipaddr_ntoa_r(&gnetif.gw, gw_str, sizeof(gw_str));
ipaddr_ntoa_r(&gnetif.netmask, nm_str, sizeof(nm_str));
printf("DHCP bound!\r\n");
printf(" IP : %s\r\n", ip_str);
printf(" Gateway : %s\r\n", gw_str);
printf(" Netmask : %s\r\n", nm_str);
printf(" Lease : %lu seconds\r\n",
dhcp_handle->offered_t0_lease);
return HAL_OK;
}
HAL_Delay(10); /* 10 ms polling interval */
}
printf("DHCP timeout! Using fallback 192.168.1.200\r\n");
/* Fallback to static address if DHCP failed */
dhcp_stop(&gnetif);
ip_addr_t static_ip, static_mask, static_gw;
IP_ADDR4(&static_ip, 192, 168, 1, 200);
IP_ADDR4(&static_mask, 255, 255, 255, 0);
IP_ADDR4(&static_gw, 192, 168, 1, 1);
netif_set_addr(&gnetif, &static_ip, &static_mask, &static_gw);
netif_set_up(&gnetif);
return HAL_TIMEOUT;
}
UDP Communication
UDP (User Datagram Protocol) provides connectionless, fire-and-forget packet delivery. There is no handshake, no retransmission, and no flow control — which makes it ideal for time-critical telemetry where a missed packet is less harmful than the latency of retransmission. Sensor data at 1 kHz, CAN bus logging, and audio streaming are classic UDP use cases on embedded systems.
The LwIP raw API for UDP:
udp_new() — allocate a new UDP protocol control block (PCB).
udp_bind(pcb, IP_ADDR_ANY, port) — bind to a local port for receiving.
udp_recv(pcb, callback, arg) — register a receive callback.
udp_sendto(pcb, pbuf, &dest_ip, dest_port) — transmit a UDP datagram to a specific destination.
udp_remove(pcb) — free the PCB.
pbuf_alloc(layer, size, PBUF_RAM) — allocate a packet buffer; pbuf_free() after transmit.
/* ================================================================
* UDP Telemetry Sender — sends ADC reading to PC every 10 ms
* Destination: 192.168.1.100:5000 (Wireshark / Python socket)
* ================================================================ */
#include "lwip/udp.h"
#include "lwip/pbuf.h"
#include <stdio.h>
#include <string.h>
static struct udp_pcb *udp_telem_pcb = NULL;
static ip_addr_t dest_addr;
void udp_telemetry_init(void)
{
udp_telem_pcb = udp_new();
if (udp_telem_pcb == NULL) {
printf("udp_new failed\r\n");
return;
}
/* Bind to any local address, ephemeral port */
udp_bind(udp_telem_pcb, IP_ADDR_ANY, 0);
/* Destination: PC at 192.168.1.100, port 5000 */
IP_ADDR4(&dest_addr, 192, 168, 1, 100);
printf("UDP telemetry ready\r\n");
}
/* Call this function every 10 ms (e.g., from a TIM interrupt flag) */
void udp_telemetry_send(void)
{
if (udp_telem_pcb == NULL) return;
/* Read ADC */
uint32_t adc_raw = HAL_ADC_GetValue(&hadc1);
uint32_t tick = HAL_GetTick();
/* Build compact binary packet: tick(4) + adc(4) = 8 bytes */
struct __attribute__((packed)) {
uint32_t timestamp_ms;
uint32_t adc_value;
} pkt = { tick, adc_raw };
/* Allocate a pbuf for the payload */
struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, sizeof(pkt), PBUF_RAM);
if (p == NULL) return; /* Out of LwIP heap — increase MEM_SIZE */
memcpy(p->payload, &pkt, sizeof(pkt));
err_t err = udp_sendto(udp_telem_pcb, p, &dest_addr, 5000);
pbuf_free(p); /* Always free after send */
if (err != ERR_OK) {
printf("UDP send error: %d\r\n", err);
}
}
TCP Server
TCP (Transmission Control Protocol) adds connection establishment, ordered delivery, and automatic retransmission over IP. Use TCP when data integrity matters and the client can tolerate a few milliseconds of latency — configuration commands, firmware update chunks, or structured log retrieval over a local network.
The LwIP raw TCP API uses a chain of callbacks:
tcp_new() / tcp_bind(pcb, IP_ADDR_ANY, port) / tcp_listen(pcb) — create a listening socket.
tcp_accept(listen_pcb, accept_callback) — register the callback invoked when a new client connects.
- Inside
accept_callback: call tcp_recv(client_pcb, recv_callback) to handle incoming data, and tcp_sent(client_pcb, sent_callback) to track sent acknowledgements.
tcp_write(pcb, data, len, TCP_WRITE_FLAG_COPY) — enqueue data to send. Actually transmitted on the next tcp_output(pcb) or when the send buffer is full.
tcp_close(pcb) — initiate graceful TCP close (FIN/FIN-ACK).
tcp_abort(pcb) — send RST and immediately free the PCB (use on fatal errors).
/* ================================================================
* Simple TCP Echo Server — listens on port 7 (echo)
* Accepts one client at a time; echoes received data back
* ================================================================ */
#include "lwip/tcp.h"
#include <string.h>
#include <stdio.h>
#define ECHO_PORT 7
static struct tcp_pcb *echo_listen_pcb = NULL;
/* Called when data arrives from client */
static err_t echo_recv_cb(void *arg, struct tcp_pcb *pcb,
struct pbuf *p, err_t err)
{
if (p == NULL) {
/* Client closed connection */
tcp_close(pcb);
return ERR_OK;
}
if (err != ERR_OK) {
pbuf_free(p);
return err;
}
/* Echo the payload back */
err_t write_err = tcp_write(pcb, p->payload, p->len,
TCP_WRITE_FLAG_COPY);
tcp_output(pcb);
/* Acknowledge received bytes to LwIP */
tcp_recved(pcb, p->len);
pbuf_free(p);
return write_err;
}
/* Called by LwIP when a new TCP client connects */
static err_t echo_accept_cb(void *arg, struct tcp_pcb *client_pcb,
err_t err)
{
if (err != ERR_OK || client_pcb == NULL) return ERR_VAL;
printf("TCP client connected from %s\r\n",
ipaddr_ntoa(&client_pcb->remote_ip));
/* Register receive callback for this connection */
tcp_recv(client_pcb, echo_recv_cb);
return ERR_OK;
}
/* Initialise and start the TCP echo server */
void tcp_echo_server_init(void)
{
struct tcp_pcb *pcb = tcp_new();
if (pcb == NULL) { printf("tcp_new failed\r\n"); return; }
err_t err = tcp_bind(pcb, IP_ADDR_ANY, ECHO_PORT);
if (err != ERR_OK) {
printf("tcp_bind failed: %d\r\n", err);
tcp_abort(pcb);
return;
}
echo_listen_pcb = tcp_listen(pcb);
if (echo_listen_pcb == NULL) {
printf("tcp_listen failed\r\n");
return;
}
tcp_accept(echo_listen_pcb, echo_accept_cb);
printf("TCP echo server listening on port %d\r\n", ECHO_PORT);
}
HTTP Server
A minimal HTTP/1.0 server on STM32 can serve configuration pages, sensor dashboards, and REST API endpoints — all accessible from any web browser on the same network. For an embedded device with a few sensor readings, a single-connection HTTP server fits comfortably in 2 KB of RAM.
The key insight for embedded HTTP: you do not need a full HTTP parser. Parse only the first line of the request (the request line), extract the method and path, and respond accordingly. For GET requests to /data, return a JSON payload. For GET to /, return a minimal HTML page.
HTTP Response Structure
A minimal HTTP/1.0 response requires only three lines before the body:
HTTP/1.0 200 OK\r\n — status line
Content-Type: application/json\r\n — MIME type
\r\n — blank line separating headers from body
HTTP/1.0 (as opposed to 1.1) is preferred for embedded servers because it closes the connection after each response — no persistent connection or chunked transfer encoding to handle.
/* ================================================================
* Minimal HTTP/1.0 server — serves sensor JSON at GET /data
* Returns {"temperature":23.5,"pressure":1013.2} from ADC/I2C
* Listens on TCP port 80
* ================================================================ */
#include "lwip/tcp.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#define HTTP_PORT 80
/* Build HTTP response with current sensor readings */
static int build_sensor_json(char *buf, size_t len)
{
/* Read temperature and pressure from I2C sensor (e.g., BMP280) */
float temperature = bmp280_read_temperature(); /* degrees C */
float pressure = bmp280_read_pressure(); /* hPa */
return snprintf(buf, len,
"HTTP/1.0 200 OK\r\n"
"Content-Type: application/json\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"\r\n"
"{\"temperature\":%.1f,\"pressure\":%.1f,"
"\"uptime_ms\":%lu}\r\n",
temperature, pressure, HAL_GetTick());
}
/* Build minimal HTML dashboard page */
static int build_html_page(char *buf, size_t len)
{
float t = bmp280_read_temperature();
float p = bmp280_read_pressure();
return snprintf(buf, len,
"HTTP/1.0 200 OK\r\n"
"Content-Type: text/html\r\n"
"Connection: close\r\n"
"\r\n"
""
""
"STM32 Dashboard"
"STM32 Sensor Dashboard
"
"Temperature: %.1f °C
"
"Pressure: %.1f hPa
"
"Uptime: %lu ms
"
"\r\n",
t, p, HAL_GetTick());
}
static err_t http_recv_cb(void *arg, struct tcp_pcb *pcb,
struct pbuf *p, err_t err)
{
static char resp[600];
int resp_len = 0;
if (p == NULL) { tcp_close(pcb); return ERR_OK; }
if (err != ERR_OK) { pbuf_free(p); return err; }
/* Parse first line only — extract method and path */
char *req = (char *)p->payload;
if (strncmp(req, "GET /data", 9) == 0) {
resp_len = build_sensor_json(resp, sizeof(resp));
} else if (strncmp(req, "GET /", 5) == 0) {
resp_len = build_html_page(resp, sizeof(resp));
} else {
const char *not_found =
"HTTP/1.0 404 Not Found\r\n\r\n404\r\n";
resp_len = strlen(not_found);
memcpy(resp, not_found, resp_len);
}
tcp_write(pcb, resp, resp_len, TCP_WRITE_FLAG_COPY);
tcp_output(pcb);
tcp_recved(pcb, p->len);
pbuf_free(p);
tcp_close(pcb); /* HTTP/1.0 — close after response */
return ERR_OK;
}
MQTT Telemetry
MQTT (Message Queuing Telemetry Transport, OASIS standard) is a publish/subscribe protocol designed explicitly for constrained IoT devices. It runs over TCP (port 1883 for plain, 8883 for TLS), uses a broker-mediated architecture, and has a protocol overhead of as little as 2 bytes per message header. These properties make it the dominant protocol for embedded telemetry in industrial IoT, home automation, and cloud-connected sensor nodes.
MQTT Concepts
- Broker: A server (e.g., Mosquitto, EMQX, AWS IoT Core) that receives all messages from publishers and routes them to subscribers. Your STM32 connects to the broker as a client.
- Topic: A UTF-8 string that hierarchically names a message channel. Convention:
device/sensor/measurement. Wildcards: + (one level), # (all levels from here).
- QoS levels: 0 = at most once (fire and forget), 1 = at least once (acknowledged), 2 = exactly once (two-phase commit). For telemetry, QoS 0 is typically sufficient.
- Retain: The broker stores the last message on a retained topic. New subscribers immediately receive the last known value — useful for status flags and configuration.
- Last Will and Testament (LWT): A message the broker publishes on your behalf if your TCP connection drops unexpectedly. Use this to indicate device offline status.
MQTT Packet Sequence
The connect → publish cycle involves five steps:
- Establish TCP connection to broker IP:1883.
- Send MQTT CONNECT packet (client ID, keep-alive, credentials, LWT).
- Receive CONNACK — check return code (0 = accepted).
- Send MQTT PUBLISH packet for each sensor reading.
- Send MQTT PINGREQ every keep-alive interval to prevent broker disconnection.
/* ================================================================
* Minimal MQTT client over LwIP raw TCP
* Publishes temperature/pressure to Mosquitto broker
* Topics: stm32/sensors/temperature, stm32/sensors/pressure
* ================================================================ */
#include "lwip/tcp.h"
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#define MQTT_PORT 1883
#define MQTT_CLIENT_ID "stm32-node-01"
#define MQTT_KEEPALIVE 60 /* seconds */
static struct tcp_pcb *mqtt_pcb = NULL;
static uint8_t mqtt_buf[256];
/* Build a minimal MQTT CONNECT packet */
static uint16_t mqtt_build_connect(uint8_t *buf)
{
const char *proto = "MQTT";
const char *cid = MQTT_CLIENT_ID;
uint8_t cid_len = (uint8_t)strlen(cid);
uint16_t payload_len = 2 + 4 + 1 + 1 + 2 + 2 + cid_len;
/* Variable header: protocol name, level, flags, keepalive */
/* Fixed header */
uint8_t *p = buf;
*p++ = 0x10; /* CONNECT packet type */
*p++ = (uint8_t)payload_len; /* Remaining length (simplified) */
/* Protocol name: "MQTT" */
*p++ = 0x00; *p++ = 0x04;
memcpy(p, proto, 4); p += 4;
*p++ = 0x04; /* Protocol level (MQTT 3.1.1) */
*p++ = 0x02; /* Connect flags: Clean Session */
*p++ = (MQTT_KEEPALIVE >> 8) & 0xFF;
*p++ = (MQTT_KEEPALIVE >> 0) & 0xFF;
/* Client ID */
*p++ = 0x00; *p++ = cid_len;
memcpy(p, cid, cid_len); p += cid_len;
return (uint16_t)(p - buf);
}
/* Build a MQTT PUBLISH packet */
static uint16_t mqtt_build_publish(uint8_t *buf,
const char *topic,
const char *payload)
{
uint8_t tlen = (uint8_t)strlen(topic);
uint8_t plen = (uint8_t)strlen(payload);
uint8_t remain = 2 + tlen + plen;
uint8_t *p = buf;
*p++ = 0x30; /* PUBLISH, QoS=0, no retain, no dup */
*p++ = remain;
*p++ = 0x00; *p++ = tlen;
memcpy(p, topic, tlen); p += tlen;
memcpy(p, payload, plen); p += plen;
return (uint16_t)(p - buf);
}
/* MQTT receive callback — handle CONNACK */
static err_t mqtt_recv_cb(void *arg, struct tcp_pcb *pcb,
struct pbuf *p, err_t err)
{
if (p == NULL) { mqtt_pcb = NULL; return ERR_OK; }
uint8_t *data = (uint8_t *)p->payload;
if (data[0] == 0x20 && data[3] == 0x00) {
printf("MQTT CONNACK: connected\r\n");
}
tcp_recved(pcb, p->len);
pbuf_free(p);
return ERR_OK;
}
/* MQTT connect callback — send CONNECT after TCP established */
static err_t mqtt_connected_cb(void *arg, struct tcp_pcb *pcb,
err_t err)
{
if (err != ERR_OK) return err;
tcp_recv(pcb, mqtt_recv_cb);
uint16_t len = mqtt_build_connect(mqtt_buf);
tcp_write(pcb, mqtt_buf, len, TCP_WRITE_FLAG_COPY);
tcp_output(pcb);
return ERR_OK;
}
/* Publish one telemetry reading */
void mqtt_publish_sensor(float temperature, float pressure)
{
if (mqtt_pcb == NULL) return;
char val[16];
/* Publish temperature */
snprintf(val, sizeof(val), "%.2f", temperature);
uint16_t len = mqtt_build_publish(mqtt_buf,
"stm32/sensors/temperature", val);
tcp_write(mqtt_pcb, mqtt_buf, len, TCP_WRITE_FLAG_COPY);
/* Publish pressure */
snprintf(val, sizeof(val), "%.2f", pressure);
len = mqtt_build_publish(mqtt_buf,
"stm32/sensors/pressure", val);
tcp_write(mqtt_pcb, mqtt_buf, len, TCP_WRITE_FLAG_COPY);
tcp_output(mqtt_pcb);
}
/* Initiate MQTT connection to broker */
void mqtt_client_connect(const ip_addr_t *broker_ip)
{
mqtt_pcb = tcp_new();
if (mqtt_pcb == NULL) return;
tcp_connect(mqtt_pcb, broker_ip, MQTT_PORT, mqtt_connected_cb);
}
Production MQTT: For production use, consider the official Paho MQTT Embedded C library or the CoreMQTT library from AWS (lightweight, no dynamic allocation). Both integrate cleanly with LwIP's socket API under FreeRTOS and handle QoS 1/2, reconnection, and TLS termination. The manual packet construction above is educational — it should not be used in production without adding proper remaining-length encoding for payloads longer than 127 bytes.
Exercises
Exercise 1
Beginner
DHCP + Ping + TCP Echo
Connect an STM32 with an Ethernet PHY (Nucleo-F429ZI or equivalent custom board with LAN8720A). Configure LwIP + DHCP in bare-metal mode (NO_SYS=1). Verify the board obtains an IP address by printing it over UART. Ping the board from a PC (ping 192.168.x.x) and verify ICMP echo replies. Start the TCP echo server from Section 5 and verify it with netcat: nc 192.168.x.x 7 — type text and see it echoed back.
LwIP
DHCP
ICMP Ping
TCP Echo
Exercise 2
Intermediate
Web Dashboard with LED Control
Build an HTTP server that serves an HTML dashboard page at / showing live ADC values and LED state. Implement two control endpoints: GET /led/on and GET /led/off toggle the board LED and redirect the browser back to /. The dashboard page auto-refreshes every 5 seconds. Verify by loading the page in a browser on a PC connected to the same LAN, clicking both links, and observing the LED and page state. Measure HTTP response latency with browser developer tools.
HTTP Server
REST API
HTML Dashboard
LED Control
Exercise 3
Advanced
FreeRTOS + LwIP Socket API + MQTT at 100 msg/s
Integrate FreeRTOS with LwIP in socket API mode (NO_SYS=0). Create two tasks: SensorTask acquires temperature + pressure at 100 Hz and posts readings to a FreeRTOS queue (depth 200). NetworkTask drains the queue and publishes each reading via MQTT to a local Mosquitto broker running on a Raspberry Pi or PC. Subscribe on the PC using mosquitto_sub -t stm32/# -v and verify 100 messages per second for 5 minutes with zero message loss (compare published count on STM32 UART with received count on PC). Measure and report peak heap usage with xPortGetMinimumEverFreeHeapSize().
FreeRTOS
LwIP Socket API
MQTT 100 msg/s
Mosquitto
STM32 Ethernet Design Tool
Use this tool to document your STM32 Ethernet network stack design — PHY chip selection, MAC/PHY interface mode, LwIP stack configuration, network services, memory allocation, and DMA descriptor counts. Download as Word, Excel, PDF, or PPTX for design review or project documentation.
Conclusion & Next Steps
In this article we have built a complete networking stack on STM32:
- Ethernet architecture: The STM32 MAC operates in RMII mode with an external PHY (LAN8720A). DMA descriptor rings in non-cached SRAM drive the TX/RX path. The STM32H7 requires MPU configuration to maintain cache coherency between the CPU and the Ethernet DMA — a critical step that is easily missed.
- LwIP integration: CubeMX generates the
ethernetif.c glue layer and lwipopts.h configuration. In bare-metal mode (NO_SYS=1), ethernetif_input() and sys_check_timeouts() must be called from the superloop at high frequency. In FreeRTOS mode, a dedicated network task handles these calls.
- DHCP client:
dhcp_start() initiates the DISCOVER-OFFER-REQUEST-ACK cycle. Always implement a fallback static IP for headless deployments where a DHCP server may not be available.
- UDP telemetry: The raw API
udp_new() / udp_sendto() / pbuf_free() pattern delivers low-latency fire-and-forget datagrams at rates exceeding 10 kHz on a 168 MHz Cortex-M4.
- TCP server: The raw TCP API's accept→recv→write→close callback chain implements a complete server in under 80 lines. The echo server demonstrates the pattern; adapting it to HTTP or a custom binary protocol requires only changing the receive callback.
- HTTP server: A minimal HTTP/1.0 server requires no parser library. Parse the request line, branch on the path, and write a pre-built response string. Dynamic sensor data is formatted into the response body using
snprintf at request time.
- MQTT telemetry: MQTT's publish/subscribe model decouples the STM32 from data consumers — dashboards, databases, and cloud services all subscribe to the same topic without any changes to the firmware. The Paho or CoreMQTT library is recommended for production; the hand-built packet constructor above illustrates the protocol structure.
Next in the Series
In Part 18: Production Readiness, the final article in this series, we will harden your STM32 firmware for the real world: configuring the Independent and Window Watchdogs, writing a robust HardFault handler that captures the stack frame, protecting flash option bytes, implementing code signing for secure firmware update, and setting up a CI/CD pipeline that builds, tests, and signs firmware on every commit.
Related Articles in This Series
Part 18: Production Readiness
Watchdog timers, HardFault handler with stack dump, flash option byte protection, code signing, and CI/CD for embedded firmware.
Read Article
Part 14: FreeRTOS Integration
Tasks, queues, semaphores, mutexes, the CMSIS-RTOS2 wrapper, and correct stack sizing — the foundation for FreeRTOS + LwIP networking.
Read Article
Part 8: DMA & Memory Efficiency
DMA streams, circular buffer mode, memory-to-memory transfers, and zero-copy data pipelines — essential for high-throughput Ethernet reception.
Read Article