首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

RTOS内功修炼记(四)— 小小的时钟节拍,撑起了内核半边天!

内容导读:

第一篇文章讲述了任务的三大元素:任务控制块、任务栈、任务入口函数,并讲述了编写RTOS任务入口函数时三个重要的注意点。

  • RTOS内功修炼记(一)—— 任务到底应该怎么写?

第二篇文章从任务如何切换开始讲起,引出RTOS内核中的就绪列表、优先级表,一层一层为你揭开RTOS内核优先级抢占式调度方法的神秘面纱。

  • RTOS内功修炼记(二)—— 优先级抢占调度到底是怎么回事?

第三篇文章讲述了RTOS内核到底是如何管理中断的?用户该如何编写中断处理函数?以及用户如何设置临界段?

  • RTOS内功修炼记(三)—— 内核到底是如何管理中断的?

「建议先阅读上文,对RTOS内核的抢占式调度机制、RTOS内核对中断的处理机制与裸机的不同之处,理解之后,再阅读本文也不迟。」


1.知识点回顾 — Systick

STM32中的 SysTick 是一个24位的向下计数定时器,当计到0时,将从RELOAD寄存器中自动重装载定时初值并继续计数,且同时触发中断,SysTick 的主要作用是作为系统的时基,产生一个周期性的中断信号。

STM32CubeMX生成的 HAL 库工程中默认已经使能 Systick 及其中断,并配置默认1ms中断一次(1KHz),此配置也可以修改:

默认生成的Systick中断处理函数如图:

Systick中断处理函数中会将计数变量递增:

依托此计数变量,HAL库中提供了一个「堵塞式」的延时函数HAL_Delay():

2. RTOS使用堵塞延时的弊端

HAL_Delay是一个完全死循环等待的延时函数,在RTOS中如果一个任务使用诸如此类的延时函数,「不仅自身浪费了CPU,而且导致其它任务根本得不到调度机会」

比如,创建下面两个任务,任务task1的优先级高于任务task2:

代码语言:javascript
复制
void task1_entry(void *arg)
{
    printf("task1 start...\r\n");
    while(1)
    {
        printf("task1 is running...\r\n");
        HAL_Delay(1000);
    }
}

void task2_entry(void *arg)
{
    printf("task2 start...\r\n");
    while(1)
    {
        printf("task2 is running...\r\n");
        HAL_Delay(1000);
    }
}

从结果可以看到,task2根本得不到运行:

为了解决这一问题,RTOS内核就需要向用户提供一个新的延时函数,这个函数是「非堵塞式」的。

堵塞与非堵塞该如何理解呢?

堵塞就是CPU在死循环做一件事情,在别人看来CPU就像堵住了一样~

非堵塞就是当一个任务需要延时的时候,内核会将该任务挂起,然后执行一次抢占式调度,CPU转而去执行当前系统中存在的最高优先级任务,CPU还是照常执行程序~

❝注意:任务被挂起就代表着任务从就绪队列中移除,此时调度器去就绪队列中寻找最高优先级任务时,肯定不会找到该任务。 ❞

3. RTOS中的时钟管理

3.1. 时钟节拍的产生

周期性的时钟信号可以由硬件定时器产生,也可以由Systick产生,显然默认已经使能的Systick更好用一点,所以一般情况下都使用Systick产生周期性的时钟信号。

Systick产生信号的频率由Systick的配置决定,默认是1Khz(1ms),可以在开篇所提到的宏定义中修改此配置。

3.2. 时钟节拍服务程序

时钟节拍中断处理函数中调用RTOS内核提供的 API 完成对每一个时钟节拍的处理即可,这也是移植一个RTOS内核很重要的一步。

比如TencentOS-tiny中提供的API为 tos_tick_handler,使用方法如下:

代码语言:javascript
复制
void SysTick_Handler(void)
{
 /* USER CODE BEGIN SysTick_IRQn 0 */
 
 /* USER CODE END SysTick_IRQn 0 */
 HAL_IncTick();
 /* USER CODE BEGIN SysTick_IRQn 1 */
 if (tos_knl_is_running())
 {
   tos_knl_irq_enter();
   tos_tick_handler();
   tos_knl_irq_leave();
 }
 /* USER CODE END SysTick_IRQn 1 */
}

