管理入口

请输入管理密码。此密码门槛仅用于前端原型,不提供真正的仓库权限保护。

0%
RTOS-任务调度器

01|用非抢占式调度器组织小车任务

从 1 ms 时基、任务表、周期判定和优先级出发,讲清协作式调度器如何运行,以及怎样让采样、控制和通信任务互不阻塞。

小车程序通常同时承担传感器采样、状态估计、PID 控制、按键、显示和串口通信。若把所有逻辑依次塞进 while (1),任何一次阻塞都会让控制周期失去规律。

本节使用裸机协作式周期调度器拆分这些工作。它不是 FreeRTOS:没有独立任务栈、上下文切换和抢占。每个任务由主循环直接调用,因此任务必须快速完成并主动返回。

调度器如何让多个任务轮流工作

调度模型

主循环持续调用 Task_Start():

while (1) {
Task_Start(Sys_GetTick);
}

每个任务保存上次运行时刻和周期。满足下面的条件时,调度器直接调用函数指针:

if (now - task->LastWakeUp >= task->Interval) {
task->Function(task->Para);
}

无符号减法可以自然处理 32 位 tick 回绕。任务执行完后,LastWakeUp 按周期递增;如果已经落后超过一个周期,则直接追到当前时刻,避免短时间连续补跑很多次。

优先级的真实含义

Task_Add() 使用 qsort() 按优先级和创建顺序排列任务表。优先级数值越小,遍历位置越靠前。

这里的优先级只决定“多个任务同时到期时先调用谁”。低优先级任务开始运行后,高优先级任务不能中途打断它,因此它不是实时系统中的抢占优先级。

任务生命周期

调度器提供暂停、恢复和删除接口:

Task_Suspend("OLED");
Task_Resume("OLED");
Task_Delete("OLED");

删除只是把状态改为 INACTIVE,不会压缩任务表,也不会减少 Task_Num。达到 MAXTASKS 后,即使删除旧任务,也不能再添加新任务。

task_t 每个字段承担什么职责

任务表最多保存 MAXTASKS 个静态槽位。每个 task_t 包含名称、上次唤醒 tick、周期、优先级、状态、创建顺序、用户参数和函数指针。

CreationIndex 用于同优先级时保持注册顺序;Para 让同一个任务函数可以处理不同对象;State 在 ACTIVE / SUSPEND / INACTIVE 之间切换。

MaxUsed 的注释称为最大执行时间,但当前实现每次都覆盖它。它实际记录最近一次整毫秒耗时,而且分辨率只有 1 ms。对亚毫秒控制任务,这个统计可能长期为零。

周期推进与过期处理

正常执行后,调度器让 LastWakeUp += Interval,而不是直接赋值为当前 tick。这样能减少任务执行时间对相位的持续漂移。

若任务已经落后至少一个额外周期,代码把 LastWakeUp 追到 NowTick,避免立即连续补跑。代价是跳过中间错过的周期,因此它适合“只关心最新状态”的控制任务,不适合必须逐项处理的采样队列。

暂停任务不会更新 LastWakeUp。恢复后它通常已经到期,会在下一次扫描立即运行。若业务希望“恢复后重新等待一个完整周期”,恢复接口还需重置时间基准。

注册接口缺少的防护

Task_Add() 没有检查任务函数是否为空,也没有拒绝 time_ms == 0。空函数指针会在到期时崩溃;零周期任务会在每次扫描都运行,并让周期推进失去意义。

Task_Start() 也直接调用传入的 tick 函数。公共组件应在注册阶段拒绝无效函数和零周期,并为超出 MAXTASKS 返回明确错误,而不是静默忽略。

1 ms 时基与系统节拍

tick 怎样产生

SysTick_Increasment() 递增 uwTick,并让 delayTick 递减。Sys_GetTick() 把当前毫秒 tick 返回给调度器。

void SysTick_Increasment(void)
{
uwTick++;
if (delayTick) delayTick--;
}

SysTick_Increasment() 必须由稳定的 1 ms 定时中断调用,调度器才能获得正确节拍。接入 MSPM0G35XX 时,应在 SysConfig 和中断入口中确认调用链,并用 GPIO 翻转或逻辑分析仪测量真实周期。

阻塞延时与协作式调度冲突

Delay() 会在 while (delayTick) 中等待。若任务函数内部调用它,主循环就无法返回 Task_Start(),其他任务也无法获得执行机会。

协作式任务应该把“等待”改为状态和时间戳:当前调用只推进一步,下一次周期到达后再继续,而不是占住 CPU 等待条件成立。

