kernel: tagged log ring — per-line pid/name/level records, klog_status

Replace the linear keep-earliest RAM buffer with a 512 KiB ring of
framed records (log-ring.zig, host-tested): every debug_write becomes
one record per payload line, stamped by the kernel with the sender's
pid, task name (its binary path), level, per-boot sequence number, and
monotonic timestamp. Attribution is structural — a payload cannot forge
another sender's tag, and newline injection lands inside the forger's
own next record. Oldest records are overwritten when full; sequence
gaps make the loss countable.

debug_write gains a level argument (err/warn/info/debug/raw; old
two-arg callers clamp to raw). klog_read becomes a stream-offset read
that fails once the cursor falls behind the ring's tail; the new
klog_status (#45) returns the cursors plus the boot wall-clock anchor —
what the logger service will name per-boot log directories with.

The log now guards itself with a dedicated spinlock (BKL -> log lock
order, never the reverse); panic paths use a bounded try-acquire and
fall back to sinks-only. Serial rendering keeps the historical
transcript byte-identical for kernel and legacy raw output; leveled
records get a kernel-rendered name prefix. log-flush/init's interim
drains start at the ring tail and write framed records until the logger
service replaces them.
This commit is contained in:
Daniel Samson
2026-07-21 15:30:28 +01:00
parent 0a84e52bf8
commit 9f18d8340e
10 changed files with 549 additions and 80 deletions
+40 -19
View File
@@ -234,6 +234,7 @@ fn system_call(state: *architecture.CpuState) void {
.process_signal => systemProcessSignal(state),
.timer_bind => systemTimerBind(state),
.klog_read => systemKlogRead(state),
.klog_status => systemKlogStatus(state),
.wall_clock => systemWallClock(state),
.shm_create => systemShmCreate(state),
.shm_map => systemShmMap(state),
@@ -1210,7 +1211,6 @@ fn systemIrqAck(state: *architecture.CpuState) void {
/// Whether the debug_write stream sits at the start of a line — the last emitted
/// byte was a newline (true at boot: nothing emitted yet). Guarded by the kernel
/// lock in `systemDebugWrite`, like the stream it describes.
var write_at_line_start: bool = true;
/// debug_write(ptr, len): copy bytes from user memory into the kernel log.
/// A bring-up diagnostic — real output goes through the VFS/console later.
@@ -1231,31 +1231,42 @@ var write_at_line_start: bool = true;
fn systemDebugWrite(state: *architecture.CpuState) void {
const ptr = architecture.systemCallArg(state, 0);
const len = architecture.systemCallArg(state, 1);
const level_raw = architecture.systemCallArg(state, 2);
if (len <= write_buffer.len and ptr < user_half_end and ptr + len <= user_half_end) {
const source: [*]const u8 = @ptrFromInt(ptr);
// Levels above the enum range clamp to raw — old two-arg callers land
// there naturally (garbage in arg 2 stays harmless).
const level: abi.KlogLevel = if (level_raw <= @intFromEnum(abi.KlogLevel.raw))
@enumFromInt(level_raw)
else
.raw;
const t = scheduler.current();
const flags = sync.enter();
defer sync.leave(flags);
@memcpy(write_buffer[0..len], source[0..len]); // keep the latest message
write_len = len;
write_from_user = architecture.fromUser(state);
write_count += 1;
log.write(source[0..len]);
if (len != 0) write_at_line_start = source[len - 1] == '\n';
// The kernel stamps the sender's identity — attribution is structural,
// not a prefix convention the payload could forge (and it is stamped
// per line inside log.append).
log.append(t.id, t.name(), level, source[0..len]);
architecture.setSystemCallResult(state, len);
} else {
fail(state);
}
}
/// klog_read(offset, ptr, len) -> bytes copied: copy the kernel's in-memory
/// diagnostic log (the RAM sink in log.zig) out to the user buffer at `ptr`,
/// starting at `offset`. Returns the count copied — 0 once `offset` reaches the
/// end — so a program reads the whole log by looping from 0 until it gets 0.
/// klog_read(offset, ptr, len) -> bytes copied: copy tagged log-ring stream
/// bytes beginning at stream offset `offset` out to the user buffer at `ptr`.
/// Returns the count copied — 0 means caught up — and fails once `offset` has
/// fallen behind the ring's tail (the records were overwritten) or lies past
/// its head; the reader re-syncs via klog_status. A reader parses
/// [KlogRecordHeader][name][message] frames out of the byte stream (abi.zig).
///
/// The mirror of `debug_write`: the same overflow-safe user-half bounds check,
/// but the copy runs kernel -> user. Written under the kernel lock so the source
/// snapshot can't grow underneath the copy. A read-only diagnostic — it exposes
/// only the log the kernel already broadcasts to serial, nothing else.
/// but the copy runs kernel -> user, under the log lock (inside log.readAt) so
/// the stream can't move underneath the copy. A read-only diagnostic.
fn systemKlogRead(state: *architecture.CpuState) void {
const offset = architecture.systemCallArg(state, 0);
const ptr = architecture.systemCallArg(state, 1);
@@ -1263,21 +1274,31 @@ fn systemKlogRead(state: *architecture.CpuState) void {
// Confine the whole destination span to the user (low) half. `len <=
// user_half_end - ptr` bounds the length without an overflowing add.
if (ptr < user_half_end and len <= user_half_end - ptr) {
const flags = sync.enter();
defer sync.leave(flags);
const snapshot = log.ramSnapshot();
var n: usize = 0;
if (offset < snapshot.len) {
n = @min(len, snapshot.len - offset);
const dest: [*]u8 = @ptrFromInt(ptr);
@memcpy(dest[0..n], snapshot[offset..][0..n]);
}
const dest: [*]u8 = @ptrFromInt(ptr);
const n = log.readAt(offset, dest[0..len]) orelse return fail(state);
architecture.setSystemCallResult(state, n);
} else {
fail(state);
}
}
/// klog_status(ptr) -> 0: copy a KlogStatus — the ring's live cursors plus the
/// boot wall-clock anchor — out to the user buffer at `ptr`. How a log reader
/// finds the oldest retained offset, detects lost records (sequence gaps), and
/// names a per-boot log directory (boot_unix_seconds).
fn systemKlogStatus(state: *architecture.CpuState) void {
const ptr = architecture.systemCallArg(state, 0);
const size = @sizeOf(abi.KlogStatus);
if (ptr < user_half_end and size <= user_half_end - ptr) {
var status = log.status();
const dest: [*]u8 = @ptrFromInt(ptr);
@memcpy(dest[0..size], std.mem.asBytes(&status)[0..size]);
architecture.setSystemCallResult(state, 0);
} else {
fail(state);
}
}
/// mmap(len, prot) -> base: grant `len` bytes (rounded up to whole pages) of
/// fresh, zeroed, writable+NX memory in the caller's mmap arena, and return the
/// base virtual address. `prot` is accepted but not yet honoured (grants are