From 174991b8f3f8e9c792eede7a52ad7beb10a08b05 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 25 Aug 2026 13:06:05 -0300 Subject: 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. --- examples/echo.zig | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 examples/echo.zig (limited to 'examples/echo.zig') diff --git a/examples/echo.zig b/examples/echo.zig new file mode 100644 index 0000000..6f17c98 --- /dev/null +++ b/examples/echo.zig @@ -0,0 +1,146 @@ +//! UART0 as a duplex byte pipe, which is the one thing this toolchain had never done. +//! +//! Everything else here talks to the host through `soc.rom.print`, a mask-ROM `ets_printf`. That is +//! one-way and it is slow: the ROM formats, then pushes a byte at a time and spins on the FIFO. An +//! editor rendering a screen needs the other direction and needs the fast path, so this example +//! exists to prove three things on the die before anything larger depends on them: +//! +//! 1. **RX works at all.** `hal/uart.zig` has had `rxCount`/`popByte` since the differential +//! suite needed them, but that suite runs on UART1 in internal loopback - no byte has ever +//! arrived from the outside world on UART0. +//! 2. **UART0 can be driven without reconfiguring it.** The header of `hal/uart.zig` is blunt +//! about the hazard: resetting UART0 clears UART_CLKDIV, the console turns to garbage +//! mid-sentence and the board dies on a watchdog reset. So this touches no configuration +//! register - the second-stage bootloader already set the divider, the format and the pad +//! routing, and this code only reads and writes the FIFO. +//! 3. **What the wire rate actually is.** Printed by asking the hardware +//! (`Uart.baudrate`), not by assuming the 115200 the host tooling opens with. +//! +//! Protocol, so the host side has something unambiguous to assert on: +//! +//! any byte -> echoed back verbatim +//! CR (0x0d) -> echoed as CRLF, so a human sees lines +//! '!' -> also emit `bulk_len` bytes of a counted pattern and report the cycles it took +//! Ctrl-D -> print the byte/frame counters +//! +//! The echo is verbatim rather than uppercased or otherwise transformed on purpose: a transform +//! that happens to be idempotent hides a duplicated byte, and a duplicated byte is exactly the +//! failure a FIFO-polling loop produces when `rxCount` is misread. + +const std = @import("std"); +const soc = @import("soc"); +const hal = @import("hal"); + +/// UART0. Instance 0 because that is the pad pair the CH340 is wired to and the one the ROM +/// configured; nothing here may reset it. +const con = hal.uart.Uart.init(0); + +/// The bulk burst `!` emits. 4 KiB is ~35 ms of wire time at 115200 and ~4.5 ms at 921600, so the +/// difference between the two is obvious to the naked eye on the host. +const bulk_len = 4096; + +/// The clock the UART's baud generator is dividing. XTAL is the reset default and what the ROM +/// leaves selected; `Uart.clockSource` is read below rather than assumed, so a bootloader that +/// switched to PLL_F80M shows up as a wrong rate instead of a silent 2x error. +fn sourceHz() u32 { + return con.clockSource().nominalHz(); +} + +/// Block until the TX FIFO has room, then push. The spin is bounded by the wire: at 115200 a full +/// 128-byte FIFO drains in 11 ms, and there is nothing else for this core to do. +/// +/// `txFree` and not "is the FIFO empty": pushing whenever there is a single free slot keeps the +/// transmitter fed, which is what makes this ~10x the throughput of the ROM's per-byte printf. +fn put(byte: u8) void { + while (con.txFree() == 0) {} + con.pushByte(byte); +} + +fn puts(bytes: []const u8) void { + for (bytes) |b| put(b); +} + +export fn zig_main() noreturn { + // Deliberately the ROM path for the banner: if the direct-FIFO writes below are wrong, the + // banner still arrives and says so. Mixing the two is safe because both end up in the same + // FIFO and this is the only writer. + soc.rom.print("\r\nMARK ECHO_BOOT uart0 duplex echo\r\n", .{}); + soc.rom.print("MARK ECHO_BAUD hw=%u src=%u Hz\r\n", .{ con.baudrate(sourceHz()), sourceHz() }); + + // Not `resetRxFifo`: that is a CONF0_SYNC read-modify-write plus two commits on the console + // UART, and this file's whole premise is that UART0's configuration is untouchable. Draining by + // popping has the same effect on the FIFO and touches only offset 0x000. + var dropped: u32 = 0; + while (con.rxCount() > 0) : (dropped += 1) _ = con.popByte(); + soc.rom.print("MARK ECHO_DRAIN dropped=%u stale bytes\r\n", .{dropped}); + puts("MARK ECHO_FIFO direct-fifo tx works\r\n"); + puts("type; '!' bulk, ctrl-D stats\r\n"); + + var rx_total: u32 = 0; + var bursts: u32 = 0; + while (true) { + if (con.rxCount() == 0) continue; + const byte = con.popByte(); + rx_total += 1; + + switch (byte) { + '\r' => puts("\r\n"), + 0x04 => { + var buf: [96]u8 = undefined; + const line = std.fmt.bufPrint( + &buf, + "\r\nMARK ECHO_STATS rx={d} bursts={d} baud={d}\r\n", + .{ rx_total, bursts, con.baudrate(sourceHz()) }, + ) catch "\r\nMARK ECHO_STATS fmt failed\r\n"; + puts(line); + }, + '!' => { + bursts += 1; + put(byte); + const t0 = soc.cycles(); + // A counted pattern, not a constant: a run of identical bytes cannot reveal a + // dropped or reordered one, and the host asserts on the sequence. + var i: u32 = 0; + while (i < bulk_len) : (i += 1) put('0' + @as(u8, @intCast(i % 10))); + const cycles = soc.cycles() - t0; + var buf: [96]u8 = undefined; + const line = std.fmt.bufPrint( + &buf, + "\r\nMARK ECHO_BULK {d} bytes in {d} cycles\r\n", + .{ bulk_len, cycles }, + ) catch "\r\nMARK ECHO_BULK fmt failed\r\n"; + puts(line); + }, + else => put(byte), + } + } +} + +/// Reset entry. Identical in shape to `src/main.zig`'s and for the same reasons - the bootloader +/// hands over with an unspecified stack pointer and the FPU off - but `std.fmt.bufPrint` above is +/// the reason the FPU bit matters here too: its float formatting path is reachable from a generic +/// `bufPrint` instantiation even when no argument is a float. +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 + ); +} + +pub const panic = std.debug.FullPanic(struct { + fn call(msg: []const u8, _: ?usize) noreturn { + soc.rom.print("MARK ECHO_PANIC %s\r\n", .{msg.ptr}); + while (true) {} + } +}.call); -- cgit v1.3