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:

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,使用方法如下:

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源码如下:

__API__ void tos_tick_handler(void)
{
    if (unlikely(!tos_knl_is_running())) {
        return;
    }

    tick_update((k_tick_t)1u);
}

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

__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中进行的第一点优化是,设计一条专门用于挂载延时任务的延时列表

/* list to hold all the tasks delayed or pend for timeout */
extern k_list_t             k_tick_list;

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

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

__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给内核,用来方便的向延时列表中添加任务或者移除任务,如下:

__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. 任务延时如何实现

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

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

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

__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;
}

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

__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中配置:

#define TOS_CFG_CPU_TICK_PER_SECOND     1000u

比如:

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

__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:

__API__ k_err_t tos_sleep_ms(k_time_t ms)
{
    return tos_task_delay(tos_millisec2tick(ms));
}

③ 以年月日为单位的任务延时API:

__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中:

__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);
}

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

#define K_TASK_PRIO_IDLE (k_prio_t)(TOS_CFG_TASK_PRIO_MAX - (k_prio_t)1u)

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

#define TOS_CFG_IDLE_TASK_STK_SIZE      512u

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

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

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

#define TOS_CFG_FAULT_BACKTRACE_EN      1u

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

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

8. 软件定时器

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

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

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

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

__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中:

__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控制是否开启时间片调度的宏定义为:

#define TOS_CFG_ROUND_ROBIN_EN          1u

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

__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中,源码如下:

__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:

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:

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,更加通用。

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

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

本期文章就到这儿了~我是喜欢玩板子的小码农Mculover666,下期文章再见!

接收更多精彩文章及资源推送,欢迎订阅我的微信公众号:『mculover666』。

Mculover666 CSDN认证博客专家 嵌入式软件开发 IoT全栈开发
CSDN博客专家,微信公众号mculover666,凭借与生俱来的热爱专注于嵌入式领域,在自己折腾的同时,以文字的方式分享所玩、所思、所想、所悟,作为一个技术人,我们一起前进~
©️2020 CSDN 皮肤主题: 成长之路 设计师:Amelia_0503 返回首页