【ESP32笔记】蓝牙BLE连接与广播

本节目标:看清 BLE 的 GATT 数据模型,能自己设计一张属性表把数据暴露给手机,看懂广播和连接是怎么建立起来的,能排查"手机连上了却看不到服务"这类问题

一、本节要解决的问题

前面几节把 ESP32 连上了 WiFi、连上了 MQTT broker。那条链路的问题是门槛高:得有路由器、得配网、得有个服务器在公网等着。如果只是想用手机近距离看一眼板子上的温度,或者按一下手机上的按钮点亮板子上的灯,搭一整套 WiFi + 云端就太重了。

蓝牙正好补这个空档:

方案 需要路由器 需要服务器 手机端 功耗 速率
串口 UART 否 否 要接线、要驱动 --- 高
WiFi + MQTT 是 是 要写 App 或开网页 几十到上百 mA 高
经典蓝牙 SPP 否 否 iOS 用不了(SPP 没进 MFI 白名单) 较高 较高
BLE 否 否 系统原生支持,装个调试 App 即可 连接态几 mA,广播态更低 低(几十 kbps)

BLE(Bluetooth Low Energy,低功耗蓝牙)是从蓝牙 4.0 引入的,和经典蓝牙不是一回事 :它砍掉了音频那种连续流传输的设计,改成"偶尔连一下、读几个字节、马上断开"的模型。代价就是速率低、不适合传音频和大文件;换来的好处是能用一颗纽扣电池跑几个月。

本篇的两个例程就是这条最小链路:

  • ble_basic:骨架。两个自定义服务、每个两个特征,定时器往特征里塞变化的数值,手机订阅后能看到数字在跳。它演示的是"GATT 服务端怎么写",不带任何真实硬件。
  • ble_env:实战。一个环境感知服务,把 DHT11 的温湿度上报给手机,同时接收手机下发的 LED 开关命令------一条完整的双向链路。

和教程的关系:《启蒙之路》那套 PDF 教程里没有蓝牙章节,所以这篇笔记的"教程原文"是空的。内容全部来自上面两个工程的真实代码,加上蓝牙 SIG 规范和 ESP-IDF 官方文档里可查证的部分。凡是代码本身的写法有疑问的地方,我会明确标出来是"代码这么写"还是"规范这么要求"。

二、BLE 与 GATT 基础知识

2.1 协议栈分层

BLE 的协议栈是分层的,从下往上大致是:

层 全称 管什么 类比
PHY 物理层 2.4 GHz 射频、调制解调 电线
LL Link Layer 链路层 广播、连接、跳频、重传、CRC 网卡
HCI Host Controller Interface 控制器和主机之间的命令通道 驱动接口
L2CAP 逻辑链路控制与适配 数据分片重组、多路复用 端口号
ATT Attribute Protocol 属性协议 读写属性的报文格式 SQL 语句
GATT Generic Attribute Profile 属性怎么组织成服务/特征 表结构定义
SM Security Manager 安全管理 配对、绑定、加密 登录鉴权
GAP Generic Access Profile 通用访问 广播、扫描、连接 怎么找到并连上设备

初学者最容易混的是 ATT 和 GATT :ATT 是"怎么读写一个属性"的协议(相当于 SQL),GATT 是"属性该按什么规矩摆成一张表"的约定(相当于建表语句)。我们写代码时打交道的基本只有 GATT ------esp_gatts_* 这组 API 里的 s 就是 Server。

GAP 是另一条线,它管的是连接 :广播参数、扫描、连接参数。代码里 esp_gap_ble_api.h 和 esp_gatts_api.h 是两组独立的 API,各自注册一个回调函数,这一点在后面看代码时会很直观。

2.2 从经典蓝牙换成 BLE:第一行代码

ble_env/main/ble_env.c 的 app_main 前三步是初始化 NVS、初始化蓝牙、初始化 DHT11。蓝牙初始化在 ble_app.c 里:

c 复制代码
esp_err_t ble_cfg_net_init(void)
{
    esp_err_t ret;
    ESP_ERROR_CHECK(esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT));

    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_bluedroid_config_t bluedroid_cfg = BT_BLUEDROID_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_bluedroid_init_with_cfg(&bluedroid_cfg));
    ESP_ERROR_CHECK(esp_bluedroid_enable());
    ESP_ERROR_CHECK(esp_ble_gatts_register_callback(gatts_profile_event_handler));
    ESP_ERROR_CHECK(esp_ble_gap_register_callback(gap_event_handler));
    ESP_ERROR_CHECK(esp_ble_gatts_app_register(ESP_APP_ID));    //一个APP对应一份Profile
    ESP_ERROR_CHECK(esp_ble_gatt_set_local_mtu(500));
    return ESP_OK;
}

逐行看:

  1. esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT) ------ 经典蓝牙和 BLE 共用一块控制器内存。不用经典蓝牙就把它还回堆里,能省下可观的内存。必须在 esp_bt_controller_init() 之前调用,初始化之后再释放会失败。
  2. esp_bt_controller_init + esp_bt_controller_enable(ESP_BT_MODE_BLE) ------ 启动控制器这一侧,模式只选 BLE(ESP_BT_MODE_BLE,不是 ESP_BT_MODE_BTDM)。这样即使忘了第一步的内存释放,也不会真的去跑经典蓝牙。
  3. esp_bluedroid_init_with_cfg + esp_bluedroid_enable ------ 启动主机侧。ESP-IDF 里蓝牙主机有两个选择:Bluedroid (默认,老牌,API 前缀 esp_ble_*)和 NimBLE (更省内存,API 前缀 nimble_*)。这两个工程都用 Bluedroid,对应 sdkconfig 里的 CONFIG_BT_BLUEDROID_ENABLED=y。
  4. 注册两个回调:GATTS 一个、GAP 一个。这两组回调是分开的,不要指望在一个函数里处理所有事件。
  5. esp_ble_gatts_app_register(ESP_APP_ID) ------ 注册一个应用(Profile)。ESP_APP_ID 是 0x55,只是个标识;注册成功后会收到 ESP_GATTS_REG_EVT,里面的 gatts_if 是这份 Profile 的接口号。
  6. esp_ble_gatt_set_local_mtu(500) ------ 设置本地能接受的最大 MTU,见 4.3 节。

注意 :ESP_ERROR_CHECK 包着 esp_bt_controller_mem_release() 是有风险的------这个函数只有第一次调用返回 ESP_OK,之后再调会返回 ESP_ERR_INVALID_STATE,ESP_ERROR_CHECK 遇到非 ESP_OK 会直接 abort()。这里只调用一次所以没事,但如果你想在别处再调一次,要自己判断返回值。

2.3 GATT 的数据模型:服务 → 特征 → 描述符

GATT 把数据组织成一棵树:

复制代码
Profile(配置文件,本工程就是 ESP_APP_ID 注册的那份)
└── Service(服务,用 UUID 标识,比如「环境感知服务」)
    ├── Characteristic(特征,数据的最小单位)
    │   ├── 特征声明  ← 说明这个特征支持读/写/通知,并指向值句柄
    │   ├── 特征值    ← 真正的数据
    │   └── 描述符    ← 补充说明,最常用的是 CCCD
    └── Characteristic ...

服务(Service) 是一组相关特征的集合。比如"环境感知服务"里放温度、湿度、气压三个特征。服务有主服务(Primary)和次服务(Secondary)之分,次服务要被主服务引用才有意义,实际项目里几乎只用主服务。

特征(Characteristic) 是能被读写的最小单位,一个特征在表里占至少两行:

  • 一行是特征声明 (Characteristic Declaration),UUID 固定为 0x2803,它的值是 3 个字节:属性位(1 字节)+ 值句柄(2 字节)+ 特征 UUID。
  • 一行是特征值(Characteristic Value),UUID 就是这个特征自己的 UUID,值是真正的数据。

属性位(Properties) 是一个字节的位掩码,声明这个特征支持哪些操作:

