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语言里是一行代码,但在汇编层面,它至少拆成了三步:
- 从内存读取
g_counter到寄存器 - 在寄存器里加1
- 把结果写回内存
如果任务1刚执行完第1步,还没来得及写回,就被任务2抢占了。任务2也去读 g_counter,读到的还是旧值。然后两个任务各自加1,写回去。结果就是:本该加2,实际只加了1。
注意:我曾经在一个产品中因为这个问题,导致计数值偏差了上万次。排查了整整两天才发现是竞态条件。从那以后,凡是涉及全局变量的修改,我都会多问自己一句:“这里会不会被抢占?”
2.4 竞态条件的典型场景
除了上面这种“读-改-写”操作,还有几种常见场景:
| 场景 | 描述 | 后果 |
|---|---|---|
| 读-改-写 | 变量修改需要三步,中间被打断 | 数据丢失、计数错误 |
| 检查-执行 | 先检查条件,再执行操作 | 条件被其他任务改变,导致误操作 |
| 非原子访问 | 32位变量在8位MCU上分多次读写 | 读到半新半旧的数据 |
| 缓冲区操作 | 一个任务写,一个任务读 | 读到不完整或错误的数据 |
你想想看,这些场景是不是在你的项目里也出现过?
2.5 怎么判断是不是竞态条件?
我有个简单的判断方法:
- 这个变量是不是被多个任务访问?
- 至少有一个任务是写操作?
- 访问过程是不是原子的?(即会不会被中断打断)
如果三个问题的答案都是“是”,那你大概率已经遇到了竞态条件。
小技巧:在调试阶段,可以故意把任务调度频率调高,或者用高优先级任务频繁抢占,这样更容易复现竞态条件。我曾经用这个方法,在实验室里就抓到了好几个隐藏的bug。
2.6 小结
临界区和资源竞争,是多任务编程的第一道坎。你绕不过去,也躲不开。但好消息是,FreeRTOS提供了多种机制来解决这个问题——互斥量、信号量、临界区保护函数等等。
下一章,咱们就正式开始讲互斥量。我会告诉你它和信号量到底有什么区别,以及怎么用才能不出错。
嗯,先消化一下今天的内容。有问题随时想,咱们下章见。