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
|
//! A small framed protocol for measuring the board's serial link, shared verbatim by the host tool
//! and the firmware that answers it.
//!
//! WHY A PROTOCOL AND NOT A STOPWATCH. Timing an editor's keystrokes measures the editor, the
//! renderer and the link at once, and cannot tell a dropped byte from a slow one: RX overrun on this
//! UART is silent in hardware and uncounted in the driver, so a missing keystroke and a late one look
//! identical from the host. A frame with a length and a checksum turns both into facts. If the CRC
//! matches, every byte of that payload crossed intact; if a frame never completes, bytes were lost
//! and the count says how many. A throughput number that is not checksummed is a guess about how
//! fast data was corrupted.
//!
//! THE SHAPE. One fixed 9-byte header, little-endian, then the payload:
//!
//! "P4" op:u8 len:u16 crc:u32 payload[len]
//!
//! The CRC covers the payload only. The header carries it rather than trailing it so a receiver
//! knows, before it has read a single payload byte, exactly how many to expect and what they must
//! hash to - which is what lets the firmware verify a stream with one 4-byte accumulator and no
//! buffer at all.
//!
//! `max_payload` is 1024 and that is a memory decision, not a wire one. The firmware has a 384 KiB
//! heap it must share with an editor, and a bulk test that needed a 64 KiB frame buffer would be
//! measuring a configuration nobody ships. Bulk transfers are therefore many frames, which is also
//! the honest shape: it is the per-frame overhead a real protocol would pay.
//!
//! Both directions use the same header, and a reply's op has the high bit set, so a stray reply can
//! never be mistaken for a request by a resynchronising receiver.
const std = @import("std");
pub const magic = "P4";
pub const header_len = 9;
pub const max_payload = 1024;
pub const Op = enum(u8) {
/// Echo the payload back as `pong`. Both directions verified in one exchange, which is what
/// makes it the right stimulus for a latency measurement.
ping = 1,
/// Payload is data to be consumed. The board accumulates a running count and CRC and answers
/// nothing, so the host can keep the uplink full and measure it without return traffic
/// competing for the same wire.
sink = 2,
/// Ask for the accumulated `sink` count and CRC, then reset them.
report = 3,
/// Payload is a u32 count: send exactly that many pattern bytes back, in `data` frames,
/// followed by a `stat`.
source = 4,
pong = 0x81,
/// Payload is `Stat`, packed little-endian.
stat = 0x83,
/// A chunk of `source` output.
data = 0x84,
pub fn isReply(o: Op) bool {
return @intFromEnum(o) & 0x80 != 0;
}
};
/// What the board reports about a stream it received or sent. Encoded by hand rather than by
/// `@bitCast` of a packed struct: this crosses between a riscv32 firmware and an x86_64 host, and a
/// layout that depends on either compiler's padding rules is a bug waiting for a target change.
pub const Stat = struct {
/// Payload bytes accumulated.
bytes: u32,
/// CRC-32 over exactly those bytes, in order.
crc: u32,
/// Frames whose CRC did not match. Nonzero means the link corrupted data rather than losing it,
/// which is a different fault with a different fix.
bad_frames: u32,
/// Bytes the firmware's UART driver gave up on writing. Its own counter, surfaced here because
/// the host cannot see it any other way.
tx_dropped: u32,
pub const encoded_len = 16;
pub fn encode(s: Stat, out: *[encoded_len]u8) void {
std.mem.writeInt(u32, out[0..4], s.bytes, .little);
std.mem.writeInt(u32, out[4..8], s.crc, .little);
std.mem.writeInt(u32, out[8..12], s.bad_frames, .little);
std.mem.writeInt(u32, out[12..16], s.tx_dropped, .little);
}
pub fn decode(in: []const u8) ?Stat {
if (in.len < encoded_len) return null;
return .{
.bytes = std.mem.readInt(u32, in[0..4], .little),
.crc = std.mem.readInt(u32, in[4..8], .little),
.bad_frames = std.mem.readInt(u32, in[8..12], .little),
.tx_dropped = std.mem.readInt(u32, in[12..16], .little),
};
}
};
pub fn crc(bytes: []const u8) u32 {
return std.hash.Crc32.hash(bytes);
}
/// The deterministic byte at stream offset `i`.
///
/// A counter would be checksummed correctly by an implementation that lost exactly 256 bytes, and a
/// constant by one that lost any amount. This is an 8-bit xorshift-ish walk whose period is long
/// enough that no realistic loss aligns with it, so the CRC catches a gap wherever it falls.
pub fn patternByte(i: u32) u8 {
var x: u32 = i +% 1;
x ^= x << 7;
x ^= x >> 3;
x ^= x << 5;
return @truncate(x);
}
pub fn fillPattern(buf: []u8, offset: u32) void {
for (buf, 0..) |*b, k| b.* = patternByte(offset +% @as(u32, @intCast(k)));
}
/// Write a frame into `out`, returning the used slice. `out` must hold `header_len + payload.len`.
pub fn encode(out: []u8, op: Op, payload: []const u8) []u8 {
std.debug.assert(payload.len <= max_payload);
std.debug.assert(out.len >= header_len + payload.len);
out[0] = magic[0];
out[1] = magic[1];
out[2] = @intFromEnum(op);
std.mem.writeInt(u16, out[3..5], @intCast(payload.len), .little);
std.mem.writeInt(u32, out[5..9], crc(payload), .little);
@memcpy(out[header_len..][0..payload.len], payload);
return out[0 .. header_len + payload.len];
}
pub const Header = struct {
op: Op,
len: u16,
crc: u32,
};
/// Read a header out of `buf`. Returns null when fewer than `header_len` bytes are present, and
/// `error.BadFrame` when the magic or the op is not one of ours - which is how a receiver that has
/// lost sync tells "wait for more" from "throw a byte away and try again".
pub fn parseHeader(buf: []const u8) error{BadFrame}!?Header {
if (buf.len < header_len) return null;
if (buf[0] != magic[0] or buf[1] != magic[1]) return error.BadFrame;
const op = std.enums.fromInt(Op, buf[2]) orelse return error.BadFrame;
const len = std.mem.readInt(u16, buf[3..5], .little);
if (len > max_payload) return error.BadFrame;
return .{ .op = op, .len = len, .crc = std.mem.readInt(u32, buf[5..9], .little) };
}
test "a frame round-trips through encode and parseHeader" {
var buf: [header_len + 4]u8 = undefined;
const f = encode(&buf, .ping, "abcd");
try std.testing.expectEqual(@as(usize, header_len + 4), f.len);
const h = (try parseHeader(f)).?;
try std.testing.expectEqual(Op.ping, h.op);
try std.testing.expectEqual(@as(u16, 4), h.len);
try std.testing.expectEqual(crc("abcd"), h.crc);
try std.testing.expectEqualStrings("abcd", f[header_len..]);
}
test "a short buffer is incomplete, not invalid" {
var buf: [header_len]u8 = undefined;
const f = encode(&buf, .report, "");
try std.testing.expectEqual(@as(?Header, null), try parseHeader(f[0 .. header_len - 1]));
}
test "wrong magic and unknown ops are rejected rather than misread" {
var buf: [header_len]u8 = undefined;
var f = encode(&buf, .report, "");
f[0] = 'X';
try std.testing.expectError(error.BadFrame, parseHeader(f));
f[0] = magic[0];
f[2] = 0x7f;
try std.testing.expectError(error.BadFrame, parseHeader(f));
}
test "a truncated stream is caught by the CRC" {
// Losing bytes is the failure this protocol exists to detect, so prove the checksum notices a
// gap that leaves the length plausible.
var full: [64]u8 = undefined;
fillPattern(&full, 0);
var gapped: [64]u8 = undefined;
fillPattern(gapped[0..32], 0);
fillPattern(gapped[32..], 33); // one byte skipped mid-stream
try std.testing.expect(crc(&full) != crc(&gapped));
}
test "the pattern does not repeat inside a byte-aligned loss" {
// A plain counter would hash identically after losing exactly 256 bytes. This must not.
var a: [128]u8 = undefined;
var b: [128]u8 = undefined;
fillPattern(&a, 0);
fillPattern(&b, 256);
try std.testing.expect(crc(&a) != crc(&b));
}
test "Stat survives the trip between a riscv32 firmware and an x86_64 host" {
const s: Stat = .{ .bytes = 0x11223344, .crc = 0xdeadbeef, .bad_frames = 7, .tx_dropped = 9 };
var buf: [Stat.encoded_len]u8 = undefined;
s.encode(&buf);
const back = Stat.decode(&buf).?;
try std.testing.expectEqual(s, back);
try std.testing.expectEqual(@as(?Stat, null), Stat.decode(buf[0 .. Stat.encoded_len - 1]));
}
|