说明:本文基于 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. 创建与初始化:空闲链表和 OSSemCreate
3.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,超时处理填 TO,OSSemPendAbort 填 ABORT。这种“单一唤醒点 + 状态字段区分原因”的写法在写驱动状态机的时候非常好用,逻辑集中,不容易漏分支。
一个非常关键的细节:只有超时分支才调用 OS_EventTaskRemove。为什么正常唤醒和中止不需要?因为 Post 走的是 OS_EventTaskRdy,它内部已经把任务从等待表摘掉了;中止走的是 OSSemPendAbort,它内部也做了摘除。只有超时这条路径,是时间到了由时钟节拍主动触发的,需要在这里补上摘除动作。这个不对称的写法初看别扭,想清楚“谁唤醒谁负责摘除”这条规则之后就顺了。
5. OSSemPost:唤醒谁、怎么唤醒,以及那个 65535
5.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 中断里为什么能安全地 Post
OSSemPost 里没有 OSIntNesting 检查,也就是说它可以在中断里调用,这是它和 Pend 最大的不对称。原因在于 Post 只做三件事:摘最高优先级等待者、把它放回就绪表、调 OS_Sched 或让 OSIntExit 去切换。这三件事都不涉及“把当前上下文挂起”。
但要注意,在中断里调用 Post 时,OS_Sched 会被 OSIntExit 取代——准确地说,你在 ISR 里调 OSSemPost,Post 内部那次 OS_Sched 调用会因为 OSLockNesting 或 OSIntNesting 的判断直接返回不做切换,真正做切换的是 ISR 结尾的 OSIntExit。OSIntExit 会递减 OSIntNesting,当它减到 0 时,如果有更高优先级任务就绪,就触发一次任务切换。所以在中断里 post 信号量之后,被唤醒的高优先级任务会在本次中断返回时立刻得到执行,而不是等到下一个时钟节拍。这个行为是很多实时性保障的基础,也是 uC/OS-II 能做到“中断驱动”的关键。
6. 超时、删除与善后:OS_EventTO 和 OSSemDel
6.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_WAITING;OS_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); } }任务优先级我特意设成 H=5、M=15、L=20,然后给 L 和 M 都排了时间。跑起来串口会打印出 H 的等待时长。
8.3 实测现象与排查
用普通信号量跑的时候,H 的等待时长在 150 到 450 tick 之间大幅波动,因为 M 经常在 L 释放锁之前抢占 L。把 shared_sem 换成互斥量(OSMutexCreate(5, &err),上限优先级设成和 H 一样的 5)之后,H 的等待时长稳定在 150 tick 左右,波动明显收窄,这就是优先级继承在起作用——L 被临时提到优先级 5,M 再也抢不走它了。
调试过程中我遇到过一个坑,值得单独说一下:第一次跑的时候 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_SEM;OSEventPtr 在互斥量里必须指向持有者 TCB,在信号量里恒为 NULL;OSEventCnt 在互斥量里被拆成高低字节存两份优先级信息,在信号量里就是一个纯计数。除此之外还有一条使用约束:互斥量必须同任务获取、同任务释放,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 走,那个才是有载荷的同步对象,也是把信号量的位图机制真正用满的地方。