2、临界区与资源竞争:临界区概念、资源竞争的产生、竞态条件示例

好,咱们正式开始聊多任务编程里最核心、也最容易踩坑的话题——临界区资源竞争

说实话,我刚开始用RTOS那会儿,觉得任务切换挺酷的。一个任务跑一会儿,另一个任务跑一会儿,CPU利用率拉满。但很快我就发现,事情没那么简单。你想想看,如果两个任务同时去操作同一个变量,会发生什么?

嗯,这就是我们今天要解决的问题。

2.1 什么是临界区?

临界区,说白了就是一段代码。这段代码一旦开始执行,就不允许被其他任务打断。为什么?因为它正在访问一个共享资源

共享资源可以是:

  • 一个全局变量
  • 一个硬件寄存器
  • 一段内存缓冲区
  • 一个外设(比如串口、SPI Flash)

我个人习惯把临界区想象成一个“手术室”。手术正在进行,你不能随便闯进去。谁闯进去,谁就得承担后果。

核心定义:临界区是访问共享资源的代码段,这段代码在执行期间必须保证互斥访问。

2.2 资源竞争是怎么产生的?

资源竞争,也叫竞态条件。它发生在两个或多个任务同时访问同一个共享资源,并且至少有一个任务是写操作

为什么会这样?因为RTOS的任务调度是抢占式的。一个任务正在执行,突然来了一个更高优先级的任务,CPU就被抢走了。如果这个被抢走的任务刚好在修改一个全局变量,那问题就来了。

我在项目中遇到过这样一个场景:一个温度传感器采集任务,每隔100ms更新一次全局变量 g_temperature。另一个显示任务,每隔500ms读取这个变量并刷新屏幕。看起来没问题对吧?

但实际测试时,屏幕偶尔会显示一个“0”或者一个乱码。查了半天,发现是采集任务写到一半,被显示任务抢占了CPU。显示任务读到了一个不完整的数据

这就是典型的资源竞争。

2.3 竞态条件示例

咱们直接上代码。看一个最简单的例子:

// 共享资源
int g_counter = 0;

// 任务1:计数器加1
void Task1(void *pvParameters) {
    while(1) {
        g_counter = g_counter + 1;  // 这行不是原子操作!
        vTaskDelay(10);
    }
}

// 任务2:计数器加1
void Task2(void *pvParameters) {
    while(1) {
        g_counter = g_counter + 1;  // 同样不是原子操作!
        vTaskDelay(10);
    }
}

看起来两个任务都在做 g_counter = g_counter + 1。你觉得最终结果会是多少?

我告诉你,结果是不确定的

为什么?因为 g_counter = g_counter + 1 在C语言里是一行代码,但在汇编层面,它至少拆成了三步:

  1. 从内存读取 g_counter 到寄存器
  2. 在寄存器里加1
  3. 把结果写回内存

如果任务1刚执行完第1步,还没来得及写回,就被任务2抢占了。任务2也去读 g_counter,读到的还是旧值。然后两个任务各自加1,写回去。结果就是:本该加2,实际只加了1

注意:我曾经在一个产品中因为这个问题,导致计数值偏差了上万次。排查了整整两天才发现是竞态条件。从那以后,凡是涉及全局变量的修改,我都会多问自己一句:“这里会不会被抢占?”

2.4 竞态条件的典型场景

除了上面这种“读-改-写”操作,还有几种常见场景:

场景 描述 后果
读-改-写 变量修改需要三步,中间被打断 数据丢失、计数错误
检查-执行 先检查条件,再执行操作 条件被其他任务改变,导致误操作
非原子访问 32位变量在8位MCU上分多次读写 读到半新半旧的数据
缓冲区操作 一个任务写,一个任务读 读到不完整或错误的数据

你想想看,这些场景是不是在你的项目里也出现过?

2.5 怎么判断是不是竞态条件?

我有个简单的判断方法:

  • 这个变量是不是被多个任务访问?
  • 至少有一个任务是写操作?
  • 访问过程是不是原子的?(即会不会被中断打断)

如果三个问题的答案都是“是”,那你大概率已经遇到了竞态条件。

小技巧:在调试阶段,可以故意把任务调度频率调高,或者用高优先级任务频繁抢占,这样更容易复现竞态条件。我曾经用这个方法,在实验室里就抓到了好几个隐藏的bug。

2.6 小结

临界区和资源竞争,是多任务编程的第一道坎。你绕不过去,也躲不开。但好消息是,FreeRTOS提供了多种机制来解决这个问题——互斥量、信号量、临界区保护函数等等。

下一章,咱们就正式开始讲互斥量。我会告诉你它和信号量到底有什么区别,以及怎么用才能不出错。

嗯,先消化一下今天的内容。有问题随时想,咱们下章见。