位 宏 含义
0x01 ESP_GATT_CHAR_PROP_BIT_BROADCAST 支持广播
0x02 ESP_GATT_CHAR_PROP_BIT_READ 支持读
0x04 ESP_GATT_CHAR_PROP_BIT_WRITE_NR 支持无响应写
0x08 ESP_GATT_CHAR_PROP_BIT_WRITE 支持写(带响应)
0x10 ESP_GATT_CHAR_PROP_BIT_NOTIFY 支持通知
0x20 ESP_GATT_CHAR_PROP_BIT_INDICATE 支持指示

代码里的两个组合就是这么来的:

c 复制代码
//读写、通知权限
static const uint8_t char_prop_read_write_notify   = ESP_GATT_CHAR_PROP_BIT_WRITE | ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_NOTIFY;
//读、通知权限
static const uint8_t char_prop_read_notify = ESP_GATT_CHAR_PROP_BIT_READ | ESP_GATT_CHAR_PROP_BIT_NOTIFY;

描述符(Descriptor) 是挂在特征值后面的补充信息。最常用的一个叫 CCCD (Client Characteristic Configuration Descriptor,客户端特征配置描述符),UUID 固定为 0x2902,2 个字节,就是一个开关:

  • bit0 = 1:客户端订阅了通知(Notification)
  • bit1 = 1:客户端订阅了指示(Indication)

手机 App 上那个"订阅/取消订阅"的按钮,本质上就是往 CCCD 里写 01 00 或 00 00。所以服务器必须在属性表里给每个要发通知的特征准备一个 CCCD,否则手机根本没法订阅。

除了 CCCD,还有用户描述符(0x2901)、展示格式(0x2904)等,本工程没用到。

2.4 句柄(Handle)

GATT 里每个属性都有一个 16 位句柄 ,从 0x0001 开始递增。句柄是协议栈在注册属性表时自动分配的,你在写代码时根本不知道它是多少------这就是为什么特征声明里的"值句柄"没法在编译期写死,得由协议栈补上。

看属性表里特征声明这一项的写法:

c 复制代码
//特征声明
[SV1_CH1_IDX_CHAR]     =
{{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
  sizeof(uint8_t), sizeof(uint8_t), (uint8_t *)&char_prop_read_write_notify}},   //特征值1允许读写和通知

max_length 和 length 都写的是 sizeof(uint8_t)(1 字节)------空中实际是 3 个字节的特征声明,但应用只要提供那 1 字节的属性位就够了。这不是这个工程的独创,ESP-IDF 所有 GATT 例程都这么写。

句柄什么时候能拿到?注册完成后:

c 复制代码
case ESP_GATTS_CREAT_ATTR_TAB_EVT:{ //注册ATTR表成功事件
    if (param->add_attr_tab.status != ESP_GATT_OK){
        ESP_LOGE(TAG, "create attribute table failed, error code=0x%x", param->add_attr_tab.status);
    }
    else if (param->add_attr_tab.num_handle != SV_IDX_NB){
        ESP_LOGE(TAG, "create attribute table abnormally, num_handle (%d) \
                doesn't equal to NETCFG_IDX_NB(%d)", param->add_attr_tab.num_handle, SV_IDX_NB);
    }
    else {
        ESP_LOGI(TAG, "create attribute table successfully, the number handle = %d\n",param->add_attr_tab.num_handle);
        memcpy(netcfg_handle_table, param->add_attr_tab.handles, sizeof(netcfg_handle_table));
        esp_ble_gatts_start_service(netcfg_handle_table[SV1_IDX_SVC]);
    }
    break;
}

三件事:

  1. 检查 status。
  2. 检查分配到的句柄数量 num_handle 是否和枚举里的 SV_IDX_NB 一致------不一致说明属性表写错了,直接打日志报错。这个检查不能省,它是唯一能在运行时发现"表的行数和枚举对不上"的地方。
  3. memcpy 把句柄数组存进自己的表,然后 esp_ble_gatts_start_service() 启动服务。注册完属性表还必须启动服务,否则手机虽然能连上,但发现不了任何服务。

之后所有对属性的操作(读响应、设值、发通知)都要靠 句柄表[枚举下标] 来定位,所以这张 handle_table 是全局的、必须一直有效。

2.5 UUID:16 位、128 位和蓝牙基础 UUID

蓝牙规范里有个固定字符串叫蓝牙基础 UUID(Bluetooth Base UUID):

复制代码
00000000-0000-1000-8000-00805F9B34FB

任何 16 位的短 UUID 0xXXXX,展开后都是 0000XXXX-0000-1000-8000-00805F9B34FB。也就是说短 UUID 是长 UUID 的简写,两者表达的是同一个东西 。SIG 把 0xXXXX 这个空间集中分配,保证不同厂家的标准服务不会撞车。

看广播数据里那个 16 字节数组就明白了:

c 复制代码
static uint8_t adv_service_uuid128[] = {
    /* LSB <--------------------------------------------------------------------------------> MSB */
    //first uuid, 16bit, [12],[13] is the value
    0xfb, 0x34, 0x9b, 0x5f, 0x80, 0x00, 0x00, 0x80, 0x00, 0x10, 0x00, 0x00, GATTS_SERVICE_UUID_TEST&0xff,(GATTS_SERVICE_UUID_TEST>>8)&0xff, 0x00, 0x00,
};

这是小端序排布的 128 位 UUID(最低位字节在最前面)。把数组倒过来读,得到:

复制代码
00 00 XX XX 00 00 10 00 80 00 00 80 5F 9B 34 FB

正好就是 0000XXXX-0000-1000-8000-00805F9B34FB,其中 XXXX 由下标 [12]、[13] 两个字节小端填入 ------ 这就是代码注释里那句 is the value 的意思。数组里那两个位置写的是 GATTS_SERVICE_UUID_TEST&0xff 和 (GATTS_SERVICE_UUID_TEST>>8)&0xff,低字节在前,符合小端。

命名坑 :ble_basic 里这个数组叫 adv_service_uuid16,但它是 16 个字节 的 128 位 UUID;ble_env 里同样作用的数组叫 adv_service_uuid128,名字才是对的。读代码时别被变量名带偏,看 sizeof 和长度字段。

2.6 两个例程的 UUID 选型对比

这是本篇最值得记的一点。两个工程的 UUID 定义:

c 复制代码
// ble_basic/main/ble_app.c ------ 自定义 UUID
static const uint16_t GATTS_SERVICE_UUID_TEST       = 0x18FF;   //自定义服务1
static const uint16_t GATTS_CHAR_UUID_CH1           = 0x2AFE;   //特征1 UUID
static const uint16_t GATTS_CHAR_UUID_CH2           = 0x2AFF;   //特征2 UUID

static const uint16_t GATTS_SERVICE2_UUID_TEST       = 0x18F3;   //自定义服务2
static const uint16_t GATTS_CHAR2_UUID_CH1           = 0x2AF1;   //特征1 UUID
static const uint16_t GATTS_CHAR2_UUID_CH2           = 0x2AF2;   //特征2 UUID
c 复制代码
// ble_env/main/ble_app.c ------ 标准 UUID
static const uint16_t GATTS_SERVICE_UUID_TEST       = 0x181A;     //自定义服务
static const uint16_t GATTS_CHAR_UUID_TEMP          = 0x2A6E;    //特征1 温度
static const uint16_t GATTS_CHAR_UUID_HUMIDITY      = 0x2A6F;     //特征2 湿度
static const uint16_t GATTS_CHAR_UUID_LED           = 0x2A57;    //特征3 数字输出(LED)

注释都写着"自定义",但两边的性质完全不同:

常量 值 SIG 是否已分配 手机 App 里的显示
ble_env 服务 0x181A 是,Environmental Sensing Service 显示「环境感知」
ble_env 特征 1 0x2A6E 是,Temperature,单位 0.01 ℃ 显示「温度」并能自动解析数值
ble_env 特征 2 0x2A6F 是,Humidity,单位 0.01 % 显示「湿度」
ble_env 特征 3 0x2A57 是,Digital Output,1 字节位域 显示「数字输出」
ble_basic 服务 0x18FF / 0x18F3 否,SIG 分配表里查不到 只显示一串 UUID
ble_basic 特征 0x2AFE / 0x2AFF / 0x2AF1 / 0x2AF2 否 同上

