uC/OS-II信号量源码拆解:事件控制块、等待队列与优先级继承
2026/9/18 6:33:34 网站建设 项目流程

把 uC/OS-II 内核里的OS_SEM.C单独拎出来数一遍,你会发现它只有两百行出头,对外函数就五个:OSSemCreateOSSemPendOSSemPostOSSemAcceptOSSemQuery。但真正带着问题去读它的人都会撞上同一堵墙——比如你想搞清楚"两个不同优先级的任务同时 Pend 一个计数为 0 的信号量,Post 一来谁先被唤醒",翻遍OS_SEM.C你会发现一行排队逻辑都没有。担当排队、唤醒、超时摘除这些脏活的函数,全部躺在OS_CORE.C里,名字还都带着Event前缀。

这就是第七篇到第九篇一直在铺的东西:uC/OS-II 把"等一个事件"这件事抽象成了一个公共底座,信号量、消息邮箱、消息队列、互斥量四种东西全都长在它上面。这一篇就顺着OS_SEM.C往上钻,把事件控制块(ECB)这条链路彻底拆开——从OS_EVENT结构体的字段排布,到OS_EventTaskWaitOS_EventTaskRdyOS_EventTO三个函数的对称设计,再到互斥量为了对抗优先级反转额外做的那段优先级继承代码。如果你正在 GD32F103 这类 Cortex-M3 上跑 uC/OS-II,或者准备把 RTOS 信号量相关的问题拎到面试桌上去聊,这篇里拆出来的东西基本都能直接对上。

内容会偏源码视角,但不会停在"这行长这样",每个设计选择我都会解释它为什么只能这么做、不这么做会出什么问题。看之前只要保证你手上有uCOS_II.HOS_CORE.COS_SEM.C这三个文件就行,编译器配置、移植层的东西这一篇不展开。

1. 两百行出头的 OS_SEM.C:排队逻辑为什么一行都不在这里

1.1 把 6736 行拆开看,内核其实分了四层

6736 行这个数字我第一次听到时也愣了一下,以为内核代码量不大,一天能看完。实际拆开才发现,这里面有一半是"注释和空行"级别的操作,真正干活的 C 代码大概四千行上下,再加上uCOS_II.H这个大块头。如果按职责分,我会把它切成四层:

层级代表文件干什么
与硬件打交道的边界层OS_CPU.HOS_CPU_C.COS_CPU_A.ASM开关中断、栈操作、任务切换入口
内核调度核心OS_CORE.COS_TASK.COS_TIME.C就绪表、调度器、TCB、时钟节拍
同步与通信OS_SEM.COS_MBOX.COS_Q.COS_MUTEX.C四个薄壳,各自几十到两百多行
可选组件OS_MEM.COS_FLAG.COS_TMR.C内存分区、事件标志组、软定时器

你会发现最反直觉的一点:同步与通信这一层,四个文件的体量加起来还不如OS_CORE.C一个文件。这不是因为它们功能弱,而是因为真正重的东西被提到OS_CORE.C里做成公共设施了

具体说,OS_CORE.C里跟事件相关的核心就是四个函数:OS_EventWaitListInitOS_EventTaskWaitOS_EventTaskRdyOS_EventTO。前两个负责"把当前任务挂到某个事件的等待队列上",第三个负责"从某个事件的等待队列里摘出最高优先级的任务并让它就绪",第四个负责"超时了,把自己从等待队列里摘掉"。信号量、邮箱、队列、互斥量的所有 Pend/Post 逻辑,全都是在这四个函数上包了一层参数校验和状态更新。

1.2 四个 API 各自的职责边界

理解了这个分层,再看OS_SEM.C就会很清爽,它做的无非四件事:

  • OSSemCreate:从空闲事件块链表里摘一个OS_EVENT出来,打上类型标记OS_EVENT_TYPE_SEM,把计数值写进OSEventCnt,然后调用OS_EventWaitListInit清空等待表。
  • OSSemPend:先看计数值,大于 0 就直接减一返回;否则把自己挂到等待表上,调OS_Sched让出 CPU,等回来之后判断是"被 Post 唤醒"还是"超时"。
  • OSSemPost:先看等待表里有没有人,有人就调OS_EventTaskRdy唤醒一个并触发调度;没人就把计数值加一。
  • OSSemAccept:非阻塞版本,计数值大于 0 就减一返回原值,否则直接返回 0,绝对不挂起当前任务。