3.3. 每个时钟节拍来临时做什么

内核提供的API究竟做了什么呢?我们来一探究竟~

TencentOS-tiny时钟管理的实现在tos_tick.c中,其中对外提供的API源码如下:

代码语言:javascript
复制
__API__ void tos_tick_handler(void)
{
    if (unlikely(!tos_knl_is_running())) {
        return;
    }

    tick_update((k_tick_t)1u);
}

其中调用了内核函数tick_update,这才是重点,源码如下:

代码语言:javascript
复制
__KNL__ void tick_update(k_tick_t tick)
{
    TOS_CPU_CPSR_ALLOC();
    k_task_t *first, *task, *tmp;

    TOS_CPU_INT_DISABLE();
    k_tick_count += tick;

    if (tos_list_empty(&k_tick_list)) {
        TOS_CPU_INT_ENABLE();
        return;
    }

    first = TOS_LIST_FIRST_ENTRY(&k_tick_list, k_task_t, tick_list);
    if (first->tick_expires <= tick) {
        first->tick_expires = (k_tick_t)0u;
    } else {
        first->tick_expires -= tick;
        TOS_CPU_INT_ENABLE();
        return;
    }

    TOS_LIST_FOR_EACH_ENTRY_SAFE(task, tmp, k_task_t, tick_list, &k_tick_list) {
        if (task->tick_expires > (k_tick_t)0u) {
            break;
        }

        // we are pending for something, but tick's up, no longer waitting
        pend_task_wakeup(task, PEND_STATE_TIMEOUT);
    }

    TOS_CPU_INT_ENABLE();
}

从源码中可以看出其中做了三件事:

  • ① 将全局计时变量 k_tick_count 递增;
  • ② 进入延时列表的「第一个任务控制块」,将此任务的延时值递减;
  • ③ 循环遍历延时列表,找出所有延时值为0的任务并唤醒,加入到就绪列表中。

第一件事没有什么好说的,后两件事的调度算法实现非常牛逼,接下来重点分析!

4. 延时列表

古老的UC/OS-II中,在每个时钟节拍来临的时候,采用的调度算法是将任务列表中所有的任务控制块都扫描一遍,将每个任务控制块中的延时值-1,然后判断是否为0,如果该值为0且不是挂起状态,则将任务加入到就绪列表中。

显然,这种算法太low了,耗时,费力。

TencentOS-tiny中进行的第一点优化是,「设计一条专门用于挂载延时任务的延时列表」

代码语言:javascript
复制
/* list to hold all the tasks delayed or pend for timeout */
extern k_list_t             k_tick_list;

优化之后,当任务需要延时的时候,系统直接从就绪列表中移除,加入到延时列表中,进而当时钟节拍来临时,只需要遍历延时列表里的任务控制块即可,大大提高了算法的效率,但是还可以更牛逼~

TencentOS-tiny中进行的第二点优化是,「将待延时任务的任务控制块的延时值设置为,与上一个延时任务的差值」

代码语言:javascript
复制
__STATIC__ void tick_task_place(k_task_t *task, k_tick_t timeout)
{
    TOS_CPU_CPSR_ALLOC();
    k_task_t *curr_task = K_NULL;
    k_tick_t curr_expires, prev_expires = (k_tick_t)0u;

    TOS_CPU_INT_DISABLE();

    task->tick_expires = timeout;

    TOS_LIST_FOR_EACH_ENTRY(curr_task, k_task_t, tick_list, &k_tick_list) {
        curr_expires = prev_expires + curr_task->tick_expires;

        if (task->tick_expires < curr_expires) {
            break;
        }
        if (task->tick_expires == curr_expires &&
            task->prio < curr_task->prio) {
            break;
        }
        prev_expires = curr_expires;
    }
    task->tick_expires -= prev_expires;
    if (&curr_task->tick_list != &k_tick_list) {
        curr_task->tick_expires -= task->tick_expires;
    }
    tos_list_add_tail(&task->tick_list, &curr_task->tick_list);

    TOS_CPU_INT_ENABLE();
}

优化之后,在时钟节拍的每个中断来临时,只需要将延时列表中的第一个任务控制块的延时值递减即可。

