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