Implementing a Custom Bluetooth GATT Service for Real-Time Debug Logging via BLE Notifications
In embedded Bluetooth Low Energy (BLE) development, debugging is often a challenge. Traditional serial logs require a physical UART connection, which may not be available in a sealed product or during field testing. A powerful alternative is to stream debug logs over BLE itself, using a custom GATT service that sends log data as notifications. This approach leverages the BLE stack's notification mechanism to push log messages from a peripheral device to a connected central device (e.g., a smartphone or PC) in real time, without requiring the central to poll the peripheral.
This article provides a technical deep-dive into designing and implementing such a service. We will cover the GATT service structure, the use of notifications for low-latency data transfer, integration with popular BLE stacks like ESP-IDF's Bluedroid or NimBLE, and performance considerations. The discussion is based on Bluetooth SIG specifications and real-world embedded development practices.
Why a Custom GATT Service for Debug Logging?
Standard BLE profiles like the Device Information Service or Battery Service have fixed UUIDs and data formats. For debug logging, we need a custom service that can carry arbitrary text or binary log data. By implementing a service that sends logs via notifications, we achieve:
- Real-time streaming: Notifications are asynchronous and do not require the central to send read requests.
- Low overhead: Each notification carries a small payload (up to 20 bytes per packet in BLE 4.x, or up to 244 bytes with Data Length Extension in BLE 5.x).
- Minimal impact on application logic: The logging service runs in parallel with the main application, using a ring buffer to queue log messages.
GATT Service Design
We define a custom GATT service with a single characteristic that supports the "Notify" property. The service UUID should be a 128-bit UUID to avoid conflicts with standard Bluetooth SIG services. For example:
Service UUID: 12345678-1234-5678-1234-56789abcdef0
Characteristic UUID: 12345678-1234-5678-1234-56789abcdef1
Properties: Notify
Descriptor: Client Characteristic Configuration (CCCD) – required for enabling notifications
The characteristic value is a byte array containing the log message. The length can vary, but the BLE stack will fragment it into multiple notification packets if it exceeds the MTU size. To simplify, we can send each log line as one notification, truncating if necessary.
Implementation on ESP32 with ESP-IDF
The ESP32 is a popular platform for BLE applications, and its ESP-IDF supports two host stacks: Bluedroid (full-featured) and NimBLE (lightweight). For a debug logging service, NimBLE is often sufficient and has a smaller memory footprint. Below is a step-by-step implementation outline.
Step 1: Define the GATT Service and Characteristic
Using the NimBLE stack, we define the service in code. First, include the necessary headers:
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
Then, define the UUIDs and the service structure:
static const ble_uuid128_t gatt_svr_svc_debug_log_uuid =
BLE_UUID128_INIT(0xf0, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00);
static const ble_uuid128_t gatt_svr_chr_debug_log_uuid =
BLE_UUID128_INIT(0xf1, 0xde, 0xbc, 0x9a, 0x78, 0x56, 0x34, 0x12,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00);
Next, declare the characteristic access callback and the service definition:
static int
gatt_svr_chr_access_debug_log(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt,
void *arg);
static const struct ble_gatt_svc_def gatt_svr_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &gatt_svr_svc_debug_log_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]) { {
.uuid = &gatt_svr_chr_debug_log_uuid.u,
.access_cb = gatt_svr_chr_access_debug_log,
.flags = BLE_GATT_CHR_F_NOTIFY,
}, {
0, /* No more characteristics */
} },
},
{
0, /* No more services */
},
};
Step 2: Implement the Access Callback
The access callback handles read, write, and subscribe/unsubscribe events. For notifications, we only need to handle the CCCD write (subscribe). When a central writes 0x0001 to the CCCD, it enables notifications. We store the connection handle for later use.
static uint16_t debug_log_conn_handle = BLE_HS_CONN_HANDLE_NONE;
static int
gatt_svr_chr_access_debug_log(uint16_t conn_handle, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt,
void *arg)
{
switch (ctxt->op) {
case BLE_GATT_ACCESS_OP_READ_CHR:
/* Not used for logging */
return BLE_ATT_ERR_UNLIKELY;
case BLE_GATT_ACCESS_OP_WRITE_CHR:
/* Handle CCCD write */
if (ctxt->om->om_len == 2) {
uint16_t cccd_val;
ble_hs_mbuf_from_flat(ctxt->om, &cccd_val, 2);
if (cccd_val == 1) {
debug_log_conn_handle = conn_handle;
} else {
debug_log_conn_handle = BLE_HS_CONN_HANDLE_NONE;
}
}
return 0;
default:
return BLE_ATT_ERR_UNLIKELY;
}
}
Step 3: Send Log Notifications
We provide a function that queues a log message and sends it as a notification if a central is subscribed. The message is placed in a ring buffer to avoid blocking the main application. A periodic task or a callback sends the notification.
#include "nimble/ble.h"
static void
send_log_notification(const char *log_msg)
{
if (debug_log_conn_handle == BLE_HS_CONN_HANDLE_NONE) {
return; /* No central subscribed */
}
struct os_mbuf *om = ble_hs_mbuf_l2cap_alloc();
if (!om) {
return;
}
int rc = os_mbuf_append(om, log_msg, strlen(log_msg));
if (rc != 0) {
os_mbuf_free_chain(om);
return;
}
rc = ble_gattc_notify_custom(debug_log_conn_handle,
0, /* attribute handle – 0 for the first characteristic? */
om);
if (rc != 0) {
/* Handle error */
}
}
Note: In NimBLE, ble_gattc_notify_custom sends a notification using the provided mbuf. The attribute handle should be the handle of the characteristic value attribute, which can be obtained during service registration. For simplicity, we assume the characteristic handle is known.
Performance and Protocol Considerations
BLE notifications are subject to the connection interval and MTU size. Key performance factors include:
- Connection Interval: The peripheral and central negotiate an interval (e.g., 7.5 ms to 4 s). Shorter intervals allow higher throughput but consume more power. For debug logging, use a connection interval of 15–30 ms to balance speed and power.
- MTU Size: The default MTU is 23 bytes (including 3 bytes of L2CAP header), leaving 20 bytes for payload. With Data Length Extension (DLE) in BLE 5.x, the MTU can be up to 251 bytes, allowing larger notifications and reducing overhead.
- Notification Rate: Each connection event can carry multiple packets. The maximum number of packets per event depends on the connection interval and the peripheral's scheduler. Typically, you can achieve 10–50 notifications per second with a 20-byte payload.
- Queue Management: Use a ring buffer to store log messages. If the central cannot keep up, older logs may be dropped. Set a reasonable buffer size (e.g., 256 entries) and log at a rate that the BLE link can sustain.
Comparison with Standard Bluetooth SIG Services
The Bluetooth SIG defines many GATT-based services, such as the Reconnection Configuration Service (RCS) and the Asset Tracking Profile (ATP). However, these are designed for specific use cases:
- RCS (v1.0.1): Controls communication parameters of a BLE peripheral, such as connection parameters and advertising settings. It is not intended for data streaming.
- ATP (v1.0): Defines a profile for direction finding (AoA/AoD) and asset tracking. It uses GATT characteristics for configuration and measurement data, but not for real-time debug logging.
Our custom service is simpler and more flexible, allowing arbitrary log data to be sent without adhering to a predefined data format. This approach is common in development and testing phases, where the goal is to capture runtime behavior without adding a physical debug interface.
Advanced: Error Handling and Flow Control
When notifications are sent faster than the BLE link can transmit, the host stack may return an error (e.g., BLE_HS_EBUSY or BLE_HS_EPREEMPTED). To handle this, we can implement a retry mechanism or a flow control scheme:
static void
try_send_notification(const char *log_msg)
{
int retries = 3;
while (retries--) {
int rc = send_log_notification_internal(log_msg);
if (rc == 0) {
return;
}
if (rc == BLE_HS_EBUSY) {
vTaskDelay(pdMS_TO_TICKS(10)); /* Wait before retry */
} else {
break; /* Fatal error */
}
}
/* Log dropped */
}
Alternatively, use a separate task that consumes from a queue and sends notifications at a controlled rate, ensuring the BLE stack is not overwhelmed.
Conclusion
Implementing a custom BLE GATT service for debug logging via notifications is a practical technique for embedded developers. It provides real-time visibility into device behavior without hardware modifications. By following the GATT service design principles and leveraging the notification mechanism, you can stream log data efficiently. The example code for ESP32 with NimBLE demonstrates a minimal implementation that can be extended with features like log levels, timestamps, and compression. This approach is particularly valuable during development, field testing, and remote diagnostics, where traditional debugging methods are limited.
For production systems, consider disabling the debug service to save memory and reduce attack surface. But during development, a custom logging service over BLE is an indispensable tool in the embedded engineer's arsenal.
常见问题解答
问: How do I enable notifications on the custom GATT debug logging characteristic from the central device?
答: Notifications are enabled by writing a value of 0x0001 to the Client Characteristic Configuration Descriptor (CCCD) associated with the characteristic. The CCCD is a mandatory descriptor for characteristics with the Notify property. On the central side, after discovering the service and characteristic, you must write to the CCCD handle to subscribe to notifications. For example, on an Android app using the BluetoothGatt API, you call setCharacteristicNotification(characteristic, true) and then write the descriptor value. On the peripheral (e.g., ESP32), the BLE stack automatically handles the CCCD write callback and starts sending notifications when the value is set to 0x0001.
问: What is the maximum payload size for each BLE notification, and how can I send longer log messages?
答: The maximum payload per notification depends on the negotiated MTU (Maximum Transmission Unit) size. By default in BLE 4.x, the MTU is 23 bytes, giving a payload of 20 bytes (3 bytes for header). With BLE 5.x and Data Length Extension (DLE), the MTU can be up to 251 bytes, providing a payload of up to 244 bytes. For longer log messages, you must fragment the data into multiple notifications. A common approach is to use a ring buffer to queue log lines, then send each line as one or more notifications. If the line exceeds the MTU, truncate it or implement a simple protocol with sequence numbers to reassemble on the central side.
问: How does implementing a custom GATT debug logging service affect the performance of my main BLE application?
答: The impact is minimal if designed carefully. The logging service runs in parallel with the main application, using a ring buffer to queue log messages. Notifications are sent asynchronously by the BLE stack, so the main application is not blocked. However, sending too many notifications in rapid succession can saturate the BLE link layer, causing packet loss or increased latency for other services. To mitigate this, implement rate limiting (e.g., a maximum number of notifications per second) and use a priority queue for critical logs. On ESP32 with NimBLE, the stack is lightweight and efficient, further reducing overhead.
问: Can I use this custom GATT service with any BLE central device, such as a smartphone app or a PC?
答: Yes, as long as the central device supports BLE and can handle custom 128-bit UUIDs. On smartphones, you can use platform-specific APIs like Android's BluetoothGatt or iOS's Core Bluetooth to discover the service and characteristic, enable notifications, and receive log data. On a PC, you can use libraries like PyGATT (Python) or Bluetoot (C#) on Windows, or BlueZ on Linux. The central must also support the notification mechanism, which is standard in BLE. Ensure the central's BLE stack is capable of handling frequent notifications without dropping packets.
问: What are the key considerations for ensuring reliable delivery of debug log notifications over BLE?
答: Reliability depends on several factors: 1) Use a ring buffer with sufficient size (e.g., 10-100 KB) to handle bursts of log messages. 2) Implement flow control by monitoring the BLE connection's available buffer space; most stacks provide a callback or API to check if the transmit queue is full. 3) Use BLE 5.x with Data Length Extension to increase payload size and reduce packet count. 4) Add sequence numbers or CRC checks in the log data to detect and handle lost or corrupted packets on the central side. 5) Consider using connection parameters with a short connection interval (e.g., 7.5 ms) for lower latency, but balance with power consumption. For critical logs, you can also use a higher priority by setting the characteristic's notification type to 'indication' (requires confirmation), but this adds overhead.
💬 欢迎到论坛参与讨论: 点击这里分享您的见解或提问