注意OSSemAccept的位置,它是唯一一个"可以被中断服务程序随便调用"的获取接口。这一点后面第 7 节会重点说,很多人的工程事故就出在把OSSemPend塞进了 ISR。

1.3 这套分层的价值,在你移植或换组件的时候才会真正显现

我最早读 uC/OS-II 时觉得这种分层是"为了显得架构漂亮",直到有一次项目需要把一个消息队列换成事件标志组,才明白它的好处有多大。

因为四个通信组件的内核等待逻辑是共用的,你要验证的只是一层薄薄的外壳,公共部分只需要验证一次;而且一旦你在调试时看到任务卡在OS_EventTaskWait里,你就知道问题不在"信号量实现错了",而是在"没人 Post"或者"Post 的对象搞错了",排查半径瞬间缩小一半。更实际的是,面试里问到"uC/OS-II 的信号量是怎么实现的",你能答出"真正实现等待队列的是 OS_CORE.C 里的事件机制,OS_SEM.C 只是薄封装",这一句就能把讨论从背 API 拉到读源码的层次上。

顺带提一句和 Linux 的差异,这也是热词里经常被一起问到的:Linux 的信号量有用户态和内核态两套语义,还要处理睡眠、唤醒、内存屏障这些复杂场景;uC/OS-II 全部运行在同一个特权态里,没有 MMU、没有进程概念,所以它可以用"位图 + 数组"这种最粗暴但确定性最强的结构来实现等待队列。确定性,才是 RTOS 的命根子。

2. OS_EVENT 结构体:16 个字节里装下一条完整等待队列

2.1 五个成员,各管一段逻辑

OS_EVENT的定义在uCOS_II.H里,短得让人想笑:

typedef struct { INT8U OSEventType; /* 事件类型:邮箱/队列/信号量/互斥量 */ INT8U OSEventGrp; /* 等待任务组,位图的高 6 位 */ INT16U OSEventCnt; /* 计数,仅信号量用 */ void *OSEventPtr; /* 消息指针,或指向 OSMutex / OS_Q */ INT8U OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任务的优先级位图 */ } OS_EVENT;

我挨个说说它们在实际运行中扮演的角色,这比单纯背定义有用得多:

成员谁在用怎么用
OSEventType所有 API 的第一道校验OS_EVENT_TYPE_SEM等宏比较,防止把邮箱当信号量 Post
OSEventCnt信号量、互斥量信号量存剩余计数;互斥量只当"0 或 1"用
OSEventPtr邮箱、队列、互斥量、空闲链表邮箱存消息指针,队列指向OS_Q,互斥量指向OSMutex
OSEventGrp事件等待表8 个优先级"分组"的位图,非 0 就代表有人在等
OSEventTbl[]事件等待表每个字节记录一组 8 个优先级的占用情况

在 32 位机器上,这个结构体按 4 字节对齐算下来正好 16 字节。OS_MAX_EVENTS一般配成 10 到 20 个,也就是说整个事件池也就占几百字节 RAM,对 GD32F103 这种 48KB SRAM 的片子完全没有压力。

2.2 OSEventTbl 与 OSEventGrp,就是第二张就绪表

这个地方是整篇文章里我认为最值得反复琢磨的一点。如果你还记得第 3 篇讲就绪表时的那两个全局变量OSRdyGrpOSRdyTbl[],再看OSEventGrpOSEventTbl[],会发现它们结构完全一样,算法完全一样,连查表用的OSUnMapTbl都是同一张

差别只在语义上:

  • 就绪表里的"1"表示这个优先级的任务可以运行
  • 事件等待表里的"1"表示这个优先级的任务正在等这个事件

