蓝牙从机添加自定义服务特征示例 (包括 Indicate 和 128bit UUID ) ...... 矜辰所致
前言
在之前的文章《沁恒微蓝牙 GATT 应用框架说明》中我们已经详细了解了 GATT 中服务和特征值有关的应用框架,官方也给出了添加自定义服务和特征的代码,在实际应用中,我们或许也需要根据需求增加服务和特征。
所以本文内容就是说明以及实际演示一下在应用中如何添加不同的服务和特征值。
相关博文: 沁恒微蓝牙 GATT 应用框架说明 CH58x/CH59x 系列芯片从机示例解析 一文搞清 BLE 蓝牙 UUID
我是矜辰所致,全网同名,尽量用心写好每一系列文章,不浮夸,不将就,认真对待学知识的我们,矜辰所致,金石为开!
📑 本文目录
一、🎯 准备实现的示例
二、📘 官方示例详解
├─ 2.1 服务声明
├─ 2.2 特征值1(Char1)- 可读可写
│ ├─ 2.2.1 服务和特征声明的区别
│ ├─ 2.2.2 GATT_PERMIT_READ vs GATT_PROP_READ
│ ├─ 2.2.3 关于 "Unknown Characteristic"
│ └─ 2.2.4 可选的描述符
├─ 2.3 特征值 2~3
├─ 2.4 特征值4 - 通知(Notify)
└─ 2.5 特征值5 - 需要认证的读
三、🧪 新增示例测试
├─ 3.1 增加 Indicate 属性特征值
├─ 3.2 增加服务
└─ 3.3 增加 128bit UUID 服务
四、⬇️ 示例下载
📝 结语
一、本文准备实现的示例
实际上,示例 Peripheral 中的 gattprofile.c 已经完全展示了 5 种不同属性的服务,实际应用需要增加完全可以复制一下,把对应的名字修改一下就可以实现了。
本文我们计划实现几个示例:
-
- 在官方示例自定义服务里增加特征值;
-
- 额外再增加我们自己的服务和特征值;
-
- 额外再增加 128bit UUID 的服务和特征值。

