news 2026/9/8 7:31:52

手写RTOS核心:信号量如何解决任务同步与互斥问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写RTOS核心:信号量如何解决任务同步与互斥问题

从LED闪烁异常到信号量:一次真实的任务协作问题

上一篇文章我们把任务调度器跑通了,多个任务可以轮转切换,"点灯大师"们也能各自闪烁互不干扰了。但问题是,板子上的场景很快就变得不满足于"独立闪烁"了——我想让两个LED任务彼此配合着闪烁,按键按下时通知另一个任务去翻转状态;我想让两个任务同时往同一个缓冲区里写数据;我想让一个慢速任务和一个快速任务共享一个串口打印。

然后我就发现,任务调度器能解决的只是"谁该运行",它解决不了"资源该给谁用"和"事情办完了怎么通知别人"。前者是互斥,后者是同步。这两类问题如果硬用延时死等或者全局变量去凑,代码会变成一团乱麻,bug出得神不知鬼不觉。

这一篇就是我们手搓RTOS系列中很关键的一次进阶:在已有的内核上加上信号量(Semaphore)。我会从信号量要解决的实际问题出发,先讲清楚它的原理,再给出一份能在你手头那颗MCU上直接跑起来的实现代码,最后用两个点灯实验把同步和互斥都验证一遍。整个实现思路和FreeRTOS这类成熟内核中的二进制信号量、计数信号量是一脉相通的,搞懂了这一篇,你再去看FreeRTOS的信号量源码,会发现大部分逻辑都是老朋友。

1. 信号量要解决的,其实是两种截然不同的困境

在动手写代码之前,我劝你先别急着百度"信号量是什么"。先来看看我在实际调试中遇到的两个具体困境。

1.1 困境一:一个任务做完了,怎么通知另一个任务开工

举个例子,板子上有两个LED,LED1由TaskA控制,负责慢速呼吸效果,LED2由TaskB控制,平时不亮。我希望实现一个功能:TaskA每完成5次呼吸,就通知TaskB让LED2闪一下。

用最笨的办法怎么做?定义一个全局变量volatile int breathe_count = 0;,TaskA每次加1,TaskB死循环里轮询这个变量,等它变成5就点亮LED2,然后再清零。

这个办法在小系统里确实能跑,但问题在于TaskB一直在"空转"——它明明没活干,却还要一直占用CPU去检查那个变量。更麻烦的是,如果我同时有3个这样的协作关系,轮询逻辑就会交叉到你想哭。而且轮询天然就有延迟:TaskA刚把变量改成5,TaskB可能隔了好几个tick才发现,这在时间敏感的场合没法接受。

信号量的做法是让TaskB主动"睡觉",等TaskA发信号再醒过来。整个过程没有任何空转,延迟也只在微秒级别。

1.2 困境二:两个任务抢同一个硬件资源

第二个场景更典型。我的板子上有一个OLED屏幕(或者一个串口),有两个任务都往里写数据。TaskA负责周期性打印温度数据,TaskB负责在按键按下时打印按键值。

问题来了:两个任务同时在写屏幕的某个显示缓冲区时,TaskA写到一半,时间片到了,系统切换到TaskB,TaskB也往同一块缓冲区写。等到再切回TaskA,TaskA继续写的时候,缓冲区里面的内容已经是两个任务的数据交错在一起了。

这种问题用全局变量也没法优雅解决,因为不管你怎么标志,都避免不了"正在写入过程中被打断"这个事实。你需要的是一把"锁"——这个资源我在用的时候,你们谁也别碰,等我用完了再放你们进来。

1.3 信号量为什么能同时解决这两类问题

信号量本质上就是一个受保护的计数器,配套两个操作:pend(拿资源/等待)和post(放资源/通知)。这个计数器背后隐藏着两种语义,恰好对应上面两个困境。

  • 计数器的值如果代表"可用资源数量",那么任务拿一次资源就减1,用完还回来就加1。多个任务争抢有限资源时,靠这个计数来保证同一时刻最多只有N个任务在用这批资源,这就是互斥与资源共享的语义。
  • 计数器的值如果代表"事件发生的次数",那么事件产生者就加1(post),等待事件的任务就尝试减1(pend)。计数为0时等待者就挂起,直到有事件到来,这就是任务同步的语义。

