<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>TinyComputers.io (Posts about ecp5)</title><link>https://tinycomputers.io/</link><description></description><atom:link href="https://tinycomputers.io/categories/ecp5.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Contents © 2026 A.C. Jokela 
&lt;!-- div style="width: 100%" --&gt;
&lt;a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/"&gt;&lt;img alt="" style="border-width:0" src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png" /&gt; Creative Commons Attribution-ShareAlike&lt;/a&gt;&amp;nbsp;|&amp;nbsp;
&lt;!-- /div --&gt;
</copyright><lastBuildDate>Mon, 06 Apr 2026 22:12:58 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Part 4: 132 Tests, Zero Failures - Verifying the Sampo CPU on Real Hardware</title><link>https://tinycomputers.io/posts/sampo-fpga-isa-verification.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/sampo-fpga-isa-verification_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;12 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;In &lt;a href="https://tinycomputers.io/posts/sampo-16-bit-risc-cpu-part-1.html"&gt;Part 1&lt;/a&gt;, we designed the Sampo 16-bit RISC architecture. In &lt;a href="https://tinycomputers.io/posts/sampo-fpga-implementation-ulx3s.html"&gt;Part 2&lt;/a&gt;, we synthesized it to an ECP5 FPGA on the ULX3S board. In &lt;a href="https://tinycomputers.io/posts/sampo-llvm-backend-rust-compiler.html"&gt;Part 3&lt;/a&gt;, we built an LLVM backend so Rust could compile for it. But there was a glaring gap in the project: we'd never systematically verified that the hardware actually implements the ISA correctly.&lt;/p&gt;
&lt;p&gt;The "Hello, Sampo!" demo program exercises maybe 10 of the CPU's 66 instructions. The LLVM backend generates code that assumes the hardware matches the spec. If a single instruction is subtly wrong - a carry flag not set, a branch offset miscalculated, a byte load sign-extending when it shouldn't - the entire toolchain is built on sand.&lt;/p&gt;
&lt;p&gt;This post documents the process of building a comprehensive test suite, running it in simulation, finding a real pipeline hazard bug in the CPU, and then the surprisingly treacherous journey of getting those tests running on real FPGA hardware.&lt;/p&gt;
&lt;h3&gt;The Test Strategy&lt;/h3&gt;
&lt;p&gt;The approach is straightforward: write assembly programs that exercise every instruction in the ISA, compare results against known-good values, and report PASS or FAIL over UART. The testbench monitors the serial output, and if it sees "FAIL" anywhere, the test run fails.&lt;/p&gt;
&lt;p&gt;Each test follows the same pattern:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;; Load known inputs&lt;/span&gt;
&lt;span class="nf"&gt;LIX&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0x1234&lt;/span&gt;
&lt;span class="nf"&gt;LIX&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0x5678&lt;/span&gt;

&lt;span class="c1"&gt;; Execute the instruction under test&lt;/span&gt;
&lt;span class="nf"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R9&lt;/span&gt;

&lt;span class="c1"&gt;; Check the result&lt;/span&gt;
&lt;span class="nf"&gt;MOV&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R10&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;; actual value&lt;/span&gt;
&lt;span class="nf"&gt;LIX&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0x68AC&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="c1"&gt;; expected value&lt;/span&gt;
&lt;span class="nf"&gt;JALX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;check_eq&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="c1"&gt;; prints PASS or FAIL&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;check_eq&lt;/code&gt; subroutine compares R4 (actual) against R5 (expected) and prints the result over the UART. This makes the test output human-readable and machine-parseable:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;=== ALU Tests ===
ADD basic: PASS
ADD zero: PASS
ADD carry out: PASS
ADD overflow: PASS
SUB basic: PASS
...
Done.
&lt;/pre&gt;&lt;/div&gt;

&lt;h3&gt;The Test Framework&lt;/h3&gt;
&lt;p&gt;Every test program begins with a block of helper subroutines that handle UART communication and result reporting. The core is a busy-wait loop that polls the MC6850-compatible UART status register:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="na"&gt;.equ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;ACIA_STATUS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0x80&lt;/span&gt;
&lt;span class="na"&gt;.equ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;ACIA_DATA&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;0x81&lt;/span&gt;

