1. 从“轮询”到“通知”:为什么串口接收需要同步机制?

大家好,我是老李,一个在嵌入式领域摸爬滚打了十多年的老兵。今天想和大家聊聊一个在FreeRTOS项目里非常经典,但也让不少新手朋友头疼的场景:串口接收不定长数据。咱们先别急着看代码,想想你以前是怎么干的。

是不是经常这样:在主循环里,隔三差五就去瞅一眼串口接收缓冲区,看看有没有新数据?这种方法,我们叫它“轮询”。轮询就像你每隔五分钟就跑去小区门口看看快递到了没,效率低不说,还特别浪费CPU时间。CPU宝贵的时间都花在“看快递”上了,真正要干的活反而被耽误了。更麻烦的是,数据啥时候来你完全不知道,可能你刚看完,数据就来了,结果你错过了,等下一次“看”的时候,数据可能已经堆积甚至被新数据覆盖了。

那用中断呢?好一些。每收到一个字节,就进一次中断服务程序(ISR),把数据存起来。这就像快递每到一个包裹,门卫就给你打一次电话。数据实时性高了,但问题也来了:如果数据帧很长,或者波特率很高,频繁中断会严重打断主程序(任务)的执行。想象一下,你正在专心写代码,门卫每隔几秒就打你一次电话,你还怎么写?系统的实时性反而可能因为中断太多而下降。

这时候,DMA(直接存储器访问)出场了。它是个“硬件搬运工”,串口收到数据,DMA自动帮你搬到内存的指定缓冲区里,完全不用CPU操心。这太棒了!CPU彻底解放了。但新的问题来了:数据搬完了,CPU怎么知道呢?DMA搬运可不会主动喊一声“老板,活干完了!”。你总不能又让CPU不停地去检查DMA搬了多少吧?那又回到轮询的老路了。

所以,我们需要一个高效的“通知机制”。当DMA完成一帧数据的接收(比如通过串口空闲中断判断),它得立刻、准确地通知到对应的处理任务:“数据准备好了,快来处理!”。在FreeRTOS里,实现这种“通知”和“等待”的神器,就是信号量。而用于这种单一事件通知的最简单、最轻量的信号量,就是二值信号量。它就像一个开关,或者一个标志位,只有“有信号”(1)和“无信号”(0)两种状态。ISR(中断)负责“给信号”(把开关打开),任务负责“等信号”(等开关打开),一旦等到,就立刻去处理数据。这套机制,完美地解决了中断(或DMA完成事件)与任务之间的同步问题,让CPU既不用忙等,又能及时响应。

2. 庖丁解牛:二值信号量如何充当“通信兵”

2.1 二值信号量的本质:一个深度为1的队列

很多朋友初次接触二值信号量,容易被“信号量”这个高大上的名字唬住。其实,你可以把它想象成一个只能放一件物品的盒子(队列深度为1)。这个盒子有两个状态:空(0)和满(1)。这也就是“二值”的由来。

在FreeRTOS的源码层面,二值信号量、计数信号量、互斥量,其实都是基于队列(Queue)实现的。当你调用 xSemaphoreCreateBinary() 创建一个二值信号量时,内核内部就是创建了一个长度为1的队列。这个队列里存放的不是你的数据,而是一个“令牌”(token)。

  • xSemaphoreTake():任务尝试从这个盒子里取走令牌。如果盒子是空的(没令牌),任务可以选择等待(阻塞)一段时间,或者立即返回失败。这就像你去收发室取快递,如果快递没到(盒子空),你可以选择在那等一会儿(阻塞),或者先回去(立即返回)。
  • xSemaphoreGive()xSemaphoreGiveFromISR():中断或另一个任务往这个盒子里放入一个令牌。如果盒子原来是空的,那么等待取令牌的任务就会被唤醒;如果盒子已经是满的(说明上次给的令牌还没被取走),那么这次“给”的操作就会失败(返回 errQUEUE_FULL)。这就像快递员把包裹放进了你的专属快递柜(盒子满了),如果之前的包裹你还没取,他就放不进去了。

在串口DMA接收的场景里,我们就是利用了这个“盒子”。初始化时,盒子是空的。我们的数据处理任务调用 xSemaphoreTake(),发现盒子是空的,于是进入阻塞状态,安心地去睡大觉,CPU可以执行其他任务。当串口空闲中断发生,意味着一帧数据通过DMA接收完成了,在中断服务程序里,我们调用 xSemaphoreGiveFromISR() 往盒子里放一个令牌。盒子从空变满,内核会立刻唤醒那个正在等待的任务。任务被唤醒后,从 xSemaphoreTake() 函数返回,然后就可以安全地去读取DMA缓冲区里的数据了。

