0.多线程概念
多线程相信大家都听说过,经常玩电脑的网友应该知道电脑处理器会所谓的8核16线程,甚至包括最近刚出的华为Meta60 Pro,也有了手机上的超线程概念。
不过,这种都属于比较复杂的结构了。而单片机,往往只有一个核。这,又从何谈起多线程呢?
事实上,这里所谓的多线程,概念上类似处理器上的超线程概念。就是拿空闲的资源去完成更多的事情。
不过区别是,硬件超线程同一时间可能真的能执行两个任务,而单片机这里的多线程仅仅是通过快速切换两个任务来让你看起来是在同时执行的罢了。
1.框架介绍
这次,我们就从实际应用出发,来看一下多线程在单片机上会绽放出怎样的火花吧!
首先呢,就是实现这个多线程,这里我使用的是STC公司的STC8H8K64U单片机,可以理解为一个增强版本的51。
来到写代码的部分,我们前面已经提过了多线程的精髓(快速切换),那就是快速切换任务,让你看着像是在同时执行的。这一个方法也有一个正式的名字-"时分复用"。
字面意思理解当然就是把时间分开,用来干不同的事情。只要这个时分足够细微,就可以感觉上是在同时进行的。
这里就要介绍一下我研究出来的这个小框架了,虽然代码只有简单的88行,但是包括了软定时器和非堵塞式延时,以及使用了switch作为线程状态机的实现。
拥有极高的可复用性和超高的拼接特性,在拥有现成代码的情况下,甚至可以做到3分钟完成底层搭建。然后就是享受编写代码核心逻辑的顺畅感觉了。
2.代码编写
这里,从零基础开始,抛开这个小程序的实现原理,先从一个实际问题出发。
"如果需要让两个LED灯不同频率(100ms变换一次和350ms变换一次)闪烁,那么你应该如何实现?"
首先,为了追求准确的时间,我们需要开一个定时器,用于实现准确的100ms和350ms,因为定时器也产生不了特别长的时间,所以我们就选定1ms的定时器就可以了。
这里,我们使用STC-ISP的定时器代码生成功能,启动中断,保证每次进入中断都是1ms(STC-ISP勾选打勾)

