Intermediate · Lesson 2 of 3

Writing constraints that match the hardware

Turn a datasheet and a schematic into XDC and SDC timing constraints, with a source-synchronous ADC interface worked through with real numbers

IntermediateAMDAltera45 minUpdated 13 Nov 2026Free until 31 Jan 2027

You will learn

  • Write create_clock and create_generated_clock commands for every clock that enters or is made inside a design
  • Derive set_input_delay and set_output_delay values from an ADC datasheet and the board layout
  • Separate unrelated clocks with set_clock_groups and state what that promise commits you to
  • Apply false-path and multicycle-path exceptions only where the RTL structure justifies them
  • Translate the same intent between Vivado XDC and Quartus Prime SDC

Before this: Static timing analysis, properly, registers clocks and state

A timing analyser knows every delay inside the chip and nothing about the world outside it. This lesson is for engineers who have read a timing report and now need to write the constraints that make the report mean something. It walks through one small design, a 12-bit source-synchronous ADC interface with a configuration port, and builds up its constraint file in both Vivado XDC and Quartus Prime SDC. Every number comes from a datasheet value or a board measurement, and you will see what the analyser does with each one. The theory of setup, hold and slack is in Static timing analysis and the formulae are collected in Timing formulae.

What the tools cannot know

After placement, the tool can compute the delay of every net and cell. What it cannot see is the oscillator on the board, the ADC that launches data from its own clock, or the fact that two clocks come from unrelated crystals. A constraint file supplies exactly that missing information, and nothing else. It answers three questions:

  • Which clocks exist, and how fast are they? The analyser needs a period for every clock pin, and a description of every clock that is divided or multiplied inside the device.
  • When does external data arrive and when must it leave? Input and output delays describe the device on the other end of each trace.
  • Which paths should not be analysed, or should be analysed differently? Clock groups, false paths and multicycle paths remove or relax checks that the RTL structure makes unnecessary.

A constraint is a statement about hardware, not a request to the tool. If a statement is wrong, the analyser will happily report a clean design that fails on the bench. Treat every line as a claim you could defend with a datasheet page or an oscilloscope trace.

Primary clocks: create_clock

The design has two clocks arriving at pins. A 100 MHz oscillator drives clk_sys, and the ADC drives adc_dco, its data clock output, at 100 MHz. Both are declared on the port, and the analyser propagates them through buffers and into every register they reach.

# XDC (Vivado): primary clocks
create_clock -name clk_sys -period 10.000 -waveform {0.000 5.000} [get_ports clk_sys]
create_clock -name adc_dco -period 10.000 [get_ports adc_dco]
# SDC (Quartus Prime): primary clocks
create_clock -name clk_sys -period 10.000 -waveform {0.000 5.000} [get_ports clk_sys]
create_clock -name adc_dco -period 10.000 [get_ports adc_dco]
derive_clock_uncertainty

The commands are identical, which is the point: XDC is a dialect of SDC. The -waveform option lists the rising and falling edge times within one period and only matters when the duty cycle is not 50 percent, so the second clock omits it. The one difference is the last line. Quartus Prime needs derive_clock_uncertainty to add jitter and skew margins to every check, while Vivado adds equivalent margins without being asked. Forgetting that line in Quartus gives optimistic reports and a warning that is easy to overlook.

Declare the clock on the port even when it goes straight into a PLL. The PLL outputs are described separately, and both tools need the input frequency to work out what the PLL produces.

Clocks made inside the design: create_generated_clock

Any clock that is derived from another one is a generated clock. There are two kinds, and the tools treat them differently.

PLL and MMCM outputs. Vivado creates these automatically from the IP configuration once the input clock is declared. Quartus Prime Standard Edition needs derive_pll_clocks in the SDC file. In Quartus Prime Pro the PLL IP ships its own SDC fragment, and at the time of writing that is the recommended route.

Clocks made from logic. A register that toggles is a divide-by-two clock, and neither tool will guess that. The configuration port in this design drives the ADC’s SPI clock from exactly such a register. Without a declaration, every path clocked by spi_sclk is unconstrained and silently ignored.

# XDC: a divide-by-two clock from a toggling register
create_generated_clock -name spi_sclk -source [get_pins u_fe/spi_sclk_reg/C] \
    -divide_by 2 [get_pins u_fe/spi_sclk_reg/Q]
