diff options
Diffstat (limited to 'examples')
| -rw-r--r-- | examples/uartperf.zig | 229 |
1 files changed, 229 insertions, 0 deletions
diff --git a/examples/uartperf.zig b/examples/uartperf.zig new file mode 100644 index 0000000..339f642 --- /dev/null +++ b/examples/uartperf.zig @@ -0,0 +1,229 @@ +//! The board half of the link measurement: answer `tools/perfproto.zig` frames over UART0. +//! +//! This is the CEILING the editor is measured against. `p4-bench` against this firmware says what +//! the wire and the UART driver can do with nothing else running; `p4-bench` against the editor says +//! how much of that the editor manages to use. Optimising the editor without the first number is +//! guessing, because at 115200 baud a good deal of what feels slow is simply the wire, and no amount +//! of firmware work moves it. +//! +//! Three things it deliberately does NOT do, each of which would corrupt the number: +//! +//! * **No `soc.rom.print`.** The mask ROM's `ets_printf` formats and then pushes one byte at a +//! time, spinning on the FIFO for each - the exact cost this is trying to measure around. Every +//! byte here goes through the same batched FIFO path `src/pardes/uart.zig` uses. +//! * **No UART reconfiguration.** Not the divider, not the format, not `reset()`. The +//! second-stage bootloader configured this block; `hal/uart.zig:195-211` records that resetting +//! it returns UART_CLKDIV to its power-on value and takes the session with it. +//! * **No allocation.** One parse buffer and one send buffer, both static, both sized by the +//! protocol's own `max_payload`. A measurement that shared a heap with anything would measure +//! the heap. +//! +//! The verification is the point. `sink` accumulates a CRC across every payload byte received and +//! `report` hands it back, so the host can prove that what arrived is what it sent - at this baud a +//! silent RX overrun is the failure mode that matters, and a byte count alone cannot see it. + +const std = @import("std"); +const hal = @import("hal"); +const proto = @import("perfproto"); + +const uart0 = hal.uart.Uart.init(0); + +/// Room for one whole frame. The protocol caps a payload at 1024 precisely so this can be static. +var rx: [proto.header_len + proto.max_payload]u8 = undefined; +var rx_len: usize = 0; + +var tx: [proto.header_len + proto.max_payload]u8 = undefined; + +/// The running `sink` accumulators, reported and reset by `report`. +var sunk_bytes: u32 = 0; +var sunk_crc: std.hash.Crc32 = undefined; +var bad_frames: u32 = 0; +var tx_dropped: u32 = 0; + +/// Push bytes through the TX FIFO, reading the status once per burst rather than once per byte. +/// +/// The spin is bounded because an unbounded one is indistinguishable from a hang on a board with no +/// debugger, and because this program's whole purpose is to report numbers: a wedged transmitter +/// that increments a counter can still be diagnosed, while one that spins forever cannot. +fn write(bytes: []const u8) void { + var rest = bytes; + while (rest.len > 0) { + var room = uart0.txFree(); + var spins: u32 = 0; + while (room == 0) { + spins += 1; + if (spins > 1_000_000) { + tx_dropped +%= @intCast(rest.len); + return; + } + room = uart0.txFree(); + } + const n = @min(room, rest.len); + for (rest[0..n]) |b| uart0.pushByte(b); + rest = rest[n..]; + } +} + +fn send(op: proto.Op, payload: []const u8) void { + write(proto.encode(&tx, op, payload)); +} + +fn sendStat() void { + var buf: [proto.Stat.encoded_len]u8 = undefined; + const s: proto.Stat = .{ + .bytes = sunk_bytes, + .crc = sunk_crc.final(), + .bad_frames = bad_frames, + .tx_dropped = tx_dropped, + }; + s.encode(&buf); + send(.stat, &buf); + sunk_bytes = 0; + sunk_crc = .init(); + bad_frames = 0; +} + +/// Stream `n` pattern bytes back as `data` frames, then a `stat` whose CRC covers all of them. +/// +/// Filled a frame at a time from the shared generator rather than from a table: the host computes +/// the same sequence from the same function, so a disagreement is a real transport fault and not two +/// copies of a constant drifting apart. +fn source(n: u32) void { + var chunk: [proto.max_payload]u8 = undefined; + var sent: u32 = 0; + var hash: std.hash.Crc32 = .init(); + while (sent < n) { + const take: u32 = @min(@as(u32, proto.max_payload), n - sent); + proto.fillPattern(chunk[0..take], sent); + hash.update(chunk[0..take]); + send(.data, chunk[0..take]); + sent += take; + } + var buf: [proto.Stat.encoded_len]u8 = undefined; + const s: proto.Stat = .{ + .bytes = sent, + .crc = hash.final(), + .bad_frames = bad_frames, + .tx_dropped = tx_dropped, + }; + s.encode(&buf); + send(.stat, &buf); +} + +/// Consume one complete frame from the head of `rx`. Returns the bytes consumed, or 0 when the +/// frame is not all here yet. +fn step() usize { + const header = proto.parseHeader(rx[0..rx_len]) catch { + // Lost sync. Drop ONE byte and let the next call try again from there: the magic is two + // bytes, so resynchronising by scanning is the only correct recovery, and dropping the whole + // buffer would discard a good frame that happened to follow a corrupt one. + return 1; + } orelse return 0; + + const total = proto.header_len + @as(usize, header.len); + if (rx_len < total) return 0; + const payload = rx[proto.header_len..total]; + + if (proto.crc(payload) != header.crc) { + // Corruption, not loss: the length was plausible and the bytes were not. Counted and + // discarded, because acting on it would put the wrong answer in the host's hands. + bad_frames +%= 1; + return total; + } + + switch (header.op) { + .ping => send(.pong, payload), + .sink => { + sunk_bytes +%= header.len; + sunk_crc.update(payload); + }, + .report => sendStat(), + .source => { + const n = if (header.len >= 4) std.mem.readInt(u32, payload[0..4], .little) else 0; + source(n); + }, + // Replies are ours to send, never to receive. A reply arriving here means the host is + // confused or the wire is looping back; count it rather than answering it. + .pong, .stat, .data => bad_frames +%= 1, + } + return total; +} + +export fn zig_main() noreturn { + // FIRST, before any `.rodata` is touched - and the marker below IS `.rodata`. Without this the + // bootloader's stale cache lines make that string read as machine code, the board emits noise, + // and it looks exactly like a firmware that never started. Measured here before the call was + // added: `\xefc\xff\xff\xd5\xb7...` instead of the marker. + @import("soc").flushFlashCache(); + + // The bootloader arms the RTC watchdog and expects the application to take it over. Nothing in + // this repo ever did, so every image here was being reset on a ten-second cycle - invisible to + // a program that prints once and spins, and fatal to one that must answer for a minute. It is + // why this responder booted, printed its marker, and then went silent: `rst:0x10 + // (CHIP_LP_WDT_RESET)` in the next boot log, measured. + _ = hal.rwdt.disable(); + sunk_crc = .init(); + + // Announce readiness in plain text rather than as a frame: the host watches for this during + // reset, while the bootloader's own chatter is still arriving and no frame parser is in sync. + write("\r\nMARK UARTPERF_READY\r\n"); + + while (true) { + // Fill from the FIFO first and always, so the RX FIFO is never left to overflow while this + // loop is busy elsewhere. 128 bytes at 115200 is 107 ms of slack and a `source` burst can + // hold the transmitter far longer than that, which is exactly the hazard being measured on + // the editor - so the measuring instrument must not have it. + if (rx_len < rx.len) { + const room = rx.len - rx_len; + var got: usize = 0; + while (got < room and uart0.rxCount() > 0) { + rx[rx_len + got] = uart0.popByte(); + got += 1; + } + rx_len += got; + } + + var off: usize = 0; + while (off < rx_len) { + const n = step(); + if (n == 0) break; + off += n; + } + if (off > 0) { + std.mem.copyForwards(u8, rx[0 .. rx_len - off], rx[off..rx_len]); + rx_len -= off; + } + } +} + +/// Reset entry, the same shape as every other application here: the bootloader hands over with an +/// unspecified stack pointer and the FPU off, and `.bss` is not cleared for us. The FPU bit matters +/// even in a program with no floats, because a generic `bufPrint` instantiation can reach std's +/// float formatting path - and `std.hash.Crc32`'s table generation is comptime, so nothing here +/// needs the FPU at run time, but nothing here is worth a trap either. +export fn _start() linksection(".text.entry") callconv(.naked) noreturn { + asm volatile ( + \\ li t0, 1 << 13 + \\ csrs mstatus, t0 + \\ la sp, __stack_top + \\ mv fp, sp + \\ la t0, __bss_start + \\ la t1, __bss_end + \\ bgeu t0, t1, 2f + \\1: + \\ sw zero, 0(t0) + \\ addi t0, t0, 4 + \\ bltu t0, t1, 1b + \\2: + \\ j zig_main + ); +} + +/// A panic here would be a measurement that silently stopped, so it says so on the wire it was +/// measuring - through the ROM's printf, because a panic may well be the UART path itself failing. +pub const panic = std.debug.FullPanic(struct { + fn call(msg: []const u8, _: ?usize) noreturn { + @import("soc").rom.print("MARK UARTPERF_PANIC %s\r\n", .{msg.ptr}); + while (true) {} + } +}.call); |
