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个状态:

  1. IDLE:等待起始位下降沿
  2. START:确认起始位有效(防毛刺)
  3. DATA:逐位采样数据(通常过采样16倍)
  4. PARITY:校验位检查(如果使能)
  5. 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 四种协议状态机对比

四种通信协议状态机核心对比 UART 5个状态 过采样+位计数 无地址/无仲裁 I2C 8+个状态 地址匹配+应答 时钟拉伸检测 SPI 5个状态 CPOL/CPHA可配 全双工移位 CAN 10+个状态 仲裁+错误管理 位时间同步 状态机复杂度对比 UART ★★ I2C ★★★ SPI ★★ CAN ★★★★★ 设计建议 简单协议(UART/SPI):状态机用switch-case,清晰可读 复杂协议(I2C/CAN):建议用状态表+状态指针,便于维护

3.7 状态机设计通用原则

做了这么多年嵌入式,我总结了几条通信协议状态机的设计原则:

  1. 状态可穷举:每个状态必须有明确的进入条件和退出条件,不能有“悬空”状态
  2. 超时保护:每个状态都要有超时计数器,防止总线异常时状态机卡死
  3. 状态可恢复:任何非法输入都能让状态机回到IDLE,而不是进入未定义状态
  4. 单时钟域:状态机最好在一个时钟域内运行,跨时钟域要加同步器

我的习惯: 写状态机之前,先画状态转移图。哪怕用纸笔画也行。状态图画清楚了,代码就是翻译工作。我见过太多人直接上手写代码,结果状态跳转逻辑自己都理不清。