同一套机制,两种解读方式,这就是信号量设计精妙的地方。用生活里的例子来类比:它就像停车场的余位牌——车进场时看牌子,有余位就进去(减1),没余位就在门口排队等着(pend阻塞);车出场时把牌子翻回来(加1),然后告诉门口排队的车可以进来一辆了(唤醒一个等待者)。既可以用来做"最多停N辆车"的资源管理,也可以用来做"走一辆才能进一辆"的事件同步。

提示:理解了这个停车场模型,后面看代码就不会懵了。信号量里最核心的成员变量就两个——count(余位数),以及一个等待队列(门口排队的那几辆车)。

2. 信号量的内核结构设计:远不止一个计数器

很多初学者以为信号量就是一个intpend就是if(count>0) count--;post就是count++。这个理解做应用层调用当然够用,但如果你想在自己的RTOS里实现它,就会马上碰到三个必须想清楚的设计问题。

2.1 等待队列选型:位图、链表还是数组?

count减到0,再来一个任务执行pend,它必须在信号量上"挂起等待"。这个挂起等待的任务集合就是等待队列。不同的RTOS有不同的做法,在我们手搓的这个小内核里,我建议用位图,理由有三点。

第一,位图极其省内存。一个16位的变量就能表示16个任务在等待,而链表至少要给每个等待节点准备两个指针(8字节),还要处理动态分配。 第二,位图的唤醒操作是O(1)的。用位运算找最低位的1,一条指令就能找到该唤醒哪个任务,链表则要遍历。 第三,我们的任务数量本来就不多(教学内核顶多16个任务),位图完全够用。

#define SEM_MAX_WAIT_TASKS 16 typedef struct sem_t { int16_t count; /* 信号量计数值,可正可负的设计见下文 */ uint16_t wait_mask; /* 等待队列位图,bit n 为1表示任务n在等待 */ } sem_t;

wait_mask的bit位和任务ID直接对应。比如wait_mask = 0x0006(二进制0000 0000 0000 0110),表示任务1和任务2在等待这个信号量。

2.2 计数值的语义:count能不能为负?

这是我在实现过程中纠结过的问题。有些RTOS的计数值在pend阻塞后不会继续减,保持为0;有些则会继续减成负数,负数的绝对值恰好表示有多少个任务在等待。

我觉得负数方案更优雅,原因有二。第一,pend逻辑可以统一:count大于0,减1直接返回;否则进入等待队列。不需要特地去判断"现在是不是已经有人在等了"。第二,负数计数值天然表达了"欠了多少个资源",调试时看这个值一眼就知道有多少任务被堵在门口。

所以上面结构体里的count我故意用signed,就是这个原因。post的时候,如果发现有任务在等(wait_mask非0),就不加计数了,直接从等待队列里唤醒一个任务;如果没有任务等,才把count加1。

2.3 原子性与临界区:为什么不能直接count--?

信号量的所有操作都必须在"一瞬间"完成,不允许中间被打断。假如我们的pend操作看count大于0,正准备count--,结果这个节骨眼上来个中断,中断里又创建了个新任务,触发调度把当前任务切换走了。等再切回来,count的情况已经变了,你自以为"原子"的操作实际上已经半途而废。

这就是临界区保护。在单核MCU上,最直接的方式就是关中断

uint32_t key = enter_critical(); /* 通常是关中断,并保存中断状态 */ /* ... 临界区代码 ... */ exit_critical(key); /* 恢复之前的中断状态 */

在我自己的实现里,enter_critical就是调了RISC-V的csrc mstatus, MIE指令关掉全局中断,返回之前的MIE值;exit_critical则根据key值决定要不要恢复中断。

注意:必须用"保存-恢复"的方式,而不是简单地"关-开"。如果你的代码在关中断的临界区里调用了某个本来就要求中断已经关闭的函数(比如某些上下文切换代码),那么退出时直接开中断就会破坏嵌套状态,这是一个很隐蔽的bug,我踩过一次。

2.4 一个关键的时机点:临界区里能不能做上下文切换?

这个问题的答案直接决定了你的实现质量。拿pend来说,当信号量资源不足时,我们要做的是:把当前任务加入等待队列,然后在临界区里面触发调度,切换到别的任务去。

