裸机开发和 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 == 0(A 拿走时减掉的),B 阻塞。A 释放时执行count++,count 变成 1,然后检查等待队列,发现 B 存在,就把 B 唤醒。
问题来了:B 被唤醒后会继续执行SemTake的剩余逻辑,它需要把 count 减 1,变成 0。这中间如果 B 还没跑,C 任务也来 Take,count 是 1,C 直接把信号量拿走了。也就是说,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, ¤tTask->pendNode); ListRemove(¤tTask->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. 用信号量驱动串口:第一个不“踩踏”的 Demo
4.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 的编程模型差异。这个过程会继续以博客的形式记录下来,如果你也在手搓操作系统,欢迎一起交流,踩过的坑互相分享,比自己死磕有效率得多。