Electronics Design AU
nRF

How Do You Implement a BLE Peripheral with Custom GATT Services on nRF52 Using Zephyr?

Last updated 28 June 2026 · 9 min read

Direct Answer

Define a custom GATT service in Zephyr NCS using the BT_GATT_SERVICE_DEFINE() macro with a 128-bit UUID, characteristic declarations, read/write callback handlers, and a CCCD descriptor for notifications. Enable advertising with bt_le_adv_start(), manage connections via BT_CONN_CB_DEFINE(), and send notifications with bt_gatt_notify(). The Zephyr BLE stack replaces the nRF5 SDK's SoftDevice API; all service definitions are static compile-time structures, and BLE callbacks execute in the Zephyr system workqueue.

Detailed Explanation

The Zephyr BLE stack in nRF Connect SDK (NCS) provides a complete Bluetooth 5.x GATT server implementation. Unlike the nRF5 SDK, where the SoftDevice runs as a pre-compiled closed-source binary and the application communicates via SVC call-gates (sd_ble_...), the Zephyr BLE stack is open-source and integrated directly into the build. GATT services are defined as static compile-time structures using C macros, and callbacks execute in Zephyr's cooperative scheduling context.

This page covers the full BLE peripheral implementation pattern: Kconfig setup, service definition, advertising, connection management, read/write handlers, and notifications.


Kconfig Prerequisites

Enable the Zephyr BLE subsystem in prj.conf before defining any GATT service:

CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="My Sensor"
CONFIG_BT_MAX_CONN=1
CONFIG_BT_DEVICE_APPEARANCE=0

For notification support, no additional symbol is needed — it is included once CONFIG_BT=y and CONFIG_BT_PERIPHERAL=y are set. For indication support specifically, CONFIG_BT_GATT_INDICATE_FUNC_ENABLED=y may be required depending on the NCS version.

See How Do You Set Up the nRF Connect SDK and Zephyr RTOS for nRF52 Development? for the full west workspace and build setup before working with BLE.


Step 1 — Define UUIDs

Every GATT service and characteristic requires a UUID. For custom services, use 128-bit UUIDs. Bluetooth SIG assigns 16-bit UUIDs to standard profiles; 128-bit UUIDs identify vendor-specific services.

#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/uuid.h>

/* Generate unique 128-bit UUIDs for your product using any UUID generator.
 * Use a different UUID for every service and every characteristic. */
#define MY_SERVICE_UUID \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \
        0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0))

#define MY_SENSOR_CHAR_UUID \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \
        0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef1))

#define MY_CTRL_CHAR_UUID \
    BT_UUID_DECLARE_128(BT_UUID_128_ENCODE( \
        0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef2))

Replace the placeholder values with your own UUID. Generate unique UUIDs using any RFC 4122 UUID generator tool — each service and characteristic in your product should have a UUID you control.


Step 2 — Write Characteristic Handlers

Each readable or writable characteristic needs callback functions with the signatures required by the Zephyr GATT API.

/* Application state — the value this characteristic exposes */
static uint16_t sensor_reading;
static uint8_t  ctrl_byte;
static bool     notify_enabled;

/* Read handler: called when a BLE central reads this characteristic */
static ssize_t read_sensor(struct bt_conn *conn,
                           const struct bt_gatt_attr *attr,
                           void *buf, uint16_t len, uint16_t offset)
{
    /* bt_gatt_attr_read handles range checking and slicing automatically */
    return bt_gatt_attr_read(conn, attr, buf, len, offset,
                             &sensor_reading, sizeof(sensor_reading));
}

/* Write handler: called when a BLE central writes to this characteristic */
static ssize_t write_ctrl(struct bt_conn *conn,
                          const struct bt_gatt_attr *attr,
                          const void *buf, uint16_t len,
                          uint16_t offset, uint8_t flags)
{
    if (offset + len > sizeof(ctrl_byte)) {
        return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET);
    }
    memcpy(((uint8_t *)&ctrl_byte) + offset, buf, len);
    /* Act on the control byte — e.g. update a GPIO */
    return len; /* Return bytes consumed, not 0 */
}

/* CCCD callback: called when the central enables or disables notifications */
static void sensor_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value)
{
    notify_enabled = (value == BT_GATT_CCC_NOTIFY);
}

Write handler return value: The write handler must return the number of bytes consumed on success (typically len). Returning 0 is not an error in the Zephyr ATT layer but is interpreted as zero bytes consumed, which can cause retry loops on some central implementations.


Step 3 — Define the GATT Service

BT_GATT_SERVICE_DEFINE() registers the service at link time using Zephyr's iterable section mechanism. The table is immutable at runtime.

BT_GATT_SERVICE_DEFINE(my_svc,
    /* Primary service declaration */
    BT_GATT_PRIMARY_SERVICE(MY_SERVICE_UUID),

    /* Sensor characteristic: readable and notifiable */
    BT_GATT_CHARACTERISTIC(MY_SENSOR_CHAR_UUID,
                           BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
                           BT_GATT_PERM_READ,
                           read_sensor, NULL, NULL),
    BT_GATT_CCC(sensor_ccc_changed,
                BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),

    /* Control characteristic: writable only */
    BT_GATT_CHARACTERISTIC(MY_CTRL_CHAR_UUID,
                           BT_GATT_CHRC_WRITE | BT_GATT_CHRC_WRITE_WITHOUT_RESP,
                           BT_GATT_PERM_WRITE,
                           NULL, write_ctrl, NULL),
);

Attribute table layout: BT_GATT_CHARACTERISTIC() expands to two attributes — the characteristic declaration and the characteristic value attribute. BT_GATT_CCC() adds one more (the Client Characteristic Configuration Descriptor). The attribute indices matter when calling bt_gatt_notify() — the value attribute for the sensor characteristic is at my_svc.attrs[2].

Permissions vs properties:

  • Properties (BT_GATT_CHRC_*) — advertised to the client in the characteristic declaration, describing what operations are supported.
  • Permissions (BT_GATT_PERM_*) — enforced by the GATT server, controlling what operations are actually allowed.

Both must be set consistently: declaring BT_GATT_CHRC_READ without BT_GATT_PERM_READ produces a characteristic that advertises reads but rejects them with an ATT error.


Step 4 — Set Up Advertising

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,
            CONFIG_BT_DEVICE_NAME,
            sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};

static void start_advertising(void)
{
    int err = bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);
    if (err) {
        /* Log error — advertising failed to start */
        return;
    }
}

BT_LE_ADV_CONN uses a 100 ms advertising interval by default. For battery-powered designs, use BT_LE_ADV_PARAM() to set a longer interval (e.g. 1 second) when no connection is expected quickly:

static const struct bt_le_adv_param adv_param =
    BT_LE_ADV_PARAM_INIT(BT_LE_ADV_OPT_CONNECTABLE,
                         BT_GAP_ADV_SLOW_INT_MIN,   /* 1 s min */
                         BT_GAP_ADV_SLOW_INT_MAX,   /* 1.28 s max */
                         NULL);

Advertising power is proportional to advertising rate. A 1-second advertising interval consumes roughly 10× less average current than a 100 ms interval — size this to the longest value your use case allows and measure with a Nordic PPK2.


Step 5 — Handle Connections

static struct bt_conn *current_conn;

static void connected(struct bt_conn *conn, uint8_t err)
{
    if (err) {
        return;
    }
    current_conn = bt_conn_ref(conn); /* Hold a reference */
}

static void disconnected(struct bt_conn *conn, uint8_t reason)
{
    bt_conn_unref(current_conn); /* Release reference */
    current_conn = NULL;
    notify_enabled = false;
    start_advertising(); /* Restart advertising after disconnect */
}

BT_CONN_CB_DEFINE(conn_callbacks) = {
    .connected    = connected,
    .disconnected = disconnected,
};

Connection reference counting: The Zephyr BLE stack uses reference counting on bt_conn objects. Call bt_conn_ref() to hold a reference when you store a bt_conn * pointer, and bt_conn_unref() when releasing it. Failing to call bt_conn_unref() is a resource leak that prevents the stack from freeing the connection slot. Accessing a bt_conn * after calling bt_conn_unref() is use-after-free.


Step 6 — Send Notifications

void send_sensor_notification(uint16_t value)
{
    if (!current_conn || !notify_enabled) {
        return;
    }

    sensor_reading = value; /* Update the application state */

    /* attrs[2] is the sensor characteristic value attribute */
    int err = bt_gatt_notify(current_conn, &my_svc.attrs[2],
                             &sensor_reading, sizeof(sensor_reading));
    if (err == -ENOTCONN) {
        /* Connection dropped between the check and the notify — normal */
    }
}

Check notify_enabled before calling bt_gatt_notify(). If the client has not written 0x0001 to the CCCD, bt_gatt_notify() returns -ECANCELED and the notification is not sent. Calling without checking is not harmful but wastes CPU and radio time.


Step 7 — Initialise and Run

int main(void)
{
    int err = bt_enable(NULL);
    if (err) {
        return err; /* BLE hardware failed to initialise */
    }

    start_advertising();

    while (1) {
        k_sleep(K_SECONDS(1));
        if (current_conn && notify_enabled) {
            send_sensor_notification(read_sensor_adc());
        }
    }
}

bt_enable(NULL) initialises the BLE stack synchronously (the NULL callback form blocks until complete in NCS). All subsequent BLE operations — advertising, connection callbacks, GATT handlers — execute on Zephyr's cooperative cooperative scheduler threads, so keep callbacks short and offload heavy processing to an application thread using a message queue or work queue.


Notify vs Indicate

PropertyNotify (BT_GATT_CHRC_NOTIFY)Indicate (BT_GATT_CHRC_INDICATE)
Client acknowledgementNoYes (ATT Confirmation)
Delivery guaranteeNoneOne outstanding indication at a time
ThroughputHighLower
Use caseSensor streaming, frequent updatesAlarm status, configuration writes
CCCD enable value0x00010x0002

