Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Severityu8When to use
Trace0Per-instruction granularity; off by default.
Debug1Diagnostic information useful while developing.
Info2Normal operation events worth surfacing once.
Warn3Unexpected but recoverable conditions.
Error4Errors the caller should surface; system continues.
Fatal5Unrecoverable; 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

TargetBackend
POSIXfprintf(stderr, "[<LEVEL>] <name>: <msg>\n")
ZephyrLOG_INF / LOG_WRN / etc. (or printk if CONFIG_LOG=n); module nros
NuttXsyslog(priority, "%s", buf)
FreeRTOSboard-registered UART writer fn-ptr
ThreadXboard-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_child returns Err(ChildError::{NameTooLong, EmptyName, ArenaFull}), as rclrs’s is fallible too;
  • C’s rclc_logger_get_child returns NULL and warns once;
  • C++’s get_child, whose upstream shape cannot fail, emits through the parent (records carry the parent’s name and level) and refuses set_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>):

KnobKeyDefaultWhat it sizes
NROS_LOG_BUFFER_SIZEbuffer_size256 (128–4096)the per-call formatting buffer, and the message in each early//rosout slot
NROS_LOG_EARLY_RECORDSearly_records4 (0 = drop, counted)records held before init installs the sinks
NROS_LOG_ROSOUT_RECORDSrosout_records16the /rosout queue (below)
NROS_LOG_DYNAMIC_LOGGERSdynamic_loggers16, 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_writer ABI.
  • 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 legacy NROS_INFO / etc. file:line printf surface stays alongside the new NROS_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.