前言
对于大部分的项目来讲,STM32基本都是处于“裸奔”的状态,将程序全部放在一个while(1)中顺序执行,但也会有部分任务要求几个任务“同时进行”,这个时候就需要一个RTOS了,但是有的时候就是这样,总会感觉为了一个小小的目的而在小容量的单片机中放一个OS,有些小题大做,我们需要的无非就是OS中的一个任务调度的功能,所以我只需要把这个功能给提取出来。
多任务
单片机只有一个处理核心,所以并不能同多核的PC机一样几个核心同时处理多个线程,我叫它“线程并行
”。而单片机中所谓的多任务就是“线程并发
”,就是多个线程看似一起发生,实际上还是单核心的顺序执行,只不过是每个线程都被分成了不同的时间片轮转执行。有关单片机多任务实现原理可以百度搜索一番,我在这里就不过多叭叭了,直接搞核心东西。
扩展知识
首先我们要知道,STM32有两个栈指针:MSP
(主栈指针)和PSP
(进程栈指针)有两种处理器模式:Thread Mode
和 Handler Mode
,有两种特权等级:特权模式
和非特权模式
。
STM32共有两个栈,主栈和进程栈,在裸机开发中全程使用主栈,而如果涉及到了多任务,就会用到进程栈,每个任务(其实就是函数)都会有一个属于任务本身的栈,这就是进程栈,这个栈的指针就要使用PSP,而这个时候到底是使用MSP还是PSP则是由 CONTROL 寄存器决定的, CONTROL[bit1] 若为0,则使用MSP,为 1 则使用PSP。中断或是异常的服务函数(例如 Systick_Handler 和 PendSV_Handler )一定是使用MSP(不需要人为控制,进入中断函数后 CONTROL[bit1] 位自动清零,函数退出后自动置 1 ),否则HardFault。
在 Thread Mode 中通过 CONTROL[bit0] 位可以选择处于特权模式或是非特权模式(0 特权模式,1 非特权模式),Handler Mode 一定是特权模式。(详情请自行搜索:任务运行在特权级或非特权级模式
)
功能实现
关于实现方式主要参考于《ARM Cortex-M3 Cortex-M4权威指南》第八章和第十章,主要用到了 PendSV 异常
和 Systick 定时器中断
,以及一些汇编语言
的知识。
其中PendSV用于在PendSV_Handler中实现上下文切换(所谓上下文切换就是在多个线程之间反复横跳),Systick定时器则是将多个任务切分成时间片,每次进入 Systick_Handler 就会挂起一个PendSV异常,在 Systick_Handler以及其他高优先级异常任务处理完成之后 再进入PendSV_Handler。
在PendSV_Handler中其实只是做了两件工作:保存现场和进程栈指针重定向。(这里需要用到汇编语言和寄存器的知识,如r0-r12,sp,lr,pc,msp,psp等,请自行搜索)
接下来分析代码。
这是使用STM32CubeIDE写的F407的代码,基于HAL库函数。
//宏定义 直接访问某个地址空间(1个字大小)
#define HW32_REG(ADDRESS) (*((volatile unsigned int *)(ADDRESS)))
//定义任务栈,每个2KB
long long task0Stack[256], task1Stack[256];
uint32_t currTask = 0; //当前任务号
uint32_t nextTask = 1; //下一个任务号
uint32_t PSP_Array[2]; //存放 进程栈指针 的数组
uint8_t initStatus = 0; //初始化完成标志,
//HAL库的Systick初始化在HAL_Init()中就完成了,不想更改,只能加一个status
void Task0()
{
initStatus = 1; //进入Task0之后,任务调度开始
while(1)
{
HAL_GPIO_TogglePin(LED_YELLOW_GPIO_Port, LED_YELLOW_Pin);
HAL_Delay(300);
}
}
void Task1()
{
while(1)
{
HAL_GPIO_TogglePin(LED_RED_GPIO_Port, LED_RED_Pin);
HAL_Delay(500);
}
}
void TaskInit(void)
{
//使能双字栈对齐
SCB->CCR |= SCB_CCR_STKALIGN_Msk;
//创建Task0的栈帧
[1] PSP_Array[0] = ((unsigned int) task0Stack) + (sizeof(task0Stack)) - 16 * 4;
//栈帧指向Task0
HW32_REG((PSP_Array[0] + (14 << 2))) = (uint32_t) Task0;
//初始化xPSR
HW32_REG((PSP_Array[0] + (15 << 2))) = 0x01000000;
PSP_Array[1] = ((unsigned int) task1Stack) + (sizeof(task1Stack)) - 16 * 4;
HW32_REG((PSP_Array[1] + (14 << 2))) = (uint32_t) Task1;
HW32_REG((PSP_Array[1] + (15 << 2))) = 0x01000000;
//使能PendSV异常
HAL_NVIC_EnableIRQ(PendSV_IRQn);
//设置PendSV为最低优先级,确保不会影响其他中断
HAL_NVIC_SetPriority(PendSV_IRQn, 15, 0);
//设置psp指向Task0的栈顶
asm volatile("ldr r1, =PSP_Array");
asm volatile("msr psp, r1");
//切换到进程栈,非特权模式
asm volatile("mov r0, #0x3");
asm volatile("msr control, r0");
Task0(); //从Task0开始任务调度
}
//Systick定时器中断服务函数,1ms进入一次,挂起一次PendSV异常
void SysTick_Handler(void)
{
HAL_IncTick();
if (initStatus)
{
switch (currTask)
{
case 0:
nextTask = 1;
break;
case 1:
nextTask = 0;
break;
default:
while (1)
;
break;
}
if (currTask != nextTask)
{
//挂起PendSV,等待高优先级任务结束后再执行PendSV_Handler
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
asm volatile("isb");
}
}
}
void PendSV_Handler(void)
{
//当前在PendSV_Handler中,所以用的是msp
//恢复r7寄存器(这里是在填坑...)
[2] asm volatile("pop {r7}");
//手动读取当前进程栈psp到r0
asm volatile("mrs r0, psp");
//将r4-r11这8个寄存器(存的是当前任务的相关信息)保存到当前进程栈(psp指向的)中
asm volatile("stmdb r0!, {r4-r11}");
//保存当前上下文
//获取当前任务号
asm volatile("ldr r1, =currTask");
asm volatile("ldr r2, [r1]");
//获取 进程栈指针 的数组地址
asm volatile("ldr r3, =PSP_Array");
//将当前psp保存到 进程栈指针 数组
asm volatile("str r0, [r3, r2, lsl #2]");
//加载下一个上下文
//获取下一个任务号
asm volatile("ldr r4, =nextTask");
//设置 当前任务号 为 下一个任务号
asm volatile("ldr r4, [r4]");
asm volatile("str r4, [r1]");
//从 任务栈指针 数组中加载下一个任务的psp
asm volatile("ldr r0, [r3, r4, lsl #2]");
//从下一个任务的进程栈中加载r4-r11(8个寄存器,存的是下一个任务的相关信息)
asm volatile("ldmia r0!, {r4-r11}");
//把psp指向下一个任务栈
asm volatile("msr psp, r0");
//返回(返回后会根据当前任务栈中保存的寄存器恢复现场,上次从哪里被打断的,这次就从哪里继续)
asm volatile("bx lr");
}
关键点分析
栈帧
:在函数调用过程中(在这里就是任务切换时),首先要做的就是保存现场
,就是把相关的寄存器压栈,我们这里用的是进程栈,所以系统会在函数调用前自动的把部分寄存器和数值压栈,包括r0-r3
,r12
,lr
,返回地址
,xpsr
,而这8个字就组成了一个栈帧,在函数调用结束后系统会自动从这个栈中弹出这个栈帧,是谓还原现场
。至于双字栈对齐
请自行百度。
[1]
//创建Task0的栈帧
[1] PSP_Array[0] = ((unsigned int) task0Stack) + (sizeof(task0Stack)) - 16 * 4;
//栈帧指向Task0
HW32_REG((PSP_Array[0] + (14 << 2))) = (uint32_t) Task0;
//初始化xPSR
HW32_REG((PSP_Array[0] + (15 << 2))) = 0x01000000;
因为数组是向上增长,而栈是向下增长,所以进程栈的栈顶需设置为数组的尾部。而 task0Stack 是 long long 类型,8字节长度,PSP_Array是 unsigned int 类型,4字节长度,所以对于task0Stack来讲,一个8字的栈帧长度就是16*4,也就是Task0这个函数在task0Stack中的栈帧,第2行就是设置这个栈帧,第3行则是初始化xPSR,xPSR的bit9置1是说使用的是Thumb指令集,具体请百度。(以上是我根据实际情况做出的猜测,不知对错,希望有大佬能够指正)。
[2]
//恢复r7寄存器(这里是在填坑...)
[2] asm volatile("pop {r7}");
开始的时候我并没有加这个指令,因为我根本没想到这个编译器会在进入函数前先“破坏现场”,如下图所示
在进入PendSV_Handler之后,执行指令之前,编译器先把 r7 压栈,然后把当前的栈指针sp存了进来,这就导致了本次保存任务现场stmdb r0!, {r4-r11}
这句话就成了笑话,因为本次任务的现场指针sp事先就存到了r7寄存器中,记住是本次任务的,而进过编译器改变之后就变成了PendSV_Handler函数的现场指针了,所以下一次进入这个任务时,就会恢复现场失败,因为它根本找不到PendSV_Handler中的这个指针,直接HardFault。所以有了这句话pop {r7}
。
至此结束,写的不是很好,我也在学习,希望大佬们评论区多多指点讨论,尤其是
[1]
这一部分,不胜感激,谢谢!