快速设计简单嵌入式操作系统(3):动手实操,基于STC8编写单任务执行程序,感悟MCU指令的执行过程

发布于:2025-08-13 ⋅ 阅读:(21) ⋅ 点赞:(0)

引言

前面我们陆续学习了操作系统常见的基础概念,接着简单了解了一下8051单片机的内存结构和执行顺序切换的相关概念。接下来,我们就开始进行实操,基于8051单片机STC8来编写一个简单的操作系统,这里我们先实现一个单任务的执行程序,体会MCU中指令的执行过程。


一、新建工程

1.1 本地新建工程目录

1. 选择一个合适的地方新建一个文件夹os_demo

2. 在os_demo目录下新建user目录,用于存放main.c

详细地说,第一步,新建os_demo文件夹,用于存放工程。笔者在本地的STC目录下直接创建一个空的os_demo文件夹;第二步,进入os_demo1目录,继续创建一个空文件夹user,用于存放main.c源文件。

最后效果如下

1.2 keil5新建工程文件

1. 进入project,点击新的uVision工程,进入本地创建好的os_demo1目录下,输入工程文件名称os_demo1,保存即可;

2. 接着选择使用的8051单片机芯片;

3. 在keil中建立逻辑目录结构;

4. keil中的简单配置。

具体步骤如下图所示

第一步,选择目录新建keil工程文件,生成相关配置文件

第二步,选择芯片型号,笔者使用的是STC8H8K64U这款8051单片机芯片

不用添加这个启动文件,因为后面我们正要编写的就是这种类似文件

第三步,在keil中建立逻辑目录结构,尽可能选择与本地目录名相同去创建目录user,然后将本地对于的文件main.c添加进来,OK即可

第四步,配置一下,进入魔法棒,接着进入C51,如下图所示,将本地新建的user目录的路径在这里包含一下

至此,该工程就创建完毕了。


二、程序编写

在keil中,打开前面创建好的工程文件,然后打开main.c开始编写代码。

首先是引入STC8的头文件,其中定义了该单片机中的一些常用的寄存器地址,包括但不限于特殊功能寄存器等

#include <stc8h.h> // 定义一些寄存器的地址

2.1 任务栈管理

每个MCU要进行的需求可以认为是一个任务,而每个任务要执行的话都有对应的指针或者说地址去便于找到该任务执行的入口。

这里我们需要先定义一个任务堆栈指针数组,用于存放不同任务的堆栈指针SP。关于SP前面也重点提过,用来记录程序执行时的下一条指令地址的特殊功能寄存器。这里定义它是为了去记录任务(我们希望执行的程序)的堆栈指针的。

接着定义一个二维数组,用来存放每个任务的堆栈信息,这个堆栈信息可以理解为每个任务的执行入口地址,其实可以见到理解为SP去记录的那个堆栈指针。

然后这俩数组都是用于存放一定数量任务的堆栈相关内容的,所以要规定最大任务数量和堆栈深度(可先理解为堆栈大小)。同时为了避免魔法数字的出现,笔者在这里便使用宏定义去分别定义最大任务数以及最大堆栈深度。当然为了简化程序复杂程度,笔者先定义最大任务数量为2,后续再慢慢扩充。

然后我们还需要定义一个任务id,用于表示任务的名字

因此,针对上述描述的逻辑,给出关于任务堆栈管理的定义部分示例代码如下:

#define MAX_TASKS	2		// 简化任务数为2
#define	MAX_TASK_DEPTH	32	// 堆栈深度

// idata 表明信息定义在STC8访问最快的内部内存空间里面

unsigned char idata task_sp[MAX_TASKS];	// 任务的堆栈指针
unsigned char idata task_stack[MAX_TASKS][MAX_TASK_DEPTH];  // 每个tasks任务的堆栈信息

unsigned char idata task_id;		// 当前任务号,从0开始

2.2 任务的创建

接着,我们开始创建几个任务,前面笔者为简化任务数量,将任务最大数定义为2,所以我们就先创建两个任务。

