Skip to content

Boundary Scan JTAG Test

Test BGA-dense PCBs with IEEE 1149.1 boundary scan to catch hidden solder defects and program flash, FPGA, and bootloader at production.

TofuPilotIn-Circuit TestPythonTofuPilot FrameworkGitHub
Boundary Scan JTAG Test

Introduction

Boundary Scan Overview

IEEE 1149.1 boundary scan (JTAG) is the only practical way to electrically test a PCB whose components are BGA, fine-pitch QFN, or HDI without exposed test points. The standard defines a 4-wire Test Access Port (TCK, TMS, TDI, TDO; optional TRST) and a 16-state TAP controller. Every compliant pin on every JTAG-enabled IC has a boundary scan cell stitched into a shift register around the die periphery. Loading instructions like EXTEST, SAMPLE/PRELOAD, or BYPASS lets a tester drive and observe pin states without physical probe contact.

Cutaway under a BGA package: the grid of solder balls between the chip and the board with one joint missing, and the JTAG header at the board edge that reaches it through the boundary scan cells.

What no probe reaches: the ball grid under a BGA. A missing joint on one ball is an open on one net, and the scan cell behind that ball reports it through the header at the board edge.

Each JTAG-compliant device ships with a BSDL file (a VHDL subset) describing pin-to-cell mapping, instruction opcodes, and the IDCODE. The tester loads the BSDLs for every JTAG device on the board plus the netlist, generates the interconnect vectors, and runs them through the chain. Boundary scan is mandatory for any board with BGA balls, fine-pitch QFN pads, or HDI vias that a bed-of-nails or a flying probe cannot reach, which is most dense designs today.

Test Purpose

Boundary scan produces a per-board structural result plus in-system programming:

  • Infrastructure: every IDCODE on the chain, the BYPASS length, the total cell count
  • Interconnect test (EXTEST): walking patterns across every chain-reachable net, diagnosing shorts, opens and stuck-at faults to a net and a pin
  • Cluster test: the DDR address and data lines exercised through the FPGA's cells
  • In-system programming: the FPGA bitstream written through the same chain, with CRC readback
  • PCOLA-SOQ coverage: the IPC score of what this test proves, per component and per net

Bar chart of the mock netlist by class: 96 BGA-to-BGA, 48 BGA-to-connector, 34 BGA-to-passive and 39 cluster RAM nets reachable by EXTEST, 18 analog and 12 power nets not reachable, 217 of 247 nets covered for 87.9 percent against the 85 percent limit.

The mock netlist by class: everything with a scan cell on at least one end is reachable, the analog and power nets are not. The 85 % coverage limit is a property of the board design, not of the unit, so a drop below it means the wrong netlist or BSDL was loaded.

The procedure catches what ICT physically cannot: BGA cold solder, dry joint, missing via, MCU dead on arrival, bricked FPGA, wrong bitstream, bent connector pin, reversed BGA. Combined with ICT and AOI it gives near-complete structural coverage on modern dense PCBAs.

Beyond the test content, this template demonstrates four framework mechanics: a JSON measurement validated as a whole object for the IDCODE table, a list measurement validated against an empty list for the fault diagnosis, an attachment built in memory for the per-net report, and phase timeouts around the two long-running steps.

Equipment & Setup

To implement boundary scan testing on a production line, the following are required:

  • A JTAG controller (TAP pod) with USB or Ethernet to the test station
  • A boundary scan engine with vector generation, a BSDL library and flash drivers
  • A current-limited DC supply for the bare board
  • The Device Under Test (DUT): a populated, unprogrammed PCB with JTAG access (header or probe pads)
  • CAD inputs: BSDL per JTAG device, the board netlist, the schematic for non-JTAG clusters
  • A TofuPilot Framework procedure to drive the engine, record per-net results and validate every metric
  • The TofuPilot Dashboard to log per-net fault diagnostics and trend BGA defect signatures

Boundary scan station: the PCB with its BGA devices on the ESD mat, the TAP controller on the ribbon cable, the current-limited supply, and the production test computer showing the scan chain with green ticks.

A single-board station: bare board on the mat, TAP pod on the header, current-limited supply, and the test computer running the engine and the procedure.

Hardware Components

JTAG Controller and Engine

Four production tool stacks dominate: ASSET InterTech ScanWorks, Goepel SYSTEM CASCON, XJTAG XJDeveloper / XJRunner, and JTAG Technologies ProVision with its JT37x7 controllers. All of them take BSDLs and a netlist, generate the interconnect vectors, diagnose failures to net and pin, and program flash and FPGAs through the chain. Vendor "fast flash" modes upload a small helper into the target's RAM and write flash through it instead of bit-banging every byte through the boundary cells, which is where the 50× speedups come from. For simple chains on in-house boards, OpenOCD or UrJTAG cover chain scan and programming without the vector generation.

