欢迎光临
我们一直在努力

【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 7 篇】

三任务 H/M/L 的经典悲剧:最高优先级任务反而被两个低优先级任务“按在地上摩擦”。uC/OS-II 用一把创建时就定死优先级的锁解决了问题。本文拆解 OSMutex 的完整源码,讲透优先级继承(PIP)机制的每一行实现,并厘清它与 FreeRTOS 动态继承的本质区别。

先看一场事故:直升机为什么飞不动了

假设你正在写一个飞行控制系统。三个任务:

  • H:直升机主控,优先级 2(最高,数值最小)
  • M:姿态解算,优先级 5
  • L:舵机驱动,优先级 8(最低)

L 任务正在临界区里修改舵机参数,持有互斥量。H 此时到来,想要同一把锁——被阻塞,挂起等待。一切正常:H 最多等 L 把临界区跑完。

坏就坏在 M 出现了。

M 优先级 5,高于 L。它一就绪,立刻抢占 L——因为 L 的优先级只有 8,根本没资格继续跑。L 被挂起,互斥量还被它攥在手里,而 H 在等锁。于是整个系统的执行顺序变成了:M 先跑完 → L 再跑完 → H 最后拿到锁。

H 明明是最高优先级,却被两个低优先级任务活活拖死。

更可怕的是:如果 M 是个周期性任务、持续就绪抢占 CPU,H 可能永远等不到锁。这在实时系统里叫优先级翻转——一个中等优先级任务,把最高优先级任务的执行时间无限推迟。

图:优先级翻转 vs PIP 对比时间线——上段信号量做互斥 H 被 M 拖住,下段 L 被提升到 4 后 M 抢不动

解法:把持有者“临时提上去”

问题的根源是:L 占着锁,却因为优先级低被 M 抢走 CPU。如果让 L 在持有互斥量期间,优先级暂时高于 M,M 就抢不动了。

这就是优先级继承(Priority Inheritance)的思路:

  • L 持有互斥量时,若有一个更高优先级的任务 H 来等锁,L 的优先级被临时提升到某个指定值
  • L 以提升后的优先级继续跑完临界区并释放锁
  • 释放后,L 恢复原优先级,H 拿到锁

注意这里的关键词:临时提升、事后恢复。它不是把 L 永久降级,也不是把 H 拉低,而是把 L“拔高”。

但 uC/OS-II 的做法有个特殊之处,必须讲清楚:

uC/OS-II 的 PIP 是创建互斥量时固定指定的优先级,不是动态继承到等待者的最高优先级。

这和 FreeRTOS 的动态继承风格完全不同。FreeRTOS 会把持有者提升到“当前所有等待者中最高优先级”;而 uC/OS-II 是创建时定死的:不管谁来等,持有者一律提到那个固定优先级别。

这个设计有好处:实现简单、行为可预测。也有代价:PIP 值选得不合适,可能反而引发其他问题。后面讲创建函数时会细说。

OSMutexCreate:一出生就要占个坑

信号量和互斥量共用 OS_EVENT 数据结构,但互斥量把 OSEventCnt 从信号量的”计数”改造成了复合字段:高 8 位存 PIP,低 8 位存可用标志或持有者优先级——同一个字段,两种语义。

先看互斥量的核心数据结构(ucos_ii.h:464-470):

typedef struct os_mutex_data {
OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待表(与信号量同构) */
OS_PRIO OSEventGrp;
BOOLEAN OSValue; /* OS_FALSE=已占用 OS_TRUE=可用 */
INT8U OSOwnerPrio; /* 持有者优先级或 0xFF */
INT8U OSMutexPIP; /* PIP 或 0xFF */
} OS_MUTEX_DATA;

创建函数的签名很特殊(os_mutex.c:170-227):

OS_EVENT *OSMutexCreate(INT8U prio, INT8U *perr)

第一个参数就是 PIP——优先级继承目标值。创建互斥量时,你必须指定“当翻转发生时,持有者该被提到哪个优先级”。

创建时的校验逻辑(逐字):

  • prio >= OS_LOWEST_PRIO → 报 OS_ERR_PRIO_INVALID(超出有效优先级)
  • 在中断里调用 → 报 OS_ERR_CREATE_ISR(创建不能在 ISR 中做)
  • OSTCBPrioTbl[prio] 非空 → 报 OS_ERR_PRIO_EXIST(PIP 位置不能已有任务)

最核心的一步是占坑:

OSTCBPrioTbl[prio] = OS_TCB_RESERVED;

这个优先级从此不能再创建任何任务。因为 PIP 是预留的,运行期间持有者会被提到这儿来。如果这里已经跑着别的任务,一提升就撞车。

然后是这个关键编码:

pevent->OSEventCnt = (prio << 8) | OS_MUTEX_AVAILABLE;

