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 likeE0. - 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
Conceptual signal and responsibility flow between device blocks.
Validation path
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:
verilatoryosysnextpnr-ecp5ecppackopenFPGALoader- 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:
- PS/2 input
ps2_clkps2_dataVGA output
vga_hsyncvga_vsyncvga_r[3:0]vga_g[3:0]vga_b[3:0]Board clock
clk_25mor 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:
- Use the ULX3S onboard VGA-capable pins or adapter path already supported in your setup.
- Connect the módulo PS/2 mini-DIN to two free 3.3 V-tolerant GPIO pins plus power and ground.
- 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-> ULX3S3V3 - PS/2 module
GND-> ULX3SGND - PS/2 module
CLK-> ULX3S chosen GPIO forps2_clk - PS/2 module
DATA-> ULX3S chosen GPIO forps2_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 ...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 ...`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:
1CF01C5AF05A
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:
- Power off the setup.
- Connect the VGA monitor to the ULX3S VGA output path.
- Connect the PS/2 module to the chosen GPIO pins and 3.3 V/GND.
- Plug the PS/2 keyboard into the mini-DIN module.
- Program the bitstream.
- 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.vandtb_ps2_vga.vare 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>




