diff --git a/articles/20220626-riscv-linux-swtimer.md b/articles/20220626-riscv-linux-swtimer.md new file mode 100644 index 0000000000000000000000000000000000000000..09dc9c24f6d668f59bb903aff63a359fc14d5c57 --- /dev/null +++ b/articles/20220626-riscv-linux-swtimer.md @@ -0,0 +1,95 @@ +Author: 709150653@qq.com +Date: 2022/06/26 +Revisor: +Project: RISC-V Linux 内核剖析 +我们在软件开发中经常会使用到与时间相关的概念,例如获取当前的时间对某一事件进行打戳;或设置某个定时任务,在时间到时执行该函数;而且内核也会通过定时的相关记录来驱动进程切换。 +总结下来,我们需要的与时间相关的功能主要有两大类: +1.获取时间; +2.定时任务; +想获取以上两大功能,需要软件与硬件相结合; +硬件部分包括:时钟和定时器电路; +时钟电路用于获取当前的时间(如 RTC,单独供电,即使 CPU 断电也会持续记录该时间); +定时器电路,大体有如下几种: +时间戳计数器 TSC (time Stamp Counter); +可编程间隔定时器 PIT (Programmable Interval Timer); +高精度时间定时器 HPET (High Precision Event Timer); +电源管理定时器 ACPI (Advanced Configuration and Power Management Interface); +不同的定时器电路设计用于不同的时间精度要求以及在不同场景下使用,不再详细展开介绍。 + +本文主要介绍软件定时器相关的概念。 +一.定时器分类: +1.硬件定时器:由芯片本身提供的定时器电路,一般基于固定频率振荡器和计数器的硬件电路组成,可以按照固定的,预先定义的频率向 CPU 发出中断;优点是精度高,但是受限于硬件限制,硬件定时器个数较少; +2.软件定时器:基于硬件定时器设计的软件定时功能,理论上数量不受限制,但是精度较低,必须是硬件定时器的整数倍; + +二.软件定时器分类: +按照定时器触发方式分为: +单次触发方式 (oneshot):该类型定时器创建后只会触发一次定时器事件,执行一次超时函数后会自动销毁,想要重新定时,需要再设置一次寄存器值; +周期触发方式 (periodic):创建后会按照设定的超时时间周期执行,每次超时后会自动加载超时时间到寄存器,除非我们主动停掉; + +按照超时处理函数的执行环境可以分为: +中断上下文:要求处理函数简短,不要使用可以导致处理函数休眠的操作,实时性较高,响应迅速; +进程上下文:需要重新创建一个任务来执行,可以进行休眠或其他操作,但实时性会较低; + +三.软件定时器的设计: +Sw timer 结构体: +主要有超时处理函数及其参数,超时时间(相对时间,即从现在起还有多久会超时的 timeout时间); + +1.timer_init(): +会初始化 timer_list 结构体数组,将定时器的超时处理函数和其参数初始为 NULL;因为在上电复位时,CPU 硬件会设置 mtime(64位的real-time计数器,系统保证其会按照固定的频率递增)为0,但不会设置 mtimecmp(time compare register,每个 hart 会有一个该寄存器,上电复位时,系统不负责将其初始化)的初值,因此需要调用 timer_load() 函数加载 TIMER_INTERVAL 到 CLINT_MTIMECMP 寄存器内;接下来需要打开全局中断并将 MIE_MTIE寄存器置1; + + +2.Timer_create(): +创建一个软件定时器,传入超时处理函数地址及其参数,并设置超时时间 timeout;将新增的定时器加入到 timer_list 数组为空的位置(从数组元素为0的地址开始逐次寻找),将传入的参数加入到该位置处的 timer 结构体中; + + +3.timer_delete(): +传入待删除的 timer,在 timer_list 中逐次匹配待删除的 timer,将其超时处理函数及其参数设置为 NULL; + +4.Timer_handler(): +当系统的 tick 中断发生时,会调用 timer_handler 函数; + +Timer_handler 函数会先调用 timer_check 函数,该函数会按顺序检查 timer 的超时时间是否到达(因为 timer_list 是按照超时时间进行排序的,如果不是这种排序的话,那么 check 函数会有不同机制),如果已到则执行该 timer 的超时处理函数,执行结束后会清除该 timer 的超时处理函数及其参数; + +然后会重新调用 timer_load() 函数加载 TIMER_INTERVAL 到 CLINT_MTIMECMP 寄存器内;然后会触发一次调度 schedule(); + +四.软件定时器的优化: +当创建一个新的定时器任务时,需要将其加入到 timer_list 进行管理;上文有介绍的一种方式是通过链表的方式进行管理,当需要对定时器进行插入或是执行超时函数时,其时间复杂度是 O(n); +这种算法在对大量的定时器任务进行管理时,其效率会非常低,当前典型的定时器管理算法有:时间轮,最小堆,红黑树,跳转表等,本文主要介绍 linux 内核采用的时间轮算法(time r wheel)。 +基于排序链表的定时器任务使用一个链表来管理所有的定时器,所以当链表数目越来越多时,效率就会越来越低;而时间轮是一种基于哈希表的概念来管理定时器任务,将定时器散列到不同链表中,在每个链表中定时器任务是无序的,直接散列到当前链表中; + + +时间轮概念可以大概理解如下:类比于钟表的秒针,分针和时针的概念;单级时间轮分为 N个槽(类似于秒针有60个槽),每个槽上对应一个定时器链表,该链表上可以挂载多个定时器任务,该链表上的所有定时器 timeout 时间相同,因此链表里的定时器无须排序。时间轮经过系统时间 Ti(可以理解为时间轮的粒度)会移动一个槽,这个粒度值取决于时间轮的具体实现,可以是系统的一个时钟时间。 + + +假设时间轮开始移动的时间为 Ts,如果想创建一个超时时间为 Tto 的定时器任务,该定时器会放到那个槽上呢?可以按照如下公式计算: +(Tto - Ts) / Ti % N +那这样会有一个问题,假设时间轮分为6个槽,每个槽所表示的时间为1ms,那么超时时间为8ms和2ms的任务会放在一个定时器链表中,8ms的超时任务在时间为2ms时就已经被执行了,这样肯定是不行的; +解决办法很简单,加大时间轮槽数 N,但是这样也会增加内存的消耗,更好的方法是采用多级时间轮的方法: +第一级时间轮转动 N 个系统时间后,会自动跳到第二级时间轮上(类似于秒针转动一圈60秒之后,分针会转动一格),第二级的时间轮每个槽上也对应有一个定时器链表,这样就可以表示更长的超时定时任务; +假设第二级时间轮有 M 个槽,那么两级时间轮可以表示的最长超时时间为:(N * M) * Ti, +假设这个两级时间轮的槽数均可以用二进制表示,即2^n=N,2^m=M,那么这个可以记录的超时时间也可以表示为(0 ~ 2^(n+m) - 1) * Ti; + +而 linux 采用5级时间轮,并且使用32bit来表示,每级时间轮所能表示的超时时间长度分别占32bit的低8位(256),次6位(64),次6位(64),次6位(64),高6位(64),如下表一所示; +表一: +tv5 tv4 tv3 tv2 tv1 +6bit 6bit 6bit 6bit 8bit + +同时每级时间轮的粒度均不同,分别为1ms,256ms,256*64ms,256*64*64ms,256*64*64*64ms; +表二: +时间轮 可表示超时时间长度 +tv1 0~255(2^8 - 1) +tv2 256~16383(2^14 - 1) +tv3 16384~1048575(2^20 - 1) +tv4 1048576~ 67108863(2^26 - 1) +tv5 67108864~4294967295(2^32 - 1) + +Q1: 那么如何插入一个定时器任务呢? +定时器超时时间 Tto 和当前时间 Tc 做差值,记为 Tdelta; +根据 Tdelta 的值将定时器放到确定的时间轮 tvn 上(参照表二),再根据定时器的超时时间相对值 Tdelta 与对应的 tvn 所占的 bit 位置(对应表一)做按位与计算得到值作为数组下标,将定时器挂入到该链表上; +举个例子来加深下理解: +假设当前时间为 Tc=1,Tto=3,Tdelta=Tto - Tc = 3-1=2 < 256,因此会将该定时器放入 tv1 中,而 Tdelta & 0xFF(低8位) = 2(取低8位对应的值:2),因此将该定时器加入到 tv1 中数组下标为2的链表中; +假设当前时间为 Tc=1,Tto=262,Tdelta=Tto - Tc = 262-1=261在 {256,16383}区间,因此会将该定时器放入 tv2 中,而 Tdelta & 0x3F00(次6位) = 0x100(取8~13位对应的值:1),因此将该定时器加入到 tv2 中数组下标为1的链表中; +假设当前时间为 Tc=1,Tto=514,Tdelta=Tto - Tc = 514-1=513在 {256,16383}区间,因此会将该定时器放入 tv2 中,而 Tdelta & 0x3F00(次6位) = 0x200(取8~13位对应的值:2),因此将该定时器加入到 tv2 中数组下标为2的链表中; + +Q2: 如何检查定时器任务到期? +假设 Tc=0x87654321,因此下一个检查时刻为0x87654322,如果 tv1.array[0x22] 链表非空,那么在下一时刻需要执行 tv1.array[0x22] 上的所有定时器任务的超时函数;如果Tc增加到0x87654300,低8位为空,这时需要移出 tv2 时间轮,并根据 tv2 上的定时器超时时间将其重新加入到定时器系统,完成了 tv2 时间轮向 tv1 时间轮的迁移,因为在下一个256ms tv2 上的定时器一定会超时,这保证了 tv1 时间轮一直有数据;如果当 Tc 的第8~13位为0时,表明 tv2 对 tv3 有进位,这时需要移出 tv3 轮对应的定时器链表并将其重新加入到定时器系统中,根据定时器超时时间将其放入 tv2 时间轮;按照上述步骤依次检查 tv4 和 tv5 时间轮。 \ No newline at end of file