Practical case: voice-controlled RUN/STOP on ULX3S

Practical case: voice-controlled RUN/STOP on ULX3S — hero

Objective and use case

What you’ll build: A compact FPGA voice-activity burst detector on a Radiona ULX3S (Lattice ECP5-85F) using an INMP441 I2S MEMS microphone. A short, loud spoken burst such as “go” or “stop” flips a workbench status output between RUN and STOP with low-latency, fully local logic.

Why it matters / Use cases

  • Hands-free status control while soldering, probing, or holding parts with both hands occupied.
  • Clear bench signaling: one LED for RUN, one for STOP, plus an activity LED that reacts to detected audio energy.
  • Shared lab indication without a PC, OS, or network stack, keeping response time predictable and typically under 50–100 ms from burst to state change.
  • Practical FPGA training in 24-bit I2S capture, envelope extraction, thresholding, debounce/confirmation timing, and event holdoff using only a small fraction of ECP5 resources.

Expected outcome

  • The FPGA samples 24-bit I2S audio from the INMP441, converts it into a simple amplitude envelope, and flags bursts above a configurable threshold.
  • A short spoken burst near the microphone triggers a state transition only after a confirmation window, reducing false toggles from background noise or bench taps.
  • Three LEDs provide immediate feedback: RUN, STOP, and audio activity, with stable toggle behavior and a configurable holdoff interval between events.
  • Simulation demonstrates silence rejection, burst detection, holdoff timing, and correct RUN/STOP toggling, with practical tuning targets such as sub-100 ms detection latency and low FPGA load.

Audience: Intermediate FPGA learners with basic digital design and command-line tool experience; Level: Intermediate

Architecture/flow: INMP441 I2S microphone → bit-clock/word-select receiver → 24-bit sample capture → absolute-value/envelope measurement → threshold + confirmation counter → holdoff/toggle state machine → RUN/STOP/audio LEDs.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

INMP441 I2S microphone

bit-clock/word-select receiver

24-bit sample capture

absolute-value/envelope measurement

threshold + confirmation counter

holdoff/toggle state machine

RUN/STOP/audio LEDs

Conceptual signal and responsibility flow between device blocks.

Validation path

Source code

Verilator

Yosys

Hardware implementation

Conceptual summary of the tools used to check the published material.

Prerequisites

You should be comfortable with:

  • Basic FPGA concepts:
  • clocks
  • synchronous logic
  • counters
  • state machines
  • Basic Verilog:
  • modules
  • registers and wires
  • always blocks
  • parameters
  • Command-line build tools on Linux
  • USB programming of the ULX3S board

Recommended software:

  • yosys
  • nextpnr-ecp5
  • ecppack
  • openFPGALoader
  • verilator

Important limitation:

  • This project is not speech recognition.
  • It is a simple loud-voice event detector tuned to approximate command-like bursts through threshold, duration, and cooldown rules.
  • It does not identify spoken words reliably in noisy environments.

Materials

Exact hardware

Use exactly:

  • Radiona ULX3S (Lattice ECP5-85F)
  • INMP441 I2S MEMS microphone
  • Status LEDs (on-board or external)

Additional items

  • USB cable for ULX3S programming and power
  • Breadboard jumper wires
  • Optional multimeter or oscilloscope for signal checks
  • A reasonably quiet area for initial tuning

Why this hardware fits

  • The ULX3S ECP5-85F has enough logic for a small audio front-end without vendor IP.
  • The INMP441 exposes a standard I2S digital interface.
  • LEDs provide immediate hardware feedback without extra software.

Setup and connection

INMP441 signals

Typical INMP441 pins:

  • VDD
  • GND
  • SCK or BCLK
  • WS or LRCLK
  • SD
  • L/R

The microphone is typically an I2S slave, so the FPGA must generate:

  • bit clock
  • word select

And the FPGA must sample:

  • serial data

Power and logic levels

The INMP441 uses 3.3 V logic and power. Use only 3.3 V with the microphone.

Connection summary

Function INMP441 pin ULX3S FPGA signal name Direction Notes
Power VDD 3V3 Board -> mic Use 3.3 V only
Ground GND GND Common Shared ground required
Bit clock SCK/BCLK mic_bclk FPGA -> mic Generated by FPGA
Word select WS/LRCLK mic_ws FPGA -> mic Generated by FPGA
Serial data SD mic_sd Mic -> FPGA Sampled by FPGA
Channel select L/R GND or 3V3 Static Select one channel
RUN LED LED led_run FPGA -> LED ON when running
STOP LED LED led_stop FPGA -> LED ON when stopped
Activity LED LED led_activity FPGA -> LED ON during audio activity

Wiring notes

  1. Connect VDD to 3.3 V, not 5 V.
  2. Connect ground between the board and microphone.
  3. Tie L/R to a defined logic level. In this tutorial, use GND to select the left channel.
  4. Keep wires short.
  5. If your LED wiring is active-low, invert in the HDL or constraints to match your hardware.

Chosen I2S format

For this tutorial:

  • FPGA input clock: 25 MHz
  • I2S bit clock: 1.5625 MHz from integer division
  • Word size: 32 bits per channel
  • Sample rate: about 24.414 kHz because 1.5625 MHz / 64 = 24.414 kHz

That sample rate is adequate for a simple voice-activity style detector.


Project files

fpga-voice-led/
├── voice_led_top.v
├── tb_voice_led_top.v
└── ulx3s_voice_led.lpf

Verilog top module

voice_led_top.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

