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图把整个跳转逻辑可视化出来了。我建议你把它打印出来贴在工位上,写代码时扫一眼就知道当前状态能去哪:

NMT状态机跳转流程图 NMT状态机跳转流程图 INITIALISATION PRE_OPERATIONAL OPERATIONAL STOPPED EVENT_INIT_COMPLETE EVENT_START EVENT_ENTER_STOPPED EVENT_ENTER_PRE_OPERATIONAL EVENT_ENTER_STOPPED EVENT_START EVENT_ENTER_PRE_OPERATIONAL EVENT_RESET_NODE / EVENT_RESET_COMM 图例说明: 初始化 预操作 操作 停止 普通跳转 复位跳转

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()的核心逻辑就完整了。查表法实现,两个回调处理副作用,再加上边界保护——这套组合拳打下来,状态机这块基本稳了。