1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
|
//! 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;
/// _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;
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 };
}
/// 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);
}
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();
}
};
|