推荐的任务形状

void EncoderTask(void *context)
{
EncoderSample *sample = context;
sample->count = Encoder_ReadAndClear();
}

任务应完成一次短操作后立即返回。编码器采样、PID、循迹可以高频运行;OLED 和串口输出应降低频率,并限制单次输出量。

放进小车工程的建议分层

可以把控制链拆成四类短任务:传感器采样只更新原始数据,状态估计生成带时间戳的快照,控制任务读取快照并计算输出,遥测任务低频发送调试数据。

任务之间通过结构体和有效标志交换数据,不在一个任务里等待另一个任务完成。控制任务还应检查数据年龄;传感器超时后进入受控停机,而不是继续使用旧值。

建议为每个任务记录最近耗时、历史最大耗时、超期次数和最近运行 tick。只有这些数据都可观察,才能判断新增 OLED、串口或滤波算法是否破坏控制周期。

把示例变成可靠组件前必须修正的问题

任务名使用指针比较

暂停、恢复、删除和查询函数使用:

if (Task_Schedule[i].Name == taskName)

这比较的是两个字符串指针是否指向同一地址,而不是文字是否相同。传入同一个字符串常量时可能看似正常,但动态字符串或不同编译单元不可靠。

正确方向是使用 strcmp(),或让注册接口返回稳定的任务句柄,后续管理不再依赖名字查找。

MaxUsed 没有保存最大值

每次任务结束后,代码直接执行:

Task_Schedule[i].MaxUsed = TickDiff;

因此字段记录的是“最近一次耗时”,而不是注释所说的“最大耗时”。应只在 TickDiff > MaxUsed 时更新,并另外保存最近值,避免一个字段承担两种语义。

空闲函数调用时机不准确

Task_IdleFunction() 位于任务遍历的 else 分支。只要当前检查的某个任务尚未到期,它就会被调用一次,即使本轮其他任务刚刚运行过。

真正的系统空闲应在完整遍历结束后,根据“本轮没有执行任何任务”统一判断。否则低功耗、后台统计或喂狗逻辑可能重复触发。

两个关闭的实验分支仍有逻辑问题

DYNAMIC_MODIFY 默认是 0。其最小周期变量从 0 开始,更新表达式无法把它变成首个任务周期,因此启用后动态调整依据会失真。

LOW_POWER 默认也是 0。状态枚举中 SCHEDULE_IDLE 为 0、SCHEDULE_BUSY 为 1,但低功耗条件直接测试 All_Scheduler_IDLE,反而会在忙状态为真。

这两个问题目前不会进入编译后的执行路径,但在打开宏之前必须修复,不能因为默认关闭就把它们视作已完成能力。

修复优先级

第一优先级是把名称查找改为任务句柄或 strcmp(),并校验空函数、零周期和满表。第二优先级是将空闲函数移到完整扫描之后,并让 MaxUsed 只在新值更大时更新。

第三优先级才是低功耗和动态调周期。进入休眠前要只统计活动任务的最近截止时间,并证明唤醒源、tick 补偿和回绕计算正确。动态修改任务周期则会改变控制器采样时间,不能作为一般的过载补救。

调度器适合什么场景

场景适合程度原因
小车周期采样与控制适合任务短、周期清晰、资源有限
大量阻塞式通信不适合一个任务会拖慢全部任务
需要硬实时抢占不适合没有上下文切换与抢占
教学和小型裸机项目适合执行路径直观、调试成本低

接入小车工程前的检查表

检查项当前实现接入要求
任务执行方式Task_Start() 按周期直接调用函数所有任务都必须短小、无阻塞并能主动返回
优先级只影响同一轮扫描中的先后顺序不得把它当成可抢占的实时优先级
时间基准提供 1 ms tick 增量函数确认中断调用并测量周期与抖动
动态周期与低功耗宏默认关闭且分支存在逻辑问题修复并单独验证后才能启用
执行时间当前统计不能得到可靠最大值记录最近耗时、历史最大耗时和超期次数

本篇总结

  • 这是一套协作式周期调度器,不是完整 RTOS。
  • 每个任务都必须短小、可返回,阻塞延时会阻塞整个系统。
  • 优先级只决定同时到期时的先后顺序,不能打断正在运行的任务。
  • 任务名比较、耗时统计、空闲调用和关闭分支需要修正后再作为公共组件使用。
  • 真正的可靠性来自周期抖动、最坏执行时间和超时路径的上板测量,而不是任务表看起来整齐。

输入标题、正文关键词或标签,查找文章。