简介:libnodave-0.8.5.zip 是面向工业自动化开发者与PLC教学研究者的开源通信库压缩包,聚焦西门子S7系列PLC(如S7-300/400)的数据交互,可在LABVIEW与C#等环境中集成调用,解决跨平台、跨语言访问PLC的接口需求。包内共120个文件,约1.71MB,以C源码与头文件为核心,辅以HTML文档、Windows/Linux下的编译脚本、可执行测试程序、C#示例及动态库文件,覆盖从协议实现到调用验证的完整素材。已有340人学习下载,说明其在PLC通信学习圈具有一定参考价值。读者可从中获得libnodave的源码、头文件、库文件与说明文档,理解S7协议读写、周期采集、状态监控与故障诊断的实现思路;LABVIEW用户可借助外部函数节点或DLL调用,C#开发者可通过DllImport接入,并依据编译脚本与测试程序完成环境验证和二次扩展。
1. libnodave 0.8.5 拆包:一个 C 写的 S7 通信库,为什么被 LabVIEW 和 C# 项目反复翻出来
产线要改造,旧上位机跑在一台 XP 工控机上,界面是别人十年前写的,源码丢了,只剩下一台还在跑的老 PLC。新方案要用 C# 做界面、用 LabVIEW 做采集面板,两套程序都要读同一批 DB 块。这种局面下,绕开中间件、直接跟 PLC 讲协议的方案里,libnodave 出现的频率很高。0.8.5 是流传较广的一个打包版本,一套纯 C 源码加几个编译好的库,放在一个 zip 里,解压即用。它解决的事情很具体:让你的程序在 TCP 102 端口上跟 S7 系列 CPU 握手,按字节读写 DB、M、I、Q 区,不依赖任何厂商运行库,也不吃授权。适合手里有老 PLC、需要自己写上位机、又不想被组态软件绑死的人。不适合指望开箱即用、碰到问题就找厂商的人——这库最后更新停在很久以前,报错信息全靠自己翻译。
2. 三条链路选型:ISO-on-TCP、MPI 与 PPI 在 0.8.5 里的能力边界
打开压缩包前先想清楚一件事:你要以什么方式碰到 PLC 的 CPU。libnodave 把这件事抽象成daveInterface,不同的协议用不同的常量传进去,选错了不是性能问题,是连都连不上。这一步定死,后面的代码才有意义。
2.1 三种协议各自能干什么、不能干什么
库里可用的协议常量不多,实际项目里能落地的就三个:
| 协议 | 常量 | 物理链路 | 典型对象 | 主要限制 |
|---|---|---|---|---|
| ISO-on-TCP | daveProtoISOTCP | 以太网,TCP 102 | 带网口的 CPU、CP 模块 | 依赖 PUT/GET 权限,块必须非优化 |
| MPI | daveProtoMPI/daveProtoMPI2 | PC Adapter(USB/串口)或 PCI 卡 | 老 S7-300/400 | 速率低,适配器驱动挑系统 |
| PPI | daveProtoPPI | RS485 转串口 | 200 系列小机 | 站地址与波特率必须对上 |
选型逻辑其实很朴素:现场只要有网口,一律走 ISO-on-TCP。它的代码路径最干净,一个openSocket(102, ip)就把句柄拿到了,后面全是同一套读写 API。MPI 是没办法时的办法,PC Adapter 在不同系统上的驱动表现差异很大,经常出现同一根线在 A 机器上通、B 机器上不通的玄学现象,而且速度受限于适配器。PPI 现在基本只在维护老设备时碰,波特率用daveSpeed187k、daveSpeed9600这类常量指定,站地址填在daveNewConnection的 MPI 参数位上。
有一点必须提前说清:ISO-on-TCP 这条路在库的层面只认字节偏移,它不理解符号名。你写DB1.DBD0它懂,你写"Motor_Speed"它不懂。所以选这条路的代价是,PLC 侧所有要读的块必须关掉“优化的块访问”,并且你要自己维护一份点位偏移表。这个代价换来的好处是不依赖任何 OPC 或中间服务,通信链路短,出问题容易定位。
2.2 包内文件构成与编译方式
解压后看到的东西大致分三类:一是源码,nodave.c加两个头文件nodave.h、daveAPI.h;二是示例,simpleClient.c、testISO_TCP.c这类单文件程序,能直接拿来改;三是构建脚本和预编译产物。真正要引用到你自己项目里的只有前面那两个头文件和一份编译出来的库。
Windows 下用 MinGW 编出 DLL,命令不复杂:
# 在源码目录下执行,生成 64 位 nodave.dll 和导入库 gcc -shared -o nodave.dll nodave.c -DWIN32 -lws2_32 -O2 # 需要静态库时 gcc -c nodave.c -DWIN32 -O2 -o nodave.o ar rcs libnodave.a nodave.o-DWIN32决定头文件里展开的是 Winsock 那套 socket 定义而不是 POSIX 的,-lws2_32是链接 Winsock 库,少了它会在openSocket处报未定义符号。用 MSVC 的话换成cl /LD /DWIN32 nodave.c ws2_32.lib。
Linux 侧的构建走 autotools 那一套:
./configure --prefix=/usr/local make sudo make install # 产物是 libnodave.a 和 libnodave.so,头文件装到 /usr/local/include编译完先别急着往业务代码里塞,用包里的testISO_TCP跑一遍连通性,这一步能挡掉后面一大半误判。参数一般是这样传的:./testISO_TCP <ip> <rack> <slot>,其中 rack 填 0,slot 对 300/400 系列通常填 2,对 1200/1500 通常填 1。跑通之后它会打印一小段 DB 区内容,能打出来就说明协议层没问题,剩下的都是你的代码问题。
2.3 连接参数:rack、slot 与超时单位
daveNewConnection(di, mpi, rack, slot)的前三个参数在不同协议下含义完全不同。走 ISO-on-TCP 时,第一个参数被忽略,真正起作用的是 rack 和 slot,库会用它拼出 TSAP 地址(连接端点标识)。这也是新手最容易翻车的地方——同一台设备,slot 填 1 和填 2 都可能表现为“连接超时”,报错信息一模一样,看不出区别。
超时的坑更隐蔽。daveSetTimeout(di, t)的单位是微秒,不是毫秒。写成daveSetTimeout(di, 3000)想表达 3 秒,实际给的是 3 毫秒,结果就是每次通信都在协议握手的半路上被自己掐断,报错五花八门。稳妥的写法是传3000 * 1000,或者在包装层里统一把毫秒乘 1000 再传进去。
daveInterface *di; _daveOSserialType fds; fds.rfd = openSocket(102, "192.168.0.10"); fds.wfd = fds.rfd; if (fds.rfd < 0) { /* 端口不通,先查网线和防火墙 */ } di = daveNewInterface(fds, "IF1", 0, daveProtoISOTCP, daveSpeed187k); di->timeout = 3000 * 1000; /* 3000 毫秒,注意单位是微秒 */daveSpeed187k在以太网协议下只是个占位,不影响实际速率,但不要省掉,某些版本对参数范围有检查。真想让通信速率变快,办法不是调这个值,而是减少调用次数——一次读 64 字节比读 16 次 4 字节快得多。
3. C# 侧落地:给 daveAPI 套一层薄封装,把句柄交给托管代码
直接把daveAPI.h里的函数全量 P/Invoke 到 C#,是很多人第一反应,然后会在_daveOSserialType这个结构体和daveInterface*这类不透明指针上卡住。更省事的做法是在中间加一层十几行的 C 代码,把复杂结构封在自己这边,对外只暴露int和unsigned char*。这层壳编译出来的 DLL 很小,但它把托管代码和老 C 库之间的耦合面降到了最低。
3.1 桥接层的四个导出函数
桥接层的设计原则是:所有句柄留在 C 侧当全局变量,C# 只传业务参数。这样 C# 端看不到任何指针,也不需要考虑结构体对齐和平台位宽。
/* s7bridge.h */ #ifndef S7BRIDGE_H #define S7BRIDGE_H #ifdef _WIN32 #define S7API __declspec(dllexport) #else #define S7API #endif S7API int s7_open(const char *ip, int rack, int slot, int timeout_ms); S7API int s7_close(void); S7API int s7_read_bytes(int area, int db, int start, int len, unsigned char *buf); S7API int s7_write_bytes(int area, int db, int start, int len, const unsigned char *buf); S7API const char *s7_err_text(int code); #endif/* s7bridge.c */ #include "nodave.h" #include "s7bridge.h" static daveInterface *g_di = 0; static daveConnection *g_dc = 0; int s7_open(const char *ip, int rack, int slot, int timeout_ms) { _daveOSserialType fds; int res; s7_close(); /* 幂等:重复调用不会泄漏 */ fds.rfd = openSocket(102, (char *)ip); fds.wfd = fds.rfd; if (fds.rfd < 0) return -1001; /* 端口不可达 */ g_di = daveNewInterface(fds, "IF1", 0, daveProtoISOTCP, daveSpeed187k); if (!g_di) return -1002; daveSetTimeout(g_di, timeout_ms * 1000); /* 毫秒转微秒 */ g_dc = daveNewConnection(g_di, 0, rack, slot); if (!g_dc) return -1003; res = daveConnectPLC(g_dc); /* 真正发起 ISO 握手 */ return (res == 0) ? 0 : res; } int s7_read_bytes(int area, int db, int start, int len, unsigned char *buf) { if (!g_dc || !buf || len <= 0) return -1004; return daveReadBytes(g_dc, area, db, start, len, buf); } int s7_close(void) { if (g_dc) { daveDisconnectPLC(g_dc); daveFree(g_dc); g_dc = 0; } if (g_di) { daveDisconnectAdapter(g_di); daveFree(g_di); g_di = 0; } return 0; }area这个参数不要用字符串传,直接用库里的数值常量更快也更不容易错:daveDB是 132,daveFlags(M 区)是 131,daveInputs是 129,daveOutputs是 130。C# 端把这些定义成const int,代码可读性不受影响。s7_close里必须先断开连接再释放接口,顺序反了会留下半开的 socket,短时间内反复重连会被 PLC 侧拒绝。
编译的时候把桥接层和nodave.c一起链接,得到一个自包含的 DLL:
gcc -shared -o s7bridge.dll s7bridge.c nodave.c -DWIN32 -lws2_32 -O2如果 LabVIEW 是 64 位的,这里必须用 MinGW-w64 编 64 位;如果 C# 是 AnyCPU 跑在 64 位进程里,同理。32/64 混着来是后面第 5 章要单独说的一条坑。
3.2 C# 侧的 P/Invoke 声明与封装类
调用约定统一用CallingConvention.Cdecl。libnodave 及其桥接层都是 C 默认的 cdecl,写成StdCall在 x86 下会直接栈失衡崩溃,x64 下反而不报错,这种“只在一部分机器上崩”的问题最难查。
internal static class S7Native { private const string Dll = "s7bridge.dll"; [DllImport(Dll, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] internal static extern int s7_open(string ip, int rack, int slot, int timeoutMs); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern int s7_read_bytes(int area, int db, int start, int len, byte[] buf); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern int s7_close(); [DllImport(Dll, CallingConvention = CallingConvention.Cdecl)] internal static extern IntPtr s7_err_text(int code); }s7_err_text返回的是 C 侧静态字符数组的首地址,用IntPtr接住,再交给Marshal.PtrToStringAnsi转成字符串,不要声明成string直接返回——虽然多数情况下也能工作,但封装层一旦换了分配方式就会读到野指针。
业务侧的封装做成一次性连接、多次读取。连接放在程序启动时做,读放在定时器里做,异常时整体重建:
public sealed class S7Client : IDisposable { public const int AreaDB = 132, AreaM = 131, AreaI = 129, AreaQ = 130; public void Connect(string ip, int rack = 0, int slot = 1, int timeoutMs = 3000) { int rc = S7Native.s7_open(ip, rack, slot, timeoutMs); if (rc != 0) throw new IOException($"连接 {ip} 失败,code={rc},{ErrorText(rc)}"); } public byte[] ReadDb(int db, int start, int len) { var buf = new byte[len]; int rc = S7Native.s7_read_bytes(AreaDB, db, start, len, buf); if (rc != 0) throw new IOException($"读 DB{db}.DBB{start}/{len} 失败,code={rc},{ErrorText(rc)}"); return buf; } public static string ErrorText(int code) => Marshal.PtrToStringAnsi(S7Native.s7_err_text(code)) ?? "unknown"; public void Dispose() => S7Native.s7_close(); }这段代码里最容易忽略的是byte[] buf的生命周期。P/Invoke 默认会对数组做一次封送拷贝,函数返回后 C 侧写入的内容会回写到托管数组,这一点是靠谱的;但如果把数组改成ref byte传首元素地址,就要自己加fixed固定内存,否则 GC 移动对象时写进去的是垃圾。
3.3 字节序与数据类型解析
S7 的数据是大端存放,而 x86/x64 上的BitConverter是小端。直接把 4 个字节丢给BitConverter.ToSingle,得到的是完全无意义的数,而且它不会报错,只是悄悄错着。这种错误最典型的症状是:整数看着像天文数字,浮点数看着像随机值,有时候还恰好能落在合理区间内,让你误以为没问题。
// 安全做法:手工换序后解析,不依赖平台 public static float ToReal(byte[] b, int off) { Span<byte> t = stackalloc byte[4]; t[0] = b[off + 3]; t[1] = b[off + 2]; t[2] = b[off + 1]; t[3] = b[off]; return BitConverter.ToSingle(t); } public static short ToInt16(byte[] b, int off) => (short)((b[off] << 8) | b[off + 1]); // .NET 5 及以上可以直接用: // BinaryPrimitives.ReadSingleBigEndian(b.AsSpan(off));错误码也建议做一张小表贴在手边。0是成功;-1025、-1024这类负数多与连接或超时相关,通常是 rack/slot 不对或者网线不通;daveResInvalidArea(值 5)表示区域或 DB 号不对;daveResInvalidPlcAnswer(值 5 之外的另一个分支)多半是 PLC 侧返回了拒绝应答。与其猜,不如在调试阶段打开daveSetDebug(4),它会把每一帧收发都打到控制台,看一眼请求和应答的差异,比自己对着手册猜半小时有效。
4. LabVIEW 侧落地:Call Library Function Node 的四个参数配置与轮询 VI 结构
同一套桥接 DLL,LabVIEW 里也能直接用,而且比 C# 更省事——因为桥接层已经把指针吃掉了,LabVIEW 只需要处理int、字符串和字节数组。真正需要调的配置只有四处,配错任何一处都会在运行时报 1097 或者直接崩掉 LabVIEW 进程。
4.1 Call Library Function Node 的参数映射
在程序框图里放一个 Call Library Function Node,路径指向s7bridge.dll,函数名填s7_open。参数页按下面这张表配:
| 参数名 | C 类型 | LabVIEW 侧配置 | 注意事项 |
|---|---|---|---|
| ip | const char * | String,格式选 C String Pointer | 不要选 Pascal String |
| rack | int | Numeric → Signed 32-bit Integer,Value | 传值 |
| slot | int | 同上 | 1200/1500 常填 1 |
| timeout_ms | int | 同上 | 单位是毫秒 |
| 返回值 | int | Return Type 设 Signed 32-bit Integer | 0 为成功 |
字符串这一项是新手最容易配错的。LabVIEW 的 String 控件默认是句柄结构,CLFN 里必须显式选成C String Pointer,让它在调用时把句柄转成临时缓冲区的首地址。如果选了 “String Handle”,传过去的是指针的指针,C 侧读到的就是垃圾地址,轻则返回连接失败,重则直接访问冲突。
slot参数建议在界面上做成可选的枚举(1 / 2 / 3),而不是写死。现场调试时三种值挨个试一遍的成本很低,比回来改代码重编快得多。
4.2 读字节函数的数组传参
s7_read_bytes的第五个参数是输出缓冲区,在 CLFN 里这样配:Parameter 类型选 Array,Data Type 选 U8,Array Format 选 Array Data Pointer,Dimensions 填 1,Minimum Size 填一个不小于最大读取长度的值(比如 256)。Array Format 千万别选 Handle 或 “Array Data Pointer” 之外的选项,Handle 模式下传过去的是 LabVIEW 的数组句柄结构,不是数据首地址。
VI 结构大致分三段:
第一段,初始化。放一个顺序结构或者在循环外,先调s7_open,把返回码取出来判断,非 0 就用s7_err_text(返回值配成 String Return,返回类型C String Pointer)转成可读信息,弹窗报错并停止。这一段只需要跑一次。
第二段,循环。用 While 循环加Wait (ms),间隔建议 100 到 200 毫秒。循环里调s7_read_bytes,把返回的 U8 数组送进后续解析。如果一次要读多个不相邻的地址,宁可多调几次,也不要为省一次调用把整个 DB 块从头读到尾——后者在 DB 很大时会明显拖慢通信,PLC 侧的处理时间随长度增长。
第三段,收尾。循环退出后调s7_close。这一步经常被忘掉,尤其在调试阶段直接点“中止执行”的时候。后果是 PLC 侧还挂着一条 ISO 连接,等你下次运行时它还没超时释放,于是第一次连接必然失败,第二次才成功——表现为“程序有时能连上有时连不上”,很折磨人。
4.3 大端数据在 LabVIEW 里怎么转
拿到 U8 数组之后,取子数组再转成数值。LabVIEW 的“强制类型转换”(Type Cast)节点自带字节序选项,输入端的 byte order 参数分别是 0(big-endian)、1(native)、2(little-endian)。所以不需要手工反序数组,直接把 byte order 接 0 就行。
步骤是这样:先用“数组子集”从 U8 数组里取出从偏移 off 开始的 4 个元素,接进 Type Cast 的输入端,Type 端接一个 SGL 常量,byte order 端接常量 0,输出就是正确的 REAL 值。转 INT32 同理,把 Type 换成 I32。转 BOOL 不能走这条路——单个位要从字节里剥出来,用“布尔数组至数值转换”或者直接对字节做位运算,取第 n 位就(byte >> n) & 1,LabVIEW 里对应“与”加“移位”两个节点。
有个细节容易被忽略:如果 PLC 侧定义的是 DINT 而你在 LabVIEW 里按 SGL 解析,得到的是一个 IEEE754 解释出来的怪数,不是报错。所以偏移表里除了地址,一定要写清数据类型,别只写“DB1.DBD0 是速度”。
5. 避坑与排查:五条按现象反查的现场记录
这一节的东西不是从手册上抄的,是调不通的时候一条条试出来的。每条都按“看到什么—为什么—怎么办”组织,方便你对着现场现象直接翻。
5.1 连接阶段的三个现象
现象一:s7_open一直返回非 0,换成 slot=2 也不行,网线拔了报错也一样。原因多半不在 PLC,而在本机。ISO-on-TCP 用的是 102 端口,某些安全软件会把它当高危端口拦掉,或者上一次异常退出留下的 socket 还占着本地端口没释放。解决方法是先在命令行确认端口可达:telnet <ip> 102,或者用 PowerShell 的Test-NetConnection -Port 102。连不上就查防火墙和 PLC 侧的连接资源数(同时连接数有上限,旧连接不释放会占满)。连得上但库还报错,就是 rack/slot 组合不对,把 1、2、3 挨个试,同时确认 PLC 侧允许来自外部设备的 PUT/GET 访问。
现象二:能连上,但第一次读就报错,重试一次又好了。这是典型的连接没有干净关闭。上一次程序异常退出,PLC 侧那条 ISO 连接还在超时计时里,此时新建连接会被拒绝或半途丢弃。解决办法有两个方向:一是进程退出路径上加s7_close,包括异常分支和AppDomain.ProcessExit;二是在s7_open失败时加一次 500 毫秒的重试,很多现场加上这一次重试就再没报过。我更倾向两个都做,重试是兜底,正常关闭是根本。
现象三:LabVIEW 里点运行就崩,或者提示错误 1097“找不到该库的某个入口点”。九成是位数不匹配:64 位 LabVIEW 去加载 32 位 DLL。检查方法很直接,看 DLL 所在目录和 LabVIEW 安装目录的位数是否一致。另外一小部分是函数名大小写或者导出符号修饰问题——MinGW 编出来的 DLL 导出名通常干净,MSVC 在 x86 下可能带下划线前缀,用dumpbin /exports看一眼实际导出名,按实际名字填。
5.2 读数与部署阶段的两个现象
现象四:读回来的数看着像天文数字,或者 12.5 变成了 1.1e-41。字节序没转。这个问题之所以难发现,是因为它从来不报错,只是安静地给出错误结果。S7 是大端,BitConverter是小端,四个字节反着解释出来的浮点数就落在那个诡异的量级区间。判断方法很省事:在 PLC 监控表里把目标变量强制成 1.0 或者 1234.5,再看上位机读到什么,如果是一串没规律的小数,基本可以确诊。改成手工换序或者用大端读取 API 即可。
现象五:白天跑得好好的,晚上回来整套程序卡死,任务管理器里内存一直涨。这通常不是库本身泄漏,而是调用方式有问题:每次轮询都重新daveNewInterface+daveNewConnection,用完不释放daveFree。连接对象里带着 socket 和缓冲区,一天几万次调用能把句柄耗光。正确姿势是连接只建一次、所有读取复用同一个daveConnection,只有在连续失败若干次之后才整体重建一次。另外别在多个线程里同时用同一个daveConnection,这个库的连接对象不是线程安全的,读写交叉会出现应答错位——症状是读回的数据偶尔错位一个字节,非常像“PLC 数据自己在跳”。
6. 进阶验证:把“读到的数是对的”这件事钉死
库能连上、能返回 0,只说明协议通了,不说明数据对了。我见过太多项目在联调最后一天才发现某个 REAL 的偏移算错了四个字节,前面几周的曲线全是错的。
最有效的办法是做一次闭环对照。在 PLC 的监控表里,把所有要读的点位手动改成一个有辨识度的值:浮点给 1234.5,整数给 4321,位变量依次置 1 再置 0。然后跑一遍上位机,逐点在表格里核对。这套动作十分钟能做完,能把偏移、类型、字节序三类错误一次性暴露干净。比在代码里加日志、逐帧对比报文要快得多。
第二层验证是写入回读。选一块平时不用的 M 区,比如 MB100 到 MB107,写进去一个已知序列,再读回来比对:
byte[] send = { 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0 }; S7Native.s7_write_bytes(S7Client.AreaM, 0, 100, send.Length, send); byte[] back = new byte[send.Length]; S7Native.s7_read_bytes(S7Client.AreaM, 0, 100, back.Length, back); for (int i = 0; i < send.Length; i++) if (back[i] != send[i]) throw new Exception($"M{100 + i} 回读不一致:写 {send[i]:X2} 读 {back[i]:X2}");这段代码的价值在于它同时验证了写通道和读通道。如果写进去读回来一致,说明链路、偏移、长度全对;如果不一致但两者都有值,多半是偏移重叠或者长度超界,把写入长度改小再试就能定位。
第三层是打开调试输出看报文。在s7_open之后调一次daveSetDebug(4),库会把每一帧的十六进制收发打到标准输出。抓一段下来,对着协议格式看请求里的区域码和字节偏移,和你的点位表逐项比。这一步有点笨,但它是唯一能在“连得上、读得到、数不对”这种死局里给出确定答案的手段。我在一个项目里靠它发现过偏移表整体错位两个字节的问题——原因是维护点位表的人把起始地址按字(Word)算了,而库里按字节算。
还有一处值得单独验证:数据类型宽度和 PLC 侧声明必须严格一致。S7 里DInt占 4 字节、Int占 2 字节、Real占 4 字节,如果点位表里把某个Int当DInt读,会连带把后面两个字节的值吃进来,读出来的数看着像“被放大了 65536 倍”,实际是拼错了字节。做一个小的自检函数,把所有点位的偏移和长度列出来,检查相邻点位有没有重叠,比事后排查省事得多。
最后说一个习惯。从那以后我每次上新点位,都先把 PLC 侧的值在监控表里改成 1234.5,再对着上位机的显示值看一遍,然后才允许进联调。这个动作看着多余,但它从来没白做过——它救回来的时间,远比我花在它上面的多。希望帮到你。
本文还有配套的精品资源,点击获取