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;
心跳报文的发送逻辑其实很简单:
- 定时器每
heartbeatTime毫秒触发一次 - 读取当前 NMT 状态(比如 Operational、Pre-operational)
- 组装 CAN 报文:COB-ID = 0x700 + nodeId,数据域 = 状态值
- 调用
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 模块间的关系与数据流
这些模块不是孤立的。它们通过对象字典和回调函数协同工作。我画了一张图,帮你理清它们之间的关系:
从这张图你能看出几个关键点:
- 对象字典是中心:所有模块都围绕它工作。心跳周期、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 的源码骨架摸了一遍。核心就三点:
- 目录结构:
stack/是核心,drivers/是硬件适配,example/是参考实现 - 核心模块:CO_driver(硬件抽象)、CO_Heartbeat(心跳)、CO_SDO(SDO 通信)、CO_NMT(状态管理)、CO_Emergency(紧急报文)
- 调度方式:轮询 + 定时器,模块之间通过对象字典协作