大家可能疑惑:任务?这怎么创建??实际上,前面说过任务就是咱CPU或MCU要去完成的某项工作,实际就是一个具有特定功能或需求的程序,因此所谓创建俩任务在这里我们直接创建俩函数用来模拟两个任务的创建就好了。

比如创建两个不断进行加法运算的功能任务

void task0()
{
	// 0号任务,代表第0个小朋友做的事情
	unsigned int a = 3;
	
	// 死循环,表示该任务永远不会执行完
	while(1)
	{
		a = a + 3;
	}
}


void task1()
{
	// 1号任务,代表第1个小朋友做的事情
	unsigned int b = 5;
	
	// 死循环,表示该任务永远不会执行完
	while(1)
	{
		b = b + 5;
	}
}

如上代码所示,我们创建了两个任务,分别叫任务0和任务1,都是进行一个不断累积加的任务。

同时我们知道,对于STC8单片机来说,其MCU为单核MCU,执行程序一般是从上至下依次执行,当碰见这种循环的程序时,更是会一直卡在那里反复运行,如果不给跳出循环的函数可能就无休止了。比如咱现在这个加法累积运算,咱MCU里面的ALU运算逻辑单元就要出马,但单核仅一个,所以正常情况一次只能处理一个运算,如果需要两个任务都执行,势必会产生资源争抢的问题。

恰巧后续咱会一步一步将这个问题解决,不过为了理解为上,我们循序渐进一步一步来看。因此,我们先从执行一个任务入手,看看单核MCU是如何执行这个任务的。

2.3 任务的加载

我们这里所谓的运行一个任务其实和普通单片机运行有一点点区别,正常情况如果点个灯,那就直接在main函数里面写段相关逻辑编译运行就行,但这里不一样,大家是否还记得前面提到的堆栈指针以及切换程序执行顺序的内容?本次我们就是要结合切换程序执行顺序的思路去利用这个堆栈指针来修改程序运行的顺序,将我们的单个任务嵌入中间运行,等运行完后再去执行原程序内容。

因此这里可能就需要先简单提一下我们单片机上电后做的事情了:

对于STC8单片机来说,其硬件上电复位后会自动将PC程序计数器强制为复位向量地址,然后从这里开始执行启动程序,
当启动程序完成初始化后,就会执行LCALL main指令调用main函数。
由于LCALL指令会在跳转前先将当前执行指令的下一条指令地址(即PC程序计数器对应的指向)压入堆栈,
然后等该指令调用的函数执行完后,再从堆栈中弹出返回地址(先前存的原来执行的下一条指令地址)赋值给当前的PC,
接着就能继续执行原来后面的程序指令了。

对于这段话,可能刚看会有一点理解不过来哈,咱也不求一看就懂,所以没事,多思考多查总能理解的。

好,我们细品上面这段话,里面有几个关键点:PC程序计数器、(PC指向压入)堆栈、后弹出给PC继续执行原程序。同时会发现,LCALL指令就是我们前面所说的call指令的逻辑,可以跳转,然后实现一个程序顺序切换的功能。也就是说,我们可以通过修改压入堆栈内容,从而使返回原程序的地址变成我们希望执行的任务地址,这样当跳转后返回时就能执行咱自己的程序了。具体逻辑如下:

首先,我们肯定要有存放自己任务入口指针的堆栈和自己的堆栈指针,用于管理我们各个任务的堆栈指针和堆栈信息,当前前面我们已经定义好这俩东西了,即task_stack和task_ip;

其次,我们还需要将我们现有的任务的堆栈信息放到模拟的堆栈空间中,同时把任务地址所在地址给我们定义的堆栈指针记录。其实咱这里就是将各个任务的地址信息存进task_stack,不过需要注意的是,当前任务是函数定义,所以其地址信息是函数指针,其类型int,16位,然后把这块堆栈信息所在的地址给自定义的task_sp里面;

最后,我们将堆栈指针压入SP,覆盖原本PC指向的下一个程序地址,也就是将前面记录的地址的指针(地址)task_sp压入SP即可。

