//! 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 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) {} }