【说在前面的话】
众所周知,一个简单的嵌入式设备都不是仅仅有一个功能。而每一个功能就相当于程序中的一个驱动(任务),比如一个时钟,就有屏幕、按键、时钟芯片等。因此,要实现一个时钟,就需要实现屏幕显示任务、按键设置任务、和时间计算任务。而在上一篇中我们已经用状态机简单实现了多任务,不过他的缺点就是延时不够精确,所以我们今天就用硬件定时器来精确延时,并实现一种基于分时轮询调度算法的多任务调度器。
我们先简单介绍一下分时轮询,这可是在像51单片机这种小资源单片机中很常用编程结构。所谓分时,就是把多个任务按照时间节点进行划分(也就是这个任务每秒执行一次,那个任务10ms执行一次……)。这样,多个任务就按照时间节点分开了。
那怎么知道到了时间节点该运行哪个任务呢?
这时,就需要使用一个定时器,比如定时1ms,也就是每毫秒询问一下,该任务到没到设定的时间,如果时间到就去执行,每个任务挨个询问一遍,这就是轮询,是不是很简单。
小提示:不过这种分时轮询的程序结构也是有一些小缺点的,就是对一些实时性要求高的任务可能导致处理不及时,这时就需要抢占式的调度器来完成了,不过我们今天不做论述。
【硬件定时器的使用】
好,知道了分时轮询,那我们就用51单片机的硬件定时器,来实现一个精确延时1ms的定时器中断程序。
这里我们也简单讲一下中断的概念。所谓中断是指CPU正在执行某段程序时,外部或内部发生了随机事件,请求CPU去处理。此时,CPU会暂停正在执行的程序转而去执行中断服务程序。如下图
有了中断的概念,我们就看看STC89C52单片机定时器中断程序怎么实现。我们今天要使用的是硬件定时器T2(16位可自动重装),因此我们主要讲一下定时器T2。首先打开数据手册,他一共有6个寄存器,如下图
第一个寄存器T2CON是T2的控制寄存器,如下图
TF2为定时器T2溢出中断标志位。也就是说定时器的定时时间到,此位会置1,且该位必须由软件清零。
TR2位定时器2启动/停止控制位。当TR2为1时,T2开始计数;当TR2为0时,T2停止计数
C/T2为定时器、计数器选择位。也就是当C/T2=0时为定时器(今天使用定时器功能),C/T2=1时为计数器
EXEN2为T2外部触发允许控制位。当EXEN2=0时,外部T2EX的跳变对定时器T2无效;当EXEN2=1时,T2EX引脚的负跳变可产生捕获(这次用不到这个功能)
CP/RL2为捕获/重装选择位。当CP/RL2=0时,定时器T2工作在可重装方式(今天使用这个方式);当CP/RL2=1时,且EXEN2也为1,T2工作在捕获方式。
好了,这个寄存器就讲这么多,其他今天用不到的大家可以自己看数据手册(* ̄︶ ̄)
接下来再讲一下T2MOD寄存器,如下
T2OE为时钟输出控制位。当T2OE=0时,禁止T2作为时钟输出(今天这里需要禁止输出);当T2OE=1时,允许T2作为时钟输出
DCEN为递增、递减计数使能位。T2可设置成递增或递减模式,当DCEN=0时,T2(默认)为递增计数(今天使用递增计数)
剩下4个寄存器就简单了,他们两两是一对,也就是给16位定时器装初值的(即不同的值代表不同的定时时间)。
那我要定时1ms,初值应该装多少呢?
这个就需要我们计算了。下面以12M晶振为例来计算如下
晶振12M = 12 000 000 |
1s = 1000ms |
STC89C52是12T单片机,也就是12个晶振周期执行一条指令 |
定时器计数加1需要的时间为 1000/12000000*12=0.001ms=1us |
定时器T2是16位的,计数范围为0~65535,定时范围为0~65.535ms |
所以定时1ms,需要计数1000 |
定时器设置为递增,所以初始值为65535-1000=64535 |
即TH2=64535>>8; TL2=64535&0xff; |
由于我们需要定时器溢出产生中断后可以自动重装,所以RCAP2H = TH2;RCAP2L = TL2; |
好,下面我们就开始编写定时器T2的初始化程序,让它定时1ms,如下
void timer2_init(){
//初始化为自动重装且递增计数
T2CON = 0x00;
T2MOD = 0x00;
//装初值
RCAP2L = TL2 = T1MS; //initial timer2 low byte
RCAP2H = TH2 = T1MS >> 8; //initial timer2 high byte
TR2 = 1;//T2启动计数
}
有了初始化函数,那怎么编写定时器2的中断函数呢?
这个就需要查看数据手册里的中断号了,如下
定时器2使用的中断号为5,使用中断程序就可以这样写,如下
int count = 1000;
void tm2_isr() interrupt 5
{
TF2 = 0;//清除中断标志位
if (count-- == 0){//1秒
count = 1000;
LED1 = ~LED1;//每秒翻转一次LED
}
}
tm2_isr函数名我们可以自己随便取,但是后面的interrupt 5是固定的,也就是加了interrupt 5的函数就是定时器2的中断服务函数了
注意:在中断函数中要记得手动清除中断标志位TF2
我们定义了一个count变量,初始值为1000,每次进入中断函数进行减减(也就是1ms减一次),减到0后让LED灯进行反转。所以我们会看到LED小灯隔1秒闪一次。
哈哈,是不是觉得一个简单的定时器中断服务程序就编写好了,
然而,并没有
因为在初始化中我们还没有打开定时器2的中断,此时需要查看数据手册的中断允许寄存器IE,如下
EA为单片机的总中断允许控制位。EA=1,单片机开放中断;EA= 0,屏蔽所有的中断申请
ET2为定时器T2的溢出中断允许位。ET2=1,允许T2中断;ET2=0,禁止T2中断
知道了这个,下面把初始化程序再修改一下,如下
void timer2_init(){
//初始化为自动重装且递增计数
T2CON = 0x00;
T2MOD = 0x00;
//装初值
RCAP2L = TL2 = T1MS; //initial timer2 low byte
RCAP2H = TH2 = T1MS >> 8; //initial timer2 high byte
TR2 = 1;//T2启动计数
ET2 = 1;//打开T2中断
EA = 1;//打开全局中断
}
定时器T2的中断函数就讲完了,接下来就进入今天的主题,实现一个基于分时轮询调度算法的多任务调度器。
【分时轮询调度器】
在实现这个调度器之前,我们需要用C语言中的结构体来定义一个任务,方便调度器调用。定义任务结构体就需要有任务函数,这里我们需要用到C语言中的函数指针。C语言中的函数指针长这样,如下
void (*fun)(void);
是不是有点变态,变量名居然在中间。不过也简单,只要先写出函数原型,然后把函数名用小括号括起来,在里面再加个*号就可以了(* ̄︶ ̄)
当然,你也可以用typedef来定义一个函数指针,如下
typedef void fun_t(void);
fun_t* fun;
这样是不是就和定义普通的指针变量一样了(* ̄︶ ̄)
当然,函数指针的讲解也可以看看下面这篇文章,里面还讲了函数指针的套娃形式哦(* ̄︶ ̄)
有了函数指针,我们就开始定义这个结构体,如下
typedef void fun_t(void);
typedef struct {
char statue;//运行状态
int cycle;//运行周期
int count;//计数变量
fun_t *run;//任务函数
}task_t;
statue表示运行状态,statue=1,表示运行此任务;statue=0为不运行此任务
cycle为运行周期,比如数码管驱动任务每2ms执行一次,则cycle = 2
count为计数变量,在定时器中断函数中加加,也就是每毫秒增加一次,达到cycle的值时使statue置1
run就是任务函数了,statue=1时执行。
有了任务的结构体,那我们就用他定义两个任务,一个数码管驱动任务,一个时钟显示任务,如下
task_t tasks[TASK_NUM];
void tasks_init(){
//数码管驱动任务
tasks[0].statue = 0;
tasks[0].cycle = 2;
tasks[0].run = &smg_display;
//时钟显示任务
tasks[1].statue = 0;
tasks[1].cycle = 1000;
tasks[1].run = &smg_clock;
}
数码管驱动每2ms调用一次,时钟显示1000ms调用一次
接下来我们就让计数值在中断函数中加加,如下
void tm2_isr() interrupt 5
{
char i;
TF2 = 0;//清除中断标志位
for(i = 0; i < TASK_NUM; i++){
tasks[i].count++;//计数值加1
if(tasks[i].count >= tasks[i].cycle){
//计数值大于周期清零
tasks[i].count = 0;
//此任务转为运行状态
tasks[i].statue = 1;
}
}
}
用for循环查询计数值是否大于等于周期,如果是则把运行标志置1
最后,就该在main函数中运行多任务了,如下
void task_progress(){
char i = 0;
for(i = 0; i < TASK_NUM; i++){
//判断任务是否是可运行状态
if(1 == tasks[i].statue){
tasks[i].statue = 0;
(*tasks[i].run)();//运行该任务
}
}
}
void main(){
timer2_init();
tasks_init();
while(1){
task_progress();
}
}
在task_progress函数中,用for循环判断任务是否是可运行状态,如果是则把状态标志清零,然后运行该任务。
(*tasks[i].run)()这个就是用函数指针来调用任务函数
好了,到这里,我们的分时轮询调度器就编写完成了,是不是很简单(* ̄︶ ̄)。当然,我们上一篇写的数码管驱动程序是不能直接拿来用的,需要简单修改,如下
void smg_display(void){
static unsigned char i=0;
SMG_A_DP_PORT=0x00;//消影
P2 = (P2 & 0xf8) | i;//位选
P0= data_display[i];//段选
i++;
if(i >= 8){
i = 0;
}
}
因为每2ms就调用一次这个任务函数,所以把之前的软件延时给去掉了,这样就更简洁了(* ̄︶ ̄)
时钟显示任务我就不在这里修改了,相信大家也会了。不过有需要这个源码的可以在公众号回复【51单片机】来获取。
接下来就是今天的彩蛋环节
彩蛋环节依然是对上面的代码进行优化,使其变得更简洁和优雅。
首先,我们的调度器在添加任务时只能手动在初始化函数中添加,那能不能和其他嵌入式操作系统一样可以动态添加任务呢?
答案是肯定的。
要实现动态添加任务,我们需要再定义一个结构体,如下
typedef struct{
char taskNum;//当前有几个任务
task_t tasks[TASK_NUM_MAX];//任务数组
}task_manage_t;
taskNum用来表示任务数组tasks中有多少个任务
tasks数组最多可以添加3个任务,当然,你也可以修改TASK_NUM_MAX宏来增加任务数
接下来再写一个添加任务的函数,如下
task_manage_t task_manage;
void add_task(int cycle,fun_t *run){
if(task_manage.taskNum < TASK_NUM_MAX){
task_manage.tasks[task_manage.taskNum].statue = 0;
task_manage.tasks[task_manage.taskNum].cycle = cycle;
task_manage.tasks[task_manage.taskNum].run = run;
task_manage.taskNum++;
}
}
此函数有两个参数,第一个cycle就是任务运行周期,第二个参数run就是任务函数
函数中首先判断任务数是不是达到最大值,如果没达到,则添加任务到任务数组(否则添加失败)
每添加一个任务,task_manage.taskNum都要增加1
有了它,初始化任务函数就可以这样写了,如下
void tasks_init(){
task_manage.taskNum = 0;
add_task(2,&smg_display);
add_task(1000,&smg_clock);
}
记得首先要把task_manage.taskNum的值清零哦,然后添加任务就可以了
怎么样,是不是有点嵌入式操作系统的样子啦
到这里,我们的分时轮询调度器就讲完了,当然还有一些不足之处,还请大家为我指出来。我也建了一个单片机学习交流群,欢迎大家加入,一起学习(* ̄︶ ̄)
原创不易,如果你喜欢我的公众号、觉得我的文章对你有所启发,
请务必“点赞、收藏、转发”,这对我很重要,谢谢!
欢迎订阅 嵌入式小书虫