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