micro-ROS on Microcontrollers: XRCE-DDS Agent Architecture and Tutorial (2026)
Most “ROS 2 on a microcontroller” projects stall at the same place. The team flashes an example, sees a topic appear in ros2 topic list, and assumes the hard part is over. Then the agent process dies during a demo, the board keeps publishing into a dead socket, or a float64[] field silently truncates because nobody sized the sequence. micro-ROS is not a smaller ROS 2. It is a deliberately different architecture, with a thin client on the chip, a bridge process on a Linux machine, and a static-memory discipline that changes how you write firmware.
That matters now because ROS 2 is increasingly the integration layer for robots whose joints, sensors and safety loops live on Cortex-M and ESP32 parts, and the distribution matrix has moved: the project’s setup repository currently marks Humble, Jazzy, Kilted and Rolling as supported. This article gives you the mental model, the memory knobs that actually exist, the executor semantics, a runnable publisher, and an honest list of failure modes.
What this covers: the client-agent split and the DDS-XRCE protocol, how the RMW layer maps ROS 2 onto it, transports and agent deployment, memory budgeting with real configuration parameters, the rclc executor, a working C publisher with reconnection logic, and where Zenoh-pico fits as an alternative.
Context and Background
ROS 2 was designed around DDS, the Data Distribution Service, in which every participant is a peer that discovers others over multicast, holds history caches and negotiates Quality of Service (QoS). That model assumes megabytes of RAM, a full IP stack and a threaded runtime. A Cortex-M4 with 256 KB of RAM and a UART to its host satisfies none of those assumptions.
The Object Management Group answered this with the DDS-XRCE specification, “DDS For Extremely Resource Constrained Environments”. The OMG page lists version 1.0 as the formal version, adopted in February 2020. It defines a protocol between a resource-constrained client and a server-side Agent, so the device can publish and subscribe in a DDS domain through an intermediary. The stated goal is interoperability between vendor implementations. Note that the protocol was drafted before ROS 2 adoption of microcontrollers took off, which is why some ROS-specific needs (graph introspection, for example) are bolted on in the RMW layer rather than the wire protocol.
micro-ROS is the ROS 2 stack built on top of that idea. Per the project’s feature overview, it follows the ROS 2 layered design but uses DDS-XRCE in place of standard DDS and a POSIX-style RTOS in place of Linux. The middleware is eProsima’s Micro XRCE-DDS, plugged in through the ROS Middleware Interface (RMW). The client library is C, with nodes, publish/subscribe, client/service, node graph and lifecycle support. The listed RTOSes are FreeRTOS, Zephyr and NuttX, and any RTOS with a POSIX interface can in principle be ported. The client library, middleware layer and tools are Apache 2.0 licensed.
The incumbents on a robot are therefore not other ROS stacks but other transport philosophies: raw serial protocols with a custom bridge (rosserial’s spiritual successors), MQTT gateways, CAN-based schemes like CANopen, and in 2026 Zenoh-pico. Each of those gives you a bridge to maintain by hand. micro-ROS gives you a standard bridge, with the price being the agent as a mandatory middleman. If you are coming from the ROS 2 side and want the bigger migration picture first, our guide to migrating between ROS 2 Kilted and Lyrical explains how distribution cadence affects the micro-ROS branches discussed below. For the middleware landscape in general, see Zenoh vs MQTT vs DDS for robotics middleware.
One caution on maturity. The micro-ROS repositories (both micro_ros_setup and the agent) carry a notice that the software is not ready for production use. That wording is the project’s own, and it reflects the fact that safety certification, long-term support guarantees and a stable ABI across distributions are the integrator’s problem. Teams ship micro-ROS in products anyway, but they own the validation. The official sources for everything below are the micro-ROS documentation and the Micro XRCE-DDS documentation.
The micro-ROS Architecture: Client, Agent and the RMW Seam
micro-ROS splits a ROS 2 node in two. A small C99 XRCE-DDS client runs on the microcontroller and speaks a compact binary protocol over serial, UDP or TCP. A C++ agent on a Linux host translates every request into real DDS entities, so the microcontroller appears as an ordinary ROS 2 node to the rest of the graph.

