diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 12:40:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 12:46:51 -0300 |
| commit | f5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch) | |
| tree | 2731a3ed4e51cae09e184e25778eded5fc37d1f5 /build.zig | |
| download | esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip | |
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig
turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask
ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker.
src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers;
src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip;
src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'build.zig')
| -rw-r--r-- | build.zig | 1423 |
1 files changed, 1423 insertions, 0 deletions
diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..3a22d4f --- /dev/null +++ b/build.zig @@ -0,0 +1,1423 @@ +//! 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 `<name>_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=<path> 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 "<path>"`: 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(); + } +}; |
