
这两年国产BLE芯片势头很猛富芮坤FR801xH算是我接触下来性价比比较突出的一颗。它主打低功耗、低成本Cortex-M0内核但很多人拿到这颗芯片的SDK后第一反应是“这代码怎么跟STM32那套完全不一样”“协议栈到底怎么启动的”“为什么我改了一堆东西手机就是收不到数据”。这篇文章我就把这颗芯片的蓝牙协议栈启动流程以及用notify把温度数据主动上传到手机这条完整链路一次讲透。适合刚接手FR801xH项目、或者从其他蓝牙芯片平台转过来的开发朋友参考也适合正在做体温计、环境温度监测、智能穿戴这类产品的工程师拿来直接抄作业。1. 项目概述与核心需求拆解1.1 这颗芯片到底是什么定位FR801xH是富芮坤面向IoT市场推出的一系列BLE SoC片上集成了Cortex-M0内核、BLE射频收发器和丰富的外设。我最早接触它是因为一个体温贴项目当时对比了好几颗国产BLE芯片最终选它的理由很直接价格友好BLE协议栈是芯片原厂直接提供的不需要自己移植的话能省掉大量时间和精力而且它的封装和外围电路都比较简单两颗电容一个晶振就能跑起来。这颗芯片的内部Flash和RAM空间不算大所以在做应用层开发时需要有点“省着用”的意识不像在Linux或Android上写代码那样肆无忌惮。但也正因为资源有限它的功耗控制做得相当不错特别适合用纽扣电池供电的传感器节点设备。从产品定位来看FR801xH主要面向的是一类“周期性采集数据、低功耗上传”的场景比如体温计、温湿度计、防丢器、HID遥控器、ibeacon信标等这些场景对成本敏感对功耗敏感对数据吞吐率反而要求不高。1.2 启动流程和notify为什么这两个点最关键我记得我刚拿到这颗芯片的SDK时第一件事就是找main函数在哪里。找到之后发现它和STM32的HAL库风格完全不一样main函数里没有一大堆外设初始化代码只调用了几个API然后就是协议栈的事。这个门槛让很多从STM32或ESP32转过来的开发者一头雾水所以“启动流程”就成了第一个必须打通的知识点。启动流程搞明白之后第二个核心需求就是把传感器采集到的温度数据发给手机。BLE GATT规范里提供了多种数据交互方式但最适合“设备主动上传温度”这种场景的就是notify。它属于服务端主动推送数据给客户端不需要手机每次发请求询问只要手机打开了通知开关设备就能按照自己的节奏把温度数据一遍遍传过去。这两个点一旦打通一个BLE温度计的核心功能就完成了80%剩下的工作量不过是改改数据格式、加加滤波算法、做做低功耗优化罢了。2. 蓝牙协议栈启动流程拆解从复位到事件循环2.1 启动流程整体脉络FR801xH的启动流程和STM32的启动流程有相似之处但也有本质区别。STM32的启动流程基本可以概括为复位向量 - 启动文件 - SystemInit时钟配置 - 外设初始化 - main函数 - while(1)主循环。FR801xH大体上也遵循这个框架只不过在“外设初始化”和“主循环”之间插入了整个蓝牙协议栈的初始化和事件调度机制。我用文字把这个流程捋一遍芯片上电复位后首先是启动文件完成堆栈指针和向量表的设置然后跳转到C语言的入口类似SystemInit和main。在main函数里应用层需要先完成硬件时钟、GPIO、UART等基础外设的初始化紧接着调用协议栈初始化接口。协议栈初始化时会完成BLE控制器的配置、GAP角色的注册、GATT服务的注册以及本地设备地址、广播参数、连接参数等一堆内容的装载。这些动作完成后并不能立刻收发数据还需要进一个循环让协议栈的任务调度跑起来这个循环负责处理所有蓝牙事件和应用层定时事件整个设备才算真正“活”了。如果之前写过STM32裸机程序可以把这段流程类比成协议栈初始化相当于启动了一个RTOS内核主循环相当于RTOS的任务调度器而蓝牙的各种回调事件相当于一个个中断服务函数。理解了这层映射关系再回头看SDK里的代码就不会觉得乱了。2.2 硬件平台与工程结构FR801xH的SDK官方推荐使用Keil MDK开发工程结构大致分为这几个部分启动文件与系统文件、协议栈库文件、驱动文件、应用层文件。我第一次打开SDK时最需要留意的几个文件其实就是启动流程的关键节点。启动文件负责最底层的向量表和复位处理这个基本不需要去动系统文件里通常会有时钟初始化相关的内容FR801xH内部有RC震荡器也支持外部晶振如果工程里选用了外部32MHz晶振那这里会配置PLL锁相环把系统时钟提上来这个环节出问题会导致芯片直接跑飞连调试器都不一定连得上驱动层文件则是对GPIO、UART、I2C、ADC等外设的封装应用层在初始化时直接调用这些接口即可最后是协议栈相关文件正式项目里通常以库或者封装代码的形式提供应用层只需要调用预留出来的接口函数不需要也不能去修改协议栈内部实现。这里我建议新人在动手改代码之前先把工程目录结构完整浏览一遍脑海中建立起“哪段代码属于应用层、哪段代码属于协议栈封装、哪段代码属于硬件驱动”的边界感。否则后面排查问题时很容易在应用层代码里找协议栈的bug或者在协议栈回调里改业务逻辑越改越乱。2.3 协议栈初始化核心步骤协议栈初始化的核心步骤我可以把它归纳成五个关键环节。第一步是基础硬件初始化包括时钟、电源、GPIO等这是所有后续操作的前提官方SDK里通常会在main函数开头调用一个硬件初始化函数这里最容易犯的错误是修改了引脚配置导致后续I2C或UART通信异常而且这种问题往往要到很后面才暴露。第二步是协议栈配置调用协议栈初始化接口时需要传入一个配置结构体里面包含本地设备名称、设备MAC地址、广播参数、连接参数等。广播参数里的广播间隔、广播数据包内容以及连接参数里的最小/最大连接间隔、从机延迟、超时时间都在这份配置里定义。设计产品时这些参数要根据实际场景调整比如温度计这种低频数据传输场景连接间隔可以设置得大一些能有效降低功耗。第三步是GATT服务注册。如果你要在设备上暴露一个温度服务就需要在这里把服务的UUID、特征值的UUID和属性都定义好调用协议栈的注册接口把它加进去。手机连接设备后看到的服务列表和特征值列表就是这一步注册的内容。很多人在这一步漏掉了特征的属性配置比如没有把特征属性设为“可通知”导致后面notify怎么都发不出去。第四步是注册应用回调。协议栈是事件驱动的设备被连接、设备被断开、收到写请求、手机改动了CCCD描述符等事件都会通过回调函数通知到应用层。这些回调函数就是应用层代码和协议栈之间的桥梁注册回调这一步如果漏了后面手机连上了设备应用层也不会得到任何通知看起来就像“单片机死机”一样。第五步是开启广播并进入主循环。开启广播意味着设备开始对外发送广播包手机此时就可以扫描到这个设备。主循环则负责调度协议栈的各种任务同时为应用层提供周期性的软件定时器事件。主循环一旦跑起来整个设备就进入了运行状态剩下的所有业务逻辑包括温度采集和notify上报都要依托这个主循环和回调机制来实现。2.4 初始化参数的设置心得初始化参数看似简单实则深坑不少。我举个例子广播间隔的设置。广播间隔越长设备功耗越低但手机发现设备的速度也就越慢广播间隔越短手机秒发现设备但功耗会明显增加。对于温度计这种需要“人拿着手机靠近设备”的使用场景广播间隔设置成100ms左右比较合适如果是长时间在户外部署、周期上报数据的传感器节点广播间隔可以放到500ms以上。连接参数的设置更需要仔细斟酌。连接间隔决定了主设备和从设备之间数据交互的频繁程度它直接影响两点一是数据上报的实时性二是整机功耗。温度数据对实时性要求不高我的做法是把连接间隔设到30ms以上甚至更宽松配合从机延迟让设备在没有数据可发时尽量多睡一会儿。很多开发者喜欢把连接间隔设得很短觉得这样数据传输“更稳”实际上在低速传感器场景下这只会白白浪费电池电量。另外有一类参数最容易被人忽略那就是MTU大小。BLE 4.2以后支持通过MTU协商扩大单包数据长度默认的ATT MTU是23字节扣掉协议头实际用户数据最多只能传20字节。如果温度数据格式比较复杂除了温度值还想附带电量、时间戳、设备状态等20字节就会很局促。好在FR801xH的协议栈支持MTU协商应用层在收到连接建立事件后可以主动发起MTU协商请求把MTU扩到247字节。MTU协商对notify发送也有影响这一点我在后面讲notify实现时还会专门提到。3. notify实现温度数据主动上传3.1 notify是什么为什么温度上报要用notifyBLE GATT规范里设备和手机之间的数据交互有四种基本方式read、write、notify、indicate。read是手机主动来读设备的数据write是手机往设备写数据这两种都是“手机发起、设备被动响应”的模式。notify和indicate则是反过来的设备在数据准备好后主动向手机推送区别在于notify不需要手机确认发出去就不管了indicate要求手机收到后必须回复确认如果没有确认设备不能发送下一条。这四种方式里最适合温度主动上报的就是notify。温度监测的场景是设备每隔一段时间采集一次温度然后推送给手机。如果改成手机主动read要么手机需要不停轮询费电费力而且读到的可能还是旧数据如果改成indicate每发一笔数据都要等手机确认速度慢不说在信号不好时还会造成数据堆积。notify就很好解决了这些问题设备想发就发手机不确认也无所谓下一条数据来了直接覆盖就行。有个细节值得强调手机端要收到notify数据前提是“对应特征的CCCD描述符”已经被使能。CCCD的全称是Client Characteristic Configuration Descriptor客户端特征配置描述符它的标准UUID是0x2902挂在每个可通知的特征下面。手机在打开通知开关时实际上就是向设备的这个描述符写入了一个使能值。所以在实现notify功能时应用层必须正确处理CCCD的写入事件否则即使用户在手机端打开了通知开关设备端也不会真正进入“允许上报”的状态。3.2 硬件层面温度数据从哪来软件层面聊notify之前先说清楚温度数据本身是怎么来的。FR801xH本身不带温度传感器所以温度数据的源头在外部。根据产品成本和精度要求的不同通常有两种方案。方案一也是最常见的低成本方案用NTC热敏电阻加分压电阻接到FR801xH的ADC引脚上通过测量电压反推电阻值再用NTC的B值公式转换成温度。这种方案的物料成本极低一个NTC几分钱到几毛钱精度取决于NTC本身的误差和ADC参考电压的稳定性。我早期做体温贴时用过10k NTC、B值3950的配置经过单点校准后在25到45摄氏度范围内的误差可以控制在±0.2摄氏度左右对消费级体温产品来说完全够用了。方案二用I2C接口的数字温度传感器比如SHT30、HTS221这类芯片。数字传感器直接输出数字化温度值不用自己做ADC换算和B值公式计算精度和一致性都要好很多但成本会贵一些。如果产品面向医疗级场景这个成本通常是可以接受的。除了硬件方案温度数据的滤波和校准也直接影响notify出去的数据质量。不过我个人的经验是滤波处理要适度尤其是基于滑动窗口的均值滤波窗口长度不要超过5个采样点。原因很简单温度本身是缓变量窗口太长除了让代码复杂化并不会带来明显改善但如果做的是体温计人体温度变化本来就慢滤波窗口稍长倒是无所谓。3.3 软件层面特征值定义与CCCD处理服务特征的定义是notify实现的关键前置步骤。我通常用一个结构体数组来定义所有服务和特征每个特征需要指定UUID、属性、访问权限和描述符。温度服务一般定义一个特征就够了UUID可以用16位的标准自定义UUID比如0xFF01也可以用128位的厂商自定义UUID取决于产品是否需要通过某些认证或者兼容特定App。特征属性里需要设置NOTIFY标志同时为这个特征添加一个CCCD描述符。属性设置不对的话手机端打开通知开关时协议栈会直接返回错误或者手机App上的开关是灰色的根本点不了。我当时第一次调试时就是因为在初始化特征时漏掉了NOTIFY属性导致nRF Connect里那个通知开关怎么点都点不亮折腾了半天才发现是属性没写全。CCCD处理逻辑在协议栈的回调函数中。当手机端打开了通知开关协议栈会触发一个写回调写入值指向特征对应的CCCD描述符。应用层需要识别出这次写入是写到了哪个特征并记录下使能状态。这个使能状态就是后面决定是否允许notify发送的标志位。手机端关闭通知时也会触发类似的写入事件应用层需要同步清除这个标志。如果不做这个判断一个很常见的现象是设备发送notify接口调用返回成功但手机端完全没有收到数据原因就是CCCD没有被使能协议栈认为客户端没有订阅这个特征的通知。3.4 主动上报的实现流程前面几个环节都打通之后主动上报的实现流程就非常清晰了。我把在FR801xH上实现温度notify上报的代码逻辑整理成了下面几个步骤。第一步在温度采样周期到来时读取ADC采样值或者通过I2C读取数字温度传感器数据经过换算和滤波后得到一个温度值。第二步把温度值按照自定义的协议格式组包常见格式是包头、长度、温度值高字节、温度值低字节、校验和温度值乘以100转成定点数再拆成高低字节可以保留两位小数精度。第三步检查CCCD使能标志如果手机没有打开通知开关这一步直接返回不进入发送流程。第四步调用协议栈提供的notify发送接口把数据推给手机。这里有一个容易踩坑的小细节notify发送接口的调用原则上应该在连接事件上下文之外调用。很多协议栈的API文档都会特别注明notify发送接口不能在某一些回调函数比如GATT事件回调内部直接调用否则可能造成重入问题。我的做法是在应用层维护一个待发送数据缓冲区当温度数据准备好之后只是把数据塞进缓冲区并置一个标志位然后等到主循环或专用的定时器事件里再去执行真正的发送动作。这样既避免了对协议栈内部态的影响也让代码逻辑更清晰。伪代码示例大致是这样的void ble_notify_send_temperature(int16_t temp) { uint8_t buf[4]; uint16_t temp_val (uint16_t)(temp 1000); if (!notify_enabled) { return; } buf[0] 0xAA; buf[1] 0x01; buf[2] temp_val 8; buf[3] temp_val 0xFF; ble_gatts_notify(conn_handle, temp_handle, buf, sizeof(buf), NULL); }这里的实现逻辑很简单但有一个地方要特别确认temp_handle是温度特征在注册时返回的句柄值这个值必须在特征注册时保存下来后面notify发送时才能正确指定发给哪个特征。如果你在代码里写死了句柄编号而实际注册顺序变了那notify数据就会发错特征甚至直接失败。3.5 实测效果与协议分析我实际测试时的流程是用手机上的nRF Connect连接设备这个App在调试BLE时非常方便。连接成功后找到温度服务和对应的温度特征打开通知开关然后观察日志窗口就会看到设备每隔一秒上报一次温度数据数据格式就是我在代码里定义的那个包头加两字节温度值加校验的结构。实测下来需要注意的一个重要现象是连接间隔对实时性的影响。如果配置的连接间隔较大比如50ms以上那么从应用层调用notify发送接口到手机真的收到这一包数据可能会有几十毫秒的延迟。对于温度这种缓慢变化的物理量这个延迟完全无感。但如果做的是游戏手柄按键这类需要低延迟的场景就显然不能用这套参数了。还有一个值得加深理解的知识点是notify发送的并不是无限长的单次发送的数据量受限于当前MTU。在默认MTU 23字节的情况下每包最多20字节用户数据。如果温度数据格式较长比如一台设备同时上报温度、湿度、电量、信号强度20字节就装不下了这时需要把数据拆成多包发送或者先协商MTU再发送。我的建议是在连接建立后主动发起MTU协商请求把MTU协商到247字节这样绝大多数业务数据包都能一包发完省去组包分包的心智负担。4. 常见问题与排查技巧实录4.1 启动流程类问题的定位思路启动流程相关的问题我总结了几个高频出现的情况列成一张速查表方便大家对照排查。现象可能原因排查方向芯片上电后无任何反应时钟配置错误、复位引脚异常先查晶振是否起振、电源是否正常程序卡死在协议栈初始化阶段外设初始化占用了协议栈需要的硬件资源检查UART/GPIO引脚是否与协议栈配置冲突设备无法广播广播参数配置异常、广播数据过长检查广播间隔设置和广播数据包长度广播能看到但连接秒断连接参数不合理、从机延迟配置过大检查最小/最大连接间隔、超时时间连接成功但手机读不到服务GATT服务注册失败或注册顺序错误检查服务注册接口的返回值启动流程问题最大的麻烦在于出现问题的位置往往不是根因所在而是在后续某个环节爆发。我的排查经验是先硬件后软件先把时钟、电源、复位这些基础项确认无误再进入协议栈和GATT层面的排查。协议栈初始化函数的返回值一定要检查SDK里每个初始化接口基本都有返回状态如果某个返回值不对不要硬着头皮往下走先把那个异常解决掉。4.2 notify发送失败与数据到不了手机notify发送失败是BLE开发中复现概率较高的问题这里我单独列几个典型情况。最常见的情况是CCCD没有使能就调用发送接口。我在前面已经强调过手机打开通知开关的过程就是向CCCD写使能值的过程。如果应用层没有正确记录CCCD的写入状态就会出现在协议栈层面发送成功了但手机端一条数据都收不到的情况。这个问题的排查方法是添加一个断点看设备在收到“打开通知”的写入事件时应用层代码有没有走到对应的处理逻辑里。第二种常见情况是在不合适的上下文里调用发送接口。有些协议栈不允许在GATT回调内部直接调用notify接口如果初学时不了解这个限制就可能在回调里收到手机的某条写入后再直接调用一次notify发送结果导致协议栈状态错乱表现为一次发送成功、一次发送失败交替出现。我建议把所有notify发送动作都放到主循环或独立定时器事件中处理通过标志位来触发可以绕过这个坑。第三种情况是发送缓冲区满了。BLE协议栈内部为每个连接维护了一定数量的发件缓冲区如果短时间内发送频率过快或者MTU协商后数据包长度过大缓冲区就可能被占满发送接口会返回busy或buffer full之类的错误码。这类问题的排查要看发送接口的返回值如果出现缓冲区类错误就需要在应用层加上发送失败重试机制。重试的方式不能是死循环硬等而是记录一个待发标志等到下一个连接事件或定时器事件里重新尝试否则容易把协议栈拖死。4.3 温度数据精度与干扰的排查温度数据不准的问题在NTC方案中特别常见。这里的原因通常不是蓝牙传输丢了数据而在于模拟链路本身。ADC参考电压不稳、NTC引脚上叠加了数字噪声、PCB走线把射频信号干扰到ADC采样引脚都有可能导致温度数值跳动。排查这个问题有一个很有效的办法把设备放在恒温环境中持续采集温度并通过notify上报然后在手机端记录半小时的数据观察数值的波动范围。如果波动范围超过预期就可以初步判断是模拟链路的问题而不是蓝牙链路的问题。接下来可以做一组对照实验断开NTC用精密电阻箱代替NTC接入ADC引脚如果波动消失说明问题出在NTC或者NTC引脚的干扰上如果波动依然存在问题出在ADC参考电压或者PCB布局。ADC滤波方面我习惯在采样端增加一个RC低通滤波器截止频率一般做到几十赫兹。软件上做均值滤波时我建议对连续5个采样点取平均既不会让响应太迟钝又能把大部分随机噪声滤掉。如果做体温类产品NTC的B值误差和分压电阻的精度也要考虑进校准流程。4.4 低功耗场景下notify上报与睡眠的取舍最后说一个工程项目里一定躲不开的话题低功耗。FR801xH主打低功耗但notify上报本身是“设备主动干活”的过程这跟睡眠省电天然存在矛盾。我早期做低功耗优化时踩过一个很典型的坑为了让功耗更低把设备的主循环运行频率降得很低结果温度上报周期的准确性和notify发送的实时性都受到了影响。合理的做法是把“采集周期”和“上报策略”解耦。比如传感器每2秒采集一次温度但不需要每次都上报给手机可以做一个判断只有当温度变化超过0.5摄氏度或者距离上次上报超过10秒时才通过网络连系启用notify上报。这样做的功耗收益非常可观因为蓝牙不但发送耗电维持连接本身更需要持续耗电。如果能利用从机延迟特性让设备在两次连接事件之间进入休眠模式功耗还能进一步下降。在这个环节也提醒一句使用FR801xH这类芯片时一定要仔细看SDK提供的低功耗接口文档不同版本的SDK支持的睡眠模式和唤醒机制略有差异。该进入睡眠的时机不对或唤醒源配置错误都可能导致设备睡死或者功耗反而上升。5. 实际项目中的避坑清单与扩展建议5.1 开发期避坑清单把前面提到的坑汇总成一张自检清单每次改完代码都过一遍可以省去大量调试时间检查项检查内容时钟与电源外部晶振是否起振PLL配置是否正确电源引脚去耦电容是否齐全初始化顺序硬件初始化、协议栈初始化、GATT注册、回调注册的顺序是否和SDK示例一致特属性检查温度特征是否设置了NOTIFY属性是否有正确的CCCD描述符CCCD状态记录打开通知/关闭通知的写入事件是否被正确解析和记录发送上下文notify发送是否在安全的上下文调用是否避免了在GATT回调内直接发送发送返回值每次发送后是否检查了返回值是否有重试机制MTU协商连接建立后是否发起了MTU协商温度数据包是否在单包可承载范围内滤波与校准ADC采样是否有硬件滤波或软件滤波是否做过一点或多点校准这个清单看起来琐碎但每一条背后都有真实的调试代价。举个例子MTU协商这一项如果不主动发起默认每包只能传20字节一旦温度数据格式超过20字节手机端收到的数据就会莫名其妙的“丢尾巴”实际上是你的数据根本没有发出去因为协议栈会拒绝发送超长包。这种问题不看协议栈返回值根本发现不了。5.2 从温度上报扩展到更多产品功能基于notify上报温度这套框架可以很自然地扩展出一系列功能。比如增加一个电量特征用read方式供手机读取当前剩余电量再增加一个设备信息服务上报固件版本号、硬件版本号、序列号等基本标识信息。这些在FR801xH的SDK里都是现成能力只需要在服务注册阶段多添加几个特征即可。另一个常见扩展是OTA固件升级。OTA升级通常基于write和notify组合实现手机通过write命令把固件分包写入设备每收到一包设备通过notify回一个确认包形成类似“乒乓”的流程。从温度上报的角度看虽然业务逻辑完全不同但底层的GATT交互机制是一样的所以把notify机制理解透对后续做OTA、做设备远程配置、做数据透传都有直接帮助。连接参数更新的应用可能也比较多。当产品需要从“低速省电模式”切换到“高速数据传输模式”时可以在应用层主动发起连接参数更新请求把连接间隔调整到更小的值获得更低的传输延迟。FR801xH的协议栈支持这种动态更新更新时机选在需要大量传输数据之前传完再切回省电模式体验和功耗都可以兼顾。一些开发后的心里话写了这么多再说点实际感受。FR801xH这颗芯片的SDK跟很多主流BLE芯片的SDK在风格上有差异尤其是协议栈初始化这件事没有图形化配置工具很多内容靠手动填写配置结构体。我最早调这颗芯片时确实慢了好几天主要问题就出在启动流程没搞透和CCCD处理没做好。一旦把这两块打通后面的开发基本都是顺水推舟了。最后再分享一个小技巧调试notify发送时如果没有现成的手机App可以用nRF Connect的日志窗口来数包率和包间隔这比自己在代码里加日志要直观得多。如果发现notify发送频率和预期不符先别急着查代码先在nRF Connect里确认手机端确实打开通知开关了再检查设备端是不是在循环里抢发了多次数据。这个过程听起来简单但在实际项目里能一下子排除掉一半的干扰项。