1. 蓝牙通信在 ESP32 项目中的定位与整体设计思路1.1 为什么联网篇要单独讲蓝牙很多人做 ESP32 项目第一反应是连 WiFi、接 MQTT、上云。但实际落地时会发现WiFi 配网本身就是个麻烦事设备第一次上电没有屏幕、没有按键怎么把 SSID 和密码告诉它这时候蓝牙就派上用场了。用手机 App 通过蓝牙把 WiFi 凭证发过去设备收到后自动连网这套流程在智能家居、穿戴设备、工业手持终端里几乎是标配。ESP32 的蓝牙能力分两大块经典蓝牙Bluetooth Classic和低功耗蓝牙BLEBluetooth Low Energy。经典蓝牙适合传音频、传大文件功耗高BLE 适合传小数据、低频通信功耗极低。ESP32 芯片同时支持两者但同一时刻只能选一种模式运行不能经典和 BLE 同时开。这一点在选型时就要定下来后面改起来很折腾。这一讲的核心目标很明确让 ESP32 通过蓝牙和手机或其他设备建立连接并完成双向数据通信。具体包括三个层次——广播与扫描、连接建立、数据收发。掌握了这三步配网、遥控、数据回传这些场景都能自己搭起来。1.2 经典蓝牙还是 BLE怎么选选型不是拍脑袋得看场景。我一般按下面这张表来判断对比维度经典蓝牙 (Classic)低功耗蓝牙 (BLE)典型用途音频、串口透传 (SPP)配网、传感器数据、遥控功耗高持续连接耗电大极低适合电池设备数据速率较高适合连续传输较低适合小包数据手机兼容性安卓友好iOS 限制多安卓 iOS 都友好ESP-IDF 支持Bluedroid 协议栈Bluedroid / NimBLE 协议栈开发复杂度中等稍高涉及 GATT 概念如果你做的是手机 App 控制 ESP32尤其是要兼容 iPhone那基本锁定 BLE。iOS 对经典蓝牙的 SPP 支持很差几乎没法用。如果你做的是两块 ESP32 之间透传数据或者接一些老式蓝牙串口模块经典蓝牙的 SPP 更省事。还有一个现实问题ESP32 的蓝牙和 WiFi 共用同一个射频单元。它们可以同时工作但会互相抢占时间片导致吞吐量下降、延迟增加。官方文档里提到共存模式下 WiFi 吞吐可能掉一半。所以如果你的项目对实时性要求高尽量别让蓝牙和 WiFi 同时满负荷跑。配网场景下蓝牙只在配网阶段工作配完就关掉这种用法最稳妥。1.3 整体架构从协议栈到应用层ESP-IDF 里的蓝牙协议栈是分层设计的理解这个层次对排查问题特别有帮助。从下往上大致是控制器层Controller跑在 ESP32 的蓝牙硬件上负责射频、链路层、HCI 传输。这部分是预编译的库一般不用动。主机层Host实现 L2CAP、ATT、GATT、SM 等协议。ESP-IDF 提供两套主机栈Bluedroid功能全占资源多和NimBLE轻量省 RAM。应用层Profile / Service你写的代码就在这一层定义自己的 GATT 服务、特征值处理读写和通知。对于资源紧张的 ESP32 项目我通常推荐NimBLERAM 占用能省下几十 KB启动也快。但如果你需要经典蓝牙 SPP那就只能用 Bluedroid因为 NimBLE 不支持经典蓝牙。这个取舍在项目初期就要定好。提示ESP-IDF 的蓝牙示例代码在examples/bluetooth/目录下BLE 的gatt_server和gatt_client是最值得先跑通的两个例子建议先烧录官方例程确认硬件没问题再改自己的逻辑。2. 开发环境准备与工程配置要点2.1 ESP-IDF 与 VSCode 插件的版本搭配环境这块踩坑的人太多了我先把关键点说清楚。ESP-IDF 的版本和 VSCode 插件的版本是有对应关系的不是随便装一个就行。截至我写这篇内容时比较稳的组合是ESP-IDF v5.1.x 或 v5.2.x 配 VSCode ESP-IDF 插件 1.6 以上。如果你用的是 v4.4 这种老版本插件可能会提示不兼容。安装方式有两种一种是直接用 VSCode 插件里的安装向导它会帮你下载 IDF 和工具链另一种是手动装好 IDF再用插件指向已有路径。我推荐第二种因为可以同时装多个 IDF 版本不同项目用不同版本互不干扰。具体做法是每个 IDF 版本装在不同目录比如esp/v5.1和esp/v5.2然后在 VSCode 里通过ESP-IDF: Select current ESP-IDF version切换。工具链的路径一定要配好。Windows 下常见问题是 Python 环境冲突插件自带的 Python 和你系统里的 Python 打架。解决办法是在插件设置里明确指定 IDF 使用的 Python 路径别让它自动找。2.2 开启蓝牙支持的 menuconfig 配置新建工程后第一件事是idf.py menuconfig把蓝牙相关的选项打开。路径在Component config - Bluetooth下面。关键几项Bluetooth总开关必须开。Bluetooth Host选 Bluedroid 还是 NimBLE。选 NimBLE 的话下面会多出 NimBLE 的细分选项。Controller Options这里能配蓝牙的射频参数一般默认即可。Bluetooth Classic如果要用经典蓝牙得在这里单独开NimBLE 下没有这个选项。还有一个容易忽略的点蓝牙默认会占用大量内存。如果编译时报内存不足去Component config - Bluetooth - Bluedroid Options里把不需要的 profile 关掉比如 A2DP、AVRC 这些音频相关的能省不少 RAM。配置完记得保存然后idf.py build编译一次确认没有链接错误。如果报undefined reference to esp_bt_...之类的错八成是蓝牙开关没开全。2.3 工程目录结构与组件依赖一个干净的蓝牙工程目录大概长这样ble_demo/ ├── CMakeLists.txt ├── sdkconfig ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ (可选放自定义组件)main/CMakeLists.txt里要声明依赖的组件。蓝牙相关的组件在 IDF 里已经内置一般不用手动加但如果用了 NimBLE需要确保REQUIRES里有bt或nimble。我习惯在main/CMakeLists.txt里写清楚idf_component_register(SRCS main.c INCLUDE_DIRS . REQUIRES bt)这样编译时链接器就知道去找蓝牙库。如果漏了REQUIRES bt会出现找不到头文件或者链接失败的问题。注意ESP-IDF 的组件依赖是显式声明的不像 Arduino 那样自动包含。新手最容易在这里卡住编译报错先检查 CMakeLists。3. BLE 通信核心细节与实操要点3.1 GATT 模型Service、Characteristic、DescriptorBLE 通信的核心是GATTGeneric Attribute Profile不理解这个就没法写代码。我用一个生活化的类比来解释把 BLE 设备想象成一栋楼楼里有若干房间Service每个房间里有一些抽屉Characteristic抽屉上贴着标签说明里面装的是什么Descriptor。Service一个功能集合。比如电池服务、心率服务。每个 Service 有一个 UUID 标识。CharacteristicService 里的具体数据点。比如电池服务里有电量值这个 Characteristic。它有三个关键属性读Read、写Write、通知Notify。DescriptorCharacteristic 的附加说明比如单位、格式。最常用的是 CCCDClient Characteristic Configuration Descriptor用来开关通知。UUID 分两种16 位短 UUID官方标准服务用比如 0x180F 是电池服务和128 位长 UUID自定义服务用。你自己定义的服务必须用 128 位避免和标准服务冲突。生成 UUID 可以用在线工具随便生成一个就行只要项目内唯一。3.2 广播、扫描与连接建立流程BLE 设备通信的第一步是广播Advertising。设备上电后如果没被连接就会周期性地往外发广播包包里包含设备名、UUID、发射功率等信息。手机扫描时就是靠这些包发现设备的。广播参数有几个关键项广播间隔多久发一次。范围 20ms 到 10.24s。间隔越短被发现越快但越耗电。配网场景一般设 100ms 左右。广播类型可连接广播、不可连接广播、定向广播等。要能被连接必须用可连接广播。广播数据最多 31 字节装不下可以放扫描响应里。连接建立后双方进入连接态。这时候有个重要参数叫连接间隔Connection Interval决定双方多久通信一次。范围 7.5ms 到 4s。间隔短延迟低但耗电间隔长省电但延迟高。手机作为主机时这个参数通常由手机决定设备端只能建议。我在实际项目里遇到过一个坑安卓手机连接间隔经常被设成 30ms 以上导致通知数据延迟明显。如果对延迟敏感可以在连接后主动发起参数更新请求但手机不一定接受。这个要有心理预期。3.3 数据收发的三种方式读、写、通知BLE 数据交互就三种模式搞清楚各自适用场景读Read主机主动来读设备的值。适合偶尔查询比如读一次电量。缺点是主机得轮询实时性差。写Write主机往设备写数据。分带响应写和无响应写。带响应写可靠但慢无响应写快但不保证到达。配网传 SSID 密码用带响应写更稳。通知Notify设备主动推数据给主机。这是最常用的实时通信方式。设备有数据就推主机订阅后就能收到。比如传感器数据上报就用通知。通知要生效主机必须先往 CCCD 写0x0001开启。设备端在代码里要检查这个值只有开启了才发通知。很多新手写完通知发现手机收不到就是忘了处理 CCCD。提示通知和指示Indicate的区别在于指示需要主机回 ACK 确认更可靠但更慢。一般数据用通知关键指令用指示。4. 完整实操从零搭建一个 BLE 数据收发工程4.1 初始化协议栈与注册回调代码从app_main开始。第一步是初始化 NVS蓝牙协议栈要用它存配对信息esp_err_t ret nvs_flash_init(); if (ret ESP_ERR_NVS_NO_FREE_PAGES || ret ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ret nvs_flash_init(); } ESP_ERROR_CHECK(ret);然后初始化控制器和主机栈。以 NimBLE 为例esp_nimble_hci_and_controller_init(); nimble_port_init();接着配置主机回调注册同步回调、重置回调等。ble_hs_cfg.sync_cb是协议栈同步完成后调用的在这里启动广播最合适ble_hs_cfg.sync_cb on_sync; ble_hs_cfg.reset_cb on_reset;on_sync里调用bleprph_advertise()开始广播。这一步做完手机就能搜到设备了。4.2 定义自定义 GATT 服务与特征值定义服务用ble_gatts_count_cfg和ble_gatts_add_svcs。先准备一个服务表static const struct ble_gatt_svc_def gatt_svr_svcs[] { { .type BLE_GATT_SVC_TYPE_PRIMARY, .uuid BLE_UUID16_DECLARE(0xABCD), // 自定义服务 UUID .characteristics (struct ble_gatt_chr_def[]) { { .uuid BLE_UUID16_DECLARE(0xABCE), .access_cb gatt_svr_chr_access, .flags BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_NOTIFY, }, { 0 } }, }, { 0 } };这里定义了一个服务里面有一个特征值支持读、写、通知三种操作。access_cb是读写回调所有读写请求都进这里处理。注册服务ble_gatts_count_cfg(gatt_svr_svcs); ble_gatts_add_svcs(gatt_svr_svcs);注意顺序先 count 再 add漏了 count 会报错。4.3 处理读写事件与主动发送通知读写回调gatt_svr_chr_access是核心逻辑所在。根据ctxt-op判断是读还是写static int gatt_svr_chr_access(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: os_mbuf_append(ctxt-om, current_value, sizeof(current_value)); return 0; case BLE_GATT_ACCESS_OP_WRITE_CHR: // 从 ctxt-om 里读出主机写来的数据 ble_hs_mbuf_to_flat(ctxt-om, recv_buf, sizeof(recv_buf), len); // 处理数据比如解析配网信息 return 0; default: return BLE_ATT_ERR_UNLIKELY; } }主动发通知用ble_gatts_notify_customstruct os_mbuf *om ble_hs_mbuf_from_flat(data, sizeof(data)); ble_gatts_notify_custom(conn_handle, chr_val_handle, om);chr_val_handle是特征值的句柄注册服务后通过ble_gatts_find_chr拿到。发通知前要确认主机已经开启了 CCCD否则发了也收不到。4.4 手机端验证与数据回环测试代码烧进去后用手机上的nRF Connect或LightBlue这类通用 BLE 调试 App 来验证。步骤打开 App扫描找到你的设备名。点连接展开服务列表找到你自定义的 UUID。点读按钮看能不能读到值。点写按钮发一串数据看串口日志有没有打印出来。点通知开关通常是三个箭头图标然后让设备发通知看 App 能不能收到。我一般会做一个回环测试手机写什么设备就通过通知原样发回去。这样能一次性验证读、写、通知三条链路都通。如果回环成功说明底层通信没问题剩下的就是业务逻辑了。注意安卓和 iOS 对 BLE 的处理有差异。iOS 会缓存 GATT 服务改了服务定义后手机可能还显示旧的需要在系统设置里忽略设备再重连。安卓相对干净但不同厂商 ROM 也有坑。5. 常见问题排查与避坑经验实录5.1 编译与烧录阶段的典型报错报错一undefined reference to esp_bt_controller_init原因menuconfig 里蓝牙总开关没开或者 CMakeLists 里没加REQUIRES bt。检查这两处。报错二region dram0_0_seg overflowed原因蓝牙占内存太多DRAM 不够。解决办法是关掉不用的 profile或者改用 NimBLE 主机栈。还可以把一些大数组改成static或放到 PSRAM 里。报错三烧录后串口一直打印rst:0x10 (RTCWDT_RTC_RESET)原因程序崩溃重启。常见于蓝牙初始化时内存分配失败。检查menuconfig里的蓝牙内存配置适当调大。报错四GATT server register failed原因服务定义有问题比如 UUID 重复、特征值 flags 冲突。检查服务表确保每个 UUID 唯一。5.2 连接不稳定与数据丢包排查连接不稳定是蓝牙开发最常见的痛点。我整理了一张速查表现象可能原因排查方向搜不到设备广播没启动 / 广播间隔太长看串口日志有没有 advertise 输出连上就断连接参数不匹配 / 内存不足抓 HCI 日志看断开原因码通知收不到CCCD 没开 / 句柄错误确认主机写了 CCCD检查句柄数据丢包连接间隔太长 / 无响应写改用带响应写或缩短连接间隔延迟大WiFi 共存干扰配网阶段关 WiFi或错开射频时间断开原因码特别有用串口日志里会打印类似reason 0x08的信息。0x08 是连接超时通常是信号弱或连接间隔太长0x13 是远端主动断开一般是手机 App 那边的问题0x3E 是连接建立失败可能是广播参数不对。5.3 蓝牙与 WiFi 共存的实战经验前面提过蓝牙和 WiFi 共用射频同时跑会互相干扰。我在一个配网项目里踩过坑设备一边开 WiFi 热点一边开蓝牙广播结果蓝牙搜不到WiFi 也卡。后来改成分时复用——上电先开蓝牙配网配网成功后关蓝牙、开 WiFi问题就解决了。如果确实需要同时开有几个优化方向在menuconfig里调整WiFi 和蓝牙的共存优先级Component config - Wi-Fi - WiFi/Bluetooth coexistence。降低蓝牙广播频率减少射频占用。把 WiFi 的省电模式打开让它别一直占着射频。实测下来共存模式下 WiFi 吞吐大概会掉 30% 到 50%延迟也会增加。所以对实时性要求高的场景能分时就分时别硬扛。5.4 配对绑定与安全性的取舍BLE 通信默认是不加密的任何人都能连。如果传的是敏感数据比如 WiFi 密码就得开配对绑定。ESP-IDF 里通过ble_hs_cfg.sm_io_cap等参数配置安全等级。安全等级从低到高Just Works无配对最不安全但最省事。Passkey显示 6 位数字双方确认。Out of Band通过其他通道交换密钥最安全但最麻烦。配网场景我一般用Just Works 加应用层加密因为 Passkey 需要屏幕或按键很多设备没有。应用层加密就是在数据里自己加个校验或简单加密防止明文传输。虽然不如标准配对安全但比裸奔强。提示开了配对绑定后设备会存配对信息到 NVS。如果换了手机或者清了 NVS需要重新配对。调试阶段如果连不上先idf.py erase-flash清一下再试。6. 从蓝牙通信延伸出的项目玩法6.1 蓝牙配网 WiFi 上云的完整链路把这一讲的内容和前面的 WiFi 篇串起来就是一个完整的物联网设备启动流程设备上电开 BLE 广播等待配网。手机 App 连接通过写特征值发送 WiFi SSID 和密码。设备收到后关 BLE用收到的凭证连 WiFi。连上后通过 MQTT 上报状态进入正常工作模式。如果 WiFi 断了重新开 BLE 等待重新配网。这套流程在智能插座、温湿度传感器、灯控模块里都能直接用。关键点是状态机要清晰配网态、连接态、工作态之间切换要干净别让蓝牙和 WiFi 同时跑。6.2 用蓝牙做设备调试通道产品开发阶段蓝牙是个极好的调试通道。设备装在壳子里串口引不出来这时候用手机连蓝牙就能看日志、改参数。我习惯在固件里留一个调试服务支持读返回当前运行状态、内存占用、WiFi 信号强度。写修改运行参数比如上报间隔、阈值。通知实时推送日志。这样现场调试不用拆机效率高很多。量产固件里把这个服务关掉就行。6.3 多设备蓝牙组网的可行性有人问能不能用蓝牙做多设备组网。技术上可行BLE 支持一个主机连多个从机也支持 Mesh。但 ESP32 的 BLE Mesh 比较吃资源节点多了延迟也大。如果只是几个设备互联用 BLE 连接模式就够如果要几十上百个节点建议还是走 WiFi Mesh 或者别的方案。我个人经验是蓝牙适合点对点或少量设备大规模组网不是它的强项。选方案时别硬套根据实际节点数和数据量来定。最后分享一个我调试蓝牙时的小习惯每次改完代码先用 nRF Connect 手动跑一遍读写通知确认协议层没问题再去调手机 App。这样能把问题范围缩小避免在 App 和固件之间来回猜。蓝牙这东西日志和抓包比瞎试管用得多。
