【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 6 篇】
信号量只是“计数器 + 等待队列”?在 uC/OS-II 里,一个 OSSemPend 背后藏着与就绪表完全同构的等待表:同一个位图结构、同一张 OSUnMapTbl。本文从 OS_EVENT 结构出发,讲透 OSSemCreate/Pend/Post 完整流程与超时机制,读懂内核任务同步的第一块基石。
先问一个看似简单的问题:当任务调用OSSemPend()却拿不到信号量时,内核把它“藏”到了哪里?
答案是:一张和就绪表长得几乎一模一样的表。
前 5 篇我们搞定了“谁该跑”——就绪表位图、OSUnMapTbl 查表、OSTimeTick 节拍驱动。从这篇开始,要解决“谁在等、等到了没”。而设计者用一个极其优雅的方式解决了它:把“等待”也做成一张位图表,和就绪表共享同一套算法。
这就是 uC/OS-II 同步机制的基石——OS_EVENT 统一模型。
一个结构,装下四种原语
信号量(Semaphore,用于资源计数与任务同步)、邮箱、消息队列、互斥量——四种完全不同的同步原语,在 uC/OS-II 里共用同一个容器:OS_EVENT,即事件控制块(Event Control Block,ECB)。
它不是某个原语的私有结构,而是所有同步机制的统一模型。看源码(ucos_ii.h:352):
typedefstructos_event{INT8U OSEventType;/* Type of event control block(1=MBOX 2=Q 3=SEM 4=MUTEX) */void*OSEventPtr;/* 指针(邮箱/队列消息或互斥量相关,信号量不用) */INT16U OSEventCnt;/* 信号量计数值 */OS_PRIO OSEventGrp;/* 等待任务组位图(与 OSRdyGrp 同构) */OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE];/* 等待任务表(与 OSRdyTbl 同构) */}OS_EVENT;逐字段看:
- OSEventType:类型标签。1=邮箱、2=队列、3=信号量、4=互斥量。内核靠它做类型校验,防止你把一个邮箱当信号量 Post。
- OSEventPtr:通用指针。信号量完全不用它;邮箱存消息指针,互斥量存优先级继承所需的结构体。
- OSEventCnt:信号量的计数值。只有信号量用。
- OSEventGrp + OSEventTbl:等待任务组位图和等待任务表。本篇主角,所有原语共用。
OS_EVENT_TBL_SIZE被定义为OS_LOWEST_PRIO / 8 + 1,即 8。也就是说,等待表和就绪表拥有完全相同的容量。
一句话总结:OS_EVENT 是个“万能插座”,四种原语各自只取自己需要的字段。
等待表和就绪表:天生的孪生兄弟
OS_EVENT 最大的秘密,藏在最后两个字段里。
回顾前几篇的就绪表结构:OSRdyGrp(8 bit 组位图)+OSRdyTbl[8](每组 8 bit 任务位图),查最高优先级任务用OSUnMapTbl两步查表。
现在看等待表:OSEventGrp+OSEventTbl[8],结构一模一样。查最高优先级等待者,用的还是同一个OSUnMapTbl。
一个是“谁可以跑”,一个是“谁在等同一个事件”。算法完全复用,这就是设计上的“孪生”。
更关键的是,这两个表在任务状态切换时是联动的。看OS_EventTaskWait()(os_core.c:1103):
OSTCBCur->OSTCBEventPtr=pevent;/* TCB 记录等哪个事件 */pevent->OSEventTbl[OSTCBCur->OSTCBY]|=OSTCBCur->OSTCBBitX;/* 挂进等待表 */pevent->OSEventGrp|=OSTCBCur->OSTCBBitY;y=OSTCBCur->OSTCBY;OSRdyTbl[y]&=~OSTCBCur->OSTCBBitX;/* 同时从就绪表清除 */if(OSRdyTbl[y]==0u){OSRdyGrp&=~OSTCBCur->OSTCBBitY;}注意看这个对称操作:一手进等待表,一手出就绪表。
这不是巧合。任务要么在就绪表里等着被调度,要么在等待表里等着事件发生,两个状态互斥。所以“登记等待”和“摘除就绪”必须一次性完成,不能拆开,否则调度器会选中一个根本不该跑的任务。
理解了这层孪生关系,后面 Pend/Post 的代码你基本能自己读懂了。
创建信号量:从空闲链表“领养”一块 OS_EVENT
OSSemCreate()(os_sem.c:94)是所有同步原语的起点。它的流程很短:
- ISR 中调用直接返回 NULL。中断里不能创建内核对象,这是硬性约束。
- 从
OSEventFreeList空闲链表中取出一块 OS_EVENT。这块内存不是动态分配的,而是静态数组OSEventTbl[OS_MAX_EVENTS],空闲链表只是把这些块串起来管理。 - 填入
OSEventType = OS_EVENT_TYPE_SEM、OSEventCnt = cnt、OSEventName = "?",再调用OS_EventWaitListInit()把等待表全部清零。
OS_EventWaitListInit做的事和就绪表清零一样朴素:OSEventGrp 置 0,OSEventTbl 八个字节逐个清 0。等待表从"没人等"开始,谁 Pend 谁登记。
顺带区分两个概念:计数信号量(计数初始为 N,用于多资源管理,比如 5 个串口)和二进制信号量(计数只取 0/1,用于任务同步,比如"数据就绪"通知)。uC/OS-II 没有独立的二进制信号量类型——初始化时传 1 就是二进制语义。但注意:用信号量做互斥有风险——高优先级任务可能被低优先级任务持锁阻塞,这就是著名的"优先级翻转",第 7 篇的互斥量专门解决它。
至此,一个全新的信号量诞生了。它的等待表空空如也,计数就是初值。
OSSemPend:计数不够,就把任务”搬进”等待表
OSSemPend(pevent, timeout, perr)是信号量最核心的 API。先看关键路径(os_sem.c:323-360):
OS_ENTER_CRITICAL();if(pevent->OSEventCnt>0u){/* 计数>0:资源现成 */pevent->OSEventCnt--;/* 直接减计数,不等待 */*perr=OS_ERR_NONE;return;}/* 否则:进入等待 */OSTCBCur->OSTCBStat|=OS_STAT_SEM;/* 1) 置”等信号量”状态位 */OSTCBCur->OSTCBStatPend=OS_STAT_PEND_OK;OSTCBCur->OSTCBDly=timeout;/* 2) 超时写进 TCB */OS_EventTaskWait(pevent);/* 3) 挂等待表 + 清就绪表 */OS_EXIT_CRITICAL();OS_Sched();/* 4) 让出 CPU */OS_ENTER_CRITICAL();switch(OSTCBCur->OSTCBStatPend){/* 5) 被唤醒后判断结果 */caseOS_STAT_PEND_OK:*perr=OS_ERR_NONE;break;caseOS_STAT_PEND_ABORT:*perr=OS_ERR_PEND_ABORT;break;caseOS_STAT_PEND_TO:OS_EventTaskRemove(OSTCBCur,pevent);/* 超时:从等待表摘除 */*perr=OS_ERR_TIMEOUT;break;}OSTCBCur->OSTCBStat=OS_STAT_RDY;/* 清理状态恢复就绪 */OSTCBCur->OSTCBStatPend=OS_STAT_PEND_OK;OSTCBCur->OSTCBEventPtr=(OS_EVENT*)0;读这段代码有三个关键点:
第一,快路径。计数大于 0 时,Pend 就是一次”减一然后返回”,连等待表都不碰。这是信号量最常见的用法——资源本来就有。
第二,慢路径的五步。计数为 0 时:置状态位(OS_STAT_SEM)→ 把 timeout 写进 OSTCBDly(衔接第 5 篇的延时机制)→ OS_EventTaskWait 挂等待表/清就绪表 → OS_Sched 让出 → 被唤醒后按结果分支。timeout 传 0 表示永不超时——OSTCBDly 为 0,节拍中断根本不会递减它。
第三,超时路径为什么多一步 OS_EventTaskRemove。看第 5 篇:节拍到期时 OSTimeTick 只做了”清状态位 + 置位就绪表”,没有把任务从等待表摘除。所以 Pend 的 switch 里要补这一步——否则任务还在等待表里,下次 Post 会把它再”唤醒”一次。这个细节是信号量与延时机制的交汇点。
还有一组前置守卫:ISR 里调用返回 OS_ERR_PEND_ISR、调度器锁定返回 OS_ERR_PEND_LOCKED、类型不对返回 OS_ERR_EVENT_TYPE——都在进入临界区之前拦截。
这组校验透露了一个设计取向:uC/OS-II 在性能与安全之间选了一个中间点。OS_ARG_CHK_EN 开启时所有 API 都做参数校验(pevent 空指针、类型标签),关闭时这些检查全部编译掉——第 1 篇讲过的配置驱动,在这里就是"牺牲一点健壮性换几个周期"的选择。
再补充一个容易踩的坑:Pend 之后必须检查 err。任务被唤醒有三种结果:正常拿到(OS_ERR_NONE)、被 PendAbort 中止(OS_ERR_PEND_ABORT)、超时(OS_ERR_TIMEOUT)。只检查 err==NONE 才访问共享资源,是信号量使用的铁律——否则超时醒来还去操作资源,等于在毫无保护的情况下访问。
OS_EventTaskRdy:唤醒,只叫一个人
等信号量的任务怎么被叫醒?看 OSSemPost 的对手戏。OS_EventTaskRdy(os_core.c:1028)是唤醒的核心:
y=OSUnMapTbl[pevent->OSEventGrp];/* 等待表两步查表,找最高优先级等待者 */x=OSUnMapTbl[pevent->OSEventTbl[y]];prio=(y<<3u)+x;ptcb=OSTCBPrioTbl[prio];ptcb->OSTCBDly=0u;/* 防止节拍提前/重复唤醒 */ptcb->OSTCBStat&=~msk;/* 清等待状态位 */ptcb->OSTCBStatPend=pend_stat;if((ptcb->OSTCBStat&OS_STAT_SUSPEND)==OS_STAT_RDY){OSRdyGrp|=ptcb->OSTCBBitY;/* 置位就绪表 */OSRdyTbl[y]|=ptcb->OSTCBBitX;}OS_EventTaskRemove(ptcb,pevent);/* 从等待表移除 */又是两步查表——等待表复用就绪表那套 OSUnMapTbl 算法。找出来的不是”任意一个等待者”,而是等待者里优先级最高的那个。
细节一:OSTCBDly = 0。如果任务带着超时在等(OSTCBDly 非 0),Post 唤醒时先把它清零——防止节拍中断晚一拍到达,把刚被唤醒的任务又当成”超时”处理。
细节二:唤醒后从等待表移除。等待表只登记”还在等”的任务,被叫醒的马上摘除,腾出位置。
OSSemPost:先看有没有人等,再决定加不加计数
Post 的逻辑只有三行核心(os_sem.c:488-499):
if(pevent->OSEventGrp!=0u){/* 有任务在等? */(void)OS_EventTaskRdy(pevent,(void*)0,OS_STAT_SEM,OS_STAT_PEND_OK);/* 唤醒最高优先级等待者 */OS_EXIT_CRITICAL();OS_Sched();/* 立刻看调度 */return(OS_ERR_NONE);}if(pevent->OSEventCnt<65535u){/* 没人等 → 计数 +1 */pevent->OSEventCnt++;return(OS_ERR_NONE);}return(OS_ERR_SEM_OVF);/* 溢出保护 */这里藏着信号量最重要的语义:有等待者时,Post 把”信号”直接转交给等待者,计数不增加。
为什么?设想一个反例:计数初始 1,任务 A Pend 拿到(计数 0),任务 B 再 Pend(挂起等)。此时如果 Post 先计数 +1 再唤醒 B,B 被唤醒后按”快路径”减一——看起来没事,但中间多了一个状态:如果唤醒和调度之间又有任务 C 插进来 Pend,C 会把那个 +1 抢走,B 就白等了。先转交后计数,从机制上杜绝了这种竞态。计数只有在”没人等”时才累加,等下次有人 Pend 时直接拿走。
另一个细节:Post 可以在 ISR 里调用(Pend 不行)——这是中断”通知”任务的标准姿势,第 4 篇的 ISR 纪律里提过。
三任务场景:一次完整的 Pend/Post 推演
系统里三个任务:A(优先级 5)、B(优先级 3)、C(优先级 10),一个计数为 0 的信号量。
- A 调 OSSemPend(sem, 0, &err):计数 0 → 慢路径。OS_STAT_SEM 置位、OSTCBDly=0、OS_EventTaskWait 把 A 挂进 sem 的等待表(OSEventTbl[0] bit5,OSEventGrp bit0)、A 从就绪表消失 → OS_Sched 切到 B。
- B 调 OSSemPend(sem, 0, &err):同样挂起。等待表现在有两个人:OSEventGrp=0x01,OSEventTbl[0]=0x28(bit3 和 bit5)。
- C 调 OSSemPost(sem):等待表非空 → OS_EventTaskRdy 两步查表:y=OSUnMapTbl[0x01]=0,x=OSUnMapTbl[0x28]=3 → prio=3,唤醒 B(最高优先级等待者)→ B 清状态、置就绪、摘等待表 → OS_Sched:就绪表里 B(3) 比 C(10) 优先级高,C 刚 Post 完就被 B 抢走 CPU——这就是抢占。
- B 恢复运行,从 Pend 返回 OS_ERR_NONE。A 继续在等待表里。
这个推演还揭示一个易错点:Post 唤醒的永远是等待者里优先级最高的,不一定是”先来先等”的那个——信号量没有排队公平性,高优先级可以插队。
什么时候用信号量?三个典型场景:资源计数(N 个同类资源、N 次机会)、任务同步(生产者/消费者握手)、ISR 通知任务(Post from ISR)。什么时候不用:互斥用 OSMutex(第 7 篇)、传数据用邮箱/队列(第 8 篇)——选错原语不是性能问题,是正确性问题。
为什么四种原语要共用 OS_EVENT
回到篇首的问题。信号量、邮箱、队列、互斥量,等待/唤醒的机制 90% 相同:等待表登记、就绪表联动、HPT 两步查表、超时处理、ISR 规则。差异只在两处——触发条件(计数、指针、队列缓冲)和唤醒后的数据交付(OSEventPtr、OSTCBMsg)。
共用 OS_EVENT 意味着:等待逻辑只写一遍,四个模块各自只写差异部分。这也解释了为什么 OS_EVENT 的注释敢说”这就是全部”——它确实就是全部。
总结
- OS_EVENT 是四种同步原语的统一容器,等待表与就绪表同构、共享 OSUnMapTbl
- OSSemPend 快路径减计数返回;慢路径五步挂起(状态位→超时→进等待表→让出→结果判断)
- 超时路径的 OS_EventTaskRemove 补齐了 OSTimeTick 没做的工作
- OS_EventTaskRdy 唤醒最高优先级等待者,OSTCBDly 清零防节拍误唤醒
- OSSemPost 先转交后计数,从机制上杜绝信号竞态;ISR 可 Post 不可 Pend
- 信号量唤醒无公平性,高优先级可插队
下一篇看 OS_EVENT 上最复杂的应用:互斥量。为什么普通信号量会导致优先级翻转?优先级继承(PIP)是怎么救场的?到时见。
