Exercise: VHDL Counter and Testbench
This exercise turns the VHDL rules from the previous lessons into a complete FPGA habit: write a small register-transfer design, simulate it before hardware, and make the testbench decide pass or fail automatically.
Learning Objectives
By the end of this exercise, you should be able to:
- write a synthesizable clocked counter using
numeric_std; - separate design RTL from non-synthesizable testbench code;
- verify synchronous reset, enable hold, increment, wrap, and terminal count;
- generate a VCD waveform for debugging;
- explain common VHDL simulation failures without guessing.
Prerequisites
You should already know:
- VHDL entity, architecture, signal, and port syntax;
std_logic,std_logic_vector, andunsigned;- the difference between combinational and sequential logic;
- how
rising_edge(clk)describes edge-triggered registers; - the basic GHDL flow: analyze, elaborate, and run.
Install GHDL and GTKWave if you want to run the waveform portion locally. The RTL is written for a generic FPGA flow and does not depend on a board constraint file.
Concrete Task
Create two files:
counter4.vhd, a synthesizable 4-bit up-counter;counter4_tb.vhd, a self-checking testbench.
The counter requirements are:
- Clock: state changes only on
rising_edge(clk). - Reset:
rst = '1'synchronously loads zero. - Enable:
en = '1'increments by one. - Hold:
en = '0'keeps the previous count. - Wrap:
1111 + 1becomes0000. - Terminal count:
terminal_count = '1'when count is 15.
Use active-high reset and keep all arithmetic in unsigned. Do not use nonstandard packages such as std_logic_unsigned.
Implementation: counter4.vhd
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity counter4 is
port (
clk : in std_logic;
rst : in std_logic;
en : in std_logic;
count : out std_logic_vector(3 downto 0);
terminal_count : out std_logic
);
end entity counter4;
architecture rtl of counter4 is
signal count_r : unsigned(3 downto 0) := (others => '0');
begin
process(clk)
begin
if rising_edge(clk) then
if rst = '1' then
count_r <= (others => '0');
elsif en = '1' then
count_r <= count_r + 1;
end if;
end if;
end process;
count <= std_logic_vector(count_r);
terminal_count <= '1' when count_r = 15 else '0';
end architecture rtl;
terminal_count is combinational because it directly reflects the current registered count. That is appropriate for this exercise, but in a larger design you may register it if another module needs a timing-friendly one-cycle pulse.
Testbench: counter4_tb.vhd
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity counter4_tb is
end entity counter4_tb;
architecture tb of counter4_tb is
constant CLK_PERIOD : time := 10 ns;
signal clk : std_logic := '0';
signal rst : std_logic := '1';
signal en : std_logic := '0';
signal count : std_logic_vector(3 downto 0);
signal terminal_count : std_logic;
procedure expect_count(
signal observed : in std_logic_vector(3 downto 0);
constant value : in natural;
constant label : in string
) is
begin
assert unsigned(observed) = to_unsigned(value, observed'length)
report label & ": expected " & integer'image(value) &
", got " & integer'image(to_integer(unsigned(observed)))
severity error;
end procedure;
begin
clk <= not clk after CLK_PERIOD / 2;
dut : entity work.counter4
port map (
clk => clk,
rst => rst,
en => en,
count => count,
terminal_count => terminal_count
);
stimulus : process
begin
-- Synchronous reset takes effect on a rising clock edge.
rst <= '1';
en <= '0';
wait until rising_edge(clk);
wait for 1 ns;
expect_count(count, 0, "reset");
rst <= '0';
en <= '1';
for i in 1 to 4 loop
wait until rising_edge(clk);
wait for 1 ns;
expect_count(count, i, "increment " & integer'image(i));
end loop;
en <= '0';
wait until rising_edge(clk);
wait for 1 ns;
expect_count(count, 4, "enable hold");
en <= '1';
for i in 5 to 15 loop
wait until rising_edge(clk);
wait for 1 ns;
expect_count(count, i, "count to " & integer'image(i));
end loop;
assert terminal_count = '1'
report "terminal_count was not high at 15"
severity error;
wait until rising_edge(clk);
wait for 1 ns;
expect_count(count, 0, "wrap");
assert terminal_count = '0'
report "terminal_count stayed high after wrap"
severity error;
assert false report "PASS counter4 testbench complete" severity note;
wait;
end process;
end architecture tb;
The wait for 1 ns statements avoid checking a signal in the same simulator delta cycle as the clock edge. In real testbenches you can also sample on a later phase of the clock.
Run And Inspect
ghdl -a counter4.vhd
ghdl -a counter4_tb.vhd
ghdl -e counter4_tb
ghdl -r counter4_tb --vcd=counter4.vcd
gtkwave counter4.vcd
Expected terminal output includes a note similar to:
counter4_tb.vhd:...: assertion note: PASS counter4 testbench complete
If GHDL reports assertion error, read the first failing message before inspecting the waveform. The first failure is usually closest to the real bug.
Expected Behavior
title "Illustrative counter test sequence"
time start=0 end=90 unit=ns divisions=9
CLK: square label="clk" low=0 high=1 duty=50 cycles=9 unit=logic color=#2563eb
RST: pulse label="rst high" low=0 high=1 at=0 width=10 unit=logic color=#dc2626
EN: step label="en" low=0 high=1 at=12 unit=logic color=#7c3aed
COUNT: sawtooth label="count 0 to wrap" min=0 max=15 cycles=1 unit=count color=#16a34a
TC: pulse label="terminal_count" low=0 high=1 at=78 width=8 unit=logic color=#ea580c
marker RESET at=5 label="reset edge"
marker WRAP at=85 label="wrap"
This waveform is explanatory. Your VCD should show the exact simulator events produced by the code.
Verification Steps
Check all of these before calling the exercise complete:
ghdl -a counter4.vhdsucceeds with no syntax errors.ghdl -a counter4_tb.vhdsucceeds after the design file is analyzed.ghdl -e counter4_tbfinds the testbench entity.ghdl -r counter4_tb --vcd=counter4.vcdends with the PASS note and no assertion errors.countstays0000during reset.countincrements once per rising clock edge whileen = '1'.countholds whileen = '0'.terminal_countis high only whencount = "1111".- The next increment after 15 wraps to 0.
Common Failure Symptoms
no declaration for unsigned: missinguse ieee.numeric_std.all;.unit counter4 not found: testbench analyzed before design or wrong entity name.- Count remains undefined: register lacks reset or initialization in simulation.
- First increment check fails: output sampled before the clocked signal update settled.
- Terminal count never asserts: compared a
std_logic_vectorincorrectly or used the wrong width. - Wrap check fails: counter was converted to an integer with an out-of-range limit.
Debugging Guidance
Start with the first assertion error. Confirm the compile order, then open counter4.vcd and inspect clk, rst, en, count, and terminal_count. If the waveform looks one cycle later than your expectation, review whether the counter is synchronous and whether the testbench waits until after the active clock edge.
For arithmetic bugs, temporarily add reports in the testbench rather than changing the RTL blindly. The design should stay simple: one register process, one output conversion, and one terminal-count comparison.
Extension Challenge
Make the counter width configurable:
entity counter_n is
generic (
WIDTH : positive := 8
);
port (
clk : in std_logic;
rst : in std_logic;
en : in std_logic;
count : out std_logic_vector(WIDTH - 1 downto 0);
terminal_count : out std_logic
);
end entity;
Update the testbench so it runs the same checks for WIDTH = 4 and WIDTH = 8. Explain why the terminal-count value is 2**WIDTH - 1 and why very large widths need careful integer range handling in test code.
Explained Solution
The design stores the counter in an unsigned(3 downto 0) register because unsigned has well-defined addition in numeric_std. The output is converted to std_logic_vector only at the port boundary. Reset is inside the rising_edge(clk) branch, so it is synchronous: asserting rst does not change count_r until the next rising edge.
The testbench first proves reset, then proves four increments, then disables en to prove the hold behavior. It counts up to 15, checks terminal_count, advances one more edge, and verifies wrap to zero. Because every expected value is asserted, the simulation can be used in a regression script instead of relying on manual waveform inspection.
Common Mistakes
- Using
std_logic_unsignedorstd_logic_arithinstead ofnumeric_std. - Expecting synchronous reset to act before a clock edge.
- Checking
countimmediately at the same delta cycle asrising_edge(clk). - Forgetting that a 4-bit
unsignednaturally wraps from 15 to 0. - Writing a waveform-only testbench with no assertions.
- Making
terminal_counthigh after wrap because it was registered at the wrong time.
Summary
A useful FPGA exercise ends with a repeatable check. This counter is small, but it practices the same workflow used for larger blocks: write synthesizable RTL, analyze dependencies in order, run a self-checking testbench, inspect waveforms only when needed, and debug from the first failing requirement.
Next: Packages, numeric_std, and Project Structure.
Further Reading
- GHDL quick start
- GHDL using VCD waveform output
- IEEE numeric_std package overview
- GTKWave documentation