第2章:CANopenNode框架入门:源码结构解析、核心模块说明、移植到STM32平台

好,咱们接着聊。上一章我们把CANopen协议的基本概念捋了一遍,这一章直接上手干活——把CANopenNode这个开源框架拆开看看,再把它移植到STM32上。

说实话,我第一次看CANopenNode源码的时候,也有点懵。代码量不小,结构也挺复杂。但别怕,我带你一步步拆解。你会发现,其实核心就那么几个模块。

2.1 源码结构总览

CANopenNode的源码,说白了就是一套C语言写的CANopen协议栈。它支持CiA 301标准,也兼容CiA 402(电机驱动)和CiA 401(I/O设备)。

我习惯把它的目录结构分成三块:

  • 核心协议栈:在stack/目录下,这是灵魂所在
  • 驱动层:在drivers/目录下,负责跟硬件打交道
  • 示例应用:在example/目录下,给你抄作业用的

你下载源码后,第一眼看到的可能是这样的结构:

CANopenNode/
├── stack/              # 核心协议栈
│   ├── CO_driver.h     # 驱动抽象层接口
│   ├── CO_Emergency.c  # 紧急报文处理
│   ├── CO_NMT_Heartbeat.c  # NMT与心跳
│   ├── CO_PDO.c        # PDO处理
│   ├── CO_SDO.c        # SDO处理
│   ├── CO_SYNC.c       # 同步报文
│   ├── CO_TIME.c       # 时间戳
│   └── CO_LSS.c        # 层设置服务(可选)
├── drivers/            # 硬件驱动
│   ├── CAN/            # 各平台CAN驱动
│   │   ├── STM32/      # STM32专用
│   │   ├── Linux/      # Linux SocketCAN
│   │   └── ...
│   └── timer/          # 定时器驱动
├── example/            # 示例工程
│   ├── STM32/          # STM32示例
│   └── Linux/          # Linux示例
└── doc/                # 文档

嗯,这里要注意:stack/里的代码是平台无关的,你移植的时候基本不用改。真正要动的是drivers/里的东西。

2.2 核心模块说明

我挑几个最重要的模块讲讲。这些模块,你在做电机控制时几乎天天都要打交道。

2.2.1 NMT与心跳模块

NMT(Network Management)是CANopen的“交警”。它负责节点状态管理:初始化、预操作、操作、停止。

我记得第一次调试时,节点死活进不了操作状态。查了半天,原来是心跳报文没配置对。主站收不到心跳,以为节点挂了,一直发NMT复位命令。

核心函数就这几个:

// NMT状态切换
void CO_NMT_process(CO_NMT_t *nmt, uint32_t timeDifference_us);

// 心跳发送
void CO_Heartbeat_process(CO_Heartbeat_t *hb, uint32_t timeDifference_us);

// 检查节点是否在线
bool CO_NMT_isOperational(CO_NMT_t *nmt);
我的习惯:在电机控制应用中,心跳周期我一般设100ms。太快了浪费总线带宽,太慢了主站反应迟钝。

2.2.2 SDO模块

SDO(Service Data Object)用于读写对象字典。说白了,就是配置参数用的。

SDO是点对点通信,有确认机制。你发一个请求,对方必须回复。所以它可靠,但慢。

我在项目中遇到过一个问题:用SDO批量下载电机参数时,如果连续发太快,从站会丢包。后来加了重传机制才搞定。

关键API:

// SDO服务器处理(从站用)
void CO_SDO_server_process(CO_SDO_t *sdo, uint32_t timeDifference_us);

// SDO客户端发起读请求(主站用)
int16_t CO_SDO_client_upload(CO_SDO_t *sdo, uint16_t index, uint8_t subIndex, 
                              uint8_t *data, uint32_t *dataSize, uint32_t timeout_ms);

// SDO客户端发起写请求
int16_t CO_SDO_client_download(CO_SDO_t *sdo, uint16_t index, uint8_t subIndex, 
                                uint8_t *data, uint32_t dataSize, uint32_t timeout_ms);

2.2.3 PDO模块

PDO(Process Data Object)是CANopen的“快车道”。它没有确认机制,发完就完事,但速度快。

电机控制中,PDO用来实时传输:

  • 目标位置/速度/转矩
  • 实际位置/速度/电流
  • 状态字/控制字

PDO有两种触发方式:

  • 事件触发:数据变化了就发。适合位置反馈。
  • 同步触发:收到SYNC报文后统一发。适合多轴同步。

你想想看,做多轴联动时,同步PDO几乎是唯一选择。我做过一个四轴机械臂,就是用同步PDO,周期1ms,效果很好。

// PDO处理
void CO_PDO_process(CO_PDO_t *pdo, uint32_t timeDifference_us);

// 发送TPDO(生产者)
void CO_PDO_sendTPDO(CO_PDO_t *pdo, uint8_t tpdoNumber);

// 接收RPDO(消费者)
void CO_PDO_receiveRPDO(CO_PDO_t *pdo, uint8_t rpdoNumber, uint8_t *data);

2.2.4 紧急报文模块

紧急报文(Emergency)是CANopen的“报警器”。当节点检测到错误时,立即发送。

电机控制中常见的紧急错误:

  • 过流
  • 过温
  • 编码器故障
  • 通信超时

