diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/console.zig | 239 | ||||
| -rw-r--r-- | tools/image.zig | 13 | ||||
| -rw-r--r-- | tools/image_test.zig | 3 | ||||
| -rw-r--r-- | tools/serial.zig | 24 |
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); |
