summaryrefslogtreecommitdiff
path: root/src/net/hosted_glue.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 12:40:53 -0300
committerGabriel Schneider <[email protected]>2026-08-25 12:46:51 -0300
commitf5f8068fac59b4f16046c2022c2fc7c7e447ef4c (patch)
tree2731a3ed4e51cae09e184e25778eded5fc37d1f5 /src/net/hosted_glue.zig
downloadesp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.tar.gz
esp32p4-f5f8068fac59b4f16046c2022c2fc7c7e447ef4c.zip
zig-p4: pure-Zig ESP32-P4 toolchain
build.zig generates the linker script and drives Zig's own LLD; tools/image.zig turns the ELF into a flashable image and tools/{rom,serial}.zig speak the mask ROM loader over the UART. No CMake, ninja, idf.py, esptool, or external linker. src/soc.zig is a comptime register model over ESP-IDF's own *_reg.h headers; src/hal/ adds peripheral sequences; src/io/ implements std.Io for the chip; src/oracle/ diffs this HAL against ESP-IDF's on the die.
Diffstat (limited to 'src/net/hosted_glue.zig')
-rw-r--r--src/net/hosted_glue.zig320
1 files changed, 320 insertions, 0 deletions
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);
+}