我曾经调试一台伺服驱动器,电机一启动就报紧急错误。查了半天,发现是电流采样偏置没校准。嗯,从那以后我每次上电都会先做一次自校准。

// 发送紧急报文
void CO_Emergency_send(CO_Emergency_t *emergency, uint16_t errorCode, 
                        uint8_t errorRegister, uint8_t *data, uint8_t dataLength);

2.3 移植到STM32平台

好了,理论说完了,咱们动手移植。我以STM32F407为例,HAL库环境。

移植说白了就三步:

  1. 配置CAN外设:波特率、过滤器、中断
  2. 实现驱动接口:CAN发送、CAN接收、定时器
  3. 集成协议栈:初始化、主循环调用

2.3.1 第一步:配置CAN外设

CAN波特率我一般设500kbps。为什么?因为这是工业现场最常用的速率,兼容性好。

// CAN初始化(HAL库)
void CAN_Init(void)
{
    hcan.Instance = CAN1;
    hcan.Init.Prescaler = 6;          // 42MHz / 6 = 7MHz
    hcan.Init.SyncJumpWidth = CAN_SJW_1TQ;
    hcan.Init.TimeSeg1 = CAN_BS1_13TQ; // 13 TQ
    hcan.Init.TimeSeg2 = CAN_BS2_2TQ;  // 2 TQ
    // 总TQ = 1 + 13 + 2 = 16
    // 波特率 = 7MHz / 16 = 437.5kbps ≈ 500kbps
    HAL_CAN_Init(&hcan);
    
    // 配置过滤器:接收所有CANopen报文(11位ID)
    CAN_FilterTypeDef filter;
    filter.FilterBank = 0;
    filter.FilterMode = CAN_FILTERMODE_IDMASK;
    filter.FilterScale = CAN_FILTERSCALE_32BIT;
    filter.FilterIdHigh = 0x0000;
    filter.FilterIdLow = 0x0000;
    filter.FilterMaskIdHigh = 0x0000;
    filter.FilterMaskIdLow = 0x0000;
    filter.FilterFIFOAssignment = CAN_RX_FIFO0;
    filter.FilterActivation = ENABLE;
    HAL_CAN_ConfigFilter(&hcan, &filter);
    
    // 使能中断
    HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);
    HAL_CAN_Start(&hcan);
}
注意:CANopen要求CAN控制器工作在标准帧模式(11位ID),不要搞成扩展帧。我见过有人配错了,折腾了两天。

2.3.2 第二步:实现驱动接口

CANopenNode的驱动层定义了一组回调函数。你需要实现它们:

// CAN发送回调
void CO_CANsend(CO_CANmodule_t *CANmodule, CO_CANtx_t *buffer)
{
    CAN_TxHeaderTypeDef header;
    uint8_t data[8];
    
    header.StdId = buffer->ident;
    header.IDE = CAN_ID_STD;
    header.RTR = CAN_RTR_DATA;
    header.DLC = buffer->DLC;
    memcpy(data, buffer->data, 8);
    
    HAL_CAN_AddTxMessage(&hcan, &header, data, &txMailbox);
}

// CAN接收中断回调
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
    CAN_RxHeaderTypeDef header;
    uint8_t data[8];
    
    HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &header, data);
    
    // 调用协议栈接收处理
    CO_CANinterrupt(&canModule, header.StdId, data, header.DLC);
}

// 定时器回调(1ms周期)
void TIM_IRQHandler(void)
{
    // 调用协议栈主循环处理
    CO_process(&canOpen, 1000); // 1000微秒
}

2.3.3 第三步:集成协议栈

在主循环中,你需要周期调用协议栈的处理函数:

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    CAN_Init();
    Timer_Init(); // 1ms定时器
    
    // 初始化CANopen协议栈
    CO_ReturnError_t err;
    err = CO_init(&canOpen, &canModule, &timerModule, 
                  nodeId, &OD, &SDO, &NMT, &Heartbeat,
                  &Emergency, &PDO, &SYNC, &TIME);
    
    if (err != CO_ERROR_NO) {
        // 初始化失败,处理错误
        Error_Handler();
    }
    
    while (1)
    {
        // 主循环中也可以调用,但建议用定时器中断驱动
        // CO_process(&canOpen, 0);
        
        // 你的应用代码
        Motor_Control_Loop();
    }
}
我的建议:协议栈的主处理函数CO_process()最好放在定时器中断里,周期1ms。主循环只跑应用逻辑。这样时序更可控。

2.4 避坑指南

移植过程中,我踩过不少坑。分享几个典型的:

  • 对象字典配置:CANopenNode的对象字典是用一个巨大的结构体数组定义的。修改时一定要同步更新CO_OD.hCO_OD.c。我见过有人只改了.h忘了改.c,编译不报错,运行就死机。
  • 心跳超时:主站和从站的心跳周期要匹配。从站设了100ms,主站超时时间就要大于100ms,一般设150ms。太短了容易误报。
  • PDO映射:PDO映射必须在预操作状态下配置。进入操作状态后就不能改了。这个顺序搞错,节点会报错。
  • 中断优先级:CAN接收中断优先级要高于定时器中断。否则数据可能丢失。我吃过这个亏。

好了,这一章的内容就到这。下一章我们开始实战——在STM32上跑一个完整的CANopen从站,实现电机的基本控制。到时候你会看到,这些模块是怎么协同工作的。