2. CANopenNode 源码架构:目录结构与核心模块功能划分

拿到一个开源协议栈,第一件事是什么?我个人习惯,先看目录结构。不看目录,你连文件在哪都找不到,更别说改代码了。CANopenNode 的源码组织得还算清晰,但如果你不熟悉它的套路,第一次打开也会有点懵。

这一章,我带你把整个源码骨架摸一遍。重点讲清楚:每个目录是干什么的,核心模块之间怎么配合。你想想看,心跳报文出了问题,你该去哪个文件找?SDO 通信卡住了,又是哪个模块在管?这些搞清楚了,后面调试才能有的放矢。

2.1 源码目录结构总览

CANopenNode 的源码根目录下,主要包含以下几个部分:

目录/文件 用途说明
stack/ 核心协议栈源码,所有 CANopen 协议逻辑都在这里
drivers/ 硬件驱动层,适配不同 MCU 和 CAN 控制器
example/ 示例工程,包含 main.c 和对象字典配置文件
objdict/ 对象字典生成工具和模板
doc/ 文档和说明文件

咱们重点关注 stack/ 目录。这里才是协议栈的心脏。我把它展开给你看:

stack/
├── CO_driver.c / CO_driver.h        // 硬件抽象层
├── CO_Emergency.c / CO_Emergency.h  // 紧急报文
├── CO_SDO.c / CO_SDO.h              // SDO 服务器
├── CO_SDOclient.c / CO_SDOclient.h  // SDO 客户端
├── CO_PDO.c / CO_PDO.h              // PDO 处理
├── CO_NMT.c / CO_NMT.h              // 网络管理
├── CO_Heartbeat.c / CO_Heartbeat.h  // 心跳报文
├── CO_SYNC.c / CO_SYNC.h            // 同步报文
├── CO_TIME.c / CO_TIME.h            // 时间戳
├── CO_LSS.c / CO_LSS.h              // 层设置服务
├── CO_fifo.c / CO_fifo.h            // FIFO 缓冲区
├── CO_timer.c / CO_timer.h          // 定时器管理
├── CO_error.c / CO_error.h          // 错误处理
├── CO_OD.c / CO_OD.h                // 对象字典接口
└── CO_main.h                        // 协议栈主配置头文件

每个模块都是一个独立的 .c/.h 文件对。这种设计的好处是:你不需要的模块,可以直接不编译。我在项目中就经常只保留 Heartbeat、SDO、NMT 三个模块,其他全砍掉,省 ROM 空间。

核心原则:CANopenNode 采用"模块化 + 回调"架构。每个模块负责一种协议功能,模块之间通过对象字典和回调函数通信。没有全局变量满天飞的情况,代码质量相当不错。

2.2 核心模块功能划分

下面我把几个最重要的模块拎出来,一个一个讲清楚。这些模块,你在调试心跳和 SDO 时一定会打交道。

2.2.1 CO_driver — 硬件抽象层

这是协议栈和硬件之间的桥梁。说白了,它把不同 MCU 的 CAN 控制器差异给屏蔽掉了。

CO_driver.h 里定义了一组接口函数:

// 发送 CAN 报文
int16_t CO_CANsend(CO_IF_CANt *CANptr, CO_CANtx_t *buffer);

// 接收 CAN 报文(中断中调用)
void CO_CANrxMsg_t(CO_IF_CANt *CANptr, uint16_t index);

// 定时器相关
uint32_t CO_timerGetTimeUs(void);
void CO_timerSetTimeUs(uint32_t time);

你在移植协议栈时,主要就是实现这些函数。我记得第一次移植到 STM32F4 上时,就踩了个坑——CO_CANsend 的返回值没处理好,导致报文发不出去。后来发现是邮箱满了没做等待处理。

我的建议:移植驱动时,先写一个简单的回环测试。让从站自己发自己收,确认驱动层没问题了,再往上跑协议栈。这一步能省你后面至少半天调试时间。

2.2.2 CO_Heartbeat — 心跳报文

心跳报文是 CANopen 网络中最基础的生命体征信号。每个从站必须定期发送,告诉主站"我还活着"。

核心数据结构:

typedef struct {
    uint8_t         nodeId;          // 节点 ID
    uint8_t         heartbeatTime;   // 心跳周期(毫秒)
    uint8_t         state;           // 当前 NMT 状态
    uint16_t        timer;           // 内部定时器
    CO_CANtx_t      CANtxBuff;       // CAN 发送缓冲区
} CO_Heartbeat_t;

心跳报文的发送逻辑其实很简单:

  1. 定时器每 heartbeatTime 毫秒触发一次
  2. 读取当前 NMT 状态(比如 Operational、Pre-operational)
  3. 组装 CAN 报文:COB-ID = 0x700 + nodeId,数据域 = 状态值
  4. 调用 CO_CANsend() 发送

这里有个容易忽略的点:心跳周期是在对象字典 0x1017 中配置的。如果你改了 0x1017 的值,协议栈会自动重新加载定时器。但如果你直接改结构体里的 heartbeatTime,定时器不会更新——我当年就被这个坑过。

注意:心跳报文的生产者(从站)和消费者(主站)是分开配置的。从站只管发,主站负责监控超时。如果你发现主站报心跳超时,先检查从站有没有在发,再检查主站的消费时间配置。

2.2.3 CO_SDO — SDO 服务器

SDO 是用来读写对象字典的。CANopenNode 把 SDO 分成了服务器和客户端两部分。从站只需要实现 SDO 服务器。

SDO 服务器的核心逻辑在 CO_SDO.c 中。它处理两种请求:

  • 读请求:主站想读取从站的某个对象字典条目
  • 写请求:主站想修改从站的某个对象字典条目

