低功耗蓝牙时间同步实战:BlueSync锚点采样与漂移补偿

发布时间:2026/10/30 20:21:56
低功耗蓝牙时间同步实战:BlueSync锚点采样与漂移补偿

简介:BlueSync 是一套面向嵌入式与低功耗蓝牙开发者的时间同步协议开源实现,适合物联网、无线传感网及 BLE 实验课程中的学习与复现。资源围绕 BLE 之上的时间同步展开,包含集线器与传感器节点的完整代码及实验启动说明,涉及 Raspberry Pi、BLED112 USB 加密狗、mbed LPC1768 微控制器和 BLE112 模块等硬件环境。压缩包共 262 个文件,约 4.38MB,以 C 语言头文件为主体,辅以 Python 脚本、mbed 工程与构建配置、XML 配置、Markdown/TXT 说明及少量图片,便于按模块查阅和搭建实验。已有 1256 人学习,说明其在 BLE 时间同步方向具有一定参考价值。读者可从中获得协议实现代码、节点工程配置、实验环境组织方式和自述文档,用于理解 BlueSync 的组件设计、开展复现实验或在自身 BLE 项目中借鉴同步思路;结合自述说明还可快速梳理集线器、传感器与构建配置之间的关系,降低从代码阅读到实验搭建的门槛。

1. 三块采集板回放时差了 8 ms:BlueSync 到底在同步什么

三块 BLE 采集板挂在同一根支架上,采同一段冲击信号,回放时波形错开了 8 ms,做了好几天分布式采集的人大概都遇到过类似的翻车。连接参数调得再整齐,也只保证包按周期发出去,保证不了每块板子对“现在”的理解一致。BlueSync 就是为解决这个问题而出现的:它是一套跑在低功耗蓝牙之上、聚焦时间同步的协议,提供时钟偏移测量、漂移跟踪和本地时基补偿的完整方法,参考实现用 C 写成,面向资源紧张的单片机,不额外增加硬件。它适合多节点采样对齐、BLE 传感器阵列、低功耗联动设备这类场景,也适合想在自己协议栈上补一层时间同步的嵌入式开发者。

2. BLE 时间同步为什么不能照搬 NTP:锚点、漂移与抖动

2.1 连接事件才是 BLE 唯一的时基

BLE 连接建立之后,主从双方按约定的连接间隔周期性进入连接事件。一个连接事件从信道锚点开始,主设备先发,从设备在 150 µs 的帧间隔窗口内回应,一来一回构成一次收发。协议栈上层看到的“收到数据”时刻,已经过了排队、调度、拷贝,跟射频真正发生的时刻能差出几百微秒甚至毫秒,这层延迟对外几乎不透明,办法只能靠打点绕过去。

常见做法是在射频中断回调里翻转一根 GPIO,外面用定时器捕获这个边沿,把“锚点发生在本地计数器上的绝对时刻”记下来。这个值才是后面所有偏移计算的基础,应用层回调里的时间戳只能当辅助参考。没有这个锚点采样链路,BlueSync 这类协议基本没法把精度压到毫秒以下,因为误差源本身就跟协议无关,纯粹是软件调度造成的。

采样链路要在初始化阶段一次性跑通,不需要每次同步都调。具体的落地细节在 4.1 展开。

2.2 三类误差拆开看:射频、晶振、调度

误差先拆开,再谈补偿方式,否则一上板子发现偏移抖动大,根本分不清是协议写错了还是硬件本身不行。我通常按下面这张表分类:

误差来源典型量级能否补偿主要手段
空口传播与收发器延迟几十 µs 到 150 µs部分双向四时间戳相消
晶振速率偏差20 到 50 ppm能斜率跟踪与插值
协议栈调度抖动100 µs 到数 ms不可预测一阶低通滤波
计数器回绕取决于位宽能扩展成 64 位微秒
温度漂移随温度变化部分分段拟合或遗忘因子

晶振偏差要单独拎出来算,因为量级上它最容易被低估。30 ppm 看着不大,1 秒漂 30 µs,1 小时就是 108 ms。如果你只做单次对时、不做速率跟踪,同步曲线上线就会看到一条斜线缓慢爬升,越用越差。实际预算得这么分:假设端到端允许 300 µs 误差,调度抖动占 150 µs,晶振漂移占 100 µs,留给射频路径不对称的余量只剩 50 µs 左右。

这直接决定了重同步间隔。给个简单的估算函数,把预算和相对漂移换算成能撑多久:

#include <stdint.h> /* budget_us: 允许的漂移误差(微秒) * ppm: 两端晶振的相对偏差(ppm) * 返回:建议的重同步周期(毫秒) */ uint32_t resync_interval_ms(uint32_t budget_us, uint32_t ppm) { if (ppm == 0) { return 0xFFFFFFFFu; /* 没有漂移,理论上不需要重同步 */ } return (uint32_t)((uint64_t)budget_us * 1000u / ppm); }

budget_us是给晶振漂移单独划出来的那部分预算,不是总预算;ppm要按两端最坏情况相加,比如主端 20 ppm、从端 30 ppm,就填 50。返回 0xFFFFFFFF 表示不用重同步,实际工程里还是会兜底一个上限,比如 30 秒一次,防止温度突变。

算出来是 2 秒就真按 2 秒跑,代价是功耗和空口占用;算出来是 30 秒就敢放松。这个值直接影响设备功耗,不要跳过这一步。

2.3 先把误差量出来:固定变量法测一轮

动手改代码之前,先量一轮基线。步骤不复杂:

  1. 固定连接间隔(比如 50 ms),MTU 用默认值,关闭其他射频业务,包括广播扫描和连接并存。
  2. 只跑单向时间戳记录,让从端每收到一个包就记一条(seq, 本地锚点时刻),累积 2000 条。
  3. 把数据导出来离线算标准差和峰峰值,或者直接在设备上跑在线统计。

在线统计用 Welford 算法就行,不用两次遍历,也不会因为数值相减损失精度:

#include <math.h> #include <stdint.h> typedef struct { int64_t n; double mean; double m2; /* 均值平方和,Welford 的中间量 */ } bs_stats_t; void bs_stats_push(bs_stats_t *s, int64_t x_us) { double x = (double)x_us; s->n += 1; double d = x - s->mean; s->mean += d / (double)s->n; s->m2 += d * (x - s->mean); } double bs_stats_std(const bs_stats_t *s) { return (s->n > 1) ? sqrt(s->m2 / (double)(s->n - 1)) : 0.0; }

x_us填每次算出来的偏移样本(相对值也可以,标准差不受整体平移影响)。n建议至少 1000,样本太少标准差会被单次抖动带偏。这段代码带浮点和sqrt,只适合调试期跑;量产代码里换成整数版本的方差累积,或者干脆把数据通过串口吐到上位机算。

量出来的结果如果峰峰值已经到几毫秒,说明调度抖动是主要矛盾,滤波系数得往小调;如果标准差很小但均值随时间线性爬升,那就是纯晶振漂移,斜率跟踪是重点。两种情况的处理方向完全不同,先分清再动手。

3. 帧格式与 C 结构体:把四个时间戳塞进一次握手

3.1 两个报文,20 字节以内

BlueSync 的同步握手用两个报文就能闭合:从端发请求,主端回响应,一共四项时间戳。这样设计是为了落在 BLE 默认 ATT MTU 的有效载荷内,不强制要求协商更大的 MTU,兼容性最好。

报文偏移字段长度(字节)说明
REQ0magic1固定 0xB5,用来识别干扰包
REQ1type10x01 表示请求
REQ2seq2小端,16 位回绕
REQ4T18从端本地发送时刻,微秒
RSP0magic1固定 0xB5
RSP1type10x02 表示响应
RSP2seq2原样回显,用于匹配
RSP4T28主端收到请求的时刻
RSP12T38主端发出响应的时刻

REQ 一共 12 字节,RSP 一共 20 字节,正好卡在默认 MTU 的上限。T4 是响应到达从端时从端自己记的本地时刻,不上包,留在本地。四项凑齐之后偏移按经典公式算:

/* theta > 0 表示从端时钟超前于主端 */ int64_t theta = ((int64_t)t2 - (int64_t)t1 + ((int64_t)t3 - (int64_t)t4)) / 2;

这个公式把两个方向上的路径延迟近似对消掉了,前提是上下行延迟大致对称。BLE 的帧间隔是固定的 150 µs,对称性通常不错;但如果有一次连接事件里塞了多个包,最后一个包的排队位置跟第一个包不一样,这个假设就弱了。所以同步报文尽量单独占一个连接事件,别跟业务数据挤在一起发。

3.2 编码与解码:不用结构体,用显式写字节

编译器的结构体对齐和填充规则在不同平台之间不保证一致,直接memcpy发出去,对端解出来大概率是天文数字,这个问题在刚上手时很容易犯。稳妥做法是逐字段序列化,字节序显式约定为小端:

#include <stdint.h> #include <string.h> #define BS_MAGIC 0xB5u #define BS_MSG_REQ 0x01u #define BS_MSG_RSP 0x02u static inline void bs_put_u16(uint8_t *p, uint16_t v) { p[0] = (uint8_t)(v & 0xFF); p[1] = (uint8_t)(v >> 8); } static inline void bs_put_u64(uint8_t *p, uint64_t v) { for (int i = 0; i < 8; i++) { p[i] = (uint8_t)(v >> (8 * i)); } } static inline uint16_t bs_get_u16(const uint8_t *p) { return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); } static inline uint64_t bs_get_u64(const uint8_t *p) { uint64_t v = 0; for (int i = 0; i < 8; i++) { v |= ((uint64_t)p[i]) << (8 * i); } return v; } int bs_encode_req(uint8_t *out, uint16_t seq, uint64_t t1) { out[0] = BS_MAGIC; out[1] = BS_MSG_REQ; bs_put_u16(&out[2], seq); bs_put_u64(&out[4], t1); return 12; /* 返回实际写入长度 */ }

out缓冲区至少要 12 字节,建议按包类型预留固定长度,防止调用方传进来的缓冲不够长。返回值是写入长度,调用方拿它去填 GATT 通知的参数,不要写死。

解码侧必须校验长度和 magic,长度对不上直接丢,别进后续逻辑:

int bs_decode_rsp(const uint8_t *in, int len, uint16_t *seq, uint64_t *t2, uint64_t *t3) { if (len != 20 || in[0] != BS_MAGIC || in[1] != BS_MSG_RSP) { return -1; /* 长度或类型不符,丢弃 */ } *seq = bs_get_u16(&in[2]); *t2 = bs_get_u64(&in[4]); *t3 = bs_get_u64(&in[12]); return 0; }

len是 GATT 回调里拿到的实际长度,不是你以为的长度。很多 BLE 协议栈在协商 MTU 之后允许拆分发送,收到的长度可能比预期短,必须显式判断。

3.3 接收侧:环形缓冲与“对不上号”的序号

从端发出 REQ 之后,T1 得先存着,等 RSP 回来才能凑齐四项。这里要用一张小的待处理表,按 seq 匹配,匹配不上就丢弃:

#define BS_PENDING_NUM 8 typedef struct { uint16_t seq; uint64_t t1; uint8_t used; } bs_pending_t; static bs_pending_t g_pending[BS_PENDING_NUM]; static bs_pending_t *bs_pending_alloc(uint16_t seq, uint64_t t1) { for (int i = 0; i < BS_PENDING_NUM; i++) { if (!g_pending[i].used) { g_pending[i].seq = seq; g_pending[i].t1 = t1; g_pending[i].used = 1; return &g_pending[i]; } } return 0; /* 表满,说明链路已经在丢包 */ } static bs_pending_t *bs_pending_find(uint16_t seq) { for (int i = 0; i < BS_PENDING_NUM; i++) { if (g_pending[i].used && g_pending[i].seq == seq) { return &g_pending[i]; } } return 0; }

表项数 8 够不够,取决于重传策略。正常情况下一个 REQ 对应一个 RSP,表里最多一两个;如果同时在跑多条链路或者开了重传,得按并发数乘 2 再留余量。

用完的表项要显式释放(used = 0),不能靠新数据覆盖,否则序号回绕的时候会撞上旧表项,解出一组错位的时间戳。回绕本身不用特殊处理,16 位序号在大约 65 万次同步之后才转一圈,配合超时清理足够。

4. 端侧实现:从射频事件捕获到本地时钟补偿

4.1 锚点采样:定时器捕获与计数器扩展

前面提到要在射频中断里翻转 GPIO,外面用定时器捕获。板子上一般只有一个 16 位或者 24 位的自由运行计数器,几十毫秒就回绕一次,必须扩展成 64 位微秒,否则偏移算出来会周期性地跳变。

#include <stdint.h> static volatile uint64_t g_base_us; static volatile uint32_t g_last_cnt; /* cnt_now: 定时器捕获寄存器读到的当前计数值 * 掩码 0x00FFFFFF 对应 24 位计数器,16 位改成 0xFFFF */ uint64_t bs_anchor_now(uint32_t cnt_now) { uint32_t delta = (cnt_now - g_last_cnt) & 0x00FFFFFFu; g_last_cnt = cnt_now; g_base_us += delta; return g_base_us; }

g_base_us的读改写不是原子的,在中断里调用时要把这段放进临界区,或者用双缓冲加序号校验。delta用无符号减法自然处理回绕,不需要额外的 if 判断。

