uC/OS-II事件控制块与信号量源码解析
2026/9/18 9:14:37 网站建设 项目流程

1. 第 7 篇为什么单挑事件控制块:一个结构体扛起五种同步对象

先说一个我在项目上真实遇到的现象。板子跑起来半小时后,一个负责采集的任务突然再也不进循环体,用调试器一看,它卡在OSSemPend里出不来——调用它的地方传进去的是一个全局的信号量指针,而这个指针指向的事件控制块,早就在任务切换前被另一个模块用OSSemDel释放掉了。更麻烦的是,释放之后那块内存被OSSemCreate重新分配给了另一个用途完全不同的信号量,于是OSSemPend里那句pevent->OSEventType != OS_EVENT_TYPE_SEM的类型校验居然顺利通过,程序进入了一条完全错误的等待链。

顺着这条线索往下挖,就必然要正面阅读 uC/OS-II 的事件管理部分。信号量、互斥量、消息邮箱、消息队列、事件标志组这五种东西,在整个内核里共享同一个叫OS_EVENT的结构体,这套设计是 uC/OS-II 源码里最值得反复看的一处手笔。它把"等待任务表""类型标签""计数/指针"挤进一块十来字节的内存里,再用一张静态事件表统一管理。看完这一块,你对 RTOS 的理解会从"会用 API"跳到"知道 API 背后在动哪几个字节",这对看懂其他 RTOS(无论是国产的 LiteOS 还是各家芯片厂配的轻量内核)都通用。

本篇的核心是事件控制块与信号量的完整执行路径,代码主要落在OS_CORE.C的事件管理函数族、OS_SEM.C的三个 API,以及uCOS_II.H里的结构体定义。读源码前建议先明确三个问题:为什么要做统一事件块、等待任务表为什么不用链表、任务从"取不到信号量"到"被唤醒"中间到底发生了什么。这三个问题串起来,就是 uC/OS-II 同步机制的骨架。

1.1 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_TYPE_UNUSEDOS_EVENT_TYPE_MBOXOS_EVENT_TYPE_QOS_EVENT_TYPE_SEMOS_EVENT_TYPE_MUTEXOS_EVENT_TYPE_FLAG。很多人写业务代码时会觉得这个字段是多余的——"我自己知道创建的是信号量啊"。但正是它,让一个被释放后又复用的内存块至少能挡住一部分误用;也是它,让OS_EventTaskRdy这类公共函数不需要知道自己在处理哪种同步对象。

OSEventPtr的语义随类型而变:做消息邮箱时它指向那条消息,做消息队列时它指向OS_Q结构,做信号量和互斥量时它是空的。同一个字段在不同场景下承载不同含义,这是 C 语言里省内存的常规做法,代价是读代码时必须时刻记住"当前这个对象是什么类型"。

OSEventCnt就更有意思了。做信号量时它是纯计数,取值 0 到 65535;做互斥量时,高字节被拿去存"是否开启优先级继承",低字节存"当前持有者的优先级"。一个 16 位字段干了两份活儿。后面第 5 节会专门拆这个设计。

最后两个字段是等待任务的位图:OSEventGrp一个字节,OSEventTbl[]若干个字节。当OS_LOWEST_PRIO配成 63(默认值)时,OS_EVENT_TBL_SIZE等于63/8 + 1 = 8,也就是 8 个字节。整个事件控制块在 32 位机器上大概 16 到 20 字节,这已经是一个相当克制的数字了。

1.2 OSEventFreeList:没有 malloc 的对象池

uC/OS-II 在OSInit里就把所有事件块一次性建好,用的是静态数组OSEventTbl[OS_MAX_EVENTS],然后串成一条单链表。串链表的指针不是新开字段,而是复用OSEventPtr——因为空闲块的OSEventPtr本来就没用。

