MCU模拟串口实现:外部中断与定时器精准控制异步通信时序
1. 项目概述为什么要在MCU上模拟串口在嵌入式开发中串口UART几乎是调试和通信的“生命线”。无论是打印日志、与传感器对话还是与上位机交换数据都离不开它。主流MCU如STM32、GD32、ESP32等都内置了多个硬件串口按理说完全够用。但现实情况往往更复杂你可能遇到过硬件串口引脚被其他功能如SPI、I2C、LCD占满的情况或者为了极致成本控制选用的是一款只有单串口甚至没有硬件串口的超低价MCU比如某些8位机又或者你需要在一个非标准波特率下工作而硬件串口的分频器无法精确产生这个频率。这时“用IO口模拟串口”就成了一个极具价值的备用方案。它不依赖专用的硬件收发器仅通过一个普通GPIO引脚配合外部中断和定时器就能在软件层面实现串行数据的收发。听起来像是“螺蛳壳里做道场”但这项技能能让你在资源受限或引脚冲突的绝境中硬生生开辟出一条通信通道。我最近在一个基于GD32E103的项目里就遇到了这个问题PA9和PA10默认串口0被用于了USB而剩下的串口1引脚又和调试接口冲突最终就是靠模拟串口解决了与一个老式485模块的通信需求。这个项目的核心就是利用外部中断来精准捕获数据帧的起始位下降沿然后用定时器产生严格的时间基准在正确的时刻对IO口进行采样接收或电平翻转发送从而模拟出硬件串口的数据时序。整个过程就像在指挥一场微秒级的交响乐任何一个音符定时出错整段旋律数据就会走调。接下来我会详细拆解其设计思路、关键实现细节并附上经过实测验证的程序代码和示波器测试结果。2. 整体设计与核心思路拆解模拟串口的核心目标是用最少的硬件资源可靠地实现异步串行通信协议。我们需要在软件中重建硬件UART的三个关键功能起始位检测、位时序生成、数据位采样/发送。2.1 协议回顾与挑战分析标准异步串口协议如8N18位数据、无校验、1位停止位的一帧数据如下图所示。空闲时线路为高电平起始位是一个位时间的低电平然后是数据位LSB先行最后是停止位高电平。空闲态(高) - 起始位(低) - D0(LSB) - D1 - ... - D7(MSB) - 停止位(高) - 空闲态(高)对于接收而言最大的挑战在于如何确定每个数据位的中心点进行采样。硬件UART内部有一个高倍率的时钟通常是波特率的16倍或8倍进行过采样和投票从而找到最佳采样点。我们用软件实现无法做到如此高频的轮询因此策略必须改变。2.2 “外部中断定时器”方案的精髓我们的方案采用“事件驱动时间基准”的思路完美解决了上述挑战起始位检测外部中断将用于接收的GPIO配置为双边沿或下降沿触发的外部中断。当总线从空闲高电平跳变到起始位低电平时立刻触发中断。这个中断是我们的“发令枪”标志着数据帧的开始。位时序生成定时器在起始位中断中启动一个定时器。这个定时器的周期严格设置为一个位时间例如波特率9600时位时间为104.16微秒。定时器每中断一次就代表到了下一个位的边界。数据位处理中断服务程序在定时器中断服务程序ISR中我们根据当前是第几次中断来决定当前需要处理的是第几位数据。对于接收在接近每个位时间的中心点例如在定时器中断发生后延迟半个位时间再采样或者直接在定时器中断里采样前提是中断响应足够快且精确去读取GPIO的电平并将其移位存入接收缓冲区。对于发送在定时器中断中根据待发送数据的当前位将GPIO输出对应的电平0为低1为高。这个方案的优点在于其高效和精准。外部中断确保了起始位捕获的即时性定时器则提供了稳定的时间基准避免了软件延时循环带来的巨大误差和CPU占用。整个通信过程由中断驱动主循环可以完全不受影响地处理其他任务。2.3 方案选型与硬件考量定时器选择优先选择MCU中精度高、易于配置的通用定时器如TIM2, TIM3。对于没有此类定时器的8位机可以使用片上的Timer0/1/2。关键是要能产生微秒级的中断且中断优先级要设置得当。IO口选择必须选择支持外部中断功能的引脚。在STM32/GD32中并不是所有GPIO都支持外部中断通常需要查阅芯片数据手册的“复用功能”或“中断映射”章节。例如PA0-PA15可能都支持但需要映射到特定的EXTI线。中断优先级配置这是一个关键细节。外部中断用于检测起始位的优先级应该设置为最高因为它必须被立即响应否则可能错过起始位。定时器中断的优先级可以稍低但必须高于其他可能长时间阻塞中断的例程。如果两者属于不同的中断向量需要仔细配置NVIC。关于GD32E103的PA15正如热搜词提到的GD32E103的PA15默认是JTAG的引脚用于调试。如果想将其用作普通GPIO甚至外部中断必须首先禁用JTAG功能仅启用SWD模式或者完全禁用调试引脚复用。这通常在系统初始化时通过配置AFIO或相应的复用重映射寄存器来完成。这是一个经典的“坑点”如果不对该引脚进行解复用操作你将无法控制其电平。3. 核心细节解析与实操要点理解了整体框架后我们深入到骨髓看看几个最容易出错的细节。这些细节决定了模拟串口的稳定性和可靠性上限。3.1 定时器精度与波特率计算这是模拟串口的生命线。定时器的计数值必须精确对应一个位时间。计算公式如下定时器周期ARR 系统时钟频率 / 定时器预分频系数 * 波特率例如对于STM32F103系统时钟72MHz要实现9600波特率先确定预分频系数PSC。为了获得更精细的调整我们通常让定时器工作在较高的计数频率。设PSC71则定时器时钟 72MHz / (711) 1MHz。此时1MHz的定时器每个计数是1微秒。9600波特率的一个位时间是 1 / 9600 ≈ 104.1667 微秒。因此自动重载值 ARR 104.1667 us / 1 us 104.1667。取整为104。误差分析使用ARR104实际位时间 104 us实际波特率 ≈ 1/104us ≈ 9615 Hz误差约为 (9615-9600)/9600 ≈ 0.16%。这个误差在标准异步串口协议通常允许2-3%的误差的容限范围内通信是稳定的。注意对于更高的波特率如115200位时间仅约8.68微秒。这对定时器中断的响应时间和代码执行效率提出了苛刻要求。中断服务程序必须极其精简否则可能无法在下一个中断到来前完成本次操作导致数据错乱。通常115200是软件模拟串口在主流Cortex-M内核MCU上的一个实用上限。3.2 外部中断的防抖与起始位验证总线上的噪声或毛刺可能意外触发下降沿中断。因此在外部中断服务程序中不能直接认定这就是合法的起始位。必须加入起始位验证逻辑进入外部中断后立即暂时关闭该外部中断防止在帧接收期间被干扰。延迟一个极短的时间例如延时到起始位的中间点约半个位时间。这个延时可以用简单的循环或另一个短定时器实现。再次读取GPIO电平。如果此时电平仍然是低电平则可以确认为有效的起始位。如果是高电平则判定为噪声干扰重新开启外部中断并退出。验证通过后初始化接收状态机清空缓冲区、设置位计数器为0等并启动位定时器。这个步骤虽然增加了少许复杂性但能极大提升在电气环境不佳的场景下的通信鲁棒性。3.3 接收与发送的状态机设计模拟串口的逻辑本质是一个状态机。我们需要清晰地定义在不同状态下定时器中断应该做什么。接收状态机以8N1为例状态0等待定时器未启动只有外部中断开启。状态1起始位确认外部中断触发验证通过后启动定时器。将第一次定时器中断视为对起始位的确认并不采样数据。可以设置一个bit_count -1表示下一次中断才进入数据位采样。状态2采样数据位从第2次到第9次定时器中断对应D0到D7在中断中读取IO电平并右移存入接收字节变量。状态3停止位处理第10次定时器中断理论上对应停止位。此时应读取IO电平并期望它为高电平。如果为低则可能发生帧错误。无论对错停止位后关闭定时器重新开启外部中断并将接收到的完整字节存入环形缓冲区供主程序读取。发送状态机状态0空闲IO输出高电平。当主程序调用发送函数后准备数据将IO拉低发送起始位启动定时器进入状态1。状态1发送数据位在定时器中断中根据当前bit_count将数据的对应位从LSB开始输出到IO口。状态2发送停止位数据位发送完毕后在中断中将IO拉高并维持一个位时间。完成后关闭定时器通知主程序发送完成。使用状态变量如rx_state,tx_state,bit_count来清晰地管理这个过程代码会更容易编写和维护。4. 实操过程与核心代码实现下面我将以STM32F103C8T6同样适用于GD32系列注意时钟配置差异为例展示关键代码片段。我们将实现一个9600波特率、8N1格式的模拟串口使用PA0作为接收引脚EXTI0PA1作为发送引脚TIM2作为位定时器。4.1 硬件与时钟初始化首先初始化用于接收的GPIO为上拉输入并配置外部中断初始化用于发送的GPIO为推挽输出。// GPIO 初始化 void IO_UART_Init(void) { // 1. 使能时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. 配置PA0为上拉输入 (RX) GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入空闲时为高电平 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置PA1为推挽输出 (TX)默认输出高电平 GPIO_InitStructure.GPIO_Pin GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_1); // 初始化为高电平空闲态 // 4. 配置PA0连接到EXTI0 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); // 5. 配置EXTI0为下降沿触发 EXTI_InitTypeDef EXTI_InitStructure; EXTI_InitStructure.EXTI_Line EXTI_Line0; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; // 下降沿触发起始位 EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); // 6. 配置NVIC设置EXTI0中断优先级最高 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; // 抢占优先级最高 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }4.2 定时器初始化配置TIM2为向上计数自动重载以1MHz的频率计数周期为104个计数对应104us。void TIM2_Init(void) { // 1. 使能TIM2时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 2. 初始化TIM2 TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period 104 - 1; // ARR: 104个计数产生104us中断 TIM_TimeBaseStructure.TIM_Prescaler 72 - 1; // PSC: 72分频72MHz/721MHz TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 3. 使能TIM2更新中断 TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); // 4. 配置TIM2中断优先级低于EXTI0 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 优先级低于EXTI0 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 注意先不启动定时器等待起始位触发 // TIM_Cmd(TIM2, ENABLE); }4.3 全局变量与状态定义我们需要一些全局变量来维护接收和发送的状态。// 接收相关 volatile uint8_t rx_byte 0; // 正在组装的字节 volatile int8_t rx_bit_count -1; // 位计数器-1表示起始位确认阶段 volatile uint8_t rx_buffer[128]; // 环形接收缓冲区 volatile uint16_t rx_wr_index 0; volatile uint16_t rx_rd_index 0; // 发送相关 volatile uint8_t tx_byte 0; volatile int8_t tx_bit_count -1; volatile uint8_t tx_busy 0; // 发送忙标志4.4 外部中断服务程序起始位检测这是整个接收过程的触发器。void EXTI0_IRQHandler(void) { if(EXTI_GetITStatus(EXTI_Line0) ! RESET) { // 1. 立即清除中断标志并暂时关闭EXTI0中断防止干扰 EXTI_ClearITPendingBit(EXTI_Line0); EXTI-IMR ~EXTI_Line0; // 暂时屏蔽EXTI0 // 2. 简易起始位验证延时约半个位时间后再次检测 // 这里使用一个简单的循环延时实际项目可用更精确的定时器 for(volatile int i0; i50; i); // 粗略延时约52us (半个位时间) if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) Bit_RESET) { // 确认是有效的起始位 rx_byte 0; rx_bit_count -1; // 下一次TIM2中断将进入起始位确认阶段 // 3. 重置并启动位定时器 TIM2-CNT 0; TIM_Cmd(TIM2, ENABLE); } else { // 是噪声干扰重新开启外部中断 EXTI-IMR | EXTI_Line0; } } }4.5 定时器中断服务程序位时序核心这是模拟串口的“心脏”所有位的采样和发送都在这里调度。void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // --- 接收处理 --- if (rx_bit_count -1 rx_bit_count 8) { // 处理起始位确认和数据位采样 if (rx_bit_count -1) { // 第一次中断对应起始位中间点不采样只是进入数据位周期 rx_bit_count 0; } else { // 数据位采样点在位的中间偏后 uint8_t bit_val GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0); rx_byte | (bit_val rx_bit_count); // LSB first rx_bit_count; } } else if (rx_bit_count 8) { // 第9次中断对应停止位 // 理论上应采样到高电平这里可做帧错误检查 rx_bit_count -2; // 标志一帧接收完成 // 关闭定时器准备恢复 TIM_Cmd(TIM2, DISABLE); // 将接收到的字节存入环形缓冲区 uint16_t next_wr_index (rx_wr_index 1) % 128; if (next_wr_index ! rx_rd_index) { // 缓冲区未满 rx_buffer[rx_wr_index] rx_byte; rx_wr_index next_wr_index; } // 重新开启外部中断等待下一帧 EXTI-IMR | EXTI_Line0; } // --- 发送处理 --- (可与接收逻辑并存通过状态机区分) if (tx_busy) { if (tx_bit_count -1) { // 发送起始位后的第一次中断开始发送LSB tx_bit_count 0; if (tx_byte 0x01) { GPIO_SetBits(GPIOA, GPIO_Pin_1); } else { GPIO_ResetBits(GPIOA, GPIO_Pin_1); } tx_byte 1; } else if (tx_bit_count 7) { // 发送剩余数据位 tx_bit_count; if (tx_byte 0x01) { GPIO_SetBits(GPIOA, GPIO_Pin_1); } else { GPIO_ResetBits(GPIOA, GPIO_Pin_1); } tx_byte 1; } else if (tx_bit_count 7) { // 发送第8位MSB后下一次中断发送停止位 tx_bit_count; } else if (tx_bit_count 8) { // 发送停止位 GPIO_SetBits(GPIOA, GPIO_Pin_1); tx_bit_count; } else { // 停止位发送完毕结束发送 tx_busy 0; TIM_Cmd(TIM2, DISABLE); // 如果接收也在用这里需要更精细的控制 // 可以在这里设置一个发送完成标志通知主程序 } } } }4.6 发送函数提供一个供主程序调用的发送API。void IO_UART_SendByte(uint8_t data) { // 等待上一次发送完成 while(tx_busy); // 准备发送 tx_busy 1; tx_byte data; tx_bit_count -1; // 发送起始位 GPIO_ResetBits(GPIOA, GPIO_Pin_1); // 拉低起始位 // 重置并启动定时器 TIM2-CNT 0; TIM_Cmd(TIM2, ENABLE); // 定时器中断会接管后续位的发送 }4.7 主程序示例一个简单的回环测试主程序。int main(void) { // 初始化系统时钟等... SystemInit(); IO_UART_Init(); TIM2_Init(); while(1) { // 检查是否有数据收到 if(rx_rd_index ! rx_wr_index) { uint8_t received_data rx_buffer[rx_rd_index]; rx_rd_index (rx_rd_index 1) % 128; // 将收到的数据原样发送回去回环测试 IO_UART_SendByte(received_data); } // 主循环可以处理其他任务 // ... } }5. 测试结果、问题排查与优化技巧理论再完美也需要实战检验。我使用GD32E103开发板将PA0和PA1短接实现自发自收。同时将发送引脚PA1连接到逻辑分析仪或示波器以观察波形。5.1 测试结果分析使用串口调试助手如SSCOM、XCOM向模拟串口发送数据“0x55”二进制01010101和“0xAA”二进制10101010这两个是经典的测试图案可以清晰看到每一位的跳变。逻辑分析仪捕获波形9600波特率8N1起始位一个精确的104us低电平。数据位8个宽度均为104us的方波电平与0x55/0xAA的位序列完全对应LSB先行。停止位一个精确的104us高电平。帧间隔符合协议要求。测量多个位的时间宽度误差在±1微秒以内主要由中断响应延迟和代码执行时间抖动引起在9600波特率下完全可接受。连续发送大量数据误码率极低证明了方案的稳定性。5.2 常见问题与排查实录在实际调试中你几乎一定会遇到下面这些问题问题完全收不到数据或者数据全是乱码。排查步骤检查硬件连接确保TX、RX交叉连接共地。如果是自发自收确保短接牢固。检查中断优先级这是最常见的原因。确保EXTI起始位中断的抢占优先级最高且没有被其他长时间中断阻塞。如果定时器中断优先级高于EXTI可能在起始位触发后定时器中断频繁抢占导致无法及时响应下一次EXTI中断。检查波特率计算用示波器测量一个位的时间是否准确为104us9600波特率。如果偏差过大检查系统时钟配置和定时器PSC、ARR计算。检查IO口模式接收引脚必须是浮空输入或上拉输入确保空闲高电平发送引脚必须是推挽输出。验证起始位在EXTI0_IRQHandler中设置一个GPIO翻转来调试看起始位下降沿是否真的触发了中断。问题能收到数据但偶尔错位比如0x55收成0xAA或0x33。原因分析这通常是采样点偏差造成的。我们的方案在定时器中断发生时立即采样但如果中断响应有延迟采样点就会偏离位的中心容易受到信号边沿抖动的影响。解决方案优化中断服务程序将ISR内的代码精简到极致只做必要的位操作和状态判断避免函数调用、复杂计算。调整采样点不在定时器中断发生时立即采样而是启动一个更短的高精度延时例如使用SysTick或另一个定时器延迟约40-50us对于104us的位时间后再采样这样更接近位的中心。加入边沿检测对于高速率可以考虑在定时器中断中不仅采样一次而是在一个短时间内多次采样如中断开始和结束前各一次进行“投票”取出现次数多的电平。问题高波特率如115200下通信不稳定。原因分析115200的位时间仅8.68us。中断响应时间从触发到进入ISR、ISR执行时间、以及可能的中断嵌套都可能消耗掉几个微秒导致时序完全错乱。解决方案提升主频尽可能提高MCU的系统时钟频率。使用更高性能的定时器有些MCU的定时器支持“单脉冲模式”或“DMA脉冲输出”可以更精确地控制波形减少软件干预。降低要求如果非必需将波特率降至38400或19200稳定性会大幅提升。软件模拟串口的实用极限通常在115200且对代码质量要求极高。问题发送数据正常但无法接收。排查步骤确认EXTI配置检查GPIO是否映射到了正确的EXTI线触发边沿是否设置为下降沿。检查NVIC配置确保EXTI中断向量已使能。检查中断屏蔽在起始位中断里是否错误地永久关闭了EXTI中断确保在接收完一帧后停止位处理阶段重新开启了中断。逻辑分析仪抓取RX引脚波形看对方发送过来的波形是否符合标准串口协议是否存在畸变。5.3 高级优化与扩展技巧环形缓冲区优化示例中的环形缓冲区非常简单。在实际项目中应考虑缓冲区满时的处理策略丢弃新数据或覆盖旧数据并做好临界区保护如果主循环和中断都会操作索引。DMA辅助发送对于发送可以结合DMA。将待发送的数据位模式包括起始位和停止位预先计算好存放到一个内存数组中然后使用定时器触发DMA将数组中的每一位依次搬运到GPIO的位设置/清除寄存器BSRR。这可以极大地解放CPU并实现极其精确的时序。但这需要MCU支持定时器触发DMA到GPIO的功能。支持多种格式通过修改状态机和位计数器可以支持不同的数据位长度7, 8, 9、校验位奇偶校验和停止位1, 1.5, 2。动态波特率切换通过修改定时器的ARR值可以实现运行时切换波特率。这在需要与多种设备通信的场景下很有用。低功耗考虑在空闲时可以关闭定时器仅保留外部中断。当起始位触发时再唤醒系统并启动定时器。这对于电池供电设备至关重要。模拟串口是一项将软件时序控制发挥到极致的技巧。它不仅仅是一个备用方案更深层次地锻炼了你对中断、定时器和硬件时序的理解。当你成功调通一个稳定的软件串口看到数据在逻辑分析仪上精准地跳动时那种对MCU掌控感带来的成就感是直接调用HAL_UART_Transmit无法比拟的。它提醒我们在嵌入式世界里当硬件资源不足时精妙的软件设计往往能化腐朽为神奇。