受够了一个单片机傻乎乎的一路执行着代码的时代,决定自己实现一个多任务系统,虽然有现成开源的,但是借这个机会用自己的想法去设计系统毕竟是个不错的事情。
一个多任务系统的重点是实现任务的上下文切换,在多个进程中间来回切换。抱着这个想法先初步设计任务切换的实现过程。
这次的处理器是stm32f103zet6,内存64KB,Flash512KB,相对于运行一个简单的多任务系统绰绰有余,况且cortex-M3的核心在设计时候就考虑到了操作系统的运行,硬件上满足操作系统的要求。
首先是关于这个芯片,仔细看一看coretex-M3权威指南和各种相关的技术手册后,大致能得到一些在多任务系统上用的着的东西:
1. Cortex-M3把SysTick定时器中断作为系统异常。
2. Cortex-M3是双堆栈结构,两个SP分别是MSP和PSP。通过CONTROL寄存器进行切换。具体的见后边的分析。
3. Cortex-M3的中断处理具体流程。见后边分析。
基础知识:
首先是系统如果不采取措施,一个进程运行开始后会霸占CPU不放手,这样任务切换肯定没法实现,所以必须有个强制的手段让系统内核“夺回”CPU,而这个措施就是系统心跳SysTick定时器,设置好这个定时器之后,它会不间断的按照设定的时间产生系统异常。而我们在系统异常中断中进行任务的各种处理就可以了。
实现任务的切换最重要的就是把任务工作的现场原封不动的备份好,再恢复回去,按照ARM过程调用规范(ATPCS规范,见官方文献),编译器如果用到了r4-r11中的寄存器,它会在使用前进行压栈,在函数推出之前进行出栈。根据《Cortex-M3权威指南》,CPU中有r0-r15寄存器,xPSR寄存器,以及控制寄存器CONTROL和中断屏蔽寄存器,我们的现场只需要关心r0-r15以及xPSR就好了。根据权威指南(权威指南几乎提到了需要的所有细节)的中断的具体行为一章,发生中断时,硬件会自动进行压栈,压入的寄存器和顺序如下表:
可以看出r4-r11是没有被硬件进行备份的所以需要我们手动备份(话说真的很麻烦,不过也是为了中断响应效率着想)。其次是,cortex-M3是双堆栈的,那么到底和单堆栈有什么区别?其实我们访问r13也叫sp,得到的是当前的堆栈指针,进行push和pop也是利用当前的堆栈,那当前的堆栈到底是谁呢?现在已经知道这个内核有MSP和PSP两个堆栈指针,通常我们写的裸机代码都是只用了MSP,因为一个堆栈指针就够了,然而想想在操作系统里边会发生什么?不同的进程用着同一个堆栈,什么时候被中断的都不知道,那肯定互相破坏堆栈中各自的数据,其次,内核的堆栈不能让人破坏,否则系统就崩了。现在知道了双堆栈的用处就是分别拿给系统和应用程序用的了。那么怎么控制双堆栈呢?就需要用CONTROL寄存器了,读一读看一看参考文献,这个寄存器就用了两个位(话说挺豪的),0位设置当前的模式,1位设置当前是否为双堆栈。如果把[1]位设定为1的话,那么就成了双堆栈,这个是会立刻生效的,马上你的指针就会变成PSP指针,这个寄存器有个特点,你不是特权级就不能写设置,当然也就不能更改为双堆栈咯,这一点会在之后提到怎么通过SVC软件中断来实现特权级切换,也不用担心,因为刚刚进入main函数的时候,只要之前的库代码中没有更改特权等级,那就是特权级。那么PSP指针和MSP指针怎么设置呢?前边我们已经通过更改control寄存器实现了堆栈的切换,那么总要切换个我们可以控制的地方吧(当然也就是我们想让数据存在哪里)。PSP和MSP这两个寄存器无论什么时候都可以用mrs指令来读取,用msr指令来写入,就像这样:
MSRR0,PSP
MRS MSP,R0
这样我们只需要实现给他们设置好值,然后切换为双堆栈就可以了。
现在我们知道怎么配置双堆栈了,还有个问题肯定是,什么时候用到MSP呢?那就是当发生中断的时候,系统肯定会切换为MSP并且你不能改变CONTROL来在中断返回之前切换堆栈。那刚刚提到的硬件自动按照表里的顺序备份,到底备份到了哪个堆栈?这就看发生中断时候的堆栈是哪个了,通常我们中断是发生在应用程序期间的,所以一般是备份到PSP,那特殊呢?还是以后慢慢想吧。
有了上边的东西我们就要总结一下系统干了什么:
1. 启动
2. 执行堆栈的设置和切换
3. 设置某个进程
4. 配置系统定时器
5. 启动定时器
6. 默默等待发生系统定时器中断
那中断里边做什么呢?当然是备份上一个进程和恢复切换下一个进程了。为了让每个程序的执行互相不会冲突,我们就要给他们分配独立的栈空间,自己用自己的不能瞎用。把被中断的进程的r4-r11寄存器备份到它的程序栈里边,计算下一个调用谁,然后把下一个的psp指针搞到,恢复r4-r11到对应寄存器,指针指向硬件压栈的末尾,然后退出任务切换之后交给硬件把表里边的参数恢复到相应寄存器就OK啦。
具体怎么实现:
(因为任务切换有两个版本,C的和汇编的,因为是效率不同,先用C的来说)
贴出代码片段来分析:我们的系统需要空间来记录下运行的状态(当前进程,总进程,哪些进程可用之类的),信息记录在下边的表中:
#define SYSINFO_BASE 0x20000f00 //系统信息区起始地址
//定义系统信息结构体
#define SYSINFO_SIZE 16
typedef struct{
u32 PID; //当前执行的进程PID
u32 PAMOUNT; //总的进程数目
u32 PMASK; //记录了31个进程号的使用情况,位x=1表示pid=x的进程号已经被使用
u32 SYSTIME; //记录系统的运行时间(微秒),用来唤醒等待中的进程
}_SYSINFO;
//定义一个系统信息访问的途径
#define SYSINFO ((_SYSINFO*)(SYSINFO_BASE))
定义了一个SYSINFO宏来方便访问信息,通过
SYSINFO->PID
来操作当前的进程PID,这里只用了31个PID,一个简单的多任务系统给31个就差不多了吧。还有就是pid=0的进程是不存在的,这么设置有其他用途。
接下来分析记录每个进程信息的表:
#define PROCINFO_BASE 0x20001000 //进程信息表的起始地址
//进程信息结构体
#define PROCINFO_SIZE 16 //定义进程信息大小
//定义进程的状态
#define PROC_STATUS_NORMAL 0x1 //进程设定为正常状态
#define PROC_STATUS_SLEEP 0x2 //进程正在休眠,等待被其他程序唤醒
#define PROC_STATUS_WAIT 0x3 //进程等待一定时间被唤醒
#define PROC_STATUS_LEISURE 0x4 //进程只有在空闲时候才被运行
#define PROC_STATUS_SERVICE 0x5 //进程处于等待服务状态(例如驱动),当其他程序进行调用时才唤醒
#define PROC_STATUS_WAITRET 0x6 //进程处于等待服务返回状态(进程1用服务方式调用进程2时,进程1自动进入等待返回状态)
typedef struct{
u32 PID; //进程ID
u32 Status; //进程状态
u32 PSP; //堆栈地址
u32 WeekUpTime; //当状态为WAIT状态时,任务切换程序会检查系统时间是不是大于唤醒时间,如果大于就会唤醒程序
}_PROCINFO;
//定义一个进程信息访问的途径
//因为pid是从1开始的,所以要进行pid-1
#define PROCINFO(pid) ((_PROCINFO*)(PROCINFO_BASE+(pid-1)*PROCINFO_SIZE))
和SYSINFO类似,只是因为不同的PID对应的存储位置不同(连续的),所以计算一下下,用PROCINFO(pid)->Status来进行进程状态信息的访问。
现在我们存储基本信息的差不多都具备了,接下来看代码怎么实现,刚才已经确定了代码的流程:
1. 启动
2. 执行堆栈的设置和切换
3. 设置某个进程
4. 配置系统定时器
5. 启动定时器
6. 默默等待发生系统定时器中断
第一个启动就不多说了,可以提到的一点是,启动的时候,向量表开头的第一个字存放了启动时候默认的MSP指针,硬件会自动加载进去。
开始分析2:
MSP_INIT equ 0x20000f00 ;MSP初始化值
PSP_INIT equ 0x20000600 ;PSP初始化值
;函数名 :void ChangeSP(u32 _BackUp)
;功能 :堆栈初始化和切换
MT_ChangeSP
EXPORT MT_ChangeSP
ldr r0,=PSP_INIT
msr psp,r0
ldr r0,=MSP_INIT
msr msp,r0
mrs r0,control
orr r0,r0,#0x2
msr control,r0
bx lr
;
大致就是修改各个堆栈的值,然后切换堆栈,当然也可以用内嵌汇编来实现(举个例子不必要都用上):
//功能:设置双堆栈,并切换堆栈为PSP
void MT_SetSP(void){
__asm(
"mov r0,%0\n"
"msr msp,r0\n"
"mov r0,%1\n"
"msr psp,r0\n"
"mrs r0, control\n"
"orr r0,r0,#0x2\n"
"msr control,r0\n"
"mov r0,#0x34\n"
"mrs r0,control\n"
::"r"(MAIN_STACK),"r"(TEMP_STACK)
);
}
开始分析3:添加进程,要想进入一个进程,就需要知道进程的起始地址,给进程传递参数(这个先不考虑,之后添加)
#define PROCSTACK_BASE 0x2000f000 //设定的进程堆栈地址,这是所有进程堆栈的起点,堆栈是向下生长的满栈
#define PROCSTACK_SIZE 0x00000600 //设定为每个进程分配的堆栈大小
//功能:添加任务,并初始化任务堆栈
//返回:新加入的进程的PID号码,如果为0表示添加失败
u32 MT_AddTask(u32 TaskAddr,u32 pid){
//检查输入的pid是不是已经被使用
if(pid && (SYSINFO->PMASK&(0x1< //返回0表示添加失败
//寻找最小的可用PID号码 if(!pid)while(SYSINFO->PMASK&(0x1<<++pid)); //如果pid为0时自动分配pid
//设定PID号码
PROCINFO(pid)->PID=pid;
//设定进程状态
PROCINFO(pid)->Status=PROC_STATUS_NORMAL; //设定为正常状态
//设定进程堆栈指针,因为每次会恢复16个字的数据,所以先将PSP-64
PROCINFO(pid)->PSP=PROCSTACK_BASE-(pid-1)*PROCSTACK_SIZE-64;
//预先修改进程堆栈中保存的PC值
*(u32*)(PROCINFO(pid)->PSP+4*(16-2))=TaskAddr;
//预先修改进程堆栈中保存的xPSR值(否则发生fault错误)
*(u32*)(PROCINFO(pid)->PSP+4*(16-1))=0x01000000;
//保证函数在return之后会正常结束进程
*(u32*)(PROCINFO(pid)->PSP+4*(16-3))=(u32)MT_ExitTask;
//增加总的进程数目
SYSINFO->PAMOUNT++;
SYSINFO->PMASK|=(0x1< return pid;
}
可以看到我们添加进程的时候事先把需要的寄存器值放入了堆栈中,分配了堆栈区域。MT_Exit这个先不管,这是后来为了防止进程return跳到错误地方,设置了一个进程结束处理的函数。这个可以先不写,但是要求进程不允许return,就像是写裸机程序总是有个while(1)一样的道理。
xPSR必须被设置,T位必须设置为1,因为T=1代表为Thumb指令,而不是ARM指令,不改的话会发生硬件异常。
分析3:这个SysTick定时器很简单的配置,时间为us。
//功能:SysTick初始化
//注意:这个函数不会启动定时器
void MT_InitSysTick(u32 usCount){
//在AHB进行8分频(9M)后把时钟提供到SysTick,所以每9K记录下1ms
SysTick->CTRL&=0xFFFFFFFB;
//定时系统产生中断
SysTick->CTRL|=(0x1<<1);
//清除计时器值
SysTick->VAL=0x0;
//设置装载值
SysTick->LOAD=9*usCount;
return;
}
;函数名 :void MT_StartSysTick(void)
;功能 :启动定时器
MT_StartSysTick
EXPORT MT_StartSysTick
push {r0,r1}
ldr r0,=0xE000E010
ldr r1,[r0]
orr r1,r1,#0x1
str r1,[r0]
pop {r0,r1}
bx lr
;函数名 :void MT_StopSysTick(void)
;功能 :停止定时器
MT_StopSysTick
EXPORT MT_StopSysTick
push {r0,r1}
ldr r0,=0xE000E010
ldr r1,[r0]
and r1,r1,#0xfffffffe
str r1,[r0]
pop {r0,r1}
bx lr
while(1);
之前已经分析过任务切换的步骤:
1. 备份
2. 计算PID
3. 恢复寄存器
4. 跳转指针
也是一步一步分析:
贴一份C版本:
//功能:进行任务切换(通过中断进行调用)
void MT_SwitchTask(void){
MT_StopSysTick(); //暂停定时器
SYSINFO->SYSTIME+=SWITCHTIME; //增加系统时间
if(SYSINFO->PID!=0){ //如果进入前在执行进程,就将数据压栈,并存储堆栈指针
__asm(
"mrs r0,psp\n"
"stmdb r0!,{r4-r11}\n" //将r4-r11压入堆栈
"mov %0,r0\n" //更新psp
:"=r"(PROCINFO(SYSINFO->PID)->PSP):
);
}
//计算下一个任务的PID
if(31==SYSINFO->PID)SYSINFO->PID=0; //保证PID号码不会超过31,这是由于SYSINFO->PMASK决定的
do{
while(!(SYSINFO->PMASK&(0x1<<++(SYSINFO->PID)))){ //寻找下一个可用进程
if(31==SYSINFO->PID)SYSINFO->PID=0; //保证PID号码不会超过31,这是由于SYSINFO->PMASK决定的
}
if(PROCINFO(SYSINFO->PID)->Status==PROC_STATUS_WAIT){ //如果进程处于等待状态,判断是不是应该唤醒
if((PROCINFO(SYSINFO->PID)->WEEKUPTIME)<=(SYSINFO->SYSTIME)){
PROCINFO(SYSINFO->PID)->Status=PROC_STATUS_NORMAL; //如果超过了等待时间,唤醒这个进程
}
}
}while(PROCINFO(SYSINFO->PID)->Status==PROC_STATUS_SLEEP || PROCINFO(SYSINFO->PID)->Status==PROC_STATUS_WAIT);
//跳过正在睡眠的进程
//恢复下一个任务的r4-r11并将堆栈指针给psp(注意这是r4-r11出栈之后的指针)
__asm(
"mov r0,%0\n"
"ldmia r0!,{r4-r11}\n" //将r4-r11压入堆栈
"msr psp,r0\n" //更新psp
::"r"(PROCINFO(SYSINFO->PID)->PSP)
);
MT_StartSysTick();
return;
}
可以看到我们在PMASK中寻找个可用的,并且状态可用的进程,然后切换上去,会掠过SLEEP的进程和WAIT时间没有到的进程(WAIT状态后边才添加,这里先不管)。具体原理见权威指南和技术参考手册。
需要说的是,这里必须清楚中断的具体流程,中断怎么保证不会破坏现场
现在万事俱备,只差中断了,我们在中断处理函数里边(也可以直接指定这个为中断处理函数)添加这个函数。
/**
* @brief This function handles SysTick Handler.
* @param None
* @retval None
*/
void SysTick_Handler(void)
{
//GPIO_ResetBits(GPIOC,GPIO_Pin_0); //用于逻辑分析仪测试时间
MT_SwitchTask();
//GPIO_SetBits(GPIOC,GPIO_Pin_0); //用于逻辑分析仪测试时间
return;
}
之后几天继续改进