有同学会问:既然要切换上下文,那必然要操作TCB和栈,这些都是全局的,为什么不开着中断呢?

原因是:如果开着中断做"加入等待队列"这个操作,刚把任务加入等待队列但还没来得及调用调度器,中断就来了。中断里的代码如果也调用了pend或者post,看到等待队列的状态是"半成品",要么重复加入,要么永远找不到唤醒目标。所以必须把修改队列 + 触发调度作为一个整体关进临界区。

注意这里调度的时机也很有讲究。在pend中触发调度后,是切不回来的——任务已经阻塞了。所以pend的临界区结束后的代码,应该是在别的任务通过post再次唤醒它之后才会继续执行的地方。这个"稍后继续"靠的是调度器在post里把该任务重新放回就绪队列,并在下一次合适的时间点上切换回它的上下文。

3. 手写信号量三个核心API:init、pend、post

好了,原理清楚之后,代码就是水到渠成的事。我会把三个API完整地贴出来,然后逐段讲清楚关键逻辑。下面的代码假设你已经有了task_block()task_wakeup()schedule()这几个基础函数——它们在前面的系列文章里已经实现过了。

3.1 sem_init:初始化一个信号量

void sem_init(sem_t *sem, int16_t init_count) { uint32_t key = enter_critical(); sem->count = init_count; sem->wait_mask = 0; exit_critical(key); }

这个函数很短,但有一个容易被忽略的点:init_count的类型。这个信号量如果是用来做互斥的,init_count应初始化为1;如果是用来计数资源(比如10个缓冲区),就初始化成10;如果是用来做纯同步的,通常初始化为0。这决定了信号量的初始语义,用错了整个系统的行为都会变味。

我见过有人在互斥场景把信号量初始化成0,结果第一个任务进来就阻塞了,后面所有任务都卡死,而且这种现象还不好排查,因为代码逻辑看起来"没毛病"。

3.2 sem_pend:拿不到资源就乖乖排队

int sem_pend(sem_t *sem, uint32_t timeout) { uint32_t key = enter_critical(); if (sem->count > 0) { /* 有可用资源,直接取走 */ sem->count--; exit_critical(key); return 0; } /* 没有资源,当前任务必须阻塞等待 */ sem->count--; /* count 变成负数,负数绝对值 = 等待任务数量 */ sem->wait_mask |= (1 << current_task->id); current_task->wait_sem = sem; /* 阻塞当前任务,让出CPU */ task_block(current_task, timeout); schedule(); /* 在临界区内切换,切走之后,要等post唤醒才回得来 */ exit_critical(key); return 0; }

有两点需要详细说明。

第一,task_blockschedule必须连着调用,而且不能跑出临界区。我们的设计中task_block把任务从就绪队列移除并放入延时/等待队列,但此时任务还在运行,只有在schedule切换之后才算真正阻塞。两个操作之间如果插了开中断,等待队列刚改完还没切走,中断来了调度到别的任务,别的任务执行不到几个tick又切回来,卡在这的代码路径就乱了。

第二,任务被post唤醒后,schedule返回,会继续往下执行。注意这时的count已经不再是"可用的资源数"了,pend函数也没有再去减一次count。原因是在阻塞路径上我们已经减过了(使count变为负数)。这个逻辑容易绕晕,我画过一张图才彻底捋顺:post唤醒等待任务时,其实等价于把资源直接交给这个被唤醒的任务,所以count在pend的阻塞路径上已经体现过了。

3.3 sem_post:释放资源,并挑一个最合适的唤醒

int sem_post(sem_t *sem) { uint32_t key = enter_critical(); if (sem->wait_mask == 0) { /* 没有任务在等待,资源数量+1 */ sem->count++; } else { /* 有等待者,找出优先级最高(ID最小)的那个唤醒 */ int tid = __builtin_ctz(sem->wait_mask); /* 找最低位1的位置 */ sem->wait_mask &= ~(1 << tid); task_wakeup(tid); } exit_critical(key); return 0; }

post的逻辑看起来比pend简单,但设计上有个关键选择:count为负数时,post该不该加count?

