7AA7 博客

RTOS 入门:任务调度与同步原语

何遇 UID:112
2026-09-05 20:39
60 0 0

裸机程序写久了会习惯用一个 while(1) 串起所有事情:读传感器、算一遍、刷屏、再看有没有按键。功能简单时这个结构工作得很好,直到某个环节耗时开始不稳定——显示刷新从 2ms 变成 200ms,按键响应跟着劣化。RTOS 解决的是同一件事,但把”谁先跑”从代码书写顺序交给了调度器。

从超级循环到多任务

超级循环加中断的结构常被称为前后台:中断处理紧急事务,主循环依次做常规事务。它的调度是隐式的,执行顺序就是代码顺序。

/* 前后台:顺序即优先级,一个环节变慢,后面全部被推迟 */
void main(void)
{
    for (;;) {
        sample_sensor();    /* 1ms   */
        refresh_display();  /* 200ms */
        scan_key();         /* 2ms   */
    }
}

按键最坏要等 200ms 才被读到。把刷新拆成状态机能缓解,但复杂度转移到代码结构上——每个长任务都要自己记状态、自己让出 CPU,本质是在手写一个调度器。

RTOS 把这部分拿走:

void vSensorTask(void *arg)
{
    for (;;) {
        sample_sensor();
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void vUiTask(void *arg)
{
    for (;;) {
        refresh_display_step();   /* 每步只做一小块 */
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

void vKeyTask(void *arg)          /* 优先级高于前两者 */
{
    for (;;) {
        ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        handle_key();
    }
}

代价同样明确:每个任务需要独立栈,内存开销随任务数增长;每次切换要保存恢复寄存器,典型在几微秒到几十微秒量级,取决于架构与是否用 FPU;还要接受一套新的心智模型——共享数据不再天然安全。

抢占式调度与时间片

抢占式调度的规则是:只要有更高优先级的任务进入就绪态,当前任务立刻被换下。这让高优先级任务的响应延迟可预测,代价是任何共享数据都可能被中途打断。

时间片解决同优先级任务的分配:就绪任务轮流执行,每个跑一个系统节拍。它只在同优先级之间生效,不同优先级之间永远是抢占。

优先级数量有限,多数内核提供几十级。分配有一条实用规则:按截止期而不是”重要性”分配——周期越短、越要求即时响应的任务优先级越高(这正是 RMS 的基本思路)。把”业务很重要”当成给高优先级的理由,往往让真正紧急的任务排到后面。

vTaskDelay 是相对延时,vTaskDelayUntil 是绝对延时。周期性任务应该用后者:

TickType_t last = xTaskGetTickCount();
for (;;) {
    do_periodic_work();
    vTaskDelayUntil(&last, pdMS_TO_TICKS(10));  /* 补偿自身执行时间 */
}

用 vTaskDelay(10) 的话,周期会变成”10ms + 执行时间”,负载变化时周期跟着漂移,采样类任务最容易在这里积累误差。

优先级反转与两种修正

优先级反转的场景不复杂:低优先级任务 L 拿到互斥量,高优先级任务 H 申请同一把锁被阻塞;此时中优先级任务 M 抢占了 L——M 不需要那把锁,却让 H 的等待取决于 M 何时跑完。H 的延迟不再由自己的优先级决定,这就是反转。

修正手段有两种:

  • 优先级继承:L 持有锁期间被临时提升到等待者中最高的优先级,释放后恢复。它动态、按需,但要等反转发生后才生效,且只解决一层。
  • 优先级天花板:给锁预设一个天花板优先级(不低于所有可能使用者的最高优先级),谁拿到锁就提升到该优先级。它需要提前知道全部使用者,但能避免链式阻塞,也能防死锁。

两者的共同前提是必须用互斥量而不是二值信号量:

/* 互斥量带优先级继承;二值信号量没有所有权概念,也没有继承机制 */
g_spi_bus = xSemaphoreCreateMutex();

void vLowPrioTask(void *arg)
{
    xSemaphoreTake(g_spi_bus, portMAX_DELAY);
    spi_transfer(buf, len);        /* 期间优先级被临时提升 */
    xSemaphoreGive(g_spi_bus);     /* 释放后恢复原优先级 */
}

用二值信号量保护共享资源是嵌入式里常见的一个错误:它在没有竞争时表现完全正常,只有在负载上来的特定时刻才暴露,而那种时刻通常已经不在调试台上了。

同步原语怎么选

原语 适用场景 关键性质
二值信号量 事件通知、中断与任务同步 无所有权、无优先级继承
计数信号量 有限资源计数(缓冲槽、连接数) 计数值即可用资源数
互斥量 保护共享资源的互斥访问 有所有权、可递归、带优先级继承
消息队列 传递数据 数据拷贝进队列,自带同步与解耦
事件标志组 等待多个条件中的任意/全部 多条件组合,不传数据
任务通知 轻量单播通知 最快,但只有一个接收者

判断标准是”传递什么”。告诉别人”发生了”,用信号量或任务通知;要交数据,用队列;要独占资源一段时间,用互斥量;要等多个条件同时满足,用事件标志组。

中断里只能使用带 FromISR 后缀的变体,它们不会阻塞:

void USART1_IRQHandler(void)
{
    BaseType_t higher_prio_woken = pdFALSE;
    uint8_t byte = USART1->DR;
    xQueueSendFromISR(g_uart_rxq, &byte, &higher_prio_woken);
    portYIELD_FROM_ISR(higher_prio_woken);   /* 必要时触发切换 */
}

portYIELD_FROM_ISR 不是立刻切换,它只是标记”退出中断后需要重新调度”,真正的切换发生在中断返回时。这个区分是理解中断上下文约束的关键。

临界区与中断上下文

中断服务程序不能调用任何可能阻塞的 API。vTaskDelay、不带 FromISR 的 xSemaphoreTake 在 ISR 里被调用,后果通常是随机位置的硬 fault——调度器在中断上下文中尝试切换栈,而中断有自己的栈帧约定。

保护临界区有两个层次,代价不同:

taskENTER_CRITICAL();       /* 关中断:最坏中断延迟增加 */
shared_counter++;
taskEXIT_CRITICAL();

vTaskSuspendAll();          /* 只挂调度器,中断仍可响应 */
update_large_buffer();
xTaskResumeAll();

taskENTER_CRITICAL 屏蔽中断,影响的是系统的最坏中断延迟——这是实时性的硬指标,一个”偶尔关掉几十微秒”的临界区在高频中断场景里可能直接导致丢中断。vTaskSuspendAll 不关中断,只禁止任务切换,适合耗时较长但对中断延迟敏感的场景;注意被挂起的调度器期间不能调用任何可能切换的 API。

关中断时长、内核临界区时长、中断处理函数时长共同决定最坏中断延迟。三者中最容易被忽略的是驱动里那些”很短”的临界区。

栈大小与溢出检测

每个任务的栈大小在创建时确定。估算要考虑:函数调用深度、各层局部变量、浮点上下文(用了 FPU 时寄存器保存显著增加,具体字节数以架构手册为准)、以及中断嵌套——有的架构中断用独立主栈,有的直接压在当前任务栈上,后者必须把最深嵌套算进去。

void vApplicationStackOverflowHook(TaskHandle_t handle, char *name)
{
    log_fault(name);        /* 只记录任务名 */
    NVIC_SystemReset();     /* 不做复杂打印,现场可能已损坏 */
}

void vCheckTask(void *arg)
{
    for (;;) {
        UBaseType_t left = uxTaskGetStackHighWaterMark(g_worker);
        if (left < MIN_EXPECTED) report_stack_risk(left);
        vTaskDelay(pdMS_TO_TICKS(60000));
    }
}

高水位线要在最坏执行路径跑过之后读,刚启动时的读数没有意义。留出余量也必要——后续改代码会增加栈用量,而栈溢出不会在改动当时报错。

栈溢出的症状常是”随机死机”,溢出写坏的是相邻内存,可能是另一个任务的栈,也可能是某个全局变量,定位成本很高。开启栈溢出钩子与高水位线统计,是投入很小回报很大的配置。

常见坑

在 ISR 里调用阻塞 API 是最典型的一类,往往在轻负载下不出现,等中断频率上来才开始随机崩溃。

共享数据没有保护,第二典型。shared++ 是读-改-写三步,不是原子操作;volatile 只保证可见性不保证原子性,除非架构明确保证该宽度的对齐访问是原子的。

用信号量当互斥量,丢失了优先级继承,问题潜伏很久。

在临界区里做耗时操作,比如打印日志或拷贝大块内存,直接抬高最坏中断延迟。

频繁创建和删除任务。创建涉及栈和 TCB 分配,反复申请释放带来内存碎片;更常见的做法是任务常驻,通过队列或通知传递工作。

任务函数直接 return 了:它不应返回,要么无限循环,要么显式 vTaskDelete(NULL)。

没有打开 configASSERT、栈溢出钩子、运行时间统计。这几项在开发阶段是免费的诊断手段,等进入联调再补,成本高很多。

调试的通用办法是把不确定性变成确定性:先复现最坏路径,再看高水位线、任务运行时间统计、临界区时长。多数”偶发死机”在这些数据面前都有明确指向。

2.2k浏览量
60赞
0评论
0参与用户
12 分钟阅读时间

评论(0)

还没有评论,来抢沙发。