这是一种主循环中的协作式调度器:时基提供毫秒数,主循环找出到期回调并逐个执行。回调之间没有独立栈,也不能被另一个回调抢占。优先级只决定下一个先执行谁;一个 20 ms 的阻塞回调仍会让所有其他回调等待至少 20 ms。它适合少量短小、可返回的周期工作,不具备 FreeRTOS 的任务调度、阻塞队列或互斥继承语义。
原记录的静态表、任务名、周期、首次延迟、优先级、暂停恢复、手动触发和统计需求在此保留。实现重新限定为单主循环所有权,解决“排序后任务 ID 变了”“中断与主循环同时改表”“回调结束覆盖刚到期事件”等问题。
先约定调度语义
跳转到“先约定调度语义”| 项目 | 本页实现的约定 |
|---|---|
| 容量与名字 | 最多 32 个活动项;名字占 16 字节,允许 1~15 个非 NUL 字节,拒绝重复名与截断 |
| 任务 ID | 非零递增 ID,删除后不复用;重新初始化使全部旧 ID 失效;ID 空间耗尽后拒绝创建 |
| 周期与首次延迟 | 周期 1~65535 ms;首次延迟 0 表示首次等待一个周期,否则等待给定的 1~65535 ms |
| 优先级 | 数值越大越先执行;同优先级、同时就绪按创建 ID 从小到大执行 |
| 到期积压 | 合并为一次执行,保留原周期相位;统计多合并掉的周期数,不连续补跑全部历史回调 |
| 手动触发 | 把一个待执行标志置位,多次触发合并;不改变周期相位;与周期同时就绪仍只执行一次 |
| 暂停与恢复 | 暂停丢弃手动触发;恢复从 now + period 重新计时,不补跑暂停期间的周期 |
| 改周期 | 新周期生效并从当前时间重新计时;清除先前手动触发 |
| 停止与启动 | 停止只停派发;时基和原定截止时间继续走,重启时按积压合并规则处理 |
| API 所有权 | 所有表操作只在主循环调用;ISR 不调用这些 API;回调期间拒绝写 API 和递归派发 |
毫秒截止时间使用无符号模运算。每次创建、恢复、改周期给出的时间必须来自同一连续时基;任意有效截止时间不能落后当前时间达到 ms(约 24.9 天)。长期停止后应重新初始化或逐项重新设周期。定时器停摆、深睡眠时基暂停和调试暂停都需要应用层规定如何恢复。
一份可提取编译的核心
跳转到“一份可提取编译的核心”下面整个代码块可保存为 timer_scheduler.c。这里用一个静态实例避免隐藏的堆分配;在工程中拆出头文件时,把类型和公开函数声明放进头文件,静态变量与实现留在 C 文件。
#include <stdbool.h>#include <stdint.h>#include <stddef.h>#include <string.h>
#define TS_CAPACITY 32u#define TS_NAME_BYTES 16u
typedef uint32_t TsId;typedef void (*TsCallback)(void *context);typedef uint32_t (*TsReadCycles)(void);typedef enum { TS_OK, TS_INVALID, TS_NOT_FOUND, TS_FULL, TS_DUPLICATE, TS_BUSY, TS_SUSPENDED, TS_ID_EXHAUSTED} TsResult;typedef enum { TS_WAITING, TS_READY, TS_PAUSED, TS_RUNNING } TsState;typedef struct { uint64_t runs, missed_periods, total_cycles; uint32_t last_cycles, max_cycles;} TsStats;typedef struct { TsId id; char name[TS_NAME_BYTES]; TsCallback callback; void *context; uint32_t deadline_ms; uint16_t period_ms; uint8_t priority; bool suspended, pending; TsStats stats;} TsTask;typedef struct { TsId id; uint16_t period_ms; uint8_t priority; TsState state; uint32_t deadline_ms; TsStats stats;} TsInfo;
static TsTask ts_tasks[TS_CAPACITY];static TsId ts_next_id = 1u;static TsId ts_running_id;static bool ts_started, ts_busy;static TsReadCycles ts_read_cycles;static uint32_t ts_cycle_hz;
static bool ts_due(uint32_t now, uint32_t deadline){ return (uint32_t)(now - deadline) < UINT32_C(0x80000000);}
static TsTask *ts_find(TsId id){ if (id == 0u) return NULL; for (unsigned i = 0; i < TS_CAPACITY; ++i) if (ts_tasks[i].id == id) return &ts_tasks[i]; return NULL;}
TsResult Ts_Init(TsReadCycles read_cycles, uint32_t cycle_hz){ if (ts_busy) return TS_BUSY; if (read_cycles != NULL && cycle_hz == 0u) return TS_INVALID; memset(ts_tasks, 0, sizeof ts_tasks); ts_next_id = 1u; ts_running_id = 0u; ts_started = false; ts_read_cycles = read_cycles; ts_cycle_hz = read_cycles != NULL ? cycle_hz : 0u; return TS_OK;}
TsResult Ts_Create(const char *name, TsCallback callback, void *context, uint16_t period_ms, uint16_t initial_delay_ms, uint8_t priority, uint32_t now_ms, TsId *out_id){ if (ts_busy) return TS_BUSY; if (name == NULL || callback == NULL || out_id == NULL || period_ms == 0u) return TS_INVALID; size_t length = 0u; while (length < TS_NAME_BYTES && name[length] != '\0') ++length; if (length == 0u || length == TS_NAME_BYTES) return TS_INVALID; TsTask *slot = NULL; for (unsigned i = 0; i < TS_CAPACITY; ++i) { if (ts_tasks[i].id == 0u) { if (slot == NULL) slot = &ts_tasks[i]; } else if (strcmp(ts_tasks[i].name, name) == 0) return TS_DUPLICATE; } if (slot == NULL) return TS_FULL; if (ts_next_id == 0u) return TS_ID_EXHAUSTED; memset(slot, 0, sizeof *slot); slot->id = ts_next_id++; memcpy(slot->name, name, length + 1u); slot->callback = callback; slot->context = context; slot->period_ms = period_ms; slot->priority = priority; slot->deadline_ms = now_ms + (initial_delay_ms != 0u ? initial_delay_ms : period_ms); *out_id = slot->id; return TS_OK;}
TsResult Ts_Delete(TsId id){ if (ts_busy) return TS_BUSY; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; memset(task, 0, sizeof *task); return TS_OK;}
TsResult Ts_Suspend(TsId id){ if (ts_busy) return TS_BUSY; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; task->suspended = true; task->pending = false; return TS_OK;}
TsResult Ts_Resume(TsId id, uint32_t now_ms){ if (ts_busy) return TS_BUSY; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; task->suspended = false; task->pending = false; task->deadline_ms = now_ms + task->period_ms; return TS_OK;}
TsResult Ts_Trigger(TsId id){ if (ts_busy) return TS_BUSY; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; if (task->suspended) return TS_SUSPENDED; task->pending = true; return TS_OK;}
TsResult Ts_SetPeriod(TsId id, uint16_t period_ms, uint32_t now_ms){ if (ts_busy) return TS_BUSY; if (period_ms == 0u) return TS_INVALID; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; task->period_ms = period_ms; task->pending = false; task->deadline_ms = now_ms + period_ms; return TS_OK;}
TsResult Ts_SetPriority(TsId id, uint8_t priority){ if (ts_busy) return TS_BUSY; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; task->priority = priority; return TS_OK;}
TsResult Ts_GetIdByName(const char *name, TsId *out_id){ if (name == NULL || out_id == NULL) return TS_INVALID; for (unsigned i = 0; i < TS_CAPACITY; ++i) if (ts_tasks[i].id != 0u && strcmp(ts_tasks[i].name, name) == 0) { *out_id = ts_tasks[i].id; return TS_OK; } return TS_NOT_FOUND;}
TsResult Ts_GetInfo(TsId id, uint32_t now_ms, TsInfo *out){ if (out == NULL) return TS_INVALID; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; out->id = task->id; out->period_ms = task->period_ms; out->priority = task->priority; out->deadline_ms = task->deadline_ms; out->stats = task->stats; out->state = id == ts_running_id ? TS_RUNNING : task->suspended ? TS_PAUSED : (task->pending || ts_due(now_ms, task->deadline_ms)) ? TS_READY : TS_WAITING; return TS_OK;}
TsResult Ts_ClearStats(TsId id){ if (ts_busy) return TS_BUSY; TsTask *task = ts_find(id); if (task == NULL) return TS_NOT_FOUND; memset(&task->stats, 0, sizeof task->stats); return TS_OK;}
TsResult Ts_Start(void){ if (ts_busy) return TS_BUSY; ts_started = true; return TS_OK;}
TsResult Ts_Stop(void){ if (ts_busy) return TS_BUSY; ts_started = false; return TS_OK;}
/* 返回本次执行的任务 ID;0 表示没有执行,包括未启动或递归调用。 */TsId Ts_RunOne(uint32_t now_ms){ if (!ts_started || ts_busy) return 0u; TsTask *best = NULL; for (unsigned i = 0; i < TS_CAPACITY; ++i) { TsTask *task = &ts_tasks[i]; if (task->id == 0u || task->suspended || (!task->pending && !ts_due(now_ms, task->deadline_ms))) continue; if (best == NULL || task->priority > best->priority || (task->priority == best->priority && task->id < best->id)) best = task; } if (best == NULL) return 0u; if (ts_due(now_ms, best->deadline_ms)) { uint32_t periods = (uint32_t)(now_ms - best->deadline_ms) / best->period_ms + 1u; best->deadline_ms += periods * best->period_ms; best->stats.missed_periods += periods - 1u; } best->pending = false; /* 在调用前认领工作;回调期间不改表。 */ ts_busy = true; ts_running_id = best->id; uint32_t begin = ts_read_cycles != NULL ? ts_read_cycles() : 0u; best->callback(best->context); uint32_t elapsed = ts_read_cycles != NULL ? ts_read_cycles() - begin : 0u; best->stats.last_cycles = elapsed; if (elapsed > best->stats.max_cycles) best->stats.max_cycles = elapsed; best->stats.total_cycles += elapsed; ++best->stats.runs; ts_running_id = 0u; ts_busy = false; return best->id;}
/* 用于单次 32 位周期数;累计值先除后乘,或在上层用浮点计算平均。 */uint64_t Ts_CyclesToUs(uint32_t cycles){ return ts_cycle_hz != 0u ? (uint64_t)cycles * 1000000u / ts_cycle_hz : 0u;}任务 ID 与数组槽位分开:选择就绪项不移动结构体,删除也不搬移后继项。旧 ID 无法碰巧落到另一个新任务上。重新初始化则建立新的 ID 生命周期,调用方必须清掉此前保存的所有 ID。失败的创建不会修改 out_id,因此调用者必须检查返回值后才使用它。
每次派发只做一次固定容量扫描,复杂度 。对于最多 32 项的小表,这比为了优先级反复排序更容易审查。原文在空表时的 task_count - 1 会先发生 C 整数提升,不能仅凭 task_count 是 uint8_t 就断言它变成 255;真正的问题是排序改变了用作 ID 的下标。
一个例子:周期 10 ms,截止点 10、20、30、40 ms,主循环直到 35 ms 才服务。这里执行一次,记录遗漏 2 个周期,下一截止点仍是 40 ms。若从 35 ms 再加 10 ms,就会把相位逐次向后推。回调执行到 42 ms 后,再用最新时间调用 Ts_RunOne(),40 ms 的工作依然能够被发现。
STM32 时基与 DWT 适配
跳转到“STM32 时基与 DWT 适配”最小主循环只需要持续递增的毫秒时间。已有 HAL 工程可直接读 HAL_GetTick(),无需再加一份调度器 tick;如果改用 TIM2,就令 TIM2 的中断只更新专用时基,不能同时让 SysTick 也更新同一个计数。时基中断中不扫描任务表、不执行回调、不打印日志。
原 main.c 以 F103C8T6、72 MHz 系统时钟、APB1 36 MHz、TIM2 定时器时钟 72 MHz 为背景:PSC=71、ARR=999 得到 1 kHz 更新,TIM2 的逻辑抢占优先级示例为 2;USART1 用 115200、8N1 打印,PC13 作为待补充的 LED 初始化示例。这些是历史工程参数,NVIC 分组、其他中断、PC13 极性与供电条件仍需按板卡验证;源码中的时钟/GPIO 初始化以及 app_tasks.h 并未给出完整实现。若采用已有 HAL 毫秒时基,就不要为保留这些示例数字再重复占用 TIM2。
下面是 STM32F1 + HAL 的可选周期计数适配,依赖工程提供的器件头文件和 SystemCoreClock。CMSIS 的 DWT_CTRL_NOCYCCNT_Msk 表示是否缺少周期计数器,不能笼统假定任何 Cortex-M 都有可用 DWT。Arm Cortex-M3 寄存器定义
#include "stm32f1xx_hal.h"
bool Scheduler_DwtEnable(void){ if ((DWT->CTRL & DWT_CTRL_NOCYCCNT_Msk) != 0u) return false; CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0u; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; __DSB(); __ISB(); return (DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk) != 0u;}
uint32_t Scheduler_ReadCycles(void){ return DWT->CYCCNT;}HAL 与老的标准外设库(SPL)是不同驱动接口,不能把原稿的 stm32f10x.h、SPL 初始化与 HAL 初始化混成一套工程。保留选定工具生成的系统时钟、启动文件、TIM 中断和错误处理;上面的纯 C 核心两边都可复用。需要用 SPL 时,仅替换外围时基、周期计数与业务 IO 适配。
原 USART 重定向逐字节忙等 TXE,也可能拖长回调;诊断日志应限量、异步发送,记录队列满。错误路径应选可执行的指示方式:原稿先关全局中断、再注释示意 HAL_Delay() 闪灯,若按这种顺序启用代码,依赖中断的 HAL tick 无法前进。不能在错误路径中再次等待已经失效的时基或串口。
核心初始化和创建示意如下。LedStep、SensorStep 是应用自行实现的短回调;它们应推进一次业务状态机后返回。
完成 HAL、系统时钟和业务外设初始化若 DWT 可用:Ts_Init(Scheduler_ReadCycles, SystemCoreClock)否则:Ts_Init(NULL, 0)检查 Ts_Create("LED", LedStep, led_context, 500, 0, 1, HAL_GetTick(), &led_id)检查 Ts_Create("SENSOR", SensorStep, sensor_context, 10, 2, 3, HAL_GetTick(), &sensor_id)全部成功后 Ts_Start()循环: Ts_RunOne(HAL_GetTick()) 执行有时间上限的应用消息处理(在这里调用 Trigger / Suspend 等 API) 不在回调内 HAL_Delay、等待串口发送完毕或等待另一个回调任务表没有 volatile 并不是此实现遗漏同步:它明确只由主循环读写。volatile 本来也不能让复杂表操作原子化。如果中断需要通知任务,用独立、经过同步设计的标志或环形缓冲区,把事件交给主循环再调用 Ts_Trigger();多任务共享表则应换成相应 RTOS 同步设计。若其他模块确需临界区,必须恢复进入前的中断屏蔽状态,不能配对使用“关中断/无条件开中断”而破坏外层屏蔽。ST PM0056 的 PRIMASK 与异常模型
执行时间、超期与负载分别看
跳转到“执行时间、超期与负载分别看”计时必须先做 uint32_t(end_cycles - begin_cycles),再换算单位。原稿先把 CYCCNT 除成微秒,再减两个商;CYCCNT 在 72 MHz 时约每 59.65 s 回绕,这会造成虚假的超长耗时。修正后跨一次回绕也可计算,但要求单次测量小于一个完整 CYCCNT 周期,并且测量期间频率不变、计数器不被重置。
last_cycles、max_cycles 记录单次耗时,total_cycles / runs 可计算平均周期数;没有启用周期读取器时这些值为 0,而 runs、missed_periods 仍有效。累计采用 64 位计数,长期运行仍应按观察窗口读取并清零。这里的耗时包括测量区间内打断回调的中断时间,也受睡眠、调试和时钟门控影响;它不是严格隔离的线程 CPU 时间。
原文把平均执行时间除以周期并求和,所得近似量为:
这仅估计理想周期工作负担,不包含所有中断、主循环和调度成本,也不体现手动触发、合并与优先级饥饿。若要报告实测忙碌占比,应在同一观察窗口统计忙碌时间与总经过时间,并明确睡眠、ISR 与通信时间的计入口径。不能仅凭这个和小于 100% 就保证响应期限。
对于超期,至少分别记录:单次执行超过应用预算、开始执行晚于截止时间,以及被合并的到期次数。本核心提供执行统计和合并次数;应用预算、截止时间迟到分布与告警策略由上层补充。高优先级任务若每次执行完已经再次到期,低优先级任务可能长期得不到执行,需要降低负担或改调度策略。
配置与扩展的实际边界
跳转到“配置与扩展的实际边界”原稿的配置文件列出了许多开关,但部分未接入代码,也与另一头文件里的定义重复。移植时只保留实际生效的选项,并用编译或测试证明差异。
| 原需求 | 本页状态与接入位置 |
|---|---|
| 最大任务数、名字长度、优先级、统计 | 上面固定宏、选取规则与 TsStats 已实现;容量应按 sizeof(ts_tasks) 计入 RAM |
| 快速排序 | 不需要;稳定槽位加扫描选择,不保留未使用开关 |
| 1 ms 或其他硬件中断周期 | 核心输入始终是毫秒;若硬件每 10 ms 中断一次,转换为实际累计毫秒,不能仍只加 1 |
| 执行超时、最大待处理数 | 应用定义预算;当前积压合并为一个实例,missed_periods 记录损失 |
| 看门狗、错误/创建/删除钩子、日志 | 可在主循环成功操作后接入,钩子同样必须有界;不要从 ISR 调用阻塞打印 |
| 栈监测 | 所有回调共用主循环调用栈,没有每项独立任务栈;分析最深调用链与中断嵌套 |
| 事件、消息队列、延迟服务 | 属于后续功能;这份核心只提供合并式手动触发,不宣称已经实现队列 |
原配置的历史初值包括:CPU 警告 80%、执行预算为周期的 80%、单次耗时警告 5000 μs、看门狗 1000 ms、最近耗时记录深度 10、TCB 对齐意图 4 字节;栈检查、看门狗、耗时历史、创建/删除/执行前后钩子以及事件/队列默认关闭。它们只是原工程的建议参数,没有普适保证;尤其前后钩子不应为了计时而把整个回调包进长临界区。原“32 项约 1.9 KB”依据的结构体估算也不能沿用:本页 ARM_CM3 编译的 TsTask 为 72 字节,32 项表实际为 2304 字节,另有调度全局量与调用栈。
预处理 #if 需要整数常量表达式,不能用 80.0f 这种浮点阈值;可改成整数百分比或千分比。SystemCoreClock 是 CMSIS 系统层提供的变量,不能用 #ifndef SystemCoreClock 判断它是否存在后定义同名宏。任务 ID 也不应一边允许 255 项,一边用只能表达非负 0~127 的 int8_t 返回数组下标。
本页验证覆盖核心严格 C 编译、Cortex-M3 编译、周期与首次延迟、优先级和同级次序、删除后 ID、暂停恢复、手动触发、停止重启、跨毫秒与周期计数回绕、积压合并、回调重入保护、输入边界和统计。HAL 适配只完成编译核验,未在真实 STM32 板上运行;中断频率、GPIO 波形、最坏回调耗时和睡眠后的时基恢复仍需工程实测。