news 2026/9/11 22:28:40

手写RTOS内核:信号量实现原理与任务同步实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写RTOS内核:信号量实现原理与任务同步实战

这个手搓RTOS的系列写到第8篇。前面几篇我们把任务切换、延时、调度器都跑通了,LED灯也能按照任务函数里的延时各自闪起来。但真到了这一步你会发现一个很尴尬的事实:两个任务只要开始“配合干活”,光靠延时函数根本写不出正确的逻辑。你要么看到串口日志乱成一团,要么发现一个任务总是抢先一步导致整个状态错乱。信号量就是专门来解决这些问题的,它是RTOS里任务同步与资源共享的基石,也是我们从“点灯大师”往真正操作系统开发者迈进的关键一环。

这篇文章我会从零开始,围绕信号量的内核模型、take/give核心实现、任务同步实测、资源共享实测、中断安全设计、优先级反转这几个方向,把信号量的方方面面拆开讲透。适合正在跟我一样手写RTOS的朋友,也适合已经用过FreeRTOS等现成系统但想搞清楚信号量底层原理的开发者。就算你完全没接触过操作系统,只要会C语言和一点链表操作,也能跟着把信号量写出来跑起来。

1. 点灯难度升级前,先想清楚一个问题:任务之间到底在抢什么

1.1 第一阶段:你的第一个多任务点灯程序其实什么都没抢

很多人的RTOS入门第一课都是点灯。比如创建两个任务,task_led1每300ms翻转一次LED1,task_led2每500ms翻转一次LED2,系统跑起来之后两个灯各闪各的,看着很热闹。这时候你会觉得,用RTOS也不过如此,不就是把while循环拆成几个函数嘛,用裸机延时也差不多能做。

但这里有个容易忽略的事实:这两个任务能互不干扰地运行,是因为它们之间根本没有共享任何数据。LED1的GPIO寄存器地址虽然在底层只有一个,但你在两个任务里翻转的是不同的PIN位,底层硬件也不存在“同时写一个位”的竞争窗口,所以裸机也能跑,多个任务也能跑。也就是说,你还没遇到RTOS真正要解决的问题。

1.2 第二阶段:从“各亮各的”变成“一起干活”就出事了

一旦任务之间需要共享数据或协调时序,问题立刻冒出来。举个最常见的例子:两个任务都要通过同一个UART打印调试信息。task_a每秒打印一行温度,task_b每2秒打印一行湿度,单独看逻辑没问题,但跑起来你会看到这样的输出:

temp=25 hum=60 temp=26 hum=61 temp=27

偶尔还会出现一行输出被切断的情况,比如hum=6后面直接跟了temp=2,整行日志彻底错乱。原因很简单:printf内部是分多次往UART发送寄存器写数据的,task_a刚写到一半,时间片到了,task_b抢占了CPU,也往同一个UART寄存器写数据,两个任务的字符就交织在一起了。

再举一个更隐蔽的例子:两个任务共同维护一个全局变量g_counter,一个做g_counter++,另一个做g_counter--。在C语言里g_counter++不是一条机器指令,在Cortex-M3上它至少是“读内存-加一-写内存”三步。如果task_a读完旧值还没写回,task_b已经完成了它的修改,最后写回的那个任务会把对方的修改覆盖掉,这个变量就丢了更新,而且问题极难复现。这种问题有个专门的名字叫竞态条件(Race Condition)。

1.3 我总结下来的三类经典症状

问题类型表面现象根因
互斥失败(竞态)串口日志乱码、共享计数不对、数据被覆盖多个任务同时进入了临界资源区
同步丢失任务B在任务A发出数据之前就启动,拿到的是旧状态任务之间有先后依赖,但没有任何机制强制顺序
死锁任务假装不运行了,系统整体卡死,看门狗乱叫任务等了一个永远等不到的信号量,或者锁的获取顺序写反

这三个问题,信号量都能治,只是用法不一样:互斥问题用“二值信号量当作锁”来治,同步问题用“二值信号量当作事件通知”来治,生产消费问题用“计数信号量”来治。后面每个场景我都会给实测代码。

2. 信号量的内核模型:一个计数值加一条等待队列

2.1 用餐厅叫号系统理解信号量

信号量这个名字起得挺玄乎,但它的内核模型特别简单,就两个东西:一个count计数器和一条等待队列。我习惯用餐厅叫号来类比。

餐厅门口有个取号机,当前号码是8号,这就相当于count = 1(还有1个“可用资源”,即8号还没被叫到)。取号这个动作就是take:如果还有可用号码,拿一个走,count减一;如果没有可用号码了,你就得在等待区排队,这就对应把当前任务挂到信号量的等待队列上。叫号这个动作就是give:服务员喊“8号请用餐”,如果有人在等待,就先让队首的人进去,count不变;如果没人等,号码池里的可用号码就会增加,count加一。

把这个模型落到代码上,信号量的“资源”不一定是某种实物,它可以是“一把锁的占用权”,也可以是“某个事件是否发生”的标记。这也是为什么信号量既能做互斥又能做同步,因为它本质上就是一种“资源数量+等待者队列”的通用的内核对象。

2.2 二值信号量与计数信号量的本质区别

信号量按最大计数值分为二值信号量(Binary Semaphore)和计数信号量(Counting Semaphore)。这个区别必须在设计数据结构的时候就留好字段,后面实现才有依据。

维度二值信号量计数信号量
最大计数值1N(N大于1)
初始值常见设置1表示锁空闲,0表示事件未发生N表示N个可用资源
典型用途互斥锁、任务间事件通知资源池管理、生产者-消费者模型
实现注意give时count不能超过1,否则锁会失效count可以累加到N,注意别溢出

很多初学者觉得二值信号量就是count最大为1的计数信号量,这个理解本身没错,但你要注意一个坑:二值信号量被用做互斥锁时,如果写give的代码没有加“最大为1”的限制,一旦某个bug导致连续give了两次,count会变成2,此时两个任务同时都能拿到锁,互斥保护直接失效。这个我在后面代码实现里会重点处理。

2.3 我设计的miniOS信号量结构体

我在自己的miniOS里把信号量定义成这样的结构体:

#define SEM_TYPE_BINARY 0 #define SEM_TYPE_COUNT 1 typedef struct mini_sem { uint8_t type; /* 二值还是计数 */ uint32_t count; /* 当前可用资源数 */ list_t wait_list; /* 等待该信号量的任务队列 */ } mini_sem_t;

每个字段的作用都很直白。type决定give时要不要把count限制在1;count是信号量最核心的状态,初值由用户设置;wait_list是一个双向链表,链表的每一个节点对应一个因为拿不到信号量而陷入阻塞态的任务控制块。

这里有个设计细节值得提一下:任务控制块(TCB)里要么持有链表节点,要么直接作为链表节点被串起来。我采取的是TCB内嵌list_node的方式,也就是说等待队列存的是TCB节点本身,这样从队列上摘下一个节点时,就能直接拿到那个任务的所有信息,不用再做一次“节点地址到任务地址”的反查。

初始化函数也很简单:

void mini_sem_init(mini_sem_t *sem, uint8_t type, uint32_t init_count) { sem->type = type; sem->count = init_count; list_init(&sem->wait_list); }

初始化信号量的时机一定要在任何一个任务使用它之前,否则会出现某个任务已经在等待了,但wait_list还没有初始化的未定义行为。在RTOS的世界里,“先初始化再用”是铁律。

3. 手写核心原语:take和give的完整实现

3.1 为什么必须先关中断

信号量的takegive看起来只有几行逻辑,但它们不是普通函数,它们操作的数据结构可能被多个任务同时访问。比如task_a正要执行count--,指令还没执行完,SysTick中断触发了,调度器把task_b切换进来了,task_b也执行到count--,这时候count的修改就乱了。

要保证“检查count-修改count-决定是否挂起”这个过程不被打断,最简单的办法就是关中断。在Cortex-M3上,可以通过设置PRIMASK寄存器来屏蔽所有可屏蔽中断,实现一个原子临界区。临界区里面做的事情越少越好,只做必要的队列操作和状态修改,绝对不要把printf、复杂运算这种耗时操作放进去。

