本节摘要:任务的一生就是四种状态之间的搬运——运行、就绪、阻塞、挂起,每条迁移路径都对应明确的触发条件。本节把状态图放大到工程精度:重点解剖阻塞态的"期限加等待对象"双属性、内核延迟链表如何管理成千上万个睡眠任务、滴答回绕为何需要两条延迟链表;再用一个心跳任务漂移实验,实证相对延时与绝对延时的误差累积差异。读完你能准确预测任何一段任务代码的行为轨迹。
上一节把任务的"静态构成"拆完了,这一节看它怎么"动"。先问一个问题:一个调用了延时的任务,此刻在干什么?它不占处理器,不在就绪队列里,也不算被挂起——它在阻塞态,而且是带着"等多久、等什么"两个明确参数的等待。把这类细节说准,是本节的目标,也是预测调度行为的基本功。
运行态:正在占用处理器的任务,单核系统同一时刻至多一个。就绪态:万事俱备只欠处理器,按优先级排队。阻塞态:在等一个带期限的条件——时间到了(延时)、事件来了(队列或信号量)、或超时先到。挂起态:被人为按停,无期限无事件,2.1 节已交代。四态里最值得展开的是阻塞态,因为它是 RTOS 与前后台系统分野的地方:前后台的"等待"是空转轮询,烧着处理器等事件;RTOS 的阻塞是登记后离场,处理器让给别人。同一个"等"字,成本差一个数量级。
阻塞态的两个属性要记牢。等待对象:延时(等时间)、队列(等数据或空间)、信号量(等令牌)、事件组(等比特组合)——对象决定它挂在哪条事件链表上。期限:除延时本身就是期限外,其余等待都带超时参数;给一个很大的特殊值表示无限期等。超时先于事件到来时,任务带着"超时"结果醒来,调用方必须检查返回值区分"等到"与"等超"。
内核怎么管理一群睡着的任务?答案是延迟链表:所有阻塞在时间上的任务按"到期滴答数"升序挂在链表上,每个滴答中断里内核只看链表头部——头部没到期,后面必然都没到期(有序的好处)。头部到期就摘下、放入就绪链表、必要时触发切换。一万个在睡的任务,每个滴答的成本仍是常数级。这里有个精巧的细节:滴答计数器是会回绕的(32 位滴答在 1 千赫兹下约 49 天一圈),回绕前后"谁先到期"的数值比较会乱套,所以内核维护两条延迟链表——本圈链表与溢出圈链表,回绕瞬间切换。你不需要写一行代码处理它,但排障时要能解释"为什么 50 天压力测试能暴露别人测不出的问题"。
心跳任务要求严格每秒打印一次。先用相对延时写:
static void beat_relative(void *arg) { for (;;) { print_beat(); vTaskDelay(pdMS_TO_TICKS(1000)); /* 从“现在”再睡一秒 */ } }
再用绝对延时写:
static void beat_absolute(void *arg) { TickType_t wake = xTaskGetTickCount(); /* 记住基准时刻 */ for (;;) { print_beat(); vTaskDelayUntil(&wake, pdMS_TO_TICKS(1000)); /* 基准每次自动推进 */ } }
两段代码在轻负载下几乎看不出差别,差别藏在每次循环的"额外开销"里:打印耗时、延时本身按滴答取整的舍入、偶尔被高优先级任务抢占的时间——相对延时把这些误差全部滚存进下一轮,绝对延时则始终锚定在最初基准的整数倍时刻上。用逻辑分析仪量打印脉冲的间隔:相对版逐次后移,累计一千次后偏差肉眼可见;绝对版每次都落在整秒附近,误差不累积。

一个容易被忽视的细节:绝对延时的基准变量由接口自动更新,不要在循环里手工改它;且如果任务被长时间抢占导致"该醒来的时刻已过去",接口会立即返回让你追进度——它不会把欠的时间补睡回来,这一点在控制环里非常重要。
阻塞的价值不只在于精度,更在于把处理器还给系统。看一次真实改造。原始写法是前后台思维的延续:
static void key_poll(void *arg) { for (;;) { if (key_pressed()) { handle_key(); } /* 没有任何等待:满速空转,CPU 占用率逼近满格 */ } }
这个任务优先级不低的话,空闲任务几乎得不到执行时间,低功耗休眠(第 6 章)无从谈起,其他任务的调度延迟也被空转搅浑。改造方向是让硬件通知代替软件轮询:按键中断里发送一个计数信号量或任务通知(第 4 章的原语,这里先用概念),任务阻塞等待:
static void key_wait(void *arg) { for (;;) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* 睡到中断唤醒 */ handle_key(); /* 醒来必有事件 */ } }
改造后测量:该任务的处理器占用从满格降到不足千分之一,整机空闲占比大幅上升——这部分时间正是低功耗模式的入场券。附带的红利是"醒来必有事":轮询版里处理逻辑要分辨真假事件,阻塞版里醒来就是真事件,代码更短也更快。
💡 判断一个任务写得好不好,看它"无事可做时在哪":在就绪链表里排队的是轮询思维,在阻塞链表里睡觉的是 RTOS 思维。第 8 章的性能优化清单里,"把轮询改成事件驱动"永远排在第一条。
排障时经常要回答"这个任务现在什么状态"。内核提供两个层次的观测:查询接口给出单个任务的瞬时状态枚举(运行、就绪、阻塞、挂起、已删除、无效);系统级快照接口遍历所有任务,输出每个任务的状态、优先级、栈水位——这是第 7 章运行时统计的主角,也是 2.4 节排障时的取证工具。用查询接口要注意时机:你读到结果的瞬间状态可能已经变了,观测本身不冻结调度;要可靠取证,配合调度器挂起(把所有任务按停)再拍快照。
还有一类"状态"问题来自误用:在中断服务程序里调用了会阻塞的接口。中断不属于任何任务,没有可挂起的栈,"阻塞"无从谈起——内核要么在断言里拦截,要么行为未定义。所有带等待的接口都有中断安全版本(后缀标记),规则很简单:中断里只许用带后缀的,第 6 章会把整套规则讲全。
状态机讲完,还剩最后一个问题:一群就绪任务,调度器让谁上?下一节的两条规则——抢占与时间片——就是裁决的法条。