diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 13:06:05 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 14:38:25 -0300 |
| commit | 174991b8f3f8e9c792eede7a52ad7beb10a08b05 (patch) | |
| tree | 3dcc42604752251227e233294d956e18cb264ad8 /src/pardes | |
| parent | f5f8068fac59b4f16046c2022c2fc7c7e447ef4c (diff) | |
| download | esp32p4-174991b8f3f8e9c792eede7a52ad7beb10a08b05.tar.gz esp32p4-174991b8f3f8e9c792eede7a52ad7beb10a08b05.zip | |
pardes as P4 firmware: the seam, and a flash-mapping bug in this toolchain
The editor arrives as one freestanding OBJECT exporting a seven-function C ABI
(src/pardes/app.zig declares it, ../02-pardes-code/src/p4.zig implements it), not
as a package dependency. A build.zig.zon path dependency was built first and
reverted: merely DECLARING it nested pardes's ~30-package graph under this one and
broke every build here - std/Build.zig:2091 exceeded its 1000-branch comptime
quota via ghostty's lazyImport, seven cached tree_sitter versions use APIs removed
in 0.16, and the fetch wrote 2.6 GB across 42,736 files into this working copy.
The seam is bytes in and bytes out, which is what a serial line is anyway: the
editor owns vaxis and the ANSI encoding, this side owns the UART, the heap and the
clock, and neither names the other's types. It is versioned, because linkers do
not type-check C symbols and a drifted signature would link cleanly and then
corrupt the stack.
THE BUG WORTH THE COMMIT. .flash.text was ALIGN(64), and the image builder's
anchor makes two mapped segments share an MMU page safely - as long as rodata does
not END inside the page where text BEGINS. With a 578 KB image it does. A volatile
read of a string literal at 0x4004a1d1 returned 37 09 fa 4f, which disassembles to
"lui s2, 0x4ffa0": this image's own .flash.text. Every literal in that last shared
page read as code, so the first thing the firmware tried to print was machine code
and it died on an instruction access fault. .flash.text is now ALIGN(0x10000),
making the segments page-disjoint. The packing trick this project opened with only
ever mattered when the alternative was 64 KiB of zeros in a 1 KB image.
Two more findings, both recorded in README.md:
* A linker symbol declared as an anyopaque OBJECT gives the optimiser a
zero-sized object, so ordinary stores through a pointer derived from its
address are dead code it may drop - and did, silently. The allocator's first
block header read back as size=2988759312 next=0x14284684 and the free-list
walk never terminated. @extern with a many-pointer has no size to lose.
examples/memprobe.zig could not have caught it: it writes through a volatile
pointer, which the optimiser must leave alone.
* The RTC watchdog is armed at handover. Every example here had been resetting on
a ten-second cycle, invisibly, because no run had ever lasted eight seconds.
State, honestly: the firmware boots, clears .bss, brings up the console, disables
the watchdog, starts the systimer, checks the ABI version, initialises the 384 KiB
heap and calls into the editor, which sets up its sink and its environment. It then
faults inside pardes_p4_init on the first allocation. The cause is measured but not
fixed: a load from .flash.rodata page 3 returns the contents of the page 0x50000
higher - exactly the vaddr distance between the rodata and text segments - while
pages 0, 2 and 4 read correctly. The bisect markers that localised it are still in
place, deliberately, because the next step needs them.
--- correction, measured after the above was written ---
Two mapped segments is NOT a choice, and the earlier comment in tools/image.zig
was right for a reason I initially got wrong and then measured.
I first read bootloader_utility.c's `#else` branch, which classifies segments by
address window with two independent ifs - and since the P4's DROM and IROM windows
are the identical range (soc.h:146-149), I concluded the last mapped segment wins
both roles and the first is never mapped. That branch does not run on this chip.
The P4 takes the SOC_MMU_DI_VADDR_SHARED branch (bootloader_utility.c:805-851),
whose own comment says it: "On chips with shared D/I external vaddr, we don't
divide them into either D or I, as essentially they are the same." It collects
mapped segments POSITIONALLY into rom_addr[2] and ends with
assert(rom_index == 2);
Shipping a one-segment image proved it, on the board:
Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)
So the split stays, image.zig keeps enforcing exactly two - turning that boot-time
abort into a build-time error - and both are now documented with the branch that
actually runs and the assert that actually fires.
What DOES change is alignment. .flash.text was ALIGN(64). Two mapped segments may
share a 64 KiB MMU page only if they also share a flash page, which the image
builder's anchor guarantees - and that holds right up until an application is large
enough for rodata to END inside the page where text BEGINS. With a 578 KB image it
does. Measured on the die: a volatile read of a string literal at 0x4004a1d1
returned 37 09 fa 4f, which disassembles to "lui s2, 0x4ffa0" - this image's own
.flash.text. Every literal in that shared page read as code, so the first thing the
firmware tried to print was machine code, and it died on an instruction access
fault. .flash.text is now ALIGN(0x10000), which makes the segments page-disjoint.
It costs up to 64 KiB of image padding against a 1.5 MiB partition; the packing
trick this project opened with only mattered when the alternative was 64 KiB of
zeros in a 1 KB image.
With that fixed the firmware gets much further: entry, .bss cleared, console up,
watchdog disabled, systimer running, ABI version checked, the 384 KiB heap
initialised, into the editor, its sink and environment ready - and the literal at
0x4004a1d1 now reads back correctly.
Still open, and characterised rather than guessed: pardes_p4_init faults on its
first allocation. The allocator struct crosses the seam intact (its function
pointers land in .flash.text), but the std.mem.Allocator vtable at 0x40035a1c reads
back as instruction bytes, and the dispatch at .flash.text+0xade2 jumps through it.
Ruled out with measurements: the ELF and the image agree at that address, the flash
is MD5-verified against the image, the wrong bytes are identical across three
resets and two reflashes (so not a stale cache), the corruption is a contiguous run
rather than 64-byte lines, and mmu_hal_map_region's arithmetic
(page_num = ceil(len/page), entry from vaddr) is correct for the segments as now
laid out. The next measurement is the one that settles it: read the MMU entry
registers from the running application and print vaddr -> flash for every page. The
register model in src/soc.zig can do that; the bisect markers are left in place for
it.
Diffstat (limited to 'src/pardes')
| -rw-r--r-- | src/pardes/app.zig | 318 | ||||
| -rw-r--r-- | src/pardes/uart.zig | 115 |
2 files changed, 433 insertions, 0 deletions
diff --git a/src/pardes/app.zig b/src/pardes/app.zig new file mode 100644 index 0000000..212e48f --- /dev/null +++ b/src/pardes/app.zig @@ -0,0 +1,318 @@ +//! pardes, as ESP32-P4 firmware. +//! +//! There is no operating system under this. `_start` is the reset entry the second-stage bootloader +//! jumps to, and this file is the entire platform: a heap, a millisecond clock, and UART0. +//! +//! ## Where the editor is +//! +//! Not in this package. `../02-pardes-code` compiles its core for riscv32-freestanding and emits +//! ONE object exporting the six C functions declared below; `-Dpardes` links it. The seam is a file +//! rather than a package dependency for a reason recorded at length in `build.zig`: declaring the +//! editor as a `build.zig.zon` path dependency nested its ~30-package graph under this one and +//! broke every build in this repo, including the ones that have nothing to do with it. +//! +//! The seam is deliberately **bytes in, bytes out**. Everything that needs to know what a cell is - +//! vaxis, the ANSI encoder, the input parser, the capability handshake - lives on the far side, +//! next to the vaxis it is built against. What crosses is a byte stream in each direction, which is +//! exactly what a serial line is, so this file has no opinion about terminals at all. +//! +//! ## Where the memory is +//! +//! Measured on this die by `examples/memprobe.zig`, not read off a datasheet: +//! +//! 0x4FF02000..0x4FF3F000 244 KiB .data/.bss/.stack live at the bottom of this +//! 0x4FF3F000..0x4FF40000 4 KiB mask ROM .data/.bss - untouchable, ets_printf needs it +//! 0x4FF40000..0x4FFC0000 512 KiB handed to the editor as its entire heap +//! +//! The 512 KiB arrives as `__heap_start`/`__heap_end` from the generated linker script, so those +//! addresses are written down in exactly one place. The editor owns that span outright: it is +//! passed in at init and this file never allocates from it. +//! +//! PSRAM is not used. The board has 32 MB fitted and it would make all of this comfortable, but +//! ESP-IDF's own ESP32-P4 implementation runs past a thousand lines - MPLL, MSPI clocking, pin +//! drive and DQS, CS timing, mode registers, a connectivity check, and an entire timing-calibration +//! subsystem - and the mask ROM offers only MMU mapping, no device init. Touching it untrained +//! faults and hangs the core, which `examples/memprobe.zig` demonstrates on purpose. + +const std = @import("std"); +const soc = @import("soc"); +const hal = @import("hal"); +const heapmod = @import("heap"); +const uart = @import("uart.zig"); + +// ------------------------------------------------------------------------------------- the ABI +// Seven functions, all `callconv(.c)`, all implemented in the linked object. This is the complete +// interface between this board and the editor, and it is deliberately bytes-and-memory only: the +// editor never learns what a UART is, and this file never learns what a cell is. + +/// How the editor emits bytes. Called with finished runs of ANSI, many times per frame. +const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void; + +/// This board's allocator, handed across as plain function pointers. `log2_align` is a log2 value, +/// which is exactly how `std.mem.Alignment` represents itself, so neither side needs a conversion +/// table. +/// +/// The memory belongs to THIS side: only the firmware knows that the heap is the 384 KiB at +/// 0x4FF40000, that the 128 KiB above it is L2 cache, and that PSRAM is untrained. The editor gets +/// an allocator, not an address range. +const Allocator = extern struct { + ctx: ?*anyopaque, + alloc: *const fn (ctx: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8, + resize: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool, + free: *const fn (ctx: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void, +}; + +/// The one number both sides must agree on. Linkers do not type-check C symbols, so a signature +/// that drifts on one side of this seam links cleanly and then corrupts the stack; checking this +/// before calling anything else turns that into a refusal to boot. +const abi_version: u32 = 1; +extern fn pardes_p4_abi_version() callconv(.c) u32; + +/// Hand over the allocator and the output sink, and state the initial window size. Returns 0, or a +/// small non-zero code this file can only report. +extern fn pardes_p4_init( + alloc: *const Allocator, + write: WriteFn, + ctx: ?*anyopaque, + cols: u16, + rows: u16, +) callconv(.c) u32; + +/// Raw bytes off the wire: keystrokes, capability-query replies, and the host bridge's in-band +/// resize reports. The editor parses all three; this file distinguishes none of them. +extern fn pardes_p4_input(ptr: [*]const u8, len: usize) callconv(.c) void; + +/// Advance time. Separate from `input` because animations and timeouts must progress on a wire +/// where nothing is arriving. +extern fn pardes_p4_tick(now_ms: u64) callconv(.c) void; + +/// Emit one frame through the write callback. Returns 0 or an error code. +extern fn pardes_p4_render() callconv(.c) u32; + +/// Is there anything to draw - a dirty surface or a running animation? Asked every iteration so a +/// quiet editor costs no bytes on a 115200-baud link. +extern fn pardes_p4_wants_frame() callconv(.c) bool; + +/// Has the user asked to leave? There is nowhere to go, so this only stops the loop. +extern fn pardes_p4_quit() callconv(.c) bool; + +// ------------------------------------------------------------------------------------ the sink + +/// The write callback handed to `pardes_p4_init`. No context is needed - there is one UART. +fn writeOut(_: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void { + uart.write(ptr[0..len]); +} + +// ------------------------------------------------------------------------------------- the heap + +/// The span the linker script hands over, from `l2high`'s ORIGIN and LENGTH. +/// +/// Reached with `@extern`, NOT with `extern const __heap_start: anyopaque` plus +/// `@intFromPtr`/`@ptrFromInt`. That spelling was here first and it was silently wrong: declaring a +/// linker symbol as an `anyopaque` OBJECT gives the optimiser a zero-sized object, so a pointer +/// derived from its address carries provenance for zero bytes, and ordinary (non-volatile) stores +/// through it are dead code it may drop. `examples/heapcheck.zig` caught it on the die - the +/// allocator's first block header read back as `size=2988759312 next=0xffffffff`-not, and the free +/// list walk never terminated. A `[*]u8` from `@extern` has no size to lose. +const heap_start = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_start" }); +const heap_end = @extern([*]align(heapmod.Heap.granule) u8, .{ .name = "__heap_end" }); + +fn heapSpan() []align(heapmod.Heap.granule) u8 { + return heap_start[0 .. @intFromPtr(heap_end) - @intFromPtr(heap_start)]; +} + +/// The one heap. A K&R coalescing free list over that span, validated on this die by +/// `examples/heapcheck.zig`: 512 blocks fill and free back to a single 393,216-byte block, a holed +/// arena still satisfies a 4 KiB request, and 20,000 random operations drain back to one block. +var gpa_heap: heapmod.Heap = undefined; + +// The four C forwarders the editor is handed. `log2_align` round-trips through +// `std.mem.Alignment`, whose representation IS the log2 value. + +fn cAlloc(_: ?*anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8 { + const a = gpa_heap.allocator(); + return a.vtable.alloc(a.ptr, len, @enumFromInt(log2_align), @returnAddress()); +} + +fn cResize(_: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8, new_len: usize) callconv(.c) bool { + const a = gpa_heap.allocator(); + return a.vtable.resize(a.ptr, ptr[0..len], @enumFromInt(log2_align), new_len, @returnAddress()); +} + +fn cFree(_: ?*anyopaque, ptr: [*]u8, len: usize, log2_align: u8) callconv(.c) void { + const a = gpa_heap.allocator(); + a.vtable.free(a.ptr, ptr[0..len], @enumFromInt(log2_align), @returnAddress()); +} + +const editor_allocator: Allocator = .{ + .ctx = null, + .alloc = cAlloc, + .resize = cResize, + .free = cFree, +}; + +// ------------------------------------------------------------------------------------ the clock + +/// Milliseconds since boot, off the systimer - a 16 MHz counter (`hal/systimer.zig:31`), which is +/// the cheapest trustworthy clock on this chip. `read` returns null if the unit is not running, in +/// which case time simply does not advance and the editor stops animating; that is a better failure +/// than a clock that jumps. +fn nowMs() u64 { + const us = hal.systimer.micros(.unit0) orelse return 0; + return us / 1000; +} + +// ------------------------------------------------------------------------------------- the loop + +export fn zig_main() noreturn { + // The very first thing, through the TX FIFO directly rather than the mask ROM. Two independent + // output paths matter during bring-up: if this line is clean and `soc.rom.print` below is + // garbage, the fault is in the ROM path (or in something this image did to the ROM's statics); + // if this line is already garbage, the fault is before it, in the entry or the clocks. + uart.write("\r\nMARK PARDES_ENTRY direct-fifo\r\n"); + // Self-consistent probe: take the literal's OWN address at run time and dump both it and the + // bytes there. Comparing a runtime read against `llvm-objdump` of a DIFFERENT build is how this + // investigation wasted a cycle - every literal moves when the file changes. + const lit = "\r\nMARK PARDES_ENTRY direct-fifo\r\n"; + uart.writeByte('<'); + uart.dumpWord(@intFromPtr(lit.ptr)); // where the linker says the literal is + uart.dumpHex(@intFromPtr(lit.ptr), 8); // what a volatile read sees there + uart.writeByte('|'); + uart.write(lit); // what the ordinary slice path sends + uart.writeByte('>'); + uart.writeByte('\r'); + // Page 3 of .flash.rodata. The editor's allocator vtable lives at 0x40035a1c and a runtime load + // of its first entry returned instruction-looking garbage, while page 4 (the literal above, and + // the allocator struct this file passes over) reads correctly. So read page 3 raw and compare + // against llvm-objdump. + // Walk page 3 at 8 KiB steps. If a load at offset 0 is right and one at 0x5a1c is wrong, the + // aliasing granularity is FINER than the 64 KiB `tools/image.zig` assumes for congruence - which + // would mean the flashed bootloader was built with a smaller CONFIG_MMU_PAGE_SIZE (the P4's page + // size is configurable, and the image builder's congruence check is only as strong as the page + // size it believes in). Where the first mismatch falls names the real size. + // A CONTIGUOUS 192 bytes across a known-bad address. The image and the ELF agree here and the + // flash is MD5-verified against the image, so the wrong bytes are produced between the flash and + // the load. If the corruption comes in 64-byte chunks with correct data either side, it is cache + // lines; if it is a clean run of thousands of bytes, it is a mapping. + uart.dumpHex(0x4003_59c0, 64); + uart.dumpHex(0x4003_5a00, 64); + uart.dumpHex(0x4003_5a40, 64); + uart.dumpHex(0x4004_0000, 8); + uart.writeByte(']'); + uart.writeByte('\r'); + uart.writeByte('\n'); + uart.writeByte('\n'); + uart.write("MARK B1 entry ok\r\n"); + const heap = heapSpan(); + uart.write("MARK B2 heapSpan ok\r\n"); + soc.rom.print("\r\nMARK B3 rom.print heap 0x%08x..0x%08x %u KiB\r\n", .{ + @as(u32, @intFromPtr(heap.ptr)), + @as(u32, @intFromPtr(heap.ptr)) + @as(u32, @intCast(heap.len)), + @as(u32, @intCast(heap.len / 1024)), + }); + uart.write("MARK B4 rom.print returned\r\n"); + + const rwdt_was_armed = hal.rwdt.disable(); + uart.write("MARK B5 rwdt ok\r\n"); + hal.systimer.init(); + uart.write("MARK B6 systimer ok\r\n"); + _ = rwdt_was_armed; + + const their_abi = pardes_p4_abi_version(); + uart.write("MARK B7 abi call returned\r\n"); + if (their_abi != abi_version) { + uart.write("MARK PARDES_ABI_MISMATCH\r\n"); + while (true) {} + } + + gpa_heap = heapmod.Heap.init(heap); + uart.write("MARK B8 heap init ok\r\n"); + _ = uart.drainInput(); + uart.write("MARK B9 drain ok, calling pardes_p4_init\r\n"); + + const rc = pardes_p4_init(&editor_allocator, writeOut, null, 80, 24); + uart.write("MARK B10 pardes_p4_init returned\r\n"); + if (rc != 0) { + soc.rom.print("MARK PARDES_INIT_FAIL rc=%u\r\n", .{rc}); + const s = gpa_heap.stats(); + soc.rom.print("MARK PARDES_HEAP free=%u largest=%u blocks=%u\r\n", .{ + s.free, s.largest_free, s.free_blocks, + }); + while (true) {} + } + soc.rom.print("MARK PARDES_READY\r\n", .{}); + + var in: [256]u8 = undefined; + while (!pardes_p4_quit()) { + const n = uart.read(&in); + if (n > 0) pardes_p4_input(&in, n); + + pardes_p4_tick(nowMs()); + + // Only when there is something to show. On a link this slow an unconditional repaint per + // iteration would saturate the wire and starve input. + if (pardes_p4_wants_frame()) { + const err = pardes_p4_render(); + if (err != 0) soc.rom.print("MARK PARDES_RENDER_FAIL rc=%u\r\n", .{err}); + } + } + + soc.rom.print("\r\nMARK PARDES_QUIT\r\n", .{}); + while (true) {} +} + +// --------------------------------------------------------------------------- the root's own duties + +/// `page_size_min`/`max`: the board has no MMU and no pages, but std derives allocator alignment +/// from these. 4 KiB is the ESP32-P4's cache and DMA granularity. +/// +/// `logFn` is not cosmetic. std's default log implementation reaches `std.debug_io`, which +/// instantiates `std.Io.Threaded` - a thread pool, `getrandom`, `IOV_MAX`, `mremap` - none of which +/// exist here, and one `log.warn` from anywhere is enough to drag all of it into the image. +pub const std_options: std.Options = .{ + .page_size_min = 4096, + .page_size_max = 4096, + .logFn = logFn, +}; + +fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime fmt: []const u8, + args: anytype, +) void { + var buf: [256]u8 = undefined; + const line = std.fmt.bufPrint(&buf, "\r\n[" ++ level.asText() ++ "/" ++ @tagName(scope) ++ "] " ++ fmt ++ "\r\n", args) catch + "\r\n[log overflow]\r\n"; + uart.write(line); +} + +pub const panic = std.debug.FullPanic(panicImpl); + +fn panicImpl(msg: []const u8, _: ?usize) noreturn { + // The ROM path deliberately: a panic may BE the console writer failing, and `ets_printf` shares + // nothing with `uart.write` except the FIFO itself. + soc.rom.print("\r\nMARK PARDES_PANIC %s\r\n", .{msg.ptr}); + while (true) {} +} + +/// Reset entry. The bootloader hands over with an unspecified stack pointer and the FPU off, so: +/// enable the F extension (`mstatus.FS`, which ESP-IDF only ever turns on lazily from a trap +/// handler this image does not have), establish a stack, clear `.bss`, and call into Zig. +export fn _start() linksection(".text.entry") callconv(.naked) noreturn { + asm volatile ( + \\ li t0, 1 << 13 + \\ csrs mstatus, t0 + \\ la sp, __stack_top + \\ mv fp, sp + \\ la t0, __bss_start + \\ la t1, __bss_end + \\ bgeu t0, t1, 2f + \\1: + \\ sw zero, 0(t0) + \\ addi t0, t0, 4 + \\ bltu t0, t1, 1b + \\2: + \\ j zig_main + ); +} diff --git a/src/pardes/uart.zig b/src/pardes/uart.zig new file mode 100644 index 0000000..ce386fe --- /dev/null +++ b/src/pardes/uart.zig @@ -0,0 +1,115 @@ +//! UART0 as the editor's terminal: bytes out, bytes in, and nothing else. +//! +//! This is the whole of the firmware's I/O. There is no framebuffer and no keyboard; the board +//! emits ANSI and consumes ANSI, and the terminal emulator on the far end of the CH340 does the +//! rest of the work - including answering the editor's own capability queries, which travel down +//! this wire like any other bytes. +//! +//! Deliberately not a `std.Io.Writer`. The ANSI encoding lives on the other side of the C ABI, next +//! to the vaxis that produces it (see `src/pardes/app.zig` for why the seam is there and not +//! elsewhere), so what crosses into this file is already a finished run of bytes. A writer here +//! would be a second buffer in front of one that already exists. +//! +//! Two decisions worth stating, because both are measurements rather than preferences. +//! +//! **Batched FIFO access.** The naive push is `while (txFree() == 0) {}` then `pushByte`, once per +//! byte: one MMIO read per byte at best, many while the FIFO is full. Reading `txFree` once and +//! then pushing that many cuts the status reads by up to the FIFO depth (128, `hal/uart.zig:52`). +//! At 115200 the wire costs ~86 us per byte and dwarfs either version, so today this is merely +//! free - and it stops being free the moment the divider is raised. +//! +//! **UART0's configuration is never touched.** Not the divider, not the format, not the pad +//! routing, and above all not `reset()`. The second-stage bootloader configured this block, and +//! `hal/uart.zig:195-211` records what happens if it is reset: UART_CLKDIV returns to its power-on +//! value, the console turns to garbage mid-sentence, and the board takes a watchdog reset with +//! nothing readable left to explain it. Everything here touches FIFO offset 0x000 and the status +//! register, and nothing else. + +const hal = @import("hal"); + +/// UART0: the instance the CH340 is wired to, and the one the ROM and bootloader configured. +const uart0 = hal.uart.Uart.init(0); + +/// Push `bytes` into the TX FIFO, blocking while it is full. +/// +/// The spin is bounded by the wire and there is nothing else for this core to do: a full 128-byte +/// FIFO drains in 11 ms at 115200. It is also the only backpressure in the system - dropping +/// instead would truncate an escape sequence, and a half-written SGR leaves the host terminal in +/// the wrong colour for the rest of the session. +pub fn write(bytes: []const u8) void { + var rest = bytes; + while (rest.len > 0) { + // One status read per burst, not per byte. + var room = uart0.txFree(); + while (room == 0) room = uart0.txFree(); + const n = @min(room, rest.len); + for (rest[0..n]) |b| uart0.pushByte(b); + rest = rest[n..]; + } +} + +/// One byte, for callers that must not touch `.rodata` to say anything - which during bring-up is +/// the difference between a diagnostic and a second copy of the bug being diagnosed. +pub fn writeByte(b: u8) void { + while (uart0.txFree() == 0) {} + uart0.pushByte(b); +} + +/// Emit `n` bytes read from `addr` as two hex digits each, computing the digits arithmetically so +/// nothing here reads a lookup table. Used to answer "does a load from this address return what the +/// linker put there", which is not a question a string literal can be trusted to ask. +pub fn dumpHex(addr: u32, n: u32) void { + const p: [*]const volatile u8 = @ptrFromInt(addr); + var i: u32 = 0; + while (i < n) : (i += 1) { + const byte = p[i]; + for ([2]u8{ byte >> 4, byte & 0xf }) |nib| { + writeByte(if (nib < 10) '0' + nib else 'a' + (nib - 10)); + } + } + writeByte('\r'); + writeByte('\n'); +} + +/// A u32 as eight hex digits, reading no memory at all. +pub fn dumpWord(v: u32) void { + var shift: u5 = 28; + while (true) { + const nib: u8 = @intCast((v >> shift) & 0xf); + writeByte(if (nib < 10) '0' + nib else 'a' + (nib - 10)); + if (shift == 0) break; + shift -= 4; + } + writeByte('\r'); + writeByte('\n'); +} + +/// Move whatever the host has sent into `buf`, without waiting. Returns the count. +/// +/// Non-blocking on purpose: the loop has a frame to render and a core to pump, and the editor must +/// not stall on a keystroke that may never come. `rxCount` is read once per call and the FIFO +/// drained to that mark, so a fast typist or a pasted buffer cannot hold the loop here. +pub fn read(buf: []u8) usize { + const waiting = @min(uart0.rxCount(), buf.len); + for (buf[0..waiting]) |*slot| slot.* = uart0.popByte(); + return waiting; +} + +/// Discard anything already received, returning how much. Used once at startup: the host-side +/// bridge injects a window-size report before this program exists, and the bootloader's chatter has +/// already been echoed at the host. Neither is user input. +/// +/// Pops rather than calling `resetRxFifo`, which is a CONF0_SYNC read-modify-write plus two commits +/// on the console UART - see this file's header. +pub fn drainInput() u32 { + var dropped: u32 = 0; + while (uart0.rxCount() > 0) : (dropped += 1) _ = uart0.popByte(); + return dropped; +} + +/// The rate the hardware is actually producing, by reading its dividers back. Reported rather than +/// assumed: the host has to be opened at the same rate, and a mismatch shows up as garbage on the +/// screen rather than as an error anyone can act on. +pub fn baudrate() u32 { + return uart0.baudrate(uart0.clockSource().nominalHz()); +} |
