引言

在嵌入式开发中,串口通信是基础且常用的接口。然而,许多微控制器(如 Arduino Uno 的 ATmega328P)仅提供 1-2 个硬件 UART,当需要连接多个串口设备(如 GPS、蓝牙、传感器模块)时,硬件资源捉襟见肘。软件串口(Software Serial)应运而生,它通过 GPIO 引脚和定时器模拟 UART 协议。

传统软件串口(如 Arduino 官方 SoftwareSerial 库)采用阻塞式位采样:发送或接收时,通过 delayMicroseconds() 精确延时,期间 CPU 被完全占用,无法处理其他任务。这在多任务场景下会引发严重问题,例如中断响应延迟、数据丢失等。

本文提出一种非阻塞式软件串口实现方案,利用定时器输入捕获(Input Capture)功能,在硬件层面自动记录引脚电平变化的时间戳,配合状态机解析,实现位级采样,从而释放 CPU 资源。

原理讲解

UART 协议基础

UART 帧格式:起始位(低电平)、8 个数据位(LSB 先发)、可选校验位、停止位(高电平)。空闲时,TX 线保持高电平。接收端需要以波特率(如 9600bps)为周期采样每个位。

阻塞式 vs 非阻塞式

  • 阻塞式:接收时,检测到起始位下降沿后,循环延时采样每个位。期间无法响应其他中断,CPU 利用率低。
  • 非阻塞式:利用定时器输入捕获中断,每当引脚电平变化(上升沿或下降沿)时,硬件自动记录当前定时器计数值并触发中断。软件在中断中记录边沿时间,通过分析边沿间隔还原数据位。

定时器输入捕获原理

以 Arduino Uno(ATmega328P)为例,定时器 1 是 16 位定时器,支持输入捕获单元。将 RX 引脚连接到 ICP1(Pin 8),配置定时器 1 为普通模式,开启输入捕获中断。当引脚发生指定边沿(如下降沿)时,定时器计数器值被复制到 ICR1 寄存器,并触发中断。

通过记录连续边沿的时间差,可以判断每个位的持续时间。例如,波特率 9600 时,位时间约为 104.17μs。若检测到两个下降沿间隔约为 1 个位时间,则中间为 0;若间隔为 2 个位时间,则中间为连续的 0(如 0b00)。

配置步骤

  1. 硬件连接:将外部设备的 TX 引脚连接到 Arduino 的 Pin 8(ICP1),共地。
  2. 定时器配置
    • 设置定时器 1 为普通模式,预分频为 8(计数频率 2MHz,分辨率 0.5μs)。
    • 开启输入捕获中断(TIMSK1 |= (1<<ICIE1))。
    • 设置边沿触发为下降沿(TCCR1B &= ~(1<<ICES1))。
  3. 中断服务程序:在 ISR 中读取 ICR1,计算与上次捕获的时间差,并调用状态机处理。
  4. 状态机设计
    • 状态 IDLE:等待起始位(下降沿)。
    • 状态 RECEIVING:根据边沿间隔解析数据位,直到收到停止位。

完整代码示例

以下代码实现一个非阻塞式软件串口接收功能,可接收 8 位数据,无校验位。

// 非阻塞软件串口接收示例(Arduino Uno)
// 使用定时器1输入捕获,Pin 8 (ICP1) 作为 RX

volatile uint32_t last_capture = 0;  // 上次捕获时间
volatile uint8_t rx_byte = 0;        // 接收到的字节
volatile uint8_t rx_ready = 0;       // 接收完成标志
volatile uint8_t bit_count = 0;      // 已接收的位数
volatile uint8_t current_byte = 0;   // 正在接收的字节
volatile uint8_t state = 0;          // 状态机状态:0=IDLE, 1=RECEIVING

#define BIT_TIME 104  // 9600bps 位时间(μs),预分频8时计数单位0.5μs,故104*2=208计数

void setup() {
  Serial.begin(115200);  // 调试串口
  pinMode(8, INPUT);     // ICP1 引脚
  
  // 配置定时器1
  TCCR1A = 0;            // 普通模式
  TCCR1B = (1<<CS11);    // 预分频8,计数频率2MHz
  TIMSK1 |= (1<<ICIE1);  // 使能输入捕获中断
  TCCR1B &= ~(1<<ICES1); // 下降沿触发
  
  sei();                 // 全局中断使能
}

ISR(TIMER1_CAPT_vect) {
  uint32_t now = ICR1;   // 读取捕获值
  uint32_t diff = now - last_capture;  // 计算时间差(考虑溢出)
  last_capture = now;
  
  // 将计数差值转换为位时间倍数(约0.5μs/计数)
  uint16_t bit_units = diff / (BIT_TIME * 2);  // 2MHz下1位时间=208计数
  
  if (state == 0) {
    // IDLE状态:下降沿到来,视为起始位
    state = 1;
    bit_count = 0;
    current_byte = 0;
    // 注意:起始位本身是低电平,但我们已经捕获到下降沿,无需处理
  } else {
    // RECEIVING状态:处理数据位和停止位
    // 每个下降沿表示一个位从1到0的变化,因此需要根据间隔填充0位
    
    // 填充间隔中的0位(bit_units-1 个0,因为下降沿本身代表一个0)
    for (uint8_t i = 0; i < bit_units - 1; i++) {
      if (bit_count < 8) {
        current_byte >>= 1;  // LSB先发,所以右移
        // 注意:当前位是0,无需置位
        bit_count++;
      }
    }
    
    // 当前下降沿对应的位是0(因为下降沿表示该位为0)
    if (bit_count < 8) {
      current_byte >>= 1;
      bit_count++;
    }
    
    // 如果已接收8位,则等待停止位(上升沿)
    if (bit_count == 8) {
      // 停止位应为高电平,但这里我们通过下一次上升沿确认
      // 简单处理:直接认为接收完成,实际应用中可校验停止位
      rx_byte = current_byte;
      rx_ready = 1;
      state = 0;  // 回到IDLE
    }
  }
}

void loop() {
  if (rx_ready) {
    rx_ready = 0;
    Serial.print("收到: 0x");
    Serial.println(rx_byte, HEX);
  }
  // 其他任务可以在这里执行,不会阻塞
}

注意事项

  • 定时器溢出处理:上述代码未处理定时器溢出(16位计数器溢出约32.7ms),在低波特率或长空闲时可能出错。建议在溢出中断中扩展高位,或使用更高预分频。
  • 边沿检测:本方案仅依赖下降沿,对于连续0的位(如0x00)需要计算间隔,但若出现连续多个0,间隔会较长,需确保定时器不溢出。
  • 波特率误差:晶振误差和预分频会导致位时间偏差,建议使用误差小于2%的晶振,并在代码中微调BIT_TIME。
  • 中断优先级:输入捕获中断应具有较高优先级,避免被其他中断延迟导致时间戳不准确。
  • 发送方向:本文仅实现接收,发送可类似使用定时器比较输出(CTC模式)或占用CPU,但通常发送不频繁,阻塞影响较小。

总结

基于定时器输入捕获的软件串口,利用硬件自动记录边沿时间,配合状态机解析,实现了非阻塞式接收。这种方法显著提高了 CPU 利用率,使 Arduino 能够同时处理多个串口通信和其他实时任务。开发者可根据实际需求扩展为完整收发功能,并优化溢出处理。掌握这一技巧,将极大提升嵌入式系统的通信设计能力。