一、问题的由来

在嵌入式项目中,我们经常需要让一个任务以固定周期运行,例如每100ms采样一次传感器。很多开发者习惯这样写:

void vSensorTask(void *pvParameters)
{
    while (1) {
        Sensor_Sample();
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

看起来周期是100ms,但实际运行一段时间后,采样时刻会逐渐“漂移”。这就是所谓的相位漂移(phase drift)。要理解它,必须先看清两个API的本质区别。

二、原理剖析

2.1 vTaskDelay:相对延时

vTaskDelay(xTicksToDelay) 的语义是“从调用时刻起阻塞指定tick数”。也就是说,下一次唤醒时刻 = 本次唤醒时刻 + 任务执行时间 + 延时tick数。

设任务体执行时间为 T_exec,延时为 T_delay,则实际周期为:

T_period = T_exec + T_delay

只要 T_exec > 0,周期就会大于设定值,误差在每个周期累加,形成线性漂移。

2.2 vTaskDelayUntil:绝对延时

vTaskDelayUntil(&xLastWakeTime, xIncrement) 的语义是“阻塞到上一次唤醒时刻 + xIncrement”。它内部维护一个上次唤醒时间戳,每次调用后自动更新:

xTimeToWake = xLastWakeTime + xIncrement;
xLastWakeTime = xTimeToWake;

因此实际周期严格等于 xIncrement(前提是任务体执行时间小于周期),与 T_exec 无关,不会累积误差。

2.3 漂移的数学表达

运行N次后:

  • vTaskDelay 累计偏差 ≈ N × T_exec(外加tick取整误差)
  • vTaskDelayUntil 累计偏差 ≈ 0(仅受tick粒度与调度抖动影响)

三、实测环境与配置

  • MCU:STM32F407ZGT6,168MHz
  • FreeRTOS:V10.3.1,configTICK_RATE_HZ = 1000(1 tick = 1ms)
  • 测量手段:在任务中翻转GPIO,用逻辑分析仪统计相邻上升沿间隔
  • 任务体人为加入约3ms的忙等,模拟真实业务耗时

四、完整测试代码

#include "FreeRTOS.h"
#include "task.h"
#include "main.h"

/* 模拟3ms业务耗时 */
static void BusyWork(void)
{
    uint32_t start = DWT->CYCCNT;
    while ((DWT->CYCCNT - start) < (3u * 168000u)) { }
}

/* 方式一:vTaskDelay */
void vTaskDelayTest(void *pv)
{
    for (;;) {
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);
        BusyWork();
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

/* 方式二:vTaskDelayUntil */
void vTaskDelayUntilTest(void *pv)
{
    TickType_t xLastWakeTime = xTaskGetTickCount();
    const TickType_t xPeriod = pdMS_TO_TICKS(100);

    for (;;) {
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET);
        BusyWork();
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);
        vTaskDelayUntil(&xLastWakeTime, xPeriod);
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();

    /* 使能DWT周期计数器用于忙等 */
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

    xTaskCreate(vTaskDelayTest, "D", 256, NULL, 2, NULL);
    xTaskCreate(vTaskDelayUntilTest, "U", 256, NULL, 2, NULL);

    vTaskStartScheduler();
    for (;;) { }
}

五、实测结果

逻辑分析仪统计1000个周期(约100s)的结果:

| 指标 | vTaskDelay | vTaskDelayUntil | |------|-----------|-----------------| | 单周期均值 | 103.0 ms | 100.0 ms | | 1000次累计时间 | 103.0 s | 100.0 s | | 累计偏差 | +3000 ms | ≈ 0 ms | | 周期抖动 | 小 | 小 |

结论非常直观:vTaskDelay 的误差与任务执行时间成正比并持续累积;vTaskDelayUntil 的周期由时间戳锁定,长期无漂移。

六、注意事项与工程建议

  • 任务体必须短于周期:若 T_exec > xIncrement,vTaskDelayUntil 会立即返回(不阻塞),周期退化为任务执行时间,此时应拆分任务或增大周期。
  • 首次调用前初始化时间戳:xLastWakeTime 必须在进入循环前用 xTaskGetTickCount() 赋值,且之后不要手动修改。
  • tick精度限制:configTICK_RATE_HZ 决定最小分辨率,1000Hz下误差在1ms量级;对更高精度需求可考虑硬件定时器触发。
  • 优先级与抖动:高优先级任务抢占会带来调度抖动,但vTaskDelayUntil不会累积该抖动,vTaskDelay会。
  • 不要混用:同一任务内交替使用两种延时会导致时间戳语义混乱。
  • 低功耗场景:tickless模式下需确认vTaskDelayUntil行为符合预期。

七、总结

周期性任务应优先使用 vTaskDelayUntil,它通过绝对时间戳消除了任务执行时间带来的相位漂移;vTaskDelay 仅适合对周期精度无要求的场景。理解两者的语义差异,是从“能跑”走向“可靠”的关键一步。