Skip to content

MCU Programming and Serialization

Flash firmware, assign serial numbers, write MAC and calibration data to PCBAs at end-of-line with traceability and secure provisioning.

TofuPilotEnd-of-LinePythonTofuPilot FrameworkGitHub
MCU Programming and Serialization

Introduction

Programming and Serialization Overview

MCU programming and serialization is the last point in PCBA manufacturing where the device is open for write access before functional test. Three operations sequence in this stage: firmware flash, per-unit data write (serial number, MAC address, calibration constants, keys), and verify-readback. It is frequently merged into the FCT station to save a fixture and one handling step, but logically separable: programming uses debug-port access whereas FCT exercises the running firmware. Many high-mix EMS lines keep them distinct so a programming failure does not block FCT throughput.

Close-up of the PCB under programming: the microcontroller, the 10-pin SWD debug header with the ribbon cable plugged in, and the data-matrix serial sticker in the board corner.

The board as the station sees it: the data-matrix serial the operator scans, the 10-pin SWD header the fixture probes, the MCU that leaves this station with an identity it keeps for life.

The output: every shipped board carries a unique identity (serial, MAC, BLE address), a production-validated firmware image (CRC-verified against the release file), and per-unit calibration constants (ADC offset and gain trim, RTC trim, sensor offsets). This identity stays with the unit through its lifetime, indexed in MES and TofuPilot.

Test Purpose

The programming and serialization procedure produces a per-board record of:

  • Target identity: SW-DP IDCODE, device ID, flash size, so a wrong-variant MCU placed at SMT is rejected before anything is written
  • Allocated MAC address from an IEEE-registered block, plus the pool level
  • Firmware flash through the vendor programmer CLI, with its transcript attached to the run
  • Identity page with serial, MAC and calibration block, recorded as JSON
  • Flash CRC computed by the target against the CRC of the release image, firmware version, readback of the identity
  • Read-out protection level after lock, and the rail state after teardown

Flash map of the 1 MB mock target: 16 KB bootloader region at 0x08000000, 480 KB application region at 0x08008000, 128 KB identity sector at 0x080E0000, with the 32 KB release image and the identity page the station writes shown above.

The mock target's flash: the release image covers the start of the bootloader and application regions, the station writes the identity page in the last sector, and the CRC check covers the image the programmer wrote.

Per-unit programming eliminates mis-flashed firmware revision, missing bootloader, duplicate serial number, blank identity page, MAC pool exhaustion, and unlocked production units. Every shipped board is traceable from the production database to the customer.

Beyond the programming content, this template demonstrates five framework mechanics: an executable phase that runs the programmer CLI and passes on its exit code, string, boolean and JSON measurements next to the numeric ones, a station-scoped plug for the MES connection, unit metadata written from Python, and a file attachment of the programmer transcript on the run.

Equipment & Setup

To implement programming and serialization on a production line, the following are required:

  • A programming tool (single or gang) matched to the target MCU family
  • A test fixture with the SWD / JTAG header on pogo pins and a switched 3.3 V rail
  • A barcode scanner for the board serial and a label printer for the shipping label
  • MES integration for MAC allocation, calibration lookup and result recording
  • The Device Under Test (DUT): an ICT-passed PCBA with an un-programmed MCU
  • A TofuPilot Framework procedure to orchestrate probe, allocate, flash, write, verify and lock
  • The TofuPilot Dashboard to log per-board programming history

End-of-line programming station: pogo-pin fixture holding the PCB, ribbon cable to the SWD programmer, barcode scanner, label printer, and the production test computer showing the flash progress and the unit QR code.

A single-board station: fixture, programmer, scanner, printer and the test computer. Gang programming adds channels, not steps.

Hardware Components

Programming Tool

