Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >使用KEIL C51实现的简单合作式多任务操作系统内核

使用KEIL C51实现的简单合作式多任务操作系统内核

作者头像
用户4645519
发布于 2020-09-07 03:00:14
发布于 2020-09-07 03:00:14
1.8K00
代码可运行
举报
文章被收录于专栏:嵌入式学习嵌入式学习
运行总次数:0
代码可运行

以前做课程设计时候,在51上实现了一个简单的合作式操作系统内核。写的时候,主要是出于检验自己单片机原理和操作系统知识的目的。这个内核现在看来,功能非常简单,实时性也不高,但是它毕竟是在51单片机上用不到每个线程17B的内存实现了一个多任务并行处理功能,而且完全用C语言写成,没有用到汇编。所以整理发出,权为资料整理。

1 单片机上的多任务操作思路

在本实验当中,涉及到了实时性较高的电机控制,DS18B20的读写有严格的时序要求。而数码管动态显示、特别是按键扫描等涉及到了不定的延时。这两种设备在实时性上有着一定的冲突。因此,实现思路有三种: 1. 无限循环+中断的前后台系统。 2. 有限状态机(FSM)系统。 主要思路如下:一个定时器生成一个系统基准时间systick(如1ms加1) 。其它任务拆分为多个状态放入主循环当中,通过状态转换和systick进行工作。 例如,按键状态机分NOT_PRESSED, PRESS_DELAY, PRESSED,REALEASE_DELAY四个状态。 3. 使用调度器的操作系统。 第一种方式在应用简单的情况下,具有编写容易、系统本身不耗费资源的优点。但当程序复杂时,各模块前后耦合维护复杂,而且很难保证实时性(当高优先级任务需要处理时,会由于低优先级任务正在运行而得不到及时处理)。如果使用中断,则当任务变多时将没有足够的中断可用,而且中断当中加入过多的程序也是稳定性的大忌。 第二种方式主要思路如下:首先使用一个变量systick存放系统运行时间(在1ms定时器中断中自加)。而后每个外设结合systick,根据当前运行状态判断是否进行状态转换,并执行相应操作。该方法实时性好,逻辑性强,且不必对PC,SP进行操作。但缺点是程序编写非常复杂。 第三种方式将不同的模块分为不同的任务,并根据优先程度赋予不同的优先级。在调度器的作用下,各任务在宏观上达到了一个“并行运行”的效果。该方法实时性好,任务编写容易,由于采用了合作式调度器,也不必担心任务的可重入性。缺点是调度器编写复杂,且本身会产生一定开销。

1 多任务切换原理

CPU是依靠PC来确定执行的程序。所以要想在多个函数之间切换,理论上只需要修改PC值即可。但单纯的修改PC值的话,原有的运行状态就会丢失,所以必须保护此时的运行状态(寄存器R0~R8还有PSW,SP)。这个过程很像中断服务程序:函数调用过程中,LCALL指令等的返回值还有被保护的寄存器值将被保存在堆栈当中,待结束之后返回原程序时从堆栈恢复。除此之外,C语言中的一些局部变量也是存放在堆栈当中的。如图:

所以,最基本的调度器如下:在系统的初始化阶段,给每一个任务分配一个私有的栈空间。这样,在任务切换时,只需要将需要保护的现场PUSH入堆栈,将被切换的任务的现场恢复(将被保存的通用寄存器R0~R8和PSW写入),再将SP指向被切换任务的私有栈即可。如图:

2 KEIL C51多任务切换实现

对于KEIL C51而言,情况有所不同。KEIL C编译器在处理函数调用时的约定规则为"子函数有可能修改任务寄存器",因此编译器在调用前已释放所有寄存器,子函数无需考虑保护任何寄存器.因此,只需要修改堆栈SP和PC即可。

基于这一特性,调度器写为了一个C语言函数的形式。

最初写好的基本切换函数如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void os_switch()
{  
    task_sp[task_id] = SP;  
    if(++task_id == MAX_TASKS)  
        task_id = 0;   
    SP = task_sp[task_id];
}

逐句解释:

首先,任务A在合适地方调用该函数进行切换,当进入该函数之前,R0~R8已被释放无需保护,而LCALL指令将2字节的PC地址PUSH入堆栈,SP+2。

随后,当前任务A(任务号task_id)的堆栈栈顶SP存入数组task_sp[]中。而后task_id自加指向下一个任务B(溢出则归零)。

而后,SP指向了任务B的堆栈栈顶(被存在了task_sp[task_id])。此时栈顶的是任务B在上一次切换(调用os_switch())时被压入的断点PC地址。

