引言
在嵌入式开发中,串口通信是基础且常用的接口。然而,许多微控制器(如 Arduino Uno 的 ATmega328P)仅提供 1-2 个硬件 UART,当需要连接多个串口设备(如 GPS、蓝牙、传感器模块)时,硬件资源捉襟见肘。软件串口(Software Serial)应运而生,它通过 GPIO 引脚和定时器模拟 UART 协议。
传统软件串口(如 Arduino 官方 SoftwareSerial 库)采用阻塞式位采样:发送或接收时,通过 delayMicroseconds() 精确延时,期间 CPU 被完全占用,无法处理其他任务。这在多任务场景下会引发严重问题,例如中断响应延迟、数据丢失等。
本文提出一种非阻塞式软件串口实现方案,利用定时器输入捕获(Input Capture)功能,在硬件层面自动记录引脚电平变化的时间戳,配合状态机解析,实现位级采样,从而释放 CPU 资源。
原理讲解
UART 协议基础
UART 帧格式:起始位(低电平)、8 个数据位(LSB 先发)、可选校验位、停止位(高电平)。空闲时,TX 线保持高电平。接收端需要以波特率(如 9600bps)为周期采样每个位。
阻塞式 vs 非阻塞式
- 阻塞式:接收时,检测到起始位下降沿后,循环延时采样每个位。期间无法响应其他中断,CPU 利用率低。
- 非阻塞式:利用定时器输入捕获中断,每当引脚电平变化(上升沿或下降沿)时,硬件自动记录当前定时器计数值并触发中断。软件在中断中记录边沿时间,通过分析边沿间隔还原数据位。
定时器输入捕获原理
以 Arduino Uno(ATmega328P)为例,定时器 1 是 16 位定时器,支持输入捕获单元。将 RX 引脚连接到 ICP1(Pin 8),配置定时器 1 为普通模式,开启输入捕获中断。当引脚发生指定边沿(如下降沿)时,定时器计数器值被复制到 ICR1 寄存器,并触发中断。
通过记录连续边沿的时间差,可以判断每个位的持续时间。例如,波特率 9600 时,位时间约为 104.17μs。若检测到两个下降沿间隔约为 1 个位时间,则中间为 0;若间隔为 2 个位时间,则中间为连续的 0(如 0b00)。
配置步骤
- 硬件连接:将外部设备的 TX 引脚连接到 Arduino 的 Pin 8(ICP1),共地。
-
定时器配置:
- 设置定时器 1 为普通模式,预分频为 8(计数频率 2MHz,分辨率 0.5μs)。
- 开启输入捕获中断(TIMSK1 |= (1<<ICIE1))。
- 设置边沿触发为下降沿(TCCR1B &= ~(1<<ICES1))。
- 中断服务程序:在 ISR 中读取 ICR1,计算与上次捕获的时间差,并调用状态机处理。
-
状态机设计:
- 状态 IDLE:等待起始位(下降沿)。
- 状态 RECEIVING:根据边沿间隔解析数据位,直到收到停止位。
完整代码示例
以下代码实现一个非阻塞式软件串口接收功能,可接收 8 位数据,无校验位。
// 非阻塞软件串口接收示例(Arduino Uno)
// 使用定时器1输入捕获,Pin 8 (ICP1) 作为 RX
volatile uint32_t last_capture = 0; // 上次捕获时间
volatile uint8_t rx_byte = 0; // 接收到的字节
volatile uint8_t rx_ready = 0; // 接收完成标志
volatile uint8_t bit_count = 0; // 已接收的位数
volatile uint8_t current_byte = 0; // 正在接收的字节
volatile uint8_t state = 0; // 状态机状态:0=IDLE, 1=RECEIVING
#define BIT_TIME 104 // 9600bps 位时间(μs),预分频8时计数单位0.5μs,故104*2=208计数
void setup() {
Serial.begin(115200); // 调试串口
pinMode(8, INPUT); // ICP1 引脚
// 配置定时器1
TCCR1A = 0; // 普通模式
TCCR1B = (1<<CS11); // 预分频8,计数频率2MHz
TIMSK1 |= (1<<ICIE1); // 使能输入捕获中断
TCCR1B &= ~(1<<ICES1); // 下降沿触发
sei(); // 全局中断使能
}
ISR(TIMER1_CAPT_vect) {
uint32_t now = ICR1; // 读取捕获值
uint32_t diff = now - last_capture; // 计算时间差(考虑溢出)
last_capture = now;
// 将计数差值转换为位时间倍数(约0.5μs/计数)
uint16_t bit_units = diff / (BIT_TIME * 2); // 2MHz下1位时间=208计数
if (state == 0) {
// IDLE状态:下降沿到来,视为起始位
state = 1;
bit_count = 0;
current_byte = 0;
// 注意:起始位本身是低电平,但我们已经捕获到下降沿,无需处理
} else {
// RECEIVING状态:处理数据位和停止位
// 每个下降沿表示一个位从1到0的变化,因此需要根据间隔填充0位
// 填充间隔中的0位(bit_units-1 个0,因为下降沿本身代表一个0)
for (uint8_t i = 0; i < bit_units - 1; i++) {
if (bit_count < 8) {
current_byte >>= 1; // LSB先发,所以右移
// 注意:当前位是0,无需置位
bit_count++;
}
}
// 当前下降沿对应的位是0(因为下降沿表示该位为0)
if (bit_count < 8) {
current_byte >>= 1;
bit_count++;
}
// 如果已接收8位,则等待停止位(上升沿)
if (bit_count == 8) {
// 停止位应为高电平,但这里我们通过下一次上升沿确认
// 简单处理:直接认为接收完成,实际应用中可校验停止位
rx_byte = current_byte;
rx_ready = 1;
state = 0; // 回到IDLE
}
}
}
void loop() {
if (rx_ready) {
rx_ready = 0;
Serial.print("收到: 0x");
Serial.println(rx_byte, HEX);
}
// 其他任务可以在这里执行,不会阻塞
}
注意事项
- 定时器溢出处理:上述代码未处理定时器溢出(16位计数器溢出约32.7ms),在低波特率或长空闲时可能出错。建议在溢出中断中扩展高位,或使用更高预分频。
- 边沿检测:本方案仅依赖下降沿,对于连续0的位(如0x00)需要计算间隔,但若出现连续多个0,间隔会较长,需确保定时器不溢出。
- 波特率误差:晶振误差和预分频会导致位时间偏差,建议使用误差小于2%的晶振,并在代码中微调BIT_TIME。
- 中断优先级:输入捕获中断应具有较高优先级,避免被其他中断延迟导致时间戳不准确。
- 发送方向:本文仅实现接收,发送可类似使用定时器比较输出(CTC模式)或占用CPU,但通常发送不频繁,阻塞影响较小。
总结
基于定时器输入捕获的软件串口,利用硬件自动记录边沿时间,配合状态机解析,实现了非阻塞式接收。这种方法显著提高了 CPU 利用率,使 Arduino 能够同时处理多个串口通信和其他实时任务。开发者可根据实际需求扩展为完整收发功能,并优化溢出处理。掌握这一技巧,将极大提升嵌入式系统的通信设计能力。