ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BLE时间同步实战:BlueSync锚点、时间戳与漂移补偿

BLE时间同步实战:BlueSync锚点、时间戳与漂移补偿

简介:BlueSync 是一套面向低功耗蓝牙时间同步研究与嵌入式开发者的开源协议实现,聚焦在 BLE 链路上完成节点间时间同步实验,适合具备 C 语言与单片机基础的读者参考。压缩包共 262 个文件、约 4.38MB,主体为 .h 头文件与 C/C++ 源码,并含 Python 辅助脚本、BLE 工程配置、说明文档及少量图片资源,可用于梳理集线器与传感器端的代码组织和构建流程。已有 1256 人学习,说明其在 BLE 同步与 mbed 平台实践中具有一定参考价值。内容覆盖 Raspberry Pi、BLED112 USB 加密狗、mbed LPC1768 及 BLE112 模块的软硬件协同,读者可据此理解协议实现、实验环境搭建和工程配置思路,并作为课程项目或后续二次开发的代码底稿。

1. 连接稳定不等于时基一致:BLE 时间同步到底在同步什么

一对低功耗蓝牙设备连着,连接间隔 7.5 ms,链路稳得像焊死的。可只要在从设备侧量一下「每次唤醒到收到第一个包」的时间,会发现它前后能差几十微秒,而且这个差值在几分钟里单调爬升。连接没断,时基却在漂——BlueSync 这类跑在 BLE 之上的时间同步协议,处理的就是这件事:让两端的本地时钟在微秒量级对齐,并且这种对齐能撑过一次又一次的休眠。

它解决的从来不是「连不上」,而是「连上了却没法在同一张时间表上干活」。多传感器同步采样、按时间片轮转的调度、事件因果排序,全都卡在这个前提上。搞懂它,等于搞懂 BLE 这颗射频芯片的时间到底被谁捏在手里。

2. 锚点与连接事件:BlueSync 把时间基准钉在哪个物理瞬间

2.1 连接事件是怎么变成天然时间栅格的

BLE 的连接不是「一直通着」,而是一段一段的。中央设备在每个连接间隔(connection interval,7.5 ms 到 4 s,必须是 1.25 ms 的整数倍)的固定时刻开一个连接事件,事件内双方按 T_IFS 交替收发,1 Mbps PHY 下这个间隔是 150 µs。事件结束,双方关射频睡觉,等下一个。

这个结构对时间同步极其友好,原因有三层。第一,周期性由协议规定死,双方都知道下一个事件什么时候来,不需要额外协商。第二,事件的起点由中央设备的时钟硬性决定,它产生在链路层调度器里,不经过应用层,因此不会被上层任务调度拖慢。第三,事件之间没有数据包,信道是干净的,不存在「同步报文被别的流量挤到后面」这种事。

于是连接事件序列天然就是一把尺子:第 n 个事件的锚点满足 A(n) = A(0) + n × CI。BlueSync 要做的,就是把从设备的本地时钟读数映射到这把尺子上,并且算出映射关系的两个参数——相位(offset)和斜率(drift)。

但这里有个容易忽略的事实:这把尺子量的是中央设备的时间。从设备永远处在「听」的位置,它只能通过观测来反推 A(0) 和 CI 在自此时基下的值。一旦反推出来,从设备就能在完全不听空口的情况下预测下一个锚点,这才是低功耗的前提。

2.2 从「事件开始」到「空中第一比特」:锚点偏移怎么补

软件意义上的「事件开始」和物理意义上的锚点不是一回事。真正的锚点是第一个 PDU 的前导码第一个比特离开发射器的瞬间。从调度器决定发,到这个比特真正出去,中间隔着射频上电、PLL 锁定、白化与加密处理。BLE 对这一段有硬性要求,但再快也是几十到一百多微秒的量级。

接收侧同样有一条链:空中第一比特到达 → 解调 → CRC 校验 → 中断触发 → 时间戳寄存器被读走。如果时间戳是在中断服务函数里用软件读计数器打的,这条链的延迟就是不确定的,取决于当时有没有更高优先级的中断在跑。几十微秒的抖动足以把微秒级同步彻底毁掉。

