Mustafa Sepen Robotics Software Engineer

Project 03

STM32H7 ROS 2 UDP Link

Deterministic motion-control link over Ethernet

Role
Firmware, protocol and host node
Year
2026
Platform
NUCLEO-H723ZG · Raspberry Pi · 100 Mbit Ethernet
Link
100 Mbit switched Ethernet, UDP
Measured
1 kHz echo, ten minutes

Measured on hardware

  • 300µs Round trip, host to MCU and back
  • 0 Packets lost in 600,000 at 1 kHz
  • 28µs MCU receive to transmit, ±1 µs
  • 0.4% MCU CPU at 150 packets/s each way

NUCLEO-H723ZG ↔ Raspberry Pi (Ubuntu 24.04, ROS 2 Humble) over a 100 Mbit switch. Ten-minute endurance run at 1 kHz.

The problem

Where the control loop should live

A general-purpose Linux scheduler is a poor place to close a motion-control loop. It is an excellent place to plan one.

The intended division of work is that the host sends intent only, and the control loop lives on the microcontroller, where timing does not depend on a general-purpose scheduler. That only works if the link between them is fast enough and predictable enough that it is not itself the problem — which means it has to be measured, not assumed.

This project builds that link between a Linux host running ROS 2 and an STM32H723 over 100 Mbit Ethernet, and characterises it: latency distribution, jitter, loss, and how much of the round trip is the microcontroller versus everything else.

The system

Two messages, fixed rate

The host publishes velocity commands; the microcontroller applies them, enforces a failsafe, and reports state back. Two messages at a fixed rate, so a bridge node on the host only has to translate between ROS topics and UDP packets.

Kinematics and closed-loop control are not written yet. So far the failsafe is the part that runs on the microcontroller.

Protocol

One header, three packet types

common/robot_link.h is the single definition. The firmware and the ROS 2 node include the same file, and the Python tools mirror it with a layout verified byte for byte against the C side.

Type Size Direction Contents
CMD 32 B host → MCU vx, vy, wz, flags, seq, timestamp
STATE 92 B MCU → host wheel vel/pos, odom, measured twist, status bits
ECHO 66 B round trip transport latency probe

Struct sizes are checked at compile time, so adding a field on one side without the other fails the build rather than misinterpreting a velocity command later. Every packet carries a CRC16-CCITT.

The command rate is decoupled from whatever publishes into the bridge. If a planner publishes irregularly or at 15 Hz, the bridge still feeds the microcontroller at a steady 100 Hz by holding the last value — so the timeout on the MCU measures real command freshness rather than planner jitter.

Safety behaviour

Failsafe, sequence and reordering

  • No valid command for 150 ms and the velocities ramp to zero at 5 m/s² and 10 rad/s², with a CMD_TIMEOUT status bit and a red LED. Ramping rather than cutting, to avoid mechanical shock and wheel slip at speed.
  • A host restart resets its sequence counter, which must not lock the robot out.
  • UDP can reorder packets. A small backward step in seq is treated as a reordered duplicate and dropped; a large one is treated as a new session.
  • SEQ_GAP and CRC_ERROR reflect the last second rather than a cumulative count, so they can clear once a problem goes away.

Results

1 kHz echo, ten minutes

Endurance — round-trip time

min 299.1 µs
p50 369.6 µs
p90 387.2 µs
p99 429.4 µs
max 1784.6 µs
mean 374.6 µs
jitter (std) 16.1 µs

sent 600000 · received 600000 · loss 0 (0.000%) · out-of-order 0

Latency budget — 300 µs round trip

In a 100 Hz control loop the link takes under 4% of the period. Both directions run at full rate with zero drops, and the microcontroller sustains it at roughly 0.4% CPU.

Moving the host node from Python to C++ reduced p50 from 371 µs to 300 µs and p99 from 429 µs to 370 µs on the same hardware — worth knowing before considering faster host hardware.

max = 1784.6 µs is a single tail event in 600,000. MCU internal processing stayed flat at 28 µs through it, so the source is host-side, not the STM32.

Measurement methodology (opens in a new tab) — transport round-trip time and command-to-telemetry latency are reported separately, because telemetry is emitted on a fixed 100 Hz timer and conflating the two makes a healthy link look far worse than it is.

Notes from bring-up

Four failures with no error message

Six issues are written up in the repository with the symptom and how each was diagnosed. Four are worth repeating, because none of them produced a compiler warning, an error return, or a change in any lwIP counter:

  • CubeMX's CMake generator does not emit the D2 SRAM linker sections, so the Ethernet DMA descriptors ended up in DTCM — memory the DMA cannot access. The build succeeded and the PHY link came up, but no frame was ever transferred.
  • The RX thread's default stack is 350 bytes, which is in bytes under CMSIS-RTOS v2. It overflowed during startup and lwIP saw nothing.
  • SO_RCVTIMEO expects a struct timeval. Passing an int makes setsockopt return −1 without any other effect, and recvfrom then blocks indefinitely.
  • lwip_sendto() references the caller's buffer rather than copying it. A buffer in .bss caused a fatal bus error in the TX DMA, which stopped the whole channel including receive.

Three of these were traceable mainly because configCHECK_FOR_STACK_OVERFLOW, LWIP_STATS and direct reads of the ETH DMA status register were enabled from the beginning.

Status

What runs, and what does not yet

Ethernet bring-up, latency characterisationDone
Protocol, failsafe, 100 Hz telemetryDone
ROS 2 measurement nodeDone
ROS 2 bridge into a Nav2 stackIn progress
Kinematics and motor driverNot started
Encoders, odometry, closed loopNot started

Motors are not driven yet. /odom carries zeros until the encoder work is done; no odometry is estimated from commands, since that would look like real data without being it.

Tech stack

Firmware

  • C
  • STM32H723ZG
  • FreeRTOS
  • lwIP
  • STM32CubeMX
  • CMake

Host

  • C++
  • ROS 2 Humble
  • Python measurement tools
  • Ubuntu 24.04

Link

  • Ethernet 100 Mbit
  • UDP
  • CRC16-CCITT
  • RMII / LAN8742 PHY

Tooling

  • OpenOCD
  • ST-LINK V3E
  • FreeRTOS stack checks
  • LWIP_STATS