在嵌入式开发里,很多人喜欢把复杂功能拆成好几个任务,每个任务管一件事。看起来井井有条,可一旦它们去读写同一个变量,麻烦就来了。有时候你抓破头也找不到是哪一行代码出的问题。这事有个专业的名字,叫竞态条件,也有个朴素的描述,叫“打架”。今天我们就聊一聊FreeRTOS里怎么用临界区这种办法来“劝架”,让多任务安安稳稳地过日子。

一、一个让人头疼的并发冲突

想象一下你在做饭,一边烧水,一边切菜。如果水开了,你放下刀去关火,回来再切,这没什么问题。可要是你右手拿刀,左手又去拿锅盖,那很可能手忙脚乱,甚至把锅打翻。CPU在跑多个任务时的感觉差不多。任务A正在修改一个变量,刚写了一部分,时间片到了,任务B跑过来读这个变量,读到的自然就是一半新一半旧的“半成品”。这个半成品会把整个系统的逻辑彻底搅乱。

举个例子:一个计数器,任务A负责加1,任务B负责显示。如果任务A加到一半被打断,比如它已经把旧的数值从内存里取出来,正准备写回新值,这时候任务B跑去读,读到了原来的值。然后任务A再写回新值,新值最终只加了一次,可任务B却以为已经加了好几次。数据就这样丢了。这种丢失不是每次都会发生,但一旦发生,就可能让设备出现奇怪的闪烁、错误报警或者数据错乱。

裸机开发里也有类似问题,比如中断打断主循环。但在多任务操作系统里,任务切换本身随时可能发生,所以竞态条件更常见,也更难排查。

二、什么是竞态条件

2.1 生活中的竞态

竞态条件,说白了就是两个或者多个动作去抢同一个东西,谁先谁后会影响结局。就像你和室友同时去冰箱拿牛奶,你拿走之后,后面的人就扑空了。如果一个动作是“看看牛奶还剩多少”,另一个是“往冰箱里放一瓶新牛奶”,两个动作交错进行,可能放的人放完,看的人看到的还是旧瓶子,最后统计出的数量就是错的。

这种“结果依赖于动作发生的精确顺序”的现象,就叫竞态。不是因为哪个操作本身有错,而是它们同时运行时,互相踩了对方的脚。

2.2 程序里的竞态

程序里的竞态,也是因为执行顺序不确定。当一个共享变量被多个任务读写,并且这些读写操作没有保护时,最终结果就依赖任务切换的精确顺序。这个顺序一乱,结果就跟着乱。下面这个简化示例能看出问题:

/* 技术栈:C语言 + FreeRTOS(示意图,不能直接运行) */
static int count = 0;

void task_count(void *args)
{
    count = count + 1;   /* 实际上这里有多条指令:读、加、写 */
}

不用纠结这段代码能不能编译成功,关键看“count = count + 1”这一行。在编译之后,它通常会被拆成至少三步:先把count的值从内存读到寄存器,然后在寄存器里加1,最后把新值写回内存。如果两个任务同时执行这三步,就可能出现:任务A读到了0,任务B也读到了0,然后任务A写回1,任务B也写回1。最后count只是1,而不是2。这就是“丢失更新”。

2.3 FreeRTOS里为什么更要小心

FreeRTOS是抢占式调度器。只要一个高优先级任务进入就绪状态,它就会立刻打断当前正在运行的低优先级任务。就算所有任务优先级相同,时间片轮转也会强制切换任务。所以,任何共享数据都要考虑“我这段代码会不会被打断”。这也就引出了今天的主角:临界区管理。

三、FreeRTOS的三件法宝

要管住竞态条件,内核提供了三种常用的办法,你可以把它们想象成三道不同尺寸的门。

3.1 第一件:关中断

最直接的办法是把所有“开关”全关上。在FreeRTOS里,任务切换是靠系统节拍中断来驱动的。一旦关闭中断,当前任务就不会被切走,整个系统就像被按了暂停键,只会专心执行你正在运行的那一小段代码。FreeRTOS提供了taskENTER_CRITICAL和taskEXIT_CRITICAL这一对宏,使用的时候必须成对出现。

这套方法的优点是真快,效果也最彻底,它不光挡住其他任务,连硬件中断都会挡在外面。但缺点同样明显:关中断的时间一长,真实的中断事件就可能被错过。比如外部传感器数据来了,中断被关着,等开中断再去读,数据已经没了。所以临界区里的代码必须又短又小,最好只做最简单的变量读写。

