ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ESP32-C3 BLE多服务GATT服务器开发实战:NimBLE从单服务到多服务

ESP32-C3 BLE多服务GATT服务器开发实战:NimBLE从单服务到多服务 蓝牙低功耗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 的bleprphBLE 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只读 通知自定义数据服务0x00FF128位自定义数据通道0xFF01读写 通知这里设备信息服务和电池服务用的是蓝牙 SIG 定义的标准 UUID16 位自定义数据服务用的是 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 写入回调里维护一个订阅标志。CCCDClient 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 这个 AppAndroid 和 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 里关于蓝牙部分的变更确认没有破坏性改动再升。
返回列表