简介:面向STM32嵌入式开发者的IO模拟串口可运行源码包,解决硬件UART不足或引脚受限时仍需要串口通信的问题。资源基于STM32F103提供完整工程,涵盖发送与接收原理、位延时计算、delay_us与定时器两种延时实现,以及外部中断检测下降沿的接收流程,适合需要底层串口协议理解或资源受限场景的开发者。包体共10个文件,包含C源码、头文件、Makefile构建脚本、链接脚本、启动文件及说明文档,压缩包仅15KB,结构精简便于在主流IDE中直接导入编译。目前已有110人学习下载。通过该工程可快速复用一套可运行的IO串口驱动,既能看到引脚配置、中断服务、定时器处理等完整代码,又能依据注释和工程结构自行调整波特率与GPIO,省去从零调试的时间。1. IO模拟串口到底解决什么问题做嵌入式开发,串口是最常用的调试手段,但硬件串口数量永远是稀缺资源。某次项目里,我手头的芯片只有两个 UART,一个接了蓝牙模块,一个接了上位机,结果传感器还要走串口上报数据。换芯片不现实,加外部串口扩展芯片又要改板子,最后靠两个普通 GPIO 引脚硬生生模拟出一个串口,把数据稳定跑到了 9600 波特率,整个项目按时交付。这就是 IO 模拟串口最典型的应用场景:硬件串口不够用、某个引脚被复用了、或者 PCB 已经定型没法改线,用软件把普通 GPIO 变成 UART,解决临时的通信需求。这份可运行源码适合正在做嵌入式开发、遇到串口资源紧缺或者想要深入理解 UART 时序的人,下载后可以直接烧录验证,再根据自己的芯片改引脚和波特率就能用。2. UART时序与IO模拟原理:位周期、起始位与采样点2.1 从UART帧结构说起:起始位、数据位与停止位模拟串口前,先要把 UART 的帧结构彻底搞清楚。UART 是异步串行通信,没有时钟线,收发双方靠约定好的波特率来对齐每一位的时间宽度。一个完整的帧包含空闲位、起始位、数据位和停止位。空闲时 TX 和 RX 引脚保持高电平,发送方先把引脚拉低,持续一个位周期,这个低电平就是起始位,它告诉接收方「注意,后面有数据了」。然后是数据位,常见的是 8 位,从最低位 LSB 开始逐个发送,每一位持续一个位周期。最后是停止位,引脚拉高持续 1 位或 2 位周期,表示这一帧结束。位周期是波特率的倒数,比如 9600 波特率下,每一位时间是 1 / 9600 ≈ 104.17 微秒。如果波特率是 115200,每一位只有约 8.68 微秒。IO 模拟串口的核心思路就是用延时函数去精确控制这个位周期,逐个位去翻转 GPIO 的电平,拼接出一个合法的 UART 波形。说起来简单,但实际写代码的时候,每一微秒的误差都会累积,等发到第 8 个数据位时,波形可能已经歪得没法看了。IO 模拟串口和硬件串口的本质区别在于:硬件 UART 外设内部有专用的波特率发生器,根据时钟源分频产生精确的位时序,同时有移位寄存器、数据缓冲和错误检测逻辑,这些完全由硬件电路完成,不占用 CPU。而 IO 模拟串口是把这些工作全部搬到软件里,用延时循环代替波特率发生器,用 GPIO 翻转代替移位寄存器输出,用轮询或中断去检测起始位。软件方案的优点是不依赖额外的硬件资源,任何有两个闲置 GPIO 的芯片都能做;代价是 CPU 被延时函数占住,无法同时做其他计算,而且波特率越高,延时的精度要求越苛刻。2.2 软件延时的误差是怎么累积到采样点上的写延时函数是 IO 模拟串口的第一步,也是最容易翻车的地方。很多人直接调用HAL_Delay(1),单位是毫秒,这根本没法用,因为 9600 波特率下一位才 104 微秒,需要的是微秒级延时。常见做法是用一个简单的循环,通过指令周期数来估算延时长度。拿 ARM Cortex-M 内核来说,假设主频是 72MHz,一条简单指令大约需要一个周期,也就是约 13.9 纳秒,100 微秒大约需要 7200 个指令周期。void delay_us(uint32_t us) { uint32_t count = us * (SystemCoreClock / 1000000UL) / 4; while (count--) { __NOP(); } }这里的关键是count的计算方式:SystemCoreClock / 1000000UL得到每微秒的 CPU 周期数,除以 4 是因为循环体里除了__NOP()之外还有count--和比较跳转指令,这些也要消耗周期。实际上这个除法系数在不同编译器优化等级下会变,最好是先用示波器实际测一下,微调系数让延时尽量接近理论值。我一般会在delay_us前后各加一个 GPIO 翻转,用示波器量出实际延时,再反向修正系数,比纯靠理论推算省心得多。误差累积的问题出在发送数据位的时候。发送一个字节要经历起始位(1 位)、8 个数据位、停止位(1 位),一共 10 个位周期。如果每个位周期的延时误差是 1 微秒,10 位累积下来就是 10 微秒的漂移,而 9600 波特率下接收方采样数据位时,一般只允许最多半个位周期(约 52 微秒)的容差,10 微秒的误差在 9600 下还能接受,但如果波特率提到 115200,位周期只有 8.68 微秒,1 微秒的单步误差加到第 4、5 位时就会引起采样位置偏移,出现乱码。3. 发送方向实战:引脚规划与源码解析3.1 引脚选择与初始化:别把复用功能踩了选模拟串口的引脚要避开几个坑。第一是避开已经分配给调试接口的引脚,很多板子上的 PA13、PA14 默认接的是 SWDIO 和 SWCLK,如果代码里没关掉调试复用,输出的电平会受影响。第二是避开默认复用为其他外设的引脚,比如 ADC 通道、定时器通道,这些引脚即使你配置成普通 GPIO,外设初始化时也可能冲突。第三是注意引脚电平,TX 引脚在空闲时要保持高电平,所以初始化要设置成推挽输出、默认输出高。void uart_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); }这里把 PB6 用作 TX,PB7 用作 RX。注意 RX 的初始化方式——因为 RX 在接收数据时是输入方向,但如果只配成输入,又没法在下文复用。常见的做法是先把两个引脚都配成输出,发送方向直接用,接收方向在需要时切换为输入。PB6 初始化为高电平,就是为了保证空闲状态符合 UART 协议。GPIO_SPEED_FREQ_HIGH不能省,模拟串口每个位周期最短只有几十微秒,GPIO 翻转速度不够会导致波形上升沿、下降沿变缓,接收方采样时读到错误的电平。初始化完成后,用一个示波器或者逻辑分析仪确认 TX 引脚确实是稳定的高电平,这一步能排除大部分接线问题。3.2 发送一个字节和发送字符串:源码逐行拆发送方向的代码是整个模拟串口里最直观的部分,把 UART 的帧结构用 GPIO 翻转逐个实现。核心是一个发送单字节的函数,里面精确控制每一位的延时。void uart_send_byte(uint8_t data) { // 发送起始位:拉低引脚 HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_RESET); delay_us(BIT_PERIOD_US); // 发送8个数据位,LSB first for (uint8_t i = 0; i < 8; i++) { if (data & (0x01 << i)) { HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_RESET); } delay_us(BIT_PERIOD_US); } // 发送停止位:拉高引脚 HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_SET); delay_us(BIT_PERIOD_US); }逻辑上就是按照起始位、数据位、停止位的顺序逐个操作 GPIO。起始位拉低后延时一个位周期,数据位从最低位开始,通过data & (0x01 << i)取出对应位的值,然后置高或拉低,每个位再延时一个位周期,最后拉高停止位。BIT_PERIOD_US就是波特率对应的微秒数,9600 下定义为104。#define BIT_PERIOD_US 104 // 1 / 9600 * 1000000 #define UART_TX_PORT GPIOB #define UART_TX_PIN GPIO_PIN_6 #define UART_RX_PORT GPIOB #define UART_RX_PIN GPIO_PIN_7这段代码有两个细节要注意。第一,每次HAL_GPIO_WritePin本身是有执行时间的,虽然只有几百纳秒,但在高速波特率下会叠加到延时上,所以BIT_PERIOD_US的值不需要太精确,实际用示波器量波形再微调。第二,整个发送过程里 CPU 被delay_us完全占住,如果此时来了一个定时器中断,中断处理函数会打断延时,导致某一位被拉长,接收方这一位之后的采样点全部错位。这个问题的处理方式在第 5 章展开。发送字符串就是对单字节发送的循环封装。void uart_send_string(const char *str) { while (*str) { uart_send_byte((uint8_t)(*str++)); } }字符串发送的效率不在于函数本身,而在于调用方怎么用。如果只是调试打印,每帧之间留几十毫秒的间隔,给接收方一点缓冲时间;如果数据量较大,建议每发送完一个字节后主动__NOP()几个周期,让总时序稍微放宽,减少累积误差的影响。3.3 波特率换算是怎么变成延时参数的波特率从 9600 换到 19200、38400、115200,对应位周期直接除倍数。9600 的位周期是 104 微秒,19200 是 52 微秒,38400 是 26 微秒,115200 是 8 微秒。问题在于延时函数本身的最小粒度:如果用的是上一节的delay_us,8 微秒意味着主频 72MHz 下循环只有约 144 个指令周期,这种精度下循环本身的开销、函数调用的压栈出栈、GPIO 翻转指令的耗时都会被放大,误差比例明显上升。实际操作中,我建议先用#define定义好所有波特率对应的位周期参数:#define BIT_PERIOD_9600 104 #define BIT_PERIOD_19200 52 #define BIT_PERIOD_38400 26 #define BIT_PERIOD_115200 8然后在发送函数里用一个宏参数替换固定的BIT_PERIOD_US。这里要提醒的是,宏替换的延时值只适用于特定的主频和编译器优化等级,换一个编译器或者改主频,延时函数的实际执行时间就会变,所以不要只改波特率宏不改延时函数。最稳妥的方式是先以 9600 为基础调好延时系数,再在示波器上验证其他波特率的波形。如果你手头有逻辑分析仪,直接抓波形看起始位到停止位之间每个位的时间宽度,一目了然,比自己逐个调试快得多。4. 接收方向与常见问题排查:为什么我不建议裸延时读引脚4.1 接收侧的困难:起始位检测与中点采样发送方向的模拟串口相对好写,接收方向才是真正的拦路虎。接收的难点在于:作为接收方,你不知道数据什么时候会来,必须先检测到起始位(即 RX 引脚从高电平跳变到低电平),然后才能开始按位周期采样。这里有两个关键点:起始位下降沿的检测和每个数据位的采样时机。常见做法是把 RX 引脚配置成外部中断输入,检测下降沿触发。一旦下降沿到来,立即启动一个延时计时,等半个位周期后采样 RX 电平,确认确实是低电平(排除毛刺干扰),然后每隔一个位周期采样一次,连续采 8 次得到 8 个数据位,最后确认停止位是高电平。void EXTI_IRQHandler(void) { if (EXTI_GetITStatus(UART_RX_EXTI_LINE) != RESET) { EXTI_ClearITPendingBit(UART_RX_EXTI_LINE); // 等半个位周期,避开起始位的前沿毛刺 delay_us(BIT_PERIOD_US / 2); if (HAL_GPIO_ReadPin(UART_RX_PORT, UART_RX_PIN) == GPIO_PIN_RESET) { // 确认是真正的起始位 uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { delay_us(BIT_PERIOD_US); if (HAL_GPIO_ReadPin(UART_RX_PORT, UART_RX_PIN) == GPIO_PIN_SET) { data |= (0x01 << i); } } // 跳过停止位 delay_us(BIT_PERIOD_US); } } }这段代码在理论上可行,但实际用起来问题不少。首先是时序精度问题:外部中断触发后进入中断处理函数,函数本身需要压栈、清中断标志、读引脚、延时,这一串操作已经消耗了不确定的指令周期,半个位周期的延时起点其实比真正的起始位中点晚了一些。其次是延时函数的误差会在 8 个数据位里逐步累积,如果每个位周期慢 1 微秒,第 8 个数据位的采样点就偏了 8 微秒,在 9600 波特率下还不算致命,在 38400 以上就很容易采错。所以我一般不建议在中断里用纯延时去做接收采样,更稳的做法是使用硬件定时器的输入捕获功能。把 RX 引脚连接到定时器的捕获通道,让定时器以微秒精度记录下降沿和上升沿的时刻,然后在主循环里根据相邻两次捕获的时间差解析出每一位的电平高低。这样可以绕开指令时延带来的误差,代价是占用了定时器资源。给读者的建议是:如果只是实现单向发送(比如调试输出),发送方向的模拟已经够用;如果需要双向通信且波特率高于 19200,优先考虑定时器捕获方案,或者直接换带多个 UART 的芯片。4.2 排查清单:五条高频翻车记录我把模拟串口常见的踩坑记录整理成了一份排查清单,每条都按「现象 → 原因 → 解决」的顺序来写,方便出问题时对照。现象一:9600 波特率下发送,接收方收到的前几个字节正常,后面的字节全是乱码。原因是延时函数的累积误差导致数据位采样点逐渐偏移,到第 4 位之后误差超过容差。解决方法是调整delay_us的系数,把实测的位周期误差控制在 ±2% 以内,发送完一个字节后额外增加 10 微秒的间隔,给接收方一点恢复时间。现象二:接收方完全收不到数据,示波器看 TX 引脚波形却正常。原因是 RX 引脚配置成了推挽输出模式,一直输出高电平,外部发来的低电平信号被内部驱动拉回来了。解决方法是接收前把 RX 引脚改成浮空输入或上拉输入,必要的时候在硬件上加一个限流电阻。现象三:发送时偶尔出现一位波形明显变宽,其他位正常。原因是发送过程中被中断打断,中断服务函数执行了十几微秒回来后继续延时时序,拉长了当前位。解决方法是在发送期间暂时屏蔽中断,或者把位周期稍微做短一点,留出中断耗时的余量。现象四:引脚空闲电平为低,波形完全对不上。原因是 GPIO 初始化时默认输出低电平,没有在初始化末尾主动拉高。解决方法是初始化后先写一次高电平,再开始发送数据。现象五:同一个代码在优化等级从 O0 改到 O2 后,波特率完全错乱。原因是delay_us的系数是按 O0 优化调出来的,循环体被编译器优化后循环次数对应的执行时间完全变了。解决方法是在延时函数里使用volatile修饰循环变量,而且换优化等级后重新用示波器校准一次延时参数。排查模拟串口问题时,工具比人靠谱。逻辑分析仪是最值的投入,设置好采样率和触发条件,抓一段波形直接测量每一位的时间宽度,问题出在哪一步一目了然。不推荐用万用表或人眼数周期的方式去排查时序问题,效率太低。5. 性能边界验证与一个实用技巧:把余量留给中断5.1 波特率上限的估算方法IO 模拟串口不是万能的,波特率的上限取决于你的延时精度和中断开销。用示波器实测过一组数据:主频 72MHz、延时系数按 O0 优化调校后,9600 波特率下位周期误差约 ±1%,19200 下约 ±2%,38400 下约 ±4%。38400 以上基本不建议用纯 IO 模拟,因为误差比例已经在容忍边界附近,环境温度变化、电源电压波动都可能让通信立刻翻车。估算方法很简单:用示波器量 100 个位周期的总时间,计算每个位周期的实际平均值和误差百分比。误差在 2% 以内才算稳定可用,超过 3% 就要考虑降低波特率或者换方案。另外要注意的是,内部 RC 振荡器的精度本来就有限,如果芯片用的是内部时钟,即使延时函数写得很准,时钟源的偏差也会反映到每一位上。要求高的时候用外部晶振,这是性价比最高的稳定性提升手段。5.2 一个实用技巧:发送期间临时关闭中断我在项目里用模拟串口发送调试日志时,遇到过一个很头疼的中断问题:系统里有一个 1 毫秒的定时器中断,用于软件计时和按键扫描,发送的过程中断被频繁触发,一个字节的 10 位经常被强行拉宽 1 到 2 位,接收方那边统计的误码率非常高。解决思路是在发送字节期间临时关闭中断,发完再恢复:void uart_send_byte_safe(uint8_t data) { __disable_irq(); uart_send_byte(data); __enable_irq(); }这段代码逻辑简单,但要注意关闭中断的时长必须可控。9600 波特率下发送一个字节大约耗时 1.04 毫秒,在那段时间里所有中断都被挂起,如果系统里有时间敏感的外设(比如电机控制的 PWM 更新、看门狗喂狗),超过阈值就会出问题。所以这个方案只适合中断负载不高的场景,喂狗操作要放在关闭中断之前或之后补上,不能放在被屏蔽的区间里。如果系统里中断太频繁,还有一个变体思路:把发送操作拆成两部分,起始位和前面 4 个数据位关中断发,后面几位开中断发,虽然波形上会有一个明显的断点,但对端接收时只要采样点没踩在断点附近就不会出错。这个技巧我用在了一个 38400 波特率的外设通信里,实测误码率从 15% 降到了 0。5.3 验证流程:一个可以固化的自检习惯模拟串口的验证不能只看打印正常就收工。从那以后我每次做完这套模拟串口,都强制走一遍完整的验证流程:先回环测试,把 TX 直接连到 RX,代码里自发自收,确认数据一致;再用逻辑分析仪抓一段波形,测量每一位的时间宽度,确保误差在 2% 以内;最后接上真实对端设备,连续发送 1000 帧随机数据,统计误码率,低于 0.1% 才算通过。这套流程看着繁琐,但能帮你在交付前把所有时序问题都暴露出来。希望帮到你,模拟串口这条路走通一次,之后再做任何 IO 模拟外设,心里都有底。本文还有配套的精品资源,点击获取
简介:面向STM32嵌入式开发者的IO模拟串口可运行源码包,解决硬件UART不足或引脚受限时仍需要串口通信的问题。资源基于STM32F103提供完整工程,涵盖发送与接收原理、位延时计算、delay_us与定时器两种延时实现,以及外部中断检测下降沿的接收流程,适合需要底层串口协议理解或资源受限场景的开发者。包体共10个文件,包含C源码、头文件、Makefile构建脚本、链接脚本、启动文件及说明文档,压缩包仅15KB,结构精简便于在主流IDE中直接导入编译。目前已有110人学习下载。通过该工程可快速复用一套可运行的IO串口驱动,既能看到引脚配置、中断服务、定时器处理等完整代码,又能依据注释和工程结构自行调整波特率与GPIO,省去从零调试的时间。1. IO模拟串口到底解决什么问题做嵌入式开发,串口是最常用的调试手段,但硬件串口数量永远是稀缺资源。某次项目里,我手头的芯片只有两个 UART,一个接了蓝牙模块,一个接了上位机,结果传感器还要走串口上报数据。换芯片不现实,加外部串口扩展芯片又要改板子,最后靠两个普通 GPIO 引脚硬生生模拟出一个串口,把数据稳定跑到了 9600 波特率,整个项目按时交付。这就是 IO 模拟串口最典型的应用场景:硬件串口不够用、某个引脚被复用了、或者 PCB 已经定型没法改线,用软件把普通 GPIO 变成 UART,解决临时的通信需求。这份可运行源码适合正在做嵌入式开发、遇到串口资源紧缺或者想要深入理解 UART 时序的人,下载后可以直接烧录验证,再根据自己的芯片改引脚和波特率就能用。2. UART时序与IO模拟原理:位周期、起始位与采样点2.1 从UART帧结构说起:起始位、数据位与停止位模拟串口前,先要把 UART 的帧结构彻底搞清楚。UART 是异步串行通信,没有时钟线,收发双方靠约定好的波特率来对齐每一位的时间宽度。一个完整的帧包含空闲位、起始位、数据位和停止位。空闲时 TX 和 RX 引脚保持高电平,发送方先把引脚拉低,持续一个位周期,这个低电平就是起始位,它告诉接收方「注意,后面有数据了」。然后是数据位,常见的是 8 位,从最低位 LSB 开始逐个发送,每一位持续一个位周期。最后是停止位,引脚拉高持续 1 位或 2 位周期,表示这一帧结束。位周期是波特率的倒数,比如 9600 波特率下,每一位时间是 1 / 9600 ≈ 104.17 微秒。如果波特率是 115200,每一位只有约 8.68 微秒。IO 模拟串口的核心思路就是用延时函数去精确控制这个位周期,逐个位去翻转 GPIO 的电平,拼接出一个合法的 UART 波形。说起来简单,但实际写代码的时候,每一微秒的误差都会累积,等发到第 8 个数据位时,波形可能已经歪得没法看了。IO 模拟串口和硬件串口的本质区别在于:硬件 UART 外设内部有专用的波特率发生器,根据时钟源分频产生精确的位时序,同时有移位寄存器、数据缓冲和错误检测逻辑,这些完全由硬件电路完成,不占用 CPU。而 IO 模拟串口是把这些工作全部搬到软件里,用延时循环代替波特率发生器,用 GPIO 翻转代替移位寄存器输出,用轮询或中断去检测起始位。软件方案的优点是不依赖额外的硬件资源,任何有两个闲置 GPIO 的芯片都能做;代价是 CPU 被延时函数占住,无法同时做其他计算,而且波特率越高,延时的精度要求越苛刻。2.2 软件延时的误差是怎么累积到采样点上的写延时函数是 IO 模拟串口的第一步,也是最容易翻车的地方。很多人直接调用HAL_Delay(1),单位是毫秒,这根本没法用,因为 9600 波特率下一位才 104 微秒,需要的是微秒级延时。常见做法是用一个简单的循环,通过指令周期数来估算延时长度。拿 ARM Cortex-M 内核来说,假设主频是 72MHz,一条简单指令大约需要一个周期,也就是约 13.9 纳秒,100 微秒大约需要 7200 个指令周期。void delay_us(uint32_t us) { uint32_t count = us * (SystemCoreClock / 1000000UL) / 4; while (count--) { __NOP(); } }这里的关键是count的计算方式:SystemCoreClock / 1000000UL得到每微秒的 CPU 周期数,除以 4 是因为循环体里除了__NOP()之外还有count--和比较跳转指令,这些也要消耗周期。实际上这个除法系数在不同编译器优化等级下会变,最好是先用示波器实际测一下,微调系数让延时尽量接近理论值。我一般会在delay_us前后各加一个 GPIO 翻转,用示波器量出实际延时,再反向修正系数,比纯靠理论推算省心得多。误差累积的问题出在发送数据位的时候。发送一个字节要经历起始位(1 位)、8 个数据位、停止位(1 位),一共 10 个位周期。如果每个位周期的延时误差是 1 微秒,10 位累积下来就是 10 微秒的漂移,而 9600 波特率下接收方采样数据位时,一般只允许最多半个位周期(约 52 微秒)的容差,10 微秒的误差在 9600 下还能接受,但如果波特率提到 115200,位周期只有 8.68 微秒,1 微秒的单步误差加到第 4、5 位时就会引起采样位置偏移,出现乱码。3. 发送方向实战:引脚规划与源码解析3.1 引脚选择与初始化:别把复用功能踩了选模拟串口的引脚要避开几个坑。第一是避开已经分配给调试接口的引脚,很多板子上的 PA13、PA14 默认接的是 SWDIO 和 SWCLK,如果代码里没关掉调试复用,输出的电平会受影响。第二是避开默认复用为其他外设的引脚,比如 ADC 通道、定时器通道,这些引脚即使你配置成普通 GPIO,外设初始化时也可能冲突。第三是注意引脚电平,TX 引脚在空闲时要保持高电平,所以初始化要设置成推挽输出、默认输出高。void uart_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); }这里把 PB6 用作 TX,PB7 用作 RX。注意 RX 的初始化方式——因为 RX 在接收数据时是输入方向,但如果只配成输入,又没法在下文复用。常见的做法是先把两个引脚都配成输出,发送方向直接用,接收方向在需要时切换为输入。PB6 初始化为高电平,就是为了保证空闲状态符合 UART 协议。GPIO_SPEED_FREQ_HIGH不能省,模拟串口每个位周期最短只有几十微秒,GPIO 翻转速度不够会导致波形上升沿、下降沿变缓,接收方采样时读到错误的电平。初始化完成后,用一个示波器或者逻辑分析仪确认 TX 引脚确实是稳定的高电平,这一步能排除大部分接线问题。3.2 发送一个字节和发送字符串:源码逐行拆发送方向的代码是整个模拟串口里最直观的部分,把 UART 的帧结构用 GPIO 翻转逐个实现。核心是一个发送单字节的函数,里面精确控制每一位的延时。void uart_send_byte(uint8_t data) { // 发送起始位:拉低引脚 HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_RESET); delay_us(BIT_PERIOD_US); // 发送8个数据位,LSB first for (uint8_t i = 0; i < 8; i++) { if (data & (0x01 << i)) { HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_RESET); } delay_us(BIT_PERIOD_US); } // 发送停止位:拉高引脚 HAL_GPIO_WritePin(UART_TX_PORT, UART_TX_PIN, GPIO_PIN_SET); delay_us(BIT_PERIOD_US); }逻辑上就是按照起始位、数据位、停止位的顺序逐个操作 GPIO。起始位拉低后延时一个位周期,数据位从最低位开始,通过data & (0x01 << i)取出对应位的值,然后置高或拉低,每个位再延时一个位周期,最后拉高停止位。BIT_PERIOD_US就是波特率对应的微秒数,9600 下定义为104。#define BIT_PERIOD_US 104 // 1 / 9600 * 1000000 #define UART_TX_PORT GPIOB #define UART_TX_PIN GPIO_PIN_6 #define UART_RX_PORT GPIOB #define UART_RX_PIN GPIO_PIN_7这段代码有两个细节要注意。第一,每次HAL_GPIO_WritePin本身是有执行时间的,虽然只有几百纳秒,但在高速波特率下会叠加到延时上,所以BIT_PERIOD_US的值不需要太精确,实际用示波器量波形再微调。第二,整个发送过程里 CPU 被delay_us完全占住,如果此时来了一个定时器中断,中断处理函数会打断延时,导致某一位被拉长,接收方这一位之后的采样点全部错位。这个问题的处理方式在第 5 章展开。发送字符串就是对单字节发送的循环封装。void uart_send_string(const char *str) { while (*str) { uart_send_byte((uint8_t)(*str++)); } }字符串发送的效率不在于函数本身,而在于调用方怎么用。如果只是调试打印,每帧之间留几十毫秒的间隔,给接收方一点缓冲时间;如果数据量较大,建议每发送完一个字节后主动__NOP()几个周期,让总时序稍微放宽,减少累积误差的影响。3.3 波特率换算是怎么变成延时参数的波特率从 9600 换到 19200、38400、115200,对应位周期直接除倍数。9600 的位周期是 104 微秒,19200 是 52 微秒,38400 是 26 微秒,115200 是 8 微秒。问题在于延时函数本身的最小粒度:如果用的是上一节的delay_us,8 微秒意味着主频 72MHz 下循环只有约 144 个指令周期,这种精度下循环本身的开销、函数调用的压栈出栈、GPIO 翻转指令的耗时都会被放大,误差比例明显上升。实际操作中,我建议先用#define定义好所有波特率对应的位周期参数:#define BIT_PERIOD_9600 104 #define BIT_PERIOD_19200 52 #define BIT_PERIOD_38400 26 #define BIT_PERIOD_115200 8然后在发送函数里用一个宏参数替换固定的BIT_PERIOD_US。这里要提醒的是,宏替换的延时值只适用于特定的主频和编译器优化等级,换一个编译器或者改主频,延时函数的实际执行时间就会变,所以不要只改波特率宏不改延时函数。最稳妥的方式是先以 9600 为基础调好延时系数,再在示波器上验证其他波特率的波形。如果你手头有逻辑分析仪,直接抓波形看起始位到停止位之间每个位的时间宽度,一目了然,比自己逐个调试快得多。4. 接收方向与常见问题排查:为什么我不建议裸延时读引脚4.1 接收侧的困难:起始位检测与中点采样发送方向的模拟串口相对好写,接收方向才是真正的拦路虎。接收的难点在于:作为接收方,你不知道数据什么时候会来,必须先检测到起始位(即 RX 引脚从高电平跳变到低电平),然后才能开始按位周期采样。这里有两个关键点:起始位下降沿的检测和每个数据位的采样时机。常见做法是把 RX 引脚配置成外部中断输入,检测下降沿触发。一旦下降沿到来,立即启动一个延时计时,等半个位周期后采样 RX 电平,确认确实是低电平(排除毛刺干扰),然后每隔一个位周期采样一次,连续采 8 次得到 8 个数据位,最后确认停止位是高电平。void EXTI_IRQHandler(void) { if (EXTI_GetITStatus(UART_RX_EXTI_LINE) != RESET) { EXTI_ClearITPendingBit(UART_RX_EXTI_LINE); // 等半个位周期,避开起始位的前沿毛刺 delay_us(BIT_PERIOD_US / 2); if (HAL_GPIO_ReadPin(UART_RX_PORT, UART_RX_PIN) == GPIO_PIN_RESET) { // 确认是真正的起始位 uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { delay_us(BIT_PERIOD_US); if (HAL_GPIO_ReadPin(UART_RX_PORT, UART_RX_PIN) == GPIO_PIN_SET) { data |= (0x01 << i); } } // 跳过停止位 delay_us(BIT_PERIOD_US); } } }这段代码在理论上可行,但实际用起来问题不少。首先是时序精度问题:外部中断触发后进入中断处理函数,函数本身需要压栈、清中断标志、读引脚、延时,这一串操作已经消耗了不确定的指令周期,半个位周期的延时起点其实比真正的起始位中点晚了一些。其次是延时函数的误差会在 8 个数据位里逐步累积,如果每个位周期慢 1 微秒,第 8 个数据位的采样点就偏了 8 微秒,在 9600 波特率下还不算致命,在 38400 以上就很容易采错。所以我一般不建议在中断里用纯延时去做接收采样,更稳的做法是使用硬件定时器的输入捕获功能。把 RX 引脚连接到定时器的捕获通道,让定时器以微秒精度记录下降沿和上升沿的时刻,然后在主循环里根据相邻两次捕获的时间差解析出每一位的电平高低。这样可以绕开指令时延带来的误差,代价是占用了定时器资源。给读者的建议是:如果只是实现单向发送(比如调试输出),发送方向的模拟已经够用;如果需要双向通信且波特率高于 19200,优先考虑定时器捕获方案,或者直接换带多个 UART 的芯片。4.2 排查清单:五条高频翻车记录我把模拟串口常见的踩坑记录整理成了一份排查清单,每条都按「现象 → 原因 → 解决」的顺序来写,方便出问题时对照。现象一:9600 波特率下发送,接收方收到的前几个字节正常,后面的字节全是乱码。原因是延时函数的累积误差导致数据位采样点逐渐偏移,到第 4 位之后误差超过容差。解决方法是调整delay_us的系数,把实测的位周期误差控制在 ±2% 以内,发送完一个字节后额外增加 10 微秒的间隔,给接收方一点恢复时间。现象二:接收方完全收不到数据,示波器看 TX 引脚波形却正常。原因是 RX 引脚配置成了推挽输出模式,一直输出高电平,外部发来的低电平信号被内部驱动拉回来了。解决方法是接收前把 RX 引脚改成浮空输入或上拉输入,必要的时候在硬件上加一个限流电阻。现象三:发送时偶尔出现一位波形明显变宽,其他位正常。原因是发送过程中被中断打断,中断服务函数执行了十几微秒回来后继续延时时序,拉长了当前位。解决方法是在发送期间暂时屏蔽中断,或者把位周期稍微做短一点,留出中断耗时的余量。现象四:引脚空闲电平为低,波形完全对不上。原因是 GPIO 初始化时默认输出低电平,没有在初始化末尾主动拉高。解决方法是初始化后先写一次高电平,再开始发送数据。现象五:同一个代码在优化等级从 O0 改到 O2 后,波特率完全错乱。原因是delay_us的系数是按 O0 优化调出来的,循环体被编译器优化后循环次数对应的执行时间完全变了。解决方法是在延时函数里使用volatile修饰循环变量,而且换优化等级后重新用示波器校准一次延时参数。排查模拟串口问题时,工具比人靠谱。逻辑分析仪是最值的投入,设置好采样率和触发条件,抓一段波形直接测量每一位的时间宽度,问题出在哪一步一目了然。不推荐用万用表或人眼数周期的方式去排查时序问题,效率太低。5. 性能边界验证与一个实用技巧:把余量留给中断5.1 波特率上限的估算方法IO 模拟串口不是万能的,波特率的上限取决于你的延时精度和中断开销。用示波器实测过一组数据:主频 72MHz、延时系数按 O0 优化调校后,9600 波特率下位周期误差约 ±1%,19200 下约 ±2%,38400 下约 ±4%。38400 以上基本不建议用纯 IO 模拟,因为误差比例已经在容忍边界附近,环境温度变化、电源电压波动都可能让通信立刻翻车。估算方法很简单:用示波器量 100 个位周期的总时间,计算每个位周期的实际平均值和误差百分比。误差在 2% 以内才算稳定可用,超过 3% 就要考虑降低波特率或者换方案。另外要注意的是,内部 RC 振荡器的精度本来就有限,如果芯片用的是内部时钟,即使延时函数写得很准,时钟源的偏差也会反映到每一位上。要求高的时候用外部晶振,这是性价比最高的稳定性提升手段。5.2 一个实用技巧:发送期间临时关闭中断我在项目里用模拟串口发送调试日志时,遇到过一个很头疼的中断问题:系统里有一个 1 毫秒的定时器中断,用于软件计时和按键扫描,发送的过程中断被频繁触发,一个字节的 10 位经常被强行拉宽 1 到 2 位,接收方那边统计的误码率非常高。解决思路是在发送字节期间临时关闭中断,发完再恢复:void uart_send_byte_safe(uint8_t data) { __disable_irq(); uart_send_byte(data); __enable_irq(); }这段代码逻辑简单,但要注意关闭中断的时长必须可控。9600 波特率下发送一个字节大约耗时 1.04 毫秒,在那段时间里所有中断都被挂起,如果系统里有时间敏感的外设(比如电机控制的 PWM 更新、看门狗喂狗),超过阈值就会出问题。所以这个方案只适合中断负载不高的场景,喂狗操作要放在关闭中断之前或之后补上,不能放在被屏蔽的区间里。如果系统里中断太频繁,还有一个变体思路:把发送操作拆成两部分,起始位和前面 4 个数据位关中断发,后面几位开中断发,虽然波形上会有一个明显的断点,但对端接收时只要采样点没踩在断点附近就不会出错。这个技巧我用在了一个 38400 波特率的外设通信里,实测误码率从 15% 降到了 0。5.3 验证流程:一个可以固化的自检习惯模拟串口的验证不能只看打印正常就收工。从那以后我每次做完这套模拟串口,都强制走一遍完整的验证流程:先回环测试,把 TX 直接连到 RX,代码里自发自收,确认数据一致;再用逻辑分析仪抓一段波形,测量每一位的时间宽度,确保误差在 2% 以内;最后接上真实对端设备,连续发送 1000 帧随机数据,统计误码率,低于 0.1% 才算通过。这套流程看着繁琐,但能帮你在交付前把所有时序问题都暴露出来。希望帮到你,模拟串口这条路走通一次,之后再做任何 IO 模拟外设,心里都有底。本文还有配套的精品资源,点击获取