引言

Arduino 的硬件串口(UART)通常只有 1-2 个,当需要连接多个串行设备(如 GPS、蓝牙、传感器)时,软件串口成为必然选择。传统软件串口(如 SoftwareSerial)依赖外部中断和位敲打(bit-banging),在高速率或高负载下容易丢失数据,且与其它中断冲突。本文介绍一种基于定时器输入捕获(Input Capture)和 DMA(Direct Memory Access)的替代方案,无需中断频繁介入,显著提升可靠性和效率。

原理剖析

传统软件串口的痛点

  • 每个字节的每一位都触发中断,CPU 频繁响应,占用率高。
  • 中断延迟导致波特率误差累积,尤其在 115200bps 以上时。
  • 与其它中断(如定时器、外部中断)竞争,造成数据丢失。

定时器捕获与 DMA 的优势

  • 输入捕获:定时器硬件检测引脚边沿,记录精确时间戳,无需 CPU 干预。
  • DMA:自动将捕获的时间戳搬运到内存,批量处理,减少中断次数。
  • 无中断:仅在缓冲区满或空闲时触发一次中断,CPU 负载极低。

实现思路

  • 接收:利用定时器捕获引脚(如 ICP1)检测起始位下降沿,记录每个边沿时刻,通过时间间隔解码数据位。
  • 发送:使用定时器比较匹配(Output Compare)和 DMA 生成精确的位时序,无需逐位中断。
  • 关键:定时器时钟频率需远高于波特率(如 16MHz 时钟,波特率 9600 时,每个位约 1667 个时钟周期)。

配置步骤(以 Arduino Mega 2560 为例)

硬件准备

  • Arduino Mega 2560(ATmega2560,16MHz)
  • 一个定时器(如 Timer1)用于捕获和比较
  • 两个引脚:一个输入捕获引脚(Pin 48,ICP1),一个输出比较引脚(Pin 11,OC1A)

定时器配置

  1. 设置定时器1:选择 CTC 模式(Clear Timer on Compare Match),用于发送;同时启用输入捕获噪声抑制。
  2. 配置捕获:设置捕获边沿为下降沿(起始位),启用捕获中断(但仅在捕获完成时触发一次)。
  3. 配置 DMA:在 AVR 上,DMA 通常由外设(如 USART)支持,但 ATmega2560 无通用 DMA。因此,我们使用定时器捕获的“批量捕获”模式(通过捕获缓冲),或利用“输入捕获”的连续采样。

注意:ATmega2560 没有通用 DMA 控制器,但我们可以模拟 DMA 行为:使用定时器捕获中断,但每次中断只处理一个字节,且通过乒乓缓冲减少中断频率。对于有 DMA 的 STM32 等平台,可直接使用硬件 DMA。

代码实现

以下代码实现一个基于 Timer1 的软件串口接收(仅接收部分,发送类似)。

// 软件串口接收 - 基于定时器捕获
#include <avr/io.h>
#include <avr/interrupt.h>

#define RX_PIN 48  // ICP1 (Pin 48 on Mega)
#define BAUD 9600
#define F_CPU 16000000UL

volatile uint8_t rx_buffer[64];
volatile uint8_t rx_head = 0, rx_tail = 0;
volatile uint16_t last_capture = 0;
volatile uint8_t bit_index = 0;
volatile uint8_t current_byte = 0;
volatile uint8_t receiving = 0;

void setup_soft_uart_rx() {
  // 设置引脚为输入
  DDRL &= ~(1 << PL0);  // Pin 48 is PL0
  PORTL |= (1 << PL0);  // 使能上拉

  // Timer1 配置
  TCCR1A = 0;
  TCCR1B = (1 << ICNC1) | (1 << ICES1) | (1 << CS10); // 噪声抑制,上升沿捕获,无分频
  TIMSK1 = (1 << ICIE1); // 使能输入捕获中断
  sei();
}

ISR(TIMER1_CAPT_vect) {
  uint16_t now = ICR1;
  uint16_t diff = now - last_capture;
  last_capture = now;

  if (!receiving) {
    // 检测到起始位(下降沿)
    if (diff > (F_CPU/BAUD)*0.5) { // 确保是起始位
      receiving = 1;
      bit_index = 0;
      current_byte = 0;
      TCCR1B &= ~(1 << ICES1); // 改为下降沿捕获
    }
  } else {
    // 采样数据位
    bit_index++;
    if (bit_index >= 1 && bit_index <= 8) {
      // 在位的中间采样,这里简化:使用边沿间隔判断
      if (diff > (F_CPU/BAUD)*0.5) {
        current_byte |= (1 << (bit_index-1));
      }
    } else if (bit_index == 9) {
      // 停止位,忽略
      receiving = 0;
      // 存入缓冲区
      rx_buffer[rx_head] = current_byte;
      rx_head = (rx_head + 1) % 64;
      TCCR1B |= (1 << ICES1); // 恢复上升沿捕获
    }
  }
}

uint8_t soft_uart_available() {
  return (rx_head != rx_tail);
}

uint8_t soft_uart_read() {
  while (rx_head == rx_tail);
  uint8_t data = rx_buffer[rx_tail];
  rx_tail = (rx_tail + 1) % 64;
  return data;
}

void setup() {
  Serial.begin(9600);
  setup_soft_uart_rx();
}

void loop() {
  if (soft_uart_available()) {
    Serial.write(soft_uart_read());
  }
}

发送实现(简述)

发送部分使用定时器比较匹配和 DMA(或模拟):

  • 设置 OC1A 引脚为输出,配置 CTC 模式。
  • 将待发送字节的位序列预先计算为比较值数组。
  • 使用 DMA 或循环逐个设置比较值,定时器自动翻转引脚,无需中断。

注意事项

  • 波特率误差:定时器捕获的精度受时钟频率影响,建议使用 16MHz 晶振,并校准。
  • 噪声抑制:启用 ICNC1 可滤除短脉冲,但会增加延迟,需权衡。
  • 缓冲区溢出:如果接收数据过快,缓冲区可能溢出,需适当增大或使用 DMA 直接存储。
  • 平台差异:Arduino Uno(ATmega328P)与 Mega 的引脚和定时器不同,需调整。
  • DMA 限制:AVR 系列无硬件 DMA,本文方法实际仍依赖中断,但频率低;若使用 STM32 等,可真正实现无中断。

总结

本文提出的基于定时器捕获的软件串口方案,显著减少了中断频率,提高了稳定性。虽然 AVR 上无法完全摆脱中断,但思路可迁移至 STM32 等带 DMA 的平台,实现真正的无中断串口。对于需要多串口的嵌入式项目,这是一种值得尝试的替代方案。