这种方法下可能会存在如下的情况,一堆任务分别需要延时5个tick(task1)、5个tick(task2)、5个tick(task3)、10个tick(task4)、12个tick(task5),当这些任务都加入到延时列表之后记录的差值如下:

  • 5(task1)
  • 0(task2-task1)
  • 0(task3-task2)
  • 5(task4-task3)
  • 2(task)

当第一个任务task1被递减到0时,后面的两个任务本身差值就是0,所以需要一次延时列表遍历,将任务值为0的任务同时唤醒。

基于此函数,TencentOS-tiny封装了一层API给内核,用来方便的向延时列表中添加任务或者移除任务,如下:

代码语言:javascript
复制
__KNL__ void tick_list_add(k_task_t *task, k_tick_t timeout)
{
    tick_task_place(task, timeout);
    task_state_set_sleeping(task);
}

__KNL__ void tick_list_remove(k_task_t *task)
{
    tick_task_takeoff(task);
    task_state_reset_sleeping(task);
}

5. 任务延时如何实现

经过上述讲述,任务的延时与取消延时已经是水到渠成的事情,非常简单:

  • 任务延时实现方法:从就绪列表中移除,加入到延时列表,「并执行一次调度」
  • 任务取消延时实现方法:从延时列表移除,加入到就绪列表中,「并执行一次调度」

上源码,先来看任务延时的实现(省略了一堆参数检查):

代码语言:javascript
复制
__API__ k_err_t tos_task_delay(k_tick_t delay)
{
    TOS_CPU_CPSR_ALLOC();

 //进入临界段
    TOS_CPU_INT_DISABLE();

    tick_list_add(k_curr_task, delay);
    readyqueue_remove(k_curr_task);

    TOS_CPU_INT_ENABLE();
 //退出临界段
 
 //执行一次调度,从就绪列表中取出最高优先级的任务执行    
 knl_sched();

    return K_ERR_NONE;
}

再来看看任务取消延时的实现(省略了一堆参数检查):

代码语言:javascript
复制
__API__ k_err_t tos_task_delay_abort(k_task_t *task)
{
    TOS_CPU_CPSR_ALLOC();

    TOS_CPU_INT_DISABLE();

    tick_list_remove(task);
    readyqueue_add(task);

    TOS_CPU_INT_ENABLE();
    knl_sched();

    return K_ERR_NONE;
}

6. 时间管理

之前任务延时的API都是以tick个数为单位的,为了更加符合用户习惯,TencentOS-ting中提供了一系列额外的时间相关API,在tos_time.c中。

这些额外的时间相关API都「依赖一个宏定义」,告诉内核1s内有多少个tick,如下,在tos_config.h中配置:

代码语言:javascript
复制
#define TOS_CFG_CPU_TICK_PER_SECOND     1000u

比如:

① tick数和ms进行转化的API:

代码语言:javascript
复制
__API__ k_time_t tos_tick2millisec(k_tick_t tick)
{
    return (k_time_t)(tick * K_TIME_MILLISEC_PER_SEC / TOS_CFG_CPU_TICK_PER_SECOND);
}

__API__ k_tick_t tos_millisec2tick(k_time_t ms)
{
    return ((k_tick_t)ms * TOS_CFG_CPU_TICK_PER_SECOND / K_TIME_MILLISEC_PER_SEC);
}

② 以ms数为单位的任务延时API:

代码语言:javascript
复制
__API__ k_err_t tos_sleep_ms(k_time_t ms)
{
    return tos_task_delay(tos_millisec2tick(ms));
}

③ 以时分秒为单位的任务延时API:

代码语言:javascript
复制
__API__ k_err_t tos_sleep_hmsm(k_time_t hour, k_time_t minute, k_time_t second, k_time_t millisec)
{
    return tos_task_delay(time_hmsm2tick(hour, minute, second, millisec));
}

7. 空闲任务

当系统中的所有任务都在延时列表中时,那就绪列表岂不是没有东西了???CPU岂不是凉了???待会用事实说话。

为了防止这种情况,「RTOS内核必须设置一个空闲任务,目的就是让CPU永远要有任务执行」,如果想玩的高级一点,还可以在空闲任务中来点骚操作,比如:

  • 进入低功耗模式
  • 检查释放系统内存
  • ……

TencentOS-tiny中空闲任务的实现在tos_sys.c中:

代码语言:javascript
复制
__STATIC__ void knl_idle_entry(void *arg)
{
    arg = arg; // make compiler happy

    while (K_TRUE) {
#if TOS_CFG_TASK_DYNAMIC_CREATE_EN > 0u
        task_free_all();
#endif

#if TOS_CFG_PWR_MGR_EN > 0u
        pm_power_manager();
#endif
    }
}

__KNL__ k_err_t knl_idle_init(void)
{
    return tos_task_create(&k_idle_task, "idle",
            knl_idle_entry, K_NULL,
            K_TASK_PRIO_IDLE,
            k_idle_task_stk_addr,
            k_idle_task_stk_size,
            0);
}

空闲任务的优先级当然是系统所支持的最低优先级:

代码语言:javascript
复制
#define K_TASK_PRIO_IDLE (k_prio_t)(TOS_CFG_TASK_PRIO_MAX - (k_prio_t)1u)

空闲任务的任务栈大小可以在tos_config.h中配置:

代码语言:javascript
复制
#define TOS_CFG_IDLE_TASK_STK_SIZE      512u

接下来演示一下在TencentOS-tiny中如果没有空闲中断,会发生什么。

① 将 knl_idle_init 中创建空闲任务的代码屏蔽,改为return 0

② 在tos_config.h中使能最后一屏的功能:

代码语言:javascript
复制
#define TOS_CFG_FAULT_BACKTRACE_EN      1u

③ 在中断文件中屏蔽中断处理函数HardFault_Handler,防止冲突;

编译,下载程序,在串口助手中查看结果:

8. 软件定时器

软件定时器的核心原理就是根据tick数判断是否超时,如果超时拉起定时器回调函数进行执行。基于RTOS内核中的时钟管理,可以方便扩展出软件定时器功能。在每个时钟节拍来临的时候,对系统中存在的软件定时器一并进行处理。

TencentOS-tiny中的软件定时器支持两种模式,通过tos_config.h中的宏定义来选择:

代码语言:javascript
复制
#define TOS_CFG_TIMER_AS_PROC           1u
  • 宏定义使能:配置软件定时器为回调函数模式;
  • 宏定义关闭:配置软件定时器为任务模式;

当配置为第一种模式时,在时钟节拍处理程序中,对应的软件定时器处理任务被使能:

代码语言:javascript
复制
__API__ void tos_tick_handler(void)
{
    if (unlikely(!tos_knl_is_running())) {
        return;
    }

    tick_update((k_tick_t)1u);

#if TOS_CFG_TIMER_EN > 0u && TOS_CFG_TIMER_AS_PROC > 0u
    timer_update();
#endif

#if TOS_CFG_ROUND_ROBIN_EN > 0u
    robin_sched(k_curr_task->prio);
#endif
}

其中软件定时器处理函数为timer_update,源码在tos_timer.c中:

代码语言:javascript
复制
__KNL__ void timer_update(void)
{
    k_timer_t *tmr, *tmp;

    if (k_timer_ctl.next_expires > k_tick_count) { // not yet
        return;
    }

    tos_knl_sched_lock();

    TOS_LIST_FOR_EACH_ENTRY_SAFE(tmr, tmp, k_timer_t, list, &k_timer_ctl.list) {
        if (tmr->expires > k_tick_count) {
            break;
        }

        // time's up
        timer_takeoff(tmr);

        if (tmr->opt == TOS_OPT_TIMER_PERIODIC) {
            tmr->expires = tmr->period;
            timer_place(tmr);
        } else {
            tmr->state = TIMER_STATE_COMPLETED;
        }

        (*tmr->cb)(tmr->cb_arg);
    }

    tos_knl_sched_unlock();
}

其中有一点需要特别注意:

「定时器回调函数被调用时,调度器是处于上锁状态的,当回调函数执行完返回之后,调度器才解锁」

所以在编写软件定时器回调函数的时候,不用担心发生任务调度的情况。

9. 时间片调度算法

时间片调度算法用来处理「系统中同时存在两个优先级相同的就绪任务,且都不让出CPU」的情况,分别按照任务设置的时间片tick数轮流执行。

TencentOS-tiny控制是否开启时间片调度的宏定义为:

代码语言:javascript
复制
#define TOS_CFG_ROUND_ROBIN_EN          1u

