If you've ever tried to get a Bluetooth Low Energy device talking to a phone app using Zephyr RTOS, you know the configuration part can feel like reading someone else's shell scripts at 2 a.m. The BLE peripheral setup in Zephyr involves Kconfig flags, device tree overlays, and a specific C code structure that all need to line up correctly. Missing one small piece means your device won't advertise, won't connect, or will silently fail with no useful error message. This walkthrough breaks down each piece of that configuration so you can actually understand what's happening and get your BLE peripheral working.
What does BLE peripheral configuration actually mean in Zephyr RTOS?
In Bluetooth Low Energy, devices take on roles. A peripheral is the device that advertises its presence and waits for a central device (like a smartphone) to connect. Think of a heart rate monitor, a temperature sensor, or a smart lock these are all BLE peripherals.
In Zephyr RTOS, configuring a BLE peripheral means setting up three layers that work together:
- Kconfig configuration enables the Bluetooth stack and selects the BLE role (peripheral, central, or both)
- Device Tree overlay tells the hardware which Bluetooth controller to use
- Application code defines your GATT services, characteristics, advertising data, and connection callbacks
Each layer depends on the others. If Kconfig doesn't enable Bluetooth, your code won't compile. If the device tree doesn't point to the right controller, the stack won't initialize. If your application code has the wrong GATT definition, devices will connect but can't read or write data.
Which Kconfig options do I need to enable BLE peripheral mode?
This is where most beginners get stuck. Zephyr's Kconfig system is powerful but verbose. Here are the options that matter for a basic BLE peripheral:
The core settings go in your prj.conf file:
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="MyBLEDevice"
CONFIG_BT_MAX_CONN=1
That's the minimum. CONFIG_BT=y enables the Bluetooth subsystem. CONFIG_BT_PERIPHERAL=y tells Zephyr to compile only the peripheral role code, which saves flash and RAM compared to enabling both roles. CONFIG_BT_DEVICE_NAME sets the name that shows up when someone scans for devices. And CONFIG_BT_MAX_CONN limits concurrent connections keeping this at 1 is fine for simple sensors.
You might also want:
CONFIG_BT_SMP=yenables Security Manager Protocol for pairing and bondingCONFIG_BT_GATT_DYNAMIC_DB=yallows adding GATT services at runtime instead of compile timeCONFIG_BT_ATT_PREPARE_COUNT=2needed if you have characteristics longer than the default ATT MTU
These settings interact with each other in ways that aren't always obvious. The STM32 task scheduling guide covers how Kconfig affects memory allocation in a different context, and the same principle applies here every enabled feature costs RAM and flash.
How do I set up the device tree overlay for Bluetooth hardware?
Zephyr uses the device tree to describe hardware. For BLE, you need to tell Zephyr which Bluetooth controller your board uses. Many development boards already have this defined, but if you're using a custom board or a module like the nRF52840 or ESP32, you might need an overlay.
A typical overlay for a board using the Zephyr Bluetooth controller looks like this:
/ {
chosen {
zephyr,bt-hci = &bt_hci;
};
};
&bt_hci {
status = "okay";
};
For boards that use the nRF connect SDK or have a built-in BLE controller (like the Raspberry Pi Pico examples show for other peripherals), you often don't need to change anything in the device tree the board's default definition handles it.
The key thing to check: does your board's .dts file already reference a Bluetooth HCI node? If yes, you're probably fine. If not, you need to add one, and it must match the actual hardware controller your chip uses.
What does the C code for a BLE peripheral look like?
This is the part that actually runs on your device. The Zephyr BLE API follows a specific sequence, and every peripheral application does the same basic steps.
Step 1: Define your GATT service and characteristics
GATT (Generic Attribute Profile) is how BLE devices organize and share data. You define a service as a container, and inside it you put characteristics individual data points that a connected device can read, write, or subscribe to (via notifications).
Zephyr uses macros to define these statically:
BT_GATT_SERVICE_DEFINE(my_service,
BT_GATT_PRIMARY_SERVICE(BT_UUID_DECLARE_16(0x180F)),
BT_GATT_CHARACTERISTIC(BT_UUID_DECLARE_16(0x2A19),
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ,
read_battery_level, NULL, &battery_level),
BT_GATT_CCC(ccc_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
);
This defines a Battery Service (UUID 0x180F) with a Battery Level characteristic (UUID 0x2A19) that supports reading and notifications. The BT_GATT_CCC macro adds the Client Characteristic Configuration descriptor, which lets the connected device enable or disable notifications.
Step 2: Set up advertising parameters
Advertising is how your device broadcasts its existence. You need to define what data goes in the advertising packet and the scan response packet.
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),
};
The flags byte tells nearby scanners that this is a general discoverable device and that it only supports BLE (not classic Bluetooth). The name data makes your device identifiable during a scan.
Step 3: Initialize Bluetooth and start advertising
In your main() function, you enable the stack and start advertising:
int main(void)
{
int err = bt_enable(NULL);
if (err) {
printk("Bluetooth init failed (err %d)\n", err);
return;
}
bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad),
NULL, 0);
}
bt_enable(NULL) initializes the Bluetooth stack. Passing NULL means it runs synchronously the function blocks until the stack is ready. Then bt_le_adv_start begins advertising with connectable mode, using the advertising data you defined.
Why won't my BLE peripheral advertising start?
This is the most common frustration. You flash the code, and nothing happens. No device shows up when you scan. Here are the usual causes:
- Missing Kconfig option
CONFIG_BT_PERIPHERAL=ymust be set, orCONFIG_BT_CENTRAL=yif you accidentally enabled the wrong role - Stack initialization error not handled check the return value of
bt_enable(). Error code -5 (-EIO) usually means the controller hardware didn't respond - Device tree mismatch the overlay references a controller that doesn't exist on your board
- Wrong build target you're building for the wrong board, so the hardware abstraction points to nothing
- Flash size overflow BLE stack adds significant code size. If your binary exceeds flash, it might build but not run correctly
Always check bt_enable's return value. It's the single most useful diagnostic for BLE startup problems.
How do I handle BLE connection and disconnection events?
Most real applications need to know when a device connects and disconnects. Zephyr uses callback structures for this:
static struct bt_conn_cb conn_callbacks = {
.connected = connected_cb,
.disconnected = disconnected_cb,
};
Register this before calling bt_enable with bt_conn_cb_register(&conn_callbacks), or after with the same call.
Inside your connected_cb, you typically stop advertising (since you're now connected) and store the connection reference. Inside disconnected_cb, you restart advertising so another device can connect.
The connection callback also gives you access to the connection object, which you need for sending notifications or reading the remote device's address. Don't forget to call bt_conn_unref(conn) when you're done with the reference Zephyr uses reference counting for connection objects, and leaking references causes subtle bugs.
What are the most common mistakes when configuring Zephyr BLE peripherals?
After working through several BLE projects, these patterns come up repeatedly:
- Forgetting CCC descriptor if your characteristic supports notifications but you don't include
BT_GATT_CCC, clients can't subscribe and notifications won't work - Using wrong UUID format Zephyr supports 16-bit, 32-bit, and 128-bit UUIDs. Mixing up
BT_UUID_DECLARE_16andBT_UUID_DECLARE_128causes silent failures where the service appears but the characteristic doesn't - Not checking bt_le_adv_start return value this function returns an error code too, and common errors include already advertising or invalid parameters
- Stack overflow BLE callbacks run on the system workqueue, which has a limited stack. If your callback does heavy processing, you'll get a stack overflow. Use a separate work item or increase the system workqueue stack size
- Modifying GATT database while connected with static
BT_GATT_SERVICE_DEFINE, this isn't an issue, but with dynamic services it can cause data corruption
How does the Zephyr BLE configuration compare to other RTOS approaches?
Zephyr's BLE stack is one of the more complete open-source implementations. Compared to FreeRTOS with NimBLE (which Zephyr also supports as an alternative HCI layer), Zephyr's native stack provides a tighter integration with the build system and device tree.
The trade-off is complexity. Configuring BLE in Zephyr touches three separate subsystems (Kconfig, device tree, application API), while a simpler RTOS might just need a few function calls. Once you understand the pattern though, every Zephyr BLE project follows the same structure.
Practical checklist: getting your Zephyr BLE peripheral working
- ☐ Confirm your board supports Bluetooth in its default device tree or add an overlay
- ☐ Set
CONFIG_BT=yandCONFIG_BT_PERIPHERAL=yinprj.conf - ☐ Set
CONFIG_BT_DEVICE_NAMEto something identifiable - ☐ Define at least one GATT service with
BT_GATT_SERVICE_DEFINE - ☐ Include
BT_GATT_CCCfor any characteristic that supports notifications - ☐ Create advertising data array with flags and device name
- ☐ Call
bt_enable(NULL)and check its return value - ☐ Call
bt_le_adv_startand check its return value - ☐ Register connection callbacks if you need to handle connect/disconnect
- ☐ Test with a BLE scanner app (nRF Connect is the standard for debugging)
If you want clean, readable code while working through these steps, using a monospace typeface in your editor like JetBrains Mono makes a real difference when reading Zephyr's macro-heavy BLE definitions. Start with the official Zephyr BLE peripheral sample in samples/bluetooth/peripheral, get it running on your board first, then modify the GATT service to match your actual application needs.
Arduino Esp32 Sensor Data Publishing Mqtt Snippet
Esp8266 Wifi Connectivity Library Boilerplate Code Reference
Stm32 Iot Rtos Task Scheduling Firmware Snippets Guide
Raspberry Pi Pico Rp2040 Iot Firmware Code Examples for Beginners
Raspberry Pi Automation Scripts Explained for Beginners
Raspberry Pi Gpio Sensor Monitoring Script Walkthrough for Makers