4.5.1 系统时钟
4.5.1 系统时钟
📚 本节导读
学习时长: 约 25 分钟
难度级别: ⭐⭐⭐☆☆
前置知识: 中断处理基础、硬件定时器基础
🎯 学习目标
- 理解系统时钟(SysTick)在 RTOS 中的核心作用
- 掌握 tick 中断处理流程
- 理解 tick 频率配置与实时性能的关系
- 了解 tick 队列的数据结构和工作原理
- 理解系统时钟如何驱动任务延时和定时器
一、系统时钟概念
1.1 什么是系统时钟
系统时钟是 RTOS 的"心跳"(Heartbeat),它由硬件定时器产生周期性中断,为操作系统提供统一的时间基准。在 OneOS 中,系统时钟通常基于 Cortex-M 内核的 SysTick 定时器实现。
每个时钟中断称为一个系统节拍(System Tick)。所有与时间相关的操作——任务延时、定时器计时、时间片轮转——都依赖于系统节拍。
1.2 系统时钟的作用
┌─────────────────────────────────────────────────────────┐
│ 系统时钟 (SysTick) │
│ 周期: 1/OS_TICK_PER_SECOND 秒 │
└───────────────────────┬─────────────────────────────────┘
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 任务延时 │ │ 定时器 │ │ 时间片 │
│ tsleep/ │ │ 计时触发 │ │ 轮转调度 │
│ msleep │ │ │ │ │
└──────────┘ └──────────┘ └──────────┘系统时钟驱动以下核心功能:
- 任务延时:
os_task_tsleep(tick)和os_task_msleep(ms)使任务阻塞指定的 tick 数或毫秒数 - 定时器计时:所有软件定时器基于系统时钟做减计数,到期后触发回调
- 时间片轮转:同优先级任务的时间片在每次 tick 中断时递减,归零时触发调度
- Tick 队列管理:维护所有延时阻塞的任务,在 tick 中断中检查并唤醒到期任务
二、Tick 频率配置
2.1 OS_TICK_PER_SECOND
系统节拍频率由 OS_TICK_PER_SECOND 宏定义,在 oneos_config.h 中配置:
#define OS_TICK_PER_SECOND 100 /* 每秒 100 个 tick,即每 10ms 一个 tick */常用配置值:
| 频率 (Hz) | 周期 (ms) | 适用场景 |
|---|---|---|
| 100 | 10ms | 通用嵌入式系统,均衡性能与功耗 |
| 200 | 5ms | 需要较快响应时间的系统 |
| 500 | 2ms | 对实时性要求较高的系统 |
| 1000 | 1ms | 高精度定时需求 |
注意:频率越高,系统实时性越好,但中断开销也越大,功耗越高。选择时需要权衡系统实时性需求和 CPU 负载。
2.2 Tick 与真实时间的转换
OneOS 提供了 tick 与毫秒的互相转换函数,实现在 kernel/source/os_clock.c 中。转换涉及 3 个 API:毫秒→tick(os_tick_from_ms)、tick→毫秒(os_ms_from_tick)、获取频率(os_get_ticks_per_second)。三者用途不同,下面逐函数讲解。
转换公式(OS_MS_PER_SECOND = 1000):
tick = OS_TICK_PER_SECOND × (ms / 1000) + (OS_TICK_PER_SECOND × (ms % 1000) + 999) / 1000
ms = (1000 / OS_TICK_PER_SECOND) × tick性能考虑:这些函数全用整数运算(乘法/除法/取模),不使用浮点。RTOS 时间换算常在内核路径被高频调用,浮点运算在无 FPU 的 MCU 上开销大、且可能引入非确定性。
2.2.1 os_tick_from_ms(毫秒 → tick)
功能:把毫秒数换算成 tick 数,供 os_task_msleep 等延时接口使用。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
os_tick_t os_tick_from_ms(uint32_t ms)
{
os_tick_t tick;
tick = OS_TICK_PER_SECOND * (ms / OS_MS_PER_SECOND);
tick += (OS_TICK_PER_SECOND * (ms % OS_MS_PER_SECOND) + (OS_MS_PER_SECOND - 1)) / OS_MS_PER_SECOND;
return tick;
}分步详解:
- 第一段
OS_TICK_PER_SECOND * (ms / OS_MS_PER_SECOND):处理整秒部分。ms/1000是整秒数,每整秒有OS_TICK_PER_SECOND个 tick,所以整秒贡献频率 × 整秒数个 tick。 - 第二段
(OS_TICK_PER_SECOND * (ms % OS_MS_PER_SECOND) + (OS_MS_PER_SECOND - 1)) / OS_MS_PER_SECOND:处理余数毫秒。ms%1000是不足 1 秒的毫秒数,换算成 tick 并向上取整(+999)。 - 返回:两段相加得到总 tick。
为什么分两段计算?—— 处理"整秒"和"余数"
一个毫秒数可能跨越多个整秒(如 2500ms = 2.5 秒),也可能有不足一秒的余数(如 35ms)。分两段分别处理,避免直接 ms * 频率 时整数运算丢失余数精度。
为什么 +999 能实现向上取整?—— 数学原理
(X + 999) / 1000 等价于 ⌈X/1000⌉(向上取整),原理:
- 若
X能被 1000 整除:(X+999)/1000 = X/1000(+999不影响,因为整除时X+999 < X+1000,商不变)。 - 若
X不能被 1000 整除:X = 1000q + r(0 < r < 1000),则(X+999)/1000 = (1000q + r + 999)/1000 = q + (r+999)/1000,由于r ≥ 1,r+999 ≥ 1000,商= q+1,即向上取整。
为什么要向上取整而不是向下? 毫秒→tick 用于延时/定时,必须保证"至少延时用户指定的时间"。若向下取整(35ms→3 tick),实际只延 30ms,比用户要求短;向上取整(→4 tick)延 40ms,至少满足用户要求。这是"宁可多延、不可少延"的安全原则。
数值演算(拆解)(OS_TICK_PER_SECOND = 100):
以 2500ms 为例:
| 段 | 计算 | 结果 |
|---|---|---|
| 整秒部分 | ms/1000 = 2500/1000 = 2 | 2 整秒 |
| 整秒贡献 | 100 × 2 | 200 tick |
| 余数部分 | ms%1000 = 500 | 500ms |
| 余数贡献 | (100×500 + 999)/1000 = 50099/1000 | 50 tick(整除) |
| 总 tick | 200 + 50 | 250 tick |
以 35ms 为例:
| 段 | 计算 | 结果 |
|---|---|---|
| 整秒部分 | ms/1000 = 0 | 0 整秒 |
| 余数部分 | ms%1000 = 35 | 35ms |
| 余数贡献 | (100×35 + 999)/1000 = 4499/1000 | 4 tick(4499/1000 = 4,向上取整) |
2.2.2 os_ms_from_tick(tick → 毫秒)
功能:把 tick 数换算成毫秒数,用于读取时间/显示(如把 tick 计数值转成用户可读的毫秒)。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
uint32_t os_ms_from_tick(os_tick_t tick)
{
return ((OS_MS_PER_SECOND / OS_TICK_PER_SECOND) * tick);
}分步详解:
OS_MS_PER_SECOND / OS_TICK_PER_SECOND:先算出每个 tick 对应的毫秒数(如频率 100Hz,则1000/100 = 10ms/tick)。- 再乘以
tick,得到总毫秒数。
为什么是向下取整?—— 与 tick→ms 对称
该函数用于读取时间/显示,不需要"保证至少",用 (1000/OS_TICK_PER_SECOND) × tick 直接换算即可。它要求 1000 % OS_TICK_PER_SECOND == 0(如 100/125/250/500/1000Hz),否则 1000/OS_TICK_PER_SECOND 是整除截断,有精度损失。
非整除频率的精度问题(OS_TICK_PER_SECOND = 125,即 8ms/tick):
| 输入 | 计算 | 结果 | 说明 |
|---|---|---|---|
os_ms_from_tick(1) | (1000/125)×1 = 8×1 | 8 ms | 精确(125 整除 1000) |
2.2.3 os_get_ticks_per_second(获取 tick 频率)
功能:返回每秒的 tick 数,即 OS_TICK_PER_SECOND 配置值,供外部查询系统节拍频率。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
uint32_t os_get_ticks_per_second(void)
{
return OS_TICK_PER_SECOND;
}分步详解:
- 直接返回宏
OS_TICK_PER_SECOND,无任何计算开销。通常用于需要知道 tick 频率的适配代码。
完整数值演算表(OS_TICK_PER_SECOND = 100,1000/100 = 10ms/tick):
| 输入 | 方向 | 计算 | 结果 |
|---|---|---|---|
os_tick_from_ms(0) | ms→tick | 0 + (0+999)/1000 | 0 tick |
os_tick_from_ms(10) | ms→tick | 0 + (100×10+999)/1000 = 1999/1000 | 1 tick |
os_tick_from_ms(11) | ms→tick | 0 + (1100+999)/1000 = 2099/1000 | 2 tick(向上取整) |
os_tick_from_ms(2500) | ms→tick | 200 + 50 | 250 tick |
os_ms_from_tick(1) | tick→ms | (1000/100)×1 | 10 ms |
os_ms_from_tick(250) | tick→ms | 10×250 | 2500 ms |
os_get_ticks_per_second() | — | 返回宏 | 100 |
2.3 获取和设置系统 tick
系统全局 tick 计数 gs_os_tick 提供了两个访问接口:读取(os_tick_get_value)与写入(os_tick_set_value)。两者对称且含义不同,下面逐函数讲解。
2.3.1 os_tick_get_value(获取当前 tick)
功能:读取当前系统 tick 计数值 gs_os_tick,即系统已运行的节拍总数。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
os_tick_t os_tick_get_value(void)
{
return gs_os_tick;
}分步详解:
- 功能:直接返回全局
gs_os_tick,无临界区保护。gs_os_tick是os_tick_t(32 位无符号)整型,单次读在多数平台上原子,因此无需进临界区。 - 用途:用于获取当前时间戳,实现超时判断(如
tick_absolute - os_tick_get_value() > 0)或统计系统运行时长。
2.3.2 os_tick_set_value(设置当前 tick,谨慎使用)
功能:把系统 tick 计数值 gs_os_tick 设置为指定值。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
void os_tick_set_value(os_tick_t tick)
{
OS_KERNEL_INIT();
OS_KERNEL_ENTER();
gs_os_tick = tick;
OS_KERNEL_EXIT();
return;
}分步详解:
OS_KERNEL_INIT():若内核尚未初始化则返回,保证只在系统就绪后调用。OS_KERNEL_ENTER():进入临界区(关中断),保证写入gs_os_tick时不被 tick 中断打断。gs_os_tick = tick:写入目标值。OS_KERNEL_EXIT():退出临界区。return:返回。
为什么必须进临界区? gs_os_tick 由 os_tick_increase 在 tick 中断里 ++。若写入不加保护,可能恰好被中断打断,导致写入值与中断增量相互覆盖、产生竞态。谨慎使用——直接改 tick 会影响所有基于绝对超时时间的定时器/延时任务(tick_absolute = gs_os_tick + timeout),通常仅用于调试或时间同步。
三、Tick 中断处理流程
3.1 os_tick_increase 函数
每次 tick 中断发生时,内核调用 os_tick_increase() 函数(位于 kernel/source/os_clock.c)。完整源码(与项目逐字一致):
来源:kernel/source/os_clock.c
void os_tick_increase(void)
{
os_ubase_t bidx;
const os_list_node_t *tickq_head;
struct os_task *iter_task;
os_bool_t is_sched;
os_task_t *current_task;
OS_KERNEL_INIT();
#ifdef OS_USING_SMP
int32_t i;
#endif
current_task = k_task_self();
/*The operating system is not running TODO:*/
if (OS_NULL == current_task)
{
return;
}
is_sched = OS_FALSE;
OS_KERNEL_ENTER();
gs_os_tick++;
/* Process tick queue */
bidx = gs_os_tick & (TICK_Q_BUCKETS - 1U);
tickq_head = &gs_os_tickq_bucket[bidx];
while (1)
{
if (os_list_empty(tickq_head))
{
break;
}
iter_task = os_list_first_entry((tickq_head), os_task_t, tick_node);
/* No sleeping task need to be waked */
if ((os_base_t)(iter_task->tick_absolute - gs_os_tick) > 0)
{
break;
}
k_tickq_remove(iter_task);
iter_task->state &= ~OS_TASK_STATE_SLEEP;
if ((iter_task->state & OS_TASK_STATE_BLOCK) != 0)
{
os_list_del(&iter_task->task_node);
iter_task->state &= ~OS_TASK_STATE_BLOCK;
iter_task->block_list_head = OS_NULL;
iter_task->switch_retval = OS_TIMEOUT;
}
/* The task state may be suspend, empty or running */
if (OS_TASK_STATE_SUSPEND != iter_task->state)
{
iter_task->state |= OS_TASK_STATE_READY;
k_readyq_put(iter_task);
is_sched = OS_TRUE;
}
OS_KERNEL_EXIT();
OS_KERNEL_ENTER();
}
#ifdef OS_USING_SMP
for (i = 0; i < OS_SMP_MAX_CPUS; i++)
{
current_task = g_os_current_task[i];
/*The operating system is not running TODO:*/
if (OS_NULL == current_task)
{
continue;
}
current_task->remaining_time_slice--;
if (current_task->remaining_time_slice == 0)
{
current_task->remaining_time_slice = current_task->time_slice;
if ((current_task->state & OS_TASK_STATE_READY) != 0)
{
if (OS_TRUE == k_readyq_move_tail(current_task))
{
is_sched = OS_TRUE;
}
}
}
}
#else
/* Process time slice */
g_os_current_task->remaining_time_slice--;
if (!g_os_current_task->remaining_time_slice)
{
g_os_current_task->remaining_time_slice = g_os_current_task->time_slice;
if (g_os_current_task->state & OS_TASK_STATE_READY)
{
if (OS_TRUE == k_readyq_move_tail(current_task))
{
is_sched = OS_TRUE;
}
}
}
#endif
#ifdef OS_USING_HASH_BUCKET_TIMER
/* Is need timer handle.*/
if (k_timer_need_handle())
{
is_sched = OS_TRUE;
}
/* Move gs_os_timer_list_current forward one step. */
k_move_timer_list_one_step();
#endif
#ifdef OS_USING_SINGLE_LIST_TIMER
#ifdef OS_HARD_SINGLE_LIST_TIMER
OS_KERNEL_EXIT();
k_single_list_timer_tick_proc();
OS_KERNEL_ENTER();
#endif
#ifdef OS_SOFT_SINGLE_LIST_TIMER
if (k_single_list_timer_need_handle())
{
is_sched = OS_TRUE;
}
#endif
#endif
if (is_sched)
{
OS_KERNEL_EXIT_SCHED();
}
else
{
OS_KERNEL_EXIT();
}
return;
}分步详解:
第一步:系统未运行判断(条件:current_task == OS_NULL)
current_task = k_task_self();
if (OS_NULL == current_task)
{
return;
}- 条件成立(当前无任务,系统未启动)→ 直接返回,不处理 tick。
- 条件不成立(系统运行中)→ 继续。
第二步:tick 计数 + 定位 tick 队列桶(无条件执行)
is_sched = OS_FALSE;
OS_KERNEL_ENTER();
gs_os_tick++;
bidx = gs_os_tick & (TICK_Q_BUCKETS - 1U);
tickq_head = &gs_os_tickq_bucket[bidx];- 进入临界区,
gs_os_tick++(全局 tick 加 1)。 - 用
gs_os_tick & (TICK_Q_BUCKETS - 1)定位本次要检查的桶(哈希,只检查当前 tick 对应的桶)。
为什么必须检查 gs_os_tick & 7 这个桶?—— 桶号是"到期 tick"而非"入队 tick"
关键点在于:任务入队时按"到期时刻的 tick"(即 tick_absolute)算桶号,而非按入队时的当前 tick。回到 4.2.2 的 k_tickq_put:
task->tick_absolute = gs_os_tick + timeout; // 绝对到期时间
bidx = task->tick_absolute & (TICK_Q_BUCKETS - 1U); // 按到期时间算桶号因此,一个任务的 tick_absolute 是它醒来那一刻的 gs_os_tick 值。当系统运行到 gs_os_tick == tick_absolute 时,本次 gs_os_tick++ 后的值(即 tick_absolute)低 3 位恰好等于该任务入队时的桶号:
- 入队时
bidx = tick_absolute & 7(任务放到该桶)。 - 到期那一刻,
gs_os_tick增量后正好等于tick_absolute,故本次要检查的桶gs_os_tick & 7 = tick_absolute & 7 = bidx,两者低 3 位必然相同。
所以"当前 tick 对应哪个桶"与"这个 tick 到期的任务在哪个桶"是同一个桶——检查 gs_os_tick & 7 就能覆盖所有恰好在此刻到期、应该被唤醒的任务。这就是只检查一个桶就能 O(1) 唤醒到期任务的根本原因(详见 4.3)。
直观理解:哈希桶的索引就是"到期时间戳 % 8"。tick 中断每走一步,就把当前时间戳对应的"那一个格子"里的到期任务全部取出唤醒。8 个格子轮流转圈,永远不会漏掉。
第三步:处理 tick 队列,唤醒到期任务(条件:桶非空且首任务到期)
while (1)
{
if (os_list_empty(tickq_head))
{
break;
}
iter_task = os_list_first_entry((tickq_head), os_task_t, tick_node);
if ((os_base_t)(iter_task->tick_absolute - gs_os_tick) > 0)
{
break;
}
k_tickq_remove(iter_task);
iter_task->state &= ~OS_TASK_STATE_SLEEP;
if ((iter_task->state & OS_TASK_STATE_BLOCK) != 0)
{
os_list_del(&iter_task->task_node);
iter_task->state &= ~OS_TASK_STATE_BLOCK;
iter_task->block_list_head = OS_NULL;
iter_task->switch_retval = OS_TIMEOUT;
}
if (OS_TASK_STATE_SUSPEND != iter_task->state)
{
iter_task->state |= OS_TASK_STATE_READY;
k_readyq_put(iter_task);
is_sched = OS_TRUE;
}
OS_KERNEL_EXIT();
OS_KERNEL_ENTER();
}- 子分支
os_list_empty(tickq_head)(桶空)→break。 - 子分支首任务
tick_absolute - gs_os_tick > 0(还没到期)→break(delta 排序保证桶内按到期顺序,首任务没到则后续也没到)。 - 否则(首任务已到期)→ 唤醒该任务:
k_tickq_remove移出 tick 队列;清除OS_TASK_STATE_SLEEP。- 子分支任务还在 BLOCK → 从阻塞链表删除,清 BLOCK 状态,
switch_retval = OS_TIMEOUT(因超时被唤醒)。 - 子分支任务非 SUSPEND → 置 READY,
k_readyq_put入就绪队列,is_sched = OS_TRUE。 - 反复循环处理桶内后续到期任务(中间退出/重进临界区避免长时间关中断)。
第四步:处理时间片轮转(无条件执行)
g_os_current_task->remaining_time_slice--;
if (!g_os_current_task->remaining_time_slice)
{
g_os_current_task->remaining_time_slice = g_os_current_task->time_slice;
if (g_os_current_task->state & OS_TASK_STATE_READY)
{
if (OS_TRUE == k_readyq_move_tail(current_task))
{
is_sched = OS_TRUE;
}
}
}- 当前任务
remaining_time_slice--;子分支归零 → 重置为time_slice,若任务是 READY,移到就绪队列尾(k_readyq_move_tail),触发轮转调度。
第五步:处理定时器(条件:启用对应定时器宏)
#ifdef OS_USING_HASH_BUCKET_TIMER
if (k_timer_need_handle()) { is_sched = OS_TRUE; }
k_move_timer_list_one_step();
#endif
#ifdef OS_USING_SINGLE_LIST_TIMER
#ifdef OS_HARD_SINGLE_LIST_TIMER
OS_KERNEL_EXIT();
k_single_list_timer_tick_proc();
OS_KERNEL_ENTER();
#endif
#ifdef OS_SOFT_SINGLE_LIST_TIMER
if (k_single_list_timer_need_handle()) { is_sched = OS_TRUE; }
#endif
#endif- 哈希桶定时器:
k_timer_need_handle()判断是否有到期定时器(是则is_sched = TRUE),k_move_timer_list_one_step()推进定时器列表。 - 单链表硬定时器:在 ISR 中
k_single_list_timer_tick_proc()直接处理。 - 单链表软定时器:
k_single_list_timer_need_handle()判断(是则is_sched = TRUE)。
第六步:决定是否调度(条件:is_sched)
if (is_sched)
{
OS_KERNEL_EXIT_SCHED();
}
else
{
OS_KERNEL_EXIT();
}- 条件
is_sched == TRUE(有任务被唤醒/时间片到)→OS_KERNEL_EXIT_SCHED()退出临界区并触发调度。 - 否则 → 仅
OS_KERNEL_EXIT()退出临界区,不调度。
if-else 条件走向总结(树状图):
os_tick_increase()
│
├─ current_task = k_task_self()
├─ if (current_task == NULL)? /* 系统未运行 */
│ ├─ 是 → 直接 return
│ └─ 否 → 继续
│
├─ gs_os_tick++
├─ bidx = gs_os_tick & 7(定位桶)
│
├─ while (桶非空 && 首任务到期):
│ ├─ 唤醒任务(清 SLEEP/BLOCK,入就绪队列,is_sched=TRUE)
│ └─ 继续处理下一个
│
├─ 时间片轮转:remaining_time_slice--
│ └─ if (归零)? → 重置 time_slice,移就绪队尾,is_sched=TRUE
│
├─ 定时器处理(按配置宏)
│ └─ 有到期定时器 → is_sched=TRUE
│
└─ if (is_sched)? /* 需要调度? */
├─ 是 → OS_KERNEL_EXIT_SCHED()(退出临界区+调度)
└─ 否 → OS_KERNEL_EXIT()(仅退出临界区)数值演算(TICK_Q_BUCKETS = 8,gs_os_tick = 101,桶内任务 A 绝对超时 102):
| 步骤 | 计算 | 结果 |
|---|---|---|
gs_os_tick++ | 101 + 1 | 102 |
bidx | 102 & 7 | 6(检查桶 6) |
| 首任务到期判断 | A->tick_absolute - gs_os_tick = 102 - 102 | 0(≤0,到期)→ 唤醒 A |
| 时间片递减 | remaining_time_slice-- | 若归零则重置并轮转 |
is_sched | 有任务被唤醒 | TRUE → OS_KERNEL_EXIT_SCHED() |
为什么这里检查的是桶 6?—— 任务入队时的桶号与之完全对应
关键点是:任务入队时按"到期时刻的 tick"(即 tick_absolute)算桶号,而非按入队时的当前 tick(见 4.2.2 的 k_tickq_put:bidx = task->tick_absolute & (TICK_Q_BUCKETS - 1U))。于是桶号与到期 tick 绑定,而不是与入队 tick 绑定:
| 时刻 | 计算 | 桶号 | 含义 |
|---|---|---|---|
| A 入队时 | tick_absolute & 7 = 102 & 7 | 6 | A 放进桶 6,等待 gs_os_tick 走到 102 时被唤醒 |
本轮 gs_os_tick++ 后 | 102 & 7 | 6 | 本次检查桶 6,恰好是 A 所在的桶 |
两次桶号必然一致:任务 A 在 gs_os_tick = 102 时到期,故它入队时被放入桶 102 & 7 = 6;而当系统真实运行到 gs_os_tick = 102(++ 后),本轮要检查的桶也是 102 & 7 = 6。到期那一刻的 gs_os_tick 就等于任务的 tick_absolute,两者低 3 位相同,所以"当前该检查的桶"与"当前该唤醒的任务所在的桶"是同一个桶。
进入 while 后判断 A->tick_absolute - gs_os_tick = 102 - 102 = 0 ≤ 0,确认 A 到期,将其唤醒并移出 tick 队列。
核心结论:检查桶
gs_os_tick & 7,是因为该桶里装的正是"此刻应该醒来"的任务。桶号 = 到期 tick 的低 3 位,而gs_os_tick每递增一次就扫到与自身同号的那个桶;只要某个任务入队桶号与此刻gs_os_tick & 7相同,就代表它到了该被唤醒的时间点。这保证了每次中断只需 O(1) 检查一个桶,无需遍历所有 8 个桶(详见 4.3)。
3.2 Tick 中断中的关键原则
重要:tick 中断处理函数在内核临界区中执行,应尽量保持简短高效。硬件定时器的回调函数在 tick ISR 中直接执行,必须遵守 ISR 的限制:不能阻塞、不能调用可能阻塞的 API、执行时间必须极短。
四、Tick 队列
4.1 数据结构
Tick 队列是内核管理延时阻塞任务的核心数据结构,定义在 kernel/source/os_clock.c 中。完整源码(与项目逐字一致):
来源:kernel/source/os_clock.c
#define TICK_Q_BUCKETS 8
static os_list_node_t gs_os_tickq_bucket[TICK_Q_BUCKETS]; /* Doubly linked bucket list head */
static os_tick_t gs_os_tick = 0; /*Operating system tick*/TICK_Q_BUCKETS:哈希桶数量(8)。gs_os_tickq_bucket[8]:8 个双向链表桶,每个桶维护一组延时任务。gs_os_tick:系统全局 tick 计数(os_tick_increase每中断 +1)。
哈希索引:任务按绝对超时时间 tick_absolute 的低 3 位(& 7)分布到 8 个桶。每次 tick 只检查当前 tick 对应的桶,O(1) 定位,无需遍历所有桶。
4.2 核心函数
Tick 队列相关的核心函数实现在 kernel/source/os_clock.c 中,包括 3 个操作接口:初始化(k_tickq_init)、入队(k_tickq_put)、出队(k_tickq_remove),以及内部排序辅助函数(_k_tickq_delta_insert)。下面逐函数讲解。
4.2.1 k_tickq_init(初始化 tick 队列)
功能:系统启动时把 8 个桶链表头全部初始化为空链表。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
void k_tickq_init(void)
{
os_size_t i;
for (i = 0; i < TICK_Q_BUCKETS; i++)
{
os_list_init(&gs_os_tickq_bucket[i]);
}
return;
}分步详解:
for (i = 0; i < TICK_Q_BUCKETS; i++):遍历0 ~ 7共 8 个桶。os_list_init(&gs_os_tickq_bucket[i]):把第i个桶的链表头next/prev都指向自身,使链表为空。
为什么必须初始化? 未初始化的链表头指针是随机值,os_list_empty、os_list_add_tail 等操作会访问非法地址。所有桶在首次入队前必须初始化,因此该函数在系统启动早期调用。
4.2.2 k_tickq_put(任务延时入队)
功能:任务延时阻塞时,把任务插入对应哈希桶,按 delta 排序。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
void k_tickq_put(struct os_task *task, os_tick_t timeout)
{
os_list_node_t *tickq_head;
os_ubase_t bidx;
task->tick_timeout = timeout;
task->tick_absolute = gs_os_tick + timeout;
bidx = task->tick_absolute & (TICK_Q_BUCKETS - 1U);
tickq_head = &gs_os_tickq_bucket[bidx];
_k_tickq_delta_insert(tickq_head, task);
return;
}分步详解:
- 记录
tick_timeout = timeout(相对超时)和tick_absolute = gs_os_tick + timeout(绝对超时时间)。 bidx = tick_absolute & (TICK_Q_BUCKETS - 1U):按绝对超时时间低 3 位算哈希桶索引。_k_tickq_delta_insert:按 delta 排序插入对应桶(见 4.2.4)。
数值演算(gs_os_tick = 100,任务延时 50 tick):
| 项 | 计算 | 结果 |
|---|---|---|
tick_absolute | 100 + 50 | 150 |
bidx | 150 & 7 | 6(150 二进制低 3 位 = 110) |
| 插入桶 | gs_os_tickq_bucket[6] | 按 delta 插入 |
4.2.3 k_tickq_remove(任务出队)
功能:把任务从它所在的桶链表移除。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
void k_tickq_remove(struct os_task *task)
{
os_list_del(&task->tick_node);
return;
}分步详解:
os_list_del(&task->tick_node):把任务从所在桶链表移除。
调用时机:任务被唤醒、取消延时、或超时被处理(os_tick_increase 唤醒到期任务)时调用。
4.2.4 辅助函数 _k_tickq_delta_insert
功能:k_tickq_put 调用它,把任务按剩余时间 delta插入桶内链表的正确位置。
来源:kernel/source/os_clock.c
完整源码(与项目逐字一致):
OS_INLINE void _k_tickq_delta_insert(os_list_node_t *head, struct os_task *task)
{
os_list_node_t *pos;
struct os_task *task_iter;
os_list_for_each(pos, head)
{
task_iter = os_list_entry(pos, os_task_t, tick_node);
/*To avoid wrapping, use delta sorting*/
if (task->tick_timeout < (task_iter->tick_absolute - gs_os_tick))
{
/* Insert this tick_node before the task_iter. */
os_list_add_tail(&task_iter->tick_node, &task->tick_node);
break;
}
}
if (pos == head)
{
os_list_add_tail(head, &task->tick_node);
}
return;
}分步详解:
- 遍历桶内链表,对每个已有任务
task_iter:- 计算
task_iter的剩余时间task_iter->tick_absolute - gs_os_tick。 - 条件
task->tick_timeout < (task_iter->tick_absolute - gs_os_tick)(新任务的剩余时间比当前节点短)→ 把新任务插到task_iter之前,break。
- 计算
- 遍历完都没找到位置(
pos == head)→ 插到链表尾。
为什么用 delta 排序:桶内任务按"剩余时间"升序,os_tick_increase 每次只需检查桶首任务是否到期(首任务没到则后续也没到),O(1) 检查,且用相对差比较避免绝对 tick 溢出回绕问题(/*To avoid wrapping, use delta sorting*/)。
数值演算(gs_os_tick = 100,桶 6 已有任务 A:tick_absolute=150、剩余 150-100=50;新任务 B 延时 tick_timeout=20):
| 项 | 计算 | 结果 |
|---|---|---|
B 的 tick_absolute | 100 + 20 | 120 |
| 比较 | B->tick_timeout(20) < A 剩余(50)? | 是 → B 插到 A 之前 |
| 结果 | 桶 6:B → A | B 先到期,先被检查 |
4.3 Tick 队列工作原理
Tick 队列采用哈希桶 + delta 链表的设计:
gs_os_tickq_bucket[8]
┌───┐ ┌───┐ ┌───┐ ┌───┐
│ 0 │ ──→ │T1 │ ──→ │T2 │ (空)
├───┤ └───┘ └───┘
│ 1 │ ──→ (空)
├───┤
│ 2 │ ──→ │T3 │
├───┤ └───┘
│...│
└───┘- 哈希桶索引:
bidx = task->tick_absolute & (TICK_Q_BUCKETS - 1),将任务按绝对超时时间的低 3 位分布到 8 个桶中 - Delta 排序:同一桶内,任务按超时时间排序(使用 delta 值避免溢出问题)
- Tick 处理:每次 tick 中断只检查
gs_os_tick & 7对应的桶,将到期任务唤醒并加入就绪队列
4.3.1 为什么放进"同号桶"?—— 哈希与 tick 环形计数的天然对齐
核心原理:& 7 等价于 % 8,因为 8 是 2 的幂
TICK_Q_BUCKETS = 8 = 2³。对 2 的幂取模存在硬件级捷径:X & 7 等价于 X % 8,且只保留 X 的最低 3 位。
bidx = tick_absolute & (TICK_Q_BUCKETS - 1); // = tick_absolute & 7 = tick_absolute % 8因此每个任务都被"打上"一个 0~7 的标签,这个标签就是它 tick_absolute 的低 3 位。这就是"放进哪个桶"的唯一依据——桶号直接刻在任务的到期时间戳上。
为什么"入队桶号"与"检查桶号"能天然对齐?
关键在于 tick 计数器的自增特性:
| 时刻 | 计算 | 桶号 |
|---|---|---|
| 任务入队 | bidx = tick_absolute & 7 | 由它的到期时间决定 |
| 每次 tick 中断 | bidx = gs_os_tick & 7 | 当前时间戳 |
当系统真正走到 gs_os_tick == tick_absolute 那一刻:
- 本轮要检查的桶 =
gs_os_tick & 7 = tick_absolute & 7 - 该任务入队时的桶号 =
tick_absolute & 7
两个数来自同一个 tick_absolute 取低 3 位,必然相等。 任务在 gs_os_tick = 102 到期,故入队时放桶 102 & 7 = 6;而系统运行到 102 时,本轮检查的桶也是 102 & 7 = 6——"当前该检查的桶"与"此刻该唤醒的任务所在的桶"始终是同一个。
数值演算(任务 A 在 gs_os_tick = 101 时入队,延时 1 tick):
| 项 | 计算 | 结果 |
|---|---|---|
A 的 tick_absolute | 101 + 1 | 102(二进制 0110 0110) |
| A 入队桶号 | 102 & 7 | 6 → 放桶 6 |
系统走到 102 时检查桶 | 102 & 7 | 6 → 恰好命中桶 6 |
| 首任务到期判断 | A->tick_absolute - gs_os_tick = 102 - 102 | 0(≤0,到期)→ 唤醒 A |
为什么只检查一个桶不会漏任务?—— 两层保证
同余分组(粗分):凡是
tick_absolute低 3 位相同的任务都在同一桶。gs_os_tick从 0 起逐 1 递增,其低 3 位循环遍历0,1,2,...,7,0,1,...,所以每个桶"轮到被检查"的时刻正是低 3 位等于该桶号的所有到期时刻——不存在该醒的任务不在当前桶的情况,因为它的低 3 位此刻必然等于当前gs_os_tick的低 3 位。桶内 delta 精排(细分):同一个桶里可能混着多个"同低 3 位但不同轮次"的任务(如
tick_absolute为 6、14、22 的都在桶 6)。光靠桶号不够精确,桶内再用_k_tickq_delta_insert按剩余时间排序。检查时看桶首任务是否到期(tick_absolute - gs_os_tick <= 0):首任务没到则后续更晚到,直接break(不误唤醒);首任务到了则唤醒后继续处理下一个。
"粗分桶 + 桶内精排"组合:外层哈希 O(1) 定位"可能的桶",内层 delta 排序保证桶内只唤醒真正到期的任务,既不漏、也不误。
4.3.2 为什么这么设计?—— 为最频繁的 tick 中断做实时性权衡
tick 中断是 RTOS 中最频繁、对实时性最敏感的中断,必须把处理耗时压到最低。三种候选方案对比:
| 方案 | 插入复杂度 | 检查/定位复杂度 | 问题 |
|---|---|---|---|
| 单个全局链表 | O(1) | 每次 tick 需遍历所有任务判断到期 | 检查 O(N),任务多时 tick 中断开销大 |
| 按到期时间线性数组 | O(N)(需移动元素) | O(1) | 插入成本高 |
| 哈希桶(OneOS 采用) | O(1)(桶内 delta 插入) | O(1)(& 7 + 链表头访问) | 最优 |
哈希桶让"这次该检查谁"退化成一次 & 7 位运算 + 一次链表头访问,与任务总数 N 无关。这正是选用哈希桶而非单链表的根本原因——用极小的插入/查询代价,换取最频繁路径的 O(1) 实时响应,保证 tick 中断时间可预测、不随负载增长。
一句话总结:因为
TICK_Q_BUCKETS = 8 = 2³,& 7就是% 8,任务按到期时间的低 3 位打上 0~7 标签分桶;tick 计数自增使得"当前 tick 的低 3 位"天然等于"此刻到期任务的标签",于是每次中断只需检查当前这一个桶(O(1)),配合桶内 delta 排序精确唤醒,既快又不漏——这是为最频繁的 tick 中断做实时性优化的刻意设计。
4.4 任务延时与 Tick 队列的关系
当任务调用 os_task_tsleep(tick) 或 os_task_msleep(ms) 时:
- 内核计算任务的绝对超时时间:
task->tick_absolute = gs_os_tick + timeout - 将任务从就绪队列中移除,状态设为
OS_TASK_STATE_SLEEP - 调用
k_tickq_put(task, timeout)将任务插入 tick 队列对应的桶中 - 触发调度,切换到其他就绪任务运行
- 当系统 tick 到达任务的绝对超时时间时,
os_tick_increase()将任务从 tick 队列中移除并唤醒
五、低功耗 Tickless 模式
OneOS 支持低功耗 Tickless 模式(OS_USING_TICKLESS_LPMGR),在系统空闲时停止系统时钟,使 CPU 进入深度睡眠模式以降低功耗:
#ifdef OS_USING_TICKLESS_LPMGR
os_tick_t os_tickless_get_sleep_ticks(void); /* 获取可睡眠的 tick 数 */
void os_tickless_update(os_tick_t sleep_ticks); /* 唤醒后更新 tick */
#endif工作原理:
- 系统空闲时,计算下一个需要唤醒的 tick(取任务延时和定时器到期时间的最小值)
- 设置硬件定时器在指定时间后唤醒 CPU
- 唤醒后,调用
os_tickless_update()补偿因睡眠而跳过的 tick,更新 tick 队列和定时器状态
📝 本节小结
核心要点回顾
- 系统时钟是 RTOS 的"心跳"(SysTick 硬件定时器),提供统一时间基准,驱动任务延时、定时器计时、时间片轮转。
- tick 频率:
OS_TICK_PER_SECOND决定每秒 tick 数,频率越高实时性越好但中断开销越大。 - tick 转换:
os_tick_from_ms用+999向上取整保证延时充足;os_ms_from_tick直接换算。 os_tick_increase(每次 tick 中断):gs_os_tick++→ 处理 tick 队列唤醒到期任务 → 时间片轮转 → 处理定时器 → 按需调度。- Tick 队列:8 个哈希桶 + delta 链表,任务按
tick_absolute & 7分布,每次只检查当前桶,O(1) 定位。 - 哈希对齐原理:因为 8 = 2³,
& 7等价于% 8;任务按到期时间戳低 3 位打标签分桶,tick 自增使"当前桶"与"此刻到期任务的桶"天然重合,配合桶内 delta 精排做到不重不漏。 - 设计权衡:哈希桶用 O(1) 定位换取 tick 中断(最频繁中断)的实时性,避免单链表检查 O(N) 的开销。
关键易错点速查表
| 易错点 | 说明 | 对应章节 |
|---|---|---|
| tick-ms 换算 | os_tick_from_ms 需向上取整(+999),避免延时不足 | 2.2.1 / 2.2.2 |
| 频率与开销 | 频率越高开销越大,需权衡实时性与 CPU 负载 | 2.1 |
os_tick_set_value 慎用 | 直接改 tick 影响所有 tick_absolute 超时 | 2.3.2 |
| 桶索引取低 3 位 | 任务分布到 8 桶靠 & 7,只检查当前桶 | 4.1 |
| 桶号由到期 tick 决定 | 任务按 tick_absolute & 7 入桶,而非入队时刻 | 4.2.2 / 4.3.1 |
| 哈希对齐 | 桶号 = 到期时间戳低 3 位,& 7 依赖 TICK_Q_BUCKETS 是 2 的幂 | 4.3.1 |
| delta 排序 | 桶内按剩余时间升序,首任务没到期则后续都没到 | 4.2.4 |
| 时间片归零轮转 | remaining_time_slice 归零才触发轮转调度 | 3.1 第四步 |
下一步
- 4.5.2 哈希桶定时器:了解基于系统时钟的 O(1) 哈希桶定时器实现与到期处理。