从上述逻辑看,接下来要做的应该是“其次...”这部分,也就是让任务的堆栈信息都放到相关位置,这相当于是真正开始前的初始化,所以这个内容我们当做任务的加载,定义task_load函数,函数原型为void task_load(unsigned int fn, unsigned char tid)

代码示例如下:

// fn 函数指针,注意数据类型int,16bit
// tid task id,8bit  0, 1
// 函数功能: 将一个task的函数指针放入对应的堆栈空间
void task_load(unsigned int fn, unsigned char tid)
{
	// 1. task的堆栈指针记录相应taskId堆栈信息地址
	task_sp[tid] = task_stack[tid] + 1;
	// 2. 使用两个空间存放task的函数指针
	task_stack[tid][0] = fn & 0xFF;		// 低8位
	task_stack[tid][1] = fn >> 8;		// 高8位
}

从代码上看,由于task_sp是用于记录堆栈指针,所以我们让存放对应id任务的堆栈空间所在地址给task_sp;然后由于定义的堆栈是8位的,所以每8位依次将对应id的任务堆栈信息存到自定义的堆栈task_stack中。

2.4 main函数执行

好了,任务加载完后,就可以开始运行咱们得任务了,按照前面所说,接下来就是覆盖SP的内容即可,所以main函数中我们先加载任务,然后指定任务id(前面定义过记录任务id的变量task_id),最后覆盖SP即可

代码如下:

void main()
{
	task_load(task0, 0);	// 装载任务0到对应堆栈内存
	task_id = 0;
	SP = task_sp[0];		// 将当前的堆栈指针压入SP中
}

从代码上看,我们是希望将任务0的程序运行出来,首先加载了任务0,将其堆栈信息和id传入了任务加载函数中,完成了堆栈信息的存储和堆栈指针的记录,接着指明任务id为0,最后将任务堆栈指针赋值给SP,覆盖原SP,实现main执行后返回执行的程序为任务0的程序。


三、调试验证

前面我们已经完成了这个单任务运行代码的编写,逻辑是通过修改SP来间接改变程序执行顺序,嵌入了自定义的程序从而完成指定程序的执行,但还未实测。因此接下来,我们在keil中测试一下:

首先编译一下

可以看出,编译没有错误。接着我们来调试一下,在任务0的累加处打断点一步一步看看

单任务执行测试

可以看出,我们指定的任务0确实按照指定逻辑被执行。当前,整个程序的执行情况为以下状态:

硬件上电复位后会先执行一段启动程序,然后启动程序完成初始化后会自动发出LCALL main的指令。

接着在调用main时确实是先将LCALL指令之后的下一条指令的地址(PC当前指向的地址)压入SP中,然后再开始执行的main中的程序。不过在执行main程序时,按顺序先执行task_load函数中的内容时,函数中会将task0的函数指针的低8位和高8位依次存储自定义堆栈中。
然后让SP堆栈指针指向自定义堆栈栈顶地址,进而覆盖了原先SP记录的LCALL之后实际的栈顶地址。
所以,main执行完后继续执行ret指令出现的结果就是:实际通过SP记录的堆栈栈顶地址从堆栈依次弹出给到PC的返回地址是我们自定义的task0函数的高8位和低8位地址,
最后程序就会跳回此时PC所指向的地址即task0函数的位置去执行task0函数的内容了。

当然,这只是一个简单的测试,还有很多可以扩展的地方,比如可以更换其他任务逻辑进行测试、开空调时信息中的内容是否正确等,可自己多探索一下。


四、小结

本次我们理解了基于STC8单片机的单任务执行的原理,使用keil进行工程创建、代码编写和实际测试的实操,深入理解了单核MCU的程序指令运行过程和其单任务运行的局限性。


笔者小白,能力有限,以上内容难免存在不足和纰漏,仅供参考,各位阅读时请带着批判性思维学习,遇到问题多查查。同时欢迎各位评论区批评指正。谢谢。