跳转至

信号量与互斥量

在多任务系统中,任务间需要同步执行顺序保护共享资源。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 创建信号量

  1. Middleware → FREERTOS → Timers and Semaphores 标签页
  2. 在 Binary Semaphores 或 Counting Semaphores 区域点击 Add
  3. 配置:
参数 二值信号量 计数信号量
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 创建互斥量

  1. Middleware → FREERTOS → Mutexes 标签页
  2. 点击 Add
  3. 命名如 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)。

死锁怎么排查?

死锁常见原因:

  1. 互锁:任务 A 持有锁1等待锁2,任务 B 持有锁2等待锁1
  2. 自锁:任务获取非递归互斥量后再次获取
  3. 忘记释放:获取后某个分支没有释放

预防方法:

  • 多个互斥量时,所有任务按固定顺序获取
  • 使用超时而非 osWaitForever,超时后记录错误
  • 获取和释放写在同一层级,避免嵌套遗漏