feat: add four 90-ring entropy generators with spatial and temporal XOR (FPGA v3)

This commit is contained in:
randogoth 2026-10-25 13:23:27 +02:00
parent 504b00752a
commit 585e755c66
9 changed files with 389 additions and 217 deletions

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/ring_bank.v fpga/rtl/freq_counter.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/entropy_generator.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": [

View file

@ -0,0 +1,92 @@
// One entropy generator: RINGS ring oscillators whose enhanced outputs are each sampled
// every clock and XORed together (spatial XOR, pipelined), then XORed over `temporal`
// consecutive clocks. The output bit is held for `temporal` clocks, so capturing it with
// decimation = temporal yields every output bit exactly once.
//
// XOR combining multiplies the relative predictabilities (2P-1) of independent inputs, so
// the ring count and temporal factor set the residual bias and correlation of the output.
//
// A selected ring's sampled outputs and its raw tap are exposed for characterization.
module entropy_generator #(
parameter RINGS = 90 // at most 256 (four 4:1 XOR stages)
) (
input wire clk,
input wire enable, // low stops all rings
input wire [3:0] temporal, // consecutive bits XORed per output bit, 1-8
input wire [7:0] ring_select, // ring exposed on the characterization outputs
output reg out_bit, // processed output, held for `temporal` clocks
output wire spatial_bit, // XOR of all rings, one per clock
output reg ring_enhanced,
output reg ring_tap,
output wire ring_raw_tap // unsynchronized, for frequency counting
);
wire [RINGS-1:0] raw_enhanced, raw_tap;
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(raw_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 [RINGS-1:0] sample_raw, sample_sync, tap_raw, tap_sync;
always @(posedge clk) begin
sample_raw <= raw_enhanced;
sample_sync <= sample_raw;
tap_raw <= raw_tap;
tap_sync <= tap_raw;
end
// Spatial XOR: four registered stages of 4-input XORs (256 -> 64 -> 16 -> 4 -> 1).
reg [255:0] level0;
reg [63:0] level1;
reg [15:0] level2;
reg [3:0] level3;
reg level4;
integer j;
always @(*) begin
level0 = 256'd0;
level0[RINGS-1:0] = sample_sync;
end
always @(posedge clk) begin
for (j = 0; j < 64; j = j + 1) level1[j] <= ^level0[4*j +: 4];
for (j = 0; j < 16; j = j + 1) level2[j] <= ^level1[4*j +: 4];
for (j = 0; j < 4; j = j + 1) level3[j] <= ^level2[4*j +: 4];
level4 <= ^level3;
end
assign spatial_bit = level4;
// Temporal XOR over `temporal` consecutive spatial bits.
reg [3:0] phase = 4'd0;
reg accumulator = 1'b0;
wire last = (phase >= temporal - 4'd1) || (temporal == 4'd0);
always @(posedge clk) begin
if (last) begin
out_bit <= accumulator ^ spatial_bit;
accumulator <= 1'b0;
phase <= 4'd0;
end else begin
accumulator <= accumulator ^ spatial_bit;
phase <= phase + 4'd1;
end
end
// Characterization taps
always @(posedge clk) begin
ring_enhanced <= sample_sync[ring_select];
ring_tap <= tap_sync[ring_select];
end
assign ring_raw_tap = raw_tap[ring_select];
endmodule

View file

@ -66,9 +66,13 @@ module raw_capture #(
end
end
// Two read registers: the first becomes the RAM's address register, the second its output
// register, which keeps the wide block-RAM output multiplexer off the SPI timing path.
reg [7:0] read_data_ram;
always @(posedge clk) begin
if (write_enable) buffer[write_addr] <= write_data;
read_data <= buffer[read_addr];
read_data_ram <= buffer[read_addr];
read_data <= read_data_ram;
end
endmodule

View file

@ -1,36 +0,0 @@
// 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,17 +1,20 @@
// Board-independent STRIX logic: ring bank, source selection, raw capture, frequency
// counter and the SPI register interface to the SAMD21.
// Board-independent STRIX logic: entropy generators, 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.
// Capture sources, for generator g = 0..3:
// 0x00+g processed output (spatial XOR, then temporal XOR; capture with decimation = TEMPORAL)
// 0x04+g spatial XOR of all rings, every clock
// 0x08+g enhanced output of ring RING_SELECT
// 0x0C+g single tap of ring RING_SELECT
module strix_core #(
parameter CAPTURE_ADDR_WIDTH = 15,
parameter FREQ_GATE_BITS = 24
parameter FREQ_GATE_BITS = 24,
parameter RINGS_PER_GEN = 90
) (
input wire clk,
input wire reset,
@ -22,8 +25,8 @@ module strix_core #(
output wire capture_done
);
localparam [7:0] VERSION = 8'h02;
localparam RINGS = 16;
localparam [7:0] VERSION = 8'h03;
localparam GENERATORS = 4;
localparam [6:0] REG_ID0 = 7'h00, // "STRX"
REG_ID1 = 7'h01,
@ -31,6 +34,8 @@ module strix_core #(
REG_ID3 = 7'h03,
REG_VERSION = 7'h04,
REG_FREQ_GATE = 7'h05, // read-only: gate is 2^value clk cycles
REG_GENERATORS = 7'h06, // read-only
REG_RINGS = 7'h07, // read-only: rings per generator
REG_SOURCE_A = 7'h08,
REG_DECIM_HIGH = 7'h09,
REG_DECIM_LOW = 7'h0A,
@ -40,39 +45,44 @@ module strix_core #(
// 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_RING_SELECT = 7'h11, // ring for the characterization sources and frequency
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;
REG_FREQ_COUNT0 = 7'h15,
REG_GEN_ENABLE = 7'h16, // bit g enables generator g
REG_TEMPORAL = 7'h17, // consecutive bits XORed per output bit, 1-8
REG_FREQ_GEN = 7'h18; // generator for the frequency measurement
// Ring bank and source selection
// Generators
wire [RINGS-1:0] enhanced, first_tap, raw_tap;
reg [GENERATORS-1:0] gen_enable;
reg [3:0] temporal;
reg [7:0] ring_select;
wire [GENERATORS-1:0] out_bit, spatial_bit, ring_enhanced, ring_tap, ring_raw_tap;
ring_bank #(.RINGS(RINGS)) bank (
.clk(clk), .enable(1'b1),
.enhanced(enhanced), .first_tap(first_tap), .raw_tap(raw_tap)
);
genvar g;
generate
for (g = 0; g < GENERATORS; g = g + 1) begin : gen
entropy_generator #(.RINGS(RINGS_PER_GEN)) generator (
.clk(clk), .enable(gen_enable[g]), .temporal(temporal), .ring_select(ring_select),
.out_bit(out_bit[g]), .spatial_bit(spatial_bit[g]),
.ring_enhanced(ring_enhanced[g]), .ring_tap(ring_tap[g]),
.ring_raw_tap(ring_raw_tap[g])
);
end
endgenerate
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
// Source selection
reg [5:0] source_a, source_b;
reg [3:0] source_a, source_b;
reg bit_a, bit_b;
wire [63:0] all_sources = {16'd0, xor_prefix, first_tap, enhanced};
wire [15:0] all_sources = {ring_tap, ring_enhanced, spatial_bit, out_bit};
always @(posedge clk) begin
xor_prefix <= xor_prefix_comb;
bit_a <= all_sources[source_a];
bit_b <= all_sources[source_b];
bit_a <= all_sources[source_a];
bit_b <= all_sources[source_b];
end
// Capture
@ -93,13 +103,13 @@ module strix_core #(
// Frequency counter
reg [3:0] freq_select;
reg [1:0] freq_gen;
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),
.clk(clk), .reset(reset), .signal(ring_raw_tap[freq_gen]), .start(freq_start),
.busy(freq_busy), .valid(freq_valid), .count(freq_count)
);
@ -134,19 +144,23 @@ module strix_core #(
REG_ID3: tx_byte = "X";
REG_VERSION: tx_byte = VERSION;
REG_FREQ_GATE: tx_byte = FREQ_GATE_BITS;
REG_SOURCE_A: tx_byte = {2'd0, source_a};
REG_GENERATORS: tx_byte = GENERATORS;
REG_RINGS: tx_byte = RINGS_PER_GEN;
REG_SOURCE_A: tx_byte = {4'd0, source_a};
REG_DECIM_HIGH: tx_byte = decimation[15:8];
REG_DECIM_LOW: tx_byte = decimation[7:0];
REG_STATUS: tx_byte = {4'd0, freq_valid, freq_busy, capture_done, capture_busy};
REG_SOURCE_B: tx_byte = {2'd0, source_b};
REG_SOURCE_B: tx_byte = {4'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_RING_SELECT: tx_byte = ring_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];
REG_GEN_ENABLE: tx_byte = {4'd0, gen_enable};
REG_TEMPORAL: tx_byte = {4'd0, temporal};
REG_FREQ_GEN: tx_byte = {6'd0, freq_gen};
default: tx_byte = 8'h00;
endcase
end
@ -155,11 +169,14 @@ module strix_core #(
capture_start <= 1'b0;
freq_start <= 1'b0;
if (reset) begin
source_a <= 6'h00;
source_b <= 6'h01;
gen_enable <= {GENERATORS{1'b1}};
temporal <= 4'd5;
ring_select <= 8'd0;
source_a <= 4'h0;
source_b <= 4'h1;
pair_mode <= 1'b0;
decimation <= 16'd1;
freq_select <= 4'd0;
decimation <= 16'd5;
freq_gen <= 2'd0;
is_read <= 1'b0;
reg_addr <= 7'd0;
bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}};
@ -178,12 +195,15 @@ module strix_core #(
bytes_read <= bytes_read + 1'b1;
end else if (!is_read) begin
case (reg_addr)
REG_SOURCE_A: source_a <= rx_byte[5:0];
REG_SOURCE_B: source_b <= rx_byte[5:0];
REG_SOURCE_A: source_a <= rx_byte[3:0];
REG_SOURCE_B: source_b <= rx_byte[3: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_RING_SELECT: if (rx_byte < RINGS_PER_GEN) ring_select <= rx_byte;
REG_GEN_ENABLE: gen_enable <= rx_byte[GENERATORS-1:0];
REG_TEMPORAL: if (rx_byte >= 8'd1 && rx_byte <= 8'd8) temporal <= rx_byte[3:0];
REG_FREQ_GEN: freq_gen <= rx_byte[1: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}};

View file

@ -1,18 +1,20 @@
// 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.
// Testbench for strix_core: register access, generator XOR logic checked against the sampled
// ring outputs, captures read back across transactions, generator enable, and the frequency
// counter. 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 RINGS = 90;
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_GENERATORS = 7'h06, REG_RINGS = 7'h07, REG_SOURCE_A = 7'h08,
REG_DECIM_LOW = 7'h0A, REG_CONTROL = 7'h0B, REG_STATUS = 7'h0C,
REG_DATA = 7'h10, REG_RING_SELECT = 7'h11, REG_FREQ_COUNT0 = 7'h15,
REG_GEN_ENABLE = 7'h16, REG_TEMPORAL = 7'h17, REG_FREQ_GEN = 7'h18;
reg clk = 1'b0;
always #4.1667 clk = ~clk; // 120 MHz
@ -21,7 +23,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), .FREQ_GATE_BITS(14)) dut (
strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14), .RINGS_PER_GEN(RINGS)) dut (
.clk(clk), .reset(reset),
.spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso),
.capture_done(done)
@ -29,6 +31,47 @@ module tb_strix_core;
integer errors = 0;
// Generator 0 XOR checks, running throughout the simulation. The spatial XOR has four
// pipeline stages; the temporal XOR covers the last `temporal` spatial bits.
reg [3:0] spatial_expected;
reg [7:0] spatial_history;
reg [3:0] previous_temporal = 4'd0;
integer windows_to_skip = 0;
integer xor_checks = 0;
always @(posedge clk) if (!reset) begin
spatial_expected <= {spatial_expected[2:0], ^dut.gen[0].generator.sample_sync};
spatial_history <= {spatial_history[6:0], dut.gen[0].generator.spatial_bit};
end
always @(posedge clk) if (!reset) begin : check_xor
reg expected_out;
integer t;
#1;
if (dut.gen[0].generator.spatial_bit !== spatial_expected[3] && $time > 1000) begin
$display("FAIL spatial XOR mismatch at %0t", $time);
errors = errors + 1;
end
// A window that spans a change of the temporal setting mixes both lengths; skip it.
if (dut.temporal != previous_temporal) begin
previous_temporal = dut.temporal;
windows_to_skip = 2;
end
// Just after an output update, out_bit must equal the XOR of the window that ended at it.
if (dut.gen[0].generator.phase == 4'd0 && windows_to_skip > 0) begin
windows_to_skip = windows_to_skip - 1;
end else if (dut.gen[0].generator.phase == 4'd0 && $time > 1000) begin
expected_out = 1'b0;
for (t = 0; t < dut.temporal; t = t + 1) expected_out = expected_out ^ spatial_history[t];
if (dut.gen[0].generator.out_bit !== expected_out) begin
$display("FAIL temporal XOR mismatch at %0t", $time);
errors = errors + 1;
end
xor_checks = xor_checks + 1;
end
end
task spi_byte(input [7:0] out, output [7:0] in);
integer i;
begin
@ -86,9 +129,9 @@ module tb_strix_core;
end
endtask
// Captured bytes must be defined and vary, otherwise the read-back checks prove nothing.
task check_buffer_contents;
integer i, changes;
// Counts distinct neighbouring bytes and flags undefined ones.
task buffer_changes(output integer changes);
integer i;
begin
changes = 0;
for (i = 0; i < CAPTURE_BYTES; i = i + 1) begin
@ -98,10 +141,6 @@ module tb_strix_core;
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
@ -123,7 +162,7 @@ module tb_strix_core;
endtask
reg [7:0] v;
integer i, polls;
integer changes, polls;
initial begin
#100 reset = 1'b0;
@ -132,38 +171,40 @@ module tb_strix_core;
// 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");
read_reg(REG_VERSION, v); expect(v, 8'h03, "VERSION");
read_reg(REG_GENERATORS, v); expect(v, 8'd4, "GENERATORS");
read_reg(REG_RINGS, v); expect(v, RINGS, "RINGS");
read_reg(REG_TEMPORAL, v); expect(v, 8'd5, "TEMPORAL");
// 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");
// Out-of-range settings are ignored
write_reg(REG_TEMPORAL, 8'd9); read_reg(REG_TEMPORAL, v); expect(v, 8'd5, "TEMPORAL 9");
write_reg(REG_RING_SELECT, RINGS); read_reg(REG_RING_SELECT, v); expect(v, 8'd0, "RING_SELECT");
// Processed output of generator 1 at its native rate, read in two transactions
write_reg(REG_TEMPORAL, 8'd3);
write_reg(REG_SOURCE_A, 8'h01);
write_reg(REG_DECIM_LOW, 8'd3);
run_capture;
check_buffer_contents;
buffer_changes(changes);
if (changes == 0) begin $display("FAIL processed capture is constant"); errors = errors + 1; end
read_data(0, CAPTURE_BYTES / 2);
read_data(CAPTURE_BYTES / 2, CAPTURE_BYTES / 2);
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);
// A disabled generator's spatial XOR is constant (all rings stop high)
write_reg(REG_GEN_ENABLE, 8'h0E);
write_reg(REG_SOURCE_A, 8'h04);
write_reg(REG_DECIM_LOW, 8'd1);
#500;
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);
buffer_changes(changes);
if (changes != 0) begin $display("FAIL disabled generator still varies"); errors = errors + 1; end
write_reg(REG_GEN_ENABLE, 8'h0F);
// Frequency counter on ring 3
write_reg(REG_FREQ_SELECT, 8'h03);
// Frequency of generator 2, ring 5
write_reg(REG_FREQ_GEN, 8'd2);
write_reg(REG_RING_SELECT, 8'd5);
write_reg(REG_CONTROL, 8'h04);
polls = 0;
read_reg(REG_STATUS, v);
@ -171,17 +212,16 @@ module tb_strix_core;
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
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);
if (v == 8'd0) begin $display("FAIL frequency count is zero"); errors = errors + 1; end
if (xor_checks < 100) begin
$display("FAIL only %0d temporal XOR checks ran", xor_checks);
errors = errors + 1;
end
if (errors == 0) $display("PASS");
if (errors == 0) $display("PASS (%0d temporal XOR checks)", xor_checks);
else $display("%0d error(s)", errors);
$finish;
end

View file

@ -60,7 +60,7 @@ 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/ring_bank.v
set_global_assignment -name VERILOG_FILE ../rtl/entropy_generator.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

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 {*ring_bank:bank|sample_raw[*]}]
set_false_path -to [get_registers {*entropy_generator:*|sample_raw[*] *entropy_generator:*|tap_raw[*]}]
# SPI inputs are synchronized in spi_slave; MISO timing relies on the half-SCK margin.
set_false_path -from [get_ports {bMKR_A[*]}]

View file

@ -1,17 +1,22 @@
#!/usr/bin/env python3
"""Ring-oscillator experiments against the STRIX capture sketch (arduino/strix_capture).
"""Entropy generator 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
freq measure ring frequencies in every generator
rings estimate each ring's relative predictability at k=1 (sizing input)
sweep capture sources at increasing decimation k and fit the accumulated jitter
output check every generator's spatial XOR and processed output for bias and correlation
pairs capture the same ring index in two generators simultaneously and test 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.
sources are XORed. Statistics of XOR-combined streams overstate their randomness, because
short contexts cannot see the long-period structure of mixed rings, so size the XOR
combining from single-ring values (`rings`), not from `output`.
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 conservatively.
"""
import argparse
@ -24,14 +29,16 @@ 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
# Capture sources for generator g
SOURCE_OUTPUT, SOURCE_SPATIAL, SOURCE_RING, SOURCE_TAP = 0x00, 0x04, 0x08, 0x0C
REG_FREQ_GATE, REG_GENERATORS, REG_RINGS = 0x05, 0x06, 0x07
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
REG_RING_SELECT, REG_FREQ_COUNT3 = 0x11, 0x12
REG_GEN_ENABLE, REG_TEMPORAL, REG_FREQ_GEN = 0x16, 0x17, 0x18
CONTROL_START_FREQ = 0x04
STATUS_FREQ_VALID = 0x08
@ -40,27 +47,36 @@ 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}"
def source_name(code: int, ring: int) -> str:
generator, kind = code & 0x03, code & 0x0C
names = {SOURCE_OUTPUT: "output", SOURCE_SPATIAL: "spatial",
SOURCE_RING: f"ring {ring}", SOURCE_TAP: f"tap {ring}"}
return f"g{generator} {names[kind]}"
# 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}")
# The sketch also prints its identity when the port opens, possibly after our "id"
# reply if the board is still booting. Take the first identity line, then drain
# until the port is quiet so replies stay aligned with commands.
self.serial = serial.Serial(port, 115200, timeout=5)
self.serial.write(b"id\n")
identity = ""
while not identity.startswith("STRX"):
identity = self.serial.readline().decode(errors="replace").strip()
if not identity:
sys.exit("no answer from the capture sketch")
self.serial.timeout = 0.5
while self.serial.readline():
pass
self.serial.timeout = 200
if not identity.startswith("STRX v3"):
sys.exit(f"unexpected board identity: {identity!r} (needs the v3 FPGA image)")
self.generators = self.read(REG_GENERATORS)
self.rings = self.read(REG_RINGS)
print(f"board: {identity}, {self.generators} generators x {self.rings} rings")
def command(self, line: str) -> str:
self.serial.write(f"{line}\n".encode())
@ -90,8 +106,9 @@ class Board:
sys.exit(f"short capture: {len(data)} of {length} bytes")
return data
def frequency(self, ring: int) -> float:
self.write(REG_FREQ_SELECT, ring)
def frequency(self, generator: int, ring: int) -> float:
self.write(REG_FREQ_GEN, generator)
self.write(REG_RING_SELECT, ring)
self.write(REG_CONTROL, CONTROL_START_FREQ)
while not self.read(REG_STATUS) & STATUS_FREQ_VALID:
pass
@ -151,12 +168,18 @@ def analyze(bits: np.ndarray, order: int) -> dict:
}
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 print_header() -> None:
print(f"{'source':<12} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}")
print(f"{'source':<14} {'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}")
print(f"{name:<14} {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
@ -190,16 +213,53 @@ def fit_jitter(decimations: list[int], relative: list[float]) -> float:
# Subcommands
def run_freq(board: Board | None, args) -> None:
if board is None:
sys.exit("freq needs the board")
print(f"{'gen':>3} {'length':>6} {'rings':>5} {'min MHz':>8} {'mean MHz':>9} {'max MHz':>8}")
for g in range(board.generators):
by_length: dict[int, list[float]] = {}
for ring in range(board.rings):
by_length.setdefault(ring_length(ring), []).append(board.frequency(g, ring) / 1e6)
for length, freqs in sorted(by_length.items()):
print(f"{g:>3} {length:>6} {len(freqs):>5} {min(freqs):8.2f} {np.mean(freqs):9.2f} {max(freqs):8.2f}")
def run_rings(board: Board | None, args) -> None:
values = []
print(f"{'ring':>4} {'length':>6} {'ones':>7} {'2P-1':>8}")
for ring in args.rings:
path = args.out / f"ring_g{args.generator}_r{ring}_k1.bin"
if board:
board.write(REG_RING_SELECT, ring)
data = cached_capture(board, path, SOURCE_RING + args.generator, 1)
if data is None:
continue
r = analyze(to_bits(data), args.order)
values.append(r["relative"])
print(f"{ring:>4} {ring_length(ring):>6} {r['ones']:7.4f} {r['relative']:8.5f}")
if values:
# Independent sources multiply: the generator's spatial XOR has 2P-1 = product over rings.
log_spatial = float(np.sum(np.log(values)))
print(f"\n2P-1 per ring at k=1 (order {args.order}): mean {np.mean(values):.4f}, worst {max(values):.4f}")
print(f"spatial XOR of these {len(values)} rings: 2P-1 = {math.exp(log_spatial):.3e}")
for temporal in range(1, 9):
print(f" temporal XOR {temporal}: output 2P-1 = {math.exp(temporal * log_spatial):.3e}, "
f"{CLOCK_HZ / temporal / 1e6:5.1f} Mbps per generator")
def run_sweep(board: Board | None, args) -> None:
if board:
board.write(REG_RING_SELECT, args.ring)
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)
data = cached_capture(board, args.out / f"sweep_s{source:02x}_r{args.ring}_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_row(source_name(source, args.ring), k, r)
print("\nfitted jitter per 120 MHz sample interval (ring cycles, rms):")
for source in args.sources:
@ -208,88 +268,80 @@ def run_sweep(board: Board | None, args) -> None:
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}")
print(f" {source_name(source, args.ring):<14} {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_output(board: Board | None, args) -> None:
# Longer contexts are undersampled at 256 Kbit and read falsely low on random data.
order = min(args.order, 8)
temporal = board.read(REG_TEMPORAL) if board else args.temporal
generators = board.generators if board else 4
print(f"temporal XOR = {temporal}; output captured with decimation {temporal}")
print("ones should be within about +-0.003 of 0.5 (3 sigma for 256 Kbit)\n")
print_header()
for g in range(generators):
for source, k in [(SOURCE_SPATIAL + g, 1), (SOURCE_OUTPUT + g, temporal)]:
data = cached_capture(board, args.out / f"output_s{source:02x}_k{k}.bin", source, k)
if data is not None:
print_row(source_name(source, 0), k, analyze(to_bits(data), order))
def run_pairs(board: Board | None, args) -> None:
generators = board.generators if board else 4
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")
print(f"{'ring':>4} {'gens':>5} {'corr':>8} {'null sd':>8} {'z':>7}")
flagged = total = 0
for ring in args.rings:
if board:
board.write(REG_RING_SELECT, ring)
for a, b in combinations(range(generators), 2):
data = cached_capture(board, args.out / f"pair_r{ring}_g{a}{b}_k{args.k}.bin",
SOURCE_RING + a, args.k, SOURCE_RING + b)
if data is None:
continue
bits = to_bits(data).astype(float)
x, y = bits[0::2], bits[1::2]
# Null distribution: the same two streams misaligned by large random offsets.
null = [correlation(x, np.roll(y, s)) for s in rng.integers(len(y) // 4, 3 * len(y) // 4, 20)]
c, sd = correlation(x, y), float(np.std(null))
z = c / sd if sd > 0 else float("inf")
mark = " <-- dependent?" if abs(z) > 5 else ""
flagged += bool(mark)
total += 1
print(f"{ring:>4} {a}-{b:<3} {c:8.4f} {sd:8.4f} {z:7.2f}{mark}")
print(f"\n{flagged} of {total} 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("--order", type=int, default=16, 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)
sub.add_parser("freq")
rings = sub.add_parser("rings")
rings.add_argument("--generator", type=int, default=0)
rings.add_argument("--rings", type=int, nargs="+", default=list(range(90)))
sweep = sub.add_parser("sweep")
sweep.add_argument("--ring", type=int, default=0, help="ring for the ring/tap sources")
sweep.add_argument("--sources", type=lambda s: int(s, 0), nargs="+",
default=[SOURCE_TAP, SOURCE_ENHANCED, SOURCE_ENHANCED + 1, SOURCE_XOR + 1])
default=[SOURCE_TAP, SOURCE_RING, SOURCE_SPATIAL])
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")
output = sub.add_parser("output")
output.add_argument("--temporal", type=int, default=5, help="for --analyze-only")
pairs = sub.add_parser("pairs")
pairs.add_argument("--rings", type=int, nargs="+", default=list(range(0, 90, 9)))
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)
handlers = {"freq": run_freq, "rings": run_rings, "sweep": run_sweep,
"output": run_output, "pairs": run_pairs}
handlers[args.command](board, args)
if __name__ == "__main__":