ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

libnodave-0.8.5源码编译与C#/LABVIEW对接S7-300

libnodave-0.8.5源码编译与C#/LABVIEW对接S7-300

简介:libnodave-0.8.5.zip 是面向工业自动化开发者与PLC学习者的开源通信库压缩包,重点解决在LABVIEW、C#等环境中与西门子S7系列PLC进行数据交换的问题。该库基于Dave通信协议实现,支持S7-300、S7-400等常见型号,可完成读写操作、周期性数据采集、状态监控与故障诊断,也能在Linux、Mac OS等非Windows平台使用,跨平台特征较为突出。压缩包共120个文件,约1.71MB,以C源码、头文件、HTML说明文档、EXE测试程序及BAT构建脚本为主,并含少量C#示例、DLL库与Makefile,便于编译、调用和验证。已有340人学习下载,适合需要理解S7协议、搭建通信程序或开展教学实验的读者。包内提供较完整的源代码、跨平台库文件与说明文档,可帮助读者对照实现自定义通信程序,并在LABVIEW外部函数节点或C#的DllImport调用场景中快速定位接口位置、理解数据类型与排查通信参数配置问题。

1. 一个只有源码的压缩包:为什么 libnodave-0.8.5 到现在还有人在装

前段时间帮人收拾一台工控机,上位机界面是 LABVIEW 画的实时曲线,配套的数据落库和 MES 对接那一侧是 C# 写的,两边要读同一台 S7-300 里的同一个 DB 块。现场没有 OPC 服务器,也没有预算买商业通信组件,翻来翻去最后落到 libnodave-0.8.5.zip 上。

问题就在这:这个包解压出来没有 setup.exe,没有现成的 dll,只有一堆 .c、.h 和 makefile。第一次装的人容易走两个极端,要么以为下载错了直接删掉,要么照着某篇半截教程把 DLL 复制到 system32 就当成装完了。真正的翻车都在后面——DLL 位宽不对,LABVIEW 点一下运行直接异常退出;调用约定写错,程序不是报错而是卡死;好不容易连上了,读回来的 REAL 是 1.4e-45 这种鬼数。

这套环境适合两类人:一类是手上只有 S7 系列 PLC、需要用 LABVIEW 或 C# 直接把数据拿出来的现场工程师;另一类是想搞明白工业通信底层怎么跑起来、不想被黑匣子组件绑死的开发者。下面按我实际装过一遍的顺序写:先钉环境、再编库、然后分别从 C# 和 LABVIEW 两侧接进去,最后是装坏了怎么救。

2. 环境钉版与源码编译:0.8.5 包里哪几个文件真正要留

开装之前先花十分钟把环境对一遍。这套东西装不成功,八成不是代码写错,而是一开始版本和位宽就没对齐。

2.1 先把版本和环境钉死

我这边实际跑通的一套组合如下,工控机上装的东西越少越好,能对上就别乱换:

项我用的版本说明与可替代方案
操作系统Windows 10 x64 工控机Win7 SP1 也能跑,老机器的网卡驱动反而更稳
编译器(Windows)VS2015 的 cl 命令行VS2010 以上都行,关键是能生成 x86 目标
编译器(Linux)gcc 5.4 + make采集网关跑 Linux 时用这条线
上位机界面LABVIEW 2015 32 位32 位这一条是硬约束,后面会讲
数据侧程序.NET Framework 4.6不要用 .NET Core 的 AnyCPU 默认值
现场设备S7-300 带以太网模块S7-200 走 CP243-1 时协议要换
网络工控机与 PLC 同一交换机网段跨网段就先别折腾,先把直连跑通

版本这块有个血泪经验:网上流传的示例代码里,有一部分是老版本写的,daveNewInterface只有四个参数,而 0.8.5 的头文件里是五个参数,第一位要传一个接口指针。照抄老示例,编译器第一行就报参数个数不匹配,新手很容易以为是下载的包坏了。判断方法很简单,打开nodave.h搜一下这个函数的声明,参数个数对不上就以头文件为准,不要信任何博客里的代码片段。

2.2 解压后先认文件,别急着编译

解压到一个不带中文、不带空格的路径下,比如D:\sdk\libnodave-0.8.5。目录长这样:

libnodave-0.8.5/ ├── nodave.c # 协议主体,S7 报文都在这里拼 ├── nodave.h # 全部宏定义和函数声明,最重要的一份文件 ├── setport.c # 串口收发,MPI / PPI 走这条 ├── openSocket.c # TCP 收发,ISO-on-TCP 走这条 ├── makefile # Linux 下的编译脚本 ├── test/ # testISO_TCP.c 之类的现成示例 ├── doc/ # 协议说明和头文件注释 └── dotnet/ # .NET 封装源码(C++/CLI 那一套)

