【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 9 篇】

📅 2026/8/25 14:21:53
【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 9 篇】
信号量、邮箱都能同步为何还要事件标志组因为这类内核对象只能表达等同一个东西表达不了等条件组合。当任务要等 ADC 就绪 按键按下 定时器到期用信号量要 Pend 三次而事件标志组一次搞定。本篇拆解它的双向链表等待表、四种等待模式与 CONSUME 消费语义看它与 OS_EVENT 家族在设计上最本质的一次分叉。从一次三条件同步说起假设你写了一个采集任务ADC 采样就绪、用户按键按下、软件定时器到期三个事件都发生后才启动处理。用信号量怎么写三个 Pend还要设计三个信号量的释放逻辑。更麻烦的是如果任务只想起始于三个都满足的那一瞬间——信号量方案会引入竞态。事件标志组Event Flag Group就是为这类需求设计的它维护一组标志位任务可以一次等待任意组合条件满足立即唤醒。先看它的核心数据结构来自 ucos_ii.h 的实测代码typedefstructos_flag_grp{/* Event Flag Group */INT8U OSFlagType;/* OS_EVENT_TYPE_FLAG */void*OSFlagWaitList;/* 等待任务链表头 */OS_FLAGS OSFlagFlags;/* 当前标志位集 */}OS_FLAG_GRP;OS_FLAGS 是 16 位整型可配 8/16/32每一位代表一个事件。而整个任务的链表头指向一个 OS_FLAG_NODE 双向链表。术语卡片标志组 一组二进制位每个位代表一个事件是否发生AND 所有指定位都为 1OR 任一指定位置 1消费 条件满足后把这些位清掉位集 一组二进制位的集合。创建函数OSFlagCreate(flags, perr)做的事很朴素从 OSFlagFreeList 空闲链表取一块 OS_FLAG_GRP把初始位集写进 OSFlagFlags等待链表头清空。创建时指定的初值就是系统的初始状态——比如系统刚上电所有事件未发生就传 0某些位想从已发生开始比如系统时钟已启动就带上那个位。等待表为什么不是位图这是理解事件标志组的关键。第 6 篇讲过OS_EVENT 家族信号量/邮箱/队列的等待表是 OSEventGrp OSEventTbl 位图任务是登记在某个优先级位上。为什么标志组不用这套看它的等待节点就明白了typedefstructos_flag_node{/* Event Flag Wait List Node */void*OSFlagNodeNext;/* 双向链表 next */void*OSFlagNodePrev;/* 双向链表 prev */void*OSFlagNodeTCB;/* 指向等待任务 TCB */void*OSFlagNodeFlagGrp;/* 指向所属标志组 */OS_FLAGS OSFlagNodeFlags;/* 该任务等哪些位个性化 */INT8U OSFlagNodeWaitType;/* 该任务怎么等ALL/ANY/CLR... */}OS_FLAG_NODE;每个等待节点都带着自己的条件OSFlagNodeFlags 记录这个任务关心哪些位OSFlagNodeWaitType 记录它怎么等。位图等待表只能回答谁在等回答不了每个人在等什么条件。同一个标志组下任务 A 等位 0 和位 1AND任务 B 等位 3任意——这种个性化条件位图根本表达不了。所以设计选择了双向链表每个节点存自己的条件挂起时头插唤醒时遍历判定。一句总结OS_EVENT 等待表是登记制事件标志组等待表是协议制——每个等待者自带一份条件协议。四种等待模式一表看懂OSFlagPend 的第三个参数 wait_type 决定任务怎么等。源码os_flag.c 核心段是这样判定的switch(wait_type){caseOS_FLAG_WAIT_SET_ALL:/* 所有位都置 1 */flags_rdypgrp-OSFlagFlagsflags;if(flags_rdyflags){/* 完全匹配 */if(consume)pgrp-OSFlagFlags~flags_rdy;OSTCBCur-OSTCBFlagsRdyflags_rdy;return(flags_rdy);/* 立即成功 */}OS_FlagBlock(pgrp,node,flags,wait_type,timeout);break;caseOS_FLAG_WAIT_SET_ANY:/* 任一位置 1 即可 */flags_rdypgrp-OSFlagFlagsflags;if(flags_rdy!0){/* 同上CONSUME 清理 返回 flags_rdy */}OS_FlagBlock(...);break;/* CLR_ALL / CLR_ANY判定取反~OSFlagFlags flags */}整理成表模式常量判定逻辑典型场景SET_ALL所有位为 1flags_rdy flags三条件全满足才启动SET_ANY任一位置 1flags_rdy ! 0任一异常即报警CLR_ALL所有位为 0(~flags) 0 等价判定全部任务完成CLR_ANY任一位置 0flags_rdy ! 0任一资源释放注意flags_rdy OSFlagFlags flags这步——先按自己关心的位做掩码屏蔽掉不关心的位再比较。这就是个性化条件在判定上的体现。Pend 挂起栈上的节点条件不满足时调用 OS_FlagBlock 挂起核心动作os_flag.c 逐字OSTCBCur-OSTCBStat|OS_STAT_FLAG;OSTCBCur-OSTCBDlytimeout;OSTCBCur-OSTCBFlagNodepnode;/* TCB 指向节点 */pnode-OSFlagNodeFlagsflags;/* 保存等待条件个性化 */pnode-OSFlagNodeWaitTypewait_type;pnode-OSFlagNodeTCBOSTCBCur;pnode-OSFlagNodeNextpgrp-OSFlagWaitList;/* 链表头插 */pnode-OSFlagNodePrev(void*)0;pnode-OSFlagNodeFlagGrppgrp;if(pnode_next!0)pnode_next-OSFlagNodePrevpnode;pgrp-OSFlagWaitListpnode;/* 从就绪表清除同 OS_EventTaskWait */这里藏着一个坑node 是 OSFlagPend 函数栈上的局部变量。任务被挂起后这个栈帧还在但一旦任务被唤醒、函数返回栈帧就没了。如果超时路径不把节点从链表摘除链表里就留了一个指向已失效栈空间的野指针——下次 Post 遍历链表就会踩内存。所以超时/中止路径必须调用 OS_FlagUnlink 摘节点。源码里 Pend 后半段正是这么写的OS_Sched();if(OSTCBCur-OSTCBStatPend!OS_STAT_PEND_OK){/* 超时/中止 */OS_FlagUnlink(node);/* 从等待链表摘除 */...}常见面试题就藏在里面为什么 OS_FlagBlock 的节点不能是 TCB 内嵌成员因为任务可能同时等两个标志组——一个 TCB 只有一份内嵌节点不够用。栈上局部变量天然支持同时等多个只要每个 Pend 调用有自己的栈帧。举个实际例子一个任务同时等系统初始化完成标志组 ASET_ALL和任一通信通道就绪标志组 BSET_ANY。两个 Pend 各占一个栈帧节点互不干扰——如果节点内嵌在 TCB 里第二个 Pend 就会覆盖第一个的条件。Post 唤醒O(n) 遍历逐个判定OSFlagPost 先改标志位SET 置位 / CLR 清位然后遍历整个等待链表对每个节点按其 WaitType 判定schedOS_FALSE;pnodepgrp-OSFlagWaitList;while(pnode!0){/* 遍历整个等待链表 */switch(pnode-OSFlagNodeWaitType){caseSET_ALL:flags_rdyOSFlagFlagspnode-OSFlagNodeFlags;if(flags_rdypnode-OSFlagNodeFlags){rdyOS_FlagTaskRdy(pnode,flags_rdy);/* 唤醒该任务 */if(rdy)schedOS_TRUE;}break;caseSET_ANY:flags_rdyOSFlagFlagspnode-OSFlagNodeFlags;if(flags_rdy!0){/* 唤醒 */}break;/* CLR 模式同理取反判定 */}pnodepnode-OSFlagNodeNext;}if(sched)OS_Sched();OS_FlagTaskRdy 做三件事把任务置回就绪态、把节点从链表摘除、按 CONSUME 语义清掉命中的位。注意清位的粒度OS_FlagTaskRdy只清这个任务命中的那些位flags_rdy不清别的——同一组里其他任务等的位置原样保留。唤醒多个任务时每个任务拿走自己命中的部分位集被瓜分。设计取舍OS_EVENT 家族唤醒是两步查位图O(1)标志组唤醒是遍历链表O(n)。原因正是等待条件个性化——系统无法用查表快速定位谁的条件满足了只能逐个问。这个复杂度差异是理解两套机制本质区别的钥匙维度信号量/邮箱/队列事件标志组等待表位图OSEventGrp/Tbl双向链表OSFlagWaitList等待条件统一等同一个对象个性化每任务不同位/模式唤醒查询两步查表 O(1)遍历链表 O(n)唤醒交付计数/消息命中的位集OSTCBFlagsRdy多条件要多次 Pend一次 Pend 组合唤醒之后任务能知道哪些条件满足了OSFlagPend 返回值就是命中的位集。所以任务不仅能被唤醒还能精确知道是哪几个条件触发的。这比信号量有信息量得多信号量唤醒只告诉你计数减一了标志组唤醒告诉你位 0 和位 3 都置位了。结合 CONSUME 语义还有一个隐藏能力一次性事件。比如按键按下这种事件如果不用 CONSUME标志位会一直保持 1任务下次 Pend 立即返回——事件被重放。加上 OS_FLAG_CONSUME 后条件满足瞬间清零事件只生效一次符合等待一个发生过的动作的语义。注意 CLR 模式的 CONSUME 方向是反的SET 模式消费 把命中的位清零CLR 模式消费 把命中的位置位——因为 CLR 等的是位为 0消费后必须置 1 才能保证下次再等时条件不成立。方向相反逻辑对称。另外两个配套 API 也值得记住OSFlagAccept是非阻塞检查——看一眼当前位集满不满足条件不满足立即返回不挂起OSFlagQuery直接读当前标志位用于诊断现在到底置了哪些位。ISR 规则与家族一致Pend 不能在中断里调用Post 可以——中断里置位通知任务是这个机制的典型用法。而超时参数依然走 OSTCBDly由节拍中断递减——第 5 篇的延时机制在这里第四次复用。三个实战场景场景一三条件启动SET_ALL CONSUMEflagsOS_FLAG_ADC_RDY|OS_FLAG_KEY_PRESS|OS_FLAG_TIMER;rdyOSFlagPend(grp,flags,OS_FLAG_WAIT_SET_ALL|OS_FLAG_CONSUME,0,err);ADC 就绪 按键按下 定时器到期一次等齐消费后这些位清零下次要重新触发。场景二数据帧完整性SET_ALL帧头收到 帧尾收到 校验通过三个位交替置位。处理任务等齐才解析少一个位就继续等——比在中断里拼状态机清晰得多。场景三多异常或处理SET_ANYflagsTEMP_OVER|PRESS_OVER|VOLT_ABNORMAL;rdyOSFlagPend(grp,flags,OS_FLAG_WAIT_SET_ANY|OS_FLAG_CONSUME,100,err);温度超限、压力超限、电压异常任一发生就报警。返回的 rdy 告诉你具体是哪个异常不用逐个查。最后聊一个设计哲学标志组把事件抽象成了电平而不是脉冲。Post 置位后位会一直保持除非消费这是电平语义——适合表达当前状态ADC 就绪了没CONSUME 消费掉是脉冲语义——适合表达发生过一次按键按下。两种语义一个 API 切换这是信号量纯计数做不到的。实际项目里常见组合状态类条件电平不消费事件类条件脉冲必消费——写代码前先想清楚每个位的语义能省掉很多标志位莫名其妙没清的 bug。什么时候用标志组什么时候用信号量补一个选型视角。信号量的强项是计数与资源管理N 个资源、N 次机会计数天然表达还剩多少。标志组的强项是条件组合一次等 N 个条件、知道哪几个满足、事件一次性消费。一个朴素的分界线如果同步的本质是等数量用信号量如果本质是等条件用标志组。采集任务等 5 个缓冲空闲是数量问题启动任务等 ADC按键定时器是条件问题。选错原语不是不能用而是语义上绕——信号量等三条件要三次 Pend 加三个全局标志等于自己手写一个标志组还少了 CONSUME 的原子性。一句话总结事件标志组是内核对象设计的一个重要分岔OS_EVENT 家族用统一条件 位图等待表换取 O(1) 唤醒标志组用个性化条件 双向链表换来条件组合表达力。多等一次 Pend 的时间比多等一个条件的时间便宜得多。回看第 6 篇到本篇信号量、互斥量、邮箱、队列共享 OS_EVENT 位图等待表标志组独自走上双向链表——内核对象家族在这里完成最后一次分岔剩下的独立机制只有内存分区和软件定时器了。这是系列第 9 篇。下一篇收官内存分区OS_MEM与软件定时器OS_TMR两套独立机制讲完 uC/OS-II 的内核全家桶。觉得有用就点个关注系列最后一篇马上来。