
1. 先别急着连手机搞清BLE Server到底是个什么角色很多朋友拿到ESP32第一件事就是想用手机蓝牙连上去控制个灯、传个数据、显示个传感器数值。这个方向完全正确但不少人卡在了最前面手机能搜到ESP32但连上去之后APP里什么都没有或者根本不知道接下来该干啥。问题出在哪儿大多数教程一上来就甩一堆代码让新手照着抄可是BLEBluetooth Low Energy低功耗蓝牙的工作方式跟普通串口、Wi-Fi完全不一样它的核心是“服务-特征”这套数据模型。如果不懂这个模型写出来的Server不是连不上就是连上了也读不到数据。我在这个系列的第一篇里就是要把这层窗户纸捅破。本文的目标很简单用ESP32建立一个BLE Server让手机能发现它、能连上它、能读它里面的数据、能往它里面写数据。这个Server一旦跑通后面做传感器上报、遥控开关、LED控制、甚至OTA升级都是在这套骨架上长出来的。在动手之前我建议你把下面这句话刻在脑子里BLE世界里Server不是“服务器”那个Server而是“提供数据的一方”。手机扫描到它、主动连接它这个手机就是Client客户端ESP32就是Server服务端。但跟HTTP那种“客户端发请求、服务端给响应”的模式不同BLE的Server更像是一个“货架”它在那边摆着里面分了好几个区域每个区域放着不同类型的数据Client连上来之后自己去对应的区域拿数据或者往里面放数据。搞懂这个比喻后面理解GATT、Service、Characteristic就顺了。2. 前置概念三件套GAP、GATT、Characteristic用30分钟搞懂2.1 GAP是“广播吆喝”GATT是“货架规则”BLE协议栈里有两个最容易混淆的缩写GAPGeneric Access Profile通用访问规范和GATTGeneric Attribute Profile通用属性规范。我见过太多人把这两个混为一谈结果看代码的时候一头雾水。用大白话解释GAP管的是“如何被找到”。它负责广播、扫描、连接建立这些事。你手机扫到ESP32的名字那个就是GAP层的工作。ESP32要让自己“可被发现”就是靠GAP层不停地往外发广播包。GATT管的是“连上之后怎么交互”。它定义了数据如何组织、如何读、如何写、如何通知。你的ESP32建了几个“货架区域”Service每个区域里放了什么“货物”Characteristic这些全是GATT层的范畴。所以你在代码里会看到两类API一类是初始化广播相关的比如BLEDevice::init、pServer-getAdvertising()这些是GAP相关另一类是创建服务和特征的比如createService、createCharacteristic这些是GATT相关。搞清楚了这个分工看SDK文档和代码时就有方向感了。2.2 Service和Characteristic就是“货架区域”和“货物格子”GATT的数据模型是层级结构的从上到下依次是Profile规范一个完整的功能定义通常由一个或多个Service组成。其实你在代码里很少直接写Profile它更像是一个逻辑上的包装。Service服务一类功能的集合比如“电量服务”“温度服务”。每个Service有一个唯一的UUID标识。Characteristic特征Service下面实际存放数据的单元每个特征也有自己的UUID。特征下面还有Descriptor描述符用来描述这个特征的一些附加信息比如是否允许通知等。举个例子假设你做的是一个温度计BLE设备你可能会建一个名为“温度服务”的ServiceUUID假设为0x1809这是标准的环境感应服务下面放一个“温度数值”CharacteristicUUID为0x2A6E。手机连上来后读取这个特征就能拿到温度数据。这里面有个新手最容易懵的点UUID到底从哪儿来UUID是128位的看起来像这样12345678-1234-1234-1234-123456789abc。BLE蓝牙技术联盟Bluetooth SIG规定了一些标准的UUID比如电量服务是0x180F。但自己做实验的话没必要去申请标准UUID直接随机编一个唯一的128位UUID就行。几乎所有示例代码都会让你用一个自定义的128位UUID比如我在下文会用6e400001-b5a3-f393-e0a9-e50e24dcca9e这个看起来很随机其实我可以告诉你很多ESP32 BLE示例喜欢用Nordic UART Service的UUID因为它配套APP调试特别方便。当然你也可以自己随便编一个只要保证唯一性即可。2.3 特征的五种“操作权限”直接决定了你Server会不会被吐槽每个Characteristic有属性Properties它决定了这个特征能被Client做什么操作READ客户端可以读这个特征的值。比如读电量。WRITE客户端可以写这个特征的值。比如APP发送一个指令。WRITE_NO_RESPONSE客户端写入时不需要设备回ACK速度更快适合高频控制场景。NOTIFY服务器主动推送数据给客户端客户端不需要不断轮询。比如温度变了就主动上报一次。INDICATE类似NOTIFY但客户端收到后会回确认可靠性更高但速度略慢。你在createCharacteristic时传的参数其实就是这些权限的位组合。新手犯的典型错误是只设置了NOTIFY却期望客户端能READ到当前值结果客户端读了个空。3. 开发环境与选型Arduino还是ESP-IDF我这个版本为什么这么写3.1 两种开发框架的取舍ESP32官方主推ESP-IDF功能全、可控性强、性能也好。但如果你是刚入门BLE我建议先用Arduino框架跑通流程。原因很直接Arduino社区里BLE库的封装更友好API抽象层级更高出错时搜索引擎能立刻找到解决方案。具体到你手上的开发板无论你是ESP32 DevKitC、NodeMCU-32S还是带屏幕的ESP32-S3Arduino框架下的操作基本一致。本文所有实验我基于的是官方ESP32 DevKitC搭载ESP32-WROOM-32模组Arduino core版本为2.0.17。如果你用的是ESP32-S3或C3代码完全不改即可编译通过只是串口打印时芯片型号差异会影响一些引脚定义但有OLED/屏幕的板子暂时先别管外设纯BLE逻辑是通用的。有人会问这都2025年了Arduino core 3.x都出来了怎么还在用2.0.x因为3.x版本对BLE库的改动较大虽然性能有提升但网上很多老帖子和旧库兼容性还没完全跟上。对新手而言稳定压倒一切2.0.x依旧是很稳妥的选择。3.2 在Arduino中安装ESP32开发板支持这一步应该很多人已经做过了但考虑到这是系列第一篇我还是快速带一遍重点是那个国内经常遇到的坑下载速度慢、失败率高。打开Arduino IDE我用的是2.x版本在“文件 - 首选项 - 附加开发板管理器网址”里填入https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json如果这个raw地址访问不了经常抽风可以在GitHub上搜“arduino-esp32 package_esp32_index.json”这类关键字找一个国内可访问的镜像地址一般是gitee上的用镜像短链换上即可。然后在“开发板管理器”里搜索“esp32”选中“esp32 by Espressif Systems”安装2.0.17版本。整个过程如果顺利大概5到10分钟。装完后选择你对应的开发板型号比如“ESP32 Dev Module”然后端口选对就能编译烧录了。3.3 BLE库基于什么理解你依赖的底层Arduino框架下ESP32的BLE库底层封装的是供应商官方提供的Bluedroid协议栈。在Arduino core中对应的库核心文件是BLEDevice.h、BLEServer.h、BLEService.h、BLECharacteristic.h等。你用到的BLEDevice::init()会初始化整个BLE协议栈。这里有一个重要的细节ESP32 BLE官网SDK在Arduino中的封装名字叫Bluedroid但它实际是基于修改版的Bluedroid NimBLE协议栈两种可选模式。Arduino core 2.0.x默认使用Bluedroid而如果要更低功耗、更小内存占用可以切换到NimBLE模式在tools菜单里有个“Partition Scheme/Bluetooth Mode”的选项或者编译宏中定义CONFIG_BT_NIMBLE_ENABLED。刚入门阶段先别折腾NimBLE就用默认的Bluedroid稳。4. 建立BLE Server的完整代码拆解一个能读能写的温度上报器4.1 代码目标与整体逻辑我先描述一下我们最终要实现的Server长什么样这样你在写代码时脑子里有画面ESP32开机后开始广播设备名为“ESP32_BLE_Server_Demo”。广播的消息里包含一个自定义Service的UUID。手机用nRF Connect或微信小程序BLE调试工具可以扫描到这个设备。连接成功后能看到一个Service里面有两个Characteristic一个只读特征0x0001用于上报温度模拟数据每2秒更新一次。一个可写特征0x0002用于接收外部指令比如翻转LED、修改设备名等我们先打印出来。Client写入数据后Server这边能打印出来并且可以立刻把收到的内容原样回写echo到一个通知特征里让Client实时看到回显。这个结构非常接近真实项目比那种只建立一个特征、完全不能交互的“哑巴Server”有用多了。4.2 完整源码下面这份代码是经过我实测、能直接编译通过的。请你在Arduino环境新建一个工程全部选粘贴进去#include BLEDevice.h #include BLEServer.h #include BLEUtils.h #include BLE2902.h // 自定义服务UUID #define SERVICE_UUID 6e400001-b5a3-f393-e0a9-e50e24dcca9e // 温度特征UUID只读通知 #define CHARACTERISTIC_UUID_TEMP 6e400002-b5a3-f393-e0a9-e50e24dcca9e // 写特征UUID可写 #define CHARACTERISTIC_UUID_WRITE 6e400003-b5a3-f393-e0a9-e50e24dcca9e BLECharacteristic *pTempChar; BLECharacteristic *pWriteChar; bool deviceConnected false; uint32_t lastUpdate 0; float simulatedTemp 25.6; // 连接状态回调 class MyServerCallbacks : public BLEServerCallbacks { void onConnect(BLEServer* pServer) { deviceConnected true; Serial.println(客户端已连接); } void onDisconnect(BLEServer* pServer) { deviceConnected false; Serial.println(客户端已断开); // 关键重新开始广播让手机能再次搜到 pServer-getAdvertising()-start(); } }; // 可写特征的回调当手机发数据过来时触发 class MyWriteCallbacks : 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((char)value[i]); } Serial.println(); // 这里可以解析指令做对应操作比如控制LED } } }; void setup() { Serial.begin(115200); Serial.println(ESP32 BLE Server 启动中...); // 1. 初始化BLE设备 BLEDevice::init(ESP32_BLE_Server_Demo); // 2. 创建服务端 BLEServer *pServer BLEDevice::createServer(); pServer-setCallbacks(new MyServerCallbacks()); // 3. 创建服务 BLEService *pService pServer-createService(SERVICE_UUID); // 4. 创建温度特征可读 可通知 pTempChar pService-createCharacteristic( CHARACTERISTIC_UUID_TEMP, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY ); // 添加描述符用于iOS上接收Notify的必要条件 pTempChar-addDescriptor(new BLE2902()); pTempChar-setValue(25.6); // 5. 创建写特征可写 pWriteChar pService-createCharacteristic( CHARACTERISTIC_UUID_WRITE, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR ); pWriteChar-setCallbacks(new MyWriteCallbacks()); // 6. 启动服务 pService-start(); // 7. 开始广播 BLEAdvertising *pAdvertising pServer-getAdvertising(); pAdvertising-start(); Serial.println(BLE Server 已启动等待手机连接...); } void loop() { // 每2秒更新一次模拟温度值并通过Notify主动推送 if (deviceConnected millis() - lastUpdate 2000) { lastUpdate millis(); simulatedTemp random(-10, 10) / 10.0; // 模拟温度波动 char buf[8]; snprintf(buf, sizeof(buf), %.1f, simulatedTemp); pTempChar-setValue(buf); pTempChar-notify(); Serial.printf(温度上报: %s\\n, buf); } delay(10); }4.3 代码逐段逻辑解读初学者看这段代码最懵的可能是“为什么一个简单的Server需要这么多类回调”我刚学的时候也这样觉得Adruino的Wire库、WiFi库都比这个直观。但BLE本身就是事件驱动模型你不注册回调设备收到数据、连接变化时根本不知道通知谁。MyServerCallbacks里两个函数的职责很清楚onConnect和onDisconnect。注意onDisconnect里我调用了pServer-getAdvertising()-start()这是很多人会漏掉的一个细节。默认情况下BLE设备在断开连接后不会自动恢复广播如果不重启广播你会发现手机再也搜不到这个设备了除非重新上电。把这一行加上就能解决“一次连接后设备消失”的经典问题。MyWriteCallbacks里只重写了onWrite所以当手机往那个可写特征里发数据时这个函数就会被触发。getValue()拿到的是一串字节流所以我在打印时逐个转成字符这样能正确处理中文编码和ASCII指令。至于指令解析目前我什么都没做只是把数据打印出来。这一步是接口预留后面做“LED开关控制”时直接在这个回调里判断数据内容就行。setup里的七个步骤顺序非常讲究尤其是第3、4、5、6步很多人把start放在createCharacteristic之后以为创建完就自动启动了实际上必须在所有特征创建完成后显式调用start()服务才会真正暴露出来。否则你手机连上了也看不到任何Service。loop里的模拟温度上报也非常关键它展示了Notify的实际用途。如果做的是传感器采集你不会想让手机一直轮询“温度多少”而是希望设备温度一变就主动告诉手机。notify()做的事就是主动推送前提是这个特征开启了PROPERTY_NOTIFY并且客户端也订阅了这个通知即手机端的CCCD描述符被置为启用。这就是为什么我在代码里加了pTempChar-addDescriptor(new BLE2902())——BLE2902是客户端特征配置描述符CCCD没有它iOS上你压根收不到Notify通知Android上也经常抽风这是很多人踩了没看出来的坑。4.4 编译烧录前的几个检查点代码你直接复制粘贴后先别急着点右上角那个“向右的箭头”烧录。按下面的清单确认一下工具 - 开发板选择“ESP32 Dev Module”端口选择你的USB转串口设备。在Windows上通常是COM3或COM4macOS上通常是/dev/cu.usbserial-0001这类。如果烧录时报“A fatal error occurred: Failed to connect to ESP32: Timed out waiting for packet header”说明芯片没有进入下载模式。按住开发板上的BOOT键然后按一下EN键复位再松开BOOT键然后再点上传。或者买一个带一键下载功能的开发板更省心。串口监视器波特率设置为115200否则看到的是一堆乱码。5. 用手机实机验证nRF Connect全流程操作指南代码烧录成功后打开手机上的nRF ConnectNordic出品iOS/Android都有免费或者“BLE调试助手”亲测两个都行但我自己更习惯nRF Connect因为信息展示更详细。5.1 第一步扫描打开APP点击扫描。正常情况下几秒内就能看到一个“ESP32_BLE_Server_Demo”设备。如果你没看到先确认ESP32是否在正常工作串口监视器是否打了日志。如果日志显示“BLE Server 已启动”但手机扫不到大概率是你周围蓝牙设备太多稍微等一下或者重启一下手机蓝牙再扫。点开这个设备能看到两个信息很有用它广播的Service UUID就是我们在代码里定义的那个6e400001-...以及是否支持连接。5.2 第二步连接并浏览GATT表点击“CONNECT”连接成功后你会进入一个“GATT”标签页类似于一个文件浏览器里面会列出这个设备拥有的所有Service。应该能看到Generic Access0x1800标准服务包含设备名、外观等。Generic Attribute0x1801标准服务包含服务声明的属性。我们自定义的那个ServiceUUID开头是6e400001那个。点开自定义Service下面有两个Characteristic6e400002: 可读、可通知的“温度特征”。6e400003: 可写、可无响应写入的“写特征”。如果这两个特征都看到了说明你的Server核心逻辑已经正常。如果只看到标准的两个Service却看不到自定义Service请检查pService-start()有没有被正确调用。5.3 第三步读特征、写特征、订阅通知在温度特征那一行点右边的“向上箭头”图标可以读取当前值应该能看到类似“25.6”这样的字符串。如果显示0x00或者空大概率是你在createCharacteristic后没有调用setValue赋初始值。然后点那个“向下箭头”图标或者长按特征找到“订阅通知”开启Notify订阅。接下来每隔2秒这个特征就会自动更新一次数值手机端会弹出通知或刷新数值。这是我推荐你亲自感受一下的过程它演示了“服务器主动推送”与“客户端频繁轮询”的天壤之别。在可写特征那一行点“向下箭头”或进入写模式输入一串字符比如LED_ON然后发送。返回ESP32的串口监视器你应该能看到“收到写入: LED_ON”。这说明双向通道已经打通了。6. 新手常见的六个坑以及对应的排查思路6.1 只有一个特征能读另一个读不出来出现这个情况先别急着怀疑代码看一下你在创建第二个特征时是不是给了PROPERTY_READ如果只给了PROPERTY_WRITE那手机APP里没有读按钮很正常。另外如果两个特征的UUID写重复了手机端GATT表可能合并显示或只显示一个检查UUID粘贴是否有误。6.2 手机能连上但一读就崩溃或断开这大概率是因为你正在读取的特征没有设置初始值。当setValue从未被调用而客户端直接来读时Bluedroid会返回空数据甚至触发异常。解决方法是每个声明了PROPERTY_READ的特征在创建后都立即调用一次setValue赋予默认值。6.3 iOS收不到NotifyAndroid正常同样的问题我在项目里遇到过特此再强调一遍特征声明里必须有PROPERTY_NOTIFY并且必须addDescriptor(new BLE2902())。iOS对Attribute表的要求比Android严格得多没有2902描述符它不会把订阅请求发下来。6.4 设备连接一次后消失重启开发板才能再次发现这就是我在代码里强调的onDisconnect里调用start()的原因。蓝牙断开后广播不会自动恢复。你必须在回调里手动重启广播。除此之外还有个细节在onDisconnect回调里调用start()前最好加一个小的延时比如delay(500)避免断开一瞬间广播频率过高导致协议栈报错。我实测直接调用没出过问题但保守起见加一点延时更稳。6.5 Notify推送了一段时间后手机端不再更新常见原因有两个一是手机锁屏后进入低功耗模式BLE外设的Notification被系统挂起此时屏幕上不显示但连接还在二是Notify频率太快超过了下层的发送缓冲。我在项目里如果碰上温度、电量这类低速数据200ms间隔完全没问题但如果要做音频流或航空遥测那种高频数据就该考虑把特征属性改成INDICATE或者用BLE的L2CAP通道了。新手先别碰那些把频率控制在100ms以上。6.6 设备名称不生效扫描到的是ESP32默认名你在BLEDevice::init(ESP32_BLE_Server_Demo)里传的名字理论上就是广播中的设备名。但实际有种情况手机缓存了旧设备名同一MAC地址改名后手机展示的还是旧的。解决方式是重启手机蓝牙或者清掉手机里该设备的蓝牙配对/缓存信息。另一种情况是代码先用BLEDevice::init后又手动设置了广播数据里的setName覆盖了默认名。7. 你还能在这个Server骨架上加什么这篇文章只是BLE系列的第一篇但骨架打好了扩展是很自然的控制LED在onWrite回调里解析字符串比如判断LED_ON就digitalWrite(2, HIGH)LED_OFF就拉低顺便把当前状态写到通知特征里给手机一个状态回显。上报真实传感器把simulatedTemp替换成DS18B20或SHT30的读取值你相当于已经有了一个自己的环境监测站。修改扫描广播间隔默认的广播间隔对功耗不友好如果设备靠电池供电可以调大广播间隔在BLEAdvertising里设置setMinInterval、setMaxInterval。功耗这块我在下一篇打算单独聊。作为BLE Client被别的设备连接也就是反过来ESP32主动去扫描并连接另一个BLE设备手机还是其他传感器这属于“BLE Client”的玩法和本文的Server正好组成一个完整的双向链路。后续这个系列会写。8. 写在最后的实操体会这篇东西花了我不少时间去反复测试尤其是Notify部分之前我也被iOS收不到通知坑过一次后来才意识到是CCCD描述符的问题。写出来就是帮大家省掉这些弯路。最后分享一个调试小技巧串口日志里把esp_log_level_set(*, ESP_LOG_VERBOSE);加上或者在tools - Core Debug Level选“Verbose”这样协议栈会输出BLUEDROID层的详细日志包活广播包内容、连接事件、GATT错误码。出问题时那一堆日志比网上找半天答案更直接。但这个只在调试阶段开因为日志会占用大量串口输出会拖慢程序运行实际产品千万别开。下一篇文章我打算写BLE连接过程中的安全机制与配对或者讲一下如何把ESP32改造成BLE Client。如果你想先看哪个可以留言。但就算留言没人回我也会按我自己的项目进度往下写毕竟这个系列首先是给我自己做笔记用的能帮到别人是额外的奖赏。