Intro to Zephyr RTOS: Build a BLE Peripheral from Scratch with Code Examples

Tested against Zephyr 3.7 LTS. If you’re on 4.x, board names use the new hardware model v2 format (e.g., nrf52840dk/nrf52840 instead of nrf52840dk_nrf52840). The code is identical; only the build target string changes.
You’ve got a working FreeRTOS project. You can blink an LED in your sleep. Then your team picks Zephyr. You open main.c from the peripheral sample and you’re staring at BT_GATT_SERVICE_DEFINE, an .overlay file you didn’t write, and a build tool called west that wraps CMake that wraps a compiler. Where did the program start?
The Zephyr learning curve isn’t about the RTOS kernel. The scheduler is boring (in the good way). The curve is that Zephyr is three things bolted together: a kernel, a hardware abstraction system, and a build/dependency tool. Skip the why of any one, and the other two stop making sense.
We’re going to build a BLE peripheral on an nRF52840 DK that advertises a custom GATT sensor service with a notify-enabled temperature characteristic. By the end you’ll have a working binary, and (more importantly) you’ll know which file to edit when something breaks.
Why Zephyr Looks Weird (And Why That’s Good)
Zephyr is an RTOS with a portability contract: the same main.c should compile and run on an nRF52840, an STM32H7, or an ESP32-C3. Keeping that promise means splitting up three concerns most embedded projects mash together.
west is a meta-tool. Your Zephyr “workspace” isn’t one Git repo, it’s dozens: the kernel, the HALs (Nordic’s, ST’s, Espressif’s), middleware like LVGL, crypto libraries, and so on. west reads a manifest file and clones the exact commits you need. Reproducible builds across a team without anyone hand-managing submodules.
Devicetree is a hardware description language inherited from Linux. Pins, buses, peripherals, interrupts: all described in .dts and .overlay files, not in C. Your code asks “give me the UART labeled console” and devicetree resolves which actual peripheral that is on this board. Porting becomes an overlay change, not a code rewrite.
And then there’s Kconfig, also from Linux. Feature selection lives in one file, prj.conf. Want BLE? CONFIG_BT=y. Want it as a peripheral specifically? CONFIG_BT_PERIPHERAL=y. No #ifdef mazes, no scattered build flags.
┌─────────────────────────────────────────┐
│ your main.c │
└───────────┬─────────────────┬───────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ Kconfig │ │ Devicetree │
│ (features) │ │ (hardware) │
└──────┬──────┘ └──────┬──────┘
│ │
┌──────▼─────────────────▼──────┐
│ west build / flash │
└───────────────────────────────┘FreeRTOS gives you a scheduler. Zephyr gives you a scheduler plus a portability contract, and you pay for that contract with the friction up front.
Environment Setup
I’ll keep this short because the official Getting Started guide is good and changes often enough that anything I write will be stale.
The short version:
pip install west
west init ~/zephyrproject
cd ~/zephyrproject
west update
west zephyr-export
pip install -r zephyr/scripts/requirements.txtThen install the Zephyr SDK (the toolchain). Sanity check:
west build -b nrf52840dk_nrf52840 zephyr/samples/basic/blinky
west flashLED blinks? You’re set.
No nRF52840 DK on your desk? You can follow the code with QEMU for non-BLE samples (west build -b qemu_cortex_m3 samples/hello_world), but BLE samples need real radio hardware. Read along anyway; the patterns transfer.
Project Skeleton
Every Zephyr application has the same four files. Create this tree somewhere outside the zephyr/ directory:
ble-sensor/
├── CMakeLists.txt
├── prj.conf
├── app.overlay
└── src/
└── main.cCMakeLists.txt is boilerplate. You’ll rarely touch it.
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(ble_sensor)
target_sources(app PRIVATE src/main.c)prj.conf turns features on:
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="ZephyrSensor"
CONFIG_LOG=yA gotcha worth flagging now: forget CONFIG_BT_PERIPHERAL=y and bt_le_adv_start() returns -ENOTSUP with a cryptic log. Ask me how I know.
app.overlay stays empty for this project. It’s where you’d remap a pin or attach a sensor on I2C. Empty file, but it must exist to be picked up.
src/main.c starts as a stub:
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(app, LOG_LEVEL_INF);
int main(void) {
LOG_INF("boot");
return 0;
}Build it, flash it, watch the RTT log say boot. That’s the floor we’re building from.
Adding BLE: Advertising First
Before we serve any data, the device needs to be discoverable. Three steps: enable the stack, hand it advertising data, start advertising.
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/conn.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>
LOG_MODULE_REGISTER(app, LOG_LEVEL_INF);
#define DEVICE_NAME "ZephyrSensor"
static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA(BT_DATA_NAME_COMPLETE, DEVICE_NAME, sizeof(DEVICE_NAME) - 1),
};
static void connected(struct bt_conn *conn, uint8_t err) {
if (err) {
LOG_ERR("connect failed (0x%02x)", err);
return;
}
LOG_INF("connected");
}
static void disconnected(struct bt_conn *conn, uint8_t reason) {
LOG_INF("disconnected (0x%02x)", reason);
}
BT_CONN_CB_DEFINE(conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
};
int main(void) {
int err = bt_enable(NULL);
if (err) {
LOG_ERR("bt_enable failed (%d)", err);
return 0;
}
LOG_INF("bluetooth ready");
err = bt_le_adv_start(BT_LE_ADV_CONN_NAME, ad, ARRAY_SIZE(ad), NULL, 0);
if (err) {
LOG_ERR("adv start failed (%d)", err);
return 0;
}
LOG_INF("advertising as %s", DEVICE_NAME);
return 0;
}(Keep DEVICE_NAME and CONFIG_BT_DEVICE_NAME in prj.conf in sync, or pull the name from the Kconfig symbol directly.)
BT_CONN_CB_DEFINE is a registration macro. It places your callback struct in a special linker section that the BLE subsystem scans at init. You never call a register_callbacks() function. Zephyr leans on this pattern hard. If you’re coming from Nordic’s SoftDevice (every callback registered imperatively at runtime), the declarative style takes some getting used to, and then you notice your boot path got shorter.
Build, flash, then open nRF Connect on your phone. You should see “ZephyrSensor” in the scan list.
Defining a Custom GATT Sensor Service
Now the payoff. We’ll declare a service with one characteristic that holds a 16-bit temperature value, supports reads, and can push notifications.
First, generate two UUIDs (one for the service, one for the characteristic):
uuidgen
uuidgenI got a1b2c3d4-1234-5678-9abc-def012345678 and a1b2c3d5-1234-5678-9abc-def012345678. Use yours.
#define BT_UUID_SENSOR_SVC \
BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \
0xa1b2c3d4, 0x1234, 0x5678, 0x9abc, 0xdef012345678))
#define BT_UUID_SENSOR_TEMP \
BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \
0xa1b2c3d5, 0x1234, 0x5678, 0x9abc, 0xdef012345678))
static int16_t temp_value = 0x1700; /* 23.00 °C, hundredths */
static bool notify_enabled;
static ssize_t read_temp(struct bt_conn *conn,
const struct bt_gatt_attr *attr,
void *buf, uint16_t len, uint16_t offset) {
return bt_gatt_attr_read(conn, attr, buf, len, offset,
&temp_value, sizeof(temp_value));
}
static void temp_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) {
notify_enabled = (value == BT_GATT_CCC_NOTIFY);
LOG_INF("notifications %s", notify_enabled ? "on" : "off");
}
BT_GATT_SERVICE_DEFINE(sensor_svc,
BT_GATT_PRIMARY_SERVICE(BT_UUID_SENSOR_SVC),
BT_GATT_CHARACTERISTIC(BT_UUID_SENSOR_TEMP,
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ,
read_temp, NULL, &temp_value),
BT_GATT_CCC(temp_ccc_changed,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
);
static void sample_work_handler(struct k_work *work);
static K_WORK_DELAYABLE_DEFINE(sample_work, sample_work_handler);
static void sample_work_handler(struct k_work *work) {
temp_value += 10; /* +0.1 °C per tick */
if (notify_enabled) {
bt_gatt_notify(NULL, &sensor_svc.attrs[1],
&temp_value, sizeof(temp_value));
}
k_work_schedule(&sample_work, K_SECONDS(2));
}Then in main(), after bt_le_adv_start():
k_work_schedule(&sample_work, K_SECONDS(2));BT_GATT_SERVICE_DEFINE is the centerpiece. It builds an attribute table at compile time and registers the service before main() runs. No gatts_add_service() boilerplate. The CCC descriptor (Client Characteristic Configuration) is what lets a phone subscribe; the callback fires when the central writes 0x0001 to enable notifications.
The k_work_delayable is Zephyr’s lightweight deferred-work primitive. It runs on the system work queue, so you’re not spinning up a thread just to tick a timer. For periodic sensor sampling this is the idiomatic pattern; reach for k_thread_create only when you need a dedicated stack or priority.
Build, Flash, Verify
west build -b nrf52840dk_nrf52840
west flashOpen nRF Connect, scan, connect to ZephyrSensor. You’ll see:
Service: a1b2c3d4-1234-5678-9abc-def012345678
└─ Characteristic: a1b2c3d5-1234-5678-9abc-def012345678
Value: 0x1700 (23.00 °C)
Properties: READ, NOTIFY
└─ CCCDTap the read icon, you get 0x1700. Tap the notifications icon (the multiple-arrows symbol), and every 2 seconds the value ticks up by 0x000A. If it does, you have a working Zephyr BLE peripheral and you wrote roughly 80 lines of C to get here.
Three Things to Try Next
The same main.c runs on any Zephyr-supported board with a BLE radio. To move to an STM32WB or a Silicon Labs part, change the build target and (maybe) write an overlay. That’s the contract paying off.
Read a real sensor. Add an I2C node in app.overlay, declare a compatible binding, use DEVICE_DT_GET in code. This is where devicetree earns its keep.
Add pairing and bonding. CONFIG_BT_SMP=y, CONFIG_BT_BONDABLE=y, register an auth callback. The stack handles the rest.
Cut power. CONFIG_PM=y plus CONFIG_PM_DEVICE=y opts your app into system-level power management. Advertising intervals, connection parameters, and idle behavior all become tunable.
If you’re building a device that needs to phone home without a gateway or cellular module nearby, the same BLE advertising packets you’ve already written can be picked up by satellites overhead. The Hubble device SDK is a Zephyr-friendly drop-in, and there’s a Zephyr reference application on GitHub that builds on exactly the patterns above.
Zephyr asks more of you upfront than FreeRTOS. You’ll feel the payoff the second time you ship the same firmware on different silicon.
Hubble Network lets the BLE advertising packets you’re already broadcasting reach satellites overhead—no gateway, no cellular, no extra hardware. See how it works →