为什么可以这么复用?因为在一个任务的生命周期里,它要么在就绪表里,要么在某一个事件的等待表里(或者同时短暂地在两处,这个"双重登记窗口"第 4 节细讲),几乎不会出现两个状态都合法且都需要长期存在的情况。uC/OS-II 干脆把两个位图做成同一套算法,好处是:

  1. 查找最高优先级等待任务,直接复用现成的OSUnMapTbl,常数时间 O(1),不需要遍历等待队列。
  2. 代码量极小,OS_EventTaskRdy整个函数不到 30 行。
  3. 开发者只需要理解一套位图逻辑,心智负担直接减半。

2.3 OS_EVENT_TBL_SIZE 这个宏,决定了你多花多少 RAM

这个宏的定义是:

#define OS_EVENT_TBL_SIZE (OS_LOWEST_PRIO / 8 + 1)

OS_LOWEST_PRIO是你在OS_CFG.H里配的最低优先级编号。常见的配置有两种:一是给满 64 个优先级,OS_LOWEST_PRIO = 63,此时OS_EVENT_TBL_SIZE = 8;二是像很多小项目那样为了省 RAM 配成 31,此时OS_EVENT_TBL_SIZE = 4

这里有个容易踩的隐形成本:每个事件控制块都要自带一份等待表。假设你配了 20 个事件、满优先级,那么光等待表就吃掉 20 × 8 = 160 字节。如果配到 31 个优先级,就只用 80 字节。所以"我把优先级数量配大一点,以后好扩展"这个想法,在小容量 MCU 上是要算账的,事件数量 × 等待表长度 是它带来的真实内存代价。

提示:改OS_LOWEST_PRIO时不要只改这一个宏。任务优先级数组OSTCBPrioTbl[]、位图数组OSRdyTbl[]的长度都跟着它走,改完之后建议把OS_MAX_TASKSOS_MAX_EVENTS一起重新核对一遍。

3. 创建与删除:空闲链表的两行摘取和 OSSemDel 的"拒绝删除"

3.1 OSEventFreeList 是一条静态池链成的单向链表

OSInit里做的事情很朴素:定义了全局数组OSEventTbl[OS_MAX_EVENTS],然后从第 0 个开始,把每一块的OSEventPtr指向下一块的地址,最后一块指向NULL,链表头是全局变量OSEventFreeList

OSEventFreeList = &OSEventTbl[0]; for (i = 0; i < OS_MAX_EVENTS - 1; i++) { OSEventTbl[i].OSEventPtr = (void *)&OSEventTbl[i + 1]; } OSEventTbl[OS_MAX_EVENTS - 1].OSEventPtr = (void *)0;

所以在 uC/OS-II 里创建信号量是绝对不会失败的,除非你把池子用完了返回NULL。这也是它和动态内存分配最大的区别——没有 malloc,就没有碎片,也就没有"某个时刻突然分配不出来"的不确定性。实时系统里这个性质比"灵活"值钱得多。

3.2 OSSemCreate 里那对临界区的位置很有讲究

OSSemCreate的骨架:

OS_EVENT *OSSemCreate (INT16U cnt) { OS_EVENT *pevent; 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; }

摘链表那两行必须在临界区里,因为OSEventFreeList是全局共享资源;但字段初始化和清等待表这几行被放到了临界区外面。这不是偷懒——这几行只操作刚摘下来的、还没被任何人引用的结构体,不存在竞争。把临界区开得越短越好,是实时内核里一以贯之的纪律。你在自己写驱动或者多任务共享数据时,也可以拿这个当参考:临界区里只干必须原子完成的那一步。

3.3 OSSemDel 在有人排队时会明确拒绝

删除信号量的逻辑比创建精彩得多,因为它要回答一个问题:如果这时候有人正在等这个信号量,删还是不删?uC/OS-II 把决定权交给你,通过一个opt参数:

  • OS_DEL_NO_PEND:只要等待表非空(OSEventGrp != 0),立刻返回OS_ERR_TASK_WAITING,什么都不做。
  • OS_DEL_ALWAYS:循环调用OS_EventTaskRdy,把等待表里的任务逐个摘出来、置为就绪、并把它们的状态掩码清掉,让它们带着"被中止"的错误码返回。

