Skip to main content

rivet/
log.rs

1//! Deferred-formatting logging: [`log!`](crate::log!) is safe to call from
2//! ISR context (it does no formatting, no allocation, and never blocks —
3//! it just pushes a `{level, task_id, timestamp, message}` frame into a
4//! ring buffer), and a drain task formats and writes frames to the
5//! console at its own pace, off the hot path.
6//!
7//! # Scope note (plan.md Phase 8, extended by Phase 16)
8//!
9//! The old plan (§6.5) called for interning format strings into a
10//! `.rivet_log_fmt` linker section, storing only a small integer index per
11//! frame, and decoding on the host from the ELF's debug info (a
12//! `rivet-decode` crate). This module takes a simpler route that still
13//! delivers the properties that actually matter (ISR-safe, O(1) in the
14//! hot path, deferred formatting, lock-free ring buffer, dropped-frame
15//! accounting): a frame stores the message as a plain `&'static str`
16//! pointer + length, plus (Phase 16) up to two [`LogArg`] values —
17//! **not** a full `format_args!`-style template (that needs the
18//! interned-format-string + host-decoder machinery the old plan
19//! described, still not attempted here). `log!("x={}", x)` covers the
20//! large majority of real call sites, which log one or two values
21//! alongside a fixed message; `write_frame` substitutes each `{}` in
22//! `msg` with the corresponding argument, formatted at drain time (off
23//! the hot path, same as everything else here).
24//!
25//! The ring buffer is Rivet's own SPSC [`crate::sync::Channel`] — but
26//! logging is inherently **multi**-producer (any task or ISR might log,
27//! on any hart), so every producer path goes through
28//! [`crate::critical::enter`] to serialize pushes into a single logical
29//! producer. Since plan.md Phase 19, `critical::enter` is a genuine
30//! cross-hart lock (not just a local interrupt mask), so this holds under
31//! real SMP too, not only the single-hart case.
32
33use crate::sync::{Channel, Once, Receiver, Sender};
34use crate::sync::atomic::{AtomicU32, Ordering};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Level {
38    Trace,
39    Debug,
40    Info,
41    Warn,
42    Error,
43}
44
45impl Level {
46    fn as_str(self) -> &'static str {
47        match self {
48            Level::Trace => "TRACE",
49            Level::Debug => "DEBUG",
50            Level::Info => "INFO",
51            Level::Warn => "WARN",
52            Level::Error => "ERROR",
53        }
54    }
55}
56
57/// A single interpolated argument (plan.md Phase 16): a small closed set
58/// covering the large majority of real log call sites, not a general
59/// `Display`/`Debug` payload (which would need real formatting work done
60/// eagerly, defeating the point of deferring it to drain time).
61#[derive(Clone, Copy)]
62pub enum LogArg {
63    /// No argument in this slot (fewer than 2 given to `log!`).
64    None,
65    U32(u32),
66    I32(i32),
67    F32(f32),
68    Str(&'static str),
69}
70
71impl From<u32> for LogArg {
72    fn from(v: u32) -> Self {
73        LogArg::U32(v)
74    }
75}
76impl From<i32> for LogArg {
77    fn from(v: i32) -> Self {
78        LogArg::I32(v)
79    }
80}
81impl From<f32> for LogArg {
82    fn from(v: f32) -> Self {
83        LogArg::F32(v)
84    }
85}
86impl From<&'static str> for LogArg {
87    fn from(v: &'static str) -> Self {
88        LogArg::Str(v)
89    }
90}
91
92#[derive(Clone, Copy)]
93pub struct LogFrame {
94    pub level: Level,
95    /// Preemptive task id, or `None` if logged from a context with no
96    /// current task (e.g. before the scheduler starts).
97    pub task_id: Option<u16>,
98    pub timestamp_us: u32,
99    pub msg: &'static str,
100    /// Up to two interpolated arguments, substituted in order for each
101    /// `{}` in `msg` at drain time. `LogArg::None` in a slot the message
102    /// doesn't reference is simply unused.
103    pub args: [LogArg; 2],
104}
105
106/// Ring capacity. Deliberately not wired into `RIVET_*` build-time config
107/// (plan.md §4.1) yet — a fixed default is the honest starting point for a
108/// feature this new; making it configurable is a small follow-up once
109/// there's a real workload to size it against.
110const CAPACITY: usize = 16;
111
112#[cfg(not(loom))]
113static CHANNEL: Channel<LogFrame, CAPACITY> = Channel::new();
114#[cfg(loom)]
115loom::lazy_static! {
116    static ref CHANNEL: Channel<LogFrame, CAPACITY> = Channel::new();
117}
118
119#[cfg(not(loom))]
120static SENDER: Once<Sender<'static, LogFrame, CAPACITY>> = Once::new();
121#[cfg(loom)]
122loom::lazy_static! {
123    static ref SENDER: Once<Sender<'static, LogFrame, CAPACITY>> = Once::new();
124}
125
126#[cfg(not(loom))]
127static RECEIVER: Once<Receiver<'static, LogFrame, CAPACITY>> = Once::new();
128#[cfg(loom)]
129loom::lazy_static! {
130    static ref RECEIVER: Once<Receiver<'static, LogFrame, CAPACITY>> = Once::new();
131}
132static DROPPED: AtomicU32 = AtomicU32::new(0);
133
134/// Called once from [`crate::init`]. Splitting the channel here (rather
135/// than lazily on first use) means the first `log!` call anywhere is
136/// never the one paying for initialization, and keeps `push`'s hot path
137/// to just a critical-section-guarded `try_send`.
138pub(crate) fn init() {
139    if let Some((tx, rx)) = CHANNEL.split() {
140        let _ = SENDER.set(tx);
141        let _ = RECEIVER.set(rx);
142    }
143}
144
145/// Push a frame. ISR-safe: no allocation, no unbounded loops, never
146/// blocks — a full ring drops the frame and counts it (see
147/// [`dropped_frames`]) rather than backing up whatever called this.
148#[doc(hidden)]
149pub fn push(level: Level, msg: &'static str, arg0: LogArg, arg1: LogArg) {
150    let task_id = crate::preempt::sched::current().map(|id| id as u16);
151    let timestamp_us = crate::port::board::now_us() as u32;
152    let frame = LogFrame {
153        level,
154        task_id,
155        timestamp_us,
156        msg,
157        args: [arg0, arg1],
158    };
159    let sent = match SENDER.get() {
160        // SAFETY-relevant, not memory-safety: `try_send` requires a
161        // single logical producer; the critical section serializes
162        // however many concurrent callers (tasks and/or ISRs) there are
163        // into one.
164        Some(tx) => crate::critical::enter(|| tx.try_send(frame)).is_ok(),
165        None => false,
166    };
167    if !sent {
168        DROPPED.fetch_add(1, Ordering::Relaxed);
169    }
170}
171
172/// Number of frames dropped so far because the ring was full (or logging
173/// hadn't been initialized yet — i.e. called before [`crate::init`]).
174pub fn dropped_frames() -> usize {
175    DROPPED.load(Ordering::Relaxed) as usize
176}
177
178fn write_frame(frame: &LogFrame) {
179    crate::console::write_str("[");
180    crate::console::write_str(frame.level.as_str());
181    crate::console::write_str("] t=");
182    write_dec(frame.timestamp_us as usize);
183    if let Some(id) = frame.task_id {
184        crate::console::write_str(" task=");
185        write_dec(id as usize);
186    }
187    crate::console::write_str(" ");
188    write_interpolated(frame.msg, &frame.args);
189    crate::console::write_str("\n");
190}
191
192/// Write `msg`, substituting each `{}` (in order) with the corresponding
193/// entry of `args`. Extra `{}` beyond the two argument slots are written
194/// through literally, since there's nothing to fill them with — no
195/// silent truncation of the message.
196fn write_interpolated(msg: &str, args: &[LogArg; 2]) {
197    let mut rest = msg;
198    let mut arg_idx = 0usize;
199    while let Some(pos) = rest.find("{}") {
200        crate::console::write_str(&rest[..pos]);
201        match args.get(arg_idx) {
202            Some(LogArg::None) | None => crate::console::write_str("{}"),
203            Some(arg) => write_arg(arg),
204        }
205        arg_idx += 1;
206        rest = &rest[pos + 2..];
207    }
208    crate::console::write_str(rest);
209}
210
211fn write_arg(arg: &LogArg) {
212    match *arg {
213        LogArg::None => {}
214        LogArg::U32(v) => crate::console::_print(core::format_args!("{v}")),
215        LogArg::I32(v) => crate::console::_print(core::format_args!("{v}")),
216        LogArg::F32(v) => crate::console::_print(core::format_args!("{v}")),
217        LogArg::Str(s) => crate::console::write_str(s),
218    }
219}
220
221fn write_dec(mut n: usize) {
222    if n == 0 {
223        crate::console::write_str("0");
224        return;
225    }
226    let mut digits = [0u8; 20];
227    let mut i = 0;
228    while n > 0 {
229        digits[i] = b'0' + (n % 10) as u8;
230        n /= 10;
231        i += 1;
232    }
233    let mut buf = [0u8; 20];
234    for j in 0..i {
235        buf[j] = digits[i - 1 - j];
236    }
237    if let Ok(s) = core::str::from_utf8(&buf[..i]) {
238        crate::console::write_str(s);
239    }
240}
241
242/// Drain and format one pending frame. Returns `false` if the ring was
243/// empty. Call this in a loop from a low-priority task to flush the log
244/// (see [`drain_forever`] for a ready-made one).
245pub fn drain_one() -> bool {
246    match RECEIVER.get().and_then(|rx| rx.try_recv()) {
247        Some(frame) => {
248            write_frame(&frame);
249            true
250        }
251        None => false,
252    }
253}
254
255/// A ready-made drain loop: `.await`s new frames and writes them to the
256/// console as they arrive. Spawn this as a low-priority `#[rivet::task]`
257/// if you want logging without writing your own drain loop.
258///
259/// ```ignore
260/// #[rivet::task(priority = 0)]
261/// async fn log_drain() {
262///     rivet::log::drain_forever().await;
263/// }
264/// ```
265pub async fn drain_forever() -> ! {
266    let rx = loop {
267        if let Some(rx) = RECEIVER.get() {
268            break rx;
269        }
270        // Logging hasn't been initialized yet (called before rivet::init)
271        // — extremely unlikely given normal boot order, but don't spin
272        // hot if it happens.
273        crate::time::Sleep::<1000>::new().await;
274    };
275    loop {
276        let frame = rx.recv().await;
277        write_frame(&frame);
278    }
279}
280
281/// Log a message at the given level. ISR-safe: pushing a frame does no
282/// formatting and never blocks (see the module docs for why arguments are
283/// a small closed set — `u32`/`i32`/`f32`/`&'static str` — rather than a
284/// full `format_args!`-style template). Up to two `{}` placeholders in
285/// `$msg` are substituted, in order, at drain time:
286///
287/// ```ignore
288/// rivet::log!(Level::Info, "task {} spawned", id);
289/// rivet::log!(Level::Warn, "retry {}/{}", attempt, max);
290/// ```
291#[macro_export]
292macro_rules! log {
293    ($level:expr, $msg:expr) => {
294        $crate::log::push(
295            $level,
296            $msg,
297            $crate::log::LogArg::None,
298            $crate::log::LogArg::None,
299        )
300    };
301    ($level:expr, $msg:expr, $a:expr) => {
302        $crate::log::push(
303            $level,
304            $msg,
305            $crate::log::LogArg::from($a),
306            $crate::log::LogArg::None,
307        )
308    };
309    ($level:expr, $msg:expr, $a:expr, $b:expr) => {
310        $crate::log::push(
311            $level,
312            $msg,
313            $crate::log::LogArg::from($a),
314            $crate::log::LogArg::from($b),
315        )
316    };
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn log_arg_from_conversions() {
325        assert!(matches!(LogArg::from(5u32), LogArg::U32(5)));
326        assert!(matches!(LogArg::from(-3i32), LogArg::I32(-3)));
327        assert!(matches!(LogArg::from("hi"), LogArg::Str("hi")));
328        match LogArg::from(1.5f32) {
329            LogArg::F32(v) => assert!((v - 1.5).abs() < f32::EPSILON),
330            _ => panic!("expected F32"),
331        }
332    }
333
334    /// Pure substitution logic — doesn't touch the ring or console (the
335    /// host `port::host` console write is a no-op, so a real end-to-end
336    /// check of what actually got written needs the QEMU suite;
337    /// `examples/*/src/bin/report_test.rs`'s `hello from A, i={0..4}`
338    /// golden lines are that check). This test isolates `write_interpolated`'s
339    /// placeholder-counting/fallback behavior instead, by checking it
340    /// against a fake sink is impossible without one — so it only
341    /// checks it doesn't panic across the boundary cases (0, 1, 2, and
342    /// more `{}` than args).
343    #[test]
344    fn write_interpolated_boundary_cases_do_not_panic() {
345        write_interpolated("no placeholders", &[LogArg::None, LogArg::None]);
346        write_interpolated("one {}", &[LogArg::U32(1), LogArg::None]);
347        write_interpolated("two {} and {}", &[LogArg::U32(1), LogArg::Str("x")]);
348        write_interpolated("three {} {} {}", &[LogArg::U32(1), LogArg::U32(2)]);
349    }
350}