当函数结束,调用RET指令返回时,任务B栈顶的断点PC地址被自动写入PC,函数从任务B上一次切换的位置继续执行。

3 带软件定时器的调度器

以上的基本调度器非常精简,调度开销也非常小。但是它实际上是一个无优先级的调度器,也不具备软件定时器功能。程序流程图如下:

而在一般的应用中,我们往往需要一个软件延时。例如:按键去抖、周期性采样等等。所以,这就要求有一个软件定时器功能。因此,修改调度器如下:

首先定义任务控制器数据结构,加入一个延时记录:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void os_switch()
{  
    task_sp[task_id] = SP;   
    if(++task_id == MAX_TASKS)   
        task_id = 0;   
    SP = task_sp[task_id]; 
}

这样,调度函数改为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
/*
 *    任务调度,转向当前延时时间到且优先级最高(id较小)任务
 */
void os_switch(void)//任务切换
{
        unsignedchar  i=OS_TASK_NUM;
        do{
                i--;
                if(os_task[i].delay==0)//如果有任务延时时间到,则跳转至相应任务
                        SP=os_task[i].sp;
        }while(i); //否则不改变SP,继续执行os_idle()
}

进入过程一样。在函数中首先将各个任务的delay--,如果计数为0则跳转至相应函数(SP赋值为相应的私有堆栈指针)。

可以看出,任务的id越小,优先级越高(例如任务1,2均计时到0,首先会任务2赋值给SP,而后检测到任务1也计时为0,SP会被任务1覆盖)。

但是这样有一个问题,假如任务0调用了os_switch()进行调度。而此时所有任务都尚未计时到0,则SP未修改,重新执行任务0,相当于任务0没能进行延时。这是不允许的。所以,必须加入一空闲任务

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
/*
 *    调度任务+空闲任务,执行任务调度;当前无需要调度任务则执行本任务
 */
void os_idle(void)
{
        while(1)
        {
                os_switch();
        }
}

空闲任务很简单,只是一个无限循环,不停的进行任务调度。当所有其它任务都挂起时,os_switch()就不会修改SP,因此任务仍然停留在SP当中。

Os_idle()也需要一个固定私有栈空间,由于不需要delay部分,因此只需要简单地定义:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
data unsignedchar  os_idle_stack[15];

在其它操作系统中如uc/OS-II中,调度器是放在中断中的,而os_idle()在不加入其它功能时只是一个while(1)。但是,由于C51对中断程序的处理与普通函数不同,会视情况压入不同个数的寄存器(从3个到13个不等)。所以出于简单起见,将调度器放入了idle任务。相比较而言,效率有所下降。

而后,作为一个软件定时器,需要定时对计数变量delay更新,这一工作放入定时器:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void os_update_time(void)
/*
 *    更新任务延时表
 *    注:应定时更新,最好放入定时器中断
 */
{
        unsigned char  i=OS_TASK_NUM;
        do{
                i--;
                if(os_task[i].delay)
                        os_task[i].delay--;
        }while(i);
}

调用间隔可以任意但必须一致,本设计设定为1ms一次。

任务放弃CPU占用可以使用os_switch(),添加延时就需要:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void os_delay(unsigned char id,unsigned char        delay)
//修改任务工作块并跳转入os_idle()进行任务切换
{
        TR0=0;//关中断
        {
                os_task[id].delay+=delay;      //延时设定
                os_task[id].sp=SP;             //保存SP
                SP=os_idle_stack+1;            //SP指向os_idle_stack[1]
                                               //os_delay()结束后跳转os_idle()
        }
        TR0=1;
}

该函数将任务控制器OS_TASK添加延时、保存SP,并跳转入os_idle()执行切换。注意这里必须关闭中断TR0。这是为了防止在该函数中碰到定时器中断(调用os_update_time()),从而出现延时错误。例如:

在某时刻任务1使用os_delay()函数延时1ms,在os_task[id].delay+=delay;之后碰见中断,将os_task[id].delay--,这样os_task[id].delay将等于0,等同于没有进行延时。

4 任务控制器的数据结构和初始化

任务控制器的数据结构在上一节已经说的很清楚,再次列举如下:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
typedef    struct
{
        unsigned char  delay;//当前延时剩余时间
        unsigned char        stack[OS_TASK_STACK_SIZE]; //私有堆栈
        unsigned char  sp;//私有堆栈指针
}OS_TASK;//任务工作块。

