How to Add Custom Board Support in Zephyr

Circuit board with microcontroller and development components for Zephyr RTOS custom board integration

Most Zephyr tutorials on custom board support will lead you straight into a build error. Not because they were wrong when written, but because Zephyr 3.7 quietly deprecated the board format those tutorials describe, and Zephyr 4.x removed it entirely. If you’ve been staring at a Board not found error after following a blog post from 2023, that’s why. The directory structure changed, the identification mechanism changed, and the YAML schema is new. Your custom PCB deserves a board definition that actually matches the build system it’s targeting.

This guide walks you through creating a zephyr custom board definition using the HWMv2 (Hardware Model v2) format, the standard in Zephyr 4.x. By the end, you’ll have a buildable board that boots to a shell on UART, using exactly 6 files in a clean directory tree.

Prerequisites: A working Zephyr SDK, a west workspace, and comfort with the build system, Kconfig, and devicetree basics. We’ll use an nRF5340 application core as the concrete example, but the pattern applies to any supported SoC.

Warning: If you see tutorials referencing a flat boards/arm/my_board/ structure without a board.yml file, you’re looking at the deprecated HWMv1 format. It will not work with Zephyr 4.x.


What Changed in the HWMv2 Board Model

The HWMv2 zephyr board definition model made three structural shifts. First, board identity is now driven by a board.yml YAML file, not by directory naming conventions or board.cmake alone. Second, boards live under a vendor subdirectory. Third, multi-core and multi-variant SoCs (like the nRF5340 with its app and network cores) are first-class citizens through a qualifiers system.

Here’s what changed at the directory level:

# Old HWMv1 (Zephyr ≤ 3.6)          # New HWMv2 (Zephyr 4.x)
boards/                               boards/
└── arm/                              └── <vendor>/
    └── my_custom_board/                  └── my_custom_board/
        ├── board.cmake                       ├── board.yml          ← NEW entry point
        ├── my_custom_board.dts               ├── my_custom_board.dts
        ├── my_custom_board_defconfig         ├── my_custom_board_defconfig
        └── Kconfig.board                     ├── Kconfig.my_custom_board
                                              └── board.cmake

The board.yml is how the build system discovers your board. Without it, your board doesn’t exist to Zephyr.

                  ┌──────────────┐
                  │  board.yml   │  ← Build system entry point
                  └──────┬───────┘
                         │ declares SoC
          ┌──────────────┼──────────────┐
          ▼              ▼              ▼
  ┌──────────────┐ ┌───────────┐ ┌────────────────────┐
  │  .dts file   │ │ _defconfig│ │ Kconfig.board_name │
  │ (hardware)   │ │ (kernel)  │ │ (symbol def)       │
  └──────────────┘ └───────────┘ └────────────────────┘
          │
          │ #include
          ▼
  ┌──────────────────┐
  │ SoC .dtsi        │
  │ (from Zephyr)    │
  └──────────────────┘

Step-by-Step: Create Your Zephyr Custom Board Files

Our example: a custom carrier board called my_custom_board, built around the nRF5340 application core. It has a UART console on pins P0.20 (TX) and P0.22 (RX), and one LED on P0.28.

Pick Your Location: In-Tree vs. Out-of-Tree

You can place your board inside the Zephyr tree at zephyr/boards/<vendor>/, but for product work, out-of-tree is the right call. It keeps your definition independent of Zephyr version updates and lives in your own repo.

Create this structure in your project workspace:

mkdir -p my_boards/boards/mycompany/my_custom_board

Then tell Zephyr where to find it by setting BOARD_ROOT at build time:

west build -b my_custom_board -- -DBOARD_ROOT=$(pwd)/my_boards

Or, for a persistent setup, add it to your west workspace CMakeLists.txt:

list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/my_boards)

Write board.yml — The Discovery File

This is the single most important file. Without valid YAML here, the build system won’t find your board.

board:
  name: my_custom_board
  vendor: mycompany
  socs:
    - name: nrf5340
      variants:
        - name: nrf5340-cpuapp

Field-by-field:

  • name: Must match your directory name and file prefixes exactly.
  • vendor: Must match the vendor directory name. If you’re not an official Zephyr vendor, pick a consistent slug for your company.
  • socs: A list of SoCs this board uses. For the nRF5340, you declare nrf5340 and specify the nrf5340-cpuapp variant since this SoC has two cores. The build system uses this to resolve Kconfig and devicetree at the SoC level.

Running west build -b my_custom_board/nrf5340-cpuapp causes the build system to match this YAML and resolve the full board + SoC combination.

Write the Devicetree Source (.dts)

This file describes your actual hardware: which UART pins you routed, which GPIO drives an LED, where RAM and flash live. Create my_custom_board_nrf5340_cpuapp.dts:

/dts-v1/;
#include <nordic/nrf5340_cpuapp_qkaa.dtsi>
#include "my_custom_board-pinctrl.dtsi"

/ {
	model = "My Custom Board nRF5340 Application Core";
	compatible = "mycompany,my-custom-board";

	chosen {
		zephyr,console = &uart0;
		zephyr,shell-uart = &uart0;
		zephyr,sram = &sram0;
		zephyr,flash = &flash0;
	};

	leds {
		compatible = "gpio-leds";
		led0: led_0 {
			gpios = <&gpio0 28 GPIO_ACTIVE_LOW>;
			label = "Green LED 0";
		};
	};

	aliases {
		led0 = &led0;
	};
};

