十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

uC/OS-II事件机制源码拆解:信号量、互斥量与优先级继承

uC/OS-II事件机制源码拆解:信号量、互斥量与优先级继承 任务能创建、能调度、能被 tick 叫醒这在前九篇里已经翻得差不多了。但一个只有调度、没有对话机制的内核是跑不起实际项目的采集任务拿到了数据谁来通知处理任务两个任务同时想写同一片 Flash谁先谁后第 10 篇正好卡在这个位置——同步与互斥。uC/OS-II 在这一块的写法非常抠门也很聪明信号量、互斥量、邮箱、消息队列这四种看起来八竿子打不着的东西全部由同一个结构体OS_EVENT承载等待逻辑也只有OS_EventTaskWait、OS_EventTaskRdy、OS_EventTO这三个函数在干活。整份内核源码拼起来大约 6736 行而事件机制相关的部分加起来不到 400 行却撑起了 RTOS 里最常被考、也最容易用错的那套 API。这篇就把这段代码从头到尾拆开重点讲清楚三件事等待列表为什么用位图而不用链表、OSSemPend那段挂起—调度—醒来再检查的三段式为什么必须这么写、以及互斥量的优先级继承到底改了哪些字段。1. OS_EVENT一个结构体吞下四种同步原语1.1 结构体里每个字段都不是随便放的先把uCOS_II.H里那个结构体抄出来看理解它基本就理解了一半的设计意图typedef struct os_event { INT8U OSEventType; /* 事件类型SEM / MUTEX / MBOX / Q */ void *OSEventPtr; /* 消息指针、队列控制块、互斥量持有者 */ INT16U OSEventCnt; /* 信号量计数 / 互斥量高低字节复用 */ OS_PRIO OSEventGrp; /* 等待任务的优先级组8 位 */ OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任务的优先级表位图 */ } OS_EVENT;注意OS_EVENTCnt是一个 16 位变量但没有哪个事件类型真的会用到它的全部 16 位。信号量只用到整个字段做计数互斥量把它劈成两半用——高字节放 PIP低字节放当前持有者的优先级0xFF表示锁是空闲的。这种一个字段两种解释的做法在讲究可读性的现代代码里会被骂但在 6736 行要装下一整个内核的前提下它是实打实省 RAM 的手段。OSEventType取值只有四个OS_EVENT_TYPE_SEM1、OS_EVENT_TYPE_MUTEX2、OS_EVENT_TYPE_MBOX3、OS_EVENT_TYPE_Q4。每次调用OSSemPend、OSMutexPost这类函数第一件事就是校验类型对不对不对直接返回OS_ERR_EVENT_TYPE。所以千万别把信号量的句柄传给互斥量的 API——虽然它们类型都是OS_EVENT *编译器一点忙都帮不上你。1.2 OSEventPtr 的双重身份空闲时它是链表指针OSEventPtr这个字段的用法最能体现这套设计的物尽其用事件状态OSEventCnt 的含义OSEventPtr 的含义信号量剩余计数未使用保持 0互斥量高字节 PIP低字节持有者优先级或 0xFF持有者 TCB 指针邮箱未使用消息指针消息队列未使用队列控制块指针空闲事件块未使用指向下一个空闲事件块关键在于最后一行。事件块是静态分配的OS_InitEventList()在系统初始化时把OSEventTbl[OS_MAX_EVENTS]这个数组串成一条单向空闲链表串联用的就是这个OSEventPtrstatic void OS_InitEventList (void) { INT16U i; OS_EVENT *pevent1; OS_EVENT *pevent2; pevent1 OSEventTbl[0]; pevent2 OSEventTbl[1]; for (i 0; i (OS_MAX_EVENTS - 1); i) { pevent1-OSEventType OS_EVENT_TYPE_UNUSED; pevent1-OSEventPtr pevent2; /* 用消息指针串下一个空闲块 */ pevent1; pevent2; } pevent1-OSEventType OS_EVENT_TYPE_UNUSED; pevent1-OSEventPtr (OS_EVENT *)0; OSEventFreeList OSEventTbl[0]; }OSSemCreate做的事情就是从这条链表上摘下一个节点然后把OSEventPtr清成 0。整个过程没有任何malloc时间也是常数。这一点是 uC/OS-II 和 Linux 在同步机制上最本质的分野之一Linux 的信号量、互斥锁、等待队列都是运行时动态分配的kmalloc、内核对象、futex 的哈希桶好处是数量不受限、语义丰富代价是分配失败和延迟抖动uC/OS-II 反过来数量在编译期由OS_MAX_EVENTS钉死创建永远不会失败在内存不够上——除非你配少了。提示OS_MAX_EVENTS配小了OSSemCreate会返回空指针而不是报错。如果在后面漏判了这个返回值你会在OSSemPend里访问地址 0现象是一进调试器就 HardFault堆栈里看不出和信号量有半点关系。这个坑我踩过不止一次。1.3 创建时该做的初始化少一个都会出怪问题OSSemCreate拿到空闲块之后干的活很短但每一条都必要pevent-OSEventType OS_EVENT_TYPE_SEM; pevent-OSEventCnt cnt ? cnt : 1; /* 计数为 0 时当 1 处理 */ pevent-OSEventPtr (void *)0; OS_EventWaitListInit(pevent); /* 清空等待位图 */OS_EventWaitListInit只是把OSEventGrp置 0 并把OSEventTbl[]全清。这一步漏掉会怎样内存里的垃圾值会让OSEventGrp非 0于是第一次有人OSSemPost内核会认为有人在等跑去OS_EventTaskRdy里用OSUnMapTbl查一个根本不存在的等待者然后拿着一个野优先级去OSTCBPrioTbl[]里取 TCB——直接跑飞。这种 bug 的可怕之处在于它依赖上电时 RAM 的随机内容可能十次里九次正常。OSSemCreate传 0 时被当成 1 也是有意为之很多人的心智模型里信号量创建出来就该是可用的传 0 反而是少数派需求。想创建初始不可用的信号量传 1 然后在用之前OSSemPend一次也行但更干净的做法是传 0——部分版本会严格按传入值初始化移植时值得翻一眼自己手上那份源码。2. 等待列表为什么是位图不是链表2.1 O(1) 找最高优先级OSUnMapTbl 这张表就是核心先回答一个几乎所有读源码的人都会问的问题等待某个信号量的任务为什么不用链表串起来而要费劲搞两个 8 位变量加一张查表答案只有两个字确定性。链表唤醒要遍历遍历耗时随等待者数量变化位图唤醒是固定几步。RTOS 里最忌讳的就是耗时随负载变化尤其在OSSemPost可能从定时器中断里被调用的情况下多花几十个周期都可能吃掉下一个中断的余量。具体怎么查OSEventGrp是 8 位每一位代表等待位图里第 n 行有等待者OSEventTbl[]的每一行每个元素也是 8 位代表 8 个具体优先级。找最高优先级等待者就是两次查表static void OS_EventTaskRdy (OS_EVENT *pevent, void *msg, INT8U msk) { OS_TCB *ptcb; INT8U x, y, prio; y OSUnMapTbl[pevent-OSEventGrp]; /* 先找有等待者的最高行 */ x OSUnMapTbl[pevent-OSEventTbl[y]]; /* 再找该行里最高的那一位 */ prio (INT8U)((y 3) x); /* 拼成 0~63 的优先级 */ if ((pevent-OSEventTbl[y] (INT8U)~OSMapTbl[x]) 0) { pevent-OSEventGrp (INT8U)~OSMapTbl[y]; /* 整行空了才清组位 */ } ptcb OSTCBPrioTbl[prio]; ptcb-OSTCBDly 0; /* 别再让 tick 叫我了 */ ptcb-OSTCBEventPtr (OS_EVENT *)0; ptcb-OSTCBStat (INT8U)~msk; /* 清掉我在等某类事件标志 */ pevent-OSEventPtr msg; OSRdyGrp | ptcb-OSTCBBitY; OSRdyTbl[ptcb-OSTCBY] | ptcb-OSTCBBitX; /* 塞回就绪表 */ }OSUnMapTbl[256]是一张 256 字节的常量表作用是给定一个字节返回最低位为 1 的位置。比如OSUnMapTbl[0x0C] 20x0C 是二进制 1100最低的 1 在第 2 位。这张表在调度器里找最高优先级就绪任务时也用同一套逻辑所以内核里只存一份。有人会问为什么位图里位号越小优先级越高因为 uC/OS-II 规定优先级数值越小优先级越高0 是最高。位号直接等于优先级数值OSUnMapTbl天然返回最低位正好对应最高优先级不需要任何反转操作。这是设计对齐得很漂亮的一处。2.2 位掩码在任务创建时就算好了上面那段代码里出现了ptcb-OSTCBBitY和ptcb-OSTCBBitX。这两个字段不是运行时算的而是在OSTaskCreate里一次性预计算并塞进 TCB 的ptcb-OSTCBY (INT8U)(prio 3); /* 优先级落在位图的第几行 */ ptcb-OSTCBX (INT8U)(prio 0x07); /* 第几列 */ ptcb-OSTCBBitY OSMapTbl[ptcb-OSTCBY]; /* 行掩码 */ ptcb-OSTCBBitX OSMapTbl[ptcb-OSTCBX]; /* 列掩码 */省下的是每次任务切换、每次事件收发都要做的移位和建表操作。任务切换可能每秒发生上万次这几条移位累积起来是实打实的开销。对这种把计算前移到创建阶段的优化读源码时值得专门留意——RTOS 里几乎所有耗时操作都被这么处理过。顺便记住一个数字OS_EVENT_TBL_SIZE的定义是(OS_LOWEST_PRIO / 8) 1。默认OS_LOWEST_PRIO 63所以表是 8 字节加上OSEventGrp一字节整个等待位图 9 字节最多支持 64 个优先级。把OS_LOWEST_PRIO改大改小会连带影响 TCB 和事件块的大小改的时候要重新算 RAM 预算。2.3 OS_EventTaskWait 的三步顺序不能乱和唤醒相对的是挂起OS_EventTaskWait总共三行有效代码static void OS_EventTaskWait (OS_EVENT *pevent) { OSTCBCur-OSTCBEventPtr pevent; /* (1) 记住我在等哪个事件 */ if ((OSRdyTbl[OSTCBCur-OSTCBY] ~OSTCBCur-OSTCBBitX) 0) { OSRdyGrp ~OSTCBCur-OSTCBBitY; /* (2) 从就绪表里摘掉自己 */ } pevent-OSEventTbl[OSTCBCur-OSTCBY] | OSTCBCur-OSTCBBitX; pevent-OSEventGrp | OSTCBCur-OSTCBBitY; /* (3) 挂进事件等待位图 */ }第一步为什么要先记OSTCBEventPtr因为一个任务只能等一个事件等到醒来时它自己并不知道是谁把它叫醒的必须靠这个指针回溯。而它的清理责任分散在两个地方被正常唤醒时由OS_EventTaskRdy清零超时醒来时由OS_EventTO清零。如果哪天你发现任务醒来后OSTCBEventPtr还指着某个事件说明唤醒路径被改坏了。第二步的写法也有讲究先清行内位如果这一行变成 0 了才清组位。如果无脑清组位同一行里其他还挂着的任务就永远醒不过来了。这个if (... 0)判断在就绪表、事件等待表里到处出现是位图维护的统一规范。3. OSSemPend 的三段式挂起、让出、醒来再检查3.1 快路径能拿到就绝不进等待逻辑OSSemPend开头是一串校验顺序不能换先看事件类型对不对防止把互斥量或邮箱的句柄传进来再看OSIntNesting中断里不允许 pend再看OSLockNesting调度器被锁住时不允许 pend。校验通过了走快路径OS_ENTER_CRITICAL(); if (pevent-OSEventCnt 0) { /* 有资源直接拿走 */ pevent-OSEventCnt--; OS_EXIT_CRITICAL(); *perr OS_NO_ERR; return; }整个过程关中断的时间只有几条指令。这是信号量最常见的场景——资源池里还有货取完就走不涉及任何调度。如果绝大多数取信号量的操作都能走快路径那系统的实时性就不会被同步机制拖累这也是为什么信号量计数要用先判断后递减而不是递减后判断是否小于 0注意Linux 的信号量down()是先减再判断负值因为它的语义是计数为负代表等待者数量。uC/OS-II 反过来先判断再递减因为在关中断的临界区里少一次写内存就少一点中断延迟。3.2 慢路径置标志、设超时、进等待位图、开中断再调度拿不到资源时才是真正的重头戏OSTCBCur-OSTCBStat | OS_STAT_SEM; /* 标记我在等信号量 */ OSTCBCur-OSTCBDly timeout; /* 超时倒计时0 表示无限等 */ OS_EventTaskWait(pevent); /* 从就绪表摘下挂进等待位图 */ OS_EXIT_CRITICAL(); /* 必须先开中断 */ OS_Sched(); /* 让出 CPU找下一个该跑的任务 */ OS_ENTER_CRITICAL();这四步里有三个关键点踩错任何一个都会出问题。第一OS_STAT_SEM这个标志是唤醒后的唯一凭据。它不只是一个状态记录而是后面二次检查的判据。清它的只有OS_EventTaskRdy也就是说只有被别人 post 唤醒这条路会清掉它超时唤醒不会碰它。第二OS_EXIT_CRITICAL()必须在OS_Sched()之前。原因是OS_Sched内部自己会进临界区、查找最高优先级就绪任务、做上下文切换如果你在外面还关着中断整个系统会直接停摆。有人会担心开中断到调度之间任务会不会被别人的中断抢走答案是会但那正好符合预期——中断里 post 了这个信号量的话OS_EventTaskRdy会把当前任务塞回就绪表并清掉OS_STAT_SEM接下来OS_Sched一执行当前任务会因为它自己的就绪位还在而被重新选中醒来后直接判定拿到信号量了完全不用等下一轮 tick。这个窗口不是漏洞是特性。第三uC/OS-II 的临界区实现方式是保存状态—恢复状态而不是关中断计数。OS_ENTER_CRITICAL把当前中断状态存进cpu_sr局部变量OS_EXIT_CRITICAL恢复它。这意味着成对使用时可以嵌套而且不会因为忘记配对而永久关中断局部变量出栈就没了。代价是每个函数都要自己声明OS_CPU_SR cpu_sr 0;并且严格配对漏写一个OS_EXIT_CRITICAL的效果是那个函数里临界区跑完直接返回——中断被开着数据就乱了。3.3 醒来之后的二次检查才是这段代码的灵魂被OS_Sched让出去之后任务会从被别人 post 唤醒或者超时唤醒两条路回来回到这里OS_ENTER_CRITICAL(); if (OSTCBCur-OSTCBStat OS_STAT_SEM) { /* 标志还在说明是超时醒的 */ OS_EventTO(pevent); /* 主动把自己从等待位图摘掉 */ OS_EXIT_CRITICAL(); *perr OS_TIMEOUT; return; } OSTCBCur-OSTCBEventPtr (OS_EVENT *)0; /* 正常唤醒清理指针 */ OS_EXIT_CRITICAL(); *perr OS_NO_ERR;这个醒来先看标志的写法是 RTOS 编程里必须刻进肌肉记忆的东西。它等价于告诉你信号量 API 的返回值必须每次都判不能默认函数返回就代表拿到了。很多新手写出来的代码是这样OSSemPend(SemHandle, 100, err); /* 拿了就不管了 */ read_adc(); /* 直接当成拿到资源了 */如果这 100 个 tick 内没有等到任务照样往下走读到的是脏数据甚至野指针。正确写法永远是if (err OS_NO_ERR) { ... }。这个坑和malloc之后不判空属于同一类只不过更隐蔽因为大多数时候它能工作。3.4 OSSemPost唤醒一个等待者还是给计数加一发送侧的逻辑是二选一if (pevent-OSEventGrp ! 0) { /* 有人在等 */ prio OS_EventTaskRdy(pevent, (void *)0, OS_STAT_SEM); OS_EXIT_CRITICAL(); OS_Sched(); /* 唤醒后立刻看看要不要切 */ } else if (pevent-OSEventCnt 65535) { /* 没人在等计数加一 */ pevent-OSEventCnt; OS_EXIT_CRITICAL(); }这里有三个细节值得单独说。先看有人在等这一支它把信号量直接交接给等待者而不是计数加一然后等待者来取。这两种实现语义上等价但直接交接少了一次无意义的状态变化。更关键的是OS_EventTaskRdy内部会把OSTCBDly清零——被交接的任务不会在下一秒因为超时又自己醒一次。再看没人在等的溢出保护。计数上限是 65535加不上去就干脆丢弃这次 post而不是回绕。信号量计数回绕的后果是灾难性的本来应该阻塞的任务因为计数从 0 变成 65535 而全部放行资源瞬间被打爆。丢弃则只是丢了一次通知配合协议层的重试还能恢复。在 RTOS 里宁可丢事件也不能让同步原语失去约束力。最后是OS_Sched的位置。它在临界区外被调用作用是如果刚才唤醒的任务优先级比我高现在就切过去。但如果你是在中断服务函数里 post 的OSIntNesting 0会让OS_Sched直接返回——真正的切换留到OSIntExit里由 PendSV 触发。这个分工很多人搞混以为OSSemPost里那个OS_Sched就是切换点其实中断场景下的切换点永远在OSIntExit。常用错误码汇总一下调试时对号入座能省不少时间错误码触发条件常见原因OS_NO_ERR正常-OS_ERR_EVENT_TYPE句柄类型不匹配把信号量句柄传给了互斥量 APIOS_ERR_PEND_ISR在中断里调用 Pend在 ISR 里等资源必须改成 deferred 处理OS_ERR_PEND_LOCKED调度器被锁时调用 Pend在OSSchedLock区间里取信号量OS_TIMEOUT超时未等到超时值偏小或上游任务忘记 postOS_ERR_PEND_ABORT被OSSemDel强制唤醒资源正在被删除4. 超时唤醒与正常唤醒的接力是怎么协调的4.1 OSTimeTick 只负责把任务塞回就绪表超时这条路径很容易被误解成内核会自动帮我把任务从等待列表里摘出来。实际不是看OSTimeTick里那段ptcb OSTCBList; while (ptcb-OSTCBPrio ! OS_IDLE_PRIO) { if (ptcb-OSTCBDly ! 0) { if (--ptcb-OSTCBDly 0) { if ((ptcb-OSTCBStat OS_STAT_SUSPEND) OS_STAT_RDY) { OSRdyGrp | ptcb-OSTCBBitY; OSRdyTbl[ptcb-OSTCBY] | ptcb-OSTCBBitX; /* 只做这一件事 */ } else { ptcb-OSTCBDly 1; /* 被挂起的任务超时先不算数 */ } } } ptcb ptcb-OSTCBNext; }它只做了把任务塞回就绪表既没有清OS_STAT_SEM也没有从事件等待位图里摘人。为什么因为 tick 处理函数根本不知道这个任务在等哪个事件——它遍历的是OSTCBList手上没有事件句柄。想摘出来得先通过OSTCBEventPtr找到事件但那是一层额外的间接访问而且把责任放在这里会和OSSemPend的二次检查重复。于是责任被明确地切开了tick 负责让任务重新可运行OS_EventTO负责把等待关系清理干净。这个分工的副作用是任务在超时之后、真正运行之前处于一个既在就绪表里又还挂在事件等待位图里的中间状态。这个状态很别扭但只持续到任务被调度上 CPU 为止而它一上 CPU 干的第一件事就是检查OS_STAT_SEM并调用OS_EventTO。4.2 OS_EventTO 做的事和唤醒完全对称static void OS_EventTO (OS_EVENT *pevent) { INT8U prio; prio OSTCBCur-OSTCBPrio; pevent-OSEventTbl[prio 3] ~OSMapTbl[prio 0x07]; if (pevent-OSEventTbl[prio 3] 0) { pevent-OSEventGrp ~OSMapTbl[prio 3]; } OSTCBCur-OSTCBEventPtr (OS_EVENT *)0; OSRdyTbl[OSTCBCur-OSTCBY] | OSTCBCur-OSTCBBitX; OSRdyGrp | OSTCBCur-OSTCBBitY; }它按优先级算出自己在位图里的行列位置清掉对应的位同样的整行空了才清组位逻辑然后清OSTCBEventPtr。最后两行把任务再塞一次就绪表——其实 tick 已经塞过一次了这里重复设置位是无害的位操作天然幂等但保留这两行有实际意义如果这个任务是被OSSemDel强制唤醒而不是超时唤醒的那就没人替它塞过就绪表必须靠这里补上。OS_EventTO里用的是OSTCBCur-OSTCBPrio而不是 TCB 里预存的OSTCBY/OSTCBX这里其实有个小不一致——唤醒路径用的是预计算的位掩码超时路径用的是现场移位。为什么我猜是因为OS_EventTO只在超时这种低频路径上跑没必要为了省几个周期多占字段。读源码时遇到这种同一件事两种写法的地方先别急着当 bug 报看看是不是性能分层的取舍。4.3 两条路同时触发会怎样这是面试和生产环境都绕不开的问题任务 T 等信号量超时时间刚好到与此同时另一个任务或中断 post 了这个信号量。会不会既超时又拿到或者信号量计数莫名其妙多了一个答案是不会丢也不会重复。原因在于两个路径都跑在临界区里被串行化了而决定胜负的那个字段就是OS_STAT_SEM谁先执行结果OSSemPost先OS_EventTaskRdy清掉OS_STAT_SEM、把OSTCBDly清零、把任务从等待位图摘出。之后 tick 再看这个任务的OSTCBDly已经是 0不会再动它。任务醒来判定为正常唤醒返回OS_NO_ERROSTimeTick先任务被塞回就绪表但OS_STAT_SEM还在、等待位图里也还在。此时若OSSemPost插进来OS_EventTaskRdy会把它当等待者处理摘出等待位图、清标志、塞回就绪表重复置位无害。任务醒来返回OS_NO_ERR且信号量是直接交接的计数没有被加一真正的关键在于第二条即使超时已经发生过只要任务还没执行到二次检查那一步post 依然能把信号量直接交给它不会出现任务报超时、而计数又加了一的错配。因为整个请求信号量的过程是标志位 等待位图两套状态共同描述的而不是靠某个单一的布尔量。那如果任务已经跑完了二次检查、返回OS_TIMEOUT、OS_EventTO也执行完了post 才来呢这时等待位图里已经没有这个任务post 走没人在等分支计数加一。信号量留在池子里下一个 pend 的人能拿到。逻辑自洽。提示虽然逻辑上没漏洞但临界区保护 双状态描述这套组合是有代价的——你必须保证OSTCBCur-OSTCBDly、OS_STAT_SEM、OSTCBEventPtr三个字段的任何一处修改都在临界区里。如果移植时在某个自定义的 post 函数里图省事没开关中断症状是偶发的信号量凭空消失或者任务永远不醒而且只在高压负载下复现。5. 互斥量与优先级继承OSEventCnt 高低字节的分工5.1 优先级反转长什么样信号量能解决资源计数但解决不了资源所有权的问题。经典的翻车场景是这样的低优先级任务 L 先拿到了某个共享资源比如一路 SPI 总线高优先级任务 H 随后要访问同一资源被阻塞此时中优先级任务 M 就绪了因为它的优先级比 L 高抢占执行一跑就是几十毫秒。结果就是H 被 M 间接阻塞了——H 明明优先级最高却要等一群比它低的任务。业内有名的案例里一个没有处理优先级反转的系统在运行中反复复位最后靠给相关任务打开优先级继承才稳住。uC/OS-II 给出的解法是互斥量 优先级继承当 H 因为 H 而阻塞在某个互斥量上时把持有者 L 的优先级临时抬到 H 的级别这样 M 就抢不过 L 了L 能尽快跑完、释放锁、把优先级还回去H 随即被唤醒。5.2 OSEventCnt 的两半到底存什么OSMutexCreate(prio, err)初始化时是这么写的pevent-OSEventType OS_EVENT_TYPE_MUTEX; pevent-OSEventCnt (INT16U)((INT16U)prio 8) | OS_MUTEX_AVAILABLE; /* 0xFF 表示空闲 */ pevent-OSEventPtr (void *)0;OS_MUTEX_AVAILABLE就是0xFF。于是低字节的语义很清晰0xFF表示锁没人拿其它值表示当前持有者的优先级。高字节存的是创建时传进来的那个prio也就是 PIP优先级继承优先级。为什么需要一个创建时就定死的 PIP 字段而不是谁阻塞就抬到谁的级别这么直接因为后者会导致同一个锁在不同时刻把持有者抬到不同高度优先级变化次数多、恢复逻辑也复杂。传一个固定值进去相当于约定这把锁最多会被抬到哪个级别抬升和恢复都变成了确定的一两步操作。传0通常表示不做优先级继承具体行为取决于你手上那份源码的版本。5.3 挂锁时的两个分支OSMutexPend的结构和OSSemPend很像但快路径多了一步记录持有人if ((pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8) OS_MUTEX_AVAILABLE) { pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; /* 保留 PIP清掉状态 */ pevent-OSEventCnt | OSTCBCur-OSTCBPrio; /* 低字节写入自己的优先级 */ pevent-OSEventPtr (void *)OSTCBCur; /* 记录持有者 TCB */ *err OS_NO_ERR; return; }这里能看出互斥量和信号量最本质的差别它记得住主人是谁。信号量没有主人概念谁 pend 到就是谁的所以可以从 ISR 里 post互斥量的低字节和OSEventPtr共同描述了谁持有因此它的 pend/post 都带有所有者语义。这也是为什么在互斥量上做中断里给出、任务里取这种单向同步是错的用法——ISR 没有 TCB没法当持有者。锁被占用时的分支才是有意思的地方逻辑骨架大致是这样pprio (INT8U)(pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8); /* 当前持有者的优先级 */ if (OSTCBCur-OSTCBPrio pprio) { /* 数值小 优先级高我比持有者高 */ pptcb OSTCBPrioTbl[pprio]; /* 找到持有者 */ /* 把持有者的优先级提升到我的级别并同步维护三处结构 */ pptcb-OSTCBY (INT8U)(OSTCBCur-OSTCBPrio 3); pptcb-OSTCBX (INT8U)(OSTCBCur-OSTCBPrio 0x07); pptcb-OSTCBBitY OSMapTbl[pptcB-OSTCBY]; pptcb-OSTCBBitX OSMapTbl[pptcB-OSTCBX]; OSTCBPrioTbl[pprio] (OS_TCB *)0; /* 旧的优先级槽清空 */ pptcb-OSTCBPrio OSTCBCur-OSTCBPrio; /* 换号 */ OSTCBPrioTbl[OSTCBCur-OSTCBPrio] pptcb; /* 新槽登记 */ /* 就绪表里的位置也要跟着搬 */ } OSTCBCur-OSTCBStat | OS_STAT_MUTEX; OSTCBCur-OSTCBDly timeout; OS_EventTaskWait(pevent); OS_EXIT_CRITICAL(); OS_Sched();改优先级不是改一个字段就完事这是很多人读这段代码时最容易看漏的点。至少要同步维护四处OSTCBPrioTbl[]的旧槽要清空、新槽要登记这个数组是优先级 → TCB的直接映射调度器靠它 O(1) 找任务OSTCBPrio本身要改TCB 里预计算的OSTCBY/OSTCBX/OSTCBBitY/OSTCBBitX四个位掩码要重算就绪表里的位要从旧位置搬到新位置。漏掉任何一处症状都不明显但是致命的——比如只改了OSTCBPrio却没搬就绪表里的位那么持有者释放锁之后会从新位置消失而旧位置还留着一个已经失效的位最终变成一个永远选不出来的幽灵任务。注意互斥量这段代码在不同小版本之间差异最大。我在两份不同版本的ucos_ii.c上读到的写法就不完全一样有的用OS_TaskChangePrio之类的封装有的直接在OSMutexPend里裸改。所以读这一部分时先确认自己手上源码的版本号别拿别人的分析硬套。5.4 释放锁时的两件事交接和还债OSMutexPost开头就是一个身份校验这是信号量里没有的if ((pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8) ! OSTCBCur-OSTCBPrio) { *err OS_ERR_NOT_MUTEX_OWNER; return; }只有持有者能释放锁。所以互斥量天然不能跨任务释放也就不能在 ISR 里 post部分版本会显式返回OS_ERR_POST_ISR部分版本靠上面的身份校验自然拦掉。这一点和信号量正好相反是面试里最爱挖的对比点。校验通过之后是两条分支。有人在等的时候先OS_EventTaskRdy叫醒优先级最高的等待者把它作为新的持有者写进低字节和OSEventPtr然后在把优先级还给自己如果刚才被抬升过的话。没人在等的时候低字节写回0xFFOSEventPtr清 0锁回到空闲态。还债这一步的顺序特别要紧必须先确定新主人、再恢复自己的优先级。如果先恢复自己的优先级就绪表里的位置会在交接完成前发生变化调度器可能在半路上选中一个状态不完整的新持有者。这类顺序问题在源码里往往没有注释只能靠推演状态机来发现。6. 挪到 GD32F103 这类 Cortex-M3 上同步机制几个高频翻车点6.1 临界区宏的选择直接决定会不会偶发死机Cortex-M3 上做OS_ENTER_CRITICAL一般用读取 PRIMASK 到局部变量、关中断、恢复也就是所谓 method 3#define OS_ENTER_CRITICAL() { cpu_sr OS_CPU_SR_Save(); } #define OS_EXIT_CRITICAL() { OS_CPU_SR_Restore(cpu_sr); }用汇编MRS/MSR实现开销三四个周期而且天然可嵌套。但有个前提每个用到的函数里都必须声明OS_CPU_SR cpu_sr 0;。漏掉声明在 C89 下可能编译不过提示未定义变量但在某些编译器配置下它会变成隐式声明的全局符号于是所有函数共用一个cpu_sr嵌套调用时互相踩表现为偶尔进入 ISR 后状态错乱。还有个更隐蔽的坑SysTick中断服务函数里调用OSTimeTick之前有没有正确调用OSIntEnter、之后有没有OSIntExit。OSIntNesting是OSSemPend判断是否在中断里的唯一依据如果中断里少调了OSIntEnter那么中断服务里调用OSSemPend会被误认为合法然后这个任务会带着中断上下文去挂起自己——基本上就是死机。6.2 中断里能做什么、不能做什么一张表说清API任务里ISR 里说明OSSemPend可以不可以返回OS_ERR_PEND_ISR等待是不可接受的阻塞OSSemPost可以可以后续切换由OSIntExit完成OSSemAccept可以可以非阻塞取值拿不到就返回 0OSMutexPend可以不可以同上OSMutexPost可以视版本通常返回错误需要所有者身份OSMboxPost/OSQPost可以可以用PostFront要注意中断里优先用OSQPost在 ISR 里需要等资源时的正确姿势是把等待动作丢到任务里ISR 只负责OSSemPost通知一个专门的事件处理任务由那个任务去 pend。这其实就是很多人说的 deferred processing和别的 RTOS 里xSemaphoreGiveFromISR 任务侧xSemaphoreTake的套路一模一样。6.3 RAM 预算事件多了会吃掉不少内存OS_EVENT结构体在默认配置下是 9 到 10 字节OSEventTbl[8]OSEventGrpOSEventCntOSEventTypeOSEventPtr再算上对齐。配 32 个事件就是 300 多字节配 128 个接近 1.3KB。GD32F103 常见的 20KB RAM 型号上这个数字不能忽略尤其是加上 TCB、任务栈、消息队列存储区之后。我一般的做法是把OS_MAX_EVENTS先按信号量数 互斥量数 邮箱数 队列数的实际上限乘 1.2 来配留一点余量给后续加功能。注意OSTaskCreate时如果用了队列或邮箱占的事件块也是从同一个池子里出的别只按信号量数量算。另一个容易被忽略的开销是OSTimeTick的遍历——它每个 tick 都要走一遍OSTCBList上的所有 TCB。任务数到了三四十个在 72MHz 的 GD32F103 上每次 tick 的处理大致是几微秒的量级具体看你编译器优化级别和任务数如果 tick 配到 1ms这部分基本可以忽略但如果你把任务栈开得特别小导致频繁 Cache/Flash 访问抖动或者任务数上百那就要考虑把 tick 周期放宽到 5ms 甚至 10ms 了。tick 周期决定了OSSemPend超时精度的上限也决定了 tick 开销的下限这两个是一起调的。6.4 调试同步问题的三个实用手段同步问题最难的地方是看起来哪儿都没错。我一般用这三个手段定位第一个是在OS_EventTaskWait和OS_EventTaskRdy里打断点看OSEventGrp和OSEventTbl[]的变化。如果发现某个任务唤醒后还挂在等待位图里基本就是唤醒路径漏了清理。第二个是给OSTCBCur-OSTCBStat加数据观察点。这个字段的每一次变化都有明确语义异常变化一眼就能看出来。尤其是OS_STAT_SEM和OS_STAT_MUTEX同时置位的任务说明它可能同时在等两个事件——而 uC/OS-II 每个任务只能等一个事件出现这种情况一定是调用顺序错了。第三个是给OS_MAX_EVENTS配一个明显偏大的值试试。如果改成大值以后问题消失那就是事件块不够导致的空指针回头把OSSemCreate的返回值判空补上。7. 这几个问题被问烂了从源码角度给答案7.1 二值信号量和互斥量到底差在哪最常见的回答是计数为 1 的信号量就是互斥量。从计数角度看没错从源码角度差得远主要差三处互斥量记录持有者OSEventPtr指向 TCB低字节存持有者优先级信号量不记录。因此互斥量有只有持有者能释放的约束信号量可以在任意上下文 post。互斥量支持优先级继承PIP 字段 挂锁时的优先级提升信号量完全不支持——用信号量保护共享资源优先级反转是迟早的事。互斥量的语义是保护临界资源信号量的语义是任务间/中断与任务间的通知。用错地方的典型症状是拿信号量当锁用系统在低优先级任务持锁期间被中优先级任务打断高优先级任务响应时间突然变成几十毫秒。7.2 为什么信号量能从 ISR 里 post不能 pendOSSemPost在 ISR 里做的事只有把等待者变就绪或者计数加一全是往内存里写几个位不涉及阻塞自己而且切换动作被推迟到OSIntExit不在 ISR 里做上下文切换。OSSemPend则必须先把自己从就绪表摘掉、再调用OS_Sched让出 CPU——中断上下文里根本没有当前任务被挂起这个概念被中断打断的那个任务还等着你返回呢。所以源码里直接一句OSIntNesting 0就把它挡回去了。这条规则在别的 RTOS 里也基本通用只是错误码名字不同。7.3 超时返回之后信号量计数变了吗很多人的直觉是超时了那这次请求就作废了。从源码看OS_EventTO只做了三件事从等待位图摘掉自己、清OSTCBEventPtr、确保自己在就绪表里。它没有碰OSEventCnt。也就是说计数从头到尾没变过——如果在这期间有人 post 过并交接给了别的等待者那计数本来就该减如果没人 post计数保持原样。这个设计保证了超时这个动作本身不会消耗也不可能消耗信号量的额度逻辑上干净。7.4 uC/OS-II 的同步原语和 Linux 差在哪被问到RTOS 和 Linux 有什么区别时从同步机制这个切面回答最有力因为差别是结构性的维度uC/OS-IILinux对象分配静态数组编译期定数量运行时动态分配slab、futex 哈希桶等待队列优先级位图O(1) 找最高优先级双链表 红黑树按优先级/期限排序唤醒策略严格按优先级优先级与公平性兼顾CFS 场景下讲公平临界区关中断保存/恢复 PRIMASK自旋锁、互斥锁、preempt_disable、RCU 等多种粒度优先级反转提供优先级继承的互斥量有 PI 互斥锁还有 rt-mutex、futex 的 PI 支持中断上下文只有 post 一侧可用有 spin_lock_irqsave、下半部等完整分层最核心的差别其实是设计目标不同uC/OS-II 追求最坏情况可预测所以宁可牺牲灵活性也要保证常数时间Linux 追求吞吐和通用性愿意为更好的平均性能接受一定的尾延迟。理解了这一点很多细节差异就不用背了——它们是同一个目标推导出来的不同结论。回到这 6736 行代码本身事件机制这一块不到 400 行但把等待、唤醒、超时、所有权、优先级继承五件事都覆盖了而且没有一次动态内存分配。我自己的体会是读这段代码最大的收获不是记住 API 怎么调而是学会了用两套状态标志位 位图描述一个等待关系这个思路。后来不管是在别的内核上排查同步问题还是自己写一些跨任务的状态协调逻辑这套状态双写、临界区串行化、醒来重新判定的三板斧都一直在用。有一次我在一个项目里自己实现了一套轻量的任务间消息通知就是照着OS_EventTaskRdy的骨架来的跑了一年多没出过问题——这种能直接搬走的套路比记住函数原型值钱多了。
返回列表