常见的实现思路是把时间戳下沉到硬件。部分 BLE SoC 的射频外设支持在收发事件边沿锁存一个自由运行的定时器,软件事后去读这个锁存值,抖动就只剩几个时钟周期。拿不到这种硬件能力时,退而求其次是「固定延迟标定」:让 t2 和 t3 在同一个中断里产生,中间只做最少的寄存器操作,把这个固定间隔当成已知常数从公式里减掉。

下面这段是预测锚点最朴素的写法,坑也埋在里头:

#include <stdint.h> /* 本地时基:1 us 一个 tick,由 32 位硬件计数器扩展成 64 位单调时间。 * anchor_us :上一次精确测得的锚点,单位 us * drift_ppb :本地时钟相对参考时基的频率偏差,正数表示本地走得快 * interval_us:连接间隔,1.25 ms 的整数倍 * n :距离锚点已经过去的事件数 */ int64_t bs_predict_anchor(int64_t anchor_us, int32_t drift_ppb, uint32_t interval_us, uint32_t n) { int64_t elapsed = (int64_t)interval_us * (int64_t)n; /* 危险:整数除法向下截断。elapsed 只有几毫秒时, * elapsed * drift_ppb 远小于 1e9,corr 直接算成 0。 */ int64_t corr = (elapsed * drift_ppb) / 1000000000LL; return anchor_us + elapsed + corr; }

拿具体数字代进去就看出问题了:连接间隔 7500 µs,偏差 200 ppb,单个事件的 elapsed × drift_ppb = 1.5×10⁶,除以 10⁹ 得 0。也就是说每个事件算出来的修正量都是零,跑满一分钟(480 个事件)累积的真实漂移是 200 ppb × 60 s = 12 µs,一分没补上。这类截断在嵌入式里太常见了,症状是「同步看着做了,误差还是按时长线性涨」。

修法有两条。一是拉长基准:不要每个事件单独算一次修正,而是从锚点起算总经过时间,一次性算 corr,误差就不会被反复截断。二是提高 tick 分辨率,把时基做成 1/16 µs,让 corr 在定点下也能保留有效位。两条可以叠加,BlueSync 这类实现通常会同时用。

2.3 接收窗口扩展:未补偿漂移的账单

BLE 规范里,从设备不能精确知道锚点什么时候到,所以必须提前开接收窗口。窗口宽度要覆盖双方时钟的不确定度,规范给出的思路是窗口正比于「自上次可靠同步以来经过的时间」乘以「双方的时钟精度之和」。

写成可估算的形式:W(T) = 2 × (ε_master + ε_slave) × T + W_fixed。W_fixed 是射频稳定时间和中断响应留的固定裕量,通常几十微秒;ε 是各自晶振相对标称频率的偏差。

代一组很常见的数:双方都是普通无源晶振,各 50 ppm,同步后 10 秒,窗口 = 2 × 100×10⁻⁶ × 10 = 2 ms。如果连接间隔是 100 ms,从设备每个事件要开 2 ms 的接收窗口,射频接收电流按 6 mA 算,平均电流就有 120 µA。对一颗纽扣电池来说,这个数字直接决定了产品能活三个月还是三年。

把相对漂移压到 2 ppm、每 30 秒重同步一次,窗口 = 2 × 2×10⁻⁶ × 30 = 120 µs,占空比从 2% 掉到 0.12%,平均电流降到 7 µA 量级。这就是时间同步在 BLE 上真正的价值锚点:它不是为了让时间戳好看,是为了把射频开机时间从毫秒压到百微秒。理解了这一点,后面所有的机制选择都有了判断标准。

注意:窗口收窄有下限。射频从睡眠到能正确解调的稳定时间、中断响应的最坏情况,共同决定了 W_fixed。窗口压到比 W_fixed 还小,丢包率会陡增,反而更费电。

3. 时间戳交换:一次往返怎么把两边的钟差算出来

3.1 四时间戳模型和它的对称性假设

最经典的钟差估计是四时间戳。中央设备在 t1 发出同步请求,从设备在 t2 收到,在 t3 回复,中央设备在 t4 收到。四个量分属两个时基:t1、t4 在中央设备时钟上,t2、t3 在从设备时钟上。

