
上一篇我们把内核调度器跑通了任务能排队切换点灯大师满心欢喜。结果灯一多就出问题两个任务同时操作一个LED总有一个控制信号被覆盖甚至从寄存器层面打架。这就是典型的多任务并发访问共享资源。今天这一篇我们来实现RTOS里最基础也最常用的进程间通信原语——信号量用它搞定任务同步和资源共享这两件大事。这篇文章是“从手搓操作系统开始”系列的第8篇不依赖具体平台用C语言配合关中断操作实现一套可运行的内核信号量机制同时回答FreeRTOS信号量常见面试问题适合正在学RTOS的嵌入式开发者、面试准备者以及想彻底搞懂内核原语的人。1. 信号量到底解决了什么问题1.1 从点灯大师遇到的新麻烦讲起前面几篇我们把任务栈、任务控制块、调度器、时间片轮转都搭起来了点灯大师的灯确实可以轮流闪了。但某天他想实现一个功能按键按一下LED1亮再按一下LED1灭同时LED2亮。于是写了一个按键扫描任务和一个LED控制任务两个任务都要访问同一个引脚寄存器。结果发现LED状态完全随机甚至按一下跳两下。原因不复杂两个任务在“读-改-写”同一个GPIO寄存器时被打断了。比如任务A读到的寄存器值是0x01准备改成0x03刚读完还没写任务B抢走CPU把寄存器改成了0x02。等任务A恢复后它用自己之前读到的旧值去写就把任务B的修改覆盖了。这种问题靠调度器无法解决因为调度器只管“谁该运行”不管“谁在用什么资源”。解决办法有很多种最经典的就是信号量。信号量像一个“资源凭证”你要用某块资源之前先拿凭证用完了再还回去。如果凭证被拿走其他任务就必须排队等。这一等一让就保证了同一时刻只有一个人能碰共享资源。1.2 信号量只干两件事同步和互斥很多人分不清信号量到底是干嘛的其实它就干两件事。第一件事是任务同步任务A要等任务B完成某件事才能继续比如A等待B把DMA搬运完成再处理数据。这时候信号量初值设为0B完成后释放信号量A获取信号量后就继续跑。第二件事是资源共享多个任务要访问同一个外设或同一块内存信号量初值设为资源个数任务使用前获取、使用后释放。这三种形态最常见。二值信号量计数只有0或1适合做同步和简单互斥计数信号量可以初始化为N适合管理N个相同资源互斥信号量初始值为1但多了一个特性支持优先级继承适合保护共享外设和内存。很多人把二值信号量和互斥信号量混着用实际在正规RTOS里两者是有区别的我们后面在实现中会讲到。注意信号量不是“锁”它是一种更通用的同步原语。锁要求“谁拿谁放”信号量不强制二值信号量可以由A任务释放给B任务获取这个是互斥信号量做不到的。2. 信号量的数据结构与内核接口设计2.1 信号量控制块长什么样手写RTOS第一步是定义数据结构。信号量控制块必须包含三样东西类型、当前计数量、等待队列。如果做互斥信号量还要记录持有者任务和继承前的优先级。我参照轻量级RTOS的常见设计给出如下定义typedef enum { SEM_BINARY 0, /* 二值信号量 */ SEM_COUNTING, /* 计数信号量 */ SEM_MUTEX /* 互斥信号量 */ } sem_type_t; typedef struct semaphore { uint8_t type; /* 信号量类型 */ uint16_t count; /* 当前可用资源数量 */ tcb_t *wait_head; /* 等待队列头 */ tcb_t *wait_tail; /* 等待队列尾 */ tcb_t *owner; /* 互斥信号量当前持有任务的TCB */ uint8_t owner_prio; /* 互斥信号量持有者原本的优先级 */ } sem_t;wait_head和wait_tail组成一个先进先出的等待队列。任务阻塞时挂在队尾唤醒时从队头取。FIFO的好处是公平不会出现某个任务长期抢不到信号量。count字段是核心它告诉内核当前还有多少资源可以拿。信号量类型为什么要在控制块里区分因为互斥信号量的behavior不同释放时必须由持有者释放等待时可能要触发优先级继承。如果不区分类型所有信号量都做成计数信号量互斥场景也能跑但会踩优先级反转的坑这个后面专门讲。2.2 任务控制块需要支持的字段实现信号量之前TCB必须要能表示“阻塞”状态。之前的TCB比较简单只有栈指针、优先级、就绪链表指针。现在需要扩展typedef struct tcb { uint32_t *sp; /* 任务栈指针 */ uint8_t prio; /* 优先级数字越小优先级越高 */ struct tcb *ready_next; /* 就绪队列链表节点 */ struct tcb *sem_wait_next; /* 挂到信号量等待队列的节点 */ sem_t *sem_wait; /* 当前等待的信号量NULL表示没有等待 */ uint8_t state; /* 任务状态 */ uint32_t timeout_remain; /* 阻塞剩余时间单位Tick */ } tcb_t;sem_wait_next和sem_wait这两个字段很重要。sem_wait_next用来把任务挂进信号量的等待队列当信号量释放时内核从等待队列取下这个任务重新放回就绪队列。sem_wait则记录“我正在等哪个信号量”超时处理时需要靠它判断是否真的获取到了信号量。任务状态中新增一个TASK_BLOCKED状态。任务因为获取不到信号量进入TASK_BLOCKED之后调度器就不应该再把它当作就绪任务调度了。所以调度器的调度函数要跳过所有非TASK_READY状态的任务。2.3 内核为什么用关中断保护信号量操作信号量属于内核全局数据结构所有任务都可能操作它。单核MCU上最常见的保护办法就是进入临界区也就是关中断。在Cortex-M上一般通过PRIMASK或者BASEPRI寄存器实现。我简化实现了两个宏#define CPU_ENTER_CRITICAL() uint32_t sr __get_PRIMASK(); __set_PRIMASK(1) #define CPU_EXIT_CRITICAL(sr) __set_PRIMASK(sr)注意把关中断前的中断状态保存下来退出时恢复而不是直接开中断。否则如果调用sem_take时本来就处于关中断状态退出后就会错误地开中断导致中断嵌套出问题。我见过很多初学者在这里翻车函数里一退出就__enable_irq()结果整个系统的中断逻辑被打乱。为什么用关中断而不是用另一个标志位实现“忙等锁”因为如果还在等一个锁而持有锁的任务优先级更低、它压根没机会运行那就是死锁。在单核MCU上多任务“并发”本质上是“时分复用”真正可能打断代码执行的是中断。只要把中断关掉当前任务就独占CPU任何内核数据操作都不会被打断。2.4 信号量操作里不能直接调度还有一个细节经验在拿到或释放信号量时任务状态会变化这就涉及调度时机。调度函数在临界区内部调用是很忌讳的因为schedule会切换栈一旦切换出去当前栈里的临界区状态就悬空了。所以我的约定是临界区里只操作链表和数据修改完状态后退出临界区然后再调用schedule。这样处理后调度器总是以“一个安静的中断状态”去切换任务不容易出问题。你在读FreeRTOS源码时也会发现它对调度器挂起和临界区嵌套有严格的层级管理本质就是为了保证调度动作在安全环境下执行。3. 信号量核心函数的实现3.1 创建并初始化信号量创建信号量本质是把控制块里的字段设置好并把等待队列清空。这里强烈建议提供一个初始化接口由调用者在静态数组或结构体中传入一个sem_t对象uint8_t sem_init(sem_t *sem, uint8_t type, uint16_t init_count) { if (sem NULL) return SEM_ERR_PARAM; uint32_t sr CPU_ENTER_CRITICAL(); sem-type type; sem-count init_count; sem-wait_head NULL; sem-wait_tail NULL; sem-owner NULL; sem-owner_prio 0; CPU_EXIT_CRITICAL(sr); return SEM_OK; }初始化过程很短但也要关中断。否则如果在一个正在运行的任务里动态创建信号量另一个中断可能恰好进来访问同一个sem_t结构产生竞态。实际产品中信号量一般在系统启动阶段创建那时候还没开始调度不关中断问题也不大但养成习惯好一点。init_count怎么定如果做同步初值填0做互斥初值填1管理缓冲区或相同资源池填资源数量。二值信号量在实现上可以用计数信号量模拟count初值为0或1但在give时如果count已经等于1二值信号量应该保持1而不是变成2所以需要加类型判断。3.2 获取信号量 sem_take获取信号量的核心逻辑先看count是否大于0大于0说明资源可用直接把count减1。注意互斥信号量要记录owner为当前任务。如果资源不可用就把当前任务从就绪链表摘下来挂到信号量等待队列然后切换到其他任务。代码可以这样写uint8_t sem_take(sem_t *sem, uint32_t timeout_ms) { uint32_t sr CPU_ENTER_CRITICAL(); if (sem-count 0) { sem-count--; if (sem-type SEM_MUTEX) { sem-owner current_tcb; } CPU_EXIT_CRITICAL(sr); return SEM_OK; } /* 资源不可用把当前任务挂入等待队列 */ current_tcb-state TASK_BLOCKED; current_tcb-sem_wait sem; current_tcb-timeout_remain timeout_ms; if (sem-type SEM_MUTEX sem-owner ! NULL current_tcb-prio sem-owner-prio) { /* 低优先级持有者提升优先级可放在优先级继承小节专门讲 */ inherit_priority(sem, current_tcb); } list_add_tail(sem-wait_head, sem-wait_tail, current_tcb); CPU_EXIT_CRITICAL(sr); schedule(); /* 被唤醒后检查是否真的拿到了信号量 */ if (current_tcb-sem_wait ! NULL) { remove_from_wait_queue(current_tcb); return SEM_TIMEOUT; } return SEM_OK; }注意最后判断sem_wait是否为NULL的写法。正常被sem_give唤醒的任务sem_wait会被置为NULL表示“我拿到信号量了”超时被系统唤醒的任务sem_wait还没来得及被清掉仍然指向原信号量所以通过这个字段可以直接区分超时和正常获取。wait_timeout该怎么处理我在tick中断里会把所有TASK_BLOCKED任务的timeout_remain减1减到0就把它从等待队列移除并加入就绪队列同时保持它的sem_wait不为NULL。这样任务恢复运行后一检查就知道自己超时了。这个遍历方法的复杂度是O(N)真实RTOS会用定时器链表优化但教学内核用遍历最直观。3.3 释放信号量 sem_give释放时优先唤醒等待队列里的任务而不是简单地把count加1。因为如果等待队列里有人说明有任务在等着用这个信号量直接加count会把任务留在等待状态违反FIFO公平性。唤醒等待任务时让任务重新进入就绪队列并把它的sem_wait置为NULL。uint8_t sem_give(sem_t *sem) { uint32_t sr CPU_ENTER_CRITICAL(); tcb_t *task sem-wait_head; if (task ! NULL) { /* 有任务在等直接把信号量交给队首任务 */ list_remove_head(sem-wait_head, sem-wait_tail); task-state TASK_READY; task-sem_wait NULL; task-timeout_remain 0; add_ready_task(task); if (sem-type SEM_MUTEX) { sem-owner task; restore_priority(sem); /* 恢复持有者原优先级 */ } } else { sem-count; if (sem-type SEM_MUTEX) { sem-owner NULL; } } CPU_EXIT_CRITICAL(sr); schedule(); return SEM_OK; }代码不长但逻辑要仔细体会信号量的释放者不一定能直接“把count加回来”。如果等待队列里有任务信号量的资源会直接交给等待者count保持不变。只有等待队列为空计数值才增加。这样做不仅公平也让信号量的状态更准确count永远表示“当前可用的资源数量”而不是“历史释放次数”。对于互斥信号量sem_give还要加上所有者和优先级恢复判断。如果当前任务不是owner就说明调用者非法不是一个合格的互斥信号量使用者。很多教学代码在这里不检查留下了隐患。我习惯在断言里加入“互斥信号量只能由持有者释放”的检查调试期能省大量时间。3.4 为什么获取不到信号量时先挂起再切换有朋友问sem_take里资源不可用直接把当前任务挂起然后调用schedule那是不是和延时阻塞很像对本质上都是把任务从就绪队列移到等待队列再触发一次调度。区别在于阻塞原因不同延时阻塞是“暂时不参与竞争”信号量阻塞是“等另一个任务给资源”。调度器不需要知道具体原因它只关心状态。所以内核的schedule函数必须具备“从就绪队列里选一个TASK_READY任务切换到它”的能力。如果一个系统里所有任务都因为信号量阻塞了没有就绪任务调度器应该选择一个空闲任务或者直接进入低功耗模式。教学内核可以定义一个idle_task保证系统有东西跑不至于翻车。4. 进阶机制互斥信号量与优先级反转4.1 优先级反转到底怎么发生只做二值信号量的话互斥访问也能跑通但会有一个经典问题高优先级任务H要获取一个共享资源的信号量这个资源正被低优先级任务L持有。中间还有个中优先级任务MM不碰共享资源但M也在就绪队列里。H等L释放信号量但L被M抢占M执行完之前L根本得不到CPUH只能一直等。结果高优先级任务被中优先级任务活活拖死这就是优先级反转。正点原子RTOS教程里讲任务调度时很喜欢举这个例子。面试时面试官也会问“为什么有了二值信号量还需要互斥信号量”答案就是互斥信号量带优先级继承机制。所以我们在实现互斥信号量时必须把优先级继承做进去否则它和二值信号量没有任何区别。真实场景中一个L持有锁H来获取锁L应该临时把自己优先级提到H的优先级。这样M虽然优先级比原L高但比提升后的L低M就抢不过LL可以一口气跑到释放锁。锁释放后L恢复原来优先级M再来跑H也能更快获得锁。4.2 优先级继承的简化实现优先级继承逻辑看起来简单代码细节容易绕。我们要改的核心是两个点。第一在sem_take失败时如果当前信号量是互斥信号量且当前任务的优先级高于owner的优先级则提升owner的优先级到当前任务的优先级。第二在sem_give释放时将owner的优先级恢复为owner_prio记录的原优先级。简化实现可以这样写void inherit_priority(sem_t *sem, tcb_t *wait_task) { if (sem-type ! SEM_MUTEX || sem-owner NULL) { return; } if (sem-owner-prio wait_task-prio) { sem-owner_prio sem-owner-prio; sem-owner-prio wait_task-prio; /* 如果owner正在就绪队列需要按新优先级重新排列 */ update_ready_queue_priority(sem-owner); } } void restore_priority(sem_t *sem) { if (sem-type SEM_MUTEX sem-owner ! NULL sem-owner_prio ! 0) { sem-owner-prio sem-owner_prio; sem-owner_prio 0; update_ready_queue_priority(sem-owner); } }这段代码有个局限如果同一个低优先级任务同时被多个高优先级任务等待它可能被多次提升恢复时只恢复到第一次记录的原优先级可能不够精确。想要彻底解决可以在TCB里维护一个“被谁提升优先级”的链表。我们教学内核做简化处理但要在注释里写清楚局限免得用在实际代码里踩坑。另一个要注意的是提升优先级后如果owner任务处于就绪状态要把它从就绪队列中按原来优先级摘下来再按新优先级插入到正确位置。很多人忘了这一步导致优先级提升成空话。update_ready_queue_priority就是干这个的它需要知道TCB当前在哪个就绪链表里所以TCB里最好还保存一个ready_list_index字段方便摘除。4.3 死锁怎么避免信号量用多了还有一个经典问题死锁。任务A持有信号量S1等待S2任务B持有信号量S2等待S1。两个任务互相等谁也不释放系统直接卡死。面试题里经常出这种问你“RTOS死锁的条件是什么如何避免”。每个信号量至少要能回答四个问题初值是多少哪个任务创建哪个任务获取哪个任务释放。如果初值给错比如同步信号量初值填成1就会出现任务B还没执行到释放点任务A就以为事件发生了然后跑错逻辑。如果任务A获取了信号量但某条分支里忘了释放其他任务就会一直阻塞排查起来很痛苦。如果任务A在等待信号量的时候带超时任务状态就会在超时场景下有机会恢复。5.2 实验二计数信号量管理公共缓冲区第二个实验模拟生产者消费者模型这才是信号量真正发挥力量的地方。生产者任务每100ms往环形缓冲区写入一个数据消费者任务从缓冲区取数据并点亮LED。用两个计数信号量管理“空槽数量”和“可用数据数量”再配合一个互斥信号量保护环形缓冲区的读写指针。生产者流程获取空槽信号量获取缓冲区互斥锁写入数据释放互斥锁释放数据信号量。消费者完全反过来获取数据信号量获取互斥锁读出数据释放互斥锁释放空槽信号量。这样即使生产者和消费者执行速度不一致系统也不会出现缓冲区越界或数据覆盖。这个实验核心是理解“资源计数”的威力。空槽信号量初值为缓冲区大小数据信号量初值为0。生产一次空槽减1、数据加1消费一次数据减1、空槽加1。两个计数永远不会越界因为它们成对出现。跑起来后你会发现LED闪烁频率不是固定的它取决于生产者和消费者的速度差但缓冲区始终安全。5.3 常见问题排查速查表下面这些坑我基本都踩过整理成表格大家调试时可以对着看。现象可能原因排查方法任务永远阻塞在sem_take信号量初值错误或释放方没执行打印信号量count和等待队列长度系统调度卡死存在死锁任务互相等对方持有锁查看每个阻塞任务sem_wait指向谁共享数据偶发错乱临界区保护范围不够GPIO操作的一半在锁外面把读改写操作完整放入锁内高优先级任务被拖慢优先级反转检查信号量类型是否为MUTEX释放信号量后无任务运行忘记调用schedule或任务还在等待队列检查sem_give是否执行了唤醒逻辑中断里调用sem_take导致崩溃中断上下文不能阻塞中断里只用sem_give从ISR给任务发通知最后一条特别常见。在中断里调用获取信号量会让当前“中断上下文”尝试阻塞这是不可能的因为中断没有任务控制块。正确做法是中断里只释放信号量把处理逻辑放到任务里。很多RTOS会专门提供sem_give_from_isk这样的接口本质就是把从ISR触发的释放动作做得更安全避免在中断里调用调度器。5.4 调试技巧全靠串口打印和GPIO翻转最后分享几个调试经验。第一我在信号量控制块里加一个debug_count字段记录它被获取和释放的次数。一旦发现获取次数和释放次数对不上说明某个地方少释放了。这个方法简单有效比看波形直观得多。第二如果怀疑临界区关闭时间过长导致定时器不准可以用一个GPIO在临界区入口置高、出口置低然后用示波器量脉冲宽度。关中断时间要尽量短最好只操作链表指针几十个周期不要在里面做打印或大循环。第三查死锁时不要只盯代码把所有任务的状态和信号量的owner打印出来。如果发现A任务阻塞前等待的sem_wait指向S1S1的owner又是A任务自己那就是明显的重复获取互斥信号量导致自死锁。这种问题代码review时很难发现但打印一眼就能看出来。我自己的经验是信号量这套机制写出来并不难难的是各种边界场景。比如两个任务同时释放同一个互斥信号量比如任务在持有互斥信号量时被删除比如超时任务恰好和唤醒任务同时竞争队列节点。这些场景的稳定性只能靠反复跑压力测试来验证。在做完上面两个实验后建议你把实验二的生产者和消费者任务数量分别加到4个跑一晚上看有没有卡死。如果一晚上都没问题你的信号量实现基本就及格了。