LK 博客
RTOS设计与开发(4):信号量、互斥锁、消息队列:同步、互斥与通信的内核视角
嵌入式
约 1 分钟阅读 3 赞 0 条评论 鸿蒙黑体

RTOS设计与开发(4):信号量、互斥锁、消息队列:同步、互斥与通信的内核视角

Yukikaze
Yukikaze @Yukikaze
累计点赞 3 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

在学完异常模型、PendSV 任务切换、Ready/Blocked 状态管理之后,RTOS 的骨架已经搭起来了。

但一个真正可用的 RTOS,不能只会“切任务”,还必须解决三个更实际的问题:

  • 任务之间怎么同步
  • 多个任务访问共享资源时怎么互斥
  • 任务之间怎么传递数据

这正是:

  • Semaphore
  • Mutex
  • Queue

要解决的三类核心问题。

所以这一部分的重点,不只是记 API 名字,而是要看明白:

它们分别在解决什么问题,任务为什么会阻塞在它们上面,内核又是如何唤醒等待者的。

一、先给三者一个总定位

1. Semaphore

解决的是:

“条件是否成立 / 资源是否可用 / 事件是否发生”

它最像“许可证”或“通知信号”。

2. Mutex

解决的是:

“某个共享资源同一时刻只能被一个任务访问”

它最像“锁”或“独占钥匙”。

3. Queue

解决的是:

“一个任务把数据交给另一个任务”

它最像“邮箱”或“消息通道”。

所以可以先记一句最简版:

Semaphore 管同步,Mutex 管互斥,Queue 管通信。

二、Semaphore:同步与资源计数

1. Semaphore 的本质

信号量本质上是一个带计数值的同步对象。

你可以把它理解成:

  • count > 0:还有可用许可

  • count == 0:当前没有许可

  • 一个任务去 take,就是申请一个许可;

  • 一个任务或中断去 give,就是归还一个许可或发出一个通知。

2. Semaphore 最常见的两种用途

用途一:资源计数

比如系统里有 3 个可用缓冲区:

sem_init(count = 3);

每拿一个:

sem_take();

计数减一;每归还一个:

sem_give();

计数加一。

这时 Semaphore 表示的是:

可用资源的数量

用途二:事件同步

比如 ISR 完成了一次 DMA 传输后,通知任务“数据准备好了”:

  • 任务先 sem_take(),等事件
  • ISR 完成后 sem_give()

这时 Semaphore 表示的是:

某个事件发生了

所以 Semaphore 既能做“资源计数”,又能做“事件同步”。

3. Semaphore 的内核行为

sem_take()

任务调用:

sem_take(sem);

内核先看:

  • sem->count > 0 吗?

如果大于 0

说明许可可用:

  • count--
  • 当前任务继续运行

如果等于 0

说明当前拿不到:

  • 当前任务从 ready list 移出
  • 挂到 sem->wait_list
  • 进入 Blocked
  • 调度别的任务运行

sem_give()

当别的任务或 ISR 调用:

sem_give(sem);

内核会先看:

  • sem->wait_list 里有没有等待者?

如果没有等待者

  • count++

如果有等待者

通常会:

  • 从 wait list 中取出等待任务
  • 把它放回 ready list
  • 必要时触发调度

所以 Semaphore 的抽象模型很清楚:

拿不到就阻塞,条件成立就唤醒。

4. Semaphore 的特点

Semaphore 强调的是:

  • 有无
  • 数量
  • 事件通知

但它一般不强调:

  • “这个对象当前属于谁”

这点和 Mutex 有本质区别。

三、Mutex:互斥与临界区保护

1. Mutex 的本质

Mutex 解决的是:

多个任务不能同时进入同一个临界区

比如几个任务都要访问:

  • 全局链表
  • 串口发送缓冲区
  • 文件系统对象
  • 某段共享变量
  • 某个外设寄存器操作序列

这时候不能只靠“希望大家别冲突”,必须显式加锁。

2. Mutex 的基本语义

mutex_lock()

任务申请锁:

mutex_lock(m);

如果锁空闲

  • 当前任务拿到锁
  • 记录 owner = 当前任务
  • 继续运行

如果锁被占用

  • 当前任务拿不到锁
  • 从 ready list 移出
  • 挂到 m->wait_list
  • 阻塞等待

mutex_unlock()

当前持有锁的任务执行:

mutex_unlock(m);

内核会:

  • 释放锁
  • wait_list 是否有人等待
  • 如果有,唤醒一个等待者
  • 将它放回 ready list
  • 必要时触发调度

