第三章 实时操作系统(RTOS)基础:FreeRTOS任务调度、中断管理、信号量与互斥锁

各位同学,咱们今天聊聊RTOS。说实话,我刚入行那会儿,对RTOS也是又爱又恨。爱的是它能让复杂的多任务变得井井有条,恨的是稍不留神就踩坑。尤其是分拣机这种高速设备,一个任务调度延迟几毫秒,可能就导致包裹分错位置。嗯,咱们今天就把FreeRTOS这几个核心机制掰开揉碎了讲。

3.1 任务调度:谁先跑,谁后跑?

任务调度说白了就是操作系统决定「现在该执行哪个任务」。FreeRTOS用的是抢占式调度,优先级高的任务先跑。但这里有个坑——优先级不是越高越好。

我曾经踩过的坑: 有一次做分拣机控制,我给所有任务都设了高优先级,结果系统频繁切换任务,CPU大部分时间花在上下文切换上,实际干活的时间反而少了。后来我学乖了,只给真正紧急的任务(比如编码器中断处理)设高优先级,其他任务用中等或低优先级。

FreeRTOS的任务状态有四种:运行态、就绪态、阻塞态、挂起态。我习惯用一张表来理解它们:

状态 说明 典型场景
运行态 正在占用CPU 当前执行的任务
就绪态 可以运行,但CPU被别的任务占着 等待高优先级任务释放CPU
阻塞态 等待某个事件(延时、信号量、队列等) vTaskDelay() 等待10ms
挂起态 被vTaskSuspend()暂停,不参与调度 调试时暂停某个任务

任务调度的核心函数是 vTaskStartScheduler()。启动调度器后,FreeRTOS会根据优先级和状态决定谁跑。我个人习惯在创建任务时,把关键任务(比如处理传感器数据的任务)优先级设高,但不要超过 configMAX_PRIORITIES-1,留点余量给中断。

避坑指南: 任务栈大小要留够。我见过有人把栈设成128字节,结果任务一运行就栈溢出,系统莫名其妙重启。建议至少256字节起步,复杂任务512字节以上。

3.2 中断管理:别让中断抢了任务的饭碗

中断是RTOS里最「霸道」的机制。它优先级最高,来了就打断当前任务。但中断服务程序(ISR)里不能调用所有FreeRTOS API,这是个容易忽略的点。

FreeRTOS提供了一组带 FromISR 后缀的API,专门给中断用。比如:

  • xQueueSendFromISR() — 在中断中发送队列数据
  • xSemaphoreGiveFromISR() — 在中断中释放信号量
  • vTaskNotifyGiveFromISR() — 在中断中通知任务

为什么不能用普通API?因为普通API可能会引起任务切换,而中断里做任务切换需要特殊处理。FreeRTOS通过 portYIELD_FROM_ISR() 来安全地触发上下文切换。

我的经验: 中断里只做最紧急的事——比如读取硬件寄存器、清除中断标志、然后通过信号量或队列通知任务去处理。千万别在中断里做复杂计算或延时。我曾经见过有人在ISR里调用printf,结果系统直接卡死。

举个例子,分拣机上的光电传感器触发中断:

// 中断服务程序
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    
    // 读取传感器状态
    uint32_t sensor_data = read_sensor();
    
    // 通过队列发送给处理任务
    xQueueSendFromISR(xSensorQueue, &sensor_data, &xHigherPriorityTaskWoken);
    
    // 如果有更高优先级的任务被唤醒,立即切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

注意那个 xHigherPriorityTaskWoken 变量。如果队列接收端任务的优先级比当前被中断的任务高,这个变量会被设为pdTRUE,然后 portYIELD_FROM_ISR 会触发一次任务切换。嗯,这个机制很巧妙,保证了实时性。

3.3 信号量与互斥锁:任务间的「交通信号灯」

信号量和互斥锁都是用来同步任务或保护共享资源的。但它们的用法完全不同,我刚开始也搞混过。

3.3.1 二值信号量:事件通知的好帮手

