Create Zephyr board files from scratch
Every Zephyr tutorial starts the same way: west build -b nrf52840dk/nrf52840. That works beautifully. Right up until the day your own PCB comes back from the fab. Your board is not a development kit. It has your sensor on it, your LED on a different pin, no QSPI flash, maybe no USB. Zephyr has never heard of it, and until you teach it, you cannot build for it at all. The DK target will happily compile your firmware for the wrong board (wrong pins, wrong peripherals, wrong flash layout), and every bug that creates looks like a firmware bug.
This guide writes the missing board definition, from an empty folder to a building, verifiable target. A board definition (board files, board port) is the set of small files that tells Zephyr: this hardware exists, this is what is soldered to it, build like this. Once it exists, your board is a first-class build target, used exactly like any board in the tree: west build -b fieldnode/nrf52840.
The board we will define is the Fieldnode, a plausible custom board we will treat as our own product hardware: a battery-powered sensor node built around a bare nRF52840. We checked first, in the pinned tree this guide uses (nRF Connect SDK v3.4.1, Zephyr 4.4.2), there is no fieldnode anywhere under zephyr/boards/. It does not exist until we create it. Every file below was created in that environment, the sample application at the end was built for the new target, and the outputs quoted in the verification steps are the real outputs of the files on this page.
By the end you will have:
- A complete board definition:
board.yml, the two Kconfig files, the board devicetree and its pinctrl file, a defconfig, the runner configuration, a Twister platform file and a documentation page (each one written as a numbered procedure, with the why for every line that matters.
- A tiny bring-up application whose whole job is to prove the board files are right, exercising the board's LED, button and console through the standard aliases.
- A verification routine), board discovery, generated devicetree, generated
.config, that proves the build really consumed your files, not something else's.
- A desk test procedure for the day the hardware (or a stand-in) is on your bench.
Meet the board
A board file is a schematic, translated. Fieldnode's schematic reduces to this:
| Fact |
Value |
| SoC |
Nordic nRF52840, QIAA (aQFN73) package: 1 MB flash, 256 KB RAM |
| Main crystal |
32 MHz (the SoC requires it; nothing to declare) |
| Low-frequency crystal |
32.768 kHz crystal fitted (XL1/XL2) |
| Status LED |
One green LED on P0.15, wired active-high |
| User button |
One push button on P0.24, active-low, internal pull-up |
| Console |
UART0 on a debug header: TX P0.06, RX P0.08, 115200 baud, no flow control |
| Debug |
SWD header for a SEGGER J-Link |
| External flash |
None. No QSPI NOR, everything lives in internal flash |
| USB |
Not routed to a connector on this board |
Three entries in that table will shape decisions later, so notice them now:
- The 32.768 kHz crystal is fitted. That single fact decides how the low-frequency clock is configured, and, pleasantly, it means the configuration is the tree's default, which Step 6 cashes in and Step 12 proves from the generated
.config.
- There is no QSPI flash. The nRF52840 DK target describes an external flash chip both to Zephyr and to the flashing tools. Ours must describe neither, and one line the DK needs in its runner file is a line we must leave out (Step 7).
- The LED is active-high, the button active-low. Polarity is a board property, not an application property. It lives in exactly one place, the devicetree, and Step 13 shows what happens when a board disagrees with the firmware about it.
How the build finds a board: and what it does with one
Before writing anything, it pays to know the machine you are feeding. When you run west build -b fieldnode/nrf52840, roughly this happens:
Read it as three ideas:
- Discovery is declarative. The build scans board roots, folders that contain a
boards/ directory, and reads every board.yml it finds. A board exists, as far as the build system is concerned, when its board.yml is somewhere the scan can see. Step 9 exists because an application folder is not scanned by default under sysbuild, and connecting the two is the single most common failure in this whole exercise (you will meet its error message in "If it breaks", because we met it while writing this guide).
- The target string is two facts, not one.
fieldnode/nrf52840 is board / SoC. The board files answer both halves: board.yml names the SoC the board carries, and the board's Kconfig selects the exact silicon variant (package) of that SoC. Mixing those two levels up is the classic beginner mistake; Steps 1 and 2 keep them deliberately separate.
- Your files are merged, not compiled. The board devicetree is layered over the SoC's own devicetree include files; the board defconfig is layered under your application's
prj.conf. The output of that merge, a generated zephyr.dts and a generated .config inside the build folder, is the ground truth of what your board became. Step 12 verifies the board by reading the generated files, never by re-reading the files we wrote. That habit is worth more than any single file in this guide.
Where boards live
Zephyr finds boards in three kinds of places, and choosing between them is a project decision, not a technicality:
- In the Zephyr tree (
zephyr/boards/<vendor>/<board>/), where the DK lives. Submitting a board there is how it ships to everyone; it is also the wrong place for your product's board during development, because your hardware should version with your firmware, not with an SDK.
- In a module. A Zephyr module can carry a
boards/ folder, the same mechanism Guide 1 used to carry a driver. Right when several products share a board platform or a board vendor ships Zephyr support.
- In the application (
<app>/boards/<board>/). The board lives in the product's own repository, next to the code that runs on it. This is where most real custom boards start, and it is where ours will live. If the board later graduates to a module or upstream, the folder moves unchanged: the files do not care which root they were found under.
The project layout follows from that choice. One application folder carries everything:
guides/fieldnode/
└── sample/ the bring-up application
├── CMakeLists.txt
├── prj.conf
├── sysbuild/
│ └── CMakeLists.txt registers the board root (Step 9)
├── src/
│ └── main.c the bring-up check (Step 10)
└── boards/
└── fieldnode/ the board definition
├── board.yml discovery: name, vendor, SoC (Step 1)
├── Kconfig.fieldnode board symbol → SoC variant (Step 2)
├── Kconfig.defconfig board-level Kconfig defaults (Step 3)
├── fieldnode_nrf52840_defconfig baseline config (Step 4)
├── fieldnode_nrf52840-pinctrl.dtsi pin control (Step 5)
├── fieldnode_nrf52840.dts the hardware description (Step 6)
├── board.cmake flash/debug runners (Step 7)
├── fieldnode_nrf52840.yaml Twister platform file (Step 8)
└── doc/index.rst the board's doc page (Step 8)
Ten small files. None of them is long, each of them has exactly one job, and the rest of this guide takes them in dependency order.
Before you start: the procedures assume the environment the Zigbee course sets up in its first chapter, the pinned NCS v3.4.1 toolchain on your PATH, west working, and a shell sitting in your west workspace. Every command below was run in exactly that setup. File paths in commands are relative to the guide's project folder (guides/fieldnode/); adjust the one path in Step 11 if you keep your application elsewhere.
Step 1: board.yml: declare that the board exists
Why this step exists: board discovery (box C in the diagram) reads exactly one file to learn a board exists and what it is called: board.yml. No board.yml, no board. The build will not even warn about your other nine files, because as far as it knows there is nothing to warn about.
Procedure:
- Create the folder that will hold the definition:
sample/boards/fieldnode/. The folder name must be the board's name; discovery keys off it.
- Create
board.yml inside it with the content below.
- Read it against the checklist after the file. Every field is load-bearing.
sample/boards/fieldnode/board.yml:
board:
name: fieldnode
full_name: Fieldnode
vendor: fieldnode
socs:
- name: nrf52840
What each field does, and what it does not do:
name: fieldnode is the board's identity. It appears in the build target (fieldnode/nrf52840), in generated symbols (CONFIG_BOARD_FIELDNODE), and in file names you will write in later steps. Pick it once, in lowercase, and never rename it casually. A board rename is a breaking change for every build script and CI job that names the target.
full_name is for humans: listings and documentation. It carries no build meaning.
vendor: fieldnode groups boards by maker in listings. In-tree, vendors own top-level folders (boards/nordic/…); for an application board the vendor is a label, and using your project or company name is the honest choice.
socs: - name: nrf52840 is the half of the target string this file owns: which SoC the board carries. Note carefully what it says, nrf52840, the SoC, and not nrf52840_qiaa, the package variant. The SoC layer of Zephyr already knows what an nRF52840 is (its CPU, its flash and RAM, its peripheral set) because the SoC is described once, centrally, in the tree. Your board.yml only has to point at it. The variant, which package of that SoC is soldered down, is a different fact, owned by a different file, in the next step. Putting the variant string here is the classic mistake: discovery will look for an SoC literally named nrf52840_qiaa, not find one, and the build stops before your devicetree is ever read.
One more decision is embedded in this file: the list has one SoC. A board that ships in two silicon flavours (the DK does exactly this, carrying an nRF52811 emulation mode as a second entry) lists both, and each becomes its own target, fieldnode/nrf52840 and a sibling. Fieldnode has one hardware reality, so it gets one entry.
Checkpoint: sample/boards/fieldnode/board.yml exists, says name: fieldnode, and its SoC entry is nrf52840 with no package suffix.
Step 2: Kconfig.fieldnode: pick the exact silicon
Why this step exists: board.yml said "an nRF52840". The chip on the reel, though, is a specific package (QIAA, the 7×7 mm aQFN73), and the package decides facts the software can see, down to which GPIO pins physically exist (the smaller packages bond out fewer). Somebody has to select the SoC variant symbol, and by convention that somebody is the board's own Kconfig file, Kconfig.<board>.
Procedure:
- Create
Kconfig.fieldnode in the board folder with the content below.
- Compare it against what it is not: there is no
config block defining BOARD_FIELDNODE itself. The build system generates the board symbols (BOARD_FIELDNODE, and one per target, BOARD_FIELDNODE_NRF52840) from board.yml; this file's job is only to hang configuration off them.
sample/boards/fieldnode/Kconfig.fieldnode:
config BOARD_FIELDNODE
select SOC_NRF52840_QIAA if BOARD_FIELDNODE_NRF52840
The one line does two things worth separating:
select SOC_NRF52840_QIAA pulls in the variant: the pin map, the flash/RAM sizes, the peripheral instances the package exposes. Every driver and library decision downstream reads variant symbols, so this select is the board's single most consequential line of configuration.
if BOARD_FIELDNODE_NRF52840 guards it per target. A one-SoC board does not strictly need the guard today, but the day board.yml grows a second SoC, the unguarded select would force QIAA onto a target it does not belong to. Board files are written once and read for years; write the guard now, while it is free.
Checkpoint: the file selects exactly one variant symbol, and the variant (QIAA) matches the package in the board's spec table, not the package on some other board you copied from. Copying the DK's file verbatim would select the same variant here (the DK also uses QIAA), which is luck, not method: the method is read your own BOM.
Step 3: Kconfig.defconfig: board defaults, not board law
Why this step exists: some configuration is a property of the board, shared by every application that will ever run on it, but is still a default. A well-behaved application may override it. Kconfig needs a place for "when this board is the target, default these symbols so", kept separate from the defconfig's baseline (Step 4). That place is Kconfig.defconfig.
Procedure:
- Create
Kconfig.defconfig in the board folder with the content below.
- Note the shape: an
if BOARD_FIELDNODE wrapper, and inside it only default statements on symbols defined elsewhere. This file defines no new symbols and selects nothing, if you find yourself writing select here, the line belongs in Step 2's file or Step 4's.
sample/boards/fieldnode/Kconfig.defconfig:
if BOARD_FIELDNODE
config HW_STACK_PROTECTION
default ARCH_HAS_STACK_PROTECTION
endif
The single example is deliberately small and real: hardware stack protection. The nRF52840's MPU can guard thread stacks, the SoC layer advertises that ability as ARCH_HAS_STACK_PROTECTION, and a board that wants the protection on by default says so here, once, instead of relying on every application to remember. Because it is a default, an application that must turn it off (a bootloader with unusual constraints, say) still can, in its own prj.conf. Board policy advises, the application decides.
You will know this file is working in Step 12 without touching hardware: the generated .config will contain CONFIG_HW_STACK_PROTECTION=y, a line that exists in no file we wrote as a =y assignment. It got there by default, through this file. That is the proof pattern for every board default you add later.
Checkpoint: the file contains defaults only, wrapped in the board guard. Anything stronger does not belong here.
Step 4: The defconfig: the board's baseline configuration
Why this step exists: when any application builds for this board, a pile of configuration has to come from somewhere before the application's own prj.conf is even read: Is there a console? Is GPIO compiled in? Is the MPU on? fieldnode_nrf52840_defconfig is that baseline, the board's answer to "what does every Fieldnode firmware get, before the application expresses an opinion?" Applications layer on top; they should never have to re-declare that the board has a console.
Note the file's name before its content: <board>_<soc>_defconfig. Like the devicetree in Step 6, the name is a contract. The build looks for exactly this pattern, and a defconfig named anything else is a file the build will never open.
Procedure:
- Create
fieldnode_nrf52840_defconfig in the board folder with the content below.
- For each line, find its consequence in the right-hand column of the table that follows. If you cannot state a line's consequence, the line does not go in your board. Cargo-culted defconfig lines are how boards acquire phantom behaviour nobody can explain two years later.
sample/boards/fieldnode/fieldnode_nrf52840_defconfig:
CONFIG_ARM_MPU=y
CONFIG_GPIO=y
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
| Line |
What it buys |
What breaks without it |
CONFIG_ARM_MPU=y |
The memory protection unit driver. Prerequisite for the stack protection defaulted in Step 3 |
Step 3's default has nothing to stand on |
CONFIG_GPIO=y |
The GPIO subsystem, so the LED and button in the devicetree become devices |
The gpio-leds/gpio-keys nodes describe hardware no driver claims |
CONFIG_SERIAL=y |
The UART driver family |
No serial device for a console to attach to |
CONFIG_CONSOLE=y + CONFIG_UART_CONSOLE=y |
printk output routed to a UART, the board's voice |
The board builds, flashes, runs, and says nothing, forever |
That last row deserves emphasis, because it is the most common board bring-up failure there is: a missing console configuration produces no error anywhere. The build is green, the flash succeeds, the application runs. The terminal is simply silent, and the silence looks exactly like a dead board. A board defconfig exists precisely so this baseline is decided once, by the board author, instead of being rediscovered per application. Our bring-up sample in Step 10 leans on this: its own prj.conf will be nearly empty, and the console will still work, because this file did its job.
What is not in the defconfig is as deliberate as what is. There is no Bluetooth, no networking, no sensor configuration: those are application opinions, and putting them here would force every future application to spend flash on, or actively undo, the board author's guesses. And there is no low-frequency-clock line. Hold that thought; Step 6 explains why the 32.768 kHz crystal needs no line, and Step 12 proves it.
Checkpoint: five assignments, each traceable to a row in the table. If your board needs a sixth (a board with an I2C bus expander every application uses, say), it earns its row in the table first.
Step 5: Pinctrl: which pins the UART owns
Why this step exists: on the nRF52840, almost any peripheral signal can be routed to almost any pin. That freedom has to be resolved somewhere, and Zephyr resolves it with pinctrl: named pin configurations, described in devicetree, that a peripheral node points at. Board files keep pinctrl in its own file, fieldnode_nrf52840-pinctrl.dtsi, because pin routing is the part of a board definition that changes most often between board revisions, and isolating it keeps a revision's diff readable.
Procedure:
- Create
fieldnode_nrf52840-pinctrl.dtsi in the board folder with the content below.
- Check the pin numbers against the spec table: TX P0.06, RX P0.08. The
NRF_PSEL(UART_TX, 0, 6) macro reads as function, port, pin, (UART_TX, 0, 6) is P0.06.
sample/boards/fieldnode/fieldnode_nrf52840-pinctrl.dtsi:
&pinctrl {
uart0_default: uart0_default {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 6)>;
};
group2 {
psels = <NRF_PSEL(UART_RX, 0, 8)>;
bias-pull-up;
};
};
uart0_sleep: uart0_sleep {
group1 {
psels = <NRF_PSEL(UART_TX, 0, 6)>,
<NRF_PSEL(UART_RX, 0, 8)>;
low-power-enable;
};
};
};
Three things to take from this small file:
- Two states, always.
uart0_default is the configuration while the UART is in use; uart0_sleep is what the pins become when the device sleeps, low-power-enable disconnects them so a powered-down peripheral does not leak current through a header cable. Battery-powered boards live or die on details like this, and the pair is the convention: a peripheral node in Step 6 will name both, as pinctrl-0 and pinctrl-1.
bias-pull-up on the RX group only. An unconnected RX pin floats, and a floating UART input reads as garbage and breaks. The pull-up holds the line at its idle level when the debug cable is unplugged. TX needs nothing: it is driven.
- No flow control. The spec table says the header carries TX/RX only, so there are no RTS/CTS psels. The DK's pinctrl file defines four pins for UART0 because the DK routes hardware flow control; copying its groups wholesale into a two-wire board describes pins that do not exist on the header. Pinctrl is where board files most often lie by inheritance. Write only the wires your board has.
Checkpoint: two groups in the default state (TX alone, RX with pull-up), one combined group in sleep. If your board adds a second UART or an I2C bus later, each gets its own named groups in this same file.
Step 6: The board devicetree: the hardware, described once
Why this step exists: everything so far was metadata and configuration. This file is the board: the one place that says what is soldered down and where. Applications, drivers and samples all read the hardware from here (through aliases and chosen nodes, never by hard-coding pins), which is what makes application code portable between your board and a DK. It is the longest file in the definition and the step that repays the most care.
Procedure:
- Create
fieldnode_nrf52840.dts in the board folder with the content below. Like the defconfig, the name is the contract: <board>_<soc>.dts.
- Write it in the order the file presents: the include chain first, then the board's own nodes, then the peripheral enablement at the bottom. The guided tour after the file walks each block.
sample/boards/fieldnode/fieldnode_nrf52840.dts:
/dts-v1/;
#include <nordic/nrf52840_qiaa.dtsi>
#include <nordic/nrf52840_partition.dtsi>
#include "fieldnode_nrf52840-pinctrl.dtsi"
#include <zephyr/dt-bindings/input/input-event-codes.h>
/ {
model = "Fieldnode NRF52840";
compatible = "fieldnode,fieldnode-nrf52840";
chosen {
zephyr,console = &uart0;
zephyr,shell-uart = &uart0;
zephyr,uart-mcumgr = &uart0;
};
leds {
compatible = "gpio-leds";
led0: led_0 {
gpios = <&gpio0 15 GPIO_ACTIVE_HIGH>;
label = "Green LED";
};
};
buttons {
compatible = "gpio-keys";
button0: button_0 {
gpios = <&gpio0 24 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>;
label = "User button";
zephyr,code = <INPUT_KEY_0>;
};
};
aliases {
led0 = &led0;
sw0 = &button0;
watchdog0 = &wdt0;
};
};
®1 {
regulator-initial-mode = <NRF5X_REG_MODE_DCDC>;
};
&gpiote {
status = "okay";
};
&gpio0 {
status = "okay";
gpio-reserved-ranges = <0 2>;
gpio-line-names = "XL1", "XL2", "", "", "", "", "TXD", "",
"RXD", "", "", "", "", "", "", "LED",
"", "", "", "", "", "", "", "",
"BUTTON", "", "", "", "", "", "", "";
};
&gpio1 {
status = "okay";
};
&uart0 {
compatible = "nordic,nrf-uarte";
status = "okay";
current-speed = <115200>;
pinctrl-0 = <&uart0_default>;
pinctrl-1 = <&uart0_sleep>;
pinctrl-names = "default", "sleep";
};
The include chain: stand on the SoC's description
The board file does not describe the nRF52840. That description already exists, written once by the silicon vendor's ecosystem, and the first includes pull it in:
<nordic/nrf52840_qiaa.dtsi>: the SoC itself: CPU, flash and RAM nodes, every peripheral instance with its registers and interrupts, all status = "disabled" until a board says otherwise. Note the variant again: the include names the package (QIAA), matching Step 2's Kconfig select. Two files, one fact, stated in the two places the build reads it from (if they ever disagree, believe neither and go back to the BOM.
<nordic/nrf52840_partition.dtsi>), Nordic's standard internal-flash layout, and the highest-leverage include in the file. It defines, under the SoC's flash node: a mcuboot partition at 0x000000 (48 KiB), image-0 at 0x00C000 (472 KiB), image-1 at 0x082000 (472 KiB) and a 32 KiB storage partition at 0x0F8000, and it sets the zephyr,code-partition chosen node to image-0. Those labels are not decoration: MCUboot (Course 1, Chapter 9) and the OTA machinery (Chapter 10) find their slots by exactly these names. A product board that invents its own names to be different will spend a week discovering why. Our board has no external flash and a completely standard internal layout, so it takes the standard file.
- The board's own pinctrl file from Step 5, included by relative path.
The board's own nodes
model and compatible name the description itself. They land in the generated devicetree and in the firmware's device model; compatible follows the same vendor,board-soc pattern the in-tree boards use.
chosen is where the board answers the questions the whole system asks. zephyr,console = &uart0 is the single most important line in the file, in the sense of most expensive to omit: it is how printk, logging and the shell find their UART. Leave it out and nothing fails to build. The board simply has no voice, the silent-board trap Step 4 warned about, now from the devicetree side. zephyr,shell-uart points the shell at the same wire (an application that enables the shell needs no further directions), and zephyr,uart-mcumgr pre-answers where the MCU manager protocol would live if the product enables it, as Course 1 did. Not every chosen property our file doesn't set is an omission: zephyr,code-partition, zephyr,flash and zephyr,sram are already answered by the partition include, which is why including it beat re-typing them.
leds (gpio-leds) describes the one LED: node label led0, pin &gpio0 15, GPIO_ACTIVE_HIGH, the polarity from the spec table, stated here and nowhere else. Application code will ask for "the LED" and get this pin with this polarity already folded into the GPIO calls.
buttons (gpio-keys) is the same pattern for input: button0 on P0.24 with (GPIO_PULL_UP | GPIO_ACTIVE_LOW), the pull-up is in the description because the board fitted no external one, and a zephyr,code of INPUT_KEY_0, the key code the input subsystem reports for this button. The code comes from the input-event-codes header included at the top of the file.
aliases are the board's public API. led0 = &led0 and sw0 = &button0 are the names Zephyr samples and portable applications are written against, sw0 is the alias half the samples in the tree expect a board to provide. watchdog0 names the SoC's watchdog for the same reason. A board that describes an LED but forgets the alias has described hardware nobody can find by the standard name; Step 10's sample makes that failure loud on purpose.
Peripheral enablement: only what the board wires up
The bottom of the file flips SoC peripherals from disabled to okay, one deliberate node at a time:
®1 selects DC/DC regulator mode. A battery node takes the efficiency; the line costs nothing and the comment in the file says why it is there. (Regular engineers forget this node exists; on a coin-cell product it is measurable runtime.)
&gpiote and &gpio0/&gpio1 enable the GPIO machinery the LED and button hang off. Inside &gpio0, two teaching details: gpio-reserved-ranges = <0 2> fences off P0.00/P0.01: the 32.768 kHz crystal pins, so no future application can claim the clock's pins as GPIO by accident; and gpio-line-names labels the pins a human will probe, in pin order, so debug tooling can show TXD, LED and BUTTON instead of bare numbers.
&uart0 is the console made real: the UARTE compatible, status = "okay", current-speed = <115200> from the spec table, and the two pinctrl states from Step 5 attached as pinctrl-0/pinctrl-1 with their names. Miss the pinctrl properties and the UART exists but is connected to nothing, another member of the silent-failure family, and another reason Step 12 inspects the generated devicetree rather than trusting this file.
The configuration that is not here
Return to the fitted 32.768 kHz crystal. Nothing in this file mentions it, and no line in the defconfig sets the low-frequency clock source. That is correct, and verifiable: the clock driver's Kconfig in the pinned tree declares the source as a choice whose default is the crystal (CLOCK_CONTROL_NRF_K32SRC_XTAL). A board with a crystal is the assumed case. A board without one, several module boards omit it to save cost and area, must say so, with one defconfig line: CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y. Get that wrong on a crystal-less board and the firmware hangs early and silently, waiting for a clock that will never tick: the third member of the silent family. Fieldnode has the crystal, takes the default, and Step 12 will grep the generated .config to prove the default is what the build actually used, because "the default should apply" is a claim, and claims get checked.
Checkpoint: the file includes exactly three things before its own nodes; chosen answers console and shell; LED and button carry the spec table's pins and polarities; aliases led0/sw0 exist; &uart0 is enabled with pinctrl attached. Every one of those will be re-verified from generated output in Step 12.
Step 7: board.cmake: how tools reach the board
Why this step exists: building is only half the workflow; west flash and west debug have to know which host tool drives your debug probe and with what arguments. That knowledge is board-specific, it depends on the header you fitted and the probes you support, so it lives in the board folder, in board.cmake. When it is wrong, builds still pass; only flashing fails, usually at the worst moment.
Procedure:
- Create
board.cmake in the board folder with the content below.
- Match each runner to the spec table: the debug header is wired for a J-Link, so J-Link carries arguments; the two Nordic-native runners are included because a Nordic DK's debug-out port or an nRF probe may be what is actually on the bench.
sample/boards/fieldnode/board.cmake:
board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000")
include(${ZEPHYR_BASE}/boards/common/nrfutil.board.cmake)
include(${ZEPHYR_BASE}/boards/common/nrfjprog.board.cmake)
include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake)
board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000") teaches the J-Link runner which silicon to talk to. The device string is J-Link's name for the chip family (not the Zephyr SoC name. Every tool has its own dialect, and this file is where the translation lives).
- The
include(...) lines pull in the shared runner definitions from the tree's boards/common/. You are declaring support, not implementing flashing: each include makes west flash --runner <name> work for that probe family.
- The line that is not there: the DK's
board.cmake passes the nrfutil runner an --ext-mem-config-file describing its QSPI flash, so the flasher knows how to reach external memory. Fieldnode has no external flash (the spec table said so in Step 0), so there is nothing to describe, including the DK's line would teach the flasher to look for a chip that does not exist. Board files fail by inheritance more than by invention; this file is three includes and one argument list because that is what this board has.
Checkpoint: the runner set matches the probes your bench actually owns, the J-Link device string names your chip in J-Link's dialect, and there is no configuration for hardware the board does not have.
Step 8: The platform file and the doc page: the board, described to tools and humans
Why this step exists: two consumers remain who are neither the compiler nor the flasher. Twister, Zephyr's test runner, discovers testable platforms from a per-target YAML file, without it, your board is invisible to the test infrastructure, and "does the tree still build for my board" stays a manual question. And humans, future you included, need the board's documentation page, which in-tree boards are required to carry in a fixed format. For an application board both files are technically optional. Write them anyway: they cost ten minutes now and are the difference between a board definition and a board product.
Procedure:
- Create
fieldnode_nrf52840.yaml in the board folder (content below). The identifier must be the exact build target string. It is how Twister names the platform.
- Create
doc/index.rst inside a doc/ subfolder of the board folder (content below). Keep it factual and short: it is generated from the same spec table as everything else, and the zephyr:board:: directive ties the page to the board by name.
sample/boards/fieldnode/fieldnode_nrf52840.yaml:
identifier: fieldnode/nrf52840
name: Fieldnode-NRF52840
type: mcu
arch: arm
ram: 256
flash: 1024
toolchain:
- zephyr
- gnuarmemb
supported:
- gpio
vendor: fieldnode
Read it as a capability claim, and keep it an honest one: ram/flash are the SoC's sizes in KiB, toolchain lists the toolchains that can build for the target, and supported names the feature families Twister may schedule tests for. Ours claims only gpio, the one family this guide has actually wired and will actually verify. Claiming ble or usb_device because the silicon supports them, before the board definition and a test have earned it, is how platform files become fiction. Grow the list as the board proves itself.
sample/boards/fieldnode/doc/index.rst:
.. zephyr:board:: fieldnode
Overview
********
The Fieldnode is a battery-powered sensor node built around a bare
Nordic Semiconductor nRF52840 ARM Cortex-M4F SoC. It is the custom
board this repository's firmware ships on: no development-kit extras,
just the peripherals the product uses.
Hardware
********
Fieldnode has two external oscillators. The frequency of the slow
clock is 32.768 kHz. The frequency of the main clock is 32 MHz. There
is no external flash: the SoC's 1 MB of internal flash holds the
bootloader, two application slots and a storage partition.
Supported Features
==================
.. zephyr:board-supported-hw::
Connections and IOs
===================
LED
---
* LED (green) = P0.15
Push buttons
------------
* BUTTON = P0.24
Debug header
------------
* UART0 TX = P0.06, RX = P0.08, 115200 baud
* SWD: SWDIO / SWCLK / RESET for a SEGGER J-Link
Programming and Debugging
*************************
.. zephyr:board-supported-runners::
Applications for the ``fieldnode/nrf52840`` board configuration can
be built, flashed, and debugged in the usual way, using the J-Link
on the debug header:
.. code-block:: console
$ west build -b fieldnode/nrf52840 path/to/app
$ west flash
Debugging is the usual ``west debug`` with the same J-Link.
The two zephyr:board-supported-* directives are the upstream trick worth knowing: when this page is built as part of a documentation tree, those tables are generated from the board's own YAML and devicetree, so the published "supported features" can never drift from the files they describe. For an application board the page mostly serves your own team's docs, but it is in exactly the form an upstream submission requires, which matters the day this board (or the next one) is worth sharing.
Checkpoint: the YAML's identifier string equals the build target you have been typing, and the doc page's pin lists match the devicetree, because both were written from the spec table, not from each other.
Step 9: sysbuild/CMakeLists.txt: connect the board to the build
Why this step exists: your board now exists on disk. The build still cannot see it. Board roots are scanned from the SDK, from modules, and from roots somebody registers, and an application folder is not registered by default. Worse, under sysbuild (the build system NCS uses, which is why the Zigbee course's builds could assemble MCUboot plus an application in one command), the board question is settled before your application's CMakeLists.txt is ever read, so the usual "set it in the app" advice arrives too late. This is the step everyone hits, and the failure is total and unambiguous:
No board named 'fieldnode' found. Did you mean:
Run 'west boards' for the full list.
CMake Error at .../cmake/modules/boards.cmake:221 (message):
Invalid BOARD; see above.
That output is quoted verbatim: it is what this project printed on its first build attempt, with Steps 1 to 8 complete and only this step missing. The fix is a four-line file in a folder most applications do not have yet.
Procedure:
- Create the folder
sample/sysbuild/.
- Create
CMakeLists.txt inside it with the content below.
- Understand the one line that matters before you build:
list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..), CMAKE_CURRENT_SOURCE_DIR is sample/sysbuild/, so .. is the application folder, and the application folder is a valid board root because it contains boards/. The root is the folder that contains boards, never the board folder itself and never the boards folder; getting that level wrong reproduces the error above with extra confusion.
sample/sysbuild/CMakeLists.txt:
list(APPEND BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/..)
find_package(Sysbuild REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(sysbuild LANGUAGES)
The alternative, passing -- -DBOARD_ROOT=<path> on every west build command line, works, and the board-porting documentation describes it, but it makes the board's existence a piece of tribal knowledge every developer and CI job must remember. The sysbuild/CMakeLists.txt makes it a property of the project: clone the repo, build, board found. Properties of the project belong in the project.
Checkpoint: sample/sysbuild/CMakeLists.txt exists, registers the application folder (not the board folder) as a board root, and does so before its find_package(Sysbuild ...) line.
Step 10: The bring-up application: a test that can only pass on the right board
Why this step exists: a board definition is not verified by existing. It is verified by an application behaving correctly on it. That application should be the smallest thing that exercises the board's claims: the console (Step 4 + Step 6's chosen), the LED and the button (Step 6's nodes and aliases). Anything bigger mixes application bugs into board bring-up; anything smaller lets board mistakes slip through silently. This one is deliberately written so that several whole classes of board error fail at build time, the cheapest place any failure will ever happen.
Procedure:
- Create the application's
CMakeLists.txt (below), the ordinary Zephyr application entry point, unchanged from every application you have ever written. Notice what it does not contain: no board knowledge at all. That absence is the design working.
- Create
prj.conf (below) and resist filling it. Read the paragraph after it.
- Create
src/main.c (below). The tour after the file explains the tripwires.
sample/CMakeLists.txt:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(fieldnode_bringup)
target_sources(app PRIVATE src/main.c)
sample/prj.conf:
Yes, it is comments only, and that is a board-definition success being demonstrated, not laziness. The application uses GPIO and a UART console, and enables neither: both came from the board defconfig in Step 4, because they are properties of the board. If you find a board whose every application must re-enable the same five symbols, the board files are unfinished. prj.conf is where application choices live. This application has none.
sample/src/main.c:
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/sys/printk.h>
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios);
static const struct gpio_dt_spec button = GPIO_DT_SPEC_GET(DT_ALIAS(sw0), gpios);
int main(void)
{
int ret;
int was_pressed = 0;
printk("\nFieldnode bring-up, board target: %s\n", CONFIG_BOARD_TARGET);
if (!gpio_is_ready_dt(&led) || !gpio_is_ready_dt(&button)) {
printk("GPIO is not ready — check the board's gpio nodes\n");
return 0;
}
ret = gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
if (ret == 0) {
ret = gpio_pin_configure_dt(&button, GPIO_INPUT);
}
if (ret < 0) {
printk("GPIO configure failed (%d)\n", ret);
return 0;
}
printk("Blinking led0, watching sw0. Press the button.\n");
while (1) {
int pressed;
gpio_pin_toggle_dt(&led);
pressed = gpio_pin_get_dt(&button);
if (pressed != was_pressed) {
printk("sw0 %s\n", pressed ? "pressed" : "released");
was_pressed = pressed;
}
k_sleep(K_MSEC(250));
}
return 0;
}
The tripwires, in order:
GPIO_DT_SPEC_GET(DT_ALIAS(led0), gpios) is a compile-time assertion wearing a trench coat. If the board's devicetree lacks the led0 alias, or the node behind it has no gpios property, the macro chain cannot resolve and the build stops with a devicetree error naming the alias. Board mistake → build failure, before any hardware is involved. Same for sw0. Portable applications are written against aliases for their own convenience; here, the aliases double as the board's exam.
CONFIG_BOARD_TARGET in the banner prints the exact target string from the generated configuration. The board identifying itself, in its own output. When Step 13's desk test runs, that line is the first thing you read: it proves which board definition the running firmware was built from, which matters enormously on the day two similar boards are on the bench.
- The behaviour is the spec table, executed. LED toggles every 250 ms; the button is polled on the same loop and every press and release prints a line. Polarity errors cannot hide: wire the LED's polarity wrong in the devicetree and the blink inverts against the desk observation; get the button's pull or polarity wrong and the prints fire inverted or not at all. Step 13 turns each of those into a named expectation.
Checkpoint: the application refers to hardware only through the led0/sw0 aliases, prints its board target at boot, and its prj.conf adds nothing the board should have provided.
Step 11: Build for your board
Why this step exists: ten files of claims meet reality. The build is the first full traversal of the diagram at the top of this guide (discovery, target resolution, both merges), and it either produces firmware for fieldnode/nrf52840 or tells you which stage rejected your work.
Procedure:
Stand in the folder that contains guides/ (the project root), with the course's pinned toolchain environment active (Chapter 1 of the Zigbee course sets this up; the same terminal you have built every lab in).
Build the bring-up application for the new target:
west build -p always -b fieldnode/nrf52840 guides/fieldnode/sample
-p always forces a pristine build, for a first build of a new board, always pristine: a reused build folder can carry a cached board from an earlier experiment and turn this step into a liar.
Read the end of the output. A successful build of the files on this page ends with the memory report and a line naming the board the files were generated for (your paths will differ; the numbers and the board name are the real output of this project's build):
Memory region Used Size Region Size %age Used
FLASH: 23048 B 1 MB 2.20%
RAM: 6080 B 256 KB 2.32%
Generating files from .../build/sample/zephyr/zephyr.elf for board: fieldnode/nrf52840
Checkpoints while it runs:
- If configure stops immediately with
No board named 'fieldnode' found, do not touch the board files; they were never read. Go to "If it breaks", first entry: this is Step 9's failure, and it is the expected first-build experience (this project hit it too).
- If configure runs long and fails inside Kconfig with an error naming a
SOC_... symbol, the problem is in the Step 1 / Step 2 pair: a variant string where an SoC name belongs, or a select for a variant that does not exist. "If it breaks", second entry.
- If the build compiles and links, the definition is structurally sound. It is not yet verified. A build can succeed while quietly using files you did not write. That is what the next step exists to exclude.
Step 12: Prove it is really your board
Why this step exists: "it built" proves the build system found a coherent board definition. These four checks prove it found yours. Each one reads a generated artifact, not a source file, following the rule from the start of this guide: verify the merge, not the ingredients. Run all four; together they take two minutes.
Check 1, the build consumed your folder. The build's CMake cache records the exact directory the board was taken from:
grep BOARD_DIR build/sample/CMakeCache.txt
The path must end in your sample/boards/fieldnode. This single line rules out the entire class of "it silently used some other definition" accidents.
Check 2, discovery sees the board. west boards lists the boards of the workspace, and prints nothing for Fieldnode, which surprises everyone the first time. It is not a failure: the workspace scan does not include your application folder. The script the build system itself runs for discovery is in the tree, and it accepts extra roots; point it at yours:
python3 $ZEPHYR_BASE/scripts/list_boards.py \
--board-root $ZEPHYR_BASE \
--board-root guides/fieldnode/sample \
--soc-root $ZEPHYR_BASE \
--board fieldnode
It answers with the board's name, from the same code path CMake used in Step 11. (Leave out the --soc-root and the script complains that the SoC is not found. The standalone script needs to be told where SoCs live, which the full build knows implicitly. That error looks alarming and means nothing about your board.) The day this board moves into a module in your workspace, plain west boards lists it too, and this check becomes a formality.
Check 3, the generated .config tells the Kconfig story. Every Kconfig claim from Steps 2 to 4 is testable in one grep:
grep -E "CONFIG_BOARD=|CONFIG_BOARD_TARGET|SOC_NRF52840|K32SRC_XTAL|HW_STACK" \
build/sample/zephyr/.config
Read the seven lines as the receipts for three different steps: BOARD_FIELDNODE and its per-target sibling were generated from board.yml (Step 1); SOC_NRF52840_QIAA arrived via Step 2's select; HW_STACK_PROTECTION=y was never assigned anywhere. It is Step 3's default, firing; and K32SRC_XTAL=y closes the Step 6 cliffhanger: the crystal default really is what the build used, proven from output rather than trusted from documentation. If any expected line is missing, the .config, not your source files, is where the investigation starts, because a Kconfig line whose dependencies are unmet vanishes silently from the output while looking perfectly healthy in the source.
Check 4, the generated devicetree kept your receipts. The build writes the fully merged devicetree to build/sample/zephyr/zephyr.dts, with a comment on nearly every line naming the file and line it came from. Inspect the three claims that matter most:
grep -n "zephyr,console" build/sample/zephyr/zephyr.dts
grep -n -A2 "led_0" build/sample/zephyr/zephyr.dts
grep -n "zephyr,code-partition" build/sample/zephyr/zephyr.dts
The console line's comment names your .dts at line 24, the exact line where you wrote it. The LED's gpios reads < &gpio0 0xf 0x0 >: pin 0xf = 15, flags 0. The devicetree compiler has already folded your GPIO_ACTIVE_HIGH into the raw flag word, which is what "the description was understood" looks like in generated form. And the code partition resolves to the slot0_partition (at 0x00C000, 472 KiB) that arrived via the partition include. When a desk test misbehaves later, this file is where you will come to see what the firmware actually believes about the hardware. It is the single most useful debugging artifact a board author owns.
Step 13: The desk test: run it on hardware
Why this step exists: compile-level verification proves the definition is coherent and was consumed. Only running firmware proves the description matches the metal, pins, polarities and all. If your Fieldnode hardware exists, it is the target. If it does not exist yet, there is a legitimate stand-in, and it is probably already on your desk.
Procedure, on Fieldnode hardware:
Connect the J-Link to the debug header and a terminal to the UART (115200, 8N1, the values from the spec table and the defconfig).
Flash from the build folder: west flash. The runners come from Step 7's board.cmake; if several probe families are attached, say which: west flash --runner jlink.
Reset the board and watch the console. What you should see is fixed by main.c, word for word:
Fieldnode bring-up, board target: fieldnode/nrf52840
Blinking led0, watching sw0. Press the button.
Confirm the LED toggles steadily (the loop toggles every 250 ms). Press and release the button; each edge prints its line:
sw0 pressed
sw0 released
Procedure: the DK as a stand-in. No Fieldnode PCB yet? Flash the same fieldnode build to an nRF52840 DK (west flash works. The DK's onboard debugger is a J-Link, and the chip is the same). Then compare against expectations derived from the two boards' files, line by line:
- Banner and console: exact. Fieldnode's UART0 pins (TX P0.06, RX P0.08) are the very pins the DK wires to its interface MCU, so the console works unmodified. First line must read
board target: fieldnode/nrf52840, on DK hardware. That is the point of the exercise: the firmware believes it is on a Fieldnode, because the board files told it so.
- Button: exact. The DK's BUTTON3 is P0.24, active-low with a pull-up, the same pin and polarity as Fieldnode's button, by the happy accident of the spec table borrowing a sane pin. Press BUTTON3; the
sw0 prints fire.
- LED: inverted, and that is correct. Fieldnode's LED is P0.15, active-high. On the DK, P0.15 is LED3, wired active-low. The firmware drives the pin exactly as Fieldnode's files describe; the DK's LED therefore lights when the firmware "turns it off" and vice versa. Nothing is broken: you are watching a polarity disagreement between two board descriptions, visible in light. Fix it only by changing the board file, never by patching the application.
Verification boundary, stated plainly: everything up to this step was executed and verified in the pinned environment while writing this guide. The files were created, the build ran green, and every quoted output above (discovery, .config, generated devicetree, memory report) is real output from those files. The console behaviour in this step is the expected behaviour of that verified build, derived from its source and from the two boards' published pin tables; run it on your bench and the board will grade the definition itself.
If it breaks
Work the symptom, in this order. The list is sorted by how often each failure actually happens, and the first two together account for most first-board afternoons.
No board named 'fieldnode' found at configure time. The board files were never read. Causes, in order: sample/sysbuild/CMakeLists.txt is missing (Step 9 skipped); or its BOARD_ROOT points at the wrong level, at the boards/ folder or the board folder instead of the folder containing boards/; or the list(APPEND BOARD_ROOT ...) sits after the find_package(Sysbuild ...) line, too late to matter. Nothing inside boards/fieldnode/ can cause or fix this error.
- Discovery or Kconfig errors naming an SoC.
SoC 'nrf52840_qiaa' is not found-family errors mean the variant string wandered into a slot that wants the SoC name, almost always board.yml's socs: list (Step 1). The mirror-image error, a configure-time Kconfig complaint naming a SOC_NRF52840_... symbol, means Step 2 selected a variant that does not exist for this SoC (or the if BOARD_..._NRF52840 guard names a target the board does not have). The rule that prevents both: SoC name in board.yml, variant in Kconfig, package include in the devicetree, one fact, three homes, no improvisation.
- A defconfig line you wrote is missing from the built
.config. Do not debug the file you wrote; debug the generated one (Step 12, Check 3). Kconfig drops assignments whose dependencies are unmet without an error. A CONFIG_UART_CONSOLE=y in a build where the serial subsystem is off simply evaporates. Find the symbol's depends on in the tree, satisfy it, rebuild pristine. This failure mode once cost the Zigbee course a subsystem that "was enabled" for an entire chapter draft; the .config grep is now Step 12 by reflex.
- It builds and flashes, and the terminal is silent. The silent family, work the checklist from the description outward: is
zephyr,console present in the generated devicetree (Check 4)? Are CONFIG_CONSOLE/CONFIG_UART_CONSOLE in the generated .config? Is &uart0 status = "okay" with its pinctrl properties. An enabled UART with no pinctrl is wired to nothing. Then leave the files and check the physics: TX and RX crossed at the header (the board's TX goes to the adapter's RX), and the terminal at 115200.
- Console works; the LED or button lies. Polarity and pins are devicetree facts: LED inverted → the
GPIO_ACTIVE_HIGH/LOW on the led0 node disagrees with how your LED is wired; button reported pressed when released (or a stream of edges at boot) → the button's pull-up flag or active level is wrong; nothing responds at all → the pin number. In all three cases, compare the generated devicetree's raw values (Check 4, 0xf for pin 15) against the schematic before touching code: the application cannot be wrong about a pin it never named.
- The firmware hangs before the banner, on a board with no 32 kHz crystal. That is Step 6's warning arriving on schedule: the tree defaults the low-frequency clock to a crystal that is not there. Add
CONFIG_CLOCK_CONTROL_NRF_K32SRC_RC=y to the board defconfig and rebuild. (Fieldnode fits the crystal, so this entry is for your next board, the one where purchasing saved the crystal away.)
Where the board goes from here
A board definition is a living file, but it grows in the patterns you have already used:
- More hardware is more of Step 5 and Step 6: an I2C sensor gets pinctrl groups and a bus node, and then the out-of-tree driver from Guide 1 drops onto this board unchanged, because the driver never knew which board it was on. That is the two guides' shared point, from opposite directions.
- A second hardware revision keeps the same folder: the hardware model has a revision mechanism (
board.yml grows revision entries; per-revision devicetree and config files sit beside the ones you wrote), so fieldnode/nrf52840 stays the target while the description tracks the PCB spin.
- A second SoC or a shared platform is the graduation signal: when two products share the board, or a second application needs it, move
boards/ into a small Zephyr module of its own, Guide 1's packaging, applied to a board instead of a driver. The files move verbatim; west boards starts listing your board in the workspace; and the doc page and Twister file you wrote in Step 8 are already in the shape an upstream submission would need.
The deeper lesson is the one Step 12 taught: a board file is not finished when it is written, or even when it builds. It is finished when the generated artifacts (the merged devicetree, the merged configuration, the running banner that names its own target), have been read and found to say what the schematic says. Write boards that way, and the day your PCB lands, bring-up is an afternoon of confirmations instead of a week of mysteries.