翻源码这事很容易上头。前阵子群里有人抛了个问题:信号量 Pend 超时返回之后,那个任务到底还在不在事件等待表里?我第一反应是"当然不在了",结果把OSSemPend的尾巴读了两遍,后背有点凉——超时唤醒的任务只被OSTimeTick塞回了就绪表,它挂在OSEventTbl上的那一位根本没人动,得靠 Pend 自己返回前亲手摘掉。这种细节,不看源码是想象不出来的。
这一篇是 uC/OS-II 内核源码精读系列的第 8 篇,聊的是整个 RTOS 任务间通信的底座:事件控制块OS_EVENT,以及建在它上面的信号量、互斥量,顺带把事件标志组、邮箱、消息队列的复用关系捋一遍。6736 行代码里,事件相关的部分也就六百行出头,却撑起了几乎所有同步与互斥场景。读完这篇你至少能搞清楚三件事:一次OSSemPend从入口校验、挂链、切走,到被唤醒后重新拿回 CPU 并返回错误码,中间到底发生了什么;OSMutexPend里那段看着绕的while循环在解决什么现实问题;以及把这份代码搬到 GD32F103 这类 Cortex-M3 板子上跑的时候,哪些地方最容易翻车。
1. 6736 行里的"万能插座":事件控制块统一了什么
RTOS 里最容易写崩的地方不是调度器,而是同步机制。调度器只有一个,逻辑收敛;同步机制有五种,如果每种各写一套数据结构、各写一套等待链表、各写一套唤醒逻辑,代码量轻松翻倍,而且每加一种就要动一次位图操作,出错概率呈指数上升。uC/OS-II 作者 Jean Labrosse 的选择很干脆:只做一种结构体,五种通信机制全部寄生在它身上。
1.1 OS_EVENT 的字段复用账本
先看结构体本身,在uCOS_II.H里:
typedef struct os_event { INT8U OSEventType; /* 事件类型 */ void *OSEventPtr; /* 消息指针 / 所有者指针 */ INT16U OSEventCnt; /* 计数器 / 优先级继承参数 */ INT8U OSEventGrp; /* 等待任务组位图 */ INT8U OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任务表位图 */ #if OS_EVENT_NAME_SIZE > 1 INT8U OSEventName[OS_EVENT_NAME_SIZE]; #endif } OS_EVENT;整个结构体不到 20 字节,没有指针链表、没有红黑树、没有自旋锁。它之所以能同时当五种东西用,靠的就是字段按类型分时复用。我把常见的对应关系整理成一张表,读源码时对着这张表走,比反复翻头文件快得多:
| 字段 | 信号量 | 互斥量 | 邮箱 | 消息队列 | 事件标志组 |
|---|---|---|---|---|---|
| OSEventType | SEM | MUTEX | MBOX | Q | FLAG |
| OSEventCnt | 可用计数 0~65535 | 优先级继承参数(含类型校验位) | 未使用 | 未使用 | 未使用 |
| OSEventPtr | 未使用 | 指向占用者 TCB | 消息指针 | 指向队列控制块 OS_Q | 与 OSEventTbl 共用内存放标志值 |
| OSEventGrp | 等待任务的组位 | 同左 | 同左 | 同左 | 同左 |
| OSEventTbl | 等待任务的成员位 | 同左 | 同左 | 同左 | 被标志组结构覆盖 |
这里有个很典型的偷懒手法值得单独说:事件标志组在 2.86 这一类版本里,是把OS_FLAG_GRP结构体直接覆盖在OS_EVENT的OSEventTbl内存上,也就是说同一块内存,你从这一头看是等待任务的位图,从那一头看就是 32 位的标志值。省内存是真的,可读性差也是真的。我第一次读到这里的时候,盯着(OS_FLAG_GRP *)&pevent->OSEventTbl[0]这行强制类型转换看了十分钟才确认自己在看什么。
1.2 OS_EventWaitListInit 的初始化代价
每个事件被创建时都会调一次OS_EventWaitListInit(),干的事简单到有点无聊:
void OS_EventWaitListInit (OS_EVENT *pevent) { INT8U *ptbl; pevent->OSEventGrp = 0x00; ptbl = &pevent->OSEventTbl[0]; for (i = 0; i < OS_EVENT_TBL_SIZE; i++) { *ptbl++ = 0x00; } }OS_EVENT_TBL_SIZE等于(OS_LOWEST_PRIO / 8 + 1)。默认OS_LOWEST_PRIO是 63,也就是 64 个优先级,位图正好 8 字节。清 8 个字节加一个字节的组位,在 72MHz 的 Cortex-M3 上大概是几十个时钟周期的事——但别忘了,这个函数会在每个信号量、每个队列创建时都跑一遍,如果你在app_cfg.h里把OS_MAX_EVENTS开到 128 甚至更大,初始化阶段的这点开销是会累积的。
提示:位图大小取决于
OS_LOWEST_PRIO。如果你只用了 32 个优先级(OS_LOWEST_PRIO = 31),OSEventTbl就只有 4 字节,事件控制块整体缩水。这个参数改小除了省 RAM,还能让OSUnMapTbl查表命中的位更集中,属于纯收益的裁剪,我一般会在产品定型前把它改到刚好够用。
1.3 为什么不是每种通信方式各写一套结构体
这个问题在面试里也常被问。我的回答通常是这样:统一结构体的第一收益是代码路径统一,第二收益是故障模型统一。因为五种机制的 Pend/Post 走的是同一套OS_EventTaskWait/OS_EventTaskRdy,所以"任务挂起""任务唤醒""超时摘链"这三件事只需要写一次、测一次。你在信号量上验证过的调度行为,天然地也在消息队列上成立。
代价也很明显:类型安全性完全靠运行时的OSEventType字段和一句if (pevent->OSEventType != OS_EVENT_TYPE_SEM)撑着。你拿一个队列的指针去调OSSemPend,编译器一声不吭,运行时返回OS_ERR_EVENT_TYPE。所以我在自己项目里养成一个习惯——所有事件句柄都用带类型前缀的变量名,sem_BusReady、q_CanRx、mbox_AdcDone,出问题时日志一眼能看出来对没对上。
2. OSSemPend 从入口到 OS_Sched 的完整路径
信号量的 Pend 是整个事件机制里最常被调用、也最值得逐行读的一支。它短,但每一步都不能少,少一步就是一个隐蔽的竞争条件。
2.1 三次非法调用检查,顺序不能乱
void OSSemPend (OS_EVENT *pevent, INT16U timeout, INT8U *perr) { if (pevent->OSEventType != OS_EVENT_TYPE_SEM) { *perr = OS_ERR_EVENT_TYPE; } if (OSIntNesting > 0) { *perr = OS_ERR_PEND_ISR; } if (OSLockNesting > 0) { *perr = OS_ERR_PEND_LOCKED; } /* 真正的逻辑从这里开始 */ }这三句看着像样板代码,其实每句都对应一类真实事故。
第一句拦的是"传错句柄"。你能想象到的低级错误,包括信号量还没创建就对它 Pend、创建失败返回NULL之后没判断直接往下用,都会在这里被挡住。
第二句拦的是"在中断里 Pend"。这是新手最常犯的错,也是后果最严重的错之一。中断里 Pend 意味着什么?意味着要执行OS_Sched(),而OS_Sched()会尝试做任务切换。中断上下文里切任务,栈指针会乱套,轻则返回后跑飞,重则直接进 HardFault 死循环。所以 uC/OS-II 干脆在入口就把它拦死,返回OS_ERR_PEND_ISR。反过来说,OSSemPost()是允许在中断里调用的——这个不对称性几乎是 RTOS 面试的必考题。
第三句拦的是"调度器被上锁期间 Pend"。OS_SchedLock()会递增OSLockNesting,很多人在关调度之后为了图方便直接调 Pend,结果就是任务永远等不到唤醒,因为OS_Sched()被OSLockNesting挡住了不会真的切走。
注意:这三句检查用的是
if而不是if...else if,所以严格说后面的检查会覆盖前面的*perr。也就是说如果你同时传错类型又在中断里调用,最终*perr可能是OS_ERR_PEND_LOCKED。这不是 bug,但如果你在调试时发现错误码"不对路",记得回头看看是不是多个错误同时成立。
2.2 OS_EventTaskWait:一只脚抬起,一只脚落下
过了校验,进临界区,先看快速路径:
OS_ENTER_CRITICAL(); if (pevent->OSEventCnt > 0) { /* 资源可用 */ pevent->OSEventCnt--; OS_EXIT_CRITICAL(); *perr = OS_ERR_NONE; return; }这四行是信号量最高频的执行路径,也是它比互斥量快的根本原因——只是把一个INT16U减一,没有链表操作,没有任何遍历。如果你的系统里信号量大部分时间都是可用的,那 Pend 的开销基本就是进出临界区那点指令。
资源不可用,才走慢路径:
OSTCBCur->OSTCBStat |= OS_STAT_SEM; OSTCBCur->OSTCBStatPend = OS_STAT_PEND_OK; OSTCBCur->OSTCBDly = timeout; OS_EventTaskWait(pevent); OS_EXIT_CRITICAL(); OS_Sched();OS_EventTaskWait()是整个过程的核心,它做两件事,可以理解成"一只脚抬起,一只脚落下":
void OS_EventTaskWait (OS_EVENT *pevent) { INT8U y; OSTCBCur->OSTCBEventPtr = pevent; /* 记住我在等谁 */ if ((OSRdyTbl[OSTCBCur->OSTCBY] &= ~OSTCBCur->OSTCBBitX) == 0x00) { OSRdyGrp &= ~OSTCBCur->OSTCBBitY; /* 抬起就绪表的脚 */ } pevent->OSEventTbl[OSTCBCur->OSTCBY] |= OSTCBCur->OSTCBBitX; pevent->OSEventGrp |= OSTCBCur->OSTCBBitY; /* 落到等待表的脚 */ }OSTCBY、OSTCBBitX、OSTCBBitY这三个字段是在任务创建时就预计算好并存在 TCB 里的。这个设计很值得学:优先级到"组号 + 组内位号"的换算本来需要移位和与运算,放在每次挂起/唤醒时算就是白白的开销,所以作者选择在OSTaskCreate里算一次、存起来,之后每次都是直接取用。OSTaskChangePrio里之所以要重算这三个字段,也是这个道理。
"抬起就绪表的脚"那段的写法也讲究:只有当一个字节被清零之后,才去动OSRdyGrp。如果同一个字节里还有别的任务就绪,组位就不能动。这种"先判断再改上位图"的模式在 uC/OS-II 里出现次数非常多,读源码时看到if ((OSRdyTbl[...] &= ...) == 0x00)这种嵌套写法,就是它。
2.3 OS_Sched 之后那句 OS_ENTER_CRITICAL 才是关键
OS_Sched()一调用,当前任务就交出了 CPU。对当前这条执行流来说,代码"停"在这里了。等到某个时刻别人Post了这个信号量、或者OSTimeTick把超时时间减到 0,这条流才会从OS_Sched()里返回。
返回之后第一件事是什么?重新进临界区。
OS_ENTER_CRITICAL(); switch (OSTCBCur->OSTCBStatPend) { case OS_STAT_PEND_OK: *perr = OS_ERR_NONE; break; case OS_STAT_PEND_TO: default: OS_EventTaskRemove(OSTCBCur, pevent); *perr = OS_ERR_TIMEOUT; break; case OS_STAT_PEND_ABORT: OS_EventTaskRemove(OSTCBCur, pevent); *perr = OS_ERR_PEND_ABORT; break; } OSTCBCur->OSTCBStatPend = OS_STAT_PEND_OK; OS_EXIT_CRITICAL();为什么必须重新进临界区?因为从被唤醒到真正拿到 CPU 之间有一段不确定的时间窗口,在这段时间里别的中断或任务可能又改动了状态。OSTCBStatPend这个字段是"这次 Pend 的结论",2.86 之后的版本专门把它从OSTCBStat里拆出来,就是为了避免"挂起原因"和"挂起结论"两件事挤在一个字节里互相干扰。老版本是用OSTCBStat的高位来编码 OK/TIMEOUT/ABORT 的,读老代码的时候要注意这个差别。
这里就是我在开头说的那个坑:超时路径必须自己摘链。OSTimeTick在把OSTCBDly减到 0 之后,只做了两件事——把OSTCBStatPend标成OS_STAT_PEND_TO,然后如果任务没被挂起,就把它塞回就绪表。它完全没有碰pevent->OSEventTbl和pevent->OSEventGrp。所以那个任务此刻的状态是"在就绪表里,同时还在事件等待表里"。如果OSSemPend的尾巴不调OS_EventTaskRemove()把自己摘干净,下一次有人Post这个信号量,OS_EventTaskRdy会从等待表里把这个"根本没在等"的任务捞出来,给它置就绪位、写返回状态——一个僵尸唤醒就诞生了,而且信号量的计数也被白白消耗掉一次。
这个设计其实是有意为之的:OSTimeTick是中断服务程序,它在中断上下文里遍历的是 TCB 链表,如果让它顺带去摘事件等待表,势必要拿到OSTCBEventPtr再回头改事件块,路径变长、临界区变宽,对中断延迟是坏事。把清理工作推给 Pend 的尾巴,让中断只做最轻的活,是典型的 RTOS 取舍思路。
2.4 各个错误码的含义与常见误用
把OSSemPend可能返回的码整理一下,出问题时对着查比翻手册快:
| 错误码 | 触发时机 | 我实际遇到的典型场景 |
|---|---|---|
| OS_ERR_NONE | 正常获取 | 无 |
| OS_ERR_EVENT_TYPE | 句柄类型不是信号量 | 队列句柄误传、创建失败返回 NULL |
| OS_ERR_PEND_ISR | 在 ISR 里调用 | 在定时器回调里直接 Pend |
| OS_ERR_PEND_LOCKED | 调度器已上锁 | OSSchedLock()之后忘了解锁 |
| OS_ERR_TIMEOUT | 超时仍未获取 | 波特率配置错导致永远收不到数据 |
| OS_ERR_PEND_ABORT | 被OSSemPendAbort打断 | 关机流程里批量中止等待任务 |
有个细节容易忽略:*perr在函数开头被赋值,函数中间一旦命中快速路径就直接 return,不会再改。所以每次调用前最好把 perr 初始化为一个明显的非法值(比如 0xFF),否则一旦某条路径忘了赋值,你看到的会是上一次调用留下的旧值,排查起来极其迷惑。
3. OSSemPost 的唤醒:位图查表法的 O(1) 魔法
挂起是把任务塞进等待表,唤醒就是从等待表里挑出优先级最高的那个。这一步的实现方式,直接决定了 RTOS 的确定性。
3.1 OS_EventTaskRdy 做的四件事
唤醒不是一个人干的活,OSSemPost只是入口,真正动手的是OS_EventTaskRdy()。这个方法干四件事:
- 从
OSEventGrp/OSEventTbl两级位图里找出优先级最高的等待任务; - 清掉它的
OSTCBDly(不让 tick 再去处理它); - 把
OSTCBStatPend写成OS_STAT_PEND_OK,并把OSTCBStat里的等待标志位清掉; - 如果这个任务没有被
OSTaskSuspend挂起,就把它放回OSRdyGrp/OSRdyTbl;同时从事件等待表里清掉它的位。
y = OSUnMapTbl[pevent->OSEventGrp]; x = OSUnMapTbl[pevent->OSEventTbl[y]]; prio = (INT8U)((y << 3) + x);这三行是整篇最值得抄下来的技巧。OSUnMapTbl是一张 256 字节的常量表,输入一个字节,返回它最低的那个"1"位的位置。先查组位图拿到组号y,再用该组字节查一次拿到组内位号x,(y << 3) + x就是优先级编号。
因为位图的位序和优先级的数值大小方向是"数值越小、位越靠低",所以"最低的 1 位"正好对应"优先级最高的等待任务"。查两次表、做一次移位和加法,整个过程是常数时间,跟等待任务的数量完全无关。这就是 RTOS 的确定性——不管有 1 个还是 60 个任务在等,唤醒耗时一样。换成链表遍历,最坏情况就得顺着 60 个节点走一遍,实时性指标根本没法给。
3.2 位图设计为什么能同时管住就绪和等待
值得停下来想一想的是:OSRdyGrp/OSRdyTbl和OSEventGrp/OSEventTbl这两套位图,结构是完全对称的。这不是巧合,而是刻意的设计——位位置和优先级一一绑定。
优先级 5 的任务,它在就绪表里占OSRdyTbl[0]的第 5 位,在它等待的那个事件块的等待表里也占OSEventTbl[0]的第 5 位。因为位置绑定,挂起和唤醒都退化成"改某一位"的按位操作,一次|=或&=~,几个时钟周期搞定,不需要查找、不需要搬移、不需要遍历。
这个设计的另一个好处是"非法状态可检测"。理论上一个任务同时只能等一个事件,所以如果它在两个事件块的等待表里都有位,那就是 bug。我在调试时会在释放信号量的钩子函数里加一段断言,检查OSTCBCur->OSTCBEventPtr是否为空,一旦发现某个被唤醒的任务OSTCBEventPtr还有残留,基本能立刻定位到是哪里漏了摘链。
3.3 OSSemPost 的两个分支与溢出判断
再看OSSemPost的逻辑:
OS_ENTER_CRITICAL(); if (pevent->OSEventGrp != 0x00) { /* 有任务在等 */ (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 < 65535) { /* 没人在等,计数+1 */ pevent->OSEventCnt++; OS_EXIT_CRITICAL(); return (OS_ERR_NONE); } OS_EXIT_CRITICAL(); return (OS_ERR_SEM_OVF);逻辑非常清楚:有人等就直接给他,没人等就累加计数,计数满了报溢出。
两个细节值得说。
第一,计数上限是 65535 而不是 4294967295,因为OSEventCnt是INT16U。这个设计在绝大多数场景下够用,但你如果把它当"事件累计次数统计"来用(比如 ISR 里 Post、任务里定期 Pend 一次把积压全消化掉),跑得久了就可能溢出。OS_ERR_SEM_OVF这个返回值很多人从来没判断过,结果就是计数静默饱和、丢事件。我的习惯是:如果信号量被用作事件计数,一定在 Post 的调用处判断返回值。
第二,OS_Sched()的调用时机。2.86 这一版的OSSemPost只要有等待者就无条件调一次OS_Sched(),连OS_EventTaskRdy的返回值都用(void)强制丢掉了;而在更早的版本里还留着if (prio < OSTCBCur->OSTCBPrio)这样的优先级比较。两种写法各有取舍:无条件调用多花一次调度判断的开销(OS_Sched内部会先找最高优先级就绪任务,如果还是自己就什么都不做),但绝对不会漏切;带比较的写法省一次判断,但要求你对"什么时候该切"的判断绝对准确。写实时系统,我倾向于宁可多花这几十个周期换"不会漏切",因为漏切导致的时序 bug 是最难复现的一类。
3.4 Post 之后的调度:为什么在中断里也没问题
OSSemPost末尾调用OS_Sched(),那在中断里直接 Post 会怎样?答案是安全的。OS_Sched()内部第一件事就是检查OSIntNesting,如果在中断里就直接返回,真正的切换交给中断返回前的OSIntExit()完成。也就是说,中断里的 Post 只是"标记有任务该跑",切换动作统一推迟到中断退出点。
这个设计的好处是中断服务程序里的代码路径是可预测的:不管 Post 唤醒了多少个任务,ISR 的时间都不会因为要切换上下文而膨胀。我见过一些自研的轻量调度器把切换直接塞在 ISR 里做,结果就是同一个中断的执行时间忽长忽短,很难给中断延迟指标定界。
4. 互斥量与优先级继承:OSMutexPend 里那个 while 循环
信号量能解决同步,但解决不了优先级反转。要理解互斥量,得先把反转这件事的时间线在脑子里画清楚。
4.1 优先级反转的真实时间线
假设三个任务:高优先级 H(10)、中优先级 M(20)、低优先级 L(30),外加一个互斥量 Mtx。
- L 先跑,拿到 Mtx,进临界区开始干活。
- H 就绪,抢占 L,尝试拿 Mtx,失败,被阻塞。
- 此时 M 就绪。因为 L 的优先级比 M 低,M 会抢占 L。
- H 在等 L,L 被 M 压着跑不动,M 也不碰 Mtx。结果:H 被一个跟它毫无关系的中优先级任务无限期地拖住了。
这就是优先级反转。1980 年代末的一个著名航天项目就栽在这个问题上,而且是那种"实验室里跑一万次都不出问题、上了天就出问题"的类型。uC/OS-II 的解决办法是优先级继承:当 H 因为等 Mtx 而阻塞时,把 Mtx 当前占用者 L 的优先级临时提升到 H 的水平,让 L 能抢占 M 赶紧干完、赶紧释放。
4.2 一条链上的连锁提升
单层的提升好懂,麻烦的是链式等待。设想 L 拿着 Mtx1,然后去等 Mtx2;而 Mtx2 被另一个更低优先级的任务 L2 拿着。当 H 来等 Mtx1 时,光把 L 提上去还不够——L 被提升了,但 L 卡在 Mtx2 上,Mtx2 的持有者 L2 还是低优先级,H 照样被拖住。所以提升必须沿着等待链一路往上走。
OSMutexPend里那段while循环就是干这个的。它的逻辑大致是这样(不同版本细节有差异,但套路一致):
while (ptcb->OSTCBPrio > OSTCBCur->OSTCBPrio) { /* 占用者优先级还不够高 */ if (ptcb->OSTCBEventPtr != (OS_EVENT *)0) { /* 占用者自己也在等某个东西 */ pevent2 = ptcb->OSTCBEventPtr; /* 把占用者的优先级搬到请求者的位置上 */ /* 重算它的 OSTCBY / OSTCBBitX / OSTCBBitY,搬动就绪位图 */ ptcb->OSTCBPrio = OSTCBCur->OSTCBPrio; /* 如果占用者等的是另一个互斥量,继续顺着链往上找 */ if (pevent2->OSEventType == OS_EVENT_TYPE_MUTEX) { pprio = ((OS_TCB *)(pevent2->OSEventPtr))->OSTCBPrio; ptcb = OSTCBPrioTbl[pprio]; } else { break; /* 等的是信号量/队列,链到此为止 */ } } else { break; /* 占用者不在等任何东西,提升到此为止 */ } }几个关键判断值得抠一下:
- 终止条件是"占用者不在等任何事件"或者"占用者等的不再是互斥量"。如果 L 等的是一个普通的信号量或消息队列,提升到 L 就够了——因为信号量的持有者(如果有)本来就不受优先级反转保护,链断在这里。
- 循环条件
ptcb->OSTCBPrio > OSTCBCur->OSTCBPrio保证了不会无谓地提升一个已经比自己优先级高的任务。这个判断既能提前退出,也顺带防止了链上出现环时死循环(正常情况下不会成环,但如果用户代码写错、出现自等的怪状态,这个条件能救一命)。 - 提升是"直接把优先级改掉",不是"记录一个临时值"。这带来两个后果:一是要同步搬动就绪位图,因为
OSRdyTbl的下标和位号都是由优先级决定的;二是这个改动是破坏性的,TCB 里没有"原始优先级"这个字段。
4.3 位图搬移:为什么提升要动这么多字段
既然优先级改了,跟优先级绑定的三个派生字段就必须一起改:
| 字段 | 含义 | 优先级变化时必须重算 |
|---|---|---|
| OSTCBPrio | 优先级数值 | 直接改 |
| OSTCBY | 优先级除以 8,位图组号 | 是 |
| OSTCBBitX | 组内位掩码 | 是 |
| OSTCBBitY | 组选通掩码 | 是 |
| OSRdyTbl 对应位 | 就绪状态位 | 必须从旧位置搬到新位置 |
| OSTCBPrioTbl | 优先级到 TCB 的反查表 | 旧位置清零、新位置填上 |
漏掉任何一项,后果都是"任务在就绪表里,但调度器找不到它"或者是"找到了但拿到的是另一个任务的 TCB"。这两类 bug 的典型表现是程序随机卡死或者某个任务莫名不再运行,而且因为触发条件跟等待链相关,复现概率极低。
我自己读这段的时候,专门写了个小实验:把提升那几行注释掉,然后跑一个三任务反转场景,观察 H 的响应时间。注释掉之前 H 大概在几毫秒内拿到互斥量,注释掉之后受中优先级任务的执行时长影响,抖动大到没法看。这个实验比看十遍代码都管用。
注意:
OSTCBPrioTbl在 uC/OS-II 里同时承担两个职责——反查 TCB 指针,以及标记"这个优先级是否已被占用"。互斥量创建时如果指定了优先级上限,会把OSTCBPrioTbl[prio]写成一个特殊的非空值来保留这个优先级,防止用户再创建同优先级的任务。这个技巧在别的地方很少见,第一遍读很容易以为是笔误。
4.4 释放时的回落:为什么不能在 TCB 里存原始优先级
有提升就得有回落,而回落比提升麻烦得多。原因是:任务可能同时拿着好几个互斥量,每个互斥量都可能因为它被提升一次。等它释放其中一个时,该降到哪一档?
uC/OS-II 的做法是不存原始优先级,而是在释放时重新算。OSMutexPost里除了唤醒等待者、把OSEventPtr清空,还要重新审视占用者当前的优先级是否还需要维持在被抬高的位置。这种"用计算代替存储"的取舍,换来的是 TCB 少一个字段、创建任务时少一次初始化,代价是每次互斥量释放要多跑一点逻辑。
我在实际项目里踩过一个跟这里相关的坑:早期为了做调试统计,我在OSMutexPost前后各加了一次串口打印,结果因为降低了关中断时长、打乱了时序,优先级反转的复现场景直接消失了。后来学乖了,观测手段一律改成 GPIO 翻转加逻辑分析仪,不引入任何串口 I/O。
4.5 递归占用与 OSMutexAccept 的取舍
最后说一个容易踩的语义问题:同一任务能不能重复 Pend 自己已经持有的互斥量?
严格来说,uC/OS-II 的互斥量不属于完全意义上的递归互斥量。不同版本的OSMutexPend入口判断略有差别,有的版本会返回OS_ERR_MUTEX_OWNER之类的错误码,有的版本则可能让你陷入"自己等自己"的尴尬境地——你会永远拿不到,因为持有者就是你自己,它永远不会被调度去释放。
所以我的建议很直接:移植或升级 uC/OS-II 版本的时候,第一件事就是打开OSMutexPend看入口判断,确认它对"自己等自己"是明确报错还是默默阻塞。这行代码决定了你写不写嵌套加锁的代码。
如果你确实需要递归加锁,OSMutexAccept()是个更安全的选择——它不阻塞,拿不到就直接返回失败,至少不会死在那里。当然更好的做法是重构代码,把加锁范围收窄,而不是靠递归互斥量兜底。
5. 在 GD32F103 上验证这套机制:从移植细节到调试手段
讲完原理,得落到板子上。GD32F103 是 Cortex-M3 内核、72MHz 主频,跟 STM32F103 的移植套路基本一致,很多现成的移植文件可以直接拿来改。但改完之后能不能正确跑起来,取决于几个关键细节有没有做对。
5.1 临界区宏:PRIMASK 还是 BASEPRI
uC/OS-II 的OS_ENTER_CRITICAL()/OS_EXIT_CRITICAL()有三种实现方式,第三种最常见——直接操作 CPU 状态寄存器,在 Cortex-M 上就是保存和恢复PRIMASK:
#define OS_ENTER_CRITICAL() { cpu_sr = OS_CPU_SR_Save(); } #define OS_EXIT_CRITICAL() { OS_CPU_SR_Restore(cpu_sr); }其中OS_CPU_SR_Save用MRS读PRIMASK、CPSID I关中断,OS_CPU_SR_Restore用MSR恢复。这套做法的特点是屏蔽所有可屏蔽中断,语义干净、移植简单,代价是中断延迟。
在 72MHz 的 M3 上,进临界区的代码本身只有几个周期,但 uC/OS-II 里有些临界区不短——比如OSTimeTick遍历 TCB 链表、OS_EventTaskRdy位图操作、OSSched查表。如果你把这些临界区的时长乘上中断频率,会发现某些情况下中断响应被拖到了几十微秒。
因此很多 M3 移植版本会改用BASEPRI:把优先级数值高于某个阈值的中断真正屏蔽掉,低于阈值的照常响应。改法不难,但有两条纪律必须守住:
- 阈值以下的中断绝不能碰内核数据结构。也就是说那些"高优先级中断"里不能调用
OSSemPost、OSTimeDly之类的内核 API,只能做纯粹的硬件操作然后置个标志。 - 调用内核 API 的中断,优先级必须低于阈值。这条一旦破例,就会出现"临界区保护失效"——内核结构被中断改了,而临界区一无所知。
提示:改
BASEPRI之前,先用 GPIO 翻转量一下当前最长临界区时长。方法是进临界区拉高一个引脚、出临界区拉低,拿逻辑分析仪看最大脉宽。这个数字直接决定了你能用多高的中断频率,比任何理论分析都有说服力。
5.2 用钩子函数观测阻塞链
uC/OS-II 提供了OSTaskCreateHook、OSTaskDelHook、OSTaskSwHook、OSTaskStatHook这一组钩子,原本是给OS_TASK_STAT用的,但拿来做调试观测非常顺手。
我常用的一个做法是在OSTaskSwHook里维护"上一次运行的任务"和"当前运行的任务",配一个环形缓冲记录切换序列。跑一段时间后把缓冲 DUMP 出来,就能看到完整的任务切换轨迹。配合每个任务在关键动作前后打的标记(比如"即将 Pend 信号量"、"已经拿到信号量"),阻塞链路一目了然。
另一个更直接的办法是定期快照事件等待表。写一个最低优先级的监视任务,每隔几百毫秒遍历一遍OSEventTbl数组,把每个事件块上挂着哪些优先级的任务打出来:
EVENT 0x200001A4 type=SEM grp=0x02 waiting prio: 18 EVENT 0x200001B8 type=MUTEX grp=0x00 owner: prio 30 waiting: none这份输出能直接回答两个很实际的问题:哪些信号量其实一直没人等(那它就是个纯计数器,用信号量还是用变量要重新想想),以及哪些任务长期堵在同一个事件上(大概率是那个事件的生产者出问题了)。
要注意,快照操作本身也要进临界区,而且遍历的是内核结构,只能在任务上下文中做,不能在中断里做。频率也别太高,几百毫秒一次足够,否则监视任务自己就成了负载。
5.3 常见问题速查表
把我在实际移植和调试中反复遇到的问题整理成表,方便对照:
| 现象 | 大概率原因 | 排查手法 |
|---|---|---|
| 任务 Pend 永不返回 | Post 方根本没被执行;信号量句柄不匹配 | 打印句柄地址,在 Post 处加计数 |
| 超时返回后系统行为异常 | 忘了判断OS_ERR_TIMEOUT就往临界区里走 | 检查 Pend 返回值分支 |
| 中断里 Pend 直接死机 | 用错了 API,应改用 Post | 搜代码里 ISR 内的 Pend 调用 |
| 高优先级任务响应抖动大 | 临界区过长,或存在优先级反转 | 用 GPIO 量临界区、检查互斥量使用 |
| 信号量计数静默饱和 | 溢出返回值OS_ERR_SEM_OVF未判断 | 检查所有 Post 的返回值 |
| 任务随机卡死 | 优先级继承搬位图时漏了字段 | 检查OSTaskChangePrio相关代码 |
| 切换时偶发硬件异常 | SysTick 与 PendSV 优先级设置不当 | 把两者都设为最低,且同优先级 |
最后一条展开说一句:Cortex-M 上,如果 SysTick 的优先级比 PendSV 高,OSTickISR就可能在上下文切换执行到一半时抢占进来,而它里面会调OSTimeTick,进而可能碰 TCB 链表。这时候 TCB 的状态正处于被修改的中间态,极其危险。稳妥的做法是把 SysTick 和 PendSV 都设成最低优先级。如果两者同优先级,硬件会按异常编号小的先执行——PendSV 编号比 SysTick 小,所以切换会先完成,这恰好是我们想要的效果。
5.4 几个高频问法的对照
最后把这一块相关的高频问题集中答一下,顺带把"RTOS 和 Linux 的区别"这类跨领域问法也理清。
信号量和互斥量的区别:信号量是计数器,谁 Pend 谁 Post,没有"所有者"概念;互斥量有所有者,带优先级继承,只能在任务上下文里操作,不能在中断里 Post(因为要判断所有者身份)。用互斥量保护临界区,用信号量做任务间同步或事件计数。
Pend 和 Post 能不能在中断里调用:Post 可以,Pend 不行。Pend 会阻塞、会触发调度,中断上下文里做不了。
优先级反转和优先级继承:反转是"低优先级任务占着资源、被中优先级任务压住、导致高优先级任务被拖";继承是"把占用者的优先级临时提到请求者的水平,让它赶紧跑完"。互斥量是继承的载体,普通信号量没有。
RTOS 和通用操作系统最大的区别:很多人第一反应是"RTOS 小",这个答案不得分。真正的区别在确定性——RTOS 保证的是最坏情况下的响应时间可预测、可界定,所有内核操作的耗时上界都是常数级(位图查找就是典型例子),内存分配基本静态预分配、内核路径里不做动态申请,调度策略是固定优先级抢占而不是追求公平吞吐。通用操作系统追求的是平均吞吐和公平性,同样的负载下它的最坏响应时间可能远大于 RTOS,但这恰恰是它设计上主动接受的取舍。把"确定性"和"内存分配策略"这两点说出来,才算答到点子上。
移植一份 RTOS 到新 MCU 要改哪些东西:核心就四件事——上下文切换(进/出栈的那段汇编)、临界区宏(PRIMASK还是BASEPRI)、时钟节拍源(一般是 SysTick)、以及异常栈的初始化。剩下的都是配置。理解了这一点,换到别的 Cortex-M 芯片上基本就是改改寄存器名和时钟配置的工夫。
6. 我自己踩过的两个小坑
第一个坑是关于OSTCBStatPend的。有一次我为了省内存,手动把一个调试用的字段删了,顺手把OSTCBStatPend也当成"调试专用"删掉了。编译没报错,因为它在头文件里被其他的宏绕过了。跑起来之后信号量的超时返回随机变成OS_ERR_NONE——因为 Pend 尾巴的switch找不到有效的状态值,走进了default分支逻辑之外的路径。这个坑教了我一件事:内核里的每一个字段,先想清楚谁在读它,再动手删。
第二个坑是观测手段本身引入的问题。我曾经在一个低优先级任务里加了段打印,输出每个事件块的等待情况,频率设成了 50ms 一次。跑起来之后发现原本表现良好的系统开始出现偶发的时序抖动,排查了很久才发现是那个监视任务每次遍历事件块都要进临界区、要打印串口,而串口输出在阻塞模式下会占用相当长的时间。后来改成只在检测到异常状态时才输出,正常情况什么都不打印,抖动就消失了。调试代码本身也是代码,它一样吃 CPU、一样影响时序,这一点在实时系统里比在任何其他地方都重要。
如果你也在读这份内核源码,我的建议是从OSSemPend和OSSemPost这两支开始,逐行跟着走一遍,把每一步对应的字段变化写在纸上。等你把"挂起时哪几个位被改、唤醒时哪几个位被改"画清楚了,互斥量里的优先级继承、事件标志组的等待逻辑、消息队列的传递路径,全部都是同一套东西的不同包装。