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 协议回顾与挑战分析
标准异步串口协议(如8N1:8位数据、无校验、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)。为了获得更精细的调整,我们通常让定时器工作在较高的计数频率。设PSC=71,则定时器时钟 = 72MHz / (71+1) = 1MHz。
- 此时,1MHz的定时器,每个计数是1微秒。9600波特率的一个位时间是 1 / 9600 ≈ 104.1667 微秒。
- 因此,自动重载值 ARR = 104.1667 us / 1 us = 104.1667。取整为104。
- 误差分析:使用ARR=104,实际位时间 = 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作为接收引脚(EXTI0),PA1作为发送引脚,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/72=1MHz 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 i=0; i<50; 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中断。
- 检查波特率计算:用示波器测量一个位的时间是否准确为104us(9600波特率)。如果偏差过大,检查系统时钟配置和定时器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无法比拟的。它提醒我们,在嵌入式世界里,当硬件资源不足时,精妙的软件设计往往能化腐朽为神奇。