Skip to content

lab-emi/EE2C2-Digital-Lab

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

EE2C2 Digital Integrated Circuits -- Lab 3: Reaction Timer ASIC

A Step-by-Step Tutorial

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:

  1. RTL Design -- write synthesisable Verilog modules that describe the digital logic.
  2. Simulation -- verify functional correctness with a testbench using Icarus Verilog.
  3. Synthesis -- translate RTL into a gate-level netlist of standard cells (Yosys).
  4. Place & Route -- position cells on silicon and connect them with metal wires (OpenLane 2 / OpenROAD).
  5. 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.


0. Prerequisites

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.

0.1 Install Docker Desktop

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.

macOS

  1. Go to https://www.docker.com/products/docker-desktop/ in your web browser.
  2. 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.
  3. Open the downloaded .dmg file.
  4. Drag the Docker icon to the Applications folder.
  5. 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.
  6. 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).
  7. Open a terminal (Applications > Utilities > Terminal, or use Spotlight to search for "Terminal").
  8. Verify the installation:
docker run hello-world

You should see output that includes the line:

Hello from Docker!
This message shows that your installation appears to be working correctly.

Windows

  1. Go to https://www.docker.com/products/docker-desktop/ in your web browser.
  2. Click Download for Windows.
  3. Run the downloaded installer (Docker Desktop Installer.exe).
  4. When prompted, ensure the Use WSL 2 instead of Hyper-V option is selected (WSL 2 is recommended).
  5. Follow the on-screen instructions. You may need to restart your computer.
  6. After the restart, Docker Desktop should launch automatically. If it does not, find it in the Start menu.
  7. Wait for Docker Desktop to finish starting (the status icon in the system tray will turn green).
  8. Open PowerShell or Command Prompt and verify:
docker run hello-world

You 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 --install

Then restart your computer and try again.

Linux (Ubuntu / Debian)

  1. Open a terminal.
  2. Remove any old Docker installations:
sudo apt-get remove docker docker-engine docker.io containerd runc
  1. Install prerequisites:
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg lsb-release
  1. 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
  1. 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
  1. Install Docker Engine:
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  1. Add your user to the docker group so you do not need sudo for every Docker command:
sudo usermod -aG docker $USER
  1. Log out and log back in for the group change to take effect.
  2. Verify:
docker run hello-world

Docker 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.

0.2 Install GTKWave

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.

macOS

brew install gtkwave

If 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.

Linux (Ubuntu / Debian)

sudo apt-get update
sudo apt-get install -y gtkwave

Windows

  1. Download GTKWave from https://gtkwave.sourceforge.net.
  2. Run the installer and follow the prompts.
  3. 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 --version

You should see a version number (e.g., GTKWave Analyzer v3.3.x).

0.3 Install KLayout

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.

macOS

brew install --cask klayout

Linux (Ubuntu / Debian)

Download 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

Windows

  1. Download the Windows installer from https://www.klayout.de/build.html.
  2. Run the installer and follow the prompts.

Verify the installation (all platforms):

klayout -v

0.4 Install a Text Editor

You 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.

  1. Download VS Code from https://code.visualstudio.com and install it.
  2. Open VS Code.
  3. Press Ctrl+Shift+X (Windows/Linux) or Cmd+Shift+X (macOS) to open the Extensions panel.
  4. Search for Verilog-HDL/SystemVerilog/Bluespec SystemVerilog by mshr-h.
  5. 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.

0.5 Clone the Repository

Open a terminal and clone the lab repository:

git clone https://github.com/<your-org>/EE2C2-Digital-Lab.git
cd EE2C2-Digital-Lab

Replace <your-org> with the GitHub organisation or URL provided by your instructor.

After cloning, verify that you can see the project structure:

ls -la

You 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

1. Environment Setup

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.

1.1 Build the Docker Image

From the root of the repository, run:

make setup

