news 2026/10/23 8:48:29

STM32普通IO口模拟串口实战:定时器中断实现软UART

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32普通IO口模拟串口实战:定时器中断实现软UART

简介:针对项目串口外设数量不足、又需要多路UART通信的嵌入式场景,这套基于STM32F407VET6的工程资源给出了普通IO口模拟串口的完整实现方案。初始化部分由CubeMx自动生成,作者独立完成核心功能逻辑:两个定时器分别用于发送延时和产生接收中断,两个IO引脚负责收发,通过配合串口时序完成不定长数据的收发,最终用串口助手验证了数据回显功能。资源共176个文件,压缩包8.01MB,主要内容包括C源码、H头文件、CubeMx的.ioc工程配置、编译中间文件以及HEX固件,其中HAL库代码覆盖定时器、DMA、UART、RCC等模块,便于对照学习时钟树配置、定时器中断处理和异步串行时序细节。已有780人学习下载,适合需要在不增加硬件外设的前提下扩展串口数量、深入理解串口底层时序的STM32开发者参考;对备赛、课程设计或多串口通信项目调试也有直接帮助。

1. 为什么要用普通IO口模拟串口:不只为了省一个串口

手头有一块STM32F407的开发板,外设接口全用光了:两个硬件串口一个给了调试,一个给了蓝牙模块,SPI和I2C也被传感器占满。这时候新加的一个功能模块偏偏只支持UART通信,板子上已经没有可用的USART外设了。重新画板子不现实,换MCU成本更高,被逼无奈之下只能让路的说法变成了“用普通IO口模拟串口”。

所谓模拟串口,就是用GPIO引脚配合定时器中断,在软件层面复刻UART的帧格式和时序:起始位、数据位、停止位、波特率一个都不能少。这个方案适合串口资源耗尽、临时复用引脚、或者只需要低速调试通信的场景。STM32F407主频168MHz,配合CubeMX生成的工程骨架,9600到115200波特率都能稳定跑,代价是CPU占用和一些细节上的折腾。如果你也遇到外设不够用的情况,这篇笔记把思路、配置、代码和踩过的坑一次说清。

2. 时序地基:定时器中断如何复刻波特率

2.1 UART帧格式:为什么起始位是模拟的钥匙

异步串口没有时钟线,收发双方靠约定好的波特率各自计时。空闲状态下总线保持高电平,发送方拉低总线一个位时间,就是“起始位”。接收方从起始位的下降沿获得同步参考点,之后按波特率在每一位的中心附近采样,数据位通常是8位、LSB先发,最后是高电平的停止位。整个帧的节奏完全由时间决定,所以模拟UART的本质不是“发送高低电平”,而是精确控制每一位的时间。

这也是为什么模拟串口的关键不是GPIO操作,而是定时器。STM32F407的GPIO翻转速度很快,快过百兆,问题在于“什么时候翻转”。如果启动一个延时函数,CPU执行指令本身也要时间,延时精度很难控制;而定时器依靠硬件计数,溢出中断的间隔是确定的,不受主循环里其他代码干扰。用定时器做位时钟,误差可控,软件结构也清晰。

2.2 两种时间基准:延时轮询 vs 定时器中断

网上常见做法分两派:一派用delay_us死等,一位时间到了就翻转一次引脚,循环发送/接收每一位;另一派用定时器溢出中断,在中断回调里做移位和采样。

延时轮询的优点是代码简单,几行就能发一个字节。缺点是发送期间CPU完全被占用,接收端必须在一个位时间内反复轮询引脚电平,如果中断频繁或者主循环里有长临界区,很容易丢位。更麻烦的是延时函数本身的指令开销导致一位的时间被拉长,通俗说就是“发着发着波特率就飘了”。在9600波特率下一位约104微秒,问题不明显,跑到115200时一位只有8.68微秒,任何一条多余指令都可能让采样点偏到位边界。

定时器中断方案不存在这个问题。定时器自动重装计数器,溢出中断的周期是硬件级的稳定,误差只取决于定时器时钟源和预分频参数的计算。接收端可以在每个位中心稳定采样一次,即使中途有别的中断抢了几微秒,只要不抢走超过半个位时间,采样点依然落在位中心附近。所以我推荐用定时器做时间基准,这也是下面要展开的完整方案。

2.3 采样点与定时器参数:先把波特率误差算干净