OSEventCnt 高 8 位存 PIP 值,低 8 位存互斥量状态:OS_MUTEX_AVAILABLE 表示可用;不可用时,低字节存的是持有者的原优先级。

一个 OS_EVENT,高 8 位写死了 PIP,低 8 位动态记录持有者。这就是互斥量与信号量在 OSEventCnt 使用上的根本差异。

OSMutexPend:继承的本质是“挪位子”

获取互斥量的核心逻辑在 os_mutex.c:414-546,逐字拆解:

pip = (INT8U)(pevent->OSEventCnt >> 8u); /* 取出 PIP */
if (低字节 == OS_MUTEX_AVAILABLE) { /* 互斥量可用 */
OSEventCnt &= KEEP_UPPER_8; /* 清低字节 */
OSEventCnt |= OSTCBCur->OSTCBPrio; /* 存持有者优先级 */
OSEventPtr = OSTCBCur; /* 指向持有者 TCB */
if (OSTCBCur->OSTCBPrio <= pip) return OS_ERR_PIP_LOWER;
return OS_ERR_NONE;
}

互斥量可用时,任务直接拿走锁,低字节记录自己的优先级。但注意那个检查:如果持有者的优先级优于或等于 PIP,直接报 OS_ERR_PIP_LOWER。意思是:你自己优先级比 PIP 还高,这把锁的 PIP 对你没意义——说明 PIP 配置不合理。

互斥量被占时,进入优先级继承段:

mprio = 低字节; /* 持有者原优先级 */
ptcb = OSEventPtr; /* 持有者 TCB */
if (ptcb->OSTCBPrio > pip) { /* 持有者优先级比 PIP 低? */
if (mprio > OSTCBCur->OSTCBPrio) {
/* 把持有者从就绪表(或等待表)移除 */
OSRdyTbl[y] &= ~ptcb->OSTCBBitX;
if (OSRdyTbl[y] == 0u) OSRdyGrp &= ~ptcb->OSTCBBitY;
/* 提升:OSTCBPrio = pip,重新算位图索引 */
ptcb->OSTCBPrio = pip;
ptcb->OSTCBY = pip >> 3;
ptcb->OSTCBX = pip & 0x07;
ptcb->OSTCBBitY = 1 << Y;
ptcb->OSTCBBitX = 1 << X;
/* 按新优先级放回就绪表 */
OSRdyGrp |= ptcb->OSTCBBitY;
OSRdyTbl[ptcb->OSTCBY] |= ptcb->OSTCBBitX;
OSTCBPrioTbl[pip] = ptcb; /* 登记新优先级槽位 */
}
}

这段代码是优先级继承的魂。

