# 引言:为什么需要无中断软件串口 在 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)。 - 利用捕获比较单元实现自动波特率检测。 (全文完)