资讯动态

STM32串口通信详解:从UART原理到工程实践与调试技巧

发布时间:2026/10/22 11:36:29 来源:尧图企业网站定制
如果让我从STM32单片机学习这条路上选一个最值得停下脚步的节点我会选串口。前面几篇我们把LED点起来了按键的外部中断也接过了定时器能让呼吸灯顺畅地吸气呼气但说句实话那会儿手里的开发板还像个只会自己表演的小盒子指令发出去没有回应数据读进来也看不见程序到底跑成什么样全靠点灯猜。直到串口接通的那一瞬间我脑海里冒出的第一个念头是这东西终于会说话了。串口一点都不神奇。它的本质就是一根线以固定的节奏送出高低电平对面那根线再用同样节奏把电平读回来。可就是这个最简单的通信方式把STM32单片机学习里零散的知识点——GPIO复用模式、外设时钟、中断优先级、数据在寄存器之间怎么流动——全部串成了一整条线。学完串口之后你写的程序才算真正开始和外界交换信息调试也不再是玄学。这篇文章我给它的定位是“第六篇”换句话说前几篇的基础能力你已经具备会用GPIO点灯、能配外部中断、能跑定时器。接下来的重点不是再学一个孤立外设而是通过串口把思路从“单片机的单机表演”切换到“单片机的对外通信”。读完你会得到三样东西串口底层原理的完整拆解、一套直接能抄的串口收发工程、以及我在实际调试中踩过的四个坑和完整排查过程。1. 为什么第六篇要停在串口上1.1 前几篇到底学了什么串口又多了什么前五篇如果按正常的单片机学习曲线走大概覆盖了这几块开发环境的搭建、GPIO输出控制LED、按键输入检测、外部中断、定时器中断以及PWM输出。这些内容的共同点是都在玩单片机的内部资源程序和数据没有离开这颗芯片。比如点亮LED本质是往GPIO的ODR寄存器写一个位按键中断本质是芯片检测到外部电平变化后跳进中断服务函数。串口这一篇的突破点在于数据要跑出芯片了。发送的时候你要把芯片里一个字节的数据拆成一串带时序的电平信号通过TX引脚往外面送接收的时候要从RX引脚上不断变化的电平里还原出对方发来的字节。所以串口学习里最核心的一关不是寄存器怎么配而是建立“信号有时序、时序有协议”的概念。这个概念一旦建立起来之后学SPI、I2C、CAN甚至是无线通信模块的AT指令交互速度都会快很多。从这个角度讲它前承GPIO和中断后启一切通信外设。1.2 串口学完之后你能做的“真东西”立刻变多我实操下来最直观的感受是学完串口你的调试手段从一根LED换成了满屏滚动的日志。以前想知道程序有没有进这个if分支得在分支里点个灯或者用逻辑分析仪抓引脚现在一句printf就能把变量值、状态标志、运行时刻全部打出来。这只是最基础的好处。再往后看串口的应用场景几乎是嵌入式开发里的基础设施和上位机软件做数据交互、接GPS模块读取定位信息、接蓝牙模块做无线透传、接串口屏做界面显示这些模块往往只认识串口协议。甚至可以说很多产品的内部通信都是走串口变种来的只不过物理层换成了RS485或者CAN收发器。所以这一篇学扎实了后面的路会很顺。另外还有一个容易忽略的价值串口调试本身是对“时序敏感度”的训练。你会发现明明波特率配对了数据还是乱码明明发送函数写得没问题接收端就是收不到。这些问题的根源往往在某个不经意的细节里排查一次你对单片机整体运行机制的理解会深一截。2. 串口通信底层原理从一根线的电平变化说起2.1 一帧数据是怎么从单片机飞出去的串口通信在大部分嵌入式场景下用的是UART协议全称叫通用异步收发传输器。它最核心的特点是“异步”也就是发送和接收之间没有单独的时钟线双方靠约定的波特率自己掐节奏。空闲状态下UART的TX线保持高电平。要发送一个字节时TX线先拉低持续时间是一个位的时间这叫起始位用来告诉接收方“准备开始收”。紧接着是数据位从最低位开始一位一位往外送数据位的个数可以是5到9位最常见的是8位。数据位之后发送方会拉高电平至少一个位时间作为停止位代表这一帧结束。这一帧传完TX线又回到空闲高电平。这里我强烈建议你打开串口波形工具或者示波器实际观察一次发送字符“A”的波形。字符A的ASCII码是0x41二进制是0100 0001。因为从低位开始发所以线上先出现的应是1、0、0、0、0、0、1、0这几个电平再配上起始的0和结尾的1你数一数一共10个位时间。把这个波形看懂了比背十遍寄存器列表都管用。2.2 波特率计算与误差容忍度这个值得自己动手算一次波特率指的是一秒内发送多少个位单位是bps。比如9600bps意味着一个位持续的时间大概是104.16微秒。发送方和接收方必须在同一个波特率下工作否则数据的位宽对不上收出来的东西必然错乱。在STM32上波特率的来源是外设时钟经过分频后得到的。以STM32F1系列为例USART1挂在APB2总线上如果APB2时钟是72MHz串口波特率设置为9600bps那么分频系数就是72000000除以9600再除以16结果约等于468.75。这个结果拆成整数部分468和小数部分0.750.75乘以16等于12也就是说USART_BRR寄存器低四位写0xC整数部分写0x1D4合起来就是0x1D4C。即便你现在用的是图形化配置工具这个计算逻辑也建议亲手算一遍因为它能帮你理解“为什么晶体频率变了串口会乱码”。那误差容忍到多少算安全工程经验上收发双方各自的波特率误差最好控制在正负2%到3%以内。原因是接收方通常在每个位的中间位置采样只要累积误差不超过半个位宽数据还能被正确解析。误差一旦超过这个边界就可能出现帧错乱、校验失败、整包丢字节。这也是为什么很多开发板上用8MHz或12MHz晶振时官方库里的RCC配置必须和晶振严格对应某款芯片明明该用8M你却按12M初始化波特率的偏差直接体现在接收端的乱码上。3. 寄存器、标准外设库、HAL库串口配置的三条路径3.1 三条路径分别长什么样用三家风格写同一个初始化差异一目了然。纯寄存器方式里你要自己打开USART1的时钟修改GPIOA复用寄存器把PA9设为复用推挽输出PA10设为浮空输入然后直接往USART_BRR写分频值最后设置控制寄存器使能发送和接收。代码比较原始但每一步都看得见硬件发生变化。标准外设库方式把寄存器包装成函数比如GPIO_Init、USART_Init、NVIC_Init。你还是要知道配置步骤和顺序但不用再记每个寄存器第几位是什么含义代码可读性高不少很适合学习阶段使用。HAL库方式更上层通常配合图形化配置工具生成初始化代码一个HAL_UART_Init函数就能完成配置后续收发也有现成的HAL_UART_Transmit和HAL_UART_Receive。优点是包了大量的异常处理逻辑缺点是这些封装层次会掩盖底层细节出了问题不好定位。我用表格对比一下方便你做选择开发方式对硬件的了解要求代码可读性排查问题的难易适合场景纯寄存器最高偏低全是位操作看得见寄存器但门槛高死磕底层原理、写启动代码标准外设库中等较高函数语义清晰中间层薄容易跟到底层学习原理、快速做DemoHAL库较低高但层次较多出错时栈比较深产品开发、图形化配置3.2 选型逻辑学习阶段和产品阶段的答案不一样我给正在学习的人的建议是先寄存器点到为止然后把重心放在标准外设库上。为什么因为纯寄存器能让你在第一次看USART_BRR寄存器的时候真正理解波特率分频是怎么回事也能让你明白为什么要把PA9配成复用推挽而不是普通推挽。但如果你所有代码都写寄存器开发效率会很低而且一个时钟使能的顺序写反就可能卡掉半天。标准外设库处在“能看到硬件操作又没完全把硬件藏起来”的中间位置。它保留了GPIO_InitStructure这样的结构体你还是要想清楚每个成员填什么这就逼着你去理解引脚模式和时钟频率的关系。我目前所有教学性质的工程基本都用这种方式。至于HAL库我建议你至少等到把串口原理和标准库写法都玩明白之后再切过去体验一下。产品开发时HAL确实舒服图形化配置改引脚方便但前提是你已经知道它背后替你做了哪些事。没有底层基础直接上HAL一旦串口收不到数据你会连从哪里下手查都不知道。4. 直接可抄的串口收发工程附带printf重定向4.1 最简初始化GPIO加USART加NVIC下面这套代码用标准外设库实现目标是把USART1配成9600、8位数据、无校验、1停止位开启接收中断。引脚用的是PA9发送、PA10接收。void Usart1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 打开USART1和GPIOA的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // TXPA9配置为复用推挽输出速度50MHz GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // RXPA10配置为浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // USART1参数配置 USART_InitStructure.USART_BaudRate 9600; 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); // 开启接收寄存器非空中断也就是每收到一个字节触发一次 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); 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); // 最后使能USART1 USART_Cmd(USART1, ENABLE); }有个顺序问题必须提醒使能外设的USART_Cmd一定要放在所有配置之后。我曾经把这条挪到GPIO配置后面结果前面参数还没写完串口已经开始工作了行为完全不可预期。此外PA9、PA10的复用功能对STM32F1来说不需要单独开AFIO时钟但如果你用的是需要重映射的引脚比如把串口1映射到PB6、PB7那就要额外打开AFIO时钟并调用GPIO_PinRemapConfig这一步漏了引脚完全不工作。4.2 printf重定向调试日志从此舒服了嵌入式里最爽的调试方式就是把串口变成printf的输出通道。标准外设库下你需要给编译器提供一个fputc函数让每一个字符都从USART1发出去。核心代码只有三行int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); return ch; }这里的关键点有两个。第一发送完之后要等TXE标志位置位意思是数据已经从移位寄存器转移到了发送缓冲区可以接收下一个字节了。如果不等连续打多字节时可能丢失数据。第二如果你的IDE默认是标准C库的全功能模式malloc、文件操作这些重函数会把半主机模式拉进来导致调试时程序卡在某个不动点。最简单的处理办法是去工程配置里勾选Use MicroLIB微库它裁剪了这些重函数printf重定向就不用处理半主机问题了。重定向完成之后你就能在代码里直接写printf(系统启动当前计数值%d\r\n, counter);我在实际调试时一般会在每个模块的关键路径上都留一条日志比如外设初始化完成打印一行定时器回调里打印计数中断接收里打印收到的原始字节。靠串口日志定位问题比反复猜代码快一个数量级。4.3 中断接收配合环形缓冲区主循环不卡接收端用中断是标准做法但如果每收到一个字节都在中断里立刻处理协议逻辑那么当对方发来一帧连续数据时上层的业务代码会阻塞中断后续字节就丢了。所以我的做法是中断只负责把字节放进缓存区主循环再去解析。这个缓存区用环形缓冲实现最合适不会频繁搬移内存。#define RX_BUF_SIZE 64 volatile uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head 0; volatile uint16_t rx_tail 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ch USART_ReceiveData(USART1); rx_buf[rx_head] ch; rx_head (rx_head 1) % RX_BUF_SIZE; // 可在这里判断head是否追上tail追上了说明缓冲区溢出 } }主循环里取数据时只要比较head和tail是否相等就行while (rx_head ! rx_tail) { uint8_t ch rx_buf[rx_tail]; rx_tail (rx_tail 1) % RX_BUF_SIZE; // 对ch做进一步处理 }这段代码的关键是head和tail的移动都用了取模运算让缓冲区逻辑上首尾相连队伍始终是一个环。实际项目中我还会加一个溢出计数器当head追上了tail就把溢出计数加一方便判断是上层处理太慢还是数据量确实超了。如果你想处理的不只是一个字节而是一帧带格式的数据比如帧头0xAA、长度、数据、校验和那么主循环里应该用一个小状态机来解析而不是把所有字节都攒到缓冲区再一次性判断。状态机的好处是逻辑清晰跳出起始状态后根据当前在帧头还是长度段决定下一个字节怎么处理整个过程不会阻塞也不依赖一次拿到完整数据。5. 串口实战中的四个坑我把完整排查过程留下来了5.1 现象TX端量不到波形配置半天没输出第一次调试串口发送时我在PC端串口助手完全收不到数据用示波器去点PA9引脚也量不到任何电平变化。代码看起来没毛病时钟开了GPIO配置了波特率也设了。排查过程我按顺序走了一遍先确认程序有没有跑进初始化函数加了个翻转LED的测试点发现程序确实进来了再用万用表量PA9的静态电平发现它一直维持在高电平这其实说明引脚本身是正常的最后检查GPIO模式配置才发现我把PA9写成了普通推挽输出GPIO_Mode_Out_PP而不是复用推挽GPIO_Mode_AF_PP。问题就在这里。普通推挽输出是把引脚当作普通IO来用你往ODR寄存器写1它就输出高写0就输出低而串口的TX信号来自USART外设内部必须通过复用功能才能让外设信号连接到引脚上。模式配错USART怎么发数据都到不了引脚外面。改回GPIO_Mode_AF_PP之后波形立刻出来了。这个坑的教训是查到代码逻辑没问题时优先怀疑引脚模式配置特别是复用功能相关的位。5.2 现象能收到字符但全是乱码而且乱得有规律有段时间我收到的东西是“灏忕粺”这样规律性乱码每个字符看似都有固定偏移。这种规律性乱码通常不是连接线松动而是波特率对不上。我用示波器量了一下TX引脚的实际位宽发现一个位大约130微秒换算过来波特率约等于7680bps和设定的9600差了整整一大截。再往下查发现系统时钟初始化配置里写的外部晶振频率是12MHz而开发板上实际焊接的是8MHz晶振。这个偏差导致系统主频整体偏低串口波特率自然跟着错位。修复办法是把时钟配置改成8MHz对应的倍频参数。这件事之后我养成了一个习惯拿到任何一块新板子先看一眼晶振封装上的频率标记再确认启动文件里的时钟配置和它一致。乱码还有一种隐蔽情况发送方波特率和接收方都是9600但误差一个在正方向、一个在负方向单个字节可能没事连续长帧时就逐渐错位。这种情况在低速波特率下还好高速比如115200以上更明显。排查时别只盯着配置拿示波器量实际位宽是最可靠的方法。5.3 现象中断里调了一次printf整个系统卡死接收中断里收到数据后我很自然地想打个日志看一眼内容于是在USART1_IRQHandler里直接写了一句printf。结果程序跑起来以后串口只输出了半行字整个系统就卡住了复位也没用只有断电才能恢复。原因很好解释。printf发送是阻塞式的函数内部要等发送数据寄存器为空才返回下一个字符而我自己做的串口中断优先级设得比较低如果此时主循环正在执行某个同优先级甚至更高优先级的操作printf的等待条件可能一直不满足于是中断无法退出主循环也进不来直接死锁。而且更隐蔽的是printf不是可重入函数中断里调用它会破坏运行时状态。从那以后我的规矩很明确中断服务函数里只做标志位置位、计数累加、把数据塞进缓冲区这三类轻量操作任何耗时的发送、解析、打印统统放到主循环中去。如果确实想在收到某条数据后立刻打印那就设一个标志量主循环看到标志再做printf。5.4 现象接上USB转串口模块上电就飘字符这个问题看起来像乱码但特征不太一样板子一上电串口助手就不停收到0xFF、0x00这类无规律字节哪怕我什么都没发送。问了身边几个人才发现是接线问题。USB转串口模块最常见的是四根线VCC、GND、TX、RX。正确接法是板子的TX接模块的RX板子的RX接模块的TXGND必须接GND。我当时把GND漏接了两边没有共用参考地串口模块只靠信号线上的电平差判断数据结果噪声全被当成有效信号收进来了。接上GND之后飘字符现象立刻消失。另外一个容易忽略的是电平匹配问题。STM32的串口IO是3.3V逻辑电平很多老的USB转串口模块是5V逻辑。如果模块的TX输出高电平是5V直接怼到PA10引脚上短期可能没事长期有超压隐患。稳妥做法是买支持3.3V逻辑电平的模块或者在信号线上串一个1K电阻限流。我之前图省事用过5V模块直连结果某次误操作把模块供电接到了5V板子直接烧了一个引脚从那以后我就非常注意供电和逻辑电平的匹配。6. 从轮询到DMAIDLE中断不定长接收的进阶思路6.1 为什么接收不能一直靠中断每收一个字节触发一次中断的方式在学习阶段是够用的因为它能帮你把中断和串口的关系理解透彻。但它有个明显的上限如果对方以较高的速率连续发几百上千字节CPU会被频繁打断每来一个字节都要进一次中断保存现场、读取数据、恢复现场开销很大。如果主循环里还有比较重的计算任务接收缓冲区就很容易溢出。我自己的一个项目就遇到过这种情况。外设模块不断上传数据每帧上百字节我开始用中断逐字节接收数据一多就丢。后来尝试把接收迁移到DMA上才算把CPU从这种细碎的中断风暴里解放出来。DMA的本质是让硬件自己把串口收到的数据搬运到内存里搬运完成之前CPU几乎不用管只有在特定条件下才需要介入。6.2 DMA配合IDLE空闲中断的工程骨架不定长接收的标准方案是DMA循环模式加串口空闲中断。串口数据到来时DMA自动把字节从数据寄存器搬到内存缓冲区当一帧数据发送完毕总线上出现一段空闲电平时间USART会产生IDLE空闲中断此时我在中断里读取DMA当前还剩多少位置没填就能算出这一帧的有效长度。初始化流程大概是这样的配置好串口的DMA接收请求打开DMA1的对应通道设置目标地址为接收缓冲区数据长度设为缓冲区大小并工作在循环模式同时打开USART的IDLE中断。DMA搬运数据时不会打断CPU只有IDLE中断触发时CPU才来处理这一帧数据。IDLE中断服务函数里的处理顺序要特别小心// 发生IDLE中断时 uint16_t len; len RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 先把缓冲区里的len个字节拷贝出来或置标志 // 清除IDLE标志先读SR再读DR (void)USART1-SR; (void)USART1-DR;清除IDLE标志的方式是先读状态寄存器再读数据寄存器这个操作顺序写错标志清不掉中断会一直被触发。另一个关键点是如果在处理长度之前DMA又源源不断收进了新数据CNDTR计数器会继续变化导致计算出来的长度不对。稳妥做法是先把DMA通道关闭读取计数器处理完数据后重新设置缓冲区地址和长度再开启DMA。不要在DMA还在工作的状态下直接操作缓冲区。6.3 进阶之后我的一点心里话说实话DMA这套东西并不是学习阶段必须立刻掌握的。如果你手头的项目只是做做调试、传输几个字节的指令那第4章的中断接收方案完全够用。DMA的意义在于当你开始接触“连续不断的数据流”这类真实场景时你会意识到CPU的算力应该花在更有价值的地方而不是被串口的每个字节牵着走。我自己的学习路径是先把中断接收折腾到能熟练写出环形缓冲区再去尝试DMA。因为有了前者的对比我才真切感受到“CPU几乎不用被数据搬运打断”是一种什么体验。这个“知其所以然”的过程虽然慢一点但积累下来的感觉是直接抄DMA例程给不了的。最后再说一个我现在的习惯任何一块新板子第一个外设永远先调串口。串口一开日志一打剩下的外设就像有了一张地图。你可以跑个自测程序把所有外设的初始化状态、关键变量、系统运行时间全部通过串口打印出来整个板子的状态一目了然。这个习惯帮我节省了非常多的排查时间也让我在每次遇到“看起来像硬件问题实际是软件问题”的疑难杂症时多了一双可靠的眼睛。

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

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

免费获取报价 →
↑