Rockchip BLE低功耗唤醒:广播扫描与GPIO唤醒源实践
做电池供电的音频类产品那阵子,最头疼的不是音质也不是配网,而是待机功耗和唤醒手感这对天生的冤家。产品要求待机一周以上,又要求用户从手机点一下"连接",机器两秒内亮屏响应。把 Rockchip BLE 低功耗唤醒这套东西真正跑通,中间踩的坑比写业务代码多得多。所谓 Rockchip BLE 低功耗唤醒,说白了就是让 RK 主控尽量睡着,把"有没有人找我"这件事交给蓝牙控制器去扛,通过广播扫描(Advertising Scan)在射频层识别特定报文,再用一根 GPIO 把主控捅醒。它解决的是"设备长期在线但几乎不耗电"这个矛盾,适合做便携音箱、智能面板、手持终端、电池门铃这类 RK 平台的低功耗产品。下面这套方案是我在几个项目里反复打磨后沉淀下来的,包含硬件接线约束、设备树改法、广播协议设计、可直接编译运行的扫描程序,以及实测出来的功耗账,照着做基本能少走两三个月的弯路。
1. 方案总览:把判断逻辑下沉到射频层
1.1 三条唤醒路径的功耗与响应账
在 RK 平台上做低功耗唤醒,实际只有三条路可走,选错方向后面全是徒劳。第一条是纯主机侧轮询:主控定期从休眠中醒来跑一次扫描,扫完再睡。第二条是连接态保活:设备一直维持一条 BLE 连接,靠链路层心跳判断对端是否还在。第三条就是本文要讲的控制器侧广播扫描:主控彻底睡着,蓝牙控制器独立维持扫描窗口,命中条件后拉 GPIO。
先看功耗账。主机轮询的问题在于"醒来"本身很贵——RK 平台从 suspend 恢复一次,DDR 重新上电、时钟树重建、驱动 resume,整套下来少说 60 到 150 毫秒,峰值电流能冲到 150 毫安以上。如果每 3 秒轮询一次,平均电流就被拉到了 3 到 8 毫安,这还没算恢复过程中外设的浪涌。连接态保活更糟,BLE 连接间隔即使放到 2 秒,射频收发加主机侧协议栈处理,平均也要 1 毫安上下,而且一旦对端离开,设备还得自己判超时重建,逻辑复杂。
控制器侧扫描则是把占空比压到极致:扫描窗口占空比通常只有 2% 到 5%,射频接收电流按 6 到 8 毫安算,平均摊下来不到 0.4 毫安;主控全程保持 suspend,只留 PMU 静态电流和 DDR 自刷新。三条路的量级差异,直接决定了产品能不能宣称"长待机"。
| 方案 | 主控状态 | 平均电流典型值 | 唤醒时延 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 主机侧轮询 | 反复 suspend/resume | 3 到 8 mA | 0.5 到 2 s | 低 | 唤醒实时性要求不高的场合 |
| 连接态保活 | 主控需定期处理链路事件 | 0.8 到 1.5 mA(仅 BLE 部分,主控仍要醒) | 20 到 200 ms | 中 | 需要双向通信的常连设备 |
| 控制器侧广播扫描 | 持续 suspend | 主控 suspend 电流 + 0.2 到 0.4 mA | 0.8 到 3 s | 高 | 电池供电、事件驱动的唤醒 |
这张表里有个容易被忽略的细节:控制器侧扫描那一行的"平均电流"只是 BLE 部分的增量,主控 suspend 电流要单独算。很多人做功耗预算时把两者相加搞混,最后实测差一倍,就是因为把 BLE 控制器的休眠电流当成了射频电流。
1.2 Rockchip 上的唤醒链路到底怎么走
一条完整的唤醒链路是:手机发出携带自定义载荷的广播包,BLE 控制器在扫描窗口内收到,控制器固件或我们的扫描程序比对载荷,命中后把 WAKE_HOST 引脚拉高,这个引脚接到 RK 主控的某个 GPIO 上,该 GPIO 在设备树里被注册为wakeup-source,PMU 检测到唤醒事件后给主控上电、恢复 DDR、跑 resume 流程,最后用户态程序收到事件开始干活。
这条链路上有四个环节,任何一环断了都唤不醒,而它们的排查手段完全不同:射频层(有没有收到包)、控制器层(有没有正确比对)、电气层(GPIO 电平有没有真的翻转)、系统层(这个 GPIO 有没有被注册成唤醒源)。我在项目里养成的习惯是,每次调不通就先分层定位,别一上来就改代码。用手机上的调试 App 看广播有没有发出去、用逻辑分析仪抓 WAKE 引脚有没有翻转、用/proc/interrupts看中断计数有没有涨,三分钟就能锁定是哪一层的问题。
Rockchip 的电源管理架构里,suspend 流程由 PMU(通常是配套的电源管理芯片)配合,主控进入mem状态后,除了保留域(always-on domain)之外大部分电源域断电。这就带来一个硬约束:蓝牙控制器绝不能挂在主控可控的电源轨上。我见过太多板子把 BLE 模组的供电接到主控的某个 LDO 使能脚上,suspend 的时候 LDO 一关,模组直接断电,这时候再怎么调软件都是白费。
1.3 为什么选广播扫描而不是连接态保活
连接态保活的思路很自然——手机和设备一直连着,手机一操作设备就知道。但放在电池产品上,它有三个绕不过去的硬伤。
第一是功耗不可控。BLE 连接的心跳由连接间隔决定,手机侧发起连接后,间隔参数被手机主导,Android 上常见 30 到 50 毫秒,iOS 更激进。设备每个连接事件都得醒来收发,射频和协议栈的功耗摊下来很难低于 0.8 毫安,而且这个值不受你控制,用户换台手机功耗就变了,产品没法给出稳定的续航承诺。
第二是状态机的坑。设备 suspend 之后连接会断,resume 后要重连;重连过程中如果手机已经走远,设备会陷在超时重试里,反而更费电。要做优雅处理必须引入状态持久化和重连退避,代码量比扫描方案大得多。
第三是并发问题。一个 BLE 控制器同时只能维持有限条连接,如果设备还要被多个手机控制,连接数一满就出问题。而广播扫描是无状态的,来多少个手机发广播都能收,谁先命中谁说了算,逻辑简单得多。
广播扫描的代价是"单向"——设备只能听,不能主动回。所以产品设计上要做一次握手:设备被唤醒后主动广播自己的服务,手机端监听到再发起连接。这个"先醒后连"的两段式流程,响应时间会比常连方案多 1 到 2 秒,但换来的续航提升是数量级的,值。
2. 硬件与系统底座:让 WAKE 引脚真能把主控叫醒
2.1 电路上的三条命脉
接线这块,我把它归纳成三条命脉,缺一条整套方案就跑不通。
第一条是供电必须常开。BLE 模组的 VBAT 要直接挂到电池或常开的电源轨上,或者挂在一个不受主控 suspend 控制的 LDO 后面。判断方法很简单:主控进入mem之后,拿万用表量模组 VBAT 脚,电压不能掉。如果掉了,说明供电设计有问题,得改板或者改 PMU 配置。同时要注意模组的休眠电流,便宜的模组休眠电流能到 200 微安,直接吃掉你一半的预算,选型时一定要看 datasheet 里的 sleep current,目标控制在 50 微安以内。
第二条是UART 双向握手要齐。除了 TX/RX,硬件流控 CTS/RTS 最好也接上。BLE 模组在扫描期间会突发上报大量广播事件,如果主控这边处理不过来又没有流控,串口 FIFO 溢出会导致丢包,丢的恰好是你要的那个唤醒包,那就很难受。流控引脚在 suspend 期间的状态也要确认——有些模组要求 RTS 保持某个电平才不休眠,这些细节必须翻模组的硬件设计手册。
第三条是WAKE_HOST 引脚极性要固定,不能悬空。这个引脚是模组输出、主控输入,模组没上电的时候是高阻态,如果主控侧没有下拉电阻,引脚会浮在一个不确定电平上,运气不好就一直触发唤醒,待机功耗直接爆炸。我的做法是在主控侧加一个 100k 下拉,把默认电平钉死,模组拉高才有效。如果模组是低有效输出,那就加上拉。极性在设备树里用GPIO_ACTIVE_HIGH或GPIO_ACTIVE_LOW标清楚,别指望软件去猜。
另外提醒一个容易忽略的点:WAKE_HOST 这根线如果走线太长,和 UART 或者射频走线平行距离过远,容易耦合进干扰,导致偶发误唤醒。板子上至少要保证它和射频天线之间有个地平面隔离,长度控制在 10 厘米以内比较稳妥。
2.2 设备树改造:把 GPIO 注册成唤醒源
硬件没问题之后,软件第一件事是把这根 GPIO 变成系统认可的唤醒源。RK 平台的常见做法是借用gpio-keys驱动,它的好处是不用自己写驱动,注册、上报、唤醒一条龙都有了。
/* 1. 先把引脚复用成 GPIO 功能,加上下拉 */ &pinctrl { bt_wake_pin: bt-wake-pin { rockchip,pins = <0 RK_PA5 RK_FUNC_GPIO &pcfg_pull_down>; }; }; /* 2. 用 gpio-keys 注册成唤醒源 */ / { bt_wake: bt-wake { compatible = "gpio-keys"; pinctrl-names = "default"; pinctrl-0 = <&bt_wake_pin>; wakeup-source; status = "okay"; bt_wake_btn: bt-wake-btn { label = "bt_wake"; gpios = <&gpio0 RK_PA5 GPIO_ACTIVE_HIGH>; linux,code = <KEY_WAKEUP>; linux,input-type = <EV_KEY>; wakeup-source; }; }; };这段设备树有三个地方必须确认。pcfg_pull_down是下拉配置,对应刚才说的硬件下拉,双保险。wakeup-source属性在父节点和子节点上都要写,只写一个在部分内核版本上不生效——这个坑我踩过,当时只写了子节点,结果/sys/class/input/inputX/device/power/wakeup里永远是 disabled,查了半天。linux,code用KEY_WAKEUP而不是普通按键码,避免和真实的物理按键在事件上报时混淆。
如果产品里不想让用户态看到按键事件,只想安静地唤醒,也可以用rockchip,gpio-wakeup之类的私有绑定,或者干脆自己写个极简的 platform driver 只注册device_init_wakeup()和enable_irq_wake()。但我的经验是,能用现成驱动就别自己写,gpio-keys 稳定、经过大量验证,出问题的概率低得多。
设备树编译烧录之后,第一件事是验证注册是否成功:
# 找到对应的输入设备 ls /sys/class/input/ # 查看唤醒能力,应该是 enabled cat /sys/class/input/input3/device/power/wakeup # 查看能否被当作唤醒源计数 cat /sys/class/input/input3/device/power/wakeup_count如果wakeup显示disabled,先echo enabled > .../power/wakeup手动打开试试。能手动打开说明驱动注册没问题,只是默认没使能;手动也打不开,那就是设备树里wakeup-source没生效,回去检查属性写的位置。
2.3 内核选项与运行时开关
内核配置里有几项是必须打开的,漏一个就会出现"能唤醒但醒不透"或者"根本进不去 suspend"的怪现象。
CONFIG_PM_SLEEP=y # suspend to RAM 的基础 CONFIG_PM_WAKELOCKS=y # 部分 RK 内核版本依赖 CONFIG_PM_WAKELOCKS_LIMIT=0 # 不限制 wakelock 数量 CONFIG_KEYBOARD_GPIO=y # gpio-keys 驱动 CONFIG_BT=y CONFIG_BT_HCIUART=y CONFIG_BT_HCIUART_H4=y # 大多数 UART BLE 模组走 H4 CONFIG_BT_HCIUART_3WIRE=y # 如果硬件流控接了就一起开 CONFIG_BT_LE=y # BLE 支持CONFIG_PM_WAKELOCKS这一项要特别留意。有些 RK 的 BSP 内核默认用它,有些默认用标准CONFIG_PM_SLEEP的主线机制,两者的用户态接口不同。如果你发现/sys/power/wake_lock这个文件不存在,说明内核没开 wakelock 框架,那 suspend 的时机就只能靠autosleep或者手动触发,脚本写法要跟着改。
运行时还有一组开关值得调:
# 允许自动进入 suspend(部分系统用这个) echo mem > /sys/power/autosleep # 或者手动控制 echo 0 > /sys/power/pm_async # 关掉异步 suspend,方便调试时序 echo 1 > /sys/power/pm_print_times # 打印每个设备的 suspend 耗时 # 确认唤醒源列表 cat /sys/kernel/debug/wakeup_sourcespm_print_times这个开关在调 suspend 卡住的问题时特别好用,它会把每个设备 suspend 花了多少毫秒打出来,谁拖后腿一目了然。我遇到过一次 suspend 要 4 秒才进去,打开这个之后发现是某个外设的驱动在 resume/suspend 里做了 3 秒的延时等待,砍掉之后就正常了。
2.4 唤醒源自检三步法
装好系统先别急着写扫描程序,先做一次"假唤醒"测试,确认电气和系统链路是通的。方法是用一根杜邦线手动短接 WAKE_HOST 到 VCC,模拟模组拉高。
第一步,进 suspend:
echo mem > /sys/power/state屏幕黑掉、串口无输出,说明进去了。
第二步,拉高引脚。如果板子上不方便短接,也可以用另一个 GPIO 输出高电平模拟:
# 前提是有个可控的 GPIO 输出脚接到 WAKE_HOST gpioset gpiochip0 12=1第三步,观察是否恢复。正常的话串口会立刻打印 resume 日志。如果没反应,去查两件事:/proc/interrupts里那个 GPIO 的中断计数在 suspend 前后有没有变化(有变化但没唤醒,说明是唤醒源没使能;没变化,说明信号根本没到)。
这个三步测试做完,硬件和系统层就基本可信了,后面所有问题都可以怀疑到蓝牙和协议这一层,排查范围一下子小了很多。
3. 广播报文协议设计:一次可校验的握手
3.1 字段布局怎么定
广播包的空间很紧张。传统 BLE 广播 PDU 的 payload 最多 31 字节,扣掉 flags、设备名、服务 UUID 这些占位,留给自定义数据通常只有 20 字节出头。BLE 5.0 的扩展广播能到 254 字节,但手机侧的支持度参差不齐,做兼容性优先的产品还是按 31 字节规划比较稳。
我用的布局是这样的,总共 14 字节,塞进 Manufacturer Specific Data(AD type 0xFF)里,前面再加 2 字节的 Company ID:
| 偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 2 | Company ID | 低字节在前,选一个自用段,别撞知名厂商 |
| 2 | 4 | Magic | 固定值,比如0x52 0x4B 0x42 0x57,快速筛掉 99.99% 的无关广播 |
| 6 | 1 | Version | 协议版本,方便后续升级兼容 |
| 7 | 1 | Cmd | 唤醒命令字,0x01 普通唤醒,0x02 强制唤醒,0x03 配对模式 |
| 8 | 2 | Seq | 16 位递增序号,防重放 |
| 10 | 2 | Nonce | 随机数,进一步降低碰撞概率 |
| 12 | 2 | CRC16 | 前 12 字节的 CCITT 校验 |
为什么先放 Magic 再放别的?因为扫描程序在收到每个广播包时都要做一次快速判断,Magic 放在最前面,比较可以直接用 32 位整数一次搞定,省掉逐字节循环。扫描期间每秒可能收到几十上百个包,这点优化累积起来是可观的,尤其是在性能本来就弱的低端 RK 芯片上。
Cmd 字段留着很有用。产品后期往往会加"配网模式"、"OTA 模式"、"工厂测试模式",这些都可以通过不同的 Cmd 值区分,设备被唤醒后读一下 Cmd 就知道该干什么,不用再建立连接问一遍。
3.2 为什么不能只靠 MAC 白名单
新手最容易想到的方案是:把手机的 MAC 地址加到白名单,只认这个地址。听起来天衣无缝,实际上现代手机和操作系统默认都开了随机私有地址(RPA),每次连接或每隔一段时间就换一个,你的白名单第二天就失效了。
更麻烦的是,白名单机制本身的功耗优势也不明显。控制器做地址过滤确实比上报所有包省电,但省的是主机侧的处理功耗,射频接收的功耗一分不少——因为控制器还是得把每个包收下来解出地址才能比对。
所以我的做法是:MAC 地址不用来做身份认证,只用来做"进一步确认"。也就是说,广播载荷里的 Magic + CRC 是主要判据,MAC 地址在载荷校验通过之后,如果用户配置了绑定设备,再比对一次,进一步降低误触发。但绝不能反过来。
如果产品确实需要强身份认证,正规做法是用 BLE 的隐私特性:手机上存设备的 IRK,设备侧维护 Resolving List,控制器用 IRK 解析 RPA 后再做白名单匹配。这套机制在 HCI 上有对应的命令(LE Add Device To Resolving List),但实现复杂度不低,而且需要手机端配合写入 IRK,对普通消费类产品来说属于过度设计。
3.3 防重放与防误触发
误唤醒是低功耗产品最致命的敌人。一次误唤醒,主控醒 3 秒,按 120 毫安算就是 0.1 毫安时的额外消耗,一天误醒 50 次,续航就少了好几个百分点。更别说用户半夜被设备莫名其妙亮灯吓到。
防误触发我分了四层。第一层是 Magic,4 字节固定值,随手写的测试 App 撞上的概率是 2 的负 32 次方,可以忽略。第二层是 CRC16,能挡住射频误码和部分随机数据。第三层是 Seq 和 Nonce,前者单调递增,设备记录上次收到的最大 Seq,收到比它小的直接丢弃,防止别人录下来重放;后者是随机数,配合 Seq 一起校验。第四层是产品级的"频率限制",设备被唤醒后记录时间戳,如果 60 秒内又收到同一个 Seq 的包,直接忽略。
这四层加起来,误唤醒率能压到每月个位数。但要注意,Seq 的记录必须放在非易失存储里,否则设备一断电重启,Seq 归零,之前录的包全都能重放了。我用的是主控的一块 eMMC 分区里的小文件,或者更轻量的做法是用 RTC 备份寄存器存一个 32 位值,掉电不丢,读写也快。
还有一个现实问题:多个手机同时想控制设备怎么办?如果每台手机各自生成 Seq,设备的"最大 Seq"记录就会互相打断。解决办法是放宽到"按 Magic + 设备绑定标识"分组记录,或者干脆只对已绑定的手机做 Seq 校验,未绑定的只做 Magic + CRC 校验,被唤醒后走配网流程。产品逻辑怎么定,取决于你的安全等级要求。
3.4 载荷生成与校验代码
手机端生成载荷我不写 App 代码,给一段可以直接在 PC 上跑的 C 参考实现,方便造测试包:
#include <stdint.h> #include <string.h> #include <stdio.h> static uint16_t crc16_ccitt(const uint8_t *data, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; i++) { crc ^= (uint16_t)data[i] << 8; for (int b = 0; b < 8; b++) crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1); } return crc; } /* 返回载荷长度,写入 out,out 至少 14 字节 */ size_t build_wake_payload(uint8_t *out, uint8_t cmd, uint16_t seq, uint16_t nonce) { memcpy(out, "\x52\x4B\x42\x57", 4); /* Magic "RKBW" */ out[4] = 0x01; /* Version */ out[5] = cmd; out[6] = seq & 0xFF; out[7] = (seq >> 8) & 0xFF; out[8] = nonce & 0xFF; out[9] = (nonce >> 8) & 0xFF; /* 这里只是演示布局,真正上产品时把 Company ID 也算进 CRC */ uint16_t crc = crc16_ccitt(out, 10); out[10] = crc & 0xFF; out[11] = (crc >> 8) & 0xFF; return 12; } int main(void) { uint8_t buf[32]; size_t n = build_wake_payload(buf, 0x01, 1001, 0xA5C3); printf("payload len=%zu:", n); for (size_t i = 0; i < n; i++) printf(" %02X", buf[i]); printf("\n"); return 0; }编译gcc -o gen_payload gen_payload.c,跑出来的十六进制串可以直接贴进手机上的广播模拟工具里发出去。设备侧用同一份 CRC 实现校验,两边一致就能通信。
实际项目里我把 Company ID 也计入了 CRC 覆盖范围,这样能防止别人用相同的 Company ID 撞车。上面的示例为了简洁省掉了,你在真正落地的时候记得加上,覆盖范围在设备侧和手机侧要严格对齐,改一处必须改两处,否则会出现"手机侧发出来校验不过"的经典问题。
4. 从验证到产品:扫描程序怎么写
4.1 先用 btmgmt 十分钟把链路打通
写代码之前,先用现成工具把"手机发广播、设备扫到"这条链路验证一遍,这一步能省掉后面大量的自我怀疑。
# 确认控制器存在并上电 hciconfig -a btmgmt --index 0 power on # 只做 LE 扫描,不发现经典蓝牙 btmgmt --index 0 find -l # 如果系统里是 bluetoothctl bluetoothctl --timeout 20 scan onbtmgmt find -l会在终端里刷出扫到的每一个广播和它的载荷十六进制,你拿手机发一条自定义广播,从这里就能看到载荷长什么样。看到了,说明射频和控制器都正常,问题只会出在后面的过滤逻辑上;看不到,先排查天线、供电、控制器固件。
顺便记录一下扫描时的电流。这时候主控是满负荷运行的,电流读数会是几百毫安,但没关系,这个阶段看的是"有没有收到"而不是"省不省电"。等程序写好了再测功耗。
4.2 C 版 HCI 扫描守护进程
产品里不能依赖 BlueZ 那一大堆进程,我用的是直接打开 HCI 通道的方式,HCI_CHANNEL_USER会把控制器从内核协议栈手里接管过来,完全由我们的程序控制。前提是先把bluetoothd停掉、hci0down 掉,否则 bind 会返回EBUSY。
/* ble_wake_scan.c * 编译: gcc -O2 -o ble_wake_scan ble_wake_scan.c -lbluetooth * 运行: hciconfig hci0 down && systemctl stop bluetooth && ./ble_wake_scan */ #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <stdint.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <signal.h> #include <sys/socket.h> #include <bluetooth/bluetooth.h> #include <bluetooth/hci.h> #include <bluetooth/hci_lib.h> #define SCAN_INTERVAL 0x0800 /* 2048 * 0.625ms = 1280ms */ #define SCAN_WINDOW 0x0040 /* 64 * 0.625ms = 40ms */ #define MATCH_MAX 4 static volatile sig_atomic_t g_stop = 0; static void on_sig(int s) { (void)s; g_stop = 1; } static uint16_t crc16_ccitt(const uint8_t *d, size_t len) { uint16_t crc = 0xFFFF; for (size_t i = 0; i < len; i++) { crc ^= (uint16_t)d[i] << 8; for (int b = 0; b < 8; b++) crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1); } return crc; } static int hci_open_user(int dev) { int fd = socket(AF_BLUETOOTH, SOCK_RAW | SOCK_CLOEXEC, BTPROTO_HCI); if (fd < 0) { perror("socket"); return -1; } struct sockaddr_hci a; memset(&a, 0, sizeof(a)); a.hci_family = AF_BLUETOOTH; a.hci_dev = (uint16_t)dev; a.hci_channel = HCI_CHANNEL_USER; if (bind(fd, (struct sockaddr *)&a, sizeof(a)) < 0) { perror("bind HCI_CHANNEL_USER"); close(fd); return -1; } return fd; } static int hci_cmd(int fd, uint16_t opcode, uint8_t plen, const uint8_t *param) { uint8_t buf[260]; buf[0] = 0x01; /* HCI Command */ buf[1] = opcode & 0xFF; buf[2] = (opcode >> 8) & 0xFF; buf[3] = plen; if (plen) memcpy(buf + 4, param, plen); return (int)write(fd, buf, 4 + plen); } static int scan_start(int fd) { uint8_t sp[7]; sp[0] = 0x00; /* 被动扫描,省电 */ sp[1] = SCAN_INTERVAL & 0xFF; sp[2] = (SCAN_INTERVAL >> 8) & 0xFF; sp[3] = SCAN_WINDOW & 0xFF; sp[4] = (SCAN_WINDOW >> 8) & 0xFF; sp[5] = 0x00; /* Public 地址 */ sp[6] = 0x00; /* 接受所有广播 */ if (hci_cmd(fd, 0x200B, sizeof(sp), sp) < 0) return -1; /* LE Set Scan Parameters */ uint8_t en[2] = { 0x01, 0x00 }; /* 开扫描,不过滤重复 */ if (hci_cmd(fd, 0x200C, sizeof(en), en) < 0) return -1; /* LE Set Scan Enable */ return 0; } /* 在 AD 结构里找 Manufacturer Specific Data,并校验载荷 */ static int match_payload(const uint8_t *ad, uint8_t adlen) { uint8_t i = 0; while (i < adlen) { uint8_t len = ad[i]; if (len == 0 || i + len >= adlen) break; const uint8_t *p = ad + i + 1; if (p[0] == 0xFF && len >= 1 + 12) { const uint8_t *d = p + 1; /* 跳过 AD type */ if (memcmp(d, "RKBW", 4) != 0) return 0; uint16_t crc = crc16_ccitt(d, 10); uint16_t got = (uint16_t)(d[10] | (d[11] << 8)); if (crc != got) { fprintf(stderr, "crc mismatch: calc=%04X got=%04X\n", crc, got); return 0; } uint16_t seq = (uint16_t)(d[6] | (d[7] << 8)); fprintf(stderr, "match! cmd=0x%02X seq=%u nonce=%04X\n", d[5], seq, (unsigned)(d[8] | (d[9] << 8))); return 1; } i += len + 1; } return 0; } int main(int argc, char **argv) { int dev = (argc > 1) ? atoi(argv[1]) : 0; int timeout_s = (argc > 2) ? atoi(argv[2]) : 120; signal(SIGTERM, on_sig); signal(SIGINT, on_sig); int fd = hci_open_user(dev); if (fd < 0) return 1; if (scan_start(fd) < 0) { close(fd); return 1; } fprintf(stderr, "scan started, interval=%ums window=%ums\n", SCAN_INTERVAL * 625 / 1000, SCAN_WINDOW * 625 / 1000); uint8_t buf[1024]; int hits = 0; time_t deadline = time(NULL) + timeout_s; while (!g_stop && hits < MATCH_MAX && time(NULL) < deadline) { ssize_t n = read(fd, buf, sizeof(buf)); if (n < 0) { if (errno == EINTR) continue; perror("read"); break; } if (n < 4 || buf[0] != 0x04) continue; /* 只处理 Event */ uint8_t evt = buf[1]; uint8_t plen = buf[2]; if (n < 3 + plen) continue; /* LE Meta Event */ if (evt != 0x3E || plen < 2) continue; const uint8_t *p = buf + 3; if (p[0] != 0x02) continue; /* Advertising Report */ uint8_t num = p[1]; const uint8_t *q = p + 2; const uint8_t *end = buf + 3 + plen; for (uint8_t k = 0; k < num && q + 9 <= end; k++) { uint8_t dlen = q[8]; if (q + 9 + dlen + 1 > end) break; if (match_payload(q + 9, dlen)) hits++; q += 9 + dlen + 1; /* +1 是 RSSI */ } } /* 停扫描,避免退出后控制器还在耗电 */ uint8_t off[2] = { 0x00, 0x00 }; hci_cmd(fd, 0x200C, sizeof(off), off); close(fd); fprintf(stderr, "exit, hits=%d\n", hits); return hits > 0 ? 0 : 2; }这段代码有几个地方值得细说。
扫描参数SCAN_INTERVAL = 0x0800对应 1280 毫秒,SCAN_WINDOW = 0x0040对应 40 毫秒,占空比 3.125%。这两个值是功耗和检出率的平衡点,后面第五节会细算。注意 BLE 规范要求 window 不能大于 interval,且两者都必须是 0.625 毫秒的整数倍,写之前算好。
扫描类型选了0x00被动扫描。主动扫描会发 SCAN_REQ 去要 scan response,多一次收发,功耗高不说,还可能暴露设备存在。被动扫描只听不说,对唤醒场景完全够用,因为唤醒载荷放在广播包里就够了,不需要 scan response。
read()是阻塞读,这是好事——没有数据时进程完全休眠,不占 CPU。所以整个程序的功耗基本就是射频的功耗,用户态几乎为零。千万别写成轮询read加usleep,那样光是检查循环就能吃掉好几毫安。
事件解析里q += 9 + dlen + 1这个步进要仔细核对:每个 report 的头部是 Event_Type(1) + Address_Type(1) + Address(6) + Data_Length(1) 共 9 字节,然后 dlen 字节数据,最后 1 字节 RSSI。算错一步后面全乱。
最后退出前一定要发LE Set Scan Enable(0),把扫描停掉。我见过程序异常退出没有停扫描,控制器还在那扫,整机功耗莫名其妙高了 0.3 毫安,查了两天才定位到。
4.3 编译、部署与 systemd 托管
gcc -O2 -Wall -o ble_wake_scan ble_wake_scan.c -lbluetooth strip ble_wake_scan install -m 0755 ble_wake_scan /usr/local/bin/用 systemd 托管,方便开机自启和崩溃重启:
[Unit] Description=BLE wake scan daemon Before=bluetooth.service After=multi-user.target [Service] Type=simple ExecStartPre=/bin/sh -c 'hciconfig hci0 down || true' ExecStart=/usr/local/bin/ble_wake_scan 0 0 Restart=on-failure RestartSec=1 Nice=-10 IOSchedulingClass=realtime [Install] WantedBy=multi-user.targetExecStart的第二个参数是超时秒数,传 0 表示一直扫不超时,产品形态下通常是常驻。Nice=-10和实时 IO 调度是防止低优先级时被其他进程抢占导致丢包——虽然被动扫描对实时性要求不算极致,但控制器的缓冲有限,读得慢了会丢事件。
还要注意Before=bluetooth.service,避免 BlueZ 抢控制器。如果产品本身不需要经典蓝牙,干脆把bluetooth.service禁用掉,省得开机时两边打架。
4.4 suspend 前后钩子脚本
真正让设备省电,还得让主控睡着,钩子脚本负责在睡眠前后做该做的事:
#!/bin/sh # /usr/lib/systemd/system-sleep/50-ble-wake.sh PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin case "$1" in pre) logger -t ble-wake "suspend prepare" # 确保唤醒源使能 for f in /sys/class/input/input*/device/power/wakeup; do [ -w "$f" ] && echo enabled > "$f" 2>/dev/null done # 停掉不必要的外设,降低 suspend 后的漏电 [ -d /sys/class/net/wlan0 ] && ip link set wlan0 down 2>/dev/null ;; post) logger -t ble-wake "resume done" # 唤醒后 3 秒内保持 wakelock,等业务起来 if [ -w /sys/power/wake_lock ]; then echo btwake > /sys/power/wake_lock ( sleep 3; echo btwake > /sys/power/wake_unlock ) & fi ;; esacpost里那个 3 秒 wakelock 很关键。设备被唤醒后,如果业务进程响应慢,主控可能在你还没处理完事件的时候又自动睡回去了,表现出来就是"设备亮了一下又灭了",用户以为坏了。加个短暂的 wakelock 把这段时间锁住,等业务接管之后再释放。
写完记得chmod +x,systemd 只执行有可执行权限的文件,少这一步脚本会静默不生效,很难查。
5. 功耗实测与调参
5.1 预算怎么算
功耗预算不靠感觉,靠公式。整机平均电流可以拆成四块:
I_total = I_ap_suspend + I_ble_sleep + I_rf_avg + I_leak I_rf_avg = (W / I) * I_rx其中W是扫描窗口,I是扫描间隔,I_rx是射频接收时的瞬时电流。以本文参数为例,W = 40ms、I = 1280ms,占空比 3.125%,I_rx按 6.5 毫安算,射频平均就是 0.203 毫安。I_ble_sleep按模组手册取 30 微安,I_ap_suspend在 RK 平台上实测 15 到 25 毫安不等(取决于 DDR 容量和外设漏电),取 18 毫安。加起来约 18.23 毫安。
拿 2000mAh 电池算,理论续航约 110 小时,也就是 4 天半。这个数字很可能低于产品宣称的"待机一周",差距主要在主控 suspend 电流上。我做过对比,同一块板子,把 DDR 从 2GB