第二种模式在工程上很有用:设备卸载、通信链路重连、模块去初始化的时候,与其让几个任务永远卡在 Pend 上,不如统一唤醒并告知失败,让上层逻辑走清理流程。但有个细节一定要清楚——这些任务被唤醒后拿到的是错误码,不是"成功获取到信号量",如果你的业务代码不检查*err,就会带着"我拿到资源了"的错误认知继续往下跑,后面出现任何离奇现象都不要惊讶。

删除动作的最后两步也值得看一眼:先把OSEventType置为OS_EVENT_TYPE_UNUSED,然后把这个块通过OSEventPtr挂回OSEventFreeList。整块内存原封不动地回收,下一句OSSemCreate立刻就能再拿到它。

4. OSSemPend 的完整执行链:从快速路径到挂起、调度、超时摘除

4.1 快速路径:计数值减一,然后立刻走人

void OSSemPend (OS_EVENT *pevent, INT16U timeout, INT8U *err) { OS_ENTER_CRITICAL(); if (pevent->OSEventCnt > 0) { /* 快速路径 */ pevent->OSEventCnt--; OS_EXIT_CRITICAL(); *err = OS_NO_ERR; return; /* 没有调度、没有挂起 */ } /* ……慢速路径 */ }

这几行是 uC/OS-II 性能的一个关键。信号量"够用"的时候,整个过程就是一次临界区加减法,没有任务挂起、没有调度器介入、没有上下文切换。你在周期任务里频繁 Pend 同一把信号量,成本极低。

这里也是新手最容易困惑的点:很多人以为 Pend 一定会触发任务切换,所以把 Pend 当"让出 CPU"的手段用,结果发现自己的任务照样占着 CPU 跑。Pend 只在资源不够时才挂起,想主动让出 CPU,用OSTimeDly或者OSTaskSuspend,别用信号量。

4.2 慢速路径:三个动作、一次调度、一次回检

计数值不够的时候,进入慢速路径,顺序非常固定:

  1. 给当前任务的 TCB 打标记:OSTCBCur->OSTCBStat |= OS_STAT_SEM;,表示"我在等信号量"。
  2. 写入超时值:OSTCBCur->OSTCBDly = timeout;timeout为 0 代表无限等待。
  3. 调用OS_EventTaskWait(pevent),把自己从就绪表摘掉、把等待表的对应位置 1,并记录OSTCBEventPtr指向这个事件。
  4. 退出临界区,调OS_Sched(),真正切走。
  5. 等被别人唤醒或超时后,重新进入临界区回检OSTCBCur->OSTCBStat & OS_STAT_SEM

第 5 步是整段代码的灵魂。任务被重新调度回来时,它并不知道自己是被 Post 唤醒的,还是等到超时了。判断依据就是那个状态掩码:如果OS_STAT_SEM已经被清掉,说明是OS_EventTaskRdy干的(它负责清掩码),那就是正常获取;如果还挂着,说明是超时,必须调OS_EventTO把自己从等待表里摘干净,然后返回OS_TIMEOUT

4.3 超时那一刻的"双重登记窗口",是理解整条链路的关键

很多人读到这里会有一个疑问:超时是怎么发生的?时钟节拍中断OSTimeTick里遍历OSTCBList递减OSTCBDly,减到 0 就把任务挂回就绪表。但注意,此刻它还在事件的等待表里,因为OS_EventTO要等任务真正被调度回来才执行。

于是出现了一个短暂窗口:这个任务同时在就绪表和某个事件的等待表里。这个窗口里如果恰好OSSemPost来了,会发生什么?OS_EventTaskRdy从等待表里把它摘出来,清掉OS_STAT_SEM,然后再往就绪表里置位(重复置位无害)。等它被调度回来一看,OS_STAT_SEM没了,于是按"成功获取"处理,返回OS_NO_ERROS_EventTO根本不会被调用。

这套设计很巧妙:信号量和超时是"谁先到谁赢",不存在两边都生效导致的资源泄漏或者状态错乱。反过来,如果超时先落地、任务已经被唤醒并调用了OS_EventTO摘除等待表,那之后到来的 Post 就会走"没人等待"分支,把计数值加一留给下一个人。逻辑上是自洽的。

