用 SysTicks 定时器实现 uptime 功能

陪她去流浪 桃子 2024年01月09日 阅读次数:253

所谓 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 位的定时器也就才能:\(2^{32}us ≈ 1.2hours\)。完全不够用。 如果是 64 位定时器,这个值是 60万年…… 所以,硬件设计上多考虑一下,软件实现将会简单很多🥹

没办法,软件实现。那就开定时器中断,在中断函数里面累加一个高位值即可(定时器当前值表示低位)。 假设定时器溢出时间设置为 \(10ms\)(FreeRTOS 等实时操作系统的常用分片时间,msPerOsTick)。 那么,获取 uptime 时的时刻即为:\(高位值 \times 10ms + 定时器当前时间值\)。 把高位值用 uint32_t 来表示,这个时长是 \(2^{32} \times 10ms ≈ 1.36years\),勉强够用。 如果换成 uint64_t 来表示,这个时长是 \(2^{64} \times 10ms ≈ 60亿年\),地球毁灭也够用了。🥵

初始化

因为在其它微控制器平台我也处理过这样的问题。所以思路及实现就尽量不特定于某平台,总体是一样的。

这里简单描述下好了:

  • 设置 SysTick 从 0 开始计数
  • 设置比较值为 10ms(即 10ms 溢出一次)
  • 开启溢出中断

中断函数

中断函数非常简单(越简单越好),仅仅是累加一下高位:

// 这就是那个高位。也即 OS Tick 数。
static volatile uint64_t _osTicks = 0;

// 中断处理函数
void __attribute__((interrupt())) SysTick_Handler() {
	_osTicks++;
}

可想而知,当前的 \(uptime = \_osTicks \times 10ms + 定时器当前值\)。 看似简单,但是这一步我踩坑多次。

获取 uptime ——— 踩坑过程

前面提到,当前的 uptime 不就是 \(\_osTicks \times 10ms + 定时器当前值\) 就行了吗? 确实是,所以一种非常 naive 的写法(伪代码)就产生了:

uint64_t SysTick_GetUptime() {
	return _osTicks * 10ms + SysTickCurrent;
}

其中的 SysTickCurrent 表示 SysTick 的当前值。看起来完全没问题? 确实,我一开始就是这么写的,直到我在统计外部中断的电平持续时间时发现:后面拿到的 uptime 怎么比较先前拿到的 小了(即不是递增的了)?WTF? 难以置信,直到我打了一堆日志,确定获取到的 uptime 不递增时,我才发现问题不简单。于是剥开了一根火腿(home-made),慢慢吃起来想了想问题出在哪。

后面获取的 _osTicks 只可能大于等于前面获取的,否则就撞鬼了。所以问题不可能是它。 那么问题就一定在 SysTickCurrent 上,因为它会溢出(每 10ms),溢出就归零。

所以,我期待的过程是这样的:

  • 获取到 _osTicks
  • 获取到 SysTickCurrent

然而真正的先后顺序是:

  • 获取到 _osTicks
  • 发生中断
  • 获取到 SysTickCurrent

发生中断后 _osTicks 已经加了 1,SysTickCurrent 也又从 0 开始计数了。 我用发生中断前的 _osTicksSysTickCurrent 来计算 uptime,当然就错了:会出现可能小、可能相等、可能大的三种情况。

所以最终结论是:我拿到的这2️⃣个变量的值均过期了。 我必须保证获取这两个变量的值的时候不能被中断打断

很意外吧?虽然是单核心的 CPU,竟然也有“多线程数据被其它线程修改”/非“原子操作”类似的问题, 而且还是在一条表达式中(Go 语言中的 unsafe 表达式就被语言确定会被“原子”地执行)。 (注:其实也没有特别意外,毕竟超级马里奥兄弟都已经这么干几十年了。 它的主线程只是个无尽的循环, 真正的事情是全部放在中断中处理的。)

问题已经非常清楚,那么就是解决。方案也有好多种:

  • 获取时先暂停定时器

    暂停定时器就能暂停中断(且不会丢中断)。尝试过,私以为表达式计算非常快,“暂停”对定时器来说影响应该非常小。 而实际上,我外部中断的频率也非常高(1ms),所以会频繁地调用 uptime。所以没跑多久,时间飘到天边去了😂。

  • 直到第二次比第一次大

    就是在函数内部定义静态变量记录最后一次获取时的值,后面获取时,一定要比此值大才有效。 方案可行,但浪费了几个字节的 .data 段空间,抠字节的我不忍心此做法。

  • 确保在两次中断间隔其间获取

    这是最优雅的做法。虽然看起来多了一个循环,但是实际上循环基本不会执行,只会确保在确实发生了中断的情况下执行一次。伪代码如下:

    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 的实现,看起来很相似:

/* Read LO, HI, then LO again, check that LO returns the same value.
 * This accounts for the case when an interrupt may happen between reading
 * HI and LO values, and this function may get called from the ISR.
 * In this case, the repeated read will return consistent values.
 */
lo_start = systimer_ll_get_counter_value_low(hal->dev, counter_id);
do {
	lo = lo_start;
	hi = systimer_ll_get_counter_value_high(hal->dev, counter_id);
	lo_start = systimer_ll_get_counter_value_low(hal->dev, counter_id);
} while (lo_start != lo);

重写 Delay 延时函数

这个就比较简单的,没有操作系统的话,死等就行:

/**
 * @brief   Microsecond Delay Time.
 * @param   n - Microsecond number.
 * @return  None
 */
void Delay_Us(uint32_t n)
{
	uint64_t t = SysTick_GetUptime();
	while(SysTick_GetUptime() - t < n);
}

这篇文章的内容已被作者标记为“过时”/“需要更新”/“不具参考意义”。

标签:CH32V003