summaryrefslogtreecommitdiff
path: root/src/net/libc.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/net/libc.zig')
-rw-r--r--src/net/libc.zig457
1 files changed, 457 insertions, 0 deletions
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 <endian.h>, 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);
+}