3.2 第二件:挂起调度器

既然问题主要出在任务切换上,那我们可以只让任务切换暂停,而不去动中断。FreeRTOS的vTaskSuspendAll会把调度器“挂起来”,在这段时间里,其他任务不会运行,但中断仍然可以响应。等处理完共享数据,再用xTaskResumeAll把调度器恢复回来。

这个方法比关中断温柔一些。它适合任务与任务之间共享数据,而且不希望在关中断期间丢失硬件事件的情况。但要注意,挂起调度器期间,系统的时间片也不走,如果挂得太久,某些依赖调度的功能就会变得迟钝。

3.3 第三件:互斥量

互斥量就像一个“带钥匙的洗手间”。只有拿到钥匙的人才能进去用,其他人只能在门口排队。FreeRTOS的互斥量专门用来保护那些会占用比较长时间的共享资源,比如缓冲区、显示器、串口。它的好处是,等锁的时候任务能睡过去,把CPU让给其他有用的任务,而不是死死占着CPU空转。

更贴心的是,FreeRTOS的互斥量还带“优先级继承”的功能。什么是优先级继承呢?假设一个低优先级任务拿到了锁,一个高优先级任务跑过来等锁,另一个中等优先级任务却一直在运行,高优先级任务就会等很久。有了优先级继承,低优先级任务在拿锁期间会临时“升级”成高优先级,赶紧把锁释放掉,免得高优先级任务被“饿死”。这就是它和普通信号量最大的不同。

有了这三件法宝,下面我们来看看具体怎么用。

四、实战示例

4.1 示例一:用临界区保护简单变量

技术栈:C语言 + FreeRTOS。我们要实现一个计数器,任务A每秒把计数器加一,任务B随时读取并打印。如果不做保护,任务B很可能读到写到一半的值。

/* 技术栈:C语言 + FreeRTOS */
#include "FreeRTOS.h"
#include "task.h"
#include <stdio.h>

/* 共享计数器 */
static uint32_t shared_counter = 0;

/* 任务1:把计数器加一 */
static void vCounterTask(void *pvParameters)
{
    for (;;)
    {
        /* 进入临界区,关闭中断,防止任务切换 */
        taskENTER_CRITICAL();
        shared_counter++;          /* 这行代码执行时,其他任务和中断都被挡在外面 */
        taskEXIT_CRITICAL();       /* 离开临界区,重新打开中断 */

        vTaskDelay(pdMS_TO_TICKS(1000));  /* 等待1秒 */
    }
}

