feat: add 16-ring bank, sampled XOR, pair capture and frequency counter (FPGA v2)
This commit is contained in:
parent
d51a6c0ca8
commit
504b00752a
12 changed files with 654 additions and 347 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 \"$@\""
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
47
fpga/rtl/freq_counter.v
Normal 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
|
||||
|
|
@ -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
36
fpga/rtl/ring_bank.v
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[*]}]
|
||||
|
|
|
|||
174
tools/capture.py
174
tools/capture.py
|
|
@ -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
296
tools/jitter.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue