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. --- tools/serial.zig | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 tools/serial.zig (limited to 'tools/serial.zig') diff --git a/tools/serial.zig b/tools/serial.zig new file mode 100644 index 0000000..c13942c --- /dev/null +++ b/tools/serial.zig @@ -0,0 +1,200 @@ +//! POSIX serial port: raw mode, standard baud rates, and the DTR/RTS lines that put an Espressif +//! chip into download mode. No libc, no external tool - std.posix for termios, std.os.linux for +//! the two modem-control ioctls std does not wrap, std.Io.File for the byte traffic. +//! +//! On this board DTR drives the boot strap (GPIO35) and RTS drives CHIP_PU through a transistor +//! pair, which is why the reset sequence below needs no button press. + +const std = @import("std"); +const posix = std.posix; +const linux = std.os.linux; + +/// Any rate the hardware can divide to, not just the historical enum values: the port is +/// configured through termios2, where the baud is a plain integer. The ESP32 ROM loader +/// auto-detects the host's rate from the 0x55 pattern in SYNC, so raising this needs no +/// CHANGE_BAUDRATE handshake. +pub const Baud = enum(u32) { + b115200 = 115200, + b230400 = 230400, + b460800 = 460800, + b921600 = 921600, + b1500000 = 1500000, + b2000000 = 2000000, + + pub fn rate(b: Baud) u32 { + return @intFromEnum(b); + } +}; + +/// `struct termios2` as the Linux kernel defines it: 19 control characters, then the two integer +/// baud fields. std's `linux.termios2` uses NCCS = 32, which makes it 60 bytes instead of 44 and +/// therefore encodes TCGETS2/TCSETS2 with the wrong size field - the ioctl then fails with ENOTTY. +/// Declaring the struct here keeps the numbers right and the intent visible. +const Termios2 = extern struct { + iflag: u32, + oflag: u32, + cflag: u32, + lflag: u32, + line: u8, + cc: [19]u8, + ispeed: u32, + ospeed: u32, + + /// _IOR('T', 0x2A, struct termios2) and _IOW('T', 0x2B, struct termios2) for a 44-byte struct. + const TCGETS2: u32 = 0x802C542A; + const TCSETS2: u32 = 0x402C542B; + + /// CBAUD escape meaning "take the rate from ispeed/ospeed" (asm-generic/termbits.h). + const BOTHER: u32 = 0o010000; + const CBAUD: u32 = 0o010017; + const CS8: u32 = 0o000060; + const CREAD: u32 = 0o000200; + const CLOCAL: u32 = 0o004000; +}; + +pub const Port = struct { + file: std.Io.File, + io: std.Io, + saved: Termios2, + + // std exposes neither these ioctl numbers nor the TIOCM bits. + const TIOCEXCL = 0x540C; + const TCFLSH = 0x540B; + const TCIFLUSH = 0; + const TIOCMGET = 0x5415; + const TIOCMSET = 0x5418; + const DTR: u32 = 0x002; + const RTS: u32 = 0x004; + + pub fn open(path: []const u8, baud: Baud) !Port { + const fd = try posix.openat(posix.AT.FDCWD, path, .{ + .ACCMODE = .RDWR, + .NOCTTY = true, + .CLOEXEC = true, + }, 0); + const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + const io = std.Io.Threaded.global_single_threaded.io(); + errdefer file.close(io); + + // Configure through termios2: it is the only interface whose baud fields the kernel + // populates. `tcsetattr` uses TCSETS, whose struct has no ispeed/ospeed, so assigning + // those fields silently does nothing and leaves the port at whatever rate it had - which + // is exactly the bug that made a 1.5 KB flash take 230 ms instead of 60. + var saved: Termios2 = undefined; + if (@as(isize, @bitCast(linux.ioctl(fd, Termios2.TCGETS2, @intFromPtr(&saved)))) < 0) + return error.NotATerminal; + + var raw = saved; + // A raw byte pipe: no canonical mode, no echo, no signals, no flow control, no CR/LF + // translation, 8N1, CLOCAL so a missing carrier-detect cannot block reads, and the baud + // taken from the integer fields. + raw.iflag = 0; + raw.oflag = 0; + raw.lflag = 0; + raw.cflag = Termios2.CS8 | Termios2.CREAD | Termios2.CLOCAL | Termios2.BOTHER; + raw.ispeed = baud.rate(); + raw.ospeed = baud.rate(); + @memset(&raw.cc, 0); + raw.cc[6] = 0; // VMIN: never block for a minimum count + raw.cc[5] = 0; // VTIME: poll() owns the timeouts + if (@as(isize, @bitCast(linux.ioctl(fd, Termios2.TCSETS2, @intFromPtr(&raw)))) < 0) + return error.SetAttrFailed; + + // Claim the port exclusively, the way pyserial (and therefore esptool) does: otherwise a + // `zig build monitor` left running in another terminal splits the ROM's replies between + // two readers, and the failure looks like random SyncFailed. + if (@as(isize, @bitCast(linux.ioctl(fd, TIOCEXCL, 0))) < 0) return error.PortBusy; + // Drop anything the kernel captured at the previous line rate. + _ = linux.ioctl(fd, TCFLSH, TCIFLUSH); + + return .{ .file = file, .io = io, .saved = saved }; + } + + pub fn close(p: *Port) void { + _ = linux.ioctl(p.file.handle, Termios2.TCSETS2, @intFromPtr(&p.saved)); + p.file.close(p.io); + } + + pub fn write(p: *Port, bytes: []const u8) !void { + try p.file.writeStreamingAll(p.io, bytes); + } + + /// Read whatever is available, waiting at most `timeout_ms`. Returns 0 on timeout. + pub fn read(p: *Port, buf: []u8) !usize { + var pfd = [_]posix.pollfd{.{ .fd = p.file.handle, .events = posix.POLL.IN, .revents = 0 }}; + const ready = try posix.poll(&pfd, 0); + if (ready == 0) return 0; + return p.file.readStreaming(p.io, &.{buf}) catch |err| switch (err) { + error.EndOfStream => 0, + else => err, + }; + } + + pub fn readTimeout(p: *Port, buf: []u8, timeout_ms: i32) !usize { + var pfd = [_]posix.pollfd{.{ .fd = p.file.handle, .events = posix.POLL.IN, .revents = 0 }}; + const ready = try posix.poll(&pfd, timeout_ms); + if (ready == 0) return 0; + return p.file.readStreaming(p.io, &.{buf}) catch |err| switch (err) { + error.EndOfStream => 0, + else => err, + }; + } + + pub fn drain(p: *Port) void { + var scratch: [512]u8 = undefined; + while (true) { + const n = p.read(&scratch) catch return; + if (n == 0) return; + } + } + + fn setLines(p: *Port, dtr: bool, rts: bool) !void { + var flags: u32 = 0; + if (linux.ioctl(p.file.handle, TIOCMGET, @intFromPtr(&flags)) != 0) return error.IoctlFailed; + flags = if (dtr) flags | DTR else flags & ~DTR; + flags = if (rts) flags | RTS else flags & ~RTS; + if (linux.ioctl(p.file.handle, TIOCMSET, @intFromPtr(&flags)) != 0) return error.IoctlFailed; + } + + /// How long to hold each phase of the reset sequence. esptool uses 100/50/50 ms; the shorter + /// numbers below were measured on this board over repeated runs. Tunable because a different + /// carrier's RC network may need longer. + pub const ResetTiming = struct { + hold_reset_ms: i64 = 40, + strap_settle_ms: i64 = 25, + release_ms: i64 = 15, + }; + + /// Classic reset into the ROM download loader: hold the boot strap asserted across a reset + /// pulse. Both lines are inverted by the board's transistor pair, so an asserted RS-232 line + /// pulls its pin low. + pub fn resetToDownload(p: *Port, timing: ResetTiming) !void { + // Never leave the board held in reset because a modem-line ioctl failed halfway through. + errdefer p.setLines(false, false) catch {}; + try p.setLines(false, true); // strap released, EN low: held in reset + p.sleepMs(timing.hold_reset_ms); + try p.setLines(true, false); // strap asserted, EN high: enters the ROM loader + p.sleepMs(timing.strap_settle_ms); + try p.setLines(false, false); + p.sleepMs(timing.release_ms); + p.drain(); + } + + /// Reset and let the flashed application run. + pub fn resetToRun(p: *Port, timing: ResetTiming) !void { + errdefer p.setLines(false, false) catch {}; + try p.setLines(false, true); + p.sleepMs(timing.hold_reset_ms); + try p.setLines(false, false); + p.sleepMs(timing.release_ms); + } + + fn sleepMs(p: *Port, ms: i64) void { + std.Io.sleep(p.io, .fromMilliseconds(ms), .boot) catch {}; + } + + /// Milliseconds on a monotonic clock, for deadlines. + pub fn nowMs(p: *Port) i64 { + return std.Io.Timestamp.now(p.io, .boot).toMilliseconds(); + } +}; -- cgit v1.3