提示:OSTimeTick里对还没解除阻塞的任务有个小技巧——递减到 0 时如果发现OSTCBStat还挂着阻塞位,就把OSTCBDly强制写回 1,让它下个节拍继续被"计数"。这保证了超时行为在 Tick 层面是"水位保持",真正的摘除动作永远由任务自己完成。

5. OSSemPost 的两条分支与 OS_EventTaskRdy 的摘取算法

5.1 有人等待:查两次表,拿到最高优先级

OS_EventTaskRdy是整条链路里最短也最漂亮的一段,核心就三行查表:

y = OSUnMapTbl[pevent->OSEventGrp]; /* 找组号 */ x = OSUnMapTbl[pevent->OSEventTbl[y]]; /* 找组内偏移 */ prio = (INT8U)((y << 3) + x); /* 拼出优先级 */

OSUnMapTbl是一张 256 字节的固定表,输入一个字节,输出其中最低位为 1 的位置。所以"从一组里找编号最小的置位"是查一次表的事,跟等待任务数量完全无关。这就是 O(1) 调度在事件层的复刻。

拿到优先级之后,动作依次是:清OSEventGrp对应的位、清OSEventTbl[y]对应的位、通过OSTCBPrioTbl[prio]找到 TCB、把OSTCBDly清零、清掉状态掩码里的OS_STAT_SEM,最后判断掩码是否为 0(因为任务可能还被OSTaskSuspend挂着),为 0 才真正往就绪表里置位。

5.2 没人等待:加计数,以及 65535 这个天花板

另一条分支简单到只有几行,但有个边界值得一提:

if (pevent->OSEventCnt < 65535) { pevent->OSEventCnt++; return OS_NO_ERR; } return OS_SEM_OVF; /* 溢出 */

OSEventCntINT16U,上限 65535。超出就返回溢出错误。正常用法几乎不可能碰到,但如果你把信号量当"事件计数器"滥用——比如在 ISR 里无脑 Post 而没人 Pend——跑个几万次就会撞上这个天花板,表现为 Post 开始失败。这种情况的正确做法是改用事件标志组或者消息队列,而不是把信号量当计数器堆。

5.3 三个事件函数之间的对称性

OS_EventTaskWaitOS_EventTaskRdyOS_EventTO摆在一起看,能看出一组非常工整的对称关系:

函数对就绪表对等待表调用者
OS_EventTaskWait清自己那一位置自己那一位Pend 挂起时
OS_EventTaskRdy置对方那一位清对方那一位Post 唤醒时
OS_EventTO不动清自己那一位Pend 超时收尾时

OS_EventTaskWaitOS_EventTO是同一件事的正反两面,一个挂上去一个摘下来,操作的对象都是"当前任务";OS_EventTaskRdy则由别人代劳。三个函数共用同一套位图约定,所以无论从哪条路径进来,位图的最终状态都是一致的。

我自己在纸上画过这三者的状态迁移图,画完才意识到 uC/OS-II 为什么敢把四个通信组件共用这套底座——只要位图状态自洽,上层是信号量还是消息队列,对事件层来说毫无区别。参数里的那个状态掩码(OS_STAT_SEM/OS_STAT_MBOX/OS_STAT_Q)就是唯一的区分依据。

6. 互斥量与优先级反转:OSMutexPend 里那段"改优先级"的代码

6.1 先复现一遍优先级反转的现场

假设三个任务:H 优先级 5,M 优先级 10,L 优先级 20。L 先拿到互斥量,H 随后 Pend 被阻塞。此时如果 M 就绪了,它会抢占 L——因为 L 的优先级比 M 低。结果就是 H 明明优先级最高,却要等 L 执行完,而 L 又被 M 无限打断,高优先级任务的响应时间被中优先级任务拖成了不确定值。这就是优先级反转。

普通信号量完全无法处理这个问题,它只认计数器。所以 uC/OS-II 单独做了一个OSMutexPend,把"我是谁拥有的"这个信息记进了事件块:

typedef struct { INT8U OSMutexPrio; /* 拥有者的优先级 */ OS_TCB *OSMutexOwner; /* 指向拥有者的 TCB */ } OSMutex;

pevent->OSEventPtr指向的就是这个结构。所以互斥量的OSEventCnt只当 0/1 的标志用,真正的所有权信息在OSMutex里。

6.2 优先级继承具体做了哪些动作

OSMutexPend在发现互斥量已被别人占有时,会比较"当前任务优先级"和"拥有者优先级"。如果当前任务的优先级更高(数值更小),就把拥有者的优先级临时提升到和当前任务一样高,这个动作不是简单改一个字段就完事,它要同步搬迁三处信息:

  1. OSTCBPrioTbl[]里把拥有者的 TCB 从老优先级位置挪到新位置。
  2. 拥有者当前在就绪表里的位,从老的组/位搬到新的组/位。
  3. 如果拥有者此刻自己也阻塞在另一个事件上(等待表里有它),那它的位也要在新的优先级位置上重新置位。

搬完之后,M 就抢不动 L 了,因为 L 现在的优先级已经和 H 一样高。等 L 释放互斥量时,OSMutexPost会把优先级恢复回OSMutexPrio记录的原值,并把所有权移交给等待队列里优先级最高的那个任务。

我在 GD32F103 上实测过一个典型场景,用三个周期任务模拟上面那套优先级关系,加上互斥量之后,高优先级任务的最坏等待时间从"被中优先级任务随意延长的毫秒级抖动"收敛到了"L 的一小段临界区时间",效果非常明显。反过来,如果把互斥量换成普通信号量,同一套代码就会出现偶发的几十毫秒延迟,而且这个延迟和系统负载正相关,非常难查。

6.3 互斥量的四条使用纪律

读到这里,互斥量的使用边界其实已经由源码本身划出来了,我总结成四条纪律:

纪律原因
绝不在中断里 Pend/Post 互斥量中断没有 TCB 参与调度,优先级继承会失去意义
谁 Pend 谁 Post所有权记录在OSMutex里,别的任务 Post 会破坏所有权链
临界区尽量短优先级继承只解决"被中优先级抢占",不解决"持锁太久"
不支持递归获取同一个任务重复 Pend 会自己死锁,不像某些实现有递归计数

顺带对比一下普通信号量,两者的差别其实比很多人想的更根本:

对比项信号量 OSSem互斥量 OSMutex
计数语义可以是 0 到 65535 的计数只能是 0 或 1
所有权无,谁 Post 都行有,记录拥有者 TCB
优先级继承
中断里使用Post 可以,Pend 不可以都不建议
典型场景任务间同步、ISR 通知任务保护共享资源

uC/OS-II 走的是优先级继承(PIP)这条路,没有实现优先级天花板协议。这意味着它没法从数学上严格保证最坏阻塞时间,但对绝大多数嵌入式场景已经足够了。如果你在做硬实时性分析,这一点必须写进你的时序预算里。

7. 落到 GD32F103 工程上:信号量使用的六个坑

7.1 中断里只能 Post,不能 Pend

这是最经典也最致命的一条。OSSemPend的慢速路径里有一步OS_EventTaskWait,它要操作"当前任务"的 TCB,还要调OS_Sched。在中断服务程序里根本没有"当前任务要被换掉"这个概念,一旦走到慢速路径,就是未定义行为,典型现象是任务状态位图被写坏、几个任务莫名其妙都不跑了。

正确的 ISR 写法就三步:

void EXTI0_IRQHandler(void) { OSIntEnter(); /* 通知内核进入中断 */ if (EXTI_GetITStatus(EXTI_Line0) != RESET) { /* 干活,尽量短 */ OSSemPost(MySem); /* 只 Post,不 Pend */ EXTI_ClearITPendingBit(EXTI_Line0); } OSIntExit(); /* 退出并触发调度判断 */ }

OSIntExit会在中断嵌套全部退出后检查是否需要调度。少了它,你在 ISR 里 Post 完,任务可能一直不切回去,现象就是"信号量发出去了但任务没动"。

