1. 为什么需要IO模拟串口?
在实际的嵌入式开发中,我们经常会遇到硬件资源不足的情况。比如手头的STM32芯片只有1-2个硬件串口,但项目需要连接GPS模块、蓝牙模块、传感器等多个外设,硬件串口数量就捉襟见肘了。这时候,用普通IO口模拟串口就成了一个经济实用的解决方案。
我第一次遇到这个问题是在做一个智能农业监测项目时,需要同时连接土壤传感器、气象站和LoRa无线模块,但使用的STM32F103C8T6只有2个硬件串口。当时尝试过用软件模拟第三个串口,结果因为时序控制不精准导致数据错乱,后来通过优化定时器才解决问题。
2. 串口通信的基本原理
2.1 串口数据帧结构
串口通信的核心在于时序控制。一个标准的UART数据帧包含:
- 起始位:1位低电平(0)
- 数据位:5-9位(通常8位)
- 校验位:可选(奇校验/偶校验)
- 停止位:1-2位高电平(1)
以最常见的"8-N-1"格式(8位数据、无校验、1位停止位)为例,传输一个字节'A'(ASCII 0x41)的波形如下:
0 1 0 0 0 0 0 1 1 | | | | | | | | | 起始 D0 D1 D2 D3 D4 D5 D6 D7 停止2.2 波特率与时序关键
波特率决定了每位数据的持续时间。例如:
- 9600bps:每位持续 1/9600 ≈ 104μs
- 115200bps:每位持续 8.68μs
在IO模拟实现中,定时器的精度直接决定通信可靠性。我曾用STM32的SysTick做延时,在115200波特率下误差太大,后来改用定时器硬件中断才稳定。
3. 硬件与软件设计对比
3.1 硬件串口的优势
- 自动处理时序,CPU占用低
- 支持DMA传输
- 内置FIFO缓冲
- 错误检测机制完善
3.2 软件模拟的特点
- 优点:不占用硬件串口资源,引脚选择灵活
- 缺点:CPU占用高(尤其高速波特率),精度依赖定时器,无硬件错误检测
实测数据对比(STM32F103@72MHz):
| 指标 | 硬件串口 | 软件模拟(定时器) |
|---|---|---|
| 最大波特率 | 4.5Mbps | 115200bps |
| CPU占用率 | <1% | ~15% (115200) |
| 字节间隔 | 任意 | 需严格定时 |
4. 具体实现步骤
4.1 硬件资源准备
以STM32F103为例,需要:
- 2个GPIO(TX输出、RX输入)
- 1个外部中断(用于RX下降沿检测)
- 2个定时器(1个用于发送延时,1个用于接收采样)
推荐配置:
- TX:推挽输出模式
- RX:上拉输入模式,配置下降沿触发中断
4.2 发送功能实现
发送时序的关键是精确控制每位数据的持续时间。以9600bps发送一个字节为例:
void IO_UART_SendByte(uint8_t data) { // 起始位 TX_PIN = 0; Delay_us(104); // 数据位(LSB first) for(int i=0; i<8; i++) { TX_PIN = (data >> i) & 0x01; Delay_us(104); } // 停止位 TX_PIN = 1; Delay_us(104); }实测建议:对于高于9600的波特率,建议用定时器中断代替Delay_us,因为函数调用开销会导致时序偏差。
4.3 接收功能实现
接收更复杂,需要:
- RX引脚配置下降沿中断
- 检测到起始位后,启动定时器
- 在每位中间时刻采样
// 外部中断服务函数 void EXTI_IRQHandler() { if(检测到下降沿) { 关闭外部中断(); 启动定时器(104/2 μs); // 半位时间后采样 } } // 定时器中断服务函数 void TIM_IRQHandler() { static uint8_t bitCount = 0; static uint8_t rxData = 0; if(bitCount == 0) { // 应处于起始位中间,验证是否为低电平 if(RX_PIN == 1) { // 错误,重新开启外部中断 开启外部中断(); return; } } else if(bitCount <= 8) { // 数据位采样 rxData |= (RX_PIN << (bitCount-1)); } else { // 停止位验证 if(RX_PIN == 0) { // 帧错误处理 } // 完成接收 处理接收数据(rxData); 开启外部中断(); return; } bitCount++; 重置定时器(104μs); }5. 关键问题与优化
5.1 波特率精度问题
普通延时函数的误差会累积。解决方案:
- 使用硬件定时器(TIM2/TIM3等)
- 在中断服务函数中直接操作IO
- 对于高速波特率,考虑使用PWM模式生成精确时序
5.2 中断冲突处理
当系统中有多个中断源时:
- 设置模拟串口中断为较高优先级
- 在关键时序段禁用其他中断
- 使用RTOS时,保持中断服务函数尽可能简短
5.3 代码优化技巧
- 发送优化:
// 预先计算好各时间点的寄存器值 uint32_t tx_timings[10] = {0,104,208,...}; void TIM_IRQHandler() { static uint8_t state = 0; switch(state) { case 0: TX_PIN=0; break; // 起始位 case 1...8: TX_PIN=(data>>(state-1))&1; break; case 9: TX_PIN=1; break; // 停止位 } state++; if(state > 9) state = 0; }- 接收抗干扰:
- 在每位采样时多次读取(3取2)
- 添加简单的CRC校验
- 设置超时机制(如1.5个字符时间无数据视为帧结束)
6. 完整代码示例
以下是基于STM32标准外设库的实现框架:
// iousart.h #ifndef __IO_USART_H #define __IO_USART_H #include "stm32f10x.h" #define IO_USART_BAUDRATE 9600 #define BIT_DURATION (1000000/IO_USART_BAUDRATE) // us void IO_USART_Init(void); void IO_USART_SendByte(uint8_t data); uint8_t IO_USART_ReceiveByte(void); #endif// iousart.c #include "iousart.h" #include "stm32f10x_gpio.h" #include "stm32f10x_tim.h" #include "stm32f10x_exti.h" #define TX_PIN GPIO_Pin_9 #define RX_PIN GPIO_Pin_10 #define GPIO_PORT GPIOB static volatile uint8_t rx_data = 0; static volatile uint8_t rx_flag = 0; void IO_USART_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; TIM_TimeBaseInitTypeDef TIM_InitStruct; EXTI_InitTypeDef EXTI_InitStruct; NVIC_InitTypeDef NVIC_InitStruct; // 1. 时钟使能 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // 2. GPIO配置 GPIO_InitStruct.GPIO_Pin = TX_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIO_PORT, &GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin = RX_PIN; GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIO_PORT, &GPIO_InitStruct); // 3. 外部中断配置 GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource10); EXTI_InitStruct.EXTI_Line = EXTI_Line10; EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Falling; EXTI_InitStruct.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStruct); // 4. 定时器配置 TIM_InitStruct.TIM_Period = BIT_DURATION * 72 / 1000; // 72MHz主频 TIM_InitStruct.TIM_Prescaler = 0; TIM_InitStruct.TIM_ClockDivision = 0; TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_InitStruct); // 5. NVIC配置 NVIC_InitStruct.NVIC_IRQChannel = EXTI15_10_IRQn; NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1; NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStruct); NVIC_InitStruct.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStruct.NVIC_IRQChannelSubPriority = 2; NVIC_Init(&NVIC_InitStruct); } void IO_USART_SendByte(uint8_t data) { // 实现同上 } uint8_t IO_USART_ReceiveByte(void) { while(!rx_flag); // 等待接收完成 rx_flag = 0; return rx_data; } // 中断服务函数 void EXTI15_10_IRQHandler(void) { if(EXTI_GetITStatus(EXTI_Line10) != RESET) { EXTI_ClearITPendingBit(EXTI_Line10); // 检测到起始位,启动定时器 TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); } } void TIM3_IRQHandler(void) { static uint8_t bit_count = 0; static uint8_t temp_data = 0; if(TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if(bit_count == 0) { // 验证起始位 if(GPIO_ReadInputDataBit(GPIO_PORT, RX_PIN) != Bit_RESET) { TIM_Cmd(TIM3, DISABLE); return; } } else if(bit_count <= 8) { // 采样数据位 temp_data |= (GPIO_ReadInputDataBit(GPIO_PORT, RX_PIN) << (bit_count-1)); } else { // 停止位 TIM_Cmd(TIM3, DISABLE); rx_data = temp_data; rx_flag = 1; bit_count = 0; temp_data = 0; return; } bit_count++; } }7. 实际应用建议
波特率选择:
- 对于72MHz主频的STM32F103,建议不超过115200bps
- 低功耗设备建议使用9600bps
引脚选择:
- 避免使用JTAG/SWD复用引脚
- 长距离传输时添加RS232/RS485转换芯片
错误处理:
- 添加超时重传机制
- 重要数据使用校验和或CRC
多串口管理:
- 可以为每个虚拟串口创建独立的数据结构
- 使用状态机模式管理通信流程
我在工业控制器项目中曾用这种方法实现了4路串口(1硬件+3模拟),关键是要做好各通道的优先级管理。当多个外设同时通信时,适当降低非关键通道的波特率可以保证系统稳定性。