diff options
Diffstat (limited to 'examples/radio.zig')
| -rw-r--r-- | examples/radio.zig | 388 |
1 files changed, 388 insertions, 0 deletions
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) {} +} |
