1. 项目概述:为什么是LL库、串口与DMA的“铁三角”?
如果你正在用STM32做项目,尤其是涉及到大量、高速、不间断的串口数据收发,比如和传感器通信、与上位机交互、或者做数据透传,那你大概率已经受够了传统中断方式的“折磨”。每收到一个字节就进一次中断,CPU被频繁打断,效率低下不说,遇到高波特率或大数据包,丢数据、卡死都是家常便饭。这时候,DMA(直接存储器访问)就成了你的“救星”。它能不经过CPU,直接在存储器和外设之间搬运数据,把CPU彻底解放出来。
但问题来了,STM32的HAL库虽然方便,但其DMA处理流程封装较深,有时为了追求极致的效率和可控性,我们不得不“下沉”到更底层的硬件抽象层。这就是LL库(Low-Layer Libraries)登场的时候。LL库提供了对STM32外设寄存器最直接、最轻量级的操作接口,没有HAL库那些复杂的状态机和回调机制,代码执行路径清晰,效率极高。将LL库、串口(UART/USART)和DMA三者结合,就构成了一个既能满足高性能需求,又保持代码精简和高度可控的“黄金组合”。这个组合特别适合对实时性、功耗、代码尺寸有严格要求的嵌入式产品,例如工业数据采集器、高速通信网关、无人机飞控等。
简单说,这个项目就是教你如何用STM32的LL库,配置串口使用DMA进行收发,打造一个高效、稳定的数据通道。我会从原理、配置、代码实现到避坑经验,手把手带你走一遍。
2. 核心方案设计与硬件原理剖析
2.1 方案选型:LL库 vs HAL库,DMA的必要性
为什么选择LL库而不是更流行的HAL库?这取决于你的项目阶段和需求。
- HAL库:优势在于跨STM32系列芯片的兼容性好,初始化简单,提供了完整的中间件(如USB、文件系统)。但其代价是代码体积大,执行效率相对较低,中断处理逻辑复杂。对于DMA传输完成、半传输等事件,它通过回调函数通知,这带来了灵活性,但也增加了理解成本和潜在的运行时开销。
- LL库:它更像是披着C语言外衣的寄存器操作。LL库的函数几乎直接对应寄存器位的设置与清除。它的优势非常明显:
- 代码尺寸小:通常比等效的HAL库代码小30%-50%。
- 执行效率高:没有多余的状态判断和跳转。
- 控制粒度细:你可以精确地知道每一个标志位(Flag)和中断使能位(IT)的状态,调试时一目了然。
- 资源占用确定:没有动态内存分配,所有资源在编译期就确定了。
对于串口+DMA这种对时序和效率敏感的场景,LL库能让你对硬件有完全的掌控感。当你需要微调DMA传输的优先级、处理特定的错误状态,或者想在传输完成中断里做最快速的反应时,LL库的优势就体现出来了。
至于DMA的必要性,我们可以算一笔账。假设你的串口波特率是115200,那么每个字节的传输时间大约是87微秒。如果用中断方式,每收一个字节,CPU都要进行现场保护、跳转到中断服务程序、读取数据寄存器、保存数据、清除标志、现场恢复这一系列操作,这可能会消耗几十甚至上百个时钟周期。在72MHz的系统频率下,这相当于几微秒,看似不多,但当数据流持续不断时,CPU大部分时间都在处理中断进出,根本无法执行主循环任务。而DMA接管后,CPU只需要在DMA传输开始和结束时介入一下,中间过程完全自由,可以处理更复杂的业务逻辑或进入低功耗模式。
2.2 硬件架构与数据流解析
要理解如何配置,必须先搞清楚STM32中UART、DMA和内存这三者是如何协同工作的。
1. 发送流程(Memory-to-Peripheral):
- 你的应用程序需要发送的数据,首先被存放在内存的一个数组中(例如
tx_buffer[256])。 - 你配置DMA通道,将其源地址(
DMA_SxPAR)指向这个内存数组(tx_buffer),目的地址(DMA_SxM0AR)指向串口的数据寄存器(USART_TDR)。 - 你启动DMA传输,设置好要发送的数据数量(
NDTR)。 - DMA控制器开始工作:它从
tx_buffer[0]读取第一个字节,直接写入USART_TDR。串口硬件检测到TDR非空,自动开始通过TX引脚移位输出这个字节。 - 与此同时,DMA的
NDTR计数器减1,源地址自动递增(如果配置了),准备读取下一个字节。 - 串口发送完一个字节,
TDR寄存器变空,会触发一个“发送数据寄存器空”的事件。这个事件会“请求”DMA控制器进行下一次传输。 - DMA收到请求,立即将
tx_buffer[1]写入USART_TDR,如此循环,直到NDTR减到0。 NDTR为0时,DMA产生“传输完成”中断(如果使能了)。你在中断里可以知道数据已全部搬运完毕(注意:此时最后一个字节可能还在串口的移位寄存器中发送,尚未完全发出到线路)。
2. 接收流程(Peripheral-to-Memory):
- 你在内存中准备一个接收缓冲区(例如
rx_buffer[256])。 - 配置DMA通道,源地址指向串口的数据寄存器(
USART_RDR),目的地址指向rx_buffer。 - 启动DMA接收,设置好缓冲区大小(
NDTR)。 - 当串口RX引脚收到一个完整的字节,硬件会将其从移位寄存器转移到
RDR寄存器。 - 串口硬件会产生一个“接收数据寄存器非空”事件,并向DMA发出传输请求。
- DMA控制器立即将
USART_RDR中的字节读取出来,写入rx_buffer[0],然后NDTR减1,目标地址递增。 - 后续字节依此类推,源源不断地存入
rx_buffer。 - 当
NDTR减到0,DMA产生“传输完成”中断。此时缓冲区已满,你需要及时处理数据并重新启动DMA,否则新来的数据会丢失。
这里有一个关键点:DMA的传输是由外设(串口)的硬件事件“请求”触发的,而不是DMA主动去轮询。这种硬件级别的联动,是高效性的根本保证。
注意:对于USART,需要使能其DMA发送请求(
USART_CR3寄存器中的DMAT位)和DMA接收请求(DMA_R位),DMA通道才能收到请求信号。
3. 基于LL库的详细配置与代码实现
我们以STM32G0系列(其他系列类似)的USART1和DMA1 Channel1(用于发送)、Channel2(用于接收)为例,进行配置。使用STM32CubeIDE的LL初始化代码作为基础。
3.1 外设时钟与GPIO初始化
首先,必须开启相关外设的时钟。这是所有操作的前提。
// 使能GPIOA和USART1时钟 LL_AHB2_GRP1_EnableClock(LL_AHB2_GRP1_PERIPH_GPIOA); LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_USART1); // 使能DMA1时钟 LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_DMA1);接着,配置USART的TX(PA9)和RX(PA10)引脚为复用功能模式。
// 配置PA9为USART1_TX, 复用推挽输出,高速 LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_9, LL_GPIO_MODE_ALTERNATE); LL_GPIO_SetAFPin_0_7(GPIOA, LL_GPIO_PIN_9, LL_GPIO_AF_1); // AF1对应USART1 LL_GPIO_SetPinSpeed(GPIOA, LL_GPIO_PIN_9, LL_GPIO_SPEED_FREQ_HIGH); LL_GPIO_SetPinOutputType(GPIOA, LL_GPIO_PIN_9, LL_GPIO_OUTPUT_PUSHPULL); // 配置PA10为USART1_RX, 浮空输入或上拉输入(抗干扰更好) LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_10, LL_GPIO_MODE_ALTERNATE); LL_GPIO_SetAFPin_0_7(GPIOA, LL_GPIO_PIN_10, LL_GPIO_AF_1); LL_GPIO_SetPinPull(GPIOA, LL_GPIO_PIN_10, LL_GPIO_PULL_UP); // 建议使能上拉3.2 USART串口基础参数配置
配置波特率、数据位、停止位、校验位等。这里配置为115200波特率,8位数据,无校验,1位停止位。
// 先失能USART,以便配置 LL_USART_Disable(USART1); // 配置波特率。使用LL库提供的函数,它会根据系统时钟自动计算分频值。 // 假设系统时钟为64MHz, 115200波特率。 LL_USART_SetBaudRate(USART1, SystemCoreClock, LL_USART_OVERSAMPLING_16, 115200); // 配置数据位、停止位、校验位、硬件流控 LL_USART_ConfigCharacter(USART1, LL_USART_DATAWIDTH_8B, LL_USART_PARITY_NONE, LL_USART_STOPBITS_1); LL_USART_SetHWFlowCtrl(USART1, LL_USART_HWCONTROL_NONE); // 使能USART LL_USART_Enable(USART1);3.3 DMA发送通道配置(Memory to Peripheral)
这是最关键的一步。我们配置DMA1的Channel1用于USART1发送。
// 1. 复位并失能DMA通道,确保在干净的状态下配置 LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_1); LL_DMA_ConfigTransfer(DMA1, LL_DMA_CHANNEL_1, LL_DMA_DIRECTION_MEMORY_TO_PERIPH | // 方向:内存到外设 LL_DMA_MODE_NORMAL | // 模式:正常模式(传输一次) LL_DMA_PERIPH_NOINCREMENT | // 外设地址不递增 LL_DMA_MEMORY_INCREMENT | // 内存地址递增 LL_DMA_PDATAALIGN_BYTE | // 外设数据对齐:字节 LL_DMA_MDATAALIGN_BYTE | // 内存数据对齐:字节 LL_DMA_PRIORITY_MEDIUM); // 优先级:中 // 2. 配置地址和数据长度(此时先不设置具体值,在发送函数里设置) // LL_DMA_ConfigAddresses(DMA1, LL_DMA_CHANNEL_1, (uint32_t)tx_buffer, LL_USART_DMA_GetRegAddr(USART1, LL_USART_DMA_REG_DATA_TRANSMIT), LL_DMA_DIRECTION_MEMORY_TO_PERIPH); // LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_1, length); // 3. 使能传输完成中断(TCIE)和传输错误中断(TEIE) LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_1); LL_DMA_EnableIT_TE(DMA1, LL_DMA_CHANNEL_1); // 4. 配置DMA请求映射:将USART1的TX请求连接到DMA1的Channel1 // 对于STM32G0, 使用LL_DMA_SetPeriphRequest LL_DMA_SetPeriphRequest(DMA1, LL_DMA_CHANNEL_1, LL_DMAMUX_REQ_USART1_TX); // 5. 使能DMA通道(此时还未启动传输,因为USART的DMA请求还未使能) LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1); // 6. 使能USART1的DMA发送请求 LL_USART_EnableDMAReq_TX(USART1);关键点解析:
LL_DMA_MODE_NORMAL:普通模式。传输完指定数量数据后,DMA通道自动停止,需要软件重新配置才能启动下一次传输。另一种常用模式是LL_DMA_MODE_CIRCULAR(循环模式),适用于持续不断的收发,比如音频流。LL_USART_DMA_GetRegAddr:这是一个非常实用的LL库宏,用于获取外设数据寄存器的地址,避免了手动查手册计算地址的麻烦和错误。- 请求映射(DMAMUX):在有多外设多DMA通道的复杂型号(如G0, G4, L4+)中,外设和DMA通道的连接不是固定的,需要通过DMAMUX来配置。这一步绝对不能省略,否则DMA收不到外设的请求信号。
3.4 DMA接收通道配置(Peripheral to Memory)
配置DMA1的Channel2用于USART1接收。思路与发送类似,方向相反。
// 1. 复位并失能通道 LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_2); LL_DMA_ConfigTransfer(DMA1, LL_DMA_CHANNEL_2, LL_DMA_DIRECTION_PERIPH_TO_MEMORY | // 方向:外设到内存 LL_DMA_MODE_CIRCULAR | // 模式:循环模式!!!这是关键区别 LL_DMA_PERIPH_NOINCREMENT | LL_DMA_MEMORY_INCREMENT | LL_DMA_PDATAALIGN_BYTE | LL_DMA_MDATAALIGN_BYTE | LL_DMA_PRIORITY_HIGH); // 接收优先级通常设高,防止数据丢失 // 2. 配置地址和长度(接收需要预先设置好缓冲区) #define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; LL_DMA_ConfigAddresses(DMA1, LL_DMA_CHANNEL_2, LL_USART_DMA_GetRegAddr(USART1, LL_USART_DMA_REG_DATA_RECEIVE), // 源:USART接收寄存器 (uint32_t)rx_buffer, // 目的:接收缓冲区 LL_DMA_DIRECTION_PERIPH_TO_MEMORY); LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_2, RX_BUFFER_SIZE); // 3. 使能中断(循环模式下,半传输HT和传输完成TC中断都很有用) LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_2); LL_DMA_EnableIT_HT(DMA1, LL_DMA_CHANNEL_2); // 半传输中断 LL_DMA_EnableIT_TE(DMA1, LL_DMA_CHANNEL_2); // 4. 配置DMA请求映射 LL_DMA_SetPeriphRequest(DMA1, LL_DMA_CHANNEL_2, LL_DMAMUX_REQ_USART1_RX); // 5. 使能DMA通道 LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_2); // 6. 使能USART1的DMA接收请求 LL_USART_EnableDMAReq_RX(USART1);关键点解析:
- 循环模式(Circular Mode):这是接收不定长数据或持续数据流的标准做法。DMA在填满缓冲区(
NDTR从RX_BUFFER_SIZE减到0)后,会自动将地址和计数器重置为初始值,然后从头开始覆盖写入。这样只要不处理慢于接收速度,数据就不会丢失。你需要通过“半传输”(HT)和“传输完成”(TC)中断来及时处理数据。 - 双缓冲机制:利用HT和TC中断,
rx_buffer在逻辑上被分为前后两半。当DMA写入后半部分时(触发HT中断),你可以安全地读取前半部分的数据;当DMA写回前半部分时(触发TC中断),你可以安全地读取后半部分的数据。这实现了无锁的“生产者(DMA)-消费者(CPU)”模型。
3.5 中断服务程序(ISR)与数据处理逻辑
DMA和USART的中断需要妥善处理。通常我们需要使能DMA通道的中断,并在NVIC中设置优先级。
// 在main初始化中配置NVIC NVIC_SetPriority(DMA1_Channel1_IRQn, 1); NVIC_EnableIRQ(DMA1_Channel1_IRQn); NVIC_SetPriority(DMA1_Channel2_IRQn, 0); // 接收中断优先级更高 NVIC_EnableIRQ(DMA1_Channel2_IRQn); // USART错误中断(可选但推荐) NVIC_SetPriority(USART1_IRQn, 2); NVIC_EnableIRQ(USART1_IRQn); LL_USART_EnableIT_ERROR(USART1); // 使能帧错误、噪声错误等中断DMA发送完成中断服务函数:
void DMA1_Channel1_IRQHandler(void) { // 1. 检查是否是传输完成中断 if(LL_DMA_IsActiveFlag_TC1(DMA1)) { LL_DMA_ClearFlag_TC1(DMA1); // 清除标志 // 可以在这里设置一个标志,通知主循环“发送完成” // 例如:uart_tx_done = 1; // 注意:此时最后一个字节可能还在物理发送中。如果需要确认完全发出,可以等待USART的TC(传输完成)标志。 // while(!LL_USART_IsActiveFlag_TC(USART1)); // 忙等待,慎用 } // 2. 检查传输错误中断 if(LL_DMA_IsActiveFlag_TE1(DMA1)) { LL_DMA_ClearFlag_TE1(DMA1); // 处理DMA传输错误,通常是硬件故障,需要系统复位或严重错误处理 Error_Handler(); } }DMA接收中断服务函数(循环模式双缓冲):
// 定义全局变量管理接收状态 volatile uint16_t rx_write_idx = 0; // DMA当前写入位置(由中断维护) uint16_t rx_read_idx = 0; // 应用读取位置 uint8_t rx_buffer[RX_BUFFER_SIZE]; void DMA1_Channel2_IRQHandler(void) { // 半传输中断:DMA写到了缓冲区中间点 if(LL_DMA_IsActiveFlag_HT2(DMA1)) { LL_DMA_ClearFlag_HT2(DMA1); // 此时,DMA正在写入后半部分(rx_buffer[RX_BUFFER_SIZE/2] ~ rx_buffer[RX_BUFFER_SIZE-1]) // 我们可以安全地处理前半部分的数据(rx_buffer[0] ~ rx_buffer[RX_BUFFER_SIZE/2 -1]) // 通常设置一个标志,通知主循环或任务 uart_rx_half_done = 1; rx_write_idx = RX_BUFFER_SIZE / 2; // 更新逻辑写入位置 } // 传输完成中断:DMA写满了一圈缓冲区 if(LL_DMA_IsActiveFlag_TC2(DMA1)) { LL_DMA_ClearFlag_TC2(DMA1); // 此时,DMA已经写满了整个缓冲区,并回到了开头 // 我们可以安全地处理后半部分的数据 uart_rx_full_done = 1; rx_write_idx = 0; // 写入位置回到起点 } // 错误中断处理... if(LL_DMA_IsActiveFlag_TE2(DMA1)) { LL_DMA_ClearFlag_TE2(DMA1); Error_Handler(); } }在主循环或任务中,检查uart_rx_half_done和uart_rx_full_done标志,然后从rx_buffer中读取从rx_read_idx到rx_write_idx之间的数据。处理完后,更新rx_read_idx。这里的关键是处理好环形缓冲区的索引计算,避免越界。
3.6 数据发送函数封装
最后,我们封装一个非阻塞的发送函数。这个函数启动DMA传输后立即返回,发送完成后由中断通知。
// 状态标志 volatile uint8_t uart_tx_busy = 0; int8_t uart_send_dma(uint8_t *data, uint16_t len) { // 1. 检查DMA通道是否繁忙(上一次传输是否完成) if(uart_tx_busy || LL_DMA_IsEnabledChannel(DMA1, LL_DMA_CHANNEL_1)) { // 可以返回错误码,或者加入超时等待机制 return -1; // 发送忙 } // 2. 失能通道,准备重新配置(对于NORMAL模式是必须的) LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_1); // 3. 清除所有可能挂起的中断标志 LL_DMA_ClearFlag_TC1(DMA1); LL_DMA_ClearFlag_TE1(DMA1); // 4. 重新配置源地址和数据长度 LL_DMA_ConfigAddresses(DMA1, LL_DMA_CHANNEL_1, (uint32_t)data, LL_USART_DMA_GetRegAddr(USART1, LL_USART_DMA_REG_DATA_TRANSMIT), LL_DMA_DIRECTION_MEMORY_TO_PERIPH); LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_1, len); // 5. 设置忙标志 uart_tx_busy = 1; // 6. 使能DMA通道,开始传输 LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1); return 0; // 启动成功 }在DMA发送完成中断里,将uart_tx_busy标志清零。
4. 避坑指南与高级技巧实录
在实际项目中,仅仅让代码跑通是远远不够的。下面这些是我和很多同行踩过坑后总结的经验,有些在官方手册里都不会写得这么直白。
4.1 内存对齐与数据一致性陷阱
问题:当你发现DMA传输的数据偶尔错乱,或者CRC校验失败,但内存里的源数据明明是对的,首先要怀疑内存对齐和Cache问题。
分析与解决:
- 内存对齐:DMA对源地址和目的地址通常有对齐要求(例如要求4字节对齐)。虽然LL库的字节传输模式(
_BYTE)要求最宽松,但为了最佳性能,建议将缓冲区地址按4字节对齐。可以使用编译器指令:
或者使用标准库的__attribute__((aligned(4))) uint8_t tx_buffer[1024];alignas(C11)。 - Cache一致性(针对Cortex-M7等带Cache的型号):这是最大的坑!CPU和DMA共享内存,但CPU有Cache。如果CPU写了数据到
tx_buffer(写入了Cache),然后启动DMA,DMA会直接从物理内存(而非Cache)读取数据,此时读到的是旧数据。反之,DMA将数据写入rx_buffer(直接写物理内存),CPU从Cache里读,读到的是旧数据。- 解决方法:使用SCB(系统控制块)的Cache维护函数。
- 发送前(CPU写 -> DMA读):确保DMA要读取的内存区域是干净的(Clean)。调用
SCB_CleanDCache_by_Addr((uint32_t*)tx_buffer, len),将Cache中的数据刷回物理内存。 - 接收后(DMA写 -> CPU读):确保CPU读取前,内存区域是无效的(Invalidate)。调用
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, len),使Cache中该区域的数据失效,迫使CPU从物理内存重新加载。
- 发送前(CPU写 -> DMA读):确保DMA要读取的内存区域是干净的(Clean)。调用
- 对于STM32H7等系列:还需要考虑DMA缓冲区所在的内存域(DTCM, AXI SRAM, SRAM1等)是否支持Cache,配置可能更复杂。
- 解决方法:使用SCB(系统控制块)的Cache维护函数。
4.2 接收不定长数据的实战方案
循环DMA+双缓冲中断是基础,但如何知道一帧数据何时结束?常见协议有帧头帧尾、超时判断、固定长度等。
方案一:空闲中断(IDLE) + DMA这是STM32 USART的一个杀手级功能。当RX线空闲(超过一个字节时间的高电平)时,会产生IDLE中断。结合DMA,可以完美接收不定长数据。
- 使能USART的IDLE中断:
LL_USART_EnableIT_IDLE(USART1)。 - 在USART中断服务函数中判断IDLE标志。
- 当IDLE中断发生时,计算已接收数据长度:
received_len = RX_BUFFER_SIZE - LL_DMA_GetDataLength(DMA1, LL_DMA_CHANNEL_2)。 - 处理从缓冲区起始到
received_len的数据。 - 关键一步:清除IDLE标志。通过先读SR寄存器,再读DR寄存器来清除:
volatile uint32_t temp = USART1->ISR; temp = USART1->RDR; (void)temp;。LL库提供了LL_USART_IsActiveFlag_IDLE和LL_USART_ClearFlag_IDLE,但后者可能因芯片而异,需查手册。 - 处理完数据后,无需重新配置DMA(因为是循环模式),DMA会继续接收。
注意:IDLE中断在总线一空闲就触发,所以即使只收到一个字节后总线空闲,也会触发。要确保你的数据处理逻辑能应对单字节“帧”。
方案二:超时定时器如果不方便用IDLE中断(例如某些型号不支持或已被占用),可以在每次DMA接收中断(HT/TC)或USART的RXNE中断(如果使能了)里,复位一个硬件定时器。如果定时器超时,则认为一帧数据接收完成。这种方法更灵活,可以自定义超时时间。
4.3 发送“最后一字节”问题与TC标志
问题:DMA发送完成中断(TC)触发时,只意味着DMA已经把最后一个字节从内存搬到了USART的TDR寄存器。但TDR到移位寄存器再到TX引脚发出,还需要时间。如果你在TC中断里立即关闭USART或进入低功耗模式,最后一字节可能发送不完整。
解决方案:
- 等待USART的TC(Transmission Complete)标志:在DMA的TC中断服务函数中,不要立即进行后续操作,可以设置一个标志。在主循环或发送完成回调函数中,等待
LL_USART_IsActiveFlag_TC(USART1)为真。这个标志表示移位寄存器也为空,最后一字节已完全发出。void wait_for_uart_tc(void) { // 简单忙等待,适用于不频繁发送的场景 while(!LL_USART_IsActiveFlag_TC(USART1)); LL_USART_ClearFlag_TC(USART1); } - 使能USART的TC中断:更优雅的方式是使能USART的TC中断,在TC中断里进行真正的发送后处理(如释放缓冲区、通知任务等)。这样CPU无需忙等待。
4.4 错误处理与鲁棒性增强
一个健壮的通信驱动必须处理错误。
- DMA传输错误(TE):通常意味着总线访问错误(例如访问了非法地址)。这种错误很严重,一般需要系统复位或进入安全状态。
- USART错误:使能
LL_USART_EnableIT_ERROR,在USART中断中检查:- 溢出错误(ORE):CPU或DMA没来得及读取RDR,新数据又来了。在DMA接收中很少发生,但如果DMA被意外停止就可能出现。处理方式是清除标志,并可能丢弃后续数据或复位接收。
- 帧错误(FE):停止位不正确。可能是波特率不匹配或线路干扰。需要清除标志,可能还需要重新同步通信。
- 噪声错误(NE):在接收时检测到噪声。同样需要清除标志。
- 清除错误标志:LL库提供了
LL_USART_ClearFlag_ORE等函数。重要:清除某些错误标志需要先读SR寄存器,再读DR寄存器,具体请参考芯片参考手册的USART章节。
4.5 性能优化与资源管理
- DMA优先级:如果有多个DMA通道同时工作,合理设置优先级。通常接收的优先级应高于发送,因为数据丢失的后果更严重。
- 内存使用:对于高速数据流,缓冲区要足够大。可以使用
__attribute__((section(".dma_buffer")))将缓冲区链接到特定的SRAM区域(如果芯片有),有时能获得更高的访问速度。 - 中断优先级分组:合理设置NVIC的优先级分组(如
NVIC_SetPriorityGrouping(3)),并分配USART、DMA中断的抢占优先级和子优先级。确保高实时性要求的中断能及时响应。 - 避免在中断中处理复杂逻辑:中断服务函数里只做最紧急的事:设置标志、拷贝索引、清除标志。复杂的数据解析、协议处理应放到主循环或RTOS任务中。
5. 调试技巧与常见问题速查
调试串口DMA,逻辑分析仪或示波器是终极武器。但软件层面也有不少方法。
1. 如何确认DMA是否在工作?
- 查看DMA通道的使能位(
EN)和传输计数器(NDTR)。在调试器中,NDTR的值应该随着数据的收发而递减(对于循环接收模式,会在缓冲区大小范围内循环)。 - 在DMA中断里设置断点,看是否能触发。
2. 数据发不出或收不到?
- 检查时钟:GPIO、USART、DMA的时钟是否全部使能?这是最常犯的错误。
- 检查引脚复用:GPIO是否正确配置为复用功能?AF映射号是否正确?
- 检查DMA请求映射:对于有DMAMUX的型号,
LL_DMA_SetPeriphRequest是否配置正确?参考芯片数据手册的DMA请求映射表。 - 检查USART的DMA使能位:
LL_USART_EnableDMAReq_TX/RX是否调用? - 检查缓冲区地址:源地址和目的地址是否设置正确?尤其是
LL_USART_DMA_GetRegAddr的使用。 - 检查中断:全局中断是否开启(
__enable_irq())?NVIC是否配置?
3. 数据错位或乱码?
- 检查波特率:计算是否正确?系统时钟(
SystemCoreClock)是否是你认为的值? - 检查数据对齐:DMA配置的
P_DATAALIGN和M_DATAALIGN是否与实际情况匹配?如果外设是16位寄存器(如某些ADC),而你按8位传输,就会错位。 - 检查Cache(M7核心):这是最隐蔽的问题。务必按4.1节处理Cache一致性。
4. 接收数据丢失?
- 缓冲区溢出:DMA循环接收,但你的数据处理太慢,没等处理完,DMA已经覆盖了未处理的数据。解决方法是增大缓冲区,或提高数据处理速度(优化代码、使用DMA双缓冲中断及时处理)。
- 中断被阻塞:DMA接收中断的优先级是否被其他长时间中断阻塞?调整中断优先级。
- DMA被意外停止:检查是否有其他地方错误地禁用了DMA通道或USART接收。
5. 发送卡住,uart_tx_busy永不清零?
- DMA传输完成中断未触发:检查DMA通道的TC中断是否使能,NVIC是否配置,中断标志是否被正确清除。
- USART的TC标志未就绪:如果你在等待USART的TC标志,检查线路是否正常(TX是否被拉低),或者对方设备是否准备好接收。
把这个“LL库串口+DMA”的组合玩熟,基本上STM32上的大部分数据搬运问题你都能举一反三。它代表的是一种对硬件直接、高效控制的编程思想。刚开始配置寄存器可能会觉得繁琐,但一旦掌握,你对芯片的理解和项目的掌控力会上一个全新的台阶。记住,嵌入式编程的乐趣和挑战,往往就藏在这些最底层的细节里。