单核单片机使用RTOS操作共享资源线程安全吗?
目录
1.前言
最近听到这样的观点:“我们用的这个STM32单片机是单核心的,即使运行实时操作系统,多个线程共享全局变量都是线程安全的,因为单核单片机不存在并行问题,同一个时间片只能运行一个线程,因此操作共享的全局变量是不需要进行线程同步的”,我相信持有这种观点的同学不在少数,因为大部分从事单片机开发的同学都是电子通信等专业毕业的。怀着这个疑问,我们来做一下实验,毕竟,实践是检验真理的唯一标准。
2.实验
作者手头上只有一块STM32L471的板子,这款单片机是意法半导体旗下的Cortex M4内核的单片机,主打低功耗,我们就使用这款单片机来做一下实验,验证单核单片机多线程共享全局变量是否是线程安全的。
借助STM32 CubeMX工具,我们可以快速创建原型验证项目,为了让实验效果更加显著,我们将单片机的主频设置为最高,以下是使用CubeMX工具配置时钟的界面:
如上图所示,我们使用锁相环将主频倍频至该款单片机支持的最大频率80MHz,外设我们使能调试串口,借助于CubeMX代码生成器的快速配置能力,我们使能FreeRTOS实时操作系统,创建两个线程:
第一个线程取名为task1,关联的线程函数为task1_entry,栈大小采用默认参数即可,优先级设置为Normal,第二个线程的配置如下图所示:
第二个线程跟第一个线程的配置一模一样,名字取为task2,线程入口函数设置为task2_entry,同样优先级设置为Normal,这两个线程优先级均设置为Normal,让两个线程均分时间片以便于观察实验结果。配置完毕后就可以生成代码了,这里我们选择生成STM32CubeIDE项目,各位同学可以按照自己的习惯选择Keil/IAR/CubeIDE等集成开发环境,生成代码后,我们开始添加业务代码。
我们的大体思路是定义一个全局变量,两个线程分别进行10万次自增操作,当操作结束后,打印该变量的值,如果自增操作是线程安全的,那么我们最后打印出来的该值应该为20万,首先在freertos.c文件中定义宏和变量:
/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */
#define MAX_NUM (100000)
/* USER CODE END PM */
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN Variables */
int num = 0;
/* USER CODE END Variables */
在task1线程的入口函数添加以下代码:
/* USER CODE BEGIN Header_task1_entry */
/**
* @brief Function implementing the task1 thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_task1_entry */
void task1_entry(void *argument)
{
/* USER CODE BEGIN task1_entry */
/* Infinite loop */
for(;;)
{
printf("task1 start, num=%d\r\n", num);
for(int i = 0; i < MAX_NUM; i++)
{
num++;
}
osDelay(20000);
printf("task1 end num=%d\r\n", num);
osDelay(0xffffffff);
}
/* USER CODE END task1_entry */
}
在task1_entry中,我们首先打印num的初始值,然后让num变量自增10万次,延时20秒等待两个线程均自增完毕,打印num的最终值,最后让线程进入无限延时的状态。线程2也作同样的修改:
/* USER CODE BEGIN Header_task2_entry */
/**
* @brief Function implementing the task2 thread.
* @param argument: Not used
* @retval None
*/
/* USER CODE END Header_task2_entry */
void task2_entry(void *argument)
{
/* USER CODE BEGIN task2_entry */
/* Infinite loop */
for(;;)
{
printf("task2 start, num=%d\r\n", num);
for(int i = 0; i < MAX_NUM; i++)
{
num++;
}
osDelay(20000);
printf("task2 end num=%d\r\n", num);
osDelay(0xffffffff);
}
/* USER CODE END task2_entry */
}
至此,代码修改完毕,编译并下载程序到目标板上,观察结果。
3.结果及原理分析
运行代码后,单片机输出的日志如下:
从图中可以看到程序运行结束后,两个线程打印出来的num的结果都是10万,这个跟我们之前设想的20万结果完全不一致,有的同学可能会说你的num变量没有定义为volatile,编译器优化后,各自线程直接就操作寄存器了,导致最后结果不对,那么我们按照这个思路修改一下,将num变量定义为volatile:
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN Variables */
volatile int num = 0;
/* USER CODE END Variables */
在num变量前添加volatile关键字进行修饰,再次边缘并运行,查看最终结果:
可以看到,实验结果确实有所变化,但是与我们的预期还是大相径庭,这是为什么呢,我们来看一下num++的汇编代码:
LDR r1,[r4,#0x00]
ADDS r1,r1,#1
STR r1,[r4,#0x00]
从num++对应的汇编代码可以看到,自增运算总共需要3个步骤,即从内存中读取值到寄存器,寄存器修改值,将寄存器的值再写入内存,由于自增运算需要3个步骤完成,因此并不是原子操作,存在线程安全问题,举个极端的例子,这两个线程轮流执行一条汇编指令,程序开始后,线程1先执行,从内存中将num的值0加载到寄存器,然后轮到线程2执行,同样将num的值加载到寄存器,此时num的值也是0,线程1继续执行,将寄存器的值加1,此时线程1寄存器的值为1,线程2执行,将寄存器的值加1,此时线程2寄存器的值也为1,线程1执行,将寄存器的值1写入到num,此时num的值为1,线程2执行,同样将寄存器的值写入到num,最终num的值还是1,这样一直运行到最后,num最终的值也就是10万,这个跟我们预期的效果完全不一样,因此多线程访问同一个全局变量是存在隐患的,要保证线程安全,我们需要可以对共享资源进行加锁或者使用临界区在num自增时禁止线程切换,修改一下代码:
void task1_entry(void *argument)
{
/* USER CODE BEGIN task1_entry */
/* Infinite loop */
for(;;)
{
printf("task1 start, num=%d\r\n", num);
for(int i = 0; i < MAX_NUM; i++)
{
taskENTER_CRITICAL();
num++;
taskEXIT_CRITICAL();
}
osDelay(20000);
printf("task1 end num=%d\r\n", num);
osDelay(0xffffffff);
}
/* USER CODE END task1_entry */
}
两个线程均按照上述代码进行修改,在自增操作之前,进入临界区,自增操作后,离开临界区,保证自增操作的原子性,编译、下载并运行,最终日志如下:
最终输出的结果跟我们预期的一致。
4.总结
本文设计了实验来验证单核单片机共享全局变量是否是线程安全的,通过实验结果来看即使是单核单片机也存在线程不安全的情况,文章分析了造成线程不安全的原因,并给出了解决方案,平时我们在使用生产者消费者结构以及多线程操作共享的循环队列,操作共享的链表时尤其需要注意,如果不注意线程安全问题,尽管你测试的时候功能都正常,但是有小概率整个程序的运行逻辑会进行改变。如果您的程序在大批量设备上应用,偶尔出现莫名的逻辑错误问题,可以排查一下是否存在共享资源线程安全问题。对于裸机程序来说,中断的引入也有可能导致共享的变量出现问题,后续文章会设计相应的实验并进行详细讲解。
作者:mysoftlab