3、通信协议中的状态机:UART、I2C、SPI、CAN
各位好,我是老周。今天咱们聊聊嵌入式开发里最实在的东西——四种主流通信协议的状态机实现。说实话,我见过太多工程师拿着逻辑分析仪抓波形,却看不懂状态跳转,最后只能靠猜来调bug。这章咱们就把UART、I2C、SPI、CAN这四兄弟的状态机掰开揉碎了讲清楚。
3.1 为什么通信协议需要状态机?
你想想看,一根数据线上,主设备要发地址、发数据、等应答,从设备要识别地址、收数据、回应答。如果没有状态机,这些时序根本没法管理。状态机就是通信协议的“交通警察”——每个时刻该干什么,下一时刻去哪里,全由它说了算。
核心观点: 通信协议状态机本质上是“时序+逻辑”的二维控制。状态决定当前行为,输入信号决定下一状态。
3.2 UART协议状态机
UART是最简单的串行协议,但它的状态机设计反而最容易翻车。我记得刚入行时调一个115200bps的UART,接收端老是丢字节,最后发现是状态机在停止位判断上少了一个时钟周期。
3.2.1 UART帧结构回顾
| 位域 | 起始位 | 数据位(LSB first) | 校验位(可选) | 停止位 |
|---|---|---|---|---|
| 长度 | 1 bit | 5~8 bits | 0/1 bit | 1/1.5/2 bits |
| 电平 | 低 | 高/低 | 奇/偶 | 高 |
3.2.2 UART接收状态机
UART接收状态机一般分5个状态:
- IDLE:等待起始位下降沿
- START:确认起始位有效(防毛刺)
- DATA:逐位采样数据(通常过采样16倍)
- PARITY:校验位检查(如果使能)
- STOP:等待停止位,完成后回到IDLE
避坑指南: 我曾经在DATA状态里直接用移位寄存器收完8位就跳转,结果遇到5位数据模式直接崩了。正确做法是:用bit_counter计数,数据位长度可配置。
// UART接收状态机伪代码
typedef enum {
IDLE, START, DATA, PARITY, STOP
} uart_rx_state_t;
void uart_rx_fsm(uart_rx_state_t *state, uint8_t rx_pin) {
switch(*state) {
case IDLE:
if(rx_pin == 0) *state = START; // 检测下降沿
break;
case START:
// 过采样中点确认
if(sample_center() == 0) *state = DATA;
else *state = IDLE; // 毛刺,丢弃
break;
case DATA:
shift_in_bit(rx_pin); // 每16个采样时钟收1位
if(bit_count == data_bits) *state = PARITY;
break;
// ... 后续状态
}
}
3.3 I2C协议状态机
I2C比UART复杂得多,因为它有主从角色、地址匹配、应答机制。我做过一个项目,I2C总线上挂了6个从设备,状态机写得不好,结果主设备发地址时从设备应答混乱,整个总线卡死。
3.3.1 I2C主设备状态机
主设备状态机至少包含以下状态:
- IDLE:总线空闲(SCL高,SDA高)
- START:产生起始条件(SDA先拉低)
- ADDR:发送7位地址+读写位
- ACK_ADDR:等待从设备应答
- DATA_TX:发送数据字节
- DATA_RX:接收数据字节
- ACK_DATA:发送/接收应答位
- STOP:产生停止条件
注意: I2C状态机最容易被忽略的是“时钟拉伸”(Clock Stretching)。从设备可能拉低SCL来延长时钟,主设备必须检测SCL电平,不能按固定时序跳转。
3.3.2 I2C从设备状态机
从设备状态机多了地址匹配逻辑:
// I2C从设备状态机简化版
switch(state) {
case IDLE:
if(sda_falling && scl_high) state = ADDR; // 检测起始
break;
case ADDR:
shift_in_7bit_addr();
if(addr_match) {
send_ack();
state = (rw_bit) ? DATA_TX : DATA_RX;
} else {
state = IDLE; // 不匹配,忽略
}
break;
// ...
}
3.4 SPI协议状态机
SPI看起来简单——主设备控制时钟,全双工。但它的状态机设计有个坑:时钟极性和相位(CPOL/CPHA)不同,采样边沿就不同。我见过有人把CPOL=1、CPHA=0的时序当成模式0来写状态机,结果数据全错位。
3.4.1 SPI主设备状态机
| 状态 | 动作 | 跳转条件 |
|---|---|---|
| IDLE | SCK=CPOL,CS=高 | 写FIFO非空 → GO |
| GO | CS拉低,启动时钟 | 时钟使能 → SHIFT |
| SHIFT | 每时钟沿移出1位、移入1位 | bit_count=7 → WAIT |
| WAIT | 等待字节间隙(可选) | 更多数据 → SHIFT;否则 → STOP |
| STOP | CS拉高,停止时钟 | 回到IDLE |
个人经验: 我习惯把SPI状态机的时钟边沿做成可配置参数。这样同一套状态机代码,改个寄存器就能适配所有4种SPI模式,不用重写。
3.5 CAN协议状态机
CAN协议的状态机是这四种里最复杂的。它不光有收发状态,还有总线仲裁、错误处理、同步机制。说实话,我第一次看CAN状态机时头都大了。
3.5.1 CAN控制器核心状态
CAN控制器内部状态机分为两大块:发送状态机和接收状态机。这里我重点讲发送部分:
- IDLE:总线空闲,等待发送请求
- ARBITRATION:发送ID并监听总线,如果输掉仲裁则退出发送
- CTRL:发送控制场(IDE、DLC等)
- DATA:发送数据字节(0~8字节)
- CRC:发送CRC校验码
- ACK:等待接收节点应答
- EOF:发送帧结束标志
关键点: CAN的仲裁机制本质上是“线与”逻辑。状态机在ARBITRATION状态必须逐位比较发送值和总线值,一旦发现不一致,立即退出并转为接收状态。这个跳转必须在1个位时间内完成。
3.5.2 错误状态机
CAN协议还有一个独立的错误状态机,管理节点的三种状态:
| 状态 | 错误计数 | 行为 |
|---|---|---|
| Error Active | TX/RX < 128 | 正常收发,发送主动错误标志 |
| Error Passive | 128 ≤ 计数 < 256 | 只能发送被动错误标志 |
| Bus Off | TX ≥ 256 | 完全断开总线,等待恢复 |
我曾经踩过的坑: 在CAN错误状态机里,Bus Off恢复需要检测128次11个隐性位。我一开始写成了连续检测128个位,结果总线永远恢复不了。正确做法是:检测到11个连续隐性位算一次“恢复机会”,累计128次才退出Bus Off。
3.6 四种协议状态机对比
3.7 状态机设计通用原则
做了这么多年嵌入式,我总结了几条通信协议状态机的设计原则:
- 状态可穷举:每个状态必须有明确的进入条件和退出条件,不能有“悬空”状态
- 超时保护:每个状态都要有超时计数器,防止总线异常时状态机卡死
- 状态可恢复:任何非法输入都能让状态机回到IDLE,而不是进入未定义状态
- 单时钟域:状态机最好在一个时钟域内运行,跨时钟域要加同步器
我的习惯: 写状态机之前,先画状态转移图。哪怕用纸笔画也行。状态图画清楚了,代码就是翻译工作。我见过太多人直接上手写代码,结果状态跳转逻辑自己都理不清。