summaryrefslogtreecommitdiff
path: root/examples/radio.zig
diff options
context:
space:
mode:
Diffstat (limited to 'examples/radio.zig')
-rw-r--r--examples/radio.zig388
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) {}
+}