&lt;span class="nl"&gt;print_char:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;; R5 = character to output&lt;/span&gt;
&lt;span class="nl"&gt;.wait:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;INI&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;ACIA_STATUS&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;; Read status register&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;AND&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R6&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="c1"&gt;; Copy to R7&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;ADDI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-2&lt;/span&gt;&lt;span class="w"&gt;             &lt;/span&gt;&lt;span class="c1"&gt;; Check if TX ready (bit 1)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;BNE&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;.wait&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="c1"&gt;; Loop until ready&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;OUTI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;ACIA_DATA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R5&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;; Send character&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;JR&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="no"&gt;RA&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="c1"&gt;; Return&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;check_eq&lt;/code&gt; helper prints "PASS" or "FAIL" based on a register comparison, and the &lt;code&gt;print_str&lt;/code&gt; helper walks a null-terminated string byte by byte. These routines are duplicated in each test file rather than linked - there's no linker in this toolchain, just a single-file assembler.&lt;/p&gt;
&lt;h3&gt;Test Coverage&lt;/h3&gt;
&lt;p&gt;We organized the tests into 10 programs, each targeting a specific area of the instruction set:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test Program&lt;/th&gt;
&lt;th&gt;Instructions Tested&lt;/th&gt;
&lt;th&gt;Test Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;test_alu&lt;/td&gt;
&lt;td&gt;ADD, SUB, AND, OR, XOR, NEG + flags&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_addi&lt;/td&gt;
&lt;td&gt;ADDI with signed immediates + flags&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_shift&lt;/td&gt;
&lt;td&gt;SLL, SRL, SRA, ROL, ROR, SWAP (1/4/8-bit variants)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_muldiv&lt;/td&gt;
&lt;td&gt;MUL, MULH, DIV, DIVU, REM, REMU&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_loadstore&lt;/td&gt;
&lt;td&gt;LW, LB, LBU, SW, SB + offset variants&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_branch&lt;/td&gt;
&lt;td&gt;All 16 branch conditions (taken + not taken)&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_jump&lt;/td&gt;
&lt;td&gt;J, JR, JALR, JX, JALX&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_stack&lt;/td&gt;
&lt;td&gt;PUSH, POP, CMP, TEST, MOV&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_misc&lt;/td&gt;
&lt;td&gt;EXX, GETF, SETF, SCF, CCF, NOP&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;test_extended&lt;/td&gt;
&lt;td&gt;ADDIX, SUBIX, ANDIX, ORIX, XORIX, SLLX, SRLX, SRAX&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;132&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The branch tests are particularly thorough - each of the 16 conditions (BEQ, BNE, BLT, BGE, BLTU, BGEU, BMI, BPL, BVS, BVC, BCS, BCC, BGT, BLE, BHI, BLS) gets tested both for the taken and not-taken case. We set up flags with arithmetic, then verify the branch goes the right way.&lt;/p&gt;
&lt;h3&gt;Finding a Real Bug: The Pipeline Hazard&lt;/h3&gt;
&lt;p&gt;The first time we ran the full test suite in simulation, 130 of 132 tests passed. Two tests in &lt;code&gt;test_loadstore&lt;/code&gt; were failing: the multi-word store/load test and a load with offset test.&lt;/p&gt;
&lt;p&gt;The failing pattern was consistent: any test that performed a store followed immediately by a load from a different address would read stale data. The load would return the value from the &lt;em&gt;previous&lt;/em&gt; memory operation instead of the current one.&lt;/p&gt;
&lt;p&gt;The root cause was a pipeline hazard between the MEMORY and FETCH states. Here's what was happening:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;Cycle N:   MEMORY state - store completes, mem_ready asserts
Cycle N+1: FETCH state  - new instruction fetch begins
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The problem: &lt;code&gt;mem_ready&lt;/code&gt; is a one-cycle delayed version of &lt;code&gt;mem_valid&lt;/code&gt; (because the RAM is synchronous). When the CPU transitions from MEMORY to WRITEBACK to FETCH, the &lt;code&gt;mem_ready&lt;/code&gt; signal from the store was still asserted during the first cycle of the next FETCH. The CPU latched the stale &lt;code&gt;mem_rdata&lt;/code&gt; from the previous store operation as if it were the new instruction.&lt;/p&gt;
&lt;p&gt;The fix was to add a WRITEBACK state after every MEMORY operation - not just loads, but stores too. This gives &lt;code&gt;mem_ready&lt;/code&gt; a cycle to deassert before the next FETCH begins:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="n"&gt;Before&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FETCH&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mem_ready&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;still&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;MEMORY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WRITEBACK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FETCH&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mem_ready&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deasserts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;during&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WRITEBACK&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;A one-line change to the next-state logic:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="no"&gt;`ST_MEMORY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;begin&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mem_ready&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;begin&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;// Always go through WRITEBACK after MEMORY.&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;// For stores: allows mem_ready to deassert before&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;// next FETCH (prevents stale rdata latch).&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;next_state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;`ST_WRITEBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This is exactly the kind of bug that simulation catches and manual inspection misses. The instruction executes correctly in isolation - it's only the &lt;em&gt;interaction&lt;/em&gt; between consecutive memory operations that triggers the hazard. After the fix, all 132 tests passed in simulation.&lt;/p&gt;
&lt;h3&gt;Taking It to the FPGA&lt;/h3&gt;
&lt;p&gt;With simulation clean, the next step was running the tests on real hardware. The ULX3S board has an &lt;a href="https://baud.rs/bJSrEK"&gt;FTDI&lt;/a&gt; FT231X USB-serial chip connected to the FPGA, so UART output appears on a serial port at 115200 baud.&lt;/p&gt;
&lt;p&gt;There was an immediate practical problem: the test programs run fast. At 12.5 MHz, the entire 20-test ALU suite completes in about 30 milliseconds. By the time openFPGALoader finishes programming the FPGA and releases the USB port, the test output is long gone. The FTDI chip has a small receive buffer, but 364 characters of test output overflows it before you can open the serial port.&lt;/p&gt;
&lt;p&gt;The solution: patch the hex files to loop instead of halting. Replace the HALT instruction with a delay loop followed by a jump back to the reset vector. The test runs, outputs its results, waits about half a second, and starts over. You can open the serial port at any time and catch a complete iteration.&lt;/p&gt;
&lt;h4&gt;The Delay Loop Patch&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;hex_loop_patch.py&lt;/code&gt; script performs binary patching on the assembled hex files. It finds the HALT instruction (encoded as &lt;code&gt;0xE100&lt;/code&gt;) and replaces it with a delay loop:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;; Delay ~0.38 seconds at 12.5 MHz&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;LIX&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0x0008&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="c1"&gt;; outer counter&lt;/span&gt;
&lt;span class="nl"&gt;outer:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;LIX&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;R9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0xFFFF&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="c1"&gt;; inner counter = 65535&lt;/span&gt;
&lt;span class="nl"&gt;inner:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;ADDI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;BNE&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;inner&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;ADDI&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="no"&gt;R8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;-1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;BNE&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="no"&gt;outer&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;JX&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="mi"&gt;0x0100&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="c1"&gt;; jump back to reset vector&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The first version of this script &lt;em&gt;inserted&lt;/em&gt; these 10 words at the HALT position. This seemed obviously correct. The tests ran on FPGA. Characters appeared on the serial port.&lt;/p&gt;
&lt;p&gt;They were the wrong characters.&lt;/p&gt;
&lt;h3&gt;The Address Shift Bug&lt;/h3&gt;
&lt;p&gt;The FPGA output for the "Hello, Sampo!" test program was &lt;code&gt;\x08\x08\x08\x08&lt;/code&gt; - four backspace characters, repeating forever. The ALU test suite showed truncated output with roughly 45% of characters missing. Same pattern at 12.5 MHz and 6.25 MHz, ruling out timing violations. Simulation with realistic UART timing (1,080 cycles per byte, matching the hardware baud rate) passed perfectly.&lt;/p&gt;
&lt;p&gt;I spent considerable time investigating the wrong theories. Was the UART transmitter dropping bytes? Was there a clock domain crossing issue? Was &lt;code&gt;$readmemh&lt;/code&gt; in Yosys interpreting the hex file differently from Icarus Verilog? None of these panned out.&lt;/p&gt;
&lt;p&gt;The breakthrough came from staring at &lt;code&gt;\x08&lt;/code&gt;. That's the byte value 8. Where would 8 come from? The "Hello, Sampo!" program loads its message pointer with &lt;code&gt;LIX R4, message&lt;/code&gt; where &lt;code&gt;message&lt;/code&gt; is the label for the string data. In the assembled hex, &lt;code&gt;message&lt;/code&gt; resolves to address &lt;code&gt;0x011E&lt;/code&gt; - the byte immediately after the HALT instruction.&lt;/p&gt;
&lt;p&gt;And there it was. Look at the assembly structure:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="nl"&gt;done:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nf"&gt;HALT&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="c1"&gt;; address 0x011C&lt;/span&gt;
&lt;span class="nl"&gt;message:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;.asciz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Hello, Sampo!\n"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;; address 0x011E&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The string data lives immediately after HALT. When &lt;code&gt;hex_loop_patch.py&lt;/code&gt; &lt;em&gt;inserts&lt;/em&gt; 10 words of delay loop code at the HALT position, it pushes the string data down by 20 bytes. But the &lt;code&gt;LIX R4, 0x011E&lt;/code&gt; instruction still points to the original address. At &lt;code&gt;0x011E&lt;/code&gt; there's now the second word of &lt;code&gt;LIX R8, 0x0008&lt;/code&gt; - which contains the value &lt;code&gt;0x0008&lt;/code&gt;. The low byte is &lt;code&gt;0x08&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The CPU faithfully reads byte &lt;code&gt;0x08&lt;/code&gt; from the patched address, outputs it via UART, advances the pointer to &lt;code&gt;0x011F&lt;/code&gt; where the high byte is &lt;code&gt;0x00&lt;/code&gt; (the null terminator), and stops. One &lt;code&gt;\x08&lt;/code&gt; per iteration, four iterations captured. Mystery solved.&lt;/p&gt;
&lt;p&gt;This same address shift corrupted every test program. The test strings ("ADD basic: ", "PASS\n", etc.) all live after HALT and all got displaced. The CPU was reading from locations that now contained delay loop machine code instead of ASCII text. Some fragments of text survived because adjacent strings partially overlapped with their shifted locations, producing the truncated output we saw.&lt;/p&gt;
&lt;h4&gt;The Fix&lt;/h4&gt;
&lt;p&gt;The correct approach: don't shift any data. Place the delay loop at address &lt;code&gt;0x0000&lt;/code&gt; - the 256 bytes of unused memory before the &lt;code&gt;0x0100&lt;/code&gt; reset vector - and replace the single-word HALT with a single-word relative &lt;code&gt;J&lt;/code&gt; (jump) instruction that jumps backward to the loop code. One word replaces one word. No data moves.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;# Place delay loop at address 0x0000 (unused space)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOOP_PATCH&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;loop_base&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;

