1. 为什么点灯到头了两个必须用信号量解决的模型坦白说手搓RTOS进行到第八篇你大概率已经实现了任务创建、任务切换、延时阻塞这一类基本功。这时候你会突然发现单靠延时和标志位已经没法优雅地解决多任务之间的复杂协作问题了。信号量这一章就是专门来填这个坑的。它在RTOS里的作用简单概括就八个字任务同步、资源共享。很多刚入门的同学对信号量有误解以为它只是上锁。其实信号量的本质是一个带等待队列的内核计数器。它的存在是为了给多个运行中的任务提供一套可阻塞、可唤醒、可超时的协作机制。点灯大师之所以要进阶是因为当任务数变成三个、五个灯的闪烁时序、按键响应、串口打印之间一旦发生资源冲突你光靠全局变量是无法收场的。1.1 场景一串口打印撞车——共享资源的互斥保护先看一个最直观的例子。两个任务任务A周期给某个传感器喂数据任务B周期打印日志两个任务都会调用同一个uart_send_string()。如果没有保护串口上就会混出半个句子Hello,Tem 然后被另一个任务的输出打断。原因是UART外设是共享资源两个任务几乎同时进入驱动的发送函数互相踩了对方的缓冲区。这就是典型的资源共享冲突。你当然可以说我在uart_send_string()里加一个全局标志位while (uart_busy); uart_busy 1; ... uart_busy 0;但问题在于如果任务A在发送中途被任务B抢占任务B也会来抢这个标志位。这个场景里uart_busy这个普通全局变量就完全失效了。普通变量没有办法在任务被抢占时让另一个任务进入等待状态而信号量可以——当任务B尝试获取被任务A持有的信号量时任务B会被挂起不再占用CPU直到任务A释放信号量后任务B才被唤醒并继续执行。1.2 场景二按键任务等事件——任务间的同步与通知再来看同步场景。系统里有一个按键扫描任务C和一个显示刷新任务D。D的任务是等C检测到按键按下事件后再刷新屏幕。两个任务没有共享外设但存在时序上的先后关系。用延时轮询的土办法也不是不行但CPU被白白浪费了——D任务哪怕没按键事件也得反复查询一个标志位。更麻烦的是如果D的查询周期和C的事件发生时间对不上就会漏掉事件。信号量最优雅的用法就体现在这里把信号量初值设置为0C任务负责sem_giveD任务负责sem_take。C没有按键事件时D阻塞在sem_take上不消耗CPUC检测到按键后调用sem_giveD立刻被唤醒去刷新屏幕。一给一取任务之间就像两根齿轮咬合住了一样。同一个信号量初始化值不同使用场景完全不同——初始化1是互斥锁初始化0是同步信号。1.3 信号量之前的几种土办法为什么都不行在引入正题之前我见过很多新手在自制RTOS阶段尝试用各种伪方案来代替信号量这里简单做一个对比能帮助你理解信号量到底解决了什么问题方案实现方式致命缺陷全局标志位修改和查询同一变量无法让任务阻塞忙等待耗CPU且违背原子性关闭中断实现临界区__disable_irq()包围关键段中断关闭时间过长严重影响实时性只能在极短临界区内使用调度器锁sched_lock阻止任务切换只能防止抢占不能实现等待事件语义信号量计数器 等待队列 阻塞唤醒无忙等支持超时语义清晰从上面的表可以看到前三种方案本质上都是回避矛盾只有信号量真正把资源不够时让任务睡觉、资源就绪时唤醒任务这两件事做成了体系化机制。这也是所有商用RTOS都会把信号量作为标配内核对象的原因。接下来我们就从内核源码角度看看一个信号量内部到底长什么样。2. 信号量的内功数据结构与等待队列的设计一切内核对象在C语言的视角下都是一个结构体。信号量也不例外。网上很多教程上来就贴代码但对结构体里每个成员为什么存在、它的生命周期覆盖了哪些场景讲得并不透。这里我按自己手写的简易RTOS里的实现来拆解。2.1 一个信号量到底存了什么先给出核心结构体定义typedef struct _sem_t { uint32_t count; /* 当前信号量计数 */ uint32_t max_count; /* 计数上限用于计数信号量 */ uint8_t type; /* 信号量类型BINARY / COUNTING / MUTEX */ list_t wait_list; /* 因获取该信号量而阻塞的任务队列 */ /* 以下为互斥信号量专用 */ task_t *owner; /* 当前持有互斥信号量的任务 */ uint32_t lock_count; /* 嵌套获取次数递归互斥用 */ uint32_t magic; /* 魔数用于检测结构体是否被非法篡改 */ } sem_t;每个成员都有明确职责count是信号量的核心状态。它表示当前还剩多少份资源或信号量是否可用。二值信号量时它只能是0或1计数信号量时可以到max_count互斥信号量时它只能是0或1且配合owner使用。wait_list是等待队列。当count 0且任务尝试获取信号量时任务节点会被挂到这个链表上进入阻塞态。type字段是必须的。因为二值、计数、互斥三类信号量的行为在give/take的边界条件上不一样用type区分可以避免写几份重复代码。owner和lock_count是为互斥信号量单独设计的服务于优先级继承和嵌套获取。magic字段可能有些人觉得多余但在内核调试阶段非常好用。你可以查看一个sem_t结构体内存被改坏时的现场magic值一旦对不上断言立刻触发。2.2 等待队列不同优先级任务应该怎么排队等待队列的设计是信号量实现中比较容易翻车的地方。最简单的做法是FIFO——谁先来等谁先被唤醒。但这对RTOS是不公平的因为高优先级的任务如果排在低优先级任务后面它要等低优先级任务先被唤醒这违背了RTOS高优先级先运行的基本原则。所以我这里采用的等待队列是按优先级排序的插入方法void sem_wait_list_insert(sem_t *sem, task_t *task) { list_t *pos; task_t *item; list_for_each(pos, sem-wait_list) { item list_entry(pos, task_t, wait_node); if (task-priority item-priority) { /* 插入到更高优先级的位置 */ list_insert_before(pos, task-wait_node); return; } } /* 没有比自己优先级高的插到队尾 */ list_add_tail(task-wait_node, sem-wait_list); }当然如果你的RTOS支持就绪队列按优先级查表调度那么等待队列通常也按同一套优先级机制来组织。保证释放信号量时第一个被唤醒的任务一定是当前等在这把锁上优先级最高的任务这是一个合格RTOS的基本素养。另外如果支持timeout你还会在任务控制块里维护一个单链表的超时队列这个我们放到第三节详细说。2.3 内核对象注册与统一的临界区保护信号量在使用前必须调用初始化函数。很多手写RTOS的教程里sem_init只是简单给结构体填零但我建议你多做一个动作把信号量注册到一个全局内核对象链表中。void sem_init(sem_t *sem, uint8_t type, uint32_t init_count, uint32_t max_count) { /* 参数校验 */ if (init_count max_count) return; sem-type type; sem-count init_count; sem-max_count max_count; sem-owner (task_t *)0; sem-lock_count 0; sem-magic SEM_MAGIC_NUMBER; list_init(sem-wait_list); /* 注册到内核对象链表便于调试遍历 */ kern_object_register((void *)sem, KERN_OBJ_SEM); }注册对象有什么好处当系统跑飞时你可以写一个kern_object_dump()函数把所有信号量的名字、当前计数、等待者数量一次性打印出来。这对定位多任务死锁简直是救命工具。内核对象注册表本身也要考虑临界区保护我通常使用关中断保护这个链表而不是调度器锁。因为内核对象注册可能发生在main函数阶段此时还根本没有开启调度。3. 两大核心操作的完整代码走查信号量的灵魂操作就是两个sem_take和sem_give。很多同学觉得这两个函数很简单无非是判断计数然后加加减减但真正写进去以后你会发现事情远不这么简单任务阻塞、阻塞态的上下文保存、唤醒后的恢复、超时的撤销、调度器切换时机——每一个细节都可能让系统跑得莫名其妙。3.1 sem_take拿不到信号量时任务是怎样睡过去的先看sem_take的实现逻辑。注意我这里以带超时参数的版本为例超时参数是区分永久等待和限时等待的关键。uint32_t sem_take(sem_t *sem, uint32_t timeout) { uint32_t status; task_t *current; /* 参数检查 */ if (sem-magic ! SEM_MAGIC_NUMBER) return SEM_ERR_PARAM; if (sem-type SEM_TYPE_MUTEX irq_context ! 0) return SEM_ERR_ISR; /* 互斥信号量不允许在中断里获取 */ current task_current(); /* 进入临界区保护信号量的count与wait_list不被多个任务同时操作 */ cpu_enter_critical(); if (sem-count 0) { /* 资源充足直接获取 */ sem-count--; if (sem-type SEM_TYPE_MUTEX) { sem-owner current; sem-lock_count 1; } cpu_exit_critical(); return SEM_OK; } /* 资源不足 */ if (timeout 0) { /* 非阻塞模式立即返回 */ cpu_exit_critical(); return SEM_ERR_TIMEOUT; } /* 需要阻塞把当前任务从就绪队列中摘除挂到等待队列 */ task_remove_from_ready(current); sem_wait_list_insert(sem, current); /* 设定超时 */ if (timeout ! WAIT_FOREVER) task_set_timeout(current, timeout); current-state TASK_STATE_BLOCKED; /* 退出临界区后触发调度执行其他任务 */ cpu_exit_critical(); schedule(); /* 从这里恢复继续执行时说明当前任务已经被sem_give唤醒或者超时被系统唤醒 */ if (current-wait_flag WAIT_FLAG_TIMEOUT) { /* 超时唤醒需要自己从等待队列里摘除 */ sem_wait_list_remove(sem, current); return SEM_ERR_TIMEOUT; } /* 正常被信号量唤醒count已经在give中被减掉直接返回成功 */ return SEM_OK; }请特别注意两个细节第一在count 0的分支里count--是立即执行的。这意味着唤醒等待者这件事交给sem_give去执行就行了唤醒者不再需要重新判断信号量资源。这可以避免一个经典的竞态两个任务同时被从等待队列中唤醒却发现信号量的count只剩1份另一个任务扑了空。第二schedule()是从临界区退出之后才调用的而不是在临界区内。因为schedule()会触发任务切换如果带着关中断状态切换任务新任务根本不知道中断已经被关闭了等到运行一段时间后再开中断实时性全毁了。关中断的范围要最小化只包住对共享数据结构的修改调度器的切换必须放在临界区外面。3.2 sem_give唤醒一个等待者背后的调度触发再来看sem_give。和take的对称性是内核设计的美感来源但在边界情况上它们完全不同void sem_give(sem_t *sem) { task_t *waiter; uint32_t need_schedule 0; if (sem-magic ! SEM_MAGIC_NUMBER) return; cpu_enter_critical(); if (sem-type SEM_TYPE_MUTEX) { /* 互斥量必须由持有者释放 */ if (sem-owner ! task_current()) { cpu_exit_critical(); return; } sem-lock_count--; if (sem-lock_count 0) { /* 递归获取还没完全释放不能真正释放信号量 */ cpu_exit_critical(); return; } sem-owner (task_t *)0; } /* 优先唤醒等待队列中排在最前面的任务 */ waiter sem_wait_list_first(sem); if (waiter ! (task_t *)0) { /* 将等待任务从等待队列摘下放回就绪队列 */ sem_wait_list_remove(sem, waiter); waiter-wait_flag WAIT_FLAG_OK; waiter-state TASK_STATE_READY; task_add_to_ready(waiter); /* 如果唤醒的任务优先级比当前任务高标记需要调度 */ if (waiter-priority task_current()-priority) need_schedule 1; } else { /* 没有任务在等待归还计数 */ if (sem-count sem-max_count) sem-count; } cpu_exit_critical(); if (need_schedule) schedule(); }这里有一个初学者非常容易写错的点give时如果等待队列有人count是不用做操作的。因为take侧的设计是唤醒时已经消费了资源如果你在give里既唤醒任务又增加count就会造成信号量多了一次释放的经典bug。这个问题在面试和实际代码review里经常出现。另外请注意互斥信号量的递归处理。lock_count在这里起到了很重要的作用。操作系统允许同一个任务多次获取同一个互斥信号量比如A函数获取了锁又调用B函数在B函数里再次获取同一个互斥量。如果没有递归计数B函数会发现互斥量被占用然后死锁。有了lock_count每次take成功count--且lock_count每次give只要lock_count还没归零就不真正释放信号量。这样同一个任务自己可以多次进入临界区但另一个任务想进来必须等到所有权完全归还。3.3 超时机制的实现如何精确地从睡梦中救回来超时是这个系统里最容易出bug的一环。我们在sem_take里通过task_set_timeout(current, timeout)设置了任务的超时时间点。这个机制的本质是系统维护一个按时间排序的超时链表每次systick中断时依次检查头部的任务节点。我常用的做法是在任务控制块里加两个字段uint32_t timeout_ticks; /* 剩余的等待节拍数 */ uint32_t wait_flag; /* 唤醒原因WAIT_FLAG_OK / WAIT_FLAG_TIMEOUT */systick中断处理函数里每来一次中断就遍历超时链表把所有timeout_ticks 0的任务唤醒并标记wait_flag WAIT_FLAG_TIMEOUT。关键问题来了一个任务可能同时挂在等待队列和超时队列上。当它被sem_give正常唤醒时它必须也从超时链表里移除。反之当它超时被唤醒时它必须从信号量的等待队列里移除。这两条路径只要漏掉一条就会出现残留在一个链表里的幽灵节点轻则内存泄漏重则导致链表指针错乱、系统崩溃。我这里给一个自检策略在任务被唤醒并恢复执行后立刻检查wait_flag如果发现是超时唤醒就在sem_take返回前调sem_wait_list_remove自行摘除。这种做法比在systick中断里同时动两个链表要安全得多——尽量减少中断处理函数里的操作复杂度这是嵌入式开发的基本工程素养。4. 三种信号的差异与互斥信号量的优先级继承前面我们已经多次提到二值信号量、计数信号量和互斥信号量。很多面试题喜欢问它们的区别我在这里用一张表把关键差异整理出来再深入说两个容易忽略的机制。4.1 二值、计数、互斥信号量的选型对照特性二值信号量计数信号量互斥信号量计数值范围0或10~N0或1主要用途互斥/同步资源计数管理互斥访问共享资源是否允许中断释放允许允许不允许优先级继承无无有是否要求持有者释放不要求不要求要求递归获取不支持不支持支持选型的原则很简单如果只是为了任务同步用二值信号量如果是为了保护共享资源且不允许优先级反转用互斥信号量如果是管理一组资源比如缓冲区池里还有几个空闲块用计数信号量。不要在保护共享资源时随意用二值信号量裸奔——它能锁住资源但没法规避高优先级任务被卡死的风险。4.2 优先级反转的经典现场与优先级继承的实现思路优先级反转是RTOS面试题里绕不开的概念。我用一个经典场景来还原任务L低优先级持有信号量S任务M中优先级不持有S但可运行且占CPU任务H高优先级试图获取信号量S。时间线是这样的任务H正在等S而S被任务L持有此时任务M被调度到因为L阻塞在等待某个条件或从休眠中被调度实际就是M的优先级高于L所以M抢占了L于是出现了一个极为反直觉的结果最高优先级任务H反而被中优先级任务M压制了。这就是优先级反转。在极端情况下M任务持续长时间运行H就一直等待。这在实时系统里是不可接受的。解决办法是优先级继承当高优先级任务H开始等待低优先级任务L持有的信号量时系统要把L的优先级临时提升到与H相同。这样L就能有机会从M手上抢回CPU尽快释放信号量S等L释放S后再把L的优先级恢复到原始值。在代码层面这主要发生在sem_take和sem_give里/* sem_take 中当资源不足且当前任务要阻塞时 */ if (sem-type SEM_TYPE_MUTEX sem-owner ! NULL) { task_t *owner sem-owner; if (current-priority owner-priority) { /* 当前任务优先级更高提升持有者的优先级 */ task_priority_change(owner, current-priority); owner-original_priority 获取持有者原本优先级; } }task_priority_change需要把任务从就绪队列中先摘下来修改优先级后按新优先级重新插入就绪队列。这套逻辑本身不复杂但它要求内核的优先级管理机制是完备的。如果就绪队列还是简单的链表扫描优先级提升的代价会非常高。4.3 递归互斥信号量同一个任务重复获取为什么不死锁递归互斥的实现在前面give的代码里已经展示了一半。现在重点说一下它为什么要有这样一个特性。想象你有一段带锁的驱动代码driver_lock()里面获取了互斥量mtx。随后它调用了内部的driver_write_reg()而这个函数里又为了保护寄存器操作再次获取了同一个mtx。如果没有递归机制第二次take会认为互斥量被别的任务持有当前任务直接阻塞——然后又没人来释放这个互斥量——系统原地死锁。递归互斥量的处理逻辑是take时如果当前任务已经是owner就不真正让count--只让lock_countgive时lock_count--只有减到0才真正释放所有权。所以递归互斥保护的不是多个任务互斥而是一个任务在多重调用下也能安全地持有锁。需要记得的是互斥信号量不能用在中断上下文里。因为中断上下文没有一个任务的概念owner无从谈起递归计数也没有意义。如果你在ISR里尝试获取互斥量轻则死锁重则破坏内核数据。正规的RTOS API里互斥量的take函数都会像我们代码里那样做一次irq_context检查。5. 中断环境下的信号量处理手写RTOS做到后面的阶段你一定会遇到一个问题中断服务程序里能不能释放信号量答案是可以但必须改用专用接口。5.1 为什么中断里释放信号量必须单独写一个函数原因有二。第一我们在give函数末尾可能会调用schedule()而中断里直接调用schedule()会带着中断栈帧进行任务切换这需要非常复杂的上下文处理保存嵌套中断现场、恢复任务现场时还要区分是从任务切换还是从中断切换。如果处理不当任务栈会被破坏。第二中断里释放信号量后唤醒的高优先级任务不能立刻抢占CPU因为CPU正在执行中断处理必须等中断退出后才能切换。所以中断里释放信号量要做两件事把该做的链表操作做了把需要调度这个需求记录下来而不是立刻调度。5.2 是否需要立即调度的标志位怎么传出来FreeRTOS里给出了一个很漂亮的设计portYIELD_FROM_ISR(pxHigherPriorityTaskWoken)。这个宏的底层是中断版本释放函数不直接调度而是通过一个指针参数把是否唤醒了一个更高优先级任务告诉调用者中断服务程序在退出前检查这个值决定是否触发PendSV进行调度。我们自己实现时也可以照搬这个思路做一个专门的原子版本void sem_give_from_isr(sem_t *sem, uint32_t *pxHigherPriorityTaskWoken) { task_t *waiter; /* ISR上下文里不需要关中断因为中断服务本身就会屏蔽同级和低优先级中断 */ waiter sem_wait_list_first(sem); if (waiter ! NULL) { sem_wait_list_remove(sem, waiter); waiter-wait_flag WAIT_FLAG_OK; waiter-state TASK_STATE_READY; task_add_to_ready(waiter); if (waiter-priority task_current()-priority) *pxHigherPriorityTaskWoken 1; } else { if (sem-count sem-max_count) sem-count; } }使用方在中断处理流程末尾写volatile uint32_t higher_task_woken 0; sem_give_from_isr(wake_sem, higher_task_woken); if (higher_task_woken) port_yield_from_isr();这个模式的核心思想是中断只修改内核数据结构和标记调度动作放到中断退出的瞬间执行。这样能保持中断响应时间短同时不错过任何需要调度的机会。5.3 正点原子/FreeRTOS中的GiveFromISR做法对比FreeRTOS的xSemaphoreGiveFromISR和我们上面的实现如出一辙它返回一个pdTRUE/pdFALSE表示是否需要任务切换然后调用portYIELD_FROM_ISR触发PendSV。正点原子移植的UCOSIII里则习惯用OSIntExit()来统一处理中断嵌套的调度切换逻辑。它们共通的思路就三条中断里的信号量操作不允许阻塞用标志位推迟调度而不是立刻调度中断上下文里不关中断——嵌套中断的存在本身就保证了当前ISR不会被同级抢占。我在自己的手写内核里irq_context计数器记录中断嵌套深度。sem_give_from_isr内部可以通过irq_context ! 0判断自己是不是真的运行在中断上下文中防止误用。6. 黑盒调试与踩坑实录最后这部分想聊聊调试方法。手写RTOS和直接用STM32裸机开发最大的区别是一旦多任务跑起来你没法用单步断点来轻松排查问题了。任务之间的时间关系让bug变得时好时坏。信号量相关的bug更是隐蔽很多时候它不触发崩溃只是一些任务偶尔卡住、跑得慢非常折磨人。6.1 信号量状态可视化很土但很有效的调试手段我在调试自制RTOS时会专门写一个dump_sem_status()函数把当前所有信号量的名字、类型、值、等待计数和第一个等待任务的优先级全部打印出来。然后在某个心跳任务里定时调用观察任务阻塞时的信号量状态变化。比如你怀疑两个任务在死锁直接看输出就明白了信号量S的值一直是0等待队列里一直有个优先级为3的任务而持有它的任务永远没有释放。再结合各个任务的状态打印你就能很快锁定是谁占着茅坑不拉屎。这一步比你在代码里加几百个printf都管用。6.2 一个典型的优先级反转bug复现与修复过程我在自己写的内核上就吃过这个亏。当时我偷懒互斥量没有实现优先级继承结果在两个任务都访问同一个外设时高优先级任务被卡了将近几毫秒才恢复。我在逻辑上怎么看代码都没问题take和give次数是对称的释放也没有遗漏。直到后来我把互斥量换成二值信号量问题依旧存在我才猛然意识到这是优先级反转。修复过程很简单就是补上了前面展示的那段task_priority_change(owner, current-priority)代码。修复后再观察高优先级任务的响应时间立刻恢复正常。这里要提醒一句别指望通过把所有任务的优先级都调高来规避反转问题RTOS里任务优先级的设计是有层次性的正确的解法永远是让内核提供优先级继承或优先级天花板机制。6.3 内核函数签名与断言早期发现问题的两个小技巧用到现在我强烈建议你在内核关键入口处都加assert。比如sem_take开头断言sem非空、magic正确、irq_context 0。这些断言看着烦人但在早期阶段可以帮你拦截90%的非法调用。我曾经遇到过一个问题一个任务在中断里错误调用了普通的sem_take导致中断栈被破坏系统随机死机。如果没有那条互斥量禁止在中断获取的断言这个问题可能要排查好几个晚上。另外一个体会是给sem_take和sem_give设计规范的返回值语义不要用1和0这种含义不明的数字。我用SEM_OK、SEM_ERR_TIMEOUT、SEM_ERR_ISR、SEM_ERR_PARAM这套枚举。调用者看到错误码就能快速定位问题这是整个调试流程里成本最低但收益最大的设计。信号量的实现到这里其实已经覆盖了商用RTOS里最核心的同步机制。如果后续你还要继续深入可以沿着消息队列、事件标志组、读写锁这几个方向去看它们很多底层都是复用信号量的那套等待队列和阻塞唤醒机制。把信号量的实现吃透其他的内核对象学起来就不会再觉得陌生了。