48 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,酒店就允许客人进去就餐;否则就等待,直到有空余的餐桌出现。
3.1.1. 接口函数
3.1.1.1. 信号量创建函数(osSemaphoreNew)
3.1.1.2. 信号量获取函数(osSemaphoreAcquire)
3.1.1.3. 信号量释放函数(osSemaphoreRelease)
3.1.2. 二值信号量的应用
建立一个按键执行任务,用于执行按键按下后的操作:翻转指示灯状态。按键的检测采用外部中断,中断和任务之间通过二值信号量实现同步。
3.1.2.1. 配置
使用PA7作为LED;PB12(button1)作为按钮;注意button1有外部上拉,不用设置内部上拉;
- 新建一个工程,取名Semaphore;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 设置PA7为输出,并设置Label为LED;
- 设置PB12为外部中断,Label为BUTTON;
- 在NVIC中配置EXTI 15:10 为启用;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- 修改缺省的任务如下:
- 在 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 添加一个信号量:
信号量的配置如下:
其中 Initial State 为信号量的初始化状态,这里选择 Available 表示1;
- 保存并生成代码
3.1.2.2. 代码编写
在main.c的 USER CODE BEGIN 0 位置加入按钮中断的回调:
/* USER CODE BEGIN 0 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == BUTTON_Pin)
osSemaphoreRelease(KeyBinarySemHandle);
}
/* USER CODE END 0 */
修改 void StartLedTask(void *argument) 函数,大约在 237 行,如下:
void StartLedTask(void *argument) {
/* USER CODE BEGIN 5 */
/* Infinite loop */
for (;;) {
osSemaphoreAcquire(KeyBinarySemHandle, osWaitForever);
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
/* USER CODE END 5 */
}
3.1.2.3. 代码分析
在配置中新建一个信号量后,主程序中自动添加了这些代码:
osSemaphoreId_t KeyBinarySemHandle;
const osSemaphoreAttr_t KeyBinarySem_attributes = { .name = "KeyBinarySem" };
KeyBinarySemHandle = osSemaphoreNew(1, 1, &KeyBinarySem_attributes);
书上说 osSemaphoreNew 的第三个参数可以是 null,在框架中自动生成了一个结构体描述;
另外生成了 StartLedTask 任务函数的代码框架,只需要在任务中添加我们的逻辑就好了。
程序流程:
- 在信号量配置的时候,初始值选择了 Available ,表示信号是可用的;
- 操作系统在运行到 StartLedTask 的循环中,执行 osSemaphoreAcquire 去取得信号;因为信号是1,表示可用,这时该函数立即返回,并同时设置 KeyBinarySemHandle 信号量为0(表示已经被占用);
- 随后执行灯的翻转(缺省灯是0,翻转后是1),点亮LED;
- 再次循环,执行 osSemaphoreAcquire 函数,获取信号;因为上一次信号已经被占用,这时的值只0,该函数不能获取信号,将被阻塞(参数 osWaitForever 表示无限制时间);
- 按钮按下,通过中断回调 HAL_GPIO_EXTI_Callback 函数中执行 osSemaphoreRelease 释放信号,信号被设置成1;
- 通过任务调度,StartLedTask 任务中的 osSemaphoreAcquire 返回后执行端口反转,关闭LED;然后再次进入第四步;
3.1.3. 计数信号量
建立两个任务:发送任务负责连续发送信号量,指示灯任务在接收到信号量后,控制指示灯闪烁一下。具体代码如程序清单 10-6 所示。
3.1.3.1. 配置
- 新建一个工程,取名SemaphoreN;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 设置PA7为输出,并设置Label为LED;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- FreeRTOS 的 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 在FreeRTOS 建立一个新的 Counting Semaphore 信号量:
按照下图进行添加信号量:
Max Count 是计数信号量的最大值;Initial Count 表示计数信号量的初始值;
- 添加两个任务:
3.1.3.2. 代码编写
补充 StartSendTask 函数:
/* USER CODE END Header_StartSendTask */
void StartSendTask(void *argument) {
/* USER CODE BEGIN 5 */
/* Infinite loop */
for (;;) {
osSemaphoreRelease(CountingSemHandle); // 第一次设置一个信号量,信号量加1
osDelay(1000);
osSemaphoreRelease(CountingSemHandle); // 第二次设置两个信号量,信号量加2
osSemaphoreRelease(CountingSemHandle);
osDelay(1000);
osSemaphoreRelease(CountingSemHandle); // 第三次设置三个信号量,信号量加3
osSemaphoreRelease(CountingSemHandle);
osSemaphoreRelease(CountingSemHandle);
osDelay(3000);
}
/* USER CODE END 5 */
}
补充 StartLedTask 函数:
/* USER CODE END Header_StartLedTask */
void StartLedTask(void *argument) {
/* USER CODE BEGIN StartLedTask */
/* Infinite loop */
for (;;) {
if (osSemaphoreAcquire(CountingSemHandle, osWaitForever) == osOK) {
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
osDelay(100);
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
osDelay(100);
}
}
/* USER CODE END StartLedTask */
}
3.1.3.3. 代码解析
- 计数信号量表示可用资源的个数,最大是10,初始值是0,表示没有可用资源;如果计数信号量是0,当调用osSemaphoreAcquire函数的时候,将会阻塞,一直到信号量大于0为止;
- 两个任务的优先级是一样的,其实在程序刚刚开始运行的时候,我们不确定到底是StartSendTask先运行还是StartLedTask先运行;不过不影响整个流程;我们能确定的是这两个任务几乎是并行的的;
- 如果 StartSendTask 先运行,调用一次 osSemaphoreRelease 函数后,信号量从0变成了1;此时,StartLedTask 的 osSemaphoreAcquire 函数返回(同时信号量减1),LED闪烁一次;当循环再次运行到 osSemaphoreAcquire 时,因为没有信号量,所有阻塞;
- 第二秒开始,StartSendTask 使用 osSemaphoreRelease 添加了两个信号量,因此 StartLedTask 控制LED闪烁两次;
- 第三秒开始,StartSendTask 使用 osSemaphoreRelease 添加了3个信号量,因此 StartLedTask 控制LED闪烁3次;
- 延迟3秒后,从第三步再次开始。
3.2. 事件标志组
在信号量的应用中。我们发现信号量只能实现两个任务之间的同步,如果要实现多个任务之间的同步,则需要使用事件标志组。
事件标志组是多个二值信号的组合,其中每一个二值信号就是一个事件标志位(相当于一个二值信号量),用来表明某一个事件是否发生,该标志位由一个相关的任务或ISR 置位。
事件标志组可以实现多个任务(包括ISR)协同控制一个任务,当各个相关任务的对应事件发生时,将设置事件标志组的对应标志位有效,进而触发对应的任务。
使用事件标志组同步的任务可以分为独立性同步(OR)和关联性同步(AND)。独立性同步表示等待的任何一个事件发生时,就可以触发任务。关联性同步表示等待的全部事件都发生时,才可以触发任务。事件标志组的基本原理如图 10-18 所示。
FreeRTOS提供了事件标志组的功能,每一个事件标志组最多具有24个事件标志位。经过 CMSIS-RTOS2封装后,提供的常用接口数有三个:
3.2.1. 函数接口
3.2.1.1. 事件标志组创建函数(osEventFlagsNew)
该函数用于创建一个新的事件标志组,具体描述如表10-11所示。
3.2.1.2. 事件标志组设置函数(osEventFlagsSet)
该函数用于设置指定事件标志组中的一个或多个的事件标志位,具体描述如表10-12 所示。
3.2.1.3. 事件标志组等待函数(osEventFlagsWait)
该函数用于等待指定事件标志组中的一个或多个事件标志位,执行时会暂停调用该函数的任务,直到等待的事件标志位置位。具体描述如表10-13所示。
3.2.2. 应用示例(和书上不一样)
3.2.2.1. 配置
- 新建一个工程,取名EventFlag;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 设置PA7为输出,并设置Label为LED;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- FreeRTOS 的 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 配置两个任务:
3.2.2.2. 代码编写
在 USER CODE BEGIN 4 部分加入以下代码:
/* USER CODE BEGIN 4 */
#define EVENT1 0x01
#define EVENT2 0x02
/* USER CODE END 4 */
补充 StartEventTask 代码:
void StartEventTask(void *argument)
{
/* USER CODE BEGIN 5 */
/* Infinite loop */
for (;;) {
osEventFlagsSet(LedEventFlagHandle, EVENT1);
osDelay(1000);
osEventFlagsSet(LedEventFlagHandle, EVENT2);
osDelay(1000);
osEventFlagsSet(LedEventFlagHandle, EVENT1);
osEventFlagsSet(LedEventFlagHandle, EVENT2);
osDelay(3000);
}
/* USER CODE END 5 */
}
补充 StartLedTask 代码:
void StartLedTask(void *argument)
{
/* USER CODE BEGIN StartLedTask */
/* Infinite loop */
for (;;) {
osEventFlagsWait(LedEventFlagHandle, EVENT1 | EVENT2, osFlagsWaitAll, osWaitForever);
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
osDelay(200);
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
}
/* USER CODE END StartLedTask */
}
3.2.2.3. 运行效果和程序分析
思考:程序运行的效果是什么?
分析两个任务的执行流程。
3.3. 线程标志
线程标志,也称为任务通知,它的功能与事件标志组类似,可以用于多个任务的同步。唯一的区别是线程标志不需要用户创建,每一个任务创建后就自动拥有一个线程标志。任务的线程标志只能由任务自身等待,由其他任务设置。而事件标志组则相当于一个公用的资源,任何任务都可以设置或等待。
FreeRTOS提供了线程标志的功能,每一个线程标志具有31个线程标志位(每一个线程标志位相当于一个二值信号量 )。经过 CMSIS-RTOS2封装后,提供的常用接口函数有两个。
3.3.1. 函数接口
3.3.1.1. 线程标志设置函数(osThreadFlagsSet)
3.3.1.2. 线程标志等待函数(osThreadFlagsWait)
3.3.2. 应用示例(书上代码不完整)
3.3.2.1. 配置
- 新建一个工程,取名ThreadFlag;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- FreeRTOS 的 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 修改缺省任务的名称:
- 打开串口2,并使能串口2的全局中断;
3.3.2.2. 代码
在 USER CODE BEGIN 0 代码段中加入:
/* USER CODE BEGIN 0 */
uint8_t RxBuffer[10];
// 中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
if (huart->Instance == USART2) {
osThreadFlagsSet(ComTaskHandle, 0x01);
HAL_UART_Receive_IT(&huart2, RxBuffer, 10); // 再次启动中断接收
}
}
/* USER CODE END 0 */
完善 StartComTask 任务代码:
/* USER CODE END Header_StartComTask */
void StartComTask(void *argument) {
/* USER CODE BEGIN 5 */
HAL_UART_Receive_IT(&huart2, RxBuffer, 10); // 启动中断接收
/* Infinite loop */
for (;;) {
osThreadFlagsWait(0x01, osFlagsWaitAny, osWaitForever);
HAL_UART_Transmit(&huart2, RxBuffer, 10, 100);
}
/* USER CODE END 5 */
}
3.3.2.3. 代码分析和效果
问题:
- StartComTask 函数中的 HAL_UART_Receive_IT(&huart2, RxBuffer, 10) 的目的是什么?可以写在其他地方吗?
- HAL_UART_RxCpltCallback 的作用是什么?
- 程序实现什么效果?
- 程序的逻辑和流程是什么?
3.4. 互斥量
在RTOS中,实现临界区的互斥访问一般使用互斥量。互斥量本质上也是一个二值信号量,二者的不同之处在于:二值信号量主要是实现任务的同步,通常是一个任务申请另一个任务释放。而互斥量主要是实现对共享资源的互斥访问,通常是同一个任务申请同一个任务释放。
3.4.1. 接口函数
3.4.1.1. 互斥量创建函数(osMutexNew)
3.4.1.2. 互斥量获取函数(osMutexAcquire)
3.4.1.3. 互斥量释放函数(osMutexRelease
3.4.2. 应用示例
建立两个任务,分别通过串口向PC发送数据,利用互斥量实现两个任务对串口的互斥访问。具体代码如程序清单10-9所示。
3.4.2.1. 配置
- 新建一个工程,取名Mutex;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 打开串口2,不用设置中断模式;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- FreeRTOS 的 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 建立一个互斥量:
- 建立两个任务:
3.4.2.2. 代码编写
在 USER CODE BEGIN 0 中加入以下代码:
/* USER CODE BEGIN 0 */
char * MSG1="Message from task1\n";
char * MSG2="Message from task2\n";
/* USER CODE END 0 */
补充 StartComTask1 代码:
/* USER CODE END Header_StartComTask1 */
void StartComTask1(void *argument) {
/* USER CODE BEGIN 5 */
/* Infinite loop */
for (;;) {
osMutexAcquire(UartMutexHandle, osWaitForever);
HAL_UART_Transmit(&huart2, (uint8_t*) MSG1, 19, 100);
osMutexRelease(UartMutexHandle);
osDelay(1000);
}
/* USER CODE END 5 */
}
补充 StartComTask2 代码:
/* USER CODE END Header_StartComTask2 */
void StartComTask2(void *argument) {
/* USER CODE BEGIN StartComTask2 */
/* Infinite loop */
for (;;) {
osMutexAcquire(UartMutexHandle, osWaitForever);
HAL_UART_Transmit(&huart2, (uint8_t*) MSG2, 19, 100);
osMutexRelease(UartMutexHandle);
osDelay(1000);
}
/* USER CODE END StartComTask2 */
}
3.4.2.3. 代码分析
- 运行后是什么效果
- 互斥量在使用上和信号量有什么区别?
3.5. 消息队列
使用信号量、事件标志组和线程标志进行任务同步时,只能提供同步的时刻信息,无法在任务之间进行数据传输。要实现任务间的数据传输,一般使用两种方式:
- 全局变量:在RTOS中使用全局变量时,必须保证每个任务对全局变量的互斥访问,一般借助互斥量来实现。另一个方法是在任务设计时,设计成只有一个任务修改这个全局变量,其他任务只是读取这个全局变量,而不修改它的值,并在全局变量前面加上 volatile 的关键字修饰,以避免编译器的优化。
- 消息队列消息队列类似于一个数据缓冲区,可以保存有限个、具有确定大小的数据。通常情况下,消息队列按照 FIFO(先进先出)的模式使用,即数据由队尾写人,从队首读出。任务向消息队列中放人消息时,需要判断消息队列是否有多余的空间:如果有空间则放入一个新的消息;如果消息队列已经存满,该任务将进入到阻塞态,直到消息队列中有多余的空间。任务从消息队列中获取消息时,需要判断消息队列是否有消息:如果消息队列中没有消息,该任务将进入到阻塞态。当消息队列中有新的消息时,处于阻塞态的任务将被唤醒并获得该消息。任务在获取消息时,需要提前定义存放消息的缓冲区,这个缓冲区的大小不能小于消息队列中单个消息的大小。
注意:FreeRTOS利用消息队列进行消息传递时,放入消息队列的是实际的数据,而不是数据的地址。例如,串口一次接收10字节的数据,如果使用消息队列来传递串口接收的数据,则应该将消息队列的单个消息大小设置为10字节,以便一次性存放串口接收的10 字节数据。
消息队列和全局变量相比,解决了多任务访问共享资源的冲突问题,还提供了任务的同步和超时处理等机制,并且可以实现中断服务程序和任务之间的数据传递。例如,多个任务都要使用串口进行数据传输时,可以采用两种方法:一种方法是利用互斥量实现对串口的互斥访问;另一种方法是创建一个消息队列和一个负责串口数据收发的任务。任务A发送的数据放入消息队列,任务B发送的数据也放入消息队列,串口发送任务则按照 FIFO的原则从消息队列中取出消息发送。
在实际应用时,由于消息队列采用数据复制的方式传输数据,而不是传输存放数据的地址。如果任务间传输的数据量较大时,使用消息队列的效率会比较低。这时,可以考虑使用全局变量来实现任务问的通信,只是要注意全局变量的互斥访问(利用互斥量实现)。
3.5.1. 接口函数
3.5.1.1. 消息队列创建函数(osMessageQueueNew)
3.5.1.2. 消息放入函数(osMessageQueuePut)
3.5.1.3. 消息获取函数(osMessageQueueGet)
3.5.2. 应用示例
利用消息队列传输串口接收的数据。串口采用中断方式接收10字节的数据,并放入消息队列。数据处理任务从消息队列中取出数据并发送到PC显示。消息队列设置为可以容纳5个消息,每个消息的大小为10字节。
配置
- 新建一个工程,取名MsgQueue;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 打开串口2,并打开串口全局中断;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- FreeRTOS 的 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 建立一个任务:
- 建立一个消息队列:
这里的Queue Size 表示队列的长度;Item Size 表示队列中每个元素的长度(字节);
代码
在 USER CODE BEGIN 0 段加入代码:
/* USER CODE BEGIN 0 */
uint8_t RxBuffer[10];
uint8_t TxBuffer[10];
// 中断回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
osMessageQueuePut(ComQueueHandle, RxBuffer, 0, osWaitForever);
HAL_UART_Receive_IT(&huart2, RxBuffer, 10);
}
/* USER CODE END 0 */
完善 StartProcessTask 任务函数:
/* USER CODE END Header_StartProcessTask */
void StartProcessTask(void *argument) {
/* USER CODE BEGIN 5 */
HAL_UART_Receive_IT(&huart2, RxBuffer, 10);
/* Infinite loop */
for (;;) {
if (osMessageQueueGet(ComQueueHandle, TxBuffer, NULL, osWaitForever)
== osOK) {
HAL_UART_Transmit(&huart2, TxBuffer, 10, 100);
}
}
/* USER CODE END 5 */
}
运行效果和代码分析
不出意外,出意外了;把:
osMessageQueuePut(ComQueueHandle, RxBuffer, 0, osWaitForever);
修改成:
osMessageQueuePut(ComQueueHandle, RxBuffer, 0, 0);
成功。
为什么?无解。
3.6. 软件定时器
在嵌入式系统设计中,常常有这样的功能需求:一个任务周期性地运行或者在将来某个时刻运行,以实现定时或者延时控制,这个功能可以利用定时器实现。
从定时器的实现角度来看,可以分为硬件定时器和软件定时器。硬件定时器数量有限,往往可以实现除了简单定时而外的其他功能;作为简单的定时而言,软件定时器可能更适合。
软件定时器的工作方式和硬件定时器类似:设置一个定时时间后,启动定时器运行预设的定时时间到达后,可以调用一个回调函数来执行相应的操作。
软件定时器可以配置为单次触发或者周期触发:单次触发定时器执行一次回调函数后将不再运行;周期触发定时器则重复执行回调函数,直到用户停止或删除该定时器为止。所有软件定时器都可以启动、停止或重新启动。
函数接口
软件定时器创建函数(osTimerNew)
软件定时器启动函数(osTimerStart)
软件定时器停止函数(osTimerStop)
应用示例
建立两个软件定时器:一个是周期触发定时器,每隔100ms通过串口向PC发送数据;一个是单次触发定时器,控制指示灯在系统启动5s后再开启。
配置
- 新建一个工程,取名OsTimer;注意:需要设置SYS中的Debug为 Serial Wire;另外固件版本可能需要调整,1.8.6的有问题;
- 同样在 SYS 中,配置Timebase Source 为一个没有使用的定时器,如TIM4;
- 设置PA7为输出,并设置Label为LED;
- 打开串口2,不用打开中断;
- 配置中间件,打开FREERTOS,Interface设置成 CMSIS_V2;
- FreeRTOS 的 Advanced settings 中打开 NEWLIB 的选项,否则要提示警告;
- 建立一个任务
代码编写
在 USER CODE BEGIN Includes 代码段补充以下代码:
/* USER CODE BEGIN Includes */
#include <string.h>
/* USER CODE END Includes */
在 USER CODE BEGIN 0 补充以下代码
/* USER CODE BEGIN 0 */
osTimerId_t ComTimerHandle;
osTimerId_t LedTimerHandle;
char *MSG = "Periodic Virtual Timer:run every 1s.\n";
void ComCallback(void *argument) {
HAL_UART_Transmit(&huart2, (uint8_t*) MSG, strlen(MSG), 100); // 发送接收到的数据
}
void LedCallback(void *argument) {
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
osDelay(200);
HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET);
}
/* USER CODE END 0 */
完善 StartInitTimer 任务函数
/* USER CODE END Header_StartInitTimer */
void StartInitTimer(void *argument) {
/* USER CODE BEGIN 5 */
ComTimerHandle = osTimerNew(ComCallback, osTimerPeriodic, NULL, NULL);
LedTimerHandle = osTimerNew(LedCallback, osTimerOnce, NULL, NULL);
osTimerStart(ComTimerHandle, 1000);
osTimerStart(LedTimerHandle, 5000);
for (;;) {
osDelay(1000);
}
/* USER CODE END 5 */
}
代码分析
按照书上的方法,启动定时器不能简单的在main 函数中完成。首先分析一下main函数的流程:
int main(void) {
HAL_Init(); // 1. 硬件代码初始化
SystemClock_Config(); // 2. 系统时钟配置
MX_GPIO_Init(); // 3. GPIO初始化
MX_USART2_UART_Init(); // 4. 串口初始化
osKernelInitialize(); // 5. 操作系统初始化
InitTimerHandle = osThreadNew(StartInitTimer, NULL, &InitTimer_attributes); // 6. 建立一个任务
osKernelStart(); // 7. 操作系统正式运行
while (1) {
// 8. 非操作系统的循环代码
}
}
- 正常情况下,当运行到7的位置时,操作系统进行自己的任务调度循环,这时,7之后的代码永远不能运行;
- 在7之前的代码都是初始化的代码,操作系统也没有运行;
- 因此启动定时器的代码放在7之前是不合适的,因为操作系统还没有运行,无法启动定时器;
- 启动定时器的代码放在7之后也是不合适的,因为7之后的代码正常情况下永远不会运行;
- 因此,我们需要一个独立的任务,在操作系统运行后对定时器进行启动,因此有了 StartInitTimer 这个任务;这个任务的目的就是在操作系统运行的时候启动定时器;
- 按理说,这个任务可以是一次性任务,不需要后面的死循环;但是删除后面的死循环后,代码无法正常运行;
- 有猜测操作系统至少应该有一个周期性任务,因此另外添加一个任务(保留该任务中的死循环);并删除 StartInitTimer 任务中的死循环,让 StartInitTimer 成为一次性任务,依旧不能正常运行。具体原因还需要进一步分析。