Embedded HandbookApps
All sections › XXVI. From prototype to product
STM32F103, STM32F407 · level intermediate · 20 min read
Open in the interactive handbook

201. Production test and programming on the line

While you have three boards, you program each one at your desk: plug in the ST-LINK, press the button, look at the result. By the fiftieth board this stops working, and by the five hundredth it becomes the main bottleneck. A production test is not "debugging, only faster": it is a different genre. Nobody is looking for a bug in the code — the code was verified before the line (section 184). The line answers one question about every board: was it assembled correctly — is the crystal soldered, is a part value mixed up, does the sensor respond — and it does so in seconds, by script, with a "pass" or "fail" verdict and a line in the log. This section is about how to set up such a station: the fixture, the test firmware, the serial number, the option bytes and the release log. The command-line tools that all of this rests on are covered in section 35б — here they are assembled into a pipeline.

The numbers in this section can be checked: the Flash timings come from the characteristics tables in the datasheets of STM32F103x8/xB and STM32F405/407 in the support packs; the syntax and frequencies of STM32_Programmer_CLI 2.23.0 come from its --help and its own device database (saved in _verify/ptest201/); the OTP and UID addresses come from the header stm32f407xx.h and the reference manuals; the timing calculation is calc_time.py; the self-test and the log were built and run on a computer (gcc and python in Docker), and the self-test core was also compiled for Cortex-M3. Nothing was run against hardware, and no hardware output is quoted. The check is _verify/ptest201_check.py.

Terms used in the documentation

Term Meaning
production test / end-of-line test the test in production, at the end of the line
bed of nails a fixture with spring-loaded contacts
pogo pin a spring-loaded needle contact
test point / test pad a pad on the board for a needle or a probe
DUT (device under test) the device being tested
in-circuit test (ICT) nets and part values checked before power-up
functional test (FCT) the assembled device is checked to work
flying probe a test with moving needles, no fixture needed
golden sample a reference unit used to check the fixture itself
go / no-go "pass / fail": a verdict with no grades in between

201.1 How a line test differs from debugging

At the desk an engineer spends an hour on one board; on the line an operator must get a verdict on the next board within a minute. Everything else follows from that:

Debugging Line test
question why does it not work was it assembled correctly
who an engineer an operator or a machine
time hours per board seconds to minutes per board
tool IDE, GDB, oscilloscope fixture + script, no GDB at all
result understanding "pass / fail" and a line in the log

The time arithmetic is strict: if the line assembles 200 boards per shift — 8 hours — each board gets a cycle time of 144 seconds, and the test has to fit into it together with placing the board on the fixture and taking it off. Whatever does not fit is removed from the test or split off to a second fixture running in parallel.

The classic inspection pipeline has two stages. The in-circuit test (ICT) checks the board while it is still unpowered: needles on the pads measure the resistance and capacitance of nets and look for shorts and open solder joints — on large volumes this is done by a dedicated machine, on small volumes the first half is replaced by "flying probes" or just visual inspection. The functional test (FCT) is what we cover here: the board is powered, programmed and made to test itself. The fixture has to be checked as well: once per shift a golden sample — a board known to be good — is run through it; if the "golden" board suddenly fails, you repair the fixture, not the batch.

Six steps of board release: contact and current check, test firmware over SWD, self-test with a verdict over UART, serial number, production firmware with verification, option bytes — and two outcomes: pass with a line in the log, or fail with the reason and the numbers


201.2 The fixture: a bed of nails and what to bring out to pads

A connector on every board is expensive, so a production fixture is a bed of nails: a fixture in which the board lies with its test pads on spring-loaded contacts (pogo pins). Press the lid down and all connections are made; lift it and the next board goes in. On a small batch the same thing is done more simply: a service connector, holding the board by hand, even soldering three wires — a bed of nails pays off over hundreds of boards, but the set of signals stays the same.

