Three findings from the report need RTL, not paperwork. The event gets a toggle synchroniser, the status word gets a Gray-coded crossing, and the configuration domain gets its own reset synchroniser. Each fix below is the standard pattern from its own section — linked, not re-taught — applied to the same module names, peri_top and rst_sync, that the brief introduced.
The engine flips eng_toggle on every event instead of emitting a pulse, the level crosses through a two-flop synchroniser in the configuration domain (tg_a, tg_b), and an edge-detect register (tg_c) with an XOR (tg_b ^ tg_c) rebuilds one clean pulse per flip. The level persists until the next event, so no destination edge can miss it. The rule that makes it safe is spacing: events arrive every 16 engine cycles, which is 160 ns, while two configuration periods are 54 ns — so consecutive flips can never merge inside one destination period. The full scheme and its re-toggle rule are taught in the toggle synchroniser lesson with the spacing limits beside it.
The engine Gray-encodes the counter (eng_count ^ (eng_count >> 1)), the Gray word crosses through two flops per bit, and the configuration domain decodes it back to binary. This works because the counter is monotonic: it advances by one, so the Gray word changes exactly one bit per step, and a synchroniser sampling mid-change can only deliver the old or the new value — both genuine. A binary count changing from 7 to 8 flips all four bits and could sample as anything; the Gray version cannot tear. Why per-bit synchronisation fails on plain binary is the subject of the bus tearing lesson.
The second rst_sync instance, u_rst_cfg, gives the configuration domain what the engine domain already had: asynchronous assert with release synchronised to its own clock. Every flop in the design now releases on its own clock edge, so no domain wakes half-reset. The required structure and the deassertion hazard it removes are covered in the reset synchroniser lesson. The threshold capture is handled in Fix 4 below, because it is the one crossing that is not fixed with a synchroniser.
The threshold crosses from clk_cfg to clk_eng as a 4-bit bus, which a plain synchroniser cannot protect. Instead of waiving a promise about software, the design makes the promise true. Writes are accepted only while the engine is fully stopped (cfg_run low and the engine's run state, synchronised back through two flops, low). The run request cannot start until two quiet cycles after the last write, so the threshold has been stable for at least two configuration periods before the engine copies it. The engine copies it exactly once per run, at start, and ignores writes while running. The bus is therefore stable whenever the engine samples it, which is the argument the waiver in the next lesson relies on. This is the pattern from the quasi-static lessons: a run handshake around a configuration bus.
The listing below is self-contained: both synchroniser-correct modules plus a testbench that exercises each crossing deterministically — fixed clock periods (10 ns engine, 27 ns configuration with staggered edges), a fixed threshold pattern, PASS/FAIL reporting, $finish, and a watchdog. It checks four things: outputs stay zero under reset, every sampled status word is a value the engine genuinely produced (the no-tear check against recent engine history), a write attempted while the engine runs is ignored and the threshold never changes while it runs, and after the run stops and every event drains, the number of events delivered equals the number launched (none lost, none doubled).
`timescale 1ns/1ps
module rst_sync (
input wire clk, input wire rst_n_async, output wire rst_n_sync
);
reg [1:0] sh;
always @(posedge clk or negedge rst_n_async) begin
if (!rst_n_async) sh <= 2'b00;
else sh <= {sh[0], 1'b1};
end
assign rst_n_sync = sh[1];
endmodule
module peri_top (
input wire clk_cfg, input wire clk_eng, input wire rst_n_async,
input wire cfg_wr, input wire [3:0] cfg_wdata, input wire cfg_run,
output wire [3:0] status_cfg, output wire event_cfg
);
wire rst_n_eng, rst_n_cfg;
rst_sync u_rst_eng (.clk(clk_eng), .rst_n_async(rst_n_async), .rst_n_sync(rst_n_eng));
rst_sync u_rst_cfg (.clk(clk_cfg), .rst_n_async(rst_n_async), .rst_n_sync(rst_n_cfg));
reg [3:0] cfg_thresh_q;
reg run_cfg, wr_q, ack_a, ack_b; // clk_cfg side of the run handshake
reg run_a, run_b; // run request synchronised into clk_eng
reg [3:0] eng_count, eng_thresh;
reg eng_toggle;
reg tg_a, tg_b, tg_c;
reg [3:0] gr_a, gr_b;
wire [3:0] eng_gray = eng_count ^ (eng_count >> 1);
wire idle_cfg = ~run_cfg & ~ack_b; // engine fully stopped, as seen from clk_cfg
// Quasi-static threshold by construction: writes are accepted only while the engine is
// stopped, and run can start only after two quiet cycles without a write.
always @(posedge clk_cfg or negedge rst_n_cfg) begin
if (!rst_n_cfg) begin
cfg_thresh_q <= 4'h0; run_cfg <= 1'b0; wr_q <= 1'b0; ack_a <= 1'b0; ack_b <= 1'b0;
end else begin
wr_q <= cfg_wr;
if (cfg_wr && idle_cfg) cfg_thresh_q <= cfg_wdata;
run_cfg <= cfg_run & ~cfg_wr & ~wr_q;
ack_a <= run_b; // engine run state, synchronised back (two flops)
ack_b <= ack_a;
end
end
always @(posedge clk_eng or negedge rst_n_eng) begin
if (!rst_n_eng) begin
run_a <= 1'b0; run_b <= 1'b0;
eng_count <= 4'h0; eng_thresh <= 4'h0; eng_toggle <= 1'b0;
end else begin
run_a <= run_cfg; // two-flop synchroniser for the run request
run_b <= run_a;
if (run_a & ~run_b) eng_thresh <= cfg_thresh_q; // one capture per run, threshold is stable
if (run_b) begin
eng_count <= (eng_count == 4'hF) ? 4'h0 : eng_count + 4'h1;
if (eng_count == eng_thresh) eng_toggle <= ~eng_toggle;
end
end
end
always @(posedge clk_cfg or negedge rst_n_cfg) begin
if (!rst_n_cfg) begin tg_a <= 1'b0; tg_b <= 1'b0; tg_c <= 1'b0; end
else begin tg_a <= eng_toggle; tg_b <= tg_a; tg_c <= tg_b; end
end
assign event_cfg = tg_b ^ tg_c;
always @(posedge clk_cfg or negedge rst_n_cfg) begin
if (!rst_n_cfg) begin gr_a <= 4'h0; gr_b <= 4'h0; end
else begin gr_a <= eng_gray; gr_b <= gr_a; end
end
assign status_cfg[3] = gr_b[3];
assign status_cfg[2] = gr_b[3] ^ gr_b[2];
assign status_cfg[1] = gr_b[3] ^ gr_b[2] ^ gr_b[1];
assign status_cfg[0] = gr_b[3] ^ gr_b[2] ^ gr_b[1] ^ gr_b[0];
endmodule
One proof is never enough for a crossing: structure says the right circuit is there, assertions say its rules hold on every run, and simulation says it behaves on the tested scenarios. The table names all three per fix, so the gate can check each column independently.
Fix | Structural | Assertion | Simulation (in the testbench above) |
|---|---|---|---|
Toggle event (F1) | Source toggle flop, two configuration flops with no logic between, edge detect on the last flop only | One destination pulse per toggle; minimum event spacing of two configuration periods | Events launched in the engine domain equal events delivered in the configuration domain after a fixed drain time — none lost, none doubled |
Gray status (F2) | Gray encode at source, two flops per bit, binary decode after the last flop; source monotonic | Every sampled status word is a value the engine produced (history-membership check) | Membership check on every configuration edge after reset: any torn word fails the run |
Per-domain reset synchronisers (F3) | One reset synchroniser per domain; every flop releases on its own clock | Release aligned to the domain clock; no flop releases on the asynchronous edge | Outputs read zero under asserted reset; staggered per-domain release before counting starts |
Threshold capture (F4, waived) | Direct capture with a run handshake: writes accepted only while the engine is stopped, one capture per run (no synchroniser by intent) | Threshold never changes while the engine runs (the testbench monitor is the runnable form) | A write attempted while running is ignored and the monitor stays quiet; argued in full in the signoff lesson |
A fixed time window can be off by one at its boundaries (a flip in flight at the start or end), so a correct design could fail or a faulty one pass by luck. Counting launches in the source domain and deliveries in the destination domain after the source stops removes the boundary: fewer delivered than launched means an event was lost (spacing or toggle fault); more means a double delivery (edge-detect or re-toggle fault).
No — it proves the tested scenarios behave. Safety comes from the other two columns: the structure guarantees delivery across all phases, and the assertions re-check the rules on every future run. Simulation is the weakest of the three proofs, kept because it is the most readable.
Extend it: halve the configuration period in the testbench (faster destination) and re-run — it still passes, showing the spacing margin. Then, in a scratch copy, swap the toggle path back to the first version's plain two-flop event synchroniser and observe what the count check reports. Explain the number from the 10 ns pulse width against the destination period — and if the run still passes, explain why that pass proves nothing: the phase luck from the first lesson's trap, reproduced on demand.