简介:面向嵌入式开发者和STM32初学者的GPIO模拟UART实验资源,基于STM32F103C8微控制器,利用PA1、PA0引脚以软件方式实现UART单字节收发,适合在缺少专用串口或需要扩展串口的场景中使用,也有助于深入理解串口协议底层时序。资源压缩包约2.76MB,内含195个文件,以HAL库工程为主体,包含.c/.h源文件、Keil工程配置(uvprojx/uvoptx)、编译生成的hex/axf文件及调试辅助文档,目录结构清晰,可直接打开工程查看GPIO初始化、定时器中断以及单个字符发送/接收函数的完整代码实现。该资源已有2004人学习浏览,是理解GPIO复用与异步串口时序的实用参考。通过学习可以掌握GPIO模式与上下拉配置、定时器控制位时序、软件模拟起始位/数据位/校验位/停止位的发送接收流程,并认识到软件模拟串口在波特率和实时性方面的局限,为后续自定义扩展通信功能打下基础。
1. 为什么要用GPIO模拟串口
1.1 硬件串口不够用时的替代方案
STM32F103C8这颗芯片,了解的人都知道,它属于F1系列的性价比担当,蓝色最小系统板几块钱就能到手,跑起来稳定可靠。但它有一个尴尬的地方:虽然内部集成了3个USART外设,但在实际项目中,板载的资源往往不够用——比如你要同时接一个GPS模块、一个蓝牙模块、一个上位机调试串口,再加上一个TTL电平的传感器,3个硬件串口瞬间就捉襟见肘了。
而且更麻烦的是,F103C8的USART1默认引脚PA9/PA10,USART2默认在PA2/PA3,USART3在PB10/PB11。如果你的板子设计比较特殊,或者你手上正好有几根杜邦线、一个闲着没事干的PA1和PA0,那GPIO模拟串口就是一条非常务实的路。
我最早接触这个方案,是因为手头一个项目临时要多出一路串口用于调试日志输出,但硬件串口都已经分配出去了,重新画板子显然不现实。于是干脆用PA1做TX、PA0做RX,硬生生在GPIO上"捏"出了一路UART。实测下来,在9600波特率下收发稳定,单字符收发完全没问题,跑几天也不丢数据。
1.2 UART协议到底在传输什么
要模拟串口,必须先把UART协议吃透。UART全称是Universal Asynchronous Receiver/Transmitter,即通用异步收发器。所谓异步,意味着收发双方不需要共享时钟信号,而是通过约定的波特率来对齐每一位的时长。
一个完整的UART字节帧长这样:空闲时总线保持高电平,然后发送一个低电平的起始位,接着是8个数据位(低位在前LSB First),最后是一个高电平的停止位。有的配置还有校验位,但最常用的8N1(8数据位、无校验、1停止位)格式里没有校验位,直接数据位结束后拉高停止位即可。
串口时序图看起来复杂,实际一句话总结就是:拉低一个位时间表示开始,后面8个位时间依次表达数据,最后拉高一个位时间收尾。
这里最容易踩坑的点是数据位的顺序。UART规定先发最低位,也就是LSB First。你心中想发送0x55(二进制01010101),实际线上出现的是从最低位开始的10101010。很多新手在这里卡壳,搞反方向之后怎么调都收不到正确数据。
1.3 引脚工作模式的选择依据
GPIO模拟串口之所以可行,是因为UART本质上就是对电平的定时采样和定时翻转,完全可以用GPIO的推挽输出和输入模式来模拟。
STM32的GPIO有8种工作模式,对于模拟串口来说,只用到其中两种:
- TX引脚(PA1)配置为推挽输出:GPIO_Mode_Out_PP,推挽结构既能输出高电平也能输出低电平,驱动能力强,响应速度快,完全符合发送端的要求。
- RX引脚(PA0)配置为浮空输入或上拉输入:GPIO_Mode_IN_FLOATING(浮空输入)是最常用的选择,因为UART总线空闲时为高电平,外部设备在空闲时会主动拉高,不需要内部上拉。如果你的外部设备是开漏输出,那就最好配置成GPIO_Mode_IPU(上拉输入),确保空闲时能读到确定的高电平。
有一个实际经验要记住:RX引脚尽量别配置成下拉输入或模拟输入,否则空闲状态下电平不确定,接收逻辑会疯狂误触发。我最初调试时踩过这个坑,PA0配成下拉输入,结果还没接外部设备,接收代码就一直认为总线忙,怎么等也等不到起始位。
下面是我的引脚初始化代码,基于标准外设库(Standard Peripheral Library)实现,这也是绝大多数STM32F103C8开发者最熟悉的方式:
void GPIO_UART_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA1 -> TX, 推挽输出, 50MHz GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA0 -> RX, 浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // 发送引脚初始化为高电平, 代表UART空闲状态 GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_SET); }2. 发送实现:PA1翻转出完整时序
2.1 延时基准:DWT微秒级延时的搭建
GPIO模拟UART的核心是时间控制。波特率决定了一个位的时长,比如9600波特率下,一个位的时间是 1/9600 秒,约104.17微秒。要让代码精确地在这个时间尺度上翻转电平,必须先有一个可靠的微秒级延时。
很多人第一个想到的是SysTick延时,我也试过。SysTick做延时很好用,但有个问题:如果项目里其他地方已经用SysTick做系统时钟节拍(比如跑操作系统或做定时调度),就会有冲突。所以我推荐用Cortex-M3内核自带的DWT(Data Watchpoint and Trace)单元,它有个CYCCNT计数器,直接统计CPU时钟周期,精度高、不占额外的定时器资源。
void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }这里有个小细节:SystemCoreClock是系统时钟频率,F103C8默认跑72MHz,所以SystemCoreClock / 1000000等于72,也就是1微秒对应72个CPU周期。DWT的CYCCNT是32位计数器,72MHz下大概59.6秒才回绕一次,完全不用担心溢出问题。
注意:如果你开了编译器优化(比如Keil的-O2),普通的空循环延时函数会被优化掉,但DWT延时基于硬件计数器,不受编译优化的影响,这也是我强烈推荐它的原因。
2.2 发送一个字节的完整代码
有了微秒级延时,发送就变得非常直接:拉低一个位时间(起始位),依次发送8个数据位,最后拉高一个位时间(停止位)。
#define UART_BIT_TIME_US (1000000UL / 9600) // 9600波特率下每bit约104us void GPIO_UART_SendByte(uint8_t data) { uint8_t i; // 起始位: 拉低总线 GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_RESET); delay_us(UART_BIT_TIME_US); // 数据位: LSB First, 从最低位开始发送 for (i = 0; i < 8; i++) { if (data & (0x01 << i)) { GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_SET); } else { GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_RESET); } delay_us(UART_BIT_TIME_US); } // 停止位: 拉高总线 GPIO_WriteBit(GPIOA, GPIO_Pin_1, Bit_SET); delay_us(UART_BIT_TIME_US); }这段代码从逻辑上看一目了然,就是按照UART时序图逐个bit翻转电平。但如果你直接拿去用,可能会发现收到的数据偶尔出错。问题出在GPIO_WriteBit函数本身的调用开销上。
GPIO_WriteBit虽然是标准库函数,但内部有一些判断和位操作,执行时间在几十个纳秒到几百个纳秒不等。在104微秒的位周期里,这个开销占比不到1%,理论上不影响。但如果你把波特率提高到115200,位时间缩短到8.68微秒,函数调用开销占比就上升到几个百分点,数据就容易出错了。
所以模拟串口发送时,我对这个函数的建议是:9600波特率下随便用,115200及以上尽量用寄存器操作。
// 寄存器版本, 更高效 #define TX_HIGH() GPIOA->BSRR = GPIO_Pin_1 #define TX_LOW() GPIOA->BRR = GPIO_Pin_1 void GPIO_UART_SendByte_Fast(uint8_t data) { uint8_t i; TX_LOW(); delay_us(UART_BIT_TIME_US); for (i = 0; i < 8; i++) { if (data & (0x01 << i)) { TX_HIGH(); } else { TX_LOW(); } delay_us(UART_BIT_TIME_US); } TX_HIGH(); delay_us(UART_BIT_TIME_US); }2.3 发送时序验证与误差分析
写完发送代码,强烈建议先用示波器或逻辑分析仪抓一下波形,看看时序是否标准。没有示波器的话,用另一个硬件串口接电脑也可以验证。
以9600波特率发送0x55为例,理想波形应该是:空闲高电平 -> 起始位低电平(104us) -> 8个数据位(01010101),LSB First实际线上顺序为1-0-1-0-1-0-1-0,每个bit 104us -> 停止位高电平(104us)。
实际用逻辑分析仪抓波形,发现每个bit的实际时间比理论值多出大约1-2微秒。这是因为delay_us本身有进出函数的开销,加上GPIO翻转指令的执行时间。在9600波特率下,单bit误差约1%,累计起来停止位会比理想位置晚几个微秒。这个误差在接收端看来在允许范围内——UART协议对波特率误差的容忍度通常在±3%以内,所以完全不用担心。
但如果你的系统时钟不是整数倍关系,比如外部晶振不是8MHz,内部PLL分频出来恰好是72MHz,那就可能导致系统时钟本身有微小偏差。解决办法是多测几次,实测下来误差确实在容忍范围内。
3. 接收实现:PA0采样恢复数据
3.1 接收原理:位中心采样法
如果说发送是"照着谱子弹琴",那接收就是"听声辨位",难度直接上了一个台阶。接收的关键在于:你不知道对方什么时候开始发送,只能通过监测电平变化来捕捉起始位。
最常用的方法是位中心采样法。具体思路是:
- 轮询RX引脚,等待下降沿(从高电平变为低电平),判定为起始位到来。
- 等半个位时间(约52微秒),再次采样,确认电平确实为低。这一步叫"起始位确认",目的是排除毛刺干扰——如果等半位之后电平又变高了,说明刚才的下降沿是噪声,不是真正的起始位。
- 从起始位确认后,每隔一个位时间采样一次,连续采样8次,得到8个数据位。
- 最后采样停止位,如果读到高电平,说明这一帧完整无误;如果读到低电平,说明传输过程出错。
为什么要在位中心采样,而不是在位的开头或结尾采样?因为电平跳变发生在位的边界附近,此时信号不稳定,容易误判。而位中心是每一位最稳定的时刻,采样成功率最高。这就是"中心采样"这个名字的由来。
3.2 接收一个字节的完整代码
在STM32F103C8上实现接收,我有两种方案可以供你选择。
方案一:轮询方式(适合单字符、实时性要求不高的场景)
这种方案代码很简单,整个函数阻塞执行,直到收到一个完整的字节才返回。适合在main主循环里调用。
uint8_t GPIO_UART_ReceiveByte(void) { uint8_t i; uint8_t data = 0; // 等待起始位下降沿 while (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET); // 延时半个位时间, 确认起始位 delay_us(UART_BIT_TIME_US / 2); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) != Bit_RESET) { return 0xFF; // 噪声误触发, 返回错误标志 } // 采样8个数据位 for (i = 0; i < 8; i++) { delay_us(UART_BIT_TIME_US); // 到达下一个位中心 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET) { data |= (0x01 << i); } } // 采样停止位 delay_us(UART_BIT_TIME_US); if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_RESET) { return 0xFE; // 停止位错误, 返回错误标志 } return data; }方案二:外部中断+定时器方式(推荐,不阻塞CPU)
轮询方案的缺点很明显:函数没有返回值时就一直卡在那里,主程序什么都干不了。更糟糕的是,如果对方发送字节间有间隔,函数可能长时间阻塞。对于实际项目,我强烈建议使用外部中断触发起始位,然后用定时器精确采样后续位的方式。
思路是这样的:
- PA0配置为外部中断输入,下降沿触发。
- 外部中断触发后,在中断服务函数里启动一个基本定时器,让它每隔一个位时间产生一次中断。
- 定时器中断里依次采样8个数据位和1个停止位,采样完关闭定时器。
这样CPU只在收到数据的一瞬间忙碌,平时主程序完全不受影响。
volatile uint8_t rx_data = 0; volatile uint8_t rx_bit_cnt = 0; volatile uint8_t rx_complete = 0; volatile uint8_t rx_temp = 0; volatile uint8_t rx_error = 0; // PA0外部中断服务函数: 检测到起始位 void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { EXTI_ClearITPendingBit(EXTI_Line0); // 关闭外部中断, 防止采样过程中再次触发 EXTI_InitStructure.EXTI_LineCmd = DISABLE; // 需要重新初始化, 见下方代码 rx_bit_cnt = 0; rx_temp = 0; // 延时半位, 到达第一个数据位中心 delay_us(UART_BIT_TIME_US / 2); // 确认起始位 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_RESET) { // 启动定时器, 每隔UART_BIT_TIME_US产生一次中断 TIM_Cmd(TIM2, ENABLE); } else { // 噪声, 重新使能外部中断等待下一帧 EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); } } } // TIM2中断服务函数: 采样数据位和停止位 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); if (rx_bit_cnt < 8) { // 采样数据位 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_SET) { rx_temp |= (0x01 << rx_bit_cnt); } rx_bit_cnt++; } else { // 采样停止位 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == Bit_RESET) { rx_error = 1; // 停止位错误 } rx_data = rx_temp; rx_complete = 1; // 关闭定时器, 等待下一帧 TIM_Cmd(TIM2, DISABLE); // 重新使能外部中断 EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); } } }方案二的代码量明显增加,但带来的好处是值得的:主循环可以放心处理其他任务,只在rx_complete标志位为1时读取数据。这也是实际项目中更合理的写法。
3.3 接收可靠性优化的几个要点
第一,确认起始位是必须的。很多初学模拟串口的人,一看到下降沿就开始采样数据位,结果在干扰多的环境里会收到一堆乱码。加了半位确认后,毛刺干扰会被挡掉一大半。实测下来,在电机驱动旁边做测试,有确认和没确认的误码率差别非常明显。
第二,数据位中间采样比开头采样可靠得多。开头采样处在信号刚跳变的边缘,容易受振铃效应和电平上升/下降时间影响;中间采样则避开了这些不稳定区。
第三,接收过程中关闭一切可能抢占CPU的中断。UART接收是硬实时的,如果在采样过程中OS调度、定时器中断、DMA中断等打断了你的采样,就会导致位时序偏移,数据错误。我的做法是采样8个数据位期间把中断优先级调到最低,或者干脆用关中断的方式保护采样的连续性。当然,方案二用定时器中断采样时,外部中断已经关闭,两者不冲突,但要确保定时器中断的优先级足够高。
4. 联调验证与实际应用扩展
4.1 环回测试与串口助手联调
代码写完了,怎么验证?第一步最直观的测试是环回测试:把PA1和PA0用杜邦线直接连起来,然后在代码里调用发送函数发一个字节,再调用接收函数读回来,对比是否一致。
int main(void) { uint8_t i; uint8_t tx_data[] = {0x00, 0x55, 0xAA, 0xFF, 0x31, 0x32, 0x33}; uint8_t rx_buf[sizeof(tx_data)]; GPIO_UART_Init(); DWT_Delay_Init(); while (1) { for (i = 0; i < sizeof(tx_data); i++) { GPIO_UART_SendByte(tx_data[i]); rx_buf[i] = GPIO_UART_ReceiveByte(); delay_us(1000); // 留出时间间隔 } // 比较 rx_buf 和 tx_data, 完全一致则说明收发逻辑正确 // 不一致则可以根据 len 判断是哪一位出错 delay_us(1000000); } }环回测试通过后,下一步是和电脑联调。用一个USB转TTL模块(比如CP2102或CH340),把它的TX接到板子的PA0(接收),把它的RX接到板子的PA1(发送),打开串口助手软件(像是SSCOM、正点原子XCOM、MobaXterm里的串口终端都行),设置波特率9600、8N1,然后发送和接收测试。
注意:USB转TTL模块和STM32板子一定要共地(GND相连),否则电平基准不同,传输必然乱码。这是新手最容易忽略的操作之一。
4.2 从单字符到字符串收发
标题里写的是"单个字符",但实际项目里没人只发一个字符。单字符验证无误后,扩展成字符串收发是非常自然的事情。
字符串收发的关键点有两个:一是发送端按字节逐个发送,字节之间保持完整帧间隔;二是接收端要有"帧"的概念,知道什么时候一个字符串接收完成。
最简单的帧协议设计方法是:帧头+数据+帧尾+校验。比如帧头0xAA,帧尾0x55,中间是数据内容。接收端检测到0xAA开始记录,遇到0x55表示一帧结束。更讲究一点可以加一个累加和校验,对中间数据进行求和取低字节,放在帧尾前面,接收端校验通过才认为是有效帧。
这里要特别提醒的是字节间间隔问题。模拟串口接收是纯粹的异步采样,两个字节之间如果根本没有间隔(严格背靠背发送),接收端可能来不及处理循环里的下一个字节(特别是轮询方式)。实际处理时,发送端在两个字节之间加一个位时间的间隔,或者接收端用中断方式+缓冲区,就能有效避免丢字节的问题。我这里测试时,是把发送间隔设置为10个位时间以上,稳定得很。
4.3 波特率与引脚选择的实战建议
波特率的选择直接影响模拟串口的可靠性。9600是最稳妥的选择,因为位时间长达104微秒,延时代码即使有少量开销,误差占比也很小。19200、38400也都是能稳定跑的,但115200就要非常谨慎了——位时间只有8.68微秒,一个GPIO_WriteBit函数调用就占掉位时间的几个百分点,一个中断响应的开销就可能吃掉大半个位时间。
如果你的应用必须用115200,我的建议是:
- 发送端用寄存器操作BSRR/BRR,不用标准库函数
- 接收端优先用定时器中断方式,而且要确保定时器中断响应时间极短
- 延时函数改用查询DWT方式,不要用SysTick
- 实测下来,寄存器版本在115200下勉强可用,但余量不大,不建议长时间高负载运行
引脚选择方面,PA0和PA1在某些板子上有额外功能。比如PA0是WKUP(唤醒引脚),PA1是USART2_RTS(如果板上引出了USART2的话)。如果板子上这两个引脚被其他外设占用了,可以灵活换到其他空闲GPIO,只要TX用推挽输出、RX用输入模式,代码逻辑不变,改一下引脚宏定义就行。我试过把TX换到PB5、RX换到PB4,一样工作正常。
5. 常见问题排查与避坑
5.1 下载时报 error #550: requested device stm32f103c8 not found
这个话题跟模拟串口本身关系不大,但几乎每个玩F103C8的都会遇到:Keil里点下载,报错说找不到目标芯片。热词列表里也在反复出现这个错误,我这里顺手提一下排查思路。
这个报错的常见原因有三个:
- 调试器没接对:ST-Link的SWDIO接PA13,SWCLK接PA14,GND共地,3.3V供电。接线少一根都不行,尤其是GND。确认接线无误后还报错,检查是否可以给SWD引脚加上拉电阻。
- 芯片被锁死了:如果之前用错了时钟配置,或者开启了读保护,芯片可能拒绝连接。解决办法是按住板子上的RESET按键,点Keil的下载按钮,然后瞬间松开RESET,利用"先连后复位"的技巧强行擦除芯片。
- BOOT0跳线帽在1位置:BOOT0置为1时芯片会进入串口下载模式,不会正常执行用户程序,SWD可能连接不上。把BOOT0跳线帽拨回0位置再下载。
5.2 USB转串口驱动装不上、串口助手打不开端口
我这边测试时用的是FT232R和CP2102两种USB转TTL模块。FT232R(热词里提到的ft231x/ft232r驱动)在Win10上一般能自动识别,偶尔需要手动安装驱动,去官方下载对应驱动包就能解决。CP2102则需要装Silicon Labs的驱动,否则设备管理器里显示的是一个未知设备。
驱动装好之后,打开串口助手发现打不开端口,大概率是端口号被其他程序占用,或者你用的是不合规的Type-C转串口线(有些线只能充电不能传数据)。换一个USB口、或者重启电脑,基本能解决。
5.3 模拟串口接收乱码、偶发丢失字节
这是模拟串口最常见的故障,我在调试过程中也反复遇到。排查思路按照优先级排列如下:
| 故障现象 | 排查方向 | 解决方法 |
|---|---|---|
| 所有数据都是乱码 | 波特率不一致 | 确认双方波特率完全一致,特别注意电脑端设置 |
| 接收到的数据和发送的不一样 | 数据位顺序错误 | 确认发送端LSB First,接收端采样也按LSB First组合 |
| 偶发丢字节 | 字节间间隔过短 | 发送端在两个字节之间增加延时,或用中断+缓冲区方式 |
| 高波特率下乱码 | 延时误差过大 | 改用DWT寄存器延时,关闭不必要的全局中断 |
| 有时收到0xFF/0xFE | 起始位误判或停止位错误 | 加强起始位确认逻辑,检查接线是否稳定、是否存在共地问题 |
| 电机/继电器动作时乱码 | 电磁干扰 | 线缆缩短、双绞线传输、接收引脚加RC滤波 |
这里特别想展开说说"接收偶发丢数据"的心得。我的一个项目里,主循环每隔几十毫秒做一次屏幕刷新,中间有一段比较耗时
本文还有配套的精品资源,点击获取