4. 初始化状态实现:从站上电后进入 Initialisation 状态,完成 SDO 参数加载、PDO 映射配置、心跳生产者启动

好,咱们直接进入正题。从站上电后,第一个要面对的就是 Initialisation(初始化) 状态。这个阶段要是没处理好,后面 NMT 状态机根本跑不起来。我早期做项目时,就吃过这个亏——上电后从站没反应,查了半天,原来是初始化流程里漏了心跳报文的使能。

说白了,Initialisation 就是给从站“搭骨架”。CANopen 协议规定,从站上电后必须自动进入这个状态,完成三件大事:

  • SDO 参数加载——把对象字典里的配置项读出来
  • PDO 映射配置——告诉从站哪些数据要发、怎么发
  • 心跳生产者启动——让主站知道你还活着

下面我一步步拆开讲,每个环节都带上代码和避坑经验。

4.1 初始化流程总览

先看整体流程。从站上电后,硬件复位完成,紧接着软件就要接管。我个人习惯把初始化拆成四个阶段:

阶段 动作 说明
1 硬件初始化 时钟、GPIO、CAN 控制器、定时器
2 对象字典加载 从 Flash/EEPROM 读取 SDO 参数
3 PDO 映射配置 根据对象字典 0x1600-0x1A00 建立映射表
4 心跳启动 配置定时器,发送 Boot-up 报文

嗯,这里要注意:Boot-up 报文必须在初始化完成后立即发送,这是 CANopen 协议强制要求的。发送完 Boot-up,从站自动进入 Pre-operational 状态。

核心原则:初始化阶段不允许收发 PDO,只能通过 SDO 配置。如果你在初始化里发了 PDO,主站会直接认为从站异常。

4.2 SDO 参数加载——从 Flash 到对象字典

SDO 参数加载,说白了就是把用户配置好的参数从非易失存储器里读出来,填到对象字典对应的索引里。我见过很多新手直接写死参数,结果现场要改波特率,还得重新烧固件——这太不专业了。

正确的做法是:在 Flash 里划出一块区域,专门存 SDO 可配置的参数。上电后,用 HAL_FLASH_Read() 或者直接指针读取,然后调用对象字典的写入接口。

举个例子,加载节点 ID 和波特率:

// 从 Flash 地址 0x08080000 读取配置
uint8_t node_id;
uint16_t baudrate;
uint32_t *p_config = (uint32_t *)0x08080000;

node_id = (uint8_t)(p_config[0] & 0xFF);
baudrate = (uint16_t)((p_config[0] >> 8) & 0xFFFF);

// 写入对象字典
OD_write(0x1000, 0x00, sizeof(uint32_t), &baudrate);
OD_write(0x1001, 0x00, sizeof(uint8_t), &node_id);

我的经验:Flash 写入次数有限,建议在初始化时只读不写。如果需要保存运行时参数,单独开一个“存储触发”SDO,不要每次掉电都写。

这里有个坑:对象字典的默认值和 Flash 加载值可能会冲突。我曾经遇到一个项目,Flash 里存的是 0x00,但对象字典默认值是 0x7F,结果从站节点 ID 变成了 0。后来我加了一个校验机制——如果 Flash 数据全为 0xFF 或 CRC 校验失败,就使用默认值。

4.3 PDO 映射配置——让数据“对号入座”

PDO 映射,说白了就是告诉从站:你要把哪些数据打包发出去,或者收到数据后拆开放到哪里。这个配置在对象字典的 0x1600-0x1A00 区域。

初始化阶段,我们需要做两件事:

  1. 读取映射参数——从 Flash 或默认值加载 PDO 通信参数和映射参数
  2. 建立映射表——把对象字典的条目和 PDO 的字节位置对应起来

看一个 TPDO1 的映射配置示例:

// TPDO1 映射:发送 0x2000 子索引 1(2 字节)和 0x2001 子索引 0(4 字节)
uint8_t mapping_data[] = {
    0x00, 0x00, 0x00, 0x00,  // 清除原有映射
    0x20, 0x00, 0x01, 0x10,  // 0x2000-01, 长度 2 字节 (0x10 = 16 bit)
    0x20, 0x01, 0x00, 0x20   // 0x2001-00, 长度 4 字节 (0x20 = 32 bit)
};

// 写入对象字典 0x1A00 子索引 0(映射数量)
uint8_t map_count = 2;
OD_write(0x1A00, 0x00, sizeof(uint8_t), &map_count);

// 写入映射条目
for(uint8_t i = 0; i < map_count; i++) {
    OD_write(0x1A00, i+1, 4, &mapping_data[i*4 + 4]);
}

注意:修改 PDO 映射前,必须先清除原有映射(子索引 0 写 0)。否则新映射会和旧映射叠加,导致数据错位。我见过有人因为这个,PDO 发出来的数据全是乱的。

