[1.1 场景描述](#1.1 场景描述)
[1.2 为什么需要同步机制?](#1.2 为什么需要同步机制?)
[2.1 什么是信号量?](#2.1 什么是信号量?)
[2.2 二值信号量的工作模型](#2.2 二值信号量的工作模型)
[2.3 计数信号量的应用场景](#2.3 计数信号量的应用场景)
[3.1 互斥量与二值信号量的区别](#3.1 互斥量与二值信号量的区别)
[3.2 互斥量的优先级继承机制](#3.2 互斥量的优先级继承机制)
[3.3 互斥量 vs 二值信号量对比表](#3.3 互斥量 vs 二值信号量对比表)
[4.1 为什么需要DMA?](#4.1 为什么需要DMA?)
[4.2 系统架构图](#4.2 系统架构图)
[4.3 核心代码实现](#4.3 核心代码实现)
[4.4 完整执行流程时序图](#4.4 完整执行流程时序图)
前言
在嵌入式实时操作系统(RTOS)的开发中,任务同步与资源共享是两个核心问题。信号量(Semaphore)和互斥量(Mutex)是解决这两类问题的最基础也是最强大的工具。然而,很多初学者仅仅停留在"信号量用于同步,互斥量用于互斥"的表面理解,在实际项目中却不知如何正确运用。
本文将从一个真实的STM32 + FreeRTOS项目出发,结合DMA实现的异步串口发送,深入剖析信号量与互斥量的底层机制、使用场景以及常见的陷阱。通过本文,你不仅会学会如何使用这些同步原语,更会理解为什么要这样用。
完整的示例代码基于STM32F4系列,使用FreeRTOS V10.0+,DMA2串口1发送
一、从问题出发:多任务共享资源的困境
1.1 场景描述
假设我们有一个嵌入式系统,需要三个任务通过同一个UART串口发送调试信息。每个任务每隔1秒发送一条消息
XML
Task1(优先级5):发送 "Task1 is running..."
Task2(优先级6):发送 "Task2 is running..."
Task3(优先级7):发送 "Task3 is running..."
这就是典型的竞争条件(Race Condition)。
1.2 为什么需要同步机制?
在裸机编程中,我们通常通过关中断或使用标志位来保护临界区。但在RTOS中,任务可能会被阻塞、挂起,简单的关中断无法解决多任务并发访问的问题。我们需要更高级的同步原语:
-
互斥量(Mutex):保证同一时刻只有一个任务访问共享资源;
-
信号量(Semaphore):实现任务间的同步,通知某个事件已经发生;
二、信号量(Semaphore)深度解析
2.1 什么是信号量?
信号量是一个非负整数的计数器,支持两种原子操作:
-
Take(P操作):如果信号量值 > 0,将其减1并继续;如果为0,任务阻塞等待
-
Give(V操作):将信号量值加1,如果有任务在等待,唤醒其中一个
信号量分为两种类型:
| 类型 | 初始值 | 典型用途 | 特点 |
|---|---|---|---|
| 二值信号量 | 0 | 事件通知 | 值只有0或1 |
| 计数信号量 | N > 0 | 资源管理 | 可以管理多个相同资源 |
2.2 二值信号量的工作模型
二值信号量就像一把"一次性钥匙":
cpp
// 创建二值信号量,初始为0
SemaphoreHandle_t sem = xSemaphoreCreateBinary();
// 任务A:等待事件
xSemaphoreTake(sem, portMAX_DELAY); // 没钥匙?那就等着
// 事件发生后才能执行到这里
// 中断服务函数:事件发生,给钥匙
xSemaphoreGiveFromISR(sem, NULL); // 给一把钥匙
关键点:如果Give时没有任务在等待,信号量会保持为1,下一个Take会立即成功。
2.3 计数信号量的应用场景
cpp
// 管理5个相同的硬件缓冲区
SemaphoreHandle_t bufferSem = xSemaphoreCreateCounting(5, 5);
// 任务获取缓冲区
xSemaphoreTake(bufferSem, portMAX_DELAY);
// 使用缓冲区...
// 使用完释放
xSemaphoreGive(bufferSem);
注:关于优先级反转和优先级继承可以参考下面的博客https://blog.csdn.net/qq_33775774/article/details/149491381?fromshare=blogdetail&sharetype=blogdetail&sharerId=149491381&sharerefer=PC&sharesource=weixin_45725144&sharefrom=from_link
三、互斥量(Mutex)深度解析
3.1 互斥量与二值信号量的区别
很多初学者会问:"既然二值信号量也能实现互斥,为什么还要互斥量?"
这是一个关键问题。看下面的例子:
cpp
// 场景:低优先级任务持有锁,高优先级任务等待
// 使用二值信号量
LowTask: xSemaphoreTake(sem); // 获得锁
// 执行长时间操作...
MediumTask: // 抢占CPU,执行无限循环
HighTask: xSemaphoreTake(sem); // 永远等不到!
问题 :优先级反转(Priority Inversion)!
3.2 互斥量的优先级继承机制
互斥量内置了优先级继承机制,可以有效缓解优先级反转:
cpp
// 使用互斥量
LowTask: xSemaphoreTake(mutex); // 获得锁
MediumTask: // 试图抢占
// 但此时LowTask临时继承了HighTask的优先级!
// MediumTask无法抢占,HighTask能更快获得锁
优先级继承的规则:
当高优先级任务等待被低优先级任务持有的互斥量时
低优先级任务临时提升到高优先级任务的优先级
释放互斥量后,恢复原始优先级
3.3 互斥量 vs 二值信号量对比表
| 特性 | 互斥量 | 二值信号量 |
|---|---|---|
| 优先级继承 | 支持 | ❌ 不支持 |
| 递归获取 | 可配置 | ❌ 不支持 |
| 典型用途 | 资源保护 | 事件同步 |
| 初始化状态 | 已释放(1) | 未发生(0) |
| 谁可以Give | 只能由持有者 | 任何任务/ISR |
四、实战案例:DMA异步串口发送
4.1 为什么需要DMA?
传统的轮询发送方式:
cpp
void uart_send_polling(uint8_t *data, uint32_t len) {
for (uint32_t i = 0; i < len; i++) {
while (!(USART->SR & TXE)); // CPU空转等待
USART->DR = data[i];
}
}
问题:发送1000字节需要约100ms,CPU完全被占用,无法执行其他任务。
解决方案:DMA(直接内存访问)+ 中断 + 信号量
4.2 系统架构图

4.3 核心代码实现
步骤1:创建同步原语
cpp
static SemaphoreHandle_t uart_tx_done_semphr; // 传输完成信号量
static SemaphoreHandle_t uart_tx_busy_mux; // UART访问互斥量
static void uart_init(void)
{
// 创建二值信号量,初始为0(表示"未完成")
uart_tx_done_semphr = xSemaphoreCreateBinary();
configASSERT(uart_tx_done_semphr);
// 创建互斥量,初始为1(表示"可用")
uart_tx_busy_mux = xSemaphoreCreateMutex();
configASSERT(uart_tx_busy_mux);
// 硬件初始化...
uart_pin_init();
uart_lowlevel_init();
uart_dma_init();
}
步骤2:DMA配置
cpp
static void uart_dma_init(void)
{
// 配置DMA中断
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = DMA2_Stream7_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 7;
NVIC_Init(&NVIC_InitStructure);
// 配置DMA流
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_Channel = DMA_Channel_4; // USART1_TX通道
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
DMA_InitStructure.DMA_DIR = DMA_DIR_MemoryToPeripheral; // 内存→外设
DMA_InitStructure.DMA_BufferSize = 0; // 稍后设置
DMA_Init(DMA2_Stream7, &DMA_InitStructure);
// 使能传输完成中断
DMA_ITConfig(DMA2_Stream7, DMA_IT_TC, ENABLE);
}
步骤3:异步发送函数(核心)
cpp
static void uart_write(const uint8_t *data, uint32_t length)
{
// ① 获取互斥量:确保只有一个任务使用UART
xSemaphoreTake(uart_tx_busy_mux, portMAX_DELAY);
// ② 配置DMA传输
DMA2_Stream7->M0AR = (uint32_t)data; // 源地址
DMA2_Stream7->NDTR = length; // 传输长度
DMA_Cmd(DMA2_Stream7, ENABLE); // 启动DMA
// ③ 等待传输完成信号量(任务进入阻塞状态)
xSemaphoreTake(uart_tx_done_semphr, portMAX_DELAY);
// ④ 释放互斥量
xSemaphoreGive(uart_tx_busy_mux);
}
关键点分析:
互斥量的作用:防止多个任务同时配置DMA寄存器
信号量的作用:让任务在DMA传输期间让出CPU,而不是空转等待
阻塞等待 :
xSemaphoreTake在信号量不可用时会让任务进入阻塞状态,不消耗CPU
步骤4:DMA中断处理
cpp
void DMA2_Stream7_IRQHandler(void)
{
if (DMA_GetFlagStatus(DMA2_Stream7, DMA_FLAG_TCIF7) != RESET)
{
DMA_ClearFlag(DMA2_Stream7, DMA_FLAG_TCIF7);
DMA_Cmd(DMA2_Stream7, DISABLE);
BaseType_t pxHigherPriorityTaskWoken = pdFALSE;
// 从ISR中释放信号量,唤醒等待的任务
xSemaphoreGiveFromISR(uart_tx_done_semphr, &pxHigherPriorityTaskWoken);
// 如果唤醒的任务优先级更高,立即切换
portYIELD_FROM_ISR(pxHigherPriorityTaskWoken);
}
}
步骤5:发送任务实现
cpp
static void uart_send_task(void *args)
{
char buff[128];
while (1)
{
vTaskDelay(pdMS_TO_TICKS(1000));
sprintf(buff, "[%lu] %s is running...\r\n",
xTaskGetTickCount(), pcTaskGetName(NULL));
uart_write((uint8_t *)buff, strlen(buff));
}
}
4.4 完整执行流程时序图
html
时间线 Task1(pri5) DMA硬件 中断 Task2(pri6) 信号量值 互斥量状态
─────────────────────────────────────────────────────────────────────────────
0ms Take互斥量 ✓ 阻塞(等互斥量) 0 持有者=T1
启动DMA
Take信号量(阻塞) 开始传输
↓ ↓
1ms 阻塞(等信号量) 传输中 阻塞(等互斥量) 0 持有者=T1
2ms 阻塞 传输中 阻塞 0 持有者=T1
3ms 阻塞 传输完成 ➡ Give信号量 1 持有者=T1
触发中断
4ms 被唤醒 阻塞 0 持有者=T1
Give互斥量 0 释放
循环重新开始 获得互斥量 ✓ 0 持有者=T2
Take互斥量(等待) 启动DMA... 0 持有者=T2
四、总结
本文从一个实际的多任务UART发送场景出发,深入剖析了FreeRTOS中信号量与互斥量的本质区别与应用技巧。我们首先理解了为什么在多任务系统中需要同步机制------没有保护的共享资源会导致数据混乱和系统崩溃;接着详细对比了二值信号量与互斥量的核心差异:互斥量通过优先级继承机制有效缓解了优先级反转问题,适用于保护临界资源,而二值信号量更擅长于任务间的事件通知与同步,典型的应用场景就是本文中的DMA传输完成通知。在实战部分,我们实现了一个完整的STM32 + FreeRTOS + DMA的异步串口发送系统,通过互斥量uart_tx_busy_mux确保同一时刻只有一个任务访问UART硬件,通过二值信号量uart_tx_done_semphr让任务在DMA传输期间进入阻塞状态、主动让出CPU,从而实现了高效的并发执行