状态机在通信协议中的应用:UART协议解析、I2C状态机、SPI状态机

通信协议解析,说白了就是状态机最拿手的活儿。你想想看,不管是UART、I2C还是SPI,数据都是一位一位、一帧一帧地传过来的。怎么把这些比特流还原成有意义的指令或数据?状态机就是干这个的。

我个人习惯,拿到一个通信协议,第一件事不是写代码,而是画状态图。把每个状态、每个跳转条件理清楚,代码自然就出来了。今天我就拿三个最常用的串行协议,带你看看状态机是怎么在里面发挥作用的。

UART协议解析状态机

UART协议,大家都很熟悉了。起始位、数据位、校验位、停止位,就这么简单。但真正做解析的时候,坑其实不少。

我记得有一次做项目,用UART接收GPS模块的数据。模块每秒输出一串NMEA语句,波特率115200。按理说挺简单的,但就是偶尔会丢帧。后来一查,问题出在起始位检测上——噪声干扰导致误触发。

来看看一个典型的UART接收状态机:

typedef enum {
    UART_IDLE,
    UART_START,
    UART_DATA,
    UART_PARITY,
    UART_STOP
} UartState_t;

typedef struct {
    UartState_t state;
    uint8_t data_byte;
    uint8_t bit_count;
    uint16_t rx_buffer[RX_BUF_SIZE];
    uint16_t buf_index;
} UartRx_t;

void uart_rx_state_machine(UartRx_t *ctx, uint8_t pin_sample) {
    switch (ctx->state) {
        case UART_IDLE:
            // 检测下降沿:起始位
            if (pin_sample == 0) {
                ctx->state = UART_START;
                ctx->bit_count = 0;
                ctx->data_byte = 0;
                // 启动定时器,在数据位中间采样
                start_bit_timer();
            }
            break;

        case UART_START:
            // 确认起始位有效(防抖动)
            if (pin_sample == 0) {
                ctx->state = UART_DATA;
                ctx->bit_count = 0;
            } else {
                ctx->state = UART_IDLE;  // 噪声,回到空闲
            }
            break;

        case UART_DATA:
            // 采样8个数据位,LSB first
            ctx->data_byte >>= 1;
            if (pin_sample) ctx->data_byte |= 0x80;
            ctx->bit_count++;
            if (ctx->bit_count == 8) {
                ctx->state = UART_PARITY;  // 或无校验则直接到STOP
            }
            break;

        case UART_PARITY:
            // 奇偶校验检查(略)
            ctx->state = UART_STOP;
            break;

        case UART_STOP:
            if (pin_sample == 1) {
                // 停止位有效,接收完成
                ctx->rx_buffer[ctx->buf_index] = ctx->data_byte;
                ctx->buf_index = (ctx->buf_index + 1) % RX_BUF_SIZE;
            }
            ctx->state = UART_IDLE;
            break;
    }
}

避坑指南:我曾经在UART起始位检测上吃过亏。直接用边沿中断触发,结果线缆一长,噪声脉冲就进来了。后来我加了一个「确认采样」——检测到下降沿后,等半个位周期再采一次,确认还是低电平才认。这个技巧在工业现场特别管用。

I2C状态机设计

I2C比UART复杂一些,因为它有主从关系,还有总线仲裁。但状态机的思路是一样的——把每个总线事件映射成一个状态。

I2C的核心事件就几个:起始条件、停止条件、数据发送、数据接收、应答位。我习惯把I2C主机的状态机分成发送和接收两条路径。

typedef enum {
    I2C_IDLE,
    I2C_START,
    I2C_SEND_ADDR,
    I2C_SEND_DATA,
    I2C_RECV_DATA,
    I2C_ACK,
    I2C_NACK,
    I2C_STOP,
    I2C_ERROR
} I2cState_t;

