尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

GPIO模拟UART:原理、实现与嵌入式通信的软件解决方案

GPIO模拟UART:原理、实现与嵌入式通信的软件解决方案 1. 项目概述为什么需要模拟UART在嵌入式开发中UART通用异步收发传输器是最基础、最常用的通信接口之一用于设备间的串行数据交换。无论是单片机与传感器对话还是通过USB转串口模块与PC通信都离不开它。然而在实际项目中你可能会遇到一个尴尬的局面硬件资源用尽了。主控芯片的硬件UART外设数量是有限的比如一个常见的STM32F103系列可能只有2-3个硬件UART。当你的项目需要连接GPS模块、蓝牙模块、调试串口等多个设备时硬件UART口就显得捉襟见肘了。这时候“使用GPIO模拟UART”这项技术就派上了大用场。它的核心思想很简单既然硬件UART的本质是按照特定时序波特率、数据位、停止位、校验位在两根线上TX发送RX接收收发高低电平那么我们完全可以用两个通用的输入输出引脚GPIO通过软件精确控制它们的电平翻转时序来“模仿”硬件UART的行为。这相当于用软件的“软实力”去弥补硬件的“硬缺口”。这种做法听起来像是“穷人的办法”但它绝不仅仅是应急之策。首先它赋予了设计极大的灵活性。你可以将“软串口”分配到任意可用的GPIO上布线更方便。其次它是对底层通信协议一次绝佳的学习机会。当你亲手用代码实现每一位的起始、数据、停止的时序控制时会对UART协议有刻骨铭心的理解这是看一百遍数据手册都换不来的。当然它也有明显的代价会持续占用CPU资源进行位时序的精确控制对高波特率支持有限且稳定性通常不如硬件UART。但对于一些非实时性要求、低速通信如9600bps的传感器数据读取的场景它无疑是一个成本低廉且可靠的解决方案。2. UART协议核心与GPIO模拟原理拆解要模拟一个东西必须先吃透它的规则。UART通信是一种异步、串行、全双工的通信方式。说人话就是通信双方没有统一的时钟线来同步数据是一位接一位地在单根线上传输并且发送和接收可以同时进行需要两根独立的线。其核心在于对时序的精确约定这个约定就是通信协议。2.1 UART帧格式一帧数据的“样貌”一帧完整的UART数据绝不是简单地把8位数据发出去就完事了。它有一个标准的“包装”格式。我们以最常见的配置1位起始位8位数据位无校验位1位停止位波特率9600为例拆解一帧数据空闲状态在无数据传输时TX和RX线都保持在高电平逻辑‘1’。起始位这是一个低电平逻辑‘0’持续1个位的时间。它像一声哨响告诉接收方“注意我要开始发数据了”这是帧同步的关键。数据位紧接着起始位之后就是实际的有效数据从最低位LSB开始发送依次到最高位MSB。每一位都占用1个位的时间用高电平表示‘1’低电平表示‘0’。停止位在数据位之后是一个高电平逻辑‘1’持续1个或1.5个2个位的时间。它标志着本帧数据的结束并将线路拉回空闲状态为下一帧数据做准备。用波形图来理解最直观一条原本持续为高的电平线突然被拉低一个位时间起始位然后根据数据内容高低起伏8个位时间数据位最后再拉高至少一个位时间停止位。这个高低变化的序列就是我们要用GPIO去精确复现的“舞蹈动作”。2.2 关键参数波特率与位时间“位时间”是整个模拟过程的“节拍器”。它由“波特率”决定。波特率Baud Rate指的是每秒传输的符号数在UART中一个符号就是一位bit。所以波特率9600意味着每秒传输9600个位。位时间Bit Time 1 / 波特率。对于9600波特率位时间 1 / 9600 ≈ 104.17 微秒μs。 对于115200波特率位时间 1 / 115200 ≈ 8.68 微秒μs。注意这里的计算是理论值。在实际软件模拟中我们通常用定时器来产生这个时间间隔。但软件指令执行本身有开销所以实际设置的定时器中断周期可能需要略小于理论位时间或者在中断服务程序中采样点要设置在位的中间时刻以规避信号边沿的抖动这是提高稳定性的关键技巧。2.3 GPIO模拟的底层逻辑模拟发送TX相对简单程序控制一个GPIO引脚为输出模式然后按照帧格式依次在计算好的时间点将引脚电平设置为高或低。难点在于精确的延时。模拟接收RX则复杂得多程序需要将一个GPIO引脚设置为输入模式并持续检测其电平。一旦检测到起始位的下降沿就要启动一个定时器在后续每个位时间的中间点例如起始位开始后的1.5个位时间处去“采样”引脚电平将其认定为数据位。这要求代码对起始边沿的检测非常灵敏且定时器的时序必须高度精确。核心挑战在无硬件协助下用软件实现微秒级的精确延时和边沿检测同时不能阻塞主程序其他任务的运行。这通常需要借助定时器中断和外部中断或引脚电平变化中断来实现。3. 模拟UART的软件架构与实现方案选择用GPIO模拟UART在软件上有几种不同复杂度和资源占用率的实现方案。选择哪种取决于你的主控芯片性能、可用的外设资源以及对通信实时性的要求。3.1 纯延时轮询法最简单粗暴这是最入门级的方法适合对时序要求不严、波特率很低如1200, 2400且系统简单的场景。发送在发送每个位之前用一个for循环或while循环进行空操作实现粗略的延时然后设置GPIO电平。接收在一个循环里不断读取RX引脚检测到低电平后延时半个位时间确认是否为起始位然后每隔一个位时间采样一次数据。优点代码极其简单不依赖任何外设。致命缺点阻塞式在发送或接收一帧数据期间CPU被完全占用无法执行其他任何任务。极不精确软件循环延时受编译器优化、中断打断等因素影响巨大波特率误差大通信极不稳定。无法实现全双工很难同时进行发送和接收。实操心得除非是教学演示或者驱动一个慢到令人发指的器件否则在实际项目中应避免使用这种方法。它几乎无法用于任何可靠的数据交换。3.2 定时器中断驱动法最主流可靠这是最常用、最可靠的软件模拟UART实现方式。其核心思想是利用一个定时器产生精确的位时间基准中断用一个状态机来管理发送和接收流程。发送流程当上层应用有数据要发送时将数据帧含起始位、数据位、停止位按位放入一个缓冲区或队列。开启定时器中断周期设置为位时间。在定时器中断服务程序ISR中从缓冲区取出下一个要发送的位将其输出到TX GPIO。发送完一帧后关闭定时器中断或进入空闲状态。接收流程将RX GPIO配置为输入并使能其外部中断或引脚变化中断触发条件为下降沿检测起始位。当检测到起始位下降沿时立即关闭该外部中断防止干扰并启动定时器。关键点将第一次定时器中断的触发时间设置为1.5个位时间。这样第一个中断到来时正好位于第一个数据位LSB的中间此时采样电平最稳定。在定时器ISR中采样RX引脚电平存入接收缓冲区。采样8次对应8个数据位后再采样停止位可用于校验然后重置状态重新使能外部中断等待下一帧。优点高精度依赖硬件定时器时序非常精准。非阻塞发送和接收过程在中断中完成主循环可以处理其他任务。可实现全双工使用两个独立的定时器和状态机可以同时模拟发送和接收。缺点需要占用一个硬件定时器资源。中断频率较高例如9600波特率时中断频率为9600Hz对CPU有一定负载。3.3 高级方案DMAPWM/定时器发送优化对于发送端如果主控芯片支持可以有更高效的方案进一步解放CPU。PWM模拟TX将GPIO配置为复用功能连接到定时器的PWM输出通道。将要发送的帧高低电平序列预先转换成一个电平数组1为高0为低。然后利用DMA将这个数组自动搬运到定时器的比较寄存器CCR中。定时器会根据CCR值自动控制输出电平的翻转。这样发送一整帧甚至多帧数据完全不需要CPU干预。定时器DMA模拟TX与PWM类似但更灵活。将GPIO配置为普通输出用定时器更新中断触发DMA由DMA将电平数组逐个搬运到GPIO的输出数据寄存器ODR。这也实现了“零CPU开销”的发送。优点发送效率极高CPU占用率几乎为零。缺点实现复杂需要芯片支持且通常只优化了发送端接收端仍需用定时器中断法。方案选择建议对于绝大多数应用定时器中断驱动法是最佳平衡点。它提供了可靠的精度和适中的资源占用代码结构清晰易于移植和调试。下文将重点围绕这种方法展开。4. 基于定时器中断的模拟UART详细实现我们以ARM Cortex-M内核的MCU如STM32为例使用一个基本定时器如TIM6/TIM7或通用定时器来详细实现一个9600波特率、8N1格式的模拟UART。4.1 硬件与软件准备硬件连接选择两个GPIO引脚例如PA5作为模拟TXPA6作为模拟RX。确保这两个引脚是普通IO没有被复用到其他特殊功能上。软件准备初始化系统时钟特别是定时器的时钟源如APB1。配置GPIOTX引脚为推挽输出初始化为高电平空闲状态。RX引脚为上拉输入或浮空输入外部加上拉电阻以便稳定检测高电平和下降沿。配置一个定时器用于产生位时间中断。以72MHz系统时钟9600波特率为例位时间 1/9600 ≈ 104.17μs。定时器时钟预分频PSC设置为71则计数器时钟 72MHz / (711) 1MHz即每计数一次为1μs。自动重装载值ARR设置为103则定时器中断周期 (1031) * 1μs 104μs与理论值104.17μs误差极小在可接受范围内。配置RX引脚对应的外部中断线如EXTI触发方式为下降沿触发。4.2 发送状态机实现发送过程是一个典型的状态机。我们定义几个状态TX_IDLE空闲TX_START发送起始位TX_DATA发送数据位TX_STOP发送停止位。// 发送状态枚举 typedef enum { TX_STATE_IDLE, TX_STATE_START_BIT, TX_STATE_DATA_BITS, TX_STATE_STOP_BIT } tx_state_t; // 发送控制结构体 typedef struct { tx_state_t state; uint8_t data_byte; // 要发送的字节 uint8_t bit_index; // 当前发送到第几位 (0-7) uint8_t stop_bits; // 停止位个数 } soft_uart_tx_t; volatile soft_uart_tx_t soft_uart_tx; // 启动发送函数 (供上层调用) void soft_uart_send_byte(uint8_t data) { // 如果发送器忙可以在这里选择等待或返回错误 if(soft_uart_tx.state ! TX_STATE_IDLE) { // 处理忙状态例如丢包或等待 return; } soft_uart_tx.data_byte data; soft_uart_tx.bit_index 0; soft_uart_tx.state TX_STATE_START_BIT; soft_uart_tx.stop_bits 1; // 1位停止位 // 手动执行第一次状态切换输出起始位 SOFT_UART_TX_GPIO_PORT-BSRR SOFT_UART_TX_PIN 16; // 输出低电平 (起始位) // 启动定时器开始发送流程 __HAL_TIM_SET_AUTORELOAD(htim6, BIT_TIME_TICKS - 1); // 设置位时间 HAL_TIM_Base_Start_IT(htim6); } // 定时器中断服务程序 (发送部分) void TIM6_IRQHandler(void) { if(__HAL_TIM_GET_FLAG(htim6, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); switch(soft_uart_tx.state) { case TX_STATE_START_BIT: // 起始位已发送完毕切换到发送数据位状态 soft_uart_tx.state TX_STATE_DATA_BITS; // 输出LSB (bit0) if(soft_uart_tx.data_byte 0x01) { SOFT_UART_TX_GPIO_PORT-BSRR SOFT_UART_TX_PIN; // 输出高 } else { SOFT_UART_TX_GPIO_PORT-BSRR SOFT_UART_TX_PIN 16; // 输出低 } break; case TX_STATE_DATA_BITS: soft_uart_tx.bit_index; if(soft_uart_tx.bit_index 8) { // 发送下一个数据位 if(soft_uart_tx.data_byte (1 soft_uart_tx.bit_index)) { SOFT_UART_TX_GPIO_PORT-BSRR SOFT_UART_TX_PIN; } else { SOFT_UART_TX_GPIO_PORT-BSRR SOFT_UART_TX_PIN 16; } } else { // 8位数据发送完毕切换到停止位 soft_uart_tx.state TX_STATE_STOP_BIT; SOFT_UART_TX_GPIO_PORT-BSRR SOFT_UART_TX_PIN; // 输出高 (停止位) } break; case TX_STATE_STOP_BIT: // 停止位发送完毕回到空闲状态 soft_uart_tx.state TX_STATE_IDLE; HAL_TIM_Base_Stop_IT(htim6); // 停止定时器 // 可以在这里触发一个发送完成回调函数 break; case TX_STATE_IDLE: default: // 不应该进入此分支安全起见停止定时器 HAL_TIM_Base_Stop_IT(htim6); break; } } }4.3 接收状态机实现接收过程同样是一个状态机但由外部中断启动。状态包括RX_IDLE等待起始位RX_START确认起始位RX_DATA接收数据位RX_STOP接收停止位。// 接收状态枚举 typedef enum { RX_STATE_IDLE, RX_STATE_START_BIT, RX_STATE_DATA_BITS, RX_STATE_STOP_BIT } rx_state_t; // 接收控制结构体 typedef struct { rx_state_t state; uint8_t data_byte; uint8_t bit_index; uint8_t stop_bits; } soft_uart_rx_t; volatile soft_uart_rx_t soft_uart_rx; volatile uint8_t rx_buffer[RX_BUF_SIZE]; volatile uint16_t rx_write_idx 0; // RX引脚外部中断服务程序 void EXTI9_5_IRQHandler(void) { // 假设RX引脚在EXTI Line6上 if(__HAL_GPIO_EXTI_GET_FLAG(SOFT_UART_RX_PIN) ! RESET) { __HAL_GPIO_EXTI_CLEAR_FLAG(SOFT_UART_RX_PIN); // 只有在空闲状态检测到下降沿才认为是起始位 if(soft_uart_rx.state RX_STATE_IDLE) { // 禁用外部中断防止在接收过程中被干扰 HAL_NVIC_DisableIRQ(EXTI9_5_IRQn); soft_uart_rx.state RX_STATE_START_BIT; soft_uart_rx.data_byte 0; soft_uart_rx.bit_index 0; soft_uart_rx.stop_bits 1; // 关键步骤设置定时器在1.5个位时间后首次中断用于采样第一个数据位的中点 __HAL_TIM_SET_COUNTER(htim7, 0); __HAL_TIM_SET_AUTORELOAD(htim7, (BIT_TIME_TICKS * 3 / 2) - 1); // 1.5倍位时间 HAL_TIM_Base_Start_IT(htim7); } } } // 定时器7中断服务程序 (用于接收采样) void TIM7_IRQHandler(void) { if(__HAL_TIM_GET_FLAG(htim7, TIM_FLAG_UPDATE) ! RESET) { __HAL_TIM_CLEAR_FLAG(htim7, TIM_FLAG_UPDATE); switch(soft_uart_rx.state) { case RX_STATE_START_BIT: // 1.5个位时间后理论上位于第一个数据位中点应采样数据位 // 但为了状态机统一我们先切换到数据位状态并立即采样 soft_uart_rx.state RX_STATE_DATA_BITS; __HAL_TIM_SET_AUTORELOAD(htim7, BIT_TIME_TICKS - 1); // 后续按1个位时间采样 // 注意这里不break继续执行RX_STATE_DATA_BITS的逻辑进行第一次采样 case RX_STATE_DATA_BITS: // 采样当前RX引脚电平 if(HAL_GPIO_ReadPin(SOFT_UART_RX_GPIO_PORT, SOFT_UART_RX_PIN) GPIO_PIN_SET) { soft_uart_rx.data_byte | (1 soft_uart_rx.bit_index); } soft_uart_rx.bit_index; if(soft_uart_rx.bit_index 8) { // 8位数据接收完毕切换到停止位状态 soft_uart_rx.state RX_STATE_STOP_BIT; } break; case RX_STATE_STOP_BIT: // 采样停止位电平可用于帧错误检测 // uint8_t stop_bit HAL_GPIO_ReadPin(...); // if(stop_bit ! 1) { /* 帧错误处理 */ } // 一帧接收完成存入缓冲区 if(rx_write_idx RX_BUF_SIZE) { rx_buffer[rx_write_idx] soft_uart_rx.data_byte; } else { // 缓冲区溢出处理 } // 重置状态重新使能外部中断等待下一帧 soft_uart_rx.state RX_STATE_IDLE; HAL_TIM_Base_Stop_IT(htim7); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn); break; case RX_STATE_IDLE: default: HAL_TIM_Base_Stop_IT(htim7); HAL_NVIC_EnableIRQ(EXTI9_5_IRQn); break; } } }关键技巧接收时序的“1.5倍起始位时间”是软件模拟UART稳定性的灵魂。它确保了第一次采样点位于第一个数据位的正中央避开了由于起始边沿检测抖动或波特率微小偏差导致的位边界区域大大提高了容错率。5. 性能优化、稳定性提升与高级功能一个基础的模拟UART跑起来后我们还需要考虑如何让它更健壮、更高效。5.1 波特率自适应性调整硬件UART有自动波特率检测功能软件模拟也可以尝试实现简易版本。一种思路是在通信开始前由主机发送一个特定的同步字节如0x55二进制为01010101。从机通过测量两个上升沿或下降沿之间的时间间隔可以反推出实际的位时间从而动态调整定时器的ARR值。这需要用到输入捕获功能实现起来较复杂但对主从机时钟偏差较大的场景很有用。5.2 缓冲区与流量控制上面的示例代码使用了简单的全局变量作为缓冲区这在中断服务程序中直接读写存在风险虽然volatile可以缓解。更稳健的做法是使用环形缓冲区Ring Buffer。发送环形缓冲区上层应用将待发送的字节放入发送环形缓冲区。发送状态机在定时器中断中从缓冲区中取出字节发送。这样上层可以连续写入多个字节而不会阻塞。接收环形缓冲区接收状态机将收到的字节放入接收环形缓冲区。主循环从缓冲区中读取并处理数据。这解决了中断中不能进行复杂处理以及数据覆盖的问题。// 简易环形缓冲区实现示例 typedef struct { uint8_t *buffer; uint16_t size; volatile uint16_t head; // 写索引 volatile uint16_t tail; // 读索引 } ring_buffer_t; // 判断缓冲区是否满/空以及读写操作都需要考虑索引回绕5.3 错误检测与处理硬件UART有帧错误、溢出错误等标志软件模拟也可以加入帧错误在停止位采样时如果电平不是高电平则说明可能存在噪声、波特率不匹配或线路问题可以置位一个帧错误标志。溢出错误当接收环形缓冲区已满但又有新数据到来时应置位溢出错误标志并选择丢弃新数据或覆盖旧数据。超时错误如果在一帧数据接收过程中长时间没有收到下一个位例如超过10个位时间可以认为通信中断应复位接收状态机重新等待起始位。这可以通过一个看门狗定时器实现。5.4 降低CPU中断负载高波特率下定时器中断非常频繁。为了减轻CPU负担可以使用DMA辅助发送如前文所述。如果芯片支持将定时器中断优先级设置为较低避免打断更关键的任务。在满足时序要求的前提下尽量使用低波特率。6. 实测调试技巧与常见问题排查理论实现后真正的挑战在调试环节。你需要一个逻辑分析仪或者一个带串口功能的示波器这是调试数字时序的“眼睛”。6.1 调试工具与连接逻辑分析仪这是最佳选择。将通道1连接到你的模拟TX引脚通道2连接到模拟RX引脚。设置好采样率和触发条件如TX引脚下降沿触发。可以清晰看到每一位的波形、宽度以及起始位、数据位、停止位的结构。对比发送的数据和波形是否一致。USB转TTL串口模块将你的模拟UART的TX、RX与模块的RX、TX交叉连接模块的USB端插入电脑。用串口调试助手如SecureCRT、Putty、或者免费的SSCOM发送数据给你的模拟UART并接收模拟UART发回的数据。这是功能验证最直接的方法。示波器如果只有示波器可以设置为单次触发触发条件为下降沿观察一帧数据的波形。测量位时间是否准确。6.2 常见问题与解决方案速查表问题现象可能原因排查思路与解决方案完全收不到数据1. 引脚配置错误输入/输出弄反。2. 电平不匹配3.3V vs 5V。3. 外部中断未正确触发。4. 定时器未启动或配置错误。1. 用万用表或点灯法确认引脚电平能否被控制/读取。2. 确认通信双方电平标准必要时加电平转换电路。3. 在外部中断服务函数入口处设置一个断点或翻转一个测试引脚看是否进入。4. 检查定时器时钟源、分频、ARR值计算是否正确。收到乱码1.波特率不匹配最常见。2. 数据位/停止位配置不一致。3. 采样点位置不对特别是接收。4. 中断被更高优先级中断长时间阻塞。1.用逻辑分析仪测量实际位时间与理论值对比。调整定时器ARR值。2. 确认双方都是8N1格式。检查停止位发送和采样逻辑。3.重点检查接收首次采样是否为1.5个位时间。逻辑分析仪看采样点是否在位的中间。4. 检查系统中其他中断服务程序的执行时间优化代码或调整中断优先级。只能接收单个字节不能连续接收1. 接收状态机在完成一帧后没有正确复位到IDLE状态。2. 外部中断在接收完成后没有重新使能。3. 停止位处理异常导致状态机卡住。1. 在接收完成停止位后的逻辑里确保状态state被重置为RX_STATE_IDLE。2. 确认HAL_NVIC_EnableIRQ被调用。3. 即使停止位错误也应强制复位状态机并记录错误。高波特率下如115200通信不稳定1. 中断响应时间、执行时间接近或超过位时间。2. 系统主频太低。3. 没有使用优化等级如-O1, -O2。1. 用逻辑分析仪测量中断服务程序的实际执行时间。必须远小于位时间115200时位时间约8.7μs。2. 提升MCU主频。3. 在编译器设置中开启优化减少不必要的开销。4.考虑放弃纯软件模拟或使用硬件辅助方案PWMDMA。发送数据正常但对方设备无响应1. 对方设备需要特定的初始化命令或协议。2. 硬件流控RTS/CTS未处理。3. 线路干扰或接触不良。1. 先用电脑串口助手确认你的模拟UART发送的数据格式完全正确。2. 查阅对方设备手册确认通信协议。3. 如果对方有流控引脚尝试将其固定为有效电平如使能。4. 检查硬件连接缩短导线或加上拉电阻。6.3 一个宝贵的避坑经验中断嵌套与优先级在混合了外部中断起始位检测和定时器中断位采样的系统中中断优先级设置不当会导致灾难。一个典型的坑是定时器中断优先级高于外部中断。假设定时器中断正在执行采样此时一个噪声脉冲触发了外部中断下降沿。如果外部中断优先级更高它会打断定时器中断导致采样时序完全错乱。因此必须将用于位采样的定时器中断优先级设置为高于用于起始位检测的外部中断优先级。这样一旦接收流程开始定时器中断就不会被意外的起始位检测打断保证了采样时序的完整性。我个人在多个项目中的体会是GPIO模拟UART是一项“知其然更要知其所以然”的基本功。它迫使你去关注最底层的时序细节这种经验对理解任何通信协议都有莫大帮助。虽然现在MCU的UART外设越来越多但在引脚紧张、需要动态配置通信端口或者进行协议教学时它依然是一个非常有价值的工具。最后一个小技巧在项目初期不妨用硬件UART进行开发和调试待稳定后再将代码移植到模拟UART上并做好充分的测试这样可以大大降低开发风险。
返回列表