diff --git a/devbox.json b/devbox.json index 2677082..fd03379 100644 --- a/devbox.json +++ b/devbox.json @@ -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": [ diff --git a/fpga/rtl/entropy_generator.v b/fpga/rtl/entropy_generator.v new file mode 100644 index 0000000..182e68b --- /dev/null +++ b/fpga/rtl/entropy_generator.v @@ -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 diff --git a/fpga/rtl/raw_capture.v b/fpga/rtl/raw_capture.v index c44d218..cdc3210 100644 --- a/fpga/rtl/raw_capture.v +++ b/fpga/rtl/raw_capture.v @@ -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 diff --git a/fpga/rtl/ring_bank.v b/fpga/rtl/ring_bank.v deleted file mode 100644 index ddccde2..0000000 --- a/fpga/rtl/ring_bank.v +++ /dev/null @@ -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 diff --git a/fpga/rtl/strix_core.v b/fpga/rtl/strix_core.v index cb24f38..d6051a8 100644 --- a/fpga/rtl/strix_core.v +++ b/fpga/rtl/strix_core.v @@ -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}}; diff --git a/fpga/sim/tb_strix_core.v b/fpga/sim/tb_strix_core.v index 7bbd795..9fc0796 100644 --- a/fpga/sim/tb_strix_core.v +++ b/fpga/sim/tb_strix_core.v @@ -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 diff --git a/fpga/src/strix.qsf b/fpga/src/strix.qsf index 88ed56d..5503f95 100644 --- a/fpga/src/strix.qsf +++ b/fpga/src/strix.qsf @@ -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 diff --git a/fpga/src/strix.sdc b/fpga/src/strix.sdc index 56a60e5..86c4c9c 100644 --- a/fpga/src/strix.sdc +++ b/fpga/src/strix.sdc @@ -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[*]}] diff --git a/tools/jitter.py b/tools/jitter.py index c0f75cb..e635b3e 100644 --- a/tools/jitter.py +++ b/tools/jitter.py @@ -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__":