4. 状态机基础:有限状态机(FSM)概念、状态转移表、状态机在固件中的常见实现模式

做固件逆向,你迟早会撞上状态机。说白了,状态机就是一套「当前在什么状态,遇到什么输入,下一步该干什么」的规则。我刚开始逆向一个工业控制器的协议栈时,面对几千行汇编代码完全摸不着头脑。后来发现,核心逻辑就是一个状态机——搞懂了状态转移,整个协议栈就清晰了。

这一节,我们就来聊聊有限状态机的基础概念、状态转移表,以及固件里最常见的几种实现模式。

4.1 有限状态机(FSM)概念

有限状态机,英文叫 Finite State Machine,简称 FSM。它描述的是一个系统在有限个状态之间跳转的行为。一个 FSM 包含以下几个要素:

  • 状态(State):系统在某一时刻所处的模式或阶段。
  • 输入(Input):触发状态转移的事件或条件。
  • 转移(Transition):从当前状态到下一个状态的规则。
  • 动作(Action):进入或离开某个状态时执行的操作。
  • 初始状态(Initial State):系统启动时的默认状态。

举个例子,一个简单的门禁系统:

  • 状态:LOCKED(锁定)、UNLOCKED(解锁)
  • 输入:INSERT_CARD(插卡)、CLOSE(关门)
  • 转移:LOCKED + INSERT_CARD → UNLOCKED;UNLOCKED + CLOSE → LOCKED

嗯,这里要注意:状态机的状态数量是有限的,所以叫「有限」状态机。固件里常见的状态机,状态数从几个到几十个不等。

4.2 状态转移表

状态转移表是描述 FSM 最直观的方式之一。它用表格的形式,把每个状态和每个输入对应的下一个状态列出来。我个人习惯在逆向时先画转移表,再画状态图。

下面是一个简单的 UART 协议解析状态机的转移表:

当前状态 输入字节 下一个状态 动作
IDLE 0xAA(帧头) RECV_LEN 清空缓冲区
IDLE 其他 IDLE 丢弃
RECV_LEN 任意 RECV_DATA 保存长度值
RECV_DATA 数据字节 RECV_DATA 存入缓冲区,计数减1
RECV_DATA 计数为0 CHECK_CRC 计算校验
CHECK_CRC 校验正确 IDLE 回调处理函数
CHECK_CRC 校验错误 ERROR 记录错误
ERROR 任意 IDLE 复位

你看,有了这张表,整个协议的处理流程就一目了然了。逆向时,我经常在 IDA Pro 里一边看汇编,一边在纸上画这种表。

小技巧: 在固件中定位状态机时,可以搜索 switch-case 或 if-else 链。如果发现一个函数里有很多对某个变量的判断,而且这个变量取值是连续的整数(0, 1, 2, 3...),那它很可能就是状态变量。

4.3 状态机在固件中的常见实现模式

固件里的状态机实现,说白了就那么几种套路。我总结了三种最常见的模式,你逆向时大概率会遇到。

4.3.1 switch-case 模式

这是最直接、最常见的实现方式。用一个 switch 语句,根据当前状态值跳转到不同的 case 分支,每个分支里处理输入并决定下一个状态。

// 典型的 switch-case 状态机
uint8_t state = STATE_IDLE;

void fsm_run(uint8_t input) {
    switch (state) {
        case STATE_IDLE:
            if (input == 0xAA) {
                state = STATE_RECV_LEN;
                // 清空缓冲区
            }
            break;
        case STATE_RECV_LEN:
            len = input;
            state = STATE_RECV_DATA;
            break;
        case STATE_RECV_DATA:
            buffer[--len] = input;
            if (len == 0) {
                state = STATE_CHECK_CRC;
            }
            break;
        // ... 其他状态
    }
}

这种模式在固件里非常普遍。逆向时,你看到一个大 switch,case 值是连续的整数,基本可以确定是状态机。

4.3.2 函数指针表模式

这种模式更高级一些。它用一个函数指针数组,把每个状态的处理函数存起来。状态转移时,直接通过数组索引调用对应的函数。

// 函数指针表模式
typedef void (*state_handler_t)(uint8_t input);

void handle_idle(uint8_t input);
void handle_recv_len(uint8_t input);
void handle_recv_data(uint8_t input);

state_handler_t state_table[] = {
    handle_idle,      // 状态 0
    handle_recv_len,  // 状态 1
    handle_recv_data  // 状态 2
};

uint8_t state = 0;

void fsm_run(uint8_t input) {
    state_table[state](input);
}

这种模式在逆向时比较隐蔽。你可能会看到一个函数指针数组,然后一个函数通过索引调用它。我曾经在一个蓝牙协议栈里遇到过这种实现,当时找了半天才把状态和函数对应上。

避坑指南: 函数指针表模式在逆向时容易混淆。因为状态值可能不是从0开始的,或者数组里混入了其他函数。我建议先找出所有被调用的函数,再反向推导状态值。

4.3.3 状态转移表模式

这种模式把状态转移规则直接放在一个二维表里。表的行是当前状态,列是输入,单元格里存的是下一个状态。

// 状态转移表模式
#define NUM_STATES 4
#define NUM_INPUTS 3

uint8_t transition_table[NUM_STATES][NUM_INPUTS] = {
    // 输入0  输入1  输入2
    { 1,      0,     3 },   // 状态0
    { 2,      1,     0 },   // 状态1
    { 2,      3,     1 },   // 状态2
    { 0,      0,     0 }    // 状态3
};

uint8_t state = 0;

void fsm_run(uint8_t input) {
    state = transition_table[state][input];
}

这种模式代码量最少,执行效率也高。逆向时,你看到一段连续的数据区,里面全是小整数,而且被一个函数用二维索引访问,那基本就是转移表了。

4.4 状态机在固件中的识别特征

根据我的经验,固件里识别状态机有几个关键特征:

  • 状态变量:一个全局变量或静态变量,值在0到N之间变化。
  • 循环结构:状态机通常在一个主循环或中断服务函数里运行。
  • 条件判断链:大量的 if-else 或 switch-case,判断条件就是状态变量。
  • 状态枚举:固件里通常会有枚举定义,比如 STATE_IDLE、STATE_BUSY 等。

下面这张图展示了状态机在固件中的典型结构:

固件状态机典型结构 主循环 / 中断 获取输入 状态变量 状态处理 转移表 / switch 更新状态 说明: 状态变量通常是一个 全局或静态 uint8_t
核心要点: 状态机逆向的关键是找到状态变量和转移逻辑。一旦你画出了状态转移图,整个固件的逻辑就清晰了。我个人习惯先用 IDA Pro 定位状态变量,然后跟踪它的读写位置,逐步还原出完整的转移表。

4.5 实战中的注意事项

最后,分享几个我在实战中踩过的坑:

  • 状态值可能被优化:编译器有时会把状态变量优化成位域或寄存器变量,导致你在内存中找不到它。这时候要关注函数的参数传递。
  • 状态机可能嵌套:一个状态内部可能还有子状态机。我曾经在一个协议栈里遇到过三层嵌套的状态机,当时花了整整两天才理清楚。
  • 中断上下文:有些状态机是在中断里运行的,状态变量可能被多个中断共享。这时候要注意同步机制,比如关中断或信号量。