新闻详情

新闻详情

首页 / 资讯中心 / 详情

从零手写RTOS:实现信号量机制并解决优先级反转

发布时间:2026/9/8 8:26:27来源:尧图网络
从零手写RTOS:实现信号量机制并解决优先级反转
先交代一下背景。这个系列从第一篇文章开始我就在一点点手写自己的 RTOS从任务控制块到链表调度从 SysTick 到 PendSV前七篇把任务创建、切换、延时、空闲任务这些基础能力都搭完了。当时觉得内核最难的骨架已经完成剩下只是填肉。结果真正跑起来才发现让多个任务同时工作比让它们跑起来难得多。我遇到的第一个崩溃场景是这样的一个任务负责把 ADC 采集的数据通过串口打印另一个任务负责按键扫描遇到按键就打印一条状态。两个任务独立跑串口输出正常放到同一个系统里跑打印信息就开始互相穿插一行数据被切成两半。第二个场景更典型任务 A 做完外设初始化之后任务 B 才能开始采集否则读到的全是乱码。我一开始用全局标志位加延时轮询去凑代码写得又丑又不稳定。这就是为什么我们得把信号量实现出来。信号量是 RTOS 任务同步和资源共享的基石不管是 FreeRTOS、RT-Thread 还是 uC/OS面试题里最常问的那组问题——二值信号量、计数信号量、互斥信号量的区别以及优先级反转怎么解决——本质上都落在这个点上。这篇文章是系列第 8 篇目标很明确在我的手搓 RTOS 上实现一套完整的信号量机制包含二值信号量、计数信号量、互斥信号量并解决优先级反转问题。为了验证它真的能用我会用两个点灯任务演示同步效果再用三个任务复现经典的优先级反转场景。代码不会很长但每一步设计为什么这样做我会尽量讲清楚。1. 红灯、绿灯、串口之争教程走到第八篇调度器缺了什么1.1 任务同步场景一个事件有人发布、有人等待先看第一个刚需场景。假设一个系统里有两个任务一个负责读取温度传感器一个负责在 OLED 上显示温度。显示任务不能一开机就去读温度值因为传感器初始化和第一次转换需要时间。如果显示任务一开始就执行读到的可能是上电默认值甚至乱码。裸机时代的做法是 main 函数里先做延时等传感器稳定后再初始化显示。但在 RTOS 里两个任务是平级调度的显示任务可能在传感器还没准备好时就抢先执行了。更别扭的是传感器任务什么时候准备好显示任务根本不知道。如果只用全局标志位volatile uint8_t sensor_ready 0; void task_sensor(void *arg) { // 初始化传感器耗时几十ms sensor_init(); sensor_ready 1; } void task_display(void *arg) { while (1) { if (sensor_ready) { display_temp(read_temp()); } delay_ms(100); } }这种写法能工作但是很粗糙。首先是忙等显示任务每隔 100ms 才轮询一次标志位传感器可能在 30ms 就绪了显示任务要白等 70ms。其次是标志位被多个地方修改后容易出现漏判和重复触发。真正要表达的逻辑是传感器任务“发布”了一个事件显示任务“等待”这个事件。信号量的 take 和 give 就是为这个语义设计的。1.2 资源共享场景车同一条路必须排队第二个场景就是串口打印。串口是一个典型的不支持并发访问的外设。两个任务同时往串口写数据最后在串口助手里看到的就是乱码交错在一起。LCD、SPI Flash、共享内存缓冲区都属于这一类资源。RTOS 解决这个问题的思路不是让两个任务别同时跑那是裸机的思路——单核本来同一时刻就只能跑一个任务。问题在于调度器可能在一个任务写到一半时被时钟中断打断然后切到另一个任务去写串口。换句话说任务之间互相“插队”了。资源共享需要一个机制保证一旦某个任务拿到了资源的访问权其他任务必须等它用完释放后才能进入。这个机制的核心就是锁。信号量里的互斥信号量专门用来干这件事。所以你看任务同步和资源共享这两类问题本质都是“控制任务执行的时机”。同步是控制先后顺序互斥是控制同时访问。信号量用一个计数器加上一个等待队列把这两件事统一了。2. 数据结构先行我用一个OS_SEM_t同时装下三种信号量2.1 用type区分三种信号量而不是拆成三个结构体网上讲 RTOS 信号量通常会把二值信号量、计数信号量、互斥信号量分开讲。但在实现层面我的选择是用一个统一的结构体通过type字段区分行为差异。typedef enum { SEM_TYPE_BINARY 0, SEM_TYPE_COUNTING, SEM_TYPE_MUTEX } OS_SemType_t; typedef struct os_sem { OS_SemType_t type; // 信号量类型 uint32_t count; // 当前计数值 uint32_t max_count; // 计数上限 OS_WaitList_t wait_list; // 等待该信号量的任务队列 TCB_t *owner; // 当前持有者仅互斥信号量有效 uint32_t owner_base_prio; // 持有者的原始优先级用于优先级继承恢复 } OS_SEM_t;为什么不拆成三个独立结构体因为三种信号量的核心组件是相同的一个计数器一个等待队列。二值信号量的 count 只能取 0 或 1计数信号量的 count 可以在 0 到 max_count 之间互斥信号量的 count 也只能取 0 或 1但多了一个持有者信息和优先级继承机制。拆成三个结构体看似清晰实际使用的时候却很痛苦。应用层得维护三个不同的创建接口、三个不同的释放接口。而且内核调度器在处理唤醒逻辑时需要一套统一的操作方式——反正都是操作 count反正都是从等待队列里摘任务不同的只是边界条件和额外动作。用一个结构体加 type 字段内核代码可以大量复用。以 RTOS 面试题里常问的一个点为例二值信号量和互斥信号量都只有 0 和 1 两种状态为什么不能完全等效关键差别就在互斥信号量有owner和owner_base_prio它要承担优先级继承。如果设计结构体的时候没留这两个字段后面想做互斥信号量的优化就得推翻数据结果重新搭。2.2 count与max_count计数的边界决定了行为差异信号量的本质是一个计数器count表示当前可用的资源数量max_count表示最多能有多少个资源。每次 take 成功count 减一每次 give 成功count 加一。三种信号量的差异完全可以用这两个字段解释类型max_count典型初始 count使用场景二值信号量10 或 1事件同步、互斥无优先级继承计数信号量NN 或 0资源池管理、消息计数互斥信号量11资源共享带优先级继承二值信号量的 count 只有两种边界0 表示事件未发生1 表示事件已发生。它最常见的用法是初始 count0一个任务在里面 take 等待另一个任务或中断给它 give 触发事件。这就是事件同步的标准写法。计数信号量的价值在资源池场景。比如系统里有 4 个 DMA 通道初始化时 count4每个任务 take 成功后占用一个通道用完后 give 释放。这样内核能自动管理“最多只能有 4 个任务同时占用 DMA”这个约束不需要应用层自己用全局变量去数。互斥信号量初始 count1表示资源空闲谁拿到谁就持有锁。它的 max_count 必须是 1否则就会出现两个任务同时持有“互斥锁”的荒谬情况。我需要特别提醒一点give 操作一定要做边界判断如果 count 已经等于 max_countgive 不能继续往上涨。否则二值信号量 count 可能变成 2那它就不再是二值了下游的错误行为会非常难排查。2.3 等待队列挂在信号量上还是挂在任务上信号量实现里最容易搞混的是等待队列的归属问题。我的设计是等待队列挂在信号量结构体里每个信号量内部维持一个任务等待链表。但这里有个细节TCB 本身也需要增加几个字段来支撑等待队列机制typedef struct tcb { uint32_t *stack_ptr; struct tcb *next; // 就绪链表相关指针 struct tcb *delay_next; // 延时链表相关指针 uint32_t state; // TASK_READY / TASK_BLOCKED / TASK_DELAYED uint32_t base_priority; // 原始优先级 uint32_t current_priority; // 当前优先级优先级继承时会被提升 // 信号量等待队列相关 struct tcb *wait_next; struct tcb *wait_prev; OS_SEM_t *wait_sem; // 当前在等哪个信号量 uint32_t wait_timeout; // 剩余阻塞节拍数 } TCB_t;TCB 里放wait_next和wait_prev是链表驱动内核的常见思路。每个 TCB 的这两个指针加信号量内部的wait_list头尾指针就构成了一条双向链表。等待队列里挂的是任务但链表的入口在信号量那边。这样做的好处是任务从等待队列摘除时只需要把 TCB 的 wait 指针操作好任务本身的调度状态不受影响。还需要在 TCB 里记录wait_sem这个字段是给超时唤醒用的。后面我会专门讲信号量被 Give 唤醒和超时唤醒处理逻辑完全不同没有wait_sem字段就分不清到底是被谁唤醒的。3. Give/Take的代码走读从释放到等待的完整链路3.1 创建一个干净的初始化函数有了结构体先写创建函数。这里的逻辑比较直接把结构体字段全部初始化干净。uint32_t OS_SemCreate(OS_SEM_t *sem, OS_SemType_t type, uint32_t init_count, uint32_t max_count) { if (sem NULL) { return OS_ERR_PARAM; } if (max_count 0 || init_count max_count) { return OS_ERR_PARAM; } if (type SEM_TYPE_BINARY || type SEM_TYPE_MUTEX) { max_count 1; if (init_count 1) { init_count 1; } } sem-type type; sem-count init_count; sem-max_count max_count; sem-wait_list.head NULL; sem-wait_list.tail NULL; sem-owner NULL; sem-owner_base_prio 0; return OS_ERR_OK; }这个函数有几点设计考量。一是做了参数合法性校验。init_count不能大于max_count这是最基本的要求。二是二值信号量和互斥信号量的 max_count 被强行限制为 1从 API 层面杜绝了滥用可能。三是owner和owner_base_prio初始化为 0虽然普通二值、计数信号量用不上这两个字段但统一初始化能避免内核其他模块访问到脏数据。3.2 释放OS_SemGive内部发生了什么Give 操作是信号量使用频率最高的接口。它的核心逻辑是把 count 加一如果等待队列里有人就唤醒一个优先级最高的任务。但如果实现只是这么简单会有两个隐藏的坑互斥信号量必须由持有者释放二值信号量 count 不能超过 1。uint32_t OS_SemGive(OS_SEM_t *sem) { TCB_t *wake_task; if (sem NULL) { return OS_ERR_PARAM; } OS_ENTER_CRITICAL(); // 互斥信号量只能由持有者释放 if (sem-type SEM_TYPE_MUTEX sem-owner ! current_tcb) { OS_EXIT_CRITICAL(); return OS_ERR_MUTEX_OWNER; } // 计数值不能超过上限 if (sem-count sem-max_count) { sem-count; } // 从等待队列里唤醒最高优先级任务 if (sem-wait_list.head ! NULL) { wake_task WaitList_GetHighestPrio(sem); WaitList_Remove(sem, wake_task); wake_task-state TASK_READY; wake_task-wait_sem NULL; wake_task-wait_timeout 0; ReadyList_Insert(wake_task); } // 互斥信号量释放后恢复持有者优先级清空持有者信息 if (sem-type SEM_TYPE_MUTEX) { if (sem-owner ! NULL) { sem-owner-current_priority sem-owner-base_priority; } sem-owner NULL; sem-owner_base_prio 0; } OS_EXIT_CRITICAL(); OS_Sched(); return OS_ERR_OK; }先说互斥信号量“只能由持有者释放”这个限制。为什么这么严格因为互斥信号量的语义是锁锁的获取和释放必须成对出现。如果 A 任务拿锁B 任务也能释放那锁就失去意义了——B 释放锁之后A 还在临界区里跑另一个任务 C 又进来了共享资源还是会冲突。第二个重点是唤醒逻辑。Give 唤醒哪个任务默认做法是唤醒等待队列里优先级最高的。有些 RTOS 会唤醒等待时间最长的一个但绝大多数实时系统追求的是高优先级任务先运行所以我这里选择按优先级唤醒。第三个关键点是互斥信号量在释放时要恢复持有者的优先级。这是优先级继承的收尾动作。持有者在拿锁期间可能被提升过优先级释放锁后必须把 current_priority 恢复成 base_priority否则这个任务会永远以高优先级运行破坏整个系统的调度公平性。3.3 获取OS_SemTake的三个分支Take 的语义是“获取信号量”。如果 count 大于 0直接减一返回如果 count 等于 0任务进入阻塞状态等别人 Give如果调用者不想阻塞可以指定非阻塞模式或者超时时间。uint32_t OS_SemTake(OS_SEM_t *sem, uint32_t timeout) { if (sem NULL) { return OS_ERR_PARAM; } OS_ENTER_CRITICAL(); // 分支1资源可用直接获取 if (sem-count 0) { sem-count--; if (sem-type SEM_TYPE_MUTEX) { sem-owner current_tcb; sem-owner_base_prio current_tcb-base_priority; } OS_EXIT_CRITICAL(); return OS_ERR_OK; } // 分支2不可用但不想等超时参数为0表示非阻塞 if (timeout 0) { OS_EXIT_CRITICAL(); return OS_ERR_TIMEOUT; } // 分支3不可用且愿意等进入阻塞 current_tcb-state TASK_BLOCKED; current_tcb-wait_sem sem; current_tcb-wait_timeout timeout; // 0xFFFFFFFF 表示永久等待 ReadyList_Remove(current_tcb); WaitList_Append(sem, current_tcb); // 互斥信号量的优先级继承 if (sem-type SEM_TYPE_MUTEX sem-owner ! NULL) { TCB_t *waiter WaitList_GetHighestPrio(sem); if (waiter-current_priority sem-owner-current_priority) { sem-owner-current_priority waiter-current_priority; ReadyList_UpdatePriority(sem-owner); } } OS_EXIT_CRITICAL(); OS_Sched(); // 被唤醒后检查是被Give唤醒还是超时唤醒 if (current_tcb-wait_sem sem) { current_tcb-wait_sem NULL; current_tcb-wait_timeout 0; return OS_ERR_TIMEOUT; } return OS_ERR_OK; }分支 1 是最顺利的路径。count 大于 0 说明资源或事件可用直接使用。如果是互斥信号量要把owner指向当前任务并记录当前任务的原始优先级。这个信息在后面优先级继承恢复时会用到。分支 3 是最复杂的路径。任务进入阻塞前要把它从就绪链表里摘下来挂到信号量的等待队列上去。这两步顺序不能反必须先改状态为 BLOCKED再从就绪链表摘除最后挂到等待队列。为什么因为如果先把任务从就绪链表摘除然后调度器刚好在某个临界点检查就绪队列这个任务既不在就绪队列也不在等待队列就成了“丢失的任务”。这里还有个优先级继承动作我需要解释清楚。当一个高优先级任务发现锁被一个低优先级任务持有时高优先级任务会阻塞等待。但这个低优先级任务可能反过来被一个中优先级任务抢占导致高优先级任务被间接饿死。优先级继承的规则就是当有高优先级任务在等锁时把锁持有者的优先级临时提升到和最高等待者相同这样持有者能尽快运行完并释放锁。唤醒后的返回值判断也很有意思。Take 返回后wait_sem有两种可能如果被 Give 唤醒Give 里已经把wait_sem清空此时返回成功如果是超时被唤醒wait_sem还指向原来的信号量此时返回超时错误。这个机制靠的是一个约定——Give 负责清wait_sem超时处理不负责清由 Take 返回前自己检查。4. 把阻塞接进调度器任务状态、等待队列与调度点4.1 TCB状态机的第三类状态BLOCKED前面几篇教程里我们的任务状态只有 RUNNING、READY、DELAYED 三种。DELAYED 状态是任务调用延时函数后把自己挂到延时链表中由 SysTick 周期性扫描并唤醒。现在信号量引入了第四种状态BLOCKED。BLOCKED 和 DELAYED 在调度器看来有相似之处它们都不在就绪队列里都不能被调度运行。但它们的唤醒机制完全不同。DELAYED 任务只依赖时间到达BLOCKED 任务依赖的是某一事件——信号量被释放、队列有数据、互斥锁被解锁。调度器的就绪队列扫描逻辑需要跳过所有非 READY 状态的任务。这个改动很小但要格外小心。我之前就在这个位置吃过亏某个任务从信号量等待队列被唤醒之后状态改成了 READY但忘记把它插回就绪队列结果它消失在所有队列里整个系统的任务数莫名少了一个而且毫无规律地周期性“丢失”任务。4.2 被唤醒的任务怎么重新回到CPU被 Give 唤醒的任务从等待队列摘除后要执行三步状态改为 READY、wait_sem清空、插入就绪队列。这三步是原子操作必须在关中断的保护下完成。这里值得多说一句为什么 Wake 操作必须在临界区里做。因为等待队列是一个被多个任务共享的数据结构。一个任务正在被唤醒另一个任务可能正同时调用 Take 往这个队列后面追加自己。如果没有原子保护链表头尾指针会被两个任务交叉修改轻则丢失节点重则产生环导致死循环。在 Cortex-M 处理器上这个保护通过关闭中断实现。为什么要关中断而不是关调度因为调度器只能阻止其他任务的并发修改但不能阻止中断服务程序里的 Give 操作。如果 ISR 里调用了 Give而 Take 还没处理完链表同样会破坏数据结构。关中断是唯一的稳妥方案。被唤醒的任务插入就绪队列后要不要马上切换到它运行这取决于被唤醒任务的优先级和当前任务的优先级。如果被唤醒任务优先级更高Give 函数尾部的OS_Sched()会切换到它如果优先级更低它只是进就绪队列等待当前任务继续运行。这就是典型的“可剥夺内核”调度行为。4.3 中断里调用不调度GiveFromISR的变体中断服务程序里最常见的操作是收到一个串口数据调用 Give 释放信号量把数据处理任务唤醒。但中断上下文不能直接执行上下文切换。因为中断返回时有严格的压栈出栈流程如果在 ISR 里强制切换任务会打乱硬件的中断返回序列。标准的做法是把 Give 的内核修改逻辑单独抽出来中断版本的 Give 只修改内核数据结构最后设置一个g_sched_pending标志真正的调度器在中断退出时执行。void OS_SemGiveFromISR(OS_SEM_t *sem) { TCB_t *wake_task; OS_ENTER_CRITICAL(); if (sem-count sem-max_count) { sem-count; } if (sem-wait_list.head ! NULL) { wake_task WaitList_GetHighestPrio(sem); WaitList_Remove(sem, wake_task); wake_task-state TASK_READY; wake_task-wait_sem NULL; ReadyList_Insert(wake_task); } OS_EXIT_CRITICAL(); // 不直接调用OS_Sched只标记中断退出后需要调度 g_sched_pending 1; }注意中断版本的 Give 省掉了互斥信号量的 owner 检查。为什么互斥信号量不应该从 ISR 里释放——锁的获取者是任务锁的释放也必须是同一个任务。从 ISR 释放互斥锁是明显的设计错误而且几乎不可能通过这种调用完成合理的资源管理。内核里这个接口只服务于二值信号量和计数信号量的事件通知场景。中断里调用 Give 还有一个隐藏问题它可能唤醒一个优先级比当前被中断任务更高的任务。这本身没问题这正是中断唤醒高优先级任务的典型模式。关键是调度时机要放到中断退出之后让中断服务程序把当前中断彻底处理完再切换到那个高优先级任务。5. 优先级反转实验二值信号量与互斥信号量的分水岭5.1 一个三任务的复现实验如果把二值信号量当互斥锁用系统可以正常工作但隐藏着一个大坑优先级反转。面试官特别爱问这个但真正能把这个场景说清楚的人不多。这里我直接给出一个可复现的实验。假设三个任务任务 H优先级 2做最紧急的事情任务 M优先级 3做普通工作任务 L优先级 4操作共享外设共享外设用一个二值信号量保护初始 count1。执行序列是这样的任务 L 先运行拿到信号量进入临界区开始操作外设。任务 H 在等待外设数据一旦外设产生数据任务 H 就绪抢占 L。任务 H 进入临界区发现信号量被 L 持有于是阻塞进入等待队列。任务 M 一直在运行它的优先级比 L 高频繁抢占 L。结果是 L 迟迟得不到 CPU无法完成临界区无法释放信号量H 明明优先级最高却只能干等。用代码来描述这个场景void task_low(void *arg) { while (1) { OS_SemTake(bin_sem, OS_WAIT_FOREVER); // 模拟在临界区内做较长时间的操作 delay_ms(50); OS_SemGive(bin_sem); delay_ms(100); } } void task_mid(void *arg) { while (1) { // 中优先级任务持续运行 LED_Toggle(LED_GREEN); delay_ms(1); } } void task_high(void *arg) { while (1) { // 高优先级任务尝试获取锁 OS_SemTake(bin_sem, OS_WAIT_FOREVER); LED_Toggle(LED_BLUE); OS_SemGive(bin_sem); delay_ms(10); } }实测现象是绿色 LED 闪烁频率正常蓝色 LED 的闪烁周期变得很不规律。理论上 H 的优先级最高它应该每 10ms 就翻转一次蓝色 LED。但因为 L 的信号量迟迟拿不到H 只能跟着 L 的节奏走。用逻辑分析仪抓三个 GPIO 的电平变化能很清楚地看到任务 H 的脉冲宽度正常但脉冲之间的间隔会突然拉长到几十毫秒甚至上百毫秒。这就是典型的优先级反转。5.2 互斥信号量的优先级继承规则解决优先级反转的标准方案是优先级继承。这里要注意它叫“继承”不是“置顶”。规则是当一个高优先级任务因为拿不到锁而阻塞时锁的持有者临时把current_priority提升到那个等待任务的优先级。注意不是直接提升到系统最高优先级只是提升到等待者中最高那个的优先级。在我的实现里这个动作发生在OS_SemTake的阻塞分支尾部if (sem-type SEM_TYPE_MUTEX sem-owner ! NULL) { TCB_t *waiter WaitList_GetHighestPrio(sem); if (waiter-current_priority sem-owner-current_priority) { sem-owner-current_priority waiter-current_priority; ReadyList_UpdatePriority(sem-owner); } }判断条件waiter-current_priority sem-owner-current_priority成立说明等待者比当前持有者优先级更高这时候才需要提升持有者。释放锁时持有者恢复原始优先级。这个恢复动作我已经写在OS_SemGive里if (sem-type SEM_TYPE_MUTEX) { if (sem-owner ! NULL) { sem-owner-current_priority sem-owner-base_priority; } sem-owner NULL; sem-owner_base_prio 0; }把实验里的二值信号量换成互斥信号量重新跑同样的三任务场景蓝色 LED 的翻转周期会明显稳定下来。原因很好理解任务 L 拿到锁之后一旦有高优先级任务 H 来等锁L 的优先级被临时提升到和 H 一样高M 再也压不住 L。L 快速执行完临界区释放锁恢复原始优先级H 拿到锁整个链条顺畅了。实现上有几个细节要小心。第一如果等待队列里有多个优先级不同的任务继承的应该是最高优先级那个。第二如果锁被多级嵌套持有——任务 A 持有锁B 在等锁C 又在等 B 持有的另一把锁——优先级继承会沿着锁的持有链逐级传递。第三持有者可能有多个高优先级任务在等它继承的是最高的那个释放时不能简单恢复到 base_priority还得看释放后是否还有其他锁在等待链上。我的实现里简化为单锁场景owner 释放锁时直接恢复base_priority。对于大多数应用已经足够但如果你要做多锁嵌套建议给每个 TCB 加一个继承优先级计数器记录当前实际被提升了几级。5.3 继承不是银弹锁持有时间的反思优先级继承能缓解优先级反转但治标不治本。它只是让锁持有者能更快地跑完并不能消除反转的根本原因——一个高优先级任务在等一个低优先级任务执行完毕。真正良好的设计是尽量减少持锁时间。锁保护的区域应该尽量短只保护真正需要原子性操作的那几行代码不要把延时、打印这些耗时操作放到临界区里。我在实测中发现如果持锁期间有delay_ms()即使有优先级继承调度行为仍然会变得不可控因为延时期间即使持有者优先级被提升它也可能处于 DELAYED 状态无法运行。所以信号量实现完成之后我给自己定了一条规范所有临界区代码必须短凡是能放到锁外操作的绝不拿锁。6. 关中断、嵌套临界区与中断里的安全释放6.1 PRIMASK关中断为什么必须用硬件级别保护信号量操作涉及就绪队列、等待队列、TCB 状态等共享数据的修改这些操作必须是原子的。Cortex-M 上最直接的做法是操作 PRIMASK 寄存器关闭中断static uint32_t ulCriticalNesting; void OS_EnterCritical(void) { uint32_t primask __get_PRIMASK(); __disable_irq(); // 记录进入前的状态防止重复开中断 } void OS_ExitCritical(void) { // 恢复之前的中断状态 }但这里有个很常见的坑如果OS_EnterCritical和OS_ExitCritical是成对调用的但嵌套使用时不加以处理内层OS_ExitCritical就会提前把中断打开破坏了外层临界区的原子性。正确的方案有两种。第一种不用嵌套计数直接保存和恢复 PRIMASKuint32_t OS_EnterCritical(void) { uint32_t primask __get_PRIMASK(); __disable_irq(); return primask; } void OS_ExitCritical(uint32_t primask) { __set_PRIMASK(primask); }调用方必须保存 Enter 的返回值Exit 时传回去。这样最稳妥不会因为嵌套而提前打开中断。第二种是嵌套计数方案static uint32_t ulCriticalNesting; void OS_EnterCritical(void) { __disable_irq(); ulCriticalNesting; } void OS_ExitCritical(void) { if (ulCriticalNesting 0) { ulCriticalNesting--; } if (ulCriticalNesting 0) { __enable_irq(); } }嵌套计数方案在实现逻辑上更简洁但有前提Enter 和 Exit 必须严格成对不能从临界区中间 return 出去跳过了 Exit。我的代码里 Give/Take 都是在一个函数内完成 Enter 和 Exit 的用嵌套计数方案就很合适。但如果临界区里还有可能提前返回的分支比如我在 Give 里的 owner 检查提前 return 之前一定要记得先 Exit。钥匙必须成对锁的生命周期也必须是函数级别的这条经验来自血泪。刚开始实现时我图省事在临界区里写了一个 switch 判断错误码某个 case 提前 return 跳过了 Exit结果系统运行几分钟后出现随机卡死排查了很久才发现中断被关掉了没恢复。6.2 为什么不能只用关调度来代替关中断有人可能会想既然信号量操作主要防的是其他任务修改那我用一个调度器锁——禁止任务切换——不也能保护信号量吗理论上关调度确实能阻止其他任务的并发修改但阻止不了中断服务程序。ISR 可以在任何时刻插入执行而 ISR 里是可能调用OS_SemGiveFromISR的。如果信号量操作只关调度不关中断ISR 就可以在信号量内部数据结构被修改到一半时来插一脚把链表改坏。所以关中断是底线。哪怕只关一个短暂的窗口也必须保证这个窗口内没有中断能进来。关于关中断的时长要尽量短。18 条指令以内的临界区可以随便关100 条指令以上的临界区就要考虑对实时性的影响了。信号量操作一般都能控制在很短的时间内等待队列操作是线性扫描任务数量不多时可接受的。6.3 中断里的Give和任务里的Give有什么不同前面已经给了OS_SemGiveFromISR的实现。这里再补充一个使用层面的经验中断里调用 Give 时参数传的信号量不能是互斥信号量。这个约束不是编译器强制而是语义上的强制。中断里可以重获锁吗不能。中断返回后执行的是被打断的任务不是中断自己。如果互斥锁被释放中断上下文里没有“持有者”这个概念锁的 owner 无法被正确赋值。即便实现了也只能通过某种方式把锁转交给下一个抢占的任务这逻辑太绕没有任何价值。所以我的代码里OS_SemGiveFromISR只用于二值信号量和计数信号量的事件通知。这也是 FreeRTOS 里xSemaphoreGiveFromISR的语义。二值信号量在同步场景中本身就是为了让中断把“事件发生”这个信息传递给任务而设计的。7. 实测两个点灯任务验证同步和互斥附三个我踩过的坑7.1 同步点灯先上车后发车第一个实验验证同步。场景是一个按键任务负责检测按键一个 LED 任务负责翻转 LED。按键按下后LED 任务才执行一次翻转没有按键LED 任务就一直傻等。OS_SEM_t g_btn_sem; void task_btn(void *arg) { while (1) { if (btn_pressed()) { OS_SemGive(g_btn_sem); // 发布事件 while (btn_pressed()); // 等待释放简单消抖 } delay_ms(5); } } void task_led(void *arg) { while (1) { OS_SemTake(g_btn_sem, OS_WAIT_FOREVER); // 等待事件 LED_Toggle(LED_RED); } }g_btn_sem 初始 count0。任务 LED 先执行还是后执行都不影响结果它在 Take 处等。按键任务检测到按键后 Give 一次LED 任务被唤醒翻转一次。这个实验验证了事件同步的基本语义。我测量了一下按键响应延迟从按键中断触发到 LED 翻转的时间大约 20us 左右已经足够快。如果用裸机轮询标志位这个延迟可能到毫秒级因为你要等任务轮询周期到来。7.2 互斥点灯让串口打印不再乱码第二个实验验证互斥。两个任务都往串口打印一长串字符但如果打印之前先 Take 一个互斥信号量打印完再 Give输出就整齐了。OS_SEM_t g_uart_mutex; void task_print_high(void *arg) { while (1) { OS_SemTake(g_uart_mutex, OS_WAIT_FOREVER); printf(high: 0123456789\n); OS_SemGive(g_uart_mutex); delay_ms(50); } } void task_print_low(void *arg) { while (1) { OS_SemTake(g_uart_mutex, OS_WAIT_FOREVER); printf(low : abcdefghij\n); OS_SemGive(g_uart_mutex); delay_ms(50); } }g_uart_mutex 初始 count1用互斥信号量保护串口。运行一段时间后串口助手里没有出现交错的行每一行都完整打印。保护全局共享外设的语义得到了验证。7.3 我在调试中遇到的三个坑写在这里帮你排雷第一个坑信号量初始 count 设计反了。同步场景里我一开始把二值信号量初始为 1结果 LED 任务在任何按键之前就先执行了一次翻转。这个错误很常见同步信号量初始必须是 0表示“事件尚未发生”互斥信号量初始必须是 1表示“资源空闲可用”。两者语义完全不同写错的后果也完全不同。第二个坑任务从等待队列被唤醒后忘了挂回就绪队列。症状是 LED 任务永远不再运行但系统其余任务还在工作。排查时用调试器看 TCB 的 state发现 task_led 的状态确实是 READY但就绪队列里根本没有它。这就是“状态是 READY人不在就绪列表”的经典问题。修复就是在唤醒逻辑里补上ReadyList_Insert(wake_task)。第三个坑等待队列用单向链表删除中间节点时丢链。在某个场景里一个信号量的等待队列先后有 A、B、C 三个任务排队B 因为超时被先移出但 A 的wait_next没有正确刷新导致后面 Give 唤醒时链表遍历到空指针整个系统 HardFault。换成双向链表后问题消失这也是我在 TCB 里加wait_prev字段的原因。还有一个让我印象深刻的坑是在OS_SemTake的阻塞分支里忘记调用ReadyList_Remove。任务已经进入 BLOCKED 状态却还在就绪队列里调度器扫描就绪队列时把它当成正常的就绪任务来切换。结果系统以固定的节拍不停切换到同一个“阻塞”任务其他优先级更低的就绪任务永远得不到运行整个系统的任务调度看起来就像卡死了一样。这个错误的排查难度很大因为它不像链表断裂那样有明确的崩溃现场而是一种逻辑层面的长期错误行为。我的排查方法是在每个任务里加一个翻转 GPIO 的调试代码用逻辑分析仪观察每个任务的运行状态。任务 L 的 GPIO 一直有脉冲说明它一直被打到 CPU其他低优先级任务的 GPIO 完全没有脉冲说明它们根本没被调度。这时候再去检查 TCB 状态和就绪队列的一致性很快就定位到了问题。这一路调试下来的经验是信号量的实现代码不多但它和任务调度器的耦合极深。任何一个状态不一致比如任务在等待队列里但 state 还写着 READY或者已从等待队列摘除但wait_sem没清空都会在运行时以非常奇葩的现象暴露出来。建议你手搓内核到这一步时一定要先画清楚任务状态迁移图再动手写代码。状态机的每个流转都必须有对应的链表操作思路清晰了代码才不容易出鬼。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

PS5扩容实战:致态Ti600s 2TB加装与实测全攻略 2026/9/8 9:02:41

PS5扩容实战:致态Ti600s 2TB加装与实测全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
dnSpy实操指南:Unity无源码项目反编译、修改与调试全攻略 2026/9/8 9:02:41

dnSpy实操指南:Unity无源码项目反编译、修改与调试全攻略

简介:在.NET与Unity开发中,程序集逆向分析是一项重要的工程能力。以dnSpy为代表的托管代码反编译工具,能够将Mono后端编译生成的Assembly-CSharp.dll等托管程序集还原为可读的C#代码,并支持直接修改IL后重新保存。其核心原理在于.…

阅读更多 →
Repast Simphony 示例项目实战:从环境搭建到批量实验 2026/9/8 9:02:41

Repast Simphony 示例项目实战:从环境搭建到批量实验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
红包超发问题解析:分布式一致性下的金额强一致方案 2026/9/8 9:02:41

红包超发问题解析:分布式一致性下的金额强一致方案

之前在做红包类活动时,遇到过一件让产品和开发都冒冷汗的事:运营人员发起一个 200 元的红包活动,活动结束后一算账,实际发出去了 250 元。多出的 50 元不是预算问题,也不是人为操作失误,而是在并发领取场景…

阅读更多 →
nvidia-smi为何发现不了vLLM KV Cache泄漏?——从显存监控到逻辑内存池诊断 2026/9/8 9:02:41

nvidia-smi为何发现不了vLLM KV Cache泄漏?——从显存监控到逻辑内存池诊断

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Qt MinGW环境下从源码编译PCL:从Boost到VTK全流程指南 2026/9/8 8:59:40

Qt MinGW环境下从源码编译PCL:从Boost到VTK全流程指南

简介:面向Qt与MinGW环境下进行三维点云开发的工程师,这套资源将PCL及Boost、Eigen、FLANN、Qhull、VTK等依赖库的头文件统一打包,解决手工编译依赖链复杂、版本匹配难的问题,使开发者能直接在Qt Creator中完成点云读取、预处理、特…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