但有一点必须注意,任务控制器只能放在内存data区(低128B内存),换言之,所有任务控制器占用的RAM少于120B。这是因为51的堆栈只能放在data区,PUSH、POP指令也是操作的data区。

因此,可以说堆栈空间非常有限,任务的数量受到限制。最重要的是,任务中允许中断嵌套的子程序数目有限。私有堆栈当中,最低2B是任务入口;由于中断随时可能发生,因此必须从最坏情况考虑留出13B空间;剩下的才是子程序调用允许使用的。假如子程序中局部变量不多不需要将局部变量放入堆栈,则每嵌套一层子程序需要2B(LCALL压栈PC)。故定义:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
#define    OS_TASK_STACK_SIZE        (2+13+2*3)//存放断点2B,中断函数可能压栈13B,子程序每嵌套一层2B
data       OS_TASK       os_task[OS_TASK_NUM];//必须定义为data(因堆栈只能在data区)

由于是全局变量,os_task[]元素初始值为0,故必须初始化。其函数为:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void os_load(unsigned char id,void(*func))
/*
 *    装载任务入对应工作块
 *    参数:任务id,任务函数
 */
{
        os_task[id].sp=os_task[id].stack+1;//私有堆栈指针指向私有堆栈
        os_task[id].stack[0]=(unsignedint)func&0xFF;//私有堆栈栈底存放任务函数入口
        os_task[id].stack[1]=(unsignedint)func>>8;
}

5 多任务系统编写规范

在作了如上处理之后,就可以方便地使用多任务系统了。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void main()
{
        //…初始化外设
        //…初始化os所用定时器
        os_load(0,os_task_0);
        //…初始化其它任务控制器
       os_idle_stack[0]=(unsignedint)os_idle&0xFF;
        os_idle_stack[1]=(unsignedint)os_idle>>8;
        SP=os_task[0].sp;//运行任务0
        return;//跳转,永不返回。
}

在main()函数当中,首先是初始化外设,而后使用os_load()初始化各任务控制器和空闲任务控制器。最后跳转入任务0,永不返回。

而各个任务则各自独立,为超级循环结构。简而言之,与一般程序的main()函数相同:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
void os_task_0(void)
{
#define    OS_CUR_ID  (0)
static unsigned char i=0;
        //KEIL一般分配临时变量在RAM不在堆栈
//因此为了防止任务之间改写凡是作用域跨越os_delay()应作为static
        while(1)
        {
                MOTOR_Driver();
                os_delay(OS_CUR_ID,1);
        }
#undef OS_CUR_ID
}

有一点必须注意:局部变量必须定义为static。这是由于KEIL C51为了节省内存,局部变量只要可能就存放在了寄存器R0~R8中。这样,一旦任务切换,局部变量相当于被覆盖。

由于是合作式调度器,不存在抢占式调度器中任务被直接打断的风险。因此,除局部变量必须定义为static外,无需加入任何可重入性代码。

6 主要问题:

1. OS设计:

OS设计思路在第2节有详解,不再赘述。任务如何切换、延时如何加入、调度器位置(在中断中还是idle任务中)、数据结构如何设计、如何优化代码……都是曾经碰到的问题

以上问题固然有难度,但写起来并无“憋屈”之感,反而写完后颇有自得之意。但主要瓶颈在于51的内存特别是能作为堆栈的内存过小,这在程序设计上带来几个重大束缚:

l 可供嵌套的子函数嵌套深度过小,使得子程序设计时不敢嵌套过多,不得不在一个任务中集成过多功能,与模块化的思路不符。

l 可以运行的任务过少,使得任务中不得不加入多个外设控制,并使用状态机切换。这使得多任务运行的优势大大削弱。

l 为了能运行4个任务,不得不将data区(低128B)几乎全部占用。使得其它全局变量不得不放入idata(高128B)乃至pdata(外部RAM的低256B),使得程序运行效率下降

不过,由于本OS的易移植性,如果将该系统移植到可用栈内存更多的CPU上,该缺点即可几乎忽略不计。还可以加入更多更复杂的功能。

2. 准面向对象设计

在外设驱动编写时,起初试图将外设的所需变量和操作方法封装成一个类,例如:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
typedef    struct
{
        long disp_num;               //待显示数字,-99999~999999
        unsigned char  ptr;  //动态扫描当前显示位指针
        unsigned char  point_signal;   //小数点标志位,对应位为1则对应位小数点显示
        void (*Set)(NIXIE_DISP_TYPE,long); //按照格式设定示数
        void (*Driver)(void);                             //驱动程序
}NIXIE_STRUCT;
NIXIE_STRUCT   DEV_NIXIE;