Figure 1: The micro-ROS stack. Everything left of the agent runs on the microcontroller with no dynamic memory after initialisation; everything right of it is conventional ROS 2.
Figure 1 shows the layers. At the top is your application code, written against rclc, a C convenience layer over rcl (the ROS client support library that rclcpp and rclpy also build on). Below that sits rmw_microxrcedds, the RMW implementation that turns rcl calls into XRCE-DDS client calls. Below that is the Micro XRCE-DDS Client, which the eProsima docs describe as a C99 library, supported by Micro CDR for serialisation and a code generator for IDL types. The agent is a separate C++11 application.
Why an agent at all
A full DDS participant must do discovery (the Simple Discovery Protocol exchanges participant and endpoint data), hold per-reader and per-writer history, and implement the RTPS reliability protocol. The XRCE design moves all of that to the agent. According to the eProsima agent documentation, the agent receives operation requests from clients, tracks the entities they create, and “interacts with the DDS data space” on their behalf. With the default Fast DDS middleware option (-m dds), each XRCE entity gets a DDS proxy entity, so a read or write on the microcontroller becomes a DDS publish or subscribe.
The consequence is architectural, not just a footprint trick. The microcontroller never sees the DDS network. It sees one peer, the agent. That gives you three properties. Discovery traffic never touches the constrained link. Any transport the agent supports becomes available (serial, UDP, TCP, CAN FD on Linux). And the microcontroller is invisible to DDS security unless you secure the agent side, which we return to in the failure-modes section.
The cost is equally structural. The agent is a single point of failure per client link, a second process to deploy and supervise, and a latency hop. Whether that trade is acceptable depends on your topology: one agent per robot on its onboard computer is usually fine; one agent for a fleet of boards across a flaky Wi-Fi network is not.
The wire protocol: sessions, streams and entities
XRCE is session-oriented. A client creates a session with a session key (the client-API docs state that each client connected to an agent needs a different key), then creates entities: participant, topic, publisher, data writer, subscriber, data reader. Each creation request is acknowledged with a status. Data moves over streams. The client documentation says stream IDs 1 to 127 are best-effort and 128 to 255 are reliable, and that the number of streams is capped by compile-time macros such as CONFIG_MAX_OUTPUT_BEST_EFFORT_STREAMS.

