ESP32 BLE Server开发实战:从GATT原理到手机通信完整教程

ESP32 BLE Server开发实战:从GATT原理到手机通信完整教程 做嵌入式开发这几年蓝牙低功耗BLE是我绕不开的一块。最近在弄ESP32的项目第一步就是搭一个最简单的BLE Server让手机能连上开发板、能读数据、能写指令。这篇文章是“ESP32 BLE学习”系列的第一篇不整虚的直接把我调通的完整流程写出来从协议栈里的主从关系讲起到服务端代码怎么写再到用手机App实测、排坑一次说清楚。适合刚拿到ESP32、想入门BLE的同学也适合已经能跑官方例程但对GATT角色和回调机制一知半解的人。1. 理清概念BLE Server在蓝牙体系里到底是什么1.1 主从关系与服务端/客户端很多人一看到Server就想到网络里的服务器容易懵。BLE里的Server不是跑在云端的服务而是负责“提供数据”的一方。BLE协议栈里有两套称呼一个叫Central中心设备和Peripheral外围设备对应到连接角色另一个叫GATT Client和GATT Server对应到数据角色。这两套称呼容易混我习惯这么记手机是Central负责主动扫描、发起连接ESP32是Peripheral负责广播自己、等着被连。数据层面的关系则是Server拥有数据Client发起读写请求。现实中绝大多数场景是手机当ClientESP32当Server因为设备端采集传感器数据之后要“喂”给手机看。所以这篇先做Server理解数据是怎么组织、怎么被读走的后面再做Client就顺了。1.2 为什么要用GATT这种“笨”方式BLE的数据交互不是像串口那样随心所欲地发字符串它强制按照GATTGeneric Attribute Profile的层级结构来组织。GATT顶层是ServerServer里有ServiceService里有CharacteristicCharacteristic才真正存数据。打个比方Server是酒店Service是楼层Characteristic是具体房间号。手机想拿数据必须知道房间号也就是UUID。初学的时候会觉得这层结构很绕但理解了就明白它的好处BLE设备之间互不熟悉通过标准化结构手机拿到Service和Characteristic列表后就能知道设备“能干什么”。比如心率计Service的UUID是固定的手机一发现就知道它是心率计。ESP32做通用Server常见做法是自定义UUID再让手机App按约定去读写。1.3 方案选型Arduino框架还是ESP-IDFESP32官方主推ESP-IDF功能全、可控性强但门槛确实高。做学习型Demo我更推荐Arduino框架。原因很直接BLE相关的库封装得够好BLEDevice.h里几个API就能把Server搭起来省去大量GATT底层配置。ESP-IDF适合做量产级、需要精细控制功耗和协议栈参数的场景等你把Arduino版本跑通了再迁移也不迟。库方面默认用的是Bluedroid协议栈RAM占用偏高但兼容性好。如果项目对内存和功耗敏感可以换NimBLE库API类似轻量不少。这篇先用默认Bluedroid后面我再单独写NimBLE的对比。2. 开发环境准备从零搭起工程2.1 硬件与工具清单硬件方面普通ESP32开发板就行ESP32-WROOM-32系列最经典ESP32-S3、C3也能跑代码基本通用。需要注意一点不同模组的Flash大小不一样烧录时选错分区表会导致重启循环后面会细说。除了开发板准备一个稳定的Micro-USB数据线很多“连不上串口”的问题其实是线的问题只能充电不能传数据的数据线坑了不少人。软件方面Arduino IDE 2.x或者1.8.x都行PlatformIO也可以。我个人习惯用Arduino IDE快速验证配好之后点一下上传按钮就能烧录对学习阶段最友好。手机端装一个nRF Connect或者LightBlue用来扫描、连接、读写BLE设备这两个App免费且稳定后面测试全靠它们。2.2 Arduino环境安装ESP32支持包Arduino IDE默认不支持ESP32需要手动添加开发板管理器地址。在“文件 - 首选项 - 附加开发板管理器网址”里填入Espressif官方提供的JSON地址然后到开发板管理器搜索esp32安装最新版。国内网络访问官方服务器偶尔慢可以换成乐鑫的镜像源速度快很多。装完以后在“工具 - 开发板”里能看到一堆ESP32型号。选板子是个关键步骤选错了编译链接可能没问题但烧录后跑不起来。确定板子上的主芯片型号WROOM-32选“ESP32 Dev Module”S3选“ESP32S3 Dev Module”C3选“ESP32C3 Dev Module”不要凭感觉选。2.3 新建工程与底层代码框架Arduino环境下BLE Server的最小代码结构很固定setup()里初始化BLE、创建Server、创建Service和Characteristic、启动广播loop()里做周期性的数据更新或通知发送。很多官方例程把这堆逻辑全堆在setup里初看会懵其实拆开就四步初始化设备、搭数据模型、挂回调函数、开广播。这一步我建议先在Arduino里新建一个空工程保持默认的setup()和loop()把开发板选好、串口波特率设成115200烧录一个空的Blink程序确认环境没问题。环境本身不通就急着写BLE代码出了问题很难判断是代码问题还是环境问题。3. 核心代码实现搭建完整的BLE Server3.1 头文件、宏定义与连接标志我们先写一个具备广播、连接、读特征值、写特征值、通知功能的最小Server。代码不长但每个部分后面都会讲到。#include BLEDevice.h #include BLEServer.h #include BLEUtils.h #include BLE2902.h #define SERVICE_UUID abcd1234-0001-4a5e-8f0a-1f2e3d4c5b6a #define CHARACTERISTIC_UUID abcd1234-0002-4a5e-8f0a-1f2e3d4c5b6a BLEServer* pServer NULL; BLECharacteristic* pCharacteristic NULL; bool deviceConnected false;UUID可以直接用网上在线生成的128位UUID也可以按这个固定格式手写。注意UUID不能随便用全F或者全0尽量用类似我上面这种带随机段的值。未来如果要做iOS平台iOS对重复的、非标准的UUID识别很严格提前养成自定义UUID的习惯能省很多麻烦。3.2 初始化BLE设备与Servervoid setup() { Serial.begin(115200); Serial.println(ESP32 BLE Server 启动); BLEDevice::init(ESP32_Server_Demo); pServer BLEDevice::createServer(); pServer-setCallbacks(new MyServerCallbacks()); }BLEDevice::init传入的是广播名称手机扫描列表里显示的名字。这个名字最长能到多少后面说先记住一个原则不要用中文不要超过20个字节否则容易出奇怪问题。createServer之后可以挂一个Server级回调用来感知手机连接和断开这个回调非常重要因为断开后如果不重新广播手机就再也没法连上设备了。很多初学者只建了Server不设置回调函数结果手机断开一次之后设备就“消失”了必须按复位键才能恢复。这属于典型的设计遗漏。3.3 创建Service和CharacteristicBLEService* pService pServer-createService(SERVICE_UUID); pCharacteristic pService-createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY ); pCharacteristic-setCallbacks(new MyCallbacks()); pCharacteristic-setValue(Hello from ESP32); pCharacteristic-addDescriptor(new BLE2902());Characteristic的属性要靠组合标志位声明。PROPERTY_READ表示手机可以读它PROPERTY_WRITE表示手机可以写它PROPERTY_NOTIFY表示设备可以主动通知手机。这三个是最常用的组合。BLE2902是Client Characteristic Configuration DescriptorCCCD它的作用是让手机订阅Notify。不加这个描述符手机端的“接收通知”开关通常就不出现或者通知消息收不到。这个细节我当年踩过坑代码里少写一行调试了一整晚。3.4 回调函数连接事件与读写事件class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* server) { deviceConnected true; Serial.println(手机已连接); } void onDisconnect(BLEServer* server) { deviceConnected false; Serial.println(手机已断开重新广播); BLEDevice::startAdvertising(); } }; class MyCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic* pCharacteristic) { std::string value pCharacteristic-getValue(); if (value.length() 0) { Serial.print(收到写入数据: ); for (int i 0; i value.length(); i) { Serial.printf(%02X , value[i]); } Serial.println(); } } };回调机制是BLE开发里最容易迷糊的地方。设备的逻辑不是靠轮询而是靠事件触发手机连上了触发onConnect手机断开触发onDisconnect手机往特征值里写数据触发onWrite。你只要在回调里处理业务逻辑就行协议栈会把事件送到这些函数里。不要在回调函数里做耗时的阻塞操作比如延时、复杂计算、串口打印大量日志否则会拖垮协议栈轻则丢包重则系统崩溃。onWrite里通过getValue()拿到的是一个std::string注意它存的不是以\0结尾的C字符串取长度要用length()方法。把它当普通字符串直接转成const char*使用可能读出多余字节。3.5 启动Service与广播配置pService-start(); BLEAdvertising* pAdvertising BLEDevice::getAdvertising(); pAdvertising-addServiceUUID(SERVICE_UUID); pAdvertising-setScanResponse(true); pAdvertising-setMinPreferred(0x06); pAdvertising-setMaxPreferred(0x12); BLEDevice::startAdvertising(); Serial.println(广播已启动等待手机连接...);广播这一块有几个细节值得说。addServiceUUID把Service的UUID塞进广播包手机在扫描阶段就能看到这个设备提供了什么服务不用连上以后再查。setScanResponse(true)表示支持扫描响应可以在不增加广播包负载的情况下多传点数据。setMinPreferred(0x06)和setMaxPreferred(0x12)这两个参数对应广播连接间隔的推荐值单位是1.25毫秒。0x06代表7.5毫秒0x12代表22.5毫秒。间隔越小数据吞吐越高但功耗也越高对电池供电的场景建议把setMaxPreferred调大一点。网上很多教程把第二个写成setMinPreferred(0x12)这其实是笔误正确写法是setMaxPreferred照抄会得到一个不合理的参数配置虽然大多数情况下仍能连上但可能影响连接稳定性。3.6 主循环发送Notify通知void loop() { if (deviceConnected) { pCharacteristic-setValue(update); pCharacteristic-notify(); Serial.println(已发送通知); } delay(2000); }这个循环每两秒给手机推送一次通知典型场景是周期性上报传感器数据。notify()只有在手机订阅了CCCD之后才能真正送出去如果没有addDescriptor(new BLE2902())和手机端的订阅操作这条通知会发出去但手机收不到。实际产品里建议在onWrite里先检查一下CCCD值确认手机确实订阅了再发避免无谓消耗。4. 烧录与手机端实测4.1 编译烧录的关键点在Arduino IDE里编译上传之前确认一下“工具”菜单里的几个选项开发板型号、上传方式和Flash大小。大多数开发板默认的“USB CDC On Boot”保持默认就行不用额外设置。点上传后IDE会先编译再自动进入烧录流程串口输出会提示连接状态。烧录时如果一直报“Connecting...”说明开发板没有自动进入下载模式。这时候按住开发板上的BootIO0按键再点一下板上的复位键或者拔插一次USB线多数情况下就能识别。注意有些开发板丝印上是EN和BOOTBOOT就是下载模式控制脚不需要额外接线路。烧录完成后开发板会自动复位运行打开串口监视器波特率设置成115200能看到“广播已启动”的日志。4.2 用nRF Connect连接与读写测试手机打开nRF Connect允许位置权限Android上扫描BLE需要定位权限不开会扫不到设备主界面会列出周围所有广播设备。找到“ESP32_Server_Demo”这个名字点击Connect按钮。连接成功后进入设备详情页能看到一个ServiceUUID开头是abcd1234-0001。展开它下面就是Characteristic。界面上会显示这个特征值支持的属性读、写、通知。点击Read按钮底部会返回“Hello from ESP32”这就是我们初始化时写入的初始值。试试在nRF Connect的写入框里输入“hello”或者任意十六进制字节点Write此时ESP32的串口监视器会打印“收到写入数据”说明写通道完全打通。再点特征值旁边的“接收通知”按钮回到主界面等几秒能看到设备每两秒推送过来的“update”字符串。4.3 初始化值、写入值与通知值的区别实测过程中会看到同一特征值有“读到的值”“写入的值”“通知收到值”三种形态理解它们的区别很重要。初始值在setValue设置时就存在手机读取时拿到的是这个值写入值是手机通过onWrite写给设备的设备拿到后可以在回调里处理通知值是设备主动推送给手机的需要在手机端订阅后才能收到。实际项目里初始值可以用来做设备信息展示比如固件版本写入值用来接收指令比如开灯、关灯通知值用来上报状态比如温度读数。这三个能力完全可以叠加在同一个特征值上像我示例代码里就同时声明了READ、WRITE、NOTIFY三种属性。4.4 扩展通过Server控制LED把写入功能用在控制上是最直观的验证。在全局定义一个GPIO引脚比如#define LED_PIN 2ESP32 Dev Module板载LED通常是IO2但不同板子不一样以板子背面丝印为准。setup()里初始化引脚为输出模式在onWrite回调里判断收到的数据if (value.length() 0) { if (value[0] 0) { digitalWrite(LED_PIN, LOW); Serial.println(LED OFF); } else if (value[0] 1) { digitalWrite(LED_PIN, HIGH); Serial.println(LED ON); } }重新烧录后用nRF Connect往特征值写“1”板载LED亮起写“0”LED熄灭。这样一个最基础的“手机App控制ESP32”就实现了。不要小看这个Demo很多智能家居设备、远程开关的第一版逻辑都是这么来的只是把手机App换成了专用客户端把特征值换成了多个服务和通道。5. 我踩过的坑常见问题与排查思路5.1 手机扫不到设备先检查权限和广播状态Android手机扫描BLE必须打开定位服务App的定位权限也要允许这是扫描不到设备最常见的原因和ESP32代码无关。代码层面优先确认BLEDevice::startAdvertising()有没有调用很多人只配置了广播但忘记启动设备自然不广播。其次确认广播名称长度BLE广播包可用空间有限名字太长会被截断手机端看到的就是一串乱码甚至直接搜不到。建议把设备名控制在10个字符以内后缀可以加MAC地址后两位来区分多台设备。5.2 连接之后频繁断开多半是供电问题ESP32的BLE射频部分功耗不低连接状态下瞬间电流能达到几百毫安如果是用电脑USB口的弱供电或劣质充电宝供电电压跌落可能导致协议栈复位表现就是连接几秒就断开。排查办法很简单换一根粗短的数据线或者给开发板用独立5V电源供电。软件层面也要注意如果在onRead或onWrite回调里做了超时阻塞操作蓝牙协议栈会因无法及时响应而被对端判定为超时断开。5.3 写数据之后回调不触发检查权限和注册onWrite不触发的原因有两个一是特征值创建时没有声明PROPERTY_WRITE属性二是没有调用pCharacteristic-setCallbacks(...)注册回调。前者导致手机端的写入按钮直接置灰后者导致写了没反应。如果用的是nRF Connect写入写入数据类型也有讲究有时候App默认的UTF-8字符串和ESP32拿到后的字节长度不同调试时优先用HEX格式写入避免编码干扰。5.4 广播间隔、连接间隔对功耗的影响做低功耗应用时广播间隔越长越省电但手机扫描发现的延迟也越大。最简做法是沿用示例里的固定参数实测下来连接都稳定。如果后续要做电池供电可以在setup里设置完广播后调用esp_ble_gap_stop_advertising()等有数据要上报时再重新开启广播配合esp_wifi_set_ps(WIFI_PS_MIN_MODEM)可以明显降低待机功耗。这个系列后面我计划专门写一篇“轻度睡眠与BLE共存”的内容把休眠状态下保持Server不可连接、但能被唤醒后快速重连的方案讲透。5.5 常见错误速查表现象可能原因解决方法烧录报Connecting开发板未进入下载模式按住BOOT再复位或重启IDE和板子串口无输出波特率不匹配确认串口监视器波特率是115200连接后日志无onConnectServer回调未设置确认setCallbacks(new MyServerCallbacks())收不到Notify未添加BLE2902描述符或未订阅添加addDescriptor(new BLE2902())并在App端订阅读到的值乱码字符串长度与字符编码问题用HEX模式读取按字节解析设备断开后无法重连断开后未重新广播在onDisconnect中调用BLEDevice::startAdvertising()WiFi和BLE同时工作时网络卡顿2.4GHz频段共存干扰尽量让WiFi连接5GHz或错开活动时间5.6 不同ESP32型号的差异ESP32-WROOM-32是最成熟的选择BLE和WiFi都能稳定跑。ESP32-S3的BLE能力与S2类似但增加了AI加速和更多GPIO官方对Arduino支持也到位。ESP32-C3是单核RISC-V功耗更低但只支持BLE 5.0部分老代码依赖Bluedroid库的API在C3上默认协议栈已经更换为NimBLE直接编译可能报错需要在menuconfig里切换。如果你手头是C3建议直接学NimBLE的API不要绕弯路。最后再分享一点个人体会调试BLE Server时串口日志是你最忠实的朋友。把每个回调入口都打印一条日志连接、断开、读写、广播启动每个关键节点都留痕迹出问题时一眼就能定位在哪一步。不要靠猜BLE的时序问题没法纯靠肉眼观察判断日志是唯一的线索。等基础Server跑通以后再做多Service、多特征值、OTA升级、功耗优化这些功能都是在同一套结构上加东西底子打牢了后面会顺很多。