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/libc.zig | 457 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 src/net/libc.zig (limited to 'src/net/libc.zig') 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); +} -- cgit v1.3