summaryrefslogtreecommitdiff
path: root/examples/radio.zig
blob: 7a2704a2ce9961eaf6768e5213ea4f392c61b4a5 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
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) {}
}