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:

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.

    two_gates.svcombinational
    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
    a b AND OR y z

    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.

    An 8-bit vector: logic [7:0] data

    Bit 7 is the most significant bit (MSB), bit 0 the least significant (LSB).

    Binary literal
    Hex literal
    As unsigned
    As signed (two’s complement)
    data[7:4]
    data[3:0]
    vectors.svcombinational
    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.

    add_compare.svcombinational
    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.

    OperatorGateMeaningExample
    &AND1 only if both inputs are 1a & b
    |OR1 if at least one input is 1a | b
    ^XOR1 if the inputs differa ^ b
    ~NOTflips the bit~a
    ? :MUXpicks one of two inputssel ? a : b

    Put an operator in front of a single vector and it combines all the bits into one. These reduction operators are handy:

    reduction.svcombinational
    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:

    mux_forms.svcombinational
    // 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.

    latch_bug.svbug: infers a latch
    always_comb begin
      if (en) y = a;        // what is y when en is 0? "keep the old value"
    end
    latch_fix.svfix: assign a default first
    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.

    Combinational output vs. registered output

    Circuit: assign y = d + 4'd1; then always_ff @(posedge clk) q <= y; Change d and watch y react instantly. q moves only when you press the clock button.

    d (input)
    0000
    0
    y (wire)
    0001
    1
    q (register)
    0000
    0

    Press “Clock edge” to load y into q.

    register.svsequential
    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:

    counter_basic.svsequential
    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.

    swap.svsequential
    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:

    load_counter.svcombinational + sequential
    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.

    counter.svsequential
    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
    top.svhierarchy
    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.

    timer_fsm.svcombinational + sequential
    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.

    register A combinational logic register B clk-to-q setup both registers share the same clock

    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.

    pipeline.svsequential
    // 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.

    tb_counter.svsimulation only
    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).

    ConstructIn a designIn a testbench
    #10 delaynot synthesizablefine
    $display, $finishignored or errorfine
    initial with stimulusnot synthesizablefine
    always_ff, always_comb, assignyesfine

    12. From code to a programmed FPGA with Vivado

    An FPGA is a sea of configurable blocks. Your description is mapped onto them:

    FPGA resourceWhat it implementsWhat you wrote
    LUTAny small logic function (up to 6 inputs on the AMD devices Vivado targets)Operators, muxes, comparisons
    Flip-flopOne bit of statealways_ff registers
    Block RAMKilobits of memoryAn array read and written in a clocked block
    DSP sliceFast multiply-accumulatea * 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

    StageWhere in VivadoWhat to check
    Create projectQuick 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.
    SimulateFlow Navigator, Simulation, Run Simulation, Run Behavioral Simulation (xsim).Waveforms match what you expect. The testbench is set as the simulation top module.
    ElaborateRTL 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.
    SynthesizeSynthesis, Run Synthesis. Then Open Synthesized Design.Read the warnings, especially inferred latches and multiple drivers. Check Report Utilization for LUTs and flip-flops.
    ImplementImplementation, 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.
    BitstreamProgram and Debug, Generate Bitstream.No DRC errors about unconstrained ports.
    ProgramOpen 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.

    blinky.svsequential
    `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:

    blinky.xdcconstraints (Vivado)
    ## 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.

    SymptomCauseFix
    Warning: latch inferredAn always_comb output is not assigned on every pathAssign a default first; add else or default
    Shift register or swap gives wrong valuesBlocking = used inside always_ffUse <= in clocked blocks
    “Multiple drivers” errorOne signal assigned in two blocks or an assign and a blockGive each signal exactly one driver
    A typo in a signal name does nothing visibleThe tool silently created a new one-bit wirePut `default_nettype none at the top of each file
    Sum is missing the top bitResult vector is too narrowMake it one bit wider than the operands
    Works in simulation, fails on the boardDelays or testbench-only constructs in the design; missing constraintsKeep to the synthesizable subset; check pins and clock constraint
    Timing not metA combinational path is longer than the clock periodPipeline it, or lower the clock frequency
    Bitstream stops with DRC errors NSTD-1 or UCIO-1A top-level port has no pin or I/O standard in the .xdc fileAdd PACKAGE_PIN and IOSTANDARD for every port
    Vivado reports a syntax error on logic or always_ffThe file is treated as plain VerilogRename it to .sv, or set its file type to SystemVerilog

    14. Cheat sheet

    Hardware ideaWhat you writeRemember
    Wire or buslogic [N-1:0] x;Always state the width
    Combinational logicassign, always_combUse =; cover every path
    Multiplexer?:, if, caseBoth branches exist in hardware
    Registeralways_ff @(posedge clk)Use <=
    Resetif (rst) q <= '0;One style per project
    Reusable blockmodule with parameterConnect ports by name
    State machineenum + state register + next-state blockDraw the diagram first
    Slower rateClock enable (tick)Avoid generated clocks
    Higher clock speedPipeline registersCosts latency, not throughput
    Check a designTestbench + waveformsDelays and $display are for simulation only