2. 状态机数据结构设计:定义NMT状态枚举、状态切换回调函数指针结构体、状态上下文结构体
好,咱们进入第二章。上一章我们聊了NMT状态机的基本概念,说白了就是那四个状态怎么转来转去。但概念归概念,落到代码里,你得有东西去描述它、存储它、操作它。
这一章,我们就来设计这些“东西”——数据结构。
我个人习惯,写任何状态机之前,先把数据模型搭好。就像盖房子,先画好图纸,再砌砖。代码结构清晰了,后面调试才不抓狂。我记得刚入行那会儿,上来就写switch-case,结果状态多了,回调满天飞,改一个地方崩一片……嗯,后来学乖了。
2.1 NMT状态枚举:把状态变成数字
状态机嘛,首先得把“状态”本身定义清楚。在C语言里,最直观的方式就是枚举。你想想看,总不能满代码飞着“0x00”、“0x04”这种魔数吧?那维护起来简直是噩梦。
我们参考CiA 301标准,把NMT从站的状态定义成下面这样:
typedef enum
{
NMT_STATE_INITIALISING = 0x00, // 初始化
NMT_STATE_PRE_OPERATIONAL = 0x7F, // 预操作
NMT_STATE_OPERATIONAL = 0x05, // 操作
NMT_STATE_STOPPED = 0x04 // 停止
} NMT_State_t;
这里有个小细节。为什么PRE_OPERATIONAL是0x7F,而不是0x01?
其实标准里没硬性规定枚举值必须跟NMT命令字一样。但我个人建议,把PRE_OPERATIONAL映射到0x7F,是因为这个值在CAN报文里不容易跟其他命令冲突。我在一个项目里见过有人把枚举值随便定义,结果调试时看CAN日志,状态值和命令值混在一起,根本分不清……那叫一个乱。
2.2 状态切换回调函数指针结构体:让状态机“活”起来
有了状态枚举,下一步就是定义“状态切换”这个动作。你想想,从站收到一个NMT命令,要从PRE_OPERATIONAL切到OPERATIONAL,中间要干什么?
- 退出当前状态(比如停止心跳发送)
- 进入新状态(比如启动PDO映射)
- 如果切换失败,还得回滚
这些动作,如果全写在一个大函数里,用if-else或者switch-case堆起来……代码会变得又臭又长。而且,每增加一个状态,就得改那个函数,违反了开闭原则。
我的做法是:用函数指针。把每个状态的“进入”和“退出”动作,抽象成回调函数。然后把这些回调函数指针,打包到一个结构体里。
typedef struct
{
NMT_State_t state; // 当前状态
void (*on_enter)(void *context); // 进入该状态时的回调
void (*on_exit)(void *context); // 退出该状态时的回调
void (*on_transition)(void *context, // 状态切换时的回调
NMT_State_t from,
NMT_State_t to);
} NMT_StateCallbacks_t;
你看,这个结构体把“状态”和“行为”绑在了一起。每个状态都有自己的on_enter和on_exit。当状态机切换时,先调用旧状态的on_exit,再调用新状态的on_enter。如果中间出了岔子,on_transition可以帮你做错误处理。
我曾经在一个多主站的项目里,就是因为用了这种回调结构,才把复杂的切换逻辑理清楚。每个状态的行为是独立的,测试起来也方便。你想想看,如果所有逻辑都揉在一起,出了bug你都不知道是哪个状态的问题。
2.3 状态上下文结构体:给状态机一个“记忆”
光有状态和回调还不够。状态机在运行过程中,需要记住一些东西:比如当前是哪个状态、上一个状态是什么、有没有错误发生、心跳定时器还剩多少时间……
这些“记忆”,需要一个统一的地方来存放。这就是状态上下文结构体。
typedef struct
{
NMT_State_t current_state; // 当前状态
NMT_State_t previous_state; // 上一个状态
uint32_t state_change_count; // 状态切换次数统计
uint32_t error_register; // 错误寄存器
uint16_t heartbeat_time; // 心跳时间
void *user_data; // 用户自定义数据指针
NMT_StateCallbacks_t callbacks[4]; // 四个状态的回调表
} NMT_Context_t;
这个结构体,就是状态机的“大脑”。所有跟状态相关的数据,都放在这里。我特别想强调一下user_data这个字段。
你想想,不同的应用场景,状态机需要的数据是不一样的。有的需要保存CAN ID,有的需要保存PDO映射表。如果我把这些都写死在结构体里,那这个结构体就太臃肿了,而且不通用。
所以,我留了一个void *user_data指针。你需要什么数据,自己定义一个结构体,然后把指针传进来。这样,状态机核心代码完全不用改,只换user_data指向的内容就行。这就是面向对象思想在C语言里的应用。
user_data 虽然灵活,但一定要保证指针的有效性。我曾经犯过一个错,在状态机运行过程中把 user_data 指向的内存释放了,结果状态机直接跑飞……嗯,血的教训。
2.4 三者关系:一张图看懂
这三个数据结构不是孤立的。它们之间的关系,我画了一张图,你看完就明白了。
从这张图可以看出来:NMT_Context_t是核心,它持有当前状态(NMT_State_t)和一组回调(NMT_StateCallbacks_t)。当状态机运行时,根据当前状态和收到的命令,找到对应的回调函数去执行。
2.5 初始化示例:把理论变成代码
光说不练假把式。我们写一个初始化函数,把这些数据结构串起来。
void NMT_Context_Init(NMT_Context_t *ctx, void *user_data)
{
if (ctx == NULL) return;
// 清空上下文
memset(ctx, 0, sizeof(NMT_Context_t));
// 设置初始状态
ctx->current_state = NMT_STATE_INITIALISING;
ctx->previous_state = NMT_STATE_INITIALISING;
// 挂载用户数据
ctx->user_data = user_data;
// 注册四个状态的回调(这里只示意,具体实现后面章节展开)
ctx->callbacks[NMT_STATE_INITIALISING] = ...;
ctx->callbacks[NMT_STATE_PRE_OPERATIONAL] = ...;
ctx->callbacks[NMT_STATE_OPERATIONAL] = ...;
ctx->callbacks[NMT_STATE_STOPPED] = ...;
}
你看,初始化很简单。把上下文清空,设置初始状态为INITIALISING,然后挂载用户数据。最后,把每个状态对应的回调函数注册进去。
这里有个关键点:previous_state一开始也设成INITIALISING。为什么?因为状态机刚启动时,还没有发生过切换,所以“上一个状态”就是它自己。这样设计,可以避免在第一次切换时出现野指针或者未定义行为。