UART Command Harness for an FPGA Bring-Up
8N1 UART, CDC synchronizers, and a protocol FSM in SystemVerilog
View repositoryLink
8N1 UART, 115200 baud
Bit sampling
Majority of 3 per bit cell
CDC
2-FF sync, ASYNC_REG
Target
Artix-7 XC7A35T (Basys 3)
01Overview
A host script needs to load Q, K and V into an accelerator, start it, poll status, and read back the output and a cycle count — over two wires. This is the RTL that makes that possible: a serial physical layer, the clock-domain-crossing that any real board needs, and a command protocol built as an explicit state machine.
It wraps the attention core without modifying it. The core has no knowledge of the UART, and the harness has no knowledge of the core's internals; the boundary is the core's existing handshake.
Status, stated plainly: the whole kit is verified in simulation and independently audited, and nothing has been run on a physical board. The FPGA results stay pending-hardware until there is a bring-up log to point at.
02Problem Statement
Every signal arriving from outside the FPGA is asynchronous to its clock: a button press, and the UART receive line from a USB bridge with its own oscillator. Sampling those directly puts a flip-flop into a metastable state sooner or later, and the failure is intermittent and near-impossible to debug on a board.
A serial receiver has a second, quieter failure mode. Sampling once in the middle of each bit cell is the textbook approach and it is fragile: a single glitch, or a baud rate that drifts, silently corrupts a byte and the protocol above it desynchronizes with no indication of why.
03Architecture
Physical layer: uart_rx counts a full bit cell of clocks — 868 at 100 MHz and 115200 baud, an actual baud of 115207, 0.006% off — and decides each bit by a majority-of-three vote taken at the cell midpoint and one sixteenth either side. A single-sample glitch therefore cannot corrupt a bit. The start bit is qualified by the same vote, so line noise does not fabricate a frame, and a failed stop-bit vote is treated as a framing error: the byte is dropped silently rather than passed upward as corrupt data.
Clock-domain crossing: sync2ff is a parameterized two-flop synchronizer carrying an ASYNC_REG attribute so the tool keeps the pair adjacent, in plain attribute syntax that non-Xilinx tools ignore. RST_VAL matters — a UART receive line idles high, so its synchronizer resets to 1 and a reset cannot fabricate a start bit, while a button synchronizer resets to 0.
There is one documented exemption to the rule that every sequential element is reset: the synchronizer for the reset button itself cannot be reset by the signal it produces, so that instance ties its reset low. The audit flagged that this leans on the FPGA's global set/reset at configuration, and the comment now says so.
Command layer: an explicit FSM makes one decision per byte received or sent, and stalls on the transmitter's valid/ready handshake rather than dropping data. Loads issued while the core is busy are answered with a NAK instead of being silently ignored.
Alongside it, tile_runner is a first-bring-up helper that plays a fixed 16-cycle stimulus into the matmul tile — chosen to be hand-checkable, with one negative row — so the very first thing tried on a board has an expected answer worked out on paper.
04Technical Challenges
Testing the protocol through the pins, not through the internals
The testbench drives only the serial lines — no peeking at internal state — and encodes and decodes frames using the same codec the host script uses, so a mismatch between hardware and host cannot hide. Every output byte is checked against the Python golden model at two sequence lengths, and the cycle count is checked against a closed-form formula.
An adversarial audit found two real holes
An independent review pass found that a phantom byte emitted on a line break was NAKed but never actually observed by any test, and that the documented resync procedure was buggy as written. Both were fixed: a test now asserts the exact tail byte, and resync drains until the line is quiet and checks the last byte before proceeding.
Recovering from a truncated frame
A host that dies mid-byte leaves the receiver part-way through a frame. The suite proves recovery from framing errors, from resync, and from a reset arriving in the middle of a byte, because on real hardware those are the states you actually land in.
Keeping the accelerator untouched
The harness wraps the core with no parameters and no edits, so the thing verified at RTL, in formal, and at gate level is the same thing the board would run. Lint checks confirm the core sources are unmodified.
05Implementation Details
cocotb suite over two seeds
Nine tests across the ten protocol commands, run at two seeds, each driving only the serial pins. Byte-level output is compared against the golden model at sequence lengths 4 and 16, and the reported cycle count is checked to be exact rather than approximately right.
Lint and synthesis checks on both tops
Verilator with all warnings enabled is clean on both top levels, verible is clean, and a Yosys check reports zero problems with no inferred latches and no vendor primitives, so the design stays portable rather than accidentally Xilinx-specific.
An elaboration-time baud guard
A bad clock-to-baud ratio is caught when the design elaborates rather than showing up as garbled characters on a terminal. The guard was negative-tested in both tools to confirm it actually fires.
A host script that self-tests
The Python host exposes a self-test that exercises the frame codec without any hardware attached, so a protocol change breaks the host in an obvious place instead of during board bring-up.
06Key Engineering Decisions
Majority-of-three sampling over a single mid-bit sample
One sample per bit is the common approach and it is what fails first on a noisy line. Three samples and a vote costs two comparators and a little control, and removes an entire class of intermittent bug that is miserable to diagnose on a board.
Drop a bad frame rather than pass it up
A byte whose stop bit fails the vote is discarded with no valid pulse. Passing it upward with an error flag invites a consumer that ignores the flag; dropping it means the protocol layer only ever sees bytes that framed correctly.
Write the exemption down rather than quietly break the rule
Every sequential element is reset except one, and that one is documented in both the code and the spec with the reason and its dependency on configuration-time global reset. An undocumented exception is indistinguishable from an oversight during review.
Hand-checkable first stimulus
The bring-up helper uses a small constant pattern with a deliberately negative row and accumulator values derived by hand. When the first attempt on a board shows the wrong number, the question is which stage is wrong, not what the answer should have been.
07Future Improvements
- Run it on the Basys 3, capture ILA traces and the timing report, and replace the pending-hardware rows with real bench evidence.
- Add button debouncing, which the audit flagged as a footnote rather than implemented logic.
- Raise the baud rate once the link is proven, since the bit-cell divider is already parameterized.
- Extend the protocol to stream multiple tiles back to back rather than one run per command sequence.
08Media & Documentation
Visual documentation for this project. Simulation artefacts can be exported today; the on-board captures stay reserved until the design has actually run on hardware.
Open to opportunities
Looking for a Summer 2027 internship.
If you're hiring for embedded, firmware, or hardware roles, I'd be glad to talk through my projects and how I work. The fastest way to reach me is email.
