# 引言 在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上的非阻塞式多路软件串口接收。该方案不仅提升了系统响应速度,还保证了多路数据的同时接收不丢失。在实际项目中,可根据需求调整缓冲区大小、采样频率和轮询策略,以达到最佳性能。希望本文能为你解决多串口通信难题提供有力参考。