按照我们上面的代码,wait_mask非0时post是不会加count的。这就保证了唤醒一个任务后,count的值依然是负数(假设之前有2个任务在等,count = -2,post后count还是-2,但实际等待队列里剩1个任务)。这看起来"不对"啊,count的绝对值明明是等待者数量,怎么现在对不上了?

解释是这样的:count为负时的绝对值只有在"所有等待者都没有被唤醒"那一瞬间才等于等待者数量。当一个任务被唤醒,它处于"正在返回pend"的状态,还没完全恢复运行,此时count没有变,但等到它真正运行后,它应该获得那个资源的所有权。所以实际语义是:count为负,代表现在"欠着资源"的个数;每次post唤醒一个任务,是清偿一笔债务,这个债务在任务真正运行后才算彻底还清。等到所有任务都被唤醒,count会回到0或正数,一切恢复正常。

你千万不要在post里想当然地加count++,那样会让资源被唤醒的任务取走之后,信号量的总资源数悄悄多出一份。我刚学信号量时犯过这个错,结果是系统运行时间越久,资源泄露越严重,最后导致所有任务全部被堵死。

3.4 关于优先级唤醒:FIFO还是按优先级?

__builtin_ctz找的是wait_mask里的最低位,在我们这个教学内核中任务ID越小优先级越高(这在前几篇中已经约定过)。所以按ID号小者优先唤醒,其实就是按优先级唤醒。

如果你希望做FIFO,那就得把位图换成双向链表,按等待先后顺序排队。两种策略各有取舍:

  • 优先级唤醒能保证高优先级任务最快获得资源,但可能出现"低优先级任务一直排不上"的饿死问题(尤其是高优先级任务频繁post的时候)。
  • FIFO唤醒绝对公平,但高优先级任务可能被低优先级任务长时间挡在资源外面。

FreeRTOS的xQueueSemaphoreTake其实有点混合的味道——等待者会按优先级在事件链表中排序,同优先级按先后,也就是优先级+先来后到的综合策略。对教学内核来说,位图+ID序已经足够,我在代码注释里写了清楚。

3.5 一个容易翻车的地方:post之后要不要立即切走?

细心的人会发现,我们post里在临界区里调了task_wakeup,但没有调用schedule()。也就是说,post的当前任务不会马上切走,而是继续往下执行。

这是标准的做法,我强烈建议你也这样。原因有二:

其一,post本身不改变当前任务的运行资格,它只是把某个任务恢复为就绪。如果此刻当前任务优先级最高,那切换了反而浪费;如果被唤醒的任务优先级更高,也应该等到下一个调度点(比如tick中断)再切,而不是立即切。立即切换会让post函数变成一个"隐藏的阻塞点",调用者的代码会莫名被打断,非常难调试。

其二,从实时性角度讲,post里若是关了中断,要尽量减少在临界区里的时间。立即切换意味着你在临界区里又做了一整套上下文切换的操作,临界区被拉得很长,这在高频率post场景下是不可接受的。在FreeRTOS的实现里,post也只是把任务解除阻塞,真正的调度交给portYIELD_FROM_ISR之类的宏在合适的时机触发,道理一样。

4. 用两个点灯实验验证信号量:看到才会信

代码写完了,不验证等于没写。这里我设计了两个实验,一个验证同步,一个验证互斥,全部用我们手头的LED灯就能直观看到结果。

4.1 实验一:TaskA呼吸5次,TaskB闪1次——任务同步验证

设计如下:

  • TaskA:循环四次,每次点灯/延时200ms,完成一次呼吸;呼吸完5次后,sem_post(&sync_sem)发一次信号。
  • TaskB:一启动就sem_pend(&sync_sem, 等待超时)。由于sync_sem初始化为0,TaskB必然阻塞。
  • 主程序:创建两个任务,TaskA优先级5,TaskB优先级4(数字越小优先级越高,所以TaskB应该被优先调度)。但TaskB一上来就阻塞了,TaskA正常跑。

预期现象:LED1(TaskA)每5次呼吸后,LED2(TaskB)闪1次(点亮200ms再熄灭)。周而复始。