module voice_led_top(
    input  wire clk_25mhz,
    input  wire mic_sd,
    output reg  mic_bclk = 1'b0,
    output reg  mic_ws   = 1'b0,
    output wire led_run,
    output wire led_stop,
    output wire led_activity
);

    reg [3:0] bclk_div = 4'd0;
    reg       bclk_prev = 1'b0;
    reg [5:0] bit_count = 6'd0;
    reg [5:0] slot_bit_index = 6'd0;
    reg [31:0] shift_reg = 32'd0;
    reg [23:0] sample_left = 24'd0;
    reg        sample_strobe = 1'b0;

    reg [31:0] envelope = 32'd0;
    reg        activity = 1'b0;
    reg [15:0] burst_count = 16'd0;
    reg [15:0] holdoff_count = 16'd0;
    reg        run_state = 1'b0;

    wire bclk_rise;
    wire signed [23:0] signed_sample;
    wire [23:0] abs_sample;
    wire [31:0] envelope_next;

    localparam [31:0] ENV_THRESHOLD      = 32'd200000;
    localparam [15:0] BURST_MIN_SAMPLES  = 16'd1200;
    localparam [15:0] BURST_MAX_SAMPLES  = 16'd9000;
    localparam [15:0] HOLDOFF_SAMPLES    = 16'd18000;

    assign bclk_rise = (bclk_prev == 1'b0) && (mic_bclk == 1'b1);
    assign signed_sample = sample_left;
    assign abs_sample = signed_sample[23] ? (~signed_sample + 24'd1) : signed_sample;
    assign envelope_next = envelope - (envelope >> 4) + {8'd0, abs_sample};

    always @(posedge clk_25mhz) begin
        bclk_prev <= mic_bclk;

        if (bclk_div == 4'd7) begin
            bclk_div <= 4'd0;
            mic_bclk <= ~mic_bclk;
        end else begin
            bclk_div <= bclk_div + 4'd1;
        end
    end

    always @(posedge clk_25mhz) begin
        sample_strobe <= 1'b0;

        if (bclk_rise) begin
            if (bit_count == 6'd63) begin
                bit_count <= 6'd0;
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

module voice_led_top(
    input  wire clk_25mhz,
    input  wire mic_sd,
    output reg  mic_bclk = 1'b0,
    output reg  mic_ws   = 1'b0,
    output wire led_run,
    output wire led_stop,
    output wire led_activity
);

    reg [3:0] bclk_div = 4'd0;
    reg       bclk_prev = 1'b0;
    reg [5:0] bit_count = 6'd0;
    reg [5:0] slot_bit_index = 6'd0;
    reg [31:0] shift_reg = 32'd0;
    reg [23:0] sample_left = 24'd0;
    reg        sample_strobe = 1'b0;

    reg [31:0] envelope = 32'd0;
    reg        activity = 1'b0;
    reg [15:0] burst_count = 16'd0;
    reg [15:0] holdoff_count = 16'd0;
    reg        run_state = 1'b0;

    wire bclk_rise;
    wire signed [23:0] signed_sample;
    wire [23:0] abs_sample;
    wire [31:0] envelope_next;

    localparam [31:0] ENV_THRESHOLD      = 32'd200000;
    localparam [15:0] BURST_MIN_SAMPLES  = 16'd1200;
    localparam [15:0] BURST_MAX_SAMPLES  = 16'd9000;
    localparam [15:0] HOLDOFF_SAMPLES    = 16'd18000;

    assign bclk_rise = (bclk_prev == 1'b0) && (mic_bclk == 1'b1);
    assign signed_sample = sample_left;
    assign abs_sample = signed_sample[23] ? (~signed_sample + 24'd1) : signed_sample;
    assign envelope_next = envelope - (envelope >> 4) + {8'd0, abs_sample};

    always @(posedge clk_25mhz) begin
        bclk_prev <= mic_bclk;

        if (bclk_div == 4'd7) begin
            bclk_div <= 4'd0;
            mic_bclk <= ~mic_bclk;
        end else begin
            bclk_div <= bclk_div + 4'd1;
        end
    end

    always @(posedge clk_25mhz) begin
        sample_strobe <= 1'b0;

        if (bclk_rise) begin
            if (bit_count == 6'd63) begin
                bit_count <= 6'd0;
            end else begin
                bit_count <= bit_count + 6'd1;
            end

            if (bit_count == 6'd31) begin
                mic_ws <= 1'b1;
            end else if (bit_count == 6'd63) begin
                mic_ws <= 1'b0;
            end

            if (bit_count == 6'd31 || bit_count == 6'd63) begin
                slot_bit_index <= 6'd0;
            end else begin
                slot_bit_index <= slot_bit_index + 6'd1;
            end

            shift_reg <= {shift_reg[30:0], mic_sd};

            if (mic_ws == 1'b0 && slot_bit_index == 6'd23) begin
                sample_left <= {shift_reg[22:0], mic_sd};
                sample_strobe <= 1'b1;
            end
        end
    end

    always @(posedge clk_25mhz) begin
        if (sample_strobe) begin
            envelope <= envelope_next;
            activity <= (envelope_next > ENV_THRESHOLD);

            if (holdoff_count != 16'd0) begin
                holdoff_count <= holdoff_count - 16'd1;
                burst_count <= 16'd0;
            end else begin
                if (envelope_next > ENV_THRESHOLD) begin
                    if (burst_count != 16'hFFFF) begin
                        burst_count <= burst_count + 16'd1;
                    end
                end else begin
                    if (burst_count >= BURST_MIN_SAMPLES &&
                        burst_count <= BURST_MAX_SAMPLES) begin
                        run_state <= ~run_state;
                        holdoff_count <= HOLDOFF_SAMPLES;
                    end
                    burst_count <= 16'd0;
                end
            end
        end
    end

    assign led_run = run_state;
    assign led_stop = ~run_state;
    assign led_activity = activity;

endmodule


Testbench

tb_voice_led_top.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

`timescale 1ns/1ps

module tb_voice_led_top;

    reg clk_25mhz = 1'b0;
    reg mic_sd = 1'b0;
    wire mic_bclk;
    wire mic_ws;
    wire led_run;
    wire led_stop;
    wire led_activity;

    integer i;
    integer k;
    reg [31:0] slot_word;

    voice_led_top dut (
        .clk_25mhz(clk_25mhz),
        .mic_sd(mic_sd),
        .mic_bclk(mic_bclk),
        .mic_ws(mic_ws),
        .led_run(led_run),
        .led_stop(led_stop),
        .led_activity(led_activity)
    );

    always #20 clk_25mhz = ~clk_25mhz;

    task send_i2s_left_sample;
        input [23:0] s;
        begin
            while (mic_ws !== 1'b0) begin
                @(posedge mic_bclk);
            end

            slot_word = {s, 8'h00};

            for (i = 31; i >= 0; i = i - 1) begin
                @(negedge mic_bclk);
                mic_sd = slot_word[i];
            end

            while (mic_ws !== 1'b1) begin
                @(posedge mic_bclk);
            end

            for (i = 31; i >= 0; i = i - 1) begin
                @(negedge mic_bclk);
                mic_sd = 1'b0;
            end
        end
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

`timescale 1ns/1ps

module tb_voice_led_top;

    reg clk_25mhz = 1'b0;
    reg mic_sd = 1'b0;
    wire mic_bclk;
    wire mic_ws;
    wire led_run;
    wire led_stop;
    wire led_activity;

    integer i;
    integer k;
    reg [31:0] slot_word;

    voice_led_top dut (
        .clk_25mhz(clk_25mhz),
        .mic_sd(mic_sd),
        .mic_bclk(mic_bclk),
        .mic_ws(mic_ws),
        .led_run(led_run),
        .led_stop(led_stop),
        .led_activity(led_activity)
    );

    always #20 clk_25mhz = ~clk_25mhz;

    task send_i2s_left_sample;
        input [23:0] s;
        begin
            while (mic_ws !== 1'b0) begin
                @(posedge mic_bclk);
            end

            slot_word = {s, 8'h00};

            for (i = 31; i >= 0; i = i - 1) begin
                @(negedge mic_bclk);
                mic_sd = slot_word[i];
            end

            while (mic_ws !== 1'b1) begin
                @(posedge mic_bclk);
            end

            for (i = 31; i >= 0; i = i - 1) begin
                @(negedge mic_bclk);
                mic_sd = 1'b0;
            end
        end
    endtask

    task send_silence;
        input integer n;
        begin
            for (k = 0; k < n; k = k + 1) begin
                send_i2s_left_sample(24'd0);
            end
        end
    endtask

    task send_burst;
        input integer n;
        begin
            for (k = 0; k < n; k = k + 1) begin
                if (k[0]) begin
                    send_i2s_left_sample(24'h180000);
                end else begin
                    send_i2s_left_sample(24'hE80000);
                end
            end
        end
    endtask

    initial begin
        $display("Starting simulation");

        send_silence(3000);
        $display("Initial state: led_run=%0d led_stop=%0d led_activity=%0d",
                 led_run, led_stop, led_activity);

        send_burst(2000);
        send_silence(3000);
        $display("After burst 1: led_run=%0d led_stop=%0d led_activity=%0d",
                 led_run, led_stop, led_activity);

        send_burst(1500);
        send_silence(4000);
        $display("After burst 2 during holdoff: led_run=%0d led_stop=%0d led_activity=%0d",
                 led_run, led_stop, led_activity);

        send_silence(20000);
        send_burst(2000);
        send_silence(3000);
        $display("After burst 3: led_run=%0d led_stop=%0d led_activity=%0d",
                 led_run, led_stop, led_activity);

        $finish;
    end

endmodule


Constraints

ulx3s_voice_led.lpf

Use FPGA pin locations that match your exact ULX3S board revision and the external header pins you actually wired. The example below is syntactically complete, but the SITE values must match your physical board wiring before hardware programming.

BLOCK RESETPATHS;
BLOCK ASYNCPATHS;

FREQUENCY PORT "clk_25mhz" 25.0 MHz;

LOCATE COMP "clk_25mhz" SITE "G2";
IOBUF PORT "clk_25mhz" IO_TYPE=LVCMOS33;

LOCATE COMP "mic_bclk" SITE "B11";
IOBUF PORT "mic_bclk" IO_TYPE=LVCMOS33 DRIVE=8;

LOCATE COMP "mic_ws" SITE "A10";
IOBUF PORT "mic_ws" IO_TYPE=LVCMOS33 DRIVE=8;

LOCATE COMP "mic_sd" SITE "B10";
IOBUF PORT "mic_sd" IO_TYPE=LVCMOS33;

LOCATE COMP "led_run" SITE "K4";
IOBUF PORT "led_run" IO_TYPE=LVCMOS33 DRIVE=8;

LOCATE COMP "led_stop" SITE "M3";
IOBUF PORT "led_stop" IO_TYPE=LVCMOS33 DRIVE=8;

LOCATE COMP "led_activity" SITE "J3";
IOBUF PORT "led_activity" IO_TYPE=LVCMOS33 DRIVE=8;

Build and run

Create a build directory first:

mkdir -p build

1) Lint the design

verilator --lint-only -Wall -Wno-DECLFILENAME voice_led_top.v tb_voice_led_top.v

2) Run the testbench

verilator -Wall -Wno-DECLFILENAME --binary tb_voice_led_top.v voice_led_top.v
./obj_dir/Vtb_voice_led_top

3) Synthesize for ECP5

yosys -p "read_verilog voice_led_top.v; synth_ecp5 -top voice_led_top -json build/voice_led_top.json"

4) Place and route

nextpnr-ecp5 \
  --85k \
  --json build/voice_led_top.json \
  --lpf ulx3s_voice_led.lpf \
  --textcfg build/voice_led_top.config

5) Pack the bitstream

ecppack build/voice_led_top.config build/voice_led_top.bit

6) Program the board

openFPGALoader -b ulx3s build/voice_led_top.bit

Validation method

This project makes only a limited hardware behavior claim: that the design can detect a strong, short audio burst and toggle LEDs under suitable threshold and timing settings.

Validation procedure

Use this method to validate the claim:

  1. Static validation
  2. Run Verilator lint.
  3. Evidence: no syntax or elaboration errors.

  4. Behavioral validation

  5. Run the provided testbench.
  6. Evidence:

    • startup shows led_run=0 led_stop=1
    • first qualified burst toggles to led_run=1 led_stop=0
    • second burst during holdoff does not toggle
    • third burst after holdoff toggles back
  7. Implementation validation

  8. Run Yosys, nextpnr-ecp5, and ecppack.
  9. Evidence:

    • JSON netlist created
    • place-and-route completes
    • bitstream generated successfully
  10. Hardware validation

  11. Program the ULX3S.
  12. Speak a short, loud burst near the microphone.
  13. Evidence:
    • led_activity flashes during speaking
    • led_run and led_stop toggle only after a burst with acceptable duration
    • immediate repeated bursts inside holdoff do not toggle the state

Expected evidence

Expected simulation console output pattern:

  • Initial state: led_run=0 led_stop=1
  • After burst 1: led_run=1 led_stop=0
  • After burst 2 during holdoff: led_run=1 led_stop=0
  • After burst 3: led_run=0 led_stop=1

Hardware evidence should be direct visual LED behavior consistent with the above logic.


Hardware bring-up

Test A: confirm generated clocks

If you have a scope or logic analyzer:

  • Check that mic_bclk is active
  • Check that mic_ws toggles slower than mic_bclk

Test B: silence baseline

With a quiet room:

  • led_activity should stay mostly OFF
  • RUN/STOP state LEDs should remain stable

Test C: short spoken burst

Speak close to the microphone:

  • led_activity should flash during the burst
  • a qualifying burst should toggle RUN/STOP

Test D: holdoff behavior

Speak again immediately:

  • led_activity may flash
  • RUN/STOP should not toggle during holdoff

Test E: post-holdoff behavior

Wait about a second, then speak again:

  • the state should toggle again

Tuning

If the detector is too sensitive or not sensitive enough, adjust these constants in voice_led_top.v:

  • ENV_THRESHOLD
  • increase if noise triggers activity
  • decrease if speech is not detected
  • BURST_MIN_SAMPLES
  • decrease if short bursts are ignored
  • increase if taps or clicks trigger toggles
  • BURST_MAX_SAMPLES
  • decrease if long background sounds trigger toggles
  • increase if your spoken bursts are longer
  • HOLDOFF_SAMPLES
  • increase to suppress repeated toggles
  • decrease if the interface feels too slow

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this FPGA/ULX3S profile, the synthesizable Verilog blocks were checked with Yosys (read_verilog) and the Verilog design/test set was linted with Verilator. The validator also checked code-block structure, copy/paste-safe ASCII command options, unsupported stacks, and availability of the ULX3S/ECP5 toolchain (yosys, nextpnr-ecp5, ecppack, openFPGALoader).

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ULX3S board revision, pin-constraint file and real wiring.

Educational safety note

Educational safety note

This project is an educational low-voltage FPGA audio experiment. Do not use it to control hazardous machinery, mains voltage, heaters, motors, medical devices, or any safety-critical system. Voice/noise detectors can false-trigger from speech, taps, fans, music, or other sounds. If you later add relays or power drivers, use proper isolation and driver circuitry.


Troubleshooting

No LEDs respond

Check:

  1. The board programmed successfully
  2. clk_25mhz matches the actual ULX3S clock pin
  3. LED pins match your hardware
  4. The LPF matches your board revision

led_activity always OFF

Possible causes:

  • microphone not powered
  • wrong mic_sd wiring
  • missing mic_bclk or mic_ws
  • threshold too high

Actions:

  • verify 3.3 V at the microphone
  • verify common ground
  • probe mic_bclk and mic_ws
  • lower ENV_THRESHOLD

led_activity always ON

Possible causes:

  • floating mic_sd
  • poor grounding
  • threshold too low
  • incorrect I2S timing

Actions:

  • shorten wires
  • secure ground
  • raise ENV_THRESHOLD
  • confirm L/R is tied to a valid level

Activity works, but RUN/STOP never toggles

This usually means burst timing is outside the accepted window.

Actions:

  • lower BURST_MIN_SAMPLES
  • raise BURST_MAX_SAMPLES
  • try shorter, more consistent spoken bursts

nextpnr-ecp5 fails

This is usually a constraints issue.

Actions:

  • verify the ULX3S pin map
  • move signals to legal I/O pins
  • update the LPF to your actual board revision and chosen header pins

Improvements

Possible extensions:

  1. Add a pushbutton override input
  2. Add UART debug output for envelope and state changes
  3. Improve the envelope detector with averaging or peak-decay logic
  4. Detect different burst patterns instead of simple toggling
  5. Add a transistor or MOSFET driver for larger low-voltage indicators

Do not connect FPGA pins directly to high-current loads.


Final checklist

  • [ ] I used a Radiona ULX3S (Lattice ECP5-85F) with an INMP441 I2S MEMS microphone
  • [ ] The microphone is powered from 3.3 V
  • [ ] Grounds are shared
  • [ ] L/R is tied to a defined logic level
  • [ ] My LPF matches my actual ULX3S wiring
  • [ ] Verilator lint completed without fatal errors
  • [ ] The testbench showed the expected toggle behavior
  • [ ] Yosys synthesis completed successfully
  • [ ] nextpnr-ecp5 completed successfully for --85k
  • [ ] The bitstream programmed with openFPGALoader -b ulx3s
  • [ ] led_activity responds to nearby speech or loud sound bursts
  • [ ] led_run and led_stop toggle only on qualified bursts
  • [ ] I tuned the threshold and timing constants for my setup

If all items pass, you have a practical ULX3S FPGA project for I2S audio capture and simple burst-triggered LED control.

        <div class="amazon-affiliate">
          <p><strong>Find this product and/or books on this topic on Amazon</strong></p>
          <p><a class="amazon-affiliate-btn" href="https://amzn.to/4mt8r4C" target="_blank" rel="nofollow sponsored noopener">Go to Amazon</a></p>
          <p class="amazon-affiliate-disclaimer">As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.</p>
        </div>

Quick Quiz

Question 1: What is the primary hardware platform used for this project?




Question 2: Which specific microphone is used in this FPGA voice-activity burst detector?




Question 3: What is the main purpose of the voice-activity burst detector in this project?




Question 4: What is the typical response time (latency) from a spoken burst to a state change?




Question 5: Why is a confirmation window used in the burst detection logic?




Question 6: How many bits does the FPGA use to sample the I2S audio from the microphone?




Question 7: What type of visual feedback is provided by the system?




Question 8: What is one of the practical use cases mentioned for this hands-free status control?




Question 9: What operating system is required for this voice-activity detector to function?




Question 10: What is the target audience level for this FPGA project?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: NMEA GPS Monitor on ULX3S

Practical case: NMEA GPS Monitor on ULX3S — hero

Objective and use case

What you’ll build: A practical FPGA-based GPS monitor using the Radiona ULX3S (Lattice ECP5-85F), a u-blox NEO-6M GPS module, and 3.3 V UART wiring. It will receive NMEA data at 9600 baud, parse time and position sentences with sub-second update latency, and display UART activity, fix status, and key state changes on the ULX3S LEDs.

Why it matters / Use cases

  • GPS module bench verification: Quickly confirm a NEO-6M is powered, transmitting valid NMEA sentences, and responding at 9600 baud without opening a PC serial terminal.
  • Portable installation diagnostics: Use USB power to check fix progress, live UART traffic, and changing coordinates in the field before attaching the final host system; typical visible status refresh is 1 Hz in line with common NMEA output.
  • Digital design training: Demonstrates real FPGA handling of asynchronous UART reception, ASCII stream parsing, and sentence validation instead of a simple loopback demo.
  • Standalone serial monitor prototype: Creates a compact gps-nmea-position-time-monitor for timing, tracker, and navigation bring-up with very low FPGA load, typically well under 5% logic and effectively 0% GPU usage.

Expected outcome

  • A working ULX3S design that reliably receives 3.3 V UART NMEA data from the NEO-6M at 9600 baud.
  • Parsed UTC time and basic position fields from common sentences such as GPRMC or GPGGA, with LED-visible response within one sentence period.
  • Status indication for no data, active serial traffic, sentence reception, and GPS fix presence, useful for fast bench testing.
  • A reusable FPGA reference for low-bandwidth serial parsing workloads where throughput is tiny but deterministic hardware behavior matters.

Audience: FPGA learners, embedded developers, and technicians validating GPS hardware; Level: Beginner to intermediate

Architecture/flow: NEO-6M outputs NMEA over 3.3 V UART → ULX3S UART receiver samples serial bytes with bit-timed logic → parser extracts time, fix, and coordinate fields from ASCII sentences → state logic updates LEDs at roughly 1 Hz sentence cadence with millisecond-scale internal processing latency.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

NEO-6M outputs NMEA over 3.3 V UART

ULX3S UART receiver samples serial bytes…

parser extracts time, fix, and coordinate…

state logic updates LEDs at roughly 1 Hz…

Conceptual signal and responsibility flow between device blocks.

Validation path

Source code

Verilator

Yosys

Hardware implementation

Conceptual summary of the tools used to check the published material.

Prerequisites

Before starting, you should be comfortable with:

  • Basic FPGA workflow from command line
  • Simple Verilog modules and synchronous design
  • UART concepts:
  • baud rate
  • start bit
  • stop bit
  • 8N1 framing
  • Editing text files and running Linux shell commands

Recommended host environment:

  • Linux PC or laptop
  • USB cable for ULX3S programming/power
  • Optional USB-UART adapter if you want to inspect GPS output independently before connecting it to the FPGA

Required software tools:

  • yosys
  • nextpnr-ecp5
  • ecppack
  • openFPGALoader
  • verilator

Materials

Use exactly these hardware items:

Item Exact model Purpose
FPGA board Radiona ULX3S (Lattice ECP5-85F) Main FPGA platform
GPS module u-blox NEO-6M GPS module NMEA UART data source
Serial voltage level 3.3 V UART wiring Safe direct logic-level connection
USB cable Micro-USB or USB-C depending on ULX3S revision Power and programming
Jumper wires Female-to-female or mixed as needed Connections between ULX3S and NEO-6M
Computer Linux host Build, program, and optional serial checks

Important model-specific note

Many NEO-6M breakout boards are powered from 5 V but still expose 3.3 V logic-level TX. You must verify your specific module. This tutorial assumes:

  • GPS module VCC is powered according to the breakout board requirement
  • GPS TX output presented to the FPGA is 3.3 V compatible
  • Direct UART connection is made only through 3.3 V UART wiring

Setup/Connection

No circuit drawing is used here; follow the text exactly.

Signal plan

This project needs only three essential electrical connections:

  1. Common ground
  2. GPS TX -> ULX3S FPGA input
  3. Power for the GPS module

Recommended practical connection scheme

  • Connect NEO-6M GND to ULX3S GND
  • Connect NEO-6M TX to a chosen ULX3S GPIO input pin
  • Power the GPS module from a suitable source:
  • If your NEO-6M breakout accepts 5 V on VCC, you may power it from a safe 5 V source, while still ensuring TX seen by FPGA is 3.3 V logic
  • If your breakout requires 3.3 V VCC, power it from a regulated 3.3 V rail
  • Do not connect GPS RX unless you specifically want to send configuration commands later; it is not required for this monitor

Pin choice used in this tutorial

To keep the design concrete, the FPGA top-level uses:

  • clk_25mhz as the system clock
  • gps_rx_i as the UART input from the GPS module
  • led[7:0] as output indicators

For the ULX3S, actual package pin names vary by board constraint set. The safest workflow is:

  1. Start from your ULX3S board’s known-good constraint template
  2. Replace only the signals used here
  3. Keep the oscillator and LED pins matched to your board revision

In the validated example below, a constraint file is provided in the style expected by nextpnr-ecp5. If your exact ULX3S revision has different aliases, adjust only the LOCATE COMP pin names using the official ULX3S pinout.

LED meaning used by this project

  • led[0]: heartbeat, proves FPGA is running
  • led[1]: UART character activity pulse
  • led[2]: valid NMEA line completed
  • led[3]: valid RMC sentence detected
  • led[4]: RMC status = A (active fix)
  • led[5]: toggles when time field updates
  • led[6]: toggles when latitude field updates
  • led[7]: toggles when longitude field updates

This gives useful field evidence without needing a display.

Validated Code

gps_uart_rx.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

module gps_uart_rx #(
    parameter integer CLK_HZ = 25000000,
    parameter integer BAUD   = 9600
) (
    input  wire clk,
    input  wire rst,
    input  wire rx,
    output reg  data_valid,
    output reg [7:0] data_byte
);

    localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;
    localparam integer HALF_BIT     = CLKS_PER_BIT / 2;

    reg [15:0] clk_count = 0;
    reg [3:0]  bit_index = 0;
    reg [7:0]  rx_shift  = 8'h00;
    reg [2:0]  state     = 0;
    reg        rx_meta   = 1'b1;
    reg        rx_sync   = 1'b1;

    localparam S_IDLE  = 3'd0;
    localparam S_START = 3'd1;
    localparam S_DATA  = 3'd2;
    localparam S_STOP  = 3'd3;

    always @(posedge clk) begin
        rx_meta <= rx;
        rx_sync <= rx_meta;
    end

    always @(posedge clk) begin
        if (rst) begin
            state      <= S_IDLE;
            clk_count  <= 0;
            bit_index  <= 0;
            rx_shift   <= 8'h00;
            data_byte  <= 8'h00;
            data_valid <= 1'b0;
        end else begin
            data_valid <= 1'b0;

            case (state)
                S_IDLE: begin
                    clk_count <= 0;
                    bit_index <= 0;
                    if (rx_sync == 1'b0) begin
                        state <= S_START;
                    end
                end
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

module gps_uart_rx #(
    parameter integer CLK_HZ = 25000000,
    parameter integer BAUD   = 9600
) (
    input  wire clk,
    input  wire rst,
    input  wire rx,
    output reg  data_valid,
    output reg [7:0] data_byte
);

    localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;
    localparam integer HALF_BIT     = CLKS_PER_BIT / 2;

    reg [15:0] clk_count = 0;
    reg [3:0]  bit_index = 0;
    reg [7:0]  rx_shift  = 8'h00;
    reg [2:0]  state     = 0;
    reg        rx_meta   = 1'b1;
    reg        rx_sync   = 1'b1;

    localparam S_IDLE  = 3'd0;
    localparam S_START = 3'd1;
    localparam S_DATA  = 3'd2;
    localparam S_STOP  = 3'd3;

    always @(posedge clk) begin
        rx_meta <= rx;
        rx_sync <= rx_meta;
    end

    always @(posedge clk) begin
        if (rst) begin
            state      <= S_IDLE;
            clk_count  <= 0;
            bit_index  <= 0;
            rx_shift   <= 8'h00;
            data_byte  <= 8'h00;
            data_valid <= 1'b0;
        end else begin
            data_valid <= 1'b0;

            case (state)
                S_IDLE: begin
                    clk_count <= 0;
                    bit_index <= 0;
                    if (rx_sync == 1'b0) begin
                        state <= S_START;
                    end
                end

                S_START: begin
                    if (clk_count == HALF_BIT) begin
                        if (rx_sync == 1'b0) begin
                            clk_count <= 0;
                            state <= S_DATA;
                        end else begin
                            state <= S_IDLE;
                        end
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                S_DATA: begin
                    if (clk_count == CLKS_PER_BIT - 1) begin
                        clk_count <= 0;
                        rx_shift[bit_index] <= rx_sync;
                        if (bit_index == 4'd7) begin
                            bit_index <= 0;
                            state <= S_STOP;
                        end else begin
                            bit_index <= bit_index + 4'd1;
                        end
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                S_STOP: begin
                    if (clk_count == CLKS_PER_BIT - 1) begin
                        clk_count <= 0;
                        if (rx_sync == 1'b1) begin
                            data_byte <= rx_shift;
                            data_valid <= 1'b1;
                        end
                        state <= S_IDLE;
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                default: begin
                    state <= S_IDLE;
                end
            endcase
        end
    end
endmodule

gps_nmea_monitor.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

module gps_nmea_monitor (
    input  wire clk_25mhz,
    input  wire gps_rx_i,
    output wire [7:0] led
);

    wire rx_valid;
    wire [7:0] rx_byte;

    reg rst = 1'b0;

    gps_uart_rx #(
        .CLK_HZ(25000000),
        .BAUD(9600)
    ) u_rx (
        .clk(clk_25mhz),
        .rst(rst),
        .rx(gps_rx_i),
        .data_valid(rx_valid),
        .data_byte(rx_byte)
    );

    reg [23:0] hb_counter = 24'd0;
    reg hb_led = 1'b0;

    reg [19:0] pulse_activity = 20'd0;
    reg [19:0] pulse_line     = 20'd0;
    reg [19:0] pulse_rmc      = 20'd0;

    reg fix_active = 1'b0;
    reg time_toggle = 1'b0;
    reg lat_toggle  = 1'b0;
    reg lon_toggle  = 1'b0;

    reg [7:0] line_pos = 8'd0;
    reg [7:0] field_pos = 8'd0;

    reg in_line = 1'b0;
    reg candidate_rmc = 1'b0;
    reg rmc_seen_this_line = 1'b0;

    reg [7:0] id_buf [0:4];
    reg [7:0] field_buf [0:15];
    reg [4:0] field_len = 5'd0;

    integer i;

    always @(posedge clk_25mhz) begin
        hb_counter <= hb_counter + 24'd1;
        hb_led <= hb_counter[23];

        if (pulse_activity != 0) pulse_activity <= pulse_activity - 20'd1;
        if (pulse_line != 0)     pulse_line     <= pulse_line - 20'd1;
        if (pulse_rmc != 0)      pulse_rmc      <= pulse_rmc - 20'd1;

        if (rx_valid) begin
            pulse_activity <= 20'd500000;

            if (rx_byte == "$") begin
                in_line <= 1'b1;
                line_pos <= 8'd0;
                field_pos <= 8'd0;
                field_len <= 5'd0;
                candidate_rmc <= 1'b0;
                rmc_seen_this_line <= 1'b0;
                fix_active <= fix_active;
            end else if (in_line) begin
                if (rx_byte == 8'h0D) begin
                    in_line <= 1'b1;
                end else if (rx_byte == 8'h0A) begin
                    pulse_line <= 20'd500000;
                    if (rmc_seen_this_line) begin
                        pulse_rmc <= 20'd500000;
                    end
                    in_line <= 1'b0;
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

module gps_nmea_monitor (
    input  wire clk_25mhz,
    input  wire gps_rx_i,
    output wire [7:0] led
);

    wire rx_valid;
    wire [7:0] rx_byte;

    reg rst = 1'b0;

    gps_uart_rx #(
        .CLK_HZ(25000000),
        .BAUD(9600)
    ) u_rx (
        .clk(clk_25mhz),
        .rst(rst),
        .rx(gps_rx_i),
        .data_valid(rx_valid),
        .data_byte(rx_byte)
    );

    reg [23:0] hb_counter = 24'd0;
    reg hb_led = 1'b0;

    reg [19:0] pulse_activity = 20'd0;
    reg [19:0] pulse_line     = 20'd0;
    reg [19:0] pulse_rmc      = 20'd0;

    reg fix_active = 1'b0;
    reg time_toggle = 1'b0;
    reg lat_toggle  = 1'b0;
    reg lon_toggle  = 1'b0;

    reg [7:0] line_pos = 8'd0;
    reg [7:0] field_pos = 8'd0;

    reg in_line = 1'b0;
    reg candidate_rmc = 1'b0;
    reg rmc_seen_this_line = 1'b0;

    reg [7:0] id_buf [0:4];
    reg [7:0] field_buf [0:15];
    reg [4:0] field_len = 5'd0;

    integer i;

    always @(posedge clk_25mhz) begin
        hb_counter <= hb_counter + 24'd1;
        hb_led <= hb_counter[23];

        if (pulse_activity != 0) pulse_activity <= pulse_activity - 20'd1;
        if (pulse_line != 0)     pulse_line     <= pulse_line - 20'd1;
        if (pulse_rmc != 0)      pulse_rmc      <= pulse_rmc - 20'd1;

        if (rx_valid) begin
            pulse_activity <= 20'd500000;

            if (rx_byte == "$") begin
                in_line <= 1'b1;
                line_pos <= 8'd0;
                field_pos <= 8'd0;
                field_len <= 5'd0;
                candidate_rmc <= 1'b0;
                rmc_seen_this_line <= 1'b0;
                fix_active <= fix_active;
            end else if (in_line) begin
                if (rx_byte == 8'h0D) begin
                    in_line <= 1'b1;
                end else if (rx_byte == 8'h0A) begin
                    pulse_line <= 20'd500000;
                    if (rmc_seen_this_line) begin
                        pulse_rmc <= 20'd500000;
                    end
                    in_line <= 1'b0;
                end else if (rx_byte == ",") begin
                    if (field_pos == 8'd0) begin
                        if ((id_buf[0] == "G") &&
                            (id_buf[1] == "P" || id_buf[1] == "N") &&
                            (id_buf[2] == "R") &&
                            (id_buf[3] == "M") &&
                            (id_buf[4] == "C")) begin
                            candidate_rmc <= 1'b1;
                            rmc_seen_this_line <= 1'b1;
                        end
                    end else if (candidate_rmc) begin
                        if (field_pos == 8'd1 && field_len != 0) begin
                            time_toggle <= ~time_toggle;
                        end
                        if (field_pos == 8'd2 && field_len != 0) begin
                            if (field_buf[0] == "A")
                                fix_active <= 1'b1;
                            else
                                fix_active <= 1'b0;
                        end
                        if (field_pos == 8'd3 && field_len != 0) begin
                            lat_toggle <= ~lat_toggle;
                        end
                        if (field_pos == 8'd5 && field_len != 0) begin
                            lon_toggle <= ~lon_toggle;
                        end
                    end

                    field_pos <= field_pos + 8'd1;
                    field_len <= 5'd0;
                end else if (rx_byte == "*") begin
                    if (candidate_rmc) begin
                        if (field_pos == 8'd1 && field_len != 0) begin
                            time_toggle <= ~time_toggle;
                        end
                        if (field_pos == 8'd2 && field_len != 0) begin
                            if (field_buf[0] == "A")
                                fix_active <= 1'b1;
                            else
                                fix_active <= 1'b0;
                        end
                        if (field_pos == 8'd3 && field_len != 0) begin
                            lat_toggle <= ~lat_toggle;
                        end
                        if (field_pos == 8'd5 && field_len != 0) begin
                            lon_toggle <= ~lon_toggle;
                        end
                    end
                end else begin
                    if (field_pos == 8'd0) begin
                        if (line_pos < 8'd5) begin
                            id_buf[line_pos] <= rx_byte;
                        end
                        line_pos <= line_pos + 8'd1;
                    end else begin
                        if (field_len < 5'd16) begin
                            field_buf[field_len] <= rx_byte;
                            field_len <= field_len + 5'd1;
                        end
                    end
                end
            end
        end
    end

    assign led[0] = hb_led;
    assign led[1] = (pulse_activity != 0);
    assign led[2] = (pulse_line != 0);
    assign led[3] = (pulse_rmc != 0);
    assign led[4] = fix_active;
    assign led[5] = time_toggle;
    assign led[6] = lat_toggle;
    assign led[7] = lon_toggle;

endmodule

tb_gps_nmea_monitor.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

`timescale 1ns/1ps

module tb_gps_nmea_monitor;

    reg clk = 1'b0;
    reg gps_rx_i = 1'b1;
    wire [7:0] led;

    gps_nmea_monitor dut (
        .clk_25mhz(clk),
        .gps_rx_i(gps_rx_i),
        .led(led)
    );

    always #20 clk = ~clk; // 25 MHz

    localparam integer BIT_NS = 104166; // approx 9600 baud

    task uart_send_byte;
        input [7:0] b;
        integer i;
        begin
            gps_rx_i = 1'b0;
            #(BIT_NS);
            for (i = 0; i < 8; i = i + 1) begin
                gps_rx_i = b[i];
                #(BIT_NS);
            end
            gps_rx_i = 1'b1;
            #(BIT_NS);
        end
    endtask

    task uart_send_string;
        input [8*96-1:0] s;
        integer i;
        reg [7:0] ch;
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

`timescale 1ns/1ps

module tb_gps_nmea_monitor;

    reg clk = 1'b0;
    reg gps_rx_i = 1'b1;
    wire [7:0] led;

    gps_nmea_monitor dut (
        .clk_25mhz(clk),
        .gps_rx_i(gps_rx_i),
        .led(led)
    );

    always #20 clk = ~clk; // 25 MHz

    localparam integer BIT_NS = 104166; // approx 9600 baud

    task uart_send_byte;
        input [7:0] b;
        integer i;
        begin
            gps_rx_i = 1'b0;
            #(BIT_NS);
            for (i = 0; i < 8; i = i + 1) begin
                gps_rx_i = b[i];
                #(BIT_NS);
            end
            gps_rx_i = 1'b1;
            #(BIT_NS);
        end
    endtask

    task uart_send_string;
        input [8*96-1:0] s;
        integer i;
        reg [7:0] ch;
        begin
            for (i = 95; i >= 0; i = i - 1) begin
                ch = s[i*8 +: 8];
                if (ch != 8'h00)
                    uart_send_byte(ch);
            end
        end
    endtask

    initial begin
        #(1000000);

        uart_send_string({
            "$GPRMC,123519,V,4807.038,N,01131.000,E,0.0,0.0,230394,003.1,W*53",
            8'h0D, 8'h0A
        });

        #(2000000);

        uart_send_string({
            "$GPRMC,123520,A,4807.038,N,01131.000,E,0.1,0.0,230394,003.1,W*52",
            8'h0D, 8'h0A
        });

        #(5000000);

        $display("LED state = %b", led);
        if (led[4] !== 1'b1) begin
            $display("ERROR: fix_active LED did not assert");
            $fatal;
        end

        $display("PASS: RMC monitor parsed active fix.");
        $finish;
    end

endmodule

ulx3s_gps_nmea.lpf

Adjust the exact LOCATE COMP pin names if your ULX3S revision differs. Keep the signal names unchanged.

BLOCK RESETPATHS;
BLOCK ASYNCPATHS;

FREQUENCY PORT "clk_25mhz" 25 MHZ;

LOCATE COMP "clk_25mhz" SITE "G2";

LOCATE COMP "gps_rx_i" SITE "P17";
IOBUF PORT "gps_rx_i" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "led[0]" SITE "B2";
LOCATE COMP "led[1]" SITE "C2";
LOCATE COMP "led[2]" SITE "C1";
LOCATE COMP "led[3]" SITE "D2";
LOCATE COMP "led[4]" SITE "D1";
LOCATE COMP "led[5]" SITE "E2";
LOCATE COMP "led[6]" SITE "E1";
LOCATE COMP "led[7]" SITE "F2";

IOBUF PORT "led[0]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[1]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[2]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[3]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[4]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[5]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[6]" IO_TYPE=LVCMOS33;
IOBUF PORT "led[7]" IO_TYPE=LVCMOS33;

Build/Flash/Run commands

Create a working directory and place the four files there.

1) Lint with Verilator

verilator --lint-only -Wall -Wno-DECLFILENAME gps_uart_rx.v gps_nmea_monitor.v tb_gps_nmea_monitor.v

2) Run simulation

verilator -Wall -Wno-DECLFILENAME --binary gps_uart_rx.v gps_nmea_monitor.v tb_gps_nmea_monitor.v
./obj_dir/Vtb_gps_nmea_monitor

Expected final console line should include:

PASS: RMC monitor parsed active fix.

3) Synthesize for ECP5-85F

Important: synthesis must use only synthesizable files.

yosys -p "read_verilog gps_uart_rx.v gps_nmea_monitor.v; synth_ecp5 -top gps_nmea_monitor -json gps_nmea_monitor.json"

4) Place and route

Use the correct ULX3S package for your board revision. A common ECP5-85F ULX3S target is CABGA381.

nextpnr-ecp5 --85k --package CABGA381 --json gps_nmea_monitor.json --lpf ulx3s_gps_nmea.lpf --textcfg gps_nmea_monitor.config

5) Pack bitstream

ecppack gps_nmea_monitor.config gps_nmea_monitor.bit

6) Program the ULX3S

openFPGALoader -b ulx3s gps_nmea_monitor.bit

7) Run on hardware

  • Power the ULX3S over USB
  • Power the NEO-6M properly
  • Connect:
  • GPS GND -> ULX3S GND
  • GPS TX -> ULX3S gps_rx_i pin used in the LPF
  • Place the GPS where satellite reception is possible:
  • outdoors is best
  • near a clear window may work
  • Watch the LEDs for 10 to 60 seconds

Step-by-step Validation

1) Validate the GPS module independently if needed

Before involving the FPGA, it is often useful to confirm that the GPS is emitting NMEA data:

  • Connect the NEO-6M TX to a known-good USB-UART adapter input
  • Open a serial terminal at 9600
  • Look for lines such as:
  • $GPRMC,...
  • $GPGGA,...

If you do not see readable NMEA text, fix that first.

2) Validate simulation behavior

After running the Verilator simulation:

  • Confirm the test exits with PASS
  • Confirm no fatal errors appear
  • The simulation injects:
  • one invalid-status RMC line (V)
  • one active-status RMC line (A)
  • The expected result is that:
  • UART logic receives bytes
  • parser detects RMC
  • led[4] becomes 1

3) Validate FPGA configuration

After openFPGALoader:

  • Confirm the tool reports the ULX3S device was found
  • Confirm no bitstream loading error is shown
  • After programming:
  • led[0] should blink as heartbeat
  • If heartbeat does not blink, the FPGA image is not running correctly

4) Validate UART activity in hardware

With GPS connected and powered:

  • led[1] should pulse or appear frequently active when NMEA characters are arriving
  • led[2] should pulse as full lines terminate
  • led[3] should pulse when RMC sentences are seen

Interpretation:

  • led[1] off all the time:
  • wiring issue
  • wrong pin mapping
  • wrong voltage level
  • wrong baud rate
  • GPS not powered
  • led[1] active but led[3] never active:
  • parser not seeing RMC
  • serial corruption
  • unexpected talker/message format

5) Validate fix indication

Observe led[4]:

  • led[4] = 0 means the last parsed RMC status was not active (V) or no valid active line has been seen yet
  • led[4] = 1 means an RMC sentence with status A has been parsed

This is the core success criterion for a useful GPS monitor.

6) Validate ongoing field updates

Observe the update indicators:

  • led[5] toggles when time field updates
  • led[6] toggles when latitude field updates
  • led[7] toggles when longitude field updates

If these change over time while led[3] pulses, the FPGA is parsing key position/time fields rather than merely detecting raw UART traffic.

7) Realistic expected behavior

In a practical session:

  • Indoors without view of sky:
  • UART activity usually appears
  • RMC may be present
  • fix may remain invalid for a long time
  • Outdoors:
  • active fix usually becomes much more likely
  • led[4] should eventually turn on
  • field toggles should continue

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this FPGA/ULX3S profile, the synthesizable Verilog blocks were checked with Yosys (read_verilog) and the Verilog design/test set was linted with Verilator. The validator also checked code-block structure, copy/paste-safe ASCII command options, unsupported stacks, and availability of the ULX3S/ECP5 toolchain (yosys, nextpnr-ecp5, ecppack, openFPGALoader).

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ULX3S board revision, pin-constraint file and real wiring.

Educational safety note

This prototype is an educational GPS data monitor, not a certified navigation, timing, vehicle, aviation, marine, industrial, or safety-critical instrument.

Safety and limitation points:

  • Use only 3.3 V UART wiring to the FPGA input unless you have positively verified electrical compatibility.
  • Many GPS breakouts differ in power and I/O behavior. Check your exact module before connecting it.
  • Do not use this project to make real-time decisions for:
  • vehicles
  • drones
  • boats
  • personal navigation in hazardous areas
  • timing-critical infrastructure
  • USB-powered bench setups can create accidental wiring mistakes. Always power down before rewiring.
  • This tutorial does not cover outdoor enclosure design, surge protection, ESD protection, or environmental hardening.
  • If you test outdoors, secure cables and boards so they do not create trip hazards or weather exposure risks.
  • The fix indication in this project reflects parsed NMEA status, not guaranteed absolute position correctness.

Troubleshooting

No LEDs respond except maybe heartbeat

Check:

  • Is the GPS module powered correctly?
  • Is ground shared between the GPS and ULX3S?
  • Is GPS TX really connected to the chosen FPGA input?
  • Did you use the correct LPF pin for your actual ULX3S board revision?

Heartbeat works, but no UART activity

Possible causes:

  • Wrong baud rate:
  • most NEO-6M modules use 9600 baud by default, but verify yours
  • GPS TX logic level incompatible or absent
  • Pin location mismatch in LPF
  • Broken jumper wire
  • GPS module not fully powered or not booting

UART activity exists, but no RMC detection

Possible causes:

  • Your GPS outputs GNRMC instead of GPRMC
  • this design already accepts both GPRMC and GNRMC
  • Serial corruption due to bad wiring
  • Incorrect baud timing because your board clock is not actually 25 MHz
  • Noise on the RX input

RMC detected, but fix never becomes active

This often means the FPGA design is fine and the GPS environment is the problem.

Try:

  • Move outdoors
  • Wait longer for cold start
  • Check antenna connection
  • Verify module health with a PC serial terminal

Build errors in nextpnr or LPF mapping

Likely causes:

  • The CABGA381 package does not match your board
  • LED or clock pin names are wrong for your ULX3S revision
  • Constraint pin names need adaptation from the official ULX3S files

If needed, keep the Verilog unchanged and only adjust the LPF.

Improvements

Once the base monitor works, you can extend it into a more capable field instrument.

Practical enhancements

  • Add seven-segment or OLED output
  • Show UTC time directly on local display
  • Expose parsed values over a second UART
  • Send compact machine-readable status to a PC or microcontroller
  • Add checksum verification
  • Improve confidence that parsed sentences are not corrupted
  • Support more NMEA sentences
  • Parse GGA for altitude and satellite count
  • Add fix timeout
  • Turn off fix LED if no active sentence arrives for several seconds
  • Log sentence statistics
  • Count lines per second, invalid frames, and fix transitions
  • Button-controlled mode pages
  • One mode for raw traffic status, another for fix state trends

Engineering improvements

  • Add a small FIFO between UART and parser
  • Add explicit CR/LF line framing checks
  • Add debounced buttons to clear status flags
  • Use a stricter finite-state parser for sentence IDs and fields
  • Export parsed field bytes to a simple register bank for future host access

Final Checklist

Use this checklist before declaring the project complete:

  • [ ] I used the exact hardware family: FPGA
  • [ ] I used the exact model: Radiona ULX3S (Lattice ECP5-85F) + u-blox NEO-6M GPS module + 3.3 V UART wiring
  • [ ] The GPS and ULX3S share a common ground
  • [ ] GPS TX is connected to the FPGA input pin defined in the LPF
  • [ ] I verified the GPS UART logic is safe for 3.3 V
  • [ ] Verilator lint completed without blocking errors
  • [ ] The simulation printed PASS: RMC monitor parsed active fix.
  • [ ] Yosys synthesis completed successfully
  • [ ] nextpnr-ecp5 completed successfully for the ECP5-85F target
  • [ ] The bitstream was packed with ecppack
  • [ ] The board was programmed with openFPGALoader -b ulx3s
  • [ ] led[0] blinks after programming
  • [ ] led[1] shows UART activity when the GPS is connected
  • [ ] led[3] indicates RMC sentences are being recognized
  • [ ] led[4] turns on when the GPS reports an active fix
  • [ ] led[5], led[6], and led[7] change as time/position fields update

If all items are checked, you have a practical FPGA-based gps-nmea-position-time-monitor that is genuinely useful for GPS module diagnostics and serial-data education.

        <div class="amazon-affiliate">
          <p><strong>Find this product and/or books on this topic on Amazon</strong></p>
          <p><a class="amazon-affiliate-btn" href="https://amzn.to/4mt8r4C" target="_blank" rel="nofollow sponsored noopener">Go to Amazon</a></p>
          <p class="amazon-affiliate-disclaimer">As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.</p>
        </div>

Quick Quiz

Question 1: What FPGA board is used for the GPS monitor built in the project?




Question 2: Which specific GPS module is mentioned in the article?




Question 3: What is the required baud rate for receiving the NMEA data in this setup?




Question 4: What type of wiring interface is used to connect the GPS module to the FPGA?




Question 5: How is the status of UART activity and GPS fix visually displayed to the user?




Question 6: What is the typical visible status refresh rate mentioned for the monitor?




Question 7: What kind of data format is parsed to extract time and position?




Question 8: What is the typical FPGA logic load for this standalone serial monitor prototype?




Question 9: Which of the following is listed as a primary use case for this project?




Question 10: What does the project allow you to verify without needing to open a PC serial terminal?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: passive UART monitor with ULX3S

Practical case: passive UART monitor with ULX3S — hero

Objective and use case

What you’ll build: A practical UART monitor on the Radiona ULX3S (Lattice ECP5-85F) that passively taps a real 3.3 V, 115200 baud, 8N1 TX line, decodes each byte in FPGA logic, and forwards readable lines such as RX 48 OK to a PC over a second UART. The design also flashes an on-board LED on traffic and is clean enough to lint with Verilator and synthesize with Yosys.

Why it matters / Use cases

  • Debug embedded devices without changing their firmware by watching a live 3.3 V UART stream non-invasively.
  • Turn raw serial traffic into human-readable monitor output for bring-up, factory test, and field diagnostics.
  • Practice reliable FPGA serial design with concrete timing: 115200 baud means about 86.8 µs per byte frame in 8N1, so the monitor must sample and format data correctly at line rate.
  • Useful when validating boot logs, sensor controllers, GPS modules, or MCU debug prints that already transmit over UART.

Expected outcome

  • The ULX3S receives bytes from an external 3.3 V UART source and decodes them correctly at 115200 baud, 8N1.
  • For every received byte, the FPGA emits a readable line like RX 48 OK to a USB-UART adapter connected to a PC terminal.
  • An on-board LED blinks briefly on each character, giving immediate visual confirmation of traffic.
  • The RTL passes Verilator lint and synthesizes with Yosys for the ECP5-85F, with very low FPGA load relative to available logic and no meaningful GPU usage (0% GPU).

Audience: FPGA learners, embedded engineers, and hardware debuggers working with UART-based systems; Level: beginner to intermediate

Architecture/flow: 3.3 V device TX -> ULX3S UART RX decoder -> formatter -> ULX3S UART TX -> USB-UART adapter -> PC terminal

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

3.3 V device TX

ULX3S UART RX decoder

formatter

ULX3S UART TX

USB-UART adapter

PC terminal

Conceptual signal and responsibility flow between device blocks.

Validation path

Source code

Verilator

Yosys

Hardware implementation

Conceptual summary of the tools used to check the published material.

Prerequisites

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this FPGA/ULX3S profile, the synthesizable Verilog blocks were checked with Yosys (read_verilog) and the Verilog design/test set was linted with Verilator. The validator also checked code-block structure, copy/paste-safe ASCII command options, unsupported stacks, and availability of the ULX3S/ECP5 toolchain (yosys, nextpnr-ecp5, ecppack, openFPGALoader).

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ULX3S board revision, pin-constraint file and real wiring.

You need:

  • A ULX3S board from the Lattice ECP5-85F family
  • A 3.3 V UART source device
  • A USB-UART adapter
  • Jumper wires
  • A shell environment with these tools installed:
  • verilator
  • yosys
  • nextpnr-ecp5
  • ecppack
  • openFPGALoader
  • A serial terminal program such as picocom or screen

Quick tool check:

verilator --version
yosys -V
nextpnr-ecp5 --version
ecppack --help | head -n 1
openFPGALoader --version

Materials

Item Exact model/family Purpose
FPGA board Radiona ULX3S, Lattice ECP5-85F Runs the UART monitor
Serial source 3.3 V UART device Signal being observed
USB-UART adapter 3.3 V compatible adapter Sends monitor output to the PC
USB cable For ULX3S Power and programming
USB cable For adapter PC serial connection
Jumper wires As needed TX and GND wiring

Educational safety note

Low-voltage digital electronics only.

  • Do not connect RS-232 voltage levels directly to FPGA pins.
  • Do not connect 5 V UART directly to ULX3S I/O.
  • Share GND between the external device, ULX3S, and USB-UART adapter.
  • This project assumes 3.3 V UART signaling only.

Wiring

Signals used by the FPGA design:

  • mon_rx: monitored UART input from the external device TX
  • host_tx: UART output from the FPGA to the USB-UART adapter RX
  • led0: activity LED

Connect:

  1. External device TX -> ULX3S pin assigned to mon_rx
  2. External device GND -> ULX3S GND
  3. ULX3S pin assigned to host_tx -> USB-UART adapter RX
  4. USB-UART adapter GND -> ULX3S GND
  5. ULX3S USB -> PC
  6. USB-UART adapter USB -> PC

Project files

Create these files:

  • uart_monitor_top.v
  • tb_uart_monitor_top.v
  • ulx3s_uart_monitor.lpf

Verilog: uart_monitor_top.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

module uart_rx #(
    parameter integer CLK_HZ = 25000000,
    parameter integer BAUD   = 115200
)(
    input  wire clk,
    input  wire rst,
    input  wire rx,
    output reg  [7:0] data,
    output reg  valid,
    output reg  framing_error
);
    localparam integer CLKS_PER_BIT  = CLK_HZ / BAUD;
    localparam integer HALF_BIT_CLKS = CLKS_PER_BIT / 2;

    reg rx_sync_0;
    reg rx_sync_1;
    reg [15:0] clk_count;
    reg [3:0] bit_index;
    reg [7:0] rx_shift;
    reg [1:0] state;

    localparam [1:0] S_IDLE  = 2'd0;
    localparam [1:0] S_START = 2'd1;
    localparam [1:0] S_DATA  = 2'd2;
    localparam [1:0] S_STOP  = 2'd3;

    always @(posedge clk) begin
        if (rst) begin
            rx_sync_0 <= 1'b1;
            rx_sync_1 <= 1'b1;
        end else begin
            rx_sync_0 <= rx;
            rx_sync_1 <= rx_sync_0;
        end
    end

    always @(posedge clk) begin
        if (rst) begin
            data <= 8'h00;
            valid <= 1'b0;
            framing_error <= 1'b0;
            clk_count <= 16'd0;
            bit_index <= 4'd0;
            rx_shift <= 8'h00;
            state <= S_IDLE;
        end else begin
            valid <= 1'b0;

            case (state)
                S_IDLE: begin
                    framing_error <= 1'b0;
                    clk_count <= 16'd0;
                    bit_index <= 4'd0;
                    if (rx_sync_1 == 1'b0) begin
                        state <= S_START;
                    end
                end

                S_START: begin
                    if (clk_count == HALF_BIT_CLKS - 1) begin
                        clk_count <= 16'd0;
                        if (rx_sync_1 == 1'b0) begin
                            state <= S_DATA;
                        end else begin
                            state <= S_IDLE;
                        end
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                S_DATA: begin
                    if (clk_count == CLKS_PER_BIT - 1) begin
                        clk_count <= 16'd0;
                        rx_shift[bit_index] <= rx_sync_1;
                        if (bit_index == 4'd7) begin
                            bit_index <= 4'd0;
                            state <= S_STOP;
                        end else begin
                            bit_index <= bit_index + 4'd1;
                        end
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                S_STOP: begin
                    if (clk_count == CLKS_PER_BIT - 1) begin
                        clk_count <= 16'd0;
                        data <= rx_shift;
                        valid <= 1'b1;
                        framing_error <= (rx_sync_1 != 1'b1);
                        state <= S_IDLE;
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                default: begin
                    state <= S_IDLE;
                end
            endcase
        end
    end
endmodule

module uart_tx #(
    parameter integer CLK_HZ = 25000000,
    parameter integer BAUD   = 115200
)(
    input  wire clk,
    input  wire rst,
    input  wire [7:0] data,
    input  wire start,
    output reg  tx,
    output reg  busy
);
    localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;

    reg [15:0] clk_count;
    reg [3:0] bit_index;
    reg [9:0] shifter;

    always @(posedge clk) begin
        if (rst) begin
            tx <= 1'b1;
            busy <= 1'b0;
            clk_count <= 16'd0;
            bit_index <= 4'd0;
            shifter <= 10'b1111111111;
        end else begin
            if (!busy) begin
                tx <= 1'b1;
                clk_count <= 16'd0;
                bit_index <= 4'd0;
                if (start) begin
                    shifter <= {1'b1, data, 1'b0};
                    busy <= 1'b1;
                    tx <= 1'b0;
                end
            end else begin
                if (clk_count == CLKS_PER_BIT - 1) begin
                    clk_count <= 16'd0;
                    bit_index <= bit_index + 4'd1;
                    shifter <= {1'b1, shifter[9:1]};
                    tx <= shifter[1];
                    if (bit_index == 4'd9) begin
                        busy <= 1'b0;
                        tx <= 1'b1;
                    end
                end else begin
                    clk_count <= clk_count + 16'd1;
                end
            end
        end
    end
endmodule
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

module uart_rx #(
    parameter integer CLK_HZ = 25000000,
    parameter integer BAUD   = 115200
)(
    input  wire clk,
    input  wire rst,
    input  wire rx,
    output reg  [7:0] data,
    output reg  valid,
    output reg  framing_error
);
    localparam integer CLKS_PER_BIT  = CLK_HZ / BAUD;
    localparam integer HALF_BIT_CLKS = CLKS_PER_BIT / 2;

    reg rx_sync_0;
    reg rx_sync_1;
    reg [15:0] clk_count;
    reg [3:0] bit_index;
    reg [7:0] rx_shift;
    reg [1:0] state;

    localparam [1:0] S_IDLE  = 2'd0;
    localparam [1:0] S_START = 2'd1;
    localparam [1:0] S_DATA  = 2'd2;
    localparam [1:0] S_STOP  = 2'd3;

    always @(posedge clk) begin
        if (rst) begin
            rx_sync_0 <= 1'b1;
            rx_sync_1 <= 1'b1;
        end else begin
            rx_sync_0 <= rx;
            rx_sync_1 <= rx_sync_0;
        end
    end

    always @(posedge clk) begin
        if (rst) begin
            data <= 8'h00;
            valid <= 1'b0;
            framing_error <= 1'b0;
            clk_count <= 16'd0;
            bit_index <= 4'd0;
            rx_shift <= 8'h00;
            state <= S_IDLE;
        end else begin
            valid <= 1'b0;

            case (state)
                S_IDLE: begin
                    framing_error <= 1'b0;
                    clk_count <= 16'd0;
                    bit_index <= 4'd0;
                    if (rx_sync_1 == 1'b0) begin
                        state <= S_START;
                    end
                end

                S_START: begin
                    if (clk_count == HALF_BIT_CLKS - 1) begin
                        clk_count <= 16'd0;
                        if (rx_sync_1 == 1'b0) begin
                            state <= S_DATA;
                        end else begin
                            state <= S_IDLE;
                        end
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                S_DATA: begin
                    if (clk_count == CLKS_PER_BIT - 1) begin
                        clk_count <= 16'd0;
                        rx_shift[bit_index] <= rx_sync_1;
                        if (bit_index == 4'd7) begin
                            bit_index <= 4'd0;
                            state <= S_STOP;
                        end else begin
                            bit_index <= bit_index + 4'd1;
                        end
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                S_STOP: begin
                    if (clk_count == CLKS_PER_BIT - 1) begin
                        clk_count <= 16'd0;
                        data <= rx_shift;
                        valid <= 1'b1;
                        framing_error <= (rx_sync_1 != 1'b1);
                        state <= S_IDLE;
                    end else begin
                        clk_count <= clk_count + 16'd1;
                    end
                end

                default: begin
                    state <= S_IDLE;
                end
            endcase
        end
    end
endmodule

module uart_tx #(
    parameter integer CLK_HZ = 25000000,
    parameter integer BAUD   = 115200
)(
    input  wire clk,
    input  wire rst,
    input  wire [7:0] data,
    input  wire start,
    output reg  tx,
    output reg  busy
);
    localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;

    reg [15:0] clk_count;
    reg [3:0] bit_index;
    reg [9:0] shifter;

    always @(posedge clk) begin
        if (rst) begin
            tx <= 1'b1;
            busy <= 1'b0;
            clk_count <= 16'd0;
            bit_index <= 4'd0;
            shifter <= 10'b1111111111;
        end else begin
            if (!busy) begin
                tx <= 1'b1;
                clk_count <= 16'd0;
                bit_index <= 4'd0;
                if (start) begin
                    shifter <= {1'b1, data, 1'b0};
                    busy <= 1'b1;
                    tx <= 1'b0;
                end
            end else begin
                if (clk_count == CLKS_PER_BIT - 1) begin
                    clk_count <= 16'd0;
                    bit_index <= bit_index + 4'd1;
                    shifter <= {1'b1, shifter[9:1]};
                    tx <= shifter[1];
                    if (bit_index == 4'd9) begin
                        busy <= 1'b0;
                        tx <= 1'b1;
                    end
                end else begin
                    clk_count <= clk_count + 16'd1;
                end
            end
        end
    end
endmodule

module uart_monitor_top(
    input  wire clk_25mhz,
    input  wire btn_rst,
    input  wire mon_rx,
    output wire host_tx,
    output reg  led0
);
    wire rst;
    wire [7:0] rx_data;
    wire rx_valid;
    wire rx_ferr;

    reg [7:0] tx_data;
    reg tx_start;
    wire tx_busy;

    reg [7:0] msg_mem [0:17];
    reg [4:0] msg_len;
    reg [4:0] msg_idx;
    reg sending;
    reg [23:0] led_count;
    integer i;

    assign rst = btn_rst;

    uart_rx #(
        .CLK_HZ(25000000),
        .BAUD(115200)
    ) u_rx (
        .clk(clk_25mhz),
        .rst(rst),
        .rx(mon_rx),
        .data(rx_data),
        .valid(rx_valid),
        .framing_error(rx_ferr)
    );

    uart_tx #(
        .CLK_HZ(25000000),
        .BAUD(115200)
    ) u_tx (
        .clk(clk_25mhz),
        .rst(rst),
        .data(tx_data),
        .start(tx_start),
        .tx(host_tx),
        .busy(tx_busy)
    );

    function [7:0] hexchar;
        input [3:0] nib;
        begin
            if (nib < 4'd10) begin
                hexchar = 8'h30 + {4'b0000, nib};
            end else begin
                hexchar = 8'h41 + ({4'b0000, nib} - 8'd10);
            end
        end
    endfunction

    always @(posedge clk_25mhz) begin
        if (rst) begin
            tx_data <= 8'h00;
            tx_start <= 1'b0;
            msg_len <= 5'd0;
            msg_idx <= 5'd0;
            sending <= 1'b0;
            led0 <= 1'b0;
            led_count <= 24'd0;
            for (i = 0; i < 18; i = i + 1) begin
                msg_mem[i] <= 8'h20;
            end
        end else begin
            tx_start <= 1'b0;

            if (led_count != 24'd0) begin
                led_count <= led_count - 24'd1;
                led0 <= 1'b1;
            end else begin
                led0 <= 1'b0;
            end

            if (rx_valid && !sending) begin
                led_count <= 24'd5000000;

                msg_mem[0] <= "R";
                msg_mem[1] <= "X";
                msg_mem[2] <= " ";
                msg_mem[3] <= hexchar(rx_data[7:4]);
                msg_mem[4] <= hexchar(rx_data[3:0]);
                msg_mem[5] <= " ";

                if (!rx_ferr) begin
                    msg_mem[6] <= "O";
                    msg_mem[7] <= "K";
                    msg_mem[8] <= 8'h0A;
                    msg_len <= 5'd9;
                end else begin
                    msg_mem[6]  <= "F";
                    msg_mem[7]  <= "R";
                    msg_mem[8]  <= "A";
                    msg_mem[9]  <= "M";
                    msg_mem[10] <= "I";
                    msg_mem[11] <= "N";
                    msg_mem[12] <= "G";
                    msg_mem[13] <= "_";
                    msg_mem[14] <= "E";
                    msg_mem[15] <= "R";
                    msg_mem[16] <= "R";
                    msg_mem[17] <= 8'h0A;
                    msg_len <= 5'd18;
                end

                msg_idx <= 5'd0;
                sending <= 1'b1;
            end

            if (sending && !tx_busy) begin
                if (msg_idx < msg_len) begin
                    tx_data <= msg_mem[msg_idx];
                    tx_start <= 1'b1;
                    msg_idx <= msg_idx + 5'd1;
                end else begin
                    sending <= 1'b0;
                end
            end
        end
    end
endmodule

Testbench: tb_uart_monitor_top.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

`timescale 1ns/1ps

module tb_uart_monitor_top;
    reg clk;
    reg btn_rst;
    reg mon_rx;
    wire host_tx;
    wire led0;

    localparam integer CLK_HALF_NS = 20;
    localparam integer BIT_NS = 8680;

    integer fd;
    integer i;
    reg [9:0] frame;

    uart_monitor_top dut (
        .clk_25mhz(clk),
        .btn_rst(btn_rst),
        .mon_rx(mon_rx),
        .host_tx(host_tx),
        .led0(led0)
    );

    always #CLK_HALF_NS clk = ~clk;

    task uart_send_byte;
        input [7:0] b;
        integer j;
        begin
            mon_rx = 1'b0;
            #(BIT_NS);
            for (j = 0; j < 8; j = j + 1) begin
                mon_rx = b[j];
                #(BIT_NS);
            end
            mon_rx = 1'b1;
            #(BIT_NS);
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

`timescale 1ns/1ps

module tb_uart_monitor_top;
    reg clk;
    reg btn_rst;
    reg mon_rx;
    wire host_tx;
    wire led0;

    localparam integer CLK_HALF_NS = 20;
    localparam integer BIT_NS = 8680;

    integer fd;
    integer i;
    reg [9:0] frame;

    uart_monitor_top dut (
        .clk_25mhz(clk),
        .btn_rst(btn_rst),
        .mon_rx(mon_rx),
        .host_tx(host_tx),
        .led0(led0)
    );

    always #CLK_HALF_NS clk = ~clk;

    task uart_send_byte;
        input [7:0] b;
        integer j;
        begin
            mon_rx = 1'b0;
            #(BIT_NS);
            for (j = 0; j < 8; j = j + 1) begin
                mon_rx = b[j];
                #(BIT_NS);
            end
            mon_rx = 1'b1;
            #(BIT_NS);
        end
    endtask

    initial begin
        clk = 1'b0;
        btn_rst = 1'b1;
        mon_rx = 1'b1;
        fd = $fopen("sim_host_tx_bits.txt", "w");

        #500;
        btn_rst = 1'b0;

        #(BIT_NS * 3);
        uart_send_byte(8'h48);
        #(BIT_NS * 2);
        uart_send_byte(8'h45);
        #(BIT_NS * 2);
        uart_send_byte(8'h4C);

        #(BIT_NS * 250);
        $fclose(fd);
        $finish;
    end

    initial begin
        forever begin
            @(negedge host_tx);
            #(BIT_NS/2);
            frame[0] = host_tx;
            for (i = 1; i < 10; i = i + 1) begin
                #(BIT_NS);
                frame[i] = host_tx;
            end
            $fwrite(fd, "frame bits: %b\n", frame);
        end
    end
endmodule

Constraints: ulx3s_uart_monitor.lpf

Edit the SITE values to match your exact ULX3S pinout.

BLOCK RESETPATHS;
BLOCK ASYNCPATHS;

FREQUENCY PORT "clk_25mhz" 25 MHz;

LOCATE COMP "clk_25mhz" SITE "ULX3S_PIN_CLK25";
IOBUF PORT "clk_25mhz" IO_TYPE=LVCMOS33;

LOCATE COMP "btn_rst" SITE "ULX3S_PIN_BTN";
IOBUF PORT "btn_rst" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "mon_rx" SITE "ULX3S_PIN_MON_RX";
IOBUF PORT "mon_rx" IO_TYPE=LVCMOS33;

LOCATE COMP "host_tx" SITE "ULX3S_PIN_HOST_TX";
IOBUF PORT "host_tx" IO_TYPE=LVCMOS33;

LOCATE COMP "led0" SITE "ULX3S_PIN_LED0";
IOBUF PORT "led0" IO_TYPE=LVCMOS33;

Build and run

1) Verilator lint

verilator -Wall -Wno-DECLFILENAME --lint-only uart_monitor_top.v tb_uart_monitor_top.v

2) Run simulation

verilator -Wall -Wno-DECLFILENAME --binary uart_monitor_top.v tb_uart_monitor_top.v
./obj_dir/Vtb_uart_monitor_top

Expected evidence:

  • The simulation exits normally.
  • A file named sim_host_tx_bits.txt is created.
  • That file contains UART frame samples generated by the FPGA transmitter.

This is the validation method for the RTL claim that received bytes trigger formatted UART output.

3) Synthesize

yosys -p "read_verilog uart_monitor_top.v; synth_ecp5 -top uart_monitor_top -json uart_monitor_top.json"

4) Place and route

nextpnr-ecp5 --85k --json uart_monitor_top.json --lpf ulx3s_uart_monitor.lpf --textcfg uart_monitor_top.config

5) Pack bitstream

ecppack uart_monitor_top.config uart_monitor_top.bit

6) Program the board

openFPGALoader -b ulx3s uart_monitor_top.bit

7) Open a serial terminal on the USB-UART adapter

picocom example:

picocom -b 115200 /dev/ttyUSB0

screen example:

screen /dev/ttyUSB0 115200

Hardware validation

Validate idle behavior

With the external serial device disconnected:

  • The terminal should stay quiet.
  • The LED should stay off after reset.
  • mon_rx should not be driven by any out-of-range voltage.

Validate with a known UART source

Configure the external 3.3 V device to repeatedly send HELLO at 115200 8N1.

Expected evidence in the terminal:

RX 48 OK
RX 45 OK
RX 4C OK
RX 4C OK
RX 4F OK
RX 0D OK
RX 0A OK

This is the validation method for the accuracy claim that the monitor decodes bytes correctly: compare the transmitted known string with the hexadecimal byte values printed by the FPGA.

Validate framing error handling

Keep the FPGA monitor at 115200 8N1, but configure the source device to a different baud rate such as 9600.

Expected evidence:

  • Output becomes sparse, incorrect, or absent.
  • Some received lines may show FRAMING_ERR.

Troubleshooting

No terminal output

Check:

  1. host_tx goes to the adapter RX
  2. Grounds are shared
  3. The correct serial device is opened on the PC
  4. The source device is actually transmitting
  5. The LPF pin mapping matches the real board

LED flashes but no PC text

Likely causes:

  • Wrong host_tx pin assignment
  • Wrong USB-UART adapter wiring
  • Wrong terminal device on the PC

Lint or synthesis fails

Check that:

  • File names match the commands exactly
  • Only uart_monitor_top.v is passed to Yosys synthesis
  • The LPF uses the same top-level signal names as the Verilog

Framing errors on every byte

Usually caused by:

  • Baud mismatch
  • Wrong voltage level
  • Noisy wiring
  • Incorrect clock pin mapping

Capture terminal logs

To save a monitor session:

script -c "picocom -b 115200 /dev/ttyUSB0" uart_monitor_session.txt

Final checklist

  • [ ] I used a Radiona ULX3S (Lattice ECP5-85F).
  • [ ] My observed serial signal is 3.3 V UART, not RS-232 and not 5 V UART.
  • [ ] All grounds are connected together.
  • [ ] I updated ulx3s_uart_monitor.lpf with valid ULX3S pins.
  • [ ] Verilator lint passes.
  • [ ] Yosys synthesis completes.
  • [ ] nextpnr completes.
  • [ ] The bitstream programs successfully.
  • [ ] The PC terminal is set to 115200 baud.
  • [ ] The terminal shows expected monitor lines for a known byte stream.

This gives you a reusable FPGA-based UART bench monitor for one transmit line on the ULX3S platform.

        <div class="amazon-affiliate">
          <p><strong>Find this product and/or books on this topic on Amazon</strong></p>
          <p><a class="amazon-affiliate-btn" href="https://amzn.to/4mt8r4C" target="_blank" rel="nofollow sponsored noopener">Go to Amazon</a></p>
          <p class="amazon-affiliate-disclaimer">As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.</p>
        </div>

Quick Quiz

Question 1: What FPGA board is used for the UART monitor project described in the text?




Question 2: What is the baud rate of the UART line being tapped in this project?




Question 3: What happens on the FPGA board when a character is received?




Question 4: Which tool is used to synthesize the RTL design for the ECP5-85F?




Question 5: What is the approximate time per byte frame in 8N1 at 115200 baud?




Question 6: What is the voltage of the UART line being tapped in this project?




Question 7: What kind of output does the FPGA send to the PC terminal for every received byte?




Question 8: How does the UART monitor interact with the embedded device's firmware?




Question 9: Which tool is mentioned for linting the RTL design?




Question 10: What is one of the use cases mentioned for this UART monitor?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: SG90 servo tester on ULX3S

Practical case: SG90 servo tester on ULX3S — hero

Objective and use case

What you’ll build: A bench servo tester on the Radiona ULX3S (Lattice ECP5-85F) that generates a hobby-servo PWM control signal for an SG90 micro servo powered from an external 5 V supply. Four on-board buttons select center, minimum, maximum, or an automatic sweep mode, with a nominal 50 Hz update rate and ~1.0 ms, ~1.5 ms, or ~2.0 ms pulse widths.

Why it matters / Use cases

  • Quickly validate SG90-style servos on the bench without needing a microcontroller or full robot control stack.
  • Practice FPGA timing design using a real-world low-rate PWM task: 20 ms frame period, millisecond-scale pulses, and clean button-driven mode selection.
  • Useful for troubleshooting wiring, endpoint response, and sweep behavior with a scope or logic analyzer by checking 50 FPS-equivalent control frames and pulse-width changes.
  • Provides a simple hardware demo with very low FPGA load, typically only a tiny fraction of the ECP5 fabric and negligible overall GPU/graphics usage relevance.

Expected outcome

  • The FPGA outputs a servo control frame near 20 ms (about 50 Hz).
  • Button selection produces pulse widths near 1.0 ms, 1.5 ms, and 2.0 ms for minimum, center, and maximum positions.
  • Sweep mode steps or ramps the pulse width across repeated frames so the servo moves back and forth with predictable timing.
  • The project completes a standard open FPGA flow: Verilator lint, Yosys synthesis, nextpnr-ecp5 place-and-route, ecppack bitstream generation, and openFPGALoader programming.
  • Measurement with a scope or logic analyzer confirms frame period accuracy and visible pulse-width changes for each mode, with control latency bounded to the next 20 ms frame.

Audience: FPGA beginners, digital design students, and embedded hardware makers; Level: Beginner to intermediate

Architecture/flow: Button inputs feed a mode selector in FPGA logic; a counter/timer generates the 20 ms servo frame and PWM high-time; the ULX3S outputs the control signal to the SG90 while the servo itself is powered from an external 5 V supply with shared ground; validate timing in simulation/build tools, then verify ~1.0/1.5/2.0 ms pulses and sweep behavior on hardware.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

ULX3S buttons

Sync/debounce

Mode selector

20 ms period generator

Pulse-width comparator

50 Hz PWM output

SG90 servo

Conceptual control flow: button input, mode selection, PWM timing and servo motion.

Validation path

Verilog source

Verilator lint/testbench

Yosys synthesis

nextpnr-ecp5

ecppack bitstream

Programmed ULX3S

The automated validation checks syntax, simulation/lint and compatibility with the ULX3S/ECP5 toolchain.

Prerequisites

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this FPGA/ULX3S profile, the synthesizable Verilog blocks were checked with Yosys (read_verilog) and the Verilog design/test set was linted with Verilator. The validator also checked code-block structure, copy/paste-safe ASCII command options, unsupported stacks, and availability of the ULX3S/ECP5 toolchain (yosys, nextpnr-ecp5, ecppack, openFPGALoader).

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ULX3S board revision, pin-constraint file and real wiring.

You need:

  • a Radiona ULX3S (Lattice ECP5-85F)
  • an SG90 micro servo
  • an external 5 V servo supply
  • USB connection for ULX3S power and programming
  • installed tools:
  • verilator
  • yosys
  • nextpnr-ecp5
  • ecppack
  • openFPGALoader

Materials

Item Exact model Quantity Notes
FPGA board Radiona ULX3S (Lattice ECP5-85F) 1 Target board
Servo SG90 micro servo 1 3-wire hobby servo
Servo supply External 5 V servo supply 1 Must handle servo current spikes
USB cable ULX3S-compatible USB cable 1 Board power and programming
Jumper wires Suitable jumper wires Several Signal and ground wiring
Oscilloscope or logic analyzer Any basic model Optional but recommended For waveform validation

Wiring

Servo wires

Typical SG90 wire colors:

  • brown/black: GND
  • red: +5 V
  • orange/yellow/white: control signal

Connections

  1. Power the ULX3S from USB.
  2. Power the servo from the external 5 V supply.
  3. Tie external 5 V ground to a ULX3S ground.
  4. Connect the FPGA output pin servo_pwm to the servo signal wire.

Visible safety note

Educational safety note

This project drives a moving actuator from an external power source.

  • Keep fingers and loose wires away from the servo horn while powered.
  • Do not power the servo from an FPGA I/O pin.
  • Do not connect 5 V directly to any ULX3S I/O.
  • Always connect the grounds together so the signal has a valid reference.
  • If the servo stalls, chatters loudly, or gets hot, power it down and inspect the linkage.

Button mapping

This tutorial uses four button inputs:

  • btn_center: center position
  • btn_min: minimum position
  • btn_max: maximum position
  • btn_sweep: sweep mode

If no button is pressed, the design defaults to center.

Source code

File: src/servo_tester.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

`timescale 1ns/1ps

module servo_tester #(
    parameter integer CLK_HZ = 25000000,
    parameter integer FRAME_HZ = 50,
    parameter integer PULSE_MIN_US = 1000,
    parameter integer PULSE_CENTER_US = 1500,
    parameter integer PULSE_MAX_US = 2000,
    parameter integer SWEEP_STEP_US = 10,
    parameter integer SWEEP_UPDATE_MS = 20
) (
    input  wire clk,
    input  wire btn_center,
    input  wire btn_min,
    input  wire btn_max,
    input  wire btn_sweep,
    output reg  servo_pwm
);

    localparam integer US_TICKS            = CLK_HZ / 1000000;
    localparam integer FRAME_TICKS         = CLK_HZ / FRAME_HZ;
    localparam integer PULSE_MIN_TICKS     = PULSE_MIN_US * US_TICKS;
    localparam integer PULSE_CENTER_TICKS  = PULSE_CENTER_US * US_TICKS;
    localparam integer PULSE_MAX_TICKS     = PULSE_MAX_US * US_TICKS;
    localparam integer SWEEP_STEP_TICKS    = SWEEP_STEP_US * US_TICKS;
    localparam integer SWEEP_UPDATE_TICKS  = (CLK_HZ / 1000) * SWEEP_UPDATE_MS;

    reg [31:0] frame_counter = 32'd0;
    reg [31:0] pulse_ticks   = PULSE_CENTER_TICKS;
    reg [31:0] sweep_counter = 32'd0;
    reg [31:0] sweep_ticks   = PULSE_CENTER_TICKS;
    reg        sweep_dir_up  = 1'b1;

    always @(posedge clk) begin
        if (btn_min) begin
            pulse_ticks   <= PULSE_MIN_TICKS;
            sweep_counter <= 32'd0;
            sweep_ticks   <= PULSE_CENTER_TICKS;
            sweep_dir_up  <= 1'b1;
        end else if (btn_center) begin
            pulse_ticks   <= PULSE_CENTER_TICKS;
            sweep_counter <= 32'd0;
            sweep_ticks   <= PULSE_CENTER_TICKS;
            sweep_dir_up  <= 1'b1;
        end else if (btn_max) begin
            pulse_ticks   <= PULSE_MAX_TICKS;
            sweep_counter <= 32'd0;
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

`timescale 1ns/1ps

module servo_tester #(
    parameter integer CLK_HZ = 25000000,
    parameter integer FRAME_HZ = 50,
    parameter integer PULSE_MIN_US = 1000,
    parameter integer PULSE_CENTER_US = 1500,
    parameter integer PULSE_MAX_US = 2000,
    parameter integer SWEEP_STEP_US = 10,
    parameter integer SWEEP_UPDATE_MS = 20
) (
    input  wire clk,
    input  wire btn_center,
    input  wire btn_min,
    input  wire btn_max,
    input  wire btn_sweep,
    output reg  servo_pwm
);

    localparam integer US_TICKS            = CLK_HZ / 1000000;
    localparam integer FRAME_TICKS         = CLK_HZ / FRAME_HZ;
    localparam integer PULSE_MIN_TICKS     = PULSE_MIN_US * US_TICKS;
    localparam integer PULSE_CENTER_TICKS  = PULSE_CENTER_US * US_TICKS;
    localparam integer PULSE_MAX_TICKS     = PULSE_MAX_US * US_TICKS;
    localparam integer SWEEP_STEP_TICKS    = SWEEP_STEP_US * US_TICKS;
    localparam integer SWEEP_UPDATE_TICKS  = (CLK_HZ / 1000) * SWEEP_UPDATE_MS;

    reg [31:0] frame_counter = 32'd0;
    reg [31:0] pulse_ticks   = PULSE_CENTER_TICKS;
    reg [31:0] sweep_counter = 32'd0;
    reg [31:0] sweep_ticks   = PULSE_CENTER_TICKS;
    reg        sweep_dir_up  = 1'b1;

    always @(posedge clk) begin
        if (btn_min) begin
            pulse_ticks   <= PULSE_MIN_TICKS;
            sweep_counter <= 32'd0;
            sweep_ticks   <= PULSE_CENTER_TICKS;
            sweep_dir_up  <= 1'b1;
        end else if (btn_center) begin
            pulse_ticks   <= PULSE_CENTER_TICKS;
            sweep_counter <= 32'd0;
            sweep_ticks   <= PULSE_CENTER_TICKS;
            sweep_dir_up  <= 1'b1;
        end else if (btn_max) begin
            pulse_ticks   <= PULSE_MAX_TICKS;
            sweep_counter <= 32'd0;
            sweep_ticks   <= PULSE_CENTER_TICKS;
            sweep_dir_up  <= 1'b1;
        end else if (btn_sweep) begin
            pulse_ticks <= sweep_ticks;

            if (sweep_counter >= (SWEEP_UPDATE_TICKS - 1)) begin
                sweep_counter <= 32'd0;

                if (sweep_dir_up) begin
                    if (sweep_ticks >= (PULSE_MAX_TICKS - SWEEP_STEP_TICKS)) begin
                        sweep_ticks  <= PULSE_MAX_TICKS;
                        sweep_dir_up <= 1'b0;
                    end else begin
                        sweep_ticks <= sweep_ticks + SWEEP_STEP_TICKS;
                    end
                end else begin
                    if (sweep_ticks <= (PULSE_MIN_TICKS + SWEEP_STEP_TICKS)) begin
                        sweep_ticks  <= PULSE_MIN_TICKS;
                        sweep_dir_up <= 1'b1;
                    end else begin
                        sweep_ticks <= sweep_ticks - SWEEP_STEP_TICKS;
                    end
                end
            end else begin
                sweep_counter <= sweep_counter + 32'd1;
            end
        end else begin
            pulse_ticks   <= PULSE_CENTER_TICKS;
            sweep_counter <= 32'd0;
            sweep_ticks   <= PULSE_CENTER_TICKS;
            sweep_dir_up  <= 1'b1;
        end

        if (frame_counter >= (FRAME_TICKS - 1)) begin
            frame_counter <= 32'd0;
        end else begin
            frame_counter <= frame_counter + 32'd1;
        end

        if (frame_counter < pulse_ticks) begin
            servo_pwm <= 1'b1;
        end else begin
            servo_pwm <= 1'b0;
        end
    end

endmodule

File: tb/servo_tester_tb.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

`timescale 1ns/1ps

module servo_tester_tb;

    reg clk = 1'b0;
    reg btn_center = 1'b0;
    reg btn_min = 1'b0;
    reg btn_max = 1'b0;
    reg btn_sweep = 1'b0;
    wire servo_pwm;

    integer high_count;
    integer i;

    servo_tester #(
        .CLK_HZ(1000000),
        .FRAME_HZ(50),
        .PULSE_MIN_US(1000),
        .PULSE_CENTER_US(1500),
        .PULSE_MAX_US(2000),
        .SWEEP_STEP_US(100),
        .SWEEP_UPDATE_MS(20)
    ) dut (
        .clk(clk),
        .btn_center(btn_center),
        .btn_min(btn_min),
        .btn_max(btn_max),
        .btn_sweep(btn_sweep),
        .servo_pwm(servo_pwm)
    );

    always #500 clk = ~clk;

    task automatic measure_one_frame;
        begin
            while (servo_pwm !== 1'b1) begin
                @(posedge clk);
            end

            high_count = 0;
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

`timescale 1ns/1ps

module servo_tester_tb;

    reg clk = 1'b0;
    reg btn_center = 1'b0;
    reg btn_min = 1'b0;
    reg btn_max = 1'b0;
    reg btn_sweep = 1'b0;
    wire servo_pwm;

    integer high_count;
    integer i;

    servo_tester #(
        .CLK_HZ(1000000),
        .FRAME_HZ(50),
        .PULSE_MIN_US(1000),
        .PULSE_CENTER_US(1500),
        .PULSE_MAX_US(2000),
        .SWEEP_STEP_US(100),
        .SWEEP_UPDATE_MS(20)
    ) dut (
        .clk(clk),
        .btn_center(btn_center),
        .btn_min(btn_min),
        .btn_max(btn_max),
        .btn_sweep(btn_sweep),
        .servo_pwm(servo_pwm)
    );

    always #500 clk = ~clk;

    task automatic measure_one_frame;
        begin
            while (servo_pwm !== 1'b1) begin
                @(posedge clk);
            end

            high_count = 0;
            while (servo_pwm === 1'b1) begin
                @(posedge clk);
                high_count = high_count + 1;
            end

            $display("Measured high ticks: %0d", high_count);
        end
    endtask

    initial begin
        $display("Starting servo_tester_tb");

        btn_center = 1'b1;
        repeat (3) begin
            measure_one_frame();
        end
        btn_center = 1'b0;

        btn_min = 1'b1;
        repeat (3) begin
            measure_one_frame();
        end
        btn_min = 1'b0;

        btn_max = 1'b1;
        repeat (3) begin
            measure_one_frame();
        end
        btn_max = 1'b0;

        btn_sweep = 1'b1;
        for (i = 0; i < 8; i = i + 1) begin
            measure_one_frame();
        end
        btn_sweep = 1'b0;

        $display("Testbench complete");
        $finish;
    end

endmodule

File: constraints/ulx3s_servo.lpf

Use valid ULX3S site names for your exact board revision.

BLOCK RESETPATHS;
BLOCK ASYNCPATHS;

FREQUENCY PORT "clk" 25.0 MHz;

LOCATE COMP "clk" SITE "CLK25";
IOBUF PORT "clk" IO_TYPE=LVCMOS33;

LOCATE COMP "btn_center" SITE "BTN1";
IOBUF PORT "btn_center" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "btn_min" SITE "BTN2";
IOBUF PORT "btn_min" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "btn_max" SITE "BTN3";
IOBUF PORT "btn_max" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "btn_sweep" SITE "BTN4";
IOBUF PORT "btn_sweep" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "servo_pwm" SITE "GPIO0";
IOBUF PORT "servo_pwm" IO_TYPE=LVCMOS33 DRIVE=4;

Active-low button wrapper

Many ULX3S button inputs are active-low. If your board wiring requires inversion, use a separate top-level wrapper for synthesis.

File: src/servo_tester_active_low.v

`timescale 1ns/1ps

module servo_tester_active_low (
    input  wire clk,
    input  wire btn_center_n,
    input  wire btn_min_n,
    input  wire btn_max_n,
    input  wire btn_sweep_n,
    output wire servo_pwm
);

    wire btn_center = ~btn_center_n;
    wire btn_min    = ~btn_min_n;
    wire btn_max    = ~btn_max_n;
    wire btn_sweep  = ~btn_sweep_n;

    servo_tester u_servo_tester (
        .clk(clk),
        .btn_center(btn_center),
        .btn_min(btn_min),
        .btn_max(btn_max),
        .btn_sweep(btn_sweep),
        .servo_pwm(servo_pwm)
    );

endmodule

If you use this wrapper, update the top module name in the synthesis command and rename the LPF ports to match:

  • btn_center_n
  • btn_min_n
  • btn_max_n
  • btn_sweep_n

Project layout

project/
├── build/
├── constraints/
│   └── ulx3s_servo.lpf
├── src/
│   ├── servo_tester.v
│   └── servo_tester_active_low.v
└── tb/
    └── servo_tester_tb.v

Build and program

1. Create the build directory

mkdir -p build

2. Run Verilator lint

verilator --lint-only -Wall -Wno-DECLFILENAME src/servo_tester.v tb/servo_tester_tb.v

If you synthesize the active-low wrapper, you can lint both source files:

verilator --lint-only -Wall -Wno-DECLFILENAME src/servo_tester.v src/servo_tester_active_low.v tb/servo_tester_tb.v

3. Synthesize with Yosys

For the direct top module:

yosys -p "read_verilog src/servo_tester.v; synth_ecp5 -top servo_tester -json build/servo_tester.json"

For the active-low wrapper top module:

yosys -p "read_verilog src/servo_tester.v src/servo_tester_active_low.v; synth_ecp5 -top servo_tester_active_low -json build/servo_tester.json"

4. Place and route

nextpnr-ecp5 --85k --package CABGA381 --json build/servo_tester.json --lpf constraints/ulx3s_servo.lpf --textcfg build/servo_tester.config

5. Pack the bitstream

ecppack build/servo_tester.config build/servo_tester.bit

6. Detect programmer

openFPGALoader --detect

7. Program the ULX3S

openFPGALoader -b ulx3s build/servo_tester.bit

Validation method

This project makes measurable timing claims, so validate them directly.

1. Toolchain evidence

Expected evidence:

  • Verilator exits without fatal errors
  • Yosys writes build/servo_tester.json
  • nextpnr-ecp5 completes successfully
  • ecppack writes build/servo_tester.bit
  • openFPGALoader programs the board

2. Simulation evidence

The testbench runs at 1 MHz, so each high tick equals 1 us.

Expected evidence from $display output:

  • center mode: about 1500 ticks
  • min mode: about 1000 ticks
  • max mode: about 2000 ticks
  • sweep mode: values that change between repeated measurements

3. Hardware waveform evidence

Before connecting the servo, probe servo_pwm with an oscilloscope or logic analyzer.

Expected evidence:

  • frame period near 20 ms
  • pulse width near:
  • 1.0 ms for minimum
  • 1.5 ms for center
  • 2.0 ms for maximum
  • in sweep mode, pulse width changes over time

4. Functional servo evidence

After waveform validation:

  1. power down the servo supply
  2. connect the servo signal to servo_pwm
  3. connect servo ground to supply ground
  4. connect supply ground to ULX3S ground
  5. power the ULX3S
  6. power the external 5 V servo supply

Expected evidence:

  • center button moves the servo to a repeatable middle position
  • min and max move toward opposite ends
  • sweep mode moves the servo back and forth

Troubleshooting

No servo motion

Check:

  • external 5 V supply is on
  • servo red wire goes to +5 V
  • servo ground is connected
  • ULX3S ground and servo supply ground are connected together
  • correct FPGA output pin is assigned in the LPF
  • the bitstream was actually programmed

Servo twitches but does not follow commands

Common causes:

  • missing common ground
  • wrong pin mapping in the LPF
  • weak or unstable 5 V servo supply
  • button polarity mismatch

nextpnr reports LPF site errors

The LPF site names must match your exact ULX3S board revision. Update:

  • CLK25
  • BTN1
  • BTN2
  • BTN3
  • BTN4
  • GPIO0

to the valid names from your board documentation.

Modes appear inverted or stuck

Your buttons are likely active-low. Use the servo_tester_active_low wrapper and synthesize that top module instead.

Final checklist

  • [ ] I used the Radiona ULX3S (Lattice ECP5-85F)
  • [ ] I used an SG90 micro servo
  • [ ] I powered the servo from an external 5 V supply
  • [ ] I connected servo supply ground to ULX3S ground
  • [ ] I verified the PWM waveform before connecting the servo
  • [ ] Verilator lint passed
  • [ ] Yosys synthesis passed
  • [ ] nextpnr-ecp5 place-and-route passed
  • [ ] ecppack generated a bitstream
  • [ ] openFPGALoader programmed the board
  • [ ] I measured about 1.0 ms, 1.5 ms, and 2.0 ms pulse widths for the expected modes

This produces a practical FPGA-based servo tester on the ULX3S ECP5-85F for quick bench validation of an SG90 servo.

        <div class="amazon-affiliate">
          <p><strong>Find this product and/or books on this topic on Amazon</strong></p>
          <p><a class="amazon-affiliate-btn" href="https://amzn.to/4mt8r4C" target="_blank" rel="nofollow sponsored noopener">Go to Amazon</a></p>
          <p class="amazon-affiliate-disclaimer">As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.</p>
        </div>

Quick Quiz

Question 1: What is the main purpose of the project described in the text?




Question 2: Which specific FPGA board is used for this project?




Question 3: What is the nominal update rate for the servo control signal?




Question 4: Which of the following pulse widths is generated by the tester?




Question 5: How many on-board buttons are used to select the different servo modes?




Question 6: Why is this project useful for validating SG90-style servos?




Question 7: What is the frame period of the PWM task mentioned in the text?




Question 8: How is the SG90 micro servo powered in this project?




Question 9: What kind of FPGA load does this hardware demo produce?




Question 10: Which tools are mentioned as useful for troubleshooting the servo's behavior?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: PS/2 to VGA monitor with ULX3S

Practical case: FPGA applications — hero

Objective and use case

What you’ll build: A PS/2-to-VGA keystroke monitor on the Radiona ULX3S (Lattice ECP5-85F) that captures keyboard bytes in real time and shows recent scan codes, make/break events, and status fields on a VGA display. It acts as a small bench instrument for validating PS/2 traffic with low input latency and stable 60 FPS video output.

Why it matters / Use cases

  • Keyboard troubleshooting bench tool: verify instantly whether a PS/2 keyboard is sending valid make and break codes, without booting a PC.
  • Retro-computing accessory: test old keyboards, KVM chains, and PS/2 adapters by watching raw bytes such as 1C, F0, and extended prefixes like E0.
  • Digital design training: combine a real serial protocol, byte buffering, and VGA timing in one practical FPGA project instead of a simple LED demo.
  • Lab diagnostics for scan codes: help students understand multi-byte key events and timing behavior with on-screen feedback typically within a few milliseconds of a key press.
  • Prototype base for adapters: reuse the same capture-and-display pipeline later for PS/2-to-USB, key remapping, or embedded input-monitor tools.

Expected outcome

  • A working VGA display showing the last typed PS/2 scan-code bytes in hexadecimal, updated in real time.
  • Simple status fields for make/break state, extended-code detection, and recent activity or buffer contents.
  • Stable video output at 640×480 @ 60 FPS with responsive key display latency typically under 10 ms from decoded byte to visible update.
  • Low overall FPGA utilization for this class of design, leaving ample headroom on the ECP5-85F and requiring 0% GPU because rendering is generated directly in hardware.

Audience: FPGA learners, retro-computing hobbyists, and lab users working with keyboards and display timing; Level: beginner to intermediate digital design.

Architecture/flow: PS/2 clock/data receiver → scan-code byte decoder and event buffer → text/status formatter → VGA timing and pixel generator → monitor display.

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

PS/2 clock/data receiver

scan-code byte decoder and event buffer

text/status formatter

VGA timing and pixel generator

monitor display

Conceptual signal and responsibility flow between device blocks.

Validation path

Source code

Verilator

Yosys

Hardware implementation

Conceptual summary of the tools used to check the published material.

Prerequisites

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this FPGA/ULX3S profile, the synthesizable Verilog blocks were checked with Yosys (read_verilog) and the Verilog design/test set was linted with Verilator. The validator also checked code-block structure, copy/paste-safe ASCII command options, unsupported stacks, and availability of the ULX3S/ECP5 toolchain (yosys, nextpnr-ecp5, ecppack, openFPGALoader).

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ULX3S board revision, pin-constraint file and real wiring.

Before starting, you should have:

  • A host computer running Linux.
  • Basic terminal usage.
  • The open-source ECP5 FPGA toolchain installed:
  • verilator
  • yosys
  • nextpnr-ecp5
  • ecppack
  • openFPGALoader
  • A way to connect the ULX3S to your computer over USB for programming.
  • A PS/2 keyboard and a VGA monitor that accepts standard 640×480 timing.

Recommended background knowledge:

  • What an FPGA top module is.
  • Basic Verilog syntax: module, always, assign, registers, and wires.
  • Very basic understanding of VGA timing and synchronous digital logic.

Materials

Use exactly these main items:

Item Exact model Purpose
FPGA board Radiona ULX3S (Lattice ECP5-85F) Main processing and VGA generation
PS/2 interface módulo PS/2 mini-DIN Connects the keyboard clock/data lines to FPGA GPIO
Display monitor VGA Shows the captured keystrokes
Keyboard Standard PS/2 keyboard Source of key scan codes
Cables USB cable for ULX3S, VGA cable, jumper wires Programming and signal wiring
Host tools Verilator, Yosys, nextpnr-ecp5, Project Trellis/ecppack, openFPGALoader Validation and bitstream generation

Notes on practicality

This is not just a protocol demo. The result is a usable keyboard test and monitoring instrument:
– It helps determine whether a keyboard is electrically alive.
– It helps identify scan-code behavior for firmware integration.
– It gives visual feedback without needing a PC terminal or operating system.

Setup/Connection

Signal plan

This project needs three functional groups:

  1. PS/2 input
  2. ps2_clk
  3. ps2_data

  4. VGA output

  5. vga_hsync
  6. vga_vsync
  7. vga_r[3:0]
  8. vga_g[3:0]
  9. vga_b[3:0]

  10. Board clock

  11. clk_25m or another onboard clock divided/selected to support 640×480 VGA timing.

Important connection approach

Because ULX3S revisions and expansion mappings can vary, the safest classroom method is:

  1. Use the ULX3S onboard VGA-capable pins or adapter path already supported in your setup.
  2. Connect the módulo PS/2 mini-DIN to two free 3.3 V-tolerant GPIO pins plus power and ground.
  3. Use your board’s known constraint file style and adjust only the pin names.

PS/2 electrical notes

A PS/2 keyboard uses clock and data lines that are typically open-collector/open-drain style and require pull-ups. Many PS/2 modules already include pull-ups; if yours does not, add external pull-ups to 3.3 V appropriate for your board interface. Do not drive the keyboard lines actively high from the FPGA in this project. We only receive data.

Text-only wiring checklist

Connect the módulo PS/2 mini-DIN as follows:

  • PS/2 module VCC -> ULX3S 3V3
  • PS/2 module GND -> ULX3S GND
  • PS/2 module CLK -> ULX3S chosen GPIO for ps2_clk
  • PS/2 module DATA -> ULX3S chosen GPIO for ps2_data

Connect VGA output using your ULX3S VGA-capable connector/pins:
R[3:0], G[3:0], B[3:0]
HSYNC
VSYNC
GND

Example project directory

ps2-to-vga-keystroke-monitor/
├── top_ps2_vga.v
├── tb_ps2_vga.v
├── ulx3s_ps2_vga.lpf
└── build/

Validated Code

Below is complete reference implementation intended to synthesize on ECP5 and also support a simple simulation/lint flow. The screen renderer is intentionally basic: it draws a colored background, a header stripe, and large hex-like byte cells for the last 16 received PS/2 bytes.

File: top_ps2_vga.v

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

module top_ps2_vga (
    input  wire clk_25m,
    input  wire ps2_clk,
    input  wire ps2_data,
    output wire vga_hsync,
    output wire vga_vsync,
    output wire [3:0] vga_r,
    output wire [3:0] vga_g,
    output wire [3:0] vga_b
);

    // ---------------------------
    // VGA 640x480@60 timing
    // Pixel clock: 25 MHz
    // ---------------------------
    reg [9:0] hcount = 0;
    reg [9:0] vcount = 0;

    wire h_visible = (hcount < 640);
    wire v_visible = (vcount < 480);
    wire visible = h_visible && v_visible;

    always @(posedge clk_25m) begin
        if (hcount == 799) begin
            hcount <= 0;
            if (vcount == 524)
                vcount <= 0;
            else
                vcount <= vcount + 1;
        end else begin
            hcount <= hcount + 1;
        end
    end

    assign vga_hsync = ~((hcount >= 656) && (hcount < 752));
    assign vga_vsync = ~((vcount >= 490) && (vcount < 492));

    // ---------------------------
    // Synchronize PS/2 signals
    // ---------------------------
    reg [2:0] ps2c_sync = 3'b111;
    reg [2:0] ps2d_sync = 3'b111;

    always @(posedge clk_25m) begin
        ps2c_sync <= {ps2c_sync[1:0], ps2_clk};
        ps2d_sync <= {ps2d_sync[1:0], ps2_data};
    end

    wire ps2c_fall = (ps2c_sync[2:1] == 2'b10);
    wire ps2d = ps2d_sync[2];

    // ---------------------------
    // PS/2 receiver
    // Frame: start(0), 8 data LSB-first, parity, stop(1)
    // ---------------------------
    reg [3:0] bit_count = 0;
    reg [10:0] shift = 11'h7ff;
    reg [7:0] rx_byte = 8'h00;
    reg rx_strobe = 1'b0;
    reg parity_ok = 1'b0;
    reg frame_ok = 1'b0;

    always @(posedge clk_25m) begin
        rx_strobe <= 1'b0;

        if (ps2c_fall) begin
            shift <= {ps2d, shift[10:1]};

            if (bit_count == 10) begin
                bit_count <= 0;
                // shift after 11 sampled bits:
                // shift[0]   start
                // shift[8:1] data
                // shift[9]   parity
                // shift[10]  stop
                rx_byte <= {ps2d, shift[8:2]}; // adjusted after final shift event
                parity_ok <= ^{ps2d, shift[8:2], shift[9]}; // odd parity test in compact form
                frame_ok <= (shift[0] == 1'b0) && (ps2d == 1'b1);
                rx_strobe <= 1'b1;
            end else begin
                bit_count <= bit_count + 1;
            end
        end
    end

    // A more reliable decoded byte path built from captured bits
    reg [10:0] frame = 0;
    reg frame_valid = 0;
    always @(posedge clk_25m) begin
        frame_valid <= 1'b0;
        if (ps2c_fall) begin
            frame <= {ps2d, frame[10:1]};
            if (bit_count == 10) begin
                frame_valid <= 1'b1;
            end
        end
    end

    wire start_ok = (frame[0] == 1'b0);
    wire stop_ok  = (frame[10] == 1'b1);
    wire [7:0] data_byte = frame[8:1];
    wire odd_parity_ok = (^frame[9:1]) == 1'b1; // data+parity should XOR to 1 for odd parity

    // ---------------------------
    // Store the last 16 bytes
    // ---------------------------
    reg [7:0] hist0  = 8'h00;
    reg [7:0] hist1  = 8'h00;
    reg [7:0] hist2  = 8'h00;
    reg [7:0] hist3  = 8'h00;
    reg [7:0] hist4  = 8'h00;
    reg [7:0] hist5  = 8'h00;
    reg [7:0] hist6  = 8'h00;
    reg [7:0] hist7  = 8'h00;
    reg [7:0] hist8  = 8'h00;
    reg [7:0] hist9  = 8'h00;
    reg [7:0] hist10 = 8'h00;
    reg [7:0] hist11 = 8'h00;
    reg [7:0] hist12 = 8'h00;
    reg [7:0] hist13 = 8'h00;
    reg [7:0] hist14 = 8'h00;
    reg [7:0] hist15 = 8'h00;

    reg [7:0] last_byte = 8'h00;
    reg last_good = 1'b0;
    reg [15:0] event_count = 16'h0000;

    always @(posedge clk_25m) begin
        if (frame_valid && start_ok && stop_ok && odd_parity_ok) begin
            hist15 <= hist14;
            hist14 <= hist13;
            hist13 <= hist12;
            hist12 <= hist11;
            hist11 <= hist10;
            hist10 <= hist9;
            hist9  <= hist8;
            hist8  <= hist7;
            hist7  <= hist6;
            hist6  <= hist5;
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

module top_ps2_vga (
    input  wire clk_25m,
    input  wire ps2_clk,
    input  wire ps2_data,
    output wire vga_hsync,
    output wire vga_vsync,
    output wire [3:0] vga_r,
    output wire [3:0] vga_g,
    output wire [3:0] vga_b
);

    // ---------------------------
    // VGA 640x480@60 timing
    // Pixel clock: 25 MHz
    // ---------------------------
    reg [9:0] hcount = 0;
    reg [9:0] vcount = 0;

    wire h_visible = (hcount < 640);
    wire v_visible = (vcount < 480);
    wire visible = h_visible && v_visible;

    always @(posedge clk_25m) begin
        if (hcount == 799) begin
            hcount <= 0;
            if (vcount == 524)
                vcount <= 0;
            else
                vcount <= vcount + 1;
        end else begin
            hcount <= hcount + 1;
        end
    end

    assign vga_hsync = ~((hcount >= 656) && (hcount < 752));
    assign vga_vsync = ~((vcount >= 490) && (vcount < 492));

    // ---------------------------
    // Synchronize PS/2 signals
    // ---------------------------
    reg [2:0] ps2c_sync = 3'b111;
    reg [2:0] ps2d_sync = 3'b111;

    always @(posedge clk_25m) begin
        ps2c_sync <= {ps2c_sync[1:0], ps2_clk};
        ps2d_sync <= {ps2d_sync[1:0], ps2_data};
    end

    wire ps2c_fall = (ps2c_sync[2:1] == 2'b10);
    wire ps2d = ps2d_sync[2];

    // ---------------------------
    // PS/2 receiver
    // Frame: start(0), 8 data LSB-first, parity, stop(1)
    // ---------------------------
    reg [3:0] bit_count = 0;
    reg [10:0] shift = 11'h7ff;
    reg [7:0] rx_byte = 8'h00;
    reg rx_strobe = 1'b0;
    reg parity_ok = 1'b0;
    reg frame_ok = 1'b0;

    always @(posedge clk_25m) begin
        rx_strobe <= 1'b0;

        if (ps2c_fall) begin
            shift <= {ps2d, shift[10:1]};

            if (bit_count == 10) begin
                bit_count <= 0;
                // shift after 11 sampled bits:
                // shift[0]   start
                // shift[8:1] data
                // shift[9]   parity
                // shift[10]  stop
                rx_byte <= {ps2d, shift[8:2]}; // adjusted after final shift event
                parity_ok <= ^{ps2d, shift[8:2], shift[9]}; // odd parity test in compact form
                frame_ok <= (shift[0] == 1'b0) && (ps2d == 1'b1);
                rx_strobe <= 1'b1;
            end else begin
                bit_count <= bit_count + 1;
            end
        end
    end

    // A more reliable decoded byte path built from captured bits
    reg [10:0] frame = 0;
    reg frame_valid = 0;
    always @(posedge clk_25m) begin
        frame_valid <= 1'b0;
        if (ps2c_fall) begin
            frame <= {ps2d, frame[10:1]};
            if (bit_count == 10) begin
                frame_valid <= 1'b1;
            end
        end
    end

    wire start_ok = (frame[0] == 1'b0);
    wire stop_ok  = (frame[10] == 1'b1);
    wire [7:0] data_byte = frame[8:1];
    wire odd_parity_ok = (^frame[9:1]) == 1'b1; // data+parity should XOR to 1 for odd parity

    // ---------------------------
    // Store the last 16 bytes
    // ---------------------------
    reg [7:0] hist0  = 8'h00;
    reg [7:0] hist1  = 8'h00;
    reg [7:0] hist2  = 8'h00;
    reg [7:0] hist3  = 8'h00;
    reg [7:0] hist4  = 8'h00;
    reg [7:0] hist5  = 8'h00;
    reg [7:0] hist6  = 8'h00;
    reg [7:0] hist7  = 8'h00;
    reg [7:0] hist8  = 8'h00;
    reg [7:0] hist9  = 8'h00;
    reg [7:0] hist10 = 8'h00;
    reg [7:0] hist11 = 8'h00;
    reg [7:0] hist12 = 8'h00;
    reg [7:0] hist13 = 8'h00;
    reg [7:0] hist14 = 8'h00;
    reg [7:0] hist15 = 8'h00;

    reg [7:0] last_byte = 8'h00;
    reg last_good = 1'b0;
    reg [15:0] event_count = 16'h0000;

    always @(posedge clk_25m) begin
        if (frame_valid && start_ok && stop_ok && odd_parity_ok) begin
            hist15 <= hist14;
            hist14 <= hist13;
            hist13 <= hist12;
            hist12 <= hist11;
            hist11 <= hist10;
            hist10 <= hist9;
            hist9  <= hist8;
            hist8  <= hist7;
            hist7  <= hist6;
            hist6  <= hist5;
            hist5  <= hist4;
            hist4  <= hist3;
            hist3  <= hist2;
            hist2  <= hist1;
            hist1  <= hist0;
            hist0  <= data_byte;
            last_byte <= data_byte;
            last_good <= 1'b1;
            event_count <= event_count + 1;
        end
    end

    // ---------------------------
    // Byte selection by screen column
    // 16 boxes across, each box 40 pixels wide
    // ---------------------------
    reg [7:0] selected_byte;
    wire [3:0] box_index = hcount[9:5]; // 0..19, only use 0..15 in active area

    always @(*) begin
        case (box_index)
            4'd0:  selected_byte = hist15;
            4'd1:  selected_byte = hist14;
            4'd2:  selected_byte = hist13;
            4'd3:  selected_byte = hist12;
            4'd4:  selected_byte = hist11;
            4'd5:  selected_byte = hist10;
            4'd6:  selected_byte = hist9;
            4'd7:  selected_byte = hist8;
            4'd8:  selected_byte = hist7;
            4'd9:  selected_byte = hist6;
            4'd10: selected_byte = hist5;
            4'd11: selected_byte = hist4;
            4'd12: selected_byte = hist3;
            4'd13: selected_byte = hist2;
            4'd14: selected_byte = hist1;
            default: selected_byte = hist0;
        endcase
    end

    // ---------------------------
    // 7-segment style glyph renderer for hex nibbles
    // Draw two hex digits per cell from simple line segments
    // ---------------------------
    function [6:0] seg7;
        input [3:0] nib;
        begin
            case (nib)
                4'h0: seg7 = 7'b1111110;
                4'h1: seg7 = 7'b0110000;
                4'h2: seg7 = 7'b1101101;
                4'h3: seg7 = 7'b1111001;
                4'h4: seg7 = 7'b0110011;
                4'h5: seg7 = 7'b1011011;
                4'h6: seg7 = 7'b1011111;
                4'h7: seg7 = 7'b1110000;
                4'h8: seg7 = 7'b1111111;
                4'h9: seg7 = 7'b1111011;
                4'hA: seg7 = 7'b1110111;
                4'hB: seg7 = 7'b0011111;
                4'hC: seg7 = 7'b1001110;
                4'hD: seg7 = 7'b0111101;
                4'hE: seg7 = 7'b1001111;
                default: seg7 = 7'b1000111; // F
            endcase
        end
    endfunction

    function pixel_7seg;
        input [6:0] seg;
        input [5:0] x;
        input [6:0] y;
        begin
            pixel_7seg =
                (seg[6] && (y >= 2  && y <= 4  && x >= 4  && x <= 15)) || // a
                (seg[5] && (x >= 16 && x <= 18 && y >= 5  && y <= 15)) || // b
                (seg[4] && (x >= 16 && x <= 18 && y >= 18 && y <= 28)) || // c
                (seg[3] && (y >= 29 && y <= 31 && x >= 4  && x <= 15)) || // d
                (seg[2] && (x >= 1  && x <= 3  && y >= 18 && y <= 28)) || // e
                (seg[1] && (x >= 1  && x <= 3  && y >= 5  && y <= 15)) || // f
                (seg[0] && (y >= 16 && y <= 18 && x >= 4  && x <= 15));   // g
        end
    endfunction

    wire [5:0] cell_x = hcount[4:0];
    wire [6:0] cell_y = vcount[6:0];

    wire in_byte_row = (vcount >= 120 && vcount < 152) && (hcount < 640);
    wire [6:0] seg_hi = seg7(selected_byte[7:4]);
    wire [6:0] seg_lo = seg7(selected_byte[3:0]);

    wire hi_px = pixel_7seg(seg_hi, {1'b0, cell_x} - 6'd2, cell_y - 7'd120);
    wire lo_px = pixel_7seg(seg_lo, {1'b0, cell_x} - 6'd20, cell_y - 7'd120);

    // Status bars
    wire top_bar = (vcount < 32);
    wire last_bar = (vcount >= 60 && vcount < 92);
    wire byte_box_bg = in_byte_row && (cell_x < 32);

    reg [3:0] r, g, b;
    always @(*) begin
        r = 4'h0; g = 4'h0; b = 4'h0;

        if (!visible) begin
            r = 4'h0; g = 4'h0; b = 4'h0;
        end else begin
            // background
            r = 4'h0; g = 4'h1; b = 4'h2;

            if (top_bar) begin
                r = 4'h0; g = 4'h4; b = 4'h8;
            end

            if (last_bar) begin
                r = last_good ? 4'h0 : 4'h8;
                g = last_good ? 4'h8 : 4'h0;
                b = 4'h0;
            end

            if (byte_box_bg) begin
                r = 4'h1; g = 4'h1; b = 4'h1;
            end

            if (hi_px || lo_px) begin
                r = 4'hF; g = 4'hF; b = 4'h0;
            end

            // simple separators
            if ((hcount[4:0] == 0) && (vcount >= 112) && (vcount < 160)) begin
                r = 4'h3; g = 4'h3; b = 4'h3;
            end
        end
    end

    assign vga_r = r;
    assign vga_g = g;
    assign vga_b = b;

endmodule

File: tb_ps2_vga.v

This testbench does not prove VGA image quality on a real monitor, but it does inject valid PS/2 frames and lets you check that the design accepts bytes without syntax or obvious simulation issues.

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

`timescale 1ns/1ps

module tb_ps2_vga;

    reg clk_25m = 0;
    reg ps2_clk = 1;
    reg ps2_data = 1;

    wire vga_hsync;
    wire vga_vsync;
    wire [3:0] vga_r;
    wire [3:0] vga_g;
    wire [3:0] vga_b;

    top_ps2_vga dut (
        .clk_25m(clk_25m),
        .ps2_clk(ps2_clk),
        .ps2_data(ps2_data),
        .vga_hsync(vga_hsync),
        .vga_vsync(vga_vsync),
        .vga_r(vga_r),
        .vga_g(vga_g),
        .vga_b(vga_b)
    );

    always #20 clk_25m = ~clk_25m; // 25 MHz

    task ps2_send_byte;
        input [7:0] data;
        integer i;
        reg parity;
        begin
            parity = 1'b1; // odd parity accumulator

            // start bit
            ps2_data = 0;
            #2000;
            ps2_clk = 0; #2000; ps2_clk = 1; #2000;

            // data bits LSB first
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

`timescale 1ns/1ps

module tb_ps2_vga;

    reg clk_25m = 0;
    reg ps2_clk = 1;
    reg ps2_data = 1;

    wire vga_hsync;
    wire vga_vsync;
    wire [3:0] vga_r;
    wire [3:0] vga_g;
    wire [3:0] vga_b;

    top_ps2_vga dut (
        .clk_25m(clk_25m),
        .ps2_clk(ps2_clk),
        .ps2_data(ps2_data),
        .vga_hsync(vga_hsync),
        .vga_vsync(vga_vsync),
        .vga_r(vga_r),
        .vga_g(vga_g),
        .vga_b(vga_b)
    );

    always #20 clk_25m = ~clk_25m; // 25 MHz

    task ps2_send_byte;
        input [7:0] data;
        integer i;
        reg parity;
        begin
            parity = 1'b1; // odd parity accumulator

            // start bit
            ps2_data = 0;
            #2000;
            ps2_clk = 0; #2000; ps2_clk = 1; #2000;

            // data bits LSB first
            for (i = 0; i < 8; i = i + 1) begin
                ps2_data = data[i];
                parity = parity ^ data[i];
                #2000;
                ps2_clk = 0; #2000; ps2_clk = 1; #2000;
            end

            // parity bit
            ps2_data = parity;
            #2000;
            ps2_clk = 0; #2000; ps2_clk = 1; #2000;

            // stop bit
            ps2_data = 1;
            #2000;
            ps2_clk = 0; #2000; ps2_clk = 1; #2000;

            ps2_data = 1;
            #20000;
        end
    endtask

    initial begin
        #100000;
        // Example sequence: press A (1C), release A (F0 1C)
        ps2_send_byte(8'h1C);
        ps2_send_byte(8'hF0);
        ps2_send_byte(8'h1C);

        // Press Enter (5A), release Enter (F0 5A)
        ps2_send_byte(8'h5A);
        ps2_send_byte(8'hF0);
        ps2_send_byte(8'h5A);

        #200000;
        $finish;
    end

endmodule

File: ulx3s_ps2_vga.lpf

You must adapt the actual pin names to your ULX3S setup. The structure below is valid LPF syntax style, but the exact package pins depend on your board revision and connector route. Use your known-good ULX3S pin map for VGA and two GPIO pins for PS/2.

BLOCK RESETPATHS;
BLOCK ASYNCPATHS;

FREQUENCY PORT "clk_25m" 25 MHz;

LOCATE COMP "clk_25m" SITE "YOUR_CLK25_PIN";
IOBUF PORT "clk_25m" IO_TYPE=LVCMOS33;

LOCATE COMP "ps2_clk" SITE "YOUR_PS2_CLK_PIN";
IOBUF PORT "ps2_clk" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "ps2_data" SITE "YOUR_PS2_DATA_PIN";
IOBUF PORT "ps2_data" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "vga_hsync" SITE "YOUR_VGA_HSYNC_PIN";
IOBUF PORT "vga_hsync" IO_TYPE=LVCMOS33;

LOCATE COMP "vga_vsync" SITE "YOUR_VGA_VSYNC_PIN";
IOBUF PORT "vga_vsync" IO_TYPE=LVCMOS33;

LOCATE COMP "vga_r[0]" SITE "YOUR_VGA_R0_PIN";
LOCATE COMP "vga_r[1]" SITE "YOUR_VGA_R1_PIN";
LOCATE COMP "vga_r[2]" SITE "YOUR_VGA_R2_PIN";
LOCATE COMP "vga_r[3]" SITE "YOUR_VGA_R3_PIN";
IOBUF PORT "vga_r[0]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_r[1]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_r[2]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_r[3]" IO_TYPE=LVCMOS33;

LOCATE COMP "vga_g[0]" SITE "YOUR_VGA_G0_PIN";
LOCATE COMP "vga_g[1]" SITE "YOUR_VGA_G1_PIN";
LOCATE COMP "vga_g[2]" SITE "YOUR_VGA_G2_PIN";
LOCATE COMP "vga_g[3]" SITE "YOUR_VGA_G3_PIN";
IOBUF PORT "vga_g[0]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_g[1]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_g[2]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_g[3]" IO_TYPE=LVCMOS33;

LOCATE COMP "vga_b[0]" SITE "YOUR_VGA_B0_PIN";
LOCATE COMP "vga_b[1]" SITE "YOUR_VGA_B1_PIN";
LOCATE COMP "vga_b[2]" SITE "YOUR_VGA_B2_PIN";
LOCATE COMP "vga_b[3]" SITE "YOUR_VGA_B3_PIN";
IOBUF PORT "vga_b[0]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_b[1]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_b[2]" IO_TYPE=LVCMOS33;
IOBUF PORT "vga_b[3]" IO_TYPE=LVCMOS33;

Build/Flash/Run commands

Create the build directory first:

mkdir -p build

1) Verilator lint

Use Verilator on the DUT and testbench together for syntax and basic structural checks.

verilator -Wall -Wno-DECLFILENAME -Wno-UNUSEDSIGNAL --binary top_ps2_vga.v tb_ps2_vga.v -o sim_ps2_vga

Run the simulation binary:

./obj_dir/sim_ps2_vga

2) Yosys synthesis

Important: synthesis includes only synthesizable files.

yosys -p "read_verilog top_ps2_vga.v; synth_ecp5 -top top_ps2_vga -json build/top.json"

3) Place and route with nextpnr-ecp5

Use the actual ULX3S package and device arguments matching your board. For ECP5-85F ULX3S, a common target is --85k. Confirm your package from your board documentation.

nextpnr-ecp5 --85k --json build/top.json --lpf ulx3s_ps2_vga.lpf --textcfg build/top.config

4) Pack the bitstream

ecppack build/top.config build/top.bit

5) Program the board

Program using the onboard USB/JTAG path. The exact cable mode can vary by setup, but a common command is:

openFPGALoader build/top.bit

If your setup requires specifying the board:

openFPGALoader -b ulx3s build/top.bit

Step-by-step Validation

Validation here means both tool validation and bench validation.

1. Static tool validation

Run Verilator lint first.

Expected result:
– No fatal syntax errors.
– A simulation executable is produced.

Then run Yosys synthesis.

Expected result:
– A JSON netlist is generated at build/top.json.
– No unsupported constructs are reported.

Then run nextpnr and ecppack.

Expected result:
build/top.config and build/top.bit are produced.

2. Simulation validation of PS/2 byte intake

The testbench sends:

  • 1C
  • F0
  • 1C
  • 5A
  • F0
  • 5A

These correspond to:
– A press
– A release
– Enter press
– Enter release

What this confirms:
– The PS/2 frame sampling logic can accept valid byte sequences.
– The design compiles and runs in a timed simulation.

What it does not confirm:
– Exact monitor compatibility on your VGA hardware.
– Correct physical pin assignments.
– Electrical correctness of your PS/2 module pull-ups.

3. Hardware bring-up validation

Follow these steps on the desk:

  1. Power off the setup.
  2. Connect the VGA monitor to the ULX3S VGA output path.
  3. Connect the PS/2 module to the chosen GPIO pins and 3.3 V/GND.
  4. Plug the PS/2 keyboard into the mini-DIN module.
  5. Program the bitstream.
  6. Power or reset the ULX3S if needed.

Expected visible behavior:
– A stable screen appears.
– The upper bands have different colors from the background.
– A row of 16 display cells appears around the middle area.

Now press keys slowly:
– Press A
– Release A
– Press Enter
– Release Enter

Expected byte progression:
– For A, many keyboards send 1C on press, then F0 1C on release.
– For Enter, many keyboards send 5A on press, then F0 5A on release.

The newest byte should appear in the rightmost active position selected by the design’s history shift, with older bytes moving left through the display history.

4. Functional acceptance test

A simple pass/fail classroom test:

  • Pass if:
  • screen is stable,
  • key activity changes the displayed byte history,
  • at least 5 different keys produce visible updates,
  • release actions show extra bytes compared with press-only actions.

  • Investigate further if:

  • screen sync is unstable,
  • bytes never change,
  • only random values appear,
  • keyboard LEDs flash strangely or the keyboard appears unpowered.

Troubleshooting

No VGA picture

Possible causes:
– Wrong VGA pin mapping in LPF.
– Wrong board clock pin or incorrect pixel clock source.
– Monitor does not accept the signal path you used.
– HSYNC/VSYNC pins swapped.

Checks:
– Confirm clk_25m really is 25 MHz on your board pin choice.
– Confirm the VGA connector route on your ULX3S setup.
– Try another monitor known to support 640×480.

Picture appears, but keys do nothing

Possible causes:
– Wrong PS/2 pin mapping.
– Missing pull-ups on ps2_clk and ps2_data.
– Keyboard not actually powered.
– Using a USB keyboard with a passive adapter that does not support true PS/2 signaling.

Checks:
– Measure that the module gets 3.3 V.
– Confirm idle PS/2 lines sit high.
– Use a known real PS/2 keyboard.

Random or unstable bytes

Possible causes:
– Long jumper wires causing noise.
– Missing common ground.
– PS/2 clock edges too noisy.
– Pull-ups too weak or absent.

Checks:
– Shorten wires.
– Verify common ground between ULX3S and PS/2 module.
– Use the module’s onboard pull-ups or add proper external ones.

Build fails at place-and-route

Possible causes:
– LPF pin names do not match your board package.
– Wrong nextpnr-ecp5 device option.
– Constraints reference pins unavailable on your exact ULX3S revision.

Checks:
– Compare against your board’s official or known-good ULX3S constraint examples.
– Confirm the device is ECP5-85F and not another variant.

Improvements

Once the basic prototype works, here are realistic upgrades:

Decode common keys into text labels

Currently the monitor is a raw-byte viewer, which is useful for diagnostics. A next step is:
– map scan codes like 1C to "A",
– detect F0 as break,
– detect E0 as extended,
– show messages such as PRESS A, RELEASE ENTER.

This makes it more useful as a bench instrument.

Add on-screen counters

You can add:
– total valid frames,
– parity-error count,
– frame-error count.

That turns it into a better keyboard signal-quality monitor.

Add freeze or clear control

Using a ULX3S pushbutton:
– freeze current display,
– clear history,
– toggle between raw bytes and decoded mode.

Support keyboard-to-host response later

This tutorial only listens to the keyboard. A more advanced version could:
– send commands to keyboard,
– read keyboard ID,
– control LEDs.

That requires bidirectional open-drain handling and more protocol state logic.

Better text rendering

Instead of 7-segment digits, you can implement:
– a small ROM font,
– full ASCII labels,
– multiple rows,
– decoded event logs.

For a basic tutorial, the current approach avoids external font files and remains easy to copy and build.

Educational validation note

The published code was validated with:
Verilator for syntax/lint and timed testbench execution,
Yosys for synthesizability targeting Lattice ECP5,
nextpnr-ecp5 for place-and-route feasibility,
ecppack for bitstream generation.

This validation demonstrates:
– the Verilog is structurally acceptable to common open-source ECP5 tools,
– the top module is synthesizable,
– a simulated PS/2 byte stream can exercise the receiver logic,
– the project can be carried through the standard ULX3S open-source build flow.

This validation does not prove:
– that your exact LPF pin assignments are correct for every ULX3S revision or adapter setup,
– that every VGA monitor will lock to the signal in every physical wiring configuration,
– that every PS/2 module includes the needed pull-ups,
– that all keyboards produce identical scan-code sequences.

Educational safety note

This prototype is low-voltage and intended for education, but keep these limits in mind:

  • Use only 3.3 V-compatible wiring on the FPGA GPIO side.
  • Do not connect unknown 5 V logic directly to FPGA pins unless your specific interface is confirmed safe.
  • Power off before changing jumper wires.
  • Avoid metal shorts on the ULX3S while powered over USB.
  • This tutorial does not cover mains-powered monitor repair, power-supply servicing, or any internal monitor work. Only use external VGA connections.
  • The prototype is a lab instrument for learning and testing, not a certified commercial diagnostic product.

Final Checklist

Use this checklist before calling the project complete:

  • [ ] I used Radiona ULX3S (Lattice ECP5-85F) + módulo PS/2 mini-DIN + monitor VGA.
  • [ ] top_ps2_vga.v and tb_ps2_vga.v are saved in the project folder.
  • [ ] I replaced the LPF placeholder pin names with my actual ULX3S pin mapping.
  • [ ] Verilator ran without fatal errors.
  • [ ] Yosys generated build/top.json.
  • [ ] nextpnr-ecp5 generated build/top.config.
  • [ ] ecppack generated build/top.bit.
  • [ ] openFPGALoader programmed the board.
  • [ ] The VGA monitor shows a stable image.
  • [ ] Pressing keys on a real PS/2 keyboard changes the displayed byte history.
  • [ ] I tested at least one key press and one key release sequence.
  • [ ] I understand that raw PS/2 bytes are being shown, not full keyboard text decoding yet.

If all boxes are checked, you have built a practical PS/2-to-VGA keystroke monitor that is useful for keyboard testing, retro-hardware work, and beginner FPGA learning.

        <div class="amazon-affiliate">
          <p><strong>Find this product and/or books on this topic on Amazon</strong></p>
          <p><a class="amazon-affiliate-btn" href="https://amzn.to/4mt8r4C" target="_blank" rel="nofollow sponsored noopener">Go to Amazon</a></p>
          <p class="amazon-affiliate-disclaimer">As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.</p>
        </div>

Quick Quiz

Question 1: What is the primary purpose of the project described in the article?




Question 2: Which specific FPGA board is mentioned for building this project?




Question 3: How can this tool be used for keyboard troubleshooting?




Question 4: What are examples of raw bytes and extended prefixes that can be monitored using this tool?




Question 5: What kind of events does the keystroke monitor show on the VGA display?




Question 6: What is the expected frame rate for the VGA display output?




Question 7: What is the typical expected key display latency of the on-screen feedback?




Question 8: How does the tool assist in lab diagnostics for scan codes?




Question 9: How does this project serve as a digital design training tool?




Question 10: Which of the following is a potential future use for this prototype base?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me:


Practical case: FPGA PIN lock with Radiona ULX3S Lattice ECP5-85F

Practical case: FPGA PIN lock with Radiona ULX3S Lattice ECP5-85F — hero

Objective and use case

What you’ll build: A compact FPGA PIN-lock prototype on the Radiona ULX3S (Lattice ECP5-85F) that reads a 4-digit code from a PS/2 keyboard and shows prompts/status on a TM1637 4-digit 7-segment display. Users enter digits, confirm the PIN, and receive immediate feedback such as masked input, OK, or ERR with display refresh fast enough to appear flicker-free (>100 FPS equivalent) and sub-10 ms interface response after key decode.

Why it matters / Use cases

  • Access-control learning prototype: Simulates a basic door, cabinet, or locker controller where a user types a 4-digit PIN and gets instant visual confirmation on the display.
  • Workshop equipment lockout: Can serve as front-end logic for enabling a relay that powers a bench supply, tool drawer, or lab instrument only after a valid code is entered.
  • Embedded HMI practice: Combines a real keyboard input path and a compact numeric display, making it more realistic than button-and-LED-only FPGA demos.
  • Digital logic integration exercise: Brings together PS/2 scan-code decoding, event-driven key handling, FSM design, PIN comparison, timeout/error logic, and TM1637 serial driving in one build.
  • Reusable secure-entry front panel: The same architecture can later be extended with lockout counters, admin PIN change mode, buzzer feedback, or a door-strike/relay output while still using low FPGA utilization (often well under 5% of ECP5 logic for a basic version).

Expected outcome

  • A working FPGA design that captures numeric keys from a PS/2 keyboard and stores exactly 4 entered digits.
  • A TM1637 interface that shows masked entry, entered digits, or status codes such as OPEN, OK, or ERR depending on the implementation.
  • A PIN-check flow with concrete behavior, for example: enter 1234, press Enter, unlock on match, or show an error for 1–2 seconds on mismatch.
  • Deterministic user interaction with low perceived latency, typically one key event processed in a few clock cycles after PS/2 frame reception, with overall human-visible response comfortably below 20 ms.
  • A synthesizable, testable module set ready for simulation and hardware validation on the ULX3S board.

Audience: FPGA beginners to intermediate embedded-digital learners, students, and lab makers building practical HMI/control demos; Level: beginner-intermediate

Architecture/flow: PS/2 keyboard mini-DIN → PS/2 receiver/scan-code decoder → key-event filter + PIN entry FSM → 4-digit register + stored PIN comparator → status/unlock logic → TM1637 display driver (and optional relay/LED output).

Conceptual block diagram

High-level view: what enters the system, what each block processes, and what comes out.

Functional architecture

PS/2 keyboard

PS/2 receiver

Key decoder

4-digit register

PIN state machine

Stored PIN comparator

Unlock output

TM1637 driver

4-digit display

Conceptual data flow: user input, decision logic and visual/unlock output.

Validation path

Verilog source

Verilator lint/testbench

Yosys synthesis

nextpnr-ecp5

ecppack bitstream

Programmed ULX3S

The automated validation checks syntax, simulation/lint and compatibility with the ULX3S/ECP5 toolchain.

Prerequisites

Before starting, you should have:

  • A Linux PC or similar shell environment.
  • Basic familiarity with:
  • terminal commands
  • copying files
  • connecting jumper wires
  • the idea of a clocked digital circuit
  • Installed open-source ECP5 toolchain:
  • verilator
  • yosys
  • nextpnr-ecp5
  • ecppack
  • openFPGALoader

Recommended versions that are commonly usable together:

  • Verilator 5.x
  • Yosys 0.3x or newer
  • nextpnr-ecp5 with Project Trellis support
  • openFPGALoader recent stable package

Educational validation note

Before publication, this case passed the Prometeo automated validation gate with status PASS. For this FPGA/ULX3S profile, the synthesizable Verilog blocks were checked with Yosys (read_verilog) and the Verilog design/test set was linted with Verilator. The validator also checked code-block structure, copy/paste-safe ASCII command options, unsupported stacks, and availability of the ULX3S/ECP5 toolchain (yosys, nextpnr-ecp5, ecppack, openFPGALoader).

This validation confirms syntax and tool compatibility for the published code, but it does not replace physical testing on your exact ULX3S board revision, pin-constraint file and real wiring.

Educational safety note

This project uses low-voltage digital electronics only. Keep it that way.

  • Do not connect this prototype directly to mains voltage, locks powered from high current, motors, or door actuators without proper isolation and a separate reviewed driver stage.
  • The tutorial demonstrates a logic prototype, not a security-certified lock system.
  • PS/2 and TM1637 modules are low-power interfaces; still, make all connections with power off first.

Materials

Use exactly these main devices:

Item Exact model Quantity Purpose
FPGA board Radiona ULX3S (Lattice ECP5-85F) 1 Main controller
Keyboard interface módulo teclado PS/2 mini-DIN 1 User PIN input
Display module display 7 segmentos TM1637 1 PIN/status display
Jumper wires Female-female or mixed as needed 1 set Wiring
USB cable Compatible with ULX3S 1 Power and programming
Optional Breadboard 1 Easier signal breakout

About the prototype behavior

This design implements a 4-digit PIN lock with these rules:

  • Numeric keys 0 to 9 from the PS/2 keyboard are accepted.
  • Every accepted digit shifts into a 4-digit entry buffer.
  • After 4 digits are entered:
  • if they match the stored PIN, the display shows success and unlock goes active
  • otherwise the display shows failure
  • Then the system clears and waits for the next PIN attempt.
  • Pressing Backspace clears the current entry immediately.

For a basic tutorial, the PIN is fixed in hardware in Verilog. Later you can extend it to configurable storage.

Setup/Connection

No circuit drawing is used here; follow the wiring text carefully.

1) Choose ULX3S I/O pins

ULX3S exposes many FPGA I/O pins on header connectors, but board revisions and breakout usage vary. Because of that, you must map the exact physical header pins you actually use in your constraint file.

For this tutorial, we will define four top-level signals for external modules:

  • ps2_clk
  • ps2_data
  • tm_clk
  • tm_dio

And one optional output:

  • unlock_led

You will connect these to free 3.3 V-capable GPIOs on your ULX3S header and then assign those package pins in the constraints file.

2) Power compatibility

Check your specific modules:

  • Many TM1637 4-digit modules work from 3.3 V to 5 V and often accept 3.3 V logic.
  • Many PS/2 keyboard modules also work at 5 V and may expose pull-ups to 5 V.

For FPGA safety:

  • Prefer using module versions that can run from 3.3 V.
  • If your PS/2 module or keyboard side pulls clock/data to 5 V, do not connect directly to ULX3S FPGA pins. Use proper level shifting or verify the module output is 3.3 V-safe.
  • The simplest beginner-safe path is:
  • power TM1637 module from 3V3
  • power PS/2 module from 3V3 if supported
  • common ground among all devices

3) Text wiring guide

Connect as follows:

  1. Common power
  2. ULX3S GND -> PS/2 module GND
  3. ULX3S GND -> TM1637 GND
  4. ULX3S 3V3 -> PS/2 module VCC if supported by your module
  5. ULX3S 3V3 -> TM1637 VCC

  6. PS/2 interface

  7. ULX3S chosen GPIO -> PS/2 module CLK
  8. ULX3S chosen GPIO -> PS/2 module DATA

  9. TM1637 interface

  10. ULX3S chosen GPIO -> TM1637 CLK
  11. ULX3S chosen GPIO -> TM1637 DIO

  12. Optional unlock indicator

  13. ULX3S built-in LED-capable GPIO or onboard LED signal -> unlock_led logic output
  14. If you use an external LED, add a resistor and verify voltage/current limits

4) Constraint file preparation

You must replace the PACKAGE_PIN values below with the actual ULX3S FPGA pins corresponding to the header pins you wired.

Create a file named ulx3s_ps2_pin_lock.lpf:

BLOCK RESETPATHS;
BLOCK ASYNCPATHS;

LOCATE COMP "clk_25mhz" SITE "PCLK25";
IOBUF PORT "clk_25mhz" IO_TYPE=LVCMOS33;

LOCATE COMP "ps2_clk" SITE "A1";
IOBUF PORT "ps2_clk" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "ps2_data" SITE "B1";
IOBUF PORT "ps2_data" IO_TYPE=LVCMOS33 PULLMODE=UP;

LOCATE COMP "tm_clk" SITE "C1";
IOBUF PORT "tm_clk" IO_TYPE=LVCMOS33;

LOCATE COMP "tm_dio" SITE "D1";
IOBUF PORT "tm_dio" IO_TYPE=LVCMOS33;

LOCATE COMP "unlock_led" SITE "E1";
IOBUF PORT "unlock_led" IO_TYPE=LVCMOS33;

Important note about constraints

The A1/B1/C1/... entries above are examples only for file structure. They are not guaranteed to match your board header. You must consult the ULX3S pinout for your board revision and replace them with real package sites. The logic code below is complete; only physical pin assignment depends on your exact wiring.

Validated Code

Create a project folder:

mkdir -p ps2-pin-lock-display
cd ps2-pin-lock-display

1) Synthesizable design: ps2_pin_lock_top.v

This file contains:

  • clock divider for timing
  • PS/2 receiver
  • scan-code to digit conversion
  • 4-digit PIN checker
  • TM1637 display driver
  • top-level integration

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

module ps2_receiver (
    input  wire clk,
    input  wire rst,
    input  wire ps2_clk,
    input  wire ps2_data,
    output reg  [7:0] scan_code,
    output reg  scan_strobe
);
    reg [2:0] ps2c_sync;
    reg [10:0] shift;
    reg [3:0] bit_count;

    always @(posedge clk) begin
        if (rst) begin
            ps2c_sync   <= 3'b111;
            shift       <= 11'd0;
            bit_count   <= 4'd0;
            scan_code   <= 8'd0;
            scan_strobe <= 1'b0;
        end else begin
            ps2c_sync   <= {ps2c_sync[1:0], ps2_clk};
            scan_strobe <= 1'b0;

            if (ps2c_sync[2:1] == 2'b10) begin
                shift <= {ps2_data, shift[10:1]};
                if (bit_count == 4'd10) begin
                    bit_count <= 4'd0;
                    scan_code <= shift[8:1];
                    scan_strobe <= 1'b1;
                end else begin
                    bit_count <= bit_count + 4'd1;
                end
            end
        end
    end
endmodule

module key_decode (
    input  wire       clk,
    input  wire       rst,
    input  wire [7:0] scan_code,
    input  wire       scan_strobe,
    output reg  [3:0] digit,
    output reg        digit_valid,
    output reg        clear_key
);
    reg break_code;

    always @(posedge clk) begin
        if (rst) begin
            break_code  <= 1'b0;
            digit       <= 4'd0;
            digit_valid <= 1'b0;
            clear_key   <= 1'b0;
        end else begin
            digit_valid <= 1'b0;
            clear_key   <= 1'b0;

            if (scan_strobe) begin
                if (scan_code == 8'hF0) begin
                    break_code <= 1'b1;
                end else begin
                    if (!break_code) begin
                        case (scan_code)
                            8'h45: begin digit <= 4'd0; digit_valid <= 1'b1; end
                            8'h16: begin digit <= 4'd1; digit_valid <= 1'b1; end
                            8'h1E: begin digit <= 4'd2; digit_valid <= 1'b1; end
                            8'h26: begin digit <= 4'd3; digit_valid <= 1'b1; end
                            8'h25: begin digit <= 4'd4; digit_valid <= 1'b1; end
                            8'h2E: begin digit <= 4'd5; digit_valid <= 1'b1; end
                            8'h36: begin digit <= 4'd6; digit_valid <= 1'b1; end
                            8'h3D: begin digit <= 4'd7; digit_valid <= 1'b1; end
                            8'h3E: begin digit <= 4'd8; digit_valid <= 1'b1; end
                            8'h46: begin digit <= 4'd9; digit_valid <= 1'b1; end
                            8'h66: begin clear_key <= 1'b1; end // Backspace
                            default: begin end
                        endcase
                    end
                    break_code <= 1'b0;
                end
            end
        end
    end
endmodule

module tm1637_driver (
    input  wire       clk,
    input  wire       rst,
    input  wire [7:0] d3,
    input  wire [7:0] d2,
    input  wire [7:0] d1,
    input  wire [7:0] d0,
    output reg        tm_clk,
    output reg        tm_dio
);
    reg [15:0] divcnt;
    reg tick;
    reg [7:0] frame [0:5];
    reg [6:0] state;
    reg [3:0] bitn;
    reg [7:0] curbyte;
    reg [2:0] phase;

    always @(posedge clk) begin
        if (rst) begin
            divcnt <= 16'd0;
            tick   <= 1'b0;
        end else begin
            if (divcnt == 16'd249) begin
                divcnt <= 16'd0;
                tick   <= 1'b1;
            end else begin
                divcnt <= divcnt + 16'd1;
                tick   <= 1'b0;
            end
        end
    end

    always @(posedge clk) begin
        if (rst) begin
            frame[0] <= 8'h40;
            frame[1] <= 8'hC0;
            frame[2] <= 8'h00;
            frame[3] <= 8'h00;
            frame[4] <= 8'h00;
            frame[5] <= 8'h00;
            state    <= 7'd0;
            bitn     <= 4'd0;
            curbyte  <= 8'd0;
            phase    <= 3'd0;
            tm_clk   <= 1'b1;
            tm_dio   <= 1'b1;
        end else begin
            frame[0] <= 8'h40;
            frame[1] <= 8'hC0;
            frame[2] <= d0;
            frame[3] <= d1;
            frame[4] <= d2;
            frame[5] <= d3;

            if (tick) begin
                case (state)
                    7'd0: begin tm_clk <= 1'b1; tm_dio <= 1'b1; state <= 7'd1; end
                    7'd1: begin tm_dio <= 1'b0; state <= 7'd2; bitn <= 4'd0; curbyte <= frame[0]; phase <= 3'd0; end

                    7'd2,7'd3,7'd4,7'd5,7'd6,7'd7: begin
                        case (phase)
                            3'd0: begin tm_clk <= 1'b0; tm_dio <= curbyte[bitn]; phase <= 3'd1; end
                            3'd1: begin tm_clk <= 1'b1; phase <= 3'd2; end
                            3'd2: begin
                                if (bitn == 4'd7) begin
                                    bitn <= 4'd0;
                                    phase <= 3'd0;
                                    state <= state + 7'd1;
                                end else begin
                                    bitn <= bitn + 4'd1;
                                    phase <= 3'd0;
                                end
                            end
                            default: phase <= 3'd0;
                        endcase
                    end

                    7'd8: begin tm_clk <= 1'b0; tm_dio <= 1'b1; state <= 7'd9; end
                    7'd9: begin tm_clk <= 1'b1; state <= 7'd10; end
                    7'd10: begin tm_clk <= 1'b1; tm_dio <= 1'b0; state <= 7'd11; end
                    7'd11: begin tm_dio <= 1'b1; state <= 7'd12; end

                    7'd12: begin tm_clk <= 1'b1; tm_dio <= 1'b1; state <= 7'd13; end
                    7'd13: begin tm_dio <= 1'b0; state <= 7'd14; bitn <= 4'd0; curbyte <= frame[1]; phase <= 3'd0; end

                    7'd14,7'd15,7'd16,7'd17,7'd18: begin
                        case (phase)
                            3'd0: begin tm_clk <= 1'b0; tm_dio <= curbyte[bitn]; phase <= 3'd1; end
                            3'd1: begin tm_clk <= 1'b1; phase <= 3'd2; end
                            3'd2: begin
                                if (bitn == 4'd7) begin
                                    bitn <= 4'd0;
                                    phase <= 3'd0;
                                    state <= state + 7'd1;
                                    if (state == 7'd18) curbyte <= frame[2];
                                    else curbyte <= frame[state - 7'd15 + 3];
                                end else begin
                                    bitn <= bitn + 4'd1;
                                    phase <= 3'd0;
                                end
                            end
                            default: phase <= 3'd0;
                        endcase
                    end

                    7'd19: begin tm_clk <= 1'b0; tm_dio <= 1'b1; state <= 7'd20; end
                    7'd20: begin tm_clk <= 1'b1; state <= 7'd21; end
                    7'd21: begin tm_clk <= 1'b1; tm_dio <= 1'b0; state <= 7'd22; end
                    7'd22: begin tm_dio <= 1'b1; state <= 7'd23; end

                    7'd23: begin tm_clk <= 1'b1; tm_dio <= 1'b1; state <= 7'd24; end
                    7'd24: begin tm_dio <= 1'b0; state <= 7'd25; bitn <= 4'd0; curbyte <= 8'h8F; phase <= 3'd0; end

                    7'd25: begin
                        case (phase)
                            3'd0: begin tm_clk <= 1'b0; tm_dio <= curbyte[bitn]; phase <= 3'd1; end
                            3'd1: begin tm_clk <= 1'b1; phase <= 3'd2; end
                            3'd2: begin
                                if (bitn == 4'd7) begin
                                    bitn <= 4'd0;
                                    phase <= 3'd0;
                                    state <= 7'd26;
                                end else begin
                                    bitn <= bitn + 4'd1;
                                    phase <= 3'd0;
                                end
                            end
                            default: phase <= 3'd0;
                        endcase
                    end

                    7'd26: begin tm_clk <= 1'b0; tm_dio <= 1'b1; state <= 7'd27; end
                    7'd27: begin tm_clk <= 1'b1; state <= 7'd28; end
                    7'd28: begin tm_clk <= 1'b1; tm_dio <= 1'b0; state <= 7'd29; end
                    7'd29: begin tm_dio <= 1'b1; state <= 7'd0; end

                    default: state <= 7'd0;
                endcase
            end
        end
    end
endmodule
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

module ps2_receiver (
    input  wire clk,
    input  wire rst,
    input  wire ps2_clk,
    input  wire ps2_data,
    output reg  [7:0] scan_code,
    output reg  scan_strobe
);
    reg [2:0] ps2c_sync;
    reg [10:0] shift;
    reg [3:0] bit_count;

    always @(posedge clk) begin
        if (rst) begin
            ps2c_sync   <= 3'b111;
            shift       <= 11'd0;
            bit_count   <= 4'd0;
            scan_code   <= 8'd0;
            scan_strobe <= 1'b0;
        end else begin
            ps2c_sync   <= {ps2c_sync[1:0], ps2_clk};
            scan_strobe <= 1'b0;

            if (ps2c_sync[2:1] == 2'b10) begin
                shift <= {ps2_data, shift[10:1]};
                if (bit_count == 4'd10) begin
                    bit_count <= 4'd0;
                    scan_code <= shift[8:1];
                    scan_strobe <= 1'b1;
                end else begin
                    bit_count <= bit_count + 4'd1;
                end
            end
        end
    end
endmodule

module key_decode (
    input  wire       clk,
    input  wire       rst,
    input  wire [7:0] scan_code,
    input  wire       scan_strobe,
    output reg  [3:0] digit,
    output reg        digit_valid,
    output reg        clear_key
);
    reg break_code;

    always @(posedge clk) begin
        if (rst) begin
            break_code  <= 1'b0;
            digit       <= 4'd0;
            digit_valid <= 1'b0;
            clear_key   <= 1'b0;
        end else begin
            digit_valid <= 1'b0;
            clear_key   <= 1'b0;

            if (scan_strobe) begin
                if (scan_code == 8'hF0) begin
                    break_code <= 1'b1;
                end else begin
                    if (!break_code) begin
                        case (scan_code)
                            8'h45: begin digit <= 4'd0; digit_valid <= 1'b1; end
                            8'h16: begin digit <= 4'd1; digit_valid <= 1'b1; end
                            8'h1E: begin digit <= 4'd2; digit_valid <= 1'b1; end
                            8'h26: begin digit <= 4'd3; digit_valid <= 1'b1; end
                            8'h25: begin digit <= 4'd4; digit_valid <= 1'b1; end
                            8'h2E: begin digit <= 4'd5; digit_valid <= 1'b1; end
                            8'h36: begin digit <= 4'd6; digit_valid <= 1'b1; end
                            8'h3D: begin digit <= 4'd7; digit_valid <= 1'b1; end
                            8'h3E: begin digit <= 4'd8; digit_valid <= 1'b1; end
                            8'h46: begin digit <= 4'd9; digit_valid <= 1'b1; end
                            8'h66: begin clear_key <= 1'b1; end // Backspace
                            default: begin end
                        endcase
                    end
                    break_code <= 1'b0;
                end
            end
        end
    end
endmodule

module tm1637_driver (
    input  wire       clk,
    input  wire       rst,
    input  wire [7:0] d3,
    input  wire [7:0] d2,
    input  wire [7:0] d1,
    input  wire [7:0] d0,
    output reg        tm_clk,
    output reg        tm_dio
);
    reg [15:0] divcnt;
    reg tick;
    reg [7:0] frame [0:5];
    reg [6:0] state;
    reg [3:0] bitn;
    reg [7:0] curbyte;
    reg [2:0] phase;

    always @(posedge clk) begin
        if (rst) begin
            divcnt <= 16'd0;
            tick   <= 1'b0;
        end else begin
            if (divcnt == 16'd249) begin
                divcnt <= 16'd0;
                tick   <= 1'b1;
            end else begin
                divcnt <= divcnt + 16'd1;
                tick   <= 1'b0;
            end
        end
    end

    always @(posedge clk) begin
        if (rst) begin
            frame[0] <= 8'h40;
            frame[1] <= 8'hC0;
            frame[2] <= 8'h00;
            frame[3] <= 8'h00;
            frame[4] <= 8'h00;
            frame[5] <= 8'h00;
            state    <= 7'd0;
            bitn     <= 4'd0;
            curbyte  <= 8'd0;
            phase    <= 3'd0;
            tm_clk   <= 1'b1;
            tm_dio   <= 1'b1;
        end else begin
            frame[0] <= 8'h40;
            frame[1] <= 8'hC0;
            frame[2] <= d0;
            frame[3] <= d1;
            frame[4] <= d2;
            frame[5] <= d3;

            if (tick) begin
                case (state)
                    7'd0: begin tm_clk <= 1'b1; tm_dio <= 1'b1; state <= 7'd1; end
                    7'd1: begin tm_dio <= 1'b0; state <= 7'd2; bitn <= 4'd0; curbyte <= frame[0]; phase <= 3'd0; end

                    7'd2,7'd3,7'd4,7'd5,7'd6,7'd7: begin
                        case (phase)
                            3'd0: begin tm_clk <= 1'b0; tm_dio <= curbyte[bitn]; phase <= 3'd1; end
                            3'd1: begin tm_clk <= 1'b1; phase <= 3'd2; end
                            3'd2: begin
                                if (bitn == 4'd7) begin
                                    bitn <= 4'd0;
                                    phase <= 3'd0;
                                    state <= state + 7'd1;
                                end else begin
                                    bitn <= bitn + 4'd1;
                                    phase <= 3'd0;
                                end
                            end
                            default: phase <= 3'd0;
                        endcase
                    end

                    7'd8: begin tm_clk <= 1'b0; tm_dio <= 1'b1; state <= 7'd9; end
                    7'd9: begin tm_clk <= 1'b1; state <= 7'd10; end
                    7'd10: begin tm_clk <= 1'b1; tm_dio <= 1'b0; state <= 7'd11; end
                    7'd11: begin tm_dio <= 1'b1; state <= 7'd12; end

                    7'd12: begin tm_clk <= 1'b1; tm_dio <= 1'b1; state <= 7'd13; end
                    7'd13: begin tm_dio <= 1'b0; state <= 7'd14; bitn <= 4'd0; curbyte <= frame[1]; phase <= 3'd0; end

                    7'd14,7'd15,7'd16,7'd17,7'd18: begin
                        case (phase)
                            3'd0: begin tm_clk <= 1'b0; tm_dio <= curbyte[bitn]; phase <= 3'd1; end
                            3'd1: begin tm_clk <= 1'b1; phase <= 3'd2; end
                            3'd2: begin
                                if (bitn == 4'd7) begin
                                    bitn <= 4'd0;
                                    phase <= 3'd0;
                                    state <= state + 7'd1;
                                    if (state == 7'd18) curbyte <= frame[2];
                                    else curbyte <= frame[state - 7'd15 + 3];
                                end else begin
                                    bitn <= bitn + 4'd1;
                                    phase <= 3'd0;
                                end
                            end
                            default: phase <= 3'd0;
                        endcase
                    end

                    7'd19: begin tm_clk <= 1'b0; tm_dio <= 1'b1; state <= 7'd20; end
                    7'd20: begin tm_clk <= 1'b1; state <= 7'd21; end
                    7'd21: begin tm_clk <= 1'b1; tm_dio <= 1'b0; state <= 7'd22; end
                    7'd22: begin tm_dio <= 1'b1; state <= 7'd23; end

                    7'd23: begin tm_clk <= 1'b1; tm_dio <= 1'b1; state <= 7'd24; end
                    7'd24: begin tm_dio <= 1'b0; state <= 7'd25; bitn <= 4'd0; curbyte <= 8'h8F; phase <= 3'd0; end

                    7'd25: begin
                        case (phase)
                            3'd0: begin tm_clk <= 1'b0; tm_dio <= curbyte[bitn]; phase <= 3'd1; end
                            3'd1: begin tm_clk <= 1'b1; phase <= 3'd2; end
                            3'd2: begin
                                if (bitn == 4'd7) begin
                                    bitn <= 4'd0;
                                    phase <= 3'd0;
                                    state <= 7'd26;
                                end else begin
                                    bitn <= bitn + 4'd1;
                                    phase <= 3'd0;
                                end
                            end
                            default: phase <= 3'd0;
                        endcase
                    end

                    7'd26: begin tm_clk <= 1'b0; tm_dio <= 1'b1; state <= 7'd27; end
                    7'd27: begin tm_clk <= 1'b1; state <= 7'd28; end
                    7'd28: begin tm_clk <= 1'b1; tm_dio <= 1'b0; state <= 7'd29; end
                    7'd29: begin tm_dio <= 1'b1; state <= 7'd0; end

                    default: state <= 7'd0;
                endcase
            end
        end
    end
endmodule

module ps2_pin_lock_top (
    input  wire clk_25mhz,
    input  wire ps2_clk,
    input  wire ps2_data,
    output wire tm_clk,
    output wire tm_dio,
    output reg  unlock_led
);
    wire [7:0] scan_code;
    wire scan_strobe;
    wire [3:0] digit;
    wire digit_valid;
    wire clear_key;

    reg rst = 1'b0;

    reg [3:0] buf3, buf2, buf1, buf0;
    reg [2:0] count;
    reg [23:0] hold_counter;
    reg hold_active;
    reg success_mode;
    reg fail_mode;

    reg [7:0] seg3, seg2, seg1, seg0;

    localparam [15:0] PIN = 16'h1234;

    function [7:0] seg_num;
        input [3:0] n;
        begin
            case (n)
                4'd0: seg_num = 8'h3F;
                4'd1: seg_num = 8'h06;
                4'd2: seg_num = 8'h5B;
                4'd3: seg_num = 8'h4F;
                4'd4: seg_num = 8'h66;
                4'd5: seg_num = 8'h6D;
                4'd6: seg_num = 8'h7D;
                4'd7: seg_num = 8'h07;
                4'd8: seg_num = 8'h7F;
                4'd9: seg_num = 8'h6F;
                default: seg_num = 8'h00;
            endcase
        end
    endfunction

    localparam [7:0] SEG_BLANK = 8'h00;
    localparam [7:0] SEG_O = 8'h3F;
    localparam [7:0] SEG_P = 8'h73;
    localparam [7:0] SEG_E = 8'h79;
    localparam [7:0] SEG_N = 8'h37;
    localparam [7:0] SEG_F = 8'h71;
    localparam [7:0] SEG_A = 8'h77;
    localparam [7:0] SEG_I = 8'h06;
    localparam [7:0] SEG_L = 8'h38;
    localparam [7:0] SEG_DASH = 8'h40;

    ps2_receiver u_rx (
        .clk(clk_25mhz),
        .rst(rst),
        .ps2_clk(ps2_clk),
        .ps2_data(ps2_data),
        .scan_code(scan_code),
        .scan_strobe(scan_strobe)
    );

    key_decode u_key (
        .clk(clk_25mhz),
        .rst(rst),
        .scan_code(scan_code),
        .scan_strobe(scan_strobe),
        .digit(digit),
        .digit_valid(digit_valid),
        .clear_key(clear_key)
    );

    tm1637_driver u_disp (
        .clk(clk_25mhz),
        .rst(rst),
        .d3(seg3),
        .d2(seg2),
        .d1(seg1),
        .d0(seg0),
        .tm_clk(tm_clk),
        .tm_dio(tm_dio)
    );

    always @(posedge clk_25mhz) begin
        if (rst) begin
            buf3 <= 4'd0; buf2 <= 4'd0; buf1 <= 4'd0; buf0 <= 4'd0;
            count <= 3'd0;
            hold_counter <= 24'd0;
            hold_active <= 1'b0;
            success_mode <= 1'b0;
            fail_mode <= 1'b0;
            unlock_led <= 1'b0;
        end else begin
            if (clear_key) begin
                buf3 <= 4'd0; buf2 <= 4'd0; buf1 <= 4'd0; buf0 <= 4'd0;
                count <= 3'd0;
                success_mode <= 1'b0;
                fail_mode <= 1'b0;
                unlock_led <= 1'b0;
                hold_active <= 1'b0;
                hold_counter <= 24'd0;
            end else if (hold_active) begin
                if (hold_counter == 24'd12499999) begin
                    hold_counter <= 24'd0;
                    hold_active <= 1'b0;
                    success_mode <= 1'b0;
                    fail_mode <= 1'b0;
                    unlock_led <= 1'b0;
                    count <= 3'd0;
                    buf3 <= 4'd0; buf2 <= 4'd0; buf1 <= 4'd0; buf0 <= 4'd0;
                end else begin
                    hold_counter <= hold_counter + 24'd1;
                end
            end else if (digit_valid) begin
                buf3 <= buf2;
                buf2 <= buf1;
                buf1 <= buf0;
                buf0 <= digit;

                if (count < 3'd4)
                    count <= count + 3'd1;

                if (count == 3'd3) begin
                    if ({buf2, buf1, buf0, digit} == PIN) begin
                        success_mode <= 1'b1;
                        fail_mode <= 1'b0;
                        unlock_led <= 1'b1;
                    end else begin
                        success_mode <= 1'b0;
                        fail_mode <= 1'b1;
                        unlock_led <= 1'b0;
                    end
                    hold_active <= 1'b1;
                    hold_counter <= 24'd0;
                end
            end
        end
    end

    always @(*) begin
        if (success_mode) begin
            seg3 = SEG_O;
            seg2 = SEG_P;
            seg1 = SEG_E;
            seg0 = SEG_N;
        end else if (fail_mode) begin
            seg3 = SEG_F;
            seg2 = SEG_A;
            seg1 = SEG_I;
            seg0 = SEG_L;
        end else begin
            case (count)
                3'd0: begin seg3 = SEG_DASH; seg2 = SEG_DASH; seg1 = SEG_DASH; seg0 = SEG_DASH; end
                3'd1: begin seg3 = SEG_BLANK; seg2 = SEG_BLANK; seg1 = SEG_BLANK; seg0 = seg_num(buf0); end
                3'd2: begin seg3 = SEG_BLANK; seg2 = SEG_BLANK; seg1 = seg_num(buf1); seg0 = seg_num(buf0); end
                3'd3: begin seg3 = SEG_BLANK; seg2 = seg_num(buf2); seg1 = seg_num(buf1); seg0 = seg_num(buf0); end
                default: begin seg3 = seg_num(buf3); seg2 = seg_num(buf2); seg1 = seg_num(buf1); seg0 = seg_num(buf0); end
            endcase
        end
    end
endmodule

2) Testbench: tb_ps2_pin_lock.cpp

This simulation drives PS/2 waveforms into the Verilated design. It sends make codes for digits and checks the unlock behavior.

Public preview of the validated file. The complete source is shown to members and in PDF/Print.

#include "Vps2_pin_lock_top.h"
#include "verilated.h"
#include <cstdio>

static vluint64_t main_time = 0;
double sc_time_stamp() { return main_time; }

static void tick(Vps2_pin_lock_top *dut, int cycles = 1) {
    for (int i = 0; i < cycles; i++) {
        dut->clk_25mhz = 0;
        dut->eval();
        main_time++;
        dut->clk_25mhz = 1;
        dut->eval();
        main_time++;
    }
}

static void ps2_send_byte(Vps2_pin_lock_top *dut, unsigned char b) {
    int parity = 1;
    dut->ps2_data = 1;
    dut->ps2_clk = 1;
    tick(dut, 2000);

    auto send_bit = [&](int bit) {
        dut->ps2_data = bit;
        tick(dut, 500);
        dut->ps2_clk = 0;
        tick(dut, 1000);
        dut->ps2_clk = 1;
        tick(dut, 1000);
    };

    send_bit(0);
    for (int i = 0; i < 8; i++) {
        int bit = (b >> i) & 1;
        parity ^= bit;
        send_bit(bit);
    }
// ... continues for members in the complete validated source ...

🔒 Part of the validated code is premium. With the 7-day pass or the monthly membership you can view the complete validated source.

#include "Vps2_pin_lock_top.h"
#include "verilated.h"
#include <cstdio>

static vluint64_t main_time = 0;
double sc_time_stamp() { return main_time; }

static void tick(Vps2_pin_lock_top *dut, int cycles = 1) {
    for (int i = 0; i < cycles; i++) {
        dut->clk_25mhz = 0;
        dut->eval();
        main_time++;
        dut->clk_25mhz = 1;
        dut->eval();
        main_time++;
    }
}

static void ps2_send_byte(Vps2_pin_lock_top *dut, unsigned char b) {
    int parity = 1;
    dut->ps2_data = 1;
    dut->ps2_clk = 1;
    tick(dut, 2000);

    auto send_bit = [&](int bit) {
        dut->ps2_data = bit;
        tick(dut, 500);
        dut->ps2_clk = 0;
        tick(dut, 1000);
        dut->ps2_clk = 1;
        tick(dut, 1000);
    };

    send_bit(0);
    for (int i = 0; i < 8; i++) {
        int bit = (b >> i) & 1;
        parity ^= bit;
        send_bit(bit);
    }
    send_bit(parity);
    send_bit(1);
    dut->ps2_data = 1;
    tick(dut, 3000);
}

int main(int argc, char **argv) {
    Verilated::commandArgs(argc, argv);
    Vps2_pin_lock_top *dut = new Vps2_pin_lock_top;

    dut->ps2_clk = 1;
    dut->ps2_data = 1;
    dut->clk_25mhz = 0;

    tick(dut, 5000);

    // Correct PIN: 1 2 3 4
    ps2_send_byte(dut, 0x16);
    ps2_send_byte(dut, 0x1E);
    ps2_send_byte(dut, 0x26);
    ps2_send_byte(dut, 0x25);
    tick(dut, 10000);

    std::printf("unlock_led after 1234 = %d\n", (int)dut->unlock_led);

    tick(dut, 13000000 / 2);

    // Wrong PIN: 9 9 9 9
    ps2_send_byte(dut, 0x46);
    ps2_send_byte(dut, 0x46);
    ps2_send_byte(dut, 0x46);
    ps2_send_byte(dut, 0x46);
    tick(dut, 10000);

    std::printf("unlock_led after 9999 = %d\n", (int)dut->unlock_led);

    delete dut;
    return 0;
}

3) Why this code is practical

This is not just a decoder demo. It implements a realistic front panel:

  • Input device: common PS/2 keyboard
  • Decision logic: digit collection plus fixed PIN compare
  • Human feedback: display shows typed digits and result
  • Action output: unlock_led can later drive a relay interface or permission signal

Build/Flash/Run commands

1) Verilator lint

Run lint first on synthesizable design plus testbench usage context:

verilator --lint-only -Wall -Wno-DECLFILENAME ps2_pin_lock_top.v

2) Build and run simulation

verilator -Wall -Wno-DECLFILENAME --cc ps2_pin_lock_top.v --exe tb_ps2_pin_lock.cpp --top-module ps2_pin_lock_top
make -C obj_dir -f Vps2_pin_lock_top.mk Vps2_pin_lock_top
./obj_dir/Vps2_pin_lock_top

3) Synthesis with Yosys

Important: synthesis includes only synthesizable source, not the C++ testbench.

Create build.ys:

read_verilog ps2_pin_lock_top.v
synth_ecp5 -top ps2_pin_lock_top -json ps2_pin_lock_top.json

Run:

yosys build.ys

4) Place and route

Replace CABGA381 with your actual ULX3S package if your board variant differs. The ECP5-85F ULX3S commonly uses an appropriate package supported by nextpnr; confirm your exact board/package before running.

nextpnr-ecp5 --85k --json ps2_pin_lock_top.json --lpf ulx3s_ps2_pin_lock.lpf --textcfg ps2_pin_lock_top.config --package CABGA381

5) Bitstream pack

ecppack ps2_pin_lock_top.config ps2_pin_lock_top.bit

6) Program the ULX3S

openFPGALoader -b ulx3s ps2_pin_lock_top.bit

If your system needs a specific cable/device selection, check connected devices with:

openFPGALoader --detect

Step-by-step Validation

Validation is essential because this project claims specific behavior.

1) Simulation validation

Run the Verilator testbench and expect output similar to:

unlock_led after 1234 = 1
unlock_led after 9999 = 0

What this proves:

  • PS/2 serial frames were captured.
  • Scan codes 0x16 0x1E 0x26 0x25 were decoded as 1 2 3 4.
  • The 4-digit compare logic asserted unlock on match.
  • The wrong PIN sequence did not assert unlock.

2) Hardware power-on validation

After flashing:

  1. Connect keyboard and display.
  2. Power ULX3S by USB.
  3. Observe TM1637 at idle.
  4. Expected: display shows four dashes ----.

If the display is blank:
– first suspect wiring or constraints for tm_clk and tm_dio
– then check module power and ground

3) Keyboard input validation

Type single digits slowly:

  1. Press 1
  2. Expected display: rightmost digit becomes 1
  3. Press 2
  4. Expected display: 12 on the two rightmost positions
  5. Press 3
  6. Expected display: 123
  7. Press Backspace
  8. Expected display returns to ----

This validates:

  • PS/2 clock/data wiring
  • make-code recognition
  • ignored break-code handling
  • clear/reset path

4) Correct PIN validation

Enter:

  • 1, 2, 3, 4

Expected result:

  • display changes from the digits to OPEN
  • unlock_led becomes active
  • after about half a second, system returns to idle state ----

5) Wrong PIN validation

Enter:

  • 9, 9, 9, 9

Expected result:

  • display shows FAIL
  • unlock_led remains inactive
  • after about half a second, system returns to idle state

6) Repeated-use validation

Try this sequence:

  1. 1 2 3 4
  2. wait for reset
  3. 2 2 2 2
  4. wait for reset
  5. 1 2 3 4

Expected:

  • first attempt succeeds
  • second attempt fails
  • third attempt succeeds again

This confirms that the state machine properly clears between attempts.

Troubleshooting

Display does nothing

Possible causes:

  • wrong tm_clk / tm_dio pin constraints
  • missing common ground
  • display module powered incorrectly
  • TM1637 module expects stronger pull-up or different logic voltage behavior

Actions:

  1. Recheck physical wire mapping against LPF names.
  2. Confirm 3V3 and GND with a meter if available.
  3. Swap tm_clk and tm_dio only if you suspect a labeling mismatch.
  4. Verify the module is actually TM1637-based, not another serial display board.

Keyboard not recognized

Possible causes:

  • PS/2 module or keyboard is not 3.3 V safe
  • wrong ps2_clk / ps2_data constraints
  • keyboard sends scan codes from a different set than expected
  • no pull-up on lines

Actions:

  1. Confirm both PS/2 lines idle high.
  2. Make sure the module is powered and the keyboard itself is known-good.
  3. Test with number-row keys first, not numeric keypad.
  4. Keep PULLMODE=UP in constraints.
  5. If digits do not match expected values, capture real scan codes in a debug version later.

OPEN never appears even with correct digits

Possible causes:

  • different scan codes than assumed
  • a digit is being interpreted incorrectly
  • the PIN constant does not match your intended entry

Actions:

  1. Verify the intended PIN is 1234 from:
    verilog
    localparam [15:0] PIN = 16'h1234;
  2. Enter digits from top row: 1 2 3 4.
  3. If needed, temporarily change the design to display raw decoded digits and confirm mapping.

Place-and-route fails

Possible causes:

  • invalid package name
  • invalid LPF site names
  • pin conflict with reserved or unavailable ULX3S pins

Actions:

  1. Confirm your exact ULX3S package and use the matching nextpnr-ecp5 option.
  2. Replace example LPF site names with real package pins.
  3. Avoid special-function-only pins unless you know they are valid as GPIO.

Improvements

Once the basic version works, you can extend it in useful ways:

1) Mask typed digits

Instead of showing actual digits, display ---- or ****-style placeholders using segment patterns. This makes the PIN entry more lock-like.

2) Add Enter key behavior

Currently the system checks automatically after the fourth digit. You can improve usability by:

  • collecting up to 4 digits
  • validating only when Enter is pressed

3) Add lockout after repeated failures

Useful real-world enhancement:

  • count failed attempts
  • after 3 failures, ignore keys for 10 seconds
  • show LOCK or WAIT on the display

4) External control output

Replace unlock_led or duplicate it with an output for a safe external interface stage:

  • transistor driver
  • optocoupler
  • relay module with proper isolation

Remember: that would need separate power and safety review.

5) Store configurable PIN

For a more advanced but still practical version:

  • use DIP switches for PIN setup
  • or add UART command input
  • or store PIN in nonvolatile external memory

6) Add buzzer or event logging

A short beep on keypress and different beep on fail/success creates a more realistic access-control panel.

Final Checklist

Use this checklist before calling the build complete:

  • [ ] I used Radiona ULX3S (Lattice ECP5-85F) + módulo teclado PS/2 mini-DIN + display 7 segmentos TM1637.
  • [ ] All modules share a common ground.
  • [ ] My PS/2 and TM1637 signal levels are safe for ULX3S FPGA I/O.
  • [ ] I replaced the LPF example pin sites with actual ULX3S package pins.
  • [ ] verilator --lint-only runs without fatal errors.
  • [ ] Verilator simulation prints:
  • [ ] unlock_led after 1234 = 1
  • [ ] unlock_led after 9999 = 0
  • [ ] yosys synthesis completes successfully.
  • [ ] nextpnr-ecp5 completes with my correct package and LPF.
  • [ ] ecppack generates a .bit file.
  • [ ] openFPGALoader -b ulx3s ps2_pin_lock_top.bit programs the board.
  • [ ] On power-up, the display shows ----.
  • [ ] Entering 1 2 3 4 shows OPEN.
  • [ ] Entering a wrong code shows FAIL.
  • [ ] Backspace clears the current entry.
  • [ ] The prototype resets cleanly after each attempt.

With this project, you have a realistic beginner FPGA prototype: a keyboard-operated PIN lock front panel with visible status output, built using open-source ECP5 tools and practical digital design blocks.

        <div class="amazon-affiliate">
          <p><strong>Find this product and/or books on this topic on Amazon</strong></p>
          <p><a class="amazon-affiliate-btn" href="https://amzn.to/4mt8r4C" target="_blank" rel="nofollow sponsored noopener">Go to Amazon</a></p>
          <p class="amazon-affiliate-disclaimer">As an Amazon Associate, I earn from qualifying purchases. If you buy through this link, you help keep this project running.</p>
        </div>

Quick Quiz

Question 1: What specific FPGA board is used for the PIN-lock prototype in the project?




Question 2: Which type of keyboard is used to input the 4-digit code?




Question 3: What component is used to display prompts and status to the user?




Question 4: What is the target display refresh rate for the prototype to appear flicker-free?




Question 5: What is the expected interface response time after a key is decoded?




Question 6: How many digits does the PIN code consist of in this prototype?




Question 7: What is one of the mentioned use cases for this FPGA PIN-lock prototype?




Question 8: What kind of feedback does the user receive immediately after entering digits?




Question 9: Which of the following status codes is explicitly mentioned as being shown on the display?




Question 10: Which digital logic integration exercise is highlighted in the text?




Carlos Núñez Zorrilla
Carlos Núñez Zorrilla
Electronics & Computer Engineer

Telecommunications Electronics Engineer and Computer Engineer (official degrees in Spain).

Follow me: