目录
- 第一部分 原理剖析
- 第 1 章 晶体:负载电容、ppm 与频率误差的量化
- 第 2 章 温漂:补偿曲线是怎么测出来的
- 第 3 章 备份域:掉电之后时间还在不在
- 第 4 章 时间的表示:BCD 与 Unix 时间戳互转
- 第 5 章 两种时钟:单调时钟与墙上时钟的分工
- 第二部分 实战案例
- 第 6 章 tickless:睡下去之后节拍怎么补
- 第 7 章 校时:NTP/SNTP/GPS 的步进与渐进
- 第 8 章 人间尺度:时区、DST 与 2038
- 第 9 章 落到盘上:文件系统与日志时间戳一致性
- 第三部分 代码分析
- 第 10 章 把上面的东西拼起来:模块结构与测试
- 第四部分 注意事项
- 第 11 章 硬件选型与设计准则
- 第 12 章 故障定位流程与量产检查清单
- 第五部分 小结
- 第 13 章 小结与互动提问
第一部分 原理剖析
第 1 章 晶体:负载电容、ppm 与频率误差的量化
1.1 先把 ppm 换算成"每天慢多少秒"
项目review的时候,我见过太多次这样的对话:
------"咱们这个晶体精度怎么样?"
------"20 ppm,够用了。"
够不够用,不取决于这个数字听起来大不大,而取决于你的设备要连续跑多久、以及慢多少秒会让你心疼。先把换算关系钉死,这是后面所有讨论的度量衡:
1 ppm 意味着每天快(或慢)0.0864 秒。
推导很简单:一天 86400 秒,86400 / 1e6 = 0.0864。由此得一张我每次立项都会贴在 wiki 里的表:
| 频偏 | 每天误差 | 每月(30天)误差 | 每年误差 |
|---|---|---|---|
| 5 ppm | 0.43 s | 13 s | 2.6 min |
| 10 ppm | 0.86 s | 26 s | 5.3 min |
| 20 ppm | 1.73 s | 51.8 s | 10.6 min |
| 50 ppm | 4.32 s | 129.6 s | 26.5 min |
| 100 ppm | 8.64 s | 259 s | 53 min |
注意最后一行。100 ppm 不是"差一点",是每年慢出将近一小时。而对一块标称 ±20 ppm 的音叉晶体来说,出厂精度只是故事的一半------另一半在负载电容和温度上,这两项能把实际频偏轻松推到 50 ppm 以上。
我曾接手过一个数据记录仪项目,客户验收时提了一句"日志时间跟我们对表差了快一分钟"。设备跑了 47 天,实测平均频偏 +18.5 ppm------晶体本身没超标,但我们的采样间隔是按"时间对齐"设计的,47 天攒下的 74 秒足够让两份本该重叠的记录窗口错开。当时我写了个十行的小程序把 ppm 折算成"多久之后误差超过阈值",后来这段代码成了团队模板:
c
/* ppm_to_limit.c ------ 给定 ppm,估算误差突破阈值所需天数 */
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
if (argc != 3) { fprintf(stderr, "usage: %s ppm limit_sec\n", argv[0]); return 1; }
double ppm = atof(argv[1]);
double limit = atof(argv[2]); /* 可容忍的单向误差,秒 */
double per_day = ppm * 86400.0 / 1e6; /* 每天漂移秒数 */
if (per_day <= 0) { printf("ppm<=0,无漂移\n"); return 0; }
printf("每天漂移 %.4f s,每月 %.1f s\n", per_day, per_day * 30);
printf("突破 %.1f s 需 %.1f 天\n", limit, limit / per_day);
return 0;
}
shell
$ gcc ppm_to_limit.c -o ppm_to_limit && ./ppm_to_limit 20 30
每天漂移 1.7280 s,每月 51.8 s
突破 30 s 需 17.4 天
20 ppm,17 天就能偏出半分钟。这个数字第一次出现在我面前的时候,我把项目里所有"按月对齐"的逻辑全查了一遍。
1.2 负载电容:一个公式,两个坑
有源晶振(晶振模块)把匹配电路封装在内部,你只有一个频率和一个电源引脚,省心但贵。无源晶体(两脚或四脚的 32.768 kHz 音叉、以及 MHz 级 AT 切)不挑这个省心------振荡器回路靠外部电容闭合,负载电容配错,晶体就在一个错误的频率上稳定地跑。
核心公式每个数据手册都印,但每个项目都会踩:
text
CL = Cstray + (CL1 × CL2) / (CL1 + CL2)
CL 是晶体数据手册标称的负载电容,CL1/CL2 是你在 MCU 两个晶体引脚下地的两颗电容,Cstray 是 PCB 走线、引脚、封装寄生电容的总和。绝大多数人的错误集中在两处:
坑一:忘了 Cstray。 寄生电容不是玄学,实测规律很稳定:贴片封装、短走线(<10 mm)的场景,Cstray 取 3~5 pF 是靠谱的;长走线、过孔多、旁边有高速信号的情况下,8 pF 都见过。忽略它等于系统性少配 3~5 pF。
坑二:以为晶体引脚上那几个电容就是全部。 有些 MCU(比如 STM32 部分型号)内部集成了可编程的 CL,通过 RTC 域的 CALR 或 OSC 域的配置位在 4~7 pF 之间分档调节;有些则完全没有,只能靠外部。你的设计是哪种,决定了后面还能不能"不焊板调精度"。
举个我反复用的算例。晶体标称 CL = 12.5 pF,实测估算 Cstray = 3 pF,求两颗外置电容:
text
Cext = (CL1×CL2)/(CL1+CL2) = CL - Cstray = 12.5 - 3 = 9.5 pF
CL1 = CL2 = 2 × 9.5 = 19 pF
工程上取最接近的 E12/E24 档位 18 pF 或 20 pF,误差 ±5% 又给频偏添了零点几个 ppm,可接受。
配错的代价有多大? 32.768 kHz 音叉晶体的"可拉扯度"(pullability)实测大约 4 ppm/pF 左右,而且方向是反直觉的:加大负载电容,频率降低。假设你按 12.5 pF 配,实际却照着"忽略 stray"的老办法配成了 6.5 pF 并联(即每脚 13 pF),少配了 6 pF,频率就会高出大约 24 ppm------一个月 74 秒,两个月两分半。冬天和夏天 PCB 的寄生还会变,这个误差跟着季节漂。
所以我的习惯是:原理图阶段就把 Cstray 估进 BOM,把 CL1/CL2 选成可调档位(比如焊 18/20 两档,或者预留并联焊盘),第一次小批回来实测频率,再决定最终档位。 等项目走到 500 套的时候再想改电容,改的是钢网、贴片程序和几十万块的库存。
| 标称 CL | Cstray | 单颗外置电容 | 常见档位选择 |
|---|---|---|---|
| 6 pF | 3 pF | 6 pF | 6.2 pF (E24) |
| 7 pF | 3 pF | 8 pF | 8.2 pF (E24) |
| 12.5 pF | 3 pF | 19 pF | 18 pF / 20 pF |
| 12.5 pF | 5 pF | 15 pF | 15 pF (E12) |
1.3 一个反面教材:时钟源选错的 12.9%
如果说负载电容配错是"温水煮青蛙",时钟源选错就是"当场去世"。
某次项目用了 STM32F103,RTC 时钟源有两个选择:LSE(外部 32.768 kHz 晶体)和 LSI(内部 40 kHz 振荡器,数据手册标称 32~70 kHz,典型值 40 kHz)。硬件同事为了省一颗晶体和两颗电容,拍板走 LSI,理由是"内部资源,省 BOM"。
问题来了:LSI 的频率范围横跨 32~70 kHz,不同芯片、不同温度、不同批次都不一样 。RTOS 的 tick 和一个"每 30 秒做一次采集"的任务都挂在 RTC 预分频上,我按标称 37 kHz(实测值)配了 PRL。结果小批试产 20 台,采集周期实测从 30 秒变成了 33.87 秒------快了 12.9%。一台台测,20 台无一例外全快,量产后每一台的偏差还不一样。
33.87 / 30 = 1.129,这个 12.9% 就是实测 LSI 频率和配置值之间的差。我们最后做了一次全校准:用 GNSS 接收机的 1PPS 做参考,逐台测出实际 LSI 频率再回填 PRL。校准流程本身是可靠的(下面讲方法),但你没法在客户端现场对 500 台设备逐个校准,产线成本直接爆炸。
教训不复杂:LSI 可以用来做独立看门狗、做 ADC 噪声采样这种对绝对精度不敏感的场景;但只要涉及"秒"这个单位,就必须 LSE 或 GNSS。 省下的成本是一颗几分钱的晶体,付出的是每一台的出厂校准工时。
1.4 频率实测:怎么把 ppm 测出来
配好了电路,剩下的问题是验证。方法不神秘,核心就一句:拿一个你信得过的秒沿,和被测时钟比沿,看单位时间内差多少。
实验室条件我用 GNSS 接收机的 1PPS 输出(对 GPS 授时有执念的可以上铷钟,但工程上 GNSS 足够,PPS 本身有 ns 级的确定性沿)。被测 MCU 配置一个定时器输入捕获,捕获 1PPS 的上升沿,同时在 RTC 里读秒。两种测法:
测法一:数周法(求平均 ppm)。 连续捕获 N 个 PPS 沿,记录第 1 个沿和第 N 个沿对应的 RTC 计数值,ppm = (理论计数 - 实际计数) / 理论计数 × 1e6。N 越大,量化误差越小。想测出 1 ppm 的分辨率,至少要比对 1000 秒以上;我一般跑 3600 秒, overnight 出数据。
测法二:比值法(测周期抖动)。 直接测相邻两个 PPS 沿之间定时器捕获值的差,看抖动分布。这个测的是噪声和补偿粒度,不是平均频偏,但能暴露"补偿跳变"类的问题。
测出来频偏超标,按下面的顺序排:
- 先确认时钟源:LSE 还是 LSI?LSI 的直接换掉,没有讨论余地。
- 再确认负载电容:拆一颗晶体下来,LCR 表测实际负载电容和 ESR;对照原理图档位核算。
- 确认走线:晶体离 MCU 超过 10 mm、走线穿过分割平面、下方有高速走线的,先改板。
- 最后才怀疑晶体本身:±20 ppm 的晶体一般坏不到哪去,但 ESR 超差会导致起振弱、温漂变大,换一颗同规格不同批次的试试。
一个实用的排障脚本,把"测量窗口里累计的周期数偏差"直接换算成 ppm:
c
/* freq_check.c ------ GNSS 1PPS 数周法测频偏 */
#include <stdio.h>
#include <stdint.h>
int main(void)
{
/* 场景:LSE 32768 Hz,1PPS 每秒一个沿。
* 连续 3600 s,第 1 沿定时器计数 0,第 3601 沿计数 N。
* 理论计数 = 32768 * 3600 */
const uint32_t rtc_hz = 32768;
const uint32_t win = 3600;
uint64_t measured = 0;
printf("输入第 %u 个 PPS 沿时定时器的累计计数: ", win + 1);
if (scanf("%llu", &measured) != 1) return 1;
double theory = (double)rtc_hz * win;
double ppm = (theory - (double)measured) / theory * 1e6;
printf("理论 %.0f,实测 %llu,频偏 %+.3f ppm\n", theory, measured, ppm);
printf("折合每天 %+.3f s\n", ppm * 86400.0 / 1e6);
if (ppm > 20 || ppm < -20) puts("超出 ±20 ppm,查负载电容/走线/批次");
return 0;
}
1.5 小结与选型建议
这一章的结论可以压缩成四句:
- 1 ppm = 0.0864 s/天,20 ppm = 51.8 s/月。先用模板算出你的场景多久偏出可容忍阈值,再决定要不要治。
- 负载电容公式
CL = Cstray + (CL1×CL2)/(CL1+CL2),Cstray 按 3~5 pF 估,别忽略;音叉晶体每差 1 pF 大约偏 4 ppm,且加容降频。 - 凡涉及"秒",时钟源一律 LSE 或 GNSS,LSI 只配做看门狗。
- 频偏用 GNSS 1PPS 数周法实测,排障顺序:时钟源 → 负载电容 → 走线 → 晶体批次。
下一章讲温度。负载电容治好了"出厂那一刻"的精度,而温度决定设备在客户现场那一年的精度。
第 2 章 温漂:补偿曲线是怎么测出来的
2.1 音叉晶体的温漂是一条抛物线
32.768 kHz 音叉晶体的频率温度特性,是一条开口向下的二次曲线。工程上用的经验公式是:
text
Δf/f = k × (T − T₀)²
其中 k ≈ −0.035 ppm/°C²,T₀ = 25 °C(拐点,也大致是室温标定点)。把晶体出厂偏差也算进去,写成:
text
ppm(T) = −0.035 × (T − 25)² + offset
offset 是 25 °C 下的实测频偏,一般在 ±5 ppm 以内,好的晶体能压在 ±2 ppm。
这条曲线的形状说明了两件事。第一,25 °C 附近最平 ,工业级设备最关心的 −20 °C~+70 °C 区间两端各掉出去几十 ppm;第二,它是对称的,+60 °C 和 −10 °C 的频偏几乎一样(都离拐点 35 °C)。把几个关键点算出来,比任何文字都直观:
| 温度 | 理想 ppm | 每天误差 | 每月误差(30天) |
|---|---|---|---|
| −20 °C | −69.1 | −5.97 s | −179 s |
| −10 °C | −41.1 | −3.55 s | −106 s |
| 0 °C | −20.1 | −1.73 s | −52 s |
| 25 °C | +1.8 | +0.16 s | +4.7 s |
| +60 °C | −41.1 | −3.55 s | −106 s |
| +85 °C | −125.1 | −10.8 s | −324 s |
注意符号:负 ppm 是钟慢。−20 °C 的时候,这块晶体每天慢将近 6 秒------不补偿的话,一个冬天就能慢出三分钟 ,而你在 25 °C 的实验室里测它是好的。这就是为什么"出厂校准"救不了现场:校准只能定住 offset,管不了温度。
所以温补不是可选项。区别只在于你补到什么精度:拿数据手册的典型曲线硬算,还是拿你自己的晶体批次实测。
2.2 实测:把曲线一个点一个点测出来
数据手册给的是典型值,一个批次的实际曲线能差出 5~10 ppm。我的做法是抽 3~5 台样机进温箱,用 GNSS 1PPS 做参考,每个温度点驻留足够长时间,测出该点的实际频偏。
具体流程:
- 选点。 温度轴取 −20、−10、0、10、20、25、30、40、50、60、70 °C 共 11 个点。25 °C 单独取是因为它是拐点,附近曲线最平、也最接近出货校准点;两端加密到 10 °C 一档,是因为抛物线曲率在两端最大。
- 驻留。 每个点等晶体外壳温度稳定(别信温箱显示,测晶体旁边的空气温度),我一般等 30 分钟。
- 测频偏。 用第 1 章的数周法,每个点测 1800 秒以上。想分辨 1 ppm,窗口不能短于 1000 秒。
- 记录。 记录温度(0.1 °C 分辨率)和该点 ppm。
去年那个户外数据记录仪项目,我实测出来的表长这样(int16 定点,0.1 °C 单位存在 MCU 里):
| 温度(°C) | −20 | −10 | 0 | 10 | 20 | 25 | 30 | 40 | 50 | 60 | 70 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 实测 ppm | −70 | −42 | −20 | −7 | +1 | +2 | 0 | −6 | −21 | −41 | −70 |
看出来了吗------它不是一条干净的抛物线 。25 °C 处偏到了 +2,30 °C 反而比 25 °C 低,20 °C 和 25 °C 几乎持平。这就是实测和典型的差距:±1~2 ppm 的批次噪声,来源可能是晶体个体差异、测量误差、温箱内温度梯度。这份带噪声的表才是你该烧进固件的,直接拿数据手册的 −0.035 系数算,等于把批次差异原样留给现场。
2.3 补偿:分段线性插值加毫秒累加器
有了表,补偿算法反而简单:分段线性插值求当前 ppm,把 ppm 折算成毫秒,用一个累加器攒着,攒够一个粒度就施加一次。
为什么是毫秒而不是直接调 RTC 预分频?两个原因。第一,多数 RTC 的校准寄存器粒度太粗(比如 ±0.95 ppm 一档),补不了 0.35 ppm/°C² 这种细账;第二,频繁写校准寄存器在有些架构上会和秒更新事件打架。毫秒累加器是纯软件的,粒度、方向、限幅全在你手里。
为什么必须用累加器?假设 −20 °C 时频偏 −70 ppm,理论补偿量是 14400 s × 70e-6 = 1.008 s/小时。如果你每小时整数施加一次,要么施加 1 s(欠 8 ms),要么施加 2 s(超 992 ms)------超的那 992 ms 本身就是一次时间跳变,比原偏差还大。累加器把零头存到下个小时,长期误差才是零。
核心代码在 tempcomp.c,插值部分值得逐行看:
c
/* tempcomp.c ------ 分段线性插值,表必须按温度升序 */
int16_t tempcomp_ppm(const tempcomp_t *c, int16_t temp_c)
{
const temp_point_t *t = c->tbl;
uint8_t n = c->n;
if (n == 0) return 0;
if (temp_c <= t[0].temp_c) return t[0].ppm; /* 端点外钳位,不外推 */
if (temp_c >= t[n-1].temp_c) return t[n-1].ppm;
for (uint8_t i = 0; i + 1 < n; i++) {
if (temp_c >= t[i].temp_c && temp_c <= t[i+1].temp_c) {
int32_t span = (int32_t)(t[i+1].temp_c - t[i].temp_c);
int32_t f = (int32_t)(temp_c - t[i].temp_c); /* 0..span */
/* 先乘后除,全部整数运算,无浮点 */
return (int16_t)(t[i].ppm + (int32_t)(t[i+1].ppm - t[i].ppm) * f / span);
}
}
return t[n-1].ppm; /* 理论上到不了 */
}
两个设计决定说明一下。端点外钳位不外推 :−30 °C 时返回 −20 °C 那一档的值,宁可欠补也不 extrapolate------外推在低温端会把误差放大,而客户现场真到 −30 °C 时设备多半已经不在保精度状态。全部整数运算 :MCU 上没有 FPU 的话,float 运算在 −20 °C 会明显拖慢 ISR,而且浮点累加的长期行为不可预期;这里的 f 和 span 都在 int32 范围内,不会溢出。
喂数据的接口是一个"时间片"模型:
c
/* tempcomp.h 摘录 */
void tempcomp_feed(tempcomp_t *c, int16_t temp_c, uint32_t dt_ms);
int tempcomp_take_action(tempcomp_t *c, int32_t *delta_ms);
tempcomp_feed 做三件事:插值求 ppm、把 ppm × dt_ms / 1e6 累加进 acc_ms、记住上次 ppm。tempcomp_take_action 在 |acc_ms| >= apply_ms 时返回 1,并通过 delta_ms 给出本次施加量。施加粒度的默认值是 500 ms------小于这个值,RTC 校准寄存器的分辨率或者时钟调整的副作用会吃掉你的补偿;大于这个值,两次施加之间攒的偏差又会让短周期任务看见抖动。
符号方向是个我踩过的坑,单独说:delta_ms 为正表示把钟拨快,为负表示拨回 。晶体慢(负 ppm)时累加器里存的是负值......不对,让我把 tempcomp.c 里的实际约定写清楚,因为这里最容易搞反:
c
/* tempcomp.c 摘录:acc_ms 的符号约定 */
/* acc_ms 负 = 钟慢(欠的秒数,待拨快);acc_ms 正 = 钟快(超前的秒数,待拨回) */
void tempcomp_feed(tempcomp_t *c, int16_t temp_c, uint32_t dt_ms)
{
int16_t ppm = tempcomp_ppm(c, temp_c);
/* ppm 正 = 钟快:这段时间多走了 ppm*dt_ms/1e6 毫秒,累加正值待拨回 */
c->acc_ms += (int32_t)ppm * (int32_t)dt_ms / 1000000;
c->last_ppm = ppm;
}
int tempcomp_take_action(tempcomp_t *c, int32_t *delta_ms)
{
if (c->acc_ms <= -c->apply_ms) { /* 钟慢,拨快 */
*delta_ms = c->apply_ms;
c->acc_ms += c->apply_ms;
return 1;
}
if (c->acc_ms >= c->apply_ms) { /* 钟快,拨回 */
*delta_ms = -c->apply_ms;
c->acc_ms -= c->apply_ms;
return 1;
}
return 0;
}
我第一次写这段的时候把两个分支的符号写反了,冬天补出来钟越补越慢------因为负 ppm 我拨回了正值。这类错误单元测试抓得住 :test_tempcomp.c 里有一条专门的用例,−20 °C 下喂 4 小时,断言输出恰好是 2 次 +500 ms、残余 −8 ms。4 小时理论欠账 14400 × 70e-6 = 1.008 s,两次 500 ms 之后剩 8 ms 零头滚到下一个周期------这就是累加器该有的样子。
图注:横轴温度 −20~70 °C,纵轴频偏 ppm。点为 11 个实测值,折线为分段线性插值,虚线为理想抛物线。注意 25~30 °C 区间实测值偏离抛物线约 1 ppm,这正是实测表存在的意义。
2.4 插值误差:一条抛物线用直线连,损失多少
有人会问:用直线连抛物线,中段亏多少?把 11 点的表拿去和理想抛物线逐点比,test_tempcomp.c 的结论是:
text
[tempcomp] worst interp error: 2 ppm (0.17 s/day)
2 ppm,折合每天 0.17 秒。 对比未补偿时的 −20 °C 每天 −5.97 s,这是两个数量级的改善。剩下这 2 ppm 全部来自抛物线中段的弦高误差(曲率 0.035 ppm/°C²、10 °C 一个间隔,理论弦高上限 0.44 ppm,加上表的批次噪声凑出 2 ppm)。想再压,只有两个办法:温度点加密到 5 °C,或者干脆用二次插值------但 5 °C 一档意味着 19 个表项,二次插值意味着每个采样点两次乘法,收益却只有 1 ppm 级别,多数项目不值得。
2.5 温度从哪来
补偿的输入是温度,温度测错比不补还糟。三条实战经验:
- 热耦合。 温度传感器必须紧贴晶体或与晶体同块 PCB 的铜面上,中间灌导热胶。隔着 5 mm 空气测出来的"板载温度",滞后晶体实际温度十几分钟,补偿永远慢半拍。
- 自热。 MCU 和电源在布线上要远离晶体。我见过一个案子,温补加上之后低温段反而更差,最后发现是传感器读数是电源自热抬上去的------晶体在 −20 °C,传感器读出 12 °C,照着 12 °C 补出个负偏差。
- 采样频率。 温度变化慢,但补偿累加是连续的。我的习惯是 1 分钟 feed 一次,
dt_ms用两次 feed 之间的实际间隔------千万别假定它总是 60000,万一某次 feed 被高优先级中断推迟了,用固定值就是二次误差。用实际间隔,累加器对 feed 抖动天然免疫,这一点第 6 章讲 tickless 时还会用上。
2.6 小结
温漂这一章的账:理想抛物线 −0.035(T−25)² + offset,−20 °C 亏 69 ppm、85 °C 亏 125 ppm;实测表 11 点、带批次噪声;分段线性插值残差 2 ppm;毫秒累加器、500 ms 粒度、端点钳位不外推;补偿方向"正 ppm 拨回、负 ppm 拨快"用单元测试钉死。下一章讲备份域------时间数据怎么活过掉电。
第 3 章 备份域:掉电之后时间还在不在
3.1 先搞清楚备份域里到底有什么
RTC 这个外设特殊在哪?它不是挂在普通 APB 总线上、跟着 VDD 一起断电的普通外设。以 STM32 为例,RTC 和一组备份寄存器(BKP)活在一个叫"备份域"的独立电源域里,这个域有两个供电来源:
text
VDD (主电源,系统运行时给备份域供电)
VBAT (电池/超级电容,VDD 掉电后接管)
切换是硬件自动的:VDD 高于 VBAT 时走 VDD,VDD 跌落自动切 VBAT,不需要软件干预。备份域里保存的东西有三类:
- RTC 的状态 :计数器(F1 的
RTC_CNT)或日历(L4+ 的 TR/DR BCD 寄存器)、预分频值、校准值。 - 备份寄存器:F1 上是 42 个 16 位的 BKP_DRx,L4+ 上是 32 个 32 位的 RTC_BKPxR。这是你放 magic number、校准系数、累计运行秒数的地方。
- RTC 的配置位:BDCR 里的 RTCSEL(时钟源选择)、RTCEN。
这里埋着第一个大坑:备份域是跨复位的,但不是万能的 。RCC_BDCR 里的所有位、备份寄存器的内容,在 VDD 掉电且 VBAT 有效时保持;但如果 VBAT 从来没接过,或者接了个没电的电池,VDD 一掉,整个备份域清零------包括 RTC 配置。下次上电,BDCR 里 RTCSEL 是 0,RTCEN 是 0,你的 RTC 是"全新未配置"状态。
我接过的售后案例里,"设备每次上电时间回到默认日期"占了一半以上,其中绝大多数查到最后是 VBAT 焊盘虚焊、电池没装、或者设计评审时觉得"设备一直插电不需要电池"把 VBAT 悬空了。只要设备存在任何意外掉电的可能,VBAT 就必须有设计,这条没有商量余地。
3.2 备份电源怎么选:超级电容还是电池
VBAT 上挂什么,本质是一道能量计算题。先估备份域的电流。STM32L4 系列,RTC 跑在 LSE 上、备份寄存器保持,VBAT 模式下的典型电流:
| 项目 | 典型电流 |
|---|---|
| RTC + LSE 运行 | ~0.9 µA |
| 备份域稳压器 | ~0.3 µA |
| 备份寄存器保持 | ~0.1 µA |
| 合计 | ~1.3~1.6 µA |
具体数值以你选的型号数据手册为准,但数量级就是这么低。有了电流,两种备份方案的账就好算了。
方案一:超级电容。 适合"掉电是偶发的、恢复供电是必然的"场景------比如工厂产线设备、维修时会断电的户外机柜。电容储存的能量是 E = ½CV²,可用区间是最高电压到 RTC 最低工作电压(一般 1.55 V 左右,含一个安全余量算 2.0 V)。
以一只 1 F / 5.5 V 的超级电容为例,从 5.0 V 放到 2.0 V:
text
E = ½ × 1 F × (5.0² − 2.0²) = ½ × (25 − 4) = 10.5 J
按平均电压 3.5 V、总电流 2 µA 估算,平均功耗 7 µW:
text
t = 10.5 J / 7e-6 W = 1.5e6 s ≈ 17 天
注意这是理想值。超级电容有漏电流(1 F 的料漏电几十到几百 µA 很正常,低温下更大),实际能撑 3~7 天。我的经验:1 F 超级电容按"撑一周"设计,0.22 F 按"撑一两天"设计,别信理想公式。超级电容的优势是充放电次数几乎无限、不用维护;劣势是体积大、有漏电、低温性能衰减。
方案二:锂锰电池。 CR2032(约 225 mAh)、CR2025(约 170 mAh)、CR1220(约 35 mAh)是常见选择,适合"可能长期断电仓储、断电期间没人在场"的场景。还是按 2 µA 算:
text
CR2032: 225 mAh / 2 µA ≈ 112500 h ≈ 12.8 年(受自放电限制,实际约 8~10 年)
CR2025: 170 mAh / 2 µA ≈ 85000 h ≈ 9.7 年(实际约 6~8 年)
CR1220: 35 mAh / 2 µA ≈ 17500 h ≈ 2 年
选型表列在这里,配套设计要点一起说:
| 方案 | 保持时间(估) | 适用场景 | 设计要点 |
|---|---|---|---|
| 0.22 F 超容 | 1~2 天 | 产线、偶发断电 | 加限流电阻或 LDO,防上电冲击 |
| 1 F 超容 | 3~7 天 | 户外机柜、定期维护 | 注意低温漏电,留 50% 余量 |
| CR1220 | ~2 年 | 紧凑设备、短仓储 | 焊点做防反,电池座优于焊接 |
| CR2025 | ~7 年 | 通用工业设备 | 留测试点,可测 VBAT |
| CR2032 | ~10 年 | 长期无人值守 | 防爆型不选,注意运输法规 |
还有一条容易被忽视的:VBAT 和 VDD 之间的倒灌 。有些 MCU 在 VDD 掉电后,备份域稳压器从 VBAT 取电;但如果系统里其他器件通过信号线给 VBAT 网络漏电(比如某颗芯片的 IO 还挂着电平),实际电流会比估算大一个数量级。我遇到过一个案子,账面上 2 µA,实测 18 µA,最后发现是电平转换芯片的下拉电阻在偷电------投产前务必实测 VBAT 网络电流,断 VDD、拔电池,测静态 VBAT 消耗。
3.3 初始化:怎么知道"时间是有效的"
备份域能保时间,前提是固件会正确地"认领"它。核心问题是:上电之后,你怎么知道 RTC 里的时间是可信的、还是垃圾?
答案是备份寄存器里的 magic number。rtc_stm32f1.c 里的 rtc_backup_init() 把这个逻辑写死了,我每次评审都拿它当范本:
c
/* stm32/rtc_stm32f1.c 摘录 */
#define RTC_BKP_MAGIC 0x32F2A55Au /* DR1:备份域有效标记 */
int rtc_backup_init(void)
{
PWR->CR |= PWR_CR_DBP; /* 打开备份域写保护 */
if (BKP->DR[0] != RTC_BKP_MAGIC) {
/* ---- 首次上电或备份域丢失:完整初始化 ---- */
RCC->BDCR |= RCC_BDCR_BDRST; /* 先复位备份域 */
RCC->BDCR &= ~RCC_BDCR_BDRST;
RCC->BDCR |= RCC_BDCR_LSEON; /* 起振 LSE,最多等 2 s */
uint32_t tmo = 2000000u;
while (!(RCC->BDCR & RCC_BDCR_LSERDY)) {
if (--tmo == 0) return -1; /* 起振失败:告警并评估转 LSI */
}
RCC->BDCR = (RCC->BDCR & ~RCC_BDCR_RTCSEL)
| RCC_BDCR_RTCSEL_LSE | RCC_BDCR_RTCEN;
while (!(RTC->CRL & RTC_CRL_RTOFF)) { } /* 等上一次写完成 */
RTC->CRL |= RTC_CRL_CNF; /* 进初始化模式,计数器停走 */
RTC->PRLH = 0x0000u; /* 32768/(32767+1) = 1 Hz */
RTC->PRLL = 0x7FFFu;
RTC->CNTH = 0u; /* 计数器 = Unix 秒 */
RTC->CNTL = 0u;
RTC->CRL &= ~RTC_CRL_CNF; /* 退出初始化,计数器起跑 */
while (!(RTC->CRL & RTC_CRL_RSF)) { } /* 等寄存器同步 */
BKP->DR[0] = RTC_BKP_MAGIC;
return 0; /* 0 = 调用方负责写入初始时间 */
}
/* ---- 已有有效备份:只确认时钟源,绝不碰计数器 ---- */
if (!(RCC->BDCR & RCC_BDCR_RTCEN)) return -2;
if ((RCC->BDCR & RCC_BDCR_RTCSEL) != RCC_BDCR_RTCSEL_LSE) return -3;
return 1; /* 1 = 时间有效,直接用 */
}
这段代码里有四个决定,每一个都是故障换来的:
第一,magic 用"怪"值。 0x32F2A55A 不是随手写的,它避开 0xFFFFFFFF(擦除态)、0x00000000(复位态)和 0xA5A5A5A5 这类常见测试值。上电读到怪值以外的任何东西,一律当作备份丢失处理。多一层保护可以再加个校验:DR2 存 DR1 的反码,两个都对上才认。
第二,只有首次初始化才写 PRER。 备份有效时,代码路径只读 BDCR 做一致性检查,一个字都不写 RTC。这条规矩被违反过太多次:有人图省事,上电无条件重新配置一次 RTC,结果每次上电都把校准值(CALR)冲掉------温补白做,设备越用越慢,还查不出原因,因为重启之后一切"正常"。
第三,LSE 起振失败有出路。 低温下晶体起振余量不足是量产常见坑,rtc_stm32l4.c 里给了补救:默认驱动档起振失败,把 LSE 驱动档(BDCR 的 LSEDRV)从中低档提到中高档再试:
c
/* stm32/rtc_stm32l4.c 摘录 */
if (--tmo == 0) {
/* 起振失败:驱动档从默认低档提到中高档再试一次。
* 低温晶体或批次差的料在低档下起振余量不足,这是量产常见坑。 */
RCC->BDCR = (RCC->BDCR & ~RCC_BDCR_LSEDRV) | RCC_BDCR_LSEDRV_MIDHI;
tmo = 2000000u;
while (!(RCC->BDCR & RCC_BDCR_LSERDY)) {
if (--tmo == 0) return -1; /* 仍失败:硬件问题,上报故障 */
}
break;
}
驱动档不是越高越好------驱动强,LSE 功耗上去,备份域总电流跟着上去,你的电池账要重算。所以这是"再试一次"的补救,不是默认值。
第四,计数器直接用 Unix 秒。 F1 的 RTC_CNT 是 32 位自由计数器,我直接让它存 Unix 时间戳,年月日交给 ts_utc_gmtime() 现算。好处是没有 BCD 寄存器的世纪、闰年一堆麻烦;代价是 2106-02-07 回绕(2^32 秒)。2106 年的事留给 2106 年的人,但你的日志里最好在 2100 年前留个 TODO。L4+ 有 BCD 日历寄存器,直接用日历,但读的时候要做跨秒重取,这个第 4 章细说。
3.4 掉电走时:停电那一刻发生了什么
备份域保住了"时间数据",但保不住"时间连续性"。VDD 开始跌落到最后 RTC 停摆,中间有一段几十毫秒到几秒的窗口,这段窗口里 RTC 的行为取决于硬件设计和你有没有做保护。
最典型的故障形态有两种。第一种是时间倒退 :掉电时固件正在写 RTC(比如刚做完一次校时),写了一半电没了,寄存器处于中间态,下次上电读出来一个几个月前的值。STM32 写计数器必须进初始化模式(F1 的 CNF 位 / L4+ 的 INIT 位),写的过程不是原子的------CNTH 写完写 CNTL 之间掉电,高 16 位是新的、低 16 位是旧的,读出来就是垃圾。对策:校时写入前,先把当前值存到备份寄存器,写完再校验一次,校验不过用备份值恢复。代码骨架:
c
/* 校时写 RTC:先备份后写再校验,防写一半掉电 */
int rtc_set_time_safe(uint32_t new_sec)
{
uint32_t old = rtc_f1_read_counter();
BKP->DR[2] = old; /* 1. 旧值入备份寄存器 */
BKP->DR[3] = ~old; /* 反码,双保险 */
rtc_f1_write_counter(new_sec); /* 2. 写新值(进 CNF,非原子) */
uint32_t chk = rtc_f1_read_counter(); /* 3. 回读校验 */
if (chk != new_sec) {
/* 写坏了(或写到一半掉了线):恢复旧值,报故障让上层决定 */
rtc_f1_write_counter(old);
return -1;
}
return 0;
}
第二种是时间"假走":VDD 跌落到稳压器输出不稳的区间,RTC 计数器还在跑,但时钟源(LSE)已经因为电压不足开始频偏甚至停振,表现出来是掉电前几秒或几十秒,RTC 走得飞快或直接卡住。等恢复供电,时间比真实值偏了一截。这种故障没有软件解法,靠硬件:VBAT 网络加大电容平抑跌落、或者接受它并在恢复供电后标记一次"时间可疑",等下次 NTP/GPS 校时修正。
要识别这两类故障,日志里必须记三样东西:掉电前的最后一个已知时间(存备份寄存器)、恢复上电的时间、以及"时间是否被修正过"的标志位。没有这三样,现场回来一台机器你只能猜。
3.5 掉电检测:PVD 给你的最后几十毫秒
如果想在掉电前优雅地做点事------flush 文件系统、保存运行状态、记一笔日志------靠的是 PVD(可编程电压检测)或掉电检测(BOD)。STM32 的 PVD 可以设阈值(比如 2.5 V),VDD 跌破阈值时产生中断或事件。
问题是时间窗口。假设系统 3.3 V 供电、VBAT 稳压器在 VDD 低于 2 V 时才接管,从 PVD 触发(比如 2.9 V)到 VDD 彻底不可用,中间的时间取决于电源架构和负载电流,工业上常见的是 几毫秒到几十毫秒。这点时间够干什么?
- 保存几百字节到备份寄存器:够。
- 保存几百字节到 SPI Flash(16 KB 一页,一次 1~3 ms):勉强够,而且掉电中写 Flash 有风险。
- 正常关闭文件系统(FAT 刷目录项、日志刷缓冲):不够,别在 PVD 里干这个。
我的做法是分级降级 :PVD 中断里只做"最小保存"------把关键运行状态、时间戳、掉电原因写进备份寄存器,置"异常掉电"标志,然后让主循环在几毫秒内完成收尾;文件系统的完整性不靠掉电保护,靠日志文件系统的断电安全和启动时的扫描修复。在 PVD 中断里做重活,是在给自己挖坑------中断里等 Flash 擦除完成,等不到就一起死。
3.6 小结与选型建议
备份域一章的账:RTC 和备份寄存器活在独立电源域,VBAT 是必需品不是可选项;备份电流按 1.5~2 µA 做预算,1 F 超容撑一周、CR2032 撑十年,投产前实测 VBAT 网络电流;上电用备份寄存器的 magic number 判断时间是否有效,备份有效时一个字节都不要写 RTC;写 RTC 非原子,校时走"备份旧值→写新值→回读校验";PVD 中断里只做最小保存。下一章讲时间的表示------RTC 里的 BCD 和你脑子里的 Unix 时间戳,怎么互相不乱。
第 4 章 时间的表示:BCD 与 Unix 时间戳互转
4.1 RTC 寄存器里的 BCD:一串骗人的十六进制
有 BCD 日历寄存器的 RTC(STM32 L4+ 及大多数现代 MCU),读出来的是这样的东西:
text
RTC_TR = 0x00123456
一眼看过去像十六进制的 0x123456,但它是 BCD------二进制编码的十进制。逐字段拆:
| 位域 | 值(0x123456) | BCD 解码 | 含义 |
|---|---|---|---|
| TR21:16 | 0x12 | 12 | 小时 |
| TR14:8 | 0x34 | 34 | 分钟 |
| TR6:0 | 0x56 | 56 | 秒 |
解码就是高 4 位乘 10 加低 4 位,ts_bcd2bin 两行搞定:
c
/* tslib.c */
uint8_t ts_bcd2bin(uint8_t v) { return (uint8_t)(((v >> 4) * 10u) + (v & 0x0Fu)); }
uint8_t ts_bin2bcd(uint8_t v) { return (uint8_t)(((v / 10u) << 4) | (v % 10u)); }
坑不在解码本身,在你读到的字节不一定是合法 BCD。三种典型情况:
未初始化的 RTC。 备份域掉电后上电,TR/DR 读回的值是不可预期的------实测常见 0x00000000、0xFFFFFFFF,也有读到 0x7F7F7F7F 这种"半 1"态的。拿 ts_bcd2bin(0x0F) 会得到 15,小时 15 合法;但 ts_bcd2bin(0x1F) 得到 25,小时 25 非法。任何从 RTC 读出的 BCD 都要先校验 ,ts_utc_mktime 的粗校验就是干这个的:
c
/* tslib.c */
int64_t ts_utc_mktime(const ts_civil_t *c)
{
if (c->mon < 1u || c->mon > 12u || c->day < 1u || c->day > 31u ||
c->hour > 23u || c->min > 59u || c->sec > 59u) {
return -1;
}
return ts_days_from_civil(c->year, c->mon, c->day) * 86400LL
+ (int64_t)c->hour * 3600LL + (int64_t)c->min * 60LL + (int64_t)c->sec;
}
年份字段尤其脏:L4 的 DR 里年份是 8 位 BCD,存 0~99,本身不含世纪信息。设备跑到 2099 年再翻回 00,你的"2000 + 年"逻辑会在 2100 年集体错一年------第 8 章讲 2038 时一起收拾。
读寄存器时的位污染。 TR/DR 里有保留位和格式位(比如 AM/PM 位、FMT 位),读出来要按掩码取,不能整字节 ts_bcd2bin。rtc_stm32l4.c 里每个字段都带掩码,一个都不能省:
c
/* stm32/rtc_stm32l4.c 摘录 */
uint16_t year = (uint16_t)(2000u + ts_bcd2bin((uint8_t)((dr >> 16) & 0xFFu)));
uint8_t mon = ts_bcd2bin((uint8_t)((dr >> 8) & 0x1Fu));
uint8_t day = ts_bcd2bin((uint8_t)(dr & 0x3Fu));
uint8_t hour = ts_bcd2bin((uint8_t)((tr >> 16) & 0x3Fu));
uint8_t min = ts_bcd2bin((uint8_t)((tr >> 8) & 0x7Fu));
uint8_t sec = ts_bcd2bin((uint8_t)(tr & 0x7Fu));
跨秒撕裂。 TR 和 DR 挂在不同的时钟域上,秒更新发生在你读 TR 和读 DR 之间,就会读出"23:59:59 + 次日日期"或者更糟的"00:00:01 + 前一日日期"。解决办法是读两次比对新旧------rtc_l4_read_civil 用 SSR(次秒递减计数器)当探针:
c
/* stm32/rtc_stm32l4.c 摘录 */
void rtc_l4_read_civil(ts_civil_t *out)
{
uint32_t ssr1 = RTC->SSR;
uint32_t tr = RTC->TR;
uint32_t dr = RTC->DR;
uint32_t ssr2 = RTC->SSR;
if (ssr1 != ssr2) { /* 跨秒:整套重取 */
ssr1 = RTC->SSR;
tr = RTC->TR;
dr = RTC->DR;
}
/* ......BCD 解码后转 Unix 秒再转回规范结构,见完整源码 */
}
SSR 是次秒计数器,两次读之间只要跨了秒边界它必然变。这个读法对 F1 的 CNTH/CNTL 同样适用------32 位计数器分成两个 16 位寄存器,读到一半进位了,高 16 位是新的低 16 位是旧的,拼出来直接偏 65536 秒(18 小时多)。rtc_f1_read_counter 的标准写法是读两遍对账:
c
/* stm32/rtc_stm32f1.c */
uint32_t rtc_f1_read_counter(void)
{
uint32_t h1 = RTC->CNTH, l = RTC->CNTL, h2 = RTC->CNTH;
while (h1 != h2) { h1 = h2; l = RTC->CNTL; h2 = RTC->CNTH; }
return (h1 << 16) | l;
}
4.2 日历换算:为什么自己写而不用 mktime
BCD 解码出来是"年月日时分秒",可你真正想要的是 Unix 时间戳------一个从 1970-01-01 00:00:00 UTC 起算的秒数。中间隔着一段日历算法。
第一个问题:mktime/timegm 能不能用?在嵌入式 Linux 上当然能,但在 MCU 上我要劝你慎重。newlib 的 mktime 有几个问题:它内部会去读时区(_timezone、tzname),算出来的是本地时间语义,想当 UTC 用你得先把环境设干净;它不是线程安全的(老版本);代码体积几百字节到几 KB;最要命的是很多嵌入式工具链的 time_t 还是 32 位,2038 年直接爆。自己写一个不依赖 libc 的 UTC 换算函数,一次性解决所有问题 ,这也是 tslib.c 存在的理由。
第二个问题:怎么写?常见的错误写法是查月长表加闰年判断,循环几十次------又慢又容易在 3 月边界写错。正解是 Howard Hinnant 的 days_from_civil / civil_from_days 算法族,tslib.c 里的实现:
c
/* tslib.c ------ civil date -> days since 1970-01-01 */
int64_t ts_days_from_civil(int y, int m, int d)
{
if (m <= 2) y -= 1;
int64_t era = (y >= 0 ? y : y - 399) / 400; /* 纪,正负都向零外取整 */
uint32_t yoe = (uint32_t)(y - era * 400); /* [0, 399] */
uint32_t doy = (153u * (uint32_t)(m + (m > 2 ? -3 : 9)) + 2u) / 5u + (uint32_t)d - 1u;
uint32_t doe = yoe * 365u + yoe / 4u - yoe / 100u + doy; /* [0, 146096] */
return era * 146097 + (int64_t)doe - 719468;
}
这个算法的巧妙之处是把 3 月当作一年的起点 。m + (m > 2 ? -3 : 9) 把 1、2 月甩到上一年的末尾(当成 13、14 月),闰日 2 月 29 日就永远落在"年末",不影响任何月份天数的计算------闰年判断被完全绕开了。yoe/4 - yoe/100 处理 400 年窗口内的闰年,era × 146097(400 年的准确天数)做纪元平移。没有循环、没有月长表、没有分支陷阱,对正负年份对称。
反方向 ts_civil_from_days 同样无表,这里不逐行贴,说一个关键细节:C 语言的整除向零取整,负数日期会出错 。ts_utc_gmtime 里专门处理:
c
/* tslib.c */
void ts_utc_gmtime(int64_t t, ts_civil_t *out)
{
int64_t days = t / 86400; /* C 整除向零,负数要修正 */
int64_t rem = t % 86400;
if (rem < 0) { days -= 1; rem += 86400; }
ts_civil_from_days(days, out);
out->hour = (uint8_t)(rem / 3600);
out->min = (uint8_t)((rem % 3600) / 60);
out->sec = (uint8_t)(rem % 60);
}
1969 年 12 月 31 日 23:59:59 的 Unix 时间是 −1,向零取整的 −1/86400 = 0、−1%86400 = −1,不做修正就会算出"1970-01-01 的 −1 秒"这种垃圾。修正之后 days = −1,正确落在 1969-12-31。设备从 NTP 收到一个早于纪元的时间戳(时区配错、服务器故障都可能),这段代码就是你的最后一道防线。
顺带一个常用小工具:星期几。1970-01-01 是星期四,所以 (days % 7 + 4) % 7 就是周序(0 = 周日):
c
/* tslib.c */
uint8_t ts_wday_from_days(int64_t z)
{
int64_t r = z % 7;
if (r < 0) r += 7;
return (uint8_t)((r + 4) % 7); /* 1970-01-01 是周四(=4) */
}
负数的 % 在 C 里是负的,必须先修正再取模------又是一个"负数杀全家"的案例。
算法可信度怎么保证? test_tslib.c 干了两件事:拿一组已知锚点值钉死(2026-10-17 = 1792195200、2038-01-19 03:14:07 UTC = 2147483647、2106-02-07 = 4294967296),然后和 glibc 的 timegm/gmtime_r 对了 25 万组随机日期,覆盖 1901~2099 年:
text
[tslib] 250000 random dates vs naive: 0 mismatches
零失配。这个测试我在三种工具链(x86-64、ARM、RISCV)上都跑过,跨平台结论一致。
4.3 闰秒:Unix 时间假装不存在的东西
Unix 时间戳有一个被普遍忽略的性质:它忽略闰秒。1972 年以来地球自转已经插过 27 个闰秒,每一个闰秒,UTC 会多出一秒(23:59:60),而 Unix 时间在那一秒照常 +1,等于把闰秒"吸收"掉了。
后果是:time_t 表示的秒数和 UTC 的"天文秒数"之间,已经悄悄差了 27 秒。GNSS 授时系统内部跟踪 UTC(含闰秒),你从 GNSS 报文里解析出"UTC 年月日时分秒",换算成 Unix 时间戳的时候要问一句:闰秒怎么处理? 绝大多数 GNSS 模块输出的是"闰秒之前的 UTC",也有模块直接输出 GPS 周内秒加当前闰秒数(Leap Seconds 字段)让你自己算。两个数对不上的时候,差的就是这 27 秒的整数倍。
工程上的建议就一条:闰秒只在跨系统对接时显式处理一次,系统内部永远用 Unix 语义。日志里出现"23:59:60"或者时间倒流一秒,先查闰秒表,别去查 RTC。
4.4 2038 与 2106:两个必须记住的数字
时间表示有两个硬上限,记住它们比任何应急预案都管用:
text
32 位 time_t 上限:2147483647 = 2038-01-19 03:14:07 UTC
32 位 RTC 计数器(Unix 秒)上限:4294967296 = 2106-02-07 06:28:16 UTC
2038 问题被讲过无数遍了,落到嵌入式有三个具体的坑:第一,time_t 是 32 位的平台,你的校准逻辑、超时判断、t - t0 全部在 2038 年 1 月 19 日那一天集体溢出,溢出之后时间倒流到 1901 年,所有"超时判断"直接失效;第二,有些 RTC 库内部用 32 位秒数做中转,即使你的计数器是 64 位,过库的时候也被砍一刀;第三,固件升级时间跨度长的设备(跑十年的表计、控制器),你 2026 年写的代码,活着见到 2038 年的概率不低------所有时间运算一律 int64_t,没有例外 。tslib.c 全程 int64_t,就是这个原因。
2106 问题管 F1 那类纯计数器 RTC:RTC_CNT 是 32 位自由计数器,存 Unix 秒,2106 年 2 月回绕。回绕之前的一年,你的程序应该能识别"时间接近上限"并做一次世纪校准。这个功能现在写就十行代码,八十年后写是十行补丁包------长周期设备的时间上限检查,属于出厂就要带的功能。
4.5 小结
BCD 一章的账:RTC 日历寄存器是 BCD 不是十六进制,解码必须配掩码和合法性校验;TR/DR 分属不同时钟域,跨秒重取用 SSR 或 CNT 高低位对账防撕裂;日历换算自己写,Hinnant 算法无表无循环,负数取整必须修正;闰秒只在系统边界处理,内部统一 Unix 语义;时间运算一律 64 位,记住 2038 和 2106 两个数字。下一章讲另一类时钟------单调时钟,以及它和墙上时钟到底该怎么分工。
第 5 章 两种时钟:单调时钟与墙上时钟的分工
5.1 一类时钟会撒谎,一类不会
系统里其实活着两类时间,物理上是同一个计数器,语义上完全不同:
墙上时钟(wall clock) ,Linux 上对应 CLOCK_REALTIME,MCU 上对应 RTC 日历/计数器。它的定义是"墙上挂钟显示的时间"------一个人类约定,会被 NTP 校正、会被运维手动 date -s、会被时区规则影响、会因闰秒产生不连续。它回答"现在是哪一年哪一天"。
单调时钟(monotonic clock) ,Linux 上 CLOCK_MONOTONIC,MCU 上没有现成的,得自己用定时器或 RTC 次秒计数器搭。它的定义是"从某个任意的起点(通常是开机)至今经过的时间"------不受任何校正影响,只增不减(忽略回绕)。它回答"两个事件之间隔了多久"。
把这两类混用,是时间 bug 的重灾区。我见过的经典案例,一个比一个典型。
案例一:用墙上时钟算超时,NTP 一步回拨,看门狗喂到了天荒地老。 某网关设备,看门狗喂狗逻辑是"距上次喂狗超过 30 秒就重置系统",时间戳用的是 gettimeofday。设备上线后第 5 天,NTP 校时发现本机快了 4 分多钟,一步校正(step),墙上时间瞬间倒退 4 分钟。下一次喂狗判断:"当前时间 − 上次喂狗时间" 变成了负 4 分钟,永远小于 30 秒阈值------看门狗再也不认为狗该喂了,真正的死锁发生时不复位,现场烧了一台机器。修法是用单调时钟算间隔,或者至少加单调钳位:间隔为负时按 0 处理。
案例二:周期任务的相位灾难。 一个每秒采样一次的任务,用 next = last + 1s 的方式调度。某次 NTP 校正把墙上时间往前调了 3 秒,任务下一次醒来发现 now 比 next 大了 3 秒,判定"错过了三个周期",连跑三次采样补齐------采集缓冲区被瞬时打满,丢数据。更糟的版本是校正往回调,next 永远在未来,任务直接停跳,直到墙上时间追上来。
案例三:日志时间戳倒流。 两台机器做分布式日志归并,A 机器先校时后写日志,B 机器后校时先写日志,归并之后时间线乱七八糟,排查问题时因果全错位。这个案例我在第 9 章还会展开。
案例四:sleep 的长度不对。 某些 RTOS 的延时基于 tick 累加,不受墙上时钟影响,这没问题;但有些库的"延时到某个绝对时刻"用的是墙上时钟,一次校时之后延时从 100 ms 变成 3 秒。控制循环里出现这种抖动,PID 直接发散。
5.2 分工原则:一句话版本
我带团队时把这条写进编码规范第一条:
测间隔、算超时、做周期调度,一律用单调时钟;只有"给人看的时间"和"跨设备对时"才用墙上时钟。
展开成可执行的规则:
| 场景 | 用什么 | 理由 |
|---|---|---|
| 超时、看门狗、周期任务 | 单调 | 校时不影响间隔语义 |
| 事件排序、乱序检测 | 单调 | 墙上时钟可能倒退导致假乱序 |
| 日志时间戳、文件 mtime | 墙上 | 人类可读、跨设备可比 |
| 定时上报"每小时的第 0 分" | 墙上 + 单调兜底 | 对齐人类时间边界,但要防倒退 |
| 证书有效期、授权到期 | 墙上 | 本质是人类约定 |
第三条最需要技巧。"每小时整点上报"这种需求,正确做法是:用单调时钟保证"每 3600 秒醒一次",醒来后用墙上时钟判断"现在是不是整点附近",两个时钟互相兜底。纯墙上时钟判断整点,校时一回调就漏报或重报;纯单调时钟,设备运行期间的相位漂移会让上报时刻慢慢偏离整点(这是可接受的,漂移量 = 累积频偏)。
5.3 在 MCU 上造一个单调时钟
Linux 有现成的 CLOCK_MONOTONIC,MCU 上没有。但 tickless 架构给了你一个免费的底座:RTC 的次秒寄存器 。第 3 章提到过,STM32 L4 把预分频配成 PREDIV_A=0 / PREDIV_S=32767 之后,ck_apre = 32768 Hz、ck_spre = 1 Hz------日历照常每秒更新,而 SSR 变成一个 32.768 kHz 的自由运行递减计数器,分辨率 30.5 µs。这就是现成的次秒单调轴。
rtc_mono.c 把它和一个日历读数拼成完整的单调时钟:
c
/* rtc_mono.c 摘录(STM32 分支) */
uint64_t rtc_mono_cycles(void)
{
/* SSR 与 TR/DR 分属两个时钟域,跨秒必须整套重取 */
uint32_t ssr1 = RTC->SSR;
uint32_t tr = RTC->TR;
uint32_t dr = RTC->DR;
uint32_t ssr2 = RTC->SSR;
if (ssr1 != ssr2) {
ssr1 = RTC->SSR;
tr = RTC->TR;
dr = RTC->DR;
}
/* ......BCD 解码年/月/日/时/分/秒...... */
uint32_t sub = 32767u - (ssr1 & 0x7FFFu); /* SSR 递减,转递增 */
int64_t days = ts_days_from_civil(year, mon, day);
uint64_t cyc = ((uint64_t)days * 86400ull + hour * 3600ull
+ min * 60ull + sec) * 32768ull + sub;
/* 单调钳位:墙钟被校时回调也不许单调轴倒退 */
if (cyc < s_last_cycles) cyc = s_last_cycles;
s_last_cycles = cyc;
return cyc;
}
三个设计点逐个说。
次秒转递增。 SSR 是从 32767 往 0 递减的(STM32 的习惯),直接用的话你的"经过周期数"是负数,做差必错。sub = 32767 - SSR 转成递增语义,cyc 就是自日历纪元起的 32.768 kHz 周期数,无符号回绕安全(uint64_t 够用到人类灭绝)。
单调钳位。 注意最后三行------这不是多余的防御。这个时钟的"秒"部分来自日历寄存器,而日历寄存器会被校时修改。如果 SNTP 把时间往回调了 3 秒,cyc 会瞬间倒退 3 × 32768 个周期,所有基于它算间隔的逻辑当场错乱。钳位逻辑保证单调轴只增不减:校时回调之后,单调轴会在"原地踏步",直到墙上时间追上被钳住的最高点,再继续前进。代价是校时后几秒内间隔测量偏小(最多偏一个校时量),但这个代价比间隔出现负值小得多。没有这个钳位的单调时钟是假单调时钟 ------我审查代码时见到拿 CLOCK_MONOTONIC 当护身符、结果底层还是墙上时间的,一律打回。
跨 STOP 保持。 这个时钟锚在 RTC 上,RTC 活过 STOP 模式和掉电(第 3 章的备份域),所以单调轴在深睡唤醒之后是连续的。这带来一个直接好处:休眠期间的超时判断终于准了。以前用"进入休眠前记 tick、醒来后补 tick"的做法,误差是休眠时长和 tick 频率的量化误差,休眠 8 小时、tick 100 Hz,理论上就要偏掉几百个 tick;用 RTC 次秒轴,误差是晶体频偏,每天 1.7 秒量级,而且可以被温补收敛到每天 0.2 秒以内。
host 桩用的是 CLOCK_MONOTONIC,同样的钳位逻辑,方便在开发机上跑和 MCU 一致的行为:
c
/* rtc_mono.c 摘录(host 分支) */
uint64_t rtc_mono_cycles(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
uint64_t cyc = (uint64_t)ts.tv_sec * 32768ull
+ (uint64_t)ts.tv_nsec * 32768ull / 1000000000ull;
if (cyc < s_last_cycles) cyc = s_last_cycles;
s_last_cycles = cyc;
return cyc;
}
5.4 用它的正确姿势
间隔计算统一走 elapsed 接口,内部是无符号相减:
c
/* rtc_mono.c */
uint64_t rtc_mono_elapsed_cycles(uint64_t since_cyc)
{
return rtc_mono_cycles() - since_cyc; /* 无符号回绕安全 */
}
无符号相减是间隔计算的标准技巧:即使时钟回绕(64 位基本不可能,但 32 位定时器常见),now - since 的模意义下差值依然正确,只要真实间隔小于回绕周期。绝对不要写 if (now > deadline) 这种带符号比较去做超时判断,回绕那一刻全部失效;正确形式是 if (rtc_mono_elapsed_cycles(start) > limit)。
rtc_mono_elapsed_ms 的换算也值得看一眼:
c
uint64_t rtc_mono_elapsed_ms(uint64_t since_cyc)
{
uint64_t c = rtc_mono_elapsed_cycles(since_cyc);
return c / 32768ull * 1000ull + (c % 32768ull) * 1000ull / 32768ull;
}
先整除取整毫秒,余数部分单独折算,避免 c * 1000 在极端大数下溢出(虽然 64 位下要到 5 亿年才溢出,但这是白送的防御)。第 6 章讲 tickless 补偿时,同一套"先除后乘"的思想还会出现一次。
5.5 小结
两种时钟的分工可以压缩成三句:单调时钟测间隔、墙上时钟记时刻;单调时钟必须有钳位,否则校时会让它变成第二个墙上时钟;MCU 上用 RTC 次秒寄存器造单调时钟,跨 STOP 连续,休眠期间的间隔测量第一次变准。下一章进入实战案例部分,先看 tickless 休眠下的节拍补偿------那里是这套时钟理论的第一个考场。
第二部分 实战案例
第 6 章 tickless:睡下去之后节拍怎么补
6.1 症状:休眠设备的秒表走成了渐近线
进入实战案例部分。第一个案子是我认为最能说明"时间管理是系统工程"的一个。
设备是电池供电的 LoRa 采集器,RTOS 开了 tickless 空闲休眠:没有就绪任务时,内核关掉周期 tick,把 STM32 丢进 STOP 模式,靠 RTC 闹钟在"下一个任务到期时刻"唤醒。机制没问题,问题出在一位同事自己加的"运行时长统计"上。
他的逻辑很直观:每次从休眠醒来,读一次 RTC,算 Δ = now - last_wake,把 Δ 折算成 tick 数补进全局变量,应用层拿这个变量当"系统已运行 tick 总数"用------驱动超时判断、周期任务相位、uptime 上报,全都基于它。
跑了一周之后,现场数据出现三个怪现象:
- uptime 越报越慢。 对照 GNSS 时间,第 1 天 uptime 报 23 小时 58 分,第 3 天报到 23 小时 55 分,第 7 天只剩 23 小时 40 分。每天少报的量从 2 分钟涨到 20 分钟------不是恒定偏差,是越差越多。
- 周期任务相位乱跳。 一个"每 1000 tick 做一次"的任务,实际间隔在 980~1040 tick 之间随机分布,直方图是双峰。
- 一个驱动超时永远不触发。 另一个"5000 tick 内没收到响应就重发"的逻辑,休眠深了之后干脆不动作。
我当时的第一判断是"折算代码写错了",看了两眼就发现不是------折算本身没错,错在每次醒来单独折算、零头被丢弃 。RTC 周期 32768 Hz、系统 tick 1000 Hz,每次休眠平均 28~32 秒,elapsed_cycles × 1000 / 32768 算出来的 tick 数几乎永远不是整数。他用的整数除法把零头扔了:每次扔掉的零头平均半个 tick,设备每天醒 86400/30 ≈ 2880 次,一天扔出去 1440 个 tick------1.44 秒,而且扔多少取决于每次休眠时长的分布,是随机的。这解释了双峰直方图;至于越丢越多,是因为休眠时长本身在变化(后期任务负载变轻,休眠变长,单次零头的期望值跟着变),累积误差不是线性增长。
至于那个 5000 tick 不重发的:超时判断用的也是同一个被污染的 tick 总数,重发计数器的推进速度比真实时间慢,等它"数到" 5000 tick,现场早就错过了几十个重发窗口。
6.2 补偿的数学:把零头存起来
修复方案一行就能说清:把每次折算的零头用一个累加器存起来,下次一起算。但零头的单位选错,这个修复就是假的------这是我要重点讲的。
最直觉的写法是用毫秒(或秒)当零头单位:
c
/* 错误示范:毫秒累加,看起来对,其实漏 */
uint32_t bad_compensate(uint64_t elapsed_cycles)
{
uint32_t ms = elapsed_cycles * 1000 / 32768; /* 先折算毫秒 */
g_ms_acc += ms; /* 零头在这里已经丢了 */
return ms; /* tick 由毫秒再除 */
}
问题出在 elapsed_cycles × 1000 / 32768 这一步:32768 Hz 折算 1000 Hz tick,elapsed_cycles × 1000 / 32768 的余数就是 tick 级的零头,可它在算"毫秒"的时候就被截断了。比如 elapsed_cycles = 1(30.5 µs),乘 1000 除 32768 等于 0------这 30.5 µs 永久消失。单次微不足道,每天 2880 次,累积出来又是每天若干秒。用浮点零头也一样 :float 有效位 24 位,累加器跑到几十万之后,每次加进来的小数低于浮点精度,等于没加。
正确的单位是 tick·cycle ------tick 数和 cycle 数相乘的单位。看 tickless_comp.c 的全部实现:
c
/* tickless_comp.c ------ tickless 睡眠的 tick 补偿(整数精确,无累积漂移) */
uint32_t tickless_compensate(tickless_acc_t *a, uint64_t elapsed_cycles,
uint32_t rtc_hz, uint32_t tick_hz)
{
a->total_cycles += elapsed_cycles;
/* 把"不足一个 tick 的零头"以 tick·cycle 为单位保存,下次一起折算。
* 全程 64 位整数运算,等价于精确有理数累加,数学上零漂移。 */
uint64_t total = (uint64_t)a->acc + elapsed_cycles * (uint64_t)tick_hz;
uint32_t ticks = (uint32_t)(total / rtc_hz);
a->acc = (uint32_t)(total - (uint64_t)ticks * rtc_hz);
a->total_ticks += ticks;
return ticks;
}
拆开讲。elapsed_cycles × tick_hz 的单位是 cycle × (tick/s) = tick·cycle;acc 存的也是 tick·cycle,两者同量纲,可以直接相加。除以 rtc_hz 得到纯 tick 数,取整;余数 total − ticks × rtc_hz 单位还是 tick·cycle,存回 acc。这就是精确有理数累加的整数实现 :任何时刻,total_ticks + acc/(tick_hz) 恒等于 total_cycles × tick_hz / rtc_hz 折算出来的精确值,误差为零------不是小,是零,因为每一步都是整数的加减乘除,没有舍入参与累积。
量纲要检查一遍:acc 的最大值是 rtc_hz − 1 = 32767(tick·cycle),单次睡眠的 elapsed_cycles × tick_hz 在 uint64_t 下能表示 1.8e19 cycle·tick/s,按 32768 Hz 算是 5800 万年。用不完。
6.3 回归测试:20 万次随机唤醒
算法对不对,不能靠"看起来对"。test_tickless.c 设计了三层验证:
c
/* test_tickless.c 摘录 */
static void test_exact_second(void)
{
tickless_acc_t a = {0};
/* 每次恰好睡 1 秒:100000 次之后必须恰好 100000000 tick */
for (int i = 0; i < 100000; i++)
tickless_compensate(&a, 32768, 32768, 1000);
assert(a.total_ticks == 100000u * 1000u); /* 一次不差 */
}
static void test_random_sleep(void)
{
tickless_acc_t a = {0};
uint64_t sim_cycles = 0;
/* 200000 次随机睡眠 28~32 秒:tick 总数必须精确等于秒数×1000 */
for (int i = 0; i < 200000; i++) {
uint32_t s = 28 + (uint32_t)(rand() % 5);
sim_cycles += (uint64_t)s * 32768u;
tickless_compensate(&a, (uint64_t)s * 32768u, 32768, 1000);
}
assert(a.total_cycles == sim_cycles);
assert(a.total_ticks == sim_cycles / 32768u * 1000u
+ (sim_cycles % 32768u) * 1000u / 32768u);
}
第一层是"整秒睡眠 10 万次":每次睡整秒,折算必须是整数,累加器纹丝不动,终值一次不差。第二层是"随机睡眠 20 万次",模拟真实负载的抖动:终值断言 total_ticks 等于精确值,不是"约等于",是相等。第三层验证非整除频率组合(比如 tick 125 Hz、RTC 100 Hz 这种故意选的难看组合),同样零误差。
实际输出:
text
[tickless] 100000 x 1s sleep: ticks=100000000 expect=100000000 err=0
[tickless] 200000 random sleeps: err=0
[tickless] RESULT: ALL PASS
零误差不是调出来的,是结构保证的------整数累加器没有舍入路径,误差从原理上就无处产生。
6.4 落地:休眠框架里怎么接
算法归内核侧管,但补偿的触发点在各家 RTOS 里位置不同,说三个我踩过的接入点。
FreeRTOS。 vTaskStepTick 或移植层的 xTaskIncrementTick 扩展是标准接入点,休眠前后调用一次,把 ulTimerCountsForOneTick 之外多出来的 tick 一次补足。新版 FreeRTOS 的 low power tickless 已经内置了 xTaskCatchUpTicks 逻辑,但那是 tick 级的------如果你的空闲预测和唤醒用的是 RTC 而不是 tick 计数器,自带的补偿不够,需要按 RTC 周期自己折算一次 。我的一般做法是在 portSUPPRESS_TICKS_AND_SLEEP 的唤醒路径上加钩子,把 RTC 实际经过周期数喂给 tickless_compensate,返回值补进 xPendingReadyList 处理之前的 tick 账。
Zephyr。 tickless 由 CONFIG_TICKLESS_KERNEL 管理,k_uptime_ticks 走 sys_clock_tick_get。Zephyr 的计时底层是 cycle_t 计数器驱动,休眠唤醒由 sys_clock_set_timeout 配合报警器完成,多数板级的报警器就是 RTC------理论上休眠时长是精确的。但要注意 k_uptime_get() 返回的是 ms,驱动超时、K_FOREVER、K_NO_WAIT 都基于它,任何对 uptime 的人为修正都会污染整个内核的计时语义。正确做法是修报警器/计数器驱动,不要在上层打补丁。
自研内核。 就是上面那个案子的场景。接入原则:tickless_compensate 的输入只有两个------RTC 实际经过周期数、以及"调用瞬间读取",中间不做任何近似。特别是别用"休眠前预测的唤醒时刻"当输入,要用"醒来后实际读到的 RTC 值"------唤醒延迟、闹钟校准误差、中断抢占都会让实际睡眠时长偏离预测值,这些偏差全部由累加器自动吸收。
6.5 休眠期间别忘温补和墙钟
有两个和 tickless 共生的坑,单独提醒。
休眠期间的温补不能停。 第 2 章的温补模型是"每 60 秒 feed 一次温度、累加毫秒、攒够 500 ms 施加一次"。设备如果 90% 时间在休眠,feed 的节奏会被拖到几分钟一次------dt_ms 用实际间隔就没问题(累加器对 feed 抖动免疫),但"施加"动作如果在休眠中被推迟,攒的量会超过粒度。我的处理:休眠唤醒后第一次主循环迭代,强制检查一次 tempcomp_take_action,把攒的量分批施加掉,避免"憋个大的"造成一次看得见的时间跳变。
休眠期间的墙上时钟。 如果 RTC 日历寄存器就是你的墙上时钟,休眠不影响它(备份域供电,RTC 一直在走),这是 RTC 当底座的最大好处。但如果你图省事用"开机时长 + 上次校准墙钟"推算墙上时间,休眠会让这个推算偏离真实 RTC 时间,量级就是补偿误差------所以第 5 章说的"墙上时钟锚定 RTC 日历"不是洁癖,是必需品。
6.6 小结
tickless 案例的账:休眠唤醒后的 tick 折算,零头必须存,而且要以 tick·cycle 为单位存------毫秒单位和浮点单位都会在截断处漏误差;整数累加器是精确有理数累加,误差结构上为零,20 万次随机唤醒回归验证;接入点在休眠框架的唤醒路径,输入必须是实际读到的 RTC 值而非预测值;休眠期间的温补施加和墙钟锚定要跟着休眠节奏适配。下一章讲这套系统里唯一允许"跳"的操作------网络校时。
第 7 章 校时:NTP/SNTP/GPS 的步进与渐进
7.1 一个 3 小时的日志倒流
第二案例的主角是同一款网关,这次问题出在校时策略上。
设备的日志和时间戳由 NTP 服务器每天校准一次。某次现场排查,技术支持拿着两台设备的日志来对时间,发现一件怪事:A 设备的日志覆盖 09:00~21:00,B 设备覆盖 12:00~次日 00:00,两条日志线都有"对方设备在 14:30 发了配置指令"的记录,但时间戳一个显示 14:30,一个显示 17:31------差 3 小时零 1 分。
3 小时的整数倍,第一反应是时区。但 1 分钟的零头不对到时区能解释。顺着查下去:A 设备的 RTC 出厂没校准过,偏慢,日均 −3.1 ppm;B 设备偏快,+2.4 ppm。两台设备都是每天一次 NTP 阶跃校正。故障那天的经过还原出来是这样的:
- A 设备开机后从没成功同步过(工地网络不通),RTC 停在出厂基准,跑了一天,慢了 4 分 28 秒。运维在下午手动用维护口设置了时间------设的是本地时间,但设备时区是 UTC,一设就差了 8 小时(当地东八区)。当天晚上网络恢复,NTP 阶跃校正把这 8 小时一步拉了回来。日志里,下午之后的条目全部带着 +8 小时的错误时间戳,阶跃那一刻,时间戳从"17:31"跳回"09:31"。
- B 设备一直同步正常,但阶跃校正是"一步到位"的:校正前它的时间已经漂了 +37 秒,阶跃把墙上时间往回拨了 37 秒。这 37 秒里写入的两条日志,时间戳比实际晚了半分钟。
两个故障叠在一起,就是技术支持看到的那条错位 3 小时零 1 分的时间线。这个案子让我把校时策略完整重构了一遍,重构的结论就是这一章的内容。
7.2 SNTP 采样:offset 和 delay 的四个时间戳
SNTP 的报文交互是四次时间戳:
text
客户端 服务器
T1 (发送) ───────────────────────────▶
T2 (接收) ◀─────────
T3 (发送) ──────────▶ T4 (接收)
T1:客户端发送请求的本地时刻T2:服务器收到请求的服务器时刻T3:服务器发送响应的服务器时刻T4:客户端收到响应的本地时刻
偏移和延迟的计算公式,RFC 4330 里给了:
text
offset = ((T2 − T1) + (T3 − T4)) / 2
delay = (T4 − T1) − (T3 − T2)
delay 是网络往返耗时(假设对称),offset 是客户端相对服务器的偏差。offset 才是校时的依据,delay 是样本质量的下注 ------delay 越大,offset 越不可信。sntp_calc 的实现就这两行,没什么花样:
c
/* sntp_sync.c */
void sntp_calc(const sntp_sample_t *s, int64_t *offset_ns, int64_t *delay_ns)
{
*offset_ns = ((s->t2 - s->t1) + (s->t3 - s->t4)) / 2;
*delay_ns = (s->t4 - s->t1) - (s->t3 - s->t2);
}
真正容易错的是单位换算。SNTP 报文里的时间是 64 位定点数:高 32 位是自 1900 年起的秒数,低 32 位是小数。换算成 Unix 纳秒,两个坑我都踩过:
c
/* sntp_sync.c */
int64_t sntp_ts_to_unix_ns(uint64_t ntp_ts)
{
uint32_t sec = (uint32_t)(ntp_ts >> 32);
uint32_t frac = (uint32_t)(ntp_ts & 0xFFFFFFFFu);
/* 秒字段相减必须走有符号:1970 之前的时刻 unsigned 相减会回绕。
* 小数部分四舍五入到最近纳秒,否则截断会让往返测试差 1 ns。 */
return ((int64_t)sec - (int64_t)SNTP_EPOCH_OFFSET) * 1000000000LL
+ (int64_t)(((uint64_t)frac * 1000000000ull + 0x80000000ull) >> 32);
}
坑一:秒字段相减的回绕。 1900 到 1970 之间的时刻(服务器时间配错、纪元搞错都可能出现),秒字段小于 2208988800。(uint32_t)sec − 2208988800u 在无符号下回绕成一个巨大的正数,时间直接跳到 2106 年之后。必须转成 int64_t 再减。
坑二:小数截断的往返误差。 定点小数转纳秒,frac × 1e9 / 2^32 截断丢弃小数部分;反方向 nsec × 2^32 / 1e9 也截断。一去一回,同样的小数在两个方向各丢一点,往返测试就对不齐 1 ns。两个方向都改四舍五入(+ 0x80000000 >> 32 和 + 500000000 / 1e9)之后,test_sntp.c 的往返断言才消停。1 ns 当然无所谓,但这个测试的真正价值是证明你的换算没有系统性偏差------有系统性偏差的时候,差的可就不是 1 ns。
7.3 三种校时策略:step、slew 和 discipline
拿到 offset 之后怎么办?这是整个校时逻辑的决策核心,sync_decide 把策略固化成一张表:
c
/* sntp_sync.c */
sync_action_t sync_decide(int64_t offset_ns, int64_t delay_ns, int ever_synced)
{
if (delay_ns > 100000000LL) {
return SYNC_NONE; /* RTT>100ms:路径不对称,样本不可信 */
}
if (!ever_synced) {
return SYNC_STEP; /* 从未同步过:阶跃,没有历史包袱 */
}
if (offset_ns >= 100000000LL || offset_ns <= -100000000LL) {
return SYNC_STEP; /* 超差 100ms:slew 追不上,阶跃 */
}
return SYNC_SLEW; /* 其余平滑校正 */
}
三个动作的语义:
step(阶跃) :clock_settime 一步把时间设到目标值。时间戳出现一次不连续,前后两条日志的时间差可能是负的。适用场景:首次同步(没有历史连续性要维护)、超差大到渐进追不上、或者时间完全不可信(时区配错那种 8 小时错误)。
slew(渐进) :adjtimex(ADJ_OFFSET),让内核按最大 500 ppm 的速率把时间"赶"到目标值。500 ppm 是什么概念?校正 1 秒需要 33 分钟 (1 / 500e-6 = 2000 s)。这决定了 slew 的能力边界:offset 超过大约 30 分钟,slew 要追 40 小时以上,追的过程中 offset 还在被晶漂持续拉大------追不上,必须 step。100 ms 的阈值是我留的工程余量:网络抖一下、样本稍差,offset 冲到 100 ms 以上,渐进校正的时间常数会变得很难看,不如一步到位。
discipline(持续驯服) :内核 NTP 服务(ntpd/chronyd)干的事------持续采样、滤波、用 PLL 调节频率而不仅仅是偏移。嵌入式设备一般够不着这么完整,但思想要继承:校时不是一次性的"对准",是持续的"驯服"。每次同步只处理 offset 的一部分,剩下的让频率跟着补,时钟才会越用越准。纯 step 的时钟,两次校正之间永远在自由漂移,日志间隔的精度上限就是晶漂。
应用侧的执行,sync_apply 把两个动作都落到了 adjtimex 上:
c
/* sntp_sync.c 摘录 */
tx.modes = ADJ_SETOFFSET | ADJ_NANO;
tx.time = now; /* ADJ_NANO 时 time 为 struct timespec */
return adjtimex(&tx);
/* ... */
tx.modes = ADJ_OFFSET | ADJ_NANO; /* 内核按 500 ppm 渐进 */
tx.offset = offset_ns; /* 纳秒,带符号 */
return adjtimex(&tx);
注意 dry_run 参数。这个不是我谨慎过度------第一版代码我直接在开发机上跑,一次测试把系统时钟拨快了两小时,容器里的证书全部"过期",重建环境花了半天。任何会碰系统时钟的代码,先在 dry-run 模式下把决策表跑一遍 ,test_sntp.c 的决策表测试就是这么设计的。
7.4 决策的边界情况
决策表看着简单,落地时每个分支都有故事。
"从未同步过"的判断。 ever_synced 不能存在内存里(重启就丢),要存备份寄存器或文件系统。但存了也有陷阱:备份域掉电丢失,设备重启后 ever_synced = 0,一次普通的网络抖动就触发 step------日志里每隔几天一次时间跳变。更稳的做法是 ever_synced 结合"当前时间是否合理":从 RTC 读出的时间离 now 超过阈值(比如 1 年),才允许 step。
RTT 阈值怎么定。 100 ms 是我给的默认值,依据是:本地 NTP 服务器 RTT 通常在 1~10 ms,跨公网上公网 NTP 在 20~50 ms。超过 100 ms 说明路径上出现了队列积压,offset 测量的对称性假设已经不成立------路径不对称时,公式算出来的 offset 混进了单向延迟差,可能是正的也可能是负的,噪声当信号用了。移动网络、卫星链路的设备要把这个阈值放宽,但要配套多采样取中位数。
step 之后的单调性修复。 step 是唯一允许时间倒退的操作,倒流会让第 5 章的单调时钟钳位起作用(rtc_mono 的钳位逻辑保证单调轴不倒退),超时判断不受影响。但日志时间戳会倒流------第 9 章讲怎么用序列号补这个洞。我的习惯是 step 之后在系统日志里落一条显式记录:"time stepped by −37 s, source=sntp, reason=offset>100ms",事后排查时这条记录就是时间线错位的免责声明。
MCU 上没有 adjtimex。 Linux 的办法在 RTOS 上不存在,step/slew 要自己实现。step 就是写 RTC(走第 3 章的"备份旧值→写新值→回读校验"安全流程);slew 就是第 2 章的毫秒累加器------offset 折算成毫秒目标量,按 offset/持续时间 的速率喂给累加器,攒够粒度施加一次。内核用频率调节实现 slew,MCU 用软件施加实现,数学等价:都是把一次跳变摊成一系列小步。
7.5 GPS 授时:另一条时间通路
有 GPS 模块的设备,校时多一条路。GPS 授时的质量和 NTP 完全不是一个量级:GNSS 接收机解算出来的 UTC 精度在 ns 级(相对 GNSS 时间),1PPS 输出的沿抖动在 10 ns 级。用它做参考源,前面所有"offset 可信度"的讨论都要重写。
但 GNSS 有 GNSS 的坑,按踩过的频率排:
- 闰秒。 前面第 4 章说过,GNSS 跟踪的 UTC 含闰秒,模块输出给 MCU 的时候是哪种语义,看数据手册的"UTC output"字段。RMC 报文里的 UTC 时间是扣过闰秒的,直接用没问题;但如果你拿 GPS 周内秒(TOW)自己算 UTC,闰秒数(当前 18 秒)必须从报文里取,写死 18 秒的设备在下一个闰秒插入时会集体差 1 秒。
- 定位质量。 没定位之前(冷启动、室内、天线被遮挡),模块输出的是"预测位置+保持时间",UTC 可能已经飘了几十毫秒到几秒。校时前必须检查定位状态和卫星数,拿保持模式的输出校时,是把误差请进来。
- 天线和安装。 金属机箱里贴个天线、走线挨着电源线、天线面朝下------我见过因为天线被安装在金属配电箱内侧,设备 UTC 一天飘 40 ms 的案例。GNSS 天线是射频器件,安装位置按射频规矩办。
- 1PPS 对齐。 用 1PPS 校时的标准做法:串口报文给出"整秒边界即将到来"的提示,MCU 用定时器输入捕获 1PPS 的沿,配合报文里的 UTC 秒值打时间戳。只用报文里的时间(串口延迟几十毫秒抖动)不用 PPS,精度损失两个数量级。
GPS 和 NTP 的分工我的建议是:GPS 做频率基准(驯服晶体的长期漂移),NTP 做快速对齐(补偿跳变)。设备有 GPS 时,晶漂被持续校准,RTC 的累积误差很小,NTP 只需要处理小 offset,slew 就够;没 GPS 的设备,NTP 承担全部校正任务,超差时 step 不可避免。
7.6 小结
校时一章的账:offset/delay 四个时间戳,delay 决定样本可信度,RTT 超 100 ms 丢弃;首次同步、超差 100 ms 用 step,其余 slew;slew 上限 500 ppm,1 秒要追 33 分钟,大 offset 必须 step;step 是唯一允许时间倒退的操作,前后要有日志声明,单调时钟要能兜住;GNSS 授时精度高但注意闰秒、定位质量和天线安装,拿 GPS 驯服频率、NTP 快速对齐是稳的组合。下一章讲时间的"人间尺度"------时区、DST 和 2038。
第 8 章 人间尺度:时区、DST 与 2038
8.1 巴西政变:时区规则是会改的
2018 年 11 月,巴西把夏令时的开始日期从"11 月第一个周日"改成了------什么都不改,年份改。2019 年的夏令时结束日,巴西政府一纸 decree 从 2 月 24 日挪到了 2 月 17 日。我们有一批 2018 年出货的设备和这个国家的一个客户签了长期数据服务合同,设备里的 DST 规则按当年的规则编译固件:BRST 结束日按"2 月第三个周日"算,落在 2 月 24 日。
结果 2019 年 2 月 17 日零点,客户的监控大屏上,所有巴西现场设备的时间集体比真实时间快了 1 小时,持续整整一周。数据归档、报表对账、事件排序全部错位。这不是 bug,是"规则过期"------固件里写死的 DST 规则,跟不上人类改主意的速度。
这个案例是本章所有内容的出发点:时区和 DST 不是物理量,是人类约定,而人类会改约定。你的代码怎么处理"约定",决定了改约定的时候你是在升级固件还是在烧香。
8.2 POSIX TZ 字符串:符号是反的
嵌入式设备存时区信息,业界标准做法是 POSIX TZ 字符串------IANA tz 数据库(/usr/share/zoneinfo)几百 KB 到几 MB,MCU 存不起也更新不动,POSIX TZ 字符串几十字节,还能随固件或配置下发。格式:
text
STD[±]hh[:mm[:ss]][DST[±]hh[:mm[:ss]][,start[/time],end[/time]]]
中国标准时间:CST-8。美国东部:EST5EDT,M3.2.0,M11.1.0。中欧:CET-1CEST,M3.5.0,M10.5.0/3。
第一个坑:偏移符号是反的。 POSIX 规定,正数表示本初子午线以西 。所以东八区写 CST-8------带负号。ts_tz_parse 里把这个反转写进了注释和实现:
c
/* tslib.c */
static int parse_offset(const char **p, int32_t *out_secs)
{
/* ......解析符号和 hh[:mm[:ss]]...... */
/* POSIX 符号反转:正数在本初子午线以西,即 UTC 偏移取负 */
*out_secs = -(int32_t)(sign * (h * 3600 + mi * 60 + se));
*p = s;
return 0;
}
解析结果统一存成"UTC 以东为正"的秒数:CST-8 解析出 +28800,EST5EDT 的标准时部分解析出 -18000。test_tslib.c 的 TZ 用例把这些锚点全钉了一遍:
text
TZ offset -8 -> UTC+-28800 s
TZ offset +1 -> UTC+3600 s
TZ offset -5 -> UTC+-18000 s
TZ offset +5:30 -> UTC+19800 s /* 印度,非整点偏移 */
TZ offset -3 -> UTC+-10800 s
TZ offset -9:30 -> UTC+-34200 s /* 澳洲达尔文,半小时 */
非整点偏移(印度 +5:30、纽芬兰 −3:30、澳洲中部 +9:30)是最容易在"我寻思时区都是整点"的手写代码里翻车的,hh[:mm[:ss]] 的三级格式一个都不能省。
8.3 DST 规则:Mm.w.d、Jn 和 n
DST 的开始结束规则有三种写法:
Mm.w.d:m 月第 w 个星期 d。M3.2.0= 3 月第二个周日(美国 DST 开始),M11.1.0= 11 月第一个周日(结束)。最常用,因为"第几个星期几"对人类最好懂。Jn:非闰年的第 n 天,跳过 2 月 29。J60恒指 3 月 1 日(非闰年)。规则不受闰年影响,适合"固定日期型"的规则。n:第 n 天,0 起算,包含 2 月 29。闰年里n的解读会平移一天,历史上踩出过事故,新代码不建议用。
/time 是跳变发生的当地时间,缺省 02:00:00。欧盟规则 M10.5.0/3 就是"10 月最后一个周日凌晨 3 点"------欧盟所有时区同时跳变(按 UTC 对齐),所以各时区的本地跳变时间不同。
解析和规则展开的代码在 ts_tz_parse 和 rule_local_secs,这里不全文贴,说三个实现里最容易被写错的地方。
第一,M 规则里的"最后一个"(w=5)。 M10.5.0 是 10 月最后一个周日,不是"第 5 个周日"------10 月不一定有 5 个周日。实现必须按"当月末日倒推回最近的周日"算,不能按 1 + (w−1)×7 算。欧盟 DST 结束规则全是 w=5,写错这个,一年错 4 周。
第二,DST 结束时刻的墙上时间语义。 这是整个 TZ 模块里最深的一个坑,我单独讲。POSIX 规定规则里的 /time 是跳变前的墙上时间 。DST 开始时,跳变前墙上时间是标准时,折算成 Unix 秒:start_unix = 规则时刻 − off_std,没坑。但 DST 结束 时,跳变前墙上时间是夏令时!同一时刻折算成 Unix 秒,基准要换成 off_dst:
c
/* tslib.c 摘录 */
static int64_t rule_local_secs(int year, uint8_t m, uint8_t w, uint8_t d,
int32_t secs, int32_t adj)
{
/* ......按规则算出日序 day...... */
return (y0 + day) * 86400LL + secs - adj;
}
/* adj: POSIX 规则里的 /time 是跳变前的墙上时间;结束规则发生时墙上还是 DST,
* 折算到标准时要减去 (off_dst - off_std);开始规则发生时墙上是标准时,adj=0。 */
调用处:开始规则传 adj = 0,结束规则传 adj = off_dst − off_std(通常 3600)。少了这个 adj,结束跳变点会被算晚 1 小时------那一小时里设备的本地时间全错。我的第一版实现就漏了它,test_tslib.c 的"fall-back"用例第一次跑直接失败,才有注释里那段说明。
第三,判断"当前是否 DST"要跨年查。 ts_tz_offset_at 的做法是把"今年和去年的开始/结束跳变点"全算出来,排序后数当前位置落在哪个区间:
c
/* tslib.c 摘录 */
int64_t ts_tz_offset_at(const ts_tz_t *tz, int64_t utc)
{
if (!tz->has_dst) return tz->off_std;
int64_t base = utc + tz->off_std; /* 当地标准时名义时刻 */
ts_civil_t c;
ts_utc_gmtime(base, &c);
/* 取今年与去年的四(或更少)个跳变点,升序遍历,落在 start 后即处于 DST */
int64_t t[4];
int is_start[4];
int n = 0;
int32_t dst_gap = tz->off_dst - tz->off_std; /* 结束规则按墙上时间要换算 */
for (int yy = c.year - 1; yy <= c.year; yy++) {
int64_t s = rule_local_secs(yy, tz->start_m, tz->start_w, tz->start_d,
tz->start_secs, 0);
int64_t e = rule_local_secs(yy, tz->end_m, tz->end_w, tz->end_d,
tz->end_secs, dst_gap);
if (s != INT64_MIN) { t[n] = s; is_start[n] = 1; n++; }
if (e != INT64_MIN) { t[n] = e; is_start[n] = 0; n++; }
}
/* ......排序后遍历,最后更新 in_dst...... */
return in_dst ? tz->off_dst : tz->off_std;
}
为什么查两年?1 月 1 日附近的时刻,DST 状态可能由去年 12 月的规则决定;南半球更明显,DST 在 12 月~2 月之间,"今年"的 DST 区间横跨两个日历年。巴西规则 M2.3.0(结束)和 M11.1.0(开始)之间隔着小半年,只查当年会把 1 月的设备误判成标准时。
规则正确性靠 test_tslib.c 的锚点验证,覆盖了 US 2026、EU 2026、BR 2018/2019、CN 1986:
text
US 2026 DST start: 2026-03-08 (expect 2026-03-08)
US 2026 DST end : 2026-11-01 (expect 2026-11-01)
EU 2026 DST start: 2026-03-29 (expect 2026-03-29)
EU 2026 DST end : 2026-10-25 (expect 2026-10-25)
BR 2018 DST start: 2018-11-04 (expect 2018-11-04)
CN 1986 2nd Sun Apr: 1986-04-13 (actual first DST start 1986-04-13)
中国 1986~1991 年实行过夏令时,4 月中旬开始、9 月中旬结束。如果你们有 1991 年之前的历史数据要做时区还原,CST-8 一条规则覆盖不了,只能按 IANA 数据库处理历史数据------规则型 TZ 字符串只能描述"当前有效的规则",历史规则它管不了,这是 POSIX TZ 的固有边界。日志归档前把本地时间统一换算成 UTC 存,是绕开这个边界唯一的办法。
8.4 2038:一个必须现在处理的未来故障
第 4 章说过数字,这里说故障形态。2038-01-19 03:14:07 UTC,32 位 time_t 溢出到 INT32_MIN,时间变成 1901 年。具体到嵌入式系统,那一天会发生什么,取决于你的代码怎么写:
- 超时判断全部失效。
now > deadline的比较,deadline 是 2038 年之后的时刻,now溢出成 1901 年,比较结果永远为假------看门狗、重传、生命周期管理,全部静默失效。 - 时间差变成负数或巨数。
t1 - t0溢出,间隔判断反向,周期任务要么疯跑要么停跳。 - 日志和文件名排序错乱。 用时间戳做后缀的日志文件,溢出前后排序倒挂,归档脚本全乱。
- 证书和授权到期判断。 有效期是 2039 年的证书,在 2038 年之后"已经过期",TLS 握手失败。
对策按优先级排:所有时间运算用 64 位类型 (int64_t,time_t 在 64 位平台上天然 64 位,但要确认工具链没有 -m32);和外部接口(网络协议、文件格式)交互时显式处理 32 位时间字段 ,RTC 计数器是 32 位的平台(F1 那种),在 2106 年前做一次世纪扩展;给长周期设备加"时间上限自检":启动时检查系统时间离 2038 年不到 2 年时上报预警,留出固件升级窗口。最后这条是我最推荐的------2038 年不是"到那天再说"的事,是"现在就该写进发布检查清单"的事。
8.5 时区数据怎么管
最后一节讲运维。设备里的时区信息有三个来源,可靠性依次降低:
- 配置下发。 时区字符串(
CST-8、EST5EDT,M3.2.0,M11.1.0)随设备配置从服务器下发,改规则不用动固件。这是我推荐的方式------巴西 decree 那种变更,服务器改一行配置,设备下次上线拉到新规则,成本接近零。 - 编译进固件。 巴西案例的原始做法。规则变更 = 固件升级 = 现场全部设备刷新,除非本来就有 OTA 机制且客户配合。
- 用户手动设置。 维护口设置时区。最灵活也最危险------设置成错的(巴西案例里差 8 小时那次)没人发现,直到下一次 NTP 校正把它拉回来,而拉回来之前写出去的数据全是错的。
配套的一条纪律:系统内部一律 UTC,只在输出层转本地时间。日志文件、网络报文、数据库、RTC 寄存器,全部 UTC;时区转换只发生在"给人看的最后一步"(屏幕显示、报表导出)。这样时区配置错误影响的是显示,不会污染数据;反过来先存本地时间再换算 UTC,一次配置错误就把历史数据全毁了。
8.6 小结
人间尺度一章的账:POSIX TZ 字符串是嵌入式时区的标准载体,偏移符号反、非整点偏移常见、三种 DST 规则格式都要支持;DST 结束规则的墙上时间语义要减 off_dst − off_std,这是最容易漏的一个坑;判断当前 DST 要跨年查跳变点;POSIX TZ 描述不了历史规则,历史数据一律存 UTC;时区配置走下发不烧固件;2038 的对策是 64 位时间运算加时间上限自检。下一章是实战案例的最后一章------时间戳怎么落到文件系统和日志里。
第 9 章 落到盘上:文件系统与日志时间戳一致性
9.1 一场因为时间戳打错的数据官司
最后一个实战案例,是我见过损失最大的一个。
客户是物流公司,我们的采集器装在分拣线上,记录每个包裹经过传感器的时间戳,数据每天打包上传,客户拿它和分拣系统自己的记录对账。运营半年后的一次盘点,客户发现我们提供的数据里,有大约 0.3% 的包裹时间戳和他们的记录差了几分钟到几小时,对账系统判定这些数据"不可信",要求赔偿一个季度的服务费。
排查过程像剥洋葱。时间戳链路是这样的:传感器触发中断 → 应用取当前时间 → 写内存缓冲 → 落盘 → 打包上传。每一层都可能引入误差,逐层查:
第一层:取时间的调用不统一。 代码里有三处取时间的地方------中断里直接读 RTC 寄存器,主循环里调用 time(NULL),通信线程里用 gettimeofday。RTC 是 UTC,time(NULL) 返回的本地时间(容器镜像的 TZ 是 America/New_York),gettimeofday 又是 UTC。同一个包裹,从不同代码路径过一遍,时间戳能差出 13 个小时。0.3% 的差异率,对应的是"某些型号的触发走了中断路径、某些走了主循环路径"。
第二层:落盘延迟。 内存缓冲攒够 64 KB 才 flush,传感器突发时缓冲里前后两条记录相差几秒是常事------这个误差客户能接受,但叠加上第一层的时区混乱之后,误差变成不可预测的。
第三层:文件时间戳。 FAT 文件系统的时间戳只有 2 秒分辨率,而且FAT 存的是本地时间,没有时区信息。设备在东八区,文件创建时间是本地时间;上传到服务器后,服务器按 UTC 解释------又一层混乱。0.3% 里对不上的部分,有相当比例是"文件时间戳被当成了数据时间"的上层脚本错误。
修复方案三步:所有取时间收敛到一个 time_service_now() 函数 ,内部只返回 UTC Unix 秒,全项目禁止直接调 time()/gettimeofday()/读 RTC 寄存器;日志记录改成"UTC 墙上时间 + 单调序列号"双字段 ,排序用序列号,展示用墙上时间;上传时把文件时间戳显式声明为 UTC 并转换,不让下游猜。官司最后和解了,但那次之后我把"时间来源统一"列为代码评审的强检项。
9.2 日志时间戳的三个字段
一个日志条目应该带几个时间字段?我的答案是三个,多一个嫌多,少一个出事:
c
/* log_entry.h ------ 一条日志的最小时间字段集 */
typedef struct {
uint64_t seq; /* 单调递增序号,掉电后从备份寄存器恢复 */
int64_t utc_ns; /* 墙上时间,UTC,纳秒精度 */
uint64_t mono_ms; /* 单调时钟,开机起算,用于间隔分析 */
} log_time_t;
三个字段各司其职,谁也替代不了谁:
seq 管排序。 墙上时间会被 NTP 校正、会被手动设置,唯一不会骗人的是序号。所有"事件先后顺序"的判断、日志归并、乱序检测,用 seq。实现上一个进程内递增计数器,关键场景(跨重启的连续性要求)掉电前存备份寄存器,恢复后接着走。计数器溢出是 64 位的事,不用管。
utc_ns 管人类可读。 给人看、跨设备对账、和外部系统对齐,用它。UTC 存储、本地展示的原则第 8 章说过,这里不重复。
mono_ms 管间隔。 "从上次上报到这次上报过了多久""这次重启距上次重启运行了几天"这类问题,墙钟回答不了(中间可能被校正过多次),单调时钟回答得干干净净。第 5 章的 rtc_mono 就是它的 MCU 版本。
反面教材是只用墙上时间戳的日志:一次 step 校正之后,日志时间线出现一个"倒退段",下游按时间排序的归并系统会把这段数据插到错误的位置,因果颠倒。加一个 seq 字段,成本是每条日志 8 字节,收益是排序逻辑再也不受校时影响------这是我用过性价比最高的一笔交易。
9.3 文件系统的坑:2 秒分辨率和本地时间语义
如果数据要落到文件系统,有两个和文件系统本身相关的坑,不分嵌入式还是 Linux。
FAT 的时间粒度是 2 秒。 FAT 目录项里创建/修改时间的秒字段只有 5 位(0~29),实际秒数 = 字段值 × 2。同一个文件连续两次写入,间隔小于 2 秒,mtime 完全一样。拿文件 mtime 做数据排序、做增量同步的判断依据,必然丢数据。规则:文件时间戳只用于人类参考,数据时间戳必须写在数据里(就是上面那三个字段)。ext4 有纳秒时间戳,但它掉电后的元数据一致性比 FAT 更依赖日志机制,嵌入式上选哪个第 11 章展开。
FAT 不存时区。 文件时间戳按"本地时间"解释,同一个 U 盘在东京的电脑和纽约的电脑上显示出不同的时刻。所以设备端写 FAT 时间戳时,要么统一写 UTC(并确保下游知道),要么干脆接受本地语义、只在数据里存 UTC 时间。我的选择是后者:文件时间戳按本地时间写(运维在设备旁边看文件列表时直观),数据内部一律 UTC。
掉电安全。 时间相关的数据没落稳就掉电,比时间错更可怕------日志出现"序号连续但 UTC 倒退"或者反过来。对策还是老三样:写入走日志型/写时复制文件系统(或掉电安全的分层设计)、关键日志(校时记录、step 声明)立即 flush、启动时扫描修复。第 12 章的检查清单里有完整条目。
9.4 日志轮转:别用时间当唯一键
日志文件名用时间戳(log_20261017.txt)是常见做法,但它和校时 step 是天敌:step 把时间往回拨,下一次轮转检查"当前日期变了没",发现日期"变回了昨天",于是接着往昨天的文件里写------两个时间段的数据混进一个文件;或者时间被往前调,直接跨过一个文件名,中间的文件不存在,归档脚本按文件名扫一遍全漏。
正确做法是时间 + 序号双键轮转 :文件名带序号(log_000123.txt),序号单调递增,每个文件内部记录起止 UTC 时间和 seq 范围。轮转触发条件可以是时间(每天)也可以是大小(10 MB),但文件名和排序永远认序号。归档时按文件内记录的范围建立索引,时间跳变影响的只是索引里的元数据,不是数据本身。
9.5 小结
日志一章的账:全项目取时间收敛到一个 UTC 入口,中断、主循环、通信线程不许各读各的;日志条目带 seq + UTC + mono 三个字段,排序用 seq、展示用 UTC、间隔用 mono;FAT 时间戳 2 秒粒度且无时区,只配当人类参考;文件系统掉电安全靠日志型文件系统和关键记录立即 flush;日志轮转用序号当键,时间只当元数据。到这里,原理和实战都讲完了,第三部分把散落各章的代码拢到一起,看它们怎么拼成一套东西。
第三部分 代码分析
第 10 章 把上面的东西拼起来:模块结构与测试
10.1 模块地图
前九章的代码散在各处,拢在一起其实是一套很小的库,五个核心模块加两个平台相关文件:
| 模块 | 文件 | 职责 | 依赖 |
|---|---|---|---|
| tslib | tslib.c/h |
BCD、日历互转、POSIX TZ | 无(零 libc 时间函数) |
| tempcomp | tempcomp.c/h |
温补表插值、毫秒累加器 | 无 |
| rtc_mono | rtc_mono.c/h |
跨 STOP 单调时钟 | tslib(STM32 分支) |
| sntp_sync | sntp_sync.c/h |
SNTP 采样、step/slew 决策 | 无(adjtimex 仅 Linux 分支) |
| tickless_comp | tickless_comp.c/h |
休眠 tick 补偿 | 无 |
| STM32 备份域 | stm32/rtc_stm32f1.c、stm32/rtc_stm32l4.c |
寄存器级初始化与读写 | tslib |
依赖关系刻意做成单向的:tslib 谁也不依赖,是地基;其余模块只准往上看,不准回头拉。这样移植的时候可以按需裁剪------纯 MCU 项目去掉 sntp_sync 的 Linux 分支,Linux 项目整个 stm32/ 目录删掉,互不影响。
10.2 各模块的设计决策回顾
代码前面都逐段讲过,这里只把"为什么这么设计"压缩成决策清单,移植时这些是不能动的骨架:
tslib :日历换算用 Hinnant 算法(无表、无循环、正负对称);负数取整必须修正;time_t 全程 int64_t;TZ 解析输出统一"UTC 以东为正";DST 结束规则减 off_dst − off_std;判断 DST 跨年取跳变点。允许改的:表大小、结构体字段宽度。
tempcomp :分段线性插值、端点钳位不外推;零头以毫秒累加、500 ms 粒度施加;acc_ms 符号约定"正 = 钟快待拨回";整数运算无浮点。允许改的:施加粒度(50~1000 ms 视你的 RTC 校准能力)、表密度。
rtc_mono :RTC 次秒寄存器(SSR 递减转递增)当底座;单调钳位防校时回调;间隔计算一律无符号相减。不能改的是钳位------去掉钳位,这个模块的名字就是撒谎。
sntp_sync :offset/delay 四时间戳公式;NTP 64 位定点秒换算走有符号、四舍五入;决策表 RTT 100 ms / offset 100 ms 两档阈值;step/slew 之外的一切校正交给上层驯服逻辑。dry_run 是开发机安全阀,别删。
tickless_comp :零头单位 tick·cycle;64 位整数累加;输入必须是实际读到的 RTC 周期数。这个模块整个都是"结构性正确"的,改动任何一处都要重跑 20 万次随机回归。
10.3 编译验证:在没有 gcc 的机器上
本机开发环境是 Windows + Git Bash,没有 gcc、没有 clang、没有 arm-none-eabi。验证 C 代码我用的是 Zig 工具链自带的 C 前端------ziglang 装进 Python venv 之后,python -m ziglang cc 就是个体格完整的 clang:
shell
# 主机端:编译全部测试
python -m ziglang cc -std=c11 -Wall -Wextra -Wpedantic -O2 \
-o test_tslib.exe test_tslib.c tslib.c
python -m ziglang cc -std=c11 -Wall -Wextra -Wpedantic -O2 \
-o test_sntp.exe test_sntp.c sntp_sync.c
# ......其余同理,一次编译链接所有源文件
有一个坑记录在此:zig cc 在 Windows 上链接多个 .o 会报 coff does not support linking multiple objects,必须一次编译链接全部源文件,别指望分步 .o 再链。另外 -Wall -Wextra -Wpedantic 全程零警告,是这套代码的准入线------时间库里有 warning,多半就是 bug 在敲门。
STM32 侧用交叉编译做语法和类型检查,不需要真板:
shell
# 目标端:thumb-freestanding 交叉编译检查(cortex-m4)
python -m ziglang cc -target thumb-freestanding-eabi -mcpu=cortex_m4 \
-ffreestanding -std=c11 -Wall -Wextra -O2 -c stm32/rtc_stm32l4.c
寄存器地址、位定义全部按参考手册手写,交叉编译能抓住类型错误和笔误,但抓不住"你抄错了寄存器偏移"------上板前对照具体型号的 RM 逐位核对该核的还是要核。两个源文件的头注释里都写了这句警告,不是客套。
10.4 测试体系:四个套件钉住四类风险
make run 一条命令跑完全部回归。四个套件各管一类风险,设计逻辑是"每类算法都有一个能自动判错的锚点":
test_tslib ------ 防日历算法翻车。 已知锚点(2026-10-17 = 1792195200、2038-01-19 03:14:07 = 2147483647、2106-02-07 = 4294967296)+ 和 glibc timegm/gmtime_r 对照 25 万组随机日期 + TZ 边界(US/EU/BR/CN 的 DST 跳变日、符号反转、非整点偏移):
text
[tslib] 250000 random dates vs naive: 0 mismatches
[tslib] RESULT: ALL PASS
test_tempcomp ------ 防温补算错方向和插值失真。 节点精确命中(插值在表节点上必须等于表值)、−40.0~85.0 °C 每 0.1 °C 扫描的插值误差、−20 °C 喂 4 小时的行为断言(2 次 +500 ms、残余 −8 ms):
text
[tempcomp] nodes exact: YES
[tempcomp] worst interp error: 2 ppm (0.17 s/day)
[tempcomp] -20C 4h -> 2 actions of +500 ms, residual -8 ms
[tempcomp] RESULT: ALL PASS
注意第三条:它不仅验证数值,还验证方向------钟慢要拨快(正 delta)。我第一版符号写反,就是这条用例抓住的。
test_tickless ------ 防休眠补偿漏账。 整秒睡眠 10 万次、1.5 秒睡眠 10 万次(验证非整除组合)、随机 28~32 秒睡眠 20 万次,全部断言精确相等:
text
[tickless] 100000 x 1s -> ticks=100000000 (expect 100000000)
[tickless] 200000 random sleeps -> ticks=6000157000 expect=6000157000 err=0
[tickless] 100000 x 1.5s -> ticks=150000000 (expect 150000000)
[tickless] RESULT: ALL PASS
test_sntp ------ 防校时决策和单位换算出错。 对称/非对称路径的 offset/delay 解析值、决策表全分支、NTP 64 位 ↔ Unix 往返、dry-run 决策输出:
text
[sntp] symmetric: offset=0 ms delay=20 ms
[sntp] asymmetric: offset=-10 ms delay=30 ms
[sntp] decision table: OK
[sntp] NTP<->Unix conversion: OK
[dry-run] action=SLEW offset=50 ms
[dry-run] action=STEP offset=500 ms
[sntp] RESULT: ALL PASS
非对称用例(offset=−10 ms、delay=30 ms)验证的是"单向延迟差会被算进 offset"这一现象本身------不是 bug,是公式的固有属性,测试把它钉住,提醒你 RTT 阈值的必要性。
10.5 搬进项目的 checklist
把这套代码搬进你们项目,按这个顺序:
- 先搬 tslib ,把项目里所有
mktime/localtime/手写闰年判断全部替换掉,跑test_tslib对照历史数据。 - 再搬 tickless_comp,接进你们的休眠框架唤醒路径,用真实设备的休眠日志跑一次随机回归。
- 然后 rtc_mono,替换所有"开机 tick + 预测时长"的间隔计算,特别是休眠期间的超时逻辑。
- tempcomp 和 sntp_sync 按需:有温补需求的上前者,有网络校时的上后者;两者都带 dry-run/测试套件,先在开发机跑通再上目标板。
- stm32/ 两个文件当参考实现,照着的寄存器流程写你们的型号,别直接编译------地址和位定义以你们的 RM 为准。
整个库的代码量:核心实现五个 .c 加起来一千行出头,测试一千行,两个 STM32 文件三百行。这个量级是故意的------时间管理的关键逻辑必须小到能被人肉审计,任何"功能丰富"的时间库都是在给未来挖坑。
第四部分 注意事项
第 11 章 硬件选型与设计准则
11.1 先定义你要的精度等级
选型之前先回答一个问题:你的设备允许时间误差多大? 这个问题没有标准答案,但有对应的档位,每一档有成熟的方案。我按项目经验分了四档:
| 精度档 | 允许误差 | 典型场景 | 推荐方案 |
|---|---|---|---|
| 粗 | ±几分钟/天 | 普通控制、无时间敏感业务 | MCU 内部 LSI/HSI,不配 RTC |
| 中 | ±几秒/月 | 一般数据记录、普通调度 | 32.768 kHz 音叉 + RTC + 软件温补 |
| 高 | ±1 秒/年 | 计费相关、跨设备协同 | 音叉 + 温补 + NTP 定期驯服,或 TCXO |
| 严苛 | ±1 毫秒/年 | 电力同步、金融交易戳 | 外置 RTC 芯片(内置 TCXO)+ GNSS 授时 |
档位定错了,后面全错。我见过给普通温控器配 GNSS 授时的(成本翻三倍,客户并不需要),也见过给计费系统用 LSI 的(每月对账差几分钟,查了三个月查到时钟源上)。精度需求的正确来源是"下游业务能容忍什么",不是"技术上能给什么"。
11.2 时钟源选型:音叉、TCXO 还是外置 RTC 芯片
32.768 kHz 音叉晶体(±20 ppm 典型)是大多数项目的答案。便宜(几分钱)、成熟、软件温补能把月误差压到几秒。适用边界:中精度档。选型时看三个参数:额定负载电容(决定外围匹配)、频率稳定性(±20 ppm 是标配,±10 ppm 贵一点但值)、以及 ESR 和驱动电平(低温起振的余量在这两个参数里)。
TCXO (温度补偿晶振,±0.5~±5 ppm)把补偿电路封装进振荡器内部,不用你做软件温补。适合高精度档且没有网络校时条件的设备。代价是价格(音叉的几十倍)、功耗(mA 级,电池供电设备直接出局)、以及老化(每年 1~3 ppm,长期设备要计入账)。电池供电 + 高精度的组合,TCXO 基本不可行------功耗这一条就过不去,这种需求只能走"音叉 + 软件温补 + GNSS 驯服频率"。
外置 RTC 芯片 是另一条路。几款常见的:
| 芯片 | 时钟源 | 精度 | 备份电池 | 特点 |
|---|---|---|---|---|
| DS3231 | 内置 TCXO | ±2 ppm(0~40 °C) | CR2032,独立电池座 | 精度高,I2C,带两个闹钟,贵 |
| PCF8563 | 外接 32.768 kHz 音叉 | ±10~20 ppm(靠晶体) | 外部备份 | 便宜,I2C,功能少 |
| RV-8803 | 内置 TCXO | ±3 ppm | 外部备份 | 低功耗,小封装 |
DS3231 这类内置 TCXO 的芯片,把"温补"这件事从你的固件里挪到了硅片里,±2 ppm 一年也就 1 分钟出头,配上几年一换的电池,是"设置一次忘了它"的体验。它的坑在电池管理:备份电池座要防反、电池电压监测脚要接(没接的话电池没电了你不知道,时间悄悄归零)、以及 I2C 总线在备份电源下的行为(有些芯片 VBAT 下 I2C 不工作,读不了时间)。
MCU 内置 RTC + 外置音叉 vs 外置 RTC 芯片,我的决策树:主控确定且备份域设计完善(VBAT 网络干净、有 PVD),用内置的,省一颗芯片;主控不靠谱(备份域漏电、没有 VBAT、或者停产风险),或者精度要求超过音叉能力,用外置芯片,把时间子系统整个外包。
11.3 设计准则:十一条血的教训
这部分是全文的浓缩,我按硬件到软件排:
- 凡涉及"秒",时钟源必须 LSE、TCXO 或 GNSS。 LSI/HSI 只配做看门狗和 ADC 触发这类绝对精度不敏感的用途。
- 负载电容按
CL = Cstray + (CL1×CL2)/(CL1+CL2)计算,Cstray 按 3~5 pF 估,晶体走线短、远离高速信号、不跨分割平面。 - VBAT 必须有设计:备份电源(超容或电池)、防倒灌、测试点。设备存在任何意外掉电可能时,这条不可协商。
- 投产前实测 VBAT 静态电流。账面上的 µA 级可能被一颗下拉电阻毁掉。
- RTC 初始化分"首次"和"已有备份"两条路径,备份有效时一个字节都不写 RTC------特别是别重写校准寄存器。
- 写 RTC 走"备份旧值→写新值→回读校验",任何非原子寄存器写都要这个模式。
- 温补表用实测,不用数据手册典型值;补偿方向(正 ppm 拨回、负 ppm 拨快)必须有单元测试钉死。
- 时间运算全部 64 位,对外部 32 位时间字段显式处理;记住 2038 和 2106。
- 单调时钟必须有钳位,测间隔、算超时、周期调度只用单调时钟;日志用 UTC + seq + mono 三字段。
- 校时决策表化:RTT 和 offset 双阈值、首次同步用 step、step 必须落日志声明、step 之后单调时钟兜底。
- 系统内部一律 UTC,时区只在输出层转换,时区配置走服务器下发,永远别烧死在固件里。
11.4 成本和精度的账
最后算一笔账,回答"值不值得上高精度方案"。假设一个项目量产 1 万台,每台成本增加 10 元(从音叉升到 DS3231 + 电池座),总成本 10 万。如果设备的时间精度需求是"±1 秒/年",音叉 + 温补 + 年度 NTP 校准可以接近但达不到(网络不可达的设备直接出局),DS3231 不需要网络就能到 ±1 分钟/年------注意,还是到不了 ±1 秒/年。真正 ±1 秒/年的方案是 DS3231 + GNSS 授时,单台成本再加 30~50 元,还得解决天线问题。
所以真实世界的决策经常是这样的:先和业务方确认"±1 秒/年"是不是真的是需求------多数情况问下去会发现,业务真正怕的是"日志对不上",而不是"每秒都要准",那么"音叉 + 温补 + 单调序号"用几千块总成本就解决了,剩下 40 万是纯浪费。把精度需求翻译成业务影响,再把业务影响翻译成技术方案,这个翻译做一次能省一辆车。
第 12 章 故障定位流程与量产检查清单
12.1 定位流程:从症状到根因的决策树
时间故障拿到手,第一件事是分类。我在售后和实验室摸出来的分类法,按"时间相对真实值的关系"分四类,每类的排查路径完全不同:
症状 A:时间系统性偏快/偏慢(偏差单调增长)。 这是频率问题,查晶体侧。
text
偏差单调增长
├── 偏差率恒定? ── 是 ──▶ 负载电容/晶体批次/时钟源配错
│ └── 否(偏差率随温度变化) ──▶ 温补缺失或方向错
└── 与温度强相关? ── 是 ──▶ 温补表错/温度测点耦合差/传感器自热
恒定偏差率先查负载电容(拆晶体测 CL、核算 Cstray),再查时钟源是不是 LSI 被当成了 LSE 用。偏差率随温度变化的,查温补:表是不是实测的、方向有没有反、温度测点是不是贴在了电源旁边。
症状 B:时间跳变(不连续,阶跃)。 这是校时问题,查 step 的来源。
text
时间阶跃
├── 阶跃后与网络时间一致? ── 是 ──▶ 正常校时 step,查日志有无声明
│ └── 否 ──▶ 有人手动设时间(维护口/脚本)
├── 阶跃量与上次同步 offset 吻合? ── 是 ──▶ 校时逻辑,查决策表阈值
└── 阶跃发生在掉电恢复后? ── 是 ──▶ 备份域/RTC 写入,查 magic 和写校验
关键动作:把跳变时刻前后 5 分钟的日志全捞出来,找 step 声明、NTP 同步记录、掉电记录。没有声明的跳变就是 bug------要么是校时没记日志,要么是有人绕过了校时逻辑直接写时钟。
症状 C:时间归零/回到出厂值。 这是备份域问题,查电源。
text
时间归零
├── 备份寄存器 magic 也丢? ── 是 ──▶ VBAT 没电/虚焊/没装/网络漏电
│ └── 否(magic 在,时间丢) ──▶ RTC 停振/时钟源被改
├── RTC 停在某个固定值不动? ──▶ LSE 停振,查起振余量/驱动档/低温
└── 掉电前最后几秒乱走? ──▶ 电源跌落区间 RTC 供电不稳,硬件问题
第一步永远是上电读 magic:magic 丢了,硬件(VBAT 网络)问题,软件救不了;magic 在而时间丢,查 LSE 起振和 BDCR 配置。
症状 D:时间戳之间互相矛盾(排序乱、对不上)。 这是语义问题,查取时间的路径。
text
时间戳矛盾
├── 同一设备不同模块差固定量? ──▶ 多时间来源混用(UTC vs 本地)
├── 同一设备同模块差随机量? ──▶ 中断/主循环取时间路径不一致
├── 跨设备差在小时级? ──▶ 时区配置不一致,查 TZ 下发
└── 跨设备差在分钟级且随机? ──▶ 各设备晶漂 + 校时策略不同
这类问题最隐蔽,因为"每个模块单独看都对"。唯一有效的排查手段是审计取时间的调用点 :全项目 grep 一遍 time(、gettimeofday、clock_gettime、直接读 RTC 寄存器的代码,每个都该收敛到统一入口。审计不过,后面全白干。
12.2 三个工具
定位时间故障,有三个低成本工具建议常备:
工具一:GNSS 1PPS + 逻辑分析仪。 被测设备的 RTC 秒脉冲(多数 MCU 有 RTC_OUT 可配置输出 1 Hz)对到逻辑分析仪一个通道,GNSS 的 1PPS 对另一个,两条沿的间隔就是实时偏差,分辨率取决于分析仪采样率。这是唯一能看到"时间在怎么走"而不是"走了多少"的工具------阶跃、停振、温补跳变全都能在波形上直接看见。我排过最疑难怪的一例:偏差每天 +2 秒但 ±1 秒抖动,示波器上看不出所以然,上逻辑分析仪抓了十分钟,发现温补施加的瞬间秒沿在抖------施加动作写校准寄存器影响了秒脉冲宽度。
工具二:长时间对表脚本。 设备跑着,每小时和一台 NTP 同步过的机器对一次时间,记 CSV。一周的数据画出来,是直线(恒定频偏)、抛物线(温漂)、锯齿(阶跃校时)还是噪声(停振前兆),一眼定性。这个脚本我每个项目都部署,是投产前验证温补效果的标准动作。
工具三:备份域内容 dump。 现场回来的设备,别急着拆,先用调试器把备份寄存器和 BDCR/RTC 寄存器整个读出来存档。magic 在不在、RTCSEL 是什么、计数器值是多少、上次校时写进的备份寄存器是什么------这四个数经常能直接定案。dump 的时机是上电后、任何写操作之前。
12.3 量产检查清单
最后是给量产阶段的清单,硬件、固件、产线三张表,直接可用。
硬件检查表:
| # | 项目 | 判据 |
|---|---|---|
| H1 | 晶体负载电容 | 与设计核算一致(含 Cstray),误差在档位内 |
| H2 | 晶体走线 | 短、不跨地分割、下方无高速线 |
| H3 | VBAT 网络 | 有备份电源、防倒灌、留测试点 |
| H4 | VBAT 静态电流 | 实测 ≤ 设计值 × 1.5 |
| H5 | 低温起振 | −40 °C(或规格下限)上电起振成功率 100%,留 100% 余量 |
| H6 | 温补传感器 | 贴装位置耦合良好,无电源自热干扰 |
| H7 | RTC 输出脚 | 如用 1PPS 对表,确认可输出且负载能力 |
固件检查表:
| # | 项目 | 判据 |
|---|---|---|
| F1 | RTC 初始化 | 首次/已有备份双路径,备份路径零写入 |
| F2 | 写 RTC 安全 | 备份旧值→写→回读校验,路径全覆盖 |
| F3 | 温补方向 | 单元测试断言"钟慢拨快",随 CI 跑 |
| F4 | 单调时钟 | 有钳位;step 校时后不回退;间隔计算无符号 |
| F5 | 时间运算 | 无 32 位 time_t;无 now > deadline 式比较 |
| F6 | 日志字段 | seq + UTC + mono 三字段齐全 |
| F7 | 校时决策 | 阈值表化;step 落日志;dry-run 默认开 |
| F8 | 时区 | 内部全 UTC;TZ 配置可下发;历史数据不依赖 TZ |
| F9 | 上限自检 | 启动检查离 2038/2106 的余量,不足则告警 |
| F10 | 取时间收敛 | grep 确认无统一入口之外的裸调用 |
产线工位建议:
| # | 工位 | 内容 |
|---|---|---|
| P1 | ICT/烧录后 | 写入 magic 前的状态校验:备份域空、BDCR 干净 |
| P2 | 校准工位 | GNSS 1PPS 对表,记录每台实测频偏,超差隔离(对应第 1 章测频法) |
| P3 | 老化工位 | 带 GNSS 参考连续跑 24~72 h,画对表曲线,斜率异常(温补方向错)当场拦截 |
| P4 | 出厂设置 | 设置当前 UTC 时间 + 时区配置,回读校验,落档 |
| P5 | 抽检 | 每批抽 3~5 台做 VBAT 静态电流和一周对表复测 |
P2 和 P3 是这套清单里回报率最高的两个工位。校准工位把每台设备的实测频偏记进档案,售后现场回来一台机器,查档案就知道它"天生偏多少",直接排除晶体因素;老化工位那 24~72 小时的曲线,是温补、时钟源、备份域所有问题的总演练------产线上拦一只问题设备的成本,是现场拦它的百分之一。
第五部分 小结
第 13 章 小结与互动提问
13.1 这篇东西到底讲了什么
回头看,全文可以压成一张因果链。时间不准只是症状,往上游追,每一环都有对应的药:
| 层级 | 典型病因 | 药方 | 章节 |
|---|---|---|---|
| 物理层 | 负载电容配错、晶体批次、时钟源选错 | 公式核算 + GNSS 实测 + 时钟源纪律 | 1 |
| 物理层 | 温漂 | 实测表 + 插值 + 毫秒累加器 | 2 |
| 硬件层 | 备份域掉电、VBAT 网络 | 双路径初始化 + magic 校验 + 安全写入 | 3 |
| 表示层 | BCD 非法值、跨秒撕裂、闰秒 | 掩码校验 + 重取 + 边界统一 UTC | 4、8 |
| 语义层 | 单调/墙钟混用 | 分工原则 + 单调钳位 + RTC 次秒底座 | 5 |
| 系统层 | tickless 零头丢弃 | tick·cycle 整数累加器 |
6 |
| 协议层 | 校时策略粗暴 | step/slew 决策表 + 单调兜底 + 日志声明 | 7 |
| 应用层 | 日志时间戳混乱 | 三字段 + UTC 内部表示 + 序号轮转 | 9 |
这套东西没有银弹,全是"一个坑一条对应措施"的笨办法。但笨办法的好处是可迁移:换一块 MCU、换一个 RTOS、换一个国家,药方不用重配。