Building an Accelerator on Basys 3, Part 1: Blink, Handshakes, and a MAC
I am starting a new series about building a small neural-network accelerator on a Basys 3 FPGA board. The long-term goal is a design that can read activations and weights, compute dot products, apply operations such as bias and ReLU, and expose the result through a simple board-level interface.
That is too much hardware to build all at once. For the first stage, I broke the problem into three small projects:
blink_top: prove that the clock, reset, constraints, bitstream, and physical board all work.handshake_fsm: define how a hardware block accepts work and reports progress.mac_unit: combine that control protocol with the multiply-accumulate operation at the heart of a dot product.
Each project is simple on its own. Together, they establish the two halves of an accelerator: a control path that decides when work happens and a datapath that decides what computation happens.
Milestone 1: Make the board blink
The first design is a counter connected to an LED:
module blink_top #(
parameter int COUNTER_WIDTH = 26
) (
input logic clk,
input logic reset,
output logic led
);
logic [COUNTER_WIDTH-1:0] counter;
always_ff @(posedge clk) begin
if (reset)
counter <= '0;
else
counter <= counter + 1'b1;
end
assign led = counter[COUNTER_WIDTH-1];
endmodule
The Basys 3 oscillator runs at 100 MHz, far too fast to connect directly to an LED and see it change. A binary counter divides that clock naturally. Every successive bit toggles at half the frequency of the previous bit, so exposing the most significant bit of a 26-bit counter produces a visible blink.
The complete period is clock cycles:
The parameter is useful because simulation should not wait through tens of millions of cycles. The testbench instantiates the same module with COUNTER_WIDTH = 4, checks the LED after each increment, and confirms that reset clears the state.
The constraint file completes the connection between RTL and the physical board:
clkmaps to pinW5, the 100 MHz oscillator.resetmaps toU18, the center pushbutton.ledmaps toU16, user LED 0.
This milestone was verified twice: first with a self-checking simulation, then by generating a bitstream and observing the LED on the Basys 3. Blink is not an accelerator, but it proves the entire tool and hardware path before the design becomes harder to debug.
Milestone 2: Give the hardware a transaction protocol
An arithmetic block needs more than inputs and an output. Its caller must know whether the block can accept a request, whether it is working, and when the result is complete.
The reusable handshake_fsm answers those questions with four signals:
start: request one operation.ready: the block can accept a request.busy: an operation is in progress.done: the operation has just completed.
The controller has three states:
request accepted work complete
| |
IDLE ---------------+----> WORK ----------------+----> DONE ----> IDLE
ready = 1 busy = 1 done = 1
A request is accepted on a rising clock edge when start && ready is true. The WORK_CYCLES parameter controls how many complete cycles the controller remains busy. Completion is represented by a one-cycle done pulse, after which the FSM returns to IDLE and raises ready again.
The outputs are determined only by the current state:
always_comb begin
ready = 1'b0;
busy = 1'b0;
done = 1'b0;
case (state)
IDLE: ready = 1'b1;
WORK: busy = 1'b1;
DONE: done = 1'b1;
default: ;
endcase
end
This creates a deliberately clear contract:
startis ignored while the block is busy.- A request presented during
DONEis also ignored. - Synchronous reset aborts the current operation and returns the block to
IDLE. - If a sender holds
starthigh until the controller becomes ready again, another operation will eventually be accepted. The sender should normally lowerstartafter an acceptance edge.
The dedicated DONE state makes the completion pulse easy to reason about, although it introduces a recovery cycle before the next request can be accepted. A higher-throughput design could complete one operation and accept another without that bubble. For this stage, clarity matters more than maximum throughput.
The self-checking testbench verifies the full protocol: request acceptance, exactly three configured busy cycles, ignored requests while occupied, the one-cycle completion pulse, return to idle, and reset in the middle of an operation.
Milestone 3: Add the MAC datapath
The basic arithmetic operation in a neural-network accelerator is multiply-accumulate:
Our first mac_unit uses signed, two’s-complement values:
- two 8-bit operands,
- a full 16-bit product,
- a 24-bit accumulator and result.
The width choices are important. Multiplying two 8-bit values can require 16 bits. Storing the product in only 8 bits would silently discard its upper bits. Before addition, the 16-bit product is sign-extended to the accumulator width so that a negative product remains negative.
logic signed [15:0] product;
logic signed [23:0] product_extended;
always_comb begin
product = operand_a * operand_b;
product_extended = product;
end
Because both signals are declared signed, assigning the narrower product to product_extended performs the required sign extension.
The controller from the previous milestone is instantiated directly inside the MAC:
handshake_fsm #(
.WORK_CYCLES(1)
) controller (
.clk (clk),
.reset (reset),
.start (start),
.ready (ready),
.busy (busy),
.done (done)
);
The connection between control and datapath is the acceptance condition:
always_ff @(posedge clk) begin
if (reset)
result <= '0;
else if (start && ready)
result <= accumulator_in + product_extended;
end
Input wires and combinational arithmetic may continue to change, but the result register updates only when the controller accepts a request. While busy or done is high, ready is low and the stored result remains stable.
For example, suppose the MAC sees these values immediately before an acceptance edge:
operand_a = 3
operand_b = -4
accumulator_in = 7
The registered result becomes:
7 + (3 × -4) = -5
The clock-by-clock behavior is:
| Moment | ready | busy | done | result |
|---|---|---|---|---|
| Before acceptance edge | 1 | 0 | 0 | previous value |
| After acceptance edge | 0 | 1 | 0 | -5 |
| After next edge | 0 | 0 | 1 | -5 |
| After third edge | 1 | 0 | 0 | -5 |
There is an intentional simplification here. The multiply and add are combinational, and the result is captured on the same edge that accepts the request. The WORK state provides a clean transaction protocol; it does not represent arithmetic gradually happening over that cycle. A genuinely multi-cycle MAC would latch the inputs, perform work during one or more cycles, and register the result when the computation finishes.
The MAC testbench checks more than a convenient positive example. It covers:
- mixed signs: ,
- changing inputs while busy without changing the accepted result,
- the positive boundary: ,
- the negative boundary: ,
- reset aborting an operation and clearing the result.
The blink, handshake, and MAC simulations all pass, and the blink design also runs on the physical board.
What these three projects establish
The most important result is not the amount of arithmetic completed. It is the structure that can now be reused:
control path
start ----------> handshake_fsm ----------> ready / busy / done
|
| accepted request
v
operands --------> arithmetic datapath --------> result register
Blink proved the FPGA workflow. The handshake FSM defined a clock-by-clock contract. The MAC showed how that controller can wrap real signed arithmetic without mixing protocol logic into the datapath.
The next step is a dot-product engine. It will reuse one MAC across a sequence of activation and weight pairs while a controller tracks an index and a running accumulator:
That is where the project begins to look like a small accelerator rather than a collection of individual RTL blocks—and it will be the subject of the next post in this series.