Skip to content
Chiplab logo
Chiplab logo
Back to Now

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.

Annotated Zephyr Twister hardware map YAML showing two boards, a reel_board on pyOCD and an nRF52840 DK on nrfjprog, with the five required schema fields labeled

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.

Flow diagram of the Zephyr Twister hardware map workflow from generate-hardware-map through a manual edit to device-testing, fanning out across pyocd, openocd, jlink, nrfjprog and dediprog to a physical board

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.

Diagram showing a Zephyr testcase.yaml fixture gpio_loopback matched against a hardware map DUT fixtures list, with an unmatched board skipped rather than failed

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

  1. Zephyr v4.4.1, scripts/schemas/twister/hwmap-schema.yaml (67 lines; required fields connected, id, platform, product, runner). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/schemas/twister/hwmap-schema.yaml

  2. Zephyr v4.4.1, HardwareMap.load() in scripts/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

  3. 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

  4. Zephyr v4.4.1, runner_mapping in scripts/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

  5. 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

  6. Zephyr v4.4.1 documentation, doc/develop/test/twister.rst:1751-1777 (Intel ADSP entry and its west flash equivalent). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L1751-L1777

  7. Zephyr v4.4.1, argparse definitions in scripts/pylib/twister/twisterlib/environment.py:170-222, and the documented commands at doc/develop/test/twister.rst:1605-1732. https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/environment.py#L170-L222

  8. Zephyr v4.4.1, flash-related flags in scripts/pylib/twister/twisterlib/environment.py:215-271 (--device-flash-timeout default 60s, --flash-before, --flash-command). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/scripts/pylib/twister/twisterlib/environment.py#L215-L271

  9. Zephyr v4.4.1 documentation, doc/develop/test/twister.rst:1819-1855 (fixtures description and the frdm_k64f gpio_loopback example). https://github.com/zephyrproject-rtos/zephyr/blob/v4.4.1/doc/develop/test/twister.rst#L1819-L1855

  10. 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

  11. 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

  12. Zephyr v4.4.1, fixture matching in scripts/pylib/twister/twisterlib/testinstance.py:220-243 and DUT reservation in scripts/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

  13. Golioth, zephyr_twister_hil_testing (Apache-2.0, self-hosted-runner HIL pattern over GitHub Actions). https://github.com/golioth/zephyr_twister_hil_testing

  14. Mike Szczys, "How to use Twister to cycle test Zephyr devices," February 2024. https://jumptuck.com/blog/2024-02-10-twister-device-testing/

  15. 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/

  16. 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/

Daniel Frassinelli
Published Aug 17, 2026