Volume 02 Intermediate 5 sub-modules ~55 min read

The Vivado Flow: Synthesis to Bitstream

Most people press "Generate Bitstream" and wait. When it fails, they change something at random and press it again. The flow is actually nine well-defined commands, each with a precise contract about what it is allowed to change - and once you know those contracts, a timing failure stops being a mystery and becomes a question with an address.

2.1 Elaboration is not synthesis

Two entirely different transformations happen before you ever see a LUT, and conflating them is why "it elaborates fine but synthesis complains" feels arbitrary.

Elaboration resolves the language. Parameters are substituted, generate blocks are unrolled, for loops are flattened, module instances are bound, and the whole thing becomes a hierarchy of generic operators - RTL_ADD, RTL_MUX, RTL_REG, RTL_ROM. Nothing device-specific has happened yet. You can elaborate a design without even selecting a part.

Synthesis maps those generic operators onto the actual primitives in the device you chose. RTL_ADD becomes LUT2 plus CARRY4. RTL_REG becomes FDRE. RTL_ROM becomes either a RAMB36E1 or a pile of LUT6s depending on its size and shape. This step is where every decision from Volume 01 gets made on your behalf.

The Vivado flow from RTL through elaboration, synthesis, optimisation, placement, routing and bitstream, with design checkpoints marked RTL .sv .v .vhd elaborate generic ops device-independent synth_design LUT FF BRAM DSP post_synth.dcp opt_design prune, remap place_design assign sites post_place.dcp phys_opt replicate, retime route_design real wire delay report_timing post_route.dcp bitstream .bit / .bin green labels are design checkpoints - save them, they are the only way to debug a failed run THE NINE COMMANDS
Figure 2.1 - The full flow. Everything left of synth_design is device-independent; everything right of it is not. Timing numbers before route_design are estimates, and estimates lie.

What synthesis is allowed to do to your code

Synthesis is an optimiser, and optimisers delete things. Four transformations surprise people regularly:

Transformation What it does When it bites
Constant propagation A register that can only ever hold one value is replaced by that value Your debug counter vanishes because nothing reads it
Equivalent register merging Two flops with identical inputs become one flop Your two-flop synchroniser collapses into one
Sequential pruning Logic with no path to any output is deleted entirely A whole module disappears; utilisation reads suspiciously low
Retiming Registers move across combinational logic to balance path delay Signal names in the timing report no longer match your RTL
The synchroniser that wasn't Register merging is the most dangerous item on that list. A clock-domain-crossing synchroniser is defined by having two flops in series with nothing between them - which is exactly the pattern the optimiser is looking for. Without protection, your carefully written 2-FF synchroniser can synthesise into a single flop and the design will work on the bench for three weeks before failing in the field. The fix is ASYNC_REG, and it is not optional. Full CDC theory lives in Volume 05 of the Verilog course.

module sync_2ff (
  input  logic clk,       // destination clock
  input  logic d_async,   // from another domain - unrelated to clk
  output logic q_sync
);

  // ASYNC_REG does three things at once:
  //   1. forbids merging these two flops into one
  //   2. tells the placer to put them in the SAME slice (short route)
  //   3. tells simulation not to propagate X on a timing violation
  (* ASYNC_REG = "TRUE" *) logic meta_q;
  (* ASYNC_REG = "TRUE" *) logic sync_q;

  always_ff @(posedge clk) begin
    meta_q <= d_async;
    sync_q <= meta_q;
  end

  assign q_sync = sync_q;

endmodule
Interview grilling - "Your module synthesised to zero LUTs. What happened?"

Almost always one of three things, and you should name all three to show you have a process rather than a guess:

  • No output path. Nothing downstream reads the module's outputs, or they are not connected to a top-level port. Sequential pruning removed everything. Check the synthesis log for "removing unused" messages - they are informational, not warnings, which is why nobody reads them.
  • Constant inputs. A parameter or tie-off made the whole function constant. The classic version is a mode signal wired to a literal during bring-up and never wired back.
  • Black box. The module was never actually elaborated - a missing file in the project, or a name mismatch - so Vivado inferred a black box and quietly carried on. This one prints a real warning, and it is the one to look for first.

The command to reach for is report_utilization -hierarchical, which shows per-module resource usage. A module reporting zero rows is unambiguous.

2.2 Attributes and directives: steering the tool