时钟基准的选择要注意:有的芯片定时器时钟是 1 MHz,一个 tick 正好 1 µs;有的是 16 MHz,需要右移 4 位,移位会丢精度。如果对短间隔特别敏感,可以把内部时间单位统一成 1/16 µs 的“子微秒”,对外再换算,避免累积误差。

4.2 一阶低通:滤掉的大部分是调度抖动

单次同步算出来的 theta 抖动很大,直接用会让时间戳一会儿快一会儿慢。一阶低通是最省事的:

typedef struct { int64_t offset_us; /* 平滑后的偏移 */ int32_t slope_q16; /* 漂移速率,单位 Q16 ppm */ int64_t last_raw; /* 上次更新时的本地原始时刻 */ } bs_clock_t; #define BS_ALPHA_Q16 (65536 / 8) /* 约 1/8,时间常数约 8 个样本 */ void bs_clock_update(bs_clock_t *c, int64_t raw_us, int64_t theta_us) { int64_t diff = theta_us - c->offset_us; /* 超过 50 ms 认为不是正常样本,是重连或重启,直接硬对齐 */ if (diff > 50000 || diff < -50000) { c->offset_us = theta_us; c->slope_q16 = 0; c->last_raw = raw_us; return; } int64_t prev = c->offset_us; c->offset_us += (diff * BS_ALPHA_Q16) >> 16; /* 用两次平滑值之差估瞬时速率,单位 ppm */ int64_t dt = raw_us - c->last_raw; if (dt > 0) { int32_t inst_ppm = (int32_t)(((c->offset_us - prev) * 1000000LL) / dt); c->slope_q16 += (((int64_t)inst_ppm << 16) - c->slope_q16) >> 3; } c->last_raw = raw_us; }

BS_ALPHA_Q16取 1/8 是折中值:更小滤得更干净但跟随慢,重连之后要很久才收敛;更大跟随快但抖动透传得多。这个系数得跟着样本间隔走,改连接间隔之后必须重新调,第五章会专门讲这个坑。

slope_q16的单位是 Q16 定点 ppm,1 ppm 对应 65536。用整数是为了在没有 FPU 的单片机上跑得动,右移 16 位就是还原成整数 ppm。

4.3 漂移跟踪与读数插值

有了平滑偏移和速率,对外暴露时间的时候不能直接用原始计数,要插值:

int64_t bs_clock_now(const bs_clock_t *c, int64_t raw_us) { int64_t dt = raw_us - c->last_raw; /* slope 是 Q16 ppm,dt 单位 µs,结果单位 µs */ int64_t extra = (((int64_t)c->slope_q16 * dt) >> 16) / 1000000; return raw_us + c->offset_us + extra; }

extra是两次同步之间累积的漂移量,不补这一项,同步周期越长误差越大。除法放在最后一步是为了减少截断损失,dt越大这个误差越明显。

这个函数在业务代码里的调用频率可能很高,注意别在里面加锁或者做浮点。如果raw_us本身是扩展出来的 64 位值,直接传进来就行。

4.4 什么时候该放弃平滑

不是所有情况都适合走低通。设备从睡眠唤醒、主端重启、温度骤变这几类场景,硬对齐比平滑更合适,继续平滑会让时间戳被旧偏移拽着走十几秒。判断条件可以叠加:偏移绝对值突变、seq 断档、距离上次有效样本超过 N 个连接周期,任一命中就走硬对齐分支。硬对齐的代价是时间戳可能跳一下,对下游要是有严格的单调递增要求,得在这一刻打标记,让业务侧决定怎么处理。

5. 避坑:五条踩坑记录与一套排查顺序

5.1 现象:主端重启后从端时间跳到分钟级

现象:日常同步误差都在几百微秒内,主端断电重连之后再测,从端时间直接差了十几分钟。

原因:硬对齐的判据只看偏移绝对值,而主端重启后偏移是从一个正常量级慢慢爬向另一个量级,中间没有单次大跳变,低通一直在“跟”,要很久才发现不对。

解决:加序号连续性检测。RSP 里的 seq 如果跟预期差超过一个阈值,或者两次有效样本间隔超过 3 个连接周期,直接判为链路重置,强制硬对齐并把斜率清零。这个判据比偏移突变敏感得多,重启、断连、切信道都能覆盖。

5.2 现象:连接间隔改小之后曲线反而更抖

现象:从 1 秒连接间隔改成 50 ms,原本很平的偏移曲线变得毛刺明显,标准差翻了几倍。