我在miniOS里封装了两个宏:

#define arch_enter_critical() arch_disable_irq() /* 关中断 */ #define arch_exit_critical() arch_enable_irq() /* 开中断 */

在实际项目里,enter返回当前的PRIMASK状态,exit时恢复而不是无条件打开,这样可以支持临界区嵌套调用。这里的嵌套场景以后写互斥量、调试打印的时候会碰到,建议一次做对。

3.2 take的核心实现

uint32_t mini_sem_take(mini_sem_t *sem, uint32_t timeout) { uint32_t saved; task_t *cur; saved = arch_enter_critical(); /* 还有可用资源,直接拿到 */ if (sem->count > 0) { sem->count--; arch_exit_critical(saved); return 0; /* 返回0表示获取成功 */ } /* 没有资源,并且不打算等,立即失败返回 */ if (timeout == 0) { arch_exit_critical(saved); return 1; /* 返回非0表示获取失败 */ } /* 把自己挂到等待队列上,等别人give */ cur = current_task; cur->state = TASK_STATE_BLOCKED; cur->wait_sem = sem; cur->take_result = 1; /* 先默认失败,被唤醒后give会改成0 */ list_add_tail(&sem->wait_list, &cur->list_node); arch_exit_critical(saved); /* 主动让出CPU,切换去执行其他任务 */ schedule(); /* 被唤醒后回到这里,返回结果 */ return cur->take_result; }

这段代码有几个关键点。

第一,count > 0count--必须在同一个临界区内完成,否则两个任务可能同时看到count等于1,然后各自减一,最后两个任务都觉得自己拿到资源了。

第二,当count为0且timeout不为0时,任务不能一直占着CPU空转,它必须主动调用schedule()让出处理器。这就是RTOS里“阻塞态”的由来:任务把自己放到等待队列上,然后不再参与调度,直到有人唤醒它。

第三,我在挂起前先把cur->take_result置为1,意思是“如果没人来叫我,我就一直等,直到give把我唤醒并告诉我成功为止”。为什么不在pending之后设置为0?因为give可能会在另一个任务上下文里修改这个任务控制块的字段,所以唤醒逻辑里会负责把take_result改成0,这样当前任务被切换回来之后直接读这个字段就行。

3.3 give的核心实现

uint32_t mini_sem_give(mini_sem_t *sem) { uint32_t saved; task_t *task; uint32_t need_sched = 0; saved = arch_enter_critical(); /* 有任务在等待,直接把资源转交给队首任务 */ task = list_first(&sem->wait_list); if (task) { list_remove(&task->list_node); task->state = TASK_STATE_READY; task->take_result = 0; /* 告诉它拿到了 */ add_to_ready_list(task); if (task->prio < current_task->prio) { need_sched = 1; /* 被唤醒的任务优先级更高,需要切换 */ } } else { /* 没有任务等待,资源归还给信号量 */ if (sem->type == SEM_TYPE_BINARY) { if (sem->count == 0) { sem->count = 1; } /* 二值信号量最多恢复到1,防止连续give导致count超过1 */ } else { sem->count++; } } arch_exit_critical(saved); if (need_sched) { schedule(); } return 0; }

give的逻辑里,最重要的设计决策是“优先唤醒等待者,而不是增加count”。这么做的理由很微妙:如果有一个任务在等待信号量,你选择count++而不是唤醒它,那么这个等待任务不会被解除阻塞,它依然停在等待队列里,信号量也没有起到“资源转交”的作用。正确语义应该是:有人等就先给人,没人等才把资源放回池子。

另外一个值得注意的点是,被唤醒的任务拥有比当前任务更高的优先级时,需要触发一次任务调度。在单核MCU上,这种调度要放在临界区之外执行,否则调度器运行时中断又是关闭的,一旦新任务里又调用了take,就可能出现死锁。

3.4 等待队列的排序问题