CAD Inputs

  • BSDL per IC: the pin-to-cell map, instruction register length and IDCODE of every JTAG device
  • Board netlist (ODB++, IPC-2581, or a CAD export)
  • Schematic for non-JTAG clusters (RAM, level shifters, glue logic)
  • Cluster models describing transparent buffers and muxes

Missing BSDLs are the first blocker at NPI: allow lead time to obtain them from silicon vendors, or have the tool vendor generate them under support.

Custom Firmware

Boundary scan operates on bare boards with no firmware loaded; the vectors drive pins directly through the TAP. The ISP step of the same station programs the FPGA bitstream (and, on other designs, the bootloader and CPLD) as part of the same run, so structural test and first programming happen at one station.

Test Procedure

Overview

After the bare board is placed on the mat and the TAP cable plugged in, the procedure runs:

  1. Setup: current-limited 3.3 V on the board, quiescent current checked before any chain access.
  2. Infrastructure: scan the chain, compare every IDCODE with the BSDL expectations, check BYPASS length and cell count.
  3. In parallel: EXTEST interconnect test over the netlist with the per-net report attached, and the RAM cluster test.
  4. Program the FPGA bitstream through the chain and read the CRC back.
  5. Score PCOLA-SOQ coverage from the components and nets the test proved.
  6. Teardown: rail off before the TAP cable is unplugged.

Why TofuPilot Framework?

TofuPilot Framework is a YAML + Python test framework built for hardware manufacturing. Instead of writing all your test logic, measurements, and limits inside Python code, you describe what the test does in a procedure.yaml file, and how in small Python phase files. The framework handles:

  • Automatic Python environment management (via uv)
  • Operator UI (no frontend code needed)
  • Measurement validation and live charts
  • Process isolation between phases and equipment plugs

Project Structure

procedure.yaml
phases
power_dut.py
infrastructure.py
interconnect.py
cluster_test.py
program_flash.py
coverage.py
power_off.py
plugs
psu.py
jtag.py
firmware
fpga_bitstream_v1.3.bin
utils
netlist.py
pcola.py
README.md
pyproject.toml

The controller plug is a mock of a three-device chain (an STM32H7 DAP, an Artix-7 FPGA, a CoolRunner-II CPLD) over a synthetic 247-net netlist, so the procedure runs green without a board. The bitstream is a real 256 KB file whose CRC32 the ISP phase expects. Run it with tofupilot run . --no-tui --no-kiosk --json. You can find the full source on GitHub.

The Procedure File

This is the exact file the template ships, verified end to end with the CLI:

procedure.yaml
name: Boundary Scan JTAG Testversion: 0.1.0description: Checks the TAP chain, runs the EXTEST interconnect test and the RAM cluster test over a bare PCBA, programs the FPGA bitstream through the same chain, and scores PCOLA-SOQ coverage.unit:  auto_identify: true  serial_number:    description: "Scan the bare-board data-matrix"    placeholder: "PCB-000000"    pattern: "^PCB-\\d{6}$"    default_value: "PCB-018234"  part_number:    default_value: "PCB-CTRL-V3"plugs:  - name: DUT Supply    description: Current-limited 3.3 V rail for the bare board (mock)    python: plugs.psu:DutSupply    key: psu  - name: Boundary Scan Controller    description: TAP pod plus vendor engine, three-device chain (mock)    python: plugs.jtag:BoundaryScanController    key: jtagsetup:  - name: Power DUT    key: power_dut    python: phases.power_dut    measurements:      - name: Rail Voltage        key: rail_voltage        unit: V        validators:          - {operator: ">=", expected_value: 3.2}          - {operator: "<=", expected_value: 3.4}      - name: Quiescent Current        key: quiescent_current        unit: mA        description: Unprogrammed board at 3.3 V. Above the limit means a short the chain would misdiagnose.        validators:          - {operator: "<=", expected_value: 80.0}main:  - name: Infrastructure    key: infrastructure    python: phases.infrastructure    measurements:      - name: Chain Devices        key: chain_devices        validators:          - {operator: "==", expected_value: 3}      - name: IDCODEs        key: idcodes        description: IDCODE read from each device in TDI to TDO order, compared as one object against the BSDL expectations.        validators:          - operator: "=="            expected_value:              U1: "0x6BA00477"              U4: "0x0362D093"              U7: "0x06E58093"      - name: BYPASS Length        key: bypass_length        unit: bits        validators:          - {operator: "==", expected_value: 3}      - name: Scan Cells        key: scan_cells        validators:          - {operator: "==", expected_value: 1406}  - name: Interconnect    key: interconnect    python: phases.interconnect    depends_on: [infrastructure]    timeout: 300s    measurements:      - name: Nets Total        key: nets_total      - name: Nets Reachable        key: nets_reachable      - name: Net Coverage        key: net_coverage        unit: "%"        validators:          - {operator: ">=", expected_value: 85.0}      - name: Vectors Applied        key: vectors_applied      - name: Faults        key: faults        description: Diagnosed faults as net, pin and fault model; empty on a good board.        validators:          - {operator: "==", expected_value: []}  - name: Cluster Test    key: cluster_test    python: phases.cluster_test    depends_on: [infrastructure]    measurements:      - name: Cluster Errors        key: cluster_errors        validators:          - {operator: "==", expected_value: 0}      - name: Cluster Lines        key: cluster_lines        validators:          - {operator: "==", expected_value: 39}  - name: Program Flash    key: program_flash    python: phases.program_flash    depends_on: [interconnect, cluster_test]    timeout: 600s    ui:      components:        - key: isp_progress          type: progress          label: "FPGA bitstream"          description: "256 KB through the fast-flash helper"          default_value: 0          max: 100    measurements:      - name: ISP Duration        key: isp_seconds        unit: s        validators:          - {operator: "<=", expected_value: 30.0}      - name: ISP Throughput        key: isp_throughput        unit: KB/s        validators:          - {operator: ">=", expected_value: 100.0}      - name: Flash CRC        key: flash_crc        validators:          - {operator: "==", expected_value: "0xEFAA1C46"}      - name: Flash CRC Match        key: flash_crc_match        validators:          - {operator: "==", expected_value: true}  - name: Coverage    key: coverage    python: phases.coverage    depends_on: [interconnect, cluster_test]    measurements:      - name: PCOLA-SOQ        key: pcola_soq        unit: "%"        description: IPC coverage score over components and nets for boundary scan alone; passives on the chain and unreachable nets cap it, ICT and AOI add the rest.        validators:          - {operator: ">=", expected_value: 50.0}      - name: PCOLA-SOQ Breakdown        key: pcola_breakdown      - name: Net Coverage Check        key: net_coverage_check        unit: "%"        validators:          - {operator: ">=", expected_value: 85.0}teardown:  - name: Power Off    key: power_off    python: phases.power_off    measurements:      - name: Rail Off        key: rail_off        unit: V        validators:          - {operator: "<=", expected_value: 0.1}

Framework features to notice:

  1. JSON measurement compared as a whole: idcodes is a dict of reference designator to IDCODE; the == validator compares the entire object, so a wrong variant, a missing device or a swapped chain order all fail the same measurement with the full table on the run.
  2. List validated against []: faults carries the engine's diagnosis (net, pin, fault model). A good board records an empty list; a failed board records exactly what failed, on the run, without a separate report.
  3. Attachment from memory: the per-net CSV is built in the phase and attached with attach.data, no temp file.
  4. Timeouts on the long steps: 300 s for the interconnect run and 600 s for ISP bound a hung pod; the teardown still powers the board down.
  5. Fan-out and fan-in: interconnect and cluster_test both depend on infrastructure; program_flash and coverage depend on both, so a broken chain skips everything downstream with one clear cause.

Infrastructure

Before any vector runs, the chain must be electrically sane: each device's IDCODE must match its BSDL, BYPASS must shift one bit per device, and the cell count must be the sum of the BSDLs. A broken TAP at one device blocks everything downstream, so this phase isolates a chain-level cause before any per-net result is reported:

phases/infrastructure.py
def infrastructure(measurements, jtag, log):    devices = jtag.scan_chain()    idcodes = {d["ref"]: d["idcode"] for d in devices}    log.info("Chain: " + ", ".join(f"{d['ref']} {d['part']} {d['idcode']}" for d in devices))    measurements.chain_devices = len(devices)    measurements.idcodes = idcodes    measurements.bypass_length = jtag.bypass_length()    measurements.scan_cells = sum(d["cells"] for d in devices)

Interconnect

The engine loads EXTEST on every device and applies walking patterns across every reachable net. The phase records the coverage, the vector count and the fault list, and attaches the per-net report as CSV:

phases/interconnect.py
import csvimport iodef interconnect(measurements, jtag, attach, log):    results, faults = jtag.extest_interconnect(8)    reachable = [r for r in results if r["reachable"]]    coverage = 100.0 * len(reachable) / len(results)    log.info(f"EXTEST: {len(reachable)}/{len(results)} nets reachable ({coverage:.1f} %), {len(faults)} faults")    measurements.nets_total = len(results)    measurements.nets_reachable = len(reachable)    measurements.net_coverage = coverage    measurements.vectors_applied = sum(r["vectors"] for r in results)    measurements.faults = faults    buf = io.StringIO()    writer = csv.DictWriter(buf, fieldnames=["name", "class", "reachable", "vectors", "result"])    writer.writeheader()    writer.writerows(results)    attach.data(buf.getvalue().encode(), "interconnect_report.csv")

