Zephyr's Twister hardware map is a YAML file, not a product

Somebody is going to explain hardware-in-the-loop testing to you this year using Zephyr's Twister, and it will be framed as a technique. A YAML file, a debug probe, a self-hosted CI runner, a small Linux box under a desk with six boards hanging off a USB hub.
It is not a technique. It is a schema-validated config file and three command line flags, shipped in the tree, documented in full. I read the whole thing at v4.4.1, tagged 2026-06-10. Here is what is actually specified, and the more interesting half: what is not.
The hardware map is a list of boards with five required fields
The contract lives in scripts/schemas/twister/hwmap-schema.yaml. Sixty-seven
lines. Five required fields per entry: connected, id, platform, product,
runner.1
Everything else is optional, and the optional list is where lab work actually
happens: probe_id, runner_params, serial_pty, serial, baud,
serial_baud, post_script, post_flash_script, pre_script, fixtures,
flash_timeout, flash_with_test, flash_before, script_param,
west_flash_cmd.
This is not a convention Twister tolerates. HardwareMap.load() reads those
exact keys off each entry and validates the file against the schema before
anything gets programmed.2 Misspell fixtures and validation rejects the
file before any board gets touched. Not a warning, not a quietly degraded run.
Zephyr's own documentation carries a two-board example:
- connected: true
id: OSHW000032254e4500128002ab98002784d1000097969900
platform: reel_board
product: DAPLink CMSIS-DAP
runner: pyocd
serial: /dev/cu.usbmodem146114202
baud: 9600
- connected: true
id: 000683759358
platform: nrf52840dk/nrf52840
product: J-Link
runner: nrfjprog
serial: /dev/cu.usbmodem0006837593581
baud: 9600
A reel_board behind a DAPLink probe driven by pyOCD, and an nRF52840 DK behind a J-Link driven by nrfjprog.3 Two vendors, two probe stacks, two flash tools, one host, one flat list.

The runner list is short, and Zephyr says so out loud
--generate-hardware-map walks your serial devices and guesses the runner. The
guess is a dictionary:
runner_mapping = {
'pyocd': ['DAPLink CMSIS-DAP', 'MBED CMSIS-DAP'],
'jlink': ['J-Link', 'J-Link OB'],
'openocd': ['STM32 STLink', '^XDS110.*', 'STLINK-V3', '^Tigard.*', 'KitProg3'],
'dediprog': ['TTL232R-3V3', 'MCP2200 USB Serial Port Emulator']
}
That is the entire autodetect surface.4 Four keys, eleven product strings,
two regexes. Note what is missing: nrfjprog is a legal runner: value (it
is in the example above), but it is not in that table, so autodetect will never
produce it. You type it in by hand.
The docs state the ceiling in a sentence more people should quote:
"Currently only boards with support for pyocd, nrfjprog, jlink, openocd, or dediprog are supported with the hardware map features. Boards that require other runners to flash the Zephyr binary are still work in progress."5
Five runners. If your part programs through something else, the hardware map is not what saves you.
There is one documented door in that wall, and it is instructive. Intel ADSP targets flash over a remote-host protocol with no debug probe at all:
- connected: true
id: None
platform: intel_adsp/cavs25
product: None
runner: intel_adsp
serial_pty: path/to/script.py
runner_params:
- --remote-host=remote_host_ip_addr
- --key=/path/to/key.pem
runner_params passes straight through, making that entry equivalent to
west flash --remote-host remote_host_ip_addr --key /path/to/key.pem.6 id
and product are literally None, and the console is a script behind
serial_pty. So the five-runner limit describes what autodetect and the default
flash path handle, not a hard boundary, and the counterexample sits a few
paragraphs from the sentence declaring the boundary.
Three commands, then the flags you actually end up needing
The generate-then-run loop is two lines, verbatim from Zephyr's docs:
./scripts/twister --generate-hardware-map map.yml
./scripts/twister --device-testing --hardware-map map.yml -T samples/hello_world/
The flags behind them, from argparse: --device-testing,
--generate-hardware-map <file>, --hardware-map <file>, and
--persistent-hardware-map for stable device names on Linux.7
Nobody stops there. The second tier is where the real bench shows up.
--device-flash-timeout defaults to 60 seconds, and some parts do not finish in
60 seconds. --flash-before exists for boards where the programming port and the
console port are the same port, or where USB only appears after software brings
it up. --flash-command discards west flash entirely and runs your script
instead.8
Shared console and programming port. Soft USB. A part slower than a minute to program. Every one of those is a flag because somebody hit it on real hardware and pushed the fix upstream. That is years of lab scar tissue, already committed.

