summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 13:06:05 -0300
committerGabriel Schneider <[email protected]>2026-08-25 14:38:25 -0300
commit174991b8f3f8e9c792eede7a52ad7beb10a08b05 (patch)
tree3dcc42604752251227e233294d956e18cb264ad8
parentf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (diff)
downloadesp32p4-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.
-rw-r--r--.gitignore7
-rw-r--r--README.md77
-rw-r--r--build.zig208
-rw-r--r--examples/echo.zig146
-rw-r--r--examples/heapcheck.zig240
-rw-r--r--examples/memprobe.zig215
-rw-r--r--examples/minimal.zig2
-rw-r--r--src/pardes/app.zig318
-rw-r--r--src/pardes/uart.zig115
-rw-r--r--tools/console.zig239
-rw-r--r--tools/image.zig13
-rw-r--r--tools/image_test.zig3
-rw-r--r--tools/serial.zig24
13 files changed, 1587 insertions, 20 deletions
diff --git a/.gitignore b/.gitignore
index bbd963e..d5c08c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,13 @@
# ELF under -Delf). Both are derived from tracked sources.
/zig-out/
+# Third-party package closure. Nothing here is ours and nothing here belongs in
+# this history: one `-Dpardes` build materialised 2.6 GB across 106 packages and
+# 42,736 files - SDL, wayland, glslang, mupdf, wasmtime, 25+ tree-sitter
+# grammars - and jj tracked every one under 1 MiB while merely warning about the
+# rest. Kept as a rule rather than a memory, because a fetch is one command away.
+/zig-pkg/
+
# Scope captures: the .json IS the measurement and stays tracked. The .png is a
# 1.1 MB screenshot of that same JSON and is referenced by nothing.
/captures/*.png
diff --git a/README.md b/README.md
index 6e15730..abb94d7 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,8 @@ zig build # compile, link, and emit a flashable image
zig build flash # ...then write it to the chip and run it
zig build run # flash, then print the console (ordered; `flash monitor` is not)
zig build monitor # reset the board and print its console
+zig build console # attach an interactive terminal: keystrokes in, screen out (Ctrl-] detaches)
+zig build interact # flash, then attach that terminal (ordered, like `run`)
zig build reset # just pulse the reset line
zig build size # where every byte of the image went
zig build test # host tests: image builder, and the register layer's field arithmetic
@@ -65,11 +67,14 @@ Everything is a `b.option`, so `zig build -h` lists them all.
| `-Dflash-size=<enum>` | `16MB` | fitted flash, written into the image header |
| `-Dmin-rev`/`-Dmax-rev` | `100`/`199` | silicon revision window. The pre-v3 P4 needs 100..199 |
| `-Ddescriptor=<enum>` | `minimal` | 184-byte descriptor, or `full` for the 256-byte one `esptool image-info` can parse |
-| `-Dstack=<u32>` | `8192` | stack size; the generated linker script follows |
+| `-Dstack=<u32>` | `8192` | stack size; the generated linker script follows. `32768` under `-Dpardes` |
| `-Dverify=<bool>` | `true` | ask the ROM for an MD5 of what it stored and compare |
| `-Delf=<bool>` | `false` | also install the ELF |
| `-Doptimize=<mode>` | `ReleaseSmall` | firmware default, not Debug (Debug costs ~780 B here) |
| `-Dseconds=<u32>` | `5` | how long `monitor` listens |
+| `-Dconsole-baud=<enum>` | `b115200` | the interactive console's rate: what the bootloader leaves UART0 at. Distinct from `-Dbaud`, which the ROM loader auto-detects |
+| `-Dpardes=<bool>` | `false` | build the pardes editor as the application. Needs the object below |
+| `-Dpardes-obj=<path>` | `../02-pardes-code/zig-out/pardes-p4.o` | the editor, compiled freestanding by its own build and linked here |
## Layout
@@ -79,16 +84,82 @@ tools/image.zig ELF -> ESP image. Header, segments, congruence filler, ch
tools/image_test.zig 8 host tests, one per rule the ROM bootloader enforces
tools/rom.zig SLIP framing + the ROM loader protocol. No software stub
tools/serial.zig termios2 raw mode, arbitrary baud, DTR/RTS reset dance
+tools/console.zig the interactive bridge: raw stdin <-> UART, and the window-size handshake
src/soc.zig comptime register model: GPIO, IOMUX, mask-ROM entry points, cycle counter
src/appdesc.zig esp_app_desc_t, linked as its own object so it cannot be optimised away
src/main.zig demo: prints what it can prove, then blinks
+src/pardes/ the pardes editor as firmware: entry, heap, UART, and the C ABI it links to
examples/minimal.zig the floor: 432 B, blinks and nothing else
+examples/echo.zig UART0 duplex echo: the proof that receive works on the die
+examples/memprobe.zig what RAM this board actually has, measured rather than assumed
+examples/heapcheck.zig the allocator under an editor's workload, on the die
```
+## What RAM this board has
+
+Measured by `examples/memprobe.zig`, on the die, because it cannot be read off ESP-IDF's linker
+fragments. The relevant one (`esp_system/ld/esp32p4/memory.ld.in:18-33`) is parameterised on
+`CONFIG_CACHE_L2_CACHE_SIZE`, and that Kconfig's own help text says the size is set "on application
+startup" — by an application this is not.
+
+```
+0x4FF03000..0x4FF3F000 240 KiB RAM .data/.bss/.stack live at the bottom of this
+0x4FF3F000..0x4FF40000 4 KiB ROM the mask ROM's .data/.bss; ets_printf needs it
+0x4FF40000..0x4FFA0000 384 KiB RAM handed over whole as __heap_start..__heap_end
+0x4FFA0000..0x4FFC0000 128 KiB cache NOT memory: the L2 cache lives here
+0x48000000 PSRAM 32 MB fitted, untrained; touching it hangs the core
+```
+
+That last RAM line cost a bug worth repeating, because the first version of the probe reported the
+whole upper 512 KiB as usable. It wrote a pattern to a page and read it straight back, one page at a
+time — and a store followed immediately by a load of the *same* address returns the stored value
+whether the backing store is real memory, an address mirror, or merely a dirty cache line. Writing
+every page before reading any page separates the three, and the top 128 KiB then failed. ESP-IDF's
+own arithmetic agrees exactly: `SRAM_HIGH_SIZE = 0x80000 - CONFIG_CACHE_L2_CACHE_SIZE`, with the
+128 KiB default from `esp_system/port/soc/esp32p4/Kconfig.cache:5,19`. The wrong number had already
+been committed to the linker script, where it handed 128 KiB of live L2 cache to an allocator.
+
+PSRAM stays untrained deliberately. ESP-IDF's ESP32-P4 implementation runs past a thousand lines —
+MPLL, MSPI clocking, pin drive and DQS, CS timing, mode registers, a connectivity check, and a
+whole timing-calibration subsystem — and the mask ROM exposes only MMU mapping
+(`Cache_PSRAM_MMU_Init`, `Cache_PSRAM_MMU_Set`), no device init. The probe reads `0x48000000` on
+purpose and hangs there, which is why it prints its cursor before every access rather than after.
+
+**The RTC watchdog is armed when the bootloader hands over**, and it expects the application to take
+it over. Nothing here did, so every example in this repo had been resetting on a ten-second cycle,
+invisibly, for as long as no run lasted eight seconds. `examples/heapcheck.zig`'s 20,000 allocations
+is the first run that did. `hal.rwdt.disable()` is the fix and `hal.rwdt.armed()` is worth printing
+at startup.
+
## What adversarial review found
-Five reviewers went at this in two rounds; sixteen findings were applied. The ones worth knowing
-about, because each is a trap the next person will hit too:
+Five reviewers went at this in two rounds; sixteen findings were applied. Two more rounds went at
+the memory map and the editor port and found the three below first. The ones worth knowing about,
+because each is a trap the next person will hit too:
+
+* **A linker symbol declared as an object gives the optimiser a zero-sized object, and it will drop
+ your stores.** `extern const __heap_start: anyopaque` plus `@intFromPtr`/`@ptrFromInt` looks like
+ the obvious way to reach a region the linker script defines. The pointer it produces carries
+ provenance for zero bytes, so the ordinary (non-volatile) store the allocator makes through it is
+ dead code the backend may remove — and did. The first block header read back as
+ `size=2988759312 next=0x14284684` instead of `{393216, 0xFFFFFFFF}`, the free-list walk followed
+ garbage, and with asserts compiled out in `ReleaseSmall` that is a silent hang with no console
+ output after `MARK HEAP_INIT`. `@extern([*]u8, .{ .name = "__heap_start" })` has no size to lose.
+ Note that `examples/memprobe.zig` could not have caught this: it writes through a `volatile`
+ pointer, which the optimiser must leave alone. The two files disagreed about whether the same
+ address worked, which is what made it findable.
+
+* **A one-page memory probe cannot tell RAM from a mirror or a cache line.** See the section above:
+ writing and reading the same address back-to-back succeeds in all three cases, and the pattern
+ being address-derived does not help because the alias is written *and* read through the alias.
+ This one had already shipped a wrong 512 KiB into the linker script.
+
+* **Ignoring `POLL.HUP`/`ERR`/`NVAL` is a hot spin, not a no-op.** `tools/console.zig` polls stdin
+ and the port and acted only on `POLL.IN`. Unplug the CH340 mid-session and `revents` carries
+ `HUP|ERR|NVAL` forever: `poll` returns immediately with a non-zero count, neither branch matches,
+ and the loop burns a core with the terminal still in raw mode and `ISIG` off — so Ctrl-C cannot
+ even end it. `std.posix.poll` cannot report it as an error either, because a dead descriptor is
+ delivered in `revents` rather than errno (`std/posix.zig:1007-1017` maps `INVAL` to `unreachable`).
* **The ROM's status byte is at `data[len-4]`, not `data[len-2]`.** The four-byte trailer is a
ROM-versus-stub difference (esptool `loader.py:653-655`). Reading the wrong byte made *every* ROM
diff --git a/build.zig b/build.zig
index 3a22d4f..0791bc5 100644
--- a/build.zig
+++ b/build.zig
@@ -3,6 +3,7 @@
//! zig build compile, link, and emit a flashable image
//! zig build flash the above, then write it to the chip over the serial port
//! zig build monitor open the console
+//! zig build console attach an interactive terminal: keystrokes in, screen out
//! zig build size print where every byte of the image went
//!
//! There is no CMake, no ninja, no idf.py, no esptool and no external linker: Zig's own LLD does
@@ -14,6 +15,7 @@ const std = @import("std");
const image = @import("tools/image.zig");
const serial = @import("tools/serial.zig");
const rom = @import("tools/rom.zig");
+const console = @import("tools/console.zig");
pub fn build(b: *std.Build) void {
// ---------------------------------------------------------------- board and target knobs
@@ -32,7 +34,12 @@ pub fn build(b: *std.Build) void {
const min_rev = b.option(u16, "min-rev", "minimum silicon revision, major*100+minor (default 100)") orelse 100;
const max_rev = b.option(u16, "max-rev", "maximum silicon revision (default 199)") orelse 199;
const descriptor = b.option(DescriptorKind, "descriptor", "app descriptor: minimal (184 B) or full (256 B)") orelse .minimal;
- const stack_size = b.option(u32, "stack", "stack size in bytes (default 8192)") orelse 8192;
+ // `-Dpardes` swaps in the editor as the application. It is a distinct option rather than just
+ // `-Dapp=src/pardes/app.zig` because it also resolves the lazy `pardes` dependency and raises
+ // the default stack: the core recurses through layout and 8 KiB is not enough for it.
+ const pardes_app = b.option(bool, "pardes", "build the pardes editor as the application (needs ../02-pardes-code)") orelse false;
+ const stack_size = b.option(u32, "stack", "stack size in bytes (default 8192, or 32768 under -Dpardes)") orelse
+ @as(u32, if (pardes_app) 32768 else 8192);
// ReleaseSmall by default: this is firmware, and `standardOptimizeOption` would otherwise
// hand out Debug builds - which for this target means panic machinery and formatting code
// linked into a 500-byte image.
@@ -150,7 +157,10 @@ pub fn build(b: *std.Build) void {
}),
});
- const app_source = b.option([]const u8, "app", "root source file (default src/main.zig)") orelse "src/main.zig";
+ // The app root still comes from `-Dapp`, so pointing that at a different shell over the same
+ // module stays possible.
+ const app_source = b.option([]const u8, "app", "root source file (default src/main.zig, or src/pardes/app.zig under -Dpardes)") orelse
+ if (pardes_app) "src/pardes/app.zig" else "src/main.zig";
const app = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
@@ -212,6 +222,53 @@ pub fn build(b: *std.Build) void {
});
app.root_module.addImport("io", io_mod);
+ // The general-purpose allocator, its own module for exactly the reason given above for `io`: a
+ // module's imports cannot escape its root directory, so neither src/pardes/ nor examples/ can
+ // reach src/net/heap.zig as a file. Pointed at the existing file rather than copied - `Heap` is
+ // a coalescing free-list over one caller-supplied span and has nothing to do with the radio; it
+ // lives under src/net/ only because ESP-Hosted needed it first. The file has zero `export`s, so
+ // compiling it into two modules cannot collide.
+ //
+ // Added unconditionally, like `io`: an application that never imports it costs nothing, because
+ // an unreferenced module emits no code.
+ const heap_mod = b.createModule(.{
+ .root_source_file = b.path("src/net/heap.zig"),
+ .target = target,
+ .optimize = optimize,
+ .single_threaded = true,
+ });
+ app.root_module.addImport("heap", heap_mod);
+
+ if (pardes_app) {
+ // The editor arrives as a linked OBJECT, not as a package dependency, and that is a
+ // measurement rather than a preference.
+ //
+ // The obvious design was `build.zig.zon` with a path dependency on ../02-pardes-code, and
+ // `dep.module("pardes_p4")`. It was written, and it broke EVERY build in this repo -
+ // `zig build`, every example, the oracle - because merely DECLARING it nests pardes's
+ // ~30-package graph under this one. Two failures, both from just the declaration:
+ //
+ // * std/Build.zig:2091 evaluates `mem.eql(u8, decl.name, pkg_hash)` over the whole
+ // dependency table at comptime, and the enlarged table exceeds the 1000 backwards
+ // branch quota. It is reached from ghostty's own build (SharedDeps.zig:874 calls
+ // `b.lazyImport`), which is not lazy in pardes's manifest and so is always compiled.
+ // * pardes's package cache holds seven tree_sitter versions, and the stale ones use
+ // `Compile.addCSourceFile`/`linkLibrary`, removed in Zig 0.16. Nesting made them
+ // reachable and their build.zig files failed to compile.
+ //
+ // Neither is fixable from this side, and both would come back the next time the editor
+ // gained a dependency. So the seam is a file instead: pardes's own build emits one
+ // freestanding object exporting a small C ABI, and this links it. The consequences are all
+ // improvements - this repo keeps having no manifest and no dependencies, the editor's
+ // renderer stays next to the vaxis it needs, and the boundary is bytes in / bytes out.
+ const obj = b.option([]const u8, "pardes-obj", "path to pardes's p4 object (default ../02-pardes-code/zig-out/pardes-p4.o)") orelse
+ "../02-pardes-code/zig-out/pardes-p4.o";
+ app.root_module.addObjectFile(if (std.fs.path.isAbsolute(obj))
+ .{ .cwd_relative = obj }
+ else
+ b.path(obj));
+ }
+
if (hosted) {
// The Zig half: the port table, the libc surface and the IP stack.
//
@@ -291,6 +348,25 @@ pub fn build(b: *std.Build) void {
run_mon.step.dependOn(&flash.step);
b.step("run", "flash the image, then print its console output").dependOn(&run_mon.step);
+ // The interactive counterpart of `monitor`. `monitor` prints for N seconds and sends nothing,
+ // which is right for an application that only reports; an application the human drives needs
+ // the keyboard on the wire. `-Dconsole-baud` is separate from `-Dbaud` because they are
+ // genuinely different rates: the flasher's rate is negotiated by the ROM loader's SYNC
+ // auto-detect, while the console's is whatever the running firmware programmed into UART0.
+ const console_baud = b.option(
+ serial.Baud,
+ "console-baud",
+ "interactive console baud (default 115200, the rate the bootloader leaves UART0 at)",
+ ) orelse .b115200;
+ const con = ConsoleStep.create(b, port_path, console_baud);
+ b.step("console", "attach an interactive terminal to the running application").dependOn(&con.step);
+
+ // Ordered, for the same reason `run` is: an unordered `flash console` lets the console reset
+ // the board out from under the writer.
+ const run_con = ConsoleStep.create(b, port_path, console_baud);
+ run_con.step.dependOn(&flash.step);
+ b.step("interact", "flash the image, then attach an interactive terminal").dependOn(&run_con.step);
+
const reset = ResetStep.create(b, port_path);
b.step("reset", "reset the board and let the flashed application run").dependOn(&reset.step);
@@ -1055,27 +1131,94 @@ fn linkerScript(b: *std.Build, stack_size: u32, peripherals_ld: ?[]const u8) []c
\\ that -Dhosted links ESP-Hosted's transport and RPC layers, and the generated protobuf
\\ descriptors alone are 33,000 lines of .rodata. One window overflowed by 14,744 bytes.
\\
- \\ 1 MiB now. It is a *region*, not a reservation: the image contains only the sections
- \\ actually emitted, so a blink app is still ~1.7 KB. The ceiling that matters is the
- \\ factory partition, 0x177000 = 1.5 MiB, and this stays comfortably inside it. Segments
- \\ still come in the two the loader wants; they simply span more than one MMU page now,
- \\ which the bootloader maps without complaint. */
- \\ flash (rx) : ORIGIN = 0x40000020, LENGTH = 0xFFFE0
- \\ l2mem (rw) : ORIGIN = 0x4FF00000, LENGTH = 0x20000
+ \\ 1.5 MiB now, less 32 KiB of slack. It is a *region*, not a reservation: the image
+ \\ contains only the sections actually emitted, so a blink app is still ~1.7 KB. The
+ \\ ceiling that matters is the factory partition - `zig build monitor` reads it off the
+ \\ bootloader's own table as `factory 00010000 00177000` - and the region is deliberately
+ \\ set just under it so that an application which outgrows the partition fails at the
+ \\ LINK, with a section-overflow naming the section, rather than at the flash write or
+ \\ (worse) at boot. Segments still come in the two the loader wants; they simply span
+ \\ more than one MMU page now, which the bootloader maps without complaint. */
+ \\ flash (rx) : ORIGIN = 0x40000020, LENGTH = 0x170000
+ \\
+ \\ /* .data/.bss/.stack. This was 0x20000 for as long as nothing needed more.
+ \\
+ \\ The die was asked (examples/memprobe.zig) rather than the datasheet, because the
+ \\ relevant ESP-IDF fragment (esp_system/ld/esp32p4/memory.ld.in:18-33) is parameterised
+ \\ on CONFIG_CACHE_L2_CACHE_SIZE, whose Kconfig help says the size is set "on application
+ \\ startup" - by an application this is not. Measured, on this rev v1.3 die:
+ \\
+ \\ 0x4FF03000..0x4FF3F000 240 KiB RAM
+ \\ 0x4FF3F000..0x4FF40000 4 KiB the mask ROM's .data/.bss, never written
+ \\ 0x4FF40000..0x4FFA0000 384 KiB RAM
+ \\ 0x4FFA0000..0x4FFC0000 128 KiB NOT memory - the L2 cache lives here
+ \\
+ \\ That last line cost a bug worth recording. The first version of the probe wrote a
+ \\ pattern and read it back one page at a time, and reported the whole upper 512 KiB as
+ \\ RAM - because a store followed immediately by a load of the SAME address returns the
+ \\ stored value whether the backing store is real, an address mirror, or merely a dirty
+ \\ cache line. Writing every page before reading any page separates the three, and the
+ \\ top 128 KiB then failed. It is the L2 cache, and ESP-IDF's own arithmetic agrees
+ \\ exactly: SRAM_HIGH_SIZE = 0x80000 - CONFIG_CACHE_L2_CACHE_SIZE, with the Kconfig
+ \\ default of 128 KiB (esp_system/port/soc/esp32p4/Kconfig.cache:5,19). Handing those
+ \\ 128 KiB to an allocator hung the heap on its first free-list walk.
+ \\
+ \\ This region stops at 0x4FF3F000 because `ets_printf` - the only console this image
+ \\ has - reads the ROM statics that begin at 0x4FF3FBA4, and losing them loses the
+ \\ ability to report having lost them. */
+ \\ l2mem (rw) : ORIGIN = 0x4FF00000, LENGTH = 0x3F000
+ \\
+ \\ /* The upper RAM, 384 KiB and contiguous, free once the second-stage bootloader has
+ \\ jumped away. Nothing is *linked* here: it carries no section, and the two symbols
+ \\ below hand it to the application as one span for a run-time heap. Kept out of `l2mem`
+ \\ because the ROM's statics sit between the two, and stops at 0x4FFA0000 because the
+ \\ L2 cache is above that - see the measurement in `l2mem`'s comment. */
+ \\ l2high (rw) : ORIGIN = 0x4FF40000, LENGTH = 0x60000
\\}}
\\
+ \\/* The heap span, from the linker rather than from a constant in Zig, so the region above is
+ \\ the single place these addresses are written down. */
+ \\__heap_start = ORIGIN(l2high);
+ \\__heap_end = ORIGIN(l2high) + LENGTH(l2high);
+ \\
\\SECTIONS {{
+ \\ /* Two flash-mapped segments, rodata then text, and the split is NOT optional: the
+ \\ second-stage bootloader asserts it. On a chip whose D and I external vaddr ranges are
+ \\ shared - which the P4's are (soc.h:146-149, both 0x40000000..0x44000000) - ESP-IDF
+ \\ takes the `SOC_MMU_DI_VADDR_SHARED` branch of `unpack_load_app`
+ \\ (bootloader_utility.c:805-851). That branch does not classify segments as D or I at
+ \\ all; it collects them positionally into rom_addr[2] and ends with
+ \\
+ \\ assert(rom_index == 2);
+ \\
+ \\ so one mapped segment aborts the boot with
+ \\ `Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)`, and a
+ \\ third trips `assert(rom_index < 2)` inside the loop. Measured, by trying it. */
\\ .flash.rodata : ALIGN(16) {{
\\ KEEP(*(.rodata.appdesc)) /* the loader reads esp_app_desc_t at image offset 0x20 */
\\ *(.rodata .rodata.* .srodata .srodata.*)
- \\ /* Leave exactly the 8 bytes the image builder needs for the next segment's header
- \\ before the 64-byte boundary that .flash.text starts on. ALIGN(64) alone does not
- \\ guarantee any hole: for one rodata length in eight the gap is 0 or 4 bytes and the
- \\ build dies with MappedSegmentsTooClose. */
- \\ . = ALIGN(. + 8, 64) - 8;
+ \\ /* Leave the 8 bytes the image builder needs for the next segment's header before the
+ \\ boundary .flash.text starts on. ALIGN alone does not guarantee any hole: for one
+ \\ rodata length in eight the gap is 0 or 4 bytes and the build dies with
+ \\ MappedSegmentsTooClose. */
+ \\ . = ALIGN(. + 8, 0x10000) - 8;
\\ }} > flash
\\
- \\ .flash.text : ALIGN(64) {{
+ \\ /* ALIGN(0x10000), one whole MMU page, and NOT the 64 bytes this used to be.
+ \\
+ \\ 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 reasoning holds right up until
+ \\ an application is large enough for rodata to END inside the page where text BEGINS.
+ \\ Measured on the die with a 578 KB image: 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.
+ \\
+ \\ Aligning text to a page boundary makes the segments page-disjoint, so no MMU entry is
+ \\ ever claimed by both. It costs up to 64 KiB of image padding, which against a 1.5 MiB
+ \\ partition is not worth reasoning about - the packing trick this project opened with
+ \\ only mattered when the alternative was 64 KiB of zeros in a 1 KB image. */
+ \\ .flash.text : ALIGN(0x10000) {{
\\ *(.text.entry)
\\ *(.text .text.*)
\\ }} > flash
@@ -1348,6 +1491,41 @@ const MonitorStep = struct {
}
};
+/// `monitor`, but the wire runs both ways. See tools/console.zig for the size handshake, which is
+/// the only part of this that is not a straight byte copy.
+///
+/// Takes the same `port_lock` as every other step that opens the port, so `zig build flash console`
+/// cannot have the console pull the board out of download mode mid-write. It then HOLDS that lock
+/// for the whole interactive session, which is correct and worth saying out loud: the session ends
+/// when the user detaches, so any other port step named on the same command line waits for the
+/// human rather than racing them.
+const ConsoleStep = struct {
+ step: std.Build.Step,
+ port: []const u8,
+ baud: serial.Baud,
+
+ fn create(b: *std.Build, port: []const u8, baud: serial.Baud) *ConsoleStep {
+ const self = b.allocator.create(ConsoleStep) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{ .id = .custom, .name = "console", .owner = b, .makeFn = make }),
+ .port = port,
+ .baud = baud,
+ };
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *ConsoleStep = @fieldParentPtr("step", step);
+ const b = step.owner;
+
+ port_lock.lockUncancelable(b.graph.io);
+ defer port_lock.unlock(b.graph.io);
+
+ console.attach(self.port, self.baud, .{}) catch |err|
+ return step.fail("console on {s}: {s}", .{ self.port, @errorName(err) });
+ }
+};
+
/// Pulse the reset line and leave. One ioctl pair, but it is the difference between "did my app
/// hang or did I forget to reset it" during development.
const ResetStep = struct {
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);
diff --git a/examples/heapcheck.zig b/examples/heapcheck.zig
new file mode 100644
index 0000000..9d61f71
--- /dev/null
+++ b/examples/heapcheck.zig
@@ -0,0 +1,240 @@
+//! Does `src/net/heap.zig` survive an editor's allocation pattern in 512 KiB?
+//!
+//! `Heap` was written for ESP-Hosted and sized by it: its own header says the workload it is
+//! "sized for is the one the parent measured - `mempool.c` recycling fixed-size buffers - which is
+//! exactly the pattern that keeps a coalescing free list short and its first fit O(1) in practice".
+//! An editor is not that workload. pardes allocates panes, text buffers, cell grids and per-frame
+//! scratch in a dozen different sizes and frees them in an order nobody chose. Two properties that
+//! were free under fixed-size recycling stop being free:
+//!
+//! * **First fit fragments.** Varied sizes leave gaps too small for the next request, and the
+//! symptom is not a clean failure - it is `largest_free` collapsing while `free` stays healthy,
+//! so the heap reports plenty of room and cannot satisfy a grid reallocation.
+//! * **First fit is O(n) in the free list.** One long-lived allocation in the middle of the arena
+//! splits it permanently, and every subsequent walk pays for it.
+//!
+//! So this measures both, on the die, before the editor depends on it. Each phase prints a line
+//! whether it passed or not: a number is the deliverable, not a verdict.
+//!
+//! The interesting column is `largest/free`. At 1.00 the free space is one block and the heap is
+//! pristine; as it falls, that fraction is the largest single allocation still possible. An editor
+//! that cannot get one contiguous cell grid is dead regardless of how many bytes are notionally
+//! free.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const heapmod = @import("heap");
+
+/// The upper RAM chunk, from the linker script - the same span the firmware gets.
+///
+/// Reached with `@extern`, NOT with `extern const __heap_start: anyopaque` plus
+/// `@intFromPtr`/`@ptrFromInt`. That spelling cost real debugging on this board. Declaring a linker
+/// symbol as an `anyopaque` OBJECT gives the optimiser a zero-sized object to reason about, so a
+/// pointer derived from its address carries provenance for zero bytes - and the ordinary
+/// (non-volatile) store `Heap.init` makes through it was simply dropped. The symptom was the block
+/// header reading back as `size=2988759312 next=0x14284684` instead of `{393216, 0xFFFFFFFF}`, after
+/// which the first free-list walk followed garbage and never terminated. With asserts compiled out
+/// in ReleaseSmall that is a silent hang, and `examples/memprobe.zig` could not see it because it
+/// writes through a `volatile` pointer, which the optimiser must not touch.
+///
+/// `@extern` with a `[*]u8` result 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" });
+
+var gpa_heap: heapmod.Heap = undefined;
+
+fn span() []align(heapmod.Heap.granule) u8 {
+ return heap_start[0 .. @intFromPtr(heap_end) - @intFromPtr(heap_start)];
+}
+
+fn report(tag: [*:0]const u8) void {
+ const s = gpa_heap.stats();
+ // Split across two calls, and no `%%`: the mask ROM's printf is size-optimised and this file's
+ // first version passed it six varargs plus a literal `%%`, which hung. Four is known to work
+ // (examples/memprobe.zig's range lines), so this stays inside what has been demonstrated.
+ soc.rom.print("MARK HEAP %s total=%u free=%u blocks=%u\r\n", .{
+ tag, s.total, s.free, s.free_blocks,
+ });
+ // `largest` is the number that matters under fragmentation: it is the largest single allocation
+ // still possible, whatever `free` claims.
+ soc.rom.print("MARK HEAP %s largest=%u\r\n", .{ tag, s.largest_free });
+}
+
+/// A deterministic LCG, so a bad run is reproducible. Numerical Recipes' constants.
+var rng_state: u32 = 0x1234_5678;
+fn rand() u32 {
+ rng_state = rng_state *% 1664525 +% 1013904223;
+ return rng_state;
+}
+
+/// How many live pointers the phases below track. 512 slots at an average of ~512 B is ~256 KiB,
+/// half the arena, which is enough to fragment it without trivially exhausting it.
+const slots = 512;
+var live: [slots][]u8 = undefined;
+var live_len: usize = 0;
+
+export fn zig_main() noreturn {
+ const s = span();
+ // The bootloader leaves the RTC watchdog running and expects the application to take it over.
+ // A bare image never did, so every demo in this repo has been resetting on a ten-second cycle
+ // (README.md:289-293) - invisible until a run lasted longer than eight seconds. The churn phase
+ // below does 20,000 allocations, so this is the first example here that would have hit it: the
+ // symptom was HEAP_BOOT printed twice and nothing after.
+ const was_armed = hal.rwdt.disable();
+ soc.rom.print("MARK HEAP_RWDT was_armed=%u now_armed=%u\r\n", .{
+ @as(u32, @intFromBool(was_armed)), @as(u32, @intFromBool(hal.rwdt.armed())),
+ });
+ soc.rom.print("\r\nMARK HEAP_BOOT 0x%08x..0x%08x %u KiB\r\n", .{
+ @as(u32, @intFromPtr(s.ptr)),
+ @as(u32, @intFromPtr(s.ptr)) + @as(u32, @intCast(s.len)),
+ @as(u32, @intCast(s.len / 1024)),
+ });
+ gpa_heap = heapmod.Heap.init(s);
+ soc.rom.print("MARK HEAP_INIT done\r\n", .{});
+ // Read the block header straight back through a volatile pointer. `stats()` walks the free list
+ // starting here, so if these two words are not {len, 0xFFFFFFFF} the walk follows garbage and
+ // never terminates - and with asserts compiled out in ReleaseSmall that is a silent hang.
+ {
+ const hdr: *volatile [2]u32 = @ptrFromInt(0x4FF4_0000);
+ soc.rom.print("MARK HEAP_HDR size=%u next=0x%08x\r\n", .{ hdr[0], hdr[1] });
+ }
+ const a = gpa_heap.allocator();
+ soc.rom.print("MARK HEAP_VTABLE done\r\n", .{});
+ const probe_stats = gpa_heap.stats();
+ soc.rom.print("MARK HEAP_STATS total=%u\r\n", .{probe_stats.total});
+ report("fresh");
+
+ // ---- phase 1: how much of the arena is actually reachable, and what does the header cost?
+ // Allocate one block at a time until refusal, to separate "512 KiB of RAM" from "512 KiB of
+ // usable allocations". Each block costs an 8-byte header, so the answer is strictly less.
+ {
+ var n: u32 = 0;
+ var total: usize = 0;
+ while (n < slots) {
+ const blk = a.alloc(u8, 512) catch break;
+ live[n] = blk;
+ total += blk.len;
+ n += 1;
+ }
+ live_len = n;
+ soc.rom.print("MARK HEAP_FILL %u blocks of 512 B = %u B payload\r\n", .{ n, @as(u32, @intCast(total)) });
+ report("filled");
+ for (live[0..live_len]) |blk| a.free(blk);
+ live_len = 0;
+ // Coalescing is the whole design: after freeing everything the arena must be ONE block
+ // again. If it is not, `insert`'s merge is wrong and every later number is meaningless.
+ report("emptied");
+ }
+
+ // ---- phase 2: the fragmentation case. Fill with alternating sizes, free every other block.
+ // This is the adversarial pattern for first fit: the holes are all the smaller size, and the
+ // next larger request has to walk past every one of them.
+ {
+ var n: usize = 0;
+ while (n < slots) : (n += 1) {
+ const size: usize = if (n % 2 == 0) 192 else 320;
+ live[n] = a.alloc(u8, size) catch break;
+ }
+ live_len = n;
+ var i: usize = 0;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ report("holed");
+
+ // Now ask for something that fits in no single hole and see what the heap does.
+ if (a.alloc(u8, 4096)) |big| {
+ soc.rom.print("MARK HEAP_BIG 4096 B satisfied after holing\r\n", .{});
+ a.free(big);
+ } else |_| {
+ soc.rom.print("MARK HEAP_BIG 4096 B REFUSED after holing\r\n", .{});
+ }
+ i = 1;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ live_len = 0;
+ report("unholed");
+ }
+
+ // ---- phase 3: the O(n) first-fit walk, measured rather than argued.
+ // Build a long free list, then time one allocation that has to traverse it. `soc.cycles()` is
+ // the cycle counter, so this is in real CPU cycles.
+ {
+ var n: usize = 0;
+ while (n < slots) : (n += 1) live[n] = a.alloc(u8, 256) catch break;
+ live_len = n;
+ // Free every other block to make `free_blocks` large, then measure a request that no hole
+ // can satisfy, which is the worst case: the full walk.
+ var i: usize = 0;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ const before = gpa_heap.stats().free_blocks;
+
+ const t0 = soc.cycles();
+ const probe = a.alloc(u8, 1024) catch null;
+ const cycles = soc.cycles() - t0;
+ soc.rom.print("MARK HEAP_WALK %u free blocks, alloc took %u cycles\r\n", .{ before, @as(u32, @intCast(cycles)) });
+ if (probe) |p| a.free(p);
+
+ i = 1;
+ while (i < live_len) : (i += 2) a.free(live[i]);
+ live_len = 0;
+ report("after-walk");
+ }
+
+ // ---- phase 4: a long random churn, which is the closest thing here to a real session.
+ // Random sizes, random free order, and a running count of refusals. A heap that fragments
+ // itself to death shows up as refusals climbing while `free` stays large.
+ {
+ var refusals: u32 = 0;
+ var ops: u32 = 0;
+ live_len = 0;
+ while (ops < 20000) : (ops += 1) {
+ const keep = live_len < slots and (live_len < 64 or rand() % 100 < 55);
+ if (keep) {
+ // 24 B to ~6 KiB: the spread pardes actually shows, from a small string to a
+ // reallocated row buffer.
+ const size = 24 + (rand() % 6000);
+ if (a.alloc(u8, size)) |blk| {
+ live[live_len] = blk;
+ live_len += 1;
+ } else |_| refusals += 1;
+ } else if (live_len > 0) {
+ const victim = rand() % @as(u32, @intCast(live_len));
+ a.free(live[victim]);
+ live[victim] = live[live_len - 1];
+ live_len -= 1;
+ }
+ }
+ soc.rom.print("MARK HEAP_CHURN %u ops, %u live, %u refusals\r\n", .{ ops, @as(u32, @intCast(live_len)), refusals });
+ report("churned");
+ for (live[0..live_len]) |blk| a.free(blk);
+ live_len = 0;
+ report("drained");
+ }
+
+ soc.rom.print("MARK HEAP_DONE\r\n", .{});
+ while (true) {}
+}
+
+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 HEAP_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
diff --git a/examples/memprobe.zig b/examples/memprobe.zig
new file mode 100644
index 0000000..1ce8a0b
--- /dev/null
+++ b/examples/memprobe.zig
@@ -0,0 +1,215 @@
+//! What RAM does this board actually have, and where?
+//!
+//! The linker script maps one 128 KiB window at 0x4FF00000 and has never needed more. Hosting a
+//! real application needs an answer with more than one digit in it, and the answer cannot be read
+//! off ESP-IDF's linker fragments, because the fragment that matters
+//! (`esp_system/ld/esp32p4/memory.ld.in:18-33`) is parameterised on two things this image does not
+//! have:
+//!
+//! * `CONFIG_ESP32P4_SELECTS_REV_LESS_V3` - true for this rev v1.3 die, which selects a SPLIT
+//! layout: a low region 0x4FF00000..0x4FF2BBD0 and a high region from 0x4FF40000, with the
+//! mask ROM's own .data/.bss in between at 0x4FF3FBA4..0x4FF40000.
+//! * `CONFIG_CACHE_L2_CACHE_SIZE` - the L2 cache is carved out of the SAME 768 KiB array, from
+//! the TOP, so `SRAM_HIGH_SIZE = 0x80000 - cache_size`. Its Kconfig default is 128 KiB, but the
+//! help text says "to be set on application startup" - the APPLICATION sets it, and this
+//! application does not. So the live size is whatever the ROM and the second-stage bootloader
+//! left behind, which is exactly the sort of thing that has to be measured.
+//!
+//! So: probe. For every 4 KiB page in the array, save the first word, write a value derived from
+//! the page's own address, read it back, and restore. An address-derived pattern is the point - a
+//! constant cannot distinguish real memory from an alias, and aliasing is the specific failure mode
+//! of poking at a region the cache controller owns. A page that reads back what it was given is
+//! RAM; anything else is reported with what it actually returned.
+//!
+//! Two pages are never touched: the one holding this image's own .data/.bss/stack, and the mask
+//! ROM's reserved window - `soc.rom.print` is the only way this program can report anything, and
+//! corrupting the ROM's statics would take the console down with it.
+//!
+//! A page that is neither RAM nor decoded may raise a bus fault, and this image has no trap
+//! handler, so a fault is a silent hang. That is why the scan prints its cursor as it goes: if this
+//! stops, the last address printed is the one that killed it, which is itself the result.
+
+const std = @import("std");
+const soc = @import("soc");
+
+/// The whole L2MEM array, per `soc/esp32p4/include/soc/soc.h:161-164`
+/// (SOC_DRAM_LOW 0x4ff00000, SOC_DRAM_HIGH 0x4ffc0000).
+const l2mem_low: u32 = 0x4FF0_0000;
+const l2mem_high: u32 = 0x4FFC_0000;
+
+/// The mask ROM's .data/.bss, from `bootloader.memory.ld.in:13-16`. Not reclaimable while anything
+/// still calls into the ROM, and `soc.rom.print` does.
+const rom_data_low: u32 = 0x4FF3_FBA4;
+const rom_data_high: u32 = 0x4FF4_0000;
+
+/// Where PSRAM appears once a driver has trained it (`soc.h:151-153`). Nothing here trains it, so
+/// this is expected to fail; it is probed anyway because the cost is four instructions and the
+/// alternative is assuming.
+const psram_base: u32 = 0x4800_0000;
+
+const page: u32 = 0x1000;
+
+/// This image's own footprint, from the linker script's symbols. `.data` starts at the region base
+/// and `__stack_top` is the last thing in it, so [l2mem_low, __stack_top) is off limits.
+extern const __stack_top: anyopaque;
+
+fn selfEnd() u32 {
+ return @intFromPtr(&__stack_top);
+}
+
+/// The heap span the linker script hands over (`build.zig`'s MEMORY block defines both from
+/// `l2high`). Referenced here so the report states the same numbers the linker will give the real
+/// application, rather than a second copy of them written down in Zig.
+extern const __heap_start: anyopaque;
+extern const __heap_end: anyopaque;
+
+/// The value page `addr` must return if it is real, distinct memory.
+inline fn pattern(addr: u32) u32 {
+ // Not `addr` itself: an address bus stuck high would return something that looks plausible.
+ // XOR with a constant that has bits set where an address never does.
+ return addr ^ 0xA5A5_0F0F;
+}
+
+/// One saved word per page, so the array can be written whole and read back whole.
+const max_pages = (l2mem_high - l2mem_low) / page;
+var saved: [max_pages]u32 = @splat(0);
+
+/// Is this page one the program refuses to touch?
+fn skipped(addr: u32) bool {
+ return (addr < selfEnd()) or (addr + page > rom_data_low and addr < rom_data_high);
+}
+
+/// WHY THIS IS TWO PASSES, and not a save/write/read/restore per page.
+///
+/// The per-page version is what this file did first, and it cannot distinguish the three things it
+/// most needs to: a store immediately followed by a load of the SAME address returns the stored
+/// value under real distinct SRAM, under an address mirror, and under a dirty line in any cache
+/// covering L2MEM. The pattern being address-derived does not help, because the alias is written and
+/// read through the alias. Cache residency is not excluded by scan length either: 192 pages touch
+/// one cache line each, ~12 KiB in total, which fits in any plausible L1 and so is never evicted.
+///
+/// Writing every page before reading any page fixes both. If 0x4FF80000 mirrors 0x4FF00000, the
+/// later write lands on the earlier page and the read pass sees the WRONG pattern at one of them.
+/// The distance between the two passes is 192 pages of traffic, which no L1 holds.
+///
+/// This matters more than a tidier loop: `__heap_end` hands the upper span straight to an allocator,
+/// so a mirror reported as RAM is silent heap corruption.
+fn writePass() void {
+ var addr = l2mem_low;
+ while (addr < l2mem_high) : (addr += page) {
+ if (skipped(addr)) continue;
+ const p: *volatile u32 = @ptrFromInt(addr);
+ saved[(addr - l2mem_low) / page] = p.*;
+ p.* = pattern(addr);
+ }
+}
+
+/// Read every page back, then put the original word back. Restoring in the same pass is safe: the
+/// comparison for this page is already done, and a mirror has by now already been detected at
+/// whichever of the two aliases was read second.
+fn readPass(addr: u32) Kind {
+ if (skipped(addr)) return .skipped;
+ const p: *volatile u32 = @ptrFromInt(addr);
+ const got = p.*;
+ p.* = saved[(addr - l2mem_low) / page];
+ return if (got == pattern(addr)) .ram else .dead;
+}
+
+/// What a page turned out to be. Three outcomes, not two: a page this program refuses to write is
+/// neither RAM nor dead, and folding "skipped" into "dead" is what made the first run of this
+/// report `dead 0x00000000..0x4ff02000`, a range that does not exist.
+const Kind = enum {
+ ram,
+ dead,
+ skipped,
+
+ fn label(k: Kind) [*:0]const u8 {
+ return switch (k) {
+ .ram => "ram",
+ .dead => "dead",
+ .skipped => "skipped",
+ };
+ }
+};
+
+/// Report a maximal run of pages that all behaved the same way.
+fn flush(kind: Kind, start: u32, end: u32) void {
+ if (end <= start) return;
+ soc.rom.print("MARK MEM_RANGE %s 0x%08x..0x%08x %u KiB\r\n", .{
+ kind.label(), start, end, (end - start) / 1024,
+ });
+}
+
+export fn zig_main() noreturn {
+ soc.rom.print("\r\nMARK MEM_BOOT probing L2MEM 0x%08x..0x%08x\r\n", .{ l2mem_low, l2mem_high });
+ soc.rom.print("MARK MEM_SELF image occupies 0x%08x..0x%08x\r\n", .{ l2mem_low, selfEnd() });
+ soc.rom.print("MARK MEM_ROMRSV rom .data 0x%08x..0x%08x (never written)\r\n", .{ rom_data_low, rom_data_high });
+ soc.rom.print("MARK MEM_HEAP linker gives 0x%08x..0x%08x %u KiB\r\n", .{
+ @as(u32, @intFromPtr(&__heap_start)),
+ @as(u32, @intFromPtr(&__heap_end)),
+ (@as(u32, @intFromPtr(&__heap_end)) - @as(u32, @intFromPtr(&__heap_start))) / 1024,
+ });
+
+ // Write every page first, read every page second. See `writePass` for why one pass cannot
+ // answer this question at all.
+ soc.rom.print("MARK MEM_PASS write\r\n", .{});
+ writePass();
+ soc.rom.print("MARK MEM_PASS read\r\n", .{});
+
+ // Runs are coalesced so the output is a map rather than 192 lines. Every page belongs to
+ // exactly one run, and every run is printed, so the ranges tile the array with no gaps - which
+ // is the property that makes the report checkable.
+ var run: Kind = .skipped;
+ var run_start: u32 = l2mem_low;
+ var addr: u32 = l2mem_low;
+ while (addr < l2mem_high) : (addr += page) {
+ const kind = readPass(addr);
+ if (kind != run) {
+ flush(run, run_start, addr);
+ run = kind;
+ run_start = addr;
+ }
+ }
+ flush(run, run_start, l2mem_high);
+
+ // PSRAM, untrained. The question here is only "does the bus answer at all", not "is it
+ // distinct", so a single write-read-restore is the right shape - and it is expected to fault.
+ // The line is printed BEFORE the access so a hang is unambiguous.
+ soc.rom.print("MARK MEM_PSRAM probing 0x%08x (untrained, may hang)\r\n", .{psram_base});
+ const pp: *volatile u32 = @ptrFromInt(psram_base);
+ const ps_saved = pp.*;
+ pp.* = pattern(psram_base);
+ const ps = pp.*;
+ pp.* = ps_saved;
+ soc.rom.print("MARK MEM_PSRAM read 0x%08x expect 0x%08x %s\r\n", .{
+ ps, pattern(psram_base), if (ps == pattern(psram_base)) "answers".ptr else "absent".ptr,
+ });
+
+ soc.rom.print("MARK MEM_DONE\r\n", .{});
+ while (true) {}
+}
+
+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 MEM_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
diff --git a/examples/minimal.zig b/examples/minimal.zig
index a70f0ef..bf93de8 100644
--- a/examples/minimal.zig
+++ b/examples/minimal.zig
@@ -15,7 +15,7 @@ pub const panic = std.debug.FullPanic(struct {
const led: u6 = @intCast(config.led_pin);
export fn zig_main() noreturn {
- soc.gpio.configureOutput(led);
+ soc.gpio.configureOutput(led, .{});
while (true) {
soc.gpio.setHigh(led);
soc.rom.ets_delay_us(100_000);
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());
+}
diff --git a/tools/console.zig b/tools/console.zig
new file mode 100644
index 0000000..2e924e3
--- /dev/null
+++ b/tools/console.zig
@@ -0,0 +1,239 @@
+//! An interactive terminal on the far side of the serial port.
+//!
+//! `MonitorStep` prints the board's output for N seconds and never sends anything. That is the
+//! right tool for a program that only reports. It is the wrong tool for a program the human is
+//! supposed to *use*, which needs the host's keystrokes on the wire and the host's terminal out of
+//! the way. So this is the other half: raw-mode stdin forwarded to the UART, UART forwarded to
+//! stdout, until the escape byte.
+//!
+//! The division of labour is the interesting part. The board runs the application and emits ANSI;
+//! the host terminal emulator (ghostty here) does the actual terminal work - fonts, scrollback,
+//! selection - and answers the application's capability queries itself. This process is a wire, and
+//! deliberately almost transparent: a `\x1b[?1049h` from the board reaches ghostty, ghostty's reply
+//! to a `CSI c` reaches the board, and neither end needs to know there are 3 metres of USB cable
+//! and a CH340 in between.
+//!
+//! Almost transparent, because of one thing a wire cannot pass through: **size**. A terminal
+//! application learns its window size from `ioctl(TIOCGWINSZ)`, and firmware has no ioctl. DEC mode
+//! 2048 ("in-band resize") solves half of it - a terminal that has been sent `\x1b[?2048h` reports
+//! every subsequent resize as `CSI 48 ; rows ; cols ; ypix ; xpix t`, and those reports flow down
+//! the wire like any other bytes. The half it does not solve is the *first* size, because a change
+//! notification is only sent on a change. This process owns the real tty, so it is the only party
+//! that can answer, and it injects that same sequence itself:
+//!
+//! * once at attach, for a board that never asks;
+//! * on SIGWINCH, because the host's window changed and mode 2048 may not be enabled;
+//! * and on seeing `\x1b[?2048h` come *back* from the board - which is the exact moment the
+//! application has declared itself ready to understand one. That is the deterministic trigger;
+//! the other two are belt and braces.
+//!
+//! Injecting a resize report the host terminal would also have sent is harmless: it carries the
+//! size as data, so a duplicate is idempotent, and `vaxis`'s parser (`src/Parser.zig:528-551`)
+//! treats both identically because they are byte-for-byte the same sequence.
+
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const serial = @import("serial.zig");
+
+/// Ctrl-] , telnet's escape and not a key any full-screen application binds. Ctrl-C, Ctrl-Q and
+/// Ctrl-Z all had to be rejected: an editor wants every one of them, and a bridge that swallowed
+/// them would be lying about being transparent.
+pub const escape_byte: u8 = 0x1d;
+
+/// Set by the SIGWINCH handler, read by the loop. `volatile` rather than atomic because a signal
+/// handler on the same thread is not a concurrent writer - it is an interruption - and this only
+/// needs the compiler to stop caching the load.
+var winch_pending: bool = false;
+
+/// The handler takes `posix.SIG`, not an int: std's `Sigaction.handler_fn` is
+/// `*align(1) const fn (SIG) callconv(.c) void` (std/os/linux.zig:6026).
+fn onWinch(_: posix.SIG) callconv(.c) void {
+ @as(*volatile bool, &winch_pending).* = true;
+}
+
+const Winsize = extern struct { row: u16, col: u16, xpixel: u16, ypixel: u16 };
+const TIOCGWINSZ = 0x5413;
+
+fn windowSize() Winsize {
+ var ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
+ // A failure here is not fatal: 80x24 is a defensible terminal, and the alternative is refusing
+ // to attach because the size could not be read.
+ _ = linux.ioctl(0, TIOCGWINSZ, @intFromPtr(&ws));
+ if (ws.row == 0) ws.row = 24;
+ if (ws.col == 0) ws.col = 80;
+ return ws;
+}
+
+/// The in-band resize report, in the form `vaxis` parses: `CSI 48 ; rows ; cols ; ypix ; xpix t`.
+/// Note the pixel fields are height-then-width, which is the opposite order from the
+/// `struct winsize` they come out of - `Parser.zig:536-539` reads height first.
+fn sendWinsize(port: *serial.Port, ws: Winsize) void {
+ var buf: [64]u8 = undefined;
+ const seq = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};{d};{d}t", .{
+ ws.row, ws.col, ws.ypixel, ws.xpixel,
+ }) catch return;
+ port.write(seq) catch {};
+}
+
+/// Matches `\x1b[?2048h` in the board's output stream one byte at a time, because the sequence can
+/// be split across reads. Returns true on the byte that completes it.
+const ModeWatch = struct {
+ const want = "\x1b[?2048h";
+ at: usize = 0,
+
+ fn feed(m: *ModeWatch, byte: u8) bool {
+ if (byte == want[m.at]) {
+ m.at += 1;
+ if (m.at == want.len) {
+ m.at = 0;
+ return true;
+ }
+ } else {
+ // Restart, and allow this byte to be a fresh start - otherwise "\x1b\x1b[?2048h" is
+ // missed.
+ m.at = if (byte == want[0]) 1 else 0;
+ }
+ return false;
+ }
+};
+
+pub const Options = struct {
+ /// Pulse reset so the application starts from boot with the console already attached. Without
+ /// it, attaching to a board that has been running for a while shows a screen mid-session with
+ /// no redraw until something changes.
+ reset: bool = true,
+ /// Print the escape-key hint. Suppressed for scripted runs, whose output is being asserted on.
+ banner: bool = true,
+};
+
+/// Forward bytes both ways until the escape byte arrives on stdin.
+///
+/// Returns normally on escape; the terminal is always restored, including on error, because the
+/// alternative is handing the user back a shell with no echo.
+pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void {
+ var port = try serial.Port.open(port_path, baud);
+ defer port.close();
+
+ // No `isatty`: `tcgetattr` answers the same question with the same syscall this needs anyway,
+ // and a null here means "stdin is a pipe" - which is a supported way to run this, for scripted
+ // sessions whose input is a file.
+ const saved: ?posix.termios = posix.tcgetattr(0) catch null;
+ if (saved) |prev| {
+ var raw = prev;
+ // The same raw mode `serial.Port.open` builds for the port, for the same reason: every byte
+ // the user types has to reach the board unmodified, including the ones the line discipline
+ // would otherwise interpret. ISIG off is what lets Ctrl-C reach the application instead of
+ // killing this process.
+ raw.lflag.ICANON = false;
+ raw.lflag.ECHO = false;
+ raw.lflag.ISIG = false;
+ raw.lflag.IEXTEN = false;
+ raw.iflag.IXON = false;
+ raw.iflag.ICRNL = false;
+ raw.iflag.INLCR = false;
+ raw.iflag.BRKINT = false;
+ raw.oflag.OPOST = false;
+ try posix.tcsetattr(0, .FLUSH, raw);
+ }
+ defer if (saved) |prev| posix.tcsetattr(0, .FLUSH, prev) catch {};
+
+ // Installed after raw mode so a resize during setup cannot be missed-but-flagged.
+ posix.sigaction(posix.SIG.WINCH, &.{
+ .handler = .{ .handler = onWinch },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ }, null);
+
+ // stdin/stdout as `std.Io.File`, because std 0.16 has no `posix.read`/`posix.write` any more -
+ // byte traffic goes through std.Io. The `io` is borrowed from the port, which already holds the
+ // single-threaded instance `serial.Port.open` created.
+ const io = port.io;
+ const stdin: std.Io.File = .{ .handle = 0, .flags = .{ .nonblocking = false } };
+ const stdout: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };
+
+ if (opts.banner) {
+ var hint: [96]u8 = undefined;
+ const line = std.fmt.bufPrint(&hint, "[zig-p4 console @ {d} baud - Ctrl-] to detach]\r\n", .{
+ baud.rate(),
+ }) catch "[zig-p4 console - Ctrl-] to detach]\r\n";
+ stdout.writeStreamingAll(io, line) catch {};
+ }
+
+ if (opts.reset) try port.resetToRun(.{});
+ sendWinsize(&port, windowSize());
+
+ var watch: ModeWatch = .{};
+ var from_board: [4096]u8 = undefined;
+ var from_user: [256]u8 = undefined;
+
+ while (true) {
+ if (@as(*volatile bool, &winch_pending).*) {
+ @as(*volatile bool, &winch_pending).* = false;
+ sendWinsize(&port, windowSize());
+ }
+
+ var pfd = [_]posix.pollfd{
+ .{ .fd = 0, .events = posix.POLL.IN, .revents = 0 },
+ .{ .fd = port.file.handle, .events = posix.POLL.IN, .revents = 0 },
+ };
+ // A bounded wait rather than an infinite one so a SIGWINCH that lands between the check
+ // above and the poll below is still serviced promptly; poll reports the signal itself as
+ // an interrupt, which is handled as "go round again".
+ const ready = posix.poll(&pfd, 200) catch continue;
+ if (ready == 0) continue;
+
+ // POLL.IN is not the only thing poll reports, and ignoring the rest is a hot spin, not a
+ // no-op: unplug the CH340 mid-session and the port's revents carries HUP|ERR|NVAL forever.
+ // poll then returns immediately with a non-zero count, neither branch below matches because
+ // both test POLL.IN, and the loop burns a core with stdin still in raw mode and ISIG off.
+ // `std.posix.poll` cannot surface it as an error either - it maps INVAL to `unreachable`
+ // (std/posix.zig:1007-1017) because a dead descriptor is reported in `revents`, not errno.
+ const gone = posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL;
+ if (pfd[1].revents & gone != 0) return error.PortDisconnected;
+ // stdin dying is ordinary: a pipe ran out, or the terminal closed. Detach quietly.
+ if (pfd[0].revents & gone != 0) return;
+
+ if (pfd[1].revents & posix.POLL.IN != 0) {
+ const n = port.read(&from_board) catch 0;
+ if (n > 0) {
+ stdout.writeStreamingAll(io, from_board[0..n]) catch {};
+ for (from_board[0..n]) |b| {
+ if (watch.feed(b)) sendWinsize(&port, windowSize());
+ }
+ }
+ }
+
+ if (pfd[0].revents & posix.POLL.IN != 0) {
+ const n = stdin.readStreaming(io, &.{&from_user}) catch 0;
+ if (n == 0) return; // stdin closed: a pipe ran out, so detach
+ if (std.mem.indexOfScalar(u8, from_user[0..n], escape_byte)) |cut| {
+ // Everything before the escape still belongs to the board.
+ if (cut > 0) port.write(from_user[0..cut]) catch {};
+ if (opts.banner) stdout.writeStreamingAll(io, "\r\n[detached]\r\n") catch {};
+ return;
+ }
+ port.write(from_user[0..n]) catch {};
+ }
+ }
+}
+
+test "ModeWatch completes only on the full sequence" {
+ var m: ModeWatch = .{};
+ for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
+ try std.testing.expect(m.feed('h'));
+}
+
+test "ModeWatch resynchronises on a false start" {
+ var m: ModeWatch = .{};
+ // A prefix that dies, then the real thing immediately after: the naive reset-to-zero misses
+ // this because the byte that broke the match is itself the next match's ESC.
+ for ("\x1b[?20") |b| try std.testing.expect(!m.feed(b));
+ for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
+ try std.testing.expect(m.feed('h'));
+}
+
+test "ModeWatch ignores unrelated traffic" {
+ var m: ModeWatch = .{};
+ for ("hello \x1b[?1049h world \x1b[0m") |b| try std.testing.expect(!m.feed(b));
+}
diff --git a/tools/image.zig b/tools/image.zig
index dea843f..ba60064 100644
--- a/tools/image.zig
+++ b/tools/image.zig
@@ -13,7 +13,10 @@
//! ships as a 1.4 KB image instead of a 66 KB one. Verified on ESP32-P4 rev v1.3 silicon.
//!
//! Rules the loader enforces, each learned by flashing a deliberately broken image at the board:
-//! * exactly two segments must land in the mapped range (bootloader_utility.c:842)
+//! * exactly two segments must land in the mapped range: on a chip with shared D/I external
+//! vaddr (the P4, soc.h:146-149) the loader collects them positionally and asserts
+//! rom_index == 2 (bootloader_utility.c:805-851); one segment aborts the boot and a third
+//! trips an assert inside the loop (bootloader_utility.c:842)
//! * every segment length must be a multiple of 4 (esp_image_format.c:857)
//! * image offset 0x20 begins an esp_app_desc_t, and min/max_efuse_blk_rev_full are read from
//! it whether or not it is really a descriptor (esp_image_format.c:796-806)
@@ -116,6 +119,14 @@ pub const Layout = struct {
}
off += seg_header_len + s.len;
}
+ // EXACTLY two, and the bootloader is what says so. On a chip whose D/I external vaddr ranges
+ // are shared - the P4's are (soc.h:146-149) - ESP-IDF takes the SOC_MMU_DI_VADDR_SHARED
+ // branch of `unpack_load_app` (bootloader_utility.c:805-851), which does not classify
+ // segments as D or I at all: it collects them positionally into rom_addr[2] and ends with
+ // `assert(rom_index == 2)`. One mapped segment aborts the boot with
+ // "Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)" - measured,
+ // by shipping one - and a third trips `assert(rom_index < 2)` inside the loop. Enforcing it
+ // here turns a boot-time abort into a build-time error.
if (mapped != 2) return error.NotTwoMappedSegments;
// One MMU entry per vaddr page: any two mapped segments in the same vaddr page must come
diff --git a/tools/image_test.zig b/tools/image_test.zig
index 9f2cbc0..c13adf7 100644
--- a/tools/image_test.zig
+++ b/tools/image_test.zig
@@ -120,6 +120,8 @@ test "an image with one mapped segment is rejected before it can brick a board"
var layout = try image.fromElf(gpa, elf, .{});
defer layout.deinit(gpa);
+ // Not a guess: shipping a one-segment image aborted the boot with
+ // "Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)".
try testing.expectError(error.NotTwoMappedSegments, layout.validate(.{}));
}
@@ -275,6 +277,7 @@ fn checkBytes(bytes: []const u8, flash_offset: u32) !void {
}
off += 8 + len;
}
+ // Exactly two: the SOC_MMU_DI_VADDR_SHARED branch asserts rom_index == 2.
try testing.expectEqual(@as(usize, 2), mapped); // bootloader_utility.c:842
// Two mapped segments sharing a vaddr page must share the flash page: one MMU entry each.
diff --git a/tools/serial.zig b/tools/serial.zig
index c13942c..da225fd 100644
--- a/tools/serial.zig
+++ b/tools/serial.zig
@@ -43,6 +43,10 @@ const Termios2 = extern struct {
/// _IOR('T', 0x2A, struct termios2) and _IOW('T', 0x2B, struct termios2) for a 44-byte struct.
const TCGETS2: u32 = 0x802C542A;
const TCSETS2: u32 = 0x402C542B;
+ /// _IOW('T', 0x2C, ...): TCSETS2's draining sibling. Changing the divisor while bytes are
+ /// still in the kernel's output queue sends the tail of the old line at the new rate, which on
+ /// a mid-session baud switch corrupts exactly the handshake line the switch was announced by.
+ const TCSETSW2: u32 = 0x402C542C;
/// CBAUD escape meaning "take the rate from ispeed/ospeed" (asm-generic/termbits.h).
const BOTHER: u32 = 0o010000;
@@ -110,6 +114,26 @@ pub const Port = struct {
return .{ .file = file, .io = io, .saved = saved };
}
+ /// Re-rate an already-open port, leaving the raw-mode flags and the exclusive claim alone.
+ ///
+ /// This exists because the console and the flasher want different rates on the same wire. The
+ /// ROM loader auto-detects the host's rate from SYNC's 0x55 pattern, so `open` can simply pick
+ /// one; a running application cannot, because its UART divider was programmed by the
+ /// second-stage bootloader and the only way to change it is for the firmware to reprogram its
+ /// own divider and for the host to follow. That makes the switch a two-sided handshake, and
+ /// `TCSETSW2` is the half that has to be ordered: the firmware announces the change on the old
+ /// rate, and the announcement must have physically left the wire before either side moves.
+ pub fn setBaud(p: *Port, baud: Baud) !void {
+ var t: Termios2 = undefined;
+ if (@as(isize, @bitCast(linux.ioctl(p.file.handle, Termios2.TCGETS2, @intFromPtr(&t)))) < 0)
+ return error.NotATerminal;
+ t.cflag = (t.cflag & ~Termios2.CBAUD) | Termios2.BOTHER;
+ t.ispeed = baud.rate();
+ t.ospeed = baud.rate();
+ if (@as(isize, @bitCast(linux.ioctl(p.file.handle, Termios2.TCSETSW2, @intFromPtr(&t)))) < 0)
+ return error.SetAttrFailed;
+ }
+
pub fn close(p: *Port) void {
_ = linux.ioctl(p.file.handle, Termios2.TCSETS2, @intFromPtr(&p.saved));
p.file.close(p.io);