蓝牙低功耗开发里ESP32-C3 是一颗很特别的芯片。它便宜、自带 RISC-V 内核、支持 BLE 5.0还内置了完整的协议栈但真正让它在小项目里站稳脚跟的是它能在同一台设备上同时跑多个 GATT 服务让一个蓝牙外设同时扮演好几个角色。我最近刚用 ESP32-C3 做了一个多服务蓝牙通信的模块从环境搭建到烧录调试踩了不少坑也积累了一些官方文档里不会写的经验。这篇内容就把整个实现过程拆开讲清楚包括 ESP-IDF 环境怎么配、GATT 多服务怎么组织、属性表怎么设计、广播数据怎么填、连接参数怎么调以及烧录失败、功耗异常这些高频问题怎么排查。适合已经上手过 ESP32、想深入 BLE 协议栈的开发者也适合刚接触 ESP-IDF 想找一个完整多服务案例的朋友。1. 多服务蓝牙通信的整体设计思路1.1 为什么要在单设备上跑多个 GATT 服务先解释一下什么叫“多服务蓝牙通信”。在 BLE 的 GATT 架构里服务Service是数据组织的顶层容器每个服务下面挂若干特征值Characteristic特征值再挂描述符Descriptor。一个蓝牙外设可以同时对外暴露多个服务主机连上来之后可以分别访问不同服务下的特征值。举个实际场景我做的这个模块既要上报传感器数据又要接收控制指令还要支持设备信息读取。如果全塞进一个服务里特征值会变得很乱主机端解析也麻烦。拆成三个服务就清爽多了——环境数据服务负责上报温湿度控制服务负责接收开关指令设备信息服务负责暴露固件版本和序列号。主机端按 UUID 分别访问逻辑清晰后期扩展也方便。从协议角度看多个服务共享同一个 ATT 属性表属性句柄Handle是全局递增的。这一点很关键后面讲属性表设计时会重点说。多服务的优势在于职责分离、复用性强、主机端适配简单代价是属性表变长、RAM 占用增加需要合理规划。1.2 ESP32-C3 在 BLE 场景下的定位与选型考量ESP32-C3 用的是 RISC-V 单核架构主频 160MHz自带 400KB SRAM支持 BLE 5.0 和 Mesh。和经典 ESP32 相比它没有双核和经典蓝牙BR/EDR只有 BLE但功耗更低、成本更低适合纯 BLE 外设场景。选它做多服务蓝牙通信主要看中三点一是 BLE 5.0 支持 2M PHY 和 Coded PHY传输速率和距离都有提升空间二是 ESP-IDF 对 BLE 的封装比较完整NimBLE 和 Bluedroid 两套协议栈可选三是芯片本身便宜做量产模块成本可控。这里要提一个选型细节ESP-IDF 默认用的是 Bluedroid 协议栈代码量大、RAM 占用高但 API 成熟、资料多。NimBLE 协议栈更轻量RAM 占用能省一半左右适合资源紧张的场景。我这次用的是 Bluedroid因为多服务的示例代码更全调试起来心里有底。如果你对功耗和 RAM 敏感可以换 NimBLEAPI 逻辑类似但细节有差异。1.3 协议栈分层与数据流向的通俗理解BLE 协议栈从下到上大致分三层控制器层Controller管射频和链路层主机层Host管 GATT、GAP、ATT、SM 这些应用层就是我们写的业务代码。ESP32-C3 把控制器和主机都集成在芯片里我们只需要调 ESP-IDF 提供的 API。数据流向可以这样理解主机比如手机发来一个读请求先到 ATT 层ATT 根据句柄找到对应的属性再交给 GATT 层处理GATT 回调应用层的读写回调函数应用层填好数据返回再原路传回去。写请求同理只是方向反过来。多服务的情况下每个服务注册时都会分配一段句柄区间ATT 靠句柄区分是哪个服务的哪个特征值。理解这个分层很重要因为调试时很多问题出在层与层之间的衔接上。比如广播发不出去可能是 GAP 层配置问题连上后读不到数据可能是 GATT 属性表没注册对写数据没反应可能是写回调没处理或者权限没开。2. ESP-IDF 环境搭建与工程配置实操2.1 ESP-IDF 安装的几种方式与选择建议ESP-IDF 的安装方式主要有三种官方安装器、Git 手动克隆、VS Code 插件。官方安装器最省事适合新手Git 手动克隆灵活适合需要切换版本的老手VS Code 插件集成度高但偶尔会有路径问题。我这次用的是官方安装器版本选的是 v5.1因为 v5.1 对 ESP32-C3 的支持比较稳定BLE 相关的 API 也没有大改动。安装过程中最容易卡住的地方是下载依赖包尤其是 Python 包和工具链网络不好的话会一直卡在某个百分比。我的经验是提前配好国内镜像源Python 用清华源Git 用国内镜像能省不少时间。安装完成后验证是否成功的方法是在终端执行idf.py --version能输出版本号就说明环境变量配好了。如果提示找不到命令说明 export 脚本没执行需要手动跑一下export.shLinux/macOS或export.batWindows。2.2 创建工程与目录结构规划环境配好后用idf.py create-project创建工程或者直接复制官方示例改。我习惯从bluetooth/bluedroid/ble/gatt_server示例起步因为它的结构最接近我要做的多服务场景。工程目录结构大致如下my_ble_multi_service/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── main.c │ ├── ble_service.c │ ├── ble_service.h │ └── Kconfig.projbuild └── sdkconfig把 BLE 相关逻辑单独放到ble_service.c里main.c只负责初始化和启动这样代码层次清晰后期加服务也方便。Kconfig.projbuild用来定义可配置项比如设备名、服务数量方便不同项目复用。2.3 menuconfig 关键配置项逐条说明idf.py menuconfig是配置工程的核心入口BLE 相关的配置集中在Component config - Bluetooth下面。几个关键项必须确认Bluetooth 使能Bluetooth - Bluetooth勾上否则协议栈不会编译进去。协议栈选择Bluetooth - Bluedroid或NimBLE二选一我选 Bluedroid。BLE only 模式Bluetooth - Bluedroid - Bluetooth controller - BLE only勾上省掉经典蓝牙的代码。最大连接数Bluetooth - Bluedroid - Max number of simultaneous connections默认 3按需调整。GATT 服务数量Bluetooth - Bluedroid - GATT - Max number of GATT services多服务场景要调大我设成 10。属性表大小Bluetooth - Bluedroid - GATT - GATT attribute table size默认 40多服务要加大我设成 100。这些配置项如果没配对编译能过但运行时会出问题。比如属性表太小注册第三个服务时就会失败报ESP_GATT_NO_RESOURCES。这个坑我踩过排查了半天才发现是配置问题。3. GATT 多服务的核心实现细节3.1 服务、特征值、描述符的层级关系GATT 的数据组织是树状的Profile 下面挂 ServiceService 下面挂 CharacteristicCharacteristic 下面挂 Descriptor。实际开发中 Profile 概念被弱化我们直接注册 Service。每个 Service 有一个 UUID每个 Characteristic 也有 UUIDDescriptor 同理。UUID 分 16 位和 128 位两种16 位是标准 UUID128 位是自定义 UUID。我这次环境数据服务用标准 UUID比如 0x181A 环境传感控制和设备信息用自定义 128 位 UUID避免和标准服务冲突。层级关系决定了属性表的排列顺序。注册服务时ESP-IDF 会按顺序把 Service Declaration、Characteristic Declaration、Characteristic Value、Descriptor 依次写进属性表每个条目占一个句柄。多服务就是重复这个过程句柄全局递增。3.2 属性表设计与句柄分配逻辑属性表是 BLE 的核心数据结构所有服务、特征值、描述符都在里面。设计属性表时要考虑三点句柄数量、权限设置、UUID 规划。句柄数量方面每个服务至少占 4 个句柄Service Declaration 至少一个 Characteristic 的 Declaration、Value、Descriptor。三个服务加上一些描述符大概需要 20 到 30 个句柄。前面 menuconfig 里设的 100 是上限实际用多少由注册的服务决定。权限设置决定主机能不能读写。比如环境数据服务的特征值设为只读ESP_GATT_PERM_READ控制服务的特征值设为可写ESP_GATT_PERM_WRITE设备信息服务的特征值设为只读加密ESP_GATT_PERM_READ_ENC。权限设错会导致主机操作被拒返回ESP_GATT_INSUF_AUTHENTICATION或ESP_GATT_READ_NOT_PERMIT。UUID 规划要避免重复。标准 UUID 查蓝牙官方文档自定义 UUID 自己生成建议用在线 UUID 生成器保证唯一性。我习惯把自定义 UUID 的前缀统一方便识别。3.3 多服务注册的代码结构与回调分发多服务注册的核心是esp_ble_gatts_create_service和esp_ble_gatts_add_char这两个 API。每个服务先创建拿到服务句柄再往里面加特征值最后esp_ble_gatts_start_service启动服务。代码结构上我封装了一个ble_service_init函数里面依次调用三个服务的注册函数。每个服务的注册函数负责创建服务、加特征值、加描述符、启动服务。这样代码模块化加服务只需加一个函数。回调分发是多服务的难点。ESP-IDF 的 GATT 回调是全局的所有服务的读写事件都进同一个回调函数。需要在回调里根据param-read.handle或param-write.handle判断是哪个服务的哪个特征值。我的做法是维护一张句柄到服务类型的映射表回调进来先查表再分发到对应的处理函数。static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param) { switch (event) { case ESP_GATTS_READ_EVT: handle_read_event(param); break; case ESP_GATTS_WRITE_EVT: handle_write_event(param); break; // 其他事件... } }handle_read_event里根据param-read.handle查映射表找到对应的数据源填进param-read.value再调esp_ble_gatts_send_response返回。3.4 广播数据与扫描响应数据的填充技巧广播数据Advertising Data和扫描响应数据Scan Response Data是主机发现设备的第一步。广播数据最多 31 字节扫描响应也是 31 字节要精打细算。广播数据里通常放 Flags、设备名、服务 UUID。Flags 占 3 字节设备名按实际长度算服务 UUID 如果是 16 位占 2 字节128 位占 16 字节。三个服务如果都用 128 位 UUID光 UUID 就占 48 字节放不下。所以广播数据里只放一个主服务的 UUID其他服务靠主机连上后自己发现。扫描响应数据里可以放设备名的剩余部分、厂商自定义数据、TX Power。我的做法是广播数据放 Flags 主服务 UUID 设备名缩写扫描响应放完整设备名 厂商数据。填充时要注意格式每个 AD 结构是「长度 类型 数据」长度字节包含类型字节但不包含自己。这个格式错了主机解析会出问题广播看起来发出去了但扫不到。4. 完整代码实现与关键环节解析4.1 服务初始化与特征值添加的完整流程先看服务初始化的整体流程。以环境数据服务为例#define ENV_SERVICE_UUID 0x181A #define TEMP_CHAR_UUID 0x2A6E #define HUMI_CHAR_UUID 0x2A6F static uint16_t env_service_handle; static uint16_t temp_char_handle; static uint16_t humi_char_handle; void env_service_init(void) { esp_bt_uuid_t service_uuid { .len ESP_UUID_LEN_16, .uuid.uuid16 ENV_SERVICE_UUID, }; esp_ble_gatts_create_service(gatts_if, service_uuid, 10, env_service_handle); }create_service的第三个参数是服务需要的句柄数我给了 10够放两个特征值和描述符。创建成功后会在ESP_GATTS_CREATE_EVT回调里返回服务句柄然后在回调里加特征值case ESP_GATTS_CREATE_EVT: if (param-create.service_handle env_service_handle) { add_temp_characteristic(); add_humi_characteristic(); esp_ble_gatts_start_service(env_service_handle); } break;加特征值的 API 是esp_ble_gatts_add_char参数包括服务句柄、特征值 UUID、权限、属性、初始值。属性决定这个特征值支持读、写、通知还是指示。环境数据用ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_NOTIFY控制服务用ESP_GATT_CHAR_PROP_BIT_WRITE。4.2 读写回调与通知发送的代码实现读回调的处理逻辑是主机发读请求回调进来根据句柄找到数据填进响应。以温度特征值为例void handle_read_event(esp_ble_gatts_cb_param_t *param) { uint16_t handle param-read.handle; esp_gatt_rsp_t rsp {0}; rsp.attr_value.handle handle; rsp.attr_value.len 2; if (handle temp_char_handle) { int16_t temp read_temperature(); rsp.attr_value.value[0] temp 0xFF; rsp.attr_value.value[1] (temp 8) 0xFF; } else if (handle humi_char_handle) { uint16_t humi read_humidity(); rsp.attr_value.value[0] humi 0xFF; rsp.attr_value.value[1] (humi 8) 0xFF; } esp_ble_gatts_send_response(gatts_if, param-read.conn_id, param-read.trans_id, ESP_GATT_OK, rsp); }写回调类似从param-write.value里取数据根据句柄分发处理。控制服务的写回调里解析指令比如第一个字节是命令类型后面是参数。通知发送用esp_ble_gatts_send_indicate最后一个参数设need_confirm false就是通知设true就是指示。通知不需要主机确认速度快但可能丢指示需要确认可靠但慢。传感器数据上报用通知重要配置用指示。esp_ble_gatts_send_indicate(gatts_if, conn_id, temp_char_handle, sizeof(temp_data), temp_data, false);4.3 连接参数协商与 MTU 设置连接参数决定连接间隔、从机延迟、超时时间。连接间隔越短响应越快但功耗越高。我的场景是传感器上报连接间隔设 30ms 到 50ms 比较合适。从机延迟设 0保证及时响应。超时时间设 4 秒避免误断。MTU 是单次传输的最大字节数默认 23 字节实际数据 20 字节。多服务场景下数据量大建议协商到 247 字节。协商方法是主机发起 MTU 交换请求从机在ESP_GATTS_MTU_EVT回调里确认。case ESP_GATTS_MTU_EVT: ESP_LOGI(TAG, MTU updated to %d, param-mtu.mtu); break;协商成功后通知和读写的单次数据量都能提升传输效率明显改善。实测下来MTU 从 23 提到 247传 100 字节数据的耗时从 5 个包降到 1 个包。4.4 主函数初始化顺序与事件循环主函数的初始化顺序很关键错了会各种报错。正确顺序是NVS 初始化 - 蓝牙控制器初始化 - Bluedroid 初始化 - 注册 GATT 回调 - 注册 GAP 回调 - 设置设备名 - 配置广播参数 - 启动广播。void app_main(void) { esp_err_t ret nvs_flash_init(); if (ret ESP_ERR_NVS_NO_FREE_PAGES) { nvs_flash_erase(); nvs_flash_init(); } esp_bt_controller_config_t bt_cfg BT_CONTROLLER_INIT_CONFIG_DEFAULT(); esp_bt_controller_init(bt_cfg); esp_bt_controller_enable(ESP_BT_MODE_BLE); esp_bluedroid_init(); esp_bluedroid_enable(); esp_ble_gatts_register_callback(gatts_event_handler); esp_ble_gap_register_callback(gap_event_handler); esp_ble_gatts_app_register(APP_ID); esp_ble_gap_set_device_name(ESP32C3_MULTI); esp_ble_gap_config_adv_data(adv_data); }NVS 初始化必须放最前面因为蓝牙协议栈要用 NVS 存配对信息。控制器初始化要在 Bluedroid 之前否则会报ESP_ERR_INVALID_STATE。GATT 和 GAP 回调注册要在app_register之前不然事件收不到。5. 烧录、调试与常见问题排查5.1 烧录失败的典型原因与解决路径烧录失败是高频问题原因主要有几类。第一类是串口被占用比如串口监视器还开着或者别的软件占着 COM 口。解决方法是关掉所有占用串口的程序重新插拔 USB。第二类是波特率不匹配。ESP32-C3 默认烧录波特率是 921600有些 USB 转串口芯片不支持这么高会失败。可以在menuconfig里把Serial flasher config - Default serial port和Baud rate调低到 115200 试试。第三类是芯片没进下载模式。ESP32-C3 需要在上电时拉低 GPIO9 才能进下载模式。有些开发板有自动下载电路有些没有需要手动按住 BOOT 键再按 RESET。如果一直烧不进去检查这个。第四类是供电不足。ESP32-C3 烧录时电流会冲到 200mA 以上USB 口供电不够会失败。换一个供电足的 USB 口或者用带供电的 HUB。5.2 蓝牙连接不稳定与断连排查连接不稳定通常和连接参数、射频干扰、电源有关。先看连接参数连接间隔太短、超时太短都容易断。用手机上的 BLE 调试 App 看连接参数对比一下是否合理。射频干扰方面2.4GHz 频段很拥挤WiFi 路由器、微波炉都会干扰。把设备远离这些干扰源或者换信道。BLE 的广播信道是 37、38、39连接后会跳频但广播阶段容易受干扰。电源方面ESP32-C3 在射频工作时电流波动大电源纹波大会导致断连。在电源脚并一个 100uF 和 0.1uF 的电容能明显改善。还有一个容易忽略的点是看门狗。如果任务阻塞太久看门狗会复位芯片表现为突然断连。检查代码里有没有长时间阻塞的操作必要时喂狗。5.3 功耗异常与优化方向ESP32-C3 的功耗在 BLE 连接状态下大概 20mA 到 40mA广播状态下 10mA 左右深度睡眠能到 5uA。如果实测功耗远高于这个先查是不是没进睡眠或者有外设一直耗电。优化方向有几个一是拉长连接间隔从 30ms 拉到 100ms功耗能降不少二是用从机延迟允许从机跳过若干连接事件三是数据上报用通知而不是指示减少交互四是空闲时进 light sleep需要时再唤醒。实测下来连接间隔从 30ms 调到 100ms平均功耗从 35mA 降到 18mA。再加从机延迟 4能降到 12mA 左右。如果场景允许进 light sleep 能到 1mA 以下。5.4 常见问题速查表问题现象可能原因排查方法解决方案烧录失败串口占用检查 COM 口关闭占用程序烧录失败波特率过高降低波特率改 115200烧录失败未进下载模式检查 GPIO9手动进下载模式广播扫不到广播数据格式错用 App 抓包检查 AD 结构连接后读不到属性表未注册看日志检查服务注册写数据无反应权限未开看回调加写权限连接不稳定连接参数不合理看参数调整间隔超时功耗偏高未进睡眠测电流优化连接参数服务注册失败属性表太小看错误码加大属性表MTU 协商失败主机不支持看日志降级到默认6. 多服务扩展与工程化建议6.1 增加新服务的标准步骤加新服务其实就四步定义 UUID、创建服务、加特征值、启动服务。以加一个电池服务为例UUID 用标准的 0x180F特征值用 0x2A19。在ble_service_init里加一个battery_service_init调用在回调里处理创建事件加特征值启动服务。要注意的是句柄映射表要同步更新不然读写回调找不到对应的处理函数。我习惯在加服务时顺手把映射表补上避免遗漏。6.2 代码分层与可维护性优化多服务代码容易越写越乱分层很重要。我的做法是分三层应用层main.c管初始化和业务逻辑服务层ble_service.c管 GATT 服务注册和回调分发驱动层sensor.c管硬件读写。层与层之间用接口函数通信不直接访问对方的数据。服务层内部再按服务拆文件每个服务一个 .c 文件公共的 UUID 和句柄定义放头文件。这样加服务、改服务都不影响其他部分维护成本低。6.3 从原型到量产的注意事项原型阶段怎么方便怎么来量产要考虑的就多了。首先是 UUID 要固定不能每次编译都变否则主机端适配麻烦。其次是设备名要能配置不同批次可能不同。再次是固件版本要能读方便售后排查。还有一点是配对和绑定。原型阶段可能不加密量产要考虑安全性开启配对绑定用加密特征值。ESP-IDF 支持 Just Works、Passkey、OOB 几种配对方式按安全需求选。最后是产测。量产需要产测工具验证蓝牙功能建议预留一个产测服务暴露测试指令产测时连上跑一遍确认读写通知都正常。6.4 我个人在实际操作中的几点体会踩过几次坑之后我总结了几条经验。第一属性表大小宁大勿小设小了运行时报错很难查设大了只是多占点 RAM。第二回调里不要做耗时操作BLE 回调运行在协议栈任务里阻塞久了会断连。第三广播数据能用 16 位 UUID 就别用 128 位省下的字节能放更多有用信息。第四调试时善用日志ESP-IDF 的日志分级很细把 BLE 相关日志开到 verbose能看到协议栈内部的交互过程排查问题快很多。还有一个小技巧多服务调试时先用手机上的通用 BLE 调试 App 连上把每个服务的特征值都读一遍写一遍确认基础功能正常再上自己的主机端代码。这样能把问题范围缩小避免主机端和从机端同时排查。这个模块后续还可以扩展比如加 OTA 服务支持固件升级加 DFU 服务支持安全升级加自定义透传服务支持大数据量传输。ESP32-C3 的 BLE 能力还有不少可挖的地方多服务只是其中一块。