STM32 GPIO模拟串口:超简单可变波特率实现方案 1. 项目概述与核心价值在嵌入式开发中串口通信UART几乎是每个项目都绕不开的基础功能。无论是调试信息打印、模块间数据交换还是与上位机通信串口都扮演着至关重要的角色。STM32这类主流MCU通常都内置了多个硬件UART外设用起来非常方便直接调用HAL库或者标准库的API就能轻松实现收发。但不知道你有没有遇到过这样的窘境项目进行到一半突然发现硬件串口资源不够用了。可能是一个简单的设备需要连接多个串口传感器也可能是为了成本考虑选用了串口数量较少的入门型号MCU。这时候要么换芯片要么增加外部扩展芯片无论哪种方案都意味着额外的成本和开发周期。“用GPIO模拟一个串口”这个想法很多工程师在资源紧张时都曾考虑过但往往又被其复杂性劝退。一想到要精确地控制时序、处理起始位、数据位、停止位还要应对不同波特率就觉得头大担心模拟出来的串口不稳定、误码率高。这个项目要分享的正是一个超简单的GPIO模拟可变波特率异步串口的实现方案。它的核心价值在于用最直观、最易于理解的代码逻辑在任意一个通用GPIO引脚上实现一个功能完整、波特率可灵活配置的“软串口”。你不需要深入理解复杂的定时器PWM模式也不用被DMA传输搞得晕头转向只需要一个基本定时器甚至可以用系统滴答定时器SysTick和两个GPIO一发一收就能解燃眉之急。这个方案特别适合那些对实时性要求不是极端苛刻但需要快速增加一个通信通道的场景。比如你的主控串口用于烧录和调试另一个硬件串口连接了核心的通信模块如4G、LoRa此时突然需要接一个GPS模块来获取定位信息而这个GPS模块只支持串口输出。这时候用两个GPIO脚模拟出一个串口来读取GPS数据就是一个非常经济且快速的解决方案。我将从设计思路、关键参数计算、代码实现到避坑技巧完整地拆解这个过程让你看完就能在自己的STM32项目里用起来。2. 整体设计思路与方案选型用GPIO模拟串口本质上就是在软件层面严格按照串口通信的时序协议去控制GPIO引脚的电平变化。异步串口的协议很简单空闲时为高电平起始位是一个比特时间的低电平接着是5-9位数据位通常为8位低位在前最后是1或2个比特时间的高电平作为停止位。2.1 为什么说“超简单”传统的GPIO模拟串口实现为了确保时序精确往往会采用以下两种复杂方案定时器中断法在起始位下降沿触发一个定时器将定时器中断周期设置为一个比特时间1/波特率。在中断服务函数里用一个状态机依次发送或采样每一位数据。这种方法中断频繁对CPU占用率高且中断响应延迟可能引入误差。定时器PWMDMA法用定时器产生特定占空比的PWM波来模拟波形结合DMA自动搬运数据到GPIO的寄存器。这种方法效率高但配置复杂且通常只能用于发送接收实现困难。而我们这个“超简单”方案的核心思路是用单个定时器在“一个比特时间”的中点进行采样或设置电平从而规避对绝对时序的严苛要求。对于发送我们计算好每个比特电平需要保持的时间然后利用阻塞延时或更优雅的定时器标志位等待来实现。对于接收我们在检测到起始沿后延时“1.5个比特时间”到达第一个数据位的中心点进行采样之后每隔一个比特时间采样一次。这个“1.5倍”延时是关键它确保了采样点位于每个数据位的中间位置这里是电平最稳定的区域抗干扰能力最强对延时误差的容忍度也最高。2.2 方案选型与核心考量基于上述思路我们需要做出几个关键选择定时器资源选择系统滴答定时器 (SysTick)这是最简单的选择无需额外初始化。但SysTick通常被操作系统如FreeRTOS或HAL库的延时函数占用直接操作可能产生冲突。如果项目简单没有使用操作系统可以谨慎使用。基本定时器 (TIM6/TIM7)这是最推荐的选择。基本定时器功能单一就是纯粹的定时计数非常适合用来做精准延时。它不涉及复杂的输出比较、输入捕获功能配置简单中断优先级也好管理。通用定时器当然也可以使用但有点“杀鸡用牛刀”的感觉配置项更多。如果你的项目里通用定时器有富余也可以使用。本项目以基本定时器TIM6为例进行讲解因为它独立、简单不影响其他外设功能。GPIO工作模式选择发送引脚 (TX)配置为推挽输出 (GPIO_MODE_OUTPUT_PP)。推挽输出可以提供明确的强高电平和强低电平驱动能力和波形边沿质量都好于开漏输出。接收引脚 (RX)配置为浮空输入或上拉输入 (GPIO_MODE_INPUT_FLOATING / GPIO_MODE_INPUT_PULLUP)。通常建议使用上拉输入让空闲状态保持确定的高电平增强抗干扰能力。绝对不要配置为输出模式。波特率精度与误差处理 模拟串口的波特率精度完全依赖于定时器的计时精度。STM32的定时器时钟源通常来自系统时钟精度很高。误差主要来源于两方面一是中断响应延迟二是软件延时函数本身的误差。我们的“中心采样法”已经为这些误差留出了容错空间每个比特时间的中间区域。只要误差累积不超过半个比特时间通信就是可靠的。因此对于常见的9600、115200等波特率在72MHz系统时钟下这个方案是足够稳定的。3. 核心原理与关键参数计算在动手写代码之前必须搞清楚几个核心的时间参数是怎么算出来的。这是整个项目的基石理解了它们代码就只是逻辑的表达了。3.1 比特时间 (Bit Time) 的计算比特时间就是传输一个数据位所需要的时间它是波特率的倒数。T_bit 1 / BaudRate例如对于波特率 115200T_bit 1 / 115200 ≈ 8.68 微秒 (µs)对于波特率 9600T_bit 1 / 9600 ≈ 104.17 微秒 (µs)我们的定时器就需要产生这个时间间隔的延时。3.2 定时器计数值的计算STM32的定时器通过预分频器 (PSC) 和自动重载寄存器 (ARR) 来控制计时周期。定时器时钟频率 (TIM_CLK) 比如如果APB1总线时钟为36MHzTIM6挂在APB1上且时钟未倍频则TIM_CLK 36MHz。定时器计数频率 (CNT_FREQ) TIM_CLK / (PSC 1)定时器计数周期 (T_cnt) 1 / CNT_FREQ我们需要定时器产生的时间T_delay T_bit对于发送或T_bit的倍数对于接收的1.5倍延时。那么需要的计数值N为N T_delay / T_cnt T_delay * CNT_FREQ T_delay * (TIM_CLK / (PSC 1))通常我们会先设定一个合适的预分频器PSC让计数频率CNT_FREQ在一个方便的数值比如1MHz即每微秒计数一次这样计算起来非常直观。 若TIM_CLK 36MHz 设置PSC 35 则CNT_FREQ 36MHz / (351) 1MHz。 此时T_cnt 1µs。 要产生T_bit 8.68µs的延时计数值N 8.68。定时器只能处理整数计数所以我们需要取整即ARR 8因为从0开始计数到8共计9个时钟周期这里需要仔细理解。这里是一个关键点定时器通常采用“向上计数”模式从0计数到ARR值然后产生更新事件。所以实际的延时时间是(ARR 1) * T_cnt。因此为了得到N 8.68的延时我们应设置ARR 8因为(81)*1µs 9µs。这引入了误差。误差率为(9 - 8.68) / 8.68 ≈ 3.7%。对于串口通信通常要求误差小于3%标准或更严格。115200波特率下这个误差在边界了。优化计算我们可以利用高精度的时钟和更大的ARR值来减小误差。例如不将CNT_FREQ设为1MHz而是直接用36MHz计算。N T_delay * TIM_CLK (1/115200) * 36,000,000 ≈ 312.5取整后ARR 312。 实际延时T_actual (3121) / 36,000,000 ≈ 8.694µs误差率(8.694-8.68)/8.68 ≈ 0.16% 这个精度就非常高了。注意在代码中波特率转换计数值的计算最好写成宏或函数根据系统时钟和预设的PSC值动态计算ARR这样波特率就可以作为参数灵活传入。3.3 “1.5个比特时间”延时的意义这是接收逻辑的灵魂。当我们检测到RX引脚出现下降沿起始位时线上的电平刚刚进入起始位。如果我们立即开始采样会采到起始位的不稳定边缘。如果我们延时1个比特时间会到达第一个数据位的开始边缘同样不稳定。延时1.5个比特时间可以让我们精准地落在第一个数据位的正中心。在此之后每隔一个完整的比特时间采样一次就能依次落在后续数据位和停止位的中心。中心区域是电平保持最稳定的阶段即使我们的定时有些许提前或滞后比如中断响应慢了1-2个微秒只要不超过半个比特时间采样的就依然是正确的电平。这大大降低了软件时序对精度的要求。4. 代码实现与实操步骤接下来我们以STM32F103C8T6系统时钟72MHzAPB1定时器时钟72MHz为例使用TIM6实现一个波特率可配的软串口。我们将分模块讲解。4.1 硬件与工程初始化首先在CubeMX或手动初始化中完成以下配置使能TIM6作为基本定时器使用。配置两个GPIOGPIOA_Pin_9作为软串口发送 TX 配置为推挽输出 (Output Push-Pull) 初始电平设为高代表空闲状态。GPIOA_Pin_10作为软串口接收 RX 配置为上拉输入 (Input Pull-up)。配置NVIC嵌套向量中断控制器 使能TIM6的中断并设置一个合适的优先级比如高于SysTick低于硬件串口。4.2 定时器驱动模块我们创建一个softuart_timer.c/.h文件来封装定时器功能。// softuart_timer.h #ifndef __SOFTUART_TIMER_H #define __SOFTUART_TIMER_H #include stm32f1xx_hal.h void SOFTUART_TIM_Init(TIM_HandleTypeDef* htim); void SOFTUART_TIM_Delay_us(TIM_HandleTypeDef* htim, uint16_t us); uint32_t SOFTUART_TIM_CalcArrForBaudrate(uint32_t tim_clk_mhz, uint32_t baudrate); #endif// softuart_timer.c #include softuart_timer.h // 定时器初始化配置为基本的向上计数模式 // 预分频器PSC根据定时器时钟频率和目标计数频率来设定。 // 假设我们希望1us计数一次对于72MHz时钟PSC 71 (72MHz/(711)1MHz) #define TIM_PRESCALER 71 void SOFTUART_TIM_Init(TIM_HandleTypeDef* htim) { htim-Instance TIM6; htim-Init.Prescaler TIM_PRESCALER; // 预分频使计数器时钟为1MHz htim-Init.CounterMode TIM_COUNTERMODE_UP; htim-Init.Period 65535; // 先设一个最大值具体延时由ARR控制 htim-Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_Base_Init(htim) ! HAL_OK) { Error_Handler(); } // 使能定时器更新中断 __HAL_TIM_ENABLE_IT(htim, TIM_IT_UPDATE); } // 微秒级阻塞延时函数 void SOFTUART_TIM_Delay_us(TIM_HandleTypeDef* htim, uint16_t us) { __HAL_TIM_SET_COUNTER(htim, 0); // 计数器清零 __HAL_TIM_SET_AUTORELOAD(htim, us - 1); // 设置重载值计数us次 __HAL_TIM_ENABLE(htim); // 启动定时器 while (__HAL_TIM_GET_FLAG(htim, TIM_FLAG_UPDATE) RESET) { // 等待更新标志位即等待计数完成 // 注意在中断服务程序频繁的系统里用标志位等待比用HAL_Delay更可靠 } __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_UPDATE); // 清除标志位 __HAL_TIM_DISABLE(htim); // 关闭定时器 } // 计算给定波特率下对应1个比特时间所需的定时器计数值ARR // 输入定时器时钟频率单位MHz 目标波特率 // 输出ARR值 // 公式ARR (TIM_CLK / (PSC1)) / BaudRate - 1 // 因为我们的SOFTUART_TIM_Delay_us函数假设了CNT_FREQ1MHz所以这里计算的是微秒数。 // 更通用的计算ARR (TIM_CLK / BaudRate) / (PSC1) - 1 uint32_t SOFTUART_TIM_CalcArrForBaudrate(uint32_t tim_clk_mhz, uint32_t baudrate) { // 计算1个比特时间对应的微秒数 float bit_time_us 1000000.0f / (float)baudrate; // 1秒10^6微秒 // 在我们的配置下1个计数值1微秒所以直接返回微秒数对应的整数计数值 // 为了精度先计算浮点数再四舍五入 uint32_t arr_val (uint32_t)(bit_time_us 0.5f); // 确保至少为1 return (arr_val 0) ? (arr_val - 1) : 0; // 因为我们的Delay函数设置ARR为 us-1 }4.3 软串口发送模块发送是相对简单的因为我们是主动控制方。核心就是按照时序依次拉低起始位、按位设置数据电平、拉高停止位每步之间等待一个比特时间。// softuart_tx.c #include softuart_tx.h #include softuart_timer.h // 假设发送引脚为 GPIOA, Pin 9 #define SOFTUART_TX_PORT GPIOA #define SOFTUART_TX_PIN GPIO_PIN_9 #define SOFTUART_TX_HIGH() HAL_GPIO_WritePin(SOFTUART_TX_PORT, SOFTUART_TX_PIN, GPIO_PIN_SET) #define SOFTUART_TX_LOW() HAL_GPIO_WritePin(SOFTUART_TX_PORT, SOFTUART_TX_PIN, GPIO_PIN_RESET) // 外部定义的定时器句柄和比特时间计数值 extern TIM_HandleTypeDef htim6; extern uint32_t bit_time_arr; // 这个值通过SOFTUART_TIM_CalcArrForBaudrate计算得到 void SOFTUART_TX_Init(void) { // GPIO已在主函数初始化这里确保初始状态为高空闲 SOFTUART_TX_HIGH(); } void SOFTUART_TX_SendByte(uint8_t data) { // 1. 发送起始位 (低电平) SOFTUART_TX_LOW(); SOFTUART_TIM_Delay_us(htim6, bit_time_arr 1); // 1 因为ARR us-1 // 2. 发送8位数据位 (LSB first) for (uint8_t i 0; i 8; i) { if (data 0x01) { SOFTUART_TX_HIGH(); } else { SOFTUART_TX_LOW(); } SOFTUART_TIM_Delay_us(htim6, bit_time_arr 1); data 1; // 准备下一位 } // 3. 发送停止位 (高电平至少1位时间) SOFTUART_TX_HIGH(); SOFTUART_TIM_Delay_us(htim6, bit_time_arr 1); // 可选再增加一个比特时间的空闲时间确保停止位完整 // SOFTUART_TIM_Delay_us(htim6, bit_time_arr 1); }4.4 软串口接收模块接收是难点因为我们需要检测起始位并在精确的时间点采样。这里采用“外部中断定时器”的方式。// softuart_rx.c #include softuart_rx.h #include softuart_timer.h // 假设接收引脚为 GPIOA, Pin 10 #define SOFTUART_RX_PORT GPIOA #define SOFTUART_RX_PIN GPIO_PIN_10 #define SOFTUART_RX_READ() HAL_GPIO_ReadPin(SOFTUART_RX_PORT, SOFTUART_RX_PIN) // 接收状态机和缓冲区 typedef enum { RX_STATE_IDLE, // 空闲等待起始位 RX_STATE_START_BIT, // 已检测到起始位等待1.5个比特时间后采样第一位 RX_STATE_DATA_BITS, // 正在采样数据位 RX_STATE_STOP_BIT // 等待停止位 } SoftUART_RxState_t; volatile SoftUART_RxState_t rx_state RX_STATE_IDLE; volatile uint8_t rx_data 0; volatile uint8_t rx_bit_count 0; volatile uint32_t rx_bit_delay 0; // 存储一个比特时间的计数值 // 外部定义的定时器句柄 extern TIM_HandleTypeDef htim6; void SOFTUART_RX_Init(uint32_t baudrate) { // 计算一个比特时间和1.5个比特时间对应的计数值微秒数 // bit_time_us 1000000 / baudrate // 这里我们直接使用之前计算好的 bit_time_arr它对应 (一个比特时间 - 1) 个计数周期 // 1.5个比特时间对应的ARR (1.5 * bit_time) - 1 rx_bit_delay bit_time_arr 1; // 这就是一个比特时间对应的微秒数 // 初始化状态 rx_state RX_STATE_IDLE; rx_data 0; rx_bit_count 0; // 配置RX引脚的外部中断下降沿触发检测起始位 // 注意这部分代码依赖于具体的HAL库或标准库中断配置此处给出逻辑 // 通常在GPIO初始化后单独配置中断 // HAL_NVIC_SetPriority(EXTI15_10_IRQn, 0, 0); // HAL_NVIC_EnableIRQ(EXTI15_10_IRQn); } // RX引脚外部中断服务函数 // 当检测到下降沿起始位时进入 void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin SOFTUART_RX_PIN) { if (rx_state RX_STATE_IDLE) { // 确认是低电平防止干扰 if (SOFTUART_RX_READ() GPIO_PIN_RESET) { // 进入起始位处理状态 rx_state RX_STATE_START_BIT; rx_data 0; rx_bit_count 0; // 禁用外部中断防止在接收过程中被新的起始沿打断 // __HAL_GPIO_EXTI_DISABLE_IT(SOFTUART_RX_PIN); // 启动定时器延时1.5个比特时间后采样第一位数据 // 注意我们的SOFTUART_TIM_Delay_us是阻塞的不能在中断里用 // 我们需要改用定时器中断来非阻塞地实现这个延时。 // 这里展示思路设置定时器ARR为1.5倍比特时间并开启定时器中断。 uint32_t delay_1_5_bit (rx_bit_delay * 3) / 2; // 计算1.5倍时间 __HAL_TIM_SET_AUTORELOAD(htim6, delay_1_5_bit - 1); __HAL_TIM_SET_COUNTER(htim6, 0); __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); __HAL_TIM_ENABLE_IT(htim6, TIM_IT_UPDATE); __HAL_TIM_ENABLE(htim6); } } } } // TIM6更新中断服务函数 (在stm32f1xx_it.c中) void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim6, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); switch (rx_state) { case RX_STATE_START_BIT: // 1.5个比特时间已到现在位于第一个数据位的中心 // 采样第一位数据 rx_data 1; // 先右移为最低位腾出空间 if (SOFTUART_RX_READ() GPIO_PIN_SET) { rx_data | 0x80; // 如果为高则最高位置1因为后面会右移8次 } rx_bit_count 1; // 已采样1位 rx_state RX_STATE_DATA_BITS; // 重新配置定时器为后续每个比特时间的采样点定时 __HAL_TIM_SET_AUTORELOAD(htim6, rx_bit_delay - 1); // 一个比特时间 __HAL_TIM_SET_COUNTER(htim6, 0); break; case RX_STATE_DATA_BITS: // 采样后续数据位 rx_data 1; if (SOFTUART_RX_READ() GPIO_PIN_SET) { rx_data | 0x80; } rx_bit_count; if (rx_bit_count 8) { // 8位数据已采样完毕进入停止位等待状态 rx_state RX_STATE_STOP_BIT; // 停止位时间可以采样验证也可以不采样直接等待 // 这里我们等待一个比特时间并采样验证是否为高电平 } break; case RX_STATE_STOP_BIT: // 停止位时间到采样停止位应为高电平 if (SOFTUART_RX_READ() GPIO_PIN_SET) { // 停止位正确一帧数据接收完成 // 可以将rx_data存入缓冲区供主程序读取 // 例如uart_rx_buffer[write_idx] rx_data; } else { // 帧错误停止位不是高电平 // 可以设置一个错误标志 } // 接收完成恢复空闲状态重新使能RX引脚的外部中断 rx_state RX_STATE_IDLE; __HAL_TIM_DISABLE(htim6); // __HAL_GPIO_EXTI_ENABLE_IT(SOFTUART_RX_PIN); break; default: break; } } }4.5 主函数整合与测试// main.c #include stm32f1xx_hal.h #include softuart_timer.h #include softuart_tx.h #include softuart_rx.h TIM_HandleTypeDef htim6; uint32_t bit_time_arr; // 存储一个比特时间对应的ARR值 int main(void) { HAL_Init(); SystemClock_Config(); // 初始化GPIO // ... (GPIOA Pin9, Pin10的初始化代码使用CubeMX生成或手动编写) // 初始化定时器并计算波特率对应参数 SOFTUART_TIM_Init(htim6); uint32_t target_baudrate 115200; // 注意CalcArrForBaudrate 返回的是 (微秒数 - 1) 因为我们的Delay函数设定如此。 bit_time_arr SOFTUART_TIM_CalcArrForBaudrate(72, target_baudrate); // 72MHz时钟 // 初始化软串口发送和接收 SOFTUART_TX_Init(); SOFTUART_RX_Init(target_baudrate); // 使能中断 HAL_NVIC_SetPriority(TIM6_IRQn, 1, 0); HAL_NVIC_EnableIRQ(TIM6_IRQn); HAL_NVIC_SetPriority(EXTI15_10_IRQn, 0, 0); // RX中断优先级更高 HAL_NVIC_EnableIRQ(EXTI15_10_IRQn); while (1) { // 示例回环测试如果收到数据就原样发送回去 // 假设有一个函数 SOFTUART_RX_GetByte 从缓冲区读取数据 // uint8_t received_byte; // if (SOFTUART_RX_GetByte(received_byte)) { // SOFTUART_TX_SendByte(received_byte); // } HAL_Delay(1000); // 每隔1秒发送一个测试数据 SOFTUART_TX_SendByte(0x55); // 发送 0x55 (二进制 01010101)方便用示波器观察波形 } }5. 常见问题、调试技巧与避坑指南即使代码逻辑正确在实际调试中也可能遇到各种问题。下面是我在多次实现中总结出来的“避坑秘籍”。5.1 通信不稳定误码率高可能原因1定时器时钟或延时计算错误。排查首先用示波器测量发送引脚TX的波形。发送一个固定的字节如0x55其波形是规整的方波。测量一个比特时间是否准确等于1/波特率。例如115200波特率一个比特时间约8.68µs。解决仔细检查SystemClock_Config函数确认系统时钟SYSCLK、APB1总线时钟PCLK1是否与代码中的计算假设一致。核对SOFTUART_TIM_CalcArrForBaudrate函数中的计算公式。建议将计算出的ARR值通过串口打印出来与理论值对比。可能原因2中断冲突或优先级问题。排查如果系统中有其他高优先级中断如硬件串口中断、USB中断长时间执行可能会打断软串口的定时器中断或外部中断导致采样点偏移。解决合理配置中断优先级NVIC。将软串口接收使用的外部中断EXTI设置为最高优先级Preemption priority数值最小确保起始沿能被及时捕获。将定时器中断TIM6的优先级设置为稍低于EXTI但高于其他可能长时间阻塞的中断。避免在中断服务函数中进行复杂运算或调用HAL_Delay。可能原因3GPIO引脚配置或外部电路问题。排查检查RX引脚是否配置为输入模式而不是输出。检查电路板上是否有上拉电阻如果没有务必在代码中启用内部上拉GPIO_PULLUP。用示波器观察RX引脚在空闲时的电平是否稳定在高电平约3.3V起始位下降沿是否干净。解决确保RX引脚配置正确。对于长导线连接考虑在RX引脚对地加一个几十皮法的小电容滤除高频毛刺。5.2 只能发送不能接收或接收不到数据可能原因1外部中断未正确触发。排查在HAL_GPIO_EXTI_Callback函数入口处设置一个断点或者翻转一个测试用的LED灯。发送数据给软串口RX看中断回调函数是否被执行。解决检查GPIO和EXTI的初始化代码确认引脚、触发边沿下降沿配置正确。确认NVIC中对应的EXTI中断线已使能。可能原因2接收状态机被意外重置。排查在接收状态机的每个状态切换处添加调试输出或LED指示。解决确保rx_state、rx_data、rx_bit_count等变量声明为volatile防止编译器优化。确保在接收一帧数据的过程中从起始位到停止位不会在其他地方如主循环意外修改这些变量或重新初始化软串口。可能原因3“1.5个比特时间”计算或定时器重载有误。排查这是最难调的部分。可以尝试在发送端连续发送 0x00全部为低电平或 0xFF全部为高电平然后用示波器同时观察TX和RX引脚。在RX中断触发时在另一个GPIO上产生一个短脉冲作为标记看看这个标记是否落在TX波形的稳定区域。解决仔细计算delay_1_5_bit。使用浮点数计算时注意精度。(rx_bit_delay * 3) / 2这种写法在整数运算时可能丢失精度可以先乘以3再除以2。一个技巧将比特时间对应的计数值设置为2的倍数这样乘以1.5后还是整数。例如对于9600波特率1比特时间≈104.17µs取整为104µs误差很小那么1.5倍就是156µs是整数。5.3 性能与资源占用考量CPU占用率发送函数SOFTUART_TX_SendByte是阻塞式的发送一个字节期间对于8N1格式是10个比特时间CPU会一直卡在延时循环里。在115200波特率下发送一个字节约需87µs期间无法处理其他任务。因此这个方案不适合在高波特率下连续大量发送数据也不适合用在有实时性要求的主循环中。接收端使用中断占用率较低。改进方向如果需要更高性能或非阻塞操作可以将发送也改为基于状态机和定时器中断的方式。开辟一个发送缓冲区主程序只需将数据放入缓冲区由定时器中断服务程序在后台依次发送。这样主程序就不会被阻塞。但这会显著增加代码复杂度。定时器资源本方案独占了一个基本定时器TIM6。如果项目中有其他功能也需要定时器需要考虑资源冲突。5.4 波特率自适应与误差容限我们这个固定波特率的方案要求通信双方严格同步。如果想实现“自适应波特率”思路是在接收起始位时通过测量两个下降沿或上升沿之间的时间间隔来反推对方的波特率。这需要更高精度的定时器输入捕获模式和更复杂的算法超出了“超简单”的范畴。但对于固定波特率的应用只要误差在允许范围内通常3%低速时可放宽中心采样法提供了足够的容错空间。最后分享一个调试“终极武器”逻辑分析仪。一个几十块钱的简易逻辑分析仪配合上位机软件如PulseView就能同时抓取TX、RX以及你设置的调试标记引脚的电平变化直观地展示出起始位、数据位、停止位的时序关系以及你的采样点是否准确落在数据位的中心。这比用示波器一点点测量要高效得多。当你调不通的时候逻辑分析仪的波形图往往能一眼看出问题所在。