STM32串口通信全解析:原理、实战与DMA进阶 开头刚拿到一块新的STM32板子点灯点得欢天喜地之后大多人第一件事就是把串口打出来让板子跟电脑“说上话”。一个printf(Hello)能在串口助手里面显示出来这感觉可比点灯爽多了。可串口通信这块说简单是真简单说坑也是真坑。乱码、收发不对称、中断跑飞、DMA丢数据几乎每个做过嵌入式的人都跟这几个问题打过照面。这篇文章我想把STM32串口通信从头到尾捋一遍包括协议底层的电平原理、硬件接线、工程初始化、收发代码、常见故障排查最后再聊几个我用过的进阶手法。不管你是刚入门的小白还是已经写过几句UART的老手看完应该都能有所收获。适合谁来读一是刚把STM32跑起来、准备跟外设通信的初学者二是已经在做项目但被乱码、丢帧、中断这些问题折磨过想系统补一补串口知识的开发者。我会尽量用大家都能懂的类比和直白的话把这块讲透。1. 先弄明白串口通信到底在传什么1.1 串口通信的本质一根线上电平翻转的游戏串口UART通用异步收发器本质上就是一根线上电平高低的变化。你要发送一个字节就把这个字节拆成一个个比特bit在这些比特对应的时间点上把引脚拉高或拉低接收端再按同样的节奏把这些高低电平还原成字节。这个“按同样的节奏”很关键。串口是异步通信也就是收发双方之间没有一根专门的时钟线告诉对方“现在该读数据了”。双方全靠事先商量好的速率各自用自己的时钟去采样对面的电平。这就好比两个人打电话不用喊“我现在开始说第一句话了”只要双方都按同样的语速念就能大致对得上。但如果你语速快、对方理解慢信息就乱了——对到串口上就是波特率不一致导致的乱码。USART和UART的区别也值得提一句。UART是纯异步串口USART里的字母S是Synchronous多了同步模式可以带时钟输出。不过实际项目里绝大多数人都只用异步模式也就是当普通UART用。STM32的USART外设兼容UART功能寄存器配置上多几个同步相关的位而已。不用被这两个词吓到。1.2 波特率、帧格式与数据校验串口通信的速率用波特率baud rate表示单位是bps比特每秒。常用的值有9600、115200、460800等。波特率不是随便定的它取决于你传输的数据量和线路的稳定性。一个串口数据帧由这么几部分组成起始位一个低电平告诉接收端“数据要来了准备同步”。数据位通常是8位也就是一个字节。校验位可选奇校验或偶校验用于简单的错误检测。停止位一个或多个高电平表示一帧结束给双方一个喘息时间。举个例子最常见配置是8N1意思是8个数据位、无校验位None、1个停止位。那么传一个字节实际要在线上发送10个比特1起始8数据1停止。波特率115200意味着每秒最多可传115200 ÷ 10 11520字节也就是约11.25KB/s。这个换算公式很实用算项目里串口吞吐量的时候会反复用到。校验位的价值在于检测传输中可能出现的单比特翻转。奇校验要求一帧数据里“1”的个数为奇数接收端会重新统计如果对不上就报错。校验位只能发现错误不能纠正错误而且对偶数字节的翻转也识别不出来。所以它只适合低噪声环境下的简单保护。真正要可靠传输还得靠协议层的校验和后面会讲到。1.3 数据方向全双工与交叉连接的原因串口的TX和RX是独立的两个引脚。发送和接收可以同时进行这叫全双工。就像一条双车道公路两个方向各走各的互不干扰。这里有个很经典的新手误区板子的TX要接对端设备的RX板子的RX要接对端设备的TX。两个板子之间通信必须交叉连接。原因很简单A设备的发送引脚当然要连到B设备的接收引脚否则各说各话谁也听不见谁。我见过不少人把TX连TX、RX连RX然后对着调试助手发呆半天检查代码、换波特率最后发现是杜邦线插错了。这种问题不丢人但真的很浪费生命建议接线前先在纸上画一下。2. 硬件接线里的门道2.1 TTL电平与交叉连线STM32的串口引脚输出的是TTL电平逻辑1大概接近3.3V逻辑0接近0V。STM32大部分IO口都是3.3V电平部分型号兼容5V如果你的对方设备是5V TTL电平直接相连有可能烧坏STM32引脚这时候需要电平转换电路或者带电平转换的模块。现在很多传感器模块都支持3.3V直接用没问题但遇到老式5V模块千万别图省事硬接。接线原则我总结为四句口诀TX接RXRX接TXGND必须共地VCC看情况接。如果STM32和电脑通信通常要经过USB转TTL模块。模块上的接线同样按交叉原则来模块RX接板子TX模块TX接板子RX模块GND接板子GND。模块的3.3V输出可以给板子供电如果板子功耗小的话但推荐还是各自独立供电只共地就够了避免电流倒灌。2.2 必须共地最容易忽略的一根线很多新手的第一个诡异问题是板子和USB转TTL模块都插在电脑上数据却怎么都传不过去。查了一圈发现GND那条线没接。共地的本质是让收发双方对“高电平”和“低电平”有一个共同的参考基准。如果不共地两边的GND之间存在电势差哪怕只有零点几伏也会导致接收端把电平判断错。比如发送端输出0V时接收端的地是0.7V那它看到的电平就不是0V而是负的或者偏低的电压逻辑值就乱了。实际调试时哪怕两个设备用同一个USB口供电也建议把GND连上因为USB口的5V在不同端口之间可能有压差。共地是串口通信的生命线没有例外别省略。2.3 电平转换模块与调试工具的选择市面上常见的USB转TTL模块核心芯片有几种。买的时候认准带自动下载电路和电源指示灯的型号能省不少事。有些便宜的模块用的是精简版芯片驱动兼容性一般在Windows下会自动装成一个串口设备但偶尔会有掉线问题。我的建议是作为开发调试用备一两个质量可靠的模块很有必要不要在这种小工具上过度省钱关键时刻会坑你。另外调试工具除了物理模块还得有软件。串口助手类的工具有很多功能大同小异关键看几个能力支持常用波特率、能显示HEX、能定时发送、最好有波形或日志记录功能。调试AT指令、传感器数据这类场景一个顺手的串口助手能帮你节省大量时间。3. 基于官方工具快速搭建工程3.1 用配置工具初始化UART的步骤与参数选择现在开发STM32绝大多数人用图形化配置工具生成工程框架然后在此基础上写业务逻辑。省去手写寄存器初始化代码的重复劳动效率高很多也不容易漏配置。打开配置工具选好芯片型号在引脚图上把UART的TX和RX引脚分配给对应的USART外设然后在UART配置页面里设置模式Asynchronous异步模式波特率115200调试用最常用速度够且稳定数据位8无校验停止位1生成代码后HAL库会提供一个MX_USART1_UART_Init()函数里面计算波特率分频系数。这个分频计算依赖系统时钟频率所以如果系统时钟配置错了波特率也会跟着错最后表现就是乱码。这个问题在第五节会详细聊。3.2 发送字符串的几种姿势轮询、中断与printf重定向发送数据最简单的办法是轮询发送也就是调用阻塞式发送函数一个字节一个字节发完再返回。这种方法代码简单CPU在发送期间没事可干数据量小的时候完全够用比如发一条状态日志、回一个ACK。轮询发送函数的用法如下uint8_t data[] Hello STM32\r\n; HAL_UART_Transmit(huart1, data, sizeof(data) - 1, 1000);第四个参数是超时时间单位毫秒。如果发送在1秒内没完成函数会返回超时错误。这个参数要合理设置太短可能在系统繁忙时误报错误太长会让卡死的程序迟迟不被发现。更常用的是把printf重定向到串口。方法有两种一种是勾选配置工具里的MicroLIB选项然后重写fputc函数另一种是用GCC编译器的_write函数重定向。前者在MDK环境下最常见代码大概这样int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 100); return ch; }重定向之后代码里就能直接写printf(温度: %.2f\r\n, temp);调试体验直线上升。提醒一句调用printf之前先确保串口已经初始化了不然程序会死在中断里而且很难定位。中断发送和DMA发送的细节会在第6节讲这里先记住一个结论轮询适合小数据量短报文中断适合中等数据量且不想阻塞CPU的场景DMA适合大数据量或需要后台持续发送的场景。3.3 接收数据的中断回调与状态机思路接收比发送麻烦。发送是主动的你随时可以把数据丢出去接收是被动的对方什么时候来数据你不知道。如果只靠轮询查接收标志位CPU会被白白耗住。推荐的做法是开启接收中断让UART外设在收到数据时自动触发中断在中断回调函数里把数据保存下来。HAL库的做法是先调用接收函数数据到达后中断产生处理完再调用一次形成持续接收。// 在初始化之后开启接收 HAL_UART_Receive_IT(huart1, rx_buffer, 1); // 中断回调函数 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // rx_buffer里就是刚收到的字节存到自己的队列或缓冲区里 ring_buffer_push(rx_ring, rx_buffer); // 重新开启下一次接收 HAL_UART_Receive_IT(huart1, rx_buffer, 1); } }注意HAL库的中断接收默认是一次性的。也就是说每次收到指定数量的数据后中断就关闭了必须重新调用一次HAL_UART_Receive_IT才能继续接收。上面代码里最后一行就是干这个的。如果你忘了重新开启串口只能收到一次数据就罢工了。这个坑我踩过不止一次几乎每个用HAL库做串口的人都会遇到。接收数据的处理建议不要在中断函数里做太多事尤其是printf、延时这类操作。中断里应该做的只有一件事把数据搬进缓冲区然后立刻退出。数据处理放到主循环里做。这是嵌入式开发的基本纪律遵守它能避免大量难以排查的古怪问题。4. 完整实操一个带收发功能的串口Demo4.1 需求描述与框架设计现在我把上面这些知识整合成一个实际的DemoSTM32通过串口接收电脑发来的命令如果收到字符串LED_ON就点亮板载LED同时回一句LED ON OK如果收到LED_OFF就熄灭LED回LED OFF OK。任何其他输入回一条UNKNOWN CMD。这个Demo麻雀虽小五脏俱全涵盖了串口初始化、中断接收、数据解析、发送响应这几个核心环节。做完它你就能在此基础上扩展成真正实用的通信程序。我把接收设计成“逐字节中断接收 主循环解析”的组合。中断里只负责把字节塞进接收缓冲区主循环里检查缓冲区里有没有完整的一行命令以\r\n结尾有就解析并执行。这样做的好处是中断路径很短不会影响系统实时性解析逻辑在主循环里可以放心使用延时、打印等操作。4.2 初始化、发送和接收的代码细节串口初始化代码由配置工具自动生成我这边直接用默认参数USART1异步模式波特率1152008数据位无校验1停止位TX引脚PA9RX引脚PA10使能USART1全局中断主文件里的业务逻辑大概长这样// 全局变量 uint8_t rx_byte; char cmd_buffer[64]; uint8_t cmd_index 0; // 中断回调逐字节接收 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { if (cmd_index sizeof(cmd_buffer) - 1) { cmd_buffer[cmd_index] (char)rx_byte; if (rx_byte \n) { // 收到结束符置标志位让主循环处理 cmd_complete 1; } } HAL_UART_Receive_IT(huart1, rx_byte, 1); } }主循环里的解析部分while (1) { if (cmd_complete) { cmd_complete 0; cmd_buffer[cmd_index] \0; // 去掉行尾的回车换行 char *cmd strtok(cmd_buffer, \r\n); if (strcmp(cmd, LED_ON) 0) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); printf(LED ON OK\r\n); } else if (strcmp(cmd, LED_OFF) 0) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); printf(LED OFF OK\r\n); } else { printf(UNKNOWN CMD\r\n); } cmd_index 0; } }strtok裁剪掉\r\n是因为Windows串口助手发送命令时通常会在末尾追加回车换行。如果你用某些Linux下的串口工具可能是只发\n。实际项目里最好在解析前做统一处理把开头结尾的空白字符都清掉这样容错性会好很多。4.3 实测流程与预期输出烧录程序后打开串口助手选择正确的串口号波特率设置成115200注意串口助手的参数要和程序完全一致。然后依次发送LED_ON\r\n、LED_OFF\r\n、TEST\r\n你应该看到板载LED相应点亮和熄灭串口助手显示对应回复。如果一切正常说明从硬件接线到软件配置这一整条链路都是通的。如果出问题下面第五节的内容会帮你快速定位。5. 常见问题与排查技巧实录5.1 乱码问题波特率、电平和时钟三个杀手乱码是串口调试里最经典的现象屏幕上显示一堆或者莫名其妙的字符。遇到乱码我一般按下面这个顺序排查。第一嫌疑是波特率不一致。电脑端串口助手的波特率和STM32程序里的波特率只要差一点点接收端采样就会越来越偏最终导致解析错误。先核对两边的波特率是不是完全一样。注意串口助手里选9600程序里初始化成了115200这种低级错误其实很常见。第二是系统时钟配置问题。STM32的波特率是从系统时钟分频出来的如果系统时钟的配置和你预期的不一致比如你用的是外部晶振25MHz但配置工具里默认选的还是8MHz那么计算出的波特率分频系数就是错的。表现在现象上也是乱码而且你检查配置工具里UART波特率设置完全没有问题。这个坑非常隐蔽因为一般人不会想到去查系统时钟。处理方法是在配置工具的时钟树页面里把外部晶振频率改成你板子上实际的晶振值然后再生成代码。第三是电平不匹配。接收端判断高电平和低电平的阈值是固定的如果发出去的电平范围不对接收端可能把信号识别成噪声表现就是整段数据完全不可读。检查一下USB转TTL模块有没有拨码开关或跳帽模块上TTL电平档和RS232电平档别选错了。第四是字符编码问题。比如你发送的是ASCII字符但对方按UTF-8解析中文显示当然就是乱码。这种情况常见于自定义协议里直接传字符串的场景调试时尽量统一用ASCII或者HEX模式观察原始数据。5.2 收不到或收发不同步的问题如果串口完全收不到数据按照从硬件到软件的路径排查先确认杜邦线是否插对了引脚TX接RX交叉再用万用表量一下模块供电是否正常最后再查程序里GPIO复用配置是否正确。GPIO复用配置错了引脚就不会被连接到UART外设上这时候程序看起来没问题但引脚根本没有串口功能。检查配置工具里引脚模式下是不是选的AFAlternate Function复用模式。另一种情况是能收到但不完整一帧长数据被切成几段。原因是逐字节中断接收时如果主循环里处理太慢缓冲区被新数据覆盖了。解决方法是把缓冲区开大或者用DMA接收或者处理逻辑放到独立任务里保证解析速度跟得上。5.3 中断与DMA使用中的坑用HAL库的中断接收最常见的问题就是忘了重开HAL_UART_Receive_IT导致只收到第一包数据。我已经在上面讲过了这里再强调一次HAL的中断接收是一次性的每次回调结束都要重新启动下一次接收。DMA接收有个经典坑你用HAL_UART_Receive_DMA指定接收固定长度比如定100个字节那么必须收满100个字节才会触发完成回调。如果对方发来的是不定长数据你就永远等不齐。这个问题会在下一节给出解决方案核心思路是利用串口的空闲中断IDLE判断一帧数据已经结束。另外DMA和Cache一起使用时要小心缓存一致性问题。带Cache的型号DMA写入内存后CPU读到的可能是旧缓存数据。解决办法通常是开启DMA的存储器端缓存维护或者在接收前后做Cache无效化操作。这个点对初学者可能有点深但遇到DMA数据不对的时候第一时间就该想到它。6. 进阶环形缓冲区、DMA与自定义协议6.1 环形缓冲区的基本思路在上面的Demo里我直接用了一个固定数组做接收缓冲遇到长数据就会溢出。更稳妥的做法是引入环形缓冲区也称作环形队列。环形缓冲区说白了就是一个固定大小的数组用两个指针分别记录写入位置和读取位置。写指针尾部追着读指针头部跑当写指针到达数组末尾时绕回到开头继续写。如果写指针追上读指针说明缓冲区满了可以选择丢弃新数据或者覆盖旧数据。它的价值在于中断里只是往缓冲区里写数据主循环里从缓冲区读取数据两边互不阻塞。即使主循环被其他任务耽误了一段时间数据也不会立刻丢失而是先暂存在缓冲区里。缓冲区的容量决定了你能容忍的最大延迟。实现一个环形缓冲区不难但有一堆细节读写指针的类型、满和空的判断条件、是否禁止中断以保证原子性。这些细节在并发环境下很容易出问题建议先用已有的轮子理解原理之后再自己实现一个也不迟。6.2 使用DMA接收不定长数据DMA真正的威力在于接收和发送大块数据时完全不占CPU。前面提过固定长度DMA接收遇到不定长数据会非常尴尬。解决方案是利用串口的空闲中断IDLE line interrupt来判断数据帧结束。思路是这样的启动DMA接收不管数据有多长往DMA缓冲区里灌就行。当串口线上出现一个空闲状态比如持续一个字节的时间没有新数据到来硬件会产生空闲中断。在空闲中断里读取DMA当前还剩余多少传输计数用缓冲区总长度减掉剩余计数就知道实际收到了多少字节。这和传统做法有个显著不同串口收到数据后不必等一个固定长度一旦对方停止发送就能立刻知道这一帧发完了非常贴合真实通信中数据长度不固定的场景。配置步骤会稍微繁琐一些需要同时开启UART的DMA接收和空闲中断然后在中断回调里根据__HAL_UART_GET_FLAG判断是空闲中断还是数据接收完成中断。如果是空闲中断就停止DMA、处理数据、重新启动DMA接收循环往复。配合前面讲的环形缓冲区就能实现一个非常健壮的不定长串口接收框架。6.3 帧结构设计加校验才敢传数据裸奔的串口传数据对于测试和调试足够了但用于工业现场或关键数据链路一定要加协议帧。最简单的帧结构如下帧头一两个固定字节例如0xAA 0x55用来定位一帧的开始。数据类型一个字节告诉接收端这段数据是什么。数据长度一个字节说明后面数据域的字节数。数据域实际要传的内容。校验和对前面所有字节做累加或CRC校验用来检测传输错误。帧尾可选一个固定字节表示结束。接收端的解析就变成先找帧头找到之后接收帧头后面的数据校验通过后再交给业务层处理。这样做的好处是两个一是抗干扰。工业环境里电磁噪声可能会把字节改成别的值校验和能拦住大多数错误。二是数据自同步。即使接收端在某次上电时正好错过了数据开头只要在后面找到了帧头就能重新对齐数据流不必等系统复位。我之前做过一个项目两块板子之间靠一根长线传数据刚开始就是裸发字节隔三差五出现一个错误读数。后来加了帧头帧尾和校验和一个错误都没再出现。做嵌入式的人应该都有这种体会协议设计多花半天时间能在现场调试省下半个礼拜。这个进阶框架你可以直接在上一节的Demo上扩展。先把环形缓冲区加上再把收包逻辑改成找帧头、解析、校验最后在业务层做响应。一个五脏俱全的通信模块就成型了这套东西放到任何需要用串口的项目里都能复用。我在实际项目中还有一个体会串口调试不要只盯着应用层代码逻辑分析仪或者示波器才是终极武器。如果问题发生在物理层比如波形畸变、毛刺软件层面怎么看都看不出问题这时候在TX引脚上挂个示波器看波形是不是高低电平分明、有没有过冲、波特率对不对一目了然。平时调试串口先用串口助手看现象再用代码逻辑排查最后拿示波器看波形这三板斧下来百分之九十的串口问题都能找到根源。最后再分享一个小技巧在正式项目里我给所有串口调试信息都加了类似[DBG]的前缀方便在串口助手里用过滤功能单独看调试日志。项目做大了之后调试信息多了会烦死人有这个前缀会清爽很多。做串口通信这件事很多坑其实是固定的遇到一次排掉一次下次就不会再犯了。希望大家都能少走弯路一次点亮串口。