2. 实时操作系统(RTOS)基础:FreeRTOS任务管理、任务优先级、任务状态转换、调度策略

好,咱们直接进入正题。这一章聊的是RTOS的基石——任务管理。说白了,就是让单片机学会“多线程”思考。你想想看,一个医疗设备要同时处理按键扫描、屏幕刷新、数据采集、报警输出,如果还用裸机的大循环,代码很快就会变成一团乱麻。

我个人习惯,在开始任何医疗设备项目之前,先把任务骨架搭好。这就像盖房子先画结构图,后面才不会返工。FreeRTOS是我用得最多的内核,轻量、稳定,而且社区资源丰富。咱们今天就把它掰开揉碎了讲。

2.1 任务是什么?

任务,在RTOS里就是一个无限循环的函数。它有自己的栈空间、优先级和状态。每个任务都觉得自己独占了CPU,但实际上内核在飞快地切换它们。

void vTaskFunction(void *pvParameters)
{
    // 初始化代码,只执行一次
    for(;;)
    {
        // 任务主体代码
        // 等待事件、处理数据、输出结果
        vTaskDelay(pdMS_TO_TICKS(100)); // 让出CPU
    }
}

嗯,这里要注意:任务函数绝对不能返回。一旦返回,系统就会调用vTaskDelete(NULL)把自己干掉。我在项目中遇到过新手把for(;;)写成while(1)然后忘记加延时,结果低优先级任务永远得不到执行——这就是典型的“饿死”现象。

2.2 任务优先级:谁更重要?

优先级是RTOS调度的心脏。FreeRTOS支持从0到(configMAX_PRIORITIES-1)的优先级,数值越大,优先级越高。

优先级 典型用途 实时性要求
最高 (N-1) 中断响应、紧急停止 微秒级
高 传感器数据采集、控制算法 毫秒级
中 人机交互、通信协议 10-100毫秒
低 日志记录、看门狗喂狗 秒级
优先级反转——医疗设备的隐形杀手

我曾经在一个输液泵项目上踩过这个坑。一个低优先级任务占用了互斥锁,中优先级任务一直在运行,导致高优先级任务拿不到锁,整个系统响应延迟。病人输液速度失控了3秒钟才恢复。从那以后,我强制要求所有医疗项目必须使用优先级继承协议,并且严格控制优先级数量,不超过5级。

2.3 任务状态转换:任务的一生

一个任务在生命周期里有四种状态:运行态、就绪态、阻塞态、挂起态。理解这个转换,是调试RTOS问题的基本功。

FreeRTOS 任务状态转换图 运行态 (Running) 就绪态 (Ready) 阻塞态 (Blocked) 挂起态 (Suspended) 调度器选择 时间片用完/被抢占 等待事件/延时 事件到达 vTaskSuspend() vTaskResume() 关键理解: • 只有就绪态的任务才能被调度器选中进入运行态 • 阻塞态是任务主动让出CPU,挂起态是被动暂停 • 高优先级任务就绪时,会立即抢占低优先级运行态任务
调试小技巧:

我习惯在任务循环里加一个GPIO翻转,用示波器看波形。如果某个任务的波形消失了,说明它卡在阻塞态或者被挂起了。这比翻日志快得多。

2.4 调度策略:谁先跑?

FreeRTOS用的是基于优先级的抢占式调度,加上同优先级任务的时间片轮转。说白了就是:高优先级的任务随时可以打断低优先级的任务;同优先级的任务轮流跑,每人一个时间片。

// 创建两个同优先级任务
xTaskCreate(vTask1, "Task1", 256, NULL, 2, NULL);
xTaskCreate(vTask2, "Task2", 256, NULL, 2, NULL);

// 创建高优先级任务
xTaskCreate(vHighTask, "High", 256, NULL, 3, NULL);

你想想看,如果vHighTask一直不阻塞,那Task1和Task2永远没机会跑。这就是为什么我反复强调:高优先级任务必须设计成“短小精悍”——干活快,然后主动阻塞等待下一个事件。

医疗设备调度黄金法则:
  • 安全关键任务(如电机控制、报警输出)放在最高优先级
  • 数据采集任务放在次高优先级,使用定时器触发
  • 人机交互任务放在中等优先级,允许被数据采集打断
  • 日志、统计等非实时任务放在最低优先级
  • 所有任务必须包含阻塞点(延时、等待队列、等待信号量)

2.5 任务创建与删除:生与死

创建任务用xTaskCreate(),删除任务用vTaskDelete()。但要注意,删除任务时要确保它持有的资源已经释放,否则会造成内存泄漏。

TaskHandle_t xTaskHandle = NULL;

void vStartMonitorTask(void)
{
    xTaskCreate(vMonitorTask, "Monitor", 512, NULL, 3, &xTaskHandle);
}

void vStopMonitorTask(void)
{
    if(xTaskHandle != NULL)
    {
        vTaskDelete(xTaskHandle);
        xTaskHandle = NULL; // 防止野指针
    }
}
我曾经犯过的错:

在一个心电监护仪项目中,我动态创建了一个数据压缩任务,用完后直接删除了。结果忘了这个任务里申请了一个DMA缓冲区。系统跑了72小时后,内存碎片导致新任务创建失败,设备死机。从那以后,我改用静态任务创建,并且所有任务在系统初始化阶段就全部创建好,运行期间只挂起/恢复,不删除。

2.6 任务堆栈:别让栈溢出了

每个任务都有自己的栈空间。栈大小设置是个技术活——设大了浪费RAM,设小了会溢出。医疗设备通常RAM有限,所以得精打细算。

任务类型 推荐栈大小 (words) 说明
简单IO任务 128-256 只有几个局部变量和函数调用
通信协议任务 256-512 需要处理缓冲区、状态机
控制算法任务 512-1024 可能有浮点运算、矩阵计算
GUI任务 1024-2048 字体渲染、窗口管理很吃栈
栈溢出检测方法:

FreeRTOS提供了uxTaskGetStackHighWaterMark()函数,可以查看任务栈的剩余空间。我习惯在系统稳定运行24小时后,读取所有任务的水位标记,然后加上20%的余量作为最终栈大小。

2.7 实战经验:任务设计七步法

最后,分享一个我用了多年的任务设计流程。每次开始新项目,我都会走一遍这个流程,确保不遗漏关键点。

  1. 列出所有功能模块——按键、显示、采集、通信、报警、存储
  2. 确定实时性要求——哪些必须在1ms内响应?哪些可以容忍100ms?
  3. 分配优先级——按实时性从高到低排序,最高优先级不超过5个
  4. 设计任务间通信——用队列传数据,用信号量做同步,用互斥锁保护共享资源
  5. 估算栈大小——先给一个保守值,跑起来后用水位标记优化
  6. 编写任务函数——每个任务一个文件,函数名清晰表明用途
  7. 压力测试——模拟最坏情况,检查是否有任务饿死或栈溢出