The fixture on the line: the board lies pads down on a bed of nails; from the needles run the ST-LINK on SWDIO, SWCLK and NRST, a USB-UART carrying the self-test verdict, and the fixture power supply with current measurement; a computer with the script and the log controls everything

Which pads to plan already when routing the board (testability is one of those requirements that people remember at the testing stage, section 79):

Pads are brought out to one side of the board (usually the bottom), not under components, and on a grid with clearances for standard needles — these are decisions that cost nothing during routing, and after assembly they can no longer be bought at any price: you cannot draw a pad onto a finished board.

⚠️ NRST on a pad is mandatory. A chip whose firmware immediately goes to sleep or reassigns the SWD pins cannot be reached without the reset line: you need the "connect under reset" technique (section 35б.2). In STM32_Programmer_CLI this is the connection mode mode=UR — but it physically has nothing to hold NRST with if the pin is not routed to a needle. On the line this is not exotic: once protection and the watchdog are enabled (201.6), service access is done exactly this way.


201.3 Programming on the line: the CLI from a script, and how many seconds it costs

On the line you program with the same tools as in section 35б, only they are called not by a person but by the fixture script. The full ST command line:

STM32_Programmer_CLI -c port=SWD freq=4000 sn=57FF6A06... -q \
    -e all -w app.elf -v -rst -log line.log

Everything follows the program's own help: freq= is the SWD frequency in kilohertz (4000 by default for ST-LINK/V2 and 8000 for ST-LINK/V3), sn= is the serial number of the programmer itself, so that a fixture with two ST-LINKs does not mix up the boards, -e all erases everything, -w writes, -v verifies by reading back, -rst resets, -q removes the progress bar, -log writes a detailed protocol to a file. The script checks the command's exit code — as with any program (the same technique as in the build pipeline, section 110). The open alternative in one line is openocd … -c "program fw.elf verify reset exit" (section 35б.3); and when SWD is not available at all, there is still the system bootloader over UART or USB DFU via BOOT0 (sections 4.9 and 35б.7).

Now the arithmetic: how many seconds a megabyte costs over SWD. A write packet for one 32-bit word in the SWD protocol is 8 request bits, bus turnaround, 3 acknowledge bits, turnaround, 32 data bits and a parity bit: 46 clocks; a read is the same 46. At 4 MHz this is 11.5 µs per word; a megabyte holds 262144 words — no less than 3.01 s of pure transfer (at 8 MHz — 1.51 s). This is a lower bound: idle clocks, WAIT responses and USB overhead only push it up. The second component is the Flash itself: on the F407 programming a word takes 16 µs typical (100 max), on the F103 a half-word takes 52.5 µs (40—70). Transfer and programming can overlap (data goes in chunks through a buffer in RAM), so an honest lower estimate is the larger of these two components, not their sum. And the third one is erasing before writing:

Component F103CB, 128 KB F407, 1 MB
erase whole Flash: 20—40 ms whole Flash at x32: 8 typ. / 16 max. s
transfer over SWD, 4 MHz no less than 0.38 s no less than 3.01 s
write (the Flash itself) 65536 half-words × 52.5 µs = 3.44 s 262144 words × 16 µs = 4.19 s
verify (read back) another ~0.4 s another ~3 s
total, lower estimate ≈4 s ≈15 s

The breakdown is instructive. On the F1 erasing is instant, and the bottleneck is the slow half-word write. On the F4 it is the other way round: writing at x32 is fast, but erasing the whole Flash takes 8 seconds typical, and there is no way around it: erasing sectors one by one takes even longer (4 × 0.25 + 0.55 + 7 × 1.0 = 8.55 s at typical times), so -e all is not laziness but an optimization. Programming a megabyte is a noticeable part of the 144-second cycle, and it is worth knowing this before you choose a chip with a large Flash for a mass-produced product.

🔧 Keep the test firmware small. On the line the chip is programmed twice: first the self-test firmware (201.4), and after its verdict the production firmware. A self-test of a few tens of kilobytes loads in a fraction of a second and adds almost nothing to the cycle; there is no reason to pull HAL and the whole project into it.


