小车程序通常同时承担传感器采样、状态估计、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。
- 每个任务都必须短小、可返回,阻塞延时会阻塞整个系统。
- 优先级只决定同时到期时的先后顺序,不能打断正在运行的任务。
- 任务名比较、耗时统计、空闲调用和关闭分支需要修正后再作为公共组件使用。
- 真正的可靠性来自周期抖动、最坏执行时间和超时路径的上板测量,而不是任务表看起来整齐。