ESP-IDF + VSCode 开发 ESP32 联网篇第七讲,这次聊蓝牙连接与通信。前几讲我们把 Wi-Fi 和 TCP/IP 网络栈跑通了,大部分联网类项目都能落地。但实际做产品你会发现,配网、本地控制、近场数据交互这些场景,Wi-Fi 反而不顺手,蓝牙才是最合适的那个角色。这一讲就专门把 ESP32 的蓝牙功能拆开揉碎,讲清楚经典蓝牙和 BLE(低功耗蓝牙)怎么选、怎么配、怎么写代码,以及调试过程中那些文档里不会明说的坑。
先说清楚这一讲适合谁看:你想做手机 App 控制设备(比如用微信小程序、Android App 连 ESP32 发指令),或者想用蓝牙做传感数据采集、室内定位测距,又或者你手上有个老的蓝牙模块(比如 HC-05)想换成 ESP32 原生方案,这讲内容全部覆盖。ESP32 的双模蓝牙意味着它既能连老式经典蓝牙设备,也能和手机上的 BLE 应用直接通信,这是大多数 MCU 不具备的能力。
作为第七讲,默认你已经把 ESP-IDF 开发环境装好,VSCode 里能够正常编译烧录,对 LEDC、GPIO、定时器这些外设也有基本认知。如果还没准备好环境,建议先回到第一讲把基础打好,这一讲默认你具备独立建工程的能力。不废话,直接进入正题。
1. 蓝牙方案选型:经典蓝牙还是 BLE
这一节先解决一个最实际的问题——ESP32 蓝牙到底该用哪一套。很多人拿到 ESP32 看到博通授权的蓝牙协议栈,反而不知道从哪里下手。其实选型逻辑很简单,看你的使用场景和对端设备。
1.1 经典蓝牙(BR/EDR)的真实用途
经典蓝牙在 ESP32 上主要就是 SPP(串口透传)和 A2DP(音频传输)两个场景。SPP 协议做的事情本质上就是“无线串口”,和 HC-05、HC-06 这类模块的行为模式完全一致——两端蓝牙配对成功后,你往 ESP32 的 UART 或者内存缓冲区丢数据,另一端就能收到。
但注意,ESP32 的 SPP 实现和独立蓝牙模块有个关键差异:ESP32 的 SPP 数据是走内部协议栈的,不是物理 UART 引脚,所以你完全可以把 SPP 当作一个无线通道,和应用层直接对接,省掉了一颗 UART 转蓝牙的独立芯片。
如果你要做的是蓝牙手柄、蓝牙键盘这类 HID 设备,ESP32 也能做,但那需要在 Bluedroid 协议栈里做 HID Device Profile 的定制,工作量比较大,一般建议优先考虑后续讲的 BLE HID。有源音箱、蓝牙耳机的 A2DP 音源发送也可以做,但音质受限于 I2S 接口和片内存储,不推荐用来做高保真音频产品。
经典蓝牙还有个令人头疼的特点——配对流程繁琐且受射频环境干扰较大。实测下来,当环境中 2.4GHz 频段拥堵时(比如办公室里几十个 Wi-Fi AP),经典蓝牙的配对成功率明显低于 BLE,连接后的重传率也偏高。对消费级产品来说,BLE 的体验会好很多。
1.2 BLE 的优势与适用边界
BLE 是我个人在绝大多数 ESP32 项目里的首选。原因很直接:手机原生支持好(iOS 从 iPhone 4S 开始就支持 BLE 4.0)、广播和连接模型简单、功耗可控、代码路径清晰。
尤其在做手机 App 控制的场景,BLE 几乎是唯一现实选择。iOS 平台对经典蓝牙 SPP 的支持早已废弃,App Store 上架审核也不鼓励应用使用 MFi 之外的经典蓝牙通道。BLE 则完全不同,iOS 的 CoreBluetooth 和 Android 的 BluetoothLe API 都对开发者完全开放,不需要任何厂商授权。
BLE 的广播—扫描—连接模型也特别适合物联网。设备上电后持续对外发广播包,手机扫描到后发起连接,连接建立后可走 GATT(通用属性协议)进行读写和通知。这个模型天然是做“设备主动上报状态,手机发起控制指令”的架构,比 TCP 长连接更省电、更轻量。
但 BLE 也有短板。它的数据吞吐率比经典蓝牙低很多——经典蓝牙 SPP 的理论速率为 2.1Mbps,而 BLE 4.2 的默认 MTU 只有 23 字节,虽然有 Data Length Extension(DLE)和 ATT MTU 协商可以把单包拉到 251 字节,但实际稳定吞吐量通常在 20~40KB/s 这个量级,不适合大文件传输。
1.3 ESP32 三款主流芯片的蓝牙能力对比
很多初学者在选型时被 ESP32、ESP32-S3、ESP32-C3 搞晕,这三者蓝牙能力差异其实非常关键。我在下表里把核心参数整理出来了。
| 芯片型号 | 经典蓝牙 | BLE | 蓝牙版本 | 典型应用场景 |
|---|---|---|---|---|
| ESP32(原版) | 支持 | 支持 | 4.2 | 双模需求、老设备兼容、音频传输 |
| ESP32-S3 | 不支持 | 支持 | 5.0 | BLE+AI+屏显、复杂 UI、摄像头数据透传 |
| ESP32-C3 | 不支持 | 支持 | 5.0 | 低功耗 IoT 传感器、低成本 BLE 控制 |
实际选择建议:只是做个简单 BLE 控制开关或传感器,ESP32-C3 性价比最高;要接屏幕、跑 LVGL 同时需要 BLE 通信,选 ESP32-S3;如果你确实需要 SPP 或者 A2DP 这种经典蓝牙能力,目前只能用原版 ESP32(或者 ESP32-S3 之外看有没有其他型号)。
这里我要强调一个很多人踩过的坑:ESP32-S3 和 ESP32-C3 宇段里根本没有老版本经典蓝牙的 ROM 和硬件链路,你在 menuconfig 里把 Bluedroid 选成 Classic Bluetooth 会直接编译出错或者运行 panic,不要在错误的方向上浪费时间。
2. ESP-IDF 蓝牙开发框架:Bluedroid 与 NimBLE 的选择
选好硬件之后,下一步就是选软件栈。ESP-IDF 里蓝牙协议栈有两条完全不同的路线,很多人在这上面卡了很久,实话说我也见过不少用错框架导致项目延期的情况。
2.1 Bluedroid:功能全但资源占用高
Bluedroid 是 Android 系统开源的蓝牙协议栈移植到 ESP-IDF 的版本,优点是对 GATT、GAP、A2DP、SPP、HFP 等 Profile 支持非常完整。如果你的项目需要做经典蓝牙(SPP/A2DP),那整个 ESP-IDF 里基本没得选,只能用它。
但 Bluedroid 的代价是资源占用非常夸张。随便一个 BLE 示例编译出来,固件体积比 NimBLE 大 300KB 以上,RAM 占用也高出几十 KB。对 ESP32-C3 这种只有 400KB SRAM 的芯片来说,如果还要跑 Wi-Fi 协议栈、 TLS、应用逻辑,内存会非常捉襟见肘。用 Bluedroid 的时候还容易遇到一些隐性 bug,比如连接断开后的资源回收不彻底,长时间运行后内存碎片化,需要定期重启才能维持稳定。
2.2 NimBLE:轻量、现代、新项目首选
NimBLE 是 Apache 基金会的开源 BLE 协议栈,被乐鑫移植到 ESP-IDF 后作为组件提供。它在 GATT 操作层面的 API 设计比 Bluedroid 清爽太多,事件驱动的模型也更适合嵌入式开发。关键是 NimBLE 只支持 BLE,不支持经典蓝牙——这也是它轻量化的根本原因。
我自己在最近三年的新项目里全部改用 NimBLE,唯一不是因为 NimBLE 有什么魔法,而是它把 BLE 开发原本复杂的状态机管理简化成了一个回调函数注册模型。调试 Bluedroid 时代常见的 “GATT 操作返回 0x01、0x02 错误码但找不到原因” 的尴尬情况,到 NimBLE 里基本消失,因为错误码的定义和状态回调非常直观。
NimBLE 对内存的占用大概是 Bluedroid 的三分之一左右。参照 ESP-IDF 官方给出的数据,BLE 应用配置下 NimBLE 大约占用 50KB Flash 和 9KB RAM,而 Bluedroid 至少要 140KB Flash 和 20KB RAM。在 ESP32-C3 这种资源敏感的芯片上,这个差异是致命的。
2.3 框架选择的三个黄金法则
既然两条路线各有适用场景,我在实际项目里总结出三条选型规律:
第一条,只要明确要做经典蓝牙(SPP、A2DP、HSP),直接选 Bluedroid,没有第二条路。第二条,纯 BLE 项目(手机 App 控制、传感器采集、Mesh)默认选 NimBLE,不管你的 Flash 和 RAM 有多宽裕,都能有效降低固件体积和复杂度。第三条,不确定未来是否需要经典蓝牙,并且手上是最新的 ESP32-S3 或 C3,放弃这个念想——芯片本身不支持,只能在 BLE 框架内做设计。
这三条规律在我参与的项目里无一例外地成立了。你可以在早期原型阶段大胆尝试 Bluedroid,但如果发现 GATT 操作回调和初始化配置太过繁琐,直接切换 NimBLE 通常能减少一半以上的代码量。
3. 动手配置工程:menuconfig 与基础初始化代码
现在开始实际操作。这一节我用 ESP-IDF 的官方示例改造成一个完整的 BLE 从机工程,重点讲配置项含义和初始化代码逻辑,而不是直接丢给你一段可以跑通的代码让你“抄作业”完事。
3.1 menuconfig 里的蓝牙相关配置项解读
先用idf.py menuconfig打开配置界面,主要关注三个区域。
第一个区域是 Component config → Bluetooth。这个入口只有在你的目标芯片支持蓝牙时才出现。你要在这里选择蓝牙协议栈,有 Bluetooth disabled、Bluedroid、NimBLE 三个选项。如果你选了原版 ESP32 并希望做双模,选 Bluedroid,然后在下方的 Bluedroid options 里勾选 Classic Bluetooth 和 SPP。如果你总是做纯 BLE,直接选 NimBLE,你会看到 NimBLE options 的配置项。
第二个区域是 Component config → Bluetooth → Bluedroid options → Peer。这里面有 Max number of connections 之类的参数,默认值是 3,意思是设备最多同时保持 3 个 BLE 连接。当你只做单从机连接手机时,改成 1 可以减少协议栈开销。
第三个区域是 Component config → Bluetooth → NimBLE Options。这里有多个性能相关的选项——BLE role 配置(广播和主从设备角色)、Maximum number of BLE connections、Enable BLE 4.2 features 选项。对大部分做从机广播的项目,默认配置就行,不用改。
这里我要特别提醒一个坑:在 ESP-IDF 较新的版本里(v5.x),如果你在 menuconfig 里切换了协议栈类型,务必同时检查 Partition Table 设置。Bluedroid 比 NimBLE 需要更大的分区,如果你省略这一步,烧录时很可能出现分区空间不足的编译报错,或者运行时报错 “No space for new partition”。
3.2 初始化 NVS 与蓝牙控制器
ESP32 的蓝牙协议栈有两类运行时依赖:NVS(非易失性存储)用来保存绑定信息、配对密钥等;Bluedroid 还需要一个系统事件循环来处理蓝牙状态变化。初始化代码必须严格按照顺序调用,否则会出现运行时断言失败。
#include "nvs_flash.h" #include "esp_bt.h" #include "esp_bt_device.h" #include "esp_gap_ble_api.h" #include "esp_gatts_api.h" #include "esp_gatt_defs.h" #include "esp_gatt_common_api.h" #include "esp_bt_main.h" static void ble_start(void) { // 1. 初始化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); // 2. 释放Wi-Fi占用的基带资源,让蓝牙使用(重要!) ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_WIFI_BT)); // 3. 配置并初始化蓝牙控制器,BLE_ONLY模式仅开启BLE esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); ret = esp_bt_controller_init(&bt_cfg); if (ret != ESP_OK) { ESP_LOGE(TAG, "Bluetooth controller initialize failed: %s", esp_err_to_name(ret)); return; } // 4. 开启BLE模式 ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); if (ret != ESP_OK) { ESP_LOGE(TAG, "Bluetooth controller enable failed: %s", esp_err_to_name(ret)); return; } // 5. Bluedroid协议栈初始化(NimBLE无需这一步) #if CONFIG_BT_BLUEDROID_ENABLED ret = esp_bluedroid_init(); if (ret != ESP_OK) { ESP_LOGE(TAG, "Bluedroid init failed: %s", esp_err_to_name(ret)); return; } ret = esp_bluedroid_enable(); if (ret != ESP_OK) { ESP_LOGE(TAG, "Bluedroid enable failed: %s", esp_err_to_name(ret)); return; } #endif }这段代码是整个 BLE 应用的地基。第二部esp_bt_controller_mem_release(ESP_BT_MODE_WIFI_BT)很容易被忽视——它把原先分配给 Wi-Fi 的基带内存释放给蓝牙。如果你用ESP_BT_MODE_BTDM双模模式或者同时用 Wi-Fi,就不能这样调用,而应该使用ESP_BT_MODE_BTDM对应的配置。很多人在这一步看到ESP_ERR_INVALID_ARG报错,多半是因为把内存释放参数写错了。
3.3 初始化 GATT 服务与注册回调
BLE 从机要对外提供读写能力,必须在 GATT 协议层注册一个服务(Service),服务下面包含特征(Characteristic),每个特征有自己的 UUID 和读写属性。这是 BLE 数据传输最核心的抽象。
static uint8_t char_value[] = {0x00}; static esp_ble_adv_params_t adv_params = { .adv_int_min = 0x20, .adv_int_max = 0x20, .adv_type = ADV_TYPE_IND, .own_addr_type = BLE_ADDR_TYPE_PUBLIC, .channel_map = ADV_CHNL_ALL, .adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY, }; static esp_ble_adv_data_t adv_data = { .set_scan_respond = false, .include_name = true, .include_txpower = true, .min_interval = 0x0006, .max_interval = 0x0010, .appearance = 0x0080, .manufacturer_len = 0, .p_manufacturer_data = NULL, .service_data_len = 0, .p_service_data = NULL, .service_uuid_len = 4, .p_service_uuid = (uint8_t[]){0xFB, 0x34, 0x9B, 0x5F}, .flag = 0x06, }; static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { switch (event) { case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT: esp_ble_gap_start_advertising(&adv_params); break; case ESP_GAP_BLE_ADV_START_COMPLETE_EVT: if (param->adv_start_cmpl.status != ESP_BT_STATUS_SUCCESS) { ESP_LOGE(TAG, "Advertising start failed"); } break; case ESP_GAP_BLE_CONNECT_EVT: // 手机已连接,停止广播以省电 esp_ble_gap_stop_advertising(); break; case ESP_GAP_BLE_DISCONNECT_EVT: // 断开后重新广播,等待下次连接 esp_ble_gap_start_advertising(&adv_params); break; default: break; } } 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_REG_EVT: // 创建服务,service_id 需要按 16bit 或 128bit 指定 esp_gatts_create_service(gatts_if, &service_id, 4); break; case ESP_GATTS_CREATE_EVT: esp_gatts_start_service(param->create.service_handle); break; // 其他事件:READ、WRITE、CONNECT、DISCONNECT 都在这处理 default: break; } } void app_main(void) { // 先初始化 NVS 和控制器(见上一节 ble_start()) ble_start(); // 向协议栈注册GAP回调 esp_ble_gap_register_callback(gap_event_handler); // 注册GATTS回调 esp_gatts_register_callback(gatts_event_handler); // 创建GATT服务表(示例4个handle,实际按需) esp_gatts_create_attr_tab(...); // 设置广播数据 esp_ble_gap_config_adv_data(&adv_data); }这一套机制需要理解的关键点是:GAP 负责设备级广播和连接,GATT 负责服务级的数据读写。广播参数里的adv_int_min和adv_int_max控制广播频率,单位是 0.625ms。0x20 换算下来是 20ms。广播间隔越小,手机扫描发现越快,但功耗越高。做低功耗产品时建议调到 100ms 以上,做需要快速配网的产品建议 20ms~50ms。
4. 数据链路打通:用手机把指令发到设备
GATT 服务注册完之后,真正让设备具备“可用性”的是 Characteristic 的读写回调逻辑。这一节重点讲数据收发实现的常见设计方法,以及你一定会遇到的 MTU 协商和数据粘包问题。
4.1 服务端接收手机指令的典型流程
手机 App 向设备发指令,本质上是向某个 Characteristic 发起 Write Request(写请求)。ESP32 侧接收到写事件后,在回调函数里解析数据、执行动作,比如开灯、控制电机、返回状态等。
下面是一个处理写事件的典型代码片段:
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_WRITE_EVT: { // 判断写的是否是我们要关注的 Characteristic if (param->write.handle == char_handle) { uint8_t *data = param->write.value; uint8_t len = param->write.len; // 例:数据第一个字节是命令字 if (len >= 1) { switch (data[0]) { case 0x01: gpio_set_level(GPIO_NUM_2, 1); break; case 0x02: gpio_set_level(GPIO_NUM_2, 0); break; default: ESP_LOGW(TAG, "Unknown command 0x%02X", data[0]); } } } break; } // 连接事件可以顺便打印对端地址 case ESP_GATTS_CONNECT_EVT: ESP_LOGI(TAG, "Connected, conn_id=%d", param->connect.conn_id); break; case ESP_GATTS_DISCONNECT_EVT: ESP_LOGI(TAG, "Disconnected");