3. Mutex 最关键的语义:owner

Mutex 和 Semaphore 的最本质区别之一是:

Mutex 有 owner,Semaphore 通常没有 owner

也就是说,Mutex 会明确知道:

  • 当前是谁持有这把锁

于是有一个重要规则:

谁加锁,谁解锁

这和 Semaphore 很不一样。 Semaphore 更像是“资源数值变化”,而 Mutex 更像是“独占关系”。

4. 为什么 Mutex 不能简单等同于 1 值 Semaphore

binary semaphore 和 mutex 看起来很像:

  • 都像只有一个资源
  • 都可能阻塞等待
  • 都可能唤醒别人

但它们内核语义并不一样。

Semaphore 更关心

  • count
  • 事件是否发生
  • 资源是否可用

Mutex 更关心

  • 当前持有者是谁
  • 共享资源是否被独占
  • 临界区是否被正确保护

所以:

binary semaphore 可以做出“类似互斥”的效果,但它不是严格意义上的 mutex。

5. Mutex 为什么更专业:优先级继承

这就是 Mutex 在 RTOS 里最“像样”的地方。

优先级反转问题

假设:

  • L:低优先级任务
  • M:中优先级任务
  • H:高优先级任务

流程如下:

  • L 先拿到了 mutex
  • H 来申请这个 mutex,拿不到,被阻塞
  • M 恰好一直在运行

这时会出现一个很糟糕的现象:

  • H 虽然优先级最高
  • 但它在等 L 释放锁
  • L 又因为 M 抢占而迟迟运行不到解锁那一步

于是:

高优先级任务 H 被中优先级任务 M 间接拖住了

这就是经典的 priority inversion(优先级反转)

优先级继承怎么解决

RTOS 的 Mutex 往往支持:

Priority Inheritance(优先级继承)

也就是:

  • H 在等 L 手里的锁
  • 内核临时把 L 的优先级提升到 H 附近
  • 让 L 先把临界区跑完
  • 尽快 unlock
  • 然后恢复 L 原本优先级

所以:

优先级继承几乎是 Mutex 的标志性能力,而不是普通 Semaphore 的典型能力。

四、Queue:通信与同步的结合体

1. Queue 的本质

Queue 不只是“通知一下”,而是:

把一份数据从发送者交给接收者

比如:

  • 采集任务把传感器数据发给处理任务
  • ISR 把接收到的数据包交给协议栈任务
  • 按键任务把事件发给 UI 任务

Queue 解决的是:

任务之间如何安全、有序地传递消息

2. Queue 为什么比 Semaphore 更复杂

因为 Queue 同时要管理两类东西:

1)数据缓冲区

队列里真的要存放消息内容。

2)等待任务

有人在等着:

所以 Queue 通常不止一个等待队列,而是至少两个:

  • send wait list
  • recv wait list

3. queue_receive()

任务执行:

queue_receive(q, &msg);

如果队列非空

  • 直接取出消息
  • 当前任务继续运行

如果队列为空

  • 当前任务拿不到消息
  • 从 ready list 移出
  • 挂到 q->recv_wait_list
  • 进入 Blocked

所以:

队列空时,接收者阻塞

4. queue_send()

任务执行:

queue_send(q, msg);

如果队列未满

  • 消息成功入队
  • 当前任务继续运行

如果队列已满

  • 当前任务无法再发送
  • 从 ready list 移出
  • 挂到 q->send_wait_list
  • 进入 Blocked

所以:

队列满时,发送者阻塞

5. Queue 最重要的唤醒逻辑

场景一:有人在等接收

如果某个任务已经因为 queue_receive() 阻塞在 recv_wait_list 上,这时别的任务 queue_send() 成功送入消息,内核最关键的动作就是:

  • 把等待接收的任务从 recv_wait_list 中移出
  • 放回 ready list
  • 必要时重新调度

也就是说:

消息一到,就尽快唤醒接收者

场景二:有人在等发送

如果某个任务已经因为队列满而阻塞在 send_wait_list 上,这时别的任务 queue_receive() 取走了一条消息,队列腾出空间,内核通常会:

  • send_wait_list 中取出一个等待发送者
  • 把它放回 ready list
  • 让它有机会继续发送

所以 Queue 的内核逻辑比 Semaphore 更完整:

它要同时管理“消息是否存在”和“空间是否可用”。

五、三者的核心区别总结

现在把三者横着看,会清楚很多。

1. Semaphore:同步 / 通知 / 资源计数

