十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F407实战:用GPIO和定时器软件模拟串口,解决硬件UART不够用难题

STM32F407实战:用GPIO和定时器软件模拟串口,解决硬件UART不够用难题 简介嵌入式开发中STM32F407片内串口数量有限当项目需要较多串口时使用普通IO口模拟串口是一种常用替代方案。这份面向单片机开发者和电子工程师的资料以CubeMx为初始化工具完整演示了如何借助IO引脚与两个定时器实现不定长数据的收发一个定时器负责发送延时另一个定时器通过中断接收数据。工程代码经串口助手实测可正常收发并将接收内容原样返回。压缩包共176个文件主要包含HAL库C语言源码与头文件、CubeMx工程配置、Keil工程文件以及编译生成的Hex和Axf文件整体约8.01MB便于按目录查看目前已有779人学习。学习本资料可掌握模拟串口的时序分析方法、定时器中断应用、CubeMx初始化配置思路并可直接获得测试固件与可复用的工程模板适合串口资源紧张或希望深入理解底层通信原理的开发人员。 上一版产品PCB已经投出去了硬件串口却成了最紧俏的资源。调试口占一个、上位机占一个、GPS占一个、蓝牙占一个到最后想再接一块称重传感器发现六个UART一个不剩。改板要等两周客户催得又急最后我盯着板上两个闲置的普通IO口看了半天决定用软件把串口“模拟”出来。这篇文章就是从那场“救火”现场沉淀下来的。STM32F407CubeMx环境下用两个普通GPIO配合一个通用定时器实现UART数据的收发并且支持不定长帧接收全程不依赖任何硬件串口外设。适合手里还有空闲定时器和两个IO、但硬件串口不够用的场景也适合想弄懂UART底层时序、好奇“串口到底是怎么收发数据”的开发者。我会把原理、CubeMx配置、核心代码、踩过的坑全部铺开讲清楚照着做就能跑。1. 为什么放着硬件串口不用非要拿IO去模拟1.1 什么场景下会缺串口STM32F407外设资源其实不算少USART1/2/3加UART4/5/6一共六个串口。但实际项目里这六个口经常不够用一个接调试打印一个接4G模块一个接蓝牙BLE一个接上位机再来个GPS或者温湿度传感器基本就见底了。更麻烦的是有些项目PCB已经定型引脚已经焊死飞线可以但要为了一个低速传感器重新改板周期成本都划不来。软件模拟串口在这种场景下就是最现实的方案占用两个普通IO加一个定时器不需要改硬件只要固件升级就能多出一路串口。对于一个9600波特率、一次传几十字节的传感器来说完全够用。这就是我最后决定走这条路的原因。当然如果你的需求是1Mbps以上的高速通信或者大数据量持续传输那就别折腾模拟串口了老老实实扩硬件串口或者换MCU。1.2 模拟串口的边界在哪里模拟串口本质上是把UART硬件移位寄存器做的事情用CPU和定时器逐个bit“挤”出来。这个方案有几个客观约束先讲清楚免得你抱着过高期望踩坑。波特率通常建议不超过384009600最稳。超过115200后定时器中断和IO翻转的开销占比太高主循环基本跑不动。发送和接收会占用CPU资源。每个字节9600波特率下大约1ms这1ms里定时器中断要触发10次每次处理几十条指令。一个字节就消耗几十微秒的CPU时间连续收发时MCU可以用“喘不过气”来形容。全双工实现复杂。发送用一套定时器节点、接收用另一套共用一个定时器时容易出现竞争半双工场景下更稳妥。搞清楚边界以后再决定要不要用这个方案。我的经验是调试口不够用了、低速外设透传、临时加功能这三个场景最适合软件模拟串口。定位准了方案就是好方案。2. 模拟串口原理拆解把UART时序掰开揉碎2.1 UART一帧数据的电平变化串口通信没有任何时钟线收发双方靠的是提前约定好的波特率。所谓波特率就是每秒传输多少个bit波特率9600意味着每个bit持续的时间大约是104.17us。一帧UART数据的电平变化长这样空闲时TX线保持高电平发送开始时先拉低一个bit时间这是起始位然后从低到高依次发送8个数据位LSB在前最后拉高一个bit时间这是停止位。接收端就是按照这个约定在电平静止的高电平上先捕捉到一个下降沿判断起始位到来然后掐着时间在后续每个bit的中间位置采样电平凑够8个bit拼成一个字节。通俗点说UART就是一个“按秒表对齐的电平序列”发送方掐点改变电平接收方掐点读电平。硬件串口内部有移位寄存器自动完成这件事软件模拟就是靠定时器中断来人工完成同样的流程。2.2 定时器节拍怎么选重载值怎么算模拟串口的核心是定时器。发送时定时器每次溢出中断就翻转一个bit的IO电平接收时定时器每次溢出中断就采样一次RX引脚电平。定时器的溢出周期必须严格等于一个bit的时间。STM32F407的定时器频率计算方式定时器实际计数频率 定时器时钟源 / (预分频PSC 1)溢出周期 (自动重载值ARR 1) / 计数频率。拿STM32F407VET6举例系统时钟168MHzAPB1分频4APB1上的定时器时钟是84MHz。如果我想让计数频率正好1MHz那么预分频设为84-1这样每个计数是1us。然后根据波特率计算重载值9600bps1bit 104.17usARR 10419200bps1bit 52.08usARR 5238400bps1bit 26.04usARR 26115200bps1bit 8.68usARR 8重载值直接取整就行误差在低速时完全可以接受。实测9600波特率下把一个字节8个bit全部发送完时序误差累积不到1%比起硬件串口完全可用。2.3 中断框架外部中断抓起始位定时器中断管采样接收比发送难在起始位检测。RX引脚空闲时是高电平当对端开始发数据时第一个动作是拉低一个bit时间也就是说RX线上会出现一个下降沿。这个下降沿就是软件串口识别起始位的依据。我用的方案是RX引脚配置成外部中断下降沿触发空闲时等待中断一旦捕捉到下降沿就在中断回调里启动定时器定时器开始按bit节拍工作。定时器第一次中断时刚好是起始位的中间位置在这里再确认一次电平是否为低如果确实低说明起始位有效继续按节拍采样8个数据位如果此时电平已经变高说明刚才的下降沿是干扰直接取消接收。这个过程中外部中断的优先级必须高于定时器中断。因为定时器中断随时在跑如果外部中断被阻塞起始位的下降沿就可能被错过整个接收状态机就废了。3. CubeMx一步步配置GPIO、TIM2、NVIC3.1 时钟与GPIO配置先用CubeMx新建工程芯片选STM32F407VET6具体型号按你的板子来。RCC里打开外部高速晶振HSE时钟树配置成168MHz主频。这里有个关键点APB1分频设置为4这样APB1外设时钟是42MHz但APB1上的定时器时钟自动是84MHz。如果你要在APB1上挂TIM2那么定时器计数频率就是84MHz后续计算PSC时按84MHz算。GPIO配置要看引脚规划。我手头板子上PB0和PB1是空闲的所以直接把PB0设置为推挽输出作为TX发送引脚输出速度选HighPB1设置为输入模式开启上拉作为RX接收引脚。同时要在GPIO配置界面里把PB1的外部中断使能下降沿触发。注意尽量避开PA13、PA14、PA15、PB3、PB4这几个默认的SWD调试引脚。如果非要用得先关闭对应调试功能否则程序烧录一次之后第二次可能连不上仿真器。别问我怎么知道的我是过来人。3.2 TIM2定时器配置与采样节拍计算定时器我选了TIM2因为它是通用定时器挂在APB1上CubeMx里配置起来最顺手。在Pinout视图里把TIM2的Internal Clock打开也就是勾选激活计数器。参数设置如下Prescaler预分频84-1也就是83计数频率 84MHz / 84 1MHzCounter ModeUp向上计数Counter Period自动重载值104对应9600波特率这就是一个bit的时间长度auto-reload preloadEnableNVIC设置里勾选TIM2 global interrupt生成代码后先不要急着改CubeMx生成的主循环里不会有任何动作真正的逻辑全在中断回调里。CubeMx会自动生成HAL_TIM_Base_Init、GPIO初始化、NVIC配置这些基础代码我们只需要在初始化末尾启动定时器中断以及编写回调函数。3.3 中断优先级与外设启动的先后顺序NVIC优先级分配是个容易忽略的细节。我的配置是EXTI1_IRQn抢占优先级设1TIM2_IRQn抢占优先级设2。这样外部中断可以打断定时器中断确保起始位下降沿被立即捕捉。启动顺序也很有讲究。系统上电后先正常初始化所有外设然后启动定时器整体中断。但接收定时器不要一上来就跑应该等外部中断触发后再启动否则定时器空转产生中断白白消耗CPU。实际代码里我是在主函数初始化末尾启动TIM2的定时器计时功能同时保持EXTI1中断开启让外部中断先“埋伏”待命。4. 核心代码发送、接收、不定长帧判断4.1 发送一个字节的完整实现发送逻辑相对简单把要发的字节拆成8个bit按照定时器节拍依序放到TX引脚上。先拉低一个bit作为起始位然后按LSB到MSB的顺序逐位输出数据最后拉高一个bit作为停止位。直接定义一个简单的发送状态结构体typedef struct { uint8_t data; uint8_t bitIndex; uint8_t sending; } SoftUartTx; SoftUartTx tx {0, 0, 0}; void soft_uart_send_byte(uint8_t byte) { while (tx.sending); // 等待上一字节发完 tx.data byte; tx.bitIndex 0; tx.sending 1; HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); // 起始位拉低 __HAL_TIM_SET_COUNTER(htim2, 0); HAL_TIM_Base_Start_IT(htim2); }定时器中断每次触发就发送一个bit。发送完8个数据位后发送停止位并关闭定时器void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM2) return; if (tx.sending) { if (tx.bitIndex 8) { uint8_t bit (tx.data tx.bitIndex) 0x01; HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, bit ? GPIO_PIN_SET : GPIO_PIN_RESET); tx.bitIndex; } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); // 停止位拉高 tx.sending 0; HAL_TIM_Base_Stop_IT(htim2); } } }发送函数里while (tx.sending)有个隐患如果主循环调用频率太高第二次调用会被阻塞到上一字节发完。模拟串口的发送速度本来就限制在9600每字节约1ms对绝大多数主循环逻辑来说这个等待可以接受。4.2 接收状态机与起始位检测接收是整个方案的核心难点。前面说过外部中断捕捉RX引脚的下降沿然后在定时器中断里采样。我在接收状态机上花了不少心思直接给出一版可用的结构体和中处理代码。typedef struct { uint8_t data; uint8_t sampleCnt; uint8_t receiving; volatile uint8_t rxFinished; volatile uint8_t rxByte; } SoftUartRx; SoftUartRx rx {0, 0, 0, 0, 0}; #define BIT_RELOAD 104 #define BIT_HALF_RELOAD (BIT_RELOAD / 2)外部中断回调里捕捉到下降沿后启动定时器并且关闭外部中断防止起始位期间的毛刺再次进入外部中断void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin ! GPIO_PIN_1) return; // 关闭EXTI避免接收过程中重复触发 HAL_NVIC_DisableIRQ(EXTI1_IRQn); rx.sampleCnt 0; rx.data 0; rx.receiving 1; __HAL_TIM_SET_AUTORELOAD(htim2, BIT_HALF_RELOAD); // 先按半bit计时 __HAL_TIM_SET_COUNTER(htim2, 0); HAL_TIM_Base_Start_IT(htim2); }定时器中断里做接收采样。第一次中断在起始位中间确认电平为低后把重载值从半bit切换为整bit后续每次中断都落在数据位的中间void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance ! TIM2) return; if (tx.sending) { // 发送逻辑见上一节 } if (rx.receiving) { if (rx.sampleCnt 0) { // 起始位采样点确认是低电平才继续 if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1) ! GPIO_PIN_RESET) { rx.receiving 0; HAL_TIM_Base_Stop_IT(htim2); __HAL_TIM_SET_AUTORELOAD(htim2, BIT_RELOAD); HAL_NVIC_EnableIRQ(EXTI1_IRQn); return; } __HAL_TIM_SET_AUTORELOAD(htim2, BIT_RELOAD); // 切换为整bit采样 } else if (rx.sampleCnt 8) { // 采样8个数据位 uint8_t bit HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_1); if (bit GPIO_PIN_SET) { rx.data | (1 (rx.sampleCnt - 1)); } else { rx.data ~(1 (rx.sampleCnt - 1)); } } else if (rx.sampleCnt 9) { // 停止位采样点正常应该为高 rx.receiving 0; HAL_TIM_Base_Stop_IT(htim2); __HAL_TIM_SET_AUTORELOAD(htim2, BIT_RELOAD); rx.rxByte rx.data; rx.rxFinished 1; HAL_NVIC_EnableIRQ(EXTI1_IRQn); // 重新使能起始位检测 return; } rx.sampleCnt; } }这里有一个细节值得说明第一次中断发生时计数器溢出归零并从0重新计数所以把ARR改成整bit值后下一个中断正好在半bit整bit的位置也就是第一个数据位的中心。整个采样的时间基准不会乱。4.3 不定长数据帧的结束判断与主循环处理模拟串口只负责把单个字节收进来不定长帧的组包逻辑要放到上层。不定长意味着每一帧数据长度不固定接收端要能判断“一帧结束了”。最实用的方法是空闲超时每收到一个字节就刷新时间戳如果超过若干个ms没有新字节到来就认为这一帧结束了。我在主循环里这样处理uint8_t rx_buf[256]; uint16_t rx_len 0; uint32_t last_rx_tick 0; while (1) { if (rx.rxFinished) { rx.rxFinished 0; if (rx_len sizeof(rx_buf)) { rx_buf[rx_len] rx.rxByte; last_rx_tick HAL_GetTick(); } } if (rx_len 0 (HAL_GetTick() - last_rx_tick 10)) { process_frame(rx_buf, rx_len); // 组装成一帧交给业务处理 rx_len 0; } }超时阈值10ms是根据9600波特率算的一个字节约1.04ms正常情况下相邻两个字节的间隔不会超过2ms10ms的阈值足够宽容又不会让两帧数据粘连。如果你的波特率更高可以适当缩小阈值。5. 实测踩坑记录时序、干扰、粘包那些事5.1 发送字节偶尔错位——重载值误差与引脚速度第一批代码写完后我用逻辑分析仪抓发送波形发现数据位电平在末尾偶尔会出现“拖尾”。排查下来有两个原因一是定时器重载值取整带来的时间误差9600波特率一个bit时间是104.17usARR设为104每个bit少了0.17us累积到第9个bit误差大约1.5us虽然不影响9600的接收容错但在高速波特率下就会出问题二是PB0引脚输出速度在CubeMx里默认是Low电平翻转时的上升沿和下降沿太缓影响了接收端的采样窗口。解决办法引脚速度统一选High如果要跑19200以上波特率重载值计算精度就得用浮点估算后取最近整数或者直接把定时器计数频率从1MHz提高到2MHz甚至4MHz减小每个bit的量化误差。5.2 接收频繁误触发——悬空引脚与干扰有一段时间板子放在满是电机驱动的台架上RX引脚疯狂触发外部中断收到的全是乱码。查了一圈发现RX引脚在杜邦线连接的情况下对端设备没上电引脚悬空导致电平漂移干扰信号轻松触发下降沿中断。这个问题要从硬件和软件两头堵。硬件上RX引脚外部加一个10k上拉电阻到3.3V必要时并联一个104电容滤波软件上起始位检测不能只信第一个下降沿我的代码里在0.5bit处回读电平如果此时读到的不是低电平直接判定为干扰丢弃。实测下来误触发率大幅下降。5.3 不定长帧粘连——超时阈值怎么调另一个高频问题出现在组帧逻辑两台主机轮流发数据时两个独立帧被并成一个长帧处理。原因是超时阈值设得太大对方发完一帧后主循环还没来得及组帧下一帧的起始字节就来了。把超时阈值从50ms改到10ms之后帧边界明显清晰了。阈值设置有个权衡太小会把正常的长字节间隔误判为帧结束太大容易粘帧。我的经验是以波特率字节时间的3到5倍作为初始值然后实测微调。9600波特率下用10ms19200用5ms基本覆盖绝大多数低速外设。5.4 最终测试结果和适用边界整套方案调通以后我做了连续48小时的压力测试两个开发板对发每100ms发一帧30字节随机数据错误率低于万分之一。CPU开销在9600波特率下大约增加10%左右主循环还能正常跑其他业务逻辑。最后说一个很实在的体会软件模拟串口不是用来替代硬件串口的它是在你无路可走时的一条应急通道。用它的前提是波特率别贪高、数据量别贪大、CPU别跑满。如果哪天你碰到改板来不及、串口不够用的情况不妨回来照着这篇配一遍40分钟能跑通比等板厂加急划算多了。本文还有配套的精品资源点击获取
返回列表