How to Write a Zephyr Device Driver for a Custom I2C Sensor

Writing a custom I2C sensor driver in Zephyr RTOS

You’ve got a custom I2C sensor on your bench. You can talk to it fine with raw register reads on a bare-metal STM32 project. But you’re moving to Zephyr, and suddenly you need a devicetree binding, a Kconfig symbol, a CMakeLists.txt, and a C source file that wires into the sensor subsystem API. They all have to agree with each other through generated macros. You copied the upstream bme280 driver, changed some names, and got 14 cryptic build errors.

Zephyr’s driver model is well-designed but poorly introduced. The official docs describe every API in detail but never walk you through building one from scratch.

By the end of this article, you’ll have a complete, buildable, out-of-tree zephyr device driver for a fictional I2C temperature/pressure sensor called the BMP-EX1 (address 0x55). More importantly, you’ll understand the mental model well enough to generalize this to any I2C peripheral.

Prerequisites: Zephyr SDK installed, west working, a board with I2C (or QEMU). Familiarity with C and basic I2C protocol.

The Four-File Contract

Every custom zephyr driver needs exactly four things, and they have to agree with each other:

┌─────────────────┐    ┌──────────────┐
│  Devicetree      │    │  Kconfig     │
│  (.overlay +     │    │  (symbol +   │
│   binding .yaml) │    │   depends on)│
└───────┬─────────┘    └──────┬───────┘
        │  generates macros    │  gates compilation
        ▼                      ▼
┌──────────────────────────────────────┐
│         Driver C Source              │
│  init() / fetch() / get()           │
│  DEVICE_DT_INST_DEFINE(...)         │
└──────────────────┬───────────────────┘
                   ▼
         ┌──────────────────┐
         │   Application    │
         │  DEVICE_DT_GET() │
         │  sensor_fetch()  │
         └──────────────────┘

The devicetree binding (YAML) declares what hardware exists and what properties it has. The devicetree overlay places an actual instance of that hardware on a specific bus at a specific address. Kconfig gates whether the driver gets compiled. The C source implements the behavior and registers itself via DEVICE_DT_INST_DEFINE.

The build system reads the devicetree, generates C macros (like DT_INST_REG_ADDR(0)), and feeds them to the driver source. The Kconfig symbol ensures the driver only compiles when a matching devicetree node exists.

This is Zephyr’s “instance-based” driver model. The DT_INST_* macros refer to instances of your driver’s compatible string, numbered starting at 0. If you have 2 BMP-EX1 sensors on different buses, DT_INST_FOREACH_STATUS_OKAY will stamp out a driver instance for each.

The only difference between an out-of-tree driver and an in-tree one is where the files live. Out-of-tree drivers are first-class.

Step 1: Project Structure and Scaffolding

Create this directory layout:

my-bmp-ex1-driver/
├── zephyr/
│   └── module.yml
├── drivers/
│   └── sensor/
│       └── bmp_ex1/
│           ├── CMakeLists.txt
│           ├── Kconfig
│           └── bmp_ex1.c
├── dts/
│   └── bindings/
│       └── sensor/
│           └── vendor,bmp-ex1.yaml
└── boards/
    └── nrf52840dk_nrf52840.overlay

The zephyr/module.yml tells Zephyr’s build system where to find your stuff:

build:
  cmake: .
  kconfig: drivers/sensor/bmp_ex1/Kconfig
  settings:
    dts_root: .

You’ll reference this module at build time with ZEPHYR_EXTRA_MODULES. No need to fork the Zephyr tree or touch west.yml during development.

Step 2: The Devicetree Binding

Create dts/bindings/sensor/vendor,bmp-ex1.yaml:

description: BMP-EX1 I2C temperature and pressure sensor

compatible: "vendor,bmp-ex1"

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

properties:
  reg:
    required: true

  osr-press:
    type: int
    default: 4
    description: Pressure oversampling rate (1, 2, 4, 8, 16)

  osr-temp:
    type: int
    default: 2
    description: Temperature oversampling rate (1, 2, 4, 8, 16)

The include: [sensor-device.yaml, i2c-device.yaml] line pulls in standard properties (including reg for the I2C address) and tells Zephyr this device lives on an I2C bus. The compatible string is the single most important piece. If it doesn’t match exactly between this file and your overlay, the driver will silently fail to register. This accounts for the majority of “device not found” errors in custom Zephyr drivers.

Step 3: The Devicetree Overlay

Create boards/nrf52840dk_nrf52840.overlay (adapt the board name to yours):

&i2c0 {
    status = "okay";

    bmp_ex1: bmp_ex1@55 {
        compatible = "vendor,bmp-ex1";
        reg = <0x55>;
        osr-press = <8>;
        osr-temp = <4>;
    };
};

The @55 in the node name and reg = <0x55> must match. The parent &i2c0 node needs status = "okay" too; if the I2C controller isn’t enabled, the child sensor node won’t generate any macros.

Step 4: Kconfig

Create drivers/sensor/bmp_ex1/Kconfig:

config BMP_EX1
	bool "BMP-EX1 temperature and pressure sensor"
	default y
	depends on DT_HAS_VENDOR_BMP_EX1_ENABLED
	select I2C
	select SENSOR
	help
	  Enable driver for the BMP-EX1 I2C sensor.

The depends on DT_HAS_VENDOR_BMP_EX1_ENABLED line is critical: Zephyr auto-generates this symbol from your compatible string. Without it, the driver compiles even when no BMP-EX1 node exists in the devicetree. That causes confusing link errors. The select I2C and select SENSOR lines pull in the subsystems the driver needs.

Step 5: The Driver C Source

This is where the real work lives. Create drivers/sensor/bmp_ex1/bmp_ex1.c:

#define DT_DRV_COMPAT vendor_bmp_ex1

#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(bmp_ex1, CONFIG_SENSOR_LOG_LEVEL);

/* BMP-EX1 register map (fictional) */
#define BMP_EX1_REG_CHIP_ID    0x00
#define BMP_EX1_REG_TEMP_MSB   0x04
#define BMP_EX1_REG_PRESS_MSB  0x07
#define BMP_EX1_CHIP_ID_VAL    0x42

struct bmp_ex1_data {
    int32_t raw_temp;
    int32_t raw_press;
};

struct bmp_ex1_config {
    struct i2c_dt_spec i2c;
    uint8_t osr_press;
    uint8_t osr_temp;
};

static int bmp_ex1_sample_fetch(const struct device *dev,
                                enum sensor_channel chan)
{
    const struct bmp_ex1_config *cfg = dev->config;
    struct bmp_ex1_data *data = dev->data;
    uint8_t buf[6];
    int ret;

    ret = i2c_burst_read_dt(&cfg->i2c, BMP_EX1_REG_TEMP_MSB, buf, 6);
    if (ret < 0) {
        LOG_ERR("Failed to read sensor data: %d", ret);
        return ret;
    }

    /* Temp: 3 bytes big-endian, 20-bit signed */
    data->raw_temp = ((int32_t)buf[0] << 12) |
                     ((int32_t)buf[1] << 4)  |
                     ((int32_t)buf[2] >> 4);

    /* Pressure: 3 bytes big-endian, 20-bit unsigned */
    data->raw_press = ((int32_t)buf[3] << 12) |
                      ((int32_t)buf[4] << 4)  |
                      ((int32_t)buf[5] >> 4);

    return 0;
}

static int bmp_ex1_channel_get(const struct device *dev,
                               enum sensor_channel chan,
                               struct sensor_value *val)
{
    struct bmp_ex1_data *data = dev->data;

    switch (chan) {
    case SENSOR_CHAN_AMBIENT_TEMP:
        /* Convert raw to degrees C (fictional formula) */
        val->val1 = data->raw_temp / 100;
        val->val2 = (data->raw_temp % 100) * 10000;
        break;
    case SENSOR_CHAN_PRESS:
        /* Convert raw to kPa (fictional formula) */
        val->val1 = data->raw_press / 1000;
        val->val2 = (data->raw_press % 1000) * 1000;
        break;
    default:
        return -ENOTSUP;
    }

    return 0;
}

static const struct sensor_driver_api bmp_ex1_api = {
    .sample_fetch = bmp_ex1_sample_fetch,
    .channel_get = bmp_ex1_channel_get,
};

static int bmp_ex1_init(const struct device *dev)
{
    const struct bmp_ex1_config *cfg = dev->config;
    uint8_t chip_id;
    int ret;

    if (!i2c_is_ready_dt(&cfg->i2c)) {
        LOG_ERR("I2C bus not ready");
        return -ENODEV;
    }

    ret = i2c_reg_read_byte_dt(&cfg->i2c, BMP_EX1_REG_CHIP_ID, &chip_id);
    if (ret < 0) {
        LOG_ERR("Failed to read chip ID: %d", ret);
        return ret;
    }

    if (chip_id != BMP_EX1_CHIP_ID_VAL) {
        LOG_ERR("Bad chip ID: 0x%02x (expected 0x%02x)",
                chip_id, BMP_EX1_CHIP_ID_VAL);
        return -ENODEV;
    }

    LOG_INF("BMP-EX1 found at 0x%02x", cfg->i2c.addr);
    return 0;
}

#define BMP_EX1_INST(inst)                                      \
    static struct bmp_ex1_data bmp_ex1_data_##inst;             \
                                                                \
    static const struct bmp_ex1_config bmp_ex1_config_##inst = {\
        .i2c = I2C_DT_SPEC_INST_GET(inst),                     \
        .osr_press = DT_INST_PROP(inst, osr_press),            \
        .osr_temp = DT_INST_PROP(inst, osr_temp),              \
    };                                                          \
                                                                \
    DEVICE_DT_INST_DEFINE(inst,                                 \
                          bmp_ex1_init,                         \
                          NULL,                                 \
                          &bmp_ex1_data_##inst,                 \
                          &bmp_ex1_config_##inst,               \
                          POST_KERNEL,                          \
                          CONFIG_SENSOR_INIT_PRIORITY,          \
                          &bmp_ex1_api);

