ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UART通信详解:从物理层电平到STM32 HAL库配置与调试实战

UART通信详解:从物理层电平到STM32 HAL库配置与调试实战 UART在我眼里一直是通信协议里最“亲民”的那个。它只有两根数据线没有时钟线协议帧结构简单到看一眼就能记住可它承载了无数嵌入式设备从调试到量产的全过程。我最早接触单片机就是从点亮LED和printf重定向开始的而那个printf的输出通道就是UART。后来做产品联调、写Bootloader、接GPS模块、配蓝牙模组几乎每一站都离不开它。这篇文章我准备把UART从物理层电平到协议层消息格式一层层拆开讲重点放在STM32 HAL库上的实际操作包括底层时序解读、stm32cubemx配置、HAL_UART_Transmit的阻塞机制分析以及我实际踩过的那些坑比如DMA收发错乱、波特率误差过大导致乱码之类的。不管你是刚入门的新手还是已经在用串口调试但没细想过底层原理的工程师这篇文章都值得读一遍。1. UART的底层机制与数据帧格式1.1 没有时钟线怎么对得上节奏UART全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。它跟SPI、I2C这些同步通信最本质的区别在于没有SCLK这根时钟线。同步通信是发送方把数据线和时钟线一起拉出来接收方跟着时钟沿采样就行而异步通信只有TX和RX两根数据线双方必须各自用自己的时钟来约定采样时刻。这就诞生了一个关键概念波特率。波特率是每秒钟传输的码元数在UART场景下基本就等于比特率单位是bps。通信双方必须配置成相同的波特率比如9600bps、115200bps否则接收方采样到的电平变化节奏对不上收到的就是乱码。我做个生活化的比喻。两个人在没有节拍器的情况下合唱一首歌必须靠心里默数的速度保持一致稍微快一点慢一点整首歌的卡点就全乱了。UART就是这种“各唱各的但速度必须一致”的合作方式。为了让双方能找准起点UART数据帧必须有起始位这是异步通信的锚点。1.2 数据帧拆解起始位、数据位、校验位、停止位一个标准的UART数据帧长这样空闲状态线路保持高电平起始位发送方将TX拉低一个位时间告诉接收方“数据要来了”数据位紧接着发送5~8位数据低位在前校验位可选用来做简单的奇偶校验停止位将线路拉高并保持1~2个位时间表示一帧结束空闲电平是高起始位是低这个设计有讲究。高电平在电气上意味着“没有数据驱动的默认状态”也方便检测线路断开。如果RX引脚一直读到低电平那大概率是设备没接对线或者对方直接拉低了总线。数据位最常见的是8位也就是一个字节配合无校验、1位停止位缩写为8N1。这是绝大多数UART应用的默认配置。接收方怎么判断一帧结束了靠停止位的高电平。停止位结束后线路回到空闲高电平等待下一个起始位的下降沿。1.3 时序图与实际波形解读用逻辑分析仪或者示波器抓UART线上的一帧数据你会看到这样的信号空闲高电平 → 起始位(低) → D0(LSB) → D1 → D2 → D3 → D4 → D5 → D6 → D7(MSB) → 停止位(高)举个具体例子发送十六进制0x41二进制是0b01000001LSB first发送线上实际顺序是1、0、0、0、0、0、1、0。很多人刚用逻辑分析仪看波形时容易把这8个bit看反以为发送的是0x82其实就是因为没搞清低位在前这个规则。关于位时间可以换算一下波特率为115200时每位时间是1/115200大约是8.68微秒。起始位拉低8.68us数据位每个也是8.68us。调试时如果用示波器抓波形可以数一下位宽来判断波特率配置是否正确比如抓一帧测出每位约104us那波特率就是9600。1.4 电平标准TTL、RS-232、RS-485UART说的是协议层逻辑物理层电平标准有多种这一块很容易把人绕晕。TTL电平3.3V或5V系统里的UART直接输出的电平高电平约等于VCC低电平为0V。单片机之间通信、USB转串口模块和MCU之间基本都是TTL电平。RS-232电平上世纪串口标准逻辑1是-3V到-15V逻辑0是3V到15V。PC老式DB9串口就是这种电平所以要经过MAX3232这类电平转换芯片。RS-485电平差分信号A、B两线之间的电压差表示逻辑状态抗干扰能力强适合长距离多点通信。实操中最常见的坑是USB转TTL模块出来的就是TTL电平不能直接怼到RS-232接口上否则要么不工作要么烧设备。同理RS-485设备要用带有485收发器的模块转接。TTL电平的UART线距一般建议不超过1米超过这个距离就得考虑换RS485或者加总线驱动器。2. 波特率的计算逻辑与时钟配置详解2.1 为什么波特率不能随便配很多人直接照搬例程代码串口助手设115200就跑起来了。但如果不知道波特率是怎么来的遇到“为什么换成28800就乱码”这种问题就完全没方向。UART波特率的本质是把外设时钟分频到目标波特率对应的频率。STM32的USART外设时钟源通常是PCLK经过USARTDIV分频后得到波特率时钟。计算关系为过采样16倍时波特率 USART时钟 / USARTDIV过采样8倍时波特率 USART时钟 / (2 × USARTDIV)USARTDIV是一个带小数位的分频值存放在BRR寄存器中整数部分写高16位小数部分写低4位。所以波特率误差的来源是你想要的波特率对应到分频值后小数部分往往不能精确表示于是产生了误差。时钟频率越整分频越准时钟频率越偏误差越大。2.2 STM32 HAL库的波特率配置用CubeMX配置UART时波特率只需要在图形界面填一个数字HAL库会在初始化时自动计算BRR寄存器值。但这里有个隐藏问题USART的时钟源选择。STM32F1系列USART1挂在APB2总线上最高72MHzUSART2和USART3挂在APB1总线上最高36MHz。如果你把PCLK1配成了36MHz而PCLK2配成了72MHz那么相同的波特率设置下USART1和USART2的实际分频系数是不同的。这一点在CubeMX里不会直接报错但从寄存器层面看分频值不一样。对于要求比较高的场景比如需要精确的时间同步建议把波特率误差算一遍。误差 |实际波特率 - 目标波特率| / 目标波特率。一般要求误差低于2%但极限情况下UART接收容忍度大约是4%左右超过这个值就会开始丢字节。2.3 自定义波特率的计算实例假设STM32F407的USART时钟源是84MHz目标波特率是115200。计算分频值USARTDIV 84000000 / (16 × 115200) ≈ 45.5729整数部分是45小数部分0.5729 × 16 ≈ 9.17取整为9。所以BRR寄存器写入的值约为整数45、小数9即0x45_9。实际分频后波特率 84000000 / (16 × (45 9/16)) 84000000 / (16 × 45.5625) ≈ 115242.6误差约0.037%完全在容忍范围内。如果同样的需求放在APB1上比如36MHz计算USARTDIV 36000000 / (16 × 115200) ≈ 19.53125。小数部分0.53125 × 16 8.5取整为8或9都会引入误差。取8的话实际波特率36000000/(16×19.5)115384.6误差0.16%还能接受。实操提示如果Project里允许优先把USART放在主频更高的总线上分频值更大小数误差通常更小。2.4 内部时钟源的选择HSE、HSI与PLL的影响STM32的UART时钟源可以来自SYSCLK分频后的PCLK也可以通过USART的时钟MUX选择HSI或者LSE作为独立时钟源。比如STM32L4系列USART可以选择LSE作为时钟源这样即使系统主频发生变化UART波特率也不会漂移适合低功耗场景。从实操角度三句话总结如果系统时钟从HSEPLL配置UART波特率通常很准放心用。如果你的系统跑在HSI上HSI的精度通常在1%~2%之间高速UART时建议降速到9600或19200。如果项目对时钟要求很高必须外部温补晶振不能靠内部RC。3. STM32 HAL库的UART发送接收API详解与实操3.1 阻塞式发送HAL_UART_Transmit先看最常用的接口HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout);参数含义很直观huart是UART句柄pData是待发送数据缓冲区Size是要发送的字节数Timeout是超时毫秒数。如果发送成功返回HAL_OK超时返回HAL_TIMEOUT参数错误返回HAL_ERROR。注意这个函数是阻塞式的。它会一直等待TXE发送数据寄存器空标志和TC发送完成标志直到所有字节都发完或者超时。在实时性要求高的场景比如10ms周期任务里发100字节可能还没发完下个周期任务就来了这时候就要考虑中断或DMA方式。我实际项目里的习惯是如果是调试打印、初始化上报、低频率命令应答用阻塞发送没问题。如果每毫秒都有数据要发必须用DMA。3.2 中断式发送HAL_UART_Transmit_ITHAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size);中断式发送不会一直占着CPU等数据发完而是注册一个中断回调每次TXE空中断时往数据寄存器填入下一个字节。数据发完后会调用中断回调函数HAL_UART_TxCpltCallback。但有一个坑HAL库在中断式发送期间如果同一个UART对象再次调用HAL_UART_Transmit_IT会直接返回HAL_BUSY。也就是说你不能在还没发完上一批数据时就开始下一批。解决方案是做一个发送队列把待发送数据缓存起来等发送完成回调里再取下一个任务。3.3 DMA式发送HAL_UART_Transmit_DMAHAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size);DMA方式最省CPU数据从内存搬运到UART数据寄存器完全不需要CPU介入。配置DMA时要注意源地址是内存外设地址是huart-Instance-DR数据寄存器。方向是内存到外设MEM_TO_PERIPH数据长度按字节配置。我遇到过一个大坑DMA发送完成后如果直接用同一个缓冲区再次调用DMA发送需要先调用HAL_UART_DMAAbort或者等待TC标志。因为DMA模式和阻塞模式不一样发送完成回调只在所有数据搬完后触发但如果之前DMA没停干净第二次启动时会从错误位置继续搬。3.4 如何选择发送方式一张表说清楚方式CPU占用实时性适用场景阻塞发送高全程占用较差初始化打印、低频应答中断发送中逐字节中断较好中低频通信、不定长发送DMA发送低搬运不占CPU好大批量连续数据、高速通信3.5 接收侧的关键难点定长与不定长接收比发送复杂得多因为你不一定知道对方什么时候发、发多长。常见接收方案有定长接收预先知道一帧固定长度用HAL_UART_Receive阻塞接收或DMA接收固定字节数。不定长接收需要结合RTOReceive Timeout中断或者IDLE总线空闲中断来判定一帧结束。HAL库最常用的不定长接收方案是启用UART的IDLE中断和DMA接收。数据到来时DMA搬运总线空闲时产生IDLE中断在中断里判断接收到的长度处理完后再重新启动DMA接收。这个方案在网上有大量代码但很多都写不完整。核心关键点是第一次启动DMA接收时要调HAL_UART_Receive_DMA设置接收缓冲区和最大长度。在IDLE中断回调中用__HAL_DMA_GET_COUNTER获取DMA剩余计数从而算出实际收到的字节数。处理完一帧数据后不要重新调用HAL_UART_Receive_DMA而是直接重设DMA计数并清除标志位否则会因为缓冲区位置偏移导致数据错位。如果接收缓冲区是环形缓冲最好用两个半满中断或者自定义记录写指针避免覆盖未处理的数据。4. 基于HAL库的UART工程实战配置流程4.1 从零开始配置STM32CubeMX这里我用某次实际项目里的配置步骤做示范目标芯片是STM32F103C8T6系统主频72MHz。第一步打开CubeMX新建工程选择芯片型号配置RCC为HSE外部晶振。如果只有HSI也可以跑但为了后续串口波特率稳定推荐外接8MHz晶振。第二步配置时钟树。将HSE倍频到72MHz。注意APB1总线时钟设为36MHzAPB2设为72MHz。如果打算用USART1就记住它可以跑72MHzUSART2/3跑36MHz。第三步在Peripherals里启用USART1Mode选Asynchronous参数配置如下Baudrate115200Word Length8 BitsParityNoneStop Bits1其他用默认值第四步如果要用DMA接收或发送在DMA Settings里添加USART1_TX和USART1_RX两个DMA通道模式设为Normal。接收DMA建议开启Circular模式方便做不定长接收的环形缓冲。第五步在NVIC Settings里使能USART1全局中断和DMA中断。第六步生成工程代码框架会自动带初始化函数MX_USART1_UART_Init。4.2 HAL库初始化流程的代码级解读生成的MX_USART1_UART_Init函数本质是在填UART_HandleTypeDef结构体然后调用HAL_UART_Init。HAL_UART_Init内部会调用HAL_UART_MspInit这个MspInit函数里配置GPIO时钟、引脚复用、DMA通道和中断优先级。实际踩过的坑是HAL_UART_MspInit里如果没有正确启用GPIO时钟或者复用功能配置错误TX/RX引脚根本没有波形而代码并不会报错。排查时先看这个函数别一上来就怀疑波特率。调试技巧初始化后读取huart-gState和huart-RxState如果都是HAL_UART_STATE_READY说明初始化正常。如果状态是HAL_UART_STATE_BUSY说明上次发送或接收没有完成。4.3 一个可复用的UART驱动模块EIoT86_UART我在项目里写了一个轻量UART驱动模块把阻塞发送、DMA发送、DMA接收、空闲中断回调都封装好直接拿来用。模块结构大概是这样typedef struct { UART_HandleTypeDef *huart; uint8_t rx_buf[256]; uint16_t rx_len; uint8_t rx_complete; } EIoT86_UART_Handle;核心函数void EIoT86_UART_Init(EIoT86_UART_Handle *dev, UART_HandleTypeDef *huart); void EIoT86_UART_SendBlock(EIoT86_UART_Handle *dev, const uint8_t *data, uint16_t len); void EIoT86_UART_SendDMA(EIoT86_UART_Handle *dev, const uint8_t *data, uint16_t len); void EIoT86_UART_StartReceiveDMA(EIoT86_UART_Handle *dev); void EIoT86_UART_IRQHandler(EIoT86_UART_Handle *dev);其中ReceiveDMA初始化后在IDLE中断回调里处理帧数据。关键代码如下void HAL_UART_IDLE_Callback(UART_HandleTypeDef *huart) { if (huart huart1) { EIoT86_UART_IRQHandler(g_eiot86_uart1); } }内部实现里首先要判断接收是否真的发生了通过RxState状态和DMA计数拿到实际长度后置rx_complete标志然后重新配置DMA接收以恢复监听。注意重配DMA时只能用HAL_UART_AbortReceive加新的HAL_UART_Receive_DMA不能直接再调用一次否则容易丢首个字节。4.4 环形缓冲区的工程实现思路如果通信数据量较大建议直接上环形缓冲区。基本结构就是一个读指针加一个写指针写指针由DMA或中断更新读指针由应用层消费。UART环形缓冲的经典实现方式开启DMA接收为Circular模式DMA会自动绕回缓冲区头部。用空闲中断记录当前DMA写位置应用层从读位置消费。读位置和写位置相等时表示缓冲区为空读位置追上写位置时表示溢出需要丢数据或置错误标志。我在实际项目里维护过一个128字节环形缓冲配合1kHz的协议解析任务跑200万包数据从未丢过字节前提是解析任务消费速度必须大于数据到达速度。这是铁律缓冲区再大也救不了消费太慢的应用。5. 常用调试方法与高频问题排查实录5.1 用逻辑分析仪/usb转串口抓波形找问题UART调试我最常用的工具就是逻辑分析仪加串口助手。逻辑分析仪可以精确抓取波形能看到数据帧是不是正确的8N1格式起始位有没有被抢停止位有没有被拉短。抓波形的操作步骤逻辑分析仪的通道接UART的TX脚地线共地。设置波特率和你配置的一致。软件里选UART解码把信道极性设正常。发送几个已知字节比如0x55、0xAA这些是交替电平模式最容易看出位是否偏移。调试时观察到的常见问题起始位正常但后续位宽明显不均大概率是波特率误差过大或者对方的实际波特率和标称值不符。比如某GPS模块标称9600实际却是10400就会让人排查半天。5.2 乱码的四大根源乱码是我被问得最多的问题归纳下来就几个原因第一波特率不匹配。最常见。发送端115200接收端9600出来的全是乱码。确认方法用逻辑分析仪抓一帧波形测量位宽反推波特率。第二电平不匹配。TTL接RS-232、RS-485忘了接终端电阻都可能造成信号畸形。TTL高电平接成3.3V对5V虽然很多时候能工作但长期不推荐。第三时钟源不准。内部RC振荡器作为USART时钟时误差随温度变化明显。解决方法是切换到外部晶振或者降低波特率使误差在容忍范围内。第四寄存器配置错误。比如Word Length被改成7位但发送端还是8位就会出现丢位现象。检查HAL_UART_Init里的WordLength字段。5.3 DMA接收偶尔丢头的典型案例有一次排查一个DMA接收丢头的问题现象是每帧32字节偶尔会丢第一个字节导致整帧数据错位。查了很久最终发现原因在于第一次调用HAL_UART_Receive_DMA之后如果紧接着有数据到达DMA的第一个搬运周期可能还没有完全使能导致首个字节被丢弃。解决方法是在启动DMA接收后等待一段时间或者确认DMA的EN位已经置1再开放数据发送。更稳妥的做法是在初始化完成后先调用一次HAL_UART_Receive_DMA让DMA处于监听状态之后第一个字节就不会丢了。还有另外一个坑如果使用了IDLE中断并清了USART的IDLE标志要注意别用__HAL_UART_CLEAR_IDLE_FLAG误清其他标志。标准做法是读SR寄存器再读DR寄存器来清除IDLE标志HAL库里用__HAL_UART_CLEAR_IDLE_FLAG宏时要注意参数类型。5.4 串口通信异常排查速查表现象排查方向完全没数据检查TX/RX是否交叉接反、GPIO复用配置、共地乱码波特率、时钟源、电平标准、数据位/停止位配置偶尔丢字节DMA配置、中断优先级、缓冲区溢出、消费速度慢发送后卡死Timeout设置太短、TXE/TC标志未清除、HAL库版本差异收发不同步接线是否正确、TTL电压域是否匹配、接线过长引入噪声5.5 两个实际项目的排障复盘第一个项目是环境监测设备的蓝牙升级使用STM32L431和BLE模块通信。现象是每过几分钟就会出现一次整包数据CRC错误。用逻辑分析仪连抓了一个小时波形发现每次出错都伴随着蓝牙模块的电源纹波变大导致UART信号的高电平最低值跌到接收门槛附近偶尔被采样成低电平。最后给BLE模块加了滤波电容问题消失。这个案例说明排障不能只盯数字逻辑层物理层供电抖动也得纳入考量。第二个项目是工业控制板使用RS-485连接多个从机。从机A偶尔无响应排查发现是终端电阻没有匹配信号反射导致边缘抖动。在总线两端加了120欧终端电阻后问题彻底解决。UART本身是点对点短距离设计一旦走了RS-485这种多点长距离总线就必须按总线规范处理不然就会踩反射问题的坑。6. 实操中的深入心得体会6.1 开箱验货拿到一个UART设备先做什么新拿到一个UART设备比如4G模组、GPS模块、指纹模块我建议按这个顺序做开箱验货查手册确认接口电平。TTL还是RS2323.3V还是5V。接线前确认设备默认波特率常见的有9600、115200、38400。上电后先发一个AT指令之类的基本命令看是否有应答。如果没有应答用逻辑分析仪抓设备TX引脚。有些设备上电后会自动发一串log这个log就能反推出实际波特率。如果TX有波形但串口助手收不到优先检查接线是否交叉以及共地。6.2 关于cubeMX版本和HAL库版本不同版本的HAL库在某些细节上有差异比如USART初始化结构体里多了一个AutoBaudRate字段和OverSampling字段低版本库可能没有。所以代码跨版本移植时不能只看函数名还得看结构体字段。特别是C和C混合工程里结构体初始化方式也可能不一样。我个人的建议是项目章程一确定就固定HAL库版本不要随意升级。如果必须升级重点回归测试UART_Init、DMA中断回调、IDLE中断相关代码。6.3 关于调试串口的额外保护在量产板设计里调试串口通常会加电平转换芯片或ESD保护管。UART引脚悬空时容易被静电打坏或者调试时反复插拔杜邦线导致物理损伤。设计时至少预留测试点建议串220欧到330欧电阻再接外部能有效减小瞬态电流冲击。如果你做的是低功耗产品还要注意UART空闲时引脚的电平状态。如果外部设备将TX拉低MCU就会一直收到起始位导致唤醒频繁。这种情况下硬件设计上要考虑下拉电阻匹配或者软件上在低功耗前把RX引脚配置成EXTI并检测空闲状态。6.4 最后一个小技巧遇到数据帧黏包和半包问题先不要急着改代码。把协议设计成帧头长度数据校验的结构接收端只在收到长度字节后才知道需要读多少个字节。这样可以兼容各种HAL接收方式也能在log里直观看到是哪一层的解析出错。我习惯在每个通信模块的log里加上原始数据hex打印比如[RX] 55 AA 1C 00 00 02 3F D0这样无论在实验室还是现场只要看log就能快速定位问题。这套习惯帮我在不少难缠的通信故障中节省了大量时间。
返回列表