我们只要搞清楚了,官方示例提供的自定义服务特征值,那么自己应用中的服务特征也不会有什么问题。
二、官方示例说明
我们需要参考的是 gattprofile.c 文件代码,我们把此文件中的结构体数组 simpleProfileAttrTbl[] 搞清楚,基本上就完成了大部分工作了。
在文章 沁恒微蓝牙 GATT 应用框架说明 的 2.1.1 服务和特征值配置 小节,关于服务和特征值的说明。
我们把这个结构体数组拆分开来讲解说明,能更快的让我们理解。
2.1 服务声明
负责服务声明的代码如下:
// Simple Profile Service
{
{ATT_BT_UUID_SIZE, primaryServiceUUID}, /* type primaryServiceUUID:0x2800*/
GATT_PERMIT_WRITE, /* permissions,*/
0, /* handle */
(uint8_t *)&simpleProfileService /* pValue,保存的值为本服务的UUID :0XFFE0 */
},
对于服务申明,第一个成员 {ATT_BT_UUID_SIZE, primaryServiceUUID} 是固定的,表示 "这是一个主服务" 。primaryServiceUUID 在我以前的文章 一文搞清 BLE 蓝牙 UUID 中也说明过,这个 UUID 类型为 0x2800 ,是用来声明这是一个服务的,是个常量,固定存放在 Flash 中的, 所以的服务声明,前面2个成员永远固定这么写就行了,不能改变!
第二个成员权限,需要是可读主机才能正确识别服务,设置为 GATT_PERMIT_READ ,实际应用这里按照标准的写成可读即可。
这里额外说明一下,有些手机(比如华为)连接过从机设备一次以后就会保存设备信息,我测试的时候使用华为手机正常连接过设备后,将第二个成员权限改成其他任意数值,包括0,重新烧录后连接都依然能够正常获取,这是华为手机自己的策略问题。大家做测试的时候记得注意一下。
第三个成员,句柄写成 0,连接上以后由协议栈自动分配;
最后一个成员,是储存的值,这里因为服务本身不是用来数据通信的,这里的值保存的是服务自己的 UUID ,就是示例中定义的 0xFFE0 。
2.2 特征值1(Char1)- 可读可写
表示特征值1 的代码如下:
// Characteristic 1 Declaration
//// 1. 特征值1 - 声明项(告诉手机:特征值1支持读+写)
{
{ATT_BT_UUID_SIZE, characterUUID}, //固定的,类型:0x2803(Characteristic)特征值声明(0x2803)
GATT_PERMIT_READ, // 声明项本身的权限:可读
0,
&simpleProfileChar1Props}, // 本特征值的属性为:GATT_PROP_READ | GATT_PROP_WRITE 特征值1的功能:读+写
// Characteristic Value 1
//2. 特征值1 - 数值项(存放特征值1的实际数据)
{
{ATT_BT_UUID_SIZE, simpleProfilechar1UUID}, //自定义的,特征值的 0xFFE1
GATT_PERMIT_READ | GATT_PERMIT_WRITE, // 数值项的访问权限:可读可写
0,
simpleProfileChar1}, // 特征值1的数据:自定义的数组(我们可读写修改)
// Characteristic 1 User Description
// 3. 特征值1 - 描述项(给特征值1起名字)
{
{ATT_BT_UUID_SIZE, charUserDescUUID}, // 类型:用户描述(0x2901)
GATT_PERMIT_READ, // 描述项本身的权限:可读
0,
simpleProfileChar1UserDesp}, // 描述项的值
那这里我们可以明显看到,这里特征的代码使用了 3 个属性成员表示,而上面服务只需要一个成员表示,这里我们先来说明一下为什么。
2.2.1 服务和特征申明的区别
在 BLE GATT 规范里,每个特征值的标准结构是:
- • 特征值声明(Characteristic Declaration):说明这是一个特征值,包含它的属性(读 / 写 / 通知等)。
- • 特征值的值(Characteristic Value):真正的数据内容。
- • 可选描述符(Descriptor):如用户描述(0x2901)、客户端配置(0x2902)等。
而对于服务来说
- • 服务的核心作用是告诉外部 “我有一个 UUID 为 0xFFE0 的主服务” ,今次而已。 使用一个属性项的 UUID(0x2800) 定义了 “这是服务声明”,数据值(0xFFE0) 定义了 “是哪个服务”,就完成了服务的核心声明。不需要额外的 “配置” 或 “描述”,所以一个属性项就够了。
所以一个完整的特征值需要多个属性项配合才能描述清楚,而服务只需要一个属性项就能完成核心声明。
下面上一张 BLE GATT 协议的数据组织结构示意图:

对于我们的示例代码而言,gattAttribute_t 结构体就是 GATT 成员属性表,数组 simpleProfileAttrTbl 中每个 { } 都是独立的 “属性项”,有自己的 UUID 和数据值:
- • 服务的 UUID(0x2800)+ 数据值(服务 UUID)就能完成声明,仅需 1 项;
- • 特征值需要 0x2803(声明属性)+ 自定义 UUID(存数据)+ 0x2901(描述,可选),至少 2 项;
再给一张示意图:

2.2.2 GATT_PERMIT_READ 和 GATT_PROP_READ
在特征值定义的时候,有两个地方都会用到关于读写的宏定义,如下图:

对于有些新人可能会有一点迷糊,两个地方都有读写,而且这两个地方有什么区别,那么它们是不是一定要一致? 我们先来搞懂这两个位置的不同,我们用一个表格来说明一下:
| simpleProfileChar1Props (特征属性,比如 GATT_PROP_READ) | GATT_PERMIT_xxx(访问权限,比如 GATT_PERMIT_READ) | |
|---|---|---|
| 位置 | 特征值声明项(0x2803)的数据值 pValue | 特性值的权限字段 permissions |
| 作用对象 | 告诉客户端(比如手机)这个特性支持什么功能 | 告诉协议栈:这个属性项实际能不能被读 / 写控制 实际数据能否访问 |
| 协议层 | 发现阶段(广播/读取声明) | 数据访问阶段(读/写值) |
| 给谁看 | 手机/主机 看,决定是否显示读/写按钮 | 协议栈 看,决定是否允许操作 |
简单总结一下就是:GATT_PROP_* 告诉外界你能做什么(代码中在特征值的声明项的第四个成员 pValue 位置被使用);GATT_PERMIT_* ,实际控制能不能做(代码中在特征值的数值项的第二个成员 permissions 位置被使用)。
正常来说,这两处的定义会保持一致,但是不一致也可以存在,我们举两个不同的例子。
EX1:
// 场景:数据只读,但声明说有写权限(故意不一致,有特殊用途)
// 声明:告诉手机"这个特性可读写"
{
{ATT_BT_UUID_SIZE, characterUUID},
GATT_PERMIT_READ,
0,
&simpleProfileChar1Props // = GATT_PROP_READ | GATT_PROP_WRITE
},
// 实际值:但协议栈禁止写入
{
{ATT_BT_UUID_SIZE, simpleProfilechar1UUID},
GATT_PERMIT_READ, // ← 只有读,没有写!
0,
simpleProfileChar1
},
效果:
- • 手机看到声明 → 显示"可读写" → 用户看到写按钮
- • 手机尝试写入 → 协议栈拒绝 → 返回 Write Not Permitted 错误
EX2 :
// 场景:声明只读,实际可写(隐藏功能)
// 声明:告诉手机"只读"
{
{ATT_BT_UUID_SIZE, characterUUID},
GATT_PERMIT_READ,
0,
&simpleProfileChar1Props // = GATT_PROP_READ (只有读!)
},
// 实际值:但协议栈允许写入
{
{ATT_BT_UUID_SIZE, simpleProfilechar1UUID},
GATT_PERMIT_READ | GATT_PERMIT_WRITE, // ← 实际可写
0,
simpleProfileChar1
},
效果:
- • 普通APP看不到写功能
- • 知道Handle的开发者可以直接写(隐藏接口)
讲到这里,我们再来看特征值1 的代码,是不是感觉很明了了,结合上文内容然后对着上面给出的代码注释,仔细看一下,是不是就清楚了。
2.2.3 关于名字 Unknown Characteristic
在上面给出的特征值1 的代码中,在描述项的注释中,我写了描述符是给本特征值取名字的,而且代码中确实也给了值,但是显示的是 Unknown Characteristic 。
如果观察比较仔细,在这个官方示例工程中 Profile 文件夹下面与 gattprofile.c 同样是添加服务特征的代码文件 devinfoservice.c 中,所定义的特征值都没有加描述项,但是它们都有显示名字,如下图:

这里说明一下为什么会有这种情况:
首先,在蓝牙协议标准层面,给人看的“名字”应该定义在描述项(Descriptor)里,具体是 0x2901 Characteristic User Description。
蓝牙联盟(Bluetooth SIG)维护了一个标准 UUID 数据库。如果特征值的 UUID 是蓝牙联盟规定的标准 DIS 服务 UUID(例如 Model Number 是 0x2A24),那么 BLE调试助手/nRF Connect 等工具不需要描述符,直接通过 UUID 查表就能显示 "Model Number String"。
用户自定义的 UUID( 128 位,或者未注册的 16/32 位)是私有的。通用工具无法查表,为了严谨,它们只能显示 "Unknown Characteristic" 并列出原始的 UUID 字符串,以防误导用户,即使加了 0x2901 描述符。
只有点进去看描述符才能看到文字,如下:

所以总结一下,只要你的 UUID 是标准的,就不需要描述符来告诉工具名字,自定义的 UUID 即便自己使用描述符定义了名字,依然会显示 Unknown Characteristic 。
2.2.4 可选的描述符
再简单说一个问题,我们前面有说过,如果不具备 Notify / Indicate 属性的话,描述符是可以不需要的,对于示例中的特征值1 ,我们都可以直接把描述项部分给删除,也不影响使用,如下:

这里这样修改以后特征值依然读写正常。 只是有一个小点,改了以后会发现手机读取不到 Notify 了,这是因为我们的 Notify 的位置发生了变化,在 gattprofile.c 的最开头,因为少了一个属性项,我们改成 10 就正常了:

2.3 特征值 2~3
把特征值 1 介绍完毕,后面的特征值类似,简单的过一遍就好:
- • 特征值2(Char2)- 只读
- • 特征值3(Char3)- 只写
这两个很基本,没什么需要特别说明的。
2.4 特征值 4 - 通知(Notify)
特征值4 ,是示例中唯一带 CCCD 的特性,因为支持 Notify,必须要多了一个客户端配置描述符(0x2902)。
// Characteristic 4 Declaration
{
{ATT_BT_UUID_SIZE, characterUUID},
GATT_PERMIT_READ,
0,
&simpleProfileChar4Props}, //声明本特征值的属性 GATT_PROP_NOTIFY
// Characteristic Value 4
{
{ATT_BT_UUID_SIZE, simpleProfilechar4UUID},
0, // 权限:0,因为 Notify 是服务器主动推送,客户端不直接读写这个值
0,
simpleProfileChar4},
// Characteristic 4 configuration
{
{ATT_BT_UUID_SIZE, clientCharCfgUUID}, // 描述符类型:客户端特征配置 0x2902
GATT_PERMIT_READ | GATT_PERMIT_WRITE, // 权限:可读可写,用于客户端开启/关闭通知
0,
(uint8_t *)simpleProfileChar4Config}, //Notifications and Indications are Enabled/disabled
// Characteristic 4 User Description
{
{ATT_BT_UUID_SIZE, charUserDescUUID},
GATT_PERMIT_READ,
0,
simpleProfileChar4UserDesp},
大家以后自己添加具备 Notify/Indicate 属性的特征时候,必须带上上面代码中的 Characteristic 4 configuration 部分,而且格式与上面保持一致。
2.5 特征值 5 - 需要认证的读
特征值 5 也只是在 特征值的第二项数值项里面的权限成员定义为 GATT_PERMIT_AUTHEN_READ ,需要和手机绑定才能正常读取数据:
// Characteristic 5 Declaration
{
{ATT_BT_UUID_SIZE, characterUUID},
GATT_PERMIT_READ,
0,
&simpleProfileChar5Props}, // 属性:GATT_PROP_READ 这个一样
// Characteristic Value 5
{
{ATT_BT_UUID_SIZE, simpleProfilechar5UUID},
GATT_PERMIT_AUTHEN_READ, // ⭐ 需要配对认证才能读
0,
simpleProfileChar5},
// Characteristic 5 User Description
{
{ATT_BT_UUID_SIZE, charUserDescUUID},
GATT_PERMIT_READ,
0,
simpleProfileChar5UserDesp},
我们如果把官方示例中的服务和特征值的定义完全搞清楚,基本上在后面应用中想要自己定义什么特征值是完全没问题的,至于数据通信流程逻辑那些,也是按照示例中的框架来添加即可,这个后面有需要可能还会单独写一篇主机与从机数据收发流程解析。
三、示例测试
对于本文来说,我们主要是学会去增加自定义的服务和特征,现在就来完成我们前面说过的想要实现的效果。
因为最后我会把我测试的源码给大家下载,所以在本次测试只会把基础步骤和关键点加以说明,大部分都是代码复制粘贴改名字那些工作,大家自己操作起来也只是花点时间,没有技术难点。
3.1 增加 Indicate 属性特征值
首先我们在示例基础上,增加一个带 Indicate 属性的特征值, 第六个特征值,直接在 simpleProfileAttrTbl 数组后面添加:

完成上述操作,我们就已经可以在连接后,看到这个特征值了:

这里只是能够显示这个特征值,但是数据交互逻辑我们还没有增加,我们接下来还要增加一些逻辑交互数据处理的操作代码。
我们需要实现几个数据处理的操作,分别如下:
-
- 我们在之前《沁恒微蓝牙 GATT 应用框架说明》文章中讲过,具备 CCCD 属性的特征值是需要绑定的,在
SimpleProfile_AddService函数对应位置添加绑定信息,然后对应几个地方也需要增加一条代码:
- 我们在之前《沁恒微蓝牙 GATT 应用框架说明》文章中讲过,具备 CCCD 属性的特征值是需要绑定的,在
2. 2. 实现一个 simpleProfile_Indicate函数,可以参考例程原始的函数:

这里比 Notify 多一个 taskID 是因为蓝牙协议规定 Indication 发送后,对方必须发送一个确认响应(Confirmation)返回,这个返回必须有个载体。
我们可以不用做任何处理,发送 Indication 协议栈会自动处理,只有当我们想要 “确认手机是否真的收到” 时,才需要应用层去处理这个响应。
3. 3. 函数simpleProfile_WriteAttrCB 中增加特征值 6 的 CCCD 读写处理:
4. 4. 最后还需要在 peripheral.c 中实现一下 Indicate 发送,这个其实就是按照示例的 Notify 发送写就行了,没特别的:

在周期任务中添加一下周期发送:

至此我们新的具备 Indicate 属性的特征值就添加完成了,我们可以看一下测试效果:

3.2 增加服务
增加服务,基本就是复制一遍 gattprofile.c 和 gattprofile.h ,然后还有在 peripheral.c 文件中,需要增加的一些逻辑和数据收发的代码别,这个部分大家需要下载示例代码自己看即可,需要说明的在上面文章中基本都说明了。

最后手机连接读到的效果如下:

不过上面 nRF Connect 我还真不知道怎么读取显示成字符串,也没在网上找到如何设置,因为这个服务的第一个特征值我是用字符串处理的,使用沁恒微 的 Ble 调试助手可以读取显示字符串:

3.3 增加128bit UUID 服务
最后来测试一下 128bit UUID 服务和特征值的增加。
可以肯定的是,即便是 128bit UUID 整体的逻辑依旧是和 16 bit 的 UUID 一样的,就等于代码框架不变,我们把需要注意的地方说明一下。
首先是服务于特征的定义声明,我们用图片可以更好的说明:

对于自定义的 UUID 而言,服务和特征值的 UUID 可以是一样的,但是同一个服务下面不同的特征值 UUID ,必须不一样。
其他的流程基本一致,还有一个地方,就是在数据读写的时候,就是在 my_128bit_app_read_attr_callback 和 my_128bit_app_write_attr_callback 函数种,需要处理一下分支,示例为 16 bit UUID,因为我们测试的时候使用 2个特征一个是 只写,一个是 Notify ,我们这里上一下 写回调的处理:
static bStatus_t my_128bit_app_write_attr_callback(uint16_t connHandle, gattAttribute_t *pAttr,
uint8_t *pValue, uint16_t len, uint16_t offset, uint8_t method)
{
bStatus_t status = SUCCESS; // 初始状态设为成功
uint8_t notifyApp = 0xFF; // 通知应用的标志(0xFF表示无通知)
// If attribute permissions require authorization to write, return error // 检查属性是否要求授权写入
if(gattPermitAuthorWrite(pAttr->permissions))
{
// Insufficient authorization
return (ATT_ERR_INSUFFICIENT_AUTHOR); //返回授权不足错误(0x08)
}
//根据UUID类型(16位或128位)
if(pAttr->type.len == ATT_BT_UUID_SIZE)
{
/*
16-bit UUID 的处理,按照官方示例来,本来觉得这里不需要
但是描述符那些是标准的 16bit 的UUID ,这些需要
*/
// 16-bit UUID
uint16_t uuid = BUILD_UINT16(pAttr->type.uuid[0], pAttr->type.uuid[1]);
switch(uuid){
case GATT_CLIENT_CHAR_CFG_UUID:
PRINT("write 128 CCCD\r\n");
status = GATTServApp_ProcessCCCWriteReq(connHandle, pAttr, pValue, len,
offset, GATT_CLIENT_CFG_NOTIFY);
break;
default:
// Should never get here! (characteristics 2 and 4 do not have write permissions)
status = ATT_ERR_ATTR_NOT_FOUND;
break;
}
}
else
{
// 128-bit UUID
// status = ATT_ERR_INVALID_HANDLE;
// uint64_t uuid = BUILD_UINT16(pAttr->type.uuid[12], pAttr->type.uuid[13]); // 处理128位UUID
/*
这里需要处理 以下到底是哪个特征值的 UUID,
这个自定义的 UUID 区分,当然是需要自己处理
比如我们上面定义的,就是第一个成员不一样,我们这里测试就用最简单的方式
*/
uint8_t test_uuid = pAttr->type.uuid[0]; //char1 为 01 cha2为 02
printf("Full 128-bit UUID: ");
for (int i = 0; i < 16; i++)
{
printf("%02X ", pAttr->type.uuid[i]);
}
printf("\n");
switch(test_uuid)
{
case1:
//Validate the value
// Make sure it's not a blob oper
if(offset == 0)
{
if(len > my_128bit_app_CHAR1_LEN)
{
status = ATT_ERR_INVALID_VALUE_SIZE;
}
}
else
{
status = ATT_ERR_ATTR_NOT_LONG;
}
//Write the value
if(status == SUCCESS)
{
tmos_memcpy(pAttr->pValue, pValue, my_128bit_app_CHAR1_LEN);
notifyApp = my_128bit_app_CHAR1;
}
break;
default:
// Should never get here! (characteristics 2 and 4 do not have write permissions)
status = ATT_ERR_ATTR_NOT_FOUND;
break;
}
}
/*若特征值发生变更,通过回调通知应用层 */
// If a charactersitic value changed then callback function to notify application of change
if((notifyApp != 0xFF) && my_128bit_app_AppCBs && my_128bit_app_AppCBs->pfnmy_128bit_app_Change)
{
my_128bit_app_AppCBs->pfnmy_128bit_app_Change(notifyApp);
}
return (status);
}
上面主要是注意 16bit UUID 的分支依然要保留,用来处理 CCCD 的写操作,因为 UUID 固定是 16bit 的 0x2902。
然后第二就是在判断是哪一个特征值的时候,是根据自定义的 UUID 去做区分的,对于上面示例而言,特征值的 UUID 就只有1个字节不一样,就做了一下最简单的区分,实际应用需要自己考虑合理性。
最后实际测试的效果如下:


好了,到这里,示例展示圆满结束,代码放下载地址放在下面,需要自取。
四、示例下载
示例工程没有做独立处理,需要放在 EVT 包对应位置运行。
测试芯片: CH585M

下载地址:

结语
本文我们分析了一下沁恒微官方从机示例种添加自定义服务和特征值的部分代码,在官方示例基础上,继续给大家展示了不同的服务和特征的添加示例。相信以后以后在沁恒微芯片蓝牙服务和特征值的添加上,大家都游刃有余。
接下来计划 GATT 部分还需要一篇文章,要结合主机示例,说明一下数据收发的接口流程。
这种基础说明文章写起来还是蛮累的,怕简单的东西都没表述好,又怕涉及到的其他该知道的东西没有讲到位,累 /(ㄒoㄒ)/~~
好了,本文就到这里,谢谢大家!

评论区
登录后即可参与讨论
立即登录