差别在调试体验上是天壤之别:

  • 用 ble_env,手机装个 nRF Connect 或 LightBlue,连上就能看到「温度 26.50 °C」「湿度 60.00 %」这样带名字带单位的条目,点一下就能读。
  • 用 ble_basic,看到的是一堆 Unknown Service / Unknown Characteristic,只有十六进制,得自己对照代码才知道哪个是哪个。

结论:自己做产品时,如果功能能对应到 SIG 已有的服务,就用标准 UUID,客户端不用专门适配,通用 App 直接就能用。实在找不到对应的(比如你要传一个厂商私有的传感器值),再退回 128 位随机 UUID ------ 128 位空间足够大,不会撞车。

严格说,16 位 UUID 空间是 SIG 的 ,未分配的号段规范上不允许厂商自己占用。ble_basic 用 0x18FF/0x18F3 属于"教学演示可以、产品别这么干"。要么用标准 UUID,要么老老实实生成一个 128 位的随机 UUID。

三、广播(Advertising)

3.1 广播信道与广播间隔

BLE 在 2.4 GHz 上开了 40 个信道 ,间隔 2 MHz。其中三个被留作广播信道:

信道号 中心频率 用途
37 2402 MHz 广播
38 2426 MHz 广播
39 2480 MHz 广播
0 ~ 36 2404 ~ 2478 MHz 连接后的数据信道(跳频)

选这三个信道不是随意的------它们正好避开了 WiFi 最常用的 1、6、11 信道的中心频率,降低互相干扰。这也是 BLE 和 WiFi 能在一台 ESP32 上共存的基础。

广播间隔的单位是 0.625 ms,代码里:

c 复制代码
//广播参数
static esp_ble_adv_params_t adv_params = {
    .adv_int_min         = 0x20,                //最小广播间隔,单位:0.625ms
    .adv_int_max         = 0x40,                //最大广播间隔,单位:0.625ms
    .adv_type            = ADV_TYPE_IND,        //广播类型(可连接的非定向广播)
    .own_addr_type       = BLE_ADDR_TYPE_PUBLIC,//使用固定地址广播
    .channel_map         = ADV_CHNL_ALL,        //在37、38、39信道进行广播
    .adv_filter_policy   = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY,   //广播过滤策略(接收任何scan和任何连接)
};
  • adv_int_min = 0x20 = 32 × 0.625 ms = 20 ms
  • adv_int_max = 0x40 = 64 × 0.625 ms = 40 ms
  • 协议规定非定向广播的间隔下限就是 0x0020(20 ms),这里正好取到最小值。广播间隔越短,手机扫到得越快,但射频开得越频繁、越费电。产品里通常取 100 ms 以上。

3.2 广播数据 vs 扫描回复数据

一个 BLE 设备"被看见"的过程分两步:

  1. 广播包(ADV_IND):从机在 37/38/39 三个信道上轮流发,包里塞着广播数据。
  2. 扫描回复(SCAN_RSP) :主机(手机)如果做的是主动扫描,收到广播包后会回一个 SCAN_REQ;从机再用一个 SCAN_RSP 包回复,里面装扫描回复数据。

两个包各 31 字节(BLE 5.0 的扩展广播能更大,但手机兼容性参差,实际项目里还是按 31 字节预算)。所以可以把信息拆成两份:必须被立刻看到(Flags、设备名)放广播包,次要的(详细的厂商数据、发射功率)放扫描回复包。

c 复制代码
//adv data广播数据
static esp_ble_adv_data_t adv_data = {
    .set_scan_rsp = false,      //此广播数据是否扫描回复
    .include_name = true,       //是否包含名字
    .include_txpower = false,   //是否包含发射功率
    .min_interval = 0x0006, //最小连接间隔 单位1.25ms
    .max_interval = 0x0010, //最大连接间隔 单位1.25ms
    .appearance = 0x00,     //apperance
    .manufacturer_len = 0, //厂商信息长度
    .p_manufacturer_data =  NULL, //厂商信息
    .service_data_len = 0,      //服务数据长度
    .p_service_data = NULL,     //服务数据
    .service_uuid_len = sizeof(adv_service_uuid128),    //服务UUID长度
    .p_service_uuid = adv_service_uuid128,              //服务UUID
    .flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT),//普通发现模式|不支持EDR经典蓝牙
};

几个关键字段:

  • min_interval / max_interval:这是期望的连接间隔范围 ,会作为「从机连接间隔范围」这个 AD 结构发出去,单位 1.25 ms(注意和广播间隔的 0.625 ms 不是一个单位!)。0x0006 = 7.5 ms,0x0010 = 20 ms。
  • flag:两个标志位拼起来 = 0x02 | 0x04 = 0x06 。ESP_BLE_ADV_FLAG_GEN_DISC 表示「普通可发现模式」,ESP_BLE_ADV_FLAG_BREDR_NOT_SPT 表示「本机不支持经典蓝牙」------这条很重要,它告诉手机"别去试 SPP 那条路"。空中这 3 个字节是 02 01 06(长度 2、类型 0x01 是 Flags、值 0x06)。
  • service_uuid_len / p_service_uuid:把服务 UUID 放进广播包,手机就能在还没连接的时候按服务过滤设备(比如只看环境感知类的设备)。这里填的是 16 字节的 128 位 UUID。

31 字节预算要自己算 :Flags 占 3 字节,128 位服务 UUID 占 18 字节(11 07 + 16 字节),设备名 "ESP32-HOME" 占 12 字节(0B 09 + 10 字节)。3 + 18 + 12 = 33 字节,已经超过 31 。而工程里 adv_data 和 scan_rsp_data 两边都开了 include_name = true 。如果你发现手机扫到设备但名字显示不出来、或者干脆扫不到服务,第一个要试的就是把 adv_data 里的 .include_name 改成 false,只让扫描回复包带名字------扫描回复另有一个独立的 31 字节预算,正好装得下。

3.3 广播类型

adv_type 决定这个包是干什么用的:

宏 含义 能被连接 典型用途
ADV_TYPE_IND 可连接的非定向广播 是 最常用,本工程用的就是它
ADV_TYPE_DIRECT_IND_HIGH 可连接的定向广播,高频 是 重连已知设备,间隔 3.75 ms
ADV_TYPE_DIRECT_IND_LOW 可连接的定向广播,低频 是 同上,间隔 1.28 s
ADV_TYPE_SCAN_IND 可扫描的非定向广播 否 只让扫,不让连
ADV_TYPE_NONCONN_IND 不可连接的非定向广播 否 iBeacon 这类纯信标

own_addr_type = BLE_ADDR_TYPE_PUBLIC 表示用芯片出厂烧录在 eFuse 里的公共地址 (相当于蓝牙 MAC)。每次上电都一样,所以手机能记住这台设备。另一种选择 BLE_ADDR_TYPE_RANDOM 用随机地址,可以定期换,隐私性更好,代价是手机认不出"还是上次那台"。

3.4 adv_config_done:为什么要两个标志位

esp_ble_gap_config_adv_data() 是异步 的------调用之后立刻返回,真正设置完成会各发一个事件。而 esp_ble_gap_start_advertising() 必须等两份数据都设置完才能调,否则先启动的话,后来的数据就丢了。

ESP-IDF 的标准解法是拿一个字节当标志位集合:

c 复制代码
static uint8_t adv_config_done       = 0;

#define ADV_CONFIG_FLAG             (1 << 0)
#define SCAN_RSP_CONFIG_FLAG        (1 << 1)

注册事件里置位:

c 复制代码
    //设置广播数据
    esp_err_t ret = esp_ble_gap_config_adv_data(&adv_data);
    if (ret){
        ESP_LOGE(TAG, "config adv data failed, error code = %x", ret);
    }
    adv_config_done |= ADV_CONFIG_FLAG;
    //设置扫描回复参数
    ret = esp_ble_gap_config_adv_data(&scan_rsp_data);
    if (ret){
        ESP_LOGE(TAG, "config scan response data failed, error code = %x", ret);
    }
    adv_config_done |= SCAN_RSP_CONFIG_FLAG;

