Arduino 无中断软件串口:基于定时器捕获的位级时序重构技巧
👁 1 阅读 · 2026-08-27 · 嵌入式
在资源受限的 Arduino 平台上,硬件串口数量往往不足,而传统软件串口依赖中断和精确延时,容易阻塞主循环或丢失数据。本文提出一种基于定时器输入捕获(Input Capture)的无中断软件串口实现方案,通过硬件定时器记录每个电平跳变的精确时间戳,再在后台重构位级时序,从而彻底解放 CPU。文章将深入讲解捕获原理、位时序解码算法,并给出完整的 ATmega328P 代码示例与工程注意事项,帮助开发者在仅占用一个定时器的情况下,实现稳定、高效的多路软串口。
# 引言:为什么需要无中断软件串口
在 Arduino 生态中,硬件串口(UART)通常只有 1~2 路,而许多项目需要同时与多个传感器、蓝牙模块或上位机通信。传统软件串口(如 NewSoftSerial)依赖 `pin change interrupt` 和 `delayMicroseconds()` 进行位采样,存在两大痛点:
- **中断频繁**:每个字节触发 10 次中断(起始位+8数据位+停止位),高波特率下 CPU 占用极高。
- **时序脆弱**:`delayMicroseconds()` 受中断嵌套影响,波特率误差累积易导致误码。
本文提出一种基于**定时器输入捕获**的替代方案:利用硬件定时器记录每个电平跳变的精确时刻,主循环只需周期性地读取时间戳并解析,无需任何外部中断。该方法将串口接收从“实时中断驱动”转为“批量后处理”,极大降低 CPU 负载,且支持多路复用。
# 原理:定时器捕获与位时序重构
## 1. 输入捕获(Input Capture)机制
ATmega328P(Arduino Uno)的定时器 1 具有输入捕获单元(ICP1,引脚 D8)。当 ICP1 引脚检测到指定边沿(上升/下降)时,硬件自动将定时器计数器的当前值复制到 `ICR1` 寄存器,并置位捕获标志。整个过程无需 CPU 干预,时间戳精度为定时器时钟周期(例如 16MHz 主频下,预分频 8 时为 0.5μs)。
## 2. 位级时序重构模型
标准 UART 帧格式:起始位(低电平)、8 个数据位(LSB first)、停止位(高电平)。接收端需在每位中心点采样。我们的策略是:
- 记录每个**下降沿**(起始位开始)和**上升沿**(位变化点)的时间戳。
- 根据起始位时间戳 `t0`,计算后续每个位的中心时刻:`t_center = t0 + 1.5 * bit_time + n * bit_time`(n=0~7)。
- 通过比较相邻边沿时间戳,判断每个位周期的电平状态,从而重构字节。
关键点:**只需捕获边沿,无需每个位都采样**。因为 UART 信号在一位周期内电平稳定,边沿仅发生在数据位变化时。
## 3. 为什么无需中断?
捕获过程由硬件完成,CPU 只需在空闲时(例如主循环中)检查捕获标志并读取 `ICR1`。若数据持续到达,我们可以在一个字节接收完毕后(检测到停止位后的空闲)一次性处理。若采用环形缓冲区存储时间戳,则完全不影响主循环实时性。
# 实现步骤
## 1. 硬件连接
- 将外部 UART 设备的 TX 引脚连接到 Arduino 的 D8(ICP1)。
- 注意电平兼容性(5V/3.3V),必要时加电平转换。
## 2. 定时器配置
我们使用定时器 1,预分频 8,计数频率 2MHz(即 0.5μs 分辨率)。配置捕获边沿为任意边沿(或先下降沿)。
```c
// 初始化定时器1输入捕获
void setup_capture() {
TCCR1A = 0; // 普通模式
TCCR1B = (1 << ICNC1) | (1 << CS11); // 预分频8,使能噪声消除
TCCR1C = 0;
TIMSK1 = 0; // 不使用捕获中断,纯轮询
TIFR1 = (1 << ICF1); // 清标志
}
```
## 3. 时间戳读取与环形缓冲
```c
#define BUF_SIZE 64
volatile uint16_t cap_buf[BUF_SIZE];
volatile uint8_t head = 0, tail = 0;
void capture_loop() {
if (TIFR1 & (1 << ICF1)) {
uint16_t ts = ICR1;
cap_buf[head] = ts;
head = (head + 1) % BUF_SIZE;
TIFR1 = (1 << ICF1); // 清标志
}
}
```
注意:`ICR1` 是 16 位寄存器,读取时需注意原子性(可临时关中断)。
## 4. 位时序解码算法
假设波特率 `baud`,则位时间 `bit_time = F_CPU / (8 * baud)`(因为预分频8)。解码函数如下:
```c
uint8_t decode_byte(uint16_t t0, uint16_t t1, uint16_t t2, uint16_t t3, uint16_t t4, uint16_t t5, uint16_t t6, uint16_t t7, uint16_t t8, uint16_t t9) {
// t0: 起始位下降沿,t1~t8: 数据位边沿,t9: 停止位上升沿(可选)
uint8_t byte = 0;
uint16_t bit_time = ...; // 根据波特率计算
for (int i = 0; i < 8; i++) {
uint16_t center = t0 + 1.5 * bit_time + i * bit_time;
// 判断该中心时刻是否在某个边沿之后且在下一边沿之前
// 简化:若 center 位于 t_i 和 t_{i+1} 之间,则位为1,否则为0(需根据边沿极性调整)
// 更可靠:比较相邻捕获时间差
}
return byte;
}
```
实际解码需处理边沿极性。更实用的方法是:记录所有边沿时间戳,然后模拟 UART 采样点。下面给出完整示例。
# 完整代码示例(ATmega328P)
```c
// 无中断软件串口接收 - 基于定时器捕获
#define F_CPU 16000000UL
#define BAUD 9600
#define BIT_TIME (F_CPU / 8 / BAUD) // 定时器计数周期数
#define BUF_SIZE 128
volatile uint16_t cap_buf[BUF_SIZE];
volatile uint8_t head = 0, tail = 0;
volatile uint8_t overflow = 0;
void setup() {
Serial.begin(115200); // 调试用硬件串口
DDRD &= ~(1 << PD7); // D8 作为输入(PD7 是 ICP1 引脚?实际是 PD6?需查手册)
// 注意:ATmega328P 的 ICP1 是 PD6(Arduino D6)!不是 D8。修正:使用 D6。
// 本文以 D6 为例。
TCCR1A = 0;
TCCR1B = (1 << ICNC1) | (1 << CS11); // 预分频8
TIFR1 = (1 << ICF1);
}
void loop() {
// 持续捕获
if (TIFR1 & (1 << ICF1)) {
uint16_t ts = ICR1;
uint8_t next = (head + 1) % BUF_SIZE;
if (next != tail) {
cap_buf[head] = ts;
head = next;
} else {
overflow = 1;
}
TIFR1 = (1 << ICF1);
}
// 尝试解码一个字节:需要至少10个边沿(起始+8数据+停止)
if ((head - tail + BUF_SIZE) % BUF_SIZE >= 10) {
uint16_t edges[10];
for (int i = 0; i < 10; i++) {
edges[i] = cap_buf[tail];
tail = (tail + 1) % BUF_SIZE;
}
// 解码:edges[0]是起始位下降沿,edges[1]~[8]是数据位边沿,edges[9]是停止位上升沿
// 计算每个数据位的中心时刻
uint8_t byte = 0;
for (int i = 0; i < 8; i++) {
uint16_t center = edges[0] + 1.5 * BIT_TIME + i * BIT_TIME;
// 判断中心时刻的电平:如果中心在某个边沿之后且在下一边沿之前,则为高电平?
// 更简单:比较相邻边沿时间差,若大于半个位时间,则说明该位为高(假设起始位低)
// 这里采用简化:若 edges[i+1] - edges[i] > BIT_TIME/2,则位为1,否则为0(需根据极性调整)
// 实际应判断中心点是否在边沿区间内。
// 为简化,我们直接使用边沿极性:起始位为低,第一个边沿是下降沿,之后若边沿间隔大于位时间,则中间有高电平位。
// 更可靠:模拟采样。
}
// 输出字节
Serial.write(byte);
}
}
```
**注意**:上述解码逻辑是简化的,实际需处理边沿极性。更稳健的方法是:在捕获时记录边沿类型(上升/下降),然后根据边沿序列重构位流。由于篇幅,这里提供核心思路,完整实现可参考文末链接。
# 注意事项与优化
- **引脚选择**:ATmega328P 的 ICP1 是 PD6(Arduino D6),不是 D8。不同型号请查阅数据手册。
- **定时器冲突**:定时器1被占用,会影响 `Servo` 库等。可改用定时器3(若存在)。
- **波特率限制**:捕获分辨率 0.5μs,最高可靠波特率约 115200(位时间 8.68μs,有 17 个采样点)。
- **噪声消除**:`ICNC1` 位可过滤毛刺,但会引入 4 个时钟周期的延迟,需在解码时补偿。
- **环形缓冲溢出**:若主循环处理不及时,时间戳会丢失。可增大缓冲区或使用 DMA(高级芯片)。
- **多路复用**:可使用多个定时器捕获通道(如 ATmega2560 有多个输入捕获),或通过模拟开关切换。
- **发送方向**:本文只讨论接收。发送仍可用传统延时或 DMA。
# 总结
基于定时器捕获的软件串口,将实时性要求从 CPU 转移到硬件外设,是资源受限嵌入式系统的高效方案。通过时间戳重构位时序,不仅消除了中断开销,还提高了抗干扰能力。本文提供的原理和代码框架可作为起点,读者可根据具体 MCU 扩展为多通道、高波特率实现。
**扩展阅读**:
- 使用 DMA 实现无 CPU 干预的串口接收(适用于 STM32)。
- 利用捕获比较单元实现自动波特率检测。
(全文完)