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 里一边看汇编,一边在纸上画这种表。
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);
}
这种模式在逆向时比较隐蔽。你可能会看到一个函数指针数组,然后一个函数通过索引调用它。我曾经在一个蓝牙协议栈里遇到过这种实现,当时找了半天才把状态和函数对应上。
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等。
下面这张图展示了状态机在固件中的典型结构:
4.5 实战中的注意事项
最后,分享几个我在实战中踩过的坑:
- 状态值可能被优化:编译器有时会把状态变量优化成位域或寄存器变量,导致你在内存中找不到它。这时候要关注函数的参数传递。
- 状态机可能嵌套:一个状态内部可能还有子状态机。我曾经在一个协议栈里遇到过三层嵌套的状态机,当时花了整整两天才理清楚。
- 中断上下文:有些状态机是在中断里运行的,状态变量可能被多个中断共享。这时候要注意同步机制,比如关中断或信号量。