diff --git a/.gitignore b/.gitignore index 674edf8..913fd12 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ fpga/**/simulation/ # Devbox .devbox/ + +# Python virtualenv created by devbox +.venv/ diff --git a/devbox.json b/devbox.json index 58bab44..15a918f 100644 --- a/devbox.json +++ b/devbox.json @@ -1,6 +1,13 @@ { - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json", - "packages": ["arduino-cli@latest", "path:./nix#quartus"], + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.2/.schema/devbox.schema.json", + "packages": [ + "arduino-cli@latest", + "path:./nix#quartus", + "python312@latest", + "python312Packages.pyserial@latest", + "python312Packages.numpy@latest", + "iverilog@latest" + ], "shell": { "init_hook": [ "echo 'Welcome to devbox!' > /dev/null" @@ -14,6 +21,11 @@ "build": [ "arduino-cli compile --fqbn arduino:samd:mkrvidor4000 arduino/strix" ], + "sim": [ + "mkdir -p /tmp/strix-sim", + "iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_gen.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v", + "vvp -n /tmp/strix-sim/tb_strix_core" + ], "fpga": [ "cd fpga/src && quartus_sh --flow compile strix" ] diff --git a/devbox.lock b/devbox.lock index 1ed5a06..339264c 100644 --- a/devbox.lock +++ b/devbox.lock @@ -52,6 +52,219 @@ "github:NixOS/nixpkgs/nixpkgs-unstable": { "last_modified": "2026-09-29T03:29:29Z", "resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D" + }, + "iverilog@latest": { + "last_modified": "2026-10-02T22:55:44Z", + "resolved": "github:NixOS/nixpkgs/55ba7f49ef2962b42cbd126522b7df5f95037679#iverilog", + "source": "devbox-search", + "version": "13.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/p2jawqjs75q5y5mzmz97mzsy41fnpk3z-iverilog-13.0", + "default": true + } + ], + "store_path": "/nix/store/p2jawqjs75q5y5mzmz97mzsy41fnpk3z-iverilog-13.0" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/z850am8r4302c3l5vscpylb7gnrd01am-iverilog-13.0", + "default": true + } + ], + "store_path": "/nix/store/z850am8r4302c3l5vscpylb7gnrd01am-iverilog-13.0" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d66hg1vhaqd0ij8iznymb79s4h4ach7c-iverilog-13.0", + "default": true + } + ], + "store_path": "/nix/store/d66hg1vhaqd0ij8iznymb79s4h4ach7c-iverilog-13.0" + } + } + }, + "python312@latest": { + "last_modified": "2026-10-02T22:55:44Z", + "plugin_version": "0.0.4", + "resolved": "github:NixOS/nixpkgs/55ba7f49ef2962b42cbd126522b7df5f95037679#python312", + "source": "devbox-search", + "version": "3.12.15", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/wki0ba66wcjwx0yxn36bgr08jgcn9bdj-python3-3.12.15", + "default": true + } + ], + "store_path": "/nix/store/wki0ba66wcjwx0yxn36bgr08jgcn9bdj-python3-3.12.15" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/pan3ykbb4wj84c6lhy2zb27pifyd48af-python3-3.12.15", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/q5h1455f2swvmwpqkiswrl946fgs2h24-python3-3.12.15-debug" + } + ], + "store_path": "/nix/store/pan3ykbb4wj84c6lhy2zb27pifyd48af-python3-3.12.15" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v7wvwm1bpm6kzlqgw33m98arm7sw40ln-python3-3.12.15", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/8965kbzqdvqmjy5zs4lxvy09g1qy9szm-python3-3.12.15-debug" + } + ], + "store_path": "/nix/store/v7wvwm1bpm6kzlqgw33m98arm7sw40ln-python3-3.12.15" + } + } + }, + "python312Packages.numpy@latest": { + "last_modified": "2025-11-23T21:50:36Z", + "resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#python312Packages.numpy", + "source": "devbox-search", + "version": "2.3.4", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/x2ij7c58vq7cw1j5fvxfcm6r7ixanrwl-python3.12-numpy-2.3.4", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/n62ay9ig7glbx774kz1kx958pmlh60l2-python3.12-numpy-2.3.4-dist" + } + ], + "store_path": "/nix/store/x2ij7c58vq7cw1j5fvxfcm6r7ixanrwl-python3.12-numpy-2.3.4" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/awrd1xis641s1mqbc2qqah9aadf3dr5a-python3.12-numpy-2.3.4", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/i0xbq8jvfbdw1i6spmr99ar07xq02yzl-python3.12-numpy-2.3.4-dist" + } + ], + "store_path": "/nix/store/awrd1xis641s1mqbc2qqah9aadf3dr5a-python3.12-numpy-2.3.4" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/9q4xvx19yv07xm65ifzkwpn4cd6g33i8-python3.12-numpy-2.3.4", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/0l5qc7i7pdrpycijb1ys607vx8h7661b-python3.12-numpy-2.3.4-dist" + } + ], + "store_path": "/nix/store/9q4xvx19yv07xm65ifzkwpn4cd6g33i8-python3.12-numpy-2.3.4" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/0cgbqmgzzzhxmqsdaxa2hqz5d50a28cp-python3.12-numpy-2.3.4", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/0kqkffxakw86i90a7zpfjicxmhzzds83-python3.12-numpy-2.3.4-dist" + } + ], + "store_path": "/nix/store/0cgbqmgzzzhxmqsdaxa2hqz5d50a28cp-python3.12-numpy-2.3.4" + } + } + }, + "python312Packages.pyserial@latest": { + "last_modified": "2025-11-23T21:50:36Z", + "resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#python312Packages.pyserial", + "source": "devbox-search", + "version": "3.5", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/69ldajx5m2cas9k3p7nr5d8923sgdi6y-python3.12-pyserial-3.5", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/zzrgf4i785a0zl6p33syzmk9h1h1ngs3-python3.12-pyserial-3.5-dist" + } + ], + "store_path": "/nix/store/69ldajx5m2cas9k3p7nr5d8923sgdi6y-python3.12-pyserial-3.5" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/v2yf4ssrygrh14s2n3hfq8w10h64jr6b-python3.12-pyserial-3.5", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/i0siiab84gysw5bpjyy7qr052w2psss0-python3.12-pyserial-3.5-dist" + } + ], + "store_path": "/nix/store/v2yf4ssrygrh14s2n3hfq8w10h64jr6b-python3.12-pyserial-3.5" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/h12lybacn8cfcxdwwlb5n5z1c1lcqpc4-python3.12-pyserial-3.5", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/ajdpw1zb0lwjd1dmyjdf2nxxg0vpbrhd-python3.12-pyserial-3.5-dist" + } + ], + "store_path": "/nix/store/h12lybacn8cfcxdwwlb5n5z1c1lcqpc4-python3.12-pyserial-3.5" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/j90zi1mxv9dvqmsjqicnaafm22bp3qca-python3.12-pyserial-3.5", + "default": true + }, + { + "name": "dist", + "path": "/nix/store/2l5xicsrirfp0v8bawhsva1856c4zakd-python3.12-pyserial-3.5-dist" + } + ], + "store_path": "/nix/store/j90zi1mxv9dvqmsjqicnaafm22bp3qca-python3.12-pyserial-3.5" + } + } } } } diff --git a/fpga/rtl/entropy_gen.v b/fpga/rtl/entropy_gen.v new file mode 100644 index 0000000..899757a --- /dev/null +++ b/fpga/rtl/entropy_gen.v @@ -0,0 +1,34 @@ +// One generator: two rings of different lengths whose enhanced outputs are XORed, plus +// the intermediate signals exposed for jitter characterization. All four are sampled +// every clock. +module entropy_gen #( + parameter LENGTH_A = 12, + parameter LENGTH_B = 10 +) ( + input wire clk, + input wire enable, + output wire [3:0] samples // {pair XOR, enhanced B, enhanced A, single tap of A} +); + + wire ring_a_tap, ring_a_enhanced, ring_b_tap, ring_b_enhanced; + + ring_osc #(.LENGTH(LENGTH_A)) ring_a ( + .enable(enable), .first_tap(ring_a_tap), .enhanced(ring_a_enhanced) + ); + ring_osc #(.LENGTH(LENGTH_B)) ring_b ( + .enable(enable), .first_tap(ring_b_tap), .enhanced(ring_b_enhanced) + ); + + (* keep *) wire pair_xor = ring_a_enhanced ^ ring_b_enhanced; + + // sample_raw catches the asynchronous ring signals and may go metastable; + // sample_sync resolves it. The SDC file cuts timing into sample_raw. + reg [3:0] sample_raw, sample_sync; + always @(posedge clk) begin + sample_raw <= {pair_xor, ring_b_enhanced, ring_a_enhanced, ring_a_tap}; + sample_sync <= sample_raw; + end + + assign samples = sample_sync; + +endmodule diff --git a/fpga/rtl/raw_capture.v b/fpga/rtl/raw_capture.v new file mode 100644 index 0000000..6d3d5f9 --- /dev/null +++ b/fpga/rtl/raw_capture.v @@ -0,0 +1,67 @@ +// Captures one burst of sampled bits into block RAM for offline analysis. Keeps every +// decimation-th sample of the selected source and packs them MSB first into bytes. +module raw_capture #( + parameter ADDR_WIDTH = 15 // 2^15 bytes = 256 Kbit per burst +) ( + input wire clk, + input wire reset, + input wire [3:0] samples, + input wire [1:0] source, + input wire [15:0] decimation, // 0 and 1 both mean every sample + input wire start, + output reg busy, + output reg done, + input wire [ADDR_WIDTH-1:0] read_addr, + output reg [7:0] read_data +); + + reg [7:0] buffer [0:(1 << ADDR_WIDTH) - 1]; + + reg [15:0] sample_count; + reg [2:0] bit_count; + reg [6:0] partial_byte; + reg [ADDR_WIDTH-1:0] next_addr; + reg write_enable; + reg [ADDR_WIDTH-1:0] write_addr; + reg [7:0] write_data; + + wire keep_sample = (sample_count == 16'd0); + wire count_wraps = (decimation <= 16'd1) || (sample_count == decimation - 16'd1); + wire new_bit = samples[source]; + + always @(posedge clk) begin + write_enable <= 1'b0; + if (reset) begin + busy <= 1'b0; + done <= 1'b0; + end else if (start) begin + busy <= 1'b1; + done <= 1'b0; + sample_count <= 16'd0; + bit_count <= 3'd0; + next_addr <= {ADDR_WIDTH{1'b0}}; + end else if (busy) begin + sample_count <= count_wraps ? 16'd0 : sample_count + 16'd1; + if (keep_sample) begin + partial_byte <= {partial_byte[5:0], new_bit}; + bit_count <= bit_count + 3'd1; + if (bit_count == 3'd7) begin + write_enable <= 1'b1; + write_addr <= next_addr; + write_data <= {partial_byte, new_bit}; + next_addr <= next_addr + 1'b1; + if (&next_addr) begin + busy <= 1'b0; + done <= 1'b1; + end + end + end + end + end + + always @(posedge clk) begin + if (write_enable) buffer[write_addr] <= write_data; + read_data <= buffer[read_addr]; + end + +endmodule diff --git a/fpga/rtl/ring_osc.v b/fpga/rtl/ring_osc.v new file mode 100644 index 0000000..d32f907 --- /dev/null +++ b/fpga/rtl/ring_osc.v @@ -0,0 +1,43 @@ +// Free-running ring oscillator built from LCELL buffers so synthesis keeps every stage. +// Three evenly spaced taps are XORed into an "enhanced" output at three times the ring +// frequency, which triples the chance that a sample lands on a jittery edge. +module ring_osc #( + parameter LENGTH = 12 // stages in the ring; exactly one of them inverts +) ( + input wire enable, // low stops the ring (all stages settle high) + output wire first_tap, + output wire enhanced +); + +`ifdef SIM + // Behavioural stand-in: independent taps toggling at random intervals, enough to + // exercise the synchronous logic downstream. It has no physical meaning. + reg [2:0] taps = 3'b000; + always begin + #(1 + {$random} % 7); + if (enable) taps[{$random} % 3] = ~taps[{$random} % 3]; + else taps = 3'b111; + end + assign first_tap = taps[0]; + assign enhanced = ^taps; +`else + // No keep attribute here: each lcell is already one logic cell, and keep on these + // nets makes Quartus insert a second cell per stage. + wire [LENGTH-1:0] stage; + + lcell inverting_stage (.in(~(stage[LENGTH-1] & enable)), .out(stage[0])); + + genvar i; + generate + for (i = 1; i < LENGTH; i = i + 1) begin : delay_stage + lcell buffer (.in(stage[i-1]), .out(stage[i])); + end + endgenerate + + (* keep *) wire tap_xor = stage[0] ^ stage[LENGTH/3] ^ stage[(2*LENGTH)/3]; + + assign first_tap = stage[0]; + assign enhanced = tap_xor; +`endif + +endmodule diff --git a/fpga/rtl/spi_slave.v b/fpga/rtl/spi_slave.v new file mode 100644 index 0000000..8acce98 --- /dev/null +++ b/fpga/rtl/spi_slave.v @@ -0,0 +1,69 @@ +// SPI mode 0 slave, oversampled in the system clock domain (SCK must stay below about a +// tenth of clk). MISO changes a few clk cycles after each SCK falling edge, which leaves +// enough margin for the master's next rising edge at 8 MHz SCK with a 120 MHz clk. +module spi_slave ( + input wire clk, + input wire reset, + input wire sck, + input wire mosi, + input wire ss_n, + output wire miso, + output wire selected, // ss_n asserted (synchronized) + output reg rx_valid, // pulse: rx_byte holds a complete byte + output reg [7:0] rx_byte, + output reg rx_is_first, // rx_byte is the first byte of the transaction + input wire [7:0] tx_byte, // sampled when the next byte starts shifting out + output reg tx_loaded // pulse: tx_byte was taken +); + + reg [2:0] sck_history, ss_n_history; + reg [1:0] mosi_history; + + always @(posedge clk) begin + sck_history <= {sck_history[1:0], sck}; + ss_n_history <= {ss_n_history[1:0], ss_n}; + mosi_history <= {mosi_history[0], mosi}; + end + + wire sck_rising = (sck_history[2:1] == 2'b01); + wire sck_falling = (sck_history[2:1] == 2'b10); + wire mosi_bit = mosi_history[1]; + assign selected = ~ss_n_history[1]; + + reg [2:0] bit_count; + reg [6:0] rx_shift; + reg [7:0] tx_shift; + reg expecting_first; + + assign miso = tx_shift[7]; + + always @(posedge clk) begin + rx_valid <= 1'b0; + tx_loaded <= 1'b0; + if (reset || !selected) begin + bit_count <= 3'd0; + expecting_first <= 1'b1; + tx_shift <= 8'h00; // MISO is don't-care during the command byte + end else begin + if (sck_rising) begin + rx_shift <= {rx_shift[5:0], mosi_bit}; + bit_count <= bit_count + 3'd1; + if (bit_count == 3'd7) begin + rx_valid <= 1'b1; + rx_byte <= {rx_shift, mosi_bit}; + rx_is_first <= expecting_first; + expecting_first <= 1'b0; + end + end + if (sck_falling) begin + if (bit_count == 3'd0) begin + tx_shift <= tx_byte; + tx_loaded <= 1'b1; + end else begin + tx_shift <= {tx_shift[6:0], 1'b0}; + end + end + end + end + +endmodule diff --git a/fpga/rtl/strix.v b/fpga/rtl/strix.v new file mode 100644 index 0000000..9b0b0b1 --- /dev/null +++ b/fpga/rtl/strix.v @@ -0,0 +1,85 @@ +// STRIX top level for the Arduino MKR Vidor 4000. Port names match the Vidor constraints +// in strix.qsf; unused board pins are left out and stay tri-stated. +module strix ( + input wire iCLK, // 48 MHz from the SAMD21 (GCLK0) + + inout wire [14:0] bMKR_D, // header D0-D14, shared with the SAMD21 + inout wire [6:0] bMKR_A, // header A0-A6, shared with the SAMD21 + + // NINA-W102 WiFi module + inout wire bWM_PIO1, // NINA MISO + inout wire bWM_PIO7, // NINA ACK (GPIO33) + inout wire bWM_PIO27, // NINA GPIO0 + inout wire bWM_PIO28, // NINA SS (GPIO5) + inout wire bWM_PIO29, // NINA SCK + inout wire bWM_PIO36, // NINA MOSI + input wire iWM_TX, + inout wire oWM_RX, + inout wire oWM_RESET, + + output wire oSAM_INT // interrupt line to the SAMD21 +); + + // Clock and reset + + wire clk, pll_locked; + + sys_pll pll (.inclk0(iCLK), .c0(clk), .locked(pll_locked)); + + reg [2:0] reset_pipe = 3'b111; + always @(posedge clk or negedge pll_locked) + if (!pll_locked) reset_pipe <= 3'b111; + else reset_pipe <= {reset_pipe[1:0], 1'b0}; + wire reset = reset_pipe[2]; + + // NINA pass-through: the SAMD21 talks to the WiFi module as if wired directly. + + wire sam_nina_mosi = bMKR_D[8]; // SERCOM1 + wire sam_nina_sck = bMKR_D[9]; + wire sam_nina_ss = bMKR_D[5]; + wire sam_nina_gpio0 = bMKR_D[6]; + wire sam_nina_resetn = bMKR_D[7]; + wire sam_uart_tx = bMKR_D[14]; + + assign bWM_PIO36 = sam_nina_mosi; + assign bWM_PIO29 = sam_nina_sck; + assign bWM_PIO28 = sam_nina_ss; + assign bWM_PIO27 = sam_nina_gpio0; + assign oWM_RESET = sam_nina_resetn; + assign oWM_RX = sam_uart_tx; + assign bWM_PIO1 = 1'bz; // inputs from NINA + assign bWM_PIO7 = 1'bz; + + wire nina_miso = bWM_PIO1; // to D10 + wire nina_ack = bWM_PIO7; // to D4 + wire nina_uart_tx = iWM_TX; // to D13 + + // STRIX SPI link on SAMD21 SERCOM0 alternate pins: A3 MOSI, A4 SCK, A5 SS, A6 MISO. + + wire strix_mosi = bMKR_A[3]; + wire strix_sck = bMKR_A[4]; + wire strix_ss_n = bMKR_A[5]; + wire strix_miso, capture_done; + + strix_core core ( + .clk(clk), .reset(reset), + .spi_sck(strix_sck), .spi_mosi(strix_mosi), .spi_ss_n(strix_ss_n), + .spi_miso(strix_miso), .capture_done(capture_done) + ); + + assign oSAM_INT = capture_done; + + // Header pins driven by the FPGA; all others are inputs. + + genvar d; + generate + for (d = 0; d < 15; d = d + 1) begin : header_d + assign bMKR_D[d] = (d == 4) ? nina_ack : + (d == 10) ? nina_miso : + (d == 13) ? nina_uart_tx : 1'bz; + end + endgenerate + + assign bMKR_A = {strix_miso, 6'bzzzzzz}; + +endmodule diff --git a/fpga/rtl/strix_core.v b/fpga/rtl/strix_core.v new file mode 100644 index 0000000..3371cf4 --- /dev/null +++ b/fpga/rtl/strix_core.v @@ -0,0 +1,130 @@ +// Board-independent STRIX logic: entropy generator, raw capture and the SPI register +// interface to the SAMD21. +// +// SPI protocol (mode 0, one transaction per ss_n low period): the first byte is a command, +// bit 7 = read (1) or write (0), bits 6:0 = register address. A write stores the following +// byte(s) in that register. A read returns the register in every following byte; DATA +// advances through the capture buffer with each byte, continuing across transactions. +module strix_core #( + parameter CAPTURE_ADDR_WIDTH = 15 +) ( + input wire clk, + input wire reset, + input wire spi_sck, + input wire spi_mosi, + input wire spi_ss_n, + output wire spi_miso, + output wire capture_done +); + + localparam [7:0] VERSION = 8'h01; + + localparam [6:0] REG_ID0 = 7'h00, // "STRX" + REG_ID1 = 7'h01, + REG_ID2 = 7'h02, + REG_ID3 = 7'h03, + REG_VERSION = 7'h04, + REG_SOURCE = 7'h08, // 0 tap A, 1 enhanced A, 2 enhanced B, 3 pair + REG_DECIM_HIGH = 7'h09, + REG_DECIM_LOW = 7'h0A, + REG_CONTROL = 7'h0B, // write: bit 0 start capture, bit 1 rewind DATA + REG_STATUS = 7'h0C, // bit 0 busy, bit 1 done + REG_DATA = 7'h10; + + // Entropy and capture + + wire [3:0] samples; + + entropy_gen generator (.clk(clk), .enable(1'b1), .samples(samples)); + + reg [1:0] source; + reg [15:0] decimation; + reg capture_start; + wire capture_busy; + reg [CAPTURE_ADDR_WIDTH-1:0] prefetch_addr; + wire [7:0] capture_byte; + + raw_capture #(.ADDR_WIDTH(CAPTURE_ADDR_WIDTH)) capture ( + .clk(clk), .reset(reset), .samples(samples), .source(source), + .decimation(decimation), .start(capture_start), + .busy(capture_busy), .done(capture_done), + .read_addr(prefetch_addr), .read_data(capture_byte) + ); + + // SPI + + wire selected, rx_valid, rx_is_first, tx_loaded; + wire [7:0] rx_byte; + reg [7:0] tx_byte; + + spi_slave spi ( + .clk(clk), .reset(reset), + .sck(spi_sck), .mosi(spi_mosi), .ss_n(spi_ss_n), .miso(spi_miso), + .selected(selected), .rx_valid(rx_valid), .rx_byte(rx_byte), + .rx_is_first(rx_is_first), .tx_byte(tx_byte), .tx_loaded(tx_loaded) + ); + + reg is_read; + reg [6:0] reg_addr; + + // DATA bytes are prefetched one ahead: prefetch_addr moves when a byte is loaded for + // shifting, bytes_read only when it has been fully clocked out. Between transactions + // prefetch_addr rewinds to bytes_read so the trailing prefetch is not lost. + reg [CAPTURE_ADDR_WIDTH-1:0] bytes_read; + + wire reading_data = is_read && reg_addr == REG_DATA; + + always @(*) begin + case (reg_addr) + REG_ID0: tx_byte = "S"; + REG_ID1: tx_byte = "T"; + REG_ID2: tx_byte = "R"; + REG_ID3: tx_byte = "X"; + REG_VERSION: tx_byte = VERSION; + REG_SOURCE: tx_byte = {6'd0, source}; + REG_DECIM_HIGH: tx_byte = decimation[15:8]; + REG_DECIM_LOW: tx_byte = decimation[7:0]; + REG_STATUS: tx_byte = {6'd0, capture_done, capture_busy}; + REG_DATA: tx_byte = capture_byte; + default: tx_byte = 8'h00; + endcase + end + + always @(posedge clk) begin + capture_start <= 1'b0; + if (reset) begin + source <= 2'd3; + decimation <= 16'd1; + is_read <= 1'b0; + reg_addr <= 7'd0; + bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}}; + prefetch_addr <= {CAPTURE_ADDR_WIDTH{1'b0}}; + end else begin + if (!selected) + prefetch_addr <= bytes_read; + else if (tx_loaded && reading_data) + prefetch_addr <= prefetch_addr + 1'b1; + + if (rx_valid) begin + if (rx_is_first) begin + is_read <= rx_byte[7]; + reg_addr <= rx_byte[6:0]; + end else if (reading_data) begin + bytes_read <= bytes_read + 1'b1; + end else if (!is_read) begin + case (reg_addr) + REG_SOURCE: source <= rx_byte[1:0]; + REG_DECIM_HIGH: decimation[15:8] <= rx_byte; + REG_DECIM_LOW: decimation[7:0] <= rx_byte; + REG_CONTROL: begin + if (rx_byte[0]) capture_start <= 1'b1; + if (rx_byte[0] || rx_byte[1]) bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}}; + end + default: ; + endcase + end + end + end + end + +endmodule diff --git a/fpga/rtl/sys_pll.v b/fpga/rtl/sys_pll.v new file mode 100644 index 0000000..75fc97f --- /dev/null +++ b/fpga/rtl/sys_pll.v @@ -0,0 +1,100 @@ +// 48 MHz in, 120 MHz out (x5 / 2). Same altpll settings style as Arduino's Vidor +// SYSTEM_PLL, reduced to one output. +module sys_pll ( + input wire inclk0, + output wire c0, + output wire locked +); + + wire [4:0] clk; + + altpll altpll_component ( + .inclk ({1'b0, inclk0}), + .clk (clk), + .locked (locked), + .areset (1'b0), + .activeclock (), + .clkbad (), + .clkena ({6{1'b1}}), + .clkloss (), + .clkswitch (1'b0), + .configupdate (1'b0), + .enable0 (), + .enable1 (), + .extclk (), + .extclkena ({4{1'b1}}), + .fbin (1'b1), + .fbmimicbidir (), + .fbout (), + .fref (), + .icdrclk (), + .pfdena (1'b1), + .phasecounterselect ({4{1'b1}}), + .phasedone (), + .phasestep (1'b1), + .phaseupdown (1'b1), + .pllena (1'b1), + .scanaclr (1'b0), + .scanclk (1'b0), + .scanclkena (1'b1), + .scandata (1'b0), + .scandataout (), + .scandone (), + .scanread (1'b0), + .scanwrite (1'b0), + .sclkout0 (), + .sclkout1 (), + .vcooverrange (), + .vcounderrange () + ); + + defparam + altpll_component.bandwidth_type = "AUTO", + altpll_component.clk0_divide_by = 2, + altpll_component.clk0_duty_cycle = 50, + altpll_component.clk0_multiply_by = 5, + altpll_component.clk0_phase_shift = "0", + altpll_component.compensate_clock = "CLK0", + altpll_component.inclk0_input_frequency = 20833, + altpll_component.intended_device_family = "Cyclone 10 LP", + altpll_component.lpm_hint = "CBX_MODULE_PREFIX=sys_pll", + altpll_component.lpm_type = "altpll", + altpll_component.operation_mode = "NORMAL", + altpll_component.pll_type = "AUTO", + altpll_component.port_activeclock = "PORT_UNUSED", + altpll_component.port_areset = "PORT_UNUSED", + altpll_component.port_clkbad0 = "PORT_UNUSED", + altpll_component.port_clkbad1 = "PORT_UNUSED", + altpll_component.port_clkloss = "PORT_UNUSED", + altpll_component.port_clkswitch = "PORT_UNUSED", + altpll_component.port_configupdate = "PORT_UNUSED", + altpll_component.port_fbin = "PORT_UNUSED", + altpll_component.port_inclk0 = "PORT_USED", + altpll_component.port_inclk1 = "PORT_UNUSED", + altpll_component.port_locked = "PORT_USED", + altpll_component.port_pfdena = "PORT_UNUSED", + altpll_component.port_phasecounterselect = "PORT_UNUSED", + altpll_component.port_phasedone = "PORT_UNUSED", + altpll_component.port_phasestep = "PORT_UNUSED", + altpll_component.port_phaseupdown = "PORT_UNUSED", + altpll_component.port_pllena = "PORT_UNUSED", + altpll_component.port_scanaclr = "PORT_UNUSED", + altpll_component.port_scanclk = "PORT_UNUSED", + altpll_component.port_scanclkena = "PORT_UNUSED", + altpll_component.port_scandata = "PORT_UNUSED", + altpll_component.port_scandataout = "PORT_UNUSED", + altpll_component.port_scandone = "PORT_UNUSED", + altpll_component.port_scanread = "PORT_UNUSED", + altpll_component.port_scanwrite = "PORT_UNUSED", + altpll_component.port_clk0 = "PORT_USED", + altpll_component.port_clk1 = "PORT_UNUSED", + altpll_component.port_clk2 = "PORT_UNUSED", + altpll_component.port_clk3 = "PORT_UNUSED", + altpll_component.port_clk4 = "PORT_UNUSED", + altpll_component.port_clk5 = "PORT_UNUSED", + altpll_component.self_reset_on_loss_lock = "OFF", + altpll_component.width_clock = 5; + + assign c0 = clk[0]; + +endmodule diff --git a/fpga/sim/tb_strix_core.v b/fpga/sim/tb_strix_core.v new file mode 100644 index 0000000..fa76755 --- /dev/null +++ b/fpga/sim/tb_strix_core.v @@ -0,0 +1,139 @@ +// Testbench for strix_core: SPI register access and a small raw capture read back in +// two transactions. Rings are replaced by the SIM model in ring_osc.v. +`timescale 1ns / 1ps + +module tb_strix_core; + + localparam CAPTURE_ADDR_WIDTH = 6; // 64-byte buffer keeps the simulation short + localparam CAPTURE_BYTES = 1 << CAPTURE_ADDR_WIDTH; + localparam SCK_HALF = 62.5; // 8 MHz SCK + + reg clk = 1'b0; + always #4.1667 clk = ~clk; // 120 MHz + + reg reset = 1'b1; + reg sck = 1'b0, mosi = 1'b0, ss_n = 1'b1; + wire miso, done; + + strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH)) dut ( + .clk(clk), .reset(reset), + .spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso), + .capture_done(done) + ); + + integer errors = 0; + + task spi_byte(input [7:0] out, output [7:0] in); + integer i; + begin + for (i = 7; i >= 0; i = i - 1) begin + mosi = out[i]; + #SCK_HALF sck = 1'b1; + in[i] = miso; + #SCK_HALF sck = 1'b0; + end + end + endtask + + task write_reg(input [6:0] addr, input [7:0] value); + reg [7:0] dummy; + begin + ss_n = 1'b0; #SCK_HALF; + spi_byte({1'b0, addr}, dummy); + spi_byte(value, dummy); + #SCK_HALF ss_n = 1'b1; #(4 * SCK_HALF); + end + endtask + + task read_reg(input [6:0] addr, output [7:0] value); + reg [7:0] dummy; + begin + ss_n = 1'b0; #SCK_HALF; + spi_byte({1'b1, addr}, dummy); + spi_byte(8'h00, value); + #SCK_HALF ss_n = 1'b1; #(4 * SCK_HALF); + end + endtask + + task read_data(input integer first, input integer count); + reg [7:0] dummy, value; + integer i; + begin + ss_n = 1'b0; #SCK_HALF; + spi_byte({1'b1, 7'h10}, dummy); + for (i = 0; i < count; i = i + 1) begin + spi_byte(8'h00, value); + if (value !== dut.capture.buffer[first + i]) begin + $display("FAIL data[%0d]: got %02x, expected %02x", first + i, value, dut.capture.buffer[first + i]); + errors = errors + 1; + end + end + #SCK_HALF ss_n = 1'b1; #(4 * SCK_HALF); + end + endtask + + task expect(input [7:0] got, input [7:0] want, input [8*16-1:0] what); + if (got !== want) begin + $display("FAIL %0s: got %02x, expected %02x", what, got, want); + errors = errors + 1; + end + endtask + + reg [7:0] v; + integer n; + + initial begin + #100 reset = 1'b0; + #200; + + read_reg(7'h00, v); expect(v, "S", "ID0"); + read_reg(7'h03, v); expect(v, "X", "ID3"); + read_reg(7'h04, v); expect(v, 8'h01, "VERSION"); + + write_reg(7'h08, 8'h01); + write_reg(7'h09, 8'h00); + write_reg(7'h0A, 8'h03); + read_reg(7'h08, v); expect(v, 8'h01, "SRC"); + read_reg(7'h0A, v); expect(v, 8'h03, "DECIM_L"); + + write_reg(7'h0B, 8'h01); + n = 0; + read_reg(7'h0C, v); + while (v[1] !== 1'b1 && n < 1000) begin + read_reg(7'h0C, v); + n = n + 1; + end + expect(v, 8'h02, "STATUS"); + if (done !== 1'b1) begin + $display("FAIL capture_done not set"); + errors = errors + 1; + end + + // Captured bytes must be defined and vary, otherwise the comparisons below prove nothing. + n = 0; + for (v = 0; v < CAPTURE_BYTES - 1; v = v + 1) begin + if (^dut.capture.buffer[v] === 1'bx) begin + $display("FAIL mem[%0d] undefined", v); + errors = errors + 1; + end + if (dut.capture.buffer[v] !== dut.capture.buffer[v + 1]) n = n + 1; + end + if (n == 0) begin + $display("FAIL capture buffer is constant"); + errors = errors + 1; + end + + // Read the buffer in two transactions; the second must continue where the first stopped. + read_data(0, CAPTURE_BYTES / 2); + read_data(CAPTURE_BYTES / 2, CAPTURE_BYTES / 2); + + // Rewind and read the first bytes again. + write_reg(7'h0B, 8'h02); + read_data(0, 4); + + if (errors == 0) $display("PASS"); + else $display("%0d error(s)", errors); + $finish; + end + +endmodule diff --git a/fpga/src/strix.qsf b/fpga/src/strix.qsf index e516554..a094e4d 100644 --- a/fpga/src/strix.qsf +++ b/fpga/src/strix.qsf @@ -55,6 +55,31 @@ set_global_assignment -name EDA_GENERATE_FUNCTIONAL_NETLIST OFF -section_id eda_ set_global_assignment -name EDA_GENERATE_FUNCTIONAL_NETLIST OFF -section_id eda_board_design_symbol set_global_assignment -name EDA_GENERATE_FUNCTIONAL_NETLIST OFF -section_id eda_board_design_signal_integrity set_global_assignment -name EDA_GENERATE_FUNCTIONAL_NETLIST OFF -section_id eda_board_design_boundary_scan + +# Sources and constraints +set_global_assignment -name VERILOG_FILE ../rtl/strix.v +set_global_assignment -name VERILOG_FILE ../rtl/sys_pll.v +set_global_assignment -name VERILOG_FILE ../rtl/strix_core.v +set_global_assignment -name VERILOG_FILE ../rtl/entropy_gen.v +set_global_assignment -name VERILOG_FILE ../rtl/ring_osc.v +set_global_assignment -name VERILOG_FILE ../rtl/raw_capture.v +set_global_assignment -name VERILOG_FILE ../rtl/spi_slave.v +set_global_assignment -name SDC_FILE strix.sdc + +# Configuration settings matching Arduino's Vidor projects; the .ttf is the input for the +# flash image embedded in the SAMD21 sketch. +set_global_assignment -name STRATIXV_CONFIGURATION_SCHEME "PASSIVE SERIAL" +set_global_assignment -name USE_CONFIGURATION_DEVICE ON +set_global_assignment -name CYCLONEIII_CONFIGURATION_DEVICE EPCS16 +set_global_assignment -name CRC_ERROR_OPEN_DRAIN OFF +set_global_assignment -name STRATIX_DEVICE_IO_STANDARD "3.3-V LVTTL" +set_global_assignment -name CYCLONEII_RESERVE_NCEO_AFTER_CONFIGURATION "USE AS REGULAR IO" +set_global_assignment -name ENABLE_CONFIGURATION_PINS OFF +set_global_assignment -name ENABLE_BOOT_SEL_PIN OFF +set_global_assignment -name CONFIGURATION_VCCIO_LEVEL AUTO +set_global_assignment -name ON_CHIP_BITSTREAM_DECOMPRESSION ON +set_global_assignment -name GENERATE_TTF_FILE ON + set_location_assignment PIN_E2 -to iCLK set_location_assignment PIN_E1 -to iRESETn set_location_assignment PIN_E10 -to oSDRAM_ADDR[11] diff --git a/fpga/src/strix.sdc b/fpga/src/strix.sdc new file mode 100644 index 0000000..e7ba551 --- /dev/null +++ b/fpga/src/strix.sdc @@ -0,0 +1,14 @@ +create_clock -name iCLK -period 20.833 [get_ports {iCLK}] +derive_pll_clocks +derive_clock_uncertainty + +# Ring oscillator outputs are asynchronous by design; the first sampling stage absorbs them. +set_false_path -to [get_registers {*entropy_gen:generator|sample_raw[*]}] + +# SPI inputs are synchronized in spi_slave; MISO timing relies on the half-SCK margin. +set_false_path -from [get_ports {bMKR_A[*]}] +set_false_path -to [get_ports {bMKR_A[*]}] +set_false_path -to [get_ports {oSAM_INT}] + +# NINA pass-through is purely combinational between pins. +set_false_path -from [get_ports {bMKR_D[*] bWM_PIO* iWM_TX}] -to [get_ports {bMKR_D[*] bWM_PIO* oWM_RX oWM_RESET}]