这里要说清楚哪些必须留。nodave.h是头等重要的,后面写 C# 时那些区域常量、协议常量、返回码,全部要从这个文件里抄,版本换了第一件事就是回来核对。test/目录不要删,里面那个基于 TCP 的测试程序是排错利器,网络通不通、机架槽位对不对,用它一跑就知道,比在自己代码里加日志快得多。dotnet/目录我一般不用,它是 C++/CLI 写的封装,编译出来是一个托管程序集,绑定特定的运行时和位数,出问题的时候堆栈都在托管层里,看不出到底哪一步失败,还不如自己写十几行 P/Invoke 声明来得清楚。

真正要参与编译的只有三个 .c 文件:nodave.c、setport.c、openSocket.c。哪怕你只用 TCP,setport.c也一起编进去,省得以后想加一条串口链路时又回头改工程。

2.3 Windows 下编出 libnodave.dll

我习惯直接用命令行,比在 IDE 里点来点去更容易复现。打开对应版本的开发者命令行,切到源码目录:

cl /nologo /LD /O2 /DWIN32 /MD ^ /Fe:libnodave.dll ^ nodave.c setport.c openSocket.c ^ ws2_32.lib

几个参数值得说明一下。/LD告诉编译器这次产物是 DLL 而不是 exe,缺了它你会得到一个带 main 的可执行文件。/O2是发布优化,协议解析对时序有要求,调试版跑长连接容易丢包。/DWIN32让源码走 Windows 分支,setport.c里串口和 socket 的实现是条件编译的,少了这个宏会编出一堆未定义符号。ws2_32.lib必须链上,openSocket.c里用的 socket 函数全在这个库里,不链的话报错信息看着像函数名拼错了,实际是缺库。/MD用动态运行时,跟后面 C# 进程加载的运行时保持一致,混用/MT偶尔会出奇怪的问题。

编完先看一眼导出表,这一步很关键:

dumpbin /exports libnodave.dll

如果列表里能看到daveNewInterface、daveNewConnection、daveConnectPLC、daveReadBytes、daveFree这些名字,说明导出宏生效了,可以往下走。如果导出表是空的,或者只有寥寥几个符号,说明这版源码的导出宏在当前编译选项下没被激活,最省事的补救是加一个.def文件把函数名显式列出来:

LIBRARY libnodave EXPORTS daveNewInterface daveNewConnection daveSetTimeout daveConnectPLC daveDisconnectPLC daveReadBytes daveWriteBytes daveFree

编译时在命令末尾补上/DEF:libnodave.def重新链接即可。这个.def我一般会一直留着,不算什么坏习惯,换编译器版本的时候能省掉一轮排查。

2.4 Linux 侧编 .so,走网关方案时用

有些现场是把采集程序放在一台 Linux 网关上,再往上转发,那就编.so。源码目录里带了 makefile,直接:

make -f makefile

如果 makefile 里的编译器路径和你机器上对不上,手动编也一样:

gcc -shared -fPIC -O2 -o libnodave.so \ nodave.c setport.c openSocket.c

-fPIC不能省,位置无关代码是共享库的前提,漏了会在链接阶段报重定位错误。-shared生成动态库。编完用nm -D libnodave.so | grep dave确认符号在。走串口那条链路的话,还要把运行用户加进串口设备所在的用户组,否则打开/dev/ttyUSB0会直接被拒绝,现象是连接函数立刻返回失败,看起来像 PLC 没响应,实际是权限问题。

3. C# 侧接进来:十二行 P/Invoke 声明读通第一个 DB 块

C# 这一侧其实是两条路里更容易的,因为 P/Invoke 对指针的处理足够灵活,不需要额外写桥接层。

3.1 为什么不用包里那个现成的 .NET 封装

dotnet/目录下的封装确实能用,但我在现场不太愿意带它。它是 C++/CLI 编译出来的,产物绑死三件事:编译时用的 VS 版本、目标框架版本、目标位数。换一台机器部署,很容易出现"在我这儿好好的,到现场就加载失败"。而且它的异常信息大多停留在托管层,你只能看到一句笼统的调用失败,看不到底层返回的那个错误码。

自己写 P/Invoke 声明,成本其实很低,一个静态类十几行就够,最关键是每一个返回值你都能自己打印出来,出问题的时候有据可查。这套东西装一次要用好几年,多花二十分钟换后面几年的可调试性,我认为是值的。

3.2 一份能跑的最小声明