2.2 中断服务程序(ISR)中的特殊操作

在中断里使用信号量,必须使用带 FromISR 后缀的API,这是FreeRTOS的硬性规定。原因在于,中断服务程序运行在特权模式,上下文与任务不同,不能进行可能导致任务切换的复杂内核操作。xSemaphoreGiveFromISR() 就是为中断环境量身定做的。

这里有个关键参数 pxHigherPriorityTaskWoken,我见过不少朋友对它感到困惑。我打个比方:假设中断好比一个紧急电话,这个电话通知了两个部门(任务):一个是普通客服(低优先级任务),一个是技术专家(高优先级任务)。中断打完电话(给出信号量)后,需要决定是否要立刻切换到更重要的那个部门去工作。

  • 如果这个信号量释放后,唤醒了一个任务,并且这个被唤醒的任务优先级,高于当前被中断打断的任务,那么 xHigherPriorityTaskWoken 这个参数就会被内核自动设置为 pdTRUE
  • 中断服务程序在结束前,需要检查这个标志。如果是 pdTRUE,就必须调用 portYIELD_FROM_ISR()portEND_SWITCHING_ISR()(这两个宏通常等价)来请求一次上下文切换。这样,中断一退出,CPU就会立刻去执行那个被唤醒的、更高优先级的任务,而不是回到原来被中断的低优先级任务。这保证了高优先级任务的实时响应。

在我们的串口例子里,如果数据处理任务的优先级设置得很高(比如高于系统里其他大部分任务),那么当空闲中断给出信号量并唤醒它时,xHigherPriorityTaskWoken 很可能就是 pdTRUE。我们在中断里调用 portEND_SWITCHING_ISR(xHigherPriorityTaskWoken),就是为了实现这个“紧急切换”,确保数据能被第一时间处理,避免因处理不及时导致缓冲区被新数据覆盖。

3. 实战构建:STM32串口DMA接收同步框架

光说不练假把式,咱们直接上干货,基于STM32和FreeRTOS,一步步搭建一个稳定可靠的串口DMA接收框架。我会把关键点和我踩过的坑都告诉你。

3.1 硬件与软件配置核心

首先,硬件上我们使用STM32的USART1,并启用其DMA接收功能。这里的关键是串口空闲中断(IDLE Interrupt)。它不是用来检测每个字节的,而是检测串口数据线在一个字节时间内持续保持高电平(即空闲状态)。当一帧数据发送完毕,总线会进入空闲,此时就会触发这个中断。这是我们判断“一帧数据接收完成”最理想的硬件信号。

软件配置流程如下,我把它总结成一个清晰的步骤:

  1. 初始化串口:配置波特率、数据位、停止位等。关键一步:使能串口的空闲中断 (USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)),而不是接收中断。
  2. 配置DMA:将DMA通道设置为从串口数据寄存器(USART1->DR)搬运到我们定义的内存缓冲区(如 Usart1.RxBuffer)。模式设置为循环模式(Circular Mode)。这一点非常重要!循环模式下,DMA的计数器(CNDTR)减到0后会自动重载初始值,缓冲区就像一个环,新数据会覆盖旧数据。这要求我们的处理任务必须足够快,在数据被覆盖前取走。
  3. 使能DMA:开启串口的DMA接收请求 (USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE)),并启动DMA通道。
  4. 创建二值信号量:在任务初始化部分,使用 xSemaphoreCreateBinary() 创建一个二值信号量句柄。记住,用这个函数创建的信号量,初始状态是“空的”(不可获取),必须先“Give”一次才能“Take”。但在我们的同步模型里,我们希望任务一开始是等待数据的状态,所以初始为空是正确的。
  5. 创建数据处理任务:这个任务的主体是一个无限循环,循环里首先调用 xSemaphoreTake(semaphore_handle, portMAX_DELAY) 来等待信号量。portMAX_DELAY 意味着永远等待,直到信号量到来。

3.2 中断服务程序(ISR)的编写要点

中断服务程序是连接硬件事件和RTOS任务的桥梁,代码必须精简高效。以下是 USART1_IRQHandler 的核心逻辑:

void USART1_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE; // 务必初始化为 pdFALSE

    // 判断是否是空闲中断
    if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) {
        // 1. 暂时关闭DMA通道。这是安全操作,防止我们计算长度时DMA还在修改缓冲区。
        DMA_Cmd(USART1_RX_DMA_CHANNEL, DISABLE);

        // 2. 清除DMA传输完成标志(如果有的话,确保状态干净)。
        DMA_ClearFlag(DMA1_FLAG_TC5);

        // 3. 核心计算:获取已接收数据的长度。
        // DMA的CNDTR寄存器保存着剩余要传输的数据单元数。
        // 初始设置是缓冲区大小(例如100)。接收了N个字节后,CNDTR = 100 - N。
        // 所以,已接收长度 = 缓冲区大小 - 当前CNDTR值。
        uint16_t received_len = RxBUFFER_SIZE - DMA_GetCurrDataCounter(USART1_RX_DMA_CHANNEL);

        // 4. 将接收到的数据长度保存到全局变量,供任务读取。
        Usart1.RxCounter = received_len;

        // 5. 重新配置DMA的计数器为缓冲区大小,为下一次接收做准备。
        // 注意:对CNDTR的写操作必须在DMA禁用时进行!
        USART1_RX_DMA_CHANNEL->CNDTR = RxBUFFER_SIZE;

        // 6. 重新使能DMA通道,开始下一轮接收。
        DMA_Cmd(USART1_RX_DMA_CHANNEL, ENABLE);

        // 7. 给出二值信号量,通知任务数据就绪。
        xSemaphoreGiveFromISR(xBinarySemaphore, &xHigherPriorityTaskWoken);

        // 8. 读取一次数据寄存器以清除空闲中断标志位(STM32的特性要求)。
        USART_ReceiveData(USART1);
    }

    // 9. 如果需要,进行任务切换。
    portEND_SWITCHING_ISR(xHigherPriorityTaskWoken);
}

几个踩坑点提醒

  • 计算长度时机:一定要在禁用DMA后、重新使能前计算长度。否则,DMA可能在后台持续搬运数据,导致你读到的 CNDTR 值不稳定。
  • 清除空闲中断:STM32的空闲中断标志需要通过读USART->SR寄存器后,再读USART->DR寄存器来清除。上面代码中的 USART_ReceiveData(USART1) 就是为了完成这个操作。
  • 缓冲区溢出保护:我们的例程使用了循环DMA。如果任务处理太慢,新数据会覆盖旧数据。在实际项目中,我通常会采用“双缓冲区”或“乒乓缓冲区”机制:准备两个缓冲区,DMA写其中一个,任务读另一个,通过信号量或标志位来交换,彻底避免覆盖。

3.3 数据处理任务的设计

数据处理任务就清晰多了,它只关心什么时候有数据可以处理。

void uart_data_process_task(void *pvParameters) {
    for(;;) {
        // 等待信号量。信号量到来,意味着至少有一帧新数据在缓冲区里了。
        if(xSemaphoreTake(xBinarySemaphore, portMAX_DELAY) == pdTRUE) {
            // 安全地访问全局缓冲区。此时中断已执行完毕,DMA也已重启。
            if(Usart1.RxCounter > 0) {
                // 处理数据,例如打印、解析协议等。
                printf("Received %d bytes: %.*s\n", Usart1.RxCounter, Usart1.RxCounter, Usart1.RxBuffer);

                // 处理完后,清空长度标志。缓冲区内容可以不清,因为下次DMA会覆盖。
                Usart1.RxCounter = 0;
            }
        }
    }
}

这个任务循环非常简单:等待信号量 -> 处理数据 -> 等待下一个信号量。因为信号量是二值的,所以即使短时间内连续产生多次空闲中断(极端情况),信号量也只能被给一次(因为盒子是满的,xSemaphoreGiveFromISR 会返回 errQUEUE_FULL),这确保了任务每次被唤醒只处理一“次”事件,避免了任务被重复无效唤醒。丢失的中断事件对应的数据,其实已经躺在DMA的循环缓冲区里了,任务下次被唤醒时,处理的是最新的数据状态。对于实时流数据,这种设计是合理的。

4. 进阶优化与避坑指南

掌握了基本框架,我们再来聊聊怎么让它更稳健、更高效。这些都是我在实际项目中用血泪教训换来的经验。

4.1 优先级设置的博弈

任务和中断的优先级设置,是FreeRTOS应用性能的关键。

  • 中断优先级:在ARM Cortex-M内核中,中断优先级数值越小,优先级越高。串口空闲中断的优先级需要根据数据的重要性来设置。如果串口数据非常关键,可以设置为较高的硬件优先级(较小的数值)。但要注意,不能高于FreeRTOS可管理的最高中断优先级(configMAX_SYSCALL_INTERRUPT_PRIORITYconfigLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY),否则在中断中调用 xSemaphoreGiveFromISR() 这类FreeRTOS API会导致系统不稳定。
  • 任务优先级:数据处理任务的优先级应该设为多少?这取决于数据处理的紧迫性。如果数据需要立刻响应,就设为高优先级。但要注意,如果这个任务处理数据耗时很长,高优先级会阻塞系统。我的经验是,在中断里只做最少的必要工作(计算长度、给信号量),把耗时的解析、存储、转发等工作放到任务里。这样,中断处理极快,任务优先级即使稍高,也不会长时间霸占CPU。

