Arduino 上实现无中断的软件串口:基于定时器捕获与 DMA 的替代方案
👁 2 阅读 · 2026-08-27 · 嵌入式
在资源受限的 Arduino 平台上,硬件串口数量往往不足,传统软件串口依赖中断和位敲打,实时性差且易冲突。本文提出一种基于定时器输入捕获与 DMA 的替代方案,彻底摆脱中断频繁介入,实现高效、稳定的软件串口收发。文章深入原理,给出完整配置步骤与代码示例,并探讨注意事项,适合希望突破 Arduino 串口限制的嵌入式开发者。
# 引言
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 的软件串口接收(仅接收部分,发送类似)。
```c
// 软件串口接收 - 基于定时器捕获
#include
#include
#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 的平台,实现真正的无中断串口。对于需要多串口的嵌入式项目,这是一种值得尝试的替代方案。