超级版主
 
- 积分
- 4980
- 金钱
- 4980
- 注册时间
- 2019-5-8
- 在线时间
- 1259 小时
|
本帖最后由 正点原子运营 于 2021-11-1 10:51 编辑
以下文章摘自微信公众号——开源电子网《STM32开发中常用的C语言知识点》
更多技术文章,请扫下方二维码关注
本文讨论内容
本文将简述如何从零开始搭建一个OS,我们搭建OS类型为RTOS,硬件平台是ARMCortex-M3!本文最终搭建出来的OS仅仅占用0.66KB空间!
为什么要指定硬件平台进行搭建OS呢?因为OS要想高效的运行必须使用汇编对一些频繁的操作(例如任务切换)进行高效处理,而汇编跟处理器的架构有着很大的联系,所以本文就以ARMCortex-M3这类处理器为例(这类处理器用得十分多)进行OS的搭建。
那么我们搭建RTOS前有哪些准备工作呢?首先就是《Cortex㎝3权威指南》,这本书中几乎有我们制作OS的所有依据,推荐大家阅读一遍再来看本文。其次就是要对RTOS这类OS有一定的了解,例如UCOS、FreeRTOS、Rtthread等等,本文也有一些思想是从这些RTOS中参考而来。
最后,是不是我们要搭建整个OS内核?当然不是啦!尽管RTOS是迷你版OS,但是它的内核实现也是十分庞大的,我们就先搭建OS中最核心的东西—多任务调度(或者叫多线程调度)!读者可以点赞+在看+留意想要搭建出来的功能,我后续会继续搭建读者希望拥有的OS功能!下面开源我搭建出来得OS内核文件:
链接:https://pan.baidu.com/s/1NHjsbR1ULFCCF7olKPX63Q
提取码:g448
第一节 自制RTOS快速体验
读者下载了OS的文件源码后,里面会有一个WenOS文件夹(没错啦,OS就是用我名字命名的!!!),然后我们找来一份源码,一定要CM3(Cortex㎝3)架构的处理器!此处找来“正点原子-战舰(STM32F103ZE)开发板-寄存器版本-实验1跑马灯实验”进行移植。我们先创建一个文件夹“Middlewares”,然后将WenOS直接复制过去,如下图:
然后我们创建一个文件组,我这里命名为“Middlewares\WenOS”,将C文件和汇编文件添加进去,然后记得包含头文件!如下图:
最后一步找到"stm32f10x_it.c”文件将PendSV和SysTick中断服务函数给屏蔽了(因为要在其他地方要写这两个中断函数),如下图所示:
进过前面的步骤你已经完全搭建好WenOS环境了!!!那么接下来在main.c里面写个程序吧,笔者的程序如下所示,这个程序可以让LED0和LED1同时闪烁,其中LED0慢闪烁,LED1快闪烁。
- #include "sys.h"
- #include "led.h"
-
- /* OS头文件 */
- #include "Wenos.h"
-
- /* 堆栈大小 */
- #define LED0_task_stk_size 64
- #define LED1_task_stk_size 64
-
- unsigned int LED0_task_stk[LED0_task_stk_size];
- unsigned int LED1_task_stk[LED1_task_stk_size];
-
- /* 该函数使得LED0闪烁,延时1000ms,慢闪烁 */
- void LED0_task(void *p_arg)
- {
- while(1)
- {
- LED0=1;
- OS_delay_ticks(1000);
- LED0=0;
- OS_delay_ticks(1000);
- }
- }
-
- /* 该函数使得LED1闪烁,延时1000ms,快闪烁 */
- void LED1_task(void *p_arg)
- {
- while(1)
- {
- LED1=1;
- OS_delay_ticks(200);
- LED1=0;
- OS_delay_ticks(200);
- }
- }
-
- int main(void)
- {
- /* 初始化LED,配置NVIC */
- LED_Init();
- NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
-
- /* 创建任务 */
- OS_create_task(LED0_task,&LED0_task_stk[LED0_task_stk_size-1],0,"123");
- OS_create_task(LED1_task,&LED1_task_stk[LED1_task_stk_size-1],1,"123");
-
- /* 开始任务调度 */
- OS_Start();
- }
复制代码
第二节 CM3架构解读
考虑到可能有初学者正在进阶OS,那么本文就讲解一下CM3(Cortex-M3)架构的一些基本知识,如果读者对CM3架构已经十分熟悉可以跳过本节的内容。本节的参考资料是《Cortex㎝3权威指南》。
1.如何实现多线程/多任务
读者可以回忆一下中断的处理过程,中断可以轻易打断当前任务的执行,从而去执行中断函数的内容,然后又继续执行原先的任务。那么最简单的多线程模式就是把不同任务函数放在不同定时器中断里面,然后开启定时器中断就可以实现了。但是这似乎不符合我们的原则,中断应该是快进快出的,而且如果有大量任务,如果都放进定时器中断执行,似乎定时器中断也不够用啊!
那么要解决这个问题就要深入理解一下中断具体执行过程了!那么我们接下来看一下中断是怎么具体实现的,如下图是中断的实现过程,这个过程几乎适用于所有处理器的中断处理过程。
从中我们思考一下基于上图实现多线程!其实最重要的是就是保存现场和恢复现场这个过程了,这个属于底层的操作了,跟处理器架构会打交道。对于上层应用部分,我们可以构思一个框架,首先让一个中断持续发生以检测任务状态,如果检测到要切换任务就触发一个中断让它进行任务切换就行了(大多RTOS都是此框架)!至此,多线程的实现框架就有了,那么检测任务状态的中断要持续发生,应该用什么中断?这是一个问题,另一个问题,如何任务切换的中断是哪一个?这涉及到架构,当然要在《Cortex㎝3权威指南》中寻找答案,如下图是《Cortex㎝3权威指南》推荐我们使用的中断!
所以CM3推荐我们使用SysTick中断和PenSV中断,那么我们的总体框架就是--SysTick中断检测是否需要任务切换,如果要切换就触发PenSV中断,PenSV中断内进行任务切换就行了!至此,CM3实现多线程框架已经完成!
2.寄存器解读
初学者可能会好奇,为什么这里要进行寄存器解读,因为前面没有具体说到中断是如何保存现场的,而且当我们在《Cortex㎝3权威指南》找到中断具体实现过程后,如下图,就会发现中断也是在操作寄存器啊!
这里会涉及到MSP和PSP寄存器,这个在本文后面再分析。我们看一下入栈的内容,是不是发现少了几个寄存器啊???这就是重点了,因为处理器帮我们入栈是几个重要寄存器,而有一些寄存器没有入栈。当然为了完整恢复现场,没入栈的几个寄存器我们要手动入栈和出栈!在《Cortex㎝3权威指南》第26页会有完整的寄存器组表格,从中可查R4~R11这几个寄存器没入栈,所以我们在写程序的时候,要手动对R4~R11这几个寄存器进行入栈和出栈!
3.MSP和PSP寄存器
细心的读者会发现,在上面的寄存器描述中会出现MSP和PSP寄存器,这几个寄存器存在的意义其实《Cortex㎝3权威指南》中有描述,但是初学者容易概念弄混,这里也解读一下这两个寄存器,如下两张图是“Cortex-M3权威指南”中对这两个寄存器的描述。
注意我圈起来的这个重点,CM3的堆栈是“向下生长的满栈”,这个设计堆栈和保存寄存器都会用!总结来说,MSP和SPS是CM3架构专门给操作系统设计的,这样就可以分离用户和系统,在系统中使用MSP,而在用户中使用SPS,用户与操作系统互不影响(这波设计我只能说妙啊!)那么应该怎么用呢?在《Cortex㎝3权威指南》的第42页有如下描述。
这里也总结一下,也就是说异常\中断中使用的是MSP堆栈,而且芯片复位开始时就是使用MSP堆栈(此时无论用户进程还是系统进程都是MSP)。如果要用户进程要使用PSP堆栈,我们还要在中断中修改相关寄存器才行!
第三节 任务调度的实现
这里就是实战环节了!会实际运用上面的理论知识,如果读者对架构还不清楚,就要多阅读几遍“Cortex-M3权威指南”了~在实现任务调度前,我们看看总的设计思路是怎么样的,如下图是总体的设计思路!
1.OS基本数据类型创建
别把这块想得太难了,通俗来讲,我们在这里要描述一下我们的任务长什么样子,代码如下所示:
- typedef struct OS_TCB
- {
- /* 任务栈顶 */
- unsigned int *TCB_head;
-
- /* 任务的延时,默认为0,配合OS_delay_ticks()函数一起使用,实现任务休眠 */
- unsigned int sleep_time;
-
- }TCB; /* 任务控制块 */
复制代码
这个就是我们的任务控制块了,已经学过RTOS的读者应该都不陌生,任务控制块就是代表了一个任务(或者叫做线程)的模样。但是我们有很多任务啊,还要集体的描述一下所有的任务吧?那么,上代码。
- /* 任务就绪表,实际上就是一张优先级的表格,优先级为0~31 */
- unsigned int OS_readly_task;
-
- unsigned char OS_present_prio;
- /* 记录当前运行的任务优先级 */
- unsigned char OS_high_prio;
- /* 所有任务中的最高优先级数 */
-
- TCB TCB_task_list[OS_task_max];
- /* 任务控制块,记录了所有TCP列表 */
- TCB *p_present_prio;
- /* 指向当前任务的TCB */
- TCB *p_high_prio;
- /* 指向最高级任务的TCB */
复制代码
这里为了简化起见,本文将OS设计成一个优先级下面只能拥有一个任务!想要设计成一个优先级下面能存在多个任务的读者,点赞+在看+留意想要搭建出来的功能,我后续会继续搭建读者希望拥有的OS功能!
数组TCB_task_list记录着所有任务的TCB,TCB_task_list数组的下标号和OS_readly_task的bit位是一致的!除了这些,我们还要记录当前任务的优先级和TCB控制块,和保存最高优先级任务的优先级、TCB控制块,这样就可以进行任务切换了!
2.OS创建线程/任务
创建任务,也就是开辟堆栈空间,这里注意R4~R11是不会自动保存的寄存器,这一点看第二节的寄存器解读讲解过,此处不累述。同时注意CM3架构是向下增长的,我们开辟空间后应该传入高地址作为栈底!如果用全局的数组变量传参,那么传入堆栈地址就是数组的&a[max-1],max代表数组大小。
- void OS_create_task(void *task_fun,
- unsigned int *task_stk,
- unsigned char task_prio,
- void *p_arg)
- {
- /* 模拟将由上下文切换创建的堆栈帧中断 */
-
- /* xPSR状态寄存器,这个比较特殊,第24位是设置THUMB模式 ,栈底(高地址) */
- *(task_stk)=(unsigned int)0x01000000;
-
- *(--task_stk)=(unsigned int)task_fun;
- /* 函数入口 */
- *(--task_stk)=(unsigned int)task_end ;
- /* R14(LR) */
- task_stk -= 4;
- /* R12 R3 R2 R1 */
- *(--task_stk)=(unsigned int)p_arg;
- /* R0(参数) */
-
- /* 未自动保存的内核寄存器:R4~R11 */
- task_stk -= 8;
- /* R4~R11 */
-
- /* 将该任务添加到控制TCB列表,即栈顶指向PCB控制块 */
- TCB_task_list[task_prio].TCB_head =task_stk;
- OS_readly_task|=0x01<<task_prio;
- /* 优先级列表标记优先级 */
- TCB_task_list[task_prio].sleep_time =0;
- /* 睡眠列表设置为0 */
- }
复制代码
3.OS开始任务调度的实现(C语言)
接下来就实现任务的调度吧,这里是上层的任务调度,底层的应用调度需要操作寄存器实现,我们先完成上层调度底层的程序,这里要注意的是CM3架构的堆栈是向下增长,而数组的最高位&arr[max-1]是处在高地址的,因此MSP堆栈的栈低应该被赋值为&arr[max-1],而&arr[max-1]=CPU_msp_stk(基地址,也就是arr[0])+OS_mps_stk_size(数组大小,数组下标从0开始,所以要-1)-1;所以最终得到:
- CPU_msp_stk_base=CPU_msp_stk+OS_mps_stk_size-1;这里还要创建一个空任务,防止CPU无事可做,代码如下:
- #define OS_mps_stk_size 128
- /* 主堆栈大小 */
- unsigned int CPU_msp_stk[OS_mps_stk_size];
- /* 主任务堆栈大小 */
- unsigned int * CPU_msp_stk_base;
- /* 指向的是数组最后一个元素 */
-
- #define idle_stk_size 64
- /* 空闲任务堆栈大小 */
- unsigned int idle_stk[idle_stk_size];
- /* 空闲任务堆栈 */
-
- void OS_Start(void)
- {
- /* M3向下增长 */
- CPU_msp_stk_base=CPU_msp_stk+OS_mps_stk_size-1;
-
- /* 空闲任务 */
- OS_create_task(OS_idle_task,&idle_stk[idle_stk_size-1],OS_task_max-1,"123");
-
- /* 获得最高级的就绪任务 */
- OS_get_high_prio();
-
- /* 当前运行任务的优先级列表好=最高优先级号 */
- OS_present_prio= OS_high_prio;
-
- /* 更新最高优先级TCP控制块 */
- p_high_prio=&TCB_task_list[OS_high_prio];
-
- /* 初始化滴答定时器 */
- System_init();
- OS_start_highprio();
- }
复制代码
获得高优先级任务函数需要注意,RTOS需要具有高实时性,而获得最高任务函数会被频繁调用,所以读者不可用for()从就绪表查询,因为这种做法会导致OS实时性降低,也就是不能确定有确定的运行时间(用for()查询,如果优先级为0,执行次数=1,但优先级=20,则执行次数=21),所以我们引用一种类似类似二分法的算法,使得内核运行时间可以被确定,无论优先级是多少,执行步骤永远为5次。函数System_init()就是初始化滴答定时器了,第二节也讲到需要用SysTick中断为OS提供节拍。函数OS_start_highprio()将会使用汇编进行开始任务调度了,这个我们汇编的时候再讲解。综合,我们可以完成如下源码:
4.OS心跳!PenSV和SysTick
我们现在已经完成OS的大致框架了,所以接下来就是最重要的使操作系统运行起来,也就是让它拥有心跳!第二节讲到,OS的心跳需要两个配合,一个是让OS一直保持心跳(SysTick中断),另一个是遇到需要任务切换,让心脏(SysTick中断)告知大脑(PenSV中断)去任务切换!
所以这部分也是很核心的东西,那么SysTick中断的具体内容是啥?那就是更新一下任务的休眠状态,如果有变化(也就是最高优先级任务不是现在任务)就触发一下PenSV中断(告诉大脑),这部分也不难,但是注意!SysTick中断和PenSV中断原本在"stm32f10x_it.c"这个文件被定义了,我们需要把这些定义给注释了!然后系统才能运行我们写的SysTick中断和PenSV中断处理函数。这部分在第一节快速体验中有这步操作,其实就是原先的中断处理函数加上注释符,这里不累述。那么综合上面所说我们就可以完成源码了!源码如下:
- <font size="4">void SysTick_Handler(void)
- {
- unsigned int cpu_sr;
- unsigned char i;
- for(i=0;i<OS_task_max;i++)
- {
- OS_ENTER_CRITICAL();
- if(TCB_task_list</font><i><font size="4">.sleep_time)
- {
- TCB_task_list</font><i><font size="4">.sleep_time--;
- if(TCB_task_list</font><i><font size="4">.sleep_time==0)
- {
- /* 从任务优先级表格中,恢复任务 */
- OS_readly_task|=0x01<<i;
- }
- }
- OS_EXIT_CRITICAL();
- }
-
- OS_task_schedule(); /* 进行任务调度 */
- }
-
- void OS_task_schedule(void)
- {
- unsigned int cpu_sr;
- OS_ENTER_CRITICAL();
- /* 进入临界区 */
- OS_get_high_prio();
- /* 找出任务就绪表中优先级最高的任务 */
- /* 如果不是当前运行任务,进行任务调度 */
- if(OS_high_prio!=OS_present_prio)
- {
- p_high_prio=&TCB_task_list[OS_high_prio];
- OS_present_prio= OS_high_prio;
- /* 更新最高优先级任务 */
- OS_task_switch();
- /* 进行任务调度,也就是触发PenSV中断 */
- }
- OS_EXIT_CRITICAL();
- /* 退出临界区 */
- }
- </font></i></i></i>
复制代码
5.OS任务切换的实现(汇编)
这部分的内容是最最核心的地方,有读者会问:难道任务切换就不能用C语言写吗?我的回答是:可以,但没有必要。因为在多任务处理时,任务要经常被切换的!所以一定要高效地执行!那么最高效的语言就是机器语言(也就是用0,1)编程,这个难度巨大,我们就用简单一点的--汇编语言吧!那么如果没学过汇编的读者可以先看一下《Cortex㎝3权威指南》的第四章“指令集”,学习一下汇编的使用!
我们先看看简单的汇编部分吧,看看关中断、开中断和任务切换怎么写,源码如下:
- NVIC_INT_CTRL EQU 0xE000ED04; 中断控制寄存器
- NVIC_PENDSVSET EQU 0x10000000; PendSV触发值
- OS_CPU_SR_Save ;PRIMASK=1,关中断(NMI和硬件FAULT可以响应)
- MRS R0, PRIMASK
- CPSID I
- BX LR
-
- OS_CPU_SR_Restore ;恢复中断,RO保存着当前中断状态
- MSR PRIMASK, R0
- BX LR
-
- OS_task_switch ;触发PendSV
- LDR R0,=NVIC_INT_CTRL
- LDR R1,=NVIC_PENDSVSET
- STR R1,[R0]
- BX LR
复制代码
这里可以看出来。汇编其实也不难!任务切换的实质就是触发PenSV中断,如何触发呢?往中断控制寄存器对应位写1就行了!接下来看看函数OS_start_highprio(),那么我们看看这是何方神通吧,源码如下:
- OS_start_highprio
- CPSID I ;关中断
- MOV32 R0, NVIC_SYSPRI14
- MOV32 R1, NVIC_PENDSV_PRI
- STRB R1,[R0] ;设置PendSV的异常中断优先级为最低等级
-
- MOVS R0,#0
- MSR PSP,R0 ;PSP清零,作为首次上下文切换的标志
-
- LDR R0,=CPU_msp_stk_base
- LDR R1,[R0]
- MSR MSP,R1 ;将MSP堆栈设为CPU_msp_stk_base,区分SPS堆栈
-
- LDR R0,=NVIC_INT_CTRL
- LDR R1,=NVIC_PENDSVSET
- STR R1,[R0] ;触发PendSV异常
-
- CPSIE I ;开中断
复制代码
原来就是设置一下PenSV中断等级、设置PSP和MSP堆栈,那么这两个堆栈有什么区别呢?这个在第二节的第③点讲述到,简单来说,在OS中,SPS常用于用户级模式,MSP常用于OS特权模式!那么为什么要讲PSP清零啊?当然是为了做个标志啊!至于具体作用,给读者留下一个悬念,后续我们会讲解这个清零的重要性!
那么接下来就是看看PenSV中断了,前面说过这部分是OS的大脑,尤为重要!PenSV中断里面就是具体的如何任务切换了,我们分析一下源码,源码如下:
- PendSV_Handler
- CPSID I ;关中断
- MRS R0,PSP ;把PSP指针的值赋给R0
- CBZ R0,OS_CPU_PendSV_Handler_first
- ;如果PSP=0,表示第一次执行中断,会跳到OS_CPU_PendSV_Handler_first
-
- SUBS R0,R0,#0x20
- ;使用STM指令手动入栈,SUBS不会改变栈指针的位置,手动改变
- STM R0,{R4-R11}
-
- LDR R1,=p_present_prio
- LDR R1,[R1]
- ;R1=p_present_prio,也就是R1=p_present_prio->StkPtr
- STR R0,[R1]
- ;PSP=p_present_prio->StkPtr
-
- OS_CPU_PendSV_Handler_first
- LDR R0,=p_present_prio
- LDR R1,=p_high_prio
- LDR R2,[R1]
- STR R2,[R0]
- ;p_present_prio=p_high_prio
-
- LDR R0,[R2]
- ;将新的栈顶给R0,实现PSP=p_TCBHightRdy->StkPtr
-
- LDM R0,{R4-R11}
- ;推出R4-R11
- ADDS R0,R0,#0x20
- ;LDM指令入栈不会改变栈指针的位置,手动改变
-
- MSR PSP,R0
- ORR LR,LR,#0x04
- ;置LR的位2为1,则用户线程使用PSP,否则用户线程使用MSP
-
- CPSIE I ;开中断
- BX LR
-
- END
复制代码
如果是初次学习汇编的读者需要注意PendSV_Handle和OS_CPU_PendSV_Handler_first是标签来的(类似C语言中goto语句中标签的含义),如果在PendSV_Handle中没有手动退出,程序就会一直执行到OS_CPU_PendSV_Handler_first处。
接下来分析源码,语句CBZR0,OS_CPU_PendSV_Handler_first就是判断R0是否为0,如果为0就跳转到OS_CPU_PendSV_Handler_first标签处,而R0在前面的赋值就是PSP的值,这里再细想一下前面设置PSP=0的原因了,原因就是当PSP=0时表示第一次执行任务调度,此时不需要入栈,只需要将寄存器出栈就可以了!当任务运行后PSP会指向具体的任务堆栈,也就是PSP!=0,此后每次任务切换都要保存当前任务的堆栈,然后再弹出最高优先级任务的寄存器了!在退出PendSV后我们一定要手动将用户堆栈设置为SPS堆栈,不然用户堆栈也是MSP堆栈!
本文以第一次任务切换后,后续的任务切换过程为例分析其实现过程,首先就是当前的任务入栈了,要用“STM”这个指令手动入栈,所以我们先要自减0x20地址,也就是十进制32,并保存R4~R11共8个寄存器(因为每个寄存器占32bit,即4字节,占4个地址,也就是要自减少”4地址/寄存器*6个寄存器=32个地址”)。为什么要减少而不是增加,因为CM3的堆栈向下增长的!我们出栈则相反,要自增32个地址再出栈。对于初学的读者可能不太好理解,那么我就以寄存器出栈为例,画一幅出栈的过程图给读者吧,如下图所示:
至此,自制RTOS的所有原理已经讲解完毕,这个OS内核目前只实现了多任务调度,读者可以点赞+在看+留意想要搭建出来的功能,我后续会继续搭建读者希望拥有的OS功能!我们下期见!!! |
|