恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

uC/OS-II信号量与互斥量源码精读:事件控制块与优先级继承

  • 首页
  • 资讯中心
  • /
  • uC/OS-II信号量与互斥量源码精读:事件控制块与优先级继承

相关资讯

模拟电子技术基础课后答案PDF:OCR解析与可检索题库构建 2026/9/18 19:47:16
昇腾 HCCL 开源仓 CI 失败诊断与修复指南:从 `/compile` 触发到 `passed` 的完整闭环 2026/9/18 19:47:16
3ds Max零基础硬表面建模:AK47全流程实战 2026/9/18 19:47:16

最新资讯

WinDLX五级流水线实战:从RAW相关到CPI优化
数控机床进给系统刚度与惯量协同设计方法
飞书不回?OpenClaw 走 TaoToken 通道后先查 channels logs
Claude Fable 5.1 在 Cursor 里跑长程 Agent,Key 走 TaoToken 统一通道
Python+Pygame从零实现射击游戏:环境搭建到完整代码
数据结构栈和队列课后题全解析:从基础操作到表达式求值

今日推荐

2026年AI设计工具在PPT制作中的核心应用与评测
Matlab手写逻辑回归:从数学原理到多变量概率预测模型实现
高值医用耗材研报PDF:用Python完成字段抽取、清洗与趋势预测

本周热门

AI SDK Harness 依赖更新指南:掌握 harness 包 SDK 依赖的升级、桥接同步与一致性校验
Refine v5 Ant Design NumberField 组件实战:基于 Intl 的本地化数字格式化
Flutter应用改名全指南:从Android到iOS的配置与工具实践

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

uC/OS-II信号量与互斥量源码精读:事件控制块与优先级继承

