summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/console.zig239
-rw-r--r--tools/image.zig13
-rw-r--r--tools/image_test.zig3
-rw-r--r--tools/serial.zig24
4 files changed, 278 insertions, 1 deletions
diff --git a/tools/console.zig b/tools/console.zig
new file mode 100644
index 0000000..2e924e3
--- /dev/null
+++ b/tools/console.zig
@@ -0,0 +1,239 @@
+//! An interactive terminal on the far side of the serial port.
+//!
+//! `MonitorStep` prints the board's output for N seconds and never sends anything. That is the
+//! right tool for a program that only reports. It is the wrong tool for a program the human is
+//! supposed to *use*, which needs the host's keystrokes on the wire and the host's terminal out of
+//! the way. So this is the other half: raw-mode stdin forwarded to the UART, UART forwarded to
+//! stdout, until the escape byte.
+//!
+//! The division of labour is the interesting part. The board runs the application and emits ANSI;
+//! the host terminal emulator (ghostty here) does the actual terminal work - fonts, scrollback,
+//! selection - and answers the application's capability queries itself. This process is a wire, and
+//! deliberately almost transparent: a `\x1b[?1049h` from the board reaches ghostty, ghostty's reply
+//! to a `CSI c` reaches the board, and neither end needs to know there are 3 metres of USB cable
+//! and a CH340 in between.
+//!
+//! Almost transparent, because of one thing a wire cannot pass through: **size**. A terminal
+//! application learns its window size from `ioctl(TIOCGWINSZ)`, and firmware has no ioctl. DEC mode
+//! 2048 ("in-band resize") solves half of it - a terminal that has been sent `\x1b[?2048h` reports
+//! every subsequent resize as `CSI 48 ; rows ; cols ; ypix ; xpix t`, and those reports flow down
+//! the wire like any other bytes. The half it does not solve is the *first* size, because a change
+//! notification is only sent on a change. This process owns the real tty, so it is the only party
+//! that can answer, and it injects that same sequence itself:
+//!
+//! * once at attach, for a board that never asks;
+//! * on SIGWINCH, because the host's window changed and mode 2048 may not be enabled;
+//! * and on seeing `\x1b[?2048h` come *back* from the board - which is the exact moment the
+//! application has declared itself ready to understand one. That is the deterministic trigger;
+//! the other two are belt and braces.
+//!
+//! Injecting a resize report the host terminal would also have sent is harmless: it carries the
+//! size as data, so a duplicate is idempotent, and `vaxis`'s parser (`src/Parser.zig:528-551`)
+//! treats both identically because they are byte-for-byte the same sequence.
+
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const serial = @import("serial.zig");
+
+/// Ctrl-] , telnet's escape and not a key any full-screen application binds. Ctrl-C, Ctrl-Q and
+/// Ctrl-Z all had to be rejected: an editor wants every one of them, and a bridge that swallowed
+/// them would be lying about being transparent.
+pub const escape_byte: u8 = 0x1d;
+
+/// Set by the SIGWINCH handler, read by the loop. `volatile` rather than atomic because a signal
+/// handler on the same thread is not a concurrent writer - it is an interruption - and this only
+/// needs the compiler to stop caching the load.
+var winch_pending: bool = false;
+
+/// The handler takes `posix.SIG`, not an int: std's `Sigaction.handler_fn` is
+/// `*align(1) const fn (SIG) callconv(.c) void` (std/os/linux.zig:6026).
+fn onWinch(_: posix.SIG) callconv(.c) void {
+ @as(*volatile bool, &winch_pending).* = true;
+}
+
+const Winsize = extern struct { row: u16, col: u16, xpixel: u16, ypixel: u16 };
+const TIOCGWINSZ = 0x5413;
+
+fn windowSize() Winsize {
+ var ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
+ // A failure here is not fatal: 80x24 is a defensible terminal, and the alternative is refusing
+ // to attach because the size could not be read.
+ _ = linux.ioctl(0, TIOCGWINSZ, @intFromPtr(&ws));
+ if (ws.row == 0) ws.row = 24;
+ if (ws.col == 0) ws.col = 80;
+ return ws;
+}
+
+/// The in-band resize report, in the form `vaxis` parses: `CSI 48 ; rows ; cols ; ypix ; xpix t`.
+/// Note the pixel fields are height-then-width, which is the opposite order from the
+/// `struct winsize` they come out of - `Parser.zig:536-539` reads height first.
+fn sendWinsize(port: *serial.Port, ws: Winsize) void {
+ var buf: [64]u8 = undefined;
+ const seq = std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};{d};{d}t", .{
+ ws.row, ws.col, ws.ypixel, ws.xpixel,
+ }) catch return;
+ port.write(seq) catch {};
+}
+
+/// Matches `\x1b[?2048h` in the board's output stream one byte at a time, because the sequence can
+/// be split across reads. Returns true on the byte that completes it.
+const ModeWatch = struct {
+ const want = "\x1b[?2048h";
+ at: usize = 0,
+
+ fn feed(m: *ModeWatch, byte: u8) bool {
+ if (byte == want[m.at]) {
+ m.at += 1;
+ if (m.at == want.len) {
+ m.at = 0;
+ return true;
+ }
+ } else {
+ // Restart, and allow this byte to be a fresh start - otherwise "\x1b\x1b[?2048h" is
+ // missed.
+ m.at = if (byte == want[0]) 1 else 0;
+ }
+ return false;
+ }
+};
+
+pub const Options = struct {
+ /// Pulse reset so the application starts from boot with the console already attached. Without
+ /// it, attaching to a board that has been running for a while shows a screen mid-session with
+ /// no redraw until something changes.
+ reset: bool = true,
+ /// Print the escape-key hint. Suppressed for scripted runs, whose output is being asserted on.
+ banner: bool = true,
+};
+
+/// Forward bytes both ways until the escape byte arrives on stdin.
+///
+/// Returns normally on escape; the terminal is always restored, including on error, because the
+/// alternative is handing the user back a shell with no echo.
+pub fn attach(port_path: []const u8, baud: serial.Baud, opts: Options) !void {
+ var port = try serial.Port.open(port_path, baud);
+ defer port.close();
+
+ // No `isatty`: `tcgetattr` answers the same question with the same syscall this needs anyway,
+ // and a null here means "stdin is a pipe" - which is a supported way to run this, for scripted
+ // sessions whose input is a file.
+ const saved: ?posix.termios = posix.tcgetattr(0) catch null;
+ if (saved) |prev| {
+ var raw = prev;
+ // The same raw mode `serial.Port.open` builds for the port, for the same reason: every byte
+ // the user types has to reach the board unmodified, including the ones the line discipline
+ // would otherwise interpret. ISIG off is what lets Ctrl-C reach the application instead of
+ // killing this process.
+ raw.lflag.ICANON = false;
+ raw.lflag.ECHO = false;
+ raw.lflag.ISIG = false;
+ raw.lflag.IEXTEN = false;
+ raw.iflag.IXON = false;
+ raw.iflag.ICRNL = false;
+ raw.iflag.INLCR = false;
+ raw.iflag.BRKINT = false;
+ raw.oflag.OPOST = false;
+ try posix.tcsetattr(0, .FLUSH, raw);
+ }
+ defer if (saved) |prev| posix.tcsetattr(0, .FLUSH, prev) catch {};
+
+ // Installed after raw mode so a resize during setup cannot be missed-but-flagged.
+ posix.sigaction(posix.SIG.WINCH, &.{
+ .handler = .{ .handler = onWinch },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ }, null);
+
+ // stdin/stdout as `std.Io.File`, because std 0.16 has no `posix.read`/`posix.write` any more -
+ // byte traffic goes through std.Io. The `io` is borrowed from the port, which already holds the
+ // single-threaded instance `serial.Port.open` created.
+ const io = port.io;
+ const stdin: std.Io.File = .{ .handle = 0, .flags = .{ .nonblocking = false } };
+ const stdout: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };
+
+ if (opts.banner) {
+ var hint: [96]u8 = undefined;
+ const line = std.fmt.bufPrint(&hint, "[zig-p4 console @ {d} baud - Ctrl-] to detach]\r\n", .{
+ baud.rate(),
+ }) catch "[zig-p4 console - Ctrl-] to detach]\r\n";
+ stdout.writeStreamingAll(io, line) catch {};
+ }
+
+ if (opts.reset) try port.resetToRun(.{});
+ sendWinsize(&port, windowSize());
+
+ var watch: ModeWatch = .{};
+ var from_board: [4096]u8 = undefined;
+ var from_user: [256]u8 = undefined;
+
+ while (true) {
+ if (@as(*volatile bool, &winch_pending).*) {
+ @as(*volatile bool, &winch_pending).* = false;
+ sendWinsize(&port, windowSize());
+ }
+
+ var pfd = [_]posix.pollfd{
+ .{ .fd = 0, .events = posix.POLL.IN, .revents = 0 },
+ .{ .fd = port.file.handle, .events = posix.POLL.IN, .revents = 0 },
+ };
+ // A bounded wait rather than an infinite one so a SIGWINCH that lands between the check
+ // above and the poll below is still serviced promptly; poll reports the signal itself as
+ // an interrupt, which is handled as "go round again".
+ const ready = posix.poll(&pfd, 200) catch continue;
+ if (ready == 0) continue;
+
+ // POLL.IN is not the only thing poll reports, and ignoring the rest is a hot spin, not a
+ // no-op: unplug the CH340 mid-session and the port's revents carries HUP|ERR|NVAL forever.
+ // poll then returns immediately with a non-zero count, neither branch below matches because
+ // both test POLL.IN, and the loop burns a core with stdin still in raw mode and ISIG off.
+ // `std.posix.poll` cannot surface it as an error either - it maps INVAL to `unreachable`
+ // (std/posix.zig:1007-1017) because a dead descriptor is reported in `revents`, not errno.
+ const gone = posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL;
+ if (pfd[1].revents & gone != 0) return error.PortDisconnected;
+ // stdin dying is ordinary: a pipe ran out, or the terminal closed. Detach quietly.
+ if (pfd[0].revents & gone != 0) return;
+
+ if (pfd[1].revents & posix.POLL.IN != 0) {
+ const n = port.read(&from_board) catch 0;
+ if (n > 0) {
+ stdout.writeStreamingAll(io, from_board[0..n]) catch {};
+ for (from_board[0..n]) |b| {
+ if (watch.feed(b)) sendWinsize(&port, windowSize());
+ }
+ }
+ }
+
+ if (pfd[0].revents & posix.POLL.IN != 0) {
+ const n = stdin.readStreaming(io, &.{&from_user}) catch 0;
+ if (n == 0) return; // stdin closed: a pipe ran out, so detach
+ if (std.mem.indexOfScalar(u8, from_user[0..n], escape_byte)) |cut| {
+ // Everything before the escape still belongs to the board.
+ if (cut > 0) port.write(from_user[0..cut]) catch {};
+ if (opts.banner) stdout.writeStreamingAll(io, "\r\n[detached]\r\n") catch {};
+ return;
+ }
+ port.write(from_user[0..n]) catch {};
+ }
+ }
+}
+
+test "ModeWatch completes only on the full sequence" {
+ var m: ModeWatch = .{};
+ for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
+ try std.testing.expect(m.feed('h'));
+}
+
+test "ModeWatch resynchronises on a false start" {
+ var m: ModeWatch = .{};
+ // A prefix that dies, then the real thing immediately after: the naive reset-to-zero misses
+ // this because the byte that broke the match is itself the next match's ESC.
+ for ("\x1b[?20") |b| try std.testing.expect(!m.feed(b));
+ for ("\x1b[?2048") |b| try std.testing.expect(!m.feed(b));
+ try std.testing.expect(m.feed('h'));
+}
+
+test "ModeWatch ignores unrelated traffic" {
+ var m: ModeWatch = .{};
+ for ("hello \x1b[?1049h world \x1b[0m") |b| try std.testing.expect(!m.feed(b));
+}
diff --git a/tools/image.zig b/tools/image.zig
index dea843f..ba60064 100644
--- a/tools/image.zig
+++ b/tools/image.zig
@@ -13,7 +13,10 @@
//! ships as a 1.4 KB image instead of a 66 KB one. Verified on ESP32-P4 rev v1.3 silicon.
//!
//! Rules the loader enforces, each learned by flashing a deliberately broken image at the board:
-//! * exactly two segments must land in the mapped range (bootloader_utility.c:842)
+//! * exactly two segments must land in the mapped range: on a chip with shared D/I external
+//! vaddr (the P4, soc.h:146-149) the loader collects them positionally and asserts
+//! rom_index == 2 (bootloader_utility.c:805-851); one segment aborts the boot and a third
+//! trips an assert inside the loop (bootloader_utility.c:842)
//! * every segment length must be a multiple of 4 (esp_image_format.c:857)
//! * image offset 0x20 begins an esp_app_desc_t, and min/max_efuse_blk_rev_full are read from
//! it whether or not it is really a descriptor (esp_image_format.c:796-806)
@@ -116,6 +119,14 @@ pub const Layout = struct {
}
off += seg_header_len + s.len;
}
+ // EXACTLY two, and the bootloader is what says so. On a chip whose D/I external vaddr ranges
+ // are shared - the P4's are (soc.h:146-149) - ESP-IDF takes the SOC_MMU_DI_VADDR_SHARED
+ // branch of `unpack_load_app` (bootloader_utility.c:805-851), which does not classify
+ // segments as D or I at all: it collects them positionally into rom_addr[2] and ends with
+ // `assert(rom_index == 2)`. One mapped segment aborts the boot with
+ // "Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)" - measured,
+ // by shipping one - and a third trips `assert(rom_index < 2)` inside the loop. Enforcing it
+ // here turns a boot-time abort into a build-time error.
if (mapped != 2) return error.NotTwoMappedSegments;
// One MMU entry per vaddr page: any two mapped segments in the same vaddr page must come
diff --git a/tools/image_test.zig b/tools/image_test.zig
index 9f2cbc0..c13adf7 100644
--- a/tools/image_test.zig
+++ b/tools/image_test.zig
@@ -120,6 +120,8 @@ test "an image with one mapped segment is rejected before it can brick a board"
var layout = try image.fromElf(gpa, elf, .{});
defer layout.deinit(gpa);
+ // Not a guess: shipping a one-segment image aborted the boot with
+ // "Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)".
try testing.expectError(error.NotTwoMappedSegments, layout.validate(.{}));
}
@@ -275,6 +277,7 @@ fn checkBytes(bytes: []const u8, flash_offset: u32) !void {
}
off += 8 + len;
}
+ // Exactly two: the SOC_MMU_DI_VADDR_SHARED branch asserts rom_index == 2.
try testing.expectEqual(@as(usize, 2), mapped); // bootloader_utility.c:842
// Two mapped segments sharing a vaddr page must share the flash page: one MMU entry each.
diff --git a/tools/serial.zig b/tools/serial.zig
index c13942c..da225fd 100644
--- a/tools/serial.zig
+++ b/tools/serial.zig
@@ -43,6 +43,10 @@ const Termios2 = extern struct {
/// _IOR('T', 0x2A, struct termios2) and _IOW('T', 0x2B, struct termios2) for a 44-byte struct.
const TCGETS2: u32 = 0x802C542A;
const TCSETS2: u32 = 0x402C542B;
+ /// _IOW('T', 0x2C, ...): TCSETS2's draining sibling. Changing the divisor while bytes are
+ /// still in the kernel's output queue sends the tail of the old line at the new rate, which on
+ /// a mid-session baud switch corrupts exactly the handshake line the switch was announced by.
+ const TCSETSW2: u32 = 0x402C542C;
/// CBAUD escape meaning "take the rate from ispeed/ospeed" (asm-generic/termbits.h).
const BOTHER: u32 = 0o010000;
@@ -110,6 +114,26 @@ pub const Port = struct {
return .{ .file = file, .io = io, .saved = saved };
}
+ /// Re-rate an already-open port, leaving the raw-mode flags and the exclusive claim alone.
+ ///
+ /// This exists because the console and the flasher want different rates on the same wire. The
+ /// ROM loader auto-detects the host's rate from SYNC's 0x55 pattern, so `open` can simply pick
+ /// one; a running application cannot, because its UART divider was programmed by the
+ /// second-stage bootloader and the only way to change it is for the firmware to reprogram its
+ /// own divider and for the host to follow. That makes the switch a two-sided handshake, and
+ /// `TCSETSW2` is the half that has to be ordered: the firmware announces the change on the old
+ /// rate, and the announcement must have physically left the wire before either side moves.
+ pub fn setBaud(p: *Port, baud: Baud) !void {
+ var t: Termios2 = undefined;
+ if (@as(isize, @bitCast(linux.ioctl(p.file.handle, Termios2.TCGETS2, @intFromPtr(&t)))) < 0)
+ return error.NotATerminal;
+ t.cflag = (t.cflag & ~Termios2.CBAUD) | Termios2.BOTHER;
+ t.ispeed = baud.rate();
+ t.ospeed = baud.rate();
+ if (@as(isize, @bitCast(linux.ioctl(p.file.handle, Termios2.TCSETSW2, @intFromPtr(&t)))) < 0)
+ return error.SetAttrFailed;
+ }
+
pub fn close(p: *Port) void {
_ = linux.ioctl(p.file.handle, Termios2.TCSETS2, @intFromPtr(&p.saved));
p.file.close(p.io);