如果确实需要在中断里获取资源,用OSSemAccept,它不会挂起任何任务,拿不到就返回 0,让上层自己决定丢帧还是缓存。

7.2 忽略 *err 返回值,是"偶发死锁"的第一嫌疑

OSSemPend的第三个参数是错误码输出,很多人图省事传个变量进去就再也不看了。但OS_TIMEOUTOS_NO_ERR的处理路径完全不一样:

OSSemPend(MySem, 100, &err); if (err == OS_NO_ERR) { /* 真的拿到资源了,才能操作共享数据 */ do_something(); } else { /* 超时或者被中止,绝对不能碰共享资源 */ handle_timeout(); }

漏掉这个判断,超时的任务会带着"我以为我拿到了"的错觉去动共享缓冲区,表现出来就是数据偶发错乱,而且只在负载高的时候出现,查起来要人命。我见过一次现场,症状是串口偶尔吐出半包乱码,最后追到根源就是这里少了一个if

7.3 调度器启动前 Pend 会直接卡死

OSSemPend的慢速路径最终会调OS_Sched,而OSStart之前调度器还没跑起来。如果你在main里创建了任务、然后想在OSStart之前 Pend 一个还没被 Post 的信号量,程序会停在那儿不动,而且没有任何错误码提示。

调度器启动前能用的是OSSemCreateOSSemAccept这类不涉及挂起的接口。要同步,就让信号量在创建时带上初始计数值,或者干脆把这段初始化逻辑挪到一个最先运行的任务里。

7.4 timeout 传 0 是无限等待,不是"立刻返回"

timeout参数为 0 的语义是"一直等下去"。想立刻返回,用OSSemAccept。这两个接口混淆的后果是:本该快速失败的地方变成了永久阻塞,整个任务链条悄无声息地停摆。我在代码审查里养成的一个习惯就是,看到OSSemPend的第二个参数是 0,就要追问一句"确定这里可以无限等?"

7.5 同一个信号量被多个地方 Post,小心语义失控

信号量没有所有权,谁都能 Post。这在"ISR 通知任务"这种一对一场景下很舒服,但一旦有多个生产者往同一个信号量上 Post,就很容易出现计数虚高:某个任务 Pend 一次拿走一个计数,实际却有两份数据,于是数据被消费两次。这不是信号量的 bug,是用法超出了它的语义。这种场景应该用消息队列,让数据和计数绑定在一起。

7.6 面试视角:这几个问题能把"读过源码"和"背过 API"分开

现在 RTOS 相关的问题越来越往原理上问,我列几个和本篇直接对得上的:

  • uC/OS-II 的信号量在资源不足时,任务是怎么排队的?——位图等待表,不是链表,查找 O(1)。
  • 超时是怎么实现的,会不会和 Post 撞车?——Tick 递减,双重登记窗口,靠OSTCBStat回检判定谁赢。
  • 为什么互斥量不能在中断里用?——优先级继承依赖 TCB 和调度器参与,中断上下文里没有这些前提。
  • 信号量和互斥量的本质区别是什么?——所有权,而不是计数范围。
  • X 信号量计数溢出会怎样?——返回溢出错误码,OSEventCnt是 16 位。

能把这些串起来讲清楚,基本就说明你不是只看了 API 手册。

7.7 我自己读这段源码时的一个小习惯

最后分享一个我个人的做法:读OS_SEM.C的时候,我会在旁边开一个记事本,把每个函数里"临界区的进出位置"单独列一列。原因很简单——RTOS 里的 bug 十有八九和临界区有关。列完之后你会发现OSSemPost的临界区里包含了OS_EventTaskRdy但排除了OS_SchedOSSemPend的临界区里包含了OS_EventTaskWait但同样排除了OS_Sched。这个规律一旦被你看穿,再去看消息邮箱、消息队列、互斥量,你就知道该盯哪儿了,速度会快很多。

下一篇我会顺着这条线往OS_Q.C走,看消息队列怎么在同一个事件底座上长出"环形缓冲区 + 生产者消费者"这套结构,以及OSEventPtr指向OS_Q之后,OS_EventTaskRdy多出来的那个参数到底是干什么用的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询