本文内出现的例程基本都是伪代码,不确保能直接运行
一、简易多任务 1、前后台 if else 标志位轮询 如果你需要实现两个不同频率的 led 闪烁任务,那么就不能使用阻塞 delay 了,此时可以考虑使用前后台。
例如下面这段代码,前台主循环不断轮询标志位,后台中断触发标志位,从而实现两个互不干扰的不同周期 led 闪烁任务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 uint8_t flag_led1 = 0 ;uint8_t flag_led2 = 0 ;uint8_t flag_button = 0 ;int main (void ) { while (1 ){ if (flag_led1){ gpio_toggle (led1_pin); flag_led1 = 0 ; } if (flag_led2){ gpio_toggle (led2_pin); flag_led2 = 0 ; } if (flag_button){ printf ("Button state change\n" ); flag_button = 0 ; } } } uint64_t systick_counter = 0 ;void systick_handler (void ) { systick_counter ++; if (systick_counter % 100 == 0 ){ flag_led1 = 1 ; } if (systick_counter % 20 == 0 ){ flag_led2 = 1 ; } } void exti_0_handler (void ) { flag_button = 1 ; }
二、多定时器(multi timer ) 1、硬件多定时器 对于前面这种需要不同周期执行的任务,如果你的硬件资源足够丰富,那么完全可以配置多个硬件定时器,在硬件定时器中断服务函数里执行相应的操作,而且还能配置优先级抢占,处理好资源竞争实时性完全不输 rtos
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 int main (void){ timer_init (&timer1, 100 , 0 ); timer_init (&timer2, 20 , 1 ); while (1 ){ system_sleep_until_interrupt (); } } void timer1_handler (void){ gpio_toggle (led1_pin); } void timer2_handler (void){ gpio_toggle (led2_pin); }
2、软件多定时器 现实是,你根本没那么多的硬件资源,以及硬件定时器灵活性和可移植性会差一点,你可能要套好几个 if else 才能实现一些效果。 所以接下来介绍这个非常常用的软件多定时器方案
对周期任务进行抽象,可以提取出下面这几个基本成员:
抽象:主体从客体身上抽取主体能够理解的意象。在互联网用语上,抽象=难懂的/怪的东西。
周期/重载值
当前值/计数值
任务标志位
任务回调函数
其实设置这些成员变量就像是在配置一个硬件定时器一样,我们只要每次在 systick 中断中将当前值减一,直到减到零,就重置为重载值,并且把标志位置一。主循环不断轮询所有任务的标志位,一旦检测到某个标志位为一,那么就调用它的回调函数来执行相应任务
代码可以写成这样:
1 2 3 4 5 6 7 8 9 10 11 struct task_stu { uint32_t period; uint32_t val; uint8_t flag; void (*task_callback_function)(struct task_stu *self); struct task_stu *next; };
把每个周期任务配置好后就可以通过数组或者链表进行管理和轮询
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 struct task_stu { uint32_t period; uint32_t val; uint8_t flag; void (*task_callback_function)(struct task_stu *self); struct task_stu *next; }; struct task_stu _task_head = {.next = NULL };void task_cb_led1 (struct task_stu *self) ;struct task_stu task_led1 = { .period = 100 ; .val = 100 ; .flag = 0 ; .task_callback_function = task_cb_led1; .next = NULL ; }; void task_cb_led2 (struct task_stu *self) ;struct task_stu task_led1 = { .period = 20 ; .val = 20 ; .flag = 0 ; .task_callback_function = task_cb_led2; .next = NULL ; }; int main (void ) { task_add (&task_led1); task_add (&task_led2); struct task_stu *pTask = _task_head.next; while (pTask != NULL ){ if (pTask->flag){ pTask->flag = 0 ; if (pTask->task_callback_function != NULL ){ pTask->task_callback_function (pTask); } } pTask = pTask->next; } } void task_cb_led1 (struct task_stu *self) { gpio_toggle (led1_pin); } void task_cb_led2 (struct task_stu *self) { gpio_toggle (led2_pin); } void systick_handler (void ) { struct task_stu *pTask = _task_head.next; while (pTask != NULL ){ if (-- pTask->val == 0 ){ pTask->val = pTask->period; pTask->flag = 1 ; } pTask = pTask->next; } }
这样就可以把每个任务放在各自的回调里进行执行,代码更加分明
有些教程会用数组去进行管理,会带来一些局限性,比如不好删除和新增任务,当然,在增删链表节点的时候需要开关中断来保证原子操作
三、 switch case 状态机 软件定时器已经可以解决大部分 项目 ,但是依然不够全面,很多任务并不一定是固定周期执行的,比如下面这个每秒读取温度的任务:
1 2 3 4 5 6 7 8 9 10 11 12 13 int main (void ) { while (1 ){ temp_trans (&my_temp_sensor); delay_ms (20 ); float temp_val = temp_read (&my_temp_sensor); display_temp (temp_val); printf ("temp update: %f\n" , temp_val); delay_ms (980 ); } }
这个时候,就可以通过状态机来实现这个功能,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 uint32_t get_systick (void ) ;float temp_val = 25.0 ;uint8_t flag_temp_read_over = 0 ;void task1_get_temp (void ) { static uint32_t last_tick = 0 ; static uint8_t step = 0 ; switch (step){ case 0 : temp_trans (&my_temp_sensor); step = 1 ; last_tick = get_systick (); break ; case 1 : if (get_systick () - last_tick > 20 ){ step = 2 ; } break ; case 2 : temp_val = temp_read (&my_temp_sensor); flag_temp_read_over ++; last_tick = get_systick (); step = 3 ; break ; case 3 : if (get_systick () - last_tick > 980 ){ step = 0 ; } break ; } } void task2_display (void ) { static uint8_t last_flag_temp_read_over = 0 ; if (last_flag_temp_read_over != flag_temp_read_over){ display_temp (temp_val); last_flag_temp_read_over = flag_temp_read_over; } } void task3_print (void ) { static uint8_t last_flag_temp_read_over = 0 ; if (last_flag_temp_read_over != flag_temp_read_over){ printf ("temp update: %f\n" , temp_val); last_flag_temp_read_over = flag_temp_read_over; } } int main (void ) { while (1 ){ task1_get_temp (); task2_display (); task3_print (); } } uint32_t systick;void systick_handler (void ) { systick ++; } uint32_t get_systick (void ) { return systick; }
这里面的 case 1 和 case 3 分别相当于 delay_ms(20) 和 delay_ms(980),不过并不是阻塞的,它每次进入都会判断时间有没有到,没有到则 break 退出状态机让其他任务执行,到了再切换状态。
或者也可以与软件定时器结合起来实现这个效果,也就是修改软件定时器的周期和计数值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 struct task_stu task_get_temp = { .period = 1000 , .val = 1000 , .flag = 0 , .task_callback_function = task2_get_temp; .next = NULL , }; void task2_get_temp(struct task_stu *self ){ static uint32_t last_tick = 0 ; static uint8_t step = 0 ; switch (step){ case 0 : temp_trans(&my_temp_sensor); self ->period = 20 ; self ->val = 20 ; step = 1 ; break ; case 1 : temp_val = temp_read(&my_temp_sensor); temp_read_over(); self ->period = 980 ; self ->val = 980 ; step = 0 ; break ; } } int main(void ){ task_add(&task_get_temp); while (1 ){ } }
四、事件驱动框架 上面的状态机也能算事件驱动,只有温度更新了才去打印和显示,这样就不会反复打印旧温度,事件驱动的底层就是 if else,所以这里更强调的是事件驱动的“框架”,通过构建框架,可以提供更高的灵活性和更清晰的程序结构。
1、osal(operating system abstraction layer ,操作系统抽象层) 直接看源码,osal 会给每个 task 分配一个 uint16 的事件集变量,每一位可以配置为一个事件,如果某个 task 有事件触发,也就是这个变量某一位不为 0,那么就会执行这个任务,以下是 osal 的部分源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 typedef struct OSALTaskREC { struct OSALTaskREC *next; pTaskInitFn pfnInit; pTaskEventHandlerFn pfnEventProcessor; uint8 taskID; uint8 taskPriority; uint16 events; } OsalTadkREC_t; uint8 osal_set_event ( byte task_id, uint16 event_flag ) ;uint8 osal_clear_event ( uint8 task_id, uint16 event_flag ) ;OsalTadkREC_t *osalNextActiveTask ( void ) { OsalTadkREC_t *TaskSech; TaskSech = TaskHead; while ( TaskSech ) { if ( TaskSech->events ) { return TaskSech; } TaskSech = TaskSech->next; } return NULL ; } void osal_start_system ( void ) { uint16 events; uint16 retEvents; while (1 ){ TaskActive = osalNextActiveTask (); if ( TaskActive ){ HAL_ENTER_CRITICAL_SECTION (); events = TaskActive->events; TaskActive->events = 0 ; HAL_EXIT_CRITICAL_SECTION (); if ( events != 0 ){ if ( TaskActive->pfnEventProcessor ){ retEvents = (TaskActive->pfnEventProcessor)(TaskActive->taskID, events); HAL_ENTER_CRITICAL_SECTION (); TaskActive->events |= retEvents; HAL_EXIT_CRITICAL_SECTION (); } } } } }
这个 osal 个人认为非常清晰明了,它里面还有一些软件定时器和消息组件等等,比较推荐大家使用
下面这个是他人 fork 在 github 的带中文注释版本的 osal 源码:
下面这个视频是 osal 的相关教程:
【Bilibili】@三林渡口 - 【架构分析】嵌入式祼机事件驱动框架_01_任务实现
2、ltx 裸机调度器 ltx 裸机调度器是由软件多定时器发展过来的,除了软件定时器,还有两个基本组件,分别为闹钟和话题。本人最近几个 开源项目 使用的都是 ltx 裸机调度器,因为这个 ltx 就是我写的。 ltx 中的话题就是一个单标志位的事件,只要话题被发布,那么订阅这个话题的所有订阅者都会被调用,例如下面这个中断按键消抖功能:
每次产生边沿中断,都会将消抖闹钟的倒计时重置,直到不抖动 15ms 之后,调用回调,发布事件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 void exti_0_handler (void ){ ltx_Alarm_add (&alarm_debounce, 15 ); } void alarm_cb_debounce (void *param ){ ltx_Topic_publish (&topic_debounce_over); } void subscriber_cb_1 (void *param ){ printf ("button change to: %d\n" , gpio_read (key1)); } void subscriber_cb_2 (void *param ){ gpio_write (led1, gpio_read (key1)); } void subscriber_cb_3 (void *param ){ }
我之前开源的 1us 低延迟反应力测试器项目就是通过闹钟对中断按键进行消抖的。
ltx 源码主循环中也是不断在对话题链表和闹钟链表进行轮询,一旦标志位不为零,那么就依次调用所有订阅者回调
以下是 ltx 源码发布页:
3、qp/c 框架 这是一个体量稍大一点的事件驱动框架。和一般的状态机不同,它更强调层次状态机,也就是状态机套状态机,这个框架更适合管理那种父状态机套着子状态机的上下文,比如一些 gui 场景或者大型项目,层次状态机对于事件的派发会更直观一点。如果想要详细了解的话,它有配套的书籍和比较多的例程可以参考,站内也有一些教程视频:
【Bilibili】QP状态机嵌入式编程
【Bilibili】@__MF - 事件驱动型编程和 QP/C 框架
【Bilibili】@心明可现 - 事件驱动编程05——逐行硬核解析QP/C源码,让编程水平上升一个level!
qpc 源码发布页:
五、协程 1、什么是协程 其实你知道它的全称就能了解个大概了,协作式多线程,任务间是相互协作的,由每个任务自己决定什么时候出让 cpu,而相对的,rtos 里的线程,则能实现抢占式多线程。 如果任务设计得当,协程可以比线程快很多,因为它不需要保存任务上下文,切换开销很低。
推荐观看这个视频,看完便能理解无栈协程的底层逻辑:
【Bilibili】@等疾风 - 【协程革命】实现篇!无栈协程 有手就行?! 全程字幕
接下来介绍几种单片机里的协程方案
2、protothreads (pt-thread) 宏 协程 和上面推荐的视频里的无栈协程非常类似,这个宏协程其实就是对 switch case 进行了一层封装,让你能够更优雅地进行出让。他可以将 label 或者行号作为 case,经过宏封装过后的代码看起来会比普通的 switch case 更清晰,甚至看起来都不像是在写裸机程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 static int protothread2_flag = 0 ;int protothread1 (struct pt *pt) { PT_BEGIN (pt); while (1 ) { printf ("Protothread 1 step1\n" ); PT_YIELD (pt); printf ("Protothread 1 step2\n" ); PT_YIELD (pt); printf ("Protothread 1 step3\n" ); protothread2_flag = 1 ; PT_YIELD (pt); } PT_END (pt); } int protothread2 (struct pt *pt) { PT_BEGIN (pt); while (1 ) { protothread2_flag = 0 ; PT_WAIT_UNTIL (pt, protothread1_flag != 0 ); printf ("Protothread 2 running\n" ); } PT_END (pt); } static struct pt pt1, pt2;int main (void ) { PT_INIT (&pt1); PT_INIT (&pt2); while (1 ) { protothread1 (&pt1); protothread2 (&pt2); } }
将其中宏进行展开:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 int protothread1 (struct pt *pt) { char PT_YIELD_FLAG = 1 ; switch (pt->lc){ case 0 : while (1 ) { printf ("Protothread 1 step1\n" ); PT_YIELD_FLAG = 0 ; pt->lc = __LINE__; case __LINE__: if (PT_YIELD_FLAG == 0 ) { return PT_YIELDED; } printf ("Protothread 1 step2\n" ); PT_YIELD (pt); printf ("Protothread 1 step3\n" ); protothread2_flag = 1 ; PT_YIELD (pt); } } PT_YIELD_FLAG = 0 ; pt->lc = 0 ; return PT_ENDED; } int protothread2 (struct pt *pt) { PT_BEGIN (pt); while (1 ) { protothread2_flag = 0 ; PT_WAIT_UNTIL (pt, protothread1_flag != 0 ); printf ("Protothread 2 running\n" ); } PT_END (pt); }
以下是源码发布页:pt-thread
他人 fork 在 github 的带中文注释的源码:
以下是搜到的两个教程视频:
【Bilibili】@峻德的 - 单片机 嵌入式 protothreads (pt-thread) 协程源码分析
【Bilibili】@我和我的妹妹雯雯 - 单片机宏协程,介于状态机和RTOS之间的选择,三行代码零资源实现丝滑的多任务切换
3、ltx 脚本组件 其实单单对 switch case 进行封装还是不够优雅,因为你还是要在 switch case 里面插入额外的倒计时轮询步骤。
所以接下来介绍 ltx 的脚本组件。 先看效果,打眼一看,其实还是 switch case,但是每个 case 之间可以设置非阻塞的延时时间,并且还可以设置下一个 case 等待某个事件并设置超时时间。 这一切都是依赖 ltx 调度器实现的,即便是协程,也是需要一个调度器的,宏协程的调度器其实就是最外层的 while(1),所以只能靠每个任务内部自己轮询 tick 和 flag,而 ltx 脚本组件是对 ltx 基础组件的封装拓展(话题与闹钟),所以对时间和事件的轮询会交给 ltx 调度器(主循环)。
下面是一个脚本回调的样例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 void script_cb_test (struct ltx_Script_stu *script) { if (ltx_Script_get_triger_type (script) == SC_TRIGER_RESET){ return ; } switch (script->step_now){ case 0 : printf ("This is step0 running...\n" ); ltx_Script_next_step_delay (script, 1 , 200 ); break ; case 1 : printf ("This is step1 running...\n" ); ltx_Script_next_step_delay (script, 2 , 0 ); break ; case 2 : printf ("This is step2 running...\n" ); ltx_Script_next_step_topic (script, 3 , 100 , &topic_button_click); break ; case 3 : if (ltx_Script_get_triger_type (script) == SC_TRIGER_TIMEOUT){ printf ("Step3 wait topic timeout!\n" ); }else { printf ("Step3 get topic\n" ); } break ; } }
效果和 rtos 等待信号量并设置超时时间比较类似,使用起来也比较直观
4、longjmp 协程 我想几乎不太会有人在单片机里用这个,你可以通过 setjmp 保存当前位置上下文,在其他地方可以用 longjmp 跳回来,可以理解为一种跨函数的 goto 。
不太推荐这种做法,所以不做详细展开,想要详细了解可以看一下下面这个视频:
【Bilibili】@阿布编程 - CPU眼里的:setjmp | longjmp
六、裸机多任务与 rtos 多任务的区别总结 1、非阻塞延时 裸机的一大痛点就是不能 直接 实现非阻塞 delay,一般需要把任务切分再通过轮询来实现
接下来是几种裸机里面的实现方法总结:
a、状态机:
例如前面提及的温度检测任务,我们不希望温度转换的 20ms 阻塞系统,那么就需要专门插入一个等待延时完成步骤给状态机。
b、直接修改软件定时器倒计时:
还是前面提及的温度检测任务,对于软件定时器方案,可以把时间轮询交给外部,比如可以直接修改软件定时器的重载值来实现非阻塞延时。
c、ltx 脚本组件
ltx 脚本组件可以设置步骤间非阻塞延时时间。
2、高优先级抢占 优先级抢占可以给产品带来更高的响应性,即便是耗时任务操作,rtos 也可以将其打断切换到其他任务。 单片机作为微控制器其实一般的情况下一个任务不会有太多耗时操作,比如 led 闪烁也就是翻转一下 led 电平,大部分时间都在 delay。cpu 耗时操作一般都体现在计算上面,比如画显存什么的,但是对于这些耗时计算任务,你依然可以手工对其进行切片,把一个 case 切成几个 case,case 之间相当于出让一次 cpu,比如你一个 case 画全屏改为一个 case 画十分之一屏或者更细分。 当然,如果你自己写代码倒是可以对任务进行手动切分,但是如果用一些耗时的闭源的库可能就不好切分了,只能上 rtos 让它来帮你切分。
裸机显然是没办法直接实现高优先级抢占了,但是依然有如下几种能够提高任务响应能力的方案:
a、任务排序:
这就是 osal 的实现方法,使用 osal 框架创建任务的时候,需要给任务设置一个唯一 id,这个 id 还承载着优先级的功能,按照这个 id 来将任务插入到链表的特定位置。 这样,在遍历的时候,高优先级的任务可以先执行,执行完后再从任务链表头遍历而不是继续遍历。这样可以提供一定的优先级能力,但依然没办法在低优先级任务执行的时候被高优先级任务打断
b、systick 抢占
有一些软件多定时器调度教程会额外增加一个优先级变量,一旦这个高优先级任务到时间,那么就直接在 systick 中断里执行,而不是在主循环,这倒是切切实实实现了抢占,不过只能实现两个优先级,以及可能带来资源竞争问题,需要关开中断来实现原子操作
c、硬件定时器抢占
也就是第二节提到的硬件多定时器方案。
3、可移植性 这里谈的不是业务的可移植性,而是框架的可移植性。 大部分裸机调度框架基本只需要你在 systick 中断服务函数里调用一个系统嘀嗒,然后把调度器扔到主循环里就行了,不用太担心硬件平台的差异,而 rtos 就要麻烦一点了,毕竟不同 架构 的 mcu 的寄存器什么的是不一样的,尤其是一些新出的 risc-v 的例程少的 mcu 会让你比较痛苦。
4、资源竞争 裸机的一大优势就是几乎不用考虑资源竞争问题,毕竟任务间不能相互打断,数据直接扔到全局变量里就能交换与通信,也不太需要拷贝,而 rtos 就需要队列或者其他组件来传递数据
5、空闲低功耗 举个例子,你有两个 led,第一个 100ms 闪一下,第二个 20ms 闪一下,那么你的系统其实大部分时间都在轮询这个时间到没到,你完全可以让系统睡 20ms 再起来翻转一下电平然后接着睡
在 rtos 里,一般会提供一个空闲(idle)任务,你可以在空闲任务里进入休眠模式,等到下一次 systick 或者其他中断唤醒系统后触发任务调度跳出 idle
1 2 3 4 5 void task_idle (void *param ){ system_sleep_until_interrupt (); }
在裸机里面,似乎不是很好办?因为裸机里提供不了空闲任务,空闲的时候系统会一直轮询有没有什么任务的事件触发,当然,硬件多定时器那个除外。 以 osal 为例,你可能需要修改他的源码,比如他的获取活跃任务,会通过 if 进行判断是否为空,你可以加一个 else,也就是没有活跃任务,此时便直接进入休眠
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 void osal_start_system ( void ) { uint16 events; uint16 retEvents; while (1 ){ TaskActive = osalNextActiveTask (); if ( TaskActive ){ HAL_ENTER_CRITICAL_SECTION (); events = TaskActive-> events; TaskActive-> events = 0 ; HAL_EXIT_CRITICAL_SECTION (); if ( events != 0 ){ if ( TaskActive-> pfnEventProcessor ){ retEvents = (TaskActive-> pfnEventProcessor)(TaskActive-> taskID, events); HAL_ENTER_CRITICAL_SECTION (); TaskActive-> events |= retEvents; HAL_EXIT_CRITICAL_SECTION (); } } }else { system_sleep_until_interrupt (); } } }
但是这样会带来风险,比如说在获取 TaskActive 为空后,但是进入 system_sleep_until_interrupt() 前,此时产生了一个中断的话,比如按下了一个按键,系统不会去获取新的 TaskActive,那么这次中断事件就得到下一次其他中断唤醒系统后才能响应了。那加入开关中断呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 void osal_start_system ( void ) { uint16 events; uint16 retEvents; while (1 ){ __irq_disable(); TaskActive = osalNextActiveTask (); if ( TaskActive ){ __irq_enable(); HAL_ENTER_CRITICAL_SECTION (); events = TaskActive-> events; TaskActive-> events = 0 ; HAL_EXIT_CRITICAL_SECTION (); if ( events != 0 ){ if ( TaskActive-> pfnEventProcessor ){ retEvents = (TaskActive-> pfnEventProcessor)(TaskActive-> taskID, events); HAL_ENTER_CRITICAL_SECTION (); TaskActive-> events |= retEvents; HAL_EXIT_CRITICAL_SECTION (); } } }else { system_sleep_until_interrupt (); __irq_enable(); } } }
但是这并不完美,关闭中断期间调用的 osalNextActiveTask() 内部其实是在遍历 task 链表,他的时间复杂度是 O(N),所以会比较耗时,会导致响应性变差以及判空效率低。
ltx V1 和 osal 一样,都是对任务链表的所有任务进行事件轮询,时间复杂度是 O(N),所以 V2 对话题进行了修改,不再直接使用链表,而是修改为链表队列,事件产生不再是置位标志位,而是把话题指针加入这个队列,主循环不断弹出话题并执行即可,不用轮询所有话题,所以时间复杂度是 O(1),提高了调度器的响应能力,以及获得了更快的空闲判定。
如果你想要一个真正的空闲任务,那么你可以通过抬升调度器优先级来实现,也就是让调度器跑在一个最低优先级的软中断里,比如 PendSV,此时的主循环就是最低优先级的空闲任务,有事件就触发软中断的标志位让调度器执行,没有任务就退出调度器。ltx V2+ 专门提供了这种能力,更便于实现空闲任务与空闲钩子。
6、tickless 因为 systick 中断的存在,上面的 idle 休眠会 1ms 被 systick 中断唤醒一次,但是醒来后调度器发现没有要做的事就又进入休眠,我们更期望系统能完整睡完 N 毫秒再醒过来,所以你可以通过获取最近一个阻塞任务的延时时间然后修改 systick 的重载值,让他 N 毫秒后再触发中断,然后在唤醒时将 systick 的倒计时更新为下一个任务的到来时间就可以实现 tickless 的效果。
不过一般的裸机框架的 systick 都需要每个 tick 遍历所有定时器的计数值减一,那么获取最近一个需要执行的任务的时间也需要对所有定时器进行遍历,效率会比较低,也会拖慢裸机的响应性。
所以可以仿照一些 rtos 的实现方法对你的裸机框架进行爆改,也就是不能按照传统裸机的每毫秒进一次 systick 去递减所有定时器 tick 计数值,而是根据定时器的倒计时进行顺序插入,每个定时器存储自身与前一个定时器的时间差,那么 systick 只需弹出首(几)个闹钟节点即可,且进行 tickless 操作时只需要获取首个节点的倒计时就行,效率更高。
走到这里,已经接近于手搓一个 rtos 了,如果不想自己手搓,那么可以直接使用 ltx V3,其内建空闲任务与 tickless 支持。