&lt;span class="c1"&gt;# Replace HALT with J instruction to address 0x0000&lt;/span&gt;
&lt;span class="c1"&gt;# J encoding: opcode 0x9, 12-bit signed offset&lt;/span&gt;
&lt;span class="c1"&gt;# target = PC + 2 + (sign_extend(offset) &amp;lt;&amp;lt; 1)&lt;/span&gt;
&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_addr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;halt_addr&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="n"&gt;j_word&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x9000&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;words&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;halt_idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;j_word&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;There's a subtle complication: the J instruction shares opcode &lt;code&gt;0x9&lt;/code&gt; with JR (register indirect jump) and JALR (jump and link register). The decoder distinguishes them by specific bit patterns in the offset field. If the calculated offset happens to have &lt;code&gt;bits[3:0] == 0x1&lt;/code&gt; and &lt;code&gt;bits[11:8] != 0xF&lt;/code&gt;, the decoder interprets it as JALR instead of J. The script tries successive target addresses (&lt;code&gt;0x0000&lt;/code&gt;, &lt;code&gt;0x0002&lt;/code&gt;, &lt;code&gt;0x0004&lt;/code&gt;, ...) until it finds one that doesn't collide with the JR/JALR encoding space.&lt;/p&gt;
&lt;p&gt;After the fix, the patched hex files have exactly the same number of words as the originals. The only changes are the delay loop code written to the zero page and the HALT word replaced with a backward jump.&lt;/p&gt;
&lt;p&gt;With the corrected patcher, the "Hello, Sampo!" program finally works on the FPGA - looping cleanly with zero character loss:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://tinycomputers.io/images/sampo-fpga-isa-verification/HelloSampo.png" style="width: 100%; max-width: 720px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); margin: 1em 0;" loading="lazy" alt="Terminal showing Hello, Sampo! repeating on the ULX3S FPGA via cu serial connection"&gt;&lt;/p&gt;
&lt;h3&gt;The Testbench: Trusting but Verifying&lt;/h3&gt;
&lt;p&gt;One important discovery during this process: the simulation testbench had &lt;code&gt;tx_ready = 1&lt;/code&gt; permanently. The simulated UART never pushed back on the CPU - it accepted every byte instantly. This meant the CPU's busy-wait loop (&lt;code&gt;INI R6, ACIA_STATUS / ADDI R7, -2 / BNE wait&lt;/code&gt;) was never actually tested in simulation. The status register always returned "ready," so the loop body executed zero times.&lt;/p&gt;
&lt;p&gt;On real hardware, the UART transmitter takes about 87 microseconds per byte at 115200 baud. The busy-wait loop runs hundreds of times per character, exercising the INI instruction, the AND/ADDI flag-setting sequence, and the BNE branch in a tight loop. If any of those instructions had a subtle bug, it would only manifest on hardware.&lt;/p&gt;
&lt;p&gt;We added realistic UART timing to the testbench:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="k"&gt;parameter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TX_BYTE_CYCLES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;108&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;// ~1080 cycles per byte&lt;/span&gt;
&lt;span class="kt"&gt;reg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mh"&gt;15&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mh"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tx_delay_cnt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;always&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="k"&gt;posedge&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;begin&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_valid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tx_ready&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;begin&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;tx_ready&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;tx_delay_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TX_BYTE_CYCLES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_delay_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;begin&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;tx_delay_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tx_delay_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx_delay_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="n"&gt;tx_ready&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mh"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;With this change, simulation exercises the same code paths as the hardware. All 132 tests still pass - the UART flow control logic was correct all along, it just wasn't being tested.&lt;/p&gt;
&lt;h3&gt;Running All Tests on the FPGA&lt;/h3&gt;
&lt;video controls style="width: 100%; max-width: 720px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); margin: 0 0 1em 0;"&gt;
&lt;source src="https://tinycomputers.io/sampo-fpga-test-suite.mp4" type="video/mp4"&gt;
Your browser does not support the video tag.
&lt;/source&gt;&lt;/video&gt;

