时钟源、时钟事件、sched_clock() 和延迟定时器

本文档试图简要解释一些基本的内核计时抽象概念。它部分与通常在内核树的 drivers/clocksource 中找到的驱动程序有关,但代码可能分布在整个内核中。

如果您在内核源代码中进行 grep 搜索,您会发现许多架构特定的时钟源、时钟事件的实现,以及几个同样架构特定的 sched_clock() 函数和一些延迟定时器的覆盖。

为了为您的平台提供计时,时钟源提供基本的时间线,而时钟事件在此时间线的某些点上触发中断,提供诸如高分辨率定时器之类的设施。 sched_clock() 用于调度和时间戳,延迟定时器使用硬件计数器提供准确的延迟源。

时钟源

时钟源的目的是为系统提供时间线,告诉您现在的时间。例如,在 Linux 系统上发出命令“date”最终将读取时钟源以确定准确的时间。

通常,时钟源是单调的原子计数器,它将提供 n 位,这些位从 0 计数到 (2^n)-1,然后回绕到 0 并重新开始。理想情况下,只要系统正在运行,它就永远不会停止滴答。它可能会在系统挂起期间停止。

时钟源应具有尽可能高的分辨率,并且与真实世界的挂钟相比,频率应尽可能稳定和正确。它不应在时间上不可预测地来回移动,也不应遗漏一些周期。

它必须能够防止硬件中发生的各种影响,例如,在总线上分两个阶段读取计数器寄存器,首先是最低 16 位,然后在第二个总线周期中读取高 16 位,计数器位可能会在此期间更新,从而导致来自计数器的非常奇怪的值的风险。

当时钟源的挂钟精度不能令人满意时,计时代码中有各种怪癖和层,例如,将用户可见的时间同步到系统中的 RTC 时钟或使用 NTP 与联网的时间服务器同步,但它们基本上所做的只是更新时钟源的偏移量,时钟源为系统提供基本的时间线。这些措施本身不会影响时钟源,它们只会使系统适应它的缺点。

时钟源结构应提供将提供的计数器转换为纳秒值的方法,该纳秒值是无符号长整型(unsigned 64 位)数字。由于此操作可能会非常频繁地调用,因此严格地以数学方式执行此操作是不可取的:相反,仅使用算术运算乘法和移位,将该数字尽可能接近纳秒值,因此在 clocksource_cyc2ns() 中您会发现

ns ~= (clocksource * mult) >> shift

您会发现时钟源代码中有许多辅助函数,旨在帮助提供这些 mult 和 shift 值,例如 clocksource_khz2mult()、clocksource_hz2mult(),它们有助于从固定移位确定 mult 因子,以及 clocksource_register_hz() 和 clocksource_register_khz(),它们将帮助使用时钟源的频率作为唯一的输入来分配 shift 和 mult 因子。

对于从单个 I/O 内存位置访问的真正简单的时钟源,现在甚至有 clocksource_mmio_init(),它将采用内存位置、位宽、一个参数,指示寄存器中的计数器是向上计数还是向下计数,以及定时器时钟速率,然后生成所有必要的参数。

由于 100 MHz 的 32 位计数器在大约 43 秒后将回绕到零,因此处理时钟源的代码必须对此进行补偿。这就是为什么时钟源结构还包含一个“mask”成员,用于指示源的多少位有效。这样,计时代码就知道计数器何时回绕,并且可以在回绕点的两侧插入必要的补偿代码,以便系统时间线保持单调。

时钟事件

时钟事件在概念上是时钟源的逆向:它们采用所需的时间规格值,并计算出要插入硬件定时器寄存器的值。

时钟事件与时钟源正交。同一硬件和寄存器范围可以用于时钟事件,但它本质上是不同的东西。驱动时钟事件的硬件必须能够触发中断,以便在系统时间线上触发事件。在 SMP 系统上,理想情况下(并且通常)每个 CPU 核心都有一个这样的事件驱动定时器,以便每个核心可以独立于任何其他核心触发事件。

您会注意到,时钟事件设备代码基于相同的基本思想,即使用乘法和移位算法将计数器转换为纳秒,并且您会再次找到相同的辅助函数系列来分配这些值。但是,时钟事件驱动程序不需要“mask”属性:系统不会尝试计划超出时钟事件时间范围的事件。

sched_clock()

除了时钟源和时钟事件之外,内核中还有一个特殊的弱函数叫做 sched_clock()。此函数应返回自系统启动以来的纳秒数。架构可以自行提供或不提供 sched_clock() 的实现。如果没有提供本地实现,系统 jiffy 计数器将用作 sched_clock()。

顾名思义,sched_clock() 用于调度系统,例如,确定 CFS 调度程序中某个进程的绝对时间片。当您选择在 printk 中包含时间信息时,它也用于 printk 时间戳,例如用于启动图。

与时钟源相比,sched_clock() 必须非常快:它被调用的频率更高,尤其是被调度程序调用。如果您必须在准确性与时钟源之间进行权衡,则可以在 sched_clock() 中牺牲准确性以换取速度。但是,它需要与时钟源相同的一些基本特征,即它应该是单调的。

sched_clock() 函数只能在无符号长整型边界上回绕,即在 64 位之后。由于这是一个纳秒值,这意味着它将在大约 585 年后回绕。(对于大多数实际系统来说,这意味着“永远不会”。)

如果架构不提供此函数的自己的实现,它将回退到使用 jiffies,使其最大分辨率为架构 jiffy 频率的 1/HZ。这将影响调度准确性,并且可能会在系统基准测试中显示出来。

驱动 sched_clock() 的时钟可能会在系统挂起/睡眠期间停止或重置为零。这对它在系统上调度事件的作用无关紧要。但是,它可能会导致 printk() 中出现有趣的时间戳。

sched_clock() 函数应该可以在任何上下文中调用,并且是 IRQ 和 NMI 安全的,并且在任何上下文中都返回一个合理的数值。

某些架构可能只有有限的时间源,并且缺少一个很好的计数器来导出 64 位纳秒值,因此例如在 ARM 架构上,已经创建了特殊的辅助函数来从 16 位或 32 位计数器提供 sched_clock() 纳秒基数。有时,也用作时钟源的同一个计数器用于此目的。

在 SMP 系统上,至关重要的是 sched_clock() 可以在每个 CPU 上独立调用,而不会有任何同步性能损失。某些硬件(例如 x86 TSC)将导致系统上 CPU 之间的 sched_clock() 函数漂移。内核可以通过启用 CONFIG_HAVE_UNSTABLE_SCHED_CLOCK 选项来解决此问题。这是使 sched_clock() 与普通时钟源不同的另一个方面。

延迟定时器(仅限某些架构)

在 CPU 频率可变的系统上,各种内核 delay() 函数有时会表现得很奇怪。基本上,这些延迟通常使用一个硬循环来延迟一定数量的 jiffy 分数,使用一个在启动时校准的“lpj”(每个 jiffy 的循环数)值。

让我们希望您的系统在校准此值时以最大频率运行:当频率降低到完整频率的一半时,任何 delay() 都将是两倍的时间。通常这没有坏处,因为您通常要求该数量的延迟或更多。但基本上,这种语义在这样的系统上是相当不可预测的。

输入基于定时器的延迟。使用这些延迟,可以使用定时器读取来代替硬编码循环来提供所需的延迟。

这是通过声明一个 struct delay_timer 并为此延迟定时器分配适当的函数指针和速率设置来完成的。

这在某些架构上可用,如 OpenRISC 或 ARM。