简介:STM32F103 RS232串口通讯开发例程,基于KEIL标准库编写,面向单片机初学者与嵌入式项目开发者,可快速实现串口收发、数据调试及外设联动,也可作为其他STM32F103型号的移植模板。压缩包共72个文件,涵盖29个C源码、29个头文件、9个启动文件、KEIL工程文件与HEX固件,另有重置编译脚本和技术答疑链接,整体大小278KB,目录包含程序、系统、库文件及汇编模块,结构清晰便于定位代码。目前已有255人学习下载。例程提供完整工程架构,串口引脚及模块接线在代码中均有定义,编译配置说明了JLINK/STLINK选择方式;读者可参考代码流程、初始化顺序和收发处理逻辑,结合自身硬件调整后直接应用于物联网节点、传感器采集等场景,降低重复造轮子的时间成本。尤其适合需要快速评估串口功能、验证硬件数据通路的开发场景。
1. RS232 串口通讯与 STM32F103:为什么例程比你想的更值得拆
拿到一份 STM32F103 的 RS232 串口通讯例程,多数人第一反应是"这不就是 USART 初始化加收发?"真去移植时才发现,坑全在工程配置、电平转换和调试器选择上。这套名字长得像打包资源的例程,里面其实是完整的 KEIL 标准库工程,带 System、Lib、USR 分层目录,还有一份 Reset 编译脚本。对做设备调试、仪器仪表、工业控制的人而言,它最实用的价值不是让你点亮灯,而是把一个能跑通串口收发的最小系统完整拆给你看。本文用一个可复现的路径,把 RS232 串口通讯从电平转换讲到帧接收,适合正在做 STM32F103 项目、又不想被 HAL 库封装挡住底层逻辑的开发者。
2. 从 USART 外设到 RS232 电平:先搞懂协议转换与接线
2.1 TTL 与 RS232 电平差异:为什么直接连会烧
STM32F103 的 USART 引脚输出的是 3.3V TTL 电平,逻辑 1 约等于 3.3V,逻辑 0 接近 0V。而标准 RS232 协议规定逻辑 1 为 -3V 到 -15V,逻辑 0 为 +3V 到 +15V,两者不仅电压范围不兼容,极性还是反的。如果直接把 STM32 的 TX 接到电脑串口或工业设备 RS232 口,轻则通讯乱码,重则烧毁 IO 口。所以例程硬件上必然有一颗电平转换芯片,常见的是 MAX3232 或 SP3232,部分板子用 MAX232 但需要 5V 供电。判断一块板子能否跑 RS232 例程,先看原理图上是否出现 MAX3232 这类芯片,以及它的 VCC 是 3.3V 还是 5V。
STM32F103 的 USART 外设输出 TTL,经电平转换芯片后变成 RS232 差分电平,再经过 DB9 或端子输出。转换芯片的 TTL 侧 RXD 接 STM32 的 TXD 引脚,TXD 接 STM32 的 RXD 引脚,交叉连接是串口通讯的基本规则。例程代码里通常定义了 GPIO 引脚和复用功能,比如 USART1 的 TXD 是 PA9,RXD 是 PA10,USART2 对应 PA2/PA3,USART3 对应 PB10/PB11。如果板子上使用的是 USART2 或 USART3,需要对照原理图修改宏定义,而不是只改初始化里的外设指针。
2.2 STM32F103 的 USART 引脚映射与最小系统接线
对照最小系统板,最常见的是用 USART1 做调试串口。标准库初始化时,GPIO 和 USART 复用要分开配置,这一步很多人写错。参考例程中典型的引脚设置:
GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; // TXD 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; // RXD GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure);GPIO_Mode_AF_PP是串口 TX 的标准配置,RXD 用浮空输入,原因是外部电平转换芯片已经决定了总线状态,不需要内部上拉去干扰电平。如果你用板子上的 RS232 接口,接线图是 DB9 的 2 脚 RXD、3 脚 TXD、5 脚 GND,交叉连接;如果是与另一个单片机 TTL 串口直连,就不需要电平转换,直接 PA9 对对方 RX、PA10 对对方 TX,共地即可。
2.3 例程里 System 和 USR 目录的工程组织逻辑
这套例程目录结构其实藏着标准库工程的标准分层。System 目录放的是系统级文件,比如启动文件startup_stm32f10x_hd.s、system_stm32f10x.c,这些一般不根据业务改动。Lib 目录是 ST 标准外设库的源码和头文件,比如stm32f10x_usart.c、stm32f10x_gpio.c,版本不同函数实现略有差异,但接口一致。USR 目录是用户代码,main.c、stm32f10x_it.c中断服务函数都在这。这种划分的好处是升级库版本时只替换 Lib,不需要动应用逻辑。
如果下载的压缩包里只有LY-STM32.hex,说明可以直接烧录运行;如果你打算重新编译,注意.Uv2工程文件是 Keil 4 格式,用 Keil 5 打开会提示迁移。迁移时建议选 Copy,不要覆盖原工程。打开后如果编译报缺头文件,多半是Include Paths里没有把 System、Lib、USR 三个目录加进去。标准的加法是在 Options for Target 的 C/C++ 选项卡里添加相对路径:
../Libraries/inc ../System ../USR../表示从工程文件所在目录的上一级开始找。如果工程文件在根目录,而这些文件夹在根目录下,就写成./或者直接填文件夹名。路径写错时编译器报错会指向某个头文件找不到,比如stm32f10x.h: No such file or directory,此时优先检查 Include Paths,而不是去重装库。
3. KEIL 标准库工程重建:从 .Uv2 到能编译的完整流程
3.1 重置 KEIL 编译.bat 的用途与手动操作
压缩包里有个重置KEIL编译.bat,这是一个很务实的清理脚本。Keil 工程经常因为编译中间文件残留、路径缓存或上一次非正常关闭导致编译报一些莫名其妙的问题,比如cannot open source file、target not created。常见做法是删除Listings、Objects两个文件夹里的中间产物,然后重新打开工程编译。批处理只是把删除操作做成了一键脚本,它不会修改你的源码。
手动清理路径如下:
rm -rf ./Listings ./Objects ./DebugConfig如果没有.bat也不需要担心。在 Keil 里也可以用菜单 Project -> Clean Target 达到类似效果。区别是 Clean Target 只清当前 target 的中间文件,而批处理会连DebugConfig都删掉,这能解决一些调试状态下改了工程配置却不生效的问题。执行完重新编译时,Keil 会按工程配置重新生成所有中间文件。如果你的杀毒软件拦截.bat写入文件,可以右键以管理员身份运行。
3.2 芯片型号与 FLASH 容量设置
STM32F103 有 64KB、128KB、512KB 等不同 FLASH 容量,对应的启动文件和链接脚本不同。例程如果按 512KB 的高密度型号编译,而你用的是 C8T6(64KB),需要改两处:一是将器件型号从STM32F103ZET6改为STM32F103C8T6,二是将 startup 文件从startup_stm32f10x_hd.s换成startup_stm32f10x_md.s。Keil 中器件型号在 Options for Target -> Device 选项卡选择,工程里已有的启动文件需要右键 Remove,再重新 Add。
FLASH 容量在链接层面影响的是程序可以存放的最大地址。OOL 方式不准确,正确的确认方法是看芯片丝印,比如STM32F103C8T6的 C8 表示 64KB,ZET6的 ZE 表示 512KB。选错型号最容易出现的现象是:程序编译正常,下载后不运行,因为代码地址和启动文件的栈顶初始化值不匹配。重置脚本不会自动修正型号,它只负责清理,所以拿到例程后第一件事就是确认目标板型号。
常用容量与启动文件对照:
| FLASH 容量 | 型号后缀示例 | 启动文件 |
|---|---|---|
| 64KB | C8T6 | startup_stm32f10x_md.s |
| 128KB | CBT6 | startup_stm32f10x_md.s |
| 256KB | RBT6 | startup_stm32f10x_hd.s |
| 512KB | ZET6 | startup_stm32f10x_hd.s |
3.3 J-Link 与 ST-Link 调试器选择的配置差异
下载时 Keil 里的调试器选择项决定了你会用哪种方式烧录。J-Link 和 ST-Link 在 Options for Target -> Debug 里选中Use对应驱动,右侧下拉选择 J-LINK / ST-LINK Debugger。选错的现象是 Keil 提示No ULINK Device found或RDDI-DAP Error。注意两点:第一,J-Link 驱动需要单独安装,并且部分 V9 以上版本需要更新固件;ST-Link 在 Keil 4 里需要选ST-Link Debugger,在 Keil 5 里是ST-Link Debugger同名选项。第二,目标板供电方式不同,ST-Link/V2 可以从 SWD 口输出 3.3V,但电流小,带不动 RS232 电平转换芯片的大电流需求。更稳妥的做法是板子单独供电,调试器只接 SWDIO、SWCLK、GND。
如果下载报错Flash Download failed - Cortex-M3,常见原因是 Flash 算法选择不对。在 Utilities -> Settings 里根据芯片密度选择对应的 Flash Algorithm,比如 64KB 芯片选STM32F10x Med-density 64K,256KB 以上选STM32F10x High-density 512K。选错不会导致编译失败,但下载时会在擦除阶段报错。例程的重置KEIL编译.bat不涉及算法配置,这部分必须手动核。
4. RS232 串口通讯程序设计:轮询、中断与 DMA 的选择
4.1 标准库串口初始化参数:波特率、数据位、停止位
USART 初始化是通讯协议的第一道闸门。RS232 上最常见的参数是 9600 8 N 1,即波特率 9600、8 位数据、无校验、1 位停止位。标准库配置代码有固定套路:
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);USART_HardwareFlowControl_None表示不使用 RTS/CTS 硬件流控。工业 RS232 设备如果定义了硬件流控,这里必须改为USART_HardwareFlowControl_RTS_CTS,同时要占用两个 GPIO。多数 RS232 串口设备默认不启用流控,所以例程里关闭是对的。波特率误差方面,9600 波特率下 STM32 的时钟误差很小,但如果你把系统时钟改成非 72MHz,要注意 36MHz 或 48MHz 下部分波特率会溢出,标准库的USART_Init内部会自动计算分频系数,异常时串口输出全 FF。
4.2 发送与接收的代码实现
轮询发送是最直观的,发送一个字节后等数据寄存器空:
void UART_SendByte(USART_TypeDef* USARTx, uint8_t data) { USART_SendData(USARTx, data); while (USART_GetFlagStatus(USARTx, USART_FLAG_TXE) == RESET); }TXE标志是发送数据寄存器空,置位表示可以写入下一个字节。注意是TXE而不是TC,TC是发送完成,等TC会多浪费一个字节的时间。若发送字符串,循环调用即可。接收端轮询同样简单:
uint8_t UART_ReceiveByte(USART_TypeDef* USARTx) { while (USART_GetFlagStatus(USARTx, USART_FLAG_RXNE) == RESET); return USART_ReceiveData(USARTx); }RXNE置位表示收到一个字节。轮询的缺点很明显:接收期间 CPU 被卡死,无法处理其他任务。在 RS232 通讯中,如果对端连续发送 10 个字节及以上的数据帧,轮询很容易丢字节,因为 CPU 可能在处理别的事情。真正可靠的做法是中断接收。开启接收中断的代码只有两行:
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_EnableIRQ(USART1_IRQn);中断服务函数里读数据:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); // 存入缓冲区 } }中断接收也有坑:全程不能放耗时操作,比如在中断里做 printf 或延时。USART 接收一次中断只读一个字节,读出USART_ReceiveData会自动清RXNE,如果不清会在中断里反复进入。
4.3 环形缓冲区处理不定长数据
上节提到"存入缓冲区",常见的做法是环形缓冲。RS232 接收是异步的,对端什么时候发、发多长都不确定,轮询数组会遇到覆盖问题。一个 256 字节的环形缓冲区实现如下:
#define UART_RX_BUF_SIZE 256 volatile uint8_t uart_rx_buf[UART_RX_BUF_SIZE]; volatile uint16_t uart_rx_head = 0; volatile uint16_t uart_rx_tail = 0; void UART_RX_Push(uint8_t data) { uint16_t next = (uart_rx_head + 1) % UART_RX_BUF_SIZE; if (next != uart_rx_tail) { uart_rx_buf[uart_rx_head] = data; uart_rx_head = next; } }主循环每次取一个字节:
int8_t UART_RX_Pop(uint8_t *data) { if (uart_rx_head == uart_rx_tail) return -1; *data = uart_rx_buf[uart_rx_tail]; uart_rx_tail = (uart_rx_tail + 1) % UART_RX_BUF_SIZE; return 0; }环形缓冲的读写位置由head和tail分开记录,满则丢弃、空则返回 -1。这里把变量定义成volatile,是因为head在中断里被修改,主循环读取时需要防止编译器优化缓存旧值。缓冲区大小取 256 或 512 可以对齐取模运算,% UART_RX_BUF_SIZE在容量为 2 的幂时能被编译器优化成位与操作,提高效率。对 RS232 定时器中断收发的场景,这种开销可以忽略,但对波特率 115200 以上的连续帧,省下的 CPU 周期能明显降低丢包率。
5. 实战中常见的 RS232 通讯故障排查
5.1 乱码的常见原因与对策
乱码几乎是 RS232 调试第一课。第一个原因就是波特率不匹配,发射 9600、接收 19200,必然花屏。先确认双方配置,用逻辑分析仪或示波器看 TX 引脚电平宽度,9600 波特率下一个位宽约 104us,如果示波器显示 1 位只有 52us,说明实际波特率翻倍了。第二个原因是时钟配置错误,STM32F103 标准库例程依赖SystemInit把系统时钟设置成 72MHz,如果时钟初始化失败,USART 的波特率会按 8MHz 计算,导致数据错乱。这时先检查system_stm32f10x.c里的宏定义,C8T6 板子外部晶振焊接不良时也会退回到 HSI,串口能收发但乱码。
第三个原因是电平转换芯片故障。TTL 端正常、RS232 端波形幅度不足时,对端会收到错误的电平判断。MAX3232 的电荷泵电容一般用 0.1uF,如果贴片电容虚焊或容量不对,输出电平可能只有 ±3V 不到,正好卡在 RS232 接收阈值附近。排查时测转换芯片 V+ 和 V- 引脚,应该分别出现 +5.5V 和 -5.5V 左右的电压,如果只有 +3.3V 和 0V,基本是芯片或电容问题。
5.2 接收不到数据时的硬件级检查
接收不到数据最常见的是 TX/RX 接反。RS232 标准线序里,DB9 针式接口的 2 脚是 RXD,3 脚是 TXD。如果板子上引出的是排针,TX、RX 标识要对端交叉。有个快速判断法:用 USB 转 RS232 模块连接,先让对端自发自收,把它的 TX 和 RX 短路,电脑串口助手里能收到自己发的字符,说明转换模块没问题。再分别测 STM32 板子的 TX 是否有波形,没有波形重点查 GPIO 复用配置,有波形但仍然收不到,就查对方的 RXD 使能。
另一种典型问题是共地。RS232 是单端通讯,必须共享参考地,DB9 的 5 脚 GND 不接,电平漂移会导致接收数据错误或直接收不到。注意有些设备 DB9 的第 8 脚是 RTS,第 7 脚是 CTS,如果你的软件把 RTS 当作接收使能而设备端没有拉高,部分硬件会拒绝发送。遇到这种情况,尝试用杜邦线把对端的 RTS 和 CTS 短接,绕过流控。
5.3 与 RS485 通讯的区别及代码切换
RS232 和 RS485 在代码层面几乎共用同一套 USART 初始化,差异在于电平转换和收发方向控制。RS485 是半双工差分信号,一般用 MAX3485 芯片,且 DIR 引脚决定当前是发送还是接收。因此在 RS485 模式中,发送数据前需要拉高 DIR,发送完成再拉低:
#define RS485_DIR_TX GPIO_SetBits(GPIOA, GPIO_Pin_1); #define RS485_DIR_RX GPIO_ResetBits(GPIOA, GPIO_Pin_1); void RS485_SendString(uint8_t *buf, uint16_t len) { RS485_DIR_TX; UART_SendBuffer(USART1, buf, len); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); RS485_DIR_RX; }注意这里必须等待TC标志,而不是TXE。原因是在 RS485 总线上,发送数据移位寄存器没有完全移出去就把 DIR 拉低,会把最后一个字节的后半段截断。RS232 是点对点全双工,不需要方向切换,这套代码在纯 RS232 环境下可以不要 DIR 操作。如果你的项目需要在 RS232 和 RS485 之间切换,可以做一个宏或者条件编译,但驱动层一定分成两个函数,不要让方向控制逻辑裸奔在主循环里。
6. 将例程改造成 Modbus 帧接收:一个可落地的进阶技巧
6.1 空闲中断与定时器超时判断帧结束
原始例程的接收逻辑是收一个字节存一个字节,如果对端发送一个 Modbus RTU 帧(比如 8 字节报文),主循环怎么知道一帧已经结束?工业上常用三种方式:一是检测帧间空闲时间,二是通过 USART 空闲中断,三是用定时器超时。其中空闲中断是 STM32 的硬件特性,开启方式很简洁:
USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);在中断服务函数里,IDLE标志置位表示总线上出现一个字节时间的高电平,即一帧数据结束。处理函数如下:
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART1); // 读一次清除IDLE标志 uart_frame_len = (uart_rx_head - uart_rx_tail + UART_RX_BUF_SIZE) % UART_RX_BUF_SIZE; uart_frame_ready = 1; } }IDLE标志的清除比较特殊,需要先读SR寄存器再读DR寄存器。标准库的USART_ReceiveData会触发第二次读,所以调用一次即可。得到uart_frame_len后,主循环从环形缓冲区把整个数组拷贝到业务缓冲区,解析 Modbus 地址、功能码和 CRC。这种方案省去了额外的定时器中断,代码路径短,适合波特率固定且数据帧较短的应用,比如 9600 波特率下 8 字节帧的帧间间隔约 8.3ms,完全足够处理。
6.2 参数与代码调整技巧
如果不用空闲中断,可以用定时器超时判断。做法是每收到一个字节就重置定时器计数值,定时器溢出时认为帧结束。在示例代码中,把原有定时器初始化改为 1ms 节拍,超时阈值设为 5 个字符时间。9600 波特率下,1 个字符约 1.04ms,帧间间隔 1.5 个字符即 1.56ms,所以 5ms 阈值是安全的。注意阈值不能设得太大,否则两个连续帧会被合并成一帧,也不能太小,否则一帧稍慢就拆成多帧。以 9600 为例,5ms 到 20ms 皆可,115200 时建议 2ms。
改造时尽量复用例程的环形缓冲区,不要新开数组。Modbus RTU 最大帧 256 字节,缓冲区设为 256 时正好对齐取模运算。解析时先校验 CRC 再处理业务,避免把错误帧当作真实指令。用 STM32F103 跑 Modbus 从站时,接收中断里绝对不要调用延时函数,连delay_ms都不能放,否则溢出时间窗口会导致IDLE事件被漏掉。做到这里,这套 RS232 例程就从一个串口收发点灯程序,变成了一个能接入 Modbus 主站的稳定节点,继续扩展多个功能码时,只需要在uart_frame_ready分支里加状态机即可。
本文还有配套的精品资源,点击获取