每次 SDO 通信需要 2-4 个 CAN 报文(取决于数据长度)。协议栈内部维护了一个状态机:

typedef enum {
    CO_SDO_ST_IDLE,          // 空闲
    CO_SDO_ST_DOWNLOAD,     // 下载(写)
    CO_SDO_ST_UPLOAD,       // 上传(读)
    CO_SDO_ST_ABORT         // 异常终止
} CO_SDO_state_t;

我调试 SDO 时最常用的方法:在 CO_SDO.c 的 CO_SDO_serverProcess() 函数里加打印。每次状态切换都打印一下,马上就能看出是哪个环节卡住了。

实战经验:SDO 通信失败,80% 的原因是对象字典索引/子索引写错了。剩下的 20% 是数据长度不匹配。用 CAN 分析仪抓一下报文,对比标准 SDO 协议格式,问题基本一眼就能看出来。

2.2.4 CO_NMT — 网络管理

NMT 模块负责从站的状态机管理。CANopen 从站有 4 个主要状态:

状态 说明 能否通信
Initialisation 初始化,完成后自动进入 Pre-operational 否
Pre-operational 预运行,可以 SDO 通信,不能 PDO SDO 可以
Operational 运行状态,SDO 和 PDO 都可以 全部可以
Stopped 停止状态,只响应 NMT 命令 仅 NMT

NMT 模块还负责处理主站发来的 NMT 命令(比如启动、停止、预运行)。这些命令的 COB-ID 是 0x000,所有从站都会收到。

2.2.5 CO_Emergency — 紧急报文

当从站检测到内部错误时,会发送紧急报文。这个模块在调试时很有用——它能告诉你从站内部出了什么问题。

紧急报文的格式:

CAN 数据域:
  Byte 0-1: 错误码(如 0x1000 表示通用错误)
  Byte 2:   错误寄存器(对象字典 0x1001)
  Byte 3-7: 制造商自定义错误信息

我习惯在代码里主动触发紧急报文来辅助调试。比如检测到传感器异常时,调用 CO_errorSet() 设置错误码,协议栈会自动发送紧急报文。这样主站就能实时知道从站的状态。

2.3 模块间的关系与数据流

这些模块不是孤立的。它们通过对象字典和回调函数协同工作。我画了一张图,帮你理清它们之间的关系:

CANopenNode 核心模块关系图 CANopenNode 核心模块关系图 对象字典 (CO_OD) 存储所有配置和数据 CO_driver (硬件抽象) CO_NMT 状态机管理 CO_Heartbeat 心跳报文 CO_Emergency 紧急报文 CO_SDO SDO 服务器 CO_PDO PDO 处理 CO_SYNC 同步报文 说明:所有协议模块都通过对象字典交换数据。CO_driver 是唯一与硬件打交道的模块,其他模块不直接操作 CAN 控制器。 调试时,重点关注模块与对象字典之间的数据交互,大部分问题都出在这里。

从这张图你能看出几个关键点:

  • 对象字典是中心:所有模块都围绕它工作。心跳周期、SDO 数据、NMT 状态,全都存在对象字典里。
  • CO_driver 在最底层:它封装了硬件差异,上层模块不需要关心用的是 STM32 还是 AVR。
  • 模块之间不直接调用:比如心跳模块不会直接调用 SDO 模块。它们通过对象字典和主循环中的轮询机制协作。

调试技巧:当你怀疑某个模块有问题时,先在 CO_main.h 里找到对应的配置宏,确认这个模块有没有被编译进去。我遇到过好几次,折腾了半天发现是宏定义没打开,模块根本没编译。

2.4 主循环与模块调度

CANopenNode 不是中断驱动的。它采用轮询 + 定时器的方式工作。主循环大概长这样:

while(1) {
    // 1. 处理接收到的 CAN 报文
    CO_CANrxMsg_t *rxMsg = CO_CANrxWait(CO->CANptr);
    if(rxMsg) {
        CO_NMT_receive(CO->NMT, rxMsg);         // NMT 处理
        CO_SDO_serverProcess(CO->SDO, rxMsg);   // SDO 处理
        CO_Heartbeat_receive(CO->Heartbeat, rxMsg); // 心跳接收
        // ... 其他模块
    }
    
    // 2. 定时任务
    if(timer_expired) {
        CO_Heartbeat_send(CO->Heartbeat);       // 发送心跳
        CO_timerSetTimeUs(CO->timer, next_time);
    }
    
    // 3. 应用层任务
    your_application_function();
}

这种设计的好处是简单、可控。坏处是:如果你的应用层任务耗时太长,会影响心跳报文的实时性。我在一个项目中就遇到过,应用层有个 50ms 的延时,结果心跳周期从 100ms 变成了 150ms。

避坑指南:千万不要在主循环里用 HAL_Delay(100) 这种阻塞延时。要用协议栈自带的定时器模块,或者把耗时任务拆分成多个小步骤。我曾经因为一个延时函数,导致整个 CANopen 网络瘫痪——主站以为从站挂了,不停地发 NMT 复位命令。

2.5 本章小结

这一章我们把 CANopenNode 的源码骨架摸了一遍。核心就三点:

  1. 目录结构:stack/ 是核心,drivers/ 是硬件适配,example/ 是参考实现
  2. 核心模块:CO_driver(硬件抽象)、CO_Heartbeat(心跳)、CO_SDO(SDO 通信)、CO_NMT(状态管理)、CO_Emergency(紧急报文)
  3. 调度方式:轮询 + 定时器,模块之间通过对象字典协作