1. 蓝牙在ESP32联网体系里的真实定位
很多人第一次接触ESP32的联网功能,脑子里第一反应就是WiFi。但实际做项目做久了你会发现,蓝牙才是那个"润物细无声"的角色。配网阶段用蓝牙传WiFi账号密码、设备调试阶段用蓝牙看日志、和手机App通信时用蓝牙做近场控制,这些场景里蓝牙比WiFi更省事——不需要路由器、不需要IP、不需要知道对方在哪个网段,开机就能连。
这一讲的核心就是蓝牙连接与通信。我会把ESP-IDF框架下蓝牙的两种主要形态——经典蓝牙和低功耗蓝牙——都过一遍,重点放在BLE上,因为实际项目里BLE用得最多。内容会覆盖协议栈初始化、GAP广播与扫描、GATT服务搭建、数据收发、以及和WiFi共存时那些让人头疼的坑。适合已经能跑通ESP-IDF基础工程、想在VSCode里把蓝牙功能真正用起来的开发者。
先说一个反直觉的结论:ESP32的蓝牙代码,难的不是写,而是配。协议栈的初始化顺序、内存配置、事件回调的注册时机,任何一处顺序错了,编译能过但运行就是连不上。我见过太多人卡在"广播发出去了但手机搜不到"或者"连上了但一写数据就断"这类问题上。所以这篇不会只给你贴代码,而是把每一步背后的逻辑讲清楚。
2. 经典蓝牙与BLE的选型逻辑
2.1 两种蓝牙到底差在哪
ESP32支持双模蓝牙,也就是经典蓝牙(BR/EDR)和低功耗蓝牙(BLE)都能跑。但这两个东西虽然都叫"蓝牙",底层机制和应用场景差别很大。
经典蓝牙走的是持续连接的路子,适合传音频、传大文件这种需要稳定带宽的场景。它的协议栈比较重,功耗也高。BLE走的是"短连接、小数据、低功耗"的路子,适合传感器数据上报、设备控制指令这类场景。BLE的广播机制让设备可以在不建立连接的情况下就把数据发出去,这是它最实用的特性之一。
| 对比维度 | 经典蓝牙 | BLE |
|---|---|---|
| 功耗 | 高 | 极低 |
| 连接方式 | 持续连接 | 短连接/广播 |
| 典型带宽 | 较高 | 低 |
| 适用场景 | 音频、文件传输 | 传感器、控制指令 |
| ESP-IDF组件 | bt(Bluedroid) | bt(Bluedroid/NimBLE) |
| 协议栈内存占用 | 大 | 小 |
2.2 为什么我建议新手从BLE入手
从项目实操角度,我强烈建议先把BLE跑通。原因有三个:第一,BLE的代码结构更清晰,GAP和GATT的分层让逻辑好理解;第二,手机端调试工具多,随便装个BLE调试助手就能看到广播和特征值;第三,BLE的功耗优势在实际产品里是硬需求,学会了直接能用。
经典蓝牙在ESP32上更多是配合A2DP做音频,或者SPP做串口透传。如果你做的是蓝牙音箱、蓝牙串口模块这类东西,那经典蓝牙跑不掉。但如果是智能家居、穿戴设备、传感器节点,BLE是唯一选择。
2.3 协议栈选型:Bluedroid还是NimBLE
ESP-IDF里BLE协议栈有两个选择:Bluedroid和NimBLE。Bluedroid是ESP-IDF默认的,功能全,经典蓝牙和BLE都支持,但内存占用大,编译出来的固件也大。NimBLE是后来引入的,主打轻量,只支持BLE,但内存占用小很多,API也更简洁。
选型建议很直接:如果你只用BLE,且对固件大小和内存敏感,选NimBLE。如果你需要经典蓝牙,或者用的是ESP-IDF比较老的版本,那就Bluedroid。在menuconfig里可以切换:
Component config → Bluetooth → Bluetooth controller → Bluetooth controller mode Component config → Bluetooth → Bluedroid Options / NimBLE Options注意:切换协议栈后,所有蓝牙相关的API都要跟着改,两者不兼容。项目中途换协议栈的成本很高,一开始就要定好。
3. 在VSCode里把BLE工程跑起来
3.1 工程创建与组件依赖
在VSCode里用ESP-IDF插件创建工程,直接选sample_project模板就行。创建完之后,打开CMakeLists.txt,确保main组件的依赖里有bt:
idf_component_register(SRCS "main.c" INCLUDE_DIRS "." REQUIRES bt nvs_flash)nvs_flash是必须的,因为蓝牙协议栈需要把一些配对信息存到NVS里。如果你忘了加这个依赖,编译能过,但运行时会报NVS相关的错误。
3.2 menuconfig里的关键配置
这一步是很多人翻车的地方。打开menuconfig,按下面的路径配置:
Component config → Bluetooth → Bluetooth controller → Bluetooth controller mode → BLE only Component config → Bluetooth → Bluedroid Options → Enable BLE 4.2 features (可选) Component config → Bluetooth → Bluedroid Options → Maximum number of BLE connections (默认3,够用)如果你用的是NimBLE,路径类似,但选项名字不一样。配置完之后保存,VSCode的ESP-IDF插件会自动重新生成sdkconfig。
提示:每次改完menuconfig,建议先
idf.py fullclean再编译。蓝牙协议栈的配置变更有时候不会触发增量编译,直接build可能用的是旧的配置。
3.3 初始化顺序不能乱
蓝牙初始化的顺序是有严格要求的,我把它总结成一条链:
- 初始化NVS
- 初始化蓝牙控制器(Controller)
- 初始化蓝牙协议栈(Host)
- 注册GAP回调
- 注册GATT服务
- 开始广播
这个顺序里,NVS必须在最前面,因为Controller初始化时会去读NVS。Controller必须在Host前面,因为Host要依赖Controller。GAP回调必须在开始广播前注册,否则广播事件来了没人处理。
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); ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)); esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); ESP_ERROR_CHECK(esp_bt_controller_init(&bt_cfg)); ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BLE)); ESP_ERROR_CHECK(esp_bluedroid_init()); ESP_ERROR_CHECK(esp_bluedroid_enable());这段代码里有个细节:esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)。这行代码的作用是释放经典蓝牙占用的内存。如果你只用BLE,这行能省下不少RAM。但如果你后面要用经典蓝牙,这行就不能加。
4. GAP层:广播、扫描与连接建立
4.1 广播数据怎么组织
GAP层负责的是设备的发现和连接。BLE设备通过广播把自己"喊"出去,周围设备扫描到广播后决定要不要连接。广播数据分两部分:广播包(Advertising Data)和扫描响应包(Scan Response Data)。广播包是必须的,扫描响应包是可选的,只有主动扫描时才会请求。
广播数据有31字节的限制,这个限制很紧。你要把设备名、服务UUID、厂商自定义数据都塞进去,很容易超。我的经验是:设备名放扫描响应包里,广播包里只放最核心的服务UUID和厂商数据。
static uint8_t adv_data[] = { 0x02, 0x01, 0x06, // Flags 0x03, 0x03, 0x12, 0x18, // 完整的16位UUID }; static uint8_t scan_rsp_data[] = { 0x0A, 0x09, 'E', 'S', 'P', '3', '2', '_', 'B', 'L', 'E', };这里0x02, 0x01, 0x06是Flags字段,0x06表示同时支持BLE和经典蓝牙。0x03, 0x03, 0x12, 0x18是服务UUID,0x12, 0x18对应的是设备信息服务。扫描响应包里的0x0A, 0x09是设备名的长度和类型,后面跟的是名字字符串。
4.2 广播参数怎么调
广播参数决定了广播的频率和功耗。adv_int_min和adv_int_max是广播间隔,单位是0.625毫秒。比如0x20就是32乘以0.625等于20毫秒。间隔越短,被发现越快,但功耗越高。
esp_ble_adv_params_t adv_params = { .adv_int_min = 0x20, .adv_int_max = 0x40, .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, };adv_type选ADV_TYPE_IND表示可连接的非定向广播,这是最常用的。channel_map选ADV_CHNL_ALL表示在37、38、39三个信道都广播,这样被扫到的概率最大。
4.3 连接事件的回调处理
GAP的回调函数是蓝牙逻辑的核心。所有连接、断开、参数更新的事件都在这里处理。回调函数里不要做耗时操作,否则会阻塞协议栈。
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_SCAN_RSP_DATA_SET_COMPLETE_EVT: esp_ble_gap_start_advertising(&adv_params); break; default: break; } }这里有个容易踩的坑:广播数据和扫描响应数据是分两次设置的,每次设置完成都会触发一个事件。如果你在ADV_DATA_SET_COMPLETE_EVT里直接开始广播,那扫描响应数据可能还没设置好。正确的做法是等两个都设置完再开始广播,或者用状态标志位控制。
5. GATT层:服务、特征值与数据收发
5.1 GATT的层次结构
GATT是BLE数据通信的核心。它的结构是:Profile → Service → Characteristic → Descriptor。一个设备可以有多个Service,每个Service下有多个Characteristic,每个Characteristic可以有多个Descriptor。
Service和Characteristic都用UUID标识。UUID有16位的和128位的。16位UUID是标准UUID,比如0x180F是电池服务。128位UUID是自定义的,格式是一串十六进制数。
| 层级 | 作用 | 示例 |
|---|---|---|
| Service | 功能集合 | 电池服务 0x180F |
| Characteristic | 具体数据点 | 电量值 0x2A19 |
| Descriptor | 特征值描述 | 客户端配置 0x2902 |
5.2 属性表怎么定义
在ESP-IDF里,GATT服务是通过属性表(Attribute Table)定义的。属性表是一个数组,每个元素描述一个属性。这个表的结构比较绕,但理解了就不难。
static const uint16_t GATTS_SERVICE_UUID = 0x00FF; static const uint16_t GATTS_CHAR_UUID = 0xFF01; static const uint16_t GATTS_DESCR_UUID = 0x3333; static const uint8_t char_value[] = {0x11, 0x22, 0x33, 0x44}; static const esp_gatts_attr_db_t gatt_db[] = { [IDX_SVC] = { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_SERVICE_UUID, ESP_GATT_PERM_READ, sizeof(uint16_t), sizeof(uint16_t), (uint8_t *)&GATTS_SERVICE_UUID} }, [IDX_CHAR] = { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, sizeof(uint16_t), 0, NULL} }, [IDX_CHAR_VAL] = { {ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, sizeof(char_value), sizeof(char_value), (uint8_t *)char_value} }, };ESP_GATT_AUTO_RSP表示自动响应,协议栈会自动处理读写请求。如果你需要自定义响应逻辑,就用ESP_GATT_RSP_BY_APP,然后在回调里手动处理。
5.3 读写事件的处理
当手机端读写特征值时,GATT回调会被触发。读操作相对简单,如果是自动响应,协议栈直接返回属性表里的值。写操作需要你在回调里处理。
case ESP_GATTS_WRITE_EVT: if (param->write.handle == char_handle) { ESP_LOGI(TAG, "Write received, len=%d", param->write.len); // 处理写入的数据 if (param->write.need_rsp) { esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, ESP_GATT_OK, NULL); } } break;这里有个关键点:need_rsp标志。如果手机端发的是Write Request,就需要响应;如果是Write Command,就不需要。如果你不判断这个标志直接发响应,可能会出错。
5.4 通知与指示:主动推数据
BLE的数据传输不只是手机读、设备答。设备也可以主动推数据给手机,这就是通知(Notify)和指示(Indicate)。通知不需要手机确认,指示需要确认。通知更快,指示更可靠。
要发通知,首先需要手机端使能通知功能。手机端会写客户端特征配置描述符(CCCD),触发ESP_GATTS_WRITE_EVT。你需要在回调里判断写的是不是CCCD,然后记录下通知是否使能。
if (param->write.handle == cccd_handle) { if (param->write.value[0] == 0x01) { notify_enabled = true; } else { notify_enabled = false; } }使能之后,就可以调用esp_ble_gatts_send_indicate发数据了:
esp_ble_gatts_send_indicate(gatts_if, conn_id, char_handle, sizeof(data), data, false);最后一个参数false表示用通知,true表示用指示。
6. 蓝牙与WiFi共存的那些坑
6.1 共存是必须面对的现实
ESP32只有一个射频模块,蓝牙和WiFi共用。这意味着它们不能同时收发,只能分时复用。ESP-IDF的共存机制会自动协调,但协调是有代价的——吞吐量下降、延迟增加。
如果你的项目既要WiFi联网又要蓝牙通信,共存是绕不开的。好消息是ESP-IDF的共存做得还不错,基本能用。坏消息是有些配置不对的话,蓝牙会频繁断连。
6.2 共存配置的关键参数
在menuconfig里,共存相关的配置在:
Component config → Bluetooth → Bluedroid Options → BT/BLE will be used with WiFi Component config → ESP32-specific → WiFi and Bluetooth coexistence打开共存后,还要注意Software controls WiFi/Bluetooth coexistence这个选项。它决定了共存策略是软件控制还是硬件控制。硬件控制性能更好,但需要射频参数配合。
注意:开启共存后,蓝牙的广播间隔建议不要设得太短。广播太频繁会挤占WiFi的时间片,导致WiFi丢包。
6.3 实测中的断连问题
我在一个项目里遇到过蓝牙连上后几十秒就断的情况。排查了很久,最后发现是WiFi的省电模式导致的。WiFi在省电模式下会周期性休眠,休眠时射频被WiFi占用,蓝牙的广播和连接维护就受影响。
解决办法是把WiFi的省电模式关掉:
esp_wifi_set_ps(WIFI_PS_NONE);关掉之后蓝牙稳定了,但WiFi功耗上去了。如果你的项目是电池供电,这个取舍要仔细权衡。
6.4 内存不够导致的初始化失败
蓝牙协议栈很吃内存。开启共存后,WiFi和蓝牙都要占内存,很容易出现内存不足。表现是esp_bluedroid_init()返回错误,或者运行一段时间后崩溃。
排查方法是看启动时的内存日志:
I (xxx) heap_init: At 3FFAE6E0 len 00001920 (6 KiB): DRAM I (xxx) heap_init: At 3FFB6388 len 00029C78 (167 KiB): DRAM如果可用堆内存低于50KB,蓝牙初始化就可能失败。解决办法是精简功能、关掉不用的组件、或者换用NimBLE协议栈。
7. 调试手段与常见问题排查
7.1 手机端调试工具怎么选
调试BLE,手机端工具是必须的。Android上我用得最多的是nRF Connect,功能全,能看到广播、服务、特征值的所有细节。iOS上LightBlue也不错。这些工具能直接读写特征值、使能通知,调试效率比写代码测试高得多。
用nRF Connect的时候,重点看几个东西:广播里有没有你的设备名、连接后服务列表里有没有你定义的服务、特征值的属性是不是对的。如果服务列表是空的,说明GATT服务注册失败了。
7.2 日志级别怎么调
ESP-IDF的蓝牙日志默认比较多,但有时候关键信息被淹没了。可以在menuconfig里调日志级别:
Component config → Log output → Default log verbosity → Debug Component config → Bluetooth → Bluedroid Options → BT DEBUG LOG LEVEL调试阶段把蓝牙日志开到Verbose,能看到协议栈内部的交互过程。但正式发布时要关掉,否则日志会拖慢性能。
7.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 手机搜不到设备 | 广播没启动 | 检查GAP回调是否触发 |
| 连上就断 | 共存冲突 | 关WiFi省电模式 |
| 写数据失败 | 权限不对 | 检查属性表权限位 |
| 通知收不到 | CCCD没使能 | 检查CCCD写事件 |
| 初始化失败 | 内存不足 | 看堆内存日志 |
| 编译报错 | 组件依赖缺失 | 检查CMakeLists |
7.4 一个真实的排查案例
有次同事的工程,蓝牙广播正常,手机能搜到,但一连就断。日志里看到ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT频繁触发。查了半天,发现是连接参数设得太激进——conn_int_min设成了6(7.5毫秒),这个间隔太短,协议栈处理不过来。
把连接参数改成:
esp_ble_conn_update_params_t conn_params = { .min_int = 0x10, // 20ms .max_int = 0x20, // 40ms .latency = 0, .timeout = 400, // 4s };改完之后就稳定了。这个案例说明,蓝牙参数不是越"快"越好,要根据实际场景调。
8. 从能跑到好用:几个实战经验
8.1 设备名不要用中文
BLE广播里的设备名,我建议只用ASCII字符。虽然协议支持UTF-8,但有些手机端工具对中文名的解析有问题,显示乱码或者干脆搜不到。用英文加数字最稳妥。
8.2 连接间隔和延迟的取舍
连接间隔(Connection Interval)决定了手机和设备多久通信一次。间隔短,响应快,但功耗高。间隔长,省电,但延迟大。对于需要快速响应的控制类应用,间隔设20-40毫秒。对于传感器数据上报,设100毫秒以上也没问题。
从机延迟(Slave Latency)是个省电的好东西。它允许设备跳过若干个连接事件不响应。比如延迟设为4,连接间隔20毫秒,那设备可以每100毫秒才响应一次,省电效果明显。但延迟太大,手机发指令时设备可能不及时响应。
8.3 数据长度扩展
BLE默认的ATT MTU是23字节,实际能传的数据只有20字节。传大一点的数据就要分包,很麻烦。ESP-IDF支持MTU协商,可以把MTU提到512字节。手机端发起MTU协商后,你可以在回调里看到新的MTU值。
case ESP_GATTS_MTU_EVT: ESP_LOGI(TAG, "MTU updated to %d", param->mtu.mtu); break;MTU提高后,单包能传的数据多了,吞吐量能提升不少。但要注意,不是所有手机都支持大MTU,代码里要做好兼容。
8.4 配对与绑定的处理
如果项目需要安全通信,就要处理配对和绑定。配对是交换密钥的过程,绑定是把密钥存下来,下次连接不用重新配对。ESP-IDF里通过esp_ble_gap_set_security_param设置安全参数。
配对过程中会触发ESP_GAP_BLE_SEC_REQ_EVT和ESP_GAP_BLE_AUTH_CMPL_EVT。前者是对方请求配对,你需要调用esp_ble_gap_security_rsp确认。后者是配对完成,可以在这里保存绑定信息。
提示:绑定信息存在NVS里,如果NVS被擦除,绑定就失效了。产品出厂前要确保NVS不被意外擦除。
8.5 低功耗设计的注意事项
如果做电池供电的产品,蓝牙的低功耗设计很关键。除了调连接参数,还要注意:广播间隔不要太短、不需要广播时及时停止、用NimBLE替代Bluedroid、关闭不用的外设时钟。这些措施加起来,能把平均功耗降一个数量级。
我在一个温湿度传感器的项目里,用BLE广播模式(不建立连接,直接把数据放在广播包里)做到了纽扣电池续航半年以上。广播间隔设1秒,每次广播几毫秒,平均电流只有几十微安。这种"无连接"的设计在传感器场景里非常实用。
8.6 固件升级与蓝牙的关系
最后提一个容易被忽略的点:OTA升级。如果你的设备通过WiFi做OTA,升级过程中蓝牙最好停掉,避免射频冲突导致升级失败。ESP-IDF的OTA组件和蓝牙共存时,建议在OTA开始前调用esp_bt_controller_disable(),升级完再重新初始化。
这个细节在开发阶段不容易发现,但量产设备做远程升级时,如果没处理,升级失败率会很高。我踩过这个坑,后来在OTA流程里加了蓝牙暂停的逻辑,升级成功率从70%多提到了接近100%。