typedef struct {
    I2cState_t state;
    uint8_t address;
    uint8_t *tx_data;
    uint8_t *rx_data;
    uint8_t tx_len;
    uint8_t rx_len;
    uint8_t byte_count;
    uint8_t scl, sda;  // 总线电平状态
} I2cMaster_t;

void i2c_master_state_machine(I2cMaster_t *ctx) {
    switch (ctx->state) {
        case I2C_IDLE:
            if (start_condition_detected()) {
                ctx->state = I2C_START;
                ctx->byte_count = 0;
            }
            break;

        case I2C_START:
            // 发送从机地址 + R/W位
            send_byte(ctx->address);
            ctx->state = I2C_SEND_ADDR;
            break;

        case I2C_SEND_ADDR:
            if (ack_received()) {
                if (ctx->address & 0x01) {
                    // 读操作
                    ctx->state = I2C_RECV_DATA;
                } else {
                    // 写操作
                    ctx->state = I2C_SEND_DATA;
                }
            } else {
                ctx->state = I2C_NACK;
            }
            break;

        case I2C_SEND_DATA:
            send_byte(ctx->tx_data[ctx->byte_count]);
            ctx->byte_count++;
            if (ctx->byte_count >= ctx->tx_len) {
                ctx->state = I2C_STOP;
            }
            // 等待ACK后继续
            break;

        case I2C_RECV_DATA:
            ctx->rx_data[ctx->byte_count] = recv_byte();
            ctx->byte_count++;
            if (ctx->byte_count >= ctx->rx_len) {
                send_nack();  // 最后一字节发NACK
                ctx->state = I2C_STOP;
            } else {
                send_ack();
            }
            break;

        case I2C_STOP:
            generate_stop_condition();
            ctx->state = I2C_IDLE;
            break;

        case I2C_NACK:
        case I2C_ERROR:
            generate_stop_condition();
            ctx->state = I2C_IDLE;
            break;
    }
}

关键点:I2C状态机里最容易出问题的地方是「时钟拉伸」。有些从机在发送数据前会把SCL拉低,告诉主机「我还没准备好」。如果状态机没处理这个情况,直接按固定时序跑,就会导致数据错位。我建议在每次SCL变化后都加一个超时检测,超过一定时间就进入ERROR状态。

SPI状态机实现

SPI协议相对简单,因为它有独立的时钟线和双向数据线。但SPI的难点在于「模式」——CPOL和CPHA的组合决定了数据在时钟的哪个边沿采样。

我个人习惯,把SPI状态机分成主模式和从模式两种。主模式控制时钟,从模式跟随时钟。这里给一个从模式SPI接收状态机的例子:

typedef enum {
    SPI_SLAVE_IDLE,
    SPI_SLAVE_CS_ACTIVE,
    SPI_SLAVE_RECV_BYTE,
    SPI_SLAVE_SEND_BYTE,
    SPI_SLAVE_CS_INACTIVE
} SpiSlaveState_t;

typedef struct {
    SpiSlaveState_t state;
    uint8_t rx_byte;
    uint8_t tx_byte;
    uint8_t bit_count;
    uint8_t *rx_buffer;
    uint8_t *tx_buffer;
    uint8_t buf_len;
    uint8_t buf_index;
    uint8_t cpol, cpha;  // SPI模式参数
} SpiSlave_t;