What 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:

  1. 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.
  2. Installs Icarus Verilog (the Verilog simulator) on top of that image.
  3. Verifies that iverilog is accessible.
  4. Downloads the SkyWater 130 nm PDK if it is not already present in the base image.
  5. Sets /work as 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

1.2 Verify the Tools

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 -V

You should see version information such as:

Icarus Verilog version 12.0 (stable) ...

Check OpenLane:

docker run --rm ee2c2-digital-lab openlane --version

You should see a version string like OpenLane 2.x.x.

Check Yosys:

docker run --rm ee2c2-digital-lab yosys --version

Expected 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.

1.3 Understanding the Makefile

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 help

Output:

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.vvp or reaction_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.


2. Understanding the Design

Before diving into the code, let us understand what the Reaction Timer does and how its digital architecture works.

2.1 What Does the Reaction Timer Do?

The Reaction Timer is an electronic game. It works like this:

A complete game, step by step:

  1. Power on / Reset. The chip starts in the IDLE state. All LEDs are off. The chip is waiting for the player to begin.

  2. 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."

  3. 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.

  4. 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.

  5. 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.

  6. 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.

2.2 Module Hierarchy

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

2.3 LFSR (Linear Feedback Shift Register)

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.

2.4 Clock Divider

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
`endif

When 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.

2.5 FSM (Finite State Machine)

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:

  1. 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.

  2. 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!

  3. 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.

  4. 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.

  5. RESULT -> IDLE: The Start button is pressed again. The chip returns to IDLE for a new round.

  6. 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.

2.6 Counter and Thermometer Encoder

The Counter module contains three functional blocks sharing a single clock domain:

  1. Clock divider (div_count): A free-running counter from 0 to CLK_DIV - 1. When it wraps, it produces a single-cycle tick pulse. This tick is the "slow clock" that drives the wait and reaction counters.

  2. Wait counter (wait_count, 8 bits): Increments on each tick while wait_en is asserted (during the WAIT state). Cleared by the clear signal (during IDLE). Used to time the random delay.

  3. Reaction counter (react_count, 8 bits): Increments on each tick while enable is asserted (during the TEST state). Cleared by clear. 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 by flash_toggle.
  • In all other states: all LEDs are off.

2.7 Button Debouncing

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)

3. Reading the RTL Code

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.

3.1 File Overview

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

3.2 btn_debounce.v -- Button Debouncer

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 _n suffix 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
    end

What 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_0 samples the raw button input, and sync_1 samples sync_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 value DEBOUNCE_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 of btn_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
endmodule

What 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_stable takes on the new value and the counter resets.
  • If sync_1 matches btn_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_prev is loaded with the previous value of btn_stable at the beginning of each clock cycle.
  • The expression btn_stable & ~btn_prev is 1 only on the exact cycle when btn_stable transitions from 0 to 1 (a rising edge).
  • This produces a single-cycle pulse for each button press.

3.3 lfsr.v -- Linear Feedback Shift Register

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;
endmodule

What 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};
end

On 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 the feedback bit 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.

3.4 counter.v -- Clock Divider and Counters

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-cycle tick pulse (tick_reg = 1 for exactly one clock cycle).
  • On all other cycles, tick_reg is 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;
    end

What 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
endmodule

What 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.

3.5 thermometer_encoder.v -- Binary to Thermometer Encoder

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;
    end

What 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
endmodule

What this does: This is the output multiplexer that selects what to show on the LEDs:

  1. False start: If false_start is asserted, the LEDs flash. flash_toggle alternates 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).
  2. Result valid: If result_valid is asserted (RESULT state), the thermometer-encoded value is displayed.
  3. 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.

3.6 fsm.v -- Finite State Machine

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;
    end

What this does: This is the state register. On every rising clock edge:

  • If reset is active, go to IDLE.
  • Otherwise, advance to whatever next_state has 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
    end

What 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 = state at the top means that if none of the conditions in the case are met, the FSM stays in its current state. This prevents accidental latches.
  • In S_WAIT, the btn_stop check comes before the wait_count >= lfsr_val check. This gives btn_stop higher 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'd0 guard in the WAIT-to-TEST transition prevents an immediate transition if the LFSR happens to output zero.
  • The default case 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
    end

What 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:

  1. No signal is accidentally left undriven (which would create a latch).
  2. 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
endmodule

What 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_start is active and a tick pulse 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_start is not active: hold at 0 (so the flash starts from a known state next time).

3.7 reaction_timer_top.v -- Top-Level Module

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
`endif

