STM32串口通信实验详解:标准库USART配置与模拟平台调试 串口通信在嵌入式开发里的地位就像白开水在厨房里的地位——太基础了但离了它什么都干不成。最近我重新整理了一遍STM32F103标准库下的USART串口通信实验场景选在嵌入式模拟平台上完成不碰实物硬件只靠虚拟仿真环境把收发链路跑通。这篇文章就把整个实验的过程、代码、踩坑记录全部摊开揉碎讲清楚。无论你刚开始接触STM32标准库还是想用模拟平台快速验证串口逻辑都可以参考这套思路。整个实验的核心很直接在模拟平台上搭一块STM32F103配置USART1实现单片机主动发送字符串、虚拟终端接收显示同时把虚拟终端发下来的字符回显回去。听起来简单但里面涉及GPIO复用配置、时钟使能、波特率计算、中断接收、printf重定向、虚拟终端接线这些环节任何一个地方有偏差结果就是黑屏无输出或者一堆乱码。1. 实验背景与方案设计1.1 为什么用模拟平台跑串口实验在没有开发板、没有USB转串口线、没有示波器的情况下模拟平台是验证嵌入式逻辑最有效率的手段。我这次用的是常见的虚拟仿真环境比如Proteus这类把STM32F103芯片、虚拟终端、虚拟示波器全部放进去完全不需要物理接线就能观察串口波形和收发数据。模拟平台最大的好处是“错了可以立刻改”。真实硬件上如果TX和RX接反可能要拿万用表量半天在模拟平台上断一根线、改一个配置、重新仿真几秒钟就能完成迭代。尤其对正在学习USART协议的新手虚拟终端能直观展示字符的收发过程这种反馈对建立概念非常有帮助。但要注意模拟平台终究是模型它没法完美模拟芯片的电气特性、边沿抖动和外部干扰。你可以在模拟平台上证明“逻辑正确”但绝对不能断言“硬件没问题”。我的习惯是先用模拟平台做功能验证再到实物上做第二轮测试。这也意味着实验阶段的代码必须按真实工程的规范来写养成好习惯之后移植到板子上几乎不用改。1.2 实验目标与验收标准在做实验之前我习惯先定验收标准。没有标准的实验很容易调到“看起来差不多”就停了实际上可能埋着雷。这次实验的目标拆成三条STM32F103通过USART1主动向上位机发送字符串虚拟终端能显示完整内容。虚拟终端发送任意字符单片机接收后原样回显屏幕上能看到重复输入的内容。用虚拟示波器或逻辑分析仪能抓到TX引脚上的波形且波形波特率与配置值一致。三条全过才算实验完成。很多人只验收第一条觉得能发出去就结束了结果接收中断从来没用过——串口通信最核心的其实是双向交互。1.3 环境与工具链选型我的实验环境组合如下模拟平台嵌入式仿真环境Proteus风格芯片型号选择STM32F103C8编译器Keil MDK使用AC5编译器固件库STM32F10x标准外设库辅助工具虚拟终端、虚拟示波器为什么要坚持用标准库而不是HAL库因为标准库直接封装寄存器通过对初始化结构体的赋值能清楚地看到每个配置项对应到哪个寄存器位。HAL库封装层级深虽然代码写起来方便但遇到问题排查时新手往往不知道底层发生了什么。标准库虽然“老”但它依然是理解STM32底层机制的绝佳教材。你去看很多老工程师维护的项目代码库里还躺着一大批标准库工程所以这个技能不能丢。版本方面提一句标准库的官方版本已经停更网上能找到的固件库压缩包资源也比较杂建议直接用带标准外设库示例的完整工程模板配置好头文件路径尽量减少手动添加源文件带来的低级错误。2. 串口协议与标准库初始化细节2.1 USART帧格式没那么神秘USART通信的本质是把并行数据变成串行比特流在一条线上按时间顺序发送。一个完整的数据帧包括起始位低电平1位、数据位通常8位低位在前、可选的校验位、停止位高电平1位或2位。我们常说的“9600-8-N-1”意思就是波特率9600bps8位数据无校验1位停止位。波特率就是每秒传输的比特数9600bps意味着一个bit的持续时间是104.16微秒这个值以后调波形的时候会用到。我在实验中用了最常用的8位无校验模式。很多人问9位模式怎么配其实标准库里只要改USART_WordLength_9b然后配合校验位配置就可以。但无特殊需求建议不要碰9位模式后期协议解析会麻烦很多。2.2 波特率计算公式必须自己算一遍标准库的USART_Init函数会自动计算波特率寄存器BRR的值但“会调API”和“懂原理”是两码事。BRR寄存器的计算方法是USARTDIV PCLK / (16 × 波特率)其中PCLK是串口所在总线的时钟频率。对STM32F103来说USART1挂在APB2总线上典型值是72MHzUSART2和USART3挂在APB1总线上典型值是36MHz。很多人乱码就是因为分不清这两个总线频率。以USART1、波特率9600为例USARTDIV 72000000 / (16 × 9600) 468.75BRR寄存器的低16位分为两部分高12位存放整数部分468低4位存放小数部分。小数部分的计算是0.75 × 16 12也就是0xC。所以BRR的值是0x1D4C。这里有个容易懵的地方小数部分不是直接写0.75而是乘以16后取整。再算一个USART2的对照。如果USART2的PCLK是36MHz同样9600波特率USARTDIV 36000000 / (16 × 9600) 234.375整数部分234小数部分0.375 × 16 6所以BRR 0xEA6。可以看到同样的波特率因为总线时钟不同BRR寄存器值完全不同。如果你把初始化代码从USART1直接复制到USART2不修改任何参数标准库会根据USART2的挂载总线重新计算BRR但前提是你得在RCC里正确使能USART2的时钟。波特率误差也是老生常谈的问题。一个bit时间是固定的如果BRR近似取整导致误差偏大积累到几个字节后就会错位。误差超过2%基本就会乱码。72MHz时钟下9600波特率是完全可整除的这算是很理想的组合。2.3 标准库初始化结构体到底做了什么标准库的初始化本质上就是写寄存器的过程。USART_InitTypeDef里的每个字段对应USART_CR1、CR2、CR3的某些位结构体字段对应寄存器说明USART_BaudRateUSART_BRR波特率分频值USART_WordLengthUSART_CR1的M位8位还是9位数据USART_StopBitsUSART_CR2的STOP位停止位个数USART_ParityUSART_CR1的PCE和PS位校验使能与校验方式USART_ModeUSART_CR1的TE和RE位发送使能与接收使能USART_HardwareFlowControlUSART_CR3的RTSE和CTSE位硬件流控我当年学标准库的时候最大的困惑是为什么GPIO配置和USART配置要分开其实USART外设要收发数据必须通过特定的引脚这些引脚由GPIO模块管理。所以第一步是让GPIO引脚进入合适的工作模式交给USART外设使用。TX引脚必须配置为复用推挽输出GPIO_Mode_AF_PP因为推挽输出能提供足够的驱动能力复用模式又告诉GPIO模块“这个引脚的控制权交给USART外设”而不是普通的GPIO输出寄存器。RX引脚配置为浮空输入GPIO_Mode_IN_FLOATING串口空闲时引脚为高电平浮空输入状态下完全由外部信号驱动这是串口接收的标准姿势。如果你在实际板卡上担心干扰可以改用上拉输入但在模拟实验里浮空输入就够了。还有一点必须提醒在配置代码里TX引脚设置GPIO_Speed是有意义的但RX输入模式下Speed字段无关紧要。很多教程把Speed在两种模式下都写结果新手以为必须这样做其实了解本质之后你自己就知道哪些字段可以省略。3. 模拟实验实现过程3.1 Proteus里的工程搭建与连接在模拟平台里建工程的步骤很简单但细节决定成败。我把流程整理一下从器件库中选一个STM32F103C8放到画布上。双击芯片确认Crystal Frequency设置为8MHz。这一步直接关系到SystemInit初始化出来的系统时钟是否是72MHz进而影响波特率。在虚拟仪器列表中找到VIRTUAL TERMINAL放到画布上。连线STM32的PA9USART1_TX接到虚拟终端的RXDPA10USART1_RX接到虚拟终端的TXD。注意是交叉连接这是串口最基本的规则。双击虚拟终端确认波特率设置为9600数据格式8位无校验。这里有个比较容易忽略的点虚拟终端本身有RTS和CTS引脚。如果不用硬件流控这两个引脚可以悬空不用担心。我见过有人把RTS也接上结果仿真时行为异常排查半天才发现是流控引脚干扰。STM32F103C8的VDD和VSS引脚在Proteus里默认是隐藏的不一定。我的习惯是主动检查一遍虚拟电源把必要的电源引脚接好避免仿真到一半芯片不工作。不同版本仿真软件差异比较大比较保险的做法是参考该版本自带的STM32示例工程看它的电源接法再照做。3.2 串口初始化与发送代码实现初始化代码是整个实验的核心。我用的是标准库先把USART1的GPIO和通信参数配置好。直接贴完整代码下面会逐步讲解#include stm32f10x.h #include stdio.h void USART1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能GPIOA和USART1的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE); // TX - PA9 复用推挽输出 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); // RX - PA10 浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); } void USART1_Init(void) { USART_InitTypeDef USART_InitStructure; 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_Cmd(USART1, ENABLE); }时钟使能这一步放在最前面是有原因的。STM32的任何外设使用前必须先打开它在RCC里的时钟门控否则写寄存器根本无效。初次接触标准库的人容易漏掉这一步然后百思不得其解为什么串口不工作。发送字符串的代码可以封装成函数核心是等待发送数据寄存器空这个标志void UART_SendString(char* str) { while (*str) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)(*str)); str; } }这里最关键的细节是发送数据前一定要等TXE标志。TXE表示发送数据寄存器为空把数据写进去之后硬件自动把数据搬到移位寄存器一位一位发出去。如果不等待TXE就直接连续调用USART_SendData第二个数据会把第一个还没移出的数据覆盖掉最终只能收到残缺的内容。等TXE还是等TCTC是发送完成标志表示整个帧包括停止位都发完了。如果只是连续发送数据等TXE就够了如果你要立刻切换引脚模式或者关闭串口必须等TC。我见过有人在这两个标志上栽跟头发送函数刚执行完就切引脚模式最后一个字节丢失就是因为只等了TXE没等TC。3.3 printf 重定向的实现细节很多人喜欢用printf输出调试信息但在嵌入式里直接用printf编译能过运行时直接HardFault。原因是C库的printf默认依赖于半主机模式需要调试器提供底层支持裸机环境下根本没这个服务。解决办法有两个。最省事的是在Keil里勾选Use MicroLIB这个精简版C库不依赖半主机模式实现一个fputc函数就够了。第二个是手动实现完整库的重定向用标准库自己的机制绕过半主机模式但代码比较绕学习阶段没必要。勾选MicroLIB之后在工程里加一个fputc函数int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }做完这件事printf(Hello USART!\r\n)就会把字符串逐个字符通过串口发出去。注意字符串里加了\r\n纯文本终端里只发\n很多时候不会换行要配合回车符。3.4 中断接收与缓冲区设计接收部分我强烈建议用中断而不是在主循环里轮询RXNE标志。轮询的方式不是不能用但它会占死CPU而且很容易因为主循环里有其他耗时操作而丢数据。中断的好处是数据一到就存进缓冲区主循环什么时候有空再去处理。先配置NVICvoid NVIC_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); 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); }优先级分组只需要设置一次整个工程里一般放在main函数开头。如果漏掉NVIC_PriorityGroupConfig默认分组方式可能是0所有中断优先级配置会错乱接收中断不触发的十有八九就是这个原因。中断服务函数里最朴素的做法是收一个字节回显一个字节但这样会影响中断执行时间。更专业的思路是定义一个环形缓冲中断只负责把数据放进去主循环负责取走处理#define RX_BUFFER_SIZE 64 volatile uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint8_t rx_head 0; volatile uint8_t rx_tail 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t data USART_ReceiveData(USART1); uint8_t next (rx_head 1) % RX_BUFFER_SIZE; if (next ! rx_tail) { rx_buffer[rx_head] data; rx_head next; } } }读取USART_ReceiveData这个操作本身会自动清除RXNE标志所以如果你先调用了USART_ClearITPendingBit再读数据反而可能多此一举。正确的顺序是先读数据再去考虑别的操作。环形缓冲区为什么要判断next ! rx_tail因为缓冲区满的时候如果继续写入就会覆盖还没取走的数据。很多教材里的实现做得比较简陋满了就直接丢掉这在实验里问题不大但工程化的代码必须处理这种临界情况。主循环里取数据回显while (1) { while (rx_tail ! rx_head) { uint8_t data rx_buffer[rx_tail]; rx_tail (rx_tail 1) % RX_BUFFER_SIZE; while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, data); } }注意我把回显放在主循环而不是中断里。如果在中断里直接回显抛开速度不谈一旦中断频繁触发主循环会被饿死整个系统的实时性会变得很差。串口中断里只做“存数据”这一件事是最安全的做法。4. 模拟平台上的调试与验证4.1 虚拟终端的正确打开方式在Proteus里放置VIRTUAL TERMINAL之后不要直接运行。先双击它把波特率设置成9600确认数据格式是8-N-1。很多人在这一步没设置默认值可能是2400或者300结果单片机明明正常发送终端却显示乱码。虚拟终端有两个关键引脚RXD和TXD。单片机发送到虚拟终端的RXD所以PA9接RXD虚拟终端的TXD发到单片机的PA10也就是RX引脚。这个交叉关系如果能理解就不会接反。接反的现象很有迷惑性如果TX接TX、RX接RX虚拟终端显示空白程序看起来一切正常因为发送一直没停过但数据就是进不了终端。4.2 运行验证从发送到回显我把主循环写成这样int main(void) { NVIC_Config(); USART1_GPIO_Init(); USART1_Init(); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); printf(STM32F103 USART Simulation Ready!\r\n); while (1) { // 主循环中的其他任务 // 回显已经在循环里处理 } }运行模拟后虚拟终端应该立刻显示那一行欢迎字符串。然后我把鼠标点到虚拟终端里随便敲几个字符每个字符都会立刻在终端里再次出现这就是回显成功。很多人到这里就宣布实验完成但我建议再加一个检查在循环里主动发送一些带格式的数据比如把ADC采样值通过printf上报。这样可以验证格式化输出是否正常。如果printf输出的数字是乱的说明发送时机或缓冲区管理还有问题。4.3 用虚拟示波器抓波形验证波特率串口是否真的按9600在工作肉眼看不出来。最好在Proteus里放一个虚拟示波器把通道接到PA9引脚上。程序发送一个固定字符串示波器上应该能看到一个低电平的起始位然后跟着8个数据位和一个高电平停止位。实测的时候可以用示波器的光标测量一个bit的宽度。9600波特率下一个bit应该是104.16微秒。如果测出来明显偏差比如变成90微秒那就是系统时钟和波特率配置不匹配而不是看起来“能收到数据”就没问题。模拟平台上的波形和真实示波器还是有区别的真实芯片的边沿会有上升沿和下降沿的过渡时间模拟波形基本是理想的方波。但度量位宽、确认电平极性模拟平台足够用了。5. 高频问题与排查技巧5.1 虚拟终端完全没有输出这是我在做实验时遇到得最多的现象没有之一。排查顺序很重要不要东一榔头西一棒子。我的习惯是从时钟开始往下查首先检查芯片属性里的晶振频率是不是8MHz。如果这里设置成其他值SystemInit算出来的系统时钟会错USART的波特率跟着错。硬件上连接不对TX和RX接反终端当然没反应。再检查GPIO模式TX必须是复用推挽输出写成普通推挽输出引脚就不会跟随USART控制。最后看波特率设置代码里是9600虚拟终端里也得是9600。还有一种情况比较隐蔽代码里循环发送太快Proteus的虚拟终端处理不过来导致界面看起来没有任何输出。这时候可以暂停仿真操作虚拟终端或者干脆在发送循环里加一个延时函数让发送节奏慢下来。5.2 输出乱码的定位方法乱码的本质是收发双方的bit时序不一致。终端显示乱码先别急着改代码把波特率配置和时钟配置核对一遍顺序如下确认SystemInit是否被执行。标准库工程如果没包含SystemInit相关代码系统时钟可能停在8MHz甚至更低。确认USART1的时钟是否来自APB2APB2预分频是否是1即72MHz。确认虚拟终端的波特率与代码一致。用示波器量一下实际bit宽度和理论值对一下。乱码还有一种非典型原因你发送的是十六进制数据终端却以ASCII模式显示。比如发送0xA5终端显示一个奇怪的符号。这种不是时序问题是数据解释方式不一致。调试时统一用十六进制显示能避免很多误会。5.3 接收中断一直不触发接收中断不触发比发送无输出更难排查。我遇到的常见原因有三个第一个是NVIC没配置。只写了USART_ITConfig使能RXNE中断却没有使能NVIC通道中断根本进不了CPU。第二个是优先级分组没设置。第三个是中断服务函数里没有读取USART_ReceiveData导致RXNE一直置位中断反复进入看似“卡死”而不是“不触发”。还有一种工程化场景中断服务函数没写或者名字写错。标准库的启动文件已经定义了USART1_IRQHandler这个弱符号如果你的函数名拼错一个字母编译不报错运行也没反应。排查这类问题可以在中断函数里加一个全局变量翻转测试看程序到底有没有进中断。5.4 常见问题速查表现象可能原因排查顺序终端完全无输出晶振频率不对、TX/RX接反、时钟未使能、引脚模式错芯片属性 → 接线 → RCC配置 → GPIO模式输出乱码系统时钟不对、波特率不匹配、终端格式错误晶振 → APB时钟 → 终端波特率 → 示波器量位宽接收无回显中断没使能、NVIC没配置、缓冲处理出错中断标志 → NVIC通道 → 缓冲区读写索引死机或卡死printf半主机模式、中断频繁占用CPU勾选MicroLIB → 中断只读数据 → 主循环处理输出终端显示部分字符发送时未等待TXE、缓冲溢出增加TXE等待 → 降低发送频率 → 检查缓冲区大小6. 经验总结与扩展方向6.1 从模拟平台迁移到真实板卡的衔接模拟实验跑通之后转真实硬件时要注意几件事。首先是电平问题STM32的串口引脚是3.3V电平如果接USB转串口模块模块的IO电平最好也选3.3V否则需要电平转换。其次是共地USB转串口模块的GND必须和板子GND连在一起这是串口通信能成立的前提。最后是接线方向TXD接RXD、RXD接TXD这个规则在实物上和模拟平台完全一样。实物上调试串口我建议再准备一个逻辑分析仪。串口调试助手显示乱码的时候逻辑分析仪抓一下TX引脚波形一眼就能看出波特率偏差到底有多大。这个工具比盲改代码高效得多。6.2 还可以扩展哪些方向实验做完了不要停在“能收发”这个层面后续可以往三个方向延伸第一是DMA收发。在高速通信场景下CPU中断方式逐字节收发会占用大量CPU时间DMA 空闲中断可以实现不定长数据的高效接收这是工程中非常常见的需求。第二是自定义协议解析。串口的本质是传输字节流收发双方约定好帧头、帧尾、校验和之后通信才能具备可靠性。可以尝试设计一个简单的帧格式比如AA 55开头、后跟长度和数据、CRC校验结尾然后在模拟平台里验证协议的解析过程。第三是多机通信。STM32的USART本身支持多处理器通信模式在模拟平台上搭两个芯片让它们互相发数据能加深对串口地址匹配和总线冲突的理解。6.3 最后想说的经验踩过几次坑之后我最大的体会是串口异常的根源大部分不是USART外设本身而是时钟系统。很多人改代码改了无数遍最后发现是晶振频率或者APB分频配置错了导致波特率计算基础就不对。所以每次重新建工程或者换板子我都会先写一个定时器翻转GPIO的小程序确认系统时钟正常再往下做串口。另外标准库虽然已经算是旧技术但读懂它对理解嵌入式底层依然非常值得。初始化结构体里每个字段背后都是一堆寄存器位你越熟悉这些细节以后遇到问题时越不会慌。模拟平台最大的价值就是让你在不烧硬件的前提下把这些细节反复折腾明白。