OSEventFreeList = &OSEventTbl[0]; pevent = &OSEventTbl[0]; for (i = 0u; i < OS_MAX_EVENTS - 1u; i++) { pevent->OSEventType = OS_EVENT_TYPE_UNUSED; pevent->OSEventPtr = &OSEventTbl[i + 1u]; pevent++; } pevent->OSEventType = OS_EVENT_TYPE_UNUSED; pevent->OSEventPtr = (OS_EVENT *)0;

这里有几个必须记住的工程结论。第一,OS_MAX_EVENTS是在OS_CFG.H里编译期定死的,运行时用多少个事件块都不能超过它,超了OSSemCreate直接返回空指针。第二,这个分配链路没有内存碎片问题,也不会有分配耗时抖动,这是硬实时系统的底线。第三,如果你在调试时发现OSSemCreate返回NULL,不要去怀疑编译器,先去OS_CFG.H里数一数你一共创建了多少个信号量、邮箱、队列、标志组,OS_MAX_EVENTS至少要覆盖它们的总和。

我在一个 GD32F103 的小项目上就吃过这个亏:起初只配了 8 个,后来加了几个用于任务间握手的信号量,创建第 9 个时返回NULL,代码里又没判空,后面OSSemPend(NULL, ...)直接进硬件异常。加一个非空判断是习惯,但根子上还是配置值给得太紧。

1.3 五种同步对象共用一块内存的代价与收益

收益很直接:内核只需要维护一条空闲链,不需要为每种对象写一套分配逻辑;OS_EventTaskRdyOS_EventTaskWaitOS_EventTO这些等待/唤醒的核心函数也只需要写一份,五种对象全部复用。这会显著缩小内核体积——别忘了整个 uC/OS-II 才 6000 多行,这种"榨"出来的紧凑感是贯穿全篇的。

代价也很明确:任何一次误用都无法在编译期发现。你把一个邮箱句柄传给OSSemPend,编译器一声不吭,只能靠运行时的OSEventType校验拦一下。所以我在封装自己的同步模块时,习惯把句柄再用一层结构体包起来,类型信息跟着句柄走,出错时在封装层就挡掉了。

还有一点容易忽略:OSSemDel是有参数控制删除行为的。OS_DEL_NO_PEND表示"当前没有任务在等才删",OS_DEL_ALWAYS表示"不管有没有人等,全部唤醒再删"。前者安全但可能失败,后者粗暴但会留下"任务被唤醒后以为自己拿到资源"的隐患——被唤醒的任务在 v2.86 里OSTCBStatPend会被设成OS_STAT_PEND_ABORTOSSemPend会返回OS_ERR_PEND_ABORT,你必须在调用处处理这个错误码。很多人第一次写删除逻辑时对返回的错误码视而不见,结果任务拿着一个已经被释放的句柄继续跑。

2. 等待表不是链表:OSEventGrp 加 OSEventTbl 的位图设计

我第一次读到这里时的疑问是:既然已经有一条空闲链表了,为什么等待任务不用链表,偏偏用位图?链表插入删除都是 O(1),看着也挺好。等看完OS_EventTaskRdy的实现才明白——等待队列需要的是"找到最高优先级的等待者",而不是"先来先服务"。链表找最高优先级得遍历整条链,最坏 O(n);位图配合OSUnMapTbl查表,常数时间一步到位,而且这个常数跟等待任务数量无关。

2.1 位图结构与就绪表的对称性

如果你读过前面关于就绪表的章节,会发现这里的OSEventGrpOSEventTbl[]跟内核的OSRdyGrp/OSRdyTbl[]结构一模一样。这不是巧合,是有意对称:任务优先级prio拆成两部分,高三位是"组号"y = prio >> 3,低三位是"组内位号"x = prio & 0x07OSEventTbl[y]的第x位为 1,表示优先级为prio的任务正在这个事件上等待。

理解这个映射关系是读懂后面所有函数的前提。我建议你在纸上画一遍:假设OS_LOWEST_PRIO是 63,那么优先级 10 的任务,y = 1x = 2,落在OSEventTbl[1]的 bit2。整个等待表最多 64 个格子。

2.2 三个操作函数把位图玩明白了

