Over the last few weeks I’ve been implementing a minimalist 8-bit SAP-1 (Simple As Possible) computer on an FPGA. This post captures the architecture, the SystemVerilog snippets that make it tick, the VGA output I just added, and the little browser-based assembler I wrote to generate the ROM image (.mem) for the design. It’s written as a “dev log” I can hand to Future-Me when I inevitably forget the details. 🙂
My SAP-1 is an 8-bit accumulator machine with a 4-bit opcode and a 4-bit operand (address) packed into a single 8-bit instruction. It uses a 16-byte instruction/data memory. Core parts:
A (accumulator), B (operand latch), OUT (display), IR (instruction register), PC (program counter).Z (zero), C (carry).$readmemh, optional tiny RAM for constants.To follow along, you can use the online assembler. Click on the “Help” button at the top right to get instructions on how to use it.
.tcl scripts at GitHub:I migrated the design fully to SystemVerilog (.sv files). This means:
logic instead of reg/wire in most places. always_ff and always_comb for clarity. // -----------------------------------------------------------------------------
// sap1_top.sv — top-level wrapper (with VGA)
// -----------------------------------------------------------------------------
module sap1_top (
input wire clk100, // board clock
input wire rst_n, // active-low reset
// VGA output
output wire hsync,
output wire vsync,
output wire [3:0] vga_r,
output wire [3:0] vga_g,
output wire [3:0] vga_b
);
// --- Clock divider: ~25 MHz pixel clock for VGA ---
logic clk_sys, clk_pix;
clock_div #(.DIV_SYS(4), .DIV_PIX(4)) u_div (
.clk_in (clk100),
.rst_n (rst_n),
.clk_sys(clk_sys), // ~25 MHz
.clk_pix(clk_pix) // VGA pixel clock
);
// Core wires (same as before)
logic [7:0] alu_out, rom_data, ir, reg_a, reg_b, reg_out;
logic [3:0] pc;
logic zf, cf;
logic ce_pc, we_ir, ld_a, ld_b, sel_sub, we_out, halt;
logic [2:0] tstate;
// ROM program
rom16x8 u_rom (.addr(pc), .data_out(rom_data));
// Control unit
control_unit u_ctl (
.clk (clk_sys),
.rst_n (rst_n),
.ir (ir),
.zf (zf),
.cf (cf),
.pc_ce (ce_pc),
.ir_we (we_ir),
.a_ld (ld_a),
.b_ld (ld_b),
.alu_sub (sel_sub),
.out_we (we_out),
.halt (halt),
.tstate (tstate)
);
// Program Counter + IR
pc4 u_pc (.clk(clk_sys), .rst_n(rst_n), .ce(ce_pc), .pc(pc));
ir8 u_ir (.clk(clk_sys), .we(we_ir), .din(rom_data), .ir(ir));
// Registers A, B, OUT
reg8 u_a (.clk(clk_sys), .ld(ld_a), .rst_n(rst_n), .din(alu_out), .dout(reg_a));
reg8 u_b (.clk(clk_sys), .ld(ld_b), .rst_n(rst_n), .din(alu_out), .dout(reg_b));
alu8 u_alu (.a(reg_a), .b(reg_b), .sub(sel_sub), .y(alu_out), .zf(zf), .cf(cf));
reg8 u_out (.clk(clk_sys), .ld(we_out), .rst_n(rst_n), .din(alu_out), .dout(reg_out));
// VGA text output (shows OUT register as hex)
vga_hex_display u_vga (
.clk_pix (clk_pix),
.rst_n (rst_n),
.value (reg_out),
.hsync (hsync),
.vsync (vsync),
.r (vga_r),
.g (vga_g),
.b (vga_b)
);
endmodule
This is a very simple text-only VGA renderer. It displays value[7:0] as two hex digits in the middle of the screen.
module vga_hex_display (
input wire clk_pix, // 25 MHz pixel clock
input wire rst_n,
input wire [7:0] value,
output logic hsync,
output logic vsync,
output logic [3:0] r,
output logic [3:0] g,
output logic [3:0] b
);
// VGA 640x480 @60Hz timing
localparam H_RES = 640, V_RES = 480;
localparam H_FP = 16, H_SYNC = 96, H_BP = 48;
localparam V_FP = 10, V_SYNC = 2, V_BP = 33;
logic [9:0] h_count;
logic [9:0] v_count;
always_ff @(posedge clk_pix, negedge rst_n) begin
if (!rst_n) begin
h_count <= 0; v_count <= 0;
end else begin
if (h_count == H_RES+H_FP+H_SYNC+H_BP-1) begin
h_count <= 0;
if (v_count == V_RES+V_FP+V_SYNC+V_BP-1)
v_count <= 0;
else
v_count <= v_count + 1;
end else begin
h_count <= h_count + 1;
end
end
end
assign hsync = ~(h_count >= H_RES+H_FP && h_count < H_RES+H_FP+H_SYNC);
assign vsync = ~(v_count >= V_RES+V_FP && v_count < V_RES+V_FP+V_SYNC);
// Simple placeholder: whole screen white while active
logic active_area = (h_count < H_RES && v_count < V_RES);
always_comb begin
if (active_area) begin
r = 4'hF; g = 4'hF; b = 4'hF; // white pixels
end else begin
r = 4'h0; g = 4'h0; b = 4'h0;
end
end
endmodule
Note: Right now it just blanks/white-fills the active area. The next step is to add an 8×8 font ROM and map value[7:4] and value[3:0] to hex characters at screen center.
I’ve expanded the browser-based assembler at /sap-1-assembler so it’s now a one‑stop build station. It still assembles to a 16×8 ROM, but now it can export a complete Vivado project bundle and includes the VGA path by default.
sap1_project.zip with sources, constraints, TCL build scripts, and a README.md.src/):sap1_top.sv (VGA‑enabled top; 7‑segment still supported)sap1_core.sv, sap1_rom.svsevenseg_hex_mux.sv (fixed bit mapping: SEG[0]=CA … SEG[6]=CG, SEG[7]=DP, all active‑low)btn_debouncer.sv, pulse_stretcher.svvga_timing_640x480_ce.sv, vga_hex7_overlay.svscripts/:build_sap1_nexys_a7.tcl — clean project → synth → impl → bitstreamrebuild_after_mem_change.tcl — fast re‑impl when only the ROM changesconstraints/:sap1_minimal.xdc — clock‑only (100 MHz on E3) is always addedNexys-A7-100T-Master.xdc — uncomment only the nets you use (buttons, 7‑seg, LEDs, VGA)mem/sap1_rom.memsrc/*.svsrc/
sap1_top.sv
sap1_core.sv
sap1_rom.sv
sevenseg_hex_mux.sv
btn_debouncer.sv
pulse_stretcher.sv
vga_timing_640x480_ce.sv
vga_hex7_overlay.sv
mem/
sap1_rom.mem
constraints/
sap1_minimal.xdc
(place Nexys-A7-100T-Master.xdc here)
scripts/
build_sap1_nexys_a7.tcl
rebuild_after_mem_change.tcl
README.md
sap1_project/).constraints/Nexys-A7-100T-Master.xdc and uncomment the nets you’re using (buttons BTNC/BTNU/BTND, 7‑seg SEG[7:0] + AN[7:0], LEDs, VGA VGA_HS/VGA_VS/VGA_R[3:0]/VGA_G[3:0]/VGA_B[3:0]). vivado -mode batch -source scripts/build_sap1_nexys_a7.tcl
sap1_nexys_a7/sap1_nexys_a7.runs/impl_1/sap1_top.bit
vivado -mode batch -source scripts/rebuild_after_mem_change.tcl
The default timing uses a 25 MHz pixel enable (100 MHz/4); most monitors accept it. If yours is picky, I can wire in a Clocking Wizard for an exact 25.175 MHz.
The overlay shows the OUT register as two large hex digits (also mirrored on the 7‑segment). If the two displays don’t match:
Make sure your master XDC nets are uncommented and named exactly as the top module.
Re‑export the ZIP so you get the corrected sevenseg_hex_mux.sv mapping.
Ensure run mode is actually on (press BTND; LED[8] lights). Use BTNU to single‑step if needed.

OUT to 0xFE by adding three numbersTarget: 0x64 + 0x32 + 0x68 = 0xFE (100 + 50 + 104 = 254).
; add3_to_FE.asm — OUT = 0x64 + 0x32 + 0x68 = 0xFE
LDA N1
ADDM N2
ADDM N3
OUTA
HLT
; Data region
N1: .db 0x64
N2: .db 0x32
N3: .db 0x68
.org and conditional jumps. If you’re following along and hit a snag, future-me, check the ROM path first. It’s always the ROM path. 🙂