summaryrefslogtreecommitdiff
path: root/build.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 13:28:33 -0300
committerGabriel Schneider <[email protected]>2026-09-16 11:28:40 -0300
commitb42ecaed412be2e30b9e780eb7c9e46e1535f26f (patch)
treeb93290beb84d87df983615c5a7847e339ee7783b /build.zig
parent38bb891dd6bd0074894cbfedbf9185e303cc549e (diff)
downloadesp32p4-main.tar.gz
esp32p4-main.zip
Make the toolchain a package another build can drive, and move the editor's glue to the editorHEADmain
Diffstat (limited to 'build.zig')
-rw-r--r--build.zig723
1 files changed, 466 insertions, 257 deletions
diff --git a/build.zig b/build.zig
index 6b62524..fef6fd4 100644
--- a/build.zig
+++ b/build.zig
@@ -10,13 +10,41 @@
//! 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.
+//!
+//! ## As a dependency
+//!
+//! Everything above is also callable from another package's build.zig: `pub fn chipTarget`,
+//! `pub fn firmware`, `pub fn hostTools` and the `pub` step types below are the whole surface, and
+//! this file's own `build()` drives them, so there is one implementation of each and no second copy
+//! to drift. A dependent obtains them with `@import("zig_p4")` and passes THIS package's builder -
+//! `b.dependency("zig_p4", .{}).builder` - to every one of them, because they resolve source paths
+//! and cache inputs relative to this root.
const std = @import("std");
-const image = @import("tools/image.zig");
-const serial = @import("tools/serial.zig");
-const rom = @import("tools/rom.zig");
+// `pub` because a dependent needs these types to talk to the steps below: `image.Options` is the
+// argument of every image, flash and size step, `serial.Baud` of every port step, and `rom.Loader`
+// is the flasher itself. Re-exported here rather than made reachable some other way because
+// build.zig is the one file a dependent can `@import`.
+pub const image = @import("tools/image.zig");
+pub const serial = @import("tools/serial.zig");
+pub const rom = @import("tools/rom.zig");
pub fn build(b: *std.Build) void {
+ // Consumed as a dependency, this `build()` has nothing to offer and must not run.
+ //
+ // Zig runs a dependency's `build()` eagerly, at configure time, on every build of the
+ // dependent - whatever its target. Everything below the register module is firmware, and
+ // building the register module means reading ESP-IDF's headers from disk, which `idfRegisters`
+ // exits the process over when the checkout is absent. So without this line, merely DECLARING
+ // this package would make an ESP-IDF checkout a hard requirement of every build of every
+ // dependent, including the ones that never mention this chip. That is the same failure mode -
+ // in the same direction, from the other side - as the nesting recorded under `-Dpardes` below.
+ //
+ // `pkg_hash` is `""` in the root package and the package hash otherwise (std/Build.zig:93-94),
+ // which is exactly the question being asked. A dependent calls the `pub fn`s directly with its
+ // own options, so there is nothing here it loses.
+ if (b.pkg_hash.len != 0) return;
+
// ---------------------------------------------------------------- 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;
@@ -34,8 +62,8 @@ pub fn build(b: *std.Build) void {
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;
// `-Dpardes` swaps in the editor as the application. It is a distinct option rather than just
- // `-Dapp=src/pardes/app.zig` because it also resolves the lazy `pardes` dependency and raises
- // the default stack: the core recurses through layout and 8 KiB is not enough for it.
+ // `-Dapp=../02-pardes-code/src/esp32p4/app.zig` because it also resolves the lazy `pardes` dependency
+ // and raises the default stack: the core recurses through layout and 8 KiB is not enough for it.
const pardes_app = b.option(bool, "pardes", "build the pardes editor as the application (needs ../02-pardes-code)") orelse false;
const stack_size = b.option(u32, "stack", "stack size in bytes (default 8192, or 32768 under -Dpardes)") orelse
@as(u32, if (pardes_app) 32768 else 8192);
@@ -44,16 +72,7 @@ pub fn build(b: *std.Build) void {
// 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 }),
- });
+ const target = chipTarget(b);
// ---------------------------------------------------------------- the chip's registers
// Every peripheral register of the P4, taken from ESP-IDF's own `*_reg.h` headers by
@@ -65,13 +84,18 @@ pub fn build(b: *std.Build) void {
// `*_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;
+ //
+ // The two knobs are declared here, where the module used to be built, so `zig build --help`
+ // still lists them in this position; `firmware` below does the building, because a dependent
+ // needs the same module set built for a target it chooses.
+ const idf_opt = b.option([]const u8, "idf", "ESP-IDF checkout, for the register headers (default $IDF_PATH or ~/esp/esp-idf)");
+ const idf_hw_ver = b.option(u8, "idf-hw-ver", "register header set: 1 for pre-v3 silicon (default), 3 for v3+") orelse 1;
+
// ---------------------------------------------------------------- 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();
+ // what keeps the image ~1 KB instead of ~66 KB (see tools/image.zig). `firmware` writes it.
+ //
// 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.
@@ -93,20 +117,12 @@ pub fn build(b: *std.Build) void {
// 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);
- // On-board attribution: time `pardes_p4_input` and `pardes_p4_render` separately and print the
+ // On-board attribution: time `pardes_esp32p4_input` and `pardes_esp32p4_render` separately and print the
// cycle counts. Off by default because it puts a line on the wire per frame, which is the very
// resource being measured - it answers "where did the 34 ms go", not "how fast is it".
- options.addOption(bool, "prof", b.option(bool, "prof", "print per-phase cycle counts (pardes)") orelse false);
+ const prof = b.option(bool, "prof", "print per-phase cycle counts (pardes)") orelse false;
// The CPU clock, in MHz. The bootloader leaves 90; the CPLL is already at 360, so 180 and 360
// are a divider change away and nothing else (see hal/clkrst.zig:setCpuFreq). Opt-in rather
// than default because it is the one setting here that changes how every other timing in the
@@ -114,63 +130,43 @@ pub fn build(b: *std.Build) void {
const cpu_mhz = b.option(u16, "cpu-mhz", "HP CPU clock: 90 (bootloader default), 180 or 360") orelse 90;
if (cpu_mhz != 90 and cpu_mhz != 180 and cpu_mhz != 360)
std.debug.panic("-Dcpu-mhz must be 90, 180 or 360; the P4's CPLL divides 360 by 4, 2 or 1", .{});
- options.addOption(u16, "cpu_mhz", cpu_mhz);
- 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"),
+ // The board-support modules, the descriptor object and the linker script, in one call - the
+ // same call a dependent makes.
+ const fw = firmware(b, .{
.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 }},
- }),
+ .idf = idf_opt,
+ .idf_hw_ver = idf_hw_ver,
+ .led_pin = led_pin,
+ .stack_size = stack_size,
+ .prof = prof,
+ .cpu_mhz = cpu_mhz,
+ .wifi_ssid = wifi_ssid,
+ .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,
+ .full_descriptor = descriptor == .full,
+ .min_rev_full = min_rev,
+ .max_rev_full = max_rev,
+ // ESP-IDF's 111 peripheral instance addresses, spliced into the script as text, and only
+ // when the oracle is what needs them. See `linkerScript` for why it is text and not an
+ // INCLUDE of a path.
+ .peripherals_ld = if (oracle) readPeripheralsLd(b, resolveIdf(b, idf_opt)) else null,
});
// The app root still comes from `-Dapp`, so pointing that at a different shell over the same
- // module stays possible.
- const app_source = b.option([]const u8, "app", "root source file (default src/main.zig, or src/pardes/app.zig under -Dpardes)") orelse
- if (pardes_app) "src/pardes/app.zig" else "src/main.zig";
+ // module stays possible. Under `-Dpardes` that root now lives in the EDITOR's checkout, reached
+ // the same sibling-relative way `-Dpardes-obj` below reaches its object. It belongs there: every
+ // line of it is a statement about that one program - the heap span that decides the grid, the
+ // input chunk sized against what one keystroke costs, the loop's read-tick-render shape - so the
+ // repository that owns the program owns the file, and this one reads it. Overridable exactly as
+ // before, and the absolute/relative branch just below already resolves a path that leaves this
+ // build root, which is what makes the `../` default work with no further plumbing.
+ const app_source = b.option([]const u8, "app", "root source file (default src/main.zig, or ../02-pardes-code/src/esp32p4/app.zig under -Dpardes)") orelse
+ if (pardes_app) "../02-pardes-code/src/esp32p4/app.zig" else "src/main.zig";
const app = b.addExecutable(.{
.name = "app",
.root_module = b.createModule(.{
@@ -186,11 +182,11 @@ pub fn build(b: *std.Build) void {
.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 },
+ .{ .name = "config", .module = fw.config },
+ .{ .name = "soc", .module = fw.soc },
+ .{ .name = "hal", .module = fw.hal },
+ .{ .name = "mmio", .module = fw.mmio },
+ .{ .name = "regs", .module = fw.regs },
// The wire protocol `examples/uartperf.zig` answers, imported rather than copied so
// the firmware and the host tool cannot disagree about a frame. It is deliberately
// free of any OS dependency for exactly this reason: one file, two targets.
@@ -199,15 +195,17 @@ pub fn build(b: *std.Build) void {
.target = target,
.optimize = optimize,
}) },
+ // `std.Io` for this chip, and the general-purpose allocator. Imported
+ // unconditionally: an application that never names one costs nothing, because an
+ // unreferenced module emits no code.
+ .{ .name = "io", .module = fw.io },
+ .{ .name = "heap", .module = fw.heap },
},
}),
});
- // 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);
+ idfReference(b, app.root_module, fw.idf_path, fw.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(.{
@@ -215,65 +213,50 @@ pub fn build(b: *std.Build) void {
.target = target,
.optimize = optimize,
.imports = &.{
- .{ .name = "hal", .module = hal_mod },
- .{ .name = "regs", .module = regs_mod },
- .{ .name = "mmio", .module = mmio_mod },
+ .{ .name = "hal", .module = fw.hal },
+ .{ .name = "regs", .module = fw.regs },
+ .{ .name = "mmio", .module = fw.mmio },
},
});
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);
- // The general-purpose allocator, its own module for exactly the reason given above for `io`: a
- // module's imports cannot escape its root directory, so neither src/pardes/ nor examples/ can
- // reach src/net/heap.zig as a file. Pointed at the existing file rather than copied - `Heap` is
- // a coalescing free-list over one caller-supplied span and has nothing to do with the radio; it
- // lives under src/net/ only because ESP-Hosted needed it first. The file has zero `export`s, so
- // compiling it into two modules cannot collide.
- //
- // Added unconditionally, like `io`: an application that never imports it costs nothing, because
- // an unreferenced module emits no code.
- const heap_mod = b.createModule(.{
- .root_source_file = b.path("src/net/heap.zig"),
- .target = target,
- .optimize = optimize,
- .single_threaded = true,
- });
- app.root_module.addImport("heap", heap_mod);
+ // The input-rescue policy, `src/esp32p4/input_rescue.zig` in the editor's checkout, is
+ // deliberately NOT registered on this application root, though it used to be. Nothing that
+ // `-Dapp` can name imports it as a module: the editor's application root reads the policy
+ // through its own `uart.zig`, as a sibling FILE beside it, and the GPIO 9P image does not read
+ // it at all. Zig hashes every registered module's root source on every compile, so registering
+ // it made EVERY application here fail to build wherever the editor was not beside this checkout
+ // - `zig build`, every `examples/` root, and a fresh clone of this repository, with
+ // `failed to check cache: ../02-pardes-code/src/esp32p4/input_rescue.zig file_hash FileNotFound`.
+ // `zig build selftest` is the one root that imports it by module name, and wires it itself.
- // The input-rescue policy as a module, so `examples/selftest.zig` can run its checks ON THE DIE
- // and not only on the host. Same file the firmware's UART uses. Added unconditionally, like
- // `heap` above: an application that never imports it costs nothing, because an unreferenced
- // module emits no code.
- app.root_module.addImport("input_rescue", b.createModule(.{
- .root_source_file = b.path("src/pardes/input_rescue.zig"),
- .target = target,
- .optimize = optimize,
- .single_threaded = true,
- }));
+ // Pardes's GPIO filesystem uses the shared freestanding 9P protocol, which lives in the
+ // published cloud9 package pinned in build.zig.zon. The module is built HERE, over that
+ // package's root source, rather than taken from its own `addModule`: `single_threaded` is a
+ // property of the module, no consumer can re-flag a module the dependency created, and this
+ // target is a chip with no threads to synchronise. The pin makes the source a fetched package;
+ // it changes nothing about how this module compiles.
+ if (std.mem.endsWith(u8, app_source, "esp32p4_9p.zig")) {
+ app.root_module.addImport("cloud9", b.createModule(.{
+ .root_source_file = b.dependency("cloud9", .{
+ .target = target,
+ .optimize = optimize,
+ }).path("src/root.zig"),
+ .target = target,
+ .optimize = optimize,
+ .single_threaded = true,
+ }));
+ }
if (pardes_app) {
// The editor arrives as a linked OBJECT, not as a package dependency, and that is a
// measurement rather than a preference.
//
// The obvious design was `build.zig.zon` with a path dependency on ../02-pardes-code, and
- // `dep.module("pardes_p4")`. It was written, and it broke EVERY build in this repo -
+ // `dep.module("pardes_p4")` (the platform tag was spelled `p4` then; it is `esp32p4` now,
+ // and no module of either name exists, because this is the design that was abandoned). It
+ // was written, and it broke EVERY build in this repo -
// `zig build`, every example, the oracle - because merely DECLARING it nests pardes's
// ~30-package graph under this one. Two failures, both from just the declaration:
//
@@ -288,10 +271,10 @@ pub fn build(b: *std.Build) void {
// Neither is fixable from this side, and both would come back the next time the editor
// gained a dependency. So the seam is a file instead: pardes's own build emits one
// freestanding object exporting a small C ABI, and this links it. The consequences are all
- // improvements - this repo keeps having no manifest and no dependencies, the editor's
+ // improvements - this repo's graph gains no editor packages, the editor's
// renderer stays next to the vaxis it needs, and the boundary is bytes in / bytes out.
- const obj = b.option([]const u8, "pardes-obj", "path to pardes's p4 object (default ../02-pardes-code/zig-out/pardes-p4.o)") orelse
- "../02-pardes-code/zig-out/pardes-p4.o";
+ const obj = b.option([]const u8, "pardes-obj", "path to pardes's p4 object (default ../02-pardes-code/zig-out/pardes-esp32p4.o)") orelse
+ "../02-pardes-code/zig-out/pardes-esp32p4.o";
// THE GRID, set from here, because the object is where it is baked and the object is built
// by the other repository. Without this, changing the geometry is two commands in two
@@ -308,10 +291,10 @@ pub fn build(b: *std.Build) void {
const theme_anim = b.option(bool, "theme-animation", "fade chrome colors across a theme change; rebuilds pardes's object (default off on this transport)");
if ((cols != null or rows != null or theme_anim != null) and b.user_input_options.get("pardes-obj") == null) {
const editor_dir = std.fs.path.dirname(std.fs.path.dirname(obj) orelse ".") orelse "..";
- const build_editor = b.addSystemCommand(&.{ b.graph.zig_exe, "build", "-Dplatform=p4" });
+ const build_editor = b.addSystemCommand(&.{ b.graph.zig_exe, "build", "-Dplatform=esp32p4" });
build_editor.setCwd(.{ .cwd_relative = editor_dir });
- if (cols) |c| build_editor.addArg(b.fmt("-Dp4-cols={d}", .{c}));
- if (rows) |v| build_editor.addArg(b.fmt("-Dp4-rows={d}", .{v}));
+ if (cols) |c| build_editor.addArg(b.fmt("-Desp32p4-cols={d}", .{c}));
+ if (rows) |v| build_editor.addArg(b.fmt("-Desp32p4-rows={d}", .{v}));
if (theme_anim) |a| build_editor.addArg(b.fmt("-Dtheme-animation={}", .{a}));
// Its output is a file this build then links, and the linker has no idea it is generated,
// so the ordering has to be said out loud.
@@ -344,28 +327,25 @@ pub fn build(b: *std.Build) void {
.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 },
+ .{ .name = "config", .module = fw.config },
+ .{ .name = "soc", .module = fw.soc },
+ .{ .name = "hal", .module = fw.hal },
+ .{ .name = "mmio", .module = fw.mmio },
+ .{ .name = "regs", .module = fw.regs },
+ .{ .name = "io", .module = fw.io },
},
});
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);
+ hostedC(b, app.root_module, fw.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);
+ // The linker script, ENTRY(_start), the descriptor object and the register census: the one
+ // arrangement every firmware here - and every dependent's - has to get right, in one call.
+ fw.attach(app);
+ // --gc-sections is the caller's, not `attach`'s: the selftest image below is linked without it,
+ // and switching that on would change bytes this file has no business changing.
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.
@@ -414,77 +394,47 @@ pub fn build(b: *std.Build) void {
"interactive console baud (default 115200, the rate the bootloader leaves UART0 at)",
) orelse .b115200;
- // A real host binary, and not an in-process step like every other tool here, for two reasons.
- // It runs with no build runner at all when the board is already flashed; and as a child process
- // under `Step.Run` with inherited stdio it gets std's stderr lock held for its whole lifetime
- // (std/Build/Step/Run.zig:1588-1592), which is what stops `std.Progress` repainting the step
- // tree over an interactive session. The in-process step this replaced never took that lock, and
- // shredded the editor's screen with fragments of `[11/13] steps`.
- const con_exe = b.addExecutable(.{
- .name = "p4-console",
- .root_module = b.createModule(.{
- .root_source_file = b.path("tools/console_main.zig"),
- .target = b.graph.host,
- .optimize = .ReleaseSafe,
- }),
- });
- // Installed by the console steps, and deliberately NOT by `install`: the default build still
- // lands exactly one file in zig-out, the flashable image. Anyone who has attached once has
- // zig-out/bin/p4-console afterwards, which is the copy to run when the board is already
- // flashed and no build is wanted.
- const con_install = b.addInstallArtifact(con_exe, .{});
+ // The two host binaries, and why they are binaries rather than in-process steps, are at
+ // `hostTools`.
+ const tools = hostTools(b);
const con_args: []const []const u8 = &.{ "--port", port_path, "--baud", b.fmt("{d}", .{console_baud.rate()}) };
- const con = b.addRunArtifact(con_exe);
+ const con = b.addRunArtifact(tools.console);
con.addArgs(con_args);
// Inherited stdio is the whole point: the board's bytes and the user's keystrokes pass through
// untouched, and the terminal the child sees is the real one, so its ioctls answer.
con.stdio = .inherit;
- con.step.dependOn(&con_install.step);
+ con.step.dependOn(&tools.console_install.step);
b.step("console", "attach a terminal to the application already on the board").dependOn(&con.step);
// Ordered, for the same reason `run` is: an unordered `flash console` lets the console reset
// the board out from under the writer.
- const run_con = b.addRunArtifact(con_exe);
+ const run_con = b.addRunArtifact(tools.console);
run_con.addArgs(con_args);
run_con.stdio = .inherit;
- run_con.step.dependOn(&con_install.step);
+ run_con.step.dependOn(&tools.console_install.step);
run_con.step.dependOn(&flash.step);
b.step("interact", "flash the image, then attach a terminal").dependOn(&run_con.step);
- // The measuring instrument. Its own binary for the same reason the console is: it drives the
- // port for tens of seconds and must not have the build runner repainting a progress tree into
- // the middle of a timed transfer. It shares `tools/perfproto.zig` with the firmware responder,
- // so a frame the host writes and a frame the board parses cannot drift apart.
- const bench_proto = b.createModule(.{
- .root_source_file = b.path("tools/perfproto.zig"),
- .target = b.graph.host,
- .optimize = .ReleaseSafe,
- });
- const bench_exe = b.addExecutable(.{
- .name = "p4-bench",
- .root_module = b.createModule(.{
- .root_source_file = b.path("tools/bench_main.zig"),
- .target = b.graph.host,
- .optimize = .ReleaseSafe,
- .imports = &.{.{ .name = "perfproto", .module = bench_proto }},
- }),
- });
- const bench_install = b.addInstallArtifact(bench_exe, .{});
- const bench = b.addRunArtifact(bench_exe);
+ const bench = b.addRunArtifact(tools.bench);
bench.addArgs(&.{ "--port", port_path });
bench.stdio = .inherit;
- bench.step.dependOn(&bench_install.step);
+ bench.step.dependOn(&tools.bench_install.step);
// `zig build selftest` - its OWN application, image and flash chain, so it is one command with no
// flags to remember. Sharing the `-Dapp` pipeline would have meant `zig build selftest
- // -Dapp=examples/selftest.zig`, which is the kind of incantation that turns a suite into
- // something nobody runs. The modules are the ones its checks need and no more.
+ // -Dapp=../02-pardes-code/src/esp32p4/selftest.zig`, which is the kind of incantation that turns a
+ // suite into something nobody runs. The modules are the ones its checks need and no more.
+ //
+ // The suite lives in the editor's checkout, with the code it makes claims about: byte-at-a-time
+ // `std.mem.eql` on this target, the lone-ESC decode on a 115200 line, the transmit-FIFO
+ // backpressure, the heap span the grid is cut from. Read from here across the same
+ // sibling-relative seam as `-Dapp` and `-Dpardes-obj`, so this step is unchanged in behaviour.
const selftest_exe = b.addExecutable(.{
.name = "selftest",
.root_module = b.createModule(.{
- .root_source_file = b.path("examples/selftest.zig"),
+ .root_source_file = b.path("../02-pardes-code/src/esp32p4/selftest.zig"),
.target = target,
.optimize = optimize,
.strip = true,
@@ -493,14 +443,14 @@ pub fn build(b: *std.Build) void {
.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 },
- .{ .name = "heap", .module = heap_mod },
+ .{ .name = "config", .module = fw.config },
+ .{ .name = "soc", .module = fw.soc },
+ .{ .name = "hal", .module = fw.hal },
+ .{ .name = "mmio", .module = fw.mmio },
+ .{ .name = "regs", .module = fw.regs },
+ .{ .name = "heap", .module = fw.heap },
.{ .name = "input_rescue", .module = b.createModule(.{
- .root_source_file = b.path("src/pardes/input_rescue.zig"),
+ .root_source_file = b.path("../02-pardes-code/src/esp32p4/input_rescue.zig"),
.target = target,
.optimize = optimize,
.single_threaded = true,
@@ -508,15 +458,7 @@ pub fn build(b: *std.Build) void {
},
}),
});
- selftest_exe.setLinkerScript(app.linker_script.?);
- selftest_exe.link_function_sections = true;
- selftest_exe.link_data_sections = true;
- selftest_exe.entry = .{ .symbol_name = "_start" };
- // The app descriptor, without which the image has nothing at offset 0x20 for the bootloader to
- // read and the board boots into silence - which is exactly how the first run of this step failed,
- // and it looks identical to a suite that hung.
- selftest_exe.root_module.addObject(appdesc_obj);
- selftest_exe.step.dependOn(registers.census);
+ fw.attach(selftest_exe);
const selftest_img = ImageStep.create(b, selftest_exe, img.opts);
const selftest_flash = FlashStep.create(b, selftest_img, .{
.port = flash.port,
@@ -593,20 +535,6 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(console_tests).step);
- // The input-rescue policy: drain the receiver while spinning on a full transmitter. This is a
- // decision rather than a register access, and it was a measured bug - a 200-byte burst typed
- // into a long frame lost 88 bytes on the die - so it is worth a test that fails without the
- // fix. `pump` takes its port as `anytype` precisely so the same code can run against a fake
- // with a two-byte FIFO here and against UART0 on the board.
- const rescue_tests = b.addTest(.{
- .root_module = b.createModule(.{
- .root_source_file = b.path("src/pardes/input_rescue.zig"),
- .target = b.graph.host,
- .optimize = .Debug,
- }),
- });
- test_step.dependOn(&b.addRunArtifact(rescue_tests).step);
-
// The measurement protocol. These are the tests that keep a throughput number honest: that a
// frame round-trips, that a short read is "incomplete" rather than "invalid", that a lost byte
// mid-stream changes the CRC, and that the pattern generator does not repeat on a 256-byte
@@ -634,14 +562,14 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/mmio.zig"),
.target = b.graph.host,
.optimize = .Debug,
- .imports = &.{.{ .name = "regs", .module = regs_mod }},
+ .imports = &.{.{ .name = "regs", .module = fw.regs }},
});
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 = "regs", .module = fw.regs },
.{ .name = "mmio", .module = host_mmio },
},
});
@@ -671,7 +599,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "soc", .module = host_soc },
.{ .name = "hal", .module = host_hal },
.{ .name = "mmio", .module = host_mmio },
- .{ .name = "regs", .module = regs_mod },
+ .{ .name = "regs", .module = fw.regs },
},
}),
});
@@ -679,6 +607,283 @@ pub fn build(b: *std.Build) void {
}
}
+// ---------------------------------------------------------------------------- public build API
+//
+// What another package drives this toolchain through, and the only thing `build()` above is: a
+// caller of these. Every one of them takes THIS package's `*std.Build` - a dependent passes
+// `b.dependency("zig_p4", .{}).builder` - because they resolve source paths, and the image step's
+// cache inputs, relative to this build root. There is no second implementation anywhere: what a
+// dependent compiles is what `zig build` here compiles.
+
+/// The chip, as a target.
+///
+/// Deliberately not a `standardTargetOption`: there is one processor here, and offering to build
+/// this firmware for anything else would be offering a build that cannot run.
+pub fn chipTarget(b: *std.Build) std.Build.ResolvedTarget {
+ return 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 }),
+ });
+}
+
+/// What `firmware` has to be told. Only the target and the optimize mode have no default, because a
+/// caller has already had to decide both by the time it creates its own root module - and passing
+/// the same pair to both is what keeps the application and the board support one link.
+pub const FirmwareOptions = struct {
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+ /// The ESP-IDF checkout the register headers are read from. `null` resolves $IDF_PATH, then
+ /// ~/esp/esp-idf, which is what `-Didf` does when it is not given.
+ idf: ?[]const u8 = null,
+ /// Which register header set: 1 for pre-v3 silicon, 3 for v3+. Load-bearing rather than
+ /// cosmetic - 61 macros keep their name and change their value between the two - so hal.zig
+ /// comptime-asserts the value this bakes in.
+ idf_hw_ver: u8 = 1,
+ led_pin: u8 = 20,
+ /// Bytes of `.stack` in the generated script. 8192 is enough for everything in examples/; the
+ /// editor recurses through layout and needs 32768.
+ stack_size: u32 = 8192,
+ prof: bool = false,
+ cpu_mhz: u16 = 90,
+ wifi_ssid: []const u8 = "",
+ wifi_psk: []const u8 = "",
+ full_descriptor: bool = false,
+ min_rev_full: u16 = 100,
+ max_rev_full: u16 = 199,
+ /// ESP-IDF's peripherals.ld as TEXT, spliced into the linker script - only the differential
+ /// oracle needs it, and `linkerScript` records why it is text and not an INCLUDE of a path.
+ peripherals_ld: ?[]const u8 = null,
+};
+
+/// Everything a firmware executable links against: the board-support modules, the app descriptor
+/// object, the generated linker script, and the register census that gates all of it.
+pub const Firmware = struct {
+ /// `-Dled`, `-Dstack`, `-Dprof`, `-Dcpu-mhz`, the Wi-Fi credentials and the descriptor's
+ /// revision window, as `@import("config")`.
+ config: *std.Build.Module,
+ soc: *std.Build.Module,
+ /// What applications and drivers use. `regs` beside it 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.
+ hal: *std.Build.Module,
+ mmio: *std.Build.Module,
+ regs: *std.Build.Module,
+ /// `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.
+ io: *std.Build.Module,
+ /// The general-purpose allocator, its own module for the same reason as `io`: a module's
+ /// imports cannot escape its root directory, so no application outside src/net/ can reach
+ /// src/net/heap.zig as a file. `Heap` is a coalescing free-list over one caller-supplied span
+ /// and has nothing to do with the radio; it lives under src/net/ only because ESP-Hosted
+ /// needed it first. The file has zero `export`s, so compiling it into two modules cannot
+ /// collide.
+ heap: *std.Build.Module,
+ /// The app descriptor, as an OBJECT rather than something to import. An application that
+ /// merely `@import`ed 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. `attach` links it.
+ appdesc: *std.Build.Step.Compile,
+ linker_script: std.Build.LazyPath,
+ /// The poison census. A gate, not a side effect: nothing may compile against the register
+ /// module without it having been counted. `attach` wires it.
+ census: *std.Build.Step,
+ /// Where the headers came from, for a caller that needs the same checkout for something else -
+ /// `hostedC` and `idfReference` here both do.
+ idf_path: []const u8,
+ hw_ver: u8,
+
+ /// Put a firmware executable on this chip: the script that places it, the entry symbol the
+ /// bootloader jumps to, the descriptor the bootloader reads, and the census.
+ ///
+ /// One call because every one of the four is a way to boot into silence when forgotten, and
+ /// three of them were, in this order:
+ ///
+ /// * no descriptor object, so nothing sits at image offset 0x20 - which is how the first run
+ /// of `zig build selftest` failed, and it looks exactly like a suite that hung;
+ /// * the descriptor linked but garbage-collected, because no code reads it and only the
+ /// bootloader does. Linking it as an object keeps it regardless of whether the application
+ /// source happens to mention it;
+ /// * no `ENTRY(_start)`, which LLD resolves to an address that is not the reset vector.
+ ///
+ /// `--gc-sections` is deliberately NOT set here: `zig build selftest` links without it, and
+ /// which sections an image keeps is a decision that belongs to the image, not to this.
+ pub fn attach(self: Firmware, exe: *std.Build.Step.Compile) void {
+ exe.setLinkerScript(self.linker_script);
+ exe.entry = .{ .symbol_name = "_start" };
+ exe.root_module.addObject(self.appdesc);
+ exe.step.dependOn(self.census);
+ exe.link_function_sections = true;
+ exe.link_data_sections = true;
+ }
+};
+
+/// The board support, built for the caller's target and optimize mode.
+///
+/// The modules are real modules, so an application can live anywhere - another directory, another
+/// package - and still `@import("soc")`. src/ holds one copy of each; examples/ holds none.
+///
+/// Calling this twice in one build graph is safe and nearly free: the second call creates a second
+/// set of module objects over the same files, and the translate-c run behind `regs` is keyed on its
+/// input, so the register work happens once.
+pub fn firmware(b: *std.Build, opts: FirmwareOptions) Firmware {
+ const target = opts.target;
+ const optimize = opts.optimize;
+
+ const registers = idfRegisters(b, target, optimize, opts.idf, opts.idf_hw_ver);
+
+ const options = b.addOptions();
+ options.addOption(u8, "led_pin", opts.led_pin);
+ options.addOption(u32, "stack_size", opts.stack_size);
+ options.addOption(bool, "prof", opts.prof);
+ options.addOption(u16, "cpu_mhz", opts.cpu_mhz);
+ options.addOption([]const u8, "wifi_ssid", opts.wifi_ssid);
+ options.addOption([]const u8, "wifi_psk", opts.wifi_psk);
+ options.addOption(bool, "full_descriptor", opts.full_descriptor);
+ options.addOption(u16, "min_rev_full", opts.min_rev_full);
+ options.addOption(u16, "max_rev_full", opts.max_rev_full);
+ const config_mod = options.createModule();
+
+ const soc_mod = b.createModule(.{
+ .root_source_file = b.path("src/soc.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ const mmio_mod = b.createModule(.{
+ .root_source_file = b.path("src/mmio.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "regs", .module = registers.mod }},
+ });
+ const hal_mod = b.createModule(.{
+ .root_source_file = b.path("src/hal.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{
+ .{ .name = "regs", .module = registers.mod },
+ .{ .name = "mmio", .module = mmio_mod },
+ },
+ });
+ soc_mod.addImport("hal", hal_mod);
+
+ return .{
+ .config = config_mod,
+ .soc = soc_mod,
+ .hal = hal_mod,
+ .mmio = mmio_mod,
+ .regs = registers.mod,
+ .io = 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 = registers.mod },
+ },
+ }),
+ .heap = b.createModule(.{
+ .root_source_file = b.path("src/net/heap.zig"),
+ .target = target,
+ .optimize = optimize,
+ .single_threaded = true,
+ }),
+ .appdesc = 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 }},
+ }),
+ }),
+ // A WriteFiles directory rather than a checked-in file, so `-Dstack` and the descriptor
+ // size are build inputs the script follows.
+ .linker_script = b.addWriteFiles().add("app.ld", linkerScript(b, opts.stack_size, opts.peripherals_ld)),
+ .census = registers.census,
+ .idf_path = registers.idf_path,
+ .hw_ver = registers.hw_ver,
+ };
+}
+
+/// The two host-side binaries: the interactive console and the link benchmark.
+///
+/// Real host binaries, and not in-process steps like every other tool here, for two reasons. They
+/// run with no build runner at all when the board is already flashed; and as a child process under
+/// `Step.Run` with inherited stdio they get std's stderr lock held for their whole lifetime
+/// (std/Build/Step/Run.zig:1588-1592), which is what stops `std.Progress` repainting the step tree
+/// over an interactive session or into the middle of a timed transfer. The in-process step this
+/// replaced never took that lock, and shredded the editor's screen with fragments of
+/// `[11/13] steps`.
+pub const HostTools = struct {
+ /// `p4-console`: keystrokes in, screen out, against whatever is already on the board.
+ console: *std.Build.Step.Compile,
+ /// Wired by the console steps, and deliberately NOT by `install`: the default build still
+ /// lands exactly one file in zig-out, the flashable image. Anyone who has attached once has
+ /// zig-out/bin/p4-console afterwards, which is the copy to run when the board is already
+ /// flashed and no build is wanted. Depend on this from the run step, never install it twice:
+ /// two install steps for one artifact race to write the same path.
+ /// Owned by this package's builder, so a dependent depending on it installs into this
+ /// package's own prefix (measured: `.zig-cache/i/<hash>/bin/p4-console`). A dependent that
+ /// wants the binary in its own zig-out calls `b.addInstallArtifact(tools.console, .{})` with
+ /// its own builder instead, and depends on that.
+ console_install: *std.Build.Step.InstallArtifact,
+ /// `p4-bench`: verified throughput each way, and latency. Shares `tools/perfproto.zig` with
+ /// the firmware responder, so a frame the host writes and a frame the board parses cannot
+ /// drift apart.
+ bench: *std.Build.Step.Compile,
+ bench_install: *std.Build.Step.InstallArtifact,
+};
+
+pub fn hostTools(b: *std.Build) HostTools {
+ const con_exe = b.addExecutable(.{
+ .name = "p4-console",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("tools/console_main.zig"),
+ .target = b.graph.host,
+ .optimize = .ReleaseSafe,
+ }),
+ });
+ const bench_exe = b.addExecutable(.{
+ .name = "p4-bench",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("tools/bench_main.zig"),
+ .target = b.graph.host,
+ .optimize = .ReleaseSafe,
+ .imports = &.{.{ .name = "perfproto", .module = b.createModule(.{
+ .root_source_file = b.path("tools/perfproto.zig"),
+ .target = b.graph.host,
+ .optimize = .ReleaseSafe,
+ }) }},
+ }),
+ });
+ return .{
+ .console = con_exe,
+ .console_install = b.addInstallArtifact(con_exe, .{}),
+ .bench = bench_exe,
+ .bench_install = b.addInstallArtifact(bench_exe, .{}),
+ };
+}
+
+/// Where ESP-IDF is. A function because two callers must reach the same answer: the register
+/// module, and - under `-Doracle` - the peripheral symbols spliced into the linker script. Those
+/// two disagreeing would link one header set's register addresses against another's instance
+/// addresses, which is a wrong-register bug with no error anywhere.
+fn resolveIdf(b: *std.Build, opt: ?[]const u8) []const u8 {
+ return opt orelse
+ b.graph.environ_map.get("IDF_PATH") orelse
+ b.pathJoin(&.{ b.graph.environ_map.get("HOME") orelse "", "esp", "esp-idf" });
+}
+
/// The ESP32-P4's entire register map as a Zig module, via `zig translate-c` over ESP-IDF's own
/// register headers.
///
@@ -694,11 +899,10 @@ fn idfRegisters(
b: *std.Build,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
+ idf_opt: ?[]const u8,
+ hw_ver: u8,
) 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 idf = resolveIdf(b, idf_opt);
const reg_dir = b.pathJoin(&.{ idf, "components", "soc", "esp32p4", "register", b.fmt("hw_ver{d}", .{hw_ver}), "soc" });
@@ -1467,20 +1671,25 @@ fn linkerScript(b: *std.Build, stack_size: u32, peripherals_ld: ?[]const u8) []c
// 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.
+//
+// All `pub`, so a dependent gets these steps rather than a second implementation of them. Their `b`
+// is this package's builder, like every other function here: `ImageStep` names tools/image.zig and
+// build.zig as cache inputs by root-relative path, so a builder rooted anywhere else would hash the
+// wrong files - or none.
/// 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 {
+pub 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 {
+ pub 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(.{
@@ -1499,7 +1708,7 @@ const ImageStep = struct {
return self;
}
- fn getOutput(self: *ImageStep) std.Build.LazyPath {
+ pub fn getOutput(self: *ImageStep) std.Build.LazyPath {
return .{ .generated = .{ .file = &self.generated } };
}
@@ -1615,12 +1824,12 @@ fn describeLayout(w: *std.Io.Writer, l: image.Layout, opts: image.Options) !void
/// the moment you actually need the table is when it did not. This one starts from the ELF and
/// reports the loader verdict as text instead of as a failed build, so an image the bootloader would
/// refuse can still be inspected.
-const LayoutStep = struct {
+pub const LayoutStep = struct {
step: std.Build.Step,
elf: std.Build.LazyPath,
opts: image.Options,
- fn create(b: *std.Build, app: *std.Build.Step.Compile, opts: image.Options) *LayoutStep {
+ pub fn create(b: *std.Build, app: *std.Build.Step.Compile, opts: image.Options) *LayoutStep {
const self = b.allocator.create(LayoutStep) catch @panic("OOM");
self.* = .{
.step = std.Build.Step.init(.{ .id = .custom, .name = "layout", .owner = b, .makeFn = make }),
@@ -1686,7 +1895,7 @@ fn failPort(step: *std.Build.Step, port: []const u8, err: anyerror) anyerror {
return step.fail("cannot open {s}: AccessDenied\n\n" ++ serial.access_denied_help, .{port});
}
-const FlashStep = struct {
+pub const FlashStep = struct {
step: std.Build.Step,
bin: std.Build.LazyPath,
opts: image.Options,
@@ -1694,14 +1903,14 @@ const FlashStep = struct {
baud: serial.Baud,
verify: bool,
- const Args = struct {
+ pub const Args = struct {
port: []const u8,
baud: serial.Baud,
verify: bool,
opts: image.Options,
};
- fn create(b: *std.Build, img: *ImageStep, args: Args) *FlashStep {
+ pub 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 }),
@@ -1778,12 +1987,12 @@ const FlashStep = struct {
}
};
-const MonitorStep = struct {
+pub const MonitorStep = struct {
step: std.Build.Step,
port: []const u8,
seconds: u32,
- fn create(b: *std.Build, port: []const u8, seconds: u32) *MonitorStep {
+ pub 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 }),
@@ -1821,11 +2030,11 @@ const MonitorStep = struct {
/// 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 {
+pub const ResetStep = struct {
step: std.Build.Step,
port: []const u8,
- fn create(b: *std.Build, port: []const u8) *ResetStep {
+ pub 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 }),
@@ -1856,12 +2065,12 @@ const ResetStep = struct {
/// Reads until `MARK SELFTEST DONE pass=N fail=M`, echoing as it goes so a failing check is visible
/// in place rather than only as a count. Absent marker within the window is itself a failure: it
/// means the board never got there, which is worse than a failed check and must not read as a pass.
-const SelftestStep = struct {
+pub const SelftestStep = struct {
step: std.Build.Step,
port: []const u8,
seconds: u32,
- fn create(b: *std.Build, port: []const u8, seconds: u32) *SelftestStep {
+ pub fn create(b: *std.Build, port: []const u8, seconds: u32) *SelftestStep {
const self = b.allocator.create(SelftestStep) catch @panic("OOM");
self.* = .{
.step = std.Build.Step.init(.{ .id = .custom, .name = "selftest", .owner = b, .makeFn = make }),
@@ -1923,12 +2132,12 @@ const SelftestStep = struct {
}
};
-const SizeStep = struct {
+pub const SizeStep = struct {
step: std.Build.Step,
bin: std.Build.LazyPath,
opts: image.Options,
- fn create(b: *std.Build, img: *ImageStep) *SizeStep {
+ pub 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 }),