4. ARM Cortex-M内核详解
多任务系统里,任务之间怎么配合?怎么传数据?怎么抢资源?
这几乎是每个 RTOS 项目都会遇到的灵魂拷问。我早年做第一个多任务项目时,就吃过“任务间通信没设计好”的亏——两个任务同时操作一个全局变量,结果数据被踩得乱七八糟,查了两天才找到原因。
从那天起,我养成了一个习惯:能用内核对象解决的问题,绝不用全局变量。
核心观点:RTOS 的同步与通信机制,本质上是给任务之间搭了一座“有规矩的桥”。桥搭好了,数据不会丢,资源不会抢,优先级也不会乱。
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 有序、可缓冲多个 |
| 最新数据传递 | 消息邮箱 | 覆盖旧值、无阻塞 |
| 变长数据传递 | 流缓冲区 | 长度灵活、接口简单 |
| 多事件等待 | 事件标志组 | 可组合多个条件 |
最后说一句:别把简单问题复杂化。我见过有人用队列传一个标志位,其实一个二值信号量就够了。选对工具,代码清爽,调试也省心。