简介:面向STM32嵌入式开发者的IO模拟串口可运行工程,适合硬件串口已被占用、外设引脚不足或希望降低成本的场景。工程通过普通GPIO精确控制电平时序,完整实现UART协议中的起始位、数据位与停止位收发流程,涵盖发送延时的两种实现方式(delay_us精确延时与定时器中断)以及接收端外部中断下降沿检测、定时器采样窗口等关键技术,对深入理解串口时序与底层驱动开发极有帮助。压缩包共10个文件,大小约15KB,主要包含C源码(主程序与串口模拟模块)、头文件、Makefile构建脚本、链接脚本与启动文件,目录结构紧凑,可直接编译下载到STM32F103系列验证。目前已有110人浏览学习,工程附带了引脚初始化、外部中断开启、定时器配置、单字符发送及接收中断函数等完整示例,读者可快速跑通基础收发逻辑,并以此为模板扩展双工通信、校验位或自定义波特率等软串口功能。
1. IO模拟串口:硬件串口不够用时的后悔药
STM32 IO模拟串口,简单说就是不用片上USART外设,用两个普通GPIO加一个定时器,把UART的收发时序在软件里造出来。做嵌入式的人迟早会遇到这种局面:板子已经画完,发现还差一路串口接传感器;或者引脚全被业务逻辑占用,只剩两个IO空着;又或者硬件串口数量不够用,改版时间又不允许。这种情况下,IO模拟串口就是最后一手后悔药,至少能先把功能跑起来再谈优化。
它的本质是和时间较劲:发送就是按波特率精确翻转TX引脚电平,接收就是在每个数据位的中心点去采样RX引脚。难点全在接收上——你不知道对方什么时候发数据,也不能靠轮询死等,得靠外部中断抓起始位,再用定时器卡准采样点。这篇实战笔记把这套方案从头拆到尾,给出可直接运行的源码思路:哪些引脚能选、定时器怎么配、收发状态机怎么写、又有哪些坑会让你翻车。适合想在不改板、不换MCU的前提下临时扩展串口的开发者,也适合调试日志输出这类对稳定性要求不算极端的场景。
2. 原理与边界:为什么接收比发送难,以及什么场景不该用
2.1 UART帧结构与时序:起始位、数据位、停止位怎么落到普通IO上
UART一帧的时序是固定的:空闲时总线保持高电平;要发数据时先拉低一个位时间,这是起始位;接着依次送出8个数据位,低位在前(LSB first);最后拉高一个位时间,这是停止位。对IO模拟来说,“一个位时间”就是 1 / 波特率 秒。9600波特率下是约104微秒,115200下约8.68微秒。GPIO要做的事就是在这几个微秒到一百多微秒的窗口里,精确地翻电平。
接收侧正好反过来:RX引脚检测到高电平变低电平的跳变,就认为对方开始发起始位了;之后每个数据位的中点采样一次电平,采8次得到一个字节;最后一个停止位必须采到高电平,否则这一帧就是错的。理解这个时序就能明白,发送是“我知道什么时候该动”,接收是“我不知道对方什么时候动”。这也是整个IO模拟串口里接收代码比发送代码复杂得多、也更容易出问题的根本原因。
工程上IO模拟串口一般固定用8N1格式,也就是8个数据位、无校验、1个停止位。不是不能支持别的格式,而是状态机越简单,采样点越不容易漂移。实际调项目时,我基本不会让模拟串口去跑带校验位的格式,收益低且容易翻车。一帧10个位时间,发送和接收的状态机都按这10个节拍来推进。
2.2 两种主流实现:阻塞延时法与定时器采样法的取舍
网上搜IO模拟串口,最常见的是阻塞延时写法:拉低引脚、延时一个位时间、逐位拉高拉低、延时、停止位拉高。这种代码看起来很短,但不实用。发送期间CPU完全被占死,接收也只是一个循环里反复读引脚,高波特率下根本忙不过来。数据量一多,整个系统就卡住了。
我一般建议用定时器驱动的方式:一个定时器溢出频率配成波特率,发送和接收状态机都挂在定时器中断里推进。发送时每次溢出中断改一次TX引脚电平;接收时先用外部中断的下降沿抓起始位,再启动定时器,在每个位的中点采样RX引脚。整段时间里CPU的主循环该干什么干什么,只是每次中断进来做一次位操作就退出,占用时间极短。
对比阻塞延时法,定时器法有两个关键优势:第一,收发可以共用一个时基,采样时刻可预期;第二,接收可以配合FIFO缓存,主程序晚点处理数据也不会丢字节。代价是代码多几十行,还要处理中断优先级和共享变量的volatile声明。如果是教训,先把阻塞版跑通理解时序,再改造成定时器版,但不要拿阻塞版直接上项目。
2.3 什么场景不该用IO模拟串口:边界与预期管理
IO模拟串口有自己的能力边界,硬扛只会浪费时间。第一,波特率不要超过115200,再往上每一位时间不到8.7微秒,中断响应稍微抖动一点就采错位;第二,不适合大批量连续传输,比如固件升级、文件传输一次几百KB,定时器中断长时间高频触发,系统其他功能基本没法用;第三,如果主控用内部RC时钟且没有校准,长时间连续收发累计误差会越来越大,偶发乱码是必然的。
还有一个容易忽略的问题:在RTOS环境下,如果某个高优先级任务长时间关中断,定时器中断会被推迟,采样点跟着漂移。IO模拟串口适合的典型场景是短报文交互:传感器数据读取、配置指令下发、调试日志输出。这个定位决定了它在项目里是“补位角色”,不可能替代硬件USART。做方案评估时先想清楚数据量有多大、能容忍多少误码率,再决定要不要走这条路。
3. 发送端实现:定时器时基 + GPIO翻转,先跑通 TX
3.1 工程搭建与GPIO初始化:哪些引脚适合做模拟串口
选引脚有几个硬性要求:TX/RX不能和现有外设复用冲突;引脚最好支持推挽输出和上拉输入;避免选默认被调试接口占用的引脚。以常见的STM32F1为例,PA13、PA14、PA15、PB3、PB4默认是SWD和JTAG功能,直接当普通IO用会不听话,要先关闭JTAG复用,这本身就是一个坑。我的习惯是优先选没有默认复用功能的通用IO,比如PB6、PB7、PB10这类,避开一上来就要写重映射代码的引脚。
GPIO初始化的关键点有三个:TX用推挽输出、速度等级选高;RX用下降沿触发的外部中断模式、配上拉;中断优先级后面和定时器配合时要单独考虑。下面的代码以72MHz主频、HAL库为例:
#define SOFT_TX_PORT GPIOB #define SOFT_TX_PIN GPIO_PIN_6 #define SOFT_RX_PORT GPIOB #define SOFT_RX_PIN GPIO_PIN_7 void SoftUART_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // 启用GPIOB时钟 // TX引脚:推挽输出,边沿要陡,所以速度等级选HIGH GPIO_InitStruct.Pin = SOFT_TX_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(SOFT_TX_PORT, &GPIO_InitStruct); // RX引脚:下降沿触发中断,上拉保证空闲时为高电平 GPIO_InitStruct.Pin = SOFT_RX_PIN; GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(SOFT_RX_PORT, &GPIO_InitStruct); // 使能RX引脚的EXTI中断,优先级先给2,后面会再调 HAL_NVIC_SetPriority(EXTI9_5_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn); }TX用推挽输出,因为模拟串口需要主动驱动电平,不开漏;速度等级HIGH是为了让翻转沿更陡,否则波特率上去后波形变成梯形,对端采样的余量就小了。RX上拉是必要的,UART空闲电平就是高,如果对端是开漏输出或者线缆较长,内部上拉能保证不悬空。注意,如果PB6、PB7恰好是板上的I2C引脚,要确认没有同时被I2C外设占用,外设和GPIO功能冲突时引脚会不按你预期工作。
3.2 定时器配置:一个时基同时驱动收发
IO模拟串口的核心是一个溢出频率等于波特率的定时器。72MHz主频下,不分频直接计数,那么9600波特率对应的计数值是 72000000 / 9600 = 7500,定时器自动重装载值设7499,溢出频率就是9600Hz,也就是每次溢出恰好经过一个位时间。这样发送和接收都用同一个时基,不会出现两套时间基准互相漂移的问题。
#define BIT_TICKS (72000000UL / 9600UL) // 1个位时间的计数值,9600波特率=7500 TIM_HandleTypeDef htim3; void SoftUART_TIM_Init(void) { __HAL_RCC_TIM3_CLK_ENABLE(); htim3.Instance = TIM3; htim3.Init.Prescaler = 0; // 不分频,72MHz直接计数 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = BIT_TICKS - 1; // 溢出一次 = 1个位时间 htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_Base_Init(&htim3); // 定时器中断优先级设为最高,后面讲接收时你就知道为什么 HAL_NVIC_SetPriority(TIM3_IRQn, 0, 0); HAL_NVIC_EnableIRQ(TIM3_IRQn); }这里有个参数换算容易被忽略:Prescaler设0意味着定时器计数频率就是72MHz,Period必须减1是因为计数器从0计数到Period才溢出,一个位时间实际是7500个计数,写成BIT_TICKS - 1代码可读性更好。换不同波特率时只改BIT_TICKS的除数就行。下表列出几个常用波特率在72MHz下的参数:
| 波特率 | BIT_TICKS 理论值 | ARR 实际值 | 单个位时间 |
|---|---|---|---|
| 9600 | 7500 | 7499 | 104.17 us |
| 19200 | 3750 | 3749 | 52.08 us |
| 38400 | 1875 | 1874 | 26.04 us |
| 57600 | 1250 | 1249 | 17.36 us |
| 115200 | 625 | 624 | 8.68 us |
如果你的系统时钟不是72MHz,把BIT_TICKS定义里的72MHz改成实际主频即可。用内部RC时钟要先确认SystemCoreClock的真实值,很多工程在时钟树配置阶段就引入了偏差,后面所有位时间都会不准。
3.3 发送状态机与字节发送函数:完整可跑代码
发送一个字节要经历10个位时间:1个起始位、8个数据位、1个停止位。发送函数把数据装进全局变量,拉低TX作为起始位,然后启动定时器。定时器每次溢出中断就推进一个状态:前8次依次输出bit0到bit7,第9次输出停止位,第10次结束并关闭定时器。
volatile uint8_t tx_busy = 0; // 发送忙标志,主循环可以查这个位 volatile uint8_t tx_state = 0; // 当前推进到的位序号 volatile uint8_t tx_byte = 0; // 正在发送的数据 // 调用后立刻返回,数据在定时器中断里逐位移出 void SoftUART_SendByte(uint8_t data) { while (tx_busy); // 上一字节没发完就等,简单但可靠 tx_byte = data; tx_state = 0; tx_busy = 1; HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, GPIO_PIN_RESET); // 起始位拉低 __HAL_TIM_SET_COUNTER(&htim3, 0); // 计数器清零 __HAL_TIM_SET_AUTORELOAD(&htim3, BIT_TICKS - 1); HAL_TIM_Base_Start_IT(&htim3); // 启动位时钟 }定时器中断服务函数里调用下面的状态机函数,注意这个函数只做位操作和状态推进,不做延时、不打日志,保持时间确定性:
void SoftUART_TX_ISR(void) // 在TIM3_IRQHandler中被调用 { if (!tx_busy) return; if (tx_state < 8) { // LSB first:第0次中断发bit0,第7次中断发bit7 uint8_t bit = (tx_byte >> tx_state) & 0x01; HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, bit ? GPIO_PIN_SET : GPIO_PIN_RESET); tx_state++; } else if (tx_state == 8) { HAL_GPIO_WritePin(SOFT_TX_PORT, SOFT_TX_PIN, GPIO_PIN_SET); // 停止位拉高 tx_state++; } else { HAL_TIM_Base_Stop_IT(&htim3); // 整帧结束,关闭定时器 tx_state = 0; tx_busy = 0; } }这段代码里最需要注意的点是起始位和bit0的分界。发送函数里拉低TX之后立刻启动定时器,定时器从0开始计数,第一次溢出时恰好经过一个位时间,此刻起始位结束,应该输出bit0的电平。tx_state从0开始,正好对应bit0,逻辑是闭合的。tx_busy的作用是防止主循环在上一字节还没发完时又调用发送函数,造成起始位被拉长或数据错位。这个简单方案里用了while(tx_busy)阻塞等待,如果主循环不能阻塞,把发送也改成FIFO队列即可,代码结构一样,只是入口处改为入队。
4. 接收端实现:下降沿启动 + 中点采样,把 RX 做稳
4.1 为什么不能只靠轮询:边沿检测与中断配合
很多人第一次写IO模拟串口接收时,习惯在主循环里死循环读RX引脚等下降沿,等到了再逐位延时采样。这个写法在9600波特率下勉强能工作,但带来的问题是:接收期间CPU完全锁死,任何其他任务都执行不了。到了115200波特率,一个位时间才8.68微秒,主循环响应稍微慢一点,起始位就错过了。
正确结构是:RX引脚配置成下降沿触发的外部中断,UART空闲时是高电平,对端拉低起始位的瞬间就会触发一次EXTI中断。在这个中断里启动定时器,定时器中断负责后续按位采样。这样起始位的检测是硬件级的,不依赖主循环轮询速度;位采样的时刻由定时器精确控制,不受主程序代码路径影响。要理解的是,EXTI中断只做“启动”动作,真正读数据位的是定时器中断。
4.2 EXTI与定时器联动:启动接收的完整代码
接收时序的核心是采样点位置。起始位下降沿出现的时刻记为0时刻,起始位本身持续1个位时间,第一个数据位(bit0)的中心点应该在1.5个位时间处。所以定时器第一次重载值不是BIT_TICKS,而是BIT_TICKS + BIT_TICKS/2。之后每个数据位中心间隔1个位时间,重载值恢复为BIT_TICKS。如果第一次也用1个位时间,采样点恰好落在起始位和bit0的边界附近,对端时钟稍有偏差就会读错。
volatile uint8_t rx_active = 0; // 接收进行中标志 volatile uint8_t rx_bit_idx = 0; // 已采样的数据位序号,0~7 volatile uint8_t rx_byte = 0; // 正在拼装的字节 // EXTI中断服务函数中调用:检测到起始位下降沿 void SoftUART_RX_EXTI_ISR(void) { if (rx_active) { // 已经处于接收状态,忽略后续下降沿(包括数据位里的0跳变) __HAL_GPIO_EXTI_CLEAR_IT(SOFT_RX_PIN); return; } rx_active = 1; rx_bit_idx = 0; rx_byte = 0; // 关键参数:第一次采样点设为1.5个位时间,落在bit0中心 __HAL_TIM_SET_AUTORELOAD(&htim3, BIT_TICKS + BIT_TICKS / 2); __HAL_TIM_SET_COUNTER(&htim3, 0); HAL_TIM_Base_Start_IT(&htim3); __HAL_GPIO_EXTI_CLEAR_IT(SOFT_RX_PIN); }定时器中断服务函数中调用接收状态机,每次中断到来时读一次RX引脚电平。rx_bit_idx小于8时是数据位,采样结果写进rx_byte;等于8时这一帧已经过了9.5个位时间,正好是停止位中心,校验停止位是否为高,然后结束接收:
// 在定时器中断里:每个位中心采样一次RX引脚 void SoftUART_RX_ISR(void) { uint8_t sample; if (!rx_active) return; // 读引脚:高电平为1,低电平为0 sample = (HAL_GPIO_ReadPin(SOFT_RX_PORT, SOFT_RX_PIN) == GPIO_PIN_SET); if (rx_bit_idx < 8) { if (sample) { rx_byte |= (1U << rx_bit_idx); // 低位在前,按bit位拼装 } rx_bit_idx++; // 之后的每个采样点间隔1个位时间 __HAL_TIM_SET_AUTORELOAD(&htim3, BIT_TICKS - 1); } else { // 第9次中断 = 停止位中心 rx_active = 0; HAL_TIM_Base_Stop_IT(&htim3); if (sample) { // 停止位为高,帧有效,入FIFO等主循环来取 if (!SoftUART_FIFO_Push(&rx_fifo, rx_byte)) { rx_fifo_overrun++; // FIFO满了,丢字节计数 } } // 停止位为低说明帧错误,直接丢弃 } __HAL_TIM_SET_COUNTER(&htim3, 0); // 每次中断后计数器归零,保证重载准确 }这里有两个参数容易搞错。第一,第一次重载值是1.5位,但之后的每次溢出间隔是1位,所以必须在采样bit0之后立刻把重载值改回BIT_TICKS - 1。第二,每次中断末尾要把计数器清零,否则从溢出时刻到重新计数之间有一个不确定的余数,采样间隔会被拉长。HAL库的__HAL_TIM_SET_COUNTER在中断里执行成本很低,但少了这一行,时间轴会逐渐漂移,表现就是偶尔错位、乱码,而且很难排查。
4.3 数据拼装与FIFO:从一字节到连续接收
接收是异步的——定时器中断不断往缓冲区里塞字节,主循环可能正在处理其他事情。如果没有缓冲,主循环稍慢一个字节的接收周期,新数据就会覆盖旧数据。所以接收端要挂一个环形FIFO,中断里写,主循环里读。环形FIFO实现不复杂,关键是要处理好满标志和头尾指针的volatile可见性。
#define UART_FIFO_SIZE 256 typedef struct { uint8_t buf[UART_FIFO_SIZE]; volatile uint16_t head; // 写指针 volatile uint16_t tail; // 读指针 } SoftUART_FIFO; static SoftUART_FIFO rx_fifo; // 返回1表示入队成功,0表示FIFO已满 uint8_t SoftUART_FIFO_Push(SoftUART_FIFO *f, uint8_t data) { uint16_t next = (f->head + 1) % UART_FIFO_SIZE; if (next == f->tail) { return 0; // 满 } f->buf[f->head] = data; f->head = next; return 1; } // 返回1表示读到数据,0表示FIFO为空 uint8_t SoftUART_FIFO_Pop(SoftUART_FIFO *f, uint8_t *data) { if (f->head == f->tail) { return 0; // 空 } *data = f->buf[f->tail]; f->tail = (f->tail + 1) % UART_FIFO_SIZE; return 1; }FIFO的容量要按最坏情况估算。比如主循环每10毫秒来取一次数据,115200波特率下10毫秒能收到约115个字节,256字节的容量留了接近一倍的余量。如果业务量更大,要么扩大FIFO,要么提高主循环取数据的频率。中断里push时用了模运算,编译器通常会把它优化成按位与操作,因为容量是2的幂。如果你把容量改成非2的幂,要先确认编译器是否生成除法指令,否则中断里耗时翻倍就得不偿失。
5. 避坑与排查:乱码、丢字节、波特率上不去的5个真实原因
5.1 掉进默认引脚复用与系统时钟不准的坑
现象:发送端波形出不来,逻辑分析仪上TX引脚一直保持高电平,好像完全没有起始位;或者通过示波器测到的位时间明显不等于1/波特率,波特率越高偏差越明显。
原因:前一类问题大多是因为引脚被默认复用功能占用。STM32部分引脚上电后默认是JTAG或SWD功能,比如PA15、PB3、PB4,它们必须被配置成普通IO才能输出电平。如果代码里只做了GPIO初始化而没有处理复用重映射,寄存器会被外设接口锁住,GPIO写操作根本不生效。后一类问题则源于系统时钟假设错误——代码里写死了72MHz,但实际主频可能是内部RC的8MHz,或者PLL配置有误,SystemCoreClock值和真实值不一致,BIT_TICKS算出来就是错的。
解决:调试起步阶段先确认两件事。第一,查芯片参考手册的GPIO复用表,凡是默认被调试接口占用的引脚,要么换掉,要么在初始化里关闭JTAG重新映射。第二,在工程里读出SystemCoreClock的值并在调试器里观察,或者直接通过示波器测某个已知频率的PWM引脚来反推实际主频。不要凭经验猜主频,这个数值差一点,整个位时序就全错了。
5.2 采样点偏移与中断优先级:让接收乱码的元凶
现象:发送端波形完全正常,对端也说收到了正确数据,但STM32这边接收到的字节偶尔错一位,整个字节全错;或者波特率调到115200后频繁丢字节,降到38400又恢复正常。
原因:接收采样点不在位的中心。最常见的错误是第一次定时器重载值用了BIT_TICKS而不是BIT_TICKS + BIT_TICKS/2,导致第一个数据位在边界附近被采样。UART双方各有独立时钟,允许的误差只有百分之几,采样点越靠近边沿,容忍度越低。丢字节的另一类原因是定时器中断被更高优先级的中断长时间打断。如果项目里还有其他外设中断频繁触发,且它们的优先级高于定时器,采样时刻就会被推迟几微秒,115200波特率下这一个位时间总共才8.68微秒,很容易错位。
解决:把定时器中断优先级调到整个应用里的最高档,接收采样这件事比任何外设都更怕延迟。EXTI中断的优先级反而可以低一些,因为下降沿的检测本身是硬件完成的,中断服务晚几个微秒执行不影响启动定时器的时刻精度。同时检查所有中断服务函数,不要在中断里做打印、浮点运算、复杂协议解析这类耗时操作,只置标志位或塞FIFO,重活全部留到主循环。排查时可以故意把两种采样参数都跑一遍对比误码率,验证自己的判断。
5.3 中断共享变量不声明volatile:优化一开就翻车
现象:程序在-O0优化等级下收发一切正常,把优化等级调到-O2或更高之后,发送干脆不动,或者接收到的数据永远是同一个值,改波特率也没用。
原因:中断服务函数和主循环之间共享的变量——tx_busy、tx_state、rx_byte、rx_active、FIFO的head/tail——如果没有声明为volatile,编译器在优化时可能认为这些变量在主循环里不会被外部修改,把读取操作缓存到寄存器里,或者把中断里的写入优化掉。这种问题最隐蔽,因为代码逻辑没变,只是编译器的“乐观假设”和硬件现实冲突了。
解决:所有在中断和主循环之间传递的变量,一律声明为volatile。注意volatile不能替代互斥保护,它只保证编译器每次都从内存读取、每次修改都写回内存。调试阶段先用-O0跑通功能,稳定后再逐步开优化,开完优化后重点回归测试收发功能。血泪经验是:IO模拟串口这种对时序敏感的代码,volatile缺失导致的故障没有固定规律,可能调试半天都定位不到,不如一开始就把这个习惯养成。
5.4 连续多字节发送丢数据:busy标志与发送FIFO
现象:单个字节收发都正常,但主循环里连续调用多次发送函数,比如发送一段字符串或一帧协议数据,对端收到的字节数少了,或者字节间出现异常长度的低电平。
原因:SoftUART_SendByte里的while(tx_busy)看着是等上一字节发完,但如果在定时器中断已经结束发送、而tx_busy还没来得及清零的瞬间,主循环再次调用发送函数,就有可能把正在收尾的状态搞乱。更常见的是主循环连续快速调用,前一个字节还没发完就必须阻塞等待,阻塞期间如果有其他中断进来,整个流程的时间确定性就被破坏了。
解决:把发送也改成队列,入口函数只负责把字节塞进发送FIFO,定时器中断里每次发完一个字节后查看队列,有数据就继续发下一个。这样主循环调用发送函数永远不会长阻塞,数据也不会丢。发送FIFO和接收FIFO用同一个结构体,容量可以小一些,64字节基本够调试日志用了。批量化传输时先在主循环里把数据组装成一帧,再一次调用入队接口,减少上下文切换。
6. 进阶验证:用逻辑分析仪校准波特率与收发时序
6.1 用逻辑分析仪验证收发时序
IO模拟串口做好之后,第一步不是接设备联调,而是用逻辑分析仪抓自己的波形。通道接TX和RX,地线共地,采样率至少设到波特率的16倍以上。抓到波形后,用分析仪的测量功能量一下起始位宽度:9600波特率下应该正好104微秒左右,误差超过2%就要检查BIT_TICKS和实际主频。数据位的宽度也要逐个量,特别是连续发0x00和0xFF两组数据,0x00产生连续的低电平跳变,0xFF产生连续的高电平,这两个极端能暴露位时间是否均匀。
接收侧的验证更直接:用另一个串口设备或直接短接TX和RX做回环测试,连续发送递增序列 0x00、0x01、0x02……0xFF,接收端对比收到的序列。一旦出现错位,立刻抓RX引脚的波形,看采样点是否在每个数据位的中点附近。大多数时候问题出在第一次重载值,少数情况是中断优先级导致采样时刻被推移。这个验证方法比直接接业务设备省心很多,也方便定位是不是自己的时序问题。
6.2 把采样点微调成一个可配置参数
如果你的对端设备时钟精度一般,或者线缆比较长导致边沿变缓,中心点采样不一定是最优解。一个有用的技巧是把第一次采样点的系数做成宏或常量,在BIT_TICKS + BIT_TICKS / 2的基础上留出微调空间。比如对端时钟略微偏慢时,把采样点从1.5位往后挪到1.55位,能显著降低误码率。
#define RX_SAMPLE_DELAY (BIT_TICKS + BIT_TICKS / 2) // 默认bit0中点采样 // 对端偏慢时可以改成 (BIT_TICKS * 3 / 2) + BIT_TICKS / 16这个参数不需要做大范围调整,一般只微调几个百分点。我自己的习惯是:IO模拟串口永远当成调试后门来用,不承担关键控制链路,判断标准是能不能稳定跑完一整轮压力测试,而不是理论波特率极限。很多翻车都不是逻辑问题,而是你没意识到自己一直假设的时间基准其实已经偏了。调时序这类问题急不得,先验证、再微调、最后固化参数,这套流程走下来,模拟串口在项目里能省下不少硬件资源。希望帮到你。
本文还有配套的精品资源,点击获取