# 引言 在嵌入式开发中,串口是最常用的调试和通信接口。但 Arduino Uno 等经典板卡仅提供一个硬件串口,当需要连接 GPS、蓝牙模块或多个传感器时,串口资源捉襟见肘。软件模拟串口成为常见解决方案,但传统的 `SoftwareSerial` 库使用阻塞式延时,严重影响系统实时性。本文将介绍一种基于定时器中断的非阻塞软件串口实现,通过位级收发技巧,在释放 CPU 的同时保证通信可靠性。 # 原理剖析 ## 异步串行通信基础 UART 通信以帧为单位,每帧包含:1 个起始位(低电平)、8 个数据位(LSB 优先)、1 个可选校验位和 1 个停止位(高电平)。通信双方需约定相同的波特率,接收端在每位中间采样以容忍时钟偏差。 ## 非阻塞设计思路 - **发送端**:将待发送数据放入缓冲区,定时器中断按位触发,依次输出起始位、数据位和停止位。主循环无需等待,可继续执行其他任务。 - **接收端**:检测起始位下降沿后,启动定时器,在每个数据位的中心时刻采样引脚电平,移位组装成字节,存入接收缓冲区。 ## 定时器中断的优势 相比 `delay` 阻塞方式,定时器中断具有以下优点: - CPU 利用率高,中断间隔内可处理其他任务 - 时序精确,不受主循环代码长度影响 - 支持多路软件串口(需多个定时器) # 硬件与定时器选择 以 Arduino Uno(ATmega328P)为例,我们使用 Timer2(8 位定时器)实现。Timer2 支持 CTC 模式(比较匹配清零),可产生精确的周期中断。波特率与定时器初值的关系: ```c // 定时器初值计算(CTC模式) // OCR2A = (F_CPU / (2 * 预分频 * 波特率)) - 1 // 例如:9600 波特率,预分频 8,F_CPU=16MHz // OCR2A = (16000000 / (2 * 8 * 9600)) - 1 = 103 ``` # 代码实现 ## 1. 发送端实现 ```c // 非阻塞软件串口发送类 class SoftUART_Tx { private: uint8_t _txPin; volatile uint8_t _txBuffer[64]; volatile uint8_t _txHead, _txTail; volatile uint8_t _txState; // 0:空闲, 1:发送中 volatile uint16_t _txByte; volatile uint8_t _txBitIndex; volatile uint8_t _txReg; public: SoftUART_Tx(uint8_t pin) : _txPin(pin) { pinMode(_txPin, OUTPUT); digitalWrite(_txPin, HIGH); // 空闲状态为高 _txHead = _txTail = 0; _txState = 0; } void begin(uint32_t baud) { // 配置 Timer2 CTC 模式 TCCR2A = (1 << WGM21); TCCR2B = (1 << CS21); // 预分频 8 OCR2A = (F_CPU / (2 * 8UL * baud)) - 1; TIMSK2 = (1 << OCIE2A); // 使能比较匹配中断 } void write(uint8_t data) { // 如果缓冲区满,等待(或丢弃) uint8_t next = (_txHead + 1) % 64; if (next == _txTail) return; // 缓冲区满 _txBuffer[_txHead] = data; _txHead = next; // 如果空闲,启动发送 if (_txState == 0) { _txState = 1; _txByte = 0; // 起始位 _txBitIndex = 0; _txReg = _txBuffer[_txTail]; _txTail = (_txTail + 1) % 64; digitalWrite(_txPin, LOW); // 输出起始位 } } // 中断服务函数(由定时器中断调用) void isr() { if (_txState == 0) return; _txBitIndex++; if (_txBitIndex <= 8) { // 发送数据位(LSB 优先) digitalWrite(_txPin, (_txReg >> (_txBitIndex - 1)) & 0x01); } else if (_txBitIndex == 9) { digitalWrite(_txPin, HIGH); // 停止位 } else { // 发送完成,检查是否有新数据 _txState = 0; if (_txHead != _txTail) { _txState = 1; _txByte = 0; _txBitIndex = 0; _txReg = _txBuffer[_txTail]; _txTail = (_txTail + 1) % 64; digitalWrite(_txPin, LOW); } } } }; // 全局对象和中断服务例程 SoftUART_Tx myTx(3); ISR(TIMER2_COMPA_vect) { myTx.isr(); } ``` ## 2. 接收端实现 ```c class SoftUART_Rx { private: uint8_t _rxPin; volatile uint8_t _rxBuffer[64]; volatile uint8_t _rxHead, _rxTail; volatile uint8_t _rxState; // 0:等待起始位, 1:接收中 volatile uint8_t _rxBitIndex; volatile uint8_t _rxData; volatile uint8_t _rxReg; public: SoftUART_Rx(uint8_t pin) : _rxPin(pin) { pinMode(_rxPin, INPUT_PULLUP); _rxHead = _rxTail = 0; _rxState = 0; } void begin(uint32_t baud) { // 配置 Timer2 为普通模式,用于采样 TCCR2A = 0; TCCR2B = (1 << CS21); // 预分频 8 OCR2A = (F_CPU / (2 * 8UL * baud)) - 1; TIMSK2 = (1 << OCIE2A); // 启用外部中断检测起始位(例如 INT0) attachInterrupt(digitalPinToInterrupt(2), startBitDetect, FALLING); } // 起始位检测中断 void startBitDetect() { if (_rxState == 0) { _rxState = 1; _rxBitIndex = 0; _rxData = 0; // 重置定时器计数,使中断在半个位周期后触发 TCNT2 = 0; // 设置比较值为半个位周期 OCR2A = (F_CPU / (2 * 8UL * 9600)) / 2 - 1; } } // 定时器中断:采样数据位 void isr() { if (_rxState == 0) return; // 在每位中心采样 if (_rxBitIndex < 8) { uint8_t bit = digitalRead(_rxPin); _rxData |= (bit << _rxBitIndex); _rxBitIndex++; // 重置比较值为一个位周期 OCR2A = (F_CPU / (2 * 8UL * 9600)) - 1; } else { // 接收完成,检查停止位(可选) _rxState = 0; uint8_t stopBit = digitalRead(_rxPin); if (stopBit) { uint8_t next = (_rxHead + 1) % 64; if (next != _rxTail) { _rxBuffer[_rxHead] = _rxData; _rxHead = next; } } // 重新启用起始位检测 attachInterrupt(digitalPinToInterrupt(2), startBitDetect, FALLING); } } int available() { return (_rxHead - _rxTail + 64) % 64; } uint8_t read() { if (_rxHead == _rxTail) return 0; uint8_t data = _rxBuffer[_rxTail]; _rxTail = (_rxTail + 1) % 64; return data; } }; // 全局对象和中断服务例程 SoftUART_Rx myRx(2); ISR(TIMER2_COMPA_vect) { myRx.isr(); } void startBitISR() { myRx.startBitDetect(); } ``` ## 3. 主程序示例 ```c void setup() { Serial.begin(115200); myTx.begin(9600); myRx.begin(9600); // 注意:发送和接收使用同一个定时器,需要协调 // 实际应用中建议使用不同定时器或分时复用 } void loop() { // 非阻塞发送 myTx.write('A'); // 非阻塞接收 if (myRx.available()) { char c = myRx.read(); Serial.print("Received: "); Serial.println(c); } // 其他任务... } ``` # 注意事项与优化 - **定时器冲突**:Arduino 的 `millis()` 使用 Timer0,`tone()` 使用 Timer2,若使用 Timer2 需注意冲突。建议使用 Timer1 或调整库。 - **中断优先级**:接收起始位检测使用外部中断,应确保其优先级高于定时器中断,避免丢失起始位。 - **波特率误差**:定时器初值取整会引入误差,高波特率下需选择合适预分频。 - **缓冲区管理**:使用环形缓冲区,注意溢出处理,可增加溢出计数或丢弃策略。 - **多路复用**:若需多路软件串口,可扩展为多个实例,但需多个定时器或使用状态机轮询。 # 总结 通过定时器中断实现非阻塞软件串口,既保留了硬件串口的高效性,又增加了灵活性。本文提供的位级收发技巧可扩展至任意 GPIO 引脚,适用于各种资源受限的嵌入式平台。掌握此技术,你将能更从容地应对多串口需求,提升系统整体实时性。