这样,驱动程序的组织更有条理,在编辑器中还能直接使用自动完成功能(少敲很多字啊)。更重要的是,当需要不同显示方式(如显示整数/负数/小数)时,只需要将不同的函数指针赋值给NIXIE.Driver(如NIXIE.Driver=NIXIE_Driver_Uint;),就能使用同一个代码(NIXIE.Driver();)调用函数,还减少了使用switch语句的开销,增改也更加容易。

在未加入OS之前,该方法是可行的。但是当加入OS之后,该方法就失效了。通过单步调试发现,当运行NIXIE.Driver();之后,程序跑飞。

上网查询,发现这一原因很复杂。不过简单说来,就是因为LCALL指令之后只能跟addr16这样的立即数,也就是说硬件不支持函数指针。而一般情况下,KEIL通过换算,将指针换算为了地址。例如,NIXIE.Driver();实际上换算为了如:

LCALL 0510H

所以,当使用OS时,程序的顺序执行结构被打乱,所以当然不能使用函数指针了。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2017/12/22 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
使用KEIL C51实现的简单合作式多任务操作系统内核(单片机实现版本)
基于https://blog.csdn.net/yyx112358/article/details/78877523
用户4645519
2020/09/07
9950
简单的51单片机多任务操作系统(C51)
在网上看到这段代码,所以自己尝试了,可以跑起来,但是没有精确的定时功能,仅仅是任务的调度而已。
用户4645519
2020/09/07
2K0
简单的51单片机多任务操作系统(C51)
51多任务系统,可以运行
图中可以看出,调用rtos_wait(100)后,PC=PC+3=0x0163,SP=SP+2;把PC值压栈,可以参考LCALL addr16这条汇编指令
用户4645519
2020/09/07
5340
51多任务系统,可以运行
OpenHarmony 内核源码分析(中断切换篇) | 系统因中断活力四射
小帅聊鸿蒙
2025/03/19
770
OpenHarmony 内核源码分析(中断切换篇) | 系统因中断活力四射
UCOSII系统移植详解「建议收藏」
1,处理器的C编译器能产生可重入型的代码,如果不行的话,那么就不能在任务之间随意的切换,因为当你切换到别的任务的时候,该任务在这个函数的数据就会被破坏。
全栈程序员站长
2022/08/19
2.4K0
值得一看|一种轻便的裸机多任务实现方法
  你是否还在为一大堆任务放在while中,通过一个个标志,做一大堆if...else...switch...case...烦恼,想跑个freertos或者ucos,发现芯片空间有限,添加不进去了...那本文小飞哥推荐你一种裸机多任务的实现方法,让你告别繁琐的while(1),有错误之处,烦请指出,一起交流~
