← 返回教程库

用 BLE 让手机直连你的 ESP32-S3

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 18 分钟 🟢 软件/低风险
你将学到
  • 用人话理解 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 HostNimBLE - BLE only(不是 Bluedroid)。NimBLE host 是 ESP-IDF 自带组件,选上即可,一般不用额外 idf.py add-dependency

💡 提示

怎么知道自己当前选的是哪个栈?编译时如果报找不到 nimble_port_initble_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 = &notify_chr_uuid.u,
                .access_cb = chr_access,
                .flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY,
                .val_handle = &notify_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 调试几乎人手一个):

  1. 打开 App,点 SCAN 扫描,列表里出现一个叫 QiFu-ESP32 的设备——这就是 GAP 广播成功的标志。
  2. 点它旁边的 CONNECT。连上的瞬间,串口刷出 手机已连接,随后每两秒打印一次 推送计数: 1 / 2 / 3...
  3. 连上后展开那个长长的服务 UUID(4fafc201...),里面有两个特征。
  4. 找到 LED 特征(带向上箭头、可写的那个),点写入按钮,类型选 TEXT,输入 1 发送——板载 LED 亮起来,串口打印 收到写入 0x31,LED 亮0x31 就是字符 '1')。再发 0,灯灭。
  5. 找到 通知特征(带三个箭头的那个),点开通知订阅按钮(一般是三个向下箭头的图标),手机上的值会每两秒自己跳一下,跟着串口的计数走——这就是设备在主动推数据给你,你没点任何刷新。

这一整套跑通,你就完整体验了 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_eventchr_accesson_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_taskcounter++ 换成读传感器:

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 可能显示乱码,稳妥起见用英文/数字

烧进去后手机扫描列表里就是这个新名字。做多设备的项目,给每个起个一眼能认的名(卧室-灯阳台-花盆),调试和使用都省心。


动手挑战

别只照抄,动手改一个:

  1. 把一个真实读数推到手机。 手边有什么传感器都行——温湿度、光强、甚至一个按键的按下次数。把它接好、读出来,套用变体一推到手机,在 nRF Connect 里订阅着看它实时跳。做完你就拥有了一个"手机直连的无线传感器",这正是无数智能硬件的内核。
  2. 进阶:在 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 密码,正是这两篇的结合。

想看 L3 这一级还有哪些课、整条进阶路线长什么样,回L3 关卡总览完整路线图

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明