引言

软件串口(Software Serial)是嵌入式开发中常见的折衷方案,尤其在Arduino平台上,当硬件UART数量不足时,利用GPIO模拟时序成为刚需。然而,基于delayMicroseconds()的阻塞式实现无法满足高波特率或并发任务需求。本文聚焦于定时器中断驱动的非库实现,并系统阐述其边界条件——这些条件决定了方案的可行性与稳定性。

原理:定时器中断与位时序

软件串口的核心是精确控制每个位的电平持续时间。硬件UART依赖专用时钟,而软件实现需借助定时器。基本思路:

  • 发送:将字节拆分为起始位(低)、8数据位(LSB first)、停止位(高),在定时器中断服务程序(ISR)中逐位翻转GPIO。
  • 接收:检测起始位下降沿,启动定时器,在每个位中点采样电平,重组字节。

关键参数:波特率(bps)决定位周期T_bit = 1/baud。例如9600bps时,T_bit≈104.17μs;115200bps时≈8.68μs。定时器中断频率需为波特率的整数倍(通常8倍或16倍),以便在每位内多次采样,提高抗干扰性。

边界条件分析

1. 定时器精度与时钟源

Arduino Uno(ATmega328P)的定时器1为16位,时钟源可选内部RC或外部晶振。晶振(16MHz)精度±20ppm,但内部RC误差可达±3%,导致波特率偏差。边界条件:波特率误差需<2%(UART规范),否则接收端采样错位。

  • 计算:实际波特率 = 定时器溢出率 / 分频系数。若配置不当,误差累积。
  • 建议:使用16位定时器(如Timer1),避免8位定时器因溢出频繁而增加中断负载。

2. 中断延迟与抖动

ISR执行时间直接影响位时序。若ISR内包含复杂运算或函数调用,将导致位周期延长。边界条件:ISR总执行时间必须远小于T_bit(通常<10%)。

  • 例如:115200bps时T_bit=8.68μs,ISR需在0.87μs内完成,而Arduino数字写入(digitalWrite)耗时约4μs,因此必须直接操作端口寄存器(如PORTB)。
  • 中断嵌套:若其他中断(如定时器0的毫秒计时)抢占,会造成抖动。需关闭全局中断或调整优先级(AVR支持cli()/sei(),但会阻塞其他功能)。

3. 波特率上限与CPU占用

定时器中断频率 = 波特率 × 采样倍数。以16倍采样为例:

  • 9600bps → 153.6kHz中断,每次ISR约20个周期(直接端口操作),CPU占用≈20/(1/153.6k)≈3%。
  • 115200bps → 1.843MHz中断,CPU占用≈37%,且ISR需极简,否则溢出。
  • 边界条件:CPU占用率不能超过50%,否则主循环几乎无法运行。实际中,115200bps已是极限,且需优化代码。

4. 多实例与资源冲突

Arduino Uno仅有一个16位定时器(Timer1),若需多个软件串口,必须共享定时器。边界条件

  • 多通道复用:在单个ISR中轮询多个通道,但需维护各自的状态机,增加ISR复杂度。
  • 冲突:若同时使用Timer1作为PWM输出,则无法用于软件串口。
  • 替代:使用Timer2(8位)但精度较低,或采用外部定时器芯片。

5. 接收起始位检测

接收时,需在空闲高电平中检测下降沿。边界条件

  • 若使用外部中断(如attachInterrupt),中断触发延迟可能错过起始位中点。
  • 更可靠:使用引脚电平变化中断(PCINT),但需在ISR中立即启动定时器,且定时器需支持单次触发模式。

配置步骤(以Arduino Uno + Timer1为例)

硬件连接

  • 发送引脚:D2(作为输出)
  • 接收引脚:D3(作为输入,启用内部上拉)

代码实现

// 定时器中断软件串口 - 发送与接收(115200bps,16倍采样)
#include <avr/io.h>
#include <avr/interrupt.h>

#define TX_PIN 2
#define RX_PIN 3
#define BAUD 115200
#define SAMPLE_RATE (BAUD * 16) // 1.8432MHz
#define TIMER_TOP (16000000 / SAMPLE_RATE - 1) // 8.68-1≈7

volatile uint8_t tx_byte, tx_bit, tx_state;
volatile uint8_t rx_byte, rx_bit, rx_state;
volatile uint8_t rx_sample;

void setup_soft_serial() {
  pinMode(TX_PIN, OUTPUT);
  digitalWrite(TX_PIN, HIGH); // 空闲高
  pinMode(RX_PIN, INPUT_PULLUP);

  // 配置Timer1为CTC模式,中断频率=SAMPLE_RATE
  TCCR1A = 0;
  TCCR1B = (1 << WGM12) | (1 << CS10); // CTC, 无分频
  OCR1A = TIMER_TOP;
  TIMSK1 = (1 << OCIE1A);
  sei();
}

ISR(TIMER1_COMPA_vect) {
  // 发送状态机
  if (tx_state) {
    if (tx_bit == 0) {
      // 起始位
      PORTD &= ~(1 << TX_PIN);
      tx_bit = 1;
    } else if (tx_bit <= 8) {
      // 数据位
      if (tx_byte & (1 << (tx_bit - 1))) PORTD |= (1 << TX_PIN);
      else PORTD &= ~(1 << TX_PIN);
      tx_bit++;
    } else {
      // 停止位
      PORTD |= (1 << TX_PIN);
      tx_state = 0;
    }
  }

  // 接收状态机(简化:仅采样)
  if (rx_state) {
    rx_sample = (PIND >> RX_PIN) & 1;
    if (rx_bit == 0) {
      // 起始位中点
      if (rx_sample == 0) {
        rx_bit = 1;
      } else {
        rx_state = 0; // 误触发
      }
    } else if (rx_bit <= 8) {
      rx_byte |= (rx_sample << (rx_bit - 1));
      rx_bit++;
    } else {
      // 停止位
      if (rx_sample == 1) {
        // 接收完成,处理rx_byte
      }
      rx_state = 0;
    }
  }
}

void soft_serial_send(uint8_t data) {
  while (tx_state); // 等待空闲
  cli();
  tx_byte = data;
  tx_bit = 0;
  tx_state = 1;
  sei();
}

void setup() {
  setup_soft_serial();
}

void loop() {
  soft_serial_send('A');
  delay(1000);
}

注意:接收起始位检测需在外部中断中启动定时器,上述代码仅为示意,实际需结合PCINT。

注意事项与优化建议

  • 直接端口操作:避免digitalWrite,使用PORTD等寄存器,可减少ISR时间。
  • 禁用中断嵌套:在ISR中避免调用delaySerial.print
  • 缓冲区管理:接收数据应存入环形缓冲区,避免丢失。
  • 波特率误差测试:用示波器测量实际波形,调整TIMER_TOP微调。
  • 多实例设计:若需多个串口,考虑使用状态机数组,但ISR复杂度增加,建议使用硬件UART扩展芯片。

结论

基于定时器中断的软件串口在Arduino上可行,但受限于定时器精度、中断延迟和CPU占用。边界条件决定了其适用场景:9600bps以下可稳定运行,115200bps需极致优化且占用大量CPU。对于要求高可靠性的项目,建议优先考虑硬件UART或外部芯片。理解这些边界,开发者方能做出合理架构决策。