- Driving a VGA Display with an FPGA
- Overview
- How VGA Timing Works
- Module Breakdown
- Key Signals and Data Layout
- What Happens Every Pixel Clock
- Connecting it in
alchitry_top.luc - Replacing
vga_memwith Your Own Screen Buffer - Pipelined Memory Addressing
- Hardware: Connecting to a VGA Monitor
- Extending the Design
50.002 Computation Structures
Information Systems Technology and Design
Singapore University of Technology and Design
Driving a VGA Display with an FPGA
Driver Code
You can download the driver code from the repository. The files below are designed to work together.
vga_text_mode.luc: VGA timing engine, tile addressing, and color outputvga_font_rom.luc: Combinational ASCII glyph ROM (8×8 bitmap font)vga_mem.luc: Example screen memory (replace with your own BRAM or ROM)
The full repository with demo top-level code can be found here.
Credits
This design is based on the original 2020 Lucid HDL work by Ragul Balaji (SUTD 50.002 Computation Structures).
Overview
This driver implements a VGA text-mode renderer targeting 640×480 @ 60 Hz. The visible area is divided into a grid of 20 columns × 15 rows of character cells. Each cell is 32×32 pixels on screen and displays one ASCII glyph from an internal 8×8 font, scaled up by 4× in each dimension. Each cell independently stores a foreground color, a background color, and an ASCII character code in a compact 16-bit word.
The FPGA outputs five digital signals: R, G, B (1 bit each), HSYNC, and VSYNC. These are routed through a VGA interface board or a resistor DAC before reaching a VGA connector, because VGA color lines are analog.
How VGA Timing Works
A VGA display is driven by a continuous stream of pixel clocks. The FPGA counts through the full horizontal and vertical timing envelope every frame, including the non-visible blanking intervals. The standard pixel clock for 640×480 @ 60 Hz is approximately 25.175 MHz.
Horizontal timing (per scanline, 800 pixel clocks total)
| Region | Pixel clocks | hzcnt range |
|---|---|---|
| Visible pixels | 640 | 0..639 |
| Front porch | 16 | 640..655 |
| HSYNC pulse | 96 | 656..751 |
| Back porch | 48 | 752..799 |
HSYNC goes high for hzcnt values 656..750, signaling the monitor to start a new scanline.
Vertical timing (per frame, 525 lines total)
| Region | Lines | vtcnt range |
|---|---|---|
| Visible lines | 480 | 0..479 |
| Front porch | 10 | 480..489 |
| VSYNC pulse | 2 | 490..491 |
| Back porch | 33 | 492..524 |
VSYNC goes high for vtcnt values 490..491, signaling the monitor to return to the top of the frame.
RGB outputs must be driven to 000 (black / blank) during any non-visible region (front porch, sync pulse, back porch), regardless of what is stored in screen memory.
Pixel clock derivation
The Alchitry Au runs at 100 MHz. The design uses a 1-bit counter (slowcnt) feeding an edge_detector to produce a 25 MHz pixel tick. The horizontal and vertical counters hzcnt and vtcnt only advance on this tick.
edges.in = slowcnt.value // 100 MHz -> 25 MHz level signal
// edge_detector fires once per rising edge
if (edges.out == 1) {
// advance hzcnt / vtcnt
}
Module Breakdown
vga_text_mode.luc
This is the top-level driver module, which is the timing and rendering engine. It:
- Counts pixel clocks (horizontal
hzcnt, verticalvtcnt) at 25 MHz. - Computes the current text cell address (
vga_mem_address) from the counters. - Outputs that address externally so screen memory can respond with a 16-bit data word (
vga_mem_data). - Feeds the character code and glyph position to
vga_font_romto determine whether the current pixel is foreground or background. - Selects and outputs the correct RGB color and sync signals.
Ports
| Port | Direction | Width | Description |
|---|---|---|---|
clk |
input | 1 | 100 MHz system clock |
rst |
input | 1 | Synchronous reset |
vga_mem_data |
input | 16 | Character + color word for the current text cell |
vga_mem_address |
output | 9 | Index of the text cell currently being rendered |
vga_rgb |
output | 3 | RGB color output (1 bit per channel) |
vga_hsync |
output | 1 | Horizontal sync signal |
vga_vsync |
output | 1 | Vertical sync signal |
vga_font_rom.luc
This is the glyph ROM.
It is a purely combinational lookup table. Given an ASCII code, a row index (0..7), and a column index (0..7), it returns a single bit indicating whether that pixel within the glyph is on (foreground) or off (background).
Glyphs are stored as 64-bit constants, one per ASCII character. The bit at position (row << 3) + col within the 64-bit constant determines the pixel value.
Ports
| Port | Direction | Width | Description |
|---|---|---|---|
asciicode |
input | 8 | ASCII character code (e.g. 8h41 for A) |
row |
input | 3 | Glyph row (0..7) |
col |
input | 3 | Glyph column (0..7) |
color |
output | 1 | 1 = foreground pixel, 0 = background pixel |
The font covers printable ASCII (0x21..0x7E) plus a handful of custom glyphs. Unmapped codes return all zeros (blank).
vga_mem.luc
This is a sample screen memory.
It is a minimal combinational stand-in for screen memory. It ignores the incoming address and returns the same 16-bit word for every cell: a white-on-black letter A everywhere. You are expected to replace this with your own implementation, for instance a synchronous dual-port BRAM that your game logic writes to.
data[15:14] = 2b00 // unused
data[13:11] = 3b111 // foreground: white
data[10:8] = 3b000 // background: black
data[7:0] = 8h41 // ASCII 'A'
The driver expects vga_mem_data to be valid combinationally for the current vga_mem_address. If you use synchronous BRAM with a 1-cycle read latency, you must perform combinational lookahead or the rendered character will be off by one cell. See this section for details.
Key Signals and Data Layout
vga_mem_address[9] (output from vga_text_mode)
Selects which of the 300 text cells is currently being rendered. The formula is:
tile_x = hzcnt >> 5 // divide pixel x by 32 => column 0..19
tile_y = vtcnt >> 5 // divide pixel y by 32 => row 0..14
vga_mem_address = tile_x + (tile_y * 20)
Linear address layout:
- Address
0= row 0, col 0 (top-left) - Address
19= row 0, col 19 (top-right) - Address
20= row 1, col 0 - Address
299= row 14, col 19 (bottom-right)
9 bits are required because 300 cells exceed the 8-bit range of 256.
During blanking intervals, the counters continue running and vga_mem_address may hold values outside 0..299. Your memory should return a safe default (such as all zeros) for out-of-range addresses rather than leaving outputs undefined.
vga_mem_data[16] (input to vga_text_mode)
Provides the character and color information for the cell currently pointed to by vga_mem_address.
Bit layout
| Bits | Field | Description |
|---|---|---|
[7:0] |
ASCII code | Character to display (fed to vga_font_rom) |
[10:8] |
Background color | 3-bit RGB, {R,G,B} 1 bit each |
[13:11] |
Foreground color | 3-bit RGB, {R,G,B} 1 bit each |
[15:14] |
Unused | Reserved, ignored by the renderer |
Color encoding (3-bit RGB)
Each color field encodes {R, G, B} with one bit per channel, giving 8 possible colors:
{R,G,B} |
Color |
|---|---|
000 |
Black |
100 |
Red |
010 |
Green |
001 |
Blue |
110 |
Yellow |
101 |
Magenta |
011 |
Cyan |
111 |
White |
Per-pixel color selection
For each pixel, the font ROM returns a 1-bit color value:
- If
font_rom.color == 1, output the foreground colorvga_mem_data[13:11] - If
font_rom.color == 0, output the background colorvga_mem_data[10:8]
Example packed word
To display a green H on a black background:
// ASCII 'H' = 0x48, FG = green = 010, BG = black = 000
vga_mem_data = 16b00_010_000_01001000
To fill every cell with white-on-black A (as in the demo):
vga_mem_data = 16b00_111_000_01000001
Glyph row and column derivation
Inside vga_text_mode, the glyph position fed to the font ROM is extracted from the pixel counters:
font_rom.row = (vtcnt.q >> 2) & 3b111 // bits [4:2] of vtcnt => 0..7
font_rom.col = (hzcnt.q >> 2) & 3b111 // bits [4:2] of hzcnt => 0..7
Shifting right by 2 divides by 4, so each glyph pixel covers a 4×4 block of screen pixels. The & 3b111 masks the result to 0..7, selecting the correct column/row within the 8×8 glyph.
RGB output bit swap
VGA connector wiring on some boards routes pins in BGR order. The module compensates with an explicit swap before output:
vga_rgb[0] = sig_RGB[2] // R from bit 2
vga_rgb[1] = sig_RGB[1] // G from bit 1
vga_rgb[2] = sig_RGB[0] // B from bit 0
If your board’s wiring is already RGB-ordered, remove this swap. Check your VGA adapter schematic to confirm.
What Happens Every Pixel Clock
Each 25 MHz tick, the following sequence completes combinationally within vga_text_mode:
- Address: compute
vga_mem_addressfromhzcntandvtcnt. - Fetch: screen memory responds with
vga_mem_data(character + colors). - Glyph lookup: feed
asciicode,row,coltovga_font_romand get 1-bitcolor. - Color select: pick foreground or background color based on
font_rom.color. - Blanking: if outside the 640×480 visible region, force RGB to
000. - Sync: assert
HSYNCorVSYNCif within the correct counter window. - Output: drive
vga_rgb,vga_hsync,vga_vsync.
All of this happens in a single combinational always block with no additional pipeline stages, so there is zero output latency beyond the pixel clock itself.
Connecting it in alchitry_top.luc
The demo top-level wires everything together:
vga_mem mem
vga_text_mode vga(.rst(rst))
// ...
mem.addr = vga.vga_mem_address
vga.vga_mem_data = mem.data
led[2:0] = vga.vga_rgb
led[3] = vga.vga_hsync
led[4] = vga.vga_vsync
The screen memory address output from the renderer feeds directly into screen memory, and the memory response feeds directly back. No registered handshake is needed because both are combinational.
Pin mapping
| Signal | led bit |
Board pin |
|---|---|---|
| RED | led[0] |
D49 |
| GREEN | led[1] |
D48 |
| BLUE | led[2] |
D2 |
| HSYNC | led[3] |
D3 |
| VSYNC | led[4] |
D46 |
These led outputs are routed to FPGA GPIO pins and connect to your VGA interface hardware. Update the pin constraints file if your board differs.
We only use 1 bit for each color here, there are 8 color combinations in total. It can be easily expanded to support 3-bit per color.
Replacing vga_mem with Your Own Screen Buffer
The demo vga_mem module is intentionally trivial.
In a real project, replace it with a writable screen buffer. A typical approach uses a dual-port BRAM: one port for the renderer to read (driven by vga_mem_address), and a second port for your game or application logic to write.
The 16-bit word per cell is wide enough to hold character plus color, so a 300-entry, 16-bit-wide BRAM is sufficient. In Lucid, this is a simple_ram or equivalent component.
Pipelined Memory Addressing
When an address is issued to RAM on clock cycle N, the data is not available until the next RAM clock cycle. This means if you request address_N while displaying pixel N, the data arrives one cycle late and the wrong data is displayed.
Rather than modifying vga_text_mode, a separate combinational module vga_addr_lookahead should be created to handle this. It takes hzcnt and vtcnt as inputs, computes what the next pixel’s coordinates will be, and outputs the correct lookahead address to the RAM.
module vga_addr_lookahead (
input hzcnt[12],
input vtcnt[12],
output mem_address[9]
) {
sig next_hzcnt[12]
sig next_vtcnt[12]
always {
next_hzcnt = (hzcnt == 799) ? 0 : hzcnt + 1
next_vtcnt = (hzcnt == 799) ? ((vtcnt == 524) ? 0 : vtcnt + 1) : vtcnt
mem_address = (next_hzcnt >> 5) + ((next_vtcnt >> 5) * 20)
}
}
The vga_text_mode module stays largely untouched, except that now hzcnt and vtcnt should be exposed as outputs from vga_text_mode and fed into this module. This module’s mem_address connects to the RAM/cache instead of vga_text_mode’s original vga_mem_address output.
At the very last cycle of the frame where hzcnt=799 and vtcnt=524, next_hzcnt=0 and next_vtcnt=0, so address 0 is requested. By the time the first visible pixel arrives, the RAM/cache has already responded and the correct data is ready.
The only exception is the very first frame after reset. At that point hzcnt=799 vtcnt=524 has never occurred yet, so address 0 has never been requested and the cache holds garbage when pixel 0 first arrives. This corrupts exactly one pixel, the top left corner, one time only at startup. From the second frame onward every pixel is correct. This is completely imperceptible in practice.
Hardware: Connecting to a VGA Monitor
VGA expects HSYNC and VSYNC as digital signals, and R, G, B as analog voltage levels. The FPGA outputs digital bits, so you need a small interface between the FPGA and the VGA connector.
Option 1: VGA accessory board (recommended for prototyping)
An accessory board such as the Waveshare VGA PS/2 Board or equivalent already includes the VGA connector and the analog interface. Wire:
R,G,Bfrom FPGA GPIOHSYNC,VSYNCfrom FPGA GPIOGND
This is the fastest way to get a working display without soldering.
Option 2: Resistor DAC (simplest DIY)
Because this design outputs only 1 bit per color channel, the simplest analog interface is a single resistor per channel between the FPGA pin and the VGA connector pin. Standard VGA color inputs expect a 0.7 V peak into 75 ohms. A series resistor chosen for this load converts the FPGA’s 3.3 V output to the correct level.
If you later want more color depth (for example 3 bits per channel = 512 colors), use a weighted resistor network per channel with resistors in powers-of-two ratios. References: fpga4fun.com/VGA and embeddedthoughts.com VGA guide.
Option 3: VGA PMOD
A VGA PMOD such as the Digilent PMOD VGA is a compact board with VGA connector and resistor network in a standard PMOD footprint. It is the same concept as the Waveshare board but in a different form factor.
What this design needs
This text-mode renderer only requires HSYNC, VSYNC, and three 1-bit color signals. A simple resistor DAC or a basic VGA interface board is entirely sufficient for this project. A dedicated video DAC is not necessary unless you expand to more color bits.
Extending the Design
More colors
The 3-bit color scheme gives 8 colors. To expand to more shades, widen the color fields in vga_mem_data and widen the RGB output ports. You also need a corresponding analog interface (more resistor bits or a DAC) to handle the extra bits per channel. vga_mem_data[15:14] is currently unused and could be repurposed.
Larger grid or different resolution
The grid is sized for 640×480 with 32×32 cells. To change cell size or resolution, update the counter limits in vga_text_mode, the shift amounts for tile and glyph coordinate extraction, and the address formula. The font ROM itself is resolution-independent since it only cares about which 8×8 glyph pixel to output.
Custom glyphs
vga_font_rom is a plain case statement. Add entries for unused ASCII codes (or reassign existing ones) to define custom tile graphics. Each glyph is a 64-bit constant, read row-major, MSB at top-left within the 8×8 grid.
Scrolling or animation
Because the screen buffer is decoupled from the renderer, you can update any cell at any time by writing to the BRAM through the second port. The renderer will pick up the new value on the next frame pass through that cell. There is no double-buffering in this design, so writes during active scan can cause a one-frame tear on updated cells.
50.002 CS