最近在某校上着一门51单片机课程,学生新学单片机,同时新学C语言,因而软硬件基础暂时都还很薄弱,难以一下子掌握过于复杂的问题,因而不容易编写出结构良好、运行稳定的单片机程序,这可把我愁坏了。
在教完定时器的内容之后,我终于忍不住了,针对学生的特殊情况,用了整整一下午,特别设计了一个超级简单的多任务操作系统,并用了2次课的时间教会学生,终于使学生能够编写稍有复杂度的程序了,效果很不错。
现在,我把这个超级简单的多任务操作系统(暂命名为EventOS)分享出来,给初学者们参考。
1. EventOS操作系统简介:
-
这是一个事件驱动型操作系统,也称为”事件-响应”式操作系统
-
它只是一个框架,没有OS源码,也不需要OS源码
-
通过摸拟单片机的中断响应模型而工作
-
属于非抢占式伪操作系统,但实时性可以达到硬件的极限
-
简单到不需要提供OS源码,懂中断就能写能用
-
暂命名为EventOS
2. 单片机的中断响应模型
单片机的中断响应模型很简单:每个中断源被触发后,都会向单片机的相应中断标志位发送”置1″信号。当单片机的硬件检测到该”置1″信号后,就会自动触发及运行相应的中断服务程序(前提是开中断)。而该中断服务程序一旦运行,单片机就会通过硬件自动或通过软件手动将该中断标志位及时清0,以便该中断标志位能够接收下一次的”置1″信号。如图1所示。
图1 单片机的中断响应模型
3. EventOS的任务响应模型
EventOS模拟了单片机的中断响应模型:每个事件源被触发后,都会向操作系统的相应事件标志位发送”置1″信号。当操作系统检测到”置1″信号后,就会自动触发及运行相应的任务程序。而该任务程序一旦运行,操作系统就会自动或通过任务程序手动将该事件标志位及时清0,以便该事件标志位能够接收下一次的”置1″信号。如图2所示:
图2 EventOS的任务响应模型
但是为了尽可能地简化概念、逻辑,以及代码的设计,减轻学生的学习负担,EventOS对该模型进行了进一步的简化:将中断标志位的检测过程交给具体的任务程序,而非操作系统。当任务检测到自己的事件标志位”置1″后,任务自己去清除该标志位,并主动响应该事件。换言之,EventOS放弃了抢占式任务切换能力,操作系统无法主动触发及运行任务程序,而只能由任务自己查询到事件标志位之后,由任务主动响应之。简化后的任务响应模型如下图所示:
图3 简化后的任务响应模型
5. EventOS的基本框架
呃。。。不就是一个大循环嘛,史上最简单的操作系统,最适合零基础的新手了。
//教学中使用的是STC89C52RC单片机,晶振为12MHz,时钟为1MHz
#include <STC89C5xRC.H>
//定义一个全局变量,用于管理事件标志位(可管理8个)
//8个标志位不够用时,可以多定义几个全局变量,或扩展event的位宽
unsigned char event = 0x00;
//请在这里添加注释,说明每个标置位是干哈的,例如:
//bit0:...
//bit1:...
void main(void) {
//初始化
//......
//轮流执行while(1)中的任务
while(1) {
task0();//0号任务
task1();//1号任务
task2();//2号任务
}
}
6. 任务(task)的基本框架
任务本质上是一个由事件驱动的响应型函数:仅当该任务的事件标置位”置1″时,该任务的具体业务代码才会真的执行。注意,单片机自带的各种中断,它们的中断服务程序本身也可以理解成EventOS的”任务”。只不过这些任务比较特殊,它们的事件标志位(即中断标志位)由硬件分配,而不必在event中分配;同时,它们的响应时机也由硬件触发,而不必在while(1)大循环中调用;它们可应用于实时性极强的场合,从而使EventOS的实时性达到单片机的硬件极限——在这一点上,EventOS的实时性,比其它一切实时和非实时、抢占式和非抢占式操作系统都要高。
void task0(void) {
//仅当检测到事件标志位"置1",才执行代码
if(event & 1 << 0) {
//及时清0事件标志位,使标志位能接收新的事件
event &= ~(1 << 0);
//然后执行具体的业务...
//这里是具体的任务代码
}
}
7. 如何向任务发送事件?
很简单,只要将全局变量event的相应标志位(称为事件标志位)”置1″即可。该事件的响应者(目标任务)会自己检测和清除该标志位,并响应该事件,而无需事件的发起者操心。
//发送0号事件
event |= 1 << 0;
EventOS应用示例
示例1 八位段式数码管显示
电路如图4所示。
图4 截图自LY-51S V3.0
J2的1、2脚分别由单片机的P0.0、P0.1引脚控制,分别作为数码管的位数据、段数据的锁存引脚。J3的1~8脚分别由单片机的P2.0~P2.7引脚控制,作为数码管的位数据、段数据的数据引脚。
数码管的硬件驱动原理:段锁存器输出的图像数据,会同步连接到八位数码管的段引脚上。此时的八位数码管,谁得到了位信号,谁就能显示图像数据。如果都得到了位信号,就都能显示图像数据——但每位数码管显示的图像数据是一样的,这并非我们的期望。若希望每位数码管位分别显示不同的图像数据,可采用分时复用的方法,逐位和循环向八位数码管传送相应的位信号和图像数据,当循环(也叫扫描)速度足够快时,人眼就会误以为八位数码管是”同时点亮”的,因则能看到八数码管的各位稳守地显示不同图像。
根据有关资料,当扫描频率高于25Hz时,人眼感应看不到数码管闪烁。如果你相信这些有关资料,眼睛就废了。实测60Hz时,免强无闪感,但快速晃动数码管时,依然能感受到闪感,眼睛累。扫描频率越高,能够容许的无闪感晃动速度就越快。当扫描频率高于120Hz时,即使以极快的速度晃动数码管,数码管也不会有闪感。故将数码管的扫描频率定为125Hz,即扫描周期为8ms,位周期为1ms。因而,需要通过定时器实现一个1ms的定时。对于12T的STC89C52RC单片机,在12MHz晶振、1MHz时钟下,时钟周期为1us,则1ms需要定时1000个时钟周期。
数码管的软件驱动原理:首先通过定时器循环定时约1ms;然后在1ms中断中发送0号事件。编写一个任务程序,当它检测到0号事件时,立即清除该事件的标志位,然后执行按键扫描任务。该任务每次执行时,都切换一位数码来显示内容,每执行八次,就完成了一轮数码管位的显示,耗时约8ms,每秒能够完成约125次扫描。
1ms定时中断:1ms定时时间到了之后,向事件标志管理器(event)发送0号事件。
void T0_1ms_isr(void) interrupt 1
{
//重新装载,约定时1ms(1000个时钟周期)
TH0 = (unsigned char)((65536 - 1000) >> 8);
TL0 = (unsigned char)(65536 - 1000);
//1ms定时时间到了,发送一个"1ms事件的标志位"
//假定0号事件是"1ms事件标志位"
event |= 1 << 0;
}
//定时器0初始化,工作于方式1,16位计数方式
void T0_init(void) {
TH0 = (unsigned char)((65536 - 1000) >> 8);
TL0 = (unsigned char)(65536 - 1000);
TMOD |= 1 << 0;
ET0 = 1;
TR0 = 1;
}
数码管扫描任务:
需要使用seg.h文件
//给定数值(下标),查找图像数据
unsigned char segMap[10] = {
SEG_IMAGE_0,
SEG_IMAGE_1,
SEG_IMAGE_2,
SEG_IMAGE_3,
SEG_IMAGE_4,
SEG_IMAGE_5,
SEG_IMAGE_6,
SEG_IMAGE_7
SEG_IMAGE_8,
SEG_IMAGE_9,
};
//数码管的显示缓冲区,默认显示20211125
unsigned char segBuffer[8] = {2, 0, 2, 1, 1, 1, 2, 5};
//数码管扫描任务
//仅在0号事件标志位"置1"时执行具体业务
//需要在while(1)主循环中调用本任务
void task_seg_scanf(void) {
static unsigned char cursor = 0;
if(event & 1 << 0) {
//及时将0号标志位清0
event &= ~(1 << 0);
//以下是具体业务代码,每隔约1ms执行一次
//清空上一位数码管的图像数据
//可以消除残影
P2 = 0x00;
//送锁存信号
P0 |= 1 << 0;
//恢复锁存引脚
P0 &= ~(1 << 0);
//写当前数码管的位数据
//共阴数码管,位信号为低电平有效
P2 = ~(1 << cursor);
//同上,只是换了写法
P0 ^= 1 << 0;
P0 ^= 1 << 0;
//写当前数码管的图像数据
P2 = segMap[segBuffer[cursor]];
//同上,只是换了写法
P0 ^= 1 << 0;
P0 ^= 1 << 0;
//使游标在0~7之间循环
if(++cursor == 8) {
cursor = 0;
}
}
}
//数码管初始化
void seg_init(void) {
//确保锁存引脚P0.0和P0.1为低电平态
P0 &= ~0x03;
}
代码解析:cursor为当前需要刷新的数码管的编号(游标),segBuffer[cursor]表示缓冲区中的cursor号数码管的数值,segMap[segBuffer[cursor]]是根据该数值找到对应的数码管图像数据,P2 = segMap[segBuffer[cursor]]; 是把该图像数据赋值给P2,即送到数码管的段引脚。
完整代码:
//教学中使用的是STC89C52RC单片机,晶振为12MHz,时钟为1MHz
#include <STC89C5xRC.H>
#include "seg.h"
//定义一个全局变量,用于管理事件标志位(可管理8个)
//8个标志位不够用时,可以多定义几个全局变量,或扩展event的位宽
unsigned char event = 0x00;
//请在这里添加注释,说明每个标置位是干哈的,例如:
//bit0:事件"1ms定时时间到"的标志位
//给定数值(下标),查找图像数据
unsigned char segMap[10] = {
SEG_IMAGE_0,
SEG_IMAGE_1,
SEG_IMAGE_2,
SEG_IMAGE_3,
SEG_IMAGE_4,
SEG_IMAGE_5,
SEG_IMAGE_6,
SEG_IMAGE_7
SEG_IMAGE_8,
SEG_IMAGE_9,
};
//数码管的显示缓冲区,默认显示20211125
unsigned char segBuffer[8] = {2, 0, 2, 1, 1, 1, 2, 5};
//1ms定时中断服务程序(中断号为1)
void T0_1ms_isr(void) interrupt 1
{
//重新装载,约定时1ms
TH0 = (unsigned char)((65536 - 1000) >> 8);
TL0 = (unsigned char)(65536 - 1000);
//1ms定时时间到了,发送一个"1ms事件的标志位"
//假定0号事件是"1ms事件标志位"
event |= 1 << 0;
}
//定时器0初始化,工作于方式1,16位计数方式
void T0_init(void) {
TH0 = (unsigned char)((65536 - 1000) >> 8);
TL0 = (unsigned char)(65536 - 1000);
TMOD |= 1 << 0;
ET0 = 1;
TR0 = 1;
}
//数码管扫描任务
//仅在0号事件标志位"置1"时执行具体业务
void task_seg_scanf(void) {
static unsigned char cursor = 0;
if(event & 1 << 0) {
//及时将0号标志位清0
event &= ~(1 << 0);
//以下是具体业务代码,每隔约1ms执行一次
//清空上一位数码管的图像数据
//可以消除残影
P2 = 0x00;
//送锁存信号
P0 |= 1 << 0;
//恢复锁存引脚
P0 &= ~(1 << 0);
//写当前数码管的位数据
//共阴数码管,位信号为低电平有效
P2 = ~(1 << cursor);
//同上,只是换了写法
P0 ^= 1 << 0;
P0 ^= 1 << 0;
//写当前数码管的图像数据
P2 = segMap[segBuffer[cursor]];
//同上,只是换了写法
P0 ^= 1 << 0;
P0 ^= 1 << 0;
//使游标在0~7之间循环
if(++cursor == 8) {
cursor = 0;
}
}
}
//数码管初始化
void seg_init(void) {
//确保锁存引脚P0.0和P0.1为低电平态
P0 &= ~0x03;
}
void main(void) {
//初始化
seg_init();
T0_init();
//开总中断
EA = 1;
//轮流执行while(1)中的任务
while(1) {
task_seg_scanf();
}
}