summaryrefslogtreecommitdiff
path: root/README.md
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 /README.md
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.
Diffstat (limited to 'README.md')
-rw-r--r--README.md77
1 files changed, 74 insertions, 3 deletions
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