初始化最简单,就是清干净:

void OS_EventWaitListInit (OS_EVENT *pevent) { INT8U i; pevent->OSEventGrp = 0x00; for (i = 0u; i < OS_EVENT_TBL_SIZE; i++) { pevent->OSEventTbl[i] = 0x00; } }

把当前任务挂到等待表,同时把它从就绪表里摘掉,注意这两件事必须在同一个临界区里完成:

void OS_EventTaskWait (OS_EVENT *pevent) { INT8U y; OSTCBCur->OSTCBEventPtr = pevent; /* 反向指针,方便超时清理 */ y = OSTCBCur->OSTCBY; OSRdyTbl[y] &= ~OSTCBCur->OSTCBBitX; /* 出就绪表 */ if (OSRdyTbl[y] == 0u) { OSRdyGrp &= ~OSTCBCur->OSTCBBitY; } pevent->OSEventTbl[y] |= OSTCBCur->OSTCBBitX; /* 进等待表 */ pevent->OSEventGrp |= OSTCBCur->OSTCBBitY; }

OSTCBEventPtr这个反向指针值得单独说一句。任务被唤醒或者超时时,内核需要知道"它当时在等哪个事件",才能把等待表里的对应位清掉。如果没有这个反向指针,超时清理时就只能遍历所有事件块,成本不可接受。这也解释了为什么一个任务同一时刻只能等一个事件——OSTCBEventPtr只有一个位置。

挑选最高优先级等待者是位图方案的高光时刻:

y = OSUnMapTbl[pevent->OSEventGrp]; /* 先找出第一个非空组 */ x = OSUnMapTbl[pevent->OSEventTbl[y]]; /* 再在组内找第一个置位的任务 */ prio = (INT8U)((y << 3) + x);

OSUnMapTbl是一张 256 字节的常量表,输入一个字节,输出最低置位的位置(从 0 数起)。因为优先级数字越小优先级越高,位图里 bit0 对应最高优先级,所以"最低置位"正好就是"最高优先级"。这一步没有循环,没有分支预测失败,执行时间完全固定,这正是实时内核需要的东西。

注意:OSUnMapTbl是 uC/OS-II 里少见的"空间换时间"典型。256 字节在 GD32F103 这种 64KB Flash 的芯片上不算小,但换来的是可预测的执行时间。如果你的芯片 Flash 极其紧张,可以删掉这张表换成循环查找,但要清楚你已经牺牲了确定性。

2.3 唤醒一个等待任务时到底改了多少东西

OS_EventTaskRdy是等待表的逆操作,同时它还要顺手完成一批"交接工作":

INT8U OS_EventTaskRdy (OS_EVENT *pevent, void *pmsg, INT8U msk) { OS_TCB *ptcb; INT8U x, y, prio; y = OSUnMapTbl[pevent->OSEventGrp]; x = OSUnMapTbl[pevent->OSEventTbl[y]]; prio = (INT8U)((y << 3u) + x); ptcb = OSTCBPrioTbl[prio]; ptcb->OSTCBDly = 0u; /* 取消延时,防止超时误伤 */ ptcb->OSTCBMsg = pmsg; /* 邮箱/队列专用,信号量为 NULL */ ptcb->OSTCBStat &= ~msk; /* 清掉对应等待原因 */ ptcb->OSTCBStatPend = OS_STAT_PEND_OK; /* 标记:正常被唤醒 */ pevent->OSEventTbl[y] &= ~(OS_PRIO)(1u << x);/* 从等待表摘除 */ if (pevent->OSEventTbl[y] == 0u) { pevent->OSEventGrp &= ~(OS_PRIO)(1u << y); } if ((OSRdyGrp & (OS_PRIO)(1u << y)) == 0u) { /* 加回就绪表 */ OSRdyGrp |= (OS_PRIO)(1u << y); } OSRdyTbl[y] |= (OS_PRIO)(1u << x); return (prio); }

