1. GATT与ATT协议在BLE系统中的定位与作用
在蓝牙低功耗(BLE)协议栈中,GATT(Generic Attribute Profile)与ATT(Attribute Protocol)并非孤立存在的抽象概念,而是构成设备间数据交互骨架的核心协议层。它们位于主机(Host)部分,上承应用层(Application Layer),下接链路层(Link Layer)与控制器(Controller)。这种分层设计并非简单的功能划分,而是工程实践中对资源隔离、职责明确与可扩展性的深度考量。
从协议栈视角看,GAP(Generic Access Profile)负责设备发现、连接建立与安全配对等“通道建立”类任务;而GATT/ATT则专注于“通道建立后”的数据组织与访问机制。二者协同工作:GAP解决“如何连上”,GATT/ATT解决“连上后如何高效、可靠、语义化地交换数据”。这种分工使开发者能将注意力聚焦于业务逻辑本身,而非底层通信细节。
ATT是GATT的底层支撑协议,其本质是一个轻量级的客户端-服务器(Client-Server)数据库访问协议。它定义了属性(Attribute)这一核心数据单元,每个属性由四个关键字段构成:
-
属性句柄(Attribute Handle)
:一个16位无符号整数,作为该属性在服务端属性表中的唯一索引。它不携带语义信息,仅用于快速寻址,类似于数组下标。
-
属性类型(Attribute Type)
:一个UUID(Universally Unique Identifier),标识该属性的语义类别。例如
0x2800
表示主服务声明(Primary Service Declaration),
0x2803
表示特征值声明(Characteristic Declaration),
0x2902
表示客户端特征配置描述符(Client Characteristic Configuration Descriptor, CCCD)。UUID可以是16位(如官方预定义的短UUID)或128位(用户自定义的长UUID),其选择直接影响协议栈的内存占用与解析效率。
-
属性值(Attribute Value)
:实际承载业务数据的字节数组。其长度与内容完全由上层应用决定,例如一个温度传感器特征值可能为2字节的有符号整数,而一个固件更新包则可能是数千字节的二进制流。
-
属性权限(Attribute Permissions)
:一个16位标志位组合,精确控制对该属性的访问方式,包括读(Read)、写(Write)、通知(Notify)、指示(Indicate)等。权限设置是实现数据安全与访问控制的第一道防线,必须与应用逻辑严格匹配。
GATT则是在ATT之上构建的一套标准化的数据组织模型。它定义了服务(Service)、特征(Characteristic)和描述符(Descriptor)三级结构,将零散的属性组织成具有清晰语义的“数据容器”。一个服务代表一个逻辑功能单元(如“电池服务”、“心率服务”),一个特征代表该服务下的一个具体数据点或操作接口(如“电池电量”、“心率测量值”),而描述符则提供对特征的额外元信息(如“单位”、“用户描述”)。这种结构化设计使得不同厂商的设备能够基于共同的语义理解进行互操作,是BLE生态得以繁荣的技术基石。
在ESP32平台的具体实现中,这一抽象模型被映射为一套清晰的API接口与事件驱动模型。开发者通过调用
esp_ble_gatts_create_service()
等函数在服务端(GATTS)注册服务,通过
esp_ble_gattc_search_service()
等函数在客户端(GATTC)发现并访问服务。所有这些高层操作,最终都转化为对ATT协议定义的PDU(Protocol Data Unit)的封装与解析,并由底层蓝牙协议栈(Bluedroid)自动完成。理解这一映射关系,是避免陷入“API调用黑盒”困境、实现健壮BLE应用开发的前提。
2. BLE客户端-服务器交互的完整生命周期
BLE设备间的通信并非线性流程,而是一个由事件驱动、多任务并发的复杂状态机。以SPP(Serial Port Profile)透传服务为例,其客户端(Central)与服务器(Peripheral)的交互过程,完美诠释了GATT/ATT模型在真实硬件上的运行逻辑。整个生命周期可划分为初始化、发现与连接、服务发现、数据交互与连接终止五个阶段,每个阶段都伴随着严格的时序约束与状态转换。
2.1 初始化:为交互奠定基础
初始化是整个BLE通信的起点,其目标是为后续所有操作准备好必要的软硬件资源。在ESP32上,这一过程分为底层协议栈初始化与上层应用初始化两个层面,且顺序不可颠倒。
底层协议栈初始化
是硬件无关的通用步骤,通常在
app_main()
函数中执行。其核心序列如下:
1.
Flash初始化
:调用
nvs_flash_init()
初始化非易失性存储区(NVS),用于持久化保存蓝牙地址、配对密钥等关键信息。
2.
控制器释放与初始化
:调用
esp_bt_controller_mem_release(ESP_BT_MODE_CLASSIC_BT)
释放经典蓝牙(BR/EDR)控制器内存,确保BLE控制器获得充足资源;随后调用
esp_bt_controller_init()
与
esp_bt_controller_enable(ESP_BT_MODE_BLE)
启动并启用BLE控制器。
3.
主机协议栈初始化
:调用
esp_bluedroid_init()
与
esp_bluedroid_enable()
初始化并启用Bluedroid主机协议栈。至此,BLE协议栈的“心脏”与“神经系统”已开始工作。
上层应用初始化
则针对具体的GATT角色进行定制化配置:
-
服务器端(GATTS)初始化
:在协议栈就绪后,注册GATTS回调函数
esp_ble_gatts_register_callback()
,用于接收来自协议栈的各类事件(如APP注册完成、连接建立、特征值写入等);接着注册GAP回调
esp_ble_gap_register_callback()
,处理广播、扫描等链路层事件;然后调用
esp_ble_gatts_app_register()
注册一个GATT应用(APP_ID),这是后续所有服务创建的归属标识;最后,初始化串口(SPP的核心外设)并创建一个专用的应用任务(Task),用于处理业务逻辑。
-
客户端端(GATTC)初始化
:流程与服务器端高度相似,同样需注册GATTC与GAP回调,并调用
esp_ble_gattc_app_register()
注册客户端APP。一个关键差异在于,客户端通常会调用
esp_ble_gattc_set_local_mtu()
预先设置本地最大传输单元(MTU),为后续连接后的MTU协商做准备。MTU决定了单次ATT PDU的最大有效载荷长度,默认为23字节,但可通过协商提升至256字节,这对提升大数据吞吐量至关重要。
此阶段的初始化代码虽看似简单,但其内部蕴含着深刻的工程考量:回调函数的注册本质上是将应用层的事件处理入口“挂载”到协议栈的事件分发总线上;APP_ID的注册则是为协议栈内部维护的服务、特征等资源建立唯一的上下文标识;而任务的创建,则是将阻塞式、耗时的业务处理(如数据解析、LED控制)与非阻塞的协议栈事件处理彻底解耦,确保系统实时性。
2.2 发现与连接:建立通信信道
当服务器端完成初始化后,它便进入“广播”状态,向周围世界宣告自己的存在与能力。服务器端的广播流程如下:
1.
配置广播数据
:调用
esp_ble_gap_config_adv_data()
配置广播数据包(Advertising Data)与扫描响应数据包(Scan Response Data)。广播数据包必须包含设备名称(
ESP_BLE_AD_TYPE_NAME_COMPLETE
)和标志位(
ESP_BLE_AD_TYPE_FLAG
),而扫描响应数据包则可容纳更多详细信息,如服务UUID列表(
ESP_BLE_AD_TYPE_16SRV_PART
),这为客户端快速筛选目标设备提供了依据。
2.
启动广播
:调用
esp_ble_gap_start_advertising()
启动广播。此时,ESP32的射频模块开始周期性地发送广播包,等待被扫描。
客户端则同步进入“扫描”状态:
1.
配置扫描参数
:调用
esp_ble_gap_set_scan_params()
配置扫描间隔(Scan Interval)与扫描窗口(Scan Window)。这两个参数决定了扫描的占空比与灵敏度,需在功耗与发现速度间权衡。
2.
启动扫描
:调用
esp_ble_gap_start_scanning()
开始扫描。协议栈会持续监听空中信号,并将捕获到的广播包解析后,通过
ESP_GAP_BLE_SCAN_RESULT_EVT
事件通知应用层。
3.
处理扫描结果
:在GAP回调中,当收到
ESP_GAP_BLE_SCAN_RESULT_EVT
事件时,应用层需解析
esp_ble_gap_cb_param_t
结构体中的
scan_rst
字段,获取远端设备的MAC地址、RSSI(信号强度)、广播数据等。典型的做法是遍历所有扫描结果,匹配预设的设备名称(
device_name
),一旦找到目标设备,立即调用
esp_ble_gap_stop_scanning()
停止扫描,避免不必要的功耗。
4.
发起连接
:调用
esp_ble_gattc_open()
向目标设备发起连接请求。该调用会触发
ESP_GATTC_OPEN_EVT
事件,标志着连接流程的正式开始。
连接的建立是一个异步过程。客户端发出请求后,会立即返回;真正的连接成功与否,需等待协议栈通过
ESP_GAP_BLE_SCAN_RESULT_EVT
事件(其中
param->scan_rst.search_evt
为
ESP_GAP_SEARCH_INQ_RES_EVT
)或更直接的
ESP_GAP_BLE_SCAN_RESULT_EVT
事件(其中
param->scan_rst.search_evt
为
ESP_GAP_SEARCH_INQ_CMPL_EVT
)来通知。此时,客户端已成功与服务器建立起一条加密的、双向的逻辑链路(Link),为后续的GATT数据交互铺平了道路。
2.3 服务发现:获取数据地图
连接建立后,客户端对服务器的“内部世界”一无所知。服务发现(Service Discovery)便是客户端绘制这张“数据地图”的过程。它通过标准的GATT协议,向服务器查询其公开的所有服务、特征及其属性句柄,从而获知“哪里有数据”以及“如何访问”。
在ESP32客户端代码中,服务发现通常在收到
ESP_GATTC_CONNECT_EVT
(连接成功事件)后自动触发:
1.
发起服务发现
:调用
esp_ble_gattc_search_service()
,传入连接句柄(
conn_id
)和一个空的UUID(
NULL
),表示搜索服务器上所有的服务。
2.
接收服务发现结果
:服务发现过程会生成一系列事件。最关键的两个是:
-
ESP_GATTC_SEARCH_RES_EVT
:每当发现一个服务时触发。
param->search_res.srvc_id
中包含了该服务的起始句柄(
start_handle
)和结束句柄(
end_handle
),以及服务的UUID。客户端需将这些信息缓存起来,形成一个“服务列表”。
-
ESP_GATTC_SEARCH_CMPL_EVT
:当所有服务发现完毕后触发,标志着服务发现阶段的终结。
3.
特征与描述符发现
:对于每一个发现的服务,客户端需进一步调用
esp_ble_gattc_get_characteristic()
来枚举其下的所有特征,再对每个特征调用
esp_ble_gattc_get_descriptor()
来获取其描述符(如CCCD)。每一次调用都会产生对应的
ESP_GATTC_GET_CHAR_EVT
和
ESP_GATTC_GET_DESCR_EVT
事件。
整个服务发现过程是高度递归和事件驱动的。客户端不能假设所有服务都能在一次调用中全部返回,必须设计一个状态机来管理当前正在发现哪个服务、哪个特征。一个常见的坑是,在
ESP_GATTC_SEARCH_RES_EVT
事件中,如果未正确区分是新服务还是已有服务的延续,会导致句柄计算错误,进而使后续的读写操作失败。因此,严谨的状态管理与句柄缓存是服务发现代码健壮性的核心。
2.4 数据交互:读、写、通知与指示
当客户端拥有了完整的“数据地图”后,真正的业务数据交互便开始了。GATT/ATT协议为此定义了四种核心操作模式,每一种都对应着不同的应用场景与性能特性。
-
读取(Read) :客户端主动向服务器请求一个特征值。这适用于获取状态信息,如读取当前LED的开关状态。调用
esp_ble_gattc_read()即可发起。服务器端在GATTS回调中收到ESP_GATTS_READ_EVT事件,此时需根据param->read.handle(属性句柄)查找到对应特征值,并将数据填充到param->read.value指向的缓冲区中。协议栈会自动将数据打包并通过空中链路返回给客户端。值得注意的是,读取操作是同步的,客户端会等待服务器响应。 -
写入(Write) :客户端向服务器发送一个特征值。这适用于下发控制指令,如发送“开灯”命令。调用
esp_ble_gattc_write_char()发起。服务器端收到ESP_GATTS_WRITE_EVT事件。此时需仔细判断param->write.is_prep标志位:若为false,表示是“直接写入”(Write Without Response),数据已存在于param->write.value中,可立即处理;若为true,表示是“准备写入”(Prepare Write),数据被暂存在协议栈的内部缓冲区中,需等待后续的ESP_GATTS_EXEC_WRITE_EVT事件(执行写入)才能真正提交。这种机制支持长数据的分片写入与原子性操作,但增加了应用层的复杂度。 -
通知(Notify) :服务器主动向客户端推送一个特征值的更新。这适用于传感器数据上报,如温度传感器每隔1秒推送一次最新读数。通知是单向、无确认的,因此效率最高。要启用通知,客户端首先需向服务器的CCCD(
0x2902)写入0x0001,这会触发服务器端的ESP_GATTS_WRITE_EVT事件。服务器在收到此写入后,应记录下该客户端的连接句柄(conn_id),并在需要推送数据时,调用esp_ble_gatts_send_indicate()(注意,此处为indicate,但notify的API是esp_ble_gatts_notify())发送通知。客户端无需额外操作,数据会自动到达其GATTC回调的ESP_GATTC_NOTIFY_EVT事件中。 -
指示(Indicate) :与通知类似,但要求客户端在收到数据后必须发送一个确认(ACK)报文。这保证了数据的可靠投递,但引入了往返延迟。其启用方式与通知相同(向CCCD写入
0x0002),发送API为esp_ble_gatts_send_indicate(),客户端收到ESP_GATTC_NOTIFY_EVT后,协议栈会自动发送ACK。
在实际项目中,如“蓝牙键盘”或“蓝牙鼠标”,通知是绝对的主角。键盘按键事件、鼠标移动坐标等,都通过高频、低延迟的通知机制实时上传。而“蓝牙手机自拍杆”的快门按钮,则可能采用写入操作,由手机App向自拍杆的“快门控制”特征写入一个特定字节来触发拍照。
2.5 连接终止:优雅地结束会话
任何连接都有其生命周期的终点。连接终止可以由任一方发起,也可以因链路质量恶化而由协议栈自动断开。在ESP32中,客户端调用
esp_ble_gattc_close()
或服务器调用
esp_ble_gatts_close()
即可主动断开连接。无论何种方式,协议栈都会通过
ESP_GAP_BLE_DISCONNECT_EVT
事件通知双方应用层。
在收到断开事件后,应用层必须执行清理工作:释放所有为该连接分配的资源,如缓存的服务句柄、特征句柄,重置内部状态机,并可根据需要重新启动扫描或广播,进入下一个通信周期。一个健壮的设计会将连接状态(
connected
/
disconnected
)作为一个全局变量或状态机的一部分,所有读写操作前都需检查此状态,避免向已断开的连接发送无效指令,导致程序异常。
3. GATT服务与特征属性表的深度解析
GATT服务与特征的结构化组织,最终都落地为一张静态的、驻留在服务器内存中的“属性表”(Attribute Table)。这张表是GATT/ATT协议的灵魂,其设计质量直接决定了BLE应用的可维护性、互操作性与性能。理解其内在结构与设计哲学,是进行高级BLE开发的必经之路。
3.1 属性表的物理结构与组织原则
在ESP32的GATTS实现中,属性表并非一个动态分配的链表,而是一块预先定义好的、连续的内存区域。它由一系列
esp_gatts_attr_db_t
结构体按顺序排列而成,每个结构体代表一个属性(Attribute)。这个结构体的定义清晰地揭示了属性的四要素:
typedef struct {
uint16_t attr_control; // 控制字段,含权限、加密要求等
const esp_attr_desc_t *attr_desc; // 指向属性描述符的指针
} esp_gatts_attr_db_t;
而
esp_attr_desc_t
则包含了属性的全部核心信息:
typedef struct {
uint16_t uuid_length; // UUID长度 (2 or 16)
const uint8_t *uuid; // UUID值
uint16_t perm; // 权限位掩码
uint16_t max_length; // 属性值最大长度
uint16_t length; // 当前属性值长度
uint8_t *value; // 指向属性值的指针
} esp_attr_desc_t;
属性表的组织遵循严格的层次化原则,即“服务 -> 特征 -> 描述符”的树状结构。每一个层级都由一个“声明”(Declaration)属性作为其入口点:
-
主服务声明(Primary Service Declaration)
:UUID为
0x2800
。它的属性值是一个UUID,标识该服务的类型(如
0x180F
为电池服务)。这是整个服务的根节点。
-
特征声明(Characteristic Declaration)
:UUID为
0x2803
。它的属性值是一个3字节的结构体:第一个字节为特征属性(Properties,如
0x0A
表示可读+通知),后两个字节为该特征值的句柄(Handle of the Characteristic Value)。
-
特征值(Characteristic Value)
:这是一个普通的属性,其UUID即为该特征的业务UUID(如
0x2A19
为电池电量),其属性值即为实际的业务数据。它是整个特征的“数据本体”。
-
描述符(Descriptor)
:紧跟在特征值之后,用于描述特征值的元信息。最常见的是客户端特征配置描述符(CCCD,
0x2902
),它允许客户端配置是否接收该特征的通知或指示。
这种“声明+数据”的配对模式,使得属性表在逻辑上形成了一条清晰的、可预测的链表。当协议栈需要查找某个特征时,它会从服务声明开始,按顺序遍历,根据声明中给出的句柄跳转到特征值,再根据特征值的位置找到其后的描述符。这种设计牺牲了一点灵活性,却换来了极高的解析效率与内存紧凑性,完美契合嵌入式系统的资源约束。
3.2 关键描述符详解:CCCD与特征展示格式
在众多描述符中,客户端特征配置描述符(CCCD)与特征展示格式描述符(Characteristic Presentation Format Descriptor,
0x2904
)最具代表性,它们深刻体现了BLE协议对用户体验与数据语义的重视。
CCCD (
0x2902
)
是通知与指示机制的开关。其属性值是一个2字节的位图(Bitmap):
- Bit 0 (
0x0001
):启用/禁用通知(Notification)
- Bit 1 (
0x0002
):启用/禁用指示(Indication)
- 其余位保留
当客户端向CCCD写入
0x0001
时,服务器便知道该客户端希望接收此特征的通知。服务器应用层必须在GATTS回调中捕获此写入事件,并将该客户端的
conn_id
记录下来,以便后续调用
esp_ble_gatts_notify()
时能准确地将数据推送给它。一个典型的陷阱是,开发者常常忘记在服务器端缓存
conn_id
,导致通知永远无法送达正确的客户端。此外,CCCD的权限必须设置为
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE
,否则客户端将无法对其进行配置。
特征展示格式描述符 (
0x2904
)
则致力于解决“数据如何被正确解读”的问题。其属性值是一个6字节的结构体:
| 字节 | 含义 | 说明 |
| :— | :— | :— |
| 0 | 格式(Format) | 一个枚举值,如
0x04
表示有符号16位整数,
0x06
表示IEEE-754单精度浮点数。 |
| 1 | 指数(Exponent) | 一个有符号整数,用于对原始值进行缩放。例如,温度值为
300
,指数为
-2
,则实际温度为
300 × 10⁻² = 3.00°C
。 |
| 2-3 | 单位(Unit) | 一个16位UUID,如
0x2729
表示摄氏度(°C),
0x2700
表示百分比(%)。 |
| 4 | 命名空间(Name Space) |
0x01
表示Bluetooth SIG定义的UUID,
0x02
表示Microsoft定义的UUID。 |
| 5 | 描述(Description) | 一个16位UUID,用于提供更详细的文本描述。 |
这个描述符的存在,使得一个BLE设备不仅能“发送数据”,还能“告诉对方如何理解数据”。例如,一个心率传感器可以发送一个原始值
120
,并附带一个
0x2904
描述符,其中格式为
0x04
(有符号16位),指数为
0x00
(无缩放),单位为
0x2731
(BPM,每分钟心跳次数)。这样,任何兼容的手机App都能自动将其渲染为“120 BPM”,而无需硬编码解析规则。这种“自描述”的设计理念,是BLE协议长期生命力的关键所在。
3.3 实践中的设计考量与经验
在实际项目中,设计一张优秀的属性表,远不止是堆砌几个
esp_gatts_attr_db_t
结构体那么简单。以下是我在多个项目中踩过坑后总结出的关键经验:
-
句柄管理自动化 :手动计算每个属性的句柄极易出错,尤其是在服务、特征频繁增删时。务必使用宏或脚本来自动生成句柄。例如,定义
#define HANDLE_SVC_BATTERY 0x0001,#define HANDLE_CHAR_BATTERY_LEVEL (HANDLE_SVC_BATTERY + 1),并确保所有声明与数据项的句柄严格遵循此顺序。ESP-IDF的ble_spp_server示例中就采用了这种模式。 -
内存布局优化 :属性表是常驻内存的,其大小直接影响RAM占用。对于只读的、固定不变的属性(如服务声明、特征声明),应将其
value指针指向const字符串或数组,避免在RAM中冗余存储。而对于需要频繁读写的特征值(如LED控制状态),则必须分配RAM空间,并在回调中通过memcpy等方式更新。 -
UUID策略 :对于通用服务(如电池、心率),务必使用Bluetooth SIG官方分配的16位UUID,以保证与标准App的兼容性。对于自定义的、私有的服务与特征,推荐使用128位UUID,并通过在线UUID生成器创建,以规避与其他厂商的冲突。切忌随意使用未注册的16位UUID,这可能导致不可预知的互操作问题。
-
权限的最小化原则 :为每个属性设置最严格的权限。一个只用于通知的特征值,其权限应仅为
ESP_GATT_PERM_READ(供客户端读取其值)和ESP_GATT_PERM_WRITE_ENC(供客户端写入CCCD),而不应开放ESP_GATT_PERM_WRITE,以防恶意客户端篡改数据。 -
错误处理的完备性 :在GATTS回调中,对每一个
ESP_GATTS_WRITE_EVT事件,都必须检查param->write.handle是否在预期范围内。如果客户端尝试写入一个不存在的句柄,协议栈会返回一个错误响应,但应用层若不做校验,可能会导致内存越界或逻辑混乱。一个健壮的回调函数,其第一行代码往往是if (handle != expected_handle) return;。
一张设计精良的属性表,就像一座城市的精密路网,它不声不响,却决定了所有数据流能否高效、准确、安全地抵达目的地。对它的每一次修改,都应如同建筑师规划城市一样,深思熟虑,反复验证。
4. 基于GATT模型的LED控制实战
理论的最终归宿是实践。让我们将前述所有概念——GATT服务结构、属性表设计、客户端-服务器交互流程——凝聚在一个最经典的嵌入式实验中:通过BLE远程控制ESP32开发板上的LED灯。这个看似简单的例子,恰恰是检验你是否真正掌握BLE内核的试金石。
4.1 服务与特征设计:定义我们的“灯控协议”
我们首先需要为LED控制定义一个专属的GATT服务。为了保证唯一性与专业性,我们不使用任何预定义的UUID,而是生成一个128位的自定义UUID。例如,通过在线工具生成:
f000aa00-0451-4000-b000-000000000000
。在此服务下,我们定义一个核心特征:“LED状态控制”,其UUID为
f000aa01-0451-4000-b000-000000000000
。
该特征的属性设计如下:
-
特征声明 (
0x2803
)
:权限为
ESP_GATT_PERM_READ
,属性值为
0x06
(表示该特征可读、可写、可通知)。
-
特征值 (
f000aa01...
)
:权限为
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE
,最大长度为1字节,初始值为
0x00
(LED熄灭)。这是真正的数据载体。
-
CCCD (
0x2902
)
:权限为
ESP_GATT_PERM_READ | ESP_GATT_PERM_WRITE
,用于让客户端配置通知。
此外,为了提升用户体验,我们还可以添加一个“LED状态描述符 (
0x2901
,User Description)”:权限为
ESP_GATT_PERM_READ
,属性值为一个
const char*
字符串,如
"LED State: 0=OFF, 1=ON"
。这能让支持该描述符的App(如nRF Connect)在界面上直接显示友好的提示。
4.2 服务器端(GATTS)实现:ESP32的“灯管家”
服务器端的核心是GATTS回调函数。当客户端执行各种操作时,协议栈会将事件派发至此。
-
APP注册与服务创建 :在
ESP_GATTS_REG_EVT事件中,调用esp_ble_gatts_create_service()创建我们的LED服务。在ESP_GATTS_CREATE_EVT事件中,调用esp_ble_gatts_add_char()添加LED状态特征,再调用esp_ble_gatts_add_char_descr()添加CCCD和用户描述符。所有这些操作完成后,调用esp_ble_gatts_start_service()启动服务,使其对外可见。 -
处理写入请求 :在
ESP_GATTS_WRITE_EVT事件中,首先检查param->write.handle是否等于我们LED特征值的句柄。如果是,则提取param->write.value[0]的值。若为0x01,则调用gpio_set_level(GPIO_NUM_2, 0)点亮LED(注意:ESP32 GPIO高电平通常为熄灭,需根据电路设计调整);若为0x00,则调用gpio_set_level(GPIO_NUM_2, 1)熄灭LED。 关键一步 :在成功处理写入后,我们应主动调用esp_ble_gatts_send_indicate(),将新的LED状态(0x00或0x01)通知给所有已订阅的客户端。这实现了“写入即反馈”的闭环体验。 -
处理CCCD写入 :在同一回调中,检查
param->write.handle是否为CCCD句柄。若写入值为0x0001,则将当前客户端的param->write.conn_id加入一个全局的uint16_t notify_conn_id_list[CONFIG_BT_NIMBLE_MAX_CONNECTIONS]数组中;若为0x0000,则将其从数组中移除。这样,当LED状态改变时,我们就能遍历此数组,向所有订阅者发送通知。 -
处理连接与断开 :在
ESP_GATTS_CONNECT_EVT中,记录param->connect.conn_id;在ESP_GATTS_DISCONNECT_EVT中,从notify_conn_id_list中清除该ID。这确保了通知只发送给当前在线且已订阅的客户端。
4.3 客户端(GATTC)实现:手机App的“遥控器”
客户端的任务是发现服务器、读取/写入LED状态、并接收通知。
-
服务发现与句柄缓存 :在
ESP_GATTC_SEARCH_CMPL_EVT事件后,遍历所有发现的服务。当找到UUID为f000aa00...的服务时,记录其start_handle和end_handle。接着,调用esp_ble_gattc_get_characteristic(),传入该服务的句柄范围,寻找UUID为f000aa01...的特征。在ESP_GATTC_GET_CHAR_EVT事件中,记录下该特征的char_handle(即特征值句柄)和descr_handle(即CCCD句柄)。 -
读取与写入LED状态 :提供一个UI按钮“Toggle LED”。点击时,调用
esp_ble_gattc_read_char()读取当前状态,并在UI上显示。再调用esp_ble_gattc_write_char(),向char_handle写入0x01或0x00,实现切换。 -
启用通知 :提供一个开关“Enable Notifications”。开启时,调用
esp_ble_gattc_write_char_descr(),向descr_handle写入0x0001。关闭时,写入0x0000。 -
接收通知 :在
ESP_GATTC_NOTIFY_EVT事件中,param->notify.value[0]即为服务器推送的最新LED状态。更新UI,例如将一个图标从灰色变为绿色。
4.4 调试与排错:工程师的日常
在调试此项目时,我曾遇到过几个极具代表性的难题:
-
LED不响应写入
:排查发现,GATTS回调中
param->write.handle
的比较使用了
==
,但实际句柄是一个16位值,而我的宏定义
HANDLE_LED_VALUE
是一个
int
。在32位平台上,这导致了高位字节的随机值,比较永远为假。解决方案是统一使用
uint16_t
类型。
-
通知只发一次
:现象是,第一次点击“Toggle LED”后,手机App能收到通知,但后续操作不再收到。根源在于,
esp_ble_gatts_send_indicate()
的API要求传入
conn_id
,而我错误地传入了
0
(默认连接ID),而非从
notify_conn_id_list
中取出的真实ID。
conn_id
是动态的,必须精确匹配。
-
服务发现超时
:在某些Android手机上,服务发现耗时过长甚至失败。这是因为手机的BLE协议栈对服务发现的响应时间有严格限制。解决方案是在
esp_ble_gattc_search_service()
后,立即调用
esp_ble_gattc_search_service()
的变体,或增加一个超时定时器,在超时后强制结束发现流程。
这个LED控制项目,麻雀虽小,五脏俱全。它涵盖了BLE开发的所有核心环节。当你亲手敲下每一行代码,看着手机App上的按钮精准地控制着那盏小小的LED灯时,你所掌握的,就不再是零散的知识点,而是一套完整的、可复用的BLE工程方法论。这正是嵌入式开发的魅力所在:抽象的协议规范,最终都将在现实世界的光与电中,得到最直观、最有力的验证。



4504

被折叠的 条评论
为什么被折叠?