Fixtures are the part worth copying
Fixtures answer "this test needs a wire between two pins". The test declares what it needs, the DUT declares what it has, Twister matches them. In the docs' words: "Some tests require additional setup or special wiring specific to the test... A test scenario can specify the fixture it needs which can then be matched with hardware capability of a board and the fixtures it supports via the command line or using the hardware map file."9
Board side:
- connected: true
fixtures:
- gpio_loopback
id: 0240000026334e450015400f5e0e000b4eb1000097969900
platform: frdm_k64f
product: DAPLink CMSIS-DAP
runner: pyocd
serial: /dev/ttyACM9
Test side, from a real in-tree test at
tests/drivers/uart/uart_async_api/testcase.yaml:
tests:
drivers.uart.async_api:
filter: CONFIG_SERIAL_SUPPORT_ASYNC and not CONFIG_UART_MCUX_LPUART
harness: ztest
harness_config:
fixture: gpio_loopback
depends_on: gpio
Same string on both sides.10 That is the entire contract. The docs add two constraints: one fixture per test scenario, and the name must be unique across the suite.11
The matching is a handful of lines on each side, and both sides split on a colon:
# testinstance.py
fixture = testsuite.harness_config.get('fixture')
if fixture:
can_run = fixture in map(lambda f: f.split(sep=':')[0], fixtures)
# hardwaremap.py
def reserve_dut(self, device, fixture):
for d in self.duts:
if fixture and fixture not in (f.split(sep=':')[0] for f in d.fixtures):
continue
...
That colon is a parameter slot.12 gpio_loopback:PA0-PA1 on the board still
matches fixture: gpio_loopback in the test, and the suffix rides along with
the reservation. Small detail, load-bearing the moment you own four boards with
the same capability wired differently.