Selection depends on volume and MCU family:

  • STMicroelectronics ST-LINK V3 with STM32CubeProgrammer: SWD / JTAG plus a UART / I2C / SPI / CAN bridge to exercise the system bootloader from the same fixture. Its STM32_Programmer_CLI is the command this template's executable phase stands in for.
  • SEGGER J-Link family: J-Link Plus / Pro for engineering and low volume, Flasher Compact for medium volume, Flasher ATE and Flasher Hub for gang programming, all scriptable from the test station over USB or Ethernet.
  • Microchip MPLAB PICkit 5 / ICD with MPLAB IPE for PIC / dsPIC; Nordic nrfjprog and pyOCD for Nordic and generic Cortex-M targets.
  • Automated programmers (BPM Microsystems, Data I/O) sit upstream of SMT to pre-program memories and MCUs on tape or tray before placement; the end-of-line station then only writes identity and verifies.

Protocols

  • SWD (ARM 2-wire, pin-compatible subset of JTAG): default for Cortex-M
  • JTAG (IEEE 1149.1): boundary scan plus in-system programming for higher-pin-count parts and FPGAs
  • System bootloader in ROM (STM32: UART, SPI, I2C, USB-DFU, CAN depending on family), when the debug port is not accessible in the fixture
  • I2C / SPI EEPROM: direct write to a side memory holding MAC and serial
  • USB-DFU: when the device's USB port is the only test-accessible interface

Identity Sources

