Project 03
STM32H7 ROS 2 UDP Link
Deterministic motion-control link over Ethernet
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.
Linux host
ROS 2
- bridge node
- /cmd_vel → CMD
- STATE → /odom
→ CMD · 100 Hz · UDP :5000
← STATE · 100 Hz · UDP :5001
STM32H723ZG
520 MHz · FreeRTOS
- link_rx (AboveNormal)
- link_tx (100 Hz, fixed)
- diag (Low)
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_TIMEOUTstatus 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
seqis treated as a reordered duplicate and dropped; a large one is treated as a new session. -
SEQ_GAPandCRC_ERRORreflect 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
- 28 µs — MCU, receive → transmit, ±1 µs
- 272 µs — host network stack and switch
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_RCVTIMEOexpects astruct timeval. Passing anintmakessetsockoptreturn −1 without any other effect, andrecvfromthen blocks indefinitely. -
lwip_sendto()references the caller's buffer rather than copying it. A buffer in.bsscaused 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 characterisation | Done |
|---|---|
| Protocol, failsafe, 100 Hz telemetry | Done |
| ROS 2 measurement node | Done |
| ROS 2 bridge into a Nav2 stack | In progress |
| Kinematics and motor driver | Not started |
| Encoders, odometry, closed loop | Not 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