&uart0 {
	status = "okay";
	current-speed = <115200>;
	pinctrl-0 = <&uart0_default>;
	pinctrl-1 = <&uart0_sleep>;
	pinctrl-names = "default", "sleep";
};

&gpio0 {
	status = "okay";
};

You’ll also need a pin control file. Create my_custom_board-pinctrl.dtsi:

&pinctrl {
	uart0_default: uart0_default {
		group1 {
			psels = <NRF_PSEL(UART_TX, 0, 20)>;
		};
		group2 {
			psels = <NRF_PSEL(UART_RX, 0, 22)>;
			bias-pull-up;
		};
	};

	uart0_sleep: uart0_sleep {
		group1 {
			psels = <NRF_PSEL(UART_TX, 0, 20)>,
				<NRF_PSEL(UART_RX, 0, 22)>;
			low-power-enable;
		};
	};
};

Key points: The #include of the SoC .dtsi gives you all the base peripherals, memory regions, and interrupt controllers. Your .dts only adds what’s specific to your PCB: pin assignments, enabled peripherals, and chosen nodes that the Zephyr kernel needs to locate console, RAM, and flash.

Write the Kconfig Defconfig (_defconfig)

Create my_custom_board_nrf5340_cpuapp_defconfig. This sets kernel and driver defaults for your board:

# Console
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_SERIAL=y

# GPIO (for LED)
CONFIG_GPIO=y

# Shell (optional, enables interactive UART shell)
CONFIG_SHELL=y

# Logging
CONFIG_LOG=y

# ARM-specific
CONFIG_ARM_MPU=y

Think of this as the board-level default configuration. Application developers can still override any of these symbols in their own prj.conf. Your defconfig should enable exactly what the board’s hardware needs to function at a baseline: console output, GPIO for LEDs, and core MCU features. For a deeper dive into layered configuration, see our Kconfig deep-dive.

Write Kconfig.my_custom_board

This small file defines the board-level Kconfig symbol and wires it to the correct SoC:

config BOARD_MY_CUSTOM_BOARD
	select SOC_NRF5340_CPUAPP

That’s the whole file. Its role is narrow but critical: when the build system selects your board, this ensures the correct SoC Kconfig tree is activated. The filename must be Kconfig.<board_name>, because the build system globs for it.

Write board.cmake

This file tells the build system how to flash your board. If you’re using a J-Link debugger:

board_runner_args(jlink "--device=nRF5340_xxAA_APP" "--speed=4000")
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)

If you use nrfjprog instead:

board_runner_args(nrfjprog "--nrf-family=NRF53" "--softreset")
include(${ZEPHYR_BASE}/boards/common/nrfjprog.board.cmake)

Pick the runner that matches your debug probe. You can include multiple runners; the first include becomes the default for west flash.


Build, Flash, and Verify

With all six files in place, build the hello_world sample:

west build -b my_custom_board/nrf5340-cpuapp \
  zephyr/samples/hello_world \
  -- -DBOARD_ROOT=$(pwd)/my_boards

A successful build ends with:

[244/244] Linking C executable zephyr/zephyr.elf
Memory region         Used Size  Region Size  %age Used
           FLASH:       32784 B         1 MB      3.13%
            SRAM:        8640 B       448 KB      1.88%

Flash and connect a serial terminal at 115200 baud:

west flash

You should see:

*** Booting Zephyr OS build v4.1.0 ***
Hello World! my_custom_board/nrf5340-cpuapp

Troubleshooting

ErrorLikely Cause
Board "my_custom_board" not foundBOARD_ROOT not set, or board.yml is missing/malformed. Verify the path resolves to the directory containing boards/.
Devicetree error: undefined nodeMissing #include of the SoC .dtsi, or referencing a node label that doesn’t exist at the SoC level.
CONFIG_SOC_... unmet dependencyKconfig.my_custom_board isn’t selecting the correct SOC symbol. Cross-check against an in-tree board using the same SoC.
pinctrl: undefined NRF_PSELMissing #include "...-pinctrl.dtsi" in your .dts, or the pin control .dtsi file has a naming mismatch.
runner: device not foundboard.cmake specifies the wrong device string or runner for your debug probe.

Where to Go After Your First Build

Once your board builds and boots, here’s what typically comes next:

Board revisions. HWMv2 supports revision-specific configurations. If you’re already planning a rev B of your PCB, you can create my_custom_board_B.conf and my_custom_board_B.overlay files, then build with west build -b my_custom_board/nrf5340-cpuapp@B. This keeps one board definition supporting multiple hardware revisions cleanly.

Shield support. If your product uses modular hardware (sensor boards, display add-ons), Zephyr’s shields system lets you layer devicetree overlays on top of your board definition. See our shields guide for the mechanics.

CI integration. Add your board to twister runs with --board my_custom_board/nrf5340-cpuapp. Point twister at your out-of-tree root with --board-root. This catches regressions against Zephyr upstream updates before they hit production.

Upstream contribution. If your board is based on open hardware, consider contributing it to the Zephyr tree. The nrf5340dk board files under zephyr/boards/nordic/nrf5340dk/ are an excellent reference implementation for the zephyr porting process. Study them to see how Nordic handles multi-core qualifiers and multiple runner configurations.

The whole definition is 6 files: board.yml, .dts, pinctrl .dtsi, _defconfig, Kconfig.<name>, and board.cmake. Once you’ve built one, the second board takes twenty minutes. Keep your definitions out-of-tree, pin them to your product repo, and they’ll survive every Zephyr version bump cleanly.


Hubble Network connects your custom hardware to satellite networks straight from a Bluetooth chip — no extra radios, no gateway infrastructure. See how it works →