4. ARM Cortex-M内核详解

多任务系统里,任务之间怎么配合?怎么传数据?怎么抢资源?

这几乎是每个 RTOS 项目都会遇到的灵魂拷问。我早年做第一个多任务项目时,就吃过“任务间通信没设计好”的亏——两个任务同时操作一个全局变量,结果数据被踩得乱七八糟,查了两天才找到原因。

从那天起,我养成了一个习惯:能用内核对象解决的问题,绝不用全局变量。

核心观点:RTOS 的同步与通信机制,本质上是给任务之间搭了一座“有规矩的桥”。桥搭好了,数据不会丢,资源不会抢,优先级也不会乱。

RTOS 同步与通信机制全景图 任务间通信 同步机制 二值信号量 计数信号量 事件标志组 任务通知 数据通信 消息队列 消息邮箱 流缓冲区 管道 资源管理 互斥量 (Mutex) 优先级继承 💡 选择原则:同步用信号量/事件组,传数据用队列/邮箱, 保护共享资源用互斥量。别混用,别滥用。

4.1 队列(Queue)—— 任务间的数据管道

队列是 RTOS 里最常用的数据通信方式。它本质上是一个 FIFO(先进先出)的环形缓冲区,但加上了任务挂起和唤醒的能力。

我习惯把队列比作“邮筒”:任务 A 往里投信,任务 B 从里面取信。如果邮筒满了,投信的人就得等着;如果邮筒空了,取信的人也得等着。

实用技巧:队列的每个元素大小要固定。如果你要传变长数据,可以传指针——但要注意指针指向的内存生命周期,别传野指针。

看一个 FreeRTOS 的队列示例:

// 创建一个能存放 5 个 uint32_t 的队列
QueueHandle_t xQueue;
xQueue = xQueueCreate(5, sizeof(uint32_t));

// 任务 A:发送数据
void TaskA(void *pvParameters) {
    uint32_t data = 100;
    while(1) {
        // 如果队列满了,等待 100ms
        if(xQueueSend(xQueue, &data, pdMS_TO_TICKS(100)) == pdPASS) {
            // 发送成功
        }
        data++;
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

// 任务 B:接收数据
void TaskB(void *pvParameters) {
    uint32_t received;
    while(1) {
        // 如果队列为空,一直等
        if(xQueueReceive(xQueue, &received, portMAX_DELAY) == pdPASS) {
            // 处理 received
        }
    }
}

这里有个细节:发送和接收的超时时间。我个人习惯,发送时给一个有限超时(比如 100ms),防止死等;接收时用 portMAX_DELAY,因为接收不到数据任务本来就应该阻塞。

4.2 信号量(Semaphore)—— 轻量级的同步工具

信号量分两种:二值信号量和计数信号量。

类型 最大值 典型用途 我踩过的坑
二值信号量 0 或 1 任务同步、中断与任务同步 忘记初始化,导致第一次 give 无效
计数信号量 可配置(如 10) 资源计数、多实例管理 计数值溢出,资源释放不匹配

二值信号量最经典的应用场景是:中断服务程序(ISR)通知任务。

// 创建一个二值信号量
SemaphoreHandle_t xSemaphore;
xSemaphore = xSemaphoreCreateBinary();

// ISR 中给出信号量
void EXTI0_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中等待信号量
void Task_Process(void *pvParameters) {
    while(1) {
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdPASS) {
            // 处理中断触发的任务
        }
    }
}

注意:ISR 中必须使用带 FromISR 后缀的 API 函数。我曾经见过同事在 ISR 里直接调用 xSemaphoreGive(),结果系统直接 HardFault——因为中断上下文不能调用可能引起任务切换的普通 API。

4.3 互斥量(Mutex)与优先级反转

互斥量是信号量的“升级版”。它专门用来保护共享资源,而且自带一个关键特性:优先级继承。

为什么要优先级继承?因为优先级反转。

举个例子:

  • 任务 H(高优先级)和任务 L(低优先级)共享一个互斥量保护的资源
  • 任务 L 先拿到了互斥量,然后被任务 M(中优先级)抢占
  • 任务 H 想拿互斥量,但被任务 L 持有,只能等待
  • 任务 M 一直运行,任务 L 得不到 CPU,任务 H 也拿不到互斥量
  • 结果:高优先级任务被中优先级任务“反转”了

互斥量的优先级继承机制会临时把任务 L 的优先级提升到任务 H 的级别,让任务 L 尽快释放互斥量。嗯,这个设计很巧妙。

// 创建互斥量
SemaphoreHandle_t xMutex;
xMutex = xSemaphoreCreateMutex();

void Task_SharedResource(void *pvParameters) {
    while(1) {
        // 获取互斥量
        if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdPASS) {
            // 访问共享资源(如 UART、SPI、全局变量)
            UART_SendString("Hello\n");
            // 释放互斥量
            xSemaphoreGive(xMutex);
        }
    }
}

关键区别:互斥量只能用在这种场景——保护资源、且存在不同优先级的任务竞争。如果只是任务间同步,用二值信号量就够了。互斥量有优先级继承的开销,别滥用。

4.4 消息邮箱(Mailbox)—— 点对点的数据容器

消息邮箱和队列很像,但有一个关键区别:邮箱只能存一条消息。新消息会覆盖旧消息。

我一般在以下场景用邮箱:

  • 传感器的最新数据,旧数据丢了无所谓
  • 状态指示,比如“系统正常/系统异常”
  • 控制命令,只关心最新的指令

在 FreeRTOS 里,没有专门的邮箱对象,但可以用队列长度为 1 的队列来模拟:

// 邮箱 = 长度为 1 的队列
QueueHandle_t xMailbox;
xMailbox = xQueueCreate(1, sizeof(MyData_t));

// 覆盖式发送(不管邮箱里有没有旧数据)
void Mailbox_Overwrite(MyData_t *data) {
    // 先清空
    xQueueReset(xMailbox);
    // 再发送
    xQueueSend(xMailbox, data, 0);
}

// 读取最新数据
BaseType_t Mailbox_Read(MyData_t *data) {
    return xQueueReceive(xMailbox, data, 0);
}

个人经验:邮箱适合“发布-订阅”模式中的发布端。比如一个温度传感器任务,每 100ms 采集一次数据,通过邮箱发布最新温度。其他任务随时来读,读到的是最新值,不用排队。

4.5 流缓冲区(Stream Buffer)—— 变长数据的救星

队列要求每个元素大小固定。那如果我想传变长数据呢?比如一个不定长的 JSON 字符串、一段音频数据?

流缓冲区就是干这个的。它像一根水管:你可以往里面灌任意长度的数据,接收端按需读取。

// 创建流缓冲区,总大小 256 字节
StreamBufferHandle_t xStreamBuffer;
xStreamBuffer = xStreamBufferCreate(256, 0);  // 第二个参数是触发级别,0 表示不触发

// 发送端:写入变长数据
void SendData(uint8_t *data, size_t len) {
    size_t sent = xStreamBufferSend(xStreamBuffer, data, len, pdMS_TO_TICKS(100));
    if(sent != len) {
        // 缓冲区满了,发送不完整
    }
}

// 接收端:读取数据
void ReceiveData(void) {
    uint8_t buffer[64];
    size_t received = xStreamBufferReceive(xStreamBuffer, buffer, sizeof(buffer), portMAX_DELAY);
    if(received > 0) {
        // 处理 received 字节的数据
    }
}

注意:流缓冲区内部有拷贝开销。如果数据量很大(比如几 KB),建议用 DMA + 环形缓冲区,而不是流缓冲区。流缓冲区适合几百字节以内的变长消息。

4.6 如何选择?一张表说清楚

场景 推荐机制 原因
ISR 通知任务 二值信号量 轻量、快速、ISR 安全
保护共享资源 互斥量 优先级继承防反转
定长数据传递 队列 FIFO 有序、可缓冲多个
最新数据传递 消息邮箱 覆盖旧值、无阻塞
变长数据传递 流缓冲区 长度灵活、接口简单
多事件等待 事件标志组 可组合多个条件

最后说一句:别把简单问题复杂化。我见过有人用队列传一个标志位,其实一个二值信号量就够了。选对工具,代码清爽,调试也省心。