尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
两个GPIO模拟UART串口:时序原理与源码解析
简介面向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 用作 TXPB7 用作 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_us8 微秒意味着主频 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 模拟外设心里都有底。本文还有配套的精品资源点击获取
RELATED

相关推荐

Dev-C++报告窗口恢复与C++11支持开启实战指南

Dev-C++报告窗口恢复与C++11支持开启实战指南

Dev-C这个IDE,虽然官方停止维护已经很多年了,但直到今天,依然有大量初学者拿它写C/C的课程作业。我自己当年也被它折腾过不少回,印象最深的就是两个看似八竿子打不着、实际上都跟“默认设置”有关的问题:一是写着写着&…

📅 2026/10/20 10:16:05
NOR Flash与NAND Flash怎么选?从底层原理到选型实战全解析

NOR Flash与NAND Flash怎么选?从底层原理到选型实战全解析

聊存储选型的时候,总绕不开一个问题:NOR Flash和NAND Flash到底怎么选?说实话,我在刚入行那年也被这两个名字搞得头大,光看规格书觉得都挺像,都是掉电不丢数据的存储芯片,结果一上手画板子、调驱…

📅 2026/10/20 10:11:04
NOR Flash和NAND Flash到底有啥区别?一文讲透原理与选型

NOR Flash和NAND Flash到底有啥区别?一文讲透原理与选型

做硬件这些年,被问到最多的一个问题,不是单片机性能,也不是电源怎么布,而是“NOR Flash和NAND Flash到底有什么区别”。老实说,这题很有含金量。能真正把这两个Flash家族差异讲清楚的人,硬件成本意识、固件…

📅 2026/10/20 10:11:04
MORE NEWS

更多资讯

📰

基于LCL滤波的三相并网逆变器控制仿真设计(带参考资料)

✅作者简介:热爱科研的Matlab仿真开发者,擅长数学建模、数据处理、建模仿真、程序设计、完整代码获取、论文复现及科研仿真。🍎 往期回顾关注个人主页:Matlab科研工作室👇 关注我领取海量matlab电子书和数学建模资料 &…

📰

描述驱动Web UI自动化:基于YAML的回归测试框架设计与实践

干过几年Web UI自动化测试的人,应该都经历过这种崩溃瞬间:昨天还全绿的回归用例,今天早起一看,全部标红。改了一早上定位符之后,刚松了口气,下午前端又上个弹窗组件,整个执行从头再来一遍。时间…

📰

Abaqus内置黏弹性本构模型验证

一、Static,General分析步是否会激活Abaqus内置的黏弹性本构 1、先说结论,Static分析步里,默认不会激活Prony时域黏弹性的时变松弛/蠕变行为,只按瞬时弹性模量计算。就算材料库定义了 Viscoelastic(Prony),程序直接跳过黏弹性松弛…

📰

LangGraph生产级智能体实战:核心组件与编排经验

做智能体项目有一段时间了,最近两个月基本上把LangGraph的重要组件挨个用了一遍。最开始看官方文档的时候,脑子里全是新名词:State、Node、Edge、Conditional Edge、Checkpointer、Tool、Memory,说实话有点劝退。但实际跑通一个完…

📰

Surface Duo 刷机全攻略:ADB Sideload 避坑指南

简介:这份资源是一份面向微软 Surface Duo 手机用户的安卓刷机入门教程,专为看不懂官方文档、缺乏刷机经验的新手准备,作者以「白痴教程」自嘲,力求把复杂流程讲得通俗易懂。压缩包内仅含 1 个 docx 文档,体积约 145KB…

📰

从一段真实的 Electron IM 前端代码,看高级前端的工程化修养

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