ESP32蓝牙开发实战:BLE GATT服务器搭建与Wi-Fi共存指南
2026/9/20 18:46:09 网站建设 项目流程

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的蓝牙协议栈分两种:BluedroidNimBLE。Bluedroid是ESP-IDF默认的完整协议栈,功能全但占Flash和RAM多;NimBLE是轻量级协议栈,占用资源少,适合资源紧张的项目。选哪个?我的经验是:如果你用的是ESP32-WROOM这种4MB Flash的模组,Bluedroid随便用;如果是ESP32-C3这种资源紧的,或者你同时跑Wi-Fi和蓝牙,NimBLE更稳。

配置蓝牙协议栈的路径在menuconfig里:Component config -> Bluetooth -> Bluetooth,然后选BluedroidNimBLE。选完之后还要在下面勾选具体的功能,比如Bluetooth controllerBluedroid 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)。最常用的是客户端特征配置描述符(CCCD,UUID 0x2902),手机通过写这个描述符来开启或关闭Notify。没有CCCD,Notify功能用不了。

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_EVT:Notify或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串口透传的实现要点

经典蓝牙SPP(Serial 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_blobnvs_get_blob,存之前先nvs_open。这里有个细节:Wi-Fi凭证属于敏感信息,建议加密存储。ESP-IDF支持NVS加密,在menuconfig里开启NVS Encryption即可,但要注意加密后密钥管理要自己处理好,丢了密钥数据就解不开了。

5. 常见问题排查与实战避坑指南

5.1 编译与初始化阶段的典型报错

蓝牙开发最让人头疼的就是编译和初始化阶段的报错。我整理了几个高频问题和解决方法:

报错信息原因解决方法
undefined reference to esp_bt_controller_init没开蓝牙controllermenuconfig里勾选Bluetooth controller
Bluetooth controller not initialized初始化顺序错先init controller再enable
NVS not initialized没初始化NVS在蓝牙初始化前调用nvs_flash_init
GATT 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 WorksPasskeyOOB等方式。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目录下,建议先跑通例程再改自己的代码,别从零开始写,容易漏掉细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询