summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 16:16:15 -0300
committerGabriel Schneider <[email protected]>2026-08-25 16:17:41 -0300
commit0b11540923d5ec2de57fc199c4c9309f838133f5 (patch)
tree81c93e1c34b41fba5d20b7706a8ccb05ee1d0c4e
parent6b176c598e55a48572e5e508a213491eec122bf4 (diff)
downloadesp32p4-0b11540923d5ec2de57fc199c4c9309f838133f5.tar.gz
esp32p4-0b11540923d5ec2de57fc199c4c9309f838133f5.zip
Move the console into its own binary; the step was fighting the progress display
`zig build interact` shredded the editor's screen with fragments of `[11/13] steps +- console`. The cause is structural, not cosmetic: an interactive step lives for as long as the human does, and `std.Progress` redraws the build runner's step tree on stderr every 80 ms for all of it. std already solves this, and the solution is a lock rather than a flag. `Step.Run` with `stdio == .inherit` holds `io.lockStderr()` for the entire lifetime of the child (std/Build/Step/Run.zig:1588-1592) - the same lock `Progress` must take to draw. A hand-rolled step gets none of that unless it takes the lock itself, and `ConsoleStep` did not. So the console is now a real host program, `tools/console_main.zig`, and `console`/`interact` are `Run` steps on it. `--color off` is not needed and is no longer suggested anywhere. Measured under a real pty (a pipe hides the bug, because progress only draws to a terminal - which is why every earlier end-to-end test here looked clean): zero bytes of progress output across an 11-second session, 3 frames, the editor's `^` modified-marker landing at row 2 after two keystrokes. The second reason for a program is that "just connect" should not imply a build. Once the firmware is in flash the board runs it across resets, so the common case during use is to open the port and nothing else: zig-out/bin/p4-console # the board is already programmed zig-out/bin/p4-console --no-reset # ...and leave a live session running `--no-reset` is the interesting one and it is proven on the die: attaching to the running editor produced 309 bytes with no ESP-ROM banner and zero `boot:` lines, then a `^` frame in response to typing. The session survived a detach and reattach with no repaint, which is exactly what a 11.9 KB/s link wants. `zig build console` is 4 steps and builds no image, no app and no object. It installs `p4-console` itself rather than going through `installArtifact`, so `zig build` alone still lands exactly one file in `zig-out` - verified from an empty tree. Also here: * `tools/console.zig`'s `ModeWatch` tests were reachable by nothing. `zig build test` runs them now; the matcher has to resynchronise when the byte that broke a match is the next match's ESC, which is worth a regression test. * The port-permission advice existed twice, in `failPort` and in the new program. It now lives once, in `tools/serial.zig` beside the code that opens ports, and says nothing about the path so each caller can name its own on the first line.
-rw-r--r--README.md16
-rw-r--r--build.zig105
-rw-r--r--tools/console_main.zig108
-rw-r--r--tools/serial.zig21
4 files changed, 193 insertions, 57 deletions
diff --git a/README.md b/README.md
index abb94d7..7ccc7b9 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ zig build # compile, link, and emit a flashable image
zig build flash # ...then write it to the chip and run it
zig build run # flash, then print the console (ordered; `flash monitor` is not)
zig build monitor # reset the board and print its console
-zig build console # attach an interactive terminal: keystrokes in, screen out (Ctrl-] detaches)
+zig build console # attach a terminal to whatever is already on the board (Ctrl-] detaches)
zig build interact # flash, then attach that terminal (ordered, like `run`)
zig build reset # just pulse the reset line
zig build size # where every byte of the image went
@@ -19,6 +19,20 @@ against a linker script this `build.zig` generates; the image builder and the se
ordinary Zig code in `tools/`, imported straight into `build.zig`, so they leave no artefacts of
their own. What lands in `zig-out` is one file: the image.
+One exception, and it earns it: `zig build console` also installs `zig-out/bin/p4-console`, a host
+binary that opens the port and nothing else. Run it directly and there is no build runner in the
+picture — which matters, because an interactive step lives for as long as the human does, and
+`std.Progress` would otherwise redraw the build tree over the screen every 80 ms. std solves that
+for child processes by holding `io.lockStderr()` for the child's whole lifetime
+(`std/Build/Step/Run.zig:1588-1592`), which is the same lock `Progress` needs to draw, so both
+spellings are clean; the binary is simply the one that assumes the board is already flashed.
+
+```
+zig-out/bin/p4-console # the board is already programmed; just connect
+zig-out/bin/p4-console --no-reset # ...and do not pulse reset, so a live session survives
+zig-out/bin/p4-console --port /dev/ttyUSB1 --baud 115200
+```
+
## Why it exists
| | ESP-IDF blink | earlier Zig proof-of-concept | this |
diff --git a/build.zig b/build.zig
index b9d0a18..e605808 100644
--- a/build.zig
+++ b/build.zig
@@ -15,7 +15,6 @@ const std = @import("std");
const image = @import("tools/image.zig");
const serial = @import("tools/serial.zig");
const rom = @import("tools/rom.zig");
-const console = @import("tools/console.zig");
pub fn build(b: *std.Build) void {
// ---------------------------------------------------------------- board and target knobs
@@ -358,14 +357,45 @@ pub fn build(b: *std.Build) void {
"console-baud",
"interactive console baud (default 115200, the rate the bootloader leaves UART0 at)",
) orelse .b115200;
- const con = ConsoleStep.create(b, port_path, console_baud);
- b.step("console", "attach an interactive terminal to the running application").dependOn(&con.step);
+
+ // A real host binary, and not an in-process step like every other tool here, for two reasons.
+ // It runs with no build runner at all when the board is already flashed; and as a child process
+ // under `Step.Run` with inherited stdio it gets std's stderr lock held for its whole lifetime
+ // (std/Build/Step/Run.zig:1588-1592), which is what stops `std.Progress` repainting the step
+ // tree over an interactive session. The in-process step this replaced never took that lock, and
+ // shredded the editor's screen with fragments of `[11/13] steps`.
+ const con_exe = b.addExecutable(.{
+ .name = "p4-console",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("tools/console_main.zig"),
+ .target = b.graph.host,
+ .optimize = .ReleaseSafe,
+ }),
+ });
+ // Installed by the console steps, and deliberately NOT by `install`: the default build still
+ // lands exactly one file in zig-out, the flashable image. Anyone who has attached once has
+ // zig-out/bin/p4-console afterwards, which is the copy to run when the board is already
+ // flashed and no build is wanted.
+ const con_install = b.addInstallArtifact(con_exe, .{});
+
+ const con_args: []const []const u8 = &.{ "--port", port_path, "--baud", b.fmt("{d}", .{console_baud.rate()}) };
+
+ const con = b.addRunArtifact(con_exe);
+ con.addArgs(con_args);
+ // Inherited stdio is the whole point: the board's bytes and the user's keystrokes pass through
+ // untouched, and the terminal the child sees is the real one, so its ioctls answer.
+ con.stdio = .inherit;
+ con.step.dependOn(&con_install.step);
+ b.step("console", "attach a terminal to the application already on the board").dependOn(&con.step);
// Ordered, for the same reason `run` is: an unordered `flash console` lets the console reset
// the board out from under the writer.
- const run_con = ConsoleStep.create(b, port_path, console_baud);
+ const run_con = b.addRunArtifact(con_exe);
+ run_con.addArgs(con_args);
+ run_con.stdio = .inherit;
+ run_con.step.dependOn(&con_install.step);
run_con.step.dependOn(&flash.step);
- b.step("interact", "flash the image, then attach an interactive terminal").dependOn(&run_con.step);
+ b.step("interact", "flash the image, then attach a terminal").dependOn(&run_con.step);
const reset = ResetStep.create(b, port_path);
b.step("reset", "reset the board and let the flashed application run").dependOn(&reset.step);
@@ -413,6 +443,19 @@ pub fn build(b: *std.Build) void {
});
test_step.dependOn(&b.addRunArtifact(mmio_tests).step);
+ // The console bridge's escape-sequence matcher. `ModeWatch` has to recognise `\x1b[?2048h`
+ // split across arbitrary read boundaries, and the naive reset-on-mismatch loses a sequence
+ // whose own ESC is the byte that broke the previous match - a bug that would show up on the
+ // wire as an editor that never learns the window size. Host-testable, so tested here.
+ const console_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("tools/console.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ }),
+ });
+ test_step.dependOn(&b.addRunArtifact(console_tests).step);
+
// The radio path's host-testable parts. A wrong checksum, a wrong snprintf, or a scheduler that
// loses a task is far cheaper to find here than on a board whose only output is a serial line.
//
@@ -1371,22 +1414,7 @@ fn readImage(step: *std.Build.Step, gpa: std.mem.Allocator, path: std.Build.Lazy
/// "fixed" it, which is a genuinely confusing place to be.
fn failPort(step: *std.Build.Step, port: []const u8, err: anyerror) anyerror {
if (err != error.AccessDenied) return step.fail("cannot open {s}: {s}", .{ port, @errorName(err) });
- return step.fail(
- \\cannot open {s}: AccessDenied
- \\
- \\ {s} is owned by a group your session is not in (uucp on Arch, dialout on Debian):
- \\ ls -l {s}
- \\
- \\ If you are NOT in that group yet, join it and log in again:
- \\ sudo usermod -aG uucp $USER
- \\
- \\ If `id -nG` already lists it, this shell simply predates the change - group membership
- \\ is captured at login. Either log out and back in, or take it in this shell:
- \\ newgrp uucp
- \\
- \\ Or run one command with the group, without touching this shell at all:
- \\ sg uucp -c 'zig build ... '
- , .{ port, port, port });
+ return step.fail("cannot open {s}: AccessDenied\n\n" ++ serial.access_denied_help, .{port});
}
const FlashStep = struct {
@@ -1522,41 +1550,6 @@ const MonitorStep = struct {
}
};
-/// `monitor`, but the wire runs both ways. See tools/console.zig for the size handshake, which is
-/// the only part of this that is not a straight byte copy.
-///
-/// Takes the same `port_lock` as every other step that opens the port, so `zig build flash console`
-/// cannot have the console pull the board out of download mode mid-write. It then HOLDS that lock
-/// for the whole interactive session, which is correct and worth saying out loud: the session ends
-/// when the user detaches, so any other port step named on the same command line waits for the
-/// human rather than racing them.
-const ConsoleStep = struct {
- step: std.Build.Step,
- port: []const u8,
- baud: serial.Baud,
-
- fn create(b: *std.Build, port: []const u8, baud: serial.Baud) *ConsoleStep {
- const self = b.allocator.create(ConsoleStep) catch @panic("OOM");
- self.* = .{
- .step = std.Build.Step.init(.{ .id = .custom, .name = "console", .owner = b, .makeFn = make }),
- .port = port,
- .baud = baud,
- };
- return self;
- }
-
- fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
- const self: *ConsoleStep = @fieldParentPtr("step", step);
- const b = step.owner;
-
- port_lock.lockUncancelable(b.graph.io);
- defer port_lock.unlock(b.graph.io);
-
- console.attach(self.port, self.baud, .{}) catch |err|
- return failPort(step, self.port, err);
- }
-};
-
/// Pulse the reset line and leave. One ioctl pair, but it is the difference between "did my app
/// hang or did I forget to reset it" during development.
const ResetStep = struct {
diff --git a/tools/console_main.zig b/tools/console_main.zig
new file mode 100644
index 0000000..0afe81e
--- /dev/null
+++ b/tools/console_main.zig
@@ -0,0 +1,108 @@
+//! `p4-console`: attach a terminal to whatever is already running on the board.
+//!
+//! **"Just connect" should not imply a build.** Once the firmware is in flash the board runs it
+//! across resets and power cycles, so the common case during *use* - as opposed to during
+//! development - is to open the port and nothing else. This program compiles nothing, reads no
+//! image, and does not care which application is on the chip. `zig build console` runs this same
+//! binary and installs it, so `zig-out/bin/p4-console` is the copy to reach for when no build is
+//! wanted at all.
+//!
+//! The other reason it is a program and not an in-process step is a bug this replaced. An
+//! interactive step runs for as long as the human is there, and `std.Progress` redraws the build
+//! runner's step tree on stderr every 80 ms for all of it - so the editor's screen arrives shredded
+//! by fragments of `[11/13] steps └─ console`. std already solves this for child processes, and the
+//! solution is a lock rather than a flag: `Step.Run` with `stdio == .inherit` holds
+//! `io.lockStderr()` for the entire lifetime of the child (`std/Build/Step/Run.zig:1588-1592`),
+//! which is the same lock `Progress` must take to draw. A hand-rolled step gets no such treatment
+//! unless it takes that lock itself, and the one this replaced did not. Measured on this board
+//! under a real pty: zero bytes of progress output across an 11-second session.
+
+const std = @import("std");
+const serial = @import("serial.zig");
+const console = @import("console.zig");
+
+const usage =
+ \\p4-console - attach a terminal to the application already running on the board
+ \\
+ \\ p4-console [--port <path>] [--baud <rate>] [--no-reset]
+ \\
+ \\ --port <path> serial port (default /dev/ttyUSB0)
+ \\ --baud <rate> line rate (default 115200; what the bootloader leaves UART0 at)
+ \\ --no-reset attach without pulsing reset, leaving the application mid-session
+ \\ -h, --help this
+ \\
+ \\Ctrl-] detaches. The board's output goes to stdout and your keystrokes go to the board;
+ \\your terminal emulator answers the application's capability queries itself, so what runs
+ \\on the chip sees a real terminal.
+ \\
+;
+
+/// `Init.Minimal` rather than the full `std.process.Init`: this needs argv and nothing else, and
+/// the POSIX arg iterator walks the real argv without allocating, so there is no allocator here at
+/// all. Linux-only is not a new restriction - `tools/serial.zig` speaks `termios2` directly.
+pub fn main(init: std.process.Init.Minimal) void {
+ var it: std.process.Args.Iterator = .init(init.args);
+ _ = it.skip(); // argv[0]
+
+ var port: []const u8 = "/dev/ttyUSB0";
+ var baud: serial.Baud = .b115200;
+ var reset = true;
+
+ while (it.next()) |a| {
+ if (eql(a, "-h") or eql(a, "--help")) {
+ stdout.writeStreamingAll(io, usage) catch {};
+ return;
+ } else if (eql(a, "--no-reset")) {
+ reset = false;
+ } else if (eql(a, "--port")) {
+ port = it.next() orelse fail("--port needs a path");
+ } else if (eql(a, "--baud")) {
+ const text = it.next() orelse fail("--baud needs a rate");
+ const rate = std.fmt.parseInt(u32, text, 10) catch fail("--baud must be a number");
+ baud = std.enums.fromInt(serial.Baud, rate) orelse
+ fail("unsupported baud: this tool offers only the rates in serial.Baud");
+ } else {
+ fail("unrecognised argument; try --help");
+ }
+ }
+
+ console.attach(port, baud, .{ .reset = reset }) catch |err| switch (err) {
+ // The one failure everybody hits, and the message the raw error name does not give. Shared
+ // with the build steps, so the two cannot drift.
+ error.AccessDenied => {
+ var buf: [256]u8 = undefined;
+ const head = std.fmt.bufPrint(&buf, "cannot open {s}: AccessDenied\n\n", .{port}) catch
+ "cannot open the port: AccessDenied\n\n";
+ // Two writes rather than one buffer, because the help text is a comptime constant and
+ // copying it into a stack buffer only to print it would be work for nothing.
+ stderr.writeStreamingAll(io, "p4-console: ") catch {};
+ stderr.writeStreamingAll(io, head) catch {};
+ stderr.writeStreamingAll(io, serial.access_denied_help ++ "\n") catch {};
+ std.process.exit(1);
+ },
+ // Unplugging the CH340 mid-session is ordinary, not a crash: say so and leave.
+ error.PortDisconnected => fail("the port disappeared - the adapter was unplugged"),
+ else => {
+ var buf: [256]u8 = undefined;
+ fail(std.fmt.bufPrint(&buf, "console failed: {s}", .{@errorName(err)}) catch "console failed");
+ },
+ };
+}
+
+fn eql(a: []const u8, b: []const u8) bool {
+ return std.mem.eql(u8, a, b);
+}
+
+const io = std.Io.Threaded.global_single_threaded.io();
+const stdout: std.Io.File = .{ .handle = 1, .flags = .{ .nonblocking = false } };
+const stderr: std.Io.File = .{ .handle = 2, .flags = .{ .nonblocking = false } };
+
+/// Report and leave. `noreturn` rather than an error union because every caller here is a usage
+/// mistake with nothing above it to recover: returning an error would add std's "error: Reported"
+/// and a stack trace on top of a message written for a human.
+fn fail(msg: []const u8) noreturn {
+ var buf: [512]u8 = undefined;
+ const line = std.fmt.bufPrint(&buf, "p4-console: {s}\n", .{msg}) catch "p4-console: error\n";
+ stderr.writeStreamingAll(io, line) catch {};
+ std.process.exit(1);
+}
diff --git a/tools/serial.zig b/tools/serial.zig
index da225fd..3398a11 100644
--- a/tools/serial.zig
+++ b/tools/serial.zig
@@ -26,6 +26,27 @@ pub const Baud = enum(u32) {
}
};
+/// The remedy for the one error every first run hits, in the module that owns port opening so the
+/// build steps and `p4-console` cannot drift apart. Deliberately says nothing about the port's
+/// path: the caller names that on its own first line, which is the only part that differs.
+///
+/// The last case is the one worth spelling out, because it is the one that looks like the fix did
+/// not work: a shell started before `usermod` never sees the new group, since credentials are
+/// captured at login and not re-read.
+pub const access_denied_help =
+ \\ It is owned by a group your session is not in (uucp on Arch, dialout on Debian).
+ \\
+ \\ If you are NOT in that group yet, join it and log in again:
+ \\ sudo usermod -aG uucp $USER
+ \\
+ \\ If `id -nG` already lists it, this shell simply predates the change - group membership is
+ \\ captured at login. Either log out and back in, or take it in this shell:
+ \\ newgrp uucp
+ \\
+ \\ Or run one command with the group, without touching this shell at all:
+ \\ sg uucp -c '...'
+;
+
/// `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.