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

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

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

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

时钟源

时钟源的目的是为系统提供一个时间线,告诉您当前所处的时间。例如,在 Linux 系统上发出 'date' 命令最终会读取时钟源以确定确切的时间。

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

与现实世界的挂钟相比,时钟源应具有尽可能高的分辨率,并且频率应尽可能稳定和正确。它不应在时间上不可预测地来回移动,也不应在这里或那里错过几个周期。

它必须不受硬件中发生的那种效应的影响,例如,在总线上分两个阶段读取计数器寄存器,首先读取最低的 16 位,然后在第二个总线周期中读取较高的 16 位,计数器位可能在此期间被更新,从而导致计数器产生非常奇怪的值的风险。

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

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

ns ~= (clocksource * mult) >> shift

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

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

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

时钟事件

时钟事件在概念上与时钟源相反:它们采用所需的时间规范值并计算要放入硬件定时器寄存器中的值。

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

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

sched_clock()

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

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

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

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

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

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

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

某些架构可能具有有限的时间源集,并且缺乏派生 64 位纳秒值的良好计数器,因此例如在 ARM 架构上,已创建特殊的辅助函数以从 16 位或 32 位计数器提供 sched_clock() 纳秒基准。有时,也用作时钟源的同一个计数器用于此目的。

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

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

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

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

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

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

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