Pulse synchronizer

A one-cycle pulse carried into another clock domain. A toggle flop makes it a level, two flops carry it across, and a flop and an XOR make it a pulse again.

Level
rtl
Code
pulse-synchronizer
Checked
Verilator 5.052, Icarus Verilog 13.0, Yosys 0.66
Published
Authorship
Drafted with AI assistance
Status
In progress — published so it can be reviewed, not finished.

A pulse synchronizer carries a one-cycle pulse from one clock domain into another, where it arrives as a one-cycle pulse of the receiving clock. It cannot carry the pulse itself, because a pulse one clk_a period wide can fall entirely between two edges of clk_b. So a toggle flop turns each pulse into a change of level, two flops carry the level across, and a third flop and an XOR turn each change back into a pulse.

pulse_a 0clk_a 0rst_a_n 1clk_b 1rst_b_n 1line 28DQrst1DQrst1DQrst1DQrst1pulse_b 0togglemetasyncsync_d

Run plays this scenario from the start; driving an input continues from here.

A circuit that runs: a schematic and its waveform on one time axis. Inputs: pulse_a, rst_a_n, rst_b_n. Outputs: pulse_b. clk_a runs at 8 ticks a period. clk_b runs at 10 ticks a period. It is built from 2 XOR gates, 4 D flip-flops. The waveform shows clk_a, rst_a_n, pulse_a, toggle, clk_b, meta, sync, sync_d, pulse_b.

Figure 1. Press Run. pulse_a is high for one clk_a period, between two clk_b edges; toggle keeps the change as a level, and pulse_b is high for one clk_b period from the second clk_b edge after the change.

How it works, scenario by scenario

clk_a's domain is one flop, at the top of the drawing: its output, toggle, comes back through an XOR with pulse_a, so it flips at every clk_a edge that finds pulse_a high. The rest is clk_b's: three flops, meta, sync and sync_d, and an XOR of the last two that drives the lamp pulse_b. The waveform's grid lines are clk_a's rising edges, at 4, 12, 20 and 28; clk_b rises at 3, 13, 23 and 33, never on a grid line, so follow its row for its edges. Both resets are released at tick 1; rst_b_n has no row, and its pad shows it high.

In One pulse, the default scenario, pulse_a is high from 4 to 12, one clk_a period. Look along the clk_b row: it rises at 3, just before, and at 13, just after, and nowhere in between. A flop on clk_b that sampled pulse_a itself would find 0 at 3 and 0 at 13, and to that flop the pulse would never have happened. So the pulse becomes a level first. The clk_a edge at 4 still samples the 0 that pulse_a held just before it, so toggle stays 0. The edge at 12 samples its 1 and flips toggle from 0 to 1, and toggle holds that 1 until the next pulse, however long clk_b takes to see it.

clk_b's edge at 13 copies the new 1 into meta, and its edge at 23 copies it into sync. From 23, sync is 1 while sync_d, which is sync one clk_b period ago, still holds 0, so their XOR, pulse_b, goes high. At 33 sync_d catches up and pulse_b falls. The window ends at tick 33, so sync_d's rise and pulse_b's fall show only as the last tick of their rows: read the rows as pulse_b high from 23 to 33, exactly one clk_b period. pulse_b rises at the second clk_b edge after the clk_a edge that sent the pulse.

The clk_b edge at 13 comes one tick after toggle changed. The two clocks are unrelated, so in silicon that change can land inside meta's setup-and-hold window. meta may then go metastable and settle either way. meta and sync are the two-flop synchronizer, which gives meta a clk_b period to settle. The toggle scheme survives either outcome, because what crosses is a level and the level stays. A meta that misses the change at 13 takes it at 23, and pulse_b arrives one clk_b period later, at 33 instead of 23: late, never lost.

Too close sends the same pulse, then holds pulse_a high from 36 to 52, two clk_a periods. To the toggle flop that is two pulses back to back, since the edges at 44 and 52 both find it high. toggle falls to 0 at 44, one tick after clk_b's edge at 43, and rises to 1 again at 52, one tick before the edge at 53. Both changes fall between the same two clk_b edges, both edges see a 1, and neither pulse crosses: meta holds its 1 and pulse_b its 0 to the end.

Reset one side sends the same pulse, then pulls rst_a_n low from 38 to 40, after the pulse has crossed and while toggle is 1. The reset is asynchronous, so toggle drops to 0 at 38 without waiting for clk_a. To clk_b's flops that fall is a change of level like any other: meta takes it at 43, sync at 53, and pulse_b is high from 53 to 63, one clk_b period, for a pulse nobody sent on pulse_a.

The code

pulse_synchronizer.sv
// A one-cycle pulse in clk_a's domain, carried into clk_b's as a one-cycle
// pulse there. A toggle flop turns each pulse into a change of level, two
// flops carry that level into clk_b's domain, and a third flop and an XOR
// turn each change back into a pulse.
//
// Reset style for this module: asynchronous, active low, one reset a
// domain: rst_a_n clears the toggle, rst_b_n the three flops after it. Each
// is released through its own domain's reset synchronizer, which this module
// leaves out.
module pulse_synchronizer (
    input  logic clk_a,
    input  logic rst_a_n,
    input  logic pulse_a,
    input  logic clk_b,
    input  logic rst_b_n,
    output logic pulse_b
);

  // Flips at every clk_a edge that finds pulse_a high. It is the only signal
  // that crosses, and it comes straight from a flop, so it changes once a
  // pulse and never glitches.
  logic toggle;

  always_ff @(posedge clk_a or negedge rst_a_n) begin
    if (!rst_a_n) begin
      toggle <= 1'b0;
    end else begin
      toggle <= toggle ^ pulse_a;
    end
  end

  // meta may go metastable and has a clk_b period to settle; sync_d is the
  // value sync had one clk_b period ago.
  logic meta, sync, sync_d;

  always_ff @(posedge clk_b or negedge rst_b_n) begin
    if (!rst_b_n) begin
      meta   <= 1'b0;
      sync   <= 1'b0;
      sync_d <= 1'b0;
    end else begin
      meta   <= toggle;
      sync   <= meta;
      sync_d <= sync;
    end
  end

  // High for one clk_b period after every change of the synchronized level.
  assign pulse_b = sync ^ sync_d;