GAP 回调里逐位清零,清零到 0 才启动广播:

c 复制代码
case ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT:     //设置广播数据成功事件
    adv_config_done &= (~ADV_CONFIG_FLAG);
    if (adv_config_done == 0){
        esp_ble_gap_start_advertising(&adv_params);
    }
    break;
case ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT:    //设置扫描回复数据成功事件
    adv_config_done &= (~SCAN_RSP_CONFIG_FLAG);
    if (adv_config_done == 0){
        esp_ble_gap_start_advertising(&adv_params);
    }
    break;

两个回调里都写了 if (adv_config_done == 0) ------ 无论哪个先完成,只要两个都完成了就会启动,不会漏也不会重复。

严格说这两个标志位应该在调用 esp_ble_gap_config_adv_data() 之前就置位,工程里(以及 ESP-IDF 官方 demo 里)都写在之后。之所以实际不出问题,是因为 GAP 回调也是由蓝牙协议栈的任务串行派发的,这一句执行完之前回调不会跑。但如果哪天你把这段代码改成多任务调用,就得按严谨的写法来。

四、连接(Connection)

4.1 从广播到连接

主机扫描到想要的设备后,发一个 CONNECT_IND 包(里面带着它选定的连接参数),连接就建立了。从这一刻起:

  • 双方不再使用广播信道,改用 0~36 号数据信道。
  • 每个「连接事件」双方都会跳频,按一个约定的伪随机序列换信道,抗干扰。
  • 连接建立后 ESP32 会收到两次事件:ESP_GATTS_CONNECT_EVT(GATTS 回调,附 conn_id 和对方地址)和 ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT(GAP 回调,参数更新完成后)。

4.2 连接参数怎么调

c 复制代码
case ESP_GATTS_CONNECT_EVT:     //收到连接事件
    ESP_LOGI(TAG, "ESP_GATTS_CONNECT_EVT, conn_id = %d", param->connect.conn_id);
    esp_log_buffer_hex(TAG, param->connect.remote_bda, 6);
    esp_ble_conn_update_params_t conn_params = {0};
    memcpy(conn_params.bda, param->connect.remote_bda, sizeof(esp_bd_addr_t));
    /* For the iOS system, please refer to Apple official documents about the BLE connection parameters restrictions. */
    conn_params.latency = 0;       //从机延迟
    conn_params.max_int = 0x20;    // 最大连接间隔 = 0x20*1.25ms = 40ms
    conn_params.min_int = 0x10;    // 最小连接间隔 = 0x10*1.25ms = 20ms
    conn_params.timeout = 400;    // 监控超时 = 400*10ms = 4000ms
    //start sent the update connection parameters to the peer device.
    esp_ble_gap_update_conn_params(&conn_params);
    gl_conn_id = param->connect.conn_id;
    break;

先记住三套不同的时间单位,这是 BLE 最容易翻车的地方:

参数 单位 换算
广播间隔 adv_int_min/max 0.625 ms 0x20 = 20 ms
连接间隔 min_int/max_int 1.25 ms 0x10 = 20 ms,0x20 = 40 ms
监督超时 timeout 10 ms 400 = 4000 ms

三个参数的物理含义:

  • 连接间隔(Connection Interval) :两个连接事件之间的时间。它是"省电"和"响应快"之间的旋钮。间隔短 → 数据来回快、功耗高;间隔长 → 省电、但手机点一下要等最多一个间隔才收到。注意这是主从双方协商出来的,从机只能"请求" ,最终由主机(手机)拍板。所以你会看到 ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT 里打印出来的值未必等于你请求的值。
  • 从机延迟(Slave Latency):允许从机连续跳过 N 个连接事件不应答,仍然保持连接。取 0 表示每个连接事件都必须醒来听。想让设备省电就调大(比如 4,配合 100 ms 间隔就是 500 ms 醒一次),想让它响应快就取 0。本工程取 0,因为要实时推数据。
  • 监督超时(Supervision Timeout) :多久收不到对方的包就判定断线。规范要求它必须满足 timeout > (1 + latency) × max_int × 2。代入本工程:(1 + 0) × 40 ms × 2 = 80 ms,远小于 4000 ms,合法。这个值不是随便填的,填小了连接会莫名其妙断掉。

代码注释里特意提了 iOS:苹果对连接参数有硬性区间 (大致是连接间隔 ≥ 15 ms、从机延迟 ≤ 30、监督超时 ≥ 2 × 最大连接间隔)。如果参数超出苹果允许的范围,iOS 会直接拒绝这次参数更新,返回错误,而且不同 iOS 版本的行为还不完全一样。跨 iOS/Android 的产品,参数要按苹果那份 Accessory Design Guidelines 的下限来取。

4.3 MTU

ATT 层默认的最大传输单元(MTU)是 23 字节 ,扣除 ATT 报文头的 3 字节,一次最多只能传 20 字节有效数据。想传更长的数据就要做 MTU 交换。

c 复制代码
ESP_ERROR_CHECK(esp_ble_gatt_set_local_mtu(500));

这一句设置的是本地能接受的最大 MTU。但要说清楚:

  • 它是能力上限 ,不是最终值。实际生效的 MTU 是双方取小,而且通常要由客户端发起 MTU 交换请求(手机连上后一般都会主动发)。
  • 交换完成后会收到 ESP_GATTS_MTU_EVT,里面 param->mtu.mtu 才是真正协商出来的值:
c 复制代码
case ESP_GATTS_MTU_EVT:
    ESP_LOGI(TAG, "ESP_GATTS_MTU_EVT, MTU %d", param->mtu.mtu);
    break;
  • 本工程传的数据只有 1~2 字节,根本用不到 500。写 500 是留余量。要注意的是它不能随便写大:Bluedroid 的 MTU 上限是 517,而且调大 MTU 会浪费内存,因为协议栈是按可能的最大长度预留缓冲的。

4.4 断开之后要重新广播

c 复制代码
case ESP_GATTS_DISCONNECT_EVT:  //收到断开连接事件
    ESP_LOGI(TAG, "ESP_GATTS_DISCONNECT_EVT, reason = 0x%x", param->disconnect.reason);
    esp_ble_gap_start_advertising(&adv_params);
    gl_conn_id = 0xFFFF;
    break;

这是新手最常踩的坑 :BLE 设备在连接建立后会自动停止广播,断开后不会自动恢复 。必须自己在断开事件里重新调用 esp_ble_gap_start_advertising(),否则看起来就是"连过一次之后手机就再也搜不到了",只能重启板子。

同时把 gl_conn_id 复位成 0xFFFF------代码里用这个值当"未连接"的哨兵(ESP_GATT_IF_NONE 是 0xFF,两者不一样,别混)。

4.5 通知与指示:怎么把数据推给手机

BLE 的 GATT 主要是"客户端拉"的模型:手机读一下,服务器答一下。要让服务器主动推,用通知或指示:

方式 中文 要对方确认吗 速度 可靠性
Notification 通知 不需要 快,一个连接事件可以发多个 可能丢
Indication 指示 需要(收到 CONFIRM) 慢,一次只能有一个在途 可靠

ESP-IDF 里两个是同一个 API,靠最后一个参数区分:

c 复制代码
esp_ble_gatts_send_indicate(gl_gatts_if, gl_conn_id, 句柄, 长度, 数据, need_confirm);
//                                                                      ^^^^^^^^^^^^
//                                                      false = 通知,true = 指示

名字里带 indicate 但实际发通知,是 ESP-IDF 里一个让人困惑的历史遗留,记住就行。

发通知前必须满足两个条件,ble_env 里写得很清楚:

c 复制代码
void ble_set_temp_value(uint16_t value)
{
    temp_value[0] = value&0xff;
    temp_value[1] = value>>8;
    //判断连接是否有效,以及客户端特征配置是否不为0
    if(gl_conn_id != 0xFFFF && (sv1_ch1_client_cfg[0] | sv1_ch1_client_cfg[1]))
    {
        esp_ble_gatts_set_attr_value(netcfg_handle_table[SV1_CH1_IDX_CHAR_VAL], 2, (const uint8_t*)&temp_value);
        esp_ble_gatts_send_indicate(gl_gatts_if, gl_conn_id,netcfg_handle_table[SV1_CH1_IDX_CHAR_VAL], 2, (uint8_t*)&temp_value, false);
    }
}
  1. 有连接 :gl_conn_id != 0xFFFF。
  2. 客户端订阅了 :sv1_ch1_client_cfg 这个数组就是 CCCD 的镜像,手机写 01 00 之后它就不为 0。没订阅就发,调用会失败,所以这个判断必须写。

另外注意 esp_ble_gatts_set_attr_value() 这一句:它更新协议栈里保存的属性值。如果只发通知不更新,那么手机主动去读这个特征时会读到旧值(初始值)。这两句要成对出现,顺序无所谓但都不能少。

五、代码分析:ble_basic

5.1 工程结构

复制代码
ble_basic/
├── CMakeLists.txt
└── main/
    ├── CMakeLists.txt
    ├── ble_app.c       540 行,BLE 协议栈的全部逻辑
    ├── ble_app.h        对外只暴露三个函数
    └── ble_basic.c      26 行,app_main + 喂数据的任务

ble_app.h 的接口非常干净,值得学习:

c 复制代码
esp_err_t ble_cfg_net_init(void);
void ble_set_ch1_value(uint16_t value);
void ble_set_ch2_value(uint16_t value);

BLE 的全部复杂度都关在 ble_app.c 里,应用层只需要"初始化一次 + 想发数据时调一下"。ble_env 沿用了同样的结构,只是把接口换成了 ble_set_temp_value() / ble_set_humidity_value()。这种分层是写 BLE 代码的推荐姿势,不要把协议栈的细节泄漏到业务代码里。

5.2 用枚举给属性表做索引

c 复制代码
//蓝牙模块
enum
{
    //服务1
    SV1_IDX_SVC,

    //特征1
    SV1_CH1_IDX_CHAR,
    SV1_CH1_IDX_CHAR_VAL,
    SV1_CH1_IDX_CHAR_CFG,

    //特征2
    SV1_CH2_IDX_CHAR,
    SV1_CH2_IDX_CHAR_VAL,
    SV1_CH2_IDX_CHAR_CFG,

    SV1_IDX_NB,
};

这是 ESP-IDF 的经典写法,靠枚举自增自动编号。这里要理解三件事:

  • SV1_IDX_NB 是最后一项,值等于前面所有项的数量(这里是 7),专门用来当数组长度。这种"在枚举末尾放 XXXX_NB"的写法在很多 C 项目里都有。
  • 每个特征占 3 项:声明(CHAR)、值(CHAR_VAL)、配置(CFG)。所以"两个特征一个服务"是 1 + 3 + 3 = 7 项。
  • 这个编号只是数组下标 ,和协议栈分配的句柄没有关系。sv1_handle_table[SV1_CH1_IDX_CHAR_VAL] 的意思才是"第 1 个服务第 1 个特征的值,对应的句柄是多少"。

ble_env 是单服务三特征,所以枚举里只有一组 SV1_*,共 1 + 3×3 = 10 项 ,SV_IDX_NB = 10。

5.3 属性表

以 ble_basic 的服务 1 为例:

c 复制代码
//gatt描述表
static const esp_gatts_attr_db_t gatt1_db[SV1_IDX_NB] =
{
    //服务声明
    [SV1_IDX_SVC]        =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&primary_service_uuid, ESP_GATT_PERM_READ,
      sizeof(uint16_t), sizeof(GATTS_SERVICE_UUID_TEST), (uint8_t *)&GATTS_SERVICE_UUID_TEST}},

    //特征1
    //特征声明
    [SV1_CH1_IDX_CHAR]     =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
      sizeof(uint8_t), sizeof(uint8_t), (uint8_t *)&char_prop_read_write_notify}},   //特征值1允许读写和通知
    //特征值
    [SV1_CH1_IDX_CHAR_VAL] =
    {{ESP_GATT_RSP_BY_APP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_CH1, ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE, 
      sizeof(sv1_char1_value), sizeof(sv1_char1_value), (uint8_t *)sv1_char1_value}},
    //特征描述->客户端特征配置
    [SV1_CH1_IDX_CHAR_CFG]  =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_client_config_uuid, ESP_GATT_PERM_READ|ESP_GATT_PERM_WRITE,
      sizeof(uint16_t), sizeof(sv1_ch1_client_cfg), (uint8_t *)sv1_ch1_client_cfg}},
    ...
};

结构体嵌套有点吓人,拆开看是两层:

c 复制代码
typedef struct
{
    uint8_t auto_rsp;              // 读/写由谁应答
} esp_attr_control_t;

typedef struct
{
    uint16_t uuid_length;          // UUID 长度:16 位还是 128 位
    uint8_t  *uuid_p;              // UUID 值
    uint16_t perm;                 // 权限:读/写/加密
    uint16_t max_length;           // 值的最大长度
    uint16_t length;               // 值的当前长度
    uint8_t  *value;               // 值的指针
} esp_attr_desc_t;

typedef struct
{
    esp_attr_control_t      attr_control;
    esp_attr_desc_t         att_desc;
} esp_gatts_attr_db_t;

(结构体定义取自 ESP-IDF 的 esp_gatt_defs.h,注释是头文件原文翻译。)

三项属性的填法对照:

项 uuid perm value 指向 auto_rsp
服务声明 0x2800 只读 服务 UUID AUTO_RSP
特征声明 0x2803 只读 属性位(1 字节) AUTO_RSP
特征值 特征自己的 UUID 读 / 读写 数据缓冲 RSP_BY_APP
CCCD 0x2902 读写 xx_client_cfg AUTO_RSP

ESP_GATT_AUTO_RSP 和 ESP_GATT_RSP_BY_APP 的区别是这个表里最重要的设计决策:

  • ESP_GATT_AUTO_RSP:读的时候协议栈直接把 value 指针指向的内容回给客户端;写的时候自动存进去。应用层完全不用管。
  • ESP_GATT_RSP_BY_APP:读请求会变成 ESP_GATTS_READ_EVT 事件交给应用,应用必须自己调 esp_ble_gatts_send_response();写同理。

工程里把特征值 全部设成 RSP_BY_APP,理由很实际:数据的来源是任务里随时更新的变量,而且读写可能要触发副作用(比如写 LED 就点亮灯)。而服务声明、特征声明、CCCD 这些"元数据"内容固定,交给协议栈自动应答最省事。

用 RSP_BY_APP 的代价是忘了回响应手机就会一直卡住 。读事件里必须调 esp_ble_gatts_send_response();写事件里如果 param->write.need_rsp 为真,也必须回。本工程两处都写了,改代码时别不小心删掉。

5.4 注册两张表

ble_basic 有两个服务,所以要注册两张表:

c 复制代码
//注册attr表1
esp_err_t create_attr_ret = esp_ble_gatts_create_attr_tab(gatt1_db, gatts_if, SV1_IDX_NB, SVC_IND_ID1);
if (create_attr_ret){
    ESP_LOGE(TAG, "create attr1 table failed, error code = %x", create_attr_ret);
}
//注册attr表2
create_attr_ret = esp_ble_gatts_create_attr_tab(gatt2_db, gatts_if, SV2_IDX_NB, SVC_IND_ID2);
if (create_attr_ret){
    ESP_LOGE(TAG, "create attr2 table failed, error code = %x", create_attr_ret);
}

最后一个参数 SVC_IND_ID1(0)和 SVC_IND_ID2(1)是服务实例号 ,作用是在 ESP_GATTS_CREAT_ATTR_TAB_EVT 里区分"哪张表注册完了":

c 复制代码
if(param->add_attr_tab.svc_inst_id == SVC_IND_ID1)
{
    ... memcpy(sv1_handle_table, param->add_attr_tab.handles, sizeof(sv1_handle_table));
    esp_ble_gatts_start_service(sv1_handle_table[SV1_IDX_SVC]);
}
else if(param->add_attr_tab.svc_inst_id == SVC_IND_ID2)
{
    ... memcpy(sv2_handle_table, param->add_attr_tab.handles, sizeof(sv2_handle_table));
    esp_ble_gatts_start_service(sv2_handle_table[SV2_IDX_SVC]);
}