DT_INST_FOREACH_STATUS_OKAY(BMP_EX1_INST)

Let’s walk through the key pieces.

#define DT_DRV_COMPAT vendor_bmp_ex1 at the top is mandatory. It tells all the DT_INST_* macros which compatible string to look up. Note the underscores: Zephyr converts "vendor,bmp-ex1" to vendor_bmp_ex1 (commas become underscores, hyphens become underscores).

The config struct holds things that don’t change at runtime: the I2C bus spec and oversampling settings pulled from devicetree. The data struct holds mutable runtime state (the raw readings).

i2c_burst_read_dt() handles the I2C transfer correctly, including the restart condition between the register address write and the data read. If you used raw i2c_transfer() instead, you’d need to set I2C_MSG_RESTART on the read message. Missing that restart is why people get 0xFF back from every read.

The BMP_EX1_INST macro at the bottom stamps out a data struct, a config struct, and a DEVICE_DT_INST_DEFINE call for each instance. DT_INST_FOREACH_STATUS_OKAY iterates over every devicetree node with compatible = "vendor,bmp-ex1" and status = "okay". If you put 3 of these sensors on different buses, you get 3 driver instances with zero extra code.

Step 6: CMakeLists.txt and Build

Create drivers/sensor/bmp_ex1/CMakeLists.txt:

zephyr_library()
zephyr_library_sources_ifdef(CONFIG_BMP_EX1 bmp_ex1.c)

Two lines. The _ifdef suffix means the source only compiles when CONFIG_BMP_EX1 is enabled in Kconfig.

Build it:

west build -b nrf52840dk/nrf52840 your_app \
  -DZEPHYR_EXTRA_MODULES=/path/to/my-bmp-ex1-driver

If you’re working with Zephyr alongside a Hubble integration, the Hubble reference application for Zephyr follows a similar out-of-tree module pattern that you can reference for project structure.

Step 7: Application Code and Testing

Your main.c is simple:

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

const struct device *sensor = DEVICE_DT_GET_ONE(vendor_bmp_ex1);

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

    if (!device_is_ready(sensor)) {
        printk("Sensor not ready\n");
        return -1;
    }

    while (1) {
        sensor_sample_fetch(sensor);
        sensor_channel_get(sensor, SENSOR_CHAN_AMBIENT_TEMP, &temp);
        sensor_channel_get(sensor, SENSOR_CHAN_PRESS, &press);

        printk("Temp: %d.%06d C  Press: %d.%06d kPa\n",
               temp.val1, temp.val2, press.val1, press.val2);

        k_sleep(K_SECONDS(1));
    }
}

Add CONFIG_SENSOR=y and CONFIG_I2C=y to your prj.conf. The Kconfig select in your driver should handle this, but being explicit doesn’t hurt.

For hardware debugging, enable the Zephyr shell and the I2C shell module (CONFIG_I2C_SHELL=y). Running i2c scan i2c@40003000 from the shell will confirm whether your sensor is visible on the bus before you even load the driver.

The Five Failures You’ll Probably Hit

SymptomLikely CauseFix
“device not found” at runtimeCompatible string mismatch between overlay and binding YAMLVerify compatible is identical in both files, character for character
Driver code never compilesKconfig depends on is missing or wrongAdd depends on DT_HAS_VENDOR_BMP_EX1_ENABLED and check with west build -t menuconfig
I2C read returns all 0xFFMissing restart condition between write and readUse i2c_burst_read_dt() or i2c_write_read_dt() instead of raw transfers
Build error: undefined reference to DT_INST_*Parent I2C node doesn’t have status = "okay"Verify the I2C controller node is enabled in your board’s base devicetree
Sensor returns garbage valuesByte order or sign extension wrong on raw dataUse sys_get_be16() / sys_get_be24() and sign-extend properly for signed registers

Two build targets will save you hours. west build -t menuconfig shows you exactly which Kconfig symbols are set (search for BMP_EX1). And west build -t devicetree dumps the final merged devicetree so you can confirm your overlay was applied correctly.

Adapting This for Your Real Sensor

Every I2C sensor driver in Zephyr follows this exact four-file pattern. To adapt it for your hardware:

  1. Swap the register map constants for your sensor’s actual registers.
  2. Update the chip ID check in init() to match your datasheet.
  3. Rewrite sample_fetch() and channel_get() with the real conversion formulas.
  4. Add any sensor-specific properties (interrupt pins, FIFO depth, etc.) to the binding YAML.

Once you’ve built one, the second takes 20 minutes instead of 2 days.

If your device ultimately needs to send sensor data over a wireless link, the Hubble Device SDK can bolt onto a Zephyr project and push BLE advertising packets through the Hubble network, pairing naturally with the kind of sensor driver you just built.

For the next challenge: look into Zephyr’s sensor trigger API (sensor_trigger_set) to handle data-ready interrupts instead of polling, and DMA-backed I2C transfers for high-throughput sensors. Both follow the same device model. You’re just filling in more callbacks.


Hubble Network connects your custom Zephyr sensors directly to the cloud over Bluetooth—no gateways, no extra radios. See how it works →