我在上面的代码里用的是list_add_tail把任务加到队尾,看起来像FIFO。但实际生产级RTOS里,等待队列通常不会简单地用FIFO,而是按任务的优先级排序。原因很简单:如果低优先级任务先去等一个信号量,它排在队首,高优先级任务后到只能排在它后面,那么高优先级任务的等待时间会被低优先级任务拖长。这虽然不是严格意义上的优先级反转,但会让高优先级任务的延迟变得不可控。

按优先级插入的做法是:遍历等待队列,找到第一个优先级比当前任务低的位置,把当前任务插到它前面。这个遍历在关中断区间内完成,因为队列长度通常只有几个任务,开销可以接受。miniOS里我把这个逻辑封装成prvAddTaskToWaitList,take里直接调用它,而不是裸调list_add_tail

按优先级排序的等待队列还有一个额外好处:give唤醒队首任务时,天然就是唤醒当前等待者中优先级最高的那个,省去了每次唤醒都要找最大优先级任务的麻烦。

4. 任务同步实测:两个LED严格交替闪烁

4.1 需求分析:纯延时方案为什么做不到

我现在把需求说得更具体一点:LED1亮100ms,灭100ms,不断循环;LED2的行为必须严格跟随“LED1熄灭”这个事件——LED1一灭,LED2立刻亮100ms,然后熄灭,等待LED1下一次熄灭再亮。

如果用裸机延时,任务a控制LED1翻转,亮100ms灭100ms;任务b控制LED2,也遵循同样的时序。表面上看两个任务周期相同,LED2总能跟LED1错开,但实际跑起来问题很大:第一个问题是任务启动顺序不确定,task_b可能比task_a先运行,那它的第一次亮灯就发生在LED1灭之前;第二个问题是调度抖动,两个任务的延时虽然都是100ms,但从“延时结束”到“下一次真正切换回来执行”的延迟并不可控,时间一长两个灯的相位就会慢慢漂移。

正确的做法是用二值信号量做“事件通知”:task_a完成了一个周期的动作之后,give一个信号量;task_b在信号量上take,拿不到就一直等。这样task_b的每一次运行都被task_a的“通知”精确驱动,相位永远对齐。

4.2 用二值信号量实现的代码

mini_sem_t sync_sem; void task_led1(void *arg) { for (;;) { LED1_ON(); delay_ms(100); LED1_OFF(); mini_sem_give(&sync_sem); /* 通知task_led2:我灭灯了 */ delay_ms(100); } } void task_led2(void *arg) { for (;;) { mini_sem_take(&sync_sem, WAIT_FOREVER); /* 一直等到通知 */ LED2_ON(); delay_ms(100); LED2_OFF(); } }

主函数里这样初始化:

mini_sem_init(&sync_sem, SEM_TYPE_BINARY, 0); create_task(task_led1, ...); create_task(task_led2, ...); start_scheduler();

注意这里二值信号量的初始count是0,表示“事件还没有发生”。语义上,take就是等待事件,give就是发送事件,跟“锁”的角色完全不同。这就是为什么我一直说,二值信号量虽然结构上和互斥量相似,但设计意图可以完全不同,你要根据场景来设置初值。

4.3 实测波形解读

我把LED1和LED2接在逻辑分析仪上看波形,整个运行过程非常清晰:LED1灭的同时,LED2的上升沿出现;LED2灭掉后,LED1也正好重新亮起,两者严格错开,相位不会漂移。

更关键的是,不管我把task_led2的优先级设置成比task_led1高还是低,运行结果都一致。为什么?因为task_led2在信号量上等待的时候是阻塞态,根本不占CPU,调度器不会把CPU分给它。只有等到task_led1给出信号量,它才进入就绪态,被调度器选中运行。

这就是信号量做同步的最大价值:它把“谁先谁后”这个问题从“靠任务创建顺序和优先级碰运气”变成了“由信号量的时序严格保证”。

5. 资源共享实测:用信号量保护串口打印和全局变量

5.1 没有保护的串口打印现场

我先故意写了个有问题的版本,把串口打印直接放在任务里,不加任何保护:

void task_a(void *arg) { for (;;) { printf("[A] g_counter=%d\n", g_counter++); delay_ms(2); } } void task_b(void *arg) { for (;;) { printf("[B] g_counter=%d\n", g_counter--); delay_ms(3); } }

跑一段时间,控制台输出从乱七八糟开始,甚至会出现把一行格式打断的情况。如果你把g_counter的读写放到打印语句外面去执行,还能看到两个任务各自执行了几百次之后,计数器的终值跟理论值对不上的现象,这就是竞态条件造成的更新丢失。

5.2 加一把信号量锁

修复方案是把“读-改-写g_counter”和“打印整行”整体包进信号量临界区。注意这里二值信号量的初值必须设为1,表示“锁是空闲的”。

mini_sem_t print_sem; void task_a(void *arg) { for (;;) { mini_sem_take(&print_sem, WAIT_FOREVER); printf("[A] g_counter=%d\n", g_counter++); mini_sem_give(&print_sem); delay_ms(2); } } void task_b(void *arg) { for (;;) { mini_sem_take(&print_sem, WAIT_FOREVER); printf("[B] g_counter=%d\n", g_counter--); mini_sem_give(&print_sem); delay_ms(3); } }

加锁之后,任何时刻系统里最多只有一个任务在执行printf,另一任务如果也想打印,就会在take处阻塞,直到锁被释放。g_counter的读改写也不再有交叉,因为整个区域是原子的。

实践中有个重要经验:锁的粒度要尽量小,锁覆盖的代码要尽量短。上面这个例子里把打印也包进去是必要的,因为整个printf输出的多行内容是一个整体,拆开反而会乱。但如果你只是要保护一个全局变量的自增,那就应该只锁那两三行赋值语句,不要在锁里做耗时工作。

5.3 临界区与信号量的取舍

很多新手会问:关中断也能保护共享资源,为什么还要用信号量?答案在于“粒度”和“代价”完全不同。

关中断适合保护那些微秒级的短操作,比如链表插入、队列读写。但它的代价是关掉了整个MCU的所有中断,如果关中断时间太长,会导致定时器中断、UART接收中断都错过,对实时性反而是灾难。

信号量则适合保护那些可能相对耗时的临界区,比如printf、外设读写。因为信号量不屏蔽中断,只是让其他任务阻塞等待,中断依然可以及时响应。它的代价是如果锁被占用,任务要经历一次“挂起-阻塞-唤醒”的调度过程,这个切换开销比关中断大一些。所以,能用短临界区解决的不用信号量,信号量解决的问题也别想着用裸中断去硬扛。

我实测过一个场景:在GD32F103上跑两个任务,每个任务都频繁打印,加了信号量之后CPU占用多了大概百分之几,但日志完全正常,这个开销对大多数应用来说完全值。

6. 中断与信号量:ISR里只能give,不能take

6.1 为什么ISR里不能take

嵌入式开发里经常出现这样的需求:外部按键触发中断,中断里设置一个标志,然后让某个任务去处理按键事件。很多人的第一反应是,在中断里调用mini_sem_take(&sem, WAIT_FOREVER)把信号量拿住,然后任务再give。这是非常严重的错误。

原因是take在信号量不可用的时候会让当前任务进入阻塞态,而中断上下文根本不属于任何任务。一旦在ISR里尝试阻塞等待,这个RTOS的调度状态就彻底乱套了:没有任务控制块可以被挂起,current_task指向的是被中断打断的那个任务,如果把它的状态改成阻塞,那么被打断的任务永远无法恢复运行,整个系统直接卡死在中断返回的那一刻。

正确的规则只有一条:中断服务函数里只能调用give的变体give_from_isr,用来唤醒一个正在等待的任务;绝对不能在中断里调用take。

6.2 实现一个ISR安全的give版本

uint32_t mini_sem_give_from_isr(mini_sem_t *sem, uint32_t *pxHigherPriorityTaskWoken) { task_t *task; uint32_t saved; *pxHigherPriorityTaskWoken = 0; saved = arch_enter_critical(); task = list_first(&sem->wait_list); if (task) { list_remove(&task->list_node); task->state = TASK_STATE_READY; task->take_result = 0; add_to_ready_list(task); if (task->prio < current_task->prio) { *pxHigherPriorityTaskWoken = 1; } } else { if (sem->type == SEM_TYPE_BINARY) { sem->count = (sem->count == 0) ? 1 : sem->count; } else { sem->count++; } } arch_exit_critical(saved); return 0; }

