Logging
nano-ros ships a ROS 2 style leveled logging facade
(nros-log)
with the same Logger + severity surface as rclcpp::Logger /
rcutils_logging. Records flow through a single per-platform sink
(PlatformSink) that delegates to whichever
nros-platform-<rtos> is linked into the binary.
Severity ladder
REP-2012 style: rcutils’s five levels in rcutils’s order, plus Trace. The
numbers are NOT rcutils_log_severity_t’s (UNSET=0, DEBUG=10 … FATAL=50)
and there is no Unset level — in Rust a logger’s “no level of its own” state
is Logger::unset_level(), and Logger::set_default_level takes a Severity
(pass Info where rclrs passes Unset). The C surface’s rcl_log_severity_t
does use rcutils’s numbers, UNSET included.
| Severity | u8 | When to use |
|---|---|---|
Trace | 0 | Per-instruction granularity; off by default. |
Debug | 1 | Diagnostic information useful while developing. |
Info | 2 | Normal operation events worth surfacing once. |
Warn | 3 | Unexpected but recoverable conditions. |
Error | 4 | Errors the caller should surface; system continues. |
Fatal | 5 | Unrecoverable; system is about to abort. |
The numeric representation is stable and used by the
nros_platform_log_write ABI.
Quick start
Rust
use nros_log::{Logger, Severity};
use nros_log::{log_info, log_warn};
static LOGGER: Logger = Logger::new("my_node");
fn main() {
nros_log::register_logger(&LOGGER);
nros_log::init(nros_log::sinks::default());
log_info!(&LOGGER, "started; domain = {}", 42);
log_warn!(&LOGGER, "queue depth {} exceeds soft limit", 5);
}
Inside a node, prefer the Node::logger() accessor — it resolves
to the same intern’d entry when the name matches:
#![allow(unused)]
fn main() {
let mut node = executor.create_node("my_node")?;
log_info!(node.logger(), "subscribed to {}", topic);
}
C
#include <rclc/rclc.h> /* also brings the RCLC_LOG_* macros */
int main(void) {
rclc_support_t support = rclc_support_get_zero_initialized();
rcl_allocator_t allocator = rcl_get_default_allocator();
rclc_support_init(&support, 0, NULL, &allocator); /* locator: build, then NROS_LOCATOR */
rcl_node_t node = rcl_get_zero_initialized_node();
rclc_node_init_default(&node, "my_node", "/", &support);
rclc_logger_t logger = rclc_node_get_logger(&node);
RCLC_LOG_INFO(logger, "started; domain=%u", 42);
RCLC_LOG_WARN(logger, "queue depth %u exceeds soft limit", 5);
rcl_node_fini(&node);
rclc_support_fini(&support);
return 0;
}
C++
#include <rclcpp/rclcpp.hpp>
int main() {
rclcpp::init_in("tcp/127.0.0.1:7447", 0);
rclcpp::Node node;
rclcpp::create_node(node, "my_node");
auto logger = node.get_logger();
RCLC_LOG_INFO(logger, "started; domain=%u", 42);
RCLC_LOG_WARN(logger, "queue depth %u exceeds soft limit", 5);
rclcpp::shutdown_in();
return 0;
}
The first NROS_LOG_* emit auto-installs PlatformSink so C/C++
call sites work without an explicit init step.
Per-platform delivery
| Target | Backend |
|---|---|
| POSIX | fprintf(stderr, "[<LEVEL>] <name>: <msg>\n") |
| Zephyr | LOG_INF / LOG_WRN / etc. (or printk if CONFIG_LOG=n); module nros |
| NuttX | syslog(priority, "%s", buf) |
| FreeRTOS | board-registered UART writer fn-ptr |
| ThreadX | board-registered UART writer fn-ptr |
| Bare-metal (mps2-an385) | QEMU semihosting via cortex_m_semihosting::hio::hstderr |
| Bare-metal (stm32f4) | defmt::info!("{=str}", msg) |
| Bare-metal (esp32 / esp32-qemu) | board-registered fn-ptr (esp-println / RTT / serial-jtag) |
Boards on platforms with no native logger (FreeRTOS / ThreadX /
bare-metal Rust ESP32) supply their writer fn-ptr at run() time:
#![allow(unused)]
fn main() {
// from nros-board-mps2-an385-freertos/src/lib.rs
fn register_log_writer() {
unsafe extern "C" fn writer(severity: u8, name_ptr: *const u8, name_len: usize,
msg_ptr: *const u8, msg_len: usize) { … }
unsafe { nros_platform_register_log_writer(Some(writer), None); }
}
}
Filtering
Compile-time ceiling
The NROS_LOG_MAX_LEVEL knob: trace (the default), debug, info, warn,
error, fatal or off — or 0–6, which is how Zephyr’s
CONFIG_NROS_LOG_MAX_LEVEL states it, as Zephyr’s own LOG_MAX_LEVEL does.
State it per image or per board, like every other knob:
# system.toml — this image only
[image.linux.knobs.log]
max_level = "info"
# nros-board.toml — every image on the board
[knobs.log]
max_level = "info"
Environment / [image.<id>.knobs] beats Kconfig beats the board; nros config explain prints which rung decided it. A Rust nros_*! call below the ceiling
folds to a constant-false branch and its format call is dead-code-eliminated.
C and C++ records — formatted on the C side — are refused by the same check
in the facade (rclc_logger_is_enabled answers false below the ceiling).
The ceiling is a BOUND, not the runtime level: the per-logger threshold below
still moves freely above it, and a threshold below it is unreachable. rcutils
has the same pair (RCUTILS_LOG_MIN_SEVERITY_* at compile time,
rcutils_logging_set_logger_level at run time).
The max-level-* Cargo features this replaced are deprecated for one release
(issue 1037): honoured with a build warning when no knob is stated, a build
error when they disagree with one.
Runtime per-logger threshold
#![allow(unused)]
fn main() {
let logger = nros_log::get_logger("my_node");
logger.set_level(nros_log::Severity::Warn); // silences Trace/Debug/Info
}
The process default, and “unset”
A logger built with Logger::new(name) has no level of its own. It filters
on the process-wide default, which is Severity::Info until something moves
it:
#![allow(unused)]
fn main() {
nros_log::Logger::set_default_level(nros_log::Severity::Debug); // every
// logger that has not been given a level
logger.set_level(nros_log::Severity::Warn); // this one now states its own
logger.unset_level(); // ...and follows the default again
}
set_default_level is retroactive — a static LOGGER: Logger = Logger::new("my_node") compiled into the image follows it — and it never
overrides a logger that states its own level, so Logger::with_level(name, Severity::Info) keeps Info whatever the default becomes.
From C, the same three verbs are rclc_log_set_default_level,
rclc_log_get_default_level, and rclc_logger_set_level(logger, RCLC_LOG_SEVERITY_UNSET) — which UNSETS the level rather than selecting a
value, exactly as rcutils_logging_set_logger_level(name, RCUTILS_LOG_SEVERITY_UNSET) does. C++ spells the last one
logger.set_level(rclcpp::Logger::Level::Unset).
Child loggers, and what an unset level inherits
As upstream, an unset logger takes the level of its nearest dotted ancestor
(x.y.z → x.y → x) before the process default (RFC-0102). Make children
with the upstream spelling:
#![allow(unused)]
fn main() {
let node_log = node.logger(); // "my_node"
let planner = node_log.create_child("planner")?; // "my_node.planner"
node_log.set_level(nros_log::Severity::Debug); // reaches `planner` too
nros_log::nros_debug!(planner, "replanning"); // emitted
}
auto planner = node->get_logger().get_child("planner"); // C++
rclc_logger_t planner = rclc_logger_get_child(node_log, "planner"); /* C */
A child with its own level keeps it; unset_level() hands it back to the
parent. The same name always answers the same logger. The catch-all’s children
are top-level names: planner, not nros.planner.
Bounds, if you are porting. Upstream keys a level by NAME, so an ancestor can hold one without being a logger; here an ancestor holds one by existing, and setting a level by name creates it, so in practice the behaviour matches. Logger names are capped at 48 bytes, and runtime-created loggers come from a bounded arena. When a child cannot be made:
- Rust’s
create_childreturnsErr(ChildError::{NameTooLong, EmptyName, ArenaFull}), as rclrs’s is fallible too; - C’s
rclc_logger_get_childreturnsNULLand warns once; - C++’s
get_child, whose upstream shape cannot fail, emits through the parent (records carry the parent’s name and level) and refusesset_level, so it can never move the parent’s threshold.get_name()still answers the name you asked for.
Sizes
nros-log formats each record into a stack-resident heapless::String<N>.
Overflow truncates and appends …; the macro never panics on a long format
string. That N, and the two static rings, are knobs on the same ladder as
the ceiling ([knobs.log] <key>, [image.<id>.knobs.log] <key>, Kconfig
CONFIG_<knob>):
| Knob | Key | Default | What it sizes |
|---|---|---|---|
NROS_LOG_BUFFER_SIZE | buffer_size | 256 (128–4096) | the per-call formatting buffer, and the message in each early//rosout slot |
NROS_LOG_EARLY_RECORDS | early_records | 4 (0 = drop, counted) | records held before init installs the sinks |
NROS_LOG_ROSOUT_RECORDS | rosout_records | 16 | the /rosout queue (below) |
NROS_LOG_DYNAMIC_LOGGERS | dynamic_loggers | 16, Linux hosts 32 (0 = lookup only) | loggers created at run time |
The C/C++ NROS_LOG_* macros format into their own 256-byte frame in the
calling translation unit, so a C record is bounded by the smaller of that and
NROS_LOG_BUFFER_SIZE. The buffer-size-<N>, early-records-<N>,
rosout-records-<N> and dynamic-loggers-<N> features are deprecated, like
max-level-*.
Interop with the log crate
Enable nros-log/log-compat:
#![allow(unused)]
fn main() {
nros_log::log_compat::install_log_crate_bridge()
.expect("log crate already initialized");
// Now `log::info!(...)` calls from ecosystem crates flow through
// nros-log's dispatcher.
// Or fan out the other direction — re-emit nros records via `log`:
static SINKS: &[&dyn nros_log::LogSink] = &[
&nros_log::sinks::PlatformSink,
&nros_log::log_compat::LogCrateSink,
];
nros_log::init(SINKS);
}
Severity maps Trace↔Trace, Debug↔Debug, Info↔Info, Warn↔Warn,
Error↔Error round-trip. Fatal folds into Error one-way (log has
no Fatal).
Working examples
- Rust:
examples/linux/rust/logging/ - C:
examples/linux/c/logging/ - C++:
examples/linux/cpp/logging/
Each demonstrates per-severity macros + the runtime threshold filter.
Reference
packages/core/nros-log/— facade crate.packages/platform/nros-platform-cffi/include/nros/platform.h—nros_platform_log_write/nros_platform_log_flush/nros_platform_register_log_writerABI.packages/api/nros-c/include/rclc/log.h— C API surface (NROS_LOG_*macros +rclc_log_emit_fmt).packages/api/nros-cpp/include/rclcpp/logging.hpp— C++ macros (the legacyNROS_INFO/ etc. file:line printf surface stays alongside the newNROS_LOG_*macros).
/rosout
It exists now (phase-467 Q4; this section read “explicitly out of scope
today” until 2026-09-29). Records can be republished as
rcl_interfaces/msg/Log on /rosout, so ros2 topic echo /rosout,
rqt_console and launch-side log aggregators see a nano-ros node.
Declare the rosout capability — [system].features = ["rosout"] in
system.toml, or set(NROS_FEATURES "rosout") before
find_package(nano_ros) in a bare CMake project (a Rust leaf can also name the
nros/rosout feature) — and wire three lines. Rust:
#![allow(unused)]
fn main() {
use nros_rcl_interfaces::msg::Log;
let rosout = executor
.create_node("my_node")?
.create_publisher_with_qos::<Log>(nros::rosout::TOPIC, nros::rosout::qos_bounded())?;
nros::rosout::enable(); // records start queueing
loop {
executor.spin_once(budget);
let _ = nros::rosout::pump(&rosout); // and reach the wire here
}
}
C (<rclc/rosout.h>):
rcl_publisher_t rosout = rcl_get_zero_initialized_publisher();
rclc_rosout_publisher_init(&rosout, &node, NULL); /* NULL = bounded QoS */
rclc_rosout_enable();
for (;;) {
rclc_executor_spin_some(&executor, 10000000);
rclc_rosout_pump(&rosout, NULL);
}
C++ (<rclcpp/rosout.hpp>):
rclcpp::rosout::Publisher rosout;
RCLCPP_TRY(rosout.create(node)); // or create(node, rclcpp::RosoutQoS())
RCLCPP_TRY(rclcpp::rosout::enable());
for (;;) {
rclcpp::spin_once(10);
rosout.pump();
}
All three share one encoder, so the records are identical. Without the
capability the C and C++ entry points still link: rcl_logging_rosout_enabled()
/ nros::rosout::enabled() answer false and the rest answer UNSUPPORTED.
rclcpp::NodeOptions::enable_rosout(true) still refuses to compile; “It is
not automatic” below says why the publisher is yours to create.
Five things to know before you rely on it.
Which loggers reach it depends on the ROS release (RFC-0102 D4), because
upstream’s does. On Humble, rcl publishes only a NODE’s logger: a child or a
free get_logger("x") never reaches /rosout. Iron and Jazzy also publish a
node’s get_child descendants. nros::rosout::enable() scopes the queue to
the release the image is built for (ros-humble / ros-iron / ros-jazzy;
an image naming none gets Humble’s rule). So log through node.logger() or
its children, not a free logger, if you want the record on the wire. One
bound: upstream stops publishing a child when its last copy is destroyed;
here it stays published, because runtime loggers are never freed.
It is not automatic. Upstream republishes from every rcl node with no user action. Here you create the publisher yourself, because a publisher is an ENTITY: it reaches the derived pool counts, the zenoh session’s tables and the sizing descriptor. An entity the runtime conjured below your declaration is how a queryable table fills up at boot with nothing in your launch file to explain it.
The default QoS is not upstream’s. nros::rosout::qos() IS
rcl_qos_profile_rosout_default — KEEP_LAST(1000), RELIABLE, TRANSIENT_LOCAL,
10 s lifespan — and on an embedded target it is expensive: a transient-local
publisher takes a retention slot AND a cache queryable out of a
NROS_ZENOH_MAX_QUERYABLES that defaults to 8 and already has eleven claimants if
you run parameter and lifecycle services, and KEEP_LAST(1000) over a ~1 KB
message is a megabyte of history. qos_bounded() is VOLATILE at the queue
depth and costs neither. What you give up is that a subscriber which attaches
LATE sees no boot story. A stock ros2 topic echo still matches — it requests
VOLATILE and downgrades across a mixed publisher set.
The stamp is the platform monotonic clock, not wall time, because an RTOS
image has none to offer: rqt_console will display an uptime as a time of day.
Without the platform clock it is a constant zero — the clock is compiled in when
the image’s platform declares [capabilities] clock = true (every in-tree port
does) or nros-log’s platform-clock feature pulls a port in.
It costs .bss. 5 760 bytes on the defaults (16 queued records x 360 B),
down to 1 856 B with NROS_LOG_ROSOUT_RECORDS=8 + NROS_LOG_BUFFER_SIZE=128
and up to 72 192 B with =64 + =1024. An image that does not enable
the feature pays nothing — the module is compiled out whole.
The sink does not publish, and that is the point
nros_log’s /rosout sink copies each record into a bounded static ring and
returns. It never enters the transport. A logging sink that publishes, on a
path that can itself log, is unbounded recursion — and on Zephyr native_sim
that shape kills the image by stack exhaustion with no message at all. The
publish happens in pump(), on your spin thread, with a flag set that makes
the queue refuse anything logged underneath it; those refusals are counted
(nros::rosout::suppressed()) alongside the ones lost to a full ring
(nros::rosout::dropped()), and pump reports both on /rosout itself so an
operator learns about a gap on the channel the gap is in.
C and C++ reach none of this yet — see issue 1589.