201.4 The self-test firmware: what it checks and how it reports the verdict

The self-test is a separate small firmware that measures whatever could have been assembled wrong and prints the numbers to the UART. Its core is a table of steps with tolerances; from _verify/ptest201/selftest201.c, which was built and run:

struct check {
    const char *name;            /* имя шага — уходит в журнал */
    uint32_t (*fn)(void);        /* измерение */
    uint32_t lo, hi;             /* допуск: годен, если lo <= v <= hi */
};

static const struct check TAB[] = {
    { "VDD_MV", meas_vdd_mv,   3135,    3465    },  /* 3,3 В +-5 % */
    { "HSE_HZ", meas_hse_hz,   7920000, 8080000 },  /* 8 МГц +-1 % */
    { "UART_N", meas_uart_echo, 16,     16      },  /* вернулись все 16 байт */
    { "I2C_ID", meas_i2c_id,    0x68,   0x68    },  /* датчик назвал свой код */
};

Each step is about the assembly, not about the code. The supply is measured by the ADC against the internal reference VREFINT (section 14): a sagging 3.0 V instead of 3.3 means bad soldering of the regulator. The crystal is checked by counting its pulses with a timer clocked from the internal RC (section 6): an unsoldered or cracked crystal gives zero, not 8 MHz ± 1 %. The UART sends 16 bytes through a loopback on the fixture — which also checks the traces to the pads. The sensor on the I2C bus must report its code in the "who am I" register (section 17). "Visual" steps are usually added here too: blinking the LEDs, the operator pressing a button — but whatever can be measured as a number is better measured as a number.

The verdict is printed line by line, in a machine-readable format:

    for (unsigned i = 0; i < sizeof TAB / sizeof TAB[0]; i++) {
        uint32_t v = TAB[i].fn();
        int pass = v >= TAB[i].lo && v <= TAB[i].hi;
        printf("%02u %-6s %lu %s\r\n", i + 1u, TAB[i].name,
               (unsigned long)v, pass ? "OK" : "FAIL");
        if (!pass && !bad)
            bad = (int)(i + 1u);
    }

The verdict logic is plain C without registers, so it can be built on a computer and checked by running it (the same technique as in sections 79 and 184). A good board in the experiment:

SELFTEST v1.2 UID 066DFF3033564E4343125623
01 VDD_MV 3312 OK
02 HSE_HZ 8001234 OK
03 UART_N 16 OK
04 I2C_ID 104 OK
=== PASS

In the first line the firmware states its version and the 96-bit factory ID of the die — it goes straight into the log (201.5). The numbers are printed in decimal (104 is 0x68), and they are printed always, not only on failure: a log with numbers lets you see later that the "good" boards of a new batch all sit near the lower limit of the tolerance. A board with an unsoldered crystal:

02 HSE_HZ 0 FAIL
=== FAIL 02

The host build returns the number of the failed step as its exit code (in the experiment exit=2) — the fixture script does not even have to parse the text. On the board the role of the exit code is played by the === PASS/=== FAIL line and an LED — green or red, so that the operator sees the verdict without a screen. The core itself is tiny: for Cortex-M3 it compiled to 343 bytes of code.

⚠️ The self-test must not ship to the customer. It is a separate firmware, and after its verdict the production firmware is written to the board (201.3) — with verification. If the final step of the fixture does not check what exactly was written, one day a box of products will ship with the test firmware inside; the "version" field in the log is your safeguard.


201.5 The serial number: the chip UID or your own in OTP

Every die already has a number: the 96-bit UID programmed at the factory (section 2а.4). The reference manual says plainly that it is unique for any die in any context and that it cannot be changed; it is located at 0x1FFFF7E8 on the F1 and at 0x1FFF7A10 on the F4, next to the Flash size register (0x1FFFF7E0 and 0x1FFF7A22). As a traceability key the UID is ideal: free, unique, impossible to forge. But as a "product number" it is awkward: 24 hex digits, no sequential numbering — on the box and on the warranty card you want "No. 000042", not 066DFF30….

