# 引言 软件串口(Software Serial)是嵌入式开发中常见的折衷方案,尤其在Arduino平台上,当硬件UART数量不足时,利用GPIO模拟时序成为刚需。然而,基于`delayMicroseconds()`的阻塞式实现无法满足高波特率或并发任务需求。本文聚焦于**定时器中断驱动**的非库实现,并系统阐述其边界条件——这些条件决定了方案的可行性与稳定性。 # 原理:定时器中断与位时序 软件串口的核心是精确控制每个位的电平持续时间。硬件UART依赖专用时钟,而软件实现需借助定时器。基本思路: - **发送**:将字节拆分为起始位(低)、8数据位(LSB first)、停止位(高),在定时器中断服务程序(ISR)中逐位翻转GPIO。 - **接收**:检测起始位下降沿,启动定时器,在每个位中点采样电平,重组字节。 关键参数:波特率(bps)决定位周期`T_bit = 1/baud`。例如9600bps时,T_bit≈104.17μs;115200bps时≈8.68μs。定时器中断频率需为波特率的整数倍(通常8倍或16倍),以便在每位内多次采样,提高抗干扰性。 # 边界条件分析 ## 1. 定时器精度与时钟源 Arduino Uno(ATmega328P)的定时器1为16位,时钟源可选内部RC或外部晶振。晶振(16MHz)精度±20ppm,但内部RC误差可达±3%,导致波特率偏差。**边界条件**:波特率误差需<2%(UART规范),否则接收端采样错位。 - 计算:实际波特率 = 定时器溢出率 / 分频系数。若配置不当,误差累积。 - 建议:使用16位定时器(如Timer1),避免8位定时器因溢出频繁而增加中断负载。 ## 2. 中断延迟与抖动 ISR执行时间直接影响位时序。若ISR内包含复杂运算或函数调用,将导致位周期延长。**边界条件**:ISR总执行时间必须远小于T_bit(通常<10%)。 - 例如:115200bps时T_bit=8.68μs,ISR需在0.87μs内完成,而Arduino数字写入(`digitalWrite`)耗时约4μs,因此必须直接操作端口寄存器(如`PORTB`)。 - 中断嵌套:若其他中断(如定时器0的毫秒计时)抢占,会造成抖动。需关闭全局中断或调整优先级(AVR支持`cli()`/`sei()`,但会阻塞其他功能)。 ## 3. 波特率上限与CPU占用 定时器中断频率 = 波特率 × 采样倍数。以16倍采样为例: - 9600bps → 153.6kHz中断,每次ISR约20个周期(直接端口操作),CPU占用≈20/(1/153.6k)≈3%。 - 115200bps → 1.843MHz中断,CPU占用≈37%,且ISR需极简,否则溢出。 - **边界条件**:CPU占用率不能超过50%,否则主循环几乎无法运行。实际中,115200bps已是极限,且需优化代码。 ## 4. 多实例与资源冲突 Arduino Uno仅有一个16位定时器(Timer1),若需多个软件串口,必须共享定时器。**边界条件**: - 多通道复用:在单个ISR中轮询多个通道,但需维护各自的状态机,增加ISR复杂度。 - 冲突:若同时使用Timer1作为PWM输出,则无法用于软件串口。 - 替代:使用Timer2(8位)但精度较低,或采用外部定时器芯片。 ## 5. 接收起始位检测 接收时,需在空闲高电平中检测下降沿。**边界条件**: - 若使用外部中断(如`attachInterrupt`),中断触发延迟可能错过起始位中点。 - 更可靠:使用引脚电平变化中断(PCINT),但需在ISR中立即启动定时器,且定时器需支持单次触发模式。 # 配置步骤(以Arduino Uno + Timer1为例) ## 硬件连接 - 发送引脚:D2(作为输出) - 接收引脚:D3(作为输入,启用内部上拉) ## 代码实现 ```c // 定时器中断软件串口 - 发送与接收(115200bps,16倍采样) #include #include #define TX_PIN 2 #define RX_PIN 3 #define BAUD 115200 #define SAMPLE_RATE (BAUD * 16) // 1.8432MHz #define TIMER_TOP (16000000 / SAMPLE_RATE - 1) // 8.68-1≈7 volatile uint8_t tx_byte, tx_bit, tx_state; volatile uint8_t rx_byte, rx_bit, rx_state; volatile uint8_t rx_sample; void setup_soft_serial() { pinMode(TX_PIN, OUTPUT); digitalWrite(TX_PIN, HIGH); // 空闲高 pinMode(RX_PIN, INPUT_PULLUP); // 配置Timer1为CTC模式,中断频率=SAMPLE_RATE TCCR1A = 0; TCCR1B = (1 << WGM12) | (1 << CS10); // CTC, 无分频 OCR1A = TIMER_TOP; TIMSK1 = (1 << OCIE1A); sei(); } ISR(TIMER1_COMPA_vect) { // 发送状态机 if (tx_state) { if (tx_bit == 0) { // 起始位 PORTD &= ~(1 << TX_PIN); tx_bit = 1; } else if (tx_bit <= 8) { // 数据位 if (tx_byte & (1 << (tx_bit - 1))) PORTD |= (1 << TX_PIN); else PORTD &= ~(1 << TX_PIN); tx_bit++; } else { // 停止位 PORTD |= (1 << TX_PIN); tx_state = 0; } } // 接收状态机(简化:仅采样) if (rx_state) { rx_sample = (PIND >> RX_PIN) & 1; if (rx_bit == 0) { // 起始位中点 if (rx_sample == 0) { rx_bit = 1; } else { rx_state = 0; // 误触发 } } else if (rx_bit <= 8) { rx_byte |= (rx_sample << (rx_bit - 1)); rx_bit++; } else { // 停止位 if (rx_sample == 1) { // 接收完成,处理rx_byte } rx_state = 0; } } } void soft_serial_send(uint8_t data) { while (tx_state); // 等待空闲 cli(); tx_byte = data; tx_bit = 0; tx_state = 1; sei(); } void setup() { setup_soft_serial(); } void loop() { soft_serial_send('A'); delay(1000); } ``` **注意**:接收起始位检测需在外部中断中启动定时器,上述代码仅为示意,实际需结合PCINT。 # 注意事项与优化建议 - **直接端口操作**:避免`digitalWrite`,使用`PORTD`等寄存器,可减少ISR时间。 - **禁用中断嵌套**:在ISR中避免调用`delay`或`Serial.print`。 - **缓冲区管理**:接收数据应存入环形缓冲区,避免丢失。 - **波特率误差测试**:用示波器测量实际波形,调整`TIMER_TOP`微调。 - **多实例设计**:若需多个串口,考虑使用状态机数组,但ISR复杂度增加,建议使用硬件UART扩展芯片。 # 结论 基于定时器中断的软件串口在Arduino上可行,但受限于定时器精度、中断延迟和CPU占用。边界条件决定了其适用场景:9600bps以下可稳定运行,115200bps需极致优化且占用大量CPU。对于要求高可靠性的项目,建议优先考虑硬件UART或外部芯片。理解这些边界,开发者方能做出合理架构决策。