//! 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); }