Plug methods take positional arguments only (they cross a process boundary as JSON), which is why the vector count is passed as 8 and not vectors_per_net=8.

Program Flash

With the structure proven, the same chain programs the FPGA bitstream. The mock models a fast-flash helper at 180 KB/s; the CRC read back from the target is compared with the CRC of the file, and the progress bar tracks the transfer:

phases/program_flash.py
import zlibBITSTREAM = "firmware/fpga_bitstream_v1.3.bin"def program_flash(measurements, jtag, ui, log):    expected = f"0x{zlib.crc32(open(BITSTREAM, 'rb').read()) & 0xFFFFFFFF:08X}"    ui.isp_progress = 5    crc, seconds, rate = jtag.program_flash(BITSTREAM)    ui.isp_progress = 100    readback = jtag.read_flash_crc()    log.info(f"ISP {seconds:.1f} s at {rate:.0f} KB/s, CRC {readback} (image {expected})")    measurements.isp_seconds = seconds    measurements.isp_throughput = rate    measurements.flash_crc = readback    measurements.flash_crc_match = readback == expected

Coverage

The IPC PCOLA-SOQ framework scores what a test proves per component (Presence, Correctness, Orientation, Live, Alignment) and per net (Shorts, Opens, Quality), each 0, 0.5 or 1. Boundary scan scores well on presence, correctness, orientation and live for the chain devices and on shorts and opens for reachable nets; it cannot judge alignment or solder quality, and it says little about a passive hanging off a chain net. The phase computes the score from the component and net counts and reads the interconnect coverage through previous-results injection:

phases/coverage.py
from utils.netlist import NET_CLASSESfrom utils.pcola import scoredef coverage(measurements, interconnect, log):    components = {"jtag_device": 3, "cluster_ram": 2, "passive_on_chain": 34}    reachable = sum(n for n, r in NET_CLASSES.values() if r)    unreachable = sum(n for n, r in NET_CLASSES.values() if not r)    pct, breakdown = score(components, {"reachable": reachable, "unreachable": unreachable})    log.info(f"PCOLA-SOQ {pct:.1f} % (components {breakdown['component_pct']} %, nets {breakdown['net_pct']} %)")    measurements.pcola_soq = pct    measurements.pcola_breakdown = breakdown    measurements.net_coverage_check = float(interconnect.net_coverage)

PCOLA-SOQ breakdown for boundary scan alone: per-component presence, correctness, orientation, live and alignment scores for JTAG devices, cluster RAM and passives on the chain giving 35.9 percent, and per-net shorts, opens and quality for reachable and unreachable nets giving 58.6 percent, 53.8 percent overall against the 50 percent limit.

The honest number for boundary scan alone on this board: 53.8 %. The 34 passives on chain nets and the zero alignment and quality scores are what ICT, AOI and AXI add on top; the value of recording the score per run is that a board revision or a BSDL change moves it visibly.

Mock Plugs

The controller mock carries the three-device chain, walks the synthetic netlist, and computes the target CRC from the same bitstream file. Its method surface is what a wrapper around XJRunner, ProVision or ScanWorks has to provide:

plugs/jtag.py
CHAIN = [    {"ref": "U1", "part": "STM32H743 (ARM DAP)", "idcode": "0x6BA00477", "ir_length": 4, "cells": 0},    {"ref": "U4", "part": "XC7A35T Artix-7", "idcode": "0x0362D093", "ir_length": 6, "cells": 1214},    {"ref": "U7", "part": "XC2C64A CoolRunner-II", "idcode": "0x06E58093", "ir_length": 8, "cells": 192},]class BoundaryScanController:    def __init__(self):        self._nets = nets()        print("TAP pod connected, TCK 10 MHz")    def scan_chain(self):        """Devices found on the chain in TDI -> TDO order, from their IDCODEs."""        return [dict(d) for d in CHAIN]    def bypass_length(self):        """Bits shifted through with every device in BYPASS: one per device."""        return len(CHAIN)    def extest_interconnect(self, vectors_per_net=8):        """Walking-1/0 interconnect test; returns per-net results and the fault list."""        results = []        faults = []        for net in self._nets:            if not net["reachable"]:                results.append({**net, "vectors": 0, "result": "not_covered"})                continue            results.append({**net, "vectors": vectors_per_net, "result": "pass"})        return results, faults

The ARM DAP on the MCU has no boundary cells (it is a debug port on the chain, not a boundary scan device), which is why its cell count is zero and why the interconnect vectors only drive the FPGA and CPLD pins. On a real bench, replace this class with the vendor engine's API; the phases, measurements, and limits stay exactly the same.

Run your first test in minutes