So on the line you do both: the UID is read and written to the log, and your own short number is written into the die. Where it goes depends on the family:

🔧 Reading and writing from the CLI — at the addresses above, with commands from the help (-r32 <address> <size>, -w32 <address> <words separated by spaces>, -w8 for the lock byte):

STM32_Programmer_CLI -c port=SWD -r32 0x1FFF7A10 12          # UID: три слова
STM32_Programmer_CLI -c port=SWD -w32 0x1FFF7800 0x0000002A  # № 42 в блок 0 OTP
STM32_Programmer_CLI -c port=SWD -w8 0x1FFF7A00 0x00         # замок блока 0

The firmware gets the number with an ordinary read at the same addresses — OTP and the Flash page are memory-mapped (section 11.5).

⚠️ The OTP lock is irreversible — lock only after verification. The order is strict: write the number → read it back and compare → only then write 0x00 to the lock. A block locked with a typo can never be fixed — 32 bytes are lost, and the product lives with a "wrong" number or moves to the next block (there are only 16 of them).


201.6 Option bytes at release: RDP, watchdog, BOR

During debugging the option bytes (sections 11.6 and 35б.7) are usually left alone. In a product they are not: as its last step the line puts the chip into the "production" state. The names and values below come from CubeProgrammer's own device database for F405/407 (file 0x413) and F1 medium-density (0x410):

Byte In production Why
RDP level 1: F4 — any value except 0xAA and 0xCC (for example 0xBB); F1 — any value except 0xA5 the debugger cannot read the firmware and calibrations; removal only with a mass erase
WDG_SW 0 — hardware watchdog the IWDG (section 12.2) starts by itself at power-up: a hung product restarts even if the firmware forgot to enable the watchdog
BOR_LEV (F4 only) threshold set by the board supply; for 3.3 V — 0 (level 3: reset below 2.70—3.60 V) a supply dip gives a clean reset rather than operation at an invalid voltage; on the F1 the threshold is fixed, nothing to configure
nRST_STOP, nRST_STDBY 1 if the product uses low-power modes (section 10) otherwise the very first entry into Stop/Standby is a reset
STM32_Programmer_CLI -c port=SWD -ob displ      # показать все байты
STM32_Programmer_CLI -c port=SWD -ob WDG_SW=0   # сторож — аппаратный
STM32_Programmer_CLI -c port=SWD -ob BOR_LEV=0  # сброс ниже 2,70—3,60 В
STM32_Programmer_CLI -c port=SWD -ob RDP=0xBB   # защита чтения — последней

RDP is set by the last command of the last step: after level 1 the debugger no longer reads the Flash, so you can neither verify the firmware nor read the OTP any more. For service, level 1 is reversible: -rdu takes the protection back to level 0, in the words of the CLI help — "shifting the RDP level from level 1 to level 0", and this is always a full Flash erase (section 11.6).

⚠️ Level 2 (RDP=0xCC) is a decision with no "back" button. Debugging and the system bootloader are disabled forever; a unit returned from repair can only be reprogrammed through the product's own bootloader (section 48), if it exists and still works. Level 2 is set on the line deliberately, and not in the first month of production.


201.7 The release log: one line per board — forever

A verdict without a log is forgotten within a minute. The release log is a simple file (or a table in a database) to which the fixture appends one line per board — both good ones and failures. From the fixture script journal201.py, run in the experiment:

row = ";".join([f"{serial:06d}", ts, station, uid, "1.4.0", crc] + meas + [verdict])
with open(path, "a", encoding="utf-8") as f:
    f.write(row + "\n")
000042;2026-09-24T10:41:07;ST-01;066DFF3033564E4343125623;1.4.0;96D91CA4;3312;8001234;16;104;PASS

