
1. 蓝牙通信在 ESP32 项目中的定位与整体设计思路1.1 为什么联网篇要单独讲蓝牙很多人做 ESP32 项目第一反应是连 WiFi、接 MQTT、上云平台。但实际落地的时候你会发现WiFi 配网本身就是个麻烦事——设备第一次上电它不知道你家路由器密码你怎么把密码告诉它这时候蓝牙就派上用场了。用手机 App 通过蓝牙把 WiFi 账号密码发过去设备收到后自己去连这就是最经典的蓝牙配网流程。除了配网蓝牙还有几个高频场景一是近距离控制比如用手机直接控制一盏灯、一个电机不需要经过路由器延迟低、响应快二是数据透传比如把传感器数据实时推到手机端做波形显示三是和米家 mesh 这类生态对接时蓝牙也常作为本地控制通道存在。所以蓝牙不是 WiFi 的替代品而是 WiFi 的补充两者在 ESP32 上是可以同时跑的。这一讲的核心目标很明确在 ESP-IDF VSCode 这套开发环境下把 ESP32 的蓝牙跑通实现手机与设备之间的连接和数据收发。我会从协议选型讲到代码落地再到实际调试中踩过的坑尽量让你看完就能直接抄作业。1.2 经典蓝牙 vs BLE到底选哪个ESP32 支持两种蓝牙经典蓝牙Classic BluetoothBR/EDR和低功耗蓝牙BLEBluetooth Low Energy。这俩虽然都叫蓝牙但底层协议栈、应用场景、API 完全不一样新手最容易在这里犯迷糊。先给结论做手机 App 控制、传感器数据上报、配网这类场景优先选 BLE。原因很简单现在手机端对经典蓝牙的支持越来越弱尤其是 iOS基本只开放 BLE 的 GATT 接口。安卓虽然还支持经典蓝牙的 SPP串口协议但各家 ROM 权限管理越来越严适配成本高。我把两者的关键差异整理成一张表方便你对照选型对比维度经典蓝牙 BR/EDR低功耗蓝牙 BLE主要用途音频、串口透传低功耗数据传输、控制手机兼容性安卓尚可iOS 差安卓 iOS 都好功耗高极低连接方式配对后建立持续连接广播 连接按需通信ESP-IDF 协议栈BluedroidBluedroid 或 NimBLE典型吞吐较高几十 KB/s较低几 KB/s开发复杂度中等中等偏上GATT 概念多ESP-IDF 里蓝牙协议栈有两个选择Bluedroid和NimBLE。Bluedroid 是博通那套功能全经典蓝牙和 BLE 都支持但占 Flash 和 RAM 比较大NimBLE 是 Apache 基金会的开源协议栈主打轻量只支持 BLE占用资源小很多。如果你的项目只需要 BLE而且对内存敏感NimBLE 是更好的选择。这一讲我主要以 Bluedroid 为例因为它资料多、坑相对少适合入门。1.3 整体架构从广播到数据收发BLE 的通信模型和经典蓝牙完全不同理解这套模型是写代码的前提。我用一个生活化的类比帮你建立直觉把 BLE 设备想象成一个公告栏。设备上电后会不停地往公告栏上贴小纸条这个动作叫广播Advertising。纸条上写着我叫什么名字、我能提供什么服务。手机Central中心设备路过看到纸条如果感兴趣就主动联系设备Peripheral外围设备建立连接。连接建立后双方通过一张服务清单GATT Profile来约定怎么通信。这张服务清单的结构是这样的Service服务一个功能模块比如电池服务心率服务。每个服务有一个 UUID 标识。Characteristic特征值服务下面的具体数据项是真正读写数据的地方。每个特征值也有 UUID还带有属性Properties标明它是可读、可写、可通知。Descriptor描述符特征值的附加说明比如单位、格式。数据交互有三种方式Read读、Write写、Notify/Indicate通知。Notify 是 BLE 的精髓——设备数据变了主动推给手机不用手机轮询省电又实时。所以整个开发流程就是初始化协议栈 → 注册 GATT 服务 → 开始广播 → 等待连接 → 在回调里处理读写和通知。下面几节我会把这套流程拆开揉碎讲。2. 开发环境准备与工程配置要点2.1 VSCode ESP-IDF 插件的环境确认在动手写蓝牙代码之前先把环境确认一遍。虽然你可能已经装好了 ESP-IDF 和 VSCode 插件但蓝牙这块有几个配置项容易漏。首先确认你的 ESP-IDF 版本。蓝牙 API 在不同版本间有变动尤其是 NimBLE 相关接口。我建议用ESP-IDF v5.x这个版本对 BLE 的支持比较成熟。在 VSCode 里按F1输入ESP-IDF: Show ESP-IDF Version就能看到当前版本。然后确认目标芯片。ESP32、ESP32-S3、ESP32-C3 的蓝牙能力不一样ESP32 支持经典蓝牙 BLEESP32-S3 只支持 BLE 5.0ESP32-C3 支持 BLE 5.0 但不支持经典蓝牙。如果你用的是 S3 或 C3经典蓝牙的代码是编译不过的这点要提前知道。在 VSCode 底部状态栏点击芯片型号那个按钮可以切换目标。切换后记得重新Full Clean再编译否则可能出现奇怪的链接错误。2.2 menuconfig 里必须打开的蓝牙选项这是新手最容易翻车的地方——代码写得没问题但 menuconfig 里没开蓝牙编译直接报一堆未定义符号。打开终端运行idf.py menuconfig按下面的路径逐项检查第一处协议栈选择Component config → Bluetooth → Bluetooth勾选Bluetooth启用蓝牙。然后Bluetooth controller选Bluetooth controller默认。在Host那一栏选Bluedroid - Dual-mode同时支持经典和 BLE或者NimBLE - BLE only。第二处如果只用 BLE 想省资源选 NimBLE 后还可以在Component config → Bluetooth → NimBLE Options里关掉一些用不到的功能比如Enable NimBLE host task的优先级调整、NimBLE log level调低减少日志。第三处经典蓝牙相关如果选了 Bluedroid 且要用经典蓝牙需要在Bluetooth → Bluedroid Options里勾选Classic Bluetooth否则 SPP、A2DP 这些 API 都不可用。第四处内存相关蓝牙协议栈吃内存如果编译时报region dram0_0_seg overflowed说明内存不够。可以在Component config → Bluetooth → Bluedroid Options里调小一些缓冲区或者把BT/BLE的 task stack size 适当降低。提示每次改完 menuconfig建议执行一次idf.py fullclean再重新编译。蓝牙的配置宏会影响大量源文件的编译增量编译有时会残留旧的目标文件导致行为诡异。2.3 一个最小可编译的蓝牙工程骨架我习惯从官方示例起步而不是从零建工程。ESP-IDF 自带蓝牙示例路径在examples/bluetooth/bluedroid/ble/gatt_server。你可以用 VSCode 的ESP-IDF: Show Examples命令直接打开或者手动复制一份出来改。复制出来后先别急着改代码直接编译烧录跑一遍确认环境没问题。串口应该能看到类似这样的日志I (xxx) BT_INIT: BT controller compile version [...] I (xxx) GATTS_DEMO: REGISTER_APP_EVT, status 0, app_id 0 I (xxx) GATTS_DEMO: advertising start success看到advertising start success说明蓝牙已经正常广播了。这时候用手机上的 nRF Connect 或者 LightBlue 扫一下应该能看到一个叫ESP_GATTS_DEMO的设备。能扫到环境就算通了。这一步看着简单但它是后面所有调试的基准。如果连广播都出不来后面写再多业务代码都是白搭。所以我的习惯是每换一个芯片、每升一次 IDF 版本都先跑一遍官方示例确认基线。3. GATT 服务端核心实现与代码拆解3.1 广播数据与扫描响应怎么配广播是 BLE 的门面手机能不能发现你、发现你之后看到什么信息全靠广播数据。ESP-IDF 里配置广播主要用esp_ble_gap_config_adv_data这个函数传入一个esp_ble_adv_data_t结构体。这个结构体里几个关键字段set_scan_rsp是否作为扫描响应。广播包只有 31 字节放不下太多信息所以通常把设备名、完整服务 UUID 放在扫描响应里。include_name是否包含设备名。include_txpower是否包含发射功率调试信号强度时有用。min_interval/max_interval广播间隔单位是 0.625ms。间隔越小被发现越快但越费电。一般设 0x2020ms到 0x4040ms之间。appearance设备外观类型比如 0x0341 表示键盘0x0342 表示鼠标。这个字段会影响手机端显示的图标。manufacturer_len/p_manufacturer_data厂商自定义数据做私有协议时常用。我一般把设备名和主要服务的 UUID 放进广播这样手机扫到就能直接判断这是不是我要连的设备不用连上再查服务。配置完广播数据后还要调用esp_ble_gap_start_advertising真正开始广播。这里有个细节广播数据的总长度不能超过 31 字节。设备名太长、UUID 太多都会超。超了之后esp_ble_gap_config_adv_data会返回错误但错误信息不一定直观。我的经验是设备名控制在 10 个字符以内UUID 只放主服务的 16 位短 UUID能省不少空间。3.2 注册服务与特征值的完整流程GATT 服务的注册是 BLE 开发里最繁琐的一步但套路很固定。整个过程分三步定义属性表 → 注册应用 → 创建服务。先说属性表。ESP-IDF 用一个esp_gatts_attr_db_t数组来描述整个服务的结构每一项对应一个属性服务声明、特征值声明、特征值本身、描述符。这个数组的顺序很重要因为句柄handle是按顺序分配的。一个典型的属性表长这样以一个有读写和通知功能的特征值为例static const esp_gatts_attr_db_t gatt_db[HRS_IDX_NB] { // 服务声明 [HRS_IDX_SVC] { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)primary_service_uuid, ESP_GATT_PERM_READ, sizeof(uint16_t), sizeof(heart_rate_svc), (uint8_t *)heart_rate_svc} }, // 特征值声明 [HRS_IDX_HR_MEAS_CHAR] { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)character_declaration_uuid, ESP_GATT_PERM_READ, CHAR_DECLARATION_SIZE, CHAR_DECLARATION_SIZE, (uint8_t *)char_prop_notify} }, // 特征值本身 [HRS_IDX_HR_MEAS_VAL] { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)heart_rate_meas_uuid, ESP_GATT_PERM_READ, HRPS_HT_MEAS_MAX_LEN, 0, NULL} }, // 描述符 [HRS_IDX_HR_MEAS_NTF_CFG] { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)character_client_config_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, sizeof(uint16_t), sizeof(heart_measurement_ccc), (uint8_t *)heart_measurement_ccc} }, };几个关键点解释一下。ESP_GATT_AUTO_RSP表示这个属性由协议栈自动响应读写请求不用你在回调里手动处理省事。如果设成ESP_GATT_RSP_BY_APP就得在ESP_GATTS_READ_EVT里自己填数据返回适合数据需要动态计算的场景。character_declaration_uuid是固定的 0x2803表示这是一个特征值声明。character_client_config_uuid是 0x2902就是那个 CCCD 描述符手机端要开通知就是往这个描述符写 0x0001。属性表定义好之后调用esp_ble_gatts_create_attr_tab创建属性表然后在ESP_GATTS_CREAT_ATTR_TAB_EVT回调里拿到各个属性的句柄再调用esp_ble_gatts_start_service启动服务。3.3 事件回调蓝牙开发的中枢神经ESP-IDF 的蓝牙 API 是事件驱动的所有状态变化都通过回调通知你。核心回调是esp_ble_gatts_cb_t注册在esp_ble_gatts_register_callback里。这个回调里要处理的事件很多我挑几个最关键的讲。ESP_GATTS_REG_EVT应用注册完成。在这里要设置设备名esp_ble_gap_set_device_name、配置广播数据、创建属性表。这是整个流程的起点。ESP_GATTS_CREAT_ATTR_TAB_EVT属性表创建完成。在这里拿到句柄启动服务。如果创建失败param-add_attr_tab.status会是非零值要打印出来排查。ESP_GATTS_CONNECT_EVT有设备连上来了。这里可以拿到对方的地址做连接参数更新esp_ble_gap_update_conn_params比如把连接间隔调小一点提升响应速度。ESP_GATTS_DISCONNECT_EVT设备断开。这里一定要重新开始广播否则设备断开后就隐身了手机再也搜不到。这是新手最常犯的错误之一。ESP_GATTS_WRITE_EVT手机往特征值写数据。如果属性设的是ESP_GATT_RSP_BY_APP要在这里调用esp_ble_gatts_send_response回复。如果是 CCCD 被写要判断写的是不是 0x0001是的话说明手机开了通知后续就可以主动推数据了。ESP_GATTS_READ_EVT手机读数据。同样ESP_GATT_RSP_BY_APP时要自己填rsp结构体返回。ESP_GATTS_MTU_EVTMTU 协商完成。默认 MTU 是 23 字节实际能传的数据只有 20 字节。如果数据量大要在这里协商更大的 MTUESP32 最大支持 517。我建议在回调开头加一句日志打印事件 ID调试的时候一眼就能看出流程走到哪了。日志级别用ESP_LOGI就行别用ESP_LOGD因为默认日志级别可能把 debug 过滤掉。3.4 数据发送Notify 与 Indicate 的区别数据从设备发到手机有两条路Notify和Indicate。功能上都是主动推送区别在于Indicate 需要手机回 ACK 确认Notify 不需要。所以 Notify 快但不可靠Indicate 可靠但慢。绝大多数场景用 Notify 就够了。发送数据的函数是esp_ble_gatts_send_indicate注意这个函数名虽然叫 indicate但通过最后一个参数need_confirm控制是 Notifyfalse还是 Indicatetrueesp_ble_gatts_send_indicate(gatts_if, conn_id, handle, len, data, false); // false Notify发送前要确认两件事一是手机已经开了通知CCCD 写了 0x0001二是数据长度不超过协商后的 MTU 减 3。如果没开通知就发函数会返回错误如果数据超长会被截断或报错。我的习惯是维护一个conn_id和notify_enable标志连接时记录断开时清零发送前判断一下避免往一个已经断开的连接发数据导致崩溃。4. 手机端联调与实战问题排查4.1 用 nRF Connect 快速验证服务代码烧进去之后别急着写手机 App先用nRF Connect这个工具验证。它是 Nordic 出的调试神器安卓和 iOS 都有能扫描、连接、查看服务、读写特征值、开通知基本覆盖了 BLE 调试的所有需求。打开 nRF Connect扫描找到你的设备名点连接。连上后会自动读取 GATT 服务列表。你应该能看到自己定义的服务 UUID展开后能看到特征值。点特征值旁边的三个箭头图标可以读、写、开通知。验证顺序我建议这样先读 → 再写 → 最后开通知。读能拿到数据说明服务注册没问题写能成功说明写权限和回调没问题开通知后设备能推数据过来说明 Notify 链路通了。三步都过硬件端就算完工了。如果扫描不到设备先确认广播有没有启动成功看日志再确认手机蓝牙权限有没有给安卓 12 以上需要BLUETOOTH_SCAN和BLUETOOTH_CONNECT权限。如果连上了但看不到服务多半是服务没启动检查ESP_GATTS_CREAT_ATTR_TAB_EVT回调里的 status。4.2 连接不稳定、频繁断开的排查思路蓝牙连接不稳定是高频问题原因可能有很多。我按排查优先级列一下第一连接参数不合理。连接间隔Connection Interval设得太小虽然响应快但双方都费电某些手机可能因为功耗策略主动断开。建议间隔设在 30ms 到 50ms 之间slave latency 设 0 到 4supervision timeout 设 4 到 6 秒。第二广播和连接冲突。ESP32 在连接状态下如果还在广播某些芯片会出问题。正确做法是连接建立后停止广播断开后再重新开始。第三内存不足。蓝牙协议栈内存吃紧时会出现连接后很快断开、或者连不上的情况。可以在 menuconfig 里调大BT/BLE相关的 buffer 数量或者降低日志级别减少内存占用。第四电源问题。ESP32 蓝牙发射瞬间电流能到 100mA 以上如果供电不足比如用电脑 USB 口带一堆外设会出现复位或连接失败。用独立电源或者加个大电容能缓解。我遇到过一次特别隐蔽的设备连上后十几秒必断查了半天发现是supervision timeout设得太短而手机在某个时刻进入了省电模式没及时回包。把 timeout 从 2 秒调到 6 秒就好了。所以连接参数这几个值别照抄示例要根据实际场景调。4.3 常见问题速查表我把调试蓝牙过程中最常遇到的问题整理成一张表方便你对照排查现象可能原因排查方向手机搜不到设备广播未启动 / 广播数据超长看日志有无 advertising start success连上后立刻断开连接参数不合理 / 内存不足调大 supervision timeout检查内存看不到自定义服务服务未启动 / UUID 配错检查 CREAT_ATTR_TAB_EVT 的 status写数据无反应属性权限没开写 / 回调没处理检查 ESP_GATT_PERM_WRITE 和 WRITE_EVT通知收不到数据CCCD 没写 0x0001 / 没发数据确认 notify_enable 标志和发送调用数据被截断超过 MTU-3协商更大 MTU 或分包发送编译报未定义符号menuconfig 没开蓝牙检查 Bluetooth 相关配置断开后搜不到没重新广播在 DISCONNECT_EVT 里重启广播这张表里的每一条我基本都真实踩过。尤其是断开后没重新广播和CCCD 没开通知这两个坑新手几乎必踩记住就行。4.4 几个提升稳定性的实操心得最后分享几个我在实际项目里总结的小技巧都是文档里不太会写的。第一设备名加唯一后缀。量产设备如果都叫同一个名字手机端扫描会看到一堆同名设备没法区分。我的做法是在设备名后面拼上 MAC 地址后两位比如ESP32_A3F2这样每台设备都能唯一识别。第二广播间隔动态调整。设备刚上电、还没被连接时广播间隔设小一点比如 20ms让手机快速发现如果一段时间没人连把间隔调大比如 500ms省电。这个策略在电池供电的设备上很有用。第三数据分包要有协议头。BLE 单包数据有限大数据要分包。分包时一定要在每包前面加个协议头标明包序号和总包数否则接收端拼不回来。我一般用 2 字节头1 字节序号 1 字节总数。第四日志别开太猛。蓝牙协议栈的日志非常啰嗦ESP_LOG_LEVEL设成 Verbose 的时候串口刷屏能把你淹没还会拖慢系统。调试阶段用 Info稳定后降到 Warning。第五连接后主动更新参数。很多手机默认的连接参数偏保守连上后主动调一次esp_ble_gap_update_conn_params把间隔调到适合你场景的值能明显改善响应速度。这套蓝牙通信的架子搭起来之后你会发现它和 WiFi 是可以共存的。ESP32 的射频是分时复用的蓝牙和 WiFi 同时开性能会有一定下降但做配网这种蓝牙传完密码就关掉的场景完全没问题。如果你的项目需要蓝牙常驻 WiFi 常驻那就要在 menuconfig 里开Software Coexistence并接受一定的吞吐损失。这块内容比较多后面有机会再单独展开讲。