What 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_CYC

What 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.


4. Simulation

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.

4.1 Running the Simulation

From the repository root, run:

make sim

What happens behind the scenes:

  1. 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.v

    This compiles all six RTL files and the testbench into a simulation executable (tb/sim.vvp). The -DSIMULATION flag activates the fast clock divider. The -Wall flag enables all warnings.

  2. Execution: The Makefile then runs:

    docker run --rm -v $(pwd):/work -w /work ee2c2-digital-lab \
        vvp tb/sim.vvp

    This 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.

4.2 Understanding the Testbench

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);
    end

What 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
    endtask

What 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
    endtask

What 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
    endtask

What 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;
    end

What 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).

4.3 Viewing Waveforms in GTKWave

After simulation completes, open the waveform file:

make waves

This 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:

  1. Navigate the hierarchy. In the left panel (SST -- Signal Search Tree), you will see tb_reaction_timer. Click the arrow to expand it, then expand dut (the DUT instance), and you will see the sub-modules: u_debounce_start, u_debounce_stop, u_fsm, u_counter, u_lfsr, u_therm.

  2. 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.

  3. Zoom to fit. Press Ctrl+Shift+F (or Cmd+Shift+F on macOS) to zoom out and see the entire simulation.

  4. 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.).

  5. 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 state signal 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.

4.4 Troubleshooting Simulation

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 -DSIMULATION is 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).

5. Synthesis and Place-and-Route

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.

5.1 What is Synthesis?

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.

5.2 What is Place-and-Route?

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:

  1. Floorplanning: Define the chip area (die size), place I/O pins around the periphery, and create the power distribution network (VDD and VSS rails).

  2. 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.

  3. 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.

  4. 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.

  5. Design Rule Check (DRC): Verify that the physical layout obeys the foundry's manufacturing rules (minimum wire width, minimum spacing, minimum overlap, etc.).

  6. Layout vs. Schematic (LVS): Verify that the layout's connectivity matches the synthesised netlist -- ensuring that no connections were lost or created during routing.

  7. GDSII Generation: Produce the final GDSII Stream file.

The PnR tool used by OpenLane is OpenROAD, an open-source chip design platform.

5.3 OpenLane Configuration

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.

5.4 Running the Flow

From the repository root, run:

make pnr

What happens behind the scenes:

docker run --rm -v $(pwd):/work -w /work ee2c2-digital-lab openlane openlane/config.json

This 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.

5.5 Reading Reports

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).

5.6 Troubleshooting Synthesis and PnR

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.

6. Viewing the Layout

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.

6.1 Opening the GDS File

From the repository root, run:

make gds

This 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.gds

Replace <run_name> with the actual run directory name (e.g., RUN_2024.01.01_12.00.00).

6.2 Navigating the Layout in KLayout

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+F or 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.

6.3 What to Observe

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.


7. Exercises

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.

7.1 Change the LFSR Seed

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:

  1. Open rtl/reaction_timer_top.v and find the line:
    parameter LFSR_SEED = 8'hA5
  2. Change 8'hA5 to a different value, for example 8'h37.
  3. Also update the testbench (tb/tb_reaction_timer.v) to use the same seed in the DUT instantiation:
    .LFSR_SEED(8'h37)
  4. Run make clean && make sim and verify all tests still pass.
  5. Open the waveforms and compare the lfsr_reg and wait_count values 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.)