同一个 gatts_if(同一个 Profile)上可以挂多个服务,靠实例号区分。两张表要各自复制句柄、各自启动服务,不能共用一张句柄表。

5.5 应用任务

ble_basic.c 一共 26 行:

c 复制代码
void mytask1(void* param)
{
    uint16_t count1 = 1;
    uint16_t count2 = 100;
    while(1)
    {
        vTaskDelay(pdMS_TO_TICKS(3000));
        ble_set_ch1_value(count1++);
        vTaskDelay(pdMS_TO_TICKS(700));
        ble_set_ch2_value(count2++);
    }
}

void app_main(void)
{
    ESP_ERROR_CHECK(nvs_flash_init());
    ble_cfg_net_init();
    xTaskCreatePinnedToCore(mytask1,"mytask",4096,NULL,3,NULL,1);
}

两个计数器以不同节奏变化:特征 1 每 3 秒加 1,特征 2 每 0.7 秒加 1,这样在手机上一眼就能看出"哪个特征是哪个"。count2 从 100 开始,也是为了区分。

xTaskCreatePinnedToCore(..., 1) 把任务绑到 core 1 。这是因为 sdkconfig 里 CONFIG_BT_BLUEDROID_PINNED_TO_CORE=0,蓝牙协议栈的任务跑在 core 0。分开绑核能避免应用任务和协议栈抢 CPU 导致蓝牙丢包。

app_main 里 nvs_flash_init() 没做失败重试。这在 BLE 工程里其实是个隐患------NVS 分区损坏或版本不匹配时应该 erase 再 init(ble_env.c 就写了这段)。ble_basic 靠 ESP_ERROR_CHECK 直接报错退出,调试时容易摸不着头脑。

5.6 这份代码里的句柄串线问题

ble_basic 是一份教学代码 ,里面有几处明显的索引混淆。之所以要专门讲,是因为它踩的正是 GATT 编程最容易出错的地方------句柄表是个手写索引的数组,写错一个下标,数据就串到别的特征上去了,而且往往不报错。

问题一:特征值变量用错。

c 复制代码
//char1的值
static char sv1_char1_value[2] = {0x00,0x16};
//char2的值
static char sv1_char2_value[2] = {0x00,0x25};

但读事件里:

c 复制代码
if(sv1_handle_table[SV1_CH1_IDX_CHAR_VAL] == param->read.handle)
{
    rsp.attr_value.len = sizeof(sv1_char2_value);
    memcpy(rsp.attr_value.value,sv1_char2_value,sizeof(sv1_char2_value));
}
if(sv1_handle_table[SV1_CH2_IDX_CHAR_VAL] == param->read.handle)
{
    rsp.attr_value.len = sizeof(sv2_char2_value);
    memcpy(rsp.attr_value.value,sv2_char2_value,sizeof(sv2_char2_value));
}
  • 读服务 1 特征 1 ,返回的是 sv1_char2_value(特征 2 的变量)。
  • 读服务 1 特征 2 ,返回的是 sv2_char2_value(服务 2 的变量)。

两处都应该是 sv1_char1_value 和 sv1_char2_value。

问题二:写事件存错变量。

c 复制代码
else if(param->write.handle == sv1_handle_table[SV1_CH1_IDX_CHAR_VAL])   //特征1的值
{
    sv1_char2_value[0] = param->write.value[0];
    sv1_char2_value[1] = param->write.value[1];
}

写特征 1,存进的是 sv1_char2_value。

有趣的是这两个错误互相抵消 了:手机写特征 1 → 改 sv1_char2_value → 再读特征 1 → 返回 sv1_char2_value → 手机读回了自己刚写的值。单看"读回自己写的值"这个测试,行为是正确的。但只要你交叉验证(写特征 1、读特征 2)或者用通知,就会立刻露馅。

问题三:ble_set_ch2_value() 用错句柄表。

c 复制代码
void ble_set_ch2_value(uint16_t value)
{
    sv2_char2_value[0] = value&0xff;
    sv2_char2_value[1] = value>>8;
    //判断连接是否有效,以及客户端特征配置是否不为0
    if(gl_conn_id != 0xFFFF && (sv1_ch2_client_cfg[0] | sv1_ch2_client_cfg[1]))
    {
        esp_ble_gatts_set_attr_value(sv1_handle_table[SV1_CH2_IDX_CHAR_VAL], 2, (const uint8_t*)&sv2_char2_value);
        esp_ble_gatts_send_indicate(gl_gatts_if, gl_conn_id,sv1_handle_table[SV1_CH2_IDX_CHAR_VAL], 2, (uint8_t*)&sv2_char2_value, false);
    }
}

函数名是"设置服务 2 的特征 2",变量也是 sv2_char2_value,但句柄查的是 sv1_handle_table,订阅标志查的是 sv1_ch2_client_cfg。后果是:客户端订阅服务 2 的特征 2 收不到任何通知;反倒是订阅"服务 1 特征 2"会收到服务 2 的数据。

问题四:数组长度写成另一个枚举。

c 复制代码
static const esp_gatts_attr_db_t gatt2_db[SV1_IDX_NB] = 

服务 2 的表用 SV1_IDX_NB 声明长度。因为两个枚举碰巧都是 7,编译不报错,纯属笔误。但如果哪天给服务 2 加了特征,这个数组就会越界。

教训 :esp_ble_gatts_create_attr_tab() 传的 num_handle 参数(这里是 SV2_IDX_NB)和数组的真实大小是两个独立的东西 ,编译器不会帮你核对。永远用同一套枚举常量,并且依赖 ESP_GATTS_CREAT_ATTR_TAB_EVT 里那个 num_handle != SVx_IDX_NB 的检查来兜底。

另外,这些错误在编译期和运行期都不会报错 ------GATT 句柄只是个 uint16_t,传错一个值协议栈照样接受。只能靠功能测试发现。这也是为什么下一节的 ble_env(单服务、没有索引混淆)更值得当模板。

六、代码分析:ble_env

ble_env 把 BLE 和真实硬件接了起来:DHT11 采温湿度往上推,手机下发 LED 开关往下走。

6.1 工程结构

复制代码
ble_env/
├── CMakeLists.txt
└── main/
    ├── CMakeLists.txt
    ├── ble_app.c       476 行,BLE 逻辑(单服务三特征)
    ├── ble_app.h
    ├── ble_env.c        63 行,app_main + 采集任务 + GPIO 初始化
    ├── dht11.c / dht11.h   DHT11 驱动

ble_env.c 的 app_main:

c 复制代码
void app_main(void)
{
    //初始化nvs
    esp_err_t ret_val = ESP_OK;
    ret_val = nvs_flash_init();
    if (ret_val == ESP_ERR_NVS_NO_FREE_PAGES || ret_val == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret_val = nvs_flash_init();
    }
    ESP_ERROR_CHECK( ret_val );

    //初始化蓝牙
    ble_cfg_net_init();

    //初始化DHT11
    DHT11_Init(DHT11_GPIO);

    //初始化GPIO
    gpio_config_t led_cfg = 
    {
        .intr_type = GPIO_INTR_DISABLE,
        .pull_up_en = GPIO_PULLUP_DISABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .mode = GPIO_MODE_OUTPUT,
        .pin_bit_mask = (1ull<<LED_GPIO),
    };
    gpio_config(&led_cfg);

    //新建任务
    xTaskCreatePinnedToCore(mytask1,"mytask",4096,NULL,3,NULL,1);
}

顺序上必须先 ble_cfg_net_init() 再初始化 GPIO ------不是强制要求,但属性表里 LED 特征的初始值 led_value[1] = {0} 是灭的,先把蓝牙跑起来再配 GPIO,手机连上时看到的状态才和实际一致。

