首页 / 资讯中心 / 文章详情

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

STM32普通IO口模拟串口实战:定时器中断实现软UART ★ FEATURED ARTICLE
简介针对项目串口外设数量不足、又需要多路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波特率下PSC0、ARR8749实际波特率约9600.55bps误差只有0.006%115200波特率下ARR728实际波特率115226bps误差0.023%完全在可接受范围内。注意定时器溢出误差不会逐位累积因为每一个中断周期都由硬件重装每位时间独立不会出现“第一位偏了导致后面全部偏移”的情况。这点和纯延时方案有本质区别。3. CubeMX工程骨架IO选型、时钟树与中断配置3.1 时钟树先把定时器时钟频率看清在CubeMX里新建工程选择STM32F407系列芯片。时钟树配置建议外部HSE晶振按8MHz输入PLL倍频到168MHz主频APB1预分频设为4得到42MHz外设时钟APB2预分频设为2得到84MHz外设时钟。这里有个极其容易踩的坑定时器时钟不是外设时钟是外设时钟的两倍。APB1上定时器时钟是84MHzAPB2上定时器时钟是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设为0Counter 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的中心。之后每中断一次采样一个bitLSB先存采完第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四字回环再联机看到波形符合预期才继续往下开发这个习惯帮我拦下了不少隐藏的时序问题希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站