你想想看,如果映射配置错了,主站收到的数据可能完全对不上号。比如你本来要发温度值,结果发出去的是电压值——这在工业现场可是要出事故的。

4.4 心跳生产者启动——让主站知道你还活着

心跳机制是 CANopen 的“保活”手段。从站作为心跳生产者,需要定时发送一个 1 字节的报文,内容就是当前 NMT 状态。主站收到后,就知道从站还在正常工作。

初始化阶段,我们要做三件事:

  • 配置心跳生产时间——对象字典 0x1017,单位毫秒
  • 启动定时器——用 STM32 的基本定时器或 TIM 产生周期中断
  • 发送 Boot-up 报文——这是从站的第一条心跳,状态值为 0x00

代码实现如下:

// 配置心跳周期为 500ms
uint16_t heartbeat_time = 500;
OD_write(0x1017, 0x00, sizeof(uint16_t), &heartbeat_time);

// 启动定时器(以 TIM6 为例)
HAL_TIM_Base_Start_IT(&htim6);

// 在定时器中断回调中发送心跳
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if(htim->Instance == TIM6) {
        uint8_t nmt_state = get_current_nmt_state();  // 当前状态
        CAN_TxHeaderTypeDef tx_header;
        uint8_t tx_data[1] = {nmt_state};
        
        tx_header.StdId = 0x700 + node_id;  // COB-ID = 0x700 + 节点 ID
        tx_header.DLC = 1;
        HAL_CAN_AddTxMessage(&hcan, &tx_header, tx_data, &tx_mailbox);
    }
}

// 初始化完成后发送 Boot-up
void send_boot_up_message(void) {
    uint8_t boot_up_data = 0x00;  // 状态值 0x00 表示 Boot-up
    CAN_TxHeaderTypeDef tx_header;
    tx_header.StdId = 0x700 + node_id;
    tx_header.DLC = 1;
    HAL_CAN_AddTxMessage(&hcan, &tx_header, &boot_up_data, &tx_mailbox);
}

避坑指南:我曾经把心跳定时器优先级设得太低,结果 CAN 中断频繁时,心跳发送被延迟,主站误判从站离线。建议心跳定时器优先级设为中等偏上,不低于 CAN RX 中断。

还有一个细节:心跳生产时间不能为 0。如果 0x1017 写 0,表示禁止心跳。初始化阶段一定要确保这个值非零,否则主站永远收不到心跳,会一直报错。

4.5 初始化状态机流程图

下面这张图把整个初始化流程串起来了。你可以把它当作代码实现的“施工图”。

CANopen 从站初始化状态机流程图 从站初始化状态机流程图 上电复位 硬件初始化 SDO 参数加载 PDO 映射配置 心跳启动 发送 Boot-up 报文 注意 初始化阶段禁止 PDO 通信 Boot-up 后自动进入 Pre-op 关键参数 0x1017:心跳生产时间 0x1A00:TPDO 映射

4.6 初始化完成后的状态迁移

所有初始化工作完成后,从站必须发送 Boot-up 报文,然后自动进入 Pre-operational 状态。这个状态转换是 CANopen 协议强制规定的,不需要主站干预。

我整理了一个状态迁移表,方便你对照:

当前状态 触发条件 下一状态 动作
Initialisation 初始化完成 Pre-operational 发送 Boot-up 报文
Pre-operational 收到 Start_Remote_Node Operational 使能 PDO 收发
Pre-operational 收到 Enter_Pre-operational Pre-operational 无变化(保持)
Operational 收到 Enter_Pre-operational Pre-operational 禁止 PDO 收发
任意状态 收到 Reset_Node Initialisation 重新初始化

重要提醒:从站进入 Pre-operational 后,不要立即开始 PDO 通信。必须等主站发送 Start_Remote_Node 命令后,才能切换到 Operational 状态。我见过有人为了省事,初始化完直接进 Operational,结果主站还没准备好,PDO 报文全丢了。

4.7 实战中的常见问题

最后,我总结几个初始化阶段最容易踩的坑:

  • Flash 读取超时——如果 Flash 被频繁写入,读操作可能变慢。建议在初始化函数开头加一个超时保护,超过 100ms 就使用默认值。
  • PDO 映射长度不匹配——映射条目总长度不能超过 8 字节(一个 CAN 帧)。如果你映射了 3 个 4 字节的数据,那肯定装不下。我一般会在配置时做一次长度校验。
  • 心跳定时器未启动——很多人写完心跳配置代码,但忘了使能定时器中断。结果从站一直不发送心跳,主站报“心跳丢失”。
  • Boot-up 报文 COB-ID 错误——Boot-up 的 COB-ID 是 0x700 + 节点 ID。如果你节点 ID 是 0x01,那 COB-ID 就是 0x701。写错的话,主站根本收不到。

嗯,初始化这块就讲这么多。代码实现上,我建议你把初始化流程封装成一个函数,比如 CANopen_Init(),然后在 main 函数里调用。这样结构清晰,也方便后期维护。