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