1. 为什么第 10 篇要停在“事件控制块”这道坎上
把 uC/OS-II 的源码翻到一半,很多人会卡在同一个地方:任务调度看懂了,时钟节拍看懂了,上下文切换也勉强能跟着堆栈指针走一遍,再往下翻到OS_EVENT相关的代码时,突然出现一堆链表指针、位图、所有权指针,还有OSEventTbl[]、OSEventFreeList这种全局变量,视线就断了。第 10 篇选在这里落脚,不是随便挑的,因为事件控制块(ECB)恰好是整个内核从“管理任务”跨到“协同任务”的那道分水岭。
前面九篇聊的基本都是“单个任务怎么活”——任务怎么建、怎么被调度、怎么切换、怎么延时。但从第 10 篇开始,问题变成“多个任务怎么配合”。信号量、互斥量、事件标志组、消息邮箱、消息队列,这五个东西在 uC/OS-II 里共用同一个底层结构,也就是事件控制块。把这层看懂,后面几个通信机制就是换皮,看不透这一层,每一个都要重新啃一遍。6736 行代码里,OS_EVENT相关逻辑大概占了七百多行,占比不算最高,却是复用度最高的一块。
这篇适合两类人:一类是刚把调度器和时钟节拍啃完、准备往任务通信推进的读者;另一类是在 GD32F103 这类 Cortex-M3 芯片上跑 uC/OS-II、被信号量和互斥量坑过、想回头补原理的工程人员。我会把OS_EVENT结构体、事件池的管理方式、等待任务链表的插入与唤醒、信号量的三段式源码、互斥量的优先级继承全部摊开,最后落到 GD32F103 上跑一遍实测,把节拍、超时、优先级这几个参数怎么定讲清楚。
提示:这篇假设你已经知道
OS_TCB、OSTCBPrioTbl[]、OSRdyTbl[]和OSRdyGrp是什么。如果这几个还没建立印象,先回看第 4 到第 6 篇,否则后面讲等待链表时会断片。
1.1 从“任务管理”到“任务协同”的转折
单任务视角的内核,核心矛盾只有一个:CPU 只有一颗,任务有 N 个,谁上谁下。调度器解决的是这个矛盾,用的是优先级位图加就绪表,O(1) 找最高优先级任务。但多任务一协同,矛盾就变了。任务 A 要等任务 B 把数据准备好才能继续跑,这期间 A 不能占着 CPU 空转,也不能直接被删掉,它得“睡着”,等 B 完成后把它“叫醒”。这就需要一个中间结构来记录:谁在等、等什么、等到了没、等超时了怎么办。
这个中间结构就是事件控制块。它承担的职责有四件:记录事件当前的状态(信号量计数、邮箱消息指针等)、记录等待这个事件的任务队列、记录事件被谁占用(互斥量专用)、以及记录等待超时用的节拍数。四个职责合在一个结构体里,这就是 uC/OS-II 能用不到 700 行搞定五种通信机制的原因。你不用为信号量写一套等待逻辑,再为邮箱写一套,全部复用同一套OSEventTaskWait和OSEventTaskRdy。这种“一结构多用”的设计思路,在资源紧张的 MCU 上是常规操作,代价是结构体里有些字段对某些机制是闲置的,比如邮箱用不到.OSEventCnt,信号量用不到.OSEventPtr。
理解这个“复用”的取舍,比背下结构体字段名更重要。因为你在读源码时会疑惑:为什么OS_EVENT里既有计数又有指针?答案不是设计冗余,是五种机制挤在同一个壳里。
1.2 6736 行里 ECB 到底占了多少分量
我按函数逐个数过一遍,和 ECB 直接相关的核心函数有这么一批:OSEventWaitListInit、OSEventTaskWait、OSEventTaskRdy、OSEventTaskWaitMulti、OS_EventTaskRdy,加上五个机制的创建、Pend、Post 系列,光信号量就是OSSemCreate、OSSemPend、OSSemPost、OSSemAccept、OSSemQuery五个。互斥量再五个,邮箱五个,队列九个左右。加起来接近四十个函数。
这四十个函数里,真正有新逻辑的其实只有三处:一是等待任务的链表插入,二是等待任务的唤醒与优先级重排,三是互斥量的优先级继承。剩下的都是套壳,参数校验、状态判断、返回错误码。所以与其一行行读,不如先把这三处新逻辑吃透,剩下的扫一眼就行。
1.3 读源码前要建立的三个认知
第一个认知:ECB 是静态池分配的,不是动态 malloc。OSEventTbl[OS_MAX_EVENTS]是编译期就固定大小的数组,OSEventFreeList串起所有空闲 ECB。OSSemCreate做的事就是从链表头摘一个下来。这意味着事件数量有上限,OS_MAX_EVENTS在OS_CFG.H里配,配小了运行时创建会返回空指针。第二个认知:等待任务是按优先级排序的,不是先进先出。这跟很多人对“队列”的直觉相反,uC/OS-II 的等待链表是优先级队列,高优先级任务先被唤醒。第三个认知:所有的阻塞都带超时参数,传 0 表示无限等待,传非 0 值走节拍递减。超时不是可有可无的装饰,它是防止死锁的最后一道保险。
这三个认知立住了,后面的源码走读就是顺水推舟。
2. 事件控制块的数据结构逐字段拆解
2.1 OS_EVENT 结构体的每个字段在干什么
翻开uCOS_II.H,OS_EVENT的定义大致是这样:
typedef struct os_event { INT8U OSEventType; void *OSEventPtr; INT16U OSEventCnt; OS_PRIO OSEventGrp; OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE]; #if OS_EVENT_NAME_EN > 0u INT8U *OSEventName; #endif } OS_EVENT;逐个说。OSEventType标类型,取值是OS_EVENT_SEM、OS_EVENT_TYPE_MUTEX、OS_EVENT_TYPE_MBOX、OS_EVENT_TYPE_Q,事件标志组在部分版本里用OS_EVENT_TYPE_FLAG。这个字段存在的意义是:OSEventTaskRdy被唤醒后要知道自己等的是什么类型的事件,好把结果写到正确的返回参数里。OSEventPtr是个万能指针,邮箱用它指消息,队列用它指队列控制块,互斥量用它指占用任务的 TCB。OSEventCnt在信号量里是计数,在互斥量里是优先级继承相关的高字节存原优先级、低字节存占用计数。
OSEventGrp和OSEventTbl[]是一对,构成等待任务的优先级位图。这套东西和就绪表OSRdyGrp/OSRdyTbl[]结构完全一致,八组每组八位,总共支持 64 个优先级。用位图而不用普通链表,是为了唤醒时能用一条查表指令找到最高优先级的等待任务,而不是遍历链表比较优先级。这套复用的做法,是整个内核调度思想的一致体现。
注意:
OSEventCnt是INT16U而非INT8U,因为互斥量要在这个 16 位里塞两个 8 位信息。信号量只用到低 16 位的全部,能支持 65535 的计数。别看到字段名带“Cnt”就以为只有信号量在用。
2.2 OSEventTbl 与 OSEventFreeList 的池化管理
事件池的管理只有两个全局变量在起作用:OSEventFreeList是空闲 ECB 单链表的头,OSEventTbl[]是存储实体。初始化函数OSEventInit把数组里每一个元素的OSEventType清零,然后把它们串成链表,OSEventFreeList指向第一个。
摘取和归还的代码也简单。OSSemCreate里第一步就是:
pevent = OSEventFreeList; if (OSEventFreeList != (OS_EVENT *)0) { OSEventFreeList = (OS_EVENT *)OSEventFreeList->OSEventPtr; }把空闲链表头往后挪一格,pevent就是摘下来的那个。归还时用OS_EventFree,把 ECB 重新挂回链表头,注意这里归还前会断言pevent->OSEventType == OS_EVENT_TYPE_UNUSED,如果类型没被清掉就说明调用方忘了清理,会触发OSEventFree的错误分支。
这套池化的好处是没有内存碎片、分配时间是常数、不需要堆。坏处是数量固定,OS_MAX_EVENTS配多少就是多少,运行期不能扩。实际项目里我的经验是按“信号量数 + 互斥量数 + 邮箱数 + 队列数”的总和至少加 20% 余量来配,因为漏删的事件在调试期很常见,配得太紧会莫名其妙创建失败。
2.3 等待链表初始化与插入
OSEventWaitListInit干的事很简单,把OSEventGrp清零,把OSEventTbl[]每个字节清零。这是创建阶段调用的。
真正做插入的是OSEventTaskWait,逻辑是:先拿到当前任务的优先级prio,然后往位图里写。
pevent->OSEventTbl[prio >> 3] |= OSMapTbl[prio & 0x07]; pevent->OSEventGrp |= OSMapTbl[prio >> 3];第一行按优先级定位到表里的字节和位,第二行把对应的组位也置上。这和就绪表的写法一模一样,只是表挂在 ECB 上而不是全局。插入完成后,任务状态从就绪改成等待,从就绪表里摘除,然后触发一次调度切换到别的任务。
2.4 唤醒时的优先级查找与就绪表回填
OSEventTaskRdy是唤醒的核心。它接收一个 ECB 指针和一个返回消息,步骤是:先用OSUnMapTbl[pevent->OSEventGrp]找到最高优先级的非空组,再用OSUnMapTbl[pevent->OSEventTbl[y]]找到组内最高优先级,拼出prio。这就是 O(1) 找最高优先级等待任务的实现,用的是查表法不是循环。
找到 prio 之后,从等待位图里清位,把任务从等待态改成就绪态,重新塞回就绪表OSRdyTbl[],并且如果有消息(邮箱、队列场景),把消息写到任务的接收变量里。最后返回这个 prio,让调用方知道该不该触发调度。
这里有一个容易被忽略的细节:唤醒比当前任务优先级高的任务时,调度在OSSemPost退出前会通过OS_Sched()立刻切换,而不是等下一个节拍。这个行为保证了高优先级任务一被唤醒就能抢占,符合实时性的需求。但反过来,如果你在中断里Post,那就不能直接调度,得靠中断退出时统一处理。
3. 信号量的三段式源码走读
3.1 OSSemCreate 到底做了三件事
OSSemCreate(cnt)的三件事。第一件,从空闲池摘一个 ECB,摘不到返回空指针,上层必须判空。第二件,初始化字段:OSEventType设为OS_EVENT_SEM,OSEventCnt设为传入的 cnt,OSEventGrp和OSEventTbl[]清零。第三件,返回 ECB 指针给用户,用户自己保存这个指针,后续所有 Pend/Post 都拿它当句柄。
cnt 传什么值是要想清楚的。传 1 表示二值信号量,类似互斥锁但没优先级继承。传大于 1 表示计数信号量。传 0 表示初始不可用,要等第一次 Post 之后才能 Pend。很多初学的人把 cnt 传错,导致第一个 Pend 就永久阻塞。
提示:
OS_MAX_EVENTS配置值如果不够,OSSemCreate返回的是空指针,而很多示例代码忽略了判空,结果后面拿空指针去 Pend,直接进OSEventTaskWait写空地址,触发硬件异常。这个坑我踩过,排查了半天才发现是池子满了。
3.2 OSSemPend 的阻塞、超时与优先级重排
OSSemPend是这个系列里最值得逐行读的函数,它的逻辑分三段。
第一段,检查计数。如果OSEventCnt > 0,说明信号量可用,直接减一,然后返回OS_NO_ERR,任务继续跑,不阻塞。这一路是快路径,绝大多数情况走这里。
第二段,如果计数为 0,走阻塞路径。先检查OSTCBCur->OSTCBDly是否被别的嵌套调用占了,然后设置超时节拍OSTCBCur->OSTCBDly = timeout,把任务用OSEventTaskWait挂到事件等待链表上,从就绪表删除,然后OS_Sched()切走,并且用OS_ENTER_CRITICAL()包住整段来找回临界区。恢复运行时,检查OSTCBCur->OSTCBDly或标记,判断是正常拿到了还是超时了,超时返回OS_TIMEOUT,正常返回OS_NO_ERR。
第三段,超时后的清理。从等待表摘除、从超时链表摘除、切换状态。
这里要注意 timeout 参数的单位是节拍不是毫秒,OS_TICKS_PER_SEC决定换算。GD32F103 上如果用 1ms 节拍,传 100 就是 100ms。传 0 是无限等待,这个选择要慎重,只有在逻辑上确定一定会被 Post 时才用,否则一个 Post 漏掉就是永久挂起。
3.3 OSSemPost 的唤醒决策与计数回补
OSSemPost的逻辑也分两路。第一路,如果等待链表为空,说明没人等,直接把OSEventCnt加一。这里有个细节:加一之前会检查是否已到65535上限,到了就不加,返回OS_SEM_OVF,防止计数溢出。
第二路,如果等待链表非空,说明有人在等,这时不走计数加一,而是直接OSEventTaskRdy唤醒等待表里优先级最高的那个任务,把它的状态改成就绪,消息置空(信号量没有数据要传),并且如果被唤醒任务的优先级高于当前任务,标记需要调度。
这两路的区别很关键:Post在有等待者时不加计数,直接把信号量“给”等待者。如果加计数再让等待者取,就会多一次加一减一的往返,效率低还容易出错。这个设计在信号量和互斥量里一致。
3.4 计数与等待表的状态关系一张表说清
| 场景 | OSEventCnt | 等待表 | Post 行为 | Pend 行为 |
|---|---|---|---|---|
| 初始 | cnt 值 | 空 | 计数加一 | 计数减一直接过 |
| 有任务阻塞 | 0 | 非空 | 直接唤醒最高优先级任务 | 挂入等待表切走 |
| 唤醒后又 Pend | 0 或被唤醒者占用 | 视情况 | 按上述两路 | 按上述两段 |
| 超时退出 | 0 | 摘除该任务 | 不涉及 | 返回 OS_TIMEOUT |
这张表是我自己画在笔记本上的,读源码时对着看,比在脑子里绕要清楚得多。信号量所有行为都能从这张表推出来。
4. 从信号量到互斥量:优先级反转的真实代价
4.1 优先级反转不是理论问题
设想三个任务:高优先级 H、中优先级 M、低优先级 L。L 先拿到了互斥量,H 随后 Pend 同一个互斥量被 L 挡住,此时 M 就绪了。M 的优先级比 L 高,所以 M 抢占 L,L 迟迟不释放互斥量,H 被 L 拖住,实际效果是 H 被 M 无限期地压着,这就是优先级反转。用信号量防止不了这个问题,因为信号量不知道谁拿着它,也没有优先级继承。互斥量存在的唯一理由就是解决它。
4.2 优先级继承的实现看两行关键代码
OSMutexPend里,当发现互斥量已被别的任务占用时,会做一件事:把占用者任务的优先级临时提升到当前 Pend 任务的优先级。源码里相关逻辑是:
if (OSTCBCur->OSTCBPrio < ppevent->OSEventCnt) { /* 高 8 位存原优先级 */ ptcb = (OS_TCB *)pevent->OSEventPtr; ... OSPrioCur = ptcb->OSTCBPrio; /* 当前运行的任务优先级也同步 */ OSTCBPrioTbl[ptcb->OSTCBPrio] = (OS_TCB *)0; ptcb->OSTCBPrio = OSTCBCur->OSTCBPrio; OSTCBPrioTbl[ptcb->OSTCBPrio] = ptcb; ... }这段做的是把占用者从原优先级位置搬到新优先级位置,同时更新OSTCBPrioTbl[],并且把原优先级存进OSEventCnt的高字节。释放的时候OSMutexPost再把优先级恢复回去。这套动作只在 Pend 阻塞时发生一次,不是每次 Pend 都做。
4.3 优先级天花板为什么在这里不用
优先级天花板是另一种方案,给互斥量预设一个最高优先级,占用者一拿到锁就升到天花板。uC/OS-II 没选它,原因是天花板优先级要人工指定、容易配错、而且要遍历所有可能用到该锁的任务算优先级。继承是动态的、自动的、对使用者透明,代价是继承链如果复杂,实现和调试都更难。实际项目里继承已经够用,天花板在通用 RTOS 里很少见。
4.4 使用互斥量的四条硬约束
第一,互斥量不能用在中断里,Pend 会阻塞,中断上下文不能阻塞。第二,一个任务不能重复 Pend 同一个互斥量,uC/OS-II 的互斥量不是可重入的,重复 Pend 会返回OS_ERR_PEND_ISR或死锁,具体看版本。第三,Pend 和 Post 必须配对,漏 Post 就是永久占用。第四,互斥量只用于保护共享资源,不要拿它当同步工具,同步用信号量或事件标志。这四条看着啰嗦,但每一条都是我在实际项目里见过翻车的点。
| 对比项 | 信号量 | 互斥量 |
|---|---|---|
| 优先级继承 | 无 | 有 |
| 计数上限 | 65535 | 二值 |
| 中断中 Post | 可以 | 可以 |
| 中断中 Pend | 可以(不阻塞路径) | 不可以 |
| 适用场景 | 同步、资源计数 | 共享资源保护 |
5. 实操:在 GD32F103 上跑一遍信号量实验
5.1 工程准备与移植要点
GD32F103 是 Cortex-M3 内核,主频 108MHz,和 STM32F103 高度兼容,移植 uC/OS-II 的路径几乎一样。移植需要改的文件集中在OS_CPU.H、OS_CPU_C.C、OS_CPU_A.ASM(或者改成内联汇编的 C 文件)。关键要做的三件事:一是实现OSStartHighRdy启动第一个任务,二是实现OSCtxSw和OSIntCtxSw完成上下文切换,三是实现OSTickISR处理时钟节拍。
节拍源我用的是 SysTick,配成 1ms 一次。OS_TICKS_PER_SEC设为 1000。如果项目不需要 1ms 的实时性,改成 10ms 能省不少中断开销,OS_TICKS_PER_SEC改成 100。参数怎么定,下一小节细说。
注意:SysTick 的中断优先级要配成最低,否则会打断其他中断里的 Post,导致临界区被重新进入。这个坑很隐蔽,表现出来是“偶尔任务卡死”,很难复现。
5.2 节拍、超时、优先级的参数计算
节拍怎么定?OSTickISR每次触发要做入队、检查超时、可能触发调度,开销固定。节拍太快,中断开销占比高,节拍太慢,超时精度差、延时粒度粗。我的经验公式是:节拍周期 ≈ 最短需要精确定时的任务的延时 / 10。如果需要 10ms 的定时精度,节拍配 1ms。
超时传多少?Pend 里的 timeout 单位是节拍。假设节拍 1ms,要给一个“最多等 200ms”的等待,传 200。要无限等就传 0,但只有确定会 Post 时才用。
优先级怎么定?uC/OS-II 优先级数字越小优先级越高,0 最高,OS_LOWEST_PRIO通常设 63,其中 62 和 63 留给统计任务和空闲任务。分配原则:硬实时任务给高优先级,会阻塞的任务给中等,后台任务给低。优先级反转发生时,互斥量只能部分缓解,真正预防靠设计时就让高优先级任务少等低优先级任务。
5.3 两个任务抢一个信号量的完整代码
我写了一个最小可跑的例子,两个任务争夺同一个信号量,观察调度顺序。
#define TASK1_PRIO 5 #define TASK2_PRIO 6 OS_STK Task1Stk[128]; OS_STK Task2Stk[128]; OS_EVENT *Sem; void Task1(void *pdata) { INT8U err; while (1) { OSSemPend(Sem, 0, &err); printf("Task1 got sem, prio 5\r\n"); OSTimeDly(50); /* 模拟占用共享资源 */ printf("Task1 release sem\r\n"); OSSemPost(Sem); OSTimeDly(100); } } void Task2(void *pdata) { INT8U err; while (1) { OSSemPend(Sem, 200, &err); /* 等 200 节拍 */ if (err == OS_NO_ERR) { printf("Task2 got sem, prio 6\r\n"); OSTimeDly(30); OSSemPost(Sem); } else { printf("Task2 timeout\r\n"); } OSTimeDly(100); } } void main(void) { INT8U err; OSInit(); Sem = OSSemCreate(1); if (Sem == (OS_EVENT *)0) { printf("sem create failed\r\n"); while (1); } OSTaskCreate(Task1, (void *)0, &Task1Stk[127], TASK1_PRIO); OSTaskCreate(Task2, (void *)0, &Task2Stk[127], TASK2_PRIO); OSStart(); }跑起来后串口的输出会按“Task1 拿、Task1 放、Task2 拿、Task2 放”交替,偶尔穿插 Task2 timeout,取决于节拍和延时配合。这个例子的意义是把前面三节讲的结构真正对应上:Pend 走快路径还是阻塞路径、Post 唤醒谁、超时怎么触发,全都能在串口日志里看到。
5.4 用串口日志验证唤醒顺序
把 Task2 的优先级改成 3(高于 Task1),再跑一次,会发现 Task2 抢到信号量的概率变高,因为等待表按优先级排序,Post 时优先唤醒高优先级。再把两个任务的优先级互换、延时参数互换,多跑几组,能直观看到优先级队列的效果。这种实验比看十遍源码管用,因为源码里的位图操作太抽象,用串口输出一对照,行为立刻具象化。
6. 常见问题与排查实录
6.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| OSSemCreate 返回空 | OS_MAX_EVENTS 太小 | 加大配置值 |
| Pend 永久阻塞 | 无人 Post 或 Post 漏调用 | 检查配对、加超时 |
| 偶发任务卡死 | SysTick 优先级过高 | 调低 SysTick 中断优先级 |
| 优先级反转 | 用信号量保护共享资源 | 换成互斥量 |
| 计数溢出 | Post 次数远大于 Pend | 检查逻辑、看 OS_SEM_OVF |
| 中断里 Pend 死机 | 中断上下文阻塞 | 改用 OSSemAccept 查询式 |
6.2 三个踩过的坑
第一个坑是临界区没配好。Cortex-M3 上关中断和开中断要用__disable_irq()和__enable_irq(),但如果用了嵌套中断,PRIMASK的操作会破坏嵌套计数。正确做法是用 BASEPRI 实现临界区,只屏蔽低于某个优先级的中断。这个改动直接写进OS_ENTER_CRITICAL宏里。
第二个坑是中断里 Post 后立即期望调度。OSSemPost里其实调用了OS_Sched,但在中断里,OSIntExit还没执行完,调度被延迟到中断退出时。如果你在 Post 之后紧跟着做依赖被唤醒任务结果的逻辑,会出错。正确做法是把状态回传放在 Post 前面,或者等中断退出后再处理。
第三个坑是调试期忘记删事件,导致池子耗尽。用一个事件场景跑几天后创建新事件失败,编译时不报错,运行时才发现。解决方式是在OS_CFG.H里OS_MAX_EVENTS留够,同时维护一份事件使用表。后来我干脆在调试版里加了一层包装函数,创建时打印事件名和剩余池量,直接解决。
提示:Keil 或 IAR 编译 uC/OS-II 时,
OS_CFG.H的配置和.c里的#define有时冲突,表现为某个功能打开后编译不过或运行异常。养成改配置只改OS_CFG.H一个地方的习惯。
我个人的体会是,uC/OS-II 的事件控制块设计最精明的地方不是用了什么高级技巧,而是把五种通信机制硬收进一个结构体、一套等待逻辑、一张位图,用最土的办法把代码量压到 6736 行。真要移植到资源更小的芯片,这套复用的思路值得抄。至于信号量和互斥量选哪个,我现在的判断标准很简单:只是任务间打个招呼用信号量,一碰共享数据就上互斥量,哪怕当前没看出反转风险,以后也省得回头改。