用户8913398
2021/08/16
1.1K1
值得一看|一种轻便的裸机多任务实现方法
用DeepSeek学嵌入式9:74HC595的使用
51单片是一种低功耗、高性能CMOS-8位微控制器,具有8K可编程Flash存储器,使得其为众多嵌入式控制应用系统提供高灵活、超有效的解决方案。
电子工程师成长日记
2025/04/27
840
用DeepSeek学嵌入式9:74HC595的使用
动手写简单的嵌入式操作系统一
业余时间想研究一下RTOS,但是现有的嵌入式系统很多,代码量也很大,厚厚的一本书,又是任务控制块,又是链表又是指针的指来指去,让人不耐心点根本看不下去,也没太多时间去研究。于是就有了自己动手去做的想法,这样可以提高兴趣.比看书有意思。慢慢的发现,操作系统也没有那么神秘。触发软中断,保存堆栈,开始进行任务切换。于是一个多任务就出来了,但是一个完整的操作系统并不简单,涉及到一系列的算法和数据结构的运用,还有系统的引导程序bootloader,内存管理,文件系统,网络管理,IO驱动管理等模块。
杨永贞
2020/08/04
7490
从零手写操作系统之RVOS抢占式多任务实现-06
本系列参考: 学习开发一个RISC-V上的操作系统 - 汪辰 - 2021春 整理而来,主要作为xv6操作系统学习的一个前置基础。
大忽悠爱学习
2023/10/11
4010
从零手写操作系统之RVOS抢占式多任务实现-06
如何设计嵌入式系统?带你理解一个小型嵌入式操作系统的精髓
1 多任务机制 其实在单一CPU 的情况下,是不存在真正的多任务机制的,存在的只有不同的任务轮流使用CPU,所以本质上还是单任务的。但由于CPU执行速度非常快,加上任务切换十分频繁并且切换的很快,所以我们感觉好像有很多任务同时在运行一样。这就是所谓的多任务机制。 实时系统的特征是延时可预测,能够在一个规定的时间内(通常是 ms 级别的)对某些信号做出反应。 2 任务的状态 任务有下面的特性:任务并不是随时都可以运行的,而一个已经运行的任务并不能保证一直占有 CPU 直到运行完。一般有就绪态,运行态,挂起态等
刘盼
2018/03/16
1.4K0
如何设计嵌入式系统?带你理解一个小型嵌入式操作系统的精髓
嵌入式实时操作系统UCOSII[通俗易懂]
1.什么是操作系统? 操作系统是管理和控制计算机硬件与软件资源的计算机程序,是直接运行在“裸机”上的最基本的系统软件,任何其他软件都必须在操作系统的支持下才能运行。介于APP和硬件之间。
全栈程序员站长
2022/08/19
4.5K0
嵌入式实时操作系统UCOSII[通俗易懂]
嵌入式:ARM中断系统设计全解
ARM920T能处理有8个异常,他们分别是:Reset,Undefined instruction,Software Interrupt,Abort (prefetch),Abort (data),Reserved,IRQ,FIQ ,它们的矢量表是:
timerring
2023/01/04
1K0
嵌入式:ARM中断系统设计全解
《Linux内核分析》之操作系统是如何工作的 实验总结
实验阶段,由于学校网速等条件限制,未能在真机上搭建出实验环境。在实验楼中,将代码粘贴进去出现严重的缩进错位,最终未能完成编译新的。本文以分析关键代码为主。
WindCoder
2018/09/20
1.9K0
《Linux内核分析》之操作系统是如何工作的 实验总结
【C51】8051 微控制器入门指南
编写 C51 嵌入式代码涉及到从标准 C 语言基础开始,逐步适应 C51 编译器和特定于 8051 微控制器的编程模型。以下是详细步骤,帮助你从标准 C 语言基础过渡到 C51 编程,并编写有效的嵌入式代码。
LuckiBit
2024/12/11
1890
C51 单片机开发中断方式控制 LED
闲话:看电视剧看到后半夜,外面除了路灯,黑了很多。电视剧说不上特别好看,但是这种感觉很棒!!!
码农UP2U
2024/06/21
3710
C51 单片机开发中断方式控制 LED
临界区保护_临界地带
由于共享资源的访问存在于任务与任务之间、任务与中断ISR之间;那么,只需要防止任务在访问共享资源时,切换至其它任务或防止中断发生即可。
全栈程序员站长
2022/11/17
8630
临界区保护_临界地带
《一个操作系统的实现》笔记(6)--进程
---- 我们可以把一个单独的任务所用到的所有东西封装在一个LDT中,这种思想是多任务处理的雏形。 多任务所用的段类型如下图,使用LDT来隔离每个应用程序任务的方法,正是关键保护需求之一:
felix
2018/07/02
1K0
STC单片机操作系统——RTX51 Tiny
RTX51 Full :使用四个任务优先权完成同时存在时间片轮转调度和抢先的任务切换 RTX51工作在
知否知否应是绿肥红瘦
2025/02/19
1250
STC单片机操作系统——RTX51 Tiny
从零开始学习UCOSII操作系统13–系统移植理论篇「建议收藏」
(1)UCOSII移植到不同的处理器上,所谓的移植就是将一个实时的内核能在其他的微处理器或者微控制器上运行。
全栈程序员站长
2022/08/19
7310
FreeRTOS 任务调度 任务切换
前面文章 < FreeRTOS 任务调度 任务创建 > 介绍了 FreeRTOS 中如何创建任务以及其具体实现。 一般来说, 我们会在程序开始先创建若干个任务, 而此时任务调度器还没又开始运行,因此每一次任务创建后都会依据其优先级插入到就绪链表,同时保证全局变量 pxCurrentTCB 指向当前创建的所有任务中优先级最高的一个,但是任务还没开始运行。 当初始化完毕后,调用函数 vTaskStartScheduler启动任务调度器开始开始调度,此时,pxCurrentTCB所指的任务才开始运行。 所以, 本章,介绍任务调度器启动以及如何进行任务切换。
orientlu
2018/09/13
5.8K0
FreeRTOS 任务调度  任务切换
相关推荐
使用KEIL C51实现的简单合作式多任务操作系统内核(单片机实现版本)
更多 >
领券
💥开发者 MCP广场重磅上线!
精选全网热门MCP server,让你的AI更好用 🚀
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验