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协商的流程是这样的:

  1. 客户端发送MTU Request,带上自己支持的MTU大小
  2. 服务器收到后,回复MTU Response,带上自己能支持的MTU大小
  3. 双方取较小值作为实际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下的配对绑定实现,那可是个容易出问题的地方。