summaryrefslogtreecommitdiff
path: root/src/net/port.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/net/port.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/net/port.zig')
-rw-r--r--src/net/port.zig2194
1 files changed, 2194 insertions, 0 deletions
diff --git a/src/net/port.zig b/src/net/port.zig
new file mode 100644
index 0000000..fafbf4e
--- /dev/null
+++ b/src/net/port.zig
@@ -0,0 +1,2194 @@
+//! ESP-Hosted's `g_h.funcs` port table, in Zig.
+//!
+//! This is the seam. Above it sit ~13,000 lines of ESP-Hosted C - the SDIO transport state machine,
+//! the RPC protocol, the protobuf codec - which are already correct and which this project has no
+//! intention of rewriting. Below it sit `std.Io`, `std.mem.Allocator` and `src/hal`. Everything
+//! ESP-Hosted asks of an operating system passes through the 71 function pointers defined here, so
+//! this file is the entire dependency of that C on FreeRTOS and ESP-IDF, and replacing it replaces
+//! both.
+//!
+//! # The struct, and why its layout is the dangerous part
+//!
+//! `hosted_osi_funcs_t` is declared at `host/esp_hosted_os_abstraction.h:13-117`. Every member is a
+//! function pointer, so on rv32 the struct is 71 words and **there is nothing in the type system,
+//! on either side, that notices a field in the wrong place**. A mis-ordered pointer is a call to
+//! the wrong function with the wrong arguments, which on this board is a hang with no console
+//! output.
+//!
+//! Worse, the C struct is not one layout. Four mempool members are guarded by
+//! `#ifdef H_USE_MEMPOOL` (`:64-69`), and `H_USE_MEMPOOL` is *always defined* - to 1 or to 0 - by
+//! `host/port/esp/freertos/include/port_esp_hosted_host_config.h:127-131`, which `#ifdef` does not
+//! care about. A translation unit that reaches the struct without having seen that header first
+//! gets a struct 16 bytes shorter, with everything from `_h_config_gpio` onward displaced by four
+//! pointers. That is reachable in the real tree: `host/esp_hosted.h:14` and
+//! `host/drivers/transport/transport_util.h:10` both include the abstraction header as their first
+//! include. Measured with our own flags:
+//!
+//! without -include port_esp_hosted_host_config.h: sizeof=268 _h_config_gpio=132 _h_event_post=264
+//! with -include port_esp_hosted_host_config.h: sizeof=284 _h_config_gpio=148 _h_event_post=280
+//!
+//! This file targets the long layout, and `layout_check` below asserts the three numbers on the
+//! right. The build force-includes that header into every ESP-Hosted translation unit and compares
+//! C's `offsetof` against these assertions, so an include-order change fails the build instead of
+//! the board.
+//!
+//! # What is real, what is a loud stub
+//!
+//! Real: memory, sync, threads, timers, time, GPIO, SDIO, events, mempool locks. That is every
+//! entry the SDIO transport and the RPC layer touch, established by grepping the tree for each
+//! `_h_` name rather than by guessing.
+//!
+//! Loud stubs: the SPI, SPI-HD and UART transports (a different bus), power-save (needs
+//! `esp_sleep`), `_h_do_bus_transfer` (SPI-only; ESP-IDF leaves it null under SDIO), and
+//! `_h_printf`. Each prints its own name through `ets_printf` and returns a failure code, so an
+//! unimplemented path announces itself on the console instead of jumping through a null pointer.
+//! `stub_calls` counts them.
+//!
+//! # Where ESP-Hosted's assumptions do not fit a cooperative single-core runtime
+//!
+//! Four places, all documented at the point of impact:
+//!
+//! * `_h_post_semaphore_from_isr` - FreeRTOS manipulates the semaphore inside a critical section
+//! and requests a context switch on return. See `hosted_os.Semaphore.postFromIsr`.
+//! * `_h_thread_cancel` - `vTaskDelete` kills a task where it stands; `std.Io`'s cancel asks and
+//! waits, and ESP-Hosted's task bodies never return. See `hosted_os.Thread.cancel`.
+//! * `_h_blocking_delay` - a deliberate busy-wait, which on a cooperative scheduler starves
+//! every other task for its duration. Unused in the tree; kept honest.
+//! * bounded waits - `std.Io` has no timed acquire for a mutex, semaphore or queue, so those
+//! poll. See `hosted_os.poll_interval_ms`. Unbounded waits, which is what every hot path uses,
+//! block properly.
+
+const std = @import("std");
+const assert = std.debug.assert;
+const Io = std.Io;
+const Allocator = std.mem.Allocator;
+
+const hal = @import("hal");
+const hheap = @import("heap.zig");
+const os = @import("hosted_os.zig");
+
+const ret = os.ret;
+
+/// `ets_printf` from the mask ROM. Declared here rather than imported from `soc` so this file's
+/// only module dependency is `hal`; the symbol comes from
+/// `components/esp_rom/esp32p4/ld/esp32p4.rom.ld`.
+extern fn ets_printf(fmt: [*:0]const u8, ...) c_int;
+
+fn note(comptime fmt: [*:0]const u8, args: anytype) void {
+ _ = @call(.auto, ets_printf, .{fmt} ++ args);
+}
+
+// ============================================================================ the struct
+
+/// `void (*start_routine)(void const *)`, `esp_hosted_os_abstraction.h:25`.
+pub const StartRoutine = *const fn (?*const anyopaque) callconv(.c) void;
+/// `void (*timeout_handler)(void *)`, `:60`.
+pub const TimerHandler = *const fn (?*anyopaque) callconv(.c) void;
+/// `void (*gpio_isr_handler)(void* arg)`, `:73`.
+pub const IsrHandler = *const fn (?*anyopaque) callconv(.c) void;
+/// `esp_event_base_t`, which is `const char *`.
+pub const EventBase = [*:0]const u8;
+
+/// `hosted_osi_funcs_t`, `host/esp_hosted_os_abstraction.h:13-117`, in the layout that
+/// `H_USE_MEMPOOL` being defined produces. Field order is the C declaration order exactly; the
+/// line number beside each is its declaration in that header.
+pub const HostedOsiFuncs = extern struct {
+ // ---- Memory, :15-22
+ /// :15 `void* (*)(void* dest, const void* src, uint32_t size)`
+ memcpy: *const fn (?*anyopaque, ?*const anyopaque, u32) callconv(.c) ?*anyopaque,
+ /// :16 `void* (*)(void* buf, int val, size_t len)`
+ memset: *const fn (?*anyopaque, c_int, usize) callconv(.c) ?*anyopaque,
+ /// :17 `void* (*)(size_t size)`
+ malloc: *const fn (usize) callconv(.c) ?*anyopaque,
+ /// :18 `void* (*)(size_t blk_no, size_t size)`
+ calloc: *const fn (usize, usize) callconv(.c) ?*anyopaque,
+ /// :19 `void (*)(void* ptr)`
+ free: *const fn (?*anyopaque) callconv(.c) void,
+ /// :20 `void* (*)(void *mem, size_t newsize)`
+ realloc: *const fn (?*anyopaque, usize) callconv(.c) ?*anyopaque,
+ /// :21 `void* (*)(size_t size, size_t align)`
+ malloc_align: *const fn (usize, usize) callconv(.c) ?*anyopaque,
+ /// :22 `void (*)(void* ptr)`
+ free_align: *const fn (?*anyopaque) callconv(.c) void,
+
+ // ---- Thread, :25-27
+ /// :25 `void* (*)(const char *tname, uint32_t tprio, uint32_t tstack_size, void (*start_routine)(void const *), void *sr_arg)`
+ thread_create: *const fn ([*:0]const u8, u32, u32, StartRoutine, ?*anyopaque) callconv(.c) ?*anyopaque,
+ /// :26 `int (*)(void *thread_handle)`
+ thread_cancel: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :27 `void (*)(void)`
+ thread_yield: *const fn () callconv(.c) void,
+
+ // ---- Sleeps, :30-32
+ /// :30 `unsigned int (*)(unsigned int mseconds)`
+ msleep: *const fn (c_uint) callconv(.c) c_uint,
+ /// :31 `unsigned int (*)(unsigned int useconds)`
+ usleep: *const fn (c_uint) callconv(.c) c_uint,
+ /// :32 `unsigned int (*)(unsigned int seconds)`
+ sleep: *const fn (c_uint) callconv(.c) c_uint,
+
+ // ---- Blocking non-sleepable delay, :35
+ /// :35 `unsigned int (*)(unsigned int number)`
+ blocking_delay: *const fn (c_uint) callconv(.c) c_uint,
+
+ // ---- Queue, :38-43
+ /// :38 `int (*)(void * queue_handle, void *item, int timeout)`
+ queue_item: *const fn (?*anyopaque, ?*const anyopaque, c_int) callconv(.c) c_int,
+ /// :39 `void* (*)(uint32_t qnum_elem, uint32_t qitem_size)`
+ create_queue: *const fn (u32, u32) callconv(.c) ?*anyopaque,
+ /// :40 `int (*)(void * queue_handle, void *item, int timeout)`
+ dequeue_item: *const fn (?*anyopaque, ?*anyopaque, c_int) callconv(.c) c_int,
+ /// :41 `int (*)(void * queue_handle)`
+ queue_msg_waiting: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :42 `int (*)(void * queue_handle)`
+ destroy_queue: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :43 `int (*)(void * queue_handle)`
+ reset_queue: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- Mutex, :46-49. Note that unlock comes *first*.
+ /// :46 `int (*)(void * mutex_handle)`
+ unlock_mutex: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :47 `void* (*)(void)`
+ create_mutex: *const fn () callconv(.c) ?*anyopaque,
+ /// :48 `int (*)(void * mutex_handle, int timeout_ms)`
+ lock_mutex: *const fn (?*anyopaque, c_int) callconv(.c) c_int,
+ /// :49 `int (*)(void * mutex_handle)`
+ destroy_mutex: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- Semaphore, :52-56. `post` precedes `create`, as with the mutex.
+ /// :52 `int (*)(void * semaphore_handle)`
+ post_semaphore: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :53 `int (*)(void * semaphore_handle)`
+ post_semaphore_from_isr: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :54 `void* (*)(int maxCount)`
+ create_semaphore: *const fn (c_int) callconv(.c) ?*anyopaque,
+ /// :55 `int (*)(void * semaphore_handle, int timeout_ms)`
+ get_semaphore: *const fn (?*anyopaque, c_int) callconv(.c) c_int,
+ /// :56 `int (*)(void * semaphore_handle)`
+ destroy_semaphore: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- Timer, :59-61. `stop` precedes `start`.
+ /// :59 `int (*)(void *timer_handle)`
+ timer_stop: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :60 `void* (*)(const char *name, int duration_ms, int type, void (*timeout_handler)(void *), void *arg)`
+ timer_start: *const fn ([*:0]const u8, c_int, c_int, TimerHandler, ?*anyopaque) callconv(.c) ?*anyopaque,
+ /// :61 `uint64_t (*)(void)`
+ get_time_ms: *const fn () callconv(.c) u64,
+
+ // ---- Mempool, :65-68, present because H_USE_MEMPOOL is defined. See the file header.
+ /// :65 `void* (*)(void)`
+ create_lock_mempool: *const fn () callconv(.c) ?*anyopaque,
+ /// :66 `void (*)(void *lock_handle)`
+ lock_mempool: *const fn (?*anyopaque) callconv(.c) void,
+ /// :67 `void (*)(void *lock_handle)`
+ unlock_mempool: *const fn (?*anyopaque) callconv(.c) void,
+ /// :68 `void (*)(void *lock_handle)`
+ destroy_lock_mempool: *const fn (?*anyopaque) callconv(.c) void,
+
+ // ---- GPIO, :72-79
+ /// :72 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t mode)`
+ config_gpio: *const fn (?*anyopaque, u32, u32) callconv(.c) c_int,
+ /// :73 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t intr_type, void (*gpio_isr_handler)(void* arg), void *arg)`
+ config_gpio_as_interrupt: *const fn (?*anyopaque, u32, u32, IsrHandler, ?*anyopaque) callconv(.c) c_int,
+ /// :74 `int (*)(void* gpio_port, uint32_t gpio_num)`
+ teardown_gpio_interrupt: *const fn (?*anyopaque, u32) callconv(.c) c_int,
+ /// :75 `int (*)(void* gpio_port, uint32_t gpio_num)`
+ read_gpio: *const fn (?*anyopaque, u32) callconv(.c) c_int,
+ /// :76 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t value)`
+ write_gpio: *const fn (?*anyopaque, u32, u32) callconv(.c) c_int,
+ /// :77 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t pull_value, uint32_t enable)`
+ pull_gpio: *const fn (?*anyopaque, u32, u32, u32) callconv(.c) c_int,
+ /// :78 `int (*)(void* gpio_port, uint32_t gpio_num, uint32_t hold_value)`
+ hold_gpio: *const fn (?*anyopaque, u32, u32) callconv(.c) c_int,
+ /// :79 `int (*)(void)`
+ get_host_wakeup_or_reboot_reason: *const fn () callconv(.c) c_int,
+
+ // ---- All transports, :81-82
+ /// :81 `void * (*)(void)`
+ bus_init: *const fn () callconv(.c) ?*anyopaque,
+ /// :82 `int (*)(void*)`
+ bus_deinit: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ // ---- :84-88
+ /// :84 `int (*)(void *transfer_context)` - SPI only; ESP-IDF leaves this null under SDIO.
+ do_bus_transfer: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :85 `int (*)(int32_t event_id, void* event_data, size_t event_data_size, uint32_t ticks_to_wait)`
+ event_wifi_post: *const fn (i32, ?*anyopaque, usize, u32) callconv(.c) c_int,
+ /// :87 `void (*)(int level, const char *tag, const char *format, ...)`
+ printf: *const fn (c_int, [*:0]const u8, [*:0]const u8, ...) callconv(.c) void,
+ /// :88 `void (*)(void)`
+ hosted_init_hook: *const fn () callconv(.c) void,
+
+ // ---- Transport - SDIO, :91-97
+ /// :91 `int (*)(void *ctx, bool show_config)`
+ sdio_card_init: *const fn (?*anyopaque, bool) callconv(.c) c_int,
+ /// :92 `int (*)(void*ctx)`
+ sdio_card_deinit: *const fn (?*anyopaque) callconv(.c) c_int,
+ /// :93 `int (*)(void *ctx, uint32_t reg, uint8_t *data, uint16_t size, bool lock_required)`
+ sdio_read_reg: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :94 same
+ sdio_write_reg: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :95 same
+ sdio_read_block: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :96 same
+ sdio_write_block: *const fn (?*anyopaque, u32, [*]u8, u16, bool) callconv(.c) c_int,
+ /// :97 `int (*)(void *ctx, uint32_t ticks_to_wait)`
+ sdio_wait_slave_intr: *const fn (?*anyopaque, u32) callconv(.c) c_int,
+
+ // ---- Transport - SPI HD, :100-105
+ /// :100 `int (*)(uint32_t reg, uint32_t *data, int poll, bool lock_required)`
+ spi_hd_read_reg: *const fn (u32, *u32, c_int, bool) callconv(.c) c_int,
+ /// :101 `int (*)(uint32_t reg, uint32_t *data, bool lock_required)`
+ spi_hd_write_reg: *const fn (u32, *u32, bool) callconv(.c) c_int,
+ /// :102 `int (*)(uint8_t *data, uint16_t size, bool lock_required)`
+ spi_hd_read_dma: *const fn ([*]u8, u16, bool) callconv(.c) c_int,
+ /// :103 same
+ spi_hd_write_dma: *const fn ([*]u8, u16, bool) callconv(.c) c_int,
+ /// :104 `int (*)(uint32_t data_lines)`
+ spi_hd_set_data_lines: *const fn (u32) callconv(.c) c_int,
+ /// :105 `int (*)(void)`
+ spi_hd_send_cmd9: *const fn () callconv(.c) c_int,
+
+ // ---- Transport - UART, :108-110
+ /// :108 `int (*)(void *ctx, uint8_t *data, uint16_t size)`
+ uart_read: *const fn (?*anyopaque, [*]u8, u16) callconv(.c) c_int,
+ /// :109 same
+ uart_write: *const fn (?*anyopaque, [*]u8, u16) callconv(.c) c_int,
+ /// :110 `int (*)(void *ctx)`
+ uart_flush_input: *const fn (?*anyopaque) callconv(.c) c_int,
+
+ /// :112 `int (*)(void)`
+ restart_host: *const fn () callconv(.c) c_int,
+
+ /// :114 `int (*)(uint32_t power_save_type, void* gpio_port, uint32_t gpio_num, int level)`
+ config_host_power_save_hal_impl: *const fn (u32, ?*anyopaque, u32, c_int) callconv(.c) c_int,
+ /// :115 `int (*)(uint32_t power_save_type)`
+ start_host_power_save_hal_impl: *const fn (u32) callconv(.c) c_int,
+ /// :116 `int (*)(esp_event_base_t event_base, int32_t event_id, void* event_data, size_t event_data_size, uint32_t ticks_to_wait)`
+ event_post: *const fn (EventBase, i32, ?*anyopaque, usize, u32) callconv(.c) c_int,
+};
+
+/// `struct hosted_config_t`, `esp_hosted_os_abstraction.h:119-121`.
+pub const HostedConfig = extern struct {
+ funcs: *const HostedOsiFuncs,
+};
+
+/// The three numbers the C side must agree on. Measured from C with the force-include in place;
+/// the build re-measures and compares, so this is a contract and not a comment.
+pub const layout_check = struct {
+ pub const sizeof: usize = 284;
+ pub const offset_config_gpio: usize = 148;
+ pub const offset_event_post: usize = 280;
+};
+
+comptime {
+ if (@sizeOf(usize) != 4) @compileError(
+ "this layout is rv32-specific: 71 pointers at 4 bytes each. Re-measure offsetof on any other target.",
+ );
+ assert(@sizeOf(HostedOsiFuncs) == layout_check.sizeof);
+ assert(@offsetOf(HostedOsiFuncs, "config_gpio") == layout_check.offset_config_gpio);
+ assert(@offsetOf(HostedOsiFuncs, "event_post") == layout_check.offset_event_post);
+ // Every member is one pointer, so the count is derivable and worth asserting: a field
+ // accidentally deleted or duplicated changes this even when the size happens to survive.
+ assert(std.meta.fields(HostedOsiFuncs).len == 71);
+ assert(@sizeOf(HostedOsiFuncs) == 71 * @sizeOf(usize));
+}
+
+// ============================================================================ the exported table
+
+/// The table itself. `HOSTED_CONFIG_INIT_DEFAULT` points `g_h.funcs` here
+/// (`esp_hosted_os_abstraction.h:125-127`), and `port_esp_hosted_host_os.c:938` is the definition
+/// this replaces.
+pub export const g_hosted_osi_funcs: HostedOsiFuncs = .{
+ .memcpy = hostedMemcpy,
+ .memset = hostedMemset,
+ .malloc = hostedMalloc,
+ .calloc = hostedCalloc,
+ .free = hostedFree,
+ .realloc = hostedRealloc,
+ .malloc_align = hostedMallocAlign,
+ .free_align = hostedFreeAlign,
+
+ .thread_create = hostedThreadCreate,
+ .thread_cancel = hostedThreadCancel,
+ .thread_yield = hostedThreadYield,
+
+ .msleep = hostedMsleep,
+ .usleep = hostedUsleep,
+ .sleep = hostedSleep,
+ .blocking_delay = hostedBlockingDelay,
+
+ .queue_item = hostedQueueItem,
+ .create_queue = hostedCreateQueue,
+ .dequeue_item = hostedDequeueItem,
+ .queue_msg_waiting = hostedQueueMsgWaiting,
+ .destroy_queue = hostedDestroyQueue,
+ .reset_queue = hostedResetQueue,
+
+ .unlock_mutex = hostedUnlockMutex,
+ .create_mutex = hostedCreateMutex,
+ .lock_mutex = hostedLockMutex,
+ .destroy_mutex = hostedDestroyMutex,
+
+ .post_semaphore = hostedPostSemaphore,
+ .post_semaphore_from_isr = hostedPostSemaphoreFromIsr,
+ .create_semaphore = hostedCreateSemaphore,
+ .get_semaphore = hostedGetSemaphore,
+ .destroy_semaphore = hostedDestroySemaphore,
+
+ .timer_stop = hostedTimerStop,
+ .timer_start = hostedTimerStart,
+ .get_time_ms = hostedGetTimeMs,
+
+ .create_lock_mempool = hostedCreateLockMempool,
+ .lock_mempool = hostedLockMempool,
+ .unlock_mempool = hostedUnlockMempool,
+ .destroy_lock_mempool = hostedDestroyLockMempool,
+
+ .config_gpio = hostedConfigGpio,
+ .config_gpio_as_interrupt = hostedConfigGpioAsInterrupt,
+ .teardown_gpio_interrupt = hostedTeardownGpioInterrupt,
+ .read_gpio = hostedReadGpio,
+ .write_gpio = hostedWriteGpio,
+ .pull_gpio = hostedPullGpio,
+ .hold_gpio = hostedHoldGpio,
+ .get_host_wakeup_or_reboot_reason = hostedGetWakeupReason,
+
+ .bus_init = hostedBusInit,
+ .bus_deinit = hostedBusDeinit,
+
+ .do_bus_transfer = stubDoBusTransfer,
+ .event_wifi_post = hostedEventWifiPost,
+ .printf = stubPrintf,
+ .hosted_init_hook = hostedInitHook,
+
+ .sdio_card_init = hostedSdioCardInit,
+ .sdio_card_deinit = hostedSdioCardDeinit,
+ .sdio_read_reg = hostedSdioReadReg,
+ .sdio_write_reg = hostedSdioWriteReg,
+ .sdio_read_block = hostedSdioReadBlock,
+ .sdio_write_block = hostedSdioWriteBlock,
+ .sdio_wait_slave_intr = hostedSdioWaitSlaveIntr,
+
+ .spi_hd_read_reg = stubSpiHdReadReg,
+ .spi_hd_write_reg = stubSpiHdWriteReg,
+ .spi_hd_read_dma = stubSpiHdReadDma,
+ .spi_hd_write_dma = stubSpiHdWriteDma,
+ .spi_hd_set_data_lines = stubSpiHdSetDataLines,
+ .spi_hd_send_cmd9 = stubSpiHdSendCmd9,
+
+ .uart_read = stubUartRead,
+ .uart_write = stubUartWrite,
+ .uart_flush_input = stubUartFlushInput,
+
+ .restart_host = hostedRestartHost,
+
+ .config_host_power_save_hal_impl = stubConfigHostPowerSave,
+ .start_host_power_save_hal_impl = stubStartHostPowerSave,
+ .event_post = hostedEventPost,
+};
+
+/// `extern struct hosted_config_t g_h;` (`esp_hosted_os_abstraction.h:129`). Statically
+/// initialised, because C reads `g_h.funcs->...` and nothing guarantees `install` ran first - it is
+/// the *state* behind the functions that needs installing, not the pointer to them.
+pub export var g_h: HostedConfig = .{ .funcs = &g_hosted_osi_funcs };
+
+// ============================================================================ installed state
+
+/// Board wiring and sizing. Compile-time so the static footprint is a build-time number.
+pub const Config = struct {
+ /// The C6's reset/enable pin. GPIO54 on this board (`sdkconfig:4557`,
+ /// `CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE=54`).
+ ///
+ /// It has an external pull-up, so the *released* state is the one the pull-up wins. ESP-Hosted
+ /// drives `H_RESET_VAL_ACTIVE` last (`sdio_drv.c:1651-1657`), and with
+ /// `CONFIG_ESP_HOSTED_RESET_GPIO_ACTIVE_LOW` unset - which is how the working IDF build on this
+ /// board was configured - `H_RESET_VAL_ACTIVE` is `H_GPIO_HIGH`
+ /// (`port_esp_hosted_host_config.h:445-451`). So the sequence is high, low, high: a reset pulse
+ /// that ends released. Invert that and the radio stays in reset for ever.
+ reset_pin: u8 = 54,
+
+ /// CLIC external line for the GPIO interrupt aggregate (`hal.intr.Source.gpio_intr0`).
+ gpio_clic_line: u5 = 20,
+ /// CLIC external line for the SDMMC host, which is where the C6's D1 slave interrupt arrives.
+ sdio_clic_line: u5 = 21,
+
+ /// Software timer slots. ESP-Hosted arms at most three at once: the slave-unresponsive timer
+ /// (`transport_drv.c:188`), a per-request asynchronous RPC timeout (`rpc_core.c:215`), and the
+ /// power-save timer. Four leaves one spare and costs 96 bytes.
+ timer_slots: usize = 4,
+
+ /// Pads whose interrupt can be registered at once. The SDIO transport registers none; SPI
+ /// registers two. Four is generous and costs 48 bytes.
+ gpio_isr_slots: usize = 4,
+
+ /// Wait for the C6's D1 slave interrupt through the CLIC, or poll for it.
+ ///
+ /// `true`. The reason it was `false` is worth keeping written down, because it was a
+ /// misdiagnosis rather than a hardware limit.
+ ///
+ /// Every attempt printed `MARK PORT_SDIO_LAPSE ... intmask=0x00000000`, and that was read as
+ /// "the unmask does not stick". It never said that: the print happens *after*
+ /// `disarmSdioLine()`, which had just written that zero on purpose, and the other witness -
+ /// `hal.sdmmc.interruptDiagnostics` - runs on the application task, which is never inside an
+ /// arming window. `hal.sdmmc.armSlaveInterrupt` now reads INTMASK back inside the same masked
+ /// region as the store, so the claim is finally testable: `stuck=` on `MARK PORT_SDIO_ARM`.
+ ///
+ /// What was really missing is `takeInterruptControl`. Nothing in this build had ever called
+ /// `hal.intr.init()` - `examples/intrcheck.zig` and `examples/portcheck.zig` do,
+ /// `examples/http.zig` and `examples/radio.zig` do not, and nothing under `src/` did either -
+ /// so mtvec still belonged to the bootloader, the threshold was never opened, and mstatus.MIE
+ /// was never this image's decision. A CLIC line enabled in that state either cannot be
+ /// delivered at all, which is a LAPSE every window for ever, or is delivered *outside this
+ /// image*, which is the "board goes silent right after Open data path at slave" that was
+ /// blamed on a storm.
+ ///
+ /// Not verified on hardware by the author of this change. Two nets remain under it: the
+ /// bounded re-look (`sdio_relook_ms`) carries the transport through any window the interrupt
+ /// misses, and `sdio_foreign_limit` consecutive unexplained handler entries abandon the line
+ /// for `sdioPoll` permanently. Set this to `false` to isolate a regression against the proven
+ /// polling path; nothing else has to change, and with it false the CLIC is not touched at all.
+ sdio_use_interrupt: bool = true,
+};
+
+pub const config: Config = .{};
+
+const State = struct {
+ io: Io = undefined,
+ /// The allocator handed to `install`. Used directly for OS-object handles, and wrapped by
+ /// `cheap` for everything C allocates.
+ gpa: Allocator = undefined,
+ cheap: hheap.CHeap = undefined,
+ timers: os.TimerService(config.timer_slots) = .{},
+ installed: bool = false,
+
+ /// The bus context `_h_bus_init` hands to C and C hands back to every `_h_sdio_*` call. Its
+ /// *identity* is all that matters - ESP-IDF returns `&context`, a file-static - so this is a
+ /// single static object and a null `ctx` from C is a real error rather than a second bus.
+ bus: BusContext = .{},
+
+ /// Called with every event ESP-Hosted posts. Association and DHCP-relevant events arrive here.
+ on_event: ?*const fn (Event) void = null,
+
+ /// Deferred wake word for the SDIO slave interrupt. The ISR bumps it and wakes; the waiter
+ /// futex-waits on it.
+ sdio_intr_epoch: std.atomic.Value(u32) = .init(0),
+
+ /// RINTSTS and IDSTS as `sdioDispatch` saw them at entry. Both are sticky, so reading them
+ /// after the handler has disarmed loses nothing. INTMASK is *not* sticky and is deliberately
+ /// absent here: the disarm has just rewritten it, so a handler-entry read of it could only ever
+ /// return the disarmed value. What the mask really was is `sdio_armed_intmask`.
+ sdio_intr_rintsts: std.atomic.Value(u32) = .init(0),
+ sdio_intr_idsts: std.atomic.Value(u32) = .init(0),
+
+ /// INTMASK and MINTSTS as `hal.sdmmc.armSlaveInterrupt` read them back, inside the same masked
+ /// region as the store that armed them. Written and read only by the waiting task, so plain
+ /// words rather than atomics.
+ sdio_armed_intmask: u32 = 0,
+ sdio_armed_mintsts: u32 = 0,
+
+ /// Consecutive handler entries whose cause was not the card interrupt. Reset by any real one.
+ /// At `sdio_foreign_limit` the wait stops using the interrupt at all.
+ sdio_intr_foreign: u32 = 0,
+
+ /// Arming windows that lapsed with no handler entry. **At idle this is the normal state and
+ /// says nothing is wrong**: the C6 has nothing to report, so no interrupt arrives inside
+ /// `sdio_relook_ms`, the re-look finds nothing either, and the wait goes round again. It is
+ /// counted and printed because a *rising* count with frames flowing is how the re-look
+ /// carrying the transport announces itself.
+ sdio_intr_lapses: u32 = 0,
+
+ /// Consecutive lapsed windows in which the re-look then found the card *already calling* -
+ /// the pad low or the latch set. That is the failure that matters, and it is the only reading
+ /// that separates "the interrupt is not being delivered" from "the card is quiet": an idle
+ /// card lapses for free, a calling card whose interrupt did not arrive costs a real frame up
+ /// to `sdio_relook_ms` of latency.
+ ///
+ /// Reset by any wake the handler really delivered. At `sdio_missed_limit` the wait gives the
+ /// line up for `sdioPoll` permanently, which is what keeps the interrupt path from being
+ /// strictly worse than the 1 ms poll it replaces.
+ sdio_intr_missed: u32 = 0,
+
+ /// MINTSTS and RINTSTS as `sdioDispatch` read them, *before* it disarmed. MINTSTS is
+ /// `RINTSTS & INTMASK` and the disarm zeroes it, so this is the only place its value at the
+ /// moment of delivery survives - and it is the direct answer to "does MINTSTS ever show this
+ /// slot's bit".
+ sdio_intr_mintsts: std.atomic.Value(u32) = .init(0),
+
+ /// Remaining diagnostic lines, one budget per failure mode. See `sdioMark`.
+ sdio_foreign_marks: u32 = 0,
+ sdio_lapse_marks: u32 = 0,
+ sdio_missed_marks: u32 = 0,
+ sdio_arm_marks: u32 = 0,
+ sdio_wake_marks: u32 = 0,
+
+ /// Arming windows completed, for the periodic tally. Every budgeted MARK above eventually goes
+ /// quiet; this one does not, because "is the interrupt or the re-look carrying the transport"
+ /// is a question that stays interesting for the whole run.
+ sdio_windows: u32 = 0,
+
+ /// GPIO ISR registrations, indexed arbitrarily.
+ gpio_isrs: [config.gpio_isr_slots]GpioIsr = @splat(.{}),
+
+ /// `_h_sleep` calls. In the file set build.zig compiles this counts exactly one thing: the
+ /// two `if (!is_rpc_lib_ready()) _h_sleep(1)` loops at the head of `rpc_rx_thread` and
+ /// `rpc_tx_thread` (rpc_core.c:482-485, :543-547). The tree's only other `_h_sleep` callers
+ /// are transport_drv.c:693, which is followed by `assert(0!=0)`, and stats.c:115 in
+ /// `raw_tp_tx_task`, which is never created with TEST_RAW_TP off.
+ ///
+ /// So a count that keeps *growing* while a synchronous RPC request is outstanding means the
+ /// RPC lib state is not READY and the request will never be transmitted - the failure that
+ /// otherwise looks exactly like a coprocessor that does not answer. Two per second while
+ /// stuck, and it costs one add.
+ hosted_sleep_calls: u32 = 0,
+
+ /// Loud-stub call count. Nonzero after a run means a path nobody implemented was taken.
+ stub_calls: u32 = 0,
+};
+
+const GpioIsr = struct {
+ pin: u8 = 0xFF,
+ handler: ?IsrHandler = null,
+ arg: ?*anyopaque = null,
+};
+
+const BusContext = struct {
+ /// `hosted_sdio_init` creates this and every `SDIO_LOCK` takes it
+ /// (`port_esp_hosted_host_sdio.c:36-42, 395`).
+ lock: os.Mutex = .{},
+ up: bool = false,
+};
+
+var state: State = .{};
+
+/// An event ESP-Hosted posted. `base` distinguishes `WIFI_EVENT` (via `_h_event_wifi_post`) from
+/// `ESP_HOSTED_EVENT` and anything else (via `_h_event_post`).
+pub const Event = struct {
+ pub const Base = union(enum) {
+ wifi,
+ /// The `esp_event_base_t` string C passed, which is a pointer to a string literal owned by
+ /// the C side and valid for the lifetime of the program.
+ named: EventBase,
+ };
+ base: Base,
+ id: i32,
+ /// Borrowed for the duration of the callback only. ESP-IDF's `esp_event_post` copies;
+ /// this does not, so a handler that needs the data past its return must copy it.
+ data: ?[]const u8,
+};
+
+/// Bring the table's state up. Idempotent.
+///
+/// After this returns, C may call anything in `g_h.funcs`. Note what it does *not* do: it does not
+/// start a scheduler and it does not touch the radio. ESP-Hosted's own `esp_hosted_init` does that,
+/// and the tasks it spawns through `_h_thread_create` first execute when the calling context next
+/// blocks - `io.async` assigns a slot and marks it ready, it does not preempt. A caller that
+/// installs, initialises ESP-Hosted and then never blocks will see nothing happen.
+pub fn install(io: Io, gpa: Allocator) void {
+ state.io = io;
+ state.gpa = gpa;
+ state.cheap = .{ .gpa = gpa };
+ state.installed = true;
+ // The timer service owns one task; start it eagerly so `_h_timer_start` cannot fail for want
+ // of a scheduler.
+ if (!state.timers.start(io, gpa)) note("MARK PORT_TIMER_SERVICE_FAIL\r\n", .{});
+}
+
+/// Register the application's event sink. Association, disconnection and the slave's own lifecycle
+/// events arrive here; this is not a reimplementation of `esp_event`, it is one callback.
+pub fn setEventHandler(handler: ?*const fn (Event) void) void {
+ state.on_event = handler;
+}
+
+/// Diagnostics for a hardware self-test: heap use, whether any loud stub was reached, and whether
+/// ESP-Hosted's RPC threads are stuck in their not-ready loop. See `State.hosted_sleep_calls`.
+pub fn stats() struct {
+ bytes_live: usize,
+ bytes_reserved: usize,
+ peak_reserved: usize,
+ blocks_live: usize,
+ alloc_failures: usize,
+ stub_calls: u32,
+ hosted_sleep_calls: u32,
+} {
+ return .{
+ .bytes_live = state.cheap.bytes_live,
+ .bytes_reserved = state.cheap.bytes_reserved,
+ .peak_reserved = state.cheap.peak_reserved,
+ .blocks_live = state.cheap.blocks_live,
+ .alloc_failures = state.cheap.failures,
+ .stub_calls = state.stub_calls,
+ .hosted_sleep_calls = state.hosted_sleep_calls,
+ };
+}
+
+/// The SDIO card-interrupt path's counters, for a heartbeat that wants to say whether the radio is
+/// being woken or polled. Every field is a running total, none is reset by anything here.
+///
+/// `epoch` is handler entries. `foreign` is *consecutive* entries whose cause was not the card
+/// interrupt - at `sdio_foreign_limit` the wait abandons the interrupt for `sdioPoll`, so a
+/// non-zero `foreign` with a growing `epoch` means the line is being taken for the wrong reason.
+/// `lapses` is arming windows that produced no entry at all; at idle that is the resting state and
+/// costs nothing. `missed` is the subset of those whose re-look then found the card already
+/// calling, which is the one that matters - at `sdio_missed_limit` the wait abandons the interrupt
+/// too. `rintsts`/`idsts` are what the last handler entry saw; `armed_intmask` is what INTMASK read
+/// back at the last arm, which is the only reading of that register that means anything.
+pub fn sdioStats() struct {
+ epoch: u32,
+ foreign: u32,
+ lapses: u32,
+ missed: u32,
+ rintsts: u32,
+ idsts: u32,
+ armed_intmask: u32,
+} {
+ return .{
+ .epoch = state.sdio_intr_epoch.load(.acquire),
+ .foreign = state.sdio_intr_foreign,
+ .lapses = state.sdio_intr_lapses,
+ .missed = state.sdio_intr_missed,
+ .rintsts = state.sdio_intr_rintsts.load(.acquire),
+ .idsts = state.sdio_intr_idsts.load(.acquire),
+ .armed_intmask = state.sdio_armed_intmask,
+ };
+}
+
+inline fn currentIo() Io {
+ assert(state.installed);
+ return state.io;
+}
+
+// ============================================================================ 1. memory
+
+fn hostedMemcpy(dest: ?*anyopaque, src: ?*const anyopaque, size: u32) callconv(.c) ?*anyopaque {
+ // ESP-IDF asserts on a null pointer with a nonzero size (port_esp_hosted_host_os.c:67-76); the
+ // same condition, as a Zig assertion.
+ if (size == 0) return dest;
+ const d: [*]u8 = @ptrCast(dest.?);
+ const s: [*]const u8 = @ptrCast(src.?);
+ @memcpy(d[0..size], s[0..size]);
+ return dest;
+}
+
+fn hostedMemset(buf: ?*anyopaque, val: c_int, len: usize) callconv(.c) ?*anyopaque {
+ if (len == 0) return buf;
+ const b: [*]u8 = @ptrCast(buf.?);
+ @memset(b[0..len], @truncate(@as(c_uint, @bitCast(val))));
+ return buf;
+}
+
+fn hostedMalloc(size: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ return @ptrCast(state.cheap.malloc(size));
+}
+
+fn hostedCalloc(blk_no: usize, size: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ return @ptrCast(state.cheap.calloc(blk_no, size));
+}
+
+fn hostedFree(ptr: ?*anyopaque) callconv(.c) void {
+ assert(state.installed);
+ state.cheap.free(@ptrCast(ptr));
+}
+
+fn hostedRealloc(mem: ?*anyopaque, newsize: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ return @ptrCast(state.cheap.realloc(@ptrCast(mem), newsize));
+}
+
+/// `_h_malloc_align(size, align)`. ESP-IDF routes this to `heap_caps_aligned_alloc` with
+/// DMA-capable caps (`port_esp_hosted_host_os.c:128-143`) because IDF's SDMMC driver DMAs straight
+/// out of the caller's buffer.
+///
+/// Ours does not: `hal.sdmmc` bounces every CMD53 through its own 64-byte-aligned buffer reached
+/// through the non-cacheable alias, and memcpy's to and from the caller's slice. So the alignment
+/// is honoured - it costs 64 bytes a buffer and callers may reasonably rely on it - but nothing
+/// downstream needs it, and `_h_malloc` would do.
+fn hostedMallocAlign(size: usize, alignment: usize) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ // ESP-Hosted only ever asks for 4, 32 or 64 (HOSTED_MEM_ALIGNMENT_*,
+ // port_esp_hosted_host_os.h:93-95). A non-power-of-two would silently corrupt the header
+ // arithmetic, so refuse it.
+ if (alignment == 0 or !std.math.isPowerOfTwo(alignment) or alignment > hheap.CHeap.max_alignment) {
+ note("MARK PORT_BAD_ALIGN %u\r\n", .{@as(u32, @intCast(alignment))});
+ return null;
+ }
+ return @ptrCast(state.cheap.mallocAligned(size, alignment));
+}
+
+/// One header format for both `_h_free` and `_h_free_align`, because ESP-IDF has one too: its
+/// `hosted_free_align` is a plain `free` (`port_esp_hosted_host_os.c:145-148`), and mixing the two
+/// is legal in the tree - `sdio_drv.c:353` frees with `_h_free_align` a buffer that
+/// `transport_util.c:14` allocated with `_h_malloc_align`, while `HOSTED_FREE` uses `_h_free`
+/// throughout.
+fn hostedFreeAlign(ptr: ?*anyopaque) callconv(.c) void {
+ assert(state.installed);
+ state.cheap.free(@ptrCast(ptr));
+}
+
+// ============================================================================ 2. sync
+
+fn hostedCreateMutex() callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ const m = state.gpa.create(os.Mutex) catch return null;
+ m.* = .{};
+ return @ptrCast(m);
+}
+
+fn hostedLockMutex(handle: ?*anyopaque, timeout_ms: c_int) callconv(.c) c_int {
+ const m: *os.Mutex = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return m.lock(currentIo(), .fromMillis(timeout_ms));
+}
+
+fn hostedUnlockMutex(handle: ?*anyopaque) callconv(.c) c_int {
+ const m: *os.Mutex = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return m.unlock(currentIo());
+}
+
+fn hostedDestroyMutex(handle: ?*anyopaque) callconv(.c) c_int {
+ const m: *os.Mutex = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ state.gpa.destroy(m);
+ return ret.ok;
+}
+
+fn hostedCreateSemaphore(max_count: c_int) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ const s = state.gpa.create(os.Semaphore) catch return null;
+ s.* = .init(if (max_count > 0) @intCast(max_count) else 1);
+ return @ptrCast(s);
+}
+
+fn hostedPostSemaphore(handle: ?*anyopaque) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return s.post(currentIo());
+}
+
+/// See `hosted_os.Semaphore.postFromIsr` for what "from ISR" can and cannot mean here.
+fn hostedPostSemaphoreFromIsr(handle: ?*anyopaque) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return s.postFromIsr(state.io);
+}
+
+fn hostedGetSemaphore(handle: ?*anyopaque, timeout_ms: c_int) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return s.wait(currentIo(), .fromMillis(timeout_ms));
+}
+
+fn hostedDestroySemaphore(handle: ?*anyopaque) callconv(.c) c_int {
+ const s: *os.Semaphore = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ state.gpa.destroy(s);
+ return ret.ok;
+}
+
+fn hostedCreateQueue(qnum_elem: u32, qitem_size: u32) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ if (qnum_elem == 0 or qitem_size == 0) return null;
+ return @ptrCast(os.Queue.create(state.gpa, qnum_elem, qitem_size));
+}
+
+fn hostedQueueItem(handle: ?*anyopaque, item: ?*const anyopaque, timeout: c_int) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ const p: [*]const u8 = @ptrCast(item orelse return ret.invalid);
+ // `_h_queue_item`'s timeout reaches xQueueSendToBack unconverted, so its units are ticks; every
+ // caller passes HOSTED_BLOCK_MAX or 0, both of which mean the same thing in either dialect.
+ return q.send(currentIo(), p, .fromMillis(timeout));
+}
+
+fn hostedDequeueItem(handle: ?*anyopaque, item: ?*anyopaque, timeout: c_int) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ const p: [*]u8 = @ptrCast(item orelse return ret.invalid);
+ // Seconds, not milliseconds, on the positive branch. See `hosted_os.Wait.fromQueueTimeout`.
+ return q.receive(currentIo(), p, .fromQueueTimeout(timeout));
+}
+
+fn hostedQueueMsgWaiting(handle: ?*anyopaque) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return q.waiting(currentIo());
+}
+
+fn hostedDestroyQueue(handle: ?*anyopaque) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ q.destroy(currentIo(), state.gpa);
+ return ret.ok;
+}
+
+fn hostedResetQueue(handle: ?*anyopaque) callconv(.c) c_int {
+ const q: *os.Queue = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return q.reset(currentIo());
+}
+
+/// The mempool lock. `H_USE_MEMPOOL` is 1 in this board's configuration, so these four must not be
+/// null even though the version of `common/mempool/mempool.c` in this tree does not call them.
+///
+/// ESP-IDF uses a `portMUX_TYPE` spinlock and `portENTER_CRITICAL`
+/// (`port_esp_hosted_host_os.c:602-643`), which on a multi-core preemptive kernel means "take the
+/// spinlock and disable interrupts". On one core with a cooperative scheduler the spinlock half is
+/// vacuous - there is no other core to contend with - and the interrupt half is the whole content.
+/// So the handle is `hal.intr`'s nesting mask guard, and the critical section is exactly as long as
+/// interrupts are off.
+const MempoolLock = struct {
+ guard: hal.clkrst.Guard = undefined,
+ held: bool = false,
+};
+
+fn hostedCreateLockMempool() callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ const l = state.gpa.create(MempoolLock) catch return null;
+ l.* = .{};
+ return @ptrCast(l);
+}
+
+fn hostedLockMempool(handle: ?*anyopaque) callconv(.c) void {
+ const l: *MempoolLock = @ptrCast(@alignCast(handle orelse return));
+ l.guard = hal.intr.mask();
+ l.held = true;
+}
+
+fn hostedUnlockMempool(handle: ?*anyopaque) callconv(.c) void {
+ const l: *MempoolLock = @ptrCast(@alignCast(handle orelse return));
+ if (!l.held) return;
+ l.held = false;
+ l.guard.release();
+}
+
+fn hostedDestroyLockMempool(handle: ?*anyopaque) callconv(.c) void {
+ const l: *MempoolLock = @ptrCast(@alignCast(handle orelse return));
+ state.gpa.destroy(l);
+}
+
+// ============================================================================ 3. threads
+
+/// ESP-Hosted spawns **seven** tasks on the SDIO transport, and their requested stacks are the
+/// single largest memory claim in the whole port:
+///
+/// sdio_rx_buf RX_BUF_TASK_STACK_SIZE sdio_drv.c:1542 (= CONFIG_ESP_HOSTED_DFLT_TASK_STACK)
+/// sdio_read DFLT_TASK_STACK_SIZE sdio_drv.c:1545
+/// sdio_process_rx DFLT_TASK_STACK_SIZE sdio_drv.c:1548
+/// sdio_write DFLT_TASK_STACK_SIZE sdio_drv.c:1551
+/// rpc_rx RPC_TASK_STACK_SIZE rpc_core.c:578
+/// rpc_tx RPC_TASK_STACK_SIZE rpc_core.c:580
+/// rpc_supp_cb RPC_TASK_STACK_SIZE rpc_wrap.c:2398
+///
+/// `DFLT_TASK_STACK_SIZE` and `RPC_TASK_STACK_SIZE` are both `5*1024`
+/// (`port_esp_hosted_host_os.h:64-67`), and ESP-IDF's `xTaskCreate` takes bytes, so the ask is
+/// 35 KB. Plus this port's timer service task, plus the main context, that is nine slots.
+///
+/// The requested size is **ignored**, and that is not laziness: `std.Io.async` has no stack-size
+/// parameter, and the runtime takes the first free slot from a pool whose slots are all declared at
+/// one size. The number to declare is therefore the worst case over all seven, which is what the
+/// caller of `install` decides when it builds its `Runtime`. 5 KB is FreeRTOS's number for tasks
+/// that call `printf`; these bodies do not, and the honest way to size the pool is a painted-stack
+/// watermark on the die, not this constant.
+pub const thread_count = 7;
+pub const requested_stack_bytes = 5 * 1024;
+
+fn hostedThreadCreate(
+ tname: [*:0]const u8,
+ tprio: u32,
+ tstack_size: u32,
+ start_routine: StartRoutine,
+ sr_arg: ?*anyopaque,
+) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ // Priority is meaningless on a cooperative scheduler: a task runs until it blocks, and
+ // ESP-Hosted gives all seven the same priority anyway (RPC_TASK_PRIO and DFLT_TASK_PRIO are
+ // both 23, port_esp_hosted_host_os.h:65-68).
+ _ = tprio;
+ _ = tstack_size;
+ return @ptrCast(os.Thread.create(currentIo(), state.gpa, tname, start_routine, sr_arg));
+}
+
+fn hostedThreadCancel(handle: ?*anyopaque) callconv(.c) c_int {
+ const t: *os.Thread = @ptrCast(@alignCast(handle orelse return ret.invalid));
+ return t.cancel(currentIo(), state.gpa);
+}
+
+fn hostedThreadYield() callconv(.c) void {
+ // A zero-duration sleep is the portable yield, and on this runtime it is a documented one
+ // trip round the run queue rather than a no-op. Cancelation is swallowed because the C caller
+ // (`spi_hd_drv.c:568`, the only one in the tree) has nowhere to report it.
+ currentIo().sleep(.zero, os.clock) catch {};
+}
+
+// ============================================================================ 4. time
+
+fn hostedMsleep(mseconds: c_uint) callconv(.c) c_uint {
+ currentIo().sleep(.fromMilliseconds(mseconds), os.clock) catch {};
+ return 0;
+}
+
+fn hostedUsleep(useconds: c_uint) callconv(.c) c_uint {
+ currentIo().sleep(.fromMicroseconds(useconds), os.clock) catch {};
+ return 0;
+}
+
+/// Counted, because in this build every call is one turn of an ESP-Hosted RPC thread's not-ready
+/// spin. See `State.hosted_sleep_calls`.
+fn hostedSleep(seconds: c_uint) callconv(.c) c_uint {
+ state.hosted_sleep_calls += 1;
+ return hostedMsleep(seconds *| 1000);
+}
+
+/// `_h_blocking_delay` is documented in ESP-Hosted as a "non sleepable delay - BLOCKING dead wait"
+/// and implemented as `for (idx = 0; idx < 100*number; idx++)` on a `volatile`
+/// (`port_esp_hosted_host_os.c:261-267`). That is a loop count, not a duration, and its wall-clock
+/// meaning depends on the compiler and the CPU clock.
+///
+/// It is reproduced as a real busy-wait rather than a sleep, because a caller reaching for this
+/// specifically wants not to yield - and reproduced against `hal.systimer` rather than a loop
+/// count, so the delay is at least defined. ESP-IDF's version at 360 MHz takes roughly 0.3 us per
+/// unit; at this board's measured 90 MHz it would be about 1.1 us, and 1 us is the round number in
+/// range. **Nothing in the tree calls this**, verified by grep, so no behaviour depends on the
+/// choice.
+///
+/// On a cooperative scheduler this starves every other task for the duration. That is inherent to
+/// what the entry means, not a defect of this implementation.
+fn hostedBlockingDelay(number: c_uint) callconv(.c) c_uint {
+ hal.systimer.delayMicros(number);
+ return 0;
+}
+
+fn hostedGetTimeMs() callconv(.c) u64 {
+ return os.nowMs(currentIo());
+}
+
+// ============================================================================ timers
+
+/// A timer handle as C sees it. ESP-IDF hands back a heap pointer
+/// (`port_esp_hosted_host_os.c:697`); this hands back a pointer to one, so `_h_timer_stop` can find
+/// the slot and free the handle exactly as ESP-IDF's does.
+const TimerHandle = struct {
+ slot: usize,
+};
+
+fn hostedTimerStart(
+ name: [*:0]const u8,
+ duration_ms: c_int,
+ kind: c_int,
+ handler: TimerHandler,
+ arg: ?*anyopaque,
+) callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ if (duration_ms < 0) return null;
+ const k: os.TimerKind = switch (kind) {
+ 0 => .oneshot,
+ 1 => .periodic,
+ else => {
+ // ESP-IDF logs "Unsupported timer type" and returns NULL (:720-725).
+ note("MARK PORT_TIMER_BAD_TYPE %s %d\r\n", .{ name, kind });
+ return null;
+ },
+ };
+ const slot = state.timers.arm(currentIo(), @intCast(duration_ms), k, handler, arg) orelse {
+ note("MARK PORT_TIMER_SLOTS_FULL %s\r\n", .{name});
+ return null;
+ };
+ const h = state.gpa.create(TimerHandle) catch {
+ _ = state.timers.disarm(currentIo(), slot);
+ return null;
+ };
+ h.* = .{ .slot = slot };
+ return @ptrCast(h);
+}
+
+fn hostedTimerStop(handle: ?*anyopaque) callconv(.c) c_int {
+ const h: *TimerHandle = @ptrCast(@alignCast(handle orelse return ret.fail));
+ const r = state.timers.disarm(currentIo(), h.slot);
+ state.gpa.destroy(h);
+ return r;
+}
+
+// ============================================================================ 5. GPIO
+
+/// `H_GPIO_MODE_DEF_*`, `port_esp_hosted_host_os.h:71-73`: bit 0 input, bit 1 output, bit 2
+/// open-drain.
+const gpio_mode_input: u32 = 1 << 0;
+const gpio_mode_output: u32 = 1 << 1;
+const gpio_mode_open_drain: u32 = 1 << 2;
+
+/// `H_GPIO_PULL_UP` is 1 and `H_GPIO_PULL_DOWN` is 0 (`port_esp_hosted_host_os.h:83-84`) - note
+/// that this is a *direction* selector and not a boolean, and the separate `enable` argument says
+/// whether to turn that resistor on or off.
+const gpio_pull_up: u32 = 1;
+
+/// `_h_config_gpio`. The `gpio_port` argument is always `H_GPIO_PORT_DEFAULT` / NULL on this chip
+/// (`port_esp_hosted_host_config.h:435`); ESP-IDF ignores it too.
+///
+/// ESP-IDF's version goes through `gpio_config`, which also clears both pulls
+/// (`port_esp_hosted_host_os.c:746-758`). Reproduced, because the reset pin depends on it: GPIO54
+/// has an external pull-up and an internal pull-down fighting it would be a weak, marginal high.
+fn hostedConfigGpio(gpio_port: ?*anyopaque, gpio_num: u32, mode: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+
+ hal.gpio.setFunction(pin, .gpio);
+ hal.gpio.setPull(pin, .none);
+ hal.gpio.setOpenDrain(pin, mode & gpio_mode_open_drain != 0);
+ hal.gpio.setInputEnable(pin, mode & gpio_mode_input != 0);
+ if (mode & gpio_mode_output != 0) {
+ // Point the matrix at the GPIO peripheral before enabling the driver, so the pad never
+ // spends an instant driven by whatever signal the matrix happened to hold.
+ hal.gpio.matrixOut(pin, hal.gpio.matrix_gpio_signal);
+ hal.gpio.outputEnable(pin);
+ } else {
+ hal.gpio.outputDisable(pin);
+ }
+ return ret.ok;
+}
+
+fn hostedReadGpio(gpio_port: ?*anyopaque, gpio_num: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ return hal.gpio.getLevel(@intCast(gpio_num));
+}
+
+fn hostedWriteGpio(gpio_port: ?*anyopaque, gpio_num: u32, value: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ hal.gpio.setLevel(@intCast(gpio_num), if (value != 0) 1 else 0);
+ return ret.ok;
+}
+
+/// `_h_pull_gpio(port, pin, pull_value, enable)`.
+///
+/// The four-argument shape does not map onto one register field: the P4 has one pull-up bit and one
+/// pull-down bit, and `hal.gpio.setPull` writes both in one store precisely so a pad can never end
+/// up with two resistors fighting. Disabling one pull therefore means "leave the *other* alone",
+/// which is read back rather than assumed.
+fn hostedPullGpio(gpio_port: ?*anyopaque, gpio_num: u32, pull_value: u32, enable: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+ const up = pull_value == gpio_pull_up;
+ if (enable != 0) {
+ hal.gpio.setPull(pin, if (up) .up else .down);
+ } else {
+ // gpio_pullup_dis / gpio_pulldown_dis clear one bit only. If the other pull is not set
+ // either, the pad ends up floating, which is what ESP-IDF leaves behind too.
+ const current = hal.gpio.getPull(pin);
+ const target: hal.gpio.Pull = if (up)
+ (if (current == .down) .down else .none)
+ else
+ (if (current == .up) .up else .none);
+ hal.gpio.setPull(pin, target);
+ }
+ return ret.ok;
+}
+
+/// `_h_hold_gpio`. ESP-IDF calls `gpio_hold_en`, which latches a pad's output through a sleep or a
+/// domain power-down so the slave is not reset by the host napping.
+///
+/// This image never sleeps and never powers a domain down: `_h_config_host_power_save_hal_impl` and
+/// `_h_start_host_power_save_hal_impl` are both loud stubs, and the only callers of this entry are
+/// in `power_save_drv.c:210,230`, which those stubs make unreachable. Holding a pad against a sleep
+/// that cannot happen is not a no-op worth pretending to - the P4's hold bit lives in
+/// `LP_AON`/`HP_SYS` registers the HAL does not model, and writing them blind is how a pad gets
+/// stuck. So this reports failure loudly instead.
+fn hostedHoldGpio(gpio_port: ?*anyopaque, gpio_num: u32, hold_value: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ state.stub_calls += 1;
+ note("MARK PORT_STUB _h_hold_gpio pin=%u hold=%u (no sleep support; nothing should reach this)\r\n", .{ gpio_num, hold_value });
+ return ret.fail;
+}
+
+/// `H_GPIO_INTR_*`, `port_esp_hosted_host_config.h:56-62`. The values coincide exactly with the
+/// P4's `GPIO_PINn_INT_TYPE` encoding (`gpio_reg.h:377-381`), which is not a coincidence: the
+/// enum was written from it.
+fn intrTypeFromHosted(intr_type: u32) ?hal.gpio.IntrType {
+ return switch (intr_type) {
+ 0 => .disable,
+ 1 => .posedge,
+ 2 => .negedge,
+ 3 => .anyedge,
+ 4 => .low_level,
+ 5 => .high_level,
+ else => null,
+ };
+}
+
+/// `_h_config_gpio_as_interrupt`.
+///
+/// ESP-IDF's version (`port_esp_hosted_host_os.c:760-797`) configures the pad as an input with a
+/// pull that opposes the edge being detected, installs IDF's shared GPIO ISR service, adds a
+/// per-pin handler, then sets the trigger type and enables. Same five steps here, with `hal.gpio`
+/// and `hal.intr` in place of the driver:
+///
+/// 1. pad as input, pull opposing the edge - a floating pad on an edge-triggered interrupt is a
+/// free-running interrupt source.
+/// 2. record (pin, handler, arg) in `state.gpio_isrs`.
+/// 3. arm the pad on GPIO interrupt line 0, which is the line ESP-IDF uses.
+/// 4. route `gpio_intr0` to a CLIC line and give it `gpioDispatch`, once.
+/// 5. enable.
+///
+/// The CLIC trigger is **level**, not edge: the GPIO peripheral holds its line asserted while any
+/// status bit is set, and the handler clears the status. An edge-triggered CLIC line here would
+/// lose a second pad's event that arrived while the first was being serviced.
+///
+/// Nothing in the SDIO transport calls this. Its callers are `spi_drv.c:625,628`,
+/// `spi_hd_drv.c:548` and `power_save_drv.c:68`. It is implemented rather than stubbed because it
+/// costs little and because a host-wakeup pin is the obvious next use.
+fn hostedConfigGpioAsInterrupt(
+ gpio_port: ?*anyopaque,
+ gpio_num: u32,
+ intr_type: u32,
+ handler: IsrHandler,
+ arg: ?*anyopaque,
+) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+ const t = intrTypeFromHosted(intr_type) orelse {
+ note("MARK PORT_GPIO_BAD_INTR_TYPE %u\r\n", .{intr_type});
+ return ret.invalid;
+ };
+
+ // ESP-IDF pulls up for a falling edge and down for anything else (:771-775).
+ hal.gpio.configureInput(pin, .{ .pull = if (t == .negedge) .up else .down });
+
+ const slot = blk: {
+ for (&state.gpio_isrs) |*s| if (s.pin == pin) break :blk s;
+ for (&state.gpio_isrs) |*s| if (s.handler == null) break :blk s;
+ note("MARK PORT_GPIO_ISR_SLOTS_FULL pin=%u\r\n", .{gpio_num});
+ return ret.fail;
+ };
+ slot.* = .{ .pin = pin, .handler = handler, .arg = arg };
+
+ if (!gpio_line_attached) {
+ gpio_line_attached = true;
+ // mtvec, MTVT, the threshold and MIE, before a line that `configureLine` enables as its
+ // last act can be delivered anywhere. See `takeInterruptControl`.
+ takeInterruptControl();
+ hal.intr.routeId(@intFromEnum(hal.intr.Source.gpio_intr0), config.gpio_clic_line);
+ hal.intr.configureLine(config.gpio_clic_line, .{
+ .handler = gpioDispatch,
+ .trigger = .level,
+ });
+ }
+ hal.gpio.setInterrupt(pin, t, .line0);
+ return ret.ok;
+}
+
+fn hostedTeardownGpioInterrupt(gpio_port: ?*anyopaque, gpio_num: u32) callconv(.c) c_int {
+ _ = gpio_port;
+ if (gpio_num > hal.gpio.max_pin) return ret.invalid;
+ const pin: u8 = @intCast(gpio_num);
+ hal.gpio.disableInterrupt(pin);
+ hal.gpio.clearInterrupt(pin);
+ for (&state.gpio_isrs) |*s| {
+ if (s.pin == pin) s.* = .{};
+ }
+ return ret.ok;
+}
+
+var gpio_line_attached: bool = false;
+
+/// The one CLIC handler behind every registered pad. Reads the whole pending mask once, clears it
+/// once, then dispatches - so an event on a second pad arriving mid-dispatch is caught by the next
+/// interrupt rather than lost.
+///
+/// The status is cleared *before* the handlers run. For an edge-triggered pad that is the correct
+/// order: clearing after the handler would drop an edge that arrived during it.
+fn gpioDispatch(line: u5) void {
+ _ = line;
+ const pending = hal.gpio.pendingMask(.line0);
+ hal.gpio.clearInterrupts(pending.low, pending.high);
+ for (&state.gpio_isrs) |*s| {
+ const h = s.handler orelse continue;
+ const bit: u32 = @as(u32, 1) << @intCast(if (s.pin < 32) s.pin else s.pin - 32);
+ const hit = if (s.pin < 32) pending.low & bit else pending.high & bit;
+ if (hit != 0) h(s.arg);
+ }
+}
+
+// ============================================================================ 6. SDIO
+
+/// `ESP_ADDRESS_MASK`, `host/drivers/transport/sdio/sdio_reg.h:87`. Slave scratch registers live in
+/// the low 10 bits of function 1's address space, and ESP-Hosted masks every register address with
+/// this before the transfer (`port_esp_hosted_host_sdio.c:500,523`). Block transfers are *not*
+/// masked, which is why `ESP_SLAVE_CMD53_END_ADDR - data_left` works.
+const esp_address_mask: u32 = 0x3FF;
+/// `ESP_BLOCK_SIZE`, `sdio_reg.h:39`.
+const esp_block_size: u32 = 512;
+/// The SDIO function ESP-Hosted talks to. `SDIO_FUNC_1`.
+const sdio_func: u3 = 1;
+
+/// `ESP_OK` / `ESP_FAIL` as `esp_err_t`, which is what the `_h_sdio_*` entries return and what
+/// `sdio_drv.c` tests against zero.
+const esp_ok: c_int = 0;
+const esp_fail: c_int = -1;
+
+fn busCtx(ctx: ?*anyopaque) ?*BusContext {
+ const p = ctx orelse return null;
+ const b: *BusContext = @ptrCast(@alignCast(p));
+ // ESP-IDF returns a pointer to one file-static context; anything else is a bug, and a wild
+ // pointer here would be a wild bus.
+ if (b != &state.bus) return null;
+ return b;
+}
+
+/// `_h_bus_init` = `hosted_sdio_init` (`port_esp_hosted_host_sdio.c:317-399`): bring the SDMMC host
+/// and slot up, create the bus mutex, return the context. Guarded against a second call, as the
+/// original is (`:322-326`).
+///
+/// The slot, width and clock are `hal.sdmmc`'s defaults, which are this board's measured working
+/// configuration: slot 1, 4-bit, 40 MHz, CLK 18 / CMD 19 / D0-D3 14-17.
+fn hostedBusInit() callconv(.c) ?*anyopaque {
+ assert(state.installed);
+ if (state.bus.up) {
+ note("MARK PORT_SDIO_ALREADY_UP\r\n", .{});
+ return @ptrCast(&state.bus);
+ }
+ hal.sdmmc.init(.{}) catch |e| {
+ note("MARK PORT_SDIO_INIT_FAIL %s\r\n", .{@errorName(e).ptr});
+ return null;
+ };
+ state.bus = .{ .lock = .{}, .up = true };
+ return @ptrCast(&state.bus);
+}
+
+fn hostedBusDeinit(ctx: ?*anyopaque) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ b.up = false;
+ return esp_ok;
+}
+
+/// `_h_sdio_card_init` = `hosted_sdio_card_init` + `hosted_sdio_card_fn_init`
+/// (`port_esp_hosted_host_sdio.c:141-217, 401-471`).
+///
+/// `hal.sdmmc.cardInit` does the SD/SDIO card identification and programmes the host's block size.
+/// What is left is the part that is ESP-Hosted's protocol rather than the bus's: enable function 1,
+/// wait for it to report ready, enable its interrupt, and set the CCCR block size for functions 0
+/// and 1. Those writes are idempotent and the read-back is the check; the sequence is reproduced
+/// in ESP-IDF's order because that order is what this board was observed to come up with.
+///
+/// Failure returns `ESP_FAIL` rather than asserting, because the caller retries: `sdio_drv.c:1638`
+/// loops up to `CARD_INIT_TIMEOUT_MS`, and the first register reads after a reset legitimately
+/// fail while the C6 is still booting (`:150-153`).
+fn hostedSdioCardInit(ctx: ?*anyopaque, show_config: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ _ = b;
+ hal.sdmmc.cardInit() catch |e| {
+ note("MARK PORT_SDIO_CARD_INIT_FAIL %s\r\n", .{@errorName(e).ptr});
+ return esp_fail;
+ };
+ if (show_config) {
+ note("MARK PORT_SDIO slot=1 width=4 khz=40000 clk=18 cmd=19 d0-3=14,15,16,17 reset=%u\r\n", .{
+ @as(u32, config.reset_pin),
+ });
+ }
+ return sdioFunctionInit();
+}
+
+// CCCR and FBR offsets, `esp-idf/components/sdmmc/include/sd_protocol_defs.h:511-533`.
+const cccr_fn_enable: u17 = 0x02;
+const cccr_fn_ready: u17 = 0x03;
+const cccr_int_enable: u17 = 0x04;
+const cccr_bus_width: u17 = 0x07;
+const cccr_blksize_l: u17 = 0x10;
+const cccr_blksize_h: u17 = 0x11;
+const fbr_start: u17 = 0x100;
+/// `FUNC1_EN_MASK`, `port_esp_hosted_host_sdio.c:29`.
+const func1_en_mask: u8 = 1 << 1;
+/// `SDIO_INIT_MAX_RETRY`, `:30`.
+const sdio_init_max_retry = 10;
+
+fn sdioFunctionInit() c_int {
+ // Function 0 is the CCCR; every access here is CMD52 on function 0.
+ var ioe = cmd52(0, cccr_fn_enable) orelse return esp_fail;
+ cmd52w(0, cccr_fn_enable, ioe | func1_en_mask) orelse return esp_fail;
+
+ // Poll IOR until function 1 reports ready. 10 tries, 10 ms apart (:180-192).
+ var tries: u32 = 0;
+ while (tries < sdio_init_max_retry) : (tries += 1) {
+ const ior = cmd52(0, cccr_fn_ready) orelse return esp_fail;
+ if (ior & func1_en_mask != 0) break;
+ _ = hostedMsleep(10);
+ }
+ if (tries >= sdio_init_max_retry) {
+ note("MARK PORT_SDIO_FN1_NOT_READY\r\n", .{});
+ return esp_fail;
+ }
+
+ // Master interrupt enable (bit 0) plus function 1's own (:196-198).
+ const ie = cmd52(0, cccr_int_enable) orelse return esp_fail;
+ cmd52w(0, cccr_int_enable, ie | 1 | func1_en_mask) orelse return esp_fail;
+
+ const bus_width = cmd52(0, cccr_bus_width) orelse return esp_fail;
+
+ // CCCR block size for function 0, then function 1 through its FBR (:120-137, 208-214).
+ if (setBlockSize(0, esp_block_size) != esp_ok) return esp_fail;
+ if (setBlockSize(1, esp_block_size) != esp_ok) return esp_fail;
+
+ ioe = cmd52(0, cccr_fn_enable) orelse return esp_fail;
+ note("MARK PORT_SDIO_FN1 ioe=0x%02x ie=0x%02x bus_width=0x%02x\r\n", .{
+ @as(u32, ioe), @as(u32, ie | 1 | func1_en_mask), @as(u32, bus_width),
+ });
+ return esp_ok;
+}
+
+fn setBlockSize(func: u3, value: u16) c_int {
+ const offset: u17 = fbr_start * @as(u17, func);
+ const lo: u8 = @truncate(value);
+ const hi: u8 = @truncate(value >> 8);
+ cmd52w(0, offset + cccr_blksize_l, lo) orelse return esp_fail;
+ cmd52w(0, offset + cccr_blksize_h, hi) orelse return esp_fail;
+ const rb_lo = cmd52(0, offset + cccr_blksize_l) orelse return esp_fail;
+ const rb_hi = cmd52(0, offset + cccr_blksize_h) orelse return esp_fail;
+ const rb = @as(u16, rb_hi) << 8 | rb_lo;
+ return if (rb == value) esp_ok else esp_fail;
+}
+
+fn cmd52(func: u3, addr: u17) ?u8 {
+ return hal.sdmmc.cmd52Read(func, addr) catch null;
+}
+
+fn cmd52w(func: u3, addr: u17, value: u8) ?void {
+ hal.sdmmc.cmd52Write(func, addr, value) catch return null;
+ return {};
+}
+
+/// `_h_sdio_card_deinit` frees IDF's DMA bounce buffer (`port_esp_hosted_host_sdio.c:473-487`).
+/// `hal.sdmmc` owns its bounce buffer statically, so there is nothing to free.
+fn hostedSdioCardDeinit(ctx: ?*anyopaque) callconv(.c) c_int {
+ _ = busCtx(ctx) orelse return esp_fail;
+ return esp_ok;
+}
+
+/// `lock_required` exists because ESP-IDF's SDMMC driver is shared: `sdio_drv.c` reaches the bus
+/// from four tasks, and a CMD53 that interleaves with another CMD53 is a corrupt transfer. Some
+/// call sites already hold the bus lock (`SDIO_DRV_LOCK`) and pass false to avoid taking it twice;
+/// the rest pass true.
+///
+/// **It is still required here**, and this is the one place where a cooperative scheduler does not
+/// let a lock go. Cooperative means no task is preempted between two *instructions*; it does not
+/// mean a task cannot yield in the middle of a transfer, and `hal.sdmmc`'s CMD53 path does exactly
+/// that if it waits on the SDMMC host's interrupt. A second task entering `cmd53Read` while the
+/// first is parked inside one would reprogramme the descriptor under it. The lock is what makes
+/// "one transfer at a time" true, and it is cheap: `Io.Mutex.tryLock` is one compare-exchange when
+/// uncontended, which is every call on the fast path.
+fn sdioLock(b: *BusContext, required: bool) void {
+ if (required) _ = b.lock.lock(state.io, .forever);
+}
+
+fn sdioUnlock(b: *BusContext, required: bool) void {
+ if (required) _ = b.lock.unlock(state.io);
+}
+
+/// `_h_sdio_read_reg`: function 1, address masked, CMD52 for one byte and CMD53 byte mode with an
+/// incrementing address for more (`port_esp_hosted_host_sdio.c:489-511`).
+fn hostedSdioReadReg(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ const addr: u17 = @intCast(reg & esp_address_mask);
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ data[0] = hal.sdmmc.cmd52Read(sdio_func, addr) catch return esp_fail;
+ return esp_ok;
+ }
+ hal.sdmmc.cmd53Read(sdio_func, addr, data[0..size], true) catch return esp_fail;
+ return esp_ok;
+}
+
+fn hostedSdioWriteReg(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ const addr: u17 = @intCast(reg & esp_address_mask);
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ hal.sdmmc.cmd52Write(sdio_func, addr, data[0]) catch return esp_fail;
+ return esp_ok;
+ }
+ hal.sdmmc.cmd53Write(sdio_func, addr, data[0..size], true) catch return esp_fail;
+ return esp_ok;
+}
+
+/// `_h_sdio_read_block` / `_h_sdio_write_block`, `port_esp_hosted_host_sdio.c:536-576`, with the
+/// splitting from `sdio_read_fromio`/`sdio_write_toio` (`:221-292`):
+///
+/// * the length is first rounded **up** to a multiple of four (`H_SDIO_TX_LEN_TO_TRANSFER`,
+/// `port_esp_hosted_host_config.h:274-275`), because the slave's FIFO is word-wide;
+/// * while 512 bytes or more remain, transfer whole 512-byte blocks;
+/// * transfer the remainder in byte mode;
+/// * the address advances by every chunk, and is **not** masked - block transfers address the
+/// slave's data window, not its scratch registers.
+///
+/// Rounding up means reading or writing past `size`. That is ESP-Hosted's design, not an accident:
+/// its buffers come from `_h_malloc_align(len, 64)`, so there are always at least 64 usable bytes
+/// at the end - and this port's `_h_malloc_align` rounds the *allocation* up to the alignment for
+/// exactly this reason. A caller that hands a tightly-sized buffer to a block transfer would have
+/// the same bug under ESP-IDF.
+fn hostedSdioReadBlock(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ // Unmasked, unlike the `_reg` entries: `hosted_sdio_read_block` has no
+ // `reg &= ESP_ADDRESS_MASK` (port_esp_hosted_host_sdio.c:536-555). Masking here would
+ // fold `ESP_SLAVE_CMD53_END_ADDR - data_left` (sdio_drv.c:756) onto a scratch register.
+ data[0] = hal.sdmmc.cmd52Read(sdio_func, @intCast(reg)) catch return esp_fail;
+ return esp_ok;
+ }
+ return blockTransfer(.read, reg, data, size);
+}
+
+fn hostedSdioWriteBlock(ctx: ?*anyopaque, reg: u32, data: [*]u8, size: u16, lock_required: bool) callconv(.c) c_int {
+ const b = busCtx(ctx) orelse return esp_fail;
+ sdioLock(b, lock_required);
+ defer sdioUnlock(b, lock_required);
+ if (size <= 1) {
+ // Unmasked; see `hostedSdioReadBlock`.
+ hal.sdmmc.cmd52Write(sdio_func, @intCast(reg), data[0]) catch return esp_fail;
+ return esp_ok;
+ }
+ return blockTransfer(.write, reg, data, size);
+}
+
+fn blockTransfer(comptime dir: enum { read, write }, reg: u32, data: [*]u8, size: u16) c_int {
+ // H_SDIO_{TX,RX}_LEN_TO_TRANSFER: (x + 3) & ~3.
+ const total: u32 = (@as(u32, size) + 3) & ~@as(u32, 3);
+ var remaining: u32 = total;
+ var addr: u32 = reg;
+ var at: u32 = 0;
+
+ while (remaining >= esp_block_size) {
+ // H_SDIO_{TX,RX}_BLOCKS_TO_TRANSFER: all whole blocks in one command unless the build
+ // forces one block at a time (port_esp_hosted_host_config.h:297-308).
+ const chunk = (remaining / esp_block_size) * esp_block_size;
+ const slice = data[at .. at + chunk];
+ switch (dir) {
+ .read => hal.sdmmc.cmd53Read(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ .write => hal.sdmmc.cmd53Write(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ }
+ remaining -= chunk;
+ at += chunk;
+ addr += chunk;
+ }
+ if (remaining > 0) {
+ const slice = data[at .. at + remaining];
+ switch (dir) {
+ .read => hal.sdmmc.cmd53Read(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ .write => hal.sdmmc.cmd53Write(sdio_func, @intCast(addr), slice, true) catch return esp_fail,
+ }
+ }
+ return esp_ok;
+}
+
+/// `_h_sdio_wait_slave_intr`: block until the C6 asserts its SDIO interrupt on D1.
+///
+/// The arming order is IDF's, from `sd_host_sdmmc.c:396-426`: mask the card interrupt, drop the
+/// previous wake's latch, look once at what is pending, and only then unmask and sleep. The look
+/// is not optional - the capture is negedge-triggered, so an edge that arrived while this task was
+/// awake is not going to arrive again.
+///
+/// ### The storm this function used to cause
+///
+/// Measured on the die: the first call here killed the machine. Every task starved, including one
+/// that does nothing but sleep and print a heartbeat, from the instant `configureLine` set the
+/// line's IE bit. On a cooperative scheduler nothing that *blocks* can do that. It was an
+/// interrupt storm.
+///
+/// The controller drives a single line into the CLIC and asserts it whenever `RINTSTS & INTMASK`
+/// (or the IDMAC's `IDSTS & IDINTEN`) is non-zero - not just for the card interrupt this function
+/// waits on. Two separate causes were holding it high permanently: `INTMASK` carried
+/// `Event.default`, whose card-detect bit no command path ever clears, and `initDma` had unmasked
+/// the IDMAC's three completion interrupts with nothing ever clearing `IDSTS` after a transfer.
+/// Either one is enough.
+///
+/// A level-triggered line whose source is still asserting re-enters the moment the handler
+/// `mret`s. The old `sdioDispatch` tested `slaveInterruptPending()` *first* and took an early
+/// return when the cause was not the card interrupt - without masking or clearing anything. So
+/// the line stayed high, the core re-entered, and it never came back. `hal.intr`'s module comment
+/// describes this precise failure for lines the ROM left armed (`intr.zig:512-518`); this was the
+/// same bug, self-inflicted.
+///
+/// Three invariants fix it, none of which depends on guessing which bit was set:
+///
+/// * **the handler deasserts on every path**, before it reads anything at all;
+/// * **only this function arms.** `hal.intr.configureLine` enables the line as its last act,
+/// which is exactly what must not happen at configuration time, so the line is configured
+/// with the individual setters and left disabled;
+/// * **the controller is silent unless armed** - `hal.sdmmc`'s half of the fix, which reduces
+/// the set of possible causes to one.
+///
+/// ### Level, not edge, and why the answer is not "either works"
+///
+/// Two different trigger behaviours meet on this path, and conflating them sends you tuning the
+/// wrong knob. **Card to controller is an edge**: D1's negedge is captured once into RINTSTS,
+/// which is why step 3 below reads D1's *pad* rather than the latch before sleeping.
+/// **Controller to CLIC is a level**: RINTSTS is a sticky write-1-to-clear latch and MINTSTS is
+/// `RINTSTS & INTMASK`, so the controller's single output stays asserted until software masks or
+/// clears the bit that raised it. The CLIC trigger describes that second stage and only that one,
+/// so it is `.level`.
+///
+/// `.edge` would be wrong three times over, and the third is the one that bites. It would need an
+/// `edgeAck` this handler does not do. It would drop a re-assert that arrived while the line was
+/// still high, because there is no second rising edge to capture. And it would *hide* a handler
+/// that fails to deassert - the re-entry would stop, the storm would go away, and the bug would
+/// still be there, waiting for the day something else holds MINTSTS non-zero. A level trigger
+/// makes that failure loud and local, which is worth more than a trigger type that works by luck.
+///
+/// ### The precondition that was missing, and was read as a mask that would not stick
+///
+/// The line was configured, routed and armed - and nothing in this image had taken ownership of
+/// the interrupt controller. `takeInterruptControl` is that step and its comment has the detail;
+/// the short form is that `hal.intr.setHandler` files a handler in a table the core does not
+/// consult until `hal.intr.init()` has written mtvec and MTVT, and that the threshold and
+/// mstatus.MIE are equally this image's job and were nobody's. Neither diagnostic that reported
+/// `intmask=0` could have shown anything else, because both read INTMASK after a deliberate
+/// disarm; `MARK PORT_SDIO_ARM` carries the read-back that can.
+///
+/// `ticks_to_wait` is FreeRTOS ticks. The only caller (`sdio_drv.c:1191`) passes
+/// `HOSTED_BLOCK_MAX`, so the bounded branch exists for completeness; at ESP-Hosted's recommended
+/// tick rate one tick is one millisecond.
+fn hostedSdioWaitSlaveIntr(ctx: ?*anyopaque, ticks_to_wait: u32) callconv(.c) c_int {
+ if (busCtx(ctx) == null) return esp_fail;
+
+ // One unconditional trip round the run queue, before anything else.
+ //
+ // Every other path out of this function can return without ever having slept: the pad read at
+ // step 3, the latch read after it, and `sdioPoll`'s fast path all answer "yes, now". That is
+ // correct - and it means a card holding D1 low that the C declines to drain (no NEW_PACKET
+ // bit, `sdio_drv.c:1247-1251`) turns `sdio_read_task`'s `for (;;)` into a loop with no
+ // yield in it anywhere, because the C has none of its own either. A blocking entry point that
+ // can return without blocking has to supply the scheduling point itself; the alternative is
+ // the same total starvation as the interrupt storm, reached by a different road.
+ state.io.sleep(.zero, os.clock) catch {};
+
+ // Configured off by default on this board: see `Config.sdio_use_interrupt`. Checked before the
+ // line is ever configured, so with polling selected the CLIC is not touched at all.
+ if (!config.sdio_use_interrupt) return sdioPoll(ticks_to_wait);
+
+ // Enough foreign handler entries, or enough calls the interrupt failed to deliver, and this
+ // line is not usable on this board whatever the mask says. Poll instead: slower per look, but
+ // bounded, proven, and faster than a 20 ms re-look that is carrying the transport on its own.
+ if (state.sdio_intr_foreign >= sdio_foreign_limit) return sdioPoll(ticks_to_wait);
+ if (state.sdio_intr_missed >= sdio_missed_limit) return sdioPoll(ticks_to_wait);
+
+ if (!sdio_line_configured) {
+ sdio_line_configured = true;
+ // First, and the step whose absence produced every LAPSE this board has reported: mtvec,
+ // MTVT, the threshold and mstatus.MIE.
+ takeInterruptControl();
+ hal.intr.route(hal.sdmmc.interrupt_source, config.sdio_clic_line);
+ // `hal.intr.configureLine` in its documented order, minus the `setEnabled(line, true)` it
+ // finishes with. See the storm note: enabling here is the bug.
+ hal.intr.setHandler(config.sdio_clic_line, sdioDispatch);
+ hal.intr.setTrigger(config.sdio_clic_line, .level);
+ hal.intr.setPriority(config.sdio_clic_line, sdio_clic_priority);
+ hal.intr.setVectored(config.sdio_clic_line, false);
+ hal.intr.setEnabled(config.sdio_clic_line, false);
+
+ // The whole delivery chain above the controller, once, before the first sleep. Each field
+ // is a distinct way for the line to exist and never arrive, and each has a different fix:
+ // `routed=99` is a matrix write that missed, `routed` unequal to `line` is two owners of
+ // one line, `thresh >= prio` masks it however armed it is (the comparison is inclusive),
+ // `mie=0` masks everything, and `mtvec` unequal to `want_mtvec` means the handler the core
+ // would reach is not this image's.
+ note("MARK PORT_SDIO_CLIC line=%u source=%u routed=%u prio=%u trig=%u thresh=%u mie=%u mtvec=0x%08x want_mtvec=0x%08x\r\n", .{
+ @as(u32, config.sdio_clic_line),
+ @as(u32, @intFromEnum(hal.sdmmc.interrupt_source)),
+ @as(u32, hal.intr.routedLine(hal.sdmmc.interrupt_source) orelse 99),
+ @as(u32, hal.intr.getPriority(config.sdio_clic_line)),
+ @as(u32, @intFromEnum(hal.intr.getTrigger(config.sdio_clic_line))),
+ @as(u32, hal.intr.getThreshold()),
+ @as(u32, @intFromBool(hal.intr.globalEnabled())),
+ hal.intr.readMtvec(),
+ hal.intr.trapEntryAddress() | hal.intr.mtvec_mode_clic,
+ });
+ }
+
+ // A bounded wait that loops, rather than the unbounded one the caller asked for.
+ //
+ // The lost-edge case that used to need this is now handled properly at step 3 of the arming
+ // sequence below, so this is no longer the mechanism - it is the net under it. It stays
+ // because an unbounded futex wait is precisely the shape of failure that cost an afternoon:
+ // silent, indistinguishable from a card that never called, and impossible to report on. A
+ // 20 ms re-look turns "the radio is dead" into `MARK PORT_SDIO_LAPSE` with the registers
+ // attached, and costs that latency only on beats where the interrupt did not arrive.
+ //
+ // `sdio_drv.c:1188` is right that a finite wait is unusable *for the caller*, so the loop, not
+ // the wait, is what honours `HOSTED_BLOCK_MAX`: this function still only returns when there is
+ // something to report. The property gained is that no path through it can be silent for ever.
+ const bounded = ticks_to_wait != std.math.maxInt(u32);
+ const deadline = os.nowMs(state.io) + ticks_to_wait;
+
+ while (true) {
+ // Read before arming, so an interrupt taken between here and the futex wait cannot be
+ // lost: `futexWaitTimeout` returns immediately on a value that no longer matches.
+ const seen = state.sdio_intr_epoch.load(.acquire);
+
+ // Steps 1-4 of `sd_host_sdmmc.c:404-426`, in that order, as written out on
+ // `hal.sdmmc.setSlaveInterruptEnabled`. Getting the order wrong loses wakeups; getting
+ // step 3 wrong loses them permanently.
+ hal.sdmmc.setSlaveInterruptEnabled(false);
+ hal.sdmmc.clearSlaveInterrupt();
+
+ // Step 3, and the one that cannot be done with the controller's registers alone. RINTSTS
+ // is a latch: it says "a negedge was captured", and step 2 has just thrown that away. D1's
+ // pad is a level: it says "the card is holding the line low *now*". A C6 that is still
+ // waiting to be drained is exactly the second without the first, and sleeping on it waits
+ // for an edge that has already happened. The latch is tested too, for the window between
+ // the clear above and this read.
+ //
+ // This is not a window that lapsed - nothing has been armed and nothing has slept - so it
+ // leaves `sdio_intr_missed` alone.
+ if (hal.sdmmc.slaveInterruptAsserted() or hal.sdmmc.slaveInterruptPending()) return esp_ok;
+
+ // Source first, CLIC last: the line must not be deliverable while the only cause it is
+ // allowed to have is still masked. The unmask reads INTMASK back inside its own masked
+ // region, which is the only reading of that register that can answer "did it stick".
+ const armed = hal.sdmmc.armSlaveInterrupt();
+ state.sdio_armed_intmask = armed.intmask;
+ state.sdio_armed_mintsts = armed.mintsts;
+ hal.intr.setEnabled(config.sdio_clic_line, true);
+
+ // `stuck=1` retires the "the unmask does not stick" hypothesis; `stuck=0` confirms it, with
+ // the word that was wanted printed beside the word the register returned. Budgeted,
+ // because it is a property of the configuration rather than of the beat.
+ sdioMark(&state.sdio_arm_marks, "MARK PORT_SDIO_ARM stuck=%u want=0x%08x intmask=0x%08x mintsts=0x%08x rintsts=0x%08x ie=%u\r\n", .{
+ @as(u32, @intFromBool(armed.stuck())),
+ armed.want,
+ armed.intmask,
+ armed.mintsts,
+ armed.rintsts,
+ @as(u32, @intFromBool(hal.intr.isEnabled(config.sdio_clic_line))),
+ });
+
+ // Timeout and cancelation are indistinguishable here and neither is a result; the epoch is
+ // the only thing that says whether the handler ran.
+ state.io.futexWaitTimeout(u32, &state.sdio_intr_epoch.raw, seen, .{
+ .duration = .{ .clock = os.clock, .raw = .fromMilliseconds(sdio_relook_ms) },
+ }) catch {};
+
+ // The CLIC's own pending bit, read *before* the disarm, because it is the discriminator a
+ // lapse otherwise has no way to report: `pend=1` with no handler entry means the CLIC
+ // latched this line and the core never took it, so the fault is mtvec, the threshold or
+ // MIE rather than the controller or the C6.
+ const clic_pending = hal.intr.isPending(config.sdio_clic_line);
+
+ // Idempotent: on a real wake the handler already did both. On a lapse it did not, and an
+ // armed line with nobody waiting is how a storm gets its second chance.
+ disarmSdioLine();
+
+ if (state.sdio_intr_epoch.load(.acquire) != seen) {
+ // The handler ran. It deliberately does not clear the latched SDIO bit - clearing it
+ // while D1 is still low would drop the next wakeup - so the bit still being set is
+ // what distinguishes "the C6 called" from "something else held the controller's line
+ // high and the handler is who noticed".
+ if (hal.sdmmc.slaveInterruptPending()) {
+ state.sdio_intr_foreign = 0;
+ state.sdio_intr_missed = 0;
+ // **The line that says the interrupt works.** Until now a successful delivery was
+ // the only outcome that printed nothing at all, so a console showing idle lapses
+ // and no wakes was indistinguishable from a console showing a dead CLIC - which is
+ // exactly the ambiguity that made the last flash inconclusive. `mintsts` is the
+ // word the controller's output follows, captured at handler entry before the
+ // disarm zeroed it; this slot's bit set in it is delivery proven end to end.
+ sdioMark(&state.sdio_wake_marks, "MARK PORT_SDIO_WAKE n=%u mintsts=0x%08x rintsts=0x%08x idsts=0x%08x\r\n", .{
+ state.sdio_intr_epoch.load(.acquire),
+ state.sdio_intr_mintsts.load(.acquire),
+ state.sdio_intr_rintsts.load(.acquire),
+ state.sdio_intr_idsts.load(.acquire),
+ });
+ return esp_ok;
+ }
+ state.sdio_intr_foreign += 1;
+ sdioMark(&state.sdio_foreign_marks, "MARK PORT_SDIO_FOREIGN n=%u mintsts=0x%08x rintsts=0x%08x idsts=0x%08x armed_intmask=0x%08x\r\n", .{
+ state.sdio_intr_foreign,
+ state.sdio_intr_mintsts.load(.acquire),
+ state.sdio_intr_rintsts.load(.acquire),
+ state.sdio_intr_idsts.load(.acquire),
+ state.sdio_armed_intmask,
+ });
+ if (state.sdio_intr_foreign >= sdio_foreign_limit) {
+ note("MARK PORT_SDIO_POLLING abandoning CLIC line %u\r\n", .{
+ @as(u32, config.sdio_clic_line),
+ });
+ return sdioPoll(ticks_to_wait);
+ }
+ } else {
+ // Nobody entered the handler. Ask both ends directly before calling it a lapse - the
+ // pad for a card asserting now, the latch for an edge captured while the CLIC was
+ // being taken down.
+ //
+ // This is the one reading that separates the two things a lapse can mean, and it is
+ // why `sdio_intr_lapses` alone is not a fault signal. **The card is calling and the
+ // interrupt did not deliver it**: a real frame has just paid up to `sdio_relook_ms` of
+ // latency, the re-look is doing the interrupt's job, and four of those in a row is a
+ // configuration that will not fix itself - so the line goes back to the poll, which is
+ // twenty times quicker at exactly this.
+ if (hal.sdmmc.slaveInterruptAsserted() or hal.sdmmc.slaveInterruptPending()) {
+ state.sdio_intr_missed += 1;
+ sdioMark(&state.sdio_missed_marks, "MARK PORT_SDIO_MISSED n=%u pend=%u armed_intmask=0x%08x armed_mintsts=0x%08x rintsts=0x%08x\r\n", .{
+ state.sdio_intr_missed,
+ @as(u32, @intFromBool(clic_pending)),
+ state.sdio_armed_intmask,
+ state.sdio_armed_mintsts,
+ hal.sdmmc.interruptStatusRaw(),
+ });
+ if (state.sdio_intr_missed >= sdio_missed_limit) {
+ note("MARK PORT_SDIO_POLLING abandoning CLIC line %u after %u undelivered calls\r\n", .{
+ @as(u32, config.sdio_clic_line),
+ state.sdio_intr_missed,
+ });
+ return sdioPoll(ticks_to_wait);
+ }
+ return esp_ok;
+ }
+
+ // The other meaning: the C6 had nothing to say. Free, and the resting state of an idle
+ // link - which is the whole point of waiting on an interrupt instead of polling.
+ state.sdio_intr_lapses +%= 1;
+ // `armed_*` is what the mask was during the window that lapsed; `now_*` is the
+ // disarmed state. Both are printed so the two can no longer be mistaken for each
+ // other: `now_intmask=0` is expected here, and always was.
+ sdioMark(&state.sdio_lapse_marks, "MARK PORT_SDIO_LAPSE n=%u armed_intmask=0x%08x armed_mintsts=0x%08x pend=%u now_rintsts=0x%08x now_intmask=0x%08x\r\n", .{
+ state.sdio_intr_lapses,
+ state.sdio_armed_intmask,
+ state.sdio_armed_mintsts,
+ @as(u32, @intFromBool(clic_pending)),
+ hal.sdmmc.interruptStatusRaw(),
+ hal.sdmmc.interruptMaskRaw(),
+ });
+ }
+
+ // The one diagnostic with no budget, because the ratio it reports is the whole question and
+ // it stays interesting after every other line has gone quiet. `wakes` is handler entries:
+ // rising with `lapses` means the interrupt is carrying the transport and the re-look is
+ // only covering the idle gaps, flat at zero means the CLIC is not delivering and the
+ // re-look is doing all of it.
+ state.sdio_windows +%= 1;
+ if (state.sdio_windows % sdio_tally_every == 0) {
+ note("MARK PORT_SDIO_TALLY windows=%u wakes=%u lapses=%u missed=%u foreign=%u\r\n", .{
+ state.sdio_windows,
+ state.sdio_intr_epoch.load(.acquire),
+ state.sdio_intr_lapses,
+ state.sdio_intr_missed,
+ state.sdio_intr_foreign,
+ });
+ }
+
+ if (bounded and os.nowMs(state.io) >= deadline) return esp_fail;
+ }
+}
+
+/// Consecutive foreign handler entries after which the interrupt is abandoned for polling. Four,
+/// because one can be a race and four in a row is a configuration that will not fix itself.
+const sdio_foreign_limit: u32 = 4;
+
+/// Consecutive undelivered calls - lapsed windows whose re-look found the card already asserting -
+/// after which the interrupt is abandoned for polling.
+///
+/// Four, for the same reason as `sdio_foreign_limit`: one can be a race against the CLIC being
+/// taken down, four in a row is a configuration. At `sdio_relook_ms` each that is 80 ms of
+/// degraded latency before the line is given up, well inside one of the transport's own 200 ms
+/// retry turns (`transport_drv.c:233`).
+///
+/// This bound is what makes flipping `sdio_use_interrupt` to `true` an experiment rather than a
+/// bet. Without it, a board where delivery is still broken would give every received frame 20 ms
+/// instead of the poll's 1 ms, for ever, with eight budgeted MARK lines to say so. Note that it
+/// counts *undelivered calls* and not lapses: an idle card lapses every window by construction,
+/// and penalising that would trade the interrupt away 160 ms after boot on a link that was
+/// working perfectly.
+const sdio_missed_limit: u32 = 4;
+
+/// Priority for the SDIO CLIC line. `hal.intr.init` leaves the threshold at 0 and the comparison
+/// is inclusive, so 1 is the lowest value that can ever be taken. Nothing higher would win against
+/// anything: `port.zig` is the only owner of a CLIC line in this image.
+const sdio_clic_priority: u3 = 1;
+
+/// Lines each distinct diagnostic may print. A wait that gives up has to be able to say why; it
+/// does not have to say so ten thousand times.
+const sdio_mark_budget: u32 = 8;
+
+/// Arming windows between `MARK PORT_SDIO_TALLY` lines. 64 windows is at most 1.3 s of idle link
+/// at `sdio_relook_ms`, and far less when frames are flowing, so the ratio is visible within a
+/// couple of seconds of boot and costs one `ets_printf` per 64 windows.
+const sdio_tally_every: u32 = 64;
+
+/// Cadence of the polling fallback. Both the pad and the latch are single register reads, so this
+/// is a latency budget rather than a cost.
+const sdio_poll_ms: u32 = 1;
+
+/// How long one arming window sleeps before looking at the pad and the latch itself.
+///
+/// The number is a latency budget, not a timeout: an interrupt that arrives is delivered at once,
+/// and this only bounds how long a *lost* negedge can go unnoticed. 20 ms is two orders of
+/// magnitude below anything the transport's own retries care about (`transport_drv.c:233` sleeps
+/// 200 ms per turn) and two orders above the cost of the register reads it gates.
+const sdio_relook_ms: u32 = 20;
+
+fn sdioMark(budget: *u32, comptime fmt: [*:0]const u8, args: anytype) void {
+ if (budget.* >= sdio_mark_budget) return;
+ budget.* += 1;
+ note(fmt, args);
+}
+
+/// The interrupt-free path. `sdio_drv.c:1188` insists a finite wait is unusable here, so an
+/// unbounded `ticks_to_wait` blocks until the card really does call - it just yields between
+/// checks instead of sleeping on a futex.
+///
+/// The clear before returning is load-bearing. `sdio_clear_intr` writes the *slave's*
+/// `ESP_SLAVE_INT_CLR_REG` (`sdio_drv.c:423-427`); nothing in the C touches this controller's
+/// RINTSTS, so a latched bit left set here makes the next call return immediately, and
+/// `sdio_read_task`'s loop contains no other yield. That is the same total starvation the
+/// interrupt storm caused, reached the slow way - and it is why the interrupt path clears at the
+/// top of every arm rather than on the way out.
+fn sdioPoll(ticks_to_wait: u32) c_int {
+ _ = ticks_to_wait;
+
+ // Fast path: if either controller-side signal says the card is calling, say so at once. Both
+ // are real when they do fire, and they cost two register reads.
+ if (hal.sdmmc.slaveInterruptAsserted() or hal.sdmmc.slaveInterruptPending()) {
+ hal.sdmmc.clearSlaveInterrupt();
+ return esp_ok;
+ }
+
+ // Otherwise sleep briefly and report "look again" - deliberately, and this is the whole point of
+ // this function.
+ //
+ // Neither controller-side signal is a trustworthy answer to "does the slave have a packet":
+ //
+ // - `slaveInterruptPending` reads RINTSTS bit 16+slot, which LATCHES an edge. The card asserts
+ // once per packet; clear that latch while the card still has data queued and the edge is
+ // gone, with nothing to re-create it until the *next* packet arrives.
+ // - `slaveInterruptAsserted` reads D1's pad level, and D1 is a DATA line. The SDMMC controller
+ // owns that pad throughout every CMD53, and the SDIO interrupt is only meaningful in defined
+ // windows between blocks. ESP-IDF never reads it for this: `sdmmc_host_io_int_wait` consults
+ // the controller's own status word instead.
+ //
+ // Measured consequence of trusting them: the receive counter reached somewhere between 6 and 18
+ // frames and then froze for ever, while transmits kept working. The board took a real DHCP lease
+ // - the host speaks first there - and then answered no ARP and no ping.
+ //
+ // The authority on "is there a packet" is the slave's own ESP_SLAVE_INT_RAW_REG, and
+ // `sdio_read_task` already reads it on every pass and tests BIT(SDIO_INT_NEW_PACKET) itself
+ // (sdio_drv.c:1204, :1247). examples/sdiocheck.zig proved that register answers reliably over
+ // CMD53. So when the cheap signals say nothing, the right move is not to guess - it is to yield
+ // and let the caller ask the slave. `HOSTED_BLOCK_MAX` is honoured in the sense that matters:
+ // this returns only when the caller has something to do, and "read your registers again" always
+ // is.
+ //
+ // The cost is one register read per `sdio_poll_ms` while the link is idle. The benefit is that a
+ // lost edge can no longer strand a packet.
+ state.io.sleep(.fromMilliseconds(sdio_poll_ms), os.clock) catch return esp_fail;
+ return esp_ok;
+}
+
+/// Take ownership of the interrupt controller, once, before any line this file configures can be
+/// delivered.
+///
+/// **This is the step whose absence made the interrupt path look like an INTMASK write that would
+/// not stick.** `hal.intr.init()` is not decoration; it is what makes an interrupt reach *this
+/// image* at all, and nothing in the `-Dapp=examples/http.zig` build had ever called it.
+/// `examples/intrcheck.zig` and `examples/portcheck.zig` do; `examples/http.zig`,
+/// `examples/radio.zig` and everything under `src/` did not. So when
+/// `hal.intr.setEnabled(sdio_clic_line, true)` ran on this board, four separate preconditions were
+/// missing:
+///
+/// * **mtvec still belonged to the bootloader.** `hal.intr.init` fills the vector table, writes
+/// MTVT and writes `mtvec = trapEntry | 3` (`intr.zig:558-577`). Without it, the CLIC vectors
+/// wherever the ROM left mtvec pointing, `hal.intr.setHandler` files `sdioDispatch` in a table
+/// the core never consults, and the core leaves this image and does not come back. That is the
+/// reported "whole board going silent right after Open data path at slave": not a storm, an
+/// exit.
+/// * **whatever the ROM armed was still armed** (`intr.zig:512-518`), so the first MIE could
+/// also deliver somebody else's level-triggered source into the same nowhere.
+/// * **the threshold was never opened.** The comparison is inclusive and this line runs at
+/// priority 1, so a threshold the ROM left at 1 or above masks it for ever - which is a LAPSE
+/// every window with no handler entry and nothing else wrong anywhere.
+/// * **mstatus.MIE.** `hal.intr.init` deliberately leaves it clear and says that turning it on
+/// is the caller's decision (`intr.zig:531`). Nothing in this build was that caller.
+///
+/// Enabling MIE here is safe *because* `init()` ran first: it has just detached all 128 sources
+/// and cleared all 48 enables, so the only lines that can be delivered afterwards are the ones
+/// this file enables itself.
+///
+/// Idempotent, and the test is the fact that matters rather than a flag of our own - if mtvec
+/// already points at this image's trap entry then somebody has already done this, and re-running
+/// `init()` would destroy `hal.intr.boot_state`, the only record of what the bootloader handed
+/// over. Both call sites (here and `hostedConfigGpioAsInterrupt`) run it before they touch a line,
+/// so whichever is first does the work and the other finds it done - which matters, because
+/// `init()` detaches every source and would otherwise silence a line the other had just armed.
+fn takeInterruptControl() void {
+ if (hal.intr.readMtvec() != (hal.intr.trapEntryAddress() | hal.intr.mtvec_mode_clic)) {
+ hal.intr.init();
+ // A fault is the one failure on this path that cannot report itself: `hal.intr` parks the
+ // core with the numbers recorded and no way to print them. Only installed if the
+ // application has not claimed the hook.
+ if (hal.intr.on_fault == null) hal.intr.on_fault = reportFault;
+ note("MARK PORT_INTR_OWN mtvec=0x%08x want=0x%08x mtvt=0x%08x thresh=%u boot_mie=%u rom_lines=0x%08x rom_sources=%u\r\n", .{
+ hal.intr.readMtvec(),
+ hal.intr.trapEntryAddress() | hal.intr.mtvec_mode_clic,
+ hal.intr.readMtvt(),
+ @as(u32, hal.intr.getThreshold()),
+ @as(u32, @intFromBool(hal.intr.boot_state.mie)),
+ hal.intr.boot_state.enabled_lines,
+ hal.intr.boot_state.routed_sources,
+ });
+ }
+ if (!hal.intr.globalEnabled()) hal.intr.globalEnable();
+}
+
+/// Last words. `hal.intr.intrFault` has already recorded the fault and will park the core after
+/// this returns, so this is the only chance the numbers get to leave the board.
+fn reportFault(f: hal.intr.Fault) void {
+ note("MARK PORT_INTR_FAULT mcause=0x%08x mepc=0x%08x mtval=0x%08x taken=%u last_id=%u spurious=%u\r\n", .{
+ f.mcause,
+ f.mepc,
+ f.mtval,
+ hal.intr.taken,
+ hal.intr.last_clic_id,
+ hal.intr.spurious,
+ });
+}
+
+/// Deassert and disable, in that order. The guarantee the handler needs: after this the line
+/// cannot be taken again until somebody arms it.
+fn disarmSdioLine() void {
+ hal.sdmmc.setSlaveInterruptEnabled(false);
+ hal.intr.setEnabled(config.sdio_clic_line, false);
+}
+
+var sdio_line_configured: bool = false;
+
+/// The CLIC handler. Runs with `mstatus.MIE` clear on the interrupted stack
+/// (`hal.intr.Handler`), so what follows cannot itself be interrupted - and after the first
+/// statement it cannot be re-entered either.
+fn sdioDispatch(line: u5) void {
+ _ = line;
+ // One load, before the disarm, and it is safe for a reason worth stating rather than assuming.
+ //
+ // The invariant is "no path returns from this handler with the line still asserted", because a
+ // level line re-enters the instant the handler `mret`s and that hangs the core. What breaks the
+ // invariant is a *branch* - any test that can return early. A read cannot return, so a load
+ // placed here costs the invariant nothing.
+ //
+ // It has to be here, though: MINTSTS is `RINTSTS & INTMASK`, so the disarm below zeroes it and
+ // reading it afterwards would report 0 on every entry - the same mistake the old INTMASK read
+ // made one line lower. This is the register the controller's output actually follows, so its
+ // value at the moment of delivery is the direct answer to "did the card interrupt reach the
+ // CLIC, or did something else".
+ const mintsts_at_entry = hal.sdmmc.interruptStatusMasked();
+
+ // Unconditional, and first among the *stores*. A level-triggered line does not deassert because
+ // the handler returned; masking the source and dropping the CLIC's enable are the only two
+ // things that stop it, and this handler does not know which status bit is holding the line up.
+ // Every test placed before this point is a chance to return with the line still asserted, which
+ // is not a missed interrupt - it is a hang of the whole core.
+ disarmSdioLine();
+
+ // The rest of what the line looked like at entry, and the reason
+ // `hal.sdmmc.interruptStatusRaw` and `hal.sdmmc.dmaStatusRaw` exist. Both of these registers
+ // are sticky, so reading them after the disarm loses nothing.
+ //
+ // INTMASK is deliberately *not* read here. It is not sticky, the disarm has just rewritten it,
+ // and a `MARK PORT_SDIO_FOREIGN` carrying that value only ever said that the disarm worked. The
+ // mask that was actually in force is `state.sdio_armed_intmask`, read back by the arm inside its
+ // own masked region.
+ state.sdio_intr_mintsts.store(mintsts_at_entry, .release);
+ state.sdio_intr_rintsts.store(hal.sdmmc.interruptStatusRaw(), .release);
+ state.sdio_intr_idsts.store(hal.sdmmc.dmaStatusRaw(), .release);
+
+ // Wake unconditionally too. The waiter can tell a real card interrupt from a foreign one, and
+ // a waiter that is told is a waiter that can report; returning silently is how the old handler
+ // turned a misconfigured mask into a wait that never ended.
+ _ = state.sdio_intr_epoch.fetchAdd(1, .release);
+ state.io.futexWake(u32, &state.sdio_intr_epoch.raw, 1);
+}
+
+// ============================================================================ 7. events
+
+fn hostedEventWifiPost(event_id: i32, event_data: ?*anyopaque, event_data_size: usize, ticks_to_wait: u32) callconv(.c) c_int {
+ _ = ticks_to_wait;
+ deliver(.{
+ .base = .wifi,
+ .id = event_id,
+ .data = sliceOf(event_data, event_data_size),
+ });
+ return esp_ok;
+}
+
+fn hostedEventPost(event_base: EventBase, event_id: i32, event_data: ?*anyopaque, event_data_size: usize, ticks_to_wait: u32) callconv(.c) c_int {
+ _ = ticks_to_wait;
+ deliver(.{
+ .base = .{ .named = event_base },
+ .id = event_id,
+ .data = sliceOf(event_data, event_data_size),
+ });
+ return esp_ok;
+}
+
+fn sliceOf(p: ?*anyopaque, len: usize) ?[]const u8 {
+ const q = p orelse return null;
+ if (len == 0) return null;
+ const b: [*]const u8 = @ptrCast(q);
+ return b[0..len];
+}
+
+/// `ticks_to_wait` is dropped, and that is a real difference. `esp_event_post` copies the payload
+/// into a queue and can block when that queue is full, which is what the argument is for. This
+/// calls the application straight through, on the posting task, so there is no queue to fill and
+/// nothing to wait for - but it also means a slow handler stalls the transport task that posted the
+/// event. The application is expected to copy what it needs and return.
+fn deliver(e: Event) void {
+ const h = state.on_event orelse {
+ // Silent by default would hide association and disconnection reasons, which is exactly
+ // what a bring-up needs to see.
+ switch (e.base) {
+ .wifi => note("MARK PORT_EVENT wifi id=%d len=%u (no handler)\r\n", .{ e.id, @as(u32, @intCast(if (e.data) |d| d.len else 0)) }),
+ .named => |n| note("MARK PORT_EVENT %s id=%d len=%u (no handler)\r\n", .{ n, e.id, @as(u32, @intCast(if (e.data) |d| d.len else 0)) }),
+ }
+ return;
+ };
+ h(e);
+}
+
+// ============================================================================ misc real entries
+
+/// `hosted_init_hook` warns if `CONFIG_FREERTOS_HZ` is below ESP-Hosted's recommendation
+/// (`port_esp_hosted_host_os.c:150-158`). There is no tick here at all - `std.Io`'s timebase is
+/// `hal.systimer`'s 16 MHz counter and sleeps are absolute deadlines, not tick counts - so the
+/// jitter that warning is about does not exist. Announce the port instead, which is the one line
+/// that proves this table is the one being called.
+fn hostedInitHook() callconv(.c) void {
+ note("MARK PORT_HOOK zig port installed=%u timers=%u\r\n", .{
+ @as(u32, @intFromBool(state.installed)),
+ @as(u32, config.timer_slots),
+ });
+}
+
+/// `_h_restart_host` reboots the host when the slave has stopped answering
+/// (`transport_drv.c:70`, `sdio_drv.c:578`, and the init-timeout callback).
+///
+/// ESP-IDF calls `esp_restart`. There is no `esp_restart` here and, more to the point, a bring-up
+/// that silently reboots is a bring-up you cannot debug: the interesting state is the state at the
+/// moment the slave went quiet. So this reports and parks, with interrupts left on so the console
+/// still works and a debugger can still attach.
+fn hostedRestartHost() callconv(.c) c_int {
+ const s = stats();
+ note("MARK PORT_RESTART_HOST requested; parking. heap live=%u reserved=%u peak=%u blocks=%u fail=%u stubs=%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)),
+ s.stub_calls,
+ });
+ while (true) {}
+}
+
+/// `_h_get_host_wakeup_or_reboot_reason`. `HOSTED_WAKEUP_NORMAL_REBOOT` is what ESP-IDF returns
+/// when power-save is not compiled in (`port_esp_hosted_host_os.c:932-934`), and it is the truth
+/// here: this image has no sleep support, so every boot is a normal one.
+fn hostedGetWakeupReason() callconv(.c) c_int {
+ return 0; // HOSTED_WAKEUP_NORMAL_REBOOT
+}
+
+// ============================================================================ 8. loud stubs
+
+/// Every stub prints its own name and returns a failure code. The two properties that matter: a
+/// path nobody implemented is *visible* on the console rather than a hang, and the pointer is never
+/// null, so a call through it cannot be a jump to address zero.
+fn stub(comptime name: []const u8) void {
+ state.stub_calls += 1;
+ note("MARK PORT_STUB " ++ name ++ "\r\n", .{});
+}
+
+/// SPI only. ESP-IDF assigns this just once, under `H_TRANSPORT_IN_USE == H_TRANSPORT_SPI`
+/// (`port_esp_hosted_host_os.c:991`), leaving it **null** for SDIO - so under IDF, reaching this on
+/// an SDIO build is a jump to zero. Here it is a message.
+fn stubDoBusTransfer(_: ?*anyopaque) callconv(.c) c_int {
+ stub("_h_do_bus_transfer (SPI transport)");
+ return esp_fail;
+}
+
+/// `_h_printf` routes ESP-Hosted's logging through the port table. Nothing in the tree calls it -
+/// every `ESP_LOG*` goes to `esp_log_writev` directly, which is the parent's symbol - so this is
+/// unreachable in practice, and implementing it would mean either a printf formatter in Zig or a
+/// `va_list` handed across an ABI boundary that has not been validated on rv32. The tag and the
+/// unexpanded format string are printed, which is enough to identify the call site if it ever
+/// happens.
+fn stubPrintf(level: c_int, tag: [*:0]const u8, format: [*:0]const u8, ...) callconv(.c) void {
+ state.stub_calls += 1;
+ note("MARK PORT_STUB _h_printf level=%d tag=%s fmt=%s (varargs not expanded)\r\n", .{ level, tag, format });
+}
+
+fn stubSpiHdReadReg(_: u32, _: *u32, _: c_int, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_read_reg");
+ return esp_fail;
+}
+fn stubSpiHdWriteReg(_: u32, _: *u32, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_write_reg");
+ return esp_fail;
+}
+fn stubSpiHdReadDma(_: [*]u8, _: u16, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_read_dma");
+ return esp_fail;
+}
+fn stubSpiHdWriteDma(_: [*]u8, _: u16, _: bool) callconv(.c) c_int {
+ stub("_h_spi_hd_write_dma");
+ return esp_fail;
+}
+fn stubSpiHdSetDataLines(_: u32) callconv(.c) c_int {
+ stub("_h_spi_hd_set_data_lines");
+ return esp_fail;
+}
+fn stubSpiHdSendCmd9() callconv(.c) c_int {
+ stub("_h_spi_hd_send_cmd9");
+ return esp_fail;
+}
+
+fn stubUartRead(_: ?*anyopaque, _: [*]u8, _: u16) callconv(.c) c_int {
+ stub("_h_uart_read");
+ return esp_fail;
+}
+fn stubUartWrite(_: ?*anyopaque, _: [*]u8, _: u16) callconv(.c) c_int {
+ stub("_h_uart_write");
+ return esp_fail;
+}
+fn stubUartFlushInput(_: ?*anyopaque) callconv(.c) c_int {
+ stub("_h_uart_flush_input");
+ return esp_fail;
+}
+
+/// Power save needs `esp_sleep`, a wakeup GPIO in the LP domain, and a hold latch this HAL does not
+/// model. ESP-IDF's own version returns -1 unless `H_HOST_PS_ALLOWED`
+/// (`port_esp_hosted_host_os.c:876-891`), so -1 is also the configured-off answer.
+fn stubConfigHostPowerSave(_: u32, _: ?*anyopaque, _: u32, _: c_int) callconv(.c) c_int {
+ stub("_h_config_host_power_save_hal_impl");
+ return -1;
+}
+fn stubStartHostPowerSave(_: u32) callconv(.c) c_int {
+ stub("_h_start_host_power_save_hal_impl");
+ return -1;
+}
+
+// ============================================================================ compile-time census
+
+/// A compile-time list of which entries are real and which are loud stubs, so the census in the
+/// module header cannot drift from the table. `port.stubbed` is what a self-test prints.
+pub const stubbed = [_][]const u8{
+ "_h_do_bus_transfer",
+ "_h_printf",
+ "_h_hold_gpio",
+ "_h_spi_hd_read_reg",
+ "_h_spi_hd_write_reg",
+ "_h_spi_hd_read_dma",
+ "_h_spi_hd_write_dma",
+ "_h_spi_hd_set_data_lines",
+ "_h_spi_hd_send_cmd9",
+ "_h_uart_read",
+ "_h_uart_write",
+ "_h_uart_flush_input",
+ "_h_config_host_power_save_hal_impl",
+ "_h_start_host_power_save_hal_impl",
+};
+
+comptime {
+ // 71 entries, 14 stubbed, 57 real.
+ assert(stubbed.len == 14);
+ assert(std.meta.fields(HostedOsiFuncs).len - stubbed.len == 57);
+}