两个继承条件缺一不可:

  • ptcb->OSTCBPrio > pip:持有者原优先级确实低于 PIP,才需要提升
  • mprio > OSTCBCur->OSTCBPrio:持有者确实比当前等待者优先级低,才有翻转风险
  • 继承动作的本质是挪位子——第 3 篇讲的位图索引在这里被动态改写:

  • 从就绪表移除持有者的旧位图
  • 改 OSTCBPrio 为 pip
  • 重算 OSTCBY / OSTCBX / OSTCBBitY / OSTCBBitX
  • 按新优先级放回就绪表
  • 任务还是那个任务,TCB 还是那个 TCB,只是它在位图里的“格子”换了位置。调度器看到的值变了,就会按提升后的优先级调度它。

    继承完成后,才执行标准的挂起流程(阻塞当前等待者)。这就是 OSMutexPend 的全貌:先处理继承,再执行挂起。

    这里有一个必须知道的坑:uC/OS-II 的互斥量不可重入。如果持有者自己再次 Pend 同一把锁(比如不小心在临界区里调用了递归函数),低字节存的是自己的优先级、不等于 AVAILABLE——走继承段时 mprio > 自己的优先级 为假,不会触发继承,然后直接挂起自己等待一把自己持有的锁——死锁。所以使用互斥量要保证临界区里不能再次获取同一把锁。

    图:OSMutexPend 流程——可用直接拿锁;被占用时两步判断触发继承(挪位子五步),最后标准挂起

    OSMutexPost:还回优先级,亲手交锁

    释放互斥量的逻辑在 os_mutex.c:572-624,第一行就与众不同:

    if (OSIntNesting > 0u) return OS_ERR_POST_ISR;

    互斥量不能从 ISR 里释放!信号量可以,互斥量不行。原因很本质:互斥量有所有权概念——谁拿的锁,谁才能还。中断是异步的,不能替别人“代持”或“代还”。这是互斥量和信号量在 API 层面的最大差异。

    接下来是所有权检查:

    prio = OSEventCnt 低字节; /* 持有者原优先级 */
    if (OSTCBCur != OSEventPtr) return OS_ERR_NOT_MUTEX_OWNER;

    必须持有者自己来 Post,否则报错。这是互斥量的“身份证明”。

    如果持有者的优先级被提升过(OSTCBCur->OSTCBPrio == pip),要恢复原优先级:

    if (OSTCBCur->OSTCBPrio == pip) {
    OSMutex_RdyAtPrio(OSTCBCur, prio);
    }

    OSMutex_RdyAtPrio 是 os_mutex.c 的内部函数,逻辑和 Pend 里的继承完全对称:从新优先级位图挪回原优先级位图。提升和恢复,本质都是挪位子。

    然后归还 PIP 槽位:

    OSTCBPrioTbl[pip] = OS_TCB_RESERVED;

    PIP 那个优先级重新变成“预留坑”,等待下一次提升。

    最后处理等待者:

    if (OSEventGrp != 0u) { /* 有等待者:直接转交 */
    prio = OS_EventTaskRdy(pevent, 0, OS_STAT_MUTEX, OS_STAT_PEND_OK);
    OSEventCnt = (pip << 8) | prio; /* 记录新持有者 */
    OSEventPtr = OSTCBPrioTbl[prio];
    if (prio <= pip) OS_ERR_PIP_LOWER;
    OS_Sched();
    return OS_ERR_NONE;
    }
    OSEventCnt |= OS_MUTEX_AVAILABLE; /* 没人等:释放 */
    OSEventPtr = (void *)0;

    有等待者时,互斥量直接转交给最高优先级等待者,不经过”可用”状态。新持有者的优先级立刻被写进低字节。没人等待时,才真正释放。

    补充两个配套 API:OSMutexAccept 是非阻塞获取——拿不到锁立即返回,不挂起(适合”试着拿一下”的场景);OSMutexQuery 把 OS_MUTEX_DATA 里的 OSValue、OSOwnerPrio、OSMutexPIP 填出来供诊断——调试”锁被谁持有”时很有用。

    终局推演:PIP 之后,世界安静了

    现在做一次完整的推演,修正之前的场景。设三任务:

    • H:优先级 10
    • M:优先级 5
    • L:优先级 8
    • PIP 设为 4

    注意这里的配置逻辑:PIP 必须比所有竞争者的优先级都高(数值更小)。竞争者里最高的是 M(5),所以 PIP 必须小于 5,比如 4。这样持有者一旦被提升,就能压住包括 M 在内的所有竞争者。

    推演过程:

  • L(8) 持有互斥量,进入临界区
  • H(10) 到来,Pend 被阻塞
  • 继承发生:L 被提到 PIP=4
  • M(5) 就绪,想抢占——抢不过。L 现在是 4,比 M 的 5 优先级更高,M 只能继续等
  • L(4) 跑完临界区,Post
  • L 恢复原优先级 8,H 拿到锁,继续执行
  • 对比没有 PIP 的场景:H 要等 M 跑完再等 L 跑完。有了 PIP:H 最多等 L 的临界区 + 一次 PIP 竞争窗口。

    翻转没有消失,但它被限制在了可控范围内。这就是优先级继承的意义:把不确定性变成确定性。

    PIP 配置的陷阱也在这:如果 PIP 选得太低(数值太大),比如选了 6——L 被提升到 6,可 M 是 5,M 依然能抢占持有者,翻转只缓解了一半。所以 PIP 的选择必须压过所有可能竞争这把锁的任务,而不是只压过当前等锁的那个。注释里反复强调:PIP 必须比所有竞争者的数值更小,这在创建时就要设计好。

    一句话总结

    互斥量与信号量的本质区别,不在于“等待表同构”,而在于所有权和优先级继承:

    • 所有权 → 只能持有者 Post,ISR 不能碰
    • 优先级继承 → Pend 时挪位子提升持有者,Post 时挪位子恢复

    互斥量的临界区本身也要尽量短——持有时间越长,其他任务被压住的时间越长。这两个机制合在一起,就是把”低优先级占锁导致高优先级被拖死”的失控场景,压缩成一个有界的等待窗口。

    什么时候用互斥量,什么时候用信号量?一句话:互斥量是”锁”,信号量是”信号”。保护共享资源(变量、外设、缓冲区)用互斥量——要所有权、要防翻转;资源计数与任务同步用信号量。把信号量当锁用是新手最常见的错误:它没有继承保护,也没有所有权检查,任何任务都能 Post,临界区形同虚设。

    第 8 篇预告:邮箱与消息队列。OS_EVENT 的指针语义,数据传递的本质,以及与信号量、互斥量的底层关系。到时见。


    你写实时系统时,遇到过最隐蔽的优先级翻转 bug 是什么?欢迎在评论区聊聊。觉得有用就点个关注,后面 3 篇继续更新。

    赞(0)
    未经允许不得转载:171主机测评 » 【系列:uC/OS-II 内核源码精读:从 6736 行代码看懂一个 RTOS · 第 7 篇】
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址