summaryrefslogtreecommitdiff
path: root/examples
diff options
context:
space:
mode:
Diffstat (limited to 'examples')
-rw-r--r--examples/differ.zig203
-rw-r--r--examples/halcheck.zig194
-rw-r--r--examples/http.zig922
-rw-r--r--examples/intrcheck.zig269
-rw-r--r--examples/iocheck.zig110
-rw-r--r--examples/minimal.zig42
-rw-r--r--examples/pie.zig224
-rw-r--r--examples/portcheck.zig267
-rw-r--r--examples/radio.zig388
-rw-r--r--examples/rf.zig613
-rw-r--r--examples/sdiocheck.zig321
11 files changed, 3553 insertions, 0 deletions
diff --git a/examples/differ.zig b/examples/differ.zig
new file mode 100644
index 0000000..865af3e
--- /dev/null
+++ b/examples/differ.zig
@@ -0,0 +1,203 @@
+//! Differential test: this project's Zig HAL against ESP-IDF's own LL, in one image, on the die.
+//!
+//! zig build diff -Doracle -Dapp=examples/differ.zig
+//!
+//! Both implementations are compiled into the same binary - IDF's `*_ll.h` by Zig's clang, ours by
+//! Zig - so they run on the same boot, the same clocks and the same silicon. For each operation the
+//! harness brings the peripheral to a known state, runs ESP-IDF's version, photographs the register
+//! block, restores, runs ours, photographs again, and compares. A pass means: for this operation and
+//! these arguments, our sequence leaves the hardware in the state ESP-IDF's does.
+//!
+//! Four rules this harness follows because adversarial review measured what happens without them:
+//!
+//! 1. **A snapshot can have side effects.** `UART_FIFO_REG` is at offset 0x000 of every UART block -
+//! the first word a "read the whole block" loop touches - and reading it *pops the RX FIFO*. The
+//! header annotates it `RO`. So each peripheral declares offsets that must not be read.
+//! 2. **A block cannot be restored by writing its snapshot back.** About 10% of this chip's fields
+//! perform an action when written; writing one saved word back to a UART's offset 0 transmits a
+//! character, and restoring GPIO's saved `ENABLE_W1TC` would clear the enables just set. Restore
+//! is either the peripheral's reset bit or a deliberate configure function - never a write-back.
+//! 3. **A clock-gated block reads stale data, silently.** Not zeros: the last value latched. Two
+//! snapshots of a gated peripheral can compare *equal* while describing nothing, so the bus
+//! clock is checked before every comparison.
+//! 4. **Equal registers do not prove equal sequences.** Ordering is invisible in the final state,
+//! and ordering is where the interesting bugs are - LEDC's shadow registers commit on a
+//! self-clearing bit that leaves no trace. Where a peripheral's correctness is an order rather
+//! than a state, its case list says so.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const oracle = @import("oracle");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK DIFF_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// Widest register block any suite compares. 400 words covers GPIO through its matrix
+/// configuration; two snapshots at that size are 3.2 KB of L2MEM, which this image has to spare.
+const max_words = 512;
+var snap_a: [max_words]u32 = @splat(0);
+var snap_b: [max_words]u32 = @splat(0);
+
+var cases_run: u32 = 0;
+var failures: u32 = 0;
+
+fn contains(haystack: []const u32, needle: u32) bool {
+ for (haystack) |h| if (h == needle) return true;
+ return false;
+}
+
+fn snapshot(p: oracle.types.Peripheral, out: []u32) void {
+ for (0..p.words) |i| {
+ const w: u32 = @intCast(i);
+ if (contains(p.no_read, w)) {
+ // A value hardware cannot produce, so a diff involving it is obviously a harness bug
+ // rather than a peripheral difference.
+ out[i] = 0xdead_0000 | w;
+ continue;
+ }
+ out[i] = mmio.Reg.atAddress(p.base + w * 4).raw();
+ }
+}
+
+fn restore(p: oracle.types.Peripheral) void {
+ switch (p.restore) {
+ .configure => |f| f(),
+ .reset_bit => |b| {
+ // Assert then deassert, with interrupts masked: these bits share a register with every
+ // other peripheral's reset.
+ const guard = hal.clkrst.maskInterrupts();
+ defer guard.release();
+ const r = mmio.Reg.atAddress(b.reg);
+ r.writeRaw(r.raw() | (@as(u32, 1) << b.bit));
+ r.writeRaw(r.raw() & ~(@as(u32, 1) << b.bit));
+ },
+ }
+}
+
+fn runSuite(suite: oracle.types.Suite) void {
+ const p = suite.descriptor;
+ if (p.words > max_words) {
+ soc.rom.print("MARK DIFF_SKIP %s wants %u words, harness holds %u\r\n", .{ p.name, p.words, @as(u32, max_words) });
+ failures += 1;
+ return;
+ }
+ if (suite.setup) |s| s();
+
+ for (suite.cases) |c| {
+ cases_run += 1;
+
+ // Rule 3: a gated block returns the last latched value, so two snapshots of it can agree
+ // and mean nothing.
+ if (p.clock) |clk| {
+ if (mmio.Reg.atAddress(clk.reg).raw() & (@as(u32, 1) << clk.bit) == 0) {
+ soc.rom.print("MARK DIFF SKIP %s.%s bus clock is off; a snapshot would be stale\r\n", .{ p.name, c.name });
+ failures += 1;
+ continue;
+ }
+ }
+
+ restore(p);
+ c.idf();
+ snapshot(p, &snap_a);
+
+ restore(p);
+ c.ours();
+ snapshot(p, &snap_b);
+
+ var diffs: u32 = 0;
+ for (0..p.words) |i| {
+ const w: u32 = @intCast(i);
+ if (contains(p.volatile_words, w)) continue;
+ if (snap_a[i] == snap_b[i]) continue;
+ diffs += 1;
+ if (diffs <= 4) {
+ soc.rom.print(" DIFF %s+0x%03x idf=0x%08x ours=0x%08x xor=0x%08x\r\n", .{
+ p.name, w * 4, snap_a[i], snap_b[i], snap_a[i] ^ snap_b[i],
+ });
+ }
+ }
+ if (diffs == 0) {
+ soc.rom.print("MARK DIFF ok %s.%s(%u) %u words identical\r\n", .{ p.name, c.name, c.arg, p.words });
+ } else {
+ failures += 1;
+ soc.rom.print("MARK DIFF FAIL %s.%s(%u) %u of %u words differ\r\n", .{ p.name, c.name, c.arg, diffs, p.words });
+ }
+ }
+}
+
+export fn zig_main() noreturn {
+ // First, before anything long-running: take the RTC watchdog off the board.
+ //
+ // The bootloader arms it to cover the handover and expects the application to take it over.
+ // Nothing in this repo ever did, and nothing noticed, because no run had exceeded eight seconds.
+ // This harness passed 26 cases, then 64, and then started resetting mid-run - which looked
+ // exactly like "the newest suite crashes the board" and was in fact a ten-second fuse that had
+ // been burning since the first image.
+ const wdt_was_armed = hal.rwdt.armed();
+ const wdt_off = hal.rwdt.disable();
+ soc.rom.print("\r\nMARK DIFF_START esp-idf LL vs zig HAL, one image, on the die\r\n", .{});
+ soc.rom.print("MARK DIFF_WDT armed_at_entry=%u disabled=%u (bootloader leaves the RTC watchdog running)\r\n", .{
+ @as(u32, @intFromBool(wdt_was_armed)),
+ @as(u32, @intFromBool(wdt_off)),
+ });
+ // If IDF's LL was compiled to call the mask ROM, the comparison would be against
+ // `rom_gpio_set_output_level` rather than against IDF's register sequence. src/oracle/
+ // oracle_sdkconfig.h exists to keep this at 0.
+ soc.rom.print("MARK DIFF_CFG gpio_ll_uses_rom_api=%u expect=0\r\n", .{
+ @as(u32, @intFromBool(oracle.gpio.usesRomApi())),
+ });
+
+ // GPIO's two suites run once per pin, because the bank split at 32 is where its arithmetic
+ // differs - and because the pad registers live in a different register file from the GPIO block,
+ // far enough away that one window cannot cover both.
+ for (oracle.gpio.pins) |p| {
+ oracle.gpio.pin = p;
+ soc.rom.print("MARK DIFF_PIN %u\r\n", .{@as(u32, p)});
+ runSuite(oracle.gpio.suite);
+ runSuite(oracle.gpio.iomux_suite);
+ }
+
+ // Every other registered peripheral. The two GPIO suites are skipped here because the loop above
+ // already ran them once per pin.
+ inline for (oracle.suites) |suite| {
+ const n = comptime std.mem.span(suite.descriptor.name);
+ if (comptime !std.mem.eql(u8, n, "gpio") and !std.mem.eql(u8, n, "iomux")) runSuite(suite);
+ }
+
+ soc.rom.print("MARK DIFF_TOTAL cases=%u failures=%u\r\n", .{ cases_run, failures });
+ soc.rom.print("MARK DIFF_DONE\r\n", .{});
+
+ // Leave the board as the rest of the project expects it: LED pin an output, blinking.
+ hal.gpio.configureOutput(20, .{ .readback = true });
+ while (true) {
+ hal.gpio.setHigh(20);
+ soc.rom.ets_delay_us(500_000);
+ hal.gpio.setLow(20);
+ soc.rom.ets_delay_us(500_000);
+ }
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/halcheck.zig b/examples/halcheck.zig
new file mode 100644
index 0000000..94b3a2d
--- /dev/null
+++ b/examples/halcheck.zig
@@ -0,0 +1,194 @@
+//! Hardware check for the register layer and the first three HAL peripherals.
+//!
+//! Every line this prints is a claim about the silicon that the die itself answers. Nothing here is
+//! a unit test of Zig code: the register numbers come from ESP-IDF's own macros, so the only thing
+//! left to doubt is whether the *sequences* built on them do what the hardware needs.
+//!
+//! Run with: zig build -Dapp=examples/halcheck.zig run -Dseconds=8
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+const config = @import("config");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK HAL_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+const led = config.led_pin;
+
+export fn zig_main() noreturn {
+ soc.rom.print("\r\nMARK HAL_START hw_ver=%u registers from ESP-IDF headers\r\n", .{
+ @as(u32, regs.ZIG_P4_HW_VER),
+ });
+
+ // ---------------------------------------------------------------- 1. the register layer itself
+ // A handful of addresses, printed so a human can check them against the technical reference
+ // manual without trusting anything in this repo. GPIO_OUT is 0x500E0004 and IO_MUX's pad 0 is
+ // 0x500E1004 on this part.
+ soc.rom.print("MARK HAL_ADDR gpio_out=0x%08x iomux_pad0=0x%08x systimer_conf=0x%08x\r\n", .{
+ @as(u32, @intCast(regs.GPIO_OUT_REG)),
+ @as(u32, @intCast(regs.PERIPHS_IO_MUX_U_PAD_GPIO0)),
+ @as(u32, @intCast(regs.SYSTIMER_CONF_REG)),
+ });
+ // The GPIO matrix constant that the hand-written predecessor got right and the first HAL draft
+ // got wrong. 256 on the P4, 128 on the S3.
+ soc.rom.print("MARK HAL_SIGMAP gpio_out_idx=%u expect=256\r\n", .{
+ @as(u32, hal.gpio.matrix_gpio_signal),
+ });
+
+ // ---------------------------------------------------------------- 2. GPIO
+ // Same observable behaviour as the hand-written GPIO this replaces: drive a pin, read it back
+ // through the pad's own input buffer while it is driven.
+ hal.gpio.configureOutput(led, .{ .readback = true });
+ hal.gpio.setHigh(led);
+ const drove_high = hal.gpio.getLevel(led);
+ hal.gpio.setLow(led);
+ const drove_low = hal.gpio.getLevel(led);
+ soc.rom.print("MARK HAL_GPIO pin=%u high=%u low=%u oe=%u expect=1,0,1\r\n", .{
+ @as(u32, led),
+ @as(u32, drove_high),
+ @as(u32, drove_low),
+ @as(u32, @intFromBool(hal.gpio.isOutputEnabled(led))),
+ });
+
+ // The second bank. The split at pin 32 is the classic P4 GPIO bug - a write to `out` with a
+ // shift of 40 lands on pin 8 - and it is only provable above 31.
+ //
+ // Pin choice matters here and cost a debugging round. GPIO54 is on JP1 but is this board's
+ // ESP32-C6 reset line with an external pull-up: it drives correctly (out1 follows 1 -> 0) and
+ // reads back stuck high, because the pull-up wins at the pad. GPIO33 (JP1 pin 21) is genuinely
+ // free, and it tracks.
+ const bank1_pin = 33;
+ hal.gpio.configureOutput(bank1_pin, .{ .readback = true });
+ hal.gpio.setHigh(bank1_pin);
+ const hi_b1 = hal.gpio.getLevel(bank1_pin);
+ hal.gpio.setLow(bank1_pin);
+ const lo_b1 = hal.gpio.getLevel(bank1_pin);
+ hal.gpio.outputDisable(bank1_pin);
+ soc.rom.print("MARK HAL_GPIO_BANK1 pin=%u high=%u low=%u expect=1,0\r\n", .{
+ @as(u32, bank1_pin), @as(u32, hi_b1), @as(u32, lo_b1),
+ });
+
+ // ---------------------------------------------------------------- 3. clock gates
+ // TWAI0 is the one peripheral whose bus clock is gated OFF at power-on, which makes it the only
+ // place where enabling a clock is observable rather than merely correct.
+ //
+ // Read what this proves narrowly. Both the write and the read-back go through this project's own
+ // accessor, so agreement shows the two are consistent with each other - not that either touches
+ // the right register. That is not a hypothetical caveat: an earlier version of clkrst.zig had
+ // these four peripherals' gate bits in HP_SYS_CLKRST_PERI_CLK_CTRL21 instead of SOC_CLK_CTRL2,
+ // and this test printed exactly the expected 0,1,0 while poking a bit of an unrelated register.
+ // It would have passed with the chip unplugged. The real check is `zig build diff`, where the
+ // reference reaches the hardware through ESP-IDF's LL instead.
+ const twai_before = hal.clkrst.isClockEnabled(.twai0);
+ hal.clkrst.setClockEnabled(.twai0, true);
+ const twai_on = hal.clkrst.isClockEnabled(.twai0);
+ hal.clkrst.setClockEnabled(.twai0, false);
+ const twai_off = hal.clkrst.isClockEnabled(.twai0);
+ soc.rom.print("MARK HAL_GATE twai0 boot=%u on=%u off=%u expect=0,1,0 (self-consistency only; see diff)\r\n", .{
+ @as(u32, @intFromBool(twai_before)),
+ @as(u32, @intFromBool(twai_on)),
+ @as(u32, @intFromBool(twai_off)),
+ });
+
+ // UART0 is the console this text is coming out of, so its gate must read back enabled - and
+ // this is a read, deliberately: writing it would cut the wire mid-sentence.
+ soc.rom.print("MARK HAL_GATE uart0=%u expect=1 (console is alive, so it must be)\r\n", .{
+ @as(u32, @intFromBool(hal.clkrst.isClockEnabled(.uart0))),
+ });
+
+ // ---------------------------------------------------------------- 4. systimer
+ hal.systimer.init();
+ const t0 = hal.systimer.read(.unit0) orelse {
+ soc.rom.print("MARK HAL_SYSTIMER dead - no valid handshake\r\n", .{});
+ hang();
+ };
+ const c0 = soc.cycles();
+ soc.rom.ets_delay_us(50_000);
+ const t1 = hal.systimer.read(.unit0) orelse unreachable;
+ const c1 = soc.cycles();
+
+ const ticks = t1 - t0;
+ const cycles = c1 - c0;
+ // The counter must advance, and by roughly 16 MHz * 50 ms = 800,000 ticks.
+ soc.rom.print("MARK HAL_SYSTIMER ticks=%u expect~800000 advancing=%u\r\n", .{
+ @as(u32, @truncate(ticks)),
+ @as(u32, @intFromBool(t1 > t0)),
+ });
+ // Two independent clocks measuring the same interval: systimer is a fixed 16 MHz, so the CPU
+ // frequency falls out of the ratio. This is the honest measurement of a number the README has
+ // only ever estimated.
+ const cpu_khz: u64 = if (ticks != 0) (cycles * (hal.systimer.hz / 1000)) / ticks else 0;
+ soc.rom.print("MARK HAL_CPUFREQ %u kHz, from %u cycles per %u systimer ticks\r\n", .{
+ @as(u32, @truncate(cpu_khz)),
+ @as(u32, @truncate(cycles)),
+ @as(u32, @truncate(ticks)),
+ });
+
+ // The 52-bit counter read as a pair of words. If the update/valid handshake were skipped, the
+ // low word could wrap between the two loads and the value would jump backwards; sample it in a
+ // tight loop and assert monotonicity, which is the only cheap way to catch that.
+ var last: u64 = hal.systimer.read(.unit0) orelse unreachable;
+ var backwards: u32 = 0;
+ for (0..2000) |_| {
+ const now = hal.systimer.read(.unit0) orelse unreachable;
+ if (now < last) backwards += 1;
+ last = now;
+ }
+ soc.rom.print("MARK HAL_SYSTIMER_MONOTONIC 2000 reads, backwards=%u expect=0\r\n", .{backwards});
+
+ // ---------------------------------------------------------------- 5. the TIMG reset trap
+ // Resetting a timer group re-arms flash-boot watchdog protection, and the board then reboots a
+ // moment later with nothing on the console to explain it. resetPeripheral clears that bit as
+ // part of the reset. The proof is negative and needs time to pass, so print before and after and
+ // let the rest of this program be the delay.
+ soc.rom.print("MARK HAL_TIMG_RESET resetting timg0 - a reboot after this line means the flashboot fixup is missing\r\n", .{});
+ hal.clkrst.resetPeripheral(.timg0);
+ hal.systimer.delayMicros(200_000);
+ soc.rom.print("MARK HAL_TIMG_SURVIVED still running 200 ms after the timg0 reset\r\n", .{});
+
+ soc.rom.print("MARK HAL_DONE\r\n", .{});
+
+ // Blink from the HAL, using the systimer for the delay rather than the ROM's.
+ var beat: u32 = 0;
+ while (true) : (beat += 1) {
+ hal.gpio.setHigh(led);
+ hal.systimer.delayMicros(250_000);
+ hal.gpio.setLow(led);
+ hal.systimer.delayMicros(250_000);
+ if (beat % 4 == 0) {
+ soc.rom.print("MARK HAL_ALIVE beat=%u t=%u us\r\n", .{
+ beat,
+ @as(u32, @truncate(hal.systimer.micros(.unit0) orelse 0)),
+ });
+ }
+ }
+}
+
+fn hang() noreturn {
+ while (true) {}
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/http.zig b/examples/http.zig
new file mode 100644
index 0000000..03d89c1
--- /dev/null
+++ b/examples/http.zig
@@ -0,0 +1,922 @@
+//! The data path, end to end: associate, take a DHCP lease, answer a ping, fetch a URL.
+//!
+//! examples/radio.zig proved the control path - SDIO up, RPC in both directions, a scan, an
+//! association event from the C6. What it did not do is move a single Ethernet frame, and the
+//! symptom was ESP-Hosted logging `task still writing Rx data to queue!` once associated: the AP's
+//! traffic arriving with nobody registered to consume it.
+//!
+//! This file registers that consumer (src/net/link.zig) and puts src/net/ip.zig behind it. Nothing
+//! from lwIP, esp_netif or the IDF network stack is linked; the IPv4, ARP, ICMP, UDP, DHCP, TCP and
+//! HTTP are all this project's, in one 3.4 KB struct that allocates nothing and reads no clock.
+//!
+//! Run:
+//! zig build -Dhosted -Dapp=examples/http.zig \
+//! -Dssid=YOUR_SSID -Dpsk-file=/path/to/psk run
+//!
+//! What it prints, in order, and what each line means. Every wait below is bounded and every
+//! bound prints why it gave up, because a silent wait on this board is indistinguishable from a
+//! dead scheduler.
+//!
+//! MARK HTTP_START cpu=... the image booted; the CPU clock, from two independent counters
+//! MARK HTTP_WDT ... the bootloader's RTC watchdog was found armed and disarmed
+//! MARK HTTP_TICK ... heartbeat during bring-up: scheduler alive, C making progress
+//! MARK HTTP_RADIO_UP after N ms the C6 answered and the transport reached its active state
+//! bad case: MARK HTTP_FAIL radio ... - the transport never came up. Nothing below can run.
+//! MARK HTTP_WIFI_START rc=0 the first RPC round trip after bring-up
+//! bad case: rc!=0, or spun>0 meaning the RPC threads never left their not-ready loop
+//! MARK HTTP_LINK mac=... foot=N the station channel is registered; `mac` is the C6's radio's
+//! own address and is what ARP will answer for. From this line on,
+//! station frames reach src/net/ip.zig instead of being discarded.
+//! bad case: MARK HTTP_FAIL link=MacUnavailable (asked before the radio was started) or
+//! =ChannelRegisterFailed (transport_drv_add_channel refused; wrong if_type)
+//! MARK HTTP_JOIN ssid=... psk_len=N the association request went out. The passphrase is never
+//! printed; only its length, which distinguishes a missing
+//! -Dpsk-file from a wrong one.
+//! MARK HTTP_ASSOC after N ms by=X rx=N associated. `by=event` means WIFI_EVENT_STA_CONNECTED
+//! (id 4) arrived; `by=traffic` means station frames arrived,
+//! which an AP does not send to an unassociated station and which
+//! is what happens when the C6 was already on this SSID.
+//! bad case: MARK HTTP_FAIL assoc timeout ... - the radio never associated. Wrong passphrase,
+//! wrong band, or out of range. Any RADIO_EVENT id=5 line before it is a disconnect
+//! and carries the reason in its length.
+//! MARK HTTP_DHCP_TX ... DISCOVER sent. This is the first frame this project has ever
+//! put on a real network.
+//! MARK HTTP_DHCP_WAIT ... progress, every second: DHCP state and the frame counters. If
+//! `rx=0` stays zero the receive callback is not being called at
+//! all; if `rx` climbs while `drop` climbs with it, frames are
+//! arriving and being rejected - which is what a frame shifted by
+//! a header looks like, so `csum` and `drop` are printed too.
+//! MARK HTTP_IP ip=... mask=... a real lease from the real DHCP server
+//! MARK HTTP_ROUTE gw=... dns=... in N ms and the route it came with
+//! bad case: MARK HTTP_FAIL dhcp state=selecting - DISCOVERs went out, no OFFER came back
+//! (frames leaving but not arriving), or state=requesting - an OFFER arrived and the
+//! ACK did not. The state is the diagnosis, which is why it is printed rather than
+//! just "timeout".
+//! MARK HTTP_PING_OPEN ip=... for N ms ping the board from the workstation NOW
+//! MARK HTTP_PING n=N arp_rx=... arp_tx=... one line per echo request answered. The ARP
+//! counters beside it are what prove the exchange was two-way:
+//! the workstation had to ARP for this address and be answered
+//! before it could send the echo at all.
+//! bad case: no such line for the whole window. If `HTTP_PING_IDLE` shows rx climbing, frames
+//! arrive and the board is not recognising them as ICMP for its own address.
+//! MARK HTTP_GET host=... port=... path=... the request begins
+//! MARK HTTP_STATUS code=... len=N tcp=... the response's status line was parsed
+//! MARK HTTP_BODY ... the first bytes of the body, non-printables as '.'
+//! bad case: MARK HTTP_FAIL get=HostUnreachable (no ARP answer for the peer),
+//! =TimedOut (SYN or data unacknowledged), =ConnectionReset (nothing listening on
+//! that port), =WouldBlock (our own deadline, printed with the TCP state)
+//! MARK HTTP_LIVE ... still running and still answering ICMP, once a second
+
+const std = @import("std");
+const hal = @import("hal");
+const soc = @import("soc");
+const net = @import("net");
+const p4 = @import("io");
+const config = @import("config");
+
+/// Required by any application implementing `std.Io.VTable` on this target, and it must be in the
+/// ROOT module: std reads `std.options` from `@import("root")`.
+pub const std_options: std.Options = .{ .page_size_min = 4096 };
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK HTTP_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+// =============================================================================== the target
+//
+// The HTTP peer. A host on this workstation's own /24 rather than the gateway's web UI, so the
+// expected response bytes are known in advance and a wrong answer means this stack is wrong rather
+// than that a router's firmware is unusual.
+//
+// Serve it with, from the directory holding the file to be fetched:
+// python3 -m http.server 8080 --bind 192.168.1.181
+// A `Content-Length` is what that sends, so the body's end is framed by the header rather than by
+// the peer's FIN - which exercises the more interesting of ip.zig's two termination paths.
+const http_host: [4]u8 = .{ 192, 168, 1, 181 };
+const http_port: u16 = 8080;
+const http_path = "/";
+
+/// How long the board stays quietly pingable before it makes the HTTP request. Long enough for a
+/// person to run `ping` after reading the line that says to.
+const ping_window_ms: u64 = 30_000;
+
+// ================================================================================== budgets
+//
+// Every one of these is a bound on a wait that would otherwise be a hang, and each is generous
+// against the thing it is waiting for rather than round.
+
+/// The C6 boots in well under a second; examples/radio.zig saw the transport up at 1,890 ms.
+const radio_deadline_ms: u64 = 10_000;
+/// A 2.4 GHz association with WPA2 is a second or two. Twenty allows for a scan and a retry.
+const assoc_deadline_ms: u64 = 60_000;
+
+/// How many association attempts before giving up.
+///
+/// Ten, because this link is genuinely marginal and the failure is not ours. Measured on this board:
+/// the same SSID and passphrase associate on some attempts and are refused on others with reason=4
+/// ASSOC_EXPIRE at about -68 dBm, and one run exhausted four attempts in a row. Reason 15 would mean
+/// a wrong passphrase; 4 means the AP expired the association, and the only cure is to ask again.
+///
+/// Ten attempts against a 45 s deadline is roughly one every 4.5 s, which is slower than the AP's own
+/// timeout and so does not pile requests on top of each other.
+const assoc_attempts: u32 = 6;
+
+/// Pause between association attempts. See the comment at the retry itself: hammering the AP is what
+/// turns a marginal link into a refused one.
+const assoc_backoff_ms: u64 = 3_000;
+/// DHCP's own first retransmission is at 4 s and ip.zig retries with backoff; fifteen seconds is
+/// three or four DISCOVERs, which is enough to distinguish "no server" from "one lost frame".
+const dhcp_deadline_ms: u64 = 15_000;
+/// ARP, a SYN, a request and a response, with TCP's initial RTO at 1 s and six retries available.
+const http_deadline_ms: u64 = 20_000;
+
+// ================================================================================ resources
+
+/// Twelve task slots. Sizing this too small does NOT fail loudly: `io.async` with no free slot runs
+/// the body eagerly instead of returning an error, so a reader task that should loop forever runs
+/// once at spawn and is never seen again. ESP-Hosted spawns seven for the transport plus RPC's.
+var pool: p4.Static(12, 2048) = .{};
+
+/// ESP-Hosted's heap. examples/radio.zig measured the transport and RPC at well under 32 KiB with
+/// the SDIO queues at 4/4; the station channel adds one 1,536-byte aligned transmit buffer in
+/// flight per queued frame, and each received frame is a `payload_len` allocation freed by
+/// `link.onRxFrame` before the next is dispatched. HTTP_HEAP prints what was really used.
+var heap_backing: [70 * 1024]u8 align(8) = undefined;
+
+// ==================================================================================== state
+
+var associated: bool = false;
+var disconnects: u32 = 0;
+var bringup_result: ?anyerror = null;
+var bringup_done: bool = false;
+
+/// ESP-Hosted's Wi-Fi RPC, through the C shim in src/net/hosted/wifi_shim.c. The structs stay in C
+/// on purpose; see that file's header.
+extern fn hosted_wifi_sta_start() c_int;
+extern fn hosted_wifi_connect(ssid: [*:0]const u8, psk: [*:0]const u8) c_int;
+
+/// NUL-terminated credential copies for the C ABI, sized to the maxima `wifi_config_t` itself uses.
+/// File scope rather than local because the retry loop below re-issues the request and must pass the
+/// same bytes; the passphrase is never printed and never written anywhere else.
+var ssid_buf: [33]u8 = @splat(0);
+var psk_buf: [65]u8 = @splat(0);
+
+/// `WIFI_EVENT_STA_CONNECTED`. The C6 reported this as `base=wifi id=4` on the run that proved
+/// association; `id=5` beside it is `WIFI_EVENT_STA_DISCONNECTED`.
+const wifi_event_sta_connected: i32 = 4;
+const wifi_event_sta_disconnected: i32 = 5;
+
+fn bringUp(ctx: struct { io: std.Io, gpa: std.mem.Allocator }) void {
+ net.init(ctx.io, ctx.gpa) catch |err| {
+ bringup_result = err;
+ bringup_done = true;
+ return;
+ };
+ bringup_done = true;
+}
+
+fn onEvent(ev: net.port.Event) void {
+ const base_name: [*:0]const u8 = switch (ev.base) {
+ .wifi => "wifi",
+ .named => |n| n,
+ };
+ soc.rom.print("MARK HTTP_EVENT base=%s id=%d len=%u\r\n", .{
+ base_name,
+ ev.id,
+ @as(u32, @intCast(if (ev.data) |d| d.len else 0)),
+ });
+ if (ev.base == .wifi) {
+ if (ev.id == wifi_event_sta_connected) associated = true;
+ // A disconnect after association is worth counting rather than acting on: the C6 retries on
+ // its own, and a stack that tore itself down on every roam would be less useful than one
+ // that says how many times it happened.
+ if (ev.id == wifi_event_sta_disconnected) {
+ disconnects += 1;
+ // The reason code is the whole diagnostic value of this event: without it a refused
+ // association is indistinguishable from an access point that was never there. Layout is
+ // `wifi_event_sta_disconnected_t` - ssid[32], ssid_len, bssid[6], reason, rssi - so 41
+ // bytes with reason at offset 39. Length-checked rather than assumed, because another
+ // IDF version would move it.
+ if (ev.data) |d| {
+ if (d.len >= 41) {
+ const reason = d[39];
+ const rssi: i8 = @bitCast(d[40]);
+ soc.rom.print("MARK HTTP_DISCONNECT reason=%u rssi=%d %s\r\n", .{
+ @as(u32, reason),
+ @as(i32, rssi),
+ reasonName(reason),
+ });
+ } else {
+ soc.rom.print("MARK HTTP_DISCONNECT payload=%u B, expected 41\r\n", .{
+ @as(u32, @intCast(d.len)),
+ });
+ }
+ }
+ }
+ }
+}
+
+/// The disconnect reasons that actually occur during bring-up, named. From `wifi_err_reason_t`
+/// (esp_wifi_types_generic.h). Anything else prints its number, which is more useful than a wrong
+/// guess at a name.
+fn reasonName(reason: u8) [*:0]const u8 {
+ return switch (reason) {
+ 2 => "AUTH_EXPIRE",
+ 4 => "ASSOC_EXPIRE",
+ 15 => "4WAY_HANDSHAKE_TIMEOUT - almost always a wrong passphrase",
+ 23 => "802_1X_AUTH_FAILED",
+ 200 => "BEACON_TIMEOUT",
+ 201 => "NO_AP_FOUND - wrong SSID, or not on a channel the C6 can see",
+ 202 => "AUTH_FAIL",
+ 203 => "ASSOC_FAIL",
+ 204 => "HANDSHAKE_TIMEOUT",
+ 205 => "CONNECTION_FAIL",
+ else => "see wifi_err_reason_t",
+ };
+}
+
+/// Reset entry. The bootloader hands over with an unspecified stack pointer and the FPU off, so:
+/// enable the F extension (mstatus.FS = Initial), establish the stack, clear .bss, then enter Zig.
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
+
+export fn zig_main() noreturn {
+ // The bootloader leaves the RTC watchdog armed and expects the application to take it over.
+ // Nothing here feeds it, and this program deliberately runs for minutes.
+ const wdt_was_armed = hal.rwdt.armed();
+ const wdt_off = hal.rwdt.disable();
+ soc.rom.print("MARK HTTP_WDT armed_at_entry=%u disabled=%u\r\n", .{
+ @as(u32, @intFromBool(wdt_was_armed)),
+ @as(u32, @intFromBool(wdt_off)),
+ });
+
+ // Two independent clocks over one interval: the systimer is a fixed 16 MHz, so the CPU
+ // frequency falls out of the ratio. Everything above assumes the bootloader's 90 MHz.
+ const t0 = hal.systimer.read(.unit0) orelse 0;
+ const c0 = soc.cycles();
+ soc.rom.ets_delay_us(10_000);
+ const t1 = hal.systimer.read(.unit0) orelse 0;
+ const c1 = soc.cycles();
+ const cpu_khz: u64 = if (t1 > t0) ((c1 - c0) * (hal.systimer.hz / 1000)) / (t1 - t0) else 0;
+ soc.rom.print("\r\nMARK HTTP_START cpu=%u kHz\r\n", .{@as(u32, @truncate(cpu_khz))});
+
+ const runtime = pool.init(.{});
+ const io = runtime.io();
+
+ var backing = net.heap.Heap.init(&heap_backing);
+ const gpa = backing.allocator();
+
+ net.port.setEventHandler(&onEvent);
+
+ // Bring-up runs on its own task: `transport_drv_reconfigure` blocks in a 200 ms retry loop
+ // waiting for the slave, so a main task inside it could not print. With it elsewhere, the
+ // heartbeat below distinguishes "the transport is stuck" from "the scheduler is stuck".
+ var bringup = io.async(bringUp, .{.{ .io = io, .gpa = gpa }});
+ soc.rom.print("MARK HTTP_SETUP dispatched\r\n", .{});
+
+ radioUp(runtime);
+ bringup.cancel(io);
+
+ // The station channel, and the IP stack behind it. Before the association request rather than
+ // after, so there is never a window in which the AP's traffic arrives with nothing to consume
+ // it - that window is exactly what produced `task still writing Rx data to queue!`.
+ wifiStart();
+ linkUp();
+ join(runtime);
+ dhcp(runtime);
+ pingWindow(runtime);
+ httpGet(runtime);
+ internetGet(runtime);
+ live(runtime);
+}
+
+// ============================================================================= 1. the radio
+
+fn radioUp(runtime: *p4.Runtime) void {
+ const started = nowMs();
+ var last_report: u64 = 0;
+ while (!net.isUp()) {
+ runtime.yield();
+ const elapsed = nowMs() - started;
+ if (elapsed - last_report >= 1000) {
+ last_report = elapsed;
+ const s = net.port.stats();
+ soc.rom.print("MARK HTTP_TICK t=%u ms heap=%u B blocks=%u stubs=%u done=%u\r\n", .{
+ @as(u32, @intCast(elapsed)),
+ @as(u32, @intCast(s.bytes_live)),
+ @as(u32, @intCast(s.blocks_live)),
+ s.stub_calls,
+ @as(u32, @intFromBool(bringup_done)),
+ });
+ }
+ if (bringup_done) {
+ if (bringup_result) |err| fail("radio", @errorName(err));
+ }
+ if (elapsed > radio_deadline_ms) {
+ soc.rom.print("MARK HTTP_FAIL radio timeout after %u ms (slave never answered)\r\n", .{
+ @as(u32, @intCast(elapsed)),
+ });
+ park();
+ }
+ }
+ soc.rom.print("MARK HTTP_RADIO_UP after %u ms\r\n", .{@as(u32, @intCast(nowMs() - started))});
+}
+
+// ========================================================================== 2. the station
+
+fn wifiStart() void {
+ // `spun` counts ESP-Hosted's RPC not-ready spins across the call. Both `rpc_rx_thread` and
+ // `rpc_tx_thread` open their loop with `if (!is_rpc_lib_ready()) _h_sleep(1)`
+ // (rpc_core.c:482-485, :543-547) and those are the only `_h_sleep` callers linked here, so a
+ // non-zero value means the request was enqueued and never transmitted - a completely different
+ // failure from the coprocessor refusing it.
+ const before = net.port.stats().hosted_sleep_calls;
+ const rc = hosted_wifi_sta_start();
+ const after = net.port.stats().hosted_sleep_calls;
+ soc.rom.print("MARK HTTP_WIFI_START rc=%d spun=%u\r\n", .{ rc, @as(u32, after - before) });
+ if (rc != 0) fail("wifi_start", "rpc_wifi_start refused");
+}
+
+fn linkUp() void {
+ net.link.open() catch |err| fail("link", @errorName(err));
+ const m = net.link.mac();
+ soc.rom.print("MARK HTTP_LINK mac=%02x:%02x:%02x:%02x:%02x:%02x foot=%u B\r\n", .{
+ @as(u32, m[0]), @as(u32, m[1]), @as(u32, m[2]),
+ @as(u32, m[3]), @as(u32, m[4]), @as(u32, m[5]),
+ @as(u32, net.link.footprint),
+ });
+}
+
+fn join(runtime: *p4.Runtime) void {
+ if (config.wifi_ssid.len == 0) {
+ soc.rom.print("MARK HTTP_FAIL join: no -Dssid given, nothing to associate with\r\n", .{});
+ park();
+ }
+ if (config.wifi_ssid.len > 32 or config.wifi_psk.len > 64) {
+ soc.rom.print("MARK HTTP_FAIL join: ssid or psk longer than 802.11 allows\r\n", .{});
+ park();
+ }
+ // NUL-terminated copies for the C ABI, sized to the maxima `wifi_config_t` itself uses.
+ @memcpy(ssid_buf[0..config.wifi_ssid.len], config.wifi_ssid);
+ @memcpy(psk_buf[0..config.wifi_psk.len], config.wifi_psk);
+
+ // The SSID is printed; the passphrase is not, and only its length is - enough to tell a missing
+ // -Dpsk-file from a wrong one without putting the secret on a serial line.
+ soc.rom.print("MARK HTTP_JOIN ssid=%s psk_len=%u\r\n", .{
+ @as([*:0]const u8, @ptrCast(&ssid_buf)),
+ @as(u32, @intCast(config.wifi_psk.len)),
+ });
+
+ // Already associated? The C6 keeps its association across a host reset, and radio.zig may have
+ // left one behind. Asking again is harmless and is what makes this file runnable twice.
+ const rc = hosted_wifi_connect(@ptrCast(&ssid_buf), @ptrCast(&psk_buf));
+ soc.rom.print("MARK HTTP_CONNECT rc=%d\r\n", .{rc});
+ if (rc != 0) fail("connect", "rpc_wifi_connect refused");
+
+ // Association is asynchronous: the request is answered at once and the outcome arrives as an
+ // event. Keep the scheduler running and bound the wait.
+ //
+ // Two conditions end this wait, not one. The event is the direct evidence. Received station
+ // traffic is the *indirect* evidence and it is just as conclusive: an AP does not forward
+ // broadcasts to a station that is not associated with it. The second condition exists because
+ // the C6 keeps its association across a host reset - examples/radio.zig may have left one
+ // behind - and a `connect` request that finds the radio already on this SSID need not produce
+ // a fresh id=4. Without it, the commonest happy case would time out with a misleading reason.
+ const started = nowMs();
+ var why: [*:0]const u8 = "event";
+ // Retry, because a single refusal is normal on a real network rather than a failure.
+ //
+ // Measured on this board: the same passphrase and SSID associate on some attempts and are
+ // refused on others with reason=4 ASSOC_EXPIRE at about -68 dBm. That is the AP expiring the
+ // association, not a credential problem - reason 15 would say wrong passphrase - and giving up
+ // on the first one made a working stack look broken half the time. Re-issuing the request is
+ // what any supplicant does.
+ var attempts: u32 = 1;
+ var seen_disconnects = disconnects;
+ while (!associated) {
+ drive(runtime);
+ if (net.link.ipCounters().rx_frames > 0) {
+ why = "traffic";
+ break;
+ }
+
+ // A new disconnect while still unassociated means this attempt is over; start another
+ // rather than waiting out a deadline that cannot now be met.
+ if (disconnects != seen_disconnects) {
+ seen_disconnects = disconnects;
+ if (attempts < assoc_attempts) {
+ attempts += 1;
+ // Back off before asking again, and this matters more than the attempt count.
+ //
+ // Retrying immediately made things worse, not better: ten back-to-back attempts
+ // produced an alternating run of reason=205 CONNECTION_FAIL at rssi=-128 and
+ // reason=15 4WAY_HANDSHAKE_TIMEOUT at rssi=-58 - a strong signal and a failed
+ // handshake, with a passphrase that had associated successfully minutes earlier.
+ // That pattern is an access point refusing a client that is hammering it, not a
+ // credential problem: reason 15 would otherwise mean a wrong passphrase, and
+ // psk_len proves the bytes are unchanged.
+ //
+ // Three seconds is longer than the AP's own handshake timeout, so each attempt is a
+ // fresh one rather than a collision with the last.
+ const backoff_started = nowMs();
+ while (nowMs() - backoff_started < assoc_backoff_ms) drive(runtime);
+ soc.rom.print("MARK HTTP_RETRY attempt %u of %u after %u ms backoff\r\n", .{
+ attempts,
+ assoc_attempts,
+ @as(u32, @intCast(assoc_backoff_ms)),
+ });
+ if (hosted_wifi_connect(@ptrCast(&ssid_buf), @ptrCast(&psk_buf)) != 0) {
+ fail("connect", "rpc_wifi_connect refused on retry");
+ }
+ }
+ }
+
+ const elapsed = nowMs() - started;
+ if (elapsed > assoc_deadline_ms) {
+ soc.rom.print(
+ "MARK HTTP_FAIL assoc timeout after %u ms (attempts=%u, disconnects=%u, no id=4, no frames)\r\n",
+ .{ @as(u32, @intCast(elapsed)), attempts, disconnects },
+ );
+ park();
+ }
+ }
+ soc.rom.print("MARK HTTP_ASSOC after %u ms by=%s rx=%u\r\n", .{
+ @as(u32, @intCast(nowMs() - started)),
+ why,
+ net.link.ipCounters().rx_frames,
+ });
+}
+
+// ================================================================================== 3. DHCP
+
+fn dhcp(runtime: *p4.Runtime) void {
+ // `dhcpStart` stamps the acquisition's start from the stack's idea of now, and only `tick`
+ // sets that. One tick first, therefore, or every DHCP deadline is measured from zero.
+ _ = net.link.tick(nowMs());
+ if (!net.link.dhcpStart()) fail("dhcp", "stack busy at dhcpStart, which cannot happen here");
+ soc.rom.print("MARK HTTP_DHCP_TX discover sent\r\n", .{});
+
+ const started = nowMs();
+ var last_report: u64 = 0;
+ while (net.link.address() == null) {
+ drive(runtime);
+ const elapsed = nowMs() - started;
+ if (elapsed - last_report >= 1000) {
+ last_report = elapsed;
+ const c = net.link.ipCounters();
+ const l = net.link.stats();
+ soc.rom.print(
+ "MARK HTTP_DHCP_WAIT t=%u state=%s rx=%u drop=%u csum=%u tx=%u dhcp_rx=%u re=%u\r\n",
+ .{
+ @as(u32, @intCast(elapsed)),
+ @tagName(net.link.dhcpState()).ptr,
+ c.rx_frames,
+ c.rx_dropped,
+ c.checksum_bad,
+ c.tx_frames,
+ c.dhcp_rx,
+ l.rx_reentrant,
+ },
+ );
+ }
+ if (elapsed > dhcp_deadline_ms) {
+ const c = net.link.ipCounters();
+ soc.rom.print(
+ "MARK HTTP_FAIL dhcp timeout after %u ms state=%s sent=%u got=%u rx=%u drop=%u\r\n",
+ .{
+ @as(u32, @intCast(elapsed)),
+ @tagName(net.link.dhcpState()).ptr,
+ c.dhcp_tx,
+ c.dhcp_rx,
+ c.rx_frames,
+ c.rx_dropped,
+ },
+ );
+ park();
+ }
+ }
+
+ const a = net.link.address().?;
+ const m = net.link.netmask();
+ const g = net.link.gateway();
+ const d = net.link.dnsServer() orelse @as([4]u8, @splat(0));
+ // Two lines rather than one seventeen-argument line. `ets_printf` is the mask ROM's, taking
+ // its arguments the ordinary variadic way - eight in registers and the rest on the stack - and
+ // there is no reason to be the first caller in this repo to find out where its limit is.
+ soc.rom.print("MARK HTTP_IP ip=%u.%u.%u.%u mask=%u.%u.%u.%u\r\n", .{
+ @as(u32, a[0]), @as(u32, a[1]), @as(u32, a[2]), @as(u32, a[3]),
+ @as(u32, m[0]), @as(u32, m[1]), @as(u32, m[2]), @as(u32, m[3]),
+ });
+ soc.rom.print("MARK HTTP_ROUTE gw=%u.%u.%u.%u dns=%u.%u.%u.%u in %u ms\r\n", .{
+ @as(u32, g[0]), @as(u32, g[1]), @as(u32, g[2]), @as(u32, g[3]),
+ @as(u32, d[0]), @as(u32, d[1]), @as(u32, d[2]), @as(u32, d[3]),
+ @as(u32, @intCast(nowMs() - started)),
+ });
+ heapReport();
+}
+
+// ================================================================================== 4. ICMP
+
+/// Yield, and tick the IP stack at most every `tick_interval_ms`.
+///
+/// The rate limit is the whole point. `link.tick` holds the stack's re-entrancy guard for its
+/// duration, and a frame that arrives while the guard is held is dropped - ESP-Hosted's receive task
+/// and this one are different tasks, so they genuinely collide. A loop of the obvious shape,
+///
+/// while (...) { runtime.yield(); _ = net.link.tick(nowMs()); }
+///
+/// ticks thousands of times a second, holds the guard for most of the elapsed time, and drops much
+/// of the traffic it is waiting for. Measured on the board: 40 dropped frames out of 113 received,
+/// and ping answered at about 40%, even though every reply that was attempted did arrive.
+///
+/// Five milliseconds is far finer than anything ip.zig needs - its timers are absolute deadlines, so
+/// a late tick is late rather than lost - and it leaves the guard clear almost all the time.
+fn drive(runtime: *p4.Runtime) void {
+ runtime.yield();
+ const now = nowMs();
+ if (now - last_tick_ms >= tick_interval_ms) {
+ last_tick_ms = now;
+ _ = net.link.tick(now);
+ }
+}
+
+const tick_interval_ms: u64 = 5;
+var last_tick_ms: u64 = 0;
+
+fn pingWindow(runtime: *p4.Runtime) void {
+ const a = net.link.address().?;
+ soc.rom.print(
+ "MARK HTTP_PING_OPEN ip=%u.%u.%u.%u for %u ms - ping it now\r\n",
+ .{
+ @as(u32, a[0]),
+ @as(u32, a[1]),
+ @as(u32, a[2]),
+ @as(u32, a[3]),
+ @as(u32, @intCast(ping_window_ms)),
+ },
+ );
+
+ const started = nowMs();
+ var seen: u32 = net.link.ipCounters().icmp_echo;
+ var last_report: u64 = 0;
+ while (true) {
+ drive(runtime);
+ const c = net.link.ipCounters();
+ if (c.icmp_echo != seen) {
+ seen = c.icmp_echo;
+ // One line per echo answered. `arp` beside it is what proves the exchange was two-way:
+ // the workstation had to ARP for this address and get an answer before it could send
+ // the echo at all.
+ soc.rom.print("MARK HTTP_PING n=%u arp_rx=%u arp_tx=%u\r\n", .{
+ seen,
+ c.arp_rx,
+ c.arp_tx,
+ });
+ }
+ const elapsed = nowMs() - started;
+ if (elapsed - last_report >= 5000) {
+ last_report = elapsed;
+ // Heap included deliberately. The board reported "mempool OOM start (RX)" at ~13 s in a
+ // previous run: with the pool off, ESP-Hosted allocates every received frame from our
+ // heap via `_h_malloc_align(1536, 64)`, and when that fails the receive path simply
+ // stops delivering. `live` climbing while `rx` is static means a leak; `live` pinned
+ // near the heap size with `rx` frozen means a heap too small for the queue depths.
+ // Without both numbers side by side those two are indistinguishable.
+ const h = net.port.stats();
+ soc.rom.print(
+ "MARK HTTP_PING_IDLE t=%u echo=%u rx=%u drop=%u tx=%u live=%u B blocks=%u fails=%u\r\n",
+ .{
+ @as(u32, @intCast(elapsed)),
+ c.icmp_echo,
+ c.rx_frames,
+ c.rx_dropped,
+ c.tx_frames,
+ @as(u32, @intCast(h.bytes_live)),
+ @as(u32, @intCast(h.blocks_live)),
+ @as(u32, @intCast(h.alloc_failures)),
+ },
+ );
+ }
+ if (elapsed > ping_window_ms) break;
+ }
+ soc.rom.print("MARK HTTP_PING_DONE echoes=%u\r\n", .{net.link.ipCounters().icmp_echo});
+}
+
+// ================================================================================== 5. HTTP
+
+/// The response body. Static rather than on the stack because `httpGet` borrows it for the whole
+/// request - the body is written into it from inside the receive callback, on a different task -
+/// and a task stack is 4 KiB.
+var body: [2048]u8 = undefined;
+
+fn httpGet(runtime: *p4.Runtime) void {
+ soc.rom.print("MARK HTTP_GET host=%u.%u.%u.%u port=%u path=%s\r\n", .{
+ @as(u32, http_host[0]), @as(u32, http_host[1]),
+ @as(u32, http_host[2]), @as(u32, http_host[3]),
+ @as(u32, http_port), @as([*:0]const u8, http_path),
+ });
+
+ const started = nowMs();
+ var last_report: u64 = 0;
+ const got: usize = while (true) {
+ drive(runtime);
+ // Identical arguments every call, and `body` never moves: that is the whole of the
+ // WouldBlock protocol. Different arguments mid-flight would return error.Busy instead.
+ if (net.link.httpGet(http_host, http_port, http_path, &body)) |n| {
+ break n;
+ } else |err| switch (err) {
+ error.WouldBlock => {},
+ else => {
+ soc.rom.print("MARK HTTP_FAIL get=%s tcp=%s after %u ms\r\n", .{
+ @errorName(err).ptr,
+ @tagName(net.link.tcpState()).ptr,
+ @as(u32, @intCast(nowMs() - started)),
+ });
+ park();
+ },
+ }
+ const elapsed = nowMs() - started;
+ if (elapsed - last_report >= 2000) {
+ last_report = elapsed;
+ const c = net.link.ipCounters();
+ soc.rom.print("MARK HTTP_WAIT t=%u tcp=%s tx=%u retx=%u rx=%u rst=%u\r\n", .{
+ @as(u32, @intCast(elapsed)),
+ @tagName(net.link.tcpState()).ptr,
+ c.tcp_tx,
+ c.tcp_retx,
+ c.tcp_rx,
+ c.tcp_rst_rx,
+ });
+ }
+ if (elapsed > http_deadline_ms) {
+ const c = net.link.ipCounters();
+ soc.rom.print(
+ "MARK HTTP_FAIL get timeout after %u ms tcp=%s arp_tx=%u tcp_tx=%u tcp_rx=%u\r\n",
+ .{
+ @as(u32, @intCast(elapsed)),
+ @tagName(net.link.tcpState()).ptr,
+ c.arp_tx,
+ c.tcp_tx,
+ c.tcp_rx,
+ },
+ );
+ park();
+ }
+ };
+
+ soc.rom.print("MARK HTTP_STATUS code=%u len=%u in %u ms\r\n", .{
+ @as(u32, net.link.httpStatus()),
+ @as(u32, @intCast(got)),
+ @as(u32, @intCast(nowMs() - started)),
+ });
+ printBody(body[0..got]);
+ heapReport();
+}
+
+/// The first bytes of the body, on one line, with anything that is not printable ASCII shown as
+/// '.'. A response body is arbitrary bytes and this is a serial console: a stray 0x0d would split
+/// the line and a 0x00 would truncate it, and either would look like a short read.
+fn printBody(bytes: []const u8) void {
+ const preview_max = 120;
+ var buf: [preview_max + 1]u8 = undefined;
+ const n = @min(bytes.len, preview_max);
+ for (bytes[0..n], 0..) |b, i| buf[i] = if (b >= 0x20 and b < 0x7f) b else '.';
+ buf[n] = 0;
+ soc.rom.print("MARK HTTP_BODY %u/%u |%s|\r\n", .{
+ @as(u32, @intCast(n)),
+ @as(u32, @intCast(bytes.len)),
+ @as([*:0]const u8, @ptrCast(&buf)),
+ });
+}
+
+// ============================================================================== 6. stay live
+
+/// Keep running. The transport's threads must keep being scheduled, the stack keeps answering ICMP,
+/// and leaving the board in a live state is what lets the next experiment start from here.
+/// A real host on the real internet: resolve a name, then fetch it.
+///
+/// A strictly harder test than the GET against this workstation, and every extra thing it exercises
+/// is one the local test could not:
+///
+/// * DNS. The name is resolved over UDP against the server DHCP handed us - on this network the
+/// gateway - so the resolver, the wire format and its name-compression handling are all in play.
+/// * Off-subnet routing. 0x4200.cafe is not on 192.168.1.0/24, so the stack has to ARP for the
+/// GATEWAY and send to its MAC while the IP header addresses the far host. The local test never
+/// leaves the subnet and so never proved that.
+/// * Name-based virtual hosting. The site is behind Cloudflare, which serves it only for a request
+/// carrying `Host: 0x4200.cafe`. `Host: 104.21.46.8` gets someone else's error page - which is
+/// the entire reason `httpGetHost` exists.
+/// * Chunked transfer encoding. Measured from a workstation the response is
+/// `Transfer-Encoding: chunked` with no Content-Length, so the body arrives in framed pieces
+/// whose boundaries fall wherever TCP puts them.
+///
+/// The lines, and what each means:
+///
+/// MARK NET_DNS name -> a.b.c.d in N ms the resolver worked end to end
+/// bad: failed=Timeout no reply from the DHCP-supplied server inside the bound
+/// failed=NoAddress no A record (the name does have AAAA; this stack is IPv4)
+/// failed=Malformed a reply arrived and was rejected
+/// MARK NET_GET host=... via gw=... the request begins, naming the next hop
+/// MARK NET_STATUS code=200 len=N in M ms a real page came back
+/// bad: get=HttpChunkMalformed chunked framing rejected
+/// get=StreamTooLong the page is bigger than the buffer here - not a stack bug; it
+/// means this buffer is small, and this says so rather than truncating
+/// MARK NET_BODY n/N |...| first bytes, non-printables as '.'
+const internet_name = "0x4200.cafe";
+
+/// 2 KiB, which is what the target resource needs and no more.
+///
+/// The site's root page is 132,932 bytes decoded - larger than this board's entire 128 KB of L2MEM,
+/// so it cannot be buffered whole by anything running here and `StreamTooLong` is the correct answer
+/// for asking. Fetching it would need a streaming API that hands the caller each chunk as it arrives
+/// and keeps none of it, which this stack does not have. That is a real limitation and it is stated
+/// rather than hidden.
+///
+/// `/robots.txt` on the same host is 1,248 bytes and exercises everything the root page would about
+/// the path being tested here: the name is resolved by DNS, the destination is off-subnet so the
+/// frame goes to the gateway's MAC, and the response only comes back at all because the request
+/// carries `Host: 0x4200.cafe`.
+var net_body: [2048]u8 = undefined;
+
+/// The path fetched. See `net_body` for why it is not `/`.
+const internet_path = "/robots.txt";
+
+fn internetGet(runtime: *p4.Runtime) void {
+ // 1. Resolve. Same WouldBlock protocol as httpGet: identical argument every call, drive between.
+ const dns_started = nowMs();
+ const addr: [4]u8 = while (true) {
+ drive(runtime);
+ if (net.link.resolve(internet_name)) |a| {
+ break a;
+ } else |err| switch (err) {
+ error.WouldBlock => {},
+ else => {
+ soc.rom.print("MARK NET_DNS %s failed=%s after %u ms\r\n", .{
+ internet_name.ptr,
+ @errorName(err).ptr,
+ @as(u32, @intCast(nowMs() - dns_started)),
+ });
+ return;
+ },
+ }
+ if (nowMs() - dns_started > 15_000) {
+ soc.rom.print("MARK NET_DNS %s gave up after %u ms\r\n", .{
+ internet_name.ptr,
+ @as(u32, @intCast(nowMs() - dns_started)),
+ });
+ return;
+ }
+ };
+ soc.rom.print("MARK NET_DNS %s -> %u.%u.%u.%u in %u ms\r\n", .{
+ internet_name.ptr,
+ @as(u32, addr[0]),
+ @as(u32, addr[1]),
+ @as(u32, addr[2]),
+ @as(u32, addr[3]),
+ @as(u32, @intCast(nowMs() - dns_started)),
+ });
+
+ // The gateway is printed because it is the thing under test here: for an off-subnet destination
+ // the Ethernet frame goes to the gateway's MAC while the IP header addresses the far host, and a
+ // stack that got that wrong would look like a dead internet rather than a routing bug.
+ const gw = net.link.gateway();
+ soc.rom.print("MARK NET_GET host=%s%s via gw=%u.%u.%u.%u\r\n", .{
+ internet_name.ptr,
+ internet_path.ptr,
+ @as(u32, gw[0]),
+ @as(u32, gw[1]),
+ @as(u32, gw[2]),
+ @as(u32, gw[3]),
+ });
+
+ // 2. Fetch, with the NAME in the Host header rather than the address.
+ const started = nowMs();
+ var last_report: u64 = 0;
+ const got: usize = while (true) {
+ drive(runtime);
+ if (net.link.httpGetHost(addr, internet_name, 80, internet_path, &net_body)) |n| {
+ break n;
+ } else |err| switch (err) {
+ error.WouldBlock => {},
+ else => {
+ soc.rom.print("MARK NET_FAIL get=%s tcp=%s after %u ms\r\n", .{
+ @errorName(err).ptr,
+ @tagName(net.link.tcpState()).ptr,
+ @as(u32, @intCast(nowMs() - started)),
+ });
+ return;
+ },
+ }
+ const elapsed = nowMs() - started;
+ if (elapsed - last_report >= 2000) {
+ last_report = elapsed;
+ soc.rom.print("MARK NET_WAIT t=%u tcp=%s\r\n", .{
+ @as(u32, @intCast(elapsed)),
+ @tagName(net.link.tcpState()).ptr,
+ });
+ }
+ if (elapsed > 30_000) {
+ soc.rom.print("MARK NET_FAIL timeout tcp=%s\r\n", .{@tagName(net.link.tcpState()).ptr});
+ return;
+ }
+ };
+
+ soc.rom.print("MARK NET_STATUS code=%u len=%u in %u ms\r\n", .{
+ @as(u32, net.link.httpStatus()),
+ @as(u32, @intCast(got)),
+ @as(u32, @intCast(nowMs() - started)),
+ });
+ // First bytes only, printable-filtered: this is a serial line and an HTML page is not a log.
+ var shown: [96]u8 = undefined;
+ const n = @min(got, shown.len - 1);
+ for (0..n) |i| {
+ const c = net_body[i];
+ shown[i] = if (c >= 0x20 and c < 0x7f) c else '.';
+ }
+ shown[n] = 0;
+ soc.rom.print("MARK NET_BODY %u/%u |%s|\r\n", .{
+ @as(u32, @intCast(n)),
+ @as(u32, @intCast(got)),
+ @as([*:0]const u8, @ptrCast(&shown)),
+ });
+}
+
+fn live(runtime: *p4.Runtime) noreturn {
+ var last: u64 = 0;
+ var last_echo: u32 = 0;
+ while (true) {
+ drive(runtime);
+ const t = nowMs();
+ if (t - last >= 1000) {
+ last = t;
+ const c = net.link.ipCounters();
+ const l = net.link.stats();
+ soc.rom.print(
+ "MARK HTTP_LIVE t=%u echo=%u(+%u) rx=%u drop=%u tx=%u txfail=%u re=%u skip=%u\r\n",
+ .{
+ @as(u32, @intCast(t / 1000)),
+ c.icmp_echo,
+ c.icmp_echo - last_echo,
+ c.rx_frames,
+ c.rx_dropped,
+ l.tx_frames,
+ l.tx_failed,
+ l.rx_reentrant,
+ l.tick_skipped,
+ },
+ );
+ last_echo = c.icmp_echo;
+ }
+ }
+}
+
+// =================================================================================== shared
+
+fn heapReport() void {
+ const s = net.port.stats();
+ soc.rom.print("MARK HTTP_HEAP live=%u B reserved=%u B peak=%u B blocks=%u fails=%u\r\n", .{
+ @as(u32, @intCast(s.bytes_live)),
+ @as(u32, @intCast(s.bytes_reserved)),
+ @as(u32, @intCast(s.peak_reserved)),
+ @as(u32, @intCast(s.blocks_live)),
+ @as(u32, @intCast(s.alloc_failures)),
+ });
+}
+
+fn fail(comptime what: []const u8, why: [*:0]const u8) noreturn {
+ soc.rom.print("MARK HTTP_FAIL " ++ what ++ "=%s\r\n", .{why});
+ park();
+}
+
+fn nowMs() u64 {
+ // The systimer is a fixed 16 MHz and the only trustworthy timebase here: the CPU runs at the
+ // bootloader's 90 MHz and nothing in this image reconfigures the PLL. A failed snapshot read
+ // yields 0, which makes an elapsed calculation conservative rather than wrong.
+ const ticks = hal.systimer.read(.unit0) orelse return 0;
+ return ticks / 16_000;
+}
+
+/// `while (true) {}` rather than a reset, so the last message stays on screen instead of scrolling
+/// past in a reboot loop. Note that this stops the scheduler too: nothing here is recoverable.
+fn park() noreturn {
+ soc.rom.print("MARK HTTP_DONE parked\r\n", .{});
+ while (true) {}
+}
diff --git a/examples/intrcheck.zig b/examples/intrcheck.zig
new file mode 100644
index 0000000..278ee8e
--- /dev/null
+++ b/examples/intrcheck.zig
@@ -0,0 +1,269 @@
+//! Does an interrupt actually get taken? The one question the differential harness cannot answer.
+//!
+//! zig build -Dapp=examples/intrcheck.zig run -Dseconds=10
+//!
+//! The register differential proves that `hal.intr`'s writes land in the same registers ESP-IDF's do.
+//! It cannot prove that the CLIC then delivers anything, because delivery leaves no trace in any
+//! register it photographs: mtvec and MTVT are CSRs, the vector table is memory, and whether the
+//! core vectored to the right handler is a fact about control flow.
+//!
+//! So this is the behavioural half, and it is deliberately arranged so each failure mode prints
+//! something different rather than all of them looking like a silent hang:
+//!
+//! * counter 0 and pending 1 - the CLIC latched it and the core never took it: mtvec, MTVT or MIE.
+//! * counter 0 and pending 0 - never latched: the matrix write missed, or the timer never fired
+//! (which the raw status distinguishes).
+//! * counter > 1 - the handler returned without clearing the source, and a level
+//! interrupt re-enters forever. On this chip the CLIC has no
+//! acknowledge for a level source, so clearing at the peripheral is
+//! the only way out, and forgetting it looks exactly like a crash.
+//! * spurious > 0 - an interrupt arrived on a line nobody claimed: a routing write
+//! went somewhere unintended.
+//!
+//! The second phase is the sharper test, and it is the claim the whole CLIC port is least able to
+//! support any other way: raise the threshold *above* the line's priority, confirm the interrupt
+//! latches but is not delivered, then lower it and confirm the pending interrupt arrives. That is
+//! what shows the memory-mapped threshold register at 0x2080_0008 is the one the arbiter reads -
+//! rather than the `mintthresh` CSR, which on this die accepts writes and does nothing.
+//!
+//! TIMG1's interrupt-enable and interrupt-clear registers are reached here through `regs` directly,
+//! because `hal.timg` deliberately does not model interrupts. That is the register layer doing its
+//! job: a peripheral the HAL has not covered yet is still fully addressable.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK INTR_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// TIMG1's timer-0 alarm interrupt. Group index 1.
+const timg1_int_ena = mmio.Reg.atAddress(@intCast(regs.TIMG_INT_ENA_TIMERS_REG(1)));
+const timg1_int_raw = mmio.Reg.atAddress(@intCast(regs.TIMG_INT_RAW_TIMERS_REG(1)));
+const timg1_int_clr = mmio.Reg.atAddress(@intCast(regs.TIMG_INT_CLR_TIMERS_REG(1)));
+const t0_int_ena = mmio.Field.of(regs.TIMG_T0_INT_ENA_S, regs.TIMG_T0_INT_ENA_V);
+const t0_int_raw = mmio.Field.of(regs.TIMG_T0_INT_RAW_S, regs.TIMG_T0_INT_RAW_V);
+const t0_int_clr = mmio.Field.of(regs.TIMG_T0_INT_CLR_S, regs.TIMG_T0_INT_CLR_V);
+
+/// The CLIC line under test. 5 is arbitrary and free; the differential suite uses 5 and 24.
+const line: u5 = 5;
+
+var fired: u32 = 0;
+
+fn onAlarm(l: u5) void {
+ fired += 1;
+ // Two things, and both are needed to return exactly once.
+ //
+ // Clear at the *peripheral*: a level-triggered source stays asserted until the peripheral
+ // deasserts it, and this chip's CLIC offers no acknowledge for one, so a handler that returns
+ // without clearing re-enters immediately and forever with the console silent.
+ //
+ // Then disable the alarm. Clearing the status alone is not enough: with auto-reload off the
+ // counter keeps running past the alarm value, the comparator stays satisfied, and the interrupt
+ // is re-asserted as fast as it is cleared. That is the same silent re-entry by a different
+ // route, and it is what this test hit first.
+ // Mask globally first, before anything else. Any handler that can be re-entered before it has
+ // deasserted its source is one console-silent hang away from being undiagnosable, and this test
+ // exists to distinguish failure modes rather than to demonstrate a tidy handler.
+ hal.intr.globalDisable();
+ timg1_int_clr.write(.{t0_int_clr.is(1)});
+ hal.timg.setAlarmEnabled(.timg1, .t0, false);
+ _ = l;
+}
+
+fn armTimer(alarm_ticks: u64) void {
+ hal.clkrst.setClockEnabled(.timg1, true);
+ hal.clkrst.resetPeripheral(.timg1);
+ // 40 MHz APB with a divider of 400 gives 100 kHz, so the alarm value is in units of 10 us.
+ hal.timg.setDivider(.timg1, .t0, 400);
+ hal.timg.setAutoReload(.timg1, .t0, false);
+ hal.timg.setAlarmValue(.timg1, .t0, alarm_ticks);
+ hal.timg.load(.timg1, .t0);
+ timg1_int_ena.modify(.{t0_int_ena.is(1)});
+ hal.timg.setAlarmEnabled(.timg1, .t0, true);
+ hal.timg.setCounterEnabled(.timg1, .t0, true);
+}
+
+fn disarmTimer() void {
+ hal.timg.setCounterEnabled(.timg1, .t0, false);
+ hal.timg.setAlarmEnabled(.timg1, .t0, false);
+ timg1_int_ena.modify(.{t0_int_ena.is(0)});
+ timg1_int_clr.write(.{t0_int_clr.is(1)});
+}
+
+export fn zig_main() noreturn {
+ // Without this the board resets about ten seconds in, mid-test.
+ _ = hal.rwdt.disable();
+
+ soc.rom.print("\r\nMARK INTR_START clic behavioural test\r\n", .{});
+ soc.rom.print("MARK INTR_CFG mtvt_csr=0x%x mintstatus_csr=0x%x nlbits=%u ext_offset=%u\r\n", .{
+ @as(u32, hal.intr.mtvt_csr),
+ @as(u32, hal.intr.mintstatus_csr),
+ @as(u32, hal.intr.NLBITS),
+ @as(u32, hal.intr.ext_offset),
+ });
+
+ // The bootloader hands over with mstatus.MIE set - `init()` masks it, and this records what it
+ // found, because that fact is what makes the ordering below matter at all.
+ const mie_at_boot = hal.intr.globalEnabled();
+
+ // A parked core is silent, and every mistake in a trap handler parks the core. This hook is the
+ // difference between a diagnosis and a reflash.
+ hal.intr.on_fault = struct {
+ fn f(x: hal.intr.Fault) void {
+ soc.rom.print("MARK INTR_FAULT mcause=0x%08x mepc=0x%08x mtval=0x%08x taken=%u last_id=%u fired=%u\r\n", .{
+ x.mcause, x.mepc, x.mtval, hal.intr.taken, @as(u32, hal.intr.last_clic_id), fired,
+ });
+ }
+ }.f;
+
+ hal.intr.init();
+ // What the ROM left behind, captured before init() cleared it. A non-zero enabled_lines is the
+ // whole explanation for the first version of this test hanging: the ROM hands over with lines
+ // armed and MIE set, so the first globalEnable() delivers someone else's interrupt to a handler
+ // that does not exist, and a level source then re-enters forever.
+ soc.rom.print("MARK INTR_BOOT mie=%u rom_enabled_lines=0x%08x rom_routed_sources=%u mtvec=0x%08x mtvt=0x%08x entry=0x%08x table=0x%08x\r\n", .{
+ @as(u32, @intFromBool(hal.intr.boot_state.mie)),
+ hal.intr.boot_state.enabled_lines,
+ hal.intr.boot_state.routed_sources,
+ hal.intr.readMtvec(),
+ hal.intr.readMtvt(),
+ hal.intr.trapEntryAddress(),
+ hal.intr.vectorTableAddress(),
+ });
+ _ = mie_at_boot;
+
+ // Quiesce the source before its line is enabled. TIMG1's raw interrupt status survives a
+ // reflash, and a level-triggered source that is already asserted fires the instant IE goes up -
+ // which, before init() masked MIE, was an immediate re-entrant trap.
+ timg1_int_clr.writeRaw(0xffff_ffff);
+
+ // What the hardware will actually fetch. With SHV=1 the CLIC loads the handler address from
+ // MTVT[id] and jumps there, so this slot - id 21 for line 5 - is the address the core will run.
+ const tbl = hal.intr.vectorTableAddress();
+ soc.rom.print("MARK INTR_TABLE table=0x%08x slot21=0x%08x slot0=0x%08x expect_entry=0x%08x\r\n", .{
+ tbl,
+ mmio.Reg.atAddress(tbl + 4 * 21).raw(),
+ mmio.Reg.atAddress(tbl).raw(),
+ hal.intr.trapEntryAddress(),
+ });
+
+ hal.intr.setThreshold(0);
+ hal.intr.attach(.tg1_t0, line, .{ .handler = onAlarm, .trigger = .level, .priority = 1 });
+ soc.rom.print("MARK INTR_ROUTE tg1_t0(49) -> line %u, routed_line=%u threshold=%u\r\n", .{
+ @as(u32, line),
+ @as(u32, hal.intr.routedLine(.tg1_t0) orelse 99),
+ @as(u32, hal.intr.getThreshold()),
+ });
+
+ // ------------------------------------------- phase 0: does the source reach the CLIC at all?
+ // No MIE, so nothing can be taken and nothing can hang: this asks only whether the matrix and
+ // the CLIC latch a real peripheral event. If pending stays 0 here, everything after it is moot.
+ timg1_int_clr.writeRaw(0xffff_ffff);
+ armTimer(2_000); // 20 ms
+ soc.rom.ets_delay_us(100_000);
+ const p0_raw = timg1_int_raw.get(t0_int_raw);
+ const p0_pending = hal.intr.isPending(line);
+ disarmTimer();
+ soc.rom.print("MARK INTR_PHASE0 timer_raw=%u expect=1 clic_pending=%u expect=1 (no MIE, cannot hang)\r\n", .{
+ p0_raw, @as(u32, @intFromBool(p0_pending)),
+ });
+
+ // ------------------------------------------------------------------ phase 1: take exactly one
+ fired = 0;
+ hal.intr.spurious = 0;
+ armTimer(5_000); // 50 ms
+ hal.intr.globalEnable();
+ soc.rom.ets_delay_us(200_000);
+ hal.intr.globalDisable();
+
+ const took = fired;
+ const spur = hal.intr.spurious;
+ const raw_after = timg1_int_raw.get(t0_int_raw);
+ const pend_after = hal.intr.isPending(line);
+ disarmTimer();
+
+ // `taken` and `last_clic_id` split "latched but not delivered" in two: taken=0 means the trap
+ // was never entered (mtvec, MTVT or SHV), taken>0 with fired=0 means it was entered and the
+ // handler lookup missed.
+ soc.rom.print("MARK INTR_PHASE1 fired=%u expect=1 taken=%u spurious=%u expect=0 last_clic_id=%u expect=21 timer_raw=%u pending=%u\r\n", .{
+ took, hal.intr.taken, spur, @as(u32, hal.intr.last_clic_id), raw_after, @as(u32, @intFromBool(pend_after)),
+ });
+ if (took == 1 and spur == 0) {
+ soc.rom.print("MARK INTR_PHASE1 PASS an interrupt was taken and vectored to its handler\r\n", .{});
+ } else if (took == 0 and pend_after) {
+ soc.rom.print("MARK INTR_PHASE1 FAIL latched but not taken - mtvec, MTVT or MIE\r\n", .{});
+ } else if (took == 0 and raw_after == 0) {
+ soc.rom.print("MARK INTR_PHASE1 FAIL the timer never fired; this measured nothing\r\n", .{});
+ } else if (took == 0) {
+ soc.rom.print("MARK INTR_PHASE1 FAIL timer fired but never latched - the matrix write missed\r\n", .{});
+ } else {
+ soc.rom.print("MARK INTR_PHASE1 FAIL re-entered %u times - the handler is not clearing the source\r\n", .{took});
+ }
+
+ // ------------------------------------------- phase 2: is the memory-mapped threshold the real one
+ // Priority 1 against a threshold of 7 must not be delivered. If the arbiter were reading the
+ // mintthresh CSR instead - which this die does not implement, and which accepts writes silently -
+ // the threshold would read back correctly and the interrupt would arrive anyway.
+ fired = 0;
+ hal.intr.spurious = 0;
+ hal.intr.setThreshold(7);
+ armTimer(5_000);
+ hal.intr.globalEnable();
+ soc.rom.ets_delay_us(200_000);
+
+ const blocked = fired;
+ const pending_while_blocked = hal.intr.isPending(line);
+
+ // Now drop the threshold with MIE still on: the latched interrupt must be delivered.
+ hal.intr.setThreshold(0);
+ soc.rom.ets_delay_us(50_000);
+ hal.intr.globalDisable();
+ const after_drop = fired;
+ disarmTimer();
+
+ soc.rom.print("MARK INTR_PHASE2 blocked=%u expect=0 pending_while_blocked=%u expect=1 after_drop=%u expect=1\r\n", .{
+ blocked, @as(u32, @intFromBool(pending_while_blocked)), after_drop,
+ });
+ if (blocked == 0 and pending_while_blocked and after_drop >= 1) {
+ soc.rom.print("MARK INTR_PHASE2 PASS the memory-mapped threshold at 0x20800008 is the one the arbiter reads\r\n", .{});
+ } else if (blocked > 0) {
+ soc.rom.print("MARK INTR_PHASE2 FAIL delivered despite threshold 7 - the write is not reaching the arbiter\r\n", .{});
+ } else {
+ soc.rom.print("MARK INTR_PHASE2 FAIL blocked but never delivered after the drop\r\n", .{});
+ }
+
+ soc.rom.print("MARK INTR_DONE\r\n", .{});
+
+ hal.gpio.configureOutput(20, .{ .readback = true });
+ while (true) {
+ hal.gpio.setHigh(20);
+ soc.rom.ets_delay_us(500_000);
+ hal.gpio.setLow(20);
+ soc.rom.ets_delay_us(500_000);
+ }
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/iocheck.zig b/examples/iocheck.zig
new file mode 100644
index 0000000..959e7f4
--- /dev/null
+++ b/examples/iocheck.zig
@@ -0,0 +1,110 @@
+//! Does `std.Io.sleep` wake up on this chip?
+//!
+//! Written to answer one question during radio bring-up. ESP-Hosted's transport stops immediately
+//! after a successful SDIO card init, at a `_h_msleep(100)` - and a cooperative scheduler whose
+//! sleep never returns is indistinguishable, from the outside, from a driver that hung. The 21 host
+//! tests in src/io/p4.zig cover the scheduler against a virtual clock; what they cannot cover is
+//! whether a real deadline against the real systimer ever comes due.
+//!
+//! Four claims, each answered by the die:
+//!
+//! MARK IO_NOW the timebase advances at all, and at 16 MHz
+//! MARK IO_SLEEP a sleep from the main task returns, and after roughly the right time
+//! MARK IO_TASK a spawned task runs, sleeps, and finishes
+//! MARK IO_ORDER three tasks with different deadlines wake in deadline order
+//!
+//! Run: zig build -Dapp=examples/iocheck.zig run -Dseconds=15
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const p4 = @import("io");
+
+pub const std_options: std.Options = .{ .page_size_min = 4096 };
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK IO_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+var pool: p4.Static(4, 4096) = .{};
+
+/// Wake order, appended by the three tasks in `IO_ORDER`.
+var order: [3]u8 = .{ 0, 0, 0 };
+var order_len: usize = 0;
+
+fn sleeper(ctx: struct { io: std.Io, ms: i64, tag: u8 }) void {
+ ctx.io.sleep(.fromMilliseconds(ctx.ms), .awake) catch {};
+ order[order_len] = ctx.tag;
+ order_len += 1;
+}
+
+export fn zig_main() noreturn {
+ // The bootloader leaves this armed and nothing here feeds it.
+ _ = hal.rwdt.disable();
+
+ const runtime = pool.init(.{});
+ const io = runtime.io();
+
+ // 1. Does the timebase move? Everything below is meaningless if not.
+ const t0 = hal.systimer.read(.unit0) orelse 0;
+ soc.rom.ets_delay_us(10_000);
+ const t1 = hal.systimer.read(.unit0) orelse 0;
+ soc.rom.print("MARK IO_NOW advanced=%u ticks expect~160000\r\n", .{@as(u32, @truncate(t1 - t0))});
+
+ // 2. The question that matters: does a sleep from the calling task return? Measured against the
+ // systimer rather than trusted, because a sleep that returns instantly would also look like
+ // success from a print alone.
+ const before = hal.systimer.read(.unit0) orelse 0;
+ io.sleep(.fromMilliseconds(100), .awake) catch |err| {
+ soc.rom.print("MARK IO_SLEEP failed=%s\r\n", .{@errorName(err).ptr});
+ park();
+ };
+ const after = hal.systimer.read(.unit0) orelse 0;
+ const slept_ms = (after - before) / 16_000;
+ soc.rom.print("MARK IO_SLEEP returned after %u ms, asked 100\r\n", .{@as(u32, @truncate(slept_ms))});
+
+ // 3. A spawned task that sleeps: this is the shape ESP-Hosted's threads have.
+ var fut = io.async(sleeper, .{.{ .io = io, .ms = 20, .tag = 'A' }});
+ fut.await(io);
+ soc.rom.print("MARK IO_TASK ran=%u\r\n", .{@as(u32, @intCast(order_len))});
+
+ // 4. Three deadlines, out of submission order, to prove the scheduler picks the earliest.
+ order_len = 0;
+ var f1 = io.async(sleeper, .{.{ .io = io, .ms = 60, .tag = '3' }});
+ var f2 = io.async(sleeper, .{.{ .io = io, .ms = 20, .tag = '1' }});
+ var f3 = io.async(sleeper, .{.{ .io = io, .ms = 40, .tag = '2' }});
+ f1.await(io);
+ f2.await(io);
+ f3.await(io);
+ soc.rom.print("MARK IO_ORDER %c%c%c expect 123\r\n", .{
+ @as(u32, order[0]), @as(u32, order[1]), @as(u32, order[2]),
+ });
+
+ soc.rom.print("MARK IO_DONE\r\n", .{});
+ park();
+}
+
+fn park() noreturn {
+ while (true) {}
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/minimal.zig b/examples/minimal.zig
new file mode 100644
index 0000000..a70f0ef
--- /dev/null
+++ b/examples/minimal.zig
@@ -0,0 +1,42 @@
+//! The floor: the smallest thing that can boot on this chip and prove it is alive.
+//! No printing, no descriptor text - just a pin that toggles. Build it with
+//! zig build -Dapp=examples/minimal.zig size
+
+const std = @import("std");
+const config = @import("config");
+const soc = @import("soc");
+
+/// Even the floor needs a panic handler: the default one formats and would pull in half of std.
+pub const panic = std.debug.FullPanic(struct {
+ fn call(_: []const u8, _: ?usize) noreturn {
+ while (true) {}
+ }
+}.call);
+const led: u6 = @intCast(config.led_pin);
+
+export fn zig_main() noreturn {
+ soc.gpio.configureOutput(led);
+ while (true) {
+ soc.gpio.setHigh(led);
+ soc.rom.ets_delay_us(100_000);
+ soc.gpio.setLow(led);
+ soc.rom.ets_delay_us(100_000);
+ }
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/pie.zig b/examples/pie.zig
new file mode 100644
index 0000000..9c51cc3
--- /dev/null
+++ b/examples/pie.zig
@@ -0,0 +1,224 @@
+//! Using the ESP32-P4's vendor ISA extensions (xespv2p1 / xesploop1p0, "PIE") from stock Zig.
+//!
+//! Two separate gaps hide behind "LLVM does not support the vendor extensions":
+//!
+//! 1. The optimiser will never *choose* an `esp.*` instruction, because LLVM has no cost model or
+//! intrinsics for the PIE unit. That is a performance ceiling: you get no autovectorisation.
+//! 2. The assembler cannot *encode* the mnemonics. Inline assembly does **not** help - it goes
+//! through the same integrated assembler:
+//!
+//! asm volatile ("esp.vld.128.ip q0, a0, 16")
+//! -> error: <inline asm>:1:2: unrecognized instruction mnemonic
+//!
+//! That is the real blocker, and it is what stops Zig from assembling ESP-IDF's own FreeRTOS
+//! context switch (portasm.S, 34 instructions under `#if SOC_CPU_HAS_PIE`).
+//!
+//! Gap 2 closes without a compiler fork: emit the words with `.insn` and pin the registers the
+//! fixed encoding names. `tools/encode.sh` generates the words with Espressif's GAS as a
+//! build-time oracle; every constant below carries the mnemonic it came from. Two of them
+//! (vld/vst q0) were also read straight out of an ESP-IDF build of this board with Espressif's
+//! objdump, and agree byte for byte.
+//!
+//! Gap 1 does not close this way - hand-written kernels only. But that is what DSP code does
+//! anyway: esp-dsp is hand-written assembly even under Espressif's own fork.
+
+const std = @import("std");
+const soc = @import("soc");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK PIE_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// CSR 0x7F2 is `CSR_PIE_STATE_REG`; ESP-IDF writes 1 to enable the unit
+/// (riscv/include/riscv/rv_utils.h:341, riscv/include/riscv/csr_pie.h:19).
+inline fn enablePie() void {
+ asm volatile ("csrw 0x7f2, 1");
+}
+
+/// 16 bytes through the PIE register file: one load, one store, neither spellable by the assembler.
+/// Both instructions post-increment their base register, so a0 is an in-out operand.
+fn pieCopy16(dst: *align(16) volatile [16]u8, src: *align(16) const volatile [16]u8) void {
+ var p: usize = @intFromPtr(src);
+ asm volatile (".insn 4, 0x0201223b" // esp.vld.128.ip q0, a0, 16
+ : [p] "={a0}" (p),
+ : [in] "{a0}" (p),
+ : .{ .memory = true });
+ var q: usize = @intFromPtr(dst);
+ asm volatile (".insn 4, 0x8201223b" // esp.vst.128.ip q0, a0, 16
+ : [q] "={a0}" (q),
+ : [in] "{a0}" (q),
+ : .{ .memory = true });
+}
+
+/// The reason the extension exists: 16 signed 8-bit multiply-accumulates in one instruction.
+///
+/// `a` and `b` are 16 bytes each; QACC accumulates 16 lanes which are then spilled as 64 bytes.
+/// The four `st.qacc` instructions dump the accumulator in quarters, chaining through a0.
+///
+/// q0/q1 stay live across the two asm blocks. That is only sound because nothing else in this
+/// image emits a PIE instruction - LLVM cannot see the q registers, so it cannot preserve them.
+fn pieMacS8(
+ out: *align(16) volatile [64]u8,
+ a: *align(16) const volatile [16]i8,
+ b: *align(16) const volatile [16]i8,
+) void {
+ var p: usize = @intFromPtr(a);
+ asm volatile (
+ \\ .insn 4, 0x0201223b # esp.vld.128.ip q0, a0, 16
+ : [p] "={a0}" (p),
+ : [in] "{a0}" (p),
+ : .{ .memory = true });
+ p = @intFromPtr(b);
+ asm volatile (
+ \\ .insn 4, 0x0201263b # esp.vld.128.ip q1, a0, 16
+ : [p] "={a0}" (p),
+ : [in] "{a0}" (p),
+ : .{ .memory = true });
+
+ var q: usize = @intFromPtr(out);
+ asm volatile (
+ \\ .insn 4, 0x0000025b # esp.zero.qacc
+ \\ .insn 4, 0x06c5005f # esp.vmulas.s8.qacc q0, q1
+ \\ .insn 4, 0xa001423b # esp.st.qacc.l.l.128.ip a0, 16
+ \\ .insn 4, 0x8001423b # esp.st.qacc.l.h.128.ip a0, 16
+ \\ .insn 4, 0xe001423b # esp.st.qacc.h.l.128.ip a0, 16
+ \\ .insn 4, 0xc001423b # esp.st.qacc.h.h.128.ip a0, 16
+ : [q] "={a0}" (q),
+ : [in] "{a0}" (q),
+ : .{ .memory = true });
+}
+
+var source: [16]u8 align(16) = .{ 0x42, 0x00, 0xca, 0xfe, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 };
+var dest: [16]u8 align(16) = @splat(0);
+
+var vec_a: [16]i8 align(16) = @splat(1);
+var vec_b: [16]i8 align(16) = .{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 };
+var qacc: [64]u8 align(16) = @splat(0);
+
+var big_a: [256]i8 align(16) = blk: {
+ var v: [256]i8 = undefined;
+ for (&v, 0..) |*e, i| e.* = @intCast((i % 7) + 1);
+ break :blk v;
+};
+var big_b: [256]i8 align(16) = blk: {
+ var v: [256]i8 = undefined;
+ for (&v, 0..) |*e, i| e.* = @intCast((i % 5) + 1);
+ break :blk v;
+};
+
+/// Gap 1, measured: a 256-element signed dot product, the way the compiler emits it versus the way
+/// the PIE unit does it. LLVM cannot autovectorise into `esp.*`, so the scalar loop is what any
+/// stock-toolchain build gets - with Espressif's fork too, since their LLVM has no autovectoriser
+/// for this unit either. The vector version is sixteen MACs per instruction with the accumulator
+/// spilled once at the end.
+fn dotScalar(a: *const volatile [256]i8, b: *const volatile [256]i8) i32 {
+ var acc: i32 = 0;
+ for (0..256) |i| acc += @as(i32, a[i]) * @as(i32, b[i]);
+ return acc;
+}
+
+fn dotPie(out: *align(16) volatile [64]u8, a: *align(16) const volatile [256]i8, b: *align(16) const volatile [256]i8) i32 {
+ asm volatile (".insn 4, 0x0000025b" ::: .{ .memory = true }); // esp.zero.qacc
+ var pa: usize = @intFromPtr(a);
+ var pb: usize = @intFromPtr(b);
+ for (0..16) |_| {
+ asm volatile (".insn 4, 0x0201223b" // esp.vld.128.ip q0, a0, 16
+ : [p] "={a0}" (pa),
+ : [in] "{a0}" (pa),
+ : .{ .memory = true });
+ asm volatile (
+ \\ .insn 4, 0x0201263b # esp.vld.128.ip q1, a0, 16
+ \\ .insn 4, 0x06c5005f # esp.vmulas.s8.qacc q0, q1
+ : [p] "={a0}" (pb),
+ : [in] "{a0}" (pb),
+ : .{ .memory = true });
+ }
+ var q: usize = @intFromPtr(out);
+ asm volatile (
+ \\ .insn 4, 0xa001423b # esp.st.qacc.l.l.128.ip a0, 16
+ \\ .insn 4, 0x8001423b # esp.st.qacc.l.h.128.ip a0, 16
+ \\ .insn 4, 0xe001423b # esp.st.qacc.h.l.128.ip a0, 16
+ \\ .insn 4, 0xc001423b # esp.st.qacc.h.h.128.ip a0, 16
+ : [q] "={a0}" (q),
+ : [in] "{a0}" (q),
+ : .{ .memory = true });
+ // Sixteen lane accumulators, horizontally summed by the scalar core.
+ var acc: i32 = 0;
+ for (0..16) |i| acc += @as(i32, @bitCast(@as(u32, out[i*4]) | @as(u32, out[i*4+1]) << 8 | @as(u32, out[i*4+2]) << 16 | @as(u32, out[i*4+3]) << 24));
+ return acc;
+}
+
+export fn zig_main() noreturn {
+ soc.rom.print("\r\nMARK PIE_START csr 0x7f2 <- 1\r\n", .{});
+ enablePie();
+ soc.rom.print("MARK PIE_ENABLED survived the CSR write\r\n", .{});
+
+ // 1. vector load/store.
+ pieCopy16(&dest, &source);
+ var copy_ok = true;
+ for (source, dest) |x, y| {
+ if (x != y) copy_ok = false;
+ }
+ soc.rom.print("MARK PIE_COPY dest=%02x%02x%02x%02x match=%u\r\n", .{
+ @as(u32, dest[0]), @as(u32, dest[1]), @as(u32, dest[2]), @as(u32, dest[3]),
+ @as(u32, @intFromBool(copy_ok)),
+ });
+
+ // 2. sixteen 8-bit MACs in one instruction. a is all ones and b is 1..16, so the sixteen
+ // accumulator lanes must hold exactly 1..16 - whatever order the QACC dump puts them in.
+ pieMacS8(&qacc, &vec_a, &vec_b);
+ var seen: u32 = 0;
+ var lanes: u32 = 0;
+ for (0..16) |i| {
+ const v = std.mem.readInt(u32, qacc[i * 4 ..][0..4], .little);
+ if (v >= 1 and v <= 16) {
+ seen |= @as(u32, 1) << @intCast(v - 1);
+ lanes += 1;
+ }
+ }
+ soc.rom.print("MARK PIE_MAC lanes=%u distinct=%08x expect=0000ffff sum_ok=%u\r\n", .{
+ lanes, seen, @as(u32, @intFromBool(seen == 0xffff)),
+ });
+
+ // 3. gap 1, in cycles: the same 256-element dot product both ways. Both results must agree,
+ // or the vector path is not computing what the compiler's loop computes.
+ const t0 = soc.cycles();
+ const scalar = dotScalar(&big_a, &big_b);
+ const t1 = soc.cycles();
+ const vector = dotPie(&qacc, &big_a, &big_b);
+ const t2 = soc.cycles();
+ soc.rom.print("MARK PIE_DOT scalar=%d in %u cyc, vector=%d in %u cyc, agree=%u\r\n", .{
+ scalar, @as(u32, @truncate(t1 - t0)),
+ vector, @as(u32, @truncate(t2 - t1)),
+ @as(u32, @intFromBool(scalar == vector)),
+ });
+ soc.rom.print("MARK PIE_DONE vendor vector ISA executed from a zig-built image\r\n", .{});
+
+ soc.gpio.configureOutput(20);
+ while (true) {
+ soc.gpio.setHigh(20);
+ soc.rom.ets_delay_us(250_000);
+ soc.gpio.setLow(20);
+ soc.rom.ets_delay_us(250_000);
+ }
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/portcheck.zig b/examples/portcheck.zig
new file mode 100644
index 0000000..ac0894e
--- /dev/null
+++ b/examples/portcheck.zig
@@ -0,0 +1,267 @@
+//! Hardware self-test for the ESP-Hosted port table, with no ESP-Hosted C linked in.
+//!
+//! Run with: zig build -Dapp=src/portcheck.zig run -Dseconds=8
+//!
+//! Every line is a claim this file can actually make from the die. What it does *not* do is bring
+//! the radio up: that needs the ESP-Hosted C linked beside it, which is the parent's build step.
+//! What it proves is that the seam works - that the table's layout is what C measured, that the
+//! heap survives the allocation pattern ESP-Hosted subjects it to, that the OS objects behave under
+//! the real `std.Io` on this chip rather than under `Threaded` on the host, and that the reset pin
+//! moves the way the radio needs.
+
+const std = @import("std");
+const hal = @import("hal");
+
+const net = @import("net");
+const port = net.port;
+const hheap = net.heap;
+const os = net.os;
+const p4 = @import("io");
+
+extern fn ets_printf(fmt: [*:0]const u8, ...) c_int;
+
+fn print(comptime fmt: [*:0]const u8, args: anytype) void {
+ _ = @call(.auto, ets_printf, .{fmt} ++ args);
+}
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ print("MARK PORT_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// Required in **every** app root that implements `std.Io.VTable` on this target, and it has to be
+/// here rather than in the runtime: std reads `std_options` from `@import("root")` only
+/// (`/usr/lib/zig/std/std.zig:112`), so the same declaration inside src/io/p4.zig is ignored.
+///
+/// The reason it is needed at all: defining `fileMemoryMapCreate` forces `Io.File.MemoryMap` to be
+/// laid out, its `memory` field is `[]align(std.heap.page_size_min) u8`
+/// (`/usr/lib/zig/std/Io/File/MemoryMap.zig:18`), and `page_size_min` has no default for
+/// freestanding (`/usr/lib/zig/std/heap.zig:48`). It is the *return type* that does it, so no stub
+/// body can avoid it. 4096 is arbitrary and honest: nothing in this image pages, and that one
+/// field's alignment is the only thing that reads it. `page_size_max` is not reached.
+pub const std_options: std.Options = .{
+ .page_size_min = 4096,
+};
+
+/// The heap ESP-Hosted allocates from. 48 KiB is a starting point, not a measurement: the honest
+/// number comes from `port.stats().peak_reserved` after a run with the C linked in, and the
+/// dominant term is the transport queues - `CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE` and `..._RX_Q_SIZE`
+/// are both 20 in the working IDF build, and 40 in-flight buffers at 1536 bytes is 60 KB on its
+/// own. Those depths will have to come down for this memory budget; see the report.
+var heap_buffer: [48 * 1024]u8 align(hheap.Heap.granule) = undefined;
+
+/// Nine task slots: ESP-Hosted's seven, the port's timer service, and this context.
+/// 4 KiB each = 36 KiB. `port.requested_stack_bytes` is 5 KiB, which is FreeRTOS's number for tasks
+/// that call `printf`; these do not, and the real number wants a painted-stack watermark.
+var runtime_storage: p4.Static(9, 4 * 1024) = .{};
+
+var events_seen: u32 = 0;
+
+fn onEvent(e: port.Event) void {
+ events_seen += 1;
+ switch (e.base) {
+ .wifi => print("MARK PORT_EVENT wifi id=%d\r\n", .{e.id}),
+ .named => |n| print("MARK PORT_EVENT %s id=%d\r\n", .{ n, e.id }),
+ }
+}
+
+export fn zig_main() noreturn {
+ hal.intr.init();
+ hal.systimer.init();
+ print("\r\nMARK PORT_START\r\n", .{});
+
+ // -------------------------------------------------------------- 1. the ABI the C side measured
+ // Three numbers, and if any of them is wrong the table is a set of calls to the wrong
+ // functions. C's own offsetof, with the force-include in place, gives 284 / 148 / 280.
+ print("MARK PORT_ABI sizeof=%u config_gpio=%u event_post=%u fields=%u expect=284,148,280,71\r\n", .{
+ @as(u32, @sizeOf(port.HostedOsiFuncs)),
+ @as(u32, @offsetOf(port.HostedOsiFuncs, "config_gpio")),
+ @as(u32, @offsetOf(port.HostedOsiFuncs, "event_post")),
+ @as(u32, std.meta.fields(port.HostedOsiFuncs).len),
+ });
+ // g_h must point at the table before anything runs; C reads `g_h.funcs->...` directly.
+ print("MARK PORT_GH funcs_is_table=%u stubs=%u real=%u\r\n", .{
+ @as(u32, @intFromBool(port.g_h.funcs == &port.g_hosted_osi_funcs)),
+ @as(u32, port.stubbed.len),
+ @as(u32, std.meta.fields(port.HostedOsiFuncs).len - port.stubbed.len),
+ });
+
+ // -------------------------------------------------------------- 2. install
+ var heap = hheap.Heap.init(&heap_buffer);
+ const rt = runtime_storage.init(.{});
+ const io = rt.io();
+ port.install(io, heap.allocator());
+ port.setEventHandler(onEvent);
+ print("MARK PORT_INSTALL heap=%u tasks=%u stack=%u\r\n", .{
+ @as(u32, heap_buffer.len),
+ @as(u32, 9),
+ @as(u32, 4 * 1024),
+ });
+
+ // -------------------------------------------------------------- 3. the timebase
+ // _h_get_time_ms off hal.systimer's 16 MHz. Two reads a known delay apart: the difference is
+ // the claim, and it is checked against the counter that produced it.
+ const t0 = port.g_h.funcs.get_time_ms();
+ hal.systimer.delayMicros(50_000);
+ const t1 = port.g_h.funcs.get_time_ms();
+ print("MARK PORT_TIME t0=%u t1=%u delta_ms=%u expect~50\r\n", .{
+ @as(u32, @intCast(t0)), @as(u32, @intCast(t1)), @as(u32, @intCast(t1 - t0)),
+ });
+
+ // -------------------------------------------------------------- 4. memory, through the table
+ // The exact pattern an arena cannot serve: allocate, free out of order, reallocate. This is
+ // mempool.c's churn, done through the C entry points rather than through Zig.
+ const f = port.g_h.funcs;
+ var held: [12]?*anyopaque = @splat(null);
+ for (&held) |*h| h.* = f.malloc_align(1536, 64);
+ var aligned_ok: u32 = 0;
+ for (held) |h| {
+ if (h) |p| if (@intFromPtr(p) % 64 == 0) {
+ aligned_ok += 1;
+ };
+ }
+ const after_alloc = port.stats();
+ const order = [_]usize{ 7, 0, 11, 3, 9, 1, 5, 10, 2, 8, 4, 6 };
+ for (order) |i| f.free_align(held[i]);
+ const after_free = port.stats();
+ for (&held) |*h| h.* = f.malloc_align(1536, 64);
+ const after_realloc = port.stats();
+ for (order) |i| f.free_align(held[i]);
+
+ print("MARK PORT_HEAP aligned=%u/12 live_after_alloc=%u live_after_free=%u peak=%u fail=%u\r\n", .{
+ aligned_ok,
+ @as(u32, @intCast(after_alloc.blocks_live)),
+ @as(u32, @intCast(after_free.blocks_live)),
+ @as(u32, @intCast(after_realloc.peak_reserved)),
+ @as(u32, @intCast(after_realloc.alloc_failures)),
+ });
+ // The whole point: the second round must not need more memory than the first.
+ print("MARK PORT_HEAP_REUSE round1=%u round2=%u expect_equal\r\n", .{
+ @as(u32, @intCast(after_alloc.bytes_reserved)),
+ @as(u32, @intCast(after_realloc.bytes_reserved)),
+ });
+ const s = heap.stats();
+ print("MARK PORT_HEAP_FREELIST total=%u free=%u largest=%u blocks=%u expect free==total,blocks==1\r\n", .{
+ s.total, s.free, s.largest_free, s.free_blocks,
+ });
+ heap.check();
+
+ // -------------------------------------------------------------- 5. the C-visible sync objects
+ // Created and driven through the table, so the handles and the return codes are the C ones.
+ const mtx = f.create_mutex().?;
+ const lock_ok = f.lock_mutex(mtx, -1);
+ const relock_busy = f.lock_mutex(mtx, 0);
+ const unlock_ok = f.unlock_mutex(mtx);
+ print("MARK PORT_MUTEX lock=%d try_while_held=%d unlock=%d expect=0,-1,0\r\n", .{ lock_ok, relock_busy, unlock_ok });
+ _ = f.destroy_mutex(mtx);
+
+ // A FreeRTOS semaphore arrives with one permit already given; sdio_drv.c:1504 depends on it.
+ const sem = f.create_semaphore(4).?;
+ const initial_take = f.get_semaphore(sem, 0);
+ const empty_take = f.get_semaphore(sem, 0);
+ _ = f.post_semaphore(sem);
+ const after_post = f.get_semaphore(sem, 0);
+ print("MARK PORT_SEM initial=%d empty=%d after_post=%d expect=0,-5,0\r\n", .{ initial_take, empty_take, after_post });
+ _ = f.destroy_semaphore(sem);
+
+ // A queue of 24-byte records, which is sizeof(interface_buffer_handle_t) on rv32.
+ const q = f.create_queue(4, 24).?;
+ var rec: [24]u8 = @splat(0xA5);
+ var out: [24]u8 = @splat(0);
+ const empty_deq = f.dequeue_item(q, &out, 0);
+ var sent: c_int = 0;
+ for (0..4) |_| sent += f.queue_item(q, &rec, -1);
+ const full_send = f.queue_item(q, &rec, 0);
+ const waiting = f.queue_msg_waiting(q);
+ const deq = f.dequeue_item(q, &out, -1);
+ print("MARK PORT_QUEUE empty=%d sent=%d full=%d waiting=%d deq=%d roundtrip=%u expect=-1,0,-1,4,0,1\r\n", .{
+ empty_deq, sent, full_send, waiting, deq, @as(u32, @intFromBool(out[0] == 0xA5 and out[23] == 0xA5)),
+ });
+ _ = f.destroy_queue(q);
+
+ // -------------------------------------------------------------- 6. timers, through the table
+ const timer = f.timer_start("portcheck_oneshot", 30, 0, timerFired, null);
+ print("MARK PORT_TIMER_ARMED handle=%u\r\n", .{@as(u32, @intFromBool(timer != null))});
+ // The timer service task only runs when this context blocks. Sleeping is what starts it.
+ _ = f.msleep(120);
+ print("MARK PORT_TIMER fired=%u expect=1\r\n", .{timer_fires});
+ // Stopping an expired one-shot reports failure, as esp_timer_stop does.
+ if (timer) |t| print("MARK PORT_TIMER_STOP %d expect=-1\r\n", .{f.timer_stop(t)});
+
+ // -------------------------------------------------------------- 7. events
+ _ = f.event_post("PORTCHECK_EVENT", 7, null, 0, 0);
+ _ = f.event_wifi_post(4, null, 0, 0);
+ print("MARK PORT_EVENTS seen=%u expect=2\r\n", .{events_seen});
+
+ // -------------------------------------------------------------- 8. the reset pin
+ // GPIO54 has an external pull-up, so released means high. ESP-Hosted's sequence
+ // (sdio_drv.c:1651-1657) is active, inactive, active, and with this board's configuration
+ // active is HIGH - so it ends released. Driven here through the table's own GPIO entries, with
+ // readback, because getting this backwards holds the radio in reset for ever.
+ const pin: u32 = port.config.reset_pin;
+ _ = f.config_gpio(null, pin, 1 | 2); // H_GPIO_MODE_INPUT_OUTPUT: drive and read back
+ _ = f.write_gpio(null, pin, 1);
+ const high1 = f.read_gpio(null, pin);
+ _ = f.msleep(10);
+ _ = f.write_gpio(null, pin, 0);
+ const low = f.read_gpio(null, pin);
+ _ = f.msleep(10);
+ _ = f.write_gpio(null, pin, 1);
+ const high2 = f.read_gpio(null, pin);
+ print("MARK PORT_RESET pin=%u high=%d low=%d released=%d expect=1,0,1\r\n", .{ pin, high1, low, high2 });
+
+ // The pull entries, on the same pad: enable a pull-down, then disable it, and check the
+ // internal pull does not end up fighting the external one.
+ _ = f.config_gpio(null, pin, 1); // input only, so the pull is what drives the pad
+ _ = f.pull_gpio(null, pin, 0, 1); // H_GPIO_PULL_DOWN, enable
+ hal.systimer.delayMicros(200);
+ const pulled_down = f.read_gpio(null, pin);
+ _ = f.pull_gpio(null, pin, 0, 0); // disable it again
+ hal.systimer.delayMicros(200);
+ const released = f.read_gpio(null, pin);
+ print("MARK PORT_PULL down=%d released=%d expect=0,1\r\n", .{ pulled_down, released });
+ // Leave the radio out of reset, whatever the test did to the pad.
+ _ = f.config_gpio(null, pin, 1 | 2);
+ _ = f.write_gpio(null, pin, 1);
+
+ // -------------------------------------------------------------- 9. what was never implemented
+ const final = port.stats();
+ print("MARK PORT_STUBS_HIT %u\r\n", .{final.stub_calls});
+ print("MARK PORT_DONE live=%u reserved=%u peak=%u blocks=%u fail=%u\r\n", .{
+ @as(u32, @intCast(final.bytes_live)),
+ @as(u32, @intCast(final.bytes_reserved)),
+ @as(u32, @intCast(final.peak_reserved)),
+ @as(u32, @intCast(final.blocks_live)),
+ @as(u32, @intCast(final.alloc_failures)),
+ });
+ while (true) {}
+}
+
+var timer_fires: u32 = 0;
+
+fn timerFired(_: ?*anyopaque) callconv(.c) void {
+ timer_fires += 1;
+}
+
+/// Reset entry, verbatim from `src/main.zig:79-95`: enable the F extension, establish a stack in
+/// L2MEM, clear .bss, jump to `zig_main`. Every app in this repo carries its own copy because the
+/// linker script's entry symbol is per-image.
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/radio.zig b/examples/radio.zig
new file mode 100644
index 0000000..7a2704a
--- /dev/null
+++ b/examples/radio.zig
@@ -0,0 +1,388 @@
+//! First light for the radio: does the ESP32-C6 answer over SDIO, driven entirely from Zig?
+//!
+//! This is the milestone the whole stack exists to reach, and it is deliberately the smallest thing
+//! that can prove it. The P4 has no radio. The C6 beside it does, and ESP-Hosted's transport - 13k
+//! lines of C that already works - is what talks to it. Everything *underneath* that C is this
+//! project's: the SDIO host driver (src/hal/sdmmc.zig), the runtime it blocks on
+//! (src/io/p4.zig, std.Io), the allocator it allocates from (src/net/heap.zig), and the function
+//! table it reaches all of them through (src/net/port.zig).
+//!
+//! So a successful run is not "the C works". It is: our SDIO driver clocked a real CMD52/CMD53
+//! exchange, our scheduler suspended and resumed ESP-Hosted's threads, our allocator served its
+//! buffers, and a separate chip agreed to talk.
+//!
+//! What it prints, and what each line proves:
+//!
+//! MARK RADIO_START the image booted and the runtime installed
+//! MARK RADIO_SETUP rc=0 setup_transport accepted; ESP-Hosted's threads now exist
+//! MARK RADIO_TICK ... the scheduler is running and the transport is progressing
+//! MARK RADIO_UP the C6 answered and the transport reached its active state
+//! MARK RADIO_CAPS ... the capability byte the C6 reported (0x0d on this board)
+//! MARK RADIO_HEAP ... what the C actually allocated, against what was reserved
+//! MARK RADIO_STUBS n ... how many loud stubs were reached: a non-zero count names work left,
+//! and `sleeps=` the running total of ESP-Hosted's RPC not-ready spins
+//! MARK WIFI_START rc= spun= the first RPC. `spun` counts not-ready spins during the call, so a
+//! non-zero value means the request was never transmitted at all
+//! MARK RADIO_FAIL <why> the transport did not come up, with the reason
+//!
+//! Run: zig build -Dhosted -Dapp=examples/radio.zig run
+
+const std = @import("std");
+const hal = @import("hal");
+const soc = @import("soc");
+const net = @import("net");
+const p4 = @import("io");
+const config = @import("config");
+
+/// Required by any application that implements `std.Io.VTable` on this target. Five vtable entries
+/// name `Io.File.MemoryMap`, whose `memory` field is `[]align(std.heap.page_size_min) u8`, and
+/// riscv32-freestanding has no default page size. It must be in the ROOT module, because std reads
+/// `std.options` from `@import("root")` - putting it in the runtime does nothing.
+pub const std_options: std.Options = .{ .page_size_min = 4096 };
+
+/// A panic must reach the serial line: this board has no other output, and the whole point of the
+/// MARK lines is that a failure is legible. `while (true) {}` rather than a reset, so the last
+/// message stays on screen instead of scrolling past in a reboot loop.
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK RADIO_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// ESP-Hosted spawns seven threads for the transport and more for RPC. Twelve slots.
+///
+/// Sizing this too small does NOT fail loudly, which is why the number is generous: `io.async` with
+/// no free slot runs the body *eagerly* rather than returning an error, so a reader task that should
+/// loop forever instead runs once at spawn time and is never seen again. The symptom is a request
+/// that goes out and a response that never arrives - which is exactly what an eight-slot pool
+/// produced here, with seven slots already taken by the time RPC started.
+///
+/// 4 KiB each is a measured starting point rather than a guess - `runtime.dump()` at the end reports
+/// each task's real high-water mark, so the next revision of this number is an observation. Total
+/// 32 KiB of stacks out of ~128 KiB of L2MEM, which has to also hold the heap below.
+var pool: p4.Static(12, 4096) = .{};
+
+/// ESP-Hosted's heap. With the SDIO queues cut to 4/4 (see src/net/hosted/sdkconfig.h), the
+/// in-flight buffers are ~12 KiB; the rest is the transport's own bookkeeping and the RPC scratch.
+/// `RADIO_HEAP` prints what was really used, which is how this number gets corrected.
+var heap_backing: [32 * 1024]u8 align(8) = undefined;
+
+/// Set from the event callback. `volatile` is not needed - the scheduler is cooperative and this is
+/// written and read on the same hart with no preemption - but the transport does set it from one of
+/// its own threads, so it is read only at yield points.
+var last_event: ?net.port.Event = null;
+
+/// Set by the bring-up task so the heartbeat can report the outcome.
+var bringup_result: ?anyerror = null;
+var bringup_done: bool = false;
+
+fn bringUp(ctx: struct { io: std.Io, gpa: std.mem.Allocator }) void {
+ net.init(ctx.io, ctx.gpa) catch |err| {
+ bringup_result = err;
+ bringup_done = true;
+ return;
+ };
+ bringup_done = true;
+}
+
+fn onEvent(ev: net.port.Event) void {
+ last_event = ev;
+ // `base` says which event family this is - Wi-Fi, or one of ESP-Hosted's own named bases - and
+ // `id` is the family's own enum. Both are printed raw rather than decoded: this file's job is to
+ // prove the transport came up, and inventing names for ids we have not seen yet would be
+ // guessing at exactly the moment the board is telling us something.
+ const base_name: [*:0]const u8 = switch (ev.base) {
+ .wifi => "wifi",
+ .named => |n| n,
+ };
+ soc.rom.print("MARK RADIO_EVENT base=%s id=%d len=%u\r\n", .{
+ base_name,
+ ev.id,
+ @as(u32, @intCast(if (ev.data) |d| d.len else 0)),
+ });
+}
+
+/// Reset entry. The bootloader hands over with an unspecified stack pointer and the FPU off, so:
+/// enable the F extension (mstatus.FS = Initial), establish the stack, clear .bss, then enter Zig.
+/// Every application in this repo carries its own copy - there is no runtime to hide it in, and the
+/// linker script's symbols are what it depends on.
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
+
+export fn zig_main() noreturn {
+ // The bootloader leaves the RTC watchdog armed and expects the application to take it over.
+ // Nothing in this image feeds it, so without this the board resets at roughly ten seconds -
+ // which is longer than any demo in this repo has ever run, and is exactly why it went unnoticed
+ // until something here waited on a real handshake. The reset reason on the first run of this
+ // file was CHIP_LP_WDT_RESET, from the park() loop below.
+ const wdt_was_armed = hal.rwdt.armed();
+ const wdt_off = hal.rwdt.disable();
+ soc.rom.print("MARK RADIO_WDT armed_at_entry=%u disabled=%u\r\n", .{
+ @as(u32, @intFromBool(wdt_was_armed)),
+ @as(u32, @intFromBool(wdt_off)),
+ });
+
+ // Two independent clocks over one interval: the systimer is a fixed 16 MHz, so the CPU
+ // frequency falls out of the ratio. Worth printing because everything above assumes the
+ // bootloader's 90 MHz - nothing in this image reconfigures the PLL - and a surprise here would
+ // explain a wrong SDIO clock divider before it wasted an afternoon.
+ const t0 = hal.systimer.read(.unit0) orelse 0;
+ const c0 = soc.cycles();
+ soc.rom.ets_delay_us(10_000);
+ const t1 = hal.systimer.read(.unit0) orelse 0;
+ const c1 = soc.cycles();
+ const cpu_khz: u64 = if (t1 > t0) ((c1 - c0) * (hal.systimer.hz / 1000)) / (t1 - t0) else 0;
+ soc.rom.print("\r\nMARK RADIO_START cpu=%u kHz\r\n", .{@as(u32, @truncate(cpu_khz))});
+
+ // 1. The runtime. Everything above blocks on this.
+ const runtime = pool.init(.{});
+ const io = runtime.io();
+
+ // 2. The allocator ESP-Hosted's C allocates from. A fixed buffer is right here: the whole point
+ // is a bounded, known footprint, and `heap.zig`'s CHeap adds the length header that C's
+ // `free` needs.
+ // A real allocator over the static buffer, rather than the buffer allocator itself.
+ //
+ // `std.heap.FixedBufferAllocator` is a bump allocator and reclaims only the most recent
+ // allocation - that is its design, not a shortcoming, and it is the right tool when allocations
+ // are freed in reverse order or never. It is the wrong tool here: ESP-Hosted allocates one
+ // MAX_TRANSPORT_BUFFER_SIZE buffer per frame in each direction and frees each when that frame is
+ // done, in no particular order. Using the buffer allocator directly for that was my mistake, and
+ // the board named it precisely - 210 bytes live, 6 blocks live, and allocation failures climbing
+ // past 33 while the receive counter sat frozen at 29. Nothing leaked; the arena had simply been
+ // walked through 1536 bytes at a time with no way to give any of it back.
+ //
+ // So a general-purpose allocator goes on top, and the static buffer stays what it is: the
+ // memory. `net.heap.Heap` is a coalescing free list over exactly that, reached through the
+ // ordinary `std.mem.Allocator` vtable, so everything above it - including the C, through
+ // `_h_malloc` - is unaware of the difference.
+ var backing = net.heap.Heap.init(&heap_backing);
+ const gpa = backing.allocator();
+
+ net.port.setEventHandler(&onEvent);
+
+ // 3. Bring the transport up. This installs the port table and hands control to the C, which
+ // resets the C6, initialises the SDIO card through our driver, and starts its threads.
+ // Bring-up runs on its OWN task, not this one, and that is a deliberate diagnostic choice.
+ //
+ // ESP-Hosted's `transport_drv_reconfigure` blocks: it waits for the slave to become ready in a
+ // 200 ms retry loop. Calling it from the main task means the main task is inside the C for the
+ // whole handshake and cannot print anything, so a stall in there is indistinguishable from a
+ // stalled scheduler. With it on a separate task, the heartbeat below keeps running and says
+ // which of the two is happening.
+ var bringup = io.async(bringUp, .{.{ .io = io, .gpa = gpa }});
+ soc.rom.print("MARK RADIO_SETUP dispatched\r\n", .{});
+
+ // 4. Run the scheduler and watch for the transport to come up. The handshake happens on
+ // ESP-Hosted's threads, so this task's only job is to yield and report.
+ //
+ // Ten seconds is generous: the C6 boots in well under one. A bounded wait matters more than
+ // the exact number, because the failure this catches - the slave never answering - otherwise
+ // looks like a hang, and a hang tells you nothing about where it stopped.
+ const deadline_ms: u64 = 10_000;
+ const started = nowMs();
+ var last_report: u64 = 0;
+ while (!net.isUp()) {
+ runtime.yield();
+ const elapsed = nowMs() - started;
+ if (elapsed - last_report >= 1000) {
+ last_report = elapsed;
+ const s = net.port.stats();
+ // heap and blocks moving means the C is doing work; both frozen while the tick advances
+ // means the transport is blocked but the scheduler is not.
+ soc.rom.print("MARK RADIO_TICK t=%u ms heap=%u B blocks=%u stubs=%u bringup_done=%u\r\n", .{
+ @as(u32, @intCast(elapsed)),
+ @as(u32, @intCast(s.bytes_live)),
+ @as(u32, @intCast(s.blocks_live)),
+ s.stub_calls,
+ @as(u32, @intFromBool(bringup_done)),
+ });
+ // The card-interrupt delivery chain, register by register, on every beat. RADIO_TICK
+ // says whether the scheduler is alive; these five lines say whether the C6 is asking
+ // for service, whether the controller latched it, and whether the CLIC could deliver
+ // it - which is not inferable from anything else printed here.
+ hal.sdmmc.interruptDiagnostics();
+ }
+ if (bringup_done) {
+ if (bringup_result) |err| {
+ soc.rom.print("MARK RADIO_FAIL bringup=%s\r\n", .{@errorName(err).ptr});
+ report(runtime);
+ park();
+ }
+ // Bring-up returned success but the transport is not up: that is the interesting case,
+ // and it is worth saying so rather than sitting in the loop silently.
+ if (elapsed > 2000 and last_report == elapsed) {
+ soc.rom.print("MARK RADIO_NOTE bringup returned ok, transport still down\r\n", .{});
+ }
+ }
+ if (elapsed > deadline_ms) {
+ soc.rom.print("MARK RADIO_FAIL timeout after %u ms\r\n", .{@as(u32, @intCast(elapsed))});
+ report(runtime);
+ park();
+ }
+ }
+ bringup.cancel(io);
+
+ soc.rom.print("MARK RADIO_UP after %u ms\r\n", .{@as(u32, @intCast(nowMs() - started))});
+ report(runtime);
+
+ // The transport is up, so the RPC layer has vocabulary. A scan is the cheapest end-to-end proof
+ // that the whole path works - it needs no credentials, and it can only succeed if the request
+ // was serialised, sent over our SDIO driver, executed by the C6's radio, and the reply parsed.
+ scan();
+
+ // Keep yielding: the transport's threads must keep running, and leaving the board in a live
+ // state is what lets the next experiment build on this one.
+ while (true) runtime.yield();
+}
+
+/// ESP-Hosted's Wi-Fi RPC, through the C shim in src/net/hosted/wifi_shim.c. The structs stay in C
+/// on purpose; see that file's header.
+extern fn hosted_wifi_sta_start() c_int;
+extern fn hosted_wifi_get_mac(out: *[6]u8) c_int;
+extern fn hosted_wifi_scan(found: *u16) c_int;
+extern fn hosted_wifi_connect(ssid: [*:0]const u8, psk: [*:0]const u8) c_int;
+extern fn hosted_wifi_scan_record(
+ index: u16,
+ ssid_out: [*]u8,
+ rssi_out: *i8,
+ channel_out: *u8,
+ authmode_out: *u8,
+) c_int;
+
+fn scan() void {
+ // ESP-Hosted's rpc_rx_thread and rpc_tx_thread both begin their loop with
+ // `if (!is_rpc_lib_ready()) _h_sleep(1)` (rpc_core.c:482-485, :543-547), and those two lines are
+ // the only `_h_sleep` callers in the file set build.zig compiles. So the delta across a
+ // synchronous request says, with no ambiguity, which of the two failures happened:
+ //
+ // spun=0 the RPC threads ran; the request went out and the answer (or its absence) is real
+ // spun~=2 per second stuck the RPC lib never reached READY; nothing was ever transmitted
+ //
+ // Without this the two are indistinguishable: `rpc_send_req` only enqueues (rpc_core.c:1019),
+ // so it returns success either way and the only other symptom is "Timeout waiting for Resp".
+ const spins_before = net.port.stats().hosted_sleep_calls;
+ const rc_start = hosted_wifi_sta_start();
+ const spins_after = net.port.stats().hosted_sleep_calls;
+ soc.rom.print("MARK WIFI_START rc=%d spun=%u\r\n", .{
+ rc_start,
+ @as(u32, spins_after - spins_before),
+ });
+ if (rc_start != 0) return;
+
+ var mac: [6]u8 = undefined;
+ if (hosted_wifi_get_mac(&mac) == 0) {
+ soc.rom.print("MARK WIFI_MAC %02x:%02x:%02x:%02x:%02x:%02x\r\n", .{
+ @as(u32, mac[0]), @as(u32, mac[1]), @as(u32, mac[2]),
+ @as(u32, mac[3]), @as(u32, mac[4]), @as(u32, mac[5]),
+ });
+ }
+
+ var found: u16 = 0;
+ const rc_scan = hosted_wifi_scan(&found);
+ soc.rom.print("MARK WIFI_SCAN rc=%d found=%u\r\n", .{ rc_scan, @as(u32, found) });
+ if (rc_scan != 0) return;
+
+ // Print the first few, enough to see this network among them without flooding the UART.
+ var i: u16 = 0;
+ const limit = @min(found, 12);
+ while (i < limit) : (i += 1) {
+ var ssid: [33]u8 = undefined;
+ var rssi: i8 = 0;
+ var chan: u8 = 0;
+ var auth: u8 = 0;
+ if (hosted_wifi_scan_record(i, &ssid, &rssi, &chan, &auth) != 0) break;
+ soc.rom.print("MARK WIFI_AP %2u ch=%2u rssi=%d auth=%u %s\r\n", .{
+ @as(u32, i), @as(u32, chan), @as(i32, rssi), @as(u32, auth),
+ @as([*:0]const u8, @ptrCast(&ssid)),
+ });
+ }
+
+ joinIfConfigured();
+}
+
+/// Join the network named by `-Dssid`, if one was given.
+///
+/// Credentials come from build options (see build.zig): the SSID and passphrase are never in this
+/// source. With no `-Dssid` this does nothing and says so, so the scan above remains a complete,
+/// credential-free demonstration on its own.
+fn joinIfConfigured() void {
+ if (config.wifi_ssid.len == 0) {
+ soc.rom.print("MARK WIFI_JOIN skipped: no -Dssid given\r\n", .{});
+ return;
+ }
+
+ // NUL-terminated copies for the C ABI. Sized to the 802.11 maxima that wifi_config_t itself
+ // uses: 32 for an SSID, 64 for a passphrase.
+ var ssid: [33]u8 = @splat(0);
+ var psk: [65]u8 = @splat(0);
+ if (config.wifi_ssid.len > 32 or config.wifi_psk.len > 64) {
+ soc.rom.print("MARK WIFI_JOIN refused: ssid or psk too long\r\n", .{});
+ return;
+ }
+ @memcpy(ssid[0..config.wifi_ssid.len], config.wifi_ssid);
+ @memcpy(psk[0..config.wifi_psk.len], config.wifi_psk);
+
+ // The SSID is printed; the passphrase is not, and only its length is, which is enough to tell a
+ // missing -Dpsk from a wrong one without putting the secret on a serial line.
+ soc.rom.print("MARK WIFI_JOIN ssid=%s psk_len=%u\r\n", .{
+ @as([*:0]const u8, @ptrCast(&ssid)),
+ @as(u32, @intCast(config.wifi_psk.len)),
+ });
+
+ const rc = hosted_wifi_connect(@ptrCast(&ssid), @ptrCast(&psk));
+ soc.rom.print("MARK WIFI_CONNECT rc=%d\r\n", .{rc});
+ if (rc != 0) return;
+
+ // Association is asynchronous: the C6 answers the request immediately and reports the outcome
+ // as an event. `onEvent` prints those, so the job here is to keep the scheduler running and put
+ // a bound on the wait.
+ soc.rom.print("MARK WIFI_WAIT for association events\r\n", .{});
+}
+
+fn report(runtime: *p4.Runtime) void {
+ const s = net.port.stats();
+ soc.rom.print("MARK RADIO_HEAP live=%u B reserved=%u B peak=%u B blocks=%u fails=%u\r\n", .{
+ @as(u32, @intCast(s.bytes_live)),
+ @as(u32, @intCast(s.bytes_reserved)),
+ @as(u32, @intCast(s.peak_reserved)),
+ @as(u32, @intCast(s.blocks_live)),
+ @as(u32, @intCast(s.alloc_failures)),
+ });
+ // A non-zero stub count is not a failure - it names the next layer to write. Printing it is
+ // what turns "it hung somewhere" into "it wanted rpc_start". `sleeps` is the same idea one
+ // layer up: it is the running total of ESP-Hosted's RPC not-ready spins, so the value here is
+ // the baseline that `MARK WIFI_START spun=` is measured against.
+ soc.rom.print("MARK RADIO_STUBS %u sleeps=%u\r\n", .{ s.stub_calls, s.hosted_sleep_calls });
+ // Per-task stack high-water marks, so the 4 KiB above becomes a measurement.
+ runtime.dump();
+}
+
+fn nowMs() u64 {
+ // The systimer is a fixed 16 MHz and the only trustworthy timebase here: the CPU runs at the
+ // bootloader's 90 MHz and nothing in this image reconfigures the PLL. A failed snapshot read
+ // yields 0, which makes the elapsed calculation conservative rather than wrong.
+ const ticks = hal.systimer.read(.unit0) orelse return 0;
+ return ticks / 16_000;
+}
+
+fn park() noreturn {
+ soc.rom.print("MARK RADIO_DONE parked\r\n", .{});
+ while (true) {}
+}
diff --git a/examples/rf.zig b/examples/rf.zig
new file mode 100644
index 0000000..93babbd
--- /dev/null
+++ b/examples/rf.zig
@@ -0,0 +1,613 @@
+//! The RF experiment from `04-report` section 5, re-emitted from this toolchain.
+//!
+//! zig build -Dapp=examples/rf.zig flash
+//! cd ../02-esp32p4-m3-radio && tools/rfprobe.py chop --tag zig-tone-chop --cmd-on T --cmd-off o
+//! cd ../02-esp32p4-m3-radio && tools/rfprobe.py iq --tag zig-tone-burst --cmd-on t --keep-iq
+//! cd ../02-esp32p4-m3-radio && tools/decode_tone.py captures/zig-tone-burst.iq
+//!
+//! The original firmware was ESP-IDF: FreeRTOS tasks, `ledc_timer_config`, `esp_timer_get_time`,
+//! `printf`. This is the same physical experiment driven by this project's own HAL, so the SDR and
+//! the unmodified analysis tools become an external oracle on the HAL: if `hal.ledc`'s divider
+//! arithmetic, `hal.gpio`'s matrix routing or `hal.systimer`'s timebase are wrong, the captured
+//! carrier lands on a different frequency, the pulse widths drift, or the decoded word is not
+//! 0x4200.
+//!
+//! What is reproducible here and what is not, stated up front:
+//!
+//! * **Reproducible: the 25 MHz keyed carrier.** It is the P4's own pin, driven by LEDC. That is
+//! the whole of section 5, including the only real SDR spectrum in the report.
+//! * **Not reproducible: Wi-Fi, BLE, 802.15.4.** The P4 has no radio. Those went out over an
+//! ESP32-C6 across SDIO under `esp_hosted` + `esp_wifi_remote` - a 19,000-line host stack plus a
+//! prebuilt coprocessor binary, none of which exists here and none of which is low-level
+//! hardware. Section 4 of the report is out of this toolchain's scope by construction, and
+//! claiming otherwise would be the dishonest part.
+//!
+//! The frequency is the sharp end. LEDC at 1-bit duty resolution off the 80 MHz PLL-derived clock
+//! cannot synthesise a round 25 MHz: the Q10.8 divider closest to it is 410 (= 1.6015625), giving
+//! 80e6 * 256 / (410 * 2) = 24,975,609 Hz. The report measured 24.977455 MHz by phase slope, +73.9
+//! ppm from that. So the number this firmware should produce is 24.9756 MHz, not 25.0 MHz, and it is
+//! predicted by the divider arithmetic rather than by the requested frequency.
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+const regs = @import("regs");
+const mmio = @import("mmio");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK RF_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// GPIO20, JP1 pin 17: the LED pin when blinking, the carrier pin when keyed. Same pad the report
+/// used, which matters because the antenna coupling is whatever the header wire happens to be.
+const tone_pin: u8 = 20;
+const tone_channel: u32 = 0;
+const tone_timer: u32 = 0;
+
+/// The word, MSB first, and the frame that carries it: 500 ms preamble, 200 ms gap, then 16 cells of
+/// 100 ms carrier-if-set followed by 100 ms silence. 3.9 s total. Identical to the C firmware's
+/// `tone_burst`, because `decode_tone.py` uses PREAMBLE_MS and GAP_MS as known constants.
+const key_word: u16 = 0x4200;
+const preamble_ms: u32 = 500;
+const gap_ms: u32 = 200;
+const cell_ms: u32 = 100;
+
+var carrier_running = false;
+
+/// The console. UART0 is where the CH340 is wired and where the ROM's printf goes, so the transmit
+/// side is already working; this is only ever used to *read* commands.
+///
+/// Reading is the one thing that is safe to do to this peripheral here. Its FIFO register at offset
+/// 0 pops on read - which is exactly what a console reader wants, and is the same property that
+/// makes a register-block snapshot of a UART unsound. Nothing in this file reconfigures UART0: a
+/// reset or a baud change on the console would cut the wire this experiment reports over.
+const console = hal.uart.Uart.init(0);
+
+/// ESP-IDF's own LEDC LL, compiled into this image by `-Doracle`. Present so the two
+/// implementations can be compared with *one* instrument on *one* board in *one* boot: the register
+/// differential already proves they write the same words, so the only question left is behavioural,
+/// and a claim about behaviour needs both sides measured the same way.
+extern fn oracle_ledc_configure_timer(timer: c_uint, src_hz: c_uint, freq_hz: c_int, resolution: c_uint) void;
+extern fn oracle_ledc_configure_channel(channel: c_uint, timer: c_uint, duty: c_uint, hpoint: c_uint, idle_level: c_uint, output_enabled: c_int) void;
+extern fn oracle_ledc_set_pin(pin: c_uint, channel: c_uint) void;
+
+/// Count rising edges on the carrier pad, bounded. The instrument for the A/B below.
+fn countEdges(reads: u32) u32 {
+ var seen: u32 = 0;
+ var prev: u1 = hal.gpio.getLevel(tone_pin);
+ var g: u32 = 0;
+ while (g < reads) : (g += 1) {
+ const v = hal.gpio.getLevel(tone_pin);
+ if (v == 1 and prev == 0) seen += 1;
+ prev = v;
+ }
+ return seen;
+}
+
+fn micros() u64 {
+ return hal.systimer.micros(.unit0) orelse 0;
+}
+
+fn mark(comptime event: [*:0]const u8, comptime detail: [*:0]const u8) void {
+ soc.rom.print("MARK %s %s t=%uus\r\n", .{ event, detail, @as(u32, @truncate(micros())) });
+}
+
+fn delayMs(ms: u32) void {
+ hal.systimer.delayMicros(ms * 1000);
+}
+
+// ------------------------------------------------------------------------------- the emitter
+
+/// Bring LEDC up on the 80 MHz source and stage the carrier, without starting it.
+fn toneInit() void {
+ hal.clkrst.init(.ledc);
+ hal.ledc.init(.pll_div);
+
+ // 1-bit duty resolution: the counter has two states, so a duty of 1 is a 50 % square wave and
+ // the output frequency is the timer frequency. Asking for 25 MHz gets divider 410 and therefore
+ // 24.9756 MHz - the arithmetic is IDF's, reproduced exactly, including its rounding.
+ hal.ledc.configureTimer(tone_timer, .{
+ .resolution = 1,
+ .freq_hz = 25_000_000,
+ .src_hz = hal.ledc.pll_div_hz,
+ }) catch {
+ mark("TONE_FAIL", "divider out of range for 25MHz at 1-bit resolution");
+ return;
+ };
+
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer,
+ .duty = 1, // half of 2^1: a square wave
+ .hpoint = 0,
+ .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.stop(tone_channel, 0);
+ carrier_running = false;
+}
+
+fn toneOn() void {
+ hal.ledc.start(tone_channel);
+ carrier_running = true;
+}
+
+fn toneOff() void {
+ hal.ledc.stop(tone_channel, 0);
+ carrier_running = false;
+}
+
+/// The keyed frame. Timed with the systimer rather than a task delay, so the cell widths depend on
+/// a 16 MHz counter instead of on a scheduler tick - the report measured 100.062 ms and 100.000 ms
+/// against 100 ms commanded, and that is the number to beat.
+fn toneBurst() void {
+ mark("TONE_START", "gpio20 24975609Hz pattern=0x4200");
+
+ toneOn();
+ delayMs(preamble_ms);
+ toneOff();
+ delayMs(gap_ms);
+
+ var bit: i32 = 15;
+ while (bit >= 0) : (bit -= 1) {
+ if (key_word & (@as(u16, 1) << @intCast(bit)) != 0) toneOn();
+ delayMs(cell_ms);
+ toneOff();
+ delayMs(cell_ms);
+ }
+
+ mark("TONE_END", "gpio20 24975609Hz pattern=0x4200");
+ // Hand the pad back as a readable output, the way the C firmware did, so the blink witness still
+ // works afterwards.
+ hal.gpio.configureOutput(tone_pin, .{ .readback = true });
+}
+
+/// Ten samples at 125 ms, the C firmware's proof that the pad is really toggling rather than sitting
+/// at a level. A 1 Hz blink sampled at 125 ms must show runs of four.
+fn blinkWitness() void {
+ soc.rom.print("MARK BLINK_WITNESS gpio20 levels:", .{});
+ var i: u32 = 0;
+ while (i < 10) : (i += 1) {
+ soc.rom.print(" %u", .{@as(u32, hal.gpio.getLevel(tone_pin))});
+ delayMs(125);
+ }
+ soc.rom.print(" t=%uus\r\n", .{@as(u32, @truncate(micros()))});
+}
+
+/// On-chip corroboration that the pad is really switching, before believing anything an SDR says.
+///
+/// The report did this with the ADC (GPIO20 is also ADC1 channel 4) and read the min and max of a
+/// 64-sample burst rather than the mean, because the ADC cannot track 25 MHz and its sampling phase
+/// is uncorrelated with the pad. This HAL has no ADC, so it uses the pad's own input register
+/// instead: at ~90 MHz the core can issue a load every few cycles, so a run of reads across a
+/// 25 MHz square wave must catch both levels. Catching only one level means the pin is sitting
+/// still, which is the failure an SDR null cannot distinguish from bad coupling.
+fn padSample() void {
+ var ones: u32 = 0;
+ var zeros: u32 = 0;
+ var edges: u32 = 0;
+ var prev: u1 = hal.gpio.getLevel(tone_pin);
+ var i: u32 = 0;
+ // The sampling phase has to be *uncorrelated* with the signal, and a tight read loop is not.
+ //
+ // A first version read the pad 4096 times back to back and reported "constant high" for every
+ // carrier at or above 20 MHz - including 40 MHz, which is exactly APB/2, and 20 MHz, exactly
+ // APB/4. Both the read cadence and the LEDC output descend from the same clock, so a loop with a
+ // fixed period samples one phase of the waveform forever and reports a level that is not there.
+ // The ESP-IDF firmware avoided this by accident of its instrument: it used the ADC, whose
+ // conversion time is unrelated to the pad, and the report is explicit that its min/max - never
+ // its mean - is what carries the information.
+ //
+ // The software equivalent is to walk the phase deliberately: a delay that grows by one cycle
+ // every iteration cannot stay locked to any fixed period.
+ var jitter: u32 = 0;
+ while (i < 4096) : (i += 1) {
+ jitter = (jitter + 1) & 63;
+ var d: u32 = 0;
+ while (d < jitter) : (d += 1) asm volatile ("nop");
+ const v = hal.gpio.getLevel(tone_pin);
+ if (v == 1) ones += 1 else zeros += 1;
+ if (v != prev) edges += 1;
+ prev = v;
+ }
+ soc.rom.print("MARK PAD_SAMPLE carrier=%u ones=%u zeros=%u edges=%u of 4096 t=%uus\r\n", .{
+ @as(u32, @intFromBool(carrier_running)), ones, zeros, edges, @as(u32, @truncate(micros())),
+ });
+}
+
+/// The registers that decide whether this pin oscillates, printed rather than inferred.
+fn dumpRegs() void {
+ const ledc_base: u32 = @intCast(regs.LEDC_CH0_CONF0_REG);
+ soc.rom.print("MARK REGDUMP ch0_conf0=0x%08x ch0_hpoint=0x%08x ch0_duty=0x%08x ch0_conf1=0x%08x\r\n", .{
+ mmio.Reg.atAddress(ledc_base).raw(),
+ mmio.Reg.atAddress(@intCast(regs.LEDC_CH0_HPOINT_REG)).raw(),
+ mmio.Reg.atAddress(@intCast(regs.LEDC_CH0_DUTY_REG)).raw(),
+ mmio.Reg.atAddress(@intCast(regs.LEDC_CH0_CONF1_REG)).raw(),
+ });
+ soc.rom.print("MARK REGDUMP timer0_conf=0x%08x timer0_value=0x%08x ledc_conf=0x%08x\r\n", .{
+ mmio.Reg.atAddress(@intCast(regs.LEDC_TIMER0_CONF_REG)).raw(),
+ mmio.Reg.atAddress(@intCast(regs.LEDC_TIMER0_VALUE_REG)).raw(),
+ mmio.Reg.atAddress(@intCast(regs.LEDC_CONF_REG)).raw(),
+ });
+ soc.rom.print("MARK REGDUMP pad20=0x%08x matrix_out20=0x%08x gpio_out=0x%08x gpio_enable=0x%08x\r\n", .{
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.PERIPHS_IO_MUX_U_PAD_GPIO0)) + 4 * 20).raw(),
+ mmio.Reg.atAddress(@as(u32, @intCast(regs.GPIO_FUNC0_OUT_SEL_CFG_REG)) + 4 * 20).raw(),
+ mmio.Reg.atAddress(@intCast(regs.GPIO_OUT_REG)).raw(),
+ mmio.Reg.atAddress(@intCast(regs.GPIO_ENABLE_REG)).raw(),
+ });
+ // The active shadow, and the counter sampled twice: `duty` is what was staged, `duty_r` is what
+ // the hardware is using, and a counter that does not move between two reads has no clock.
+ const v1 = mmio.Reg.atAddress(@intCast(regs.LEDC_TIMER0_VALUE_REG)).raw();
+ const v2 = mmio.Reg.atAddress(@intCast(regs.LEDC_TIMER0_VALUE_REG)).raw();
+ soc.rom.print("MARK REGDUMP duty_r=0x%08x cnt1=0x%08x cnt2=0x%08x moved=%u\r\n", .{
+ mmio.Reg.atAddress(@intCast(regs.LEDC_CH0_DUTY_R_REG)).raw(),
+ v1, v2, @as(u32, @intFromBool(v1 != v2)),
+ });
+ soc.rom.print("MARK REGDUMP ledc_sig_idx=%u clkrst_ctrl22=0x%08x t=%uus\r\n", .{
+ @as(u32, @intCast(regs.LEDC_LS_SIG_OUT_PAD_OUT0_IDX)),
+ mmio.Reg.atAddress(@intCast(regs.HP_SYS_CLKRST_PERI_CLK_CTRL22_REG)).raw(),
+ @as(u32, @truncate(micros())),
+ });
+}
+
+fn status() void {
+ const div = hal.ledc.getClockDivider(tone_timer);
+ soc.rom.print(
+ "MARK STATUS carrier=%u divider=%u(Q10.8) freq=%uHz src=80000000Hz pin=%u cpu_mhz_x1000=%u t=%uus\r\n",
+ .{
+ @as(u32, @intFromBool(carrier_running)),
+ div,
+ hal.ledc.frequencyOf(hal.ledc.pll_div_hz, div, 1),
+ @as(u32, tone_pin),
+ cpuKhz(),
+ @as(u32, @truncate(micros())),
+ },
+ );
+}
+
+/// The CPU clock, measured against the systimer's fixed 16 MHz rather than assumed. Printed in the
+/// status line because every timing number in this experiment depends on the systimer, and this is
+/// the cheapest continuous check that its timebase is what it claims.
+fn cpuKhz() u32 {
+ const t0 = hal.systimer.read(.unit0) orelse return 0;
+ const c0 = soc.cycles();
+ soc.rom.ets_delay_us(20_000);
+ const t1 = hal.systimer.read(.unit0) orelse return 0;
+ const c1 = soc.cycles();
+ const ticks = t1 - t0;
+ if (ticks == 0) return 0;
+ return @intCast(((c1 - c0) * (hal.systimer.hz / 1000)) / ticks);
+}
+
+export fn zig_main() noreturn {
+ // Without this the board resets about ten seconds in, which for a 3.9 s frame captured inside a
+ // 10 s SDR dwell is the difference between a capture and a reboot.
+ _ = hal.rwdt.disable();
+
+ hal.systimer.init();
+ hal.gpio.configureOutput(tone_pin, .{ .readback = true });
+ toneInit();
+
+ soc.rom.print("\r\nMARK RF_READY zig toolchain, no esp-idf, no freertos\r\n", .{});
+ status();
+ soc.rom.print(
+ \\commands: T carrier on o carrier off t keyed burst (0x4200)
+ \\ ? status g blink witness p pad sampler i idle
+ \\
+ , .{});
+
+ // The command loop doubles as the 1 Hz blink when nothing is being transmitted, so the pad is
+ // never left floating and `g` has something to witness.
+ var last_toggle = micros();
+ var level: u1 = 0;
+ while (true) {
+ if (console.rxCount() > 0) {
+ var buf: [1]u8 = undefined;
+ if (console.read(&buf) == 1) {
+ switch (buf[0]) {
+ 'T' => {
+ toneOn();
+ mark("TONE_CONTINUOUS_ON", "gpio20 24975609Hz square");
+ },
+ 'o' => {
+ toneOff();
+ mark("TONE_OFF", "gpio20 released to blink");
+ hal.gpio.configureOutput(tone_pin, .{ .readback = true });
+ },
+ 't' => toneBurst(),
+ '?' => status(),
+ 'g' => blinkWitness(),
+ 'p' => padSample(),
+ 'd' => dumpRegs(),
+ // Experiment: hand the pad's output enable back to GPIO_ENABLE (oen_sel = 1)
+ // instead of to the routed peripheral. LEDC's signal carries no output-enable
+ // line, so with oen_sel = 0 there may be nothing asserting OE at all.
+ // Bisection: a slow LEDC output that the pad sampler can obviously see. If this
+ // toggles, LEDC and the routing work and the 25 MHz case is about the divider or
+ // a frequency ceiling; if it does not, the output path itself is broken.
+ 's' => {
+ hal.ledc.configureTimer(tone_timer, .{
+ .resolution = 8,
+ .freq_hz = 1000,
+ .src_hz = hal.ledc.pll_div_hz,
+ }) catch {
+ soc.rom.print("MARK SLOW_FAIL divider out of range\r\n", .{});
+ continue;
+ };
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer,
+ .duty = 128, // half of 2^8
+ .hpoint = 0,
+ .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.start(tone_channel);
+ carrier_running = true;
+ const div = hal.ledc.getClockDivider(tone_timer);
+ soc.rom.print("MARK SLOW_ON 1kHz 8-bit divider=%u freq=%uHz\r\n", .{
+ div, hal.ledc.frequencyOf(hal.ledc.pll_div_hz, div, 8),
+ });
+ },
+ // Where does it stop? Sweep resolution/frequency pairs and count edges on the
+ // pad. This turns "25 MHz does not work" into a measured ceiling.
+ 'S' => {
+ const cases = [_]struct { res: u5, hz: u32 }{
+ .{ .res = 8, .hz = 1_000 },
+ .{ .res = 8, .hz = 100_000 },
+ .{ .res = 4, .hz = 1_000_000 },
+ .{ .res = 2, .hz = 5_000_000 },
+ .{ .res = 2, .hz = 12_500_000 },
+ .{ .res = 1, .hz = 1_000_000 },
+ .{ .res = 1, .hz = 10_000_000 },
+ .{ .res = 1, .hz = 20_000_000 },
+ .{ .res = 1, .hz = 25_000_000 },
+ .{ .res = 1, .hz = 40_000_000 },
+ };
+ inline for (cases) |c| {
+ const want_div = hal.ledc.divisor(hal.ledc.pll_div_hz, c.hz, c.res);
+ if (!hal.ledc.divisorValid(want_div)) {
+ soc.rom.print("MARK SWEEP res=%u want=%uHz div=%u REJECTED\r\n", .{
+ @as(u32, c.res), c.hz, want_div,
+ });
+ } else {
+ hal.ledc.setClockDivider(tone_timer, want_div);
+ hal.ledc.setDutyResolution(tone_timer, c.res);
+ hal.ledc.commitTimer(tone_timer);
+ hal.ledc.resumeTimer(tone_timer);
+ hal.ledc.resetTimer(tone_timer);
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer,
+ .duty = @as(u32, 1) << (c.res - 1),
+ .hpoint = 0,
+ .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.start(tone_channel);
+ var ones: u32 = 0;
+ var edges: u32 = 0;
+ var prev: u1 = hal.gpio.getLevel(tone_pin);
+ var k: u32 = 0;
+ while (k < 2048) : (k += 1) {
+ const v = hal.gpio.getLevel(tone_pin);
+ if (v == 1) ones += 1;
+ if (v != prev) edges += 1;
+ prev = v;
+ }
+ soc.rom.print("MARK SWEEP res=%u want=%uHz div=%u got=%uHz ones=%u edges=%u\r\n", .{
+ @as(u32, c.res), c.hz, want_div,
+ hal.ledc.frequencyOf(hal.ledc.pll_div_hz, want_div, c.res),
+ ones, edges,
+ });
+ }
+ }
+ hal.ledc.stop(tone_channel, 0);
+ carrier_running = false;
+ },
+ // The LEDC registers are bit-identical to ESP-IDF's for this configuration
+ // (proven by the differential harness, 96 words), so if the pad still does not
+ // move the difference is in a clock mux outside the block. There are two:
+ // HP_SYS_CLKRST.peri_clk_ctrl22.reg_ledc_clk_src_sel, and LEDC_CONF.APB_CLK_SEL
+ // inside the block whose documented meaning is 0=APB_CLK, 1=RC_FAST, 2=XTAL,
+ // 3=invalid. Try every combination and sample the pad.
+ // Measure the LEDC output period against the systimer's fixed 16 MHz, at a
+ // frequency slow enough to time edges reliably. That yields the *actual* source
+ // clock, which is the number every divider here assumes and none has verified:
+ // this image runs at whatever the bootloader left (90 MHz CPU), not at the
+ // 360 MHz the ESP-IDF firmware configures, so its APB need not be 80 MHz.
+ // The A/B that settles it: ESP-IDF's LL and this HAL, same image, same pad, same
+ // edge counter, at the resolutions that matter.
+ 'A' => {
+ const cases = [_]struct { hz: u32, res: u5 }{
+ .{ .hz = 5_000_000, .res = 2 },
+ .{ .hz = 10_000_000, .res = 1 },
+ .{ .hz = 25_000_000, .res = 1 },
+ };
+ inline for (cases) |c| {
+ // ESP-IDF's side.
+ oracle_ledc_configure_timer(tone_timer, hal.ledc.pll_div_hz, @intCast(c.hz), c.res);
+ oracle_ledc_configure_channel(tone_channel, tone_timer, @as(u32, 1) << (c.res - 1), 0, 0, 1);
+ oracle_ledc_set_pin(tone_pin, tone_channel);
+ const idf_edges = countEdges(300_000);
+ // Ours.
+ hal.ledc.configureTimer(tone_timer, .{
+ .resolution = c.res, .freq_hz = c.hz, .src_hz = hal.ledc.pll_div_hz,
+ }) catch {};
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer, .duty = @as(u32, 1) << (c.res - 1),
+ .hpoint = 0, .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.start(tone_channel);
+ const our_edges = countEdges(300_000);
+ soc.rom.print("MARK AB res=%u hz=%u idf_edges=%u our_edges=%u (300k reads each)\r\n", .{
+ @as(u32, c.res), c.hz, idf_edges, our_edges,
+ });
+ }
+ hal.ledc.stop(tone_channel, 0);
+ carrier_running = false;
+ },
+ 'F' => {
+ // Same measurement across rates, with a generous guard: the question is only
+ // whether ANY edge appears, so a read loop that cannot keep up with the rate
+ // still answers it.
+ const rates = [_]struct { hz: u32, res: u5 }{
+ .{ .hz = 10_000, .res = 8 }, .{ .hz = 1_000_000, .res = 4 },
+ .{ .hz = 5_000_000, .res = 2 }, .{ .hz = 10_000_000, .res = 1 },
+ .{ .hz = 15_000_000, .res = 1 }, .{ .hz = 20_000_000, .res = 1 },
+ .{ .hz = 25_000_000, .res = 1 },
+ };
+ inline for (rates) |r| {
+ const d = hal.ledc.divisor(hal.ledc.pll_div_hz, r.hz, r.res);
+ hal.ledc.setClockDivider(tone_timer, d);
+ hal.ledc.setDutyResolution(tone_timer, r.res);
+ hal.ledc.commitTimer(tone_timer);
+ hal.ledc.resumeTimer(tone_timer);
+ hal.ledc.resetTimer(tone_timer);
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer,
+ .duty = @as(u32, 1) << (r.res - 1),
+ .hpoint = 0,
+ .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.start(tone_channel);
+ var seen: u32 = 0;
+ var prev: u1 = hal.gpio.getLevel(tone_pin);
+ var g: u32 = 0;
+ while (seen < 200 and g < 2_000_000) : (g += 1) {
+ const v = hal.gpio.getLevel(tone_pin);
+ if (v == 1 and prev == 0) seen += 1;
+ prev = v;
+ }
+ soc.rom.print("MARK EDGES want=%uHz res=%u div=%u rising_edges=%u in %u reads\r\n", .{
+ r.hz, @as(u32, r.res), d, seen, g,
+ });
+ }
+ hal.ledc.stop(tone_channel, 0);
+ carrier_running = false;
+ },
+ 'f' => {
+ const want: u32 = 10_000;
+ const res: u5 = 8;
+ const div = hal.ledc.divisor(hal.ledc.pll_div_hz, want, res);
+ hal.ledc.setClockDivider(tone_timer, div);
+ hal.ledc.setDutyResolution(tone_timer, res);
+ hal.ledc.commitTimer(tone_timer);
+ hal.ledc.resumeTimer(tone_timer);
+ hal.ledc.resetTimer(tone_timer);
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer, .duty = 128, .hpoint = 0, .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.start(tone_channel);
+ // Time 100 rising edges.
+ var seen: u32 = 0;
+ var prev: u1 = hal.gpio.getLevel(tone_pin);
+ const t_start = hal.systimer.read(.unit0) orelse 0;
+ var guard: u32 = 0;
+ while (seen < 100 and guard < 20_000_000) : (guard += 1) {
+ const v = hal.gpio.getLevel(tone_pin);
+ if (v == 1 and prev == 0) seen += 1;
+ prev = v;
+ }
+ const t_end = hal.systimer.read(.unit0) orelse 0;
+ const us = (t_end - t_start) / (hal.systimer.hz / 1_000_000);
+ // measured_hz = edges / seconds; source = measured * div * 2^res / 256
+ const measured_hz: u64 = if (us != 0) (@as(u64, seen) * 1_000_000) / us else 0;
+ const src_est: u64 = (measured_hz * div * (@as(u64, 1) << res)) / 256;
+ soc.rom.print("MARK FREQ want=%uHz div=%u edges=%u in %uus -> measured=%uHz implied_src=%uHz\r\n", .{
+ want, div, seen, @as(u32, @truncate(us)),
+ @as(u32, @truncate(measured_hz)), @as(u32, @truncate(src_est)),
+ });
+ },
+ 'M' => {
+ const ctrl22 = mmio.Reg.at(regs.HP_SYS_CLKRST_PERI_CLK_CTRL22_REG);
+ const src_sel = mmio.Field.of(regs.HP_SYS_CLKRST_REG_LEDC_CLK_SRC_SEL_S, regs.HP_SYS_CLKRST_REG_LEDC_CLK_SRC_SEL_V);
+ const fclk_en = mmio.Field.of(regs.HP_SYS_CLKRST_REG_LEDC_CLK_EN_S, regs.HP_SYS_CLKRST_REG_LEDC_CLK_EN_V);
+ const conf = mmio.Reg.at(regs.LEDC_CONF_REG);
+ const apb_sel = mmio.Field.of(regs.LEDC_APB_CLK_SEL_S, regs.LEDC_APB_CLK_SEL_V);
+ var ssel: u32 = 0;
+ while (ssel < 3) : (ssel += 1) {
+ var asel: u32 = 0;
+ while (asel < 3) : (asel += 1) {
+ ctrl22.modify(.{ src_sel.is(ssel), fclk_en.is(1) });
+ conf.modify(.{apb_sel.is(asel)});
+ hal.ledc.configureTimer(tone_timer, .{
+ .resolution = 1,
+ .freq_hz = 25_000_000,
+ .src_hz = hal.ledc.pll_div_hz,
+ }) catch {};
+ hal.ledc.configureChannel(tone_channel, .{
+ .timer = tone_timer,
+ .duty = 1,
+ .hpoint = 0,
+ .idle_level = 0,
+ });
+ hal.ledc.attachPin(tone_channel, tone_pin);
+ hal.ledc.start(tone_channel);
+ var ones: u32 = 0;
+ var edges: u32 = 0;
+ var prev: u1 = hal.gpio.getLevel(tone_pin);
+ var k: u32 = 0;
+ var jit: u32 = 0;
+ while (k < 1024) : (k += 1) {
+ jit = (jit + 1) & 31;
+ var d: u32 = 0;
+ while (d < jit) : (d += 1) asm volatile ("nop");
+ const v = hal.gpio.getLevel(tone_pin);
+ if (v == 1) ones += 1;
+ if (v != prev) edges += 1;
+ prev = v;
+ }
+ soc.rom.print("MARK MUX src_sel=%u apb_sel=%u ones=%u edges=%u of 1024\r\n", .{
+ ssel, asel, ones, edges,
+ });
+ }
+ }
+ },
+ 'E' => {
+ const sel = mmio.Reg.atAddress(@as(u32, @intCast(regs.GPIO_FUNC0_OUT_SEL_CFG_REG)) + 4 * @as(u32, tone_pin));
+ sel.modify(.{mmio.Field.of(regs.GPIO_FUNC0_OEN_SEL_S, regs.GPIO_FUNC0_OEN_SEL_V).is(1)});
+ hal.gpio.outputEnable(tone_pin);
+ soc.rom.print("MARK OEN_SEL set to 1 (GPIO_ENABLE drives OE), matrix_out20=0x%08x\r\n", .{sel.raw()});
+ },
+ 'i' => {
+ toneOff();
+ hal.gpio.configureOutput(tone_pin, .{ .readback = true });
+ mark("IDLE", "-");
+ },
+ else => {},
+ }
+ }
+ }
+
+ if (!carrier_running) {
+ const now = micros();
+ if (now - last_toggle >= 500_000) {
+ last_toggle = now;
+ level = ~level;
+ hal.gpio.setLevel(tone_pin, level);
+ }
+ }
+ }
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}
diff --git a/examples/sdiocheck.zig b/examples/sdiocheck.zig
new file mode 100644
index 0000000..d9fde11
--- /dev/null
+++ b/examples/sdiocheck.zig
@@ -0,0 +1,321 @@
+//! Does CMD53 return the same bytes as CMD52?
+//!
+//! Written to settle one question during radio bring-up. The transport gets all the way to "Open
+//! data path at slave" and then never sees the slave's NEW_PACKET bit, which it learns by reading the
+//! slave's interrupt register through `sdio_read_regs` - a multi-byte read, so CMD53. CMD52 is
+//! already proven on this board: the CCCR write-and-read-back during card init cannot succeed
+//! without it. CMD53 is not, and it is the one that uses the IDMAC and therefore the one exposed to
+//! every DMA and cache mistake.
+//!
+//! The test is a differential against the bus itself. Read the same slave register window twice -
+//! once with a single CMD53, once byte at a time with CMD52 - and compare. Both go to the same
+//! addresses on the same card in the same boot, so a disagreement is our CMD53 and nothing else.
+//!
+//! Registers are the ones the transport actually reads, from
+//! host/drivers/transport/sdio/sdio_reg.h, masked with ESP_ADDRESS_MASK (0x3FF) exactly as
+//! `hosted_sdio_read_reg` does at port_esp_hosted_host_sdio.c:500:
+//!
+//! 0x050 ESP_SLAVE_INT_RAW_REG bit 23 is RX_NEW_PACKET, the bit the read task waits for
+//! 0x058 ESP_SLAVE_INT_ST_REG
+//! 0x060 ESP_SLAVE_PACKET_LEN_REG the slave's cumulative TX length counter
+//! 0x044 ESP_SLAVE_TOKEN_RDATA the slave's receive-buffer credit
+//! 0x06C ESP_SLAVE_SCRATCH_REG_0 written by the coprocessor firmware
+//!
+//! What the output means:
+//!
+//! MARK SDIO_CMP ... SAME CMD53 agrees with CMD52 - the data path is good and the missing
+//! NEW_PACKET is the slave's silence, not our bus
+//! MARK SDIO_CMP ... DIFFER CMD53 is broken; the bytes printed say how (all-zero is a DMA that
+//! never landed, stale is a cache view, shifted is an address or
+//! length error)
+//! MARK SDIO_LEN ... the slave's packet-length counter, read twice a second apart. If it
+//! moves, the C6 is queueing data for us and the fault is on the host
+//! side of the interrupt. If it never moves, the C6 has nothing to say.
+//!
+//! Run: zig build -Dapp=examples/sdiocheck.zig run -Dseconds=15
+
+const std = @import("std");
+const soc = @import("soc");
+const hal = @import("hal");
+
+pub const panic = std.debug.FullPanic(struct {
+ fn call(msg: []const u8, _: ?usize) noreturn {
+ soc.rom.print("MARK SDIO_PANIC %s\r\n", .{msg.ptr});
+ while (true) {}
+ }
+}.call);
+
+/// FN1: every slave register lives in function 1 (port_esp_hosted_host_sdio.c:505).
+const func: u3 = 1;
+
+/// The windows to compare. Length 4 for the 32-bit registers, and one longer run to catch a length
+/// or block-boundary error that a 4-byte read would not.
+const windows = [_]struct { name: [*:0]const u8, addr: u17, len: usize }{
+ .{ .name = "INT_RAW 0x050", .addr = 0x050, .len = 4 },
+ .{ .name = "INT_ST 0x058", .addr = 0x058, .len = 4 },
+ .{ .name = "PKT_LEN 0x060", .addr = 0x060, .len = 4 },
+ .{ .name = "TOKEN 0x044", .addr = 0x044, .len = 4 },
+ .{ .name = "SCRATCH 0x06C", .addr = 0x06C, .len = 4 },
+ .{ .name = "RUN 0x050", .addr = 0x050, .len = 16 },
+};
+
+export fn zig_main() noreturn {
+ _ = hal.rwdt.disable();
+ soc.rom.print("\r\nMARK SDIO_START\r\n", .{});
+
+ // The C6 must be out of reset and booted before it will answer anything. Active low with an
+ // external pull-up: drive low to hold, release to run. Driving it high would fight the pull-up.
+ hal.gpio.configureOutput(54, .{});
+ hal.gpio.setLow(54);
+ soc.rom.ets_delay_us(20_000);
+ hal.gpio.outputDisable(54); // release; the pull-up takes it high
+ soc.rom.print("MARK SDIO_RESET released gpio54, waiting for the C6 to boot\r\n", .{});
+ soc.rom.ets_delay_us(1_500_000);
+
+ hal.sdmmc.init(.{ .slot = 1, .width = .four, .khz = 40_000 }) catch |err| {
+ soc.rom.print("MARK SDIO_FAIL init=%s\r\n", .{@errorName(err).ptr});
+ park();
+ };
+ hal.sdmmc.cardInit() catch |err| {
+ soc.rom.print("MARK SDIO_FAIL cardInit=%s\r\n", .{@errorName(err).ptr});
+ park();
+ };
+ soc.rom.print("MARK SDIO_CARD up\r\n", .{});
+
+ // FN1 must be enabled and ready before its registers answer, exactly as card init does for the
+ // transport. Without this the reads below are against a disabled function and return zeros -
+ // which would look identical to a broken CMD53, so it is done explicitly rather than assumed.
+ enableFn1() catch |err| {
+ soc.rom.print("MARK SDIO_FAIL fn1=%s\r\n", .{@errorName(err).ptr});
+ park();
+ };
+
+ var buf53: [32]u8 = undefined;
+ var buf52: [32]u8 = undefined;
+ var differ: u32 = 0;
+
+ for (windows) |w| {
+ // CMD53 first, then CMD52, then CMD53 again. The third read is what tells a genuine
+ // disagreement apart from a register that simply changed between the two reads - these are
+ // live status registers, and a differing INT_RAW could be honest.
+ hal.sdmmc.cmd53Read(func, w.addr, buf53[0..w.len], true) catch |err| {
+ soc.rom.print("MARK SDIO_CMP %s cmd53=%s\r\n", .{ w.name, @errorName(err).ptr });
+ differ += 1;
+ continue;
+ };
+ for (0..w.len) |i| {
+ buf52[i] = hal.sdmmc.cmd52Read(func, @intCast(w.addr + i)) catch {
+ soc.rom.print("MARK SDIO_CMP %s cmd52 failed at +%u\r\n", .{ w.name, @as(u32, @intCast(i)) });
+ differ += 1;
+ break;
+ };
+ }
+ const same = std.mem.eql(u8, buf53[0..w.len], buf52[0..w.len]);
+ if (!same) differ += 1;
+ soc.rom.print("MARK SDIO_CMP %s len=%u cmd53=%s cmd52=%s %s\r\n", .{
+ w.name,
+ @as(u32, @intCast(w.len)),
+ hex(&buf53, w.len, &hexbuf_a),
+ hex(&buf52, w.len, &hexbuf_b),
+ @as([*:0]const u8, if (same) "SAME" else "DIFFER"),
+ });
+ }
+ soc.rom.print("MARK SDIO_CMP_TOTAL windows=%u differing=%u\r\n", .{
+ @as(u32, windows.len), differ,
+ });
+
+ // Is the slave producing anything at all? PACKET_LEN is a cumulative counter of bytes the slave
+ // has made available. Sampled twice a second apart: movement means the C6 is queueing data and
+ // the fault is on our side of the interrupt; no movement means it has nothing to send.
+ var a: [4]u8 = undefined;
+ var b: [4]u8 = undefined;
+ hal.sdmmc.cmd53Read(func, 0x060, &a, true) catch {};
+ soc.rom.ets_delay_us(1_000_000);
+ hal.sdmmc.cmd53Read(func, 0x060, &b, true) catch {};
+ const len_a = std.mem.readInt(u32, &a, .little) & 0xFFFFF;
+ const len_b = std.mem.readInt(u32, &b, .little) & 0xFFFFF;
+ soc.rom.print("MARK SDIO_LEN first=%u second=%u moved=%u\r\n", .{
+ len_a, len_b, @as(u32, @intFromBool(len_a != len_b)),
+ });
+
+ // And the interrupt register, decoded, because bit 23 is the whole question.
+ var ir: [4]u8 = undefined;
+ hal.sdmmc.cmd53Read(func, 0x050, &ir, true) catch {};
+ const raw = std.mem.readInt(u32, &ir, .little);
+ soc.rom.print("MARK SDIO_INTRAW 0x%08x new_packet=%u\r\n", .{
+ raw, @as(u32, @intFromBool(raw & (1 << 23) != 0)),
+ });
+
+ // Every scratch register, because this is where the coprocessor firmware announces itself. All
+ // zeroes would say the C6 is answering as a card but not running ESP-Hosted's slave app.
+ var scratch: [8]u32 = undefined;
+ const scratch_addr = [_]u17{ 0x06C, 0x070, 0x074, 0x078, 0x07C, 0x080, 0x088, 0x08C };
+ for (scratch_addr, 0..) |a2, i| {
+ var w: [4]u8 = undefined;
+ hal.sdmmc.cmd53Read(func, a2, &w, true) catch {};
+ scratch[i] = std.mem.readInt(u32, &w, .little);
+ }
+ soc.rom.print("MARK SDIO_SCRATCH %08x %08x %08x %08x %08x %08x %08x %08x\r\n", .{
+ scratch[0], scratch[1], scratch[2], scratch[3],
+ scratch[4], scratch[5], scratch[6], scratch[7],
+ });
+
+ // The poke the transport makes after card init: ESP_OPEN_DATA_PATH is enum value 0, so
+ // sdio_generate_slave_intr writes BIT(0 + ESP_SDIO_CONF_OFFSET) = 0x01 to
+ // HOST_TO_SLAVE_INTR = ESP_SLAVE_SCRATCH_REG_7, masked to offset 0x08C
+ // (sdio_drv.c:404-415, sdio_reg.h:49,77,99).
+ //
+ // This is the decisive test. If the slave answers a poke with a packet, our host's read loop is
+ // at fault. If it stays silent, the coprocessor is not responding to the protocol and no amount
+ // of host-side work will help.
+ soc.rom.print("MARK SDIO_POKE writing 0x01 to 0x08c (ESP_OPEN_DATA_PATH)\r\n", .{});
+ hal.sdmmc.cmd52Write(func, 0x08C, 0x01) catch |err| {
+ soc.rom.print("MARK SDIO_POKE write failed=%s\r\n", .{@errorName(err).ptr});
+ };
+
+ var beat: u32 = 0;
+ while (beat < 20) : (beat += 1) {
+ soc.rom.ets_delay_us(100_000);
+ var w1: [4]u8 = undefined;
+ var w2: [4]u8 = undefined;
+ hal.sdmmc.cmd53Read(func, 0x050, &w1, true) catch {};
+ hal.sdmmc.cmd53Read(func, 0x060, &w2, true) catch {};
+ const iraw = std.mem.readInt(u32, &w1, .little);
+ const plen = std.mem.readInt(u32, &w2, .little) & 0xFFFFF;
+ if (iraw & (1 << 23) != 0 or plen != 0) {
+ soc.rom.print("MARK SDIO_ANSWER beat=%u int_raw=0x%08x new_packet=1 pkt_len=%u\r\n", .{
+ beat, iraw, plen,
+ });
+ break;
+ }
+ if (beat % 5 == 0) {
+ soc.rom.print("MARK SDIO_WAIT beat=%u int_raw=0x%08x pkt_len=%u\r\n", .{ beat, iraw, plen });
+ }
+ }
+ if (beat >= 20) soc.rom.print("MARK SDIO_SILENT no packet 2 s after the poke\r\n", .{});
+
+ // CMD53 WRITES, which nothing has yet verified.
+ //
+ // The reads above are proven identical to CMD52. Writes are the other half and they are the
+ // half the transport depends on: every RPC request leaves through a CMD53 write, and a request
+ // that arrives corrupted at the slave produces exactly what the board shows - the request goes
+ // out, the coprocessor makes nothing of it, and no response ever comes back.
+ //
+ // Writes are also where the cache hazard points the other way. On a read, DMA fills memory and
+ // the CPU must not see a stale cached line. On a write, the CPU fills a buffer - which lands in
+ // the L1 D-cache - and DMA reads from memory, so without a write-back the controller sends
+ // whatever was in memory before. Reads working tells us nothing about writes.
+ //
+ // Scratch register 1 (offset 0x070) is the target: writable from the host, and not the one that
+ // triggers slave interrupts (that is register 7 at 0x08C, poked above).
+ const scratch1: u17 = 0x070;
+ const patterns = [_][4]u8{
+ .{ 0xde, 0xad, 0xbe, 0xef },
+ .{ 0x00, 0x00, 0x00, 0x00 },
+ .{ 0xff, 0xff, 0xff, 0xff },
+ .{ 0x42, 0x00, 0x42, 0x00 },
+ };
+ var write_bad: u32 = 0;
+ for (patterns) |pat| {
+ var out = pat; // a mutable copy, so the driver may not rely on the source being static
+ hal.sdmmc.cmd53Write(func, scratch1, &out, true) catch |err| {
+ soc.rom.print("MARK SDIO_W53 pattern=%s write failed=%s\r\n", .{
+ hex(&pat, 4, &hexbuf_a), @errorName(err).ptr,
+ });
+ write_bad += 1;
+ continue;
+ };
+ // Read back with CMD52, the path already proven, so a mismatch can only be the write.
+ var back: [4]u8 = undefined;
+ for (0..4) |i| {
+ back[i] = hal.sdmmc.cmd52Read(func, @intCast(scratch1 + i)) catch 0xAA;
+ }
+ const ok = std.mem.eql(u8, &pat, &back);
+ if (!ok) write_bad += 1;
+ soc.rom.print("MARK SDIO_W53 wrote=%s read=%s %s\r\n", .{
+ hex(&pat, 4, &hexbuf_a),
+ hex(&back, 4, &hexbuf_b),
+ @as([*:0]const u8, if (ok) "SAME" else "DIFFER"),
+ });
+ }
+
+ // And the same patterns through CMD52 writes, as the control: if these also fail the register is
+ // not writable and the CMD53 result above means nothing.
+ var ctrl_bad: u32 = 0;
+ for (patterns) |pat| {
+ for (0..4) |i| {
+ hal.sdmmc.cmd52Write(func, @intCast(scratch1 + i), pat[i]) catch {};
+ }
+ var back: [4]u8 = undefined;
+ for (0..4) |i| {
+ back[i] = hal.sdmmc.cmd52Read(func, @intCast(scratch1 + i)) catch 0xAA;
+ }
+ if (!std.mem.eql(u8, &pat, &back)) ctrl_bad += 1;
+ }
+ soc.rom.print("MARK SDIO_W_TOTAL cmd53_bad=%u cmd52_bad=%u of %u\r\n", .{
+ write_bad, ctrl_bad, @as(u32, patterns.len),
+ });
+
+ soc.rom.print("MARK SDIO_DONE\r\n", .{});
+ park();
+}
+
+/// Enable function 1 and wait for it to report ready, then set its block size. The CCCR offsets are
+/// the standard SDIO ones; the sequence mirrors what src/net/port.zig's card init does, kept local so
+/// this example does not depend on the radio stack being built.
+fn enableFn1() !void {
+ const cccr_fn_enable = 0x02;
+ const cccr_fn_ready = 0x03;
+ const fn1: u8 = 1 << 1;
+
+ const ioe = try hal.sdmmc.cmd52Read(0, cccr_fn_enable);
+ try hal.sdmmc.cmd52Write(0, cccr_fn_enable, ioe | fn1);
+
+ var tries: u32 = 0;
+ while (tries < 1000) : (tries += 1) {
+ const ready = try hal.sdmmc.cmd52Read(0, cccr_fn_ready);
+ if (ready & fn1 != 0) {
+ soc.rom.print("MARK SDIO_FN1 ready after %u polls\r\n", .{tries});
+ return;
+ }
+ soc.rom.ets_delay_us(1000);
+ }
+ return error.Timeout;
+}
+
+var hexbuf_a: [80]u8 = undefined;
+var hexbuf_b: [80]u8 = undefined;
+
+/// Bytes as lowercase hex into a caller-provided buffer, NUL-terminated for the ROM printer.
+fn hex(bytes: []const u8, len: usize, out: *[80]u8) [*:0]const u8 {
+ const digits = "0123456789abcdef";
+ var i: usize = 0;
+ while (i < len and i * 2 + 2 < out.len) : (i += 1) {
+ out[i * 2] = digits[bytes[i] >> 4];
+ out[i * 2 + 1] = digits[bytes[i] & 0xF];
+ }
+ out[i * 2] = 0;
+ return @ptrCast(out);
+}
+
+fn park() noreturn {
+ while (true) {}
+}
+
+export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
+ asm volatile (
+ \\ li t0, 1 << 13
+ \\ csrs mstatus, t0
+ \\ la sp, __stack_top
+ \\ mv fp, sp
+ \\ la t0, __bss_start
+ \\ la t1, __bss_end
+ \\ bgeu t0, t1, 2f
+ \\1:
+ \\ sw zero, 0(t0)
+ \\ addi t0, t0, 4
+ \\ bltu t0, t1, 1b
+ \\2:
+ \\ j zig_main
+ );
+}