sem_t sync_sem; void task_a(void *arg) { while (1) { for (int i = 0; i < 5; i++) { led_on(LED1); delay_ms(200); led_off(LED1); delay_ms(200); } sem_post(&sync_sem); /* 通知TaskB:我完成了5次呼吸 */ } } void task_b(void *arg) { while (1) { sem_pend(&sync_sem, 5000); /* 等信号,等5秒没来就超时返回 */ led_on(LED2); delay_ms(200); led_off(LED2); /* 继续循环,等待下一次信号 */ } } void main(void) { /* ... 前面的初始化 ... */ sem_init(&sync_sem, 0); /* 同步信号量,初始为0 */ task_create(task_a, NULL, 5); task_create(task_b, NULL, 4); schedule(); }

这个实验的关键观察点是:TaskB在等待期间不能消耗CPU。你可以在系统里加一个"空闲任务计数器",等信号量期间计数不增长;或者直接观察TaskA的呼吸节奏——如果TaskB在傻转,TaskA的呼吸会变卡顿(因为两个任务在共享CPU)。信号量实现正确的话,TaskA的呼吸节奏应该是稳定不变的。

我还建议你试一下故意写错一个地方:把sem_init(&sync_sem, 0)改成sem_init(&sync_sem, 1)。你会发现LED2在程序启动时立刻闪了一次,因为TaskB最开始就拿到信号量了,这能帮助你理解初始计数值的实际作用。

4.2 实验二:两个任务抢一个计数器——资源共享验证

这个实验更硬核一点。我们设一个全局变量shared_counter,初始为0。TaskA和TaskB都循环10万次,每次shared_counter++。然后我们在主循环里检查最终值。

shared_counter++在C语言中是三步:读、加、写。如果两个任务不加保护同时操作,由于任务切换可能发生在任意两步之间,最终值一定小于20万,而且每次运行结果还不一样。这正好证明竞争条件(race condition)的存在。

然后我们加信号量保护:

sem_t mutex_sem; int shared_counter = 0; void add_task(void *arg) { for (int i = 0; i < 100000; i++) { sem_pend(&mutex_sem, 0); /* 拿锁 */ shared_counter++; sem_post(&mutex_sem); /* 释放锁 */ } while (1) { delay_ms(1000); } } void main(void) { /* ... 初始化 ... */ sem_init(&mutex_sem, 1); /* 做互斥用,初始化为1,代表只有一把锁 */ task_create(add_task, NULL, 6); task_create(add_task, NULL, 7); schedule(); /* 等待两个任务都执行完后,打印 shared_counter */ }

预期现象:没有信号量时,最终结果每次都不一样(比如199532、199287这类),而且明显小于20万;加上信号量后,结果稳定为200000。

注意sem_pend(&mutex_sem, 0)中timeout传0代表永久等待,不超时。这在互斥场景是合理的——拿不到锁就等着,绝对不能超时跳过。

这个实验跑完之后,建议你做一个反向实验:把sem_post故意注释掉,同时两个任务的sem_pend都设了超时(比如100ms)。你会看到两个任务交替打印"拿锁超时",然后一个任务运行完自己的100000步后,另一个任务才能拿锁。在实际项目中,这种"超时后跳过关键操作"的行为往往会引发更隐蔽的bug,所以大部分RTOS的互斥量都是不支持超时的,或者超时后会强制走失败路径。

4.3 调试信号量问题的三板斧

写完了实验,我不得不分享几个我在调试信号量时踩过的坑。这属于那种文档里不会写、但实际开发必然会遇到的经验。

第一板斧:在临界区里打印。我刚开始手搓内核时,总喜欢在sem_pendsem_post里加printf调试信息。结果发现输出错乱,连printf本身都在抢占资源。后来才知道:调试输出也是一种共享资源,它本身也需要互斥。正确的做法是把调试信息放到临界区外面,只把关键计数器(比如signal_lost_count)在临界区内加1,事后统一打印。

第二板斧:关注等待队列状态而不是count。网上很多教程让你看count值,但实际调试时,wait_mask才是最有价值的信息——它直接告诉你哪个任务在等。我习惯在系统异常时打印所有信号量的wait_mask,一眼就能看出是不是有任务死锁了。而count为负的情况下,光看count根本看不出等的是谁。