这个函数和普通give的逻辑几乎一样,唯一的区别是它通过一个指针参数pxHigherPriorityTaskWoken把“是否唤醒了更高优先级任务”这个信息传出去,而不是直接在函数内调用schedule。

为什么不能在ISR里直接调用schedule?因为中断返回时的任务切换需要遵循Cortex-M3的中断尾链机制,直接调用会让CPU提前切换到新任务,栈指针和异常返回帧的处理就乱了。正确做法是:中断函数退出前检查这个标志,如果为1,就在中断返回前请求一次PendSV异常,由PendSV来执行真正的上下文切换。这个机制在FreeRTOS里就叫“中断安全版本”,我强烈建议所有手写RTOS实现都照这个思路来。

6.3 一个完整的中断配合示例

mini_sem_t key_sem; void EXTI0_IRQHandler(void) { uint32_t need_switch = 0; ... mini_sem_give_from_isr(&key_sem, &need_switch); if (need_switch) { port_yield_from_isr(); /* 触发PendSV切换 */ } } void task_key_handler(void *arg) { for (;;) { mini_sem_take(&key_sem, WAIT_FOREVER); printf("key pressed\r\n"); } }

这样一个模型就把“中断产生事件”和“任务处理事件”解耦了。中断里只做最少的操作,实时的部分由高优先级任务去完成。这种写法在裸机里通常要用一个全局标志变量+轮询来实现,在RTOS里用信号量干净地搞定。

7. 别把二值信号量当互斥量用:优先级反转的坑

7.1 一个真实会发生的反转场景

二值信号量做互斥,表面上看没有问题:初值为1,take拿锁,give放锁。但这里埋着一个很深的坑,叫优先级反转。假设系统里有三个任务:

  • 任务H:高优先级,负责紧急控制逻辑
  • 任务M:中优先级,普通计算任务
  • 任务L:低优先级,偶尔访问慢速外设

L先拿到了一个二值信号量锁,正在操作某个共享外设。这时H就绪并尝试take同一个锁,因为锁被L持有,H被阻塞。如果此时M也进入就绪状态,由于M的优先级高于L但低于H,它会立刻抢占L,L被挂起无法继续执行,也就无法释放信号量。结果就是H明明优先级最高,却被一个中优先级的M活活堵住,要等M跑完才轮得到L继续,L释放锁后H才能继续。这个“反转”的持续时间完全取决于M的执行时间,可能在毫秒到秒级不定。

优先级反转不是理论问题,在真实项目里我见过因为这个问题导致看门狗复位的情况。所以,正经RTOS里做互斥用的不是二值信号量,而是专门的互斥量(Mutex),它带优先级继承机制。

7.2 优先级继承的简化实现思路

优先级继承的核心思想是:当低优先级任务持有的锁被高优先级任务申请时,临时把低优先级任务的优先级提升到高优先级任务同一水平,等它释放锁后再恢复原优先级。这样M就再也抢不过L,L得以尽快执行并释放锁,H的等待时间被压缩到一个很短的区间。

我给的miniOS互斥量结构如下:

typedef struct mini_mutex { task_t *owner; /* 当前持有锁的任务,没有则为NULL */ uint32_t owner_prio; /* 记录持有者进入前的原始优先级,释放锁时恢复 */ list_t wait_list; /* 等待该互斥量的任务队列 */ } mini_mutex_t;

take的简化流程是:

  1. 如果owner为NULL,说明锁空闲,直接设置owner为当前任务,记录owner_prio,拿走锁。
  2. 如果owner不是当前任务,说明锁被占用了。检查当前任务优先级是否高于owner当前优先级,如果是,就把owner的优先级提升到当前任务优先级,并把owner从就绪队列里取出来重新按新优先级插入。
  3. 当前任务加入wait_list并阻塞。

