summaryrefslogtreecommitdiff
path: root/tools/serial.zig
diff options
context:
space:
mode:
Diffstat (limited to 'tools/serial.zig')
-rw-r--r--tools/serial.zig200
1 files changed, 200 insertions, 0 deletions
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();
+ }
+};