For indication, replace bt_gatt_notify() with bt_gatt_indicate() and provide a struct bt_gatt_indicate_params with a completion callback to know when the indication is acknowledged.


Comparison with ESP32 NimBLE

For engineers familiar with ESP32 BLE via NimBLE, the Zephyr approach differs in key ways: Zephyr uses static compile-time BT_GATT_SERVICE_DEFINE() macros rather than NimBLE's runtime ble_gatts_count_cfg() + ble_gatts_add_svcs() registration. Zephyr's connection reference counting (bt_conn_ref/unref) has no direct NimBLE equivalent — NimBLE uses connection handles (integers). See ESP32 BLE Fundamentals with NimBLE for the NimBLE API comparison.


Common Mistakes

  • Write handler returning 0 instead of len — a write handler returning 0 signals zero bytes consumed, not success. Some BLE centrals will retry the write or report a protocol error. Always return the number of bytes consumed (len) on success.
  • Calling bt_gatt_notify() without checking notify_enabled — the client must write to the CCCD to subscribe to notifications. If the characteristic lacks a CCC descriptor or the client has not subscribed, bt_gatt_notify() silently discards the notification. Always track CCCD state via the ccc_changed callback.
  • Not calling bt_conn_unref() on disconnect — every bt_conn_ref() must be paired with exactly one bt_conn_unref(). Skipping the unref leaks the connection slot; after enough reconnects, the stack runs out of connection objects and new connections are rejected.
  • Running BLE callbacks on the system workqueue with blocking operations — GATT read/write callbacks execute in the Zephyr system workqueue. Any blocking call (k_sleep, k_mutex_lock with a timeout, flash writes) in a BLE callback can deadlock or delay other workqueue items. Move blocking work to a dedicated application thread using k_work_submit() or a dedicated thread.
  • Forgetting to restart advertising after disconnect — once a BLE connection closes, the peripheral is no longer discoverable unless advertising is explicitly restarted. Call bt_le_adv_start() in the disconnected callback.

Design Considerations

  • For production BLE peripherals, implement a security pairing flow (CONFIG_BT_SMP=y) if the characteristic data is sensitive. Unauthenticated BLE connections expose all readable characteristics to any nearby device.
  • Use CONFIG_BT_GATT_DYNAMIC_DB=y if you need to add service definitions at runtime rather than at link time — this is unusual but sometimes required for DFU-as-a-service implementations.
  • Validate end-to-end with a BLE client app (nRF Connect for Mobile is the standard tool for nRF52 development) before testing with your own iOS or Android app. nRF Connect exposes all services, UUIDs, properties, and CCCD state in a readable form, which is invaluable for diagnosing service configuration issues.
  • For OTA firmware updates over BLE, the standard approach on nRF52 with Zephyr NCS is MCUboot with the NUS (Nordic UART Service) DFU transport — see How Does OTA Firmware Update Work? for the dual-slot partition and signing workflow.

For nRF52 BLE peripheral firmware including custom GATT profile design, pairing and security, MCUboot OTA, and mobile app BLE integration, Zeus Design delivers complete firmware stacks for wearable and IoT products.

Frequently Asked Questions

What is the difference between BT_GATT_CHRC_NOTIFY and BT_GATT_CHRC_INDICATE in Zephyr?
NOTIFY sends data to the client without requiring acknowledgement — the peripheral fires and forgets. INDICATE sends data and requires the client to send an ATT Confirmation before the next indication can be sent. Use NOTIFY for high-frequency sensor data where occasional loss is acceptable (e.g. streaming accelerometer readings). Use INDICATE for critical state changes where delivery confirmation matters (e.g. alarm status, configuration updates). The client must write to the CCCD to enable either: 0x0001 for notifications, 0x0002 for indications.
Can I change GATT service attributes at runtime in Zephyr?
No. BT_GATT_SERVICE_DEFINE() creates a static, read-only attribute table at compile time — the service structure, UUIDs, and attribute count are fixed. What you can change at runtime is the characteristic value data (stored in your application variables and read by callback handlers), the advertising payload, and connection parameters. Dynamic services are not supported by the Zephyr GATT server. If your product needs different GATT profiles for different operating modes, define all required characteristics at compile time and control their visibility through characteristic properties and permissions.
Does Zephyr BLE on nRF52 support multiple simultaneous BLE connections?
Yes. Set CONFIG_BT_MAX_CONN to the number of concurrent connections you need (e.g. CONFIG_BT_MAX_CONN=3). Each connection requires memory from the BLE connection buffers pool (CONFIG_BT_BUF_ACL_RX_COUNT, CONFIG_BT_BUF_ACL_TX_COUNT). The nRF52840 (256 KB RAM) can typically support 4–8 simultaneous connections depending on GATT table size and buffer configuration; the nRF52832 (64 KB RAM) is more constrained. Nordic's PPK2 measurement at realistic multi-connection advertising rates is essential for validating battery life before committing to a multi-connection architecture.

References

Related Questions

Related Forum Discussions