接收一个字节的时序点是:检测下降沿启动定时,半个位时间后第一次中断落在起始位中心,随后每个位时间中断一次,依次采样8个数据位和1个停止位。采样点必须落在每位中心的±25%以内才安全,越靠近中心越好。

定时器中断周期由PSC和ARR决定。STM32F407的TIM3挂在APB1总线,当APB1预分频设置为4时,定时器时钟是APB1外设时钟的两倍,也就是84MHz。选择PSC和ARR时,要满足:

def find_timer_params(timer_clk, baud): best = None for psc in range(0, 1024): cnt = timer_clk / (baud * (psc + 1)) arr = round(cnt) - 1 if arr < 0 or arr > 65535: continue actual = timer_clk / ((psc + 1) * (arr + 1)) err = abs(actual - baud) / baud if best is None or err < best[0]: best = (err, psc, arr, actual) return best print(find_timer_params(84000000, 9600)) print(find_timer_params(84000000, 115200))

以84MHz定时器时钟计算:9600波特率下PSC=0、ARR=8749,实际波特率约9600.55bps,误差只有0.006%;115200波特率下ARR=728,实际波特率115226bps,误差0.023%,完全在可接受范围内。注意定时器溢出误差不会逐位累积,因为每一个中断周期都由硬件重装,每位时间独立,不会出现“第一位偏了导致后面全部偏移”的情况。这点和纯延时方案有本质区别。

3. CubeMX工程骨架:IO选型、时钟树与中断配置

3.1 时钟树:先把定时器时钟频率看清

在CubeMX里新建工程,选择STM32F407系列芯片。时钟树配置建议外部HSE晶振按8MHz输入,PLL倍频到168MHz主频,APB1预分频设为4得到42MHz外设时钟,APB2预分频设为2得到84MHz外设时钟。这里有个极其容易踩的坑:定时器时钟不是外设时钟,是外设时钟的两倍。APB1上定时器时钟是84MHz,APB2上定时器时钟是168MHz。所以在CubeMX时钟树页面里,必须确认TIM3的时钟显示是84MHz而不是42MHz,否则后面ARR算出来波特率直接差一倍。

选择定时器时还要考虑后续扩展。TIM3和TIM4都在APB1上,时钟频率一致,可以让TIM3做接收位时钟、TIM4做帧超时计时,两者互不干扰。发送端如果也走中断方式,还可以用TIM5做发送位时钟,这样收发各自独立,逻辑清晰。

3.2 GPIO与EXTI:接收脚必须上拉,发送脚要有初始电平

模拟串口建议引脚选PB10做接收、PB11做发送,避开JTAG调试脚和板载LED脚。接收脚必须配置为输入模式并启用内部上拉,因为UART空闲电平是高,如果引脚浮空,一个毛刺就可能被当成下降沿触发。发送脚配置为推挽输出,初始电平必须设为高,否则上电瞬间总线一直是低电平,对接设备会以为收到起始位。

EXTI中断绑定在接收脚上,触发方式选下降沿。需要注意STM32F407的EXTI线是按端口分组的,PB10对应EXTI10,中断入口是EXTI15_10_IRQn,不是单独的EXTI10。在NVIC里使能这个中断组,并把优先级设置得比定时器中断稍微高一些,保证下降沿能第一时间响应。定时器中断优先级可以低一点,但两者的抢占优先级差值不要超过一级,避免出现中断嵌套时间过长导致采样错位。

3.3 定时器与NVIC初始化:只配置不启动,等起始位来了再开

CubeMX里TIM3的配置:Prescaler设为0,Counter Period按上一章公式填入ARR,自动重装使能,中断使能。但不要勾选“启动定时器”——定时器应该保持停止状态,等EXTI检测到下降沿后再手动启动。这样既省电,又能避免定时器先跑起来导致状态机混乱。

NVIC配置完成后生成工程。HAL库会在MX_TIM3_Init里把定时器参数填好,但不会自动调用HAL_TIM_Base_Start_IT,这个细节正好符合需求。后续操作全放在中断回调里,主循环只负责轮询接收完成标志,不参与位级时序。这也是整个设计的主心骨:中断管位级同步,主循环管数据消费。

4. 收发实现:移位采集、字节恢复与不定长帧判定

4.1 发送实现:阻塞版逐位翻转与临界区保护

发送端最常见的实现是阻塞式:拉低引脚产生起始位,延时一个位时间,然后按LSB顺序逐位发送数据,最后拉高产生停止位。代码直接,逻辑简单:

void soft_uart_send_byte(uint8_t data) { uint8_t i; HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, GPIO_PIN_RESET); // 起始位 delay_us(BIT_TIME_US); for (i = 0; i < 8; i++) { if (data & (0x01 << i)) { HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, GPIO_PIN_RESET); } delay_us(BIT_TIME_US); } HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, GPIO_PIN_SET); // 停止位 delay_us(BIT_TIME_US); }

BIT_TIME_US是单位时间的微秒数,定义为1000000 / baud。115200波特率下约8.68微秒,取整9微秒会造成约3.7%的误差,偏大,所以更推荐把接收端的时间基准统一用定时器计数表示,发送端也沿用定时器计数而不做微秒取整。

发送一帧数据时最好关闭全局中断:

void soft_uart_send_buffer(uint8_t *buf, uint16_t len) { __disable_irq(); for (uint16_t i = 0; i < len; i++) { soft_uart_send_byte(buf[i]); } __enable_irq(); }

这么做的原因是:发送过程中任何中断插入都会拖延当前位的翻转时刻,导致发送时序出现毛刺。关闭中断后,期间定时器中断全部挂起,发送节奏完全由自身循环控制。代价是这段发送时间内所有中断都被冻结,如果系统有实时性要求高的中断(比如电机编码器计数),就不适合长帧阻塞发送,需要改成中断驱动的发送方式。对于短帧、低速、非实时的场景,阻塞式完全够用。

4.2 接收实现:状态机与位中心采样

接收端是整套方案的核心。EXTI下降沿触发启动定时器,定时器中断负责采样。关键点在于第一次定时器中断必须发生在起始位的中心,为此在下降沿到来后先把ARR设为半位时间:

volatile uint8_t rx_state; volatile uint8_t rx_byte; volatile uint8_t bit_idx; volatile uint8_t rx_buf[128]; volatile uint16_t rx_len; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin == SOFT_RX_PIN) { HAL_NVIC_DisableIRQ(EXTI15_10_IRQn); // 采样期间关掉外部中断,防止数据位边沿干扰 bit_idx = 0; rx_byte = 0; rx_state = 1; // 进入采样状态 __HAL_TIM_SET_AUTORELOAD(&htim3, HALF_BIT_CNT); // 半位后触发第一次中断 __HAL_TIM_SET_COUNTER(&htim3, 0); HAL_TIM_Base_Start_IT(&htim3); } }

定时器中断回调里,第一次中断落在起始位中心,此时不采样数据,只把ARR改成一位时间,让下一次中断精确落在数据位0的中心。之后每中断一次采样一个bit,LSB先存,采完第8个数据位后下一个中断就是停止位:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim == &htim3 && rx_state == 1) { if (bit_idx == 0) { // 起始位中心,只校准定时器,不采样 __HAL_TIM_SET_AUTORELOAD(&htim3, BIT_CNT); bit_idx = 1; return; } uint8_t lvl = HAL_GPIO_ReadPin(SOFT_RX_PORT, SOFT_RX_PIN); if (bit_idx <= 8) { rx_byte |= lvl << (bit_idx - 1); // LSB first bit_idx++; } else { // 第9次中断 = 停止位 if (lvl == 0) { rx_state = 0; // 停止位错误,丢弃本字节 } else { rx_buf[rx_len++] = rx_byte; // 接收成功,存入缓冲区 __HAL_TIM_SET_COUNTER(&htim4, 0); // 刷新空闲超时计时 HAL_TIM_Base_Start_IT(&htim4); } HAL_TIM_Base_Stop_IT(&htim3); HAL_NVIC_EnableIRQ(EXTI15_10_IRQn); // 重新打开下降沿触发 rx_state = 0; } } }

这段逻辑里有两个容易被忽略的细节。第一,起始位中心那次中断不能采样数据,否则数据位0会整体偏移一次采样周期,收到的字节全部错位。第二,停止位采完后要立刻关闭TIM3并重新打开EXTI,这样下一个字节的下降沿才能重新触发一轮新的采样流程,实现连续接收。

4.3 不定长帧判定:总线空闲超时阈值选择

不定长的含义是:接收方不知道一帧有多少字节,必须自己判断帧结束。判断方法有两种思路。第一种是协议约定,帧头加长度字段,收到指定字节数后结束,这需要和发送端配合改协议。第二种是利用总线空闲时间,即一帧内字节间隔很短,帧与帧之间间隔较长,用一个超时定时器来测量“当前总线是否已经空闲了足够久”。

第二种方法对发送端透明,也是模拟串口场景里最常用的方案。TIM4作为超时定时器,每收到一个字节就清零重新计时,ARR设置为超时阈值。如果超过阈值没有新的下降沿到来,就认为整帧结束:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim == &htim4) { HAL_TIM_Base_Stop_IT(&htim4); frame_done = 1; // 帧完成标志,主循环轮询后处理 rx_buf } }

超时阈值取多少合适?一般取2到3个字符时间。9600波特率下一个字符约10位、1.04毫秒,阈值设3毫秒左右;115200波特率下阈值设300微秒左右。阈值太小会把发送端中途的字节间隔误判成帧尾,导致一帧被拆成两包;阈值太大则帧结束检测变慢,应用层响应延迟明显。如果发送端是单片机且中断较多,字节间隔可能大于一个字符时间,这时阈值应该适当放宽到3个字符时间,牺牲一点响应速度换取稳定。

5. 常见问题与排查:误码、丢字节、时序偏差的三个坑

5.1 EXTI未关闭:起始位错乱

现象:接收数据偶发错位,第一个字节经常是错的,后面的数据有时又能对。

原因:进入位采样后,外部中断仍然开着。数据位里存在0到1或1到0的跳变,这些边沿同样触发下降沿中断,把定时器重新清零重启,采样点整体错乱。

解决:EXTI回调里检测到起始位后,第一时间关闭EXTI15_10中断,直到停止位采样完成才重新打开。关闭顺序必须是先关中断再启动定时器,避免在启动前的间隙被边沿打断。

5.2 波特率误差:定时器时钟算错导致全盘乱码

现象:自发自收完全正常,但和PC或别的设备对接时乱码。用逻辑分析仪看波形,每位的宽度比理论值宽或窄接近一倍。

原因:ARR和PSC使用42MHz计算,而TIM3实际时钟是84MHz;或者直接拿主频168MHz除波特率,以为定时器跑在主频上。STM32F407的定时器时钟经过APB预分频器倍频,取值要么和APB外设时钟一致(分频系数为1时),要么是外设时钟的两倍(分频系数大于1时)。

解决:到CubeMX的Clock Configuration页面点开TIM3的时钟树显示,确认实际数值。把该数值代入公式重新计算ARR和PSC。排查时先用自发自收确认软件没有逻辑问题,再用硬件串口或逻辑分析仪核对实际位宽。

5.3 引脚冲突:调试打印和模拟串口互相干扰

现象:模拟串口一跑,调试信息就开始丢字符;或者发送波形出现毛刺。

原因:选择了和调试串口相邻的引脚,或者把发送脚接到了板载USB转串口的某个引脚上。最常见的是PA9和PA10,这两个脚同时又是USART1的TX和RX,很多板子默认接了USB转串口芯片,GPIO配置被硬件电路拉低或拉高。

解决:换引脚。接收脚优先选PB口或PC口,确保板子上没有被外部电路占用。检查原理图,凡是接了LED、按键、USB转串口、下载器的引脚都要避开。

5.4 超时阈值不当:半包或响应慢

现象:明明发了完整一帧,接收端却分成两次上报;或者帧结束检测延迟明显,系统反应慢半拍。

原因:超时定时器的阈值设得太短,发送端两个字节之间稍有延迟就被判成帧尾;阈值设太长,空闲检测迟迟不来。

解决:按波特率计算基础字符时间,阈值设为2到3个字符时间。115200波特率下阈值建议300到400微秒,9600波特率下3到4毫秒。如果发送端中断负载重,字节间隔可能拉大,阈值适当上调。稳妥的做法是在逻辑分析仪上抓一发实际波形,统计字节间隔再定阈值。

6. 调试验证技巧:回环测试、误码统计与参数收尾

验证模拟串口是否可靠,最直接的手段是回环测试:把发送脚直接连到接收脚,让单片机自发自收,核对接收数据是否和发送一致。但常规回环只能证明逻辑正常,不能证明时序边界是否可靠。更有效的做法是发送一组专门暴露采样问题的字符:0x55是二进制01010101,每一位都在翻转;0xAA是10101010,同样所有位都翻转但极性相反。这两种字符最容易看出采样点是否偏离位中心,如果采样点偏了,回环数据会整体错乱成另一个字节。

实测中我把软串口接到逻辑分析仪上,抓取0x55和0xAA的完整波形。理想波形是10个宽度完全相等的脉冲,每个脉冲宽度等于波特率的倒数。如果某个位明显宽或窄,说明定时器中断在该位附近被打断过,需要检查中断优先级和临界区保护。逻辑分析仪还能直接读出实际波特率,省去手动数波形的时间。

调通回环后,用误码统计做压力测试。我在主循环里循环发送0x00到0xFF共256个字节,接收端统计错误字节数并上报到另一路硬件串口:

uint16_t sent_count = 0; uint16_t err_count = 0; for (uint16_t i = 0; i < 256; i++) { uint8_t test_data[256]; for (uint16_t j = 0; j < 256; j++) { test_data[j] = (uint8_t)j; } soft_uart_send_buffer(test_data, 256); while (!frame_done) { // 等待接收完成,超时要加保护 } frame_done = 0; for (uint16_t j = 0; j < 256; j++) { if (rx_buf[j] != (uint8_t)j) { err_count++; } } sent_count++; }

压测100轮,如果误码率低于万分之一,这个模拟串口才算能交付使用。最后收尾两件事:把不用的定时器中断在空闲时关闭,避免无谓的CPU开销;把EXTI中断和定时器中断的优先级截图存到工程文档里,方便以后维护。从那以后我每次用模拟串口都会先做一轮0x55、0xAA、0x00、0xFF四字回环再联机,看到波形符合预期才继续往下开发,这个习惯帮我拦下了不少隐藏的时序问题,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 9:49:19

FANUC 0i-MF操作手册核心要点:从安全须知到固定循环与故障排查

简介&#xff1a;FANUC 0i-MF操作手册是面向数控机床操作人员、PLC与智能制造领域从业者的加工中心操作指南&#xff0c;覆盖FANUC Series 0i-MODEL F系统的安全须知、接口与传输、NC程序编程、控制面板操作及日常维护等核心模块。文档特别强调安全分级概念&#xff0c;清晰区分…

作者头像 李华
网站建设 2026/10/22 16:32:20

YOLOv5斗地主牌面识别:数据增强、训练调优与安卓NCNN部署全攻略

简介&#xff1a;面向具备一定深度学习基础、希望快速落地扑克牌识别功能的开发者&#xff0c;这套斗地主牌面识别方案提供了从YOLOv5模型训练到安卓端NCNN部署的完整实现。方案核心是基于YOLOv5训练的高精度检测模型&#xff0c;可自动定位游戏截图中的每张牌面并识别数字和花…

作者头像 李华
网站建设 2026/10/22 18:27:07

Railway上部署小龙虾-OpenClaw:Docker与配置避坑指南

部署开源智能助理这事&#xff0c;我一直觉得最烦人的不是代码&#xff0c;而是环境。好好的项目&#xff0c;本地跑得飞快&#xff0c;一上云端就各种崩&#xff0c;数据库连不上、镜像拉不下来、日志刷屏看半天不知道错在哪。所以当我看到“小龙虾-OpenClaw”这个项目的时候&…

作者头像 李华
网站建设 2026/10/21 4:36:31

3D高斯泼溅与神经渲染:CV技术演进新范式

1. 这不是论文列表&#xff0c;而是一份“CV方向技术演进路线图”如果你最近打开 arXiv 的 cs.CV 分类页面&#xff0c;看到标题里带“2026.09.25”这种未来日期的汇总帖&#xff0c;第一反应可能是&#xff1a;时间错乱&#xff1f;刷屏机器人&#xff1f;还是某位教授在测试版…

作者头像 李华
网站建设 2026/10/23 4:14:24

Unity第三人称角色移动系统详解:从输入到相机跟随的完整实现

1. 项目起底&#xff1a;第三人称角色移动&#xff0c;到底在解决什么问题做Unity开发的人&#xff0c;不管你是做动作游戏、RPG还是开放世界探索&#xff0c;绕不开的第一座山就是“让角色动起来”。输入一个方向、角色响应、镜头跟随&#xff0c;这套链路的体验好坏&#xff…

作者头像 李华
网站建设 2026/10/23 4:07:17

元尚宅乡墅分享农村自建房规划中用地审批与设计建造要点助力农户建安全舒适乡居

农村自建房宅基地使用权申请需满足谁的条件 在农村自建房规划中&#xff0c;宅基地使用权有明确的身份限定。申请人必须是所在村集体经济组织的成员&#xff0c;这是取得用地资格基本前提。已经外迁或者户口落户城市的人&#xff0c;原则上不能再回原宅基地申请建房。符合条件的…

作者头像 李华