Write a Zephyr device driver from scratch
Sooner or later it happens to every embedded engineer: the perfect sensor for your product exists, the datasheet is open on your desk, and Zephyr has never heard of the part. No driver, no binding, nothing to west your way out of. This guide writes the missing driver, end to end, the way an upstream Zephyr driver is written: a devicetree binding, Kconfig, CMake, and a C file that implements the standard sensor API. Then it wires the sensor to a real board, flashes it, and tests it on the desk, because a driver isn't done when it compiles; it's done when it reads the room.
The part is the Maxim MAX30205, a small I2C temperature sensor. We checked first: in the pinned tree this guide uses (nRF Connect SDK v3.4.1, Zephyr 4.4.2), searching drivers/sensor/ and dts/bindings/sensor/ for max30205 returns nothing, no driver, no binding. Perfect. Everything below was built for an nRF52840 DK in exactly that environment, and the build output quoted further down is the real output of the code on this page.
By the end you'll have a reusable driver packaged as a Zephyr module, plus a sample application that reads the temperature once a second through the standard sensor_sample_fetch() / sensor_channel_get() calls, the same calls you'd use for any supported sensor. Follow the steps in order and don't skip the checkpoints: each one catches a specific class of mistake while it's still cheap.
What you need
Hardware (for the desk test in Steps 10 to 11):
- An nRF52840 DK: the same board the Zigbee, Engineered course uses. Any other Zephyr board with I2C works too; only the overlay changes.
- A MAX30205 breakout board (bare chips need a PCB and reflow; a breakout is the sane route). Most breakouts strap the address pins for you and carry pull-up resistors (check the silkscreen or the breakout's docs for which address it answers to.
- Four jumper wires (female-to-female or female-to-male to suit your breakout): power, ground, SDA, SCL.
- A USB cable for the DK), a real data cable, not a charge-only one.
Software:
- A working west workspace with the pinned stack: nRF Connect SDK v3.4.1 (Zephyr 4.4.2) and the Zephyr SDK toolchain (arm-zephyr-eabi-gcc 14.3.0). If you took Course 1, your Chapter 1 install is exactly this. Everything is built from the workspace root, in the toolchain terminal that install gives you.
- A serial terminal for the console (the one from Course 1 Chapter 1, or any other, PuTTY, minicom,
screen, VS Code's serial monitor).
Time: about 3 hours end to end, following along, reading, building and the desk test included. Steps 1 to 9 are the driver; Steps 10 to 11 are wire, flash, and test.
Meet the part
A driver is just a datasheet, translated. The MAX30205 datasheet reduces to a handful of facts:
| Fact | Value |
|---|---|
| Bus | I2C, up to 400 kHz |
| Address | Set by strapping pins A2/A1/A0; all three to GND gives 0x48 (the breakout used here) |
| Temperature register | Pointer 0x00, read-only, 2 bytes, big-endian, signed |
| Temperature scale | 1 LSB = 0.00390625 °C, that is, the raw value is the temperature × 256 |
| Configuration register | Pointer 0x01: one-shot mode, shutdown, alarm behaviour |
| Alarm thresholds | T_HYST at 0x02, T_OS at 0x03, driving the part's OS output pin |
| Behaviour after power-up | Converts continuously on its own. Nothing to configure for plain reads |
Keep this table open while you work. Every row is spent somewhere specific:
- The bus and address become a devicetree node (Step 6) (never a
#definein C. - The temperature register pointer and byte order are the whole of
sample_fetch(Step 5). - The scale is the whole of
channel_get(Step 5). - The configuration and threshold registers are deliberately not spent), they're the extension exercise at the end, and leaving them out is what keeps this driver small enough to understand in one sitting.
- The "converts continuously" row is why
initis five lines instead of fifty: there is nothing to wake up, calibrate, or start.
That last fact shapes the whole driver: init has almost nothing to do, sample_fetch is one I2C transaction, and channel_get is one division. Short register maps make honest first drivers. The Zephyr machinery around the part is the real subject, and it's the same machinery no matter how complicated the silicon gets.
One warning about the address, because it is the single most common way this project goes wrong: 0x48 is not "the MAX30205's address", it is the address of one strapping. The datasheet's address table maps the A2/A1/A0 pins to a range of addresses. All three grounded gives 0x48; a breakout that straps them differently answers somewhere else. Your breakout's docs are the authority. The firmware and the hardware must agree on this number or nothing else in this guide matters. Step 10 makes you verify it before power, and the troubleshooting section starts with it when things fail.
What the driver model gives you
Zephyr's deal with you, the driver author, is this:
- Devicetree describes the hardware. Which bus, which address, which pins, none of that lives in your C code. It lives in a devicetree node, so the same driver serves every board.
- A binding describes the description. A small YAML file declares what a node with your
compatiblestring means. The build system checks every node against its binding and turns the node into C macros your driver reads. - Your driver registers a device. At build time your code creates a
struct deviceper devicetree node, pointing at your init function, your runtime data, your compile-time config, and a table of API functions. - Applications never call you directly. They hold the
struct device *and call the standard sensor API, which dispatches into your function table. Swap the sensor, keep the application.
Here is that flow for the part we're building:
It helps to know whose job each piece is before you write any of it:
| Piece | Owned by | Changes when… |
|---|---|---|
| Binding YAML | The driver | The part gains a configurable property |
| Kconfig + CMake | The driver | Almost never. They're boilerplate shapes |
| Driver C file | The driver | The datasheet says so |
| Overlay node | The application/board | The part moves bus, address, or board |
| Application C file | The application | The product does something new |
Everything in the left three rows ships once and is reused forever. The right two rows are per-project. That split is the entire economic argument for writing drivers this way instead of pasting I2C code into main.c.
Before you start: set up the workspace
Why this step exists: every later command assumes you are standing in your west workspace root, in a terminal where the pinned toolchain is on the path, with the project folder in the place the build command expects. Five minutes here prevents the three most confusing build errors later (wrong tree, wrong toolchain, module not found).
Open your toolchain terminal. The terminal your NCS install gives you (Toolchain Manager's "Open Terminal", or a shell where you've sourced the environment). All commands in this guide run there.
Go to your workspace root, the folder that contains
zephyr/,nrf/, andmodules/afterwest update:cd <your-workspace-root> pwd lsCheckpoint:
lsshowszephyr,nrf,bootloader,modules. If it doesn't, you're not at the root,cduntil you are. Building from the wrong folder produces errors that blame files you've never heard of.Prove the tree is the pinned one. This guide's outputs were produced against NCS v3.4.1 / Zephyr 4.4.2:
west --version grep -m1 "VERSION" zephyr/VERSIONYou should see west 1.5.x and a Zephyr version of 4.4.x. A different Zephyr version doesn't make the guide impossible, but line-level details (macro names, Kconfig symbols) drift between releases. Pin first, improvise later.
Create the project skeleton. This guide keeps its work in a top-level
guides/max30205/folder inside the workspace, so the module and sample sit next to, not inside, the SDK tree. Create every folder now:mkdir -p guides/max30205/max30205-driver/dts/bindings/sensor mkdir -p guides/max30205/max30205-driver/drivers/sensor/max30205 mkdir -p guides/max30205/max30205-driver/zephyr mkdir -p guides/max30205/sample/srcCheckpoint:
find guides/max30205 -type dlists those six folders. The layout they form is the finished project's shape:guides/max30205/ ├── max30205-driver/ the driver, as a Zephyr module │ ├── zephyr/module.yml module glue (Step 4) │ ├── CMakeLists.txt module entry point │ ├── Kconfig module entry point │ ├── dts/bindings/sensor/maxim,max30205.yaml │ └── drivers/sensor/max30205/ │ ├── CMakeLists.txt │ ├── Kconfig │ └── max30205.c └── sample/ the application ├── CMakeLists.txt ├── prj.conf ├── app.overlay └── src/main.cWriting it as a module, rather than files scattered in one app, is deliberate: a module is how real out-of-tree drivers ship, and the same folder drops into any future project unchanged.
Step 1: The binding: teach the build your part exists
Why this step exists: devicetree nodes are just text until a binding says what they mean. The binding is also a contract, if a board file misspells a property or forgets reg, the build fails at configure time with the binding's name in the error, instead of producing firmware that talks to a wrong address.
Create the file
max30205-driver/dts/bindings/sensor/maxim,max30205.yaml(in the folder you made above). The filename itself matters: bindings are found by matching the node'scompatiblestring to a file named<vendor>,<part>.yaml.Fill it with this content:
max30205-driver/dts/bindings/sensor/maxim,max30205.yaml:
# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0
description: |
Maxim MAX30205 human body temperature sensor (I2C).
Measures temperature continuously after power-up and exposes it in a
signed 16-bit register scaled by 1/256 degC per LSB.
compatible: "maxim,max30205"
include: [sensor-device.yaml, i2c-device.yaml]- Read it before moving on, three things to notice:
compatible: "maxim,max30205"is the string everything keys off. The overlay node will carry it (Step 6), the build generates aDT_HAS_MAXIM_MAX30205_ENABLEDflag from it, and the driver's#define DT_DRV_COMPAT maxim_max30205(Step 5) matches it. One string, three spellings (comma, all-caps-underscore, lowercase-underscore). The build system translates between them; you just have to be consistent with each context's convention.include: [sensor-device.yaml, i2c-device.yaml]inherits the standard property sets instead of redeclaring them.i2c-device.yamlis what makesregmean "I2C address" and gives the node a bus;sensor-device.yamladds the properties all sensors share. This driver needs no part-specific properties, so the file ends there. If your part needed, say, a conversion-rate setting, it would be declared here as a property, and only here, because the binding is the single place the build validates properties against.- The binding lives inside the module, under
dts/bindings/. Becausemodule.yml(Step 4) declares this module'sdts_root, the build searches it automatically. No central registry to edit, no file in the SDK tree to touch, which is exactly why the driver can ship as a folder you copy around.
Checkpoint: the file exists at that exact path, and its compatible line reads "maxim,max30205". A typo here doesn't error yet. It errors three steps later, disguised as something else (Step 5's driver finding no instances). Check it now while the fix is free.
Step 2: Kconfig: the on/off switch, wired to the devicetree
Why this step exists: Zephyr builds are configured by Kconfig, so your driver needs a symbol. The trick worth copying is how it turns on: not by the user remembering to enable it, but automatically, the moment an enabled node with your compatible appears in the devicetree. Hardware described → driver built. Hardware absent → driver costs zero bytes and zero thought.
- Create the driver's Kconfig,
max30205-driver/drivers/sensor/max30205/Kconfig, with this content:
# MAX30205 temperature sensor configuration options
# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0
config MAX30205
bool "MAX30205 Temperature Sensor"
default y
depends on DT_HAS_MAXIM_MAX30205_ENABLED
select I2C
help
Enable the driver for the Maxim MAX30205 I2C temperature sensor.- Understand the three moves it makes:
depends on DT_HAS_MAXIM_MAX30205_ENABLED, that symbol exists only when the devicetree, checked against your binding, contains an enabledmaxim,max30205node. No node, no symbol, no driver code in the image. This is the wire between Step 1's YAML and this step's switch.default y, combined with that dependency, means: if the hardware is described, build the driver. The application never has to name your part inprj.conf(watch for exactly that absence in Step 8. It's the proof this step worked).select I2Cpulls in the bus subsystem the driver needs, so the application doesn't have to know the driver rides I2C at all.
- Create the module-root Kconfig,
max30205-driver/Kconfig. The build sources a module's Kconfig from the pathmodule.ymlnames, so the module needs this one-line entry point that pulls the driver's file in:
# SPDX-License-Identifier: Apache-2.0
# Module entry point for Kconfig: pull the driver's own options in.
rsource "drivers/sensor/max30205/Kconfig"Checkpoint: two Kconfig files exist (one in the module root, one in the driver folder), and the driver's symbol is named MAX30205. If you ever adapt this guide to another part, the symbol name, the compatible, and the DT_HAS_... symbol are a matched set: change all three together or the driver silently never builds (the troubleshooting section shows how to detect that).
Step 3: CMake: two tiny files
Why this step exists: something has to tell the build which .c file to compile, and when. Zephyr's answer is a per-driver CMakeLists.txt that creates a library and adds sources conditionally on the Kconfig symbol, so a disabled driver costs zero bytes, and an enabled one needs no application-side build edits.
- Create the driver's CMake file,
max30205-driver/drivers/sensor/max30205/CMakeLists.txt:
# SPDX-License-Identifier: Apache-2.0
zephyr_library()
zephyr_library_sources_ifdef(CONFIG_MAX30205 max30205.c) The one line that matters is the sources_ifdef shape: the .c file joins the build only when CONFIG_MAX30205 is set, and Step 2 wired that symbol to the devicetree. Describe hardware → symbol → source file. That chain is the driver model in one sentence.
2. Create the module root's CMake file, max30205-driver/CMakeLists.txt, the file the build includes because module.yml points at the module root. It just descends into the driver:
# SPDX-License-Identifier: Apache-2.0
# Module entry point. The build includes this file because
# zephyr/module.yml says so; all it does is descend into the driver.
add_subdirectory(drivers/sensor/max30205)Checkpoint: both CMake files exist. They're boilerplate, for your next driver you'll copy them and change one filename. That's not laziness; upstream drivers look exactly like this too, and recognisable boilerplate is a feature when someone else reads your driver at 2 a.m.
Step 4: module.yml: the glue
Why this step exists: the build finds out-of-tree code through modules. module.yml is the module's declaration: where its Kconfig entry point is, where its CMake entry point is, and where to look for its devicetree bindings. If you've used the nRF Connect SDK's Zigbee add-on, you've already consumed exactly this mechanism. It's how Nordic ships it. (It's also why this guide writes a module instead of dropping the driver inside the sample app: the in-app route genuinely doesn't link for devicetree-instantiated drivers. The module is the supported shape, not a style preference.)
- Create
max30205-driver/zephyr/module.yml:
# Copyright (c) 2026 Bit to Board
# SPDX-License-Identifier: Apache-2.0
name: max30205
build:
# Kconfig file sourced into the Zephyr Kconfig tree (under
# Modules > max30205). Path is relative to the root of this module.
kconfig: Kconfig
# Folder whose CMakeLists.txt the build includes — "." is the
# module root itself.
cmake: .
settings:
# Look for devicetree bindings under this module's dts/ folder.
dts_root: .- Map the three declarations to the files you've already made: the Kconfig path points at Step 2's root file, the CMake path at Step 3's root file, and the
dts_rootis what makes Step 1's binding discoverable. If any of those paths is wrong, the failure mode is silence. The build succeeds and the driver simply isn't in it. Step 9 exists precisely to catch that silence with evidence instead of vibes. - Know how an application opts in: by naming the module folder at build time,
west build ... -- -DZEPHYR_EXTRA_MODULES=<path-to>/max30205-driver(Step 9 does this), or permanently, by making the module a project in a west workspace, where discovery is automatic. For a product, the workspace route is the right end-state; for learning, the explicit flag keeps the mechanism visible.
Checkpoint: module.yml sits inside a zephyr/ folder inside the module root, not at the module root itself. That nested path is the one thing people get wrong here, and the build won't tell them.
Step 5: The driver itself
Why this step exists: everything so far was plumbing. This file is the driver: it turns devicetree instances into devices, and datasheet facts into API calls. It's also the only file that changes substantially when you adapt this guide to a different part. Everything else was shapes; this is substance.
- Create
max30205-driver/drivers/sensor/max30205/max30205.cwith this content:
/*
* Copyright (c) 2026 Bit to Board
* SPDX-License-Identifier: Apache-2.0
*
* Driver for the Maxim MAX30205 I2C temperature sensor, written from the
* datasheet. The part converts continuously after power-up; the only
* register this driver needs is the temperature register (pointer 0x00):
* a signed, big-endian 16-bit value scaled by 1/256 degC per LSB.
*/
#define DT_DRV_COMPAT maxim_max30205
#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/logging/log.h>
#include <zephyr/sys/byteorder.h>
LOG_MODULE_REGISTER(max30205, CONFIG_SENSOR_LOG_LEVEL);
/* Pointer address of the temperature register. The other registers
* (0x01 configuration, 0x02 T_HYST, 0x03 T_OS) drive the alarm output;
* plain temperature reads do not need them.
*/
#define MAX30205_REG_TEMP 0x00
struct max30205_data {
int16_t raw; /* last fetched temperature, in 1/256 degC units */
};
struct max30205_config {
struct i2c_dt_spec i2c;
};
static int max30205_sample_fetch(const struct device *dev,
enum sensor_channel chan)
{
const struct max30205_config *cfg = dev->config;
struct max30205_data *data = dev->data;
uint8_t reg = MAX30205_REG_TEMP;
uint8_t rx[2];
int ret;
if (chan != SENSOR_CHAN_ALL && chan != SENSOR_CHAN_AMBIENT_TEMP) {
return -ENOTSUP;
}
/* Write the register pointer, then read the two data bytes in one
* transaction (repeated start between the write and the read).
*/
ret = i2c_write_read_dt(&cfg->i2c, ®, sizeof(reg), rx, sizeof(rx));
if (ret < 0) {
LOG_ERR("Failed to read the temperature register (%d)", ret);
return ret;
}
data->raw = (int16_t)sys_get_be16(rx);
return 0;
}
static int max30205_channel_get(const struct device *dev,
enum sensor_channel chan,
struct sensor_value *val)
{
struct max30205_data *data = dev->data;
if (chan != SENSOR_CHAN_AMBIENT_TEMP) {
return -ENOTSUP;
}
/* sensor_value wants whole degrees in val1 and micro-degrees in
* val2. raw is signed and scaled by 1/256 degC, so divide it out;
* both parts keep the sign of raw (-1.5 degC -> val1 = -1,
* val2 = -500000).
*/
val->val1 = data->raw / 256;
val->val2 = (data->raw % 256) * 1000000 / 256;
return 0;
}
static DEVICE_API(sensor, max30205_driver_api) = {
.sample_fetch = max30205_sample_fetch,
.channel_get = max30205_channel_get,
};
static int max30205_init(const struct device *dev)
{
const struct max30205_config *cfg = dev->config;
if (!i2c_is_ready_dt(&cfg->i2c)) {
LOG_ERR("I2C bus %s is not ready", cfg->i2c.bus->name);
return -ENODEV;
}
/* Nothing to configure: the MAX30205 starts converting on its own
* after power-up. Do one fetch now anyway, so a missing or
* mis-wired sensor fails at init instead of at first use.
*/
return max30205_sample_fetch(dev, SENSOR_CHAN_AMBIENT_TEMP);
}
#define MAX30205_INST(inst) \
static struct max30205_data max30205_data_##inst; \
static const struct max30205_config max30205_config_##inst = { \
.i2c = I2C_DT_SPEC_INST_GET(inst), \
}; \
SENSOR_DEVICE_DT_INST_DEFINE(inst, max30205_init, NULL, \
&max30205_data_##inst, &max30205_config_##inst, \
POST_KERNEL, CONFIG_SENSOR_INIT_PRIORITY, \
&max30205_driver_api);
DT_INST_FOREACH_STATUS_OKAY(MAX30205_INST)- Take the guided tour, five pieces, in the order the file presents them:
#define DT_DRV_COMPAT maxim_max30205comes before everything. It tells theDT_INST_*macros which devicetree nodes this file serves. The underscore form of the binding's compatible string (compare Step 1: comma there, underscore here; that translation is fixed, learn to read both).- Config vs data.
max30205_configis compile-time and read-only: it holds ani2c_dt_spec, bus pointer plus address, which the instantiation macro fills from the devicetree node, so no address ever appears in C.max30205_datais the writable runtime state: here, just the last raw reading. Keeping the two apart is what lets the same code serve several sensors on several buses, and it's what makes the config struct safe to live in flash. sample_fetchdoes the bus work, and only the bus work: write the register pointer (0x00), read two bytes, stash the big-endian result.i2c_write_read_dt()performs both halves as one transaction with a repeated start, which is what the datasheet's read sequence shows. Fetch stores raw units on purpose. Conversion belongs to the reader, not the wire. If the bus transaction fails, the error propagates to the caller unchanged; the driver doesn't invent a temperature to hide a wiring fault.channel_getconverts. The sensor API's currency isstruct sensor_value: whole units inval1, millionths inval2. Raw counts are temperature × 256, soval1 = raw / 256andval2 = (raw % 256) * 1000000 / 256. Both keep the sign of the raw value, which is why −1.5 °C comes out asval1 = −1, val2 = −500000and not something subtly wrong. Work that example by hand once and you'll never misread the split-sign convention again. Channels the driver doesn't implement get-ENOTSUP, andchannel_getnever touches the bus: fetching and reading are separate verbs in this API, which is what lets an application sample once and convert many times (or convert the same sample into two channel types) for free.- The API table and instantiation.
DEVICE_API(sensor, ...)builds the dispatch table the generic API calls through.SENSOR_DEVICE_DT_INST_DEFINEcreates one device per node (atPOST_KERNELinit level, with data, config, and the API table attached), andDT_INST_FOREACH_STATUS_OKAYruns that macro once per enabled node. Two sensors in the overlay, two devices, zero extra code.POST_KERNELmatters: it guarantees the I2C controller (which initialises earlier) exists before this driver's init runs. initearns its keep. The part needs no configuration, so init does the two things that still matter: it refuses to come up if the I2C bus isn't ready, and it performs one real fetch, so a sensor that isn't soldered down (or an address that doesn't match the strapping) fails loudly at boot instead of returning garbage forever. Every hour this guide saves you later, it saves through that one decision. A driver that validates its hardware at init turns "the readings look weird" mysteries into a single clear boot-time error.
Step 6: The overlay: describe your board
Why this step exists: the driver knows the part; somebody still has to say this board has one, on this bus, at this address. That's the application's overlay. Hardware description lives with the application, not the driver. Get this file right and the same driver serves your next board unchanged; get it wrong and the failures look like driver bugs, which is why this step has two checkpoints.
- Create
sample/app.overlay:
/* SPDX-License-Identifier: Apache-2.0
*
* A MAX30205 on the nRF52840 DK's I2C0 bus (the Arduino-header SDA/SCL
* pins, P0.26/P0.27). The breakout's address pins are strapped to GND,
* which the datasheet's address table decodes as 0x48.
*/
/* The node label "max30205" is how the application finds the device. */
&i2c0 {
status = "okay";
max30205: max30205@48 {
compatible = "maxim,max30205";
reg = <0x48>;
};
};- Read the three lines that do the work:
- The node sits under
&i2c0, on the nRF52840 DK that's the bus routed to the Arduino-header SDA/SCL pins (P0.26/P0.27). Step 10 wires to exactly those pins; the overlay and the wiring are two descriptions of the same fact, and they must agree. reg = <0x48>must match the breakout's strapped address. Strap the pins differently and this is the only number that changes, in the whole project.- The node label
max30205is the application's handle, Step 7 looks the device up by it. Labels are how C code refers to nodes; the unit address (@48) is how humans (and error messages) refer to them. status = "okay"on the bus is easy to forget on boards where the bus ships disabled. A node under a disabled bus is a node that doesn't exist, as far asDT_INST_FOREACH_STATUS_OKAYis concerned. The driver would build zero instances and say nothing.
- The node sits under
- Why an overlay and not an edit to the board file: the board's own
.dtsdescribes what the board has. Your sensor is something you plugged in, so it belongs to the application. Overlays layer onto the board description at build time, which is why the same sample builds for a different board by swapping one file.
Checkpoint: the overlay names &i2c0, the node carries compatible = "maxim,max30205" (character-for-character the binding's string), and reg matches your breakout's strapped address. If you take one habit from this guide, take this one: the overlay is where hardware truth lives, when behaviour and expectation disagree, re-read the overlay first.
Step 7: The application: three API calls
Why this step exists: to prove the point of the whole exercise. The application uses the bog-standard sensor API and contains no MAX30205 knowledge at all beyond a node label. If this file had a register address or a magic scale factor in it, the driver would have failed at its one job.
- Create the application source,
sample/src/main.c:
/*
* Copyright (c) 2026 Bit to Board
* SPDX-License-Identifier: Apache-2.0
*
* Sample: fetch the MAX30205's temperature once a second through the
* standard sensor API and print it. Nothing in this file knows the
* part is a MAX30205 — swap the overlay node and the same code reads
* any temperature sensor Zephyr has a driver for.
*/
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/sys/printk.h>
static const struct device *const max30205 = DEVICE_DT_GET(DT_NODELABEL(max30205));
int main(void)
{
struct sensor_value temp;
int ret;
if (!device_is_ready(max30205)) {
printk("MAX30205 is not ready (check wiring and I2C address)\n");
return 0;
}
printk("Reading %s once a second\n", max30205->name);
while (1) {
ret = sensor_sample_fetch(max30205);
if (ret == 0) {
ret = sensor_channel_get(max30205,
SENSOR_CHAN_AMBIENT_TEMP, &temp);
}
if (ret == 0) {
/* printk does not print floats by default, so print
* the sensor_value parts with an explicit sign
* (val2 carries the sign when val1 is 0).
*/
printk("ambient: %s%d.%06d C\n",
(temp.val1 == 0 && temp.val2 < 0) ? "-" : "",
temp.val1,
temp.val2 < 0 ? -temp.val2 : temp.val2);
} else {
printk("read failed (%d)\n", ret);
}
k_sleep(K_SECONDS(1));
}
return 0;
}- Walk the four things it does:
DEVICE_DT_GET(DT_NODELABEL(max30205))resolves the node label from Step 6 to thestruct device *the driver registered in Step 5. This is the one and only place the application names the hardware.device_is_ready()is checked before anything else, and thanks to the driver's init (Step 5, piece 6), "ready" doesn't just mean "the device struct exists"; it means the driver's init ran and its test fetch succeeded. A wiring fault therefore stops the program here, with a message that says what to check, instead of streaming plausible-looking garbage.- The loop is the API's two verbs in their intended rhythm:
sensor_sample_fetch()(talk to the wire, store raw) thensensor_channel_get()(convert, no bus traffic). One fetch, one conversion, one print, one second of sleep. - The print formats
sensor_value's two fields by hand, including the sign handling whenval1is 0 andval2carries the sign.printkdoesn't print floats by default; knowing that saves a baffling afternoon the first time a Zephyr console shows nothing where a float should be.
- Create the application's CMake file,
sample/CMakeLists.txt. The ordinary three-liner every Zephyr application starts with. Nothing in it mentions the driver or the module; module discovery is the build command's business (Step 9):
# SPDX-License-Identifier: Apache-2.0
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(max30205_sample)
target_sources(app PRIVATE src/main.c)Checkpoint: search main.c for 0x48, 0x00 (the register), and 256. You should find none of them. The address is in the overlay, the register is in the driver, the scale is in the driver. If any of those numbers leaked into the application, the layering is broken and future-you pays for it.
Step 8: The application's configuration: two lines and an absence
Why this step exists: prj.conf is where an application declares which subsystems it uses. This one is four lines long, and the most important line is the one that isn't there.
- Create
sample/prj.conf:
# The sensor subsystem and the I2C driver. CONFIG_MAX30205 itself is
# not set here on purpose: the driver's Kconfig defaults it to y
# whenever an enabled maxim,max30205 node exists in the devicetree.
CONFIG_I2C=y
CONFIG_SENSOR=y- Read it the way the build does:
CONFIG_SENSOR=yturns on the sensor subsystem, the generic API the application calls. Without it,sensor_sample_fetchdoesn't exist and the link fails (which is good: subsystem switches fail loudly).CONFIG_I2C=yturns on the I2C subsystem. The driver alsoselects I2C (Step 2), so this line is belt-and-braces (stated explicitly because the application knows it plugs something into I2C pins, even if it doesn't know what.CONFIG_MAX30205is absent), deliberately. Step 2'sdefault y+depends on DT_HAS_MAXIM_MAX30205_ENABLEDmeans the symbol switches itself on because Step 6's overlay describes the hardware. Writing it here anyway would "work", and would quietly defeat the mechanism, because the next engineer to remove the node would still get the driver compiled in, wondering why. Configuration that follows the hardware description never drifts out of sync with it; configuration typed by hand always eventually does.
- Know what you would add for a product: a logging backend and
CONFIG_MAX30205_LOG_LEVEL_*if you want the driver's own log output,CONFIG_I2Cspeed options if your bus needs them. None of it is required to read a temperature, and none of it belongs in a first build. Add configuration when a requirement names it, not before.
Checkpoint: prj.conf enables the sensor subsystem and I2C, and does not mention the driver. Step 9 proves (from the build's own generated files, not from hope), that the driver got built anyway.
Step 9: Build it, then prove the driver is inside
Why this step exists: a green build is necessary and nowhere near sufficient. A driver that never got compiled also "builds", the application links, the memory report prints, and the firmware would boot to a "device not ready" because nothing registered the device. This step therefore has two halves: build, then prove, with checks that cannot pass vacuously.
Run the build from your workspace root.
-p alwaysforces a pristine build (no stale cache can fake a pass),-bnames the board, and the--forwards everything after it to CMake, here, the module path that opts this build into Step 4's module:west build -p always -b nrf52840dk/nrf52840 \ guides/max30205/sample \ -- -DZEPHYR_EXTRA_MODULES=$PWD/guides/max30205/max30205-driverRead the memory report at the end of the output. You should see the build finish with (this is the actual output of building the code on this page):
Memory region Used Size Region Size %age Used FLASH: 26268 B 1 MB 2.51% RAM: 6272 B 256 KB 2.39%Checkpoint: the build completes and prints a memory report with those figures (26268 B of flash, small, because this firmware does exactly one thing). Different figures mean a different tree or extra configuration crept in; the build succeeding with wildly different numbers is worth a pause, not a shrug.
Prove the Kconfig mechanism fired. The generated
.configis the build's ground truth, not yourprj.conf, not your intentions:grep CONFIG_MAX30205 build/sample/zephyr/.config # CONFIG_MAX30205=y <- enabled by the devicetree node, not by prj.confCheckpoint: exactly that line,
=y. If instead you find# CONFIG_MAX30205 is not set, the chain broke somewhere upstream: the overlay node isn't enabled, the compatible is misspelled, or the module wasn't found. The troubleshooting section walks that chain link by link.Prove the driver is linked into the image. Symbols don't lie about what got compiled:
arm-zephyr-eabi-nm build/sample/zephyr/zephyr.elf | grep max30205 # ... t max30205_init # ... t max30205_sample_fetch # ... t max30205_channel_get # ... r max30205_driver_apiCheckpoint: all four symbols present, init, the two API functions, and the dispatch table (
rfor read-only data: the config-side of Step 5's split, living in flash as designed). Those symbols are the driver. With them confirmed, any failure from here on is a hardware conversation, not a build-system one, which is precisely the confidence Step 10 is about to spend.
Step 10: Wire the sensor to the DK
Why this step exists: everything so far described the hardware in files; this step makes the physical world match the description. The overlay (Step 6) claims the sensor is on I2C0 at 0x48. The next ten minutes make that claim true. Wire with the DK unpowered, I2C hot-plugging usually survives, and "usually" is a bad reason to risk a sensor.
Verify your breakout's strapping before touching a wire. Find its address documentation (silkscreen, product page, or schematic) and confirm which address the A2/A1/A0 straps select. This guide's overlay expects 0x48 (all three to GND). If your breakout answers at a different address, stop and change
reg = <0x48>in the overlay and rebuild (Step 9) now. The firmware's address must match the hardware's before anything else can work, and editing it after a failed test costs you a debugging session you don't need.Wire the four lines. The DK's I2C0 is routed to the Arduino-style header, the pins labelled SDA (P0.26) and SCL (P0.27) on the board's underside silkscreen:
MAX30205 breakout nRF52840 DK Why VDD / VIN / 3V3 3.3V (Arduino header power pin) The part runs from 3.3 V here: do not use 5 V; the DK's I2C pins are not 5 V-tolerant in this setup GND GND (any Arduino header ground) Common ground, or the bus signals mean nothing SDA SDA (P0.26) The data line Step 6's &i2c0node describesSCL SCL (P0.27) The clock line. SDA and SCL look identical on a jumper wire, swapping them is the #1 wiring fault; the driver fails at init and Step 11 tells you what that looks like If your breakout brings out address pins, strap A2, A1, A0 to GND for 0x48 (or confirm the board already does, many breakouts hard-strap them and expose nothing).
Pull-ups, once, so they never surprise you: I2C lines idle high through pull-up resistors. Most MAX30205 breakouts carry them, and the DK's board definition enables pull-ups on these pins, in the common case you add nothing. If your breakout has no pull-ups and reads misbehave (see troubleshooting), add 4.7 kΩ from SDA to 3.3 V and 4.7 kΩ from SCL to 3.3 V. Do not stack multiple strong pull-up sets on a short bench bus; one set is correct.
The pre-power inspection. Read it out loud, it takes twenty seconds: power wire to 3.3 V, not 5 V. Ground to ground. SDA to SDA, SCL to SCL (trace each with a finger; "the blue one" is not a scheme). Nothing shorting on the breakout's back. Address confirmed as 0x48. This checklist exists because every fault it catches produces the same symptom in Step 11, and re-checking wires then is slower than checking them now.
Checkpoint: four wires, each verified against the table, address confirmed against the overlay. Power comes in the next step, through the flash, so the board boots straight into the test firmware.
Step 11: Flash it and run the desk test
Why this step exists: this is where the guide stops being about code. You are going to put the verified firmware on real hardware and check that the physical world behaves the way the driver math says it must. Everything in this step is phrased as expected behaviour. Your readings will differ from the examples by whatever your room is doing, and the tests below are designed around that: they check direction, plausibility, and responsiveness, not exact numbers.
Flash the DK. Connect USB (the DK's debugger USB port) and run, from the workspace root:
west flash -d build/sampleCheckpoint: the flasher reports the image written and the board resets (you'll see the DK's LEDs blink as it restarts). A "no probe found" error here is a cable/port problem, not a firmware one. Use the DK's USB connector (not the nRF USB port) and a data cable.
Open the console before you reset. The DK exposes a USB serial port (the J-Link COM port on Windows; a
/dev/ttyACM*device on Linux/macOS). Connect your terminal to it at 115200 baud, 8N1, then press the DK's RESET button so you catch the boot from the top.Read the boot. What you should see first is the sample's opening line, the device name comes from the devicetree node, unit address and all:
Reading max30205@48 once a secondThat line is already a test result: it prints only after
device_is_ready()passed, which, per Step 5, means the driver's init performed a successful fetch from the real sensor. If you see this line, the wiring, the address, the bus, the binding, and the driver have all agreed with each other at least once.Test 1: plausibility (the ambient reading). One line a second follows:
ambient: 24.250000 C ambient: 24.281250 C ambient: 24.250000 CPass criteria: the value is in a sane band for your room (roughly 18 to 30 °C indoors), it's stable to within a few hundredths of a degree between reads, and consecutive values move in small steps (note the quantisation: the sensor's step is 0.00390625 °C, so readings land on multiples of it, 24.281250 is 24 + 72/256). A value that is wildly off (say, 85 or −40), frozen to the digit for minutes, or jumping by degrees between seconds is a fail. Go to troubleshooting with the specific behaviour noted; each failure shape points somewhere different.
Test 2: warm the sensor (the responsiveness test). Rest a fingertip firmly on the sensor package (or cup your hands around the breakout and breathe on it) for 15 to 20 seconds, watching the console. Pass criteria: the reading climbs smoothly while you warm it (expect a rise of roughly 1 to 4 °C for a fingertip on the package, more if you breathe warm air directly on it), then, when you let go, decays back toward ambient over the following minute, settling near where Test 1 started. Direction and shape are the test: up promptly, down gently, no steps or spikes. A sensor that doesn't respond to a warm finger is not measuring anything real, whatever the ambient reading looked like; a sensor that jumps 20 °C instantly is not measuring temperature. Both fail here, and both are wiring/bus stories, not driver stories.
Test 3, the cross-check (optional, if you have any reference thermometer). Leave the setup running undisturbed for five minutes next to a room thermometer or a trusted weather station's indoor sensor. Pass criteria: agreement within about 1 °C. The MAX30205 is an accurate part; a persistent multi-degree offset at steady state usually means the sensor is being warmed by its neighbour. Check it's not sitting on top of the DK's regulator or in the exhaust path of your laptop before blaming the code.
Test 4: the power-cycle (proves init isn't luck). Press RESET twice more, a minute apart. Pass criteria: both boots reach the once-a-second readings with no "not ready" and no failed reads. Intermittent init is a physical-layer smell (marginal pull-ups, a tired jumper wire, a strap making poor contact), catching it here, on the bench, is the entire reason this test exists.
Checkpoint: all four tests pass, sane ambient, fingertip rise and decay, agreement with a reference if you had one, boringly repeatable boots. That is a finished driver: verified code, verified wiring, verified behaviour.
The verification boundary, stated plainly: the firmware in this guide was compile-verified end to end in the pinned environment (Step 9's .config and symbol checks are that proof). The console outputs and readings above are the expected behaviour of that verified code on real hardware, your desk check, your readings. If your desk disagrees, the troubleshooting section below is written for exactly that moment.
If it breaks
Work this table top-down by symptom, don't rummage. Each row names the most likely cause first and the check that distinguishes it.
| Symptom | Most likely cause | Check / fix |
|---|---|---|
Build fails: devicetree error naming maxim,max30205 |
The binding wasn't found. The module wasn't passed to this build, or module.yml isn't in a zephyr/ subfolder |
Rebuild with the exact -DZEPHYR_EXTRA_MODULES=... flag (Step 9); confirm max30205-driver/zephyr/module.yml exists at that nested path (Step 4) |
Build succeeds but Step 9's .config check shows the driver not set |
The chain from overlay to symbol broke: node disabled, compatible misspelled, or overlay not applied |
Compare the overlay's compatible character-for-character with the binding (Step 6); confirm status = "okay" reaches &i2c0; confirm the file is named app.overlay in the sample root |
nm check finds no max30205_* symbols |
The driver source never compiled, usually the same causes as the row above, or the driver CMake's sources_ifdef symbol mismatched |
Compare the symbol in drivers/sensor/max30205/CMakeLists.txt against the Kconfig symbol name (Step 2/Step 3) |
Console: MAX30205 is not ready at boot |
Init's test fetch failed: wiring, power, or address mismatch, in that order of likelihood after a fresh build | Re-run Step 10's pre-power inspection; confirm the breakout's strapped address equals reg; try swapping SDA/SCL if they're at all uncertain (a classic, and harmless to test) |
Console: read failed (-5) after readings started |
Bus errors at runtime (-EIO): a marginal connection, missing pull-ups, or a strap moving | Reseat the jumpers; check pull-ups (Step 10, item 3); shorten the wires; confirm nothing else drives the bus |
| Readings frozen at one value forever | The "sensor" isn't responding and something else ACKs, or SDA is shorted/stuck | Power-cycle; disconnect the breakout and boot. A healthy setup now prints not ready (proving the driver notices absence); if it still "reads", the bus is shorted |
| Readings plausible but offset by several degrees, steady | The sensor is being warmed by its surroundings, not misconverted | Move the breakout away from the DK's regulator/LEDs and your laptop's exhaust; re-run Test 3 after five undisturbed minutes |
| Readings look shifted by a huge factor (e.g., room shows as ~6,200) | You're looking at raw counts, not the converted value. The fetch/get split was skipped in your modified code | channel_get does the ÷256 (Step 5); if you edited the sample, check you didn't print the raw register |
Negative temperatures print strangely (e.g. 0.-500000) |
The sensor_value split-sign convention mishandled in a modified print |
val1 and val2 share the raw value's sign (Step 5, piece 4); the stock main.c handles the val1 == 0 case explicitly |
west flash: no probe / target not found |
Wrong USB port on the DK, charge-only cable, or the DK's power switch is off | Use the debugger USB connector and a data cable; this is never a firmware problem |
And the meta-rule that resolves what the table doesn't: change one thing at a time, and re-run the failing checkpoint, not the whole project. Every step above ends in a checkpoint precisely so a fault can be located to one link of the chain (description, configuration, build, wiring, behaviour), instead of five.
You are done when
- The binding, both Kconfig files, both CMake files, and
module.ymlexist at the Step 1 to 4 paths, and you can say what each one is for - The driver compiles as a module and Step 9's two proofs pass:
CONFIG_MAX30205=yin the generated.config(set by the devicetree, not byprj.conf), and the fourmax30205_*symbols inzephyr.elf - The sample application contains no address, no register pointer, and no scale factor. The layering check from Step 7
- The sensor is wired per Step 10 (3.3 V, SDA→P0.26, SCL→P0.27, straps matching
reg) and the DK boots to once-a-second readings - Test 1 passes: a stable, plausible ambient reading, moving in 0.00390625 °C steps
- Test 2 passes: a fingertip raises the reading by roughly 1 to 4 °C, and it decays back to ambient when released
- Two extra resets both reach the readings cleanly (Test 4)
- You can point at any wrong behaviour and say which link of the chain (binding, Kconfig, driver, overlay, wiring), you'd check first, and why
Adapt it to your part
Everything except one file is reusable unchanged. To drive a different sensor:
- New binding: copy the YAML, change
compatibleand the description. Add properties here if your part is configurable. - New Kconfig/CMake: same shapes; rename the symbol; the
DT_HAS_<YOUR_COMPAT>_ENABLEDdependency follows your compatible automatically. - The C file is where your datasheet goes: the register pointer and byte order in
sample_fetch, the scale inchannel_get. A humidity channel means a second register read and a secondsensor_channelcase. The skeleton doesn't change. - Natural next steps for this driver:
attr_setfor the configuration register (this part's one-shot and shutdown bits live at pointer 0x01), thresholds via T_HYST/T_OS with the OS output wired to a GPIO interrupt, andsensor_triggersupport so the application sleeps instead of polling. Each is one API-table row and one datasheet section. You now own the pattern they plug into. - When the driver outgrows a guide: submit it upstream, so the next engineer finds it in the tree. That's where the drivers in
drivers/sensor/come from. Somebody's product needed a part Zephyr didn't have yet. Yours might be next.