FR801xH低功耗蓝牙协议栈启动与Notify温度上报实战
做低功耗蓝牙设备这几年我手里过过的BLE芯片不算少富芮坤FR801xH是让我印象比较深的一颗——价格低、外设全、协议栈也算稳。最近一个项目需要把温度数据实时传上手机我完整走了一遍FR801xH的蓝牙协议栈启动流程再用notify机制把温度主动推给手机。这篇就把从main函数开始到协议栈跑起来再到notify数据上行的整条链路复盘一遍顺便把踩过的坑和调参经验都写出来。如果你正在调FR801xH或者准备用这颗芯片做传感器数据上传这篇应该能帮你少走不少弯路。1. 整体设计与方案选型1.1 为什么选FR801xH这颗芯片先说选型。当时手头的需求很明确做一个便携式温度采集节点电池供电手机端能实时看到温度曲线。评估了几款芯片之后最终定在富芮坤FR801xH上。FR801xH是富芮坤面向低功耗蓝牙应用推出的SoC内核是Cortex-M0主频可以跑到64MHz。BLE协议栈支持到5.12Mbps、长包、CODED PHY这些特性都有。关键是它的资源对于一个温度采集类应用来说完全够用内置12位ADC、UART、SPI、I2C、PWM、多个定时器不用外挂太多东西。价格上也比同定位的进口芯片有优势批量走起来成本控制很明显。和常见的nRF52系列比FR801xH的生态和社区资料确实少一些但官方SDK里例程还是比较完整的外设驱动、BLE从机demo都直接给了。刚开始上手时可能需要花点时间适应它的工程结构和协议栈封装方式但跑通之后就会发现它的开发体验并没有想象中那么难。另外FR801xH的待机功耗控制得不错。对电池供电的传感器节点来说功耗是硬指标。我记得官方手册上标注的睡眠电流可以到微安级实际使用中跟外围电路的静态功耗、LDO的自身功耗都有关系但整体在可接受范围内。1.2 温度采集方案NTCADC还是数字传感器温度采集本身有两个路线一种是直接用数字温度传感器比如DS18B20、SHT30、BMP280这类I2C或单总线读出温度值精度高、免校准代码也简单。另一种是用NTC热敏电阻加ADC采样自己换算温度。我最终选了NTC加FR801xH内置ADC的方式。原因有三一是成本一颗高精度数字温度传感器可能是NTC价格的几倍到十几倍做量产时这个差距就很明显了。二是FR801xH本身带了12位ADC不用白不用。三是NTC的响应速度和量程在这个场景下完全够用体温或者室温监测都不在话下。NTC方案的代价是需要软件做换算和校准。这其实就是个查表或套公式的活并不复杂后面第三章我会把具体换算过程写出来。选NTC还有一个细节要注意NTC的B值和R25阻值一定要买一致性好的批次否则每颗传感器都要单独校准量产时很痛苦。1.3 为什么用notify而不是Read或IndicateBLE的数据上行方式有好几种做设计的时候需要先想清楚用哪种不然写到一半再换方案会很被动。Read方式是最基础的手机端主动发起读请求从机在回调里返回特征值。这种方式实时性差如果手机要拿到最新的温度就必须不停轮询不仅费电还会增加空中包的数量在低功耗场景下非常不划算。Indicate方式是从机主动推数据但需要手机端回复确认ACK每发一条数据都要等对方确认了才能发下一条可靠性高但吞吐量低。适合传输关键事件比如报警、开关状态不适合连续的传感器数据流。Notify方式是从机主动往手机推数据不需要手机回复确认发送效率高实时性好。虽然理论上存在丢包的可能但BLE链路本身有重传机制实际使用中丢包率很低。对温度这种变化缓慢、数据量又小的信号来说notify是再合适不过的选择。这里要提前说清楚一个机制notify不是从机想发就能发的。BLE规定客户端必须先往特征值的CCCD描述符里写入0x0001完成订阅服务端才能给它发notify。不理解这条后面调notify的时候很容易一头雾水。2. FR801xH蓝牙协议栈启动流程拆解2.1 工程结构与初始化前的准备FR801xH的SDK工程典型的目录结构会包含main.c、app.c、platform.c、user_config.h这几个关键文件。刚拿到SDK时不建议直接闷头改代码先花半天把工程结构和启动入口摸清楚后面调试会顺利很多。main.c是整个程序的入口里面做的事情很纯粹初始化硬件、初始化协议栈、启动广播、进入主循环。但就是这几步顺序有讲究。比如协议栈初始化必须在系统时钟初始化之后GPIO的初始化也要在协议栈跑起来之前完成否则可能出现外设配置被覆盖或者中断配置冲突的问题。在写代码之前还需要确认硬件上的一些基础配置比如晶振频率、调试串口号、ADC引脚的GPIO复用。FR801xH的引脚复用寄存器需要仔细看数据手册我就在这上面吃过亏一个引脚既想用作UART又想用作ADC复用关系没配对导致串口和ADC都工作异常。2.2 main()函数里的启动序列FR801xH的协议栈启动流程我用一个简化版的main函数来展示int main(void) { system_init(); // 1. 时钟、电源、系统外设初始化 gpio_init(); // 2. 板级GPIO初始化按键、LED uart_init(); // 3. 调试串口初始化建议最先打开 adc_init(); // 4. 温度采集使用的ADC通道初始化 ble_stack_init(); // 5. 协议栈初始化核心步骤 ble_gap_device_name_set(FR801xH_Temp); // 6. 设置设备名 gap_adv_param_set(adv_param); // 7. 配置广播参数 gap_adv_start(); // 8. 启动广播 while (1) { app_main_loop(); // 9. 主循环处理业务逻辑 } }注意看顺序先底层硬件再协议栈再GAP配置最后才是广播启动。这个顺序几乎是所有BLE芯片的通用套路FR801xH也不例外。system_init()是第一步。它会配置系统时钟、电源模式、总线时钟等基础资源。没有这一步后面所有外设和协议栈都是空中楼阁。uart_init()我建议放在早期因为后面每一步初始化的日志输出都靠它有问题能第一时间在串口里看到。ble_stack_init()是整个流程里的重头戏。FR801xH的协议栈是以库的形式提供的应用层通过头文件调用接口。协议栈初始化会完成链路层、主机层、安全管理器等模块的配置同时注册应用层的回调函数。这个初始化做完之后芯片的BLE协议栈才是真正“活”的状态。接下来是GAP层的配置。设备名在这里设置广播数据也在这一步准备好。然后调用广播参数设置接口把广播间隔、广播类型、通道映射填好。最后启动广播。从协议栈初始化到广播真正发出去整个过程是毫秒级的串口日志里能看到每一步的关键打印。2.3 GATT服务注册的时机这里有一个很多人容易忽略的细节在启动广播之前最好把GATT服务先注册好。因为手机扫描到设备后会立刻发起连接并读取服务列表。如果广播启动了但服务还没注册完手机连上来可能读到不完整的GATT表就会出现“能连上但找不到服务”的怪现象。GATT服务注册本身也是异步流程。调用服务注册接口之后SDK会返回一个操作句柄等协议栈完成服务端数据库的创建会通过回调事件通知应用层。所以严格来说广播启动的最佳时机是在服务注册完成事件之后而不是简单地按顺序写完就算完事。我在实际工程里的做法是维护一个初始化状态机只有GAP配置完成、GATT服务注册完成、广播参数配置完成这三个条件都满足了才调用广播启动接口。这样尽管初始化代码看起来比顺序调用复杂一点但稳定性和可维护性都好很多。2.4 协议栈事件回调机制FR801xH的协议栈和应用层之间是事件驱动的。协议栈在运行过程中会产生各种事件比如广播结束、有设备连接、连接断开、收到写请求、CCCD被修改等。这些事件会通过回调函数通知到应用层。常见的回调有GAP事件回调和ATT事件回调。GAP事件回调处理连接、断开、广播完成这类链路状态事件ATT事件回调处理属性相关的操作包括读写请求、CCCD写入、notify发送结果等。连接事件处理中有个关键的细节在回调里一定要把连接句柄conn_idx保存下来后面notify发送需要用到这个句柄。断开事件里要把这个句柄清零。很多notify发送失败的问题追根溯源都是连接句柄管理出了问题发的时候用的句柄根本不对。3. Notify实现温度数据主动上传3.1 自定义GATT服务设计温度数据要通过notify主动上传首先要有一个承载数据的GATT服务。GATT结构是分层的Service下面包含CharacteristicCharacteristic下面可以有Descriptor。我设计了一个极简的自定义服务Service UUID用0xFFE0里面包含一个CharacteristicUUID是0xFFE1属性设置为可读和可通知然后给这个Characteristic挂一个CCCD描述符UUID固定是0x2902。这个CCCD就是手机端订阅通知用的开关。这里要解释一下为什么CCCD必不可少。BLE规范规定notify是服务端主动发起的但客户端必须先“订阅”。订阅动作本质上就是往CCCD里写数据写0x0001表示开启通知写0x0000表示关闭。如果没有CCCD或者应用层没处理CCCD的写入事件那服务端的notify就是非法的手机端收不到数据。在FR801xH的SDK里添加自定义服务核心是构造一个属性数据库表然后调用服务注册接口。属性数据库的每个条目描述一个属性包括UUID、权限、初始值等。下面的代码是一个示意结构实际接口名称以你手里的SDK版本头文件为准const attm_desc_128_t temp_att_db[] { {ATT_UUID_128_SERVICE, PERM(RD, ENABLE), 0, 0}, // Service 声明 {ATT_UUID_128_CHAR, PERM(RD, ENABLE), temp_char_prop, 0}, // Characteristic 声明 {ATT_UUID_128_TEMP_VALUE, PERM(RD, ENABLE) | PERM(NTF, ENABLE), 0, 0}, // 温度值 {ATT_UUID_16_CCCD, PERM(RD, ENABLE) | PERM(WR, ENABLE), 0, 0}, // CCCD };服务添加完成之后SDK会返回该服务中各个属性的句柄。温度值这个特征的句柄要单独存起来notify发送的时候要指定这个句柄用错了手机端同样收不到。3.2 CCCD写入事件与订阅状态管理服务注册好之后接下来最关键的就是处理CCCD写入事件。手机端的APP在打开通知开关时会往CCCD里写两个字节。FR801xH的协议栈捕获到这次写入之后会通过ATT事件回调通知应用层。在回调里要做的事情不复杂判断是不是自己关注的CCCD句柄读取写入的值如果是0x0001就把订阅标志置位如果是0x0000就清除标志。整个过程可以用下面的伪代码来表达void app_att_event_handler(uint8_t event, void *param) { if (event ATT_EVENT_CCCD_WRITE_REQ_IND) { cccd_write_ind_t *ind (cccd_write_ind_t *)param; if (ind-handle temp_cccd_handle) { if (ind-value 0x0001) notify_enabled true; else notify_enabled false; } } }写这段代码时有几个容易踩的坑。第一个必须判断句柄不要只看事件类型。一个工程里可能有多个特征值都带CCCD如果都统一处理会出现一个特征值订阅了另一个也跟着发的串扰问题。第二个判断值的时候一定要用0x0001和0x0000不要用true和false也不要只判断是否为0。第三个有些协议栈实现中CCCD写入回调里还需要应用层主动确认回复否则协议栈会认为处理失败。这个要看SDK文档别漏了。我还遇到过一个情况手机端关闭通知时CCCD里写入的不是0x0000而是0x0002。0x0002对应的其实是indicate的订阅所以严谨的判断应该包括0x0001和0x0002两种情况。如果服务端只支持notify不支持indicate那0x0002可以忽略但状态标志不要被它误置位。3.3 温度采集与数据换算温度采集电路我用了经典的分压方案VDD接一个100k欧姆的上拉电阻电阻另一端接NTC热敏电阻NTC的另一端接地。ADC采样点接在两个电阻的中间节点。这里NTC是下端接地所以当温度升高时NTC阻值下降ADC采样点电压也随之下降。反过来如果NTC接在上端、固定电阻在下端那采样电压随温度升高而升高。两种接法换算公式要调整别套错。FR801xH的ADC是12位分辨率参考电压选择要看具体芯片型号和SDK配置我这边用的是内部参考电压。假设ADC读到的原始值是adc_val参考电压是VREF那么采样点电压float v_adc (float)adc_val / 4095.0f * VREF;然后根据分压关系NTC的当前阻值float r_ntc PULL_UP_R * v_adc / (VREF - v_adc);其中PULL_UP_R是上拉电阻的阻值即100k欧姆。有了NTC阻值再用B值公式换算温度。NTC的型号参数里有R25和B值R25是25摄氏度时的标称阻值B值是热敏指数。我用的NTC是R25100k、B3950。换算公式float t_k 1.0f / (1.0f / 298.15f logf(r_ntc / 100000.0f) / 3950.0f); float temp_c t_k - 273.15f;298.15K就是25摄氏度换算成开尔文温标。logf是自然对数数学库在编译时要注意链接libm。算出来的temp_c是浮点摄氏温度放到notify数据包里的时候我习惯把它放大100倍存为int16_t这样既能保留0.01摄氏度的分辨率又不需要在BLE协议栈里传浮点数省掉不少麻烦。int16_t temp_report (int16_t)(temp_c * 100.0f);温度换算还有一个经验ADC采出来的原始值要经过软件滤波再用。我这边每100毫秒采一次连续采10次去掉最大值和最小值剩下的取平均然后参与换算。否则NTC分压点的噪声会直接反映在温度值上显示出来上下乱跳。3.4 定时采样与notify主动上报温度和按键这类事件不一样它没有“突发”特性是一个缓慢变化的连续物理量。所以上报策略采用周期定时比较合理。我的实现是在主循环里维护一个软件计数器每满1秒就执行一次采样和上报逻辑。核心代码逻辑如下void app_main_loop(void) { static uint32_t last_report_tick 0; uint32_t now get_system_tick_ms(); if (now - last_report_tick 1000) { last_report_tick now; int16_t temp_report read_temperature_x100(); if (connected notify_enabled) { ble_gatt_notify(conn_idx, temp_value_handle, (uint8_t *)temp_report, sizeof(temp_report)); } } }发送条件有三个当前处于连接状态、手机端已经通过CCCD订阅了通知、温度值已经成功采样。这三个条件缺一个都不能发。尤其是notify_enabled这个标志我见过不少人在这个地方偷懒直接省略订阅判断结果手机端始终收不到数据排查半天。ble_gatt_notify这个接口不同SDK版本的名字可能略有不同有的叫att_notify有的叫ble_gatt_notify_send但入参基本一致连接句柄、属性句柄、数据指针、数据长度。返回值建议检查一下如果返回了发送失败说明发送缓冲区满了或者连接状态不对这时候不要强行重试丢掉本次数据等下一个周期就好。上报周期也不是越短越好。温度变化本身很慢1秒钟一次已经完全够手机画曲线了。如果周期太短比如20毫秒一次既浪费电量还可能造成协议栈发送缓冲区积压实际效果反而更差。做产品时这个参数应该根据场景去调冷链运输之类的场景可以拉长到5秒甚至更长。4. 常见问题与调试心得4.1 问题速查表这几个月调FR801xH前前后后遇到过不少问题。我把典型的列成一张表方便大家照着排查现象可能原因解决办法手机扫描不到设备广播没有启动广播参数没配置设备名设置失败检查广播启动接口返回值确认广播参数结构体已正确填充用抓包工具看空中有没有广播包能连接但读不到服务GATT服务注册晚于广播启动服务注册异步事件未完成等服务注册完成事件后再启动广播或在连接回调里再次触发服务注册连接到就断开连接参数冲突供电不足从机任务阻塞导致协议栈无法响应合理配置连接间隔检查电池电压主循环里避免长阻塞操作notify发不出数据CCCD没被订阅句柄传错连接状态判断异常打开手机端通知开关检查CCCD写入回调打印conn_idx确认连接句柄手机收到温度跳变ADC滤波不足参考电压不稳NTC分压电路虚焊增加软件滤波使用内部参考电压检查电路焊接广播一段时间后消失广播超时机制触发看门狗未喂根据应用场景配置广播超时或设置为持续广播及时喂狗偶尔死机主循环操作阻塞过久中断优先级配置问题协议栈缓冲区溢出用串口日志定位死机位置检查中断配置降低发送频率4.2 调试工具与抓包经验调试BLE协议栈光靠printf日志效率太低。我的建议是配齐三样东西一个支持BLE的串口调试工具、手机端的nRF Connect、一个BLE抓包设备。nRF Connect是我最常用的工具。它可以扫描设备、查看广播包、连接设备、浏览GATT服务列表、手动订阅notify、查看实时数据流。排查notify问题的时候我一般先连上设备手动打开温度特征的通知开关。如果看了开关之后数据出现了说明从机侧的notify实现是通的问题大概率在手机APP的订阅逻辑上。如果没有数据再去看从机的回调是否有触发。抓包设备有条件就上。我用的是nRF52840 dongle配合Wireshark能看到空中的连接事件、数据包重传、连接参数协商等底层信息。很多时候应用层看起来一切正常但问题出在链路层的参数上比如连接间隔与从机延迟配置不合理导致丢包这些只有抓包才能发现。串口日志建议分级处理。我在初始化阶段打详细信息正常运行阶段只打关键事件和错误。不然温度数据每秒钟刷一次日志串口缓冲区一会儿就被占满了反而拖慢主循环。4.3 功耗调优与参数实测温度节点是电池供电的功耗必须重点优化。FR801xH的功耗表现和配置关系很大我自己实测下来有几点体会。第一广播参数直接影响待机期间的平均电流。广播间隔越短被扫描到越快但功耗越高。如果产品允许广播间隔设置在100毫秒以上比较合理。连接成功之后如果业务上不再需要广播可以关闭广播只维持连接。第二连接参数方面连接间隔和从机延迟是两个关键值。温度上报一秒钟一条连接间隔设到30毫秒到50毫秒从机延迟设1到3个周期功耗和实时性就能平衡。如果不需要实时交互把从机延迟调大芯片大部分时间都在睡眠效果立竿见影。第三温度采集的ADC不要一直开着。我每次上报前才采样采样完成后立刻关闭ADC。这样一轮采样加换算的时间很短剩下的时间芯片都能进低功耗模式。功耗测量我建议用万用表串联测平均电流或者直接买一个带记录功能的USB功耗仪。测的时候一定要测完整的工作周期如果只测瞬时值会高估或低估芯片的真实功耗。比如广播瞬间的峰值电流能到十几毫安但持续不到一毫秒平均下来并不高。4.4 初始化时序的稳定性最后再说一个容易被忽略但非常影响稳定性的点协议栈初始化过程中的时序问题。FR801xH的协议栈初始化不是一个函数调用就全部完成的。比如GATT服务注册是异步流程注册完成的回调事件可能在广播启动之后才到。如果在服务尚未注册完成时就启动广播手机连上设备后会看到空的GATT服务列表只能断开重连。我的做法是在工程里维护一个初始化状态标志static uint8_t init_state 0; void on_gatt_service_registered(void) { init_state | INIT_FLAG_SERVICE_DONE; if (init_state INIT_FLAG_ALL_DONE) { gap_adv_start(); } }这样确保GAP配置、GATT注册、广播参数配置都完成了才真正把广播放出去。虽然代码多几行但上电启动的成功率几乎是100%。特别是在做量产固件时这种初始化顺序的稳定性比什么都重要。5. 写在最后的一些体会这套基于FR801xH和notify的温度上报方案难度其实不在代码本身而是整个链路的状态管理协议栈初始化有没有按顺序走完、GATT服务有没有注册好、连接句柄有没有保存对、CCCD有没有被人订阅过、广播有没有及时重启。任何一个环节断了表现出来都是“手机收不到数据”这一个现象排查起来非常费劲。我个人在实际项目里的建议是拿到FR801xH的SDK之后先别急着改业务逻辑把官方的BLE从机示例烧进去用nRF Connect连一次、读一次、订阅一次感受一下正常的时序和数据流是什么样子。然后再把温度采集和notify一点点加进去。每加一部分就验证一部分这样出了问题能快速定位到是硬件、协议栈还是业务代码的问题。后续如果想继续扩展可以考虑在这个基础上加多特征值上报比如同时报温度和湿度或者加配对绑定防止数据被其他设备恶意读取再往后可以做OTA远程升级用notify下发升级包配合协议栈的升级服务整个产品就完整了。FR801xH这套东西跑通一次之后再做类似的项目其实就是复制粘贴加替换外设驱动的活。希望这篇能帮你在调BLE的路上省下几个加班的晚上。