蓝牙低功耗(BLE)开发在嵌入式领域一直有个尴尬的现实:协议栈文档厚得像字典,但真正落到“我要让一个设备同时提供多个服务”这种具体需求时,能直接参考的完整示例并不多。ESP32-C3 这颗芯片把 BLE 5.0 和 Wi-Fi 塞进 RISC-V 架构里,价格压到个位数,很多人拿到手第一件事就是跑通蓝牙通信。但跑通“一个服务”和跑通“多个服务”之间,隔着一堆关于属性表、句柄、回调分发的细节。这篇内容就是把我自己在 ESP32-C3 上用 ESP-IDF 搭建多服务 GATT 服务器的完整过程拆开,从工程结构到代码实现,再到烧录时那些让人抓狂的坑,全部摊开讲。不管你是刚接触 BLE 的新手,还是已经跑过单服务想扩展到多服务的老手,应该都能从里面找到能直接用的东西。
1. 先搞清楚多服务 GATT 到底难在哪
1.1 单服务和多服务的本质区别
很多人第一次写 BLE 服务端代码,都是照着例程改一个服务、一个特征值,跑通了就觉得“不过如此”。然后需求一来——设备要同时暴露电池电量、设备信息和自定义数据通道——就懵了。单服务和多服务的区别,表面上看是多了几个服务定义,实际上核心难点在三个地方。
第一是属性表的组织方式。BLE 的 GATT 服务器本质上是一张属性表(Attribute Table),每个属性有一个 16 位的句柄(Handle)。当你只有一个服务时,句柄从 1 开始往后排,服务声明占一个,特征声明占一个,特征值占一个,描述符再占一个,顺序很清晰。但当你挂载多个服务时,句柄的分配就变成了一个需要规划的事情。ESP-IDF 的 BLE 协议栈会自动分配句柄,但你在注册服务时的顺序会直接影响句柄的排列,而句柄一旦确定,客户端那边拿到的就是固定值,后续所有读写操作都依赖这个句柄来定位。
第二是回调函数的分发逻辑。单服务的时候,所有读写事件都来自同一个服务,你在回调里直接处理就行。多服务的时候,回调是全局的,你需要根据事件里携带的句柄或者服务 ID 来判断“这次操作是冲着哪个服务来的”。如果判断逻辑写错了,就会出现“读电池电量结果返回了自定义数据”这种让人哭笑不得的 bug。
第三是内存和资源的分配。每个服务都需要自己的属性表空间,ESP32-C3 的 BLE 协议栈在初始化时会从堆里分配一块内存给属性表。服务越多,占用的内存越大。ESP32-C3 的 RAM 总共就 400KB 左右,BLE 协议栈本身要吃掉一部分,如果你的服务定义太多、特征值太长,很容易在初始化阶段就失败。
1.2 ESP32-C3 上 BLE 协议栈的选型考量
ESP-IDF 里跑 BLE 有两种选择:Bluedroid 和 NimBLE。这两个协议栈在 ESP32-C3 上都支持,但选哪个直接影响到你的开发体验和最终固件的资源占用。
Bluedroid 是 ESP-IDF 的默认蓝牙协议栈,功能完整,支持经典蓝牙和 BLE 双模。但它的代码体积大,RAM 占用也高。在 ESP32-C3 这种单核 RISC-V 芯片上跑 Bluedroid,编译出来的固件轻松超过 1MB,启动时间也偏长。NimBLE 则是 Apache 基金会维护的一个轻量级 BLE 协议栈,ESP-IDF 对它做了移植。NimBLE 的代码体积大概只有 Bluedroid 的一半,RAM 占用也明显更低,而且 API 设计更简洁。
对于 ESP32-C3 做多服务 BLE 通信这个场景,我的建议是优先用 NimBLE。原因很直接:ESP32-C3 的定位就是低成本、低功耗的物联网终端,NimBLE 的资源占用更符合这个定位。而且 NimBLE 的服务注册 API 比 Bluedroid 更直观,多服务场景下代码组织起来更清晰。
当然,如果你需要同时支持经典蓝牙(比如做音频设备),那就只能选 Bluedroid。但对于纯 BLE 的多服务通信,NimBLE 是更合理的选择。
1.3 多服务场景的典型应用
多服务 GATT 服务器在实际产品中非常常见。举几个例子:一个智能手环需要同时提供心率服务、电池服务、设备信息服务;一个工业传感器需要提供环境数据服务、设备配置服务、固件升级服务;一个智能家居节点需要提供控制服务、状态上报服务、OTA 服务。
这些场景的共同点是:不同的服务面向不同的功能模块,客户端(手机 App 或者网关)会根据需求去访问对应的服务。如果所有功能都塞进一个服务里,用不同的特征值来区分,虽然技术上可行,但代码会变得非常混乱,而且不利于模块化开发和后期维护。把功能拆成独立的服务,每个服务有自己的 UUID、自己的特征值、自己的读写权限,这才是符合 BLE 设计规范的做法。
2. 工程搭建与协议栈配置的实操细节
2.1 创建工程与 menuconfig 关键项
用 ESP-IDF 创建工程的标准流程是idf.py create-project,但更推荐直接从例程复制一份出来改。ESP-IDF 的 examples 目录下有bluetooth/bluedroid/ble和bluetooth/nimble两个目录,里面有针对不同场景的例程。我们以 NimBLE 的bleprph(BLE Peripheral)例程为基础来改,这个例程已经实现了基本的 GATT 服务器框架,我们只需要在上面增加服务。
创建好工程后,第一件事是跑idf.py menuconfig,有几个关键项必须确认:
- Component config → Bluetooth → Bluetooth:确保蓝牙功能已启用。
- Component config → Bluetooth → Bluetooth → Bluetooth Host:选择 NimBLE。
- Component config → Bluetooth → NimBLE Options → Maximum number of connections:根据需求设置,多服务场景下通常 1-3 个连接就够了,设太多浪费内存。
- Component config → Bluetooth → NimBLE Options → Maximum number of GATT services:这个值要大于等于你实际要注册的服务数量,默认值可能不够。
- Component config → Bluetooth → NimBLE Options → Maximum number of GATT characteristics:同样要留足余量。
这里有个容易忽略的点:Maximum number of GATT services和Maximum number of GATT characteristics这两个值如果设小了,服务注册会在运行时失败,而且错误信息不一定直观。我的经验是,实际需要 3 个服务,就设成 5;实际需要 8 个特征值,就设成 12。留点余量不费多少内存,但能避免很多调试时间。
2.2 分区表和烧录配置的注意事项
ESP32-C3 的默认分区表对于 BLE 应用来说通常够用,但如果你要加 OTA 功能,就需要自定义分区表。在menuconfig的Partition Table里可以选择Custom partition table CSV,然后编辑partitions.csv文件。
烧录配置方面,ESP32-C3 支持 USB Serial/JTAG 和 UART 两种下载方式。如果你用的是带 USB 接口的开发板(比如 ESP32-C3-DevKitM-1),可以直接用 USB 线连接,menuconfig里Serial flasher config → Default serial port会自动识别。但要注意,有些开发板的 USB 接口只用于供电,不连接下载引脚,这种情况需要用 UART 接口来烧录。
烧录时的波特率建议设成 460800 或 921600,默认的 115200 太慢,烧一个 1MB 的固件要等很久。但如果你的 USB 线质量一般或者开发板上的 USB 转串口芯片比较老,高波特率可能会导致烧录失败,这时候降回 115200 反而更稳。
2.3 工程目录结构的组织方式
多服务工程的目录结构建议按功能模块来组织,而不是把所有代码堆在main.c里。我的做法是:
project/ ├── main/ │ ├── main.c # 入口,初始化协议栈和启动广播 │ ├── ble_services.c # 所有服务的注册逻辑 │ ├── ble_services.h # 服务 UUID 和句柄的声明 │ ├── ble_gap.c # GAP 相关:广播、连接回调 │ ├── ble_gap.h │ └── CMakeLists.txt ├── CMakeLists.txt └── sdkconfig.defaults把服务注册逻辑单独放在ble_services.c里,GAP 逻辑放在ble_gap.c里,main.c只负责初始化和串联。这样当你要增加或删除服务时,只需要改ble_services.c和对应的头文件,不会影响到广播和连接逻辑。
sdkconfig.defaults文件用来固化menuconfig里的关键配置,这样团队协作时每个人拿到的配置都是一致的,不会出现“我这里能跑你那里跑不了”的情况。
3. 多服务 GATT 服务器的代码实现
3.1 服务 UUID 的规划与定义
在写代码之前,先把要注册的服务和特征值规划清楚。假设我们要实现三个服务:
| 服务名称 | 服务 UUID | 特征值 | 特征值 UUID | 属性 |
|---|---|---|---|---|
| 设备信息服务 | 0x180A | 制造商名称 | 0x2A29 | 只读 |
| 固件版本 | 0x2A26 | 只读 | ||
| 电池服务 | 0x180F | 电池电量 | 0x2A19 | 只读 + 通知 |
| 自定义数据服务 | 0x00FF(128位自定义) | 数据通道 | 0xFF01 | 读写 + 通知 |
这里设备信息服务和电池服务用的是蓝牙 SIG 定义的标准 UUID(16 位),自定义数据服务用的是 128 位 UUID。标准 UUID 的好处是手机上的通用 BLE 调试 App 能直接识别并显示含义,自定义 UUID 则用于私有协议。
在代码里,UUID 的定义方式有两种:16 位 UUID 可以直接用宏定义,128 位 UUID 需要用数组表示。NimBLE 里用BLE_UUID16_DECLARE和BLE_UUID128_DECLARE两个宏来声明。
// ble_services.h #ifndef BLE_SERVICES_H #define BLE_SERVICES_H #include "host/ble_uuid.h" // 标准服务 UUID extern const ble_uuid16_t gatt_svr_svc_dev_info_uuid; extern const ble_uuid16_t gatt_svr_chr_mfr_name_uuid; extern const ble_uuid16_t gatt_svr_chr_fw_rev_uuid; extern const ble_uuid16_t gatt_svr_svc_battery_uuid; extern const ble_uuid16_t gatt_svr_chr_battery_level_uuid; // 自定义 128 位 UUID extern const ble_uuid128_t gatt_svr_svc_custom_uuid; extern const ble_uuid128_t gatt_svr_chr_custom_data_uuid; // 句柄存储 extern uint16_t gatt_svr_chr_battery_level_handle; extern uint16_t gatt_svr_chr_custom_data_handle; void gatt_svr_register_all(void); void gatt_svr_on_connect(uint16_t conn_handle); void gatt_svr_on_disconnect(uint16_t conn_handle); #endif句柄变量用全局变量存起来,因为后续在通知(Notify)的时候需要用到。比如电池电量变化时,要通过gatt_svr_chr_battery_level_handle来发送通知。
3.2 属性表注册的核心逻辑
NimBLE 注册 GATT 服务用的是ble_gatts_count_cfg和ble_gatts_add_svcs两个函数。前者用来统计所有服务的属性数量,后者用来实际注册。这两个函数都需要一个ble_gatt_svc_def数组。
// ble_services.c #include "ble_services.h" #include "host/ble_gatt.h" #include "host/util/util.h" #include "services/gatt/ble_svc_gatt.h" #include "esp_log.h" static const char *TAG = "BLE_SVC"; // 设备信息服务 static const struct ble_gatt_svc_def gatt_svr_svcs[] = { { // 设备信息服务 .type = BLE_GATT_SVC_TYPE_PRIMARY, .uuid = &gatt_svr_svc_dev_info_uuid.u, .characteristics = (struct ble_gatt_chr_def[]) { { .uuid = &gatt_svr_chr_mfr_name_uuid.u, .access_cb = gatt_svr_access_cb, .flags = BLE_GATT_CHR_F_READ, }, { .uuid = &gatt_svr_chr_fw_rev_uuid.u, .access_cb = gatt_svr_access_cb, .flags = BLE_GATT_CHR_F_READ, }, { 0 } }, }, { // 电池服务 .type = BLE_GATT_SVC_TYPE_PRIMARY, .uuid = &gatt_svr_svc_battery_uuid.u, .characteristics = (struct ble_gatt_chr_def[]) { { .uuid = &gatt_svr_chr_battery_level_uuid.u, .access_cb = gatt_svr_access_cb, .flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY, .val_handle = &gatt_svr_chr_battery_level_handle, }, { 0 } }, }, { // 自定义数据服务 .type = BLE_GATT_SVC_TYPE_PRIMARY, .uuid = &gatt_svr_svc_custom_uuid.u, .characteristics = (struct ble_gatt_chr_def[]) { { .uuid = &gatt_svr_chr_custom_data_uuid.u, .access_cb = gatt_svr_access_cb, .flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY, .val_handle = &gatt_svr_chr_custom_data_handle, }, { 0 } }, }, { 0 } };这里有几个关键点需要展开说。
val_handle的作用:这个字段用来接收协议栈分配的句柄值。只有需要发送通知或指示的特征值才需要设置这个字段。如果你不需要主动向客户端推送数据,可以不设。
access_cb的统一处理:所有特征值共用同一个回调函数gatt_svr_access_cb,在回调里根据attr_handle来区分是哪个特征值。这样做的好处是代码集中,便于管理;坏处是回调函数会变得比较长。如果服务数量很多,也可以给每个服务单独写回调,但那样代码会更分散。
数组末尾的{ 0 }:这是 NimBLE 要求的终止标记,不能省略。漏掉的话会导致协议栈读取越界,表现为随机崩溃或者注册失败。
注册函数本身很简单:
void gatt_svr_register_all(void) { int rc; // 先统计属性数量 rc = ble_gatts_count_cfg(gatt_svr_svcs); if (rc != 0) { ESP_LOGE(TAG, "ble_gatts_count_cfg failed: %d", rc); return; } // 再注册服务 rc = ble_gatts_add_svcs(gatt_svr_svcs); if (rc != 0) { ESP_LOGE(TAG, "ble_gatts_add_svcs failed: %d", rc); return; } ESP_LOGI(TAG, "All GATT services registered"); }注意ble_gatts_count_cfg必须在ble_gatts_add_svcs之前调用,而且要在 NimBLE 协议栈初始化完成之后、开始广播之前调用。顺序错了会返回错误码。
3.3 读写回调的分发与处理
回调函数是多服务 GATT 服务器的核心。所有来自客户端的读写请求都会进到这个函数里,你需要根据attr_handle来判断请求的是哪个特征值,然后返回对应的数据。
static int gatt_svr_access_cb(uint16_t conn_handle, uint16_t attr_handle, struct ble_gatt_access_ctxt *ctxt, void *arg) { int rc; switch (ctxt->op) { case BLE_GATT_ACCESS_OP_READ_CHR: // 判断是哪个特征值的读请求 if (attr_handle == gatt_svr_chr_battery_level_handle) { uint8_t battery_level = 85; // 实际项目中从 ADC 或电量计读取 rc = os_mbuf_append(ctxt->om, &battery_level, sizeof(battery_level)); return rc == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; } else if (attr_handle == gatt_svr_chr_custom_data_handle) { // 返回自定义数据 const char *data = "Hello from ESP32-C3"; rc = os_mbuf_append(ctxt->om, data, strlen(data)); return rc == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; } // 设备信息服务的特征值 else if (attr_handle == gatt_svr_chr_mfr_name_handle) { const char *name = "Espressif"; rc = os_mbuf_append(ctxt->om, name, strlen(name)); return rc == 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES; } break; case BLE_GATT_ACCESS_OP_WRITE_CHR: if (attr_handle == gatt_svr_chr_custom_data_handle) { // 处理写入的数据 uint8_t buf[128]; uint16_t len = OS_MBUF_PKTLEN(ctxt->om); if (len > sizeof(buf)) len = sizeof(buf); rc = ble_hs_mbuf_to_flat(ctxt->om, buf, len, NULL); if (rc == 0) { ESP_LOGI(TAG, "Received data: %.*s", len, buf); // 这里可以处理接收到的数据 } return 0; } break; default: break; } return BLE_ATT_ERR_UNLIKELY; }这段代码里有几个实操中容易出问题的地方。
os_mbuf_append的返回值处理:这个函数在内存不足时会返回非零值,如果不检查返回值直接返回 0,客户端会收到一个空响应或者超时。正确的做法是检查返回值,失败时返回BLE_ATT_ERR_INSUFFICIENT_RES。
写入数据的长度限制:BLE 单次写入的最大长度受 MTU 限制,默认 MTU 是 23 字节,实际可写数据大概 20 字节。如果要传更长的数据,需要先协商更大的 MTU,或者分包传输。ESP32-C3 的 NimBLE 支持 MTU 最大到 512 字节,但需要客户端主动发起 MTU 协商。
句柄比较的顺序:如果服务数量多,if-else链会很长。可以改用switch语句,或者用一个查找表来映射句柄和数据类型。但在服务数量少于 10 个的情况下,if-else的可读性反而更好。
3.4 通知与指示的发送时机
通知(Notify)是 BLE 服务端主动向客户端推送数据的方式。在多服务场景下,你需要确保通知发送到正确的特征值句柄上。
void gatt_svr_send_battery_notify(uint16_t conn_handle, uint8_t level) { struct os_mbuf *om; int rc; // 检查客户端是否订阅了通知 // 实际项目中应该维护一个订阅状态表 om = ble_hs_mbuf_from_flat(&level, sizeof(level)); if (om == NULL) { ESP_LOGE(TAG, "Failed to allocate mbuf"); return; } rc = ble_gatts_notify_custom(conn_handle, gatt_svr_chr_battery_level_handle, om); if (rc != 0) { ESP_LOGE(TAG, "Notify failed: %d", rc); } }这里有个关键点:通知只能发给已经订阅了该特征值的客户端。如果客户端没有订阅(没有写 CCCD 描述符),ble_gatts_notify_custom会返回错误。所以在发送通知之前,最好先检查订阅状态。NimBLE 提供了ble_gatts_peer_cl_sup_feat_get之类的接口来查询,但更简单的做法是在 CCCD 写入回调里维护一个订阅标志。
CCCD(Client Characteristic Configuration Descriptor)是每个支持通知/指示的特征值自动带的一个描述符。客户端通过写这个描述符来告诉服务端“我要订阅通知”。在 NimBLE 里,CCCD 的写入也会进到access_cb里,ctxt->op是BLE_GATT_ACCESS_OP_WRITE_DSC。你可以在这里记录订阅状态。
4. 烧录、调试与常见问题排查
4.1 烧录失败的五种典型情况和排查路径
ESP32-C3 烧录失败是新手遇到最多的问题,没有之一。我把常见的失败情况归成五类,每一类的排查路径不一样。
第一类:找不到串口。现象是idf.py flash报错说找不到端口。排查步骤:先确认 USB 线是数据线不是充电线(这个坑我踩过,换了三根线才发现是线的问题);然后在设备管理器里看有没有识别到串口芯片;如果识别到了但 ESP-IDF 找不到,检查menuconfig里的串口配置是不是设成了Auto。
第二类:连接超时。现象是卡在Connecting...然后超时。这种情况通常是开发板没有进入下载模式。ESP32-C3 需要在上电时拉低 GPIO9 才能进入下载模式。大部分开发板有自动下载电路,但有些板子需要手动按住 BOOT 键再按 RESET 键。如果自动下载电路有问题,可以手动操作:按住 BOOT,按一下 RESET,松开 RESET,再松开 BOOT。
第三类:烧录到一半失败。现象是进度条走到某个百分比就报错。这通常是波特率太高或者 USB 线质量不好。把波特率降到 115200 再试,如果降速后能成功,说明是信号完整性问题。
第四类:烧录成功但设备不运行。现象是烧录显示成功,但串口没有输出。检查menuconfig里的Channel for console output是不是设成了正确的串口。另外,有些开发板的 USB 接口和 UART 接口是分开的,如果你用 USB 烧录但串口监视器连的是 UART 接口,自然看不到输出。
第五类:固件太大放不下。现象是编译时报错说固件超过分区大小。ESP32-C3 的默认分区表给应用分区大概 1MB 左右,如果开了 Bluedroid 再加一堆功能,很容易超。解决办法是换用自定义分区表,把应用分区调大,或者改用 NimBLE 减小体积。
4.2 蓝牙连接不稳定的排查思路
设备能广播但连不上,或者连上了很快断开,这类问题的排查需要一点耐心。
先看广播数据。用手机上的 BLE 调试 App(比如 nRF Connect)扫描,确认设备名称和广播内容是否正确。如果广播都看不到,说明 GAP 层的配置有问题,检查ble_gap_adv_start的返回值。
如果能扫到但连不上,检查广播类型。ESP32-C3 默认用的是可连接的非定向广播(BLE_HCI_ADV_TYPE_CONN_UNDIRECT),这个类型是支持连接的。如果你改成了非连接广播,那自然连不上。
如果连上了很快断开,最常见的原因是连接参数不合适。BLE 的连接间隔(Connection Interval)、从机延迟(Slave Latency)、监督超时(Supervision Timeout)这三个参数需要匹配。手机端通常会发起连接参数更新请求,如果你的固件没有正确处理这个请求,连接可能会因为参数不匹配而断开。NimBLE 里需要在 GAP 回调里处理BLE_GAP_EVENT_CONN_UPDATE事件。
另一个可能的原因是内存不足。多服务场景下,如果属性表太大,连接建立时协议栈需要分配内存来维护连接状态,内存不够就会导致连接失败。可以通过esp_get_free_heap_size()来监控内存使用情况。
4.3 功耗优化的实际手段
ESP32-C3 的 BLE 功耗表现和很多因素有关,但最直接的影响因素是广播间隔和连接间隔。
广播间隔默认是 100ms 左右,如果对功耗敏感,可以调到 500ms 甚至 1s。广播间隔越长,平均功耗越低,但设备被发现的延迟也越大。这个需要根据实际场景权衡。
连接间隔的影响更大。BLE 连接建立后,主从设备会按照连接间隔周期性地通信。连接间隔越短,响应越快,但功耗越高。对于电池供电的设备,连接间隔设成 100ms 到 500ms 是比较常见的做法。如果设备大部分时间没有数据要传,可以启用从机延迟(Slave Latency),让从机跳过若干个连接事件不响应,进一步降低功耗。
还有一个容易被忽略的点:关闭不用的外设。ESP32-C3 的 Wi-Fi 和 BLE 共用射频,如果只用了 BLE,确保 Wi-Fi 没有在后台运行。另外,调试用的串口输出也会消耗功耗,量产固件里应该把日志级别调低或者关闭。
实测下来,ESP32-C3 在 BLE 广播状态下(广播间隔 500ms)的平均电流大概在 1mA 左右,连接状态下(连接间隔 100ms,无数据传输)大概在 3-5mA。这个数据供参考,实际值会因开发板和外围电路不同而有差异。
4.4 用手机 App 验证多服务的实操步骤
代码烧进去之后,怎么验证三个服务都正常工作?我的做法是用 nRF Connect 这个 App(Android 和 iOS 都有),它能把设备的所有服务、特征值、描述符都列出来。
打开 App,扫描到设备后点击连接。连接成功后,你会看到服务列表。设备信息服务(0x180A)应该显示制造商名称和固件版本;电池服务(0x180F)应该显示电池电量;自定义服务(128 位 UUID)应该显示数据通道。
点击每个特征值旁边的读取按钮,看返回的数据是否正确。对于支持通知的特征值,点击那个三个箭头组成的图标来订阅通知,然后触发设备端发送通知,看 App 能不能收到。
如果某个服务没有显示出来,回到代码里检查ble_gatts_count_cfg和ble_gatts_add_svcs的返回值。如果服务显示了但特征值读取出错,检查access_cb里的句柄判断逻辑。
5. 从单服务到多服务的代码重构经验
5.1 服务注册的模块化拆分
当服务数量增加到五六个的时候,把所有服务定义塞在一个数组里会变得很难维护。我的做法是把每个服务的定义拆成独立的函数,返回一个ble_gatt_svc_def结构体,然后在主数组里引用。
static struct ble_gatt_svc_def gatt_svr_svcs[] = { get_dev_info_service_def(), get_battery_service_def(), get_custom_service_def(), { 0 } };但这里有个坑:ble_gatt_svc_def里的characteristics字段是一个指针,指向一个数组。如果这个数组是在函数里定义的局部变量,函数返回后数组就失效了,协议栈读到的就是垃圾数据。所以特征值数组必须定义成static或者全局变量。
static struct ble_gatt_chr_def dev_info_chrs[] = { { .uuid = &gatt_svr_chr_mfr_name_uuid.u, .access_cb = gatt_svr_access_cb, .flags = BLE_GATT_CHR_F_READ, }, { .uuid = &gatt_svr_chr_fw_rev_uuid.u, .access_cb = gatt_svr_access_cb, .flags = BLE_GATT_CHR_F_READ, }, { 0 } }; static struct ble_gatt_svc_def get_dev_info_service_def(void) { struct ble_gatt_svc_def svc = { .type = BLE_GATT_SVC_TYPE_PRIMARY, .uuid = &gatt_svr_svc_dev_info_uuid.u, .characteristics = dev_info_chrs, }; return svc; }这样每个服务的定义就独立了,增加或删除服务只需要改对应的函数和数组,不会影响到其他服务。
5.2 句柄管理的集中化
句柄分散在各个文件里定义和引用,时间长了很容易搞混。我的做法是建一个ble_handles.h,把所有需要对外暴露的句柄集中声明,在ble_services.c里定义和赋值。
// ble_handles.h extern uint16_t gatt_svr_chr_battery_level_handle; extern uint16_t gatt_svr_chr_custom_data_handle; extern uint16_t gatt_svr_chr_mfr_name_handle; extern uint16_t gatt_svr_chr_fw_rev_handle;然后在ble_services.c里定义这些变量,并在注册服务时通过val_handle字段让协议栈填充。对于只读的特征值,其实不需要val_handle,因为不会主动发送通知。但为了统一管理,我习惯把所有句柄都存下来,方便调试时打印。
5.3 回调函数的可扩展设计
当服务数量多、特征值多的时候,access_cb里的if-else链会变得很长。有两种优化思路。
第一种是用switch语句替代if-else,可读性稍好一些,但本质上还是线性查找。
第二种是建一个查找表,把句柄和对应的处理函数关联起来:
typedef int (*chr_access_handler_t)(uint16_t conn_handle, struct ble_gatt_access_ctxt *ctxt); struct chr_handler_entry { uint16_t handle; chr_access_handler_t read_handler; chr_access_handler_t write_handler; }; static const struct chr_handler_entry chr_handlers[] = { { 0, handle_battery_read, NULL }, // handle 在运行时填充 { 0, handle_custom_read, handle_custom_write }, // ... };但句柄是运行时分配的,所以查找表需要在服务注册完成后动态填充。这个方案在服务数量超过 10 个时比较有优势,但代码复杂度也上去了。对于大多数项目,if-else或switch就够了。
6. 多服务通信的进阶话题
6.1 MTU 协商对数据传输的影响
默认的 BLE MTU 是 23 字节,去掉 ATT 协议头,实际可用的数据长度大概 20 字节。如果你要传输的数据超过这个长度,就需要协商更大的 MTU。
ESP32-C3 的 NimBLE 支持最大 512 字节的 MTU。协商过程是客户端发起的:客户端发送一个 ATT_EXCHANGE_MTU_REQ,服务端回复 ATT_EXCHANGE_MTU_RSP,双方取较小值作为最终 MTU。
在 NimBLE 里,MTU 协商是自动处理的,你不需要写额外的代码。但你需要知道最终协商出来的 MTU 是多少,以便在发送数据时做分包处理。可以通过ble_att_mtu函数查询当前连接的 MTU。
实测中,Android 手机通常会协商到 517 字节,iOS 通常会协商到 185 字节左右。这个差异在做跨平台开发时需要注意。
6.2 多连接场景的资源分配
ESP32-C3 支持多个 BLE 连接(具体数量取决于配置),在多连接场景下,每个连接都需要独立的资源来维护状态。如果你要同时连接多个客户端,需要注意几点。
第一是内存。每个连接大约需要几 KB 的 RAM 来维护状态,连接数越多,内存压力越大。在menuconfig里设置最大连接数时,要结合实际可用内存来定。
第二是通知的定向发送。当你有多个连接时,发送通知需要指定conn_handle。如果你想让所有已连接的客户端都收到通知,需要遍历所有连接分别发送。
第三是连接参数的管理。不同客户端可能请求不同的连接参数,你需要为每个连接单独维护参数状态。
6.3 服务变更时的客户端通知
当你的设备动态增加或删除服务时(比如固件升级后增加了新功能),需要通知客户端“服务列表变了”。BLE 协议里有一个 Service Changed 特征值(UUID 0x2A05),属于 Generic Attribute 服务(0x1801)。当服务变更时,向这个特征值发送指示(Indicate),客户端收到后会重新发现服务。
在 NimBLE 里,Generic Attribute 服务是内置的,你只需要在服务变更后调用ble_svc_gatt_changed函数来触发指示。
这个功能在开发阶段可能用不到,但在产品 OTA 升级后非常有用。如果不发这个指示,客户端可能还在用旧的服务列表,导致访问新服务时找不到句柄。
6.4 从调试到量产的配置差异
开发阶段和量产阶段的配置有几个关键差异。
日志级别:开发时用ESP_LOG_DEBUG甚至ESP_LOG_VERBOSE,量产时改成ESP_LOG_WARN或ESP_LOG_ERROR。日志输出会占用 CPU 和串口带宽,对功耗和性能都有影响。
断言:开发时开启CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_ENABLE,量产时关闭。断言在出错时会重启设备,量产环境下这可能导致设备反复重启。
看门狗:确保任务看门狗(Task Watchdog)已启用,并且 BLE 相关任务正确喂狗。BLE 协议栈的任务如果被阻塞太久,看门狗会触发重启。
广播名称:开发时用容易识别的名称,量产时改成产品名称。广播名称的长度会影响广播包的大小,进而影响广播间隔和功耗。
配对与安全:如果产品需要配对,开发阶段可以先不启用安全机制,量产时必须启用。NimBLE 支持多种配对方式,包括 Just Works、Passkey Entry、Numeric Comparison 等。选择哪种方式取决于产品的安全需求和交互能力。
7. 写在最后的实操体会
多服务 BLE 通信这个事,代码量其实不大,但细节特别多。我踩过的坑里,印象最深的是句柄判断写错导致数据串了——电池服务读出来的是自定义数据,查了半天才发现是if-else里两个句柄写反了。还有一次是ble_gatts_count_cfg的返回值没检查,服务注册失败但程序继续跑,广播正常但连上后看不到任何服务,排查了很久才发现是配置里Maximum number of GATT services设小了。
如果你刚开始做 ESP32-C3 的 BLE 开发,我的建议是先用例程跑通单服务,确认烧录、广播、连接、读写都正常,再往上加服务。每加一个服务就验证一次,不要一次性把五六个服务全加上去再调试,那样出了问题很难定位是哪个服务的配置有误。
另外,nRF Connect 这个 App 真的很好用,它能显示所有的服务、特征值、描述符,还能直接读写和订阅通知。调试 BLE 的时候,有一个能直观看到协议栈数据的工具,效率会高很多。
最后说一个关于 ESP-IDF 版本选择的经验。ESP-IDF 的版本迭代比较快,不同版本之间 NimBLE 的 API 可能有细微差异。建议锁定一个稳定版本(比如 v5.1 或 v5.2),不要频繁升级。升级前先看 release notes 里关于蓝牙部分的变更,确认没有破坏性改动再升。