You cannot rewrite Vivado's optimiser, but you can constrain it. Two mechanisms exist, and they operate at different scopes: attributes are written into the RTL and attach to a specific object; directives are command-line switches that change the whole run's strategy.

The attributes worth memorising

Attribute Effect Survives to implementation?
ASYNC_REG Protects and co-locates synchroniser flops Yes - and it must
DONT_TOUCH Forbids all optimisation of the object Yes
KEEP Preserves a net through synthesis only No
KEEP_HIERARCHY Stops cross-boundary optimisation for a module Yes - useful for floorplanning and for readable reports
MAX_FANOUT Replicates a driver when its fanout exceeds N Yes
RAM_STYLE block / distributed / ultra / registers Decided at synthesis
SRL_STYLE register / srl / srl_reg / reg_srl_reg Decided at synthesis
FSM_ENCODING one_hot / sequential / gray / johnson / none Decided at synthesis
USE_DSP Forces or forbids DSP48 mapping Decided at synthesis
MARK_DEBUG Makes a net available to the ILA insertion flow Yes - implies DONT_TOUCH on that net

// ---- fan-out control -------------------------------------------------
// A reset or enable feeding 4000 flops has enormous fanout. Replicating
// the driver costs a handful of flops and can be worth 2 ns of slack.
(* MAX_FANOUT = 64 *) logic rst_n_int;

// ---- memory style ----------------------------------------------------
// 32 entries x 8 bits: too small to justify a whole BRAM tile.
(* RAM_STYLE = "distributed" *) logic [7:0] lut_table [0:31];

// ---- shift register style -------------------------------------------
// A 64-deep delay line maps to two SRL32s (2 LUTs!) instead of 64 flops.
// "srl_reg" adds one real flop at the output for better clock-to-out.
(* SRL_STYLE = "srl_reg" *) logic [63:0] delay_line;

// ---- FSM encoding ----------------------------------------------------
// Vivado chooses automatically and is usually right. Override only when
// you have measured a reason to.
(* FSM_ENCODING = "one_hot" *) state_e state, next_state;

// ---- debug -----------------------------------------------------------
// MARK_DEBUG keeps the net alive AND lets you attach an ILA probe to it
// from the netlist, without editing RTL again. See Volume 07.
(* MARK_DEBUG = "TRUE" *) logic [7:0] fsm_trace;
DONT_TOUCH is a scalpel, not a hammer Putting DONT_TOUCH on a top-level module because "the optimiser is breaking something" disables constant propagation, register merging and retiming for everything inside it. Designs have lost 30% of their fmax this way. Find the one net or one register that actually needs protecting and mark only that. If you cannot identify it, the bug is probably not the optimiser.

Directives: whole-run strategy

Each implementation command takes a -directive. These are not knobs you tune randomly - each one trades runtime for a specific kind of quality, and there is a right order to try them in when you are 200 ps short.


# Baseline: fast, and what the GUI runs by default.
synth_design -directive default
place_design -directive default
route_design -directive default

# Short on setup slack? Push the placer harder before touching RTL.
place_design -directive ExtraTimingOpt
phys_opt_design -directive AggressiveExplore
route_design -directive AggressiveExplore

# Running out of area, not time?
synth_design -directive AreaOptimized_high

# Routing congestion (many "overlap" messages, long route times)?
place_design -directive AltSpreadLogic_high
route_design -directive AlternateCLBRouting

# Reproducing a customer's exact result? Pin the seed.
place_design -directive default -seed 7
Directive sweeps are triage, not a fix If a design closes at AggressiveExplore and fails at default, you have roughly 100 ps of real margin and a design that will fail the next time anything changes. Treat a passing sweep as permission to ship this build, and then go fix the architecture. A healthy design closes on default with room to spare.

2.3 Opt, place, phys_opt, route

Implementation takes the synthesised netlist and turns it into physical reality. Four commands do the work, and each has a strict contract about what it may modify.

Command May change May not change Typical gain
opt_design Netlist - prunes, remaps LUTs, propagates constants across the whole design Nothing is placed yet 5-15% area
place_design Which physical site each cell occupies The netlist itself Sets the achievable floor for timing
phys_opt_design Both - it can replicate a high-fanout driver, retime a register, or rewire a critical net, then re-place the affected cells Design intent (it is timing-driven only) 100-400 ps on the worst path
route_design Which physical wires connect the placed cells Placement, except for minor legalisation Converts estimates into truth

