简介:面向嵌入式开发初学者,这是一份基于STM32F103C8T6芯片的UART串口通信标准库实验工程,通过串口输入1、2或3触发不同输出,直观演示串口初始化、参数配置以及数据收发等完整流程。工程在Keil环境下可直接编译下载,适合课堂实验、毕业设计或自学练手,也可作为移植到其他STM32F1系列项目的参考模板。资源包共175个文件,约5.78MB,按标准库工程结构组织,包括29个C源文件、30个头文件,以及启动文件、链接脚本、Keil工程配置和编译中间文件、hex烧录文件等,便于源码阅读与直接烧录验证。目前已有2645人学习下载,通过对照代码与实际运行,可掌握USART的GPIO复用配置、波特率设置、中断或轮询收发方式,并了解标准库工程的文件组织与构建流程,为串口类应用开发打下基础。整个实验体量适中,便于反复调试和扩展,也可作为后续学习其他外设驱动的参考骨架。
1. STM32F103 标准库 Uart 实验开篇:串口是嵌入式联调的地基
刚把 stm32f103 最小系统点亮一颗 LED,下一个实验几乎必然是 Uart 串口通信。串口这跟线承担着调试打印、传感器读取、上位机指令交互这些最基础的通信任务,STM32 标准库 V3.5 下配置串口的套路在 F1 全系列里高度统一,学会一次就能迁移到 F407、F429 上。很多人以为串口实验就是把波特率配对,真正上手才发现乱码、丢字节、收不到数据,问题往往不在波特率,而在 GPIO 复用模式配错、RCC 时钟总线使能错,或者 PC 端的 USB 转串口驱动没装对。
下面按“硬件准备 → 参数初始化 → 轮询收发 → 中断接收 → 排错验证”的实际开发顺序展开,所有代码基于 STM32 标准库,可以直接抄进自己的工程。适合刚建好标准库工程的新手,也适合从寄存器或 HAL 库转过来想对照差异的工程师。
2. STM32F103 最小系统与标准库工程的串口硬件准备
2.1 USART1 引脚映射与交叉接线
STM32F103 系列最常用的调试串口是 USART1,默认引脚映射在 PA9(TX)和 PA10(RX)。绝大多数最小系统板把这组引脚单独引出,接 USB 转 TTL 模块时有一个最高频的接错点:模块的 TXD 必须接板子的 RXD(PA10),模块的 RXD 接板子的 TXD(PA9),也就是“发对收、收对发”。接成同名的 TX 对 TX,用示波器量电平完全正常,数据就是出不去。
F103 的串口资源不止 USART1 一个,做多串口项目前先对照一下外设挂载和引脚映射,避免初始化时张冠李戴。
| 串口外设 | 挂载总线 | 默认 TX/RX 引脚 | 重映射 TX/RX | 时钟使能函数 |
|---|---|---|---|---|
| USART1 | APB2,72MHz | PA9 / PA10 | PB6 / PB7 | RCC_APB2PeriphClockCmd |
| USART2 | APB1,36MHz | PA2 / PA3 | PD5 / PD6 | RCC_APB1PeriphClockCmd |
| USART3 | APB1,36MHz | PB10 / PB11 | PD8 / PD9 | RCC_APB1PeriphClockCmd |
在这个表里能看到 stm32f103 串口1和串口3使用差异的根源:USART1 挂在 APB2 总线上,主频 72MHz;USART2 和 USART3 挂在 APB1 总线上,主频最大 36MHz。同样配置 115200 波特率,USART1 和 USART3 在 BRR 寄存器里写入的分频值完全不同。初始化时如果把 USART1 的时钟使能误写成 RCC_APB1PeriphClockCmd,向时钟位写值会失败,后续所有 USART 寄存器操作都无效,表现就是串口完全没反应。
2.2 标准库 V3.5 的工程结构与系统时钟
标准库 V3.5(网上常搜到的 stm32f103 库 v3.50 下载就是这个版本)新建工程的骨架由几个固定文件组成:main.c 写应用逻辑,stm32f10x_it.c 放中断服务函数,stm32f10x_conf.h 做外设头文件包含,system_stm32f10x.c 负责时钟初始化,启动文件根据芯片容量选择,F103ZET6 这类高密度芯片用 startup_stm32f10x_hd.s。main 函数里由 SystemInit() 把系统时钟配置到 72MHz,SystemCoreClock 全局变量等于 72000000。
分频计算直接依赖这个 72MHz。USART 的 BRR 寄存器计算公式是 USARTDIV = PCLK / (16 * 波特率),当 PCLK=72MHz、波特率=115200 时,USARTDIV=39.0625,BRR 写成 0x271。如果外部晶振不是 8MHz,或者板子用了内部 HSI 时钟,PCLK 就不是 72MHz,同样的 BRR 值实际产生的波特率会偏移,对端自然收出乱码。排查串口乱码时先确认板子晶振频率,再看 system_stm32f10x.c 里的 PLL 倍频配置。
2.3 GPIO 复用推挽与浮空输入的初始化代码
#include "stm32f10x.h" void UART1_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 使能 GPIOA 与 USART1 时钟。 串口1挂在 APB2 总线上,主频 72MHz,所以用 APB2 的时钟使能函数。 如果换成 USART3,这行要改成 RCC_APB1PeriphClockCmd(...) */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | 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); }TX 引脚用 GPIO_Mode_AF_PP 而不是 GPIO_Mode_Out_PP,原因是复用推挽模式把引脚控制权交给片上外设,USART 移位寄存器输出的 TX 信号才能接到引脚上;普通推挽输出由 ODR 寄存器人工控制,配成这种模式后 USART 发送的数据根本到不了 PA9。RX 配浮空输入是官方例程的默认做法,如果外部设备输出端是开漏结构,也可以改用上拉输入,但会轻微影响电平边沿质量,调试期建议先保持浮空。
提示:如果最小系统的串口引脚不是 PA9/PA10,而是 PB6/PB7,除了 GPIO 时钟外还要单独使能 AFIO 时钟,并调用 GPIO_PinRemapConfig(GPIO_Remap_USART1, ENABLE),否则重映射后的引脚同样不工作。
3. 标准库 Uart 通信实验:USART 初始化的参数与轮询收发
3.1 USART_Init 的六个结构体成员拆解
void UART1_Init(uint32_t baudrate) { USART_InitTypeDef USART_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); USART_Cmd(USART1, ENABLE); } int main(void) { SystemInit(); UART1_GPIO_Config(); UART1_Init(115200); while (1) { /* 主循环里做轮询收发 */ } }每个成员单独说清楚:
- USART_BaudRate = 115200。库内部根据 PCLK 自动计算 BRR 寄存器值,但 PCLK 取值来自 SystemCoreClock 全局变量。如果工程里手动改过时钟初始化,比如把外部晶振倍频数从 9 改成 18,SystemCoreClock 没有同步更新,算出来的 BRR 就是错的。手动改时钟后要检查 system_stm32f10x.c 里的 SystemCoreClock 更新逻辑。
- USART_WordLength = 8b,指 8 个数据位。开启奇偶校验时这里必须改成 9b,原因见 3.2 的参数表。
- USART_StopBits = 1,常规配置。对端停止位设 2,F103 这边仍按 1 位停止位发送也能通信,但反过来不行。
- USART_Parity = No。串口帧的校验位会占用数据位长度,这一点最容易踩。
- USART_HardwareFlowControl = None。只有板子引出 RTS/CTS 引脚时才需要打开,普通三线制串口直接关闭。
- USART_Mode = Rx | Tx,收发都开。只调发送可以只写 USART_Mode_Tx,但实际调试中几乎没有只发不收的场景。
3.2 数据位、停止位、校验位的参数组合
| 应用场景 | WordLength | Parity | StopBits | 实际帧结构 |
|---|---|---|---|---|
| 常规调试,8N1 | 8b | No | 1 | 1起始 + 8数据 + 1停止 |
| Modbus RTU,8E1 | 9b | Even | 1 | 1起始 + 8数据 + 1校验 + 1停止 |
| 与老式设备对接 | 8b | No | 2 | 1起始 + 8数据 + 2停止 |
| 9 位多机通信 | 9b | No | 1 | 1起始 + 8数据 + 1地址/标志位 |
USART_Parity 配成 Even 或 Odd 时,标准库不会自动调整 WordLength,得手动把 WordLength 改成 9b。9 位里低 8 位是数据,第 9 位是校验位,硬件自动计算和插入。如果只改 Parity 不改 WordLength,发送端会产生 10 位帧,接收端按 9 位解析,帧边界全部错位,表现就是偶发乱码且无法通过调整波特率解决。上电联调第一个动作是确认 PC 端串口助手的校验位和停止位设置,和单片机完全一致后再谈乱码问题。
3.3 轮询发送:判断 TXE 还是 TC
void UART1_SendByte(uint8_t byte) { /* 等待发送数据寄存器空。TXE=1 说明 TDR 已空,可以写入下一字节 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, byte); }USART 有两个发送相关标志位最容易混。TXE 表示 TDR 里的数据已经搬到移位寄存器,TDR 空出来了;TC 表示移位寄存器把这一帧的停止位也发送完毕。发单个字节等 TXE 就够,因为 TDR 空出后立刻写入下一字节,不会覆盖当前正在移位的数据。但半双工总线场景必须等 TC,典型例子是 RS485 方向控制:
void UART1_SendByte_RS485(uint8_t byte) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, byte); /* RS485 方向引脚必须在整帧发送完后才能释放总线, 只等 TXE 会导致最后一位还在移位寄存器里时总线已被切换 */ while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); GPIO_SetBits(GPIOC, GPIO_Pin_4); /* 释放总线 */ }这里的 GPIO_SetBits 只是示意,实际方向控制引脚的拉高拉低电平取决于收发器芯片。核心逻辑是:TXE 置位时数据还没发完,TC 置位才代表物理层发送完毕。需要精确控制总线切换时间的场景,一律等 TC。
3.4 轮询接收与 ORE 溢出丢帧
uint8_t UART1_ReceiveByte(void) { /* 等待接收数据寄存器非空。RXNE=1 表示 RDR 已收到一帧数据 */ while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET); return (uint8_t)USART_ReceiveData(USART1); }轮询接收的硬伤是等待期间 CPU 被完全占住。上位机连续发来两帧,第一帧被读走之后,软件还没回到 while 循环开头,第二帧已经到达 RDR,此时 RXNE 尚未清除,新的数据把旧数据覆盖,硬件置 ORE 溢出标志。读 DR 读到的还是上一帧数据,新帧直接丢失。
这一点决定了轮询方式只适合一问一答式的短交互,比如 Modbus 主站查询后从站应答。连续高速数据流必须换中断接收。
提示:排查丢帧时先看 ORE 标志是否被置位,在接收到预期数据后执行 if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) != RESET) 并计数,连续出现 ORE 说明数据到达速率超过消费速率,调高主循环处理效率或改中断接收才是正路。
4. STM32F103 Uart 通信实验进阶:中断接收与不定长帧解析
4.1 NVIC 初始化与 USART_ITConfig 的先后顺序
NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); /* 2位抢占优先级 + 2位子优先级 */ NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); /* 必须在 NVIC 配置完成后使能串口中断 */ USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);NVIC_PriorityGroupConfig 必须在任何 NVIC_Init 之前调用,否则分组设置无效。抢占优先级和子优先级的分配取决于整个工程的中断负载:如果同时有定时器中断和 DMA 中断在跑,串口中断的抢占优先级建议放高,抢占优先级数值越小优先级越高,给 0 或 1 都可以,定时器放后面。中断里如果还做了浮点运算或长耗时操作,抢占优先级再高也会拖慢主循环,所以中断里只做数据搬运,业务处理放主循环。
中断源选择看具体需求。RXNE 负责收数据,TC 负责发送完成回调,ORE 负责溢出恢复。对帧丢失敏感的应用建议把 USART_IT_ORE 也打开,溢出中断里做状态复位。
4.2 中断服务函数的标志位清理规则
uint8_t rx_buf[128]; volatile uint16_t rx_len = 0; void USART1_IRQHandler(void) { uint8_t byte; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { byte = (uint8_t)USART_ReceiveData(USART1); /* 读 DR 自动清 RXNE */ if (rx_len < sizeof(rx_buf)) { rx_buf[rx_len++] = byte; } } if (USART_GetITStatus(USART1, USART_IT_ORE) != RESET) { /* 溢出后先读 SR 再读 DR,把残留数据取走,清除 ORE */ (void)USART_GetFlagStatus(USART1, USART_FLAG_ORE); (void)USART_ReceiveData(USART1); rx_len = 0; } }标准库里 USART_GetITStatus 和 USART_GetFlagStatus 的差别在于:IT 版本同时检查 SR 标志位和 CR 寄存器里的中断使能位,FLAG 版本只查 SR。中断服务里统一用 IT 版本,避免外设中断根本没开启时误入处理分支。
RXNE 标志的清除方式是读 DR,读完自动清。不要在中断里再调 USART_ClearITPendingBit(USART1, USART_IT_RXNE),标准库的这个清除操作对 RXNE 是无效的,还会多一次写 CR 操作。ORE 清除比较特殊,必须先读 SR 再读 DR,两者顺序反了清不掉。
| 中断源 | 触发条件 | 清除方式 | 典型用途 |
|---|---|---|---|
| RXNE | 接收数据寄存器非空 | 读 DR 自动清除 | 收数据 |
| TC | 一帧发送完成 | 读 SR 后写 DR,或 ClearFlag | 发送完成钩子 |
| ORE | 接收溢出 | 先读 SR 再读 DR | 溢出恢复 |
| IDLE | 总线空闲超过一帧时间 | 先读 SR 再读 DR | 不定长帧收尾 |
4.3 用状态机解析不定长数据帧
中断接收解决了不丢字节的问题,但字节流本身没有边界。上位机发来长度不定的命令帧时,常见做法是用状态机逐字节推进:帧头 0xAA 0x55,接下来是数据长度,然后数据区,最后是累加校验和。
typedef enum { FRAME_WAIT_HEAD1, FRAME_WAIT_HEAD2, FRAME_WAIT_LEN, FRAME_WAIT_DATA, FRAME_WAIT_CHECK } FrameState; FrameState state = FRAME_WAIT_HEAD1; uint8_t frame_buf[64]; uint8_t frame_cnt = 0; uint8_t frame_len = 0; uint8_t check_sum = 0; void process_byte(uint8_t b) { switch (state) { case FRAME_WAIT_HEAD1: if (b == 0xAA) state = FRAME_WAIT_HEAD2; break; case FRAME_WAIT_HEAD2: if (b == 0x55) state = FRAME_WAIT_LEN; else if (b == 0xAA) state = FRAME_WAIT_HEAD2; /* 连续两个 0xAA 时保持等待 */ else state = FRAME_WAIT_HEAD1; break; case FRAME_WAIT_LEN: if (b > 0 && b <= 60) { frame_len = b; frame_cnt = 0; check_sum = b; state = FRAME_WAIT_DATA; } else { state = FRAME_WAIT_HEAD1; /* 长度非法,重新找帧头 */ } break; case FRAME_WAIT_DATA: frame_buf[frame_cnt++] = b; check_sum += b; if (frame_cnt == frame_len) state = FRAME_WAIT_CHECK; break; case FRAME_WAIT_CHECK: if (check_sum == b) { /* 帧校验通过,在 frame_buf 里解析业务数据 */ } state = FRAME_WAIT_HEAD1; break; } }把 process_byte 挂到中断服务里,RXNE 分支收到字节后直接调用。状态机的核心收益是:不管上位机以什么速度发帧,只要字节不丢、不乱序,状态就能正确推进。帧头出现错误字节时,通过状态回退保证重新同步,这是最基础但最实用的容错设计。
如果上位机发的是 ASCII 字符串命令,比如 AT+STATUS\r\n,也可以用 strcmp 直接比较。要注意 stm32f103 里 strcmp 依赖字符串结尾的 \0,而串口收到的原始数据没有结尾,必须在接收缓冲区末尾手动补 \0 再比较,否则 strcmp 会越界读到不确定内容。比较之前先做 \r\n 定界处理,比直接在整个缓冲区上 strcmp 更可靠。
4.4 环形缓冲区替代简单数组
简单数组接收有个边界问题:不定长帧什么时候算收完。一个常见做法是环形缓冲区加 IDLE 中断,空闲超时代表一帧结束。环形缓冲的实现用 256 字节配合掩码取模,比取模运算快很多。
#define RX_RING_SIZE 256 #define RX_RING_MASK 0xFF volatile uint8_t rx_ring[RX_RING_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; /* 中断里写入,缓冲区被写满时覆盖最旧数据 */ void ring_push(uint8_t b) { rx_ring[rx_head] = b; rx_head = (rx_head + 1) & RX_RING_MASK; } /* 主循环里读取,返回 0 表示没有新数据 */ uint8_t ring_pop(uint8_t *byte) { if (rx_tail == rx_head) { return 0; } *byte = rx_ring[rx_tail]; rx_tail = (rx_tail + 1) & RX_RING_MASK; return 1; }head 和 tail 相等代表缓冲区为空。这个实现不做防覆盖,适合帧速率不高的调试场景。要防覆盖,在 push 里判断 ((rx_head + 1) & RX_RING_MASK) == rx_tail,相等说明缓冲区已满,这时丢弃新字节并置一个溢出计数,供主循环查询。注意 RX_RING_SIZE 必须是 2 的整数次幂,掩码才能用 & 来代替 %,改成 512 或 1024 时掩码要同步改成 0x1FF、0x3FF。
5. Uart 串口通信实验的故障排查与波形验证技巧
5.1 回环测试定位是哪一端的问题
把 PA9 和 PA10 用杜邦线短接,程序上电后发送 0x5A,随后轮询接收,把收到的字节再发回 PC。短接时能自收自发,说明单片机侧的 GPIO 配置、USART 初始化、波特率都是对的,问题在单片机与 PC 之间的链路。接着去掉短接,接上 USB 转 TTL 模块。
USB 转 TTL 模块常用 FT232R 或 CP2102 芯片,这两类芯片在 Windows 下如果设备管理器看不到 COM 口,先装芯片厂商的官方驱动,Windows 10 以上系统通常能自动识别,但部分精简版系统需要手动指定驱动路径。CH340 芯片的驱动名和 FT232R 不一样,不要装错。设备管理器出现 COM 口后再打开串口助手,波特率、停止位、校验位全部与单片机一致,发送 0x5A,接收区能看到同样字节就说明整条链路通。
回环测试有个陷阱:PA9 和 PA10 短接后,发送端和接收端是同一个芯片的同一个 USART,哪怕两边波特率配置不一致也能收到数据,因为收发共用同一个 BRR。所以回环测试通过不能完全证明波特率准确,只能证明外设本身没坏。
5.2 用逻辑分析仪看波特率误差
逻辑分析仪夹在 PA9 上,抓一段实际发送波形。一帧的标准结构是 1 位起始位低电平、8 位数据位、1 位停止位高电平,共 10 个位时间。量出起始位下降沿到停止位结束的总时间,除以 10 得到单个位时间,做 1000000 / 位时间 换算就能得到实际波特率。
实际波特率与设定值偏差在 ±2% 以内基本能正常通信,超过 ±3% 就会出现偶发误码。自制最小系统的晶振不是 8MHz,或晶振负载电容不匹配导致频率偏移,在逻辑分析仪上一目了然。检查时把示波器探头或逻辑分析仪的地线夹在板子地,不要夹在 USB 转 TTL 模块的地上,两边参考地不一致时测出的波形是浮动的,数值没有参考意义。
5.3 十六进制打印对比字节序
调试多字节协议时,按 ASCII 字符串打印会把 0x00 显示成空字符,0xA0 这类数据被当成扩展字符,帧边界完全看不清。写一个十六进制打印函数,把缓冲区内容逐字节转成两个十六进制字符输出:
void uart_print_hex(uint8_t *buf, uint16_t len) { uint8_t hex[] = "0123456789ABCDEF"; uint16_t i; for (i = 0; i < len; i++) { UART1_SendByte((uint8_t)hex[(buf[i] >> 4) & 0x0F]); UART1_SendByte((uint8_t)hex[buf[i] & 0x0F]); UART1_SendByte(' '); } UART1_SendByte('\r'); UART1_SendByte('\n'); }用这个方法观察收到的帧头、长度字段和校验和,能快速定位帧格式错误。另一个高频问题现象是串口收到的整数高低字节颠倒:F103 是 Cortex-M3 小端核,上位机按大端发送 0x12 0x34,单片机里按 uint16_t 读出来却是 0x3412。把发送端和接收端的打印都切到十六进制,两端对比字节顺序,这类问题几秒钟就能确认。串口协议里多字节整数建议统一按大端传输,也就是高字节先发,MCU 端解析时手动拼装,避免不同平台字节序不一致带来的移植问题。
本文还有配套的精品资源,点击获取