&lt;p&gt;With the patch bug fixed, we ran the complete suite. Each test requires a separate FPGA build (Yosys synthesis, nextpnr place-and-route, ecppack bitstream generation), programming via JTAG, and serial capture. The Makefile automates the entire pipeline:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="nf"&gt;fpga-%&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;BUILD_DIR&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;/&lt;span class="n"&gt;sampo_&lt;/span&gt;%.&lt;span class="n"&gt;bit&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;openFPGALoader&lt;span class="w"&gt; &lt;/span&gt;-b&lt;span class="w"&gt; &lt;/span&gt;ulx3s&lt;span class="w"&gt; &lt;/span&gt;$&amp;lt;
&lt;span class="w"&gt;    &lt;/span&gt;sleep&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;python3&lt;span class="w"&gt; &lt;/span&gt;fpga_capture.py&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;SERIAL_PORT&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;SERIAL_BAUD&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;fpga_capture.py&lt;/code&gt; script opens the serial port, discards the first partial iteration (we might join mid-stream), waits for the &lt;code&gt;=== ... ===&lt;/code&gt; header line that starts each test, captures everything until the header repeats, and outputs one clean iteration.&lt;/p&gt;
&lt;p&gt;The results:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;========================================
=== FPGA: test_alu ===
========================================
=== ALU Tests ===
ADD basic: PASS
ADD zero: PASS
ADD carry out: PASS
...
AND clr C/V: PASS
All tests passed!

========================================
=== FPGA: test_addi ===
========================================
...
All tests passed!

...

