From f5f8068fac59b4f16046c2022c2fc7c7e447ef4c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 25 Aug 2026 12:40:53 -0300 Subject: 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. --- src/net/all.zig | 156 ++ src/net/heap.zig | 673 +++++++++ src/net/hosted/abi_assert.c | 78 + src/net/hosted/include_dirs.txt | 171 +++ src/net/hosted/pin_assert.c | 56 + src/net/hosted/sdkconfig.h | 139 ++ src/net/hosted/sdkconfig_idf.h | 1473 +++++++++++++++++++ src/net/hosted/wifi_shim.c | 200 +++ src/net/hosted_glue.zig | 320 +++++ src/net/hosted_os.zig | 890 ++++++++++++ src/net/ip.zig | 2903 +++++++++++++++++++++++++++++++++++++ src/net/ip_test.zig | 3029 +++++++++++++++++++++++++++++++++++++++ src/net/libc.zig | 457 ++++++ src/net/link.zig | 552 +++++++ src/net/port.zig | 2194 ++++++++++++++++++++++++++++ 15 files changed, 13291 insertions(+) create mode 100644 src/net/all.zig create mode 100644 src/net/heap.zig create mode 100644 src/net/hosted/abi_assert.c create mode 100644 src/net/hosted/include_dirs.txt create mode 100644 src/net/hosted/pin_assert.c create mode 100644 src/net/hosted/sdkconfig.h create mode 100644 src/net/hosted/sdkconfig_idf.h create mode 100644 src/net/hosted/wifi_shim.c create mode 100644 src/net/hosted_glue.zig create mode 100644 src/net/hosted_os.zig create mode 100644 src/net/ip.zig create mode 100644 src/net/ip_test.zig create mode 100644 src/net/libc.zig create mode 100644 src/net/link.zig create mode 100644 src/net/port.zig (limited to 'src/net') diff --git a/src/net/all.zig b/src/net/all.zig new file mode 100644 index 0000000..3fc6dba --- /dev/null +++ b/src/net/all.zig @@ -0,0 +1,156 @@ +//! Everything the radio path needs, in one translation unit. +//! +//! This file exists for the same reason src/appdesc.zig is a separate object: the files it names +//! define `export`ed C symbols that ESP-Hosted's C calls, and nothing in the application source +//! mentions them. An ordinary `@import` would be analysed lazily under ReleaseSmall, the exports +//! would never be emitted, and the link would fail with a list of missing `_h_*` symbols that looks +//! like the port table was never written. +//! +//! Referencing each import in a `comptime` block forces analysis, which forces the exports. +//! +//! The layers, bottom up: +//! +//! src/hal/sdmmc.zig the P4's SDMMC peripheral as an SDIO host +//! src/io/p4.zig std.Io for this chip - the cooperative runtime everything above uses +//! src/net/libc.zig the libc symbols ESP-Hosted's C reaches for +//! src/net/port.zig `g_h`, the table ESP-Hosted reaches the machine through +//! src/net/hosted_glue.zig logging, event bases, and loud stubs for layers not yet run +//! src/net/ip.zig IPv4/ARP/ICMP/UDP/DHCP/TCP/HTTP, replacing lwIP +//! src/net/link.zig ESP-Hosted's station channel, bridged to that stack +//! +//! ESP-Hosted's transport C sits on top of `port.zig` and is compiled by `hostedC` in build.zig. + +const std = @import("std"); + +pub const libc = @import("libc.zig"); +pub const glue = @import("hosted_glue.zig"); +pub const port = @import("port.zig"); +pub const heap = @import("heap.zig"); +pub const os = @import("hosted_os.zig"); +pub const ip = @import("ip.zig"); +/// The station data path. Separate from `init` on purpose: bringing the transport up and putting an +/// IP stack on the station interface are two decisions, and an application may want the first +/// without the second (examples/radio.zig does). Call `link.open()` once `hosted_wifi_sta_start` +/// has returned. +pub const link = @import("link.zig"); +/// The std.Io implementation. A module rather than a path: Zig confines a module's imports to its +/// own root directory, so src/net/ cannot reach ../io/ by file. build.zig wires it as `io`. +pub const runtime = @import("io"); + +comptime { + _ = libc; + _ = glue; + _ = port; + _ = heap; + _ = os; + _ = ip; + _ = link; + _ = runtime; +} + +/// ESP-Hosted's transport entry point, from +/// host/drivers/transport/transport_drv.h:131. Returns an `esp_err_t`, so 0 is success. The +/// callback fires once the slave has answered and the transport has reached its "active" state, +/// which is the moment the radio becomes usable. +extern fn setup_transport(up_cb: ?*const fn () callconv(.c) void) c_int; + +/// Populate the transport configuration from Kconfig - SDIO slot, bus width, clock, the pin map and +/// the C6 reset pin. From host/api/src/esp_hosted_transport_config.c:25. +/// +/// This is not optional and skipping it does not fail loudly. `esp_hosted_sdio_get_config` hands +/// back a pointer to static storage which starts out all zeroes, so a transport started without +/// this reads slot 0, width 0, 0 kHz, every pin GPIO0 and queue sizes of zero. The first run of +/// examples/radio.zig did exactly that: the only hint was ESP-Hosted warning "provided sdio tx queue +/// size is zero! Setting to 20", and then nothing ever came up. ESP-Hosted's own esp_hosted_init +/// calls this at esp_hosted_api.c:151; this file calls the same function for the same reason. +extern fn esp_hosted_set_default_config() c_int; + +/// True if a configuration has already been set, so `init` can be called twice without clobbering +/// a configuration an application deliberately overrode. +extern fn esp_hosted_is_config_valid() bool; + +/// Attempt the connection to the coprocessor: release its reset, bring the SDIO card up, and run +/// the capability handshake. From host/drivers/transport/transport_drv.h:133. +/// +/// `setup_transport` does NOT do this - it only calls transport_drv_init (bus and threads) and +/// stores the up-callback (transport_drv.c:170-177). ESP-Hosted's own API splits the two the same +/// way: esp_hosted_init calls setup_transport, and esp_hosted_connect_to_slave calls this +/// (esp_hosted_api.c:184). Calling only the first is a transport that exists and never speaks; that +/// is exactly what the third run of examples/radio.zig showed - threads created, nothing allocated, +/// no SDIO traffic, and silence for ten seconds. +extern fn transport_drv_reconfigure() c_int; + +/// Bring the RPC layer up and register its event callbacks. From +/// host/drivers/rpc/wrap/rpc_wrap.h:45,50. +/// +/// Separate from the transport on purpose: the transport is the pipe, RPC is the language spoken +/// over it. esp_hosted_init calls setup_transport and then these two (esp_hosted_api.c:154-156). +/// Skipping them leaves a transport that is genuinely up and a control path that answers every +/// request with "RPC not initialized or transport down, failing fast" - which is what the first +/// Wi-Fi call on this board printed. +/// +/// These must run BEFORE `transport_drv_reconfigure`, and the reason is a single line in +/// ESP-Hosted: `rpc_core_init` ends with `set_rpc_lib_state(RPC_LIB_STATE_INIT)` +/// (rpc_core.c:1164), and the *only* thing that ever raises that state to READY is `rpc_start`, +/// called from `transport_delayed_init` (transport_drv.c:802) on the transport's own RX thread the +/// moment the slave's INIT event is parsed. Call `rpc_init` after the transport is up and +/// `rpc_core_init` stamps INIT over the READY that already happened, with no second writer: both +/// `rpc_rx_thread` and `rpc_tx_thread` then sit in `if (!is_rpc_lib_ready()) _h_sleep(1)` +/// (rpc_core.c:482-485, :543-547) forever. `rpc_send_req` still succeeds - it only enqueues +/// (rpc_core.c:1019) - so every synchronous request is accepted, never transmitted, and returns +/// "Timeout waiting for Resp" ten seconds later. That is exactly the Req_WifiInit failure. +extern fn rpc_init() c_int; +extern fn rpc_register_event_callbacks() c_int; + +/// Set once the C reports the transport up. Read through `isUp`. +var transport_up: bool = false; + +fn onTransportUp() callconv(.c) void { + transport_up = true; +} + +/// True once the C6 has answered and ESP-Hosted's transport has reached its active state. +pub fn isUp() bool { + return transport_up; +} + +pub const Error = error{ ConfigFailed, TransportSetupFailed, SlaveConnectFailed, RpcInitFailed }; + +/// Bring the radio path up, in the one order that works. +/// +/// Each step depends on the one before it: +/// 1. the libc allocator must exist before ESP-Hosted allocates anything, and its very first act +/// is to allocate, +/// 2. the port table must be installed before the transport starts, because the transport reaches +/// the SDIO bus, the clock and its own threads through that table, +/// 3. the transport must be *set up* - bus, queues, threads - before RPC, because `rpc_core_init` +/// opens a serial endpoint on it, +/// 4. RPC must be initialised before the coprocessor is spoken to, because the handshake's own +/// `rpc_start` is what takes the RPC lib from INIT to READY and `rpc_core_init` would +/// overwrite it. See `rpc_init` above, +/// 5. only then is the C6 reset released and the capability handshake run, through our driver. +/// +/// Blocks for the handshake: step 5 is `transport_drv_reconfigure`, which polls the slave every +/// 200 ms (transport_drv.c:219-234). It runs on whatever task calls this, so that task must not be +/// the one printing progress. +pub fn init(io_impl: std.Io, gpa: std.mem.Allocator) Error!void { + libc.install(gpa); + port.install(io_impl, gpa); + // The configuration must exist before the transport reads it. Only set defaults if an + // application has not already provided its own, which is the order esp_hosted_init uses. + if (!esp_hosted_is_config_valid()) { + if (esp_hosted_set_default_config() != 0) return error.ConfigFailed; + } + if (setup_transport(&onTransportUp) != 0) return error.TransportSetupFailed; + + // The control path, before the pipe is opened. This is ESP-Hosted's own order + // (esp_hosted_api.c:154-156 init, then esp_hosted_api.c:184 connect) and it is load-bearing, + // not cosmetic: see the comment on `rpc_init` above. With RPC initialised first, `rpc_start` + // from the handshake is the last writer of the RPC lib state, and the reader and writer threads + // leave their not-ready loop for good. + if (rpc_init() != 0) return error.RpcInitFailed; + if (rpc_register_event_callbacks() != 0) return error.RpcInitFailed; + + // And now actually talk to the coprocessor. Blocks until the slave answers. + if (transport_drv_reconfigure() != 0) return error.SlaveConnectFailed; +} diff --git a/src/net/heap.zig b/src/net/heap.zig new file mode 100644 index 0000000..73ebcf2 --- /dev/null +++ b/src/net/heap.zig @@ -0,0 +1,673 @@ +//! Two allocators, because ESP-Hosted needs two different things and `std` supplies neither. +//! +//! **`Heap`** is a general-purpose allocator over a caller-supplied static buffer, exposed through +//! the ordinary `std.mem.Allocator` vtable. Writing one was not the first choice. `std.heap` was +//! read first, and nothing in it fits this workload: +//! +//! * `FixedBufferAllocator` can only free the *most recent* allocation. ESP-Hosted frees +//! per-packet buffers in whatever order the radio finishes with them. +//! * `ArenaAllocator` cannot reclaim at all until the whole arena dies, and this arena never dies. +//! * `BrkAllocator` (`std/heap/BrkAllocator.zig:38`) rounds its backing store to +//! `@max(64 * 1024, page_size_max)` per *size class*. One 64 KiB page per class does not fit in +//! a 128 KiB L2MEM, let alone the ~48 KiB this heap is meant to occupy. +//! * `DebugAllocator` is page-granular and carries per-page metadata for a debugging feature set +//! nothing here wants. +//! * `SmpAllocator`, `PageAllocator`, `c_allocator` all need an OS. +//! +//! So `Heap` is a K&R-style allocator: one address-sorted free list, coalescing on free, first fit. +//! The workload it is sized for is the one the parent measured - `mempool.c` recycling fixed-size +//! buffers - which is exactly the pattern that keeps a coalescing free list short and its first fit +//! O(1) in practice: freed blocks of one size are handed straight back out. +//! +//! **`CHeap`** is the part that has nothing to do with which allocator is underneath. C's `free` +//! takes a bare pointer and no length, and `std.mem.Allocator.rawFree` requires both the exact +//! length and the original alignment. `CHeap` recovers them from an eight-byte header stored +//! immediately below every pointer it hands out. That header is the price of the C ABI and it is +//! paid per allocation: +//! +//! _h_malloc(n) -> 8 bytes overhead +//! _h_malloc_align(n, 64) -> 64 bytes overhead (the header plus the alignment slack) +//! +//! `CHeap` is written against `std.mem.Allocator`, not against `Heap`, so `install()` can be handed +//! any allocator at all and the C side does not change. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Alignment = std.mem.Alignment; + +// ---------------------------------------------------------------------------------------- Heap + +/// A coalescing free-list allocator over one contiguous buffer. +/// +/// Not thread-safe, and deliberately so: this runs under a cooperative single-core scheduler where +/// no task can be preempted between two instructions, and every entry point here runs to completion +/// without yielding. An interrupt handler must never allocate - see `port.zig`, which never does. +pub const Heap = struct { + base: [*]align(granule) u8, + /// Arena length in bytes, always a multiple of `granule`. + len: u32, + /// Offset of the first free block's header, or `null_off`. + free_head: u32, + + /// Every block header and every payload is 8-byte aligned. Eight is `_Alignof(max_align_t)` on + /// rv32 (`long long` and `double` are 8-byte aligned), which is the weakest guarantee C's + /// `malloc` is allowed to make, so it is also the strongest one a caller may assume. + pub const granule = 8; + + /// Free blocks store their list link in the payload, so a block must hold a header plus one + /// link. Any split that would leave less than this is absorbed into the neighbour instead. + const min_block = @sizeOf(Block) + granule; + const null_off: u32 = std.math.maxInt(u32); + + /// Header of every block, allocated or free. + /// + /// `next` is only meaningful while the block is on the free list; in an allocated block it + /// holds `alloc_magic`, which turns a double free or a wild pointer into an assertion instead + /// of a corrupted list. + const Block = extern struct { + /// Total bytes of this block *including* the header. Always a multiple of `granule`. + size: u32, + next: u32, + + const alloc_magic: u32 = 0xA110_C8ED; + }; + + comptime { + assert(@sizeOf(Block) == granule); + assert(@alignOf(Block) <= granule); + } + + /// Take ownership of `buffer`. The whole buffer becomes one free block; nothing else is stored + /// outside it, so the heap's own footprint is `@sizeOf(Heap)` (12 bytes) plus the buffer. + pub fn init(buffer: []align(granule) u8) Heap { + const usable: u32 = @intCast(buffer.len & ~@as(usize, granule - 1)); + assert(usable >= min_block); + var h: Heap = .{ .base = buffer.ptr, .len = usable, .free_head = 0 }; + const first = h.blockAt(0); + first.* = .{ .size = usable, .next = null_off }; + return h; + } + + pub fn allocator(h: *Heap) Allocator { + return .{ .ptr = h, .vtable = &.{ + .alloc = alloc, + .resize = resize, + .remap = remap, + .free = freeFn, + } }; + } + + inline fn blockAt(h: *Heap, off: u32) *Block { + assert(off + @sizeOf(Block) <= h.len); + return @ptrCast(@alignCast(h.base + off)); + } + + inline fn payloadOf(h: *Heap, off: u32) [*]u8 { + return h.base + off + @sizeOf(Block); + } + + fn alloc(ctx: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 { + _ = ret_addr; + const h: *Heap = @ptrCast(@alignCast(ctx)); + + // A zero-length allocation still needs a distinct address with a valid header, because it + // will be handed back to `free` with its length and must be findable. + const want: u32 = std.math.cast(u32, std.mem.alignForward(usize, @max(len, granule), granule)) orelse return null; + const a: u32 = @intCast(@max(granule, alignment.toByteUnits())); + + var prev: u32 = null_off; + var cur: u32 = h.free_head; + while (cur != null_off) { + const blk = h.blockAt(cur); + + // Where the payload would land if the block were used as-is, and how far it has to + // move to satisfy `a`. A gap smaller than `min_block` cannot become its own free + // block, so step one whole alignment further - the block was sized for that case. + const natural = @intFromPtr(h.payloadOf(cur)); + var gap: u32 = @intCast(std.mem.alignForward(usize, natural, a) - natural); + if (gap != 0 and gap < min_block) gap += a; + + const need = gap + @sizeOf(Block) + want; + if (blk.size < need) { + prev = cur; + cur = blk.next; + continue; + } + + // The block that will be handed out starts `gap` bytes into the free block. When + // `gap` is zero that is the free block itself, which then leaves the list. + const alloc_off = cur + gap; + var alloc_size = blk.size - gap; + const tail_off = alloc_off + @sizeOf(Block) + want; + const tail_size = alloc_size - @sizeOf(Block) - want; + + if (gap == 0) { + h.unlink(prev, cur); + } else { + // The leading gap stays a free block at the same address, so the list order is + // unchanged and no relinking is needed. + blk.size = gap; + } + + if (tail_size >= min_block) { + alloc_size -= tail_size; + const tail = h.blockAt(tail_off); + tail.* = .{ .size = tail_size, .next = undefined }; + h.insert(tail_off); + } + + const out = h.blockAt(alloc_off); + out.* = .{ .size = alloc_size, .next = Block.alloc_magic }; + const payload = h.payloadOf(alloc_off); + assert(@intFromPtr(payload) % a == 0); + return payload; + } + return null; + } + + fn resize(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool { + _ = alignment; + _ = ret_addr; + const h: *Heap = @ptrCast(@alignCast(ctx)); + const off = h.offsetOfPayload(memory.ptr); + const blk = h.blockAt(off); + assert(blk.next == Block.alloc_magic); + + const want: u32 = std.math.cast(u32, std.mem.alignForward(usize, @max(new_len, granule), granule)) orelse return false; + const have = blk.size - @sizeOf(Block); + if (want <= have) { + // Shrink: give the tail back if it is big enough to be a block of its own. + const tail_size = have - want; + if (tail_size >= min_block) { + blk.size -= tail_size; + const tail_off = off + @sizeOf(Block) + want; + h.blockAt(tail_off).* = .{ .size = tail_size, .next = undefined }; + h.insert(tail_off); + } + return true; + } + + // Grow in place only by swallowing the physically adjacent free block, which is the case + // that matters: `serial_ll_if.c:282` reassembles a fragmented RPC response by repeatedly + // reallocating the same buffer upward with nothing allocated after it. + const next_off = off + blk.size; + if (next_off >= h.len) return false; + const prev_link = h.findFreePredecessor(next_off) orelse return false; + const next = h.blockAt(next_off); + if (blk.size + next.size < @sizeOf(Block) + want) return false; + + h.unlink(prev_link, next_off); + blk.size += next.size; + const tail_size = blk.size - @sizeOf(Block) - want; + if (tail_size >= min_block) { + blk.size -= tail_size; + const tail_off = off + @sizeOf(Block) + want; + h.blockAt(tail_off).* = .{ .size = tail_size, .next = undefined }; + h.insert(tail_off); + } + return true; + } + + fn remap(ctx: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 { + // Relocation is never cheaper here than the caller's own alloc/copy/free, because this + // allocator cannot move a block without copying it either. + return if (resize(ctx, memory, alignment, new_len, ret_addr)) memory.ptr else null; + } + + fn freeFn(ctx: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void { + _ = alignment; + _ = ret_addr; + const h: *Heap = @ptrCast(@alignCast(ctx)); + const off = h.offsetOfPayload(memory.ptr); + // A double free lands here with `next` already holding a list offset rather than the + // magic, and would otherwise splice the block into the free list twice. + assert(h.blockAt(off).next == Block.alloc_magic); + h.insert(off); + } + + fn offsetOfPayload(h: *Heap, p: [*]u8) u32 { + const delta = @intFromPtr(p) - @intFromPtr(h.base); + assert(delta >= @sizeOf(Block) and delta < h.len); + return @intCast(delta - @sizeOf(Block)); + } + + /// Splice `off` out of the free list. `prev` is its predecessor, or `null_off` if it is head. + fn unlink(h: *Heap, prev: u32, off: u32) void { + const nxt = h.blockAt(off).next; + if (prev == null_off) h.free_head = nxt else h.blockAt(prev).next = nxt; + } + + /// The free-list predecessor of `off`, or null if `off` is not on the free list at all. + /// `null_off` is returned when `off` is the head, mirroring `unlink`'s convention. + fn findFreePredecessor(h: *Heap, off: u32) ?u32 { + var prev: u32 = null_off; + var cur = h.free_head; + while (cur != null_off) : ({ + prev = cur; + cur = h.blockAt(cur).next; + }) { + if (cur == off) return prev; + if (cur > off) return null; + } + return null; + } + + /// Insert a block into the address-sorted free list, coalescing with either neighbour it + /// physically touches. Address order is what makes coalescing a pointer comparison rather than + /// a search, and it is why the list is sorted at all. + fn insert(h: *Heap, off: u32) void { + var prev: u32 = null_off; + var cur = h.free_head; + while (cur != null_off and cur < off) : ({ + prev = cur; + cur = h.blockAt(cur).next; + }) {} + + const blk = h.blockAt(off); + blk.next = cur; + if (prev == null_off) h.free_head = off else h.blockAt(prev).next = off; + + if (cur != null_off and off + blk.size == cur) { + const nxt = h.blockAt(cur); + blk.size += nxt.size; + blk.next = nxt.next; + } + if (prev != null_off) { + const p = h.blockAt(prev); + if (prev + p.size == off) { + p.size += blk.size; + p.next = blk.next; + } + } + } + + pub const Stats = struct { + /// Bytes in the arena, header overhead included. + total: u32, + /// Bytes on the free list, header overhead included. + free: u32, + /// Largest single free block, which is the largest allocation that can still succeed + /// (less one header, less alignment slack). + largest_free: u32, + free_blocks: u32, + }; + + pub fn stats(h: *Heap) Stats { + var s: Stats = .{ .total = h.len, .free = 0, .largest_free = 0, .free_blocks = 0 }; + var cur = h.free_head; + while (cur != null_off) : (cur = h.blockAt(cur).next) { + const size = h.blockAt(cur).size; + s.free += size; + s.free_blocks += 1; + if (size > s.largest_free) s.largest_free = size; + } + return s; + } + + /// Walk the free list and assert every invariant. Used by the tests; also usable from a + /// hardware self-test, where a corrupted list is otherwise invisible until it is fatal. + pub fn check(h: *Heap) void { + var cur = h.free_head; + var prev: u32 = null_off; + while (cur != null_off) { + const blk = h.blockAt(cur); + assert(blk.size >= min_block); + assert(blk.size % granule == 0); + assert(cur % granule == 0); + assert(cur + blk.size <= h.len); + if (prev != null_off) { + // Sorted, and never two free blocks that touch: `insert` would have merged them. + assert(prev < cur); + assert(prev + h.blockAt(prev).size < cur); + } + prev = cur; + cur = blk.next; + } + } +}; + +// --------------------------------------------------------------------------------------- CHeap + +/// C `malloc`/`free`/`realloc` semantics on top of any `std.mem.Allocator`. +/// +/// The whole reason this type exists is that `free(p)` carries no size and `rawFree` demands one. +/// Every pointer handed to C therefore has a `Header` in the eight bytes below it, holding what +/// `rawFree` needs: the exact length that was allocated, and the distance back to the base pointer. +/// +/// The alignment passed to the backing allocator is always `granule` (8). Stronger alignments are +/// satisfied *inside* the allocation by over-allocating and moving the payload up, rather than by +/// asking the backing allocator for them - which keeps the header immediately below the payload in +/// every case, and means `Heap` only ever sees one alignment. +pub const CHeap = struct { + gpa: Allocator, + + /// Live bytes as seen by C, i.e. what was asked for, not what was consumed. `bytes_reserved` + /// is the honest number. + bytes_live: usize = 0, + bytes_reserved: usize = 0, + peak_reserved: usize = 0, + blocks_live: usize = 0, + /// Allocations that returned NULL. Nonzero means the heap is too small; ESP-Hosted logs and + /// limps on rather than failing loudly, so this counter is the only durable evidence. + failures: usize = 0, + + pub const granule = Heap.granule; + + const Header = extern struct { + /// Bytes passed to `rawAlloc`, and therefore the length `rawFree` must be given. + total: u32, + /// `payload - base`. Between `granule` and the requested alignment, inclusive. + offset: u16, + /// `log2` of the alignment C asked for. Kept for `realloc`, which must preserve it. + log2_align: u8, + magic: u8, + + const value: u8 = 0x48; // 'H' + }; + + comptime { + assert(@sizeOf(Header) == granule); + assert(@alignOf(Header) <= granule); + } + + /// The strongest alignment expressible in `Header.offset`. ESP-Hosted asks for at most 64 + /// (`HOSTED_MEM_ALIGNMENT_64`, port_esp_hosted_host_os.h:95). + pub const max_alignment = 1 << 15; + + pub fn malloc(c: *CHeap, size: usize) ?[*]u8 { + return c.mallocAligned(size, granule); + } + + /// `size` is rounded up to a multiple of `alignment` before allocating, which is what + /// `heap_caps_aligned_alloc` does and therefore what `_h_malloc_align`'s callers get today. + /// It matters for DMA: a buffer whose *end* is not aligned shares its last cache line with + /// whatever follows it. + pub fn mallocAligned(c: *CHeap, size: usize, alignment: usize) ?[*]u8 { + assert(std.math.isPowerOfTwo(alignment)); + assert(alignment <= max_alignment); + const a = @max(granule, alignment); + + const payload = std.mem.alignForward(usize, @max(size, 1), a); + // `a` bytes of slack is always enough: the base is `granule`-aligned, the header needs + // `granule` of that slack, and moving up to the next `a` boundary costs at most `a - + // granule` more. + const total = std.math.add(usize, payload, a) catch { + c.failures += 1; + return null; + }; + + const base = c.gpa.rawAlloc(total, .fromByteUnits(granule), @returnAddress()) orelse { + c.failures += 1; + return null; + }; + const user_addr = std.mem.alignForward(usize, @intFromPtr(base) + @sizeOf(Header), a); + const offset = user_addr - @intFromPtr(base); + assert(offset >= @sizeOf(Header) and offset <= a); + assert(offset + payload <= total); + + const user: [*]u8 = @ptrFromInt(user_addr); + headerOf(user).* = .{ + .total = @intCast(total), + .offset = @intCast(offset), + .log2_align = @intCast(std.math.log2_int(usize, a)), + .magic = Header.value, + }; + + c.bytes_live += size; + c.bytes_reserved += total; + c.blocks_live += 1; + if (c.bytes_reserved > c.peak_reserved) c.peak_reserved = c.bytes_reserved; + return user; + } + + pub fn calloc(c: *CHeap, count: usize, size: usize) ?[*]u8 { + const n = std.math.mul(usize, count, size) catch { + c.failures += 1; + return null; + }; + const p = c.malloc(n) orelse return null; + @memset(p[0..n], 0); + return p; + } + + pub fn free(c: *CHeap, ptr: ?[*]u8) void { + const user = ptr orelse return; + const h = headerOf(user).*; + assert(h.magic == Header.value); + const base: [*]u8 = @ptrFromInt(@intFromPtr(user) - h.offset); + // Poison the magic so a second free asserts here rather than corrupting the backing + // allocator's own bookkeeping several calls later. + headerOf(user).magic = 0; + + c.bytes_live -|= usableLen(h); + c.bytes_reserved -= h.total; + c.blocks_live -= 1; + c.gpa.rawFree(base[0..h.total], .fromByteUnits(granule), @returnAddress()); + } + + /// C `realloc`: null pointer means allocate, zero size means free, and the old contents are + /// preserved up to the smaller of the two sizes. + /// + /// Growth in place is attempted first. `serial_ll_if.c:282` reassembles a fragmented RPC + /// response by calling this in a loop on the same buffer, so a `realloc` that always copies + /// turns an n-fragment response into O(n^2) bytes moved. + pub fn realloc(c: *CHeap, ptr: ?[*]u8, new_size: usize) ?[*]u8 { + const user = ptr orelse return c.malloc(new_size); + if (new_size == 0) { + c.free(user); + return null; + } + + const h = headerOf(user).*; + assert(h.magic == Header.value); + const a = @as(usize, 1) << @intCast(h.log2_align); + const old_usable = usableLen(h); + if (new_size <= old_usable) return user; + + const base: [*]u8 = @ptrFromInt(@intFromPtr(user) - h.offset); + const new_total = std.math.add(usize, std.mem.alignForward(usize, new_size, a), a) catch { + c.failures += 1; + return null; + }; + if (c.gpa.rawResize(base[0..h.total], .fromByteUnits(granule), new_total, @returnAddress())) { + c.bytes_live += new_size - old_usable; + c.bytes_reserved += new_total - h.total; + if (c.bytes_reserved > c.peak_reserved) c.peak_reserved = c.bytes_reserved; + headerOf(user).total = @intCast(new_total); + return user; + } + + const fresh = c.mallocAligned(new_size, a) orelse return null; + @memcpy(fresh[0..old_usable], user[0..old_usable]); + c.free(user); + return fresh; + } + + /// Bytes the caller may legitimately touch. Larger than what was asked for whenever the + /// request was rounded up to the alignment. + fn usableLen(h: Header) usize { + return h.total - h.offset; + } + + inline fn headerOf(user: [*]u8) *Header { + return @ptrFromInt(@intFromPtr(user) - @sizeOf(Header)); + } +}; + +// ---------------------------------------------------------------------------------------- tests + +const testing = std.testing; + +fn testHeap(comptime bytes: usize) struct { buf: []align(Heap.granule) u8, heap: Heap } { + const buf = testing.allocator.alignedAlloc(u8, .fromByteUnits(Heap.granule), bytes) catch unreachable; + return .{ .buf = buf, .heap = Heap.init(buf) }; +} + +test "Heap: alloc, free, and reuse of a hole in the middle" { + var t = testHeap(4096); + defer testing.allocator.free(t.buf); + const a = t.heap.allocator(); + + const p0 = try a.alloc(u8, 64); + const p1 = try a.alloc(u8, 64); + const p2 = try a.alloc(u8, 64); + t.heap.check(); + + // Free the middle one. An arena or a FixedBufferAllocator cannot give this back; the whole + // point of this allocator is that the next 64-byte request lands right here. + a.free(p1); + t.heap.check(); + const p3 = try a.alloc(u8, 64); + try testing.expectEqual(p1.ptr, p3.ptr); + + a.free(p0); + a.free(p2); + a.free(p3); + t.heap.check(); + // Everything coalesced back into one block. + const s = t.heap.stats(); + try testing.expectEqual(@as(u32, 1), s.free_blocks); + try testing.expectEqual(s.total, s.free); +} + +test "Heap: the malloc/free/realloc churn that defeats an arena" { + var t = testHeap(16 * 1024); + defer testing.allocator.free(t.buf); + const a = t.heap.allocator(); + + // mempool.c's pattern: allocate a batch of same-size buffers, release them in a scrambled + // order, allocate the same batch again. An arena's high-water mark would double each round; + // this must not grow at all. + var live: [16][]u8 = undefined; + const order = [_]usize{ 7, 0, 15, 3, 11, 1, 9, 4, 13, 2, 8, 6, 14, 5, 12, 10 }; + + for (&live) |*slot| slot.* = try a.alloc(u8, 200); + const after_first_round = t.heap.stats().free; + + for (0..8) |_| { + for (order) |i| a.free(live[i]); + t.heap.check(); + for (&live) |*slot| slot.* = try a.alloc(u8, 200); + t.heap.check(); + try testing.expectEqual(after_first_round, t.heap.stats().free); + } + for (live) |slot| a.free(slot); + + // Interleave reallocs that grow past their block, which is the serial reassembly path. + var grow = try a.alloc(u8, 32); + @memset(grow, 0xAB); + var n: usize = 64; + while (n <= 2048) : (n *= 2) { + const old_len = grow.len; + grow = try a.realloc(grow, n); + try testing.expect(std.mem.allEqual(u8, grow[0..old_len], 0xAB)); + @memset(grow[old_len..], 0xAB); + t.heap.check(); + } + a.free(grow); + t.heap.check(); + try testing.expectEqual(t.heap.stats().total, t.heap.stats().free); +} + +test "Heap: strong alignment splits the leading gap back into the free list" { + var t = testHeap(8192); + defer testing.allocator.free(t.buf); + const a = t.heap.allocator(); + + // 64-byte alignment is what _h_malloc_align asks for on the SDIO data path. + var blocks: [8][]align(64) u8 = undefined; + for (&blocks, 0..) |*b, i| { + b.* = try a.alignedAlloc(u8, .@"64", 100 + i); + try testing.expectEqual(@as(usize, 0), @intFromPtr(b.ptr) % 64); + } + t.heap.check(); + for (blocks) |b| a.free(b); + t.heap.check(); + try testing.expectEqual(t.heap.stats().total, t.heap.stats().free); +} + +test "Heap: exhaustion returns null rather than trampling the arena" { + var t = testHeap(1024); + defer testing.allocator.free(t.buf); + const a = t.heap.allocator(); + + var held: [64][]u8 = undefined; + var n: usize = 0; + while (n < held.len) : (n += 1) { + held[n] = a.alloc(u8, 64) catch break; + } + try testing.expect(n > 0 and n < held.len); + try testing.expectError(error.OutOfMemory, a.alloc(u8, 64)); + t.heap.check(); + for (held[0..n]) |b| a.free(b); + t.heap.check(); + try testing.expectEqual(t.heap.stats().total, t.heap.stats().free); +} + +test "CHeap: malloc/free/realloc against the C ABI, over the Heap" { + var t = testHeap(16 * 1024); + defer testing.allocator.free(t.buf); + var c: CHeap = .{ .gpa = t.heap.allocator() }; + + const p = c.malloc(100).?; + @memset(p[0..100], 0x5A); + try testing.expectEqual(@as(usize, 0), @intFromPtr(p) % CHeap.granule); + try testing.expectEqual(@as(usize, 1), c.blocks_live); + + // realloc must preserve contents across a move. + const q = c.realloc(p, 4000).?; + try testing.expect(std.mem.allEqual(u8, q[0..100], 0x5A)); + // Shrinking inside the same block returns the same pointer, as C permits. + try testing.expectEqual(q, c.realloc(q, 8).?); + c.free(q); + try testing.expectEqual(@as(usize, 0), c.blocks_live); + try testing.expectEqual(@as(usize, 0), c.bytes_reserved); + + // calloc zeroes. + const z = c.calloc(10, 16).?; + try testing.expect(std.mem.allEqual(u8, z[0..160], 0)); + c.free(z); + + // free(NULL) is a no-op, and realloc(NULL, n) is malloc. + c.free(null); + const r = c.realloc(null, 32).?; + // realloc(p, 0) frees and yields NULL. + try testing.expectEqual(@as(?[*]u8, null), c.realloc(r, 0)); + try testing.expectEqual(@as(usize, 0), c.blocks_live); + + t.heap.check(); + try testing.expectEqual(t.heap.stats().total, t.heap.stats().free); +} + +test "CHeap: _h_malloc_align(n, 64) is 64-aligned at both ends and frees exactly" { + // 12 x (1536 rounded to 64, plus 64 of header and slack) = 19,200 bytes, plus block headers. + var t = testHeap(24 * 1024); + defer testing.allocator.free(t.buf); + var c: CHeap = .{ .gpa = t.heap.allocator() }; + + var held: [12][*]u8 = undefined; + for (&held, 0..) |*slot, i| { + slot.* = c.mallocAligned(1536 - i, 64).?; + try testing.expectEqual(@as(usize, 0), @intFromPtr(slot.*) % 64); + } + // 64-byte alignment costs exactly 64 bytes of overhead per buffer: the eight-byte header plus + // the slack that moves the payload onto the boundary. + try testing.expectEqual(@as(usize, 12), c.blocks_live); + for (held) |slot| c.free(slot); + try testing.expectEqual(@as(usize, 0), c.bytes_reserved); + t.heap.check(); + try testing.expectEqual(t.heap.stats().total, t.heap.stats().free); +} + +test "CHeap: allocation failure is reported, not fatal" { + var t = testHeap(1024); + defer testing.allocator.free(t.buf); + var c: CHeap = .{ .gpa = t.heap.allocator() }; + + try testing.expectEqual(@as(?[*]u8, null), c.malloc(100_000)); + try testing.expectEqual(@as(usize, 1), c.failures); + // The heap is untouched by the failure. + t.heap.check(); + try testing.expectEqual(t.heap.stats().total, t.heap.stats().free); +} diff --git a/src/net/hosted/abi_assert.c b/src/net/hosted/abi_assert.c new file mode 100644 index 0000000..8815e89 --- /dev/null +++ b/src/net/hosted/abi_assert.c @@ -0,0 +1,78 @@ +/* + * The one ABI check that stands between this build and a silent hang. + * + * `hosted_osi_funcs_t` (host/esp_hosted_os_abstraction.h) is the function-pointer table ESP-Hosted + * reaches everything through - memory, sync, threads, GPIO, SDIO. src/net/port.zig defines it in + * Zig. Zig can assert its own layout; it cannot assert C's. This file asserts C's, so the two are + * checked against each other at build time. + * + * Why this is not paranoia. Four of the table's entries are guarded: + * + * #ifdef H_USE_MEMPOOL <- host/esp_hosted_os_abstraction.h:64-69 + * void *(*_h_get_mempool)(...); + * ... + * #endif + * + * `#ifdef`, not `#if`. H_USE_MEMPOOL is defined by + * host/port/esp/freertos/include/port_esp_hosted_host_config.h:127-131 - to 1 or to 0, but always + * DEFINED. So the four pointers are present in any translation unit that saw that header, and + * absent in any that did not, and every entry after them shifts by four pointers. + * + * That is reachable, not theoretical: host/esp_hosted.h:14 and + * host/drivers/transport/transport_util.h:10 both include esp_hosted_os_abstraction.h as their + * FIRST include, so a TU reaching the struct through either of those - before any port header - + * gets the short layout. Under IDF's CMake the ordering happens to work out. Under our flags it + * would be luck. + * + * Measured with our exact flags, both ways: + * + * sizeof _h_config_gpio _h_event_post + * without the force-include 268 132 264 + * with the force-include 284 148 280 + * + * Sixteen bytes. A TU with the short layout calling _h_config_gpio jumps through a mempool + * pointer instead - which is a jump to the wrong function, on a board with no debugger, and the + * symptom would look exactly like the SDIO bus failing to come up. + * + * build.zig therefore force-includes port_esp_hosted_host_config.h into every ESP-Hosted + * translation unit, and compiles this file to assert that it worked. The numbers below are the + * long (correct) layout. + */ + +#include "esp_hosted_os_abstraction.h" +#include + +/* The guard must be visible here, or this file is asserting the wrong layout and proving nothing. */ +#ifndef H_USE_MEMPOOL +#error "H_USE_MEMPOOL is not visible: the force-include of port_esp_hosted_host_config.h is missing." +#endif + +_Static_assert( + sizeof(hosted_osi_funcs_t) == 284, + "hosted_osi_funcs_t is not the 284-byte layout. Either the force-include of " + "port_esp_hosted_host_config.h was lost (short layout, 268), or ESP-Hosted changed the table. " + "Compare against the struct in src/net/port.zig before touching this number."); + +/* Two offsets, chosen because they sit on either side of the mempool block: the first entry after + * it, and one near the end. If the block appears or disappears, both move. */ +_Static_assert( + offsetof(hosted_osi_funcs_t, _h_config_gpio) == 148, + "_h_config_gpio moved. It is the first entry after the #ifdef H_USE_MEMPOOL block, so this is " + "what the short layout breaks first: 132 instead of 148."); + +_Static_assert( + offsetof(hosted_osi_funcs_t, _h_event_post) == 280, + "_h_event_post moved. Together with the _h_config_gpio assertion this pins both ends of the " + "table."); + +/* Field count, checked through the size. src/net/port.zig asserts its Zig struct has 71 fields; + * every entry is a pointer, so 71 * 4 must be the size on this 32-bit target. A size check alone + * would not catch a field deleted in one place and duplicated in another - the length survives and + * the two sides silently disagree about which pointer is which. */ +_Static_assert( + sizeof(hosted_osi_funcs_t) == 71 * sizeof(void (*)(void)), + "hosted_osi_funcs_t is not 71 function pointers. Compare field by field against the struct in " + "src/net/port.zig - a count mismatch means one side has an entry the other does not, and every " + "entry after it calls the wrong function."); + +const int esp_hosted_abi_assertions_hold = 1; diff --git a/src/net/hosted/include_dirs.txt b/src/net/hosted/include_dirs.txt new file mode 100644 index 0000000..75ee2d3 --- /dev/null +++ b/src/net/hosted/include_dirs.txt @@ -0,0 +1,171 @@ +# Include directories ESP-Hosted's C needs, in order. +# +# Provenance: extracted from the -I flags ESP-IDF v6.0.2 used to compile +# host/drivers/transport/transport_drv.c in 02-esp32p4-m3-radio/build/compile_commands.json - +# the build that worked on this board. Order is IDF's and matters: esp_wifi_remote's +# idf_v6.0/include/injected must precede components/esp_wifi/include, because a host with no +# radio needs the injected Wi-Fi types rather than the real ones. +# +# IDF/ -> relative to the ESP-IDF checkout +# MC/ -> relative to 02-esp32p4-m3-radio (the managed_components tree) +# +# The one path dropped from IDF's list is build/config, the generated Kconfig header. This +# project supplies its own copy as src/net/hosted/sdkconfig.h. +MC/managed_components/espressif__esp_hosted/host +MC/managed_components/espressif__esp_hosted/host/api/include +MC/managed_components/espressif__esp_hosted/host/drivers/transport +MC/managed_components/espressif__esp_hosted/host/drivers/transport/spi +MC/managed_components/espressif__esp_hosted/host/drivers/transport/sdio +MC/managed_components/espressif__esp_hosted/host/drivers/serial +MC/managed_components/espressif__esp_hosted/host/utils +MC/managed_components/espressif__esp_hosted/host/api/priv +MC/managed_components/espressif__esp_hosted/host/drivers/rpc/core +MC/managed_components/espressif__esp_hosted/host/drivers/rpc/slaveif +MC/managed_components/espressif__esp_hosted/host/drivers/rpc/wrap +MC/managed_components/espressif__esp_hosted/host/drivers/virtual_serial_if +MC/managed_components/espressif__esp_hosted/common +MC/managed_components/espressif__esp_hosted/common/log +MC/managed_components/espressif__esp_hosted/common/rpc +MC/managed_components/espressif__esp_hosted/common/transport +MC/managed_components/espressif__esp_hosted/common/protobuf-c +MC/managed_components/espressif__esp_hosted/common/proto +MC/managed_components/espressif__esp_hosted/common/mempool/include +MC/managed_components/espressif__esp_hosted/common/utils +MC/managed_components/espressif__esp_hosted/host/drivers/bt +MC/managed_components/espressif__esp_hosted/host/drivers/power_save +MC/managed_components/espressif__esp_hosted/host/port/esp/freertos/include +IDF/components/esp_libc/platform_include +IDF/components/freertos/config/include +IDF/components/freertos/config/include/freertos +IDF/components/freertos/config/riscv/include +IDF/components/freertos/FreeRTOS-Kernel/include +IDF/components/freertos/FreeRTOS-Kernel/portable/riscv/include +IDF/components/freertos/FreeRTOS-Kernel/portable/riscv/include/freertos +IDF/components/freertos/esp_additions/include +IDF/components/esp_hw_support/include +IDF/components/esp_hw_support/include/soc +IDF/components/esp_hw_support/ldo/include +IDF/components/esp_hw_support/debug_probe/include +IDF/components/esp_hw_support/etm/include +IDF/components/esp_hw_support/mspi_timing_tuning/include +IDF/components/esp_hw_support/mspi_timing_tuning/tuning_scheme_impl/include +IDF/components/esp_hw_support/power_supply/include +IDF/components/esp_hw_support/modem/include +IDF/components/esp_hw_support/port/esp32p4/. +IDF/components/esp_hw_support/port/esp32p4/include +IDF/components/esp_hw_support/port/esp32p4/private_include +IDF/components/esp_hw_support/mspi_timing_tuning/port/esp32p4/. +IDF/components/heap/include +IDF/components/heap/tlsf +IDF/components/log/include +IDF/components/soc/include +IDF/components/soc/esp32p4 +IDF/components/soc/esp32p4/include +IDF/components/soc/esp32p4/register/hw_ver1 +IDF/components/hal/platform_port/include +IDF/components/hal/esp32p4/include +IDF/components/hal/include +IDF/components/esp_rom/include +IDF/components/esp_rom/esp32p4/include +IDF/components/esp_rom/esp32p4/include/esp32p4 +IDF/components/esp_rom/esp32p4 +IDF/components/esp_common/include +IDF/components/esp_system/include +IDF/components/esp_system/port/soc +IDF/components/esp_system/port/include/riscv +IDF/components/esp_system/port/include/private +IDF/components/esp_stdio/include +IDF/components/riscv/include +IDF/components/esp_hal_gpio/include +IDF/components/esp_hal_gpio/esp32p4/include +IDF/components/esp_hal_usb/include +IDF/components/esp_hal_usb/esp32p4/include +IDF/components/esp_hal_pmu/include +IDF/components/esp_hal_pmu/esp32p4/include +IDF/components/esp_hal_ana_conv/include +IDF/components/esp_hal_ana_conv/esp32p4/include +IDF/components/esp_hal_dma/include +IDF/components/esp_hal_dma/esp32p4/include +IDF/components/lwip/include +IDF/components/lwip/include/apps +IDF/components/lwip/lwip/src/include +IDF/components/lwip/port/include +IDF/components/lwip/port/freertos/include +IDF/components/lwip/port/esp32xx/include +IDF/components/lwip/port/esp32xx/include/arch +IDF/components/lwip/port/esp32xx/include/sys +IDF/components/esp_driver_sdmmc/include +IDF/components/esp_driver_sdmmc/legacy/include +IDF/components/esp_driver_sd_intf/include +IDF/components/sdmmc/include +IDF/components/esp_blockdev/include +IDF/components/esp_hal_sd/include +IDF/components/esp_hal_sd/esp32p4/include +IDF/components/esp_driver_spi/include +IDF/components/esp_pm/include +IDF/components/esp_hal_gpspi/include +IDF/components/esp_hal_gpspi/esp32p4/include +IDF/components/esp_driver_dma/include +IDF/components/esp_driver_uart/include +IDF/components/esp_hal_uart/include +IDF/components/esp_hal_uart/esp32p4/include +IDF/components/vfs/include +IDF/components/esp_driver_gpio/include +IDF/components/esp_event/include +IDF/components/esp_netif/include +IDF/components/esp_timer/include +IDF/components/driver/i2c/include +IDF/components/driver/touch_sensor/include +IDF/components/driver/twai/include +IDF/components/esp_hal_i2c/esp32p4/include +IDF/components/esp_hal_i2c/include +IDF/components/esp_hal_twai/include +IDF/components/esp_hal_twai/esp32p4/include +IDF/components/esp_hal_touch_sens/esp32p4/include +IDF/components/esp_hal_touch_sens/include +MC/managed_components/espressif__esp_wifi_remote/idf_v6.0/include/injected +IDF/components/esp_wifi/include +IDF/components/esp_wifi/wifi_apps/nan_app/include +MC/managed_components/espressif__esp_wifi_remote/include +MC/managed_components/espressif__esp_wifi_remote/idf_v6.0/include +IDF/components/bt/common/osi/include +IDF/components/bt/common/api/include/api +IDF/components/bt/common/btc/profile/esp/blufi/include +IDF/components/bt/common/btc/profile/esp/include +IDF/components/bt/common/hci_log/include +IDF/components/bt/common/ble_log/include +IDF/components/bt/common/ble_log/deprecated/include +IDF/components/bt/common/tinycrypt/include +IDF/components/bt/common/tinycrypt/port +IDF/components/bt/host/nimble/nimble/nimble/host/include +IDF/components/bt/host/nimble/nimble/nimble/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/ans/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/bas/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/dis/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/gap/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/gatt/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/hr/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/htp/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/ias/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/ipss/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/lls/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/prox/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/cts/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/tps/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/hid/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/sps/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/cte/include +IDF/components/bt/host/nimble/nimble/nimble/host/util/include +IDF/components/bt/host/nimble/nimble/nimble/host/store/ram/include +IDF/components/bt/host/nimble/nimble/nimble/host/store/config/include +IDF/components/bt/host/nimble/nimble/nimble/host/services/ras/include +IDF/components/bt/host/nimble/nimble/porting/nimble/include +IDF/components/bt/host/nimble/port/include +IDF/components/bt/host/nimble/nimble/nimble/transport/include +IDF/components/bt/host/nimble/nimble/nimble/transport/common/hci_h4/include +IDF/components/bt/porting/include +IDF/components/bt/host/nimble/nimble/porting/npl/freertos/include +IDF/components/esp_http_client/include +IDF/components/console +IDF/components/wpa_supplicant/esp_supplicant/include +IDF/components/esp_driver_usb_serial_jtag/include diff --git a/src/net/hosted/pin_assert.c b/src/net/hosted/pin_assert.c new file mode 100644 index 0000000..8704c7b --- /dev/null +++ b/src/net/hosted/pin_assert.c @@ -0,0 +1,56 @@ +/* + * The board, asserted at compile time. + * + * ESP-Hosted derives its SDIO pin map, bus width, clock and the C6 reset pin from Kconfig, and this + * project checks in that Kconfig verbatim as src/net/hosted/sdkconfig.h. That makes the wiring a + * build input rather than something written in Zig - which is the right choice, because the real + * `struct esp_hosted_sdio_config` interleaves `gpio_pin_t {void *port; int pin;}` pairs and + * transcribing it into Zig invites a silent wrong-pin bug. + * + * The cost of that choice is that the wiring is now several files away from the board. This file + * pays it back: every value is asserted against what was measured on the die. If a Kconfig symbol + * ever drifts, the build fails naming the pin, instead of the C6 quietly never answering - which + * is the same symptom as a dead radio, a wrong clock, or a held reset, and takes an afternoon to + * tell apart. + * + * Measurements: Guition JC-ESP32P4-M3-DEV schematic sheet 5 (schematics/5_ESP32-C6.png in the + * unofficial board repo), confirmed against the boot log of the ESP-IDF build in + * 02-esp32p4-m3-radio that reached esp_hosted transport state "active" on this die. + */ + +#include "port_esp_hosted_host_config.h" + +/* SDIO slot and bus. Slot 1 is the only one routed to the C6 on this board. */ +_Static_assert(H_SDMMC_HOST_SLOT == 1, "SDIO slot: board routes the C6 to slot 1"); +_Static_assert(H_SDIO_BUS_WIDTH == 4, "SDIO bus width: all four data lines are wired"); +_Static_assert(H_SDIO_CLOCK_FREQ_KHZ == 40000, "SDIO clock: 40 MHz was measured working"); + +/* Pin map. D1 doubles as the slave interrupt line, which is why it must be a real data pin and + * not left unconfigured. */ +_Static_assert(H_SDIO_PIN_CLK == 18, "SDIO CLK is GPIO18"); +_Static_assert(H_SDIO_PIN_CMD == 19, "SDIO CMD is GPIO19"); +_Static_assert(H_SDIO_PIN_D0 == 14, "SDIO D0 is GPIO14"); +_Static_assert(H_SDIO_PIN_D1 == 15, "SDIO D1 is GPIO15, and doubles as the slave interrupt"); +_Static_assert(H_SDIO_PIN_D2 == 16, "SDIO D2 is GPIO16"); +_Static_assert(H_SDIO_PIN_D3 == 17, "SDIO D3 is GPIO17"); + +/* The C6 reset. Active low with an external pull-up: it must be RELEASED, never driven high. + * Driving it the other way holds the radio in reset forever while looking like a config detail. */ +_Static_assert(H_GPIO_PIN_RESET == 54, "C6 reset is GPIO54"); + +/* The coprocessor. H_SLAVE_TARGET_ESP32C6 is what + * host/port/esp/freertos/include/port_esp_hosted_host_config.h:65-95 derives from + * CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6, and it gates wire-format decisions further up. */ +#ifndef H_SLAVE_TARGET_ESP32C6 +#error "Slave target is not ESP32-C6. The coprocessor on this board is an ESP32-C6-MINI." +#endif + +/* H_USE_MEMPOOL must be DEFINED - its value is a real choice (see sdkconfig.h override 3), but + * whether the name exists at all is what decides the length of hosted_osi_funcs_t, because the + * struct guards four members with #ifdef. abi_assert.c checks the resulting size directly. */ +#ifndef H_USE_MEMPOOL +#error "H_USE_MEMPOOL is not defined: the force-include of port_esp_hosted_host_config.h is missing." +#endif + +/* A definition, so the translation unit is not empty. */ +const int esp_hosted_pin_assertions_hold = 1; diff --git a/src/net/hosted/sdkconfig.h b/src/net/hosted/sdkconfig.h new file mode 100644 index 0000000..63e77e9 --- /dev/null +++ b/src/net/hosted/sdkconfig.h @@ -0,0 +1,139 @@ +/* + * The Kconfig surface ESP-Hosted's C compiles against in this project. + * + * Two parts, deliberately separated: + * + * sdkconfig_idf.h ESP-IDF v6.0.2's generated header, verbatim, from the build in + * 02-esp32p4-m3-radio that reached transport state "active" on this die. + * Unedited, so its provenance is checkable. + * + * this file that header, plus a short list of overrides. Each one states what it changes + * and why, so the delta from the proven configuration is reviewable rather than + * buried in 1,400 generated lines. + * + * The generated header is used rather than a hand-picked subset because ESP-Hosted's headers derive + * struct layouts and the slave target from these symbols: CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6 is + * what defines H_SLAVE_TARGET_ESP32C6, and CONFIG_ESP_HOSTED_USE_MEMPOOL is what decides the length + * of hosted_osi_funcs_t (see abi_assert.c). Hand-picking would be a second, unproven configuration. + * + * Nothing here starts a FreeRTOS kernel or an IDF component. The CONFIG_FREERTOS_* values only + * shape type declarations; src/net/port.zig and src/io/p4.zig supply the runtime. + */ + +#pragma once + +#include "sdkconfig_idf.h" + +/* ------------------------------------------------------------------------------------------------ + * Override 1: SDIO queue depths, 20 -> 4 each. + * + * IDF's build ran with 20 TX and 20 RX descriptors. That is a reasonable number when the heap is + * PSRAM-backed; here it is not. Forty in-flight buffers at MAX_SDIO_BUFFER_SIZE (1536 B) reserve + * ~60 KB before a single task stack exists, and this image has ~128 KB of L2MEM in total with + * nothing initialising the 32 MB of PSRAM. + * + * Four each was the first attempt and it was measured wrong. Once the board associated, the AP's + * ordinary broadcast traffic filled a four-deep queue immediately: the console filled with + * "task still writing Rx data to queue!", the receive counter froze at 8 frames, and the board + * stopped answering ARP - so it took a DHCP lease and then went silent, which looked like a bug in + * the IP stack rather than a queue two sizes too small. + * + * Sixteen each was then too many, for the reason that makes this setting awkward: with the mempool + * off (override 3) every frame is a fresh `_h_malloc_align(MAX_TRANSPORT_BUFFER_SIZE, 64)` from our + * heap, so the depths bound peak heap demand at (tx + rx) x 1536 bytes. At sixteen each that is + * ~49 KB of a 56 KB heap, and the board duly ran out: "mempool OOM start (RX)" at 11 s, then + * "STA TX: mempool_alloc failed, dropping pkt", after which nothing moved in either direction. + * + * Eight each: ~24.5 KB peak, against a heap sized well above it in examples/http.zig. Deep enough + * that ordinary broadcast traffic does not fill the queue between two `tick`s, shallow enough that a + * burst cannot exhaust the heap and stop the transmit path as collateral damage. That second + * property is the one worth protecting: a receive queue that overflows drops a frame, but a heap + * that empties takes the whole radio down. + * ---------------------------------------------------------------------------------------------- */ +#undef CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE +#define CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 8 +#undef CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE +#define CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE 8 + +/* These two are aliases the transport reads; they must follow the values above rather than the + * originals, or the queues and the descriptors disagree about their own depth. */ +#undef CONFIG_ESP_SDIO_TX_Q_SIZE +#define CONFIG_ESP_SDIO_TX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE +#undef CONFIG_ESP_SDIO_RX_Q_SIZE +#define CONFIG_ESP_SDIO_RX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE + +/* ------------------------------------------------------------------------------------------------ + * Override 2: Bluetooth off. + * + * The IDF build this configuration came from used BLE through the C6, so it enabled NimBLE and the + * VHCI transport. This project does not do Bluetooth, and leaving it on is not free: ESP-Hosted's + * transport calls hci_drv_init() unconditionally (transport_drv.c:126), and with NimBLE enabled that + * pulls in the real vhci_drv.c and the whole NimBLE host - ble_transport_*, os_mbuf_*, + * ble_hs_mbuf_to_flat - which is another stack this image has no reason to carry. + * + * With these off, ESP-Hosted's own host/drivers/bt/hci_stub_drv.c compiles to a no-op hci_drv_init + * and a drop-everything hci_rx_handler. That file is in the source list in build.zig, which is why + * this is a configuration change rather than a Zig stub: the C already ships the right answer for a + * host without Bluetooth, and using it keeps one fewer thing for us to get wrong. + * + * The C6 still reports HCI capability in its capability byte (0x0d on this board). That is the + * coprocessor saying what it can do, not a request; declining is the host's decision. + * ---------------------------------------------------------------------------------------------- */ +#undef CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE +#undef CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI +#undef CONFIG_ESP_HOSTED_ENABLE_BT_BLUEDROID +#undef CONFIG_ESP_HOSTED_BLUEDROID_HCI_VHCI +#undef CONFIG_BT_ENABLED +#undef CONFIG_BT_NIMBLE_ENABLED + +/* ------------------------------------------------------------------------------------------------ + * Override 3: mempool off. + * + * ESP-Hosted's mempool recycles fixed-size packet buffers instead of going to malloc each time. It + * needs a backend, supplied by `os_mempool_get_ops()`, and on ESP-IDF that comes from FreeRTOS's own + * pool implementation. This image has no FreeRTOS, and mempool.c treats a null ops table as a hard + * failure rather than a fallback (mempool.c:63-65, "hosted mempool init failed: no mempool ops") - + * which is what the second run of examples/radio.zig printed. + * + * The choice is to write a pool backend or to switch the optimisation off. Off, for now: the + * allocator behind _h_malloc (src/net/heap.zig) is a coalescing free list over a static buffer, so + * the same-size churn mempool exists to avoid is already cheap and cannot fragment the way a + * general-purpose heap would. If profiling later says otherwise, the backend is a small job and this + * is the one line to flip back. + * + * Layout note, because this looks dangerous and is not: turning this off does NOT change + * hosted_osi_funcs_t. port_esp_hosted_host_config.h:127-131 defines H_USE_MEMPOOL to 1 or to 0, and + * the struct's four mempool members are guarded by `#ifdef`, which only asks whether the name is + * defined. Both ways the struct is 284 bytes. src/net/hosted/abi_assert.c asserts that directly. + * ---------------------------------------------------------------------------------------------- */ +#undef CONFIG_ESP_HOSTED_USE_MEMPOOL +#define CONFIG_ESP_HOSTED_USE_MEMPOOL 0 + +/* ------------------------------------------------------------------------------------------------ + * Override 4: the ESP-Hosted CLI off. + * + * A console command set for poking the transport at runtime. It needs IDF's `console` component - + * esp_console_cmd_register, a line editor, and a UART driver - none of which exists in this image, + * and none of which this project wants: the serial line here is a log, not a shell. + * + * `H_ESP_HOSTED_CLI_ENABLED` is an `#ifdef` on the *value* of this symbol being defined + * (transport_drv.c:804), so it must be #undef'd rather than defined to 0. + * ---------------------------------------------------------------------------------------------- */ +#undef CONFIG_ESP_HOSTED_CLI_ENABLED + +/* ------------------------------------------------------------------------------------------------ + * Override 5: compile DEBUG-level logging in. + * + * The generated configuration stops at CONFIG_LOG_MAXIMUM_LEVEL 3 (INFO), which compiles ESP_LOGD + * away entirely. That hides exactly the lines needed to tell a stalled receive path apart from a + * silent slave: sdio_drv.c:1190 logs "--- Wait for SDIO intr ---" at DEBUG on every pass of + * sdio_read_task, so its presence or absence answers "is the read task still looping?" directly. + * + * 4, not 5: VERBOSE adds a per-interrupt line that floods a 115200 baud console and changes the + * timing of the thing being measured. + * + * The runtime filter in src/net/hosted_glue.zig is separate and independent - this only decides what + * exists in the image to be filtered. + * ---------------------------------------------------------------------------------------------- */ +#undef CONFIG_LOG_MAXIMUM_LEVEL +#define CONFIG_LOG_MAXIMUM_LEVEL 4 diff --git a/src/net/hosted/sdkconfig_idf.h b/src/net/hosted/sdkconfig_idf.h new file mode 100644 index 0000000..1457dcf --- /dev/null +++ b/src/net/hosted/sdkconfig_idf.h @@ -0,0 +1,1473 @@ +/* + * ESP-IDF v6.0.2's generated Kconfig header, verbatim. + * + * Provenance: 00-projects/0x4200.cafe/02-esp32p4-m3-radio/build/config/sdkconfig.h, produced by the + * IDF build that brought this P4 onto Wi-Fi through the onboard ESP32-C6 over SDIO. Not edited - + * not one line. Deviations belong in sdkconfig.h, which includes this file and then overrides + * individual symbols with a reason attached. + * + * Do not include this directly. Include "sdkconfig.h", which is what ESP-IDF's own headers ask for. + */ +/* + * Automatically generated file. DO NOT EDIT. + * Espressif IoT Development Framework (ESP-IDF) 6.0.2 Configuration Header + */ +#pragma once +#define CONFIG_SOC_ADC_SUPPORTED 1 +#define CONFIG_SOC_ANA_CMPR_SUPPORTED 1 +#define CONFIG_SOC_DEDICATED_GPIO_SUPPORTED 1 +#define CONFIG_SOC_UART_SUPPORTED 1 +#define CONFIG_SOC_GDMA_SUPPORTED 1 +#define CONFIG_SOC_UHCI_SUPPORTED 1 +#define CONFIG_SOC_AHB_GDMA_SUPPORTED 1 +#define CONFIG_SOC_AXI_GDMA_SUPPORTED 1 +#define CONFIG_SOC_DW_GDMA_SUPPORTED 1 +#define CONFIG_SOC_DMA2D_SUPPORTED 1 +#define CONFIG_SOC_GPTIMER_SUPPORTED 1 +#define CONFIG_SOC_PCNT_SUPPORTED 1 +#define CONFIG_SOC_LCDCAM_CAM_SUPPORTED 1 +#define CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED 1 +#define CONFIG_SOC_LCDCAM_RGB_LCD_SUPPORTED 1 +#define CONFIG_SOC_LCD_I80_SUPPORTED 1 +#define CONFIG_SOC_LCD_RGB_SUPPORTED 1 +#define CONFIG_SOC_MIPI_CSI_SUPPORTED 1 +#define CONFIG_SOC_MIPI_DSI_SUPPORTED 1 +#define CONFIG_SOC_MCPWM_SUPPORTED 1 +#define CONFIG_SOC_TWAI_SUPPORTED 1 +#define CONFIG_SOC_ETM_SUPPORTED 1 +#define CONFIG_SOC_PARLIO_SUPPORTED 1 +#define CONFIG_SOC_PARLIO_LCD_SUPPORTED 1 +#define CONFIG_SOC_ASYNC_MEMCPY_SUPPORTED 1 +#define CONFIG_SOC_EMAC_SUPPORTED 1 +#define CONFIG_SOC_USB_OTG_SUPPORTED 1 +#define CONFIG_SOC_WIRELESS_HOST_SUPPORTED 1 +#define CONFIG_SOC_USB_SERIAL_JTAG_SUPPORTED 1 +#define CONFIG_SOC_TEMP_SENSOR_SUPPORTED 1 +#define CONFIG_SOC_SUPPORTS_SECURE_DL_MODE 1 +#define CONFIG_SOC_ULP_SUPPORTED 1 +#define CONFIG_SOC_LP_CORE_SUPPORTED 1 +#define CONFIG_SOC_EFUSE_KEY_PURPOSE_FIELD 1 +#define CONFIG_SOC_EFUSE_SUPPORTED 1 +#define CONFIG_SOC_RTC_FAST_MEM_SUPPORTED 1 +#define CONFIG_SOC_RTC_MEM_SUPPORTED 1 +#define CONFIG_SOC_RMT_SUPPORTED 1 +#define CONFIG_SOC_I2S_SUPPORTED 1 +#define CONFIG_SOC_SDM_SUPPORTED 1 +#define CONFIG_SOC_GPSPI_SUPPORTED 1 +#define CONFIG_SOC_LEDC_SUPPORTED 1 +#define CONFIG_SOC_ISP_SUPPORTED 1 +#define CONFIG_SOC_I2C_SUPPORTED 1 +#define CONFIG_SOC_SYSTIMER_SUPPORTED 1 +#define CONFIG_SOC_AES_SUPPORTED 1 +#define CONFIG_SOC_MPI_SUPPORTED 1 +#define CONFIG_SOC_SHA_SUPPORTED 1 +#define CONFIG_SOC_HMAC_SUPPORTED 1 +#define CONFIG_SOC_DIG_SIGN_SUPPORTED 1 +#define CONFIG_SOC_ECC_SUPPORTED 1 +#define CONFIG_SOC_ECC_EXTENDED_MODES_SUPPORTED 1 +#define CONFIG_SOC_ECDSA_SUPPORTED 1 +#define CONFIG_SOC_KEY_MANAGER_SUPPORTED 1 +#define CONFIG_SOC_HUK_SUPPORTED 1 +#define CONFIG_SOC_FLASH_ENC_SUPPORTED 1 +#define CONFIG_SOC_SECURE_BOOT_SUPPORTED 1 +#define CONFIG_SOC_BOD_SUPPORTED 1 +#define CONFIG_SOC_VBAT_SUPPORTED 1 +#define CONFIG_SOC_APM_SUPPORTED 1 +#define CONFIG_SOC_PMU_SUPPORTED 1 +#define CONFIG_SOC_PMU_PVT_SUPPORTED 1 +#define CONFIG_SOC_PVT_EN_WITH_SLEEP 1 +#define CONFIG_SOC_PVT_RETENTION_BY_REGDMA 1 +#define CONFIG_SOC_DCDC_SUPPORTED 1 +#define CONFIG_SOC_PAU_SUPPORTED 1 +#define CONFIG_SOC_RTC_TIMER_V2_SUPPORTED 1 +#define CONFIG_SOC_ULP_LP_UART_SUPPORTED 1 +#define CONFIG_SOC_LP_GPIO_MATRIX_SUPPORTED 1 +#define CONFIG_SOC_LP_PERIPHERALS_SUPPORTED 1 +#define CONFIG_SOC_LP_I2C_SUPPORTED 1 +#define CONFIG_SOC_LP_I2S_SUPPORTED 1 +#define CONFIG_SOC_LP_SPI_SUPPORTED 1 +#define CONFIG_SOC_LP_ADC_SUPPORTED 1 +#define CONFIG_SOC_LP_VAD_SUPPORTED 1 +#define CONFIG_SOC_LP_MAILBOX_SUPPORTED 1 +#define CONFIG_SOC_SPIRAM_SUPPORTED 1 +#define CONFIG_SOC_PSRAM_DMA_CAPABLE 1 +#define CONFIG_SOC_SDMMC_HOST_SUPPORTED 1 +#define CONFIG_SOC_CLK_TREE_SUPPORTED 1 +#define CONFIG_SOC_ASSIST_DEBUG_SUPPORTED 1 +#define CONFIG_SOC_DEBUG_PROBE_SUPPORTED 1 +#define CONFIG_SOC_WDT_SUPPORTED 1 +#define CONFIG_SOC_SPI_FLASH_SUPPORTED 1 +#define CONFIG_SOC_TOUCH_SENSOR_SUPPORTED 1 +#define CONFIG_SOC_RNG_SUPPORTED 1 +#define CONFIG_SOC_GP_LDO_SUPPORTED 1 +#define CONFIG_SOC_PPA_SUPPORTED 1 +#define CONFIG_SOC_LIGHT_SLEEP_SUPPORTED 1 +#define CONFIG_SOC_DEEP_SLEEP_SUPPORTED 1 +#define CONFIG_SOC_PM_SUPPORTED 1 +#define CONFIG_SOC_BITSCRAMBLER_SUPPORTED 1 +#define CONFIG_SOC_SIMD_INSTRUCTION_SUPPORTED 1 +#define CONFIG_SOC_I3C_MASTER_SUPPORTED 1 +#define CONFIG_SOC_XTAL_SUPPORT_40M 1 +#define CONFIG_SOC_AES_SUPPORT_DMA 1 +#define CONFIG_SOC_AES_SUPPORT_GCM 1 +#define CONFIG_SOC_AES_GDMA 1 +#define CONFIG_SOC_AES_SUPPORT_AES_128 1 +#define CONFIG_SOC_AES_SUPPORT_AES_256 1 +#define CONFIG_SOC_AES_SUPPORT_PSEUDO_ROUND_FUNCTION 1 +#define CONFIG_SOC_ADC_RTC_CTRL_SUPPORTED 1 +#define CONFIG_SOC_ADC_DIG_CTRL_SUPPORTED 1 +#define CONFIG_SOC_ADC_DMA_SUPPORTED 1 +#define CONFIG_SOC_ADC_PERIPH_NUM 2 +#define CONFIG_SOC_ADC_MAX_CHANNEL_NUM 8 +#define CONFIG_SOC_ADC_ATTEN_NUM 4 +#define CONFIG_SOC_ADC_DIGI_CONTROLLER_NUM 2 +#define CONFIG_SOC_ADC_PATT_LEN_MAX 16 +#define CONFIG_SOC_ADC_DIGI_MAX_BITWIDTH 12 +#define CONFIG_SOC_ADC_DIGI_MIN_BITWIDTH 12 +#define CONFIG_SOC_ADC_DIGI_IIR_FILTER_NUM 2 +#define CONFIG_SOC_ADC_DIGI_MONITOR_NUM 2 +#define CONFIG_SOC_ADC_DIGI_RESULT_BYTES 4 +#define CONFIG_SOC_ADC_DIGI_DATA_BYTES_PER_CONV 4 +#define CONFIG_SOC_ADC_SAMPLE_FREQ_THRES_HIGH 83333 +#define CONFIG_SOC_ADC_SAMPLE_FREQ_THRES_LOW 611 +#define CONFIG_SOC_ADC_RTC_MIN_BITWIDTH 12 +#define CONFIG_SOC_ADC_RTC_MAX_BITWIDTH 12 +#define CONFIG_SOC_ADC_CALIBRATION_V1_SUPPORTED 1 +#define CONFIG_SOC_ADC_SELF_HW_CALI_SUPPORTED 1 +#define CONFIG_SOC_ADC_CALIB_CHAN_COMPENS_SUPPORTED 1 +#define CONFIG_SOC_ADC_SHARED_POWER 1 +#define CONFIG_SOC_BROWNOUT_RESET_SUPPORTED 1 +#define CONFIG_SOC_SHARED_IDCACHE_SUPPORTED 1 +#define CONFIG_SOC_CACHE_WRITEBACK_SUPPORTED 1 +#define CONFIG_SOC_CACHE_FREEZE_SUPPORTED 1 +#define CONFIG_SOC_CACHE_INTERNAL_MEM_VIA_L1CACHE 1 +#define CONFIG_SOC_CPU_CORES_NUM 2 +#define CONFIG_SOC_CPU_INTR_NUM 32 +#define CONFIG_SOC_CPU_HAS_FLEXIBLE_INTC 1 +#define CONFIG_SOC_INT_CLIC_SUPPORTED 1 +#define CONFIG_SOC_INT_HW_NESTED_SUPPORTED 1 +#define CONFIG_SOC_BRANCH_PREDICTOR_SUPPORTED 1 +#define CONFIG_SOC_CPU_COPROC_NUM 3 +#define CONFIG_SOC_CPU_HAS_FPU 1 +#define CONFIG_SOC_CPU_HAS_FPU_EXT_ILL_BUG 1 +#define CONFIG_SOC_CPU_HAS_HWLOOP 1 +#define CONFIG_SOC_CPU_HAS_HWLOOP_STATE_BUG 1 +#define CONFIG_SOC_CPU_HAS_PIE 1 +#define CONFIG_SOC_HP_CPU_HAS_MULTIPLE_CORES 1 +#define CONFIG_SOC_CPU_BREAKPOINTS_NUM 3 +#define CONFIG_SOC_CPU_WATCHPOINTS_NUM 3 +#define CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE 0x100 +#define CONFIG_SOC_CPU_HAS_PMA 1 +#define CONFIG_SOC_CPU_IDRAM_SPLIT_USING_PMP 1 +#define CONFIG_SOC_CPU_PMP_REGION_GRANULARITY 128 +#define CONFIG_SOC_CPU_HAS_LOCKUP_RESET 1 +#define CONFIG_SOC_CPU_HAS_ZC_EXTENSIONS 1 +#define CONFIG_SOC_CPU_ZCMP_WORKAROUND 1 +#define CONFIG_SOC_CPU_ZCMP_PUSH_REVERSED 1 +#define CONFIG_SOC_CPU_ZCMP_POPRET_ISSUE 1 +#define CONFIG_SOC_SIMD_PREFERRED_DATA_ALIGNMENT 16 +#define CONFIG_SOC_DS_SIGNATURE_MAX_BIT_LEN 4096 +#define CONFIG_SOC_DS_KEY_PARAM_MD_IV_LENGTH 16 +#define CONFIG_SOC_DS_KEY_CHECK_MAX_WAIT_US 1100 +#define CONFIG_SOC_DMA_CAN_ACCESS_FLASH 1 +#define CONFIG_SOC_AHB_GDMA_VERSION 2 +#define CONFIG_SOC_GDMA_SUPPORT_CRC 1 +#define CONFIG_SOC_GDMA_SUPPORT_ETM 1 +#define CONFIG_SOC_GDMA_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_GDMA_EXT_MEM_ENC_ALIGNMENT 16 +#define CONFIG_SOC_GPIO_PORT 1 +#define CONFIG_SOC_GPIO_PIN_COUNT 55 +#define CONFIG_SOC_GPIO_SUPPORT_PIN_GLITCH_FILTER 1 +#define CONFIG_SOC_GPIO_FLEX_GLITCH_FILTER_NUM 8 +#define CONFIG_SOC_GPIO_SUPPORT_PIN_HYS_FILTER 1 +#define CONFIG_SOC_GPIO_SUPPORT_ETM 1 +#define CONFIG_SOC_GPIO_SUPPORT_HP_PERIPH_PD_SLEEP_WAKEUP 1 +#define CONFIG_SOC_LP_IO_HAS_INDEPENDENT_WAKEUP_SOURCE 1 +#define CONFIG_SOC_LP_IO_CLOCK_IS_INDEPENDENT 1 +#define CONFIG_SOC_GPIO_VALID_GPIO_MASK 0x007FFFFFFFFFFFFF +#define CONFIG_SOC_GPIO_IN_RANGE_MAX 54 +#define CONFIG_SOC_GPIO_OUT_RANGE_MAX 54 +#define CONFIG_SOC_GPIO_HP_PERIPH_PD_SLEEP_WAKEABLE_MASK 0 +#define CONFIG_SOC_GPIO_HP_PERIPH_PD_SLEEP_WAKEABLE_PIN_CNT 16 +#define CONFIG_SOC_GPIO_VALID_DIGITAL_IO_PAD_MASK 0x007FFFFFFFFF0000 +#define CONFIG_SOC_GPIO_SUPPORT_FORCE_HOLD 1 +#define CONFIG_SOC_GPIO_SUPPORT_HOLD_SINGLE_IO_IN_DSLP 1 +#define CONFIG_SOC_GPIO_CLOCKOUT_BY_GPIO_MATRIX 1 +#define CONFIG_SOC_GPIO_CLOCKOUT_CHANNEL_NUM 2 +#define CONFIG_SOC_CLOCKOUT_SUPPORT_CHANNEL_DIVIDER 1 +#define CONFIG_SOC_DEBUG_PROBE_NUM_UNIT 1 +#define CONFIG_SOC_DEBUG_PROBE_MAX_OUTPUT_WIDTH 16 +#define CONFIG_SOC_RTCIO_PIN_COUNT 16 +#define CONFIG_SOC_RTCIO_INPUT_OUTPUT_SUPPORTED 1 +#define CONFIG_SOC_RTCIO_HOLD_SUPPORTED 1 +#define CONFIG_SOC_RTCIO_WAKE_SUPPORTED 1 +#define CONFIG_SOC_SDM_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_ETM_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_ANA_CMPR_NUM 2 +#define CONFIG_SOC_ANA_CMPR_CAN_DISTINGUISH_EDGE 1 +#define CONFIG_SOC_ANA_CMPR_SUPPORT_ETM 1 +#define CONFIG_SOC_I2C_NUM 3 +#define CONFIG_SOC_HP_I2C_NUM 2 +#define CONFIG_SOC_LP_I2C_NUM 1 +#define CONFIG_SOC_I2C_SUPPORT_XTAL 1 +#define CONFIG_SOC_I2C_SUPPORT_RTC 1 +#define CONFIG_SOC_I2C_SUPPORT_10BIT_ADDR 1 +#define CONFIG_SOC_I2C_SUPPORT_SLAVE 1 +#define CONFIG_SOC_I2C_SLAVE_SUPPORT_BROADCAST 1 +#define CONFIG_SOC_I2C_SLAVE_CAN_GET_STRETCH_CAUSE 1 +#define CONFIG_SOC_I2C_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_I2S_HW_VERSION_2 1 +#define CONFIG_SOC_I2S_SUPPORTS_ETM 1 +#define CONFIG_SOC_I2S_SUPPORTS_APLL 1 +#define CONFIG_SOC_I2S_SUPPORTS_PCM 1 +#define CONFIG_SOC_I2S_SUPPORTS_PDM 1 +#define CONFIG_SOC_I2S_SUPPORTS_PDM_TX 1 +#define CONFIG_SOC_I2S_SUPPORTS_PCM2PDM 1 +#define CONFIG_SOC_I2S_SUPPORTS_PDM_RX 1 +#define CONFIG_SOC_I2S_SUPPORTS_PDM2PCM 1 +#define CONFIG_SOC_I2S_SUPPORTS_PDM_RX_HP_FILTER 1 +#define CONFIG_SOC_I2S_SUPPORTS_TX_SYNC_CNT 1 +#define CONFIG_SOC_I2S_SUPPORTS_TDM 1 +#define CONFIG_SOC_I2S_PDM_MAX_TX_LINES 2 +#define CONFIG_SOC_I2S_PDM_MAX_RX_LINES 4 +#define CONFIG_SOC_LP_I2S_NUM 1 +#define CONFIG_SOC_ISP_BF_SUPPORTED 1 +#define CONFIG_SOC_ISP_BLC_SUPPORTED 1 +#define CONFIG_SOC_ISP_CCM_SUPPORTED 1 +#define CONFIG_SOC_ISP_COLOR_SUPPORTED 1 +#define CONFIG_SOC_ISP_CROP_SUPPORTED 1 +#define CONFIG_SOC_ISP_DEMOSAIC_SUPPORTED 1 +#define CONFIG_SOC_ISP_DVP_SUPPORTED 1 +#define CONFIG_SOC_ISP_LSC_SUPPORTED 1 +#define CONFIG_SOC_ISP_SHARPEN_SUPPORTED 1 +#define CONFIG_SOC_ISP_WBG_SUPPORTED 1 +#define CONFIG_SOC_ISP_SHARE_CSI_BRG 1 +#define CONFIG_SOC_ISP_AE_BLOCK_X_NUMS 5 +#define CONFIG_SOC_ISP_AE_BLOCK_Y_NUMS 5 +#define CONFIG_SOC_ISP_AF_WINDOW_NUMS 3 +#define CONFIG_SOC_ISP_AWB_WINDOW_X_NUMS 5 +#define CONFIG_SOC_ISP_AWB_WINDOW_Y_NUMS 5 +#define CONFIG_SOC_ISP_BF_TEMPLATE_X_NUMS 3 +#define CONFIG_SOC_ISP_BF_TEMPLATE_Y_NUMS 3 +#define CONFIG_SOC_ISP_CCM_DIMENSION 3 +#define CONFIG_SOC_ISP_DEMOSAIC_GRAD_RATIO_INT_BITS 2 +#define CONFIG_SOC_ISP_DEMOSAIC_GRAD_RATIO_DEC_BITS 4 +#define CONFIG_SOC_ISP_DEMOSAIC_GRAD_RATIO_RES_BITS 26 +#define CONFIG_SOC_ISP_SHARPEN_TEMPLATE_X_NUMS 3 +#define CONFIG_SOC_ISP_SHARPEN_TEMPLATE_Y_NUMS 3 +#define CONFIG_SOC_ISP_SHARPEN_H_FREQ_COEF_INT_BITS 3 +#define CONFIG_SOC_ISP_SHARPEN_H_FREQ_COEF_DEC_BITS 5 +#define CONFIG_SOC_ISP_SHARPEN_H_FREQ_COEF_RES_BITS 24 +#define CONFIG_SOC_ISP_SHARPEN_M_FREQ_COEF_INT_BITS 3 +#define CONFIG_SOC_ISP_SHARPEN_M_FREQ_COEF_DEC_BITS 5 +#define CONFIG_SOC_ISP_SHARPEN_M_FREQ_COEF_RES_BITS 24 +#define CONFIG_SOC_ISP_HIST_BLOCK_X_NUMS 5 +#define CONFIG_SOC_ISP_HIST_BLOCK_Y_NUMS 5 +#define CONFIG_SOC_ISP_HIST_SEGMENT_NUMS 16 +#define CONFIG_SOC_ISP_HIST_INTERVAL_NUMS 15 +#define CONFIG_SOC_ISP_LSC_GRAD_RATIO_INT_BITS 2 +#define CONFIG_SOC_ISP_LSC_GRAD_RATIO_DEC_BITS 8 +#define CONFIG_SOC_ISP_LSC_GRAD_RATIO_RES_BITS 22 +#define CONFIG_SOC_LEDC_SUPPORT_PLL_DIV_CLOCK 1 +#define CONFIG_SOC_LEDC_SUPPORT_XTAL_CLOCK 1 +#define CONFIG_SOC_LEDC_TIMER_NUM 4 +#define CONFIG_SOC_LEDC_CHANNEL_NUM 8 +#define CONFIG_SOC_LEDC_TIMER_BIT_WIDTH 20 +#define CONFIG_SOC_LEDC_GAMMA_CURVE_FADE_SUPPORTED 1 +#define CONFIG_SOC_LEDC_GAMMA_CURVE_FADE_RANGE_MAX 16 +#define CONFIG_SOC_LEDC_SUPPORT_FADE_STOP 1 +#define CONFIG_SOC_LEDC_FADE_PARAMS_BIT_WIDTH 10 +#define CONFIG_SOC_LEDC_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_LEDC_SUPPORT_ETM 1 +#define CONFIG_SOC_MMU_PERIPH_NUM 2 +#define CONFIG_SOC_MMU_LINEAR_ADDRESS_REGION_NUM 2 +#define CONFIG_SOC_MMU_DI_VADDR_SHARED 1 +#define CONFIG_SOC_MMU_PER_EXT_MEM_TARGET 1 +#define CONFIG_SOC_MPU_MIN_REGION_SIZE 0x20000000 +#define CONFIG_SOC_MPU_REGIONS_MAX_NUM 8 +#define CONFIG_SOC_PCNT_SUPPORT_RUNTIME_THRES_UPDATE 1 +#define CONFIG_SOC_PCNT_SUPPORT_CLEAR_SIGNAL 1 +#define CONFIG_SOC_RMT_MEM_WORDS_PER_CHANNEL 48 +#define CONFIG_SOC_RMT_SUPPORT_RX_PINGPONG 1 +#define CONFIG_SOC_RMT_SUPPORT_TX_LOOP_COUNT 1 +#define CONFIG_SOC_RMT_SUPPORT_TX_LOOP_AUTO_STOP 1 +#define CONFIG_SOC_RMT_SUPPORT_DMA 1 +#define CONFIG_SOC_RMT_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_MCPWM_SWSYNC_CAN_PROPAGATE 1 +#define CONFIG_SOC_MCPWM_SUPPORT_ETM 1 +#define CONFIG_SOC_MCPWM_SUPPORT_EVENT_COMPARATOR 1 +#define CONFIG_SOC_MCPWM_CAPTURE_CLK_FROM_GROUP 1 +#define CONFIG_SOC_MCPWM_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_USB_OTG_PERIPH_NUM 2 +#define CONFIG_SOC_USB_FSLS_PHY_NUM 1 +#define CONFIG_SOC_USB_UTMI_PHY_NUM 1 +#define CONFIG_SOC_USB_UTMI_PHY_NO_POWER_OFF_ISO 1 +#define CONFIG_SOC_PARLIO_TX_UNIT_MAX_DATA_WIDTH 16 +#define CONFIG_SOC_PARLIO_RX_UNIT_MAX_DATA_WIDTH 16 +#define CONFIG_SOC_PARLIO_TX_CLK_SUPPORT_GATING 1 +#define CONFIG_SOC_PARLIO_RX_CLK_SUPPORT_GATING 1 +#define CONFIG_SOC_PARLIO_TX_SUPPORT_LOOP_TRANSMISSION 1 +#define CONFIG_SOC_PARLIO_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_PARLIO_SUPPORT_I80_LCD 1 +#define CONFIG_SOC_MPI_MEM_BLOCKS_NUM 4 +#define CONFIG_SOC_MPI_OPERATIONS_NUM 3 +#define CONFIG_SOC_RSA_MAX_BIT_LEN 4096 +#define CONFIG_SOC_SDMMC_USE_IOMUX 1 +#define CONFIG_SOC_SDMMC_USE_GPIO_MATRIX 1 +#define CONFIG_SOC_SDMMC_NUM_SLOTS 2 +#define CONFIG_SOC_SDMMC_DATA_WIDTH_MAX 8 +#define CONFIG_SOC_SDMMC_DELAY_PHASE_NUM 8 +#define CONFIG_SOC_SDMMC_IO_POWER_EXTERNAL 1 +#define CONFIG_SOC_SDMMC_PSRAM_DMA_CAPABLE 1 +#define CONFIG_SOC_SDMMC_UHS_I_SUPPORTED 1 +#define CONFIG_SOC_SHA_DMA_MAX_BUFFER_SIZE 3968 +#define CONFIG_SOC_SHA_SUPPORT_DMA 1 +#define CONFIG_SOC_SHA_SUPPORT_RESUME 1 +#define CONFIG_SOC_SHA_GDMA 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA1 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA224 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA256 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA384 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA512 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA512_224 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA512_256 1 +#define CONFIG_SOC_SHA_SUPPORT_SHA512_T 1 +#define CONFIG_SOC_ECC_CONSTANT_TIME_POINT_MUL 1 +#define CONFIG_SOC_ECC_SUPPORT_CURVE_P384 1 +#define CONFIG_SOC_ECDSA_SUPPORT_EXPORT_PUBKEY 1 +#define CONFIG_SOC_ECDSA_SUPPORT_DETERMINISTIC_MODE 1 +#define CONFIG_SOC_ECDSA_SUPPORT_HW_DETERMINISTIC_LOOP 1 +#define CONFIG_SOC_ECDSA_USES_MPI 1 +#define CONFIG_SOC_ECDSA_SUPPORT_CURVE_P384 1 +#define CONFIG_SOC_ECDSA_SUPPORT_CURVE_SPECIFIC_KEY_PURPOSES 1 +#define CONFIG_SOC_SPI_PERIPH_NUM 3 +#define CONFIG_SOC_SPI_MAX_CS_NUM 6 +#define CONFIG_SOC_SPI_MAXIMUM_BUFFER_SIZE 64 +#define CONFIG_SOC_SPI_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_SPI_SUPPORT_SLAVE_HD_VER2 1 +#define CONFIG_SOC_SPI_SLAVE_SUPPORT_SEG_TRANS 1 +#define CONFIG_SOC_SPI_SUPPORT_DDRCLK 1 +#define CONFIG_SOC_SPI_SUPPORT_CD_SIG 1 +#define CONFIG_SOC_SPI_SUPPORT_OCT 1 +#define CONFIG_SOC_SPI_SUPPORT_CLK_XTAL 1 +#define CONFIG_SOC_SPI_SUPPORT_CLK_RC_FAST 1 +#define CONFIG_SOC_MSPI_HAS_INDEPENT_IOMUX 1 +#define CONFIG_SOC_MEMSPI_IS_INDEPENDENT 1 +#define CONFIG_SOC_SPI_MAX_PRE_DIVIDER 16 +#define CONFIG_SOC_LP_SPI_MAXIMUM_BUFFER_SIZE 64 +#define CONFIG_SOC_SPIRAM_XIP_SUPPORTED 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_WAIT_IDLE 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_SUSPEND 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_AUTO_RESUME 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_IDLE_INTR 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_SW_SUSPEND 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_CHECK_SUS 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_TIMING_TUNING 1 +#define CONFIG_SOC_MEMSPI_TIMING_TUNING_BY_DQS 1 +#define CONFIG_SOC_MEMSPI_TIMING_TUNING_BY_FLASH_DELAY 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_CACHE_32BIT_ADDR_MAP 1 +#define CONFIG_SOC_SPI_MEM_SUPPORT_TSUS_TRES_SEPERATE_CTR 1 +#define CONFIG_SOC_SPI_PERIPH_SUPPORT_CONTROL_DUMMY_OUT 1 +#define CONFIG_SOC_SPI_MEM_FLASH_SUPPORT_HPM 1 +#define CONFIG_SOC_MEMSPI_ENCRYPTION_ALIGNMENT 16 +#define CONFIG_SOC_SYSTIMER_COUNTER_NUM 2 +#define CONFIG_SOC_SYSTIMER_ALARM_NUM 3 +#define CONFIG_SOC_SYSTIMER_BIT_WIDTH_LO 32 +#define CONFIG_SOC_SYSTIMER_BIT_WIDTH_HI 20 +#define CONFIG_SOC_SYSTIMER_FIXED_DIVIDER 1 +#define CONFIG_SOC_SYSTIMER_SUPPORT_RC_FAST 1 +#define CONFIG_SOC_SYSTIMER_INT_LEVEL 1 +#define CONFIG_SOC_SYSTIMER_ALARM_MISS_COMPENSATE 1 +#define CONFIG_SOC_SYSTIMER_SUPPORT_ETM 1 +#define CONFIG_SOC_LP_TIMER_BIT_WIDTH_LO 32 +#define CONFIG_SOC_LP_TIMER_BIT_WIDTH_HI 16 +#define CONFIG_SOC_TIMER_SUPPORT_ETM 1 +#define CONFIG_SOC_TIMER_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_MWDT_SUPPORT_XTAL 1 +#define CONFIG_SOC_MWDT_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_TOUCH_SENSOR_VERSION 3 +#define CONFIG_SOC_TOUCH_MIN_CHAN_ID 1 +#define CONFIG_SOC_TOUCH_MAX_CHAN_ID 14 +#define CONFIG_SOC_TOUCH_SUPPORT_SLEEP_WAKEUP 1 +#define CONFIG_SOC_TOUCH_SUPPORT_BENCHMARK 1 +#define CONFIG_SOC_TOUCH_SUPPORT_WATERPROOF 1 +#define CONFIG_SOC_TOUCH_SUPPORT_PROX_SENSING 1 +#define CONFIG_SOC_TOUCH_PROXIMITY_CHANNEL_NUM 3 +#define CONFIG_SOC_TOUCH_SAMPLE_CFG_NUM 3 +#define CONFIG_SOC_TWAI_CONTROLLER_NUM 3 +#define CONFIG_SOC_TWAI_MASK_FILTER_NUM 1 +#define CONFIG_SOC_TWAI_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_EFUSE_DIS_PAD_JTAG 1 +#define CONFIG_SOC_EFUSE_DIS_USB_JTAG 1 +#define CONFIG_SOC_EFUSE_DIS_DIRECT_BOOT 1 +#define CONFIG_SOC_EFUSE_SOFT_DIS_JTAG 1 +#define CONFIG_SOC_EFUSE_DIS_DOWNLOAD_MSPI 1 +#define CONFIG_SOC_EFUSE_ECDSA_KEY 1 +#define CONFIG_SOC_EFUSE_XTS_AES_KEY_128 1 +#define CONFIG_SOC_EFUSE_XTS_AES_KEY_256 1 +#define CONFIG_SOC_EFUSE_ECDSA_KEY_P192 1 +#define CONFIG_SOC_EFUSE_ECDSA_KEY_P384 1 +#define CONFIG_SOC_KEY_MANAGER_SUPPORT_KEY_DEPLOYMENT 1 +#define CONFIG_SOC_KEY_MANAGER_ECDSA_KEY_DEPLOY 1 +#define CONFIG_SOC_KEY_MANAGER_FE_KEY_DEPLOY 1 +#define CONFIG_SOC_KEY_MANAGER_FE_KEY_DEPLOY_XTS_AES_128 1 +#define CONFIG_SOC_KEY_MANAGER_FE_KEY_DEPLOY_XTS_AES_256 1 +#define CONFIG_SOC_KEY_MANAGER_HMAC_KEY_DEPLOY 1 +#define CONFIG_SOC_KEY_MANAGER_DS_KEY_DEPLOY 1 +#define CONFIG_SOC_SECURE_BOOT_V2_RSA 1 +#define CONFIG_SOC_SECURE_BOOT_V2_ECC 1 +#define CONFIG_SOC_EFUSE_SECURE_BOOT_KEY_DIGESTS 3 +#define CONFIG_SOC_EFUSE_REVOKE_BOOT_KEY_DIGESTS 1 +#define CONFIG_SOC_SUPPORT_SECURE_BOOT_REVOKE_KEY 1 +#define CONFIG_SOC_FLASH_ENCRYPTED_XTS_AES_BLOCK_MAX 64 +#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES 1 +#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_OPTIONS 1 +#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_128 1 +#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_256 1 +#define CONFIG_SOC_FLASH_ENCRYPTION_XTS_AES_SUPPORT_PSEUDO_ROUND 1 +#define CONFIG_SOC_FLASH_ENCRYPTION_PAGE_CONFIGURABLE 1 +#define CONFIG_SOC_PSRAM_ENCRYPTION_SEPARATE_KEY 1 +#define CONFIG_SOC_PSRAM_ENCRYPTION_PAGE_CONFIGURABLE 1 +#define CONFIG_SOC_RECOVERY_BOOTLOADER_SUPPORTED 1 +#define CONFIG_SOC_UART_NUM 6 +#define CONFIG_SOC_UART_HP_NUM 5 +#define CONFIG_SOC_UART_LP_NUM 1 +#define CONFIG_SOC_UART_FIFO_LEN 128 +#define CONFIG_SOC_LP_UART_FIFO_LEN 16 +#define CONFIG_SOC_UART_BITRATE_MAX 5000000 +#define CONFIG_SOC_UART_SUPPORT_RTC_CLK 1 +#define CONFIG_SOC_UART_SUPPORT_XTAL_CLK 1 +#define CONFIG_SOC_UART_SUPPORT_WAKEUP_INT 1 +#define CONFIG_SOC_UART_HAS_LP_UART 1 +#define CONFIG_SOC_UART_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_UART_WAKEUP_CHARS_SEQ_MAX_LEN 5 +#define CONFIG_SOC_UART_WAKEUP_SUPPORT_ACTIVE_THRESH_MODE 1 +#define CONFIG_SOC_UART_WAKEUP_SUPPORT_FIFO_THRESH_MODE 1 +#define CONFIG_SOC_UART_WAKEUP_SUPPORT_START_BIT_MODE 1 +#define CONFIG_SOC_UART_WAKEUP_SUPPORT_CHAR_SEQ_MODE 1 +#define CONFIG_SOC_LP_I2S_SUPPORT_VAD 1 +#define CONFIG_SOC_UHCI_NUM 1 +#define CONFIG_SOC_COEX_HW_PTI 1 +#define CONFIG_SOC_PHY_DIG_REGS_MEM_SIZE 21 +#define CONFIG_SOC_WIFI_LIGHT_SLEEP_CLK_WIDTH 12 +#define CONFIG_SOC_PM_SUPPORT_EXT1_WAKEUP 1 +#define CONFIG_SOC_PM_SUPPORT_EXT1_WAKEUP_MODE_PER_PIN 1 +#define CONFIG_SOC_PM_EXT1_WAKEUP_BY_PMU 1 +#define CONFIG_SOC_PM_SUPPORT_WIFI_WAKEUP 1 +#define CONFIG_SOC_PM_SUPPORT_TOUCH_SENSOR_WAKEUP 1 +#define CONFIG_SOC_PM_SUPPORT_LP_UART_WAKEUP 1 +#define CONFIG_SOC_PM_SUPPORT_CPU_PD 1 +#define CONFIG_SOC_PM_SUPPORT_XTAL32K_PD 1 +#define CONFIG_SOC_PM_SUPPORT_RC32K_PD 1 +#define CONFIG_SOC_PM_SUPPORT_RC_FAST_PD 1 +#define CONFIG_SOC_PM_SUPPORT_VDDSDIO_PD 1 +#define CONFIG_SOC_PM_SUPPORT_TOP_PD 1 +#define CONFIG_SOC_PM_SUPPORT_CNNT_PD 1 +#define CONFIG_SOC_PM_SUPPORT_RTC_PERIPH_PD 1 +#define CONFIG_SOC_PM_SUPPORT_DEEPSLEEP_CHECK_STUB_ONLY 1 +#define CONFIG_SOC_PM_CPU_RETENTION_BY_SW 1 +#define CONFIG_SOC_PM_FPU_RETENTION_BY_SW 1 +#define CONFIG_SOC_PM_CACHE_RETENTION_BY_PAU 1 +#define CONFIG_SOC_PM_PAU_LINK_NUM 4 +#define CONFIG_SOC_PM_PAU_REGDMA_LINK_MULTI_ADDR 1 +#define CONFIG_SOC_PAU_IN_TOP_DOMAIN 1 +#define CONFIG_SOC_PM_PAU_REGDMA_UPDATE_CACHE_BEFORE_WAIT_COMPARE 1 +#define CONFIG_SOC_SLEEP_SYSTIMER_STALL_WORKAROUND 1 +#define CONFIG_SOC_SLEEP_TGWDT_STOP_WORKAROUND 1 +#define CONFIG_SOC_PM_RETENTION_MODULE_NUM 64 +#define CONFIG_SOC_CLK_RC_FAST_SUPPORT_CALIBRATION 1 +#define CONFIG_SOC_CLK_APLL_SUPPORTED 1 +#define CONFIG_SOC_CLK_MPLL_SUPPORTED 1 +#define CONFIG_SOC_CLK_XTAL32K_SUPPORTED 1 +#define CONFIG_SOC_CLK_RC32K_SUPPORTED 1 +#define CONFIG_SOC_CLK_LP_FAST_SUPPORT_LP_PLL 1 +#define CONFIG_SOC_CLK_LP_FAST_SUPPORT_XTAL 1 +#define CONFIG_SOC_PERIPH_CLK_CTRL_SHARED 1 +#define CONFIG_SOC_TEMPERATURE_SENSOR_INTR_SUPPORT 1 +#define CONFIG_SOC_TSENS_IS_INDEPENDENT_FROM_ADC 1 +#define CONFIG_SOC_TEMPERATURE_SENSOR_SUPPORT_ETM 1 +#define CONFIG_SOC_TEMPERATURE_SENSOR_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_MEM_SPM_SUPPORTED 1 +#define CONFIG_SOC_ASYNCHRONOUS_BUS_ERROR_MODE 1 +#define CONFIG_SOC_EMAC_IEEE1588V2_SUPPORTED 1 +#define CONFIG_SOC_EMAC_USE_MULTI_IO_MUX 1 +#define CONFIG_SOC_EMAC_MII_USE_GPIO_MATRIX 1 +#define CONFIG_SOC_EMAC_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_JPEG_CODEC_SUPPORTED 1 +#define CONFIG_SOC_JPEG_DECODE_SUPPORTED 1 +#define CONFIG_SOC_JPEG_ENCODE_SUPPORTED 1 +#define CONFIG_SOC_H264_ENCODER_SUPPORTED 1 +#define CONFIG_SOC_LCDCAM_CAM_SUPPORT_RGB_YUV_CONV 1 +#define CONFIG_SOC_LCDCAM_LCD_SUPPORT_SLEEP_RETENTION 1 +#define CONFIG_SOC_I3C_MASTER_PERIPH_NUM 1 +#define CONFIG_SOC_I3C_MASTER_ADDRESS_TABLE_NUM 12 +#define CONFIG_SOC_I3C_MASTER_COMMAND_TABLE_NUM 12 +#define CONFIG_SOC_LP_CORE_SUPPORT_ETM 1 +#define CONFIG_SOC_LP_CORE_SUPPORT_LP_ADC 1 +#define CONFIG_SOC_LP_CORE_SUPPORT_STORE_LOAD_EXCEPTIONS 1 +#define CONFIG_IDF_CMAKE 1 +#define CONFIG_IDF_TOOLCHAIN "gcc" +#define CONFIG_IDF_TOOLCHAIN_GCC 1 +#define CONFIG_IDF_TARGET_ARCH_RISCV 1 +#define CONFIG_IDF_TARGET_ARCH "riscv" +#define CONFIG_IDF_TARGET "esp32p4" +#define CONFIG_IDF_INIT_VERSION "6.0.2" +#define CONFIG_IDF_TARGET_ESP32P4 1 +#define CONFIG_IDF_FIRMWARE_CHIP_ID 0x0012 +#define CONFIG_APP_BUILD_TYPE_APP_2NDBOOT 1 +#define CONFIG_APP_BUILD_GENERATE_BINARIES 1 +#define CONFIG_APP_BUILD_BOOTLOADER 1 +#define CONFIG_APP_BUILD_USE_FLASH_SECTIONS 1 +#define CONFIG_BOOTLOADER_COMPILE_TIME_DATE 1 +#define CONFIG_BOOTLOADER_PROJECT_VER 1 +#define CONFIG_BOOTLOADER_OFFSET_IN_FLASH 0x2000 +#define CONFIG_BOOTLOADER_COMPILER_OPTIMIZATION_SIZE 1 +#define CONFIG_BOOTLOADER_LOG_VERSION_1 1 +#define CONFIG_BOOTLOADER_LOG_VERSION 1 +#define CONFIG_BOOTLOADER_LOG_LEVEL_INFO 1 +#define CONFIG_BOOTLOADER_LOG_LEVEL 3 +#define CONFIG_BOOTLOADER_LOG_TIMESTAMP_SOURCE_CPU_TICKS 1 +#define CONFIG_BOOTLOADER_LOG_MODE_TEXT_EN 1 +#define CONFIG_BOOTLOADER_LOG_MODE_TEXT 1 +#define CONFIG_BOOTLOADER_CPU_CLK_FREQ_MHZ 90 +#define CONFIG_BOOTLOADER_FLASH_XMC_SUPPORT 1 +#define CONFIG_BOOTLOADER_REGION_PROTECTION_ENABLE 1 +#define CONFIG_BOOTLOADER_WDT_ENABLE 1 +#define CONFIG_BOOTLOADER_WDT_TIME_MS 9000 +#define CONFIG_BOOTLOADER_RESERVE_RTC_SIZE 0x0 +#define CONFIG_SECURE_BOOT_V2_RSA_SUPPORTED 1 +#define CONFIG_SECURE_BOOT_V2_ECC_SUPPORTED 1 +#define CONFIG_SECURE_BOOT_V2_ECDSA_INSECURE 1 +#define CONFIG_SECURE_BOOT_V2_PREFERRED 1 +#define CONFIG_SECURE_ROM_DL_MODE_ENABLED 1 +#define CONFIG_APP_COMPILE_TIME_DATE 1 +#define CONFIG_APP_RETRIEVE_LEN_ELF_SHA 9 +#define CONFIG_ESP_ROM_HAS_CRC_LE 1 +#define CONFIG_ESP_ROM_HAS_CRC_BE 1 +#define CONFIG_ESP_ROM_UART_CLK_IS_XTAL 1 +#define CONFIG_ESP_ROM_USB_SERIAL_DEVICE_NUM 6 +#define CONFIG_ESP_ROM_USB_OTG_NUM 5 +#define CONFIG_ESP_ROM_HAS_RETARGETABLE_LOCKING 1 +#define CONFIG_ESP_ROM_GET_CLK_FREQ 1 +#define CONFIG_ESP_ROM_HAS_RVFPLIB 1 +#define CONFIG_ESP_ROM_HAS_HAL_WDT 1 +#define CONFIG_ESP_ROM_HAS_HAL_SYSTIMER 1 +#define CONFIG_ESP_ROM_SYSTIMER_INIT_PATCH 1 +#define CONFIG_ESP_ROM_HAS_LAYOUT_TABLE 1 +#define CONFIG_ESP_ROM_WDT_INIT_PATCH 1 +#define CONFIG_ESP_ROM_HAS_LP_ROM 1 +#define CONFIG_ESP_ROM_WITHOUT_REGI2C 1 +#define CONFIG_ESP_ROM_HAS_NEWLIB 1 +#define CONFIG_ESP_ROM_HAS_NEWLIB_NANO_FORMAT 1 +#define CONFIG_ESP_ROM_HAS_NEWLIB_NANO_PRINTF_FLOAT_BUG 1 +#define CONFIG_ESP_ROM_HAS_VERSION 1 +#define CONFIG_ESP_ROM_CLIC_INT_TYPE_PATCH 1 +#define CONFIG_ESP_ROM_HAS_OUTPUT_PUTC_FUNC 1 +#define CONFIG_ESP_ROM_HAS_SUBOPTIMAL_NEWLIB_ON_MISALIGNED_MEMORY 1 +#define CONFIG_ESP_ROM_ECDSA_VERIFY_PATCH 1 +#define CONFIG_ESP_ROM_BOOTLOADER_OFFSET_FLASH 0x2000 +#define CONFIG_ESP_ROM_CACHE_WRITEBACK_NEEDS_SYNC_TWICE_MAP 1 +#define CONFIG_BOOT_ROM_LOG_ALWAYS_ON 1 +#define CONFIG_ESPTOOLPY_FLASHMODE_DIO 1 +#define CONFIG_ESPTOOLPY_FLASH_SAMPLE_MODE_STR 1 +#define CONFIG_ESPTOOLPY_FLASHMODE "dio" +#define CONFIG_ESPTOOLPY_FLASHFREQ_80M 1 +#define CONFIG_ESPTOOLPY_FLASHFREQ_VAL 80 +#define CONFIG_ESPTOOLPY_FLASHFREQ "80m" +#define CONFIG_ESPTOOLPY_FLASHSIZE_16MB 1 +#define CONFIG_ESPTOOLPY_FLASHSIZE "16MB" +#define CONFIG_ESPTOOLPY_BEFORE_RESET 1 +#define CONFIG_ESPTOOLPY_BEFORE "default-reset" +#define CONFIG_ESPTOOLPY_AFTER_RESET 1 +#define CONFIG_ESPTOOLPY_AFTER "hard-reset" +#define CONFIG_ESPTOOLPY_MONITOR_BAUD 115200 +#define CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE 1 +#define CONFIG_PARTITION_TABLE_CUSTOM_FILENAME "partitions.csv" +#define CONFIG_PARTITION_TABLE_FILENAME "partitions_singleapp_large.csv" +#define CONFIG_PARTITION_TABLE_OFFSET 0x8000 +#define CONFIG_PARTITION_TABLE_MD5 1 +#define CONFIG_COMPILER_OPTIMIZATION_DEBUG 1 +#define CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE 1 +#define CONFIG_COMPILER_FLOAT_LIB_FROM_RVFPLIB 1 +#define CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL 2 +#define CONFIG_COMPILER_HIDE_PATHS_MACROS 1 +#define CONFIG_COMPILER_STACK_CHECK_MODE_NONE 1 +#define CONFIG_COMPILER_RT_LIB_GCCLIB 1 +#define CONFIG_COMPILER_RT_LIB_NAME "gcc" +#define CONFIG_COMPILER_ORPHAN_SECTIONS_ERROR 1 +#define CONFIG_COMPILER_CXX_GLIBCXX_CONSTEXPR_NO_CHANGE 1 +#define CONFIG_BT_ENABLED 1 +#define CONFIG_BT_NIMBLE_ENABLED 1 +#define CONFIG_BT_CONTROLLER_DISABLED 1 +#define CONFIG_BT_ALARM_MAX_NUM 50 +#define CONFIG_BT_SMP_CRYPTO_STACK_TINYCRYPT 1 +#define CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL 1 +#define CONFIG_BT_NIMBLE_PINNED_TO_CORE 0 +#define CONFIG_BT_NIMBLE_PINNED_TO_CORE_0 1 +#define CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE 4096 +#define CONFIG_BT_NIMBLE_ROLE_PERIPHERAL 1 +#define CONFIG_BT_NIMBLE_ROLE_BROADCASTER 1 +#define CONFIG_BT_NIMBLE_ROLE_OBSERVER 1 +#define CONFIG_BT_NIMBLE_GATT_SERVER 1 +#define CONFIG_BT_NIMBLE_SECURITY_ENABLE 1 +#define CONFIG_BT_NIMBLE_SM_LEGACY 1 +#define CONFIG_BT_NIMBLE_SM_SC 1 +#define CONFIG_BT_NIMBLE_LL_CFG_FEAT_LE_ENCRYPTION 1 +#define CONFIG_BT_NIMBLE_SM_LVL 0 +#define CONFIG_BT_NIMBLE_SM_SC_ONLY 0 +#define CONFIG_BT_NIMBLE_MAX_BONDS 3 +#define CONFIG_BT_NIMBLE_RPA_TIMEOUT 900 +#define CONFIG_BT_NIMBLE_WHITELIST_SIZE 12 +#define CONFIG_BT_NIMBLE_HS_PVCY 1 +#define CONFIG_BT_NIMBLE_MAX_CONNECTIONS 3 +#define CONFIG_BT_NIMBLE_MAX_CCCDS 8 +#define CONFIG_BT_NIMBLE_HS_STOP_TIMEOUT_MS 2000 +#define CONFIG_BT_NIMBLE_USE_ESP_TIMER 1 +#define CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU 256 +#define CONFIG_BT_NIMBLE_ATT_MAX_PREP_ENTRIES 64 +#define CONFIG_BT_NIMBLE_GATT_MAX_PROCS 4 +#define CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM 0 +#define CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT 12 +#define CONFIG_BT_NIMBLE_MSYS_1_BLOCK_SIZE 256 +#define CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT 24 +#define CONFIG_BT_NIMBLE_MSYS_2_BLOCK_SIZE 320 +#define CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT 24 +#define CONFIG_BT_NIMBLE_TRANSPORT_ACL_SIZE 255 +#define CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE 70 +#define CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT 30 +#define CONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT 8 +#define CONFIG_BT_NIMBLE_L2CAP_COC_SDU_BUFF_COUNT 1 +#define CONFIG_BT_NIMBLE_PROX_SERVICE 1 +#define CONFIG_BT_NIMBLE_ANS_SERVICE 1 +#define CONFIG_BT_NIMBLE_CTS_SERVICE 1 +#define CONFIG_BT_NIMBLE_HTP_SERVICE 1 +#define CONFIG_BT_NIMBLE_IPSS_SERVICE 1 +#define CONFIG_BT_NIMBLE_TPS_SERVICE 1 +#define CONFIG_BT_NIMBLE_IAS_SERVICE 1 +#define CONFIG_BT_NIMBLE_LLS_SERVICE 1 +#define CONFIG_BT_NIMBLE_SPS_SERVICE 1 +#define CONFIG_BT_NIMBLE_HR_SERVICE 1 +#define CONFIG_BT_NIMBLE_BAS_SERVICE 1 +#define CONFIG_BT_NIMBLE_DIS_SERVICE 1 +#define CONFIG_BT_NIMBLE_GAP_SERVICE 1 +#define CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME "nimble" +#define CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN 31 +#define CONFIG_BT_NIMBLE_SVC_GAP_APPEARANCE 0x0 +#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_ENC 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_AUTHEN 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_NAME_WRITE_PERM_AUTHOR 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_CAR_CHAR_NOT_SUPP 1 +#define CONFIG_BT_NIMBLE_SVC_GAP_CENT_ADDR_RESOLUTION -1 +#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ENC 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ATHN 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_APPEAR_WRITE_PERM_ATHR 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MAX_CONN_INTERVAL 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_MIN_CONN_INTERVAL 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SLAVE_LATENCY 0 +#define CONFIG_BT_NIMBLE_SVC_GAP_PPCP_SUPERVISION_TMO 0 +#define CONFIG_BT_NIMBLE_EATT_CHAN_NUM 0 +#define CONFIG_BT_NIMBLE_DTM_MODE_TEST 1 +#define CONFIG_BT_NIMBLE_MEM_OPTIMIZATION 1 +#define CONFIG_BT_NIMBLE_STATIC_TO_DYNAMIC 1 +#define CONFIG_BT_NIMBLE_SM_SIGN_CNT 1 +#define CONFIG_BT_NIMBLE_CPFD_CAFD 1 +#define CONFIG_BT_NIMBLE_RECONFIG_MTU 1 +#define CONFIG_UART_HW_FLOWCTRL_DISABLE 1 +#define CONFIG_BT_NIMBLE_HCI_UART_FLOW_CTRL 0 +#define CONFIG_BT_NIMBLE_HCI_UART_RTS_PIN 19 +#define CONFIG_BT_NIMBLE_HCI_UART_CTS_PIN 23 +#define CONFIG_BT_NIMBLE_LOG_LEVEL_INFO 1 +#define CONFIG_BT_NIMBLE_LOG_LEVEL 1 +#define CONFIG_BT_NIMBLE_PRINT_ERR_NAME 1 +#define CONFIG_BT_NIMBLE_CHK_HOST_STATUS 1 +#define CONFIG_BT_NIMBLE_UTIL_API 1 +#define CONFIG_BT_NIMBLE_EXTRA_ADV_FIELDS 1 +#define CONFIG_EFUSE_MAX_BLK_LEN 256 +#define CONFIG_ESP_TLS_USING_MBEDTLS 1 +#define CONFIG_ESP_TLS_USE_DS_PERIPHERAL 1 +#define CONFIG_ESP_TLS_DYN_BUF_STRATEGY_SUPPORTED 1 +#define CONFIG_ESP_ERR_TO_NAME_LOOKUP 1 +#define CONFIG_ANA_CMPR_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_ANA_CMPR_OBJ_CACHE_SAFE 1 +#define CONFIG_GDMA_CTRL_FUNC_IN_IRAM 1 +#define CONFIG_GDMA_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_GDMA_OBJ_DRAM_SAFE 1 +#define CONFIG_GPTIMER_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_GPTIMER_OBJ_CACHE_SAFE 1 +#define CONFIG_I2C_MASTER_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_MCPWM_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_MCPWM_OBJ_CACHE_SAFE 1 +#define CONFIG_PARLIO_TX_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_PARLIO_RX_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_PARLIO_OBJ_CACHE_SAFE 1 +#define CONFIG_RMT_ENCODER_FUNC_IN_IRAM 1 +#define CONFIG_RMT_TX_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_RMT_RX_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_RMT_OBJ_CACHE_SAFE 1 +#define CONFIG_SPI_MASTER_ISR_IN_IRAM 1 +#define CONFIG_SPI_SLAVE_ISR_IN_IRAM 1 +#define CONFIG_USJ_ENABLE_USB_SERIAL_JTAG 1 +#define CONFIG_ETH_ENABLED 1 +#define CONFIG_ETH_USE_ESP32_EMAC 1 +#define CONFIG_ETH_DMA_BUFFER_SIZE 512 +#define CONFIG_ETH_DMA_RX_BUFFER_NUM 20 +#define CONFIG_ETH_DMA_TX_BUFFER_NUM 10 +#define CONFIG_ETH_USE_SPI_ETHERNET 1 +#define CONFIG_ESP_EVENT_POST_FROM_ISR 1 +#define CONFIG_ESP_EVENT_POST_FROM_IRAM_ISR 1 +#define CONFIG_ESP_GDBSTUB_ENABLED 1 +#define CONFIG_ESP_GDBSTUB_SUPPORT_TASKS 1 +#define CONFIG_ESP_GDBSTUB_MAX_TASKS 32 +#define CONFIG_ESPHID_TASK_SIZE_BT 2048 +#define CONFIG_ESPHID_TASK_SIZE_BLE 4096 +#define CONFIG_ESP_HTTP_CLIENT_ENABLE_HTTPS 1 +#define CONFIG_ESP_HTTP_CLIENT_EVENT_POST_TIMEOUT 2000 +#define CONFIG_HTTPD_MAX_REQ_HDR_LEN 1024 +#define CONFIG_HTTPD_MAX_URI_LEN 512 +#define CONFIG_HTTPD_ERR_RESP_NO_DELAY 1 +#define CONFIG_HTTPD_PURGE_BUF_LEN 32 +#define CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT 2000 +#define CONFIG_ESP_HTTPS_OTA_EVENT_POST_TIMEOUT 2000 +#define CONFIG_ESP_HTTPS_SERVER_EVENT_POST_TIMEOUT 2000 +#define CONFIG_ESP_HW_SUPPORT_FUNC_IN_IRAM 1 +#define CONFIG_ESP32P4_SELECTS_REV_LESS_V3 1 +#define CONFIG_ESP32P4_REV_MIN_100 1 +#define CONFIG_ESP32P4_REV_MIN_FULL 100 +#define CONFIG_ESP_REV_MIN_FULL 100 +#define CONFIG_ESP32P4_REV_MAX_FULL 199 +#define CONFIG_ESP_REV_MAX_FULL 199 +#define CONFIG_ESP_EFUSE_BLOCK_REV_MIN_FULL 0 +#define CONFIG_ESP_EFUSE_BLOCK_REV_MAX_FULL 199 +#define CONFIG_ESP_MAC_ADDR_UNIVERSE_ETH 1 +#define CONFIG_ESP_MAC_UNIVERSAL_MAC_ADDRESSES_ONE 1 +#define CONFIG_ESP_MAC_UNIVERSAL_MAC_ADDRESSES 1 +#define CONFIG_ESP32P4_UNIVERSAL_MAC_ADDRESSES_ONE 1 +#define CONFIG_ESP32P4_UNIVERSAL_MAC_ADDRESSES 1 +#define CONFIG_ESP_SLEEP_FLASH_LEAKAGE_WORKAROUND 1 +#define CONFIG_ESP_SLEEP_PSRAM_LEAKAGE_WORKAROUND 1 +#define CONFIG_ESP_SLEEP_GPIO_RESET_WORKAROUND 1 +#define CONFIG_ESP_SLEEP_WAIT_FLASH_READY_EXTRA_DELAY 0 +#define CONFIG_ESP_SLEEP_GPIO_ENABLE_INTERNAL_RESISTORS 1 +#define CONFIG_RTC_CLK_SRC_INT_RC 1 +#define CONFIG_RTC_CLK_CAL_CYCLES 1024 +#define CONFIG_RTC_FAST_CLK_SRC_RC_FAST 1 +#define CONFIG_RTC_CLK_FUNC_IN_IRAM 1 +#define CONFIG_RTC_TIME_FUNC_IN_IRAM 1 +#define CONFIG_ESP_PERIPH_CTRL_FUNC_IN_IRAM 1 +#define CONFIG_ESP_REGI2C_CTRL_FUNC_IN_IRAM 1 +#define CONFIG_XTAL_FREQ_40 1 +#define CONFIG_XTAL_FREQ 40 +#define CONFIG_ESP_SLEEP_DCM_VSET_VAL_IN_SLEEP 14 +#define CONFIG_ESP_LDO_RESERVE_SPI_NOR_FLASH 1 +#define CONFIG_ESP_LDO_CHAN_SPI_NOR_FLASH_DOMAIN 1 +#define CONFIG_ESP_LDO_VOLTAGE_SPI_NOR_FLASH_3300_MV 1 +#define CONFIG_ESP_LDO_VOLTAGE_SPI_NOR_FLASH_DOMAIN 3300 +#define CONFIG_ESP_LDO_RESERVE_PSRAM 1 +#define CONFIG_ESP_LDO_CHAN_PSRAM_DOMAIN 2 +#define CONFIG_ESP_LDO_VOLTAGE_PSRAM_1800_MV 1 +#define CONFIG_ESP_LDO_VOLTAGE_PSRAM_DOMAIN 1800 +#define CONFIG_ESP_BROWNOUT_DET 1 +#define CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7 1 +#define CONFIG_ESP_BROWNOUT_DET_LVL 7 +#define CONFIG_ESP_BROWNOUT_USE_INTR 1 +#define CONFIG_ESP_SPI_BUS_LOCK_ISR_FUNCS_IN_IRAM 1 +#define CONFIG_ESP_ENABLE_PVT 1 +#define CONFIG_ESP_INTR_IN_IRAM 1 +#define CONFIG_P4_REV3_MSPI_WORKAROUND_SIZE 0x0 +#define CONFIG_LCD_DSI_ISR_HANDLER_IN_IRAM 1 +#define CONFIG_LCD_DSI_OBJ_FORCE_INTERNAL 1 +#define CONFIG_LIBC_PICOLIBC 1 +#define CONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITY 1 +#define CONFIG_LIBC_MISC_IN_IRAM 1 +#define CONFIG_LIBC_LOCKS_PLACE_IN_IRAM 1 +#define CONFIG_LIBC_STDOUT_LINE_ENDING_CRLF 1 +#define CONFIG_LIBC_STDIN_LINE_ENDING_CR 1 +#define CONFIG_LIBC_TIME_SYSCALL_USE_RTC_HRT 1 +#define CONFIG_LIBC_OPTIMIZED_MISALIGNED_ACCESS 1 +#define CONFIG_LIBC_ASSERT_BUFFER_SIZE 200 +#define CONFIG_ESP_NETIF_LOST_IP_TIMER_ENABLE 1 +#define CONFIG_ESP_NETIF_IP_LOST_TIMER_INTERVAL 120 +#define CONFIG_ESP_NETIF_TCPIP_LWIP 1 +#define CONFIG_ESP_NETIF_USES_TCPIP_WITH_BSD_API 1 +#define CONFIG_ESP_NETIF_REPORT_DATA_TRAFFIC 1 +#define CONFIG_ESP_NETIF_RECEIVE_REPORT_ERRORS 1 +#define CONFIG_PM_SLEEP_FUNC_IN_IRAM 1 +#define CONFIG_PM_SLP_IRAM_OPT 1 +#define CONFIG_PM_SLP_DEFAULT_PARAMS_OPT 1 +#define CONFIG_SPIRAM 1 +#define CONFIG_SPIRAM_MODE_HEX 1 +#define CONFIG_SPIRAM_SPEED_200M 1 +#define CONFIG_SPIRAM_SPEED 200 +#define CONFIG_SPIRAM_BOOT_HW_INIT 1 +#define CONFIG_SPIRAM_BOOT_INIT 1 +#define CONFIG_SPIRAM_PRE_CONFIGURE_MEMORY_PROTECTION 1 +#define CONFIG_SPIRAM_USE_MALLOC 1 +#define CONFIG_SPIRAM_MEMTEST 1 +#define CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL 16384 +#define CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL 32768 +#define CONFIG_ESP_ROM_PRINT_IN_IRAM 1 +#define CONFIG_ESP_CONSOLE_UART_DEFAULT 1 +#define CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG 1 +#define CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG_ENABLED 1 +#define CONFIG_ESP_CONSOLE_UART 1 +#define CONFIG_ESP_CONSOLE_UART_NUM 0 +#define CONFIG_ESP_CONSOLE_ROM_SERIAL_PORT_NUM 0 +#define CONFIG_ESP_CONSOLE_UART_BAUDRATE 115200 +#define CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_360 1 +#define CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ 360 +#define CONFIG_CACHE_L2_CACHE_128KB 1 +#define CONFIG_CACHE_L2_CACHE_SIZE 0x20000 +#define CONFIG_CACHE_L2_CACHE_LINE_64B 1 +#define CONFIG_CACHE_L2_CACHE_LINE_SIZE 64 +#define CONFIG_CACHE_L1_CACHE_LINE_SIZE 64 +#define CONFIG_ESP_SYSTEM_IN_IRAM 1 +#define CONFIG_ESP_SYSTEM_PANIC_PRINT_REBOOT 1 +#define CONFIG_ESP_SYSTEM_PANIC_REBOOT_DELAY_SECONDS 0 +#define CONFIG_ESP_SYSTEM_RTC_FAST_MEM_AS_HEAP_DEPCHECK 1 +#define CONFIG_ESP_SYSTEM_ALLOW_RTC_FAST_MEM_AS_HEAP 1 +#define CONFIG_ESP_SYSTEM_NO_BACKTRACE 1 +#define CONFIG_ESP_SYSTEM_MEMPROT 1 +#define CONFIG_ESP_SYSTEM_MEMPROT_PMP 1 +#define CONFIG_ESP_SYSTEM_EVENT_QUEUE_SIZE 32 +#define CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE 2304 +#define CONFIG_ESP_MAIN_TASK_STACK_SIZE 6144 +#define CONFIG_ESP_MAIN_TASK_AFFINITY_CPU0 1 +#define CONFIG_ESP_MAIN_TASK_AFFINITY 0x0 +#define CONFIG_ESP_MINIMAL_SHARED_STACK_SIZE 2048 +#define CONFIG_ESP_INT_WDT 1 +#define CONFIG_ESP_INT_WDT_TIMEOUT_MS 300 +#define CONFIG_ESP_INT_WDT_CHECK_CPU1 1 +#define CONFIG_ESP_TASK_WDT_EN 1 +#define CONFIG_ESP_TASK_WDT_INIT 1 +#define CONFIG_ESP_TASK_WDT_TIMEOUT_S 5 +#define CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 1 +#define CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 1 +#define CONFIG_ESP_DEBUG_OCDAWARE 1 +#define CONFIG_ESP_SYSTEM_CHECK_INT_LEVEL_4 1 +#define CONFIG_ESP_SYSTEM_HW_STACK_GUARD 1 +#define CONFIG_ESP_SYSTEM_HW_PC_RECORD 1 +#define CONFIG_ESP_IPC_ENABLE 1 +#define CONFIG_ESP_IPC_TASK_STACK_SIZE 1024 +#define CONFIG_ESP_IPC_USES_CALLERS_PRIORITY 1 +#define CONFIG_ESP_IPC_ISR_ENABLE 1 +#define CONFIG_ESP_TIMER_IN_IRAM 1 +#define CONFIG_ESP_TIME_FUNCS_USE_RTC_TIMER 1 +#define CONFIG_ESP_TIME_FUNCS_USE_ESP_TIMER 1 +#define CONFIG_ESP_TIMER_TASK_STACK_SIZE 3584 +#define CONFIG_ESP_TIMER_INTERRUPT_LEVEL 1 +#define CONFIG_ESP_TIMER_TASK_AFFINITY 0x0 +#define CONFIG_ESP_TIMER_TASK_AFFINITY_CPU0 1 +#define CONFIG_ESP_TIMER_ISR_AFFINITY_CPU0 1 +#define CONFIG_ESP_TIMER_IMPL_SYSTIMER 1 +#define CONFIG_ESP_TRACE_LIB_NONE 1 +#define CONFIG_ESP_TRACE_LIB_NAME "none" +#define CONFIG_ESP_TRACE_TRANSPORT_NONE 1 +#define CONFIG_ESP_TRACE_TRANSPORT_NAME "none" +#define CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM 10 +#define CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM 32 +#define CONFIG_ESP_WIFI_TX_BUFFER_TYPE 1 +#define CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM 32 +#define CONFIG_ESP_WIFI_DYNAMIC_RX_MGMT_BUF 0 +#define CONFIG_ESP_WIFI_RX_MGMT_BUF_NUM_DEF 5 +#define CONFIG_ESP_WIFI_AMPDU_TX_ENABLED 1 +#define CONFIG_ESP_WIFI_TX_BA_WIN 6 +#define CONFIG_ESP_WIFI_AMPDU_RX_ENABLED 1 +#define CONFIG_ESP_WIFI_RX_BA_WIN 6 +#define CONFIG_ESP_WIFI_NVS_ENABLED 1 +#define CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN 752 +#define CONFIG_ESP_WIFI_MGMT_SBUF_NUM 32 +#define CONFIG_ESP_WIFI_IRAM_OPT 1 +#define CONFIG_ESP_WIFI_EXTRA_IRAM_OPT 1 +#define CONFIG_ESP_WIFI_RX_IRAM_OPT 1 +#define CONFIG_ESP_WIFI_ENABLE_WPA3_SAE 1 +#define CONFIG_ESP_WIFI_ENABLE_SAE_H2E 1 +#define CONFIG_ESP_WIFI_ENABLE_SAE_PK 1 +#define CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT 1 +#define CONFIG_ESP_WIFI_ENABLE_WPA3_OWE_STA 1 +#define CONFIG_ESP_WIFI_WPA3_COMPATIBLE_SUPPORT 1 +#define CONFIG_ESP_WIFI_SLP_IRAM_OPT 1 +#define CONFIG_ESP_WIFI_SLP_DEFAULT_MIN_ACTIVE_TIME 50 +#define CONFIG_ESP_WIFI_BSS_MAX_IDLE_SUPPORT 1 +#define CONFIG_ESP_WIFI_SLP_DEFAULT_MAX_ACTIVE_TIME 10 +#define CONFIG_ESP_WIFI_SLP_DEFAULT_WAIT_BROADCAST_DATA_TIME 15 +#define CONFIG_ESP_WIFI_STA_DISCONNECTED_PM_ENABLE 1 +#define CONFIG_ESP_WIFI_GMAC_SUPPORT 1 +#define CONFIG_ESP_WIFI_SOFTAP_SUPPORT 1 +#define CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM 7 +#define CONFIG_ESP_WIFI_MBEDTLS_CRYPTO 1 +#define CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT 1 +#define CONFIG_ESP_WIFI_TX_HETB_QUEUE_NUM 3 +#define CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT 1 +#define CONFIG_ESP_COREDUMP_ENABLE_TO_NONE 1 +#define CONFIG_FATFS_VOLUME_COUNT 2 +#define CONFIG_FATFS_LFN_HEAP 1 +#define CONFIG_FATFS_SECTOR_4096 1 +#define CONFIG_FATFS_CODEPAGE_437 1 +#define CONFIG_FATFS_CODEPAGE 437 +#define CONFIG_FATFS_MAX_LFN 255 +#define CONFIG_FATFS_API_ENCODING_ANSI_OEM 1 +#define CONFIG_FATFS_FS_LOCK 0 +#define CONFIG_FATFS_TIMEOUT_MS 10000 +#define CONFIG_FATFS_PER_FILE_CACHE 1 +#define CONFIG_FATFS_ALLOC_PREFER_EXTRAM 1 +#define CONFIG_FATFS_USE_STRFUNC_NONE 1 +#define CONFIG_FATFS_VFS_FSTAT_BLKSIZE 0 +#define CONFIG_FATFS_LINK_LOCK 1 +#define CONFIG_FATFS_USE_DYN_BUFFERS 1 +#define CONFIG_FATFS_DONT_TRUST_FREE_CLUSTER_CNT 0 +#define CONFIG_FATFS_DONT_TRUST_LAST_ALLOC 0 +#define CONFIG_FREERTOS_HZ 1000 +#define CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY 1 +#define CONFIG_FREERTOS_THREAD_LOCAL_STORAGE_POINTERS 1 +#define CONFIG_FREERTOS_IDLE_TASK_STACKSIZE 1536 +#define CONFIG_FREERTOS_MAX_TASK_NAME_LEN 16 +#define CONFIG_FREERTOS_USE_TIMERS 1 +#define CONFIG_FREERTOS_TIMER_SERVICE_TASK_NAME "Tmr Svc" +#define CONFIG_FREERTOS_TIMER_TASK_NO_AFFINITY 1 +#define CONFIG_FREERTOS_TIMER_SERVICE_TASK_CORE_AFFINITY 0x7FFFFFFF +#define CONFIG_FREERTOS_TIMER_TASK_PRIORITY 1 +#define CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH 2048 +#define CONFIG_FREERTOS_TIMER_QUEUE_LENGTH 10 +#define CONFIG_FREERTOS_QUEUE_REGISTRY_SIZE 0 +#define CONFIG_FREERTOS_TASK_NOTIFICATION_ARRAY_ENTRIES 1 +#define CONFIG_FREERTOS_TASK_FUNCTION_WRAPPER 1 +#define CONFIG_FREERTOS_TLSP_DELETION_CALLBACKS 1 +#define CONFIG_FREERTOS_CHECK_MUTEX_GIVEN_BY_OWNER 1 +#define CONFIG_FREERTOS_ISR_STACKSIZE 1536 +#define CONFIG_FREERTOS_INTERRUPT_BACKTRACE 1 +#define CONFIG_FREERTOS_TICK_SUPPORT_SYSTIMER 1 +#define CONFIG_FREERTOS_CORETIMER_SYSTIMER_LVL1 1 +#define CONFIG_FREERTOS_SYSTICK_USES_SYSTIMER 1 +#define CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM 1 +#define CONFIG_FREERTOS_PORT 1 +#define CONFIG_FREERTOS_NO_AFFINITY 0x7FFFFFFF +#define CONFIG_FREERTOS_SUPPORT_STATIC_ALLOCATION 1 +#define CONFIG_FREERTOS_DEBUG_OCDAWARE 1 +#define CONFIG_FREERTOS_NUMBER_OF_CORES 2 +#define CONFIG_HAL_ASSERTION_EQUALS_SYSTEM 1 +#define CONFIG_HAL_DEFAULT_ASSERTION_LEVEL 2 +#define CONFIG_HAL_SYSTIMER_USE_ROM_IMPL 1 +#define CONFIG_HAL_WDT_USE_ROM_IMPL 1 +#define CONFIG_HAL_GPIO_USE_ROM_IMPL 1 +#define CONFIG_HEAP_POISONING_DISABLED 1 +#define CONFIG_HEAP_TRACING_OFF 1 +#define CONFIG_LOG_VERSION_1 1 +#define CONFIG_LOG_VERSION 1 +#define CONFIG_LOG_DEFAULT_LEVEL_INFO 1 +#define CONFIG_LOG_DEFAULT_LEVEL 3 +#define CONFIG_LOG_MAXIMUM_EQUALS_DEFAULT 1 +#define CONFIG_LOG_MAXIMUM_LEVEL 3 +#define CONFIG_LOG_DYNAMIC_LEVEL_CONTROL 1 +#define CONFIG_LOG_TAG_LEVEL_IMPL_CACHE_AND_LINKED_LIST 1 +#define CONFIG_LOG_TAG_LEVEL_CACHE_BINARY_MIN_HEAP 1 +#define CONFIG_LOG_TAG_LEVEL_IMPL_CACHE_SIZE 31 +#define CONFIG_LOG_TIMESTAMP_SOURCE_RTOS 1 +#define CONFIG_LOG_MODE_TEXT_EN 1 +#define CONFIG_LOG_MODE_TEXT 1 +#define CONFIG_LOG_IN_IRAM 1 +#define CONFIG_LWIP_ENABLE 1 +#define CONFIG_LWIP_LOCAL_HOSTNAME "espressif" +#define CONFIG_LWIP_TCPIP_TASK_PRIO 18 +#define CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES 1 +#define CONFIG_LWIP_TIMERS_ONDEMAND 1 +#define CONFIG_LWIP_ND6 1 +#define CONFIG_LWIP_MAX_SOCKETS 10 +#define CONFIG_LWIP_SO_REUSE 1 +#define CONFIG_LWIP_SO_REUSE_RXTOALL 1 +#define CONFIG_LWIP_IP_DEFAULT_TTL 64 +#define CONFIG_LWIP_IP4_FRAG 1 +#define CONFIG_LWIP_IP6_FRAG 1 +#define CONFIG_LWIP_IP_REASS_MAX_PBUFS 10 +#define CONFIG_LWIP_IPV6_DUP_DETECT_ATTEMPTS 1 +#define CONFIG_LWIP_ESP_GRATUITOUS_ARP 1 +#define CONFIG_LWIP_GARP_TMR_INTERVAL 60 +#define CONFIG_LWIP_ESP_MLDV6_REPORT 1 +#define CONFIG_LWIP_MLDV6_TMR_INTERVAL 40 +#define CONFIG_LWIP_TCPIP_RECVMBOX_SIZE 32 +#define CONFIG_LWIP_DHCP_DOES_ARP_CHECK 1 +#define CONFIG_LWIP_DHCP_DISABLE_VENDOR_CLASS_ID 1 +#define CONFIG_LWIP_DHCP_OPTIONS_LEN 69 +#define CONFIG_LWIP_NUM_NETIF_CLIENT_DATA 0 +#define CONFIG_LWIP_DHCP_COARSE_TIMER_SECS 1 +#define CONFIG_LWIP_DHCPS 1 +#define CONFIG_LWIP_DHCPS_REPORT_CLIENT_HOSTNAME 1 +#define CONFIG_LWIP_DHCPS_LEASE_UNIT 60 +#define CONFIG_LWIP_DHCPS_MAX_STATION_NUM 8 +#define CONFIG_LWIP_DHCPS_MAX_HOSTNAME_LEN 64 +#define CONFIG_LWIP_DHCPS_STATIC_ENTRIES 1 +#define CONFIG_LWIP_IPV4 1 +#define CONFIG_LWIP_IPV6 1 +#define CONFIG_LWIP_IPV6_NUM_ADDRESSES 3 +#define CONFIG_LWIP_NETIF_LOOPBACK 1 +#define CONFIG_LWIP_LOOPBACK_MAX_PBUFS 8 +#define CONFIG_LWIP_MAX_ACTIVE_TCP 16 +#define CONFIG_LWIP_MAX_LISTENING_TCP 16 +#define CONFIG_LWIP_TCP_HIGH_SPEED_RETRANSMISSION 1 +#define CONFIG_LWIP_TCP_MAXRTX 12 +#define CONFIG_LWIP_TCP_SYNMAXRTX 12 +#define CONFIG_LWIP_TCP_MSS 1440 +#define CONFIG_LWIP_TCP_TMR_INTERVAL 250 +#define CONFIG_LWIP_TCP_MSL 60000 +#define CONFIG_LWIP_TCP_FIN_WAIT_TIMEOUT 20000 +#define CONFIG_LWIP_TCP_SND_BUF_DEFAULT 5760 +#define CONFIG_LWIP_TCP_WND_DEFAULT 5760 +#define CONFIG_LWIP_TCP_RECVMBOX_SIZE 6 +#define CONFIG_LWIP_TCP_ACCEPTMBOX_SIZE 6 +#define CONFIG_LWIP_TCP_QUEUE_OOSEQ 1 +#define CONFIG_LWIP_TCP_OOSEQ_TIMEOUT 6 +#define CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS 4 +#define CONFIG_LWIP_TCP_OVERSIZE_MSS 1 +#define CONFIG_LWIP_TCP_RTO_TIME 1500 +#define CONFIG_LWIP_MAX_UDP_PCBS 16 +#define CONFIG_LWIP_UDP_RECVMBOX_SIZE 6 +#define CONFIG_LWIP_CHECKSUM_CHECK_ICMP 1 +#define CONFIG_LWIP_TCPIP_TASK_STACK_SIZE 3072 +#define CONFIG_LWIP_TCPIP_TASK_AFFINITY_NO_AFFINITY 1 +#define CONFIG_LWIP_TCPIP_TASK_AFFINITY 0x7FFFFFFF +#define CONFIG_LWIP_IPV6_MEMP_NUM_ND6_QUEUE 3 +#define CONFIG_LWIP_IPV6_ND6_NUM_NEIGHBORS 5 +#define CONFIG_LWIP_IPV6_ND6_NUM_PREFIXES 5 +#define CONFIG_LWIP_IPV6_ND6_NUM_ROUTERS 3 +#define CONFIG_LWIP_IPV6_ND6_NUM_DESTINATIONS 10 +#define CONFIG_LWIP_ICMP 1 +#define CONFIG_LWIP_MAX_RAW_PCBS 16 +#define CONFIG_LWIP_SNTP_MAX_SERVERS 1 +#define CONFIG_LWIP_SNTP_UPDATE_DELAY 3600000 +#define CONFIG_LWIP_SNTP_STARTUP_DELAY 1 +#define CONFIG_LWIP_SNTP_MAXIMUM_STARTUP_DELAY 5000 +#define CONFIG_LWIP_DNS_MAX_HOST_IP 1 +#define CONFIG_LWIP_DNS_MAX_SERVERS 3 +#define CONFIG_LWIP_BRIDGEIF_MAX_PORTS 7 +#define CONFIG_LWIP_ESP_LWIP_ASSERT 1 +#define CONFIG_LWIP_HOOK_TCP_ISN_DEFAULT 1 +#define CONFIG_LWIP_HOOK_IP6_ROUTE_NONE 1 +#define CONFIG_LWIP_HOOK_ND6_GET_GW_NONE 1 +#define CONFIG_LWIP_HOOK_IP6_SELECT_SRC_ADDR_NONE 1 +#define CONFIG_LWIP_HOOK_DHCP_EXTRA_OPTION_NONE 1 +#define CONFIG_LWIP_HOOK_NETCONN_EXT_RESOLVE_NONE 1 +#define CONFIG_LWIP_HOOK_DNS_EXT_RESOLVE_NONE 1 +#define CONFIG_LWIP_HOOK_IP6_INPUT_DEFAULT 1 +#define CONFIG_MBEDTLS_VER_4_X_SUPPORT 1 +#define CONFIG_MBEDTLS_COMPILER_OPTIMIZATION_SIZE 1 +#define CONFIG_MBEDTLS_FS_IO 1 +#define CONFIG_MBEDTLS_THREADING_C 1 +#define CONFIG_MBEDTLS_THREADING_PTHREAD 1 +#define CONFIG_MBEDTLS_ERROR_STRINGS 1 +#define CONFIG_MBEDTLS_VERSION_C 1 +#define CONFIG_MBEDTLS_HAVE_TIME 1 +#define CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC 1 +#define CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN 1 +#define CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN 16384 +#define CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN 4096 +#define CONFIG_MBEDTLS_SELF_TEST 1 +#define CONFIG_MBEDTLS_X509_USE_C 1 +#define CONFIG_MBEDTLS_PEM_PARSE_C 1 +#define CONFIG_MBEDTLS_PEM_WRITE_C 1 +#define CONFIG_MBEDTLS_PK_C 1 +#define CONFIG_MBEDTLS_PK_PARSE_C 1 +#define CONFIG_MBEDTLS_PK_WRITE_C 1 +#define CONFIG_MBEDTLS_X509_CRL_PARSE_C 1 +#define CONFIG_MBEDTLS_X509_CRT_PARSE_C 1 +#define CONFIG_MBEDTLS_X509_CSR_PARSE_C 1 +#define CONFIG_MBEDTLS_X509_RSASSA_PSS_SUPPORT 1 +#define CONFIG_MBEDTLS_ASN1_PARSE_C 1 +#define CONFIG_MBEDTLS_ASN1_WRITE_C 1 +#define CONFIG_MBEDTLS_CERTIFICATE_BUNDLE 1 +#define CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL 1 +#define CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_MAX_CERTS 200 +#define CONFIG_MBEDTLS_TLS_ENABLED 1 +#define CONFIG_MBEDTLS_SSL_PROTO_TLS1_2 1 +#define CONFIG_MBEDTLS_TLS_SERVER 1 +#define CONFIG_MBEDTLS_TLS_CLIENT 1 +#define CONFIG_MBEDTLS_TLS_SERVER_AND_CLIENT 1 +#define CONFIG_MBEDTLS_SSL_CACHE_C 1 +#define CONFIG_MBEDTLS_SSL_ALL_ALERT_MESSAGES 1 +#define CONFIG_MBEDTLS_KEY_EXCHANGE_RSA 1 +#define CONFIG_MBEDTLS_KEY_EXCHANGE_ELLIPTIC_CURVE 1 +#define CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_RSA 1 +#define CONFIG_MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA 1 +#define CONFIG_MBEDTLS_SSL_SERVER_NAME_INDICATION 1 +#define CONFIG_MBEDTLS_SSL_ALPN 1 +#define CONFIG_MBEDTLS_SSL_MAX_FRAGMENT_LENGTH 1 +#define CONFIG_MBEDTLS_SSL_RENEGOTIATION 1 +#define CONFIG_MBEDTLS_CLIENT_SSL_SESSION_TICKETS 1 +#define CONFIG_MBEDTLS_SERVER_SSL_SESSION_TICKETS 1 +#define CONFIG_MBEDTLS_AES_C 1 +#define CONFIG_MBEDTLS_CCM_C 1 +#define CONFIG_MBEDTLS_CIPHER_MODE_CBC 1 +#define CONFIG_MBEDTLS_CIPHER_MODE_CFB 1 +#define CONFIG_MBEDTLS_CIPHER_MODE_CTR 1 +#define CONFIG_MBEDTLS_CIPHER_MODE_OFB 1 +#define CONFIG_MBEDTLS_CIPHER_MODE_XTS 1 +#define CONFIG_MBEDTLS_GCM_C 1 +#define CONFIG_MBEDTLS_AES_ROM_TABLES 1 +#define CONFIG_MBEDTLS_CMAC_C 1 +#define CONFIG_MBEDTLS_RSA_C 1 +#define CONFIG_MBEDTLS_ECP_C 1 +#define CONFIG_MBEDTLS_ECP_DP_SECP256R1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_SECP384R1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_SECP521R1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_SECP256K1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_BP256R1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_BP384R1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_BP512R1_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_DP_CURVE25519_ENABLED 1 +#define CONFIG_MBEDTLS_ECP_NIST_OPTIM 1 +#define CONFIG_MBEDTLS_ECDH_C 1 +#define CONFIG_MBEDTLS_ECDSA_C 1 +#define CONFIG_MBEDTLS_PK_PARSE_EC_EXTENDED 1 +#define CONFIG_MBEDTLS_PK_PARSE_EC_COMPRESSED 1 +#define CONFIG_MBEDTLS_ECDSA_DETERMINISTIC 1 +#define CONFIG_MBEDTLS_MD_C 1 +#define CONFIG_MBEDTLS_MD5_C 1 +#define CONFIG_MBEDTLS_SHA1_C 1 +#define CONFIG_MBEDTLS_SHA256_C 1 +#define CONFIG_MBEDTLS_SHA384_C 1 +#define CONFIG_MBEDTLS_SHA512_C 1 +#define CONFIG_MBEDTLS_ROM_MD5 1 +#define CONFIG_MBEDTLS_HARDWARE_ECDSA_VERIFY 1 +#define CONFIG_MBEDTLS_HARDWARE_ECC 1 +#define CONFIG_MBEDTLS_ECC_OTHER_CURVES_SOFT_FALLBACK 1 +#define CONFIG_MBEDTLS_HARDWARE_SHA 1 +#define CONFIG_MBEDTLS_HARDWARE_MPI 1 +#define CONFIG_MBEDTLS_LARGE_KEY_SOFTWARE_MPI 1 +#define CONFIG_MBEDTLS_MPI_USE_INTERRUPT 1 +#define CONFIG_MBEDTLS_MPI_INTERRUPT_LEVEL 0 +#define CONFIG_MBEDTLS_HARDWARE_AES 1 +#define CONFIG_MBEDTLS_HARDWARE_GCM 1 +#define CONFIG_MBEDTLS_GCM_SUPPORT_NON_AES_CIPHER 1 +#define CONFIG_MBEDTLS_AES_USE_INTERRUPT 1 +#define CONFIG_MBEDTLS_AES_INTERRUPT_LEVEL 0 +#define CONFIG_MBEDTLS_AES_HW_SMALL_DATA_LEN_OPTIM 1 +#define CONFIG_MBEDTLS_HARDWARE_RSA_DS_PERIPHERAL 1 +#define CONFIG_MBEDTLS_CTR_DRBG_C 1 +#define CONFIG_MBEDTLS_HMAC_DRBG_C 1 +#define CONFIG_MBEDTLS_BASE64_C 1 +#define CONFIG_MBEDTLS_PKCS5_C 1 +#define CONFIG_MBEDTLS_PKCS7_C 1 +#define CONFIG_MBEDTLS_PKCS1_V15 1 +#define CONFIG_MBEDTLS_PKCS1_V21 1 +#define CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_VERSION_2 1 +#define CONFIG_ESP_PROTOCOMM_SUPPORT_SECURITY_PATCH_VERSION 1 +#define CONFIG_PTHREAD_TASK_PRIO_DEFAULT 5 +#define CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT 3072 +#define CONFIG_PTHREAD_STACK_MIN 768 +#define CONFIG_PTHREAD_DEFAULT_CORE_NO_AFFINITY 1 +#define CONFIG_PTHREAD_TASK_CORE_DEFAULT -1 +#define CONFIG_PTHREAD_TASK_NAME_DEFAULT "pthread" +#define CONFIG_SD_ENABLE_SDIO_SUPPORT 1 +#define CONFIG_MMU_PAGE_SIZE_64KB 1 +#define CONFIG_MMU_PAGE_MODE "64KB" +#define CONFIG_MMU_PAGE_SIZE 0x10000 +#define CONFIG_SPI_FLASH_BROWNOUT_RESET_XMC 1 +#define CONFIG_SPI_FLASH_BROWNOUT_RESET 1 +#define CONFIG_SPI_FLASH_HPM_AUTO 1 +#define CONFIG_SPI_FLASH_HPM_ON 1 +#define CONFIG_SPI_FLASH_HPM_DC_AUTO 1 +#define CONFIG_SPI_FLASH_SUSPEND_TSUS_VAL_US 50 +#define CONFIG_SPI_FLASH_PLACE_FUNCTIONS_IN_IRAM 1 +#define CONFIG_SPI_FLASH_DANGEROUS_WRITE_ABORTS 1 +#define CONFIG_SPI_FLASH_YIELD_DURING_ERASE 1 +#define CONFIG_SPI_FLASH_ERASE_YIELD_DURATION_MS 20 +#define CONFIG_SPI_FLASH_ERASE_YIELD_TICKS 1 +#define CONFIG_SPI_FLASH_WRITE_CHUNK_SIZE 8192 +#define CONFIG_SPI_FLASH_VENDOR_XMC_SUPPORT_ENABLED 1 +#define CONFIG_SPI_FLASH_VENDOR_GD_SUPPORT_ENABLED 1 +#define CONFIG_SPI_FLASH_SUPPORT_GD_CHIP 1 +#define CONFIG_SPI_FLASH_SUPPORT_BOYA_CHIP 1 +#define CONFIG_SPI_FLASH_ENABLE_ENCRYPTED_READ_WRITE 1 +#define CONFIG_SPIFFS_MAX_PARTITIONS 3 +#define CONFIG_SPIFFS_CACHE 1 +#define CONFIG_SPIFFS_CACHE_WR 1 +#define CONFIG_SPIFFS_PAGE_CHECK 1 +#define CONFIG_SPIFFS_GC_MAX_RUNS 10 +#define CONFIG_SPIFFS_PAGE_SIZE 256 +#define CONFIG_SPIFFS_OBJ_NAME_LEN 32 +#define CONFIG_SPIFFS_USE_MAGIC 1 +#define CONFIG_SPIFFS_USE_MAGIC_LENGTH 1 +#define CONFIG_SPIFFS_META_LENGTH 4 +#define CONFIG_SPIFFS_USE_MTIME 1 +#define CONFIG_WS_TRANSPORT 1 +#define CONFIG_WS_BUFFER_SIZE 1024 +#define CONFIG_UNITY_ENABLE_FLOAT 1 +#define CONFIG_UNITY_ENABLE_DOUBLE 1 +#define CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER 1 +#define CONFIG_VFS_SUPPORT_IO 1 +#define CONFIG_VFS_SUPPORT_DIR 1 +#define CONFIG_VFS_SUPPORT_SELECT 1 +#define CONFIG_VFS_SUPPRESS_SELECT_DEBUG_OUTPUT 1 +#define CONFIG_VFS_MAX_COUNT 8 +#define CONFIG_VFS_SEMIHOSTFS_MAX_MOUNT_POINTS 1 +#define CONFIG_VFS_INITIALIZE_DEV_NULL 1 +#define CONFIG_WL_SECTOR_SIZE_4096 1 +#define CONFIG_WL_SECTOR_SIZE 4096 +#define CONFIG_EPPP_LINK_DEVICE_UART 1 +#define CONFIG_EPPP_LINK_CONN_MAX_RETRY 6 +#define CONFIG_ESP_HOSTED_ENABLED 1 +#define CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6 1 +#define CONFIG_ESP_HOSTED_PRIV_ENABLE_WIFI_OPTIONS 1 +#define CONFIG_ESP_HOSTED_IDF_SLAVE_TARGET "esp32c6" +#define CONFIG_ESP_HOSTED_P4_DEV_BOARD_NONE 1 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_OPTION 1 +#define CONFIG_ESP_HOSTED_PRIV_SPI_HD_OPTION 1 +#define CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE 1 +#define CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH 1 +#define CONFIG_ESP_HOSTED_SDIO_OPTIMIZATION_RX_STREAMING_MODE 1 +#define CONFIG_ESP_HOSTED_SDIO_SLOT_1 1 +#define CONFIG_ESP_HOSTED_SDIO_SLOT 1 +#define CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS 1 +#define CONFIG_ESP_HOSTED_SDIO_BUS_WIDTH 4 +#define CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ 40000 +#define CONFIG_ESP_HOSTED_SDIO_CMD_GPIO_RANGE_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_CMD_GPIO_RANGE_MAX 100 +#define CONFIG_ESP_HOSTED_SDIO_CLK_GPIO_RANGE_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_CLK_GPIO_RANGE_MAX 100 +#define CONFIG_ESP_HOSTED_SDIO_D0_GPIO_RANGE_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_D0_GPIO_RANGE_MAX 100 +#define CONFIG_ESP_HOSTED_SDIO_D1_GPIO_RANGE_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_D1_GPIO_RANGE_MAX 100 +#define CONFIG_ESP_HOSTED_SDIO_D2_GPIO_RANGE_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_D2_GPIO_RANGE_MAX 100 +#define CONFIG_ESP_HOSTED_SDIO_D3_GPIO_RANGE_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_D3_GPIO_RANGE_MAX 100 +#define CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_GPIO_MIN 0 +#define CONFIG_ESP_HOSTED_SDIO_RESET_SLAVE_GPIO_MAX 100 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CMD_SLOT_1 19 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_CLK_SLOT_1 18 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D0_SLOT_1 14 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D1_4BIT_BUS_SLOT_1 15 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D2_4BIT_BUS_SLOT_1 16 +#define CONFIG_ESP_HOSTED_PRIV_SDIO_PIN_D3_4BIT_BUS_SLOT_1 17 +#define CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE 54 +#define CONFIG_ESP_HOSTED_SDIO_PIN_CMD 19 +#define CONFIG_ESP_HOSTED_SDIO_PIN_CLK 18 +#define CONFIG_ESP_HOSTED_SDIO_PIN_D0 14 +#define CONFIG_ESP_HOSTED_SDIO_PRIV_PIN_D1_4BIT_BUS 15 +#define CONFIG_ESP_HOSTED_SDIO_PIN_D2 16 +#define CONFIG_ESP_HOSTED_SDIO_PIN_D3 17 +#define CONFIG_ESP_HOSTED_SDIO_PIN_D1 15 +#define CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 20 +#define CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE 20 +#define CONFIG_ESP_HOSTED_SDIO_RESET_DELAY_MS 1500 +#define CONFIG_ESP_HOSTED_SLAVE_RESET_ON_EVERY_HOST_BOOTUP 1 +#define CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE 54 +#define CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE 1 +#define CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI 1 +#define CONFIG_ESP_HOSTED_RPC_TASK_STACK 4096 +#define CONFIG_ESP_HOSTED_DFLT_TASK_STACK 3072 +#define CONFIG_ESP_HOSTED_TRANSPORT_RESTART_ON_FAILURE 1 +#define CONFIG_ESP_HOSTED_MEM_MONITOR 1 +#define CONFIG_ESP_HOSTED_ENABLE_ITWT 1 +#define CONFIG_ESP_HOSTED_USE_MEMPOOL 1 +#define CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_SYNC_RPC_REQUESTS 5 +#define CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_ASYNC_RPC_REQUESTS 5 +#define CONFIG_ESP_HOSTED_CLI_ENABLED 1 +#define CONFIG_ESP_HOSTED_HOST_TO_ESP_WIFI_DATA_THROTTLE 1 +#define CONFIG_ESP_HOSTED_PRIV_WIFI_TX_SDIO_HIGH_THRESHOLD 80 +#define CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_HIGH_THRESHOLD 80 +#define CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_LOW_THRESHOLD 60 +#define CONFIG_ESP_HOSTED_ENABLE_PEER_DATA_TRANSFER 1 +#define CONFIG_ESP_HOSTED_MAX_CUSTOM_MSG_HANDLERS 3 +#define CONFIG_ESP_WIFI_REMOTE_ENABLED 1 +#define CONFIG_ESP_WIFI_REMOTE_IDF_SPECIFIC_ADDED 1 +#define CONFIG_SLAVE_IDF_TARGET_ESP32C6 1 +#define CONFIG_SLAVE_SOC_WIFI_SUPPORTED 1 +#define CONFIG_SLAVE_SOC_WIFI_WAPI_SUPPORT 1 +#define CONFIG_SLAVE_SOC_WIFI_CSI_SUPPORT 1 +#define CONFIG_SLAVE_SOC_WIFI_MESH_SUPPORT 1 +#define CONFIG_SLAVE_SOC_WIFI_LIGHT_SLEEP_CLK_WIDTH 12 +#define CONFIG_SLAVE_SOC_WIFI_HW_TSF 1 +#define CONFIG_SLAVE_SOC_WIFI_FTM_SUPPORT 1 +#define CONFIG_SLAVE_FREERTOS_UNICORE 1 +#define CONFIG_SLAVE_SOC_WIFI_GCMP_SUPPORT 1 +#define CONFIG_SLAVE_SOC_WIFI_TXOP_SUPPORT 1 +#define CONFIG_SLAVE_IDF_TARGET_ARCH_RISCV 1 +#define CONFIG_SLAVE_SOC_WIFI_HE_SUPPORT 1 +#define CONFIG_SLAVE_SOC_WIFI_MAC_VERSION_NUM 2 +#define CONFIG_WIFI_RMT_STATIC_RX_BUFFER_NUM 10 +#define CONFIG_WIFI_RMT_DYNAMIC_RX_BUFFER_NUM 32 +#define CONFIG_WIFI_RMT_DYNAMIC_TX_BUFFER 1 +#define CONFIG_WIFI_RMT_TX_BUFFER_TYPE 1 +#define CONFIG_WIFI_RMT_DYNAMIC_TX_BUFFER_NUM 32 +#define CONFIG_WIFI_RMT_STATIC_RX_MGMT_BUFFER 1 +#define CONFIG_WIFI_RMT_DYNAMIC_RX_MGMT_BUF 0 +#define CONFIG_WIFI_RMT_RX_MGMT_BUF_NUM_DEF 5 +#define CONFIG_WIFI_RMT_AMPDU_TX_ENABLED 1 +#define CONFIG_WIFI_RMT_TX_BA_WIN 6 +#define CONFIG_WIFI_RMT_AMPDU_RX_ENABLED 1 +#define CONFIG_WIFI_RMT_RX_BA_WIN 6 +#define CONFIG_WIFI_RMT_NVS_ENABLED 1 +#define CONFIG_WIFI_RMT_SOFTAP_BEACON_MAX_LEN 752 +#define CONFIG_WIFI_RMT_MGMT_SBUF_NUM 32 +#define CONFIG_WIFI_RMT_IRAM_OPT 1 +#define CONFIG_WIFI_RMT_EXTRA_IRAM_OPT 1 +#define CONFIG_WIFI_RMT_RX_IRAM_OPT 1 +#define CONFIG_WIFI_RMT_ENABLE_WPA3_SAE 1 +#define CONFIG_WIFI_RMT_ENABLE_SAE_H2E 1 +#define CONFIG_WIFI_RMT_ENABLE_SAE_PK 1 +#define CONFIG_WIFI_RMT_SOFTAP_SAE_SUPPORT 1 +#define CONFIG_WIFI_RMT_ENABLE_WPA3_OWE_STA 1 +#define CONFIG_WIFI_RMT_WPA3_COMPATIBLE_SUPPORT 1 +#define CONFIG_WIFI_RMT_SLP_IRAM_OPT 1 +#define CONFIG_WIFI_RMT_SLP_DEFAULT_MIN_ACTIVE_TIME 50 +#define CONFIG_WIFI_RMT_BSS_MAX_IDLE_SUPPORT 1 +#define CONFIG_WIFI_RMT_SLP_DEFAULT_MAX_ACTIVE_TIME 10 +#define CONFIG_WIFI_RMT_SLP_DEFAULT_WAIT_BROADCAST_DATA_TIME 15 +#define CONFIG_WIFI_RMT_STA_DISCONNECTED_PM_ENABLE 1 +#define CONFIG_WIFI_RMT_GMAC_SUPPORT 1 +#define CONFIG_WIFI_RMT_SOFTAP_SUPPORT 1 +#define CONFIG_WIFI_RMT_ESPNOW_MAX_ENCRYPT_NUM 7 +#define CONFIG_WIFI_RMT_MBEDTLS_CRYPTO 1 +#define CONFIG_WIFI_RMT_MBEDTLS_TLS_CLIENT 1 +#define CONFIG_WIFI_RMT_TX_HETB_QUEUE_NUM 3 +#define CONFIG_WIFI_RMT_ENTERPRISE_SUPPORT 1 +#define CONFIG_ESP_WIFI_REMOTE_LIBRARY_HOSTED 1 +#define CONFIG_ESP_WIFI_REMOTE_EAP_ENABLED 1 + +/* List of deprecated options */ +#define CONFIG_BROWNOUT_DET CONFIG_ESP_BROWNOUT_DET +#define CONFIG_BROWNOUT_DET_LVL CONFIG_ESP_BROWNOUT_DET_LVL +#define CONFIG_BROWNOUT_DET_LVL_SEL_7 CONFIG_ESP_BROWNOUT_DET_LVL_SEL_7 +#define CONFIG_BT_NIMBLE_ACL_BUF_COUNT CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT +#define CONFIG_BT_NIMBLE_ACL_BUF_SIZE CONFIG_BT_NIMBLE_TRANSPORT_ACL_SIZE +#define CONFIG_BT_NIMBLE_HCI_EVT_BUF_SIZE CONFIG_BT_NIMBLE_TRANSPORT_EVT_SIZE +#define CONFIG_BT_NIMBLE_HCI_EVT_HI_BUF_COUNT CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT +#define CONFIG_BT_NIMBLE_HCI_EVT_LO_BUF_COUNT CONFIG_BT_NIMBLE_TRANSPORT_EVT_DISCARD_COUNT +#define CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT +#define CONFIG_BT_NIMBLE_SM_SC_LVL CONFIG_BT_NIMBLE_SM_LVL +#define CONFIG_BT_NIMBLE_TASK_STACK_SIZE CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE +#define CONFIG_COMPILER_OPTIMIZATION_DEFAULT CONFIG_COMPILER_OPTIMIZATION_DEBUG +#define CONFIG_COMPILER_OPTIMIZATION_LEVEL_DEBUG CONFIG_COMPILER_OPTIMIZATION_DEBUG +#define CONFIG_CONSOLE_UART CONFIG_ESP_CONSOLE_UART +#define CONFIG_CONSOLE_UART_BAUDRATE CONFIG_ESP_CONSOLE_UART_BAUDRATE +#define CONFIG_CONSOLE_UART_DEFAULT CONFIG_ESP_CONSOLE_UART_DEFAULT +#define CONFIG_CONSOLE_UART_NUM CONFIG_ESP_CONSOLE_UART_NUM +#define CONFIG_ESP32_DEFAULT_PTHREAD_CORE_NO_AFFINITY CONFIG_PTHREAD_DEFAULT_CORE_NO_AFFINITY +#define CONFIG_ESP32_ENABLE_COREDUMP_TO_NONE CONFIG_ESP_COREDUMP_ENABLE_TO_NONE +#define CONFIG_ESP32_PTHREAD_STACK_MIN CONFIG_PTHREAD_STACK_MIN +#define CONFIG_ESP32_PTHREAD_TASK_CORE_DEFAULT CONFIG_PTHREAD_TASK_CORE_DEFAULT +#define CONFIG_ESP32_PTHREAD_TASK_NAME_DEFAULT CONFIG_PTHREAD_TASK_NAME_DEFAULT +#define CONFIG_ESP32_PTHREAD_TASK_PRIO_DEFAULT CONFIG_PTHREAD_TASK_PRIO_DEFAULT +#define CONFIG_ESP32_PTHREAD_TASK_STACK_SIZE_DEFAULT CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT +#define CONFIG_ESP32_WIFI_AMPDU_RX_ENABLED CONFIG_ESP_WIFI_AMPDU_RX_ENABLED +#define CONFIG_ESP32_WIFI_AMPDU_TX_ENABLED CONFIG_ESP_WIFI_AMPDU_TX_ENABLED +#define CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM +#define CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM +#define CONFIG_ESP32_WIFI_ENABLE_WPA3_OWE_STA CONFIG_ESP_WIFI_ENABLE_WPA3_OWE_STA +#define CONFIG_ESP32_WIFI_ENABLE_WPA3_SAE CONFIG_ESP_WIFI_ENABLE_WPA3_SAE +#define CONFIG_ESP32_WIFI_IRAM_OPT CONFIG_ESP_WIFI_IRAM_OPT +#define CONFIG_ESP32_WIFI_MGMT_SBUF_NUM CONFIG_ESP_WIFI_MGMT_SBUF_NUM +#define CONFIG_ESP32_WIFI_NVS_ENABLED CONFIG_ESP_WIFI_NVS_ENABLED +#define CONFIG_ESP32_WIFI_RX_BA_WIN CONFIG_ESP_WIFI_RX_BA_WIN +#define CONFIG_ESP32_WIFI_RX_IRAM_OPT CONFIG_ESP_WIFI_RX_IRAM_OPT +#define CONFIG_ESP32_WIFI_SOFTAP_BEACON_MAX_LEN CONFIG_ESP_WIFI_SOFTAP_BEACON_MAX_LEN +#define CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM +#define CONFIG_ESP32_WIFI_TX_BA_WIN CONFIG_ESP_WIFI_TX_BA_WIN +#define CONFIG_ESP32_WIFI_TX_BUFFER_TYPE CONFIG_ESP_WIFI_TX_BUFFER_TYPE +#define CONFIG_ESP_DFLT_TASK_STACK CONFIG_ESP_HOSTED_DFLT_TASK_STACK +#define CONFIG_ESP_ENABLE_BT_NIMBLE CONFIG_ESP_HOSTED_ENABLE_BT_NIMBLE +#define CONFIG_ESP_GPIO_SLAVE_RESET_SLAVE CONFIG_ESP_HOSTED_GPIO_SLAVE_RESET_SLAVE +#define CONFIG_ESP_GRATUITOUS_ARP CONFIG_LWIP_ESP_GRATUITOUS_ARP +#define CONFIG_ESP_MAX_SIMULTANEOUS_ASYNC_RPC_REQUESTS CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_ASYNC_RPC_REQUESTS +#define CONFIG_ESP_MAX_SIMULTANEOUS_SYNC_RPC_REQUESTS CONFIG_ESP_HOSTED_MAX_SIMULTANEOUS_SYNC_RPC_REQUESTS +#define CONFIG_ESP_NIMBLE_HCI_VHCI CONFIG_ESP_HOSTED_NIMBLE_HCI_VHCI +#define CONFIG_ESP_RPC_TASK_STACK CONFIG_ESP_HOSTED_RPC_TASK_STACK +#define CONFIG_ESP_SDIO_4_BIT_BUS CONFIG_ESP_HOSTED_SDIO_4_BIT_BUS +#define CONFIG_ESP_SDIO_BUS_WIDTH CONFIG_ESP_HOSTED_SDIO_BUS_WIDTH +#define CONFIG_ESP_SDIO_CLOCK_FREQ_KHZ CONFIG_ESP_HOSTED_SDIO_CLOCK_FREQ_KHZ +#define CONFIG_ESP_SDIO_GPIO_RESET_SLAVE CONFIG_ESP_HOSTED_SDIO_GPIO_RESET_SLAVE +#define CONFIG_ESP_SDIO_HOST_INTERFACE CONFIG_ESP_HOSTED_SDIO_HOST_INTERFACE +#define CONFIG_ESP_SDIO_OPTIMIZATION_RX_STREAMING_MODE CONFIG_ESP_HOSTED_SDIO_OPTIMIZATION_RX_STREAMING_MODE +#define CONFIG_ESP_SDIO_PIN_CLK CONFIG_ESP_HOSTED_SDIO_PIN_CLK +#define CONFIG_ESP_SDIO_PIN_CMD CONFIG_ESP_HOSTED_SDIO_PIN_CMD +#define CONFIG_ESP_SDIO_PIN_D0 CONFIG_ESP_HOSTED_SDIO_PIN_D0 +#define CONFIG_ESP_SDIO_PIN_D1 CONFIG_ESP_HOSTED_SDIO_PIN_D1 +#define CONFIG_ESP_SDIO_PIN_D2 CONFIG_ESP_HOSTED_SDIO_PIN_D2 +#define CONFIG_ESP_SDIO_PIN_D3 CONFIG_ESP_HOSTED_SDIO_PIN_D3 +#define CONFIG_ESP_SDIO_RX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_RX_Q_SIZE +#define CONFIG_ESP_SDIO_TX_Q_SIZE CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE +#define CONFIG_ESP_SYSTEM_BROWNOUT_INTR CONFIG_ESP_BROWNOUT_USE_INTR +#define CONFIG_ESP_SYSTEM_MEMPROT_FEATURE CONFIG_ESP_SYSTEM_MEMPROT +#define CONFIG_ESP_SYSTEM_MEMPROT_FEATURE_VIA_TEE CONFIG_ESP_SYSTEM_MEMPROT +#define CONFIG_ESP_SYSTEM_PMP_IDRAM_SPLIT CONFIG_ESP_SYSTEM_MEMPROT +#define CONFIG_ESP_TASK_WDT CONFIG_ESP_TASK_WDT_INIT +#define CONFIG_ESP_USE_MEMPOOL CONFIG_ESP_HOSTED_USE_MEMPOOL +#define CONFIG_FLASHMODE_DIO CONFIG_ESPTOOLPY_FLASHMODE_DIO +#define CONFIG_GARP_TMR_INTERVAL CONFIG_LWIP_GARP_TMR_INTERVAL +#define CONFIG_GDBSTUB_MAX_TASKS CONFIG_ESP_GDBSTUB_MAX_TASKS +#define CONFIG_GDBSTUB_SUPPORT_TASKS CONFIG_ESP_GDBSTUB_SUPPORT_TASKS +#define CONFIG_HOST_TO_ESP_WIFI_DATA_THROTTLE CONFIG_ESP_HOSTED_HOST_TO_ESP_WIFI_DATA_THROTTLE +#define CONFIG_IDF_SLAVE_TARGET CONFIG_ESP_HOSTED_IDF_SLAVE_TARGET +#define CONFIG_INT_WDT CONFIG_ESP_INT_WDT +#define CONFIG_INT_WDT_CHECK_CPU1 CONFIG_ESP_INT_WDT_CHECK_CPU1 +#define CONFIG_INT_WDT_TIMEOUT_MS CONFIG_ESP_INT_WDT_TIMEOUT_MS +#define CONFIG_IPC_TASK_STACK_SIZE CONFIG_ESP_IPC_TASK_STACK_SIZE +#define CONFIG_LOG_BOOTLOADER_LEVEL CONFIG_BOOTLOADER_LOG_LEVEL +#define CONFIG_LOG_BOOTLOADER_LEVEL_INFO CONFIG_BOOTLOADER_LOG_LEVEL_INFO +#define CONFIG_MAIN_TASK_STACK_SIZE CONFIG_ESP_MAIN_TASK_STACK_SIZE +#define CONFIG_MONITOR_BAUD CONFIG_ESPTOOLPY_MONITOR_BAUD +#define CONFIG_NEWLIB_STDIN_LINE_ENDING_CR CONFIG_LIBC_STDIN_LINE_ENDING_CR +#define CONFIG_NEWLIB_STDOUT_LINE_ENDING_CRLF CONFIG_LIBC_STDOUT_LINE_ENDING_CRLF +#define CONFIG_NEWLIB_TIME_SYSCALL_USE_RTC_HRT CONFIG_LIBC_TIME_SYSCALL_USE_RTC_HRT +#define CONFIG_NIMBLE_ATT_PREFERRED_MTU CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU +#define CONFIG_NIMBLE_ENABLED CONFIG_BT_NIMBLE_ENABLED +#define CONFIG_NIMBLE_GAP_DEVICE_NAME_MAX_LEN CONFIG_BT_NIMBLE_GAP_DEVICE_NAME_MAX_LEN +#define CONFIG_NIMBLE_L2CAP_COC_MAX_NUM CONFIG_BT_NIMBLE_L2CAP_COC_MAX_NUM +#define CONFIG_NIMBLE_MAX_BONDS CONFIG_BT_NIMBLE_MAX_BONDS +#define CONFIG_NIMBLE_MAX_CCCDS CONFIG_BT_NIMBLE_MAX_CCCDS +#define CONFIG_NIMBLE_MAX_CONNECTIONS CONFIG_BT_NIMBLE_MAX_CONNECTIONS +#define CONFIG_NIMBLE_MEM_ALLOC_MODE_INTERNAL CONFIG_BT_NIMBLE_MEM_ALLOC_MODE_INTERNAL +#define CONFIG_NIMBLE_PINNED_TO_CORE CONFIG_BT_NIMBLE_PINNED_TO_CORE +#define CONFIG_NIMBLE_PINNED_TO_CORE_0 CONFIG_BT_NIMBLE_PINNED_TO_CORE_0 +#define CONFIG_NIMBLE_ROLE_BROADCASTER CONFIG_BT_NIMBLE_ROLE_BROADCASTER +#define CONFIG_NIMBLE_ROLE_OBSERVER CONFIG_BT_NIMBLE_ROLE_OBSERVER +#define CONFIG_NIMBLE_ROLE_PERIPHERAL CONFIG_BT_NIMBLE_ROLE_PERIPHERAL +#define CONFIG_NIMBLE_RPA_TIMEOUT CONFIG_BT_NIMBLE_RPA_TIMEOUT +#define CONFIG_NIMBLE_SM_LEGACY CONFIG_BT_NIMBLE_SM_LEGACY +#define CONFIG_NIMBLE_SM_SC CONFIG_BT_NIMBLE_SM_SC +#define CONFIG_NIMBLE_SVC_GAP_APPEARANCE CONFIG_BT_NIMBLE_SVC_GAP_APPEARANCE +#define CONFIG_NIMBLE_SVC_GAP_DEVICE_NAME CONFIG_BT_NIMBLE_SVC_GAP_DEVICE_NAME +#define CONFIG_NIMBLE_TASK_STACK_SIZE CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE +#define CONFIG_OPTIMIZATION_ASSERTIONS_ENABLED CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE +#define CONFIG_OPTIMIZATION_ASSERTION_LEVEL CONFIG_COMPILER_OPTIMIZATION_ASSERTION_LEVEL +#define CONFIG_OPTIMIZATION_LEVEL_DEBUG CONFIG_COMPILER_OPTIMIZATION_DEBUG +#define CONFIG_PERIPH_CTRL_FUNC_IN_IRAM CONFIG_ESP_PERIPH_CTRL_FUNC_IN_IRAM +#define CONFIG_POST_EVENTS_FROM_IRAM_ISR CONFIG_ESP_EVENT_POST_FROM_IRAM_ISR +#define CONFIG_POST_EVENTS_FROM_ISR CONFIG_ESP_EVENT_POST_FROM_ISR +#define CONFIG_PRIV_WIFI_TX_SDIO_HIGH_THRESHOLD CONFIG_ESP_HOSTED_PRIV_WIFI_TX_SDIO_HIGH_THRESHOLD +#define CONFIG_SDIO_RESET_ACTIVE_HIGH CONFIG_ESP_HOSTED_SDIO_RESET_ACTIVE_HIGH +#define CONFIG_SEMIHOSTFS_MAX_MOUNT_POINTS CONFIG_VFS_SEMIHOSTFS_MAX_MOUNT_POINTS +#define CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY CONFIG_FREERTOS_TASK_CREATE_ALLOW_EXT_MEM +#define CONFIG_SPI_FLASH_WRITING_DANGEROUS_REGIONS_ABORTS CONFIG_SPI_FLASH_DANGEROUS_WRITE_ABORTS +#define CONFIG_STACK_CHECK_NONE CONFIG_COMPILER_STACK_CHECK_MODE_NONE +#define CONFIG_SUPPRESS_SELECT_DEBUG_OUTPUT CONFIG_VFS_SUPPRESS_SELECT_DEBUG_OUTPUT +#define CONFIG_SYSTEM_EVENT_QUEUE_SIZE CONFIG_ESP_SYSTEM_EVENT_QUEUE_SIZE +#define CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE +#define CONFIG_TASK_WDT CONFIG_ESP_TASK_WDT_INIT +#define CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0 CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 +#define CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU1 CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 +#define CONFIG_TASK_WDT_TIMEOUT_S CONFIG_ESP_TASK_WDT_TIMEOUT_S +#define CONFIG_TCPIP_RECVMBOX_SIZE CONFIG_LWIP_TCPIP_RECVMBOX_SIZE +#define CONFIG_TCPIP_TASK_AFFINITY CONFIG_LWIP_TCPIP_TASK_AFFINITY +#define CONFIG_TCPIP_TASK_AFFINITY_NO_AFFINITY CONFIG_LWIP_TCPIP_TASK_AFFINITY_NO_AFFINITY +#define CONFIG_TCPIP_TASK_STACK_SIZE CONFIG_LWIP_TCPIP_TASK_STACK_SIZE +#define CONFIG_TCP_MAXRTX CONFIG_LWIP_TCP_MAXRTX +#define CONFIG_TCP_MSL CONFIG_LWIP_TCP_MSL +#define CONFIG_TCP_MSS CONFIG_LWIP_TCP_MSS +#define CONFIG_TCP_OVERSIZE_MSS CONFIG_LWIP_TCP_OVERSIZE_MSS +#define CONFIG_TCP_QUEUE_OOSEQ CONFIG_LWIP_TCP_QUEUE_OOSEQ +#define CONFIG_TCP_RECVMBOX_SIZE CONFIG_LWIP_TCP_RECVMBOX_SIZE +#define CONFIG_TCP_SND_BUF_DEFAULT CONFIG_LWIP_TCP_SND_BUF_DEFAULT +#define CONFIG_TCP_SYNMAXRTX CONFIG_LWIP_TCP_SYNMAXRTX +#define CONFIG_TCP_WND_DEFAULT CONFIG_LWIP_TCP_WND_DEFAULT +#define CONFIG_TIMER_QUEUE_LENGTH CONFIG_FREERTOS_TIMER_QUEUE_LENGTH +#define CONFIG_TIMER_TASK_PRIORITY CONFIG_FREERTOS_TIMER_TASK_PRIORITY +#define CONFIG_TIMER_TASK_STACK_DEPTH CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH +#define CONFIG_TIMER_TASK_STACK_SIZE CONFIG_ESP_TIMER_TASK_STACK_SIZE +#define CONFIG_TO_WIFI_DATA_THROTTLE_HIGH_THRESHOLD CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_HIGH_THRESHOLD +#define CONFIG_TO_WIFI_DATA_THROTTLE_LOW_THRESHOLD CONFIG_ESP_HOSTED_TO_WIFI_DATA_THROTTLE_LOW_THRESHOLD +#define CONFIG_UDP_RECVMBOX_SIZE CONFIG_LWIP_UDP_RECVMBOX_SIZE +#define CONFIG_WPA_MBEDTLS_CRYPTO CONFIG_ESP_WIFI_MBEDTLS_CRYPTO +#define CONFIG_WPA_MBEDTLS_TLS_CLIENT CONFIG_ESP_WIFI_MBEDTLS_TLS_CLIENT diff --git a/src/net/hosted/wifi_shim.c b/src/net/hosted/wifi_shim.c new file mode 100644 index 0000000..67bdcc6 --- /dev/null +++ b/src/net/hosted/wifi_shim.c @@ -0,0 +1,200 @@ +/* + * A narrow C surface over ESP-Hosted's Wi-Fi RPC, so Zig never transcribes an IDF struct. + * + * `rpc_wifi_init` takes a `wifi_init_config_t`, `rpc_wifi_set_config` takes a `wifi_config_t`, and + * scanning hands back `wifi_ap_record_t`. Those are large, versioned structs full of bitfields, and + * IDF builds them with macros - WIFI_INIT_CONFIG_DEFAULT() alone sets over twenty fields + * (esp_wifi.h:316). Writing Zig `extern struct`s to match would be a transcription that compiles + * happily and goes wrong on the next IDF release, exactly the mistake that + * `esp_hosted_sdio_get_config` taught this project once already. + * + * So the structs stay on the C side, built by IDF's own macros, and Zig gets plain scalars and byte + * buffers. Everything here is a thin forwarder; the interesting code is all in ESP-Hosted's RPC + * layer, which this does not duplicate. + */ + +#include + +#include "esp_wifi_types.h" +#include "esp_wifi.h" +#include "rpc_wrap.h" + +/* ESP-Hosted's RPC entry points (host/drivers/rpc/wrap/rpc_wrap.c). Declared here rather than + * relying on the header, so a signature change is a compile error in this file. */ +int rpc_wifi_init(const wifi_init_config_t *arg); +int rpc_wifi_set_mode(wifi_mode_t mode); +int rpc_wifi_set_config(wifi_interface_t interface, wifi_config_t *conf); +int rpc_wifi_connect(void); +int rpc_wifi_scan_start(const wifi_scan_config_t *config, bool block); +int rpc_wifi_scan_get_ap_num(uint16_t *number); +int rpc_wifi_scan_get_ap_records(uint16_t *number, wifi_ap_record_t *ap_records); +int rpc_wifi_start(void); +int rpc_wifi_get_mac(wifi_interface_t mode, uint8_t mac[6]); +int rpc_wifi_set_ps(wifi_ps_type_t type); + +/* Initialise the coprocessor's Wi-Fi and put it in station mode, started. + * + * The order is IDF's own and is not negotiable: init, set_mode, start. `esp_wifi_start` is what + * actually brings the radio up on the C6; a config set before it is accepted and a connect before + * it is not. */ +int hosted_wifi_sta_start(void) +{ + /* IDF's WIFI_INIT_CONFIG_DEFAULT() is deliberately NOT used, and this is not a shortcut. + * + * That macro's first two fields are `.osi_funcs = &g_wifi_osi_funcs` and + * `.wpa_crypto_funcs = g_wifi_default_wpa_crypto_funcs` (esp_wifi.h:317-318) - the local Wi-Fi + * driver's OS adapter and crypto tables. This chip has no Wi-Fi driver: the C6 does, and it uses + * its own. Referencing them here pulls in symbols that cannot exist in this image, which is + * exactly the link error that led to this comment. + * + * They are also provably unnecessary. rpc_req.c:182-215 packs the request field by field, and + * every field it packs is a scalar; neither function pointer is ever serialised. So the struct + * only has to carry the scalars, and those come from the same Kconfig-derived macros the real + * default uses - via the checked-in sdkconfig, so they are this project's configuration and not + * a second set of numbers. + * + * `magic` is load-bearing: the coprocessor validates it (esp_wifi.h's own note says it must + * always be WIFI_INIT_CONFIG_MAGIC), so a zeroed struct is rejected. */ + wifi_init_config_t cfg = { 0 }; + cfg.static_rx_buf_num = CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM; + cfg.dynamic_rx_buf_num = CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM; + cfg.tx_buf_type = CONFIG_ESP_WIFI_TX_BUFFER_TYPE; + cfg.static_tx_buf_num = WIFI_STATIC_TX_BUFFER_NUM; + cfg.dynamic_tx_buf_num = WIFI_DYNAMIC_TX_BUFFER_NUM; + cfg.rx_mgmt_buf_type = CONFIG_ESP_WIFI_DYNAMIC_RX_MGMT_BUF; + cfg.rx_mgmt_buf_num = WIFI_RX_MGMT_BUF_NUM_DEF; + cfg.cache_tx_buf_num = WIFI_CACHE_TX_BUFFER_NUM; + cfg.csi_enable = WIFI_CSI_ENABLED; + cfg.ampdu_rx_enable = WIFI_AMPDU_RX_ENABLED; + cfg.ampdu_tx_enable = WIFI_AMPDU_TX_ENABLED; + cfg.amsdu_tx_enable = WIFI_AMSDU_TX_ENABLED; + cfg.nvs_enable = WIFI_NVS_ENABLED; + cfg.nano_enable = WIFI_NANO_FORMAT_ENABLED; + cfg.rx_ba_win = WIFI_DEFAULT_RX_BA_WIN; + cfg.wifi_task_core_id = WIFI_TASK_CORE_ID; + cfg.beacon_max_len = WIFI_SOFTAP_BEACON_MAX_LEN; + cfg.mgmt_sbuf_num = WIFI_MGMT_SBUF_NUM; + cfg.feature_caps = WIFI_FEATURE_CAPS; + cfg.sta_disconnected_pm = WIFI_STA_DISCONNECTED_PM_ENABLED; + cfg.espnow_max_encrypt_num = CONFIG_ESP_WIFI_ESPNOW_MAX_ENCRYPT_NUM; + cfg.tx_hetb_queue_num = WIFI_TX_HETB_QUEUE_NUM; + cfg.dump_hesigb_enable = WIFI_DUMP_HESIGB_ENABLED; + cfg.magic = WIFI_INIT_CONFIG_MAGIC; + + int err = rpc_wifi_init(&cfg); + if (err) { + return err; + } + err = rpc_wifi_set_mode(WIFI_MODE_STA); + if (err) { + return err; + } + err = rpc_wifi_start(); + if (err) { + return err; + } + + /* Power save OFF, and this is not a performance tweak - it decides whether the board is + * reachable at all. + * + * ESP-IDF's default is WIFI_PS_MIN_MODEM (esp_wifi_types_generic.h:376): the station sleeps and + * only wakes for a beacon every DTIM period. A sleeping station misses frames the AP does not + * buffer for it, and broadcast ARP is exactly that. The observed symptom on this board was + * precise and misleading: DHCP completed - because the host speaks first and the reply arrives + * inside the wake window - the board took a real lease, and then it answered no ARP and no ping, + * with the frame counter advancing about once per ten seconds. It looked like a broken receive + * path rather than a radio that was asleep. + * + * A device that exists to answer requests cannot sleep between them. WIFI_PS_NONE. */ + return rpc_wifi_set_ps(WIFI_PS_NONE); +} + +/* The station's MAC. Needed by the IP stack: ARP and Ethernet framing are built around it, and it + * belongs to the C6's radio, not to this chip. */ +int hosted_wifi_get_mac(uint8_t out[6]) +{ + return rpc_wifi_get_mac(WIFI_IF_STA, out); +} + +/* Scan every channel and report how many networks were seen. + * + * Blocking: the RPC layer waits for the coprocessor to finish, which takes a couple of seconds + * across all channels. A scan needs no credentials, which makes it the cheapest end-to-end proof + * that the RPC path and the radio both work. */ +int hosted_wifi_scan(uint16_t *found) +{ + wifi_scan_config_t scan = { 0 }; + scan.show_hidden = true; + int err = rpc_wifi_scan_start(&scan, true); + if (err) { + return err; + } + return rpc_wifi_scan_get_ap_num(found); +} + +/* One scan result, flattened to scalars. + * + * `ssid_out` must have room for 33 bytes; the SSID is copied NUL-terminated. Returns the number of + * records actually written into the caller's view, which is `min(*count, what the slave has)`. + */ +int hosted_wifi_scan_record(uint16_t index, char *ssid_out, int8_t *rssi_out, + uint8_t *channel_out, uint8_t *authmode_out) +{ + /* One record at a time, into a local, so the caller never sees a wifi_ap_record_t. Asking the + * slave for a single record by index is not part of the RPC, so this fetches the run up to + * `index` and keeps the last - fine for the small numbers a diagnostic prints, and stated here + * rather than hidden because it is O(n^2) if someone loops it over hundreds of networks. */ + static wifi_ap_record_t records[16]; + uint16_t want = index + 1; + if (want > 16) { + return -1; + } + int err = rpc_wifi_scan_get_ap_records(&want, records); + if (err) { + return err; + } + if (index >= want) { + return -1; + } + + const wifi_ap_record_t *r = &records[index]; + size_t n = strnlen((const char *)r->ssid, 32); + memcpy(ssid_out, r->ssid, n); + ssid_out[n] = 0; + *rssi_out = r->rssi; + *channel_out = r->primary; + *authmode_out = (uint8_t)r->authmode; + return 0; +} + +/* Join a network. + * + * `ssid` and `psk` are NUL-terminated. The PSK is copied into the request and never stored here; + * it arrives from a build option so it is not in the source, and this function keeps no copy after + * the RPC returns. + * + * `threshold.authmode` is deliberately left at 0 (WIFI_AUTH_OPEN) rather than forced to WPA2: it is + * a *minimum* acceptable security level, and pinning it too high refuses networks that would + * otherwise work while pinning it low refuses nothing. The AP's actual authmode is what gets used. + */ +int hosted_wifi_connect(const char *ssid, const char *psk) +{ + wifi_config_t conf = { 0 }; + + size_t ssid_len = strnlen(ssid, sizeof(conf.sta.ssid) - 1); + memcpy(conf.sta.ssid, ssid, ssid_len); + + size_t psk_len = strnlen(psk, sizeof(conf.sta.password) - 1); + memcpy(conf.sta.password, psk, psk_len); + + /* Scan all channels and pick the strongest match rather than the first: this network has both a + * 2.4 GHz and a 5 GHz radio on the same SSID family, and the C6 is 2.4 GHz only. */ + conf.sta.scan_method = WIFI_ALL_CHANNEL_SCAN; + conf.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL; + + int err = rpc_wifi_set_config(WIFI_IF_STA, &conf); + if (err) { + return err; + } + return rpc_wifi_connect(); +} diff --git a/src/net/hosted_glue.zig b/src/net/hosted_glue.zig new file mode 100644 index 0000000..40bac2e --- /dev/null +++ b/src/net/hosted_glue.zig @@ -0,0 +1,320 @@ +//! The symbols ESP-Hosted's transport needs that are neither libc nor the `g_h` port table: +//! this board's transport configuration, a logging sink, and honest stubs for the layers above +//! the transport that milestone 1 does not run. +//! +//! Measured, not guessed. Linking transport_drv.o + transport_util.o + sdio_drv.o + mempool.o +//! leaves 26 undefined symbols. src/net/libc.zig covers the libc ones, src/net/port.zig covers +//! `g_h`, and everything else is here. +//! +//! The distinction that matters in this file: a *configuration* symbol returns real values for this +//! board, and a *stub* prints its own name and parks. Nothing here silently returns success. On a +//! board with no debugger, a function that quietly does nothing is indistinguishable from a +//! function that worked, and that is the failure mode this project keeps paying for. + +const std = @import("std"); +const soc = @import("soc"); +const hal = @import("hal"); + +// --------------------------------------------------------------------------------------------- +// Board configuration is NOT here, deliberately. +// +// An earlier version of this file hand-wrote `esp_hosted_sdio_get_config` and +// `esp_hosted_transport_get_reset_config` in Zig, with a Zig `extern struct` mirroring +// `struct esp_hosted_sdio_config`. That was wrong twice over: the real signature takes a +// `struct esp_hosted_sdio_config **` and hands back a pointer to static storage rather than +// filling a caller's struct (host/api/include/esp_hosted_transport_config.h:151), and the real +// struct interleaves `gpio_pin_t {void *port; int pin;}` pairs rather than plain ints +// (same header, lines 22-45). A transcription of that layout is a silent wrong-pin bug waiting +// to happen. +// +// ESP-Hosted already ships both getters, deriving every value from Kconfig: +// host/api/src/esp_hosted_transport_config.c +// host/port/esp/freertos/src/port_esp_hosted_host_transport_defaults.c +// Together they compile clean under our flags and need only esp_log, esp_log_timestamp and the +// ROM's mem* - so the build compiles them instead. Nothing is transcribed and nothing can drift. +// +// What guarantees they produce THIS board's wiring is a compile-time check, not a comment: +// src/net/hosted/pin_assert.c static-asserts the Kconfig macros against the measured pin map +// (slot 1, 4-bit, 40 MHz, CLK 18, CMD 19, D0-D3 = 14/15/16/17, C6 reset 54) and is compiled as +// part of the hosted build. If a Kconfig value ever drifts from the board, the build fails with +// the name of the pin instead of the radio silently not answering. + +// --------------------------------------------------------------------------------------------- +// Logging +// +// ESP-Hosted logs through IDF's `esp_log`. Routing it to the ROM UART printer keeps the transport's +// own diagnostics - which are good, and are how we will see the handshake progress - without +// linking esp_log_write, its lock, its timestamp source or its level filtering. +// --------------------------------------------------------------------------------------------- + +/// IDF's log levels, from esp_log_level_t. +pub const Level = enum(c_int) { none = 0, err = 1, warn = 2, info = 3, debug = 4, verbose = 5 }; + +/// Everything at or below this prints. `.debug` while bringing the transport up: its per-packet +/// logging is the only view into the handshake before the IP stack exists. +var level: Level = .debug; + +pub fn setLevel(l: Level) void { + level = l; +} + +export fn esp_log_timestamp() callconv(.c) u32 { + // Milliseconds since boot, from the 16 MHz systimer - the only trustworthy timebase on this + // die, since the CPU runs at the bootloader's 90 MHz and nothing reconfigures the PLL. + // + // `read` is optional because the counter has a latch-then-read handshake that can fail to + // complete (see src/hal/systimer.zig, and the differential case that exists because of it). + // A failed read yields 0 rather than propagating: this is a log timestamp, and a logging call + // that panics would destroy exactly the diagnostics being printed. + const ticks = hal.systimer.read(.unit0) orelse return 0; + return @intCast(ticks / 16_000); +} + +export fn esp_log_level_get(_: ?[*:0]const u8) callconv(.c) c_int { + return @intFromEnum(level); +} + +export fn esp_log_level_set(_: ?[*:0]const u8, _: c_int) callconv(.c) void { + // Deliberately ignored: this build has one global level, set from Zig. Silently accepting the + // call is right here - a caller lowering a tag's verbosity is not load-bearing - and it is the + // only silent no-op in this file. +} + +export fn esp_log_default_level() callconv(.c) c_int { + return @intFromEnum(level); +} + +/// IDF v6's variadic log entry point. ESP-Hosted's ESP_LOG* macros land here. +export fn esp_log( + cfg: u32, + // Unused: the tag is already inside `fmt`, put there by ESP-Hosted's own logging macros. Kept + // in the signature because this is a C ABI entry point and the argument is really passed. + _: ?[*:0]const u8, + fmt: ?[*:0]const u8, + ..., +) callconv(.c) void { + // esp_log_config_t packs the level into the low bits; IDF's esp_log_level_t ordering means a + // numerically higher value is more verbose. + // The level lives in the low 3 bits: esp_log_config_t's `log_level` field is declared + // `esp_log_level_t log_level: ESP_LOG_LEVEL_LEN` (esp_log_config.h:122) with ESP_LOG_LEVEL_LEN + // = 3 (esp_log_level.h:30). Verified on the die by printing the raw word: info lines arrive as + // cfg=0x00000003 and warnings as cfg=0x00000002. + const msg_level: c_int = @intCast(cfg & 0x7); + if (msg_level > @intFromEnum(level)) return; + + var ap = @cVaStart(); + defer @cVaEnd(&ap); + emit(fmt orelse "", &ap); +} + +/// The other entry point in IDF v6; same job, already-started va_list. +export fn esp_log_writev( + msg_level: c_int, + _: ?[*:0]const u8, + fmt: ?[*:0]const u8, + ap: *std.builtin.VaList, +) callconv(.c) void { + if (msg_level > @intFromEnum(level)) return; + emit(fmt orelse "", ap); +} + +/// Format one already-level-filtered log line and put it on the wire. +/// +/// Takes neither the level nor the tag, and that is the point: ESP-Hosted's logging macros +/// (esp_hosted_log.h) bake the level letter, the timestamp and the tag into the format string they +/// hand us. An earlier version of this function added its own prefix as well, and every line came +/// out doubled: +/// +/// W (136) H_SDIO_DRV: W (136) H_SDIO_DRV: provided sdio tx queue size is zero! +/// +/// The level still matters - `esp_log` and `esp_log_writev` filter on it before calling here - it +/// just has no business in the output a second time. +fn emit(fmt: [*:0]const u8, ap: *std.builtin.VaList) void { + var line: [256]u8 = undefined; + const n = vsnprintf(&line, line.len, fmt, ap); + if (n <= 0) return; + const len = @min(@as(usize, @intCast(n)), line.len - 1); + // Print with plain `%s`, never `%.*s`: the ROM's `ets_printf` does not implement `.*` + // precision and prints the specifier literally, which is how the first run of this code + // produced "W (141) H_SDIO_DRV: %*0s" instead of a message. + line[len] = 0; + soc.rom.print("%s", .{@as([*:0]const u8, @ptrCast(&line))}); + // ESP-Hosted's own lines already end in \n; anything else gets a terminator so the next line + // does not run into it. + if (line[len - 1] != '\n') soc.rom.print("\r\n", .{}); +} + +extern fn vsnprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ap: *std.builtin.VaList) c_int; + +/// Reached by protobuf-c's error paths. Lives here rather than in src/net/libc.zig because it needs +/// the ROM printer, and libc.zig is deliberately free of chip imports so it can be host-tested. +export fn printf(fmt: [*:0]const u8, ...) callconv(.c) c_int { + var ap = @cVaStart(); + defer @cVaEnd(&ap); + var line: [256]u8 = undefined; + const n = vsnprintf(&line, line.len, fmt, &ap); + if (n > 0) soc.rom.print("%s", .{@as([*:0]const u8, @ptrCast(&line))}); + return n; +} + +/// IDF's hex dump, referenced by ESP-Hosted's ESP_HEXLOG* macros once DEBUG-level logging is +/// compiled in (sdkconfig.h override 5). A real implementation, because a hexdump that prints +/// nothing is worse than none at all when the thing being debugged is a wire format - but bounded to +/// 64 bytes a call, since the point is to identify a packet rather than to transcribe it. +export fn esp_log_buffer_hexdump_internal( + tag: ?[*:0]const u8, + buffer: ?*const anyopaque, + buff_len: u16, + msg_level: c_int, +) callconv(.c) void { + if (msg_level > @intFromEnum(level)) return; + const bytes: [*]const u8 = @ptrCast(buffer orelse return); + const n = @min(buff_len, 64); + soc.rom.print("%s: %u bytes:", .{ @as([*:0]const u8, tag orelse "hex"), @as(u32, buff_len) }); + for (0..n) |i| soc.rom.print(" %02x", .{@as(u32, bytes[i])}); + if (n < buff_len) soc.rom.print(" ...", .{}); + soc.rom.print("\r\n", .{}); +} + +export fn esp_rom_printf(fmt: [*:0]const u8, ...) callconv(.c) c_int { + // The ROM printer is what this is named after; hand it straight over. + var ap = @cVaStart(); + defer @cVaEnd(&ap); + var line: [256]u8 = undefined; + const n = vsnprintf(&line, line.len, fmt, &ap); + if (n > 0) soc.rom.print("%s", .{@as([*:0]const u8, @ptrCast(&line))}); + return n; +} + +// --------------------------------------------------------------------------------------------- +// Event base +// +// `ESP_HOSTED_EVENT` and `WIFI_EVENT` are esp_event base symbols - opaque pointers whose *address* +// is the identity. Nothing dereferences them, so a byte of storage each is a complete +// implementation, and `_h_event_post` in port.zig is what actually routes events. +// --------------------------------------------------------------------------------------------- + +export const ESP_HOSTED_EVENT: u8 = 0; +export const WIFI_EVENT: u8 = 0; + +// --------------------------------------------------------------------------------------------- +// Stubs for the layers milestone 1 does not run. +// +// Each prints its own name and parks. That is the whole point: reaching one of these means the +// transport got further than expected and the next layer is now needed, which is information. A +// stub that returned 0 would turn that into a hang with no console output. +// --------------------------------------------------------------------------------------------- + +fn unimplemented(comptime name: []const u8) noreturn { + soc.rom.print("\r\n=== esp_hosted reached " ++ name ++ ", which this build does not implement.\r\n", .{}); + soc.rom.print("=== The transport got further than milestone 1. Implement it in src/net/.\r\n", .{}); + while (true) {} +} + +// rpc_start and serial_ll_rx_handler are no longer stubbed here: build.zig compiles ESP-Hosted's +// own RPC layer (host/drivers/rpc/**, plus protobuf-c and the generated descriptors), which defines +// both. The loud stub did its job first - it is what turned "the radio hangs" into a console line +// naming rpc_start as the next thing to build. + +// Bluetooth is not stubbed here. ESP-Hosted ships host/drivers/bt/hci_stub_drv.c, which is the +// vendor's own no-op hci_drv_init and a drop-everything hci_rx_handler for a host without BT, and +// build.zig compiles it. Defining them here as well is a duplicate-symbol link error - which is how +// this comment came to exist. BT is switched off in src/net/hosted/sdkconfig.h so that file does not +// pull NimBLE in. + +/// ESP-Hosted's console commands. There is no console component in this image. +export fn esp_hosted_cli_start() callconv(.c) c_int { + unimplemented("esp_hosted_cli_start"); +} + +export fn esp_hosted_cli_stop() callconv(.c) c_int { + unimplemented("esp_hosted_cli_stop"); +} + +// create_debugging_tasks is ESP-Hosted's own (host/utils/stats.c), compiled by build.zig. With the +// stats Kconfig options off it spawns nothing. + +/// IDF's internal Wi-Fi receive-callback registration, and it stays a loud stub deliberately: the +/// station frame path does not go through it, and it has never been reached. +/// +/// It was expected to be the seam. It is not. In the file set build.zig compiles, the only caller +/// is `transport_drv_remove_channel` (transport_drv.c:252), which unregisters on teardown - and +/// nothing in this project tears a channel down. `transport_drv_add_channel`, the registration +/// half, never mentions it (transport_drv.c:440-502): it stores the callback in `chan_arr[if_type]` +/// and `sdio_process_rx_task` calls it from there (sdio_drv.c:1394-1408). That channel callback is +/// the whole story, and src/net/link.zig is what registers it. +/// +/// This function exists because ESP-Hosted's C references the symbol and the link needs it. If it +/// is ever reached, the console line it prints is real information - something began tearing the +/// station channel down - and that is worth more than a silent zero. +export fn esp_wifi_internal_reg_rxcb(_: c_int, _: ?*anyopaque) callconv(.c) c_int { + unimplemented("esp_wifi_internal_reg_rxcb"); +} + +/// Host power-save. Not used: this board is mains-powered and the path adds a wakeup protocol +/// between the P4 and the C6 that nothing here needs. +export fn stop_host_power_save() callconv(.c) c_int { + unimplemented("stop_host_power_save"); +} + +export fn esp_hosted_woke_from_power_save() callconv(.c) bool { + // Answering this one honestly is better than parking: it is called on the normal boot path, + // and the truthful answer on a board that never sleeps is "no". + return false; +} + +export fn release_slave_reset_gpio_post_wakeup() callconv(.c) void { + // Same reasoning: only meaningful after a power-save wakeup, which cannot have happened. +} + +// --------------------------------------------------------------------------------------------- +// esp_netif, declined. +// +// ESP-Hosted's RPC layer asks IDF's network-interface layer whether an interface exists and whether +// it is up, before handing it a received frame. This project does not use esp_netif or lwIP - the +// whole point of src/net/ip.zig is to replace them - so there is no handle to give it and no +// interface it would recognise. +// +// Answering "no interface" is the truthful answer and it is safe, and this is now observed rather +// than hoped for: station frames reach this project through the transport's own channel callback, +// which src/net/link.zig registers with `transport_drv_add_channel` and which sdio_drv.c:1394-1408 +// dispatches to. That path does not consult esp_netif at all. These two stay as they are. +// --------------------------------------------------------------------------------------------- + +export fn esp_netif_get_handle_from_ifkey(_: ?[*:0]const u8) callconv(.c) ?*anyopaque { + return null; +} + +export fn esp_netif_is_netif_up(_: ?*anyopaque) callconv(.c) bool { + return false; +} + +/// mempool.c's pluggable backend. ESP-Hosted's own static pool is used, so the ops table is null; +/// mempool.c checks for null and falls back. +export fn os_mempool_get_ops() callconv(.c) ?*anyopaque { + return null; +} + +// There are no tests in this file, and that is a deliberate answer rather than an omission. +// +// Everything here ends in the ROM UART printer (`ets_printf`, a mask-ROM address) or in +// `vsnprintf` from src/net/libc.zig, so a standalone host build compiles but cannot link. What is +// worth checking is the level *ordering* - and that is checkable at compile time, on every build, +// which is strictly better than a test that only runs when someone asks: + +comptime { + // IDF's esp_log_level_t numbers levels so that a HIGHER value is MORE verbose. The filter in + // `esp_log` above is therefore `msg_level > level -> drop`. Inverting that inequality would + // silently discard exactly the transport diagnostics that bring-up depends on, and the code + // would look right. These assertions pin the ordering the filter assumes. + std.debug.assert(@intFromEnum(Level.none) < @intFromEnum(Level.err)); + std.debug.assert(@intFromEnum(Level.err) < @intFromEnum(Level.warn)); + std.debug.assert(@intFromEnum(Level.warn) < @intFromEnum(Level.info)); + std.debug.assert(@intFromEnum(Level.info) < @intFromEnum(Level.debug)); + std.debug.assert(@intFromEnum(Level.debug) < @intFromEnum(Level.verbose)); + + // The values must be IDF's own, not merely ordered: ESP-Hosted's C passes esp_log_level_t + // integers across the ABI, so a shifted enum would misclassify every line. + std.debug.assert(@intFromEnum(Level.err) == 1); + std.debug.assert(@intFromEnum(Level.verbose) == 5); +} diff --git a/src/net/hosted_os.zig b/src/net/hosted_os.zig new file mode 100644 index 0000000..d844177 --- /dev/null +++ b/src/net/hosted_os.zig @@ -0,0 +1,890 @@ +//! ESP-Hosted's OS objects - mutex, counting semaphore, fixed-capacity queue, thread, software +//! timer - expressed in `std.Io`, with FreeRTOS's exact observable behaviour. +//! +//! Nothing here reimplements a synchronisation primitive. `std.Io.Mutex`, `std.Io.Semaphore` and +//! `std.Io.TypeErasedQueue` do the blocking; this file supplies only the three things ESP-Hosted +//! needs that they do not have: +//! +//! 1. **The timeout dialect.** `_h_lock_mutex`, `_h_get_semaphore` and `_h_dequeue_item` all take +//! an `int`, where 0 means "do not block", a negative value means "block forever", and a +//! positive value means a bounded wait. The unit of that positive value is *not* the same in +//! all three - see `Wait`. +//! 2. **The return codes.** `RET_OK`/`RET_FAIL`/`RET_INVALID`/`RET_FAIL_TIMEOUT` from +//! `port_esp_hosted_host_os.h:86-91`, which the C caller branches on. +//! 3. **The initial state.** A FreeRTOS semaphore created by +//! `hosted_create_semaphore` (`port_esp_hosted_host_os.c:523-547`) is given *once* before it +//! is returned, so it starts with one permit, and callers rely on that: `sdio_drv.c:1504`, +//! `:1508` and `:1540` each take that permit back immediately after creating the semaphore. A +//! semaphore that started at zero would leave every count in the transport off by one. +//! +//! This file is deliberately free of hardware and of the C ABI, so it runs on the host under +//! `std.Io.Threaded` and the tests below are real tests. + +const std = @import("std"); +const assert = std.debug.assert; +const Io = std.Io; +const Allocator = std.mem.Allocator; + +/// `port_esp_hosted_host_os.h:86-91`. +pub const ret = struct { + pub const ok: c_int = 0; + pub const fail: c_int = -1; + pub const invalid: c_int = -2; + pub const fail_mem: c_int = -3; + pub const fail4: c_int = -4; + pub const fail_timeout: c_int = -5; +}; + +/// The clock everything here measures against. `.awake` is `std.Io`'s monotonic clock; on this +/// board it is `hal.systimer`'s fixed 16 MHz, which does not move when the CPU clock does. +pub const clock: Io.Clock = .awake; + +/// How often a bounded wait re-checks. +/// +/// `std.Io.Semaphore` and `std.Io.TypeErasedQueue` have no timed acquire, and neither does +/// `std.Io.Mutex`; only `futexWaitTimeout` does, and reaching for it would mean rebuilding those +/// three primitives instead of using them. So a *bounded* wait polls, and an unbounded one - which +/// is what every hot path in ESP-Hosted actually uses - blocks properly with no polling at all. +/// +/// The cost is bounded and small: a bounded wait is used in exactly one place in the tree, +/// `rpc_core.c:844`, the synchronous-RPC response wait, whose timeout is measured in seconds. One +/// millisecond of added latency on a request that is allowed to take five seconds is not worth a +/// hand-rolled futex semaphore. +pub const poll_interval_ms: u32 = 1; + +/// The three shapes an ESP-Hosted timeout argument can take. +pub const Wait = union(enum) { + /// `0` - try, do not block. + immediate, + /// Negative, i.e. `HOSTED_BLOCKING` (-1) or `HOSTED_BLOCK_MAX` (`portMAX_DELAY`, which reaches + /// an `int` parameter as -1). + forever, + /// A bounded wait, in milliseconds. + bounded_ms: u32, + + /// The dialect used by `_h_lock_mutex` and `_h_get_semaphore`: a positive value is + /// milliseconds (`port_esp_hosted_host_os.c:452`, `:573`). + pub fn fromMillis(timeout: c_int) Wait { + if (timeout == 0) return .immediate; + if (timeout < 0) return .forever; + return .{ .bounded_ms = @intCast(timeout) }; + } + + /// The dialect used by `_h_dequeue_item`: a positive value is *seconds*, because the + /// implementation converts it with `SEC_TO_MILLISEC` before `pdMS_TO_TICKS` + /// (`port_esp_hosted_host_os.c:336`). The asymmetry with `fromMillis` is not a mistake in this + /// file; it is a mistake in ESP-Hosted that this file has to reproduce. No caller in the tree + /// passes a positive value to a queue, so nothing depends on it today. + pub fn fromQueueTimeout(timeout: c_int) Wait { + if (timeout == 0) return .immediate; + if (timeout < 0) return .forever; + return .{ .bounded_ms = @as(u32, @intCast(timeout)) *| 1000 }; + } +}; + +/// Milliseconds on `clock`, which is what `_h_get_time_ms` returns. +/// +/// The narrowing to `u64` before the division is not cosmetic. `Io.Timestamp.nanoseconds` is `i96`, +/// and `@divFloor` on an `i96` compiles to a call to compiler_rt's `__divti3` - a 128-bit software +/// division, on every call, on a 90 MHz in-order core. Narrowing first turns that into +/// `__udivdi3`, a 64-bit one. Both were read out of the object's undefined-symbol list rather than +/// guessed; ReleaseSmall declines to strength-reduce even a constant 64-bit divisor, so the +/// libcall stays, but it is now half the width. The range given up is imaginary: 2^64 nanoseconds +/// is 584 years, and this clock starts at boot. +pub fn nowMs(io: Io) u64 { + const ns = clock.now(io).nanoseconds; + if (ns <= 0) return 0; + const ns64: u64 = @intCast(ns); + return ns64 / std.time.ns_per_ms; +} + +/// Sleep one poll interval. Reports cancelation so bounded waits abandon promptly rather than +/// spinning out the full timeout after the task has been asked to stop. +fn pollSleep(io: Io) error{Canceled}!void { + return io.sleep(.fromMilliseconds(poll_interval_ms), clock); +} + +// --------------------------------------------------------------------------------------- Mutex + +/// FreeRTOS gives ESP-Hosted a *recursive-capable* mutex handle but ESP-Hosted never recurses on +/// one: every use is a bracketed `SDIO_LOCK`/`SDIO_UNLOCK` or equivalent, and every one of the ten +/// call sites in the tree passes `HOSTED_BLOCK_MAX`. So a plain `std.Io.Mutex` is the whole +/// requirement. +pub const Mutex = struct { + inner: Io.Mutex = .init, + + pub fn lock(m: *Mutex, io: Io, w: Wait) c_int { + switch (w) { + .immediate => return if (m.inner.tryLock()) ret.ok else ret.fail, + .forever => { + m.inner.lockUncancelable(io); + return ret.ok; + }, + .bounded_ms => |ms| { + const deadline = nowMs(io) + ms; + while (true) { + if (m.inner.tryLock()) return ret.ok; + if (nowMs(io) >= deadline) return ret.fail; + pollSleep(io) catch return ret.fail; + } + }, + } + } + + pub fn unlock(m: *Mutex, io: Io) c_int { + m.inner.unlock(io); + return ret.ok; + } +}; + +// ----------------------------------------------------------------------------------- Semaphore + +/// A counting semaphore with FreeRTOS's cap and FreeRTOS's initial count. +/// +/// The blocking path is `std.Io.Semaphore` untouched. What is added around it: +/// +/// * a **maximum count**, because `xSemaphoreCreateCounting(maxCount, 0)` refuses a give past +/// `maxCount` and `std.Io.Semaphore` has no ceiling. `sdio_drv.c:1502` sizes +/// `sem_to_slave_queue` at `tx_queue_size * MAX_PRIORITY_QUEUES` precisely so that the +/// semaphore saturates when the queues do. +/// * a **non-blocking take**, which `std.Io.Semaphore` does not expose. It is the tail of +/// `Semaphore.wait` (`std/Io/Semaphore.zig:18-24`) with the `cond.wait` loop removed, using +/// the same public fields, so it takes and releases the same mutex in the same order. +/// * an **ISR-deferred post**; see `postFromIsr`. +pub const Semaphore = struct { + inner: Io.Semaphore, + max: u32, + /// Posts an interrupt handler could not deliver directly. Folded in by the next task-side + /// operation on this semaphore. + isr_posts: std.atomic.Value(u32) = .init(0), + + /// `maxCount` as ESP-Hosted passes it: `<= 1` means a binary semaphore. + /// + /// Starts with one permit, matching `port_esp_hosted_host_os.c:544` - see the file header. + pub fn init(max_count: u32) Semaphore { + return .{ .inner = .{ .permits = 1 }, .max = @max(max_count, 1) }; + } + + pub fn post(s: *Semaphore, io: Io) c_int { + // Fold in anything an interrupt deferred, so every task-side entry point closes that + // window and not just the waiting ones. Two instructions when nothing is pending. + s.drainIsrPosts(io); + return if (s.add(io, 1) == 1) ret.ok else ret.fail; + } + + /// `_h_post_semaphore_from_isr`, and the one entry in the whole table whose FreeRTOS meaning + /// does not survive the move to a cooperative scheduler intact. + /// + /// FreeRTOS has `xSemaphoreGiveFromISR`, which manipulates the semaphore inside a port-level + /// critical section and then asks for a context switch on return from the interrupt. Neither + /// half exists here. `std.Io.Semaphore.post` takes the semaphore's own `Io.Mutex`, and an + /// interrupt that blocked on a mutex held by the task it interrupted would deadlock the core - + /// there is no other task to run and no preemption to run it. + /// + /// What is safe on this runtime, confirmed with the runtime's author: `io.futexWake` runs + /// inside a critical section that clears `mstatus.MIE`, touches only the run queue, and never + /// takes a task-held lock. `Io.Mutex.tryLock` is a single compare-exchange. So: + /// + /// * if the mutex is free, the post happens inline and completely. On a single core with + /// interrupts already masked, no task can observe the intermediate state. + /// * if the mutex is held, the interrupted task is *running* and holds it - `Io.Condition` + /// releases the mutex before it blocks (`std/Io.zig:1689`), so nobody ever sleeps holding + /// it. The post is recorded in `isr_posts` and folded in by that task's next operation on + /// this semaphore, which is a few instructions away. + /// + /// The residual hole: if the interrupt lands in that few-instruction window *and* the only + /// other participant is already blocked in `wait`, the deferred post sits until someone else + /// touches the semaphore. `drainIsrPosts` exists so an application can close it from an + /// interrupt epilogue. On the SDIO transport this is moot: `_h_post_semaphore_from_isr` has + /// exactly two callers in the tree, `spi_drv.c:181` and `:190`, plus `spi_hd_drv.c:174`, and + /// none of them is compiled for SDIO. + /// + /// Returns `RET_OK` if the post was delivered or deferred, never fails: an interrupt has + /// nowhere to report a failure to. + pub fn postFromIsr(s: *Semaphore, io: Io) c_int { + if (s.inner.mutex.tryLock()) { + defer s.inner.mutex.unlock(io); + if (s.inner.permits < s.max) { + s.inner.permits += 1; + s.inner.cond.signal(io); + } + return ret.ok; + } + _ = s.isr_posts.fetchAdd(1, .release); + return ret.ok; + } + + /// Fold any interrupt-deferred posts into the semaphore. Safe and cheap to call from a task at + /// any time; a no-op when nothing is pending. + pub fn drainIsrPosts(s: *Semaphore, io: Io) void { + const pending = s.isr_posts.swap(0, .acquire); + if (pending != 0) _ = s.add(io, pending); + } + + /// Add `n` permits, saturating at `max`. Returns how many were actually added. + fn add(s: *Semaphore, io: Io, n: u32) u32 { + s.inner.mutex.lockUncancelable(io); + defer s.inner.mutex.unlock(io); + const room = s.max -| @as(u32, @intCast(s.inner.permits)); + const added = @min(room, n); + if (added == 0) return 0; + s.inner.permits += added; + // One signal per permit: `Io.Condition.signal` releases exactly one waiter. + for (0..added) |_| s.inner.cond.signal(io); + return added; + } + + /// `_h_get_semaphore`. Returns 0 on success and `RET_FAIL_TIMEOUT` otherwise, which is what + /// `port_esp_hosted_host_os.c:577-579` returns and what `rpc_core.c:844` tests. + pub fn wait(s: *Semaphore, io: Io, w: Wait) c_int { + switch (w) { + .immediate => return if (s.tryTake(io)) ret.ok else ret.fail_timeout, + .forever => { + s.drainIsrPosts(io); + // Cancelation is reported as RET_FAIL_TIMEOUT, which is the only failure code + // `hosted_get_semaphore` ever returns (port_esp_hosted_host_os.c:579) and + // therefore the only one callers test for. + s.inner.wait(io) catch return ret.fail_timeout; + return ret.ok; + }, + .bounded_ms => |ms| { + const deadline = nowMs(io) + ms; + while (true) { + if (s.tryTake(io)) return ret.ok; + if (nowMs(io) >= deadline) return ret.fail_timeout; + pollSleep(io) catch return ret.fail; + } + }, + } + } + + /// Take a permit if one is available. The body is `Semaphore.wait` + /// (`std/Io/Semaphore.zig:18-24`) minus its `cond.wait` loop. + pub fn tryTake(s: *Semaphore, io: Io) bool { + s.drainIsrPosts(io); + s.inner.mutex.lockUncancelable(io); + defer s.inner.mutex.unlock(io); + if (s.inner.permits == 0) return false; + s.inner.permits -= 1; + if (s.inner.permits > 0) s.inner.cond.signal(io); + return true; + } + + pub fn count(s: *Semaphore, io: Io) u32 { + s.inner.mutex.lockUncancelable(io); + defer s.inner.mutex.unlock(io); + return @intCast(s.inner.permits); + } +}; + +// --------------------------------------------------------------------------------------- Queue + +/// A fixed-capacity queue of runtime-sized items. +/// +/// `_h_create_queue(qnum_elem, qitem_size)` fixes the element size at *run* time, so +/// `std.Io.Queue(Elem)` - which needs the type at compile time - cannot be used, but +/// `std.Io.TypeErasedQueue` can: it is a byte ring with `min`-byte put and get, which is exactly a +/// queue of fixed-size records once every operation moves `item_size` bytes. +/// +/// That the ring only ever moves whole items is what makes the non-blocking forms exact. The +/// buffer is `count * item_size` bytes and every transfer is `item_size`, so the occupied length is +/// always a multiple of `item_size`; a `min = 0` put therefore either fits the whole item or moves +/// nothing at all, and can never leave half a record in the ring. +pub const Queue = struct { + inner: Io.TypeErasedQueue, + item_size: u32, + /// Owned; freed by `destroy`. + buffer: []u8, + + pub fn create(gpa: Allocator, count: u32, item_size: u32) ?*Queue { + assert(item_size > 0); + const q = gpa.create(Queue) catch return null; + const buf = gpa.alloc(u8, @as(usize, count) * item_size) catch { + gpa.destroy(q); + return null; + }; + q.* = .{ .inner = .init(buf), .item_size = item_size, .buffer = buf }; + return q; + } + + pub fn destroy(q: *Queue, io: Io, gpa: Allocator) void { + q.inner.close(io); + gpa.free(q.buffer); + gpa.destroy(q); + } + + /// `_h_queue_item`. `item` points at one `item_size` record, which is copied into the queue - + /// FreeRTOS's `xQueueSendToBack` copies too, which is why every caller passes `&handle` rather + /// than a heap pointer. + pub fn send(q: *Queue, io: Io, item: [*]const u8, w: Wait) c_int { + const n = q.item_size; + const slice = item[0..n]; + switch (w) { + .immediate => { + const put = q.inner.put(io, slice, 0) catch return ret.fail; + return if (put == n) ret.ok else ret.fail; + }, + .forever => { + // Uncancelable, deliberately. A cancelable blocking put can be interrupted + // *after* it has copied part of a record into the ring, and since the ring's + // occupied length is what makes the non-blocking forms exact, a half record + // desynchronises every subsequent transfer. FreeRTOS's portMAX_DELAY does not + // return early either. The cost is that a canceled task blocked here stays + // blocked - which it would anyway: `Future.cancel` signals only the *next* + // cancelation point, and ESP-Hosted's task bodies loop straight back into the + // queue. See `Thread.cancel`. + const put = q.inner.putUncancelable(io, slice, n) catch return ret.fail; + return if (put == n) ret.ok else ret.fail; + }, + .bounded_ms => |ms| { + const deadline = nowMs(io) + ms; + while (true) { + const put = q.inner.put(io, slice, 0) catch return ret.fail; + if (put == n) return ret.ok; + assert(put == 0); // a partial record would corrupt the ring + if (nowMs(io) >= deadline) return ret.fail; + pollSleep(io) catch return ret.fail; + } + }, + } + } + + /// `_h_dequeue_item`. Returns 0 on success, `RET_FAIL` on timeout - note the asymmetry with + /// `Semaphore.wait`, which returns `RET_FAIL_TIMEOUT`; `port_esp_hosted_host_os.c:342` really + /// does return the plain failure code here. + pub fn receive(q: *Queue, io: Io, out: [*]u8, w: Wait) c_int { + const n = q.item_size; + const slice = out[0..n]; + switch (w) { + .immediate => { + const got = q.inner.get(io, slice, 0) catch return ret.fail; + return if (got == n) ret.ok else ret.fail; + }, + .forever => { + // Uncancelable for the same reason as `send`. + const got = q.inner.getUncancelable(io, slice, n) catch return ret.fail; + return if (got == n) ret.ok else ret.fail; + }, + .bounded_ms => |ms| { + const deadline = nowMs(io) + ms; + while (true) { + const got = q.inner.get(io, slice, 0) catch return ret.fail; + if (got == n) return ret.ok; + assert(got == 0); + if (nowMs(io) >= deadline) return ret.fail; + pollSleep(io) catch return ret.fail; + } + }, + } + } + + /// `_h_queue_msg_waiting` = `uxQueueMessagesWaiting`, which counts *buffered* items only and + /// not producers blocked with an item in hand. + pub fn waiting(q: *Queue, io: Io) c_int { + q.inner.mutex.lockUncancelable(io); + defer q.inner.mutex.unlock(io); + return @intCast(q.inner.len / q.item_size); + } + + /// `_h_reset_queue` = `xQueueReset`: discard everything buffered. Blocked producers and + /// consumers are left alone, which is also what FreeRTOS does for waiting *receivers*; it + /// differs in that FreeRTOS re-evaluates blocked senders. No caller in the tree uses this. + pub fn reset(q: *Queue, io: Io) c_int { + q.inner.mutex.lockUncancelable(io); + defer q.inner.mutex.unlock(io); + q.inner.start = 0; + q.inner.len = 0; + return ret.ok; + } +}; + +// -------------------------------------------------------------------------------------- Thread + +/// ESP-Hosted's task entry point: `void (*)(void const *)`, called once and never expected to +/// return (`port_esp_hosted_host_os.c:163`, and every body in the tree is a `while (1)` loop). +pub const StartRoutine = *const fn (?*const anyopaque) callconv(.c) void; + +pub const Thread = struct { + future: Io.Future(void), + name: [*:0]const u8, + + fn trampoline(start: StartRoutine, arg: ?*const anyopaque) void { + start(arg); + } + + /// `io.concurrent`, not `io.async`, and the difference is the whole point of the entry. + /// + /// `xTaskCreate` returns a task that exists and will run whatever its creator does next. + /// `io.async` promises less: the implementation is allowed to run the body inline before + /// returning, which for an ESP-Hosted task body - an unconditional `while (1)` - would never + /// return and would deadlock initialisation on the spot. `io.concurrent` forbids exactly that + /// (`std/Io.zig:2358-2364`) and reports `error.ConcurrencyUnavailable` when no unit of + /// concurrency is free. + /// + /// Turning that error into NULL is right: `_h_thread_create` is documented to return NULL on + /// failure and its callers check (`rpc_core.c:582`, `sdio_drv.c:1543`). A task pool one slot + /// too small then produces a legible "thread creation failed" instead of a hang. + pub fn create(io: Io, gpa: Allocator, name: [*:0]const u8, start: StartRoutine, arg: ?*const anyopaque) ?*Thread { + const t = gpa.create(Thread) catch return null; + t.* = .{ + .future = io.concurrent(trampoline, .{ start, arg }) catch { + gpa.destroy(t); + return null; + }, + .name = name, + }; + return t; + } + + /// `_h_thread_cancel` maps to `Future.cancel`, and this is the second place FreeRTOS's model + /// does not fit. + /// + /// `vTaskDelete` destroys a task from outside, wherever it happens to be. `std.Io`'s cancel is + /// cooperative: it asks, then *waits for the task body to return*. ESP-Hosted's task bodies + /// never return - `sdio_read_task`, `rpc_rx_thread` and the rest are unconditional loops - so + /// this call completes only if the body happens to exit, and otherwise blocks. + /// + /// That is survivable because of where it is called from: `cancel_rpc_threads` + /// (`rpc_core.c`) and the transport teardown paths, both of which run only when the host is + /// about to restart the slave. It is not survivable as a routine operation, and if a teardown + /// path becomes routine the fix is a `killTask` on the runtime that reclaims the slot without + /// unwinding, not a change here: there is no way to unwind a C frame from Zig. + pub fn cancel(t: *Thread, io: Io, gpa: Allocator) c_int { + t.future.cancel(io); + gpa.destroy(t); + return ret.ok; + } +}; + +// ------------------------------------------------------------------------------- software timers + +pub const TimerHandler = *const fn (?*anyopaque) callconv(.c) void; + +pub const TimerKind = enum(c_int) { + /// `H_TIMER_TYPE_ONESHOT`, port_esp_hosted_host_os.h:39. + oneshot = 0, + /// `H_TIMER_TYPE_PERIODIC`. + periodic = 1, +}; + +pub const Timer = struct { + handler: TimerHandler = undefined, + arg: ?*anyopaque = null, + /// Absolute deadline on `clock`, in milliseconds. + deadline_ms: u64 = 0, + /// 0 for a one-shot. + period_ms: u32 = 0, + in_use: bool = false, +}; + +/// One task servicing every software timer, rather than one task per timer. +/// +/// ESP-IDF backs `_h_timer_start` with `esp_timer`, which has its own dedicated task. Doing the +/// obvious thing here - `io.async` per timer - would cost one whole task slot and one whole static +/// stack per timer, and ESP-Hosted starts up to three concurrently: the slave-unresponsive timer +/// (`transport_drv.c:188`), the per-request asynchronous RPC timeout (`rpc_core.c:215`), and the +/// power-save timer. At the stack sizes this runtime needs that is 15 KB to run three sleeps. +/// +/// So: one task, an array of slots, and a futex word that a `start` or `stop` bumps to make the +/// service task recompute its next deadline. Static footprint is `@sizeOf(Timer)` (24 bytes on +/// rv32) per slot plus one task stack. +/// +/// Handlers run on the service task, not in an interrupt, so they may block. `init_timeout_cb` +/// (`transport_drv.c`) calls `_h_restart_host`, which never returns, and that is fine here. +pub fn TimerService(comptime slot_count: usize) type { + return struct { + const Self = @This(); + + slots: [slot_count]Timer = @splat(.{}), + /// Bumped whenever a slot is armed or disarmed; the service task waits on it. + epoch: std.atomic.Value(u32) = .init(0), + /// Guards `slots`. A plain `Io.Mutex`: every critical section here is a few dozen + /// instructions and never blocks. + mutex: Io.Mutex = .init, + task: ?*Thread = null, + stopping: bool = false, + + pub fn start(self: *Self, io: Io, gpa: Allocator) bool { + if (self.task != null) return true; + const t = gpa.create(Thread) catch return false; + // Concurrent for the same reason as `Thread.create`: the service loop never returns, + // so an implementation permitted to run it inline would never return from `start`. + t.* = .{ + .future = io.concurrent(service, .{ self, io }) catch { + gpa.destroy(t); + return false; + }, + .name = "hosted_timers", + }; + self.task = t; + return true; + } + + /// Arm a slot. Returns its index, or null when every slot is in use. + pub fn arm(self: *Self, io: Io, ms: u32, kind: TimerKind, handler: TimerHandler, arg: ?*anyopaque) ?usize { + self.mutex.lockUncancelable(io); + const idx = blk: { + for (&self.slots, 0..) |*s, i| if (!s.in_use) break :blk i; + self.mutex.unlock(io); + return null; + }; + self.slots[idx] = .{ + .handler = handler, + .arg = arg, + .deadline_ms = nowMs(io) + ms, + .period_ms = if (kind == .periodic) ms else 0, + .in_use = true, + }; + self.mutex.unlock(io); + self.kick(io); + return idx; + } + + pub fn disarm(self: *Self, io: Io, idx: usize) c_int { + if (idx >= slot_count) return ret.invalid; + self.mutex.lockUncancelable(io); + const was = self.slots[idx].in_use; + self.slots[idx].in_use = false; + self.mutex.unlock(io); + self.kick(io); + return if (was) ret.ok else ret.fail; + } + + fn kick(self: *Self, io: Io) void { + _ = self.epoch.fetchAdd(1, .release); + io.futexWake(u32, &self.epoch.raw, 1); + } + + fn service(self: *Self, io: Io) void { + while (!self.stopping) { + const seen = self.epoch.load(.acquire); + const now = nowMs(io); + + // Fire everything due, collecting the handlers first so none of them runs while + // the slot table is locked: a handler may arm or disarm a timer. + var due: [slot_count]struct { h: TimerHandler, a: ?*anyopaque } = undefined; + var due_len: usize = 0; + var next_deadline: ?u64 = null; + + self.mutex.lockUncancelable(io); + for (&self.slots) |*s| { + if (!s.in_use) continue; + if (s.deadline_ms <= now) { + due[due_len] = .{ .h = s.handler, .a = s.arg }; + due_len += 1; + if (s.period_ms == 0) { + s.in_use = false; + } else { + s.deadline_ms = now + s.period_ms; + } + } + if (s.in_use) { + if (next_deadline == null or s.deadline_ms < next_deadline.?) + next_deadline = s.deadline_ms; + } + } + self.mutex.unlock(io); + + for (due[0..due_len]) |d| d.h(d.a); + if (due_len != 0) continue; + + if (next_deadline) |dl| { + const remaining = dl -| nowMs(io); + io.futexWaitTimeout(u32, &self.epoch.raw, seen, .{ + .duration = .{ .clock = clock, .raw = .fromMilliseconds(@intCast(remaining)) }, + }) catch return; + } else { + io.futexWait(u32, &self.epoch.raw, seen) catch return; + } + } + } + + pub fn stop(self: *Self, io: Io, gpa: Allocator) void { + const t = self.task orelse return; + self.stopping = true; + self.kick(io); + t.future.cancel(io); + gpa.destroy(t); + self.task = null; + } + }; +} + +// ---------------------------------------------------------------------------------------- tests + +const testing = std.testing; + +fn hostIo() struct { threaded: *Io.Threaded, io: Io } { + const t = testing.allocator.create(Io.Threaded) catch unreachable; + t.* = .init(testing.allocator, .{}); + return .{ .threaded = t, .io = t.io() }; +} + +test "Semaphore starts with one permit, as FreeRTOS's create+give does" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + + // sdio_drv.c:1502-1504 creates a counting semaphore and immediately takes the permit that + // hosted_create_semaphore left behind. If the count started at zero this take would fail and + // every subsequent count would be one too high. + var s = Semaphore.init(60); + try testing.expectEqual(@as(u32, 1), s.count(io)); + try testing.expectEqual(ret.ok, s.wait(io, .immediate)); + try testing.expectEqual(@as(u32, 0), s.count(io)); + + // Empty: a non-blocking take reports RET_FAIL_TIMEOUT, which is the code rpc_core.c:844 tests. + try testing.expectEqual(ret.fail_timeout, s.wait(io, .immediate)); +} + +test "Semaphore counts, saturates at max, and times out" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + + var s = Semaphore.init(3); + // Starts at 1; two more posts reach the cap. + try testing.expectEqual(ret.ok, s.post(io)); + try testing.expectEqual(ret.ok, s.post(io)); + try testing.expectEqual(@as(u32, 3), s.count(io)); + // FreeRTOS's xSemaphoreGive returns pdFALSE past maxCount, and so does this. + try testing.expectEqual(ret.fail, s.post(io)); + try testing.expectEqual(@as(u32, 3), s.count(io)); + + for (0..3) |_| try testing.expectEqual(ret.ok, s.wait(io, .immediate)); + + // A bounded wait on an empty semaphore returns RET_FAIL_TIMEOUT, and takes at least as long as + // it was asked to. + const before = nowMs(io); + try testing.expectEqual(ret.fail_timeout, s.wait(io, .{ .bounded_ms = 25 })); + try testing.expect(nowMs(io) - before >= 25); +} + +test "Semaphore: a blocked waiter is released by a post from another task" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + + var s = Semaphore.init(4); + try testing.expectEqual(ret.ok, s.wait(io, .immediate)); // drain the initial permit + + const Worker = struct { + fn run(sem: *Semaphore, i: Io) c_int { + return sem.wait(i, .forever); + } + }; + var f = io.async(Worker.run, .{ &s, io }); + // Give the waiter time to actually block, then release it. + try io.sleep(.fromMilliseconds(20), clock); + try testing.expectEqual(ret.ok, s.post(io)); + try testing.expectEqual(ret.ok, f.await(io)); + try testing.expectEqual(@as(u32, 0), s.count(io)); +} + +test "Semaphore: an interrupt-deferred post is folded in by the next task-side operation" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + + var s = Semaphore.init(4); + try testing.expectEqual(ret.ok, s.wait(io, .immediate)); + + // Simulate the contended case: hold the semaphore's mutex, so postFromIsr cannot deliver + // inline and must defer. This is the exact window described on `postFromIsr`. + s.inner.mutex.lockUncancelable(io); + try testing.expectEqual(ret.ok, s.postFromIsr(io)); + try testing.expectEqual(@as(u32, 1), s.isr_posts.load(.acquire)); + s.inner.mutex.unlock(io); + + // The next task-side touch delivers it. + try testing.expectEqual(ret.ok, s.wait(io, .immediate)); + try testing.expectEqual(@as(u32, 0), s.isr_posts.load(.acquire)); + + // Uncontended, it lands directly. + try testing.expectEqual(ret.ok, s.postFromIsr(io)); + try testing.expectEqual(@as(u32, 0), s.isr_posts.load(.acquire)); + try testing.expectEqual(@as(u32, 1), s.count(io)); +} + +test "Queue: fixed-capacity records, non-blocking edges, and message count" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + const gpa = testing.allocator; + + // 24 bytes is sizeof(interface_buffer_handle_t) on rv32, which is what every transport queue + // in ESP-Hosted carries. + const item_size = 24; + const q = Queue.create(gpa, 4, item_size).?; + defer q.destroy(io, gpa); + + try testing.expectEqual(@as(c_int, 0), q.waiting(io)); + // Empty, non-blocking: RET_FAIL, and note it is RET_FAIL and not RET_FAIL_TIMEOUT. + var out: [item_size]u8 = undefined; + try testing.expectEqual(ret.fail, q.receive(io, &out, .immediate)); + + var item: [item_size]u8 = undefined; + for (0..4) |i| { + @memset(&item, @intCast(i)); + try testing.expectEqual(ret.ok, q.send(io, &item, .immediate)); + try testing.expectEqual(@as(c_int, @intCast(i + 1)), q.waiting(io)); + } + // Full: a non-blocking send fails and leaves no partial record behind. + @memset(&item, 0xFF); + try testing.expectEqual(ret.fail, q.send(io, &item, .immediate)); + try testing.expectEqual(@as(c_int, 4), q.waiting(io)); + + // FIFO order, whole records. + for (0..4) |i| { + try testing.expectEqual(ret.ok, q.receive(io, &out, .forever)); + try testing.expect(std.mem.allEqual(u8, &out, @intCast(i))); + } + try testing.expectEqual(@as(c_int, 0), q.waiting(io)); + + // A bounded receive on an empty queue waits and then fails. + const before = nowMs(io); + try testing.expectEqual(ret.fail, q.receive(io, &out, .{ .bounded_ms = 25 })); + try testing.expect(nowMs(io) - before >= 25); +} + +test "Queue: blocking receive is woken by a producer, and reset discards" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + const gpa = testing.allocator; + + const q = Queue.create(gpa, 2, 4).?; + defer q.destroy(io, gpa); + + const Consumer = struct { + fn run(queue: *Queue, i: Io) u32 { + var buf: [4]u8 = undefined; + if (queue.receive(i, &buf, .forever) != ret.ok) return 0xDEAD; + return std.mem.readInt(u32, &buf, .little); + } + }; + var f = io.async(Consumer.run, .{ q, io }); + try io.sleep(.fromMilliseconds(20), clock); + + var word: [4]u8 = undefined; + std.mem.writeInt(u32, &word, 0xC0FFEE, .little); + try testing.expectEqual(ret.ok, q.send(io, &word, .forever)); + try testing.expectEqual(@as(u32, 0xC0FFEE), f.await(io)); + + // reset drops buffered records. + try testing.expectEqual(ret.ok, q.send(io, &word, .immediate)); + try testing.expectEqual(ret.ok, q.send(io, &word, .immediate)); + try testing.expectEqual(@as(c_int, 2), q.waiting(io)); + _ = q.reset(io); + try testing.expectEqual(@as(c_int, 0), q.waiting(io)); +} + +test "Mutex: the three timeout dialects" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + + var m: Mutex = .{}; + try testing.expectEqual(ret.ok, m.lock(io, .forever)); + // Held: a non-blocking lock fails rather than deadlocking. + try testing.expectEqual(ret.fail, m.lock(io, .immediate)); + const before = nowMs(io); + try testing.expectEqual(ret.fail, m.lock(io, .{ .bounded_ms = 25 })); + try testing.expect(nowMs(io) - before >= 25); + try testing.expectEqual(ret.ok, m.unlock(io)); + try testing.expectEqual(ret.ok, m.lock(io, .immediate)); + try testing.expectEqual(ret.ok, m.unlock(io)); +} + +test "Wait: the two timeout dialects ESP-Hosted uses" { + // _h_lock_mutex and _h_get_semaphore: positive means milliseconds. + try testing.expectEqual(Wait.immediate, Wait.fromMillis(0)); + try testing.expectEqual(Wait.forever, Wait.fromMillis(-1)); + // HOSTED_BLOCK_MAX is portMAX_DELAY, 0xFFFFFFFF, which reaches an `int` parameter as -1. + try testing.expectEqual(Wait.forever, Wait.fromMillis(@bitCast(@as(u32, 0xFFFF_FFFF)))); + try testing.expectEqual(Wait{ .bounded_ms = 5000 }, Wait.fromMillis(5000)); + + // _h_dequeue_item: positive means seconds. port_esp_hosted_host_os.c:336. + try testing.expectEqual(Wait{ .bounded_ms = 5000 }, Wait.fromQueueTimeout(5)); + try testing.expectEqual(Wait.forever, Wait.fromQueueTimeout(-1)); +} + +test "TimerService: one-shot fires once, periodic repeats, stop cancels" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + const gpa = testing.allocator; + + const Counter = struct { + var oneshot: u32 = 0; + var periodic: u32 = 0; + fn bumpOneshot(_: ?*anyopaque) callconv(.c) void { + oneshot += 1; + } + fn bumpPeriodic(_: ?*anyopaque) callconv(.c) void { + periodic += 1; + } + }; + Counter.oneshot = 0; + Counter.periodic = 0; + + var svc: TimerService(4) = .{}; + try testing.expect(svc.start(io, gpa)); + defer svc.stop(io, gpa); + + _ = svc.arm(io, 10, .oneshot, Counter.bumpOneshot, null).?; + const p = svc.arm(io, 10, .periodic, Counter.bumpPeriodic, null).?; + + try io.sleep(.fromMilliseconds(120), clock); + try testing.expectEqual(@as(u32, 1), Counter.oneshot); + try testing.expect(Counter.periodic >= 3); + + // Disarming stops it; the count must not move afterwards. + try testing.expectEqual(ret.ok, svc.disarm(io, p)); + const frozen = Counter.periodic; + try io.sleep(.fromMilliseconds(60), clock); + try testing.expectEqual(frozen, Counter.periodic); + // Disarming an already-disarmed slot reports failure, as esp_timer_stop does. + try testing.expectEqual(ret.fail, svc.disarm(io, p)); +} + +test "TimerService: slot exhaustion is reported, not fatal" { + var h = hostIo(); + defer { + h.threaded.deinit(); + testing.allocator.destroy(h.threaded); + } + const io = h.io; + + const Nop = struct { + fn f(_: ?*anyopaque) callconv(.c) void {} + }; + var svc: TimerService(2) = .{}; + _ = svc.arm(io, 10_000, .oneshot, Nop.f, null).?; + _ = svc.arm(io, 10_000, .oneshot, Nop.f, null).?; + try testing.expectEqual(@as(?usize, null), svc.arm(io, 10_000, .oneshot, Nop.f, null)); +} diff --git a/src/net/ip.zig b/src/net/ip.zig new file mode 100644 index 0000000..2cc4301 --- /dev/null +++ b/src/net/ip.zig @@ -0,0 +1,2903 @@ +//! A minimal IPv4 stack: Ethernet, ARP, IPv4, ICMP echo, UDP, a DHCP client, one TCP client and +//! HTTP GET. This is what replaces lwIP. +//! +//! Two functions drive everything and nothing else touches the outside world: +//! +//! stack.onFrame(frame) a received Ethernet frame, headers and all +//! stack.tick(now_ms) time passing, in milliseconds, from anywhere the caller likes +//! +//! and one callback carries frames out (`send`, supplied to `init`). There is no `std.Io`, no +//! allocator, no clock read and no hidden thread. That is not minimalism for its own sake: it is +//! what makes the whole stack testable on the host, where a "network" is a test function that hands +//! `onFrame` bytes it wrote by hand and reads back whatever `send` was given. Every protocol +//! behaviour in this file is exercised that way in `ip_test.zig`, including retransmission - which +//! on a real timer would be a flaky test and here is two calls to `tick`. +//! +//! **Everything is statically sized.** `Stack` is one struct with fixed buffers inside it; there is +//! no allocator, not even a `FixedBufferAllocator`, because nothing here has a lifetime that an +//! arena would model better than a field does. `@sizeOf(Stack)` is asserted at compile time below +//! (`footprint`) so the number cannot drift silently against the ~128 KB of L2MEM the image has. +//! +//! Wire formats are matched field by field against the lwIP this replaces, and every one is cited: +//! ESP-IDF v6.0.2 carries lwIP at `components/lwip/lwip/src/`, and the packed structs in +//! `include/lwip/prot/*.h` are the reference for offsets, and its `.c` files for behaviour. Where +//! this stack deliberately differs from lwIP, the comment says so and why. +//! +//! ## What this is not +//! +//! * No IPv6, no TCP listen/accept, no IP fragmentation or reassembly, no TLS. Out of scope. +//! * No congestion control. TCP sends at most one unacknowledged segment at a time (see `Tcp`), +//! which is a fixed window of one and therefore needs no congestion window, no slow start and +//! no fast recovery. It is also slow. For an HTTP GET of a few kilobytes over Wi-Fi that is the +//! right trade; for bulk transfer it is not, and nothing here pretends otherwise. +//! * No VLAN tags, no 802.1Q. A tagged frame is dropped as an unknown ethertype. +//! * No transfer coding but `identity` and `chunked`. Anything else - `gzip`, `deflate`, a +//! stack of them - is rejected with `error.UnsupportedTransferEncoding` rather than handed +//! back with its framing bytes still in it. +//! * DNS resolves A records only, one query at a time, over the UDP already here, with no +//! cache. `resolve` follows `httpGet`'s protocol exactly: start, `error.WouldBlock`, the +//! caller drives `tick`/`onFrame`, call again with the same name. + +const std = @import("std"); +const assert = std.debug.assert; + +// =============================================================================== sizing +// +// The whole static footprint, in one place. Every buffer in `Stack` is one of these. + +/// Ethernet MTU: the largest IP datagram that fits in one frame. +pub const mtu: usize = 1500; +/// Ethernet header: 6 destination + 6 source + 2 ethertype. lwIP `prot/ethernet.h:89` +/// (`SIZEOF_ETH_HDR`, with its optional `ETH_PAD_SIZE` at zero). +pub const eth_hlen: usize = 14; +/// The largest frame this stack will build or accept, excluding the FCS the MAC appends. +pub const frame_max: usize = eth_hlen + mtu; + +/// ARP cache entries. Four is enough for the gateway, one peer, and two strangers, which is the +/// whole population a single-connection HTTP client on a home /24 ever needs to address. +pub const arp_cache_len: usize = 4; + +/// Bytes of HTTP response head (status line plus headers) that may be buffered while waiting for +/// the blank line. Exceeding this fails the request rather than truncating silently. +/// +/// 2048, raised from 1024 against a measurement rather than a guess. A real response from the site +/// this stack was pointed at - Cloudflare in front of GitHub Pages - carries **1043 bytes** of head: +/// 26 header lines, of which `Report-To` alone is 254 bytes and `Nel`, `X-Fastly-Request-ID`, +/// `X-GitHub-Request-Id` and `alt-svc` are another 200 between them. At 1024 the request failed with +/// `HttpHeadersTooLong` after the body had already been negotiated, 19 bytes short. +/// +/// Modern CDN responses simply have large heads, and 1 KB is not a realistic ceiling for one. 2 KB +/// leaves about a kilobyte of margin over the measured case; the failure remains a named error +/// rather than truncation, so a head that exceeds even this is still diagnosable rather than silently +/// wrong. +pub const http_head_max: usize = 2048; + +/// Bytes of chunked *framing* - one chunk's extension parameters, or the whole trailer section - +/// tolerated before the response is failed. Framing is skipped rather than stored, so this bounds +/// work and not memory: without it a peer that streams `;a=b` forever, or trailer lines forever, +/// is a request that never ends and never errors. 512 is generous; a real trailer section is one +/// or two short lines. +pub const http_framing_max: usize = 512; + +/// The longest host name `resolve` will encode into a DNS question, in dotted text. RFC 1035 2.3.4 +/// allows 255; this stack holds the encoded question in `Stack` for the duration of the query, and +/// 64 covers every name a device that fetches one URL will ever ask for. A longer one is +/// `error.NameTooLong`, never a silently truncated question. +pub const dns_name_max: usize = 64; + +/// The encoded question that `dns_name_max` produces. Encoding turns `a.b` into `1a1b0`: one +/// length byte per label plus the root label, which for a name with no trailing dot is exactly +/// two bytes more than the text. RFC 1035 4.1.2. +pub const dns_qname_max: usize = dns_name_max + 2; + +/// Bytes of outbound TCP payload held for retransmission. This is sized for one HTTP request line +/// plus headers; there is no streaming send, so it is also the hard limit on request size. +pub const tcp_tx_max: usize = 512; + +/// The receive window this stack advertises, in bytes, when it has that much room to consume into. +/// One MSS: a peer that fills the window gets a segment acknowledged before it may send another. +pub const tcp_window: u16 = 1460; + +/// TCP MSS offered in the SYN. 1500 - 20 (IP) - 20 (TCP). +pub const tcp_mss: u16 = 1460; + +/// RFC 1122 4.2.2.6: a peer that sends no MSS option is assumed to accept 536. +pub const tcp_default_mss: u16 = 536; + +/// Initial retransmission timeout. RFC 6298 2.1 specifies 1 s for a connection with no RTT sample, +/// and this stack never takes an RTT sample - see `Tcp.rto_ms`. +pub const tcp_rto_initial_ms: u32 = 1000; +/// Retransmission timeout ceiling. RFC 6298 5.7 allows any value at or above 60 s; 16 s is chosen +/// against a device whose whole reason to exist is one short request. +pub const tcp_rto_max_ms: u32 = 16_000; +/// Retransmissions of the same segment before the connection is abandoned with `error.TimedOut`. +/// With the backoff above that is 1+2+4+8+16+16 = 47 s of trying. +pub const tcp_max_retries: u8 = 6; +/// TIME_WAIT duration. RFC 793 says 2*MSL, conventionally 240 s. Two seconds is what this uses: +/// holding a connection block for four minutes on a part with 128 KB of RAM to protect a +/// port number that this stack increments on every connect is the wrong trade. The risk it drops is +/// a late duplicate segment from the *previous* incarnation of the same 4-tuple being accepted into +/// a new one, and incrementing the local port already makes a repeat 4-tuple require 16,384 +/// connections first. +pub const tcp_time_wait_ms: u32 = 2000; +/// How long a half-closed connection waits for the peer's FIN before the block is released. RFC +/// 793 has no such timer and a connection may legitimately sit in FIN-WAIT-2 forever; Linux uses +/// 60 s for the same reason this uses 10 s - a peer that has our FIN and never answers is a peer +/// that is gone, and the one connection block here is not worth holding for it. +pub const tcp_fin_wait2_ms: u32 = 10_000; + +/// DHCP retransmission backoff, in milliseconds, indexed by attempt. RFC 2131 4.1 asks for +/// randomised exponential backoff starting at 4 s; this starts at 2 s because the first DHCP +/// exchange is on the critical path of every boot, and does not randomise because there is one +/// client on this board and the collision RFC 2131 is avoiding is between many. +const dhcp_backoff_ms = [_]u32{ 2_000, 4_000, 8_000, 16_000, 32_000, 64_000 }; + +/// Minimum length of the BOOTP/DHCP message this stack transmits, in UDP payload bytes. RFC 951 +/// fixed BOOTP messages at 300 bytes and relay agents in the field still expect at least that +/// much; lwIP pads the same way through its fixed-size `struct dhcp_msg` +/// (`prot/dhcp.h:63-91`: 236 + 4 cookie + `DHCP_OPTIONS_LEN` 68 = 308). +const dhcp_min_msg_len: usize = 300; + +/// DNS retransmission backoff, in milliseconds, indexed by attempt. RFC 1035 4.2.1 leaves the +/// timer to the implementation; this is BIND's classic 1 s doubling, and the array length is the +/// try count, so the whole exchange is bounded at 1+2+4 = 7 s and then `error.TimedOut`. The +/// transaction id is *not* redrawn between tries: a slow first answer must still be accepted. +const dns_backoff_ms = [_]u32{ 1_000, 2_000, 4_000 }; + +/// Compression pointers followed while skipping one name (RFC 1035 4.1.4). This is the bound that +/// makes a hostile message terminate: see `dnsSkipName`, where the argument is written out. +const dns_max_jumps: u8 = 16; + +// =============================================================== addresses and enumerations + +pub const Mac = [6]u8; +pub const Ip4 = [4]u8; + +pub const mac_broadcast: Mac = @splat(0xff); +pub const ip_any: Ip4 = @splat(0x00); +pub const ip_broadcast: Ip4 = @splat(0xff); + +/// Ethernet type field values. lwIP `prot/ieee.h:52-85` (`enum lwip_ieee_eth_type`). +pub const EtherType = enum(u16) { + ip4 = 0x0800, + arp = 0x0806, + vlan = 0x8100, + ip6 = 0x86dd, + _, +}; + +/// IP header protocol numbers. lwIP `prot/ip.h:46-50`. +pub const Protocol = enum(u8) { + icmp = 1, + tcp = 6, + udp = 17, + _, +}; + +// =============================================================================== checksum +// +// One implementation for IPv4, ICMP, UDP and TCP. The last two prepend a pseudo-header, which is +// the only difference between them: the arithmetic is identical, so it is written once. + +/// The Internet checksum of RFC 1071: the one's complement of the one's complement sum of the +/// data taken as 16-bit big-endian words, with a zero byte appended if the length is odd. +/// +/// Incremental, because TCP and UDP checksum a pseudo-header, a header and a payload that are +/// three separate buffers and never adjacent in memory. Feeding them in sequence must give the +/// same answer as checksumming the concatenation, which is why `half` exists: a chunk of odd +/// length leaves the high byte of a word owed, and the next chunk's first byte completes it. +/// Getting that wrong is invisible until a payload happens to have odd length, which for HTTP is +/// most of the time. +pub const Checksum = struct { + /// Accumulated 16-bit words. Deferring the end-around carry is safe for any length this + /// stack can produce: 32 bits absorbs 65,536 words, and the largest thing checksummed here is + /// 1,500 bytes. + sum: u32 = 0, + /// High byte of a 16-bit word whose low byte has not arrived yet. + half: ?u8 = null, + + pub fn update(self: *Checksum, bytes: []const u8) void { + var b = bytes; + if (self.half) |hi| { + if (b.len == 0) return; + self.sum += (@as(u32, hi) << 8) | b[0]; + self.half = null; + b = b[1..]; + } + var i: usize = 0; + while (i + 1 < b.len) : (i += 2) self.sum += std.mem.readInt(u16, b[i..][0..2], .big); + if (i < b.len) self.half = b[i]; + } + + /// Feed a big-endian 16-bit value, for the pseudo-header fields that are not in any buffer. + pub fn update16(self: *Checksum, v: u16) void { + var tmp: [2]u8 = undefined; + std.mem.writeInt(u16, &tmp, v, .big); + self.update(&tmp); + } + + /// The checksum as it goes on the wire. RFC 1071 1: an odd-length buffer is padded with a + /// zero byte, which the fold below does implicitly by shifting the owed byte up. + pub fn final(self: Checksum) u16 { + var s = self.sum; + if (self.half) |hi| s += @as(u32, hi) << 8; + while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16); + return ~@as(u16, @truncate(s)); + } +}; + +/// The Internet checksum of one contiguous buffer. +pub fn checksum(bytes: []const u8) u16 { + var c: Checksum = .{}; + c.update(bytes); + return c.final(); +} + +/// The TCP/UDP pseudo-header of RFC 793 3.1: source address, destination address, a zero byte, the +/// protocol number and the transport length. Not transmitted; only checksummed. +fn pseudoHeader(c: *Checksum, src: Ip4, dst: Ip4, proto: Protocol, len: u16) void { + c.update(&src); + c.update(&dst); + c.update16(@intFromEnum(proto)); // the zero byte and the protocol byte, as one word + c.update16(len); +} + +/// A checksum for a UDP or TCP segment: pseudo-header, then the segment with its own checksum +/// field already zeroed. +fn transportChecksum(src: Ip4, dst: Ip4, proto: Protocol, segment: []const u8) u16 { + var c: Checksum = .{}; + pseudoHeader(&c, src, dst, proto, @intCast(segment.len)); + c.update(segment); + return c.final(); +} + +/// Verify a received transport checksum. A UDP datagram may carry zero, meaning "not computed" +/// (RFC 768); TCP may not. +fn transportChecksumOk(src: Ip4, dst: Ip4, proto: Protocol, segment: []const u8, field: u16) bool { + if (proto == .udp and field == 0) return true; + // Summing a segment that already contains its own checksum yields 0 (or, equivalently, the + // sum before complementing is 0xffff). RFC 1071 1. + return transportChecksum(src, dst, proto, segment) == 0; +} + +/// A transmitted UDP checksum of zero would be read as "not computed", so RFC 768 requires it be +/// sent as the equivalent 0xffff instead. TCP has no such rule and no such ambiguity. +pub fn udpChecksumOnWire(c: u16) u16 { + return if (c == 0) 0xffff else c; +} + +// ============================================================== unaligned big-endian access +// +// `std.mem.readInt`/`writeInt` with an explicit endianness at every single field. Never a shift and +// an or: a network header written by hand is where byte order goes wrong, and it goes wrong +// silently, on one field, in a way that looks like a hardware problem. + +inline fn rd16(b: []const u8, off: usize) u16 { + return std.mem.readInt(u16, b[off..][0..2], .big); +} +inline fn rd32(b: []const u8, off: usize) u32 { + return std.mem.readInt(u32, b[off..][0..4], .big); +} +inline fn wr16(b: []u8, off: usize, v: u16) void { + std.mem.writeInt(u16, b[off..][0..2], v, .big); +} +inline fn wr32(b: []u8, off: usize, v: u32) void { + std.mem.writeInt(u32, b[off..][0..4], v, .big); +} +inline fn rdIp(b: []const u8, off: usize) Ip4 { + return b[off..][0..4].*; +} +inline fn wrIp(b: []u8, off: usize, v: Ip4) void { + b[off..][0..4].* = v; +} +inline fn rdMac(b: []const u8, off: usize) Mac { + return b[off..][0..6].*; +} +inline fn wrMac(b: []u8, off: usize, v: Mac) void { + b[off..][0..6].* = v; +} + +// ============================================================================ header offsets +// +// Byte offsets rather than packed structs. `extern struct` would need `align(1)` on every field and +// a byte-swap on every access on this little-endian part, and the offsets are what the RFCs and +// lwIP's headers actually state, so this is the form that can be checked against them by eye. + +/// lwIP `prot/ethernet.h:76-83` (`struct eth_hdr`). +const eth = struct { + const dst = 0; + const src = 6; + const ethertype = 12; +}; + +/// lwIP `prot/etharp.h:86-96` (`struct etharp_hdr`), `SIZEOF_ETHARP_HDR` 28 at `:102`. +const arp = struct { + const hwtype = 0; + const proto = 2; + const hwlen = 4; + const protolen = 5; + const opcode = 6; + const sha = 8; // sender hardware address + const spa = 14; // sender protocol address + const tha = 18; // target hardware address + const tpa = 24; // target protocol address + const len = 28; + + /// lwIP `prot/iana.h:54` (`LWIP_IANA_HWTYPE_ETHERNET`). + const hwtype_ethernet: u16 = 1; + /// lwIP `prot/etharp.h:105-108` (`enum etharp_opcode`). + const op_request: u16 = 1; + const op_reply: u16 = 2; +}; + +/// lwIP `prot/ip4.h:73-97` (`struct ip_hdr`), `IP_HLEN` 20 at `:64`. +const ip4 = struct { + const v_hl = 0; + const tos = 1; + const total_len = 2; + const id = 4; + const frag = 6; + const ttl = 8; + const proto = 9; + const chksum = 10; + const src = 12; + const dst = 16; + const hlen = 20; + + /// lwIP `prot/ip4.h:84-87`. + const flag_df: u16 = 0x4000; + const flag_mf: u16 = 0x2000; + const offset_mask: u16 = 0x1fff; +}; + +/// lwIP `prot/icmp.h:89-95` (`struct icmp_echo_hdr`). +const icmp = struct { + const type_ = 0; + const code = 1; + const chksum = 2; + const id = 4; + const seq = 6; + const hlen = 8; + + /// lwIP `prot/icmp.h:46,50`. + const echo_reply: u8 = 0; + const echo_request: u8 = 8; +}; + +/// lwIP `prot/udp.h:53-58` (`struct udp_hdr`), `UDP_HLEN` 8 at `:46`. +const udp = struct { + const src_port = 0; + const dst_port = 2; + const len = 4; + const chksum = 6; + const hlen = 8; +}; + +/// lwIP `prot/tcp.h:56-65` (`struct tcp_hdr`), `TCP_HLEN` 20 at `:47`. +const tcp = struct { + const src_port = 0; + const dst_port = 2; + const seq = 4; + const ack = 8; + /// Top four bits are the header length in 32-bit words; the low six are the flags. + /// lwIP `prot/tcp.h:85-87`. + const hdrlen_flags = 12; + const window = 14; + const chksum = 16; + const urgent = 18; + const hlen = 20; + + /// lwIP `prot/tcp.h:72-81`. + const fin: u8 = 0x01; + const syn: u8 = 0x02; + const rst: u8 = 0x04; + const psh: u8 = 0x08; + const ack_f: u8 = 0x10; + const urg: u8 = 0x20; + + /// RFC 793 3.1: kind 2, length 4, then the 16-bit MSS. + const opt_end: u8 = 0; + const opt_nop: u8 = 1; + const opt_mss: u8 = 2; +}; + +/// lwIP `prot/dhcp.h:50-91` (`struct dhcp_msg`) and `:51-56` for the offsets named there. +const dhcp = struct { + const op = 0; + const htype = 1; + const hlen = 2; + const hops = 3; + const xid = 4; + const secs = 8; + const flags = 10; + const ciaddr = 12; + const yiaddr = 16; + const siaddr = 20; + const giaddr = 24; + const chaddr = 28; + const sname = 44; // DHCP_SNAME_OFS + const file = 108; // DHCP_FILE_OFS + const cookie = 236; // DHCP_MSG_LEN + const options = 240; // DHCP_OPTIONS_OFS = DHCP_MSG_LEN + 4 + + /// lwIP `prot/dhcp.h:116-117`. + const bootrequest: u8 = 1; + const bootreply: u8 = 2; + /// lwIP `prot/dhcp.h:120-127`. + const discover: u8 = 1; + const offer: u8 = 2; + const request: u8 = 3; + const ack: u8 = 5; + const nak: u8 = 6; + /// lwIP `prot/dhcp.h:129`. + const magic_cookie: u32 = 0x63825363; + /// RFC 2131 figure 2: the top bit of `flags` asks the server to broadcast its reply. + const flag_broadcast: u16 = 0x8000; + + /// lwIP `prot/dhcp.h:134-165`. Only the ones this client uses. + const opt_pad: u8 = 0; + const opt_subnet_mask: u8 = 1; + const opt_router: u8 = 3; + const opt_dns: u8 = 6; + const opt_hostname: u8 = 12; + const opt_requested_ip: u8 = 50; + const opt_lease_time: u8 = 51; + const opt_overload: u8 = 52; + const opt_msg_type: u8 = 53; + const opt_server_id: u8 = 54; + const opt_param_list: u8 = 55; + const opt_max_msg_size: u8 = 57; + const opt_t1: u8 = 58; + const opt_t2: u8 = 59; + const opt_end: u8 = 255; + + /// lwIP `prot/iana.h:66-68`. + const server_port: u16 = 67; + const client_port: u16 = 68; +}; + +/// RFC 1035 4.1. There is no lwIP reference for this one: lwIP's resolver is `core/dns.c`, which +/// builds the same header out of its own `struct dns_hdr` (`core/dns.c:180-190`) - the offsets +/// below are the RFC's, and `dns.c` is only a cross-check. +const dns = struct { + // 4.1.1 header, six 16-bit fields. + const id = 0; + const flags = 2; + const qdcount = 4; + const ancount = 6; + const nscount = 8; + const arcount = 10; + const hlen = 12; + + /// 4.1.1: QR is the top bit of `flags`, RD is bit 8, RCODE the bottom four bits. + const flag_qr: u16 = 0x8000; + const flag_rd: u16 = 0x0100; + const rcode_mask: u16 = 0x000f; + /// RCODE 3, "name error": the name authoritatively does not exist. RFC 1035 4.1.1. + const rcode_name_error: u16 = 3; + + /// 4.1.4: the two top bits of a length byte set means the rest is a 14-bit offset. + const ptr_mask: u8 = 0xc0; + /// 2.3.4: a label is at most 63 bytes, which is also why 0x40 and 0x80 are free to be flags. + const label_max: u8 = 63; + + /// 3.2.2 TYPE and 3.2.4 CLASS. Only the two this stack looks at, plus CNAME, which is not + /// followed but must be stepped over: a name behind a CNAME chain answers with the chain and + /// the A record together, and a resolver that stops at the first record finds the CNAME. + const type_a: u16 = 1; + const type_cname: u16 = 5; + const class_in: u16 = 1; + + /// 3.2.1: TYPE(2) CLASS(2) TTL(4) RDLENGTH(2) after the name. + const rr_fixed = 10; + + /// lwIP `prot/iana.h:64` (`LWIP_IANA_PORT_DNS`). + const port: u16 = 53; +}; + +// ============================================================================== ARP cache + +const ArpEntry = struct { + ip: Ip4 = ip_any, + mac: Mac = @splat(0), + /// `tick`'s clock at the last hit or update. Zero means the entry is empty. + stamp_ms: u64 = 0, + + inline fn valid(self: ArpEntry) bool { + return self.stamp_ms != 0; + } +}; + +/// Entries older than this are treated as absent and re-resolved. lwIP's default is 300 s +/// (`ARP_TMR_INTERVAL` 1000 ms x `ARP_MAXAGE` 300, `core/ipv4/etharp.c`); the same here. +const arp_max_age_ms: u64 = 300_000; +/// How often an unanswered ARP request is repeated while `httpGet` waits for a MAC address. +const arp_retry_ms: u64 = 1000; +/// ARP requests sent for one destination before `httpGet` gives up with `error.HostUnreachable`. +const arp_max_tries: u8 = 5; + +// ================================================================================ DHCP state + +pub const DhcpState = enum { + /// `dhcpStart` has not been called, or `setStatic` has taken over. + off, + /// DISCOVER sent, waiting for an OFFER. + selecting, + /// REQUEST sent, waiting for an ACK. + requesting, + /// Bound, lease held, T1 not yet reached. + bound, + /// Past T1: unicast REQUEST to the server that granted the lease. + renewing, + /// Past T2: broadcast REQUEST to any server. + rebinding, +}; + +const Dhcp = struct { + state: DhcpState = .off, + xid: u32 = 0, + /// The address the server offered, held between OFFER and ACK. + offered: Ip4 = ip_any, + /// Option 54 from the OFFER, echoed in the REQUEST and unicast to when renewing. + server: Ip4 = ip_any, + /// Option 51, seconds. `0xffff_ffff` is an infinite lease (RFC 2131 3.3). + lease_s: u32 = 0, + /// Absolute deadlines derived from the lease at bind time, in `tick`'s milliseconds. + t1_ms: u64 = 0, + t2_ms: u64 = 0, + expire_ms: u64 = 0, + /// When the next DISCOVER/REQUEST retransmission is due, and how many have gone out. + retry_ms: u64 = 0, + tries: u8 = 0, + /// `tick`'s clock when acquisition began, for the `secs` field. + started_ms: u64 = 0, +}; + +// ================================================================================ TCP state + +pub const TcpState = enum { + closed, + /// Waiting for the peer's MAC address before the SYN can be built. + arp_wait, + syn_sent, + established, + /// Our FIN is sent; the peer has not FINed. + fin_wait_1, + fin_wait_2, + /// The peer FINed first and we have replied with our own FIN. + last_ack, + time_wait, +}; + +const Tcp = struct { + state: TcpState = .closed, + + peer_ip: Ip4 = ip_any, + peer_port: u16 = 0, + local_port: u16 = 0, + + /// Initial send sequence number. The SYN occupies `iss`; request data occupies + /// `iss+1 .. iss+1+tx_len`; a FIN occupies `iss+1+tx_len`. Every offset in this struct is + /// derived from that one layout, which is why there is no separate "unacked offset". + iss: u32 = 0, + /// Oldest sequence number not yet acknowledged by the peer. + snd_una: u32 = 0, + /// Next sequence number to send. + snd_nxt: u32 = 0, + /// The peer's advertised window. + snd_wnd: u32 = 0, + /// The peer's MSS, from its SYN's option or RFC 1122's default. + snd_mss: u16 = tcp_default_mss, + /// Set once a FIN has been queued behind the request data. + fin_queued: bool = false, + /// Set once the peer's FIN has been received in order. Receiving it does not by itself move + /// `state`, so that `tcpSendData` stays the only thing that changes it. + peer_fin: bool = false, + + /// Next sequence number expected from the peer. + rcv_nxt: u32 = 0, + + /// Retransmission deadline in `tick`'s milliseconds, and the current timeout. `rto_ms` doubles + /// on every retransmission and is never reduced by an RTT measurement, because this stack + /// takes none: with a single segment in flight and a fixed backoff there is nothing an RTT + /// estimator would change except the first timeout, and 1 s is already RFC 6298's answer for + /// that case. + rto_deadline_ms: u64 = 0, + rto_ms: u32 = tcp_rto_initial_ms, + retries: u8 = 0, + /// When TIME_WAIT ends. + close_deadline_ms: u64 = 0, + + /// The request bytes, held for retransmission until acknowledged. + tx: [tcp_tx_max]u8 = undefined, + tx_len: usize = 0, + + /// Sequence number of the first byte of `tx`. + inline fn dataStart(self: Tcp) u32 { + return self.iss +% 1; + } + /// Sequence number one past the last byte of `tx`. + inline fn dataEnd(self: Tcp) u32 { + return self.iss +% 1 +% @as(u32, @intCast(self.tx_len)); + } +}; + +/// Sequence-number comparison. TCP sequence numbers wrap, so they are compared by the sign of the +/// difference and never by `<`. RFC 1982 serial arithmetic; the classic bug this avoids is a +/// connection that stalls forever once the sequence space crosses 2^32. +inline fn seqLt(a: u32, b: u32) bool { + return @as(i32, @bitCast(a -% b)) < 0; +} +inline fn seqLe(a: u32, b: u32) bool { + return @as(i32, @bitCast(a -% b)) <= 0; +} +inline fn seqGt(a: u32, b: u32) bool { + return seqLt(b, a); +} +inline fn seqGe(a: u32, b: u32) bool { + return seqLe(b, a); +} + +// =============================================================================== HTTP state + +pub const HttpError = error{ + /// The request is in flight. Call `tick`, feed frames to `onFrame`, and call `httpGet` again + /// with the same arguments. This is the only "error" a healthy request returns. + WouldBlock, + /// `httpGet` was called with different arguments while a request was in flight. + Busy, + /// The peer's MAC address could not be resolved. + HostUnreachable, + /// The peer sent RST. + ConnectionReset, + /// The peer FINed or vanished before the body was complete. + ConnectionClosed, + /// Retransmissions exhausted. + TimedOut, + /// The status line, the headers, or a chunked body's trailer section exceeded its budget + /// (`http_head_max`, `http_framing_max`). + HttpHeadersTooLong, + /// The status line was not `HTTP/1.x SSS`. + HttpMalformed, + /// A chunked body's framing was not RFC 7230 4.1: a size with no hex digits, a size that + /// overflows `usize`, or a CRLF that was not where the grammar puts it. Distinct from + /// `HttpMalformed` because the two point at different halves of the response, and on a board + /// with one UART the error name is the whole diagnosis. + HttpChunkMalformed, + /// `Transfer-Encoding` was present and was neither `identity` nor `chunked`. + UnsupportedTransferEncoding, + /// The body did not fit in the caller's `out` buffer. + StreamTooLong, + /// The request line and headers did not fit in `tcp_tx_max`, or `path` is unusable. + RequestTooLong, + /// `httpGet` was called before the stack had an address. + NoAddress, +}; + +const HttpPhase = enum { idle, head, body, complete, failed }; + +const Http = struct { + phase: HttpPhase = .idle, + /// Valid when `phase == .failed`. + err: HttpError = error.WouldBlock, + + /// The caller's output buffer, borrowed for the duration of the request. Recorded rather than + /// copied, so the caller must not move or resize it between `httpGet` calls; the identity check + /// in `httpGet` catches the common way of getting that wrong. + out: []u8 = &.{}, + out_len: usize = 0, + + /// The request being served, kept so a re-entrant `httpGet` can be told apart from a new one. + /// The hash covers the path *and* the `Host:` name, which is what makes two requests to the + /// same address for the same path but different virtual hosts distinguishable - and they must + /// be, or the second silently rides on the first's connection. A hash rather than the strings + /// themselves because `Stack` has a 4 KiB budget and the strings are the caller's, alive for + /// the duration of the call only. + req_host: Ip4 = ip_any, + req_port: u16 = 0, + req_hash: u64 = 0, + + /// Status line and headers, accumulated until the blank line. + head: [http_head_max]u8 = undefined, + head_len: usize = 0, + + status: u16 = 0, + /// `null` means the response had no usable `Content-Length`, so the body ends at the peer's + /// FIN - or, when `chunked`, at the zero-length chunk. + content_length: ?usize = null, + + // ------------------------------------------------------- RFC 7230 4.1 chunked decoding + // + // Four fields hold the whole position in the chunked grammar, because a segment boundary may + // fall between any two bytes of it and the decoder has to resume from exactly here. + + /// `Transfer-Encoding: chunked` was in force on this response. + chunked: bool = false, + chunk: ChunkState = .size, + /// In `.size`, the hexadecimal size accumulated so far; in `.data`, the bytes of this chunk + /// still to come. The two are the same number, which is why one field serves both: the size + /// read is the count remaining the instant the header ends. + chunk_left: usize = 0, + /// At least one hex digit has been seen in the size being read. RFC 7230 4.1 is `1*HEXDIG`, + /// so an empty size is malformed - and without this flag a stray CRLF reads as a chunk of + /// length zero, which is the terminator, which ends the body early and looks like success. + chunk_digit: bool = false, + /// Framing bytes consumed in the extension or trailer section now being skipped, against + /// `http_framing_max`. + chunk_skip: u16 = 0, +}; + +/// Where the chunked decoder is in RFC 7230 4.1's grammar: +/// +/// chunked-body = *chunk last-chunk trailer-part CRLF +/// chunk = chunk-size [ chunk-ext ] CRLF chunk-data CRLF +/// last-chunk = 1*("0") [ chunk-ext ] CRLF +/// +/// Every terminal in that grammar that can be split by a segment boundary is a state, including +/// the two halves of each CRLF. That is not pedantry: a 1,460-byte segment ends wherever the +/// server's writes happen to end, and "the CR arrived and the LF did not" is a case that happens. +const ChunkState = enum { + /// Reading hex digits of `chunk-size`. + size, + /// A `;` was seen: skipping `chunk-ext` to the CR that ends the header. + ext, + /// The CR of the chunk header is in; its LF must follow. + size_lf, + /// Copying `chunk_left` more bytes of `chunk-data` into the caller's `out`. + data, + /// The data is in; the CR of the CRLF that closes the chunk must follow. + data_cr, + /// ...and its LF. + data_lf, + /// At the first byte of a trailer line - or of the CRLF that ends the whole body. + trailer, + /// Inside a trailer line, skipping to its CR. + trailer_line, + /// The LF of a trailer line. + trailer_lf, + /// The LF of the final empty line. The response is complete after it, and not before. + end_lf, +}; + +// ================================================================================ DNS state + +pub const DnsError = error{ + /// The query is in flight. Call `tick`, feed frames to `onFrame`, and call `resolve` again + /// with the same name. This is the only "error" a healthy query returns. + WouldBlock, + /// `resolve` was called with a different name while a query was in flight. One query is + /// outstanding at a time; the caller must finish or abandon the first. + Busy, + /// `resolve` was called before the stack had an address of its own to send from. + NoAddress, + /// No resolver: DHCP supplied none and `setDnsServer` was not called. + NoDnsServer, + /// The name was empty, had an empty label, or had a label over 63 bytes. RFC 1035 2.3.4. + NameInvalid, + /// The name was longer than `dns_name_max`. + NameTooLong, + /// `dns_backoff_ms.len` queries went out and nothing came back. + TimedOut, + /// The server said the name does not exist (RCODE 3), or answered with no A record in it - + /// a CNAME chain leading nowhere, or an AAAA-only name. Both mean the same thing to a stack + /// that speaks IPv4 only. + NameNotFound, + /// The server answered with a non-zero RCODE other than name-error: SERVFAIL, REFUSED. + DnsRefused, + /// A response that matched the id and the question could not be parsed: a name that runs off + /// the end, a compression pointer that goes forward or loops, an RDLENGTH past the message. + /// Responses that do *not* match the id and question are ignored rather than reported, so + /// this is the server or an attacker who already guessed both, never stray traffic. + DnsMalformed, +}; + +const DnsPhase = enum { idle, waiting, done, failed }; + +const DnsQuery = struct { + phase: DnsPhase = .idle, + /// Valid when `phase == .failed`. + err: DnsError = error.WouldBlock, + + /// The question, in RFC 1035 4.1.2 wire form, root label included. Held rather than + /// re-encoded because it is needed in three places: to build each retransmission from `tick`, + /// to compare against the question echoed in a response, and to tell a re-entrant `resolve` + /// from a new one. Comparing the encoded form is what makes the last two exact. + qname: [dns_qname_max]u8 = undefined, + qname_len: u8 = 0, + + /// The transaction id, held across retransmissions so a slow first answer still matches. + id: u16 = 0, + /// The ephemeral source port, redrawn per query. Together with `id` that is 32 bits an + /// off-path spoofer has to guess, which is the whole of what plain DNS offers. + local_port: u16 = 0, + + tries: u8 = 0, + retry_ms: u64 = 0, + + /// Valid when `phase == .done`. + result: Ip4 = ip_any, +}; + +// ================================================================================= counters +// +// Not statistics for their own sake: the first hardware bring-up of this stack will be a board that +// either answers a ping or does not, with no debugger and one UART. These are what turns "nothing +// happens" into "1,204 frames arrived, 1,204 were dropped, and the checksum counter is zero", which +// says the frames are not for us rather than that the checksum code is broken. + +pub const Counters = struct { + rx_frames: u32 = 0, + rx_dropped: u32 = 0, + tx_frames: u32 = 0, + tx_dropped: u32 = 0, + arp_rx: u32 = 0, + arp_tx: u32 = 0, + icmp_echo: u32 = 0, + udp_rx: u32 = 0, + dhcp_rx: u32 = 0, + dhcp_tx: u32 = 0, + tcp_rx: u32 = 0, + tcp_tx: u32 = 0, + tcp_retx: u32 = 0, + tcp_rst_rx: u32 = 0, + /// Queries sent, retransmissions among them, and responses that matched the outstanding + /// query's id and question. `dns_tx > dns_rx` with `dns_retx` climbing is a resolver that is + /// not answering; `dns_rx == 0` with `udp_rx` climbing is an answer arriving and being + /// rejected, which is a different bug in a different place. + dns_tx: u32 = 0, + dns_retx: u32 = 0, + dns_rx: u32 = 0, + /// Frames discarded because a checksum did not verify. A non-zero value here with a working + /// link means a bug in this file or a broken SDIO transfer, not a network problem. + checksum_bad: u32 = 0, +}; + +// ==================================================================================== Stack + +pub const Stack = struct { + // ------------------------------------------------------------------ identity and route + mac: Mac, + /// `null` until DHCP binds or `setStatic` is called. + addr: ?Ip4 = null, + mask: Ip4 = ip_any, + gw: Ip4 = ip_any, + /// The resolver, from DHCP option 6 or `setDnsServer`. `null` means nothing to ask. + dns: ?Ip4 = null, + + /// Where frames go. Called synchronously from `onFrame`, `tick` and `httpGet`; the slice is + /// borrowed for the duration of the call and must be copied if the transport needs it later. + /// + /// Note the absence of a context pointer: the interface this slice implements specifies + /// `*const fn ([]const u8) void`, so a callee needing state has to reach it some other way. + send: *const fn (frame: []const u8) void, + + // ------------------------------------------------------------------------------ clock + /// The last value handed to `tick`. `onFrame` needs a timestamp for the ARP cache and takes it + /// from here rather than reading a clock, which is what keeps this file free of any hardware + /// dependency at all. + now_ms: u64 = 0, + + // ------------------------------------------------------------------------------ state + arp_cache: [arp_cache_len]ArpEntry = @splat(.{}), + /// Pending ARP resolution for the TCP peer: deadline, tries. + arp_retry_ms: u64 = 0, + arp_tries: u8 = 0, + + dhcp: Dhcp = .{}, + tcp: Tcp = .{}, + http: Http = .{}, + /// The one outstanding DNS query. Named `query` and not `dns`, which is the server's address. + query: DnsQuery = .{}, + counters: Counters = .{}, + + /// IPv4 identification field. Incremented per datagram. Nothing here fragments, so this only + /// has to be non-constant for the benefit of middleboxes and packet captures. + ip_id: u16 = 0, + /// Mixed into transaction ids, initial sequence numbers and ephemeral ports. There is no + /// hardware RNG in this file's reach, so this is seeded from the MAC and stirred by every + /// `tick` value observed - which for the two uses here (not colliding with a previous + /// incarnation of the same connection, and not matching a stale DHCP reply) is sufficient. + /// It is emphatically *not* a source of security-relevant randomness. + entropy: u64, + + /// The single transmit staging buffer. Every frame this stack sends is built here and handed to + /// `send` before the next one starts, so one is enough - and `send` is documented as borrowing. + tx: [frame_max]u8 = undefined, + + /// The total static footprint of one `Stack`, asserted so the number in the report cannot rot. + pub const footprint = @sizeOf(Stack); + + // =========================================================================== lifecycle + + /// A single struct-literal return, deliberately: result-location semantics then construct the + /// buffers in the caller's storage instead of memcpy-ing several kilobytes off a stack that is + /// 8 KB by default on this target. + pub fn init(mac: [6]u8, send: *const fn (frame: []const u8) void) Stack { + return .{ + .mac = mac, + .send = send, + .entropy = std.hash.Wyhash.hash(0x4200_cafe, &mac), + }; + } + + /// Stir and draw. Not random; see `entropy`. + fn draw(self: *Stack) u32 { + self.entropy = self.entropy *% 6364136223846793005 +% 1442695040888963407; + return @truncate(self.entropy >> 32); + } + + // ============================================================================= address + + /// The configured address, or `null` if there is none yet. + pub fn ip(self: *Stack) ?[4]u8 { + return self.addr; + } + + pub fn netmask(self: *Stack) Ip4 { + return self.mask; + } + + pub fn gateway(self: *Stack) Ip4 { + return self.gw; + } + + /// The resolver `resolve` will ask: the first server DHCP offered (option 6), or whatever + /// `setDnsServer` last set. `null` means `resolve` will answer `error.NoDnsServer`. + pub fn dnsServer(self: *Stack) ?Ip4 { + return self.dns; + } + + /// Override the resolver. Only needed on a network whose DHCP server offers none, or when + /// configuring statically: the ordinary path is a lease that carries option 6, which + /// `dhcpBind` already stores, and a caller that does nothing gets that. + /// + /// Any query in flight is abandoned: it was addressed to the old server and its answer would + /// now be rejected as coming from the wrong source. + pub fn setDnsServer(self: *Stack, addr: Ip4) void { + self.dns = addr; + self.query.phase = .idle; + } + + pub fn dhcpState(self: *Stack) DhcpState { + return self.dhcp.state; + } + + pub fn tcpState(self: *Stack) TcpState { + return self.tcp.state; + } + + /// The status code of the last response whose head was parsed. Zero before that. + pub fn httpStatus(self: *Stack) u16 { + return self.http.status; + } + + /// Configure statically and stop any DHCP activity. This is the path the first hardware test + /// takes: it makes the board reachable without a working DHCP client, so an ARP or ping + /// failure means the SDIO transport or the association is wrong rather than this file. + pub fn setStatic(self: *Stack, addr: [4]u8, mask: [4]u8, gw: [4]u8) void { + self.dhcp = .{}; + self.addr = addr; + self.mask = mask; + self.gw = gw; + // Any query in flight was sent from the old address, so its answer is addressed to a + // station that no longer exists. `dns` itself is left alone: a resolver learnt from a + // previous lease is still the right one to ask on the same wire. + self.query.phase = .idle; + self.announce(); + } + + /// Is this address ours, or one everybody on the wire is meant to hear? + fn forUs(self: *Stack, dst: Ip4) bool { + if (std.mem.eql(u8, &dst, &ip_broadcast)) return true; + const a = self.addr orelse return false; + if (std.mem.eql(u8, &dst, &a)) return true; + // Subnet broadcast: host bits all ones. + var i: usize = 0; + while (i < 4) : (i += 1) { + if (dst[i] | self.mask[i] != 0xff) return false; + if (dst[i] & self.mask[i] != a[i] & self.mask[i]) return false; + } + return true; + } + + fn onLink(self: *Stack, dst: Ip4) bool { + const a = self.addr orelse return true; // unconfigured: everything is a direct neighbour + var i: usize = 0; + while (i < 4) : (i += 1) { + if ((dst[i] ^ a[i]) & self.mask[i] != 0) return false; + } + return true; + } + + // ================================================================== frame construction + + fn emitFrame(self: *Stack, len: usize) void { + if (len > frame_max) { + self.counters.tx_dropped += 1; + return; + } + // Ethernet's 60-byte minimum (64 with FCS) is padded by the MAC, and ESP-Hosted's slave + // hands the frame to the C6's Wi-Fi MAC, which does the same. Nothing is padded here. + self.counters.tx_frames += 1; + self.send(self.tx[0..len]); + } + + fn ethHeader(self: *Stack, dst: Mac, ethertype: EtherType) void { + wrMac(&self.tx, eth.dst, dst); + wrMac(&self.tx, eth.src, self.mac); + wr16(&self.tx, eth.ethertype, @intFromEnum(ethertype)); + } + + /// Build and send an IPv4 datagram whose payload the caller has already written to + /// `self.tx[eth_hlen + ip4.hlen ..]`. Returns false if the destination's MAC is unknown, in + /// which case an ARP request has been sent and the datagram is dropped. + /// + /// Dropping rather than queueing is lwIP's `ETHARP_SUPPORT_STATIC_ENTRIES`-less behaviour minus + /// its one-packet queue (`core/ipv4/etharp.c`, `etharp_query`). Nothing here needs the queue: + /// DHCP is broadcast, ICMP replies go to a peer whose MAC just arrived in the request, and TCP + /// resolves the peer before the SYN is built (`TcpState.arp_wait`). + fn emitIp(self: *Stack, src: Ip4, dst: Ip4, proto: Protocol, payload_len: usize) bool { + assert(payload_len <= mtu - ip4.hlen); + const total: u16 = @intCast(ip4.hlen + payload_len); + + const dst_mac = self.routeMac(dst) orelse { + self.counters.tx_dropped += 1; + return false; + }; + self.ethHeader(dst_mac, .ip4); + + const h = self.tx[eth_hlen..][0..ip4.hlen]; + h[ip4.v_hl] = 0x45; // IPv4, 5 words of header, no options + h[ip4.tos] = 0; + wr16(h, ip4.total_len, total); + wr16(h, ip4.id, self.ip_id); + self.ip_id +%= 1; + // DF set: this stack neither fragments what it sends nor reassembles what it receives, so + // saying so is more useful than letting a router fragment a datagram we cannot rebuild. + wr16(h, ip4.frag, ip4.flag_df); + h[ip4.ttl] = 64; // RFC 1122 3.2.1.7 recommends 64 + h[ip4.proto] = @intFromEnum(proto); + wr16(h, ip4.chksum, 0); + wrIp(h, ip4.src, src); + wrIp(h, ip4.dst, dst); + wr16(h, ip4.chksum, checksum(h)); + + self.emitFrame(eth_hlen + total); + return true; + } + + /// The MAC a datagram for `dst` must be sent to: broadcast for a broadcast address, the peer + /// itself if it is on-link, otherwise the gateway. `null` means unresolved, and an ARP request + /// has been sent. + fn routeMac(self: *Stack, dst: Ip4) ?Mac { + if (std.mem.eql(u8, &dst, &ip_broadcast)) return mac_broadcast; + if (self.addr != null) { + // Subnet broadcast. + var all_ones = true; + var i: usize = 0; + while (i < 4) : (i += 1) { + if (dst[i] | self.mask[i] != 0xff) all_ones = false; + } + if (all_ones and self.onLink(dst)) return mac_broadcast; + } + const next = if (self.onLink(dst)) dst else self.gw; + if (self.arpLookup(next)) |m| return m; + self.arpRequest(next); + return null; + } + + // ================================================================================= ARP + + /// An entry's timestamp is *not* refreshed by a lookup, only by an ARP packet from that host. + /// Refreshing on use looks like a cheap optimisation and is a real bug: an entry kept alive by + /// our own traffic is never re-resolved, so a gateway whose MAC changes - VRRP failover, a + /// replaced router, a roam to a different AP with a different BSSID-derived address - is never + /// noticed, and every frame goes to a MAC that no longer answers. Ageing out after + /// `arp_max_age_ms` of no ARP traffic from that host costs one dropped segment and a + /// retransmission; getting it wrong costs the connection. + fn arpLookup(self: *Stack, target: Ip4) ?Mac { + for (&self.arp_cache) |*e| { + if (!e.valid()) continue; + if (self.now_ms -% e.stamp_ms > arp_max_age_ms) { + e.stamp_ms = 0; + continue; + } + if (std.mem.eql(u8, &e.ip, &target)) return e.mac; + } + return null; + } + + /// Insert or refresh. `insert` false means "update only if already known", which is how a + /// four-entry cache survives a busy /24: every ARP request on the segment is a broadcast, and a + /// cache that admitted all of them would evict the gateway within seconds. lwIP draws the same + /// line with `ETHARP_FLAG_TRY_HARD` (`core/ipv4/etharp.c`, `etharp_update_arp_entry`). + fn arpStore(self: *Stack, target: Ip4, hw: Mac, insert: bool) void { + if (std.mem.eql(u8, &target, &ip_any)) return; + if (std.mem.eql(u8, &target, &ip_broadcast)) return; + for (&self.arp_cache) |*e| { + if (e.valid() and std.mem.eql(u8, &e.ip, &target)) { + e.mac = hw; + e.stamp_ms = self.now_ms; + return; + } + } + if (!insert) return; + // Free slot, else the least recently used. + var victim: *ArpEntry = &self.arp_cache[0]; + for (&self.arp_cache) |*e| { + if (!e.valid()) { + victim = e; + break; + } + if (e.stamp_ms < victim.stamp_ms) victim = e; + } + victim.* = .{ .ip = target, .mac = hw, .stamp_ms = self.now_ms }; + } + + fn arpEmit(self: *Stack, opcode: u16, target_ip: Ip4, target_mac: Mac, dst_mac: Mac, spa: Ip4) void { + self.ethHeader(dst_mac, .arp); + const h = self.tx[eth_hlen..][0..arp.len]; + wr16(h, arp.hwtype, arp.hwtype_ethernet); + wr16(h, arp.proto, @intFromEnum(EtherType.ip4)); + h[arp.hwlen] = 6; + h[arp.protolen] = 4; + wr16(h, arp.opcode, opcode); + wrMac(h, arp.sha, self.mac); + wrIp(h, arp.spa, spa); + wrMac(h, arp.tha, target_mac); + wrIp(h, arp.tpa, target_ip); + self.counters.arp_tx += 1; + self.emitFrame(eth_hlen + arp.len); + } + + fn arpRequest(self: *Stack, target: Ip4) void { + // RFC 826: the target hardware address of a request is "don't care"; zero is conventional. + self.arpEmit(arp.op_request, target, @splat(0), mac_broadcast, self.addr orelse ip_any); + } + + /// Gratuitous ARP: a broadcast request for our own address, which every listener treats as + /// "this MAC now owns this IP". Sent when an address is acquired, so the gateway and the AP + /// learn us without waiting to need us. RFC 5227 2.3. + fn announce(self: *Stack) void { + const a = self.addr orelse return; + self.arpEmit(arp.op_request, a, @splat(0), mac_broadcast, a); + } + + fn arpInput(self: *Stack, body: []const u8) void { + if (body.len < arp.len) { + self.counters.rx_dropped += 1; + return; + } + // RFC 826 "Packet Reception", exactly the four checks lwIP makes at + // `core/ipv4/etharp.c:656-659`. + if (rd16(body, arp.hwtype) != arp.hwtype_ethernet or + rd16(body, arp.proto) != @intFromEnum(EtherType.ip4) or + body[arp.hwlen] != 6 or body[arp.protolen] != 4) + { + self.counters.rx_dropped += 1; + return; + } + self.counters.arp_rx += 1; + + const spa = rdIp(body, arp.spa); + const sha = rdMac(body, arp.sha); + const tpa = rdIp(body, arp.tpa); + const for_us = if (self.addr) |a| std.mem.eql(u8, &tpa, &a) else false; + + // Learn the sender. Admitted to a free slot only when the packet was addressed to us - + // either a request we must answer or the reply to a request we sent. + self.arpStore(spa, sha, for_us); + + if (rd16(body, arp.opcode) == arp.op_request and for_us) { + // A reply goes back to the requester, not to the broadcast address. + self.arpEmit(arp.op_reply, spa, sha, sha, self.addr.?); + } + } + + // =============================================================================== input + + /// A received Ethernet frame. Everything this stack does in response happens before this + /// returns, including any frame it sends. + pub fn onFrame(self: *Stack, frame: []const u8) void { + self.counters.rx_frames += 1; + if (frame.len < eth_hlen or frame.len > frame_max) { + self.counters.rx_dropped += 1; + return; + } + const dst = rdMac(frame, eth.dst); + // The C6's MAC filter should already have done this, but a promiscuous or misconfigured + // transport would otherwise have this stack answering ARP for other stations. + if (!std.mem.eql(u8, &dst, &self.mac) and !std.mem.eql(u8, &dst, &mac_broadcast)) { + self.counters.rx_dropped += 1; + return; + } + const body = frame[eth_hlen..]; + switch (@as(EtherType, @enumFromInt(rd16(frame, eth.ethertype)))) { + .arp => self.arpInput(body), + .ip4 => self.ip4Input(body), + // .vlan lands here: an 802.1Q tag would need the 4-byte shim skipped and the real + // ethertype read from behind it. Nothing on this board tags frames, so it is dropped + // rather than half-handled. + else => self.counters.rx_dropped += 1, + } + } + + fn ip4Input(self: *Stack, body: []const u8) void { + if (body.len < ip4.hlen) { + self.counters.rx_dropped += 1; + return; + } + if (body[ip4.v_hl] >> 4 != 4) { + self.counters.rx_dropped += 1; + return; + } + const hlen = @as(usize, body[ip4.v_hl] & 0x0f) * 4; + if (hlen < ip4.hlen or hlen > body.len) { + self.counters.rx_dropped += 1; + return; + } + if (checksum(body[0..hlen]) != 0) { + self.counters.checksum_bad += 1; + return; + } + const total = rd16(body, ip4.total_len); + if (total < hlen or total > body.len) { + // Shorter than claimed: truncated. Longer than claimed happens legitimately - a + // 60-byte minimum-length Ethernet frame padding a 28-byte datagram - and is handled by + // trusting `total` below, but a frame shorter than its own IP header claims is junk. + self.counters.rx_dropped += 1; + return; + } + const frag = rd16(body, ip4.frag); + if (frag & (ip4.flag_mf | ip4.offset_mask) != 0) { + // A fragment. Reassembly is out of scope, and accepting the first fragment as a whole + // datagram would be worse than dropping it. + self.counters.rx_dropped += 1; + return; + } + + const src = rdIp(body, ip4.src); + const dst = rdIp(body, ip4.dst); + const proto: Protocol = @enumFromInt(body[ip4.proto]); + const payload = body[hlen..total]; + + if (!self.forUs(dst)) { + // One exception, and it is the reason DHCP works at all: a server may unicast its + // OFFER or ACK to the address it is about to grant, which is not yet ours, at a MAC + // that is. RFC 2131 4.1 permits exactly this. So while unbound, UDP is let through to + // the demultiplexer, which will only match the DHCP client port. + const dhcp_pending = self.addr == null and self.dhcp.state != .off; + if (!(dhcp_pending and proto == .udp)) { + self.counters.rx_dropped += 1; + return; + } + } + + switch (proto) { + .icmp => self.icmpInput(src, dst, payload), + .udp => self.udpInput(src, dst, payload), + .tcp => self.tcpInput(src, dst, payload), + else => self.counters.rx_dropped += 1, + } + } + + // ================================================================================ ICMP + + fn icmpInput(self: *Stack, src: Ip4, dst: Ip4, payload: []const u8) void { + if (payload.len < icmp.hlen) { + self.counters.rx_dropped += 1; + return; + } + // ICMP has no pseudo-header (RFC 792): the checksum covers the message alone. + if (checksum(payload) != 0) { + self.counters.checksum_bad += 1; + return; + } + if (payload[icmp.type_] != icmp.echo_request) { + // Destination-unreachable and time-exceeded carry useful information that nothing here + // consumes; a stack with no routing decisions to revise has nothing to do with them. + self.counters.rx_dropped += 1; + return; + } + // A request addressed to the broadcast address is answered from our own address only; a + // reply sourced from a broadcast address is malformed and some hosts treat it as an attack. + if (self.addr == null) return; + if (payload.len > mtu - ip4.hlen) { + // Would need fragmenting to answer. `ping -s 1473` from the development host lands + // here as a fragmented request and is already dropped above; this covers the rest. + self.counters.rx_dropped += 1; + return; + } + _ = dst; + + const out = self.tx[eth_hlen + ip4.hlen ..][0..payload.len]; + @memcpy(out, payload); + out[icmp.type_] = icmp.echo_reply; + out[icmp.code] = 0; + wr16(out, icmp.chksum, 0); + wr16(out, icmp.chksum, checksum(out)); + self.counters.icmp_echo += 1; + _ = self.emitIp(self.addr.?, src, .icmp, payload.len); + } + + // ================================================================================= UDP + + fn udpInput(self: *Stack, src: Ip4, dst: Ip4, payload: []const u8) void { + if (payload.len < udp.hlen) { + self.counters.rx_dropped += 1; + return; + } + const ulen = rd16(payload, udp.len); + if (ulen < udp.hlen or ulen > payload.len) { + self.counters.rx_dropped += 1; + return; + } + const datagram = payload[0..ulen]; + if (!transportChecksumOk(src, dst, .udp, datagram, rd16(datagram, udp.chksum))) { + self.counters.checksum_bad += 1; + return; + } + self.counters.udp_rx += 1; + + const sport = rd16(datagram, udp.src_port); + const dport = rd16(datagram, udp.dst_port); + const data = datagram[udp.hlen..]; + if (dport == dhcp.client_port) { + self.dhcpInput(src, data); + } else if (self.query.phase == .waiting and dport == self.query.local_port) { + self.dnsInput(src, sport, data); + } else { + // No sockets, so nothing else has a port. A real stack would answer with ICMP port + // unreachable; announcing which ports are closed is of no use to this device. + self.counters.rx_dropped += 1; + } + } + + /// Send a UDP datagram. `src` may be `0.0.0.0`, which DHCP needs before it has an address. + fn emitUdp(self: *Stack, src: Ip4, sport: u16, dst: Ip4, dport: u16, data_len: usize) bool { + const seg_len = udp.hlen + data_len; + assert(seg_len <= mtu - ip4.hlen); + const seg = self.tx[eth_hlen + ip4.hlen ..][0..seg_len]; + wr16(seg, udp.src_port, sport); + wr16(seg, udp.dst_port, dport); + wr16(seg, udp.len, @intCast(seg_len)); + wr16(seg, udp.chksum, 0); + wr16(seg, udp.chksum, udpChecksumOnWire(transportChecksum(src, dst, .udp, seg))); + return self.emitIp(src, dst, .udp, seg_len); + } + + // ================================================================================ DHCP + + /// Begin acquiring an address. Idempotent while an acquisition is in progress; a call while + /// bound restarts from DISCOVER. + pub fn dhcpStart(self: *Stack) void { + self.addr = null; + self.mask = ip_any; + self.gw = ip_any; + self.dns = null; + // The resolver is gone with the lease, so anything in flight to it is abandoned rather + // than left to time out against a server this stack no longer believes in. + self.query.phase = .idle; + self.dhcp = .{ + .state = .selecting, + .xid = self.draw(), + .started_ms = self.now_ms, + }; + self.dhcpSend(dhcp.discover); + self.dhcp.tries = 1; + self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[0]; + } + + /// Options are appended through this so a length byte can never be written by hand. + const OptWriter = struct { + buf: []u8, + i: usize = 0, + + fn raw(self: *OptWriter, code: u8, value: []const u8) void { + assert(value.len <= 255); + assert(self.i + 2 + value.len <= self.buf.len); + self.buf[self.i] = code; + self.buf[self.i + 1] = @intCast(value.len); + @memcpy(self.buf[self.i + 2 ..][0..value.len], value); + self.i += 2 + value.len; + } + fn byte(self: *OptWriter, code: u8, v: u8) void { + self.raw(code, &[_]u8{v}); + } + fn word(self: *OptWriter, code: u8, v: u16) void { + var t: [2]u8 = undefined; + std.mem.writeInt(u16, &t, v, .big); + self.raw(code, &t); + } + fn address(self: *OptWriter, code: u8, v: Ip4) void { + self.raw(code, &v); + } + fn end(self: *OptWriter) void { + assert(self.i < self.buf.len); + self.buf[self.i] = dhcp.opt_end; + self.i += 1; + } + }; + + /// Build and send one DHCP message. The RFC 2131 4.3.6 table is what decides which fields are + /// set: it is the part of DHCP that servers actually enforce, and getting `ciaddr` or the + /// server identifier wrong produces a NAK rather than an error message. + fn dhcpSend(self: *Stack, kind: u8) void { + const msg = self.tx[eth_hlen + ip4.hlen + udp.hlen ..][0..dhcp_min_msg_len]; + @memset(msg, 0); + + const renewing = self.dhcp.state == .renewing; + const rebinding = self.dhcp.state == .rebinding; + // RENEWING and REBINDING carry the bound address in `ciaddr` and no requested-IP option; + // SELECTING and REQUESTING carry zero and use option 50. lwIP makes the same distinction + // at `core/ipv4/dhcp.c:2026-2030`. + const use_ciaddr = renewing or rebinding; + + msg[dhcp.op] = dhcp.bootrequest; + msg[dhcp.htype] = @intCast(arp.hwtype_ethernet); + msg[dhcp.hlen] = 6; + msg[dhcp.hops] = 0; + wr32(msg, dhcp.xid, self.dhcp.xid); + wr16(msg, dhcp.secs, @intCast(@min(0xffff, (self.now_ms -% self.dhcp.started_ms) / 1000))); + // Ask the server to broadcast its reply. lwIP clears this flag + // (`core/ipv4/dhcp.c:2024-2025`: "we don't need the broadcast flag since we can receive + // unicast traffic before being fully configured"), and so can this stack - `ip4Input` has + // the explicit exemption for it. The flag is set anyway because a broadcast reply is the + // path with the fewest ways to fail on first bring-up: it needs no ARP entry at the server, + // no unicast-to-unconfigured-host handling in the AP, and no exemption in this file. + wr16(msg, dhcp.flags, dhcp.flag_broadcast); + if (use_ciaddr) wrIp(msg, dhcp.ciaddr, self.addr orelse ip_any); + @memcpy(msg[dhcp.chaddr..][0..6], &self.mac); + wr32(msg, dhcp.cookie, dhcp.magic_cookie); + + var o: OptWriter = .{ .buf = msg[dhcp.options..] }; + o.byte(dhcp.opt_msg_type, kind); + // RFC 2131 3.5: the maximum message size we can reassemble. One MTU minus the headers, + // which for this stack is also the largest datagram it can receive at all. + o.word(dhcp.opt_max_msg_size, @intCast(mtu - ip4.hlen - udp.hlen)); + if (kind == dhcp.request and !use_ciaddr) { + o.address(dhcp.opt_requested_ip, self.dhcp.offered); + o.address(dhcp.opt_server_id, self.dhcp.server); + } + // RFC 2131 4.3.6: a REQUEST in RENEWING/REBINDING must not carry a server identifier. + o.raw(dhcp.opt_param_list, &[_]u8{ + dhcp.opt_subnet_mask, + dhcp.opt_router, + dhcp.opt_dns, + dhcp.opt_lease_time, + dhcp.opt_t1, + dhcp.opt_t2, + }); + o.raw(dhcp.opt_hostname, "esp32p4"); + o.end(); + // Everything past the END option stays zero: RFC 2131 4.1 pads with option 0. + + const src = if (use_ciaddr) (self.addr orelse ip_any) else ip_any; + // RENEWING unicasts to the server that granted the lease; every other message is broadcast + // (RFC 2131 4.3.6, 4.4.5). + const dst = if (renewing) self.dhcp.server else ip_broadcast; + self.counters.dhcp_tx += 1; + _ = self.emitUdp(src, dhcp.client_port, dst, dhcp.server_port, dhcp_min_msg_len); + } + + /// One parsed option, or the end of the list. + const Opt = struct { code: u8, value: []const u8 }; + + /// Walk a DHCP option list. Stops at END, at a truncated option, or at the end of the buffer - + /// a malformed length must not walk off the datagram, which is the classic DHCP parser bug. + fn dhcpOption(body: []const u8, want: u8) ?[]const u8 { + if (body.len <= dhcp.options) return null; + var i: usize = dhcp.options; + while (i < body.len) { + const code = body[i]; + if (code == dhcp.opt_end) return null; + if (code == dhcp.opt_pad) { + i += 1; + continue; + } + if (i + 2 > body.len) return null; + const len = body[i + 1]; + if (i + 2 + len > body.len) return null; + if (code == want) return body[i + 2 ..][0..len]; + i += 2 + len; + } + return null; + } + + fn dhcpOptionIp(body: []const u8, want: u8) ?Ip4 { + const v = dhcpOption(body, want) orelse return null; + if (v.len < 4) return null; + return v[0..4].*; + } + + fn dhcpOptionU32(body: []const u8, want: u8) ?u32 { + const v = dhcpOption(body, want) orelse return null; + if (v.len != 4) return null; + return std.mem.readInt(u32, v[0..4], .big); + } + + fn dhcpInput(self: *Stack, src: Ip4, body: []const u8) void { + if (self.dhcp.state == .off) return; + if (body.len < dhcp.options) { + self.counters.rx_dropped += 1; + return; + } + if (body[dhcp.op] != dhcp.bootreply) return; + if (rd32(body, dhcp.cookie) != dhcp.magic_cookie) return; + if (rd32(body, dhcp.xid) != self.dhcp.xid) return; + // The reply must be about our hardware address, not a relayed one for someone else. + if (body[dhcp.hlen] != 6 or !std.mem.eql(u8, body[dhcp.chaddr..][0..6], &self.mac)) return; + + const kind_opt = dhcpOption(body, dhcp.opt_msg_type) orelse return; + if (kind_opt.len != 1) return; + self.counters.dhcp_rx += 1; + + switch (kind_opt[0]) { + dhcp.offer => { + if (self.dhcp.state != .selecting) return; + self.dhcp.offered = rdIp(body, dhcp.yiaddr); + if (std.mem.eql(u8, &self.dhcp.offered, &ip_any)) return; + // Option 54 is how the REQUEST names which offer it accepts. A server that omits + // it is out of spec; `siaddr` is the best fallback, and the sender is the last. + self.dhcp.server = dhcpOptionIp(body, dhcp.opt_server_id) orelse blk: { + const s = rdIp(body, dhcp.siaddr); + break :blk if (std.mem.eql(u8, &s, &ip_any)) src else s; + }; + self.dhcp.state = .requesting; + self.dhcpSend(dhcp.request); + self.dhcp.tries = 1; + self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[0]; + }, + dhcp.ack => { + switch (self.dhcp.state) { + .requesting, .renewing, .rebinding => {}, + else => return, + } + const granted = rdIp(body, dhcp.yiaddr); + if (std.mem.eql(u8, &granted, &ip_any)) return; + self.dhcpBind(body, granted, src); + }, + dhcp.nak => { + switch (self.dhcp.state) { + .requesting, .renewing, .rebinding => {}, + else => return, + } + // RFC 2131 4.4.5: a NAK sends the client back to INIT. The lease is gone, so the + // address goes with it - continuing to use it would be squatting. + self.dhcpStart(); + }, + else => {}, + } + } + + fn dhcpBind(self: *Stack, body: []const u8, granted: Ip4, src: Ip4) void { + self.addr = granted; + self.mask = dhcpOptionIp(body, dhcp.opt_subnet_mask) orelse .{ 255, 255, 255, 0 }; + self.gw = dhcpOptionIp(body, dhcp.opt_router) orelse ip_any; + self.dns = dhcpOptionIp(body, dhcp.opt_dns); + if (dhcpOptionIp(body, dhcp.opt_server_id)) |s| self.dhcp.server = s else if (std.mem.eql(u8, &self.dhcp.server, &ip_any)) { + self.dhcp.server = src; + } + + // RFC 2131 3.3. A server that sends no lease time is out of spec; an hour is a safe + // assumption, being short enough that a wrong guess self-corrects. + const lease = dhcpOptionU32(body, dhcp.opt_lease_time) orelse 3600; + self.dhcp.lease_s = lease; + if (lease == 0xffff_ffff) { + // Infinite lease: never renew. + self.dhcp.t1_ms = std.math.maxInt(u64); + self.dhcp.t2_ms = std.math.maxInt(u64); + self.dhcp.expire_ms = std.math.maxInt(u64); + } else { + // The server may state T1 and T2 itself; otherwise lwIP's derivation, which is RFC + // 2131 4.4.5's: half the lease, and seven eighths of it + // (`core/ipv4/dhcp.c:757` and `:766`). + const t1 = dhcpOptionU32(body, dhcp.opt_t1) orelse lease / 2; + const t2 = dhcpOptionU32(body, dhcp.opt_t2) orelse (lease / 8) * 7; + const base = self.now_ms; + self.dhcp.t1_ms = base + @as(u64, @min(t1, lease)) * 1000; + self.dhcp.t2_ms = base + @as(u64, @min(t2, lease)) * 1000; + self.dhcp.expire_ms = base + @as(u64, lease) * 1000; + } + self.dhcp.state = .bound; + self.dhcp.tries = 0; + self.dhcp.retry_ms = 0; + self.announce(); + } + + fn dhcpTick(self: *Stack) void { + // T1 while bound: start renewing. RFC 2131 4.4.5 requires a fresh transaction id, and the + // first REQUEST goes out on this same tick rather than one backoff later - a state change + // that transmits nothing is how a lease quietly expires while the client thinks it is + // renewing. + if (self.dhcp.state == .bound and self.now_ms >= self.dhcp.t1_ms) { + self.dhcp.state = .renewing; + self.dhcp.xid = self.draw(); + self.dhcp.started_ms = self.now_ms; + self.dhcp.tries = 0; + self.dhcp.retry_ms = 0; + } + switch (self.dhcp.state) { + .off, .bound => return, + .selecting, .requesting, .renewing, .rebinding => {}, + } + if (self.dhcp.state == .renewing and self.now_ms >= self.dhcp.t2_ms) { + // T2: the granting server is not answering. Ask anyone. + self.dhcp.state = .rebinding; + self.dhcp.tries = 0; + self.dhcp.retry_ms = 0; + } + if ((self.dhcp.state == .renewing or self.dhcp.state == .rebinding) and + self.now_ms >= self.dhcp.expire_ms) + { + // The lease is over. Give up the address before asking again: keeping it would mean + // using an address the server may already have given away. + self.dhcpStart(); + return; + } + if (self.dhcp.retry_ms != 0 and self.now_ms < self.dhcp.retry_ms) return; + const kind: u8 = if (self.dhcp.state == .selecting) dhcp.discover else dhcp.request; + self.dhcpSend(kind); + const idx = @min(self.dhcp.tries, dhcp_backoff_ms.len - 1); + self.dhcp.retry_ms = self.now_ms + dhcp_backoff_ms[idx]; + if (self.dhcp.tries < 255) self.dhcp.tries += 1; + } + + // ================================================================================= DNS + // + // One question, QTYPE=A, QCLASS=IN, recursion desired, over the UDP above. No cache, no + // search list, no NS or SOA handling, no TCP fallback on a truncated answer: this resolves + // the one name a device that fetches one URL has to resolve, and says so with a named error + // when it cannot. + // + // The hard part of DNS parsing is not the header, it is that a name in a resource record may + // be a compression pointer into anywhere earlier in the message (RFC 1035 4.1.4). A parser + // that follows those without a bound hangs on a message that points at itself, and such a + // message costs an attacker two bytes. `dnsSkipName` is where that is dealt with. + + /// Encode a dotted name into RFC 1035 4.1.2 wire form: each label prefixed with its length, + /// terminated by the zero-length root label. Returns the encoded length. + /// + /// `out` must be at least `dns_qname_max`, which the length check below makes sufficient: a + /// name of `n` text bytes with no trailing dot encodes to exactly `n + 2`. + fn dnsEncodeName(name: []const u8, out: []u8) DnsError!usize { + assert(out.len >= dns_qname_max); + if (name.len > dns_name_max) return error.NameTooLong; + // A trailing dot is the root label written out, and `example.com.` names the same node as + // `example.com`. Everything after it - an empty final label - is not. + var rest = name; + if (rest.len != 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1]; + if (rest.len == 0) return error.NameInvalid; + + var o: usize = 0; + var labels = std.mem.splitScalar(u8, rest, '.'); + while (labels.next()) |label| { + // An empty label inside a name (`a..b`, or a leading dot) is not a name. + if (label.len == 0 or label.len > dns.label_max) return error.NameInvalid; + out[o] = @intCast(label.len); + @memcpy(out[o + 1 ..][0..label.len], label); + o += 1 + label.len; + } + out[o] = 0; + return o + 1; + } + + /// Compare an encoded name against the question we asked, ASCII-case-insensitively. RFC 4343: + /// label comparison ignores case, and a resolver is entitled to answer `0X4200.CAFE` to a + /// question about `0x4200.cafe`. Length bytes are 0-63 and so are never touched by the fold. + fn dnsQNameEql(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + return true; + } + + /// Step over the name at `start` and return the offset of the byte after it - which for a + /// name that ends in a compression pointer is two bytes after the pointer, *not* wherever the + /// pointer led. `null` means the name is unparseable and the message is to be rejected. + /// + /// **Why this terminates.** Two independent bounds, because one of them is not enough: + /// + /// * A pointer must point strictly backwards (`target < here`). That alone is the check + /// most implementations stop at, and it is *not* sufficient: after jumping back the walk + /// moves forward again over labels, so a pointer at offset 12 to offset 10 and a label at + /// 10 that is two bytes long lands back at 12, and the pair loops forever with every + /// individual jump going backwards. + /// * So the jumps themselves are counted, and `dns_max_jumps` of them ends the name. That + /// is the bound that actually holds: the loop below does at most `dns_max_jumps` jumps + /// and, between them, walks labels whose lengths are positive, so it visits at most + /// `dns_max_jumps * msg.len` bytes and stops. A legitimate answer uses one jump per name. + fn dnsSkipName(msg: []const u8, start: usize) ?usize { + var i = start; + var jumps: u8 = 0; + // The offset after the name in the *message*, fixed by the first pointer taken. + var after: ?usize = null; + while (true) { + if (i >= msg.len) return null; + const len = msg[i]; + if (len & dns.ptr_mask == dns.ptr_mask) { + if (i + 1 >= msg.len) return null; + const target = (@as(usize, len & 0x3f) << 8) | msg[i + 1]; + if (after == null) after = i + 2; + if (target >= i) return null; + jumps += 1; + if (jumps > dns_max_jumps) return null; + i = target; + continue; + } + // 0x40 and 0x80 are the reserved label types of RFC 1035 4.1.4 / RFC 6891; neither is + // something this stack can skip a known number of bytes past, so neither is accepted. + if (len & dns.ptr_mask != 0) return null; + if (len == 0) return after orelse i + 1; + i += 1 + @as(usize, len); + if (i > msg.len) return null; + } + } + + /// Build and send the query held in `self.query`. Called for the first transmission and for + /// every retransmission, from the same fields, so the two cannot drift apart. + fn dnsSend(self: *Stack) void { + const src = self.addr orelse return; + const server = self.dns orelse return; + const qn_len: usize = self.query.qname_len; + const msg_len = dns.hlen + qn_len + 4; + const msg = self.tx[eth_hlen + ip4.hlen + udp.hlen ..][0..msg_len]; + wr16(msg, dns.id, self.query.id); + // RD only. Not AD, not CD, not EDNS0: this asks a recursive resolver for one A record and + // has nothing to validate with. + wr16(msg, dns.flags, dns.flag_rd); + wr16(msg, dns.qdcount, 1); + wr16(msg, dns.ancount, 0); + wr16(msg, dns.nscount, 0); + wr16(msg, dns.arcount, 0); + @memcpy(msg[dns.hlen..][0..qn_len], self.query.qname[0..qn_len]); + wr16(msg, dns.hlen + qn_len, dns.type_a); + wr16(msg, dns.hlen + qn_len + 2, dns.class_in); + self.counters.dns_tx += 1; + _ = self.emitUdp(src, self.query.local_port, server, dns.port, msg_len); + } + + fn dnsFail(self: *Stack, e: DnsError) void { + self.query.phase = .failed; + self.query.err = e; + } + + /// A datagram to the port the outstanding query was sent from. Called from inside `onFrame`. + /// + /// Everything that does not match the query is *ignored*, not failed: on a real network the + /// port this query owns will collect late answers to previous queries, scans, and whatever + /// else is loose on the segment, and any of those failing the query would be a denial of + /// service that costs one packet. Only a response that matches the source, the id and the + /// question can decide the query - and then it decides it either way. + fn dnsInput(self: *Stack, src: Ip4, sport: u16, msg: []const u8) void { + const server = self.dns orelse return; + if (!std.mem.eql(u8, &src, &server)) return; + if (sport != dns.port) return; + if (msg.len < dns.hlen) return; + if (rd16(msg, dns.id) != self.query.id) return; + + const flags = rd16(msg, dns.flags); + if (flags & dns.flag_qr == 0) return; // a query, not a response + if (rd16(msg, dns.qdcount) != 1) return; + + // The question, echoed. A server that answers a different question - or an attacker who + // guessed the id and the port but not the name - is not answering this. + const qn = self.query.qname[0..self.query.qname_len]; + var off = dns.hlen + qn.len + 4; + if (msg.len < off) return; + if (!dnsQNameEql(msg[dns.hlen..][0..qn.len], qn)) return; + if (rd16(msg, dns.hlen + qn.len) != dns.type_a) return; + if (rd16(msg, dns.hlen + qn.len + 2) != dns.class_in) return; + + self.counters.dns_rx += 1; + + const rcode = flags & dns.rcode_mask; + if (rcode != 0) { + self.dnsFail(if (rcode == dns.rcode_name_error) error.NameNotFound else error.DnsRefused); + return; + } + + // Walk the answer section and take the first A record. Walking rather than reading the + // first record is what makes a CNAME chain work: `0x4200.cafe` may answer with the CNAME + // and the A together, in that order, and a resolver that reads answer[0] gets a name. + var left = rd16(msg, dns.ancount); + while (left != 0) : (left -= 1) { + off = dnsSkipName(msg, off) orelse { + self.dnsFail(error.DnsMalformed); + return; + }; + if (off + dns.rr_fixed > msg.len) { + self.dnsFail(error.DnsMalformed); + return; + } + const rtype = rd16(msg, off); + const rclass = rd16(msg, off + 2); + const rdlen: usize = rd16(msg, off + 8); + off += dns.rr_fixed; + if (off + rdlen > msg.len) { + self.dnsFail(error.DnsMalformed); + return; + } + if (rtype == dns.type_a and rclass == dns.class_in and rdlen == 4) { + self.query.result = rdIp(msg, off); + self.query.phase = .done; + return; + } + off += rdlen; + } + // A well-formed answer with no A record in it: NODATA, or a CNAME chain this stack will + // not chase a second query down. + self.dnsFail(error.NameNotFound); + } + + fn dnsTick(self: *Stack) void { + if (self.query.phase != .waiting) return; + if (self.now_ms < self.query.retry_ms) return; + if (self.query.tries >= dns_backoff_ms.len) { + self.dnsFail(error.TimedOut); + return; + } + self.query.retry_ms = self.now_ms + dns_backoff_ms[self.query.tries]; + self.query.tries += 1; + self.counters.dns_retx += 1; + self.dnsSend(); + } + + /// Resolve `name` to an IPv4 address. + /// + /// **The protocol is `httpGet`'s, deliberately.** There is no clock and no `std.Io` here, so + /// there is nothing for a blocking call to block on: the first call sends the query and + /// returns `error.WouldBlock`, and the caller drives `tick` and `onFrame` and calls again + /// with the same name until an address or a real error comes back. + /// + /// const addr = while (true) { + /// stack.tick(hal.systimer.millis()); + /// while (transport.next()) |frame| stack.onFrame(frame); + /// if (stack.resolve("0x4200.cafe")) |a| break a + /// else |e| if (e != error.WouldBlock) return e; + /// }; + /// + /// The wait is bounded whether or not the caller bounds it: `dns_backoff_ms` retransmits + /// three times over 7 s and then answers `error.TimedOut`. Nothing here waits forever, and + /// the retransmissions happen in `tick`, so a caller that ticks and polls rarely still gets + /// them on time. + /// + /// One query is outstanding at a time. A call naming something else while one is in flight is + /// `error.Busy`; a call naming something else after one has finished starts a new query, + /// which is what makes the loop above safe to write for two names in a row. + pub fn resolve(self: *Stack, name: []const u8) DnsError!Ip4 { + // Encoded first, and compared in encoded form: `0x4200.cafe`, `0x4200.cafe.` and + // `0X4200.CAFE` are one name, and a caller that spells it differently between two polls + // of the same loop must not get `error.Busy` for it. + var wire: [dns_qname_max]u8 = undefined; + const wire_len = try dnsEncodeName(name, &wire); + const same = self.query.qname_len == wire_len and + dnsQNameEql(self.query.qname[0..wire_len], wire[0..wire_len]); + + switch (self.query.phase) { + .idle => {}, + .waiting => { + if (!same) return error.Busy; + return error.WouldBlock; + }, + // A finished query for this name is collected and the slot released. A finished query + // for a different name falls through and is replaced. + .done => if (same) { + self.query.phase = .idle; + return self.query.result; + }, + .failed => if (same) { + self.query.phase = .idle; + return self.query.err; + }, + } + + if (self.addr == null) return error.NoAddress; + if (self.dns == null) return error.NoDnsServer; + + @memcpy(self.query.qname[0..wire_len], wire[0..wire_len]); + self.query.qname_len = @intCast(wire_len); + self.query.id = @truncate(self.draw()); + // RFC 6335's dynamic range, as `tcpConnect` uses. Redrawn per query so a late answer to + // the previous one cannot be mistaken for this one even if the id happens to repeat. + self.query.local_port = @intCast(49152 + self.draw() % (65535 - 49152 + 1)); + self.query.result = ip_any; + self.query.err = error.WouldBlock; + self.query.phase = .waiting; + self.query.tries = 1; + self.query.retry_ms = self.now_ms + dns_backoff_ms[0]; + self.dnsSend(); + return error.WouldBlock; + } + + // ================================================================================= TCP + // + // One connection, client side only, one unacknowledged segment at a time. The send side is a + // single static buffer holding the whole request, so "retransmission" is always "send from + // `snd_una` again" and there is no retransmission queue. The receive side has no reassembly + // buffer at all: a segment that is not the next one expected is answered with a duplicate ACK + // and dropped. Three of those is a fast-retransmit signal to any modern peer, so the common + // case of one lost segment costs a round trip rather than an RTO - but a reordered segment + // costs a retransmission that a reassembly buffer would have avoided. That is the price of not + // having one, and on a Wi-Fi link where reordering is rare it is the right price. + + fn emitTcp(self: *Stack, flags: u8, seq: u32, data: []const u8, mss_opt: bool) bool { + const src = self.addr orelse return false; + const opt_len: usize = if (mss_opt) 4 else 0; + const seg_len = tcp.hlen + opt_len + data.len; + assert(seg_len <= mtu - ip4.hlen); + const seg = self.tx[eth_hlen + ip4.hlen ..][0..seg_len]; + + wr16(seg, tcp.src_port, self.tcp.local_port); + wr16(seg, tcp.dst_port, self.tcp.peer_port); + wr32(seg, tcp.seq, seq); + wr32(seg, tcp.ack, self.tcp.rcv_nxt); + const words: u16 = @intCast((tcp.hlen + opt_len) / 4); + wr16(seg, tcp.hdrlen_flags, (words << 12) | flags); + wr16(seg, tcp.window, self.rcvWindow()); + wr16(seg, tcp.chksum, 0); + wr16(seg, tcp.urgent, 0); + if (mss_opt) { + seg[tcp.hlen] = tcp.opt_mss; + seg[tcp.hlen + 1] = 4; + wr16(seg, tcp.hlen + 2, tcp_mss); + } + if (data.len != 0) @memcpy(seg[tcp.hlen + opt_len ..], data); + wr16(seg, tcp.chksum, transportChecksum(src, self.tcp.peer_ip, .tcp, seg)); + + self.counters.tcp_tx += 1; + return self.emitIp(src, self.tcp.peer_ip, .tcp, seg_len); + } + + /// The window to advertise: real back-pressure, not a constant. Everything accepted is consumed + /// synchronously into the HTTP head buffer or the caller's `out`, so the window is whatever + /// room is left there, capped at one MSS. Advertising a fixed window while the consumer had no + /// room left would turn "the response is bigger than your buffer" into a silently dropped + /// segment and an RTO storm. + fn rcvWindow(self: *Stack) u16 { + const room: usize = switch (self.http.phase) { + .head => (http_head_max - self.http.head_len) + self.http.out.len, + // Chunked framing - the CRLF closing each chunk, the zero-length chunk, the trailer + // section and the final CRLF - is consumed and discarded rather than delivered, so it + // needs window that `out` does not account for. Without this a body that exactly + // fills `out` closes the window before its own terminator can arrive, and the request + // stalls until the RTO gives up on a peer that is behaving perfectly. + .body => (self.http.out.len - self.http.out_len) + + @as(usize, if (self.http.chunked) http_framing_max + 16 else 0), + else => tcp_window, + }; + return @intCast(@min(room, tcp_window)); + } + + /// Reset the connection and fail the request. RST is sent unless the peer sent one. + fn tcpAbort(self: *Stack, err: HttpError, send_rst: bool) void { + if (send_rst and self.tcp.state != .closed and self.tcp.state != .arp_wait) { + _ = self.emitTcp(tcp.rst | tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + self.tcp.state = .closed; + if (self.http.phase == .head or self.http.phase == .body) { + self.http.phase = .failed; + self.http.err = err; + } + } + + /// Set up the connection block for a fresh connect. Written field by field on purpose: the + /// obvious `self.tcp = .{ ... }` would assign `tx` from the struct's `undefined` default, which + /// in a safe build overwrites the request bytes with 0xAA, and in a release build memsets half + /// a kilobyte for nothing. + fn tcpConnect(self: *Stack, peer: Ip4, port: u16) void { + // A fresh ephemeral port every time. RFC 6335's dynamic range is 49152-65535, and moving + // through it is what makes the short TIME_WAIT above safe. + const span: u32 = 65535 - 49152 + 1; + const iss = self.draw(); + self.tcp.state = .arp_wait; + self.tcp.peer_ip = peer; + self.tcp.peer_port = port; + self.tcp.local_port = @intCast(49152 + self.draw() % span); + self.tcp.iss = iss; + self.tcp.snd_una = iss; + self.tcp.snd_nxt = iss; + self.tcp.snd_wnd = 0; + self.tcp.snd_mss = tcp_default_mss; + self.tcp.fin_queued = false; + self.tcp.peer_fin = false; + self.tcp.rcv_nxt = 0; + self.tcp.rto_deadline_ms = 0; + self.tcp.rto_ms = tcp_rto_initial_ms; + self.tcp.retries = 0; + self.tcp.close_deadline_ms = 0; + self.tcp.tx_len = 0; + self.arp_tries = 0; + self.arp_retry_ms = 0; + } + + fn tcpSendSyn(self: *Stack) void { + self.tcp.state = .syn_sent; + self.tcp.snd_nxt = self.tcp.iss +% 1; + _ = self.emitTcp(tcp.syn, self.tcp.iss, &.{}, true); + self.armRto(); + } + + fn armRto(self: *Stack) void { + self.tcp.rto_deadline_ms = self.now_ms + self.tcp.rto_ms; + } + + /// Send as much of the request as the peer's window and MSS allow, then the FIN if the whole + /// request has gone out. One segment in flight, so this sends at most one segment per call. + /// + /// Only `established` sends: receiving the peer's FIN does not move the state, it sets + /// `peer_fin`, so this stays the single place that decides what goes on the wire and the state + /// only ever changes when a FIN of ours actually leaves. + fn tcpSendData(self: *Stack) void { + if (self.tcp.state != .established) return; + // Nothing outstanding is the precondition for sending: this is the fixed window of one. + if (seqLt(self.tcp.snd_una, self.tcp.snd_nxt)) return; + + const end = self.tcp.dataEnd(); + if (seqLt(self.tcp.snd_nxt, end)) { + const off: usize = self.tcp.snd_nxt -% self.tcp.dataStart(); + const remaining = self.tcp.tx_len - off; + const window: usize = self.tcp.snd_una +% self.tcp.snd_wnd -% self.tcp.snd_nxt; + const n = @min(@min(remaining, self.tcp.snd_mss), @max(window, 1)); + // PSH on the last segment of the request: the peer's application should see it without + // waiting for more. RFC 793 has no requirement here; every HTTP server expects it. + const last = off + n == self.tcp.tx_len; + const flags: u8 = tcp.ack_f | (if (last) tcp.psh else 0); + const seq = self.tcp.snd_nxt; + self.tcp.snd_nxt = seq +% @as(u32, @intCast(n)); + _ = self.emitTcp(flags, seq, self.tcp.tx[off..][0..n], false); + self.armRto(); + return; + } + if (self.tcp.fin_queued and self.tcp.snd_nxt == end) { + const seq = self.tcp.snd_nxt; + self.tcp.snd_nxt = seq +% 1; + _ = self.emitTcp(tcp.fin | tcp.ack_f, seq, &.{}, false); + self.armRto(); + // RFC 793's FIN-WAIT-1 if we closed first, its CLOSING/LAST-ACK if the peer did. Both + // of the latter are `last_ack` here: they differ only in which ACK is still owed, and + // `closeCheck` settles that from the sequence numbers. + self.tcp.state = if (self.tcp.peer_fin) .last_ack else .fin_wait_1; + } + } + + /// Half-close: everything we mean to send has been sent, so send FIN once the data is out. + fn tcpFinish(self: *Stack) void { + if (self.tcp.fin_queued) return; + self.tcp.fin_queued = true; + self.tcpSendData(); + } + + /// Both directions closed and our FIN acknowledged: nothing is left in flight, so the + /// connection block can be released after TIME_WAIT. Called once at the end of every segment, + /// which covers both orders of arrival - the peer's FIN then its ACK, or the reverse. + fn closeCheck(self: *Stack) void { + switch (self.tcp.state) { + .fin_wait_1, .fin_wait_2, .last_ack => {}, + else => return, + } + if (!self.tcp.peer_fin) return; + if (self.tcp.snd_una != self.tcp.snd_nxt) return; + self.tcp.state = .time_wait; + self.tcp.rto_deadline_ms = 0; + self.tcp.close_deadline_ms = self.now_ms + tcp_time_wait_ms; + } + + fn tcpInput(self: *Stack, src: Ip4, dst: Ip4, seg: []const u8) void { + if (seg.len < tcp.hlen) { + self.counters.rx_dropped += 1; + return; + } + const hf = rd16(seg, tcp.hdrlen_flags); + const hlen = @as(usize, hf >> 12) * 4; + if (hlen < tcp.hlen or hlen > seg.len) { + self.counters.rx_dropped += 1; + return; + } + if (!transportChecksumOk(src, dst, .tcp, seg, rd16(seg, tcp.chksum))) { + self.counters.checksum_bad += 1; + return; + } + const flags: u8 = @truncate(hf & 0x3f); + const sport = rd16(seg, tcp.src_port); + const dport = rd16(seg, tcp.dst_port); + + if (self.tcp.state == .closed or + dport != self.tcp.local_port or + sport != self.tcp.peer_port or + !std.mem.eql(u8, &src, &self.tcp.peer_ip)) + { + // Not for our one connection. A real stack would RST; a client with no listening port + // gains nothing by telling a scanner it is there. + self.counters.rx_dropped += 1; + return; + } + self.counters.tcp_rx += 1; + + const seq = rd32(seg, tcp.seq); + const ackno = rd32(seg, tcp.ack); + const data = seg[hlen..]; + + if (flags & tcp.rst != 0) { + self.counters.tcp_rst_rx += 1; + // RFC 5961 3: only a RST whose sequence number is the next one expected may tear the + // connection down. Anything else gets a challenge ACK, which is also what stops a + // blind off-path reset. + if (self.tcp.state == .syn_sent) { + // In SYN-SENT the RST is validated by its ACK instead: there is no rcv_nxt yet. + if (flags & tcp.ack_f != 0 and ackno == self.tcp.snd_nxt) self.tcpAbort(error.ConnectionReset, false); + return; + } + if (seq == self.tcp.rcv_nxt) { + self.tcpAbort(error.ConnectionReset, false); + } else { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + return; + } + + if (self.tcp.state == .syn_sent) { + if (flags & tcp.syn == 0) { + self.counters.rx_dropped += 1; + return; + } + if (flags & tcp.ack_f == 0) { + // A simultaneous open. Nothing on the other end of an HTTP GET does this. + self.counters.rx_dropped += 1; + return; + } + if (ackno != self.tcp.iss +% 1) { + // Not acknowledging our SYN: an old duplicate. RFC 793 says reset it. + _ = self.emitTcp(tcp.rst, ackno, &.{}, false); + return; + } + self.tcp.rcv_nxt = seq +% 1; + self.tcp.snd_una = ackno; + self.tcp.snd_wnd = rd16(seg, tcp.window); + self.tcp.snd_mss = parseMss(seg[tcp.hlen..hlen]) orelse tcp_default_mss; + self.tcp.state = .established; + self.tcp.rto_ms = tcp_rto_initial_ms; + self.tcp.retries = 0; + // The ACK completing the handshake carries the first data segment, which is one frame + // saved and what every other stack does. + self.tcp.rto_deadline_ms = 0; + self.tcpSendData(); + if (self.tcp.snd_nxt == self.tcp.snd_una) { + // Nothing to send yet; the handshake still needs acknowledging. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + return; + } + + // A duplicate SYN in an established connection is either a retransmitted SYN whose ACK was + // lost - answer with an ACK - or an attack. Never a reason to re-open. + if (flags & tcp.syn != 0 and seqLt(seq, self.tcp.rcv_nxt)) { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + return; + } + + if (flags & tcp.ack_f != 0) self.tcpAck(ackno, rd16(seg, tcp.window)); + + // ---- receive side + var payload = data; + var accept = false; + if (payload.len != 0) { + if (seqLe(seq, self.tcp.rcv_nxt) and seqGt(seq +% @as(u32, @intCast(payload.len)), self.tcp.rcv_nxt)) { + // Overlaps what we already have: trim the duplicate prefix. A retransmission after + // a lost ACK arrives exactly like this, and rejecting it would deadlock. + const skip: usize = self.tcp.rcv_nxt -% seq; + payload = payload[skip..]; + accept = true; + } else if (seqLe(seq +% @as(u32, @intCast(payload.len)), self.tcp.rcv_nxt)) { + // Wholly old. Re-acknowledge so the peer stops. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + return; + } else { + // Out of order, and there is nowhere to keep it. The duplicate ACK below is the + // signal that makes the peer resend. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + return; + } + } + + if (accept) { + // Never accept more than the window we advertised. + const room = self.rcvWindow(); + if (payload.len > room) payload = payload[0..room]; + self.tcp.rcv_nxt +%= @intCast(payload.len); + // Consuming the data may itself put a segment on the wire - completing the body sends + // our FIN - and every segment carries `rcv_nxt`, so a separate ACK would be a wasted + // frame. Counting is the honest way to know: anything emitted has already acknowledged + // this data, and nothing emitted means we still owe an ACK. + const tx_before = self.counters.tcp_tx; + self.httpOnData(payload); + if (self.tcp.state == .closed) return; // httpOnData failed and aborted + if (self.counters.tcp_tx == tx_before) { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + } + + // ---- FIN, in order only. An out-of-order FIN names a sequence number beyond data we have + // not seen, and honouring it would close the connection over a hole. + if (flags & tcp.fin != 0) { + const fin_seq = seq +% @as(u32, @intCast(data.len)); + const in_order = fin_seq == self.tcp.rcv_nxt; + // A FIN we have already consumed, arriving again because our ACK was lost. It must be + // re-acknowledged or the peer retransmits until it gives up and resets. + const duplicate = self.tcp.peer_fin and fin_seq +% 1 == self.tcp.rcv_nxt; + if (in_order and !self.tcp.peer_fin) { + self.tcp.rcv_nxt +%= 1; + self.tcp.peer_fin = true; + self.httpOnEof(); + } + if (in_order or duplicate) { + // Our own FIN, if it has not gone yet, acknowledges the peer's on the way out. + const tx_before = self.counters.tcp_tx; + self.tcpFinish(); + if (self.counters.tcp_tx == tx_before) { + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + } + } + } + + self.closeCheck(); + } + + fn tcpAck(self: *Stack, ackno: u32, window: u16) void { + // An ACK ahead of what we sent is invalid; an old one is a duplicate. + if (seqGt(ackno, self.tcp.snd_nxt)) return; + self.tcp.snd_wnd = window; + if (seqLe(ackno, self.tcp.snd_una)) { + // Duplicate ACK. With one segment in flight there is nothing to fast-retransmit. + return; + } + self.tcp.snd_una = ackno; + self.tcp.retries = 0; + self.tcp.rto_ms = tcp_rto_initial_ms; + if (self.tcp.snd_una == self.tcp.snd_nxt) { + self.tcp.rto_deadline_ms = 0; // nothing outstanding + } else { + self.armRto(); + } + if (self.tcp.state == .fin_wait_1 and self.tcp.snd_una == self.tcp.snd_nxt) { + self.tcp.state = .fin_wait_2; + self.tcp.close_deadline_ms = self.now_ms + tcp_fin_wait2_ms; + } + // Window opened or data acknowledged: there may be more to send. + self.tcpSendData(); + } + + /// RFC 793 3.1 option format: kind, then for kinds above 1 a length byte covering both. + fn parseMss(opts: []const u8) ?u16 { + var i: usize = 0; + while (i < opts.len) { + const kind = opts[i]; + if (kind == tcp.opt_end) return null; + if (kind == tcp.opt_nop) { + i += 1; + continue; + } + if (i + 2 > opts.len) return null; + const len = opts[i + 1]; + if (len < 2 or i + len > opts.len) return null; + if (kind == tcp.opt_mss and len == 4) { + const v = rd16(opts, i + 2); + // Below RFC 1122's floor a peer's MSS is not believable; above our MTU it cannot + // be honoured anyway. + return @min(@max(v, 64), tcp_mss); + } + i += len; + } + return null; + } + + fn tcpTick(self: *Stack) void { + switch (self.tcp.state) { + .closed => {}, + .arp_wait => { + if (self.arpLookup(self.tcpNextHop())) |_| { + self.tcpSendSyn(); + return; + } + if (self.arp_retry_ms != 0 and self.now_ms < self.arp_retry_ms) return; + if (self.arp_tries >= arp_max_tries) { + self.tcpAbort(error.HostUnreachable, false); + return; + } + self.arpRequest(self.tcpNextHop()); + self.arp_tries += 1; + self.arp_retry_ms = self.now_ms + arp_retry_ms; + }, + .fin_wait_2 => { + // Our FIN is acknowledged and nothing is outstanding, so there is no RTO to run: + // the only thing left is the peer's FIN, and this is how long we wait for it. + if (self.now_ms >= self.tcp.close_deadline_ms) self.tcp.state = .closed; + }, + .time_wait => { + if (self.now_ms >= self.tcp.close_deadline_ms) self.tcp.state = .closed; + }, + else => { + if (self.tcp.rto_deadline_ms == 0) return; + if (self.now_ms < self.tcp.rto_deadline_ms) return; + if (self.tcp.retries >= tcp_max_retries) { + self.tcpAbort(error.TimedOut, true); + return; + } + self.tcp.retries += 1; + self.counters.tcp_retx += 1; + // Exponential backoff, RFC 6298 5.5. + self.tcp.rto_ms = @min(self.tcp.rto_ms * 2, tcp_rto_max_ms); + self.tcpRetransmit(); + }, + } + } + + fn tcpNextHop(self: *Stack) Ip4 { + return if (self.onLink(self.tcp.peer_ip)) self.tcp.peer_ip else self.gw; + } + + /// Go back to `snd_una` and send again. With one segment in flight this is the whole of + /// retransmission: there is no queue to walk and no partial-ACK case to handle. + fn tcpRetransmit(self: *Stack) void { + const una = self.tcp.snd_una; + if (una == self.tcp.iss) { + // The SYN. Its MSS option must be repeated: a peer that only ever sees the + // retransmission would otherwise assume 536. + self.tcp.snd_nxt = self.tcp.iss; + self.tcpSendSyn(); + return; + } + const end = self.tcp.dataEnd(); + if (seqLt(una, end)) { + self.tcp.snd_nxt = una; + self.tcpSendData(); + return; + } + if (self.tcp.fin_queued and una == end) { + self.tcp.snd_nxt = una; + // `tcpSendData` re-sends the FIN and re-arms, but it refuses to run in FIN_WAIT_1 + // (which is where a lost FIN leaves us), so the segment is emitted directly. + _ = self.emitTcp(tcp.fin | tcp.ack_f, una, &.{}, false); + self.tcp.snd_nxt = una +% 1; + self.armRto(); + return; + } + // Nothing identifiable outstanding: a bare ACK, which costs one frame and cannot hurt. + _ = self.emitTcp(tcp.ack_f, self.tcp.snd_nxt, &.{}, false); + self.armRto(); + } + + // ================================================================================ HTTP + + /// Fetch `path` from `host:port` over HTTP/1.1 and write the body to `out`, sending the + /// address literal as the `Host:` header. Exactly `httpGetHost(host, null, ...)`; see there + /// for the protocol, which is the whole of how this is used. + pub fn httpGet(self: *Stack, host: [4]u8, port: u16, path: []const u8, out: []u8) HttpError!usize { + return self.httpGetHost(host, null, port, path, out); + } + + /// Fetch `path` from `host:port` over HTTP/1.1 and write the body to `out`. + /// + /// `name` is the `Host:` header. `null` sends the address literal - `Host: 192.168.1.90` - + /// which is right for a bare address and is what `httpGet` does. A name is what a + /// name-based virtual host requires: one address behind a CDN serves thousands of sites and + /// picks between them on this header alone, so `Host: 104.21.46.8` gets the CDN's own error + /// page and never the site. The address is still where the connection goes; the name only + /// ever appears in the header, and nothing here resolves it - `resolve` does that, and the + /// two are separate because a caller may have the address already. + /// + /// The port is appended as `:port` only when it is not 80, name or no name. RFC 7230 5.4. + /// + /// **This does not block, and it is not a one-shot call.** There is no `std.Io` here and no + /// clock, so there is nothing for a blocking call to block on: the frames that carry the + /// response arrive through `onFrame` and time advances through `tick`, both of which are the + /// caller's to drive. So the first call starts the request and returns `error.WouldBlock`, and + /// the caller keeps driving and keeps calling with the same arguments until it returns a length: + /// + /// while (true) { + /// stack.tick(hal.systimer.millis()); + /// while (transport.next()) |frame| stack.onFrame(frame); + /// if (stack.httpGetHost(addr, "0x4200.cafe", 80, "/", &buf)) |n| break :done buf[0..n] + /// else |e| if (e != error.WouldBlock) return e; + /// } + /// + /// `out` is borrowed until the request completes: it is written to from inside `onFrame` as the + /// body arrives, so it must not move or be reused meanwhile. Calling with different arguments + /// while a request is in flight returns `error.Busy` rather than quietly abandoning the first, + /// and `name` is one of those arguments: two requests to one address for one path but + /// different virtual hosts are different requests. + /// + /// `Content-Length` is honoured, and so is `Transfer-Encoding: chunked` - the body handed back + /// is decoded, with no framing bytes in it. A response with neither ends at the peer's FIN, + /// which is why the request says `Connection: close`. + pub fn httpGetHost( + self: *Stack, + host: [4]u8, + name: ?[]const u8, + port: u16, + path: []const u8, + out: []u8, + ) HttpError!usize { + // The name is folded into the path hash rather than given a field of its own: `Stack` has + // a 4 KiB budget, and what this has to distinguish is "the same call again" from "a + // different call", which a hash does exactly. Seeding with the name's hash rather than + // concatenating keeps `null` (seed 0) distinct from any name, including the empty one. + const req_hash = std.hash.Wyhash.hash( + if (name) |nm| std.hash.Wyhash.hash(0x486f_7374, nm) else 0, + path, + ); + switch (self.http.phase) { + .idle => {}, + .head, .body => { + if (!std.mem.eql(u8, &self.http.req_host, &host) or + self.http.req_port != port or + self.http.req_hash != req_hash or + self.http.out.ptr != out.ptr or + self.http.out.len != out.len) return error.Busy; + return error.WouldBlock; + }, + .complete => { + const n = self.http.out_len; + self.http.phase = .idle; + return n; + }, + .failed => { + const e = self.http.err; + self.http.phase = .idle; + return e; + }, + } + + if (self.addr == null) return error.NoAddress; + + // The request, built once into the TCP send buffer where it stays until acknowledged. + var w: RequestWriter = .{ .buf = &self.tcp.tx }; + w.str("GET "); + w.str(if (path.len == 0) "/" else path); + w.str(" HTTP/1.1\r\nHost: "); + if (name) |nm| w.str(nm) else w.ipv4(host); + if (port != 80) { + w.str(":"); + w.dec(port); + } + // Connection: close is not politeness, it is the framing: it is what makes a response with + // no Content-Length terminable, and it is what makes the peer's FIN the end of the body. + w.str("\r\nUser-Agent: zig-p4/0.1\r\nAccept: */*\r\nConnection: close\r\n\r\n"); + if (w.overflow) return error.RequestTooLong; + + self.http = .{ + .phase = .head, + .out = out, + .req_host = host, + .req_port = port, + .req_hash = req_hash, + }; + self.tcpConnect(host, port); + self.tcp.tx_len = w.i; + self.tcp.fin_queued = false; + // A MAC address may already be known, in which case the SYN goes out now rather than one + // `tick` later. + if (self.arpLookup(self.tcpNextHop()) != null) { + self.tcpSendSyn(); + } else { + self.arpRequest(self.tcpNextHop()); + self.arp_tries = 1; + self.arp_retry_ms = self.now_ms + arp_retry_ms; + } + return error.WouldBlock; + } + + /// A bounds-checked append into a fixed buffer. Overflow is recorded, not asserted: a caller's + /// long path is a request error, not a bug in this file. + const RequestWriter = struct { + buf: []u8, + i: usize = 0, + overflow: bool = false, + + fn str(self: *RequestWriter, s: []const u8) void { + if (self.overflow or self.i + s.len > self.buf.len) { + self.overflow = true; + return; + } + @memcpy(self.buf[self.i..][0..s.len], s); + self.i += s.len; + } + fn dec(self: *RequestWriter, v: u32) void { + var tmp: [10]u8 = undefined; + var n: usize = 0; + var x = v; + while (true) { + tmp[n] = '0' + @as(u8, @intCast(x % 10)); + n += 1; + x /= 10; + if (x == 0) break; + } + while (n > 0) { + n -= 1; + self.str(tmp[n .. n + 1]); + } + } + fn ipv4(self: *RequestWriter, a: Ip4) void { + for (a, 0..) |b, k| { + if (k != 0) self.str("."); + self.dec(b); + } + } + }; + + /// Fail the request and reset the connection. The phase is set before `tcpAbort`, which would + /// otherwise overwrite `err` with its own argument on the way past. + fn httpFail(self: *Stack, e: HttpError) void { + self.http.phase = .failed; + self.http.err = e; + self.tcpAbort(e, true); + } + + /// In-order TCP payload. Called from inside `onFrame`. + fn httpOnData(self: *Stack, bytes: []const u8) void { + var rest = bytes; + if (self.http.phase == .head) { + const room = http_head_max - self.http.head_len; + const n = @min(room, rest.len); + @memcpy(self.http.head[self.http.head_len..][0..n], rest[0..n]); + const scan_from = self.http.head_len -| 3; + self.http.head_len += n; + rest = rest[n..]; + + const blank = std.mem.indexOfPos(u8, self.http.head[0..self.http.head_len], scan_from, "\r\n\r\n") orelse { + if (self.http.head_len == http_head_max) self.httpFail(error.HttpHeadersTooLong); + return; + }; + const head_end = blank + 4; + // Anything the head buffer swallowed past the blank line is body. This is the case a + // test has to cover deliberately, because it only happens when a segment boundary does + // not coincide with the end of the headers - which on a real server is most of the time. + const spill = self.http.head[head_end..self.http.head_len]; + self.parseHead(self.http.head[0..blank]) catch |e| { + self.httpFail(e); + return; + }; + self.http.phase = .body; + // `spill` aliases `self.http.head`, and `httpBody` only ever writes to `self.http.out`, + // so passing it through is safe. Copy first if that ever stops being true. + // + // It is called unconditionally, even when `spill` is empty: that is what completes a + // `Content-Length: 0` response, whose body is over the moment its headers are. + self.httpBody(spill); + if (self.http.phase != .body) return; + } + if (rest.len != 0) self.httpBody(rest); + } + + /// Status line and headers, without the terminating blank line. + fn parseHead(self: *Stack, head: []const u8) HttpError!void { + var lines = std.mem.splitSequence(u8, head, "\r\n"); + const status_line = lines.next() orelse return error.HttpMalformed; + // "HTTP/1.1 200 OK": version, space, three digits. + if (status_line.len < 12) return error.HttpMalformed; + if (!std.mem.startsWith(u8, status_line, "HTTP/1.")) return error.HttpMalformed; + if (status_line[8] != ' ') return error.HttpMalformed; + var code: u16 = 0; + for (status_line[9..12]) |c| { + if (c < '0' or c > '9') return error.HttpMalformed; + code = code * 10 + (c - '0'); + } + self.http.status = code; + self.http.content_length = null; + self.http.chunked = false; + + while (lines.next()) |line| { + if (line.len == 0) continue; + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const name = line[0..colon]; + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + // RFC 7230 3.2: field names are case-insensitive. Servers vary, and a stack that + // compares them exactly works against nginx and fails against something else. + if (std.ascii.eqlIgnoreCase(name, "content-length")) { + self.http.content_length = std.fmt.parseInt(usize, value, 10) catch + return error.HttpMalformed; + } else if (std.ascii.eqlIgnoreCase(name, "transfer-encoding")) { + // RFC 7230 3.3.1: the final coding decides the framing. Exactly two are + // understood - `chunked`, which frames the body, and `identity`, which does not - + // and a list, or a coding that transforms the bytes, is refused. Guessing at + // `gzip` would hand the caller compressed data and call it a body. + if (std.ascii.eqlIgnoreCase(value, "chunked")) { + self.http.chunked = true; + } else if (!std.ascii.eqlIgnoreCase(value, "identity")) { + return error.UnsupportedTransferEncoding; + } + } + } + if (self.http.chunked) { + // RFC 7230 3.3.3 case 3: when both are present the chunked framing wins and + // `Content-Length` must be ignored - it is the classic request-smuggling + // disagreement, and a response that carries both is not to be believed twice. + self.http.content_length = null; + self.http.chunk = .size; + self.http.chunk_left = 0; + self.http.chunk_digit = false; + self.http.chunk_skip = 0; + } + // A response whose body cannot possibly fit is refused now rather than after copying most + // of it: the caller gets a clean error instead of a truncated buffer. A chunked response + // announces no total, so its equivalent check is per chunk, in `httpChunkedBody`. + if (self.http.content_length) |len| { + if (len > self.http.out.len) return error.StreamTooLong; + } + } + + fn httpBody(self: *Stack, bytes: []const u8) void { + if (self.http.chunked) return self.httpChunkedBody(bytes); + var b = bytes; + if (self.http.content_length) |len| { + const want = len - self.http.out_len; + if (b.len > want) b = b[0..want]; + } + if (self.http.out_len + b.len > self.http.out.len) { + self.httpFail(error.StreamTooLong); + return; + } + @memcpy(self.http.out[self.http.out_len..][0..b.len], b); + self.http.out_len += b.len; + if (self.http.content_length) |len| { + if (self.http.out_len >= len) self.httpComplete(); + } + } + + /// Charge `n` bytes against the framing budget. False means the request has been failed and + /// the decoder must stop. + fn chunkSkip(self: *Stack, n: usize) bool { + const total = @as(usize, self.http.chunk_skip) + n; + if (total > http_framing_max) { + self.httpFail(error.HttpHeadersTooLong); + return false; + } + self.http.chunk_skip = @intCast(total); + return true; + } + + /// RFC 7230 4.1 chunked decoding, resumable between any two bytes. + /// + /// The decoder's whole position lives in `http.chunk`, `chunk_left`, `chunk_digit` and + /// `chunk_skip`, and `bytes` is whatever the last segment happened to carry. Nothing is + /// buffered and nothing is looked ahead at: a size split across two segments accumulates a + /// digit at a time, a CRLF split across two segments is two states, and a chunk's data is + /// copied out as it arrives however it is cut up. That is not a hypothetical - a 1,460-byte + /// segment ends where the server's writes ended, which is nowhere in particular. + /// + /// `out` receives decoded data only. No size, no extension, no CRLF and no trailer byte is + /// ever copied into it, and every failure is a named error rather than a short body. + fn httpChunkedBody(self: *Stack, bytes: []const u8) void { + var b = bytes; + while (b.len != 0) { + switch (self.http.chunk) { + .size => { + const c = b[0]; + const digit: ?u8 = switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => null, + }; + b = b[1..]; + if (digit) |d| { + // Checked, not truncated: a size that does not fit `usize` is a malformed + // message, and wrapping it would turn a hostile header into a short read + // that looks like a complete body. + if (self.http.chunk_left > (std.math.maxInt(usize) - @as(usize, d)) / 16) { + self.httpFail(error.HttpChunkMalformed); + return; + } + self.http.chunk_left = self.http.chunk_left * 16 + d; + self.http.chunk_digit = true; + continue; + } + // RFC 7230 4.1 is `1*HEXDIG`. Without this an empty line reads as a chunk of + // size zero, which is the terminator, which ends the body early. + if (!self.http.chunk_digit) { + self.httpFail(error.HttpChunkMalformed); + return; + } + self.http.chunk_skip = 0; + switch (c) { + ';' => self.http.chunk = .ext, + '\r' => self.http.chunk = .size_lf, + else => { + self.httpFail(error.HttpChunkMalformed); + return; + }, + } + }, + .ext => { + // chunk-ext is skipped whole: nothing here depends on one, so the only thing + // that matters is finding the CR that ends the header - possibly not in this + // segment at all. + const cr = std.mem.indexOfScalar(u8, b, '\r'); + const n = cr orelse b.len; + if (!self.chunkSkip(n)) return; + b = b[n..]; + if (cr != null) { + b = b[1..]; + self.http.chunk = .size_lf; + } + }, + .size_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + if (self.http.chunk_left == 0) { + // The zero-length chunk. What follows is the trailer section, and the + // body is not complete until its final CRLF. + self.http.chunk_skip = 0; + self.http.chunk = .trailer; + } else { + // Refused on the header rather than part-way through the copy, which is + // what `Content-Length` gets: the caller sees the error before the buffer + // has been half filled with a body it will never be given. + if (self.http.chunk_left > self.http.out.len - self.http.out_len) { + self.httpFail(error.StreamTooLong); + return; + } + self.http.chunk = .data; + } + }, + .data => { + // In bounds by construction: `.size_lf` refused any chunk larger than the room + // left, and this only ever takes `chunk_left` of it. + const n = @min(self.http.chunk_left, b.len); + @memcpy(self.http.out[self.http.out_len..][0..n], b[0..n]); + self.http.out_len += n; + self.http.chunk_left -= n; + b = b[n..]; + if (self.http.chunk_left == 0) self.http.chunk = .data_cr; + }, + .data_cr => { + if (b[0] != '\r') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + self.http.chunk = .data_lf; + }, + .data_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + // `.data` is only ever left with the chunk exhausted, so the accumulator the + // next size builds in already reads zero and is not re-zeroed here. Asserted + // rather than assumed: re-zeroing would be dead code that hides the day the + // invariant stops holding, and a stale count would be silent. + assert(self.http.chunk_left == 0); + self.http.chunk_digit = false; + self.http.chunk = .size; + }, + .trailer => { + if (!self.chunkSkip(1)) return; + const cr = b[0] == '\r'; + b = b[1..]; + self.http.chunk = if (cr) .end_lf else .trailer_line; + }, + .trailer_line => { + const cr = std.mem.indexOfScalar(u8, b, '\r'); + const n = cr orelse b.len; + if (!self.chunkSkip(n)) return; + b = b[n..]; + if (cr != null) { + b = b[1..]; + self.http.chunk = .trailer_lf; + } + }, + .trailer_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + b = b[1..]; + self.http.chunk = .trailer; + }, + .end_lf => { + if (b[0] != '\n') { + self.httpFail(error.HttpChunkMalformed); + return; + } + self.httpComplete(); + // Anything after the final CRLF belongs to a response this connection will + // never ask for: `Connection: close` was sent, and the FIN follows. + return; + }, + } + } + } + + fn httpComplete(self: *Stack) void { + self.http.phase = .complete; + // The body is in hand; close our half. Reading further would only cost frames. + self.tcpFinish(); + } + + /// The peer closed. Whether that completes the response depends on the framing. + fn httpOnEof(self: *Stack) void { + switch (self.http.phase) { + .body => { + if (self.http.chunked) { + // The zero-length chunk and its trailer never arrived. RFC 7230 4.1 makes + // them the framing, so a close before them is a truncated body, however many + // whole chunks came first - reporting what did arrive would be reporting a + // prefix as the whole. + self.http.phase = .failed; + self.http.err = error.ConnectionClosed; + } else if (self.http.content_length) |len| { + if (self.http.out_len >= len) { + self.http.phase = .complete; + } else { + // Fewer body bytes than Content-Length promised. + self.http.phase = .failed; + self.http.err = error.ConnectionClosed; + } + } else { + // No Content-Length: the FIN *is* the framing (RFC 7230 3.3.3 case 7). + self.http.phase = .complete; + } + }, + .head => { + self.http.phase = .failed; + self.http.err = error.ConnectionClosed; + }, + else => {}, + } + } + + // ================================================================================ tick + + /// Advance time. Drives DHCP retransmission and renewal, ARP resolution and TCP + /// retransmission. `now_ms` must be monotonic; it need not start at zero and it need not be + /// called at any particular rate, but nothing times out between calls, so a 47-second TCP + /// deadline needs ticks more often than every 47 seconds to be observed on time. + pub fn tick(self: *Stack, now_ms: u64) void { + self.now_ms = now_ms; + // Stir. The MAC alone would make every boot draw the same transaction ids, initial sequence + // numbers and ephemeral ports, which is how two runs of the same firmware end up accepting + // each other's stale DHCP replies. `now_ms` is the only outside input this file has, and a + // caller that ticks a real timer before starting DHCP therefore gets a different sequence + // every boot. Still not a source of security-relevant randomness - see `entropy`. + self.entropy ^= now_ms *% 0x9e37_79b9_7f4a_7c15; + self.dhcpTick(); + self.dnsTick(); + self.tcpTick(); + } +}; + +// The footprint claim, enforced at compile time, so a buffer that grows fails the build rather than +// the board. +// +// 4 KiB is the budget and it is measured, not guessed: the image has ~128 KB of L2MEM, nothing +// initialises the 32 MB of PSRAM, and ESP-Hosted's queues and its seven task stacks are competing +// for the same space. `Stack` is 3,576 bytes today. The failure this prevents is a stack overflow +// on a part with no debugger, which is indistinguishable from the SDIO bus not coming up. +comptime { + // 6 KiB, raised from 4 KiB when `http_head_max` went from 1024 to 2048 to fit a real CDN + // response head (1043 bytes measured). This is a regression alarm, not a budget: it exists so a + // buffer cannot grow unnoticed, and moving it is a decision to be justified at the buffer that + // caused it - which the comment on `http_head_max` does. The image's real constraint is the + // ~128 KB of L2MEM, and the heap in examples/http.zig was reduced by the same amount to pay for + // this. + if (Stack.footprint > 6 * 1024) @compileError(std.fmt.comptimePrint( + "ip.Stack is {d} bytes, over the 4 KiB budget", + .{Stack.footprint}, + )); +} + +// The host tests live in `ip_test.zig` - 117 cases, and they are the correctness argument for this +// slice, since it is the one part of the P4 bring-up that can be proven without the board. They are +// in their own file because they are longer than the stack, and because the tests deliberately +// re-derive every header offset from the RFCs rather than importing the tables above: a test that +// shares the constant it is checking passes on a consistent misreading. +// +// This reference is what makes `zig build test` find them: build.zig runs `src/net/ip.zig` as a +// test root, and Zig only collects tests from files the root actually references. +test { + _ = @import("ip_test.zig"); +} diff --git a/src/net/ip_test.zig b/src/net/ip_test.zig new file mode 100644 index 0000000..3e4c1f7 --- /dev/null +++ b/src/net/ip_test.zig @@ -0,0 +1,3029 @@ +//! Host tests for the IPv4 stack. +//! +//! This is the one slice of the P4 bring-up that can be *proven* without the board, and this file is +//! the proof. The stack takes frames through `onFrame` and time through `tick`, so a network here is +//! a function that writes bytes by hand and reads back whatever the stack handed to its `send` +//! callback. Nothing is mocked, nothing is stubbed: the code under test is the code that will run on +//! the die, byte for byte. +//! +//! Two rules keep this honest: +//! +//! * **The headers are re-derived here.** These tests do not import `ip.zig`'s offset tables; they +//! write literal offsets taken from the RFCs and from lwIP's packed structs. A test that shared +//! the constant it was checking would pass on a consistent misreading of the RFC, which is +//! exactly the failure mode this stack has to avoid. Where the two transcriptions disagree, one +//! of them is wrong and the test says so. +//! * **Every checksum is verified, never merely computed.** A checksum built by the same helper +//! the stack uses would prove nothing. `verify` below sums the received bytes independently and +//! asserts the fold is zero, which is the property a peer's kernel will check. +//! +//! Run with: zig build test + +const std = @import("std"); +const testing = std.testing; +const ip = @import("ip.zig"); + +// ============================================================================ capture rig +// +// `Stack.init` takes `*const fn ([]const u8) void` - no context pointer - so the captured frames +// have to live somewhere a plain function can reach. That is a wart in the interface, not in the +// stack, and the cost is this file-scope buffer. + +const cap_max = 32; +var cap_bytes: [cap_max][ip.frame_max]u8 = undefined; +var cap_lens: [cap_max]usize = undefined; +var cap_n: usize = 0; +var cap_over: usize = 0; + +fn capture(frame: []const u8) void { + if (cap_n == cap_max) { + cap_over += 1; + return; + } + @memcpy(cap_bytes[cap_n][0..frame.len], frame); + cap_lens[cap_n] = frame.len; + cap_n += 1; +} + +fn clearCapture() void { + cap_n = 0; + cap_over = 0; +} + +fn sent(i: usize) []const u8 { + return cap_bytes[i][0..cap_lens[i]]; +} + +fn lastSent() []const u8 { + return sent(cap_n - 1); +} + +/// A stack with a MAC and an empty capture log. Every test starts here. +fn newStack() ip.Stack { + clearCapture(); + return .init(our_mac, capture); +} + +const our_mac: ip.Mac = .{ 0x40, 0x4c, 0xca, 0xfe, 0x00, 0x01 }; +const gw_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0x11, 0x22, 0x33 }; +const peer_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0xaa, 0xbb, 0xcc }; +const our_ip: ip.Ip4 = .{ 192, 168, 1, 42 }; +const gw_ip: ip.Ip4 = .{ 192, 168, 1, 1 }; +const mask24: ip.Ip4 = .{ 255, 255, 255, 0 }; +const peer_ip: ip.Ip4 = .{ 192, 168, 1, 90 }; +const off_net_ip: ip.Ip4 = .{ 93, 184, 216, 34 }; +const bcast_mac: ip.Mac = .{ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }; +/// RFC 826: the target hardware address of a request is "don't care". +const zero_mac: ip.Mac = .{ 0, 0, 0, 0, 0, 0 }; + +// ================================================================== independent primitives +// +// Header offsets written out again, from the RFCs. See the note at the top of the file. + +/// RFC 1071. Written differently from `ip.Checksum` on purpose: a `u32` accumulator over +/// `readInt`-free manual pairing, so a mistake in one is not a mistake in both. +fn sum16(bytes: []const u8) u32 { + var s: u32 = 0; + var i: usize = 0; + while (i + 1 < bytes.len) : (i += 2) { + s += (@as(u32, bytes[i]) << 8) | bytes[i + 1]; + } + if (i < bytes.len) s += @as(u32, bytes[i]) << 8; + while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16); + return s; +} + +/// The property every receiver relies on: a buffer that already contains its own checksum sums to +/// 0xffff, so the complement is zero. +fn verify(bytes: []const u8) !void { + try testing.expectEqual(@as(u32, 0xffff), sum16(bytes)); +} + +fn verifyTransport(src: ip.Ip4, dst: ip.Ip4, proto: u8, seg: []const u8) !void { + var ph: [12]u8 = undefined; + @memcpy(ph[0..4], &src); + @memcpy(ph[4..8], &dst); + ph[8] = 0; + ph[9] = proto; + std.mem.writeInt(u16, ph[10..12], @intCast(seg.len), .big); + var s = sum16(&ph) + sum16(seg); + while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16); + try testing.expectEqual(@as(u32, 0xffff), s); +} + +fn be16(b: []const u8, off: usize) u16 { + return std.mem.readInt(u16, b[off..][0..2], .big); +} +fn be32(b: []const u8, off: usize) u32 { + return std.mem.readInt(u32, b[off..][0..4], .big); +} +fn put16(b: []u8, off: usize, v: u16) void { + std.mem.writeInt(u16, b[off..][0..2], v, .big); +} +fn put32(b: []u8, off: usize, v: u32) void { + std.mem.writeInt(u32, b[off..][0..4], v, .big); +} + +/// A scratch frame under construction. `len` is the total frame length. +const Frame = struct { + buf: [ip.frame_max]u8 = undefined, + len: usize = 0, + + /// Ethernet II: 6 destination, 6 source, 2 ethertype. RFC 894 / lwIP `prot/ethernet.h:76-83`. + fn eth(self: *Frame, dst: ip.Mac, src: ip.Mac, ethertype: u16) void { + @memcpy(self.buf[0..6], &dst); + @memcpy(self.buf[6..12], &src); + put16(&self.buf, 12, ethertype); + self.len = 14; + } + + /// RFC 791 3.1. Fills the header and returns the payload slice to be written; the caller then + /// calls `sealIp`. + fn ip4(self: *Frame, src: ip.Ip4, dst: ip.Ip4, proto: u8, payload_len: usize) []u8 { + const h = self.buf[14..][0..20]; + h[0] = 0x45; + h[1] = 0; + put16(h, 2, @intCast(20 + payload_len)); + put16(h, 4, 0x1234); + put16(h, 6, 0); + h[8] = 64; + h[9] = proto; + put16(h, 10, 0); + @memcpy(h[12..16], &src); + @memcpy(h[16..20], &dst); + self.len = 14 + 20 + payload_len; + return self.buf[34 .. 34 + payload_len]; + } + + fn sealIp(self: *Frame) void { + const h = self.buf[14..][0..20]; + put16(h, 10, 0); + put16(h, 10, ~@as(u16, @truncate(sum16(h)))); + } + + /// Fill in a UDP or TCP checksum over the pseudo-header plus the segment. + fn sealTransport(self: *Frame, chksum_off: usize) void { + const h = self.buf[14..][0..20]; + const proto = h[9]; + const seg = self.buf[34..self.len]; + var ph: [12]u8 = undefined; + @memcpy(ph[0..4], h[12..16]); + @memcpy(ph[4..8], h[16..20]); + ph[8] = 0; + ph[9] = proto; + std.mem.writeInt(u16, ph[10..12], @intCast(seg.len), .big); + put16(seg, chksum_off, 0); + var s = sum16(&ph) + sum16(seg); + while (s >> 16 != 0) s = (s & 0xffff) + (s >> 16); + put16(seg, chksum_off, ~@as(u16, @truncate(s))); + self.sealIp(); + } + + fn bytes(self: *const Frame) []const u8 { + return self.buf[0..self.len]; + } +}; + +/// RFC 826 packet format, 28 bytes. lwIP `prot/etharp.h:86-96`. +fn arpFrame(opcode: u16, sha: ip.Mac, spa: ip.Ip4, tha: ip.Mac, tpa: ip.Ip4, eth_dst: ip.Mac) Frame { + var f: Frame = .{}; + f.eth(eth_dst, sha, 0x0806); + const a = f.buf[14..][0..28]; + put16(a, 0, 1); // hwtype: Ethernet + put16(a, 2, 0x0800); // proto: IPv4 + a[4] = 6; + a[5] = 4; + put16(a, 6, opcode); + @memcpy(a[8..14], &sha); + @memcpy(a[14..18], &spa); + @memcpy(a[18..24], &tha); + @memcpy(a[24..28], &tpa); + f.len = 14 + 28; + return f; +} + +/// RFC 792 echo. `payload` is the data after the 8-byte header. +fn icmpEchoFrame(src: ip.Ip4, dst: ip.Ip4, id: u16, seq: u16, payload: []const u8) Frame { + var f: Frame = .{}; + f.eth(our_mac, peer_mac, 0x0800); + const p = f.ip4(src, dst, 1, 8 + payload.len); + p[0] = 8; // echo request + p[1] = 0; + put16(p, 2, 0); + put16(p, 4, id); + put16(p, 6, seq); + @memcpy(p[8..], payload); + // ICMP has no pseudo-header (RFC 792): the checksum covers the message alone. + put16(p, 2, ~@as(u16, @truncate(sum16(p)))); + f.sealIp(); + return f; +} + +// ================================================================================= checksum + +test "RFC 1071 worked example" { + // RFC 1071 section 3, the byte sequence spelled out in the document's own figure: + // 00 01 f2 03 f4 f5 f6 f7 -> sum ddf2, checksum 220d + const data = [_]u8{ 0x00, 0x01, 0xf2, 0x03, 0xf4, 0xf5, 0xf6, 0xf7 }; + try testing.expectEqual(@as(u32, 0xddf2), sum16(&data)); + try testing.expectEqual(@as(u16, 0x220d), ip.checksum(&data)); +} + +test "checksum: incremental feeding matches contiguous, including at odd boundaries" { + // The bug this catches is a chunk of odd length leaving the high byte of a word unaccounted + // for. Splitting at every possible offset is cheap and total. + const data = [_]u8{ 0x45, 0x00, 0x00, 0x54, 0xab, 0xcd, 0x40, 0x00, 0x40, 0x01, 0x00, 0x00, 0xc0, 0xa8, 0x01, 0x2a, 0xc0, 0xa8, 0x01, 0x01, 0x7f }; + const want = ip.checksum(&data); + var split: usize = 0; + while (split <= data.len) : (split += 1) { + var c: ip.Checksum = .{}; + c.update(data[0..split]); + c.update(data[split..]); + try testing.expectEqual(want, c.final()); + } + // Three-way split too, so two consecutive odd chunks are exercised. + var i: usize = 0; + while (i < data.len) : (i += 1) { + var j: usize = i; + while (j < data.len) : (j += 1) { + var c: ip.Checksum = .{}; + c.update(data[0..i]); + c.update(data[i..j]); + c.update(data[j..]); + try testing.expectEqual(want, c.final()); + } + } +} + +test "checksum: an odd-length buffer is padded with a zero byte, not with the previous byte" { + // RFC 1071 section 1. A three-byte buffer must checksum as if it were four with a trailing 0. + const odd = [_]u8{ 0xde, 0xad, 0xbe }; + const padded = [_]u8{ 0xde, 0xad, 0xbe, 0x00 }; + try testing.expectEqual(ip.checksum(&padded), ip.checksum(&odd)); +} + +test "checksum: an all-zero buffer checksums to 0xffff, never to 0x0000" { + // A transmitted zero means "no checksum" in UDP, so the distinction is load-bearing. + const zeros: [20]u8 = @splat(0); + try testing.expectEqual(@as(u16, 0xffff), ip.checksum(&zeros)); +} + +test "checksum: RFC 768's transmitted zero is sent as 0xffff" { + // A UDP checksum field of zero means "not computed", so a datagram whose checksum genuinely + // works out to zero must transmit the arithmetically equivalent 0xffff instead. Tested on the + // helper because the case cannot be provoked by choosing DHCP option bytes: it depends on the + // whole datagram, headers included, summing to exactly 0xffff. + try testing.expectEqual(@as(u16, 0xffff), ip.udpChecksumOnWire(0)); + try testing.expectEqual(@as(u16, 0xffff), ip.udpChecksumOnWire(0xffff)); + try testing.expectEqual(@as(u16, 0x1234), ip.udpChecksumOnWire(0x1234)); +} + +test "checksum: a real IPv4 header verifies to zero once its own checksum is in place" { + var h = [_]u8{ 0x45, 0x00, 0x00, 0x3c, 0x1c, 0x46, 0x40, 0x00, 0x40, 0x06, 0x00, 0x00, 0xac, 0x10, 0x0a, 0x63, 0xac, 0x10, 0x0a, 0x0c }; + const c = ip.checksum(&h); + put16(&h, 10, c); + try verify(&h); + // And the classic published value for this header, from the Wikipedia/Comer worked example. + try testing.expectEqual(@as(u16, 0xb1e6), c); +} + +// ====================================================================================== ARP + +test "ARP: a request for our address is answered, and the reply is well formed" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + // setStatic announces; drop that so the reply is the only frame under test. + clearCapture(); + + var req = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(req.bytes()); + + try testing.expectEqual(@as(usize, 1), cap_n); + const r = lastSent(); + try testing.expectEqual(@as(usize, 42), r.len); + // Unicast back to the requester, not broadcast: a broadcast reply is legal but wasteful, and + // every stack on the segment would have to parse it. + try testing.expectEqualSlices(u8, &peer_mac, r[0..6]); + try testing.expectEqualSlices(u8, &our_mac, r[6..12]); + try testing.expectEqual(@as(u16, 0x0806), be16(r, 12)); + + const a = r[14..42]; + try testing.expectEqual(@as(u16, 1), be16(a, 0)); // hwtype Ethernet + try testing.expectEqual(@as(u16, 0x0800), be16(a, 2)); // proto IPv4 + try testing.expectEqual(@as(u8, 6), a[4]); + try testing.expectEqual(@as(u8, 4), a[5]); + try testing.expectEqual(@as(u16, 2), be16(a, 6)); // reply + try testing.expectEqualSlices(u8, &our_mac, a[8..14]); // sender hw = us + try testing.expectEqualSlices(u8, &our_ip, a[14..18]); // sender proto = us + try testing.expectEqualSlices(u8, &peer_mac, a[18..24]); // target hw = requester + try testing.expectEqualSlices(u8, &peer_ip, a[24..28]); +} + +test "ARP: a request for somebody else's address is ignored" { + var s = newStack(); + s.setStatic(our_ip, mask24, gw_ip); + clearCapture(); + var req = arpFrame(1, peer_mac, peer_ip, zero_mac, .{ 192, 168, 1, 77 }, bcast_mac); + s.onFrame(req.bytes()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "ARP: a malformed header is rejected on all four RFC 826 reception checks" { + const bad_fields = [_]struct { off: usize, val: u8 }{ + .{ .off = 1, .val = 2 }, // hwtype 2, not Ethernet + .{ .off = 3, .val = 0x06 }, // proto 0x0806, not IPv4 + .{ .off = 4, .val = 8 }, // hwlen 8 + .{ .off = 5, .val = 16 }, // protolen 16 + }; + for (bad_fields) |bad| { + var s = newStack(); + s.setStatic(our_ip, mask24, gw_ip); + clearCapture(); + var req = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + req.buf[14 + bad.off] = bad.val; + s.onFrame(req.bytes()); + try testing.expectEqual(@as(usize, 0), cap_n); + } +} + +test "ARP: setStatic announces the address gratuitously" { + var s = newStack(); + s.tick(500); + s.setStatic(our_ip, mask24, gw_ip); + try testing.expectEqual(@as(usize, 1), cap_n); + const g = lastSent(); + try testing.expectEqualSlices(u8, &bcast_mac, g[0..6]); + try testing.expectEqual(@as(u16, 0x0806), be16(g, 12)); + const a = g[14..42]; + try testing.expectEqual(@as(u16, 1), be16(a, 6)); // a request... + try testing.expectEqualSlices(u8, &our_ip, a[14..18]); // ...whose sender... + try testing.expectEqualSlices(u8, &our_ip, a[24..28]); // ...and target are both us +} + +test "ARP: a four-entry cache is not thrashed by unrelated broadcast traffic" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + + // Learn the gateway the legitimate way: it ARPs for us, we reply, and it goes in the cache. + var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + + // Now flood the segment with ARP between other hosts. None of it is addressed to us, so none + // of it may evict the gateway. + var k: u8 = 0; + while (k < 20) : (k += 1) { + var noise = arpFrame( + 1, + .{ 0x02, 0, 0, 0, 0, k }, + .{ 192, 168, 1, 100 + k }, + zero_mac, + .{ 192, 168, 1, 200 }, + bcast_mac, + ); + s.onFrame(noise.bytes()); + } + clearCapture(); + + // If the gateway survived, a datagram to an off-net address goes straight out to `gw_mac` + // instead of provoking an ARP request. + var echo = icmpEchoFrame(gw_ip, our_ip, 1, 1, "x"); + s.onFrame(echo.bytes()); + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqual(@as(u16, 0x0800), be16(lastSent(), 12)); // IPv4, not an ARP request + try testing.expectEqualSlices(u8, &gw_mac, lastSent()[0..6]); +} + +test "ARP: a cache entry ages out even while it is being used" { + // The bug this pins: refreshing an entry's timestamp on every lookup. It looks harmless and it + // means an entry kept alive by our own traffic is never re-resolved, so a gateway whose MAC + // changes is never noticed. + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + + // Keep using the entry, all the way past the 300 s age limit. + var now: u64 = 1000; + while (now < 400_000) : (now += 10_000) { + s.tick(now); + clearCapture(); + var echo = icmpEchoFrame(peer_ip, our_ip, 1, 1, "x"); + s.onFrame(echo.bytes()); + try testing.expectEqual(@as(usize, 1), cap_n); + } + // Past the limit the entry is gone: the reply is dropped and an ARP request goes in its place. + try testing.expectEqual(@as(u16, 0x0806), be16(lastSent(), 12)); + try testing.expectEqualSlices(u8, &peer_ip, lastSent()[14 + 24 ..][0..4]); +} + +test "ARP: a host that changes its MAC is followed" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + + // Same address, new hardware: a replaced router, or a VRRP failover. + const new_mac: ip.Mac = .{ 0x02, 0x00, 0x00, 0xde, 0xad, 0x01 }; + var again = arpFrame(1, new_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(again.bytes()); + clearCapture(); + + var echo = icmpEchoFrame(peer_ip, our_ip, 1, 1, "x"); + s.onFrame(echo.bytes()); + try testing.expectEqualSlices(u8, &new_mac, lastSent()[0..6]); +} + +test "IPv4: a received header carrying options is parsed by its own length field" { + // `ping -R` and any router-alert path produce these. A parser that assumes 20 bytes reads the + // options as the ICMP header and answers nonsense - or, worse, answers with the checksum + // covering the wrong bytes. + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + // 24-byte header: 20 plus a 4-byte NOP,NOP,NOP,END option block. + var f: Frame = .{}; + f.eth(our_mac, peer_mac, 0x0800); + const total = 24 + 8 + 4; + const h = f.buf[14..][0..24]; + h[0] = 0x46; // IPv4, 6 words of header + h[1] = 0; + put16(h, 2, total); + put16(h, 4, 0x1234); + put16(h, 6, 0); + h[8] = 64; + h[9] = 1; // ICMP + put16(h, 10, 0); + @memcpy(h[12..16], &peer_ip); + @memcpy(h[16..20], &our_ip); + h[20] = 1; // NOP + h[21] = 1; + h[22] = 1; + h[23] = 0; // END + put16(h, 10, ~@as(u16, @truncate(sum16(h)))); + const m = f.buf[14 + 24 ..][0 .. 8 + 4]; + m[0] = 8; + m[1] = 0; + put16(m, 2, 0); + put16(m, 4, 0x0102); + put16(m, 6, 0x0304); + @memcpy(m[8..], "wxyz"); + put16(m, 2, ~@as(u16, @truncate(sum16(m)))); + f.len = 14 + total; + s.onFrame(f.bytes()); + + try testing.expectEqual(@as(usize, 1), cap_n); + const r = lastSent(); + // The reply is emitted with a plain 20-byte header - nothing here generates options - and the + // echoed id, sequence and data prove the request's payload was found at the right offset. + try testing.expectEqual(@as(u8, 0x45), r[14]); + try verify(r[14..34]); + const e = r[34..]; + try testing.expectEqual(@as(u8, 0), e[0]); + try testing.expectEqual(@as(u16, 0x0102), be16(e, 4)); + try testing.expectEqual(@as(u16, 0x0304), be16(e, 6)); + try testing.expectEqualStrings("wxyz", e[8..12]); + try verify(e); +} + +// ===================================================================================== ICMP + +test "ICMP: an echo request is answered with a correct echo reply" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + clearCapture(); + // Teach the stack the peer's MAC by having it ARP for us first. + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + // The payload `ping` sends: 56 bytes, a timestamp then a counting pattern. + var payload: [56]u8 = undefined; + for (&payload, 0..) |*b, i| b.* = @intCast(i); + var req = icmpEchoFrame(peer_ip, our_ip, 0xbeef, 7, &payload); + s.onFrame(req.bytes()); + + try testing.expectEqual(@as(usize, 1), cap_n); + const r = lastSent(); + try testing.expectEqual(@as(usize, 14 + 20 + 8 + 56), r.len); + try testing.expectEqualSlices(u8, &peer_mac, r[0..6]); + try testing.expectEqual(@as(u16, 0x0800), be16(r, 12)); + + const h = r[14..34]; + try testing.expectEqual(@as(u8, 0x45), h[0]); + try testing.expectEqual(@as(u16, 20 + 8 + 56), be16(h, 2)); + try testing.expectEqual(@as(u8, 1), h[9]); // ICMP + // RFC 1122 3.2.1.7 recommends 64. A TTL of 1 is the failure that works on the bench and dies + // at the first router, which is the worst possible time to find out. + try testing.expectEqual(@as(u8, 64), h[8]); + // Don't Fragment: this stack neither fragments nor reassembles, so a router must not fragment + // what it cannot rebuild. + try testing.expectEqual(@as(u16, 0x4000), be16(h, 6)); + try testing.expectEqualSlices(u8, &our_ip, h[12..16]); // src and dst swapped + try testing.expectEqualSlices(u8, &peer_ip, h[16..20]); + try verify(h); // the IP header checksum, checked independently + + const m = r[34..]; + try testing.expectEqual(@as(u8, 0), m[0]); // echo reply + try testing.expectEqual(@as(u8, 0), m[1]); + try testing.expectEqual(@as(u16, 0xbeef), be16(m, 4)); // id echoed + try testing.expectEqual(@as(u16, 7), be16(m, 6)); // sequence echoed + try testing.expectEqualSlices(u8, &payload, m[8..]); + try verify(m); // and the ICMP checksum +} + +test "ICMP: a request with a bad IP header checksum is dropped and counted" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd"); + req.buf[14 + 10] ^= 0xff; // corrupt the IP header checksum + s.onFrame(req.bytes()); + try testing.expectEqual(@as(usize, 0), cap_n); + try testing.expectEqual(@as(u32, 1), s.counters.checksum_bad); +} + +test "ICMP: a request with a bad ICMP checksum is dropped and counted" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd"); + req.buf[34 + 2] ^= 0xff; // corrupt the ICMP checksum + s.onFrame(req.bytes()); + try testing.expectEqual(@as(usize, 0), cap_n); + try testing.expectEqual(@as(u32, 1), s.counters.checksum_bad); +} + +test "ICMP: a fragment is dropped rather than answered as a whole datagram" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_mac, peer_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd"); + put16(&req.buf, 14 + 6, 0x2000); // MF set + req.sealIp(); + s.onFrame(req.bytes()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "a frame addressed to another station is dropped" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + clearCapture(); + var req = icmpEchoFrame(peer_ip, our_ip, 1, 1, "abcd"); + req.buf[0] = 0x02; // not our MAC, not broadcast + s.onFrame(req.bytes()); + try testing.expectEqual(@as(usize, 0), cap_n); + try testing.expect(s.counters.rx_dropped >= 1); +} + +// ===================================================================================== DHCP +// +// RFC 2131. The synthetic server below is what a real one does with the fields that matter, and +// nothing else: no relay agent, no overload, no vendor options. + +/// Offsets into the BOOTP message, from RFC 2131 figure 1 / lwIP `prot/dhcp.h:50-91`. +const d = struct { + const op = 0; + const htype = 1; + const hlen = 2; + const xid = 4; + const secs = 8; + const flags = 10; + const ciaddr = 12; + const yiaddr = 16; + const siaddr = 20; + const chaddr = 28; + const cookie = 236; + const options = 240; +}; + +fn dhcpReply(kind: u8, xid: u32, yiaddr: ip.Ip4, server: ip.Ip4, opts: []const u8, dst_ip: ip.Ip4, dst_mac: ip.Mac) Frame { + var f: Frame = .{}; + f.eth(dst_mac, gw_mac, 0x0800); + const payload_len = 8 + d.options + 3 + opts.len + 1; + const p = f.ip4(server, dst_ip, 17, payload_len); + put16(p, 0, 67); // source port: DHCP server + put16(p, 2, 68); // destination port: DHCP client + put16(p, 4, @intCast(payload_len)); + put16(p, 6, 0); + const m = p[8..]; + @memset(m, 0); + m[d.op] = 2; // BOOTREPLY + m[d.htype] = 1; + m[d.hlen] = 6; + put32(m, d.xid, xid); + @memcpy(m[d.yiaddr..][0..4], &yiaddr); + @memcpy(m[d.siaddr..][0..4], &server); + @memcpy(m[d.chaddr..][0..6], &our_mac); + put32(m, d.cookie, 0x63825363); + m[d.options] = 53; // message type + m[d.options + 1] = 1; + m[d.options + 2] = kind; + @memcpy(m[d.options + 3 ..][0..opts.len], opts); + m[d.options + 3 + opts.len] = 255; // END + f.sealTransport(6); + return f; +} + +/// Option 1 (mask), 3 (router), 6 (DNS), 51 (lease), 54 (server id) for the network in the brief. +const standard_opts = [_]u8{ + 1, 4, 255, 255, 255, 0, // subnet mask /24 + 3, 4, 192, 168, 1, 1, // router + 6, 4, 192, 168, 1, 1, // DNS + 51, 4, 0, 0, 0x1c, 0x20, // lease 7200 s + 54, 4, 192, 168, 1, 1, // server identifier +}; + +fn findOption(msg: []const u8, want: u8) ?[]const u8 { + var i: usize = d.options; + while (i < msg.len) { + if (msg[i] == 255) return null; + if (msg[i] == 0) { + i += 1; + continue; + } + if (i + 2 > msg.len) return null; + const len = msg[i + 1]; + if (i + 2 + len > msg.len) return null; + if (msg[i] == want) return msg[i + 2 ..][0..len]; + i += 2 + len; + } + return null; +} + +/// The DHCP message inside a captured frame, and a few sanity checks that apply to all of them. +fn dhcpOut(frame: []const u8) ![]const u8 { + try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12)); + const h = frame[14..34]; + try testing.expectEqual(@as(u8, 17), h[9]); // UDP + try verify(h); + const seg = frame[34..]; + try testing.expectEqual(@as(u16, 68), be16(seg, 0)); // from the client port + try testing.expectEqual(@as(u16, 67), be16(seg, 2)); // to the server port + try testing.expectEqual(@as(u16, @intCast(seg.len)), be16(seg, 4)); + try verifyTransport(h[12..16].*, h[16..20].*, 17, seg); + const msg = seg[8..]; + try testing.expectEqual(@as(u8, 1), msg[d.op]); // BOOTREQUEST + try testing.expectEqual(@as(u8, 1), msg[d.htype]); // Ethernet + try testing.expectEqual(@as(u8, 6), msg[d.hlen]); + try testing.expectEqual(@as(u32, 0x63825363), be32(msg, d.cookie)); + try testing.expectEqualSlices(u8, &our_mac, msg[d.chaddr..][0..6]); + // RFC 951: a BOOTP message is at least 300 bytes. + try testing.expect(msg.len >= 300); + return msg; +} + +test "DHCP: a full DISCOVER / OFFER / REQUEST / ACK exchange binds the address" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); + + // ---- DISCOVER + try testing.expectEqual(@as(usize, 1), cap_n); + const disc_frame = sent(0); + // Broadcast at both layers: no address yet, so nothing else could work. + try testing.expectEqualSlices(u8, &bcast_mac, disc_frame[0..6]); + try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, disc_frame[14 + 12 ..][0..4]); + try testing.expectEqualSlices(u8, &.{ 255, 255, 255, 255 }, disc_frame[14 + 16 ..][0..4]); + const disc = try dhcpOut(disc_frame); + try testing.expectEqual(@as(u16, 0x8000), be16(disc, d.flags)); // ask for a broadcast reply + try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, disc[d.ciaddr..][0..4]); + try testing.expectEqualSlices(u8, &.{1}, findOption(disc, 53).?); // DHCPDISCOVER + try testing.expect(findOption(disc, 55) != null); // parameter request list + try testing.expect(findOption(disc, 57) != null); // maximum message size + // A DISCOVER must not claim an address or name a server. + try testing.expect(findOption(disc, 50) == null); + try testing.expect(findOption(disc, 54) == null); + const xid = be32(disc, d.xid); + + // ---- OFFER, unicast to the address about to be granted (RFC 2131 4.1 permits this, and it is + // the case that only works because `ip4Input` lets UDP through while unbound). + clearCapture(); + var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(offer.bytes()); + try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState()); + + // ---- REQUEST + try testing.expectEqual(@as(usize, 1), cap_n); + const req = try dhcpOut(sent(0)); + try testing.expectEqual(xid, be32(req, d.xid)); // same transaction + try testing.expectEqualSlices(u8, &.{3}, findOption(req, 53).?); // DHCPREQUEST + // RFC 2131 4.3.2: SELECTING carries the offered address in option 50 and the server it is + // accepting in option 54, and `ciaddr` stays zero. + try testing.expectEqualSlices(u8, &our_ip, findOption(req, 50).?); + try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?); + try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, req[d.ciaddr..][0..4]); + + // ---- ACK + clearCapture(); + var ack = dhcpReply(5, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(ack.bytes()); + + try testing.expectEqual(ip.DhcpState.bound, s.dhcpState()); + try testing.expectEqual(our_ip, s.ip().?); + try testing.expectEqual(mask24, s.netmask()); + try testing.expectEqual(gw_ip, s.gateway()); + try testing.expectEqual(gw_ip, s.dnsServer().?); + // Binding announces the new address. + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqual(@as(u16, 0x0806), be16(lastSent(), 12)); + try testing.expectEqualSlices(u8, &our_ip, lastSent()[14 + 14 ..][0..4]); +} + +test "DHCP: a reply with the wrong transaction id is ignored" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + var offer = dhcpReply(2, xid ^ 0xffff_ffff, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(offer.bytes()); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "DHCP: a reply for another station's hardware address is ignored" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + offer.buf[34 + 8 + d.chaddr + 5] ^= 0xff; // a different chaddr + offer.sealTransport(6); + s.onFrame(offer.bytes()); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "DHCP: DISCOVER is retransmitted with a growing backoff and the same transaction id" { + var s = newStack(); + s.tick(0); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + + // Nothing before the first backoff expires. + s.tick(1_999); + try testing.expectEqual(@as(usize, 0), cap_n); + s.tick(2_000); + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqual(xid, be32(sent(0)[42..], d.xid)); + + // The next interval is longer: nothing at +2 s, a frame at +4 s. + s.tick(5_999); + try testing.expectEqual(@as(usize, 1), cap_n); + s.tick(6_000); + try testing.expectEqual(@as(usize, 2), cap_n); + + // And the `secs` field tracks how long acquisition has been going. + try testing.expectEqual(@as(u16, 6), be16(sent(1)[42..], d.secs)); +} + +test "DHCP: a NAK surrenders the address and restarts from DISCOVER" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(offer.bytes()); + clearCapture(); + + var nak = dhcpReply(6, xid, .{ 0, 0, 0, 0 }, gw_ip, &.{}, ip.ip_broadcast, bcast_mac); + s.onFrame(nak.bytes()); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); + try testing.expect(s.ip() == null); + // And a fresh DISCOVER went out immediately. + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqualSlices(u8, &.{1}, findOption(try dhcpOut(sent(0)), 53).?); +} + +test "DHCP: at T1 the lease is renewed by unicast REQUEST with ciaddr set" { + var s = newStack(); + s.tick(0); + s.dhcpStart(); + const xid0 = be32(sent(0)[42..], d.xid); + var offer = dhcpReply(2, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(offer.bytes()); + var ack = dhcpReply(5, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(ack.bytes()); + try testing.expectEqual(ip.DhcpState.bound, s.dhcpState()); + + // Lease 7200 s, so T1 = 3600 s (lwIP `core/ipv4/dhcp.c:757`: half the lease). + clearCapture(); + s.tick(3_599_000); + try testing.expectEqual(@as(usize, 0), cap_n); + try testing.expectEqual(ip.DhcpState.bound, s.dhcpState()); + + // T1. The REQUEST is unicast to the server, so it needs the server's MAC first: with the cache + // empty, the datagram is dropped and an ARP request goes out in its place. + s.tick(3_600_000); + try testing.expectEqual(ip.DhcpState.renewing, s.dhcpState()); + try testing.expectEqual(@as(u16, 0x0806), be16(sent(0), 12)); + try testing.expectEqualSlices(u8, &gw_ip, sent(0)[14 + 24 ..][0..4]); // ARP for the server + + // The server answers by ARPing for us, which is enough to populate the cache. + var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + // The next retransmission now has a route. + s.tick(3_602_000); + try testing.expectEqual(@as(usize, 1), cap_n); + const r = lastSent(); + try testing.expectEqualSlices(u8, &gw_mac, r[0..6]); // unicast to the server + try testing.expectEqualSlices(u8, &gw_ip, r[14 + 16 ..][0..4]); + const msg = try dhcpOut(r); + try testing.expectEqualSlices(u8, &.{3}, findOption(msg, 53).?); // DHCPREQUEST + // RFC 2131 4.3.6, the RENEWING column: ciaddr carries the bound address, and there is no + // requested-IP option and no server identifier. + try testing.expectEqualSlices(u8, &our_ip, msg[d.ciaddr..][0..4]); + try testing.expect(findOption(msg, 50) == null); + try testing.expect(findOption(msg, 54) == null); + // A fresh transaction id for the new exchange (RFC 2131 4.4.5). + try testing.expect(be32(msg, d.xid) != xid0); + + // The server ACKs and the lease is extended from now. + const xid1 = be32(msg, d.xid); + clearCapture(); + var ack2 = dhcpReply(5, xid1, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(ack2.bytes()); + try testing.expectEqual(ip.DhcpState.bound, s.dhcpState()); + try testing.expectEqual(our_ip, s.ip().?); +} + +test "DHCP: at T2 renewal becomes a broadcast rebind, and an expired lease is surrendered" { + var s = newStack(); + s.tick(0); + s.dhcpStart(); + const xid0 = be32(sent(0)[42..], d.xid); + var offer = dhcpReply(2, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(offer.bytes()); + var ack = dhcpReply(5, xid0, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(ack.bytes()); + + // Give the stack the server's MAC so the renewal is not blocked on ARP. + var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + + s.tick(3_600_000); // T1 + try testing.expectEqual(ip.DhcpState.renewing, s.dhcpState()); + + // T2 = 7/8 of 7200 s = 6300 s (lwIP `core/ipv4/dhcp.c:766`). + clearCapture(); + s.tick(6_300_000); + try testing.expectEqual(ip.DhcpState.rebinding, s.dhcpState()); + try testing.expectEqual(@as(usize, 1), cap_n); + // Rebinding is broadcast: the granting server is not answering, so ask anybody. + try testing.expectEqualSlices(u8, &bcast_mac, lastSent()[0..6]); + const msg = try dhcpOut(lastSent()); + try testing.expectEqualSlices(u8, &our_ip, msg[d.ciaddr..][0..4]); + try testing.expect(findOption(msg, 54) == null); + + // Lease expiry: the address must go, because the server may already have handed it out. + clearCapture(); + s.tick(7_200_000); + try testing.expect(s.ip() == null); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); +} + +test "DHCP: an option whose length runs past the datagram does not read off the end" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + // Option 54 - the server identifier, which the OFFER handler actually looks for - claiming 200 + // bytes of a message with three left. Unchecked, that is a 200-byte read past the end of the + // frame, which is the classic DHCP parser bug and is reachable by any host on the segment. + var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 54, 200, 192, 168 }, our_ip, our_mac); + offer.sealTransport(6); + s.onFrame(offer.bytes()); + // The option did not resolve, so the handler fell back to `siaddr` - and the exchange carried + // on rather than crashing. + try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState()); + try testing.expectEqual(@as(usize, 1), cap_n); + const req = try dhcpOut(sent(0)); + try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?); // from siaddr +} + +test "DHCP: an option truncated by one byte does not read off the end" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + // Length 4 with only three bytes of message left after it, counting the END marker. + var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 54, 4, 192, 168 }, our_ip, our_mac); + offer.sealTransport(6); + s.onFrame(offer.bytes()); + try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState()); +} + +test "DHCP: a bogus option before a good one does not hide it" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + // A zero-length option, then a pad, then the real server identifier. + var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 12, 0, 0, 54, 4, 192, 168, 1, 1 }, our_ip, our_mac); + offer.sealTransport(6); + s.onFrame(offer.bytes()); + const req = try dhcpOut(sent(0)); + try testing.expectEqualSlices(u8, &gw_ip, findOption(req, 54).?); +} + +/// Cut `drop` bytes off the end of a UDP datagram and re-seal, so the last byte of the options is +/// wherever the caller wants it. `dhcpReply` always writes an END marker, and END is what stops a +/// well-behaved option walk - so the only way to test what happens when the walk reaches the end of +/// the buffer instead is to take the marker away. +fn truncateUdp(f: *Frame, drop: usize) void { + f.len -= drop; + const h = f.buf[14..][0..20]; + put16(h, 2, @intCast(f.len - 14)); + const seg = f.buf[34..f.len]; + put16(seg, 4, @intCast(seg.len)); + f.sealTransport(6); +} + +test "DHCP: an option code in the last byte, with no length byte after it, is not read past" { + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + // A hostname option, then a bare code 3 where a length byte should be. The END marker that + // `dhcpReply` appends is cut off, so the walk runs into the end of the datagram - and no + // option 54 is present, so the handler's search for the server identifier walks the whole + // list and reaches that last byte. Unchecked, reading its length byte is one past the frame. + var offer = dhcpReply(2, xid, our_ip, gw_ip, &[_]u8{ 12, 1, 'x', 3 }, our_ip, our_mac); + truncateUdp(&offer, 1); + s.onFrame(offer.bytes()); + // It read what it could and stopped, and fell back to `siaddr` for the server identifier. + try testing.expectEqual(ip.DhcpState.requesting, s.dhcpState()); + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqualSlices(u8, &gw_ip, findOption(try dhcpOut(sent(0)), 54).?); +} + +test "DHCP: a reply without the magic cookie is not a DHCP message" { + // RFC 2131 3: the four-byte cookie is what distinguishes a DHCP message from plain BOOTP. + // Without the check, any BOOTP reply - or any UDP datagram to port 68 that happens to have the + // right xid in the right place - is parsed as options. + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + put32(&offer.buf, 34 + 8 + d.cookie, 0x63825364); // one off + offer.sealTransport(6); + s.onFrame(offer.bytes()); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "DHCP: a BOOTREQUEST is not mistaken for a reply" { + // Every DISCOVER on the segment is a broadcast, including our own. A client that does not check + // the `op` field parses its own request - or another client's - as an offer, and RFC 2131 gives + // it a `yiaddr` of zero to work with. + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const xid = be32(sent(0)[42..], d.xid); + clearCapture(); + var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + offer.buf[34 + 8 + d.op] = 1; // BOOTREQUEST + offer.sealTransport(6); + s.onFrame(offer.bytes()); + try testing.expectEqual(ip.DhcpState.selecting, s.dhcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +// ====================================================================================== TCP +// +// The synthetic peer. Sequence numbers here are the *peer's*; the stack's are read out of what it +// sends, because its ISN is not something a test may assume. + +/// Offsets into the TCP header, RFC 793 3.1 / lwIP `prot/tcp.h:56-65`. +const t = struct { + const src = 0; + const dst = 2; + const seq = 4; + const ack = 8; + const hdrlen_flags = 12; + const window = 14; + const chksum = 16; + + const fin: u8 = 0x01; + const syn: u8 = 0x02; + const rst: u8 = 0x04; + const psh: u8 = 0x08; + const ack_f: u8 = 0x10; +}; + +const Peer = struct { + ip: ip.Ip4, + port: u16, + mac: ip.Mac, + /// Our own sequence space, as the peer. + seq: u32 = 0x1000_0000, + /// The stack's ports and sequence numbers, learnt from its SYN. + stack_port: u16 = 0, + window: u16 = 8192, + /// With an MSS option in our SYN-ACK, or without. + mss: ?u16 = 1460, + + fn segment(self: *Peer, flags: u8, ackno: u32, data: []const u8, with_mss: bool) Frame { + var f: Frame = .{}; + f.eth(our_mac, self.mac, 0x0800); + const opt_len: usize = if (with_mss) 4 else 0; + const p = f.ip4(self.ip, our_ip, 6, 20 + opt_len + data.len); + put16(p, t.src, self.port); + put16(p, t.dst, self.stack_port); + put32(p, t.seq, self.seq); + put32(p, t.ack, ackno); + put16(p, t.hdrlen_flags, (@as(u16, @intCast((20 + opt_len) / 4)) << 12) | flags); + put16(p, t.window, self.window); + put16(p, t.chksum, 0); + put16(p, 18, 0); + if (with_mss) { + p[20] = 2; + p[21] = 4; + put16(p, 22, self.mss.?); + } + if (data.len != 0) @memcpy(p[20 + opt_len ..], data); + f.sealTransport(t.chksum); + return f; + } +}; + +/// A captured TCP segment, decoded, with its checksums verified independently. +const Seg = struct { + src_port: u16, + dst_port: u16, + seq: u32, + ack: u32, + flags: u8, + window: u16, + data: []const u8, + mss: ?u16, +}; + +fn decode(frame: []const u8) !Seg { + try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12)); + const h = frame[14..34]; + try testing.expectEqual(@as(u8, 6), h[9]); + try verify(h); + const total = be16(h, 2); + const seg = frame[34 .. 14 + total]; + try verifyTransport(h[12..16].*, h[16..20].*, 6, seg); + const hf = be16(seg, t.hdrlen_flags); + const hlen = @as(usize, hf >> 12) * 4; + var mss: ?u16 = null; + var i: usize = 20; + while (i + 1 < hlen) { + if (seg[i] == 0) break; + if (seg[i] == 1) { + i += 1; + continue; + } + const olen = seg[i + 1]; + if (olen < 2 or i + olen > hlen) break; + if (seg[i] == 2 and olen == 4) mss = be16(seg, i + 2); + i += olen; + } + return .{ + .src_port = be16(seg, t.src), + .dst_port = be16(seg, t.dst), + .seq = be32(seg, t.seq), + .ack = be32(seg, t.ack), + .flags = @truncate(hf & 0x3f), + .window = be16(seg, t.window), + .data = seg[hlen..], + .mss = mss, + }; +} + +/// Bring a stack up statically with the peer's MAC already in the ARP cache, then start a GET. +/// Returns the peer and the SYN the stack sent. +fn startGet(s: *ip.Stack, peer: *Peer, path: []const u8, out: []u8) !Seg { + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, path, out)); + try testing.expectEqual(@as(usize, 1), cap_n); + const syn = try decode(sent(0)); + peer.stack_port = syn.src_port; + return syn; +} + +/// Complete the handshake: deliver the SYN-ACK and return the sequence number that acknowledges the +/// whole request. Afterwards `sent(0)` is the request segment - the capture log is cleared first, so +/// tests never have to remember whether the SYN is still in it. That off-by-one is exactly the kind +/// of thing a test helper exists to remove. +fn handshake(s: *ip.Stack, peer: *Peer, iss: u32) !u32 { + clearCapture(); + var synack = peer.segment(t.syn | t.ack_f, iss +% 1, &.{}, true); + s.onFrame(synack.bytes()); + peer.seq +%= 1; + const req = try decode(sent(0)); + try testing.expect(req.data.len > 0); + return iss +% 1 +% @as(u32, @intCast(req.data.len)); +} + +test "TCP: the SYN offers an MSS, uses an ephemeral port and advertises a window" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + + try testing.expectEqual(t.syn, syn.flags); + try testing.expectEqual(@as(u16, 80), syn.dst_port); + try testing.expect(syn.src_port >= 49152); // RFC 6335 dynamic range + try testing.expectEqual(@as(?u16, 1460), syn.mss); + try testing.expect(syn.window > 0); + try testing.expectEqual(@as(usize, 0), syn.data.len); + try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState()); +} + +test "TCP: a handshake, the request, a response and a clean teardown" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/index.html", &out); + const iss = syn.seq; + + // ---- SYN-ACK + _ = try handshake(&s, &peer, iss); + try testing.expectEqual(ip.TcpState.established, s.tcpState()); + + // The handshake's ACK carries the request: one frame, not two. + try testing.expectEqual(@as(usize, 1), cap_n); + const req = try decode(sent(0)); + try testing.expectEqual(t.ack_f | t.psh, req.flags); + try testing.expectEqual(iss +% 1, req.seq); + try testing.expectEqual(peer.seq, req.ack); + try testing.expect(std.mem.startsWith(u8, req.data, "GET /index.html HTTP/1.1\r\n")); + // The Host header is the address literal - there is no DNS here - and port 80 is elided. + try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 192.168.1.90\r\n") != null); + // Connection: close is the framing for a body with no Content-Length. + try testing.expect(std.mem.indexOf(u8, req.data, "\r\nConnection: close\r\n") != null); + try testing.expect(std.mem.endsWith(u8, req.data, "\r\n\r\n")); + const req_len = req.data.len; + + // ---- the peer acknowledges the request and sends the whole response in one segment + clearCapture(); + const body = "hello, world"; + const response = "HTTP/1.1 200 OK\r\nServer: test\r\nContent-Length: 12\r\n\r\n" ++ body; + var resp = peer.segment(t.ack_f | t.psh, iss +% 1 +% @as(u32, @intCast(req_len)), response, false); + s.onFrame(resp.bytes()); + peer.seq +%= @intCast(response.len); + + // The body is complete, so the stack half-closes: the FIN is the acknowledgement too. + try testing.expectEqual(@as(usize, 1), cap_n); + const fin = try decode(sent(0)); + try testing.expectEqual(t.fin | t.ack_f, fin.flags); + try testing.expectEqual(peer.seq, fin.ack); + try testing.expectEqual(ip.TcpState.fin_wait_1, s.tcpState()); + + // ---- the peer acknowledges our FIN and sends its own + clearCapture(); + var peer_fin = peer.segment(t.fin | t.ack_f, fin.seq +% 1, &.{}, false); + s.onFrame(peer_fin.bytes()); + peer.seq +%= 1; + const last = try decode(lastSent()); + try testing.expectEqual(t.ack_f, last.flags); + try testing.expectEqual(peer.seq, last.ack); + try testing.expectEqual(ip.TcpState.time_wait, s.tcpState()); + + // ---- and the body comes out + const n = try s.httpGet(peer.ip, peer.port, "/index.html", &out); + try testing.expectEqual(@as(usize, 12), n); + try testing.expectEqualStrings(body, out[0..n]); + try testing.expectEqual(@as(u16, 200), s.httpStatus()); + + // TIME_WAIT is short by design; it ends on the clock, not on a frame. + s.tick(1_000_000); + try testing.expectEqual(ip.TcpState.closed, s.tcpState()); +} + +test "TCP: the SYN is retransmitted with its MSS option, on a doubling timer" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + clearCapture(); + + // Nothing before the RTO. + s.tick(1_999); + try testing.expectEqual(@as(usize, 0), cap_n); + s.tick(2_000); + try testing.expectEqual(@as(usize, 1), cap_n); + const again = try decode(sent(0)); + try testing.expectEqual(t.syn, again.flags); + try testing.expectEqual(syn.seq, again.seq); + // The MSS option must be repeated: a peer that only ever sees the retransmission would + // otherwise fall back to 536. + try testing.expectEqual(@as(?u16, 1460), again.mss); + + // The next timeout is twice as long: 2 s, not 1 s. + s.tick(3_999); + try testing.expectEqual(@as(usize, 1), cap_n); + s.tick(4_000); + try testing.expectEqual(@as(usize, 2), cap_n); + try testing.expectEqual(@as(u32, 2), s.counters.tcp_retx); +} + +test "TCP: retransmission after a dropped data segment resends the identical bytes" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/drop", &out); + const iss = syn.seq; + + _ = try handshake(&s, &peer, iss); + const first = try decode(sent(0)); + try testing.expect(first.data.len > 0); + + // Pretend the segment was lost: never acknowledge it, just let time pass. + clearCapture(); + s.tick(1_500); + try testing.expectEqual(@as(usize, 0), cap_n); // handshake completed at t=1000, RTO at t=2000 + s.tick(2_000); + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqual(@as(u32, 1), s.counters.tcp_retx); + + const again = try decode(sent(0)); + try testing.expectEqual(first.seq, again.seq); + try testing.expectEqualSlices(u8, first.data, again.data); + try testing.expectEqual(first.flags, again.flags); + + // Now it gets through, and the connection carries on from the same place. + clearCapture(); + const response = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n"; + var resp = peer.segment(t.ack_f, iss +% 1 +% @as(u32, @intCast(first.data.len)), response, false); + s.onFrame(resp.bytes()); + try testing.expectEqual(@as(usize, 0), try s.httpGet(peer.ip, peer.port, "/drop", &out)); + try testing.expectEqual(@as(u16, 204), s.httpStatus()); +} + +test "TCP: retransmission eventually gives up with TimedOut" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + _ = try startGet(&s, &peer, "/", &out); + + // Six retransmissions with a doubling, capped backoff, then failure. Ticking well past every + // deadline in one step is enough: the deadline is absolute. + var now: u64 = 1000; + var k: usize = 0; + while (k < 8) : (k += 1) { + now += 60_000; + s.tick(now); + } + try testing.expectEqual(ip.TcpState.closed, s.tcpState()); + try testing.expectError(error.TimedOut, s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqual(@as(u32, 6), s.counters.tcp_retx); +} + +test "TCP: an out-of-order segment is not accepted, and provokes a duplicate ACK" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + const iss = syn.seq; + const our_next = try handshake(&s, &peer, iss); + const in_order_seq = peer.seq; + + // The second half of the response arrives first. + const head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n"; + clearCapture(); + peer.seq = in_order_seq +% @as(u32, @intCast(head.len)); + var late = peer.segment(t.ack_f, our_next, "abcd", false); + s.onFrame(late.bytes()); + + // A duplicate ACK for what we are still waiting for, and nothing consumed. + try testing.expectEqual(@as(usize, 1), cap_n); + const dup = try decode(sent(0)); + try testing.expectEqual(t.ack_f, dup.flags); + try testing.expectEqual(in_order_seq, dup.ack); + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out)); + + // The missing piece arrives. + clearCapture(); + peer.seq = in_order_seq; + var missing = peer.segment(t.ack_f, our_next, head, false); + s.onFrame(missing.bytes()); + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqual(@as(u16, 200), s.httpStatus()); + + // And the retransmission of the tail completes it. + peer.seq = in_order_seq +% @as(u32, @intCast(head.len)); + var tail = peer.segment(t.ack_f, our_next, "abcd", false); + s.onFrame(tail.bytes()); + try testing.expectEqual(@as(usize, 4), try s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqualStrings("abcd", out[0..4]); +} + +test "TCP: a retransmission overlapping data already received is trimmed, not rejected" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + const iss = syn.seq; + const our_next = try handshake(&s, &peer, iss); + + // Headers first, so the overlap lands squarely in the body where duplicated bytes cannot hide + // in a header line the parser would have skipped anyway. + const head = "HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\n"; + var h = peer.segment(t.ack_f, our_next, head, false); + s.onFrame(h.bytes()); + peer.seq +%= @intCast(head.len); + const base = peer.seq; + + // Ten body bytes. + var a = peer.segment(t.ack_f, our_next, "0123456789", false); + s.onFrame(a.bytes()); + + // Then a retransmission that starts four bytes before what we now expect and carries six new + // bytes past it. Without trimming, `6789` is written twice, `rcv_nxt` runs four ahead of the + // truth, and the final six bytes are then rejected as old - so the request never completes. + peer.seq = base +% 6; + var b = peer.segment(t.ack_f, our_next, "6789abcdef", false); + s.onFrame(b.bytes()); + + try testing.expectEqual(@as(usize, 16), try s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqualStrings("0123456789abcdef", out[0..16]); +} + +test "TCP: a SYN-ACK that does not acknowledge our SYN is reset, not accepted" { + // RFC 793 3.4: an old duplicate SYN-ACK, or one aimed at a previous incarnation of this + // 4-tuple, is answered with a reset. Accepting it would establish a connection whose sequence + // space the peer does not agree with, and every subsequent segment would be discarded. + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + clearCapture(); + + var wrong = peer.segment(t.syn | t.ack_f, syn.seq +% 999, &.{}, true); + s.onFrame(wrong.bytes()); + try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState()); + try testing.expectEqual(@as(usize, 1), cap_n); + const rst = try decode(sent(0)); + try testing.expectEqual(t.rst, rst.flags); + try testing.expectEqual(syn.seq +% 999, rst.seq); // RST carries the offending ACK number + + // The right one still works. + clearCapture(); + var right = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true); + s.onFrame(right.bytes()); + try testing.expectEqual(ip.TcpState.established, s.tcpState()); +} + +test "TCP: a FIN ahead of the data we have is not honoured" { + // A FIN whose sequence number is past `rcv_nxt` closes the connection over a hole. Honouring it + // would report a complete body that is missing its middle. + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + const iss = syn.seq; + const our_next = try handshake(&s, &peer, iss); + const base = peer.seq; + + const head = "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\n"; + var h = peer.segment(t.ack_f, our_next, head, false); + s.onFrame(h.bytes()); + peer.seq +%= @intCast(head.len); + + // A FIN 100 bytes into the future, as though a segment we never saw preceded it. + clearCapture(); + peer.seq = base +% @as(u32, @intCast(head.len)) +% 100; + var early = peer.segment(t.fin | t.ack_f, our_next, &.{}, false); + s.onFrame(early.bytes()); + // Not closed, not completed: the body is still outstanding. + try testing.expectEqual(ip.TcpState.established, s.tcpState()); + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/", &out)); + + // The real body arrives and completes it. + peer.seq = base +% @as(u32, @intCast(head.len)); + var body = peer.segment(t.ack_f, our_next, "wxyz", false); + s.onFrame(body.bytes()); + try testing.expectEqual(@as(usize, 4), try s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqualStrings("wxyz", out[0..4]); +} + +test "TCP: an in-window RST tears the connection down; an out-of-window one does not" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + const iss = syn.seq; + _ = try handshake(&s, &peer, iss); + + // RFC 5961 3: a RST whose sequence number is not the next one expected gets a challenge ACK + // and is otherwise ignored. This is what stops a blind off-path reset. + clearCapture(); + const good_seq = peer.seq; + peer.seq = good_seq +% 5000; + var bogus = peer.segment(t.rst, 0, &.{}, false); + s.onFrame(bogus.bytes()); + try testing.expectEqual(ip.TcpState.established, s.tcpState()); + try testing.expectEqual(@as(usize, 1), cap_n); + try testing.expectEqual(t.ack_f, (try decode(sent(0))).flags); + + // The real thing. + peer.seq = good_seq; + var reset = peer.segment(t.rst, 0, &.{}, false); + s.onFrame(reset.bytes()); + try testing.expectEqual(ip.TcpState.closed, s.tcpState()); + try testing.expectError(error.ConnectionReset, s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqual(@as(u32, 2), s.counters.tcp_rst_rx); +} + +test "TCP: a segment for a different port is not mistaken for this connection" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + clearCapture(); + const real_port = peer.stack_port; + peer.stack_port = real_port ^ 1; + var stray = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true); + s.onFrame(stray.bytes()); + try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "TCP: a segment from a different host is not mistaken for this connection" { + // The whole 4-tuple has to match, not just the ports. A stack that checks only the ports can + // have its connection completed - or reset - by any host on the segment that guesses a + // 16-bit number. + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + clearCapture(); + + // Same ports, different source address. + var impostor: Peer = .{ .ip = gw_ip, .port = 80, .mac = gw_mac, .seq = 0x7000_0000 }; + impostor.stack_port = peer.stack_port; + var stray = impostor.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true); + s.onFrame(stray.bytes()); + try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); + + // And a reset from the same impostor is ignored too. + var reset = impostor.segment(t.rst, 0, &.{}, false); + s.onFrame(reset.bytes()); + try testing.expectEqual(ip.TcpState.syn_sent, s.tcpState()); + try testing.expectEqual(@as(u32, 0), s.counters.tcp_rst_rx); +} + +test "TCP: the peer's MSS is honoured, and the request is split across segments" { + // The MSS option only matters when the request is bigger than it, which for a GET means a long + // path. A stack that ignores the option sends one oversized segment that a peer with a small + // MSS - a tunnel, a PPPoE link, anything with encapsulation overhead - drops silently. + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac, .mss = 100 }; + var out: [64]u8 = undefined; + const path: [300]u8 = @splat('q'); + var full_path: [301]u8 = undefined; + full_path[0] = '/'; + @memcpy(full_path[1..], &path); + + const syn = try startGet(&s, &peer, &full_path, &out); + const iss = syn.seq; + clearCapture(); + var synack = peer.segment(t.syn | t.ack_f, iss +% 1, &.{}, true); + s.onFrame(synack.bytes()); + peer.seq +%= 1; + + // Reassemble the request from however many segments it takes, acknowledging each one: with a + // window of one segment, nothing more is sent until the previous is acknowledged. + var assembled: [512]u8 = undefined; + var got: usize = 0; + var rounds: usize = 0; + while (true) : (rounds += 1) { + try testing.expect(rounds < 16); // termination, so a stall fails rather than hangs + try testing.expectEqual(@as(usize, 1), cap_n); + const seg = try decode(sent(0)); + try testing.expect(seg.data.len <= 100); // the peer's MSS, honoured + try testing.expectEqual(iss +% 1 +% @as(u32, @intCast(got)), seg.seq); + @memcpy(assembled[got..][0..seg.data.len], seg.data); + got += seg.data.len; + if (seg.flags & t.fin != 0) break; + clearCapture(); + var ack = peer.segment(t.ack_f, seg.seq +% @as(u32, @intCast(seg.data.len)), &.{}, false); + s.onFrame(ack.bytes()); + if (cap_n == 0) break; // request fully sent and acknowledged + } + try testing.expect(rounds >= 3); // 400-odd bytes at 100 per segment + try testing.expect(std.mem.startsWith(u8, assembled[0..got], "GET /qqq")); + try testing.expect(std.mem.endsWith(u8, assembled[0..got], "\r\n\r\n")); + try testing.expect(std.mem.indexOf(u8, assembled[0..got], &path) != null); +} + +test "TCP: sequence numbers wrap across 2^32 without stalling" { + var s = newStack(); + // A peer whose ISN is chosen so its data crosses the wrap. This is the case a `<` comparison + // instead of RFC 1982 serial arithmetic breaks, and it breaks by hanging forever. + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac, .seq = 0xffff_ffe0 }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + const iss = syn.seq; + const our_next = try handshake(&s, &peer, iss); + + const head = "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\n"; + clearCapture(); + var a = peer.segment(t.ack_f, our_next, head, false); // 38 bytes: crosses the wrap + s.onFrame(a.bytes()); + peer.seq +%= @intCast(head.len); + try testing.expect(peer.seq < 0x1000); // we really did wrap + + var b = peer.segment(t.ack_f, our_next, "12345678", false); + s.onFrame(b.bytes()); + try testing.expectEqual(@as(usize, 8), try s.httpGet(peer.ip, peer.port, "/", &out)); + try testing.expectEqualStrings("12345678", out[0..8]); +} + +test "TCP: an unresolvable peer fails with HostUnreachable after ARP gives up" { + var s = newStack(); + s.tick(0); + s.setStatic(our_ip, mask24, gw_ip); + var out: [64]u8 = undefined; + // Nothing in the cache, and nothing ever answers. + try testing.expectError(error.WouldBlock, s.httpGet(peer_ip, 80, "/", &out)); + try testing.expectEqual(ip.TcpState.arp_wait, s.tcpState()); + var now: u64 = 0; + var k: usize = 0; + while (k < 8) : (k += 1) { + now += 1000; + s.tick(now); + } + try testing.expectError(error.HostUnreachable, s.httpGet(peer_ip, 80, "/", &out)); + // Every attempt was a broadcast ARP request for the peer. + try testing.expect(s.counters.arp_tx >= 5); +} + +test "TCP: an off-net destination is sent to the gateway's MAC" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, gw_mac, gw_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + var out: [64]u8 = undefined; + try testing.expectError(error.WouldBlock, s.httpGet(off_net_ip, 80, "/", &out)); + try testing.expectEqual(@as(usize, 1), cap_n); + const syn = lastSent(); + try testing.expectEqualSlices(u8, &gw_mac, syn[0..6]); // to the gateway... + try testing.expectEqualSlices(u8, &off_net_ip, syn[14 + 16 ..][0..4]); // ...for the peer +} + +// ===================================================================================== HTTP + +/// Handshake, then feed the response in the given pieces, one segment each. +fn runResponse(s: *ip.Stack, peer: *Peer, path: []const u8, out: []u8, pieces: []const []const u8) !void { + const syn = try startGet(s, peer, path, out); + const iss = syn.seq; + const our_next = try handshake(s, peer, iss); + for (pieces) |piece| { + clearCapture(); + var seg = peer.segment(t.ack_f, our_next, piece, false); + s.onFrame(seg.bytes()); + peer.seq +%= @intCast(piece.len); + } +} + +test "HTTP: headers split across two segments" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + // The split falls inside the `Content-Length` field name, and the second piece carries the + // blank line and the start of the body. This is the ordinary case on a real server, and it is + // the one a parser that assumes headers arrive whole gets wrong. + try runResponse(&s, &peer, "/split", &out, &.{ + "HTTP/1.1 200 OK\r\nServer: nginx\r\nContent-Len", + "gth: 11\r\nETag: \"x\"\r\n\r\nhello wor", + "ld", + }); + const n = try s.httpGet(peer.ip, peer.port, "/split", &out); + try testing.expectEqual(@as(usize, 11), n); + try testing.expectEqualStrings("hello world", out[0..n]); + try testing.expectEqual(@as(u16, 200), s.httpStatus()); +} + +test "HTTP: the status line and blank line split one byte at a time" { + // The pathological segmentation: every byte its own segment. If any offset in the parser is + // off by one, one of these iterations lands on it. + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + const response = "HTTP/1.1 201 Created\r\nContent-Length: 3\r\nX: y\r\n\r\nabc"; + var pieces: [response.len][]const u8 = undefined; + for (&pieces, 0..) |*p, i| p.* = response[i .. i + 1]; + try runResponse(&s, &peer, "/bytes", &out, &pieces); + try testing.expectEqual(@as(usize, 3), try s.httpGet(peer.ip, peer.port, "/bytes", &out)); + try testing.expectEqualStrings("abc", out[0..3]); + try testing.expectEqual(@as(u16, 201), s.httpStatus()); +} + +test "HTTP: a header name's case is not significant" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + try runResponse(&s, &peer, "/case", &out, &.{ + "HTTP/1.0 200 OK\r\ncOnTeNt-LeNgTh: 7 \r\n\r\n1234567", + }); + try testing.expectEqual(@as(usize, 7), try s.httpGet(peer.ip, peer.port, "/case", &out)); + try testing.expectEqualStrings("1234567", out[0..7]); +} + +test "HTTP: a body with no Content-Length is terminated by the peer's FIN" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4096]u8 = undefined; + const syn = try startGet(&s, &peer, "/stream", &out); + const iss = syn.seq; + const our_next = try handshake(&s, &peer, iss); + + var a = peer.segment(t.ack_f, our_next, "HTTP/1.1 200 OK\r\nServer: x\r\n\r\npart one ", false); + s.onFrame(a.bytes()); + peer.seq +%= 39; + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/stream", &out)); + + var b = peer.segment(t.ack_f, our_next, "part two", false); + s.onFrame(b.bytes()); + peer.seq +%= 8; + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/stream", &out)); + + // RFC 7230 3.3.3 case 7: with no Content-Length and no chunking, the connection close is the + // framing. That is why the request said `Connection: close`. + clearCapture(); + var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false); + s.onFrame(fin.bytes()); + const n = try s.httpGet(peer.ip, peer.port, "/stream", &out); + try testing.expectEqualStrings("part one part two", out[0..n]); + + // The peer closed first, so this is RFC 793's CLOSE-WAIT -> LAST-ACK: our FIN goes out + // acknowledging theirs, and the connection is not finished until that FIN is acknowledged. + try testing.expectEqual(@as(usize, 1), cap_n); + const ours = try decode(sent(0)); + try testing.expectEqual(t.fin | t.ack_f, ours.flags); + try testing.expectEqual(peer.seq +% 1, ours.ack); // their FIN consumed one sequence number + try testing.expectEqual(ip.TcpState.last_ack, s.tcpState()); + + // Their ACK of our FIN finishes it. + clearCapture(); + peer.seq +%= 1; + var final = peer.segment(t.ack_f, ours.seq +% 1, &.{}, false); + s.onFrame(final.bytes()); + try testing.expectEqual(ip.TcpState.time_wait, s.tcpState()); + try testing.expectEqual(@as(usize, 0), cap_n); // a bare ACK needs no answer + + // The peer's FIN again, because our ACK of it was lost. It has already been consumed, so it is + // "old" by one sequence number - and a stack that only accepts an exactly-in-order FIN answers + // nothing, leaving the peer retransmitting until it gives up and resets. + clearCapture(); + var again: Peer = peer; + again.seq = peer.seq -% 1; // the sequence number their FIN actually carried + var dup = again.segment(t.fin | t.ack_f, ours.seq +% 1, &.{}, false); + s.onFrame(dup.bytes()); + try testing.expectEqual(@as(usize, 1), cap_n); + const reack = try decode(sent(0)); + try testing.expectEqual(t.ack_f, reack.flags); + try testing.expectEqual(peer.seq, reack.ack); // still the sequence number past their FIN + try testing.expectEqual(ip.TcpState.time_wait, s.tcpState()); +} + +// ============================================================================= HTTP chunked +// +// RFC 7230 4.1. The framing is a size in hex, CRLF, that many bytes, CRLF, repeated, ended by a +// zero size, an optional trailer section and one more CRLF. Two things make it worth this many +// cases: the caller must see the decoded bytes and none of the framing, and a segment boundary +// may fall anywhere - including inside a size, inside a CRLF, and inside a chunk whose *data* +// contains CRLFs of its own. + +/// The example from RFC 7230's own appendix, by way of the one everybody quotes. Its third chunk +/// carries `\r\n\r\n` as data, which is the trap: a decoder that scans for a delimiter instead of +/// counting the size it was given loses the rest of the body here, and reports success. +const chunked_head = "HTTP/1.1 200 OK\r\nServer: cloudflare\r\nTransfer-Encoding: chunked\r\n\r\n"; +const chunked_wire = "4\r\nWiki\r\n5\r\npedia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n"; +const chunked_want = "Wikipedia in\r\n\r\nchunks."; + +/// Drive a response through a fresh connection, cut into `pieces`, and return the decoded body. +fn decodeChunked(out: []u8, pieces: []const []const u8) ![]const u8 { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + try runResponse(&s, &peer, "/c", out, pieces); + const n = try s.httpGet(peer.ip, peer.port, "/c", out); + return out[0..n]; +} + +/// The same, expecting a named failure rather than a body. +fn expectChunkedError(want: anyerror, out: []u8, pieces: []const []const u8) !void { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + try runResponse(&s, &peer, "/c", out, pieces); + try testing.expectError(want, s.httpGet(peer.ip, peer.port, "/c", out)); +} + +test "HTTP chunked: a whole response in one segment decodes, framing bytes and all removed" { + var out: [256]u8 = undefined; + const got = try decodeChunked(&out, &.{chunked_head ++ chunked_wire}); + try testing.expectEqualStrings(chunked_want, got); + // Said the other way round, because it is the property that matters: no size, no CRLF and no + // terminator reached the caller. + try testing.expect(std.mem.indexOf(u8, got, "\r\nE\r\n") == null); + try testing.expect(std.mem.indexOf(u8, got, "0\r\n") == null); +} + +test "HTTP chunked: the response split at every single offset, two segments" { + // The decoder has to resume from wherever the cut landed: mid-size, between the CR and the LF + // of a chunk header, mid-data, mid-terminator. This walks every one of those positions. + const response = chunked_head ++ chunked_wire; + var split: usize = 1; + while (split < response.len) : (split += 1) { + var out: [256]u8 = undefined; + const got = try decodeChunked(&out, &.{ response[0..split], response[split..] }); + try testing.expectEqualStrings(chunked_want, got); + } +} + +test "HTTP chunked: the response split one byte at a time" { + // The pathological segmentation. Every state in the machine is entered with an empty input + // and re-entered with one byte, which is where a decoder that peeks at `b[1]` dies. + const response = chunked_head ++ chunked_wire; + var pieces: [response.len][]const u8 = undefined; + for (&pieces, 0..) |*p, i| p.* = response[i .. i + 1]; + var out: [256]u8 = undefined; + const got = try decodeChunked(&out, &pieces); + try testing.expectEqualStrings(chunked_want, got); +} + +test "HTTP chunked: the body arrives across three segments cut inside one chunk's data" { + var out: [256]u8 = undefined; + const got = try decodeChunked(&out, &.{ + chunked_head ++ "4\r\nWi", + "ki\r\n5\r\npe", + "dia\r\nE\r\n in\r\n\r\nchunks.\r\n0\r\n\r\n", + }); + try testing.expectEqualStrings(chunked_want, got); +} + +test "HTTP chunked: chunk extensions are skipped, not delivered" { + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++ + "5;name=value;flag\r\nhello\r\n0;last\r\n\r\n", + }); + try testing.expectEqualStrings("hello", got); +} + +test "HTTP chunked: an extension split across segments is still skipped" { + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;na", + "me=val", + "ue\r\nhello\r\n0\r\n\r\n", + }); + try testing.expectEqualStrings("hello", got); +} + +test "HTTP chunked: a trailer section is skipped and only its final CRLF completes the body" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + const syn = try startGet(&s, &peer, "/c", &out); + const our_next = try handshake(&s, &peer, syn.seq); + + // Everything up to but not including the CRLF that ends the trailer section. + const piece = + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nExpires: now\r\n"; + var a = peer.segment(t.ack_f, our_next, piece, false); + s.onFrame(a.bytes()); + peer.seq +%= @intCast(piece.len); + + // The zero chunk is in and every body byte is here, and it is still not complete: the trailer + // section is part of the message, and a decoder that finished at the zero chunk would hand + // the caller a body while leaving the connection mid-message. + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, peer.port, "/c", &out)); + + var b = peer.segment(t.ack_f, our_next, "\r\n", false); + s.onFrame(b.bytes()); + peer.seq +%= 2; + try testing.expectEqual(@as(usize, 5), try s.httpGet(peer.ip, peer.port, "/c", &out)); + try testing.expectEqualStrings("hello", out[0..5]); +} + +test "HTTP chunked: sizes in upper case hex, and with leading zeros" { + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" ++ + "00000A\r\n0123456789\r\nB\r\nabcdefghijk\r\n000\r\n\r\n", + }); + try testing.expectEqualStrings("0123456789abcdefghijk", got); +} + +test "HTTP chunked: an empty body is the terminator alone" { + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 204 No Content\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n", + }); + try testing.expectEqual(@as(usize, 0), got.len); +} + +test "HTTP chunked: Content-Length beside chunked is ignored, not obeyed" { + // RFC 7230 3.3.3 case 3. A response carrying both is the request-smuggling disagreement, and + // the framing that wins is the chunked one. Obeying the length here would stop after 2 bytes + // and report success on a fifth of the body. + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nTransfer-Encoding: chunked\r\n\r\n" ++ + "5\r\nhello\r\n0\r\n\r\n", + }); + try testing.expectEqualStrings("hello", got); +} + +test "HTTP chunked: the header order does not decide which framing wins" { + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 2\r\n\r\n" ++ + "5\r\nhello\r\n0\r\n\r\n", + }); + try testing.expectEqualStrings("hello", got); +} + +test "HTTP chunked: a size with no hex digits is refused, never read as the terminator" { + // The dangerous misparse: a stray CRLF where a size belongs is a zero-length chunk to a + // decoder with no `1*HEXDIG` check, and a zero-length chunk ends the body. That is a + // truncated response reported as a complete one. + var out: [64]u8 = undefined; + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n\r\nhello\r\n0\r\n\r\n", + }); + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nxyz\r\nhello\r\n0\r\n\r\n", + }); +} + +test "HTTP chunked: a chunk not followed by CRLF is refused" { + var out: [64]u8 = undefined; + // Data, then a bare LF where the CRLF belongs. + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\n0\r\n\r\n", + }); + // A chunk header whose CR is not followed by LF. + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\rhello\r\n0\r\n\r\n", + }); + // The final CRLF of the message, mangled. + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\rx", + }); +} + +test "HTTP chunked: each half of each CRLF is required in its own position" { + // The three cases above are all refused by a decoder that merely skips *two* bytes wherever a + // CRLF belongs; these are not. Each one is a well-framed message to such a decoder - it + // returns `hello` and reports success - and a malformed one to this stack. That is the + // difference between checking the delimiter and counting past it. + var out: [64]u8 = undefined; + // LF where the chunk's closing CR belongs, and the real LF behind it. + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\n\n0\r\n\r\n", + }); + // CR in place, then a byte that is not the LF. + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\rZ0\r\n\r\n", + }); + // And in the chunk header: CR in place, junk where the LF belongs. + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\rZhello\r\n0\r\n\r\n", + }); +} + +test "HTTP chunked: a second chunk with an empty size is refused, not read as the terminator" { + // The first chunk's size sets the "a digit was seen" flag, and it has to be cleared for the + // next one. Left set, the CRLF below reads as a zero-length chunk - the terminator - and the + // response ends silently five bytes in. + var out: [64]u8 = undefined; + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n\r\nmore\r\n0\r\n\r\n", + }); +} + +test "HTTP chunked: an impossible Content-Length beside chunked does not fail the request" { + // The other half of "chunked wins": the length is not merely unused for framing, it is not + // consulted at all - including by the check that refuses a body too big for `out`. A server + // that sends both is already not to be believed about the length. + var out: [64]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nTransfer-Encoding: chunked\r\n\r\n" ++ + "5\r\nhello\r\n0\r\n\r\n", + }); + try testing.expectEqualStrings("hello", got); +} + +test "HTTP chunked: a body that exactly fills out still leaves window for its terminator" { + // The deadlock this pins: the advertised window is the room left in `out`, and chunked + // framing is consumed without going there. A body that fills `out` to the last byte closes + // the window, the terminator can never be accepted, and the request stalls against a peer + // that is behaving perfectly - until the RTO calls it a timeout. + var out: [5]u8 = undefined; + const got = try decodeChunked(&out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n", + "0\r\n\r\n", + }); + try testing.expectEqualStrings("hello", got); +} + +test "HTTP chunked: a size that overflows usize is refused, not wrapped" { + // Seventeen f's. Wrapped, this is a small number and the response looks well framed. + var out: [64]u8 = undefined; + try expectChunkedError(error.HttpChunkMalformed, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nfffffffffffffffff\r\n", + }); +} + +test "HTTP chunked: a chunk larger than the caller's buffer fails on the header, before any copy" { + var out: [8]u8 = undefined; + try expectChunkedError(error.StreamTooLong, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n64\r\n", + }); +} + +test "HTTP chunked: chunks that together outgrow the buffer fail, and do not truncate" { + var out: [8]u8 = undefined; + try expectChunkedError(error.StreamTooLong, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n5\r\nworld\r\n0\r\n\r\n", + }); +} + +test "HTTP chunked: an endless chunk extension is bounded" { + const pad: [http_framing_over]u8 = @splat('x'); + var out: [4096]u8 = undefined; + try expectChunkedError(error.HttpHeadersTooLong, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5;", + &pad, + }); +} + +test "HTTP chunked: an endless trailer section is bounded" { + const pad: [http_framing_over]u8 = @splat('x'); + var out: [4096]u8 = undefined; + try expectChunkedError(error.HttpHeadersTooLong, &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nX: ", + &pad, + }); +} + +/// One byte past the framing budget, so the bound is tested at the bound and not far above it. +const http_framing_over = ip.http_framing_max + 1; + +test "HTTP chunked: a close before the terminator is an error, not the body that did arrive" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + const syn = try startGet(&s, &peer, "/c", &out); + const our_next = try handshake(&s, &peer, syn.seq); + + const piece = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n"; + var a = peer.segment(t.ack_f, our_next, piece, false); + s.onFrame(a.bytes()); + peer.seq +%= @intCast(piece.len); + + var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false); + s.onFrame(fin.bytes()); + // Five bytes of body are sitting in `out`, and they are not the answer: chunked framing says + // the message ends at the zero chunk, so a close before it truncated the response. + try testing.expectError(error.ConnectionClosed, s.httpGet(peer.ip, peer.port, "/c", &out)); +} + +test "HTTP: a transfer coding that is neither identity nor chunked is still refused" { + for ([_][]const u8{ "gzip", "deflate", "chunked, gzip", "gzip, chunked" }) |coding| { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + var head: [128]u8 = undefined; + const resp = try std.fmt.bufPrint( + &head, + "HTTP/1.1 200 OK\r\nTransfer-Encoding: {s}\r\n\r\n5\r\nhello\r\n0\r\n\r\n", + .{coding}, + ); + try runResponse(&s, &peer, "/tc", &out, &.{resp}); + try testing.expectError( + error.UnsupportedTransferEncoding, + s.httpGet(peer.ip, peer.port, "/tc", &out), + ); + try testing.expectEqual(ip.TcpState.closed, s.tcpState()); + } +} + +test "HTTP: Transfer-Encoding: identity is accepted" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + try runResponse(&s, &peer, "/id", &out, &.{ + "HTTP/1.1 200 OK\r\nTransfer-Encoding: identity\r\nContent-Length: 2\r\n\r\nok", + }); + try testing.expectEqual(@as(usize, 2), try s.httpGet(peer.ip, peer.port, "/id", &out)); +} + +test "HTTP: a malformed status line is refused" { + for ([_][]const u8{ + "ICY 200 OK\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1 200 OK\r\n\r\n", + "HTTP/1.1 2xx OK\r\n\r\n", + // The right shape, the wrong protocol. HTTP/2 has no textual status line at all, so a + // server answering this over a cleartext HTTP/1.1 request is not something to guess at. + "HTTP/2.0 200 OK\r\nContent-Length: 0\r\n\r\n", + "ICE/1.0 200 OK\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1\r\n\r\n", + }) |bad| { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + try runResponse(&s, &peer, "/bad", &out, &.{bad}); + try testing.expectError(error.HttpMalformed, s.httpGet(peer.ip, peer.port, "/bad", &out)); + } +} + +test "HTTP: a Content-Length larger than the caller's buffer fails before any body is copied" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [8]u8 = undefined; + try runResponse(&s, &peer, "/big", &out, &.{ + "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789", + }); + try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/big", &out)); +} + +test "HTTP: an impossible Content-Length fails at once, not after a partial body" { + // 100 promised bytes into an 8-byte buffer, and only five of them ever arrive. The request is + // already impossible when the headers are parsed, and saying so then is the difference between + // an immediate error and a request that hangs until the peer closes. + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [8]u8 = undefined; + try runResponse(&s, &peer, "/early", &out, &.{ + "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n01234", + }); + try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/early", &out)); + try testing.expectEqual(ip.TcpState.closed, s.tcpState()); +} + +test "HTTP: a body longer than the caller's buffer with no Content-Length fails" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [4]u8 = undefined; + try runResponse(&s, &peer, "/big2", &out, &.{ + "HTTP/1.1 200 OK\r\n\r\n0123456789", + }); + try testing.expectError(error.StreamTooLong, s.httpGet(peer.ip, peer.port, "/big2", &out)); +} + +test "HTTP: an oversized header block fails rather than truncating" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + // One header line per segment until the head buffer is full. No blank line ever arrives. + var pieces: [40][]const u8 = undefined; + for (&pieces) |*p| p.* = "X-Padding: 0123456789012345678901234567890123456789\r\n"; + var first: [2][]const u8 = .{ "HTTP/1.1 200 OK\r\n", pieces[0] }; + _ = &first; + try runResponse(&s, &peer, "/hdr", &out, &pieces); + try testing.expectError(error.HttpHeadersTooLong, s.httpGet(peer.ip, peer.port, "/hdr", &out)); +} + +test "HTTP: a Content-Length: 0 response completes on the headers alone" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + try runResponse(&s, &peer, "/empty", &out, &.{ + "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\n\r\n", + }); + try testing.expectEqual(@as(usize, 0), try s.httpGet(peer.ip, peer.port, "/empty", &out)); + try testing.expectEqual(@as(u16, 304), s.httpStatus()); + // Completing the body half-closes, whatever the length was. + try testing.expect(s.tcpState() != .established); +} + +test "HTTP: a truncated body - FIN before Content-Length is met - is an error, not a short read" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + const syn = try startGet(&s, &peer, "/trunc", &out); + const iss = syn.seq; + const our_next = try handshake(&s, &peer, iss); + + const piece = "HTTP/1.1 200 OK\r\nContent-Length: 20\r\n\r\nshort"; + var a = peer.segment(t.ack_f, our_next, piece, false); + s.onFrame(a.bytes()); + peer.seq +%= @intCast(piece.len); + var fin = peer.segment(t.fin | t.ack_f, our_next, &.{}, false); + s.onFrame(fin.bytes()); + try testing.expectError(error.ConnectionClosed, s.httpGet(peer.ip, peer.port, "/trunc", &out)); +} + +test "HTTP: a non-default port appears in the Host header" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac }; + var out: [64]u8 = undefined; + const syn = try startGet(&s, &peer, "/", &out); + var synack = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true); + clearCapture(); + s.onFrame(synack.bytes()); + const req = try decode(sent(0)); + try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 192.168.1.90:8080\r\n") != null); +} + +// ========================================================================= the Host: header +// +// A name-based virtual host - which is what everything behind a CDN is - chooses the site from +// this header alone. `Host: 104.21.46.8` reaches Cloudflare and gets Cloudflare's error page; the +// site is only reachable by name. But a bare address in a lab is only reachable by address, so +// both spellings have to be exactly right. + +/// Start a request, complete the handshake, and return the request segment the stack sent. +fn requestFor(s: *ip.Stack, peer: *Peer, name: ?[]const u8, path: []const u8, out: []u8) !Seg { + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, name, peer.port, path, out)); + const syn = try decode(sent(0)); + peer.stack_port = syn.src_port; + clearCapture(); + var synack = peer.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true); + s.onFrame(synack.bytes()); + return try decode(sent(0)); +} + +test "HTTP Host: a supplied name is sent instead of the address" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + const req = try requestFor(&s, &peer, "0x4200.cafe", "/", &out); + try testing.expect(std.mem.indexOf(u8, req.data, "\r\nHost: 0x4200.cafe\r\n") != null); + // The address is still where the connection went; the name is only ever a header. + try testing.expect(std.mem.indexOf(u8, req.data, "192.168.1.90") == null); +} + +test "HTTP Host: a name keeps the rule that only a non-default port is appended" { + var s80 = newStack(); + var peer80: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out80: [64]u8 = undefined; + const req80 = try requestFor(&s80, &peer80, "0x4200.cafe", "/", &out80); + try testing.expect(std.mem.indexOf(u8, req80.data, "\r\nHost: 0x4200.cafe\r\n") != null); + + var s8080 = newStack(); + var peer8080: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac }; + var out8080: [64]u8 = undefined; + const req8080 = try requestFor(&s8080, &peer8080, "0x4200.cafe", "/", &out8080); + try testing.expect(std.mem.indexOf(u8, req8080.data, "\r\nHost: 0x4200.cafe:8080\r\n") != null); +} + +test "HTTP Host: no name is byte for byte what httpGet has always sent" { + // The working test against a bare address depends on this, so it is asserted on the bytes and + // not on a substring: two stacks with the same MAC and the same tick draw the same ephemeral + // port and the same ISN, so the two requests must be identical octet for octet. + var a = newStack(); + var peer_a: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac }; + var out_a: [64]u8 = undefined; + const req_a = try requestFor(&a, &peer_a, null, "/index.html", &out_a); + var kept: [512]u8 = undefined; + @memcpy(kept[0..req_a.data.len], req_a.data); + const first = kept[0..req_a.data.len]; + + var b = newStack(); + var peer_b: Peer = .{ .ip = peer_ip, .port = 8080, .mac = peer_mac }; + var out_b: [64]u8 = undefined; + b.tick(1000); + b.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer_b.mac, peer_b.ip, zero_mac, our_ip, bcast_mac); + b.onFrame(probe.bytes()); + clearCapture(); + try testing.expectError(error.WouldBlock, b.httpGet(peer_b.ip, peer_b.port, "/index.html", &out_b)); + const syn = try decode(sent(0)); + peer_b.stack_port = syn.src_port; + clearCapture(); + var synack = peer_b.segment(t.syn | t.ack_f, syn.seq +% 1, &.{}, true); + b.onFrame(synack.bytes()); + const req_b = try decode(sent(0)); + + try testing.expectEqualSlices(u8, first, req_b.data); + try testing.expect(std.mem.indexOf(u8, req_b.data, "\r\nHost: 192.168.1.90:8080\r\n") != null); +} + +test "HTTP Host: the name is part of the request's identity, so changing it is Busy" { + var s = newStack(); + const peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var probe = arpFrame(1, peer.mac, peer.ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + + try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, "0x4200.cafe", 80, "/", &out)); + // The same call again is the protocol. + try testing.expectError(error.WouldBlock, s.httpGetHost(peer.ip, "0x4200.cafe", 80, "/", &out)); + // A different virtual host on the same address for the same path is a different request, and + // riding on this connection would fetch the wrong site under the right name. + try testing.expectError(error.Busy, s.httpGetHost(peer.ip, "example.com", 80, "/", &out)); + // And "no name" is not the same request as any name. + try testing.expectError(error.Busy, s.httpGetHost(peer.ip, null, 80, "/", &out)); + try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/", &out)); +} + +test "HTTP: httpGet before an address exists is refused" { + var s = newStack(); + var out: [64]u8 = undefined; + try testing.expectError(error.NoAddress, s.httpGet(peer_ip, 80, "/", &out)); +} + +test "HTTP: re-entering with different arguments is refused rather than silently switching" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + var other: [64]u8 = undefined; + _ = try startGet(&s, &peer, "/one", &out); + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, 80, "/one", &out)); + try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/two", &out)); + try testing.expectError(error.Busy, s.httpGet(peer.ip, 81, "/one", &out)); + try testing.expectError(error.Busy, s.httpGet(gw_ip, 80, "/one", &out)); + // A different output buffer is the dangerous one: the body is written as it arrives, so the + // stack is holding a pointer into the first. + try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", &other)); + // Same buffer, shorter: `Content-Length` was already checked against the original length, and + // the body is written through the original slice, so a shrunk view is just as wrong. + try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", out[0..32])); + try testing.expectError(error.Busy, s.httpGet(peer.ip, 80, "/one", out[1..])); + // The original arguments still work. + try testing.expectError(error.WouldBlock, s.httpGet(peer.ip, 80, "/one", &out)); +} + +test "HTTP: a path longer than the request buffer is refused" { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + var out: [64]u8 = undefined; + const long: [600]u8 = @splat('a'); + try testing.expectError(error.RequestTooLong, s.httpGet(peer_ip, 80, &long, &out)); +} + +test "HTTP: two requests in sequence use different ephemeral ports" { + var s = newStack(); + var peer: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + var out: [64]u8 = undefined; + try runResponse(&s, &peer, "/a", &out, &.{"HTTP/1.1 200 OK\r\nContent-Length: 1\r\na\r\n\r\na"}); + _ = try s.httpGet(peer.ip, peer.port, "/a", &out); + const first_port = peer.stack_port; + + const peer2: Peer = .{ .ip = peer_ip, .port = 80, .mac = peer_mac }; + clearCapture(); + try testing.expectError(error.WouldBlock, s.httpGet(peer2.ip, peer2.port, "/b", &out)); + const syn = try decode(sent(0)); + try testing.expect(syn.src_port != first_port); +} + +// ====================================================================================== DNS +// +// RFC 1035. The header offsets and the name encoding below are written out again from the RFC, +// like every other wire format in this file. The parts that need testing are not the header - +// six 16-bit fields - but the two that are easy to get wrong and impossible to see when they are: +// matching the *question* as well as the id, and following compression pointers under a bound. + +/// RFC 1035 4.1.1, re-derived. +const q = struct { + const id = 0; + const flags = 2; + const qdcount = 4; + const ancount = 6; + const nscount = 8; + const arcount = 10; + const hlen = 12; +}; + +/// The resolver this network's DHCP server hands out: the gateway itself. +const dns_ip: ip.Ip4 = .{ 192, 168, 1, 1 }; + +/// RFC 1035 4.1.2 name encoding. No validation, deliberately: a test that shared the encoder's +/// checks could not write a malformed name to see the stack reject it. +fn wireName(buf: []u8, name: []const u8) usize { + var o: usize = 0; + var labels = std.mem.splitScalar(u8, name, '.'); + while (labels.next()) |label| { + buf[o] = @intCast(label.len); + @memcpy(buf[o + 1 ..][0..label.len], label); + o += 1 + label.len; + } + buf[o] = 0; + return o + 1; +} + +/// A DNS message under construction. +const Msg = struct { + buf: [512]u8 = @splat(0), + len: usize = 0, + + fn header(self: *Msg, id: u16, flags: u16, qd: u16, an: u16) void { + put16(&self.buf, q.id, id); + put16(&self.buf, q.flags, flags); + put16(&self.buf, q.qdcount, qd); + put16(&self.buf, q.ancount, an); + put16(&self.buf, q.nscount, 0); + put16(&self.buf, q.arcount, 0); + self.len = q.hlen; + } + + fn question(self: *Msg, name: []const u8, qtype: u16, qclass: u16) void { + self.len += wireName(self.buf[self.len..], name); + self.be(qtype); + self.be(qclass); + } + + /// Append one big-endian 16-bit field. + fn be(self: *Msg, v: u16) void { + put16(&self.buf, self.len, v); + self.len += 2; + } + + fn bytes(self: *Msg, b: []const u8) void { + @memcpy(self.buf[self.len..][0..b.len], b); + self.len += b.len; + } + + /// A resource record whose owner name is a compression pointer to `name_off`, which is what a + /// real server emits for every record after the first: the question's name is at offset 12, + /// and every answer points at it. + fn rr(self: *Msg, name_off: u16, rtype: u16, rclass: u16, rdata: []const u8) void { + self.be(0xc000 | name_off); + self.be(rtype); + self.be(rclass); + put32(&self.buf, self.len, 300); // TTL + self.len += 4; + self.be(@intCast(rdata.len)); + self.bytes(rdata); + } + + fn slice(self: *const Msg) []const u8 { + return self.buf[0..self.len]; + } +}; + +/// A UDP datagram from `src`:`sport` to our address at `dport`. +fn udpFrame(src: ip.Ip4, sport: u16, dport: u16, payload: []const u8) Frame { + var f: Frame = .{}; + f.eth(our_mac, gw_mac, 0x0800); + const seg_len = 8 + payload.len; + const p = f.ip4(src, our_ip, 17, seg_len); + put16(p, 0, sport); + put16(p, 2, dport); + put16(p, 4, @intCast(seg_len)); + put16(p, 6, 0); + @memcpy(p[8..], payload); + f.sealTransport(6); + return f; +} + +/// A stack with an address, a resolver, and the resolver's MAC already learnt. +fn newResolverStack() ip.Stack { + var s = newStack(); + s.tick(1000); + s.setStatic(our_ip, mask24, gw_ip); + s.setDnsServer(dns_ip); + var probe = arpFrame(1, gw_mac, dns_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + return s; +} + +/// The DNS payload of a captured query, with both checksums verified independently. Also returns +/// the source port, which is the other half of what an off-path spoofer has to guess. +fn queryOut(frame: []const u8) !struct { msg: []const u8, sport: u16 } { + try testing.expectEqual(@as(u16, 0x0800), be16(frame, 12)); + const h = frame[14..34]; + try testing.expectEqual(@as(u8, 17), h[9]); // UDP + try verify(h); + try testing.expectEqualSlices(u8, &dns_ip, h[16..20]); + const total = be16(h, 2); + const seg = frame[34 .. 14 + total]; + try testing.expectEqual(@as(u16, 53), be16(seg, 2)); + try testing.expectEqual(@as(u16, @intCast(seg.len)), be16(seg, 4)); + try verifyTransport(h[12..16].*, h[16..20].*, 17, seg); + return .{ .msg = seg[8..], .sport = be16(seg, 0) }; +} + +/// Answer the outstanding query with `an` answer records built by `fill`, and return the address +/// `resolve` then produces - or the error it produces. +fn answerWith(s: *ip.Stack, name: []const u8, m: *Msg) !ip.Ip4 { + var f = udpFrame(dns_ip, 53, dns_query_port, m.slice()); + s.onFrame(f.bytes()); + return s.resolve(name); +} + +/// The source port of the query most recently captured, filled in by `startResolve`. +var dns_query_port: u16 = 0; + +/// Start a query and record its id and source port. +fn startResolve(s: *ip.Stack, name: []const u8) !u16 { + try testing.expectError(error.WouldBlock, s.resolve(name)); + try testing.expectEqual(@as(usize, 1), cap_n); + const out = try queryOut(sent(0)); + dns_query_port = out.sport; + clearCapture(); + return be16(out.msg, q.id); +} + +test "DNS: the query is one A/IN question, recursion desired, from an ephemeral port" { + var s = newResolverStack(); + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); + try testing.expectEqual(@as(usize, 1), cap_n); + const out = try queryOut(sent(0)); + const msg = out.msg; + + try testing.expect(out.sport >= 49152); // RFC 6335 dynamic range + // QR=0, OPCODE=0, RD=1, and nothing else. RFC 1035 4.1.1. + try testing.expectEqual(@as(u16, 0x0100), be16(msg, q.flags)); + try testing.expectEqual(@as(u16, 1), be16(msg, q.qdcount)); + try testing.expectEqual(@as(u16, 0), be16(msg, q.ancount)); + try testing.expectEqual(@as(u16, 0), be16(msg, q.nscount)); + try testing.expectEqual(@as(u16, 0), be16(msg, q.arcount)); + + // The question: `6 0x4200 4 cafe 0`, then QTYPE=A, QCLASS=IN. Written out literally, because + // the length-prefixed encoding is the thing being checked. + const want = [_]u8{ 6, '0', 'x', '4', '2', '0', '0', 4, 'c', 'a', 'f', 'e', 0 }; + try testing.expectEqualSlices(u8, &want, msg[q.hlen..][0..want.len]); + try testing.expectEqual(@as(u16, 1), be16(msg, q.hlen + want.len)); // QTYPE=A + try testing.expectEqual(@as(u16, 1), be16(msg, q.hlen + want.len + 2)); // QCLASS=IN + try testing.expectEqual(@as(usize, q.hlen + want.len + 4), msg.len); + try testing.expectEqual(@as(u32, 1), s.counters.dns_tx); +} + +test "DNS: an answer resolves the name, and the query slot is released" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); // QR, RD, RA, RCODE 0 + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); + try testing.expectEqual(@as(u32, 1), s.counters.dns_rx); + // The slot is free again: a second name resolves without an intervening reset. + try testing.expectError(error.WouldBlock, s.resolve("example.com")); +} + +test "DNS: a CNAME ahead of the A record is stepped over, not read as an address" { + // This is the shape a CDN answers with, and a resolver that reads answer[0] gets a name where + // it wanted four octets. RDLENGTH would even be 4 for a short enough label. + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var cname: [32]u8 = undefined; + const cname_len = wireName(&cname, "edge.example"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 3); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 5, 1, cname[0..cname_len]); // CNAME + m.rr(q.hlen, 28, 1, &[_]u8{0} ** 16); // AAAA - also not an address this stack can use + m.rr(q.hlen, 1, 1, &[_]u8{ 172, 67, 221, 247 }); // and finally the A + + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 172, 67, 221, 247 }, &got); +} + +test "DNS: an owner name written out in full, not compressed, is skipped correctly" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + var full: [32]u8 = undefined; + m.bytes(full[0..wireName(&full, "0x4200.cafe")]); + m.be(1); // A + m.be(1); // IN + m.bytes(&[_]u8{ 0, 0, 1, 44 }); // TTL + m.be(4); + m.bytes(&[_]u8{ 104, 21, 46, 8 }); + + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); +} + +test "DNS: a compression pointer that loops is bounded, not followed forever" { + // The gadget: at the start of the answer section, a one-byte label followed by a pointer back + // to that label. Every jump goes strictly backwards - so the "pointers must point backwards" + // check that most parsers stop at passes it - and the walk still never ends, because stepping + // over the label moves forward again. Only counting the jumps terminates this. + // + // If this test hangs, it has failed. That is the whole point of it. + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + const gadget: u16 = @intCast(m.len); + m.bytes(&[_]u8{ 1, 'x' }); // a label... + m.be(0xc000 | gadget); // ...and a pointer back to it + + try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: a compression pointer that points forward is rejected" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + // A forward pointer that a parser without the backwards rule would happily follow: it lands + // on a root label placed at the very end of this message, so the name resolves, the record + // behind it parses, and an address comes out. RFC 1035 4.1.4 only ever compresses against a + // *prior* occurrence, and the rule is what keeps `dnsSkipName`'s jumps monotone. + m.be(0xc000 | 0x002d); // -> offset 45, the root label appended below + m.be(1); // A + m.be(1); // IN + m.bytes(&[_]u8{ 0, 0, 1, 44 }); // TTL + m.be(4); + m.bytes(&[_]u8{ 6, 6, 6, 6 }); + try testing.expectEqual(@as(usize, 45), m.len); + m.bytes(&[_]u8{0}); // the root label the pointer aims at + try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m)); + + // And one aimed past the end of the message entirely. + var s2 = newResolverStack(); + const id2 = try startResolve(&s2, "0x4200.cafe"); + var far: Msg = .{}; + far.header(id2, 0x8180, 1, 1); + far.question("0x4200.cafe", 1, 1); + far.be(0xc000 | 0x00fa); + try testing.expectError(error.DnsMalformed, answerWith(&s2, "0x4200.cafe", &far)); +} + +test "DNS: a reserved label type is refused rather than guessed past" { + // RFC 1035 4.1.4 defines the two top bits of a length byte: 00 is a label, 11 is a pointer, + // 01 and 10 are reserved. A parser that treats 0x40 as "a label of 64 bytes" walks somewhere + // arbitrary and then keeps going - here, straight onto a well-formed A record. + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.bytes(&[_]u8{0x40}); // reserved type, low bits zero + m.bytes(&([_]u8{'z'} ** 64)); // what a 0x40-as-length parser would skip + m.bytes(&[_]u8{0}); // ...landing on a root label, so the name "parses" + m.be(1); + m.be(1); + m.bytes(&[_]u8{ 0, 0, 1, 44 }); + m.be(4); + m.bytes(&[_]u8{ 6, 6, 6, 6 }); + try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: a pointer to a self-referential offset in the question is bounded too" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + const here: u16 = @intCast(m.len); + // A pointer to itself: rejected by the backwards check alone, since the target is not less + // than the pointer's own offset. + m.be(0xc000 | here); + try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: a response with the wrong transaction id is ignored, and the query stays live" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id +% 1, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 1, 2, 3, 4 }); + try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m)); + try testing.expectEqual(@as(u32, 0), s.counters.dns_rx); +} + +test "DNS: a response echoing a different question is ignored" { + // The id alone is 16 bits. A resolver that checks only the id accepts an answer for any name + // an attacker likes, which is the entire cache-poisoning family. + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("evil.example", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m)); + + // The one that matters, and the one a length-blind check misses: a different name of exactly + // the same encoded length, so QTYPE and QCLASS still land where they are expected and every + // check but the name's own passes. `kafe` for `cafe`. + var lookalike: Msg = .{}; + lookalike.header(id, 0x8180, 1, 1); + lookalike.question("0x4200.kafe", 1, 1); + lookalike.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + // The same encoded length as the question we actually asked, so nothing after the name moves. + var ours: Msg = .{}; + ours.header(id, 0x8180, 1, 1); + ours.question("0x4200.cafe", 1, 1); + ours.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + try testing.expectEqual(ours.len, lookalike.len); + try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &lookalike)); + + // Nor a right name asked as the wrong type or class. + var wrong_type: Msg = .{}; + wrong_type.header(id, 0x8180, 1, 1); + wrong_type.question("0x4200.cafe", 28, 1); // AAAA + wrong_type.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &wrong_type)); + + var wrong_class: Msg = .{}; + wrong_class.header(id, 0x8180, 1, 1); + wrong_class.question("0x4200.cafe", 1, 3); // CH + wrong_class.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &wrong_class)); +} + +test "DNS: the echoed question is matched case-insensitively, as RFC 4343 requires" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0X4200.CAFE", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); +} + +test "DNS: a response from the wrong source, or the wrong port, is ignored" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + + var wrong_src = udpFrame(.{ 192, 168, 1, 250 }, 53, dns_query_port, m.slice()); + s.onFrame(wrong_src.bytes()); + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); + + var wrong_port = udpFrame(dns_ip, 5353, dns_query_port, m.slice()); + s.onFrame(wrong_port.bytes()); + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); + + // And to a port that is not the one this query was sent from. + var wrong_dport = udpFrame(dns_ip, 53, dns_query_port +% 1, m.slice()); + s.onFrame(wrong_dport.bytes()); + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); + + // The right one still works, so the three rejections above are not rejecting everything. + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 6, 6, 6, 6 }, &got); +} + +test "DNS: a query, not a response, on the right port is ignored" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x0100, 1, 1); // QR clear + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 6, 6, 6, 6 }); + try testing.expectError(error.WouldBlock, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: NXDOMAIN and a refusal are distinct named errors" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var nx: Msg = .{}; + nx.header(id, 0x8183, 1, 0); // RCODE 3 + nx.question("0x4200.cafe", 1, 1); + try testing.expectError(error.NameNotFound, answerWith(&s, "0x4200.cafe", &nx)); + + var s2 = newResolverStack(); + const id2 = try startResolve(&s2, "0x4200.cafe"); + var refused: Msg = .{}; + refused.header(id2, 0x8185, 1, 0); // RCODE 5, REFUSED + refused.question("0x4200.cafe", 1, 1); + try testing.expectError(error.DnsRefused, answerWith(&s2, "0x4200.cafe", &refused)); +} + +test "DNS: an answer with no A record in it is NameNotFound, not a hang" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 28, 1, &[_]u8{0} ** 16); // AAAA only + try testing.expectError(error.NameNotFound, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: an A record with the wrong RDLENGTH is not read as an address" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 2); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 1, 2, 3 }); // an A record three bytes long + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); // the real one, behind it + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); +} + +test "DNS: an RDLENGTH that runs past the end of the message is refused, not read" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.be(0xc000 | q.hlen); + m.be(1); + m.be(1); + m.bytes(&[_]u8{ 0, 0, 1, 44 }); + m.be(400); // RDLENGTH far past what follows + m.bytes(&[_]u8{ 104, 21, 46, 8 }); + try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: every truncation of a good response is refused, and none is read off the end" { + // Every prefix of a well-formed answer, each against a *fresh* query - which is the part that + // matters. Feeding them all to one query would stop testing after the first prefix that + // decided it, because a decided query stops listening, and the prefixes that cut inside the + // resource record - exactly the ones whose bounds are worth checking - come last. + var cut: usize = 0; + while (cut < 45) : (cut += 1) { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + try testing.expectEqual(@as(usize, 45), m.len); + + var f = udpFrame(dns_ip, 53, dns_query_port, m.buf[0..cut]); + s.onFrame(f.bytes()); + // Ignored or refused, but never resolved: a prefix of the truth is not the truth. + if (s.resolve("0x4200.cafe")) |_| return error.TestUnexpectedResult else |_| {} + } + // ...and the whole thing does resolve, so the loop above is rejecting truncation and not + // simply rejecting everything. + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); +} + +test "DNS: two queries in sequence use different source ports" { + // The id is 16 bits and the port is the other 16. Reusing one port halves what an off-path + // spoofer has to guess, and makes a late answer to the previous query land on the live one. + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + const first_port = dns_query_port; + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + _ = try answerWith(&s, "0x4200.cafe", &m); + + _ = try startResolve(&s, "example.com"); + try testing.expect(dns_query_port != first_port); +} + +test "DNS: an answer count larger than the answers present does not walk off the end" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 0xffff); // 65,535 answers promised, none delivered + m.question("0x4200.cafe", 1, 1); + try testing.expectError(error.DnsMalformed, answerWith(&s, "0x4200.cafe", &m)); +} + +test "DNS: the query is retransmitted on a doubling timer and then times out" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + + // Nothing before the first deadline. The query went out at t=1000 with a 1 s timer. + s.tick(1_999); + try testing.expectEqual(@as(usize, 0), cap_n); + + s.tick(2_000); + try testing.expectEqual(@as(usize, 1), cap_n); + const re = try queryOut(sent(0)); + // The same id, so an answer to the first attempt still counts. Redrawing it is how a slow + // resolver turns into a timeout on a network that was working. + try testing.expectEqual(id, be16(re.msg, q.id)); + clearCapture(); + + s.tick(3_999); + try testing.expectEqual(@as(usize, 0), cap_n); + s.tick(4_000); + try testing.expectEqual(@as(usize, 1), cap_n); + clearCapture(); + + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); + s.tick(8_000); + try testing.expectError(error.TimedOut, s.resolve("0x4200.cafe")); + try testing.expectEqual(@as(u32, 3), s.counters.dns_tx); + try testing.expectEqual(@as(u32, 2), s.counters.dns_retx); + + // And the slot is free: the next call starts a new query rather than returning the old error. + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); +} + +test "DNS: a late answer to an abandoned query does not resolve a new one" { + var s = newResolverStack(); + const first_id = try startResolve(&s, "0x4200.cafe"); + const first_port = dns_query_port; + // The whole schedule: 1 s, 2 s, 4 s, then out of tries. + s.tick(2_000); + s.tick(4_000); + s.tick(8_000); + clearCapture(); + try testing.expectError(error.TimedOut, s.resolve("0x4200.cafe")); + _ = try startResolve(&s, "0x4200.cafe"); + + var m: Msg = .{}; + m.header(first_id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 9, 9, 9, 9 }); + var f = udpFrame(dns_ip, 53, first_port, m.slice()); + s.onFrame(f.bytes()); + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); +} + +test "DNS: a second name while a query is in flight is Busy, and the first is untouched" { + var s = newResolverStack(); + const id = try startResolve(&s, "0x4200.cafe"); + try testing.expectError(error.Busy, s.resolve("example.com")); + // The same name, spelled with a trailing root dot and in a different case, is the same query. + try testing.expectError(error.WouldBlock, s.resolve("0X4200.CAFE.")); + try testing.expectError(error.WouldBlock, s.resolve("0x4200.cafe")); + + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + const got = try answerWith(&s, "0x4200.cafe.", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); +} + +test "DNS: with no resolver and no address, resolve says which one is missing" { + var no_server = newStack(); + no_server.tick(1000); + no_server.setStatic(our_ip, mask24, gw_ip); + clearCapture(); + try testing.expectError(error.NoDnsServer, no_server.resolve("0x4200.cafe")); + try testing.expectEqual(@as(usize, 0), cap_n); + + var no_addr = newStack(); + no_addr.tick(1000); + no_addr.setDnsServer(dns_ip); + clearCapture(); + try testing.expectError(error.NoAddress, no_addr.resolve("0x4200.cafe")); + try testing.expectEqual(@as(usize, 0), cap_n); +} + +test "DNS: an unusable name is refused before a byte leaves, and says which way it was unusable" { + var s = newResolverStack(); + const long: [ip.dns_name_max + 1]u8 = @splat('a'); + try testing.expectError(error.NameTooLong, s.resolve(&long)); + // A label over 63 bytes, inside a name that is itself short enough - so this is the label + // rule and not the name rule that rejects it. + const long_label = "b" ** 64; + for ([_][]const u8{ "", ".", "..", ".a", "a..b", long_label }) |bad| { + try testing.expectError(error.NameInvalid, s.resolve(bad)); + } + try testing.expectEqual(@as(usize, 0), cap_n); + // A name of exactly the maximum is fine, and is what proves the limit is off by nothing: + // 31 + 1 + 32 = 64 text bytes, encoding to 66 - which is `dns_qname_max` exactly. + const ok = "a" ** 31 ++ "." ++ "b" ** 32; + try testing.expectEqual(@as(usize, ip.dns_name_max), ok.len); + try testing.expectError(error.WouldBlock, s.resolve(ok)); +} + +test "DNS: the resolver DHCP supplied is the one resolve asks, with nothing configured" { + // The default path on this network: the lease carries option 6 and the caller does nothing. + var s = newStack(); + s.tick(10_000); + s.dhcpStart(); + const discover = try dhcpOut(sent(0)); + const xid = be32(discover, d.xid); + + var offer = dhcpReply(2, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(offer.bytes()); + var ack = dhcpReply(5, xid, our_ip, gw_ip, &standard_opts, our_ip, our_mac); + s.onFrame(ack.bytes()); + try testing.expectEqual(ip.DhcpState.bound, s.dhcpState()); + try testing.expectEqualSlices(u8, &dns_ip, &(s.dnsServer().?)); + + // The resolver's MAC, so the query can actually be addressed. + var probe = arpFrame(1, gw_mac, dns_ip, zero_mac, our_ip, bcast_mac); + s.onFrame(probe.bytes()); + clearCapture(); + + const id = try startResolve(&s, "0x4200.cafe"); + var m: Msg = .{}; + m.header(id, 0x8180, 1, 1); + m.question("0x4200.cafe", 1, 1); + m.rr(q.hlen, 1, 1, &[_]u8{ 104, 21, 46, 8 }); + const got = try answerWith(&s, "0x4200.cafe", &m); + try testing.expectEqualSlices(u8, &[_]u8{ 104, 21, 46, 8 }, &got); +} + +test "DNS: a new lease abandons a query in flight rather than leaving it to time out" { + var s = newResolverStack(); + _ = try startResolve(&s, "0x4200.cafe"); + s.dhcpStart(); + // No address and no resolver now, and the query is gone with them - so this is the error that + // names what is missing, not `Busy` from a query nobody can answer. + try testing.expectError(error.NoAddress, s.resolve("0x4200.cafe")); +} + +test "identity: the clock stirs the transaction ids, so two boots do not collide" { + // Same MAC, same firmware, different moment of first tick. If `tick` did not mix `now_ms` into + // the entropy, both would draw identical DHCP transaction ids and identical initial sequence + // numbers, and a reboot would happily accept a reply meant for its previous incarnation. + var a = newStack(); + a.tick(1234); + a.dhcpStart(); + const xid_a = be32(sent(0)[42..], d.xid); + + var b = newStack(); + b.tick(9_876_543); + b.dhcpStart(); + const xid_b = be32(sent(0)[42..], d.xid); + + try testing.expect(xid_a != xid_b); +} + +// ================================================================================ footprint + +test "footprint: the static cost of one Stack" { + // No printing. The test runner speaks a binary protocol over its own stdio under + // `zig build test`, and a diagnostic in the middle of it costs the whole suite's results for + // the sake of a number that an assertion states better anyway. + // + // 6 KiB is the ceiling, and it is not arbitrary: the image has ~128 KB of L2MEM, nothing + // initialises the 32 MB of PSRAM, and ESP-Hosted's queues and its task stacks compete for the + // same space. The stack is ~4,600 bytes today: 3,472 before chunked decoding and the resolver + // (104 bytes between them, mostly the encoded question), then 1,024 more when `http_head_max` + // went 1024 -> 2048 to fit a real CDN response head - measured at 1,043 bytes from the site this + // was pointed at, which failed the request by 19 bytes at the old size. + // + // A regression to 30 KiB would not announce itself any other way; it would show up as a stack + // overflow on the die. The heap in examples/http.zig was reduced by the same 2 KB this raise + // cost, so the image's total is unchanged. + const n = ip.Stack.footprint; + try testing.expect(n <= 6 * 1024); + // And a floor, so the ceiling cannot be met by quietly shrinking a buffer that the protocol + // needs: one full frame to build in, the request held for retransmission, the response head + // held while waiting for the blank line, and the DNS question held for the retransmissions + // and for the comparison against what the server echoes back. + try testing.expect(n >= ip.frame_max + ip.tcp_tx_max + ip.http_head_max + ip.dns_qname_max); +} diff --git a/src/net/libc.zig b/src/net/libc.zig new file mode 100644 index 0000000..38eab6d --- /dev/null +++ b/src/net/libc.zig @@ -0,0 +1,457 @@ +//! The libc symbols ESP-Hosted's C reaches for, and nothing more. +//! +//! This is not a libc. It is the exact set measured by linking the transport, and each entry is here +//! because a specific call site needs it: +//! +//! nm on the milestone-1 objects (transport_drv.o transport_util.o sdio_drv.o mempool.o) leaves +//! 26 undefined symbols. These are the libc ones: memcpy memset strcpy snprintf __errno_location +//! htole16 le16toh, plus malloc/free/realloc once mempool.c is included. +//! +//! Two sources cover them: +//! +//! 1. compiler_rt, which Zig links automatically. It provides the memory primitives - memcpy, +//! memset, memcmp, memmove - and the integer helpers clang emits for 64-bit division on a +//! 32-bit target, __udivdi3 and __divdi3. It provides no `str*` functions at all. +//! 2. This file, for everything else. +//! +//! The P4 mask ROM is a third possibility that this project deliberately does not use yet. +//! `components/esp_rom/esp32p4/ld/esp32p4.rom.newlib.ld` exports 32 newlib symbols - strlen, +//! strlcpy, strchr, strstr, memset, qsort, atoi and friends - as absolute addresses, which would +//! cost no code in the image and would be the same implementation IDF links. It is not wired in +//! because that file assigns those names unconditionally rather than with PROVIDE, so it would +//! collide with compiler_rt's own memset and memcpy definitions. Trading a real duplicate-symbol +//! hazard for a few hundred bytes is not worth it while the image is 2 KB. +//! +//! Deliberately absent: stdio beyond snprintf, locale, floating-point formatting beyond what +//! std.fmt gives, and anything reentrant. If a link error names a symbol not here, the honest move +//! is to add it here with a comment saying which call site wanted it - not to link a real libc. + +const std = @import("std"); + +/// Set by `install`. ESP-Hosted allocates per-packet buffers and frees them, so this cannot be an +/// arena; see the allocator discussion in src/net/port.zig. +var gpa: ?std.mem.Allocator = null; + +pub fn install(allocator: std.mem.Allocator) void { + gpa = allocator; +} + +// --------------------------------------------------------------------------------------------- +// malloc family +// +// C's `free` carries no size, but Zig's Allocator.free needs one. The classic fix is a header word +// in front of every block holding the length. It costs 8 bytes per allocation (the word plus +// padding to keep the payload 8-aligned, which the SDIO IDMAC path needs anyway) and it is the only +// way to bridge the two contracts without a side table. +// --------------------------------------------------------------------------------------------- + +/// Payload alignment. 8 rather than 4 because DMA descriptors on this chip want 8-byte alignment, +/// and buffers handed to CMD53 come from here. +const malloc_align: std.mem.Alignment = .@"8"; +const header_size = malloc_align.toByteUnits(); + +comptime { + // The header must not push the payload out of alignment. + std.debug.assert(header_size >= @sizeOf(usize)); + std.debug.assert(header_size % malloc_align.toByteUnits() == 0); +} + +fn allocBlock(total_payload: usize) ?[*]u8 { + const a = gpa orelse @panic("libc malloc before install()"); + const raw = a.rawAlloc(header_size + total_payload, malloc_align, @returnAddress()) orelse + return null; + // Record the payload length in the word directly before the payload. + const payload = raw + header_size; + @as(*usize, @ptrCast(@alignCast(raw))).* = total_payload; + return payload; +} + +fn payloadLen(payload: [*]u8) usize { + return @as(*const usize, @ptrCast(@alignCast(payload - header_size))).*; +} + +fn freeBlock(payload: [*]u8) void { + const a = gpa orelse @panic("libc free before install()"); + const len = payloadLen(payload); + a.rawFree((payload - header_size)[0 .. header_size + len], malloc_align, @returnAddress()); +} + +export fn malloc(size: usize) callconv(.c) ?*anyopaque { + if (size == 0) return null; + return @ptrCast(allocBlock(size)); +} + +export fn calloc(n: usize, size: usize) callconv(.c) ?*anyopaque { + const total = std.math.mul(usize, n, size) catch return null; + if (total == 0) return null; + const p = allocBlock(total) orelse return null; + @memset(p[0..total], 0); + return @ptrCast(p); +} + +export fn free(ptr: ?*anyopaque) callconv(.c) void { + const p = ptr orelse return; + freeBlock(@ptrCast(p)); +} + +export fn realloc(ptr: ?*anyopaque, size: usize) callconv(.c) ?*anyopaque { + const p = ptr orelse return malloc(size); + if (size == 0) { + freeBlock(@ptrCast(p)); + return null; + } + const old: [*]u8 = @ptrCast(p); + const old_len = payloadLen(old); + if (old_len == size) return ptr; + + // Try to grow or shrink in place first; the allocator may well be able to, and mempool.c + // reallocs the same buffer repeatedly. + const a = gpa orelse @panic("libc realloc before install()"); + const whole = (old - header_size)[0 .. header_size + old_len]; + if (a.rawResize(whole, malloc_align, header_size + size, @returnAddress())) { + @as(*usize, @ptrCast(@alignCast(old - header_size))).* = size; + return ptr; + } + + const new = allocBlock(size) orelse return null; + @memcpy(new[0..@min(old_len, size)], old[0..@min(old_len, size)]); + freeBlock(old); + return @ptrCast(new); +} + +/// ESP-Hosted's `_h_malloc_align` path and IDF's `heap_caps_aligned_alloc` both land here. The +/// header trick still works as long as the requested alignment is not stricter than ours; anything +/// stricter would need the payload moved and the header written at a computed offset, and nothing +/// in the measured surface asks for that. Assert rather than silently misalign a DMA buffer. +export fn aligned_alloc(alignment: usize, size: usize) callconv(.c) ?*anyopaque { + // A stricter alignment would need the payload moved and the header written at a computed + // offset. Nothing in the measured surface asks for it, so this asserts rather than silently + // handing back a misaligned DMA buffer - which would corrupt a packet, not fail a call. + if (alignment > malloc_align.toByteUnits()) @panic("aligned_alloc: alignment stricter than 8"); + return malloc(size); +} + +// --------------------------------------------------------------------------------------------- +// string +// +// compiler_rt covers `mem*` and nothing else, so every `str*` ESP-Hosted references is here. The +// list is exactly what the link demanded - measured, not anticipated. +// --------------------------------------------------------------------------------------------- + +export fn strlen(s: [*:0]const u8) callconv(.c) usize { + // std.mem.len is the same loop; going through it keeps this honest about being a wrapper rather + // than a hand-optimised copy of something the standard library already has. + return std.mem.len(s); +} + +export fn strcpy(dst: [*]u8, src: [*:0]const u8) callconv(.c) [*]u8 { + var i: usize = 0; + while (src[i] != 0) : (i += 1) dst[i] = src[i]; + dst[i] = 0; + return dst; +} + +export fn strnlen(s: [*]const u8, max: usize) callconv(.c) usize { + var i: usize = 0; + while (i < max and s[i] != 0) : (i += 1) {} + return i; +} + +export fn strcmp(a: [*:0]const u8, b: [*:0]const u8) callconv(.c) c_int { + var i: usize = 0; + while (a[i] != 0 and a[i] == b[i]) : (i += 1) {} + return @as(c_int, a[i]) - @as(c_int, b[i]); +} + +export fn strncmp(a: [*]const u8, b: [*]const u8, n: usize) callconv(.c) c_int { + var i: usize = 0; + while (i < n) : (i += 1) { + if (a[i] != b[i]) return @as(c_int, a[i]) - @as(c_int, b[i]); + if (a[i] == 0) break; + } + return 0; +} + +// --------------------------------------------------------------------------------------------- +// endian helpers +// +// These are macros in musl's , but ESP-Hosted takes their address in a couple of places, +// so clang emits calls and the linker wants real symbols. riscv32 is little-endian, so both are +// identity - which is exactly why getting them wrong would be invisible here and corrupt on a +// big-endian host. Written as byte-order conversions rather than `return x` to say so. +// --------------------------------------------------------------------------------------------- + +export fn htole16(x: u16) callconv(.c) u16 { + return std.mem.nativeToLittle(u16, x); +} + +export fn le16toh(x: u16) callconv(.c) u16 { + return std.mem.littleToNative(u16, x); +} + +export fn htole32(x: u32) callconv(.c) u32 { + return std.mem.nativeToLittle(u32, x); +} + +export fn le32toh(x: u32) callconv(.c) u32 { + return std.mem.littleToNative(u32, x); +} + +// --------------------------------------------------------------------------------------------- +// errno +// +// ESP-Hosted reads errno after its own calls fail. There are no threads competing for it in a +// cooperative runtime, so one global is correct here; it would need to be per-task the moment a +// preemptive scheduler appeared. +// --------------------------------------------------------------------------------------------- + +var errno_storage: c_int = 0; + +export fn __errno_location() callconv(.c) *c_int { + return &errno_storage; +} + +// --------------------------------------------------------------------------------------------- +// snprintf +// +// The one genuinely non-trivial entry. ESP-Hosted uses it for log lines and for formatting MAC +// addresses and transport state, so the conversions that matter are %d %u %x %s %c %p and width / +// zero-pad on the integer ones. std.fmt does the formatting; this only parses the C format string. +// +// Unsupported conversions print `%!` followed by the specifier rather than being skipped, so a +// format this does not handle is visible in the log instead of silently dropping its argument. +// --------------------------------------------------------------------------------------------- + +export fn snprintf(buf: [*]u8, size: usize, fmt: [*:0]const u8, ...) callconv(.c) c_int { + var ap = @cVaStart(); + defer @cVaEnd(&ap); + return vsnprintfImpl(buf, size, fmt, &ap); +} + +export fn vsnprintf( + buf: [*]u8, + size: usize, + fmt: [*:0]const u8, + ap: *std.builtin.VaList, +) callconv(.c) c_int { + return vsnprintfImpl(buf, size, fmt, ap); +} + +/// `callconv(.c)` is required, not stylistic: `@cVaArg` is only available in a function using the C +/// calling convention, and Zig rejects it in an `auto` one. +fn vsnprintfImpl( + buf: [*]u8, + size: usize, + fmt: [*:0]const u8, + ap: *std.builtin.VaList, +) callconv(.c) c_int { + // Writes into the caller's buffer, tracking how many bytes *would* have been written, because + // that is what snprintf returns and callers use it to size a second call. + var out: Counting = .{ .buf = if (size == 0) &.{} else buf[0 .. size - 1] }; + + var i: usize = 0; + while (fmt[i] != 0) : (i += 1) { + if (fmt[i] != '%') { + out.byte(fmt[i]); + continue; + } + i += 1; + if (fmt[i] == '%') { + out.byte('%'); + continue; + } + + // flags and width: only the subset ESP-Hosted uses + var zero_pad = false; + var width: usize = 0; + while (fmt[i] == '0' or fmt[i] == '-' or fmt[i] == '+' or fmt[i] == ' ') : (i += 1) { + if (fmt[i] == '0') zero_pad = true; + } + while (fmt[i] >= '1' and fmt[i] <= '9') : (i += 1) { + width = width * 10 + (fmt[i] - '0'); + } + // length modifiers: consumed, and `ll`/`z` widen the fetch below + var long_long = false; + while (true) : (i += 1) { + switch (fmt[i]) { + 'l' => if (fmt[i + 1] == 'l') { + long_long = true; + } else {}, + 'h', 'z', 't', 'j' => {}, + else => break, + } + } + + switch (fmt[i]) { + 'd', 'i' => { + if (long_long) { + out.int(@cVaArg(ap, i64), 10, false, width, zero_pad); + } else { + out.int(@cVaArg(ap, c_int), 10, false, width, zero_pad); + } + }, + 'u' => { + if (long_long) { + out.int(@cVaArg(ap, u64), 10, false, width, zero_pad); + } else { + out.int(@cVaArg(ap, c_uint), 10, false, width, zero_pad); + } + }, + 'x' => out.int(@cVaArg(ap, c_uint), 16, false, width, zero_pad), + 'X' => out.int(@cVaArg(ap, c_uint), 16, true, width, zero_pad), + 'c' => out.byte(@truncate(@as(c_uint, @bitCast(@cVaArg(ap, c_int))))), + 's' => { + const s = @cVaArg(ap, ?[*:0]const u8) orelse "(null)"; + var n: usize = 0; + while (s[n] != 0) : (n += 1) {} + out.pad(width, n, ' '); + out.slice(s[0..n]); + }, + 'p' => { + out.slice("0x"); + out.int(@intFromPtr(@cVaArg(ap, ?*anyopaque)), 16, false, 8, true); + }, + 0 => break, + else => { + // Unsupported: say so in the output rather than desynchronising silently. The + // argument is deliberately not consumed - there is no way to know its width. + out.slice("%!"); + out.byte(fmt[i]); + }, + } + } + + if (size != 0) buf[@min(out.written, size - 1)] = 0; + return @intCast(out.would); +} + +/// A writer that stops filling at the end of the buffer but keeps counting, which is what +/// snprintf's return value means. +const Counting = struct { + buf: []u8, + written: usize = 0, + would: usize = 0, + + fn byte(self: *Counting, c: u8) void { + if (self.written < self.buf.len) { + self.buf[self.written] = c; + self.written += 1; + } + self.would += 1; + } + + fn slice(self: *Counting, s: []const u8) void { + for (s) |c| self.byte(c); + } + + fn pad(self: *Counting, width: usize, len: usize, fill: u8) void { + if (width > len) for (0..width - len) |_| self.byte(fill); + } + + fn int( + self: *Counting, + value: anytype, + base: u8, + upper: bool, + width: usize, + zero_pad: bool, + ) void { + var tmp: [24]u8 = undefined; + const end = std.fmt.printInt(&tmp, value, base, if (upper) .upper else .lower, .{}); + const s = tmp[0..end]; + self.pad(width, s.len, if (zero_pad) '0' else ' '); + self.slice(s); + } +}; + +// --------------------------------------------------------------------------------------------- +// Tests. These run on the host, where a wrong snprintf is cheap to find; on the die it would be a +// garbled log line at best and a buffer overrun at worst. +// --------------------------------------------------------------------------------------------- + +test "snprintf: the conversions esp_hosted actually uses" { + var buf: [64]u8 = undefined; + const n = snprintf(&buf, buf.len, "state %d port %u flags 0x%x %s", @as(c_int, -3), @as(c_uint, 7), @as(c_uint, 0xbeef), "ok"); + try std.testing.expectEqualStrings("state -3 port 7 flags 0xbeef ok", buf[0..@intCast(n)]); +} + +test "snprintf: return value is the length that would have been written" { + var buf: [8]u8 = undefined; + const n = snprintf(&buf, buf.len, "%s", "0123456789"); + // Truncated to 7 chars plus NUL, but reports the full 10 so a caller can size a second call. + try std.testing.expectEqual(@as(c_int, 10), n); + try std.testing.expectEqualStrings("0123456", buf[0..7]); + try std.testing.expectEqual(@as(u8, 0), buf[7]); +} + +test "snprintf: zero-padded width, as used for MAC bytes" { + var buf: [32]u8 = undefined; + const n = snprintf(&buf, buf.len, "%02x:%02x", @as(c_uint, 0x0a), @as(c_uint, 0xf1)); + try std.testing.expectEqualStrings("0a:f1", buf[0..@intCast(n)]); +} + +test "snprintf: size 0 writes nothing at all" { + var buf = [_]u8{0xAA} ** 4; + const n = snprintf(&buf, 0, "hello"); + try std.testing.expectEqual(@as(c_int, 5), n); + try std.testing.expectEqual(@as(u8, 0xAA), buf[0]); +} + +test "snprintf: an unsupported conversion is visible, not silent" { + var buf: [32]u8 = undefined; + const n = snprintf(&buf, buf.len, "f=%f", @as(f64, 1.5)); + try std.testing.expectEqualStrings("f=%!f", buf[0..@intCast(n)]); +} + +test "malloc/free/realloc survive the churn mempool.c generates" { + var backing: [4096]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&backing); + install(fba.allocator()); + defer gpa = null; + + // Same-size alloc/free churn: the case an arena cannot serve. + var i: usize = 0; + while (i < 8) : (i += 1) { + const p = malloc(64) orelse return error.OutOfMemory; + free(p); + } + + const a = malloc(32) orelse return error.OutOfMemory; + @memset(@as([*]u8, @ptrCast(a))[0..32], 0x5A); + const b = realloc(a, 64) orelse return error.OutOfMemory; + // Contents must survive the grow. + try std.testing.expectEqual(@as(u8, 0x5A), @as([*]u8, @ptrCast(b))[31]); + free(b); +} + +test "calloc zeroes, and rejects overflow rather than under-allocating" { + var backing: [1024]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&backing); + install(fba.allocator()); + defer gpa = null; + + const p = calloc(16, 4) orelse return error.OutOfMemory; + for (@as([*]u8, @ptrCast(p))[0..64]) |byte| try std.testing.expectEqual(@as(u8, 0), byte); + free(p); + + try std.testing.expect(calloc(std.math.maxInt(usize), 2) == null); +} + +test "strlen, strcpy, strcmp and strncmp agree with std" { + try std.testing.expectEqual(@as(usize, 0), strlen("")); + try std.testing.expectEqual(@as(usize, 3), strlen("abc")); + // strnlen stops at the bound, which is the whole reason the shim uses it on wire data. + try std.testing.expectEqual(@as(usize, 3), strnlen("abc", 8)); + try std.testing.expectEqual(@as(usize, 2), strnlen("abc", 2)); + try std.testing.expectEqual(@as(usize, 0), strnlen("abc", 0)); + + var dst: [8]u8 = undefined; + _ = strcpy(&dst, "abc"); + try std.testing.expectEqualStrings("abc", dst[0..3]); + try std.testing.expectEqual(@as(u8, 0), dst[3]); + + try std.testing.expect(strcmp("abc", "abc") == 0); + try std.testing.expect(strcmp("abc", "abd") < 0); + try std.testing.expect(strncmp("abcX", "abcY", 3) == 0); + try std.testing.expect(strncmp("abcX", "abcY", 4) != 0); +} diff --git a/src/net/link.zig b/src/net/link.zig new file mode 100644 index 0000000..4277595 --- /dev/null +++ b/src/net/link.zig @@ -0,0 +1,552 @@ +//! The seam: ESP-Hosted's station data channel, bridged to `src/net/ip.zig`. +//! +//! Everything below this file is proven - the SDIO host driver, the runtime, the port table, the +//! RPC layer, the association. Everything above it is proven too: `ip.zig` has 117 host tests and a +//! mutation sweep. This file is the twenty lines of pointer handling in between, and it is the one +//! part of the path that no host test can check, because both of its neighbours are C. +//! +//! So every decision here is cited rather than inferred. +//! +//! ------------------------------------------------------------------------------------------ +//! 1. Where the received frame starts: at `buffer`, offset zero. +//! +//! This is the single most expensive thing to get wrong. A frame shifted by the 12-byte +//! `esp_payload_header` parses as garbage - the ethertype lands in the middle of a MAC address - +//! and every one of ip.zig's tests would still pass. The RX convention is established by the +//! producer and confirmed by the vendor's own consumer: +//! +//! * sdio_drv.c:830 rejects any packet whose header `offset` field is not +//! `sizeof(struct esp_payload_header)`, so the payload always begins exactly one header in. +//! * sdio_drv.c:887 `buf_handle.payload = rxbuff + offset` - `payload` already points past the +//! header. `priv_buffer_handle` (:882) is what still points at the header. +//! * sdio_drv.c:1396-1400 allocates `copy_payload = _h_malloc(buf_handle->payload_len)` and +//! memcpy's `payload_len` bytes from `buf_handle->payload` into it, then frees the original +//! buffer at :1401. So the copy is exactly the payload, nothing more. +//! * sdio_drv.c:1407-1408 `rx(api_chan, copy_payload, copy_payload, payload_len)` - `buffer` +//! and `buff_to_free` are the same pointer, and it is the start of the frame. +//! * The vendor's own consumer agrees: esp_wifi_remote_net2.c:40-51 passes `buffer` straight to +//! the netif receive function as the frame and `buff_to_free` only as the free handle. +//! +//! `H_ESP_PAYLOAD_HEADER_OFFSET` appears on the *transmit* side only (transport_drv.c:381), where +//! ESP-Hosted is *building* a buffer and has to leave room for the header it is about to write. +//! Adding it on receive would be applying the same correction twice, in the wrong direction. +//! +//! ------------------------------------------------------------------------------------------ +//! 2. Who frees, and with what. +//! +//! `copy_payload` came from `_h_malloc` (sdio_drv.c:1396), so it is freed with `_h_free` - which +//! is exactly what `HOSTED_FREE` expands to (port_esp_hosted_host_os.h:139) and what +//! `transport_sta_free_cb` reaches through `MEMPOOL_FREE` with the pool disabled +//! (transport_util.h:29-31). `onRxFrame` below frees it through `g_h.funcs->_h_free`, once, on +//! every path including the error paths, and always returns `ESP_OK`. +//! +//! Returning `ESP_OK` unconditionally is not laziness, it is the only value that is safe under +//! both of sdio_drv.c's ownership rules. With `ESP_WIFI_REMOTE_VERSION` >= 1.3.1 the callee always +//! owns the buffer and the caller never frees (:1418). Below that, and when the macro is undefined, +//! the caller frees the buffer *if the callee returned non-zero* (:1411-1416). A non-zero return +//! from a callback that has already freed is therefore a double free under one rule and a leak +//! under neither - so this file frees and returns zero, which is one free under both. +//! +//! ------------------------------------------------------------------------------------------ +//! 3. `api_chan` must not be null. +//! +//! `transport_drv_sta_tx` opens with `assert(h && h == chan_arr[ESP_STA_IF]->api_chan)` +//! (transport_drv.c:369), and the vendor's reference RX callback opens with `assert(h)` +//! (esp_wifi_remote_net2.c:41). ESP-Hosted's own registration honours that: it allocates a cookie +//! and passes it in (esp_hosted_api.c:200-203). This build compiles the C at -O2 with `-DNDEBUG` +//! (Zig adds it for every non-Debug optimize mode), so those asserts are compiled out today and a +//! null cookie would merely be an unchecked contract violation rather than a crash - which is a +//! worse outcome, not a better one. `channel_cookie` below is that non-null cookie, and it is +//! handed back to `tx` on every transmit so the identity check holds. +//! +//! ------------------------------------------------------------------------------------------ +//! 4. The transmitted frame need not outlive the call. +//! +//! `transport_drv_sta_tx` allocates its own buffer and copies into it before queueing: +//! `mempool_alloc(..., MAX_TRANSPORT_BUFFER_SIZE, true)` at transport_drv.c:372 - with the pool +//! disabled that is `_h_malloc_align(1536, 64)` (transport_util.h:21-27) - then +//! `_h_memcpy(copy_buff + H_ESP_PAYLOAD_HEADER_OFFSET, buffer, len)` at :381, and only then +//! `esp_hosted_tx(..., copy_buff, ...)` at :383. Nothing retains `buffer`. That is what makes +//! `ip.Stack`'s "the slice is borrowed for the duration of the call" contract satisfiable, and it +//! is why `sendFrame` may hand over a pointer into the stack's single transmit staging buffer. +//! +//! ------------------------------------------------------------------------------------------ +//! 5. Why there is a re-entrancy guard. +//! +//! This is the one hazard the task description does not mention and it is real. +//! +//! `ip.Stack` is a single-threaded state machine: `onFrame` may send (an ARP reply, an ICMP echo +//! reply, a TCP ACK) before it returns, and `tick` and `httpGet` may too. Sending ends in +//! `esp_hosted_tx`, whose last act is +//! `_h_queue_item(to_slave_queue[prio], &buf_handle, HOSTED_BLOCK_MAX)` (sdio_drv.c:1607). That +//! queue holds four items (`CONFIG_ESP_HOSTED_SDIO_TX_Q_SIZE 4`, src/net/hosted/sdkconfig.h:41) +//! and `_h_queue_item` with `HOSTED_BLOCK_MAX` is a *blocking* send: port.zig:734-740 forwards it +//! to `os.Queue.send`, which suspends the calling task until there is room. +//! +//! So a full transmit queue suspends whoever is inside the stack. `onRxFrame` runs on ESP-Hosted's +//! `sdio_process_rx_task`; `tick` and `httpGet` run on the application's task. Without a guard, +//! either one can be suspended mid-mutation and the other walk straight into the same `Stack`. +//! On a cooperative scheduler that is not a torn read, it is two interleaved state machines +//! sharing one transmit buffer, one TCP sequence space and one `http.out` slice. +//! +//! The guard makes that impossible, and every way it can fire has a correct answer already: +//! +//! * a frame arriving while the stack is busy is dropped, which is what a real NIC does when its +//! transmit queue is full. DHCP, ARP and TCP all retransmit. +//! * a `tick` skipped is a `tick` deferred: `ip.zig`'s timers are absolute deadlines compared +//! against `now_ms` (`dhcpTick`, `tcpTick`), not increments, so nothing is lost. +//! * `httpGet` returns `error.WouldBlock`, which is precisely the answer its protocol already +//! requires the caller to handle by calling again with identical arguments. +//! +//! Each of those is counted, so a log can say which one happened rather than leaving a stall +//! unexplained. + +const std = @import("std"); + +const ip = @import("ip.zig"); +const port = @import("port.zig"); + +// ================================================================= ESP-Hosted's C surface + +/// `esp_hosted_if_type_t`, common/esp_hosted_interface.h:14-24. +/// +/// Note the value. The enumeration opens with `ESP_INVALID_IF`, so the station interface is **1**, +/// not 0. Registering channel 0 would fall through `transport_drv_add_channel`'s switch to +/// `default:` (transport_drv.c:481-484), which logs "Not yet supported" and returns NULL after +/// having already installed a half-built channel - and `chan_arr[ESP_STA_IF]` would stay NULL, so +/// sdio_drv.c:1394 would go on discarding every station frame in silence. +const esp_sta_if: c_uint = 1; + +/// `transport_channel_tx_fn_t`, transport_drv.h:118. Returns `esp_err_t`; 0 is `ESP_OK`. +const TxFn = *const fn (h: ?*anyopaque, buffer: ?*anyopaque, len: usize) callconv(.c) c_int; + +/// `transport_channel_rx_fn_t`, transport_drv.h:119. +const RxFn = *const fn ( + h: ?*anyopaque, + buffer: ?*anyopaque, + buff_to_free: ?*anyopaque, + len: usize, +) callconv(.c) c_int; + +/// transport_drv.h:134-136. `tx` is an out-parameter: the transport writes the interface's own +/// transmit function into it (transport_drv.c:469-471) and that is the only way to obtain it. +/// +/// This is compiled in - `transport_drv.c` is on build.zig's source list - but nothing calls it, +/// because the file that normally does (`esp_hosted_api.c`'s `add_esp_wifi_remote_channels`) is +/// not compiled: this project calls `setup_transport`, `rpc_init` and `transport_drv_reconfigure` +/// directly from `src/net/all.zig`. Registering the station channel is therefore ours to do. +extern fn transport_drv_add_channel( + api_chan: ?*anyopaque, + if_type: c_uint, + secure: u8, + tx: *?TxFn, + rx: RxFn, +) ?*anyopaque; + +/// The station's MAC, through the C shim (src/net/hosted/wifi_shim.c:96). It belongs to the C6's +/// radio, not to this chip, and ARP and Ethernet framing are built on it. Valid only after +/// `hosted_wifi_sta_start`, because that is what brings the radio up on the coprocessor. +extern fn hosted_wifi_get_mac(out: *[6]u8) c_int; + +// ============================================================================== module state + +/// The one IPv4 stack. A module-level variable rather than something the caller owns, because +/// `ip.Stack.send` is `*const fn ([]const u8) void` with no context pointer: the transmit callback +/// has to reach the transport some other way, and a file-scope binding is the honest version of +/// "some other way". 3,576 bytes of .bss - see `footprint`. +var sta: ip.Stack = undefined; + +/// The `api_chan` cookie. Its address is what ESP-Hosted stores and compares; its contents are +/// never read by anyone. See note 3 in the header for why it may not be null. +var channel_cookie: u32 = 0x5354_4100; // 'STA\0', so a memory dump names it + +/// The transport's station transmit function, from `transport_drv_add_channel`'s out-parameter. +var tx_fn: ?TxFn = null; + +/// Set once the channel is registered and the stack is live. +var opened: bool = false; + +/// The re-entrancy guard. See note 5 in the header. +var in_stack: bool = false; + +pub const Stats = struct { + /// Frames handed to us by sdio_drv.c, before any filtering. + rx_frames: u32 = 0, + /// Frames whose `h` was not our cookie. Non-zero means another channel's traffic reached this + /// callback, which would be an ESP-Hosted bug and not something to paper over. + rx_wrong_channel: u32 = 0, + /// `buffer` was null, or `len` was zero or larger than an Ethernet frame. + rx_bad: u32 = 0, + /// Frames dropped because the stack was already entered. See note 5. + rx_reentrant: u32 = 0, + /// Frames actually delivered to `ip.Stack.onFrame`. + rx_delivered: u32 = 0, + /// `tick` calls that found the stack entered and did nothing. + tick_skipped: u32 = 0, + /// `httpGet`/`httpGetHost` calls answered `WouldBlock` by the guard rather than by the stack. + http_deferred: u32 = 0, + /// `resolve` calls answered `WouldBlock` by the guard rather than by the stack. The query's + /// own timer runs in `tick`, so these cost a poll and never a retransmission. + dns_deferred: u32 = 0, + /// Frames handed to the transport. + tx_frames: u32 = 0, + /// Transmits the transport rejected: not ready, throttled, or out of buffers. + tx_failed: u32 = 0, + /// Transmits attempted before the channel existed. Should be zero. + tx_no_channel: u32 = 0, + /// Frames the stack asked to send, accepted into the deferred ring. The difference between this + /// and `tx_frames` is what is still waiting for the next `tick`. + tx_queued: u32 = 0, + /// Frames dropped because the deferred ring was full when the stack tried to send. Non-zero + /// means `tick` is not keeping up with the offered load; every protocol above this retransmits, + /// so it costs latency rather than correctness. + tx_ring_full: u32 = 0, + /// Frames the stack offered with an impossible length. Should be zero; a non-zero value points + /// at ip.zig rather than at the transport. + tx_bad: u32 = 0, +}; + +var counters: Stats = .{}; + +/// Everything this file adds to .bss, so the number in a report cannot rot. The stack dominates it. +pub const footprint: usize = + @sizeOf(@TypeOf(sta)) + + @sizeOf(@TypeOf(channel_cookie)) + + @sizeOf(@TypeOf(tx_fn)) + + @sizeOf(@TypeOf(opened)) + + @sizeOf(@TypeOf(in_stack)) + + @sizeOf(@TypeOf(counters)) + + @sizeOf(@TypeOf(tx_ring)); + +// ================================================================================= transmit + +/// `ip.Stack.send`. The slice is borrowed for the duration of this call only, which is exactly what +/// the transport needs - see note 4 in the header. +fn sendFrame(frame: []const u8) void { + if (tx_fn == null) { + counters.tx_no_channel += 1; + return; + } + if (frame.len == 0 or frame.len > ip.frame_max) { + counters.tx_bad += 1; + return; + } + // Queued, never transmitted from here. See `flushTx`. + const next = (tx_ring.head + 1) % tx_ring_slots; + if (next == tx_ring.tail) { + counters.tx_ring_full += 1; + return; + } + @memcpy(tx_ring.slot[tx_ring.head][0..frame.len], frame); + tx_ring.len[tx_ring.head] = @intCast(frame.len); + tx_ring.head = next; + counters.tx_queued += 1; +} + +/// Hand every queued frame to ESP-Hosted. MUST be called only from a task that may block. +/// +/// This indirection is the fix for a deadlock the board demonstrated, and it is worth stating +/// exactly because the shape of it is not obvious. +/// +/// `ip.Stack.onFrame` answers things: an ARP request gets a reply, an ICMP echo gets an echo, a TCP +/// segment gets an ACK. So a received frame turns into a transmitted frame inside `onFrame`. But +/// `onFrame` runs on ESP-Hosted's `sdio_process_rx_task`, and transmitting ends in +/// `_h_queue_item(to_slave_queue, HOSTED_BLOCK_MAX)` (sdio_drv.c:1607), which SUSPENDS the caller +/// when the queue is full. Suspend the RX task and it stops draining the receive queue; the receive +/// queue fills; ESP-Hosted logs "task still writing Rx data to queue!" and stops delivering. +/// Everything then looks like a dead IP stack. +/// +/// Measured on the board before this change: frames received froze at 17 and never advanced again, +/// no ping was ever answered, and the HTTP GET failed with HostUnreachable because the ARP reply it +/// needed was never sent. Raising the SDIO queue depth from 4 to 16 only moved the number. +/// +/// So the receive path now only ever copies into this ring, which cannot block, and the application +/// task drains it from `tick`. The cost is one copy and `tx_ring_slots * frame_max` of .bss. +fn flushTx() void { + const tx = tx_fn orelse return; + while (tx_ring.tail != tx_ring.head) { + const i = tx_ring.tail; + const n = tx_ring.len[i]; + counters.tx_frames += 1; + // The const cast is sound and it is load-bearing that it is: `transport_drv_sta_tx` reads + // `buffer` exactly once, as the source of a memcpy into its own aligned buffer + // (transport_drv.c:381), and neither writes through it nor retains it. ESP-Hosted's + // signature is simply not const-correct. + const rc = tx(@ptrCast(&channel_cookie), @ptrCast(&tx_ring.slot[i]), n); + if (rc != 0) counters.tx_failed += 1; + // Advance only after the call returns, so a frame is never handed out twice. + tx_ring.tail = (i + 1) % tx_ring_slots; + } +} + +/// Outgoing frames waiting for a task that may block. +/// +/// Four slots, at `ip.frame_max` each. Enough that the replies one pass of received frames can +/// generate - an ARP answer, an ICMP echo, a TCP ACK - all fit, since the whole ring is drained on +/// the very next `tick`. A full ring drops the newest frame and counts it, which is what a real +/// network interface does under load, and every protocol above this retransmits. +/// +/// Deliberately small: this is .bss competing with the heap ESP-Hosted allocates every received +/// frame from, and eight slots cost 12 KB that the transport needs more than this ring does. +const tx_ring_slots = 4; + +var tx_ring: struct { + slot: [tx_ring_slots][ip.frame_max]u8 = undefined, + len: [tx_ring_slots]u16 = @splat(0), + head: usize = 0, + tail: usize = 0, +} = .{}; + +// ================================================================================== receive + +/// `transport_channel_rx_fn_t`. Called from ESP-Hosted's `sdio_process_rx_task` +/// (sdio_drv.c:1407), which is one of the tasks `port.zig` spawned on this project's own runtime. +/// +/// The buffer is ours the moment this is entered, and it is freed on every path. See notes 1 and 2. +fn onRxFrame( + h: ?*anyopaque, + buffer: ?*anyopaque, + buff_to_free: ?*anyopaque, + len: usize, +) callconv(.c) c_int { + // `HOSTED_FREE(buff)` is `g_h.funcs->_h_free(buff)` (port_esp_hosted_host_os.h:139), and this + // is that call. First statement in the function so that no early return can miss it: the + // failure mode of a missed free here is not a leak that shows up in a heap report, it is the + // 32 KiB heap exhausted in a few seconds of the AP's broadcast traffic. + defer port.g_h.funcs.free(buff_to_free); + + counters.rx_frames += 1; + + if (h != @as(?*anyopaque, @ptrCast(&channel_cookie))) { + counters.rx_wrong_channel += 1; + return 0; + } + const bytes: [*]const u8 = @ptrCast(buffer orelse { + counters.rx_bad += 1; + return 0; + }); + if (!opened or len == 0 or len > ip.frame_max) { + counters.rx_bad += 1; + return 0; + } + if (in_stack) { + counters.rx_reentrant += 1; + return 0; + } + + in_stack = true; + defer in_stack = false; + counters.rx_delivered += 1; + sta.onFrame(bytes[0..len]); + return 0; +} + +// ================================================================================ lifecycle + +pub const Error = error{ + /// `hosted_wifi_get_mac` failed, or answered with the all-zero MAC that means "no radio yet". + /// The usual cause is calling this before `hosted_wifi_sta_start`. + MacUnavailable, + /// `transport_drv_add_channel` refused, or accepted without filling in the transmit function. + ChannelRegisterFailed, + AlreadyOpen, +}; + +/// Register the station channel and bring the IP stack up behind it. +/// +/// Call after `net.init` and after `hosted_wifi_sta_start`; association may follow or may already +/// have happened, it makes no difference to this. Registering *before* associating is the tidier +/// order, because `chan_arr[ESP_STA_IF]` becoming non-null is the moment sdio_drv.c stops +/// discarding station frames, and until then a live association fills ESP-Hosted's receive queue +/// and logs "task still writing Rx data to queue!". +/// +/// The order inside matters: the stack is constructed *before* the channel is registered. The +/// instant `transport_drv_add_channel` returns, `sdio_process_rx_task` may call `onRxFrame`, and +/// that must not find `sta` uninitialised. +pub fn open() Error!void { + if (opened) return error.AlreadyOpen; + + var mac_bytes: [6]u8 = @splat(0); + if (hosted_wifi_get_mac(&mac_bytes) != 0) return error.MacUnavailable; + // An all-zero MAC is not a MAC. It is what the shim hands back if the coprocessor answered + // without having a station interface, and building an ARP cache on it would produce a stack + // that transmits frames no switch will ever route back. + if (std.mem.allEqual(u8, &mac_bytes, 0)) return error.MacUnavailable; + + sta = .init(mac_bytes, &sendFrame); + + var tx: ?TxFn = null; + const channel = transport_drv_add_channel( + @ptrCast(&channel_cookie), + esp_sta_if, + 0, // secure=0: plain text, as ESP-Hosted itself uses for the two Wi-Fi interfaces + // (esp_hosted_api.c:105-107). The secure path is the RPC channel's, and RPC has + // its own already. + &tx, + &onRxFrame, + ); + if (channel == null) return error.ChannelRegisterFailed; + // Belt and braces: the switch at transport_drv.c:467-485 is the only writer of `*tx`, and the + // one branch that leaves it untouched also returns NULL. Checking both means a future + // ESP-Hosted that separates those cannot leave us with a live channel and no way to transmit. + tx_fn = tx orelse return error.ChannelRegisterFailed; + + opened = true; +} + +/// True once `open` has succeeded. +pub fn isOpen() bool { + return opened; +} + +// ============================================================ the guarded entry points +// +// Every function that can mutate the stack goes through `in_stack`. Every function that only reads +// it does not, because a read cannot suspend and the worst it can observe is a value one frame out +// of date. + +/// Advance the stack's clock. Returns false if the stack was busy and the tick was skipped, which +/// is harmless - see note 5 - but worth being able to see. +pub fn tick(now_ms: u64) bool { + if (in_stack) { + counters.tick_skipped += 1; + return false; + } + in_stack = true; + sta.tick(now_ms); + in_stack = false; + // Outside the guard, and last: draining may block, and `in_stack` must not be held across a + // suspension or the receive path would drop every frame that arrived while we waited. + flushTx(); + return true; +} + +/// Begin DHCP. Call `tick` at least once first: `dhcpStart` stamps the acquisition's start time +/// from the stack's idea of now, which only `tick` sets. Returns false if the stack was busy. +pub fn dhcpStart() bool { + if (in_stack) return false; + in_stack = true; + defer in_stack = false; + sta.dhcpStart(); + return true; +} + +/// Configure statically instead of asking a server. +pub fn setStatic(addr: [4]u8, mask: [4]u8, gw: [4]u8) bool { + if (in_stack) return false; + in_stack = true; + defer in_stack = false; + sta.setStatic(addr, mask, gw); + return true; +} + +/// Override the resolver `resolve` asks. Not needed on a network whose DHCP server offers one - +/// `dhcpBind` stores option 6 and `resolve` uses it with no configuration at all. Returns false if +/// the stack was busy. +pub fn setDnsServer(addr: [4]u8) bool { + if (in_stack) return false; + in_stack = true; + defer in_stack = false; + sta.setDnsServer(addr); + return true; +} + +/// One HTTP GET, with the address literal as the `Host:` header. `ip.Stack.httpGet`'s protocol, +/// unchanged: this returns `error.WouldBlock` until the body is complete, and the caller must keep +/// calling with *identical* arguments while driving `tick`. `out` is borrowed until a length comes +/// back. +pub fn httpGet(host: [4]u8, remote_port: u16, path: []const u8, out: []u8) ip.HttpError!usize { + return httpGetHost(host, null, remote_port, path, out); +} + +/// The same, with an explicit `Host:` name for a name-based virtual host. See +/// `ip.Stack.httpGetHost`; `name` is part of the request's identity, so it must not change between +/// calls any more than `path` may. +pub fn httpGetHost( + host: [4]u8, + name: ?[]const u8, + remote_port: u16, + path: []const u8, + out: []u8, +) ip.HttpError!usize { + if (in_stack) { + // Answering the caller's own protocol back at it. The alternative - waiting - would be a + // second place in this file that can block, and the guard exists to have exactly none. + counters.http_deferred += 1; + return error.WouldBlock; + } + in_stack = true; + defer in_stack = false; + return sta.httpGetHost(host, name, remote_port, path, out); +} + +/// Resolve a name to an address. `ip.Stack.resolve`'s protocol, which is `httpGet`'s: this returns +/// `error.WouldBlock` until an address or a real error comes back, and the caller keeps calling +/// with the same name while driving `tick`. +/// +/// The guard's answer is the same `error.WouldBlock`, for the same reason it is in `httpGetHost`: +/// the query's own retransmissions run in `sta.tick`, so a deferred poll costs nothing and the 7 s +/// bound still holds. Frames the query sends go through `sendFrame` into the deferred ring like +/// every other frame here - nothing on this path touches the transport's tx function directly. +pub fn resolve(name: []const u8) ip.DnsError!ip.Ip4 { + if (in_stack) { + counters.dns_deferred += 1; + return error.WouldBlock; + } + in_stack = true; + defer in_stack = false; + return sta.resolve(name); +} + +// ==================================================================== read-only accessors + +/// The station MAC the stack was built on. +pub fn mac() [6]u8 { + return sta.mac; +} + +/// The configured address, or null if there is none yet. +pub fn address() ?[4]u8 { + return sta.addr; +} + +pub fn netmask() [4]u8 { + return sta.mask; +} + +pub fn gateway() [4]u8 { + return sta.gw; +} + +pub fn dnsServer() ?[4]u8 { + return sta.dns; +} + +pub fn dhcpState() ip.DhcpState { + return sta.dhcp.state; +} + +pub fn tcpState() ip.TcpState { + return sta.tcp.state; +} + +pub fn httpStatus() u16 { + return sta.http.status; +} + +/// The IP stack's own counters: frames in, frames dropped, echoes answered, checksums rejected. +pub fn ipCounters() ip.Counters { + return sta.counters; +} + +/// This file's counters: the transport boundary, and every way the guard fired. +pub fn stats() Stats { + return counters; +} + +// There are no tests here, and that is an answer rather than an omission. Two of the three things +// this file does are calls into ESP-Hosted's C - `transport_drv_add_channel` and the transmit +// function it hands back - and the third is a callback that C invokes. A host test could only +// exercise it against a mock of the very code whose conventions are the thing in doubt, and it +// would pass just as happily against a mock that put the frame one header too late. The evidence +// that matters is the citations in this file's header and a board that answers a ping. 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); +} -- cgit v1.3