
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.HOS_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_SEMOSEventCnt设为传入的 cntOSEventGrp和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非空直接唤醒最高优先级任务挂入等待表切走唤醒后又 Pend0 或被唤醒者占用视情况按上述两路按上述两段超时退出0摘除该任务不涉及返回 OS_TIMEOUT这张表是我自己画在笔记本上的读源码时对着看比在脑子里绕要清楚得多。信号量所有行为都能从这张表推出来。4. 从信号量到互斥量优先级反转的真实代价4.1 优先级反转不是理论问题设想三个任务高优先级 H、中优先级 M、低优先级 L。L 先拿到了互斥量H 随后 Pend 同一个互斥量被 L 挡住此时 M 就绪了。M 的优先级比 L 高所以 M 抢占 LL 迟迟不释放互斥量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 行。真要移植到资源更小的芯片这套复用的思路值得抄。至于信号量和互斥量选哪个我现在的判断标准很简单只是任务间打个招呼用信号量一碰共享数据就上互斥量哪怕当前没看出反转风险以后也省得回头改。