第三板斧:用超时参数兜底。所有的测试任务,初始阶段一定要给sem_pend传一个非零超时。超时返回后,打印错误信息。这样可以防止系统死锁后连错误都看不到——毕竟死锁时所有任务都挂着,你的调试器都很难下手。等系统稳定了,再改成永久等待,性能会更好。

5. 信号量实战中的深坑:优先级反转、死锁与互斥量的选择

有了能运行的信号量,就能用它去做产品了吗?别急,还有几个在真实应用中必然会遇到的坎,我一次说透。

5.1 优先级反转:一个让高优先级任务"原地待命"的陷阱

所谓优先级反转,就是高优先级任务反而被低优先级任务阻塞住了,而且这个阻塞时间可能长得不可接受。

典型的场景是:TaskHigh(高优先级)和TaskLow(低优先级)共用一个互斥信号量保护某个共享资源。TaskLow先拿到了锁,开始操作资源。此时TaskMid(中等优先级)就绪了——它不使用这个锁——但它的优先级比TaskLow高,于是系统立刻切换到TaskMid。TaskMid是个CPU密集型的活儿,跑了很长时间。TaskHigh一直等锁等到天荒地老。

你看出问题了吗?TaskHigh明明优先级最高,却被一个"不使用共享资源"的TaskMid给拖住了。这就是优先级反转,它在实时系统中是致命的。

解决这个问题的经典方案是优先级继承——当检测到高优先级任务在等一个低优先级任务持有的锁时,临时把低优先级任务的优先级提升到高优先级任务的级别,等它释放锁后再恢复。

FreeRTOS的互斥量(Mutex)就实现了优先级继承算法,而二进制信号量(Binary Semaphore)没有。这就是为什么在FreeRTOS里做互斥时文档反复强调要用Mutex而不是Binary Semaphore的根本原因。在面试中这个问题也经常被问到,你要能把这个场景完完整整讲出来,比背十遍定义都有用。

我在手搓内核里暂时没做优先级继承,但我在数据结构里预留了org_prio这个字段,做优先级继承时需要动态修改TCB的优先级,等下一篇实现互斥量时再展开。

5.2 死锁:两个任务互相等对方手里的锁

死锁的经典场景比优先级反转更直白:TaskA持有了锁1,想拿锁2;TaskB持有了锁2,想拿锁1。两边都在等对方释放,结果谁都没法继续。

教科书上说死锁需要四个条件:互斥、持有并等待、不可剥夺、循环等待。打破任意一个就能预防死锁。在嵌入式环境里的实际建议是:

  • 约定锁的获取顺序。如果所有任务都按"先锁1再锁2"的顺序获取资源,就不会出现循环等待。这听着简单,但在大项目里很难严格执行,所以更实用的方法是下一条。
  • 用超时获取锁。不要让pend变成永久等待,而是给它一个超时。获取失败后,主动释放自己已经持有的其它锁,延时重试。这个策略需要业务代码配合,写起来麻烦一点,但能避免系统全军覆没。
  • 尽量用互斥量而不是二值信号量做资源保护。因为很多互斥量的实现里,还包含"谁持有谁释放"的所有者检查,二者能发现并强制解除死锁。

5.3 二值信号量、计数信号量和互斥量的区别,一张表说清楚

这是我面试必问自己的一个问题,也是很多初学者最混乱的地方。我整理了一张对比表,建议你收藏。

特性二值信号量(Binary Semaphore)计数信号量(Counting Semaphore)互斥量(Mutex)
计数值范围只有0和10到初始化指定的最大值只有0和1
典型用途任务同步、中断与任务通信资源池管理、事件计数保护共享资源、临界区
是否允许给定的任务释放允许任何任务释放允许任何任务释放通常只能由持有者释放
优先级继承有(FreeRTOS等实现)
谁都能释放导致的隐患任务A加的锁可能被任务B释放,逻辑上危险同理有所有者检查,更安全
推荐使用场景一个任务通知另一个任务"事件来了"管理N个相同资源的分配保护需要互斥访问的硬件或数据

信号量在中断中调用post是完全有必要的——比如UART接收中断里post一个信号量,等待数据的任务被唤醒,这是一种非常标准的中断与任务通信方式。但要注意:中断里绝对不能调用pend,因为中断上下文里没有"阻塞"这个概念,一旦阻塞就没人能救你了。这属于RTOS API使用的铁律,写错几乎都会导致系统崩溃。