========================================
FPGA Test Summary: 10 passed, 0 failed
========================================
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;All 10 test suites pass. All 132 individual tests pass. Zero failures on real hardware.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test Suite&lt;/th&gt;
&lt;th&gt;Tests&lt;/th&gt;
&lt;th&gt;FPGA Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ALU (ADD, SUB, AND, OR, XOR)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADDI (immediate arithmetic)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shift (SLL, SRL, SRA, ROL, SWAP)&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MulDiv (MUL, DIV, REM variants)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load/Store (LW, LB, LBU, SW, SB)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Branch (all 16 conditions)&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jump (J, JR, JALR, JX, JALX)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack (PUSH, POP, CMP, TEST, MOV)&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Misc (EXX, GETF, SETF, SCF, CCF, NOP)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extended (ADDIX, SUBIX, SLLX, etc.)&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;All PASS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;132&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;All PASS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;What This Means&lt;/h3&gt;
&lt;p&gt;Having all 132 ISA tests pass on hardware is a significant milestone for the project. It means:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Verilog RTL is correct.&lt;/strong&gt; Every instruction in the Sampo ISA produces the right result, sets the right flags, and handles edge cases (zero, overflow, carry, sign extension) correctly. Not just in behavioral simulation, but in synthesized logic on a real FPGA running at 12.5 MHz.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The assembler is correct.&lt;/strong&gt; All 66 instructions encode properly. Branch offsets calculate correctly. Extended instructions (LIX, JALX, OUTX) with their 32-bit encoding work. The &lt;code&gt;sasm&lt;/code&gt; Rust assembler and the Verilog decoder agree on every instruction format.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The LLVM backend has a solid foundation.&lt;/strong&gt; When the Rust compiler generates a &lt;code&gt;ADD&lt;/code&gt; or &lt;code&gt;BNE&lt;/code&gt; or &lt;code&gt;JALX&lt;/code&gt;, the hardware will execute it correctly. The test suite doesn't exercise every possible code generation pattern, but it validates every primitive instruction that the compiler builds upon.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The UART subsystem works end-to-end.&lt;/strong&gt; Status register polling, TX busy-wait, byte transmission, baud rate generation - all verified on hardware. The MC6850-compatible interface works exactly as specified.&lt;/p&gt;
&lt;h3&gt;Lessons Learned&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Test your assumptions.&lt;/strong&gt; The testbench had &lt;code&gt;tx_ready = 1&lt;/code&gt;. It went unnoticed because simulation "worked." The real hardware exercises code paths that simulation shortcuts. Add realistic peripheral timing to your testbenches from day one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Binary patching is fragile.&lt;/strong&gt; Inserting bytes into a binary without updating references is a classic relocation bug - the same class of problem that linkers exist to solve. If your patch changes the size of anything, every address reference past the patch point is wrong. The fix - placing the patch in unused address space and using a same-size replacement instruction - avoids the problem entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Simulation is necessary but not sufficient.&lt;/strong&gt; The pipeline hazard bug was caught by simulation. The address shift bug was invisible to simulation (both used the same patching script, and the original programs - without patching - worked fine). You need both simulation and hardware testing, exercising different code paths and different failure modes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Systematic testing finds bugs that demos don't.&lt;/strong&gt; "Hello, Sampo!" worked on the FPGA from day one. It exercises &lt;code&gt;LIX&lt;/code&gt;, &lt;code&gt;LBU&lt;/code&gt;, &lt;code&gt;CMP&lt;/code&gt;, &lt;code&gt;BEQ&lt;/code&gt;, &lt;code&gt;INI&lt;/code&gt;, &lt;code&gt;OUTI&lt;/code&gt;, &lt;code&gt;ADDI&lt;/code&gt;, and &lt;code&gt;J&lt;/code&gt; - about 8 instructions. The pipeline hazard only manifested when a store was followed by a load to a different address, a pattern that doesn't occur in a simple print loop. You need tests specifically designed to exercise corner cases.&lt;/p&gt;
&lt;h3&gt;What's Next&lt;/h3&gt;
&lt;p&gt;The entire Sampo project - assembler, emulator, Verilog RTL, FPGA build scripts, test suite, and LLVM backend - is open source on &lt;a href="https://baud.rs/r74wA8"&gt;GitHub&lt;/a&gt;. With hardware verification complete, the next steps might be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Running Rust-compiled code on the FPGA.&lt;/strong&gt; The LLVM backend generates assembly, the assembler produces hex files, and we now know the hardware executes them correctly. Closing this loop - &lt;code&gt;cargo build&lt;/code&gt; to blinking LEDs - is the obvious next milestone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adding more peripherals.&lt;/strong&gt; The ULX3S has 32MB of SDRAM, an HDMI output, a microSD slot, and an ESP32 co-processor. Each of these opens up interesting possibilities for a working 16-bit computer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Performance optimization.&lt;/strong&gt; The CPU currently runs at 12.5 MHz with a multi-cycle FSM (5-8 cycles per instruction). Pipelining could push this significantly higher on the ECP5.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But first: 132 tests, zero failures. The Sampo CPU works.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This is Part 4 of the Sampo series. &lt;a href="https://tinycomputers.io/posts/sampo-16-bit-risc-cpu-part-1.html"&gt;Part 1&lt;/a&gt; covers architecture design, &lt;a href="https://tinycomputers.io/posts/sampo-fpga-implementation-ulx3s.html"&gt;Part 2&lt;/a&gt; covers FPGA implementation, and &lt;a href="https://tinycomputers.io/posts/sampo-llvm-backend-rust-compiler.html"&gt;Part 3&lt;/a&gt; covers the LLVM backend.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;Recommended Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://baud.rs/wvPosK"&gt;OrangeCrab ECP5 FPGA Board&lt;/a&gt; - A compact Lattice ECP5 board with DDR3 and USB-C, available on Amazon&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/6U3DBr"&gt;ECP5 FPGA Development Boards&lt;/a&gt; - Other ECP5 boards available on Amazon&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/RGjpAj"&gt;&lt;em&gt;Getting Started with FPGAs&lt;/em&gt;&lt;/a&gt; by Russell Merrick - Beginner-friendly introduction with Verilog and VHDL examples&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/bJSrEK"&gt;FTDI USB Serial Adapters&lt;/a&gt; - Useful for UART debugging with FPGAs&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/dBX5Ij"&gt;USB Logic Analyzers&lt;/a&gt; - Essential for debugging digital signals&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Source Code&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://baud.rs/r74wA8"&gt;github.com/ajokela/sampo&lt;/a&gt;&lt;/strong&gt; - CPU architecture, assembler, emulator, Verilog RTL, test suite, and FPGA build scripts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://baud.rs/GCQDRa"&gt;github.com/ajokela/llvm-sampo&lt;/a&gt;&lt;/strong&gt; - LLVM backend and Rust target specification&lt;/li&gt;
&lt;/ul&gt;</description><category>cpu design</category><category>ecp5</category><category>fpga</category><category>hardware</category><category>isa</category><category>risc</category><category>sampo</category><category>testing</category><category>uart</category><category>ulx3s</category><category>verification</category><category>verilog</category><guid>https://tinycomputers.io/posts/sampo-fpga-isa-verification.html</guid><pubDate>Sun, 15 Feb 2026 20:00:00 GMT</pubDate></item><item><title>Part 2: Implementing Sampo on the ULX3S FPGA</title><link>https://tinycomputers.io/posts/sampo-fpga-implementation-ulx3s.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;p&gt;After designing the &lt;a href="https://tinycomputers.io/posts/sampo-16-bit-risc-cpu-part-1.html"&gt;Sampo RISC architecture&lt;/a&gt; on paper (complete with a working assembler and emulator) it's time to bring it to life in silicon. Or at least, in programmable logic. This post documents the hardware selection and implementation planning for synthesizing Sampo on an FPGA.&lt;/p&gt;
&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/sampo-fpga-implementation-ulx3s_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;7 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;The Story So Far&lt;/h3&gt;
&lt;p&gt;If you haven't read &lt;a href="https://tinycomputers.io/posts/sampo-16-bit-risc-cpu-part-1.html"&gt;Part 1 of this series&lt;/a&gt;, here's the quick version: Sampo is a 16-bit RISC CPU designed to bridge the gap between clean RISC design principles and Z80-friendly features. It has 16 general-purpose registers, ~66 instructions, port-based I/O, block operations (LDIR, LDDR), alternate registers for fast interrupt handling, and hardware multiply/divide.&lt;/p&gt;
&lt;p&gt;The project already includes working tools written in Rust:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;sasm&lt;/strong&gt; - A full assembler&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;semu&lt;/strong&gt; - An emulator with TUI debugger (step, breakpoints, memory inspection)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And for hardware implementation, we now have two complete RTL implementations:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Amaranth HDL&lt;/strong&gt; (&lt;code&gt;/rtl/&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cpu.py&lt;/code&gt;, &lt;code&gt;alu.py&lt;/code&gt;, &lt;code&gt;decode.py&lt;/code&gt;, &lt;code&gt;regfile.py&lt;/code&gt;, &lt;code&gt;soc.py&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Python-based, excellent for rapid iteration&lt;/li&gt;
&lt;li&gt;Generates Verilog for synthesis&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;AI Assisted Hand-written Verilog&lt;/strong&gt; (&lt;code&gt;/verilog/rtl/&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cpu.v&lt;/code&gt;, &lt;code&gt;alu.v&lt;/code&gt;, &lt;code&gt;decode.v&lt;/code&gt;, &lt;code&gt;regfile.v&lt;/code&gt;, &lt;code&gt;shifter.v&lt;/code&gt;, &lt;code&gt;uart.v&lt;/code&gt;, &lt;code&gt;ram.v&lt;/code&gt;, &lt;code&gt;soc.v&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Readable, portable, works with any toolchain&lt;/li&gt;
&lt;li&gt;Includes testbenches for Icarus Verilog and Verilator&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now it's time to synthesize it to real hardware.&lt;/p&gt;
&lt;h3&gt;Choosing an FPGA Platform&lt;/h3&gt;
&lt;p&gt;The FPGA world is split between proprietary toolchains (Xilinx Vivado, Intel Quartus) and the growing open source ecosystem. For a project like Sampo, where understanding every layer of the stack matters, open source tooling is the clear choice.&lt;/p&gt;
&lt;h4&gt;Open Source FPGA Options&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FPGA Family&lt;/th&gt;
&lt;th&gt;Capacity&lt;/th&gt;
&lt;th&gt;Toolchain&lt;/th&gt;
&lt;th&gt;Maturity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gowin GW1N/GW2A&lt;/td&gt;
&lt;td&gt;1K-55K LUTs&lt;/td&gt;
&lt;td&gt;Project Apicula&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lattice iCE40&lt;/td&gt;
&lt;td&gt;1K-8K LUTs&lt;/td&gt;
&lt;td&gt;Project IceStorm&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lattice ECP5&lt;/td&gt;
&lt;td&gt;12K-85K LUTs&lt;/td&gt;
&lt;td&gt;Project Trellis&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xilinx 7-series&lt;/td&gt;
&lt;td&gt;10K-200K+ LUTs&lt;/td&gt;
&lt;td&gt;Project X-Ray (partial)&lt;/td&gt;
&lt;td&gt;Experimental&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For Sampo, which estimates at &lt;strong&gt;~1,500-2,500 LUTs&lt;/strong&gt; for the basic CPU, even the smaller FPGAs have more than enough capacity. But if we want room to grow (adding caches, more peripherals, maybe even multi-core experiments) a larger device makes sense.&lt;/p&gt;
&lt;h3&gt;The ULX3S Board&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://baud.rs/Ij7oaR"&gt;ULX3S&lt;/a&gt; is an open hardware development board built around the ECP5 FPGA. It's designed by &lt;a href="https://baud.rs/v9aiPd"&gt;Radiona.org&lt;/a&gt; and has become the de facto standard for open source FPGA development.&lt;/p&gt;
&lt;h4&gt;Specifications&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Specification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FPGA&lt;/td&gt;
&lt;td&gt;Lattice ECP5 (LFE5U-85F/45F/12F-6BG381C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LUTs&lt;/td&gt;
&lt;td&gt;12K / 44K / 84K (depending on variant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;USB&lt;/td&gt;
&lt;td&gt;FTDI FT231XS (500 kbit JTAG, 3 Mbit serial)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPIO&lt;/td&gt;
&lt;td&gt;56 pins (28 differential pairs), PMOD-compatible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;td&gt;32 MB SDRAM @ 166 MHz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flash&lt;/td&gt;
&lt;td&gt;4-16 MB Quad-SPI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;microSD slot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LEDs&lt;/td&gt;
&lt;td&gt;11 total (8 user, 2 USB, 1 WiFi)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buttons&lt;/td&gt;
&lt;td&gt;7 (4 direction, 2 fire, 1 power)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audio&lt;/td&gt;
&lt;td&gt;3.5mm jack (stereo + digital/composite)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;td&gt;GPDI (HDMI-compatible) with level shifter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Display&lt;/td&gt;
&lt;td&gt;Header for 0.96" SPI OLED (SSD1331)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wireless&lt;/td&gt;
&lt;td&gt;ESP32-WROOM-32 (WiFi/Bluetooth, standalone JTAG)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADC&lt;/td&gt;
&lt;td&gt;8 channels, 12-bit, 1 MS/s (MAX11125)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clock&lt;/td&gt;
&lt;td&gt;25 MHz onboard, differential input available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power&lt;/td&gt;
&lt;td&gt;3 switching regulators (1.1V, 2.5V, 3.3V)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sleep&lt;/td&gt;
&lt;td&gt;5 µA standby, RTC wake-up with battery backup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dimensions&lt;/td&gt;
&lt;td&gt;94mm × 51mm&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;Why ULX3S for Sampo&lt;/h4&gt;
&lt;p&gt;The ULX3S isn't just an FPGA breakout board; it's a complete system:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;32MB SDRAM&lt;/strong&gt;: Real memory, not just block RAM. Essential for running actual programs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HDMI output&lt;/strong&gt;: Video terminal without external hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;microSD slot&lt;/strong&gt;: Load programs, implement a filesystem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ESP32 co-processor&lt;/strong&gt;: WiFi-based JTAG debugging from any device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Buttons and LEDs&lt;/strong&gt;: Instant I/O for testing without wiring anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audio output&lt;/strong&gt;: Even supports composite video through the audio jack.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Budget Alternative: Tang Nano 9K&lt;/h3&gt;
&lt;p&gt;Before we dive into the ULX3S, it's worth mentioning a much cheaper option. The &lt;strong&gt;Tang Nano 9K&lt;/strong&gt; (~$15 on AliExpress) uses a Gowin GW1NR-9 FPGA with 8,640 LUTs, more than enough for Sampo:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;8,640 LUTs&lt;/li&gt;
&lt;li&gt;64Mbit PSRAM (can serve as the full 64KB address space and then some)&lt;/li&gt;
&lt;li&gt;HDMI output for a video terminal&lt;/li&gt;
&lt;li&gt;USB-C programming&lt;/li&gt;
&lt;li&gt;Fully supported by open-source toolchain (Yosys + nextpnr-gowin)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For initial development and testing, the Tang Nano 9K is hard to beat on price. But the ULX3S offers more I/O, more RAM, and a richer peripheral set, making it the better choice for a more complete Sampo system.&lt;/p&gt;
&lt;h3&gt;LUT Budget Planning&lt;/h3&gt;
&lt;p&gt;The Sampo RTL implementation is designed to be compact. Here's the resource breakdown:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Estimated LUTs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16 × 16-bit registers&lt;/td&gt;
&lt;td&gt;~256 FFs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALU (16-bit)&lt;/td&gt;
&lt;td&gt;200 - 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control logic&lt;/td&gt;
&lt;td&gt;500 - 1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction decode&lt;/td&gt;
&lt;td&gt;300 - 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sampo CPU core&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1,500 - 2,500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UART (115200 baud)&lt;/td&gt;
&lt;td&gt;200 - 300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SPI controller (SD card)&lt;/td&gt;
&lt;td&gt;300 - 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPIO controller&lt;/td&gt;
&lt;td&gt;200 - 400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Basic system&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~2,500 - 4,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDRAM controller&lt;/td&gt;
&lt;td&gt;500 - 1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction cache&lt;/td&gt;
&lt;td&gt;1,000 - 2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data cache&lt;/td&gt;
&lt;td&gt;1,000 - 2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Full system&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~6,000 - 10,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;These estimates are based on typical RISC CPU implementations. The actual numbers will depend on optimization choices and synthesis settings.&lt;/p&gt;
&lt;h4&gt;Variant Recommendations&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;12K LUTs&lt;/strong&gt; (ULX3S-12F): Plenty for basic Sampo + peripherals, tight for caches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;45K LUTs&lt;/strong&gt; (ULX3S-45F): Comfortable. Full CPU with cache, room for experiments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;85K LUTs&lt;/strong&gt; (ULX3S-85F): Luxurious. Multi-core experiments, extensive peripherals.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Toolchain Setup&lt;/h3&gt;
&lt;p&gt;The ECP5 toolchain is fully open source:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;# macOS (Homebrew)&lt;/span&gt;
brew&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;yosys&lt;span class="w"&gt; &lt;/span&gt;nextpnr-ecp5&lt;span class="w"&gt; &lt;/span&gt;ecpprog&lt;span class="w"&gt; &lt;/span&gt;fujprog

&lt;span class="c1"&gt;# Ubuntu/Debian&lt;/span&gt;
apt&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;yosys&lt;span class="w"&gt; &lt;/span&gt;nextpnr-ecp5&lt;span class="w"&gt; &lt;/span&gt;ecpprog

&lt;span class="c1"&gt;# Amaranth HDL (for our existing RTL)&lt;/span&gt;
pip&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;amaranth&lt;span class="w"&gt; &lt;/span&gt;amaranth-boards

&lt;span class="c1"&gt;# Or build FPGA tools from source for latest features&lt;/span&gt;
git&lt;span class="w"&gt; &lt;/span&gt;clone&lt;span class="w"&gt; &lt;/span&gt;https://github.com/YosysHQ/yosys
git&lt;span class="w"&gt; &lt;/span&gt;clone&lt;span class="w"&gt; &lt;/span&gt;https://github.com/YosysHQ/nextpnr
git&lt;span class="w"&gt; &lt;/span&gt;clone&lt;span class="w"&gt; &lt;/span&gt;https://github.com/YosysHQ/prjtrellis
&lt;/pre&gt;&lt;/div&gt;

&lt;h4&gt;Tool Roles&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Amaranth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Python-based HDL (generates Verilog)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Yosys&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Verilog synthesis (RTL → netlist)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;nextpnr-ecp5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Place and route (netlist → bitstream)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Project Trellis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ECP5 bitstream documentation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ecpprog/fujprog&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Upload bitstream to board&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;Amaranth Build Flow&lt;/h4&gt;
&lt;p&gt;Since Sampo's RTL is written in Amaranth, the build flow starts with Python:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;# Generate Verilog from Amaranth&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;rtl/
python&lt;span class="w"&gt; &lt;/span&gt;-m&lt;span class="w"&gt; &lt;/span&gt;amaranth&lt;span class="w"&gt; &lt;/span&gt;generate&lt;span class="w"&gt; &lt;/span&gt;soc.py&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;sampo.v

&lt;span class="c1"&gt;# Then synthesize with standard tools&lt;/span&gt;
yosys&lt;span class="w"&gt; &lt;/span&gt;-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"synth_ecp5 -top sampo_soc -json sampo.json"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sampo.v
nextpnr-ecp5&lt;span class="w"&gt; &lt;/span&gt;--85k&lt;span class="w"&gt; &lt;/span&gt;--package&lt;span class="w"&gt; &lt;/span&gt;CABGA381&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--lpf&lt;span class="w"&gt; &lt;/span&gt;ulx3s.lpf&lt;span class="w"&gt; &lt;/span&gt;--json&lt;span class="w"&gt; &lt;/span&gt;sampo.json&lt;span class="w"&gt; &lt;/span&gt;--textcfg&lt;span class="w"&gt; &lt;/span&gt;sampo.config
ecppack&lt;span class="w"&gt; &lt;/span&gt;sampo.config&lt;span class="w"&gt; &lt;/span&gt;sampo.bit

&lt;span class="c1"&gt;# Program the board&lt;/span&gt;
fujprog&lt;span class="w"&gt; &lt;/span&gt;sampo.bit
&lt;/pre&gt;&lt;/div&gt;

&lt;h4&gt;Hand-Written Verilog Implementation&lt;/h4&gt;
&lt;p&gt;In addition to the Amaranth RTL, we now have a complete ai-assisted hand-written Verilog implementation at &lt;code&gt;/verilog/&lt;/code&gt;. While Amaranth can generate Verilog, the auto-generated output isn't particularly readable. The hand-written version is designed for clarity and portability:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="n"&gt;verilog&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rtl&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sampo_pkg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vh&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# Opcodes, constants, state definitions&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;alu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# 16-bit ALU with all operations&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shifter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# Barrel shifter (1/4/8-bit shifts, rotates)&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;regfile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# 16 registers + alternate set (EXX)&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="c1"&gt;# Instruction decoder&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cpu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# FSM-based CPU core (8 states)&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ram&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# 64KB synchronous RAM&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="c1"&gt;# Simple UART for serial I/O&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;└──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;soc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# Top-level SoC integration&lt;/span&gt;
&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tb&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;alu_tb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="c1"&gt;# ALU unit tests&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;regfile_tb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# Register file tests&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;└──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sampo_tb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c1"&gt;# Full system testbench&lt;/span&gt;
&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;programs&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;
&lt;span class="err"&gt;│&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;└──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;hello&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# Test program in Verilog hex format&lt;/span&gt;
&lt;span class="err"&gt;├──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Makefile&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="c1"&gt;# Build automation&lt;/span&gt;
&lt;span class="err"&gt;└──&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bin2hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="c1"&gt;# Convert sasm output to Verilog $readmemh format&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The Verilog implementation uses an 8-state FSM for the CPU: RESET → FETCH → FETCH_EXT → DECODE → EXECUTE → MEMORY → WRITEBACK → HALTED. This makes timing predictable and debugging straightforward.&lt;/p&gt;
&lt;h4&gt;Simulation with Icarus Verilog&lt;/h4&gt;
&lt;p&gt;The Verilog implementation includes a complete Makefile for testing:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;verilog/

&lt;span class="c1"&gt;# Run the main simulation (hello world)&lt;/span&gt;
make&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;

&lt;span class="c1"&gt;# Run ALU unit tests&lt;/span&gt;
make&lt;span class="w"&gt; &lt;/span&gt;test-alu

&lt;span class="c1"&gt;# Run register file tests&lt;/span&gt;
make&lt;span class="w"&gt; &lt;/span&gt;test-regfile

&lt;span class="c1"&gt;# Build with Verilator (faster simulation)&lt;/span&gt;
make&lt;span class="w"&gt; &lt;/span&gt;verilate

&lt;span class="c1"&gt;# View waveforms in GTKWave&lt;/span&gt;
make&lt;span class="w"&gt; &lt;/span&gt;wave
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Sample output from &lt;code&gt;make test&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c"&gt;=== Sampo CPU Testbench ===&lt;/span&gt;
&lt;span class="c"&gt;RAM init file: &lt;/span&gt;&lt;span class="nt"&gt;..&lt;/span&gt;&lt;span class="c"&gt;/programs/hello&lt;/span&gt;&lt;span class="nt"&gt;.&lt;/span&gt;&lt;span class="c"&gt;hex&lt;/span&gt;

&lt;span class="c"&gt;CPU started at PC=0x0100&lt;/span&gt;
&lt;span class="c"&gt;UART output:&lt;/span&gt;
&lt;span class="nb"&gt;----------------------------------------&lt;/span&gt;
&lt;span class="c"&gt;Hello&lt;/span&gt;&lt;span class="nt"&gt;,&lt;/span&gt;&lt;span class="c"&gt; Sampo!&lt;/span&gt;
&lt;span class="nb"&gt;----------------------------------------&lt;/span&gt;

&lt;span class="c"&gt;Simulation complete:&lt;/span&gt;
&lt;span class="c"&gt;  Final PC:    0x011E&lt;/span&gt;
&lt;span class="c"&gt;  Cycles:      847&lt;/span&gt;
&lt;span class="c"&gt;  UART chars:  14&lt;/span&gt;
&lt;span class="c"&gt;  Status:      HALTED&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The Verilog version is portable to any FPGA toolchain (Xilinx, Intel, Lattice, Gowin) without requiring Amaranth or Python in the build chain.&lt;/p&gt;
&lt;h3&gt;Implementation Roadmap&lt;/h3&gt;
&lt;p&gt;With both Amaranth and Verilog implementations complete and tested in simulation, the roadmap is now about bringing them up on hardware.&lt;/p&gt;
&lt;h4&gt;Phase 1: Core Bring-up ✓ (Complete)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;✓ Instruction fetch and decode&lt;/li&gt;
&lt;li&gt;✓ ALU operations (all 16 operations)&lt;/li&gt;
&lt;li&gt;✓ Barrel shifter (1/4/8-bit shifts, rotates, RCL/RCR)&lt;/li&gt;
&lt;li&gt;✓ Register file with alternate set (EXX)&lt;/li&gt;
&lt;li&gt;✓ FSM-based CPU core (8 states)&lt;/li&gt;
&lt;li&gt;✓ RAM interface (64KB)&lt;/li&gt;
&lt;li&gt;✓ UART for serial I/O&lt;/li&gt;
&lt;li&gt;✓ SoC integration&lt;/li&gt;
&lt;li&gt;✓ Testbenches passing (ALU, regfile, full system)&lt;/li&gt;
&lt;li&gt;✓ Hello World runs in simulation&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Phase 1.5: FPGA Bring-up (Current)&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;○ ULX3S pin constraints (.lpf file)&lt;/li&gt;
&lt;li&gt;○ Clock setup (PLL from 25MHz)&lt;/li&gt;
&lt;li&gt;○ Map UART to FTDI&lt;/li&gt;
&lt;li&gt;○ LED heartbeat / debug outputs&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Phase 2: Memory System&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;SDRAM controller for 32MB RAM&lt;/li&gt;
&lt;li&gt;Instruction cache (optional but helps timing)&lt;/li&gt;
&lt;li&gt;Basic interrupt handling&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Phase 3: Peripherals&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;SPI controller for SD card boot&lt;/li&gt;
&lt;li&gt;GPIO controller (buttons, LEDs)&lt;/li&gt;
&lt;li&gt;Timer/counter module&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Phase 4: Advanced Features&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Data cache&lt;/li&gt;
&lt;li&gt;MMU for memory protection&lt;/li&gt;
&lt;li&gt;HDMI text console (VGA timing → GPDI)&lt;/li&gt;
&lt;li&gt;ESP32 WiFi integration for wireless debugging&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Recommended Tools &amp;amp; Books&lt;/h3&gt;
&lt;h4&gt;Hardware&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://baud.rs/HBq3zf"&gt;Tang Nano 9K FPGA&lt;/a&gt; - Budget-friendly FPGA board (~$25 on Amazon, ~$15 on AliExpress)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/BYIR58"&gt;USB Logic Analyzer&lt;/a&gt; - Essential for debugging signals (24MHz, 8 channels)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Books&lt;/h4&gt;
&lt;p&gt;If you're new to Verilog or FPGA development, these are excellent starting points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://baud.rs/RGjpAj"&gt;&lt;em&gt;Getting Started with FPGAs&lt;/em&gt;&lt;/a&gt; by Russell Merrick - Beginner-friendly with Verilog and VHDL examples&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/tEyX95"&gt;&lt;em&gt;Programming FPGAs: Getting Started with Verilog&lt;/em&gt;&lt;/a&gt; by Simon Monk - Practical hands-on guide&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/6qfzvC"&gt;&lt;em&gt;Verilog by Example&lt;/em&gt;&lt;/a&gt; by Blaine Readler - Concise reference for working engineers&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://baud.rs/VQxLTd"&gt;Sampo on GitHub&lt;/a&gt; - Full source including assembler, emulator, and RTL&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/JUjA8C"&gt;ULX3S GitHub&lt;/a&gt; - Schematics, examples, documentation&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/JLKZBr"&gt;Project Trellis&lt;/a&gt; - ECP5 bitstream documentation&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/0QCVAC"&gt;Amaranth HDL&lt;/a&gt; - Python-based hardware description&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/xlX31y"&gt;nextpnr&lt;/a&gt; - Place and route tool&lt;/li&gt;
&lt;li&gt;&lt;a href="https://baud.rs/LZdP4F"&gt;Yosys&lt;/a&gt; - Verilog synthesis&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Where to Buy&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ULX3S:&lt;/strong&gt;
- &lt;a href="https://baud.rs/NClAGd"&gt;AliExpress&lt;/a&gt; - ~$100-150 depending on variant
- &lt;a href="https://baud.rs/AQB0Xg"&gt;Mouser&lt;/a&gt; - Official distribution
- &lt;a href="https://baud.rs/0gTuW6"&gt;CrowdSupply&lt;/a&gt; - Original campaign page&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tang Nano 9K (budget alternative):&lt;/strong&gt;
- &lt;a href="https://baud.rs/HBq3zf"&gt;Amazon&lt;/a&gt; - ~$25, faster shipping
- &lt;a href="https://baud.rs/9G7KR0"&gt;AliExpress&lt;/a&gt; - ~$15, slower shipping&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Next up: Getting our first instructions executing on real hardware. Both the Amaranth and Verilog implementations are ready and tested; Hello World runs in simulation and the testbenches pass. Now it's a matter of pin constraints, clock domains, and debugging the inevitable timing issues.&lt;/p&gt;</description><category>amaranth</category><category>cpu design</category><category>ecp5</category><category>fpga</category><category>hardware</category><category>lattice</category><category>open-source</category><category>risc</category><category>sampo</category><category>ulx3s</category><category>verilog</category><guid>https://tinycomputers.io/posts/sampo-fpga-implementation-ulx3s.html</guid><pubDate>Mon, 02 Feb 2026 18:00:00 GMT</pubDate></item></channel></rss>