写 RTOS 代码有一类任务出现频率最高周期任务。ADC 每 100ms 采一次、状态每 1s 上报一次、控制环路每 10ms 算一次……几乎所有嵌入式系统都建立在周期任务之上。我们讲过osDelay和osDelayUntil的基础区别。但当你在真实项目里写周期任务时会冒出更工程化的问题为什么我的10ms 周期任务实际跑起来周期是 11ms、12ms 还在漂osDelayUntil 真的能保证绝对周期吗它什么时候会失效任务单次执行超时了比周期还长会发生什么这篇把周期任务的工程化讲透抖动的来源、两种延时 API 在周期场景下的行为差异、以及写一个稳健周期任务的标准模板。一、先看抖动从哪来所谓抖动Jitter就是周期任务实际唤醒时刻与理想唤醒时刻的偏差。理想 10ms 周期实际可能是 10、11、12ms这个波动就是抖动。抖动有四个主要来源来源 1tick 量化误差FreeRTOS 的延时基于 tick 中断本系列 1ms 一次。osDelay(10)的真实唤醒时刻取决于调用那一刻相对 tick 边界的相位情况 A: 调用 osDelay(10) 时刚过 tick 边界 1μs → 下一次 tick 在 999μs 后再过 9 个 tick 唤醒 → 实际延时 9.999ms ≈ 10ms 情况 B: 调用 osDelay(10) 时距下个 tick 还有 999μs即将到 tick → 几乎立即到 tick再过 9 个 tick 唤醒 → 实际延时 9.001ms ≈ 9ms仅 tick 量化就能引入 ±1ms即 ±1 个 tick的抖动。这是TICK_RATE_HZ决定的硬限制。来源 2任务执行耗时算进了周期/* 错误写法执行耗时被算进周期 */ for (;;) { DoWork(); /* 耗时 3ms */ osDelay(10); /* 想要 10ms 周期 */ } /* 实际周期 3ms(DoWork) 10ms(osDelay) 13ms */这是第 06 篇讲过的累积误差。osDelay是从当前时刻起延时 N执行耗时被叠加上去。来源 3同优先级时间片轮转第 14 篇讲过同优先级任务唤醒后可能要等其它同优先级任务的时间片用完才被调度。引入 ±1~2 tick 抖动。来源 4高优先级任务抢占任务唤醒变就绪后如果有更高优先级任务正在运行要等它阻塞才能轮到。抢占时长不可控抖动更大。二、osDelay 的累积漂移实测我们写一个10ms 周期任务用osDelay实测它的实际周期。/** * file jitter_demo.c * brief 周期任务抖动实测osDelay vs osDelayUntil * date 2026-07-15 * version V1.0 - 初版创建 */ #include cmsis_os2.h #include stdio.h /** * brief 用 osDelay 实现10ms 周期有累积漂移 * note DoWork 耗时会被算进周期实际周期 10ms 且持续漂移 */ static void PeriodicDelayTask(void *argument) { uint32_t last_tick; (void)argument; last_tick osKernelGetTickCount(); printf([DELAY] start tick%lu\r\n, (unsigned long)last_tick); for (;;) { DoWork_2ms(); /* 模拟 2ms 工作 */ osDelay(10); /* 相对延时 */ uint32_t now osKernelGetTickCount(); printf([DELAY] wake, period%lu ms\r\n, (unsigned long)(now - last_tick)); last_tick now; } } /** * brief 用 osDelayUntil 实现10ms 绝对周期消除累积漂移 * note 唤醒目标对齐到固定的 tick 边界DoWork 耗时不再叠加 */ static void PeriodicUntilTask(void *argument) { uint32_t wake; uint32_t last_tick; (void)argument; wake osKernelGetTickCount(); last_tick wake; printf([UNTIL] start tick%lu\r\n, (unsigned long)wake); for (;;) { wake 10; /* 下次唤醒目标 上次 10 */ DoWork_2ms(); osDelayUntil(wake); /* 绝对延时 */ uint32_t now osKernelGetTickCount(); printf([UNTIL] wake, period%lu ms\r\n, (unsigned long)(now - last_tick)); last_tick now; } } /* 模拟 2ms 工作72MHz 下约 14 万次循环 */ static void DoWork_2ms(void) { volatile uint32_t i; for (i 0; i 140000U; i) { } }osDelay 的串口结果[DELAY] start tick0 [DELAY] wake, period12 ms [DELAY] wake, period12 ms [DELAY] wake, period13 ms [DELAY] wake, period12 ms [DELAY] wake, period13 ms串口设置USART1115200-8-N-1。观察点周期稳定在 12~13ms不是 10ms因为2ms(DoWork) 10ms(osDelay) 12ms且 DoWork 耗时微变导致 12/13 波动。永远回不到 10ms。osDelayUntil 的串口结果[UNTIL] start tick0 [UNTIL] wake, period10 ms [UNTIL] wake, period10 ms [UNTIL] wake, period11 ms [UNTIL] wake, period10 ms [UNTIL] wake, period10 ms串口设置USART1115200-8-N-1。观察点周期基本严格 10ms偶尔 11ms 是 tick 量化轮转抖动DoWork 的 2ms 被吃进 osDelayUntil 的等待时间里不再叠加。结论周期任务必须用osDelayUntil否则单次执行耗时会被算进周期导致周期变长且漂移。三、osDelayUntil 的失效边界osDelayUntil不是万能的。它有一个失效条件单次执行耗时超过周期。wake 10; /* 目标 10ms 周期 */ DoWork_15ms(); /* 但工作要 15ms! */ osDelayUntil(wake); /* wake 已经过去了立即返回不延时 */当DoWork耗时 15ms 周期 10ms 时osDelayUntil(wake)发现目标时刻wake已经过去当前 tick wake它会立即返回延时 0。任务全速循环再也回不到 10ms 周期——这叫追不上。更糟的是wake 10还在累加wake 越来越落后于实际时间可能产生整数回绕问题tick 是 uint32_t长期运行会溢出第 26 篇讲。怎么处理追不上/** * brief 带追不上检测的绝对周期任务 * details 若 DoWork 耗时超过周期wake 会落后。检测到落后时 * 重新对齐到当前 tick避免无限追赶和整数回绕风险。 */ static void SafePeriodicTask(void *argument) { uint32_t wake; uint32_t period 10; (void)argument; wake osKernelGetTickCount(); for (;;) { wake period; DoWork(); int32_t delta (int32_t)(wake - osKernelGetTickCount()); if (delta 0) { /* 追不上了重新对齐记录这次超时 */ printf([WARN] overrun! work period, resync\r\n); wake osKernelGetTickCount(); } else { osDelayUntil(wake); } } }工程教训周期任务的周期必须大于最坏执行时间。设计时要预估 DoWork 的最坏耗时含中断抢占周期至少留 30% 余量。否则系统会持续 overrun周期任务退化成能跑多快跑多快。四、标准周期任务模板综合上面所有要点给出一个可复用的周期任务模板/** * file periodic_task_template.c * brief 标准周期任务模板绝对周期 超时检测 抖动统计 * date 2026-07-15 * version V1.0 - 初版创建 */ #include cmsis_os2.h #include stdio.h /* 周期任务统计信息 */ typedef struct { uint32_t period_ms; /* 目标周期 */ uint32_t run_count; /* 累计运行次数 */ uint32_t overrun_count; /* 超时次数追不上 */ int32_t jitter_max_ms; /* 最大抖动可正可负 */ int32_t jitter_min_ms; /* 最小抖动 */ } PeriodicStat_t; /** * brief 标准周期任务模板 * * details 实现绝对周期对齐 超时检测 抖动统计。 * 使用方法把 DoPeriodicWork 替换为实际工作函数 * 调整 PERIOD_MS 为目标周期。 * * param argument: 未使用 * note 前提PERIOD_MS 必须大于 DoPeriodicWork 的最坏执行时间。 * 任务优先级要足够高避免被同优先级任务拖延抖动来源3。 */ #define PERIOD_MS 10U static PeriodicStat_t g_stat {0}; static void PeriodicTask(void *argument) { uint32_t wake; uint32_t last_wake; (void)argument; g_stat.period_ms PERIOD_MS; wake osKernelGetTickCount(); last_wake wake; for (;;) { wake PERIOD_MS; /* 目标唤醒时刻 */ DoPeriodicWork(); /* 实际工作 */ /* 抖动统计 */ uint32_t now osKernelGetTickCount(); int32_t actual_period (int32_t)(now - last_wake); int32_t jitter actual_period - (int32_t)PERIOD_MS; if (jitter g_stat.jitter_max_ms) g_stat.jitter_max_ms jitter; if (jitter g_stat.jitter_min_ms) g_stat.jitter_min_ms jitter; last_wake now; g_stat.run_count; /* 超时检测 */ int32_t remaining (int32_t)(wake - now); if (remaining 0) { g_stat.overrun_count; wake now; /* 重新对齐 */ } else { osDelayUntil(wake); } } } /* 实际周期工作替换为你的业务 */ static void DoPeriodicWork(void) { volatile uint32_t i; for (i 0; i 50000U; i) { } /* 模拟工作 */ } /** * brief 监控任务周期打印统计 */ static void StatTask(void *argument) { (void)argument; for (;;) { printf([STAT] period%lu run%lu overrun%lu jitter[%d,%d]ms\r\n, (unsigned long)g_stat.period_ms, (unsigned long)g_stat.run_count, (unsigned long)g_stat.overrun_count, (int)g_stat.jitter_min_ms, (int)g_stat.jitter_max_ms); osDelay(2000); } }串口结果[STAT] period10 run198 overrun0 jitter[0,1]ms [STAT] period10 run396 overrun0 jitter[0,1]ms [STAT] period10 run594 overrun0 jitter[0,1]ms串口设置USART1115200-8-N-1。观察点周期严格 10ms抖动 [0,1]ms即 0 或 1ms 抖动无 overrun。这是一个健康的周期任务。如果看到overrun 0或jitter_max很大就说明工作函数耗时过长或优先级不够——回过头优化。五、什么时候不能用 osDelayUntil要用 osDelay虽然周期任务首选osDelayUntil但有几种情况必须用osDelay场景用什么原因固定周期任务采集/控制/上报osDelayUntil消除累积漂移周期会动态变化如 LED 闪烁速度可调osDelayosDelayUntil 的 wake 累加基于固定周期变化时会错乱做一次歇一会的非周期等待osDelay本就没有固定周期概念首次启动不确定时机的任务先 osDelay 对齐再转 osDelayUntil见下基础篇第 10 篇的 LED 任务就是周期可变的例子命令会改闪烁速度那里用的osDelay。不要教条地全用osDelayUntil。动态周期的正确写法/* LED 闪烁速度由命令改变 */ static void LedTask(void *argument) { (void)argument; for (;;) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); osDelay(g_led_speed); /* 周期动态变化用 osDelay */ } }六、tick 溢出长期运行的隐患osKernelGetTickCount()返回uint32_t1000Hz 下每秒 1000约49.7 天溢出归零。osDelay的实现用(target - now)的无符号减法天然处理溢出所以osDelay本身不受影响。但osDelayUntil(prev period)里的prev period如果在溢出前后跨越依赖的也是无符号减法正确性——FreeRTOS 内部处理是正确的但你手写的wake period累加如果 wake 溢出归零逻辑仍正确无符号回绕osDelayUntil 内部的差值计算正确。真正要小心的是你自己比较 tick 的代码/* 错误直接比较大小溢出时会出错 */ if (osKernelGetTickCount() g_start_tick 5000) { ... } /* 正确用差值无符号减法天然处理溢出 */ if ((osKernelGetTickCount() - g_start_tick) 5000) { ... }记住比较时间差永远用减法不要用大于小于直接比 tick 值。这条规则能避开 49 天后的崩溃。第 26 篇会专门讲 tick 溢出机制。七、本篇 API 速查功能CMSIS-RTOS v2说明相对延时osDelay(ticks)从当前起延时周期可变场景用绝对周期延时osDelayUntil(tick)对齐到固定 tick周期任务首选当前 tickosKernelGetTickCount()uint32_t49.7 天溢出tick 频率osKernelGetTickFreq()本系列 1000ms 转 tickms * freq / 1000自行换算或定义宏周期任务设计检查清单检查项通过条件用了 osDelayUntil✓动态周期除外周期 最坏执行时间✓留 30% 余量有 overrun 检测✓追不上时重新对齐时间差比较用减法✓防 tick 溢出优先级足够高✓避免同优先级轮转抖动总结1. 周期任务抖动四大来源tick 量化、执行耗时算进周期、同优先级轮转、高优先级抢占。2.周期任务必须用osDelayUntil否则执行耗时叠加进周期导致周期变长且持续漂移——实测 10ms 任务跑成 12~13ms。3.osDelayUntil失效条件单次执行耗时 周期会追不上需检测并重新对齐。4. 周期可变的场景如 LED 速度可调用osDelay不要教条用 osDelayUntil。5. 标准周期任务模板 绝对周期 overrun 检测 抖动统计可作为所有周期任务的起点。6. tick 是 uint32_t49.7 天溢出比较时间差永远用减法不要直接比较大小。