nvs_flash_init() 的失败重试写法是标准模板 ,每个 ESP-IDF 工程都该这么写:NVS 分区被写坏或格式版本升级时会返回这两个错误码,擦掉重建即可。ble_basic 里省略了这段,可以自己补上。

引脚定义在 ble_env.c 顶部:

c 复制代码
#define DHT11_GPIO      25              // DHT11引脚定义
#define LED_GPIO        27              // LED定义

引脚要改两处 :ble_env.c 里用的是宏 LED_GPIO,但 ble_app.c 写 LED 的那一行是硬编码 的 gpio_set_level(GPIO_NUM_27, ...)。换板子改引脚时,两个文件都要改,漏一个就会出现"蓝牙上报正常但灯不亮"的怪现象。

6.2 属性表:一个服务三个特征

c 复制代码
static const uint16_t GATTS_SERVICE_UUID_TEST       = 0x181A;     //自定义服务
static const uint16_t GATTS_CHAR_UUID_TEMP          = 0x2A6E;    //特征1 温度
static const uint16_t GATTS_CHAR_UUID_HUMIDITY      = 0x2A6F;     //特征2 湿度
static const uint16_t GATTS_CHAR_UUID_LED           = 0x2A57;    //特征3 数字输出(LED)

三个特征的性质各不相同,这是设计 GATT 服务时的典型套路:

特征 UUID 属性位 权限 数据方向 长度
温度 0x2A6E 读 + 通知 只读 上行(板子 → 手机) 2 字节
湿度 0x2A6F 读 + 通知 只读 上行 2 字节
LED 0x2A57 读 + 写 + 通知 读写 下行(手机 → 板子) 1 字节

"只上行"的特征给只读权限,"只下行"的给写权限,这个权限(perm)和属性位(char_prop_*)是两道独立的闸门,要一致设置。 属性位是"告诉客户端我能做什么",权限是"协议栈实际放不放行"。两个地方不一致时,手机看到可写但实际写不进去(或者反过来),非常难查。

看 LED 特征这一项:

c 复制代码
    //特征3 LED
    //特征声明
    [SV1_CH3_IDX_CHAR]      =
    {{ESP_GATT_AUTO_RSP}, {ESP_UUID_LEN_16, (uint8_t *)&character_declaration_uuid, ESP_GATT_PERM_READ,
      sizeof(uint8_t), sizeof(uint8_t), (uint8_t *)&char_prop_read_write_notify}},  //LED值允许读写

    //特征值
    [SV1_CH3_IDX_CHAR_VAL]  =
    {{ESP_GATT_RSP_BY_APP}, {ESP_UUID_LEN_16, (uint8_t *)&GATTS_CHAR_UUID_LED, ESP_GATT_PERM_READ|ESP_GATT_PERM_WRITE,
      sizeof(led_value), sizeof(led_value), (uint8_t *)led_value}},

led_value 是 uint8_t led_value[1] = {0},1 个字节。这符合 0x2A57 Digital Output 的规范:它的值是一个 8 位的位图,bit0 表示第一路数字输出。用 1 个字节是正确 的------而温度和湿度那两个用的是 2 字节的 uint16_t,也符合规范。

6.3 下行:手机怎么控制 LED

c 复制代码
else if(param->write.handle == netcfg_handle_table[SV1_CH3_IDX_CHAR_CFG])   //特征3客户端特征配置
{
    sv1_ch3_client_cfg[0] = param->write.value[0];
    sv1_ch3_client_cfg[1] = param->write.value[1];
}
else if(param->write.handle == netcfg_handle_table[SV1_CH3_IDX_CHAR_VAL])   //LED值
{
    led_value[0] = param->write.value[0];
    //改写LED的值
    ESP_LOGI(TAG,"led value:%d",led_value[0]);
    gpio_set_level(GPIO_NUM_27,led_value[0]?1:0);
    

}

if (param->write.need_rsp){
    esp_ble_gatts_send_response(gatts_if, param->write.conn_id, param->write.trans_id, ESP_GATT_OK, NULL);
}

流程是:手机往 LED 特征值里写一个字节 → 触发 ESP_GATTS_WRITE_EVT → 按句柄匹配到 LED 特征 → 存进 led_value → 直接调 gpio_set_level() 点灯。

注意 else if 链的顺序:CCCD 的判断写在特征值前面。理论上 CCCD 和特征值的句柄不会重复,写反了也不影响;但把 CCCD 放在前面是更好的习惯------因为 CCCD 的写入是"订阅动作",语义上优先级更高,而且有些客户端会把 CCCD 写到属性表末尾。

led_value[0]?1:0 这个写法做了保护:手机如果写了个 0x02 过来,也只会当成"1"(亮),不会把 gpio_set_level() 喂个非 0/1 的值进去。

这里有个可以改进的地方 :LED 特征的属性位里带了 NOTIFY,但没有在写事件里发通知。如果手机可能有多个(比如手机 App 和另一个遥控器同时连),其中一个改了灯的状态,另一个不会知道。要补的话,在 gpio_set_level() 之后加一句:

c 复制代码
    if(gl_conn_id != 0xFFFF && (sv1_ch3_client_cfg[0] | sv1_ch3_client_cfg[1]))
    {
        esp_ble_gatts_send_indicate(gl_gatts_if, gl_conn_id, netcfg_handle_table[SV1_CH3_IDX_CHAR_VAL], 1, led_value, false);
    }

6.4 上行:DHT11 数据定时上报

c 复制代码
void mytask1(void* param)
{
    int tempx10 = 0;
    int humidity = 0;
    while(1)
    {
        if(DHT11_StartGet(&tempx10,&humidity))
        {
            ESP_LOGI(TAG,"temperature:%.1f,humidity:%i%%",(float)tempx10/10.0,humidity);
            ble_set_temp_value(tempx10&0xffff);
            ble_set_humidity_value(humidity&0xffff);
        }
        vTaskDelay(pdMS_TO_TICKS(1500));
    }
}

循环每 1.5 秒采一次 DHT11,成功就调 ble_set_temp_value() / ble_set_humidity_value() 推给手机。变量名 tempx10 和 dht11.h 里的注释「温度值X10」都说明:温度是以"实际温度 × 10"的整数形式保存的,比如 26.5 ℃ 存成 265。湿度是整数百分比,比如 60 表示 60%。

上位函数每次都会把 gl_conn_id 和 CCCD 判断一遍,所以"手机没连"或"手机没订阅"时,只是安静地不发送,不影响采集循环继续跑。这个模式是对的。

6.5 单位问题:数值对了,含义不对

这是 ble_env 最值得注意的地方。既然用了 SIG 标准 UUID,就得按 SIG 规定的数值格式来填,否则手机 App 会按规范去解读,显示出来的数字就是错的。

看规范(可在蓝牙 SIG Assigned Numbers 文档里查到):

特征 规范规定的格式 含义
0x2A6E Temperature uint16,单位 0.01 ℃,小端 值 2650 表示 26.50 ℃
0x2A6F Humidity uint16,单位 0.01 %,小端 值 6000 表示 60.00 %

而代码发出去的是:

c 复制代码
ble_set_temp_value(tempx10&0xffff);     // 26.5 ℃ 发出去的是 265
ble_set_humidity_value(humidity&0xffff); // 60 % 发出去的是 60

于是:

  • 温度:真实 26.5 ℃,手机按 0.01 ℃ 解读 265 → 显示 2.65 ℃
  • 湿度:真实 60 %,手机按 0.01 % 解读 60 → 显示 0.60 %

差了整整 100 倍。串口日志(%.1f 除以 10)打印的是对的,所以只看串口是发现不了的,必须在手机 App 里对着看。

修法有两种:

c 复制代码
// 方案一:上报前换算成规范单位
ble_set_temp_value((tempx10 * 10) & 0xffff);    // 26.5℃ -> 2650
ble_set_humidity_value((humidity * 100) & 0xffff);  // 60% -> 6000

// 方案二:如果不想让 BLE 层知道业务单位,就在采集侧就乘好