Zephyr documents the DUT, not the lab
I went looking for Zephyr's reference lab architecture (the host box, the USB hub, the udev rules, the runner registration). Zephyr's Twister documentation specifies the DUT contract down to the field name, but it does not provide a reference architecture for the host, USB topology, recovery, or CI runner.
What exists instead is third-party layering. Golioth publishes an open-source
repo wiring Twister-based HIL into GitHub Actions self-hosted runners.13 An
independent write-up wraps --device-testing --device-serial plus
--west-flash="--skip-rebuild,--dev-id=..." in a bash loop to cycle-test
boards.14 A Zephyr meetup talk this June covers hardware-in-the-loop at scale
with Twister, pitched as how a consultancy automates testing on real hardware
and scales the infrastructure.15
None of that is bad. It is useful, it is attributed, and it fills a real gap. Each one starts from the same specified DUT contract, then adds the host, cabling, recovery, and CI policy that upstream leaves open. The schema did not change. The flags did not change.
Be careful what you read into Zephyr's own blog, too. There is a case study on the project site involving a Raspberry Pi Pico, and the Pico is the board under test, not the lab controller.16 Conflate those and you end up asserting that Zephyr recommends a Pi-as-lab-host architecture, which the docs never say.
So the honest framing: the mechanism is documented and boring, the lab is undocumented and opinionated. Re-explanation keeps happening at the boring layer, because the opinionated layer is harder to write and does not generalize past your own bench.
What it replaces, and what it does not
The hardware map answers exactly one question: does this test pass on that physical part. That question is worth real money, and it is also the most expensive question in the pipeline, because the answer requires a board, a probe, a cable, and a human when the cable falls out. Most teams never get this far: firmware CI ends at the linker. The map is the HIL slot in what belongs in CI.
Which is why it belongs above a layer that needs none of those. Zephyr already ships hardware-free targets, and the difference between native_sim and unit_testing decides how much of your suite never needs a map entry at all.
The mechanism was never the hard part. Sixty-seven lines of schema, five required fields, five supported runners, one string compared on both sides. Everything genuinely expensive about hardware testing lives in the layer Zephyr chose not to document, and that is exactly the layer each re-explanation skips.
Sources
Footnotes
-
Zephyr v4.4.1,
scripts/schemas/twister/hwmap-schema.yaml(67 lines; required fieldsconnected, id, platform, product, runner). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/schemas/twister/hwmap-schema.yaml ↩ -
Zephyr v4.4.1,
HardwareMap.load()inscripts/pylib/twister/twisterlib/hardwaremap.py:239-293. https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/hardwaremap.py#L239-L293 ↩ -
Zephyr v4.4.1 documentation,
doc/develop/test/twister.rst:1670-1691(two-board hardware map example). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L1670-L1691 ↩ -
Zephyr v4.4.1,
runner_mappinginscripts/pylib/twister/twisterlib/hardwaremap.py:123-139. https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/hardwaremap.py#L123-L139 ↩ -
Zephyr v4.4.1 documentation,
doc/develop/test/twister.rst:1738-1742(supported runner list). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L1738-L1742 ↩ -
Zephyr v4.4.1 documentation,
doc/develop/test/twister.rst:1751-1777(Intel ADSP entry and itswest flashequivalent). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L1751-L1777 ↩ -
Zephyr v4.4.1, argparse definitions in
scripts/pylib/twister/twisterlib/environment.py:170-222, and the documented commands atdoc/develop/test/twister.rst:1605-1732. https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/environment.py#L170-L222 ↩ -
Zephyr v4.4.1, flash-related flags in
scripts/pylib/twister/twisterlib/environment.py:215-271(--device-flash-timeoutdefault 60s,--flash-before,--flash-command). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/environment.py#L215-L271 ↩ -
Zephyr v4.4.1 documentation,
doc/develop/test/twister.rst:1819-1855(fixtures description and the frdm_k64fgpio_loopbackexample). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L1819-L1855 ↩ -
Zephyr v4.4.1,
tests/drivers/uart/uart_async_api/testcase.yaml:9-15. https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/tests/drivers/uart/uart_async_api/testcase.yaml#L9-L15 ↩ -
Zephyr v4.4.1 documentation,
doc/develop/test/twister.rst:597-606(fixture:field definition and uniqueness constraint). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L597-L606 ↩ -
Zephyr v4.4.1, fixture matching in
scripts/pylib/twister/twisterlib/testinstance.py:220-243and DUT reservation inscripts/pylib/twister/twisterlib/hardwaremap.py:488-497. https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/testinstance.py#L220-L243 ↩ -
Golioth,
zephyr_twister_hil_testing(Apache-2.0, self-hosted-runner HIL pattern over GitHub Actions). https://github.com/golioth/zephyr_twister_hil_testing ↩ -
Mike Szczys, "How to use Twister to cycle test Zephyr devices," February 2024. https://jumptuck.com/blog/2024-02-10-twister-device-testing/ ↩
-
Zephyr Project, "What to expect at the Zephyr Project Meetup, June 30 2026, Lyon" (Witekio talk, "Hardware-in-the-Loop at Scale with Zephyr Twister"). https://zephyrproject.org/what-to-expect-at-the-zephyr-project-meetup-june-30-2026-in-lyon-france/ ↩
-
Zephyr Project, "How Zephyr simplified pre-silicon and production firmware development" (case study; the Raspberry Pi Pico appears as a device under test, not a lab host). https://zephyrproject.org/how-zephyr-simplified-pre-silicon-and-production-firmware-development/ ↩