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

资讯详情

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

uC/OS-II互斥信号量与优先级继承:从优先级反转实测到OSMutex源码

uC/OS-II互斥信号量与优先级继承:从优先级反转实测到OSMutex源码 1. 第 8 篇为什么绕不开互斥信号量一次真实的优先级反转这个系列写到第七篇uC/OS-II 里那些看着眼熟、真问起来又说不清的骨架已经拆得差不多了目录怎么分层、上电之后一路走到 OSStart 的启动链、OS_TCB 和 OSRdyTbl 怎么用两个字节装下 64 个优先级、OSSched 凭什么用常数时间找到最高优先级任务、PendSV 里那几行汇编怎么把栈指针换来换去、OSTimeTick 和 OSTimeDly 怎么配合以及第七篇那个只当模板用的 OS_EVENT 事件控制块。前面攒下来的最大悬念是OS_EVENT 自己什么都不干真正干活的是挂在它下面的具体对象——信号量、互斥量、邮箱、消息队列。第八篇就从这堆对象里挑一个最容易被低估、也最容易在项目里出事的家伙互斥信号量 OSMutex以及它背后那套优先级继承Priority Inheritance ProtocolPIP。选它不是因为代码多——整个 uC/OS-II 内核骨架算下来六千多行不同版本和裁剪配置会差几十行os_mutex.c 连注释一起也就三百行上下——而是因为它解决的是 RTOS 领域最经典的实验室能跑、上压力测试就炸的问题优先级反转。这篇适合三类人看一是刚啃完信号量、正准备把信号量当万能锁到处用的新手二是移植过 gd32f103 或者 STM32F103 上的 RTOS、但从来没认真读过 os_mutex.c 的嵌入式工程师三是正在准备面试、被uC/OS-II 的互斥量为什么创建时要传一个优先级这类问题卡住的人。看完之后你应该能做到在自己的板子上复现一次优先级反转再用互斥量把它消掉并且能用示波器或者串口打点把两者的差别量出来。1.1 前七篇留下的坑信号量到底解决不了什么二值信号量OSFlag 之前的 OSSem初值为 1是实现互斥最直觉的做法谁要进临界区先 OSSemPend 拿一下出来 OSSemPost 放一下语义上完全够用。问题出在够用这两个字上——OSSSem 只管有没有不管谁拿着。假设三个任务L低优先级编号数值大比如 15、M中优先级10、H高优先级5。L 先拿到二值信号量进了临界区准备访问一块共享的 SPI Flash 或者一段共享缓冲区这时候 M 因为某个外部事件被激活了它不碰这块共享资源纯粹是算一段浮点或者刷一段 UART 缓冲区。uC/OS-II 的调度器是抢占式的M 的优先级比 L 高所以 M 一就绪就把 L 挤下去了。紧接着 H 也醒了H 要访问同一块共享资源去 Pend 那个已经被 L 拿着的信号量只能挂起等待。到这一步H 的实际等待时间是L 剩余临界区时间加上M 的全部执行时间。M 跟共享资源一点关系都没有却把最高优先级任务堵在后面——这就是无界优先级反转。反转本身不可怕可怕的是它不可预测M 什么时候来、跑多久取决于外部输入最坏情况下 H 的响应时间可以做到完全没法算。硬实时系统里没法算等于不合格。1.2 把反转摊开成时间线看光讲概念容易飘把它按时间轴铺开更直观。下面这张表是一次典型的实测过程时间单位为毫秒与后面第五节的实验参数一致时刻发生了什么此时 H 的状态t0L 拿到锁进入临界区计划忙等 200ms还没被激活t50H 被激活尝试 Pend锁被占用H 挂起阻塞开始计时t60M 被激活优先级 10 高于 L 的 15抢占 L继续阻塞t60~560M 跑满 500ms纯计算不碰共享资源阻塞中被无关任务拖延t560M 结束L 恢复运行跑完剩下 150ms 临界区继续阻塞t710L 放锁H 被唤醒阻塞结束实测约 660ms用互斥量替换二值信号量之后同样的三个任务H 的阻塞时间会掉到 200ms 出头。差别就在 t50 那一刻H 一挂到互斥量上内核会立刻把 L 的优先级从 15 临时抬到 5L 一旦被抬到 5M 就抢不过它了临界区会一路跑到底然后放锁H 随即执行。优先级继承的本质是让持有锁的人暂时借走等锁的人的优先级把不该插进来的中优先级任务挡在外面。1.3 这篇要回答的三个问题接下来的内容围绕三个问题展开这也是我读 os_mutex.c 时反复问自己的一个 16 位的 OSEventCnt 字段怎么同时存下优先级上限、可用标志和持有者优先级这三件事优先级继承这套动作在 OSMutexPend 里到底是哪几行写出来的恢复又是在哪里做的以及最实际的——在 GD32F103 这种 Cortex-M3 的板子上怎么搭一个可以反复复现、能量出数据的对照实验这三个问题答完互斥量这条线基本就通了。顺带还会聊几个面试高频问题和几个只有在真项目里踩过才知道的坑。2. 拆开 OSMutex一个 16 位字段怎么存下三件事第七篇已经看过 OS_EVENT 的结构这里再贴一遍因为互斥量的所有秘密都藏在这个结构里没有任何额外的私有数据结构typedef struct os_event { INT8U OSEventType; /* 事件类型信号量/互斥量/邮箱/队列 */ void *OSEventPtr; /* 类型不同指向的东西也不同 */ INT16U OSEventCnt; /* 计数器 / 位组合 / 优先级组合 */ OS_PRIO OSEventGrp; /* 等待任务组8 位 */ OS_PRIO OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任务表8 字节 */ #if OS_EVENT_NAME_EN 0u INT8U *OSEventName; /* 调试用名字 */ #endif } OS_EVENT;信号量里 OSEventCnt 就是个普通计数邮箱里 OSEventPtr 直接指向消息消息队列里 OSEventPtr 指向队列控制块。到了互斥量这里剧情变了OSEventCnt 被当成一个位域来用高字节和低字节承载完全不同的语义。2.1 OSEventCnt 的位布局以我手上这份 v2.9x 的源码为例位定义是这样的不同小版本可能有一点点差异建议对着自己那份 os_mutex.c 核一遍位段含义生命周期bit15 ~ bit8优先级上限就是 OSMutexCreate 传进来的 prio创建时写入之后不再变bit7 ~ bit00xFF 表示锁空闲锁被占用时存的是持有者当前优先级每次 Pend/Post 都会变对应的宏也有几个看名字就懂#define OS_MUTEX_KEEP_LOWER_8 ((INT16U)0x00FFu) /* 取低字节 */ #define OS_MUTEX_KEEP_UPPER_8 ((INT16U)0xFF00u) /* 取高字节 */ #define OS_MUTEX_AVAILABLE ((INT16U)0x00FFu) /* 空闲标志 */ #define OS_MUTEX_EN 0u /* 开启优先级继承 */ #define OS_MUTEX_DIS 1u /* 关闭优先级继承 */判断锁是否空闲的方式非常位运算if ((pevent-OSEventCnt OS_MUTEX_AVAILABLE) OS_MUTEX_AVAILABLE)。低字节等于 0xFF 就是空闲否则说明低字节里塞着某个人当前的有效优先级永远小于 0xFF因为 uC/OS-II 最多 64 个优先级0~63。这个用一个不可能出现的值做哨兵的小技巧在 uC/OS-II 里到处都是读源码时记住它能省下不少困惑。顺带说一句为什么低字节存的是持有者当前优先级而不是是否被占用这样一个布尔值因为恢复优先级的时候需要它。如果只存一个布尔Post 的时候就不知道该把持有者抬回哪个数值了。2.2 OSMutexCreate 里那个占位动作OSMutexCreate(prio, perr)只接收一个参数 prio很容易被当成随便填一个而忽略。实际上创建函数开头有一段检查if (prio OS_LOWEST_PRIO) { /* OS_LOWEST_PRIO 通常配成 63 */ *perr OS_ERR_PRIO_INVALID; return ((OS_EVENT *)0); } OS_ENTER_CRITICAL(); if (OSTCBPrioTbl[prio] ! (OS_TCB *)0) { /* 这个优先级槽已被占用 */ OS_EXIT_CRITICAL(); *perr OS_ERR_PRIO_EXIST; return ((OS_EVENT *)0); } OSTCBPrioTbl[prio] OS_TCB_RESERVED; /* 用一个特殊指针把槽占住 */ OS_EXIT_CRITICAL();OS_TCB_RESERVED其实就是(OS_TCB *)1这么一个假指针。它的作用是告诉任务创建函数 OSTaskCreate这个优先级已经被占了你不许用。换句话说uC/OS-II 的互斥量是从任务的优先级池里硬生生抠出来一个槽位当上限用的。这个设计经常被吐槽但逻辑其实很自洽优先级继承要回答最多能把持有者抬到多高而这个上限必须是一个真实存在、可比较的优先级数值。uC/OS-II 没有额外开一块空间存它就直接借用优先级池里的一格。注意正因为如此你在 os_cfg.h 里给 OS_LOWEST_PRIO 留的余量必须够。如果项目里同时用了 3 个互斥量那就要额外准备 3 个优先级槽而且这些槽不能再分给任何任务。见过太多次任务数刚好卡满 63 个再加一个互斥量就创建失败的情况。2.3 为什么上限这个参数不能随便填一个常见误区是既然低字节存的是持有者的真实优先级那高字节这个 prio 好像只是占位随便填个没用到的值就行。这种用法在单层继承的场景下确实能跑因为 uC/OS-II 恢复优先级时抬升用的是等待者的优先级回落用的是低字节里记录的值加上池里的对照prio 本身参与得不多。但一旦出现多级继承H 等 LL 又在等另一个互斥量prio 就会参与判断填得离谱会导致持有者被抬到一个比自己还高的位置或者干脆算错。稳妥的做法是这个 prio 就填所有可能使用这个互斥量的任务中最高的那个优先级。比如 H5、M10、L15都会用这把锁prio 填 5。这个规则在 uC/OS-III 里被明确写进了文档uC/OS-II 的注释里说得比较含糊所以很多人栽在这里。我在实际项目里养成的习惯是给每把互斥量单独建一个注释块写清楚使用者TaskH(5)/TaskM(10)/TaskL(15)prio 5占用优先级槽 5。半年后回头看这一行注释能救命。3. 优先级继承是怎么写出来的OSMutexPend 逐段过OSMutexPend 的签名是void OSMutexPend(OS_EVENT *pevent, INT16U timeout, INT8U *perr)比 OSSemPend 多了什么其实没有多只是内部多了一大段。整个函数可以切成三段来看下面给的是去掉参数检查、临界区进出和超时处理的精简骨架主干逻辑都在void OSMutexPend (OS_EVENT *pevent, INT16U timeout, INT8U *perr) { OS_ENTER_CRITICAL(); if ((pevent-OSEventCnt OS_MUTEX_AVAILABLE) OS_MUTEX_AVAILABLE) { /* ---- 第一段快路径锁是空的 ---- */ pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; /* 清掉低字节的空闲标志 */ pevent-OSEventCnt | OSTCBCur-OSTCBPrio; /* 低字节记下持有者优先级 */ pevent-OSEventPtr (void *)OSTCBCur; /* 指向持有者 TCB */ OS_EXIT_CRITICAL(); *perr OS_ERR_NONE; return; } /* ---- 第二段慢路径锁已经被别人拿着 ---- */ pto (OS_PRIO)(pevent-OSEventCnt OS_MUTEX_KEEP_LOWER_8); /* 持有者当前优先级 */ ppto OSTCBCur-OSTCBPrio; /* 我自己 */ if (ppto pto) { /* 数值越小优先级越高说明我比持有者高 */ /* 把持有者抬到我的优先级这就是继承动作 */ pevent-OSEventCnt OS_MUTEX_KEEP_UPPER_8; pevent-OSEventCnt | ppto; /* 同步修改 TCB 里的优先级和就绪表里的位置此处省略细节 */ } /* ---- 第三段把自己挂进等待表按优先级排序 ---- */ OS_EventTaskWait(pevent); OS_EXIT_CRITICAL(); OS_Sched(); /* ... 被唤醒后处理超时和返回码 ... */ }3.1 第一段快路径为什么只动三个字段快路径干的事只有三件清低字节、写持有者优先级、把 OSEventPtr 指向当前任务。这三件事的顺序不能颠倒尤其是先把低字节清成 0 再写优先级这一步。如果先写优先级再操作低字节里就会同时残留 0xFF 的高位和优先级数值 OS_MUTEX_AVAILABLE的判断可能出问题。这里还有个小细节值得留意快路径里 OSEventCnt 的高字节完全没动优先级上限还是原样保留。这印证了前面的说法——上限是静态配置持有者优先级是动态状态两者物理上挤在一个 16 位变量里但生命周期完全不同。这种把配置和状态打包进一个变量的写法在资源紧张的年代很常见放到今天的代码规范里大概率会被 review 打回来但它确实省了内存。3.2 第二段继承那几行是整个互斥量的灵魂第二段的判断条件是ppto pto。uC/OS-II 里优先级数值越小越高所以这个不等式成立就意味着我等待者比持有者优先级高应该继承。这个判断同时起到两个作用一是决定要不要抬二是顺便挡掉了低优先级任务来 Pend 时白白提权的浪费。提权这个动作看起来只有三行实际牵涉的东西不少OSEventCnt 的低字节要改、持有者 TCB 的 OSTCBPrio 字段要改、持有者在 OSRdyTbl 里占的那一位要从旧位置搬到新位置、OSTCBPrioTbl 里也要跟着换。uC/OS-II 把后面这些打包在内部的优先级变更逻辑里代码量不大但很容易读漏。我建议你在看这一段的时候拿张纸把L 从 15 抬到 5这个变化画出来标一下 OSRdyTbl 里两个 bit 的翻转理解会牢固得多。另外还有一个容易忽略的点PIP 是可以通过宏关掉的。os_cfg.h 里OS_MUTEX_EN和OS_MUTEX_DIS决定行为关掉之后互斥量的用法就退化成带所有权检查的信号量不再继承。什么时候会关一般是逻辑简单、任务数极少、且能证明不会发生反转的场景。我个人的建议是别关继承了不亏关了省不下几个字节。3.3 第三段挂等待表之后必须先让出 CPU第三段是熟悉的套路OS_EventTaskWait把自己从就绪表挪到 pevent 的等待表里然后OS_EXIT_CRITICAL()开中断最后OS_Sched()触发一次调度。这里有个新手经常踩的坑OS_EventTaskWait之后必须在退出临界区之后才调用 OS_Sched如果顺序反了会在关中断状态下做一次任务切换栈切换和中断嵌套会打架表现是偶发死机很难查。还有个更隐蔽的问题这段代码执行时当前任务的 TCB 已经被挂到等待表里了但它仍然占着 CPU直到 OS_Sched 真正切走。中间这段时间里如果有个中断服务程序调用了OSMutexPost把锁放了会不会出问题不会因为此时还在关中断状态ISR 根本进不来。这就是临界区存在的意义。提示OSMutexPend 的 timeout 参数传 0 表示无限等待这一点和 OSSemPend 一致。如果传了非 0 值挂起期间时钟节拍会把它移到超时链表上返回码是 OS_ERR_TIMEOUT。互斥量等待超时是很危险的状态——任务没拿到锁却继续往下跑会直接破坏共享数据的完整性。如果你用了非 0 超时一定要在所有返回码分支上都做正确判断宁可让任务进错误处理也不要假装成功。4. OSMutexPost 的收尾解锁、唤醒、恢复优先级OSMutexPost 的签名只有INT8U OSMutexPost(OS_EVENT *pevent)一个参数因为谁在放锁这件事内核是知道的——它就是当前任务。整个函数的逻辑主线是确认调用者是持有者把锁交给等待队列里优先级最高的那个人如果有恢复被抬高的优先级最后触发一次调度。4.1 所有权检查为什么比信号量多这一道if (OSTCBCur ! (OS_TCB *)pevent-OSEventPtr) { OS_EXIT_CRITICAL(); return (OS_ERR_NOT_MUTEX_OWNER); }这两行是互斥量和二值信号量在语义上最本质的区别。信号量是票谁拿都能放A 拿了 B 也能放内核不管互斥量是所有权只有持有者能释放。这个约束看起来是多此一举实际上挡掉了一整类设计错误任务 A 因为在临界区里出错提前返回忘了放锁任务 B 好心帮它放了一把结果共享数据在 A 还在写的时候就被 B 打开了破坏得比死锁还惨。返回 OS_ERR_NOT_MUTEX_OWNER 的时候很多人会习惯性地忽略返回值觉得反正我知道我在放了。这个假设在调试期不成立——一旦设计里出现了交叉释放你忽略的这个返回值就是唯一的线索。我自己的习惯是把它包一层宏失败时点亮一颗 LED 并记录任务号和错误码这样在实验室里就能发现不用等到客户现场。4.2 唤醒等待者直接交接而不是先置空闲放锁的核心动作是这样一段如果等待表非空就把锁直接交给排队里最高优先级的任务而不是先把 OSEventCnt 置成 0xFF 空闲再让那个任务自己重新 Pend 一次。这个直接交接的设计避免了两个麻烦一是中间窗口被别的任务插进来抢走锁二是避免了多一次正常的 Pend 调用。交接完成之后还要处理优先级问题。如果之前为了继承把某个任务的优先级抬高了那么放锁的时候要把它放回去。这里的逻辑有两种写法一种是恢复成创建互斥量时指定的那个上限另一种是恢复成持有者原本的优先级。uC/OS-II 走的是相对简单的路径而 uC/OS-III 干脆在互斥量里多开了一个字段记录持有者原优先级从根上解决了嵌套场景下恢复不准确的问题。你读自己那份源码的时候最快的验证方式是打开 OSMutexPost看它调用的优先级变更函数第二个参数是从哪儿取出来的——是从 OSEventCnt 的高字节取还是从别的地方取一眼就能看出来。4.3 嵌套与多级继承的边界优先级继承不是万能的它有两个天然的边界实际项目里必须知道。第一个边界是嵌套持锁。任务 L 持有互斥量 A同时任务 M 也在等 A 并且 M 的优先级更高L 被抬到了 M 的优先级如果此时 L 又去 Pend 互斥量 B而 B 被另一个低优先级任务持有那这个链会继续传递下去吗uC/OS-II 的处理是有限的链长了容易恢复不准确。工程上的对策很简单不要在持有锁的时候去申请另一把锁也就是避免锁嵌套。真需要嵌套就把几把锁合并成一把或者重新设计数据访问路径。第二个边界是不参与继承的等待者。如果等锁的任务是通过二值信号量或者消息队列间接依赖共享资源的靠互斥量是感知不到的。也就是说优先级继承只对直接等这把锁的任务生效间接依赖、跨层依赖统统看不见。真正的解决办法是设计阶段的优先级分配时尽量避免长临界区把共享资源的访问时间压到最短。注意这里说的长临界区不是指几毫秒而是指包含了可能引起阻塞的调用。临界区里绝对不能调用 OSTimeDly、OSxxxPend、OS_QPost 里带 Pend 语义的函数。只要你调了任何一个会让出 CPU 的东西临界区就不再是临界区等于自己拆了保护。5. 上手实操在 GD32F103 上跑一个可复现的优先级反转实验理论讲完落到板上。这一节给出一个完整可复现的实验设计硬件用 GD32F103Cortex-M372MHz和 STM32F103 引脚兼容移植层可以直接借用软件是标准 uC/OS-II 加 Cube 风格的外设初始化。整个实验的目标只有一个量出同一组任务在二值信号量和互斥量两种保护方式下最高优先级任务的阻塞时间差多少。5.1 硬件与工程准备硬件只要三样一块 GD32F103 最小系统板、一个 USB 转串口模块接 PA9/PA10、一根杜邦线把 PA0 引到示波器或者逻辑分析仪上。PA0 用来打点标记临界区开始/结束如果你没有示波器用串口打点也能看只是时间精度差一些。os_cfg.h 里必须确认打开的开关#define OS_MUTEX_EN 1u /* 互斥量模块必须为 1 */ #define OS_SEM_EN 1u /* 对照实验要用信号量 */ #define OS_TIME_DLY_HMSM_EN 1u /* 用 OSTimeDlyHMSM 做粗粒度延时 */ #define OS_TASK_STAT_EN 0u /* 实验阶段关掉统计任务减少干扰 */ #define OS_LOWEST_PRIO 63 /* 留够优先级槽 */ #define OS_TICKS_PER_SEC 1000u /* 1ms 一个节拍方便 OSTimeGet 直接当毫秒读 */注意 OS_TICKS_PER_SEC 设成 1000 意味着 SysTick 每 1ms 中断一次。这个频率在 72MHz 的 M3 上完全扛得住中断里只做一次 OSTimeTick而且让后面的时间统计变得极其直观OSTimeGet 返回的 tick 数就是毫秒数。这点小便利在调时序问题的时候很值。5.2 三个任务的参数设计任务设计直接照搬第一节的时间线参数如下表任务优先级行为是否会访问共享资源TaskH5延时 50ms 后 Pend记录阻塞时长是TaskM10被激活后忙等 500ms否TaskL15先拿锁忙等 200ms再放锁是关键点在于TaskM 必须在 TaskL 拿锁之后、TaskH 之前被激活实验才有效。实现方式有两种一是让 TaskM 自己也延时时间点卡在 60ms 左右二是让 TaskM 等一个信号量由 TaskL 在进入临界区之后 Post。第二种更精确但会引入额外的同步动作可能干扰判断。我一般用第一种靠粗略的时序对齐就够用了。堆栈大小按 TASK_STK_SIZE 给 256 就够了Cortex-M3 上 OS_STK 是 32 位也就是每任务 1KB。三个任务加空闲任务一共占用 4KB 左右的 SRAM20KB 的 GD32F103 完全够。如果开了浮点或者调用了 sprintf建议提到 512。5.3 核心代码两种保护方式的对照先看 TaskL 和 TaskM这两个任务在两种模式下代码完全一样#define TASK_STK_SIZE 256 #define TASK_H_PRIO 5u #define TASK_M_PRIO 10u #define TASK_L_PRIO 15u static OS_EVENT *pLock; /* 指向信号量或互斥量 */ static volatile INT32U g_h_block_ms; /* TaskH 的阻塞时长 */ static void TaskL (void *p_arg) { INT8U err; (void)p_arg; for (;;) { OSSemPend(pLock, 0u, err); /* 互斥量模式改成 OSMutexPend(pLock, 0, err) */ GPIO_BOP(GPIOA) GPIO_PIN_0; /* 打点进临界区 */ BusyWaitMs(200u); /* 模拟耗时 200ms 的共享资源操作必须忙等 */ GPIO_BC(GPIOA) GPIO_PIN_0; /* 打点出临界区 */ OSSemPost(pLock); /* 互斥量模式改成 OSMutexPost(pLock) */ OSTimeDlyHMSM(0u, 0u, 1u, 0u); } } static void TaskM (void *p_arg) { (void)p_arg; OSTimeDlyHMSM(0u, 0u, 0u, 60u); /* 卡在 L 拿锁之后醒来 */ for (;;) { BusyWaitMs(500u); /* 纯计算不碰任何共享资源 */ OSTimeDlyHMSM(0u, 0u, 1u, 0u); } }TaskH 负责测量它的循环里只做一件事static void TaskH (void *p_arg) { INT32U t0, t1; INT8U err; (void)p_arg; OSTimeDlyHMSM(0u, 0u, 0u, 50u); /* 等 L 先拿到锁 */ for (;;) { t0 OSTimeGet(); /* 记录 Pend 之前的时间 */ OSSemPend(pLock, 0u, err); /* 互斥量模式改成 OSMutexPend */ t1 OSTimeGet(); /* 拿到锁的瞬间 */ g_h_block_ms t1 - t0; /* 阻塞时长单位 ms */ printf(H block %lu ms\r\n, (unsigned long)g_h_block_ms); OSSemPost(pLock); /* 互斥量模式改成 OSMutexPost */ OSTimeDlyHMSM(0u, 0u, 2u, 0u); } }创建部分两种模式只在初始化那一行不同/* 模式 A二值信号量 */ pLock OSSemCreate(1u); /* 模式 B互斥量prio 取使用者里最高的优先级 5 */ pLock OSMutexCreate(TASK_H_PRIO, err);5.4 实测数据与观测要点把两种模式各跑一遍串口输出的数字差别非常明显保护方式TaskH 实测阻塞时长说明OSSemCreate(1)约 660 msTaskM 的 500ms 被算进了等待里OSMutexCreate(5)约 220 msTaskL 被抬到优先级 5TaskM 完全插不进来220ms 这个数字不是严格等于 200ms多出来的 20ms 是几件事凑出来的TaskL 已经跑掉了一部分临界区TaskH 是 50ms 才 Pend 的此时 L 大概已经忙等了 50ms加上 GPIO 操作、串口打印、调度开销。数量级对得上就说明实验成功了不用纠结那几毫秒。用示波器看 PA0 更直观模式 A 下 PA0 的高电平会被撑得特别长因为 L 被 M 抢占高电平中间还夹着L 被切走的空档实际上会看到一个高电平被拉长到 660ms 左右模式 B 下 PA0 就是一段干净的 200ms 高电平形状非常规整。这个波形对比比任何文字描述都有说服力面试的时候如果你能把这两张图拿出来讲基本就不用担心被追问了。提示BusyWaitMs 必须是自己写的不让出 CPU 的空循环不能调用 OSTimeDly。这一点是整个实验成立的前提——如果 TaskL 在临界区里用 OSTimeDly 让出了 CPU那它跟 TaskM 就是普通的优先级抢占关系测出来的数据毫无意义。我见过不止一个人在这里翻车然后得出优先级继承没用的结论。6. 常见问题与排查实录这一节是我在几个实际项目里攒下来的都是文档里不会写、但碰到一次就记一辈子的东西。6.1 问题速查表现象最可能的原因排查动作OSMutexCreate 返回空指针err OS_ERR_PRIO_EXIST传进去的 prio 已经被某个任务或其他互斥量占了打印 OSTCBPrioTbl 里非空的槽位看谁占了Post 返回 OS_ERR_NOT_MUTEX_OWNER有人替别人放锁或者任务在 Pend 失败后仍执行了 Post检查所有 Pend 的返回码分支优先级继承好像没生效OS_MUTEX_EN 被关掉了或者 TaskM 的优先级其实比 TaskH 低打开 os_cfg.h 确认再看任务优先级表系统在临界区里死锁持有锁的任务 Pend 了另一把被别人持有的锁用 OSMutexQuery 打印每把锁的持有者和等待队列移植后 SysTick 一开就跑飞OS_CPU_SysTickInit 里没按 72MHz 算重装载值核对 reload SystemCoreClock / OS_TICKS_PER_SEC - 16.2 三个不太容易想到的坑第一个坑跟中断服务程序有关。ISR 里能不能 Pend 互斥量不能。OSMutexPend 会挂起当前任务并触发调度而 ISR 上下文里没有当前任务这个概念OSTCBCur 指向的是被打断的那个任务但它不是你。ISR 里只能 Post不能 Pend。这个限制对信号量同样成立但互斥量上更容易犯错因为直觉上我在中断里访问共享资源所以我要加锁这个想法太自然了。第二个坑是优先级上限填错导致的隐性降级。假设你有三把互斥量prio 全填了 5创建的时候后两把就会返回 OS_ERR_PRIO_EXIST如果代码里没检查返回值直接往下用pLock 就是空指针OSMutexPend 会返回 OS_ERR_PEVENT_NULL而很多人的 Pend 是不检查返回码的——结果就是锁根本没生效但程序照跑一直跑到负载上来了才炸。所以我在模板代码里强制要求所有 Pend/Post 都要判返回码这点坚持几年下来救过不少项目。第三个坑是优先级继承和任务优先级分配互相打架。如果你的 TaskL 优先级本来就比 TaskM 高那反转根本不会发生实验也复现不出来。反过来说实际项目里如果发现某把锁的继承动作很频繁说明任务优先级分配本身有问题——可能 L 干了太多本该由更高优先级任务干的事。优先级继承是补丁不是设计目标能不动用就不动用。6.3 什么时候不该用互斥量互斥量解决的是任务之间互斥访问共享资源它有两个明确的使用边界。一是任务和 ISR 之间的互斥。ISR 里不能 Pend所以任务和中断之间共享数据时常规做法是关中断加内存屏障或者用二值信号量做单向通知而不是互斥量。二是纯计数场景。资源池比如三个串口缓冲区需要的是计数信号量用 OSSemCreate(3) 配 OSSemPend/Post 更贴切互斥量天然只有一个。硬要用互斥量模拟会白白浪费一个优先级槽还丢掉了计数的表达能力。顺带说一句和 Linux 的对比这个问题面试里出现频率不低。Linux 内核里也有 PI优先级继承相关的锁比如用户态 pthread_mutex 配 PTHREAD_PRIO_INHERIT 属性内核里的 rt_mutex 也是按继承的思路设计的。区别在于 Linux 面对的是动态优先级、调度类、CPU 亲和性一大堆变量而裸机 RTOS 里的临界区通常只有几十到几百微秒静止优先级、单核、抢占式调度所以 uC/OS-II 那个低字节存优先级、高字节存上限的朴素做法在它的场景里刚好够用。理解了这个差异也就理解了为什么嵌入式 RTOS 的代码可以写得这么抠门。系列写到这里os_mutex.c 这条线基本通了。下一篇准备啃消息邮箱和消息队列那边有个和互斥量完全相反的坑邮箱传递的是指针不是数据副本一不小心就会把已经复用的栈缓冲区传出去。
返回列表