外观
2.8 蓝牙:BLE 与特征值
这节要搞懂:蓝牙怎么组织数据,什么是服务和特征值。 需要基础:2.1
本节是必修
天气时钟没有按键,闹钟靠手机蓝牙或 MQTT 来设置,所以 BLE 是核心功能之一。
一、蓝牙也是外设
蓝牙(Bluetooth)是一种短距离无线通信技术。在 ESP32-S3 上,射频电路和蓝牙控制器直接集成在芯片内部,属于片上外设。
它和你已经学过的 I2C、SPI 是同一类东西 —— 都是通信外设,都通过 API 控制:
| I2C / SPI | 蓝牙 | |
|---|---|---|
| 是什么 | 通信外设 | 通信外设 |
| 在哪 | 片上 | 片上 |
| 靠什么传 | 电平信号,要接线 | 无线电波,不用线 |
| 传多远 | 几十厘米 | 约 10 米 |
| 谁发起 | 主机 | 双方都可以 |
区别只在于"传输介质":一个走导线,一个走空气。
二、经典蓝牙 vs BLE
蓝牙分两套完全不同的技术,别搞混:
| 经典蓝牙(BR/EDR) | BLE(低功耗蓝牙) | |
|---|---|---|
| 全称 | Bluetooth Classic | Bluetooth Low Energy |
| 功耗 | 高 | 极低,纽扣电池能撑一年 |
| 速率 | 快(1-3 Mbps) | 慢(约 0.1-1 Mbps) |
| 适合传 | 音频、大文件 | 少量数据:温度、开关状态 |
| 典型设备 | 蓝牙耳机、音箱 | 手环、传感器、智能锁 |
ESP32-S3 只有 BLE,没有经典蓝牙
这一点必须说清楚:ESP32-S3 支持的是 BLE 5.0,不支持经典蓝牙。
也就是说 —— S3 做不了蓝牙音箱、连不了蓝牙耳机。要用经典蓝牙得选老款 ESP32(原版 ESP32 两种都支持)。
好消息是物联网设备传的都是"温度 26.5℃"这种几个字节的小数据,BLE 完全够用,而且省电得多。下面说的"蓝牙"都指 BLE。
三、两个角色:谁连谁
Central(主机 / 中心设备):主动扫描并发起连接的一方,通常是手机。
Peripheral(从机 / 外围设备):不停广播自己的存在、等着被连接的一方,通常是你的设备。
手机(Central) ESP32(Peripheral)
│ │
│ ◄── ① 我在这儿!(广播) ────────│ 不停喊
│ │
│ ── ② 我要连你 ─────────────────►│
│ │
│ ◄══ ③ 连上了,开始收发数据 ════►│我们的 ESP32 一般当 Peripheral(从机)。
四、广播:让手机能发现你
广播(Advertising)是 Peripheral 周期性向外发送小数据包的行为,用来宣告"我存在、我叫什么、我能提供什么服务"。
打个比方:广播就像在人群里举着牌子喊"我是小明,我这有温度数据"。手机扫描就是在人群里看牌子。
广播包很小(最多 31 字节),一般只放:
- 设备名称(比如
"Clock-1234") - 主要服务的 UUID
- 有时候还塞一点数据(比如电量)
名字别太长
31 字节要塞好几样东西,设备名超过十几个字符就放不下了,手机上会显示成"未知设备"。
五、GATT:BLE 怎么组织数据 ⭐
这一节是本课重点。 连上之后,数据不是随便发的,而是按一套固定结构组织。
GATT(Generic Attribute Profile,通用属性配置文件)规定了 BLE 设备的数据组织方式:数据被分成服务(Service),每个服务下面有若干特征值(Characteristic),每个特征值还可以带描述符(Descriptor)。
用文件夹来理解:
📁 设备
│
├── 📁 电池服务 (Service)
│ └── 📄 电量 (Characteristic) = 87
│
└── 📁 环境服务 (Service)
├── 📄 温度 (Characteristic) = 26.5
│ └── 🏷 单位说明 (Descriptor) = "℃"
└── 📄 湿度 (Characteristic) = 62.0| 概念 | 相当于 | 说明 |
|---|---|---|
| 服务 Service | 文件夹 | 把相关的数据归到一组 |
| 特征值 Characteristic | 文件夹里的一份数据 | ⭐ 真正读写的就是它 |
| 描述符 Descriptor | 贴在数据上的标签 | 补充说明,比如单位、可读名称 |
记住这一句
你和 BLE 设备之间所有的数据交换,都是在读写"特征值"。
服务只是用来分组的容器,描述符只是附加说明。
六、UUID:每个东西的身份证号
UUID(Universally Unique Identifier)是服务和特征值的唯一标识。手机就是靠它找到"我要读的是哪一个数据"。
分两种:
| 类型 | 长度 | 谁定的 | 例子 |
|---|---|---|---|
| 16 位 | 短 | 蓝牙官方标准 | 0x180F = 电池服务0x2A19 = 电量特征值 |
| 128 位 | 长 | 你自己随便定 | 4fafc201-1fb5-459e-8fcc-c5c9c331914b |
该用哪种
- 做标准功能(电池电量、心率、温度)→ 用官方 16 位 UUID,手机上的通用 App 直接就能认出来
- 做自己的私有功能(设置闹钟)→ 用 128 位自定义 UUID,网上搜"UUID 生成器"随便生成一个
新手自己玩,用 128 位自定义的最省事,不用去查标准表。
七、特征值的四种属性 ⭐
一个特征值能被怎么操作,由它的**属性(Properties)**决定。这是设计 BLE 设备时最需要想清楚的:
| 属性 | 谁发起 | 干什么 | 什么时候用 |
|---|---|---|---|
| Read | 手机 | 手机主动来读一次 | 查询当前温度、查设备型号 |
| Write | 手机 | 手机写数据进来 | 设置闹钟、开关灯 |
| Notify | 设备 | 设备主动推给手机,不用手机回应 | ⭐ 温度变了立刻推,最常用 |
| Indicate | 设备 | 设备主动推,且要手机回应确认 | 重要数据,不能丢 |
一个特征值可以同时具备多个属性,比如"温度"通常是 Read + Notify:手机既能主动查,设备也能主动推。
Notify 为什么这么重要
不用 Notify 的话,手机只能每秒去问一次"温度变了吗?变了吗?" —— 又费电又慢。
有了 Notify,设备发现温度变了才推一次,手机平时可以歇着。这就是 BLE 省电的关键。
Notify 需要手机先"订阅"
设备不能上来就推 —— 手机得先说"我要订阅这个特征值",设备才能开始推送。
这个订阅开关就存在一个特殊的描述符里 —— 叫 CCCD(Client Characteristic Configuration Descriptor),UUID 固定是 0x2902。
手机点"订阅",实际做的事就是往 CCCD 里写两个字节:
写入 0x0001 → 开启 Notify
写入 0x0002 → 开启 Indicate
写入 0x0000 → 取消订阅本课程用的 Bluedroid 协议栈里,这个描述符要你自己加、写入也要你自己处理(下一节的代码里就是 handle_cccd_write() 干的事)。
记住一件事:手机没订阅时推了也没人收,而且不会报错。
八、一次完整的流程
① ESP32 开机 → 开始广播 "Clock-1234"
↓
② 手机扫描 → 发现它 → 点击连接
↓
③ 连接成功 → 手机查看它有哪些服务和特征值
↓
④ 手机订阅"温度"特征值的 Notify
↓
⑤ 温度变化 → ESP32 主动推送新值 → 手机界面更新
↓
⑥ 手机往"闹钟"特征值 Write 一个 07:30 → ESP32 收到并保存九、ESP32-S3 上怎么写
ESP-IDF 提供两套蓝牙协议栈,功能上都能做 BLE:
| Bluedroid | NimBLE | |
|---|---|---|
| 占内存 | 多 | 少一半 |
| 支持 | 经典蓝牙 + BLE(S3 上只有 BLE 能用) | 只支持 BLE |
| 风格 | 事件回调,一个 switch 处理所有事 | 结构体数组定义 GATT 表 |
| 官方例子 | bluetooth/bluedroid/ble/gatt_server | bluetooth/nimble/bleprph |
本节的入门例子用 Bluedroid,因为它的写法更直白 —— 每发生一件事(连上了、被读了、被写了)就回调你一次,照着 switch 一条条加就行,不用先理解一整张声明式的表。
第三章的天气时钟用的是 NimBLE ⭐
两章用的不是同一套协议栈,这是有意为之:
| 用哪套 | 为什么 | |
|---|---|---|
| 本节的入门例子 | Bluedroid | 事件一条条回调,跟着流程走就能看懂 |
| 第三章的天气时钟 | NimBLE | 省一半内存 —— 那个工程还要同时跑 WiFi、MQTT、HTTP 服务器和屏幕,内存很紧张 |
但这一节学的东西一点都不会白学。 换协议栈换的只是函数名,概念是完全通用的:
| 概念 | 换到 NimBLE 还成立吗 |
|---|---|
| 广播 / Peripheral / Central | ✅ 一模一样 |
| Service → Characteristic → Descriptor | ✅ 一模一样 |
| UUID、Read / Write / Notify | ✅ 一模一样 |
| CCCD、必须先订阅才能收到推送 | ✅ 一模一样 |
| MTU 协商、超长数据要分包 | ✅ 一模一样 |
| 具体函数名、GATT 怎么声明 | ❌ 写法不同 |
先用 Bluedroid 把上面这些概念摸清楚,到第三章再看 NimBLE 的写法,你会发现只是"同一件事换了个说法"。
两个回调,各管一摊
Bluedroid 的代码骨架就是两个回调函数:
| 回调 | 管什么 | 典型事件 |
|---|---|---|
| GAP 回调 | 连接之前的事:广播参数设好了没、广播数据设好了没、广播启动了没 | EXT_ADV_SET_PARAMS_COMPLETE / EXT_ADV_START_COMPLETE |
| GATTS 回调 | 连接之后的事:建服务、加特征值、被读、被写、MTU 变了、断开了 | REG / CREATE / ADD_CHAR / READ / WRITE / CONNECT |
c
esp_ble_gap_register_callback(gap_event_handler); // 广播相关
esp_ble_gatts_register_callback(gatts_event_handler); // 数据相关服务和特征值是"一步步建"出来的 ⭐
NimBLE 是一次性写一张表,Bluedroid 是链式的 —— 每一步建完会回调你,你在回调里建下一步:
注册 App ──► ESP_GATTS_REG_EVT
│ 在这里:设名字、配广播、建服务
▼
建服务完成 ──► ESP_GATTS_CREATE_EVT
│ 在这里:启动服务、加特征值
▼
加特征值完成 ──► ESP_GATTS_ADD_CHAR_EVT
│ 在这里:记下 char_handle、加 CCCD 描述符
▼
加描述符完成 ──► ESP_GATTS_ADD_CHAR_DESCR_EVT
│ 在这里:记下 cccd_handle
▼
广播中,等手机来连为什么要这样一环扣一环:每一步都是异步的 —— 你调用 esp_ble_gatts_create_service() 只是"提交了请求",什么时候建好、建出来的 handle 是多少,要等协议栈回调告诉你。
handle 是什么,为什么要存下来
c
static uint16_t service_handle;
static uint16_t char_handle;
static uint16_t cccd_handle;handle 是协议栈给每个属性分配的编号,相当于门牌号。
手机写数据进来时,你只知道"有人写了 handle=42 这个属性",得靠事先存下来的编号判断它写的是哪一个:
c
if (param->write.handle == char_handle) {
handle_write(...); // 写的是数据
} else if (param->write.handle == cccd_handle) {
handle_cccd_write(...); // 写的是订阅开关
}编号不是固定值,每次跑可能不一样,所以必须在回调里当场记下来,不能写死。
广播用的是 BLE 5.0 的"扩展广播"
本课程的例子用的是 esp_ble_gap_ext_adv_* 这一套 —— 扩展广播(Extended Advertising),BLE 5.0 加的新功能。
| 传统广播 | 扩展广播 | |
|---|---|---|
| 单包容量 | 31 字节 | 最大 1650 字节 |
| 兼容性 | 所有手机都支持 | 需要手机也支持 BLE 5.0 |
| 用起来 | 一个函数配好 | 分三步:配参数 → 配数据 → 启动 |
三步正好对应 GAP 回调里那三个事件,一步做完自动触发下一步:
c
esp_ble_gap_ext_adv_set_params(...) // ① 配参数
└─► EXT_ADV_SET_PARAMS_COMPLETE_EVT
esp_ble_gap_config_ext_adv_data_raw(...) // ② 配广播内容
└─► EXT_ADV_DATA_SET_COMPLETE_EVT
esp_ble_gap_ext_adv_start(...) // ③ 开始广播广播内容是手动拼的字节
c
static uint8_t ext_adv_raw_data[] = {
0x02, 0x01, 0x06, // 长度2, 类型01(Flags), 值06
0x03, 0x03, 0xFF, 0x00, // 长度3, 类型03(16位服务UUID), 0x00FF
0x11, 0x09, 'E','S','P','3','2','S','3','_',
'B','L','E','_','D','E','M','O', // 长度17, 类型09(完整设备名)
};广播包的格式是 长度 + 类型 + 内容 一段段拼起来的:
| 字节 | 含义 |
|---|---|
0x02, 0x01, 0x06 | 这段长 2 字节,类型 0x01 是 Flags,值 0x06 = 支持 BLE、不支持经典蓝牙 |
0x03, 0x03, 0xFF, 0x00 | 这段长 3 字节,类型 0x03 是 16 位服务 UUID,值 0x00FF(低字节在前) |
0x11, 0x09, 'E','S','P'... | 这段长 0x11=17 字节,类型 0x09 是完整设备名,后面 16 个字符 |
改设备名的时候,第一个字节的长度也要跟着改 —— 名字 16 个字符,加上类型那 1 字节就是 17(0x11)。改了名字忘了改长度,手机上就会显示成乱码或者截断。
被读、被写的时候怎么办
这两件事都在 GATTS 回调里处理:
c
// 手机来读 → 把当前值塞进响应里发回去
static void handle_read(esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param)
{
esp_gatt_rsp_t rsp = {0};
rsp.attr_value.handle = param->read.handle;
rsp.attr_value.len = char_value_len;
memcpy(rsp.attr_value.value, char_value, char_value_len);
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id,
ESP_GATT_OK, &rsp);
}
// 手机写进来 → 存下来
static void handle_write(esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param)
{
char_value_len = param->write.len;
memcpy(char_value, param->write.value, char_value_len);
// ……
}主动推送数据给手机(Notify):
c
esp_ble_gatts_send_indicate(gatts_if, conn_id, char_handle,
notify_len, char_value, false);
// ⭐ false = Notify,true = Indicate推之前必须确认手机订阅了
c
if (notify_enabled && is_connected) {
esp_ble_gatts_send_indicate(...);
} else {
ESP_LOGW(TAG, "Notify is not enabled by phone yet, echo was not sent");
}notify_enabled 这个标志是手机写 CCCD 时才置起来的(见第七节)。手机没订阅就推,数据直接丢掉,还不报错 —— 所以代码里要自己判断、自己打一行日志,不然查起来毫无头绪。
十、BLE 调试注意事项
① 小数据优先,长数据要分包
BLE 默认单包很小。短命令比如:
json
{"alarm":"07:30"}可以直接发送。
如果 JSON 很长,就要依赖 MTU 协商或应用层分包。本工程的配置写入支持分包拼接,适合发送较长的配置 JSON。
② 本工程采用 BLE + WiFi 共存
ESP32-S3 的 BLE 和 WiFi 都工作在 2.4GHz。本工程为了方便调试和配网,实际会同时开启:
| 功能 | 干什么 |
|---|---|
| BLE | 近距离配置、状态读取、WiFi 配网 |
| WiFi STA | 连接路由器,访问天气、MQTT、OTA |
| WiFi AP | 开启 ESP32-Clock-Setup 热点 |
| HTTP Server | 提供网页配网和 SD 卡管理 |
代价:多占用一些内存;WiFi 和 BLE 同时大量传输时速度会下降。
所以本工程的设计原则是:
BLE 只传配置命令,不传图片、音频这类大文件。
③ Notify 必须先订阅
设备主动推送状态用的是 Notify。
手机端必须先对状态特征值开启 "订阅 / Enable Notification",设备推送的数据才能被看到。
一个排查技巧
能写配置、但看不到设备状态 —— 先检查有没有订阅 Notify。
④ 调试工具用微信小程序
本课程演示使用微信小程序 「BLE 蓝牙调试小助手」。
它可以扫描设备、查看服务和特征值、手动写入 JSON、订阅 Notify。
发送 JSON 时把输入模式切到「文本 / UTF-8」
不要用默认的 HEX(十六进制) 模式 —— 那样 {"alarm":"07:30"} 会被当成十六进制解析,设备收到的是一堆乱码。
这是新手最常卡住的地方。
本节要点
- 蓝牙是片上外设,和 I2C、SPI 同类,只是传输介质是无线电波
- ⭐ ESP32-S3 只支持 BLE,没有经典蓝牙(做不了蓝牙音箱)
- BLE 省电、适合传小数据,物联网都用它
- 设备当 Peripheral,靠广播让手机发现(广播包最多 31 字节)
- ⭐ GATT 三层结构:服务(文件夹)→ 特征值(真正读写的数据) → 描述符(标签)
- UUID 是身份证号:标准功能用 16 位官方的,自己的功能用 128 位自定义的
- ⭐ 特征值四种属性:Read(手机来读)、Write(手机写入)、Notify(设备主动推,最常用)、Indicate(推且要确认)
- 本节例子用 Bluedroid,核心是两个回调:GAP 管广播,GATTS 管数据;第三章的工程用 NimBLE,概念相同、写法不同
- ⭐ 服务和特征值是一步步建出来的,每步的 handle 要在回调里当场存下来
- 短命令一包就够,长 JSON 靠分包
- 本工程 BLE + WiFi + AP + HTTP 同时开着,所以 BLE 只传配置命令,不传大文件
- Notify 必须手机先订阅
- ⭐ Notify 每次重连都要重新订阅,订阅状态不跨连接保留
- 调试用微信小程序 「BLE蓝牙调试小助手」,输入模式切到「文本 / UTF-8」
操作过程
目标:跑通官方的 BLE 例子,用微信小程序扫描到它并连上。
这节不用接线
BLE 是片上外设,天线就在板子上,不需要接任何线。
第 1 步:打开蓝牙并选 Bluedroid
蓝牙默认是关的,要先在工程配置里打开。点 VS Code 底部状态栏的齿轮进 SDK 配置编辑器,搜 Bluetooth:
Component config → Bluetooth
[*] Bluetooth ← 先打勾,下面的选项才会出来
Host → Bluedroid - Dual-mode ← 本课程用这个顺便把 MTU 上限也调大,不然后面协商不到 517:
Component config → Bluetooth → Bluedroid Options
(517) MTU size ← 默认 23,改成 517改完点右上角保存。
打开蓝牙之后固件会变大不少
蓝牙协议栈本身就占几百 KB。如果编译时报 分区放不下(Error: app partition is too small),把分区表换成 Single factory app (large),或者自定义一张分区表把 app 分区调大。
第 2 步:写代码
这个例子做一件很简单的事:开机广播,手机连上后可以读、可以写;写进来的内容,再原样推回给手机。
c
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_gap_ble_api.h"
#include "esp_gatt_common_api.h"
#include "esp_gatts_api.h"
#include "esp_log.h"
#include "esp_system.h"
#include "nvs_flash.h"
#define DEVICE_NAME "ESP32S3_BLE_DEMO"
#define SERVICE_UUID 0x00FF // 服务的 UUID
#define CHAR_UUID 0xFF01 // 特征值的 UUID
#define PROFILE_ID 0
#define DEFAULT_BLE_MTU 23
#define EXT_ADV_HANDLE 0
#define NUM_EXT_ADV_SET 1
#define EXT_ADV_DURATION 0
#define EXT_ADV_MAX_EVENTS 0
static const char *TAG = "ble_demo";
// 这几个 handle 是协议栈分配的"门牌号",要在回调里当场记下来
static uint16_t service_handle;
static uint16_t char_handle;
static uint16_t cccd_handle;
static uint16_t conn_id;
static uint16_t notify_mtu = DEFAULT_BLE_MTU;
static bool is_connected;
static bool notify_enabled; // 手机订阅了没
static uint8_t char_value[64] = "Hello from ESP32-S3";
static uint16_t char_value_len = sizeof("Hello from ESP32-S3") - 1;
static esp_attr_value_t gatts_demo_char_val = {
.attr_max_len = sizeof(char_value),
.attr_len = sizeof("Hello from ESP32-S3") - 1,
.attr_value = char_value,
};
static esp_attr_control_t char_control = {
.auto_rsp = ESP_GATT_RSP_BY_APP, // 读写都由我们自己回应
};
// 广播内容:长度 + 类型 + 值,一段段拼起来
static uint8_t ext_adv_raw_data[] = {
0x02, 0x01, 0x06, // Flags:仅 BLE
0x03, 0x03, 0xFF, 0x00, // 16 位服务 UUID 0x00FF
0x11, 0x09, 'E', 'S', 'P', '3', '2', 'S', '3', '_',
'B', 'L', 'E', '_', 'D', 'E', 'M', 'O', // 完整设备名,共 16 字符
};
static esp_ble_gap_ext_adv_t ext_adv[NUM_EXT_ADV_SET] = {
[0] = {EXT_ADV_HANDLE, EXT_ADV_DURATION, EXT_ADV_MAX_EVENTS},
};
static esp_ble_gap_ext_adv_params_t ext_adv_params = {
.type = ESP_BLE_GAP_SET_EXT_ADV_PROP_CONNECTABLE, // 可被连接
.interval_min = 0x20, // 广播间隔
.interval_max = 0x40,
.channel_map = ADV_CHNL_ALL,
.own_addr_type = BLE_ADDR_TYPE_PUBLIC,
.peer_addr_type = BLE_ADDR_TYPE_PUBLIC,
.filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,
.tx_power = EXT_ADV_TX_PWR_NO_PREFERENCE,
.primary_phy = ESP_BLE_GAP_PHY_1M,
.max_skip = 0,
.secondary_phy = ESP_BLE_GAP_PHY_2M,
.sid = 0,
.scan_req_notif = false,
};
static void start_advertising(void)
{
esp_err_t ret = esp_ble_gap_ext_adv_start(NUM_EXT_ADV_SET, &ext_adv[0]);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Extended advertising start request failed: %s", esp_err_to_name(ret));
}
}
// ===== GAP 回调:管广播 =====
static void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param)
{
switch (event) {
case ESP_GAP_BLE_EXT_ADV_SET_PARAMS_COMPLETE_EVT: // ① 参数配好了
if (param->ext_adv_set_params.status == ESP_BT_STATUS_SUCCESS) {
esp_ble_gap_config_ext_adv_data_raw(EXT_ADV_HANDLE, sizeof(ext_adv_raw_data),
ext_adv_raw_data); // → 配广播内容
} else {
ESP_LOGE(TAG, "Extended advertising params set failed: %d",
param->ext_adv_set_params.status);
}
break;
case ESP_GAP_BLE_EXT_ADV_DATA_SET_COMPLETE_EVT: // ② 内容配好了
if (param->ext_adv_data_set.status == ESP_BT_STATUS_SUCCESS) {
start_advertising(); // → 开始广播
} else {
ESP_LOGE(TAG, "Extended advertising data set failed: %d",
param->ext_adv_data_set.status);
}
break;
case ESP_GAP_BLE_EXT_ADV_START_COMPLETE_EVT: // ③ 广播起来了
if (param->ext_adv_start.status == ESP_BT_STATUS_SUCCESS) {
ESP_LOGI(TAG, "Extended advertising started, device name: %s", DEVICE_NAME);
} else {
ESP_LOGE(TAG, "Extended advertising start failed: %d", param->ext_adv_start.status);
}
break;
default:
break;
}
}
// 手机来读:把当前值塞进响应发回去
static void handle_read(esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param)
{
esp_gatt_rsp_t rsp = {0};
rsp.attr_value.handle = param->read.handle;
rsp.attr_value.len = char_value_len;
memcpy(rsp.attr_value.value, char_value, char_value_len);
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id,
ESP_GATT_OK, &rsp);
ESP_LOGI(TAG, "Central read value: %.*s", char_value_len, char_value);
}
// 手机写进来:存下来,再原样推回去
static void handle_write(esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param)
{
if (!param->write.is_prep) {
char_value_len = param->write.len > sizeof(char_value) ? sizeof(char_value) : param->write.len;
memcpy(char_value, param->write.value, char_value_len);
ESP_LOGI(TAG, "Central wrote value: %.*s", char_value_len, char_value);
if (param->write.need_rsp) {
esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id,
ESP_GATT_OK, NULL);
}
if (notify_enabled && is_connected) {
uint16_t notify_len = char_value_len;
uint16_t max_notify_len = notify_mtu > 3 ? notify_mtu - 3 : 20; // ⭐ 减掉 3 字节开销
if (notify_len > max_notify_len) {
notify_len = max_notify_len;
ESP_LOGW(TAG, "Echo is truncated to %u bytes by current MTU", notify_len);
}
esp_ble_gatts_send_indicate(gatts_if, conn_id, char_handle,
notify_len, char_value, false); // false = Notify
ESP_LOGI(TAG, "Echoed value by notify: %.*s", notify_len, char_value);
} else {
ESP_LOGW(TAG, "Notify is not enabled by phone yet, echo was not sent");
}
}
}
// 手机点"订阅",实际就是往 CCCD 写两个字节
static void handle_cccd_write(esp_gatt_if_t gatts_if, esp_ble_gatts_cb_param_t *param)
{
if (param->write.len == 2) {
uint16_t cccd_value = param->write.value[0] | (param->write.value[1] << 8);
notify_enabled = (cccd_value & 0x0001) != 0; // bit0 = Notify 开关
ESP_LOGI(TAG, "Notify %s", notify_enabled ? "enabled" : "disabled");
}
if (param->write.need_rsp) {
esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id,
ESP_GATT_OK, NULL);
}
}
// ===== GATTS 回调:管服务和数据 =====
static void gatts_event_handler(esp_gatts_cb_event_t event,
esp_gatt_if_t gatts_if,
esp_ble_gatts_cb_param_t *param)
{
switch (event) {
case ESP_GATTS_REG_EVT: { // 注册好了 → 设名字、配广播、建服务
esp_ble_gap_set_device_name(DEVICE_NAME);
esp_ble_gap_ext_adv_set_params(EXT_ADV_HANDLE, &ext_adv_params);
esp_gatt_srvc_id_t service_id = {
.is_primary = true,
.id.inst_id = 0x00,
.id.uuid.len = ESP_UUID_LEN_16,
.id.uuid.uuid.uuid16 = SERVICE_UUID,
};
esp_ble_gatts_create_service(gatts_if, &service_id, 4);
break;
}
case ESP_GATTS_CREATE_EVT: { // 服务建好了 → 启动它、加特征值
service_handle = param->create.service_handle;
esp_ble_gatts_start_service(service_handle);
esp_bt_uuid_t char_uuid = {
.len = ESP_UUID_LEN_16,
.uuid.uuid16 = CHAR_UUID,
};
esp_gatt_perm_t perm = ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE;
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, perm, property,
&gatts_demo_char_val, &char_control);
break;
}
case ESP_GATTS_ADD_CHAR_EVT: // 特征值加好了 → 加 CCCD 描述符
char_handle = param->add_char.attr_handle;
esp_bt_uuid_t cccd_uuid = {
.len = ESP_UUID_LEN_16,
.uuid.uuid16 = ESP_GATT_UUID_CHAR_CLIENT_CONFIG, // 0x2902
};
esp_ble_gatts_add_char_descr(service_handle, &cccd_uuid,
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE,
NULL, NULL);
ESP_LOGI(TAG, "Service UUID: 0x%04X, characteristic UUID: 0x%04X",
SERVICE_UUID, CHAR_UUID);
ESP_LOGI(TAG, "Enable notify, then write data to receive the echo.");
break;
case ESP_GATTS_ADD_CHAR_DESCR_EVT:
cccd_handle = param->add_char_descr.attr_handle;
ESP_LOGI(TAG, "CCCD descriptor handle: %u", cccd_handle);
break;
case ESP_GATTS_CONNECT_EVT:
conn_id = param->connect.conn_id;
is_connected = true;
notify_enabled = false; // 每次连上都要重新订阅
ESP_LOGI(TAG, "Central connected");
break;
case ESP_GATTS_DISCONNECT_EVT:
is_connected = false;
notify_enabled = false;
notify_mtu = DEFAULT_BLE_MTU;
ESP_LOGI(TAG, "Central disconnected, advertising again");
start_advertising(); // ⭐ 断开后要重新广播
break;
case ESP_GATTS_MTU_EVT: // 协商完 MTU 会通知你
notify_mtu = param->mtu.mtu;
ESP_LOGI(TAG, "MTU updated: %u", notify_mtu);
break;
case ESP_GATTS_READ_EVT:
if (param->read.handle == char_handle) {
handle_read(gatts_if, param);
}
break;
case ESP_GATTS_WRITE_EVT:
if (param->write.handle == char_handle) { // ⭐ 靠 handle 区分写的是哪个
handle_write(gatts_if, param);
} else if (param->write.handle == cccd_handle) {
handle_cccd_write(gatts_if, param);
}
break;
default:
break;
}
}
static void ble_demo_task(void *arg)
{
// 1. 蓝牙要用 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);
// 2. S3 没有经典蓝牙,把那部分内存还回去
ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));
// 3. 从底到上,一层层启动:控制器 → 协议栈
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));
ESP_ERROR_CHECK(esp_bluedroid_init());
ESP_ERROR_CHECK(esp_bluedroid_enable());
// 4. 挂上两个回调,设 MTU 上限,注册 App(会触发 ESP_GATTS_REG_EVT)
ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));
ESP_ERROR_CHECK(esp_ble_gatts_register_callback(gatts_event_handler));
ESP_ERROR_CHECK(esp_ble_gatt_set_local_mtu(517));
ESP_ERROR_CHECK(esp_ble_gatts_app_register(PROFILE_ID));
while (true) {
ESP_LOGI(TAG, "BLE %s, current value: %.*s",
is_connected ? "connected" : "advertising",
char_value_len, char_value);
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
void app_main(void)
{
xTaskCreate(ble_demo_task, "ble_demo_task", 4096, NULL, 5, NULL);
}为什么一上来要初始化 NVS
c
esp_err_t ret = nvs_flash_init();蓝牙协议栈要在 Flash 里存配对信息、绑定的密钥这类东西,用的就是 NVS(3.4 会细讲)。
NVS 没初始化,蓝牙起不来。这几乎是所有 ESP32 蓝牙/WiFi 例子的第一句。
这句是在"还内存"
c
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT);协议栈启动时会给经典蓝牙也预留一块内存。S3 根本没有经典蓝牙,这块内存放着也是浪费,这句话把它还给系统堆。
能省几十 KB,对 512KB 的片子来说很值。
第 3 步:编译烧录
操作流程见 2.2 第 4 步
在 VS Code 底部状态栏依次点:编译 → 烧录(或者直接点"编译、烧录并监视")。
第 4 步:装调试小程序
微信里搜 「BLE蓝牙调试小助手」:

为什么不用 nRF Connect
网上教程基本都让装 nRF Connect,但国内手机应用商店大多搜不到,还要自己找安装包。
微信小程序不用装、不用注册,扫一扫就能用,功能足够做这一节和第三章的实验。
第 5 步:扫描并连接
打开小程序,点右上角的扫描:

板子正常广播的话,列表里就会出现刚才改的那个名字,点连接:

名字前面那个 -66 是什么
那是 RSSI(信号强度),单位 dBm,是个负数,越接近 0 越强:
| RSSI | 大概什么情况 |
|---|---|
| −30 ~ −50 | 贴着手机 |
| −50 ~ −70 | 同一个房间 |
| −70 ~ −90 | 隔了墙,可能连不稳 |
下面那个 ≈ 1.9m 是小程序按 RSSI 反推的距离,只能当个大概参考 —— 中间隔点东西就会差很多。
再下面一行 94:A9:90:DD:E9:AE 是蓝牙 MAC 地址,每块板子都不一样。名字可以重复,MAC 不会 —— 周围有好几块一样的板子时,靠它区分。
第 6 步:应该看到什么
串口(开机时):
I (xxx) ble_demo: Service UUID: 0x00FF, characteristic UUID: 0xFF01
I (xxx) ble_demo: CCCD descriptor handle: 43
I (xxx) ble_demo: Enable notify, then write data to receive the echo.
I (xxx) ble_demo: Extended advertising started, device name: ESP32S3_BLE_DEMO
I (xxx) ble_demo: BLE advertising, current value: Hello from ESP32-S3手机连上的那一刻:
I (xxx) ble_demo: Central connected
I (xxx) ble_demo: MTU updated: 517
I (xxx) ble_demo: BLE connected, current value: Hello from ESP32-S3小程序跳到"BLE 通信"页面,顶部显示已连接:

日志里这三行值得看:
| 日志 | 说明 |
|---|---|
连接成功 | 链路建立了 |
发现 3 个服务 | ⭐ 这就是第五节讲的 Service,小程序把设备的服务全列出来了 |
当前 MTU 517(载荷 514 字节/包) | ⭐ 一次最多能传多少,见下 |
MTU 517 是怎么来的 ⭐
第十节说过 BLE 默认单包很小。这里显示 517,是因为连上之后双方协商过了。
默认 MTU = 23 字节,其中 3 字节是协议开销 → 实际能传 20 字节
协商后 MTU = 517 字节,减去 3 字节开销 → 实际能传 514 字节协商是自动的,手机和设备各报一个自己支持的上限,取小的那个。
但不能指望它 —— 有的手机、有的系统版本就是不给你涨。所以代码里推送前要按当前实际的 MTU 算一次能发多少:
c
uint16_t max_notify_len = notify_mtu > 3 ? notify_mtu - 3 : 20;notify_mtu 就是 ESP_GATTS_MTU_EVT 里存下来的值。协商没成功时它还是 23,这句就会退回 20 字节。
第 7 步:发一条数据试试
页面底部有三个地方要确认:

| 位置 | 设成 | 为什么 |
|---|---|---|
| 编码(上下各一处) | UTF-8 | ⭐ 见下面的提醒 |
| UUID | 你要写的那个特征值(图中是 FF01) | 一个设备有多个特征值,得选对 |
在输入框里打几个字符,点发送:

两个地方能确认发出去了:
- 日志区多了一行
20:24:04.912 TX ddd - 顶部计数从
TX 0 B变成TX 3 B(三个字符正好 3 字节)
板子那边的串口也会打印出收到了什么:
I (xxx) ble_demo: Central wrote value: ddd
W (xxx) ble_demo: Notify is not enabled by phone yet, echo was not sent编码一定要选 UTF-8,别用 HEX
小程序默认可能是 HEX(十六进制) 模式。那种模式下,你打的 ddd 会被当成十六进制数去解析,设备收到的是完全不同的字节。
第三章要发 JSON 命令({"alarm":"07:30"}),用 HEX 模式发过去就是一堆乱码,设备解析不了还不报错,最难查。
上下两处编码都要是 UTF-8。
第 8 步:订阅之后再发一次,看回显
上一步串口打的那行警告,说的就是手机还没订阅:
W (xxx) ble_demo: Notify is not enabled by phone yet, echo was not sent对照截图也能看出来 —— 顶部计数是 RX 0 B,手机一个字节都没收到。
去小程序上方的「服务」标签页,找到 FF01 这个特征值,点开它的 Notify / 订阅 开关。
订阅的瞬间,板子串口会打印:
I (xxx) ble_demo: Notify enabled这就是手机往 CCCD 写了 0x0001(见第七节),被 handle_cccd_write() 收到了。
现在回到"调试"页再发一次 ddd,两边都会有反应:
| 哪里 | 看到什么 |
|---|---|
| 板子串口 | Central wrote value: ddd 后面跟着 Echoed value by notify: ddd |
| 小程序日志 | 除了 TX ddd,还多一条 RX ddd |
| 小程序顶部 | RX 计数从 0 B 变成 3 B |
一个来回走通了,说明 Write 和 Notify 两条路都对了。
每次断开重连都要重新订阅
c
case ESP_GATTS_CONNECT_EVT:
notify_enabled = false; // ⭐ 归零订阅状态不跨连接保留。手机断开再连上,notify_enabled 回到 false,必须重新点一次订阅。
这不是代码偷懒 —— BLE 协议就是这么规定的(除非设备做了绑定并保存 CCCD)。"昨天能收到,今天收不到了",先看是不是忘了重新订阅。
动手试试
- 用小程序扫一扫周围,看看能发现多少 BLE 设备,点开一个看看它有哪些服务和特征值。
- 把手机拿远一点再扫一次,观察 RSSI 和估算距离怎么变。
- 把例子里的设备名改一下重新烧录,确认小程序里显示的名字跟着变了,而 MAC 地址没变。
- 故意把编码切成 HEX,再发一次
ddd,看看板子收到的是什么。 - 在小程序的「服务」页点一次读取,看看能不能读到
Hello from ESP32-S3。 - 订阅之后断开再重连,不重新订阅直接发数据,看串口打的是哪一行。
- 给例子再加一个特征值,属性设成
Read + Notify,用手机读一次。 - 想一想:如果要传一张 1KB 的图片,单包的限制该怎么解决?