3. 状态切换核心逻辑:实现nmt_dispatch_event()函数
好,咱们进入本章的重头戏。前面两章我们把NMT状态机的七个状态和事件定义都铺好了,现在要做的,就是把这些状态和事件串起来——说白了,就是写那个核心调度函数:nmt_dispatch_event()。
这个函数是整个NMT状态机的心脏。每次有事件发生,不管是来自主站的NMT命令,还是来自应用层的错误信号,都得经过这个函数来判断:当前状态能不能处理这个事件?如果能,跳转到哪个新状态?跳转前后要做什么动作?
我个人习惯把这类函数叫做“状态机引擎”。它不复杂,但必须严谨。我在项目中见过因为漏了一个状态跳转条件,导致从站莫名其妙进入Pre-Operational的bug,查了两天才定位到。
3.1 函数原型与设计思路
先看函数原型:
/**
* @brief NMT事件分发函数
* @param state 当前状态
* @param event 触发的事件
* @retval 新状态
*/
nmt_state_t nmt_dispatch_event(nmt_state_t state, nmt_event_t event);
这个函数接收两个参数:当前状态和事件。返回值是跳转后的新状态。如果事件在当前状态下不合法,就返回当前状态不变。
设计思路其实很直接——用二维表查表法。你想想看,7个状态 × 12个事件,最多84种组合。但实际合法的跳转路径,CANopen DS-301规范里只定义了不到20条。所以我们不需要写一堆if-else嵌套,用查表最干净。
核心设计原则:
- 非法事件 → 状态不变,不报错(静默处理)
- 合法事件 → 执行状态跳转,必要时触发回调
- 跳转前后要更新内部标志位和定时器
3.2 状态跳转表
我先把完整的跳转表列出来。这张表我建议你直接抄进代码里,作为静态常量数组:
| 当前状态 | 事件 | 新状态 | 说明 |
|---|---|---|---|
| INITIALISATION | EVENT_INIT_COMPLETE | PRE_OPERATIONAL | 初始化完成,进入预操作 |
| PRE_OPERATIONAL | EVENT_START | OPERATIONAL | 收到Start命令 |
| PRE_OPERATIONAL | EVENT_ENTER_STOPPED | STOPPED | 收到Stop命令 |
| PRE_OPERATIONAL | EVENT_RESET_NODE | INITIALISATION | 收到Reset Node命令 |
| PRE_OPERATIONAL | EVENT_RESET_COMM | INITIALISATION | 收到Reset Comm命令 |
| OPERATIONAL | EVENT_ENTER_PRE_OPERATIONAL | PRE_OPERATIONAL | 收到Enter Pre-op命令 |
| OPERATIONAL | EVENT_ENTER_STOPPED | STOPPED | 收到Stop命令 |
| OPERATIONAL | EVENT_RESET_NODE | INITIALISATION | 收到Reset Node命令 |
| OPERATIONAL | EVENT_RESET_COMM | INITIALISATION | 收到Reset Comm命令 |
| STOPPED | EVENT_START | OPERATIONAL | 收到Start命令 |
| STOPPED | EVENT_ENTER_PRE_OPERATIONAL | PRE_OPERATIONAL | 收到Enter Pre-op命令 |
| STOPPED | EVENT_RESET_NODE | INITIALISATION | 收到Reset Node命令 |
| STOPPED | EVENT_RESET_COMM | INITIALISATION | 收到Reset Comm命令 |
| 任何状态 | EVENT_BOOTUP | INITIALISATION | 特殊:强制复位 |
注意:INITIALISATION状态下,除了EVENT_INIT_COMPLETE和EVENT_BOOTUP之外,其他所有事件都应该被忽略。我曾经遇到一个bug,从站在初始化过程中收到了Start命令,结果直接跳到了OPERATIONAL,SDO还没配置好,导致通信异常。
3.3 代码实现
有了跳转表,实现就很简单了。我习惯用结构体数组来存储跳转规则:
/* 跳转规则结构体 */
typedef struct {
nmt_state_t current_state;
nmt_event_t event;
nmt_state_t next_state;
} nmt_transition_t;
/* 跳转规则表 */
static const nmt_transition_t g_transition_table[] = {
{ NMT_STATE_INITIALISATION, EVENT_INIT_COMPLETE, NMT_STATE_PRE_OPERATIONAL },
{ NMT_STATE_PRE_OPERATIONAL, EVENT_START, NMT_STATE_OPERATIONAL },
{ NMT_STATE_PRE_OPERATIONAL, EVENT_ENTER_STOPPED, NMT_STATE_STOPPED },
{ NMT_STATE_PRE_OPERATIONAL, EVENT_RESET_NODE, NMT_STATE_INITIALISATION },
{ NMT_STATE_PRE_OPERATIONAL, EVENT_RESET_COMM, NMT_STATE_INITIALISATION },
{ NMT_STATE_OPERATIONAL, EVENT_ENTER_PRE_OPERATIONAL, NMT_STATE_PRE_OPERATIONAL },
{ NMT_STATE_OPERATIONAL, EVENT_ENTER_STOPPED, NMT_STATE_STOPPED },
{ NMT_STATE_OPERATIONAL, EVENT_RESET_NODE, NMT_STATE_INITIALISATION },
{ NMT_STATE_OPERATIONAL, EVENT_RESET_COMM, NMT_STATE_INITIALISATION },
{ NMT_STATE_STOPPED, EVENT_START, NMT_STATE_OPERATIONAL },
{ NMT_STATE_STOPPED, EVENT_ENTER_PRE_OPERATIONAL, NMT_STATE_PRE_OPERATIONAL },
{ NMT_STATE_STOPPED, EVENT_RESET_NODE, NMT_STATE_INITIALISATION },
{ NMT_STATE_STOPPED, EVENT_RESET_COMM, NMT_STATE_INITIALISATION },
{ NMT_STATE_ANY, EVENT_BOOTUP, NMT_STATE_INITIALISATION },
};
然后调度函数就是查表:
nmt_state_t nmt_dispatch_event(nmt_state_t state, nmt_event_t event)
{
uint32_t i;
uint32_t table_size = sizeof(g_transition_table) / sizeof(g_transition_table[0]);
for (i = 0; i < table_size; i++) {
/* 匹配当前状态和事件 */
if ((g_transition_table[i].current_state == state ||
g_transition_table[i].current_state == NMT_STATE_ANY) &&
g_transition_table[i].event == event) {
/* 找到合法跳转 */
nmt_state_t new_state = g_transition_table[i].next_state;
/* 跳转前回调:可以在这里做状态退出清理 */
nmt_on_state_exit(state, new_state);
/* 跳转后回调:可以在这里做状态进入初始化 */
nmt_on_state_enter(new_state, state);
return new_state;
}
}
/* 没有找到合法跳转,保持当前状态 */
return state;
}
小技巧:我在实际项目中,会在nmt_on_state_exit()和nmt_on_state_enter()里做三件事:更新LED指示灯状态、重置对应状态的心跳计数器、通知应用层状态变化。这样状态切换的副作用都集中管理,不会散落在各处。
3.4 状态切换流程图
下面这张SVG图把整个跳转逻辑可视化出来了。我建议你把它打印出来贴在工位上,写代码时扫一眼就知道当前状态能去哪:
3.5 状态切换的回调机制
刚才代码里提到了两个回调函数:nmt_on_state_exit()和nmt_on_state_enter()。这两个函数是状态切换的“钩子”,用来执行副作用。
我举个例子。当从站从OPERATIONAL切换到STOPPED时,需要做几件事:
- 停止PDO通信(如果有正在传输的PDO)
- 更新心跳报文的状态字节
- 通知应用层:别再发过程数据了
这些逻辑如果散落在各个地方,后期维护就是噩梦。我见过一个项目,状态切换逻辑写了三处不同的实现,最后改一处忘了改另一处,导致心跳状态和实际状态不一致。
所以我的建议是:所有状态切换的副作用,统一放在这两个回调里:
static void nmt_on_state_exit(nmt_state_t old_state, nmt_state_t new_state)
{
switch (old_state) {
case NMT_STATE_OPERATIONAL:
/* 停止PDO传输 */
pdo_stop_all();
break;
case NMT_STATE_PRE_OPERATIONAL:
/* 停止SDO紧急传输(如果有) */
sdo_abort_pending();
break;
default:
break;
}
}
static void nmt_on_state_enter(nmt_state_t new_state, nmt_state_t old_state)
{
switch (new_state) {
case NMT_STATE_INITIALISATION:
/* 复位通信参数 */
comm_params_reset();
break;
case NMT_STATE_PRE_OPERATIONAL:
/* 启动SDO服务器 */
sdo_server_start();
break;
case NMT_STATE_OPERATIONAL:
/* 启动PDO传输 */
pdo_start_all();
break;
case NMT_STATE_STOPPED:
/* 停止所有通信 */
comm_stop_all();
break;
}
/* 更新心跳状态 */
heartbeat_update_state(new_state);
}
关键点:回调函数里不要做耗时操作。状态切换应该是微秒级的。如果需要做复杂初始化(比如重新配置外设),建议用状态机+标志位的方式异步处理,而不是在回调里阻塞等待。
3.6 边界情况与避坑指南
写这个函数的时候,有几个边界情况我踩过坑,分享给你:
- 重复事件:如果当前状态已经是OPERATIONAL,又收到一个EVENT_START,应该忽略。查表法天然支持这一点——因为表中没有从OPERATIONAL + EVENT_START的条目。
- 非法状态值:如果state参数传进来一个0xFF之类的非法值,查表法会找不到匹配,直接返回原值。但为了安全,我建议在函数入口加一个状态有效性检查。
- 中断上下文调用:这个函数可能会在CAN接收中断里被调用。所以函数内部不能有锁、不能有malloc、不能有阻塞调用。查表法+纯函数实现正好满足这个要求。
我曾经踩过的坑:有一次我在nmt_on_state_enter()里调用了printf()来打印调试信息,结果在中断里触发了硬fault。因为printf用了互斥锁,而中断里不能加锁。从那以后,我所有状态切换回调都只用原子操作和寄存器操作,调试信息通过DMA+环形缓冲区输出。
3.7 单元测试建议
最后说一句测试。这个函数逻辑简单但关键,我建议你写一个单元测试,覆盖所有合法跳转和几个典型非法跳转:
/* 测试用例示例 */
void test_nmt_dispatch_event(void)
{
/* 合法跳转测试 */
assert(nmt_dispatch_event(NMT_STATE_INITIALISATION, EVENT_INIT_COMPLETE)
== NMT_STATE_PRE_OPERATIONAL);
assert(nmt_dispatch_event(NMT_STATE_PRE_OPERATIONAL, EVENT_START)
== NMT_STATE_OPERATIONAL);
/* 非法跳转测试:INIT状态下收到START应该忽略 */
assert(nmt_dispatch_event(NMT_STATE_INITIALISATION, EVENT_START)
== NMT_STATE_INITIALISATION);
/* 非法事件测试 */
assert(nmt_dispatch_event(NMT_STATE_OPERATIONAL, (nmt_event_t)0xFF)
== NMT_STATE_OPERATIONAL);
}
嗯,到这里,nmt_dispatch_event()的核心逻辑就完整了。查表法实现,两个回调处理副作用,再加上边界保护——这套组合拳打下来,状态机这块基本稳了。