STM32 GPIO模拟串口:外设不够时的UART通信方案
发布时间:2026/9/10 14:49:11来源:尧图网络
简介面向嵌入式开发者的STM32 GPIO模拟串口通信工程示例通过GPIO引脚电平变化模拟UART协议重点解决硬件UART资源不足、通信乱码等问题适用于资源受限的MCU项目或需要多路串口扩展的场景。压缩包共71个文件以31个H头文件和30个C源文件为主另含hex固件、工程配置、启动文件、清理脚本及README说明整体仅315KB目录按CORE、SYSTEM、USER等清晰划分便于定位模块代码。已有2345人学习下载。工程基于SoftWareSerial-master方案默认波特率9600通过软件定时器精确产生波特率完整演示了利用普通GPIO模拟串口收发、时序控制与中断处理的关键实现并涉及起始位、停止位及校验位的同步机制可有效避免乱码。资源可直接在Keil中打开编译适合需要灵活扩展串口或深入理解串口协议的开发者参考。1. 为什么需要 GPIO 模拟串口外设不够时的通信方案开发 STM32 项目时硬件串口通常是按模块数量分配的无线模块一个、传感器一个、上位机通信一个等你想加调试日志输出发现 USART 已经全部用光。这时候用普通 GPIO 按串口协议时序翻转电平就是 GPIO 模拟串口通信。它不追求替代硬件 USART而是解决调试日志、低速率数据回传这类补充需求。本质上是把 UART 的帧格式拆成起始位、数据位、停止位用软件或定时器在时间轴上精确输出。这个方法在 STM32F1、F4 和不少国产兼容型号上都能跑代码量不大难点在于接收侧的边沿硬件。本文不绑定某款开发板示例用 STM32F1 标准外设库写方便对照自己的工程调整。2. GPIO 的 8 种工作模式与 UART 时序基础2.1 GPIO 的 8 种工作模式模拟串口该怎么选STM32F1 系列 GPIO 可配置成 8 种工作模式输入浮空、输入上拉、输入下拉、模拟输入、开漏输出、推挽输出、复用开漏、复用推挽。做模拟串口时发送脚一般用推挽输出接收脚用上拉输入。推挽输出由内部电路主动驱动高低电平上升沿和下降沿都陡位时序稳定接收脚选上拉输入可以避免线路悬空时误触发中断。方向推荐配置原因TX 发送推挽输出高低电平都由内部驱动边沿陡、时序可控TX 发送电平转换开漏输出 外部上拉可以匹配不同电平但上升沿变缓高波特率不适用RX 接收上拉输入空闲线为高避免悬空误触发RX 接收噪声环境输入下拉 外部强上拉外部上拉把空闲电平钳在高位抗噪更好GPIO 初始化代码用标准外设库写大致是GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // TX 引脚PA9推挽输出初始高电平 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_WriteBit(GPIOA, GPIO_Pin_9, Bit_SET); // 先置高再初始化 GPIO_Init(GPIOA, GPIO_InitStructure); // RX 引脚PA10上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure);代码里先把 TX 引脚输出寄存器置 1再执行 GPIO_Init这一步很重要。因为很多 STM32 的 GPIO 输出数据寄存器默认值是 0如果先改成输出模式引脚会瞬间输出低电平对端设备会误认为收到了一个起始位。常见的串口通信乱码有一部分就是初始化时序不对造成的。2.2 UART 帧格式与位时间计算一帧 UART 数据由起始位、数据位、可选的校验位和停止位组成。线路空闲时是高电平发送方先拉低一个位时间表示起始位然后按 LSB 在前依次输出数据位最后拉高一个位时间以上作为停止位。模拟串口通信做的事情就是在时间轴上把这些电平变化做出来时间基准就是位时间。位时间的计算公式是 1 除以波特率。9600 波特率下一位约 104.17 微秒115200 波特率下一位约 8.68 微秒。下面这张表列出了常见的设置和模拟难度波特率位时间一帧总时长8N1适用实现方式9600104.2 μs1041.7 μs阻塞延时即可1920052.1 μs520.8 μs阻塞延时需补偿误差3840026.0 μs260.4 μs定时器中断驱动更稳1152008.7 μs86.8 μs必须用定时器时钟频率要精确为什么 19200 以上就不推荐阻塞延时因为调用延时函数本身的指令开销、循环判断、中断打断都会吃掉几个微秒在 104 微秒的位时间里这几个微秒可以忽略在 26 微秒的位时间里就很可观了。所以真正做模拟串口通信的成品方案一般都会用定时器来定一个精确的时间基准。2.3 三种常见的实现方式阻塞延时、定时器驱动、EXTI定时器实现方式发送实现接收实现适合场景阻塞延时逐位翻转引脚延时等待只能轮询输入电平调试日志单向低频输出定时器中断状态机逐位输出需要配合 EXTI 检测起始位双向通信速率 38400 以下EXTI定时器环形缓冲区 定时器取数下降沿启动定时器采样严谨的点对点通信阻塞延时的代码最简单适合刚接触模拟串口的项目定时器方案复杂一些但可以让主循环不被长时间占用。后面两章分别按发送和接收展开采用的就是这样一层层加厚的方式。3. GPIO 模拟串口发送推挽输出与位时序实现3.1 阻塞式发送从起始位到停止位最直观的发送做法是把字节的每一位依次写到 GPIO每两位之间延时一个位时间。下面是基于标准外设库的阻塞版函数// 软件串口发送一个字节阻塞式 void SoftUART_SendByte(GPIO_TypeDef *port, uint16_t pin, uint8_t data) { uint8_t i; // 发送起始位拉低持续一个位时间 GPIO_WriteBit(port, pin, Bit_RESET); Delay_Us(SOFTUART_BIT_TIME); // 依次发送 8 个数据位LSB 在前 for (i 0; i 8; i) { if (data (1 i)) { GPIO_WriteBit(port, pin, Bit_SET); } else { GPIO_WriteBit(port, pin, Bit_RESET); } Delay_Us(SOFTUART_BIT_TIME); } // 停止位拉高保持一个位时间以上 GPIO_WriteBit(port, pin, Bit_SET); Delay_Us(SOFTUART_BIT_TIME); }逻辑很直白先输出低电平作为起始位再逐位判断 data 的每一位位为 1 输出高、为 0 输出低每位之间延时同样时长。最后拉高做停止位。这里 SOFTUART_BIT_TIME 用宏定义位时间微秒数是 1000000 除以波特率#define BAUD_RATE 9600 #define SOFTUART_BIT_TIME (1000000 / BAUD_RATE)注意 1000000 除以 9600 的结果是 104.16整数宏定义会截断成 104。一位误差大约 0.17 微秒一帧累加不到 2 微秒对 UART 的容差来说很安全。如果波特率升到 115200一位只有 8.68 微秒整数截断带来的相对误差会明显变大再用这种写死宏的方式就不合适了。Delay_Us 的实现方式有讲究。SysTick 是最常用的方案但 HAL 库自带的 HAL_Delay 是毫秒级不能直接用于微秒延时。我一般会直接用 SysTick 的 LOAD 寄存器做微秒翻转配置成 HCLK 时钟源这样延时精度可以控制在几个指令周期内。3.2 用定时器实现非阻塞发送阻塞发送会占死 CPU如果一个字节 10 个位时间都在等那主循环里其他任务就别想跑了。换成定时器中断发送后主循环只需要往缓冲区填数据中断逐位把数据发出去CPU 占用率大幅下降。状态机写起来是这样volatile uint8_t tx_byte; // 待发送的字节 volatile uint8_t tx_bit_index; // 当前位序号0起始位1~8数据位9停止位 volatile uint8_t tx_busy; // 1 表示正在发送 // 定时器中断间隔为一个位时间 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (!tx_busy) return; if (tx_bit_index 0) { // 起始位 GPIO_WriteBit(SOFT_TX_PORT, SOFT_TX_PIN, Bit_RESET); } else if (tx_bit_index 8) { // 数据位 GPIO_WriteBit(SOFT_TX_PORT, SOFT_TX_PIN, (tx_byte (tx_bit_index - 1)) 0x01); } else { // 停止位 GPIO_WriteBit(SOFT_TX_PORT, SOFT_TX_PIN, Bit_SET); } tx_bit_index; if (tx_bit_index 10) { tx_busy 0; TIM_Cmd(TIM3, DISABLE); } } }调用发送时把要发的字节赋给 tx_byte置 tx_busy 为 1清空 tx_bit_index打开定时器即可。这个方案在中断里只做翻转引脚和变量自增耗时极短不会影响系统实时性。这里要提一个容易踩的坑如果定时的中断优先级设得比系统其他中断低高优先级中断处理期间定时器中断会被拖延结果就是当前位的宽度变长整个字节的时序出现毛刺。把模拟串口定时器中断优先级设为高于普通外设中断比如 NVIC 优先级分组后设成 1 或 2可以明显改善时序稳定性。3.3 发送侧还要关注的 GPIO 初始化细节有一个常见误区是把发送脚配置成复用推挽。模拟串口并没有用到片上 UART 外设不需要复用功能配成普通推挽输出即可。复用推挽在 HAL 库里对应 GPIO_MODE_AF_PP普通输出是 GPIO_MODE_OUTPUT_PP。用错模式后引脚依然可以手动翻转但后续一旦初始化了其他外设复用关系会搅在一起排查起来很费时间。参数推荐值说明GPIO 模式推挽输出开漏需要外部上拉否则低电平无法复位初始输出电平高防止配置为输出瞬间产生伪起始位GPIO 速率50MHz 或最高档提高翻转斜率降低波形畸变延时函数精度≤1μs位时间误差控制在 5% 以内另外延时函数本身可能存在问题。如果你在网上找过延时代码会看到很多网友遇到 Delay 卡死的情况常见原因是 SysTick 的时钟源被改过或者系统里已经用 SysTick 做了时基、但优先级没有配好。处理办法是检查 SysTick-CTRL 的 CLKSOURCE 位是否等于 HCLK并确保没有别的任务禁用 SysTick 中断。4. GPIO 模拟串口接收下降沿检测与定时器采样4.1 为什么接收比发送难发送时引脚由自己控制时间误差好纠正接收时外部信号什么时候来、边沿有多快完全是未知的。接收侧有两个关键动作检测起始位的下降沿然后在正确的时刻采样 8 个数据位。如果没检测到下降沿或者采样点落在数据位边缘结果就会错位。最常用的方案是外部中断检测下降沿定时器负责在采样点产生中断。下降沿触发后先把外部中断关掉避免后续数据位跳变再次触发然后启动定时器让采样点落在起始位的中间位置。之后再按一个位时间的间隔采样 8 个数据位。4.2 EXTI 检测起始位 定时器采样以 STM32F103 为例接收引脚接 PA4对应 EXTI4 线。先做初始化EXTI_InitTypeDef EXTI_InitStructure; // PA4 下降沿触发中断 RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource4); EXTI_InitStructure.EXTI_Line EXTI_Line4; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); NVIC_SetPriority(EXTI4_IRQn, 2); NVIC_EnableIRQ(EXTI4_IRQn);外部中断服务函数里做两件事关闭 EXTI4 中断启动一个定时器周期设为半个位时间void EXTI4_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line4) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line4); // 关闭 EXTI4避免后续数据位边沿再次触发 EXTI_InitStructure.EXTI_LineCmd DISABLE; EXTI_Init(EXTI_InitStructure); // 启动定时器半个位时间后第一次采样 TIM_SetCounter(TIM3, 0); TIM_SetAutoreload(TIM3, SOFTUART_HALF_BIT); TIM_Cmd(TIM3, ENABLE); } }定时器中断里的采样逻辑比发送复杂因为它要区分普通位和边界条件void TIM3_IRQHandler(void) { static uint8_t bit_cnt 0; static uint8_t rx_data 0; uint8_t level; if (TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); level GPIO_ReadInputDataBit(SOFT_RX_PORT, SOFT_RX_PIN); if (bit_cnt 0) { // 起始位采样正常应为低电平 if (level ! Bit_RESET) { // 电平不是低说明刚才的下降沿是干扰 bit_cnt 0; TIM_Cmd(TIM3, DISABLE); EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); return; } } else if (bit_cnt 8) { // 数据位LSB 先到 rx_data 1; if (level ! Bit_RESET) { rx_data | 0x80; } } else { // 停止位正常应为高电平 if (level Bit_RESET) { // 停止位为低说明发生帧错误这里可做统计 } App_RxCallback(rx_data); rx_data 0; bit_cnt 0; TIM_Cmd(TIM3, DISABLE); EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); return; } bit_cnt; // 后续采样间隔改为一个完整位时间 TIM_SetAutoreload(TIM3, SOFTUART_ONE_BIT); } }这段代码的思路是第一次定时器中断发生在半个位时间后如果读到的电平是低说明起始位有效然后每隔一个位时间采样一次数据位rx_data 右移一位后把新位放到最高位就能拼出完整的字节。最后采样停止位如果停止位应该为高却读到低说明波形不对直接丢弃这一帧。这里有个细节要注意定时器第三次及以后中断的间隔都是 1 个位时间。因为起始位采样点是 0.5T数据位第 1 位的采样点就是 0.5T1T以此类推。如果第一个数据位就按 1T 间隔计时会把采样点落在位边界上导致错误的可能性大幅增加。4.3 定时器重载值计算与不同波特率的支持定时器重载值的计算方式是目标时间微秒乘以定时器时钟频率MHz再减 1。假设 APB1 定时器时钟 72MHz则一个位时间对应的重载值大概是 72 乘以微秒数。下表是几种波特率下的参考值波特率半位时间 (μs)半位重载值单位时间重载值960052.1375075001920026.0187437493840013.09371875576008.762612531152004.3312625实际工程里这个重载值还需要考虑定时器溢出中断的入栈时间。如果中断里代码执行时间过长重载值应该做小幅补偿具体补偿量只能自己用逻辑分析仪测出来。不要照抄网上别人的数值因为不同 MCU 内核频率和中断延迟并不一样。4.4 接收侧常见的容易出问题的点外部中断重触发问题。RX 线上如果有毛刺第一个下降沿可能来自噪声而不是真正的起始位。处理方法在代码里已经体现起始位采样点读到高电平就放弃接收重新打开外部中断。对于干扰更严重的场景可以在 EXTI 中断里加一个 20 微秒左右的软件延时再确认电平但这样会牺牲响应速度在 115200 下不可行。多个模拟串口共用一个定时器的问题。如果同时模拟两路接收不要把两路的采样逻辑全放在同一个定时器中断里建议每路各用一个定时器或者用一个时基轮询两个通道的缓冲。同一中断里做两路数据移位一旦出现定时优先级冲突很容易丢位。还有 EXTI 线的冲突。STM32F1 系列同一组 EXTI 线只能映射到一个引脚如果 PA4 和 PB4 都想用 EXTI4就不行。选引脚时先查下 EXTI 线占用情况可以省去很多调试时间。5. 调参验证与进阶用法把模拟串口用得更稳5.1 用逻辑分析仪验证模拟串口时序写完后先用逻辑分析仪看一眼波形。探针接 TX 脚触发方式设为下降沿发送一串 0x55观察起始位、数据位、停止位的宽度是否都和理论位时间一致。0x55 的二进制是 01010101能让数据位呈现规律的方波一眼就能看出位宽是否均匀。如果起始位明显偏宽或偏窄重点检查延时函数和中断优先级。5.2 把 printf 重定向到模拟串口做调试有了模拟串口调试输出也能像硬件 UART 一样用 printf。推荐的做法是先实现一个环形发送缓冲区定时器中断从缓冲区取字节逐位发送printf 的底层输出函数 fputc 只往缓冲区里写。这样即使日志量大也不会阻塞主循环很长时间。切换波特率时先把接收中断和发送中断都关掉更新定时器重载值再重新使能不要在发送中途改重载值。5.3 让模拟串口在真实项目里更稳的做法上电顺序是很多人忽略的细节。MCU 外部复位期间引脚是高阻态如果对端设备已经开始发送数据第一个下降沿可能被当成起始位导致后面对齐全部错位。建议在接收端初始化完成后再使能外部中断或者用 PA0/PA1 这类支持 WAKEUP 的引脚配合低功耗唤醒从根本错开上电时序。最后所有的模拟串口方案都有限度位采样周期里 CPU 会被定时器中断频繁打断占空比过高会让系统平均响应变差。能用硬件 UART 的场景不要因为代码写完了就硬撑。模拟串口只适合作为补充手段上线前一定要做连续多字节、长时间的老化测试确认在最高波特率下零丢帧再交付给业务层使用。本文还有配套的精品资源点击获取
网站建设高端定制企业官网