本节目标:看清 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;
}
逐行看:
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)------ 经典蓝牙和 BLE 共用一块控制器内存。不用经典蓝牙就把它还回堆里,能省下可观的内存。必须在esp_bt_controller_init()之前调用,初始化之后再释放会失败。esp_bt_controller_init+esp_bt_controller_enable(ESP_BT_MODE_BLE)------ 启动控制器这一侧,模式只选 BLE(ESP_BT_MODE_BLE,不是ESP_BT_MODE_BTDM)。这样即使忘了第一步的内存释放,也不会真的去跑经典蓝牙。esp_bluedroid_init_with_cfg+esp_bluedroid_enable------ 启动主机侧。ESP-IDF 里蓝牙主机有两个选择:Bluedroid (默认,老牌,API 前缀esp_ble_*)和 NimBLE (更省内存,API 前缀nimble_*)。这两个工程都用 Bluedroid,对应sdkconfig里的CONFIG_BT_BLUEDROID_ENABLED=y。- 注册两个回调:GATTS 一个、GAP 一个。这两组回调是分开的,不要指望在一个函数里处理所有事件。
esp_ble_gatts_app_register(ESP_APP_ID)------ 注册一个应用(Profile)。ESP_APP_ID是0x55,只是个标识;注册成功后会收到ESP_GATTS_REG_EVT,里面的gatts_if是这份 Profile 的接口号。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;
}
三件事:
- 检查
status。 - 检查分配到的句柄数量
num_handle是否和枚举里的SV_IDX_NB一致------不一致说明属性表写错了,直接打日志报错。这个检查不能省,它是唯一能在运行时发现"表的行数和枚举对不上"的地方。 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 msadv_int_max = 0x40= 64 × 0.625 ms = 40 ms- 协议规定非定向广播的间隔下限就是 0x0020(20 ms),这里正好取到最小值。广播间隔越短,手机扫到得越快,但射频开得越频繁、越费电。产品里通常取 100 ms 以上。
3.2 广播数据 vs 扫描回复数据
一个 BLE 设备"被看见"的过程分两步:
- 广播包(ADV_IND):从机在 37/38/39 三个信道上轮流发,包里塞着广播数据。
- 扫描回复(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);
}
}
- 有连接 :
gl_conn_id != 0xFFFF。 - 客户端订阅了 :
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(¶ms) |
开始广播 |
esp_ble_gap_stop_advertising() |
停止广播 |
esp_ble_gap_update_conn_params(¶ms) |
请求更新连接参数 |
读写和通知
| 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 应该看到的现象:
- 手机上扫描,能看到设备名 ESP32-HOME。
- 展开后能看到两个服务:一个显示成一串 UUID(0x18FF 或 0x18F3 未分配,App 认不出名字),另一个也是。
- 每个服务下有两个特征,同样显示 UUID。
- 点某个特征旁边那个"三个箭头"的订阅图标 → App 会往 CCCD 写
01 00→ 串口会打印GATT_WRITE_EVT, handle = xx, value len = 2,值打印出来是01 00。 - 订阅成功后,每 3 秒 / 0.7 秒收到一次通知 ,数值在递增。串口同时会打印
ESP_GATTS_CONF_EVT(每个发出的包都会有确认事件)。
ble_env 应该看到的现象:
- 扫描到 ESP32-HOME (注意和
ble_basic同名,两个板子同时开会分不清,可以把BLE_DEVICE_NAME改掉)。 - 展开服务,这次 App 会显示 Environmental Sensing 这样的名字,三个特征分别显示 Temperature 、Humidity 、Digital Output。
- 订阅 Temperature 和 Humidity,每秒半左右会收到一次新的通知。
- 在 Digital Output 那一项上点"写"(一个向上的箭头图标),选
Uint8,写01→ 板子上的 LED 点亮 ;写00→ 熄灭。串口打印led value:1。 - 温度显示的数字应该是 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。这就是"从机只能请求、主机拍板"的直观体现。