推导不复杂。设真实传播延迟下行 d_down、上行 d_up,从设备相对中央设备的钟差为 θ(即从设备的读数比真实时间多 θ)。那么 t2 = t1 + d_down + θ,t4 = t3 - θ + d_up。两式相减再相加减,得到:

θ = [(t2 - t1) - (t4 - t3)] / 2 RTT = (t2 - t1) + (t4 - t3)

第一个式子成立的前提是 d_down = d_up。这个假设在 BLE 里成立吗?空中传播三米是 10 ns,完全可以忽略;但协议栈处理是不对称的。下行是「收 + 解调 + 校验 + 进中断」,上行是「组包 + 等 T_IFS + 发射」。两边差个几十微秒太正常了。而 θ 的误差正好是非对称量的一半——非对称 20 µs,钟差就错 10 µs。

这里有一条 BLE 特有的硬约束值得单独拎出来:从设备必须在 T_IFS 之内开始回复,1 Mbps PHY 下就是 150 µs。超过这个时间,中央设备认为从设备没响应,连接事件提前结束。也就是说从设备只有 150 µs 完成「打时间戳 + 准备回包 + 交给射频」这一整套动作。在中断里做浮点运算、跑复杂分支,基本等于自杀。这条约束直接决定了时间戳处理逻辑必须写成极短的定点代码。

3.2 非对称延迟从哪来,怎么标定

把非对称拆开看,来源基本是这几项:射频上电与锁相环锁定、解调器固有延迟(跟 PHY 模式有关,1M、2M、Coded 各不相同)、CRC 校验时间、中断响应延迟(Cortex-M 从检测到中断到执行第一条指令是 12 个周期,再加上优先级抢占和尾链优化带来的变化)、协议栈软件路径。

前几项是硬件常数,可以标定;后两项是变量,只能靠设计去压。压中断抖动的办法是把同步相关的中断设成最高优先级,并且保证在同步窗口内不关中断。压软件路径的办法是让 t2 和 t3 在同一段代码里产生,中间只有几条指令。

标定的实用手段是角色互换:让 A 当主、B 当从测一轮,再换过来测一轮。如果两次测得的钟差符号相反、绝对值接近,说明非对称主要在单侧,可以解方程把常数项扣出来。更土但更有效的办法是拿逻辑分析仪夹一个 GPIO,在打时间戳的同一行代码里翻转电平,直接量出软件路径的真实延迟。

下面这段是钟差估计的核心,包含一个坏样本门限:

#include <stdint.h> /* 双方都把本地 32 位硬件计数器扩展成 64 位单调 tick,单位 1 us。 * 2^63 us 约等于 29 万年,实际使用中不可能回绕。 */ typedef struct { int64_t t1_master; /* 中央设备发送时刻(中央时基) */ int64_t t2_slave; /* 从设备接收时刻(从设备时基) */ int64_t t3_slave; /* 从设备回复时刻(从设备时基) */ int64_t t4_master; /* 中央设备收到时刻(中央时基) */ } bs_ts4_t; /* 成功返回 0,坏样本返回 -1。 * offset_us 输出钟差估计,rtt_us 输出往返时延用于质量判断。 */ int bs_estimate_offset(const bs_ts4_t *ts, int64_t *offset_us, int64_t *rtt_us) { /* down = 钟差 + 下行延迟,up = 上行延迟 - 钟差 */ int64_t down = ts->t2_slave - ts->t1_master; int64_t up = ts->t4_master - ts->t3_slave; int64_t rtt = down + up; /* 上下行延迟之和,钟差被抵消 */ /* RTT 为负说明时间戳顺序错了;超过 20 ms 说明中间被别的流量插队 */ if (rtt < 0 || rtt > 20000) return -1; *offset_us = (down - up) / 2; *rtt_us = rtt; return 0; }

几个参数的意义得说清楚。down 和 up 都是跨时基的减法,数学上是「从设备读数减中央设备读数」,结果里天然含 θ,所以不能当成纯粹的延迟来看。rtt 把它们相加,θ 正好抵消,剩下的才是真正的往返延迟,这就是它适合做质量门限的原因。20 ms 这个上限不是随便定的——BLE 连接间隔最大 4 s,但正常的一次同步往返在几毫秒内,超过 20 ms 基本可以确定这一对被重传或调度打断污染了。