void spi_slave_state_machine(SpiSlave_t *ctx, uint8_t sclk, uint8_t mosi, uint8_t cs) {
    switch (ctx->state) {
        case SPI_SLAVE_IDLE:
            if (cs == 0) {  // 片选拉低,开始传输
                ctx->state = SPI_SLAVE_CS_ACTIVE;
                ctx->bit_count = 0;
                ctx->rx_byte = 0;
                ctx->buf_index = 0;
            }
            break;

        case SPI_SLAVE_CS_ACTIVE:
            // 等待第一个时钟边沿
            ctx->state = SPI_SLAVE_RECV_BYTE;
            break;

        case SPI_SLAVE_RECV_BYTE:
            // 在正确的时钟边沿采样MOSI
            if (clock_edge_detected(ctx->cpol, ctx->cpha, sclk)) {
                ctx->rx_byte <<= 1;
                if (mosi) ctx->rx_byte |= 0x01;
                ctx->bit_count++;

                // 同时发送MISO数据
                set_miso(ctx->tx_buffer[ctx->buf_index] & 0x80);
                ctx->tx_buffer[ctx->buf_index] <<= 1;

                if (ctx->bit_count == 8) {
                    // 一个字节接收完成
                    ctx->rx_buffer[ctx->buf_index] = ctx->rx_byte;
                    ctx->buf_index++;
                    ctx->bit_count = 0;
                    ctx->rx_byte = 0;

                    if (ctx->buf_index >= ctx->buf_len) {
                        ctx->state = SPI_SLAVE_CS_INACTIVE;
                    }
                }
            }

            // 检测片选是否提前释放
            if (cs == 1) {
                ctx->state = SPI_SLAVE_CS_INACTIVE;
            }
            break;

        case SPI_SLAVE_CS_INACTIVE:
            // 传输结束,复位
            ctx->state = SPI_SLAVE_IDLE;
            break;
    }
}

注意:SPI从模式状态机里,片选信号(CS)的优先级是最高的。不管当前在哪个状态,只要CS拉高,就应该立即回到IDLE状态。我曾经遇到过一个bug,从机在接收过程中CS被噪声拉高了一下又拉低,结果状态机没复位,后面的数据全乱了。后来我在每个状态里都加了CS检测,才彻底解决。

三种协议状态机的对比

特性 UART I2C SPI
状态数(典型) 5-6个 8-10个 4-5个
时钟来源 本地波特率 主机产生,从机可拉伸 主机产生,固定速率
同步方式 异步,需位定时 同步,需时钟边沿 同步,需时钟边沿
状态机难点 起始位检测、噪声抑制 时钟拉伸、总线仲裁 片选管理、模式匹配
典型应用 GPS、蓝牙模块 传感器、EEPROM 显示屏、ADC、SD卡

通信协议状态机的通用设计原则

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

  1. 每个状态只做一件事。比如UART的起始位检测就只检测起始位,不要在里面顺便采样数据。状态职责越单一,调试越容易。
  2. 超时处理不能少。通信线缆随时可能断开,状态机必须能超时复位。我一般会在每个状态里加一个计数器,超过N个时钟周期没跳转就强制回到IDLE。
  3. 错误状态要收敛。所有错误状态最终都要回到IDLE,不要出现「死状态」。我曾经见过一个设计,状态机进了ERROR状态后没有出口,只能靠看门狗复位。
  4. 状态切换用查表法。如果状态和跳转条件很多,用switch-case会变得很冗长。我建议用状态转移表,把当前状态和输入条件映射到下一个状态,代码更清晰。

一个小技巧:调试通信协议状态机的时候,我习惯在每个状态入口加一个GPIO翻转。用示波器看GPIO的波形,就能直观地看到状态机在哪个状态卡住了。这个方法帮我省了无数调试时间。

通信协议状态机核心流程对比 UART 接收状态机 IDLE START DATA STOP 超时/错误 I2C 主机状态机 IDLE START ADDR SEND RECV STOP NACK/错误 SPI 从机状态机 IDLE CS_ACT RECV_BYTE CS_INACT CS拉高 三种协议状态机的共同点:IDLE → 起始条件 → 数据传输 → 结束条件 → 回到IDLE 区别在于:UART需位定时,I2C有应答和仲裁,SPI依赖片选和时钟模式

好了,三种协议的状态机实现就聊到这儿。其实核心思想都一样——把通信过程拆成离散的状态,每个状态处理一个明确定义的事件。你掌握了这个思路,不管以后遇到什么协议,CAN也好、LIN也好、甚至自定义协议,都能用状态机轻松搞定。