ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ican源码解析:从对象字典到NMT的CanOpen协议栈移植实践

ican源码解析:从对象字典到NMT的CanOpen协议栈移植实践 简介面向嵌入式开发者和CAN-bus通信学习者这份源代码提供了iCAN增强型协议在控制器局域网络中的C语言实现。iCAN在标准CAN基础上扩展了数据帧格式、错误检测恢复机制和网络管理功能常用于汽车电子、工业自动化等可靠性要求较高的场景有助于读者理解协议底层工作原理与实际工程应用。压缩包内共2个文件包含1个C源文件与1个头文件整体仅14KB结构精简便于快速定位收发、校验、同步与仲裁等核心逻辑。已有220人学习/下载适合具备一定C语言基础、希望深入掌握CAN通信及协议栈设计的工程师参考。通过阅读源码实现和头文件定义可对照学习CAN报文的构造与解析方式结合实物硬件或仿真环境练习有效提升嵌入式实时通信与网络协议设计方面的代码实践能力。 拿到一份协议栈的源代码和拿着一个库的 API 文档完全是两种体验。我最早接触 ican 这套基于 can-bus 的开源协议栈是在一个四轴运动控制项目里控制器用 STM32F407要做一个 CanOpen 从站支持 16 组 PDO 映射和两个 SDO 服务器。当时文档只有薄薄几页真正让我把 CanOpen 规范理解透的反而是后面一行一行读这套源代码的过程。这篇文章就把我梳理 ican 源代码的思路写出来包括它内部有哪些模块、对象字典是怎么组织的、NMT 状态机怎么跑以及移植到自己的 MCU 上到底需要做哪些事。适合准备移植 CanOpen 的嵌入式工程师也适合刚接触 CAN 总线、想通过源码理解协议栈运行机制的人。先声明一点不同版本、不同分支的 ican 源码在文件命名和接口上会有差异但底层思路都遵循 CiA DS-301 标准照着标准去看代码基本不会迷路。1. 为什么选 ican和 CanOpenNode、libcanopen 的取舍市面上开源的 CanOpen 协议栈不少GitHub 上随便一搜就能翻出一堆。我选 ican 不是因为它功能最全恰恰相反是看中了它“足够小、足够透明”。这套协议栈的设计目标很明确能在资源受限的 MCU 上跑起来代码里没有太多抽象层每个功能模块都能单独读下来。做一个直观的对比当时我在几个主流方案里选型维度icanCanOpenNodelibcanopen代码量精简核心栈几千行级别功能完整体量大不少中等偏重抽象层较多外设依赖只需 CAN 驱动 定时器依赖较多 RAM/ROM有些版本带网口栈对底层接口封装较深裁剪难度直接改模块文件容易下手模块多裁剪工作量大配置灵活但理解成本高维护状态稳定改动不多社区活跃持续更新维护较好偏现代风格适合场景中小型从站、资源紧张的单片机复杂从站或完整网络管理需要长期迭代的产品项目我选 ican 还有一个原因它把 NMT、SDO、PDO、心跳这些 CanOpen 核心机制都集中在一两个 C 文件里调用关系非常直接。对比 CanOpenNode 那种大而全的结构ican 更像一个“能一口气读完”的参考实现。对于想理解协议本身的人来说代码量小反而是优势——你可以完整追踪一条 SDO 报文从总线进来到对象字典被修改的全过程中间不会有太多跳转和隐藏逻辑。如果项目后续要做复杂的主站功能、需要频繁的 LSS 配置或者大量节点管理ican 的某些能力确实不够用需要自己扩展。但做中小规模从站、网关或者测试工具它足够稳。2. 读完目录结构ican 源码里到底有哪些模块拿到源码第一件事不是读代码而是先建立全局认知这个协议栈由哪些模块组成每个模块管什么模块之间怎么通信。这一步做好了后面读任何具体函数都不会迷路。2.1 核心模块清单与职责ican 的源码结构不算复杂拆开看无非几块NMT 状态机模块负责节点在初始化、预操作、操作、停止四个状态之间切换同时处理网络管理请求。这是从站的生命周期管理也是 CanOpen 协议栈的“主骨架”。SDO 服务模块实现对象字典的读写访问。主站通过 SDO 请求读、写从站对象字典里的条目SDO 支持快速传输和分段传输两种模式。PDO 处理模块负责实时过程数据的收发。发送 PDOTPDO在事件、定时或远程帧请求下触发接收 PDORPDO从总线拿到数据后写入对象字典。对象字典OD模块整个协议栈的“数据中枢”所有网络通信最终都在读写 OD 里的条目。OD 的实现方式直接决定了协议栈的内存占用和访问效率。心跳与节点监控从站按设定周期发送心跳报文数据字节是当前 NMT 状态主站靠它判断从站是否在线。同步与紧急报文SYNC 报文用于同步多个从站EMCY 报文用于上报故障。这两个模块在某些裁剪版里是可选的。LSS 模块链路层服务用于在线配置节点 ID 和波特率。在 ican 里通常默认不启用需要的话自己打开宏。从代码阅读顺序上我建议按这个路径走先看主循环或处理入口知道协议栈怎么被调用再看对象字典知道数据存在哪、怎么被索引再看 NMT 状态机知道什么时候哪种报文能被处理接着看 SDO理解网络如何访问 OD最后看 PDO理解实时数据通道为什么这么设计。2.2 理清调用关系比看懂每个函数更重要很多初学者读源码喜欢从第一个函数逐行看到最后一个函数这种做法在协议栈代码里效率很低。CanOpen 的核心是异步事件驱动CAN 消息来了触发接收定时器到了触发心跳主站发来 NMT 命令触发状态切换。这些事件会调用协议栈里不同的处理函数。所以正确的读法是先找“事件入口”接收中断或者轮询函数里调用了哪些协议栈函数定时器回调里又调用了哪些。先把这些关系画出来哪怕手写在一张纸上再顺着路径去读具体实现就能把协议栈的运行脉络理顺。ican 的好处就在这里它的函数命名比较直白从命名基本能看出这个函数属于哪个模块、处理什么事件全局搜索 COB-ID 0x580/0x600 这类关键字也能很容易定位到 SDO 相关的收发逻辑。3. 对象字典整个源码的“藏宝图”如果你问我 CanOpen 协议栈的灵魂是什么我一定会说是对象字典而不是状态机。状态机再复杂也就是几个状态互相切换但对象字典决定了整个设备暴露给网络的是什么样子也决定了你写业务代码时数据从哪里来、到哪里去。ican 里所有的 SDO 读写、PDO 映射最终都要落到对象字典上。3.1 索引空间怎么划分对象字典本质上是一张按索引组织的表。索引是一个 16 位地址每个索引下面可以有多个子索引。CanOpen 标准把索引空间划成了几个区段索引范围用途说明0x1000 - 0x1FFF通信对象区设备类型、心跳周期、PDO 通信参数、PDO 映射等0x2000 - 0x5FFF制造商特定区厂商自定义数据通常放设备的功能参数0x6000 - 0x9FFF标准设备对象区由设备配置文件定义比如伺服驱动的控制字、状态字这里最容易踩坑的是索引的分层关系。0x1800 下面有子索引子索引 0 表示该对象一共有几个子项子索引 1 是 COB-ID子索引 2 是传输类型……写代码和调试时如果忘记子索引 0 的“条目数”含义经常会遇到“索引读到了但数据怪怪的”的情况。3.2 关键的通信对象逐个看一个典型 CanOpen 从站里通信相关的几个核心索引需要重点关注0x1000 设备类型32 位高 16 位是设备配置文件号低 16 位是附加信息。主站通常先读这个索引确认设备类型。0x1017 心跳周期单位是毫秒。填 0 表示禁止发送心跳。主站判断从站离线依赖的也是这个值。0x1018 标识对象包含厂商 ID、产品代码、版本号等。很多主站软件扫描设备时会读这个区域。0x1400 - 0x140F RPDO 通信参数每个 RPDO 的 COB-ID、传输类型等。0x1600 - 0x160F RPDO 映射参数定义这个 PDO 报文里每个字节对应对象字典的哪个条目。0x1800 - 0x180F TPDO 通信参数每个 TPDO 的 COB-ID、传输类型、事件周期等。0x1A00 - 0x1A0F TPDO 映射参数定义 TPDO 要发送哪些对象字典条目。顺带一提0x1600 和 0x1A00 的映射条目格式是固定的每个 32 位字的高 16 位是索引号接下来的 8 位是子索引号低 8 位是位长度。调试时如果 PDO 发出来数据长度不对大多数时候是映射条目的位长度写错了。3.3 对象字典在 C 代码里是怎么实现的ican 里对象字典的实现方式属于比较直白的一类定义一张结构体数组每一行描述一个索引条目的属性包括索引、子索引、数据类型、读写权限、数据指针、长度和访问回调。SDO 请求到达时协议栈用目标索引去这张表里查找到对应的数据指针和权限然后执行读或写。实际使用中可以这样注册一个自定义对象/* 注册一个 0x2000 索引下的自定义对象 */ OD_Entry_t my_entry { .index 0x2000, .subindex 0x01, .type OD_TYPE_U32, .access OD_ACCESS_RW, .data my_var, .data_len sizeof(my_var), }; OD_AddEntry(my_entry);这里只是示意具体 API 要以你拿到的版本为准但思路是通用的你的业务变量先注册进对象字典之后主站通过 SDO 就能直接读写它。这样做的好处是应用层代码不需要关心网络协议细节只需要维护自己的变量坏处是如果对象字典实现得很大、条目很多查表耗时和内存占用会上升所以 MCU 上要做好裁剪。4. NMT 状态机和报文处理主循环对象字典决定了协议栈“有什么数据”NMT 状态机则决定了协议栈“在什么时候允许干什么”。理解了状态机你就理解了为什么主站要先发启动命令、从站才动 PDO。4.1 四个状态与典型切换路径CanOpen 的 NMT 状态机有四个状态初始化设备上电后自动进入完成 CAN 控制器初始化和协议栈自检。预操作初始化完成后进入。这个状态下允许 SDO、心跳、紧急报文通信但PDO 禁止收发。操作主站发送启动节点命令后进入。PDO 被允许设备进入正常工作模式。停止主站可以随时让节点进入停止状态此时除了 NMT 和心跳其他通信都被挂起。状态切换的报文格式很直接CAN ID 为 0x000数据区第一个字节是命令字节第二个字节是节点 ID。比如启动节点命令是 0x01停止是 0x02进入预操作是 0x80。如果节点 ID 是 0表示所有节点都执行这个命令。为什么这样设计核心是为了安全。设备可能在配置没有完成之前被误启动预操作阶段把 PDO 关掉逼着主站先通过 SDO 完成参数配置再显式发出启动命令避免设备带着错误参数执行运动。4.2 一个典型的报文处理流程协议栈收到一帧 CAN 报文后先按 CAN-ID 分类再分发到不同处理函数。伪代码大概是这样的逻辑void CAN_RxIndication(uint32_t cob_id, uint8_t *data, uint8_t len) { if (cob_id 0x000) { NMT_ProcessCommand(data[0], data[1]); /* NMT 命令 */ } else if ((cob_id 0x7F) 0x580) { /* 收到 SDO 响应帧说明本节点还承担 SDO 客户端功能 */ } else if ((cob_id 0xFF) 0x080) { /* 紧急报文 */ } else if ((cob_id 0x7F) 0x700) { /* 心跳或节点守护报文 */ } else if (cob_id 0x180 node_id) { /* 根据具体的 COB-ID 区间进一步判断 PDO 或 SDO 请求 */ } }4.3 为什么主循环和定时器节拍这么重要ican 这类协议栈通常不是把所有逻辑都塞进中断里而是建议你维护一个主循环不断调用协议栈的处理函数中断或底层接收函数只负责把 CAN 帧放进缓冲区主循环再去取帧调用协议栈。原因在于 SDO 分段传输、对象字典访问这些操作可能比较耗时放在中断里会拉长中断响应时间影响系统的实时性。定时器节拍则是整个协议栈的“时间基准”心跳周期靠它计数SDO 超时靠它判断PDO 事件驱动模式也靠它触发。如果定时器节拍没启动或者频率不对表现就是心跳不发了、SDO 超时、PDO 不定期发送。调试这类问题第一步永远是确认 tick 有没有正常进入协议栈。一个最小主循环看起来是这样的int main(void) { CAN_Init(1000000); /* 1 Mbps */ Timer_Tick_Init(1000); /* 1ms 节拍 */ OD_Init(); NMT_SetNodeID(0x01); NMT_Start(); while (1) { CAN_ProcessRxQueue(); /* 取出收到帧交给协议栈 */ Timer_Process(); /* 驱动心跳、超时等 */ App_Process(); /* 用户业务逻辑 */ } }5. 移植到自己的 MCU需要实现的接口与配置把 ican 跑在自己的板子上核心工作不是抄代码而是把协议栈和你的硬件“粘”起来。这一步做得好不好直接决定后面调试花多少时间。5.1 必须提供的底层能力清单CAN 驱动收发协议栈要发报文时调用你提供的发送函数底层收到报文时调用协议栈的接收回调把数据喂进去。要注意 CAN 控制器里的 FIFO 深度接收中断里最好只做拷贝队列不要直接处理协议。定时器 tick至少需要一个 1ms 或 10ms 的系统节拍协议栈的心跳、SDO 超时、PDO 事件周期都依赖它。临界区保护如果协议栈会被多个上下文调用比如主循环和定时器中断都调用了协议栈函数需要提供进入/退出临界区的接口防止对象字典读写被并发打断。5.2 初始化流程和最小配置初始化顺序有讲究。我一般按这个顺序来初始化 CAN 控制器和定时器。调用协议栈的初始化函数建立对象字典。设置节点 ID、波特率和心跳周期。配置 TPDO/RPDO 的 COB-ID 和映射表。启动协议栈进入预操作状态。这里提两个容易忽略的配置点0x1018 里的厂商 ID、产品代码和版本号一定要填很多主站工具按这个区分设备类型空着会导致主站识别异常TPDO 的传输类型如果没设置成事件触发PDO 可能只在定时到或远程请求时才发表现为“主站收不到数据”。5.3 踩过的一个典型移植坑我一次移植到国产某款 MCU 上时SDO 怎么读写都失败抓包发现 CAN 帧的字节序反了。因为 CanOpen 标准规定多字节数据在 CAN 帧里按小端传输而我的 MCU 平台的内存序和原参考平台不一样导致 0x2000 索引下那个 32 位参数被解释成了完全不同的值。解决办法是在协议栈底层收发函数里加一层字节序转换读上来的数据先统一转成协议栈内部的小端序写下去之前再转回平台本地序。这一点在移植到不同架构的 MCU 时特别值得提前检查。移植完成后建议先跑通三个链路再去做业务心跳能稳定上报、SDO 能读写对象字典、PDO 能收发。这三条通了协议栈的移植就算成功了一半。6. 调试 CanOpen 时最容易翻车的三个地方调试 CanOpen 和调试普通 UART 程序不一样网络上一旦有多个节点问题往往是多方交互导致的不能只盯着一块板子看。我在用 ican 的过程中翻过几次车总结下来有三个现象最常见。6.1 心跳消失了别说协议栈坏了先查定时器现象主站能扫描到从站但过几秒就报节点丢失重连也不行。抓包看从站刚上电时发了 1 到 2 帧心跳之后就再也没发过。排查链路先看定时器回调有没有实际调用协议栈的心跳处理函数再看心跳周期 0x1017 有没有正确写进对象字典。我当时遇到的问题是定时器中断里调用了一个比较重的函数导致 tick 节拍抖动心跳发送被反复重置。6.2 SDO 能读但 PDO 数据不更新现象主站通过 SDO 读写对象字典完全正常PDO 也显示映射成功但业务数据就是不动。排查链路打开抓包工具确认 TPDO 的 COB-ID 是否和主站配置一致再看 0x1A00 映射表里第二个映射条目发现位长度写成了 8 位但实际数据是 16 位导致主站解析时把两个字节当成了一个字节数据怎么解释都不对。PDO 映射表的索引和位长度是最容易“看起来正确但实际错误”的地方。6.3 两个节点只有一个能通信现象一个网段里接了两个 ican 从站节点 A 一切正常节点 B 完全搜不到。换到单独接 B 时又能正常工作。排查链路终端电阻确认两头都接了 120 欧姆再看两个节点的节点 ID 是否和 COB-ID 计算冲突最后检查波特率B 节点的 CAN 控制器时钟分频配置和 A 不对导致实际波特率偏差。这类问题靠万用表量不出来直接抓总线波形最直观。为了方便快速定位我放一张自己常用的排查表现象可能原因排查方向完全搜不到节点波特率不一致、节点 ID 错误、收发器故障抓总线波形、确认波特率心跳偶尔丢定时器节拍抖动、总线负载高检查 tick 回调耗时、确认总线负载SDO 超时对象字典没有对应索引、访问权限不对读回 0x1000 确认设备类型、检查索引表PDO 数据错乱映射表位长度/子索引错误、字节序问题检查 0x1A00/0x1600 映射项调试工具方面USBCAN 适配器加一个简单的上位机就够用。我特别推荐在开发阶段保持主动抓包的习惯而不是只在出问题时才插上抓包器——这样能建立“什么报文是正常的”直觉后面排查问题时效率高很多。最后分享一个我自己的体会读 ican 这类协议栈源码千万别一上来就钻进 SDO 分段传输或者 PDO 事件定时器里出不来。先把对象字典和 NMT 状态机吃透后面的代码基本都能顺着地址和状态读下来。遇到接口对不上的版本差异回到 CiA DS-301 标准对照比在网上翻碎片化的提问帖要可靠得多。实际调试时准备一块 USB-CAN 适配器、设置好抓包过滤能帮你省掉一半的定位时间。本文还有配套的精品资源点击获取
返回列表