资讯详情

资讯详情

OpENer EtherNet/IP适配器协议栈源码拆解与从站移植调试

简介OpENer 2.1.0 是一套面向 I/O 适配器设备的 EtherNet/IP 协议栈源码适合工业自动化、工业以太网协议开发及嵌入式移植人员学习参考用于构建符合 ODVA 规范并支持多个 I/O 连接与显式连接的适配器产品。压缩包共 180 个文件约 560KB以 51 个 .h 头文件、48 个 .c 源文件为核心辅以 15 个 .cpp、21 个 .txt 文档、10 个 .sh 与 9 个 .cmake 构建脚本并包含批处理、配置及 EDS 等配套文件覆盖协议实现、示例代码、文档说明与跨平台编译材料。已有 1065 人学习下载。读者可从中获得较完整的 EtherNet/IP 适配器协议栈框架、CIP 对象与服务实现参考、默认面向 POSIX 操作系统和 BSD 套接字网络接口的构建入口以及面向嵌入式平台的目录组织便于对照规范梳理连接管理、封装层、TCP/IP 接口和网络处理流程也可借助 Cygwin 在 Windows 上测试并用于协议一致性学习与二次开发。1. EtherNet/IP 适配器协议栈给带网口的设备补上从站能力车间里一台老设备只会吐串口数据上位控制器走的是 EtherNet/IP中间差的不是一根网线而是一整套 CIP 对象模型、连接管理与会话状态机。OpENer 补的就是这段一套纯 C 写的 EtherNet/IP 适配器协议栈多个 IO 连接和显式连接都由它管标识对象、消息路由、连接管理器、TCP/IP 接口、以太网链路等标准对象现成可用做出来的设备在规范层面站得住主站扫描时不会被当成哑设备。适合两类人手上有带网口的板子、想把设备做成标准从站又不想从规范第一页啃起的嵌入式团队以及想搞明白 CIP 报文怎么编码的工程师代码量不大、分层清楚读一遍比翻几十页规范更快建立直觉。2. 源码分层encap、CIP、ports、utils 四层各管一段路拿到这种协议栈源码我一般先不急着看 main而是把目录结构摊开看一遍搞清楚“哪一层负责拆包、哪一层负责找对象、哪一层负责跟硬件打交道”。OpENer 的分层相当干净四层加一个测试目录边界几乎没有互相渗透。这样后面出了问题看日志落在哪一层基本就能定位到哪个文件。2.1 顶层目录四个文件夹把协议切成四段目录职责典型文件source/src/enet_encapEtherNet/IP 封装层TCP/UDP 44818 端口的命令解析与应答组装encap.c、ciptcpipinterface.csource/src/cipCIP 核心对象容器、服务路由、连接管理、内置标准对象cipclass.c、cipmessagerouter.c、cipconnection.c、cipassembly.c、cipidentity.csource/src/ports平台适配层决定栈怎么收发帧、怎么计时、程序从哪启动posix/platform.c、posix/networkhandler.c、win32/同名文件source/src/utils随机数、非易失存储、字节序与定时辅助nvg_storage.c、random.csource/test基于 CppUTest 的单元测试覆盖编码解码和对象服务各模块对应的*_test.cpp读代码的顺序我建议反过来先看enet_encap因为那里最贴近报文本身一个字节一个字节都能对上规范再看cipmessagerouter理解报文怎么被派发到具体对象然后看cipclass这套“类—实例—属性”的容器实现最后才看ports那是纯平台代码逻辑最少但最容易卡住人。下面这几条命令可以帮你两分钟摸清边界不用通读。# 只看两层先把模块边界看清楚别一上来就 tree 到底 tree -L 2 source/src # 命令分发一定在这几个字符串附近四种封装命令是入口 grep -rn SendUnitData source/src/enet_encap # 服务码处理散在各对象里先看通用服务怎么走 grep -rn GetAttributeSingle source/src/cip | head -20 # 内置对象一览一个 .c 基本就是一个对象 ls source/src/cip/*.c这几条命令的用处不在于输出多好看而在于让你确认一件事这不是一个“框架黑匣子”而是一堆按对象拆开的 C 文件。每个对象自己实现自己的服务函数消息路由只负责按 class id / instance id 找到它。2.2 一条报文从网线进来封装头、CPF、路径EtherNet/IP 的报文分两层看最省事外层是封装头Encapsulation Header固定 24 字节内层是 CIP 报文。封装头字段是固定的用小端序字段长度说明Command2 字节0x0063 ListIdentity、0x0065 RegisterSession、0x006F SendRRData、0x0070 SendUnitDataLength2 字节后面数据区的字节数Session Handle4 字节显式连接建立后由设备返回后续报文带上Status4 字节一般填 0出错时设备回填错误码Sender Context8 字节原样回传主站用来匹配请求Options4 字节固定 0显式报文走 TCP 44818用 SendRRData里面可以塞任意 CIP 请求读属性、写属性、走自定义服务都行IO 报文走 UDP 44818用 SendUnitData里面是 CPFCommon Packet Format结构先是 2 字节的条目数然后一个连接地址条目类型 0x00A1长度 4内容是连接 ID再一个连接数据条目类型 0x00B1长度 N内容前 2 字节是序列号后面才是真正的 IO 数据。这两条路径在代码里最终都汇到消息路由但入口和生命周期完全不同后面第 4 章会细说。CIP 报文本身的编码值得单独记一下因为调试时你会反复看到它。请求路径EPATH是按段拼的每段两字节0x20表示“类”后面跟类 ID0x24表示“实例”后面跟实例号0x30表示“属性”后面跟属性号。比如读 Assembly 类0x04实例 100 的属性 3完整请求就是0E 06 20 04 24 64 30 03 │ │ └──┬──┘ └──┬──┘ └──┬──┘ │ │ 类0x04 实例100 属性3 │ └ 路径长度 6 字节 └ 服务码 0x0E Get_Attribute_Single抓到这么一串你就能反推出主站在读哪个对象的哪个属性比对着日志猜快得多。0x64是十进制 100十六进制和十进制在这套协议里来回切血泪经验是EDS 文件、代码宏、抓包结果三处必须统一用一种进制去核对混着看必翻车。2.3 ports 层换个平台真正要动的就这几处ports目录是整份代码里唯一“不可移植”的部分也是移植工作量的全部来源。以 POSIX 端口为例它做的事情拆开看只有五件打开网卡拿原始套接字、收帧、发帧、提供毫秒级时间戳、提供掉电保存。栈本身对这些一无所知只调用一组约定的函数。能力POSIX 端口的做法裸机/RTOS 上的常见改法收帧原始套接字阻塞或轮询读取以太网帧过滤 EtherType 0x80E1在 MAC 中断或轮询里把帧塞进队列再喂给栈发帧原始套接字写入整帧调用 MAC 驱动的发送函数计时gettimeofday转毫秒用一个硬件定时器累加计数随机数伪随机序列有硬件 TRNG 就用没有就用启动时刻的计数器做种子非易失存储写文件写 Flash 的保留扇区需要注意的是那个以太网类型值 0x80E1它是 EtherNet/IP 在链路层的标识主站发过来的帧靠它过滤自己发出去的帧也必须带上它写错一位主站就当没收到而且不会有任何报错——这种“发了但没人理”的问题最耗时间。移植时我的做法是把ports/posix整个目录复制一份改名然后只改里面三四个文件的函数体cip和enet_encap一行不动。这样升级栈版本时冲突范围永远只在端口层。3. 编译与跑通从 cmake 到被主站扫到的最小闭环协议栈这种东西最怕的是“编译过了但网上没人认识你”。所以跑通的标准不能定在make成功而要定在主站能扫到、能建显式连接、能读到标识对象。这一章按这个顺序走一遍中间每一步都有明确的判断依据。3.1 依赖、权限和一个必须提前确认的网卡名构建依赖很少一台干净的 Linux 机器上装三样就够编译器、cmake、git。真正容易卡住的是运行权限和网卡名。POSIX 端口走的是原始套接字普通用户没有权限打开必须sudo或者给可执行文件加CAP_NET_RAW网卡名则是在端口配置头文件里写死的默认大概率是eth0而现在很多机器和开发板上网卡叫enp3s0、end0之类名字对不上程序起来后一帧都收不到也几乎不报错。# 1. 依赖 sudo apt-get install -y build-essential cmake git # 2. 确认你要绑的那块网卡到底叫什么记下来 ip -br link # 3. 确认 44818 端口没被别的进程占着 sudo ss -lunp | grep 44818ip -br link输出里挑那块连着主站或交换机的网口名字记下来后面要填进配置。如果机器上有多个网口只绑一个别想着让栈自己挑它不会挑。3.2 编译命令与产物定位这套代码用 CMake 管理没有乱七八糟的第三方依赖标准流程即可。构建类型建议先用 Debug因为协议栈里不少状态迁移在 Release 下出问题更难查。git clone 仓库地址 OpENer cd OpENer mkdir -p build cd build cmake .. -DCMAKE_BUILD_TYPEDebug make -j$(nproc) # 产物路径随版本会变别死记直接找 find . -maxdepth 6 -name opener -type f -perm -ux-DCMAKE_BUILD_TYPEDebug打开调试符号和断言make -j里的核数按你的机器来。产物是一个叫opener的可执行文件位于build下ports/posix对应层级的目录里找不到就用上面那条find。这里提醒一句如果你改了opener_user_conf.h里的网卡名或设备参数必须重新cmake一次只make有时候不会重新生成配置头改了半天没生效会怀疑人生。运行的时候这样起# 原始套接字需要权限网卡名如果不在配置里先改配置再重编 sudo ./source/src/ports/posix/opener正常运行起来后终端会打印初始化过程和收发报文的信息如果编译时开了调试输出。看到周期性打印说明栈在心跳一片安静先回去查网卡名和权限。3.3 设备身份参数和 EDS 必须对齐主站识别一台 EtherNet/IP 设备靠的是标识对象Class 0x01实例 1里的几项属性厂商 ID、设备类型、产品代码、主次版本号、序列号、产品名。这几项在代码里有默认值一般在端口配置头文件和标识对象实现里按你的设备实际情况改掉。同时仓库里带了一份示例 EDS 文件电子数据表主站扫描时读的配置文件就是它里面写的是同样的字段加上 Assembly 实例列表、连接参数、IO 数据长度。这里是最经典的翻车点代码里 Assembly 长度改成 32 字节EDS 里还写着 16主站按 16 配连接设备按 32 收连接能建起来数据永远是错的。所以改参数的正确姿势是同步改三处——C 代码里的缓冲区长度、EDS 里的 Assembly 大小、主站侧的连接配置。三处对齐了后面的路才顺。3.4 三阶段验证广播、显式、IO 逐层推进不要一上来就配 IO 连接失败了根本分不清是网络问题、会话问题还是数据映射问题。我一般分三段验第一步发一个 ListIdentity 广播看设备回不回。这一步不建立任何会话纯粹验证“设备在不在网上、封装层活着没有”。用 Python 手搓一个 24 字节的封装头就够不依赖任何库import socket # 封装头 24 字节命令 0x0063(ListIdentity) 长度 0 会话 0 状态 0 上下文 8 字节 选项 0 payload bytes.fromhex( 6300 # Command 0x0063小端 0000 # Length 0 00000000 # Session Handle 00000000 # Status 0000000000000000 # Sender Context 00000000 # Options ) s socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) s.settimeout(3) s.sendto(payload, (255.255.255.255, 44818)) try: data, addr s.recvfrom(2048) print(设备回应, addr) print(原始应答, data.hex()) except socket.timeout: print(没回应查网卡名、权限、防火墙、是否同网段)这段脚本的价值在于把“扫不到”这个问题从主站软件里剥出来。主站扫不到有很多原因但这脚本扫不到就只剩网络层和端口层的原因排查范围立刻小一半。注意广播要发在同一网段跨网段广播过不去。第二步建 TCP 显式连接读标识对象实例 1 的属性 1厂商 ID。通了说明会话层、消息路由、对象服务都是好的。第三步才是配 IO 连接把输入输出 Assembly 的实例号、长度、RPI 填对。到这一步如果数据不对问题一定在数据映射上跟网络无关。提示三阶段验证时把日志开满每通过一步再关不然输出会淹没有效信息。4. 核心代码拆解栈初始化、Assembly 对象与 IO 数据通路前面是外围这一章进到真正要改的那几百行。整个项目的应用侧代码量其实很小核心就是你写的那份应用初始化、两个 Assembly 实例、以及主循环里的回调。读懂这三块剩下的都是标准对象在自转。4.1 栈初始化和主循环顺序不能反程序入口在端口的platform.c里POSIX 版本的结构非常直白初始化栈、初始化应用对象、进死循环。看起来简单但顺序错了会出玄学问题——比如先建 Assembly 再初始化栈对象注册到一半被栈初始化重置掉表现是主站能扫到设备但读不到 Assembly 实例。#include cipstackinit.h int main(void) { /* 唯一连接 ID参与连接实例号和连接 ID 的生成。 同一网段里两台设备用同一个值主站侧可能出现连接串号 多实例部署时每个实例必须给不同的值 */ if (CipStackInit(0x00000001u) ! kEipStatusOk) { return -1; } /* 必须在栈初始化之后这一步会创建 Assembly 实例、 注册自定义对象、挂业务回调。顺序反了对象会被覆盖 */ if (ApplicationInitialization() ! kEipStatusOk) { return -1; } for (;;) { /* RunIdle 里干两件事跑栈内部的定时任务连接超时、 看门狗、重连再执行用户自己的周期逻辑。 这个函数必须被高频调用别在里面 sleep 太久 */ g_application.RunIdle(); } return 0; }参数只有一个就是那个unique_connection_id。它的作用是给连接实例号做种子让不同设备生成的连接 ID 不容易撞。单台设备随便填成批出货的设备建议用序列号或 MAC 末几位派生省得现场两台设备抢同一个连接 ID。主循环里的RunIdle是全局心跳所有基于时间的状态机都靠它推进一次睡几百毫秒连接超时检测就会失准表现是主站那边连接断了设备这边还以为活着。4.2 Assembly 对象缓冲区、实例号、方向Assembly 对象是 IO 数据的载体也是整个项目里你最需要动手改的地方。它本质上是“一块缓冲区 一个实例号 一个方向”。创建分两步先建对象实例再配置连接方式。很多人只做了第一步结果对象能读不能连。#include cipassembly.h /* 实例号不是随便取的主站连接配置里写的就是这两个数字 EDS 里的 Assembly 实例列表也必须一致三处对不上就建不起连接 */ #define kAssemblyInputInstance 100u /* Adapter 产生、主站消费 */ #define kAssemblyOutputInstance 101u /* 主站产生、Adapter 消费 */ /* 缓冲区长度决定每个 IO 周期真正传输的字节数。 长度发布出去之后主站按 EDS 里的值来配改代码不改 EDS 就会错位 */ static EipUint8 g_input_data[32]; static EipUint8 g_output_data[32]; EipStatus ApplicationInitialization(void) { /* 第一步把实例号和缓冲区登记进对象表此时属性可读 */ if (CreateAssemblyObject(kAssemblyInputInstance, g_input_data, sizeof(g_input_data)) ! kEipStatusOk) { return kEipStatusError; } if (CreateAssemblyObject(kAssemblyOutputInstance, g_output_data, sizeof(g_output_data)) ! kEipStatusOk) { return kEipStatusError; } /* 第二步配置成点对点连接允许主站跟本设备建 IO 连接。 只有走了这一步实例才会被连接管理器纳入可用资源 */ ConfigureAssemblyPointToPoint(kAssemblyInputInstance, g_input_data, sizeof(g_input_data)); ConfigureAssemblyPointToPoint(kAssemblyOutputInstance, g_output_data, sizeof(g_output_data)); /* 多播场景一台主站把输出数据同时发给多台设备换成 ConfigureAssemblyMulticast(...)参数一致 */ return kEipStatusOk; }这里最需要说清的是方向也是我见过最多人搞反的地方。对适配器从站来说输入指的是自己产生、发给主站的数据比如传感器读数、状态字输出指的是主站发过来、自己接收的数据比如控制字、设定值。名字是站在主站视角命名的第一次接触的人几乎都会反着理解反了之后现象很诡异连接正常、数据在刷新但业务逻辑完全对不上。CreateAssemblyObject和ConfigureAssemblyPointToPoint的区别也值得记一笔前者只把实例挂进对象表让它对显式报文可见后者才把它标记成可参与 IO 连接并把数据指针交给连接管理器。只调前者主站能用显式报文读到数据但建 IO 连接时会报路径错误或者实例不存在。4.3 显式报文和 IO 报文在代码里的分界两条路径在封装层就分开了。TCP 上收到的是 SendRRData解出 CPF 拿到未连接数据条目整个 CIP 报文交给消息路由路由按路径找到对象和实例调用对应服务函数同步算完再回包。UDP 上收到的是 SendUnitData解出连接 ID连接管理器按 ID 找到连接对象再用连接上记录的生产/消费实例号直接往对应 Assembly 的缓冲区读写不经过服务函数。这个差异决定了你的数据什么时候有效显式读 Assembly 属性读到的是当前缓冲区的即时快照IO 连接读的是上一个周期锁定的数据。如果你在业务里对缓冲区做了非原子的大块搬移显式读和 IO 读可能看到不一致的内容。我的做法是把业务数据先算进一个暂存区再整块拷进 Assembly 缓冲区拷贝前后关一下中断或加个短锁几十字节的量级开销可以忽略。还有一点IO 连接的输出数据到达时栈只负责把字节写进g_output_data不会通知你“数据来了”。需要感知更新的话自己在主循环里对比上一周期的内容或者利用连接对象上的状态变化。指望回调通知在这个栈里是找不到的。4.4 应用回调把业务逻辑挂在正确的位置应用侧要做的就是把业务数据灌进缓冲区顺便处理一下自定义对象的请求。栈通过一个全局的应用结构体调用你的函数结构体里几个函数指针各有明确分工。/* 应用结构体字段名以你下载的版本为准 不同提交间函数签名调整过编译报错就照头文件补参数 */ Application g_application { .InitialiseCipClass ApplicationInitialiseCipClass, /* 注册自定义类 */ .RunIdle ApplicationRunIdle, /* 周期任务 */ .HandleApplication ApplicationHandleApplication, /* 自定义服务分发 */ .ShutdownApplication ApplicationShutdown, /* 掉电/退出清理 */ .Reset ApplicationReset, /* 复位到出厂状态 */ }; /* 周期任务采集、计算、写输入缓冲区。 频率跟着主循环走别在这里做阻塞 IO */ void ApplicationRunIdle(void) { static EipUint32 last_ms 0; EipUint32 now GetMillisecs(); if (now - last_ms 10) { /* 10ms 采集一次比 RPI 略快即可 */ return; } last_ms now; /* 采集值先放暂存区整块拷贝进输入缓冲区避免读到半更新数据 */ g_input_data[0] (EipUint8)(ReadChannelA() 0xFF); g_input_data[1] (EipUint8)(ReadChannelB() 0xFF); /* 输出缓冲区由主站写入这里只读不写 */ if (g_output_data[0] 0x01) { SetRelayOn(); } else { SetRelayOff(); } }RunIdle是所有周期逻辑的落点采集、控制、状态机都放这里注意别做阻塞操作。HandleApplication是另一条路只有主站用显式报文访问你自定义的类时才进来参数是原始 CIP 报文缓冲区和长度你需要自己解析服务码和路径把结果写回发送缓冲区。Reset常被忽略但主站下发复位命令时会走到里面该做的是把输出清零、状态机归位而不是什么都不写。5. 常见问题排查扫不到、连不上、数据不动这套栈的报错风格偏安静很多问题不会主动打印只能靠现象反推。下面五条是我在实际调试里重复遇到过的按“现象 → 原因 → 解决”记下来遇到时对号入座比从头翻代码快。5.1 网络层设备像不存在一样现象一主站扫描列表里始终没有这台设备Python 广播脚本也没回应。原因通常有三个程序没有原始套接字权限起来就退出了但终端被别的输出刷过去没注意端口配置里写死的网卡名跟实际网卡对不上机器上开了防火墙把 UDP 44818 挡了。解决顺序是先用ip -br link确认网卡名改配置重新cmakemake再用sudo启动最后临时关掉防火墙验证。判断依据很简单广播脚本能收到回应说明网络层通了问题一定在上层。现象二广播能收到回应但 TCP 连接一建就断。原因多是 44818 端口被同机器上的另一个进程占了或者上一次运行残留的进程还挂着没退。用sudo ss -lunp | grep 44818和sudo ss -ltnp | grep 44818分别看 UDP 和 TCP把占用进程清掉。还有一种情况是设备的 IP 和主站不在同一网段广播能过但单播回不去这种用ping就能确认。5.2 对象与连接层通了但不对现象三显式连接能读到标识对象建 IO 连接时报路径错误或实例不存在。基本可以断定是 Assembly 只做了CreateAssemblyObject没做ConfigureAssemblyPointToPoint或者实例号和 EDS 里写的不一致。先抓一次主站的连接请求报文看它请求的实例号是多少再回头对照代码里的宏和 EDS 里的列表三处必须完全一致。十六进制和十进制混着看是常见失误0x64和100是同一个值别当成两个。现象四IO 连接建起来了数据一直在刷新但业务对不上。九成是输入输出方向搞反了。适配器的“输入”是自己产生给主站的数据“输出”是主站发过来给自己消费的数据。验证方法很直接在主站侧强制写一个已知值到输出区看设备端g_output_data里有没有出现这个值如果没有说明主站写的其实是你的输入实例。改代码里的实例号分配同时改 EDS重新来过。现象五跑几个小时或几天后连接莫名断开重启又正常。这一类最像玄学实际原因往往很朴素主循环里的周期任务越跑越慢导致RunIdle调用频率掉下去连接看门狗超时或者并发连接数超过了栈里的上限宏新连接把老连接挤掉。解决方式是先把主循环里所有可能阻塞的调用文件 IO、串口等待、动态内存分配找出来干掉再看端口配置头文件里那些以OPENER_开头的上限宏按实际需要的连接数往上调。长时间跑的压力测试一定要做别等现场出问题。注意这套栈对内存和连接资源的管理是静态分配为主跑之前先把上限宏核对一遍比事后查崩溃日志省事得多。6. 二次开发把示例 IO 换成你自己的设备模型真正落地的时候示例里的那两个 32 字节缓冲区肯定不够用。常见的改法有两类一类是纯数据映射把更多传感器通道塞进输入 Assembly、把更多控制字塞进输出 Assembly改缓冲区长度和 EDS业务逻辑照样挂在RunIdle里另一类是加自定义对象让主站能用显式报文读一些不适合放进 IO 周期的参数比如设备序列号、标定系数、故障历史。自定义对象的写法不复杂定义一个类 ID厂商自定义段一般是 0x64 往上准备好实例和属性表实现Get_Attribute_Single、Set_Attribute_Single这两个最常用的服务然后在ApplicationInitialiseCipClass里把这个类注册进对象表。属性读写的回调里直接操作你自己的结构体别去碰 Assembly 缓冲区。这样显式通道和 IO 通道职责分明主站侧配置也清楚。几个实操上的经验都是踩过之后才想明白的改造点建议做法原因缓冲区扩容一次改到位留 20% 余量改长度要同步动 EDS 和主站配置返工成本高业务与协议解耦业务只操作自己的结构体拷贝进 Assembly避免显式读和 IO 读看到不一致数据周期任务用时间戳做非阻塞节拍不用 sleep保证RunIdle调用频率稳定移植只改ports目录cip和enet_encap不动升级栈版本时冲突范围可控版本管理EDS 文件和代码放同一个仓库、同一次提交现场出问题能追溯到对应的配置移植到裸机或 RTOS 的时候改动清单其实很短实现毫秒计时、实现帧的收发队列、实现随机数种子、实现非易失存储的读写然后把ports/posix里那几个函数的函数体换掉。栈本身不用改一行这也是这套代码结构最舒服的地方。需要注意的是收发路径上不要做内存拷贝帧缓冲直接在驱动和栈之间传指针否则在百兆网络上高 RPI 场景会明显掉周期。我现在的习惯是任何一次改 Assembly 长度或者实例号都强制走一遍“改代码 → 改 EDS → 重新广播验证 → 建显式连接读一遍 → 建 IO 连接看数据”这条链一步不跳。跳过任何一步后面都会用更长的时间补回来。希望这些能帮你少走点弯路把设备顺利接进主站。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →