
1. 串口通讯为什么是嵌入式的“刚需”每个玩STM32的人迟早都要过USART这关。说句大白话串口是你和单片机之间最直接的对话窗口。调试代码要打印日志、把传感器数据传到上位机、和WiFi模块模块交换数据几乎都离不开串口。我见过不少人跳过去先学SPI、I2C结果回头排查问题时又灰溜溜回头把USART补上——倒不是说前两个不重要而是USART调试起来太方便了它能让你“看见”单片机内部发生了什么。USART全称是Universal Synchronous Asynchronous Receiver/Transmitter通用同步异步收发器。STM32上这个外设比51单片机自带的UART要复杂不少但也因此灵活得多。你只需要三根线——TX、RX、GND——就能实现双向通信如果是板载USB转串口芯片的开发板甚至连RX/TX都不用自己接直接一根USB线连电脑打开串口助手就完事。这篇博文我会从零开始拆解USART先从硬件原理讲清楚波特率、帧格式这些概念到底是啥意思然后分别用寄存器方式、标准外设库方式和HAL库方式写出可用的初始化代码最后把我在实际项目中踩过的坑一次性倒出来。无论你是刚点完LED灯、正准备学串口的小白还是已经会用但经常被乱码折磨的进阶玩家这篇东西都能帮你节省不少折腾时间。为了不让文章变成“抄寄存器手册”我会把每一个关键配置背后的计算逻辑和判断依据也讲明白。毕竟串口通讯的坑大半都出在“配置看似一样实际完全不对”上。2. 从硬件角度看USART帧格式、波特率和接线2.1 一个字节到底是怎么发出去的USART发送一个字节并不是直接把8根线并排甩出去而是把这些位排成一队按时间顺序逐个送出。这就是“串行”的含义。以最常用的8位数据位、无校验、1位停止位格式8N1为例一个完整的帧长这样空闲状态TX线保持高电平。起始位先拉低1个位时间告诉接收方“我要开始发数据了请注意”。数据位通常先发最低位LSB first共8位。停止位拉高1个或2个位时间表示这一帧结束。接收端怎么判断每个“位时间”的长度这就需要通信双方提前约定波特率。波特率表示每秒传输多少个符号bit常见的有9600、115200、460800等。双方波特率误差必须足够小否则时间一长采样点就会逐渐偏移轻则偶尔收到错数据重则整帧报废。STM32的USART外设内部有一个接收移位寄存器它会在每个位时间的中间点对RX线做采样。只要你的波特率误差在可容忍范围内标准是±2%~3%中间采样就能稳定落在每个bit的有效区间内。很多人调试串口收到乱码第一反应是怀疑接线其实多半是波特率没对上或时钟配置有偏差。2.2 波特率寄存器BRR的计算逻辑STM32的USART波特率由时钟源分频得到。标准库/HAL库里写USART_Init时填的波特率数值最终都会被换算成BRR寄存器里的分频系数。换算公式是USARTDIV 时钟频率 / (16 * 目标波特率)以STM32F103系列最常用的PCLK2 72MHz为例想得到115200波特率USARTDIV 72000000 / (16 * 115200) 39.0625BRR寄存器的DIV_Mantissa部分存整数39DIV_Fraction部分存小数0.0625乘以16后的整数1所以最终写入BRR的值大约为39 4 | 1 625。如果用标准库这些换算由库函数内部完成你只需要保证传入的波特率数值正确即可但如果你自己写寄存器版驱动就必须自己算这个数。这里有个特别容易忽略的坑USART1挂载在APB2总线上最高72MHz而USART2和USART3挂载在APB1总线上最高36MHz。如果你的主频是72MHzAPB1预分频系数是2那么USART2、3的时钟源就是36MHz而不是72MHz。用同一个波特率USART1和USART2的BRR值完全不同。很多人把USART1的初始化模板照搬到USART2结果波特率偏了将近一倍串口助手自然是满屏乱码。2.3 TX、RX、GND三根线的连接规则单片机之间或单片机与USB转串口模块之间连接时一个最常见的错误是TX接TX、RX接RX。正确的规则是交叉连接设备A的TX接设备B的RX设备A的RX接设备B的TXGND必须共地。为什么GND这么重要因为串口信号是单端电平接收方判断高/低电平参照的都是自己的地电位。如果两边不共地地电位存在压差轻则信号判断错误重则损坏引脚。我见过有人用两个独立供电的板子互发串口数据数据一直不稳定后来接上共地线立刻恢复正常。另外如果用的是3.3V逻辑的STM32和5V逻辑的老式设备通信不能直接相连需要电平转换芯片或分压电路。现在大部分USB转串口模块都是3.3V/5V兼容的一般有一个跳线帽或拨动开关切换电平使用前要确认一下。2.4 同步还是异步USART的“US”到底指什么USART比UART多出来的“S”是Synchronous意味着它支持同步模式——也就是除了TX/RX之外还需要一根SCLK时钟线由主机提供时钟信号。同步模式下数据收发节奏完全由这个SCLK决定适用于和外接移位寄存器芯片这类需要时钟同步的设备通信。但在绝大多数嵌入式场景和PC串口助手通信、接GPS模块、接ESP8266等我们用的都是异步模式。异步模式不发时钟线通信双方靠预先约定波特率来“对齐”节奏。这也是为什么波特率哪怕差一点点都不行——没有时钟线帮你纠正节奏只能靠扎实的定时配置来保证采样点稳定。3. 寄存器级USART配置拆解从时钟到收发3.1 第一步打开USART和引脚的时钟在STM32上任何外设要工作必须先把对应的时钟使能否则写寄存器也没反应。USART1属于APB2总线GPIOA也属于APB2总线USART2和USART3属于APB1总线但对应的GPIO如PA2/PA3往往在APB2上。所以开启时钟时步骤要分清。以USART1、PA9作为TX、PA10作为RX的经典配置为例RCC-APB2ENR | RCC_APB2ENR_USART1EN; // 使能USART1时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN; // 使能GPIOA时钟此时USART1的时钟源来自PCLK272MHzAPB2外设时钟使能后还需要注意如果你把APB2预分频系数改过实际波特率会随之变化。系统初始化时SystemInit函数已经把时钟配置成72MHz这个前提要心里有数。3.2 第二步配置引脚复用功能STM32的PA9和PA10默认是一般GPIO需要把它们切换到USART1的复用功能状态。对标准库而言GPIO_Init结构体中设置GPIO_Mode_AF_PP复用推挽输出给TXGPIO_Mode_IN_FLOATING浮空输入给RXGPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; // TX GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; // RX GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure);TX为什么是复用推挽输出因为此时引脚的控制权交给USART外设USART内部逻辑会把数据位驱动到TX引脚上复用模式下的推挽结构能够提供足够的驱动能力。RX为什么是浮空输入因为输入模式下引脚需要读取外部电平浮空输入让外部信号直接驱动内部施密特触发器适合这种点对点的直接连接。如果你是HAL库引脚模式可以填GPIO_MODE_AF_PP或GPIO_MODE_AF_OD再调用HAL_GPIO_Init。实际上HAL库对USART的RX引脚一般仍建议配置成GPIO_MODE_INPUT有些代码示例也直接用GPIO_MODE_AF_PP两种都能工作关键在于不要让引脚内部上拉把RX电平拉死。3.3 第三步USART初始化参数的手动计算不用标准库的话你需要直接操作USART_CR1、CR2和BRR寄存器。这里我给出一个最小配置流程对应8N1、115200、无硬件流控、使能接收和发送USART1-BRR 0x271; // 72MHz时钟下的115200也可用公式计算 USART1-CR1 | USART_CR1_TE | USART_CR1_RE; // 使能发送和接收 USART1-CR1 | USART_CR1_UE; // 使能USART这个0x271是怎么来的回头看公式USARTDIV 39.0625整数部分是39即0x27小数部分0.0625*161即0x1拼起来就是0x271。注意BRR寄存器的高4位存小数低12位存整数但实际硬件分配不是简单的拼接而是DIV_Mantissa占[15:4]DIV_Fraction占[3:0]。标准库的USART_BRR计算宏会帮你处理这个细节手写时务必按位操作。CR1寄存器里还有几个常用位USART_CR1_PCE校验使能、USART_CR1_M字长选择、USART_CR1_RXNEIE接收中断使能、USART_CR1_TXEIE发送空中断使能。在2.0版本的参考手册里有些位的位置定义可能和旧版有差异一定要以实际芯片型号对应的参考手册为准。3.4 第四步发送一个字节的轮询实现寄存器版发送单个字节的核心逻辑是把数据写入数据寄存器DR之前必须先确认发送数据寄存器为空TXE标志位置1否则新写入的数据会覆盖掉还没发出去的旧数据。void USART1_SendByte(uint8_t ch) { while (!(USART1-SR USART_SR_TXE)); // 等待TXE为1 USART1-DR ch; }TXE标志何时置1当数据从DR转移到移位寄存器时TXE会变1表示你可以在DR里写入下一个数据了。但TXE置1不代表这一帧已经完整发完——如果你要在发送完一帧后拉高/拉低某个引脚例如RS485方向控制还必须等TC发送完成标志变1。TC在移位寄存器发完最后一位数据后置位。区分TXE和TC是这个环节最常见的混淆点。3.5 第五步接收一个字节的轮询实现接收时只要RXNE标志为1就表示接收移位寄存器已经收到一个完整字节可以从DR读出来uint8_t USART1_ReceiveByte(void) { while (!(USART1-SR USART_SR_RXNE)); // 等待RXNE为1 return (uint8_t)(USART1-DR 0xFF); }读DR这个动作本身会自动清除RXNE标志。有些初学者会发现如果长时间没有数据进来程序会一直卡在while循环里。这在单任务裸机程序里问题不大但在实际项目中千万别用这种死等方式接收不定长数据否则一旦对端不发数据你的单片机就“冻”住了。解决方案是加超时判断或者干脆改用中断接收。3.6 中断接收和发送的配置要点中断接收是项目中最常见的模式。配置步骤在初始化时使能USART_CR1的RXNEIE位。配置NVIC使能USART1全局中断并设置抢占优先级。在中断服务函数USART1_IRQHandler中读取SR判断RXNE标志然后读DR。void USART1_IRQHandler(void) { if (USART1-SR USART_SR_RXNE) { uint8_t data USART1-DR; // 把data存入环形缓冲区或直接处理 } }中断发送则要小心一个陷阱如果开启TXEIE中断只要发送寄存器为空就会立刻进中断。如果你初始化后没有数据要发但TXE本来就是1中断会疯狂触发形成中断风暴。因此通常的做法是需要发送数据时先关中断把一个字节写入DR然后再开TXEIE中断在发送完成中断里继续发送下一字节全部发完后关闭TXEIE。这个坑我现在提出来是因为不少人在做DMA发送和中断组合时被卡过。裸机调试半天看不到现象一查发现是中断风暴把程序拖死了。4. 标准外设库与HAL库两套工程的实际代码实测4.1 标准库版的完整初始化函数如果你是STM32F103系列老用户大概率还在用标准外设库。下面这段代码是我常用的USART1初始化模板直接复制就能用void USART1_Init(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // 引脚配置TX复用推挽RX浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // 串口参数 USART_InitStructure.USART_BaudRate baudrate; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); // 中断配置接收 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); }初始化完成后用USART_SendData(USART1, ch)和USART_ReceiveData(USART1)收发数据。注意标准库的USART_SendData只把数据写到DR真正发送完成要等USART_GetFlagStatus(USART1, USART_FLAG_TC)返回SET。4.2 HAL库版的初始化与收发回调如果你用的STM32CubeMX生成的HAL代码里串口初始化主要是MX_USART1_UART_Init之后调用HAL_UART_Receive_IT(huart1, buffer, 1)启动单字节中断接收。HAL库的机制看起来简单但回调函数比较多容易绕晕HAL_UART_RxCpltCallback一帧接收完成对应你启动时指定的字节数。HAL_UART_TxCpltCallback发送完成。HAL_UART_ErrorCallback出错回调比如OREOverrun Error溢出错误。HAL库的坑在于如果你启动HAL_UART_Receive_IT(huart1, rx_buf, 1)后只接收一个字节就进一次回调而回调里没有再次调用HAL_UART_Receive_IT去启动新一次接收那么串口只会在第一次收到数据时进回调之后再也不触发。这是新手最容易忽略的问题。正确写法是每次回调结束前重新启动接收。4.3 做一个简单的回环Demo电脑发什么单片机回什么回环测试是验证串口通路是否正常的经典方法。你现在手头的板子只要能连接PC串口助手就可以做这样一个实验单片机初始化好串口开启RXNE中断。中断服务里收到一个字节立即把同一个字节调用发送函数发回去。电脑端串口助手发送任意数据如果接收区能原样看到说明硬件通路和软件配置基本没问题。用中断方式实现回环volatile uint8_t rx_data; void USART1_IRQHandler(void) { if (USART1-SR USART_SR_RXNE) { rx_data USART1-DR; while (!(USART1-SR USART_SR_TXE)); USART1-DR rx_data; } }这个Demo看起来简单却能把时钟配置、引脚复用、中断优先级、波特率计算全部串起来验证一遍。如果回环失败重点排查以下几点串口助手的波特率是否和初始化参数一致。TX/RX是否接反。是否共地。引脚是否选对了复用功能。很多情况下回环不通不是代码问题而是硬件连接问题。硬件连接问题比软件问题更隐蔽因为它不报错。5. 常见问题排查与实战经验总结5.1 乱码的N种可能性与解决顺序串口收到乱码是嵌入式开发中最高频的问题。按照排查优先级从高到低波特率是否一致PC端和单片机端设置不一致几乎必然乱码。波特率是否算错检查你的串口挂载在APB1还是APB2时钟源频率是否和计算时一致。电平是否匹配3.3V的STM32 TX接5V的USB转串口模块如果模块不兼容3.3V电平识别会异常。GND是否连接不共地时信号波形可能出现漂移。接线是否交叉TX接RX是常识但肉眼排查时经常看走眼。提示调试串口问题时先用逻辑分析仪或示波器看TX引脚的波形。如果波形幅值正常、波特率间隔正确那问题一定出在接收端反之则是发送端或时钟配置问题。没有示波器时可以降低波特率到9600再试因为低波特率对线路质量和时钟误差的容忍度更高。5.2 使用printf重定向时要注意什么很多人喜欢用printf直接输出调试信息这就涉及重定向fputc函数。标准库的printf最终会调用底层字符输出函数在ARM MDK下可以这样写int fputc(int ch, FILE *f) { while (!(USART1-SR USART_SR_TXE)); USART1-DR ch; return ch; }还需要注意MDK下要勾选“Use MicroLIB”否则标准库的printf实现可能因为半主机模式卡死在底层。GCC环境则通常需要自己实现_write系统调用方法不同但本质一样。重定向失败的现象是程序运行到第一个printf就停住或者在调试器里报半主机相关错误。此外printf本身是有开销的它会做格式化字符串的解析。如果你在中断服务函数里直接调用printf轻则拖慢实时性重则因为重入问题导致数据错乱。正确做法是把待打印数据存入缓冲区在主循环里统一处理。5.3 ORE溢出错误为什么可怕OREOverrun Error是指接收寄存器里的数据还没被读走新数据又到了。硬件处理方式是丢弃新数据同时置位ORE标志。如果开启了RXNE中断ORE还会把RXNE中断一并触发。问题是很多人处理中断时只判断RXNE标志读到DR后以为万事大吉却没有显式清除ORE标志。结果ORE一直挂着干扰后续中断状态判断甚至导致串口像“假死”一样再收不到数据。寄存器版处理方式if (USART1-SR USART_SR_ORE) { volatile uint8_t tmp USART1-DR; // 读DR清除ORE }标准库版可以调用USART_ClearFlag(USART1, USART_FLAG_ORE)但要注意清标志的操作顺序有时要先读SR再读DR不同版本芯片可能有细微差别。保持简单粗暴的“读一次DR”也是有效清除手段。5.4 接收不定长数据的3个实用方案串口接收最核心的问题是我怎么知道一帧数据什么时候结束常见方案有三种。第一种是固定长度帧。接收N个字节后认为一帧完成适合协议中每条数据长度固定的场景实现最简单。中断接收时设置接收字节计数器计数到N即触发完整帧处理。第二种是空闲中断。HAL库中有HAL_UART_Receive_IDLE这类扩展接口在检测到总线空闲超过一个字符时间的无数据状态时触发回调。这样一帧不定长数据结束就能被及时捕获。第三种是超时轮询。主循环里不断检查“距上次收到数据的时间”是否超过阈值超过则认为帧结束。这个方案不依赖特定外设特性移植性最强但主循环的扫描周期要足够快否则可能漏帧。我项目里最常见的是第二种用IDLE中断配合DMA传输DMA负责把数据搬进缓冲区IDLE中断负责标记一帧结束。CPU几乎零开销速度也快。5.5 DMA发送和接收又该如何处理如果你只需发送短数据轮询发送完全够用。但如果你要发送几十甚至几百字节的日志或固件升级数据轮询发送会长时间占住CPU这时候DMA就派上用场了。配置DMA发送的流程使能DMA时钟配置DMA通道、外设地址USART1-DR、内存地址发送缓冲区、传输方向、传输大小。开启DMA发送完成中断。使能USART的DMA请求USART_CR3的DMAT位。调用DMA_Cmd启动传输DMA会把整个缓冲区自动按字节搬运到DR期间CPU可以做其他事。关键坑点DMA传输过程中绝对不能修改正在传输的缓冲区内容。另外DMA配置时“内存地址增量模式”要开启外设地址增量要关闭。因为每次访问的都是同一个DR。接收使用DMA时需要特别小心“半字/字节对齐”的问题以及由DMA模式和IDLE模式配合时的缓冲区指针计算。如果你刚开始接触建议先跑通轮询和中断再上DMA一步步来不容易被搓败。5.6 我踩过的一个典型坑上电后串口不断收到0x00或0xFF有段时间我用某国产MCU的串口调试一上电串口就疯狂收到0x00。查了一圈发现是RX引脚复用配置错误导致引脚处于悬空状态外部微小干扰被当成数据信号。解决方案是给RX引脚配置内部上拉或者检查初始化顺序确保GPIO复用模式在USART使能之前就正确设置。还有一种情况PA9和PA10的默认AF映射确实也是USART1但在部分F103芯片上如果你之前把这两个引脚配置为模拟输入没有正确切回AF模式就会导致RX读不到正常电平。处理办法是重新初始化GPIO必要时复位USART外设RCC_APB2PeriphResetCmd。6. 从USART延伸到整个嵌入式学习路线串口通讯用到的东西其实是嵌入式开发中最基础也最必需的几个能力的集合看数据手册查寄存器、计算分频系数、配置中断、理解外设与GPIO的交互。你在USART上学到的这套“读手册-推算-写配置-调试”的流程可以丝滑平移到SPI、I2C、定时器等外设上。我在实际项目中还有一个心得无论项目多忙先花五分钟把串口打通再把其他模块的调试信息通过串口引出来这会成为后续所有调试工作的“眼睛”。没有串口日志传感器数据异常时你根本不知道是硬件问题还是软件问题有了串口很多问题一眼就能定位。如果这篇文章让你少走了一些弯路那我也算没白写。接着往下学DMA和IDLE接收吧这两样是串口通讯从“能用”走向“好用”的关键分水岭等你把“中断DMA空闲中断”这套组合练熟基本上就可以自信地对接任何带UART接口的模块了。