using System; using System.Runtime.InteropServices; internal static class LibNoDave { private const string Dll = "libnodave.dll"; // 下面这些整型常量全部抄自 nodave.h 的宏定义,换版本必须回头文件核对 public const int daveProtoISOTCP = 5; // ISO over TCP,以太网走这个 public const int daveProtoPPI = 4; // S7-200 走 PPI 时用 public const int daveSpeed187k = 0; public const int daveInputs = 0; public const int daveOutputs = 1; public const int daveFlags = 2; public const int daveDB = 3; // 调用约定必须是 Cdecl,源码没有加 __stdcall 修饰 [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr daveNewInterface( IntPtr iface, [MarshalAs(UnmanagedType.LPStr)] string name, int localMPI, int protocol, int speed); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern IntPtr daveNewConnection( IntPtr di, int mpi, int rack, int slot); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern int daveSetTimeout(IntPtr di, int timeoutMicroseconds); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern int daveConnectPLC(IntPtr dc); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern int daveReadBytes( IntPtr dc, int area, int db, int start, int len, byte[] buffer); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern int daveDisconnectPLC(IntPtr dc); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] public static extern int daveFree(IntPtr p); }

声明里有三处容易写错,逐条说。

第一是调用约定。源码是纯 C 写的,函数没有__stdcall修饰,默认就是 cdecl。C# 里如果不显式写CallingConvention.Cdecl,默认走 Winapi 也就是 StdCall,调用时栈的清理方不对,症状不是报错而是进程直接崩或者调用返回后数据错乱,这种问题在调试器里极难定位。

第二是字符串。name参数按 ANSI 字符串传,用LPStr。顺带说一个很多人卡很久的点:走以太网的时候,这个参数不是随便起的接口名,它会被当成目标地址拿去做域名解析,所以这里要填 PLC 的 IP,比如"192.168.0.10"。照抄示例写成"IF1",现象就是连接一直超时,看起来像网络不通,其实是根本没连对地方。

第三是指针。daveNewInterface和daveNewConnection返回的是结构体指针,用IntPtr接,不要试图用int或者long,32 位和 64 位下长度不一样,用错类型在 64 位进程里会截断。

3.3 连接、读块、断开的完整顺序

IntPtr di = IntPtr.Zero; IntPtr dc = IntPtr.Zero; try { // 第一个参数传 IntPtr.Zero,对应头文件里的 NULL 占位 di = LibNoDave.daveNewInterface( IntPtr.Zero, "192.168.0.10", 0, LibNoDave.daveProtoISOTCP, LibNoDave.daveSpeed187k); if (di == IntPtr.Zero) throw new Exception("接口创建失败"); // 单位是微秒,5000000 就是 5 秒,现场抖动大可以调到 10 秒 LibNoDave.daveSetTimeout(di, 5000000); // ISO 模式下 mpi 参数无效填 0;rack/slot 常见 0/2 dc = LibNoDave.daveNewConnection(di, 0, 0, 2); if (dc == IntPtr.Zero) throw new Exception("连接对象创建失败"); int rc = LibNoDave.daveConnectPLC(dc); if (rc != 0) throw new Exception($"连接 PLC 失败,返回码 {rc}"); byte[] buf = new byte[4]; rc = LibNoDave.daveReadBytes(dc, LibNoDave.daveDB, 1, 0, 4, buf); if (rc != 0) throw new Exception($"读 DB1.DBD0 失败,返回码 {rc}"); // S7 是大端,x86 是小端,REAL 必须先反转字节再转换 Array.Reverse(buf); float value = BitConverter.ToSingle(buf, 0); Console.WriteLine($"DB1.DBD0 = {value}"); } finally { if (dc != IntPtr.Zero) { LibNoDave.daveDisconnectPLC(dc); LibNoDave.daveFree(dc); } if (di != IntPtr.Zero) { LibNoDave.daveFree(di); } }

参数含义逐个过一遍。daveReadBytes的area是区域码,daveDB表示数据块,daveFlags是 M 区,daveInputs是 I 区,daveOutputs是 Q 区。db参数只在读 DB 区时有意义,传块号,读 M 区或者 I 区时传 0。start是块内偏移字节数,len是读多少字节,buffer要事先按len分配好,库不做分配。

错误码的判断很简单,0就是成功,非 0 一律按nodave.h里daveRes开头的宏对照。我一般不把那些宏全部翻成 C# 常量,太多了,维护成本高,直接打印数值然后回头头文件查,比抄错一个宏名强。

3.4 断线重连和资源释放

现场不是实验室,网线会松、交换机会重启、PLC 会被断电。一个能上线的封装必须处理重连,而重连代码最容易犯的错是每次重连都新建一对daveInterface和daveConnection却不释放。

我的做法是把整个会话封在一个类里,连接状态用一个字段标,读失败后把两个指针daveFree掉置空,等下一次读的时候按退避间隔重建。退避间隔从 1 秒开始翻倍,封顶 30 秒,别用固定 100 毫秒死循环重连,PLC 那边会把你当成异常流量。释放顺序也要注意,先daveFree(dc)再daveFree(di),反过来的话dc内部还引用着已被释放的接口,某些实现下会直接崩。

4. LABVIEW 侧接进来:调用库函数节点的参数怎么填才不崩

LABVIEW 这一侧比 C# 麻烦,不是说难,是它的调用节点对类型的容忍度低,填错一个下拉框就整个 VI 崩掉。

4.1 32 位是硬约束,先确认再动手

打开 LABVIEW 的关于对话框,确认是 32 位还是 64 位。这一条必须先确认,因为 LABVIEW 是 32 位进程的时候,它只能加载 32 位的 DLL,反过来也一样,加载失败不会给你什么有用的提示,只弹一个调用库函数节点的错误框,说找不到指定的模块。

确认方法有两种。一是看关于对话框里的版本信息,二是编完 DLL 之后反查:

dumpbin /headers libnodave.dll | findstr machine

输出里如果是x86,就是 32 位;如果是x64,就是 64 位。前面 2.3 节的编译命令用的是默认的 32 位目标,一般正好能对上 32 位的 LABVIEW。如果你的 LABVIEW 是 64 位,编译时先用开发者命令行里的 x64 版本切换环境再编一次,别想着让 32 位 DLL 在 64 位进程里跑。

另外提醒一句,老版本 LABVIEW 装完之后,系统里可能同时存在两个版本,快捷方式指向的那个不一定是你在用的那个。如果 DLL 明明编对了却一直报加载失败,先确认进程位数,这一步能省掉一整天。

4.2 调用库函数节点的参数怎么映射

在程序框图里放一个调用库函数节点,双击配置。库名填libnodave.dll的完整路径,函数名从下拉里选,调用约定选C。这一步选错,和 C# 里写错 CallingConvention 是一样的后果,程序不是报错而是卡住。

参数类型对照表我整理成这样,照着填基本不会错:

函数参数头文件类型节点里选什么我填的值
函数返回值指针Pointer-sized Unsigned Integer接出来存到移位寄存器
iface(首参)指针Pointer-sized Unsigned Integer0
namechar*C String PointerPLC 的 IP 字符串
localMPIintNumeric,Signed 32-bit0
protocol枚举Numeric,Signed 32-bit5(ISO over TCP)
speed枚举Numeric,Signed 32-bit0
buffer(读取时)void*Array Data Pointer一维 U8 数组

返回值这一栏特别说明:一定要选 Pointer-sized 的整数,不要图省事选 I32。在 64 位环境里指针是 8 字节,选 I32 会被截断成低 32 位,接出来的句柄看着有个数,实际传回去就是野指针,现象是第一次读就崩。32 位环境下两者长度碰巧一样,所以这种错在 32 位机上测不出来,换台机器就露馅。

buffer参数要选数组数据指针,然后把数组元素类型设成 U8,维度 1。数组要在节点前面先用初始化数组函数分配好长度,比如要读 4 个字节就先初始化一个长度为 4 的 U8 数组。库不会替你分配内存,传一个空数组进去,写的时候就是越界。

4.3 更稳的做法:加一层薄壳

上面这套直接映射在简单场景下能用,但我更推荐给 LABVIEW 单独包一层。原因有三个:一是 LABVIEW 里传指针、传数组的配置项太多,一个项目里三个人配出三种写法,出了问题互相看不懂;二是把连接生命周期收进 C 层之后,LABVIEW 只负责调open、read、write、close四个函数,参数全是标量和字节数组,几乎没有配置项可填错;三是 C# 那边也能复用同一套语义,两边行为一致。

薄壳本身很短:

// plc_bridge.c —— 给 LABVIEW 和 C# 共用的薄壳,只导出标量和字节数组参数 #include <string.h> #include "nodave.h" static daveInterface *g_di = 0; static daveConnection *g_dc = 0; // 返回 0 成功,非 0 直接透传 libnodave 的错误码 int plc_open(const char *ip, int rack, int slot, int timeoutUs) { if (g_dc) return -100; // 已经打开,防止重复连接 g_di = daveNewInterface(0, (char *)ip, 0, daveProtoISOTCP, daveSpeed187k); if (!g_di) return -101; daveSetTimeout(g_di, timeoutUs); g_dc = daveNewConnection(g_di, 0, rack, slot); if (!g_dc) { daveFree(g_di); g_di = 0; return -102; } int rc = daveConnectPLC(g_dc); if (rc != 0) { plc_close(); return rc; } // 失败就把自己清理干净 return 0; } // 读一块数据,area 用 <p> <a href="https://download.csdn.net/download/zmy3090/19443909" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进