ESP32 BLE Server从零搭建:手机与设备双向通信实战 做嵌入式这几年经常会碰到一类问题板子跑起来了、串口打印正常、传感器也能读但一到手机App就不知道怎么把数据弄过去。ESP32自带的蓝牙BLE算是最快的一条路很多项目第一步就是让手机能连上你的设备、能读到数据。这篇文章记录的是我当时从零开始搭ESP32 BLE server的过程写得很细适合刚接触BLE、想在Arduino环境下快速跑通一个最小可用server的开发者。核心就三件事把ESP32变成一个可以被手机扫描到的蓝牙设备、建立服务和特征值、让手机能读写数据。你会看到一次完整的“从一个空工程到一个能用的BLE server”是怎么回事。不光是贴代码还包括背后为什么要这么配置、哪些参数是关键、踩过哪些坑。1. 动手前先搞清楚BLE的server到底是个什么角色1.1 先分清楚BLE和经典蓝牙很多人一听到蓝牙脑子里是那种连音箱、传文件的经典蓝牙。ESP32的蓝牙模块比较特殊它同时支持经典蓝牙和BLE低功耗蓝牙但IoT项目里我们绝大多数时候用的是BLE。BLE和经典蓝牙的关键区别用一个生活化的类比比较好理解。经典蓝牙更像一个“电话”拨通之后持续占线两边的数据一直保持活跃连接适合音频这种持续传输的场景。而BLE更像一个“公告栏加信箱”平时设备不主动发数据只在有人来看的时候才响应或者偶尔往信箱里塞一张小纸条通知对方。所以BLE的功耗可以低很多一节纽扣电池撑几个月甚至一年这正好对上了很多传感器节点、遥控器、智能家居配网的需求。从协议层看BLE把“谁主动、谁被动”分得很清楚Central中心设备通常是手机、平板、电脑负责扫描和发起连接。Peripheral外围设备就是广播自己、等待被连接的设备比如传感器、手环、我们的ESP32。GATT Server / GATT Client这是逻辑角色GATT Server负责提供数据GATT Client负责读取或写入数据。在绝大多数场景下ESP32扮演的是“Peripheral GATT Server”手机扮演“Central GATT Client”。所以标题里说的“ESP32 BLE server”准确说是“一个基于BLE GATT协议的server端设备”。它不是互联网里的那种HTTP server而是“数据提供方”。1.2 GATT把数据组织成了“目录树”BLE通信不像UART那样直接扔一串字节就行它需要先把数据组织成GATT规定的结构。GATT的层级从高到低是这样Profile协议档案一个应用场景的完整定义通常对应一个产品功能。Service服务一组相关数据的集合一个设备可以有多个服务。Characteristic特征值真正存放数据的地方一个服务下可以挂多个特征值。Descriptor描述符描述特征值的附加信息比如通知开关。用文件系统来类比Service像是“文件夹”Characteristic像是“文件”Descriptor像是“文件的属性说明”。手机连接设备后其实就是在这个目录树里找文件夹、找文件、读内容、往里写内容。每个Service和Characteristic都有一个唯一的标识叫UUIDUniversally Unique Identifier。UUID有16位和128位两种16位的是蓝牙联盟规定的标准服务比如电量服务是0x180F、设备信息服务是0x180A。实际项目里更多用自己生成的128位UUID这样能避免和其他设备的服务撞车。我当时第一次看这段概念最迷惑的就是“server到底往哪监听”。后来想明白了BLE server不需要像TCP server那样去accept一个连接你只要把服务和特征值注册好、把广播开起来剩下的连接管理、数据收发协议栈都会帮你处理你只需要在回调函数里响应事件就可以了。1.3 这个最小工程要完成的目标为了不让学习曲线太陡我把第一个BLE server的验收标准定得很简单ESP32上电后能被手机扫描到设备名是自定义的。手机能成功连接ESP32连接过程稳定不掉线。设备上有一个自定义ServiceService下有一个自定义Characteristic。手机能读到特征值里的默认数据。手机能往特征值里写数据ESP32能收到并打印出来。手机断开后ESP32能重新进入广播状态等待下一次连接。这六条跑通你对BLE server的核心机制就算入门了。后面什么按键控制灯、温湿度上报、传感器数据推送都是在这个基础上加业务逻辑而已。2. 开发环境与硬件准备2.1 器材清单与避坑建议这个工程需要的硬件非常少ESP32开发板一块。市面上的ESP32开发板绝大多数都可以我用的是经典的DevKitC样式38Pin、带CH340串口芯片。NodeMCU-32S、ESP32-S3的板子也基本适用S3需要注意部分引脚的默认状态。一条Micro USB或Type-C数据线。注意一定要确认是“数据线”而不是“充电线”。我遇到过不少次折腾半天编译、烧录都报错最后发现是手头那根线只能充电不能传数据。一台电脑装好串口驱动。Windows一般用CH340或CP2102驱动macOS和Linux大多免驱但也偶尔需要手动装。一台手机安装nRF Connect或者LightBlue这类BLE调试App。如果你用的是ESP32-C3或者ESP32-S2这种单核芯片BLE功能也是有的代码基本通用只是板卡型号别选错就行。网上总有人问esp32 c5功耗怎么样、能不能跑BLE其实对于学习阶段不用太纠结低功耗先把功能调通后面做电池供电再考虑功耗优化。2.2 Arduino IDE里把ESP32板卡装好我建议新手用Arduino IDE打底。虽然ESP-IDF功能更完整、更接近底层但Arduino框架封装程度高代码量少特别适合快速理解BLE的工作流程。在Arduino IDE里装ESP32支持的步骤打开Arduino IDE进入“文件”-“首选项”-“附加开发板管理器网址”里填上https://espressif.github.io/arduino-esp32/package_esp32_index.json打开“工具”-“开发板”-“开发板管理器”搜索esp32找到“esp32 by Espressif Systems”这个包点击安装。安装时间取决于网络情况如果卡在下载阶段可以考虑换网络环境或者用国内镜像源。装完之后在“工具”-“开发板”里就能看到ESP32系列的一堆型号。这一步是整个环境里最容易出问题的地方很多人卡在开发板管理器里搜不到“esp32”十有八九是附加开发板管理器网址填错了或者网址访问不通。2.3 选型思考为什么先从Arduino框架而不是ESP-IDF我知道有人会争论ESP-IDF才是ESP32的原生开发框架为什么推荐用Arduino我的看法是这样的。如果你的目标是“搞懂BLE的GATT机制、快速验证一个产品原型、或者做一个简单的小工具”Arduino框架完全够用而且BLE库封装得很好几个API就能把服务建起来。如果你之后要做超低功耗、自定义蓝牙协议栈、复杂多任务调度那再切换到ESP-IDF也不晚而且有了Arduino阶段的概念切过去会顺手很多。Arduino的BLE库其实就是对ESP-IDF蓝牙组件做了一层封装。你在Arduino里写的BLEDevice、BLEServer、BLEService底层都是调用的ESP-IDF的API。所以学的时候别以为这是两套完全割裂的东西理解了这个关系后面看ESP-IDF文档会轻松很多。3. 代码逐段解析一个最小BLE server先直接看完整代码我建议你先把下面这段拷到Arduino IDE里烧录跑通再回来看逐行拆解。#include BLEDevice.h #include BLEServer.h #include BLEUtils.h #include BLE2902.h #define SERVICE_UUID 4fafc201-1fb5-459e-8fcc-c5c9c331914b #define CHARACTERISTIC_UUID beb5483e-36e1-4688-b7f5-ea07361b26a8 bool deviceConnected false; class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* server) { deviceConnected true; Serial.println(设备已连接); } void onDisconnect(BLEServer* server) { deviceConnected false; Serial.println(设备已断开重新开始广播); BLEDevice::startAdvertising(); } }; class MyCharacteristicCallbacks : 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.print(value[i]); } Serial.println(); pCharacteristic-setValue(esp32 got it); } } }; void setup() { Serial.begin(115200); Serial.println(ESP32 BLE Server 启动); BLEDevice::init(ESP32_BLE_Server); BLEServer* pServer BLEDevice::createServer(); pServer-setCallbacks(new MyServerCallbacks()); BLEService* pService pServer-createService(SERVICE_UUID); BLECharacteristic* pCharacteristic pService-createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY ); pCharacteristic-setValue(Hello from ESP32); pCharacteristic-setCallbacks(new MyCharacteristicCallbacks()); pCharacteristic-addDescriptor(new BLE2902()); pService-start(); BLEAdvertising* pAdvertising BLEDevice::getAdvertising(); pAdvertising-addServiceUUID(SERVICE_UUID); pAdvertising-setScanResponse(true); pAdvertising-setMinPreferred(0x06); pAdvertising-setMinPreferred(0x12); BLEDevice::startAdvertising(); Serial.println(广播已启动等待连接...); } void loop() { delay(1000); }这段代码目前看起来有点小问题注意看我在广播配置里连续写了两次setMinPreferred这里实际上是个笔误。如果你的代码照抄那个setMinPreferred(0x12)应该改成setMaxPreferred(0x12)才对。不过这也侧面说明一个问题很多人抄代码不检查结果就卡在莫名其妙的地方。下面逐段拆。3.1 引库与UUID定义#include BLEDevice.h #include BLEServer.h #include BLEUtils.h #include BLE2902.h四个头文件前三个是BLE基础功能的库第四个是“客户端特征配置描述符”的支持具体作用后面讲NOTIFY通知时再说。#define SERVICE_UUID 4fafc201-1fb5-459e-8fcc-c5c9c331914b #define CHARACTERISTIC_UUID beb5483e-36e1-4688-b7f5-ea07361b26a8这两个UUID是我随手生成的你可以用在线UUID生成器换一组自己的。注意一定要是128位UUID不要随便写一个什么123456这种16位值标准16位UUID是蓝牙联盟分配好的你随便用反而可能和别人的服务撞在一起。这个工程里我用deviceConnected这个全局布尔变量来记录当前是否有客户端连接后面如果你的逻辑需要在连接状态切换时做处理比如连上了就停止传感器采集、断开后再恢复这个标志位就有用了。3.2 连接状态回调设备断了要重新广播class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* server) { deviceConnected true; Serial.println(设备已连接); } void onDisconnect(BLEServer* server) { deviceConnected false; Serial.println(设备已断开重新开始广播); BLEDevice::startAdvertising(); } };这是整个server里最容易忽略但最关键的一个环节。BLE的机制是这样的设备上电后通过广播让周围手机看到自己手机连接后广播就自动停了。如果连接断开你不会自动恢复广播需要主动重新调用BLEDevice::startAdvertising()否则手机永远扫描不到你的板子。很多新手遇到“第一次能连上断开后就再也找不到了”基本就是忘了在onDisconnect里重启广播。这两个回调属于“服务器级回调”和后面特征值的读写回调是两码事别搞混。3.3 创建服务与特征值BLEServer* pServer BLEDevice::createServer(); pServer-setCallbacks(new MyServerCallbacks()); BLEService* pService pServer-createService(SERVICE_UUID);流程是固定的先初始化BLE设备再创建server再创建service。BLECharacteristic* pCharacteristic pService-createCharacteristic( CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_NOTIFY );创建特征值时第二个参数是关键。它决定了这个特征值支持哪些操作PROPERTY_READ手机可以读取这个特征值的内容。PROPERTY_WRITE手机可以往里面写数据。PROPERTY_NOTIFY设备可以主动通知手机数据更新。这三个属性可以按需组合。比如你只想做一个“数据只读”的设备那就只保留READ如果你想遥控开关那就要WRITE如果你想做传感器主动上报那NOTIFY基本必选。setValue设置的是特征值的默认值也就是手机连接上来、还没写任何数据时读到的就是这个内容。pCharacteristic-setCallbacks(new MyCharacteristicCallbacks());这一行注册了特征值回调用来接收手机的写入事件。写入回调类里我做了两件事打印收到的字节然后把特征值内容改成了esp32 got it。注意这里改的是服务器内存里的值手机下次再来读读到的就是新内容。pCharacteristic-addDescriptor(new BLE2902());这行代码在特征值下挂了一个“客户端特征配置描述符”。有同学会问我不打算用NOTIFY是不是就不用加了如果你的特征值属性里有NOTIFY这个描述符就必须加。因为手机要接收通知必须先在这个描述符里写一个”使能通知“的开关值你没这个描述符手机端根本找不到开启通知的入口。3.4 广播配置和启动BLEAdvertising* pAdvertising BLEDevice::getAdvertising(); pAdvertising-addServiceUUID(SERVICE_UUID); pAdvertising-setScanResponse(true); pAdvertising-setMinPreferred(0x06); pAdvertising-setMaxPreferred(0x12); BLEDevice::startAdvertising();addServiceUUID的作用是把你的服务UUID放进广播包里。这样手机在扫描阶段就能看到“这个设备提供了哪些服务”很多调试App不需要连接就能直接显示服务UUID列表。setScanResponse(true)是开启扫描响应。广播包里的内容有限扫描响应可以额外再多放一些数据比如设备名称、连接参数等。setMinPreferred(0x06)和setMaxPreferred(0x12)是广播时间间隔的偏好设置单位是1.25毫秒。0x06即7.5ms0x12即22.5ms。数值越小广播越频繁手机越容易扫到但功耗越高。学习阶段保持默认就行等之后做低功耗优化再细调。有一点需要注意广播间隔只是“偏好”手机端不一定完全按这个来它的作用是作为连接请求时的一个参考参数。4. 烧录与真机验证手机连上才算跑通4.1 烧录环节的几个细节代码写好后在“工具”-“开发板”里选择你手上的板子型号。如果拿不准可以先看板子上的丝印或者查一下主控芯片型号选“ESP32 Dev Module”基本都能兼容。烧录前我把串口波特率调成115200这样运行日志和Arduino串口监视器设置一致不会看到乱码。实际烧录时会有一个自动擦写和自动复位的动作ESP32的EN使能引脚和BOOT引脚都接了自动下载电路正常情况下点一下Arduino IDE的上传按钮板子会自动进入下载模式不需要手动按键。我实测下来很稳。但有个别开发板自动下载电路不标准这时候就得在板子上找到BOOT按键等日志出现“Connecting...”时按住不放然后再点按一下EN键松开BOOT键就能进入烧录状态。这个“玄学”步骤现在遇到得少了但真要遇到也别慌方法就是这个。烧录成功之后打开Arduino IDE的串口监视器波特率设115200正常情况下会看到ESP32 BLE Server 启动 广播已启动等待连接...看到这两行说明你的ESP32已经在广播了。这时如果serial monitor里反复重启、打印乱码大概率是供电不稳定或者复位电路有问题换一根短一点的数据线、换一个USB口往往能解决。4.2 用nRF Connect做“手术级”检查手机装好nRF Connect打开App进入Scanner页面开始扫描。你应该很快就能看到名为“ESP32_BLE_Server”的设备广播信息里还能看到服务UUID4fafc201-1fb5-459e-8fcc-c5c9c331914b。点击这个设备App会进入连接页面。连接成功后底部会列出这个设备的所有服务。你能看到两个默认服务Generic Access、Generic Attribute、Device Information这类由蓝牙协议栈自动添加的服务它们是BLE协议标准的组成部分属于正常现象。我们自定义的那个服务显示的就是我们刚才写入的UUID。展开我们的自定义服务里面能看到一个CharacteristicUUID也和我们定义的一致。此时可以做一个最核心的验证点击这个Characteristic右侧的“读取”图标正常情况下会返回ASCII格式的Hello from ESP32。然后测试写入。在nRF Connect里选择“写入”模式输入一段文本比如12345点发送。回到串口监视器应该能看到收到写入数据: 12345说明ESP32成功收到了手机发来的数据双向通信已经打通。接着再读一次特征值你会发现返回内容变成了esp32 got it。这就证明了“写操作不仅能收数据还能改变特征值里存放的内容”。关于NOTIFY我们需要先在App里找到特征值下方的“开启通知”开关实际上就是往BLE2902描述符里写入启用值打开之后如果之后ESP32端调用pCharacteristic-setValue()和pCharacteristic-notify()App里就能实时收到推送。这个最小工程里没有做主动通知的周期性任务但测试通知开关是能打开的这就说明描述符配置没问题。4.3 对照日志观察连接状态连接、断开、重连这些事件在串口监视器里都有对应打印。我实测下来连接成功后打印“设备已连接”断开后打印“设备已断开重新开始广播”。然后手机上不需要重新进扫描页就可能看到一个重新发布的广播包因为ESP32已经在后台自己重新开始广播了。这里有一个真实的经验断开重连往往比首次连接更考验代码。如果onDisconnect里漏了startAdvertising首次连接测试没问题但一旦断开重连手机就再也扫描不到设备。所以我后来写BLE工程都会把重连逻辑当成一个独立验收项专门测一次“断开之后能不能再次被扫到”。5. 常见问题与排查思路我把实际调试中遇到过的几类典型问题整理成了速查表方便大家按图索骥现象可能原因排查与解决编译报错找不到BLEDevice.h板卡包没装好重新检查开发板管理器是否安装了esp32包确认选择了esp32开发板烧录失败提示串口被占用串口监视器没关、驱动没装、线是充电线关闭串口监视器、重插USB线、换数据线烧录卡在“Connecting...”自动下载电路异常按住BOOT按键再按一下EN键再松开BOOT手机扫描不到设备广播没启动、设备名不对、广播间隔太大确认调用过startAdvertising串口日志应有打印连接成功但马上断开蓝牙缓存异常关闭手机蓝牙再打开或重启ESP32找不到自定义服务UUID广播包里没包含服务UUID检查是否执行了addServiceUUID读不到任何数据特征值没有设置默认值检查setValue是否执行写数据后没有反应特征值没有WRITE属性检查createCharacteristic的属性参数重启后配置丢失代码没有重新执行初始化这是正常现象BLE配置不带持久化重置即恢复5.1 编译和烧录阶段的坑BLEDevice.h not found这种编译错误大概率不是代码问题而是板卡包环境问题。很多人以为装好了esp32板卡包就完事但实际上如果选了错误的开发板型号库文件搜索路径不对一样会报找不到头文件。我的经验是先重新选一次开发板确认是“ESP32 Dev Module”这类通用型号再试一次编译。烧录阶段最常见的就是“串口被占用”或者“连接不上”。一条经验是不要同时开着串口监视器和烧录操作很多IDE在烧录前会自动关闭串口监视器但如果你手动开着就很容易冲突。另一个常见原因是接线不牢固ESP32开发板插得松接触不良导致烧录中断。5.2 广播和连接阶段的坑扫描不到设备时第一步不是怀疑代码而是确认串口日志。如果日志里已经打印了“广播已启动”那代码层面基本没问题问题可能出在手机蓝牙的缓存、广播间隔设置过大、或者天线信号差。我见过最隐蔽的问题是某些安卓手机在连接过一次带有特定服务UUID的设备后如果服务端改了代码、改了名字手机端仍然会按旧缓存去搜索导致扫描结果异常。这时候在手机系统设置里“清除蓝牙缓存”或“忽略此设备”往往就能解决。iOS在这方面倒是相对干净没有那么多花活。5.3 数据读写阶段的异常如果手机连上了、服务也看到了但读不到数据有两个方向要检查一是特征值属性是否包含READ二是是否设置了默认值。只定义特征值不setValue一般默认数据是空手机读取时自然要么没有内容、要么返回长度为零。写入功能不响应最常见的原因是属性里没加PROPERTY_WRITE。人很容易盯着代码逻辑看半天结果发现是属性枚举漏写了一个。还有一种是手机端App选了“写入请求”还是“写入命令”的区别这个和处理机制有关但我们在Arduino封装的回调里都能收到通知差别不大遇到没反应时试试换一个写入类型。6. 这个server能扩展出什么能跑通上面的最小工程你已经把BLE通信的“骨架”搭起来了。剩下的业务逻辑几乎都是往这个骨架上填肉。如果你想做一个小灯控制思路就是给特征值加一个PROPERTY_WRITE属性在onWrite回调里解析收到的字节比如1开灯、0关灯然后控制GPIO输出电平。这也解释了为什么很多蓝牙控制灯的教程第一步都是教你建立server。如果你想做温湿度上报思路就是ESP32定时读取传感器数据然后调用特征值的setValue和notify主动推送给手机。我自己做过一个环境监测节点就是这么干的ESP32加一个SHT30传感器通过BLE把温湿度广播给手机App手机端收到通知后刷新页面。整个工程最核心的部分其实就是你刚才跑通的那个server。如果你想做手机App配网那就在这个server里加一个专门用于接收WiFi信息的Characteristic手机把SSID和密码写进来ESP32收到后断开蓝牙连接、再去连WiFi。很多智能家居设备第一次使用时的“配网”流程底层原理就是BLE配网或蓝牙配网。再往后你还可以研究ESP32的light sleep、deep sleep模式下如何保持BLE广播这和低功耗场景直接相关。ESP32作为BLE central去连接其他传感器这和server正好相反但机制类似。如果你有ESP32接入米家mesh这类需求那就开始涉及网关协议和Mesh组网BLE server是理解这些复杂协议的良好起点。就我个人体会而言先别急着把功能做得特别花哨能把最小server稳定跑通、能解释清楚每个API在做什么BLE这块就算是真正上路了。之后换ESP-IDF写还是加各种特性都会顺畅很多。