原因:滤波系数是按 1 秒间隔的经验值固定写死的。样本间隔缩短 20 倍,等效时间常数也跟着缩短 20 倍,滤波几乎失效,调度抖动全透传出来了。

解决:系数跟着间隔自适应算,alpha = dt / (dt + tau),tau固定成你想要的物理时间常数(比如 500 ms),这样换连接间隔不用改代码。tau根据业务能容忍的跟随延迟倒推,一般 3 到 10 个连接周期。

5.3 现象:对端解出来的时间戳是负数

现象:自己发的包自己解没问题,换一块板子接上去,T2 解出来是个接近 int64 上限的负数。

原因:用了结构体直接memcpy进缓冲区,一边是 32 位小端加 4 字节对齐,另一边是别的方式,字段位置整体偏移了,解出来的自然不是原值。

解决:全部改成显式逐字节序列化,同时在包头加 magic 和长度校验,不匹配的直接丢。实测中这类问题最容易被误判成“射频有问题”,其实数据本身在出栈那一刻就已经错位了。

5.4 现象:室温很稳,跑几个小时慢慢漂到毫秒级

现象:刚上电同步精度很好,设备连续跑 6 小时之后误差涨到 2 ms 以上,重启立刻恢复。

原因:斜率估计用的是全程累积,早期样本一直占着权重。器件温度上来之后真实漂移速率变了,但模型还停在冷机状态。

解决:斜率改成指数遗忘,新样本权重高,旧样本逐步衰减;有条件的话把温度也接进来,做分段拟合。这一步做完,长时间运行的漂移能压下来一大截。

5.5 现象:进低功耗之后时间戳整段失效

现象:设备按周期休眠唤醒,唤醒后的第一批时间戳要么断层,要么直接跳到睡前。

原因:低频时钟切换导致计数器基准变了,或者休眠期间计数器停了,g_base_us还在按旧基准累加。

解决:睡眠前把当前状态和基准一起存下来,唤醒后判断计数器源有没有变,变了就把同步状态标记成“未同步”,等至少一次完整握手之后再对外提供时间戳。别在未同步状态下输出时间,宁可直接拒绝,也不要让下游拿到一段看似合理实则错位的时间。

这五条里前三条是设计问题,后两条是运行环境问题。排查顺序建议从帧格式校验开始,确认数据本身没错;再看序号连续性,确认链路没断;最后才怀疑滤波器参数和时钟源。倒着查容易在参数上反复调,实际问题却在更下面一层。

6. 精度验证:一块逻辑分析仪和一个温度循环就够

6.1 双通道打点对拍

协议跑通不等于同步准了,必须实测。最省事的验证方法是两端各引一根 GPIO,在各自“时间戳落账”的那一刻翻转电平,逻辑分析仪同时采两路,看边沿间隔。这测的是应用层真正拿到的时间,正好是你关心的量,比看协议内计算值可靠得多。

/* 在写入时间戳之后立刻翻转,采样率要足够高 */ static inline void bs_mark(void) { BS_MARK_PORT->ODR ^= BS_MARK_PIN; }

BS_MARK_PORT和BS_MARK_PIN按板子替换,翻转要放在时间戳赋值之后、任何日志打印之前。中间只要插一条串口打印,打点位置就被推后了几百微秒,测出来的数字全是假的,这个坑踩一次就记住了。

6.2 跑满一个温度循环再看数据

恒温下的数字好看不说明问题,温度循环才是真考验。我的做法是跑 0 到 60 度一个完整循环,每 10 度停 20 分钟让它稳定下来,记录该温度点下的最大偏移和峰峰值:

条件最大偏移峰峰值抖动备注
25 度恒温约 180 µs约 60 µs基线,滤波正常
25 度加射频并发约 260 µs约 90 µs扫描与连接并存
0 到 60 度循环,无温补约 900 µs约 220 µs漂移速率没跟上温度
同上,加遗忘因子斜率约 310 µs约 110 µs收敛变慢但跟得上

这些是量级参考,不是协议规格,你的板子换一颗晶振、换一层结构件,数字就会变。重点看的是趋势:温度变化期间偏移是单调爬升还是来回震荡,前者是速率估计跟不上,后者是滤波系数偏小。

从那以后我每拿到一版新的协议栈或者换一批板子,都会先把双通道打点和温度循环强制走一遍,再谈别的功能。同步这种东西,平时看不出问题,等到业务层发现采样对不上再回头查,代价就太大了。希望帮到你。

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