0x00 我们先卖掉吧
0x01 写作背景:满足好奇心
0x02 运行环境:51单片机或仿真软件
0x03 操作系统功能
0x04 先展示效果
0x05 再解释一下源码
0x06 测试代码
0x00 我们先卖掉吧
最简单的操作系统是什么? 我个人的理解是,它是一个运行在最简单的硬件上的操作系统!
0x01 写作背景:满足好奇心
很多年前的一天,我对操作系统的任务调度非常感兴趣。 我特别好奇如何在两个无限循环之间切换。 虽然操作系统原理的书籍告诉我,时间是通过定时器中断来切分的。 每个任务执行一段时间后,切换到下一个任务; 原理我明白了,但是实际执行的细节是什么——代码是怎么写的? 于是我萌生了编写自己的操作系统来满足我的好奇心的想法。 我利用业余时间写代码、调试,终于实现了这个愿望;
0x02 运行环境:51单片机或仿真软件
硬件环境:51单片机(芯片型号AT89C52)或单片机仿真软件
编译环境:Keil
由于编写操作系统需要了解底层硬件,所以我最熟悉的硬件是51单片机。 这也应该是很多嵌入式爱好者的第一款微控制器。 因此,类似于网上很多搜索“自己写操作”。 “系统”的结果并不相同。 我的系统运行在51单片机上。 由于是运行在51单片机最小系统上的操作系统,代码量并不大,也比较容易理解,所以我将这个操作系统命名为Easy。 操作系统; 如果你没有单片机硬件,不用担心,你可以使用仿真软件或者Keil自己的软仿真来运行这个系统;
0x03 操作系统功能
由于主要是为了满足自己的好奇心,所以Easy OS的功能主要体现在任务调度上。 在config.h中,可以设置相应的宏开关,将操作系统配置成不同的模式:分时系统、抢占式实时操作系统、非抢占式实时操作系统; 同时还实现了简单的任务间通信;
0x04 先展示效果
在讲解源码之前,我们先来看看效果!
创建多个任务:task0、task1、task2、task3; task0负责将计数器加1,task1将counter的值输出到P1端口,task2将(counter+10)的值输出到P2端口,task3将(counter+20)的值输出到P3端口。 任务处理代码如下:
u8 counter = 0;
void task0()
{
u16 i,j;
while (1)
{
counter++;
for (i = 0; i < 200; i++)
for (j = 0; j < 200; j++);
}
}
void task1()
{
while (1)
{
P1 = counter;
}
}
void task2()
{
while (1)
{
P2 = counter + 10;
}
}
void task3()
{
while (1)
{
P3 = counter + 20;
}
}
以下是模拟软件运行时随机选取的三个时刻的截图。 在仿真软件中,芯片引脚上的蓝点代表0(低电平),红点代表1(高电平),所以下面的数据如下:
图1:当P1输出0x11、P2输出0x1b、P3输出0x25时;
图2:当P1输出0x08、P2输出0x12、P3输出0x1c时;
图3:当P1输出0x0b、P2输出0x15、P3输出0x1f时;
图1
图2
图3
可见,每一时刻P2都等于P1+10,P3都等于P1+20; 这说明多任务确实是“同时”运行的!
0x05 再解释一下源码
本文主要用于讲解源码的结构和重要逻辑。 它没有列出代码。 如果您需要下载完整的源代码,可以点击Easy OS源代码下载链接。
头文件类型.h
基本数据类型定义
配置文件
与内核配置相关的宏定义
系统.h
系统功能定义
任务.h
对于任务相关的定义,这里重点关注程序控制块struct __tcb,见注释;
// 任务控制块,task control block
struct __tcb
{
// 为了节省资源id,status,signal使用位段定义
u8 id:4; // 任务id,用户任务从0开始递增,实时系统中id越小优先级越高
u8 status:2; // 任务状态,READY/RUNNING/SLEEP/DELAY
u8 signal:2; // 信号,用于任务之间进行通信
u8 sp; // 堆栈指针
u8 sp_top; // 栈顶
#if (OS_SCHED_ALGO == OS_REAL_TIME)
u8 dly; // 实时系统中的准备延时,当dly为0时,才允许进入REAYD状态
#endif
struct __tcb * next; //指向下一个__tcb
};
asm 文件和 C 文件 boot.asm
这是上电后运行的第一段代码。 这个文件相当于很多大型操作系统的bootloader。 该文件的作用是设置SP指向系统堆栈,然后跳转到C程序的main函数;
既然提到了系统栈,那我也解释一下系统栈; 在这个操作系统中,所谓的系统堆栈就是为了方便堆栈的管理以及关心51单片机上的资源而专门划分的一块固定地址的内存。 当主函数(后续称为主任务)和中断处理函数调用其他函数时,将PC指针压入堆栈,使用系统堆栈; 由于系统初始化后主任务进入死循环,以后不会再使用系统堆栈; 中断函数不会嵌套,从而保证每个中断函数只会在不同时刻使用系统堆栈,不会导致程序跑飞;
如果要实现中断嵌套,或者系统正常运行后主任务仍然可以执行业务逻辑代码,建议为主任务和中断函数设置设置独立的堆栈。 这样的堆栈管理会更加清晰,但是会牺牲很多。 记忆;
与系统栈相比,后面介绍的任务都有自己独立的栈,称为任务栈;
isr_a.asm
中断处理的汇编函数定义了每次中断发生时回调哪个C函数。 该文件中定义的宏CALL_ISR_HANDLER封装了一般的中断处理流程。 每个中断都按照这个流程进行处理; 中断处理过程可以参考后面介绍的任务切换过程。 了解了任务切换流程,自然也就了解了中断处理流程;
isr_c.c
中断处理C程序,该文件的函数都是Isr_a.asm的回调。 在实际项目的应用过程中,如果想要处理中断,可以在该文件中实现; 当然,内核代码已经占用了51单片机的很多资源,如果要应用到实际项目中,可能还需要扩展很多资源。 能够在51最小系统上运行自己编写的操作系统已经满足了我的编码愿望!
系统.c
系统初始化函数,初始化系统时钟并创建空任务;
任务_a.asm 任务_c.c
这两份文件的重点是
1 任务切换
我们先介绍一下任务切换的核心逻辑。 在文字解释之前,先来一张示意图;
task_a.asm和task_c.c分别是与任务调度相关的汇编和C代码文件。 这里有几个重要的地方; SAVE_TASK_CONTEXT,RESUME_TASK_CONTEXT,__timer_isr0,__os_swtich_task;
__timer_isr0是定时器0的中断入口点,任务切换从这里开始;
SAVE_TASK_CONTEXT用于保存任务在线文本,即将当前CPU寄存器的值压入堆栈。 RESUME_TASK_CONTEXT用于恢复上下文,即将堆栈中的数据恢复到CPU寄存器中。 请注意,弹出和推送的顺序是相反的;
你可以大致想一下。 当任务切换时,保存旧任务的上下文(保存到旧任务的tcb->sp指向的堆栈中),然后保存新任务的上下文(tcb->sp指向的堆栈)新任务)。 恢复到CPU,使CPU当前的运行状态变成新任务的状态; 有了新的状态还不够,还需要将PC指针切换到新的任务。 PC切换是如何完成的? 无论任务调度如何,当中断发生时,程序在进入中断处理程序之前,都会自动将PC压入SP指向的堆栈中。 当中断处理程序结束并调用RETI指令时,PC将被压入SP指向的堆栈中。 将数据作为PC指针出栈,从而保证中断结束后程序能够在原来的位置继续执行。 如果要实现任务切换,就需要处理它。 当中断发生时,PC自动入栈(SP指向当前任务的栈,这个不需要处理)。 在中断处理函数中,改变SP指向新任务的堆栈,然后出栈时,给PC新任务的执行地址;
如上所述,新旧任务 SP 之间的切换是在 __os_swtich_task 中完成的。 在汇编中,SP的值被复制到OS_SP_BK中,而在C函数__os_swtich_task中,OS_SP_BK的值被保存在当前任务current->sp中。 然后切换任务(即根据调度算法将current指向下一个任务),然后将新任务的current->sp赋值给OS_SP_BK。 此时,进入汇编器,汇编器将OS_SP_BK的值赋给SP,从而完成新旧堆栈指针的切换;
让我们仔细看看整个过程。 当定时器0中断发生时,程序进入汇编入口__timer_isr0。 进入时,自动将PC压入current->sp(指向的堆栈),并调用SAVE_TASK_CONTEXT将上下文压入current->sp。 ,SP复制到OS_SP_BK,调用__os_swtich_task将OS_SP_BK保存到current->sp,调度__os_swtich_task使current指向新任务,然后将新任务的current->sp赋值给OS_SP_BK,并将OS_SP_BK复制到SP ,然后SP指向新的任务栈,调用RESUME_TASK_CONTEXT恢复上下文,并调用RETI使PC指向新的任务执行地址;
在__timer_isr0处理程序中,系统堆栈OS_SYSTEM_STK_SP也被分配给SP。 这主要是为了在调用__timer_isr0中的C函数时,将返回地址压入系统堆栈而不是任务堆栈,这样就可以将任务上下文的弹出和堆栈的弹出区分开来-中断程序调用的函数的弹出和弹出,更容易区分和处理;
2 创建任务
任务创建是使用__os_create_task实现的,也可以使用宏定义os_create_task来创建。 第一个任务是由system.c 的os_init 代码创建的。 该任务称为空任务或系统任务,以区别于主任务。 主任务创建的任务称为用户任务; 创建空任务时,当前指针指向空任务。 任务切换过程中,当前指针始终指向当前任务;
任务创建后,使用单向循环链表进行管理。 不同状态的任务都使用同一个链表。 第一个用户任务创建后,将排在空任务后面。 创建的第二个用户任务将排在第一个用户任务后面。 完成任务后,依此类推; 所有任务创建完成后,调用os_start之前,每个任务的tcb及其对应的栈数据结构如下,其中tcb只列出了部分字段;
3 加载第一个任务
在任务切换的介绍中,提到了CPU在旧任务和新任务之间切换在线文本和PC。 那么第一个老任务是谁呢? 答案是os_load_current_task。 该函数通过__os_load_current_task间接将current->sp的值赋给SP。 它还调用__os_load_current_task执行RESUME_TASK_CONTEXT和RET来“强制”恢复当前任务的上下文和PC,从而导致CPU进入current指向的地址执行指令;
4 调度算法
调度算法的实现参见__os_switch_task和os_timer_tick函数;
如果OS_SCHED_ALGO定义为OS_TIME_SHARED,则系统被编译为分时操作系统。 每个任务以统一的时间片长度运行。 时间片用完后,顺序切换到任务链表中的下一个任务执行;
如果OS_SCHED_ALGO定义为OS_REAL_TIME,则系统被编译为实时操作系统。 实时操作系统有一个子宏定义OS_PREEMPTIVE_EN。 如果OS_PREEMPTIVE_EN为1,则表示抢占式系统。 如果OS_PREEMPTIVE_EN为0,则表示非抢占式系统;
在实时系统中,以任务的ID作为运行优先级,空任务的优先级最低。 为了算法简单,用户任务中,任务ID创建越早,优先级越高; 实时系统每次调度时,所有处于就绪态且优先级最高的任务都会被执行;
不同调度算法对应的任务状态迁移图如下:
分时操作系统
非抢占式实时系统
抢占式实时系统
三种调度方式在获取CPU使用权方面的区别:分时操作系统除了os_start外,完全依赖时间片的到来; 非抢占式操作系统依赖于优先级,一旦获得了CPU,如果任务不活跃,如果调用os_delay来释放CPU,CPU就会一直被占用。 即使高优先级任务处于就绪状态,也不能抢占低优先级任务的CPU。 抢占式操作系统依赖于优先级。 如果更高优先级的任务进入就绪状态,高优先级的任务就会抢占CPU。 如果高优先级任务不通过os_delay主动释放CPU,低优先级任务将永远无法获得CPU的使用权;
0x06 测试代码
Main.c编写了几个测试代码来测试和观察不同调度模式的性能。 读者可以修改宏定义TEST_MODE来选择使用哪种测试代码。 修改TEST_MODE后,请记得相应修改config.h中的内核配置; 对于实时系统,建议尝试不同的延迟值,观察阻塞os_delay后的性能。 您可以使用在线模拟来调试和跟踪任务切换,从而获得更深入的了解;
Easy OS源代码下载链接
感谢您的阅读,欢迎在留言区交流!