嵌入式

FreeRTOS 任务调度实战:信号量、队列与互斥锁

裸机的超级循环一旦加入多个功能模块,就会变成一团互相等待的意大利面。本文讲清 FreeRTOS 的优先级抢占式调度原理,用队列与信号量完成任务间通信,并避开优先级翻转、死锁这些经典陷阱。

裸机程序写到最后,主循环里往往塞满了按键扫描、串口解析、LED 闪烁、电机控制。这些模块对时间的要求完全不同, 互相阻塞在所难免。RTOS 的价值就在于:把”什么时候该执行谁”这件事交给调度器,而不是靠程序员在主循环里手工排布。

RTOS 不是让你写得更快,而是让你把每个功能写成独立任务,各自用阻塞的方式等待自己的事件。

1. 任务与调度器

在 FreeRTOS 里,任务就是一个永不返回的函数,通常写成 for (;;) 死循环。 每个任务有独立的栈空间和优先级,调度器在它们之间切换。

void vSensorTask(void *pvParameters)
{
    for (;;) {
        float value = read_sensor();
        // 阻塞式发送:队列满时任务自动挂起,不占用 CPU
        xQueueSend(g_sensorQueue, &value, portMAX_DELAY);

        // 阻塞 100ms,让出 CPU 给其他任务
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

int main(void)
{
    xTaskCreate(vSensorTask, "sensor", 256, NULL, 3, NULL);
    xTaskCreate(vControlTask, "ctrl", 256, NULL, 2, NULL);
    vTaskStartScheduler();       // 一般不会返回
    for (;;);
}

1.1 优先级与抢占

FreeRTOS 默认使用抢占式调度:只要出现一个比当前任务优先级更高的就绪任务,就立刻发生上下文切换。 同优先级任务则按时间片轮转(需要开启 configUSE_TIME_SLICING)。

2. 队列:任务间通信

任务之间最不该做的就是共享全局变量——因为没有任何机制保证读写是原子的。 队列是 FreeRTOS 推荐的通信方式:它自带拷贝、自带阻塞,天然线程安全。

QueueHandle_t g_sensorQueue;

// 创建:最多缓存 8 个 float
g_sensorQueue = xQueueCreate(8, sizeof(float));

// 接收方:portMAX_DELAY 表示一直阻塞直到有数据
void vControlTask(void *pv)
{
    float v;
    for (;;) {
        if (xQueueReceive(g_sensorQueue, &v, portMAX_DELAY) == pdTRUE) {
            update_control(v);
        }
    }
}

队列传递的是数据的副本而非指针,所以发送完就可以立即修改原缓冲区,不存在悬垂指针问题。 代价是大结构体会带来拷贝开销,此时可以改为传指针,但必须保证所指内存生命周期覆盖整个消费过程。

3. 信号量与互斥锁

两者都基于队列实现,但用途与语义不同,混用会埋下隐患:

机制用途是否支持优先级继承典型 API
二值信号量任务与中断同步否xSemaphoreGive / Take
计数信号量管理多个相同资源否xSemaphoreCreateCounting
互斥锁保护共享资源是xSemaphoreCreateMutex
// 保护共享资源:必须用 Mutex,才有优先级继承
SemaphoreHandle_t g_spiMutex = xSemaphoreCreateMutex();

void write_flash(uint8_t *buf, size_t n)
{
    if (xSemaphoreTake(g_spiMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
        spi_transfer(buf, n);
        xSemaphoreGive(g_spiMutex);
    }
}

// 中断里同步任务:只能用 FromISR 版本
SemaphoreHandle_t g_rxSem = xSemaphoreCreateBinary();

void USART1_IRQHandler(void)
{
    BaseType_t hpw = pdFALSE;
    xSemaphoreGiveFromISR(g_rxSem, &hpw);
    portYIELD_FROM_ISR(hpw);    // 若唤醒了更高优先级任务则立即切换
}

4. 三个经典陷阱

还有一个隐藏陷阱:xSemaphoreTake 超时后仍去访问共享资源,或者忘记 xSemaphoreGive 就提前 return。 建议把加解锁写成固定配对的小函数,而不是散落在业务分支里。

5. 总结

用 RTOS 的正确姿势是:每个功能一个任务,任务之间只通过队列和信号量通信,绝不直接共享全局变量。 优先级按实时性分层,共享资源用互斥锁保护,中断里只做最轻的唤醒动作。

做到这几点,代码会从”一堆 if 判断的超级循环”变成”若干职责清晰的任务”, 调试时也更容易定位到底是谁在阻塞谁。