Zephyr Sensor Subsystem: Reading Hardware Sensors with a Portable API

You’ve probably written code like this at least once: a hundred lines of raw I2C transactions, hand-crafted register reads, bit-shifting calibration data, all to get a temperature value out of a BME280. It works. Then your hardware team swaps the BME280 for a BMP390 because of supply chain issues, and you’re rewriting half your sensor code.
That rewrite is entirely avoidable. Zephyr ships over 200 in-tree sensor drivers behind a single C API. Two function calls, sensor_sample_fetch() and sensor_channel_get(), read data from any of them. Your application code doesn’t know or care which chip is on the board.
This article walks through the complete process: devicetree configuration, Kconfig setup, and application code to read temperature, pressure, and humidity from a Bosch BME280 over I2C using Zephyr’s sensor driver API. Everything here transfers to any other in-tree sensor with minimal changes.
The Architecture Behind the Sensor API
Here’s the stack:
┌─────────────────────┐
│ Application Code │
│ sensor_sample_fetch│
│ sensor_channel_get │
└────────┬────────────┘
│ Sensor API (uniform)
┌────────▼────────────┐
│ In-Tree Driver │
│ (e.g. bme280) │
└────────┬────────────┘
│ Bus API (I2C / SPI)
┌────────▼────────────┐
│ Hardware / Sensor │
│ (BME280 on I2C bus) │
└──────────────────────┘Your app talks to the Sensor API. The API dispatches to whatever in-tree driver matches your devicetree node. The driver handles bus transactions, calibration math, and register details. You never touch any of that.
Two functions do the heavy lifting. sensor_sample_fetch(dev) tells the driver to read the hardware. sensor_channel_get(dev, channel, &val) pulls a specific measurement out of that read. Channels are predefined constants: SENSOR_CHAN_AMBIENT_TEMP, SENSOR_CHAN_PRESS, SENSOR_CHAN_HUMIDITY, SENSOR_CHAN_ALL.
Values come back as struct sensor_value, which deserves a closer look:
struct sensor_value {
int32_t val1; /* Integer part */
int32_t val2; /* Fractional part (in 1/1,000,000) */
};
/* Example: 23.456789 °C → val1 = 23, val2 = 456789 */
/* Example: -4.002100 hPa → val1 = -4, val2 = -2100 */Why a two-part integer instead of a float? Many Cortex-M0 and M0+ targets lack an FPU, so floating-point math is expensive on those chips. The sensor subsystem stays integer-only throughout. If you do have an FPU and want a double, there’s sensor_value_to_double() for convenience.
The API also supports triggers (interrupt-driven reads via sensor_trigger_set()). I’ll cover those briefly later. Polling is the default and works fine for most use cases.
Describing the Hardware in Devicetree
The BME280 driver already exists in Zephyr’s tree. You just need to describe your hardware wiring so the build system knows the sensor exists and which bus it’s on.
If your dev board’s default .dts already includes a BME280 node (some do), you’re set. For custom boards or breakout boards wired to a dev kit, you’ll write an overlay.
Here’s a typical overlay that puts a BME280 on i2c0:
/* app.overlay — BME280 on I2C0, address 0x76 */
&i2c0 {
status = "okay";
bme280: bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>; /* SDO pin low = 0x76, high = 0x77 */
status = "okay";
};
};The compatible string is the key. It tells the build system which driver to bind. You can find the correct string by looking in dts/bindings/sensor/ in the Zephyr tree, or by checking the driver’s binding YAML file directly (e.g., dts/bindings/sensor/bosch,bme280.yaml).
The reg property is the I2C address. For BME280, it’s 0x76 or 0x77 depending on the SDO pin state. Check your schematic.
If you want extra portability, add a devicetree alias:
/ {
aliases {
temperature-sensor = &bme280;
};
};This lets your app code reference the alias instead of the specific node label, but it’s optional.
Kconfig: Three Lines in prj.conf
Add these to your prj.conf:
CONFIG_SENSOR=y
CONFIG_BME280=y
CONFIG_I2C=yCONFIG_BME280 enables the BME280 driver, which depends on CONFIG_SENSOR and an I2C (or SPI) bus. If you forget CONFIG_SENSOR, the build system will usually tell you.
For troubleshooting during development, add:
CONFIG_SENSOR_LOG_LEVEL_DBG=yThis dumps driver-level debug info to your console, including raw bus errors and calibration data reads. Strip it before production.
Note: you don’t need CONFIG_FPU or any floating-point config unless you choose to call sensor_value_to_double() in your app.
Application Code: A Complete Working Example
Here’s a full main.c that reads all three BME280 channels in a loop:
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/sensor.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(bme280_app, LOG_LEVEL_INF);
int main(void)
{
/* Get device pointer from devicetree */
const struct device *dev = DEVICE_DT_GET_ANY(bosch_bme280);
if (dev == NULL) {
LOG_ERR("No BME280 device found in devicetree");
return -ENODEV;
}
if (!device_is_ready(dev)) {
LOG_ERR("BME280 device %s is not ready", dev->name);
return -ENODEV;
}
LOG_INF("BME280 device %s is ready", dev->name);
struct sensor_value temp, press, hum;
while (1) {
/* Fetch fresh samples from all channels */
int ret = sensor_sample_fetch(dev);
if (ret < 0) {
LOG_ERR("sensor_sample_fetch failed: %d", ret);
k_sleep(K_SECONDS(2));
continue;
}
/* Read individual channels */
sensor_channel_get(dev, SENSOR_CHAN_AMBIENT_TEMP, &temp);
sensor_channel_get(dev, SENSOR_CHAN_PRESS, &press);
sensor_channel_get(dev, SENSOR_CHAN_HUMIDITY, &hum);
LOG_INF("Temp: %d.%06d °C | Press: %d.%06d kPa | Hum: %d.%06d %%RH",
temp.val1, temp.val2,
press.val1, press.val2,
hum.val1, hum.val2);
k_sleep(K_SECONDS(2));
}
return 0;
}Getting the device pointer. DEVICE_DT_GET_ANY(bosch_bme280) scans devicetree for any node with compatible = "bosch,bme280" and returns a pointer. If you have multiple BME280s (rare but possible), use DEVICE_DT_GET(DT_NODELABEL(bme280)) to target a specific one. Always check device_is_ready() before touching it.
Fetching samples. sensor_sample_fetch(dev) tells the driver to read all channels from the hardware in one go. The data is cached inside the driver; sensor_channel_get() reads from that cache, not from the hardware. One fetch, multiple gets.
Reading channels. Each sensor_channel_get() call extracts one measurement. The channel constants (SENSOR_CHAN_AMBIENT_TEMP, etc.) are the same across all drivers that support those measurement types.
Error handling. sensor_sample_fetch() returns a negative errno on failure. Common ones: -EIO for bus errors (bad wiring, pulled-up SDA/SCL issues), -ENODEV if the device disappeared. The code above logs the error and retries. In production, you’d probably want a backoff strategy.
The portability payoff. This exact main.c works with an SI7006, SHT4X, or any other temp/humidity sensor, as long as the devicetree and Kconfig point to the right driver.
Building, Flashing, and Verifying
Build with your board target and overlay:
west build -b nrf52840dk/nrf52840 -- -DDTC_OVERLAY_FILE=app.overlay
west flashOpen a serial console (minicom, screen, or the Zephyr console) at 115200 baud. You should see output like:
[00:00:00.215,000] <inf> bme280_app: BME280 device bme280@76 is ready
[00:00:00.234,000] <inf> bme280_app: Temp: 23.450000 °C | Press: 101.312500 kPa | Hum: 42.187500 %RH
[00:00:02.236,000] <inf> bme280_app: Temp: 23.460000 °C | Press: 101.312500 kPa | Hum: 42.195312 %RHQuick troubleshooting guide:
- “Device not ready” → Check physical wiring. Verify pull-ups on SDA/SCL. Confirm the I2C address matches your overlay’s
regproperty. -ENODEVfrom fetch → Kconfig probably missingCONFIG_BME280=yorCONFIG_I2C=y.- Garbage values → Wrong I2C address (0x76 vs 0x77), or the device is actually a BMP280 (no humidity, different compatible string).
Interrupt-Driven Reads with Sensor Triggers
Polling every 2 seconds works, but sometimes you need to react the instant data is ready, especially in low-power designs where the MCU should sleep between reads. Triggers let you do exactly that:
struct sensor_trigger trig = {
.type = SENSOR_TRIG_DATA_READY,
.chan = SENSOR_CHAN_ALL,
};
sensor_trigger_set(dev, &trig, my_data_ready_handler);Your callback my_data_ready_handler fires when the sensor signals data is ready. Inside it, you’d call sensor_sample_fetch() and sensor_channel_get() as before.
Driver support for triggers varies. Check the driver’s Kconfig for a _TRIGGER option (e.g., CONFIG_BME280_TRIGGER). The BME280 has limited interrupt support compared to, say, an LIS2DH accelerometer, which has rich trigger options for tap detection, free-fall, and threshold crossings. If the driver doesn’t support triggers, sensor_trigger_set() returns -ENOTSUP.
Portability in Practice
Here’s the real payoff, condensed into a table:
┌──────────────────┬──────────────────┬──────────────────┐
│ │ BME280 (I2C) │ BMP390 (I2C) │
├──────────────────┼──────────────────┼──────────────────┤
│ DT compatible │ "bosch,bme280" │ "bosch,bmp390" │
│ Kconfig │ CONFIG_BME280=y │ CONFIG_BMP390=y │
│ App code changes │ None │ None │
│ API calls │ Identical │ Identical │
└──────────────────┴──────────────────┴──────────────────┘Two lines change. Your application code stays identical. This matters if you’re building products where the BOM might shift mid-production. It’s equally useful when you’re prototyping with whatever breakout board happens to be on your desk.
If you’re building BLE sensor devices that transmit this data, the Zephyr sensor subsystem pairs naturally with Bluetooth stacks. For projects where sensor data eventually needs to reach the cloud over BLE, the Hubble Zephyr reference application shows how to structure a Zephyr app that integrates BLE advertising with the Hubble network, and the terrestrial SDK quick-start for Zephyr walks through the integration step by step.
Concrete Next Steps
The pattern is: devicetree describes the hardware, Kconfig enables the driver, the Sensor API reads the data. Once you’ve internalized this, every new sensor is just a new overlay and a Kconfig line.
- Sensor shell. Enable
CONFIG_SENSOR_SHELL=yand usesensor get bme280from the Zephyr shell to read values interactively, without writing any app code. Great for hardware bringup. - Multiple sensors. Add a second sensor (say, an LIS2DH accelerometer) to the same app. You’ll see the pattern scales cleanly: one more DT node, one more Kconfig line, one more device pointer.
- Triggers for low power. If you’re building a battery-powered device, swap the polling loop for a trigger-based design. The MCU sleeps until the sensor says “data’s ready.”
- Custom payloads. Once you’re reading sensor data reliably, you can pack it into a custom payload for transmission over BLE or other transports.
Hubble Network enables direct-to-satellite transmission of sensor data from Bluetooth devices—no gateways, no infrastructure. See how it works →