还有一个细节:时间戳要打在「空中第一比特」对应的时刻,不是「包收完」的时刻。BLE 的 PDU 长度可变,如果按收包完成打点,不同 payload 长度的样本之间会差出一个包传输时间,混在一起做回归会得到一条斜率完全错误的直线。

3.3 时间戳打在哪一层,决定了几微秒还是几十微秒

同一套公式,时间戳取在不同层,结果能差三个数量级。

应用层打点基本没法用,因为从链路层收到包到应用层回调,中间要过协议栈队列、事件调度、可能还有 RTOS 的任务切换,抖动在几十毫秒量级。协议栈回调层好一些,但仍有几百微秒到几毫秒的抖动,取决于当时协议栈在忙什么。链路层中断里打点能到几微秒,前提是中断优先级够高且没有长临界区。硬件捕获能做到亚微秒,代价是要占用定时器捕获通道,而且芯片得支持。

BlueSync 这类协议的能力上限,本质上由「时间戳能打到多深」决定。算法再精巧,也救不回一个在应用层打的时间戳——那不是精度问题,是信息在源头就已经烂了。所以评估一个 BLE 时间同步方案,第一个该问的问题不是「用的什么滤波算法」,而是「时间戳在哪一行代码产生」。

4. 时钟漂移:ppm 级偏差如何吃掉毫秒级精度

4.1 一次校正撑不了多久:ppm 是怎么变成毫秒的

ppm 这个单位有欺骗性。1 ppm 听起来微不足道,但它意味着每秒钟差 1 微秒,而且要按时间线性累积。

BLE 的链路层时钟通常由一颗 32 MHz 晶振分频得到。普通无源晶振的初始精度在 ±10 到 ±50 ppm 之间,还要叠加温度特性和老化。两颗各偏 50 ppm 的晶振,相对偏差就是 100 ppm,10 秒之后差 1 ms,一分钟差 6 ms。而 2.3 节算过,窗口宽度正比于这个偏差,1 ms 的偏差直接翻译成 1 ms 的额外射频开机时间。

更麻烦的是温度。AT-cut 晶振的频率-温度曲线是三次曲线,在 25℃ 附近很平坦,往两端急剧恶化。-40℃ 到 85℃ 的范围内,同一个器件能漂出几十 ppm。这意味着即使在室温下标定得很准,设备一出门、一进冷库,斜率就变了。只做一次标定的同步方案,在真实环境里撑不过一次温度阶跃。

4.2 漂移估计:单点校正、线性回归、还是卡尔曼

按能力可以分三层。

最底层只校正相位:每次同步把 offset 写进去,下一次同步之前就靠这个固定值硬扛。误差随时间线性增长,斜率就是相对漂移率。

中间层校正相位加斜率:把多次测量的 offset 对时间做拟合,斜率就是频率偏差。这样两次同步之间的误差不再是一次函数,而是斜率误差乘以时间的平方的一半——增长慢了一个数量级。实现上最常用的是最小二乘。

最上层是带过程噪声的卡尔曼滤波:状态量取 [offset, drift],观测是每次同步测到的 offset,过程噪声用来吸收温度变化。它能自动调节对新样本的信任程度,代价是要调噪声参数,而且在 MCU 上运算量更大。

中间层的实现长这样:

#include <stdint.h> /* 增量式最小二乘:只维护五个累加量,不保存历史样本。 * x 单位 us(自基准点起算),y 单位 us(测得的钟差) */ typedef struct { double sx, sy, sxx, sxy; uint32_t n; } bs_lsq_t; void bs_lsq_add(bs_lsq_t *s, double x_us, double y_us) { s->n++; s->sx += x_us; s->sy += y_us; s->sxx += x_us * x_us; s->sxy += x_us * y_us; } /* 返回 0 表示拟合有效。drift_ppb 输出频率偏差,offset_us 输出截距。 */ int bs_lsq_fit(const bs_lsq_t *s, double *drift_ppb, double *offset_us) { double n = (double)s->n; double den = n * s->sxx - s->sx * s->sx; /* 样本少于 8 个,或者 x 几乎全相同(den 趋零),斜率无意义 */ if (s->n < 8 || den < 1e-9) return -1; double slope = (n * s->sxy - s->sx * s->sy) / den; double intercept = (s->sy - slope * s->sx) / n; *drift_ppb = slope * 1e9; /* us/us 换算成 ppb */ *offset_us = intercept; return 0; }