请重点看ptcb->OSTCBDly = 0u这一行。任务在等待时可能设置了超时,它的OSTCBDly会被节拍中断每 tick 减一。既然它现在被正常唤醒了,就必须把剩余延时清零,否则下一秒节拍中断会把它的OSTCBDly减到 0,然后判定"超时",再往一个已经不在等待表里的任务身上做清理动作。这种"双重唤醒"是最难查的一类时序问题,uC/OS-II 用一行赋值堵住了它。

我当年在一个国产 RTOS 上移植类似的等待逻辑时忘了这一步,现象是"偶尔任务会莫名其妙收到一次超时错误",概率大概千分之几。排查了两天才定位到是延时计数没清零,节拍中断和信号量发送两条路径同时"认为"自己该处理这个任务。

3. OSSemPend 的完整执行路径:取不到就睡下去

OSSemPend是用户最常调用的函数,也是最值得逐行读的。它有三条出口:立刻拿到、睡一觉后被唤醒、睡一觉后超时。为了让你看清全貌,我把 v2.86 的代码按逻辑分段拆开讲,同时会指出老版本(如 v2.52)的差异——这个差异在面试里被问到的概率相当高。

3.1 快路径:计数还有余量就减一

OS_ENTER_CRITICAL(); if (pevent->OSEventType != OS_EVENT_TYPE_SEM) { OS_EXIT_CRITICAL(); *perr = OS_ERR_EVENT_TYPE; return; } if (pevent->OSEventCnt > 0u) { pevent->OSEventCnt--; OS_EXIT_CRITICAL(); *perr = OS_ERR_NONE; return; }

这段就是"信号量还有资源,直接拿走"。注意计数减一和类型校验都在临界区里,中间不会被节拍中断或任务切换打断。很多初学者写自己的锁时会写成"先读计数、再判断、再减一"三步,如果中间没有关闭中断,两个任务可能同时判断出"计数为 1"然后都减,最后计数变成 -1(或用无符号数直接回绕)。uC/OS-II 用临界区把这三步焊成一个原子操作。

OSEventType的校验放在最前面,这是防御性编程的一个示范:先确认对象类型对不对,再谈业务逻辑。代价是每次调用多几次内存访问,在 Cortex-M3 上这点开销可以忽略。

3.2 慢路径:三步把自己"挂"起来

取不到资源时,要做的事情比想象中多:

OSTCBCur->OSTCBStat |= OS_STAT_SEM; /* 记录等待原因 */ OSTCBCur->OSTCBStatPend = OS_STAT_PEND_OK; /* 清除上一次的挂起结果 */ OSTCBCur->OSTCBDly = timeout; /* 装载超时计数 */ OS_EventTaskWait(pevent); /* 挂等待表 + 出就绪表 */ OS_EXIT_CRITICAL(); OS_Sched(); /* 关中断之后才调度 */

OSTCBStat是一个位域,OS_STAT_SEMOS_STAT_MBOXOS_STAT_QOS_STAT_FLAGOS_STAT_MUTEX各占一位(不同版本位宽有差异)。一个任务理论上只会在一个事件上等,那为什么还要用位域?因为这个字段同时承载了挂起、延时等状态,是多个状态共用一块八位空间的结果。

OS_EventTaskWait执行完之后,当前任务已经不在就绪表里了。此时如果直接掉进调度器,它当然不会被选中,这正是我们想要的。但调度的时机必须放在退出临界区之后。原因有两个:一是OS_Sched内部自己会关中断,嵌套保护会白白多两次读写 PRIMASK;二是如果临界区里中断被长时间关闭,实时性就没了。所以你会看到 uC/OS-II 的固定套路——临界区里只做数据结构的原子修改,调度动作一律放到外面。

OSTCBDly = timeout也值得说。timeout为 0 表示无限等待,此时OSTCBDly置零,节拍中断里的超时判定逻辑会跳过这个任务。很多人误以为传 0 表示"不等待立刻返回",结果写出了一个永久阻塞的 bug。要非阻塞地取信号量,得用OSSemAccept(旧名OSSemPend之外的非阻塞版本),它只做减一或报错,从不阻塞。