phys_opt_design is the one worth understanding properly, because it is the only stage that can restructure logic with real placement information in hand. Before placement, the tool guesses that a net will be short. After placement it knows the driver is in clock region X0Y2 and one of its forty loads is in X3Y0. That knowledge unlocks fixes that were invisible earlier:

Run phys_opt twice - it is not idempotent Each pass works on whatever path is worst at that moment. Fixing path A promotes path B to worst, and a second pass will attack B. Two or three invocations with different directives is a standard, legitimate technique and costs only runtime: phys_opt_design -directive AggressiveExplore followed by phys_opt_design -directive AlternateReplication.

2.4 Reading the reports

Two reports decide whether you ship. Everything else is supporting evidence.

report_utilization


+----------------------------+-------+-------+-----------+-------+
|          Site Type         |  Used | Fixed | Available | Util% |
+----------------------------+-------+-------+-----------+-------+
| Slice LUTs                 | 28451 |     0 |     53200 | 53.48 |
|   LUT as Logic             | 25103 |     0 |     53200 | 47.19 |
|   LUT as Memory            |  3348 |     0 |     17400 | 19.24 |
| Slice Registers            | 41120 |     0 |    106400 | 38.65 |
| Block RAM Tile             |  98.5 |     0 |       140 | 70.36 |
| DSPs                       |    44 |     0 |       220 | 20.00 |
| Bonded IOB                 |   112 |   112 |       200 | 56.00 |
| BUFGCTRL                   |     5 |     0 |        32 | 15.63 |
+----------------------------+-------+-------+-----------+-------+

The rows that actually predict trouble are not the ones people look at:

Anatomy of a timing path

Everything in static timing analysis reduces to one comparison: does the data arrive before it is required? The theory is in Volume 07 of the Verilog course; here is what Vivado's version of it looks like.

Timing path decomposition showing data arrival time built from clock skew, clock-to-Q, logic delay and net delay, compared against the required time SETUP SLACK = REQUIRED - ARRIVAL Arrival clk 0.9 Tcq .4 logic 1.10 net (routing) 2.85 = 5.25 ns Required clk 0.9 clock period 5.00 -Tsu .13 -unc .035 = 5.735 ns Slack = +0.485 ns (met) Diagnosis: net delay is 72% of the data path. This is a placement/fanout problem, not a logic-depth problem. Do not add pipeline stages.
Figure 2.4 - The same numbers Vivado prints, laid out as two bars. The split between logic and net delay is the single most useful diagnostic in the report, and it points at completely different fixes.
What the path looks like Root cause Fix
Logic delay dominant, many logic levels Combinational depth Pipeline it. Add a register stage in the middle of the cone.
Net delay dominant, few logic levels Placement spread or high fanout phys_opt_design, MAX_FANOUT, or a pblock
Net delay dominant, 2 logic levels, huge fanout number One driver feeding thousands of loads (usually reset or enable) Manual replication, or remove the reset entirely
Path crosses two different clocks Missing CDC constraint Do not fix the timing - fix the constraint. See Volume 03.
Enormous negative slack (> 5 ns) on one path Almost always an unconstrained or wrongly constrained clock Check report_clocks before touching RTL

# The four commands that answer "why is my design failing timing", in order.

# 1. Is anything actually failing, and how badly?
report_timing_summary -delay_type min_max -max_paths 10 -file timing_sum.rpt

# 2. Show me the worst path with full detail.
report_timing -delay_type max -max_paths 1 -nworst 1 -significant_digits 3

# 3. Are my clocks what I think they are? (Catches the biggest failures.)
report_clocks
report_clock_interaction -delay_type min_max

# 4. Which nets have absurd fanout?
report_high_fanout_nets -timing -load_types -max_nets 20
Interview grilling - "WNS is -0.2 ns and TNS is -840 ns. What does that tell you?"

That the design has a systemic problem, not a critical path. With a WNS of only -0.2 ns, roughly four thousand endpoints must be failing by a small amount each to accumulate -840 ns of total negative slack.

One bad path and thousands of marginal paths need opposite responses:

  • WNS -3 ns, TNS -3 ns - one path. Find it, pipeline it, done. An afternoon's work.
  • WNS -0.2 ns, TNS -840 ns - the whole design is marginal. The clock is probably 10-15% too fast for this architecture, or the device is too full and everything is being routed the long way round. Directive sweeps will not save this; either the frequency target or the architecture has to move.

The follow-up worth volunteering: "I would also check report_design_analysis -congestion, because a TNS that large with a small WNS is the classic signature of routing congestion rather than logic depth."

2.5 Bitstream, configuration and the Tcl flow

write_bitstream serialises the routed design into the configuration bits that will be shifted into the device's SRAM at power-up. Two things about it matter in practice.

First, it runs design rule checks that nothing earlier runs. An unconnected I/O buffer, a missing CFGBVS setting, or a pin assigned to a bank whose voltage contradicts its I/O standard will all pass place-and-route and fail here - after the twenty minutes you just spent routing. Run report_drc after opt_design to catch these early.

Second, an FPGA's configuration is volatile. The bitstream lives in external flash and is loaded on every power-up:

Mode Source Use
JTAG Cable, direct from Vivado Development. Volatile - gone at power-off.
Master SPI / QSPI On-board flash, FPGA clocks itself Production. Requires an .mcs written with write_cfgmem.
Slave SelectMAP An external CPU pushes bytes in Systems where a host already exists
Zynq PS boot The ARM cores load the PL from the boot image Every Zynq design - see Volume 06

Non-project mode: the flow you can put in git

A Vivado .xpr project is a directory of tool state that changes every time you open it, which makes it close to useless in version control. Non-project mode is a Tcl script that reads your sources, runs the flow and writes outputs - reproducible, diffable, and the way essentially every production team runs builds.


# ---------------------------------------------------------------------
# Non-project build. Run with:  vivado -mode batch -source build.tcl
# ---------------------------------------------------------------------
set part      xc7a100tcsg324-1
set top       system_top
set outdir    ./build

file mkdir $outdir

# ---- read sources (order does not matter; Vivado resolves it) --------
read_verilog -sv [glob ./rtl/*.sv]
read_xdc  ./constraints/timing.xdc
read_xdc  ./constraints/pins.xdc

# ---- synthesis -------------------------------------------------------
synth_design -top $top -part $part -directive default
write_checkpoint -force $outdir/post_synth.dcp
report_utilization -file $outdir/post_synth_util.rpt

# ---- implementation --------------------------------------------------
opt_design
report_drc -file $outdir/drc.rpt          ;# catch pin/bank errors EARLY

place_design
phys_opt_design -directive AggressiveExplore
write_checkpoint -force $outdir/post_place.dcp

route_design
write_checkpoint -force $outdir/post_route.dcp

# ---- sign-off --------------------------------------------------------
report_timing_summary -file $outdir/timing.rpt
report_utilization    -file $outdir/util.rpt

# Fail the build if timing did not close. Without this, CI will happily
# publish a bitstream that does not work.
set wns [get_property SLACK [get_timing_paths -delay_type max]]
if {$wns < 0} {
  puts "ERROR: setup timing failed, WNS = $wns ns"
  exit 1
}

write_bitstream -force $outdir/$top.bit
exit 0
The three lines that make CI worth having Reading SLACK and exiting non-zero is what turns a build script into a gate. Without it every commit produces a bitstream, including the ones that fail timing by two nanoseconds - and someone will program it, and it will half-work, and the bug report will say "intermittent". Check WNS and WHS (hold), and fail on either.
Interview grilling - "Why did the same RTL give a different result on the second run?"

Placement and routing are heuristic searches over an enormous space. They are deterministic given identical inputs, but "identical" is stricter than people assume:

  • Tool version. Different Vivado versions have different cost functions. This is the most common cause of "it built last month".
  • Seed. place_design -seed N changes the starting point. Different seed, different local optimum, up to several hundred picoseconds apart on a marginal design.
  • Thread count. Multi-threaded routing can produce slightly different results depending on core count. Pin it with set_param general.maxThreads 4 if bit-exact reproducibility matters.
  • Incremental compile. If a previous checkpoint is being reused, the result depends on that checkpoint, not only on the RTL.

The real point to make: a design whose timing outcome depends on the seed has no margin. Reproducibility problems are usually margin problems wearing a disguise.

Volume 02 recap

Concept The one thing to remember
Elaboration Resolves the language. Device-independent, no LUTs yet.
Register merging Will eat your synchroniser. ASYNC_REG is mandatory.
DONT_TOUCH Scope it to one net. On a module it costs real performance.
phys_opt_design The only stage that restructures logic knowing real placement.
Logic vs net delay Net-dominant means placement. Logic-dominant means pipeline.
WNS vs TNS One bad path, or a thousand marginal ones. Different fixes.
Tcl flow Fail the build on negative slack, or CI is decoration.