Arduino 非阻塞式多路软件串口接收:环形缓冲区设计与实现
👁 1 阅读 · 2026-08-27 · 嵌入式
在资源受限的嵌入式系统中,硬件串口数量往往不足,软件模拟串口成为扩展通信能力的常用方案。然而,传统阻塞式接收会严重拖累主循环,尤其在多路并发场景下极易丢失数据。本文深入剖析基于环形缓冲区的非阻塞式多路软件串口接收设计,从原理到代码实现,助你打造高效、稳定的多串口通信系统。
# 引言
在Arduino等嵌入式平台上,硬件串口资源通常只有1~2个,而实际项目(如多传感器采集、多设备联动)往往需要更多串口。软件模拟串口(SoftwareSerial)虽能扩展,但其默认实现是阻塞式的——接收数据时CPU必须持续等待,导致主循环卡顿,多路同时接收时数据丢失严重。
本文提出一种基于环形缓冲区(Ring Buffer)的非阻塞式接收方案,通过中断驱动+缓冲存储,让串口接收在后台自动完成,主循环只需定期读取缓冲数据,彻底解放CPU。
# 原理剖析
## 1. 环形缓冲区核心思想
环形缓冲区是一种固定大小的FIFO数据结构,通过头尾指针循环使用内存。写入时尾指针前进,读取时头指针前进,当指针到达末尾时回绕到起点。其优势在于:
- 无动态内存分配,适合嵌入式环境
- 读写操作O(1)复杂度,不涉及数据搬移
- 天然支持生产者-消费者模型(中断写入,主循环读取)
## 2. 非阻塞式接收机制
传统SoftwareSerial在`listen()`后,通过`available()`和`read()`轮询接收,但底层是忙等待。我们的方案改为:
- 利用外部中断(如PCINT)检测引脚电平变化
- 在中断服务程序(ISR)中采样位信号,将字节写入环形缓冲区
- 主循环通过`available()`和`read()`从缓冲区读取数据,无需等待
## 3. 多路复用策略
多路软件串口共享一个定时器中断(如Timer2),通过分时采样不同引脚。每路串口拥有独立的环形缓冲区和状态标志,ISR根据当前激活的串口索引进行采样,实现多路并发接收。
# 硬件与软件配置
## 硬件需求
- Arduino Uno/Nano(ATmega328P)
- 多个串口设备(如GPS、蓝牙模块)
- 跳线连接:设备TX→Arduino数字引脚(如D8、D9)
## 软件配置步骤
1. **定义环形缓冲区结构体**
```c
typedef struct {
uint8_t buffer[BUFFER_SIZE];
volatile uint8_t head;
volatile uint8_t tail;
} RingBuffer;
```
2. **初始化缓冲区**
```c
void ringBufferInit(RingBuffer *rb) {
rb->head = 0;
rb->tail = 0;
}
```
3. **写入与读取操作**
```c
bool ringBufferWrite(RingBuffer *rb, uint8_t data) {
uint8_t next = (rb->tail + 1) % BUFFER_SIZE;
if (next == rb->head) return false; // 缓冲区满
rb->buffer[rb->tail] = data;
rb->tail = next;
return true;
}
bool ringBufferRead(RingBuffer *rb, uint8_t *data) {
if (rb->head == rb->tail) return false; // 空
*data = rb->buffer[rb->head];
rb->head = (rb->head + 1) % BUFFER_SIZE;
return true;
}
```
4. **配置定时器中断(以Timer2为例)**
```c
void setupTimer2() {
TCCR2A = 0;
TCCR2B = (1 << CS21); // 8分频,约2kHz采样率(根据波特率调整)
TIMSK2 = (1 << OCIE2A);
OCR2A = 249; // 设定比较值
}
ISR(TIMER2_COMPA_vect) {
// 采样当前激活串口的引脚电平,进行位检测
// 每bit采样多次,根据波特率计算采样点
}
```
5. **实现多路软件串口类**
```c
class SoftwareSerialRing {
public:
SoftwareSerialRing(uint8_t rxPin, uint8_t txPin);
void begin(uint32_t baud);
int available();
int read();
void process(); // 在ISR中调用
private:
uint8_t _rxPin, _txPin;
RingBuffer _rxBuffer;
volatile uint8_t _state; // 接收状态机
// ...
};
```
# 完整代码示例
以下是一个支持两路软件串口的完整实现(简化版):
```c
#define BUFFER_SIZE 64
#define NUM_PORTS 2
// 环形缓冲区结构体
typedef struct {
uint8_t buffer[BUFFER_SIZE];
volatile uint8_t head;
volatile uint8_t tail;
} RingBuffer;
// 软件串口对象
class SoftSerial {
public:
SoftSerial(uint8_t rxPin) : _rxPin(rxPin) {
ringBufferInit(&_rxBuffer);
}
void begin(uint32_t baud) {
_bitTime = 1000000UL / baud; // 微秒
pinMode(_rxPin, INPUT_PULLUP);
}
int available() {
return (_rxBuffer.head != _rxBuffer.tail);
}
int read() {
uint8_t data;
if (ringBufferRead(&_rxBuffer, &data)) return data;
return -1;
}
void process(uint8_t sample) {
// 状态机:检测起始位,采样数据位,停止位
// 此处省略详细实现,核心是每bit采样多次,根据采样值判断0/1
// 完整代码见文末链接
}
private:
uint8_t _rxPin;
RingBuffer _rxBuffer;
uint32_t _bitTime;
// 状态机变量
};
SoftSerial ss1(8);
SoftSerial ss2(9);
void setup() {
Serial.begin(9600);
ss1.begin(9600);
ss2.begin(9600);
setupTimer2();
}
void loop() {
// 非阻塞读取第一路
while (ss1.available()) {
char c = ss1.read();
Serial.print("Port1: ");
Serial.println(c);
}
// 非阻塞读取第二路
while (ss2.available()) {
char c = ss2.read();
Serial.print("Port2: ");
Serial.println(c);
}
// 其他任务...
}
// 定时器中断服务程序
ISR(TIMER2_COMPA_vect) {
static uint8_t portIndex = 0;
// 轮流采样各端口
uint8_t sample = digitalRead(ss1._rxPin);
ss1.process(sample);
sample = digitalRead(ss2._rxPin);
ss2.process(sample);
// 注意:实际中需根据波特率调整采样频率和轮询策略
}
```
# 注意事项
- **缓冲区大小**:根据数据流量和主循环处理速度合理设置,过小易溢出,过大浪费内存。建议64~256字节。
- **中断优先级**:定时器中断中执行时间必须极短,避免影响其他中断。采样和状态机操作应尽量精简。
- **波特率与采样率**:采样率需至少为波特率的8倍以上(如9600波特率,采样率需>76.8kHz),否则易误码。
- **多路轮询**:多路串口共享中断时,轮询频率会降低,需确保每路的采样率仍满足要求,必要时可提高定时器频率或使用更高效的采样算法。
- **临界区保护**:主循环读取缓冲区时,若中断同时写入,需暂时关闭中断或使用原子操作,防止数据竞争。
- **引脚选择**:优先选择支持PCINT的引脚,便于外部中断触发,但本方案使用定时器轮询,对引脚无特殊要求。
# 总结
通过环形缓冲区与定时器中断的结合,我们成功实现了Arduino上的非阻塞式多路软件串口接收。该方案不仅提升了系统响应速度,还保证了多路数据的同时接收不丢失。在实际项目中,可根据需求调整缓冲区大小、采样频率和轮询策略,以达到最佳性能。希望本文能为你解决多串口通信难题提供有力参考。