尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32 LL库串口DMA高效通信:非阻塞收发与不定长数据解析实战

STM32 LL库串口DMA高效通信:非阻塞收发与不定长数据解析实战 1. 项目缘起为什么是LL库串口DMA在嵌入式开发尤其是基于STM32这类MCU的项目里串口通信几乎是标配。从打印调试信息到与传感器、模块通信串口无处不在。但很多开发者尤其是刚入门的往往止步于使用HAL库的HAL_UART_Transmit和HAL_UART_Receive这种阻塞式函数。项目简单时没问题一旦系统复杂起来频繁的阻塞等待会严重拖累CPU效率让整个系统响应变慢实时性大打折扣。这时DMA直接存储器访问就成了救星。它能不经过CPU直接在存储器和外设之间搬运数据。对于串口这种以字节流形式收发数据的设备配合DMA简直是天作之合。发送时CPU只需把数据丢到缓冲区启动DMA传输就可以去处理其他任务等DMA发完再通知CPU接收时DMA可以默默地把串口接收到的每一个字节存到指定数组等收到一帧完整数据或达到指定长度再通过中断告知CPUCPU在此期间完全解放。那么为什么强调LL库HAL库不是更简单易用吗确实HAL库的抽象层次高移植方便但其代价是代码体积大、执行效率相对较低且有时为了通用性牺牲了灵活性。LL库Low-Layer底层库则不同它提供的是对MCU寄存器最直接的、最轻量级的操作封装。使用LL库你几乎是在直接操作寄存器但比裸写寄存器更安全、可读性更好。在资源紧张、对时序和效率有极致要求的场景下LL库DMA的组合能让你对硬件有完全的控制力榨干MCU的每一分性能。这个项目就是深入探讨如何用STM32的LL库配合DMA打造一个高效、可靠、非阻塞的串口通信引擎。2. LL库串口DMA发送从配置到“发射后不管”让我们先从发送开始这是相对简单的一环。目标是把一个数据缓冲区通过串口发送出去期间不阻塞CPU。2.1 硬件与时钟初始化首先无论用HAL还是LL硬件初始化是跑不掉的。我们需要初始化串口和DMA的时钟并配置串口的基本参数波特率、数据位、停止位等。// 假设使用 USART1, DMA1 Channel4 (Stream4) 用于发送 (根据STM32型号参考手册) static void USART1_Init(void) { // 1. 使能时钟 LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_USART1); LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_DMA1); // DMA1时钟 // 2. 配置GPIO (PA9-TX, PA10-RX)此处略去GPIO初始化代码 // ... // 3. 配置USART参数 LL_USART_InitTypeDef USART_InitStruct {0}; USART_InitStruct.BaudRate 115200; USART_InitStruct.DataWidth LL_USART_DATAWIDTH_8B; USART_InitStruct.StopBits LL_USART_STOPBITS_1; USART_InitStruct.Parity LL_USART_PARITY_NONE; USART_InitStruct.TransferDirection LL_USART_DIRECTION_TX_RX; USART_InitStruct.HardwareFlowControl LL_USART_HWCONTROL_NONE; USART_InitStruct.OverSampling LL_USART_OVERSAMPLING_16; LL_USART_Init(USART1, USART_InitStruct); // 4. 使能USART LL_USART_Enable(USART1); }这里的关键是确保串口和DMA的时钟已经打开。不同系列的STM32时钟总线可能不同务必查阅对应型号的参考手册。2.2 DMA发送通道配置接下来配置DMA用于发送。我们需要告诉DMA数据从哪里来内存地址到哪里去串口数据寄存器地址传输多少数据以及传输完成怎么办。static void DMA1_Init_TX(void) { // 1. 配置DMA通道 (以DMA1_Channel4为例对应USART1_TX) LL_DMA_InitTypeDef DMA_InitStruct {0}; DMA_InitStruct.PeriphOrM2MSrcAddress (uint32_t)(USART1-DR); // 外设地址串口数据寄存器 DMA_InitStruct.MemoryOrM2MDstAddress (uint32_t)0; // 内存地址在启动传输时指定 DMA_InitStruct.Direction LL_DMA_DIRECTION_MEMORY_TO_PERIPH; // 传输方向内存到外设 DMA_InitStruct.Mode LL_DMA_MODE_NORMAL; // 模式正常模式发完一次就停止 DMA_InitStruct.PeriphOrM2MSrcIncMode LL_DMA_PERIPH_NOINCREMENT; // 外设地址不递增 DMA_InitStruct.MemoryOrM2MDstIncMode LL_DMA_MEMORY_INCREMENT; // 内存地址递增 DMA_InitStruct.PeriphOrM2MSrcDataSize LL_DMA_PDATAALIGN_BYTE; // 外设数据宽度字节 DMA_InitStruct.MemoryOrM2MDstDataSize LL_DMA_MDATAALIGN_BYTE; // 内存数据宽度字节 DMA_InitStruct.NbData 0; // 传输数量在启动时指定 DMA_InitStruct.Priority LL_DMA_PRIORITY_MEDIUM; // 优先级 LL_DMA_Init(DMA1, LL_DMA_CHANNEL_4, DMA_InitStruct); // 2. 使能DMA通道的传输完成中断TCIE和传输错误中断TEIE LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_4); LL_DMA_EnableIT_TE(DMA1, LL_DMA_CHANNEL_4); // 3. 配置NVIC嵌套向量中断控制器使能DMA通道中断 NVIC_SetPriority(DMA1_Channel4_IRQn, 0); NVIC_EnableIRQ(DMA1_Channel4_IRQn); }配置要点解析PeriphOrM2MSrcAddress对于发送这是目标地址即USART1-DR。DMA会不断把数据从内存搬到这里。MemoryOrM2MDstAddress这是源地址即我们存放待发送数据的数组地址。初始化时先填0实际发送时再赋值。ModeNORMAL模式意味着DMA传输完指定数量数据后自动停止。如果需要循环发送例如用于不断刷新显示可以设置为CIRCULAR。地址递增外设地址串口数据寄存器固定不变所以不递增。内存地址需要递增以便DMA依次取出数组中的每个字节。中断使能传输完成中断TC这样我们才能在DMA发完所有数据后得到通知进行后续处理如释放缓冲区、准备下一包数据。2.3 启动发送与中断处理配置好后启动一次DMA发送就很简单了。void USART1_SendData_DMA(uint8_t *pData, uint16_t Size) { // 1. 等待上一次DMA传输完成如果DMA还在忙 while(LL_DMA_IsEnabledChannel(DMA1, LL_DMA_CHANNEL_4) 1) { // 可以加入超时机制防止死等 } // 2. 失能DMA通道在重新配置前先关闭 LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_4); // 3. 清除所有中断标志位避免旧标志引起误中断 LL_DMA_ClearFlag_TC4(DMA1); LL_DMA_ClearFlag_TE4(DMA1); // 4. 重新配置DMA设置内存地址和传输数量 LL_DMA_SetMemoryAddress(DMA1, LL_DMA_CHANNEL_4, (uint32_t)pData); LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_4, Size); // 5. 使能USART的DMA发送请求 LL_USART_EnableDMAReq_TX(USART1); // 6. 使能DMA通道开始传输 LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_4); }这个函数的核心是设置源数据地址和长度然后打开开关。DMA会立即开始工作。CPU执行完这个函数就可以返回去做其他事情了。当DMA传输完成会触发中断我们需要在中断服务函数里处理void DMA1_Channel4_IRQHandler(void) { // 1. 检查是否是传输完成中断 if(LL_DMA_IsActiveFlag_TC4(DMA1)) { LL_DMA_ClearFlag_TC4(DMA1); // 清除标志 // 传输完成后的处理例如 // - 通知主程序或任务发送完成 // - 将发送缓冲区标记为空闲 // - 如果需要可以在这里自动启动下一次发送 send_complete_callback(); // 用户自定义的回调函数 } // 2. 检查传输错误中断 if(LL_DMA_IsActiveFlag_TE4(DMA1)) { LL_DMA_ClearFlag_TE4(DMA1); // 处理传输错误例如重试或上报错误 handle_dma_error(); } }注意在中断里一定要先判断再清除标志位并且处理要快不要做耗时操作。复杂的处理应该放到主循环或任务中中断只负责设置标志或发送信号量。3. LL库串口DMA接收不定长数据的艺术发送相对直接而接收才是LL库DMA串口的精髓和难点所在尤其是处理不定长数据。我们无法预知对方会发来多长的数据常见的做法有超时判断、空闲中断IDLE结合DMA、或者特定帧头帧尾。这里重点讲最常用、最高效的“串口空闲中断IDLE DMA”方案。3.1 DMA接收的循环缓冲区配置对于接收我们希望DMA能一直工作随时准备接收数据。因此DMA要配置为循环模式CIRCULAR并指向一个足够大的缓冲区环形缓冲区。#define UART_RX_DMA_BUFFER_SIZE 256 // 接收缓冲区大小 uint8_t uart_rx_dma_buffer[UART_RX_DMA_BUFFER_SIZE]; // DMA接收缓冲区 static void DMA1_Init_RX(void) { // 1. 配置DMA通道 (以DMA1_Channel5为例对应USART1_RX) LL_DMA_InitTypeDef DMA_InitStruct {0}; DMA_InitStruct.PeriphOrM2MSrcAddress (uint32_t)(USART1-DR); // 外设地址串口数据寄存器源 DMA_InitStruct.MemoryOrM2MDstAddress (uint32_t)uart_rx_dma_buffer; // 内存地址我们的缓冲区目标 DMA_InitStruct.Direction LL_DMA_DIRECTION_PERIPH_TO_MEMORY; // 传输方向外设到内存 DMA_InitStruct.Mode LL_DMA_MODE_CIRCULAR; // 关键循环模式 DMA_InitStruct.PeriphOrM2MSrcIncMode LL_DMA_PERIPH_NOINCREMENT; DMA_InitStruct.MemoryOrM2MDstIncMode LL_DMA_MEMORY_INCREMENT; DMA_InitStruct.PeriphOrM2MSrcDataSize LL_DMA_PDATAALIGN_BYTE; DMA_InitStruct.MemoryOrM2MDstDataSize LL_DMA_MDATAALIGN_BYTE; DMA_InitStruct.NbData UART_RX_DMA_BUFFER_SIZE; // 传输数量等于缓冲区大小 DMA_InitStruct.Priority LL_DMA_PRIORITY_HIGH; // 接收优先级可以设高一些 LL_DMA_Init(DMA1, LL_DMA_CHANNEL_5, DMA_InitStruct); // 2. 使能DMA通道的半传输完成HTIE和传输完成中断TCIE用于复杂处理本例暂不使能 // LL_DMA_EnableIT_HT(DMA1, LL_DMA_CHANNEL_5); // LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_5); // 注意循环模式下TC中断会在DMA指针回到缓冲区开头时触发不一定代表一帧数据结束。 // 3. 使能USART的DMA接收请求 LL_USART_EnableDMAReq_RX(USART1); // 4. 使能DMA通道开始循环接收 LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_5); }配置为循环模式后DMA会周而复始地将串口收到的数据填入uart_rx_dma_buffer。当写到缓冲区末尾时会自动跳回开头继续写覆盖旧数据。这就像一个环形的 conveyor belt。3.2 串口空闲中断IDLE的使能与处理仅有循环DMA还不够我们需要知道“一帧数据什么时候结束”。串口空闲中断IDLE就是这个信号当串口总线上在一个字节的时间周期内没有新的数据时就会产生IDLE中断。这通常意味着对方发送完了一包数据。首先在串口初始化后使能IDLE中断static void USART1_Init(void) { // ... 之前的初始化代码 ... LL_USART_Enable(USART1); // 使能串口接收中断用于错误处理等和空闲中断 LL_USART_EnableIT_IDLE(USART1); // 关键 LL_USART_EnableIT_ERROR(USART1); // 配置USART的NVIC中断 NVIC_SetPriority(USART1_IRQn, 0); NVIC_EnableIRQ(USART1_IRQn); }然后在串口中断服务函数中处理IDLE事件// 全局变量用于记录DMA的写指针位置 static volatile uint16_t dma_last_ndtr UART_RX_DMA_BUFFER_SIZE; void USART1_IRQHandler(void) { // 1. 处理空闲中断 if(LL_USART_IsActiveFlag_IDLE(USART1)) { LL_USART_ClearFlag_IDLE(USART1); // 清除空闲中断标志通过读SR和DR寄存器 // 关键步骤计算本次接收到的数据长度 uint16_t current_ndtr LL_DMA_GetDataLength(DMA1, LL_DMA_CHANNEL_5); // 获取DMA当前剩余传输计数 uint16_t received_size UART_RX_DMA_BUFFER_SIZE - current_ndtr; // 已传输的数量 总大小 - 剩余数量 // 计算本次IDLE事件触发时新收到的数据长度 // 注意dma_last_ndtr是上次记录时的剩余计数 uint16_t new_data_len 0; if(received_size dma_last_ndtr) { // 常规情况本次已传输数 上次剩余数说明没有发生缓冲区回绕 new_data_len received_size - (UART_RX_DMA_BUFFER_SIZE - dma_last_ndtr); } else { // 发生了缓冲区回绕DMA已经写满一圈并从头开始了 // 新数据长度 本次已写到指针A (缓冲区总长 - 上次指针B) new_data_len received_size (UART_RX_DMA_BUFFER_SIZE - dma_last_ndtr); } // 更新记录值为下一次计算做准备 dma_last_ndtr current_ndtr; if(new_data_len 0) { // 2. 定位新数据在环形缓冲区中的起始位置 // DMA的当前写入位置 缓冲区基地址 (总大小 - 当前剩余计数) uint32_t current_write_addr (uint32_t)uart_rx_dma_buffer (UART_RX_DMA_BUFFER_SIZE - current_ndtr); // 新数据的起始地址 当前写入地址 - 新数据长度 uint32_t new_data_start_addr current_write_addr - new_data_len; // 处理环形缓冲区的地址回绕 if(new_data_start_addr (uint32_t)uart_rx_dma_buffer) { new_data_start_addr UART_RX_DMA_BUFFER_SIZE; } // 3. 将新数据拷贝到应用层缓冲区进行处理避免在中断中长时间处理 // 这里简单演示实际应使用信号量/消息队列通知任务 uint8_t *p_data (uint8_t *)new_data_start_addr; process_received_data(p_data, new_data_len); // 用户自定义的数据处理函数 } } // 处理其他串口中断如错误中断 if(LL_USART_IsActiveFlag_FE(USART1) || LL_USART_IsActiveFlag_NE(USART1) || LL_USART_IsActiveFlag_ORE(USART1)) { LL_USART_ClearFlag_FE(USART1); LL_USART_ClearFlag_NE(USART1); LL_USART_ClearFlag_ORE(USART1); // 记录或处理错误 } }这段代码是不定长DMA接收的核心逻辑理解起来需要一些思考。其精髓在于利用DMA传输计数器CNDTR寄存器的变化来推算接收了多少新数据并处理环形缓冲区回绕的情况。实操心得LL_USART_ClearFlag_IDLE()的实现比较特殊。对于STM32清除IDLE标志的方法通常是先读一次状态寄存器USARTx-SR再读一次数据寄存器USARTx-DR。LL库的LL_USART_ClearFlag_IDLE()函数内部已经帮我们做了这两步直接调用即可。但有些早期的LL库或自己写的代码可能需要手动操作这两步否则IDLE中断会一直触发。3.3 数据处理与缓冲区管理策略在IDLE中断中我们只做了最必要的工作计算数据长度和位置然后尽快将数据“转移”出去。为什么不在中断里直接解析协议因为中断服务函数应该执行得越快越好。长时间占用中断会导致其他中断被延迟响应影响系统实时性。更佳的做法是双缓冲区/环形队列在IDLE中断中将DMA环形缓冲区里的新数据快速拷贝到另一个应用层的环形队列中。通知机制通过设置标志位、释放信号量如果用了RTOS或触发事件等方式通知主循环或一个专用的解析任务。任务级处理主循环或解析任务从应用层队列中取出数据进行协议解析、校验、执行等耗时操作。这样中断服务函数的执行时间极短DMA接收缓冲区也能尽快被释放准备接收下一包数据即使数据帧非常密集也不会丢包。4. 实战避坑LL库DMA串口的那些“坑”与优化理论很美好但实际调试中总会遇到各种问题。下面分享几个我踩过的坑和对应的解决方案。4.1 DMA传输计数器CNDTR的“陷阱”在计算接收数据长度时我们严重依赖LL_DMA_GetDataLength()即读取DMA_CNDTR寄存器。这里有个关键点这个寄存器表示的是“剩余待传输的数据单元数”而不是已经传输的数量。在循环模式下它会在NbData我们设置的缓冲区大小和0之间循环变化。坑点如果你在使能DMA通道之前就读取CNDTR它的值可能是未定义的比如0xFFFF。如果你用这个未定义的值去做计算会导致长度计算完全错误。避坑方法确保在DMA通道使能并稳定运行后再进行第一次CNDTR的读取和记录。通常可以在所有初始化完成后在主循环开始前主动读取一次并赋值给dma_last_ndtr这个全局变量。void App_Init(void) { // ... 其他初始化 ... USART1_Init(); DMA1_Init_RX(); // 这个函数最后会 LL_DMA_EnableChannel // 等待一小段时间确保DMA已启动非必须但更稳妥 Delay_ms(1); // 初始化记录值 dma_last_ndtr LL_DMA_GetDataLength(DMA1, LL_DMA_CHANNEL_5); }4.2 数据覆盖与缓冲区大小设计使用循环DMA模式如果数据接收太快而应用层处理太慢新数据就会覆盖还未被处理的老数据造成数据丢失。解决方案增大DMA缓冲区这是最简单粗暴的方法。根据你的最大数据帧长度和系统处理能力留出足够的余量。例如如果一帧数据最大100字节处理一帧最慢需要10ms而数据可能以1ms/帧的速度发送那么缓冲区至少需要100 * (10ms / 1ms) 1000字节再加上安全余量。优化应用层处理速度检查数据处理逻辑是否有耗时操作如不必要的延时、低效的查找算法。考虑使用RTOS将解析任务设为高优先级。流控如果通信协议支持如RS485通常不支持可以使用硬件流控RTS/CTS来通知发送方暂停发送。4.3 串口波特率与DMA时钟的匹配问题这是一个隐蔽的硬件问题。DMA传输数据的速度必须跟得上串口接收数据的速度。串口接收一个字节的时间是1 / 波特率 * 10因为一个字节有10位包括起始位、停止位。DMA从串口数据寄存器DR搬运一个字节到内存需要若干个AHB时钟周期。计算与验证假设波特率是115200那么接收一个字节的时间约为86.8us。DMA传输一个字节通常需要2个AHB周期一个读外设一个写内存。如果AHB时钟是72MHz周期约13.9ns那么DMA搬一个字节约27.8ns远小于86.8us所以完全没问题。坑点当波特率非常高比如2M、4M而系统主频较低时就可能出现DMA来不及搬运导致串口数据寄存器溢出ORE错误。这时你会看到数据丢失。避坑方法在高速通信时确保系统时钟特别是AHB总线时钟足够高。在串口中断中监控ORE溢出错误标志如果频繁出现就需要考虑降低波特率或提升系统性能。对于STM32F4/H7等高性能系列这个问题不常见但对于低速的STM32F0/F1在超高波特率下需要留意。4.4 发送与接收DMA的冲突与优先级如果一个串口同时启用了发送DMA和接收DMA全双工它们会共享同一个DMA控制器如DMA1的不同通道。虽然DMA通道有优先级设置但通常不会冲突。需要注意的反而是软件逻辑上的冲突。例如在接收DMA的IDLE中断处理函数中如果你直接调用发送函数而发送函数里有关闭/重新配置DMA通道的操作可能会意外干扰到正在进行接收的DMA通道尤其是在它们使用同一个DMA控制器时。建议发送和接收使用独立的DMA控制器如DMA1和DMA2或至少间隔较远的通道可以减少软件误操作的影响。在操作DMA如失能、重新配置前增加状态检查或互斥锁在RTOS中。发送和接收的中断处理函数尽量保持独立避免复杂的相互调用。5. 进阶应用超时管理与协议解析框架基础的IDLEDMA能解决大部分不定长数据接收问题但对于一些没有明显帧间隔或者需要严格超时控制的协议如Modbus RTU还需要引入超时管理。5.1 基于SysTick或定时器的接收超时思路是每次收到一个字节通过DMA但我们可以在IDLE中断或利用DMA的半传输/传输完成中断来模拟“收到数据”的事件就重置一个定时器。如果定时器超时比如3.5个字符时间就认为一帧数据接收完成。// 使用一个硬件定时器如TIM2或SysTick volatile uint32_t uart_rx_timer 0; #define UART_RX_TIMEOUT_MS 50 // 超时时间根据波特率调整 // 在SysTick中断或定时器中断中递减 void SysTick_Handler(void) { if(uart_rx_timer 0) { uart_rx_timer--; } } // 修改USART1_IRQHandler在IDLE中断或每次有新数据时重置定时器 void USART1_IRQHandler(void) { if(LL_USART_IsActiveFlag_IDLE(USART1)) { LL_USART_ClearFlag_IDLE(USART1); // ... 原有的长度计算和数据提取逻辑 ... // 重置超时定时器 uart_rx_timer UART_RX_TIMEOUT_MS; // 同时可以设置一个“数据就绪”标志但先不处理 uart_rx_ready_flag 1; } } // 在主循环中检查超时 void main(void) { while(1) { // 如果数据就绪标志被置位并且超时定时器减到0 if(uart_rx_ready_flag (uart_rx_timer 0)) { uart_rx_ready_flag 0; // 此时才真正开始处理这一帧数据确保数据已经“静默”了一段时间 process_complete_frame(); } // ... 其他任务 } }这种方法结合了IDLE中断的即时性和超时判断的严谨性非常适合对帧完整性要求高的场合。5.2 构建一个简单的协议解析状态机当数据被完整接收后我们需要解析它。一个清晰的状态机能让代码更易维护。以下是一个解析类似“[命令字][数据长度N][N字节数据][校验和]”简单协议的框架typedef enum { PARSE_STATE_IDLE, PARSE_STATE_CMD, PARSE_STATE_LEN, PARSE_STATE_DATA, PARSE_STATE_CHECKSUM } parse_state_t; parse_state_t current_state PARSE_STATE_IDLE; uint8_t rx_cmd; uint8_t rx_data_len; uint8_t rx_data[256]; uint8_t rx_data_index 0; uint8_t rx_checksum; void parse_byte(uint8_t byte) { switch(current_state) { case PARSE_STATE_IDLE: if(byte 0xAA) // 假设帧头是0xAA { current_state PARSE_STATE_CMD; rx_checksum 0; // 重置校验和 rx_checksum ^ byte; } break; case PARSE_STATE_CMD: rx_cmd byte; rx_checksum ^ byte; current_state PARSE_STATE_LEN; break; case PARSE_STATE_LEN: rx_data_len byte; rx_checksum ^ byte; rx_data_index 0; if(rx_data_len 0) { current_state PARSE_STATE_DATA; } else { current_state PARSE_STATE_CHECKSUM; // 没有数据直接跳转到校验 } break; case PARSE_STATE_DATA: rx_data[rx_data_index] byte; rx_checksum ^ byte; if(rx_data_index rx_data_len) { current_state PARSE_STATE_CHECKSUM; } break; case PARSE_STATE_CHECKSUM: if(rx_checksum byte) { // 校验通过执行命令 execute_command(rx_cmd, rx_data, rx_data_len); } else { // 校验失败丢弃或重发 } current_state PARSE_STATE_IDLE; // 回归空闲状态 break; } } // 在 process_received_data 函数中遍历收到的每个字节调用 parse_byte void process_received_data(uint8_t *data, uint16_t len) { for(uint16_t i0; ilen; i) { parse_byte(data[i]); } }这个状态机逻辑清晰易于扩展新的命令或更复杂的协议格式。将它与前面的DMA接收引擎结合就能构建一个稳定高效的串口通信子系统。
返回列表