简介: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 先把误差量出来:固定变量法测一轮
动手改代码之前,先量一轮基线。步骤不复杂:
- 固定连接间隔(比如 50 ms),MTU 用默认值,关闭其他射频业务,包括广播扫描和连接并存。
- 只跑单向时间戳记录,让从端每收到一个包就记一条
(seq, 本地锚点时刻),累积 2000 条。 - 把数据导出来离线算标准差和峰峰值,或者直接在设备上跑在线统计。
在线统计用 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,兼容性最好。
| 报文 | 偏移 | 字段 | 长度(字节) | 说明 |
|---|---|---|---|---|
| REQ | 0 | magic | 1 | 固定 0xB5,用来识别干扰包 |
| REQ | 1 | type | 1 | 0x01 表示请求 |
| REQ | 2 | seq | 2 | 小端,16 位回绕 |
| REQ | 4 | T1 | 8 | 从端本地发送时刻,微秒 |
| RSP | 0 | magic | 1 | 固定 0xB5 |
| RSP | 1 | type | 1 | 0x02 表示响应 |
| RSP | 2 | seq | 2 | 原样回显,用于匹配 |
| RSP | 4 | T2 | 8 | 主端收到请求的时刻 |
| RSP | 12 | T3 | 8 | 主端发出响应的时刻 |
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 | 收敛变慢但跟得上 |
这些是量级参考,不是协议规格,你的板子换一颗晶振、换一层结构件,数字就会变。重点看的是趋势:温度变化期间偏移是单调爬升还是来回震荡,前者是速率估计跟不上,后者是滤波系数偏小。
从那以后我每拿到一版新的协议栈或者换一批板子,都会先把双通道打点和温度循环强制走一遍,再谈别的功能。同步这种东西,平时看不出问题,等到业务层发现采样对不上再回头查,代价就太大了。希望帮到你。
本文还有配套的精品资源,点击获取