/* 任务2:读取并打印计数器 */
static void vReaderTask(void *pvParameters)
{
    uint32_t tmp;
    for (;;)
    {
        /* 读取时也要进入临界区,否则可能读到一半新一半旧 */
        taskENTER_CRITICAL();
        tmp = shared_counter;
        taskEXIT_CRITICAL();

        printf("当前计数:%lu\n", (unsigned long)tmp);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

int main(void)
{
    xTaskCreate(vCounterTask, "Counter", 128, NULL, 1, NULL);
    xTaskCreate(vReaderTask, "Reader", 128, NULL, 1, NULL);
    vTaskStartScheduler();        /* 启动调度器,之后一切由FreeRTOS接管 */
    for (;;);                     /* 正常情况下不会运行到这里 */
}

这段代码里,加一和读值都放在了临界区中。因为临界区会把中断关掉,所以里面绝对不能出现耗时的函数调用,否则系统节拍会乱,其他任务也会被耽误。

4.2 示例二:用互斥量保护长操作

假设两个任务都要往串口打印一整组数据,一次打印好几行。如果把打印过程放进“关中断”的临界区,那中断可能会被关闭很长时间,导致外部事件丢失。更合理的办法是用互斥量。

/* 技术栈:C语言 + FreeRTOS */
#include "FreeRTOS.h"
#include "task.h"
#include "semphr.h"
#include <stdio.h>

/* 互斥量句柄 */
static SemaphoreHandle_t xPrintMutex;

/* 任务1:打印环境参数 */
static void vPrintTask1(void *pvParameters)
{
    for (;;)
    {
        /* 拿锁,如果别人拿着,就在这里等 */
        if (xSemaphoreTake(xPrintMutex, portMAX_DELAY) == pdPASS)
        {
            printf("任务1 - 温度:%.1f\n", 25.3);
            printf("任务1 - 湿度:%.1f\n", 60.1);
            printf("任务1 - 完毕\n");
            /* 用完必须放锁,否则其他任务会一直等下去 */
            xSemaphoreGive(xPrintMutex);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

/* 任务2:打印电源参数 */
static void vPrintTask2(void *pvParameters)
{
    for (;;)
    {
        if (xSemaphoreTake(xPrintMutex, portMAX_DELAY) == pdPASS)
        {
            printf("任务2 - 电压:%.1f\n", 12.1);
            printf("任务2 - 电流:%.1f\n", 0.8);
            printf("任务2 - 完毕\n");
            xSemaphoreGive(xPrintMutex);
        }
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

int main(void)
{
    /* 创建一个互斥量 */
    xPrintMutex = xSemaphoreCreateMutex();
    if (xPrintMutex != NULL)
    {
        xTaskCreate(vPrintTask1, "Print1", 128, NULL, 1, NULL);
        xTaskCreate(vPrintTask2, "Print2", 128, NULL, 1, NULL);
        vTaskStartScheduler();
    }
    for (;;);
}

在这个例子中,两个任务同时去取锁的话,只有其中一个能成功。另一个任务会进入阻塞状态,让出CPU。第一个任务打印完,放下锁,第二个任务才进来。这样串口上就不会出现两段文字混在一起的情况。互斥量特别适合保护这种“耗时较长”的共享资源,因为等待锁的时候任务可以休息,而不是傻乎乎地占着CPU。

4.3 示例三:用挂起调度器保护数据块

有时候,任务要同时读取温度、湿度、压力好几个变量,并且希望读到的是一组完整的快照,而不是读到一半被切走。如果这些变量只被任务访问,不被中断访问,那么挂起调度器是个很轻量级的选择。

/* 技术栈:C语言 + FreeRTOS */
#include "FreeRTOS.h"
#include "task.h"
#include <string.h>
#include <stdio.h>

/* 共享的数据块 */
static char g_data_buffer[256] = {0};

/* 写任务:更新数据 */
static void vDataWriteTask(void *pvParameters)
{
    for (;;)
    {
        vTaskSuspendAll();   /* 挂起调度器,任务切换暂时停止 */
        /* 从这里开始,整个操作不会被其他任务打断 */
        strcpy(g_data_buffer, "Hello FreeRTOS");
        /* 这里还可以继续准备别的数据,都在同一个“快照”内 */
        xTaskResumeAll();    /* 恢复调度器 */

        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

/* 读任务:读取数据并显示 */
static void vDataReadTask(void *pvParameters)
{
    char local_buf[256];
    for (;;)
    {
        vTaskSuspendAll();
        strcpy(local_buf, g_data_buffer);  /* 先复制到自己的局部缓冲区 */
        xTaskResumeAll();

        printf("数据块内容:%s\n", local_buf);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

int main(void)
{
    xTaskCreate(vDataWriteTask, "Write", 256, NULL, 1, NULL);
    xTaskCreate(vDataReadTask, "Read", 256, NULL, 1, NULL);
    vTaskStartScheduler();
    for (;;);
}

注意,挂起调度器期间,中断并没有被屏蔽。所以如果有中断服务函数想访问同一份数据,这种保护是挡不住的。因为中断不经过调度器,它想什么时候跑就什么时候跑。对于中断和任务共享数据的情况,通常还是需要关中断,或者用FreeRTOS专门为中断设计的通信机制。

4.4 任务与中断之间的共享变量

任务和中断之间共享数据,在嵌入式中非常常见。比如定时器中断会每秒钟更新一个实时时钟计数,任务需要读取这个计数。这种情况下,任务读取时必须关中断,防止读到撕裂的数据。而中断写入时,因为中断正在执行,任务本来就被打断了,所以不需要额外保护。反过来,如果任务也在写,中断也在写,那就必须两边都做保护,否则照样会冲突。这里有个小经验:凡是在中断里要修改的数据,在任务里访问时,一定要用最短的临界区去保护。

五、应用场景与优缺点

5.1 适合用临界区的场景

临界区最擅长保护那些“操作很快”的共享数据。比如一个全局状态标志、一个计数器、一个自定义的协议状态字段。这些操作只有几条指令,关一下中断,做完马上开,几微秒就结束,对实时性影响微乎其微。相反,如果你把一个几毫秒的操作塞进临界区,那就等于让整个系统“失聪”几毫秒,任何中断都听不见。

挂起调度器适合保护那些需要多个变量一起更新的任务间共享块。比如一个运动控制系统,需要同时更新目标位置、速度、加速度。这些数据被多个任务使用,又不希望关中断影响传感器采样,那么可以把“更新任务”和“读取任务”都放在调度器挂起段中。

互斥量适合保护带有操作系统调用的长操作。比如把数据写入SD卡、通过DMA发送一串数据、更新一整块显示缓冲区。这种场景下等待任务可以睡过去,系统还能继续响应其他事件,真正做到了“等而不乱”。

5.2 三种方法的优缺点

为了更清楚,我把它们的优缺点整理了一下:

方法 优点 缺点
关中断 保护最彻底,不怕任何打断 关久了会丢中断,影响实时性
挂起调度器 不关中断,中断能及时响应 高优先级任务会被推迟,挂久了调度会迟钝
互斥量 等待可阻塞,CPU利用率高,有优先级继承 不能用在中断中,使用不当可能死锁

5.3 怎么选才不慌

选哪一种,其实主要看三个问题。第一,这段代码要跑多久?如果就几条指令,果断关中断。第二,中断会不会也访问同一个数据?如果会,那必须考虑用关中断,不然挂起调度器没作用。第三,等这段代码的人能不能接受“睡觉”?如果能接受,就用互斥量;如果连睡都不允许,那就只能靠临界区了。

比如一个简单的按键计数器,任务和中断都不访问它,只有一个任务改、另一个任务读,那用关中断就够了。如果你要把一整页文字显示到屏幕上,显示期间其他任务完全可以等着,那就用互斥量。如果你正在采集一组传感器数据,这些数据需要连续写入一个全局结构体,而且有个任务必须立刻看到整个结构体,那么挂起调度器可能更顺手。

六、注意事项

  1. 临界区代码一定要“精而短”。别在taskENTER_CRITICAL和taskEXIT_CRITICAL之间放延时、打印、或者复杂的运算。否则关了中断后,系统节拍会延迟,整个调度都会跟着乱。

  2. 使用互斥量时,拿锁和放锁必须严格对应。尤其是在函数里有多个返回的情况下,一定要在每一个退出点都释放锁。否则一个锁忘记还,其他任务可能永远等下去,这叫死锁。

  3. 互斥量绝对不能用在中断服务函数里。中断中想要发消息或唤醒任务,应该使用带FromISR后缀的函数,比如xSemaphoreGiveFromISR。

  4. 挂起调度器之后,不能调用任何会阻塞的FreeRTOS API,例如vTaskDelay、xSemaphoreTake带超时等待之类的函数。因为调度器本身都挂了,谁来处理这些阻塞呢?这就像把交警叫走了,路上却还让人停车问路,肯定会堵死。

  5. 临界区是可以嵌套的,但必须成对出现。FreeRTOS内部有嵌套计数,只要taskENTER_CRITICAL和taskEXIT_CRITICAL数量匹配,就能正确恢复。但是嵌套多了会影响代码可读性,能避免尽量别嵌套。

  6. “优先级继承”并不是万能的,它只能缓解优先级反转,不能完全消除。如果你的系统对实时性要求极高,最好还是尽量减少拿锁的时间,或者使用优先级绑定的方式去避免反转。

  7. 本文讨论的是单核情况。在多核处理器上,任务真的同时运行,临界区的实现和语义会更加复杂,需要额外的内存屏障和锁机制,不是简单关中断就能解决的。

七、总结

多任务带来的并发冲突,本质上是因为共享数据没有受到统一管理。FreeRTOS给了我们三件武器:关中断、挂起调度器、互斥量。用它们的时候,一定要根据代码块的执行时长、是否涉及中断来决定。顺手选一个也许能解决当下问题,但真正要维护一个稳定系统,还是得把临界区的边界想清楚。

记住一个口诀:临界区越小越好,锁拿了一定要还,中断里面用FromISR。把这几条记牢,FreeRTOS里的多任务协作就会顺畅很多。竞态条件的排查往往比编写更难,它可能好几天不出现,一出现就让你头疼。所以最好的办法是在设计架构的时候,就把哪些数据是共享的、哪些代码能进临界区想清楚。把丑话说在前面,后面调程序才不会抓狂。