3.3 醒来之后:怎么分辨"被唤醒"和"超时"

调度器切回来时,当前任务重新获得 CPU。它可能是被OSSemPost唤醒的,也可能是超时被节拍中断捞回来的。uC/OS-II 用OSTCBStatPend来区分:

OS_ENTER_CRITICAL(); if (OSTCBCur->OSTCBStatPend != OS_STAT_PEND_OK) { OS_EXIT_CRITICAL(); switch (OSTCBCur->OSTCBStatPend) { case OS_STAT_PEND_TO: *perr = OS_ERR_TIMEOUT; break; case OS_STAT_PEND_ABORT:*perr = OS_ERR_PEND_ABORT; break; default: *perr = OS_ERR_TIMEOUT; break; } return; } OSTCBCur->OSTCBEventPtr = (OS_EVENT *)0; /* 正常拿到,清反向指针 */ OS_EXIT_CRITICAL(); *perr = OS_ERR_NONE;

超时清理的动作在OS_EventTO里:

void OS_EventTO (OS_EVENT *pevent) { INT8U y; y = OSTCBCur->OSTCBY; pevent->OSEventTbl[y] &= ~OSTCBCur->OSTCBBitX; if (pevent->OSEventTbl[y] == 0u) { pevent->OSEventGrp &= ~OSTCBCur->OSTCBBitY; } OSTCBCur->OSTCBStat &= ~OS_STAT_SEM; OSTCBCur->OSTCBStatPend = OS_STAT_PEND_TO; OSTCBCur->OSTCBEventPtr = (OS_EVENT *)0; }

提示:v2.52 及更早版本没有OSTCBStatPend字段,OSSemPend用的是if ((OSTCBCur->OSTCBStat & OS_STAT_SEM) != 0)来判断是否超时。这两种写法的差别在于,"被异常唤醒"和"自己放弃等待"这两个场景能否被区分开。如果你在维护一份老代码,看到的是位判断而不是OSTCBStatPend,说明内核版本偏早,相关错误码也少一些。

这里还有一个容易埋雷的地方:OSTCBEventPtr在正常路径和超时路径里都会被清空。为什么必须清?因为如果不清,这个指针会一直悬着,指向某个事件块。万一这个事件块后面被删除并复用,任务里留存的这个指针就成了定时炸弹。uC/OS-II 在两条路径上都做了清理,说明作者是认真考虑过这个问题的。

4. OSSemPost 的三条分支与计数溢出陷阱

发送端的逻辑比接收端短,但分支的语义差异很大,而且溢出那条分支经常在真实项目里救过命。

4.1 有等待者:直接把资源交出去

OS_ENTER_CRITICAL(); if (pevent->OSEventType != OS_EVENT_TYPE_SEM) { OS_EXIT_CRITICAL(); return (OS_ERR_EVENT_TYPE); } if (pevent->OSEventGrp != 0u) { /* 有人在等 */ (void)OS_EventTaskRdy(pevent, (void *)0, OS_STAT_SEM); OS_EXIT_CRITICAL(); OS_Sched(); /* 可能触发切换 */ return (OS_ERR_NONE); }

只要等待表非空,就不去动OSEventCnt,而是直接把"资源"交给最高优先级的等待者,然后调用调度器。注意这里的语义:计数不加一,资源直通。如果这里先加一再去唤醒,会有两个任务同时以为自己拿到了同一份资源,信号量就失去了互斥意义。

OS_Sched放在临界区外,原因和第 3 节说的一样。

4.2 没有等待者:计数加一,但有上限

if (pevent->OSEventCnt < 65535u) { pevent->OSEventCnt++; OS_EXIT_CRITICAL(); return (OS_ERR_NONE); } OS_EXIT_CRITICAL(); return (OS_ERR_SEM_OVF);

