ft2232h: add frame drop counter (AUDIT-C12) + cfar RMW cadence guard (AUDIT-S22)

AUDIT-C12: usb_data_interface_ft2232h had a misleading single-buffer comment
that overstated the timing slack and referenced a frame_ack_toggle CDC that
was never implemented. Re-verified actual numbers: at 178 fps the slack is
1.14 ms (20%), not "much shorter than gap". No data corruption today (write
order matches read order, addresses don't collide), but frame_complete
firing while WR_FSM is still draining the previous frame causes silent
frame drops via the missed frame_ready_toggle edge.

Fix is instrumentation, not architectural rework: add wr_done_toggle
(ft_clk -> clk CDC) on WR_DONE -> WR_IDLE, track frame_pending in clk
domain, count drops in 7-bit saturating frame_drop_count, surface in
unused upper 7 bits of status_words[5]. Host now has visibility into the
failure mode if margin ever shrinks (faster frame rate or USB bandwidth
shortfall). Replaced misleading comment with corrected timing breakdown.

AUDIT-S22: cfar_ca emits one detection per 3 cycles (THR/MUL/CMP); the
detection RMW takes 3 cycles. Match by construction today, fragile against
any CFAR speedup. Added a header comment in cfar_ca.v documenting the
dependency, and a SIMULATION-only assertion in usb_data_interface_ft2232h.v
that fires [ASSERT FAIL] AUDIT-S22 if cfar_valid arrives while RMW busy.
Catches silent-drop regressions in the test suite.

Verification: new tb_ft2232h_frame_drop.v with 5 scenarios (no drops /
stalled drops / multi-drop / recovery / saturation at 127) - 10/10 PASS.
Quick regression 31/31 PASS (was 30/30; +1 new test, 0 regressions).
This commit is contained in:
Jason
2026-04-29 17:51:30 +05:45
parent 0c82de54a2
commit e67368d621
4 changed files with 416 additions and 17 deletions
+15
View File
@@ -51,6 +51,21 @@
* = 0.55 ms. Frame period @ PRF=1932 Hz, 32 chirps = 16.6 ms. Fits easily.
* (3 cycles per CUT due to pipeline: THR MUL CMP)
*
* AUDIT-S22 DOWNSTREAM CADENCE DEPENDENCY (DO NOT BREAK):
* detect_valid pulses every 3rd cycle (one per CUT triplet). The downstream
* consumer usb_data_interface_ft2232h.v runs a 3-cycle read-modify-write
* on the detection-flag BRAM (idle read-wait write-back) and silently
* drops cfar_valid arriving while RMW is busy. The two cadences match
* today by construction.
*
* If you optimize this pipeline below 3 cycles per CUT (e.g., merging
* ST_CFAR_MUL+CMP into a single state, or feeding the comparator
* combinationally), you MUST also pipeline the RMW in
* usb_data_interface_ft2232h.v to keep up otherwise every Nth
* detection is silently lost. A SIMULATION-only assertion in that
* module fires `[ASSERT FAIL] AUDIT-S22: cfar_valid arrived while RMW
* busy` to catch this regression in the test suite.
*
* Resources:
* - 1 BRAM36K for magnitude buffer (16384 x 17 bits)
* - 1 DSP48 for alpha multiply
+4
View File
@@ -550,6 +550,10 @@ run_test "FFT AXI Bridge tready handshake (AUDIT-C10)" \
tb/tb_fft_engine_axi_bridge.vvp \
tb/tb_fft_engine_axi_bridge.v fft_engine_axi_bridge.v
run_test "FT2232H Frame Drop Counter (AUDIT-C12)" \
tb/tb_ft2232h_frame_drop.vvp \
tb/tb_ft2232h_frame_drop.v usb_data_interface_ft2232h.v
echo ""
# ===========================================================================
@@ -0,0 +1,283 @@
`timescale 1ns / 1ps
`include "radar_params.vh"
// ============================================================================
// tb_ft2232h_frame_drop.v — verifies AUDIT-C12 frame_drop_count instrumentation
// ============================================================================
// The bridge logic added under AUDIT-C12 surfaces silent USB frame drops via
// a 7-bit `frame_drop_count` register exposed in `status_words[5][31:25]`.
// Tracking handshake:
// - clk-domain frame_pending: SET on frame_complete, CLEARED by wr_done_pulse
// - ft_clk-domain wr_done_toggle: flips on WR_DONE → WR_IDLE (frame fully sent)
// - 3-stage CDC syncs wr_done_toggle into clk for edge-detect
//
// Test cases:
// 1. Single frame, USB drains promptly → drop count stays 0
// 2. Two back-to-back frame_complete with USB stalled → drop count = 1
// 3. Multiple drops while stalled → drop count saturates at 127
// 4. Stalled + recovery → drop count stable, frame_pending clears post-drain
//
// Stimulus uses `stream_control = 6'b001_000` (mag_only=1, no sections enabled)
// so the WR FSM goes HDR (8B) → FOOTER (1B) → DONE in 9 ft_clk cycles. This
// gives a fast, deterministic per-frame transfer time. AUDIT-C9 sim assertion
// is satisfied (mag_only=1, sparse_det=0).
//
// PASS criteria:
// - frame_drop_count matches expected value after each scenario
// - frame_pending tracks correctly across handshake
// - wr_done_toggle observed to flip on each successful drain
// ============================================================================
module tb_ft2232h_frame_drop;
localparam CLK_PER = 10.0; // 100 MHz
localparam FT_CLK_PER = 16.667; // 60 MHz
reg clk = 1'b0;
reg ft_clk = 1'b0;
reg reset_n = 1'b0;
reg ft_reset_n = 1'b0;
// Radar data inputs (clk domain) - all idle for this test
reg [31:0] range_profile = 32'd0;
reg range_valid = 1'b0;
reg [15:0] doppler_real = 16'd0;
reg [15:0] doppler_imag = 16'd0;
reg doppler_valid = 1'b0;
reg cfar_detection = 1'b0;
reg cfar_valid = 1'b0;
reg [`RP_RANGE_BIN_WIDTH_MAX-1:0] range_bin_in = 0;
reg [4:0] doppler_bin_in = 5'd0;
reg frame_complete = 1'b0;
// FT2232H interface (ft_clk domain)
wire [7:0] ft_data;
reg ft_rxf_n = 1'b1; // No host read commands
reg ft_txe_n = 1'b0; // 0 = USB ready (default)
wire ft_rd_n;
wire ft_wr_n;
wire ft_oe_n;
wire ft_siwu;
wire [31:0] cmd_data;
wire cmd_valid;
wire [7:0] cmd_opcode;
wire [7:0] cmd_addr;
wire [15:0] cmd_value;
// mag_only=1, sparse_det=0, all sections disabled (skip range/doppler/cfar)
// WR FSM: HDR FOOTER DONE = fast deterministic drain
reg [5:0] stream_control = 6'b001_000;
// Status inputs (irrelevant for this test)
reg status_request = 1'b0;
reg [15:0] status_cfar_threshold = 16'd0;
reg [5:0] status_stream_ctrl = 6'b001_000;
reg [1:0] status_radar_mode = 2'd0;
reg [15:0] status_long_chirp = 16'd0;
reg [15:0] status_long_listen = 16'd0;
reg [15:0] status_guard = 16'd0;
reg [15:0] status_short_chirp = 16'd0;
reg [15:0] status_short_listen = 16'd0;
reg [5:0] status_chirps_per_elev = 6'd0;
reg [1:0] status_range_mode = 2'd0;
reg status_chirps_mismatch = 1'b0;
reg [4:0] status_self_test_flags = 5'd0;
reg [7:0] status_self_test_detail = 8'd0;
reg status_self_test_busy = 1'b0;
reg [3:0] status_agc_current_gain = 4'd0;
reg [7:0] status_agc_peak_magnitude = 8'd0;
reg [7:0] status_agc_saturation_count = 8'd0;
reg status_agc_enable = 1'b0;
integer pass = 0;
integer fail = 0;
always #(CLK_PER/2) clk = ~clk;
always #(FT_CLK_PER/2) ft_clk = ~ft_clk;
usb_data_interface_ft2232h u_dut (
.clk(clk),
.reset_n(reset_n),
.ft_reset_n(ft_reset_n),
.range_profile(range_profile),
.range_valid(range_valid),
.doppler_real(doppler_real),
.doppler_imag(doppler_imag),
.doppler_valid(doppler_valid),
.cfar_detection(cfar_detection),
.cfar_valid(cfar_valid),
.range_bin_in(range_bin_in),
.doppler_bin_in(doppler_bin_in),
.frame_complete(frame_complete),
.ft_data(ft_data),
.ft_rxf_n(ft_rxf_n),
.ft_txe_n(ft_txe_n),
.ft_rd_n(ft_rd_n),
.ft_wr_n(ft_wr_n),
.ft_oe_n(ft_oe_n),
.ft_siwu(ft_siwu),
.ft_clk(ft_clk),
.cmd_data(cmd_data),
.cmd_valid(cmd_valid),
.cmd_opcode(cmd_opcode),
.cmd_addr(cmd_addr),
.cmd_value(cmd_value),
.stream_control(stream_control),
.status_request(status_request),
.status_cfar_threshold(status_cfar_threshold),
.status_stream_ctrl(status_stream_ctrl),
.status_radar_mode(status_radar_mode),
.status_long_chirp(status_long_chirp),
.status_long_listen(status_long_listen),
.status_guard(status_guard),
.status_short_chirp(status_short_chirp),
.status_short_listen(status_short_listen),
.status_chirps_per_elev(status_chirps_per_elev),
.status_range_mode(status_range_mode),
.status_chirps_mismatch(status_chirps_mismatch),
.status_self_test_flags(status_self_test_flags),
.status_self_test_detail(status_self_test_detail),
.status_self_test_busy(status_self_test_busy),
.status_agc_current_gain(status_agc_current_gain),
.status_agc_peak_magnitude(status_agc_peak_magnitude),
.status_agc_saturation_count(status_agc_saturation_count),
.status_agc_enable(status_agc_enable)
);
task pulse_frame_complete;
begin
@(posedge clk); #1;
frame_complete = 1'b1;
@(posedge clk); #1;
frame_complete = 1'b0;
end
endtask
task wait_cycles;
input integer n;
integer i;
begin
for (i = 0; i < n; i = i + 1) @(posedge clk);
end
endtask
task check;
input integer test_id;
input [127:0] label;
input integer expected;
input integer actual;
begin
if (expected == actual) begin
$display("[PASS] Test %0d (%0s): %0d == %0d",
test_id, label, actual, expected);
pass = pass + 1;
end else begin
$display("[FAIL] Test %0d (%0s): got %0d, expected %0d",
test_id, label, actual, expected);
fail = fail + 1;
end
end
endtask
initial begin
$display("===========================================================");
$display("tb_ft2232h_frame_drop AUDIT-C12 frame_drop_count regression");
$display("===========================================================");
// Reset both domains
repeat (4) @(posedge clk);
reset_n = 1'b1;
ft_reset_n = 1'b1;
wait_cycles(20);
// -----------------------------------------------------------
// Test 1: Normal flow. ft_txe_n=0 (USB ready).
// Single frame_complete; expect drain to complete and drop count=0.
// -----------------------------------------------------------
$display("\n[TEST 1] Single frame, USB ready -> no drops");
ft_txe_n = 1'b0;
pulse_frame_complete();
// Wait for frame to drain through WR_FSM. With mag_only mode and
// stream_control[2:0]=000, FSM goes HDR (8B) -> FOOTER (1B) -> DONE.
// Each byte = 1 ft_clk cycle. Plus CDC latency. Allow ~50 ft_clk
// = ~833 ns = ~83 clk cycles. Be generous: wait 200 clk cycles.
wait_cycles(200);
check(1, "drop_count", 0, u_dut.frame_drop_count);
check(1, "frame_pending_cleared", 0, u_dut.frame_pending);
// -----------------------------------------------------------
// Test 2: USB stalled, two frame_completes back-to-back.
// Expect drop_count = 1 (second pulse arrives while pending).
// -----------------------------------------------------------
$display("\n[TEST 2] USB stalled, 2 frame_completes -> drop count = 1");
ft_txe_n = 1'b1; // Stall USB
wait_cycles(10);
pulse_frame_complete(); // frame N: pending=1, no drop
wait_cycles(20); // give time for any spurious wr_done_pulse
pulse_frame_complete(); // frame N+1: pending was 1, drop count++
wait_cycles(20);
check(2, "drop_count_after_stall", 1, u_dut.frame_drop_count);
check(2, "frame_pending_still_set", 1, u_dut.frame_pending);
// -----------------------------------------------------------
// Test 3: Multiple drops while stalled.
// 3 more frame_completes -> drop count = 4 (1 from test 2 + 3 new).
// -----------------------------------------------------------
$display("\n[TEST 3] 3 more frames during stall -> drop count = 4");
pulse_frame_complete(); wait_cycles(15);
pulse_frame_complete(); wait_cycles(15);
pulse_frame_complete(); wait_cycles(15);
check(3, "drop_count_after_3_more", 4, u_dut.frame_drop_count);
// -----------------------------------------------------------
// Test 4: Recovery. Release USB, FSM drains, pending clears.
// Then a new frame_complete should NOT increment drop count.
// -----------------------------------------------------------
$display("\n[TEST 4] USB recovers, drain completes, no new drop");
ft_txe_n = 1'b0; // USB ready
wait_cycles(200); // Allow drain
check(4, "frame_pending_cleared_after_drain", 0, u_dut.frame_pending);
check(4, "drop_count_stable_after_drain", 4, u_dut.frame_drop_count);
// Now a clean frame_complete should add no drop
pulse_frame_complete();
wait_cycles(200);
check(4, "drop_count_unchanged_clean_frame", 4, u_dut.frame_drop_count);
check(4, "frame_pending_cleared_post_clean", 0, u_dut.frame_pending);
// -----------------------------------------------------------
// Test 5: Saturation at 127. Stall USB and pulse frame_complete
// many times. drop_count should saturate, not wrap.
// -----------------------------------------------------------
$display("\n[TEST 5] Saturation: 200 drops requested -> count saturates at 127");
ft_txe_n = 1'b1;
// First make pending=1 (this isn't a drop)
pulse_frame_complete(); wait_cycles(10);
// Now 199 more frame_completes — each is a drop after the first counted earlier
// We're at drop_count=4 from prior tests, plus this new sequence will drive it up.
// After ~130 more pulses, should saturate at 127.
begin: sat_loop
integer k;
for (k = 0; k < 200; k = k + 1) begin
pulse_frame_complete();
wait_cycles(5);
end
end
check(5, "drop_count_saturated", 127, u_dut.frame_drop_count);
$display("\n-----------------------------------------------------------");
$display("RESULTS: %0d PASS, %0d FAIL", pass, fail);
$display("-----------------------------------------------------------");
if (fail == 0)
$display("[OVERALL PASS]");
else
$display("[OVERALL FAIL]");
$finish;
end
initial begin
#(CLK_PER * 50000);
$display("[FATAL] Global timeout");
$finish;
end
endmodule
+114 -17
View File
@@ -204,6 +204,12 @@ localparam [3:0] WR_IDLE = 4'd0,
reg [3:0] wr_state;
// AUDIT-C12 instrumentation: ft_clk → clk handshake. Toggles when WR_FSM
// completes a successful frame transfer (WR_DONE → WR_IDLE). Lets the clk
// domain detect frame drops (frame_complete arrives while previous transfer
// is still in flight). See full analysis in the clk-domain block below.
reg wr_done_toggle;
// ============================================================================
// READ FSM STATES (Host → FPGA, ft_clk domain — unchanged from legacy)
// ============================================================================
@@ -484,25 +490,76 @@ always @(posedge clk or negedge reset_n) begin
range_write_counter <= {RANGE_BIN_BITS{1'b0}};
end
// === Resume filling after ft_clk domain acknowledges frame transfer ===
// (handled below via frame_ack_toggle CDC)
// For simplicity, we resume filling immediately — the USB transfer reads
// from the same BRAM while new data writes. Since the Doppler burst
// (0.164 ms) is much shorter than the inter-frame gap (5.6 ms), and USB
// transfer (0.875 ms) also fits within the gap, there's no overlap:
// t=0: frame_complete pulse (1 clk cycle, from edge detector)
// t=0-0.87ms: USB reads BRAM (old frame data is stable)
// t=5.6ms: next Doppler burst starts writing
// So single-buffer is safe. Re-enable filling after a short delay.
// === Resume filling after frame_complete ===
// AUDIT-C12 timing analysis (corrected from earlier comment):
// - Frame period (178 fps): 5.62 ms
// - Doppler emit window: ~0.5 ms at end of frame (16384 cells × 1
// emission/cycle + per-range-bin 16-pt FFT compute, ~50K cycles)
// - USB transfer (Hi-Speed bulk @ 8 MB/s): 35849 bytes / 8 MB/s
// = 4.48 ms (NOT 0.875 ms as the original comment claimed)
// - Slack at 178 fps: 5.62 4.48 = 1.14 ms (~20%)
//
// Write/read order BOTH advance range_bin slowest, doppler_bin fastest;
// FPGA write of frame N+1 starts at addr 0 while USB read of frame N is
// near addr 16383, so the brief overlap (~0.16 ms at end of WR_DOPPLER)
// never collides on the same address. No data corruption today.
//
// Real failure mode: at higher frame rates (or USB bandwidth shortfalls),
// frame_complete N+1 may fire while WR_FSM is still draining frame N.
// frame_ready_toggle's edge in the ft_clk domain is missed unless WR_FSM
// is in WR_IDLE — frame N+1 silently dropped. The frame_drop_count
// counter below makes this loud.
//
// Filling continues immediately so the next frame's data is captured
// (it goes to the same BRAM, possibly stomping on stale frame N data
// — but stale-stomp is fine: USB has either read those addresses
// already, or it hasn't and will read frame N+1's data at those
// addresses, which is what the host wants when frames drop).
if (!frame_filling && !frame_complete) begin
// Re-enable after 1 cycle (frame_complete already deasserted)
frame_filling <= 1'b1;
detect_clearing <= 1'b1; // Clear detection BRAM for next frame
frame_filling <= 1'b1;
detect_clearing <= 1'b1; // Clear detection BRAM for next frame
detect_clear_addr <= 14'd0;
end
end
end
// ============================================================================
// AUDIT-C12: frame_pending + frame_drop_count (clk domain)
// ============================================================================
// Tracks whether a frame is queued for USB transfer and counts dropped frames
// (frame_complete fires while previous frame still in WR_FSM transit). The
// counter is exposed in status_words[5] for host visibility.
//
// CDC: wr_done_toggle (ft_clk) flips on every WR_DONE → WR_IDLE; we 3-stage
// sync into clk and edge-detect to clear frame_pending.
// ============================================================================
reg frame_pending;
reg [6:0] frame_drop_count; // 7-bit, saturates at 127
(* ASYNC_REG = "TRUE" *) reg [2:0] wr_done_sync;
reg wr_done_prev;
wire wr_done_pulse = wr_done_sync[2] ^ wr_done_prev;
always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin
frame_pending <= 1'b0;
frame_drop_count <= 7'd0;
wr_done_sync <= 3'b000;
wr_done_prev <= 1'b0;
end else begin
wr_done_sync <= {wr_done_sync[1:0], wr_done_toggle};
wr_done_prev <= wr_done_sync[2];
if (frame_complete) begin
if (frame_pending && frame_drop_count != 7'd127)
frame_drop_count <= frame_drop_count + 7'd1;
frame_pending <= 1'b1;
end else if (wr_done_pulse) begin
frame_pending <= 1'b0;
end
end
end
// ============================================================================
// TOGGLE CDC: clk (100 MHz) → ft_clk (60 MHz)
// ============================================================================
@@ -533,6 +590,10 @@ wire status_req_ft = status_toggle_sync[2] ^ status_toggle_prev;
(* ASYNC_REG = "TRUE" *) reg [5:0] stream_ctrl_sync_0;
(* ASYNC_REG = "TRUE" *) reg [5:0] stream_ctrl_sync_1;
// --- AUDIT-C12: frame_drop_count CDC (slow-changing 7-bit value, 2-stage sync) ---
(* ASYNC_REG = "TRUE" *) reg [6:0] frame_drop_sync_0;
reg [6:0] frame_drop_sync_1;
wire stream_range_en = stream_ctrl_sync_1[0];
wire stream_doppler_en = stream_ctrl_sync_1[1];
wire stream_cfar_en = stream_ctrl_sync_1[2];
@@ -645,6 +706,8 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin
status_toggle_prev <= 1'b0;
stream_ctrl_sync_0 <= `RP_STREAM_CTRL_DEFAULT;
stream_ctrl_sync_1 <= `RP_STREAM_CTRL_DEFAULT;
frame_drop_sync_0 <= 7'd0;
frame_drop_sync_1 <= 7'd0;
for (si = 0; si < 6; si = si + 1)
status_words[si] <= 32'd0;
wr_state <= WR_IDLE;
@@ -671,6 +734,7 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin
cmd_opcode <= 8'd0;
cmd_addr <= 8'd0;
cmd_value <= 16'd0;
wr_done_toggle <= 1'b0;
end else begin
cmd_valid <= 1'b0;
@@ -684,6 +748,10 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin
stream_ctrl_sync_0 <= stream_control;
stream_ctrl_sync_1 <= stream_ctrl_sync_0;
// AUDIT-C12: frame_drop_count CDC (clk → ft_clk for status read)
frame_drop_sync_0 <= frame_drop_count;
frame_drop_sync_1 <= frame_drop_sync_0;
// Status snapshot on request
if (status_req_ft) begin
// Word 0: {0xFF, mode[1:0], stream[5:0], threshold[15:0]}
@@ -700,7 +768,11 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin
status_chirps_mismatch, // [10] TX-G mismatch flag
8'd0, // [9:2] reserved
status_range_mode}; // [1:0]
status_words[5] <= {7'd0, status_self_test_busy,
// AUDIT-C12: frame_drop_count exposed at status_words[5][31:25]
// (was 7'd0 reserved). Saturates at 127. Counts frame_complete
// events that arrived while previous frame was still in WR_FSM
// transit (silent frame drop indicator for host visibility).
status_words[5] <= {frame_drop_sync_1, status_self_test_busy,
8'd0, status_self_test_detail,
3'd0, status_self_test_flags};
end
@@ -977,9 +1049,10 @@ always @(posedge ft_clk or negedge ft_effective_reset_n) begin
end
WR_DONE: begin
ft_wr_n <= 1'b1;
ft_data_oe <= 1'b0;
wr_state <= WR_IDLE;
ft_wr_n <= 1'b1;
ft_data_oe <= 1'b0;
wr_done_toggle <= ~wr_done_toggle; // AUDIT-C12: signal frame transfer complete to clk domain
wr_state <= WR_IDLE;
end
default: wr_state <= WR_IDLE;
@@ -1070,4 +1143,28 @@ always @(posedge clk) begin
end
`endif
// ============================================================================
// AUDIT-S22: cfar_valid-vs-RMW-busy checker (simulation only)
//
// Detection RMW (idle→read→write-back) takes 3 cycles. cfar_ca emits one
// detect_valid pulse per 3 cycles (THR/MUL/CMP pipeline). They match by
// construction today — line 469 silently rejects cfar_valid arriving when
// detect_rmw_state != 0, which never fires at the current cadence.
//
// If cfar_ca is ever optimized to <3-cycle cadence (e.g., merging MUL+CMP,
// flagged as a possible target in cfar_ca.v), the silent rejection becomes
// a silent detection-drop. This assertion makes that violation loud, so the
// regression suite catches the coupling on the day someone speeds CFAR up
// without also pipelining the RMW. Synthesis-inert.
// ============================================================================
`ifdef SIMULATION
always @(posedge clk) begin
if (reset_n && cfar_valid && frame_filling && !detect_clearing &&
detect_rmw_state != 2'd0) begin
$display("[ASSERT FAIL] AUDIT-S22: cfar_valid arrived while RMW busy (state=%0d) detection at range_bin=%0d doppler_bin=%0d dropped",
detect_rmw_state, range_bin_in, doppler_bin_in);
end
end
`endif
endmodule