Welcome to Lab 3. In this lab you will design, simulate, and physically implement a Reaction Timer as an Application-Specific Integrated Circuit (ASIC). By the end of this tutorial you will have taken a set of Verilog source files all the way from behavioural simulation to a GDSII layout file -- the same file format that would be sent to a semiconductor foundry for fabrication.
A reaction timer measures the time between a visual stimulus and a human response: the system lights an LED at an unpredictable moment, and the user presses a button as quickly as possible. The elapsed time is displayed on a bank of eight LEDs using a thermometer code, where more illuminated LEDs indicate a slower reaction.
You will follow the complete ASIC design flow:
- RTL Design -- write synthesisable Verilog modules that describe the digital logic.
- Simulation -- verify functional correctness with a testbench using Icarus Verilog.
- Synthesis -- translate RTL into a gate-level netlist of standard cells (Yosys).
- Place & Route -- position cells on silicon and connect them with metal wires (OpenLane 2 / OpenROAD).
- GDSII Generation -- produce the final layout file.
This tutorial assumes no prior experience with ASIC design tools. Every command you need to type is shown explicitly. If something goes wrong, check the Troubleshooting sections and Appendix D at the end.
Before you begin the lab, you need to install four pieces of software on your own computer: Docker Desktop, GTKWave, KLayout, and a text editor. This section walks through the installation for each one on macOS, Windows, and Linux.
Docker is a containerisation platform. We use it to package all of the EDA (Electronic Design Automation) tools -- Icarus Verilog, Yosys, OpenROAD, OpenLane 2, Magic, KLayout -- into a single image that runs identically on every student's machine, regardless of operating system. This eliminates "works on my machine" problems.
What is Docker? Think of a Docker container as a lightweight virtual machine. It contains a complete Linux environment with all the tools pre-installed. When you run a command inside Docker, it executes in that environment but can read and write files in your project directory.
- Go to https://www.docker.com/products/docker-desktop/ in your web browser.
- Click Download for Mac. If your Mac has an Apple Silicon chip (M1, M2, M3, or M4), choose the Apple Silicon variant. If it has an Intel chip, choose Intel Chip. If you are unsure, click the Apple menu in the top-left corner of the screen, select About This Mac, and look at the Chip or Processor line.
- Open the downloaded
.dmgfile. - Drag the Docker icon to the Applications folder.
- Open Docker from the Applications folder. macOS may ask you to confirm that you want to open an application downloaded from the internet -- click Open.
- Docker Desktop will start. You will see the Docker whale icon appear in the menu bar at the top of the screen. Wait until it stops animating (this may take a minute).
- Open a terminal (Applications > Utilities > Terminal, or use Spotlight to search for "Terminal").
- Verify the installation:
docker run hello-worldYou should see output that includes the line:
Hello from Docker!
This message shows that your installation appears to be working correctly.
- Go to https://www.docker.com/products/docker-desktop/ in your web browser.
- Click Download for Windows.
- Run the downloaded installer (
Docker Desktop Installer.exe). - When prompted, ensure the Use WSL 2 instead of Hyper-V option is selected (WSL 2 is recommended).
- Follow the on-screen instructions. You may need to restart your computer.
- After the restart, Docker Desktop should launch automatically. If it does not, find it in the Start menu.
- Wait for Docker Desktop to finish starting (the status icon in the system tray will turn green).
- Open PowerShell or Command Prompt and verify:
docker run hello-worldYou should see the "Hello from Docker!" message.
Note for Windows users: If you encounter a message about WSL 2 not being installed, open PowerShell as Administrator and run:
wsl --installThen restart your computer and try again.
- Open a terminal.
- Remove any old Docker installations:
sudo apt-get remove docker docker-engine docker.io containerd runc- Install prerequisites:
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release- Add Docker's official GPG key:
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg- Set up the repository:
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null- Install Docker Engine:
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin- Add your user to the
dockergroup so you do not needsudofor every Docker command:
sudo usermod -aG docker $USER- Log out and log back in for the group change to take effect.
- Verify:
docker run hello-worldDocker resource recommendations: The OpenLane flow can be memory-intensive. In Docker Desktop settings (macOS/Windows), go to Resources and allocate at least 4 GB of RAM and 10 GB of disk space. On Linux, Docker uses the host resources directly, so no configuration is needed.
GTKWave is a waveform viewer. After simulation, the testbench produces a .vcd (Value Change Dump) file that records every signal transition. GTKWave lets you view these waveforms graphically, which is essential for debugging.
brew install gtkwaveIf you do not have Homebrew installed, visit https://brew.sh and follow the one-line installation command first.
Note: On recent macOS versions, you may need to right-click the GTKWave app the first time you open it and select Open to bypass Gatekeeper security warnings.
sudo apt-get update
sudo apt-get install -y gtkwave- Download GTKWave from https://gtkwave.sourceforge.net.
- Run the installer and follow the prompts.
- Add the installation directory (e.g.,
C:\Program Files\GTKWave\bin) to your system PATH if the installer does not do so automatically.
Verify the installation (all platforms):
gtkwave --versionYou should see a version number (e.g., GTKWave Analyzer v3.3.x).
KLayout is a layout viewer and editor. You will use it to view the GDSII file produced by the OpenLane flow -- this is the physical representation of your chip.
brew install --cask klayoutDownload the appropriate package from https://www.klayout.de/build.html and install it with dpkg:
sudo dpkg -i klayout_*_amd64.deb
sudo apt-get install -f # fix any missing dependencies- Download the Windows installer from https://www.klayout.de/build.html.
- Run the installer and follow the prompts.
Verify the installation (all platforms):
klayout -vYou will need a text editor to read and modify Verilog files. We recommend Visual Studio Code with the Verilog-HDL/SystemVerilog extension, which provides syntax highlighting, error underlining, and module navigation.
- Download VS Code from https://code.visualstudio.com and install it.
- Open VS Code.
- Press
Ctrl+Shift+X(Windows/Linux) orCmd+Shift+X(macOS) to open the Extensions panel. - Search for Verilog-HDL/SystemVerilog/Bluespec SystemVerilog by mshr-h.
- Click Install.
You can use any text editor you are comfortable with (Sublime Text, Vim, Emacs, Notepad++, etc.), but Verilog syntax highlighting is strongly recommended.
Open a terminal and clone the lab repository:
git clone https://github.com/<your-org>/EE2C2-Digital-Lab.git
cd EE2C2-Digital-LabReplace <your-org> with the GitHub organisation or URL provided by your instructor.
After cloning, verify that you can see the project structure:
ls -laYou should see directories named rtl/, tb/, openlane/, lab_handout/, and files including Makefile and Dockerfile.
Take a moment to explore the directory layout:
EE2C2-Digital-Lab/
Dockerfile -- Docker image definition (EDA tools)
Makefile -- Build automation (sim, pnr, gds, etc.)
rtl/
btn_debounce.v -- Button debouncing module
lfsr.v -- Linear Feedback Shift Register
counter.v -- Clock divider + counters
thermometer_encoder.v -- Binary-to-thermometer encoder
fsm.v -- Finite State Machine
reaction_timer_top.v -- Top-level wiring
tb/
tb_reaction_timer.v -- Testbench
openlane/
config.json -- OpenLane 2 configuration
lab_handout/
Reaction_Timer_Lab.md -- This document
All EDA tools (Icarus Verilog, Yosys, OpenROAD, OpenLane 2, Magic) are packaged inside a Docker container. This means you do not need to install any of them individually -- Docker handles everything.
From the root of the repository, run:
make setupWhat happens behind the scenes: This command executes docker build -t ee2c2-digital-lab ., which reads the Dockerfile in the repository root and builds a Docker image. The Dockerfile:
- Starts from the official OpenLane 2 image (
ghcr.io/efabless/openlane2:latest), which already contains Yosys, OpenROAD, Magic, KLayout, and the SkyWater 130 nm PDK. - Installs Icarus Verilog (the Verilog simulator) on top of that image.
- Verifies that
iverilogis accessible. - Downloads the SkyWater 130 nm PDK if it is not already present in the base image.
- Sets
/workas the working directory.
Expected output: You will see Docker downloading layers and installing packages. The first build typically takes 5--15 minutes depending on your internet speed. Subsequent builds use cached layers and complete in seconds.
[+] Building 385.2s (9/9) FINISHED
=> [internal] load build definition from Dockerfile
=> ...
=> exporting to image
=> => naming to docker.io/library/ee2c2-digital-lab
Docker image 'ee2c2-digital-lab' built successfully.
Run 'make sim' to simulate, or 'make all' for the full flow.
Troubleshooting:
| Problem | Cause | Solution |
|---|---|---|
Cannot connect to the Docker daemon |
Docker Desktop is not running | Open Docker Desktop and wait for it to start |
no space left on device |
Disk is full | Docker images can be large. Free at least 10 GB. Run docker system prune to remove unused images |
network timeout or TLS handshake timeout |
Network issue | Check your internet connection. If you are behind a proxy, configure Docker's proxy settings in Docker Desktop > Settings > Resources > Proxies |
permission denied (Linux) |
User not in docker group |
Run sudo usermod -aG docker $USER, then log out and back in |
| Build hangs at PDK download | Large download (~5 GB) | Be patient. If it keeps failing, you may need a faster connection |
After the image is built, verify that the key tools are available inside the container.
Check Icarus Verilog:
docker run --rm ee2c2-digital-lab iverilog -VYou should see version information such as:
Icarus Verilog version 12.0 (stable) ...
Check OpenLane:
docker run --rm ee2c2-digital-lab openlane --versionYou should see a version string like OpenLane 2.x.x.
Check Yosys:
docker run --rm ee2c2-digital-lab yosys --versionExpected output: Yosys 0.x ....
If any of these commands fail, the Docker image did not build correctly. Re-run make setup and check for errors.
The Makefile in the repository root automates every step of the lab. You never need to type long Docker commands manually. Here is what each target does:
make helpOutput:
EE2C2 Digital Lab - Reaction Timer ASIC
Targets:
help Show this help message
setup Build the Docker image (one-time, ~10 min)
sim Compile and run simulation
waves Open waveform viewer (GTKWave)
pnr Run full OpenLane place-and-route flow
gds Open final GDS layout in KLayout
all Run simulation + place-and-route (full flow)
clean Remove all generated files
Detailed target descriptions:
| Target | Command | What It Does |
|---|---|---|
make setup |
docker build -t ee2c2-digital-lab . |
Builds the Docker image containing all EDA tools. Run this once at the start of the lab. |
make sim |
Compiles all RTL + TB with iverilog, then runs with vvp |
Compiles the Verilog design and testbench inside Docker, runs the simulation, and produces a .vcd waveform file. The -DSIMULATION flag is passed automatically. |
make waves |
gtkwave reaction_timer.vcd |
Opens the waveform file in GTKWave. This runs on your host machine (not in Docker), so GTKWave must be installed locally. |
make pnr |
openlane openlane/config.json |
Runs the full OpenLane 2 RTL-to-GDSII flow inside Docker. This includes synthesis, floorplanning, placement, clock tree synthesis, routing, and GDSII generation. Takes 5--15 minutes. |
make gds |
klayout <gds_file> |
Opens the final GDSII layout in KLayout. Runs on your host machine. |
make all |
Runs sim then pnr |
Executes the complete flow: simulation followed by place-and-route. |
make clean |
Removes sim.vvp, .vcd, and runs/ |
Deletes all generated files so you can start fresh. |
How Docker mounting works: The Makefile uses this Docker invocation pattern:
docker run --rm -v $(pwd):/work -w /work ee2c2-digital-lab <command>The -v $(pwd):/work flag mounts your current directory (the repository root) into the container at /work. This means:
- The container can read your Verilog source files.
- Any output files the container produces (like
sim.vvporreaction_timer.vcd) appear in your local directory. - You edit files with your local editor; the container runs the tools.
The --rm flag automatically removes the container after it exits, keeping your system clean.
Before diving into the code, let us understand what the Reaction Timer does and how its digital architecture works.
The Reaction Timer is an electronic game. It works like this:
A complete game, step by step:
-
Power on / Reset. The chip starts in the IDLE state. All LEDs are off. The chip is waiting for the player to begin.
-
Player presses the Start button. The chip moves to the WAIT state. The RGB LED turns blue, telling the player: "Get ready -- the green GO signal is coming, but you do not know exactly when."
-
Random delay. Behind the scenes, the chip counts clock ticks. It keeps counting until it reaches a pseudo-random target value that was generated by the LFSR (Linear Feedback Shift Register). This delay is different every round, so the player cannot predict when the green light will appear. The delay ranges from approximately 10 ms to 2.55 seconds.
-
GO! When the random delay expires, the chip moves to the TEST state. The RGB LED turns green. This is the signal for the player to react as quickly as possible.
-
Player presses the Stop button. The chip records how long the player took to react (measured in tick intervals) and moves to the RESULT state. The RGB LED turns off, and the thermometer LED bar displays the reaction time. Fewer LEDs lit = faster reaction. More LEDs lit = slower reaction. If all 8 LEDs are lit, the reaction counter has saturated at its maximum value of 255 ticks.
-
View result, then play again. The result stays on the display until the player presses Start again, which returns the chip to IDLE and begins a new round.
What if the player cheats? If the player presses the Stop button during the WAIT state (before the green light appears), the chip transitions to the FALSE_START state. The RGB LED turns red, and all eight thermometer LEDs flash on and off to indicate the foul. The player must press Start to acknowledge and return to IDLE.
The design is composed of six Verilog modules. One top-level module instantiates the other five:
reaction_timer_top (top-level wiring)
+-- btn_debounce (u_debounce_start) (debounces the Start button)
+-- btn_debounce (u_debounce_stop) (debounces the Stop button)
+-- lfsr (u_lfsr) (generates pseudo-random delay)
+-- counter (u_counter) (clock divider + wait/react counters)
+-- fsm (u_fsm) (5-state Moore FSM, the "brain")
+-- thermometer_encoder (u_therm) (converts count to LED bar)
Data-flow diagram:
+--------------+
btn_start ----->| btn_debounce |----> start_pulse
+--------------+
+--------------+
btn_stop ----->| btn_debounce |----> stop_pulse
+--------------+
|
+------+ v
| lfsr |--lfsr_val--->+----------+
+------+ | fsm |----> rgb_led[2:0]
+----------+ +--->| |----> control signals
clk ------->| counter |<---+ +----------+
| |--react_count--+
| |--wait_count---+ +-----------------------+
| |--tick---------+--->| thermometer_encoder |---> thermometer_leds[7:0]
+----------+ +-----------------------+
Signal summary:
| Signal | Width | From | To | Purpose |
|---|---|---|---|---|
start_pulse |
1 | btn_debounce |
fsm |
Clean single-cycle pulse when Start is pressed |
stop_pulse |
1 | btn_debounce |
fsm |
Clean single-cycle pulse when Stop is pressed |
lfsr_val |
8 | lfsr |
fsm |
Pseudo-random 8-bit value for wait duration |
lfsr_en |
1 | fsm |
lfsr |
Enable signal: LFSR advances while in IDLE |
counter_en |
1 | fsm |
counter |
Enable reaction counter (in TEST state) |
counter_clr |
1 | fsm |
counter |
Clear all counters (in IDLE state) |
wait_en |
1 | fsm |
counter |
Enable wait counter (in WAIT state) |
tick |
1 | counter |
fsm, thermometer_encoder |
Divided clock pulse |
react_count |
8 | counter |
thermometer_encoder |
Reaction time measurement |
wait_count |
8 | counter |
fsm |
Elapsed wait ticks |
result_valid |
1 | fsm |
thermometer_encoder |
Show thermometer code |
false_start |
1 | fsm |
thermometer_encoder |
Flash all LEDs |
flash_toggle |
1 | fsm |
thermometer_encoder |
Toggle for flashing pattern |
rgb_led |
3 | fsm |
top-level output | RGB status LED |
What is an LFSR? An LFSR is a shift register whose input bit is computed by XOR-ing selected bits (called taps) from the current register value. This creates a sequence of values that appears random but is entirely deterministic -- given the same starting value (seed), it always produces the same sequence. LFSRs are widely used in digital circuits because they produce "good enough" randomness using very little hardware (just a shift register and a few XOR gates).
Why do we need one? The reaction timer needs an unpredictable delay so the player cannot memorise the timing. The LFSR generates a different 8-bit target value each round. Since the LFSR advances on every clock cycle while the player is in IDLE (before pressing Start), the actual delay depends on exactly when the player presses the button, which introduces genuine unpredictability from the player's perspective.
The feedback polynomial: This design uses the polynomial:
x^8 + x^6 + x^5 + x^4 + 1
This polynomial is maximal-length for 8 bits, meaning the LFSR cycles through all 255 non-zero 8-bit states before repeating (the all-zeros state is excluded because XOR of zeros is always zero, which would lock the register).
The feedback equation in Verilog:
wire feedback = lfsr_reg[7] ^ lfsr_reg[5] ^ lfsr_reg[4] ^ lfsr_reg[3];The taps at bits 7, 5, 4, and 3 correspond to the polynomial terms x^8, x^6, x^5, and x^4 (bit indices are zero-based, so bit 7 corresponds to x^8, bit 5 to x^6, etc.).
Worked example from the seed 8'hA5:
Let us trace the first five states of the LFSR starting from the seed value 8'hA5 = 1010_0101:
| Step | lfsr_reg[7:0] |
[7] |
[5] |
[4] |
[3] |
Feedback ([7]^[5]^[4]^[3]) |
Next State |
|---|---|---|---|---|---|---|---|
| 0 (reset) | 1010_0101 |
1 | 0 | 0 | 0 | 1^0^0^0 = 1 | 0100_1011 |
| 1 | 0100_1011 |
0 | 0 | 1 | 1 | 0^0^1^1 = 0 | 1001_0110 |
| 2 | 1001_0110 |
1 | 1 | 0 | 1 | 1^1^0^1 = 1 | 0010_1101 |
| 3 | 0010_1101 |
0 | 1 | 1 | 1 | 0^1^1^1 = 1 | 0101_1011 |
| 4 | 0101_1011 |
0 | 1 | 1 | 0 | 0^1^1^0 = 0 | 1011_0110 |
Each step shifts the register left by one position and inserts the feedback bit at position 0. The sequence is: A5 -> 4B -> 96 -> 2D -> 5B -> B6 -> ... and continues through all 255 non-zero states before returning to A5.
The problem: The system clock runs at 50 MHz, which means one clock cycle is 20 nanoseconds. Human reaction times are typically 150--400 milliseconds. If we incremented the reaction counter on every clock cycle, the counter would overflow from 0 to 255 in just 5.1 microseconds -- far too fast to measure human reactions.
The solution: A clock divider generates a slower tick signal from the fast system clock. The counter module increments only on tick pulses, not on every clock edge.
The maths: With CLK_DIV = 500,000:
tick period = CLK_DIV x clock period
= 500,000 x 20 ns
= 10,000,000 ns
= 10 ms
With an 8-bit reaction counter (range 0--255) and a 10 ms tick:
Maximum measurable time = 255 x 10 ms = 2.55 seconds
Resolution = 10 ms per tick
This is well suited for human reaction times.
The simulation problem: In simulation, we do not want to wait 500,000 clock cycles per tick -- that would make the testbench run for millions of cycles and take too long. The design uses conditional compilation to select a fast divider for simulation:
`ifdef SIMULATION
`define DEFAULT_CLK_DIV 4
`define DEFAULT_DEBOUNCE_CYC 4
`else
`define DEFAULT_CLK_DIV 500_000
`define DEFAULT_DEBOUNCE_CYC 50_000
`endifWhen you compile with the -DSIMULATION flag (which the Makefile does automatically), CLK_DIV is set to 4, so each tick is only 4 clock cycles. The debounce counter is similarly shortened from 50,000 to 4 cycles.
Moore vs. Mealy machines: There are two classical types of FSM:
- A Mealy machine produces outputs that depend on both the current state and the current inputs. Outputs can change as soon as an input changes (within the same clock cycle).
- A Moore machine produces outputs that depend only on the current state. Outputs change only when the state changes (on the next clock edge after a transition).
This design uses a Moore machine. The advantage is that outputs are stable for the entire duration of a state and do not glitch in response to input changes. This makes the design more predictable and easier to debug.
The five states:
| State | Encoding | RGB LED | Description |
|---|---|---|---|
S_IDLE |
3'b000 |
OFF (black) | Waiting for the player to press Start. Counters are cleared. LFSR is free-running. |
S_WAIT |
3'b001 |
Blue | Random delay in progress. The wait counter is incrementing. The player should NOT press Stop yet. |
S_TEST |
3'b010 |
Green | GO! The reaction counter is incrementing. The player should press Stop as fast as possible. |
S_RESULT |
3'b011 |
OFF (black) | Displaying the result on the thermometer LEDs. Waiting for Start to play again. |
S_FALSE_START |
3'b100 |
Red | The player pressed Stop during WAIT. All thermometer LEDs flash. Waiting for Start to acknowledge. |
State transitions:
btn_start
+------[pressed]------+
| |
v |
+--------+ +---------+
+--->| IDLE | | RESULT |
| | LED=OFF| | LED=OFF |
| +--------+ +---------+
| | ^
| | btn_start | btn_stop
| v |
| +--------+ +--------+
| | WAIT |---------->| TEST |
| | LED=BLU| wait >= | LED=GRN|
| +--------+ lfsr_val +--------+
| |
| | btn_stop (too early!)
| v
| +-------------+
+----|FALSE_START |
| LED=RED |
btn_start
+-------------+
Transition conditions explained:
-
IDLE -> WAIT: The debounced Start button produces a pulse. The FSM latches the current LFSR value as the delay target and begins the WAIT state.
-
WAIT -> TEST: The wait counter has reached or exceeded the LFSR value (
wait_count >= lfsr_val), AND the LFSR value is not zero (lfsr_val != 8'd0). The non-zero guard prevents an instant transition in the rare case that the LFSR outputs zero. The green LED lights up -- react now! -
WAIT -> FALSE_START: The Stop button was pressed before the green light appeared. This is a foul. The red LED lights up and the thermometer LEDs flash.
-
TEST -> RESULT: The Stop button was pressed while the green LED is on. The player has reacted! The reaction count is frozen and displayed on the thermometer LEDs.
-
RESULT -> IDLE: The Start button is pressed again. The chip returns to IDLE for a new round.
-
FALSE_START -> IDLE: The Start button is pressed to acknowledge the false start and return to IDLE.
Output signals by state:
| State | rgb_led |
counter_clr |
counter_en |
wait_en |
lfsr_en |
result_valid |
false_start |
|---|---|---|---|---|---|---|---|
| IDLE | 000 (OFF) |
1 | 0 | 0 | 1 | 0 | 0 |
| WAIT | 001 (Blue) |
0 | 0 | 1 | 0 | 0 | 0 |
| TEST | 010 (Green) |
0 | 1 | 0 | 0 | 0 | 0 |
| RESULT | 000 (OFF) |
0 | 0 | 0 | 0 | 1 | 0 |
| FALSE_START | 100 (Red) |
0 | 0 | 0 | 0 | 0 | 1 |
Notice the Moore property: each row of outputs depends only on the state, not on any input signals.
The Counter module contains three functional blocks sharing a single clock domain:
-
Clock divider (
div_count): A free-running counter from 0 toCLK_DIV - 1. When it wraps, it produces a single-cycletickpulse. Thistickis the "slow clock" that drives the wait and reaction counters. -
Wait counter (
wait_count, 8 bits): Increments on eachtickwhilewait_enis asserted (during the WAIT state). Cleared by theclearsignal (during IDLE). Used to time the random delay. -
Reaction counter (
react_count, 8 bits): Increments on eachtickwhileenableis asserted (during the TEST state). Cleared byclear. Saturates at 255 -- once it reaches 255, it stops incrementing to prevent wrap-around, which would incorrectly show a fast reaction for a very slow player.
The Thermometer Encoder converts the 8-bit binary reaction count into a visual LED bar display. The name "thermometer code" comes from the pattern: like mercury in a thermometer, LEDs light up from the bottom, and more lit LEDs means a higher (slower) value.
react_count Range |
therm_out (binary) |
LEDs Lit | Interpretation |
|---|---|---|---|
| 0 | 00000000 |
0 | No reaction recorded |
| 1 -- 31 | 00000001 |
1 | Excellent (< 310 ms) |
| 32 -- 63 | 00000011 |
2 | Very good |
| 64 -- 95 | 00000111 |
3 | Good |
| 96 -- 127 | 00001111 |
4 | Average |
| 128 -- 159 | 00011111 |
5 | Below average |
| 160 -- 191 | 00111111 |
6 | Slow |
| 192 -- 223 | 01111111 |
7 | Very slow |
| 224 -- 255 | 11111111 |
8 | Saturated / timeout |
Visualising the LED bar (physical layout):
LED bar for react_count = 100 (4 LEDs lit):
[7] [6] [5] [4] [3] [2] [1] [0]
O O O O * * * *
^----^----^----^-- lit
Output gating: The thermometer output is not always shown:
- In RESULT state (
result_valid = 1): the thermometer code is displayed. - In FALSE_START state (
false_start = 1): all eight LEDs flash on and off, toggled byflash_toggle. - In all other states: all LEDs are off.
Why do buttons bounce? When you press a mechanical push-button, the metal contacts do not make a clean, single connection. Instead, they physically bounce -- making and breaking contact rapidly for 1--10 milliseconds before settling. Without filtering, a single button press could generate dozens of false edges that the FSM would interpret as multiple button presses.
The two-flip-flop synchroniser: There is a second problem. The button is an asynchronous input -- the player can press it at any time, with no relationship to the clock edge. If the button's electrical transition happens during the clock's setup or hold time window, the flip-flop can enter a metastable state (an output voltage that is neither a valid 0 nor a valid 1). Metastability can propagate through the design and cause unpredictable behaviour.
The standard solution is a two-flip-flop synchroniser: the button signal passes through two back-to-back flip-flops (sync_0 and sync_1). The first flip-flop may go metastable, but it has an entire clock period to settle before the second flip-flop samples it. This reduces the probability of metastability reaching the rest of the design to a negligible level.
The debounce counter: After synchronisation, the signal enters a counter-based filter. The counter increments while the synchronised signal differs from the currently accepted stable value. Only when the new value has been stable for DEBOUNCE_CYCLES consecutive clock cycles is it accepted. This filters out the rapid on-off bouncing.
Rising-edge detection: The module produces a single-cycle output pulse on the rising edge of the debounced signal:
btn_out <= btn_stable & ~btn_prev;This ensures that holding a button down produces exactly one pulse, not a continuous high. The FSM therefore sees one clean event per button press.
Signal flow through the debouncer:
btn_in -> [sync_0] -> [sync_1] -> [debounce counter] -> btn_stable -> [edge detector] -> btn_out
(FF 1) (FF 2) (filter bouncing) (single pulse)
This section walks through every line of the six Verilog source files and the testbench. For each file, the complete source code is shown, followed by detailed explanations of each section. If you are new to Verilog, read this section carefully -- key concepts are explained as they appear.
| File | Lines | Module | Purpose |
|---|---|---|---|
rtl/btn_debounce.v |
46 | btn_debounce |
2-FF synchroniser + counter debounce + edge detect |
rtl/lfsr.v |
20 | lfsr |
8-bit maximal-length LFSR for pseudo-random delays |
rtl/counter.v |
48 | counter |
Clock divider + wait counter + reaction counter |
rtl/thermometer_encoder.v |
39 | thermometer_encoder |
Binary-to-thermometer conversion + output gating |
rtl/fsm.v |
105 | fsm |
5-state Moore FSM controller |
rtl/reaction_timer_top.v |
103 | reaction_timer_top |
Top-level module that wires everything together |
tb/tb_reaction_timer.v |
193 | tb_reaction_timer |
Testbench with three test scenarios |
File: rtl/btn_debounce.v
module btn_debounce #(
parameter DEBOUNCE_CYCLES = 50_000
)(
input wire clk,
input wire rst_n,
input wire btn_in,
output reg btn_out
);What this does: This is the module declaration. The #(parameter ...) syntax defines a parameter that can be overridden when the module is instantiated. DEBOUNCE_CYCLES defaults to 50,000 (which at 50 MHz gives a 1 ms debounce window), but the top-level module passes a different value for simulation. The ports are:
clk-- the system clock.rst_n-- active-low reset (the_nsuffix is a naming convention meaning "active low").btn_in-- the raw, bouncy button signal.btn_out-- the clean, debounced, single-cycle output pulse.
The keyword wire after input means the signal is a simple connection. The keyword reg after output means the signal is driven by a procedural block (an always block). In modern Verilog, reg does not necessarily mean a physical register -- it simply means "assigned inside a procedural block."
reg sync_0, sync_1;
always @(posedge clk) begin
if (!rst_n) begin
sync_0 <= 1'b0;
sync_1 <= 1'b0;
end else begin
sync_0 <= btn_in;
sync_1 <= sync_0;
end
endWhat this does: This is the two-flip-flop synchroniser. On every rising edge of the clock:
- If reset is active (
!rst_n), both flip-flops are cleared to 0. - Otherwise,
sync_0samples the raw button input, andsync_1samplessync_0.
The <= operator is the non-blocking assignment, which is the correct assignment type for sequential logic. Non-blocking assignments evaluate simultaneously at the end of the time step, so sync_1 gets the old value of sync_0, not the new one that was just assigned. This is what creates the two-stage pipeline.
After two clock cycles, any metastability from the asynchronous btn_in has had time to resolve. The signal at sync_1 is safe to use in the rest of the synchronous design.
reg [$clog2(DEBOUNCE_CYCLES)-1:0] count;
reg btn_stable;
reg btn_prev;What this does: These are the internal registers for the debounce logic:
count-- the debounce counter. Its width is calculated automatically using$clog2(DEBOUNCE_CYCLES), which gives the number of bits needed to represent the valueDEBOUNCE_CYCLES. For example,$clog2(50000)= 16, so the counter is 16 bits wide.btn_stable-- the current accepted (debounced) button state.btn_prev-- the previous value ofbtn_stable, used for edge detection.
always @(posedge clk) begin
if (!rst_n) begin
count <= 0;
btn_stable <= 1'b0;
btn_prev <= 1'b0;
btn_out <= 1'b0;
end else begin
btn_prev <= btn_stable;
if (sync_1 != btn_stable) begin
if (count == DEBOUNCE_CYCLES - 1) begin
btn_stable <= sync_1;
count <= 0;
end else begin
count <= count + 1;
end
end else begin
count <= 0;
end
btn_out <= btn_stable & ~btn_prev;
end
end
endmoduleWhat this does: This is the debounce counter and edge detector, all in one clocked block.
Reset logic (lines with !rst_n): When reset is active, everything is cleared to zero.
Debounce counter (the if (sync_1 != btn_stable) block):
- If the synchronised input (
sync_1) differs from the currently accepted state (btn_stable), the counter starts incrementing. - If the counter reaches
DEBOUNCE_CYCLES - 1, the new value has been stable long enough and is accepted:btn_stabletakes on the new value and the counter resets. - If
sync_1matchesbtn_stable(no change), the counter resets to zero. This means any brief bounce resets the count, so only a sustained change is accepted.
Edge detection (btn_out <= btn_stable & ~btn_prev):
btn_previs loaded with the previous value ofbtn_stableat the beginning of each clock cycle.- The expression
btn_stable & ~btn_previs 1 only on the exact cycle whenbtn_stabletransitions from 0 to 1 (a rising edge). - This produces a single-cycle pulse for each button press.
File: rtl/lfsr.v
module lfsr #(
parameter SEED = 8'hA5
)(
input wire clk,
input wire rst_n,
input wire enable,
output wire [7:0] lfsr_out
);
reg [7:0] lfsr_reg;
wire feedback = lfsr_reg[7] ^ lfsr_reg[5] ^ lfsr_reg[4] ^ lfsr_reg[3];
always @(posedge clk) begin
if (!rst_n)
lfsr_reg <= SEED;
else if (enable)
lfsr_reg <= {lfsr_reg[6:0], feedback};
end
assign lfsr_out = lfsr_reg;
endmoduleWhat this does, section by section:
Module declaration: The module has a parameter SEED that defaults to 8'hA5 (hexadecimal A5, which is binary 10100101). This is the initial value loaded into the register on reset. Ports: clk, rst_n, enable (advance the LFSR only when enabled), and lfsr_out (the current 8-bit value).
Feedback calculation:
wire feedback = lfsr_reg[7] ^ lfsr_reg[5] ^ lfsr_reg[4] ^ lfsr_reg[3];This is a continuous assignment (using wire and =). It computes the XOR of bits 7, 5, 4, and 3 of the current register state. This value updates immediately whenever lfsr_reg changes. The ^ operator is bitwise XOR.
Shift register logic:
always @(posedge clk) begin
if (!rst_n)
lfsr_reg <= SEED;
else if (enable)
lfsr_reg <= {lfsr_reg[6:0], feedback};
endOn each rising clock edge:
- Reset: Load the seed value.
- Enabled: Shift the register left by one position. The
{lfsr_reg[6:0], feedback}syntax is concatenation: it takes bits 6 down to 0 of the register (the upper 7 bits, shifted left) and appends thefeedbackbit at the bottom. This is exactly a left shift with the feedback bit entering at position 0. - Not enabled: The register holds its current value.
Output:
assign lfsr_out = lfsr_reg;The output port is simply wired to the register. This is a continuous assignment.
Key Verilog concept -- wire vs reg: Notice that lfsr_out is declared as output wire and is driven by an assign statement. The lfsr_reg internal signal is declared as reg because it is assigned inside an always block. Both synthesise to flip-flops in this case, but Verilog's syntax requires the distinction.
File: rtl/counter.v
module counter #(
parameter CLK_DIV = 500_000
)(
input wire clk,
input wire rst_n,
input wire clear,
input wire enable,
input wire wait_en,
output reg [7:0] react_count,
output wire tick,
output reg [7:0] wait_count
);What this does: The module takes a clock, reset, clear signal, two enable signals (enable for the reaction counter, wait_en for the wait counter), and produces three outputs: the 8-bit react_count, the tick pulse, and the 8-bit wait_count. The CLK_DIV parameter controls the divider ratio.
reg [$clog2(CLK_DIV)-1:0] div_count;
reg tick_reg;
always @(posedge clk) begin
if (!rst_n) begin
div_count <= 0;
tick_reg <= 1'b0;
end else begin
if (div_count == CLK_DIV - 1) begin
div_count <= 0;
tick_reg <= 1'b1;
end else begin
div_count <= div_count + 1;
tick_reg <= 1'b0;
end
end
end
assign tick = tick_reg;What this does: This is the clock divider. It is a free-running counter that:
- Counts up from 0 on every clock cycle.
- When it reaches
CLK_DIV - 1, it wraps back to 0 and produces a single-cycletickpulse (tick_reg = 1for exactly one clock cycle). - On all other cycles,
tick_regis 0.
The result is that tick pulses once every CLK_DIV clock cycles. With CLK_DIV = 500,000 at 50 MHz, tick pulses every 10 ms. With CLK_DIV = 4 in simulation, tick pulses every 4 clock cycles.
The $clog2(CLK_DIV) function computes the bit width needed for the counter. For CLK_DIV = 500,000, this gives 19 bits (since 2^19 = 524,288 > 500,000).
always @(posedge clk) begin
if (!rst_n || clear)
wait_count <= 8'd0;
else if (wait_en && tick)
wait_count <= wait_count + 1;
endWhat this does: The wait counter increments by 1 on each tick pulse, but only while wait_en is asserted (WAIT state). It is cleared to zero on reset OR when the clear signal is high (IDLE state). There is no saturation check here because the LFSR value is at most 255, so the wait counter will match it before overflow could be an issue.
always @(posedge clk) begin
if (!rst_n || clear)
react_count <= 8'd0;
else if (enable && tick) begin
if (react_count < 8'd255)
react_count <= react_count + 1;
end
end
endmoduleWhat this does: The reaction counter is similar to the wait counter, but with one important difference: saturation. The if (react_count < 8'd255) guard prevents the counter from wrapping around from 255 to 0. Without this guard, a very slow player might see the counter wrap around and get a misleadingly low (fast-looking) result.
File: rtl/thermometer_encoder.v
module thermometer_encoder (
input wire [7:0] binary_in,
input wire false_start,
input wire flash_toggle,
input wire result_valid,
output reg [7:0] therm_out
);What this does: This is a purely combinational module (no clock input). It takes the 8-bit binary reaction count and converts it to an 8-bit thermometer code. It also handles the output gating logic: displaying the result, flashing during false starts, or showing nothing.
reg [7:0] therm_encoded;
always @(*) begin
if (binary_in == 8'd0)
therm_encoded = 8'b0000_0000;
else if (binary_in <= 8'd31)
therm_encoded = 8'b0000_0001;
else if (binary_in <= 8'd63)
therm_encoded = 8'b0000_0011;
else if (binary_in <= 8'd95)
therm_encoded = 8'b0000_0111;
else if (binary_in <= 8'd127)
therm_encoded = 8'b0000_1111;
else if (binary_in <= 8'd159)
therm_encoded = 8'b0001_1111;
else if (binary_in <= 8'd191)
therm_encoded = 8'b0011_1111;
else if (binary_in <= 8'd223)
therm_encoded = 8'b0111_1111;
else
therm_encoded = 8'b1111_1111;
endWhat this does: This is the thermometer encoding logic. The always @(*) sensitivity list means this block re-evaluates whenever any input changes (this is combinational logic, not sequential). The = operator (blocking assignment) is used instead of <= because this is combinational, not sequential.
The priority-encoded if-else chain divides the 0--255 range into 9 bins (0, 1--31, 32--63, ..., 224--255). Each bin maps to a thermometer code where the number of 1-bits increases from the right. The pattern of 1s in the binary literal makes the thermometer nature visually clear: 0000_0001, 0000_0011, 0000_0111, etc.
Key Verilog concept -- always @(*) and blocking assignments: In combinational logic, you must use always @(*) (which automatically includes all read signals in the sensitivity list) and blocking assignments (=). In sequential logic (clocked), you use always @(posedge clk) and non-blocking assignments (<=). Mixing these up is a very common source of simulation/synthesis mismatches.
always @(*) begin
if (false_start)
therm_out = flash_toggle ? 8'hFF : 8'h00;
else if (result_valid)
therm_out = therm_encoded;
else
therm_out = 8'h00;
end
endmoduleWhat this does: This is the output multiplexer that selects what to show on the LEDs:
- False start: If
false_startis asserted, the LEDs flash.flash_togglealternates between 0 and 1 on each tick (generated by the FSM), so the output alternates between all-on (8'hFF) and all-off (8'h00). - Result valid: If
result_validis asserted (RESULT state), the thermometer-encoded value is displayed. - Otherwise: All LEDs are off. This covers IDLE, WAIT, and TEST states.
The ? : is the conditional (ternary) operator -- condition ? value_if_true : value_if_false.
File: rtl/fsm.v
module fsm (
input wire clk,
input wire rst_n,
input wire btn_start,
input wire btn_stop,
input wire [7:0] wait_count,
input wire [7:0] lfsr_val,
input wire tick,
output reg [2:0] rgb_led,
output reg counter_en,
output reg counter_clr,
output reg wait_en,
output reg lfsr_en,
output reg result_valid,
output reg false_start,
output reg flash_toggle
);What this does: The FSM is the largest module and serves as the controller -- the "brain" of the design. It receives button pulses and counter status, and drives control signals back to all the other modules. The outputs are all reg because they are assigned in always blocks.
localparam [2:0] S_IDLE = 3'b000,
S_WAIT = 3'b001,
S_TEST = 3'b010,
S_RESULT = 3'b011,
S_FALSE_START = 3'b100;
reg [2:0] state, next_state;What this does: The localparam keyword defines named constants that cannot be overridden (unlike parameter). Each state is assigned a unique 3-bit binary encoding. Using named constants instead of raw numbers makes the code much more readable -- you see S_IDLE instead of 3'b000.
Two registers are declared: state (the current state, updated on clock edges) and next_state (the next state, computed combinationally).
always @(posedge clk) begin
if (!rst_n)
state <= S_IDLE;
else
state <= next_state;
endWhat this does: This is the state register. On every rising clock edge:
- If reset is active, go to IDLE.
- Otherwise, advance to whatever
next_statehas been computed.
This is the standard pattern for an FSM: a clocked block for the state register and a combinational block for the next-state logic.
always @(*) begin
next_state = state;
case (state)
S_IDLE: begin
if (btn_start)
next_state = S_WAIT;
end
S_WAIT: begin
if (btn_stop)
next_state = S_FALSE_START;
else if (wait_count >= lfsr_val && lfsr_val != 8'd0)
next_state = S_TEST;
end
S_TEST: begin
if (btn_stop)
next_state = S_RESULT;
end
S_RESULT: begin
if (btn_start)
next_state = S_IDLE;
end
S_FALSE_START: begin
if (btn_start)
next_state = S_IDLE;
end
default: next_state = S_IDLE;
endcase
endWhat this does: This is the next-state logic, implemented as a combinational block. It is written as a case statement that checks the current state and input conditions to determine the next state.
Key points:
- The default assignment
next_state = stateat the top means that if none of the conditions in thecaseare met, the FSM stays in its current state. This prevents accidental latches. - In
S_WAIT, thebtn_stopcheck comes before thewait_count >= lfsr_valcheck. This givesbtn_stophigher priority: if both conditions are true in the same cycle, the FSM goes to FALSE_START, not TEST. This is a deliberate design decision. - The
lfsr_val != 8'd0guard in the WAIT-to-TEST transition prevents an immediate transition if the LFSR happens to output zero. - The
defaultcase sends the FSM to IDLE if it somehow enters an undefined state (defensive coding).
always @(*) begin
rgb_led = 3'b000;
counter_en = 1'b0;
counter_clr = 1'b0;
wait_en = 1'b0;
lfsr_en = 1'b0;
result_valid = 1'b0;
false_start = 1'b0;
case (state)
S_IDLE: begin
rgb_led = 3'b000;
counter_clr = 1'b1;
lfsr_en = 1'b1;
end
S_WAIT: begin
rgb_led = 3'b001;
wait_en = 1'b1;
end
S_TEST: begin
rgb_led = 3'b010;
counter_en = 1'b1;
end
S_RESULT: begin
rgb_led = 3'b000;
result_valid = 1'b1;
end
S_FALSE_START: begin
rgb_led = 3'b100;
false_start = 1'b1;
end
default: rgb_led = 3'b000;
endcase
endWhat this does: This is the output logic. It is a combinational block that sets all output signals based purely on the current state (Moore machine).
The pattern of setting all outputs to zero at the top, then overriding specific outputs in each state, is a clean style that ensures:
- No signal is accidentally left undriven (which would create a latch).
- Only the outputs that differ from the defaults need to be listed in each state.
State-by-state explanation:
- IDLE: LED off; clear the counters (
counter_clr = 1); let the LFSR free-run (lfsr_en = 1). - WAIT: LED blue (
001); enable the wait counter (wait_en = 1). - TEST: LED green (
010); enable the reaction counter (counter_en = 1). - RESULT: LED off; display the result (
result_valid = 1). - FALSE_START: LED red (
100); signal false start (false_start = 1).
always @(posedge clk) begin
if (!rst_n)
flash_toggle <= 1'b0;
else if (false_start && tick)
flash_toggle <= ~flash_toggle;
else if (!false_start)
flash_toggle <= 1'b0;
end
endmoduleWhat this does: This is a separate sequential block that generates the flash_toggle signal for the false-start LED flashing effect:
- On reset: clear to 0.
- While
false_startis active and atickpulse occurs: toggle the value (0 becomes 1, 1 becomes 0). This creates a flashing effect at the tick rate (10 ms in hardware, 4 clocks in simulation). - When
false_startis not active: hold at 0 (so the flash starts from a known state next time).
File: rtl/reaction_timer_top.v
// rtl/reaction_timer_top.v
// Top-level module for Reaction Timer ASIC
`ifdef SIMULATION
`define DEFAULT_CLK_DIV 4
`define DEFAULT_DEBOUNCE_CYC 4
`else
`define DEFAULT_CLK_DIV 500_000
`define DEFAULT_DEBOUNCE_CYC 50_000
`endifWhat this does: These are preprocessor directives, similar to #ifdef in C. When the -DSIMULATION flag is passed to the compiler, the SIMULATION macro is defined, and the ifdef block selects the small values (4 clock cycles each). Without the flag, the large values for real hardware are used.
The backtick (`) prefix is Verilog's preprocessor syntax. `define creates a macro, `ifdef tests whether a macro is defined.
module reaction_timer_top #(
parameter CLK_DIV = `DEFAULT_CLK_DIV,
parameter DEBOUNCE_CYC = `DEFAULT_DEBOUNCE_CYC,
parameter LFSR_SEED = 8'hA5
)(
input wire clk,
input wire rst_n,
input wire btn_start,
input wire btn_stop,
output wire [2:0] rgb_led,
output wire [7:0] thermometer_leds
);What this does: The top-level module declares three parameters that can be overridden: the clock divider ratio, the debounce cycle count, and the LFSR seed. The port list matches the hardware interface described at the beginning of this document. All outputs are wire because they are driven by sub-module instances, not by always blocks in this module.
wire start_pulse, stop_pulse;
wire [7:0] lfsr_val;
wire [7:0] react_count;
wire [7:0] wait_count;
wire tick;
wire counter_en, counter_clr, wait_en;
wire lfsr_en;
wire result_valid, false_start, flash_toggle;What this does: These are the internal wires that connect the sub-modules together. They are not visible from outside the top-level module. Think of them as the wires on a circuit board connecting one chip to another.
btn_debounce #(
.DEBOUNCE_CYCLES(DEBOUNCE_CYC)
) u_debounce_start (
.clk (clk),
.rst_n (rst_n),
.btn_in (btn_start),
.btn_out(start_pulse)
);
btn_debounce #(
.DEBOUNCE_CYCLES(DEBOUNCE_CYC)
) u_debounce_stop (
.clk (clk),
.rst_n (rst_n),
.btn_in (btn_stop),
.btn_out(stop_pulse)
);What this does: Two instances of btn_debounce are created -- one for each button. The #(.DEBOUNCE_CYCLES(...)) syntax overrides the parameter. The .port_name(signal_name) syntax is named port connection: each port of the sub-module is connected to a specific signal in the parent module. The instance names u_debounce_start and u_debounce_stop are used to refer to each instance in simulation (e.g., in GTKWave's hierarchy browser).
lfsr #(
.SEED(LFSR_SEED)
) u_lfsr (
.clk (clk),
.rst_n (rst_n),
.enable (lfsr_en),
.lfsr_out(lfsr_val)
);
counter #(
.CLK_DIV(CLK_DIV)
) u_counter (
.clk (clk),
.rst_n (rst_n),
.clear (counter_clr),
.enable (counter_en),
.wait_en (wait_en),
.react_count(react_count),
.tick (tick),
.wait_count (wait_count)
);What this does: The LFSR and counter are instantiated with their respective parameters. Notice how the FSM's output signals (lfsr_en, counter_clr, counter_en, wait_en) connect to the control inputs of the LFSR and counter. The data outputs (lfsr_val, react_count, wait_count, tick) flow back to the FSM and the thermometer encoder.
fsm u_fsm (
.clk (clk),
.rst_n (rst_n),
.btn_start (start_pulse),
.btn_stop (stop_pulse),
.wait_count (wait_count),
.lfsr_val (lfsr_val),
.tick (tick),
.rgb_led (rgb_led),
.counter_en (counter_en),
.counter_clr (counter_clr),
.wait_en (wait_en),
.lfsr_en (lfsr_en),
.result_valid(result_valid),
.false_start (false_start),
.flash_toggle(flash_toggle)
);
thermometer_encoder u_therm (
.binary_in (react_count),
.false_start (false_start),
.flash_toggle(flash_toggle),
.result_valid(result_valid),
.therm_out (thermometer_leds)
);What this does: The FSM and thermometer encoder are instantiated. The FSM has no parameter override (it has none defined). Notice that rgb_led connects directly to the top-level output port -- the FSM drives the RGB LED directly. Similarly, thermometer_leds is driven by the thermometer encoder's therm_out port.
endmodule
`undef DEFAULT_CLK_DIV
`undef DEFAULT_DEBOUNCE_CYCWhat this does: The `undef directives remove the preprocessor macros to avoid polluting the macro namespace for any files compiled after this one. This is good hygiene.
Simulation is how you verify that your design works correctly before committing to silicon. You will compile the design and testbench, run the simulation, and examine the output both as text and as waveforms.
From the repository root, run:
make simWhat happens behind the scenes:
-
Compilation: The Makefile runs:
docker run --rm -v $(pwd):/work -w /work ee2c2-digital-lab \ iverilog -Wall -DSIMULATION -o tb/sim.vvp \ rtl/btn_debounce.v rtl/lfsr.v rtl/counter.v \ rtl/thermometer_encoder.v rtl/fsm.v rtl/reaction_timer_top.v \ tb/tb_reaction_timer.vThis compiles all six RTL files and the testbench into a simulation executable (
tb/sim.vvp). The-DSIMULATIONflag activates the fast clock divider. The-Wallflag enables all warnings. -
Execution: The Makefile then runs:
docker run --rm -v $(pwd):/work -w /work ee2c2-digital-lab \ vvp tb/sim.vvpThis executes the compiled simulation. The testbench drives inputs, checks outputs, and prints results.
Complete expected output:
=== Compiling with Icarus Verilog ===
=== Running Simulation ===
========================================
Reaction Timer Testbench
========================================
--- Test 1: Normal Reaction ---
[PASS] IDLE: rgb_led is OFF
[PASS] IDLE: thermometer_leds is 0
[PASS] WAIT: rgb_led is Blue
[PASS] TEST: rgb_led is Green
[PASS] RESULT: rgb_led is OFF
[PASS] RESULT: thermometer_leds is non-zero
Thermometer value: 00000001 (1)
--- Test 2: False Start ---
[PASS] IDLE: rgb_led is OFF (reset)
[PASS] WAIT: rgb_led is Blue
[PASS] FALSE_START: rgb_led is Red
Thermometer during false start: 11111111
--- Test 3: Slow Reaction (Saturation) ---
[PASS] IDLE: rgb_led is OFF (reset)
[PASS] TEST: rgb_led is Green
[PASS] RESULT (slow): thermometer_leds is saturated
Thermometer value: 11111111 (255)
========================================
Results: 12 PASSED, 0 FAILED
========================================
ALL TESTS PASSED
Simulation complete. VCD waveform: reaction_timer.vcd
Run 'make waves' to view waveforms in GTKWave.
All 12 checks should pass. If any check fails, see Section 4.4 (Troubleshooting).
The simulation also produces a file called reaction_timer.vcd -- this is the waveform dump that you will view in GTKWave.
The testbench (tb/tb_reaction_timer.v) is not synthesisable -- it exists only for simulation. It instantiates the design-under-test (DUT), drives the inputs, and checks the outputs. Let us walk through it.
File: tb/tb_reaction_timer.v
`timescale 1ns / 1ps
`define SIMULATION
module tb_reaction_timer;
parameter CLK_PERIOD = 20;
parameter CLK_DIV = 4;
parameter DEBOUNCE_CYC = 4;
reg clk;
reg rst_n;
reg btn_start;
reg btn_stop;
wire [2:0] rgb_led;
wire [7:0] thermometer_leds;
integer pass_count = 0;
integer fail_count = 0;What this does: The `timescale directive sets the time unit to 1 ns and the precision to 1 ps. The `define SIMULATION macro ensures the simulation parameters are used. The parameters match the simulation-mode values. Input signals are reg (driven by the testbench), outputs are wire (driven by the DUT). The integer counters track pass/fail results.
reaction_timer_top #(
.CLK_DIV (CLK_DIV),
.DEBOUNCE_CYC(DEBOUNCE_CYC),
.LFSR_SEED (8'hA5)
) dut (
.clk (clk),
.rst_n (rst_n),
.btn_start (btn_start),
.btn_stop (btn_stop),
.rgb_led (rgb_led),
.thermometer_leds(thermometer_leds)
);What this does: The DUT (Design Under Test) is instantiated with the simulation parameters. The instance name dut is conventional for testbenches.
initial clk = 0;
always #(CLK_PERIOD/2) clk = ~clk;What this does: The clock generator. initial clk = 0 sets the clock low at time zero. The always block toggles the clock every CLK_PERIOD/2 = 10 ns, creating a 50 MHz clock with a 20 ns period. This is the standard pattern for generating a clock in a testbench.
initial begin
$dumpfile("reaction_timer.vcd");
$dumpvars(0, tb_reaction_timer);
endWhat this does: $dumpfile specifies the output VCD filename. $dumpvars(0, tb_reaction_timer) dumps all signals in the entire hierarchy starting from the testbench module. The 0 means "dump all levels of hierarchy." This is what creates the waveform file you will view in GTKWave.
task press_button;
input integer is_start;
begin
if (is_start)
btn_start = 1'b1;
else
btn_stop = 1'b1;
repeat (DEBOUNCE_CYC + 10) @(posedge clk);
if (is_start)
btn_start = 1'b0;
else
btn_stop = 1'b0;
repeat (5) @(posedge clk);
end
endtaskWhat this does: A task is a reusable procedure in Verilog (similar to a function in software). press_button simulates a button press: it holds the button high for DEBOUNCE_CYC + 10 clock cycles (long enough to pass through the debouncer), then releases it and waits 5 more cycles for the release to propagate. The is_start parameter selects which button to press (1 = Start, 0 = Stop).
task wait_ticks;
input integer n;
integer i;
begin
for (i = 0; i < n; i = i + 1) begin
repeat (CLK_DIV) @(posedge clk);
end
end
endtaskWhat this does: wait_ticks waits for n tick periods. Since each tick is CLK_DIV clock cycles, this waits n * CLK_DIV clock cycles total.
task check;
input [511:0] test_name;
input condition;
begin
if (condition) begin
$display("[PASS] %0s", test_name);
pass_count = pass_count + 1;
end else begin
$display("[FAIL] %0s", test_name);
fail_count = fail_count + 1;
end
end
endtaskWhat this does: check is an assertion helper. It prints PASS or FAIL based on a boolean condition and updates the counters. The %0s format specifier prints the string without padding.
Test 1: Normal Reaction (IDLE -> WAIT -> TEST -> RESULT -> IDLE)
// Reset sequence
rst_n = 1'b0;
btn_start = 1'b0;
btn_stop = 1'b0;
repeat (20) @(posedge clk);
rst_n = 1'b1;
repeat (5) @(posedge clk);
// Verify IDLE state
check("IDLE: rgb_led is OFF", rgb_led == 3'b000);
check("IDLE: thermometer_leds is 0", thermometer_leds == 8'h00);
// Press Start -> go to WAIT
press_button(1);
repeat (5) @(posedge clk);
check("WAIT: rgb_led is Blue", rgb_led == 3'b001);
// Wait 300 ticks -> WAIT -> TEST transition
wait_ticks(300);
check("TEST: rgb_led is Green", rgb_led == 3'b010);
// Wait 10 ticks for some reaction time
wait_ticks(10);
// Press Stop -> go to RESULT
press_button(0);
repeat (5) @(posedge clk);
check("RESULT: rgb_led is OFF", rgb_led == 3'b000);
check("RESULT: thermometer_leds is non-zero", thermometer_leds != 8'h00);This exercises the complete normal flow: start a round, wait for the green light, react, view the result.
Test 2: False Start (IDLE -> WAIT -> FALSE_START -> IDLE)
// Press Start to return to IDLE, then start a new round
press_button(1);
repeat (5) @(posedge clk);
check("IDLE: rgb_led is OFF (reset)", rgb_led == 3'b000);
press_button(1);
repeat (5) @(posedge clk);
check("WAIT: rgb_led is Blue", rgb_led == 3'b001);
// Press Stop during WAIT (false start!)
press_button(0);
repeat (5) @(posedge clk);
check("FALSE_START: rgb_led is Red", rgb_led == 3'b100);This verifies that pressing Stop during the wait period triggers the false start state with a red LED.
Test 3: Slow Reaction (saturation at 255)
// Start a new round and wait for TEST
press_button(1);
repeat (5) @(posedge clk);
press_button(1);
repeat (5) @(posedge clk);
wait_ticks(300);
check("TEST: rgb_led is Green", rgb_led == 3'b010);
// Wait 300 more ticks (counter saturates at 255)
wait_ticks(300);
// Press Stop -> all 8 LEDs lit
press_button(0);
repeat (5) @(posedge clk);
check("RESULT (slow): thermometer_leds is saturated", thermometer_leds == 8'hFF);This verifies that the reaction counter saturates at 255 and all 8 thermometer LEDs light up.
// Safety timeout
initial begin
#10_000_000;
$display("[TIMEOUT] Simulation exceeded maximum time");
$finish;
endWhat this does: A safety net that terminates the simulation after 10 ms of simulation time. This prevents the simulation from running forever if there is a bug (e.g., an FSM stuck in a state).
After simulation completes, open the waveform file:
make wavesThis runs gtkwave reaction_timer.vcd on your host machine (not in Docker). GTKWave will open with a window showing the module hierarchy on the left.
Step-by-step GTKWave tutorial:
-
Navigate the hierarchy. In the left panel (SST -- Signal Search Tree), you will see
tb_reaction_timer. Click the arrow to expand it, then expanddut(the DUT instance), and you will see the sub-modules:u_debounce_start,u_debounce_stop,u_fsm,u_counter,u_lfsr,u_therm. -
Add signals to the viewer. Click on a module (e.g.,
u_fsm) to see its signals in the signal list. Select the signals you want and click Insert (or press the Insert key) to add them to the waveform viewer on the right. -
Zoom to fit. Press
Ctrl+Shift+F(orCmd+Shift+Fon macOS) to zoom out and see the entire simulation. -
Change signal format. Right-click on a multi-bit signal in the waveform viewer and choose Data Format to change how it is displayed (Binary, Hex, Decimal, etc.).
-
Add markers. Click in the waveform area to place a cursor. Use this to measure time differences between events.
Recommended signals to add and what to look for:
| Signal Path | Format | What to Look For |
|---|---|---|
tb_reaction_timer.clk |
Binary | Regular 50 MHz clock |
tb_reaction_timer.rst_n |
Binary | Low for first 20 cycles, then high |
tb_reaction_timer.btn_start |
Binary | Button press pulses |
tb_reaction_timer.btn_stop |
Binary | Button press pulses |
tb_reaction_timer.dut.u_fsm.state |
Binary | State transitions: 000->001->010->011 (normal), 000->001->100 (false start) |
tb_reaction_timer.rgb_led |
Binary | Matches state: 000=off, 001=blue, 010=green, 100=red |
tb_reaction_timer.dut.u_counter.tick |
Binary | Periodic single-cycle pulses every 4 clock cycles |
tb_reaction_timer.dut.u_counter.wait_count |
Decimal | Incrementing during WAIT state |
tb_reaction_timer.dut.u_counter.react_count |
Decimal | Incrementing during TEST state, saturates at 255 |
tb_reaction_timer.dut.u_lfsr.lfsr_reg |
Hex | Pseudo-random value, changes while LFSR is enabled |
tb_reaction_timer.thermometer_leds |
Binary | Zero during IDLE/WAIT/TEST; encoded value during RESULT; flashing during FALSE_START |
tb_reaction_timer.dut.u_debounce_start.btn_out |
Binary | Clean single-cycle pulses from the debouncer |
Tips:
- Use Ctrl+Shift+F (Zoom Fit) to see the entire simulation at once.
- Use the scroll wheel to zoom in and out on specific regions.
- Right-click a signal and choose Data Format -> Binary to see individual bits of multi-bit signals.
- Look for the
statesignal changing value -- each transition should match the FSM state diagram from Section 2.5. - You can use Ctrl+Shift+R to reload the VCD file after re-running the simulation, without closing GTKWave.
Compilation errors:
| Error Message | Cause | Solution |
|---|---|---|
error: Unknown module type: reaction_timer_top |
Missing source file in compilation | Check that all 6 RTL files and the testbench are listed in the iverilog command |
error: port ... is not a port of ... |
Port name mismatch between instantiation and module definition | Check spelling and capitalisation of port names |
warning: implicit definition of wire |
A wire is used but not declared | Add an explicit wire declaration or check for typos in signal names |
error: syntax error |
Verilog syntax error (missing semicolon, end, etc.) |
Check the line number in the error message and look for missing ; or end |
Test failures:
| Failure | Possible Cause | Solution |
|---|---|---|
[FAIL] WAIT: rgb_led is Blue |
FSM not transitioning from IDLE to WAIT | Check that the debouncer is producing a pulse (look at start_pulse in waveforms) |
[FAIL] TEST: rgb_led is Green |
FSM stuck in WAIT | Check that wait_count is incrementing and eventually exceeds lfsr_val |
[FAIL] FALSE_START: rgb_led is Red |
FSM not detecting false start | Check that stop_pulse is being generated during the WAIT state |
[FAIL] RESULT: thermometer_leds is non-zero |
Counter not incrementing during TEST | Check that counter_en is high during TEST state and tick is pulsing |
Timeout:
If you see [TIMEOUT] Simulation exceeded maximum time, the simulation is running too long. This usually means:
- The clock divider is too large for simulation (make sure
-DSIMULATIONis defined). - The FSM is stuck in a state (check the state signal in waveforms).
- The debounce counter is too large (should be 4 in simulation mode).
In this section you will take the verified RTL and push it through an automated ASIC physical design flow. The result is a GDSII file -- the industry-standard format that a semiconductor foundry uses to fabricate chips.
Synthesis is the process of translating RTL (Register Transfer Level) Verilog -- which describes what the logic does in terms of registers, multiplexers, and arithmetic -- into a gate-level netlist composed of specific standard cells from a cell library.
Think of it this way:
- RTL says: "If the input is greater than 127, set the output high."
- Gate-level netlist says: "Use this specific combination of NAND, NOR, and inverter cells from the sky130_fd_sc_hd library to implement that comparison."
The synthesis tool used by OpenLane is Yosys, an open-source RTL synthesis suite. Yosys reads your Verilog, performs logic optimisation (constant propagation, common sub-expression elimination, technology mapping), and produces a netlist where every element is a standard cell from the SkyWater 130 nm PDK.
What is a standard cell? A standard cell is a pre-designed, pre-characterised logic gate (AND, OR, NOT, flip-flop, multiplexer, buffer, etc.) with a fixed height and variable width. The cell library provides timing models (how fast each cell is), power models, and physical layouts. The sky130_fd_sc_hd library is a high-density library with hundreds of cell types.
What is the SkyWater 130 nm PDK? A Process Design Kit (PDK) describes everything about a specific manufacturing process: the transistor models, the metal layer stack, the design rules (minimum wire width, spacing, etc.), and the standard cell libraries. SkyWater 130 nm is an open-source PDK that describes a real 130 nm CMOS process at the SkyWater Technology foundry.
After synthesis, you have a netlist -- a list of standard cells and how they are connected. But you do not yet know where on the chip each cell should go, or how the metal wires should be routed. That is the job of Place-and-Route (PnR).
PnR consists of several stages:
-
Floorplanning: Define the chip area (die size), place I/O pins around the periphery, and create the power distribution network (VDD and VSS rails).
-
Placement: Assign a physical (x, y) location to every standard cell. Global placement spreads cells roughly; detailed placement snaps them into legal positions on the cell rows.
-
Clock Tree Synthesis (CTS): Build a tree of buffer cells to distribute the clock signal from the clock pin to every flip-flop with minimal skew (difference in arrival time). Clock skew can cause timing violations, so CTS is critical.
-
Routing: Connect all the cell pins with metal wires across the available routing layers (met1 through met4). Global routing plans approximate wire paths; detailed routing realises them on the exact metal grid tracks.
-
Design Rule Check (DRC): Verify that the physical layout obeys the foundry's manufacturing rules (minimum wire width, minimum spacing, minimum overlap, etc.).
-
Layout vs. Schematic (LVS): Verify that the layout's connectivity matches the synthesised netlist -- ensuring that no connections were lost or created during routing.
-
GDSII Generation: Produce the final GDSII Stream file.
The PnR tool used by OpenLane is OpenROAD, an open-source chip design platform.
The file openlane/config.json tells OpenLane everything it needs to know about your design. Here is the complete file with annotations:
{
"DESIGN_NAME": "reaction_timer_top",
"VERILOG_FILES": [
"dir::../rtl/btn_debounce.v",
"dir::../rtl/lfsr.v",
"dir::../rtl/counter.v",
"dir::../rtl/thermometer_encoder.v",
"dir::../rtl/fsm.v",
"dir::../rtl/reaction_timer_top.v"
],
"CLOCK_PORT": "clk",
"CLOCK_PERIOD": 20.0,
"FP_CORE_UTIL": 40,
"PL_TARGET_DENSITY_PCT": 45,
"FP_PDN_AUTO_ADJUST": true,
"RUN_HEURISTIC_DIODE_INSERTION": true,
"DIODE_ON_PORTS": "in",
"GPL_CELL_PADDING": 2,
"DPL_CELL_PADDING": 2,
"RT_MAX_LAYER": "met4",
"GRT_MAX_DIODE_INS_ITERS": 5,
"QUIT_ON_TIMING_VIOLATIONS": false,
"pdk::sky130*": {
"MAX_FANOUT_CONSTRAINT": 6,
"FP_CORE_UTIL": 40,
"scl::sky130_fd_sc_hd": {
"CLOCK_PERIOD": 20.0
}
}
}Parameter-by-parameter explanation:
| Parameter | Value | Meaning |
|---|---|---|
DESIGN_NAME |
reaction_timer_top |
Name of the top-level Verilog module. OpenLane uses this to identify the design entry point. |
VERILOG_FILES |
(list of 6 files) | Paths to all RTL source files. The dir:: prefix resolves paths relative to the config file location (not the run directory). |
CLOCK_PORT |
clk |
Name of the clock input port. OpenLane uses this for timing analysis and clock tree synthesis. |
CLOCK_PERIOD |
20.0 |
Target clock period in nanoseconds. 20 ns = 50 MHz. This is the timing target the tools will try to meet. |
FP_CORE_UTIL |
40 |
Core utilisation target: 40% of the die area will be filled with standard cells. Lower values give more routing space but a larger die. Higher values produce a smaller die but are harder to route. |
PL_TARGET_DENSITY_PCT |
45 |
Target placement density percentage. Slightly higher than core utilisation to allow for some clustering. |
FP_PDN_AUTO_ADJUST |
true |
Automatically adjust the power distribution network to fit the die area. |
RUN_HEURISTIC_DIODE_INSERTION |
true |
Insert antenna diodes to protect gate oxides from charge build-up during manufacturing. |
DIODE_ON_PORTS |
"in" |
Insert antenna diodes on input ports. |
GPL_CELL_PADDING |
2 |
Padding around cells during global placement (in site widths). |
DPL_CELL_PADDING |
2 |
Padding around cells during detailed placement. |
RT_MAX_LAYER |
met4 |
Highest metal layer available for signal routing. sky130 has 5 metal layers (met1--met5); we restrict to met4. |
GRT_MAX_DIODE_INS_ITERS |
5 |
Maximum iterations for diode insertion during global routing. |
QUIT_ON_TIMING_VIOLATIONS |
false |
Do not abort if timing is not met. Useful for learning -- you can still view the layout even if timing is slightly off. |
MAX_FANOUT_CONSTRAINT |
6 |
Maximum fan-out (number of inputs driven by a single output) before the tool inserts buffers. Prevents excessive loading. |
From the repository root, run:
make pnrWhat happens behind the scenes:
docker run --rm -v $(pwd):/work -w /work ee2c2-digital-lab openlane openlane/config.jsonThis launches the full OpenLane 2 flow inside Docker. You will see progress messages as each stage completes:
=== Running OpenLane 2 Flow ===
This may take 5-15 minutes...
[INFO]: Starting OpenLane flow...
[INFO]: Running Synthesis...
[INFO]: Running Floorplanning...
[INFO]: Running Placement...
[INFO]: Running Clock Tree Synthesis...
[INFO]: Running Routing...
[INFO]: Running DRC...
[INFO]: Running LVS...
[INFO]: Generating GDSII...
[INFO]: Flow complete.
=== Flow Complete ===
Results in: runs/RUN_2024.01.01_12.00.00/
Run 'make gds' to view the layout.
The exact output format may vary depending on the OpenLane version, but the stages are the same.
The flow creates a timestamped run directory under runs/ containing all reports, netlists, and the final GDSII file.
Timing: The flow typically takes 5--15 minutes depending on your machine's speed. The synthesis step is fast (under a minute). Placement and routing are the most time-consuming stages.
After the flow completes, you can examine the reports to understand what the tools did.
Synthesis statistics:
runs/<run_name>/reports/synthesis/stat.rpt
This report shows how many standard cells were used and what types. For this design, you should expect to see:
- Flip-flops (DFF): Approximately 60--80. These come from: 8-bit LFSR (8), 8-bit react_count (8), 8-bit wait_count (8), ~19-bit div_count, ~16-bit debounce counters x 2, 3-bit FSM state, and various single-bit registers.
- Combinational cells: AND, OR, NOT, NAND, NOR, XOR, MUX, buffers. These implement the next-state logic, thermometer encoder, LFSR feedback, counter arithmetic, etc.
- Total cell count: Typically 200--400 cells for this design.
Timing report:
runs/<run_name>/reports/cts/timing.rpt
or
runs/<run_name>/reports/routing/timing.rpt
This report shows the worst-case setup and hold timing paths. Look for:
- Setup slack: Positive means the design meets timing. Negative means it violates timing (the logic is too slow for the target clock period).
- Hold slack: Positive means hold times are met. Negative means hold violations exist (data changes too quickly after a clock edge).
At 50 MHz (20 ns period), this design should have ample positive slack because the logic is simple.
DRC report:
runs/<run_name>/reports/routing/drc.rpt
A clean report shows zero violations. If there are violations, they must be fixed before fabrication (though for this lab, we accept minor violations as learning experiences).
| Problem | Symptom | Solution |
|---|---|---|
| Timing violations (setup) | Negative setup slack in timing report | Increase CLOCK_PERIOD (e.g., 40 ns = 25 MHz) to give the tools more time budget. Or simplify combinational logic on the critical path. |
| DRC violations | Non-zero violation count | Reduce FP_CORE_UTIL to give the router more space (e.g., 30 instead of 40). Or increase RT_MAX_LAYER to met5. |
| Unmapped cells | Yosys warnings about $display, initial, or #delay |
These are non-synthesisable constructs that belong only in testbenches, not in RTL. Remove them from the design files. |
| Hold violations | Negative hold slack | OpenLane inserts buffer cells automatically. If violations persist, the clock tree may need tuning. Try increasing CTS_CLK_BUFFER_LIST. |
| Docker memory error | Killed or OOM during routing |
Increase Docker's memory allocation in Docker Desktop settings (try 6 GB or 8 GB). |
| "No module named openlane" | OpenLane not installed in Docker image | Re-run make setup to rebuild the Docker image. |
| Synthesis takes very long | More than 5 minutes on synthesis alone | Check that you are not accidentally synthesising the testbench (which contains $display and delays). Only RTL files should be in VERILOG_FILES. |
After a successful OpenLane run, you can inspect the physical layout of your chip. This is the most visually rewarding part of the lab -- you can see your Verilog code transformed into actual metal wires and transistors.
From the repository root, run:
make gdsThis command finds the latest GDSII file in the runs/ directory and opens it in KLayout. If KLayout is not found, it will print installation instructions.
Alternatively, you can open it manually:
klayout runs/<run_name>/final/gds/reaction_timer_top.gdsReplace <run_name> with the actual run directory name (e.g., RUN_2024.01.01_12.00.00).
When KLayout opens, you will see the chip layout with different colours for different layers:
- Zoom in/out: Use the scroll wheel or
Ctrl+Scroll. - Pan: Click and drag with the middle mouse button, or hold Shift and drag.
- Zoom to fit: Press
Ctrl+For use the menu View > Zoom Fit. - Toggle layers: The layer panel on the right side lists all GDS layers. Click the eye icon next to a layer to show or hide it. This is very useful for examining individual metal layers.
- Select objects: Click on a shape to select it. The properties panel will show the layer, coordinates, and other information.
- Hierarchy: Use the cell hierarchy browser (View > Cell) to navigate into sub-cells if the design is hierarchical.
Take time to examine these features of your layout:
Standard cell rows: Zoom in and you will see that cells are arranged in horizontal rows. Adjacent rows have alternating orientations (N-facing and S-facing) so that they can share power rails. Each row is the same height (the standard cell height for sky130_fd_sc_hd is about 2.72 um).
Metal routing layers: Each metal layer has a distinct colour:
- met1 (Metal 1): The lowest routing layer, typically used for local connections within cells. Usually runs horizontally.
- met2 (Metal 2): The next layer up, typically vertical.
- met3 (Metal 3): Horizontal.
- met4 (Metal 4): Vertical. This is the highest layer we allowed for signal routing.
Toggle layers on and off to see how routes are distributed across the metal stack.
Power and ground grid: Look for thick horizontal stripes running across the entire die. These are the VDD (power) and VSS (ground) distribution rails. They connect to thinner rails within each standard cell row. Vertical power straps connect the horizontal rails to form a grid.
Clock tree: If you can identify the clock net (by selecting wires and checking the net name), trace it from the clock input pin to the flip-flops. You will see that CTS has inserted buffer cells along the way to balance the clock arrival times.
I/O pin locations: The 14 I/O signals (clk, rst_n, btn_start, btn_stop, rgb_led[2:0], thermometer_leds[7:0]) are placed along the die boundary as metal shapes. Look for them around the edges of the layout.
Die area: The die should be quite small (a few hundred micrometres on each side) because this is a simple design. For reference, a grain of rice is about 1 mm wide, and this chip is likely 200--500 um on a side.
These exercises extend the base design. Each one is independent -- you can attempt them in any order. After making changes, always re-run make sim to verify correctness and make pnr to check that the modified design still synthesises and meets timing.
Difficulty: Easy
Task: Change the LFSR seed from 8'hA5 to a different non-zero value and observe the effect on the random delays.
Steps:
- Open
rtl/reaction_timer_top.vand find the line:parameter LFSR_SEED = 8'hA5
- Change
8'hA5to a different value, for example8'h37. - Also update the testbench (
tb/tb_reaction_timer.v) to use the same seed in the DUT instantiation:.LFSR_SEED(8'h37) - Run
make clean && make simand verify all tests still pass. - Open the waveforms and compare the
lfsr_regandwait_countvalues to the original. The random delay sequence should be different, but the design behaviour should be identical.
Question: What happens if you set the seed to 8'h00? Why? (Hint: consider the LFSR feedback equation when all bits are zero.)
Difficulty: Medium
Task: Add a new state S_TIMEOUT that the FSM enters if the player does not press Stop within 255 ticks during the TEST state.
Steps:
- In
rtl/fsm.v, add a new localparam:localparam [2:0] S_TIMEOUT = 3'b101;
- In the next-state logic, add a condition in the
S_TESTcase:Note: you will need to addS_TEST: begin if (btn_stop) next_state = S_RESULT; else if (react_count == 8'd255) next_state = S_TIMEOUT; end
react_countas an input to the FSM module. - Add the
S_TIMEOUTcase to the output logic (choose an RGB colour, e.g.,3'b110for yellow). - Add a transition from
S_TIMEOUTtoS_IDLEwhenbtn_startis pressed. - Update the testbench to test the timeout scenario.
- Run
make simandmake pnrto verify.
Difficulty: Medium
Task: Add an option to display the raw binary reaction count on the 8 thermometer LEDs instead of the thermometer encoding.
Steps:
- Add a new input pin
display_modeto the top-level module and the thermometer encoder. - In
thermometer_encoder.v, modify the output logic:if (result_valid) begin if (display_mode) therm_out = binary_in; // Raw binary else therm_out = therm_encoded; // Thermometer end
- Wire the new pin through the hierarchy.
- Update
openlane/config.json-- the new pin will be automatically included (OpenLane reads the top-level ports from the Verilog). - Update the testbench to test both modes.
- Run
make simandmake pnr.
Difficulty: Hard
Task: Change the clock period target from 20 ns (50 MHz) to 10 ns (100 MHz) and attempt to close timing.
Steps:
- In
openlane/config.json, change:Also update the PDK-specific override:"CLOCK_PERIOD": 10.0
"scl::sky130_fd_sc_hd": { "CLOCK_PERIOD": 10.0 }
- Update
CLK_DIVinrtl/reaction_timer_top.vto1_000_000(to maintain the same 10 ms tick period: 1,000,000 x 10 ns = 10 ms). - Run
make pnr. - Check the timing report for setup violations. At 100 MHz with sky130_fd_sc_hd, this design may or may not meet timing depending on the critical path.
- If timing is violated, examine the timing report to identify the critical path and consider optimisations (pipeline stages, logic restructuring).
Question: Why is it harder to meet timing at 100 MHz? What is the relationship between clock period and the maximum combinational delay allowed between flip-flops?
The complete pin list for the reaction_timer_top module:
| Pin Name | Direction | Width | Bit | Description |
|---|---|---|---|---|
clk |
Input | 1 | -- | 50 MHz system clock |
rst_n |
Input | 1 | -- | Active-low synchronous reset. Resets all state to initial values. |
btn_start |
Input | 1 | -- | Start button. Initiates a new round or acknowledges a result/false start. |
btn_stop |
Input | 1 | -- | Stop button. Records the user's reaction or triggers a false start. |
rgb_led[2] |
Output | 1 | [2] | Red LED component. High during FALSE_START state. |
rgb_led[1] |
Output | 1 | [1] | Green LED component. High during TEST state ("GO"). |
rgb_led[0] |
Output | 1 | [0] | Blue LED component. High during WAIT state ("get ready"). |
thermometer_leds[7] |
Output | 1 | [7] | Slowest reaction band (224--255 ticks, approx. 2.24--2.55 s). |
thermometer_leds[6] |
Output | 1 | [6] | Reaction band 192--223 ticks (approx. 1.92--2.23 s). |
thermometer_leds[5] |
Output | 1 | [5] | Reaction band 160--191 ticks (approx. 1.60--1.91 s). |
thermometer_leds[4] |
Output | 1 | [4] | Reaction band 128--159 ticks (approx. 1.28--1.59 s). |
thermometer_leds[3] |
Output | 1 | [3] | Reaction band 96--127 ticks (approx. 0.96--1.27 s). |
thermometer_leds[2] |
Output | 1 | [2] | Reaction band 64--95 ticks (approx. 0.64--0.95 s). |
thermometer_leds[1] |
Output | 1 | [1] | Reaction band 32--63 ticks (approx. 0.32--0.63 s). |
thermometer_leds[0] |
Output | 1 | [0] | Fastest reaction band (1--31 ticks, approx. 0.01--0.31 s). |
Total pins: 4 inputs + 11 outputs = 15 pins (including individual bits of bus signals).
The complete state transition table for the Moore FSM. Outputs are shown for the current state (Moore convention). Dashes (--) indicate "don't care" (the condition is not evaluated in that state).
| Current State | btn_start |
btn_stop |
wait_count >= lfsr_val |
Next State | rgb_led |
counter_clr |
counter_en |
wait_en |
lfsr_en |
result_valid |
false_start |
|---|---|---|---|---|---|---|---|---|---|---|---|
| IDLE | 0 | -- | -- | IDLE | 000 (OFF) |
1 | 0 | 0 | 1 | 0 | 0 |
| IDLE | 1 | -- | -- | WAIT | 000 (OFF) |
1 | 0 | 0 | 1 | 0 | 0 |
| WAIT | -- | 0 | 0 | WAIT | 001 (Blue) |
0 | 0 | 1 | 0 | 0 | 0 |
| WAIT | -- | 1 | -- | FALSE_START | 001 (Blue) |
0 | 0 | 1 | 0 | 0 | 0 |
| WAIT | -- | 0 | 1 (and lfsr!=0) | TEST | 001 (Blue) |
0 | 0 | 1 | 0 | 0 | 0 |
| TEST | -- | 0 | -- | TEST | 010 (Green) |
0 | 1 | 0 | 0 | 0 | 0 |
| TEST | -- | 1 | -- | RESULT | 010 (Green) |
0 | 1 | 0 | 0 | 0 | 0 |
| RESULT | 0 | -- | -- | RESULT | 000 (OFF) |
0 | 0 | 0 | 0 | 1 | 0 |
| RESULT | 1 | -- | -- | IDLE | 000 (OFF) |
0 | 0 | 0 | 0 | 1 | 0 |
| FALSE_START | 0 | -- | -- | FALSE_START | 100 (Red) |
0 | 0 | 0 | 0 | 0 | 1 |
| FALSE_START | 1 | -- | -- | IDLE | 100 (Red) |
0 | 0 | 0 | 0 | 0 | 1 |
Notes:
- In the WAIT state,
btn_stophas higher priority than the wait-count condition: if both are true simultaneously, the FSM goes to FALSE_START, not TEST. - The
lfsr_val != 0guard on the WAIT -> TEST transition prevents an instant transition when the LFSR happens to output zero (which would mean zero delay). - The
flash_toggleoutput is not shown in the table because it is generated by a separate sequential block (see Section 3.6). It toggles on eachtickpulse whilefalse_startis asserted and resets to 0 otherwise.
All commands are run from the repository root directory.
| Command | What It Does | Prerequisites | Output |
|---|---|---|---|
make help |
Prints available targets with descriptions | None | Terminal output |
make setup |
Builds the Docker image with all EDA tools | Docker Desktop running | Docker image ee2c2-digital-lab |
make sim |
Compiles and runs the Verilog simulation | Docker image built (make setup) |
Pass/fail output + reaction_timer.vcd |
make waves |
Opens waveforms in GTKWave | reaction_timer.vcd exists; GTKWave installed locally |
GTKWave window |
make pnr |
Runs full OpenLane synthesis + place-and-route | Docker image built | runs/<timestamp>/ directory with reports and GDS |
make gds |
Opens the GDSII layout in KLayout | PnR completed (make pnr); KLayout installed locally |
KLayout window |
make all |
Runs sim then pnr sequentially |
Docker image built | Both simulation output and PnR results |
make clean |
Removes generated files (sim.vvp, .vcd, runs/) |
None | Clean working directory |
Typical workflow:
make setup # One-time: build Docker image (5-15 min)
make sim # Run simulation and check all tests pass
make waves # Open GTKWave to inspect waveforms
make pnr # Run synthesis and place-and-route (5-15 min)
make gds # View the final chip layout in KLayoutClean start:
make clean # Remove all generated files
make sim # Re-run from scratchProblem: Running any make target gives an error about the Docker daemon.
Solution: Docker Desktop is not running. Open Docker Desktop from your Applications folder (macOS), Start menu (Windows), or start the Docker service (Linux: sudo systemctl start docker). Wait for it to finish loading before retrying.
Problem: Docker build or run fails with a disk space error.
Solution: Docker images and containers consume disk space. Run docker system prune -a to remove all unused images, containers, and build cache. This can free several gigabytes. Then retry make setup.
Problem: The Docker build hangs or fails while downloading the SkyWater PDK.
Solution: The PDK is approximately 5 GB. Ensure you have a stable internet connection. If using a university network with a proxy, configure Docker's proxy settings in Docker Desktop > Settings > Resources > Proxies. If the download keeps failing, you can build the image without the PDK and run volare enable manually later.
Problem: iverilog reports an unknown module type during compilation.
Solution: A source file is missing from the compilation command. If you have modified the Makefile, ensure all six RTL files and the testbench are listed. If you added a new module, add its file to the RTL_SRCS variable in the Makefile.
Problem: One or more [FAIL] checks appear in the simulation output.
Solution: Open the waveforms (make waves) and trace the failing signal through the design hierarchy. Common causes:
- Port mismatch (signal connected to the wrong port).
- Missing reset (a register starts with an undefined
xvalue becauserst_nwas not asserted long enough). - Incorrect sensitivity list (combinational block missing
always @(*)). - Blocking vs. non-blocking assignment confusion (using
=in sequential logic or<=in combinational logic).
Problem: The simulation hits the 10 ms safety timeout without completing.
Solution: The simulation is running too slowly, usually because the clock divider or debounce counter is too large. Ensure that:
- The
-DSIMULATIONflag is being passed toiverilog(the Makefile does this automatically). - The
ifdef SIMULATIONblock inreaction_timer_top.vis setting small values (4) for both parameters. - If you modified the code, check that the FSM is not stuck in a state (examine the
statesignal in waveforms).
Problem: make waves reports that GTKWave is not found.
Solution: GTKWave must be installed on your host machine (not in Docker). Follow the installation instructions in Section 0.2. On macOS, if you installed via Homebrew but the command is not found, try open reaction_timer.vcd which will use the system file association. On Windows, ensure the GTKWave installation directory is in your system PATH.
Problem: The timing report shows negative slack after PnR.
Solution: At 50 MHz this design should easily meet timing. If you changed the clock period to a more aggressive target (e.g., 10 ns), negative slack is expected. To fix it:
- Increase
CLOCK_PERIODinconfig.json. - Reduce
FP_CORE_UTILto give the placer and router more room. - If the critical path is in the thermometer encoder (combinational), consider adding a pipeline register.
Note that QUIT_ON_TIMING_VIOLATIONS is set to false, so the flow will complete even with timing violations. You can still view the layout.
Problem: The DRC report shows non-zero violations.
Solution: DRC violations typically occur when the die is too small (cells are packed too tightly). Try:
- Reduce
FP_CORE_UTILfrom 40 to 30 or 25. - Increase
GPL_CELL_PADDINGandDPL_CELL_PADDINGfrom 2 to 4. - Allow an additional metal layer by changing
RT_MAX_LAYERfrommet4tomet5.
Problem: KLayout opens but shows a blank layout or an error.
Solution: Verify that the GDS file exists and is non-zero:
ls -la runs/*/final/gds/*.gdsIf no file exists, the OpenLane flow did not complete successfully. Check the OpenLane logs in runs/<run_name>/ for errors. If the file exists but KLayout shows nothing, try zooming to fit (Ctrl+F) -- the layout may be very small and off-screen.
End of Lab Handout