所谓 uptime,通常来说,指系统自启动以来的微秒数(或更精确的:纳秒数)。
比如在 Linux 上,用 uptime
命令来得知系统已经运行的时长是一个常见操作。
注意,这个时间是相对于系统上电以来的,从 0 开始计数,跟真实世界的日历时间无关。
它是单调递增的(除非系统运行的时间已经足够长,定时器发生了溢出)。
Uptime 与 日历时间
uptime 通常由 SysTick 定时器实现,上电就开始递增,跟时钟周期/机器周期同步。 而日历时间(date)通常由 RTC(RealTime Clock,实时时钟)实现,关机后也能正常计时,因为主板上有一颗电池🔋专门为它供电。 由于晶体振荡器的精度(及温飘等)问题,RTC 通常会有误差(且不小),所以联网后,系统通常会自动通过 NTP(Network Time Protocol)向网络时间同步。 (话外:我的大疆无人机,几个月没给遥控器联网,已经有 4 个小时的误差了,真是拉垮!)
日历时间随区域等配置会进行调整(所谓时差/时区,出现“时光倒流”等情况),不适合用来做时间测量工作。 而 uptime 则可以看作是永远单调递增的,适合用来做时间测量工作。比如:性能测试时的计时。
用 SysTick 实现 Uptime
背景 & 设计
最近给老家设计的某款硬件产品需要用到定时器(比如:十分钟后执行某个操作),用到了微控制器(CH32V003)内置的 SysTick 定时器。 官方 SDK 中,仅仅把这个 SysTick 定时器用来作为延时函数使用:进入函数时启动定时器,时间到后退出。其间:死等。 这种独占一个系统资源来死等的方式是非常浪费资源的。所以我废掉了官方的做法,改成了“后台”长期运行的 uptime 实现。 这么基础的功能,虽然看似实现很简单,但是我还是踩了好几个坑。“底层”好难🥵。
SysTick 定时器是 32 位的,按最低的 CPU 主频 8MHz、定时器 8 分频来算,SysTick 会每微秒 Tick 1 次。 那么满打满算,一个 32 位的定时器也就才能:。完全不够用。 如果是 64 位定时器,这个值是 60万年…… 所以,硬件设计上多考虑一下,软件实现将会简单很多🥹
没办法,软件实现。那就开定时器中断,在中断函数里面累加一个高位值即可(定时器当前值表示低位)。
假设定时器溢出时间设置为 (FreeRTOS 等实时操作系统的常用分片时间,msPerOsTick)。
那么,获取 uptime 时的时刻即为:。
把高位值用 uint32_t
来表示,这个时长是 ,勉强够用。
如果换成 uint64_t
来表示,这个时长是 ,地球毁灭也够用了。🥵
初始化
因为在其它微控制器平台我也处理过这样的问题。所以思路及实现就尽量不特定于某平台,总体是一样的。
这里简单描述下好了:
- 设置 SysTick 从 0 开始计数
- 设置比较值为 10ms(即 10ms 溢出一次)
- 开启溢出中断
中断函数
中断函数非常简单(越简单越好),仅仅是累加一下高位:
1 2 3 4 5 6 7 |
|
可想而知,当前的 。 看似简单,但是这一步我踩坑多次。
获取 uptime ——— 踩坑过程
前面提到,当前的 uptime 不就是 就行了吗? 确实是,所以一种非常 naive 的写法(伪代码)就产生了:
1 2 3 |
|
其中的 SysTickCurrent
表示 SysTick 的当前值。看起来完全没问题?
确实,我一开始就是这么写的,直到我在统计外部中断的电平持续时间时发现:后面拿到的 uptime 怎么比较先前拿到的 小了(即不是递增的了)?WTF?
难以置信,直到我打了一堆日志,确定获取到的 uptime 不递增时,我才发现问题不简单。于是剥开了一根火腿(home-made),慢慢吃起来想了想问题出在哪。
后面获取的 _osTicks
只可能大于等于前面获取的,否则就撞鬼了。所以问题不可能是它。
那么问题就一定在 SysTickCurrent
上,因为它会溢出(每 10ms),溢出就归零。
所以,我期待的过程是这样的:
- 获取到
_osTicks
- 获取到
SysTickCurrent
然而真正的先后顺序是:
- 获取到
_osTicks
- 发生中断
- 获取到
SysTickCurrent
发生中断后 _osTicks
已经加了 1,SysTickCurrent
也又从 0 开始计数了。
我用发生中断前的 _osTicks
和 SysTickCurrent
来计算 uptime,当然就错了:会出现可能小、可能相等、可能大的三种情况。
所以最终结论是:我拿到的这2️⃣个变量的值均过期了。 我必须保证获取这两个变量的值的时候不能被中断打断。
很意外吧?虽然是单核心的 CPU,竟然也有“多线程数据被其它线程修改”/非“原子操作”类似的问题, 而且还是在一条表达式中(Go 语言中的 unsafe 表达式就被语言确定会被“原子”地执行)。 (注:其实也没有特别意外,毕竟超级马里奥兄弟都已经这么干几十年了。 它的主线程只是个无尽的循环, 真正的事情是全部放在中断中处理的。)
问题已经非常清楚,那么就是解决。方案也有好多种:
-
获取时先暂停定时器
暂停定时器就能暂停中断(且不会丢中断)。尝试过,私以为表达式计算非常快,“暂停”对定时器来说影响应该非常小。 而实际上,我外部中断的频率也非常高(1ms),所以会频繁地调用 uptime。所以没跑多久,时间飘到天边去了😂。
-
直到第二次比第一次大
就是在函数内部定义静态变量记录最后一次获取时的值,后面获取时,一定要比此值大才有效。 方案可行,但浪费了几个字节的
.data
段空间,抠字节的我不忍心此做法。 -
确保在两次中断间隔其间获取
这是最优雅的做法。虽然看起来多了一个循环,但是实际上循环基本不会执行,只会确保在确实发生了中断的情况下执行一次。伪代码如下:
1 2 3 4 5 6 7 8 9 10 11 12
uint64_t SysTick_GetUptime() { uint64_t osTicks; uint32_t cnt1, cnt2; do { cnt1 = SysTickCurrent; osTicks = _osTicks; cnt2 = SysTickCurrent; } while(!(cnt1 < cnt2)); return osTicks * 10ms + cnt2; }
两次获取
SysTickCurrent
,如果第二次获取到的值比第一次获取到的值大,那么,就一定能确保这条语句其间没有发生中断, 那么在它们之前获取到的_osTicks
就一定是正确的。有没有可能其它中断函数处理时间太长,使得
cnt2
实际上第二次比cnt1
大?有可能。但是这个时间,系统复位看门狗该工作了。
参考实现
代码链接:movsb/ch32v003。
ESP32 中的类似实现
我是在踩了坑之后才想到大家应该都会遇到这个问题,于是看了看 ESP-IDF 的实现,看起来很相似:
1 2 3 4 5 6 7 8 9 10 11 |
|
重写 Delay 延时函数
这个就比较简单的,没有操作系统的话,死等就行:
1 2 3 4 5 6 7 8 9 10 |
|