STM32 HAL库串口通信实战:从CubeMX配置到中断DMA避坑指南
简介基于STM32F103C8T6微控制器、HAL库和STM32CubeMX的UART串口通信完整资料包面向嵌入式初学者以及需要快速完成串口收发的开发者解决从CubeMX可视化配置到HAL库代码调用的常见疑问。压缩包共146个文件大小约4.31MB以HAL库驱动源文件c/h和CubeMX工程配置ioc/mxproject/uvprojx为核心同时包含o、axf、map、hex等编译中间与最终产物以及scvd、lst等调试记录文件可直接打开工程查看也可编译烧录验证。目前已有6055人学习下载热度较高。借助该工程读者能完整了解STM32CubeMX中系统时钟、GPIO和UART外设的图形化配置流程理清HAL_UART_Init、HAL_UART_Transmit、HAL_UART_Receive等函数的使用情景与参数含义描述中提到的NV_USART模块也在工程中有对应代码适合需要掉电保存接收数据的场景。整套资源自带构建链和调试信息可作为串口通信项目底稿方便二次开发和功能扩展。1. 串口看起来简单但你的 HAL 工程为什么总差最后一步第一次调 STM32 串口的人十有八九会卡在同一个位置代码编译通过、下载成功串口助手却要么一片乱码要么一个字节都不回。尤其在从标准库转到 HAL 库和 STM32CubeMX 之后寄存器操作被藏到图形界面后面很多人连串口挂在哪条总线上、时钟是多少都不清楚调起来跟碰运气一样。这个基于 STM32F103C8T6 的 HAL 库工程包好就好在不是只给一个能闪烁的 main.c而是把 UART 串口通信数据收发的完整链路都串好了CubeMX 配置、HAL 初始化、中断收发、printf 重定向、回环和上位机联调都覆盖到了。对刚入门的嵌入式开发者来说可以顺着工程一步步复现对已经写过不少单片机代码的工程师也能直接借用里面的收发框架和避坑经验。2. 用 CubeMX 把串口配置到可信时钟树、引脚映射、参数面板三个关键点想要串口不翻车第一步不是写代码而是确认 CubeMX 里的配置。很多时候问题根本不出在代码逻辑而是配置阶段埋下的雷。下面三个地方是我每次拿到新板子都会先过的。2.1 时钟树和 PCLK1/PCLK2波特率误差是从这里来的STM32F103C8T6 这颗芯片有多个 USART但它们的时钟源不一样。USART1 挂在 APB2 总线上默认时钟 72MHzUSART2 和 USART3 挂在 APB1 总线上默认时钟 36MHz。这个差异直接影响波特率发生器 UARTDIV 的计算也决定了你设多少波特率最稳。在 CubeMX 里我习惯先把时钟树调到一个确定的状态选 HSE 外部晶振然后让 HCLK 到 72MHzAPB1 预分频器保持默认的 /2这样 PCLK1 就是 36MHz。以下是一份典型的 USART2 初始化代码包里的工程也是这样生成的static void MX_USART2_UART_Init(void) { huart2.Instance USART2; huart2.Init.BaudRate 115200; huart2.Init.WordLength UART_WORDLENGTH_8B; huart2.Init.StopBits UART_STOPBITS_1; huart2.Init.Parity UART_PARITY_NONE; huart2.Init.Mode UART_MODE_TX_RX; /* 收发都开别只开 TX */ huart2.Init.HwFlowCtl UART_HWCONTROL_NONE; huart2.Init.OverSampling UART_OVERSAMPLING_16; if (HAL_UART_Init(huart2) ! HAL_OK) { Error_Handler(); } }注意HAL 库这里的 BaudRate、WordLength、StopBits 只是把用户参数装进结构体真正生效是在 HAL_UART_Init 里读取实例对应的 PCLK 来算分频系数。所以我一直强调PCLK1 是多少必须心里有数。如果 APB1 预分频器被改成 /1PCLK1 变成 72MHz同一份初始化代码生成的实际波特率就翻倍了上位机自然收到全乱码。串口广泛使用的 115200 波特率在 36MHz 下误差很小但如果想用 460800 甚至更高就需要确认芯片能否在目标误差范围内产生这个频率。常见做法是列一张表把期望波特率、实际分频值和误差算出来误差超过 2% 就要另想办法。资源包里没有单独把这张表做成文档但附录会给一个计算函数你可以直接替换 BaudRate 来试。2.2 引脚映射USART2 和 USART1 的 TX/RX 到底在哪个脚F103C8T6 引脚不算多USART 复用关系却是初学者的重灾区。PA9/PA10 是 USART1PA2/PA3 是 USART2PB10/PB11 是 USART3。很多旧例程默认用 USART1如果你新建工程时随手选的是 USART2又在 PA9/PA10 上找信号那当然什么都收不到。包里的工程默认用的是 USART2对应 PA2 作为 TX2PA3 作为 RX2。在 CubeMX 的 Pinout 视图里直接点选 PA2 和 PA3系统会弹出功能选择列表选 USART2_TX 和 USART2_RX 即可。引脚功能分配好后CubeMX 会自动生成 GPIO 初始化代码把引脚模式配成复用推挽输出和浮空输入不需要手写 AFIO。这里有个容易忽略的坑如果你的板子把 PA2/PA3 同时接到了其他外设比如 ADC 通道或者按键那么即使 CubeMX 显示配置成功实际信号也会互相干扰。我一般会在打开工程后先看左下角的资源冲突提示确认没有黄色或红色警告。还有一个检查技巧查看生成的 MX_GPIO_Init 里PA2/PA3 的 GPIO_Mode 是否被正确设置成 GPIO_MODE_AF_PP。如果是 GPIO_MODE_OUTPUT_PP说明引脚根本没切到串口复用模式。2.3 参数面板8N1、Mode、NVIC、DMA 这四个地方先填对串口参数面板看起来很直观实际经常因为漏勾一个选项导致功能残缺。常见的一组稳定配置是Baud Rate 115200Word Length 8 bitsParity NoneStop Bits 1Mode 选 TX_RX 而不是只选 TXHardware Flow Control NoneOver Sampling 16。这套组合就是我们常说的 8N1直接对应串口助手的“115200 8N1”。但只填完这些还不够。很多人第一次用 HAL 库中断接收一直不触发问题十有八九出在 NVIC Settings 页面没有勾选 USART2 global interrupt。CubeMX 生成的中断处理名是 USART2_IRQHandler这个使能位是在 NVIC 里独立配置的不是说你调用了 HAL_UART_Receive_IT 就一定能进中断。我在包里的.ioc文件里特意把这一点保留成了默认勾选状态方便直接编译验证。如果你打算以后扩展 DMA 接收可以在 DMA Settings 页面把 USART2_RX 和 USART2_TX 各添加一个 DMA Channel配置成 Circular 或 Normal。这里建议 RX 用 CircularTX 用 Normal后面第 6 章会更详细讲。3. HAL 库收发函数的拆解和中段接收的推荐写法配置完成只是把 MCU 的串口外设从“0”变成“1”真正干活的是 HAL 库里的收发函数。这个包对轮询、中断、DMA 三种模式都留了接口但一开始不建议全用先把最常用的两种吃透。3.1 轮询、中断、DMA根据数据量和实时性下菜碟HAL 库把串口收发封装成了三组接口看起来像同一类函数实际行为差异很大。轮询模式最直观调用 HAL_UART_Transmit 和 HAL_UART_Receive 时会一直阻塞等待直到数据发完或收到指定长度。它的优点是逻辑简单缺点也明显在等待过程中 CPU 什么都干不了如果上位机一直不发数据程序就卡死在接收函数里。适合只在初始化阶段发调试信息或者你有把握对方一定会回应的情况。中断模式用 HAL_UART_Transmit_IT 和 HAL_UART_Receive_IT函数本身只是把请求登记到句柄里真正的搬运在中断服务程序里进行。CPU 不用忙等等中断到来自动搬运适合小数据量和事件驱动型业务。DMA 模式是通过 DMA 通道搬运数据适合连续大流量传输。CPU 占用率最低但初次配置要花时间理解 DMA 通道映射和中断标志。三种模式的选择逻辑我的经验是寄存器单个字节用轮询普通命令行交互用中断连续上传传感器数据流或接收变长帧用 DMA。3.2 中断接收的正确姿势回调函数里重新挂接收HAL 库的中断接收是一个“一次性”请求。这句话是很多人踩坑的源头。你以为调了一次 HAL_UART_Receive_IT串口就会一直自动接收实际上它在收到指定字节数后就停了之后不再产生接收中断。如果你想持续接收就必须在每次接收完成回调里再次调用 HAL_UART_Receive_IT。包里的工程采用的就是这种持续接收方式代码逻辑非常干净uint8_t rx_byte 0; uint8_t rx_buf[64]; volatile uint16_t rx_len 0; volatile uint8_t frame_ready 0; void UART2_StartReceive(void) { /* 启动第一次接收等待一个字节 */ HAL_UART_Receive_IT(huart2, rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart ! huart2) { return; } /* 这里用换行符作为一帧结束标志 */ if (rx_byte \n) { rx_buf[rx_len] \0; frame_ready 1; } else if (rx_len sizeof(rx_buf) - 1) { rx_buf[rx_len] rx_byte; } /* 关键重新挂接收否则接收只触发一次 */ HAL_UART_Receive_IT(huart2, rx_byte, 1); }这段代码里rx_byte是每次中断填进去的临时变量rx_buf是用户缓冲区rx_len是累计长度frame_ready是供主循环判断的帧完成标志。回调函数执行完后必须再次调用 HAL_UART_Receive_IT这样下次数据到达时才能继续触发中断。之所以这样设计是因为 HAL 库为了减轻中断处理负担认为“收完一次就结束”更安全避免开发者忘记处理接收缓冲而反复触发。把重新挂接收的调用放在回调末尾是嵌入式工程师普遍采用的做法虽然不是唯一方案但最容易理解。3.3 printf 重定向到串口让调试输出更顺手串口调通以后下一步往往是想用 printf 打日志。因为 printf 内部最终调用 fputc而你要做的就是把 fputc 重新定向到串口。包里的工程提供了一份兼容 Keil 和 GCC 的写法核心代码只有几行#include stdio.h int fputc(int ch, FILE *f) { /* 用串口2发送一个字节阻塞等发送完成 */ HAL_UART_Transmit(huart2, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }在 Keil MDK 中需要同时勾选 Target 选项里的 MicroLIB这样才能避免半主机模式导致的硬件异常。如果你用的是 GCC 工具链有些版本会把底层输出函数定义为__io_putchar那时候就改成int __io_putchar(int ch) { HAL_UART_Transmit(huart2, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }完成重定向后main 函数里的printf(Hello\r\n)就会自动从串口输出。这里有个提醒printf 内部有格式化过程耗时相对较长不要在高频中断里调用否则会拖慢整个中断响应甚至导致中断标志丢失。4. 接线、回环和 PC 联调把收发流程完整跑一遍代码写好后必须动手把它跑起来。这一章先讲硬件连接再做两种验证一种是板卡自己的回环测试另一种是和 PC 上位机联调。顺序不能乱先排除硬件问题再验证代码逻辑。4.1 硬件接线TTL 转 USB 模块TXD/RXD 必须交叉绝大多数人调试串口时会用一个 USB 转 TTL 模块常见方案是 CH340 或 FT232。这个模块的作用是把 PC 的 USB 转成串口 TTL 电平然后和 STM32 的 UART 引脚直连。连接方式看着简单却最容易搞错。模块的 TXD 要接 STM32 的 RX 引脚模块的 RXD 要接 STM32 的 TX 引脚也就是交叉连接。很多初学者把两根线一一对应直连结果完全不通还以为是代码问题。我给的接线表是这个包的实际接线依据USB转TTL模块STM32F103C8T6说明TXDPA3 (USART2_RX)模块发单片机收RXDPA2 (USART2_TX)单片机发模块收GNDGND必须共地否则参考电位不一致3.3V一般不需要模块不向板子供电时不用接特别提醒地线一定要接。很多“串口乱码”“数据对不上”的诡异问题根源就是模块和板子各自用不同的电源没有电位参考点信号完全错乱。如果你用 5V 输出的 TTL 模块先确认 STM32 板子上的引脚是兼容 5V 输入F103 的大部分引脚是 5V 容忍的但在没有手册佐证时我建议还是给串口做电平转换或者直接把模块调到 3.3V 档稳一点。4.2 板级回环测试先短路 TX/RX再谈其他回环测试是用来验证 MCU 串口外设本身是否工作的最快方法。做法是把 USART2 的 TX 和 RX 引脚短接也就是 PA2 和 PA3 之间连一根杜邦线然后通过程序自发自收。如果发送一个字节中断回调能收到一个字节说明芯片内部串口通路完全正常问题与它无关。资源包里有个 LoopbackTest 函数代码很简单void UART2_LoopbackTest(void) { uint8_t test_data[] {0x55, 0xAA, 0x01, 0x02, 0x0E}; for (uint8_t i 0; i sizeof(test_data); i) { HAL_UART_Transmit(huart2, test_data[i], 1, HAL_MAX_DELAY); HAL_Delay(10); } }发送完成后你在串口助手里应该能连续收到 55 AA 01 02 0E 五个字节。如果收不到说明硬件连接或初始化存在问题但也别急着换芯片。先用万用表量 PA2/PA3 是否有电压变化再看 CubeMX 的初始化是否正确。如果把这五个字节当成一帧数据还可以测试中断接收的稳定性看是否丢字节或者多字节。4.3 PC 联调串口助手配合 Python 脚本验证数据时序回环测试过了以后下一步是撤掉 PA2/PA3 之间的短接线把模块的 TXD 接 PA3RXD 接 PA2然后打开串口助手设置 115200、8N1、无流控。发送任意字符串比如“Hello”如果工程里的中断回显代码在工作你会在接收区立刻看到同样的内容。除了用图形化串口助手我也会用脚本做更精确的时序验证。以 Python 的 pyserial 为例包里的验证脚本思路如下import serial ser serial.Serial(COM3, 115200, timeout1) test_cmd bHello STM32\r\n ser.write(test_cmd) recv ser.read(64) print(recv:, recv) ser.close()如果有条件把发送周期调成 10ms 一次连续运行观察几百帧数据里有没有出现丢字节。配合逻辑分析仪抓取 UART 波形可以同时看到帧间隔和每个字节的实际宽度比单纯靠肉眼判断可靠得多。这也是我自己每换一套硬件环境都会做的事。5. STM32 串口避坑指南5 个问题每个都能让工程师迷糊半天很多串口问题不是代码逻辑不行而是硬件状态、工具配置和时序细节混在一起。下面这五个坑都是我在拆这个工程的过程中反复遇到过的按“现象、原因、解决”的方式写出来方便你对照排查。5.1 乱码和满屏??先查波特率再查地线最后查时钟现象程序下载成功也能看到数据输出但显示的是乱码不是可读的 ASCII 字符。有时候是每隔一段时间固定错几个字节。原因最常见的是上位机波特率和 MCU 实际波特率不一致。比如你以为 PCLK1 是 36MHz但 CubeMX 里 APB1 预分频器被改成了 /1实际 PCLK1 变成了 72MHz波特率翻倍。另一个高频原因是没有共地模块和板子的参考电位不同。还有一种情况是发送缓冲区内容未初始化直接把 0xCC 这种地址填充字符发了出来。解决先用一条杜邦线把 TX 和 RX 短接做回环测试判断是 MCU 内部的问题还是外部模块问题。然后把串口助手波特率从 9600 到 460800 都试一遍看是否有某一个档位能够稳定识别。最后打开 CubeMX 的 Clock Configuration确认 HCLK、APB1、APB2 这三级时钟别只看外设配置页里的波特率数字。5.2 只能发不能收Mode 没开全、NVIC 没勾选、RX 接错现象发送给上位机字符正常但从串口助手发给 MCU 的数据没有任何反应回调函数永远不执行。原因第一种是初始化时 Mode 只选了 UART_MODE_TX没有选 TX_RX导致 RX 通路根本没使能。第二种是 NVIC 设置里没有勾选串口全局中断中断接收无法触发。第三种是接线时把模块 TXD 接到了 STM32 TX 上等于两个发送端对着怼。解决先看生成的huart2.Init.Mode确认是不是UART_MODE_TX_RX。然后打开 CubeMX 的 NVIC Settings 页面确认USART2 global interrupt是 Enabled。最后再核对硬件连线用万用表量模块 TXD 的电压正常情况下空闲应该是高电平这样能排除模块损坏或 RX 引脚虚焊的问题。5.3 中断只进一次回调里忘了重新启动接收现象程序刚上电时能正常收到第一帧数据后面就再也没反应复位一下又能收一次。原因这就是前面第 3 章说的 HAL 库中断接收是一次性的。调用一次 HAL_UART_Receive_IT只会为接收指定长度字节准备一次中断。如果你的回调函数里没有再次调用 HAL_UART_Receive_IT那么接收通道就被“用完”了。解决在 HAL_UART_RxCpltCallback 末尾重新调用HAL_UART_Receive_IT。如果你使用的是 DMA 循环模式则不需要每次重新挂载但也要在 Idle 中断处理函数里确认 DMA 是否还在继续传输。这个问题的典型特征是“复位一次能用一次”只要见到这个规律直接检查回调函数末尾。5.4 内部时钟 HSI 引发的波特率漂移上电正常跑热就丢字节现象刚上电时通信正常运行半个小时后开始偶然丢字节再久一点甚至出现连续乱码。原因如果 CubeMX 的时钟源选的不是 HSE 而是 HSI系统主频就靠内部 RC 振荡器。HSI 的初始精度大约在 ±1% 左右再加上温度漂移会让 UART 波特率慢慢偏离目标值。F1 系列的 UART 对波特率误差比较敏感超过 2% 后采样窗口会出错数据就会随机丢失。解决把时钟源改成外部晶振 HSE并让 CORE 主频跑到 72MHz。如果硬件设计里没有外部晶振那就降低波特率到 9600 或者 19200用更大的位时间窗口来抵消频率漂移。还有一种兼顾方法是开启自动波特率检测但 F1 的自动检测能力有限只适合特定场景不能完全依赖。5.5 上电瞬间收到垃圾字节浮空引脚和复位时序现象每次给 MCU 上电串口助手的接收区会立即收到一个 0xFF 或乱码字节之后一切正常。原因在 MCU 复位期间GPIO 还没有被初始化成复用功能引脚处于浮空状态外部干扰或模块上电瞬间的脉冲可能被当成起始位。另外如果 TX 引脚被电平拉低串口就会误认为发送开始从而产生一个假字节。解决在 CubeMX 里把串口 RX 引脚的上拉设置为 Pull-up让空闲状态保持在高电平。同时在 main 函数里等系统时钟和外设初始化完成后再调用 HAL_UART_Receive_IT不要在复位后立刻开启接收。还有一个效率更高的做法是让上位机主控发送一帧握手命令只有收到握手命令后才开始业务数据交互主动过滤掉上电噪声。6. 进阶玩法IDLE 空闲中断配合 DMA收任意长度帧前面讲的中断接收适用于数据量不大、帧格式简单的场景。如果数据是一包一包来的每包长度又不固定再靠逐字节回调拼帧CPU 容易被频繁中断拖累。更稳妥的方案是用硬件空闲中断加 DMADMA 负责把数据连续搬进内存串口空闲中断负责告诉 CPU“一包数据已经结束了”。这个思路在很多量产项目里都能见到。需要做的事情分两步。第一步在 CubeMX 的 DMA Settings 里为 USART2_RX 添加一个 DMA 通道模式选 Circular数据宽度都选 Byte。第二步在代码里定义一个足够大的接收缓冲区和空闲中断处理逻辑#define RX_DMA_BUF_SIZE 256 uint8_t uart2_rx_dma_buf[RX_DMA_BUF_SIZE]; volatile uint16_t uart2_rx_len 0; void UART2_StartDMAReceive(void) { /* DMA 循环接收一直往缓冲区写 */ HAL_UART_Receive_DMA(huart2, uart2_rx_dma_buf, RX_DMA_BUF_SIZE); /* 开启空闲中断 */ __HAL_UART_ENABLE_IT(huart2, UART_IT_IDLE); }第二步需要把空闲中断的判读代码放进 stm32f1xx_it.c 的 USART2_IRQHandler 里。CubeMX 生成的中断服务函数已经调用了 HAL_UART_IRQHandler所以做法是先在它之前判断空闲标志void USART2_IRQHandler(void) { uint16_t len 0; if (__HAL_UART_GET_FLAG(huart2, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart2); /* DMA 还剩下多少计数就知道这一帧实际收了多少字节 */ len RX_DMA_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart2_rx); if (len 0) { uart2_rx_len len; /* 在这里把 uart2_rx_dma_buf 里的数据交给协议解析层 */ } /* 重新开启 DMA 接收因为 Circular 模式下会自动循环 */ HAL_UART_Receive_DMA(huart2, uart2_rx_dma_buf, RX_DMA_BUF_SIZE); } HAL_UART_IRQHandler(huart2); }这个方案的要点是DMA 一直在循环写缓冲区空闲中断意味着总线上出现了一小段安静时间正好是一帧结束的信号。取 DMA 剩余计数器的差值就能知道新到的数据长度。相比逐字节中断这样每帧才进一次中断CPU 占有率低得多而且不会漏字节。要注意空闲中断标志必须在 HAL_UART_IRQHandler 之前判断和处理因为 HAL 库本身不会替你清这个标志。如果你先调用了 HAL_UART_IRQHandler它可能会修改一些状态位导致空闲标志被意外覆盖。从那以后我每次拿到一套 UART 相关的新代码都会强制自己先做一遍“电源共地检查、回环测试、断帧观察”这三件事。串口通信工作量不大但坑全藏在细节里按这个顺序能省下一大半排错时间。希望帮到你。本文还有配套的精品资源点击获取