What the line holds: the serial number, the time, the fixture number, the die UID, the version of the production firmware and its CRC32, all measured numbers and the verdict. The script takes the next serial number from the log itself — the last one plus one: the log is the single source of numbering. Storing it is cheap: the line in the experiment is 98 bytes, a hundred thousand boards take less than 10 MB; there is no need for "log rotation" for the release log, it is written once and kept for the whole life of the product.

Why numbers and not just "pass": the log shows what you cannot see on a single board. A batch where VDD_MV drifted from 3312 to 3200 means the supplier is changing the regulator; a rise in failures at step 02 after a new reel of crystals is a conversation with purchasing, not with the programmer. And when a product comes back from the field a year later, the log line for its serial number will tell you which firmware, which fixture and which numbers it left with. Returns and failure analysis are part of support (section 79.8); the log is what makes them possible to analyze.


201.8 Pitfalls

The board programs at the desk but not on the fixture. Between the ST-LINK and the chip there are now needles, fixture wiring and interference from nearby equipment: the SWD frequency you debugged with at the desk gives connection errors on the fixture. The cure is freq=1000 (or lower) in the connection string and checking the needle contact pressure; the symptom "the first boards went fine, then errors started" means worn or dirty needles — they are consumables.

A box of products shipped with the test firmware. The chip is programmed twice, and if the fixture does not verify the production firmware after the self-test (-v) and does not write its version to the log, a skipped step goes unnoticed. The final check is to read the version/CRC from the chip, not to take the script's word for it.

RDP was set before verification. After level 1 the debugger cannot read the Flash: -v, reading the OTP and completing the log are impossible, and removing the protection erases the whole chip. The protection bytes are strictly the last step, after all checks.

The OTP lock was written before the number was verified. 0x00 in the lock byte is irreversible: a block with a typo stays that way forever. First write, read back, compare — then lock (201.5).

Two fixtures issued the same serial number. Each fixture kept its own counter — the numbers collided on the very first parallel run. There must be one source of numbers: a shared log or ranges per fixture (ST-01 — from 1, ST-02 — from 500000), and the fixture number goes into the log line.

The hardware watchdog restarts the self-test. If WDG_SW=0 was written before the test (or remained from a previous attempt), the IWDG starts at power-up, and the self-test, which does not refresh it, is reset halfway through — "the board keeps rebooting, fail", although the board is good. Option bytes go after the self-test; and the service fixture must remember: on released boards the watchdog is already in hardware mode, so connect under reset (mode=UR, 201.2).


201.9 Summary

Full sources

selftest201.c — the self-test firmware: steps, tolerances, verdict.

/* selftest201.c — тестовая прошивка-самопроверка, раздел 201.4.
 *
 * Ядро одно и то же для платы и для компьютера: таблица шагов, допуски,
 * вердикт. На плате meas_* читают ADC, таймер и периферию, а printf
 * уходит в UART; на компьютере (-DHOST) те же шаги получают числа из
 * сценария — логика вердикта проверяется прогоном без железа, как
 * в разделе 184.
 */
#include <stdint.h>
#include <stdio.h>

uint32_t meas_vdd_mv(void);      /* питание: ADC по внутреннему опорному */
uint32_t meas_hse_hz(void);      /* кварц: таймер считает HSE против HSI */
uint32_t meas_uart_echo(void);   /* UART: 16 байт в петлю стенда и обратно */
uint32_t meas_i2c_id(void);      /* датчик платы: регистр «кто я» по I2C */
const uint32_t *uid(void);       /* 96 бит заводского номера (раздел 2а.4) */

struct check {
    const char *name;            /* имя шага — уходит в журнал */
    uint32_t (*fn)(void);        /* измерение */
    uint32_t lo, hi;             /* допуск: годен, если lo <= v <= hi */
};