二值信号量就像一个旗子,只有0和1两种状态。它常用于「通知任务某个事件发生了」。比如中断来了,释放信号量,任务收到信号量后开始处理数据。

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

// 任务A:等待信号量
void TaskA(void *pvParameters) {
    while(1) {
        // 等待信号量,超时时间portMAX_DELAY表示一直等
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 处理数据
            process_data();
        }
    }
}

// 中断中释放信号量
void ISR_Handler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
注意: 二值信号量在创建后初始值为0,第一次 xSemaphoreGive 后变为1。如果连续Give两次,第二次会被忽略,因为已经是1了。这就是「二值」的含义。

3.3.2 计数信号量:管理有限资源

计数信号量可以记录多个事件。比如分拣机有5个缓冲区,每放入一个包裹,信号量加1;每取走一个包裹,信号量减1。信号量的值就是可用缓冲区的数量。

// 创建计数信号量,初始值5,最大值5
SemaphoreHandle_t xSemaphore = xSemaphoreCreateCounting(5, 5);

// 放入包裹
void put_package() {
    // 如果信号量>0,表示有可用缓冲区
    if(xSemaphoreTake(xSemaphore, 100) == pdTRUE) {
        // 放入缓冲区
        buffer_add();
    } else {
        // 缓冲区满了,处理异常
        handle_overflow();
    }
}

// 取走包裹
void get_package() {
    // 取出包裹后释放信号量
    package_t pkg = buffer_remove();
    xSemaphoreGive(xSemaphore);
}

3.3.3 互斥锁:保护共享资源

互斥锁和二值信号量很像,但有个关键区别:互斥锁有「优先级继承」机制。什么意思呢?假设低优先级任务拿了锁,高优先级任务来抢锁,系统会临时提升低优先级任务的优先级,防止「优先级反转」。

优先级反转是什么? 我举个例子:任务A(高优先级)和任务C(低优先级)共享一个资源,任务B(中优先级)不共享。如果C拿了锁,A来抢锁被阻塞,此时B开始运行,B比C优先级高,所以B一直跑,C一直拿不到CPU,A也就一直拿不到锁。这就是优先级反转。互斥锁的优先级继承机制能解决这个问题——C拿锁期间,优先级被临时提升到和A一样高,这样B就不能抢占C了。

使用互斥锁很简单:

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

// 任务中访问共享资源
void task_use_shared_resource(void *pvParameters) {
    while(1) {
        // 拿锁
        if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
            // 安全访问共享资源
            shared_variable++;
            // 释放锁
            xSemaphoreGive(xMutex);
        }
    }
}
我的建议: 能用互斥锁就别用二值信号量保护共享资源。互斥锁的优先级继承机制能避免很多诡异的bug。但注意,中断中不能使用互斥锁,因为优先级继承在中断上下文中没有意义。

3.4 实际应用:分拣机上的任务设计

咱们把前面讲的串起来,看看分拣机上怎么用FreeRTOS。一个典型的分拣机控制程序,我一般会设计这么几个任务:

任务名称 优先级 功能 同步方式
传感器中断处理 最高(中断) 读取传感器数据,通知任务 二值信号量
分拣控制任务 根据传感器数据控制分拣臂 接收队列数据
通信任务 与上位机通信,上报状态 互斥锁保护共享数据
显示任务 更新LCD显示 无,空闲时运行

你看,中断处理优先级最高,但只做最少的操作。分拣控制任务优先级高,保证实时响应。通信任务中等,显示任务最低。这样设计,系统既实时又稳定。

最后说一句: FreeRTOS的配置很重要。configUSE_PREEMPTION、configUSE_TIME_SLICING、configTICK_RATE_HZ这些参数,要根据实际需求调整。我一般把tick设为1000Hz,这样时间精度是1ms,对分拣机来说够用了。如果tick太高,系统开销会变大,得不偿失。

好了,这一章就到这里。下一章咱们聊聊内存管理——别小看它,嵌入式系统里内存用不好,程序跑着跑着就崩了。