diff options
Diffstat (limited to 'build.zig')
| -rw-r--r-- | build.zig | 208 |
1 files changed, 193 insertions, 15 deletions
@@ -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 { |
