信号量与互斥量¶
在多任务系统中,任务间需要同步执行顺序和保护共享资源。FreeRTOS 提供了信号量(Semaphore)用于同步,互斥量(Mutex)用于互斥访问,它们是 RTOS 编程中最重要的同步原语。
信号量(Semaphore)¶
什么是信号量?¶
信号量可以理解为一个计数器:
- 释放(Release/Give):计数器 +1
- 获取(Acquire/Take):计数器 -1(如果计数器为 0,则阻塞等待)
根据计数器的最大值,分为两种:
| 类型 | 最大计数值 | 主要用途 |
|---|---|---|
| 二值信号量 | 1 | 事件通知、中断同步 |
| 计数信号量 | N | 资源计数、限制并发数 |
二值信号量(Binary Semaphore)¶
只有 0 和 1 两个状态,像一个"开关":
sequenceDiagram
participant ISR as 中断(生产者)
participant Sem as 二值信号量
participant Task as 任务(消费者)
Note over Sem: 初始值 = 0
Task->>Sem: Acquire(获取)
Note over Task: 阻塞等待...
ISR->>Sem: Release(释放)
Note over Sem: 值: 0→1
Sem->>Task: 唤醒!
Note over Task: 继续执行
最典型的用法:中断通知任务
/* CubeMX 中创建二值信号量:BinarySem */
osSemaphoreId_t BinarySemHandle;
/* 外部中断回调(按键按下) */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == GPIO_PIN_0)
{
/* 在中断中释放信号量,通知任务 */
osSemaphoreRelease(BinarySemHandle);
}
}
/* 按键处理任务 */
void Button_Task(void *argument)
{
for (;;)
{
/* 等待信号量(阻塞,不消耗 CPU) */
if (osSemaphoreAcquire(BinarySemHandle, osWaitForever) == osOK)
{
/* 信号量获取成功,说明按键被按下 */
HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
/* 简单消抖 */
osDelay(50);
}
}
}
为什么不在中断里直接处理?
- 中断处理应尽量短而快,不能调用阻塞 API
- 复杂处理(如串口发送、屏幕刷新)应在任务中进行
- 信号量 = "中断说发生了什么" → "任务来处理"
CubeMX 创建信号量¶
- Middleware → FREERTOS → Timers and Semaphores 标签页
- 在 Binary Semaphores 或 Counting Semaphores 区域点击 Add
- 配置:
| 参数 | 二值信号量 | 计数信号量 |
|---|---|---|
| Semaphore Name | BinarySem |
CountingSem |
| Count | - | 最大计数值(如 3) |
计数信号量(Counting Semaphore)¶
计数值可以大于 1,用于资源计数场景:
/* CubeMX 中创建计数信号量:最大值=3,初始值=3 */
osSemaphoreId_t ParkingSemHandle;
/* 模拟停车场管理(3个车位) */
void Car_Arrive_Task(void *argument)
{
for (;;)
{
/* 尝试获取车位(计数 -1) */
osStatus_t status = osSemaphoreAcquire(ParkingSemHandle, 5000);
if (status == osOK)
{
printf("停车成功,剩余车位:%lu\r\n",
osSemaphoreGetCount(ParkingSemHandle));
// 模拟停车一段时间后离开
osDelay(3000);
osSemaphoreRelease(ParkingSemHandle); // 离开,释放车位
printf("车辆离开,剩余车位:%lu\r\n",
osSemaphoreGetCount(ParkingSemHandle));
}
else
{
printf("停车场已满,等待超时!\r\n");
}
osDelay(1000);
}
}
互斥量(Mutex)¶
为什么需要互斥量?¶
当多个任务访问同一个共享资源(如串口、I2C、全局变量)时,必须保证同一时刻只有一个任务在操作:
// ⚠️ 危险:两个任务同时使用串口
void Task1(void *argument)
{
for (;;)
{
HAL_UART_Transmit(&huart1, "Hello from Task1\r\n", 18, 100);
// ↑ 如果传输过程中被 Task2 抢占...
osDelay(100);
}
}
void Task2(void *argument)
{
for (;;)
{
HAL_UART_Transmit(&huart1, "Hello from Task2\r\n", 18, 100);
// ↑ ...Task2 也来操作串口,数据就乱了!
osDelay(200);
}
}
输出可能变成:Hello froHello from Task2\r\nm Task1\r\n(数据混乱)
互斥量 vs 二值信号量¶
虽然互斥量看起来和二值信号量很像(都是 0/1),但有关键区别:
| 特性 | 二值信号量 | 互斥量 |
|---|---|---|
| 用途 | 同步(通知事件) | 互斥(保护资源) |
| 谁释放 | 任何任务/中断都可以释放 | 只有获取者才能释放 |
| 优先级继承 | ❌ 无 | ✅ 有(防止优先级反转) |
| 可在 ISR 中使用 | ✅ | ❌ |
| 初始状态 | 空(0) | 满(1,可直接获取) |
CubeMX 创建互斥量¶
- Middleware → FREERTOS → Mutexes 标签页
- 点击 Add
- 命名如
UartMutex
使用互斥量保护共享资源¶
/* CubeMX 中创建互斥量:UartMutex */
osMutexId_t UartMutexHandle;
/* 封装一个线程安全的串口发送函数 */
void SafeUartPrint(const char *msg)
{
/* 获取互斥量(加锁) */
if (osMutexAcquire(UartMutexHandle, osWaitForever) == osOK)
{
HAL_UART_Transmit(&huart1, (uint8_t*)msg, strlen(msg), 100);
/* 释放互斥量(解锁) */
osMutexRelease(UartMutexHandle);
}
}
/* 现在多个任务可以安全地使用串口 */
void Task1(void *argument)
{
for (;;)
{
SafeUartPrint("Hello from Task1\r\n"); // 安全!
osDelay(100);
}
}
void Task2(void *argument)
{
for (;;)
{
SafeUartPrint("Hello from Task2\r\n"); // 安全!
osDelay(200);
}
}
sequenceDiagram
participant T1 as Task1(高优先级)
participant M as 互斥量
participant T2 as Task2(低优先级)
T2->>M: Acquire ✅(获取成功)
Note over T2: 正在使用串口...
T1->>M: Acquire ❌(被占用)
Note over T1: 阻塞等待...
T2->>M: Release(释放)
M-->>T1: 唤醒!
T1->>M: Acquire ✅(获取成功)
Note over T1: 开始使用串口
优先级反转¶
什么是优先级反转?¶
这是使用互斥量时的经典陷阱:
sequenceDiagram
participant H as 高优先级任务
participant M as 中优先级任务
participant L as 低优先级任务
participant Mutex as 互斥量
L->>Mutex: 获取互斥量 ✅
Note over L: 持有互斥量,使用资源...
H->>Mutex: 获取互斥量 ❌(被L占用)
Note over H: 阻塞等待L释放...
Note over M: 中优先级就绪!
M->>M: 抢占L运行
Note over L: 被M抢占,无法释放互斥量
Note over H: 还在等...H被M间接阻塞了!
Note right of H: ⚠️ 优先级反转!<br>高优先级被中优先级阻塞
后果:高优先级任务被中优先级任务间接阻塞,违反了优先级调度原则。
优先级继承¶
FreeRTOS 的互斥量自动支持优先级继承来解决这个问题:
sequenceDiagram
participant H as 高优先级任务
participant M as 中优先级任务
participant L as 低优先级任务
participant Mutex as 互斥量
L->>Mutex: 获取互斥量 ✅
Note over L: 持有互斥量...
H->>Mutex: 获取互斥量 ❌
Note over L: 优先级被提升到和H一样!
Note over M: 中优先级就绪,但无法抢占L
L->>Mutex: 释放互斥量
Note over L: 优先级恢复原来的低
Mutex-->>H: 获取成功
H->>H: 立即执行
互斥量自动处理优先级继承
当高优先级任务等待一个被低优先级任务持有的互斥量时,低优先级任务的优先级会被临时提升到和高优先级任务一样,从而不会被中优先级任务抢占,尽快释放互斥量。
递归互斥量¶
普通互斥量不能在同一个任务中重复获取(会死锁)。递归互斥量允许同一任务多次获取:
/* CubeMX 中创建递归互斥量 */
osMutexId_t RecursiveMutexHandle;
const osMutexAttr_t RecursiveMutex_attr = {
.name = "RecursiveMutex",
.attr_bits = osMutexRecursive, // 关键:设置递归属性
};
void MX_FREERTOS_Init(void)
{
RecursiveMutexHandle = osMutexNew(&RecursiveMutex_attr);
}
/* 递归调用示例 */
void FuncA(void)
{
osMutexAcquire(RecursiveMutexHandle, osWaitForever); // 第1次获取
// 做一些操作...
FuncB(); // 内部也要获取同一个互斥量
osMutexRelease(RecursiveMutexHandle); // 第1次释放
}
void FuncB(void)
{
osMutexAcquire(RecursiveMutexHandle, osWaitForever); // 第2次获取(不会死锁)
// 做另一些操作...
osMutexRelease(RecursiveMutexHandle); // 第2次释放
}
// 获取和释放必须配对!获取2次就要释放2次
递归互斥量的注意事项
- 每次
Acquire必须有对应的Release,获取 N 次就要释放 N 次 - 只有在确实需要递归锁定时才使用,普通场景用普通互斥量
实战案例¶
案例 1:I2C 总线保护¶
多个任务需要使用同一个 I2C 读取不同传感器:
osMutexId_t I2C_MutexHandle;
/* 封装线程安全的 I2C 读取 */
HAL_StatusTypeDef Safe_I2C_Read(uint16_t addr, uint8_t reg,
uint8_t *data, uint16_t len)
{
HAL_StatusTypeDef status = HAL_ERROR;
if (osMutexAcquire(I2C_MutexHandle, 100) == osOK)
{
status = HAL_I2C_Mem_Read(&hi2c1, addr, reg,
I2C_MEMADD_SIZE_8BIT,
data, len, 100);
osMutexRelease(I2C_MutexHandle);
}
return status;
}
/* 温度传感器任务 */
void Temp_Task(void *argument)
{
uint8_t data[2];
for (;;)
{
Safe_I2C_Read(0x48 << 1, 0x00, data, 2);
float temp = ((data[0] << 8) | data[1]) / 256.0f;
osDelay(500);
}
}
/* 加速度传感器任务 */
void Accel_Task(void *argument)
{
uint8_t data[6];
for (;;)
{
Safe_I2C_Read(0x68 << 1, 0x3B, data, 6);
// 解析加速度数据...
osDelay(20);
}
}
案例 2:中断同步 + 数据处理¶
使用二值信号量让中断通知任务进行 DMA 传输完成后的数据处理:
osSemaphoreId_t DMA_SemHandle;
uint8_t adc_buffer[100];
/* 启动 DMA 采集(在初始化中) */
void Start_ADC_DMA(void)
{
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, 100);
}
/* DMA 传输完成中断回调 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
/* 通知任务:数据准备好了 */
osSemaphoreRelease(DMA_SemHandle);
}
/* 数据处理任务 */
void ADC_Process_Task(void *argument)
{
float average;
for (;;)
{
/* 等待 DMA 完成通知 */
if (osSemaphoreAcquire(DMA_SemHandle, osWaitForever) == osOK)
{
/* 计算均值 */
uint32_t sum = 0;
for (int i = 0; i < 100; i++)
{
sum += adc_buffer[i];
}
average = (float)sum / 100.0f * 3.3f / 4096.0f;
/* 重新启动 DMA */
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, 100);
}
}
}
使用原则总结¶
graph TD
Q{需要做什么?} --> A[传递数据]
Q --> B[事件通知 / 同步]
Q --> C[保护共享资源]
A --> A1[队列 Queue]
B --> B1{谁通知谁?}
B1 --> B2[中断 → 任务]
B1 --> B3[任务 → 任务]
B2 --> B4[二值信号量]
B3 --> B5[任务通知 / 信号量]
C --> C1[互斥量 Mutex]
| 场景 | 推荐机制 | 原因 |
|---|---|---|
| 中断通知任务处理 | 二值信号量 | 支持 ISR,开销小 |
| 限制资源并发数 | 计数信号量 | 天然的资源计数器 |
| 保护串口/I2C/SPI | 互斥量 | 有优先级继承,防止反转 |
| 任务间传数据 | 队列 | 自带缓冲,线程安全 |
| 轻量级事件通知 | 任务通知 | 效率最高,无额外开销 |
| 等待多条件满足 | 事件组 | 支持 AND/OR 等待 |
常见问题¶
信号量获取后忘记释放会怎样?
- 二值信号量:后续所有等待该信号量的任务永远阻塞
- 互斥量:持有互斥量的任务永远不释放,其他等待的任务死锁
- 计数信号量:可用资源数永久减 1
务必确保每次 Acquire 都有对应的 Release。
能在中断中使用互斥量吗?
不能! 互斥量的获取可能导致阻塞,中断中不允许阻塞。中断中只能使用二值/计数信号量(且 timeout 必须为 0)。
死锁怎么排查?
死锁常见原因:
- 互锁:任务 A 持有锁1等待锁2,任务 B 持有锁2等待锁1
- 自锁:任务获取非递归互斥量后再次获取
- 忘记释放:获取后某个分支没有释放
预防方法:
- 多个互斥量时,所有任务按固定顺序获取
- 使用超时而非
osWaitForever,超时后记录错误 - 获取和释放写在同一层级,避免嵌套遗漏