
简介STM32的GPIO口模拟串口通信是一份面向嵌入式开发者的实战资源重点解决MCU缺少硬件UART或串口资源不足时的通信替代方案适合STM32入门及进阶学习者、项目移植人员参考。内容围绕GPIO模拟串口的核心原理展开涵盖IO配置、软件定时器产生波特率、发送接收时序、中断检测、错误处理与软流控等完整链路。资源包共133个文件约775KB以C源文件、头文件、Keil工程文件为主另含多个辅助脚本、说明文档及工程备份工具可帮助读者快速对照工程代码理解实现细节。已有3722人学习下载。通过学习可掌握无硬件串口环境下基于GPIO与软件定时器实现可靠串行通信的方法并可将代码迁移至不同STM32型号或自定义波特率场景。 做项目最怕的就是硬件串口不够用。前段时间调试一个设备主串口拿去跟Wi-Fi模块通信调试串口要打印日志第二个串口又得分给传感器翻完芯片手册发现USART已经全部用完了板子上还剩一把GPIO闲着。于是只好硬着头皮用GPIO去模拟串口通信也就是平时大家说的软件串口、bit-banging。折腾了一周把发送、接收、误码、中断优先级这些问题全部踩了一遍最后算是稳定跑起来了。这篇文章就把我的实现思路、代码和踩过的坑完整记录下来给遇到同样问题的朋友一个参考。如果你正面临硬件串口不够用、引脚被占用、或者想省一颗外挂UART芯片的钱这篇文章应该能帮上忙。1. 为什么非得拿GPIO去模拟串口1.1 硬件串口的不够用时刻STM32的USART数量是固定的F103C8T6只有3个串口F407也就6个。实际项目里串口永远不够用蓝牙模块占一个、GPS占一个、调试日志占一个、上位机通信又占一个还没接传感器就没口了。有些引脚还有重映射冲突比如你想用的那个USART引脚已经被ADC或者PWM占了这时候重新画板子不现实换芯片成本又高。用GPIO模拟串口就成了最直接的解法。我这次的情况是手上有一批F103C8T6的最小系统板SPI、I2C、USART1和USART2全都被占用唯一的USART3引脚又被我设计成了LED控制板子已经打样不能改。最后看到PB10和PB11还空着就决定拿这两个脚模拟一个串口波特率不需要太高9600就能满足传感器数据回传的需求。1.2 GPIO模拟串口和硬件UART的差别拿GPIO模拟串口本质上是把物理层的时序用代码一点点挤出来而硬件UART是芯片内部的外设模块自动完成这件事。两者各有取舍我整理了一张对比表对比项硬件UARTGPIO模拟串口波特率上限可达数Mbps受主频和代码效率影响一般建议115200以内CPU占用极低硬件自动收发较高每一位都要CPU参与延时或中断处理可靠性高带硬件FIFO和错误检测较低受中断抢占和代码时序影响大引脚选择受芯片设计约束任意GPIO均可调试难度低高逻辑分析仪几乎是必需品适用场景大多数常规通信串口资源不足、临时方案、低频低速通信从表格可以看出来模拟串口不是万能替代品它最适合的是低速、短距离、数据量不大的场景。我这次只需要每隔100ms上报一次温度数据一帧不超过20字节9600波特率完全够用CPU占用也很低这才敢放心用模拟方案。1.3 适合用GPIO模拟串口的典型场景总结下来这几种情况用GPIO模拟串口是划算的一是硬件串口确实用完且来不及改板二是只需要单向通信比如只发不收或者只收不发实现难度直接减半三是跟外部设备临时调试对接比如手边没有USB转TTL工具直接拿GPIO翻转电平配一个简易波特率发生器四是在低功耗场景下不想给闲置的硬件串口供时钟用GPIO按需唤醒收发几帧数据就继续睡。但如果是高速通信波特率超过115200、长帧传输一帧超过几十字节、或者通信双方距离较远还是老老实实换芯片或者加一颗MAX3107这类外部UART芯片更靠谱。GPIO模拟串口的CPU占用会随着波特率和数据量直线上升这是硬约束别硬扛。2. 动手前必须懂的UART底层原理2.1 UART一帧到底长什么样串口通信的物理层在空闲时是高电平这是约定俗成的静止状态。要发一个字节先拉低电平持续一个位时间这叫起始位用来告诉接收方我要开始发数据了。然后依次输出8个数据位低位在前、高位在后。最后拉高电平持续一个位时间或更长这是停止位。如果加了校验位奇校验或偶校验就插在数据位和停止位之间。这一帧结构必须刻在脑子里因为后面所有的代码都是在跟这个时序打交道高→拉低起始位→逐位翻转电平数据位LSB first→拉高停止位。只要有一个位的时间不对对端就解析出错轻则乱码重则整帧丢掉。2.2 波特率、位时间与采样点波特率就是每秒传输的位数单位是bps。9600bps意味着每一位持续的时间是 1/9600 ≈ 104.17微秒115200bps每一位大约8.68微秒。模拟串口发送的本质就是把每个位持续的时间用延时函数撑住到点就翻电平接收则是反过来在每一个位时间的中间点去读引脚电平因为位中间是信号最稳定的位置远离跳变沿不容易采到不确定电平。这里有个关键认知模拟接收时并不是在起始位的下降沿立刻采样而是要延后大约1.5个位时间。为什么因为下降沿只是告诉你开始第一个数据位真正稳定出现要到起始位结束之后。延后1.5个位时间采样正好落在第一个数据位的中间往后每隔一个位时间再采一次就能依次拿到8个数据位。这个1.5倍的偏移量是模拟串口接收的灵魂。2.3 GPIO工作模式怎么选STM32的每个GPIO都有8种工作模式热词里也总被问到分别是浮空输入、上拉输入、下拉输入、模拟输入、开漏输出、推挽输出、开漏复用输出、推挽复用输出。模拟串口用得上的其实就几种别被8种模式吓到。发送引脚配置成推挽输出最省事输出高电平就是高电平输出低就是低驱动能力强不需要外部上拉。如果通信双方电平不匹配比如STM32是3.3V对端是5V设备可以改用开漏输出再接一个上拉电阻到对端需要的电平这样电平转换的问题顺便就解决了。接收引脚配置成上拉输入或浮空输入都可以我习惯用上拉输入因为空闲态是高电平上拉输入能保证引脚闲着时不会悬空乱跳误触发起始位中断。顺带说一句GPIO和Pin的关系GPIO是端口比如GPIOBPin是具体的引脚比如Pin10代码里写GPIOB-BSRR GPIO_Pin_10这样的操作本质就是操作GPIOB端口的第10位。理解了这个映射关系配置GPIO时思路会清晰很多。3. 发送端实现按位灌电压延迟控节拍3.1 发送的核心逻辑发送比接收简单得多因为主动权在自己手里。第一个数据位是起始位输出低电平紧接着要发8个数据位LSB在前每次判断这一位是0还是1把引脚拉低或拉高最后输出停止位高电平。每一位之间都要延时一个位时间保持电平稳定让接收方有时间采样。具体到代码核心就是一个bytectrl字节控制函数。在硬件配置上我习惯用一个宏定义把引脚号、端口封装好方便不同板子之间移植。下面是基于标准库的完整实现芯片是STM32F103C8T6结构跟用户提供的代码风格保持一致。3.2 发送代码实现#include stm32f10x.h #include systick_delay.h // 自己封装的微秒级延时 /* 硬件配置 */ #define SOFT_TX_GPIO_PORT GPIOB #define SOFT_TX_GPIO_PIN GPIO_Pin_10 #define SOFT_TX_GPIO_CLK RCC_APB2Periph_GPIOB #define SOFT_TX_HIGH() GPIO_SetBits(SOFT_TX_GPIO_PORT, SOFT_TX_GPIO_PIN) #define SOFT_TX_LOW() GPIO_ResetBits(SOFT_TX_GPIO_PORT, SOFT_TX_GPIO_PIN) #define BAUDRATE 9600 #define BIT_TIME_US (1000000 / BAUDRATE) // 104us 9600 static void Soft_TX_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(SOFT_TX_GPIO_CLK, ENABLE); GPIO_InitStructure.GPIO_Pin SOFT_TX_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 高速翻转减少边沿失真 GPIO_Init(SOFT_TX_GPIO_PORT, GPIO_InitStructure); SOFT_TX_HIGH(); // 空闲状态保持高电平 } void Soft_UART_SendByte(uint8_t byte) { uint8_t i; SOFT_TX_LOW(); // 起始位 delay_us(BIT_TIME_US); for (i 0; i 8; i) { if (byte 0x01) { SOFT_TX_HIGH(); } else { SOFT_TX_LOW(); } byte 1; delay_us(BIT_TIME_US); } SOFT_TX_HIGH(); // 停止位 delay_us(BIT_TIME_US); }这段代码的逻辑非常直白先拉低输出起始位然后循环8次每次取当前最低位输出再右移一位每一位输出后延时一个位时间最后拉高放停止位。用byte 0x01判断当前要发的位用右移操作逐位取出这个写法最直观也方便调试。3.3 微秒延时精度是发送成败的关键发送方最怕的就是延时不准。delay_us我建议用SysTick或者TIM定时器实现不要用简单的空循环计数因为空循环的延时时间会随着编译器优化等级变化同一个延时函数在-O0和-O2下时间能差出两倍。用SysTick的SysTick_Config配置一个1MHz的计数器每次延时就是纯粹的硬件计数不受软件优化影响。另外提醒一个坑如果你在中断服务函数里调用发送函数优先保证这个中断不能被更高优先级的中断打断太多次。GPIO模拟串口要求每一位的延时尽量精确一旦在延时的104微秒中间被一个耗时50微秒的中断插一脚这一位的电平持续时间就变成了154微秒对端立刻采样错位。所以在发送过程中要么临时屏蔽不重要的中断要么就把模拟串口的发送函数放到一个足够高的优先级上下文去执行。4. 接收端实现起始位下降沿捕获定时器采样4.1 接收的核心难点接收比发送难一个数量级。发送方知道什么时候输出每一位接收方不知道只能靠听——先听到下降沿起始位然后数着时间点去采样后面的数据位。如果采样点偏了或者中途被中断打断这一帧就废了。我实现接收用了两种思路对比。第一种是纯延时查询外部中断检测到起始位下降沿之后进入一个阻塞式的循环用delay_us依次在1.5倍位时间、2.5倍位时间……去读引脚电平。这种方式代码最简单但有一个致命问题如果这时候来了一个串口接收中断采样时序全乱。所以我后来改成第二种方案外部中断检测起始位然后用定时器的周期中断去采样这样即使有短暂中断插入只要定时器中断优先级够高采样点就不会被其他中断挤掉。4.2 外部中断定时器采样的完整实现接收引脚用PB11配置成上拉输入同时开启EXTI外部中断检测下降沿触发。定时器用TIM3配置周期正好等于一个位时间。检测到起始位下降沿后启动定时器并且让定时器在第一次溢出时晚半拍——这里我用了一个小技巧启动定时器时把ARR设置成1.5倍位时间第一次溢出正好落在第一个数据位的中间之后再把ARR改回1倍位时间这样就不需要单独的半位延时代码了。/* 硬件配置 */ #define SOFT_RX_GPIO_PORT GPIOB #define SOFT_RX_GPIO_PIN GPIO_Pin_11 #define SOFT_RX_GPIO_CLK RCC_APB2Periph_GPIOB #define SOFT_RX_EXTI_LINE EXTI_Pin_11 #define SOFT_RX_EXTI_PORT EXTI_Source_GPIOB #define SOFT_RX_EXTI_PIN EXTI_PinSource11 #define SOFT_RX_READ() GPIO_ReadInputDataBit(SOFT_RX_GPIO_PORT, SOFT_RX_GPIO_PIN) #define BAUDRATE 9600 #define BIT_TIME_US (1000000 / BAUDRATE) #define HALF_BIT_US (BIT_TIME_US / 2) #define ONE_AND_HALF (BIT_TIME_US HALF_BIT_US) /* 接收状态机 */ static volatile uint8_t rx_state 0; // 0:空闲 1:起始位确认 2:接收数据 static volatile uint8_t rx_bit_idx 0; static volatile uint8_t rx_byte 0; static volatile uint8_t rx_done 0; static volatile uint8_t rx_buffer 0; /* 定时器预分频和周期配置APB1时钟36MHz预分频36计数周期1us */ #define TIM3_PRESCALER (36 - 1) static void Soft_RX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(SOFT_RX_GPIO_CLK, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin SOFT_RX_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_Init(SOFT_RX_GPIO_PORT, GPIO_InitStructure); GPIO_EXTILineConfig(SOFT_RX_EXTI_PORT, SOFT_RX_EXTI_PIN); EXTI_InitStructure.EXTI_Line SOFT_RX_EXTI_LINE; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; // 下降沿触发起始位 EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel EXTI15_10_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 高优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); } static void Soft_RX_TIM_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_InitStructure.TIM_Period BIT_TIME_US - 1; // 默认一个位时间 TIM_InitStructure.TIM_Prescaler TIM3_PRESCALER; TIM_InitStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_InitStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; // 最高优先级保证采样不被挤掉 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }中断服务函数写在下面。外部中断负责捕捉起始位下降沿定时器中断负责按节奏采样。有一点要注意接收数据的起始位验证下降沿触发后我们不能立刻确定这就是起始位可能只是干扰毛刺所以我在定时器第一次中断时又读了一次引脚确认还是低电平才进入数据接收状态这样就滤掉了一部分噪声。void EXTI15_10_IRQHandler(void) { if (EXTI_GetITStatus(SOFT_RX_EXTI_LINE) ! RESET) { EXTI_ClearITPendingBit(SOFT_RX_EXTI_LINE); if (rx_state 0 SOFT_RX_READ() 0) { /* 检测到下降沿准备采样第一个数据位中间点 */ rx_state 1; TIM_SetAutoreload(TIM3, ONE_AND_HALF - 1); // 先等1.5个位时间 TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); // 启动定时器采样 } } } void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (rx_state 1) { /* 第一次中断确认当前确实还是低电平起始位有效 */ if (SOFT_RX_READ() 0) { rx_state 2; rx_bit_idx 0; rx_byte 0; TIM_SetAutoreload(TIM3, BIT_TIME_US - 1); // 后面的采样间隔改为一个位时间 TIM_SetCounter(TIM3, 0); } else { rx_state 0; TIM_Cmd(TIM3, DISABLE); } } else if (rx_state 2) { if (rx_bit_idx 8) { /* 依次采样8个数据位 */ if (SOFT_RX_READ()) { rx_byte | (uint8_t)(0x01 rx_bit_idx); } rx_bit_idx; } else { /* 采样停止位理论上应为高电平若为低说明这帧数据可能有问题 */ if (SOFT_RX_READ() 1) { rx_buffer rx_byte; rx_done 1; } rx_state 0; TIM_Cmd(TIM3, DISABLE); } } } }4.3 状态机设计的一点心得很多朋友写模拟串口接收喜欢用一堆if堆逻辑结果越写越乱。我建议一开始就按状态机来设计哪怕简单一点也值得。状态0是空闲只等下降沿状态1是起始位确认只做一件事状态2是数据位收集循环8次收完为止。每一个状态只干属于自己的活出错了回到状态0重新等起始位这样即使收到一个坏帧下一个帧还能继续收。这里第一个容易踩的坑是定时器中断里千万不要调用任何耗时的函数比如printf、delay_us、GPIO_ToggleBits带的反逻辑操作。定时器中断每一段时间就进来一次你多耗一微秒采样点就偏一微秒几脚下来整帧就废了。我调试的时候用了一个很笨的办法在定时器中断里给一个全局变量置标志主循环里看到标志再打印这样既能看到收了多少字节又不干扰采样时序。第二个坑是整体规划好中断优先级。我把定时器采样优先级设成0最高外部中断起始位检测设成1这样即使外部中断正在执行定时器也能打断它完成采样反过来如果主程序里有一些次要的中断想插队也插不进采样中断里。优先级配反了数据就会乱。5. 实战中的常见问题与排查实录5.1 波特率误差导致的乱码最典型的问题就是波特率不一致。GPIO模拟的波特率取决于延时函数的精度如果你的延时函数是空循环写的在不同优化等级下编译出来的延时时间会变化实际波特率可能不是9600而是9000对端自然收不到正确数据。排查方法是使用逻辑分析仪没有的话用示波器直接看波形测量起始位到停止位的实际时间。如果实测位时间差超过5%就要检查延时代码。另外两个设备之间通信建议发送方和接收方都把自己测出来的波特率误差控制在±2%以内这比理论上的最大容限更保守尤其是在连续传输长帧时误差会逐位累计最后一位的采样点偏移很容易超出容限。5.2 延时函数卡死的问题热词里经常看到stm32延时函数delay卡死这类搜索我在这次调试中也被坑了一次。排查下来发现卡死的原因是SysTick的优先级被配置成了最低主程序里正好有一段关全局中断的临界区代码SysTick一直得不到执行而模拟串口又在等延时时间到两边就这么干瞪眼。要解决这个问题一是SysTick优先级尽量配高二是不要在关中断的保护区内调用模拟串口的收发函数。如果确实需要原子操作先把要发的字节放进缓冲区出了临界区再逐字节发送。这是很多人容易忽略的细节但实际项目中很致命。5.3 接收丢字节和中断优先级配置异常现象可能原因排查与解决方法收不到任何字节接收引脚配置成下拉或浮空空闲态不稳定改成上拉输入确保空闲为高电平只能收到第一个字节状态机卡在数据接收状态没有复位每次完成或出错都要回到状态0数据偶发错误个别位反了采样点偏移或定时器中断被其他中断抢占用示波器测量位时间观察采样点位置提高定时器中断优先级发送正常对方收不到发送引脚推挽输出驱动能力不足或多个输出源冲突检查引脚是否被其他外设复用AFIO配置冲突通信双方波特率明明一样还是乱码双方的时钟精度差异或延时函数误差累积用逻辑分析仪实测双方波形校准延时5.4 电平不匹配和接线问题STM32是3.3V逻辑如果你要对接的传感器模块是5V TTL电平直接用推挽输出怼过去有烧引脚的风险改用开漏输出加外部上拉到5V是最稳的方案。接收端如果外设输出5V电平GPIO配置成上拉输入会有容忍风险F103其实并不完全5V兼容稳妥做法是加一个电阻分压比如2K1K或者串一个限流电阻再接引脚。另外接线越短越好模拟串口的时序本来就不如硬件UART稳定杜邦线拉个20厘米波形上会有明显的振铃和过冲轻则偶发误码重则完全收不到数据。我一般控制在10厘米以内并且收发共地这是串口通信最基础也最容易被忽略的点。最后再分享一点我的实际体会用GPIO模拟串口这件事说难也难说简单也简单。难在接收端的时序和状态机处理简单在发送端只要会翻电平就行。我在这次项目里总共花了两天时间调试接收最后稳定跑起来之后CPU占用在9600波特率下几乎可以忽略20字节一帧的数据收发完全无压力。即便后来调高了波特率到38400也只是占用多一些CPU功能上依然能跑。这里给后来的朋友两个建议第一发送端尽量用硬件定时器做延时不要依赖空循环第二接收端一定用中断加定时器采样的方案别图省事用纯阻塞查询。另外调试工具里逻辑分析仪比示波器好用得多能直接解析UART帧省去数波形的时间。如果你正要接一个类似的活儿希望这篇记录能让你少走几步弯路。本文还有配套的精品资源点击获取