
1. 蓝牙通信在ESP32项目中的定位与整体设计思路1.1 为什么联网篇要单独讲蓝牙很多人做ESP32项目第一反应是连WiFi、接MQTT、上云平台。但实际落地的时候你会发现蓝牙才是那个“最后一米”的通信方案。原因很直接配网阶段设备还没连上路由器你总得有个通道把WiFi的SSID和密码送进去有些场景压根不需要上云比如一个手持的温湿度采集器手机凑过去就能读数据再比如产线上的工装夹具用蓝牙做参数下发比拉一根串口线方便得多。ESP32的蓝牙分两大块经典蓝牙Bluetooth Classic和低功耗蓝牙BLEBluetooth Low Energy。这两个东西虽然都叫蓝牙但协议栈、应用场景、API完全是两套。经典蓝牙适合传音频、传大块数据比如做蓝牙音箱BLE适合小数据量、低功耗、间歇性通信比如传感器读数上报、手机APP控制开关。ESP32同时支持两者但不能同时跑经典蓝牙和BLE这一点在后面会详细说。这一讲的核心目标很明确让你在ESP-IDF VSCode的环境下能把蓝牙跑起来能连上能收发数据。不涉及音频编解码那些复杂的东西聚焦在“连接与通信”这个最基础也最关键的环节。1.2 经典蓝牙与BLE的选型逻辑选哪个取决于你的数据特征和功耗要求。我一般用下面这张表来快速判断对比维度经典蓝牙 (Classic BT)低功耗蓝牙 (BLE)数据速率较高适合音频流较低适合小数据包功耗高持续连接耗电大极低适合电池供电连接方式点对点为主支持广播、一对多手机兼容性安卓好iOS受限安卓iOS都友好ESP-IDF支持Bluedroid协议栈Bluedroid或NimBLE典型场景音频、串口透传传感器、配网、控制如果你做的是手机APP控制ESP32优先选BLE。iOS对经典蓝牙的限制很多除非你走MFi认证否则经典蓝牙在iPhone上基本没法用。BLE就不一样安卓iOS都能扫到、都能连。如果你做的是两块ESP32之间传数据或者ESP32当蓝牙串口模块用经典蓝牙的SPPSerial Port Profile会更顺手因为它模拟的就是串口收发逻辑简单。1.3 协议栈选择Bluedroid还是NimBLEESP-IDF里BLE协议栈有两个选择Bluedroid和NimBLE。Bluedroid是博通的一套完整协议栈功能全经典蓝牙和BLE都支持但占用Flash和RAM比较大。NimBLE是Apache基金会的一个开源BLE协议栈只支持BLE但体积小、功耗低适合资源紧张或者只做BLE的项目。我的经验是如果你只需要BLE用NimBLE编译出来的固件能小几十KBRAM占用也少。如果你需要经典蓝牙或者需要同时兼容一些老设备那就只能用Bluedroid。这一讲的示例代码我会以Bluedroid为主因为它是默认配置开箱即用NimBLE的API虽然类似但有些细节差异新手容易踩坑。注意ESP32的蓝牙和WiFi共用同一个射频前端不能同时全速工作。它们会分时复用所以如果你同时开WiFi和蓝牙吞吐量会下降延迟会增大。这一点在做网关类产品时要特别注意。2. 开发环境确认与蓝牙相关配置2.1 检查ESP-IDF和VSCode环境是否就绪在开始写蓝牙代码之前先确认你的环境没问题。打开VSCode按CtrlShiftP输入ESP-IDF: Show Examples如果能正常弹出示例列表说明插件工作正常。然后在终端里跑一下idf.py --version应该能看到类似ESP-IDF v5.x.x的输出。如果版本低于v4.4建议升级因为蓝牙API在v5之后有一些重要更新。另外确认一下你的开发板型号。ESP32、ESP32-S3、ESP32-C3都支持蓝牙但ESP32-S2没有蓝牙如果你用的是S2这一讲的内容跑不了。C3只支持BLE 5.0不支持经典蓝牙选型时要注意。2.2 menuconfig里必须打开的蓝牙选项环境没问题后新建一个工程或者用idf.py create-project ble_demo创建一个空项目。然后进入配置菜单idf.py menuconfig在Component config→Bluetooth下面有几个关键选项Bluetooth总开关必须启用Bluetooth controller选择控制器模式一般选Bluedroid - Dual-mode或者Bluedroid - BLE onlyBluetooth Host选Bluedroid或NimBLEEnable BLE 4.2 features建议打开支持更大的MTUEnable BLE 5.0 features如果你的芯片支持C3、S3可以打开还有一个容易忽略的地方蓝牙默认使用内部晶振但如果你板子上有32.768kHz的外部晶振可以在Bluetooth→Controller Options里把XTAL frequency改成对应的值这样功耗和稳定性都会更好。配置完保存退出编译一下看看有没有报错idf.py build如果编译通过说明环境没问题可以开始写代码了。2.3 分区表与Flash空间的调整蓝牙协议栈会占用不少Flash空间。默认的Single factory app分区表可能不够用尤其是你同时开了WiFi和蓝牙的时候。建议在menuconfig里把分区表改成Custom partition table CSV然后自己定义一个给app分区至少留1.5MB。我一般用这样的分区表# Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 0x180000,0x180000就是1.5MB够用了。改完分区表记得idf.py fullclean再重新编译不然可能不生效。3. BLE通信的核心实现与代码拆解3.1 BLE的GATT模型用“快递柜”来理解BLE的通信模型核心是GATTGeneric Attribute Profile。很多新手看文档看到Service、Characteristic、Descriptor这些词就晕了。我用一个生活化的类比来解释把BLE设备想象成一个快递柜。这个快递柜就是Service服务比如“温度服务”、“电池服务”。快递柜里有很多格子每个格子就是一个Characteristic特征值。每个格子上贴了标签说明里面放的是什么这个标签就是Descriptor描述符。手机要读数据就是打开某个格子把东西取走这叫Read。手机要写数据就是往格子里放东西这叫Write。如果格子里的东西会变比如温度一直在更新手机可以订阅这个格子一变就收到通知这叫Notify。每个Service和Characteristic都有一个UUID来标识。标准服务有固定的16位UUID比如电池服务是0x180F。自定义服务用128位UUID你自己生成一个就行只要保证唯一。3.2 创建BLE服务的完整代码流程下面是一个完整的BLE服务创建流程我把它拆成几个关键步骤。第一步初始化NVS。蓝牙协议栈需要存储一些配对信息所以必须先初始化NVSesp_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);第二步释放经典蓝牙内存如果你只用BLEESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));这一步很关键因为Bluedroid默认会为经典蓝牙预留内存你不用的话释放掉能省不少RAM。第三步初始化和启用控制器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_ble_gatts_register_callback(gatts_event_handler)); ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));第五步注册应用Profile和Service。这一步是核心决定了你的设备对外暴露什么服务esp_ble_gatts_app_register(0);然后在gatts_event_handler里处理ESP_GATTS_REG_EVT事件创建Serviceesp_gatt_srvc_id_t service_id { .is_primary true, .id.inst_id 0, .id.uuid.len ESP_UUID_LEN_16, .id.uuid.uuid.uuid16 0x00FF, }; esp_ble_gatts_create_service(gatts_if, service_id, 4);这里的0x00FF是我随便选的一个UUID实际项目里建议用128位的自定义UUID避免和标准服务冲突。3.3 特征值的读写与通知配置Service创建好后会在ESP_GATTS_CREATE_EVT事件里返回一个service_handle。拿着这个handle去添加Characteristicesp_bt_uuid_t char_uuid { .len ESP_UUID_LEN_16, .uuid.uuid16 0xFF01, }; esp_gatt_char_prop_t property ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_NOTIFY; esp_ble_gatts_add_char(service_handle, char_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, property, NULL, NULL);property决定了这个特征值支持哪些操作。READ允许手机读WRITE允许手机写NOTIFY允许ESP32主动推送。权限ESP_GATT_PERM_READ和ESP_GATT_PERM_WRITE控制的是访问权限一般设成开放就行需要加密的话再改。添加完Characteristic后还需要给它加一个Descriptor用来启用Notify功能esp_bt_uuid_t descr_uuid { .len ESP_UUID_LEN_16, .uuid.uuid16 ESP_GATT_UUID_CHAR_CLIENT_CONFIG, }; esp_ble_gatts_add_char_descr(service_handle, descr_uuid, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, NULL, NULL);这个Descriptor的UUID是固定的0x2902手机往里面写0x0001就是订阅通知写0x0000就是取消订阅。3.4 处理连接、断开与数据收发事件所有的事件都在gatts_event_handler里处理。几个关键事件ESP_GATTS_CONNECT_EVT手机连上了可以在这里记录conn_idESP_GATTS_DISCONNECT_EVT断开了需要重新开始广播ESP_GATTS_WRITE_EVT手机写了数据从param-write.value里取ESP_GATTS_READ_EVT手机读数据需要调用esp_ble_gatts_send_response回复ESP_GATTS_CONF_EVTNotify的确认事件发送Notify的代码是这样的esp_ble_gatts_send_indicate(gatts_if, conn_id, char_handle, len, data, false);最后一个参数false表示用Notify不需要确认true表示用Indicate需要确认。Notify快但不保证到达Indicate慢但可靠看你的数据重要性选。4. 经典蓝牙SPP串口透传的实现要点4.1 SPP协议的本质把蓝牙当串口用经典蓝牙的SPPSerial Port Profile本质上就是在蓝牙上模拟一条串口。ESP32这边看起来就像在读写UART手机那边看起来就像在读写一个串口设备。这个模型的好处是简单你不需要理解GATT那一套直接收发字节流就行。SPP的初始化流程和BLE类似但用的是esp_spp_系列的API。首先在menuconfig里确保经典蓝牙是启用的然后ESP_ERROR_CHECK(esp_bt_controller_enable(ESP_BT_MODE_BTDM)); ESP_ERROR_CHECK(esp_bluedroid_init()); ESP_ERROR_CHECK(esp_bluedroid_enable()); ESP_ERROR_CHECK(esp_spp_register_callback(spp_callback)); ESP_ERROR_CHECK(esp_spp_init(ESP_SPP_MODE_CB));ESP_SPP_MODE_CB表示用回调模式接收数据还有一种ESP_SPP_MODE_VFS模式可以把SPP当成文件描述符来读写适合移植现有串口代码。4.2 SPP数据收发的回调处理SPP的回调里主要处理这几个事件ESP_SPP_INIT_EVT初始化完成开始广播ESP_SPP_SRV_OPEN_EVT手机连上了ESP_SPP_DATA_IND_EVT收到数据ESP_SPP_WRITE_EVT发送完成ESP_SPP_CLOSE_EVT连接断开收到数据时case ESP_SPP_DATA_IND_EVT: ESP_LOGI(TAG, 收到数据长度%d, param-data_ind.len); esp_spp_write(param-data_ind.handle, param-data_ind.len, param-data_ind.data); break;上面这段代码是回显收到什么就发回什么。实际项目里你可以把数据解析后做相应处理。发送数据用esp_spp_write第一个参数是连接句柄第二个是长度第三个是数据指针。4.3 经典蓝牙与BLE的共存问题前面提过ESP32的射频前端是共享的。如果你同时开经典蓝牙和BLE协议栈会分时调度吞吐量和响应速度都会受影响。更麻烦的是Bluedroid在双模模式下内存占用很大如果你的应用逻辑比较复杂可能会遇到内存不足的问题。我的建议是能用BLE就用BLE实在需要经典蓝牙就只开经典蓝牙。如果非要双模把menuconfig里的Bluetooth controller设成Bluedroid - Dual-mode然后做好心理准备调试时间会翻倍。提示ESP32-C3和ESP32-S3只支持BLE不支持经典蓝牙。选芯片的时候一定要看清楚规格书。5. 常见问题排查与实战避坑经验5.1 手机搜不到设备怎么办这是最常见的问题。排查顺序如下第一确认广播是否真的开始了。在ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT事件里调用esp_ble_gap_start_advertising如果这个事件没触发广播就不会开始。第二检查广播数据是否合法。广播包最大31字节超了会被截断。用esp_ble_gap_config_adv_data配置的时候注意adv_data和scan_rsp_data加起来不要超。第三确认手机蓝牙权限开了。安卓6.0以上需要定位权限才能扫描BLE设备这个坑很多人踩。第四如果用的是自定义UUID确认手机APP的扫描过滤条件没写错。有些APP默认只显示已知服务自定义服务需要手动添加过滤。5.2 连接后立刻断开是什么原因连接后马上断开通常是这几个原因MTU协商失败手机请求的MTU超过了ESP32支持的最大值。在menuconfig里把Bluetooth→Bluedroid Options→Max MTU size调大一般设成517。配对信息冲突之前配对过NVS里存了旧密钥。执行idf.py erase-flash清空Flash再试。电源不稳蓝牙发射瞬间电流会冲到100mA以上如果USB供电不足或者LDO带载能力不够会导致复位。换一个好点的USB口或者加个大电容。5.3 数据收发异常排查表现象可能原因解决方法写数据没反应Characteristic没设WRITE属性检查property配置读数据返回空没调用send_response在READ_EVT里回复Notify收不到没订阅或没加CCCD检查0x2902描述符数据截断MTU太小协商更大MTU或分包中文乱码编码不一致统一用UTF-8频繁断连射频干扰或供电不足换信道、加电容5.4 几个我踩过的坑第一个坑忘记释放经典蓝牙内存。只用BLE的时候如果不调用esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)会白白浪费几十KB的RAM在内存紧张的项目里可能是致命的。第二个坑在回调里做耗时操作。蓝牙回调是在协议栈的任务里执行的如果你在里面做Flash读写、网络请求这种耗时操作会阻塞整个协议栈导致连接超时。正确的做法是把数据拷贝出来用队列发给另一个任务处理。第三个坑广播数据没更新。改了广播内容后必须重新调用esp_ble_gap_config_adv_data和esp_ble_gap_start_advertising只改数组不重新配置是不生效的。第四个坑iOS的连接间隔。iOS对BLE的连接参数很挑剔如果连接间隔设得太短iOS会拒绝。建议把min_interval设成0x00067.5ms以上max_interval设成0x001020ms左右。6. 蓝牙与WiFi共存时的性能调优6.1 共存机制的基本原理ESP32的WiFi和蓝牙共用一根天线靠分时复用来工作。协议栈内部有一个仲裁器会在WiFi和蓝牙之间快速切换。这个切换非常快人感觉不到但会带来两个后果一是吞吐量下降二是延迟增加。实测数据单独跑WiFiTCP吞吐能到20Mbps以上同时开蓝牙后WiFi吞吐可能降到10Mbps左右蓝牙的响应延迟也会从几毫秒增加到几十毫秒。6.2 menuconfig里的共存参数调整在menuconfig→Component config→WiFi→WiFi and Bluetooth coexistence里有几个关键参数Software coexistence软件共存模式一般选这个Coexistence scheme选Prefer WiFi还是Prefer Bluetooth看你哪个是主业务Coexistence priority可以给WiFi或蓝牙更高的优先级如果蓝牙是控制通道要求低延迟就选Prefer Bluetooth。如果WiFi在传大文件就选Prefer WiFi。6.3 实际项目中的取舍策略我的经验是不要指望WiFi和蓝牙同时满速跑。设计产品的时候就要想清楚哪个是主通道哪个是辅助通道。比如一个智能家居网关WiFi是主通道负责和云平台通信蓝牙是配网通道只在配网时用。那就在配网完成后把蓝牙关掉或者降低蓝牙的广播频率把射频资源让给WiFi。再比如一个蓝牙Mesh灯控蓝牙是主通道WiFi只是偶尔用来OTA升级。那就把WiFi的省电模式打开减少它对蓝牙的干扰。具体到代码里可以用esp_bt_controller_disable()和esp_bt_controller_enable()来动态开关蓝牙用esp_wifi_stop()和esp_wifi_start()来动态开关WiFi。这样在不需要某个功能的时候彻底关掉既省电又减少干扰。7. 从Demo到产品的进阶建议7.1 安全性配对与加密Demo里一般用的是开放权限谁都能连。实际产品必须加配对和加密。BLE的加密流程是手机发起配对请求双方交换密钥之后所有通信都加密。在ESP-IDF里设置ESP_GATT_PERM_READ_ENCRYPTED和ESP_GATT_PERM_WRITE_ENCRYPTED就能强制加密访问。然后在ESP_GAP_BLE_SEC_REQ_EVT事件里调用esp_ble_gap_security_rsp接受配对请求。配对方式有几种Just Works无确认、Passkey输密码、Numeric Comparison数字比对。Just Works最简单但安全性最低适合对安全要求不高的场景。Passkey需要设备有显示或输入能力ESP32一般用固定密码或者随机生成后通过串口打印出来。7.2 低功耗优化如果做电池供电的产品BLE的低功耗优化很重要。几个关键点增大连接间隔连接间隔越大射频开启时间越短越省电。但间隔太大响应会慢一般设100ms到500ms之间。使用Slave Latency允许从设备跳过若干个连接事件不响应进一步省电。关闭不必要的广播连接建立后如果不需要被其他设备发现就停掉广播。降低发射功率esp_ble_tx_power_set可以调发射功率近距离通信时调低能省不少电。实测下来优化好的BLE从设备用CR2032纽扣电池能跑几个月甚至一年。没优化的话几天就没电了。7.3 OTA升级与蓝牙的结合产品卖出去之后要升级固件OTA是必须的。ESP-IDF自带OTA功能可以通过WiFi下载新固件。但如果设备还没配网或者WiFi信号不好蓝牙OTA就是一个备选方案。蓝牙OTA的原理很简单手机把固件文件通过BLE传给ESP32ESP32收到后写入OTA分区然后重启切换。难点在于BLE的吞吐量有限传一个1MB的固件可能要几分钟。所以一般用BLE做小版本更新大版本还是走WiFi。实现上可以用一个自定义的Characteristic来接收固件数据每收到一包就调用esp_ota_write写入。全部收完后调用esp_ota_end和esp_ota_set_boot_partition完成切换。7.4 调试工具与抓包分析调试蓝牙问题光看串口日志有时候不够。推荐几个工具nRF Connect手机APP能扫描、连接、读写Characteristic看服务结构非常直观。Wireshark nRF Sniffer抓蓝牙空口包能看到广播、连接、数据交互的每一个细节。配合nRF52840 Dongle使用。ESP-IDF的蓝牙日志在menuconfig里把蓝牙日志级别调到Verbose能看到协议栈内部的详细流程。我一般先用nRF Connect确认服务结构对不对再用Wireshark抓包看数据交互有没有异常。这两个工具配合基本能定位90%以上的蓝牙问题。7.5 代码结构建议最后说一下代码组织。蓝牙相关的代码不要全堆在main.c里建议拆成几个模块ble_init.c负责协议栈初始化和事件注册ble_service.c负责Service和Characteristic的创建ble_handler.c负责事件回调的具体处理app_main.c只负责调用初始化和启动应用逻辑这样拆的好处是换协议栈比如从Bluedroid换到NimBLE的时候只需要改ble_init.c和ble_service.c业务逻辑不用动。而且调试的时候哪个模块出问题一目了然。我在实际项目里还习惯加一个ble_debug.c专门放一些调试用的函数比如打印当前连接状态、打印服务列表、模拟发送数据等。这些函数在发布版本里用宏关掉不影响固件大小。蓝牙这个东西入门容易精通难。协议栈的细节很多文档又分散很多时候得靠抓包和试错。但只要把GATT模型理解透了把连接、读写、通知这三个基本操作跑通剩下的就是查文档和积累经验的事了。ESP-IDF的蓝牙API虽然有点繁琐但胜在稳定官方示例也全照着改改就能用。