OSEventCntINT16U,上限 65535。这里用< 65535u而不是直接++,就是为了防止回绕。这个溢出保护在教科书的信号量实现里经常被省略,但它非常必要——一旦计数回绕到 0,后续所有OSSemPend都会立刻返回,互斥保护形同虚设,而且现象极其诡异。

4.3 OS_ERR_SEM_OVF 为什么值得停下来查代码

我个人的经验是:OSSemPost返回OS_ERR_SEM_OVF基本可以断定是设计问题而不是偶发故障。信号量计数能涨到 65535,意味着"发送"的次数比"接收"多出了六万多次。常见原因有这么几个:

现象可能原因排查动作
计数持续增长发送方在不该发的时候也发,比如定时器回调无条件 post在 post 前加断点,看调用栈
计数缓慢增长接收方某条分支提前 return,漏掉一次 pend检查任务函数里所有 return 路径
计数瞬间涨满中断里 post 次数远超预期用 GPIO 翻转 + 示波器数中断频率
创建时参数写错OSSemCreate(1)被误写成OSSemCreate(0xFFFF)复查初始化代码

一个更隐蔽的场景是"信号量被当成事件通知用"。信号量本身是计数型的,每 post 一次就多一份资源,它不会记住"已经通知过了"。如果你的意图是"只通知一次、后续通知都忽略",那应该用事件标志组,而不是信号量。我在一个按键处理模块里见过这种写法:按键中断里 post 信号量,任务里 pend 后处理。按键连击时会 post 很多次,任务处理不过来,计数就开始堆积。后来改成事件标志组并做清零处理,问题就消失了。

注意:OSSemPost是可以从中断服务程序里调用的,这在内核文档里明确列出。但有一点必须清楚——在 ISR 里调OS_Sched不会立刻发生任务切换,因为OSIntNesting大于 0,调度器会直接返回。真正的切换发生在OSIntExit的末尾。理解这个机制,你才不会写出"在中断里等任务切换完再继续"这种错误假设。

5. 信号量、互斥量、消息队列在源码层面的分岔口

看完信号量,很多人会以为其他几种对象只是"换个名字"。实际上它们在同一个OS_EVENT结构上做了完全不同的字段复用,这个复用方式恰恰暴露了它们的设计意图。

5.1 OSEventCnt 在互斥量里的一物二用

互斥量的OSEventCnt被拆成两半用:

  • 高字节:OSMutexCreate时写入的一个标志,表示是否允许优先级继承(uC/OS-II 里prio参数传入OS_PRIO_MUTEX_CEIL_DIS就是不启用)。
  • 低字节:当前持有互斥量的任务优先级。没有任务持有时是OS_NO_MUTEX_OWNER
pevent->OSEventCnt = (INT16U)prio; /* 低字节存"天花板优先级" */ pevent->OSEventPtr = (void *)0;

OSMutexPend拿到锁时,会把持有者优先级写进去;OSMutexPost释放时清掉。这个字段之所以能这么用,是因为互斥量不需要计数——它只能是 0 或 1。信号量是"有多少资源",互斥量是"谁拿着锁",这一句话概括了两者的根本差别。

5.2 OSEventPtr 在邮箱和队列里的角色

消息邮箱(MBOX)里,OSEventPtr直接指向那条消息,所以邮箱的容量就是 1,发第二条时如果没人取,会返回OS_ERR_MBOX_FULL(或覆盖,取决于版本)。消息队列(Q)里,OSEventPtr指向一个OS_Q结构,里面才是真正的环形缓冲数组。

这个差别在工程上很重要。用邮箱传递一条指针型消息,内核只搬 4 个字节,很轻;用队列传结构体,你通常也是传指针进入队列,队列里存的是指针数组。无论哪种,uC/OS-II 都不做深拷贝,这意味着消息指向的内存块生命周期必须由你自己管理。我在项目里立过一条规矩:凡是进入队列的缓冲区,一律从固定的内存池里取,用完归还,绝不使用局部数组的地址。这条规矩源于一次惨痛的调试——任务 A 在栈上定义了数组,把地址塞进队列就返回了,任务 B 取出来读到的是一堆随机值。