这个时候,我们可以在每次进入中断的时候计数,设定两个变量,到时间后就可以将变量归零,并且操作一下LED,功能轻松实现。
- #include "stc8h.h"
- void
Timer0_Init(void); - void
main(void) {Timer0_Init();while(1);}- void
Timer0_Isr(void) interrupt 1 {unsignedintt100ms, t350ms;t100ms++;t350ms++;if(t100ms >=100)t100ms =0, P20 = ~P20;if(t350ms >=350)t350ms =0, P21 = ~P21;}- void
Timer0_Init(void)// 1毫秒@24.000MHz {AUXR |=0x80;// 定时器时钟1T模式TMOD &=0xF0;// 设置定时器模式TL0 =0x40;// 设置定时初始值TH0 =0xA2;// 设置定时初始值TF0 =0;// 清除TF0标志TR0 =1;// 定时器0开始计时ET0 =1;// 使能定时器0中断}
复制代码
但是这个时候突然又来改需求了,说要LED变着花样闪烁,不是固定频率了,要LED0闪烁三次100ms后切换350ms。
这你一想,坏了,太麻烦了,标志位可能整不明白了。这时候就该有请状态机出场了,使用全局变量确定一下当前的状态,然后每切换一次就可以换一种状态。
下面更改的是定时器中断部分的代码
- unsigned
chartask;// 全局变量 - void
Timer0_Isr(void) interrupt 1 {unsignedintt100ms, t350ms, count;switch(task){case0:{t100ms++;if(t100ms >=100)t100ms =0, P20 = ~P20, task++;// 一次100ms闪烁,切换下一个状态}break;case1:{count++;if(count <5)task =0;// 闪烁三次是变换6次,即1~5次是需要回去继续闪烁100ms的elsetask++;}break;case2:{t350ms++;if(t350ms >=350)t350ms =0, P20 = ~P20;}break;default:break;}}
复制代码
好了,这下状态也解决完了。现在一想,也没用到多线程啊,但是其实从宏观来看,已经是第一个程序的时候,两个任务"led1闪烁"和"led2闪烁"已经是在一起动作了。不过,刚才的程序是不是还略显简单?
3.多线程框架实现
那么,让我们来继续优化一下,看看一个成品的,令两个LED实现不同频率闪烁的代码吧,这部分也就是我的多线程小框架。
当然,小白硬看可能是看不大明白,下面会带大家讲解一下。
- #include "config.h"
- // 多线程功能定义
- #define Task_Max 10 // 最大线程数
u8 Task =0;// 全局线程指针u8 Task_This[Task_Max] = {0};// 线程状态表u16 Task_Timer[Task_Max] = {0};// 线程私有定时器- void
Core_Init(void);// 函数声明 - void
Delay(unsignedintTime); - void
Get_Delay(void); - void
main(void) {Core_Init();while(1){Task =1;// 线程1开始switch(Task_This[Task]){case0:P20 = ~P20, Delay(100);break;case2:// 进入Delay会自动跳下一个数字并进行等待,所以完成延时已经是下下个数字了Task_This[Task] =0;// 回到最初的状态break;default:Get_Delay();break;}Task =2;// 线程1开始switch(Task_This[Task]){case0:P21 = ~P21, Delay(350);//换一个灯闪烁,并且不同频率break;case2:// 进入Delay会自动跳下一个数字并进行等待,所以完成延时已经是下下个数字了Task_This[Task] =0;// 回到最初的状态break;default:Get_Delay();break;}}}- // 初始化定时及核心功能
- void
Core_Init(void) {AUXR |=0x80;// 定时器时钟1T模式TMOD &=0xF0;// 设置定时器模式TL0 =0x40;// 设置定时初始值@24MHz,1msTH0 =0xA2;// 设置定时初始值TF0 =0;// 清除TF0标志TR0 =1;// 定时器0开始计时ET0 =1;// 使能定时器0中断EA =1;// IO初始化P0M0 = P0M1 = P1M0 = P1M1 = P2M0 = P2M1 = P3M0 = P3M1 = P4M0 = P4M1 = P5M0 = P5M1 =0x00;P_SW2 |=0x80;// 允许操作寄存器}- void
Timer0_Isr(void) interrupt 1 {u8 xdata i;// 遍历所有线程定时器for(i =0; i < Task_Max; i++){if(Task_Timer[i] >0){Task_Timer[i]--;}}}- // 设置非堵塞定时,刻度1ms
- void
Delay(unsignedintTime) {Task_Timer[Task] = Time;Task_This[Task]++;}- // 获取当前定时器状态
- void
Get_Delay(void) {if(Task_Timer[Task] ==0){Task_This[Task]++;}}
复制代码
现在,我们就化身单片机,一行一行代码的 对这个多线程框架进行执行。
首先是定义部分
- #include "config.h"
- // 多线程功能定义
- #define Task_Max 10 // 最大线程数
u8 Task =0;// 全局线程指针u8 Task_This[Task_Max] = {0};// 线程状态表u16 Task_Timer[Task_Max] = {0};// 线程私有定时器- void
Core_Init(void);// 函数声明 - void
Delay(unsignedintTime); - void
Get_Delay(void);
复制代码通过这部分代码,我们可以知道。首先,通过数组开辟了一个线程状态表,用来存储每个线程执行到的位置。并且最大的线程数量通过一个宏定义Task_Max实现。
并且还定义了一个针对每个线程私有的定时器,类似前面代码中t100ms和t350ms的作用。同时还有一个用于指示当前运行的是第几个线程的Task全局变量。
至此,定义部分已经运行完毕,内存里面也开辟了相应的数组,接下来就是看如何运用了。
下一部分是main函数
Core_Init();//main函数中语句
复制代码
首先运行的是核心初始化函数,里面包含了进行定时器1ms定时的初始化,已经IO口模式的初始化。
Task =1;// 线程1开始switch(Task_This[Task]){case0:P20 = ~P20, Delay(100);break;case2:// 进入Delay会自动跳下一个数字并进行等待,所以完成延时已经是下下个数字了Task_This[Task] =0;// 回到最初的状态break;default:Get_Delay();break;}
复制代码然后就是第一个线程任务的开始,此时Task线程指针被赋值为1,所以现在程序蹦到哪里执行,都不影响现在是属于线程1的执行时间这个问题。
接着是一个Switch语句,里面的变量用于判断当前线程的执行的位置,然后对应跳到相应的case程序块进行运行。很显然,现在Task_This[1]的值是0,所以需要跳到case 0进行运行。
在这里,首先执行了一次LED0的状态取反,然后就是延时函数。当然,这个是非堵塞式延时,具体如何实现的呢?且看下面的Delay函数。
- // 设置非堵塞定时,刻度1ms
- void
Delay(unsignedintTime) {Task_Timer[Task] = Time;Task_This[Task]++;}
复制代码进入Delay函数后,首先就是对当前线程的私有定时器进行赋值,然后让线程状态往下加一。那么这里的延时函数是怎么知道需要给哪个线程定时的呢?
想起来之前有一个全局变量Task 了嘛?执行到这里,因为上面已经声明了Task为1,所以这里理所当然的给线程1的私有定时器进行了赋值,是不是挺巧妙的?
别着急,接着往下看,还有更巧妙的呢!
执行完Delay了以后,我们先不看第二个线程,继续研究第一个线程。
Task =1;// 线程1开始switch(Task_This[Task]){case0:P20 = ~P20, Delay(100);break;case2:// 进入Delay会自动跳下一个数字并进行等待,所以完成延时已经是下下个数字了Task_This[Task] =0;// 回到最初的状态break;default:Get_Delay();break;}
复制代码观察上面的case块,会发现,Delay后面的下一个任务序号是2 。为什么不是1呢?因为刚才Delay已经将任务序号变成1了。
这也是非堵塞式延时的精髓所在-通过判断来延时,而不是无意义的消耗CPU的时间来达到延时。
而switch语句的特点 是什么?没有找到对应的case,就会进入default(默认)块。
那么,默认块里面又写了什么呢?是的,一个判断是否达到定时时间的函数。相应的,看一下这个函数的构成,依然不复杂。
- // 获取当前定时器状态
- void
Get_Delay(void) {if(Task_Timer[Task] ==0){Task_This[Task]++;}}
复制代码进入判断后,就是对私有定时器是否到0进行判断。如果到0了,就将任务序号加一,跳出延时部分。
那么,可能又有疑问了。前面对私有定时器进行赋值了,但是也没看到这个定时器自减 啊,为什么能判断是否到0。
这时候,就要搬出定时器中断函数中的代码了
- void
Timer0_Isr(void) interrupt 1 {u8 xdata i;// 遍历所有线程定时器for(i =0; i < Task_Max; i++){if(Task_Timer[i] >0){Task_Timer[i]--;}}}
复制代码很显然,定时器每次干的活就是遍历所有私有定时器,发现有没到0的,就进行一次自减。
这样,只需要判断私有定时器的数值是否到0 ,就可以知道是否完成了定时。
回到,之前的线程1再看,此时应该已经豁然开朗了。
Task =1;// 线程1开始switch(Task_This[Task]){case0:P20 = ~P20, Delay(100);break;case2:// 进入Delay会自动跳下一个数字并进行等待,所以完成延时已经是下下个数字了Task_This[Task] =0;// 回到最初的状态break;default:Get_Delay();break;}
复制代码在经过一次100ms的延时后,Get_Delay()函数判断计时完成,进入下一个任务序号。这里就是将这个线程的数值清零,从而完成一次闭环 。
如果不回去,那么这就是一个单次的运行。如果不加入延时,那么任务序号也可以连续使用,使得通过不同条件进行下一步的跳转,甚至可以通过直接赋值任务序号,完成状态跳跃。
甚至可以通过操作其他线程的任务序号,完成更为复杂的任务。
4.总结
到这里,其实整个任务框架也就讲解完成了,因为整个框架中没有需要过度消耗CPU时间的行为,所以两个任务可以以极高的速度相互切换运行。看起来也就是所谓的多线程了。
那么,揭开了多线程这个神秘的面纱,希望可以激发大家对于操作系统的兴趣,本文谨用于入门。框架其实比不上FreeRTOS这种实时操作系统的。
但是从原理上来说,如果时间要求不高。也是可以使用这个框架进行简单的应用开发的,比如说平常的电子钟,温度计,或者一些报警器变调什么的,仍然可以使用这个非常轻量级别的框架。
最后,祝愿大家可以理解并且运用多线程的思想。很多任务单线程可能很难解决,但是使用多线程就会变得简单起来。
资料部分,我会贴出可以在打狗棒-STC8H8K64U上运行的基础代码,后面还会更新一下如何使用多文件方式操作这个多线程框架的示例。