static const struct check TAB[] = {
    { "VDD_MV", meas_vdd_mv,   3135,    3465    },  /* 3,3 В +-5 % */
    { "HSE_HZ", meas_hse_hz,   7920000, 8080000 },  /* 8 МГц +-1 % */
    { "UART_N", meas_uart_echo, 16,     16      },  /* вернулись все 16 байт */
    { "I2C_ID", meas_i2c_id,    0x68,   0x68    },  /* датчик назвал свой код */
};

int selftest_run(const char *ver)
{
    const uint32_t *u = uid();
    int bad = 0;
    printf("SELFTEST %s UID %08lX%08lX%08lX\r\n", ver,
           (unsigned long)u[2], (unsigned long)u[1], (unsigned long)u[0]);
    for (unsigned i = 0; i < sizeof TAB / sizeof TAB[0]; i++) {
        uint32_t v = TAB[i].fn();
        int pass = v >= TAB[i].lo && v <= TAB[i].hi;
        printf("%02u %-6s %lu %s\r\n", i + 1u, TAB[i].name,
               (unsigned long)v, pass ? "OK" : "FAIL");
        if (!pass && !bad)
            bad = (int)(i + 1u);
    }
    if (bad)
        printf("=== FAIL %02d\r\n", bad);
    else
        printf("=== PASS\r\n");
    return bad;    /* на плате по вердикту зажигается зелёный или красный */
}

#ifdef HOST
/* Сценарии: 0 — все измерения в норме; 1 — кварц молчит, таймер
 * насчитал ноль. Числа «в норме» — обычные для платы с 3,3 В и 8 МГц. */
static const uint32_t S[2][4] = {
    { 3312, 8001234, 16, 0x68 },
    { 3308, 0,       16, 0x68 },
};
static int sc;

uint32_t meas_vdd_mv(void)    { return S[sc][0]; }
uint32_t meas_hse_hz(void)    { return S[sc][1]; }
uint32_t meas_uart_echo(void) { return S[sc][2]; }
uint32_t meas_i2c_id(void)    { return S[sc][3]; }

static const uint32_t UID_HOST[3] = { 0x43125623u, 0x33564E43u, 0x066DFF30u };
const uint32_t *uid(void) { return UID_HOST; }

int main(int argc, char **argv)
{
    sc = argc > 1 && argv[1][0] == '1';
    return selftest_run("v1.2");    /* код возврата читает скрипт стенда */
}
#endif

journal201.py — the fixture script: the release log, one line per board.

# -*- coding: utf-8 -*-
"""Раздел 201.7: журнал выпуска — одна строка на плату.

Скрипт стенда: запускает самопроверку (в опыте — её HOST-сборку),
разбирает вывод и дописывает строку в journal.csv. Серийный номер —
следующий за последним в журнале; CRC32 считается от исходника
самопроверки (на линии — от прошитого образа)."""
import os
import re
import subprocess
import sys
import zlib

exe, sc, ts, station = sys.argv[1:5]

out = subprocess.run([exe, sc], capture_output=True, text=True).stdout
uid = re.search(r"UID ([0-9A-F]{24})", out).group(1)
meas = re.findall(r"^\d\d \S+\s+(\d+) ", out, re.M)
m = re.search(r"=== FAIL (\d+)", out)
verdict = ("FAIL " + m.group(1)) if m else "PASS"
crc = "%08X" % (zlib.crc32(open("selftest201.c", "rb").read()) & 0xFFFFFFFF)

path = "journal.csv"
serial = 42                      # первый номер партии
if os.path.exists(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            if line.strip():
                serial = int(line.split(";")[0]) + 1

row = ";".join([f"{serial:06d}", ts, station, uid, "1.4.0", crc] + meas + [verdict])
with open(path, "a", encoding="utf-8") as f:
    f.write(row + "\n")
print(row)
print("bytes=%d" % (len(row.encode("utf-8")) + 1))

Next: section 201а "Firmware release: version, metadata and one file for production" — where the version and the CRC that the fixture writes to the log come from, and how to build one file for the line.

Open in the interactive handbook