endmodule

The reset style is stated at the top of the file: asynchronous, active low, and one reset a domain, rst_a_n for the toggle and rst_b_n for the three flops after it. Each is released through its own domain's reset synchronizer, which the module leaves out, and so does the drawing. The always_ff on clk_a is the flop at the top of the drawing, and toggle <= toggle ^ pulse_a is that flop and the XOR in front of it. The always_ff on clk_b is the three flops along the bottom, meta, sync and sync_d, each a line in the reset branch and a line after else, and the assign is the XOR that drives pulse_b. Only toggle crosses between the domains, and it comes straight from a flop, so it changes once a pulse, or at a reset, and never glitches.

Synthesis result

Yosys's generic synthesis turns pulse_synchronizer.sv into 6 cells, 4 of them flip-flops. The cells are Yosys's own gate library, not an FPGA's or a standard-cell library's: the count measures the logic, not the area of any chip.

Cells after synth -nofsm, by type
CellWhat it isCount
$_DFF_PN0_D flip-flop on the rising edge, active-low asynchronous reset to 04
$_XOR_2-input XOR2
Total6

Lint by Verilator 5.052, the testbench by Icarus Verilog 13.0, synthesis by Yosys 0.66.

Icarus replayed this page's three scenarios on this code, and all 1,584 values the waveform draws match.

The netlist before synthesis

The netlist of pulse_synchronizer.sv, drawn by netlistsvg 1.0.2, is 802 × 299 px, too large to read inline: open it on its own, where the browser can zoom.

Pitfalls

Sampling the pulse directly. One pulse is the reason the circuit exists: no clk_b edge falls while pulse_a is high. Here pulse_a is high for one clk_a period, shorter than a clk_b period, so a flop on clk_b fed with pulse_a catches the pulse only when a clk_b edge happens to land inside it, and nothing relates the phases of the two clocks. A two-flop synchronizer on the raw pulse is the same mistake with one more flop.

Pulses closer than two clk_b periods. Too close shows the limit: two changes of toggle between the same two clk_b edges, here 43 and 53, cancel, since clk_b sees only the level at its edges. At most other phases of clk_b an edge would fall between them and catch the 0, and the two pulses would merge into one pulse_b two clk_b periods long. Two pulses one clk_a cycle apart, as here, never arrive as two while clk_b is no faster than clk_a. They always do, at every phase, when the clk_a edges that sample them are at least two clk_b periods apart, three clk_a cycles at this page's clocks. Both are zero-delay results, as in the figure. In silicon a metastable meta can move a capture by one clk_b edge, so the spacing needs a margin on top.

A sender that may pulse more often needs more than this circuit, such as a handshake that tells it when it may send again.

Resetting one side alone. Reset one side pulls rst_a_n while toggle is 1, and pulse_b delivers a pulse nobody sent. Pulling rst_b_n alone, low over the same ticks, does it too: its three flops go to 0 while toggle still holds 1, meta takes that 1 again at 43, and pulse_b rises at 53 for one clk_b period. Reset both domains together, as the testbench does: it pulls both resets while toggle is 1 and checks that nothing comes out.

A rising-edge detector instead of the XOR. The last stage looks like the rising-edge detector, and it is tempting to write it as one, pulse_b = sync & ~sync_d. But toggle rises for one pulse and falls for the next, so every second pulse arrives as a falling edge of sync, and the AND drops it. The XOR is high after either edge. In the figure sync falls only in Reset one side, after a reset rather than a pulse, so no scenario shows the loss. The testbench does: it sends 151 pulses, and a copy with that line lost 75 of them, every second one.

What the figure does not model. The figure is zero-delay, and meta never goes metastable in it: meta takes each change of toggle at the first clk_b edge after the change. In silicon a change just before a clk_b edge may be taken one edge later than drawn, as the walk-through says, and one just after an edge may be taken by that edge, one edge earlier. Metastability itself is the two-flop synchronizer page's lesson.

Check yourself

Why is pulse_b the XOR of sync and sync_d, and not sync & ~sync_d as in a rising-edge detector?
Because every pulse on pulse_a is a change of toggle, and the changes alternate. The first pulse makes toggle rise, the next makes it fall, and sync follows it two clk_b edges later. An AND with ~sync_d is high only after a rise, so it drops every second pulse; the XOR is high for one clk_b period after a change either way.
Why does pulse_a not go straight into meta?
Because a pulse one clk_a period wide can begin and end between two clk_b edges, as it does in One pulse, high from 4 to 12 with clk_b rising at 3 and 13. A flop on clk_b that sampled pulse_a would find 0 at both edges. The toggle flop turns it into a level that stays until the next pulse, however long clk_b takes to look.
How far apart must two pulses on pulse_a be to arrive as two pulses on pulse_b?
To arrive as two at every phase of clk_b in simulation, the clk_a edges that sample them have to be at least two clk_b periods apart, which at this page's clocks is three clk_a cycles, and silicon needs a margin on top. Closer than two periods, a single clk_b edge can fall between the two changes of toggle, and the two pulses merge into one pulse_b two clk_b periods long. Closer than one clk_b period, it can happen that no edge falls between them at all, and then they cancel, as in Too close.

Tags rtl flip-flop clock-domain-crossing edge-detection

Found an error? Send a correction