give的简化流程是:

  1. 从wait_list里取第一个任务作为新的owner。
  2. 如果有新owner,把它设为就绪态,并让它获得锁。
  3. 恢复原来owner的优先级到owner_prio,重新放入就绪队列。
void mini_mutex_take(mini_mutex_t *m) { if (m->owner == NULL) { m->owner = current_task; m->owner_prio = current_task->prio; return; } if (m->owner == current_task) { /* 同一任务重复拿锁,要么支持递归要么直接报错 */ return; } /* 优先级继承:等待者比持有者更重要,先提升持有者 */ if (current_task->prio < m->owner->prio) { m->owner->prio = current_task->prio; update_ready_list(m->owner); } current_task->state = TASK_STATE_BLOCKED; list_add_tail(&m->wait_list, &current_task->list_node); schedule(); } void mini_mutex_give(mini_mutex_t *m) { task_t *next = list_first(&m->wait_list); if (next) { list_remove(&next->list_node); next->state = TASK_STATE_READY; add_to_ready_list(next); m->owner = next; m->owner_prio = next->prio; } else { m->owner = NULL; } /* 恢复原先持有者的优先级 */ current_task->prio = m->owner_prio; update_ready_list(current_task); }

这段代码是教学级简化版,但已经足以打断优先级反转的链条。我在实际miniOS里还加了“递归锁”和“互斥量删除时唤醒所有等待者”的处理,那些属于边界情况,等你有项目真正需要时再补也不迟。

7.3 什么时候该用互斥量,什么时候用二值信号量

我的建议很简单:凡是“保护共享资源”这种场景,一律用互斥量;凡是“事件通知、任务同步”这种场景,用二值信号量。虽然底层都能实现,但互斥量的优先级继承属性是专门为锁设计的,二值信号量没有这个属性。很多嵌入式教科书把这两个概念混着讲,导致实际代码里踩了优先级反转的坑都不知道,这是系列文章里我最想强调的一个点。

8. 我踩过的几个坑和验证方法

8.1 踩坑记录:关中断时间太长导致定时器中断丢失

我在给信号量加调试打印的时候,习惯性地在take的临界区里放了一个LOG输出。结果SysTick定时器中断被屏蔽的时间超过了整整一个Tick周期,系统的调度时钟直接漂移,所有延时都变慢了。排查了好久才意识到是关中断区间太长。

这个教训让我养成了习惯:临界区代码要么只有几条内存操作指令,要么就是调试版本里用一个计数器统计关中断时间,上线前把调试打印全部拿掉。

8.2 踩坑记录:二值信号量连续give导致锁失效

有一次我写了一个资源释放函数,里面不管三七二十一先give一下信号量。结果一个任务释放了锁之后,另一个任务也调用了give,count从0变到1,然后两个任务同时take都成功了。共享资源同时被两个任务写,BUG特别隐蔽。

后来我把give的实现改成了“二值信号量只有在count==0时才置1”,并且给give加了一个内部断言:如果二值信号量在被give之前count已经是1,就立刻触发错误处理。虽然正常情况下不该出现这种调用,但防御性编程能帮你尽早发现问题。

8.3 验证方法:GPIO翻转加逻辑分析仪

手写RTOS没有调试器辅助的情况下,我最常用的验证方法是在关键路径上翻转一个GPIO,然后用逻辑分析仪抓波形。比如在take的临界区前后各翻转一次某个引脚,测量脉冲宽度就能知道临界区关闭了多久中断;在两个LED同步测试中直接抓LED引脚的上升沿和下降沿,就能判断任务同步是否严格。

另一个很实用的指标是“最大关中断时间”,用一段高优先级中断来测量:起一个定时器中断,让它每次都记录从进入中断到退出中断的时间差,如果这个时间差波动很大,就说明某处关中断时间过长。把信号量的实现从内到外测一遍,什么问题都藏不住。

8.4 一个简单的压测脚本:多任务争抢信号量

