资讯动态

STM32CubeMX USART实战:轮询、中断、DMA三种模式详解与避坑指南

发布时间:2026/10/15 21:38:34 来源:尧图企业网站定制
1. 从一个实际需求说起为什么USART值得单独拿出来聊搞嵌入式开发的人绕不开串口。不管你是做工业控制、传感器采集、还是调试打印USART几乎是每个STM32项目里最早被点亮的外设。但恰恰因为它太基础很多人反而停留在“能收发就行”的阶段一旦遇到波特率对不上、数据丢包、中断优先级冲突就开始抓瞎。我自己带过几个做嵌入式方向的朋友入门发现一个共性现象大家用STM32CubeMX配置USART点几下鼠标生成代码然后往主循环里塞一个HAL_UART_Transmit就完事了。轮询方式跑通了觉得串口不过如此。等到项目里要同时接GPS模块、上位机通信、还要打印调试日志三个串口一起跑问题就全冒出来了——数据错位、接收不全、程序卡死。这篇内容就是围绕STM32CubeMX环境下USART的完整应用展开的。从CubeMX里的参数配置逻辑到轮询、中断、DMA三种收发模式的选型与实现再到实际项目中常见的坑和排查思路我都会结合自己的实操经验掰开揉碎讲清楚。适合已经能点亮LED、但对串口只停留在“能用”阶段的嵌入式初学者也适合想系统梳理USART应用的中级开发者。看完你至少能搞清楚什么场景该用什么模式、CubeMX里每个参数到底在配什么、出了问题从哪几个方向去查。2. CubeMX里配置USART每个参数背后是什么逻辑2.1 波特率不是随便填的误差率要算在CubeMX的USART配置界面第一个要填的就是Baud Rate。很多人直接填115200就过了但你知道这个值是怎么来的吗STM32的USART波特率由以下公式决定Baud fCK / (16 * USARTDIV)其中fCK是挂载在USART上的总线时钟频率USARTDIV是一个分频系数包含整数部分和小数部分BRR寄存器里12位整数4位小数。CubeMX会自动帮你算USARTDIV但你要关注的是实际波特率与目标波特率的误差。举个例子假设USART2挂在APB1总线上时钟为42MHz目标波特率115200USARTDIV 42000000 / (16 * 115200) 22.7865整数部分22小数部分0.7865×16≈12.58取13。实际分频值2213/1622.8125实际波特率42000000/(16×22.8125)≈115068。误差(115200-115068)/115200≈0.11%完全可以接受。但如果你的总线时钟是36MHz目标波特率是921600USARTDIV 36000000 / (16 * 921600) 2.4414整数部分2小数部分0.4414×16≈7.06取7。实际分频值27/162.4375实际波特率36000000/(16×2.4375)≈923077。误差≈0.16%还行。但如果小数部分取整误差再大一点就可能超过3%的容限导致通信不稳定。实操建议在CubeMX里配置完波特率后切到Clock Configuration页面确认一下USART挂载的总线时钟心里默算一下误差。一般误差控制在2%以内比较稳妥超过3%就要考虑换时钟源或调整波特率了。2.2 数据位、停止位、校验位别被“默认”坑了CubeMX默认是8位数据位、1位停止位、无校验。这个配置能覆盖80%的场景但剩下20%往往是坑的来源。数据位标准是8位或9位。9位通常用于多处理器通信或者带校验的场景。如果你接的是一个老式设备手册上写着“7位数据位偶校验”那你在CubeMX里就要把Word Length改成7位实际上STM32的8位模式配合校验位可以模拟7位数据校验的效果具体要看手册。停止位默认1位。有些设备要求2位停止位比如某些PLC或老式仪表。这个在CubeMX里直接选就行但要注意停止位变了通信双方必须一致否则会出现帧错误。校验位None、Even、Odd三种。如果选了Even或Odd数据位实际上会变成9位8位数据1位校验但CubeMX里Word Length还是显示8位这个不用纠结硬件会自动处理。踩过的坑有一次接一个称重传感器手册上写的是“9600, 8, N, 1”我按默认配的结果读出来的数据一直是乱码。后来用示波器抓波形才发现传感器实际用的是“9600, 7, E, 1”手册写错了。所以遇到乱码先别怀疑代码拿示波器或逻辑分析仪抓一下波形确认双方的帧格式是否一致。2.3 过采样模式16倍还是8倍CubeMX里有一个Over Sampling选项默认是16倍。这个参数决定了USART对接收信号的采样方式。16倍过采样每个位周期采样16次取第8、9、10次的值进行多数表决。这种方式抗噪能力强适合大多数场景。8倍过采样每个位周期采样8次取第4、5、6次表决。这种方式可以让波特率上限翻倍但抗噪能力下降。一般在需要极高波特率比如4.5Mbps以上且信号质量很好的时候才用。我的经验除非你的波特率需求超过了16倍过采样能提供的上限否则一律用16倍。稳定性优先别为了那点速率裕量牺牲抗干扰能力。2.4 中断优先级别让串口中断被“饿死”如果你用中断或DMA模式NVIC里的优先级配置就非常关键。STM32的中断优先级分为抢占优先级和响应优先级数值越小优先级越高。串口中断的优先级怎么定我的原则是如果串口用于接收关键控制指令比如急停信号抢占优先级设高一点比如1或2。如果只是打印调试日志优先级可以设低比如5或6避免打断其他关键中断。多个串口同时使用时接收缓冲小的串口优先级高一些防止溢出。注意HAL库的串口中断处理函数里如果开了多个中断源RXNE、TC、IDLE等中断服务程序会依次判断标志位。如果优先级设得太低被其他中断打断太久可能导致接收溢出ORE标志置位。所以高波特率场景下串口中断优先级不能太低。3. 三种收发模式怎么选轮询、中断、DMA3.1 轮询模式简单但代价大轮询模式就是调用HAL_UART_Transmit或HAL_UART_Receive函数内部会一直等待标志位直到发送完成或接收完成才返回。// 发送示例 uint8_t txBuf[] Hello\r\n; HAL_UART_Transmit(huart2, txBuf, sizeof(txBuf)-1, 1000); // 接收示例不推荐在实际项目中这样用 uint8_t rxByte; HAL_UART_Receive(huart2, rxByte, 1, 1000);优点代码简单逻辑直观不需要配置中断或DMA。缺点阻塞CPU。发送一个字节的时间取决于波特率115200下大约87微秒。如果发100个字节CPU就被占用8.7毫秒。在这期间主循环里其他任务全部停摆。适用场景只在系统初始化阶段发几条配置指令或者调试阶段临时打印。正式运行的产品里主循环中绝对不要用轮询接收。我见过一个项目主循环里用轮询接收GPS数据结果屏幕刷新率直接掉到个位数。后来改成DMA空闲中断CPU占用率从70%降到5%以下。3.2 中断模式按字节触发灵活但开销不小中断模式下每收到一个字节硬件触发一次中断HAL库的中断服务程序把数据存到你提供的缓冲区里。CubeMX里配置好USART后在NVIC选项卡里勾选“USARTx global interrupt”然后生成代码。接收通常用HAL_UART_Receive_IT启动uint8_t rxByte; HAL_UART_Receive_IT(huart2, rxByte, 1);然后在回调函数里处理void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART2) { // 处理rxByte // 重新启动接收 HAL_UART_Receive_IT(huart2, rxByte, 1); } }优点不阻塞主循环CPU利用率比轮询高。缺点每个字节一次中断波特率高了之后中断频率很高。115200下每秒最多11520次中断。如果系统里还有其他中断CPU会被频繁打断上下文切换开销不可忽视。适用场景低波特率、数据量小的场景比如接收上位机的短指令。实操心得用中断接收时一定要在回调函数里重新调用HAL_UART_Receive_IT否则只能收到一个字节。这个坑我踩过不止一次调试半天才发现忘了重启接收。3.3 DMA模式大数据量收发的首选DMA模式下USART的数据传输由DMA控制器接管CPU完全不参与。发送时你把数据放进缓冲区启动DMA然后就可以去干别的事了。接收时DMA自动把收到的数据搬到指定内存区域。CubeMX里配置DMA的步骤在USART配置页的DMA Settings选项卡里点击Add选择USARTx_RX和USARTx_TX。Mode选择Normal或Circular。接收一般用Circular发送用Normal。优先级根据数据重要性设置。接收代码示例#define RX_BUF_SIZE 256 uint8_t rxBuf[RX_BUF_SIZE]; // 启动DMA接收 HAL_UART_Receive_DMA(huart2, rxBuf, RX_BUF_SIZE);但DMA接收有个问题你怎么知道一帧数据收完了这时候就要用到空闲中断IDLE Interrupt。空闲中断的原理是总线在接收完一帧数据后会出现一个空闲周期超过一个字节的传输时间硬件检测到这个空闲就触发IDLE标志。结合DMA可以这样用// 在main里启动DMA接收和空闲中断 __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); HAL_UART_Receive_DMA(huart2, rxBuf, RX_BUF_SIZE); // 在stm32fxxx_it.c里添加IDLE中断处理 void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart2); // 计算接收到的数据长度 uint16_t len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); // 处理数据 processData(rxBuf, len); // 重启DMA接收 HAL_UART_Receive_DMA(huart2, rxBuf, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart2); }优点CPU占用极低适合高波特率、大数据量场景。缺点配置稍复杂需要理解DMA和空闲中断的配合。适用场景GPS模块、4G模块、高速数据采集等。重要提示DMA接收缓冲区的大小要大于一帧最大长度。如果一帧数据超过了缓冲区DMA会覆盖前面的数据。我一般把缓冲区设成最大帧长的2倍以上留足余量。3.4 三种模式对比速查对比项轮询中断DMACPU占用高中低代码复杂度低中高适合波特率低中高适合数据量小小到中大实时性差好好典型场景初始化配置短指令接收大数据收发4. 完整实操从CubeMX配置到代码落地4.1 CubeMX配置全流程打开CubeMX新建工程选好芯片型号。我以STM32F103系列为例其他系列操作类似。第一步配置时钟在RCC里把HSE设为Crystal/Ceramic Resonator然后到Clock Configuration页面把系统时钟配到72MHzAPB1设为36MHzAPB2设为72MHz。第二步配置USART以USART1为例在Connectivity里选USART1Mode选Asynchronous。参数配置Baud Rate: 115200Word Length: 8 BitsParity: NoneStop Bits: 1Data Direction: Receive and TransmitOver Sampling: 16 Samples第三步配置NVIC在NVIC Settings选项卡里勾选USART1 global interrupt。优先级先设成默认后面根据项目调整。第四步配置DMA如果用DMA模式在DMA Settings里Add选USART1_RXMode选CircularPriority选Medium。再Add一个USART1_TXMode选Normal。第五步生成代码Project Manager里选好工具链MDK-ARM或STM32CubeIDE勾选“Generate peripheral initialization as a pair of .c/.h files”这样每个外设的初始化代码会单独成文件方便管理。4.2 发送功能的三种实现轮询发送void uart_send_polling(uint8_t *data, uint16_t len) { HAL_UART_Transmit(huart1, data, len, HAL_MAX_DELAY); }中断发送void uart_send_it(uint8_t *data, uint16_t len) { HAL_UART_Transmit_IT(huart1, data, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 发送完成可以在这里通知任务或启动下一次发送 } }DMA发送void uart_send_dma(uint8_t *data, uint16_t len) { HAL_UART_Transmit_DMA(huart1, data, len); }注意DMA发送时数据缓冲区必须是全局变量或静态变量不能是局部变量。因为DMA传输是异步的函数返回后局部变量就销毁了DMA还在读那块内存结果就是乱码或硬件错误。这个坑非常隐蔽我当初调了半天才发现。4.3 接收功能的完整实现DMA空闲中断这是我最推荐的接收方案适合大多数需要接收不定长数据的场景。首先定义缓冲区和相关变量#define RX_BUFFER_SIZE 512 uint8_t rxBuffer[RX_BUFFER_SIZE]; volatile uint16_t rxLen 0; volatile uint8_t rxFlag 0;在main函数初始化部分// 启动DMA接收 HAL_UART_Receive_DMA(huart1, rxBuffer, RX_BUFFER_SIZE); // 使能空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);在中断服务程序里处理空闲中断void USART1_IRQHandler(void) { // 检查空闲中断标志 if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 停止DMA接收计算接收长度 HAL_UART_DMAStop(huart1); rxLen RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); rxFlag 1; // 重启DMA接收 HAL_UART_Receive_DMA(huart1, rxBuffer, RX_BUFFER_SIZE); } HAL_UART_IRQHandler(huart1); }在主循环里处理数据while (1) { if (rxFlag) { rxFlag 0; // 处理rxBuffer里的rxLen个字节 processData(rxBuffer, rxLen); } }实操心得__HAL_DMA_GET_COUNTER返回的是DMA剩余未传输的数据量用缓冲区总大小减去它就是已经接收到的字节数。这个计算要在停止DMA之后、重启DMA之前做否则计数器会变。4.4 printf重定向到串口调试时经常需要用printf打印信息。在STM32上可以通过重定向fputc函数把printf输出到串口#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }如果用的是MDK-ARM还需要在工程设置里勾选“Use MicroLIB”。如果是STM32CubeIDE需要实现_write函数int _write(int file, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, HAL_MAX_DELAY); return len; }注意printf重定向用的是轮询发送在中断或DMA接收的回调里调用printf会导致阻塞可能引起数据丢失。调试信息最好在主循环里打印或者用DMA发送。5. 常见问题与排查技巧实录5.1 数据乱码先查波特率和时钟乱码是最常见的问题排查顺序如下确认双方波特率一致。别笑我见过太多人一边设115200一边设9600。确认时钟配置正确。如果CubeMX里时钟树配错了USART的实际波特率会偏。用示波器测一下TX引脚上的波形算一下位宽。115200下一个位宽约8.68微秒。确认帧格式一致。数据位、停止位、校验位都要对上。检查电平匹配。STM32是3.3V电平如果接5V设备需要电平转换。直接接可能导致通信不稳定甚至损坏引脚。5.2 接收丢数据查中断优先级和缓冲区丢数据的典型表现是收到的数据不完整或者偶尔少几个字节。原因一中断优先级太低。高波特率下如果串口中断被其他高优先级中断打断太久接收移位寄存器里的数据还没被读走下一个字节就来了导致溢出。解决方法是提高串口中断的抢占优先级。原因二缓冲区太小。DMA接收时如果一帧数据长度超过了缓冲区DMA会从头覆盖导致数据错乱。解决方法是增大缓冲区或者用双缓冲机制。原因三处理速度跟不上。如果主循环处理数据太慢DMA缓冲区被填满后新数据会覆盖旧数据。解决方法是提高处理速度或者用更大的缓冲区和队列机制。5.3 发送卡死查DMA缓冲区和标志位DMA发送卡死通常有两个原因原因一缓冲区是局部变量。前面说过DMA发送是异步的局部变量在函数返回后就失效了。解决方法是把发送缓冲区定义为全局或静态变量。原因二DMA忙标志未清除。如果上一次DMA发送还没完成就启动下一次HAL库会返回HAL_BUSY。解决方法是等待上一次发送完成或者在发送完成回调里启动下一次。void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { dma_tx_busy 0; } }5.4 常见问题速查表现象可能原因排查方法解决方案数据乱码波特率不一致示波器测位宽统一双方波特率数据乱码时钟配置错误检查Clock Configuration重新配置时钟树数据乱码电平不匹配万用表测电压加电平转换电路接收丢数据中断优先级低查看NVIC配置提高串口抢占优先级接收丢数据缓冲区溢出打印DMA计数器增大缓冲区或提高处理速度发送卡死局部变量作缓冲检查变量作用域改为全局或静态变量发送卡死DMA忙查看HAL返回值等待上次完成或加忙标志只能收一个字节未重启接收检查回调函数在回调里重新调用接收函数5.5 几个容易被忽略的细节细节一GPIO配置。CubeMX会自动配置USART对应的TX和RX引脚但你要确认引脚没有被其他外设占用。比如STM32F103的PA9/PA10默认是USART1但如果之前配置了其他功能可能会冲突。细节二串口空闲中断的标志清除。IDLE标志的清除需要先读SR寄存器再读DR寄存器HAL库的__HAL_UART_CLEAR_IDLEFLAG宏已经封装好了这个操作。但如果你手动操作寄存器顺序错了标志清不掉中断会一直触发。细节三DMA传输完成中断和串口中断的关系。用DMA接收时DMA传输完成中断和串口空闲中断是两回事。DMA传输完成中断在缓冲区满时触发空闲中断在一帧数据结束时触发。两者要配合使用不能混淆。细节四低功耗模式下的串口唤醒。如果项目需要低功耗串口可以用来唤醒MCU。配置方法是使能USART的唤醒功能在Stop模式下检测到起始位就唤醒。这个功能在电池供电的设备里很有用但配置起来稍复杂需要仔细看参考手册。6. 进阶技巧多串口管理与协议解析6.1 多串口同时工作的资源分配一个项目里用两三个串口很常见。比如USART1接上位机USART2接GPSUSART3打印调试日志。这时候要注意DMA通道不能冲突。每个USART的TX和RX要分配到不同的DMA通道。CubeMX会自动分配但你要检查一下有没有冲突。中断优先级要合理。接收关键数据的串口优先级高调试串口优先级低。缓冲区要独立。每个串口有自己的接收缓冲区不能共用。6.2 简单的协议解析框架接收到的数据往往需要按协议解析。比如上位机发来的指令格式是[帧头][长度][命令][数据][校验]。可以在空闲中断处理完之后把数据放入一个队列主循环里从队列取数据解析。typedef struct { uint8_t data[256]; uint16_t len; } uart_frame_t; uart_frame_t frameQueue[10]; uint8_t queueHead 0; uint8_t queueTail 0; // 空闲中断里入队 void enqueue_frame(uint8_t *data, uint16_t len) { if (len 256) return; memcpy(frameQueue[queueHead].data, data, len); frameQueue[queueHead].len len; queueHead (queueHead 1) % 10; } // 主循环里出队解析 void process_queue(void) { while (queueTail ! queueHead) { uart_frame_t *frame frameQueue[queueTail]; parse_frame(frame-data, frame-len); queueTail (queueTail 1) % 10; } }这种队列机制可以避免在中断里做复杂的解析保证中断的实时性。6.3 校验方式的选型常见的校验方式有和校验、CRC校验、异或校验。和校验最简单把所有字节相加取低8位。CRC校验最可靠但计算量稍大。异或校验居中。对于短指令和校验或异或校验就够了。对于大数据量传输建议用CRC16。STM32的硬件CRC外设可以加速CRC计算但需要额外配置。我的选择一般项目用和校验简单够用。如果数据量大或者对可靠性要求高上CRC16。别为了省事不校验串口通信受干扰的概率比你想象的高。7. 我个人的实操体会串口这东西入门容易精通难。我刚开始做嵌入式的时候觉得USART不就是收发数据嘛能有多复杂。后来在项目里踩了各种坑才慢慢理解每个参数、每种模式背后的设计意图。最大的体会是不要等到出了问题才去研究底层。CubeMX把很多细节封装起来了点几下鼠标就能生成代码这很方便但也容易让人忽略底层的原理。一旦通信出问题如果你不懂波特率是怎么算的、中断是怎么触发的、DMA是怎么搬运数据的排查起来就非常被动。另一个体会是调试工具很重要。一个逻辑分析仪或者带串口解码功能的示波器能帮你省下大量猜测的时间。数据乱码的时候抓一下波形看看位宽、帧格式比盯着代码看半天有效得多。最后分享一个小技巧在项目初期就把串口调试框架搭好包括printf重定向、DMA空闲中断接收、简单的协议解析。后面不管接什么外设直接复用这套框架能省很多事。我现在的习惯是每个新项目先把串口调通确保能打印、能收指令然后再去搞其他外设。这样出了问题至少有一个可靠的调试通道。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