Figure 2: XRCE-DDS session lifecycle. Entities are created by request/status pairs; reliable streams add HEARTBEAT/ACKNACK exchanges on top of data.
Reliable streams are where the memory budget lives. The client docs explain that a reliable stream takes a buffer, a size and a history: the buffer is divided into history slots, and history must be a power of two. Each slot holds one MTU-sized message that may need retransmission until the agent acknowledges it. The agent side exposes a heartbeat period; the agent docs list UAGENT_CONFIG_HEARTBEAT_PERIOD with a default of 200 ms and UAGENT_CONFIG_CLIENT_DEAD_TIME with a default of 30,000 ms. Those are compile-time agent settings. I could not verify from the pages I fetched how a dead-time expiry interacts with a client that is still alive but silent, so treat that boundary as something to test on your own link.
Two entity-creation modes exist, and they matter for flash and RAM. In XML mode (the default, bin in the RMW configuration), the entity description is carried by the client and the agent parses it. In reference mode (refs), the entities are defined in a profile file on the agent, given labels, and the client refers to the label. The Micro XRCE-DDS docs note this reduces client memory use because the XML is not compiled in. It also means QoS is defined centrally on the agent, which many teams prefer operationally.
How the RMW maps ROS 2 concepts
rmw_microxrcedds has a deliberately small contract. Graph introspection (RMW_UXRCE_GRAPH) is a build option that defaults to OFF in the repository’s parameter table. Services and clients are supported, as are guard conditions and wait sets, but each has a hard-coded maximum, discussed in the memory section. QoS is limited: the micro-ROS docs say the mechanism inherits the XRCE QoS set, and the XRCE-level controls the client documentation names are reliable and best-effort. Plan on those two plus history depth, and verify any other policy (deadline, lifespan, liveliness) against the current RMW README before depending on it.
Name length is also bounded: node and topic names default to 60 characters, type names to 100 (RMW_UXRCE_NODE_NAME_MAX_LENGTH, RMW_UXRCE_TOPIC_NAME_MAX_LENGTH, RMW_UXRCE_TYPE_NAME_MAX_LENGTH). A namespaced topic like /fleet/arm_left/joint_states_filtered is fine; a generated name embedding a UUID may not be.
The agent middleware options
Besides Fast DDS, the agent offers a Centralized (ced) middleware in which the agent acts, in the docs’ words, as an MQTT-like broker with no DDS output. Clients connected to the same agent exchange topics directly; by default clients on different agents cannot talk unless P2P is enabled with --p2p, which requires that middleware. The docs list an rtps option as deprecated. For ROS 2 interoperability you want dds, the default. The ced mode is interesting for microcontroller-to-microcontroller messaging without a ROS 2 graph at all, but then you have given up the reason to use micro-ROS.
The agent also supports a long list of transports: UDP and TCP over IPv4 and IPv6, serial and multiserial (Linux only), CAN FD (Linux only, interface must support 64-byte frames), pseudo-terminal, and a custom transport class (eprosima::uxr::CustomAgent). Discovery of the agent by clients can be enabled with -d on the default port 7400. The client side has a matching PROFILE_DISCOVERY build profile, which the docs mark as Linux only, so on a microcontroller you will almost always configure the agent address statically.
The practical reading of Figure 1: the dashed branch to the centralised broker is a niche; the solid path through Fast DDS proxy entities is what you deploy.
Deeper Analysis: Transports, Memory Budgets and the rclc Executor
Picking a transport
The agent supports serial, UDP and TCP natively, and the micro-ROS overview adds that Wi-Fi, 6LoWPAN and Bluetooth have been used, with templates for custom transports. On the client side, the RMW is built for exactly one transport at a time, selected by RMW_UXRCE_TRANSPORT (udp, serial or custom, default udp) and RMW_UXRCE_IPV (ipv4 or ipv6, default ipv4). That is a build-time decision baked into the library, so a board that must talk UDP in the lab and UART in the field needs two builds or a custom transport that wraps both.
| Transport | Typical use | Strengths | Watch out for |
|---|---|---|---|
| Serial (UART) | Board wired to onboard computer | Deterministic, no IP stack, cheap | Framing is a custom XRCE serial protocol; baud rate caps throughput (agent default 115200) |
| UDP | Ethernet or Wi-Fi boards | Simple, low overhead, agent discovery optional | Packet loss needs reliable streams; no built-in security |
| TCP | Routed or NAT links | Delivery guarantees at transport layer | Docs note best-effort over TCP behaves like reliable over UDP; larger stack footprint |
| Custom (USB CDC, CAN, SPI, radio) | Everything else | Fits odd hardware | You implement open/close/read/write callbacks and test them under loss |
The serial option deserves a note. At the default agent baud rate of 115,200, a UART moves roughly 11.5 kilobytes per second at 8N1 framing (10 bits per byte), before XRCE headers and framing overhead. A 100 Hz IMU stream of 80-byte messages is 8 KB/s of payload and already eats most of that. Raise the baud rate on both ends (the agent exposes -b), or move to USB CDC or UDP. This is arithmetic, not a benchmark; measure your own overhead.
For Espressif parts, the micro_ros_espidf_component repository documents UDP as the default transport and UART through a custom transport, with a sample called int32_publisher_custom_transport. Switching the RMW from UDP to a custom transport means adding -DRMW_UXRCE_TRANSPORT=custom to the cmake-args of the rmw_microxrcedds entry in colcon.meta. The same repository lists ESP32, ESP32-S2, ESP32-S3, ESP32-C3 and ESP32-C6 as supported chips, with ESP-IDF v5.4, v5.5 and v6.0 as supported IDF versions at the time of my reading.
Memory: what is configurable and what that means in bytes
The headline claim in the micro-ROS overview is that the client library (rcl plus rclc) needs no dynamic memory allocation after initialisation, with static memory pools replacing runtime allocation in the RMW layer. The Micro XRCE-DDS docs go further for the middleware client alone: they call it “completely dynamic memory free” and say the latest version uses under 75 KB of flash and about 3 KB of RAM for a publisher-plus-subscriber application with roughly 512 B messages. Read that carefully. It describes the XRCE client for one test application, not a full micro-ROS image including rcl, rclc, message type support and your RTOS. Treat 75 KB and 3 KB as a floor, not a budget. I did not find an official micro-ROS number for a complete image, so any figure you see elsewhere should be treated as the author’s measurement on their build.
The knobs live in the RMW configuration, usually set through colcon.meta. The rmw_microxrcedds README lists these defaults:
| Parameter | Default | What it bounds |
|---|---|---|
RMW_UXRCE_MAX_NODES |
4 | Nodes in the application |
RMW_UXRCE_MAX_PUBLISHERS |
4 | Publishers |
RMW_UXRCE_MAX_SUBSCRIPTIONS |
4 | Subscriptions |
RMW_UXRCE_MAX_SERVICES / _CLIENTS |
4 / 4 | Service servers and clients |
RMW_UXRCE_MAX_HISTORY |
8 | History slots for subscriptions, requests and replies |
RMW_UXRCE_STREAM_HISTORY |
4 | MTUs buffered per stream, power of two |
RMW_UXRCE_MAX_SESSIONS |
1 | XRCE sessions |
RMW_UXRCE_MAX_WAIT_SETS / _GUARD_CONDITION |
4 / 4 | Wait sets, guard conditions |
RMW_UXRCE_ALLOW_DYNAMIC_ALLOCATIONS |
OFF | Whether static pools may grow via heap |
RMW_UXRCE_PUBLISH_RELIABLE_TIMEOUT |
1000 ms | Block time for a reliable publish |
RMW_UXRCE_ENTITY_CREATION_TIMEOUT |
1000 ms | Block time for entity creation |
Here is a worked, illustrative budget (the MTU is an assumption; read your transport’s real value). Suppose the MTU is 512 bytes and RMW_UXRCE_STREAM_HISTORY is 4. A reliable output stream then buffers up to 4 x 512 = 2,048 bytes of unacknowledged traffic, and the input stream another 2,048 bytes, about 4 KB for stream buffers alone. Raise history to 8 and it doubles. Now add subscription history: with RMW_UXRCE_MAX_HISTORY at 8, a subscription can queue 8 incoming messages, each needing storage sized for your message type, so a 200-byte message costs 1,600 bytes per subscriber at worst. Five subscribers hit the default cap of 4 and fail at creation, which is the usual first surprise.
Message memory is the second cost center and the one most often wrong. The micro-ROS type-memory tutorial explains that you work in C99, so sequences (variable-size arrays) are structs with data, size and capacity fields, and “usually in micro-ROS, the user is in charge of assigning memory and values” to them. Strings are char sequences. The micro_ros_utilities helper offers micro_ros_utilities_create_static_message_memory, which carves message members from a user-provided static buffer, plus micro_ros_utilities_get_static_size and _get_dynamic_size to compute the requirement. Its default configuration allocates 20 characters for strings and 5 elements for both ROS 2 type sequences and basic type sequences, with per-member overrides by dotted path such as "multiheaders.frame_id". A sensor_msgs/msg/JointState has four sequences (name, position, velocity, effort) plus a header frame id. At the defaults you get 5 joints and a 20 character frame id. A 6-axis arm silently needs capacity 6 or larger, and a subscriber that receives a message bigger than the configured capacity will drop or fail rather than grow, because growth is the heap you removed.
The tutorial itself warns about its examples: the manual loop assigns data = i instead of data[i], and one malloc sizes a field with the wrong capacity. Copy-paste from that page at your own risk and read the code.
Executors: rclc is not rclcpp
The rclc executor is the other place where micro-ROS behaves differently enough to bite. The micro-ROS documentation explains the design rationale: ROS 2 rclcpp executors process callbacks in an order determined by the wait set, which is hard to analyse for worst-case latency. The rclc executor lets you specify the callback processing order.

