用 BLE 让手机直连你的 ESP32-S3
- 用人话理解 BLE 的 GAP/GATT、服务、特征、UUID 这几个核心概念
- 在 ESP-IDF 里用 NimBLE 协议栈初始化主机、开 GAP 广播、注册一个 GATT 服务和特征
- 写一个最小可跑的 BLE 外设,手机用 nRF Connect 连上能读写、能收推送
- 分清什么时候该用 BLE、什么时候该用 WiFi 或 ESP-NOW
你想用手机直接连上手里的 ESP32-S3:看一眼传感器读数、按个开关、改个阈值。可你不想为这点小事搭一台服务器,也不想让设备一定得连上家里的路由器——在工地、在车上、在没 WiFi 的地方它照样得能用。这正是 BLE(低功耗蓝牙) 的主场:手机和设备就近一对一,掏出手机连上就读写,关掉就走,全程不碰网络。
这一节让你的 ESP32-S3 变成一个 BLE 外设,广播出一个设备名,手机用一个免费 App 就能扫到、连上、读到值、写值控制板载 LED,还能收设备主动推来的通知。
但我先把两句话放在最前面。第一,ESP32-S3 只有 BLE,没有经典蓝牙——这对物联网正好,下面会讲清原因。第二,也是最关键的一句:BLE 协议栈的样板代码非常多,本篇给的是能跑通主干的骨架(一个服务 + 两个特征),不是逐字逐句生产级完整实现。 完整、经过乐鑫验证的写法以官方 NimBLE 示例 bleprph(外设通用模板)和 blehr(心率通知模板)为准,烧之前请对着官方示例自校。这一点后面还会反复点明——别把本篇的代码当成"确定无误"。
读这篇前,你需要已经跑通过点亮第一个 LED——也就是 ESP-IDF 环境装好了、idf.py build flash monitor 这条链路通了、能看懂串口日志。BLE 这节不接任何额外的线,纯软件加板载 LED,调试全靠串口日志和手机 App。
先分清:BLE、经典蓝牙、以及 ESP32-S3 为什么只有 BLE
蓝牙其实是两套东西,干的活也不同:
- 经典蓝牙(BT Classic):为持续、大流量的数据流设计。你的蓝牙耳机、蓝牙音箱走的就是它——音频得一刻不停地传。代价是费电,不适合用纽扣电池跑几个月的小设备。
- 低功耗蓝牙(BLE):为偶尔传一点小数据设计。温湿度、心率、开关状态这种几个字节的东西,传完就睡,平均功耗能压得极低,一颗纽扣电池能跑大半年。绝大多数手机能直连的智能硬件——手环、体温计、智能锁、一堆环境传感器——走的都是 BLE。
ESP32-S3 只支持 BLE(而且是 BLE 5.0),没有经典蓝牙。 这不是缺陷,是定位:做联网传感器、做手机直连配置、做电池供电的小玩意,你要的几乎都是 BLE。老的经典款 ESP32(ESP32-WROOM)两种都有,但新一代变体(C3/S3 等)干脆只留 BLE,正说明物联网这边 BLE 才是主流。
一个常见误会:BLE 不是"更新版的经典蓝牙",它是另起炉灶的协议。一个设备支持经典蓝牙不代表支持 BLE,反之亦然。所以在 ESP32-S3 上你压根没有经典蓝牙那套(BluetoothSerial 那一类)可用,也不用纠结选哪个——只有 BLE 一条路。
BLE 的核心概念:GAP 和 GATT,用人话讲
BLE 的官方术语第一次看像天书,但拆成两层就清楚了:怎么被发现、怎么连上是一层(GAP),连上之后怎么交换数据是另一层(GATT)。
GAP(通用访问规范)——负责"被发现和连接":
- 外设(Peripheral)vs 中心(Central):你的 ESP32-S3 是外设——它在那儿广播"我在这,我叫某某",等人来连。你的手机是中心——它主动去扫描、挑一个连上。注意这和 WiFi 的主客关系不一样:BLE 里谁广播谁就是外设,一个外设通常只被一个中心连着。
- 广播(Advertising):外设周期性地往外喊一小包数据(设备名、可连标志等),手机扫描时就是在听这些广播包。在 ESP-IDF 里这一步对应
ble_gap_adv_set_fields()(填广播内容)+ble_gap_adv_start()(开始喊)。
GATT(通用属性规范)——负责"连上之后交换数据":
- 服务(Service):一栋楼里的一个部门。比如"环境监测服务""设备控制服务"。一个外设可以有好几个服务,按功能把数据分门别类。
- 特征(Characteristic):部门里的一个具体窗口,是真正存放数据、能被操作的那个点。比如"温度值"是一个特征,"LED 开关"是另一个特征。每个特征带着权限,决定中心能拿它干什么:
- 可读(Read):手机能主动来问"现在多少?"
- 可写(Write):手机能往里塞值,比如写个 1 让 LED 亮。
- 通知(Notify):设备能主动推给手机,不用手机一直问——温度一变就推过去。这是 BLE 最好用的特性,省电又及时。
- UUID:每个服务、每个特征都要有一个唯一编号,手机靠它认出"这是哪个窗口"。它是一串 128 位的长字符串,长这样:
4fafc201-1fb5-459e-8fcc-c5c9c331914b。你自己的项目随便生成一个就行(网上搜 "UUID generator" 点一下),只要别和标准服务的短 UUID 撞上。
记住这条链就够用了:外设广播(GAP)→ 手机连上 → 找到服务(GATT)→ 在服务里读写某个特征。下面的代码就是照这条链搭的。
选栈:NimBLE 还是 Bluedroid?
在 ESP-IDF 里跑 BLE,有两套蓝牙主机(host)协议栈可选,这是动手前必须先定的一件事:
- Bluedroid:从安卓移植来的老栈,功能全(连经典蓝牙也靠它),但占 RAM 大,对只跑 BLE 的小设备是浪费。
- NimBLE:Apache Mynewt 项目来的轻量栈,省 RAM、专为 BLE 设计,是乐鑫现在主推的选择。ESP32-S3 这种只有 BLE 的芯片,用 NimBLE 最合适。
本篇用 NimBLE。在 menuconfig 里走 Component config → Bluetooth → Bluetooth → 打开,再到 Bluetooth Host 选 NimBLE - BLE only(不是 Bluedroid)。NimBLE host 是 ESP-IDF 自带组件,选上即可,一般不用额外 idf.py add-dependency。
怎么知道自己当前选的是哪个栈?编译时如果报找不到 nimble_port_init、ble_gap_* 这类符号,多半是 menuconfig 里还停在 Bluedroid 或蓝牙没打开。NimBLE 的函数前缀基本都是 nimble_* / ble_gap_* / ble_gatts_*,认前缀就能判断你引的是不是对的栈。
最小可跑代码:一个 NimBLE GATT 外设(主干骨架)
下面这段是一个能跑通主线的骨架:初始化 NimBLE 主机、开 GAP 广播、注册一个 GATT 服务,服务里两个特征——一个手机写值控制板载 LED,另一个设备每两秒主动推一个递增计数(模拟传感器)。
再强调一次(这关系到你别被坑):BLE 栈样板多,下面是主干,不是逐字完整的生产实现。 广播重启、绑定配对、各种边界回调、错误处理在真项目里还要补。完整、可直接照搬的实现请以官方 bleprph 示例为准,本篇代码需要你对着官方示例自校后再用。这恰恰是本站反复讲的场景:ESP-IDF 的 BLE 样板正是最该让 AI copilot 帮你抹平的地方——把"我要一个 NimBLE 外设,一个服务、一个可写特征控制 GPIO、一个 notify 特征推计数"喂给 AI,让它照 bleprph 的结构生成样板,你来核对主干逻辑,比手敲这几百行样板高效得多。
#include "freertos/FreeRTOS.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "driver/gpio.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "host/ble_hs.h"
#include "host/util/util.h"
#include "services/gap/ble_svc_gap.h"
#include "services/gatt/ble_svc_gatt.h"
#define TAG "ble"
#define LED_GPIO 2 // 板载 LED,多数 DevKit 在 GPIO2,按你的板子改
// 自己生成的 128 位 UUID(网上随便生成,别和标准短 UUID 撞)
// 注意 NimBLE 的字节序是小端,BLE_UUID128_INIT 要按低字节在前填
static const ble_uuid128_t svc_uuid =
BLE_UUID128_INIT(0x4b, 0x91, 0x31, 0xc3, 0xc9, 0xc5, 0xcc, 0x8f,
0x9e, 0x45, 0xb5, 0x1f, 0x01, 0xc2, 0xaf, 0x4f);
static const ble_uuid128_t led_chr_uuid =
BLE_UUID128_INIT(0xa8, 0x26, 0x1b, 0x36, 0x07, 0xea, 0xf5, 0xb7,
0x88, 0x46, 0xe1, 0x36, 0x3e, 0x48, 0xb5, 0xbe);
static const ble_uuid128_t notify_chr_uuid =
BLE_UUID128_INIT(0x7e, 0xe8, 0x7b, 0x5d, 0x2e, 0x7a, 0x3d, 0xbf,
0x3a, 0x41, 0xf7, 0xd8, 0xe3, 0xd5, 0x95, 0x1c);
static uint8_t own_addr_type;
static uint16_t conn_handle = BLE_HS_CONN_HANDLE_NONE;
static uint16_t notify_val_handle; // ble_gatts_add_svcs 会回填这个特征的句柄
static uint32_t counter = 0;
static int gap_event(struct ble_gap_event *event, void *arg);
// 特征的读写回调:手机读/写某个特征时,NimBLE 调用它
static int chr_access(uint16_t ch, uint16_t attr_handle,
struct ble_gatt_access_ctxt *ctxt, void *arg) {
if (ctxt->op == BLE_GATT_ACCESS_OP_WRITE_CHR) {
// 手机写 LED 特征:取第一个字节,'1' 点亮否则熄灭
uint8_t v = 0;
uint16_t len = OS_MBUF_PKTLEN(ctxt->om);
if (len > 0) ble_hs_mbuf_to_flat(ctxt->om, &v, 1, NULL);
gpio_set_level(LED_GPIO, v == '1' ? 1 : 0);
ESP_LOGI(TAG, "收到写入 0x%02x,LED %s", v, v == '1' ? "亮" : "灭");
return 0;
}
if (ctxt->op == BLE_GATT_ACCESS_OP_READ_CHR) {
// 手机来读 notify 特征:把当前计数填回去
return os_mbuf_append(ctxt->om, &counter, sizeof(counter))
== 0 ? 0 : BLE_ATT_ERR_INSUFFICIENT_RES;
}
return BLE_ATT_ERR_UNLIKELY;
}
// GATT 服务定义表:一个服务,里面两个特征。结尾必须用 {0} 收尾
static const struct ble_gatt_svc_def gatt_svcs[] = {
{
.type = BLE_GATT_SVC_TYPE_PRIMARY,
.uuid = &svc_uuid.u,
.characteristics = (struct ble_gatt_chr_def[]){
{ // 特征一:可写,控制 LED
.uuid = &led_chr_uuid.u,
.access_cb = chr_access,
.flags = BLE_GATT_CHR_F_WRITE,
},
{ // 特征二:可读 + 可通知,推计数
.uuid = ¬ify_chr_uuid.u,
.access_cb = chr_access,
.flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY,
.val_handle = ¬ify_val_handle,
},
{0}, // 特征表收尾
},
},
{0}, // 服务表收尾
};
// 配好广播内容并开始广播
static void advertise(void) {
struct ble_hs_adv_fields fields = {0};
fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP;
const char *name = ble_svc_gap_device_name(); // "QiFu-ESP32"
fields.name = (uint8_t *)name;
fields.name_len = strlen(name);
fields.name_is_complete = 1;
ble_gap_adv_set_fields(&fields);
struct ble_gap_adv_params adv = {0};
adv.conn_mode = BLE_GAP_CONN_MODE_UND; // 可连接、不定向
adv.disc_mode = BLE_GAP_DISC_MODE_GEN; // 可被发现
ble_gap_adv_start(own_addr_type, NULL, BLE_HS_FOREVER, &adv, gap_event, NULL);
ESP_LOGI(TAG, "开始广播,设备名 %s,等待连接...", name);
}
// GAP 事件回调:连上、断开等
static int gap_event(struct ble_gap_event *event, void *arg) {
switch (event->type) {
case BLE_GAP_EVENT_CONNECT:
if (event->connect.status == 0) {
conn_handle = event->connect.conn_handle;
ESP_LOGI(TAG, "手机已连接");
} else {
advertise(); // 连接失败,重新广播
}
return 0;
case BLE_GAP_EVENT_DISCONNECT:
ESP_LOGI(TAG, "手机已断开,重新广播");
conn_handle = BLE_HS_CONN_HANDLE_NONE;
advertise(); // 断开后重开广播,方便再连
return 0;
default:
return 0;
}
}
// NimBLE 主机同步好(拿到地址)后被调用,这里开始广播
static void on_sync(void) {
ble_hs_id_infer_auto(0, &own_addr_type);
advertise();
}
// NimBLE 协议栈跑在它自己的 FreeRTOS 任务里
static void host_task(void *param) {
nimble_port_run(); // 阻塞运行,直到 nimble_port_stop
nimble_port_freertos_deinit();
}
// 单独一个任务:连上后每 2 秒推一次计数
static void notify_task(void *param) {
while (1) {
vTaskDelay(pdMS_TO_TICKS(2000));
if (conn_handle != BLE_HS_CONN_HANDLE_NONE) {
counter++;
struct os_mbuf *om = ble_hs_mbuf_from_flat(&counter, sizeof(counter));
ble_gatts_notify_custom(conn_handle, notify_val_handle, om);
ESP_LOGI(TAG, "推送计数: %u", (unsigned)counter);
}
}
}
void app_main(void) {
// NVS:BLE 也要它存射频校准/配对信息,连蓝牙前必做
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);
gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
ESP_ERROR_CHECK(nimble_port_init()); // 初始化 NimBLE 主机协议栈
ble_svc_gap_init(); // GAP 服务
ble_svc_gatt_init(); // GATT 服务
ble_gatts_count_cfg(gatt_svcs); // 先按表预算资源
ble_gatts_add_svcs(gatt_svcs); // 再把服务/特征注册进去
ble_svc_gap_device_name_set("QiFu-ESP32"); // 这就是手机扫到的设备名
ble_hs_cfg.sync_cb = on_sync; // 主机就绪后回调 on_sync 开广播
nimble_port_freertos_init(host_task); // 启动 NimBLE 任务
xTaskCreate(notify_task, "notify", 4096, NULL, 5, NULL);
}
在工程目录下一条命令编译、烧录、看日志:
idf.py build flash monitor
(第一次用要先 idf.py set-target esp32s3 选好芯片型号,并在 idf.py menuconfig 里把蓝牙主机选成 NimBLE。按 Ctrl + ] 退出监视。)
你应该看到什么
idf.py monitor 的日志里,启动后会滚出(中间夹着一堆系统自己打的 NimBLE/BLE_ 开头的底层日志,正常):
I (1234) ble: 开始广播,设备名 QiFu-ESP32,等待连接...
手机这边,装一个免费 App nRF Connect(Nordic 官方出的,安卓和 iOS 都有,BLE 调试几乎人手一个):
- 打开 App,点 SCAN 扫描,列表里出现一个叫 QiFu-ESP32 的设备——这就是 GAP 广播成功的标志。
- 点它旁边的 CONNECT。连上的瞬间,串口刷出
手机已连接,随后每两秒打印一次推送计数: 1 / 2 / 3...。 - 连上后展开那个长长的服务 UUID(
4fafc201...),里面有两个特征。 - 找到 LED 特征(带向上箭头、可写的那个),点写入按钮,类型选 TEXT,输入
1发送——板载 LED 亮起来,串口打印收到写入 0x31,LED 亮(0x31就是字符'1')。再发0,灯灭。 - 找到 通知特征(带三个箭头的那个),点开通知订阅按钮(一般是三个向下箭头的图标),手机上的值会每两秒自己跳一下,跟着串口的计数走——这就是设备在主动推数据给你,你没点任何刷新。
这一整套跑通,你就完整体验了 BLE 的读、写、通知三种交互。
nRF Connect 里特征旁边的小图标决定你能干什么:单箭头朝上是"写",单箭头朝下是"读",三箭头是"订阅通知"。看不到设备名时,先按一下板子的 RST/EN 复位键让它重开广播;手机蓝牙也确认是开着的。
把骨架讲透:NimBLE 这套是怎么转起来的
这段代码比 Arduino 那套长,但每一块都对应一个清晰的职责。理解了这套结构,后面做 BLE 配网、做信标、做更多特征都复用它。我们一块块拆。
NimBLE 是个跑在自己任务里的协议栈
和 WiFi 的事件驱动一脉相承:你 nimble_port_init() 初始化主机,再用 nimble_port_freertos_init(host_task) 把协议栈丢进一个独立的 FreeRTOS 任务里跑(host_task 里那行 nimble_port_run() 会一直阻塞运行)。从此 BLE 的所有动静——连上、断开、手机读写——都不是你主动轮询来的,而是 NimBLE 在它的任务里回调你注册的函数(gap_event、chr_access、on_sync)。这和上一篇 WiFi 的"事件循环 + 回调"是同一种产品级思路,别在主循环里 while 死等。
ble_gatts_count_cfg + ble_gatts_add_svcs:先预算再注册
gatt_svcs[] 这张表是整篇的核心数据结构——它用 struct ble_gatt_svc_def 把"一个服务、里面哪几个特征、每个特征什么权限、谁来处理读写"一次性声明清楚。注册分两步:
ble_gatts_count_cfg(gatt_svcs):先扫一遍表,预算要占多少属性资源(NimBLE 要先知道总量才好分配)。ble_gatts_add_svcs(gatt_svcs):再真正把服务和特征注册进 GATT 数据库。注册时它会把 notify 特征的句柄回填进notify_val_handle,后面推送要用它点名"推哪个特征"。
每个特征的 .flags 就是它的权限:BLE_GATT_CHR_F_WRITE(可写)、BLE_GATT_CHR_F_READ(可读)、BLE_GATT_CHR_F_NOTIFY(可通知),对应前面讲的三种权限。表的结尾必须用 {0} 收尾,漏了会越界——这是新手高频坑。
access_cb:手机读写特征时谁接招
特征定义里的 .access_cb = chr_access 是关键。手机每次读或写某个特征,NimBLE 就调用这个回调,并通过 ctxt->op 告诉你这次是读(BLE_GATT_ACCESS_OP_READ_CHR)还是写(BLE_GATT_ACCESS_OP_WRITE_CHR)。
- 写 LED 特征时,数据在
ctxt->om(一个叫 mbuf 的链式缓冲)里,用ble_hs_mbuf_to_flat()把它拷成普通字节,再gpio_set_level()控灯。 - 读 notify 特征时,用
os_mbuf_append()把当前值塞回ctxt->om返回给手机。
这一层就是 GATT"窗口"真正办事的地方——所有读写最终都落到 access_cb。
主动推送:ble_gatts_notify_custom
通知(notify)和读不一样:读是手机来问,通知是设备主动推。notify_task 里每 2 秒,只要还连着(conn_handle 有效),就把计数打包成 mbuf,用 ble_gatts_notify_custom(conn_handle, notify_val_handle, om) 推给手机。注意推送要带两个东西:推给谁(conn_handle)、推哪个特征(notify_val_handle,就是注册时回填的那个句柄)。手机那边只有点了"订阅通知",才会收到这些推送。
断开自动重广播
gap_event 里收到 BLE_GAP_EVENT_DISCONNECT 就再 advertise() 一次——否则手机断开后设备就"哑"了,再也扫不到。这一行让设备断开后自动重新可发现,是外设的标配。
UUID 在 NimBLE 里用 BLE_UUID128_INIT 按小端字节序填(低字节在前),所以代码里那串十六进制看起来和 nRF Connect 显示的 4fafc201-... 是"倒着"的——这是对的,不是写错了。这也是抄官方示例时最容易栽的细节之一。
BLE vs WiFi vs ESP-NOW:什么时候用哪个
ESP32-S3 这几样都能干,但它们解决的是不同的问题,别拿一个硬套另一个的场景。
选 BLE,当:
- 你要手机就近直连设备——配置、调试、看实时数据,人就在设备旁边。
- 现场没有路由器/没 WiFi,或者你不想让设备依赖网络。
- 设备靠电池供电要省电,传的又是小数据(传感器读数、开关状态)。
- 典型:给新设备做出厂前的初始设置、智能锁、手环、用手机当遥控器。
选 WiFi,当:
- 数据要上云、要远程访问——你人不在现场,在外地也要看到、要控制。
- 要传的数据量大(图片、连续日志),或要让多个客户端同时访问。
- 设备能稳定供电、附近有路由器。看让 ESP32-S3 连上 WiFi。
选 ESP-NOW,当:
- 是设备和设备之间直连传小数据,不经手机也不经路由器(比如一堆传感器节点把数据汇到一个网关)。它是乐鑫私有的轻量协议,延迟低、不用配网,但对端也得是 ESP 芯片、手机连不了。
一句话记法:人在旁边用手机连,用 BLE;要上云远程,用 WiFi;ESP 设备之间互传,用 ESP-NOW。 很多成品组合用——出厂时用 BLE 让你手机配好 WiFi(这就是后面 BLE 配网),配好后切到 WiFi 上云,各取所长。
故障排查:连不上或不对劲,按这个查
| 现象 | 最可能的原因 | 怎么办 |
|---|---|---|
编译报找不到 nimble_port_init / ble_gap_* |
menuconfig 里没选 NimBLE / 蓝牙没打开 | idf.py menuconfig → Bluetooth 打开 → Host 选 NimBLE - BLE only,重新 build |
| nRF Connect 扫不到设备 | 没在广播 / 没复位 | 按 RST 键重开;确认串口打出了"开始广播";确认 on_sync 被调到 |
| 扫到了但连接失败 | 上一个连接没断干净 / 广播参数 | 关掉 App 里旧连接,按板子 RST 再连 |
| 写 LED 没反应 | LED 引脚不对 / 写成了别的特征 | 确认 LED_GPIO(有的板载灯在 GPIO2 有的不同,甚至是 RGB 灯需另写);写的是带"写"图标的那个特征 |
| 收不到通知、值不跳 | 没点订阅 | 在通知特征上点"订阅通知"图标(三向下箭头)才会推;确认 notify_val_handle 被注册回填了 |
| 数据收发被截断 | MTU 太小 | 见下方坑:默认 MTU 只有 23 字节,传大数据要协商更大 MTU |
| 一连就重启、串口刷乱码 | 供电不足 | 换好数据线 / 插供电足的 USB 口 |
MTU(一次能传多少字节)是 BLE 新手最容易撞的隐形天花板。 BLE 默认 MTU 只有 23 字节,去掉协议头实际能放 20 字节 payload。你以为写进去一长串字符串,结果手机只收到前 20 个字节、后面被悄悄截断,还不报错。要传更长的数据,得在连上后协商更大的 MTU(nRF Connect 里有 "Request MTU" 选项,代码侧 NimBLE 也能设),或者把数据自己分包。本篇推的是 4 字节计数,没踩到这条线,但你一改成长字符串就会遇到——记住它。
两块 ESP32-S3 烧了同一份代码、用了同一个设备名和 UUID,在同一个房间里会让你在 App 里分不清哪个是哪个。给每块板子改一个不同的设备名(ble_svc_gap_device_name_set("QiFu-A"))。还有个隐蔽坑:iOS 的 nRF Connect 不显示设备的蓝牙 MAC 地址(系统限制),只能靠设备名认,这时改名更重要。
配对绑定(pairing/bonding):本篇的骨架是"开放"的,谁扫到都能连、能读写。真做产品时,敏感操作(改阈值、开锁)要加配对加密和绑定,让设备记住授权过的手机。NimBLE 通过 ble_hs_cfg.sm_* 一组配置和 SMP 流程来做——这块样板更多、更绕,完整以官方 bleprph 示例里的安全配置为准,本篇不展开,别误以为这个骨架能直接用在需要安全的场景。
变体一:把真实传感器读数推到手机
上面推的是假计数。把它换成真传感器,BLE 的价值就出来了——比如手里有个 DHT11 温湿度,把读数实时推到手机。思路只是把 notify_task 里 counter++ 换成读传感器:
static void notify_task(void *param) {
while (1) {
vTaskDelay(pdMS_TO_TICKS(2000));
if (conn_handle != BLE_HS_CONN_HANDLE_NONE) {
float temp = read_temperature(); // 换成你的传感器读数
char buf[16];
int len = snprintf(buf, sizeof(buf), "%.1f", temp);
struct os_mbuf *om = ble_hs_mbuf_from_flat(buf, len);
ble_gatts_notify_custom(conn_handle, notify_val_handle, om);
ESP_LOGI(TAG, "推送温度: %s", buf);
}
}
}
手机订阅那个特征,温度变一下它就跟着跳一下。注意一句字符串别超过 20 字节(MTU 那条坑),温度这种几位数没问题。怎么接 DHT11、怎么读数,看DHT11 温湿度传感器。
变体二:给设备起个能认出来的名字
设备多了最烦的是分不清。改名只要动一行——把 ble_svc_gap_device_name_set() 里的字符串换掉:
ble_svc_gap_device_name_set("客厅-温度计"); // 中文也行,但部分老 App 可能显示乱码,稳妥起见用英文/数字
烧进去后手机扫描列表里就是这个新名字。做多设备的项目,给每个起个一眼能认的名(卧室-灯、阳台-花盆),调试和使用都省心。
动手挑战
别只照抄,动手改一个:
- 把一个真实读数推到手机。 手边有什么传感器都行——温湿度、光强、甚至一个按键的按下次数。把它接好、读出来,套用变体一推到手机,在 nRF Connect 里订阅着看它实时跳。做完你就拥有了一个"手机直连的无线传感器",这正是无数智能硬件的内核。
- 进阶:在
gatt_svcs[]里再加一个特征或一个服务。比如加一个"读阈值"的可读特征,让手机能主动来问当前设定值——动手改一次这张服务定义表,你才算真懂了 NimBLE 的 GATT 是怎么声明的。
卡住了?把你的代码、串口日志、nRF Connect 里看到的现象一起发给 AI,讲清楚"我看到了什么、期望看到什么"——描述越具体,它定位得越准。NimBLE 样板多,这种"照官方示例改样板"的活,正适合让 AI 当你的 copilot。
本篇 BLE 代码为参考骨架,给的是能跑通的主干(一个服务 + 两个特征),不是逐字逐句的生产级完整实现。广播参数、配对绑定、MTU 协商、各类边界回调与错误处理在真项目里还要补全。烧录前请对照乐鑫官方 NimBLE 示例
bleprph(外设通用模板)和blehr(通知模板)自校,API 细节随 ESP-IDF 版本可能微调,一切以官方 NimBLE 文档为准,别把本篇代码当成"确定无误"。
小结 · 你现在掌握了什么
- 你能分清 BLE 和经典蓝牙:音频大流量用经典蓝牙,小数据省电用 BLE;而 ESP32-S3 只有 BLE(5.0),物联网基本都走它。
- 你理解了 BLE 的两层核心:GAP(广播、被发现、连接)和 GATT(服务 / 特征 / 读写通知),以及那条链路:外设广播 → 手机连上 → 找到服务 → 读写特征。
- 你会在 ESP-IDF 里用 NimBLE(比 Bluedroid 省 RAM、乐鑫主推)搭一个 BLE 外设:
nimble_port_init起栈、ble_gap_adv_*开广播、ble_gatts_count_cfg/add_svcs注册服务、用access_cb处理读写、用ble_gatts_notify_custom主动推。 - 你知道了 BLE / WiFi / ESP-NOW 的取舍,以及 BLE 的几个真坑:NimBLE vs Bluedroid 选择、默认 MTU 只有 23 字节、配对绑定要另做、调试用 nRF Connect。
- 你清楚一件最重要的事:BLE 栈样板多,本篇是主干骨架,生产代码以官方
bleprph/blehr示例为准,需自校。
BLE 解决了"手机就近直连"。如果你接下来想让设备真正上云、被远程访问,下一步走 WiFi 这条线:先让 ESP32-S3 连上 WiFi 拿到 IP,再用浏览器控制 ESP32(网页服务器) 或用 MQTT 把数据送上云。而把本篇的 BLE 和 WiFi 合起来,就是 ESP-IDF 官方的 BLE 配网——出厂用 BLE 让用户填好 WiFi 密码,正是这两篇的结合。