5.3 优先级反转:为什么保护共享资源不能用信号量

这是 RTOS 面试里出现频率最高的问题之一,而答案在源码里看得清清楚楚:OSSemPend里没有任何关于优先级调整的代码,OSMutexPend里有。

/* OSMutexPend 里的关键片段(示意) */ if (pevent->OSEventPtr != (void *)0) { /* 已被别的任务持有 */ powner = (OS_TCB *)pevent->OSEventPtr; if (OSTCBCur->OSTCBPrio < powner->OSTCBPrio) { /* 我优先级更高 */ ... OS_PrioRemove(powner->OSTCBPrio); /* 抬升持有者优先级 */ ... } ... /* 然后才是把自己挂到等待表 */ }

设想一个经典的三任务场景:低优先级任务 L 拿着锁,中优先级任务 M 在跑计算,高优先级任务 H 想拿锁却拿不到,只能等 L 释放。如果 L 被 M 抢占,H 就得等 M 跑完——一个高优先级任务被中优先级任务间接阻塞,这就是优先级反转。互斥量通过临时把 L 的优先级抬到和 H 一样,让 L 尽快跑完并释放锁,从而把这个窗口压到最小。信号量没有这套机制,所以保护共享资源必须用互斥量,信号量用于任务间同步和计数

我在实际项目里见过用信号量当锁的代码,在负载轻的时候表现正常,一旦引入一个计算密集的中优先级任务,系统就开始出现"偶发卡顿"。把信号量换成互斥量之后,卡顿消失。这个教训值得写进团队规范里。

6. 在 GD32F103 上把信号量跑起来:移植层的几个关键点

理论讲完了,落到板子上。GD32F103 是 Cortex-M3 内核,主频 108MHz,和 STM32F103 属于同一类器件,uC/OS-II 的移植思路完全通用。

6.1 临界区宏与 PRIMASK 的关系

uC/OS-II 推荐用OS_CRITICAL_METHOD == 3,也就是"读-关-恢复"的模式:

/* OS_CPU.H */ #define OS_ENTER_CRITICAL() { cpu_sr = OS_CPU_SR_Save(); } #define OS_EXIT_CRITICAL() { OS_CPU_SR_Restore(cpu_sr); }

对应的汇编实现(OS_CPU_A.ASM或内联汇编):

OS_CPU_SR_Save MRS R0, PRIMASK CPSID I BX LR OS_CPU_SR_Restore MSR PRIMASK, R0 BX LR

这套实现的好处是支持嵌套——在内层临界区退出时,恢复的是进入前的PRIMASK值,不会把外层关闭的中断给打开。这一点非常重要:如果你的临界区宏写成"进入时关中断、退出时无条件开中断",那么在嵌套调用中,内层退出就会提前打开中断,OS_EventTaskWait这类"出就绪表 + 挂等待表"的操作就可能被中断打断,产生不可预期的状态。

提示:OS_CRITICAL_METHOD有 1、2、3、4 四种写法,新项目一律用 3。老代码里如果看到方法 1(直接__disable_irq()/__enable_irq()),说明它不支持嵌套,嵌套场景下要格外小心。

6.2 在中断里发信号量的正确写法

这是实践中最容易出问题的地方。一个典型场景:定时器中断里通知任务做采样。

void TIMER2_IRQHandler(void) { OSIntEnter(); /* 通知内核:进中断了 */ if (TIMER_INT_FLAG_UP == timer_interrupt_flag_get(TIMER2, TIMER_INT_FLAG_UP)) { timer_interrupt_flag_clear(TIMER2, TIMER_INT_FLAG_UP); OSSemPost(SampleSem); /* 允许在 ISR 里调用 */ } OSIntExit(); /* 出中断,这里可能触发切换 */ }

