ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

星闪SLE开发实战:UUID Server与Client配对通信全链路详解

星闪SLE开发实战:UUID Server与Client配对通信全链路详解 做星闪开发有一阵子了从最开始对着SDK里的SLE_UUID_Server示例一头雾水到后来能在Server和Client之间稳定地互传数据中间踩了不少坑。最开始我以为星闪的SLE开发跟BLE差不多能拿蓝牙那套经验直接套结果被协议栈的回调机制、UUID服务创建方式、配对流程轮番教做人。这篇博文就围绕星闪SLE_UUID_Server与Client配对通信的完整链路来讲把服务端怎么建服务、客户端怎么找服务、数据怎么收发、调试时该看哪些指标一次说清楚适合刚拿到星闪开发板、准备从BLE转过来的嵌入式开发者或者正在做物联网设备互联的方案选型人员。文末我会专门讲代码调试技巧和几个典型的坑位基本都是能直接落地的经验。1. 先看懂SLE_UUID_Server与Client的通信模型1.1 星闪SLE到底是什么它和BLE的对应关系星闪是中国自主创新的近距离无线通信技术协议栈分为SLEStar Link Emission和SLBStar Link Basic两条路线。SLE更贴近传统BLE的定位主打低功耗、中等速率、低时延适合遥控器、键鼠、传感器、智能家居面板这类设备SLB侧重高带宽、低时延的场景比如音频传输、投屏、工业控制。本文讲的SLE_UUID_Server和Client就是SLE协议栈里基于UUID的服务端/客户端通信模型。很多人第一次接触星闪SDK的时候看到sle_开头的API会有点懵其实它的设计思路跟BLE的GATT层非常像。Server端负责把设备能力抽象成一个个服务Service每个服务里挂着若干个特征值Characteristic。Client端则像一个拿着清单的访客先扫描找到Server再按照UUID去核对清单上有没有自己需要的服务找到了就对这个服务里的特征值做读写操作。说个生活化的类比Server就是大楼里的前台服务窗口每个窗口贴着一个编号UUID有的窗口只接受资料可写特征值有的窗口只发放证件可读特征值有的窗口会主动把材料递给访客通知/指示。Client就是来办事的人它得先找到大楼扫描连接再找到对应编号的窗口服务发现然后才能办业务读写数据。这套逻辑和BLE几乎一致所以有BLE开发经验的人学SLE会很快但细节上千万不能照搬后面我会讲几个不一样的地方。1.2 为什么一定要用UUID来标识服务UUIDUniversally Unique Identifier是服务端和客户端之间的暗号。SLE支持16位UUID和128位UUID两种类型。16位UUID一般用于标准服务和厂商自定义的短标识比如0xFFE0、0xFFE1这类在蓝牙生态里也常见的厂商自定义区间128位UUID适合需要全局唯一性的自定义服务避免不同厂商之间冲突。实际开发里最常见的坑就是UUID类型不匹配。Server用128位UUID建了服务Client却用16位UUID去搜扫描结果永远是空的反过来也是。所以编写代码的时候我习惯把服务端和客户端的UUID定义放在同一个头文件里从源头杜绝这种不一致。比如#define SERVICE_UUID_TYPE SLE_UUID_TYPE_128 #define CHARACTER_UUID {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, \ 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}128位UUID在代码里一般以数组形式存在注意字节序数组下标0是UUID的低字节这一点会在抓包对照的时候经常被翻出来先记在心里。1.3 SLE协议栈的状态机流转理解了模型还要理解协议栈的运行逻辑。SLE协议栈从初始化到数据收发大致经历以下几个状态协议栈使能SLE_ENABLE、Server注册服务SLE_CREATE_SERVER、Client扫描SLE_START_SCAN、连接建立SLE_CONNECT/ACCEPT、服务发现SLE_DISCOVERY、数据交互SLE_READ/WRITE/NOTIFY。这里有个跟BLE很不一样的点星闪的SLE协议栈在收到连接、断开、数据到达等事件时大量依赖异步回调。也就是说你不能写完一句连上对方就立刻去读数据因为连接成功的确认是在回调函数里异步返回的。很多从BLE转过来的开发者包括我一开始会在这个地方踩坑——回调还没触发代码就已经往下执行了结果读到的全是无效句柄。sle_connect(conn_param); // 这里的代码执行时连接不一定已经建立成功 // 不要在这里直接调用服务发现接口正确的做法是把后续流程压到回调里靠状态机驱动static void on_connection_changed(uint8_t conn_id, sle_connection_state_t state) { if (state SLE_CONNECTED) { // 连接建立成功此时才能开始服务发现 start_discover_service(conn_id); } }2. 开发环境与工程目录SDK、编译链、烧录的准备工作2.1 芯片与开发板选型目前市面上能跑SLE协议栈的芯片主要是海思Hi2821系列、以及部分国产IoT厂商推出的星闪SoC。我手头这块是Hi2821的开发板SDK里自带server和client两个示例工程代码框架基本是现成的主要工作是理解它然后改造。选型时重点看三件事芯片的Flash/RAM是否够你跑业务逻辑、SDK是否包含SLE协议栈的完整库文件、厂商有没有提供串口调试工具和烧录工具。如果是个人学习建议直接买官方开发套件虽然价格比普通蓝牙开发板贵一点但资料和例程齐全遇到问题也容易找到厂商技术支持。别为了省几十块钱买没资料的杂牌板子后面排查问题会非常痛苦。2.2 SDK目录结构说明厂商SDK一般会分成几个大目录sdk/协议栈库文件、公开头文件app/用户应用代码我们主要改这里os/操作系统抽象层有些基于LiteOS有些基于FreeRTOStools/烧录工具、日志工具、抓包工具编译脚本Makefile或build.py开发时改app目录下的业务逻辑就够了sdk目录下的协议栈库文件尽量不要动。我见过有同事为了优化协议栈性能直接改sdk底层文件结果编译过不了回滚了大半天。协议栈库一般是预编译的.a或.lib文件真的出了问题也没法在源码层面改不如直接提单给厂商。2.3 编译与烧录的常见坑交叉编译工具链版本SDK往往对工具链版本有严格要求gcc版本太低会冒出一些莫名其妙的编译错误比如unknown type name uint32_t。搭建环境时最好用SDK文档里明确指定的版本号。ROM/RAM大小评估星闪协议栈本身会占用一定资源再加上LiteOS内核、驱动框架留给业务的RAM往往只有几十KB。如果业务代码里用了大数组链接时很容易报overflow。此时要看你申请的内存是放静态区还是堆区放堆区的话还得评估协议栈的堆内存配置够不够。我习惯在工程配置文件里把协议栈的堆大小调大一些同时把不用的协议特性裁剪掉。烧录工具的串口选择很多开发板同时有调试串口和AT指令串口烧录时选错串口会一直报连接超时。我每次换开发板后都会先确认一下设备管理器的COM口号对应哪个物理串口再开始烧录。我通常的做法是先把SDK自带的server和client示例烧进去跑通默认逻辑再开始改自己的服务和UUID。很多人急于写自己的业务代码跳过这一步结果环境问题、编译问题、烧录问题混在一起排查难度翻倍。先跑通例程相当于给环境打了个满分后面就只需要面对代码了。3. Server端实战UUID服务创建、特征值属性与回调机制3.1 Server初始化链路Server端的初始化流程一般是这样// 初始化协议栈 errcode_t ret sle_enable(NULL, NULL); if (ret ! SLE_OK) { printf(sle_enable failed: 0x%x\r\n, ret); return; } // 注册协议栈事件回调 sle_callback_t cb {0}; cb.connection_state_changed on_connection_state_changed; cb.uuid_server_data_received on_server_data_received; sle_register_callback(cb);注意sle_register_callback注册的回调是整个协议栈级的Server和Client共用同一套回调入口需要靠连接句柄conn_id来区分是哪条连接的事件。最开始我把连接回调当成只属于Server的回调结果Client端也莫名其妙触发了同一个回调翻API文档才发现回调是统一分发的。这个设计跟BLE stack很像但星闪SDK的例程一般不会主动说这件事属于文档角落信息。3.2 创建服务与特征值服务的创建相对直观关键是处理好UUID类型和长度sle_uuid_t service_uuid {0}; service_uuid.type SLE_UUID_TYPE_16; service_uuid.uuid16 0x1800; // 举例实际按你的业务定义 sle_uuid_server_info_t server_info {0}; server_info.uuid service_uuid; server_info.min_attr_len 4; server_info.max_attr_len 64; uint8_t server_handle sle_create_server(server_info); if (server_handle INVALID_ATT_HANDLE) { printf(create server failed\r\n); return; }然后往服务里添加特征值sle_uuid_t char_uuid {0}; char_uuid.type SLE_UUID_TYPE_16; char_uuid.uuid16 0x2A00; sle_characteristic_info_t char_info {0}; char_info.uuid char_uuid; char_info.properties SLE_UUID_PROPERTY_READ | SLE_UUID_PROPERTY_WRITE; char_info.permissions SLE_UUID_PERMISSION_READ_ALLOWED | SLE_UUID_PERMISSION_WRITE_ALLOWED; sle_add_characteristic(server_handle, char_info);这里的properties和permissions是两个不同的概念很多教程会把它们混在一起讲但实际开发中两者有明确分工。properties是给Client看的我能干什么相当于窗口挂出来的服务标语permissions是协议栈实际执行的权限检查相当于保安手里的权限表。如果属性位开了READ但权限没给READ_ALLOWEDClient读的时候会收到拒绝而且这个拒绝是协议栈直接返回的错误码你的Server代码甚至都感知不到。反过来属性位没开READ、权限给了READ_ALLOWEDClient端也会因为特征值不支持读而放弃。我整理了一张表方便对照properties位permissions位Client行为Server是否感知READREAD_ALLOWED正常读取有读请求回调READ无协议栈拒绝不感知无READ_ALLOWEDClient不发起读不感知WRITEWRITE_ALLOWED正常写入有写请求回调WRITE无协议栈拒绝不感知NOTIFY无可订阅通知有订阅回调实际项目里我见过不止一次这种问题两个设备明明连上了数据就是不通查到最后是properties和permissions配置不一致。所以写代码的时候两个字段建议放在一起连续配并且加注释。3.3 启动广播与等待连接Server建好服务之后一般会进入广播状态让Client能发现它。广播参数里配置广播周期、广播数据内容通常会把服务UUID塞进广播包方便Client提前判断sle_broadcast_param_t bc_param {0}; bc_param.broadcast_interval_min 100; // 单位通常为0.625ms100表示62.5ms bc_param.broadcast_interval_max 100; bc_param.adv_data.len 12; memcpy_s(bc_param.adv_data.data, sizeof(bc_param.adv_data.data), adv_data, 12); sle_set_broadcast_data(bc_param); sle_start_broadcast();注意星闪的广播数据包长度相对有限如果服务UUID是128位广播包里未必放得下。实操时我会把128位UUID放在扫描响应里或者干脆让客户端发起扫描后通过服务发现流程去获取——广播包里只放一个厂商标识既省空间又能起到过滤作用。广播启动成功之后Server就处于待连接状态。连接事件会触发前面注册的回调static void on_connection_state_changed(uint8_t conn_id, sle_connection_state_t state) { if (state SLE_CONNECTED) { printf(server connected, conn_id%d\r\n, conn_id); } else if (state SLE_DISCONNECTED) { printf(server disconnected, conn_id%d\r\n, conn_id); // 按需重启广播等待下一次连接 sle_start_broadcast(); } }断开重连是实际项目中很容易忽略的点。很多开发板在首次连接成功后一切正常但设备断电重启后Client就再也连不上了原因是Server断开后没有重新启动广播。建议把sle_start_broadcast()放在断开回调里并在主循环里加一个未连接状态才广播的保护。3.4 数据接收与发送的回调处理Server收到Client发来的数据会触发数据接收回调static void on_server_data_received(uint8_t server_handle, uint16_t conn_id, uint8_t *data, uint16_t len) { printf(recv len%d\r\n, len); // 先校验数据合法性和长度 if (len MAX_RECV_LEN) { return; } // 处理业务逻辑 process_received_data(data, len); // 如果需要回包 uint8_t resp_data[8] {0}; uint16_t resp_len 0; build_response(resp_data, resp_len); sle_uuid_server_send_response(server_handle, conn_id, resp_data, resp_len); }Server主动发数据的方式是通知notifysle_uuid_server_notify(server_handle, conn_id, notify_data, data_len);这里有一个非常容易踩的坑Write请求分为带响应和不带响应两种。带响应的写入Client写完会等待Server回包如果Server回调里没有调用send_responseClient会一直阻塞到超时报写超时错误不带响应的写入则不需要回包。很多从串口485通信转过来的开发者习惯收包即处理处理完不回复到了星闪这里就会出现Client端超时重试的现象。解决办法很简单先看历史抓包确认Client用的是写请求还是写命令再决定要不要回响应。4. Client端实战扫描、连接、服务发现与数据收发4.1 扫描参数配置与过滤Client端首先做扫描。扫描参数主要关注扫描类型和扫描间隔sle_scan_param_t scan_param {0}; scan_param.scan_type SLE_SCAN_TYPE_ACTIVE; scan_param.scan_interval 0x10; // 扫描间隔 scan_param.scan_window 0x10; // 扫描窗口 sle_start_scan(scan_param);扫描结果会在回调里上抛。如果Server端在广播包里塞了服务UUIDClient可以通过UUID过滤快速锁定目标设备如果广播包里没有就得先把扫描到的设备地址记下来连接后再走服务发现去确认。实际项目中我推荐一个折中方案在广播包里放一个厂商自定义的短标识比如2字节的设备类型编码Client扫描时按这个短标识过滤过滤命中后再发起连接连接成功后再做一次完整的UUID服务发现确认。这样广播数据不臃肿扫描阶段也不会被一屋子无关设备刷屏。4.2 发起连接与连接参数找到目标设备后发起连接sle_connect_param_t conn_param {0}; memcpy_s(conn_param.peer_addr, sizeof(conn_param.peer_addr), target_addr, sizeof(target_addr)); conn_param.conn_interval_min 8; // 短间隔 conn_param.conn_interval_max 16; conn_param.conn_latency 0; sle_connect(conn_param);连接间隔conn_interval决定收发数据的节拍。间隔越短时延越低但功耗越高间隔越长越省电但吞吐量和响应速度都会下来。做遥控器、键鼠这类交互型应用建议用短间隔数值8~16做传感器定时上报型应用把间隔拉长到80以上再配合连接延迟latency跳过不必要的唤醒节省更多功耗。这个权衡跟BLE基本一致但星闪在同样的间隔参数下因为调度机制的差异实测时延和功耗曲线会跟BLE不一样所以还是得在真实板子上量一遍。4.3 服务发现UUID匹配的完整逻辑连接成功后Client需要遍历Server的服务列表。这一步最关键的是一开始就要确认两端UUID是否匹配sle_discovery_param_t d_param {0}; d_param.enable_uuid_filter true; d_param.uuid target_service_uuid; // 和Server端定义保持一致 sle_discover_services(d_param);发现结果会通过回调返回里面包含服务句柄、特征值句柄等关键信息。拿到特征值句柄之后后续的所有读写操作都依赖它。经典坑发现服务回调里拿到的句柄必须保存为全局变量或者状态机变量千万不要在回调里用完后丢到栈上。后续Client做Read/Write时如果句柄无效协议栈会直接返回句柄不存在错误而且这个错误跟真实句柄错误长得一模一样排查起来会很费劲。我做服务发现的时候会把所有服务和特征值打印一遍包括UUID、类型、属性。这样即使目标UUID没匹配上也能从日志里看出来Server实际注册了哪些UUID快速定位是Server注册错了还是Client找错了。4.4 特征值读写与通知订阅Client端的读写操作非常直观// 写数据 errcode_t ret sle_uuid_client_write(conn_id, char_handle, data, len); if (ret ! SLE_OK) { printf(write failed: 0x%x\r\n, ret); } // 读数据 ret sle_uuid_client_read(conn_id, char_handle); // 订阅通知 ret sle_uuid_client_register_notify(conn_id, char_handle);订阅通知这个动作很关键。Server能不能主动给Client发数据取决于Client是否订阅了该特征值的通知。很多新手发现Server调了notify但Client收不到八成是漏了register_notify这一步。订阅成功之后Server的notify消息才会被协议栈路由到Client的数据回调里。再补充一下几种写法和通知的区别方便对照操作语义Client APIServer回调写请求带响应Server需回包write有回调需send_response写命令无响应只发不回复write_no_response有回调不用回复通知Server主动推送无需回应订阅后收notifynotify调用即触发4.5 Client侧的状态机管理实际项目里Client端的逻辑复杂度远高于Server端。从扫描到连接、发现服务、订阅通知、数据交互任何一个环节出错都要能及时暴露。我建议Client侧一定维护一个状态机typedef enum { SLE_STATE_IDLE, SLE_STATE_SCANNING, SLE_STATE_CONNECTING, SLE_STATE_DISCOVERING, SLE_STATE_READY, SLE_STATE_DATA_EXCHANGE, SLE_STATE_DISCONNECTED, } sle_client_state_t;每个状态只处理该状态下允许的事件不合法的转换直接打印日志并丢弃。这样做的最大好处是当某个环节异常时你可以通过状态机当前所处的位置快速判断问题出在哪个阶段。比如卡在DISCOVERING就说明连接没问题、服务发现有问题卡在CONNECTING就要去看连接参数和安全配置。没有状态机所有事件都平铺在一块排查一个问题要翻几百行日志才能确定到底是哪一步没走通。5. 配对安全与连接参数从能用到可靠5.1 配对模式的选择SLE协议栈支持的安全模式包括Just Works免交互配对和Passkey Entry密码配对两种。Just Works适合对安全性要求不高的设备如玩具遥控器、简单传感器流程简单但容易被中间人攻击Passkey Entry需要显示屏或输入设备配合交互上复杂一些但安全性高很多。选配对模式要看具体场景。如果是室内固定设备比如智能插座和手机App配对推荐Passkey Entry如果是设备之间自动组网比如大量传感器节点上报数据Just Works就够了省去每次配对的交互成本。星闪SDK一般会在安全配置里设置配对模式有的还支持I/O能力组合决定配对时采用哪种认证方式这个需要仔细看厂商SDK的安全模块文档。5.2 密钥管理与加密连接配对成功后协议栈会生成密钥当连接断开再重连时可以考虑使用长期密钥LTK进行快速加密避免每次重连都重新配对。这个机制在BLE里非常成熟星闪SLE也类似但不同厂商SDK的API命名和存储方式可能不一样。我遇到过的坑是重连时加密老是失败查到最后是本地保存的密钥跟对端不一致了。原因是其中一端恢复了出厂设置、密钥被清掉另一端还保留着旧密钥。所以每次重连失败时先确认两端密钥状态是否同步必要时主动触发一次重新配对。5.3 连接稳定的几个参数细节影响连接稳定性的因素很多除了连接间隔还有扫描窗口、广播间隔、看门狗超时时间等。我整理了一个比较常用的初始参数参考表实际根据你的业务场景微调参数推荐初始值说明连接间隔8~16交互单位通常为1.25ms数字越小越流畅连接延迟0~4越大越省电但响应变慢看门狗超时400~1000超过此时间未收到包判为连接超时广播间隔100~200单位通常为0.625ms太短费电太长难被发现扫描窗口等于扫描间隔连续扫描时两者相等最稳这里重点说看门狗超时。很多时候连上就断开并不是因为参数配错而是看门狗超时设置得太小导致稍微卡顿一下就被协议栈判定为连接超时。做低功耗场景的人要注意如果设备会深睡唤醒后的前几个包可能因为时钟同步问题延迟到达这时候超时时间设小了就会误判断开。6. 调试技巧、错误码排查与常见坑位6.1 日志打点的正确姿势嵌入式的串口日志是调试的第一手段。我习惯在状态机切换入口打印一条总日志然后每个API调用返回后立刻检查错误码。日志格式用带时间戳的方便对比两端时序// 每个API调用统一格式 errno sle_connect(conn_param); printf([%u] sle_connect - 0x%x\r\n, (unsigned int)osal_get_tick(), errno);实际调试时发现很多问题靠日志就能定位。比如Client发了一条写请求Server日志什么都没打——那就说明数据根本没到Server如果Server日志打了说明数据链路是通的问题是出在业务逻辑或者回包处理上。这里有个经验不只是打印错误还要打印正常流程的关键节点比如进入扫描发现服务订阅成功这样你才能知道程序走到哪一步了。6.2 常见错误码快速对照表不同厂商SDK的错误码定义会有差异但大体上都会包含以下几类。我整理了一个排查方向对照表遇到错误码先按这个方向查错误码范围常见含义一般排查方向0x0101~0x0104参数无效检查API入参、枚举值、UUID类型0x0201~0x0205连接相关异常检查连接句柄、连接状态、地址类型0x0301~0x0306句柄无效检查服务句柄/特征值句柄是否有效0x0401~0x0403权限不足检查properties/permissions配置0x0501~0x0503内存不足检查堆配置、大数组、内存泄漏0x0601~0x0604安全/配对失败检查配对模式、密钥存储0xF001~0xF010供应商自定义看SDK手册或串口日志注意这个表给的是排查思路不是标准答案。真遇到错误码第一件事永远是查你手上SDK的errcode定义头文件别靠猜。6.3 抓包工具别靠猜如果条件允许用支持星闪的协议分析仪或厂商提供的Sniffer抓空口报文这是最直接定位数据到底有没有发出/收到的方式。没有抓包设备时可以在Server和Client各打一条带打印的收发日志对比双方的时间戳基本也能判断是哪一端的问题。我遇到过的一个经典案例Client显示数据已发出Server显示数据已收到但业务上处理结果不对。后来抓包发现数据在空口传输时因为重传机制被折叠成了一帧导致Server收到的是合并后的数据按单帧解析就错了。这个问题只靠两端日志很难发现因为两端各自都是对的只有站在中间看链路才看得出问题。所以强烈建议有条件就上抓包工具没有条件也要在代码里预留两端的收发计数定期比对总数是否一致。6.4 经典故障排查案例案例一Client扫描不到Server大概率是广播参数没生效或者Server在创建服务之前就把广播开了。检查广播间隔、检查服务UUID是否已成功注册另外注意广播数据长度有没有超限。还有一种很隐蔽的情况是Server端协议栈还没完全使能就去开广播API返回了错误但代码没做错误处理看起来好像广播开了实际上没有。案例二连接后立刻断开先看连接参数里的看门狗超时是否太小再看有没有配对要求不匹配。比如Server要求Passkey配对Client配成了Just Works协议栈会在加密协商阶段直接断开连接。这种问题日志里一般能看到安全相关的错误码但如果你没开安全模块的调试日志很容易被当成普通断线处理。案例三能连接但数据发不通依次检查三件事特征值属性位/权限位是否匹配、通知订阅是否完成、数据长度是否超出协议栈最大传输单元。第三点很容易被忽略——SPP串口通信习惯了发几百字节到了星闪SLE这里单包数据长度是有限的超长数据必须自己分包发送。如果文档里写了最大MTU是某个值代码里就要做好数据分片一般建议分片长度留个安全余量不要顶着上限发。案例四断开重连不成功上面说过Server断开后要重启广播Client断开后要从状态机的IDLE重新走一遍不能直接跳到连接。现实中有不少开发者在重连逻辑里加了个延时5秒然后让Client直接调连接接口结果Server广播还没起来连接自然失败。正确做法是Client在重连前先做一次扫描确保Server在线、广播可发现再走连接流程。最后再分享一个小技巧在源码里维护一个测试专用的回环模式——客户端发什么服务端就原样回什么。两端各自打时间戳测往返时延RTT既能验证链路通断又能顺带测出连接参数配置是否合理。跑通了回环模式再去实现业务逻辑整个开发节奏会稳很多。星闪SLE_UUID_Server和Client的配对通信其实不难难的是那些藏在API时序和参数配置里的细节把这些细节理清楚了后面的业务开发就会顺畅很多。
返回列表