# SDC: the same clock in Quartus Prime naming
create_generated_clock -name spi_sclk -source [get_pins u_fe|spi_sclk|clk] \
    -divide_by 2 [get_pins u_fe|spi_sclk|q]

The -source is the clock pin of the register, so the generated clock inherits the frequency and phase of whatever arrives there. The target is the register’s output pin, where the new clock begins. The only thing that changed between the two files is naming. Vivado adds a _reg suffix to every synthesised register and separates hierarchy with a forward slash. Quartus keeps the RTL name, separates hierarchy with a vertical bar, and calls the pins clk and q rather than C and Q. The XDC and SDC cheat sheet tabulates these conventions.

The design under constraint

The module below is deliberately small. Samples are registered at the pins, run through a short history, and summed in groups of four. A separate clock domain holds a stand-in for the SPI configuration port: a divided clock and a data line that changes on its falling edge. A real master adds chip select and a frame counter, but those do not change the constraints.

ADC100 MSPSt_co 1.0 to 2.5 nsFPGAadc_d[11:0]IBUF0.8 nsadc_qcapture registeradc_dcoIBUFBUFG3.0 nsclock insertion delay is far longer than the data path
The source-synchronous input path. Data and clock leave the ADC together, but inside the FPGA the clock travels through a global buffer and arrives at the capture register much later than the data does.
// adc_front_end.v
// Captures 12-bit samples from a source-synchronous ADC, sums them in
// groups of four, and provides a divided clock for the ADC's SPI port.
module adc_front_end #(
    parameter integer W = 12
) (
    // ADC data domain. clk_adc is the ADC's DCO after whatever buffering
    // scheme you choose (see the text).
    input  wire         clk_adc,
    input  wire         rst_adc,      // synchronous, already in the clk_adc domain
    input  wire [W-1:0] adc_d,
    output reg  [W+1:0] sum,          // h0 + h1 + h2 + h3
    output reg          sum_valid,

    // System domain. Only a stand-in for the configuration port is shown.
    input  wire         clk_sys,
    input  wire         rst_sys,
    output reg          spi_sclk,     // clk_sys divided by two
    output reg          spi_mosi
);

    // Stage 1: capture at the pins. One register with no logic in front
    // of it, so the tools can place it in the I/O block.
    reg [W-1:0] adc_q;
    always @(posedge clk_adc) begin
        adc_q <= adc_d;
    end

    // Stage 2: a short history and a tick every fourth cycle.
    reg [W-1:0] s1, s2, s3;
    reg [1:0]   phase;
    wire        tick = (phase == 2'd3);

    always @(posedge clk_adc) begin
        s1 <= adc_q;
        s2 <= s1;
        s3 <= s2;
        if (rst_adc) phase <= 2'd0;
        else         phase <= phase + 2'd1;
    end

    // Stage 3: a held bank and the sum. h0..h3 and sum all update on tick,
    // so every path from h* to sum has four clock periods available.
    // That is the multicycle path constrained in the text.
    reg [W-1:0] h0, h1, h2, h3;
    always @(posedge clk_adc) begin
        if (rst_adc) begin
            sum_valid <= 1'b0;
            sum       <= {(W+2){1'b0}};
            h0 <= {W{1'b0}};
            h1 <= {W{1'b0}};
            h2 <= {W{1'b0}};
            h3 <= {W{1'b0}};
        end else begin
            sum_valid <= tick;
            if (tick) begin
                h0  <= adc_q;
                h1  <= s1;
                h2  <= s2;
                h3  <= s3;
                sum <= h0 + h1 + h2 + h3;
            end
        end
    end

    // Configuration port stand-in: a divide-by-two clock and a data line
    // that changes on the falling edge of that clock.
    localparam [15:0] CONFIG_WORD = 16'h8A5C;
    reg [15:0] cfg_shift;

    always @(posedge clk_sys) begin
        if (rst_sys) begin
            spi_sclk  <= 1'b0;
            cfg_shift <= CONFIG_WORD;
            spi_mosi  <= 1'b0;
        end else begin
            spi_sclk <= ~spi_sclk;
            if (spi_sclk) begin                 // sclk is about to fall
                spi_mosi  <= cfg_shift[15];
                cfg_shift <= {cfg_shift[14:0], cfg_shift[15]};
            end
        end
    end

endmodule

The testbench models the ADC’s behaviour from the datasheet: a new sample appears 2.0 ns after each rising edge of the data clock. It checks that consecutive sums differ by 16, which is what four consecutive ramp values shifted by four must give.

// tb_adc_front_end.v  (testbench, not for synthesis)
`timescale 1ns/1ps
module tb_adc_front_end;
    localparam integer W = 12;
    reg          clk_adc = 1'b0;
    reg          clk_sys = 1'b0;
    reg          rst_adc = 1'b1;
    reg          rst_sys = 1'b1;
    reg  [W-1:0] adc_d   = {W{1'b0}};
    wire [W+1:0] sum;
    wire         sum_valid;
    wire         spi_sclk, spi_mosi;

    adc_front_end #(.W(W)) dut (
        .clk_adc(clk_adc), .rst_adc(rst_adc), .adc_d(adc_d),
        .sum(sum), .sum_valid(sum_valid),
        .clk_sys(clk_sys), .rst_sys(rst_sys),
        .spi_sclk(spi_sclk), .spi_mosi(spi_mosi)
    );

    always #5.0 clk_adc = ~clk_adc;   // 100 MHz data clock from the ADC
    always #4.0 clk_sys = ~clk_sys;   // 125 MHz system clock

    // ADC model: launch each sample 2.0 ns after the rising edge, inside
    // the 1.0 to 2.5 ns window the datasheet promises.
    integer n = 0;
    always @(posedge clk_adc) begin
        #2.0 adc_d <= n[W-1:0];
        n = n + 1;
    end

    integer     valid_count = 0;
    integer     errors      = 0;
    reg [W+1:0] last_sum    = {(W+2){1'b0}};

    always @(posedge clk_adc) begin
        if (sum_valid) begin
            valid_count = valid_count + 1;
            if (valid_count > 2 && sum != last_sum + 16) begin
                errors = errors + 1;
                $display("FAIL at %0t: sum %0d, expected %0d", $time, sum, last_sum + 16);
            end
            last_sum = sum;
        end
    end

    initial begin
        repeat (3) @(posedge clk_adc);
        rst_adc <= 1'b0;
        repeat (3) @(posedge clk_sys);
        rst_sys <= 1'b0;
        repeat (60) @(posedge clk_adc);
        if (errors == 0) $display("PASS: %0d sums checked", valid_count);
        else             $display("FAIL: %0d errors", errors);
        $finish;
    end
endmodule

Input delays for a source-synchronous ADC

The ADC launches each sample from its own clock, so the relevant datasheet numbers are the clock-to-output times. The values below are typical of a 100 MSPS parallel-output converter and are used here as an example, not as a specification for any part.

Quantity Value Source
Data clock period 10.0 ns 100 MSPS
t_co minimum 1.0 ns ADC datasheet
t_co maximum 2.5 ns ADC datasheet
Trace skew, data versus clock plus or minus 0.2 ns board layout

Input delay is defined as the time from the clock edge at the ADC to the moment data arrives at the FPGA pin. The maximum value is the latest the data could arrive and feeds the setup check. The minimum is the earliest the data could change and feeds the hold check. Board skew widens the window in both directions.

# XDC: data arrives 0.8 to 2.7 ns after each rising edge of adc_dco
set_input_delay -clock adc_dco -max 2.700 [get_ports adc_d[*]]
set_input_delay -clock adc_dco -min 0.800 [get_ports adc_d[*]]
# SDC: identical in Quartus Prime
set_input_delay -clock adc_dco -max 2.700 [get_ports adc_d[*]]
set_input_delay -clock adc_dco -min 0.800 [get_ports adc_d[*]]
One sample per clock period. Data becomes invalid shortly after each rising edge and settles again within 2.7 ns. The next rising edge, 10 ns later, falls well inside the valid window.adc_dcoadc_d[11:0]nn+1n+2t_co(max) + skew = 2.7 nscaptured at 10 nsacb
One sample per clock period. Data becomes invalid shortly after each rising edge and settles again within 2.7 ns. The next rising edge, 10 ns later, falls well inside the valid window.

For a double data rate interface, where the ADC launches on both edges, add a second pair of constraints with -clock_fall -add_delay. Without -add_delay the second pair replaces the first rather than joining it.

What the analyser does with those numbers

The constraint is correct, and the design will fail hold. Working the check by hand shows why, and why this interface class so often catches people out. Assume the data path from pin to capture register is 0.8 ns, the clock path from pin through a global buffer to the same register is 3.0 ns, and the register needs 0.3 ns of setup and 0.2 ns of hold.

Event Time
Launch edge at the ADC 0.0 ns
Data valid at the capture register 2.7 + 0.8 = 3.5 ns
Data next changes at the capture register 10.0 + 0.8 + 0.8 = 11.6 ns
Capture edge arrives at the register 10.0 + 3.0 = 13.0 ns
Setup slack 13.0 − 0.3 − 3.5 = 9.2 ns
Hold slack 11.6 − (13.0 + 0.2) = −1.6 ns

The clock arrives 3.0 ns after the edge that was meant to capture the data, and by then the ADC has already launched the next sample. Setup is comfortable and hold is broken, which is the signature of a source-synchronous input whose clock takes the long way round. There are three usual fixes, and all of them are hardware decisions rather than constraint changes:

  • Shift the capture clock. Run adc_dco through a PLL or MMCM in zero-delay mode with a 180 degree phase-shifted output. The capture edge then lands at 5.0 ns, giving 1.2 ns of setup slack and 6.4 ns of hold slack against the same data window.
  • Delay the data. Use the input delay elements in the I/O block so the data arrives after the clock. The tools can set the tap count for you once the input delay constraint is in place.
  • Capture on the falling edge. Write the capture register on negedge, which moves the capture point to 5.0 ns without a PLL. The half-period path that follows then needs care at higher rates.

In every case the input delay constraints stay exactly as written. They describe the ADC, and the ADC has not changed.

Output delays for the configuration port

The configuration port runs the other way. The FPGA launches spi_mosi and the ADC captures it on the rising edge of spi_sclk. A datasheet for such a port gives a setup time and a hold time relative to that edge, say 4.0 ns and 2.0 ns. Trace skew on this slow interface is at most 0.3 ns.

# XDC and SDC: data must be stable 4.3 ns before and 1.7 ns after each rising sclk edge at the ADC
set_output_delay -clock spi_sclk -max 4.300 [get_ports spi_mosi]
set_output_delay -clock spi_sclk -min -1.700 [get_ports spi_mosi]

The maximum value is the ADC’s setup time plus skew, because the tool must make the data arrive that much before the edge. The minimum value is the negative of the hold time plus skew. A negative minimum is normal and means the data is allowed to change before the clock edge reaches the ADC, as long as it is not earlier than 1.7 ns after it. Because the generated clock was declared on the register that drives the pin, both tools know the relationship between clk_sys and spi_sclk and check the right pair of edges.

Clock groups

clk_sys and adc_dco come from different oscillators. Without a constraint, the analyser assumes every pair of clocks is related and checks paths between them against the worst alignment of their edges. On two 100 MHz clocks with no phase relationship that produces failing paths that cannot be fixed.

# XDC and SDC: the two oscillators are unrelated
set_clock_groups -asynchronous -group [get_clocks {clk_sys spi_sclk}] \
                               -group [get_clocks {adc_dco}]

The syntax is the same in both tools. Vivado also accepts -include_generated_clocks so that PLL outputs follow their parent automatically, while in Quartus Prime you list every clock in each group by name. The command removes all timing checks between the groups, which is a promise that every crossing uses a synchroniser, an asynchronous FIFO or a handshake. The patterns are in Reset and CDC patterns and the FIFO case is covered in Memories, block RAM and FIFOs. Nothing in the tool will tell you if the promise is false.

Exceptions: false paths and multicycle paths

An exception tells the analyser that a path exists but should be checked differently. Both kinds are easy to over-use. The rule is that the exception must be justified by the structure of the RTL, not by a report you would like to go green.

False paths

A false path is never checked. The legitimate cases are narrow: a push button that feeds a synchroniser, a configuration register written once before the clock starts, a test signal that is never exercised. In this design the reset button is synchronised at the top level, so the path from the pin to the first synchroniser stage carries no timing requirement.

# XDC and SDC
set_false_path -from [get_ports btn_rst_n]

Do not use false paths to silence clock-domain crossings between clocks that are already in different groups. The group constraint has done that. For crossings within a group, where the latency matters, Vivado’s set_max_delay -datapath_only keeps a bound on the delay while ignoring the clock relationship.

Multicycle paths

The held registers h0 to h3 and the register sum all update on tick, which is high one cycle in four. A value launched from h0 on one tick is captured by sum on the next tick, 40 ns later. The analyser does not know that and checks the adder against a 10 ns period. At 100 MHz a 12-bit four-input adder fits comfortably, but the same structure at 300 MHz or with a wider adder would fail for no real reason.

# XDC: four periods for setup, hold stays at the launch edge
set_multicycle_path 4 -setup -from [get_cells {u_fe/h0_reg[*] u_fe/h1_reg[*] u_fe/h2_reg[*] u_fe/h3_reg[*]}] \
                            -to   [get_cells u_fe/sum_reg[*]]
set_multicycle_path 3 -hold  -from [get_cells {u_fe/h0_reg[*] u_fe/h1_reg[*] u_fe/h2_reg[*] u_fe/h3_reg[*]}] \
                            -to   [get_cells u_fe/sum_reg[*]]
# SDC: same intent, Quartus Prime naming
set_multicycle_path 4 -setup -from [get_registers {u_fe|h0[*] u_fe|h1[*] u_fe|h2[*] u_fe|h3[*]}] \
                            -to   [get_registers u_fe|sum[*]]
set_multicycle_path 3 -hold  -from [get_registers {u_fe|h0[*] u_fe|h1[*] u_fe|h2[*] u_fe|h3[*]}] \
                            -to   [get_registers u_fe|sum[*]]

The two numbers together are the part that catches people. By default the hold check uses the edge one period before the setup edge. Moving setup to the fourth edge drags hold to the third edge, 30 ns after launch, which no adder can satisfy. The second command pulls hold back by three periods so it is checked at the launch edge, where it belongs. In both tools a setup multicycle of N is almost always paired with a hold multicycle of N minus 1.

Notice what is not in the exception. The path from phase to the enable input of sum is a normal single-cycle path, and so is the path from adc_q into h0. Only paths whose source and destination share the same enable qualify.

Checking that the constraints took effect

A constraint that matches nothing is silently ignored, which is how a typo in a register name produces a design that is unconstrained and passes. After every change, run the checks that report what the tool did not analyse.

Purpose Vivado Quartus Prime Timing Analyzer
List every clock the tool knows report_clocks report_clocks
Find unconstrained paths and missing I/O delays check_timing report_ucp
See which clock pairs are analysed report_clock_interaction report_clock_transfers
Confirm an exception matched something warnings in the log report_exceptions

The differences between the two formats are small enough to summarise in one table.

Concern Vivado XDC Quartus Prime SDC
File role Timing, pin locations and I/O standards together Timing only, with pins in the project settings file
PLL output clocks Derived automatically from the IP derive_pll_clocks in Standard, IP-supplied SDC in Pro
Clock uncertainty Applied automatically derive_clock_uncertainty required
Register names RTL name with _reg suffix, pins C and Q RTL name, pins clk and q
Hierarchy separator Forward slash Vertical bar
Conflicting constraints Last one read wins Last one read wins

Both tools read constraint files in the order you list them, so a late file can override an early one. Keep the clock definitions first, the I/O delays second and the exceptions last, and the file reads in the same order the analyser needs.

Exercise

Change the capture register in adc_front_end to use the falling edge of clk_adc, keeping everything else on the rising edge. Rework the hand calculation in the input delay section for the new capture point, using the same 0.8 ns data path and 3.0 ns clock path, and show the setup and hold slack. Then decide whether the half-period path from adc_q to s1 is safe at 100 MHz, and at what clock rate it would stop being safe if that path needed 2.0 ns of logic and routing. Run the testbench to confirm the sums are still correct.

Summary

  • Constraints describe the hardware outside the chip and the clocks made inside it. They are claims, and a wrong claim gives a clean report for a broken design.
  • Every clock pin needs create_clock, and every clock built from logic needs create_generated_clock. PLL outputs are automatic in Vivado and need a command or an IP file in Quartus Prime.
  • Input and output delays come straight from the datasheet of the device on the other end, widened by board skew. A source-synchronous input often passes setup easily and fails hold because of clock insertion delay.
  • set_clock_groups removes all checks between unrelated clocks and is a promise that every crossing is properly synchronised.
  • Multicycle exceptions need both a setup and a hold number, and a false path needs a reason you could explain to a reviewer.

Further reading