用标准 UUID 是双刃剑 :好处是手机 App 自动认得、自动换算;代价是你必须按规范填值,否则显示的数字会错得理直气壮(App 完全不报错,只是显示 2.65 ℃)。如果实在不想受规范约束,那就别用标准 UUID,改用自定义 UUID ------ 但那样也就失去了"通用 App 直接能用"的好处。选之前先想清楚。

七、关键 API 速查

初始化和注册

API 作用
esp_bt_controller_mem_release(mode) 释放不用的蓝牙模式的内存,必须在 init 之前
esp_bt_controller_init(&cfg) / esp_bt_controller_enable(mode) 初始化 / 启动控制器
esp_bluedroid_init_with_cfg(&cfg) / esp_bluedroid_enable() 初始化 / 启动 Bluedroid 主机
esp_ble_gatts_register_callback(cb) 注册 GATTS 事件回调
esp_ble_gap_register_callback(cb) 注册 GAP 事件回调
esp_ble_gatts_app_register(app_id) 注册一个 Profile,成功后收到 ESP_GATTS_REG_EVT
esp_ble_gatts_create_attr_tab(db, if, num, svc_inst_id) 注册一张属性表
esp_ble_gatts_start_service(handle) 启动服务,不启动客户端发现不了
esp_ble_gatt_set_local_mtu(mtu) 设置本地能接受的最大 MTU

广播

API 作用
esp_ble_gap_set_device_name(name) 设置设备名
esp_ble_gap_config_adv_data(&adv_data) 配置广播数据或扫描回复数据(看 set_scan_rsp)
esp_ble_gap_start_advertising(&params) 开始广播
esp_ble_gap_stop_advertising() 停止广播
esp_ble_gap_update_conn_params(&params) 请求更新连接参数

读写和通知

API 作用
esp_ble_gatts_send_response(if, conn, trans, status, &rsp) 对 RSP_BY_APP 的读写请求发应答
esp_ble_gatts_set_attr_value(handle, len, value) 更新协议栈里保存的属性值
esp_ble_gatts_send_indicate(if, conn, handle, len, value, need_confirm) 发通知(false)或指示(true)
esp_log_buffer_hex(tag, buf, len) 十六进制打印数据,调 BLE 必备

关键事件

事件 触发时机 要做什么
ESP_GATTS_REG_EVT Profile 注册完成 设设备名、配广播数据、注册属性表
ESP_GATTS_CREAT_ATTR_TAB_EVT 属性表注册完成 存句柄表、启动服务
ESP_GATTS_CONNECT_EVT 主机连上了 存 conn_id、请求更新连接参数
ESP_GATTS_DISCONNECT_EVT 断开了 重新开始广播 、复位 conn_id
ESP_GATTS_READ_EVT 客户端要读 填 esp_gatt_rsp_t 并回应答
ESP_GATTS_WRITE_EVT 客户端写了 按句柄分发,处理 CCCD 和数据
ESP_GATTS_MTU_EVT MTU 协商完成 记录协商结果
ESP_GAP_BLE_ADV_DATA_SET_COMPLETE_EVT 广播数据配好了 清标志位,都清完就启动广播
ESP_GAP_BLE_SCAN_RSP_DATA_SET_COMPLETE_EVT 扫描回复配好了 同上
ESP_GAP_BLE_ADV_START_COMPLETE_EVT 广播启动结果 检查 status
ESP_GAP_BLE_UPDATE_CONN_PARAMS_EVT 连接参数更新完成 打印实际生效的参数

八、编译与现象验证

两个工程的目标芯片都是 esp32 (不是 S3),Flash 4 MB,sdkconfig 里蓝牙已经在默认配置下打开了:

复制代码
CONFIG_IDF_TARGET="esp32"
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=y
CONFIG_BT_BLE_ENABLED=y
CONFIG_BT_GATTS_ENABLE=y
CONFIG_BT_BLUEDROID_PINNED_TO_CORE=0
CONFIG_BT_ACL_CONNECTIONS=4

编译烧录(对每个工程分别做):

bash 复制代码
cd esp32-board/ble_basic     # 或 ble_env
idf.py build
idf.py -p COM<端口> flash monitor

这两个工程不需要 idf.py set-target,sdkconfig 里已经定好了。但按工作区的情况,如果 IDF 是刚迁移/升级过的,先 idf.py fullclean 删掉旧的 build/ 目录再编译。

手机端准备 :装一个 BLE 调试 App。Android 上推荐 nRF Connect (Nordic 官方,免费,功能最全),iOS 上 LightBlue 或 nRF Connect 都可以。注意 iOS 上很多"蓝牙调试助手"是收费的。

ble_basic 应该看到的现象:

  1. 手机上扫描,能看到设备名 ESP32-HOME。
  2. 展开后能看到两个服务:一个显示成一串 UUID(0x18FF 或 0x18F3 未分配,App 认不出名字),另一个也是。
  3. 每个服务下有两个特征,同样显示 UUID。
  4. 点某个特征旁边那个"三个箭头"的订阅图标 → App 会往 CCCD 写 01 00 → 串口会打印 GATT_WRITE_EVT, handle = xx, value len = 2,值打印出来是 01 00。
  5. 订阅成功后,每 3 秒 / 0.7 秒收到一次通知 ,数值在递增。串口同时会打印 ESP_GATTS_CONF_EVT(每个发出的包都会有确认事件)。

ble_env 应该看到的现象:

  1. 扫描到 ESP32-HOME (注意和 ble_basic 同名,两个板子同时开会分不清,可以把 BLE_DEVICE_NAME 改掉)。
  2. 展开服务,这次 App 会显示 Environmental Sensing 这样的名字,三个特征分别显示 Temperature 、Humidity 、Digital Output。
  3. 订阅 Temperature 和 Humidity,每秒半左右会收到一次新的通知。
  4. 在 Digital Output 那一项上点"写"(一个向上的箭头图标),选 Uint8,写 01 → 板子上的 LED 点亮 ;写 00 → 熄灭。串口打印 led value:1。
  5. 温度显示的数字应该是 2.65 这种量级(见 6.5 节的单位问题),湿度是 0.60 这种量级。这不是传感器坏了,是代码少乘了 100。

串口正常启动的日志大致长这样:

复制代码
I (xxx) BLE_CFG: create attribute table successfully, the number handle = 10
I (xxx) BLE_CFG: advertising start successfully
...
I (xxx) BLE_CFG: ESP_GATTS_CONNECT_EVT, conn_id = 0
I (xxx) BLE_CFG: ESP_GATTS_MTU_EVT, MTU 517
I (xxx) BLE_CFG: update connection params status = 0, min_int = 16, max_int = 32, conn_int = 24, latency = 0, timeout = 400

注意 conn_int = 24:你请求的是 20~40 ms,手机最后给了 24 × 1.25 = 30 ms。这就是"从机只能请求、主机拍板"的直观体现。

相关推荐
sayang_shao1 小时前
用 Rust 开发 STM32H743:从零搭建一个能烧录、能看日志的项目
stm32·单片机·rust
额额额对了1 小时前
Linux 驱动学习笔记(部分)
linux·笔记·学习
碧海银沙音频科技研究院1 小时前
基于炬芯ATS308X内置音乐铃声与扫频音频功能实现
单片机·嵌入式硬件
硅基造物主2 小时前
18.从 IP 到 MQTT:STM32 用 AT 指令驱动 ESP-12F 上云的完整链路(含全套实战代码 + 踩坑清单)
网络·stm32·单片机·嵌入式硬件·网络协议·tcp/ip
霖霖7142 小时前
【ESP32笔记】SmartConfig一键配网
笔记·单片机·mcu·物联网
霖霖7142 小时前
【ESP32笔记】WIFI连接—STA模式
笔记·单片机·mcu·物联网
HRTOS2 小时前
HRTOS 4.0 实测:时间片模式与调度位模式的任务切换性能对比
经验分享·单片机·嵌入式硬件·51单片机
学电子她就能回来吗2 小时前
LeetCode Hot 100|滑动窗口专题零基础学习笔记
笔记·学习·leetcode
我先去打把游戏先2 小时前
Git-Rebase使用指南-从原理到实战
服务器·git·嵌入式硬件·物联网·ubuntu