GuideZephyr · Devicetree~3 hours, following along

Zephyr devicetree, explained by wiring one sensor end to end

Zephyr devicetree, explained by wiring one sensor end to end

Ask a room of Zephyr developers which part of the system they understand least and devicetree wins every time. Not because it is hard, exactly, but because every tutorial teaches it in fragments: an overlay snippet here, a macro there, a wall of generated filenames nobody opens. So the description stays magic. You copy an overlay from a forum, it works or it doesn't, and when it doesn't you have no model of what to check, only superstitions to rearrange.

This guide builds the model by following one sensor through every layer its description passes through. The sensor is a BME280 (temperature, pressure, humidity) on a wire to an nRF52840 DK. By the end, that sensor is described exactly once, in one small file, and you will have watched the description do four separate jobs: select the driver, configure the build, hand the application its facts, and fail the build loudly when it goes missing. Every layer gets opened and read, not gestured at: the board's own devicetree, your overlay, the merged result in the build folder, the generated header the C compiler actually sees, and the generated .config.

The application at the end contains no I2C address, no bus name, and no part number as a string. That is not a style preference. It is the proof that the description, and only the description, carries the hardware facts, so the day the sensor moves to a different bus, or the whole application moves to a different board (Guide 2's territory), the code does not change at all.

By the end you will have:

  • A four-file application (CMake, one Kconfig line, one overlay, one C file) that reads a real sensor once a second, every hardware fact sourced from the devicetree.
  • A working map of the description pipeline: board .dts and pinctrl, overlay merge, zephyr.dts, devicetree_generated.h, and the .config the merge steers.
  • Three proofs you can run on any project, not just this one, that the description you wrote is the description the build used.
  • A deliberate failure, induced and repaired, that teaches you how devicetree problems announce themselves (and how to make them announce themselves better, in words you wrote).
  • A desk test that grades the wiring and the address strap against the description, including the classic wrong-address experiment, fixed in the one place such a fix belongs.

What you need

  • An nRF52840 DK. Any Zephyr board with an exposed I2C bus works; the overlay and the wiring table are the only board-specific parts.
  • A BME280 breakout board (the ubiquitous purple or blue module with four or six pins) and four jumper wires. If your module is a BMP280, stop and swap it if you can: the BMP280 is the same package with no humidity element, and one of this guide's desk tests uses humidity. It will still build and read temperature and pressure, and the "If it breaks" table explains the symptom that gives it away.
  • The pinned workspace from this site's other guides: nRF Connect SDK v3.4.1 (Zephyr 4.4.2) with the toolchain on your path, and a terminal that can run west. Guide 1 sets this up step by step if you are starting cold.
  • About three hours, following along: an hour of reading descriptions, an hour of building and proving, an hour of wiring and testing. The sensor itself is a five-minute wire-up; the time goes on learning to read what the build did with your description, a skill that pays out on every Zephyr project afterwards.

Meet the pieces: what a devicetree is, and is not

A devicetree is a description of hardware written for the build, in a small language of nodes (things: buses, chips, pins) and properties (facts about the things: addresses, speeds, which pins). It is not code. Nothing in it executes. It is compiled, by a tool called dtc and then by Zephyr's own Python tooling, into two things your build consumes: a set of C macros (so code can ask questions like "what address did the description give this node?") and a set of facts Kconfig can test (so the build can ask "is there an enabled node of this kind?" and pull in the matching driver).

Three audiences read the description, and keeping them separate is the whole mental model:

  1. The build system reads it to select code. A driver whose Kconfig says "default on, when an enabled node with my compatible exists" is selected by the description alone. You will watch this happen: this guide's prj.conf never names the sensor driver, and the driver is in the build anyway.
  2. Drivers read it at compile time for their instance data: which bus they sit on, their address, their pins. The BME280 driver knows how to be a BME280; the description tells it where this one lives.
  3. Your application reads it through macros (DT_ALIAS, DEVICE_DT_GET, DT_REG_ADDR, ...), which are all resolved by the preprocessor. By the time your code runs, every devicetree question has already been answered. There is no devicetree at runtime, only its consequences.

And three files do the describing, in layers:

  • The SoC description (Nordic's, in the SDK) names every peripheral the silicon has, at its hardware address, almost all of it status = "disabled". You will rarely open it, but it is the bottom of every merge.
  • The board description (the DK's .dts plus its pinctrl file) enables what the board wires out: this connector is live, on these pins. Step 1 reads it, because it has already done half your work.
  • Your overlay (app.overlay, in the application folder) is applied last and wins: it adds your sensor, on your board, at your address. Overlays extend and amend; a property you restate replaces the earlier value, a node you add joins the tree.

Two special nodes ride along in almost every tree, and this series uses both before it is done. aliases you will meet in Step 3: name-to-node pointers that let applications speak of "the sensor" instead of a path. chosen is its system-level sibling: pointers every subsystem agrees to consult, like zephyr,console (where printk goes), zephyr,shell-uart (where the shell lives; the next guide's coordinator sets it in its overlay, exactly the mechanism you are learning here), and zephyr,flash (which flash device holds the settings). When a subsystem seems to know something you never configured, a chosen pointer is almost always how it knows.

Your schematic
BME280 wired to the Arduino I2C header

Board devicetree
nrf52840dk.dts: the bus, enabled, on P0.26/P0.27

app.overlay
one child node + one alias

Merged devicetree
build/zephyr/zephyr.dts, every line sourced

Generated header
devicetree_generated.h: the macros

Driver selection
CONFIG_BME280=y with no prj.conf line

Application
DEVICE_DT_GET on the alias, nothing hardcoded

One vocabulary warning before the steps, because it causes real confusion: a node's label (bme280: before the node name, a handle other files use to refer to it, written &bme280) is not a name (bme280@76, the node's identity, where 76 is its unit address) and neither is a compatible ("bosch,bme280", the string that marries the node to a binding and a driver). Three different jobs. The steps use each one where it belongs.

Before you start: the application skeleton

This guide keeps its project in a top-level guides/devicetree-sensor/ folder next to the other guide projects, outside the SDK tree:

mkdir -p guides/devicetree-sensor/src

Four files will live there: CMakeLists.txt, prj.conf, app.overlay, src/main.c. Create the first two now; the overlay and the C file get their own steps, because they are the guide.

CMakeLists.txt is the standard application form, nothing devicetree-specific:

# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(devicetree_sensor)

target_sources(app PRIVATE src/main.c)

prj.conf is one real line and a comment that is doing teaching work:

# The sensor subsystem. That is the whole file, and the absence below is
# the lesson of this guide: there is no CONFIG_BME280 line here, and no
# CONFIG_I2C line either. The BME280 driver is pulled in by the
# devicetree node in app.overlay alone (its Kconfig defaults to y when
# an enabled bosch,bme280 node exists), and the I2C controller driver
# comes from the board's own devicetree the same way. Step 6 of the
# guide proves both from the generated .config.
# (Step numbering: the proof lives in Step 6.)
CONFIG_SENSOR=y

Read that comment again, because Step 6 cashes it in: there is deliberately no CONFIG_BME280 and no CONFIG_I2C in this application. If those symbols end up set in the build (and the build needs both), the description put them there. That is the experiment this whole guide is rigged to run.

Checkpoint: the folder holds CMakeLists.txt and prj.conf, and you can say, in one sentence, what is not in prj.conf and why its absence is a test and not an oversight.

Step 1: Read the board's description before writing yours

Your overlay amends the board's description, so read the board first. In the SDK tree, open zephyr/boards/nordic/nrf52840dk/nrf52840dk_nrf52840.dts and find the I2C section. The DK labels its Arduino-header I2C bus arduino_i2c, and the block reads:

arduino_i2c: &i2c0 {
    compatible = "nordic,nrf-twi";
    status = "okay";
    pinctrl-0 = <&i2c0_default>;
    pinctrl-1 = <&i2c0_sleep>;
    pinctrl-names = "default", "sleep";
};

Four facts in six lines, each worth naming:

  1. arduino_i2c: is a label on the SoC's i2c0 node. The SoC description defined the controller (at its hardware address, disabled). This block refers to that same node by its label (&i2c0) and amends it. Your overlay will refer to it by its label, &arduino_i2c. Same node, three descriptions deep.
  2. compatible is restated as "nordic,nrf-twi". The SoC's generic description offered a choice of hardware blocks (the nRF52 has combined TWI/TWIM peripherals); the board picks the two-wire-master flavour. Even Nordic amends descriptions at board level. You are not doing anything unusual; you are doing what every layer does.
  3. status = "okay" is the enable. A peripheral the board does not wire out stays disabled and costs no code and no power. This bus is wired to a header you can touch, so the board enables it. (Your sensor node, when you add it, will be enabled too: a node with no status property at all counts as okay, which is why your overlay will not need the line. The distinction matters in Step 6.)
  4. The pins live somewhere else entirely: pinctrl-0 = <&i2c0_default> is a phandle, a pointer to a pin-control group defined in the companion file nrf52840dk_nrf52840-pinctrl.dtsi:
i2c0_default: i2c0_default {
    group1 {
        psels = <NRF_PSEL(TWIM_SDA, 0, 26)>,
            <NRF_PSEL(TWIM_SCL, 0, 27)>;
    };
};

SDA is P0.26, SCL is P0.27. That is a fact about the DK's copper, recorded once, in the board's files. Your application will never mention these pins, your overlay will not repeat them, and Step 8's wiring table comes straight from this block. When people say "the board file knows the board", this is the entire trick: the facts are written down once, in the description layer, and every consumer reads them from there.

Checkpoint: without looking, name the bus label your overlay will extend, its two pins, and the file each fact came from. If you cannot, reread this step; everything after it leans on these three facts.

Step 2: The binding is the schema

Before describing a node, know what a legal description of it looks like. Every node with a compatible is checked against a binding: a YAML file that declares which properties that kind of node may (and must) carry. The BME280's binding is zephyr/dts/bindings/sensor/bosch,bme280-i2c.yaml, and it is short enough to read whole:

description: BME280 integrated environmental sensor

compatible: "bosch,bme280"

include: [sensor-device.yaml, i2c-device.yaml]

The power is in the includes. i2c-device.yaml is the shared contract for anything that sits on an I2C bus, and it contributes the one property this guide cares most about: reg, required, the device's address on its bus. sensor-device.yaml contributes the sensor-common properties. So a legal BME280 node is a child of an I2C controller node, carrying compatible = "bosch,bme280" and a reg. Nothing else is required. The driver adds its own optional properties in the full binding (sampling choices and the like); the guide's node needs none of them, because the defaults are sane and the guide is about the description, not about tuning a weather sensor.

The marriage works both ways, and Step 6 proves the second direction: the driver's Kconfig (in zephyr/drivers/sensor/bosch/bme280/Kconfig) opens with:

menuconfig BME280
    bool "BME280/BMP280 sensor"
    default y
    depends on DT_HAS_BOSCH_BME280_ENABLED
    select I2C if $(dt_compat_on_bus,$(DT_COMPAT_BOSCH_BME280),i2c)

Read it as a sentence: this driver is on by default, provided the devicetree contains an enabled node whose compatible is bosch,bme280; if that node sits on an I2C bus, also select the I2C subsystem. DT_HAS_BOSCH_BME280_ENABLED is a symbol the build generates from your description. This one depends on is why the experiment in prj.conf works: describe the part, and the build selects the driver and its bus to match. Description first, code follows.

The driver's source completes the circuit, and its last meaningful line is worth knowing by shape even before you read sensor drivers fluently: DT_INST_FOREACH_STATUS_OKAY(BME280_DEFINE). For each enabled instance of my compatible, stamp out a device. The macro it names builds, per node, a config struct whose fields are read from that node's properties (its bus, its address, its sampling choices) and a SENSOR_DEVICE_DT_INST_DEFINE that registers the device object your DEVICE_DT_GET will later find. Two nodes in the tree, two devices in the firmware, zero additional C. This is why Zephyr drivers feel eerily uniform once you have read two of them: they are all the same conversation with the description, conducted through the same handful of macros, and the binding from the top of this step is the contract both sides are honouring.

Checkpoint: say which file decides that reg is required (the binding, via its include), and which decides the driver defaults on (the driver's Kconfig, via its depends on). Two files, two directions, one compatible string joining them.

Step 3: Write the overlay: one node, one alias

Create app.overlay in the application folder. The build applies a file with exactly this name automatically, no registration anywhere:

/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Describe the sensor to the build. This file is the entire hardware
 * description the application needs: which bus, which address, which
 * part. The C code never repeats any of it.
 */

/*
 * The board file already enables the Arduino header's I2C bus
 * (&arduino_i2c is the nRF52840 DK's label for its i2c0 node, pins
 * SDA P0.26 / SCL P0.27) and attaches its pin control. All the overlay
 * adds is the child node: one device, described once.
 *
 * The address is the breakout's strapped address. Most BME280
 * breakouts ship strapped for 0x76 (SDO wired to ground); a board with
 * SDO pulled up answers at 0x77 instead. If the finished build reports
 * the sensor "not ready", this number is the first suspect, and the
 * fix belongs here, in the description, not in the code.
 */
&arduino_i2c {
    bme280: bme280@76 {
        compatible = "bosch,bme280";
        reg = <0x76>;
    };
};

/*
 * A name the application can use. Code that refers to the alias never
 * learns which bus or board the sensor ended up on, so moving the
 * sensor to another bus, or the whole application to another board,
 * changes this file and nothing else.
 */
&{/} {
    aliases {
        env-sensor = &bme280;
    };
};

Line by line, because every line is a concept:

  • &arduino_i2c { ... }; opens the board's bus node for amendment. You are not redefining the bus (the board did that, Step 1); you are adding a child to it. Anything written inside lands on that node in the merged tree.
  • bme280: bme280@76 { ... }; is the child node. bme280: is its label, the handle &bme280 that other descriptions (and your alias, below) use to point at it. bme280@76 is its name: bme280 is the generic node name for the part, and @76 is its unit address, which by devicetree convention repeats the first value of reg. Keep them equal; the tools check that they agree, and a disagreement is the description arguing with itself.
  • compatible = "bosch,bme280"; is the join key from Step 2. It selects the binding that validates this node and the driver that will serve it. Spell it wrong and both go missing, silently (Step 7 shows how the guide's application defends against exactly that).
  • reg = <0x76>; is the address, the one hardware fact the bus needs. The angle brackets are a cell array: devicetree values are 32-bit cells, and <0x76> is a one-cell array holding the address.
  • There is no status line. A node with no status property is enabled by default; status is how you disable things (or enable what a lower layer disabled). Beginners cargo-cult status = "okay" onto every node; now you know it is only load-bearing when something below you said disabled.
  • The second block adds an alias. aliases is a node of phandles: name-to-node pointers the whole system agrees on (led0, sw0, and friends are how boards speak to applications). env-sensor = &bme280; gives your sensor such a name. The application in Step 4 will use only the alias, which is what buys the portability the introduction promised: move the sensor to i2c1 on the next board and the application changes zero lines, because the alias still resolves.

One question worth answering before it bites someone: why is the sensor a child of the bus node at all, instead of a free-standing node with a property saying "my bus is i2c0"? Because the tree is the wiring. Parent-child in devicetree means "sits on, is powered by, is reached through". The build derives DT_BUS() of your node from its parent, the driver finds its bus the same way, and the generated header in Step 6 will name your node /soc/i2c@40003000/bme280@76: a path that reads like the schematic.

Checkpoint: cover the file and reconstruct it from the concepts: parent by label, child with label + name + unit address, compatible, reg, alias. If a line's job is fuzzy, that line is the one to reread.

Where overlays live

One refinement, because you will meet it in real projects within the week. app.overlay is not the only filename the build hunts for. An application folder may also carry boards/<board>.overlay (for instance boards/nrf52840dk_nrf52840.overlay), applied for that board only, and shields and configuration fragments add their own. All the overlay files the build finds are merged, in a defined order, on top of the board description, and where two layers restate the same property the later one wins. This project needs only the one file, but when you inherit a project with three, do not guess which layer set the fact you are staring at: the merged zephyr.dts of Step 6 prints the winning file and line beside every property. The description keeps its own receipts; your job is to ask for them.

Step 4: The application: three devicetree questions, answered at compile time

Create src/main.c:

/*
 * Copyright (c) 2026 Bit to Board
 * SPDX-License-Identifier: Apache-2.0
 *
 * Read a BME280 that the devicetree describes.
 *
 * The application names exactly one thing from the hardware: the
 * env-sensor alias written in app.overlay. The bus, the address and
 * the part itself all reach this file as generated facts. Search this
 * file for 0x76, for i2c0, for "bme280" spelled as a string: the only
 * matches are in comments and in the devicetree macro arguments. That
 * is the layering the devicetree exists to give you.
 */

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/sys/printk.h>

/* The alias is the only hardware name the application uses. */
#define SENSOR_NODE DT_ALIAS(env_sensor)

/*
 * Fail the build, in plain words, if the description is missing.
 * Without an enabled bosch,bme280 node there is nothing to read, and
 * "the build went red with a sentence you wrote" beats "the board
 * booted and the sensor was silently absent" every single time.
 * Delete or rename app.overlay and rebuild to watch this fire.
 */
#if DT_NUM_INST_STATUS_OKAY(bosch_bme280) == 0
#error "No enabled bosch,bme280 node in the devicetree. Is app.overlay in the application folder, and is its node status okay?"
#endif

static const struct device *const sensor = DEVICE_DT_GET(SENSOR_NODE);

/*
 * Print a sensor_value as fixed point with three decimals, spelled
 * out by hand: a sensor_value is an integer part plus a micro part
 * that shares its sign, and the naive "%d.%06d" prints a reading of
 * minus half a degree as "0.-500000". Converting to milli first keeps
 * the sign in exactly one place.
 */
static void print_reading(const char *label, const struct sensor_value *val,
              const char *unit)
{
    int32_t milli = val->val1 * 1000 + val->val2 / 1000;
    int32_t whole = milli / 1000;
    int32_t frac = milli - whole * 1000;

    if (frac < 0) {
        frac = -frac;
    }
    printk("%s %s%d.%03d %s\n", label,
           (milli < 0 && whole == 0) ? "-" : "", whole, frac, unit);
}

int main(void)
{
    struct sensor_value temp, press, humidity;

    /*
     * Everything on this line was read out of the generated
     * devicetree at compile time: the node's path, its reg value,
     * the name of the bus node it hangs from. Change app.overlay,
     * rebuild, and this line changes with it. Nothing was typed here.
     */
    printk("Sensor node: %s\n", DT_NODE_PATH(SENSOR_NODE));
    printk("I2C address: 0x%02x, on bus node %s\n",
           DT_REG_ADDR(SENSOR_NODE), DT_NODE_PATH(DT_BUS(SENSOR_NODE)));

    if (!device_is_ready(sensor)) {
        printk("Sensor %s is not ready. Check the wiring first, "
               "then the strapped address against reg in app.overlay.\n",
               sensor->name);
        return 0;
    }

    printk("Reading %s once a second. Breathe on it.\n", sensor->name);

    while (1) {
        if (sensor_sample_fetch(sensor) == 0) {
            sensor_channel_get(sensor, SENSOR_CHAN_AMBIENT_TEMP, &temp);
            sensor_channel_get(sensor, SENSOR_CHAN_PRESS, &press);
            sensor_channel_get(sensor, SENSOR_CHAN_HUMIDITY, &humidity);

            print_reading("temp    ", &temp, "deg C");
            print_reading("pressure", &press, "kPa");
            print_reading("humidity", &humidity, "%RH");
            printk("\n");
        } else {
            printk("sample fetch failed (bus error - wiring? address?)\n");
        }
        k_sleep(K_SECONDS(1));
    }
    return 0;
}

It is an ordinary sensor program (fetch, get three channels, print, sleep) with three devicetree moments, and they are the whole point:

  1. #define SENSOR_NODE DT_ALIAS(env_sensor): the application asks for "the node the alias points at", once. Every later use is in terms of that answer. The C file never contains 0x76, never contains i2c0, never contains the string "bme280" outside comments and macro arguments. Run the layering check yourself: grep -n "0x76\|i2c0" src/main.c finds only comments. If a hardware fact ever creeps into code, it will be in addition to the description, and the two will eventually disagree. Keep the description the only source.
  2. The #if DT_NUM_INST_STATUS_OKAY(bosch_bme280) == 0 guard: a compile-time census: "how many enabled instances of this compatible exist?" If the answer is zero (overlay missing, misnamed, compatible misspelled), the build stops with your sentence, not with a silent boot into a sensorless program. Step 7 fires this on purpose. This is a pattern worth stealing for every project: pick the one description fact your application cannot live without, and assert it in words a tired human understands at 1 a.m.
  3. DEVICE_DT_GET(SENSOR_NODE) plus the boot printout: the device object is created by the driver, at an init priority the driver chose, bound to the bus device the description named; the application just holds the handle. And the first two lines it prints (DT_NODE_PATH, DT_REG_ADDR, DT_BUS) are the description quoting itself at runtime: if the overlay changes, the banner changes. On the desk, that banner is your fastest check that the firmware on the board is the firmware you think it is.

One question this code raises, worth answering before the desk test: if DEVICE_DT_GET hands over the device at compile time, why does the program still ask device_is_ready()? Because existing and being initialised are different facts. Zephyr initialises devices in devicetree dependency order, parents before children: the I2C controller's init runs before your sensor's because the tree says the sensor sits on that bus, and the sensor driver's init is where it probes the part and decides the device is usable. DEVICE_DT_GET is an address, resolved by the preprocessor, always available. Readiness is the runtime verdict of that init sequence, and asking for it is how the application respects the machinery instead of assuming it. When the sensor is unplugged for Step 9's test, the driver init is what fails, the device object still exists, and this one call is the difference between a clean sentence on the console and a program marching into reads against a dead bus.

The printing helper deserves one sentence of its own, because it is the same trap Guide 1 documented from the other side: a sensor_value is an integer part and a micro part that shares its sign, so naive formatting prints minus half a degree as 0.-500000. Converting to milli first keeps the sign in one place. It is not devicetree; it is the tax every sensor program pays, paid once, in one function.

Checkpoint: the application asks the devicetree exactly three kinds of questions (which node, what facts about it, is it ready) and hardcodes zero hardware facts. Name them.

Step 5: Build it

From the SDK workspace root, with the toolchain environment loaded:

west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

(If your guide projects live in the Bit to Board workspace rather than inside the SDK tree, pass the application's path: the projects for this site's guides sit at ~/workspace/zigbee-course/guides/devicetree-sensor, and west accepts the absolute path from the workspace root unchanged.)

A healthy build ends with the memory report:

Memory region         Used Size  Region Size  %age Used
           FLASH:       28860 B         1 MB      2.75%
             RAM:        6336 B       256 KB      2.42%

FLASH 28,860 B, RAM 6,336 B. Those numbers are this guide's verified build, from these exact files, on the pinned SDK. Yours should match to the byte; if they do not, something in your tree differs from this page, and that difference (not the sensor, not the wiring) is the first thing to find. Small, though, is the headline: a sensor subsystem, an I2C driver, a sensor driver, and an application, for under 29 KB, because the description selected exactly the code the hardware needed and not a line more.

Checkpoint: build green, memory report noted. Do not flash yet. The next step is the one this guide exists for, and it happens entirely inside the build folder.

Step 6: Prove the description drove the build

A build that succeeds proves the description parsed. It does not prove the description was used, that the node you wrote is the node in the tree, or that the driver present is present because of it. Three proofs, all run in the build folder (build/devicetree-sensor/zephyr/ if you built in-tree, or your -d folder), close that gap. Run all three; they take a minute and they generalise to every devicetree problem you will ever debug.

Proof 1: the merged devicetree names your file. Open zephyr.dts, the fully merged description the build actually consumed, and find your node:

/* node '/soc/i2c@40003000/bme280@76' defined in ../workspace/zigbee-course/guides/devicetree-sensor/app.overlay:23 */
bme280: bme280@76 {
    compatible = "bosch,bme280"; /* in ../workspace/zigbee-course/guides/devicetree-sensor/app.overlay:24 */
    reg = < 0x76 >;              /* in ../workspace/zigbee-course/guides/devicetree-sensor/app.overlay:25 */
};

Every line of the merged tree carries its birth certificate as a comment: which file, which line. Your node sits inside the i2c@40003000 controller node (that address is the SoC description's contribution), holding exactly your two properties, sourced from exactly your overlay. When a description "isn't taking effect", this file is where the argument ends: either the fact is here, sourced from your file, or it is not in the build at all. (The same file shows your alias, env-sensor = &bme280, up in the root's aliases, sourced from app.overlay too.)

Proof 2: the .config shows the selection chain. Grep the generated configuration:

grep -E "CONFIG_BME280|CONFIG_SENSOR=|CONFIG_I2C=" build/devicetree-sensor/zephyr/.config
CONFIG_SENSOR=y
CONFIG_I2C=y
CONFIG_BME280=y

Only the first of those three lines is in your prj.conf (the skeleton experiment, paying off). CONFIG_I2C=y arrived because the board enabled an I2C controller and the driver's select I2C if ... fired. CONFIG_BME280=y arrived because DT_HAS_BOSCH_BME280_ENABLED was true, and it was true because your node exists and is enabled. Delete the node and all three lines change; that is what "the description selects the code" means, mechanically, no adjectives.

Proof 3: the generated header resolved your alias. Everything the C compiler knew about your sensor came from zephyr/include/generated/zephyr/devicetree_generated.h. It is not meant to be read cover to cover (it is tens of thousands of lines of macros), but one grep shows the shape of the trick:

grep "DT_N_ALIAS_env_sensor " build/devicetree-sensor/zephyr/include/generated/zephyr/devicetree_generated.h
#define DT_N_ALIAS_env_sensor    DT_N_S_soc_S_i2c_40003000_S_bme280_76

The alias macro expands to the node's macro name, and that name is just the node's path with the punctuation flattened: soc, i2c@40003000, bme280@76. Every DT_REG_ADDR(node), DT_BUS(node), DT_NODE_PATH(node) you used is a lookup into macros spelled from this path. There is no table, no search, no runtime. The description became names, and the names became your program. Follow one lookup all the way down, because it is the last piece of magic and it evaporates on contact. Your DT_REG_ADDR(SENSOR_NODE) becomes, after the alias, a reference to this line, also in the generated header:

#define DT_N_S_soc_S_i2c_40003000_S_bme280_76_REG_IDX_0_VAL_ADDRESS 118 /* 0x76 */

That is the whole mechanism, end to end. A number you typed into an overlay, once, became a #define whose name is the node's path and whose value is your address, comment included. The driver's instance data is built from the same family of macros (its bus pointer is the parent node's device, resolved the same way), which is why the driver needed no configuration from you either. Whenever a devicetree macro misbehaves in some future project, this header is the ground truth: expand the chain by hand, read the final define, and compare it against the description you meant to write. Nine times out of ten the build described something subtly different from your intention, and the header is where the two of you can finally agree on what was said.

Checkpoint: all three proofs pass, and you can state, for each, what its failure would look like (node absent or sourced from the wrong file; CONFIG_BME280 missing or present without your node, meaning something else selected it; alias macro absent). A proof whose failure you cannot picture is a ritual, not a proof.

Step 7: Break the description on purpose, and read the failure

Knowledge you have not watched fail is borrowed. So: rename the overlay out of the way and rebuild, exactly as if it had never been written.

mv app.overlay app.overlay.hidden
west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

The build fails, and the first error is the sentence from Step 4:

src/main.c:32:2: error: #error "No enabled bosch,bme280 node in the devicetree. Is app.overlay in the application folder, and is its node status okay?"

Below it comes the fallout, a volley of preprocessor complaints (__device_dts_ord_DT_N_ALIAS_env_sensor_ORD undeclared, DT_N_ALIAS_env_sensor_PATH undeclared, and friends). Read them with the model from Step 6 and they stop being noise: the alias macro does not exist, because the node it pointed at does not exist, because the description lost a layer. Every one of those errors is the same fact in the preprocessor's accent. The discipline: fix the first error, ignore the rest, rebuild. And notice what the guard bought you. Without it, the first error would still have been a wall of macro noise, just without your sentence at the top translating it. Two lines of #if turned a riddle into a diagnosis. That is the difference between devicetree feeling capricious and devicetree feeling mechanical: not the absence of failures, but failures that speak your language.

There is a second, quieter lesson in this experiment, and it is the more important one: the build only failed because the application asked. Rename the overlay and remove the guard, and the build would fail later and uglier (the alias macros), but remove the application's devicetree use entirely and the build would succeed with no sensor in it at all: a green build, a dead product. Descriptions do not defend themselves. The guard is you, defending them.

Restore the overlay, and before rebuilding, run the crueller variant of the same experiment: leave the file in place and change one character inside it, the compatible, from "bosch,bme280" to "bosch,bme2800":

mv app.overlay.hidden app.overlay
sed -i 's/bosch,bme280"/bosch,bme2800"/' app.overlay
west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

Same red build, same first error, your sentence again. But notice what is absent this time: no devicetree error, no binding complaint, nothing at configure time at all. The build's description tooling validates nodes against bindings it can match; a node whose compatible matches nothing is not an error, it is just a description of a part nobody has heard of, and it sails through to the C compile in perfect silence. Sit with that, because it inverts the beginner's intuition: the description layer is not a spell-checker that catches your typos. It is faithful, not vigilant. A missing file and a one-character typo are the same event to it (no enabled bosch,bme280 instance exists), and the only reason either one announces itself in this project is that the application counted the instances and objected. In a project without the guard, the typo version is worse than the missing file: the build may go fully green, firmware and all, with a sensor-shaped hole in it, discovered on the desk or in the field. Two lines of #if are the cheapest insurance in this entire guide.

Restore the character and rebuild:

sed -i 's/bosch,bme2800"/bosch,bme280"/' app.overlay
west build -p always -b nrf52840dk/nrf52840 guides/devicetree-sensor

Green again, memory report identical. Checkpoint: you have seen this project's failure mode, in your own words, in both its loud form (missing file) and its silent one (typo'd compatible), and you know the two lines that make them equally loud.

Step 8: Wire the sensor to the header

Power off (unplug the DK). The wiring is Step 1's pinctrl block, made physical. The DK's Arduino header carries the bus on the pins the board description named:

BME280 breakout DK pin Why
VIN / VCC 3.3V (Arduino power header) The breakout's regulator (if fitted) and the chip both accept 3.3 V
GND GND (any) Common ground, or nothing talks
SDA Arduino SDA (P0.26) The pin i2c0_default names for data
SCL Arduino SCL (P0.27) The pin i2c0_default names for clock

One electrical note before the gotchas: I2C lines must idle high, held up by pull-up resistors. Most BME280 breakouts carry them on the module, which is why the four-wire hookup usually just works. If your module is a bare sensor on a minimal carrier with no pull-ups fitted, the bus will sit low and silent and every later test will fail confusingly; the cure is a 4.7 kΩ resistor from each of SDA and SCL to 3.3 V, and the "not ready" symptom in Step 9 is what sends you back to this paragraph.

Two module gotchas, both cheap to check now and miserable later. First, the address strap: most BME280 breakouts ship with SDO pulled to ground, which is why the overlay says 0x76. A module with SDO tied to VCC (or a pin header that leaves it to you) answers at 0x77. If your module has an SDO pin, it is either wired to ground already or it is your choice; the desk test in Step 9 grades the result either way, and the fix, if needed, is one hex digit in one file. Second, pin naming: some breakouts label the pins SDI/SCK (SPI vocabulary) for what are still the I2C data and clock lines on a dual-mode module. Wire SDI to SDA and SCK to SCL, and make sure the module is in I2C mode (on most breakouts, the CSB pin pulled high or left in its default state; check your module's silkscreen notes).

Checkpoint: four wires, each traceable to a fact in the board description or the binding. No wire is "where the forum said".

Step 9: Flash it and run the desk test

Flash and open the console:

west flash
west attach  # or open the DK's serial port at 115200 in your terminal

Expected console output (desk expectation; the banner and the shape are the test, the digits are your room):

Sensor node: /soc/i2c@40003000/bme280@76
I2C address: 0x76, on bus node /soc/i2c@40003000
Reading bme280@76 once a second. Breathe on it.

temp     22.840 deg C
pressure 98.913 kPa
humidity 41.207 %RH

Four tests grade the whole chain, description to air:

  • Test 1 (the banner): the node path and address on the first two lines match the overlay exactly. They were printed from the generated macros, so a mismatch means the board is running different firmware than this build (flash again), never that the overlay is "sort of" applied. There is no sort of.
  • Test 2 (plausible air): temperature within a degree or two of the room, pressure in the high 90s to low 100s kPa (weather and altitude move it; a reading of 0 or of 1013.25 exactly means you are reading a placeholder, not air), humidity somewhere in the 20 to 60 %RH band indoors. One nuance that saves a confused half hour: weather services report pressure reduced to sea level, while the sensor reports the pressure actually standing on your desk, and the difference is roughly 1.2 kPa per 100 metres of elevation, always in the direction of the sensor reading lower. A desk in a second-floor flat, in a city at altitude, can sit 3 kPa below the evening forecast with both numbers perfectly correct. Judge the sensor against a second sensor, or against yesterday's own readings, not against the weather app.
  • Test 3 (the breath test): cup your hands and breathe slowly over the sensor for ten seconds. Humidity climbs steeply (toward 80 %RH or beyond) and temperature rises a degree or two; both settle back over a minute. This is the test that proves you wired a living sensor and not a fault that happens to print numbers, the same trick Guide 1 used with a thumb on the package.
  • Test 4 (the wrong-address experiment): edit reg in app.overlay to <0x77> (and the unit address to match: bme280@77), rebuild, reflash. The application now boots to Sensor bme280@77 is not ready... and stops. The description lied about a fact, and no compiler on earth can catch a lie about the physical world; only the desk can. Restore 0x76, rebuild, reflash, and sit with what just happened: the address lived in exactly one line of exactly one file, so the experiment cost one edit and the repair was the same edit backwards. That is the entire argument for the description layer, demonstrated instead of asserted.

Checkpoint: all four tests pass. You have a running sensor, a description you can prove the build consumed, and a failure you have already met once on purpose, so it will never surprise you at a customer's site.

If it breaks

Symptom Likely cause Fix
Build stops with your own #error sentence The overlay is missing, misnamed (it must be exactly app.overlay in the application folder), or its compatible is misspelled, so the census found zero enabled nodes Restore/fix the overlay; the sentence names the suspects in order
A wall of macro errors mentioning DT_N_ALIAS_env_sensor Same root cause as above, seen without the guard's translation, or the alias block is missing from the overlay Fix the first error only; verify the alias exists: env-sensor = &bme280;
devicetree error at configure time, about the node or its properties The node violates its binding (a property the binding does not declare, or reg missing) Compare the node against bosch,bme280-i2c.yaml and its includes, property by property
Boot banner prints, then "is not ready" Hardware fact wrong: wiring, power, or the strapped address (module at 0x77, description says 0x76) Check the four wires against Step 8; try the other address in reg and the unit address together
Readings print but humidity never moves, even under a breath The module is a BMP280 (same package, no humidity element), or humidity is being read from a stale struct after a failed channel get Breathe test against a known BME280; treat the module swap as the fix, not the code
Banner shows the wrong path or address The board is running older firmware (the flash did not take, or you flashed a different build folder) west flash from this build; the banner is generated, so it cannot lie about the build it came from
Temperature plausible, pressure absurd (0, or a suspiciously round constant) You are printing a value from a failed fetch, or the module is a clone with a different compensation (rare) Check the fetch return code path; cross-check against a weather report for your town, adjusted for altitude

You are done when

  • The project builds green from these four files, and the memory report matches the guide's (FLASH 28,860 B, RAM 6,336 B) or you know exactly why yours differs
  • You can point at your node in the merged zephyr.dts, read its source comments back to your overlay, and say which layer contributed the parent bus node
  • Your .config shows CONFIG_BME280=y and CONFIG_I2C=y, and you can explain why neither is in prj.conf
  • You induced the missing-overlay failure, read your own #error sentence as the first error, and restored the build to green
  • The boot banner quotes your overlay's path and address back at you, and the breath test moves humidity and temperature
  • You ran the wrong-address experiment and repaired it with a single edit in a single file

Where the description goes from here

You now hold the three moves every devicetree task is made of: read the layer below you, describe your facts once, and prove from the build's own outputs that the description landed. Everything else in the devicetree world is these moves at larger scale. Moving this sensor to SPI, or to the second I2C bus, is an overlay edit and a different set of wires; the application will not notice. Giving a sensor its own driver, when no in-tree driver exists, is Guide 1's subject: Write a Zephyr device driver from scratch writes the binding and driver that a node like this one marries. And when the board itself is yours, Create Zephyr board files from scratch writes the board layer this guide only read, the .dts and pinctrl files that future overlays will amend.

In the Zigbee course, this description layer is the ground the networking stands on: the coordinator, the endpoints, and the sensor all arrive through it, and the engineers who debug Zigbee well are the ones who can prove what the build actually described before they blame the radio. Start with Zigbee, Engineered if that is where you are headed. And when you are ready to watch descriptions and radios meet in the wild, the next guide in this series puts a whole network on the desk: Your first Zigbee network on a desk.

← All guides