4. QNX蓝牙GATT层开发:Service/Characteristic定义、属性协议(ATT)、通知与指示、MTU协商
好,咱们进入GATT层。说实话,很多做蓝牙开发的工程师,在HCI和L2CAP层都玩得挺溜,一到GATT就开始犯迷糊。我个人觉得,GATT层才是真正决定蓝牙设备「智能」程度的关键。你想想看,没有GATT,两个蓝牙设备就只能互相认识,干不了正事。
4.1 GATT与ATT的关系
先理清一个概念。GATT(通用属性协议)和ATT(属性协议)是什么关系?
简单说,ATT是运输工具,GATT是货物清单。ATT负责把数据打包、传输、确认,而GATT定义了这些数据长什么样、怎么组织、有什么含义。
我在项目中遇到过不少同事,把ATT的MTU协商和GATT的Service定义混为一谈。其实分工很明确:
- ATT层:处理属性(Attribute)的读写、通知、指示,以及MTU协商
- GATT层:基于ATT,定义Service、Characteristic、Descriptor的层次结构
核心要点:GATT Profile是建立在ATT Protocol之上的。没有ATT,GATT就是空中楼阁。
4.2 Service与Characteristic的定义
在QNX下定义Service和Characteristic,我习惯用结构体来组织。先看一个实际例子:
// 定义一个心率服务
static const bt_gatt_attr_t heart_rate_service_attrs[] = {
// 心率服务声明
BT_GATT_PRIMARY_SERVICE(BT_UUID_HEART_RATE),
// 心率测量特征 - 带通知
BT_GATT_CHARACTERISTIC(BT_UUID_HRS_MEASUREMENT,
BT_GATT_CHR_PROP_NOTIFY),
// 心率测量值 - CCCD用于使能通知
BT_GATT_CCC(&hrs_ccc_cfg),
// 体感器位置特征 - 可读
BT_GATT_CHARACTERISTIC(BT_UUID_HRS_BODY_SENSOR_LOCATION,
BT_GATT_CHR_PROP_READ),
BT_GATT_DESCRIPTOR(BT_UUID_HRS_BODY_SENSOR_LOCATION,
BT_GATT_PERM_READ,
read_body_sensor_location, NULL, NULL),
};
这里有几个关键点:
- Service UUID:标准服务用Bluetooth SIG定义的UUID,自定义服务用128位UUID
- Characteristic属性:Read、Write、Notify、Indicate等,按需组合
- CCCD:Client Characteristic Configuration Descriptor,控制通知/指示的开关
我的经验:定义Service时,尽量复用标准UUID。自定义UUID虽然灵活,但兼容性差。我见过一个项目,所有Service都用自定义UUID,结果手机端App开发痛苦得要命。
4.3 属性协议(ATT)的核心操作
ATT协议定义了6种操作类型,但实际开发中常用的就4种:
| 操作 | 方向 | 说明 |
|---|---|---|
| Read Request/Response | Client→Server | 读取属性值 |
| Write Request/Response | Client→Server | 写入属性值,需要确认 |
| Write Command | Client→Server | 写入属性值,不需要确认 |
| Handle Value Notification | Server→Client | 服务器主动推送,不需要确认 |
| Handle Value Indication | Server→Client | 服务器主动推送,需要确认 |
嗯,这里要注意Notification和Indication的区别。Notification发出去就不管了,Indication必须等客户端确认。我刚开始做蓝牙开发时,用Notification传关键数据,结果丢包了也不知道。后来改成Indication,虽然慢一点,但可靠多了。
4.4 通知(Notification)与指示(Indication)的实现
在QNX中实现通知,核心是注册一个回调函数。看代码:
// 通知使能回调
static void hrs_ccc_cfg_changed(const struct bt_gatt_attr *attr,
uint16_t value)
{
if (value == BT_GATT_CCC_NOTIFY) {
// 客户端使能了通知
hrs_notify_enabled = true;
printf("心率通知已使能\n");
} else {
hrs_notify_enabled = false;
printf("心率通知已禁用\n");
}
}
// 发送通知
void send_heart_rate_measurement(uint8_t hr_value)
{
if (!hrs_notify_enabled) {
return; // 客户端没使能,不发
}
uint8_t data[2];
data[0] = 0x00; // 标志位:心率值格式
data[1] = hr_value;
bt_gatt_notify(NULL, &heart_rate_service_attrs[1], data, sizeof(data));
}
这里有个坑,我曾经踩过:发送通知前一定要检查CCCD的值。如果客户端没使能通知,你发了也是白发,还会浪费带宽。
避坑指南:我曾经在量产前才发现,某个设备在连接后立即发送通知,但CCCD还没配置好。结果手机端收不到数据,用户以为设备坏了。解决方案:在CCCD回调中设置标志位,通知发送前检查。
4.5 MTU协商
MTU(Maximum Transmission Unit)协商,说白了就是让两端商量一次能传多少数据。默认ATT MTU是23字节,其中3字节是ATT头,实际有效载荷只有20字节。
为什么要协商MTU?你想想看,如果每次只传20字节,传个固件升级包得发几千次。把MTU提高到512甚至更大,效率能提升几十倍。
在QNX中,MTU协商的流程是这样的:
- 客户端发送MTU Request,带上自己支持的MTU大小
- 服务器收到后,回复MTU Response,带上自己能支持的MTU大小
- 双方取较小值作为实际MTU
// 发起MTU协商
static void mtu_negotiate(struct bt_conn *conn)
{
int ret = bt_gatt_exchange_mtu(conn, NULL, NULL);
if (ret) {
printf("MTU协商失败: %d\n", ret);
}
}
// MTU协商完成回调
static void mtu_updated(struct bt_conn *conn, uint16_t mtu)
{
printf("MTU协商完成: %d字节\n", mtu);
// 更新应用层缓冲区大小
app_buffer_size = min(mtu - 3, APP_MAX_BUFFER);
}
我的建议:MTU协商最好在连接建立后立即进行。我习惯在连接回调中调用bt_gatt_exchange_mtu(),这样后续的数据传输都能用上大MTU。
4.6 实际开发中的注意事项
最后分享几个实战经验:
- MTU不是越大越好:MTU越大,单次传输的数据越多,但重传代价也越大。我一般用247字节(刚好能塞进一个L2CAP包)
- Notification vs Indication的选择:实时性要求高、允许丢包用Notification;关键数据、必须可靠用Indication
- Service定义要提前规划:一旦设备上市,Service和Characteristic的UUID就不能改了。我见过一个产品,因为后期要加功能,不得不做固件升级,用户怨声载道
- 调试工具很重要:用nRF Connect或LightBlue抓包,能省很多排查时间
好了,GATT层的内容就这些。下一章咱们聊聊蓝牙配对的那些坑,尤其是QNX下的配对绑定实现,那可是个容易出问题的地方。