开启时间片调度后,在每个时钟节拍来临的时候,对当前任务优先级进行时间片调度:

代码语言:javascript
复制
__API__ void tos_tick_handler(void)
{
    if (unlikely(!tos_knl_is_running())) {
        return;
    }

    tick_update((k_tick_t)1u);

#if TOS_CFG_TIMER_EN > 0u && TOS_CFG_TIMER_AS_PROC > 0u
    timer_update();
#endif

#if TOS_CFG_ROUND_ROBIN_EN > 0u
    robin_sched(k_curr_task->prio);
#endif
}

其中处理时间片调度的函数为robin_sched,在tos_robin.c中,源码如下:

代码语言:javascript
复制
__KNL__ void robin_sched(k_prio_t prio)
{
    TOS_CPU_CPSR_ALLOC();
    k_task_t *task;

    TOS_CPU_INT_DISABLE();

    task = readyqueue_first_task_get(prio);
    if (!task || knl_is_idle(task)) {
        TOS_CPU_INT_ENABLE();
        return;
    }

    if (readyqueue_is_prio_onlyone(prio)) {
        TOS_CPU_INT_ENABLE();
        return;
    }

    if (knl_is_sched_locked()) {
        TOS_CPU_INT_ENABLE();
        return;
    }

    if (task->timeslice > (k_timeslice_t)0u) {
        --task->timeslice;
    }

    if (task->timeslice > (k_timeslice_t)0u) {
        TOS_CPU_INT_ENABLE();
        return;
    }

    readyqueue_move_head_to_tail(k_curr_task->prio);

    task = readyqueue_first_task_get(prio);
    if (task->timeslice_reload == (k_timeslice_t)0u) {
        task->timeslice = k_robin_default_timeslice;
    } else {
        task->timeslice = task->timeslice_reload;
    }

    TOS_CPU_INT_ENABLE();
    knl_sched();
}

从源码中可以看到,时间片调度算法的实现非常简单:当时钟节拍来临的时候,将就绪列表中第一个任务控制块的时间片值递减,如果递减到0,则移到就绪列表的队尾去,让出此次执行机会,内核发生调度。

接下来用一个实例说话,编写两个task任务,采用开篇提到的堵塞延时,不让出CPU:

代码语言:javascript
复制
void task1_entry(void *arg)
{
    printf("task1 start...\r\n");
    while(1)
    {
        printf("task1 is running...\r\n");
        HAL_Delay(1000);
    }
}

void task2_entry(void *arg)
{
    printf("task2 start...\r\n");
    while(1)
    {
        printf("task2 is running...\r\n");
        HAL_Delay(1000);
    }
}

创建任务的时候设置优先级相同,时间片参数为10个tick:

代码语言:javascript
复制
tos_task_create(&task1, "task1", task1_entry, NULL, 2, task1_stack, sizeof(task1_stack), 10);
printf("task1 create success\r\n");
tos_task_create(&task2, "task2", task2_entry, NULL, 2, task2_stack, sizeof(task2_stack), 10);
printf("task2 create success\r\n");

首先在 tos_config.h 中将时间片调度关掉,观察实验结果,任务2虽然优先级相同,但是根本得不到运行:

在 tos_config.h 中将时间片调度开启,观察实验结果:

可以看到两个任务都不让出CPU,因为两个任务的优先级想通过,所以系统依然根据设置的时间片tick数进行轮流调度运行,这也是进一步符合RTOS这种实时操作系统的调度要求。

10. 总结

本文内容比较多,最后来总结一下比较重要的点:

① RTOS内核需要时钟节拍来周期性的处理任务延时、软件定时器、时间片调度的逻辑,所以「移植时必须要提供时钟节拍」

② RTOS内核提供以tick数为单位的延时API,和以ms为单位的延时API,因为不同的平台上每个tick可能对应的时长不一样,所以「建议应用程序采用以ms为单位的API」,更加通用。

③ 软件定时器采用回调函数模式时,执行回调函数的时候系统调度处于上锁状态,执行完毕之后才会解锁,「不用担心会发生任务切换」

④ 调用任务延时函数的时候,不仅仅会使当前任务延时一段时间,更重要的是「会发生一次调度」,使低优先级的任务运行。

下一篇
举报
领券