The serial number comes from the data-matrix already on the board (scanned at identification). The MAC address comes from an IEEE-registered block (an MA-L, MA-M or MA-S assignment under the company's OUI) handed out by MES one address per request, so two stations never write the same one. Calibration constants come from upstream test phases keyed by serial. The firmware image is built per release with a fixed layout: bootloader, application, and a reserved identity page the station fills.

Test Procedure

Overview

After the board is loaded into the fixture and its data-matrix scanned, the procedure runs:

  1. Power the 3.3 V rail, check idle current, read the SW-DP IDCODE, device ID and flash size.
  2. Ask MES for a MAC address, record the pool level, stamp the MAC on the unit metadata.
  3. Flash the release image through the programmer CLI (executable phase).
  4. Write the identity page: serial, MAC, calibration block.
  5. Read back the flash CRC and compare it with the CRC of the release image, read the firmware version, check the identity readback, attach the programmer transcript.
  6. Set read-out protection level 1 and confirm the debug port is locked.
  7. Teardown: rail off before the operator opens the fixture.

Phase timeline of one run of the mock station: probe target 11 ms, allocate identity 4 ms, flash firmware 1344 ms through the programmer CLI, write identity 10 ms, verify image 33 ms, lock device 11 ms, power off 6 ms.

One run of the mock station as recorded by the CLI: the programmer transcript is the only phase that takes real time, everything around it is bookkeeping the framework records.

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
probe_target.py
allocate_identity.py
write_identity.py
verify_image.py
lock_device.py
power_off.py
plugs
psu.py
swd.py
mes.py
tools
mock_flash.sh
firmware
app_v2.4.1.hex
utils
intel_hex.py
flash_map.py
README.md
pyproject.toml

The plugs are mocks and the programmer CLI is a shell script that prints a CubeProgrammer-style transcript, so the procedure runs green on any machine. The firmware file is a real 32 KB Intel HEX image whose CRC32 is the value the verify 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: MCU Programming and Serializationversion: 0.1.0description: Flashes the release firmware over SWD, allocates the unit identity from MES, writes the identity page, verifies the image CRC, and locks the debug port.unit:  auto_identify: true  serial_number:    description: "Scan the data-matrix on the board"    placeholder: "UAUT-00000"    pattern: "^UAUT-\\d{5}$"    default_value: "UAUT-04829"  part_number:    default_value: "PCB-MAIN-V2"  metadata:    mac_address:      description: "Allocated by MES during the run"    firmware_version:      description: "Read back from the target after flashing"plugs:  - name: Fixture Power    description: 3.3 V rail through the pogo pins (mock)    python: plugs.psu:FixturePower    key: power  - name: SWD Probe    description: Debug probe on the 10-pin header (mock ST-LINK / J-Link)    python: plugs.swd:SwdProbe    key: swd  - name: MES Identity Service    description: MAC pool and calibration lookup, connection held across units    python: plugs.mes:IdentityService    key: mes    scope: stationmain:  - name: Probe Target    key: probe_target    python: phases.probe_target    measurements:      - name: Rail Voltage        key: rail_voltage        unit: V        validators:          - {operator: ">=", expected_value: 3.2}          - {operator: "<=", expected_value: 3.4}      - name: Idle Current        key: idle_current        unit: mA        description: Unprogrammed target in reset. A short on the rail shows up here before any flash access.        validators:          - {operator: "<=", expected_value: 80.0}      - name: SW-DP IDCODE        key: idcode        validators:          - {operator: "==", expected_value: "0x2BA01477"}      - name: Device ID        key: device_id        description: Rejects a wrong-variant MCU placed at SMT before anything is written.        validators:          - {operator: in, expected_value: ["0x413", "0x419"]}      - name: Flash Size        key: flash_size        unit: KB        validators:          - {operator: "==", expected_value: 1024}  - name: Allocate Identity    key: allocate_identity    python: phases.allocate_identity    depends_on: [probe_target]    measurements:      - name: MAC Address        key: mac_address        validators:          - {operator: matches, expected_value: "^70:B3:D5:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$"}      - name: MAC Pool Remaining        key: pool_remaining        description: Addresses left in the OUI block; reorder below 500.        validators:          - {operator: ">=", expected_value: 500}  - name: Flash Firmware    key: flash_firmware    depends_on: [allocate_identity]    timeout: 120s    executable:      command: "sh tools/mock_flash.sh firmware/app_v2.4.1.hex"      # Real bench: STM32_Programmer_CLI -c port=SWD mode=UR -d firmware/app_v2.4.1.hex -v  - name: Write Identity    key: write_identity    python: phases.write_identity    depends_on: [flash_firmware]    measurements:      - name: Identity Page Address        key: identity_address        validators:          - {operator: "==", expected_value: "0x080E0000"}      - name: Calibration Block        key: calibration_block        description: Per-unit constants written next to the identity, recorded as JSON for later comparison.  - name: Verify Image    key: verify_image    python: phases.verify_image    depends_on: [write_identity]    measurements:      - name: Flash CRC        key: flash_crc        description: CRC32 the target computes over its flash, compared to the CRC of the release image.        validators:          - {operator: "==", expected_value: "0x60A7D0AA"}      - name: Firmware Version        key: firmware_version        validators:          - {operator: in, expected_value: ["2.4.1"]}      - name: Verify Mismatch Bytes        key: verify_mismatch_bytes        unit: B        validators:          - {operator: "==", expected_value: 0}      - name: Identity Readback        key: identity_readback        validators:          - {operator: "==", expected_value: true}  - name: Lock Device    key: lock_device    python: phases.lock_device    depends_on: [verify_image]    measurements:      - name: RDP Level        key: rdp_level        validators:          - {operator: "==", expected_value: 1}      - name: Debug Locked        key: debug_locked        validators:          - {operator: "==", expected_value: true}teardown:  - name: Power Off    key: power_off    python: phases.power_off    measurements:      - name: Rail Off Voltage        key: rail_off_voltage        unit: V        validators:          - {operator: "<=", expected_value: 0.1}

Framework features to notice:

  1. Executable phase: flash_firmware has no Python. The framework runs the command through the shell from the procedure directory, passes the phase on exit code 0 and fails it otherwise; the timeout bounds a programmer that hangs on a dead target. Swapping the mock script for STM32_Programmer_CLI, JLinkExe or nrfjprog is a one-line change.
  2. String measurements: idcode validates with ==, device_id with in (two accepted variants), mac_address with a matches regex pinned to the company's OUI, flash_crc and firmware_version with == and in.
  3. Boolean and JSON measurements: identity_readback and debug_locked are booleans validated == true; calibration_block is a JSON measurement with no validator, recorded for trending.
  4. Station-scoped plug: scope: station keeps the MES connection alive across units in station mode, so back-to-back boards do not pay the reconnection; in a one-shot tofupilot run it behaves as a normal per-execution plug.
  5. Unit metadata from Python: mac_address and firmware_version are declared under unit.metadata and filled by the phases, so the dashboard unit carries both and can be filtered by either.

Probe Target

The first phase powers the rail and reads what is on the other end of the SWD header. The SW-DP IDCODE identifies the debug port (0x2BA01477 is the ARM Cortex-M4 SW-DP), the device ID identifies the silicon (0x413 is STM32F405/407), and the flash size confirms the variant. Together they reject a wrong part before the programmer writes a byte:

phases/probe_target.py
def probe_target(measurements, power, swd, log):    power.enable(3.3)    measurements.rail_voltage = power.measure_rail()    measurements.idle_current = power.measure_current_ma()    idcode = swd.read_idcode()    device_id = swd.read_device_id()    flash_kb = swd.flash_size_kb()    log.info(f"Target {device_id} IDCODE {idcode}, {flash_kb} KB flash")    measurements.idcode = idcode    measurements.device_id = device_id    measurements.flash_size = flash_kb

Allocate Identity

MES hands out the next MAC in the company's block and reports how many are left. The phase records both and stamps the MAC on the unit metadata:

phases/allocate_identity.py
def allocate_identity(measurements, mes, unit, log):    identity = mes.allocate(unit.serial_number)    log.info(f"Allocated MAC {identity['mac']} for {unit.serial_number}")    measurements.mac_address = identity["mac"]    measurements.pool_remaining = mes.pool_remaining()    unit.metadata["mac_address"] = identity["mac"]

Flash Firmware

The executable phase runs the programmer. On the real bench the command is the vendor CLI; the template ships a shell script that prints the same kind of transcript, writes it to flash.log for the verify phase to attach, and exits 0:

text
-------------------------------------------------------------------                        Mock Programmer CLI v2.19.0      -------------------------------------------------------------------ST-LINK SN  : 0670FF3234424E3043212637Voltage     : 3.29VSWD freq    : 4000 KHzConnect mode: Under resetDevice ID   : 0x413Device name : STM32F405xx/F407xx/F415xx/F417xxFlash size  : 1 MBytesMemory Programming ...  File          : firmware/app_v2.4.1.hex  Size          : 32768 Bytes  Address       : 0x08000000Erasing memory corresponding to segment 0:  Erasing internal memory sectors [0 1]Download in Progress:  10%  ...  100%File download completeTime elapsed during download operation: 00:00:01.240Verifying ...Download verified successfully

A non-zero exit from the programmer fails the phase, the dependent phases are skipped, and the teardown still powers the fixture down.

Write Identity

The identity page holds the serial, the MAC just allocated, and the calibration constants MES keeps for this serial. The MAC comes from the previous phase through previous-results injection: naming a parameter after a completed phase's key injects its measurements:

phases/write_identity.py
def write_identity(measurements, swd, mes, unit, allocate_identity, log):    calibration = mes.calibration_for(unit.serial_number)    block = {"serial": unit.serial_number, "mac": allocate_identity.mac_address, **calibration}    address = swd.write_identity(block)    log.info(f"Identity page written at 0x{address:08X}")    measurements.identity_address = f"0x{address:08X}"    measurements.calibration_block = calibration

Assigning a dict to a measurement stores it as JSON, so the exact constants written to this board are on its run without flattening them into separate fields.

Verify Image

The CRC the target computes over its flash is compared with the CRC32 of the release file, computed on the station from the Intel HEX. The firmware version is read back, the identity page is compared with what was allocated, the firmware version lands on the unit metadata, and the programmer transcript is attached to the run:

phases/verify_image.py
from utils.intel_hex import image_crc32FIRMWARE = "firmware/app_v2.4.1.hex"def verify_image(measurements, swd, unit, attach, allocate_identity, log):    expected = image_crc32(FIRMWARE)    actual = swd.read_flash_crc()    version = swd.read_firmware_version()    log.info(f"Flash CRC {actual}, release image {expected}, firmware {version}")    measurements.flash_crc = actual    measurements.firmware_version = version    measurements.verify_mismatch_bytes = swd.verify_mismatch_bytes()    measurements.identity_readback = swd.read_identity().get("mac") == allocate_identity.mac_address    unit.metadata["firmware_version"] = version    attach.file("flash.log", f"{unit.serial_number}_flash.log")

Two details worth knowing: a measurement read back from measurements.<key> returns a proxy, so keep the value in a local variable when you also write it to unit.metadata (metadata accepts strings, numbers and booleans only); and attach.file takes an optional second argument to name the attachment, here prefixed with the serial so the transcript is findable from the unit page.

The release image CRC is a plain utility over the HEX records:

utils/intel_hex.py
import zlibdef read_hex(path):    ext = 0    chunks = []    with open(path) as fh:        for line in fh:            line = line.strip()            if not line.startswith(":"):                continue            raw = bytes.fromhex(line[1:])            count, addr, rtype, data = raw[0], (raw[1] << 8) | raw[2], raw[3], raw[4:-1]            if rtype == 0x04:                ext = ((data[0] << 8) | data[1]) << 16            elif rtype == 0x00:                chunks.append((ext + addr, data))    chunks.sort(key=lambda c: c[0])    return b"".join(d for _, d in chunks)def image_crc32(path):    return f"0x{zlib.crc32(read_hex(path)) & 0xFFFFFFFF:08X}"

Lock Device

After verification passes, read-out protection goes to level 1: the debug port is blocked, the flash cannot be read back, and the setting is reversible with a mass erase. Level 2 is permanent and belongs behind an explicit sign-off, which is why the template stops at 1:

phases/lock_device.py
def lock_device(measurements, swd, log):    swd.set_rdp(1)    level = swd.read_rdp()    log.info(f"Read-out protection level {level}")    measurements.rdp_level = level    measurements.debug_locked = level >= 1

Mock Plugs

The SWD mock returns the identifiers of an STM32F405, keeps the identity page it was given so the readback check has something to compare, and computes the "target" CRC from the same release file. The MES mock hands out sequential MACs from a fixed block and returns a fixed calibration block. Both carry the method surface a real pylink or MES client needs to implement:

plugs/mes.py
class IdentityService:    OUI = "70:B3:D5"  # example IEEE MA-M style prefix    POOL_SIZE = 4096    def __init__(self):        self._next = 0x1A2B00        self._used = 0        print("MES identity service connected")    def allocate(self, serial_number):        mac = f"{self.OUI}:{(self._next >> 16) & 0xFF:02X}:{(self._next >> 8) & 0xFF:02X}:{self._next & 0xFF:02X}"        self._next += 1        self._used += 1        return {"serial": serial_number, "mac": mac, "ble_addr": mac}    def pool_remaining(self):        return self.POOL_SIZE - self._used    def calibration_for(self, serial_number):        """Constants measured upstream (ADC trim, RTC trim) keyed by serial."""        return {"adc_offset_lsb": 8, "adc_gain": 1.012, "rtc_trim_ppm": -3.5}

On a real bench, point the flash_firmware command at the programmer CLI, replace plugs/swd.py with a pylink or pyOCD implementation, and plugs/mes.py with the MES client; the phases, measurements, and limits stay exactly the same.

Run your first test in minutes