1. 蓝牙通信在ESP32项目中的定位与整体设计思路1.1 为什么联网篇要单独讲蓝牙很多刚接触ESP32的朋友会有个疑问ESP32明明自带Wi-Fi为什么还要折腾蓝牙我刚开始做物联网项目时也这么想直到实际落地几个产品后才明白蓝牙和Wi-Fi在ESP32上根本不是替代关系而是互补关系。Wi-Fi适合高带宽、长距离、需要接入路由器的场景比如把温湿度数据上传到云端而蓝牙尤其是BLE低功耗蓝牙适合短距离、低功耗、点对点直连的场景比如手机App配网、设备调试、近场控制。这一讲放在“联网篇”里是因为蓝牙本身就是ESP32联网能力的一部分。你想想一个智能灯泡刚出厂时它不知道你家Wi-Fi的账号密码怎么把密码告诉它最常用的方案就是手机通过蓝牙连上灯泡把Wi-Fi凭证传过去灯泡再自己去连路由器。这个过程叫蓝牙配网是ESP32产品化绕不开的一环。所以蓝牙不是可有可无的附加功能而是联网链路里的关键一环。ESP32的蓝牙能力分两大块经典蓝牙Bluetooth Classic和低功耗蓝牙BLE。经典蓝牙支持SPP串口协议、A2DP音频传输等适合音频、大数据量传输BLE主打低功耗适合传感器数据、控制指令。ESP32芯片同时支持两者但不能同时开启经典蓝牙和BLE只能二选一这点后面会详细说。至于“ESP32蓝牙和Wi-Fi可以一起用吗”这个高频问题答案是可以共存但共享同一个射频前端需要分时复用实际使用中会有一定的性能折损具体怎么权衡我在第4节会展开。1.2 整体方案选型经典蓝牙还是BLE选型这件事我踩过坑。早期做一个蓝牙音箱项目想当然用了BLE结果发现BLE的带宽根本撑不住音频流延迟高得没法听。后来换成经典蓝牙的A2DP才解决。所以选型不能拍脑袋得看你的数据特征。我把常见场景和选型建议整理成一张表你对着自己的需求对号入座场景类型推荐方案原因手机App配网传Wi-Fi密码BLE数据量小手机原生支持好功耗低蓝牙音箱、音频传输经典蓝牙A2DP需要持续高带宽BLE扛不住串口透传、调试打印经典蓝牙SPP模拟串口PC端工具成熟传感器数据上报BLE数据包小间歇发送省电近场控制指令开关灯BLE指令短响应快手机兼容性好大文件传输经典蓝牙带宽优势明显从这张表能看出来BLE是绝大多数物联网控制场景的首选因为手机端支持好、功耗低、开发资料多。经典蓝牙更多用在音频和串口透传这类特定需求上。这一讲我会以BLE为主线因为它是当前ESP32项目里用得最多的同时也会把经典蓝牙SPP的关键点讲清楚方便你做串口透传类项目。1.3 开发环境与工程结构规划在ESP-IDF里做蓝牙开发工程结构和普通项目没本质区别但有几个目录和配置项需要特别注意。我习惯的工程结构是这样的ble_demo/ ├── CMakeLists.txt ├── sdkconfig ├── main/ │ ├── CMakeLists.txt │ ├── ble_demo.c │ ├── ble_gatt_server.c │ └── ble_gatt_server.h关键点在于main/CMakeLists.txt里要声明蓝牙相关的源文件以及sdkconfig里要打开蓝牙协议栈。ESP-IDF的蓝牙协议栈分两种Bluedroid和NimBLE。Bluedroid是ESP-IDF默认的完整协议栈功能全但占Flash和RAM多NimBLE是轻量级协议栈占用资源少适合资源紧张的项目。选哪个我的经验是如果你用的是ESP32-WROOM这种4MB Flash的模组Bluedroid随便用如果是ESP32-C3这种资源紧的或者你同时跑Wi-Fi和蓝牙NimBLE更稳。配置蓝牙协议栈的路径在menuconfig里Component config - Bluetooth - Bluetooth然后选Bluedroid或NimBLE。选完之后还要在下面勾选具体的功能比如Bluetooth controller、Bluedroid Enable等。这里有个坑如果你只勾了协议栈但没勾controller编译会报一堆链接错误我第一次配的时候就被这个坑了半天。提示ESP-IDF版本不同menuconfig里蓝牙选项的位置和名称会有差异。我用的是ESP-IDF v5.x如果你用的是v4.x路径可能略有不同建议以官方文档的版本对应说明为准。2. BLE核心概念与GATT协议栈拆解2.1 BLE通信的本质GATT是数据交换的骨架BLE通信看起来复杂但抓住一个核心就通了所有BLE数据交换都围绕GATT通用属性配置文件展开。你可以把GATT想象成一个数据库里面存着各种数据手机App通过读写这个数据库来和设备交互。这个数据库的结构是分层的最上面是服务Service服务下面是特征Characteristic特征下面是描述符Descriptor。打个比方一个智能手环的GATT数据库里可能有一个“心率服务”这个服务下面有一个“心率测量特征”特征里存着当前心率值。手机App要读心率就是去读这个特征的值。要订阅心率变化就是对这个特征开启通知Notify。所以做BLE开发本质上就是设计你的GATT数据库结构然后实现读写和通知的回调逻辑。每个服务和特征都有一个UUID来标识。UUID分两种16位的短UUID和128位的长UUID。16位UUID是蓝牙官方分配给标准服务的比如心率服务是0x180D电池服务是0x180F。128位UUID是你自己生成的用于自定义服务。我一般用在线UUID生成器生成一个然后固定下来别每次编译都变否则手机端配对记录会乱。2.2 广播、扫描与连接的三步走BLE设备通信的第一步是广播Advertising。设备上电后如果不主动广播手机根本发现不了它。广播包里有设备名、UUID、发射功率等信息手机扫描时就是靠这些信息识别设备。广播间隔是个关键参数设太短功耗高设太长手机发现慢。我一般设100ms到500ms之间配网场景用100ms响应快传感器场景用500ms省电。手机扫描到设备后发起连接Connection连接建立后就进入连接态。连接态下有个重要参数叫连接间隔Connection Interval范围是7.5ms到4s。这个参数直接决定通信的实时性和功耗间隔短响应快但费电间隔长省电但延迟高。手机端通常会根据App的需求协商这个值但设备端也可以在连接参数更新请求里提建议。我做过一个遥控器项目连接间隔设成15ms操作手感很跟手换成100ms后明显感觉有延迟。连接建立后手机就可以读写特征值了。读是手机主动发起写也是手机主动发起而通知Notify和指示Indicate是设备主动推给手机。Notify不需要手机确认速度快但可能丢包Indicate需要手机确认可靠但慢。传感器数据上报用Notify就够了重要配置下发用Indicate更稳妥。2.3 属性表与句柄数据怎么被寻址GATT数据库在协议栈里是以属性表Attribute Table的形式存在的每个属性有一个句柄Handle从0x0001开始递增。手机读写数据时实际用的是句柄而不是UUID。UUID只是用来发现服务的发现之后协议栈会返回对应的句柄后续操作都用句柄。这个机制有点像你去图书馆找书UUID是书名你先用书名查到书架编号句柄然后直接去书架拿书。所以代码里你会看到大量handle变量别被吓到它就是地址而已。ESP-IDF的BLE例程里服务注册后会返回一个handle你把它存下来后面读写通知都用它。有个细节要注意属性表的顺序决定了句柄的分配。如果你先注册服务A再注册服务B那A的句柄就比B小。这个顺序在代码里是固定的但手机端发现服务的顺序不一定和句柄顺序一致所以别依赖句柄大小来判断服务顺序。3. 从零搭建BLE GATT服务器实操3.1 协议栈初始化与广播配置先看协议栈初始化。ESP-IDF里BLE初始化的流程是固定的初始化NVS蓝牙协议栈要用NVS存配对信息、释放经典蓝牙内存如果只用BLE、初始化controller、初始化Bluedroid、注册回调、使能协议栈。这一串调用顺序不能乱乱了就报错。// 初始化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); // 释放经典蓝牙内存只用BLE时 ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)); // 初始化controller 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)); // 初始化Bluedroid ESP_ERROR_CHECK(esp_bluedroid_init()); ESP_ERROR_CHECK(esp_bluedroid_enable()); // 注册回调 ESP_ERROR_CHECK(esp_bt_gap_register_callback(gap_event_handler)); ESP_ERROR_CHECK(esp_ble_gatts_register_callback(gatts_event_handler)); ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));这段代码里esp_bt_controller_mem_release这行很关键。ESP32的蓝牙controller默认给经典蓝牙和BLE都留了内存如果你只用BLE把这部分内存释放出来能给应用省不少RAM。我实测过释放后能多出大约30KB的堆空间对内存紧张的项目很有用。广播配置在esp_ble_gap_config_adv_data里做。广播数据分两部分广播包Advertising Data和扫描响应包Scan Response Data。广播包最多31字节放设备名和主要UUID扫描响应包也是31字节放补充信息。我一般把设备名放广播包把自定义服务的UUID放扫描响应包这样手机扫描时能快速识别。static uint8_t adv_data[] { 0x02, 0x01, 0x06, // Flags 0x0A, 0x09, E, S, P, _, B, L, E, _, D, E, M, O }; static uint8_t scan_rsp_data[] { 0x03, 0x03, 0x0D, 0x18 // 心率服务UUID };这里0x02, 0x01, 0x06是固定格式表示设备支持BLE且可被发现。0x0A, 0x09后面跟设备名长度要算准算错了广播会失败。我建议用宏或者函数来拼广播包别手写容易错。3.2 服务与特征的注册流程注册GATT服务是BLE开发的核心。ESP-IDF里用esp_ble_gatts_create_service创建服务然后用esp_ble_gatts_add_char添加特征。每个特征要指定属性Property比如可读、可写、可通知。属性决定了手机能对这个特征做什么操作。// 创建服务 esp_ble_gatts_create_service(gatts_if, service_uuid, 4, service_handle); // 添加特征 esp_ble_gatts_add_char(service_handle, char_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_NOTIFY, char_value, NULL);ESP_GATT_PERM_READ是权限ESP_GATT_CHAR_PROP_BIT_READ是属性两者要匹配。我见过有人只设了属性没设权限结果手机能发现特征但读不了值排查半天才发现是权限没开。权限和属性必须同时配置缺一不可。添加完特征后还要给特征加描述符Descriptor。最常用的是客户端特征配置描述符CCCDUUID 0x2902手机通过写这个描述符来开启或关闭Notify。没有CCCDNotify功能用不了。esp_ble_gatts_add_char_descr(service_handle, descr_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, NULL, NULL);服务注册完后要调用esp_ble_gatts_start_service启动服务否则手机发现不了。启动之后手机就能扫描到服务并读写特征了。3.3 读写通知的事件处理BLE是事件驱动的所有操作结果都通过回调返回。gatts_event_handler里要处理的事件很多核心的有这几个ESP_GATTS_REG_EVT协议栈注册完成在这里创建服务ESP_GATTS_CREATE_EVT服务创建完成在这里添加特征ESP_GATTS_ADD_CHAR_EVT特征添加完成在这里添加描述符ESP_GATTS_WRITE_EVT手机写了特征值在这里处理写入数据ESP_GATTS_READ_EVT手机读了特征值在这里返回数据ESP_GATTS_CONF_EVTNotify或Indicate的确认事件写事件是最常用的手机下发指令就是通过写特征实现的。处理写事件时要注意如果写的数据超过特征值缓冲区大小会触发长写Long Write需要多次写入并拼接。ESP-IDF默认特征值缓冲区是512字节一般够用但传大文件时要留意。case ESP_GATTS_WRITE_EVT: if (param-write.handle char_handle) { // 处理写入的数据 ESP_LOGI(TAG, 收到数据长度: %d, param-write.len); // 如果开启了Notify可以在这里回推数据 esp_ble_gatts_send_indicate(gatts_if, conn_id, char_handle, param-write.len, param-write.value, false); } break;Notify的发送用esp_ble_gatts_send_indicate最后一个参数need_confirm设false就是Notify设true就是Indicate。发送前要检查CCCD是否被手机开启了没开启就发会报错。我一般用一个全局变量记录CCCD状态在写CCCD描述符的事件里更新。4. 经典蓝牙SPP串口透传与Wi-Fi共存4.1 SPP串口透传的实现要点经典蓝牙SPPSerial Port Profile是模拟串口的协议PC端和手机端都有成熟的串口工具支持。做SPP透传ESP32端相当于一个串口服务器收到蓝牙数据就转发到UART收到UART数据就通过蓝牙发出去。SPP的初始化和BLE不同它用的是经典蓝牙的API。关键步骤是初始化经典蓝牙controller、注册SPP回调、启动SPP服务。SPP服务启动后设备会广播一个串口服务PC端用蓝牙串口工具就能连上。// 初始化经典蓝牙 ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_CLASSIC_BT)); // 注册SPP回调 esp_spp_register_callback(spp_callback); // 初始化SPP esp_spp_init(ESP_SPP_MODE_CB); // 启动SPP服务 esp_spp_start_srv(ESP_SPP_SEC_NONE, ESP_SPP_ROLE_SLAVE, 0, SPP_DEMO);SPP的数据收发在回调里处理。ESP_SPP_DATA_IND_EVT是收到数据的事件ESP_SPP_WRITE_EVT是发送完成的事件。发送数据用esp_spp_write把UART收到的数据直接扔进去就行。有个坑要注意SPP的MTU最大传输单元默认是672字节但实际单次发送建议不超过512字节超过要分包。我做过一个透传项目一次发2KB数据结果丢包严重后来改成512字节分包发送就稳了。4.2 蓝牙与Wi-Fi共存的取舍回到那个高频问题ESP32蓝牙和Wi-Fi能一起用吗答案是能但要看怎么用。ESP32的射频前端是共享的蓝牙和Wi-Fi不能真正同时收发协议栈内部会做时分复用Coexistence。这个机制在ESP-IDF里是默认开启的你不需要额外配置但性能会受影响。实测数据单独跑Wi-Fi时TCP吞吐能到10Mbps以上同时开蓝牙后Wi-Fi吞吐会降到5-6Mbps蓝牙的响应也会变慢。如果蓝牙只是偶尔发个配网信息影响不大如果蓝牙要持续传数据Wi-Fi性能会明显下降。我的建议是配网阶段用蓝牙配网完成后关掉蓝牙只跑Wi-Fi。这样既利用了蓝牙配网的便利又保证了Wi-Fi的性能。ESP-IDF里可以用esp_bt_controller_disable关掉蓝牙controller需要时再开。不过要注意关掉再开需要重新初始化协议栈不能简单disable再enable。如果项目必须同时跑蓝牙和Wi-Fi那就把蓝牙的连接间隔调大减少射频占用时间。另外Wi-Fi尽量用5GHz频段如果模组支持避开2.4GHz的蓝牙频段干扰。ESP32-C5支持5GHz Wi-Fi做双频项目时可以考虑。4.3 配网场景的完整链路设计蓝牙配网的完整链路是这样的设备上电后开启BLE广播手机App扫描到设备后连接通过写特征把Wi-Fi账号密码传给设备设备收到后尝试连接Wi-Fi连接成功则通过Notify告诉手机手机断开蓝牙。这个链路里状态同步是关键手机要知道设备当前处于哪个阶段。我一般设计三个特征一个用于接收Wi-Fi凭证可写一个用于上报配网状态可通知一个用于上报设备信息可读。状态用枚举值表示0等待配网1正在连接2连接成功3连接失败。手机端根据状态值更新UI用户体验会好很多。配网成功后设备要把Wi-Fi凭证存到NVS里下次上电直接连不用再配。NVS的读写用nvs_set_blob和nvs_get_blob存之前先nvs_open。这里有个细节Wi-Fi凭证属于敏感信息建议加密存储。ESP-IDF支持NVS加密在menuconfig里开启NVS Encryption即可但要注意加密后密钥管理要自己处理好丢了密钥数据就解不开了。5. 常见问题排查与实战避坑指南5.1 编译与初始化阶段的典型报错蓝牙开发最让人头疼的就是编译和初始化阶段的报错。我整理了几个高频问题和解决方法报错信息原因解决方法undefined reference to esp_bt_controller_init没开蓝牙controllermenuconfig里勾选Bluetooth controllerBluetooth controller not initialized初始化顺序错先init controller再enableNVS not initialized没初始化NVS在蓝牙初始化前调用nvs_flash_initGATT server register failed服务UUID重复或格式错检查UUID长度和格式Advertising data too long广播包超31字节精简广播数据用扫描响应包初始化顺序这个坑我踩过好几次。正确的顺序是NVS - controller mem release - controller init - controller enable - bluedroid init - bluedroid enable - 注册回调。任何一步顺序错了都会报错而且报错信息往往不直接指向根因得靠经验判断。5.2 连接不稳定与数据丢包排查连接不稳定是BLE开发里最烦人的问题表现是手机连上后频繁断开或者Notify数据丢包。排查思路分三步第一步看信号强度RSSI。RSSI低于-80dBm时连接就不稳了低于-90dBm基本连不上。如果RSSI太低检查天线匹配电路或者把设备靠近手机测试。ESP32模组的天线布局对信号影响很大PCB天线周围不能铺铜这点在画板时要特别注意。第二步看连接参数。连接间隔太短会导致射频冲突太长会导致响应慢。我一般设30ms到50ms之间兼顾响应和稳定。从机延迟Slave Latency设0保证每次连接事件都响应。监督超时Supervision Timeout设4s到6s太短容易误判断连。第三步看数据长度。BLE默认MTU是23字节实际可用20字节。要传更长数据需要协商MTU。ESP-IDF里用esp_ble_gatt_set_local_mtu设置本地MTU手机端也要发起MTU协商。协商成功后MTU能到247字节甚至512字节。但要注意MTU协商是双方的事设备端设了手机端不配合也没用。我一般设247兼容性和效率平衡得比较好。5.3 手机兼容性与配对问题不同手机对BLE的兼容性差异很大尤其是安卓阵营。我遇到过华为手机能连、小米连不上的情况排查后发现是广播包里的设备名太长小米的扫描过滤把它截断了。后来把设备名缩短到8个字符以内问题解决。配对Pairing是另一个坑。BLE配对分Just Works、Passkey、OOB等方式。Just Works最简单不需要输入密码但安全性低Passkey需要输入6位数字安全性高但体验差。配网场景一般用Just Works就够了因为配网过程本身是短时的而且Wi-Fi密码传输可以再加一层加密。iOS和安卓的配对行为也不同。iOS配对后会缓存配对信息下次连接不需要重新配对安卓有些版本每次连接都要重新配对。如果设备端没处理好配对信息存储就会出现安卓连不上、iOS能连的情况。解决办法是在NVS里存好配对信息并在ESP_GAP_BLE_AUTH_CMPL_EVT事件里正确处理配对完成逻辑。注意调试BLE时手机的蓝牙缓存是个大坑。有时候代码改了但手机连上还是旧行为就是因为手机缓存了GATT数据库。解决办法是在手机设置里“忽略此设备”再重新扫描或者改一下设备名强制手机重新发现。5.4 低功耗优化与实战建议如果项目是电池供电的BLE的低功耗优化就很重要。几个关键点广播间隔从100ms调到500ms甚至1s功耗能降一半以上连接间隔在满足响应要求的前提下尽量调大发射功率默认是0dBm如果通信距离近可以降到-12dBm甚至更低休眠策略不通信时让ESP32进入Light Sleep用GPIO或定时器唤醒我做过一个蓝牙温湿度计用CR2032纽扣电池供电优化前只能跑一周优化后能跑三个月。关键就是把广播间隔调到1s连接间隔调到100ms发射功率降到-6dBm不通信时进Light Sleep。当然Light Sleep下蓝牙连接会断需要重新广播这个取舍要看具体场景。最后分享一个调试技巧用nRF Connect这个手机App来调试BLE。它能显示所有服务、特征、描述符还能读写和订阅通知比你自己写App调试快得多。我每次做BLE项目都是先用nRF Connect把GATT结构调通再写手机端代码效率高很多。ESP-IDF的例程里也有ble_gatt_server的完整示例在examples/bluetooth/bluedroid/ble目录下建议先跑通例程再改自己的代码别从零开始写容易漏掉细节。