
裸机开发和 RTOS 开发最大的分水岭在哪里如果你问以前的自己答案可能是“任务切换”、“调度器”这类听起来很硬核的东西。但真正跑过几个任务之后你会发现最卡脖子的不是任务怎么切换而是多个任务同时抢一个资源时系统怎么保证不出乱子。信号量就是解决这个问题的钥匙。这篇是“从手搓操作系统开始”系列的第 8 篇前几篇我们把任务表、调度器、上下文切换一个个折腾通了任务能跑起来了但这只是万里长征第一步。任务之间怎么通信、共享资源怎么保护、谁先谁后怎么协调这些才是 RTOS 真正体现价值的地方。这期我要做的事很具体在我自己的极小内核里从零实现一套可用的信号量机制然后拿它驱动一个经典的串口打印 Demo把任务同步和资源共享这两个最核心的场景跑通。提前说明一下这个系列的代码目标是 GD32F103 这类 Cortex-M3 内核的 MCU代码风格偏教学但绝不玩具化你如果手头有正点原子或者野火的开发板直接照着建工程就能跑。现在开始先把信号量这个玩意儿彻底拆明白。1. 信号量的本质与设计动机1.1 裸机开发里最难啃的骨头是什么裸机工程师嘴上不说但心里都清楚最头疼的不是逻辑写不出来而是多个执行流抢同一个东西。举个例子你用两路传感器采集数据每路都往同一个串口打印调试信息。单线程时串口输出的时序是完全可控的一条打印语句从开头到结尾不会被插队。可一旦上了中断或者你用了某种“伪多任务”的方式交替执行两个逻辑问题就来了printf 打到一半中断触发另一个逻辑也开始往串口写数据两条消息直接绞成了一团乱麻。你会想加个标志位不就行了比如while(busy); busy 1; printf(...); busy 0;。这套逻辑在有的场景下确实能凑合但有几个硬伤第一标志位本身的操作不是原子的读判断和置位之间可能被打断第二如果你有多个资源、多级优先级就要写一堆全局变量代码会越来越像蜘蛛网第三如果任务在等待时还想要超时、想要被阻塞不去空转耗 CPU裸机标志位根本做不到。这是所有裸机开发者的共同痛点也是 RTOS 里信号量、互斥量、队列这些组件存在的根本原因。它们本质上干的是同一件事把“资源不可用时的等待”这件事从应用层逻辑里剥离开交给内核统一管理。1.2 信号量能解决的两类核心问题信号量Semaphore是一个整数配合两个基本操作Take获取/P 操作和 Give释放/V 操作。它的语义用大白话说就是获取信号量时如果计数大于 0就把计数减 1继续执行如果计数等于 0任务就乖乖进入阻塞状态挂到等待队列里释放信号量时计数加 1如果有任务在等待就唤醒其中一个。基于这套语义信号量可以覆盖两个完全不同的场景。第一个场景是共享资源互斥。把信号量初值设为 1就成了二值信号量。谁拿到了它谁就拥有了对资源的独占权用完了再还回来。串口、LCD、ADC 校准值、Flash 缓冲区这些同一时刻只允许一个任务碰的东西都用它来保护。第二个场景是任务间同步。信号量初值设为 0当一个任务需要等待某个事件发生时它就尝试 Take因为计数为 0 会直接阻塞另一个任务在事件发生的地方调用 Give把信号量置为 1同时唤醒等待者。这个模式特别适合“生产者-消费者”、“硬件中断通知任务”这类结构。你可以把信号量想象成一把洗手间的钥匙。互斥场景里外面有 5 个人、只有 1 把钥匙谁拿到钥匙谁进去出来再交回。同步场景里钥匙一开始不在外头而是放在某个特定的人身上别人拿不到就得在门口排队等直到那个人把钥匙递出来。1.3 为什么不自研的架构要参考 FreeRTOS 的“套路”有人会问既然 FreeRTOS 已经封装好了现成的信号量 API为什么不直接拿来用非要自己从零写一遍我个人的观点是直接调用 API 只能让你成为一个“用户”而手搓一遍能让你成为“设计者”。FreeRTOS 的信号量 API 封装层级很多从队列到信号量中间隔了好几层初学者直接看源码常常一头雾水。但如果你自己写过一遍核心逻辑再回头看 FreeRTOS 的实现会有种豁然开朗的感觉你会明白它为什么要把互斥量单独做成一个特殊队列为什么优先级继承逻辑要放在某个位置。所以这期文章的思路是参照 FreeRTOS 的经典数据结构设计和调度器配合方式但不用它的代码用手头的任务表和调度器从头写一套最小可用版本。代码量不大但麻雀虽小五脏俱全。2. 先搭框架信号量的数据结构与 API 设计2.1 信号量结构体两个成员就够了在动手写代码之前先明确一个原则复用已有的内核基础设施而不是重复造轮子。我的内核里已经有一份维护所有任务控制块TCB的双向链表还有一套任务就绪队列的管理机制。信号量要管理的“等待队列”本质上也是一堆 TCB 排队的链表完全可以复用同一套链表结构。基于这个思路信号量的结构体定义很简单typedef struct { uint32_t count; /* 信号量计数值 */ List_t waiterList; /* 等待该信号量的任务链表 */ } Semaphore_t;count就是信号量的“钥匙数量”waiterList是所有因为 Take 不到这个信号量而阻塞的任务列表。每个阻塞任务的 TCB 里会有一个链表节点把 TCB 挂上去就行。这个设计的妙处在于信号量不需要自己维护“哪个任务在等”只需要维护一个链表头剩下的都复用任务管理的逻辑。这也是商业 RTOS 里普遍的做法局部性少不容易出 bug。2.2 初始化二值和计数在结构上没有区别信号量的初始化接口我设计了两个宏方便从语义上区分使用场景#define SemCreateBinary() do { (pSem)-count 1; ListInit((pSem)-waiterList); } while(0) #define SemCreateCounting(n) do { (pSem)-count (n); ListInit((pSem)-waiterList); } while(0)这里有个很多新手容易迷糊的点二值信号量和计数信号量在数据结构上完全一样区别只在初始值。初值为 1 就是互斥锁的二值形态初值为 0 就变成了同步事件标志初值为 N 就是资源池计数信号量。真正把二值信号量升级成互斥量Mutex时才需要另外加“持有者”、“优先级继承”等字段。但这个我们放在后面专门讨论这里先做最基础的版本。2.3 API 接口越少越好信号量的最小 API 集就三个SemTake(Semaphore_t *pSem)获取信号量拿不到就阻塞SemGive(Semaphore_t *pSem)释放信号量唤醒一个等待者SemInit(Semaphore_t *pSem, uint32_t initCount)初始化。如果你还想要超时等待的语义可以给SemTake加一个timeout参数比如SemTake(Semaphore_t *pSem, uint32_t timeoutMs)。但是在这个系列的早期版本里我决定先不做超时只支持永久等待。为什么因为永久等待的实现只需要把任务挂到等待链表然后触发一次任务切换就完事不涉及定时器、超时扫描这些额外机制。先跑通基础再考虑增强这是自研操作系统最稳的路线。你回想一下FreeRTOS 也是从极简 API 一步步做大的。2.4 为什么等待队列用链表而不是固定数组有的朋友可能在裸机里写过“信号量”的简化版用一个全局计数器来实现。到了一定规模后发现多个任务同时等待时不知道该唤醒谁也不知道谁在等只能靠轮询。链表的优势在于任务阻塞和唤醒都是 O(1) 级别的插入删除操作不需要事先知道最大任务数。数组版会面临“最多支持 N 个等待者”的尴尬而在嵌入式里任务数量本身应该是一个设计时确定的上限链表则完全没有这个问题。3. 核心实现从第一次写 SemTake 到能跑3.1 SemGive 的完整实现先唤醒还是先加一很多人第一次写SemGive时第一反应是Count然后看看有没有等待者有就唤醒一个。逻辑上说没毛病但细想一下就发现问题。假如信号量初值为 1任务 A 持有它任务 B 在等待。此时count 0A 拿走时减掉的B 阻塞。A 释放时执行countcount 变成 1然后检查等待队列发现 B 存在就把 B 唤醒。问题来了B 被唤醒后会继续执行SemTake的剩余逻辑它需要把 count 减 1变成 0。这中间如果 B 还没跑C 任务也来 Takecount 是 1C 直接把信号量拿走了。也就是说A 释放的信号量最终可能被后来者截胡而等待已久的 B 反而拿不着这就是经典的“唤醒丢失”问题。所以正确的做法是如果等待队列非空直接唤醒队首任务不需要 count。因为被唤醒的任务本来就是要拿信号量的信号量等于直接从释放者手里交接给了等待者计数保持不变。void SemGive(Semaphore_t *pSem) { uint32_t savedIrq; /* 关中断保护临界区 */ savedIrq EnterCritical(); /* 关中断并保存状态 */ if (!ListIsEmpty(pSem-waiterList)) { /* 有任务在等直接唤醒队首不需要 count */ TCB_t *pTask ListFirstEntry(pSem-waiterList); ListRemove(pTask-pendNode); SetTaskReady(pTask); /* 把任务加回就绪队列 */ } else { /* 没人等计数加一 */ pSem-count; } ExitCritical(savedIrq); /* 恢复中断状态 */ }这段代码里有个隐藏很深的细节也是我调试了很久才发现的问题唤醒动作里任务从等待链表摘除、加入就绪链表这两步必须和调度器的就绪队列操作保持同一个优先级视角。如果你在SetTaskReady时被唤醒的任务优先级比当前任务高就要立刻发起一次任务切换让高优先级任务抢占 CPU而不是等当前任务自己跑完再切换。这涉及到调度点的放置。3.2 SemTake 的完整实现拿不到就挂起自己SemTake的逻辑相对直白但有两个关键点容易被忽略一是“把自己挂到等待链表”和“从就绪链表摘除”必须是一个原子操作二是挂完之后必须立刻触发任务切换否则当前任务已经不可运行了调度器如果还去选它整个系统就乱了。void SemTake(Semaphore_t *pSem) { uint32_t savedIrq; savedIrq EnterCritical(); if (pSem-count 0) { /* 有可用资源直接拿走 */ pSem-count--; ExitCritical(savedIrq); return; } /* 没有资源当前任务阻塞 */ currentTask-state TASK_BLOCKED; ListInsertTail(pSem-waiterList, currentTask-pendNode); ListRemove(currentTask-readyNode); /* 从就绪队列摘除 */ /* 触发任务切换 */ ExitCritical(savedIrq); Schedule(); /* 切换到下一个就绪任务 */ }Schedule函数具体做什么取决于内核实现的调度方式。如果是基于 PendSV 的上下文切换Schedule内部会置一个标志位然后触发 PendSV在异常返回时完成真正的切换如果是简化版的协程式调度Schedule会直接调用TaskSwitch保存现场并跳转。这里有个细节为什么ListInsertTail用尾插而不是头插因为 FIFO 顺序唤醒对多数场景更公平。FreeRTOS 在这方面默认也是 FIFO你如果想做优先级顺序唤醒可以在SemGive里遍历等待链表找到优先级最高的唤醒。但注意这个遍历的开销在高优先级任务频繁等信号量的场景下可能不可忽略。3.3 为什么必须关中断一个安全内核对临界区的底线要求很多人写裸机代码时用“全局中断开关”来保护临界区会被一些老工程师批评“关中断太粗暴了”。但在一个自研的小内核里EnterCritical/ExitCritical就是最简单、最可靠的方案。为什么因为我们的信号量操作涉及三项数据的修改信号量的count字段当前任务的state和readyNode等待链表的节点插入删除。这三项数据的任何一项在多任务环境下被并发访问都会导致数据竞态。比如如果SemGive在检查count之后、执行count之前被任务切换打断另一个任务也调了一次SemGive两个 Give 加起来只算了一次信号量计数就错乱了。关中断的作用是在当前任务访问这些共享数据时禁止一切抢占包括中断引起的任务切换保证一段代码要么不执行、要么一气呵成。对我当前的内核来说没有实现多核也没有实现中断嵌套调度关中断是最稳妥的选择。这里送给你一条自研开发中的经验不到万不得已不要用 Flag 来做互斥也不要试图用原子的读改写指令来模拟完整的信号量。LDREX/STREX只能保证单个变量的原子修改但“把任务从就绪队列摘除”、 “挂在等待队列末尾”这种跨多个数据结构的操作必须靠关中断保证整体一致。3.4 关中断和调度的微妙关系先解锁再调度在SemTake的实现中我特意把ExitCritical(savedIrq)放在Schedule()之前。这源于一个关键原因调度函数内部很可能也要操作就绪队列如果再在关中断状态下执行就可能发生“临界区套临界区”或者更严重的问题——调度器在选择下一个任务时发现自己需要操作硬件上下文而中断被关了整个流程会变得极其脆弱。以 PendSV 为例上下文切换依赖 PendSV 异常触发 PendSV 异常属于硬件行为但如果你在关中断状态下触发 PendSV可能无法及时响应。所以正确的顺序是先恢复中断使能再请求调度。这个先后顺序要是写反了系统跑起来会出一些非常诡异的现象偶尔卡死、偶尔任务不切换、偶尔信号量状态错乱。我当时排查了整整一个晚上最后把ExitCritical挪到了Schedule前面一切恢复正常。这也是裸机思维转型到 RTOS 思维最典型的一道坎。4. 用信号量驱动串口第一个不“踩踏”的 Demo4.1 场景两个任务抢串口光讲实现原理太抽象得跑起来看效果。我搭了一个最简单的实验两个周期任务Task_A 每 500ms 打印一串以[A]开头的内容Task_B 每 800ms 打印一串以[B]开头的内容。它们共用 UART没有信号量保护时你会看到输出被彻底打乱。注意我这里说的是真实硬件 UART不是仿真器里的虚拟串口。真实 UART 发送是字节级的一帧打印语句会被拆成几十个字节逐个发送。如果两个任务交替发送串口终端上就会看到类似这样的乱象[A] Hello [B] World[A] from乱的原因很简单任务 A 打印到一半时间片到了任务 B 被调度进来也开始写 UART。硬件层面UART 数据寄存器是共享资源谁写最后一位谁就覆盖了前一个人的输出。4.2 实现思路一个二值信号量保护输出我的演示代码只增加了一个共享信号量static Semaphore_t g_uartSem; void AppTask_A(void *param) { for (;;) { SemTake(g_uartSem); Uart_Printf([A] Hello from Task A, tick %d\n, GetTick()); SemGive(g_uartSem); DelayMs(500); } } void AppTask_B(void *param) { for (;;) { SemTake(g_uartSem); Uart_Printf([B] Hello from Task B, tick %d\n, GetTick()); SemGive(g_uartSem); DelayMs(800); } }核心逻辑极其简单。SemTake确保当前时刻只有拿到信号量的任务能进入打印区打印完成后再SemGive释放。两个任务的打印操作就被强制串行化了。这里有一个值得注意的点Uart_Printf这个函数本身应该是“缓冲式”的还是“轮询式”的我用的驱动是轮询式的即每个字节都等待发送完成寄存器置位。这意味着一次打印会耗时较长进一步放大资源冲突问题也更能体现保护的价值。如果你的板子用的是 DMA 中断式 UART 驱动信号量保护逻辑还需要配合一个“发送完成”回调否则可能发生任务已经释放信号量但 DMA 还在后台搬运数据的情况。4.3 实测对比信号量带来的差异有多明显在同一个开发板上我先后跑了“无信号量版”和“有信号量版”两个固件串口终端的输出对比非常直观。无信号量版本终端的输出随机穿插几乎每次运行结果都不一样。有信号量版本输出稳定成块[A] Hello from Task A, tick 512 [A] Hello from Task A, tick 1000 [B] Hello from Task B, tick 1024 [A] Hello from Task A, tick 1504 [B] Hello from Task B, tick 2048 [A] Hello from Task A, tick 2000这种对比是学习信号量最好的教材它用看得见的铁一般的事实告诉你RTOS 的同步机制不是摆设是刚需。4.4 从信号量到互斥量什么时候你应该换一种锁串口打印这个场景里用二值信号量做互斥是没问题的因为它足够简单。但如果你仔细观察会发现二值信号量有一个短板它不记录“谁持有”。任何任务都可以 Give 一个自己没有 Take 过的信号量。这在一些应用场景下会导致问题。比如任务 A 获取了串口任务 B 不知情地调用了一次 Give信号量计数变成 2之后任务 C 也以为能拿到串口三个任务一起写 UART乱象又回来了。解决办法是引入互斥量Mutex。互斥量的核心特性是只有持有者才能释放支持递归获取具备优先级继承后面展开。所以在资源互斥场景尤其是严肃的工程项目中我更推荐用互斥量而信号量则更偏向同步场景。这也是为什么 FreeRTOS 里互斥量 API 叫xSemaphoreCreateMutex但它底下实际是一个特殊的队列实现——本质上就是带所有权约束的二值信号量。5. 信号量算法的经典坑优先级翻转问题5.1 什么场景下会翻车当你把串口信号量放进一个多优先级系统里一场事故已经在路上。假设系统里有三个任务任务 H高优先级处理紧急报警需要用到串口打印任务 M中优先级做大量浮点运算不碰串口任务 L低优先级日常打印日志拿着串口信号量。正常运行情况下没有冲突。但如果出现这样一个时间窗口任务 L 拿到了信号量打印到一半被中断打断接着任务 M 就绪并抢占 CPU开始长时间运行。此时任务 H 也变成就绪态因为它优先级比 M 高立刻抢占了 M然后它调用SemTake发现信号量被 L 占着只能阻塞等待。这时候H 在等 L 释放但 L 根本运行不了因为 CPU 被中优先级的 M 占着。M 的优先级比 L 高L 永远抢不过 M。于是 H 就被 M “间接”堵死了尽管 M 和信号量毫无关系。这个场景里最高优先级的任务 H实测响应时间反而被一个中优先级任务拖住这就是经典的优先级翻转。5.2 优先级继承到底是怎么解决问题的优先级继承Priority Inheritance的思路很直接当一个低优先级任务持有了信号量而高优先级任务正在等这个信号量时系统临时把低优先级任务的优先级抬高到和高优先级任务一样使它能够尽快运行并释放信号量。在上面的例子里当 H 阻塞在信号量上时内核会把 L 的优先级临时提升到和 H 一样高。这样 M 就抢不过 L 了L 可以顺利跑完打印并释放信号量H 得以继续执行。信号量释放后L 的优先级恢复为原来的低优先级。这个机制核心价值在于它只解决“无界优先级翻转”的问题让阻塞时间限定在一个可以预测的范围内并没有根治“翻转”现象本身只能说是有界翻转。真正的绝对优先级调度在存在共享互斥资源时做不到完全不翻转这是学术上也证明过的结论。实际工程中好的做法是“优先级继承 尽量避免长临界区 合理划分任务优先级”。5.3 自研内核里怎么实现优先级继承简化版本在自己写的极小内核里不用一上来就搞非常复杂的优先级继承。我建议你先把最朴素的二值信号量跑通然后按下面这个步骤加上继承能力第一步给 TCB 增加三个字段uint32_t basePriority; /* 原始优先级 */ uint32_t priority; /* 当前优先级可能被临时提升 */ Semaphore_t *pMutex; /* 当前持有的互斥量 */第二步在SemTake拿不到信号量、即将阻塞前检查这个信号量是否被标记为互斥量带有持有者。如果是就遍历等待队列找到优先级最高的等待者把持有者的priority提升为这个高优先级并记录它是因为哪个锁被提升的。第三步在SemGive释放互斥量后把持有者的priority恢复为basePriority。恢复时要注意一种情况一个任务可能同时持有多个互斥量所以要检查并表把等待者中优先级最高的那个设为当前优先级。这个简化实现比 FreeRTOS 的完整版本粗糙但核心思想完全一致。你写完以后再去读 FreeRTOS 的xQueuePriorityInherit函数会发现思路完全接得上。5.4 优先级继承的代价什么时候不建议用优先级继承不是免费的午餐。它每触发一次都要遍历等待链表和持有者信息这部分开销在实时性敏感的系统中需要仔细评估。而且它会让任务的调度顺序变得不那么“纯粹”低优先级任务可能因为“被继承”而突然变成高优先级处理不当甚至会造成新的优先级反转链。还有一点如果你用信号量做“同步事件”而不是“资源互斥”就不要加优先级继承。因为同步场景里信号量没有固定的持有者加持有者字段就是画蛇添足。所以 FreeRTOS 的做法是互斥量单独一种内核对象和二值信号量区分开各有各的语义。这个设计哲学非常值得学习建议自研时也这么做。6. 调试信号量时我踩过的坑6.1 死锁的经典姿势我第一个信号量驱动的 Demo 跑通后激动得不行马上扩展成三个任务互发消息结果第二天就出现系统挂死。现象是任务都不跑了串口没有输出调试器暂停后一切任务停在SemTake里。我检查了半天最后发现是死锁任务 A 持有信号量 S1等信号量 S2任务 B 持有 S2等信号量 S1。谁都不让谁谁也没法继续跑。排查这类问题最直接的方法是在任务阻塞的日志里加入“等待的资源 ID”。我的内核里给每个信号量加了名字字段调试时用printf在阻塞前后打印信息。死锁一出现立刻就能看到两个任务卡在互相等对方资源的状态。另外在早期调试时建议加一个看门狗定时器。如果任务超过一定时间没有被打断就导出当前所有任务的状态这一招在调试死锁时极其好用。6.2 别忘了关中断时不能调用阻塞 API这个坑信号量实现中容易遇到几乎每个新手都会踩在临界区里调用了SemTake而信号量又不可用于是当前任务被挂起但中断却被关着。整个系统直接卡死。要清楚一个铁律凡是可能导致阻塞的 API都不允许在临界区里调用。SemGive可以在临界区里调用它不会阻塞但SemTake不行除非你能确认信号量一定可用。搞清楚哪些 API 能关中断调用哪些不能比 API 本身的签名更重要。6.3 等待队列顺序导致的饥饿问题当多个任务等待同一个信号量时如果使用 FIFO 顺序唤醒低优先级任务可能频繁抢占高优先级任务的唤醒机会。比如两个高优先级任务轮流等待低优先级任务排在队列后面可能长时间得不到信号量。这就是饥饿。解决策略有几个按优先级排序唤醒或者使用优先级继承配合优先级顺序唤醒再或者在业务层用轮询调度保证每个任务都有机会执行。自研内核早期建议先用 FIFO因为它简单、可预测调试起来不玄学等到整个系统稳定了再优化成优先级顺序唤醒。6.4 中断里调用 SemGive 的注意细节信号量最常见的用途之一就是中断与任务之间的同步外设触发中断中断服务函数里释放一个信号量通知任务去读取数据。这个模式正确但实现时有几个细节不能忽略。第一中断服务函数里调用的SemGive应该是“从中断环境释放”的特殊版本通常叫SemGiveFromISR。它不会触发任务切换本身只修改信号量和就绪队列然后通过一个参数告诉中断退出时是否需要执行任务切换。第二不要在中断服务函数里调用普通版本的SemGive因为普通版本可能会在“关中断”状态下操作而中断执行期间本来就已经处于硬中断上下文了情况会变得非常微妙。我当时就因为这个原因导致中断返回后任务切换异常代码看起来完全没有逻辑问题但实际上隐藏了上下文切换顺序的缺陷。7. 扩展思考信号量往哪走7.1 下一步互斥量、消息队列、事件组信号量是内核对象的地基。地基打好了后面很多组件都是水到渠成的事。互斥量在信号量的基础上加上所有权和优先级继承消息队列则更进一步不只是同步还要搬运数据事件组一次可以等待多个标志位。它们的核心底层仍然是任务阻塞、就绪队列、切换这一套东西。我个人建议的下一个自研组件是消息队列因为它能解决很多信号量搞不定的数据传输问题比如传感器数据送显存、网络协议栈收包给应用层这些用信号量只能传递“有没有新数据”这个信息但数据本身呢还得靠共享内存 信号量来拼非常麻烦。消息队列一次搞定。7.2 商业 RTOS 里信号量的“真身”是什么你可能会好奇FreeRTOS 的信号量 API 是怎么实现的答案是它底层复用了一个叫“队列”的内核对象。二值信号量就是长度为 1 且初始为满的队列计数信号量就是长度为 N 且初始为满的队列。这种设计的工程意义很大一套队列代码同时支撑了消息传递、互斥锁、事件通知三种场景代码复用率高、内存占用少、维护成本低。等你把信号量手写完再去看 FreeRTOS 的 queue.c会彻底看懂它为什么要绕这么一层。7.3 给自己的内核加一个“信号量诊断命令”最后分享一个提升幸福感的技巧往调试串口里加一个命令比如输入sem就打印当前所有信号量的状态。这是我被优先级翻转坑了整整一天之后加的功能。void SemDump(void) { Semaphore_t *pSem; // 遍历所有信号量 // 打印 count、等待任务列表 }命令输出长这样Semaphore UartSem count: 0 waiter: TaskA(prio 3) Semaphore EventSensor count: 0 waiter: TaskC(prio 2)这个功能当时救了我的命帮我一分钟定位出任务 A 卡在等串口信号量、而持有者是低优先级任务导致的问题。它也能直观看到信号量是否被频繁竞争、等待时间是否异常。强烈建议你在自研内核里加一个。8. 写在最后的个人体会信号量看着很基础但真正动手写完、调试完才算摸着 RTOS 的一点边。这个过程中的感受如果用一句话说那就是代码逻辑本身并不难难的是让它在各种时序组合下都不出错。我写的信号量不到 100 行代码但为了找那个ExitCritical和Schedule的顺序问题整整折腾了一个晚上。这种教训只有亲手踩过才会在潜意识里留下印记。接下来给自己的小内核定一个目标加上软件定时器和消息队列然后试着把一个真实的小应用从裸机方案移植到我的自研 RTOS 上比较一下前后台系统和 RTOS 的编程模型差异。这个过程会继续以博客的形式记录下来如果你也在手搓操作系统欢迎一起交流踩过的坑互相分享比自己死磕有效率得多。