17 KiB
1. 概述
1.1. 软件编程模式
我们以一个温度监控系统的设计为例来分析一下前后台系统的实时性问题。温度监控系统的主要功能是监控现场温度,并根据用户设置的报警温度执行声光报警。根据功能要求,我们可以设计三个任务:
- 温度采集任务:定时采集温度信息,当温度超过预设值后,发出声光报警
- 键盘扫描任务:检测用户输人,根据输入修改预设的报警温度。
- 温度显示任务:显示采集的温度信息。
上述代码有点问题,在后台程序中,一旦判断FlagXX为1的时候,应该重新赋值成0;
这是一个典型的前后台程序,前台程序一般是中断,设置一些全局变量(状态);后台程序(主程序),通过判断这些全局变量来执行相应的任务。注意,这个伪代码的三个任务是严格的顺序执行的。因此如果在读取键盘的时候,温度突然升高,是无法进行及时的相应的。
顺序执行在很多时候并不符合我们的要求,我们需要任务按照优先级来进行执行,如同中断一样。另外 HAL_Delay函数的底层是通过System tick 实现,也是一种中断,具备中断优先级;当其他的中断优先级低于 HAL_Delay 的时候,其他中断是无法执行的;当然,主程序在 HAL_Delay 之后的代码也是无法执行的。
因此我们需要一种新的机制来实现,任务的执行是有优先级的,另外,delay不会正真让cpu什么也不做,而只让特定的任务延时。
每个任务都是一个死循环,而且任务是有优先级的,优先级高的任务会抢占优先级低的任务获得CPU的执行;另外注意使用的延时不再是 HAL_Delay,二十 osDelay。
上图是两种方式的优缺点,下图是如何选择两种方式:
1.2. 嵌入式操作系统的基本概念
1.2.1. 任务
任务,也称为线程,是嵌入式操作系统中最重要的概念。在软件设计阶段,我们常常采用模块化设计思想,将一个复杂的嵌入式系统,根据具体应用,分解成具有独立功能的程序模块,这样的程序模块被称为任务。各个任务之间相互协作,利用操作系统提供的接口函数共同实现整个系统的功能。
- 独立性:任务之间不能彼此直接调用,也不能直接进行数据交换。任务在我们的学习中是以函数的方式表现出来的,其实内涵比函数要大。
- 并行性:操作系统将为每个任务虚拟了一个CPU,相互独立的任务各自拥有一个CPU,每个CPU各自执行各自的任务。
1.2.2. 内核
内核是嵌入式操作系统的核心模块,主要功能是负责任务的调度以及实现任务之间的相互作用(任务同步和通信)。任务的调度指的是按照一定的调度算法选择某一个任务执行。常用的调度算法有两种:
- 抢占式调度:每个任务根据其重要程度不同,被赋予一定的优先等级,在任务调度时,总是让处于就绪态的优先级最高的任务先运行。
- 时间片调度:任务优先级不同时,按照抢占式调度执行任务。任务优先级相同时,根据时间片的大小轮转运行:即每个任务执行完指定数量的时间片或发生阻塞(等待事件或延时)时,才会进行任务切换。
时间片调度和抢占式调度相结合的任务调度过程如图10-3所示。
具体的任务调度过程如下:当任务优先级相同时,采用时间片调度方式,每个任务运行指定数量的时间片后,主动放弃 CPU的使用权,切换到下一个任务执行。当任务优先级不同时,采用优先级调度方式,处于就绪状态的高优先级任务可以打断低优先级任务获得CPU的使用权。中断的优先级高于所有的任务,一旦发生中断,将打断当前正在执行的任务,进入到中断服务程序。中断服务程序在退出时,需要检査一下是否有优先级更高任务就绪。如果有,则进入到更高优先级任务执行;如果没有,则返回到之前被中断的那个任务执行。
1.2.3. 任务的上下文和任务切换
任务的上下文(context)指的是CPU寄存器(包括程序计数器PC)。当内核决定运行另外的任务时,它需要保存当前正在运行任务的上下文,这些内容将保存到任务自身的任务栈中。入栈工作完成后,再把下一个将要运行任务的上下文从其任务栈中重新装人CPU的寄存器,然后开始下一个任务的运行,这个过程称为任务切换或上下文切换。
1.2.4. 事件
在嵌入式操作系统中,为了使系统达到高效处理和快速响应的目的,大量采用“事件驱动”的方式来编写任务,这里的事件表示一种具体的操作。例如,按键按下、串口接收完一帧数据以及定时时间到等都可以看作是一个事件。
在嵌入式操作系统中,提供了信号量、事件标志组以及消息队列等系统服务来表示某一个事件的发生。
1.2.5. 任务状态
嵌入式操作系统中的任务一共有四种状态,任务一旦创建后,将在这四种状态中切换。
- 运行态:当前正在执行的任务处于运行状态,此时该任务拥有CPU的使用权。任何时刻下只能有一个任务处于运行态。
- 就绪态:准备好运行的任务处于就绪状态,一旦当前运行任务被终止或阻塞,下一个优先级最高的就绪任务将成为运行任务。
- 阻塞态:任务在执行过程中需要等待某一个事件的发生或者需要延时一定的时间,此时任务将处于阻塞态。当任务等待的事件发生或者延时时间到后,将切换到就绪态。
- 终止态:当任务的功能已经完成,不再需要该任务时,可以使任务挂起,处于终止状态,并释放任务拥有的资源。
1.2.6. 任务的三要素
从任务的存储结构上看,任务主要由二部分组成:任务函数、任务栈和任务控制块:
- 任务函数:每一个任务都要完成对应的功能,实现这个功能的函数就称为任务函数。任务函数从本质上来说,就相当于一个独立的前后台程序。操作系统所进行的任务调度,本质上就是调度任务函数,从一个任务的任务函数中退出,切换到另一个任务的任务函数中执行。任务函数一般设计为一个无限循环,在循环中至少要调用一次操作系统所提供的接口函数,以实现任务的调度以及任务之间的交互。
- 任务栈:由于每个任务都是独立运行的,因此必须给每个任务提供单独的内存空间(RAM)这个内存空间称为任务栈。在任务的运行过程中,任务栈用于保存任务切换时的上下文环境、任务函数中定义的局部变量以及任务调用普通函数时保存的返回地址和传入普通函数的人口参数。在大多数情况下,任务栈的大小一般为几百个字节。
- 任务控制块:任务控制块是内核定义的一个数据结构,里面包括了任务的基本属性,如任务栈指针、任务状态、任务优先级以及指向任务函数的指针等,内核通过任务控制块实现对任务的管理和调度。
1.2.7. 时钟节拍
时钟节拍是一个周期性的定时中断,定时中断的时间间隔一般在1ms到 10 ms 之间时钟节拍可以为操作系统提供延时功能,将任务延时若干整数倍的时钟节拍,以及当任务等待事件发生时,提供超时判断的依据。
1.3. 嵌入式实时操作系统
有很多,根据硬件资源和芯片支持进行选择,常见的
- uc/Os
- RTX(CMSIS-RTOS):接口规范,目前被STM32所使用,底层目前使用的是 FreeRTOS
- RT-Thread 国产,推荐
- FreeRTOS
2. 任务管理
2.1. 任务划分
说明如何确定任务的一些经验,注意这不是确定的,根据你的应用进行调整。
- 关键功能
- 触发事件相同
- 运行周期相同
- 功能聚合
- 耗时操作
- 固定顺序任务
2.2. 任务函数的结构
2.2.1. 单次任务
2.2.2. 周期执行任务
2.2.3. 事件触发任务
2.3. 任务优先级设置
- 中断相关性
- 紧迫性
- 关键性
- 频繁性
- 快捷性
- 传递性
注意:中断的优先级高于所有的任务
2.4. 任务管理接口函数
2.4.1. 任务创建
2.4.2. 任务优先级设置
注意:FreeRTOS的任务优先级采用数字表示,编号越大,优先级越高。为了明确地表示任务优先级的属性,CMSIS-RTOS2将原有的优先级重新封装,分成了六大类,具体描述如表 10-5 所示。
2.4.3. 延时函数
2.5. 应用:任务创建
2.5.1. 芯片配置
- 建立一个项目,设置中间件:
- 配置两个任务,以前有个缺省任务,可以双击编辑:
LED任务:
串口任务:
- 设置PA7为输出,Label为LED:
- 开启串口2:
- 生成代码的时候提示:
目前不知道这个 reentrant 具体什么情况,按照提示打开这个功能(会增加RAM的占用);
- 打开 newlib:
- 生成代码后编译,出现下列错误:
大约意思是 freetos_mpool.h 这个文件没有找到。上网搜索解决方案,有两个:
- 使用CMSIS_V1
- 更换Embedded Software Package 的版本
我们尝试使用第二种方式,在菜单的 Help 中找到 Manage Embedded Software Packages 的选项,出现以下的窗口:
检查STM32F1的版本是1.8.6(每个人的可能有差异),点击1.8.5 版本,并安装。
回到芯片配置,在 Project Manager 的最下面:
取消:Use latest available version。然后选择刚刚下载的 1.8.5 的版本,保存生成代码后可以成功编译。
2.5.2. 代码编写
- 在main.c 的头文件引用中加入:
/* USER CODE BEGIN Includes */
#include <string.h>
/* USER CODE END Includes */
修改 StartLedTask 函数的代码:
/* USER CODE END Header_StartLedTask */
void StartLedTask(void *argument) {
/* USER CODE BEGIN 5 */
/* Infinite loop */
for (;;) {
osDelay(100);
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
/* USER CODE END 5 */
}
修改 StartComTask 函数的代码:
/* USER CODE END Header_StartComTask */
void StartComTask(void *argument) {
/* USER CODE BEGIN StartComTask */
/* Infinite loop */
for (;;) {
osDelay(1000);
HAL_UART_Transmit(&huart2, (uint8_t*) MSG, strlen(MSG), 100); // 发送接收到的数据
}
/* USER CODE END StartComTask */
}
我们看到,这里的每个任务的主函数都是一个死循环,如同 main 函数一样;另外,任务中的延时不能使用 HAL_Delay 而必须使用 osDelay,否则会影响任务的调度!
在用户代码区4中加入代码:
/* USER CODE BEGIN 4 */
char *MSG = "COM Task, run every 1s !";
/* USER CODE END 4 */
2.5.3. 代码分析
框架自动生成了两个任务的属性结构体:
/* Definitions for ledTask */
osThreadId_t ledTaskHandle;
const osThreadAttr_t ledTask_attributes = { .name = "ledTask", .stack_size = 128
* 4, .priority = (osPriority_t) osPriorityNormal, };
/* Definitions for comTask */
osThreadId_t comTaskHandle;
const osThreadAttr_t comTask_attributes = { .name = "comTask", .stack_size = 128
* 4, .priority = (osPriority_t) osPriorityLow, };
在主函数中,硬件初始化完成后的代码:
/* Init scheduler */
osKernelInitialize(); // 操作系统内核初始化
/* USER CODE BEGIN RTOS_MUTEX */
/* add mutexes, ... */
/* USER CODE END RTOS_MUTEX */
/* USER CODE BEGIN RTOS_SEMAPHORES */
/* add semaphores, ... */
/* USER CODE END RTOS_SEMAPHORES */
/* USER CODE BEGIN RTOS_TIMERS */
/* start timers, add new ones, ... */
/* USER CODE END RTOS_TIMERS */
/* USER CODE BEGIN RTOS_QUEUES */
/* add queues, ... */
/* USER CODE END RTOS_QUEUES */
/* Create the thread(s) */
/* creation of ledTask */
ledTaskHandle = osThreadNew(StartLedTask, NULL, &ledTask_attributes); // 建立LED任务
/* creation of comTask */
comTaskHandle = osThreadNew(StartComTask, NULL, &comTask_attributes); // 建立串口任务
/* USER CODE BEGIN RTOS_THREADS */
/* add threads, ... */
/* USER CODE END RTOS_THREADS */
/* USER CODE BEGIN RTOS_EVENTS */
/* add events, ... */
/* USER CODE END RTOS_EVENTS */
/* Start scheduler */
osKernelStart(); // 启动操作系统的任务调度
3. 任务的同步和通知
在一个复杂的系统中,往往把一个复杂的任务分解成为很多相对独立的小任务,任务和任务之间协调工作。这样做做有很多好处,有点像变成过程中的函数,都是把一个复杂的问题转变成为一系列的简单问题进行求解和管理。但任务划分和函数划分还是有很多区别。
-
函数划分的目的除了把复杂问题拆分成简单外,更总要的作用是代码复用和降低代码耦合;任务划分同样是把复杂的问题简化成一系列简单的问题,但是内涵是协作分工以及对关键资源的管理。
-
在操作系统中,任务函数的功能往往是多个函数的聚合,功能较复杂;而从编程的角度看,普通的函数往往功能单一,更抽象;
-
在任务中,需要考虑多个任务的协调工作,这就好比管理一个企业,或者是一支军队;而普通的函数一般不关系协作的问题,一般只有简单的堆栈调用;
-
任务中的协作一般包含两个层次的内涵:
- 优先级,优先级高的会打断优先级低的任务(抢占);
- 同步:某些任务需要等待其他任务执行完成后才能执行;硬件资源的管理属于同步的一个特殊应用,例如串口被多个任务使用,任务需要排队。
在复杂的操作系统中,往往有进程、线程等概念,在FreeRTOS中没有区分这么细致。你可以理解成只有线程。
3.1. 信号量
信号量一般代表可用资源的数量,可以分为二值信号量和计数信号量。
- 二值信号量:二值信号量是取值只能为0或1的信号量,用于表示某种事件是否发生或条件是否满足,取值为0时表示事件没有发生,取值为1时表示事件已经发生。二值信号量的初始值一般设置为0,当一个任务需要和其他任务协同工作时,可以发出同步信号,调用RTOS提供的信号量发送函数,使二值信号量的值变为1。另一个任务在需要任务同步的地方调用 RTOS提供的信号量获取函数。此时该任务的执行情况分为两种:如果二值信号量已经为1,则将二值信号量清零并继续运行下去;如果二值信号量为0,则该任务由运行态转为阻塞态,等待二值信号量变为有效(为1)。二值信号量有效后,该任务才会由阻塞态变为就绪态,等待RTOS的调度。
- 计数信号量:计数信号量主要用于资源的计数,它的初始值一般为可用资源的数量。例如,我们在酒店就餐时,酒店所提供的餐桌数量是固定的。现在设计一个计数器,其初值为最大的餐桌数。假设一人占用一张餐桌,每进去一人,计数器就会自动减一,每出来一人,计数器自动加一。如果计数器的值大于0,酒店就允许客人进去就餐;否则就等待,直到有空余的餐桌出现。