自己做一个RTOS
什么是操作系统?其实就是一个程序, 这个程序可以控制计算机的所有资源,对资源进行分配,包括CPU时间,内存,IO端口等,按一定规则分配给所需要的进程(进程?也就是一个程序,可以单独执行),并且自动控制让CPU可以执行多个互不相关的任务,按照书中的介绍,一个操作系统需要具备四个要素:进程调度、内存管理、IO管理、文件管理。
那怎么样可以让CPU同时执行多个任务呢?首先想象一下如果让CPU执行单道程序,它会从MAIN函数开始一直顺序地执行下去,CPU里面有一个叫PC的寄存器,也就是程序计数器,它永远指向下一条要执行的指令的存放地址,因为大多数情况下指令都是逐条执行的,所以PC寄存器也只是简单地加一,所以大家都叫它”程序计数器“,从PC寄存器的特点也许我们可以做点文章?比如人为地让PC寄存器指到另外一段程序的入口地址,那CPU不就自动地跑到另一段程序了么?哈哈。假如我们可以这样做,那没错,CPU确定是跑到别人的领地去执行代码了,问题是:怎么样让它回来继续执行?换句话说,PC寄存器改变之后CPU 已经不知道刚刚这段程序执行到哪里了,亦即跑不回来了,就像断了线的风筝。呃。。这问题麻烦。。解决了这个问题就似乎有点苗头了。。
好吧,我们来看看有一个很相似的问题,就是单片机在执行代码的时候,突然有一个中断信号过来了,单片机马上就屁颠屁颠地跑到中断服务程序里面去执行了,执行完毕之后,奇怪!!它怎么还记得跑回来原来的地方!!??OH NO .它是怎么办到的。其实这里还要介绍另外一个寄存器叫SP的,即:STACK POINTER堆栈指针,这个指针指向一个内存的地址,里面存放了一些数据。首先,单片机遇到中断信号的时候,它就把当前的PC寄存器的值保存到SP所指的地址,这就相当于它记住了当前执行的地方,叫做断点保护,然后PC寄存器就指向中断服务程序的地址,下一个时刻CPU就自动执行中断服务程序里面的代码了,执行完毕之后中断服务程序调用了一个指令:RETI,这条指令叫返回指令,在函数结束之后调用,它会自动从SP指针指向的地址把值取出来放到PC寄存器里面,然后CPU就会自动回到之前断掉的地方继续执行了!基于这个原理,我们可以回到上面的问题:首先,让CPU把当前的PC保存起来,然后把PC指向别段程序地址,CPU就跑到别人的领地去执行了,执行完了之后我们可以把SP指向的内容放回PC,这样调用RET指令之后,CPU就会回到原来的地方继续执行了!!貌似这个问题完美地解决了!!
可是还有一个关键的问题:CPU在执行当前代码的时候 CPU里面所有的寄存器都保存的当前这个程序所用到的值,比如做加法的时候用到PSW寄存器的进位标志位,如果此时切换到别的任务,那再回到当前程序的时候,这些值都会被改变,CPU会陷入混乱然后直接跑飞!!解决这问题同样要靠SP同学,在切换任务的时候我们把所有寄存器依次入到SP指向的地址,称为入栈操作,每次入栈SP指针的值都会加一或者减一,视不同CPU而定。而要恢复的时候,就从SP指向的地址依次把值取出来放回原来的地方,称为弹栈操作。最后才弹出地址到PC寄存器,下一时刻,CPU自动跑到原来的地址继续执行,从CPU的角度看就像没有发生任务切换一样,一切依旧,继续工作。如果CPU的执行速度够快,切换速度也够快,这样就可以给人感觉CPU同时在执行很多任务,这就是操作系统里面最基本的原理。
SO,解释完原理,我们首先来就来实现简单的任务切换,这里的难点就在于:执行这一动作必须要操作CPU的寄存器,而C语言是无法实现的,这就是为什么要用到汇编的原因了,所有操作系统的最底层代码都是用汇编语言实现的,否则根本无法实现任务切换。下面要介绍汇编里面的几条相关指令。PS:虽然每种CPU的汇编都不同,但是基本原理还是相通的。
第一条:CALL。函数调用指令,当我们要调用一个函数的时候,就会用到CALL这条指令,它执行再从个动作,第一,先把当前的PC值保存起来,即现场保护,第二,把要调用的函数的入口地址送到PC,这样,在下一时刻到来的时候,CPU就自动跳转到特定的函数入口地址开始执行了。
第二条:RET/RETI。当一个函数执行完毕的时候,需要返回到原来执行的地方,这时候就要调用 RET指令(在中断函数中返回的时候调用RETI指令)。它把SP指向的数据,即上一次调用CALL时保存的那个地址原来到PC,这样,当下一时刻到来的时候,CPU就会跳回到原来的地方了。实际上函数调用过程就是这样的,所以有时候一些简单简短的函数宁愿用#define宏定义写出来,因为这样写出来就不用使用调用/返回过程,节省了时间。
第三/四条:PUSH/POP。这两个指令是两兄弟,即入栈及出栈。关于堆栈的特性说明一下:堆栈这种结构的特性就是后进先出,就像叠盘子一样,最后叠上去的盘子会被最先取出,这种原理非常好用,想象一下函数嵌套的时候发生的一切,就是利用到这种思路。PUSH指令用到把寄存器的值保存起来,它会把值到保存到SP指针所指的地方。POP指令则把数据从SP所指的地址恢复到原来的寄存器中。
用这几条指令,我们就可以写出一个任务切换函数了,不过写之前还要说明一下什么叫人工堆栈。其实上,一个程序在执行的时候,它会用到一块内存空间用于保存各种变量,比如调用函数的时候这块地方会用于保存地址以及寄存器,而在执行一些复杂算法的时候,如果CPU的寄存器已经用完了,这块地方也会作为临时中间变量的存放区,另外,在向一个函数传递参数的时候,比如:printf(a,b,c,d,e....),如果参数过多,多余的参数也会先存放到这块地方。所以说,这块地方就像是这个程序的仓库一样,存放着要用的东西。如果是在单道程序中,显然这样用没问题,但是如果是多道程序的话,问题就来了,因为如果所有任务共用那块区域,那旧任务保存的东西就会被新任务所冲掉,CPU一下子就疯掉了。。解决的办法就是:每个任务都给它提供一块专用的区域,这块专用区域就叫人工堆栈,每个任务都不一样,保证了不会相互冲突。
PS:因为51单片机的内存太小,基本无法实现多任务,实现了也不实用,所以硬件平台我选用了AVR单片机ATMEGA16,有1KB内存,应该够用了,花了两天时间把AVR的汇编指令看了一遍
首先,当需要切换任务的时候,要先把当前的所有寄存器全部入栈,在AVR单片机中有32个通用寄存器R0-R31,还有PC指针,PSW程序状态寄存器,这些都要入栈,所以需要的内存挺多的。现在的编译器都支持在线汇编,就是在C语言里面嵌入汇编语言,方便得多,下面我宏定义了一组入栈操作:PUSH_REG(),里面是用PUSH指令把32个寄存器全部入栈
\#define PUSH_REG() \
{_asm("PUSH R0\n\t" "PUSH R1\n\t" "PUSH R2\n\t" "PUSH R3\n\t" \
"PUSH R4\n\t" "PUSH R5\n\t" "PUSH R6\n\t" "PUSH R7\n\t" \
"PUSH R8\n\t" "PUSH R9\n\t" "PUSH R10\n\t" "PUSH R11\n\t" \
"PUSH R12\n\t" "PUSH R13\n\t" "PUSH R14\n\t" "PUSH R15\n\t" \
"PUSH R16\n\t" "PUSH R17\n\t" "PUSH R18\n\t" "PUSH R19\n\t" \
"PUSH R20\n\t" "PUSH R21\n\t" "PUSH R22\n\t" "PUSH R23\n\t" \
"PUSH R24\n\t" "PUSH R25\n\t" "PUSH R26\n\t" "PUSH R27\n\t" \
"PUSH R28\n\t" "PUSH R29\n\t" "PUSH R30\n\t" "PUSH R31\n\t" ); }
入完栈完接下来要保护当前程序的SP指针,以便下次它要返回的时候能找到该人工堆栈的地址:
OS_LastThread->ThreadStackTop=(OS_DataType_ThreadStack *)SP;
这一句用C语言就可以实现了。
接下来关于当前这段程序的现场算是保护好了,然后找到要切换到的任务的人工堆栈地址,把它赋给SP指针,如下:
SP=(uint16_t)OS_CurrentThread->ThreadStackTop;
出栈跟入栈的语法差不多,只是出栈顺序要相反:
POP_REG();
接下来,要调用一条很重要的指令了!!!此令一出,CPU就乖乖地切换任务了!
_asm("RET\n\t");
调用返回指令,它就从SP里面取出函数地址放到PC,注意他取出的是刚刚放入SP指向地址的函数入口,所以它会返回到新任务执行。
就这样,一个操作系统里面最核心的”任务调度器“的模型就这样简单地实现了,操作系统里面所作的跟任务切换有关的事情到最后都要调用到这个任务调度器,现在我们实现调度器了,相当于成功了1/3,接下来的事情就是考虑在什么情况下调用这个调度器。
*调度策略*:实现了调度,还要继续考虑调度策略,就是什么情况下需要调度哪些任务。调度策略分很多种,有兴趣的可以去看那本《操作系统原理》,在我的源代码里面使用了”抢占式优先级调度+同一优先级下时间片轮询调度“的方法。
所谓抢占式优先级调度是一种实时调度的方法,在实时操作系统中常用,这种方法的原理就是:操作系统在任何时候都要保证拥有最高优先级的那个任务处于运行态,比如此记在运行着优先级为2的任务,因为一些信号到达,优先级为1的那个任务解除了阻塞,处于就绪态,这时操作系统就必须马上停止任务2,切换到任务1,切换的这段时间需要越短越好。
而时间片轮询即是让每个任务都处于平等地位,然后给每个任务相同的时间片,当一个任务的运行时间用完了,操作系统就马上切换给下一个需要执行的任务,这种方法的实时性不高,但它确保了每个任务都有相同的执行时间。
我把这两种方法结合起来,首先设定了8个优先级组,每个优先级组下面都用单向链表把具有相同优先级的任务连接起来。这样的话首先操作系统会查找最高优先级的那组,然后在组里面轮流执行所有任务(和UCOS II相比这种做法更具有灵活性,因为UCOS II只有抢占式调度,这是UCOS II的硬伤。。)。我声明了一个任务结构体称为线程控制块,把关于该任务的所有状态都放在一起:
/**
\* @结构体声明
\* @名称 : OS_TCB , *pOS_TCB
\* @成员 : 1. OS_DataType_ThreadStack *ThreadStackTop
\* 线程人工堆栈栈顶指针
\* 2. OS_DataType_ThreadStack *ThreadStackBottom
\* 线程人工堆栈栈底指针
\* 3. OS_DataType_ThreadStackSize ThreadStackSize
\* 线程人工堆栈大小
\* 4. OS_DataType_ThreadID ThreadID
\* 线程ID号
\* 5. OS_DataType_ThreadStatus ThreadStatus
\* 线程运行状态
\* 6. OS_DataType_PSW PSW
\* 记录线程的程序状态寄存器
\* 7. struct _OS_TCB *Front
\* 指向上一个线程控制块的指针
\* 8. struct _OS_TCB *Next
\* 指向下一人线程控制块的指针
\* 9.struct _OS_TCB *CommWaitNext ;
\* 指向线程通信控制块的指针
\* 10.struct _OS_TCB *TimeWaitNext ;
\* 指向延时等待链表的指针
\* 11.OS_DataType_PreemptionPriority Priority ;
\* 任务优先级
\* 12.OS_DataType_TimeDelay TimeDelay ;
\* 任务延时时间
\* @描述 : 定义线程控制块的成员
*/
typedef struct _OS_TCB {
OS_DataType_ThreadStack *ThreadStackTop;
OS_DataType_ThreadStack *ThreadStackBottom;
OS_DataType_ThreadStackSize ThreadStackSize;
OS_DataType_ThreadID ThreadID;
OS_DataType_ThreadStatus ThreadStatus;
OS_DataType_PSW PSW;
struct _OS_TCB *Front;
struct _OS_TCB *Next;
#if OS_COMMUNICATION_EN == ON
struct _OS_TCB *CommWaitNext;
#endif
struct _OS_TCB *TimeWaitNext;
OS_DataType_PreemptionPriority Priority;
OS_DataType_TimeDelay TimeDelay;
} OS_TCB, *pOS_TCB;
首先启动系统的时候需要先创建任务,任务被创建之后才可以得到执行,使用如下函数:
/**
* @名称:线程创建函数
* @输入参数:1.pOS_TCB ThreadControlBlock 线程控制块结构体指针
* 2.void (*Thread)(void*) 线程函数入口地址,接受一个空指针形式的输入参数,无返回参数
* 3.void *Argument 需要传递给线程的参数,空指针形式
*/
void OS_ThreadCreate(pOS_TCB ThreadControlBlock,void (*Thread)(void *),void *Argument)
关于创建任务的大致描述就是:填定线程控制块,把线程控制块链到单向链表中,设置人工堆栈,细节很多,就不一一赘述了。
当前版本只实现了轮询调度,还没加上抢占调度,使用下面的函数就可以启动操作系统开始多线程任务!
/**
* @名称 : 实时内核引发函数
* @版本 : V 0.0
* @输入参数 : 无
* @输出参数 : 无
* @描述 : 在主函数中用于启动,调用该函数后不会返回,直接切换到最高优先级任务开始执行
*/
void OS_KernelStart(void)
{
OS_Status = OS_RUNNING ; //把内核状态设置为运行态
//取得第一个需要运行的任务
OS_CurrentThread = OS_TCB_PriorityGroup[pgm_read_byte(ThreadSearchTab + OS_PreemptionPriority)].OS_TCB_Current;
OS_LastThread = NULL ; //SP指针指向该任务的栈顶
SP = (uint16_t)OS_CurrentThread->ThreadStackTop ;
//使用出栈操作
POP_REG(); //调用RET,调用之后开始执行任务,不会再返回到这里
_asm("RET\n\t");
}
怎样实现时间片?答案是用定时器定时,每次定时器产生中断的时候就转换一次任务,时基可以自己确定,一般来说时基越小的话会让CPU花很多时间在切换任务上,降低了效率,时基大的话又使时间粒度变粗,会使一些程序得不到及时的执行。我设定了每10MS中断一次,就是说每一轮中每个线程都有10MS的执行时间。具体算法不再赘述。
内存管理策略
接下来要考虑怎样管理内存了!在PC里面编程的时候,如果需要开辟一个内存空间,我们可以很容易地调用malloc()和free()来完成,但是在单片机里面却行不通,因为要实现这两个函数背后需要完成很多算法支持,从速度和空间上单片机都做不到。
在单片机里面如果你需要开辟内存空间,你只有在编译的时候就先定义好变量,无法动态申请,但是我们可以设计一个简单的内存管理策略来实现这种动态申请!原理就是在编译的时候先向编译器要一块足够大的内存并且声明为静态,然后把这块空间交给内存管理模块来调用,内存管理模块负责分配这块内存,当有任务要向它申请内存的时候它就从里面拿出一块交给任务,而任务要释放的时候就把该内存空间交给内存管理模块来实现。
关于内存管理也有很多种策略,在这里就不一一述说了,我在源代码里面使用了一种简单的随机分配的方法,即有线程申请的时候就从当前内存块的可用空间里拿出一块来,然后在内存头加上一个专用的结构体,把每个内存块都链接起来,这样便于管理。当线程释放内存的时候,就把内存返回到内存空间并跟其他空间的内存块合并起来等待线程再次调用。
/**
* @名称 : 内存块申请函数
* @版本 : V 0.0
* @输入参数 : 1. OS_DataType_MemorySize MemorySize
需要申请内存块的大小
* @输出参数 : 1. void *
若申请成功,则返回可使用内存块首地址,否则返回NULL
* @描述 :
*/
#if OS_MEMORY_EN
void *OS_MemoryMalloc(OS_DataType_MemorySize MemorySize) {
pOS_MCB pmcb = OS_MCB_Head;
pOS_MCB pmcb2;
MemorySize += OS_MEMORY_BLOCK_SIZE;
//进入内存搜索算法
while (1) {
//检测该内存块是否存在
if (pmcb == NULL) {
return NULL;
}
//如果存在则检测该内存块的使用状态
else if ( ( pmcb->Status == OS_MEMORY_STATUS_IDLE ) \
&& ( pmcb->Size >= MemorySize ) ) {
//如果可用内存块大小刚好等于需要申请的大小
//则立即分配
if (pmcb->Size == MemorySize) {
pmcb->Status = OS_MEMORY_STATUS_USING;
OS_MemoryIdleCount -= MemorySize;
return (OS_DataType_Memory *) pmcb + OS_MEMORY_SIZE;
}
//若可用内存块大小大于需要申请的大小
//则进行分割操作
else {
pmcb2 = (pOS_MCB)((OS_DataType_Memory *) pmcb + MemorySize);
pmcb2->Front = pmcb;
pmcb2->Next = pmcb->Next;
pmcb2->Status = OS_MEMORY_STATUS_IDLE;
pmcb2->Size = pmcb->Size - MemorySize;
pmcb->Status = OS_MEMORY_STATUS_USING;
pmcb->Size = MemorySize;
pmcb->Next = pmcb2;
OS_MemoryIdleCount -= MemorySize;
return (OS_DataType_Memory *) pmcb + OS_MEMORY_BLOCK_SIZE;
}
} else {
pmcb = pmcb->Next;
}
}
}
#endif
内存释放函数:
/**
* @名称 : 内存块释放函数
* @版本 : V 0.0
* @输入参数 : 1. OS_DataType_MemorySize MemorySize
需要申请内存块的大小
* @输出参数 : 1. void *
若申请成功,则返回可使用内存块首地址,否则返回NULL
* @描述 :
*/
#if OS_MEMORY_EN
void OS_MemoryFree(void *MCB) {
pOS_MCB pmcb = (pOS_MCB)( (OS_DataType_Memory *)MCB - OS_MEMORY_BLOCK_SIZE );
//将当前内存块设置为空闲状态
pmcb->Status = OS_MEMORY_STATUS_IDLE;
OS_MemoryIdleCount += pmcb->Size;
//如果存在上一块内存块,则进入判断
if (pmcb->Front != NULL) {
//如果上一块内存块处于空闲状态,则进行合并操作
if (pmcb->Front->Status == OS_MEMORY_STATUS_IDLE) {
pmcb->Front->Size += pmcb->Size;
pmcb->Front->Next = pmcb->Next;
pmcb = pmcb->Front;
OS_MemoryIdleCount += pmcb->Size;
}
}
//如果存在下一块内存块,则进入判断
if (pmcb->Next != NULL) {
//如果下一块内存块处于空闲状态,则进行合并操作
if (pmcb->Next->Status == OS_MEMORY_STATUS_IDLE) {
pmcb->Size += pmcb->Next->Size;
pmcb->Next = pmcb->Next->Next;
OS_MemoryIdleCount += pmcb->Size;
}
}
}
#endif
这种分配策略虽然实现简单,但是缺点就是容易产生内存碎片,即随着时间推移,可用内存会越来越碎片化,最后导致想要申请足够大的内存块都没办法。。。
/********************************************************************************/
至此,一个简单的单片机使用的操作系统模型就算完成了,应用在AVR单片机中,下面进入测试阶段:
因为还没有完成线程通信模块还抢占式算法,所以目前只能执行轮询多任务操作。我写了一个测试程序,就是创建三个流水灯程序(是不是觉得写个操作系统就用来跑流水灯太浪费了,哈哈),让它们同时闪,在PROTEUS中仿真查看
在AVR STUDIO5开发环境中编写,代码如下:
#include "includes.h"
#include "OS_core.h"
#define STACK_SIZE 80 //定义每个任务的人工堆栈大小
//定义三个任务各自的人工堆栈
uint8_t Test1Stack[STACK_SIZE];
uint8_t Test2Stack[STACK_SIZE];
uint8_t Test3Stack[STACK_SIZE];
//定义三个任务各自的线程控制块
OS_TCB Task1;
OS_TCB Task2;
OS_TCB Task3; //线程1让PB口闪烁
void Test1(void *p) {
uint8_t i;
DDRB = 0XFF;
PORTB = 0xff;
SREG |= 0X80;
while (1) {
for (i = 0; i < 8; i++) PORTB = 1 << i;
}
}
//线程2让PC口闪烁
void Test2(void *p) {
uint8_t i;
DDRC = 0xff;
PORTC = 0XFF;
SREG |= 0X80;
while (1) {
for (i = 0; i < 8; i++) PORTC = 1 << i;
}
}
//线程3让PD口闪烁
void Test3(void *p) {
uint8_t i;
DDRD = 0XFF;
PORTD = 0xff;
SREG |= 0X80;
while (1) {
for (i = 0; i < 8; i++) PORTD = 1 << i;
}
}
//MAIN函数
int main(void) {
uint8_t i = 0x77;
//初始化操作系统
OS_Init();
//初始化线程控制块并创建任务
OS_ThreadInit(&Task1, Test1Stack, STACK_SIZE, 5, 0);
OS_ThreadCreate(&Task1, Test1, &i);
OS_ThreadInit(&Task3, Test3Stack, STACK_SIZE, 5, 0);
OS_ThreadCreate(&Task3, Test3, &i);
OS_ThreadInit(&Task2, Test2Stack, STACK_SIZE, 5, 0);
OS_ThreadCreate(&Task2, Test2, &i);
//初始化定时器
OS_TimerInit();
//启动内核
OS_KernelStart();
//正常的话程序永远不会执行到这里!!!
while (1);
}
OK,开始调试咯!打开PROTEUS连线,LOAD程序,然后运行。。。。
成功同时运行三个流水灯程序!太棒了!接下来在这个内核的支持下你就可以创作你的应用程序了,使用内核提供的线程创建函数你可以创建N多个线程,当然了,必须在内存可接受的范围内。利用内存分配函数你可以动态申请和释放内存了。再也不用为DELAY()这种浪费CPU效率的作法郁闷很久了。