要点有三条。第一,OSIntEnter/OSIntExit必须成对出现,它们维护的是OSIntNesting计数,调度器靠这个计数判断"现在能不能切任务"。第二,OSSemPost在 ISR 里调用是安全的,它会修改等待表和就绪表,但它触发的OS_Sched会因为OSIntNesting > 0而直接返回,真正的切换在OSIntExit末尾完成。第三,OSSemPend绝对不能在 ISR 里调用,因为它会尝试阻塞当前上下文,而中断上下文没有 TCB,行为未定义。

如果你希望中断处理尽可能短,可以进一步优化:中断里只置标志 + 发信号量,重活留给任务。我在一个 1kHz 采样项目里这么做过,中断服务程序压缩到十来个时钟周期,任务侧的抖动明显变小。

6.3 用调试器看等待表:一次真实排查记录

最后分享一个我常用的排查手法。当怀疑信号量有问题时,不要光看计数,直接看等待表。以 GD32 + Keil 为例:

  1. 全速运行前,把SampleSem这个全局指针加入 Watch 窗口。
  2. 展开*SampleSem,查看OSEventCntOSEventGrp
  3. 如果OSEventCnt一直是 0 而OSEventGrp非 0,说明有任务在等而没人发。
  4. 如果OSEventCnt持续上涨,说明发的比收的多,回到第 4.3 节那张表排查。

我遇到过的一次真实故障:OSEventCnt稳定为 0,OSEventGrp = 0x01OSEventTbl[0] = 0x0C。换算一下,OSEventTbl[0]的 bit2 和 bit3 置位,对应优先级 2 和 3 的任务都在等待。再查代码发现是一个初始化顺序问题——负责发送的任务优先级比接收任务低,接收任务先跑起来等信号量,发送任务却因为更低的优先级一直跑不上来,形成了死等。把信号量创建时机提前、或者调整任务优先级后问题解除。这个案例说明:信号量本身没有错,错的是任务优先级设计

7. 面试常问的几个点,用源码回答

聊到这儿,顺手把几个高频问题用源码视角回答一下,这些回答比背结论靠谱得多。

问:信号量和互斥量能不能互相替代?不能。信号量是计数型,可用于同步(一个任务发、另一个任务收),不提供优先级继承,不适合保护共享资源;互斥量只有 0/1 两态,记录持有者,提供优先级继承,专为保护共享资源设计。源码层面的证据是OSMutexPend里那段抬升持有者优先级的代码,OSSemPend里根本没有。

问:OSSemPend 的超时是怎么实现的?OSTCBDly加节拍中断。pend 时把timeout装进OSTCBDly,每个 tick 中断把它减一,减到 0 时执行OS_EventTO把任务从等待表摘出来并置OSTCBStatPend = OS_STAT_PEND_TO,然后触发调度。timeout传 0 表示无限等待,不是"立即返回"。

问:为什么等待表用位图而不是链表?因为需要 O(1) 找出最高优先级等待者,位图配合OSUnMapTbl查表可以做到执行时间固定,且与等待任务数量无关。链表虽然插入删除快,但找最高优先级要遍历。

问:RTOS 的信号量和 Linux 的有什么本质差别?从使用视角看,Linux 的信号量(或更常用的 futex、互斥锁)建立在虚拟内存和调度器之上,阻塞时会进入睡眠,切换成本高但语义丰富;uC/OS-II 的信号量是纯内存位图操作,pend 时的"阻塞"只是把自己从就绪表挪到等待表,切换代价是几十个时钟周期,规模小但确定性极强。前者追求吞吐与公平,后者追求可预测的响应时间,这是两类系统的根本分野。

把 uC/OS-II 的事件管理读透之后,我再去看 LiteOS 或者其他轻量内核的同步机制,会发现思路高度相似——统一的等待结构、位图优先级队列、超时链表、中断里只做唤醒不做切换。这套模式几乎是小型 RTOS 的标准答案。真要动手改内核的话,从OSSemPendOSSemPost这两百来行入手是最合适的,改完跑一遍任务切换的时序测试,能直观感受到那些临界区到底保护了什么。

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

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

立即咨询