Skip to content

2.8 蓝牙:BLE 与特征值 ​

这节要搞懂:蓝牙怎么组织数据,什么是服务和特征值。 需要基础:2.1

本节是必修

天气时钟没有按键,闹钟靠手机蓝牙或 MQTT 来设置,所以 BLE 是核心功能之一。

一、蓝牙也是外设 ​

蓝牙(Bluetooth)是一种短距离无线通信技术。在 ESP32-S3 上,射频电路和蓝牙控制器直接集成在芯片内部,属于片上外设。

它和你已经学过的 I2C、SPI 是同一类东西 —— 都是通信外设,都通过 API 控制:

I2C / SPI蓝牙
是什么通信外设通信外设
在哪片上片上
靠什么传电平信号,要接线无线电波,不用线
传多远几十厘米约 10 米
谁发起主机双方都可以

区别只在于"传输介质":一个走导线,一个走空气。

二、经典蓝牙 vs BLE ​

蓝牙分两套完全不同的技术,别搞混:

经典蓝牙(BR/EDR)BLE(低功耗蓝牙)
全称Bluetooth ClassicBluetooth 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:

BluedroidNimBLE
占内存多少一半
支持经典蓝牙 + BLE(S3 上只有 BLE 能用)只支持 BLE
风格事件回调,一个 switch 处理所有事结构体数组定义 GATT 表
官方例子bluetooth/bluedroid/ble/gatt_serverbluetooth/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蓝牙调试小助手」:

微信搜「BLE蓝牙调试小助手」,就是这个
微信搜「BLE蓝牙调试小助手」,就是这个

为什么不用 nRF Connect

网上教程基本都让装 nRF Connect,但国内手机应用商店大多搜不到,还要自己找安装包。

微信小程序不用装、不用注册,扫一扫就能用,功能足够做这一节和第三章的实验。

第 5 步:扫描并连接 ​

打开小程序,点右上角的扫描:

点右上角「扫描」开始搜索附近的 BLE 设备
点右上角「扫描」开始搜索附近的 BLE 设备

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

扫到 ESP32S3_BLE_DEMO,右边是信号强度和估算距离,点「连接」
扫到 ESP32S3_BLE_DEMO,右边是信号强度和估算距离,点「连接」

名字前面那个 -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 个服务」「当前 MTU 517」
连上之后的通信页面:日志区能看到「连接成功」「发现 3 个服务」「当前 MTU 517」

日志里这三行值得看:

日志说明
连接成功链路建立了
发现 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 选对要写的那个特征值
发送前先确认:编码选 UTF-8(两处),UUID 选对要写的那个特征值
位置设成为什么
编码(上下各一处)UTF-8⭐ 见下面的提醒
UUID你要写的那个特征值(图中是 FF01)一个设备有多个特征值,得选对

在输入框里打几个字符,点发送:

发送 ddd,日志里出现一条 TX 记录,顶部 TX 计数变成 3 B
发送 ddd,日志里出现一条 TX 记录,顶部 TX 计数变成 3 B

两个地方能确认发出去了:

  • 日志区多了一行 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)。"昨天能收到,今天收不到了",先看是不是忘了重新订阅。

动手试试 ​

  1. 用小程序扫一扫周围,看看能发现多少 BLE 设备,点开一个看看它有哪些服务和特征值。
  2. 把手机拿远一点再扫一次,观察 RSSI 和估算距离怎么变。
  3. 把例子里的设备名改一下重新烧录,确认小程序里显示的名字跟着变了,而 MAC 地址没变。
  4. 故意把编码切成 HEX,再发一次 ddd,看看板子收到的是什么。
  5. 在小程序的「服务」页点一次读取,看看能不能读到 Hello from ESP32-S3。
  6. 订阅之后断开再重连,不重新订阅直接发数据,看串口打的是哪一行。
  7. 给例子再加一个特征值,属性设成 Read + Notify,用手机读一次。
  8. 想一想:如果要传一张 1KB 的图片,单包的限制该怎么解决?