目录
1. 信号量的基本概念
在了解信号量的概念之前,我们先来回忆一下,当我们在逻辑编程的时候,时候使用过这样一个变量:用于标记某个事件是否发生,或者标志一下某个东西是否正在被使用,如果是被占用了的或者没发生,我们就不对它进行操作。
那么当我们来到操作系统,信号量的概念就是:信号量(Semaphore)是一种实现任务间通信的机制,可以实现任务之间同步或临界资源的互斥访问,常用于协助一组相互竞争的任务来访问临界资源。在多任务系统中,各任务之间需要同步或互斥实现临界资源的保护,信号量功能可以为用户提供这方面的支持。
抽象的来讲,信号量是一个非负整数,所有获取它的任务都会将该整数减一(获取它当然是为了使用资源),当该整数值为零时,所有试图获取它的任务都将处于阻塞状态。通常一个信号量的计数值用于对应有效的资源数,表示剩下的可被占用的互斥资源数。其值的含义分两种情况:
- 0:表示没有积累下来的释放信号量操作,且有可能有在此信号量上阻塞的任务。
- 正值,表示有一个或多个释放信号量操作。
2. 分类
2.1 二值信号量
二值信号量是一种特殊的信号量,其内部状态只有两种:被释放(释放状态)或被占用(占用状态)。为什么叫二值信号量呢?因为信号量资源被获取了,信号量值就是 0,信号量资源被释放,信号量值就是 1,把这种只有 0 和 1 两种情况的信号量称之为二值信号量。
可以将二值信号量看作只有一个消息的队列,因此这个队列只能为空或满(因此称为二
值),我们在运用的时候只需要知道队列中是否有消息即可,而无需关注消息是什么。
二值信号量既可以用于临界资源访问,也可用于同步功能。
而他如何实现任务键的同步呢?举个例子,假设现在又几个任务正在运行,而此时我们想要将任务一和任务二进行同步,那么我们首先创建信号量,在创建后应被置为空,任务 1 获取信号量而进入阻塞,任务 2 在某种条件发生后,释放信号量,于是任务 1 获得信号量得以进入就绪态,如果任务 1 的优先级是最高的,那么就会立即切换任务,从而达到了两个任务间的同步。同样的,在中断服务函数中释放信号量,任务 1 也会得到信号量,从而达到任务与中断间的同步。
2.2 计数信号量
二值信号量可以被认为是长度为 1 的队列,而计数信号量则可以被认为长度大于 1的队列,信号量使用者依然不必关心存储在队列中的消息,只需关心队列是否有消息即可。
计数信号量顾名思义,就是计数的,在实际的使用中,我们常将计数信号量用于事件计数与资源管理。
- 每当某个事件发生时,任务或者中断将释放一个信号量(信号量计数值加 1);
- 当处理被事件时(一般在任务中处理),处理任务会取走该信号量(信号量计数值减 1)。
信号量的计数值则表示还有多少个事件没被处理。
此外,系统还有很多资源,我们也可以使用计数信号量进行资源管理,信号量的计数值表示系统中可用的资源数目,任务必须先获取到信号量才能获取资源访问权,当信号量的计数值为零时表示系统没有可用的资源,但是要注意,在使用完资源的时候必须归还信号量,否则当计数值为 0的时候任务就无法访问该资源了。
2.3 互斥信号量
互斥信号量其实是特殊的二值信号量,由于其特有的优先级继承机制从而使它更适用于简单互锁,也就是保护临界资源。
用作互斥时,信号量创建后可用信号量个数应该是满的,任务在需要使用临界资源时,(临界资源是指任何时刻只能被一个任务访问的资源),先获取互斥信号量,使其变空,这样其他任务需要使用临界资源时就会因为无法获取信号量而进入阻塞,从而保证了临界资源的安全。
2.4 递归信号量
递归嘛,就是可以重复获取调用的,正常信号量是用一个少一个,但是递归不然,用户可以对已经获取的递归信号量进行再次获取该信号量,拥有信号量的所有权,不过成功获取几次就要返回几次。
3. 应用场景
3.1 二值信号量
首先是在中断当中使用,前面我们也说了,二值信号量非0即1,其裸机的案例,例如我们串口读取传感器数据,当我们串口接收完数据后,不知道下一次什么时候数据发送过来,这些接收的数据也需要进行后续的处理,我们通常会创建一个标志位,当这个标志位为1时表示串口接收到数据,我们根据标志位在外面进行数据处理,处理完后将标志位置0,后面如果一直没有数据进来,标志位一直为0,是不是就类似进入阻塞态,当标志位为1才能运行。
引申到FreeRTOS中,在串口接收中,我们不知道啥时候有数据发送过来,有一个任务是做接收这些数据处理,总不能在任务中每时每刻都在任务查询有没有数据到来,那样会浪费 CPU 资源,所以在这种情况下我们使用二值信号量,当没有数据到来的时候,任务就进入阻塞态,不参与任务的调度,等到数据到来了,释放一个二值信号量,任务就立即从阻塞态中解除,进入就绪态,然后运行的时候处理数据,这样子系统的资源就会很好的被利用起来。
前面说了在中断当中的运用,下面我们来举一个任务和任务之间同步的例子,例如:
假设我们有一个温湿度的传感器,假设是 1s 采集一次数据,那么我们让他在液晶屏中显示数据出来,这个周期也是要 1s 一次的,如果液晶屏刷新的周期是 100ms 更新一次,那么此时的温湿度的数据还没更新,液晶屏根本无需刷新,只需要在 1s 后温湿度数据更新的时候刷新即可,否则 CPU 就是白白做了多次的无效数据更新,CPU的资源就被刷新数据这个任务占用了大半,造成 CPU 资源浪费,如果液晶屏刷新的周期是 10s更新一次,那么温湿度的数据都变化了 10 次,液晶屏才来更新数据,那拿这个产品有啥用,根本就是不准确的,所以,还是需要同步协调工作,在温湿度采集完毕之后,进行液晶屏数据的刷新,这样子,才是最准确的,并且不会浪费 CPU的资源。
3.2 计数信号量
这个有点类似:生产者-消费者模型。
比如有一个停车场,里面只有 100 个车位,那么能停的车只有 100 辆,也相当于我们的信号量有 100 个,假如一开始停车场的车位还有 100 个,那么每进去一辆车就要消耗一个停车位,车位的数量就要减一,对应的,我们的信号量在使用之后也需要减一,当停车场停满了 100 辆车的时候,此时的停车位为 0,再来的车就不能停进去了,否则将造成事故,也相当于我们的信号量为 0,后面的任务对这个停车场资源的访问也无法进行,当有车从停车场离开的时候,车位又空余出来了,那么,后面的车就能停进去了,我们信号量的操作也是一样的,当我们释放了这个资源,后面的任务才能对这个资源进行访问。
3.3 互斥信号量
互斥量又称互斥信号量(本质是信号量),是一种特殊的二值信号量。相较于二值信号量,互斥量更多用于保护资源的互锁。
其相较于二值信号量的区别在于,互斥量具有“所有权”概念,只有获取(Take)Mutex 的任务才能释放(Give)它,防止其他任务误释放锁。而二值信号量无所有权,任何任务或中断均可调用 xSemaphoreGive(),即使未先调用 Take。
除此之外,互斥量能够进行优先级翻转,而二值信号量没有此功能(优先级翻转机制将会在下面进行描述)。
3.4 递归信号量
任务需要递归删除SD卡中的目录及其子目录,涉及多层嵌套的文件系统API调用。
又或者任务执行一个复杂状态机,其中某些状态的处理函数需要调用其他状态函数,且所有操作需保护共享数据。
4. 运作机制
4.1 二值信号量
创建信号量时,系统会为创建的信号量对象分配内存,并把可用信号量初始化为用户自定义的个数,二值信号量的最大可用信号量个数为 1。
二值信号量获取,任何任务都可以从创建的二值信号量资源中获取一个二值信号量,获取成功则返回正确,否则任务会根据用户指定的阻塞超时时间来等待其它任务/中断释放信号量。在等待这段时间,系统将任务变成阻塞态,任务将被挂到该信号量的阻塞等待列表中。
在二值信号量无效的时候,假如此时有任务获取该信号量的话,那么任务将进入阻塞状态:
假如某个时间中断/任务释放了信号量,那么,由于获取无效信号量而进入阻塞态的任务将获得信号量并且恢复为就绪态:
二值信号量运作机制:
4.2 计数信号量
计数信号量可以用于资源管理,允许多个任务获取信号量访问共享资源,但会限制任务的最大数目。访问的任务数达到可支持的最大数目时,会阻塞其他试图获取该信号量的任务,直到有任务释放了信号量。这就是计数型信号量的运作机制,虽然计数信号量允许多个任务访问同一个资源,但是也有限定,比如某个资源限定只能有 3 个任务访问,那么第 4 个任务访问的时候,会因为获取不到信号量而进入阻塞,等到有任务(比如任务 1)释放掉该资源的时候,第 4 个任务才能获取到信号量从而进行资源的访问,其运作的机制:
4.3 互斥信号量
用互斥量处理不同任务对临界资源的同步访问时,任务想要获得互斥量才能进行资源访问,如果一旦有任务成功获得了互斥量,则互斥量立即变为闭锁状态,此时其他任务会因为获取不到互斥量而不能访问这个资源,任务会根据用户自定义的等待时间进行等待,直到互斥量被持有的任务释放后,其他任务才能获取互斥量从而得以访问该临界资源,此时互斥量再次上锁,如此一来就可以确保每个时刻只有一个任务正在访问这个临界资源,保证了临界资源操作的安全性:
①:因为互斥量具有优先级继承机制,一般选择使用互斥量对资源进行保护,如果资源被占用的时候,无论是什么优先级的任务想要使用该资源都会被阻塞。
②:假如正在使用该资源的任务 1 比阻塞中的任务 2 的优先级还低,那么任务 1 将被系统临时提升到与高优先级任务 2 相等的优先级(任务 1 的优先级从 L 变成 H)。
③:当任务 1 使用完资源之后,释放互斥量,此时任务 1 的优先级会从 H 变回原来的 L。
④⑤:任务 2 此时可以获得互斥量,然后进行资源的访问,当任务 2 访问了资源的时候,该互斥量的状态又为闭锁状态,其他任务无法获取互斥量。
4.4 递归信号量
递归信号量是专为嵌套锁需求设计的同步机制,其核心在于允许同一任务多次获取锁,并通过内部计数器管理锁状态。
任务A(高优先级)
│
├─ 获取递归锁(TakeRecursive) → [锁计数器=1, 持有者=任务A]
│ ├─ 进入临界区
│ ├─ 调用嵌套函数
│ │ ├─ 再次获取递归锁(TakeRecursive) → [锁计数器=2, 持有者=任务A]
│ │ ├─ 操作共享资源
│ │ └─ 释放递归锁(GiveRecursive) → [锁计数器=1]
│ └─ 释放递归锁(GiveRecursive) → [锁计数器=0, 锁释放]
│
└─ 其他任务可竞争锁
任务B(低优先级)───────────────────┐
│
(任务A持有锁期间,任务B尝试获取锁)→ 阻塞等待
图示案例解析
任务A(高优先级)首次获取递归锁:成为持有者,计数器=1。
任务A在临界区内调用嵌套函数,再次获取锁:计数器增至2,仍由任务A持有。
任务B(低优先级)尝试获取锁:因锁被任务A持有,任务B阻塞。
任务A逐层释放锁:每次GiveRecursive减少计数器,直到0时完全释放锁。
任务B被唤醒:锁可用后,任务B获取锁(若其优先级最高)。
5. 互斥量优先级继承机制
在 FreeRTOS 操作系统中为了降低优先级翻转问题利用了优先级继承算法。优先级继承算法是指,暂时提高某个占有某种资源的低优先级任务的优先级,使之与在所有等待该资源的任务中优先级最高那个任务的优先级相等,而当这个低优先级任务执行完毕释放该资源时,优先级重新回到初始设定值。因此,继承优先级的任务避免了系统资源被任何中间优先级的任务抢占。
互斥量与二值信号量最大的不同是:互斥量具有优先级继承机制,而信号量没有。也就是说,某个临界资源受到一个互斥量保护,如果这个资源正在被一个低优先级任务使用,那么此时的互斥量是闭锁状态,也代表了没有任务能申请到这个互斥量,如果此时一个高优先级任务想要对这个资源进行访问,去申请这个互斥量,那么高优先级任务会因为申请不到互斥量而进入阻塞态,那么系统会将现在持有该互斥量的任务的优先级临时提升到与高优先级任务的优先级相同,这个优先级提升的过程叫做优先级继承。这个优先级继承机制确保高优先级任务进入阻塞状态的时间尽可能短,以及将已经出现的“优先级翻转”危害降低到最小。
5.1 优先级翻转
我们知道任务的优先级在创建的时候就已经是设置好的,高优先级的任务可以打断低优先级的任务,抢占 CPU 的使用权。但是在很多场合中,某些资源只有一个,当低优先级任务正在占用该资源的时候,即便高优先级任务也只能乖乖的等待低优先级任务使用完该资源后释放资源。这里高优先级任务无法运行而低优先级任务可以运行的现象称为“优先级翻转”。
没理解的话,我们附张图对图进行理解,下面是优先级翻转的图解:
① L 任务正在使用某临界资源, H 任务被唤醒,执行 H 任务。但 L 任务并未执行完毕,此时临界资源还未释放。
② 这个时刻 H 任务也要对该临界资源进行访问,但 L 任务还未释放资源, 由于保护机制,H 任务进入阻塞态,L任务得以继续运行,此时已经发生了优先级翻转现象。
③ 某个时刻 M 任务被唤醒,由于 M 任务的优先级高于 L 任务, M 任务抢占了 CPU 的使用权,M任务开始运行,此时 L 任务尚未执行完,临界资源还没被释放。
④ M 任务运行结束,归还 CPU 使用权,L 任务继续运行。
⑤ L 任务运行结束,释放临界资源,H 任务得以对资源进行访问,H 任务开始运行。
以上流程可以看出,由于保护机制,高优先级的任务 H 想要访问和 L 相同的临界资源,但是 L 未释放,H 就需要等 L 释放,但是若是此时比 L 更高的优先级的任务发生抢占,占用CPU资源,那么作为最高优先级的任务 H 就需要等待,比自己优先级低,但是比 L 优先级高的任务先执行,等这些任务执行完,才会执行任务 L,等任务 L 运行结束释放完临界资源,此时 H 才会开始运行,这样若是比 L 优先级高的任务过多,那么,到执行 H 任务的时间会被延长,这样就会出现,一种现象最高优先级的 H ,需要一直等待比自己低的任务先执行。在这过程中,H 任务的等待时间过长,这对系统来说这是很致命的,所以这种情况不允许出现,而互斥量就是用来降低优先级翻转的产生的危害。
5.2 优先级继承
在 H 任务申请该资源的时候,由于申请不到资源会进入阻塞态,那么系统就会把当前正在使用资源的 L 任务的优先级临时提高到与 H 任务优先级相同,此时 M 任务被唤醒了,因为它的优先级比 H 任务低,所以无法打断 L 任务,因为此时 L 任务的优先级被临时提升到 H,所以当 L 任务使用完该资源了,进行释放,那么此时 H 任务优先级最高,将接着抢占 CPU 的使用权, H 任务的阻塞时间仅仅是 L 任务的执行时间,此时的优先级的危害降到了最低:
① L 任务正在使用某临界资源, H 任务被唤醒,执行 H 任务。但 L 任务并未执行完毕,此时临界资源还未释放。
② 这个时刻 H 任务也要对该临界资源进行访问,但 L 任务还未释放资源, 由于保护机制,H 任务进入阻塞态,此时由于优先级继承机制,将 L 任务的优先级拉倒和 H 任务的优先级一样。
③ 某个时刻 M 任务被唤醒,虽然 M 优先级比最初的 L 任务的优先级高,但是,此时 L 的优先级被拉到和 H 任务的优先级一样,所以 M 处于就绪态。
④ L 任务运行结束,释放临界资源,L 任务的优先级回到最初的状态。
⑤ 此时任务 H 和 M均处于就绪态,但是 H 的优先级比 M 的高,且 L 任务已经运行结束,释放临界资源,因此 H 任务得以对资源进行访问,H 任务开始运行。
⑥ M 任务比 L 任务的优先级高,M 任务执行。
6. 区别
6.1 二值信号量(Binary Semaphore)
特点:
只有0(不可用)和1(可用)两种状态。
无所有权概念:任何任务/中断均可Give,即使未先Take。
初始状态为空:需先Give才能Take。
使用场景:
✅ 任务间事件通知(如按键触发)
✅ 中断与任务同步(如串口接收完成)
❌ 不适用于资源保护(无优先级继承)
6.2 计数信号量(Counting Semaphore)
特点:
计数范围≥0:表示可用资源数量(如空闲缓冲区数)。
无所有权限制:生产者Give,消费者Take。
使用场景:
✅ 资源池管理(如缓冲区块、DMA通道)
✅ 生产者-消费者模型
❌ 不适用于互斥访问
6.3 互斥信号量(Mutex)
特点:
严格互斥访问:同一时间仅一个任务可持有锁。
优先级继承:防止低优先级任务阻塞高优先级任务。
初始状态为可用:创建后可直接Take。
使用场景:
✅ 保护共享资源(全局变量、硬件外设)
✅ 防止数据竞争
❌ 不可递归获取
6.4 递归信号量(Recursive Mutex)
特点:
允许同一任务多次获取锁:内部计数器记录Take次数。
必须匹配释放:Give次数等于Take次数才会真正释放。
无优先级继承:可能引发优先级反转。
使用场景:
✅ 递归函数保护(如文件系统递归删除)
✅ 嵌套调用临界区
❌ 不适用于简单互斥
特性 | 二值信号量 | 计数信号量 | 互斥信号量 | 递归信号量 |
---|---|---|---|---|
核心用途 | 事件通知 | 资源池管理 | 共享资源保护 | 嵌套锁保护 |
初始状态 | 空(0) | 可设置(N) | 可用(1) | 可用(1) |
所有权 | 无 | 无 | 有(仅持有者能释放) | 有(仅持有者能释放) |
优先级继承 | ❌ | ❌ | ✅ | ❌ |
递归获取 | ❌ | ❌ | ❌ | ✅ |
适用场景 | 中断-任务同步 | 缓冲区管理 | SPI/I2C外设保护 | 递归文件操作 |
如何选择?
- 需要事件通知? → 二值信号量
- 管理有限资源? → 计数信号量
- 保护共享数据? → 互斥信号量
- 处理递归调用? → 递归信号量