You are not writing a program. You are describing a circuit.
A hands-on introduction to SystemVerilog for FPGAs, written for people who have never drawn a logic gate and for people who have already blinked an LED. Each part has a short explanation, working code, and the mistake most beginners make there. Tool notes assume AMD Vivado.
- Amber: combinational logic, no memory
- Blue: sequential logic, registers and clocks
Run the examples yourself. Code blocks with a Simulate link open a matching example in the course simulator, where you can edit the code and watch the waveforms. Every code block also has a Copy button. The two example sets:
- Basics: combinational logic goes with parts 2 to 5.
- Basics: sequential logic goes with parts 6 to 10 and 12.
The simulator drives the inputs from a stimulus block inside each example, so there is no testbench to write there. In Vivado you do write one (parts 11 and 12), and Vivado’s own simulator, xsim, is the reference for this course.
Contents
1. Code that becomes hardware
A software program is a list of instructions that a processor carries out one after another. A SystemVerilog module is a different kind of thing: it names some parts and the wires between them. Every assign and every always block becomes its own piece of circuitry, and all of those pieces are active at the same moment.
module two_gates (
input logic a, b,
output logic y, z
);
assign y = a & b; // an AND gate
assign z = a | b; // an OR gate, existing at the same time
endmodule
This is the circuit the tools build. Swap the two assign lines and you get exactly the same circuit, because order in a description is not order in time.
Common trap for programmers. Do not read the code as “first this, then that”. Ask instead: what hardware does this line create, and what is it connected to?
Two words you will hear all the time
- Simulation runs a model of your description on a computer and shows waveforms. It is how you check behaviour.
- Synthesis converts your description into the resources of a real FPGA. Only a subset of the language can be synthesized.
In the snippets below, declarations are sometimes left out so the interesting part stays visible.
2. Bits and vectors
A wire carries one bit: 0 or 1. A vector is a group of wires with one name, and it is the basic data type of FPGA design. Whether the group means an unsigned number, a signed number, or just a pattern of flags is a matter of interpretation; the wires do not care.
Try it: click bits to flip them and watch the same eight wires read as different things.
logic [7:0] data;
logic [3:0] hi, lo;
logic [11:0] padded;
logic [7:0] ones;
assign hi = data[7:4]; // slice: four of the eight wires
assign lo = data[3:0];
assign padded = {4'b0000, data}; // concatenation: glue vectors together
assign ones = {8{1'b1}}; // replication: eight copies of a 1
logic signed [7:0] temperature; // -128 to 127
logic [7:0] level; // 0 to 255
Literals have the form width ' base value: 4'b1010 is four bits in binary, 8'hFF is eight bits in hex, 8'd200 is eight bits in decimal. Write the width every time; it prevents a whole family of silent bugs.
3. Combinational logic
Combinational logic has no memory. The output depends only on the inputs right now. Change an input and the output follows after a short propagation delay. It behaves like a spreadsheet formula: the result cell is always up to date with the cells it refers to.
module add_compare (
input logic [7:0] a, b,
output logic [8:0] sum, // one bit wider to keep the carry
output logic a_bigger
);
assign sum = a + b; // an 8-bit adder
assign a_bigger = (a > b); // a comparator
endmodule
You wrote “add” and “compare”, not gates. The synthesis tool chooses how to build them from the FPGA’s lookup tables (LUTs). That is why practical FPGA design is done with vectors and arithmetic and almost never with individual gates.
Width matters. Adding two 8-bit numbers can produce 9 bits. If sum were only 8 bits wide, the carry would be lost without any error message.
4. Gates, briefly
You do not need to hand-design gates, but you should recognise them, because the operators are named after them. A gate is a tiny combinational circuit on single bits. Applied to vectors, the operators work on every bit position at once.
| Operator | Gate | Meaning | Example |
|---|---|---|---|
& | AND | 1 only if both inputs are 1 | a & b |
| | OR | 1 if at least one input is 1 | a | b |
^ | XOR | 1 if the inputs differ | a ^ b |
~ | NOT | flips the bit | ~a |
? : | MUX | picks one of two inputs | sel ? a : b |
Put an operator in front of a single vector and it combines all the bits into one. These reduction operators are handy:
assign all_ones = &data; // 1 only if every bit is 1
assign any_set = |data; // 1 if at least one bit is 1
assign parity = ^data; // 1 if an odd number of bits are 1
assign masked = data & 8'h0F; // keep the low nibble, clear the rest
The multiplexer (mux) is the gate that matters most, because every decision you write becomes one. That is the next part.
5. Decisions become multiplexers
A mux has several data inputs and a select input. The select decides which input reaches the output. In hardware both choices always exist; the select just picks between them. Three different notations describe the same circuit:
// 1. Conditional operator
assign y1 = sel ? a : b;
// 2. if / else inside always_comb
always_comb begin
if (sel) y2 = a;
else y2 = b;
end
// 3. case, best for many choices (a 4-to-1 mux)
always_comb begin
case (sel4)
2'd0: y3 = in0;
2'd1: y3 = in1;
2'd2: y3 = in2;
default: y3 = in3;
endcase
end
always_comb is a block that re-evaluates whenever anything it reads changes. Inside it you assign with =.
The accidental latch
If an always_comb block does not give an output a value on every path, the circuit would have to remember the old value. The tool builds a latch, which is a memory element you did not intend and usually do not want.
always_comb begin
if (en) y = a; // what is y when en is 0? "keep the old value"
end
always_comb begin
y = '0; // default for every path
if (en) y = a;
end
Habit to build. Start every always_comb block with default values for all of its outputs, and always include a default branch in a case.
6. Clocks, flip-flops and registers
Circuits that remember need a memory element. The basic one is the flip-flop: a one-bit memory that looks at its input only at one instant, the rising edge of a clock, and holds that value until the next edge. A register is a vector of flip-flops sharing one clock. The clock is the metronome of the design: everything useful happens as combinational logic between registers.
module register8 (
input logic clk, rst,
input logic [7:0] d,
output logic [7:0] q
);
always_ff @(posedge clk) begin
if (rst) q <= '0; // synchronous reset
else q <= d;
end
endmodule
Wiring q back into the logic that produces d gives you a counter, which is exactly the checkbox in the widget:
logic [3:0] count;
always_ff @(posedge clk) begin
if (rst) count <= 4'd0;
else if (en) count <= count + 4'd1; // wraps from 15 to 0 by itself
end
Why <= and not =
Inside always_ff use the non-blocking assignment <=. Every right-hand side is read first, then all the registers update together, like real flip-flops that all sample on the same edge.
always_ff @(posedge clk) begin
a <= b;
b <= a; // a real swap: both read the OLD values
end
Rule of thumb. always_ff uses <=. always_comb and assign use =. Never mix the two styles in one block.
Two characters, one meaning. The non-blocking assignment is written with two characters: a less-than sign followed by an equals sign, <=. It is not the single symbol ≤. Inside an expression such as if (a <= b) the same two characters mean “less than or equal”, and the position tells you which one it is: at the start of a statement, q <= d; is an assignment.
A reset that waits for the clock is a synchronous reset. An asynchronous reset acts immediately and is written always_ff @(posedge clk or posedge rst). Pick one style per project and keep it consistent; synchronous is a good default on most FPGAs.
7. RTL thinking: registers plus next-state logic
RTL stands for register transfer level. A design at this level is a set of registers plus combinational logic that calculates what each register should hold after the next clock edge. When you plan a design, ask two questions for every piece of state:
- What are my registers?
- What is the next-value equation for each one?
Writing the two halves as separate blocks keeps the thinking clean. Below, a counter that can hold, count or load a value:
logic [7:0] count, count_next;
// Combinational: what should count become?
always_comb begin
count_next = count; // default: hold
if (load) count_next = load_value;
else if (en) count_next = count + 8'd1;
end
// Sequential: copy it in on the clock edge
always_ff @(posedge clk) begin
if (rst) count <= '0;
else count <= count_next;
end
Notice how the default count_next = count doubles as the latch protection from part 5 and as the “do nothing” behaviour.
8. Modules, parameters and hierarchy
A module is a box with named ports. You build larger designs by placing instances of modules and connecting their ports, much like placing chips on a board. A parameter makes a module configurable: the same source gives you a 4-bit or a 32-bit counter.
module counter #(
parameter int WIDTH = 8
) (
input logic clk, rst, en,
output logic [WIDTH-1:0] count
);
always_ff @(posedge clk) begin
if (rst) count <= '0;
else if (en) count <= count + 1'b1;
end
endmodule
module top (
input logic clk, rst,
output logic [3:0] slow
);
logic [7:0] fast;
logic tick;
counter #(.WIDTH(8)) u_fast (
.clk(clk), .rst(rst), .en(1'b1), .count(fast)
);
assign tick = (fast == 8'hFF); // true once every 256 clocks
counter #(.WIDTH(4)) u_slow (
.clk(clk), .rst(rst), .en(tick), .count(slow)
);
endmodule
Slow things down with an enable, not a second clock. The second counter above runs on the same clock but only advances when tick is high. This is the standard FPGA way to get a slower rate, and it avoids the timing problems of generated clocks.
Always connect ports by name (.clk(clk)), never by position. It makes mistakes visible.
9. Finite state machines
A finite state machine (FSM) is a design whose behaviour depends on which of a small set of states it is in. It is the natural place where everything so far comes together: a state register, next-state logic, and outputs. Use an enum so the states have names.
Example: a timer controller. Pressing start runs the timer for 100 clock cycles, then it reports done until start is released.
typedef enum logic [1:0] {IDLE, RUN, DONE} state_t;
state_t state, state_next;
logic [7:0] timer;
// 1. State register
always_ff @(posedge clk) begin
if (rst) state <= IDLE;
else state <= state_next;
end
// 2. Next-state logic
always_comb begin
state_next = state;
case (state)
IDLE: if (start) state_next = RUN;
RUN: if (timer == 8'd99) state_next = DONE;
DONE: if (!start) state_next = IDLE;
default: state_next = IDLE;
endcase
end
// 3. Data path: the timer counts only while running
always_ff @(posedge clk) begin
if (state == RUN) timer <= timer + 8'd1;
else timer <= '0;
end
// 4. Outputs
assign busy = (state == RUN);
assign done = (state == DONE);
Draw the states as circles and the transitions as arrows on paper first. The code is then a direct transcription of the drawing.
10. Timing: why the clock cannot be infinitely fast
Signals take time to travel through logic and wires. Between two registers, the data must travel from the first register’s output, through the combinational logic, and arrive at the second register’s input a little before the next clock edge.
The clock period must be longer than the sum of the delays along the slowest such path, called the critical path:
Tclk ≥ tclk-to-q + tlogic + trouting + tsetup
If the logic in the middle is too slow, you can cut it in two by adding a register in the middle. This is pipelining: each stage does less work per clock, so the clock can run faster. The price is one cycle of extra latency.
// One long path: multiply, then add, in a single clock cycle
always_ff @(posedge clk) y <= a * b + c;
// Pipelined: two shorter paths, result is one cycle later
logic [15:0] prod, c_d;
always_ff @(posedge clk) begin
prod <= a * b; // stage 1
c_d <= c; // delay c so it stays aligned with prod
y <= prod + c_d; // stage 2
end
You do not calculate delays by hand. The implementation tool reports timing after place-and-route, and you tell it your target with a clock constraint (part 12). A report that says timing is not met means your critical path is too long for that clock.
11. Testbenches and simulation-only code
A testbench is a module with no ports that surrounds your design, generates a clock, applies inputs and prints or checks results. It is never synthesized, so it may use constructs that have no hardware meaning: time delays, initial blocks, $display.
module tb_counter;
logic clk = 0, rst = 1, en = 1;
logic [7:0] count;
counter #(.WIDTH(8)) dut (.clk(clk), .rst(rst), .en(en), .count(count));
always #5 clk = ~clk; // 10 ns period: toggle every 5 ns
initial begin
#22 rst = 0; // release reset between two edges
repeat (20) @(posedge clk);
#1; // let the non-blocking update settle
$display("count = %0d", count); // prints 20
assert (count == 8'd20) else $error("counter is wrong");
$finish;
end
endmodule
Open the waveform viewer and look at clk, rst and count together. Waveforms are where students first see concurrency and time, which text alone cannot show.
In Vivado, add the testbench under Simulation Sources, not Design Sources, so synthesis ignores it, and make it the simulation top module. Then run Behavioral Simulation (xsim).
| Construct | In a design | In a testbench |
|---|---|---|
#10 delay | not synthesizable | fine |
$display, $finish | ignored or error | fine |
initial with stimulus | not synthesizable | fine |
always_ff, always_comb, assign | yes | fine |
12. From code to a programmed FPGA with Vivado
An FPGA is a sea of configurable blocks. Your description is mapped onto them:
| FPGA resource | What it implements | What you wrote |
|---|---|---|
| LUT | Any small logic function (up to 6 inputs on the AMD devices Vivado targets) | Operators, muxes, comparisons |
| Flip-flop | One bit of state | always_ff registers |
| Block RAM | Kilobits of memory | An array read and written in a clocked block |
| DSP slice | Fast multiply-accumulate | a * b + c |
A LUT can be programmed to be any logic function of a few inputs. That is why you can think in equations and vectors while the tool finds the gates for you.
The flow in Vivado
| Stage | Where in Vivado | What to check |
|---|---|---|
| Create project | Quick Start, Create Project, RTL Project. Pick your board or FPGA part. Add .sv files as design sources, the .xdc file as a constraint, and testbenches as simulation sources. | Files ending in .sv are read as SystemVerilog. For a .v file, select it and set Type to SystemVerilog in Source File Properties. |
| Simulate | Flow Navigator, Simulation, Run Simulation, Run Behavioral Simulation (xsim). | Waveforms match what you expect. The testbench is set as the simulation top module. |
| Elaborate | RTL Analysis, Open Elaborated Design, Schematic. | Your code drawn as muxes, adders and flip-flops, before any optimisation. Best place to make “code is hardware” concrete. |
| Synthesize | Synthesis, Run Synthesis. Then Open Synthesized Design. | Read the warnings, especially inferred latches and multiple drivers. Check Report Utilization for LUTs and flip-flops. |
| Implement | Implementation, Run Implementation (place and route). Then Report Timing Summary. | Worst negative slack (WNS) and worst hold slack (WHS) are zero or positive, meaning timing is met. |
| Bitstream | Program and Debug, Generate Bitstream. | No DRC errors about unconstrained ports. |
| Program | Open Hardware Manager, Open Target, Auto Connect, Program Device. | The DONE light on the board turns on and your design runs. |
Vivado’s synthesizer supports a large synthesizable subset of SystemVerilog. When it rejects a construct, look it up in the Vivado Design Suite User Guide: Synthesis (UG901). The same guide documents attributes such as (* ram_style = "block" *) and (* use_dsp = "yes" *), which steer whether an array becomes block RAM and whether a multiplier uses a DSP slice.
First project: blink an LED
A 100 MHz clock is far too fast to see. A counter divides it down: the top bit of a 27-bit counter toggles roughly every 0.67 seconds.
`default_nettype none
module blinky (
input wire clk_100mhz,
output logic led
);
logic [26:0] ticks = '0; // FPGA registers can start with a value
always_ff @(posedge clk_100mhz)
ticks <= ticks + 1'b1;
assign led = ticks[26]; // 100e6 / 2^27 is about 0.75 blinks per second
endmodule
The tool also needs to know which physical pin each port uses and how fast the clock is. This is written in a constraints file, whose syntax depends on the vendor. Vivado example:
## Replace <pin> with the pins listed in your board's manual
set_property PACKAGE_PIN <pin> [get_ports clk_100mhz]
set_property PACKAGE_PIN <pin> [get_ports led]
set_property IOSTANDARD LVCMOS33 [get_ports {clk_100mhz led}]
create_clock -period 10.000 -name sys_clk [get_ports clk_100mhz]
Board makers usually publish a master .xdc file with every pin of the board. Copy it into your project, uncomment the lines you need, and rename the ports in the file to match the ports of your top module.
13. Mistakes to expect
Almost every beginner meets these. Knowing the symptom saves hours.
| Symptom | Cause | Fix |
|---|---|---|
| Warning: latch inferred | An always_comb output is not assigned on every path | Assign a default first; add else or default |
| Shift register or swap gives wrong values | Blocking = used inside always_ff | Use <= in clocked blocks |
| “Multiple drivers” error | One signal assigned in two blocks or an assign and a block | Give each signal exactly one driver |
| A typo in a signal name does nothing visible | The tool silently created a new one-bit wire | Put `default_nettype none at the top of each file |
| Sum is missing the top bit | Result vector is too narrow | Make it one bit wider than the operands |
| Works in simulation, fails on the board | Delays or testbench-only constructs in the design; missing constraints | Keep to the synthesizable subset; check pins and clock constraint |
| Timing not met | A combinational path is longer than the clock period | Pipeline it, or lower the clock frequency |
| Bitstream stops with DRC errors NSTD-1 or UCIO-1 | A top-level port has no pin or I/O standard in the .xdc file | Add PACKAGE_PIN and IOSTANDARD for every port |
Vivado reports a syntax error on logic or always_ff | The file is treated as plain Verilog | Rename it to .sv, or set its file type to SystemVerilog |
14. Cheat sheet
| Hardware idea | What you write | Remember |
|---|---|---|
| Wire or bus | logic [N-1:0] x; | Always state the width |
| Combinational logic | assign, always_comb | Use =; cover every path |
| Multiplexer | ?:, if, case | Both branches exist in hardware |
| Register | always_ff @(posedge clk) | Use <= |
| Reset | if (rst) q <= '0; | One style per project |
| Reusable block | module with parameter | Connect ports by name |
| State machine | enum + state register + next-state block | Draw the diagram first |
| Slower rate | Clock enable (tick) | Avoid generated clocks |
| Higher clock speed | Pipeline registers | Costs latency, not throughput |
| Check a design | Testbench + waveforms | Delays and $display are for simulation only |