这里用 double 是刻意的取舍,不是偷懒。slope 的量级在 10⁻⁷ 到 10⁻⁵ 之间,定点运算很容易在中间乘法上溢出:x 是几十秒的微秒数(10⁷ 量级),y 是微秒数(10³ 到 10⁵),累加后的 sxy 能到 10¹³ 以上,再乘 10⁹ 换算成 ppb 就直接爆 int64。硬要定点就得做分步除法或者用 128 位中间量,代码复杂度和收益完全不成比例。拟合是低频操作(几秒到几十秒一次),Cortex-M0 上软浮点跑一次也就几十微秒,何况在 T_IFS 之外。真正不能碰浮点的是连接事件中断里的那几步,两者不是一回事。

n ≥ 8 这个门限也不是拍脑袋。样本太少时斜率对单个噪声点极其敏感,一个被重传污染的 RTT 就能把斜率带偏几百 ppb。至于采样窗口长度,全历史回归在温度变化后会失效——旧样本描述的斜率和当前真实斜率已经不是一条线了。实用做法是固定长度滑动窗口,比如最近 32 个样本,或者用指数加权让旧样本逐渐失权。

拟合完一定要看残差。最大残差超过几倍的中位数残差,说明模型和实际数据对不上,多半是温度拐点到了或者中间有丢包。这时候正确的动作是丢弃整个窗口重新开始,而不是继续往里塞样本。

4.3 守时与预测补偿:没有参考时怎么自己走

守时的意思是:失去同步参考之后,设备靠自己的估计继续走。误差来源就两项——残余频率误差乘以经过时间,加上这期间温度变化引起的频率漂移。

如果斜率估到 ±0.5 ppm,守时 60 秒的相位误差是 30 µs,这个水平对大多数应用够了。要再往上走,就得引入温度补偿。片上温度传感器通常有 ±2℃ 的绝对精度,但同一个芯片上温度读数的重复性远好于绝对值,所以更实用的做法不是套用晶振的三次曲线公式,而是记录「温度分桶 → 实测漂移率」的经验表:每 5℃ 一个桶,运行时按当前温度查表并线性插值。

这个做法不优雅,但在真实产品里比物理模型靠谱得多,因为它把焊接应力、PCB 热梯度、个体差异全都一起吸收了。

5. 避坑:BLE 时间同步里最容易翻车的五件事

5.1 同步后十分钟很准,然后突然跳几十微秒

现象:设备刚上电时同步得很漂亮,残差在 2 µs 以内,跑十分钟后钟差突然跳变,之后又稳定在新的水平上。

原因:线性模型假设漂移率是常数,但晶振频率随温度变。设备从室温移到空调出风口、或者主控芯片自己发热起来,斜率就变了。全历史回归的窗口里新旧两种斜率混在一起,拟合出的直线两头都不贴,预测自然偏。

解决:滑动窗口加上残差监控。残差超门限就丢弃窗口重来,别让它继续污染后续估计。有条件的话引入温度分桶,让不同温度区间的样本分开拟合。这条踩坑记录我见过太多次,几乎每个做 BLE 同步的项目都会撞一遍。

5.2 每次估计都稳定偏几微秒,方向还不随机

现象:所有样本的钟差估计都往同一个方向偏 5 到 8 µs,抖动很小,说明不是噪声。

原因:固定的非对称延迟没被扣掉。最常见的是 t2 在中断里打的,中断响应延迟固定存在;或者 t3 到真正发射之间还有一段协议栈处理时间,被算进了上行延迟里。对称性假设被打破,误差直接以一半的量灌进钟差。

解决:做一次闭环标定。用 GPIO 在打时间戳的位置翻转,逻辑分析仪量出实际延迟,把它作为常数项扣掉。更好的办法是把延迟做成可配置参数,产线上每台设备标一次写进 flash,因为不同批次的射频参数确实会有差异

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进