5.4 实现之后回头看:我们到底往RTOS里加了多少东西

从架构视角看,这一篇给我们的"手搓操作系统"新增了三个内核对象:信号量的数据结构、临界区操作原语、以及阻塞/唤醒机制的完整实现。这三样表面上是"加了一个信号量",实际上是给系统补上了"资源管理"这一大块能力。

在此之前,任务之间是完全隔离的——就像各自独立的单片机程序在轮流跑。有了信号量之后,任务才真正开始“协作”,系统也从"调度器"进化成了"操作系统"。

进程/线程同步领域里,信号量并不是唯一的原语,但它是所有其他同步机制的基础。理解了它的实现,你再去看FreeRTOS的队列、事件组、任务通知,会发现它们本质上都是在"信号量"这个思想上做了延伸和包装。而所有实现背后的关键都是那三件事:数据怎么存、操作怎么原子、阻塞的怎么唤醒。这三个问题回答清楚了,任何同步机制在你眼里都不再是玄学。

后面我会继续用这个套路去写互斥量、事件组和消息队列。到时候你会觉得这些东西越写越顺手,因为它们其实都是"计数器 + 等待队列 + 临界区"这个黄金组合的变体。

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

PHP中echo与return的区别:语言结构、返回值与输出控制详解

1. 先说清楚&#xff1a;echo不是函数&#xff0c;是高仿函数的语言结构早年带过几个实习生&#xff0c;面试的时候问PHP的echo和return有什么区别&#xff0c;十个里得有六七个会愣一下&#xff0c;然后说"echo是输出&#xff0c;return是返回"。这话不算错&#xf…

作者头像 李华
网站建设 2026/9/8 7:31:46

伴随状语全面分析:逻辑主语、位置标点与长难句拆解实战

伴随状语这个东西&#xff0c;从表面上看就是一个再普通不过的语法术语&#xff0c;可实际一到长难句分析、翻译、写作里&#xff0c;它造成的误判数量远超大多数人的想象。同一个句子&#xff0c;有人分析成方式状语&#xff0c;有人分析成定语修饰&#xff0c;还有人直接当成…

作者头像 李华
网站建设 2026/9/8 7:31:36

跨部门数据运营机制如何支撑指标资产规模化复用

导语 指标资产要实现跨部门规模化复用&#xff0c;必须配套建立权责清晰、流程闭环、持续迭代的跨部门数据运营机制&#xff0c;通过明确指标全生命周期各环节的部门权责&#xff0c;搭配适配的权限治理和运营流程&#xff0c;才能保障指标资产从建设到消费全链路协同&#xff…

作者头像 李华
网站建设 2026/9/8 7:28:51

HTML静态旅游网站源码:从零搭建到部署上手指南

简介&#xff1a;面向网页设计初学者、前端学习者以及需要快速搭建静态展示页的建站人员&#xff0c;这套HTML静态旅游网站源码包基于HTML、CSS与JavaScript构建&#xff0c;无需服务端处理即可呈现景点介绍、旅游套餐、预订表单等典型旅游内容&#xff0c;适合直接用于练习或二…

作者头像 李华
网站建设 2026/9/8 7:28:25

Kaggle共享单车需求预测实战:特征工程与时间序列避坑指南

简介&#xff1a;这是一份面向数据科学初学者的Kaggle共享单车需求预测赛题实践代码包&#xff0c;基于Python实现。作者结合Coursera《数据科学导论》课程作业&#xff0c;围绕天气、时间、温度、工作日等特征&#xff0c;探索了10种不同机器学习算法用于逐小时租借量预测。代…

作者头像 李华
网站建设 2026/9/8 7:27:41

AI Agent写SQL时代,数据库选型与查询治理指南

最近和一个团队聊到他们准备给内部系统接入 AI Agents 的需求。第一轮技术评审时&#xff0c;大家花了很长时间争论 MySQL 和 PostgreSQL 的差异&#xff0c;但从我的角度看&#xff0c;这个问题可能被带偏了。当数据库查询语句不再由程序员手写&#xff0c;而是由 LLM 动态生成…

作者头像 李华