一个常见的反模式是:在中断里进行复杂的数据拷贝或协议解析。这会让其他低优先级中断被长时间阻塞,严重影响系统整体实时性。记住,中断要快进快出

4.2 应对数据粘包与断帧

串口是流式数据,没有天然的“帧”概念。空闲中断是我们判断帧结束的一种方式,但它并非万能。如果发送方发送速度极快,帧与帧之间没有空闲时间(粘包),那么空闲中断就无法区分两帧数据。反过来,如果一帧数据被意外打断(断帧),空闲中断可能提前触发。

为了解决这个问题,我通常会在应用层协议上做文章:

  1. 增加帧头帧尾:例如使用 0xAA 0x55 作为帧头,0x0D 0x0A 作为帧尾。任务收到数据后,需要在缓冲区里搜索这些特定字符来定位一帧完整的数据。
  2. 超时机制:结合一个硬件定时器。当收到第一个字节时启动定时器,如果在规定时间内没有收到空闲中断,则认为一帧数据已经接收完成(可能不完整或粘包),定时器中断给出另一个信号量,强制任务对已接收的数据进行处理。
  3. 协议长度字段:在帧头中包含本帧数据的长度。任务根据长度字段来解析,即使有空闲中断提前到来,也能正确找到帧尾。

这些机制可以和二值信号量结合使用。例如,空闲中断和超时中断都可以给同一个信号量,任务被唤醒后,根据一个全局标志位来判断是哪种事件触发的,进而采用不同的解析策略。

4.3 资源管理与稳定性保障

  • 信号量创建失败检查xSemaphoreCreateBinary() 在内存不足时会返回 NULL。生产代码中一定要检查返回值。
  • 缓冲区大小选择:DMA缓冲区大小要仔细权衡。太小容易溢出,太大浪费内存。需要根据最大帧长度、数据流量和任务处理速度来评估。我一般会设置为最大帧长的2-3倍。
  • 关中断的时机:在任务中访问 Usart1.RxCounterUsart1.RxBuffer 这些与中断共享的全局变量时,虽然我们的同步机制(信号量)已经保证了任务在数据就绪后才访问,但为了防止极端情况下的竞态,可以在任务中短暂关中断或使用互斥量来保护。不过,对于这个特定场景,由于任务只在收到信号量后访问,且中断中在给信号量前已经完成了对它们的写操作,所以通常是安全的。
  • 使用静态内存分配:对于可靠性要求极高的系统,可以考虑使用 xSemaphoreCreateBinaryStatic() 在编译时就分配好信号量所需的内存,避免运行时动态分配失败的风险。

5. 不止于串口:二值信号量的其他应用场景

掌握了串口DMA这个经典案例,你会发现二值信号量的思想可以应用到很多需要“事件通知”的地方。它本质上是一个同步原语

  • 按键检测:将按键的外部中断服务程序与一个去抖任务同步。中断里快速记录按键事件并给出信号量,任务中等待信号量,然后进行稳定的去抖处理和长按/短按判断。
  • 定时器事件:使用硬件定时器周期性地触发中断,在中断中给出信号量。一个任务等待这个信号量,从而实现精确的周期性任务调度,比如每100ms采集一次传感器数据。
  • 外部事件触发:比如一个光电传感器触发中断,通知一个任务开始执行一系列复杂的动作。
  • 任务间简单同步:两个任务需要交替执行。任务A做完自己的工作后,给出一个信号量,然后等待另一个信号量;任务B等待这个信号量,被唤醒后执行自己的工作,然后给出任务A等待的信号量。如此循环。

与队列(Queue)相比,二值信号量传递的是“事件发生了”这个消息本身,而不携带具体数据内容。数据需要通过共享内存(如全局缓冲区)来传递,并由开发者自己保证访问安全。而队列则自带数据传递和同步能力,但开销稍大。选择哪种,取决于你的需求是纯粹的同步,还是同步加数据传递。

最后我想说,嵌入式RTOS编程就像搭积木,二值信号量就是这样一块简单却不可或缺的积木。理解它“空盒子”和“满盒子”的状态变化,理解中断中“Give”和任务中“Take”的配合,你就能在无数需要高效响应的场景中游刃有余。刚开始可能会觉得配置繁琐,但一旦跑通,看到CPU利用率降下来,系统响应却更及时了,那种成就感是非常棒的。希望这篇文章能帮你少走些弯路,如果有不清楚的地方,多写代码,多调试,信号量的状态变化在调试器中是看得见的,这比空想管用得多。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