Implementing the SAP-1 Computer on FPGA

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


What is SAP-1 (in my build)?

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:

  • Registers: A (accumulator), B (operand latch), OUT (display), IR (instruction register), PC (program counter).
  • ALU: add/sub with carry/borrow; flags: Z (zero), C (carry).
  • Memory: 16×8 ROM for program via $readmemh, optional tiny RAM for constants.
  • Control: a simple micro-step sequencer (T0..T5) that asserts control signals for fetch/decode/execute.
  • I/O: originally a 7-segment display, but I’ve now added a VGA text output that displays OUT as two hex digits.

Getting Started

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.

Checkout the SAP-1 HDL and .tcl scripts at GitHub:

SAP-1 Repository

Diagrams

SAP-1 Architecture Diagram

SAP-1 Sequence Diagram

Example Video

SystemVerilog Updates

I migrated the design fully to SystemVerilog (.sv files). This means:

  • Use logic instead of reg/wire in most places.
  • Use always_ff and always_comb for clarity.
  • Cleaner module interfaces and type safety.

Code Walkthrough (snippets + commentary)

1) Top-Level (with VGA support)

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

2) VGA Hex Display

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.


The Assembler (updated for SystemVerilog + VGA + one‑click ZIP)

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.

What’s new

  • ZIP export: one click produces sap1_project.zip with sources, constraints, TCL build scripts, and a README.md.
  • SystemVerilog sources included (auto-generated into src/):
  • sap1_top.sv (VGA‑enabled top; 7‑segment still supported)
  • sap1_core.sv, sap1_rom.sv
  • sevenseg_hex_mux.sv (fixed bit mapping: SEG[0]=CA … SEG[6]=CG, SEG[7]=DP, all active‑low)
  • btn_debouncer.sv, pulse_stretcher.sv
  • VGA: vga_timing_640x480_ce.sv, vga_hex7_overlay.sv
  • Vivado TCL in scripts/:
  • build_sap1_nexys_a7.tcl — clean project → synth → impl → bitstream
  • rebuild_after_mem_change.tcl — fast re‑impl when only the ROM changes
  • Constraints in constraints/:
  • sap1_minimal.xdc — clock‑only (100 MHz on E3) is always added
  • You add Nexys-A7-100T-Master.xdc — uncomment only the nets you use (buttons, 7‑seg, LEDs, VGA)
  • README.md: generated with proper newlines; includes step‑by‑step build/program instructions.

Buttons in the assembler UI

  • Assemble — compile and show errors/bytes
  • Download .mem — writes mem/sap1_rom.mem
  • Download SystemVerilog pack — individual src/*.sv
  • Download build .tcl / Download rebuild .tcl / Download minimal .xdc
  • Download .zip — the full bundle

ZIP layout

src/
  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

Build & program from the ZIP

  1. Unzip somewhere handy (e.g., sap1_project/).
  2. Copy your Digilent master XDC into 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]).
  3. Build from the project root:
   vivado -mode batch -source scripts/build_sap1_nexys_a7.tcl
  1. Program in Vivado Hardware Manager with:
   sap1_nexys_a7/sap1_nexys_a7.runs/impl_1/sap1_top.bit
  1. Quick update after only ROM changes:
   vivado -mode batch -source scripts/rebuild_after_mem_change.tcl

VGA notes

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

    SAP-1 VGA Output

Troubleshooting quickies

  • No pin constraints → Vivado warnings: verify your master XDC and the exact net names.
  • Nothing changes on LEDs/VGA → toggle run (BTND) or single‑step (BTNU). Each instruction takes two micro‑steps.
  • Only clock is constrained → expected if you only include the minimal XDC; add the master XDC for I/O.

Sample Program: set OUT to 0xFE by adding three numbers

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

Next Steps

  • Add VGA font ROM so the OUT register is drawn as two hex digits.
  • Expand assembler with .org and conditional jumps.
  • Try dual-output (7-seg + VGA simultaneously).

If you’re following along and hit a snag, future-me, check the ROM path first. It’s always the ROM path. 🙂

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top