3、CANopenNode框架概览:架构、模块划分、源码结构与许可证
CANopenNode 是一个开源的 CANopen 协议栈实现,专门面向嵌入式系统设计,尤其适用于工业 I/O 从站、运动控制、传感器节点等资源受限的 MCU 环境。与商业协议栈(如 CANopen Stack、EmSA)相比,CANopenNode 以轻量、可移植、模块化著称,其源码结构清晰,便于开发者裁剪和移植。本章将深入剖析 CANopenNode 的架构分层、模块划分、源码目录组织、许可证约束,以及在实际嵌入式工程中的集成方式,帮助读者建立从“协议理解”到“工程落地”的完整认知。
上图展示了 CANopenNode 的经典分层架构。自顶向下依次为应用层、对象字典、协议核心、驱动抽象层和硬件层。各层之间通过明确的接口函数进行交互,层与层之间不存在跨层调用,这种设计保证了协议栈的可移植性和可测试性。下面我们将逐层展开,并结合源码文件说明每个模块的职责与实现要点。
3.1 架构设计核心原理:分层与事件驱动
CANopenNode 的架构设计遵循两个核心原则:分层隔离与事件驱动。分层隔离确保了协议栈不依赖具体 MCU 外设寄存器操作,而是通过驱动抽象层提供统一接口;事件驱动则使得协议栈能够在无 RTOS 的裸机环境下高效运行,也可方便地移植到 RTOS 中。
在 CANopenNode 中,协议栈的每个功能模块(如 NMT、PDO、SDO)都维护自己的状态机,并通过一个全局的 CO_t 结构体(主对象)进行统一管理。主循环或定时器中断周期性地调用 CO_process() 函数,该函数内部会依次处理接收到的 CAN 报文、检查超时定时器、更新心跳状态、触发 OD 回调等。这种设计使得协议栈的 CPU 占用率极低,在 1ms 时基下,典型的 8 位 MCU 也能轻松运行。
关键设计决策:CANopenNode 将协议栈与应用程序完全解耦。应用程序不直接调用协议栈内部函数,而是通过操作对象字典(OD)中的变量来与协议栈交互。例如,要改变 PDO 的映射,应用程序只需修改 OD 索引 0x1600/0x1A00 中的映射参数,协议栈会自动检测到变化并重新配置硬件。这种“数据驱动”的模式极大降低了应用开发的复杂度。
3.2 模块划分与源码结构详解
CANopenNode 的源码目录结构清晰,每个模块对应一个 .c 和 .h 文件。以下是一个典型的源码树布局(以 4.x 版本为例):
CANopenNode/
├── CANopen.h // 主头文件,包含所有公共 API 和类型定义
├── CANopen.c // 核心初始化、主循环处理函数
├── CO_driver.h // 驱动抽象层接口定义(必须由用户实现)
├── CO_driver.c // 驱动抽象层默认实现(弱函数,可覆盖)
├── CO_Emergency.c/h // 紧急报文(EMCY)处理
├── CO_LSS.c/h // 层设置服务(LSS),用于节点地址配置
├── CO_NMT.c/h // 网络管理(NMT)状态机与心跳
├── CO_OD.c/h // 对象字典存储与访问接口
├── CO_PDO.c/h // 过程数据对象(PDO)收发管理
├── CO_SDO.c/h // 服务数据对象(SDO)服务器实现
├── CO_SYNC.c/h // 同步报文(SYNC)处理
├── CO_TimeStamp.c/h // 时间戳对象
├── CO_trace.c/h // 可选:调试跟踪功能
├── CO_config.h // 协议栈编译配置(裁剪功能)
└── example/ // 各平台示例工程(STM32、PIC、AVR 等)
每个模块的职责如下表所示:
| 模块文件 | 功能职责 | 关键 API / 数据结构 |
|---|---|---|
| CO_NMT.c | NMT 状态机(初始化、预操作、操作、停止)、心跳报文发送、节点启动/停止控制 | CO_NMT_reset(), CO_NMT_process() |
| CO_PDO.c | 管理 TPDO/RPDO 的映射、传输类型(同步/异步/事件)、动态 PDO 配置 | CO_PDO_init(), CO_PDO_send() |
| CO_SDO.c | 实现 SDO 服务器,支持快速传输和分段传输,处理 OD 读写请求 | CO_SDO_init(), CO_SDO_process() |
| CO_OD.c | 对象字典的存储管理,提供 OD 查找、读写回调机制 | CO_OD_find(), OD_Entry_t 结构 |
| CO_Emergency.c | 紧急错误报文生成与发送,错误历史记录 | CO_EM_init(), CO_errorReport() |
| CO_LSS.c | LSS 从站功能,支持通过 CAN 总线修改节点 ID 和波特率 | CO_LSS_init(), CO_LSS_process() |
| CO_driver.c | 提供 CAN 发送/接收、定时器、EEPROM 读写等底层驱动接口 | CO_CANsend(), CO_CANreceive(), CO_timer1ms() |
3.3 许可证与商业使用注意事项
CANopenNode 使用 Apache License 2.0 许可证。这是一个对商业应用非常友好的宽松许可证,允许自由使用、修改和分发,包括闭源商业产品。但需要满足以下条件:
- 保留原始版权声明和许可证文本。
- 如果修改了代码,需要在修改的文件中注明变更。
- 不得使用原始作者的名字或商标来推广衍生品。
与 GPL 许可证不同,Apache 2.0 不要求衍生作品开源,因此 CANopenNode 非常适合嵌入到专有的工业产品中。但需要注意,CANopenNode 的某些示例代码或第三方移植可能采用其他许可证(如 MIT 或 BSD),在整合到商业项目前应逐一核对。
风险提示:虽然 CANopenNode 本身是 Apache 2.0,但如果你使用了 STM32CubeMX 生成的 HAL 库或其他中间件,这些组件可能采用不同的许可证(如 ST 的 SLA0044)。在发布产品前,务必进行完整的许可证合规审查,特别是当产品包含 GPL 组件时,可能触发开源义务。
3.4 嵌入式系统中的应用方式与移植流程
将 CANopenNode 集成到嵌入式系统中的典型流程分为五个步骤:驱动适配 → 配置裁剪 → 对象字典定义 → 应用逻辑编写 → 联调测试。下面以 STM32F103 + TJA1050 收发器为例,说明具体操作。
3.4.1 驱动适配(CO_driver 实现)
驱动层是移植工作量最大的部分。需要实现以下接口:
// 1. CAN 发送:将 CO_CANtxMsg_t 结构体内容写入 CAN 外设发送邮箱
uint16_t CO_CANsend(CO_CANmodule_t *CANmodule, CO_CANtxMsg_t *buffer);
// 2. CAN 接收:从 CAN 外设读取报文到 CO_CANrxMsg_t,并调用回调
void CO_CANreceive(CO_CANmodule_t *CANmodule, uint8_t index);
// 3. 定时器:提供 1ms 时基,通常由 SysTick 或 TIM 中断产生
void CO_timer1ms(void);
// 4. 非易失存储:读写 OD 中标记为“存储”的变量(如节点 ID、波特率)
uint8_t CO_FLASH_read(const uint16_t addr, void *buf, const uint32_t len);
uint8_t CO_FLASH_write(const uint16_t addr, const void *buf, const uint32_t len);
在 STM32 上,CAN 接收通常使用中断 + 消息队列的方式。CANopenNode 的驱动模板中提供了 CO_CANrxMsg_t 缓冲区数组,每个接收通道对应一个 CAN 标识符过滤器。你需要根据 CANopen 的预定义连接集(如 NMT 使用 0x000+NodeID,SDO 使用 0x580+NodeID)配置硬件过滤器。
3.4.2 配置裁剪(CO_config.h)
CO_config.h 是协议栈的“开关面板”。你可以通过宏定义启用或禁用功能模块,以减小代码体积:
#define CO_CONFIG_NMT 1 // 启用 NMT
#define CO_CONFIG_PDO 1 // 启用 PDO
#define CO_CONFIG_SDO 1 // 启用 SDO
#define CO_CONFIG_EMCY 1 // 启用紧急报文
#define CO_CONFIG_LSS 0 // 禁用 LSS(节省约 2KB Flash)
#define CO_CONFIG_SYNC 1 // 启用 SYNC
#define CO_CONFIG_TIME 0 // 禁用时间戳
#define CO_CONFIG_OD_STORE 1 // 启用 OD 非易失存储
#define CO_CONFIG_OD_READWRITE 1 // 允许运行时修改 OD
对于简单的 I/O 从站,如果不需要 LSS 和 SDO 分段传输,可以关闭这些功能,将协议栈的 Flash 占用从约 12KB 降低到 6KB 左右。
3.4.3 对象字典定义与生成
对象字典是 CANopenNode 的灵魂。通常使用 CO_OD.h 中的 OD_Entry_t 数组来静态定义。每个条目包含索引、子索引、数据类型、访问权限、数据指针等。例如,定义一个 8 位数字量输出对象:
// 在 OD 数组的 0x2000 索引处定义
OD_Entry_t OD_EntryTable[] = {
// 0x1000 设备类型
{0x1000, 0x00, OD_TYPE_UNSIGNED32, OD_ACCESS_RD, (void*)&DeviceType},
// 0x2000 数字量输出(子索引 0x01 为通道 1)
{0x2000, 0x01, OD_TYPE_UNSIGNED8, OD_ACCESS_RW, (void*)&DO_Channel1},
// ... 其他条目
{0, 0, 0, 0, NULL} // 结束标记
};
当主站通过 SDO 写入 0x2000:01 时,协议栈会自动更新 DO_Channel1 变量。应用程序只需在循环中读取该变量并驱动 GPIO 即可。
3.4.4 应用逻辑与主循环集成
在裸机环境中,主循环的典型结构如下:
int main(void) {
// 硬件初始化(时钟、GPIO、CAN、定时器)
// 初始化 CANopen 协议栈
CO_CANmodule_t CANmodule;
CO_CANinit(&CANmodule, &CAN_Handle, CO_CONFIG_CAN_RX_BUFFER_SIZE);
CO_NMT_reset(&CO_NMT);
// 主循环
while (1) {
// 处理 CANopen 协议栈(接收报文、更新状态机)
CO_process(&CO_CANmodule, &CO_NMT, &CO_OD, &CO_PDO, &CO_SDO, &CO_EM);
// 应用逻辑:读取传感器、控制输出
DO_Channel1 = read_switch();
// 发送 TPDO(如果配置为异步事件触发)
if (DO_Channel1 != last_state) {
CO_PDO_send(&CO_PDO, 1); // 发送 TPDO1
last_state = DO_Channel1;
}
}
}
3.5 实战案例:8 路数字量 I/O 从站开发
下面通过一个完整的案例来串联上述知识点。假设我们需要开发一个 8 路数字量输入、8 路数字量输出的 CANopen 从站,节点 ID 为 5,波特率 500kbps。
背景与约束:MCU 选用 STM32F103C8T6(64KB Flash,20KB RAM),CAN 收发器为 TJA1050,供电 24V 工业电源。要求支持 NMT、PDO(异步事件触发)、SDO(用于配置)、心跳报文。不支持 LSS 和 SYNC。
实施步骤:
- 驱动移植:基于 STM32 HAL 库实现 CO_driver.c,配置 CAN1 中断优先级为最高,1ms 定时器使用 TIM2 中断。
- 配置裁剪:在 CO_config.h 中关闭 LSS、SYNC、TimeStamp,启用 EMCY。
- OD 定义:在 0x2000 索引定义 8 个输入位(只读),0x2100 定义 8 个输出位(读写)。同时定义 0x1017 心跳时间(默认 1000ms)。
- 应用逻辑:主循环中读取 GPIOA 引脚状态写入 OD 输入变量;将 OD 输出变量写入 GPIOB 引脚。当输入状态变化时,调用
CO_PDO_send()发送 TPDO1。 - 测试验证:使用 CANopen 主站工具(如 CANopen for Python、PCAN-Explorer)进行 SDO 读写测试、PDO 事件触发测试、心跳监控测试。
结果与复盘:最终协议栈占用 Flash 约 8.2KB,RAM 约 1.5KB(不含 CAN 缓冲区)。在 1ms 主循环下,CPU 占用率低于 5%。开发中遇到的主要问题是 CAN 过滤器配置错误导致 SDO 报文无法接收,通过对照 CANopen 预定义连接集(SDO 接收 COB-ID = 0x600 + NodeID = 0x605)逐一检查过滤器掩码后解决。
经验总结:在移植 CANopenNode 时,建议先使用官方示例工程(如 STM32F107 的评估板例程)跑通基础通信,再逐步修改驱动和 OD。不要一开始就追求功能齐全,先实现 NMT + SDO + 心跳,再添加 PDO 和 EMCY,这样排障范围更小。
3.6 常见误区与设计建议
在实际项目中,开发者常犯以下错误:
- 误区一:将 CANopenNode 视为一个“黑盒”,不阅读源码就盲目调用 API。实际上,理解 CO_process() 内部的执行顺序(先收报文、再处理定时器、最后更新状态)对调试至关重要。
- 误区二:在中断服务函数中直接调用 CO_PDO_send() 或修改 OD 变量。CANopenNode 的 API 并非完全可重入,建议在中断中只置标志位,在主循环中处理。
- 误区三:忽略 OD 存储功能。如果产品需要掉电保存节点 ID 或波特率,必须在 CO_config.h 中启用 CO_CONFIG_OD_STORE,并实现 Flash 读写驱动,否则每次上电都会恢复默认值。
- 误区四:PDO 映射配置错误。例如将 16 位变量映射到 8 位 PDO 槽位,会导致数据错位。建议使用 CANopen 配置工具自动生成映射参数。
最后,建议在项目初期就建立协议栈版本管理(当前稳定版为 4.3.x),并关注官方 GitHub 仓库的更新。CANopenNode 社区活跃,对于常见 MCU(STM32、NXP、Renesas)都有现成的移植示例,充分利用这些资源可以大幅缩短开发周期。