Figure 3: One rclc executor cycle. The trigger decides whether to run; callbacks then run in the order you registered them.
The mechanics: rclc_executor_init takes the total number of handles you will register, and the memory is allocated once during configuration, so the executor is static and callbacks cannot be added at runtime. Handle types are subscriptions, timers, services, clients, guard conditions, actions and lifecycle. Each handle can be set to ON_NEW_DATA (call only if data arrived) or ALWAYS (call regardless, passing NULL when there is no data). The default semantics match rclcpp: wait with rcl_wait, then rcl_take each message immediately before its callback. A second mode, Logical Execution Time (LET), reads all available inputs at the trigger instant into buffers and runs the callbacks on the snapshot. The documentation notes that the LET behaviour of writing outputs at the end of the period is not implemented yet, so do not design around full LET.
Trigger conditions are the feature to learn. rclc_executor_trigger_any is the default and runs when any callback has new data. rclc_executor_trigger_all waits until every handle has new data. rclc_executor_trigger_one(&handle) runs when a particular handle has data, and a user function can encode arbitrary logic, including hardware triggers such as interrupts. The docs’ own motivating example is sensor fusion with an IMU at 500 Hz and a laser at 10 Hz: trigger on the laser and aggregate 50 IMU samples per scan, rather than waking the fusion callback 500 times a second. Spin functions are spin_some (once, with a timeout), spin_period (fixed period) and spin (forever). Multi-threaded execution, one thread per callback with an OS scheduling policy, is marked work in progress in the docs, so assume a single-threaded executor and put real-time work either in ordinary RTOS tasks or in a dedicated executor thread you own.
The timing consequence is clean enough to plan with. Within one cycle, callback k starts only after callback k-1 returns, so the worst-case response of the last callback is the sum of the earlier callbacks’ worst-case times plus executor overhead. Put the control-critical callback first, keep callbacks short, and never block in a callback on a reliable publish: the RMW_UXRCE_PUBLISH_RELIABLE_TIMEOUT default of 1000 ms means a lost agent can hold your control loop for a full second.
Walk-through: a publisher that survives agent loss
Start with the agent, since nothing works without it. The micro-ROS ESP-IDF and Zephyr component repositories both document Docker invocations using the microros/micro-ros-agent image, tagged by distribution. For a UDP board:
# UDP transport on port 8888 (tag must match the distro your firmware library was built for)
docker run -it --rm --net=host microros/micro-ros-agent:kilted udp4 --port 8888 -v6
# Serial transport; pass the device through
docker run -it --rm -v /dev:/dev --privileged --net=host \
microros/micro-ros-agent:kilted serial --dev /dev/ttyUSB0 -v6
The Zephyr module README uses the kilted tag and the ESP-IDF one shows rolling, so the tag is your distribution selector. The agent README warns that sharing serial ports with the agent inside Docker can work “but sometimes with significant packet loss”, and that the snap package has known serial issues; it recommends the Dockerized version or a source build. If you see serial corruption, test the agent natively before blaming the firmware. Use the same distribution for the agent image, the firmware’s micro-ROS library and the ROS 2 install on the rest of the robot; mixing is the leading cause of “it connects but types mismatch”.
Now the firmware. The following is an rclc publisher in the style of the upstream int32_publisher example, extended with the agent state machine that the micro-ROS community uses to survive restarts. The core calls (rclc_support_init, rclc_node_init_default, rclc_publisher_init_default, rclc_executor_*, rmw_uros_ping_agent) are the standard API; the exact timer-init signature has changed across distributions (newer ones add an autostart argument), so check the headers of the branch you build against.
#include <stdio.h>
#include <rcl/rcl.h>
#include <rcl/error_handling.h>
#include <rclc/rclc.h>
#include <rclc/executor.h>
#include <rmw_microros/rmw_microros.h>
#include <std_msgs/msg/int32.h>
#define RC_CHECK(fn) { rcl_ret_t rc = (fn); if (rc != RCL_RET_OK) return false; }
typedef enum { WAITING_AGENT, AGENT_AVAILABLE, AGENT_CONNECTED, AGENT_DISCONNECTED } state_t;
static rclc_support_t support;
static rcl_allocator_t allocator;
static rcl_node_t node;
static rcl_publisher_t pub;
static rcl_timer_t timer;
static rclc_executor_t executor;
static std_msgs__msg__Int32 msg;
static void timer_cb(rcl_timer_t *t, int64_t last_call_time) {
(void) last_call_time;
if (t == NULL) return;
msg.data++;
(void) rcl_publish(&pub, &msg, NULL); /* best effort: ignore transient errors */
}
static bool create_entities(void) {
allocator = rcl_get_default_allocator();
RC_CHECK(rclc_support_init(&support, 0, NULL, &allocator));
RC_CHECK(rclc_node_init_default(&node, "mcu_counter", "", &support));
RC_CHECK(rclc_publisher_init_best_effort(
&pub, &node, ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "mcu/counter"));
RC_CHECK(rclc_timer_init_default(&timer, &support, RCL_MS_TO_NS(100), timer_cb));
executor = rclc_executor_get_zero_initialized_executor();
RC_CHECK(rclc_executor_init(&executor, &support.context, 1, &allocator));
RC_CHECK(rclc_executor_add_timer(&executor, &timer));
return true;
}
static void destroy_entities(void) {
rmw_context_t *rmw_ctx = rcl_context_get_rmw_context(&support.context);
(void) rmw_uros_set_context_entity_destroy_session_timeout(rmw_ctx, 0);
(void) rcl_publisher_fini(&pub, &node);
(void) rcl_timer_fini(&timer);
rclc_executor_fini(&executor);
(void) rcl_node_fini(&node);
rclc_support_fini(&support);
}
void micro_ros_task(void *arg) {
(void) arg;
state_t state = WAITING_AGENT;
/* transport must be registered before this task: rmw_uros_set_custom_transport(...) or UDP/serial helper */
for (;;) {
switch (state) {
case WAITING_AGENT:
state = (RMW_RET_OK == rmw_uros_ping_agent(100, 1)) ? AGENT_AVAILABLE : WAITING_AGENT;
break;
case AGENT_AVAILABLE:
if (create_entities()) state = AGENT_CONNECTED;
else { destroy_entities(); state = WAITING_AGENT; }
break;
case AGENT_CONNECTED:
/* ping every cycle is too chatty at high rates; rate-limit in production */
if (RMW_RET_OK != rmw_uros_ping_agent(100, 1)) { state = AGENT_DISCONNECTED; break; }
rclc_executor_spin_some(&executor, RCL_MS_TO_NS(100));
break;
case AGENT_DISCONNECTED:
destroy_entities();
state = WAITING_AGENT;
break;
}
/* RTOS delay or yield here */
}
}

