Arduino 非阻塞软件串口:定时器中断驱动的位级收发实战
👁 3 阅读 · 2026-08-27 · 嵌入式
在资源受限的嵌入式系统中,硬件串口数量往往不足。本文深入探讨如何在 Arduino 平台上利用定时器中断实现非阻塞式软件串口,通过位级精确的时序控制,实现可靠的异步串行通信。文章将从原理剖析、代码实现到调试技巧,带你掌握这一高性能外设扩展技术。
# 引言
在嵌入式开发中,串口是最常用的调试和通信接口。但 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 引脚,适用于各种资源受限的嵌入式平台。掌握此技术,你将能更从容地应对多串口需求,提升系统整体实时性。