小小调度器 V1.1 设计原理 (讨论稿)
By smset
前言:
小小调度器是一款基于 C 语言的,协作式多任务编程框架。它基于状态机原理实现,所有任务
均采用公共堆栈,具有简单小巧,易于移植的特点,非常适合于资源紧张的单片机编程使用。
小小调度器的多任务并行机制和传统的状态机的并行机制原理是相通的。
主要的区别在于:小小调度器利用了 C 语言的__LINE__宏,这个__LINE__宏,代表了源文件中
代码的行号,通过将代码行号保存到静态变量的方式,来记录程序运行的位置信息,从而使得
原来需要人工实现的状态值设计、状态变量赋值以及状态跳转的系列编程工作,大部分均由记
录行号的宏自动实现了,使得开发者可以节省很多底层的状态设计和处理的编程工作。
基于此核心原理,小小调度器设计了一个框架和宏定义,以极低的 CPU 资源代价,模拟了一个
和真实 RTOS 系统相似的多任务编程环境,使得编程者可以用更自然、更优雅、更易于理解的方
式,来编写多任务并行代码,把时间和精力,更多的放在应用业务层逻辑的代码实现上。
并行编程的基本原理:
当一个 CPU 需要并行执行几个任务函数时,就意味着这几个任务函数在宏观上看上去是并行运行的。当然在微观上,单核 CPU,只能一个时刻执行一个函数的代码,所以实现并行编程的关键在于,每个时刻 CPU 都只花很少的时间,运行某个任务函数的一小段代码,然后 CPU 以同样的方式,又花很少的时间去执行下一个任务的一小段代码。 这样,从宏观上看起来,多个任务函数在并行执行了。
因此 CPU 实际上是在不断的进入一个任务函数的内部运行一瞬间,并不断从任务函数内部跳至另一个任务的内部运行一瞬间,同时,CPU 下次进入任务函数时,必须从上次跳出的位置接着运行。
因此,这里必然的涉及到几方面的问题:
1) 使用什么机制,能让 CPU 从一个任务函数的某个点,切换至另一个任务函数的某个点。并且再次进入一个任务时,还能从原来跳走的地方接着往下执行。
这个问题的关键 :涉及到代码跳转机制以及代码执行位置的记录。
在 C 语言中,代码的跳转有多种实现机制,两种主要的实现机制:一个是纯粹利用 C 语言本身提供的语法来实现, 另一个是利用底层操作 CPU 内部寄存器来实现跳转。
具体来讲, C 语言本身提供的语法里,有以下几种在函数内部跳转的语法:
比如 goto 配合标签,可以实现一个函数内部代码的跳转,用 if else 实际上在逻辑上也能控制 cpu,用 switch case 也可以实现代码的跳转。
C 语言本身还提供 setjmp, longjmp 机制,实现函数间全局跳转机制。
事实上,基于 goto, if else, switch case,这三种机制,是大多数传统状态机编程所使用的代码块跳转控制语法。而 setjmp/longjmp 则是相对少用的语法。 这几种语法均不涉及到 CPU 寄存器的操作,因此不会因为 CPU 的不同而导致跳转实现的代码有所区别。
另一种机制是利用修改 CPU 内部代码指针寄存器来实现代码跳转,这种机制是大多数 RTOS操作系统使用的,由于设计到底层寄存器的操作,因此针对不同的 CPU,跳转实现的代码实际上是各不相同的。
这里,我们也不想讨论得过于深入,总之就是: 一种是不涉及到寄存器操作的代码切换方式,一种是涉及到寄存器操作的代码切换方式,小小调度器选择的是不涉及到寄存器操作的代码切换方式。
具体来说,小小调度器 V1.1 , 采用的是 switch case 跳转语法,该语法只能在函数内部进行跳转,而不能在任务之间之间进行跳转。采用 switch case 跳转语法,总体来说这种跳转更有优势(在后面的描述中会提到)。
在调度过程中,任务函数是需要先进入函数的开头,从函数开头跳转至函数中间的某个位置,执行一个瞬间,然后退出任务函数。然后下一个任务也是一样的: 先进入函数的开头,从函数开头跳转至函数中间的某个位置,执行一个瞬间,然后退出任务函数。 所有任务依次轮转,从而实现并行效果。这种架构,仔细想想,似乎感觉和传统的状态机编程也没有区别:就是在任务函数开头处,根据状态值,进行代码的散转。
对的,其实框架是相同的,由于框架相同,使得几乎所有状态机可以实现的过程逻辑,都能由小小调度器代替实现。并且 CPU 的资源消耗和状态机实现的代码几乎没有区别。主要的区别在于行号的记录,以及一些语法宏,使得代码变得更简单了。
我们以一个任务为例:
unsigned short task0(){
_SS
while(1){
WaitX(50);
LED0=!LED0;
}
_EE
}
这个任务的作用是: 每 500 毫秒,让 LED1 亮灭翻转一次。
我们先把宏替代了:
unsigned short task0(){
static unsigned char _lc=0; switch(_lc){default:
while(1){
do { _lc=(__LINE__%255)+1; return 50 ; case (__LINE__%255)+1:;} while(0);
LED0=!LED0;
}
;}; _lc=0; return 65535;
}
这里__LINE__实际上是一个行号,在一个文件内,行号从 1 开始,一直递增。(使用__LINE__行号来记录代码运行位置,在已知信息中,最早是由 PT Thread 采用的,PT Thread 是一种多任务机制,有兴趣的可以自行去网上搜索相关资料)
比如 WaitX(50);这个代码行在第 100 行,那么实际上任务函数内就是:
unsigned short task0(){
static unsigned char _lc=0; switch(_lc){default:
while(1){
do { _lc=(100%255)+1; return 50 ; case (100%255)+1:;} while(0);
LED0=!LED0;
}
;}; _lc=0; return 65535;
}
从这个 task0 任务函数,我们可以看到,一开头,就申明了 static unsigned char _lc=0; 这个静态变量。初值为 0, 这个_lc 变量,就是任务函数用来记录运行位置的变量。
这里采用了 static unsigned char _lc 申明, 其中 static 保证了它作为一个静态变量,在任务函数退出后,变量的值不会丢失。
另外,就是它是一个 unsigned char 变量,之所以采用 unsigned char,是只给它分配了一个字节,也就是意味着_lc 只有 0-255 的取值范围。(只分配一个字节,是因为小小调度器 V1.1版本是针对 CPU 资源极致优化的导向。)
在_lc 后,就是一个 switch(_lc){default: 注意,其实这里用 switch(_lc){case 0: 也是一样的。
任务函数开头的第一个 case,必须占用一个常值,因为,当 task0 任务函数整个过程被执行完毕后,当需要重新进入 task0 这个任务流程时,task0 任务函数还能从头运行。因此在 task0任务函数的结尾处,_lc 必须赋值为一个常值,并且这个常值和 task0 任务函数内第一个 case的值是相同的,只有这样才能保证task0 任务函数流程被执行完毕后,在以后重启任务流程时,代码又从第一个 case 处开始执行。
反之,如果没有这个机制,那么当 task0 任务函数被执行完毕后,_lc 就永远的记录的是task0 任务函数内最后一个 case 的位置,再也没有办法从头进入task0 任务流程执行了。(注意,这里说的任务函数执行完毕,是指宏观上一个任务的总体时序流程运行完成,而不是指任务单次执行时的进入和退出)。
既然需要用到一个常数,那么我们自然的选择 0 这个常数,因此,就有了函数尾部_EE 的宏定义里面,_lc=0;将_lc 赋值为 0 的操作。(当然,你说,我选 255 这个常量,可以吗?其实,也是
评论0