互斥锁
共享资源保护 — OSAL 互斥锁
前置阅读:多任务并发 — 需要先理解任务抢占和调度锁的概念
学习目标
- 理解互斥锁的核心用途——保护多任务共享的临界资源
- 掌握
osal_mutex_init(mutex)→osal_mutex_lock(mutex)→osal_mutex_unlock(mutex)的标准调用链 - 理解优先级继承机制——互斥锁如何防止经典的优先级反转问题
- 能够识别死锁风险并使用
osal_mutex_lock_timeout超时退出
基本概念
互斥锁解决什么问题
当多个任务访问同一个共享资源时,需要保证"原子性"——一个任务访问期间,其他任务不能插进来。
sequenceDiagram
participant A as 任务A
participant M as 互斥锁
participant B as 任务B
participant U as 串口
A->>M: mutex_lock — 拿到锁
A->>U: printf TaskA: Hello
Note over B: 也想 printf,抢锁
B->>M: mutex_lock — 阻塞等待
A->>U: printf World
A->>M: mutex_unlock — 释放锁
M->>B: 锁现在归 B
B->>U: printf TaskB: Hello World
B->>M: mutex_unlock
优先级反转与优先级继承
互斥锁最经典的陷阱是优先级反转。看下面这个场景:
sequenceDiagram
participant C as 任务C 低 prio=10
participant B as 任务B 中 prio=6
participant A as 任务A 高 prio=3
participant M as 互斥锁
C->>M: lock — C 持有锁
Note over C: C 在访问共享资源...
Note over A: A 就绪,抢占 C
A->>M: lock — 阻塞等待!C 还拿着锁
Note over B: B 就绪,抢占 C
B->>B: 运行中... 不需要锁
Note over C: C 无法运行,锁无法释放
Note over A: A 被 B 间接阻塞——这就是反转
Note over M: ═══ 启用优先级继承后 ═══
Note over C: 内核将 C 临时提升到 A 的优先级
Note over B: B 无法抢占 C
C->>C: 快速完成,释放锁
M->>A: A 拿到锁
优先级反转导致高优先级任务被中优先级任务间接阻塞,可能让系统实时性崩溃。互斥锁的优先级继承机制自动解决这个问题——信号量没有这个机制,这是选择互斥锁的关键理由。
三种保护的对比
| 对比项 | 互斥锁 | 调度锁 | 关中断 |
|---|---|---|---|
| 禁止什么 | 其他任务访问同一个锁保护的资源 | 所有任务调度 | 所有中断 + 调度 |
| 其他任务能运行 | 能(访问不同锁/无锁区域) | 不能 | 不能 |
| 允许 ISR | 允许 | 允许 | 不允许 |
| 持锁上限 | 不限(但越短越好) | < 1ms | < 10μs |
| 优先级继承 | 有 | 无 | 无 |
| 典型场景 | 保护 printf / SPI / 配置结构体 | 保护一个标志位 | 保护 ISR 与任务共享的变量 |
大多数场景用互斥锁。只在保护极短临界区(< 1μs)且不需要优先级继承时才用调度锁。
死锁的避免
当两个任务互相等对方释放锁时发生死锁:
三条规则避免死锁:
- 统一加锁顺序——所有任务按相同顺序加锁(如永远先锁 A 再锁 B)
- 使用超时加锁——
osal_mutex_lock_timeout(&mutex, 100)超时后退出并报错 - 减少锁持有时间——不在锁内调
osal_msleep,不在锁内调可能阻塞的 API
涉及 API
| API | 谁调用 | 用途 | 头文件 |
|---|---|---|---|
osal_mutex_init(osal_mutex *mutex) |
入口任务 | 初始化互斥锁 | osal_mutex.h |
osal_mutex_lock(osal_mutex *mutex) |
任务A/B | 加锁——阻塞等待直到拿到锁 | osal_mutex.h |
osal_mutex_lock_timeout(osal_mutex *mutex, unsigned int timeout) |
任务A/B | 加锁——超时返回,timeout 单位 ms | osal_mutex.h |
osal_mutex_unlock(osal_mutex *mutex) |
任务A/B | 解锁——释放锁,唤醒等待者 | osal_mutex.h |
互斥锁有优先级继承,信号量没有——这是选择互斥锁的关键理由。保护共享资源时优先用互斥锁。
案例说明
做什么
两个任务同时向串口打印字符串。先看不加锁时的输出交叉混乱,再看加锁后输出整齐。用肉眼可见的效果直观理解互斥锁的作用。
这个案例演示的是嵌入式开发中最常见的问题之一——"为什么我的串口日志是乱的?"
规格与功能
| 规格项 | 任务A | 任务B |
|---|---|---|
| 优先级 | OSAL_TASK_PRIORITY_MIDDLE(6) |
同(6) |
| 栈大小 | 4096 字节 | 4096 字节 |
| 职责 | 循环打印 "TaskA: Hello World" | 循环打印 "TaskB: Hello World" |
| 共享资源 | 串口(隐含在 printf 中) |
|
| 保护方式 | 全局互斥锁 g_print_mutex |
程序运行流程:
- 创建互斥锁 → 创建任务A → 创建任务B
- 两个任务同优先级,时间片轮转执行
- 不加锁时:A 打印到一半被 B 抢占 → 输出交叉混乱
- 加锁后:A 拿到锁 → 完整打印 → 释放 → B 拿到锁 → 完整打印
案例流程
sequenceDiagram
participant A as 任务A
participant B as 任务B
participant M as 互斥锁
participant U as 串口
Note over A,U: ═══ 不加锁 — 输出交叉 ═══
A->>U: printf TaskA: Hel
B->>U: printf TaskB: Hel
Note over U: 输出: TaskA: HelTaskB: Hellolo World
Note over A,U: ═══ 加锁 — 输出完整 ═══
A->>M: lock
A->>U: printf TaskA: Hello World
A->>M: unlock
B->>M: lock
B->>U: printf TaskB: Hello World
B->>M: unlock
Note over U: 输出: TaskA Hello World / TaskB Hello World
案例操作指导
第一步:编译
第二步:烧录
第三步:验证
先编译运行不带互斥锁的版本,观察串口输出:
字符串交叉,无法辨认。
再编译运行带互斥锁的版本:
每行完整,清晰可读。
关键配置
| 参数 | 值 | 说明 |
|---|---|---|
| 互斥锁超时 | 100ms | 用 lock_timeout 替代 lock,防止死锁永久挂起 |
| 锁持有时间 | 越短越好 | 只在实际访问共享资源时持有,不在锁内调 osal_msleep |
| 加锁顺序 | 全局统一 | 所有任务按相同顺序加多个锁(如永远先 A 后 B) |
| 任务栈 | 4096 | printf 内部有较大的栈消耗,给足余量 |
代码详解
互斥锁初始化
#include "osal_mutex.h"
static osal_mutex g_print_mutex; // 互斥锁对象
/* 入口中初始化 */
osal_mutex_init(&g_print_mutex);
不加锁的版本——输出交叉混乱
/* 任务A 和 任务B 都直接 printf,不加任何保护 */
static int task_a_handler(void *data)
{
(void)data;
while (1) {
printf("TaskA: Hello World\n"); // 可能被 B 打断
osal_msleep(100);
}
return 0;
}
static int task_b_handler(void *data)
{
(void)data;
while (1) {
printf("TaskB: Hello World\n"); // 可能被 A 打断
osal_msleep(100);
}
return 0;
}
/* 结果:两个 printf 随机交叉,输出混乱 */
加锁的版本——输出完整
static int task_a_handler(void *data)
{
(void)data;
while (1) {
osal_mutex_lock(&g_print_mutex); // 拿锁
printf("TaskA: Hello World\n"); // 安全打印
osal_mutex_unlock(&g_print_mutex); // 放锁
osal_msleep(100);
}
return 0;
}
static int task_b_handler(void *data)
{
(void)data;
while (1) {
osal_mutex_lock(&g_print_mutex); // 拿锁——等 A 释放
printf("TaskB: Hello World\n"); // 安全打印
osal_mutex_unlock(&g_print_mutex); // 放锁
osal_msleep(100);
}
return 0;
}
/* 结果:每次打印都完整,不会交叉 */
超时加锁——防止死锁
/* 用超时版本避免永久阻塞 */
if (osal_mutex_lock_timeout(&g_print_mutex, 100) != 0) {
printf("lock timeout!\n"); // 100ms 还没拿到锁,报错退出
return -1;
}
/* 拿到锁,安全操作 */
printf("TaskA: Hello World\n");
osal_mutex_unlock(&g_print_mutex);
死锁场景演示——为什么统一加锁顺序重要
/* 危险写法——任务A 和 任务B 用相反的加锁顺序 */
/* 任务A: 先锁 A 再锁 B */
osal_mutex_lock(&mutex_a);
osal_msleep(10); // 给 B 时间拿到 mutex_b
osal_mutex_lock(&mutex_b); // 等 B 释放 mutex_b...
/* 任务B: 先锁 B 再锁 A */
osal_mutex_lock(&mutex_b);
osal_mutex_lock(&mutex_a); // 等 A 释放 mutex_a...
/* 结果:A 等 B 放 mutex_b,B 等 A 放 mutex_a → 死锁! */
/* 正确:两个任务都用相同顺序(如永远先锁 A 再锁 B) */
死锁的典型特征是系统"无声无息地卡死"——没有 crash,没有 panic,任务就是不再执行了。用
lock_timeout可以检测并恢复。