资讯动态

uC/OS-II事件控制块、信号量与互斥量源码实战解析

发布时间:2026/9/18 9:31:26 来源:尧图企业网站定制
上一篇把时间管理那块啃完之后我本来打算直接跳到消息队列结果翻了两页 os_q.c 就发现绕不过去——uC/OS-II 里的消息队列、邮箱、信号量、互斥量、事件标志组这五样东西底层用的是同一个结构体、同一张等待表、同一个唤醒函数。这 6736 行内核源码里os_core.c 有一大半篇幅都在服务这套机制。如果不先把 OS_EVENT 这一层吃透后面看 OS_Q.c 和 OSFlag.c 基本就是看天书只能靠猜。这篇是第 6 篇主题就锁定在事件控制块和它承载的同步原语上。具体说我会把 OS_EVENT 结构体逐字段拆开把 OSSemCreate / OSSemPend / OSSemPost 三个函数的执行路径走一遍再重点讲互斥信号量的优先级继承在代码里到底长什么样最后在 GD32F103 上把实验跑起来用调试器看内存里的真实变化。适合谁看如果你已经能看懂 OSTaskCreate 和 OSSched知道就绪表和 OSTCBPrioTbl 是干什么的那这篇正好接得上。如果你连 OS_ENTER_CRITICAL 都还没搞明白建议先回去补前面几篇不然读到优先级继承那段会有点吃力。1. 第 6 篇为什么绕不开事件控制块1.1 任务能跑起来了但任务之间还不会说话前面几篇我们把任务管理、调度器、时间管理这三块过完了。到这一步你的工程里应该能创建出几个任务让它们各自按不同优先级跑起来也能用 OSTimeDly 做延时。但这时候你会发现一个问题任务之间是孤立的。举个特别常见的场景。一个串口接收任务负责收数据一个解析任务负责处理数据。接收任务收完一帧怎么告诉解析任务我这儿有货了最原始的办法是搞一个全局变量当标志位接收任务置 1解析任务循环检测这个变量。这种写法能跑但它有两个致命问题一是轮询浪费 CPU二是这个标志位本身不是原子操作中断里改一下、任务里读一下就可能出现竞争。真要严格一点还得自己关中断开中断代码很快就乱成一团。uC/OS-II 给出的答案就是事件控制块Event Control Block简称 ECB。它是一个统一的数据结构用来描述有人在等某个事情发生以及这个事情现在处于什么状态。信号量、互斥量、消息邮箱、消息队列、事件标志组全都挂在它上面。理解了这个结构体你会发现这五种同步对象其实是一家人只是字段的用法不一样。1.2 6736 行里事件相关代码的分布第一次看到6736 行这个数字的时候我也有点怀疑——一个能跑在 Cortex-M3 上的实时内核核心代码才这么点后来自己动手数了一遍才明白这个数字通常是不含注释、不含移植层的纯内核逻辑。按这个口径拆一下各文件的量级大概是这样文件大致量级主要职责os_core.c最厚约占总量的三成初始化、调度、事件等待表、事件唤醒、就绪表维护os_task.c次厚任务创建、删除、挂起、恢复、优先级变更os_flag.c单人占比很高事件标志组逻辑最绕的一块os_q.c / os_mbox.c中等消息队列与邮箱os_sem.c / os_mutex.c很薄信号量与互斥量各只有几个函数os_time.c / os_tmr.c中等偏薄延时与软件定时器os_mem.c薄固定大小内存块管理有意思的地方在这里os_sem.c 和 os_mutex.c 加起来可能都不到两百行纯逻辑但它们的含金量极高。因为这两个文件里的函数几乎每一行都在跟 os_core.c 里的事件等待表打交道。所以读源码的顺序应该是先读 os_core.c 里的 OS_EventWaitListInit、OS_EventTaskWait、OS_EventTaskRdy、OS_EventTaskRemove 这四个函数把底层的等待表机制搞清楚再回头看 os_sem.c就会觉得异常简单。1.3 一个结构体统一五种同步对象为什么要用统一结构体这背后是一个很实际的内存考量。如果没有统一结构体那信号量得有 SEM 结构、队列得有 Q 结构、邮箱得有个 MBOX 结构每种都要单独维护一个空闲池每种都要写一套创建、删除、等待、唤醒的逻辑。代码量会翻好几倍RAM 开销也更大。uC/OS-II 的做法是定义一个 OS_EVENT把所有同步对象共用的字段抽出来各对象特有的数据通过指针挂载或者通过字段复用实现。这样整个内核只需要维护一个空闲 ECB 池创建任何同步对象都是从同一个池子里摘块删除就是还回去。代价是什么代价是 OSEventCnt 和 OSEventPtr 这两个字段会在不同对象类型下被赋予完全不同的含义读代码的时候必须随时带着当前这个 ECB 是什么类型的意识。这是初学者最容易翻车的地方——看到一个pevent-OSEventPtr赋值如果不看上下文判断类型根本猜不出它指向什么。2. OS_EVENT 结构体逐字段解剖2.1 五个字段各自管什么先把结构体原样贴出来这段代码在 uCOS_II.H 里是整个同步机制的地基typedef struct os_event { INT8U OSEventType; /* 事件类型见 OS_EVENT_TYPE_xxxx */ void *OSEventPtr; /* 指向消息、队列控制块或持有者 TCB */ INT16U OSEventCnt; /* 信号量计数 / 互斥量的优先级信息 */ OS_PRIO OSEventGrp; /* 等待任务优先级分组位图 */ OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任务优先级位图表 */ #if OS_EVENT_NAME_EN 0u INT8U *OSEventName; /* 事件名称调试用 */ #endif } OS_EVENT;OSEventType 是身份证取值有这么几种#define OS_EVENT_TYPE_UNUSED 0u #define OS_EVENT_TYPE_MBOX 1u #define OS_EVENT_TYPE_Q 2u #define OS_EVENT_TYPE_SEM 3u #define OS_EVENT_TYPE_MUTEX 4u #define OS_EVENT_TYPE_FLAG 5u这个字段在函数入口几乎一定会被检查。比如 OSSemPend 一进来就会判断pevent-OSEventType ! OS_EVENT_TYPE_SEM不是信号量直接返回 OS_ERR_EVENT_TYPE。很多人调试时遇到莫名其妙的 OS_ERR_EVENT_TYPE八成是把消息队列的指针当成信号量传进去了。OSEventGrp 和 OSEventTbl 是两个配合使用的位图字段它们合起来表示当前有哪些任务在等这个事件。OSEventGrp 的每一位对应 8 个优先级OSEventTbl 里每个元素对应 8 个连续优先级。这种两级位图的设计和就绪表 OSRdyGrp / OSRdyTbl 完全一样目的就是让找最高优先级等待者这个操作变成查表而不是遍历。2.2 OSEventPtr 是个变形金刚这是最容易看懵的字段它在不同事件类型下的含义完全不同信号量SEM不用创建时被赋为 NULL。消息邮箱MBOX指向那条消息本身。消息队列Q指向一个 OS_Q 控制块真正的消息队列结构挂在那边。互斥量MUTEX指向当前持有者任务的 TCB这个用法后面讲优先级继承时会重点说。事件标志组FLAG指向 OS_FLAG_GRP 结构。你可能会问为什么邮箱不也搞个控制块因为邮箱本质上就是一条消息的搬运没有多条消息排队的需求所以直接把消息指针塞进去最省事。这就是内核设计里典型的够用就好。2.3 空闲 ECB 池与 OSEventFreeList整个内核的 ECB 是静态分配的数组定义在 os_cfg_r.h 或者 uCOS_II.C 里OS_EVENT OSEventTbl[OS_MAX_EVENTS];OS_MAX_EVENTS 是你自己配的默认 10做实际项目我一般给到 20 到 32。这个数组里的每一项在 OSInit 阶段被串成一条单向链表用 OSEventPtr 字段当链表指针链表头是全局变量 OSEventFreeList。所以 OSEventPtr 还有个隐藏身份当这个 ECB 还躺在空闲池里的时候它存的是下一个空闲 ECB 的地址。创建时把它摘出来删除时再塞回去。这就解释了 OSSemCreate 里那段看着有点怪的代码为什么能工作——它在给 OSEventPtr 赋 NULL 之前已经用它的值推进过空闲链表了。这个设计要记住一个直接影响OS_MAX_EVENTS 是编译期常量配小了运行到一半 OSSemCreate 会返回 NULL 指针而且不会给你任何报错只能自己判空。我第一次踩这个坑的时候现象是任务莫名其妙跑飞查了半天才想起来加判空。3. 信号量的三个核心函数逐行读3.1 OSSemCreate从空闲链表摘一个块出来OS_EVENT *OSSemCreate (INT16U cnt) { OS_EVENT *pevent; OS_CPU_SR cpu_sr 0u; if (OSIntNesting 0u) { /* 中断里不允许创建 */ return ((OS_EVENT *)0); } OS_ENTER_CRITICAL(); pevent OSEventFreeList; if (OSEventFreeList ! (OS_EVENT *)0) { OSEventFreeList (OS_EVENT *)OSEventFreeList-OSEventPtr; } OS_EXIT_CRITICAL(); if (pevent ! (OS_EVENT *)0) { pevent-OSEventType OS_EVENT_TYPE_SEM; pevent-OSEventCnt cnt; /* 初始计数值 */ pevent-OSEventPtr (void *)0; OS_EventWaitListInit(pevent); /* 等待表清零 */ } return (pevent); }这段代码信息量不小我拆四点说。第一进来先查 OSIntNesting。信号量的创建涉及链表操作必须在临界区里做中断上下文里做这个没有意义所以内核直接拒绝返回空指针。这就是为什么你必须在 OSInit 之后、OSStart 之前把所有同步对象创建好。第二摘链表这一步真的只需要关中断保护因为改动的是全局的 OSEventFreeList。摘完就可以开中断了后面填字段的过程不需要保护——这个 ECB 现在只有当前任务能访问别人还不知道它的存在。第三OS_EventWaitListInit 干的事情非常朴素void OS_EventWaitListInit (OS_EVENT *pevent) { INT8U i; pevent-OSEventGrp 0u; for (i 0u; i OS_EVENT_TBL_SIZE; i) { pevent-OSEventTbl[i] 0u; } }就是把分组位图和位图表全清零。注意这里没有给 OSEventCnt 赋值因为那是创建函数自己的事。这个初始化函数被所有五种同步对象的创建函数共用。第四cnt 参数的含义。传 0 表示资源暂时不可用谁拿谁等这是最常用的写法常用于任务间同步。传 N 表示初始就有 N 个资源可用常用于资源计数。传 1 就是典型的二值信号量。这里有个小细节OSEventCnt 是 INT16UOSSemPost 里的溢出检查是if (pevent-OSEventCnt 65535u)也就是说计数值上限 65535超过就返回 OS_ERR_SEM_OVF。如果你把它当计数器用疯狂 Post 不 Pend跑到 65535 就会开始报错。3.2 OSSemPend能拿就拿拿不到就挂起void OSSemPend (OS_EVENT *pevent, INT16U timeout, INT8U *perr) { OS_CPU_SR cpu_sr 0u; if (pevent-OSEventType ! OS_EVENT_TYPE_SEM) { *perr OS_ERR_EVENT_TYPE; return; } if (OSIntNesting 0u) { /* 中断里不能 Pend */ *perr OS_ERR_PEND_ISR; return; } if (OSLockNesting 0u) { /* 调度器被锁不能挂起 */ *perr OS_ERR_PEND_LOCKED; return; } OS_ENTER_CRITICAL(); if (pevent-OSEventCnt 0u) { /* 有资源直接拿走 */ pevent-OSEventCnt--; OS_EXIT_CRITICAL(); *perr OS_ERR_NONE; return; } /* 没资源挂起自己 */ OSTCBCur-OSTCBStat | OS_STAT_SEM; OSTCBCur-OSTCBStatPend OS_STAT_PEND_OK; OSTCBCur-OSTCBDly timeout; OS_EventTaskWait(pevent); OS_EXIT_CRITICAL(); OS_Sched(); /* 让出 CPU */ OS_ENTER_CRITICAL(); switch (OSTCBCur-OSTCBStatPend) { case OS_STAT_PEND_OK: *perr OS_ERR_NONE; break; case OS_STAT_PEND_ABORT: *perr OS_ERR_PEND_ABORT; break; case OS_STAT_PEND_TO: default: 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; OS_EXIT_CRITICAL(); }这是整个信号量机制里最值得反复读的函数。它的结构是一次快路径 一次慢路径 一次收尾。快路径就是前半个 if计数值大于 0减一退出临界区返回。整个过程全在临界区内不会被中断打断所以不会出现两个任务同时把计数从 1 减到 -1 的情况。这就是信号量的原子性所在。慢路径分支里OSTCBStat 上或了 OS_STAT_SEM 标志这是给调度器和唤醒函数看的标记这个任务正卡在信号量上。OSTCBDly 被设为 timeout这就是超时机制的起点后面 OSTimeTick 每来一个节拍会把所有 OSTCBDly 非 0 的任务减一减到 0 就调 OS_EventTaskRemove 把它从等待表摘掉同时把 OSTCBStatPend 置成 OS_STAT_PEND_TO。于是 OSSemPend 恢复运行后一进 switch 就命中 timeout 分支。这里有个特别关键的点timeout 传 0 表示永久等待因为 OSTCBDly 为 0 时OSTimeTick 里压根不会去处理这个任务。很多人以为 0 表示立即返回这是典型的误解我第一次用的时候也踩过。再看最后的收尾三行把 OSTCBStat 清成 OS_STAT_RDY、把 OSTCBEventPtr 置空这是状态复原。为什么唤醒路径里不顺手做掉因为唤醒路径和超时路径都要走这三行写在 Pend 函数末尾才能保证两条路径都覆盖到。这种让被挂起的任务自己收拾残局的思路在 uC/OS-II 里反复出现理解了这个模式后面读队列和邮箱的 Pend 就轻松了。3.3 OSSemPost唤醒优先级最高的等待者INT8U OSSemPost (OS_EVENT *pevent) { OS_CPU_SR cpu_sr 0u; if (pevent-OSEventType ! OS_EVENT_TYPE_SEM) { return (OS_ERR_EVENT_TYPE); } OS_ENTER_CRITICAL(); 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) { /* 没人在等计数加一 */ pevent-OSEventCnt; OS_EXIT_CRITICAL(); return (OS_ERR_NONE); } OS_EXIT_CRITICAL(); return (OS_ERR_SEM_OVF); }逻辑分支很清晰但里面有两个点必须点出来。第一个点是pevent-OSEventGrp ! 0u这个判断。它其实是在问等待表里还有没有活人因为 OSEventGrp 是等待表的一级位图只要有一个任务在等某一位就是 1。这个判断成本极低比遍历 OSEventTbl 快多了这就是位图设计的价值。第二个点是 OS_Sched() 的位置。它被放在 OS_EXIT_CRITICAL() 之后而不是之前。顺序很重要如果先调度再退出临界区那新任务跑起来的时候中断还是关着的整个系统的实时性就废了。uC/OS-II 里所有唤醒后立即调度的地方都是这个顺序。这里还有个容易被忽略的行为OS_Sched() 执行后如果被唤醒的任务优先级比当前任务高当前任务会被立即打断Post 后面的代码要等高优先级任务跑完或者被它自己阻塞才轮到。这意味着你在 Post 之后写的日志打印输出的顺序往往会让你迷惑。我在一个双任务串口实验里就吃过这个亏Post 之后的 post done 永远比消费者任务的 get ok 后打印出来一开始还以为哪里出错了其实是抢占式调度在正常工作。3.4 等待表的位图算法与 OSUnMapTbl唤醒动作的真正核心在 os_core.c 的 OS_EventTaskRdyINT8U OS_EventTaskRdy (OS_EVENT *pevent, void *pmsg, INT8U msk, INT8U pend_stat) { OS_TCB *ptcb; INT8U y; INT8U x; INT8U prio; INT8U bity; INT8U bitx; y OSUnMapTbl[pevent-OSEventGrp]; /* 找出第一个非空分组 */ bity (INT8U)(1u y); x OSUnMapTbl[pevent-OSEventTbl[y]]; /* 找出分组内最高优先级 */ bitx (INT8U)(1u x); if ((pevent-OSEventTbl[y] (OS_PRIO)~bitx) 0u) { pevent-OSEventGrp ~bity; /* 该分组空了清掉 */ } prio (INT8U)((y 3u) x); /* 还原出优先级 */ ptcb OSTCBPrioTbl[prio]; ptcb-OSTCBDly 0u; /* 关掉超时计时 */ ptcb-OSTCBMsg pmsg; ptcb-OSTCBStat (INT8U)~msk; /* 清掉等待标志 */ ptcb-OSTCBStatPend pend_stat; ptcb-OSTCBEventPtr (OS_EVENT *)0; OSRdyGrp | bity; /* 挂进就绪表 */ OSRdyTbl[y] | bitx; return (prio); }OSUnMapTbl 是一张 256 字节的常量表作用就是给定一个字节返回最低的那个置 1 位的下标。比如输入 0x06二进制 0000 0110返回 1。有了它找最高优先级等待者只需要两次查表加两次移位常数时间完成跟等待任务的数量没关系。对比一下另一种常见实现维护一个按优先级排序的链表插入时按顺序找位置唤醒时直接取链表头。那种做法唤醒快但插入慢而且链表操作在中断里做容易出问题。uC/OS-II 选了位图是典型的用空间换确定性。这里有个配置细节值得注意上面这段代码是 OS_LOWEST_PRIO 63 时的位图版本。一旦你把优先级上限设置到 64 以上内核会切到另一套实现等待表不再是位图而是用数组承载的链表插入和删除都要遍历。函数体在 os_core.c 里是被#if OS_LOWEST_PRIO 63u分开的两个分支。所以对绝大多数项目来说把 OS_LOWEST_PRIO 保持在 63 以内性能最好代码也最好读。4. 互斥信号量优先级继承落到代码上的样子4.1 优先级翻转到底怎么发生的信号量能解决互斥问题但用二值信号量做互斥会引入一个经典问题优先级翻转。描述一下场景三个任务 A优先级 3最高、B优先级 5中、C优先级 8最低。C 先跑了拿到了信号量进临界区干活。这时候 A 就绪了抢占 CA 也想拿这个信号量拿不到挂起等。好了现在轮到 C 继续跑——但注意B 就绪了。B 的优先级比 C 高于是 B 把 C 抢占掉B 开始跑。如果 B 跑很久那么 A 就得等 B 跑完、C 跑完、C 释放信号量之后才能继续。结果就是一个中优先级的任务 B间接地把最高优先级的任务 A 阻塞了。A 的响应时间被完全不确定的 B 拖长了。这就是优先级翻转在实时系统里是能直接导致控制周期超时的事故级问题。解决办法叫优先级继承当 A 因为等信号量而挂起时把持有者 C 的优先级临时提到和 A 一样高这样 B 就没法抢占 C 了。C 快速跑完临界区、释放信号量优先级恢复原样A 第一时间拿到信号量。uC/OS-II 把这件事做成了单独的互斥量类型也就是 OSMutex。这里顺便说一句 RTOS 面试里被问烂的题信号量和互斥量有什么区别标准答案里最实的一条就是互斥量带优先级继承二值信号量不带。用二值信号量做互斥就是把优先级翻转的风险留着。4.2 OSMutexPend 里的继承动作互斥量的 OSEventCnt 被拆成了两个字节用这是整个 uC/OS-II 里最巧妙也最容易看糊涂的设计#define OS_MUTEX_KEEP_LOWER_8 0x00FFu #define OS_MUTEX_KEEP_UPPER_8 0xFF00u #define OS_MUTEX_AVAILABLE 0x00FFu创建互斥量时是这样的OS_EVENT *OSMutexCreate (INT8U prio, INT8U *perr) { ... OSTCBPrioTbl[prio] OS_TCB_RESERVED; /* 关键把这个优先级预订掉 */ pevent OSEventFreeList; if (pevent ! (OS_EVENT *)0) { OSEventFreeList (OS_EVENT *)pevent-OSEventPtr; pevent-OSEventType OS_EVENT_TYPE_MUTEX; pevent-OSEventCnt (INT16U)((INT16U)prio 8u) | OS_MUTEX_AVAILABLE; pevent-OSEventPtr (void *)0; /* 无持有者 */ OS_EventWaitListInit(pevent); } ... }高字节存的是这个互斥量的优先级上限也就是你创建时传进来的 prio一旦设定就再也不变。低字节存的是当前持有者的优先级未持有时是 OS_MUTEX_AVAILABLE低 8 位全 1。因为 OS_LOWEST_PRIO 默认是 63所以低字节的全 1 值绝不会和任何合法优先级冲突这个编码很省空间。最需要划重点的是那行OSTCBPrioTbl[prio] OS_TCB_RESERVED;。它在优先级表里插了个占位符导致这个优先级在系统里被永久保留你不能再用这个优先级去创建任务。如果你后面调用 OSTaskCreate 传了同一个优先级会直接返回 OS_ERR_PRIO_EXIST。这条规则背后的理由很直白优先级继承需要一个天花板。所有可能用这个互斥量的任务优先级都必须比互斥量的 prio 更低数值更大这样继承的时候才有空间把持有者提到那个天花板位置。如果你传的 prio 比使用者还低继承就无从谈起OSMutexPend 会返回 OS_ERR_PIP_LOWER。所以创建互斥量时传的那个 prio规则是要高于所有会用到它的任务。Pend 时的继承逻辑我用结构化的方式把关键动作列出来完整实现里还有一层循环处理持有者自己也在等别的资源的嵌套情况这里只呈现主干OS_ENTER_CRITICAL(); if ((pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8) OS_MUTEX_AVAILABLE) { /* 没人持有直接把低字节写成本任务优先级Ptr 指向自己的 TCB */ pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; pevent-OSEventCnt | OSTCBCur-OSTCBPrio; pevent-OSEventPtr (void *)OSTCBCur; if (OSTCBCur-OSTCBPrio pip) { /* 本任务优先级必须高于天花板 */ OS_EXIT_CRITICAL(); *perr OS_ERR_PIP_LOWER; return; } OS_EXIT_CRITICAL(); *perr OS_ERR_NONE; return; } /* 有人持有准备继承 */ ptcb (OS_TCB *)pevent-OSEventPtr; /* 拿到持有者 TCB */ if (ptcb-OSTCBPrio OSTCBCur-OSTCBPrio) { *perr OS_ERR_MUTEX_OWNER; /* 自己已经持有重复 Pend */ return; } if (OSTCBCur-OSTCBPrio ptcb-OSTCBPrio) { /* 当前任务优先级比持有者高才需要继承 */ if (ptcb-OSTCBStat OS_STAT_PEND_ANY) { OS_EventTaskRemove(ptcb, ptcb-OSTCBEventPtr); /* 持有者也在等别的先摘下来 */ } else { /* 从就绪表里摘掉 */ OS_RdyTbl[ptcb-OSTCBPrio 3u] (INT8U)~(1u (ptcb-OSTCBPrio 0x07u)); if (OS_RdyTbl[ptcb-OSTCBPrio 3u] 0u) { OSRdyGrp (INT8U)~(1u (ptcb-OSTCBPrio 3u)); } } OSTCBPrioTbl[ptcb-OSTCBPrio] (OS_TCB *)0; /* 旧优先级位置腾空 */ ptcb-OSTCBPrio OSTCBCur-OSTCBPrio; /* 优先级借过来 */ ptcb-OSTCBY (INT8U)(ptcb-OSTCBPrio 3u); ptcb-OSTCBX (INT8U)(ptcb-OSTCBPrio 0x07u); ptcb-OSTCBBitY (INT8U)(1u ptcb-OSTCBY); ptcb-OSTCBBitX (INT8U)(1u ptcb-OSTCBX); OSTCBPrioTbl[ptcb-OSTCBPrio] ptcb; /* 挂到新优先级 */ OSRdyGrp | ptcb-OSTCBBitY; /* 重新进就绪表 */ OSRdyTbl[ptcb-OSTCBY] | ptcb-OSTCBBitX; }看着长但逻辑就一句话把持有者从就绪表里摘出来改掉它的优先级和 X/Y 位域再挂回就绪表。之所以要摘了再挂是因为就绪表是按优先级位图索引的优先级一变位置就全变了必须重新定位。这也是为什么 TCB 里要冗余存 OSTCBY、OSTCBX、OSTCBBitY、OSTCBBitX 四个字段——用空间换时间避免每次都要现算。4.3 OSMutexPost 的优先级归还与移交释放的时候要做两件事如果有等待者把所有权移交如果自己是被提过优先级的恢复原优先级。INT8U OSMutexPost (OS_EVENT *pevent) { INT8U pip; INT8U prio; ... if (pevent-OSEventType ! OS_EVENT_TYPE_MUTEX) { return (OS_ERR_EVENT_TYPE); } if (OSIntNesting 0u) { /* 中断里不能释放互斥量 */ return (OS_ERR_POST_ISR); } OS_ENTER_CRITICAL(); pip (INT8U)(pevent-OSEventCnt 8u); /* 天花板 */ prio (INT8U)(pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8);/* 当前持有者 */ if (OSTCBCur-OSTCBPrio ! prio) { OS_EXIT_CRITICAL(); return (OS_ERR_NOT_MUTEX_OWNER); /* 你不是持有者不能释放 */ } if (OSTCBCur-OSTCBPrio ! pip) { /* 优先级被提过要还回去 */ OSTCBPrioTbl[OSTCBCur-OSTCBPrio] (OS_TCB *)0; OSTCBCur-OSTCBPrio pip; OSTCBCur-OSTCBY (INT8U)(pip 3u); OSTCBCur-OSTCBX (INT8U)(pip 0x07u); OSTCBCur-OSTCBBitY (INT8U)(1u OSTCBCur-OSTCBY); OSTCBCur-OSTCBBitX (INT8U)(1u OSTCBCur-OSTCBX); OSTCBPrioTbl[pip] OSTCBCur; } if (pevent-OSEventGrp ! 0u) { /* 有等待者直接移交 */ prio OS_EventTaskRdy(pevent, (void *)0, OS_STAT_MUTEX, OS_STAT_PEND_OK); pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; pevent-OSEventCnt | prio; /* 新持有者的优先级 */ pevent-OSEventPtr OSTCBPrioTbl[prio]; if (prio pip) { /* 新持有者不能高于天花板 */ OS_EXIT_CRITICAL(); return (OS_ERR_PIP_LOWER); } OS_EXIT_CRITICAL(); OS_Sched(); return (OS_ERR_NONE); } pevent-OSEventCnt | OS_MUTEX_AVAILABLE; /* 没人等标记空闲 */ pevent-OSEventPtr (void *)0; OS_EXIT_CRITICAL(); return (OS_ERR_NONE); }这里有个设计很值得玩味释放时直接移交不需要先置空闲再让等待者去拿。OS_EventTaskRdy 唤醒等待者之后紧接着就把 OSEventCnt 的低字节写成了新持有者的优先级OSEventPtr 指向新持有者的 TCB。等被唤醒的任务真正恢复执行时它已经在 OSMutexPend 的收尾阶段了压根不需要再判断一次计数。这种所有权在 Post 里完成交割的做法避免了一个中间态窗口很干净。恢复优先级那段也很有讲究注意它是在移交之前做的。因为如果先把所有权交给别人自己还是被提升的优先级就绪表里就有两个同优先级的 TCB 了这是内核不允许的。顺序错了会直接触发断言或者更诡异的行为。4.4 用错互斥量的三种典型姿势我在项目里见过、自己也犯过的错误基本就这三种第一种用二值信号量做互斥。代码能跑压力测试也不一定出问题但在一个高优先级采集任务、一个中优先级通讯任务、一个低优先级存储任务的系统里就会出现采集任务被通讯任务间接拖慢。这种 bug 特别难查因为单独测每个任务都正常只有三个任务同时存在时才偶发。第二种创建互斥量的优先级选错。有人随手传 OS_LOWEST_PRIO-1 或者直接传使用者任务的优先级前者会导致继承天花板形同虚设后者会直接返回 OS_ERR_PRIO_EXIST因为传的优先级正好被某个任务占用了。正确做法是找一个比所有使用者都高、又没被任何任务占用的优先级。第三种在中断里释放互斥量。OSMutexPost 会直接返回 OS_ERR_POST_ISR。有些同学在中断里收到数据想顺手释放一个互斥量通知任务结果发现没生效。中断和任务之间做同步应该用信号量或者消息队列它们允许在中断里 Post通过 OSIntExit 触发调度。互斥量在语义上就要求谁持有谁释放中断不是持有者自然不该释放。5. 两个配置开关如何改变实现5.1 OS_LOWEST_PRIO 越过 63 之后OS_LOWEST_PRIO 这个宏决定了系统支持多少个优先级默认是 63也就是优先级 0 到 63 共 64 级。这个数字不是随便定的它跟位图实现强相关。当 OS_LOWEST_PRIO 63 时OSEventTbl 的每一项是 8 位用 8 个元素就能覆盖 64 个优先级两级查找正好用两个字节做索引OSUnMapTbl 也只需要 256 字节。整套机制非常紧凑。一旦你把 OS_LOWEST_PRIO 提到 64 以上8 位的分组就装不下了。uC/OS-II 的处理方式是放弃位图把 OSEventTbl 数组改成承载链表的容器插入等待的时候要沿着链表找位置删除的时候要遍历找节点。这时候唤醒最高优先级等待者从常数时间退化成了线性时间。所以我的建议很明确没特殊需求就不要动 OS_LOWEST_PRIO。真有几十上百个任务的项目优先考虑用任务优先级复用加任务内部状态机而不是把优先级数量堆上去。真需要动态创建删除任务的场景uC/OS-II 的优先级数量也够用——实际上一个 Cortex-M3 项目里超过 20 个任务就已经很难维护了。5.2 OS_EVENT_NAME_EN 与调试观感OS_EVENT_NAME_EN 是新版本 uC/OS-II 加进来的开启之后 OSEvent 里多一个名字指针字段每个 ECB 多占 4 字节。看起来是负担但调试时价值极大。开之前调试器里看到的是pSem1、pSem2、pSem3你得翻代码确认哪个是哪个。开之后配合 OSEventNameSet 或者创建函数可以直接在 watch 窗口里看到 UartRxSem、SpiBusMutex 这样的字符串名字。多花几十字节 RAM省下的是每次调试都要翻源码的时间。我一般只在对 RAM 极度敏感的量产版本里关掉它Debug 版本一定打开。5.3 中断上下文里能调用什么、不能调用什么这一块规则必须记牢因为它直接决定系统会不会死锁函数能否在 ISR 中调用说明OSSemPost可以内部走 OS_EventTaskRdy由 OSIntExit 触发切换OSSemPend不可以返回 OS_ERR_PEND_ISROSMutexPost不可以返回 OS_ERR_POST_ISROSMutexPend不可以返回 OS_ERR_PEND_ISROSMboxPost可以中断里投递消息的标准做法OSQPost可以用 OSQPost不用 OSQPostFront 之外要小心队列满时行为要提前想清楚OSTimeDly不可以会去操作调度器中断里绝对禁止OSSemCreate不可以返回空指针规律其实很好总结通知别人的动作可以在中断里做把自己挂起的动作绝对不行。因为中断服务程序本身不是一个任务没有 TCB没有就绪状态你怎么挂起它挂起之后谁负责恢复它另外还有个常被忽略的细节在 ISR 里 Post 的时候如果唤醒了更高优先级的任务切换发生在 OSIntExit 里不是在 Post 函数内部。这是和任务上下文里的 Post 最大的区别任务里是直接 OS_Sched()中断里要等所有嵌套中断退出后在 OSIntExit 的最后判断。所以你的中断服务程序应该是 OSIntEnter、干活、OSIntExit 这个标准三段式中间别漏了 OSIntEnter否则中断嵌套计数不准切换时机全乱。6. 在 GD32F103 上把信号量实验跑一遍6.1 移植层要改的几处GD32F103 是 Cortex-M3 内核主频最高可以跑到 108MHz这点和不少同类芯片的 72MHz 不一样动手前要确认自己用的是哪档。uC/OS-II 官方没提供这个芯片的移植包但 Cortex-M3 是通用的直接把官方的 Cortex-M3 移植层拿过来改几处就能用。os_cpu.h 里的关键几个宏#define OS_CRITICAL_METHOD 3u #define OS_STK_GROWTH 1u typedef unsigned int OS_STK; typedef unsigned int OS_CPU_SR; #define OS_ENTER_CRITICAL() {cpu_sr OS_CPU_SR_Save();} #define OS_EXIT_CRITICAL() {OS_CPU_SR_Restore(cpu_sr);}OS_CRITICAL_METHOD 用 3 是必须的。这一档的语义是进临界区前保存 PRIMASK出临界区时恢复而不是简单地开中断。这个区别在嵌套临界区里特别重要如果内层退出时无脑开中断外层的临界区就被破坏了。OS_CPU_SR_Save 和 OS_CPU_SR_Restore 是两条短短的汇编OS_CPU_SR_Save MRS R0, PRIMASK CPSID I BX LR OS_CPU_SR_Restore MSR PRIMASK, R0 BX LR任务切换走 PendSV这是 Cortex-M 的标准做法OSCtxSw 触发 PendSV真正的上下文保存在 PendSV_Handler 里做。好处是任务切换的优先级可以设成最低不会打断正在处理的硬中断。时基用 SysTick在 OSStart 之前调用一次初始化OS_CPU_SysTickInit(SystemCoreClock / OS_TICKS_PER_SEC);这里 SystemCoreClock 是按实际主频算的。如果芯片跑 108MHzOS_TICKS_PER_SEC 是 1000那么重装载值就是 108000。这个值算出错了表现是所有延时都按比例不准——我调过一个 1.5 倍的偏差查了半天发现是系统时钟配置和 SystemCoreClock 变量不一致。SysTick 的中断服务程序void SysTick_Handler(void) { OSIntEnter(); OSTimeTick(); OSIntExit(); }串口输出我用的 USART0PA9 做 TX、PA10 做 RX115200 波特率。注意uC/OS-II 里如果要用标准库 printf需要重定向 fputc而且 printf 本身很占栈任务栈至少给 256 以上单位是 OS_STKCortex-M3 上是 4 字节也就是 1KB 以上。我一般会自己写一个只支持十进制和字符串的轻量打印函数栈开销小很多。6.2 一个生产者-消费者的最小实验代码很短但覆盖了完整链路#include includes.h #define TASK_STK_SIZE 256 OS_STK TaskProducerStk[TASK_STK_SIZE]; OS_STK TaskConsumerStk[TASK_STK_SIZE]; OS_EVENT *pSem; INT8U err; void TaskProducer(void *pdata) { INT16U i 0; for (;;) { OSTimeDlyHMSM(0, 0, 0, 200); /* 每 200ms 供一次货 */ OSSemPost(pSem); uart_printf(produce %u\r\n, i); } } void TaskConsumer(void *pdata) { INT32U count 0; for (;;) { OSSemPend(pSem, 0, err); /* 0 永久等待 */ if (err OS_ERR_NONE) { count; uart_printf(consume, total%lu\r\n, count); OSTimeDlyHMSM(0, 0, 0, 500); /* 故意慢一点模拟处理耗时 */ } } } int main(void) { sysclk_config(); uart_init(115200); OSInit(); pSem OSSemCreate(0); /* 初始不可用 */ if (pSem (OS_EVENT *)0) { uart_printf(sem create failed, check OS_MAX_EVENTS\r\n); for (;;); } OSTaskCreate(TaskProducer, (void *)0, TaskProducerStk[TASK_STK_SIZE - 1], 5); OSTaskCreate(TaskConsumer, (void *)0, TaskConsumerStk[TASK_STK_SIZE - 1], 6); OSStart(); return 0; }有几个细节我说一下为什么这么写。信号量初始计数传 0不是 1。这决定了消费者任务启动后第一次 Pend 会阻塞直到生产者第一次 Post。如果传 1消费者会先消费掉那个凭空多出来的令牌第一轮行为就对不上了。这个初始值的选择是按资源一开始有没有来定不要想当然写 1。生产者周期 200ms消费者处理耗时 500ms这是故意设计的。这样生产者会跑在前面信号量计数会累积。串口输出会看到 consumer 打印的间隔是 500ms而 produce 的间隔是 200ms两者速率不一致正是信号量计数语义的体现。如果你把信号量当成纯粹的事件通知用那就该在消费者 Pend 成功后把计数清零——但 uC/OS-II 的信号量不是二值清零语义它会一直累加这里要特别注意超过 65535 会返回 OS_ERR_SEM_OVF。任务栈指针传的是TaskProducerStk[TASK_STK_SIZE - 1]数组最后一个元素。因为 Cortex-M3 的栈是向下生长的OS_STK_GROWTH 1传栈顶地址。这是个高频错误点传成TaskProducerStk[0]会导致栈往下长到数组外面改掉别的变量。我第一次写的时候就是这么错的现象是任务里的局部变量莫名变成 0。6.3 用调试器观察 ECB 的内存变化这段实验最有意思的部分是在调试器里看内存。把 pSem 加到 watch 窗口展开它指向的结构体然后在几个断点上观察。在消费者任务第一次 Pend 之前你会看到 OSEventCnt 是 0OSEventGrp 是 0OSEventTbl 全是 0。Pend 挂起之后再看OSEventGrp 变成了 0x01OSEventTbl[0] 变成了 0x40——消费者优先级是 66 除以 8 是 0所以落在第 0 个元素6 对 8 取模是 6所以是该元素的第 6 位也就是 0x40。这两个数字能对上说明等待表机制确实是这么工作的。然后让生产者 Post。单步跟进 OSSemPost你会看到 OS_EventTaskRdy 执行之后OSEventGrp 和 OSEventTbl[0] 立刻被清零同时就绪表里消费者的位置被置 1。如果你此时去看 OSRdyTbl 和 OSRdyGrp也能看到对应的位变化。做互斥量实验的话还能观察到更有意思的现象创建一个 prio 为 3 的互斥量之后去 watch 窗口看 OSTCBPrioTbl[3]它的值不是 0而是一个特殊的非零值——这就是那个 OS_TCB_RESERVED 占位符。这个观察能让你一下就明白为什么互斥量的优先级不能再被任务使用。7. 常见问题与排查技巧实录7.1 症状任务卡死不再切换这个现象通常是所有任务都不跑了或者只剩一两个任务在跑用调试器看 OSRdyGrp 是 0。第一步先查信号量的 Pend / Post 配对。最常见的原因是在某个分支里 Pend 了但没 Post比如错误处理路径里 return 之前忘了释放。用调试器看那个 ECB 的 OSEventCnt 是不是 0、OSEventGrp 是不是非 0、OSEventPtr 指向哪个 TCB 的地址就能反推出是谁持有没释放。第二步查任务栈是否溢出。uC/OS-II 有 OSTaskStkChk 可以查栈使用量需要 OSTaskCreateExt 创建如果你用的是 OSTaskCreate那就得靠人工估算或者用调试器看栈底附近的填充值有没有被改。栈溢出会破坏 TCB 或者相邻任务的栈症状千奇百怪。第三步查是不是在某个临界区里卡住了。如果某个函数的 OS_ENTER_CRITICAL 和 OS_EXIT_CRITICAL 没有配对比如中间有个 return 直接跳出去了中断就永远关着SysTick 进不来OSTimeTick 不跑所有延时任务全部永远挂起。这个 bug 特别隐蔽因为代码看起来没错。7.2 症状超时不生效一等等到天荒地老明明写了 OSSemPend(pSem, 100, err)结果等了几秒钟也不返回。先确认 OS_TICKS_PER_SEC 和你实际的 SysTick 中断频率是不是一致的。如果 OS_TICKS_PER_SEC 配的是 1000但 SysTick 实际只有 100Hz那么 100 个 tick 就是 1 秒而不是 100 毫秒——不会永不返回但会慢十倍。如果真的是永不返回检查一个细节OSTCBDly 是否被设成了 0。看 OSSemPend 的代码OSTCBCur-OSTCBDly timeout;这行如果 timeout 传的是 0OSTimeTick 就不会处理这个任务。检查你的调用处有没有传错参数尤其是用变量传的时候。还有一种情况是 OSTimeTick 被挡住了。比如在高优先级任务里写了个超长的忙等循环SysTick 中断虽然还能进因为它的优先级够高但如果忙等是在临界区里中断进不来时间就不走了。7.3 常见问题速查表现象高频原因排查动作OSSemCreate 返回 NULLOS_MAX_EVENTS 配小了或空闲池耗尽调大 OS_MAX_EVENTS检查是否有泄漏的 ECB 没释放返回 OS_ERR_EVENT_TYPE传进来的指针类型不对核对创建时用的类型和 Pend/Post 的匹配关系返回 OS_ERR_PEND_ISR在中断服务程序里调了 Pend改成在中断里 Post任务里 Pend返回 OS_ERR_PEND_LOCKED调度器被 OSSchedLock 锁住了检查 OSSchedLock / OSSchedUnlock 是否配对返回 OS_ERR_MUTEX_OWNER同一个任务重复 Pend 同一个互斥量互斥量不支持递归持有要么改设计要么换信号量返回 OS_ERR_NOT_MUTEX_OWNER非持有者调用了 OSMutexPost检查是不是在错误的上下文里释放返回 OS_ERR_PRIO_EXIST互斥量优先级被任务占用了换一个没被任务使用的优先级返回 OS_ERR_PIP_LOWER使用者优先级高于互斥量天花板把互斥量的 prio 设得比所有使用者都高返回 OS_ERR_SEM_OVF信号量计数超过 65535检查是不是只 Post 不 PendPost 之后日志顺序错乱高优先级任务被立即抢占正常现象不是 bug日志加时间戳或任务 ID 再看8. 一点自己的经验讲完信号量和互斥量我想再分享两个实际项目里总结出来的习惯。第一个习惯信号量永远只做通知资源互斥永远走互斥量。刚工作那会儿图省事一个二值信号量走天下觉得反正都能实现互斥。后来在一个带优先级继承需求的场合吃了亏才发现两种原语的语义是有分野的。判断标准很简单——如果这个资源会被多个任务抢而且抢不到的任务优先级差异较大那就必须用互斥量如果只是我干完了通知你一声用信号量而且可以考虑用事件标志组更合适。第二个习惯每个同步对象都给它起名字并且在头文件里集中声明。我在工程里会建一个 os_res.h把所有信号量、互斥量、队列的指针统一 extern 出来创建函数集中放在一个地方按固定顺序创建。这样第一创建顺序固定避免了谁先创建谁先拿到池子的不确定性第二排查的时候一眼能看到全部同步对象不用满工程搜 OSSemCreate。再补一个关于读源码的体会。uC/OS-II 这套事件机制真正的精髓不在 os_sem.c 或 os_mutex.c而在 os_core.c 里那四个事件函数。它们加起来可能不到三百行但消息队列、邮箱、信号量、互斥量、事件标志组这五种对象全都建立在它们上面。我在读第二遍的时候特地只盯着这四个函数看把每一行的位操作都手算一遍画在纸上之后再看任何同步对象的创建和等待函数脑子里都能自动展开成等待表的变化过程。这个方法比逐文件顺读有效得多推荐你也试试。下一篇我打算把消息队列和邮箱放在一起讲重点说说 OSMboxPost 里那条消息覆盖的坑还有 OSQPost 在队列满时的两种行为该怎么选。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价