发布时间:2026/9/18 19:52:16
uC/OS-II信号量与互斥量源码精读:事件控制块与优先级继承 说明本文基于 uC/OS-II V2.92 的源码展开贴出来的代码为了阅读顺畅做了删减删掉了断言、命名、部分分支你手上的版本行号、细节可能略有出入以实际工程里的文件为准。把 uC/OS-II 的内核源码来回翻到第六遍我才算真正把信号量这一块看顺。前五篇我们把任务、就绪表、调度器、任务切换、时钟节拍这几层地基铺完了一个能跑多任务的 RTOS 骨架已经立起来了。但那时候的任务之间其实是“哑巴”——想让两个任务配合干活你只能靠全局变量加关中断硬凑代码丑还容易出竞态。这一篇要讲的事件控制块和信号量是 uC/OS-II 里第一套真正意义上的任务间同步机制也是很多人读内核源码时第一次感到“设计感”的地方。如果你正在做 uC/OS-II 的内核源码精读或者想把一个 RTOS 移植到 GD32F103、STM32F103 这类 Cortex-M3 芯片上跑起多任务协同再或者你正准备面试被问到“信号量和互斥量到底差在哪”“为什么 OSSemPend 不能在中断里调”这篇内容应该能帮你把线串起来。我会从结构体字段一路讲到调度器联动中间穿插我在实际项目里踩过的坑最后给一套在 GD32F103 上能直接复现的验证工程思路。1. 为什么把信号量放在这个系列的第六篇1.1 前五篇留下的那个坑前五篇的推进顺序是这样的先看 OS_TCB 这个任务控制块长什么样再看 OSRdyGrp 和 OSRdyTbl 构成的就绪表然后是 OSSched 这个调度器怎么挑人接着是 OSCtxSw 怎么做上下文切换最后是 OSTimeTick 怎么把时间这个维度引进来。走到这一步系统能同时跑几个任务了任务也能靠 OSTimeDly 主动让出 CPU。但你会发现一个尴尬的事情任务之间没有任何“合法”的交流通道。我最早写这类代码的时候干过一件蠢事用一个全局 int 当标志位一个任务写、一个任务读读写前后各关一次中断。跑起来好像没问题直到某天把编译优化开到 -O2那个标志位被编译器缓存进寄存器另一个任务永远读不到变化现象是“任务卡死在 while 里”。这个坑几乎每个嵌入式工程师都踩过一次它的本质就是缺少一个带内存屏障和临界区保护的标准同步原语。信号量正是为了解决这类问题被设计出来的它把“谁能往下走、谁要等”这件事收进了内核统一管理。1.2 6736 行代码里事件相关的部分藏在哪很多人的误区是以为信号量有一个独立的 OS_EVENT.C 文件。实际上 uC/OS-II 把事件机制拆成了两层底层是放在 OS_CORE.C 里的五个 OS_Eventxxx 内部函数它们是所有同步对象的公用底座上层才是 OS_SEM.C、OS_MUTEX.C、OS_MBOX.C、OS_Q.C 这些具体实现。我手上这份 2.92 的 OS_SEM.C 连注释一共四百行出头真正干活的逻辑不到一百五十行。而 OS_CORE.C 里那几个 OS_Eventxxx 函数加起来也就一百多行。也就是说整个信号量机制的核心在六千多行代码里占不到 2%。但就是这不到 2% 的代码支撑起了整个 RTOS 的任务协作能力。读源码的顺序我建议是先读 OSInit 里事件控制块空闲链表的初始化再读 OS_CORE.C 里的 OS_EventWaitListInit、OS_EventTaskWait、OS_EventTaskRdy、OS_EventTaskRemove、OS_EventTO最后才回到 OS_SEM.C 看 OSSemCreate、OSSemPend、OSSemPost 这些外壳函数。这个顺序和我第一遍读的时候正好相反第一遍我是从 OSSemPend 开始硬啃的结果卡在 OS_EventTaskWait 里那几行位运算上看了半天不知道它在干什么。1.3 一句话讲清 uC/OS-II 的设计哲学uC/OS-II 做同步机制的核心思路可以概括成一句话用一个统一的结构体装下五种同步对象靠一个类型字段区分靠一张位图记录等待者。这个选择的直接收益是代码量小、RAM 占用可预测代价是灵活性有限、不支持一个任务同时等多个事件。理解了这句话后面所有细节都是它的自然推论。2. OS_EVENT 结构体五种同步对象共用的容器2.1 六个字段逐个拆在 ucos_ii.h 里OS_EVENT 的定义大概是这样typedef struct os_event { INT8U OSEventType; /* 类型SEM / MUTEX / MBOX / Q / FLAG */ INT8U OSEventGrp; /* 等待任务的优先级组位图 */ INT16U OSEventCnt; /* 计数器只有信号量和互斥量用 */ void *OSEventPtr; /* 载荷指针邮箱/队列用互斥量存拥有者 TCB */ INT8U OSEventTbl[OS_EVENT_TBL_SIZE]; /* 等待任务的优先级位图 */ #if OS_EVENT_NAME_EN 0u INT8U *OSEventName; #endif } OS_EVENT;OSEventType 是整套机制的分派依据取值包括 OS_EVENT_TYPE_UNUSED、OS_EVENT_TYPE_SEM、OS_EVENT_TYPE_MUTEX、OS_EVENT_TYPE_MBOX、OS_EVENT_TYPE_Q、OS_EVENT_TYPE_FLAG。每个对外函数进来第一件事就是校验这个字段类型不对直接返回 OS_ERR_EVENT_TYPE不做任何后续动作。这个校验看着啰嗦但它救过我一次项目里有个地方把邮箱句柄和信号量句柄存混了因为类型校验存在程序返回了一个明确的错误码而不是默默把邮箱的消息指针当成计数器减掉。要是没有这道检查那种内存被莫名其妙改写的 bug 能让人查一星期。OSEventCnt 是个 INT16U只有信号量和互斥量会用它。信号量用它记余量互斥量用它同时存两样东西高字节是创建时指定的“上限优先级”低字节在锁被占用时记录拥有者任务的原始优先级——这个设计是优先级继承能实现的关键后面第 7 节会细讲。OSEventPtr 是个万能指针不同对象含义完全不同。邮箱里它指向消息队列里指向队列控制块互斥量里直接指向当前持有锁的任务的 TCB。信号量里它始终是 NULL因为信号量没有“归属”这个概念。2.2 OSEventGrp 和 OSEventTbl 是就绪表的孪生兄弟OSEventGrp 加 OSEventTbl 这一对和 OSRdyGrp 加 OSRdyTbl 的结构完全一样都是“8 组 × 8 位”的两级位图。区别只在语义就绪表记录的是“系统里现在哪些任务可以跑”事件等待表记录的是“现在有哪些任务在等这一个事件”。OS_EVENT_TBL_SIZE 的定义是((OS_LOWEST_PRIO) / 8u 1u)当 OS_LOWEST_PRIO 配成 63 时算出来是 8也就是 64 个优先级正好铺满 8 个字节。这里有个容易忽略的细节OS_LOWEST_PRIO 必须配成 7、15、31、63、255 这几个值之一因为它们减下来能被 8 整除再加一。我见过有人在 os_cfg.h 里手改成 100编译能过但位图数组大小算出来是 13就绪表和等待表全部错位现象是任务偶尔被唤醒一次、偶尔彻底睡死。这种问题不看源码根本查不出来因为编译器一句警告都没有。2.3 为什么不用联合体或者结构体继承有人会问既然五种对象字段用法差这么多为什么不干脆用 union 省点内存。原因有两个。第一OS_EVENT 这个结构体本身不大OS_LOWEST_PRIO 配成 63 时也就二十来个字节用 union 省下的那几字节根本不值得。第二也是最关键的OSInit 会把所有事件控制块串成一条空闲链表靠的是OSEventPtr这个字段指向下一个空闲块如果用 union这条链表的实现和队列、邮箱的载荷指针就会冲突初始化逻辑要写一堆特判。用一个通用结构体加一个类型字段换来的是统一的分配逻辑和统一的操作入口这在只有几千行的内核里是非常划算的取舍。3. 创建与初始化空闲链表和 OSSemCreate3.1 OSEventFreeList 是怎么串起来的系统启动阶段OSInit 会把 os_cfg.h 里 OS_MAX_EVENTS 指定的事件控制块数组 OSEventTbl[] 全部串成一条单链表链表头就是全局变量 OSEventFreeList。核心循环大概这样for (i 0u; i (OS_MAX_EVENTS - 1u); i) { pevent OSEventTbl[i]; pevent-OSEventType OS_EVENT_TYPE_UNUSED; pevent-OSEventPtr OSEventTbl[i 1u]; /* 用 Ptr 串下一个空闲块 */ OS_EventWaitListInit(pevent); } OSEventTbl[OS_MAX_EVENTS - 1u].OSEventPtr (void *)0; OSEventTbl[OS_MAX_EVENTS - 1u].OSEventType OS_EVENT_TYPE_UNUSED; OSEventFreeList OSEventTbl[0];这里有个设计细节值得说OSInit 顺手把所有块的等待表都清了一遍看起来是多余动作但它的价值在于“失败恢复”。如果某个信号量被删掉OSSemDel块的等待表会在删除时被清空万一有人在异常路径下漏了清理初始化阶段的这次全量清零能保证下一次分配出来的块至少位图是干净的。更重要的是OSEventType 在这里被设成 UNUSED这让“未初始化就被使用”这件事变得可检测。3.2 OSSemCreate 的完整流程与参数陷阱创建信号量的代码短得可怜OS_EVENT *OSSemCreate (INT16U cnt) { OS_EVENT *pevent; if (OSIntNesting 0u) { /* 不允许在中断里创建 */ return ((OS_EVENT *)0); } 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); }第一个陷阱是那句if (OSIntNesting 0u) return NULL。为什么中断里不能创建因为信号量的申请是“用完必须还”的资源内核没法帮你跟踪谁申请的中断服务程序里创建的信号量一旦忘记删除这块控制块就永久泄漏了。uC/OS-II 选择直接拒绝返回空指针。所以你的代码里必须有if (sem NULL) { ... }这种判空我见过太多人写完不判然后在事件用满之后收到一个空指针解引用HardFault 现场一片狼藉。第二个陷阱是 OS_MAX_EVENTS 的配置。这个值是所有同步对象共享的池子——信号量、互斥量、邮箱、队列、事件标志加起来总数不能超过它。默认配置通常是 10 到 16在一个稍大的项目里很容易不够。不够的表现不是编译报错而是创建到第 N 个之后全部返回 NULL。我在一个工程里配了 16后来同事加了几个队列正好在跑了两小时后才报错因为其中一个信号量是延迟创建的。后来我把 OS_MAX_EVENTS 直接提到 40多占几百字节 RAM换来的是再也不用担心这事。3.3 初值 0 和 1 的语义差别cnt这个参数取值不是随便定的它直接决定了信号量的用途。初值为 0 的信号量是纯同步用的任何 pend 都会阻塞必须等别人 post 才能过去典型的用法是“中断通知任务”中断里 post任务里 pend。初值为 1 的信号量是互斥用的第一个 pend 的人直接拿走不阻塞。初值为 N 的信号量是资源计数用的比如有 3 个串口可以分配给任务就建一个初值 3 的信号量申请前 pend用完 post。这里有个坑很多人把初值为 1 的信号量当互斥量用还觉得自己挺聪明。在 uC/OS-II 里这么做能跑但一旦涉及优先级反转就会出大问题因为普通信号量不提供优先级继承。更隐蔽的是如果持有信号量的任务中途被删除了这个信号量就永久丢失没有任何机制能自动归还。所以我的经验是凡是“谁拿谁还”的场景一律用 OSMutexCreate凡是“发一次收一次”的场景一律用 OSSemCreate 且初值为 0。这两者不要混着用。4. OSSemPend从快速通道到挂起自己4.1 五个前置检查一个都不能少OSSemPend 一进门的检查是有顺序的if (pevent-OSEventType ! OS_EVENT_TYPE_SEM) { *perr OS_ERR_EVENT_TYPE; return; } if (OSIntNesting 0u) { *perr OS_ERR_PEND_ISR; return; } if (OSLockNesting 0u) { *perr OS_ERR_PEND_LOCKED; return; }第一道是类型校验前面说过了。第二道是关键中断服务程序里绝对不允许调用 OSSemPend。原因是 Pend 可能会把“当前任务”挂起但中断上下文里根本没有“当前任务”这个概念——你在中断里OSTCBCur 指向的是被中断打断的那个任务把它挂起是灾难性的。uC/OS-II 用一个 OSIntNesting 计数器就能判断出来代价极小。第三道检查 OSLockNesting这是给 OSSchedLock 用的。如果调度器被锁住任务一旦在 Pend 里挂起就再也没有人能调度它了死锁。所以内核直接拒绝并返回 OS_ERR_PEND_LOCKED。这道检查知道的人不多但很实用它可以帮你快速定位“我在调度锁里调了 Pend”这类问题不然你只会看到任务莫名卡死。4.2 计数大于 0 时为什么直接减一返回OS_ENTER_CRITICAL(); if (pevent-OSEventCnt 0u) { /* 快速通道 */ pevent-OSEventCnt--; OS_EXIT_CRITICAL(); *perr OS_ERR_NONE; return; }这段快速通道是信号量性能的关键。没有竞争的时候一个 Pend 就是进临界区、判断、减一、出临界区几次操作而已绝不会引起调度。这也是为什么在任务里频繁 Pend 一个初值充足的信号量几乎不产生开销。注意这里的临界区用的是 OS_ENTER_CRITICAL 宏在 OS_CRITICAL_METHOD 配成 3 的情况下它会把 PRIMASK 保存到局部变量 cpu_sr 再关中断退出时恢复原值而不是无脑开中断。这个细节很重要——如果临界区嵌套无脑开中断会把外层临界区也破坏掉。4.3 OS_EventTaskWait 到底做了什么没抢到就进入等待流程前面那几行状态设置先不看核心是这一句 OS_EventTaskWait(pevent)它的实现是这样的void OS_EventTaskWait (OS_EVENT *pevent) { INT8U y; OSTCBCur-OSTCBEventPtr pevent; /* 记下我在等谁 */ y OSTCBCur-OSTCBY; if ((OSRdyTbl[y] (INT8U)~OSTCBCur-OSTCBBitX) 0u) { OSRdyGrp (INT8U)~OSTCBCur-OSTCBBitY; /* 从就绪表摘掉 */ } pevent-OSEventTbl[y] | OSTCBCur-OSTCBBitX; /* 挂进事件等待表 */ pevent-OSEventGrp | OSTCBCur-OSTCBBitY; }就三件事但每一件都有讲究。第一件是记下自己在等谁这样超时或者被中止的时候内核知道该从哪个等待表里把自己摘出来。注意 OS_TCB 里只有 OSTCBEventPtr 这一个指针这就是为什么 uC/OS-II 的任务同一时刻只能等一个事件——如果你需要等“A 或 B 任意一个”uC/OS-II 帮不了你只能自己用两个任务或者用事件标志组绕。第二件是从就绪表里摘掉自己。这里有个容易看漏的写法只有当 OSRdyTbl[y] 整个字节被清成 0 时才去清 OSRdyGrp 里对应的位。因为 OSRdyGrp 是粗粒度的组标志同一组里还有别的任务就绪这个组位就不能动。这个“先清细粒度、判断为零再清粗粒度”的套路在整个内核里反复出现把两个地方都看懂后面读事件表和就绪表的所有位运算都会顺畅很多。第三件是把自己挂进事件的等待表用的还是 OSTCBY、OSTCBBitX、OSTCBBitY 这三个预先算好的字段。这三个字段在任务创建时就一次性算好并存在 TCB 里用空间换时间避免在 Pend 这种高频路径上做移位运算。4.4 OS_Sched 之后回来要看三个分支挂起自己之后代码是这样走的OS_EXIT_CRITICAL(); OS_Sched(); /* 让出 CPU */ OS_ENTER_CRITICAL(); switch (OSTCBCur-OSTCBStatPend) { case OS_STAT_PEND_OK: *perr OS_ERR_NONE; /* 正常拿到 */ break; case OS_STAT_PEND_ABORT: *perr OS_ERR_PEND_ABORT; /* 被 OSSemPendAbort 中止 */ break; case OS_STAT_PEND_TO: default: OS_EventTaskRemove(OSTCBCur, pevent); *perr OS_ERR_TIMEOUT; /* 超时 */ break; } OSTCBCur-OSTCBStat OS_STAT_RDY; OSTCBCur-OSTCBStatPend OS_STAT_PEND_OK; OS_EXIT_CRITICAL();这里的设计很有意思Pend 只有一条“出口”就是被唤醒后从头继续执行至于“为什么被唤醒”则通过 OSTCBStatPend 这个字段来区分。这个字段由唤醒方填OSSemPost 填 OK超时处理填 TOOSSemPendAbort 填 ABORT。这种“单一唤醒点 状态字段区分原因”的写法在写驱动状态机的时候非常好用逻辑集中不容易漏分支。一个非常关键的细节只有超时分支才调用 OS_EventTaskRemove。为什么正常唤醒和中止不需要因为 Post 走的是 OS_EventTaskRdy它内部已经把任务从等待表摘掉了中止走的是 OSSemPendAbort它内部也做了摘除。只有超时这条路径是时间到了由时钟节拍主动触发的需要在这里补上摘除动作。这个不对称的写法初看别扭想清楚“谁唤醒谁负责摘除”这条规则之后就顺了。5. OSSemPost唤醒谁、怎么唤醒以及那个 655355.1 先看等待表还是先看计数OSSemPost 的骨架只有十几行但顺序很关键OS_ENTER_CRITICAL(); if (pevent-OSEventGrp ! 0u) { /* 有人在等 */ OS_EventTaskRdy(pevent, (void *)0, OS_STAT_SEM, OS_STAT_PEND_OK); OS_EXIT_CRITICAL(); OS_Sched(); /* 立刻切换 */ return (OS_ERR_NONE); } if (pevent-OSEventCnt 65535u) { /* 没人在等计数加一 */ pevent-OSEventCnt; OS_EXIT_CRITICAL(); return (OS_ERR_NONE); } OS_EXIT_CRITICAL(); return (OS_ERR_SEM_OVF); /* 计数溢出 */注意这里是“先看有没有人在等”而不是“先加计数”。如果有任务在等Post 不会把计数加 1而是直接把信号量“移交”给等待者计数保持不变。这个行为叫 handoff它解释了很多人困惑的一个现象为什么我 post 了三次、pend 了三次但查询计数的时候还是 0。因为三次 post 都直接给了等待者计数从来没动过。理解这一点才能算清楚信号量到底代表什么——它只反映“没有等待者时攒下来的余量”。另一个细节是 OS_Sched() 被放在临界区外面调用。这不是随手写的因为调度器会去操作就绪表和运行指针这些操作自己会进临界区如果嵌套在 Post 的临界区里虽然功能上也能跑但会白白拉长关中断时间。uC/OS-II 在这类地方很讲究凡是“能放到临界区外”的动作都尽量放出去这也是它能做到中断延迟可控的原因之一。5.2 OS_EventTaskRdy 与 OSUnMapTbl唤醒的核心在 OS_EventTaskRdy 里第一件事就是找出等待者中优先级最高的那个y OSUnMapTbl[pevent-OSEventGrp]; x OSUnMapTbl[pevent-OSEventTbl[y]]; prio (INT8U)((y 3u) x);原理和调度器里选最高优先级任务完全一样OSUnMapTbl 是一张 256 字节的查找表输入一个字节返回这个字节里最低位为 1 的位置索引。因为优先级数值越小优先级越高所以“低位优先”正好对应“高优先级先被选中”。用查表替代循环移位一次内存访问就出结果这是 uC/OS-II 在 8 位机上也能跑得很快的原因之一。接着是状态处理我把核心逻辑简化一下ptcb-OSTCBStat (INT8U)~msk; /* 清掉 OS_STAT_SEM 等待位 */ ptcb-OSTCBStatPend pend_stat; /* 告诉它为什么被唤醒 */ if ((ptcb-OSTCBStat OS_STAT_SUSPEND) ! 0u) { /* 任务被 OSTaskSuspend 挂起了先不放进就绪表 */ } else { OSRdyGrp | ptcb-OSTCBBitY; OSRdyTbl[y] | ptcb-OSTCBBitX; OS_EventTaskRemove(ptcb, pevent); /* 从等待表摘掉 */ }这里的分支处理的是“任务既在等信号量、又被手动挂起”这种复合状态。原版代码还处理了 pmsg 参数给邮箱和队列传消息以及 ABORT 场景我这段是删减版但“先清等待位、再填原因、最后决定要不要进就绪表”这个顺序是原样的。理解这个顺序的意义在于如果任务处于挂起状态它不会进就绪表但它已经从等待表里出来了——等它将来被 OSTaskResume 唤醒时它会直接变成就绪而不是回到之前那个信号量的等待队列里。这个语义在实际项目里很重要否则你会以为恢复的任务还在等信号量。5.3 计数上限和溢出返回if (pevent-OSEventCnt 65535u)这一句是在防溢出。OSEventCnt 是 INT16U最大 65535。如果有人反复 post 而没人 pend计数涨到 65535 之后继续 post 就会返回 OS_ERR_SEM_OVF。这个返回值必须检查不然你就丢掉了同步事件现象是生产者跑得飞快、消费者永远跟不上最后表现为内存爆掉或者数据错乱。我在一个采集项目里就是因为没查这个返回值串口中断每秒 post 几百次主任务处理不过来计数溢出后事件全部丢失最后是抓串口日志才发现的。后面改成了用队列传数据计数溢出这个问题自然就消失了。5.4 中断里为什么能安全地 PostOSSemPost 里没有 OSIntNesting 检查也就是说它可以在中断里调用这是它和 Pend 最大的不对称。原因在于 Post 只做三件事摘最高优先级等待者、把它放回就绪表、调 OS_Sched 或让 OSIntExit 去切换。这三件事都不涉及“把当前上下文挂起”。但要注意在中断里调用 Post 时OS_Sched 会被 OSIntExit 取代——准确地说你在 ISR 里调 OSSemPostPost 内部那次 OS_Sched 调用会因为 OSLockNesting 或 OSIntNesting 的判断直接返回不做切换真正做切换的是 ISR 结尾的 OSIntExit。OSIntExit 会递减 OSIntNesting当它减到 0 时如果有更高优先级任务就绪就触发一次任务切换。所以在中断里 post 信号量之后被唤醒的高优先级任务会在本次中断返回时立刻得到执行而不是等到下一个时钟节拍。这个行为是很多实时性保障的基础也是 uC/OS-II 能做到“中断驱动”的关键。6. 超时、删除与善后OS_EventTO 和 OSSemDel6.1 OSTCBDly 与 OSTimeTick 的联动Pend 的时候有一句OSTCBCur-OSTCBDly timeout;这就是超时机制的入口。timeout 为 0 表示永久等待非 0 表示最多等这么多 tick。每个时钟节拍里OSTimeTick 会遍历任务链表把每个 OSTCBDly 非 0 的任务递减一次减到 0 的就调用 OS_EventTO 做超时处理。这个遍历的开销和任务数量成正比所以任务数特别多的时候比如超过 30 个如果 OS_TICKS_PER_SEC 配成 1000每毫秒遍历一次任务链表会带来可观的开销。我的经验是如果项目里大量任务用超时等待OS_TICKS_PER_SEC 配 200 到 500 就够没必要追求 1000。另一个必须知道的限制OSTimeTick 只会遍历 OSTCBList也就是已经创建的任务。任务创建之前、删除之后都不在链表上所以超时机制对它们无效。还有一点OSTCBDly 这个字段是复用的Pend 超时用它OSTimeDly 也用它所以一个任务不可能同时在“延时中”和“等信号量中”。这个理解能帮你避免写出OSTimeDly(100); OSSemPend(sem, 200, err);然后期望 300 毫秒超时的代码实际行为是延时 100 毫秒后开始 pend再等 200 个 tick。6.2 OS_EventTO 的命运超时处理函数只有几行void OS_EventTO (OS_EVENT *pevent) { if (OSTCBCur-OSTCBStatPend ! OS_STAT_PEND_OK) { OSTCBCur-OSTCBStatPend OS_STAT_PEND_TO; /* 标记超时原因 */ OS_EventTaskRemove(OSTCBCur, pevent); /* 从等待表摘除 */ OSRdyGrp | OSTCBCur-OSTCBBitY; /* 放回就绪表 */ OSRdyTbl[OSTCBCur-OSTCBY] | OSTCBCur-OSTCBBitX; } }那个if (OSTCBStatPend ! OS_STAT_PEND_OK)判断是在处理一个微妙的竞态如果时钟节拍递减 OSTCBDly 到 0 的同时正好有人 Post 把这个任务唤醒了那么 OSTCBStatPend 已经被填成 OK这时候就不该再把它当超时处理。两个操作都在临界区里所以这个检查是可靠的。这种“在临界区里做二次确认”的写法在处理并发事件时特别有用我在写电机控制的状态机时也用了同样的套路。6.3 OSSemDel 的删除策略删除信号量有三种方式通过 opt 参数控制OS_DEL_NO_PEND 要求无人等待才删有人在等就返回 OS_ERR_TASK_WAITINGOS_DEL_ALWAYS 强制删除把所有等待者全部就绪化并让它们得到 OS_ERR_PEND_ABORT。强制删除的时候内核会做这几件事把所有等待这个信号量的任务从等待表摘出来、逐个放回就绪表、给它们的 OSTCBStatPend 填上 OS_STAT_PEND_ABORT。注意它并不会立刻触发调度而是等调用者返回后由调度点统一处理。这里有个真实的坑如果一个任务在 Pend 里被强制中止返回的错误码是 OS_ERR_PEND_ABORT而不是你先看到的 OS_ERR_NONE。很多人的代码只判断了 OS_ERR_NONE导致任务在中止后继续往下执行访问了本该受信号量保护的数据。更麻烦的是OSSemDel 之后那个 OS_EVENT 块会被挂回空闲链表如果你手上还留着一个指向它的指针下次分配出去之后这个指针就指向了别人的信号量操作它的后果是彻底不可预测的。所以我的原则是信号量尽量不删要删就确保没有任何地方还持有句柄。6.4 OSSemPendAbort 的用法除了删除时的被动中止还有一个主动中止接口 OSSemPendAbort(pevent, opt, perr)opt 可以是 OS_PEND_ABORT_ONLY 或者 OS_PEND_ABORT_ALL。它的典型用途是在错误处理里清场比如一个通信任务等数据的信号量超时了主控任务想立刻终止这次通信就可以用 PendAbort 把所有等在这个信号量上的任务全部踢出来。要注意它只影响等待者不影响计数器本身的值所以调用之后如果还有余量新的 Pend 依然能立刻通过。7. 互斥量与优先级反转uC/OS-II 给的半个答案7.1 优先级反转到底长什么样用一个具体场景说明。假设有三个任务高优先级任务 H优先级 5中优先级任务 M优先级 15低优先级任务 L优先级 20。H 和 L 共享一个信号量保护的资源。时间线是这样的L 先拿到信号量开始处理一段耗时的操作此时 H 就绪抢占 L去 pend 同一个信号量失败挂起这时 M 因为某个事件就绪了它的优先级比 L 高于是抢占 L 开始跑结果 H 明明优先级最高却要等 M 跑完、L 再跑完、L 释放信号量之后才能继续。如果 M 是个长时间的运算任务H 的等待时间就完全不可预测了。这在控制类项目里是致命的——一个高优先级任务做失控检测被中优先级任务挤压几百毫秒该响应的故障就没响应到。这就是经典的优先级反转。注意它和死锁不同系统没卡死只是实时性崩了。7.2 OSMutex 的优先级继承怎么实现uC/OS-II 给的解法是优先级继承。用 OSMutexCreate 创建互斥量时你要传一个优先级参数作为“上限优先级”。当 H 去 pend 一个被 L 持有的互斥量时内核做的是把 L 的优先级临时提升到和 H 一样高或者提升到创建时给的上限优先级等 L 释放互斥量时再改回原来的优先级。实现上互斥量用 OSEventCnt 干了双份活低字节在锁被占用时记着拥有者的原始优先级高字节存着创建时指定的上限优先级OSEventPtr 则直接指向持有锁的那个任务的 TCB。当发生竞争时内核通过 OSEventPtr 找到拥有者的 TCB把它的 OSTCBPrio 改掉同时更新 OSTCBBitX、OSTCBBitY、OSTCBY 这几个位图辅助字段把它从旧优先级的就绪表位置挪到新位置。释放的时候再做一次反向操作把原始优先级还原回去。这里有个值得注意的实现约束uC/OS-II 的互斥量只支持“提升到上限优先级”这一种策略不支持多级继承链的完整展开。真正严谨的优先级继承需要处理“一个任务同时持有多个互斥量”的复杂情况uC/OS-II 在这方面做得比较简单工程上够用但不能指望它像某些更复杂的 RTOS 那样严密。7.3 为什么普通信号量不内建继承有人会问既然继承这么好用为什么不让 OSSem 也支持答案在于信号量的语义本身就不支持“拥有者”这个概念。信号量的 counter 可以被一个任务减、另一个任务加也可以连续减两次、连续加两次内核对“谁持有”根本没有记录。没有拥有者信息就无从知道该提升谁的优先级。互斥量则强制要求“谁拿谁放”内核在 OSEventPtr 里牢牢记住持有者才能实现继承。所以判断标准很清晰如果这段临界资源必须由同一个任务取得和释放用互斥量如果是一方发另一方收的通知机制用信号量。混用会带来各种难以定位的问题尤其是优先级继承失效这类问题现象上完全不报错只是偶尔响应慢一下查起来极其痛苦。8. 实操在 GD32F103 上验证信号量行为8.1 工程与时钟配置GD32F103 是 Cortex-M3 内核主频 108MHz也有跑 72MHz 的型号配置移植 uC/OS-II 和 STM32F103 的流程基本一致直接用 Ports/ARM-Cortex-M3/Generic 下的移植文件即可需要改的主要是三个地方。第一处是 SysTick 的配置。uC/OS-II 用 SysTick 产生时钟节拍要保证 SysTick 中断优先级是最低的数值最大否则它可能打断其他中断导致 OSTimeTick 里的操作被重入。在 Cortex-M3 上配置 SHPR3 寄存器PendSV 和 SysTick 通常都设成 0xFF。第二处是 OS_CRITICAL_METHOD配成 3。这个模式下临界区会把 PRIMASK 保存到 cpu_sr 再关中断退出时恢复支持嵌套。GD32F103 提供的 __disable_irq() 和 __enable_irq() 是现成的OS_CPU_SR_Save 和 OS_CPU_SR_Restore 里直接调 MRS/MSR 指令读写的 PRIMASK 效率更高我用的是直接内联汇编版本。第三处是 OS_TICKS_PER_SEC我通常配 200。理由前面说过超时遍历的开销和节拍频率成正比200Hz 对于绝大多数控制场景已经足够5 毫秒的调度粒度不会成为瓶颈。8.2 三个任务的代码与预期现象验证优先级反转我搭了这样一个最小工程#define TASK_H_PRIO 5 #define TASK_M_PRIO 15 #define TASK_L_PRIO 20 static OS_EVENT *shared_sem; /* 换成 OSMutexCreate 做对比 */ static volatile INT32U h_wait_ticks; /* 低优先级任务拿锁后干一段活 */ void TaskL(void *p_arg) { INT8U err; for (;;) { OSSemPend(shared_sem, 0, err); /* 先拿锁 */ printf([L] got lock, working...\r\n); busy_delay_ms(150); /* 模拟耗时操作 */ printf([L] release lock\r\n); OSSemPost(shared_sem); OSTimeDly(500); } } /* 中优先级任务纯占用 CPU */ void TaskM(void *p_arg) { for (;;) { printf([M] running\r\n); busy_delay_ms(300); OSTimeDly(800); } } /* 高优先级任务等锁记录等待时长 */ void TaskH(void *p_arg) { INT8U err; INT32U t0, t1; for (;;) { OSTimeDly(20); /* 让 L 先拿到锁 */ t0 OSTimeGet(err); OSSemPend(shared_sem, 0, err); t1 OSTimeGet(err); h_wait_ticks t1 - t0; printf([H] wait %lu ticks\r\n, h_wait_ticks); OSSemPost(shared_sem); OSTimeDly(1000); } }任务优先级我特意设成 H5、M15、L20然后给 L 和 M 都排了时间。跑起来串口会打印出 H 的等待时长。8.3 实测现象与排查用普通信号量跑的时候H 的等待时长在 150 到 450 tick 之间大幅波动因为 M 经常在 L 释放锁之前抢占 L。把 shared_sem 换成互斥量OSMutexCreate(5, err)上限优先级设成和 H 一样的 5之后H 的等待时长稳定在 150 tick 左右波动明显收窄这就是优先级继承在起作用——L 被临时提到优先级 5M 再也抢不走它了。调试过程中我遇到过一个坑值得单独说一下第一次跑的时候 H 的等待时长打印出来是 0看起来“完美”其实是 H 根本没等——因为 H 的 OSTimeDly(20) 加上调度顺序H 每次都在 L 释放锁之后才去 pend。这种“测试通过得莫名其妙”的情况最危险我的做法是在 Pend 前后都加时间戳打印同时按键触发一个信号让 H 立刻 pend确保它真的在竞争路径上。做实时性验证一定要先确认测试真的覆盖了目标场景否则测出来的数据毫无意义。另一个排查技巧如果发现任务唤醒后完全没有切换过去先检查 OS_Sched 里那个if (OSLockNesting 0u) return;看看是不是哪里调了 OSSchedLock 忘记解锁。我见过一个项目在初始化函数里调了 OSSchedLock 保护一段配置结果提前 return 的那条错误分支跳过了 OSSchedUnlock整个系统就只剩一个任务在跑而且没有任何报错。8.4 几个参数选择的经验任务栈大小方面用 printf 的任务建议至少 256 个 OS_STK 单元。printf 内部会用到比较深的调用栈尤其带浮点格式化的时候。我一开始给 H 任务配了 128跑起来正常但偶尔会出现数据错乱最后加了一个栈填充检测才发现是栈快溢出了。uC/OS-II 有个 OSTaskStkChk 可以统计栈使用量建议在调试阶段每个任务都跑一次把结果打印出来然后按最大值乘 1.5 到 2 倍配置。信号量数量方面OS_MAX_EVENTS 我一般按“实际用量 × 2 4”来配。多出来的部分是为了应对后续功能增加以及某些模块内部自己隐藏用掉的同步对象。超时值方面凡是 Pend 我都建议给一个非 0 的超时。永久等待只在极少数确定不会出问题的场景下使用。我踩过的坑就是某个任务永久等一个中断才会 post 的信号量结果中断配置错误任务永远睡死整个系统的状态机卡在半路还没有任何日志。加了 500 毫秒超时之后同一个 bug 立刻变成了可见的错误日志五分钟就定位了。9. 几个被问烂的面试题从源码角度回答9.1 信号量和互斥量到底差在哪标准答案通常是“互斥量支持优先级继承信号量不支持”。从源码角度看差别体现在三个字段上OSEventType 一个是 OS_EVENT_TYPE_MUTEX 一个是 OS_EVENT_TYPE_SEMOSEventPtr 在互斥量里必须指向持有者 TCB在信号量里恒为 NULLOSEventCnt 在互斥量里被拆成高低字节存两份优先级信息在信号量里就是一个纯计数。除此之外还有一条使用约束互斥量必须同任务获取、同任务释放Pend 和 Post 的调用者必须是同一个信号量不受这个限制。这条约束才是“互斥量能实现继承”的前提。9.2 为什么 OSSemPend 不能在中断里调用从源码看就一句话Pend 可能走到 OS_EventTaskWait而它操作的是 OSTCBCur中断上下文里的 OSTCBCur 是被打断的那个任务把它挂起会破坏任务状态机。内核用 OSIntNesting 计数器拦住这种情况返回 OS_ERR_PEND_ISR。反过来 OSSemPost 不操作当前任务只操作等待表所以可以在 ISR 里用。很多面试官问这个其实是想确认你知不知道“中断上下文没有任务身份”这个本质。9.3 和 Linux 的信号量差在哪Linux 的信号量是一种睡眠锁拿不到的时候把进程挂到等待队列上调用 schedule() 主动让出 CPU等唤醒后再重试整个过程可能经历多次上下文切换甚至跨核的唤醒。它是为吞吐量和不浪费 CPU 设计的。uC/OS-II 的信号量是位图加调度器等待者的排队顺序是“优先级顺序”而不是“先来先服务”唤醒时靠查表一次定位最高优先级的等待者不重建任何数据结构。这种差异的来源很清楚uC/OS-II 面对的是几十个任务、几十 KB RAM 的场景每个事件控制块只有二十来字节一切为了确定性和可预测性Linux 面对的是上千个进程和复杂的多核调度需要完全不同的权衡。顺带说一句uC/OS-II 的等待队列是按优先级排的这一点和 FreeRTOS 的队列不同——FreeRTOS 默认是 FIFO 唤醒除非你开启优先级排序。在做多任务公平性测试的时候这个差异会非常明显uC/OS-II 里低优先级的等待者可能一直等不到只要高优先级的任务不停来抢。9.4 一个任务能不能同时等多个事件不能。OS_TCB 里只有一个 OSTCBEventPtr 字段同一时刻只能记录一个等待对象。想要实现“等 A 或 B 任意一个”uC/OS-II 提供的方案是事件标志组OSFlagCreate 那一套它用一个 32 位标志位图来记录多个事件支持“与”和“或”两种等待模式。FreeRTOS 那边对应的就是 EventGroup。这个限制在做多路输入整合的时候会很突出比如一个任务既要处理按键又要处理串口命令要么用两个任务分流要么用事件标志组不能靠两个 Pend 串起来等。疲了这么多源码细节最后分享一个我自己的习惯每次读 uC/OS-II 的这类同步函数我都会在纸上画两张位图一张画就绪表的 OSRdyGrp/OSRdyTbl一张画当前事件的 OSEventGrp/OSEventTbl然后跟着代码一行一行改动里面的位。信号量的每一次 Pend 和 Post本质上都是在这两张表之间搬一位。把这个动作在脑子里跑顺之后再看 OS_EventTaskRdy 里那些|和~就不会再有“这是在干什么”的困惑了。下一次我会顺着这条线往队列 OS_Q 走那个才是有载荷的同步对象也是把信号量的位图机制真正用满的地方。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号