我最后一次验收信号量模块时写了一个压力测试:创建5个任务,优先级各不相同,每个任务循环10000次,每次take锁后对一个全局计数器做100次自增再give。理论值在单核下应该是严格串行的,如果出现计数偏差,说明锁失效。这个测试跑通的瞬间,我基本可以确定信号量的实现是可靠的。

volatile uint32_t g_protected_counter = 0; void stress_task(void *arg) { for (int i = 0; i < 10000; i++) { mini_sem_take(&mutex, WAIT_FOREVER); for (int j = 0; j < 100; j++) { g_protected_counter++; } mini_sem_give(&mutex); } /* 结束后打印自己的完成标志 */ }

5个任务全部跑完后,g_protected_counter的值必须是10000 * 100 * 5 = 5000000。只要结果不是这个数,就说明存在竞态漏洞。

我在调这段压测代码的时候,还真抓出过一个bug:等待队列在按优先级插入时没有处理“优先级相同”的情况,导致两个同优先级任务互相插队,某个任务一直得不到唤醒,最终整个系统卡住。后来我把插入逻辑改成“同优先级任务排在后面”,这个问题才消失。

信号量写完,其实你已经跨过了RTOS内核里最难理解的一道门槛。它背后涉及的不只是那几行链表和计数逻辑,更是“临界区、阻塞、唤醒、调度时机”这一整套思维模型。接下来你可以继续往更高级的方向走——互斥量的优先级继承、消息队列、事件标志组,这些组件本质上都是在信号量这个“资源数加等待队列”的骨架上做扩展。你在两个LED和一个串口上把信号量彻底玩明白,后面不管用什么商用RTOS,再看到它内部的信号量实现,都会有一种“原来如此”的熟悉感。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 22:27:23

WinForms企业级HRMS实战:三层架构与ADO.NET最佳实践

简介&#xff1a;本资源是一套基于C#开发的完整人力资源管理系统&#xff08;HRMS&#xff09;源码工程&#xff0c;面向计算机类及相关专业在校学生、课程设计与毕业设计指导教师&#xff0c;解决课程大作业、期末项目及毕设选题中对典型B/S或C/S架构业务系统实践需求。压缩包…

作者头像 李华
网站建设 2026/9/11 22:26:21

钙质土中重力锚水平承载力有限元分析与优化

1. 项目概述&#xff1a;钙质土中重力串锚水平承载力有限元分析重力锚在海洋工程、桥梁建设等领域应用广泛&#xff0c;其水平承载力特性直接关系到结构安全性。钙质土作为一种特殊地质材料&#xff0c;具有高孔隙比、易破碎等特点&#xff0c;传统理论公式往往难以准确预测其力…

作者头像 李华
网站建设 2026/9/11 22:26:03

Apache Doris 压缩算法怎么选:ZSTD、LZ4、Snappy 配置指南

Apache Doris 压缩算法怎么选&#xff1a;ZSTD、LZ4、Snappy 配置指南 【免费下载链接】doris Apache Doris is a real-time analytics and hybrid search database for AI agents. 项目地址: https://gitcode.com/GitHub_Trending/doris/doris 在 Apache Doris 中&…

作者头像 李华
网站建设 2026/9/11 22:24:48

Maestro record 上手:1 条命令产出 1080p 测试演示视频

Maestro record 上手&#xff1a;1 条命令产出 1080p 测试演示视频 【免费下载链接】Maestro Painless E2E Automation for Mobile and Web 项目地址: https://gitcode.com/GitHub_Trending/ma/Maestro 发版评审、给开发提 bug 报告&#xff0c;你多半都遇到过这种事&am…

作者头像 李华
网站建设 2026/9/11 22:22:52

中国植被栅格数据的完整处理流程:从ArcGIS到MaxEnt建模

简介&#xff1a;覆盖中国范围的植被类型GIS数据&#xff0c;面向GIS分析师、生态科研与林业规划人员&#xff0c;可直接用于植被分布制图、空间分析与MaxEnt生态位建模。数据基于遥感解译与野外调查整合&#xff0c;涵盖森林、草原、荒漠、湿地等主要植被类型&#xff0c;可作…

作者头像 李华