它回答的问题是:

  • 事件到了吗?
  • 资源可用吗?
  • 还有几个许可?

它强调的是:

  • 数量
  • 条件
  • 通知

2. Mutex:互斥 / 独占 / 临界区保护

它回答的问题是:

  • 这把锁现在归谁?
  • 共享资源现在能不能进?
  • 谁在临界区里?

它强调的是:

  • owner
  • 独占
  • 优先级继承

3. Queue:带数据的通信

它回答的问题是:

  • 有没有消息?
  • 消息内容是什么?
  • 发送方和接收方如何同步?

它强调的是:

  • 数据传递
  • 有序缓存
  • 发送/接收双方阻塞与唤醒

4. 一句最简对比

Semaphore 传“有没有”,Mutex 管“归谁用”,Queue 传“是什么”。

六、从 Blocked/Ready 角度看三者

1. 等 Semaphore

任务在等:

许可 / 资源 / 事件

挂到:

  • sem->wait_list

2. 等 Mutex

任务在等:

锁释放

挂到:

  • mutex->wait_list

3. 等 Queue

任务可能在等两种条件:

等收

  • 队列里来消息
  • 挂到 recv_wait_list

等发

  • 队列里腾出空间
  • 挂到 send_wait_list

所以 Queue 比 Semaphore / Mutex 更复杂的一个直观原因就是:

它天然就可能有两类等待者。

七、为什么被唤醒后不一定立刻运行

这个点在同步原语上也必须继续保持清醒。

当某个任务因为:

  • sem_give()
  • mutex_unlock()
  • queue_send()
  • queue_receive()

而被唤醒时,它并不是直接 Running,而是:

  • 从 wait list 中移出
  • 回到 ready list
  • 再由调度器比较优先级

所以:

唤醒只是恢复运行资格,不是直接拿到 CPU

如果被唤醒任务优先级更高,就可能触发 PendSV; 如果不更高,就先在 Ready 中排队。

八、ISR 场景下三者怎么理解

这个问题很常见,也很适合你现在这个阶段理解。

1. ISR 通知任务“完成了”

更像:

  • Semaphore
  • 或 event flag

因为本质是“事件同步”,而不是保护临界区。

2. ISR 不仅通知“完成了”,还要把数据交给任务

更像:

  • Queue

因为已经不是简单通知,而是:

通知 + 传数据

3. Mutex 一般不适合拿来做 ISR 通知

因为 Mutex 强调:

  • owner
  • 谁 lock 谁 unlock
  • 临界区保护

而 ISR 做的往往是:

  • 投递事件
  • 投递数据
  • 唤醒等待任务

所以这类语义更像 Semaphore / Queue,而不是 Mutex。

九、什么时候用谁

1. 只想通知“条件成立了”

比如:

  • DMA 完成
  • 按键中断到了
  • 某硬件事件发生

优先想:

  • Semaphore

2. 要保护共享变量、共享链表、共享设备

比如:

  • 多任务访问同一个 UART 输出
  • 多任务改同一个全局结构
  • 多任务访问同一外设驱动状态

优先想:

  • Mutex

3. 要把数据从 A 交给 B

比如:

  • 采样数据
  • 按键消息
  • 网络包
  • 日志消息

优先想:

  • Queue

4. 一个简单记忆

通知用 Semaphore,保护用 Mutex,传数据用 Queue。

十、把三者放到一条总线上理解

现在可以把它们和前面的调度、阻塞、唤醒完整串起来了:

1. 任务执行某个获取操作

比如:

  • sem_take
  • mutex_lock
  • queue_receive

2. 内核先判断条件是否满足

如果满足:

  • 直接成功
  • 继续运行

如果不满足:

  • 从 Ready 中移出
  • 挂到对象的 wait list
  • 进入 Blocked

3. 某个释放/投递操作发生

比如:

  • sem_give
  • mutex_unlock
  • queue_send

4. 内核唤醒等待者

  • 从 wait list 中移出等待任务
  • 放回 ready list
  • 比较优先级
  • 必要时触发调度与 PendSV

所以这一部分和第三部分不是分开的,而是完美衔接的:

同步原语决定“任务在等什么”,调度器决定“谁该先跑”,PendSV 决定“如何真正切过去”。

作者名片

Yukikaze
Yukikaze
@Yukikaze

私にできることなら何でもするから

评论区
文章作者和管理员都可以管理这里的评论。
0 条评论
登录后即可参与评论。 去登录
还没有评论,欢迎留下第一条交流内容。