1. 从一个实际需求说起:为什么中文扫描响应包值得单独写一篇
做过低功耗蓝牙产品的人大概都有过这种经历:设备广播出去,手机端一扫,名字显示出来是乱码,或者干脆只显示一串十六进制。尤其是做面向国内市场的产品时,客户第一反应就是"为什么别人家的设备扫出来是中文名,你这个是英文的?"这个问题看起来很小,但真到落地的时候,踩坑的人一批接一批。
我最近在基于TLSR_8258这颗芯片做一款低功耗蓝牙设备,需求里明确要求广播名称和扫描响应里都要能显示中文。一开始我以为这事儿很简单,无非就是把字符串塞进广播包或者扫描响应包里。结果真动手才发现,广播包和扫描响应包在协议层面是两套不同的逻辑,中文又涉及UTF-8编码和字节长度的问题,再加上TLSR_8258的SDK里对广播数据的组织方式有自己的封装,稍不注意就会出现"名字被截断""中文变问号""扫描响应包根本没发出去"这些情况。
这篇内容就是把我从零摸索到跑通的整个过程整理出来,重点讲清楚三件事:扫描响应包和广播包到底有什么区别、中文在扫描响应包里应该怎么编码和填充、TLSR_8258的SDK里具体改哪些地方。适合正在用TLSR_8258做蓝牙产品、需要显示中文设备名的开发者参考,也适合刚接触低功耗蓝牙广播机制、想搞明白扫描响应包原理的朋友。代码层面我会给出可直接参考的写法,原理层面我会解释每个参数为什么这么设,尽量让你看完就能上手改自己的工程。
2. 先把概念理清楚:广播包和扫描响应包不是一回事
2.1 广播包与扫描响应包的分工
很多人刚接触蓝牙广播的时候,会把广播包和扫描响应包混为一谈,觉得反正都是往外发数据,塞哪个不是塞。实际上这两个东西在协议里承担的角色完全不同。
广播包(Advertising Packet)是设备主动、周期性地发出去的,它不需要任何中心设备请求就会一直发。它的作用是"告诉周围我存在",里面通常放的是设备的基础信息,比如设备类型、是否可连接、服务UUID的简要信息等。广播包的长度上限是31字节,这个限制非常硬,是协议规定的。
扫描响应包(Scan Response Packet)则不一样,它是被动触发的。当中心设备(比如手机)收到广播包后,如果它想获取更多信息,会主动发一个扫描请求(Scan Request),设备收到请求后才回复一个扫描响应包。也就是说,扫描响应包不是你想发就能发的,得等对方来问。它的长度上限同样是31字节。
这个机制带来的直接好处就是:设备可以把信息拆成两部分,广播包里放最关键的、必须让所有设备都看到的内容,扫描响应包里放那些"有人问再说"的补充信息。设备名称就是典型的适合放在扫描响应包里的内容,因为不是所有扫描场景都需要读名字,只有真正要连接或者展示列表的时候才需要。
2.2 为什么中文名称更适合放扫描响应包
这里有个很现实的考量。中文经过UTF-8编码后,一个汉字通常占3个字节。如果你的设备名是"智能温控器"这五个字,光名字就占15个字节,再加上名称字段本身的类型标识和长度标识,差不多要17个字节。如果把这个塞进31字节的广播包,剩下的空间基本就放不下别的了,服务UUID、厂商自定义数据这些统统没地方放。
放到扫描响应包里就宽松多了。广播包里只放必要的连接参数和服务标识,扫描响应包里专门放名称,两边各司其职,互不挤占。而且从实际使用体验看,手机在扫描列表里显示设备名,走的正是扫描响应这条路径,所以把中文名放这里既符合协议设计意图,也符合用户实际看到的效果。
注意:有些开发者为了省事,把名称同时塞进广播包和扫描响应包,这在协议上是允许的,但会浪费广播包的宝贵空间。除非你的设备名很短(比如两个汉字以内),否则不建议这么做。
2.3 TLSR_8258在这套机制里的角色
TLSR_8258是泰凌微电子的一款低功耗蓝牙SoC,支持蓝牙5.0,在智能家居、穿戴设备里用得很多。它的SDK把广播和扫描响应的数据组织封装成了几个结构体和接口函数,你不需要自己去拼裸的协议数据,但代价是你得理解它这套封装逻辑,否则改起来会找不到北。
具体来说,SDK里广播数据和扫描响应数据是分开配置的,各自有独立的设置接口。扫描响应包的设置入口和广播包很像,但有几个关键差异,这也是后面实操部分要重点讲的。理解了这层封装,你才能知道中文名称应该往哪个接口里塞、塞的时候要注意什么。
3. 中文编码这件事:UTF-8、字节长度与协议字段的配合
3.1 蓝牙名称字段的协议格式
在蓝牙协议里,设备名称对应的是"Complete Local Name"或者"Shortened Local Name"这个数据类型,它的AD Type值分别是0x09和0x08。一个完整的名称字段在广播数据里的结构是这样的:第一个字节是长度(Length),第二个字节是AD Type,后面跟着的就是名称的实际字节内容。
举个例子,如果名称是"ABC",那么这段数据就是:0x04, 0x09, 0x41, 0x42, 0x43。第一个字节0x04表示后面还有4个字节(1个类型字节加3个名称字节),0x09是类型,后面三个是ASCII字符。
关键点来了:这个长度字节统计的是"类型字节加名称字节"的总数,不包括长度字节自己。很多人在拼接的时候把长度算错,导致名称显示不全或者后面跟了一堆乱码,根源就在这里。
3.2 中文UTF-8编码的字节数计算
中文在UTF-8里的编码规则是这样的:常用的汉字(基本平面)占3个字节,少数生僻字占4个字节。标点符号里,中文标点一般也是3个字节,英文标点占1个字节。
所以你在计算名称字段长度的时候,不能按"字符数"算,必须按"字节数"算。比如"温度"两个字,字符数是2,但字节数是6。如果名称是"温度计123",那就是3个汉字(9字节)加3个数字(3字节),总共12字节,加上类型字节1个,长度字段应该填13(0x0D)。
我见过有人直接按字符数填长度,结果手机端只显示了第一个汉字就断了,因为长度字段告诉协议栈"后面只有2个字节是名称",协议栈就只读了2个字节,而一个汉字就要3个字节,自然就乱了。
3.3 编码转换在代码里怎么落地
在TLSR_8258的SDK里,你写代码的时候字符串是以什么形式存在的,直接决定了你要不要做转换。如果你在源码里直接写中文字符串字面量,编译器一般会按UTF-8存储(取决于你的工程编码设置),这时候你直接取这个字符串的字节内容就行。但如果你的工程编码是GBK或者其他格式,那就需要先转成UTF-8再填充。
我的建议是:统一用UTF-8。在源码文件里确保中文是以UTF-8保存的,然后在填充扫描响应数据的时候,直接用sizeof或者strlen来获取字节长度。注意strlen对UTF-8字符串是安全的,因为它按字节统计,遇到0x00才停,而UTF-8编码里不会出现0x00字节(除非是字符串结束符)。
// 假设名称定义如下,源码文件保存为UTF-8编码 const char device_name[] = "智能温控器"; // 获取字节长度,这里得到的是15(5个汉字 x 3字节) int name_len = strlen(device_name);这段代码里name_len就是15,不是5。你后面填长度字段的时候要用这个15去算。
4. TLSR_8258 SDK里扫描响应包的具体设置方法
4.1 找到扫描响应数据的配置入口
在TLSR_8258的SDK里,广播相关的配置通常集中在app_config.h或者专门的广播配置文件里。扫描响应数据的设置接口一般叫ble_sts_t bls_ll_setScanRspData(u8 *data, u8 len)这样的形式,不同版本的SDK可能名字略有差异,但核心逻辑是一样的:你给它一个数据缓冲区指针和长度,它就把这段数据作为扫描响应内容。
我建议你先在工程里全局搜一下ScanRsp这个关键词,找到SDK里实际使用的那个接口。不要凭记忆写,因为不同SDK版本的接口签名可能不一样,有的要求你传完整的AD结构数据,有的只要求你传名称部分,它内部帮你封装。搞清楚这一点很关键,否则你填的数据格式不对,发出去也是白搭。
4.2 手动构造扫描响应数据的完整写法
假设SDK要求你自己构造完整的AD结构数据,那么构造过程是这样的。先定义一个缓冲区,然后按协议格式往里填:
#define DEVICE_NAME "智能温控器" u8 scan_rsp_data[31]; u8 index = 0; const char *name = DEVICE_NAME; u8 name_len = strlen(name); // 第一个字节:长度字段 = 类型字节(1) + 名称字节数 scan_rsp_data[index++] = name_len + 1; // 第二个字节:AD Type,0x09表示完整本地名称 scan_rsp_data[index++] = 0x09; // 后面填充名称的实际字节 memcpy(&scan_rsp_data[index], name, name_len); index += name_len; // 最后调用SDK接口设置扫描响应数据 bls_ll_setScanRspData(scan_rsp_data, index);这段代码里,index最终的值就是整个扫描响应数据的长度。对于"智能温控器"这个名称,name_len是15,加上长度字节和类型字节,总共17字节,远小于31字节的上限,完全没问题。
提示:如果你的扫描响应包里还要放其他数据(比如厂商自定义信息),记得把这些数据也按同样的AD结构追加在后面,并且总长度不能超过31字节。每追加一段数据,都要单独算它自己的长度字段。
4.3 名称长度接近上限时的处理策略
如果你的设备名特别长,比如"某某牌智能家居环境监测控制终端",算下来可能超过20个字节,再加上其他扫描响应数据就可能顶到31字节的天花板。这时候有几个处理思路。
第一个思路是精简名称,把不必要的前缀去掉,只保留核心识别部分。第二个思路是使用"Shortened Local Name"(AD Type 0x08),协议允许你只发名称的一部分,但这样手机端显示的就是截断后的名字,体验不好。第三个思路是把名称拆开,广播包里放一部分,扫描响应包里放一部分,但这样手机端可能显示不完整,不推荐。
我的实际经验是:设备名控制在8个汉字以内(24字节),给扫描响应包留出至少7个字节的余量给其他数据。如果确实需要长名称,考虑用英文或者拼音,能省不少空间。
5. 实操过程中最容易踩的坑与排查方法
5.1 中文显示乱码的三种典型原因
中文乱码是这类需求里最高频的问题,我总结下来主要有三种原因。
第一种是编码不匹配。你的源码文件是GBK编码,但手机端按UTF-8解析,自然就是乱码。解决办法是统一工程编码为UTF-8,并且在代码里确认字符串字节确实是UTF-8格式。你可以用十六进制查看器看一下字符串的字节内容,UTF-8的中文第一个字节通常在0xE4到0xE9之间,如果看到的是0xC4、0xD5这种,那就是GBK。
第二种是长度字段算错。前面讲过,长度字段必须按字节数算,不能按字符数算。如果你填的是字符数,协议栈只会读取部分字节,剩下的字节要么被丢弃,要么被当成下一个AD结构的长度字段,导致整个数据解析错乱。
第三种是扫描响应包根本没发出去。有些SDK默认不开启扫描响应功能,你设置了数据但设备不响应扫描请求,手机端自然读不到名称。这时候要检查SDK里是否有使能扫描响应的配置项,通常是一个布尔开关或者一个专门的初始化调用。
5.2 用手机端工具验证扫描响应内容
排查问题的时候,光看代码是不够的,得用工具看实际发出去的数据。手机端有一些蓝牙调试工具可以显示原始的广播数据和扫描响应数据,你能看到每个字节的内容。我常用的方法是:先用工具扫一次,看扫描响应包里有没有0x09这个类型标识,如果有,再看它后面的字节是不是你期望的UTF-8内容。
如果扫描响应包里压根没有0x09,那说明你的数据没设置成功,回去检查接口调用和使能开关。如果有0x09但内容不对,那就是编码或者长度的问题,对照前面讲的规则逐字节排查。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 名称显示为乱码 | 编码不是UTF-8 | 检查源码文件编码和字符串字节内容 |
| 名称只显示一部分 | 长度字段按字符数计算 | 改为按字节数计算长度 |
| 扫描响应包为空 | 未使能扫描响应功能 | 检查SDK配置里的使能开关 |
| 名称完全不显示 | AD Type填错 | 确认使用0x09或0x08 |
| 名称后面跟乱码 | 长度字段多算了字节 | 核对长度字段与实际内容是否一致 |
| 扫描响应包超过31字节 | 数据总量超限 | 精简名称或移除次要数据 |
这张表基本覆盖了我遇到过的所有情况,你可以对照着排查。实际调试的时候,建议一次只改一个变量,改完立刻用工具验证,这样才能准确定位问题出在哪一步。
6. 一些让代码更稳的工程化建议
6.1 把名称和长度计算封装成宏或函数
每次手动算长度容易出错,我习惯把名称定义和长度计算封装起来。比如定义一个宏,把名称字符串和它的字节长度绑定在一起,用的时候直接取,避免手算出错。
#define DEVICE_NAME_STR "智能温控器" #define DEVICE_NAME_LEN (sizeof(DEVICE_NAME_STR) - 1)注意这里用sizeof减1,因为字符串字面量末尾有一个隐式的\0,那个不算在名称内容里。用sizeof而不是strlen的好处是编译期就能算出长度,不占用运行时开销。
6.2 给扫描响应数据留足缓冲区并做边界检查
我见过有人在填充扫描响应数据的时候不检查边界,名称一长就写越界,把后面的内存踩了,导致各种奇怪的问题。稳妥的做法是定义一个31字节的缓冲区,每次写入前检查剩余空间,不够就截断或者报错。
#define SCAN_RSP_MAX_LEN 31 u8 scan_rsp_data[SCAN_RSP_MAX_LEN]; u8 index = 0; if ((name_len + 2) > SCAN_RSP_MAX_LEN) { // 名称太长,做截断处理或者报错 name_len = SCAN_RSP_MAX_LEN - 2; }这段检查看起来简单,但能避免很多莫名其妙的崩溃和显示异常。尤其是产品后期名称可能被修改,有了边界检查就不怕改出问题。
6.3 名称变更时的注意事项
如果你的产品支持动态修改设备名(比如通过APP改名字),那扫描响应数据也需要在名称变更后重新设置。这时候要注意两点:一是重新设置前先确认新的名称字节长度没有超限,二是重新设置后最好重启一次广播或者重新使能扫描响应,确保新数据生效。
我遇到过改了名字但手机端还是显示旧名字的情况,排查半天发现是扫描响应数据没有重新下发,设备还在用旧的缓冲区内容。后来养成习惯,名称一变就重新调用设置接口,问题就再没出现过。
7. 关于广播包和扫描响应包配合的几点补充
7.1 广播包里要不要放名称
前面说过,名称放扫描响应包是更合理的做法。但有些场景下,你确实需要在广播包里也放一个简短名称。比如某些扫描设备只读广播包不读扫描响应包,这时候如果广播包里没有名称,设备就显示为未知设备。
我的建议是:如果广播包空间充裕,可以放一个简短的英文名或者拼音名作为兜底,完整的中文名放扫描响应包。这样既保证了兼容性,又不浪费太多广播包空间。如果广播包空间紧张,那就只放扫描响应包,接受部分设备显示未知名称的现实。
7.2 扫描响应包的触发条件
扫描响应包不是无条件发送的,它需要中心设备主动发扫描请求。大部分手机在扫描时会自动发扫描请求,所以你能看到名称。但有些低功耗场景下,中心设备可能配置为被动扫描,不发请求,这时候扫描响应包就不会被触发,名称也就读不到。
这一点在调试的时候要心里有数。如果你确认代码没问题但就是读不到名称,先确认一下扫描端是不是被动扫描模式。换个手机或者换个扫描APP试试,往往就能排除这个问题。
7.3 不同手机平台的显示差异
同样一份扫描响应数据,不同手机平台显示出来的效果可能有差异。有的平台对UTF-8支持很好,中文显示正常;有的平台可能对某些字符处理不一样,出现方框或者问号。这通常不是你的数据问题,而是平台端的解析差异。
遇到这种情况,我的处理方式是:先用通用调试工具确认原始数据是正确的UTF-8,然后换几台不同品牌的手机测试,如果大部分正常,那就可以认为数据没问题,个别平台的显示差异属于平台侧的问题,不在设备端解决范围内。
8. 我在这颗芯片上摸爬滚打后的几点真实体会
TLSR_8258这颗芯片整体来说上手难度不算高,SDK的封装也比较友好,但广播和扫描响应这块的文档确实不够细致,很多细节得自己试出来。我最大的体会是:不要怕看原始数据。代码写得再漂亮,最终发出去的是一串字节,用工具把这串字节看清楚,比在代码里猜来猜去高效得多。
另外一个体会是关于编码的。中文编码这个问题在嵌入式开发里特别容易被忽视,因为很多开发者习惯用英文,一旦涉及中文就出问题。我的建议是在项目初期就把编码规范定下来,统一用UTF-8,所有涉及字符串的地方都按字节处理,不要混用字符和字节的概念。这个习惯一旦养成,后面遇到类似问题就能很快定位。
最后说一个实际的小技巧:如果你在调试的时候不确定名称字段应该填多长,可以先用一个简短的英文名跑通流程,确认扫描响应包能正常发出、手机能读到,然后再把英文名换成中文名,观察字节长度的变化。这样分步验证,比一上来就搞中文名要容易排查问题。我自己就是这么一步步试过来的,每次只改一个变量,出问题立刻就能知道是哪一步引入的。