7.2 Add a Timeout State

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:

  1. In rtl/fsm.v, add a new localparam:
    localparam [2:0] S_TIMEOUT = 3'b101;
  2. In the next-state logic, add a condition in the S_TEST case:
    S_TEST: begin
        if (btn_stop)
            next_state = S_RESULT;
        else if (react_count == 8'd255)
            next_state = S_TIMEOUT;
    end
    Note: you will need to add react_count as an input to the FSM module.
  3. Add the S_TIMEOUT case to the output logic (choose an RGB colour, e.g., 3'b110 for yellow).
  4. Add a transition from S_TIMEOUT to S_IDLE when btn_start is pressed.
  5. Update the testbench to test the timeout scenario.
  6. Run make sim and make pnr to verify.

7.3 Binary Display Mode

Difficulty: Medium

Task: Add an option to display the raw binary reaction count on the 8 thermometer LEDs instead of the thermometer encoding.

Steps:

  1. Add a new input pin display_mode to the top-level module and the thermometer encoder.
  2. 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
  3. Wire the new pin through the hierarchy.
  4. Update openlane/config.json -- the new pin will be automatically included (OpenLane reads the top-level ports from the Verilog).
  5. Update the testbench to test both modes.
  6. Run make sim and make pnr.

7.4 Tighten Timing to 100 MHz

Difficulty: Hard

Task: Change the clock period target from 20 ns (50 MHz) to 10 ns (100 MHz) and attempt to close timing.

Steps:

  1. In openlane/config.json, change:
    "CLOCK_PERIOD": 10.0
    Also update the PDK-specific override:
    "scl::sky130_fd_sc_hd": {
        "CLOCK_PERIOD": 10.0
    }
  2. Update CLK_DIV in rtl/reaction_timer_top.v to 1_000_000 (to maintain the same 10 ms tick period: 1,000,000 x 10 ns = 10 ms).
  3. Run make pnr.
  4. 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.
  5. 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?


Appendix A: Pin Definitions

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).


Appendix B: FSM State Transition Table

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_stop has higher priority than the wait-count condition: if both are true simultaneously, the FSM goes to FALSE_START, not TEST.
  • The lfsr_val != 0 guard on the WAIT -> TEST transition prevents an instant transition when the LFSR happens to output zero (which would mean zero delay).
  • The flash_toggle output is not shown in the table because it is generated by a separate sequential block (see Section 3.6). It toggles on each tick pulse while false_start is asserted and resets to 0 otherwise.

Appendix C: Makefile Quick Reference

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 KLayout

Clean start:

make clean       # Remove all generated files
make sim         # Re-run from scratch

Appendix D: Troubleshooting FAQ

D.1 Docker: "Cannot connect to the Docker daemon"

Problem: 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.

D.2 Docker: "no space left on device"

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.

D.3 Docker: Image build fails at PDK download

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.

D.4 Simulation: "Unknown module type"

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.

D.5 Simulation: Tests fail with unexpected signal values

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 x value because rst_n was 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).

D.6 Simulation: "[TIMEOUT] Simulation exceeded maximum time"

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 -DSIMULATION flag is being passed to iverilog (the Makefile does this automatically).
  • The ifdef SIMULATION block in reaction_timer_top.v is setting small values (4) for both parameters.
  • If you modified the code, check that the FSM is not stuck in a state (examine the state signal in waveforms).

D.7 GTKWave: "command not found" or does not open

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.

D.8 OpenLane: Timing violations in the report

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:

  1. Increase CLOCK_PERIOD in config.json.
  2. Reduce FP_CORE_UTIL to give the placer and router more room.
  3. 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.

D.9 OpenLane: DRC violations after routing

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:

  1. Reduce FP_CORE_UTIL from 40 to 30 or 25.
  2. Increase GPL_CELL_PADDING and DPL_CELL_PADDING from 2 to 4.
  3. Allow an additional metal layer by changing RT_MAX_LAYER from met4 to met5.

D.10 KLayout: GDS file does not open or looks empty

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/*.gds

If 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

About

EE2C2 Lab 3 - Digital Integrated Circuits

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors