Part 3: DFT Scan Chain Insertion
Why OpenLane 2 Has No Native DFT
OpenLane 2 version 2.3.10 ships with zero DFT steps. Running Step.factory.list() against the installed package confirms this: no ScanReplace, no ScanInsert, no DFTConfig step exists anywhere in the openlane.steps namespace. OpenLane 1 had a run_dft flag that invoked Yosys’s dfflegalize pass followed by a custom Perl script to stitch scan chains, but this was never ported to OpenLane 2.
The only community-maintained DFT option for OpenLane 2 is difetto, an alpha-state package distributed through Nix. It was not used here because it introduces a separate dependency chain that conflicts with the existing Docker-based flow and is not stable enough for integration.
OpenROAD (the PnR engine underlying OpenLane 2) does have native scan chain support through three TCL commands: set_dft_config, scan_replace, and insert_dft. These are fully functional in the OpenROAD binary bundled with OpenLane 2.3.10. The problem is that OpenLane 2 never wraps these commands into steps. The solution was to write those steps manually using the OpenLane 2 Python step API.
Why Scan Insertion Requires Two Separate Steps
OpenROAD’s scan insertion cannot be done in one pass. It requires two separate operations at two different points in the PnR flow.
scan_replace must run before global placement. It iterates over every flip-flop instance in the netlist and replaces it with its scan-equivalent cell from the standard cell library. In sky130_fd_sc_hd, a DFF_X1 becomes SDFF_X1, which adds SCD (scan data) and SCE (scan enable) ports. Scan-equivalent cells are physically larger than the original cells. If scan_replace runs after placement, the placer has already arranged the original smaller cells in layout rows. Swapping them to larger variants after the fact causes cell overlaps that detailed placement cannot legally resolve without moving cells beyond the legal perturbation range. The sequential cell area increase in this design was from 18,918 um² to 23,516 um², a 24% increase, all of which the placer needs to account for from the start.
insert_dft must run after detailed placement. It uses the physical coordinates of the already-placed scan flops to build minimum-wirelength chains by ordering the flops spatially. Running it before detailed placement means the tool has no final location data and cannot optimize chain ordering. Running it before detailed placement also means the scan stitching wires are committed before cell positions are finalized, creating mismatches between the wiring and the physical cell locations.
The correct flow order is:
ScanReplace -> GlobalPlacement -> DetailedPlacement -> ScanStitch -> CTS -> Routing -> ...
CTS must come after scan stitching because the scan enable signal (scan_enable) needs to be treated as a quasi-clock signal during clock tree synthesis. If CTS runs before scan stitching, the scan enable net has no buffering infrastructure and will violate max-fanout constraints across all 720 scan flops.
Custom Step Implementation
Two files were written to implement DFT inside the OpenLane 2 Python API.
dft_step.py
Both classes subclass OpenROADStep, the correct base class for any step that generates a TCL script and runs it through the OpenROAD binary. The get_script_path method returns a path inside self.step_dir, the per-step directory OpenLane 2 creates fresh for each step execution. The run method writes the TCL file and calls super().run() to execute it through the OpenROAD subprocess.
ScanReplace generates this TCL:
source $::env(SCRIPTS_DIR)/openroad/common/io.tcl
read_current_odb
set_dft_config \
-max_chains 4 \
-clock_mixing no_mix
scan_replace
write_views
The source and read_current_odb pattern is required because OpenROADStep does not automatically load the database before running user TCL. An initial attempt used read_db $::env(CURRENT_ODB) directly, which fails: CURRENT_ODB is not defined in the subprocess environment when OpenROADStep launches OpenROAD. The correct path is to source the io.tcl helper bundled with OpenLane 2, which defines read_current_odb. That function reads the ODB path through a mechanism OpenLane 2 does wire up correctly.
set_dft_config -max_chains 4 -clock_mixing no_mix configures the scan chain structure. max_chains 4 creates 4 separate scan chains across the 720 flip-flops. no_mix prevents the tool from interleaving flops clocked by different clock domains into the same chain. scan_replace then modifies the ODB in-place, swapping every DFF* instance for its SDFF* equivalent. write_views flushes the modified ODB and netlist to disk.
ScanStitch generates:
source $::env(SCRIPTS_DIR)/openroad/common/io.tcl
read_current_odb
insert_dft
place_pin -pin_name scan_enable_1 -layer met2 -location {0 100} -pin_size {0.2 2}
place_pin -pin_name scan_in_1 -layer met2 -location {0 250} -pin_size {0.2 2}
place_pin -pin_name scan_out_1 -layer met2 -location {0 400} -pin_size {0.2 2}
place_pin -pin_name scan_in_2 -layer met2 -location {0 550} -pin_size {0.2 2}
place_pin -pin_name scan_out_2 -layer met2 -location {0 700} -pin_size {0.2 2}
place_pin -pin_name scan_in_3 -layer met2 -location {0 850} -pin_size {0.2 2}
place_pin -pin_name scan_in_4 -layer met2 -location {0 1000} -pin_size {0.2 2}
place_pin -pin_name scan_out_3 -layer met2 -location {0 1150} -pin_size {0.2 2}
write_views
insert_dft reads the set_dft_config parameters stored in the ODB from the earlier scan_replace run, builds minimum-wirelength chains using the flop placement coordinates from detailed placement, and creates the scan I/O ports in the ODB. It auto-names them scan_in_N, scan_out_N, and scan_enable_N. The eight place_pin calls assign physical locations to those ports on met2. Die dimensions are 1358.645 um x 1369.365 um. All eight scan ports are placed on the left edge (x=0) at Y coordinates spaced 150 um apart, all within the die boundary.
run_dft_flow.py
The flow builder retrieves the standard Classic step list via SequentialFlow.factory.get("Classic"), filters out problematic steps, finds the indices of OpenROAD.GlobalPlacement and OpenROAD.DetailedPlacement, and splices in the two custom steps:
steps = steps[:gpl_idx] + [ScanReplace] + steps[gpl_idx:dpl_idx+1] + [ScanStitch] + steps[dpl_idx+1:]
Three existing steps were removed from the flow:
OpenROAD.RepairDesignPostGPL was removed because after scan replace, the timing constraints include transition time checks on the newly created scan ports (SCD, SCE), which have no set_max_transition SDC constraints. The repair step throws an unrecoverable error about unconstrained transition paths on these ports. Removing this step is safe because post-GPL design repair is a timing optimization, not a correctness requirement.
Odb.CheckDesignAntennaProperties was removed because the Magic-generated LEF for the DFT design contains syntactically invalid USE ; lines for the scan ports (correct syntax is USE SCAN ;). The ODB LEF parser aborts on this syntax error. The GDS and ODB are already written before this step, so nothing is lost.
Yosys.EQY was removed because formal equivalence checking cannot pass after scan replacement: the scan flops have SCD and SCE ports that do not exist on the original DFF cells in the RTL. Comparing the original RTL to the post-scan-replace gate netlist will always report unmatched module interfaces. A DFT-aware equivalence flow would compare non-scan mode behavior only, which requires separate EQY configuration outside the scope of this work.
Bugs Encountered
8 bugs were found and fixed during implementation.
Bug 1: CURRENT_ODB variable reference.
Initial TCL used read_db $::env(CURRENT_ODB). This fails because CURRENT_ODB is not set in the subprocess environment when OpenROADStep launches OpenROAD. Fix: source io.tcl and call read_current_odb.
Bug 2: execute_dft_plan does not exist.
The OpenROAD TCL command for scan stitching is insert_dft, not execute_dft_plan. OpenROAD documentation has inconsistencies between versions. Using the wrong command name produces invalid command name "execute_dft_plan" from TCL.
Bug 3: %OL_CREATE_REPORT is not valid TCL.
An earlier version of the ScanStitch TCL script included %OL_CREATE_REPORT as a directive to trigger OpenLane 2’s report generation hook. This is a Python-side template substitution marker, not valid TCL. OpenROAD produces a TCL parse error. Fix: remove it entirely and use write_views.
Bug 4: RepairDesignPostGPL transition constraint failure. Described in the step filtering section above. Fix: remove the step from the flow.
Bug 5: DetailedPlacement failure from ScanReplace position.
The original flow injected ScanReplace after GlobalPlacement. This caused DetailedPlacement to fail with cell overlap errors because the placer had already arranged original-size flip-flops in rows, and the larger scan cells could not fit in the pre-allocated spaces. Fix: move ScanReplace to before GlobalPlacement.
Bug 6: scan_out_3 had no routing layer assigned.
After the first successful ScanStitch run, global routing failed with [GRT-0209] Pin scan_out_3 is completely outside the die area and cannot be routed. The DEF showed that all scan_in_* pins had FIXED status but all scan_out_* pins had no placement entry. The place_pin calls in the initial TCL were using database unit values (e.g., {0 1150000}) rather than micron values. place_pin in OpenROAD expects coordinates in microns. The value 1150000 was being interpreted as 1,150,000 microns, far outside the 1358 um die. OpenROAD did not error on this; it silently dropped the out-of-bounds placement for output-direction ports. Fix: divide all coordinate values by 1000 to convert from the erroneous DBU values to correct micron values. After this fix, all eight scan ports appeared with FIXED status in the DEF.
Bug 7: Magic LEF writer emitting USE with empty value.
After routing completed, the flow reached Odb.CheckDesignAntennaProperties and crashed with a SIGABRT. The Magic-generated LEF contained:
PIN scan_enable_1
DIRECTION INPUT ;
USE ;
The correct syntax is USE SCAN ;. Magic’s LEF writer does not handle the USE SCAN attribute for ports created by OpenROAD’s DFT flow. The ODB LEF parser treats the empty USE value as a syntax error and aborts. Since the GDS is written at step 58 (Magic.StreamOut) before this LEF is generated, removing Odb.CheckDesignAntennaProperties loses only the antenna check, not the GDS or ODB.
Bug 8: flow.start() returning more than two values.
After all fixes, the flow completed 75/75 stages but Python threw ValueError: too many values to unpack (expected 2) at state, steps = flow.start(tag="pipe_dft"). A newer version of SequentialFlow.start() returns a tuple with more elements than two. Fix: replace the unpacking assignment with a bare flow.start(tag="pipe_dft") call, since neither return value was used.
DFT Results
The flow completed at 75/75 stages with LVS passing.
| Metric | Value |
|---|---|
| Total flip-flops replaced | 720 |
| Scan chains created | 4 |
| Scan ports | scan_in_1, scan_in_2, scan_in_3, scan_in_4, scan_out_1, scan_out_2, scan_out_3, scan_enable_1 |
| Scan port layer | met2, left edge of die |
| Sequential cell area before scan replace (um²) | 18,918 |
| Sequential cell area after scan replace (um²) | 23,516 |
| Sequential cell area increase | 24.3% |
Output files were produced in runs/pipe_dft/final/ covering DEF, GDS, JSON header, KLayout GDS, LEF, LIB, MAG, metrics CSV, metrics JSON, netlist, ODB, power netlist, SDC, SDF, SPEF, SPICE, and Verilog header.
DRC Results and the OpenRAM Issue
The final design reports 132 DRC errors from KLayout. All 132 errors are nwell.4 violations. Every single one is located within the boundaries of the SRAM macro instances (sram_1rw_16x16). Zero routing DRC errors exist on the standard cell or interconnect portion of the design.
The nwell.4 rule in sky130 checks that every nwell region has a metal-connected N+ tap within a specified distance. OpenRAM-generated SRAM macros for sky130 use an optimized SRAM-specific layout that includes tap structures inside the macro, but the abstract LEF file used during PnR contains only the metal layers that connect externally, not the internal tap cell geometry. When Magic runs DRC on the assembled design, it sees nwells from the macro boundary without seeing the internal tap cells that satisfy the rule.
This is a documented, known limitation. The official OpenLane documentation for OpenRAM integration explicitly notes that SRAM cells in sky130 have a special set of DRC rules; OpenRAM uses these optimized SRAM cells but the current DRC deck is missing these rules, causing false violations. The SkyWater PDK known issues page confirms that Magic does not have DRC checking rules for the specialized exceptions for SRAM cells in the sky130_fd_sp_sram SRAM build space.
These violations exist identically in the non-DFT runs and in every run throughout the low-power experiment series. They are not caused by scan insertion, routing, or any change made in this work.
Additional pre-existing issues present across all runs:
- 1 antenna pin violation and 1 antenna net violation, both in the SRAM macro boundary
- Hold violations in the ss_100C_1v60 corner: pre-existing timing margin issue present in the non-DFT baseline
- Slew and cap violations: pre-existing, attributed to SRAM macro output driver characteristics as described in the low-power section
- 270 disconnected pins: SRAM macro ports (
csb0,spare_wen0) that are not on the routing grid, a known property of the OpenRAM-generated macro’s port placement in sky130. These ports are internally connected within the macro GDS but appear disconnected when the abstract LEF is used during PnR.
LVS passed. The GDS is valid. The 4 scan chains are physically inserted, placed, routed, and verified.