feat: add 16-ring bank, sampled XOR, pair capture and frequency counter (FPGA v2)

This commit is contained in:
randogoth 2026-10-25 12:56:54 +02:00
parent d51a6c0ca8
commit 504b00752a
12 changed files with 654 additions and 347 deletions

View file

@ -1,10 +1,12 @@
// STRIX raw capture tool: boots the STRIX FPGA image and serves raw ring-oscillator
// captures over USB serial for jitter characterization (see tools/capture.py).
// STRIX raw capture tool: boots the STRIX FPGA image and gives the host (tools/jitter.py)
// register access and raw captures over USB serial.
//
// Commands (one per line):
// Commands (one per line, numbers in decimal):
// id print the FPGA identification
// cap <source> <decimation> capture a burst and send it as "DATA <n>\n" + n raw bytes
// source: 0 tap A, 1 enhanced A, 2 enhanced B, 3 pair XOR
// r <register> read a register, prints its value
// w <register> <value> write a register, prints "OK"
// cap start a capture with the current settings and send it as
// "DATA <n>\n" followed by n raw bytes
#include <SPI.h>
#include <VidorBoot.h>
@ -18,17 +20,14 @@ const SPISettings STRIX_SPI_SETTINGS(8000000, MSBFIRST, SPI_MODE0);
enum : uint8_t {
REG_ID0 = 0x00,
REG_VERSION = 0x04,
REG_SOURCE = 0x08,
REG_DECIM_HIGH = 0x09,
REG_DECIM_LOW = 0x0A,
REG_CONTROL = 0x0B,
REG_STATUS = 0x0C,
REG_DATA = 0x10,
};
const uint8_t READ = 0x80;
const uint8_t CONTROL_START = 0x01;
const uint8_t STATUS_DONE = 0x02;
const uint8_t CONTROL_START_CAPTURE = 0x01;
const uint8_t STATUS_CAPTURE_DONE = 0x02;
const size_t CAPTURE_BYTES = 32768;
const size_t CHUNK_BYTES = 512;
@ -66,15 +65,12 @@ void printId() {
Serial.println(readRegister(REG_VERSION));
}
void capture(uint8_t source, uint16_t decimation) {
writeRegister(REG_SOURCE, source);
writeRegister(REG_DECIM_HIGH, decimation >> 8);
writeRegister(REG_DECIM_LOW, decimation & 0xFF);
writeRegister(REG_CONTROL, CONTROL_START);
void capture() {
writeRegister(REG_CONTROL, CONTROL_START_CAPTURE);
unsigned long start = millis();
while (!(readRegister(REG_STATUS) & STATUS_DONE)) {
// 262,144 samples at 120 MHz take up to 143 s at the maximum decimation of 65535.
unsigned long start = millis();
while (!(readRegister(REG_STATUS) & STATUS_CAPTURE_DONE)) {
if (millis() - start > 180000) {
Serial.println("ERR timeout");
return;
@ -93,25 +89,35 @@ void capture(uint8_t source, uint16_t decimation) {
}
}
// Parses "<a>" or "<a> <b>" after the command word; returns the number of values found.
int parseArgs(const String &args, long &a, long &b) {
int space = args.indexOf(' ');
if (args.length() == 0) return 0;
a = args.substring(0, space < 0 ? args.length() : space).toInt();
if (space < 0) return 1;
b = args.substring(space + 1).toInt();
return 2;
}
void handleCommand(String line) {
line.trim();
if (line == "id") {
int space = line.indexOf(' ');
String command = space < 0 ? line : line.substring(0, space);
String args = space < 0 ? "" : line.substring(space + 1);
long reg = 0, value = 0;
int count = parseArgs(args, reg, value);
if (command == "id") {
printId();
} else if (line.startsWith("cap ")) {
int space = line.indexOf(' ', 4);
if (space < 0) {
Serial.println("ERR usage: cap <source> <decimation>");
return;
}
long source = line.substring(4, space).toInt();
long decimation = line.substring(space + 1).toInt();
if (source < 0 || source > 3 || decimation < 1 || decimation > 65535) {
Serial.println("ERR source 0-3, decimation 1-65535");
return;
}
capture(source, decimation);
} else if (command == "r" && count == 1 && reg >= 0 && reg < 0x80) {
Serial.println(readRegister(reg));
} else if (command == "w" && count == 2 && reg >= 0 && reg < 0x80 && value >= 0 && value < 256) {
writeRegister(reg, value);
Serial.println("OK");
} else if (command == "cap") {
capture();
} else if (line.length() > 0) {
Serial.println("ERR unknown command");
Serial.println("ERR unknown or malformed command");
}
}
@ -128,7 +134,7 @@ void setup() {
pinMode(STRIX_SS, OUTPUT);
digitalWrite(STRIX_SS, HIGH);
strixSpi.begin();
// SPIClass::begin() applies the pins' default mux; A3-A6 need SERCOM0's alternate mux.
// SPIClassSAMD::begin() applies the pins' default mux; A3-A6 need SERCOM0's alternate mux.
pinPeripheral(A3, PIO_SERCOM_ALT);
pinPeripheral(A4, PIO_SERCOM_ALT);
pinPeripheral(A6, PIO_SERCOM_ALT);

View file

@ -23,7 +23,7 @@
],
"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",
"iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/ring_bank.v fpga/rtl/freq_counter.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": [
@ -38,8 +38,8 @@
"upload-capture": [
"arduino-cli upload --fqbn arduino:samd:mkrvidor4000 -p ${PORT:-/dev/ttyACM0} arduino/strix_capture"
],
"capture": [
"python3 tools/capture.py --port ${PORT:-/dev/ttyACM0} --out captures"
"jitter": [
"python3 tools/jitter.py --port ${PORT:-/dev/ttyACM0} --out captures \"$@\""
]
}
}

View file

@ -1,34 +0,0 @@
// 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

47
fpga/rtl/freq_counter.v Normal file
View file

@ -0,0 +1,47 @@
// Measures a ring frequency: an 8-bit prescaler clocked by the ring itself divides it by
// 256, and its top bit's rising edges are counted in the clk domain over a gate of
// 2^GATE_BITS clk cycles. frequency = count * 256 * f_clk / 2^GATE_BITS.
module freq_counter #(
parameter GATE_BITS = 24 // 2^24 cycles at 120 MHz = 140 ms
) (
input wire clk,
input wire reset,
input wire signal, // asynchronous signal to measure (used as a clock)
input wire start,
output reg busy,
output reg valid,
output reg [31:0] count
);
reg [7:0] prescaler = 8'd0;
always @(posedge signal) prescaler <= prescaler + 8'd1;
// The SDC file cuts timing from prescaler into this synchronizer.
reg [2:0] divided_history;
always @(posedge clk) divided_history <= {divided_history[1:0], prescaler[7]};
wire divided_rising = (divided_history[2:1] == 2'b01);
reg [GATE_BITS-1:0] gate;
reg [31:0] edges;
always @(posedge clk) begin
if (reset) begin
busy <= 1'b0;
valid <= 1'b0;
end else if (start) begin
busy <= 1'b1;
valid <= 1'b0;
gate <= {GATE_BITS{1'b0}};
edges <= 32'd0;
end else if (busy) begin
gate <= gate + 1'b1;
if (divided_rising) edges <= edges + 32'd1;
if (&gate) begin
busy <= 1'b0;
valid <= 1'b1;
count <= edges + divided_rising;
end
end
end
endmodule

View file

@ -1,12 +1,15 @@
// 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.
// decimation-th sample and packs it MSB first into bytes: one bit per sample from
// source A, or in pair mode two bits per sample (A then B) so both sources are recorded
// at the same instants.
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 bit_a,
input wire bit_b,
input wire pair_mode,
input wire [15:0] decimation, // 0 and 1 both mean every sample
input wire start,
output reg busy,
@ -18,8 +21,8 @@ module raw_capture #(
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 [3:0] bit_count;
reg [7:0] partial_byte;
reg [ADDR_WIDTH-1:0] next_addr;
reg write_enable;
reg [ADDR_WIDTH-1:0] write_addr;
@ -27,7 +30,9 @@ module raw_capture #(
wire keep_sample = (sample_count == 16'd0);
wire count_wraps = (decimation <= 16'd1) || (sample_count == decimation - 16'd1);
wire new_bit = samples[source];
wire [7:0] shifted = pair_mode ? {partial_byte[5:0], bit_a, bit_b} : {partial_byte[6:0], bit_a};
wire [3:0] new_count = bit_count + (pair_mode ? 4'd2 : 4'd1);
always @(posedge clk) begin
write_enable <= 1'b0;
@ -38,22 +43,24 @@ module raw_capture #(
busy <= 1'b1;
done <= 1'b0;
sample_count <= 16'd0;
bit_count <= 3'd0;
bit_count <= 4'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
partial_byte <= shifted;
if (new_count == 4'd8) begin
bit_count <= 4'd0;
write_enable <= 1'b1;
write_addr <= next_addr;
write_data <= {partial_byte, new_bit};
write_data <= shifted;
next_addr <= next_addr + 1'b1;
if (&next_addr) begin
busy <= 1'b0;
done <= 1'b1;
end
end else begin
bit_count <= new_count;
end
end
end

36
fpga/rtl/ring_bank.v Normal file
View file

@ -0,0 +1,36 @@
// A bank of ring oscillators. Every ring's single tap and enhanced output is synchronized
// into the clk domain separately, so all combining happens after sampling. Ring lengths
// cycle through 9, 12 and 15 stages so neighbouring rings run at different frequencies.
module ring_bank #(
parameter RINGS = 16
) (
input wire clk,
input wire enable,
output wire [RINGS-1:0] enhanced, // synchronized enhanced outputs
output wire [RINGS-1:0] first_tap, // synchronized single taps
output wire [RINGS-1:0] raw_tap // unsynchronized single taps, for frequency counting
);
wire [RINGS-1:0] ring_enhanced;
genvar i;
generate
for (i = 0; i < RINGS; i = i + 1) begin : ring
ring_osc #(.LENGTH(9 + 3 * (i % 3))) osc (
.enable(enable), .first_tap(raw_tap[i]), .enhanced(ring_enhanced[i])
);
end
endgenerate
// sample_raw catches the asynchronous ring signals and may go metastable;
// sample_sync resolves it. The SDC file cuts timing into sample_raw.
reg [2*RINGS-1:0] sample_raw, sample_sync;
always @(posedge clk) begin
sample_raw <= {raw_tap, ring_enhanced};
sample_sync <= sample_raw;
end
assign enhanced = sample_sync[RINGS-1:0];
assign first_tap = sample_sync[2*RINGS-1:RINGS];
endmodule

View file

@ -1,12 +1,17 @@
// Board-independent STRIX logic: entropy generator, raw capture and the SPI register
// interface to the SAMD21.
// Board-independent STRIX logic: ring bank, source selection, raw capture, frequency
// counter 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.
//
// Capture sources (6-bit codes): 0x00-0x0F enhanced output of ring i, 0x10-0x1F single
// tap of ring i, 0x20-0x2F XOR of the enhanced outputs of rings 0..n-1 for n = 1..16.
// All sources are taken after sampling.
module strix_core #(
parameter CAPTURE_ADDR_WIDTH = 15
parameter CAPTURE_ADDR_WIDTH = 15,
parameter FREQ_GATE_BITS = 24
) (
input wire clk,
input wire reset,
@ -17,27 +22,62 @@ module strix_core #(
output wire capture_done
);
localparam [7:0] VERSION = 8'h01;
localparam [7:0] VERSION = 8'h02;
localparam RINGS = 16;
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_FREQ_GATE = 7'h05, // read-only: gate is 2^value clk cycles
REG_SOURCE_A = 7'h08,
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;
REG_CONTROL = 7'h0B, // write: bit 0 start capture, bit 1 rewind DATA,
// bit 2 start frequency measurement
REG_STATUS = 7'h0C, // bit 0 capture busy, bit 1 capture done,
// bit 2 frequency busy, bit 3 frequency valid
REG_SOURCE_B = 7'h0D,
REG_MODE = 7'h0E, // bit 0 pair capture (A and B per sample)
REG_RING_COUNT = 7'h0F,
REG_DATA = 7'h10,
REG_FREQ_SELECT = 7'h11, // ring to measure
REG_FREQ_COUNT3 = 7'h12, // prescaled edge count, most significant byte first
REG_FREQ_COUNT2 = 7'h13,
REG_FREQ_COUNT1 = 7'h14,
REG_FREQ_COUNT0 = 7'h15;
// Entropy and capture
// Ring bank and source selection
wire [3:0] samples;
wire [RINGS-1:0] enhanced, first_tap, raw_tap;
entropy_gen generator (.clk(clk), .enable(1'b1), .samples(samples));
ring_bank #(.RINGS(RINGS)) bank (
.clk(clk), .enable(1'b1),
.enhanced(enhanced), .first_tap(first_tap), .raw_tap(raw_tap)
);
reg [1:0] source;
reg [RINGS-1:0] xor_prefix_comb, xor_prefix;
integer j;
always @(*) begin
xor_prefix_comb[0] = enhanced[0];
for (j = 1; j < RINGS; j = j + 1)
xor_prefix_comb[j] = xor_prefix_comb[j-1] ^ enhanced[j];
end
reg [5:0] source_a, source_b;
reg bit_a, bit_b;
wire [63:0] all_sources = {16'd0, xor_prefix, first_tap, enhanced};
always @(posedge clk) begin
xor_prefix <= xor_prefix_comb;
bit_a <= all_sources[source_a];
bit_b <= all_sources[source_b];
end
// Capture
reg pair_mode;
reg [15:0] decimation;
reg capture_start;
wire capture_busy;
@ -45,12 +85,24 @@ module strix_core #(
wire [7:0] capture_byte;
raw_capture #(.ADDR_WIDTH(CAPTURE_ADDR_WIDTH)) capture (
.clk(clk), .reset(reset), .samples(samples), .source(source),
.clk(clk), .reset(reset), .bit_a(bit_a), .bit_b(bit_b), .pair_mode(pair_mode),
.decimation(decimation), .start(capture_start),
.busy(capture_busy), .done(capture_done),
.read_addr(prefetch_addr), .read_data(capture_byte)
);
// Frequency counter
reg [3:0] freq_select;
reg freq_start;
wire freq_busy, freq_valid;
wire [31:0] freq_count;
freq_counter #(.GATE_BITS(FREQ_GATE_BITS)) counter (
.clk(clk), .reset(reset), .signal(raw_tap[freq_select]), .start(freq_start),
.busy(freq_busy), .valid(freq_valid), .count(freq_count)
);
// SPI
wire selected, rx_valid, rx_is_first, tx_loaded;
@ -81,20 +133,33 @@ module strix_core #(
REG_ID2: tx_byte = "R";
REG_ID3: tx_byte = "X";
REG_VERSION: tx_byte = VERSION;
REG_SOURCE: tx_byte = {6'd0, source};
REG_FREQ_GATE: tx_byte = FREQ_GATE_BITS;
REG_SOURCE_A: tx_byte = {2'd0, source_a};
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_STATUS: tx_byte = {4'd0, freq_valid, freq_busy, capture_done, capture_busy};
REG_SOURCE_B: tx_byte = {2'd0, source_b};
REG_MODE: tx_byte = {7'd0, pair_mode};
REG_RING_COUNT: tx_byte = RINGS;
REG_DATA: tx_byte = capture_byte;
REG_FREQ_SELECT: tx_byte = {4'd0, freq_select};
REG_FREQ_COUNT3: tx_byte = freq_count[31:24];
REG_FREQ_COUNT2: tx_byte = freq_count[23:16];
REG_FREQ_COUNT1: tx_byte = freq_count[15:8];
REG_FREQ_COUNT0: tx_byte = freq_count[7:0];
default: tx_byte = 8'h00;
endcase
end
always @(posedge clk) begin
capture_start <= 1'b0;
freq_start <= 1'b0;
if (reset) begin
source <= 2'd3;
source_a <= 6'h00;
source_b <= 6'h01;
pair_mode <= 1'b0;
decimation <= 16'd1;
freq_select <= 4'd0;
is_read <= 1'b0;
reg_addr <= 7'd0;
bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}};
@ -113,12 +178,16 @@ module strix_core #(
bytes_read <= bytes_read + 1'b1;
end else if (!is_read) begin
case (reg_addr)
REG_SOURCE: source <= rx_byte[1:0];
REG_SOURCE_A: source_a <= rx_byte[5:0];
REG_SOURCE_B: source_b <= rx_byte[5:0];
REG_MODE: pair_mode <= rx_byte[0];
REG_DECIM_HIGH: decimation[15:8] <= rx_byte;
REG_DECIM_LOW: decimation[7:0] <= rx_byte;
REG_FREQ_SELECT: freq_select <= rx_byte[3:0];
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}};
if (rx_byte[2]) freq_start <= 1'b1;
end
default: ;
endcase

View file

@ -1,5 +1,5 @@
// 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.
// Testbench for strix_core: SPI register access, single and pair captures read back across
// transactions, and the frequency counter. Rings are replaced by the SIM model in ring_osc.v.
`timescale 1ns / 1ps
module tb_strix_core;
@ -8,6 +8,12 @@ module tb_strix_core;
localparam CAPTURE_BYTES = 1 << CAPTURE_ADDR_WIDTH;
localparam SCK_HALF = 62.5; // 8 MHz SCK
localparam [6:0] REG_ID0 = 7'h00, REG_ID3 = 7'h03, REG_VERSION = 7'h04,
REG_SOURCE_A = 7'h08, REG_DECIM_LOW = 7'h0A, REG_CONTROL = 7'h0B,
REG_STATUS = 7'h0C, REG_SOURCE_B = 7'h0D, REG_MODE = 7'h0E,
REG_RING_COUNT = 7'h0F, REG_DATA = 7'h10, REG_FREQ_SELECT = 7'h11,
REG_FREQ_COUNT0 = 7'h15;
reg clk = 1'b0;
always #4.1667 clk = ~clk; // 120 MHz
@ -15,7 +21,7 @@ module tb_strix_core;
reg sck = 1'b0, mosi = 1'b0, ss_n = 1'b1;
wire miso, done;
strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH)) dut (
strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14)) dut (
.clk(clk), .reset(reset),
.spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso),
.capture_done(done)
@ -55,12 +61,56 @@ module tb_strix_core;
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
task run_capture;
reg [7:0] status;
integer polls;
begin
write_reg(REG_CONTROL, 8'h01);
polls = 0;
read_reg(REG_STATUS, status);
while (status[1] !== 1'b1 && polls < 1000) begin
read_reg(REG_STATUS, status);
polls = polls + 1;
end
if (status[1] !== 1'b1 || done !== 1'b1) begin
$display("FAIL capture did not finish");
errors = errors + 1;
end
end
endtask
// Captured bytes must be defined and vary, otherwise the read-back checks prove nothing.
task check_buffer_contents;
integer i, changes;
begin
changes = 0;
for (i = 0; i < CAPTURE_BYTES; i = i + 1) begin
if (^dut.capture.buffer[i] === 1'bx) begin
$display("FAIL buffer[%0d] undefined", i);
errors = errors + 1;
end
if (i > 0 && dut.capture.buffer[i] !== dut.capture.buffer[i - 1]) changes = changes + 1;
end
if (changes == 0) begin
$display("FAIL capture buffer is constant");
errors = errors + 1;
end
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);
spi_byte({1'b1, REG_DATA}, dummy);
for (i = 0; i < count; i = i + 1) begin
spi_byte(8'h00, value);
if (value !== dut.capture.buffer[first + i]) begin
@ -72,65 +122,65 @@ module tb_strix_core;
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;
integer i, polls;
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");
// Identification
read_reg(REG_ID0, v); expect(v, "S", "ID0");
read_reg(REG_ID3, v); expect(v, "X", "ID3");
read_reg(REG_VERSION, v); expect(v, 8'h02, "VERSION");
read_reg(REG_RING_COUNT, v); expect(v, 8'd16, "RING_COUNT");
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.
// Single capture of the XOR of rings 0-1, every 3rd sample, read in two transactions
write_reg(REG_SOURCE_A, 8'h21);
write_reg(REG_DECIM_LOW, 8'h03);
read_reg(REG_SOURCE_A, v); expect(v, 8'h21, "SOURCE_A");
run_capture;
check_buffer_contents;
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);
write_reg(REG_CONTROL, 8'h02); // rewind
read_data(0, 4);
// Pair capture with A = B: every bit pair must be 00 or 11
write_reg(REG_SOURCE_A, 8'h10);
write_reg(REG_SOURCE_B, 8'h10);
write_reg(REG_MODE, 8'h01);
write_reg(REG_DECIM_LOW, 8'h01);
run_capture;
check_buffer_contents;
for (i = 0; i < 4 * CAPTURE_BYTES; i = i + 1) begin
v = dut.capture.buffer[i / 4] >> (2 * (i % 4));
if (v[1] !== v[0]) begin
$display("FAIL pair %0d differs", i);
errors = errors + 1;
end
end
read_data(0, CAPTURE_BYTES);
// Frequency counter on ring 3
write_reg(REG_FREQ_SELECT, 8'h03);
write_reg(REG_CONTROL, 8'h04);
polls = 0;
read_reg(REG_STATUS, v);
while (v[3] !== 1'b1 && polls < 1000) begin
read_reg(REG_STATUS, v);
polls = polls + 1;
end
if (v[3] !== 1'b1) begin
$display("FAIL frequency measurement did not finish");
errors = errors + 1;
end
read_reg(REG_FREQ_COUNT0, v);
if (v == 8'd0 || dut.counter.count === 32'bx) begin
$display("FAIL frequency count is %0d", dut.counter.count);
errors = errors + 1;
end
if (errors == 0) $display("PASS");
else $display("%0d error(s)", errors);
$finish;

View file

@ -60,7 +60,8 @@ set_global_assignment -name EDA_GENERATE_FUNCTIONAL_NETLIST OFF -section_id eda_
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_bank.v
set_global_assignment -name VERILOG_FILE ../rtl/freq_counter.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

View file

@ -3,7 +3,7 @@ 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[*]}]
set_false_path -to [get_registers {*ring_bank:bank|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[*]}]
@ -12,3 +12,6 @@ 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}]
# The frequency counter's prescaler runs on a ring output; its top bit is synchronized.
set_false_path -from [get_registers {*freq_counter:counter|prescaler[*]}]

View file

@ -1,174 +0,0 @@
#!/usr/bin/env python3
"""Collect raw ring-oscillator captures from the STRIX capture sketch and estimate jitter.
For each source and decimation k (keep every k-th sample at 120 MHz) the script stores the
capture, estimates the per-bit entropy from conditional block entropies, and converts it to a
predictability P and relative predictability 2P-1. Accumulated phase jitter grows with sqrt(k),
so fitting a jittered square-wave sampling model to P(k) gives the effective jitter per sample
interval, in ring cycles. The estimate is approximate: the entropy is measured from past
samples, while the model assumes the ideal phase is known. On synthetic captures with known
random-walk jitter it overestimates by a factor of about 1.2-1.3, so divide by 1.3 when sizing
the XOR combining conservatively.
"""
import argparse
import math
import sys
from pathlib import Path
import numpy as np
import serial
SOURCES = {0: "tap A", 1: "enhanced A", 2: "enhanced B", 3: "pair XOR"}
DEFAULT_DECIMATIONS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]
# Board communication
def open_board(port: str) -> serial.Serial:
board = serial.Serial(port, 115200, timeout=200)
board.reset_input_buffer()
board.write(b"id\n")
while True:
line = board.readline().decode(errors="replace").strip()
if not line:
sys.exit("no answer from the capture sketch")
if line.startswith("STRX"):
print(f"board: {line}")
return board
if line.startswith("ERR"):
sys.exit(f"board: {line}")
def capture(board: serial.Serial, source: int, decimation: int) -> bytes:
board.write(f"cap {source} {decimation}\n".encode())
header = board.readline().decode(errors="replace").strip()
if not header.startswith("DATA "):
sys.exit(f"board: {header or 'timeout'}")
length = int(header.split()[1])
data = board.read(length)
if len(data) != length:
sys.exit(f"short capture: {len(data)} of {length} bytes")
return data
# Statistics
def binary_entropy(p: float) -> float:
if p <= 0.0 or p >= 1.0:
return 0.0
return -(p * math.log2(p) + (1 - p) * math.log2(1 - p))
def predictability_from_entropy(h: float) -> float:
"""Inverse of the binary entropy on P >= 0.5, by bisection."""
low, high = 0.5, 1.0
for _ in range(60):
mid = (low + high) / 2
if binary_entropy(mid) > h:
low = mid
else:
high = mid
return (low + high) / 2
def block_entropy(bits: np.ndarray, length: int) -> float:
weights = 1 << np.arange(length)
windows = np.lib.stride_tricks.sliding_window_view(bits, length)
counts = np.bincount(windows @ weights, minlength=1 << length)
p = counts[counts > 0] / counts.sum()
return float(-(p * np.log2(p)).sum())
def analyze(data: bytes, order: int) -> dict:
bits = np.unpackbits(np.frombuffer(data, dtype=np.uint8)).astype(np.int64)
signed = 2.0 * bits - 1.0
entropy = block_entropy(bits, order + 1) - block_entropy(bits, order)
predictability = predictability_from_entropy(entropy)
return {
"ones": float(bits.mean()),
"autocorr": float(np.mean(signed[:-1] * signed[1:]) - np.mean(signed) ** 2),
"entropy": entropy,
"relative_predictability": 2 * predictability - 1,
}
# Jitter model: a square wave sampled at a nominal phase mu in the high half-cycle, with
# Gaussian phase noise of standard deviation sigma (in cycles). Averaging the probability of
# reading the expected level over mu gives the predictability of a sample.
_erf = np.vectorize(math.erf)
def model_predictability(sigma: float) -> float:
mu = np.linspace(0.0, 0.5, 201)
x = np.arange(-math.ceil(6 * sigma) - 1, math.ceil(6 * sigma) + 2)
cdf = lambda edge: 0.5 * (1 + _erf((edge[None, :] - mu[:, None]) / (sigma * math.sqrt(2))))
p_high = (cdf(x + 0.5) - cdf(x)).sum(axis=1)
return float(p_high.mean())
def fit_jitter(decimations: list[int], relative: list[float]) -> float:
"""Least-squares fit of sigma_1 in sigma_k = sigma_1 * sqrt(k), via a lookup table."""
table_sigma = np.logspace(-5, 1, 600)
table_relative = np.array([2 * model_predictability(s) - 1 for s in table_sigma])
ks = np.array(decimations, dtype=float)
measured = np.array(relative)
def model(sigma_1: float) -> np.ndarray:
return np.interp(np.log(sigma_1 * np.sqrt(ks)), np.log(table_sigma), table_relative)
candidates = np.logspace(-5, 0, 1000)
errors = [np.sum((model(s) - measured) ** 2) for s in candidates]
return float(candidates[int(np.argmin(errors))])
# Main
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", default="/dev/ttyACM0")
parser.add_argument("--out", type=Path, default=Path("captures"))
parser.add_argument("--sources", type=int, nargs="+", default=list(SOURCES))
parser.add_argument("--decimations", type=int, nargs="+", default=DEFAULT_DECIMATIONS)
parser.add_argument("--order", type=int, default=8, help="context bits for the entropy estimate")
parser.add_argument("--analyze-only", action="store_true", help="reuse captures in --out")
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
board = None if args.analyze_only else open_board(args.port)
results = {}
print(f"{'source':<11} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}")
for source in args.sources:
for k in args.decimations:
path = args.out / f"src{source}_dec{k}.bin"
if board:
path.write_bytes(capture(board, source, k))
if not path.exists():
continue
r = analyze(path.read_bytes(), args.order)
results[(source, k)] = r
print(f"{SOURCES[source]:<11} {k:>5} {r['ones']:7.4f} {r['autocorr']:9.4f} "
f"{r['entropy']:7.4f} {r['relative_predictability']:8.5f}")
print("\nfitted jitter per 120 MHz sample interval (ring cycles, rms):")
for source in args.sources:
# Near-random points sit on the estimator's noise floor and would bias the fit.
points = [(k, results[(source, k)]["relative_predictability"])
for k in args.decimations
if (source, k) in results and results[(source, k)]["relative_predictability"] > 0.1]
if len(points) >= 3:
ks, rel = zip(*points)
print(f" {SOURCES[source]:<11} {fit_jitter(list(ks), list(rel)):.5f}")
print("\ncheck: enhanced A should match (tap A)^3 in relative predictability")
for k in args.decimations:
if (0, k) in results and (1, k) in results:
tap = results[(0, k)]["relative_predictability"]
enhanced = results[(1, k)]["relative_predictability"]
print(f" k={k:<5} tap^3={tap ** 3:8.5f} enhanced={enhanced:8.5f}")
if __name__ == "__main__":
main()

296
tools/jitter.py Normal file
View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Ring-oscillator experiments against the STRIX capture sketch (arduino/strix_capture).
Subcommands:
sweep capture sources at increasing decimation k and fit the accumulated jitter
freq measure every ring's frequency
xor capture the XOR of rings 0..n-1 for n = 1..16 and compare with the product law
pairs capture ring pairs simultaneously and test them for correlation
Entropy per bit is estimated from conditional block entropies and converted to a
predictability P; 2P-1 is the relative predictability, which multiplies when independent
sources are XORed. The jitter fit models a square wave sampled with Gaussian phase noise
whose standard deviation grows with sqrt(k). On synthetic captures it overestimates by a
factor of about 1.2-1.3, so divide by 1.3 when sizing the XOR combining conservatively.
"""
import argparse
import math
import sys
import time
from itertools import combinations
from pathlib import Path
import numpy as np
import serial
RINGS = 16
CLOCK_HZ = 120e6
SOURCE_ENHANCED, SOURCE_TAP, SOURCE_XOR = 0x00, 0x10, 0x20
REG_FREQ_GATE = 0x05
REG_SOURCE_A, REG_DECIM_HIGH, REG_DECIM_LOW = 0x08, 0x09, 0x0A
REG_CONTROL, REG_STATUS, REG_SOURCE_B, REG_MODE = 0x0B, 0x0C, 0x0D, 0x0E
REG_FREQ_SELECT, REG_FREQ_COUNT3 = 0x11, 0x12
CONTROL_START_FREQ = 0x04
STATUS_FREQ_VALID = 0x08
def ring_length(ring: int) -> int:
return 9 + 3 * (ring % 3)
def source_name(code: int) -> str:
if code >= SOURCE_XOR:
return f"xor 0-{code - SOURCE_XOR}"
if code >= SOURCE_TAP:
return f"tap {code - SOURCE_TAP}"
return f"enhanced {code}"
# Board communication
class Board:
def __init__(self, port: str):
self.serial = serial.Serial(port, 115200, timeout=200)
# The sketch prints its identity once when the port opens; drain it so replies stay
# aligned with commands.
time.sleep(0.5)
self.serial.reset_input_buffer()
identity = self.command("id")
if not identity.startswith("STRX v2"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v2 FPGA image)")
print(f"board: {identity}")
def command(self, line: str) -> str:
self.serial.write(f"{line}\n".encode())
reply = self.serial.readline().decode(errors="replace").strip()
if not reply or reply.startswith("ERR"):
sys.exit(f"board: {reply or 'timeout'} (command {line!r})")
return reply
def read(self, register: int) -> int:
return int(self.command(f"r {register}"))
def write(self, register: int, value: int) -> None:
self.command(f"w {register} {value}")
def capture(self, source_a: int, decimation: int, source_b: int | None = None) -> bytes:
self.write(REG_SOURCE_A, source_a)
self.write(REG_SOURCE_B, source_b if source_b is not None else 0)
self.write(REG_MODE, 1 if source_b is not None else 0)
self.write(REG_DECIM_HIGH, decimation >> 8)
self.write(REG_DECIM_LOW, decimation & 0xFF)
header = self.command("cap")
if not header.startswith("DATA "):
sys.exit(f"board: {header}")
length = int(header.split()[1])
data = self.serial.read(length)
if len(data) != length:
sys.exit(f"short capture: {len(data)} of {length} bytes")
return data
def frequency(self, ring: int) -> float:
self.write(REG_FREQ_SELECT, ring)
self.write(REG_CONTROL, CONTROL_START_FREQ)
while not self.read(REG_STATUS) & STATUS_FREQ_VALID:
pass
count = 0
for i in range(4):
count = (count << 8) | self.read(REG_FREQ_COUNT3 + i)
gate_seconds = (1 << self.read(REG_FREQ_GATE)) / CLOCK_HZ
return count * 256 / gate_seconds
def cached_capture(board: Board | None, path: Path, *args) -> bytes | None:
if board:
path.write_bytes(board.capture(*args))
return path.read_bytes() if path.exists() else None
# Statistics
def to_bits(data: bytes) -> np.ndarray:
return np.unpackbits(np.frombuffer(data, dtype=np.uint8)).astype(np.int64)
def binary_entropy(p: float) -> float:
if p <= 0.0 or p >= 1.0:
return 0.0
return -(p * math.log2(p) + (1 - p) * math.log2(1 - p))
def predictability_from_entropy(h: float) -> float:
"""Inverse of the binary entropy on P >= 0.5, by bisection."""
low, high = 0.5, 1.0
for _ in range(60):
mid = (low + high) / 2
if binary_entropy(mid) > h:
low = mid
else:
high = mid
return (low + high) / 2
def block_entropy(bits: np.ndarray, length: int) -> float:
weights = 1 << np.arange(length)
windows = np.lib.stride_tricks.sliding_window_view(bits, length)
counts = np.bincount(windows @ weights, minlength=1 << length)
p = counts[counts > 0] / counts.sum()
return float(-(p * np.log2(p)).sum())
def analyze(bits: np.ndarray, order: int) -> dict:
signed = 2.0 * bits - 1.0
entropy = block_entropy(bits, order + 1) - block_entropy(bits, order)
return {
"ones": float(bits.mean()),
"autocorr": float(np.mean(signed[:-1] * signed[1:]) - np.mean(signed) ** 2),
"entropy": entropy,
"relative": 2 * predictability_from_entropy(entropy) - 1,
}
def print_header() -> None:
print(f"{'source':<12} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}")
def print_row(name: str, k: int, r: dict) -> None:
print(f"{name:<12} {k:>5} {r['ones']:7.4f} {r['autocorr']:9.4f} {r['entropy']:7.4f} {r['relative']:8.5f}")
# Jitter model: a square wave sampled at a nominal phase mu in the high half-cycle, with
# Gaussian phase noise of standard deviation sigma (in cycles). Averaging the probability of
# reading the expected level over mu gives the predictability of a sample.
_erf = np.vectorize(math.erf)
def model_predictability(sigma: float) -> float:
mu = np.linspace(0.0, 0.5, 201)
x = np.arange(-math.ceil(6 * sigma) - 1, math.ceil(6 * sigma) + 2)
cdf = lambda edge: 0.5 * (1 + _erf((edge[None, :] - mu[:, None]) / (sigma * math.sqrt(2))))
return float((cdf(x + 0.5) - cdf(x)).sum(axis=1).mean())
def fit_jitter(decimations: list[int], relative: list[float]) -> float:
"""Least-squares fit of sigma_1 in sigma_k = sigma_1 * sqrt(k), via a lookup table."""
table_sigma = np.logspace(-5, 1, 600)
table_relative = np.array([2 * model_predictability(s) - 1 for s in table_sigma])
ks = np.array(decimations, dtype=float)
measured = np.array(relative)
def model(sigma_1: float) -> np.ndarray:
return np.interp(np.log(sigma_1 * np.sqrt(ks)), np.log(table_sigma), table_relative)
candidates = np.logspace(-5, 0, 1000)
errors = [np.sum((model(s) - measured) ** 2) for s in candidates]
return float(candidates[int(np.argmin(errors))])
# Subcommands
def run_sweep(board: Board | None, args) -> None:
results = {}
print_header()
for source in args.sources:
for k in args.decimations:
data = cached_capture(board, args.out / f"sweep_s{source:02x}_k{k}.bin", source, k)
if data is None:
continue
results[(source, k)] = r = analyze(to_bits(data), args.order)
print_row(source_name(source), k, r)
print("\nfitted jitter per 120 MHz sample interval (ring cycles, rms):")
for source in args.sources:
# Near-random points sit on the estimator's noise floor and would bias the fit.
points = [(k, results[(source, k)]["relative"]) for k in args.decimations
if (source, k) in results and results[(source, k)]["relative"] > 0.1]
if len(points) >= 3:
ks, rel = zip(*points)
print(f" {source_name(source):<12} {fit_jitter(list(ks), list(rel)):.5f}")
def run_freq(board: Board | None, args) -> None:
if board is None:
sys.exit("freq needs the board")
print(f"{'ring':>4} {'length':>6} {'MHz':>9}")
for ring in range(RINGS):
print(f"{ring:>4} {ring_length(ring):>6} {board.frequency(ring) / 1e6:9.2f}")
def run_xor(board: Board | None, args) -> None:
single = {}
for ring in range(RINGS):
data = cached_capture(board, args.out / f"single_s{ring:02x}_k{args.k}.bin", SOURCE_ENHANCED + ring, args.k)
if data is not None:
single[ring] = analyze(to_bits(data), args.order)["relative"]
print(f"{'n':>3} {'ones':>7} {'H/bit':>7} {'2P-1':>8} {'product':>8}")
product = 1.0
for n in range(1, RINGS + 1):
product *= single.get(n - 1, float("nan"))
data = cached_capture(board, args.out / f"xor_n{n}_k{args.k}.bin", SOURCE_XOR + n - 1, args.k)
if data is None:
continue
r = analyze(to_bits(data), args.order)
print(f"{n:>3} {r['ones']:7.4f} {r['entropy']:7.4f} {r['relative']:8.5f} {product:8.5f}")
print("\n'product' is the prediction for independent rings: the product of the single rings' 2P-1.")
print("Measured values sit on the estimator's noise floor (about 0.03 for 256 Kbit) once it is reached.")
def correlation(a: np.ndarray, b: np.ndarray) -> float:
a = a - a.mean()
b = b - b.mean()
return float(np.dot(a, b) / math.sqrt(np.dot(a, a) * np.dot(b, b)))
def run_pairs(board: Board | None, args) -> None:
rng = np.random.default_rng(0)
pairs = list(combinations(range(RINGS), 2))
print(f"{'pair':>7} {'corr':>8} {'null sd':>8} {'z':>7}")
flagged = 0
for i, j in pairs:
data = cached_capture(board, args.out / f"pair_{i}_{j}_k{args.k}.bin",
SOURCE_ENHANCED + i, args.k, SOURCE_ENHANCED + j)
if data is None:
continue
bits = to_bits(data).astype(float)
a, b = bits[0::2], bits[1::2]
# Null distribution: the same two streams misaligned by large random offsets.
null = [correlation(a, np.roll(b, s)) for s in rng.integers(len(b) // 4, 3 * len(b) // 4, 20)]
c, sd = correlation(a, b), float(np.std(null))
z = c / sd if sd > 0 else float("inf")
mark = " <-- dependent?" if abs(z) > 5 else ""
flagged += bool(mark)
print(f"{i:>3}-{j:<3} {c:8.4f} {sd:8.4f} {z:7.2f}{mark}")
print(f"\n{flagged} of {len(pairs)} pairs exceed |z| > 5")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", default="/dev/ttyACM0")
parser.add_argument("--out", type=Path, default=Path("captures"))
parser.add_argument("--order", type=int, default=8, help="context bits for the entropy estimate")
parser.add_argument("--analyze-only", action="store_true", help="reuse captures in --out")
sub = parser.add_subparsers(dest="command", required=True)
sweep = sub.add_parser("sweep")
sweep.add_argument("--sources", type=lambda s: int(s, 0), nargs="+",
default=[SOURCE_TAP, SOURCE_ENHANCED, SOURCE_ENHANCED + 1, SOURCE_XOR + 1])
sweep.add_argument("--decimations", type=int, nargs="+",
default=[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096])
sub.add_parser("freq")
xor = sub.add_parser("xor")
xor.add_argument("-k", type=int, default=1, help="decimation")
pairs = sub.add_parser("pairs")
pairs.add_argument("-k", type=int, default=64, help="decimation")
args = parser.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
board = None if args.analyze_only else Board(args.port)
{"sweep": run_sweep, "freq": run_freq, "xor": run_xor, "pairs": run_pairs}[args.command](board, args)
if __name__ == "__main__":
main()