Figure 4: The reconnection state machine used in the code above. Losing the agent destroys all entities and returns to waiting; recreating them on the next pong gives the agent a clean slate.
A few things in that listing are deliberate. The publisher is best-effort, because telemetry from a 10 Hz counter should not stall waiting for acknowledgements. The RC_CHECK macro returns false on any creation failure, and the caller destroys partial state before retrying, because the static pools have fixed capacity and a leaked node eats one of the four default slots until reboot. Entities are destroyed with the session timeout set to zero (rmw_uros_set_context_entity_destroy_session_timeout, in the micro-ROS RMW helper API), so teardown against a dead agent does not block for the 1000 ms default. And pinging is done through rmw_uros_ping_agent(timeout_ms, attempts), which is a transport-level probe, not a ROS-level liveliness check.
On the ROS 2 side, verify end to end with the stock tools: ros2 topic list should show /mcu/counter and ros2 topic hz /mcu/counter should report close to 10 Hz. Kill the agent and restart it. The firmware should show a reconnect within a ping cycle or two, and the topic should reappear. Because every session start recreates entities, any subscriber on the ROS 2 side sees the publisher disappear and reappear, which matters if your downstream nodes treat publisher loss as a fault. Our walk-through of the ros2_control hardware interface shows where such a bridge would sit in a control stack and why you generally keep the hard real-time loop on the controller side.
Build flow and RTOS support
For the build, the micro_ros_setup package provides a generic build system as a ROS 2 package. Its README lists distributions as Humble, Jazzy, Kilted and Rolling supported, with Crystal, Dashing, Foxy, Galactic and Iron marked end of life. Its platform table is dated and conservative (Zephyr v2.6.0, FreeRTOS on STM32 Nucleo boards, ESP32 under FreeRTOS v8.2.0) and says support there is basic and aimed at ROS developers new to microcontrollers; it points to platform-specific components for deeper integration. Those are the ESP-IDF component, the Zephyr module (which lists Zephyr 4.0.0 and 4.1.0 in its README) and the Arduino and STM32CubeMX integrations. In practice you should use the platform component, not the generic table, for new work. For a comparison of the RTOS choices themselves, see Zephyr vs FreeRTOS vs NuttX for industrial RTOS selection.
Lyrical, the ROS 2 release that follows Kilted, already appears in the ESP-IDF component’s version table (26.x.y), alongside humble 22.x.y, iron 23.x.y, jazzy 24.x.y and kilted 25.x.y. I could not confirm from the sources I read whether the core micro-ROS repositories have cut a lyrical branch, so check the branch list before planning a migration.
micro-ROS versus Zenoh-pico
Zenoh-pico is the other serious option for ROS 2 on microcontrollers in 2026. The Eclipse repository describes it as the Zenoh implementation for constrained devices with a native C API, compatible with the main Rust implementation. It lists support for Zephyr, Arduino, ESP-IDF, FreeRTOS-Plus-TCP, MbedOS, Raspberry Pi Pico and others, over UDP, TCP, serial and more, and notes that buffer sizes such as BATCH_UNICAST_SIZE, BATCH_MULTICAST_SIZE and FRAG_MAX_SIZE can be reduced to fit limited memory. It publishes no quantified RAM or flash figures on that page, so I will not quote any. The ROS 2 integration is not in zenoh-pico itself; it comes from the Zenoh bridge or the rmw_zenoh middleware on the host. Our Zenoh-ROS 2 bridge tutorial covers that side.
| Dimension | micro-ROS (XRCE-DDS) | Zenoh-pico |
|---|---|---|
| Programming model | rcl/rclc nodes, topics, services, actions, lifecycle | Zenoh keys, publishers, subscribers, queries; ROS semantics via bridge or key mapping |
| Host component | micro-ROS agent (required, one hop) | Zenoh router or peer, bridge for ROS 2 |
| Executor | rclc with ordered callbacks and triggers | You write the loop; no ROS executor |
| Memory model | Static pools, sized at build time | Configurable buffers; verify on your target |
| QoS | XRCE reliable and best-effort | Zenoh reliability and congestion control settings |
| ROS tooling | Standard ROS 2 tools see an ordinary node | Depends on bridge configuration |
| Maturity signal | Project states not ready for production | Check release notes for your version |
My opinion, labelled as such: choose micro-ROS when the board is a ROS 2 citizen (parameters, services, lifecycle, ros2 tooling) and the link is point-to-point to a computer you control. Choose Zenoh-pico when you want peer-style routing across heterogeneous networks, or when you do not want a per-link agent. For a three-way comparison with MQTT, the middleware comparison goes deeper.
Trade-offs, Gotchas, and What Goes Wrong
The agent is a single point of failure. Everything between a board and the ROS 2 graph passes through one process. If it dies, the board is deaf and mute. The state machine in Figure 4 handles recovery on the firmware side, but the agent still needs supervision: run it under systemd or a container restart policy, and monitor it separately. For safety functions, never route an emergency stop through micro-ROS alone; keep a hardwired path.
Static pools fail at creation, not at runtime. With defaults of 4 nodes, publishers, subscriptions, services and clients, the fifth subscription returns an error from initialisation. That is good: failures happen at boot, not in the field. The anti-pattern is “fixing” it by enabling RMW_UXRCE_ALLOW_DYNAMIC_ALLOCATIONS, which lets static pools grow via heap. It trades a clear boot failure for fragmentation hazards on a device that may run for months.
Sequence truncation hides in message design. If a message has unbounded sequences and you rely on the utility defaults (20 characters, 5 elements), larger incoming data is a capacity problem, not a warning. Define worst-case capacities per member, compute them with micro_ros_utilities_get_static_size, and write a boot-time test that fills each sequence to capacity.
QoS is narrower than ROS 2. The reliable/best-effort split is what the XRCE documentation names; do not assume deadline, lifespan or liveliness policies behave as on a desktop. A common mismatch: a ROS 2 subscriber requests reliable and a microcontroller publisher is best-effort, so nothing connects and nothing errors loudly. Match them explicitly, and use ros2 topic info -v to inspect endpoint QoS.
Blocking calls bleed into control loops. The default reliable publish timeout is 1000 ms and entity creation timeouts are 1000 ms. A lost agent in the middle of a reliable publish can stall a callback for that long. Use best-effort for periodic telemetry, set timeouts deliberately, and keep control loops outside the micro-ROS executor task.
Distribution mismatch. The agent image tag, the firmware library branch and the host ROS 2 install should match. Humble, Jazzy, Kilted and Rolling are listed as supported in the setup repository; older ones are end of life. Mixing, for example, a Humble library with a Kilted agent can fail in ways that look like type or session errors.
Security gap. Neither the micro-ROS overview nor the pages I read describe transport encryption for XRCE sessions on microcontrollers, and I could not verify a supported secure path. Treat UDP and serial links as plaintext unless you add network-level protection such as VPN, an isolated wire or MACsec, and put the agent on a trusted segment. SROS 2 security applies to the DDS side behind the agent, not to the leg between board and agent.
Multithreading is not a given. The Micro XRCE-DDS client has a multithread profile (UCLIENT_PROFILE_MULTITHREAD) for stream locking, and rclc’s multi-threaded executor is marked work in progress. If two tasks publish from different threads, you must serialise access or enable the profile and test it.
Serial and Docker. As noted, sharing serial devices with the agent inside Docker can produce packet loss. Reliable streams will retransmit, which hides the problem until latency grows.
Practical Recommendations
Start from the platform component for your chip (the ESP-IDF component for Espressif parts, the Zephyr module for Zephyr boards), not the generic micro_ros_setup table, and pin the distribution tag everywhere. Keep one agent per robot on the onboard computer, supervised, with the same distribution as the rest of the robot. Prefer serial or wired Ethernet for control-adjacent boards and Wi-Fi only for non-critical telemetry.
Size memory from the top down. Decide how many nodes, publishers, subscriptions and services each firmware needs and set the RMW_UXRCE_* limits to those numbers plus a margin of one, rather than raising everything to 16. Put the worst-case capacity of every sequence in code and verify it at boot. Measure free heap and stack high-water marks after the first reconnection, since reconnect paths are the least-tested code in most deployments.
Use the executor deliberately. Put the control-critical callback first, trigger fusion callbacks on the slow sensor, and avoid blocking. Make every reliable publish a conscious choice.
A short checklist before you ship:
- Agent image tag, firmware branch and host ROS 2 distribution match.
- Agent runs under a supervisor and restarts on failure; firmware has the reconnect state machine.
- Every
RMW_UXRCE_*limit is set explicitly and noted incolcon.meta. - Sequence capacities are explicit and boot-tested.
- Telemetry is best-effort; only commands and parameters use reliable streams.
- Pings and entity timeouts are tuned; no callback can block for the 1000 ms default unnoticed.
- A hardwired safety path exists independent of the micro-ROS link.
- Link security is handled outside XRCE, and the gap is documented.
Frequently Asked Questions
What is micro-ROS?
micro-ROS is a ROS 2 client stack for microcontrollers. It uses the rcl and rclc C libraries, the rmw_microxrcedds middleware layer and eProsima’s Micro XRCE-DDS client to connect a board to a ROS 2 system through a micro-ROS agent. The project supports FreeRTOS, Zephyr and NuttX and is licensed under Apache 2.0. The project itself says the software is not ready for production use, so integrators carry the validation work.
Do I need the micro-ROS agent?
Yes. The agent is the bridge between the XRCE-DDS client on the microcontroller and the DDS data space. It creates proxy DDS entities for each client entity and returns status results. Without it, the board has no ROS 2 presence. It runs on a Linux host, commonly via the microros/micro-ros-agent Docker image, and supports serial, UDP and TCP, among others.
Which ROS 2 distributions does micro-ROS support?
According to the micro_ros_setup README at the time of writing, Humble, Jazzy, Kilted and Rolling are supported, and Crystal, Dashing, Foxy, Galactic and Iron are end of life. The ESP-IDF component’s table additionally lists a lyrical entry. Always check the branch for your distribution and make the agent image tag match.
How much memory does micro-ROS need?
The Micro XRCE-DDS docs state the client uses under 75 KB of flash and about 3 KB of RAM for a publisher and subscriber application with roughly 512 B messages. That covers the middleware client only. A full micro-ROS image adds rcl, rclc, type support, message buffers and your RTOS, so budget more and measure on your own build. I did not find an official full-stack figure.
What is the difference between rclc and rclcpp executors?
The rclc executor lets you set callback execution order and offers trigger conditions (any, all, one, or custom). Memory is allocated once at init, so handles cannot be added at runtime. rclcpp executors process work in wait-set order and allocate dynamically. rclc also offers LET semantics, though the docs say output writing at period end is not yet implemented.
Is micro-ROS better than Zenoh-pico?
Neither is universally better. micro-ROS gives you rclc nodes, services, lifecycle and ordinary ROS 2 tooling at the cost of a mandatory per-link agent. Zenoh-pico offers a router-based, peer-oriented model with a native C API, with ROS 2 integration coming from the host side. Choose by topology, tooling needs and your appetite for running agents.
Further Reading
- ROS 2 Kilted to Lyrical migration guide
- Zenoh-ROS 2 bridge tutorial
- Zenoh vs MQTT vs DDS for robotics middleware
- Zephyr vs FreeRTOS vs NuttX for industrial RTOS selection
- ros2_control hardware interface tutorial
- micro-ROS official documentation
- OMG DDS-XRCE specification
- Micro XRCE-DDS agent documentation
By Riju — about
