//! A complete ESP32-P4 toolchain in one build graph. //! //! 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 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 //! the link, and the image builder and flasher are ordinary Zig code (tools/) imported straight //! into this file, so they produce no artefacts of their own. What lands in zig-out is the ELF and //! the image, and nothing else. const std = @import("std"); const image = @import("tools/image.zig"); const serial = @import("tools/serial.zig"); const rom = @import("tools/rom.zig"); pub fn build(b: *std.Build) void { // ---------------------------------------------------------------- board and target knobs const port_path = b.option([]const u8, "port", "serial port (default /dev/ttyUSB0)") orelse "/dev/ttyUSB0"; const baud = b.option(serial.Baud, "baud", "flashing baud rate (default 921600, measured reliable on this board; 2000000 is not)") orelse .b921600; const led_pin = b.option(u8, "led", "GPIO to blink, 0-31 (default 20 = JP1 pin 17)") orelse 20; if (led_pin > 31) { std.log.err( "-Dled={d}: src/soc.zig models GPIO0-31; GPIO32-56 need the OUT1/ENABLE1/IN1 bank", .{led_pin}, ); std.process.exit(1); } const app_offset = b.option(u32, "offset", "flash offset of the app partition (default 0x10000)") orelse 0x10000; const flash_size = b.option(image.FlashSize, "flash-size", "fitted flash (default 16MB)") orelse .@"16MB"; 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; // 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. const optimize = b.option(std.builtin.OptimizeMode, "optimize", "optimize mode (default ReleaseSmall)") orelse .ReleaseSmall; const target = b.resolveTargetQuery(.{ .cpu_arch = .riscv32, .os_tag = .freestanding, .abi = .none, // rv32imafc with the CSR/fence extensions the ESP32-P4 implements. Espressif's own GCC // adds the vendor extensions xesploop and xespv2p1 on top; upstream LLVM has neither, and // ordinary code never emits them, so this matches the base ISA exactly. .cpu_model = .{ .explicit = &std.Target.riscv.cpu.generic_rv32 }, .cpu_features_add = featureSet(&.{ .m, .a, .f, .c, .zicsr, .zifencei }), }); // ---------------------------------------------------------------- the chip's registers // Every peripheral register of the P4, taken from ESP-IDF's own `*_reg.h` headers by // `zig translate-c`. There is no generator and no checked-in generated file: the C front end // does the work, so the addresses, shifts and masks in Zig are not a re-derivation of IDF's // numbers, they *are* IDF's numbers. 94 headers -> 86,253 constants in about 0.26 s, and // importing the module costs ~0.16 s because Zig only analyses the handful of decls used. // // `*_struct.h` is deliberately not used: translate-c demotes every one of those register // structs to `opaque {}` ("has bitfield"), so the C bitfields buy nothing. src/mmio.zig builds // the typed layer on top of the flat constants instead. const registers = idfRegisters(b, target, optimize); const regs_mod = registers.mod; // ---------------------------------------------------------------- generated linker script // The layout is a build input, not a checked-in file: change -Dstack or the descriptor size // and the script follows. Both flash-mapped sections sit in one 64 KiB MMU window, which is // what keeps the image ~1 KB instead of ~66 KB (see tools/image.zig). const ld = b.addWriteFiles(); // The oracle links ESP-IDF's own LL functions in beside ours as the differential reference. // Off by default: it is a test rig, it needs an IDF checkout with the C headers, and it has no // business in a shipping image. const oracle = b.option(bool, "oracle", "link ESP-IDF's LL functions in as the differential reference") orelse false; // `-Dhosted` links ESP-Hosted's transport C into the image, on top of this project's Zig // runtime and SDIO driver. The P4 has no radio: the ESP32-C6 beside it does, and it speaks a // protocol whose host half is 13,000 lines of already-debugged C. Reimplementing that before // anything can reach the network would be the wrong order, so it is linked in and replaced from // the bottom up - exactly as -Doracle links IDF's LL beside the HAL. // // Off by default: it needs an IDF checkout, and a managed_components tree from the sibling // 02-esp32p4-m3-radio project. const hosted = b.option(bool, "hosted", "link ESP-Hosted's SDIO transport C in, for the radio path") orelse false; // Wi-Fi credentials arrive as build options, never as source. This keeps the passphrase out of // the tree and out of git. It does end up in the image - unavoidable for a device that has to // join a network - but nothing here writes it to a file or prints it. const wifi_ssid = b.option([]const u8, "ssid", "Wi-Fi SSID to join (hosted builds)") orelse ""; const wifi_psk_opt = b.option([]const u8, "psk", "Wi-Fi passphrase; prefer -Dpsk-file") orelse ""; // A file is the better route: the passphrase never appears in a command line, so it stays out of // the shell history and out of the process table where `ps` can see it. const psk_file = b.option([]const u8, "psk-file", "read the passphrase from this file instead of -Dpsk"); const ld_script = ld.add("app.ld", linkerScript( b, stack_size, if (oracle) readPeripheralsLd(b, registers.idf_path) else null, )); // ---------------------------------------------------------------- the application const options = b.addOptions(); options.addOption(u8, "led_pin", led_pin); options.addOption(u32, "stack_size", stack_size); options.addOption([]const u8, "wifi_ssid", wifi_ssid); options.addOption([]const u8, "wifi_psk", if (psk_file) |path| blk: { const raw = std.Io.Dir.cwd().readFileAlloc(b.graph.io, path, b.allocator, .limited(256)) catch @panic("cannot read the file named by -Dpsk-file"); break :blk std.mem.trim(u8, raw, " \t\r\n"); } else wifi_psk_opt); options.addOption(bool, "full_descriptor", descriptor == .full); options.addOption(u16, "min_rev_full", min_rev); options.addOption(u16, "max_rev_full", max_rev); const config_mod = options.createModule(); // The board-support modules are real modules, so an app can live anywhere and still // `@import("soc")`. src/ holds one copy of each; examples/ holds none. const soc_mod = b.createModule(.{ .root_source_file = b.path("src/soc.zig"), .target = target, .optimize = optimize, }); // The whole chip's registers, and the typed layer over them. `hal` is what applications and // drivers use; `regs` is the raw translate-c output, exposed so a driver can reach a register // the HAL does not model yet without waiting for one to be written. const mmio_mod = b.createModule(.{ .root_source_file = b.path("src/mmio.zig"), .target = target, .optimize = optimize, .imports = &.{.{ .name = "regs", .module = regs_mod }}, }); const hal_mod = b.createModule(.{ .root_source_file = b.path("src/hal.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "regs", .module = regs_mod }, .{ .name = "mmio", .module = mmio_mod }, }, }); soc_mod.addImport("hal", hal_mod); // The app descriptor is its own translation unit, linked in unconditionally. An application // that merely `@import`s it would not do: under ReleaseSmall the import is analysed lazily, // nothing forces the constant to be emitted, `.flash.rodata` disappears, and the image ends up // with a single mapped segment at the wrong offset. As a separate object with an exported // symbol it always exists, and no application has to remember anything. const appdesc_obj = b.addObject(.{ .name = "appdesc", .root_module = b.createModule(.{ .root_source_file = b.path("src/appdesc.zig"), .target = target, .optimize = optimize, .imports = &.{.{ .name = "config", .module = config_mod }}, }), }); const app_source = b.option([]const u8, "app", "root source file (default src/main.zig)") orelse "src/main.zig"; const app = b.addExecutable(.{ .name = "app", .root_module = b.createModule(.{ .root_source_file = if (std.fs.path.isAbsolute(app_source)) .{ .cwd_relative = app_source } else b.path(app_source), .target = target, .optimize = optimize, .strip = true, .single_threaded = true, .unwind_tables = .none, .omit_frame_pointer = true, .error_tracing = false, .imports = &.{ .{ .name = "config", .module = config_mod }, .{ .name = "soc", .module = soc_mod }, .{ .name = "hal", .module = hal_mod }, .{ .name = "mmio", .module = mmio_mod }, .{ .name = "regs", .module = regs_mod }, }, }), }); // The census is a gate, not a side effect: nothing may compile against the register module // without it having been counted. app.step.dependOn(registers.census); if (oracle) { // IDF's LL compiled into this very image, as the reference half of the differential. idfReference(b, app.root_module, registers.idf_path, registers.hw_ver); // The suites: one module listing every peripheral registered with the harness, so the // harness itself does not grow as peripherals are added. const oracle_mod = b.createModule(.{ .root_source_file = b.path("src/oracle/all.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "hal", .module = hal_mod }, .{ .name = "regs", .module = regs_mod }, .{ .name = "mmio", .module = mmio_mod }, }, }); app.root_module.addImport("oracle", oracle_mod); } // `std.Io` implemented for this chip: a cooperative scheduler, timers off the systimer, and // futexes. Its own module rather than a file inside `net`, because Zig confines a module's // imports to its root directory - src/net/ cannot reach ../io/ - and because it is useful // without the radio: any application wanting tasks and timeouts can import it alone. const io_mod = b.createModule(.{ .root_source_file = b.path("src/io/p4.zig"), .target = target, .optimize = optimize, .single_threaded = true, .imports = &.{ .{ .name = "soc", .module = soc_mod }, .{ .name = "hal", .module = hal_mod }, .{ .name = "mmio", .module = mmio_mod }, .{ .name = "regs", .module = regs_mod }, }, }); app.root_module.addImport("io", io_mod); if (hosted) { // The Zig half: the port table, the libc surface and the IP stack. // // A plain module, NOT an `addObject` like appdesc. The object route looks tempting - these // files exist to define exported C symbols, and appdesc is an object for exactly that // reason - but it is wrong here and fails loudly: an object gets its own copy of every // module it imports, so `hal` and `io` end up compiled into both net.o and the executable, // and the link dies on duplicate `trapEntry`, `intrDispatch`, `intrFault`, `g_h` and // `g_hosted_osi_funcs`. // // A module is safe here for a reason appdesc could not rely on: a hosted application has to // call `net.init(io, gpa)` to bring the radio up, so the module is genuinely referenced and // its exports are emitted. appdesc had nothing referencing it at all. const net_mod = b.createModule(.{ .root_source_file = b.path("src/net/all.zig"), .target = target, .optimize = optimize, .single_threaded = true, .imports = &.{ .{ .name = "config", .module = config_mod }, .{ .name = "soc", .module = soc_mod }, .{ .name = "hal", .module = hal_mod }, .{ .name = "mmio", .module = mmio_mod }, .{ .name = "regs", .module = regs_mod }, .{ .name = "io", .module = io_mod }, }, }); app.root_module.addImport("net", net_mod); // The C half, compiled against this project's Kconfig surface. It attaches to the // executable's own module rather than net's, because the C is linked, not imported. hostedC(b, app.root_module, registers.idf_path); } app.setLinkerScript(ld_script); app.entry = .{ .symbol_name = "_start" }; // The app descriptor must survive --gc-sections even though no code reads it: the bootloader // does, at image offset 0x20. Asking the linker for the symbol is what keeps the module alive, // regardless of whether the application source happens to mention it. app.root_module.addObject(appdesc_obj); app.link_gc_sections = true; app.link_function_sections = true; app.link_data_sections = true; // One install step for the ELF, reachable two ways: `zig build elf` on its own (handy when // debugging the image builder) and `-Delf` to get it alongside the image. const elf_only = b.addInstallArtifact(app, .{}); b.step("elf", "build and install just the ELF, skipping the image").dependOn(&elf_only.step); if (b.option(bool, "elf", "also install the ELF (default false)") orelse false) b.getInstallStep().dependOn(&elf_only.step); // ---------------------------------------------------------------- ELF -> image const img = ImageStep.create(b, app, .{ .chip = .esp32p4, .min_rev_full = min_rev, .max_rev_full = max_rev, .flash_size = flash_size, .flash_offset = app_offset, }); b.getInstallStep().dependOn(&b.addInstallBinFile(img.getOutput(), "app.bin").step); // ---------------------------------------------------------------- flash / monitor / size const flash = FlashStep.create(b, img, .{ .port = port_path, .baud = baud, .verify = b.option(bool, "verify", "ask the ROM for an MD5 of what it stored (default true)") orelse true, .opts = img.opts, }); b.step("flash", "write the image to the chip and run it").dependOn(&flash.step); const monitor_seconds = b.option(u32, "seconds", "monitor duration (default 5)") orelse 5; const mon = MonitorStep.create(b, port_path, monitor_seconds); b.step("monitor", "reset the board and print its console output").dependOn(&mon.step); // `zig build flash monitor` names two independent steps, and the runner may start either // first - in practice monitor wins and prints the *old* firmware. This one is ordered. const run_mon = MonitorStep.create(b, port_path, monitor_seconds); run_mon.step.dependOn(&flash.step); b.step("run", "flash the image, then print its console output").dependOn(&run_mon.step); const reset = ResetStep.create(b, port_path); b.step("reset", "reset the board and let the flashed application run").dependOn(&reset.step); const size = SizeStep.create(b, img); b.step("size", "print the image layout byte by byte").dependOn(&size.step); // `zig build diff` - the hardware oracle. Builds the differential harness with ESP-IDF's own LL // functions linked in beside ours, flashes it, and prints the comparison. This is the project's // real correctness argument for the HAL: not "the tests pass" but "the registers this leaves // behind are the registers ESP-IDF leaves behind, measured on the die". // // It is a separate step rather than part of `test` because it needs the board, and because it // needs an ESP-IDF checkout to compile the reference against. if (oracle) { const diff_mon = MonitorStep.create(b, port_path, monitor_seconds); diff_mon.step.dependOn(&flash.step); b.step("diff", "flash the differential harness and compare against ESP-IDF's LL on the die") .dependOn(&diff_mon.step); } else { const hint = b.step("diff", "flash the differential harness and compare against ESP-IDF's LL on the die"); hint.dependOn(&NeedsOracle.create(b).step); } // Host tests for the image builder: every case is a rule the ROM bootloader enforces. const tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("tools/image_test.zig"), .target = b.graph.host, .optimize = .Debug, }), }); const test_step = b.step("test", "run the host tests: image builder, and the register layer's field arithmetic"); test_step.dependOn(&b.addRunArtifact(tests).step); // The typed register layer's arithmetic - masks, shifts, bank splits - is host-testable and // worth testing there: a wrong shift is otherwise a silent misconfiguration on the die. These // run against the host target, so they exercise mmio.zig without needing the chip's registers. const mmio_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("src/mmio.zig"), .target = b.graph.host, .optimize = .Debug, }), }); test_step.dependOn(&b.addRunArtifact(mmio_tests).step); // The radio path's host-testable parts. A wrong checksum, a wrong snprintf, or a scheduler that // loses a task is far cheaper to find here than on a board whose only output is a serial line. // // These need the board-support modules built for the *host*, not for the chip: handing a test // the riscv-targeted `hal` crashes the compiler outright rather than reporting a target // mismatch. The modules themselves are happy to be built either way - src/mmio.zig computes // addresses with @ptrFromInt and never dereferences one at comptime - so a second instance of // the same source files is all it takes. Nothing here touches a real register; the tests that // must do that run on the die, through examples/halcheck.zig. const host_mmio = b.createModule(.{ .root_source_file = b.path("src/mmio.zig"), .target = b.graph.host, .optimize = .Debug, .imports = &.{.{ .name = "regs", .module = regs_mod }}, }); const host_hal = b.createModule(.{ .root_source_file = b.path("src/hal.zig"), .target = b.graph.host, .optimize = .Debug, .imports = &.{ .{ .name = "regs", .module = regs_mod }, .{ .name = "mmio", .module = host_mmio }, }, }); const host_soc = b.createModule(.{ .root_source_file = b.path("src/soc.zig"), .target = b.graph.host, .optimize = .Debug, }); host_soc.addImport("hal", host_hal); for ([_][]const u8{ "src/net/libc.zig", // the malloc header trick, and snprintf's conversions "src/net/ip.zig", // checksums, ARP, DHCP, the TCP state machine "src/net/heap.zig", // the allocator behind the port table "src/net/hosted_os.zig", // the port table's sync and queue wrappers "src/io/p4.zig", // the scheduler, futexes and timers }) |path| { // Skip quietly if a file has not landed: these are written in parallel, and one missing // file should not stop the rest of the suite from running. std.Io.Dir.cwd().access(b.graph.io, b.pathFromRoot(path), .{}) catch continue; const t = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path(path), .target = b.graph.host, .optimize = .Debug, .imports = &.{ .{ .name = "soc", .module = host_soc }, .{ .name = "hal", .module = host_hal }, .{ .name = "mmio", .module = host_mmio }, .{ .name = "regs", .module = regs_mod }, }, }), }); test_step.dependOn(&b.addRunArtifact(t).step); } } /// The ESP32-P4's entire register map as a Zig module, via `zig translate-c` over ESP-IDF's own /// register headers. /// /// This is the one place the toolchain reads ESP-IDF, and it reads it as *data*: header files, at /// build time, through the C front end Zig already ships. No IDF program runs, nothing from IDF is /// linked, and no generated file is committed. What it needs is a checkout to point at. /// /// `hw_ver1` is the correct register set for a pre-v3 die, which is what this board has (silicon /// rev v1.3). ESP-IDF makes the same choice the same way: soc/CMakeLists.txt:37-41 selects /// `register/hw_ver1` when CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is set, and this project's own IDF /// build resolved to that directory. fn idfRegisters( b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, ) struct { mod: *std.Build.Module, census: *std.Build.Step, idf_path: []const u8, hw_ver: u8 } { const idf = b.option([]const u8, "idf", "ESP-IDF checkout, for the register headers (default $IDF_PATH or ~/esp/esp-idf)") orelse b.graph.environ_map.get("IDF_PATH") orelse b.pathJoin(&.{ b.graph.environ_map.get("HOME") orelse "", "esp", "esp-idf" }); const hw_ver = b.option(u8, "idf-hw-ver", "register header set: 1 for pre-v3 silicon (default), 3 for v3+") orelse 1; const reg_dir = b.pathJoin(&.{ idf, "components", "soc", "esp32p4", "register", b.fmt("hw_ver{d}", .{hw_ver}), "soc" }); // One umbrella translation unit including every peripheral header, listed by reading the // directory: a new IDF release that adds a peripheral is picked up without editing this file. var names: std.ArrayList([]const u8) = .empty; const io = b.graph.io; var dir = std.Io.Dir.cwd().openDir(io, reg_dir, .{ .iterate = true }) catch { std.log.err( \\cannot read the ESP-IDF register headers at \\ {s} \\This is the only thing the build needs ESP-IDF for - the headers are read as data at \\build time; no IDF program runs and nothing from IDF is linked. Point at a checkout: \\ zig build -Didf=/path/to/esp-idf \\or set IDF_PATH. ESP-IDF v6.0.2 is what this was developed against. , .{reg_dir}); std.process.exit(1); }; defer dir.close(io); var it = dir.iterate(); while (it.next(io) catch null) |entry| { // Deliberately not testing `entry.kind`: a `*_reg.h` that is a symlink - ordinary in Nix // store paths, Bazel sandboxes and `cp -as` mirrors - was silently dropped from the // umbrella. Replacing one header with a symlink to its own content removed all three I2S // controllers (600 constants) while the build stayed green and the poison census stayed at // exactly 524, because a clean header contributes no poison. The name suffix decides. if (entry.kind == .directory or !std.mem.endsWith(u8, entry.name, "_reg.h")) continue; names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM"); } // Directory order is filesystem order. Sorting keeps the generated C - and therefore the // translate-c cache key and the module's decl order - stable across machines. std.mem.sort([]const u8, names.items, {}, struct { fn lt(_: void, x: []const u8, y: []const u8) bool { return std.mem.lessThan(u8, x, y); } }.lt); var umbrella: std.ArrayList(u8) = .empty; umbrella.appendSlice(b.allocator, \\/* GENERATED by build.zig: every ESP32-P4 peripheral register header, in one translation \\ unit, for zig translate-c. */ \\ ) catch @panic("OOM"); for (names.items) |n| { umbrella.print(b.allocator, "#include \"soc/{s}\"\n", .{n}) catch @panic("OOM"); } // Not a register header, but the other half of the GPIO matrix: 474 peripheral signal indices, // which is what a driver routes to a pad. Taking SIG_GPIO_OUT_IDX from here rather than writing // 256 in Zig is not pedantry - it is 128 on the ESP32-S3, and the wrong one leaves a pad // undriven with no error anywhere. umbrella.appendSlice(b.allocator, "#include \"soc/gpio_sig_map.h\"\n") catch @panic("OOM"); // Six more register headers live in include/soc/ rather than the generated register directory, // and they are not minor ones: clic_reg.h is the interrupt controller this chip actually has // (the P4 has a CLIC, not a PLIC - soc_caps.h:191 SOC_INT_CLIC_SUPPORTED), interrupt_reg.h // carries the per-die threshold selection, and spi_mem/system/hwcrypto are the flash and system // blocks. Globbing only the register directory silently omits all of them. // // They use an older macro-comment form than the generated headers, which matters not at all // here: nothing in this toolchain parses those comments, clang does the reading. for ([_][]const u8{ "clic_reg.h", "interrupt_reg.h", "spi_mem_reg.h", "system_reg.h", "hwcrypto_reg.h", "regi2c_dig_reg.h", }) |extra| { umbrella.print(b.allocator, "#include \"soc/{s}\"\n", .{extra}) catch @panic("OOM"); } const wf = b.addWriteFiles(); const umbrella_c = wf.add("esp32p4_regs.c", umbrella.items); const tc = b.addTranslateC(.{ .root_source_file = umbrella_c, .target = target, .optimize = optimize, // Header text only: nothing in the register macros calls a libc function, and this target // has no libc to link (`unable to provide libc for target riscv32-freestanding-none`). .link_libc = false, }); // The hw_ver the headers came from, as a constant inside the module. It is load-bearing and // otherwise invisible: 61 macros keep their name and change their value between hw_ver1 and // hw_ver3 (AHB_DMA_INFIFO_CNT_CH0 is bits [7:2] on v1 and [14:8] on v3), so a module built from // the wrong header set is silently wrong rather than absent. hal.zig comptime-asserts it. tc.defineCMacroRaw(b.fmt("ZIG_P4_HW_VER={d}", .{hw_ver})); // soc.h pulls in a few headers that only exist for a hosted target. The register headers // themselves need nothing from them, so empty stand-ins are enough and keep the include set to // ESP-IDF proper. const shims = b.addWriteFiles(); _ = shims.add("stdlib.h", "#pragma once\n"); _ = shims.add("string.h", "#pragma once\n"); _ = shims.add("assert.h", "#pragma once\n#define assert(x) ((void)0)\n"); tc.addIncludePath(shims.getDirectory()); for ([_][]const u8{ "components/soc/include", "components/soc/esp32p4/include", b.fmt("components/soc/esp32p4/register/hw_ver{d}", .{hw_ver}), "components/esp_common/include", "components/esp_rom/include", }) |rel| { tc.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ idf, rel }) }); } // Six base macros are referenced by IDF's own register headers and defined nowhere in IDF: // DMAC, MB, TSENS, RTCLOCKCALI, H264 and H264_DMA. Those 507 register macros are dead in C too // - nothing expands them, so nothing notices - and translate-c makes them visible by turning // each into a poisoned decl that fails only if a driver names it. // // IDF reaches those peripherals a different way: through the linker, not a macro. // soc/esp32p4/ld/esp32p4.peripherals.ld PROVIDEs 111 instance addresses, and for the 65 // peripherals that appear in both that file and reg_base.h the two agree on all 65. That makes // it a sound source for the missing three that are in scope here. // // H264 and H264_DMA stay unavailable on purpose (video encode is out of scope) and // RTCLOCKCALI is absent from both files, so its 21 registers stay unreachable rather than // reachable at a guessed address. for ([_][]const u8{ "DR_REG_DMAC_BASE=0x50081000", // ld: DW_GDMA - the AXI general-purpose DMA "DR_REG_MB_BASE=0x50118000", // ld: LP_MAILBOX - LP<->HP mailbox "DR_REG_TSENS_BASE=0x5012f000", // ld: LP_TSENS - temperature sensor }) |def| { tc.defineCMacroRaw(def); } // translate-c exits 0 with empty stderr and still emits `pub const X = @compileError(...)` for // every macro it could not translate. Those are invisible until a driver names one, months // later, with a message that blames Zig for an ESP-IDF header bug. So count them and fail the // build if the number grows: the current 524 are accounted for, and a 525th means either a new // IDF release broke something or an include path was lost. const census = PoisonCensus.create(b, tc.getOutput(), 524); return .{ .mod = tc.createModule(), .census = &census.step, .idf_path = idf, .hw_ver = hw_ver }; } /// Counts the poisoned declarations in the translated register module and fails if there are more /// than expected. /// /// Composition of the 524 expected today, all of them harmless *here* and none of them ours: /// * 333 `*_REG` addresses whose `DR_REG_*_BASE` ESP-IDF references but never defines. 308 are /// H264 and H264_DMA (video encode, out of scope) and 21 are RTCLOCKCALI, which is absent from /// both reg_base.h and peripherals.ld, so there is no honest address to supply. /// * 153 `_M` pre-shifted masks that are broken C inside ESP-IDF: interrupt_core0_reg.h writes /// `(CORE0_x_V << CORE0_x_S)` for `INTERRUPT_CORE0_x_M`, dropping the prefix, and spi_mem_c /// does the same with `SPI_XTS_PLAIN_V`. Nothing in C expands them, so nobody noticed. This /// toolchain builds every field from the `_S`/`_V` pair and never from `_M`, which is why all /// 153 sit in code it cannot reach - and mmio.Field.of rejects a pre-shifted mask at comptime /// if one is ever passed by hand. /// * 38 function-like or compiler-predefined macros (`REG_WRITE`, `ESP_STATIC_ASSERT`, /// `__UINT32_C_SUFFIX__`), which have Zig equivalents in mmio.zig. const PoisonCensus = struct { step: std.Build.Step, zig_file: std.Build.LazyPath, budget: usize, fn create(b: *std.Build, zig_file: std.Build.LazyPath, budget: usize) *PoisonCensus { const self = b.allocator.create(PoisonCensus) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "register poison census", .owner = b, .makeFn = make, }), .zig_file = zig_file, .budget = budget, }; zig_file.addStepDependencies(&self.step); return self; } fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { const self: *PoisonCensus = @fieldParentPtr("step", step); const b = step.owner; const io = b.graph.io; const gpa = options.gpa; const path = self.zig_file.getPath2(b, step); const text = std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 << 20)) catch |err| return step.fail("unable to read the translated registers at {s}: {s}", .{ path, @errorName(err) }); defer gpa.free(text); const found = std.mem.count(u8, text, "@compileError"); if (found > self.budget) { return step.fail( \\the translated register module has {d} untranslatable macros, expected at most {d}. \\Each one is a `pub const X = @compileError(...)` that compiles fine until a driver \\names it. Find the new ones with: \\ grep -n '@compileError' {s} \\A likely cause is a lost include path or an ESP-IDF version whose headers moved. , .{ found, self.budget, path }); } if (found < self.budget) { std.log.info( "register poison census: {d} untranslatable macros, below the expected {d} - tighten the budget in build.zig", .{ found, self.budget }, ); } } }; /// ESP-Hosted's SDIO transport C, compiled into this image on top of this project's Zig runtime. /// /// The P4 has no radio. The ESP32-C6 on this board does, and the host half of the protocol between /// them is ~13,000 lines of C that already works. This function compiles the SDIO path of that C /// with Zig's clang, for this target, so the Zig side can be built underneath it and the C replaced /// layer by layer - with the C still linked in as the differential reference, the same way /// `idfReference` above keeps IDF's LL beside the HAL. /// /// Every decision here was measured, not guessed. 31 of ESP-Hosted's 43 host sources compile under /// these flags (the 12 that do not are the SPI and UART transports, which this board does not use, /// plus protobuf-c's own test files). Linking just the SDIO set leaves 26 undefined symbols, which /// is the entire cost of the reuse: src/net/libc.zig covers the libc ones, src/net/port.zig /// defines `g_h`, and src/net/hosted_glue.zig covers logging and the upper-layer hooks. fn hostedC(b: *std.Build, mod: *std.Build.Module, idf: []const u8) void { const io = b.graph.io; // The managed_components tree lives in the sibling project that proved this C on this board. // Not vendored: it is 19,000 lines of someone else's code, it is pinned by that project's // dependency lock, and copying it would make the provenance of the sdkconfig checked in here // a lie. const mc_root = b.pathJoin(&.{ b.pathFromRoot(".."), "02-esp32p4-m3-radio" }); { var probe = std.Io.Dir.cwd().openDir(io, b.pathJoin(&.{ mc_root, "managed_components" }), .{}) catch { std.debug.print( \\-Dhosted needs the ESP-Hosted component from the sibling radio project: \\ {s}/managed_components/espressif__esp_hosted \\That tree is created by `idf.py build` in 02-esp32p4-m3-radio, which is also where \\the checked-in src/net/hosted/sdkconfig.h came from. \\ , .{mc_root}); @panic("ESP-Hosted component not found"); }; probe.close(io); } const hosted_root = b.pathJoin(&.{ mc_root, "managed_components", "espressif__esp_hosted" }); // Include order is ESP-IDF's own, taken from the compile_commands.json of the build that // worked, and it matters: esp_wifi_remote's `injected` directory has to precede // components/esp_wifi/include, because a host with no radio needs the injected Wi-Fi types // rather than the real ones. See src/net/hosted/include_dirs.txt for the provenance note. { const list = std.Io.Dir.cwd().readFileAlloc( io, b.pathFromRoot("src/net/hosted/include_dirs.txt"), b.allocator, .limited(1 << 20), ) catch @panic("src/net/hosted/include_dirs.txt is missing"); var lines = std.mem.tokenizeScalar(u8, list, '\n'); while (lines.next()) |raw| { const line = std.mem.trim(u8, raw, " \t\r"); if (line.len == 0 or line[0] == '#') continue; const abs = if (std.mem.startsWith(u8, line, "IDF/")) b.pathJoin(&.{ idf, line["IDF/".len..] }) else if (std.mem.startsWith(u8, line, "MC/")) b.pathJoin(&.{ mc_root, line["MC/".len..] }) else @panic("include_dirs.txt: every line must start with IDF/ or MC/"); mod.addIncludePath(.{ .cwd_relative = abs }); } } // Our own Kconfig surface and the two compile-time assertion files. mod.addIncludePath(b.path("src/net/hosted")); // libc *declarations* from Zig's own bundled musl headers. This is the alternative to a pile of // hand-written stubs: real prototypes, real errno values, real sys/queue.h, and no drift. The // few symbols actually referenced come from the P4 mask ROM, compiler_rt and src/net/libc.zig - // nothing links a libc. Resolved through the Zig installation rather than hardcoded, so this // survives a toolchain move. const zig_lib = b.graph.zig_lib_directory.path orelse @panic("cannot locate the Zig lib directory for the bundled libc headers"); for ([_][]const u8{ "riscv32-linux-musl", "generic-musl", "any-linux-any" }) |flavour| { mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ zig_lib, "libc", "include", flavour }) }); } // Four headers musl does not have and IDF or ESP-Hosted expects. Each is here for one named // reason, and none of them invents a declaration. const shims = b.addWriteFiles(); // `sys/queue.h` is the BSD linked-list macros. musl has no such header; ESP-Hosted's // common/mempool/include/mempool.h:12 includes it, and every transport source reaches it // through that. Zig does bundle a copy for glibc targets, so this forwards to exactly that one // file by absolute path. Putting glibc's whole include directory on the path would work too and // would be a trap: it would shadow musl's headers wholesale and mix two libcs' declarations. _ = shims.add("sys/queue.h", b.fmt( \\#pragma once \\/* Generated by build.zig. The BSD queue macros, from Zig's bundled glibc headers - \\ musl has no sys/queue.h, and mempool.h needs one. Pure macros: nothing is linked. */ \\#include "{s}" \\ , .{b.pathJoin(&.{ zig_lib, "libc", "include", "generic-glibc", "sys", "queue.h" })})); _ = shims.add("esp_newlib.h", \\#pragma once \\/* FreeRTOS's riscv portmacro.h:76 includes this for newlib re-entrancy hooks. This image \\ has no newlib runtime, so nothing is declared. */ \\ ); _ = shims.add("sys/features.h", \\#pragma once \\/* A newlib feature-test header. IDF's platform_include wrappers include it by name; the \\ musl headers underneath supply the real declarations. */ \\ ); _ = shims.add("machine/endian.h", \\#pragma once \\/* newlib puts the byte-order macros here and esp_netif_ip_addr.h:11 includes that path. \\ riscv32 is little-endian. */ \\#define _LITTLE_ENDIAN 1234 \\#define _BIG_ENDIAN 4321 \\#define _PDP_ENDIAN 3412 \\#define _BYTE_ORDER _LITTLE_ENDIAN \\#define LITTLE_ENDIAN _LITTLE_ENDIAN \\#define BIG_ENDIAN _BIG_ENDIAN \\#define BYTE_ORDER _BYTE_ORDER \\ ); mod.addIncludePath(shims.getDirectory()); // The SDIO path, and nothing else. Named one by one rather than globbed: the SPI, SPI-half- // duplex and UART transports are in the same tree and compile, and pulling them in would link // three unused transports and their symbol demands into a 128 KB image. const sources = [_][]const u8{ // The transport state machine and its helpers. "host/drivers/transport/transport_drv.c", "host/drivers/transport/transport_util.c", "host/drivers/transport/sdio/sdio_drv.c", // common/mempool/mempool.c is deliberately NOT here. Its backend is NimBLE's os_mempool - // MYNEWT_VAL and struct os_mempool - so the file only compiles in a build that carries the // NimBLE host, which is why the IDF project this came from had NimBLE enabled. Bluetooth and // the pool are both off (src/net/hosted/sdkconfig.h overrides 2 and 3), and every mempool // call site in sdio_drv.c is behind `#if H_USE_MEMPOOL` (:109, :177, :244, :264), so with the // pool off there is nothing to link. Buffers come straight from _h_malloc instead. // The RPC layer: what carries Wi-Fi association, scanning and everything else the // coprocessor does on our behalf. The transport alone gets the two chips talking; this is // what gives that conversation vocabulary. Reached at transport_delayed_init -> rpc_start. // transport_drv.c:807 calls create_debugging_tasks() unconditionally. This is the vendor's // own implementation; with the stats Kconfig options off it spawns nothing, which is the // answer we want and one we do not have to write. "host/utils/stats.c", // The control endpoint the RPC layer talks over: a virtual serial device multiplexed onto // the same SDIO transport. transport_drv.c dispatches into serial_ll_rx_handler. "host/drivers/serial/serial_ll_if.c", "host/drivers/serial/serial_drv.c", "host/drivers/virtual_serial_if/serial_if.c", "host/drivers/rpc/core/rpc_core.c", "host/drivers/rpc/core/rpc_req.c", "host/drivers/rpc/core/rpc_rsp.c", "host/drivers/rpc/core/rpc_evt.c", "host/drivers/rpc/core/rpc_utils.c", "host/drivers/rpc/slaveif/rpc_slave_if.c", "host/drivers/rpc/wrap/rpc_wrap.c", // The wire format underneath it. esp_hosted_rpc.pb-c.c is 33,000 generated lines of // protobuf descriptors - almost entirely .rodata, so it costs flash rather than the L2MEM // this image is actually short of. "common/protobuf-c/protobuf-c/protobuf-c.c", "common/proto/esp_hosted_rpc.pb-c.c", // Bluetooth, declined. transport_drv.c:126 calls hci_drv_init() unconditionally, and this // is ESP-Hosted's own no-op for a host without BT - a real answer from the vendor rather // than a stub of ours. Bluetooth is switched off in src/net/hosted/sdkconfig.h, which is // what keeps this file from pulling NimBLE in. "host/drivers/bt/hci_stub_drv.c", // The transport configuration, derived from Kconfig. Compiled rather than transcribed into // Zig: the real struct interleaves `gpio_pin_t {void *port; int pin;}` pairs, and a // hand-copy of that layout is a silent wrong-pin bug. src/net/hosted/pin_assert.c checks // the values these produce against the board. "host/api/src/esp_hosted_transport_config.c", "host/port/esp/freertos/src/port_esp_hosted_host_transport_defaults.c", }; // The force-include is load-bearing, and this is the single most expensive thing to get wrong // in this whole file. // // `hosted_osi_funcs_t` guards four mempool entries with `#ifdef H_USE_MEMPOOL` // (host/esp_hosted_os_abstraction.h:64-69). H_USE_MEMPOOL is *defined* - to 1 or to 0, but // always defined - by port_esp_hosted_host_config.h:127-131, and `#ifdef` does not care which. // So the struct is four pointers longer in any translation unit that saw that header first. // Both host/esp_hosted.h:14 and host/drivers/transport/transport_util.h:10 include // esp_hosted_os_abstraction.h as their very first include, so reaching the short layout is // easy. Measured with these exact flags: // // sizeof _h_config_gpio _h_event_post // without this -include 268 132 264 // with this -include 284 148 280 // // A TU with the short layout calling _h_config_gpio jumps through a mempool pointer instead. // On a board with no debugger that is a hang whose symptom is indistinguishable from the SDIO // bus failing to come up. src/net/hosted/abi_assert.c asserts the long layout so this cannot // regress silently. const force_include = b.fmt("-include{s}", .{ b.pathJoin(&.{ hosted_root, "host/port/esp/freertos/include/port_esp_hosted_host_config.h" }), }); const flags = [_][]const u8{ "-std=gnu17", // Same reason as the oracle: IDF's code otherwise pulls in six __ubsan_handle_* symbols // that do not exist in a freestanding image. "-fno-sanitize=undefined", // The reference should be the code as shipped, not a debug build of it. "-O2", // IDF's build defines this, and ESP-Hosted's config header uses it to decide whether to // derive the slave target from Kconfig or from a hand-edited block of #defines. Without it // the build fails with "No Slave Target or more than one Slave Target was defined". "-DESP_PLATFORM", "-DIDF_VER=\"v6.0.2\"", // esp_libc/platform_include/stdio.h:46 uses newlib's spelling of off_t for its // fopencookie typedefs. musl spells it off_t. "-D__off_t=off_t", // esp_private/interrupt_clic.h:34 uses newlib's assert.h internals. "-D__ASSERT_FUNC=__func__", // ESP-Hosted's own code is not this project's to clean up. "-Wno-everything", force_include, }; for (sources) |rel| mod.addCSourceFile(.{ .file = .{ .cwd_relative = b.pathJoin(&.{ hosted_root, rel }) }, .flags = &flags, }); // The Wi-Fi shim: a narrow C surface over the RPC layer, so Zig never transcribes an IDF // struct. Compiled with the ESP-Hosted flags because it includes their headers. mod.addCSourceFile(.{ .file = b.path("src/net/hosted/wifi_shim.c"), .flags = &flags, }); // The two assertion files. They define nothing anyone calls; they exist so that a Kconfig // value drifting from the board, or an include order shifting a struct, fails the build with a // sentence instead of hanging the radio. for ([_][]const u8{ "pin_assert.c", "abi_assert.c" }) |name| mod.addCSourceFile(.{ .file = b.path(b.pathJoin(&.{ "src/net/hosted", name })), .flags = &flags, }); } /// ESP-IDF's own `*_ll.h` functions, compiled into this image as the differential reference. /// /// The whole point of the oracle is that both implementations run on the same die, in the same /// boot, against the same clocks - so IDF's headers have to be *compiled*, by Zig's clang, for the /// same target. They are, with a 10-file shim and one flag; the details below each cost a debugging /// round to find, so they are recorded rather than summarised. fn idfReference( b: *std.Build, mod: *std.Build.Module, idf: []const u8, hw_ver: u8, ) void { // IDF's LL headers include a handful of hosted-libc headers for types they mostly do not use. // Empty stand-ins are enough for all but one line: `assert.h` must define `static_assert`, // because esp_assert.h defines ESP_STATIC_ASSERT in terms of it and IDF sprinkles those through // the `*_types.h` headers. Without that single line, 16 of the 96 LL headers fail to compile. const shims = b.addWriteFiles(); _ = shims.add("stdlib.h", "#pragma once\ntypedef unsigned int size_t;\n#define NULL ((void *)0)\n"); _ = shims.add("string.h", "#pragma once\n"); _ = shims.add("stdio.h", "#pragma once\n"); _ = shims.add("math.h", "#pragma once\n"); _ = shims.add("inttypes.h", "#pragma once\n"); _ = shims.add("sys/param.h", "#pragma once\n"); _ = shims.add("sys/cdefs.h", "#pragma once\n"); _ = shims.add("assert.h", \\#pragma once \\#define assert(x) ((void)0) \\/* the load-bearing line: ESP_STATIC_ASSERT expands to this, in 16 headers */ \\#define static_assert _Static_assert \\ ); // IDF's headers include "sdkconfig.h" by that exact name. The real content is checked in as // src/oracle/oracle_sdkconfig.h, where the name says what it is; this is the bridge. _ = shims.add("sdkconfig.h", "#pragma once\n#include \"oracle_sdkconfig.h\"\n"); mod.addIncludePath(shims.getDirectory()); // Our own Kconfig surface, checked in next to the reference so it is part of the experiment. mod.addIncludePath(b.path("src/oracle")); // ESP-IDF v6 split the HAL into one component per peripheral, so a reference for peripheral X // needs components/esp_hal_X/{esp32p4/include,include} on the path. Rather than listing them - // and editing this file every time the oracle grows - take all of them: there are ~30, they are // header-only, and an unused include path costs nothing. { const io = b.graph.io; const comp_path = b.pathJoin(&.{ idf, "components" }); var dir = std.Io.Dir.cwd().openDir(io, comp_path, .{ .iterate = true }) catch @panic("cannot read ESP-IDF components/"); defer dir.close(io); var names: std.ArrayList([]const u8) = .empty; var it = dir.iterate(); while (it.next(io) catch null) |entry| { if (entry.kind != .directory or !std.mem.startsWith(u8, entry.name, "esp_hal_")) continue; names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM"); } std.mem.sort([]const u8, names.items, {}, struct { fn lt(_: void, x: []const u8, y: []const u8) bool { return std.mem.lessThan(u8, x, y); } }.lt); for (names.items) |n| { mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ comp_path, n, "esp32p4", "include" }) }); mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ comp_path, n, "include" }) }); } } for ([_][]const u8{ "components/hal/esp32p4/include", "components/hal/include", "components/hal/platform_port/include", "components/soc/include", "components/soc/esp32p4/include", b.fmt("components/soc/esp32p4/register/hw_ver{d}", .{hw_ver}), "components/esp_common/include", "components/esp_rom/include", "components/esp_rom/esp32p4/include", "components/esp_rom/esp32p4/include/esp32p4", "components/riscv/include", "components/esp_hw_support/include", }) |rel| { mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ idf, rel }) }); } // Every reference translation unit in src/oracle. Adding a peripheral to the oracle means // adding one `_ref.c` there; this file does not need to know about it. var refs: std.ArrayList([]const u8) = .empty; { const io = b.graph.io; var dir = std.Io.Dir.cwd().openDir(io, b.pathFromRoot("src/oracle"), .{ .iterate = true }) catch @panic("src/oracle is missing"); defer dir.close(io); var it = dir.iterate(); while (it.next(io) catch null) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, "_ref.c")) continue; refs.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM"); } std.mem.sort([]const u8, refs.items, {}, struct { fn lt(_: void, x: []const u8, y: []const u8) bool { return std.mem.lessThan(u8, x, y); } }.lt); } for (refs.items) |name| mod.addCSourceFile(.{ .file = b.path(b.pathJoin(&.{ "src/oracle", name })), .flags = &.{ "-std=gnu17", // Without this, IDF's LL code pulls in six __ubsan_handle_* symbols that have nothing // to do with the peripheral and do not exist in a freestanding image. "-fno-sanitize=undefined", // The reference should be the code IDF ships, not a debug build of it. "-O2", // gpio_ll.h:588 (`gpio_ll_is_digital_io_hold`) has a path that returns nothing under // this chip's `#if`s. It is IDF's bug, in a function nothing here calls, but clang // analyses every static inline in a header it parses. Downgrading it is the only way to // compile the reference at all, and pretending we could fix IDF here would be worse. "-Wno-return-type", // Force the Kconfig surface into every reference translation unit. Some IDF headers read // CONFIG_* macros without including sdkconfig.h themselves - soc/interrupt_reg.h keys // the CLIC threshold mechanism off CONFIG_ESP32P4_SELECTS_REV_LESS_V3 and assumes the // includer already has it - so relying on each file to remember is a silent // misconfiguration waiting to happen. On this die that particular one decides whether // the interrupt threshold is a memory-mapped register or a CSR that does nothing. // Absolute path to the checked-in file, not `-include sdkconfig.h`: the bridging header // lives in a WriteFiles directory whose name changes with its hash, and a C object cached // against the old path fails the build with CacheCheckFailed on the next run. b.fmt("-include{s}", .{b.pathFromRoot("src/oracle/oracle_sdkconfig.h")}), }, }); } /// `zig build diff` without -Doracle would silently flash whatever app is default and compare /// nothing, so it fails with the command to run instead. const NeedsOracle = struct { step: std.Build.Step, fn create(b: *std.Build) *NeedsOracle { const self = b.allocator.create(NeedsOracle) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "diff needs -Doracle", .owner = b, .makeFn = make, }) }; return self; } fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void { return step.fail( \\the differential harness needs ESP-IDF's LL functions linked in as the reference: \\ zig build diff -Doracle -Dapp=examples/differ.zig \\That compiles ESP-IDF's own *_ll.h into this image with zig cc, so both go on the die \\in one boot. It needs an ESP-IDF checkout, via -Didf= or $IDF_PATH. , .{}); } }; /// ESP-IDF's peripheral instance addresses, as text to splice into the generated linker script. /// /// IDF's LL code addresses peripherals through struct instances (`GPIO`, `IO_MUX`, `HP_SYS_CLKRST`) /// which are not C objects at all: esp32p4.peripherals.ld PROVIDEs 111 of them as linker symbols. fn readPeripheralsLd(b: *std.Build, idf: []const u8) []const u8 { const path = b.pathJoin(&.{ idf, "components/soc/esp32p4/ld/esp32p4.peripherals.ld" }); const text = std.Io.Dir.cwd().readFileAlloc(b.graph.io, path, b.allocator, .limited(256 << 10)) catch { std.log.err("cannot read {s}, which the oracle needs for ESP-IDF's peripheral symbols", .{path}); std.process.exit(1); }; return b.fmt("/* spliced from {s} */\n{s}", .{ path, text }); } const DescriptorKind = enum { minimal, full }; fn featureSet(features: []const std.Target.riscv.Feature) std.Target.Cpu.Feature.Set { var set = std.Target.Cpu.Feature.Set.empty; for (features) |f| set.addFeature(@intFromEnum(f)); return set; } /// The eleven ESP-IDF linker scripts, replaced by these twenty lines. fn linkerScript(b: *std.Build, stack_size: u32, peripherals_ld: ?[]const u8) []const u8 { // `peripherals_ld` is the *text* of ESP-IDF's peripherals.ld, not a path, and that is the whole // point. An earlier version emitted `INCLUDE ""`: LLD read the file at link time, but only // the generated app.ld was a cache input, so the 111 peripheral base addresses the reference // half is linked against were untracked. Perturbing GPIO's base in that file and rebuilding // produced a byte-identical image - no relink at all - and the reverse was worse: a relink // forced for an unrelated reason baked the perturbed address in, and restoring the file left it // there under a green build. Splicing the text in makes the content hash the link's cache key, // and drops an absolute host path out of a generated artefact. return b.fmt( \\/* generated by build.zig - do not edit */ \\ENTRY(_start) \\{s} \\ \\/* ESP32-P4 mask ROM entry points, the only "library" this image links against */ \\ets_printf = 0x4fc00024; \\ets_delay_us = 0x4fc0003c; \\ \\MEMORY {{ \\ /* Flash-mapped code and rodata. \\ \\ This was 0xFFE0 - one 64 KiB MMU window - for as long as every application here was a \\ few KB, and the comment said the bootloader demands two mapped segments and nothing \\ says they may not share a page. Both halves of that are still true; what changed is \\ 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 \\}} \\ \\SECTIONS {{ \\ .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; \\ }} > flash \\ \\ .flash.text : ALIGN(64) {{ \\ *(.text.entry) \\ *(.text .text.*) \\ }} > flash \\ \\ .data : ALIGN(4) {{ *(.data .data.* .sdata .sdata.*) }} > l2mem \\ .bss (NOLOAD) : ALIGN(4) {{ \\ __bss_start = .; \\ *(.bss .bss.* .sbss .sbss.* COMMON) \\ __bss_end = .; \\ }} > l2mem \\ /* ALIGN(16) aligns the section start; __stack_top is start + size, so round that too - \\ the RISC-V ABI wants sp 16-byte aligned and -Dstack takes any integer. */ \\ .stack (NOLOAD) : ALIGN(16) {{ . = ALIGN(. + {d}, 16); __stack_top = .; }} > l2mem \\ \\ /DISCARD/ : {{ *(.eh_frame) *(.eh_frame_hdr) *(.comment) *(.riscv.attributes) }} \\}} \\ , .{ // ESP-IDF's LL code addresses peripherals through struct instances (`GPIO`, `IO_MUX`, // `HP_SYS_CLKRST`), which are not C objects at all: soc/esp32p4/ld/esp32p4.peripherals.ld // PROVIDEs 111 of them as linker symbols. Pulling that file in with INCLUDE rather than a // second -T matters - Zig's driver honours only the last -T given, a limitation this project // already ran into once. if (peripherals_ld) |text| text else "", stack_size, }); } // ---------------------------------------------------------------------------- custom steps // // Three steps, each following the same shape the standard steps use (see std/Build/Step/ObjCopy.zig): // hash the inputs into a cache manifest, skip the work on a hit, publish the result as a LazyPath. // Nothing is passed between steps through private fields, so `zig build flash` works whether or not // the image step ran in this process. /// The serial port is one device but `flash` and `monitor` are unordered top-level steps, and the /// build runner executes independent steps concurrently. Serializing them here turns /// `zig build flash monitor` from a race into a sequence. var port_lock: std.Io.Mutex = .init; const ImageStep = struct { step: std.Build.Step, elf: std.Build.LazyPath, opts: image.Options, basename: []const u8, generated: std.Build.GeneratedFile, fn create(b: *std.Build, app: *std.Build.Step.Compile, opts: image.Options) *ImageStep { const self = b.allocator.create(ImageStep) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "image", .owner = b, .makeFn = make, }), .elf = app.getEmittedBin(), .opts = opts, .basename = "app.bin", .generated = .{ .step = undefined }, }; self.generated.step = &self.step; self.elf.addStepDependencies(&self.step); return self; } fn getOutput(self: *ImageStep) std.Build.LazyPath { return .{ .generated = .{ .file = &self.generated } }; } fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { const self: *ImageStep = @fieldParentPtr("step", step); const b = step.owner; const io = b.graph.io; const gpa = options.gpa; var man = b.graph.cache.obtain(); defer man.deinit(); const elf_path = self.elf.getPath2(b, step); _ = try man.addFile(elf_path, null); // The image builder is part of the input: without these two, editing tools/image.zig gives // a cache hit and ships the previous bytes, and `--watch` never notices the edit at all. _ = try man.addFile(b.pathFromRoot("tools/image.zig"), null); _ = try man.addFile(b.pathFromRoot("build.zig"), null); inline for (@typeInfo(image.Options).@"struct".fields) |f| { const v = @field(self.opts, f.name); man.hash.add(switch (@typeInfo(@TypeOf(v))) { .@"enum" => @as(u32, @intFromEnum(v)), else => @as(u32, v), }); } if (try step.cacheHitAndWatch(&man)) { const digest = man.final(); self.generated.path = try b.cache_root.join(b.allocator, &.{ "o", &digest, self.basename }); return; } const digest = man.final(); const cache_dir = "o" ++ std.fs.path.sep_str ++ digest; b.cache_root.handle.createDirPath(io, cache_dir) catch |err| return step.fail("unable to make {s}: {s}", .{ cache_dir, @errorName(err) }); const elf_bytes = std.Io.Dir.cwd().readFileAlloc(io, elf_path, gpa, .limited(8 << 20)) catch |err| return step.fail("unable to read {s}: {s}", .{ elf_path, @errorName(err) }); defer gpa.free(elf_bytes); var layout = image.fromElf(gpa, elf_bytes, self.opts) catch |err| return step.fail("image build failed: {s}", .{@errorName(err)}); defer layout.deinit(gpa); // Validate before anything is written: a rejected image must never exist on disk under a // name the flash step - or a human with esptool - would pick up. layout.validate(self.opts) catch |err| return step.fail("image violates a loader rule: {s}", .{@errorName(err)}); const out_path = try b.cache_root.join(b.allocator, &.{ cache_dir, self.basename }); std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = layout.bytes }) catch |err| return step.fail("unable to write {s}: {s}", .{ out_path, @errorName(err) }); self.generated.path = out_path; try step.writeManifestAndWatch(&man); } }; /// Read a built image back off disk. Both the flash and size steps do exactly this, which is what /// lets them work on a cache hit, in any order, or on an image from a previous build. fn readImage(step: *std.Build.Step, gpa: std.mem.Allocator, path: std.Build.LazyPath, opts: image.Options) !image.Layout { const b = step.owner; const io = b.graph.io; const p = path.getPath2(b, step); const bytes = std.Io.Dir.cwd().readFileAlloc(io, p, gpa, .limited(4 << 20)) catch |err| return step.fail("unable to read {s}: {s}", .{ p, @errorName(err) }); defer gpa.free(bytes); return image.parse(gpa, bytes, opts) catch |err| return step.fail("{s} is not a usable image: {s}", .{ p, @errorName(err) }); } const FlashStep = struct { step: std.Build.Step, bin: std.Build.LazyPath, opts: image.Options, port: []const u8, baud: serial.Baud, verify: bool, const Args = struct { port: []const u8, baud: serial.Baud, verify: bool, opts: image.Options, }; fn create(b: *std.Build, img: *ImageStep, args: Args) *FlashStep { const self = b.allocator.create(FlashStep) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "flash", .owner = b, .makeFn = make }), .bin = img.getOutput(), .opts = args.opts, .port = args.port, .baud = args.baud, .verify = args.verify, }; self.bin.addStepDependencies(&self.step); return self; } fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { const self: *FlashStep = @fieldParentPtr("step", step); const b = step.owner; const gpa = options.gpa; var layout = try readImage(step, gpa, self.bin, self.opts); defer layout.deinit(gpa); port_lock.lockUncancelable(b.graph.io); defer port_lock.unlock(b.graph.io); var port = serial.Port.open(self.port, self.baud) catch |err| return step.fail("cannot open {s}: {s}", .{ self.port, @errorName(err) }); defer port.close(); const t_open = port.nowMs(); port.resetToDownload(.{}) catch |err| return step.fail("reset failed: {s}", .{@errorName(err)}); var loader = rom.Loader{ .port = &port }; loader.sync(gpa) catch |err| return step.fail("ROM loader did not answer: {s}", .{@errorName(err)}); const t_sync = port.nowMs(); loader.attachFlash(gpa) catch |err| return step.fail("SPI attach failed: {s}", .{@errorName(err)}); loader.setFlashParams(gpa, self.opts.flash_size.bytes()) catch |err| return step.fail("SPI params failed: {s}", .{@errorName(err)}); const t_setup = port.nowMs(); loader.writeFlash(gpa, self.opts.flash_offset, layout.bytes) catch |err| return step.fail("write failed: {s}", .{@errorName(err)}); const t_write = port.nowMs(); // The ROM hashes what it actually stored; compare that with our own digest of what we sent. var expect: [16]u8 = undefined; std.crypto.hash.Md5.hash(layout.bytes, &expect, .{}); var expect_hex: [32]u8 = undefined; _ = std.fmt.bufPrint(&expect_hex, "{x}", .{&expect}) catch unreachable; if (self.verify) { var rom_md5: [32]u8 = undefined; if (loader.flashMd5(gpa, self.opts.flash_offset, @intCast(layout.bytes.len), &rom_md5)) |_| { if (!std.mem.eql(u8, &rom_md5, &expect_hex)) return step.fail( "flash verify failed: the ROM reports {s}, the image is {s}", .{ &rom_md5, &expect_hex }, ); } else |err| return step.fail("flash verify failed: {s}", .{@errorName(err)}); } const t_verify = port.nowMs(); port.resetToRun(.{}) catch {}; const t_run = port.nowMs(); var buf: [256]u8 = undefined; var stdout = std.Io.File.stdout().writer(b.graph.io, &buf); try stdout.interface.print( "flashed {d} B at 0x{x} in {d} ms: reset+sync {d}, setup {d}, write {d}, verify {d}, run {d} (md5 {s})\n", .{ layout.bytes.len, self.opts.flash_offset, t_run - t_open, t_sync - t_open, t_setup - t_sync, t_write - t_setup, t_verify - t_write, t_run - t_verify, expect_hex[0..16], }, ); try stdout.interface.flush(); } }; const MonitorStep = struct { step: std.Build.Step, port: []const u8, seconds: u32, fn create(b: *std.Build, port: []const u8, seconds: u32) *MonitorStep { const self = b.allocator.create(MonitorStep) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "monitor", .owner = b, .makeFn = make }), .port = port, .seconds = seconds, }; return self; } fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void { const self: *MonitorStep = @fieldParentPtr("step", step); const b = step.owner; port_lock.lockUncancelable(b.graph.io); defer port_lock.unlock(b.graph.io); var port = serial.Port.open(self.port, .b115200) catch |err| return step.fail("cannot open {s}: {s}", .{ self.port, @errorName(err) }); defer port.close(); port.resetToRun(.{}) catch {}; var out_buf: [4096]u8 = undefined; var stdout = std.Io.File.stdout().writer(b.graph.io, &out_buf); var buf: [1024]u8 = undefined; const deadline = port.nowMs() + @as(i64, self.seconds) * 1000; while (port.nowMs() < deadline) { const n = port.readTimeout(&buf, 200) catch break; if (n > 0) { try stdout.interface.writeAll(buf[0..n]); try stdout.interface.flush(); } } } }; /// 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 { step: std.Build.Step, port: []const u8, fn create(b: *std.Build, port: []const u8) *ResetStep { const self = b.allocator.create(ResetStep) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "reset", .owner = b, .makeFn = make }), .port = port, }; return self; } fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void { const self: *ResetStep = @fieldParentPtr("step", step); const b = step.owner; port_lock.lockUncancelable(b.graph.io); defer port_lock.unlock(b.graph.io); var port = serial.Port.open(self.port, .b115200) catch |err| return step.fail("cannot open {s}: {s}", .{ self.port, @errorName(err) }); defer port.close(); port.resetToRun(.{}) catch |err| return step.fail("reset failed: {s}", .{@errorName(err)}); } }; const SizeStep = struct { step: std.Build.Step, bin: std.Build.LazyPath, opts: image.Options, fn create(b: *std.Build, img: *ImageStep) *SizeStep { const self = b.allocator.create(SizeStep) catch @panic("OOM"); self.* = .{ .step = std.Build.Step.init(.{ .id = .custom, .name = "size", .owner = b, .makeFn = make }), .bin = img.getOutput(), .opts = img.opts, }; self.bin.addStepDependencies(&self.step); return self; } fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void { const self: *SizeStep = @fieldParentPtr("step", step); const b = step.owner; const gpa = options.gpa; var layout = try readImage(step, gpa, self.bin, self.opts); defer layout.deinit(gpa); var buf: [4096]u8 = undefined; var stdout = std.Io.File.stdout().writer(b.graph.io, &buf); const w = &stdout.interface; try w.print("image {d} B = {d} B segments + {d} B overhead, entry 0x{x}\n", .{ layout.bytes.len, layout.payload, layout.overhead, layout.entry, }); var off: usize = 24; for (layout.segments) |s| { const flash = self.opts.flash_offset + off + 8; try w.print(" {s:<6} vaddr=0x{x:0>8} len={d:>6} flash=0x{x:0>6}{s}\n", .{ @tagName(s.kind), s.addr, s.len, flash, if (s.kind == .mapped and flash % self.opts.mmu_page == s.addr % self.opts.mmu_page) " congruent" else "", }); off += 8 + s.len; } try w.print(" 24 B header + {d} B segment headers + checksum pad + 32 B sha256\n", .{layout.segments.len * 8}); try w.flush(); } };