简介:libnodave-0.8.4 是一套面向西门子 S7 系列 PLC 的开源通信库,目标用户是需要在 Delphi、Visual Basic、Visual C++ 等语言环境中实现上位机与 S7-200/300/400 之间数据交换的工业自动化开发者。资源包内含 334 个文件、约 3.02MB,主体为 C 源码、Delphi 单元(.pas)、VB 模块(.bas)、头文件(.h),同时提供编译脚本、可执行示例(.exe)以及动态链接库(.dll),并配有 HTML 帮助文档和 readme 说明,可覆盖从源码阅读、库编译、链接配置到实际调用的完整流程。测试程序中包含针对 ISO_TCP、PPI、MPI 等常见通信方式的示例,便于验证网络配置与协议细节。已有 174 人学习浏览,适合具备一定 PLC 通信基础的工程师参考。通过该资源,读者可获得 PDO 通信模型实现、读写输入输出、诊断与块操作等核心功能的代码范例,并能在 Windows/Linux 下快速搭建测试环境,为设备互联、产线数据采集与监控系统开发提供可复用的底层通信方案。
1. libnodave 不是黑匣子:Delphi 上位机为什么值得用它
libnodave 是一个用纯 C 写的、与 S7 系列 PLC 直接对话的通信库。它不需要厂商私有授权,也不需要专用通信硬件,只要电脑有一个网口,就能走 TCP 触达 PLC 的内存区。不少上位机项目一提“与 PLC 通信”,第一反应是厂商 SDK 或工控组态软件,但那通常意味着额外授权费和学习成本。libnodave 的思路很简单:建立 Socket 连接,按 S7 协议把读写请求发过去,再解析响应。对 Delphi 开发者来说,它不绑定任何 UI 框架,编译成动态链接库后,从 Delphi 7 到 Delphi 11 都能直接引用。读这篇文章,你不需要懂底层协议的全部细节,只要会基础 Socket 概念、能操作 Delphi 的单元引用,就能在一天内把第一行 PLC 数据读回来。
2. libnodave 的底层机制:S7 协议与 C 库的 Delphi 桥接
libnodave 的核心价值在于它替你封装了 S7 通信协议中繁琐的报文组帧、校验和解析逻辑。它既不是一个完整的配置工具,也不是一个跨平台运行时,它就只做一个事:把 PLC 的内存区变成你本地程序可以读写的一块缓冲区。源码本身用 C 写成,代码量不大,依赖面非常窄,在 Windows 上只需要 Winsock,在 Linux 上只需要 BSD Socket。正因为依赖少,它非常适合被编译成动态链接库供 Delphi 调用。
2.1 C 库与 Delphi 绑定的三种方式
第一种方式是把 C 头文件人工翻译成 Delphi 单元。历史上有人这样做过,也发布过不错的结果,但风险在于翻译版本往往滞后于 C 源码的维护节奏。你在 0.8.4 的 C 源码里能看到一些结构体字段和常量定义,翻译单元不一定同步更新,一旦字段偏移对不上,查起来非常费劲。
第二种方式是把 libnodave 源码编译成 DLL,然后在 Delphi 里通过 external 关键字声明导入函数。这个方式是我最常用的。DLL 的更新只涉及替换一个文件,不影响 Delphi 侧任何代码;而且在现场排查问题时,可以从 DLL 的版本入手确认库的逻辑是否一致。缺点是需要自己维护一版导入函数的声明,但凭心而论,libnodave 的函数数量并不多,核心函数不超过二十个。
第三种方式是把 C 源码直接加到 Delphi 的编译工程里做混合编译。这种方式对工具链的要求比较高,一般要用到 C++Builder 或者自定义的 Makefile,把 C 编译成目标文件再链接到 Delphi 工程中。听起来最彻底,但工程的构建复杂度大幅上升,一旦换了开发环境的版本,整个编译链需要重新验证。我一般不建议普通上位机项目采用它。
三种方式各有适用场景,下面的表格可以帮你快速做选型判断。
| 绑定方式 | 优点 | 缺点 |
|---|---|---|
| 人工翻译头文件 | 调用自然,不需要额外 DLL | 版本滞后,适配成本高 |
| 编译 DLL 再声明导入 | 更新简单,部署可控 | 需要维护一份导入声明 |
| 混合编译进工程 | 不需要分发 DLL 文件 | 构建链复杂,环境依赖强 |
2.2 编译 DLL 并搭最小 Delphi 工程
编译 DLL 的常见做法是在 Windows 上装一套 MinGW-w64,然后用 gcc 一次性把源码编译成动态链接库。命令大致如下:
bash cd libnodave-0.8.4 gcc -shared -o libnodave.dll -I. *.c -lws2_32 -O2
编译完成以后,目录下会出现 libnodave.dll。这条命令里,-shared 告诉编译器生成动态链接库,-I. 表示当前目录作为头文件搜索路径,*.c 把源码包里所有 C 文件一起参与编译,-lws2_32 链接 Windows 的 Socket 库。O2 优化级别对通信库是安全的,不改变协议行为,只降低报文解析的开销。
注意:在 64 位操作系统上,默认的 MinGW-w64 会生成 64 位 DLL。Delphi 工程如果是 32 位的,需要确认编译目标架构匹配,否则加载 DLL 时会报“模块不兼容”的错误。如果你身边只有 64 位环境,可以加 -m32 参数强制编译 32 位版本,前提是你的 MinGW 环境安装了 multilib 支持。
得到 DLL 之后,在 Delphi 里建一个公共单元,把要用到的函数逐一声明出来。下面这段代码是我在实际项目中会保留的最小集合:
delphi unit uLibnodave;
interface
uses SysUtils, Windows;
const daveProtoISOTCP = 0; daveProtoS7 = 1;
daveInputs = 0; daveOutputs = 1; daveFlags = 2; daveDB = 3;
type PdaveConnection = Pointer; PdaveInterface = Pointer;
function daveNewInterface(slot: Integer; protocol: Integer; port: Integer): PdaveInterface; stdcall; external 'libnodave.dll'; function daveNewConnection(iface: PdaveInterface; mpifn: Integer; rack: Integer; slot: Integer): PdaveConnection; stdcall; external 'libnodave.dll'; function daveConnectPLC(con: PdaveConnection): Integer; stdcall; external 'libnodave.dll'; function daveDisconnectPLC(con: PdaveConnection): Integer; stdcall; external 'libnodave.dll'; function daveFree(con: PdaveConnection): Integer; stdcall; external 'libnodave.dll'; function daveReadBytes(con: PdaveConnection; area: Integer; dbNum: Integer; start: Integer; len: Integer; pBuf: PByte): Integer; stdcall; external 'libnodave.dll'; function daveWriteBytes(con: PdaveConnection; area: Integer; dbNum: Integer; start: Integer; len: Integer; pBuf: PByte): Integer; stdcall; external 'libnodave.dll'; function daveReadBits(con: PdaveConnection; area: Integer; dbNum: Integer; start: Integer; aBit: Integer; pBit: PByte): Integer; stdcall; external 'libnodave.dll'; function daveWriteBits(con: PdaveConnection; area: Integer; dbNum: Integer; start: Integer; aBit: Integer; pBit: PByte): Integer; stdcall; external 'libnodave.dll'; function daveGetS32(con: PdaveConnection; area: Integer; dbNum: Integer; start: Integer): Integer; stdcall; external 'libnodave.dll';
implementation
end.
这个单元只专注于函数导入,不包含业务逻辑。把参数类型统一成 Integer 和 Pointer,是因为 libnodave 的多数函数参数在 C 侧就是 int 和指针,Delphi 的 Integer 正好与之对应。stdcall 调用约定必须与 DLL 保持一致,这一点非常重要。有人把 stdcall 误写为 cdecl 之后,程序在调用时表现得时好时坏,轻则偶尔崩溃,重则首次调用就触发内存访问冲突。
daveGetS32 的返回值在 libnodave 里是有符号 32 位整数,可以用于读取 S7 系统内的一些 CPU 诊断数据。注意这个函数内部会发起一次新的协议请求,调用频率过高时会增加 PLC 的通信负载,不建议在高速轮询中使用。
DLL 部署时,我习惯把它放在和 exe 同一个目录,而不是放到系统目录。原因很简单:便于版本管理,也便于在出现问题时做替换测试。如果程序里还用了第三方组件,DLL 的搜索路径会因为 LoadLibrary 的搜索顺序而变得不可控,exe 所在目录是最先被搜索的位置,放这里最稳。
3. libnodave 的 Delphi 调用链路:连接建立、数据区读写与批量传输
这一章是动手的核心。你要做的第一件事是搞清楚 S7 连接的那几个关键参数,然后再逐个调通读写 API。
3.1 建立 S7 连接:五个关键参数与连接流程
连接 S7 PLC 时,最常用到的五个参数是 rack、slot、clk、协议类型和端口号。下表是我在项目里惯用的默认值:
| 参数 | 典型值 | 说明 |
|---|---|---|
| rack | 0 | 机架号,S7-300 通常为 0,S7-400 可根据配置变成 1 或 2 |
| slot | 2 | CPU 槽号,S7-300 常见 2,S7-400 常见 3 |
| clk | 4 | MPI 地址,TCP 方式下固定填 4 |
| 协议 | daveProtoISOTCP | S7-300/400 常用 ISO over TCP |
| 端口 | 102 | S7 通信的标准 TCP 端口 |
这五个参数只要错一个,连接就建立不起来。尤其需要注意 rack 和 slot 不是随意填的,它们要在 PLC 的硬件组态里去查。有些项目里 PLC 是挂在扩展机架上的,rack 就可能变成 1、2 或更高的数字。现场最常见的连接失败原因之一,是工程师拿着另一台设备的参数直接套用,结果 rack 对不上。
下面是一段可以放进公共模块里的连接函数:
delphi function ConnectToPLC(const ip: string; rack, slot: Integer): PdaveConnection; var iface: PdaveInterface; conn: PdaveConnection; res: Integer; begin Result := nil;
// 创建接口时要指定协议类型和端口 iface := daveNewInterface(0, daveProtoISOTCP, 102); if iface = nil then raise Exception.Create('libnodave 接口初始化失败');
// 第二个参数是 MPI 地址,固定 4 conn := daveNewConnection(iface, 4, rack, slot); if conn = nil then begin daveFree(iface); raise Exception.Create('libnodave 连接对象创建失败'); end;
res := daveConnectPLC(conn); if res <> 0 then begin daveDisconnectPLC(conn); daveFree(conn); daveFree(iface); raise Exception.CreateFmt('连接 PLC 失败,错误码 %d', [res]); end;
Result := conn; end;
这段代码里,daveNewInterface 的第一个参数填 0,表示使用默认的通信接口;第二个和第三个参数分别是协议类型和端口号。TCP 方式下端口固定为 102,这是一个在 PLC 侧硬件组态里通常不可改的固定值。
daveNewConnection 的第一个参数是从上一步拿到的接口指针,第二个参数是 MPI 地址。我在前文提过,TCP 模式下 MPIFn 不参与实际寻址,但 libnodave 内部会把这个值写进报文的一些字段,直接填 4 是最稳妥的。第三个和第四个参数就是 rack 和 slot。
连接成功之后,返回值是一个不透明的指针。你不需要关注它内部结构,只管保存好这个指针,后续所有读写请求都需要它。连接失败时的清理顺序很重要:先断开,再释放连接对象,最后释放接口对象。顺序反了容易出现句柄泄漏,这个在后面的避坑章节会详细展开。
3.2 读写 M 区与 DB 区:核心 API 与字节序处理
连接建好之后,下一步是读 M 区。M 区也叫标志位存储区,通常存放布尔量、计数器值和中间变量。
delphi procedure ReadMFlags(conn: PdaveConnection; startByte: Integer; len: Integer); var buf: array[0..63] of Byte; res: Integer; i: Integer; begin if len > SizeOf(buf) then raise Exception.Create('len 超出缓冲区上限');
res := daveReadBytes(conn, daveFlags, 0, startByte, len, @buf[0]); if res <> 0 then raise Exception.CreateFmt('读取 M 区失败,错误码 %d', [res]);
// 逐字节处理 for i := 0 to len - 1 do begin // 上位机侧进行业务处理,比如把 buf[i] 映射到界面上 end; end;
daveReadBytes 的参数从左到右依次是:连接指针、数据区类型、DB 号、起始字节位置、读取长度、缓冲区指针。当 area 填 daveFlags 时,dbNum 参数会被忽略,填 0 即可。len 的单位是字节,不是字,也不是位。
读 DB 区的代码结构完全一样,只是 area 改为 daveDB,同时 dbNum 需要填实际的 DB 块号。下面是一个读 DB 中 REAL 类型变量的例子:
delphi function ReadDBReal(conn: PdaveConnection; dbNumber, offset: Integer): Single; var raw: array[0..3] of Byte; swapped: array[0..3] of Byte; res: Integer; begin res := daveReadBytes(conn, daveDB, dbNumber, offset, 4, @raw[0]); if res <> 0 then raise Exception.CreateFmt('读取 DB%d 失败,错误码 %d', [dbNumber, res]);
// S7 的 REAL 是 IEEE 754 大端序,必须反转字节 swapped[0] := raw[3]; swapped[1] := raw[2]; swapped[2] := raw[1]; swapped[3] := raw[0]; Result := PSingle(@swapped[0])^; end;
这个函数特别适合用在点位采集的场景里,一个 DB 里的几个 REAL 变量分别调用几次就行。注意 S7 的 REAL(浮点数)在内存中是按大端序存储的,Delphi 在 x86 平台上的浮点数则按小端序存储,所以必须做一次字节反转。反转之后用 PSingle 指针直接把四个字节解释成浮点数,这是 Delphi 里最高效的做法。
写 DB 数据时,步骤几乎一致,唯一不同的是在调用 daveWriteBytes 之前要先把数据转成对应的字节序。比如写入一个 REAL 到 DB 里:
delphi procedure WriteDBReal(conn: PdaveConnection; dbNumber, offset: Integer; value: Single); var raw: array[0..3] of Byte; src: array[0..3] of Byte; i: Integer; begin // 先把浮点数按内存原始字节序取出来 Move(value, src, 4);
// S7 的 REAL 是 big-endian,所以要整体反转 for i := 0 to 3 do raw[i] := src[3 - i];
// 写入 DB 块,失败时要检查返回码 if daveWriteBytes(conn, daveDB, dbNumber, offset, 4, @raw[0]) <> 0 then raise Exception.Create('写入 REAL 到 DB 失败'); end;
这里用 Move 把浮点数按内存字节序复制到字节数组,再做一个整体反转,最后调用 daveWriteBytes。特别注意 daveWriteBytes 的返回值,写失败往往比读失败更隐蔽,比如 PLC 侧变量被强制写入保护时不会报错,但数据不会生效。
3.3 批量读写的地址连续性与效率
很多刚接触 libnodave 的人会在循环里一个个读点位,比如读 10 个 Bool 变量就调用 10 次 daveReadBytes。这样做在数据量小时看不出问题,点位一多,报文头开销就非常可观。每次 S7 请求的报文至少 20 字节左右,再加上响应解析时间,10 次请求的开销和 1 次批量读 30 字节的开销不在一个量级。
我的一般习惯是把同一个区域、地址连续的数据合并成一次读取。下面是一个批量读取 DB 数据的封装:
delphi procedure BatchReadDB(conn: PdaveConnection; dbNumber, startOffset, totalLen: Integer; pData: PByte); var res: Integer; begin res := daveReadBytes(conn, daveDB, dbNumber, startOffset, totalLen, pData); if res <> 0 then raise Exception.CreateFmt('批量读取失败,错误码 %d', [res]); end;
调用这个函数之前,由调用方分配好缓冲区。比如要连续读 DB10 的第 0 到 39 字节,一共 40 字节,就在上层 GetMem 一块 40 字节的空间,传入指针。读取完成后,缓冲区里的数据按起始地址紧密排列,按偏移量解析即可。
批量读并不是想读多长就多长。S7 协议单次读取长度受限于请求报文里的长度字段,实测下来单次读超过 200 字节,部分 PLC 会返回错误或者分包不完整。所以如果一次要读的数据超过 200 字节,我会拆成多段读,每段控制在 180 字节以内,段与段之间留一个字节的间隔,避免报文边界计算错误。
写操作也有类似的限制和习惯。写少量数据时单次写,写多块连续变量时批量写。如果写入的变量在 DB 中的地址不是连续的,那么一次一次写更保险,因为批量写时只要有一个字节被 PLC 侧的写保护拒绝,整个返回码都会异常,排查起来反而麻烦。
4. libnodave 常见问题排查:连接超时、数据错位与内存泄漏
这一章要讲的是这几年在实际项目里遇到最多、也最让新手头疼的几类问题。每一条都是踩坑后才总结出来的处理思路。
4.1 连接超时的三处诱因
现象:daveConnectPLC 长时间不返回,最后超时退出;或者连接成功但第一次读写就失败。
先说一个容易误判的点:daveConnectPLC 返回 0 只表示 TCP 连接建立成功,不代表 PLC 上的 S7 通信服务已经准备就绪。有时候是 PLC 还在上电初始化阶段,S7 服务没有起来;有时候是集成的以太网模块只有 TCP 握手能力,没有完整的 S7 服务。所以连接成功之后第一次读写失败是正常的,要在第一次读写失败时做重连或延时重试,而不是直接报错退出。
真正导致连接超时的原因,我遇到过三种。第一,rack、slot 与 PLC 硬件组态不匹配。TCP 连接能通,但 PLC 在收到 S7 连接请求后,发现自己组态里的 CPU 槽位对不上,就会丢弃请求,客户端收不到确认报文,只能等超时。第二,PLC 侧启用了访问保护。老项目里 CPU 可能配置了节点保护或者访问级别限制,上位机的 IP 不在允许列表内,PLC 会在协议层拒绝连接。这种拒绝表现的时机不一样,有的立即返回错误码,有的直接保持静默直到超时。第三,网络上有地址冲突。现场施工时经常有第二个网段或者临时设备占用了 PLC 的 IP,上位机做 ARP 解析时找不到正确目标,连接就会进入一直重试的状态。
解决办法是先把 rack 和 slot 对照硬件组态确认无误;再用网络调试工具连接 PLC 的 102 端口看 TCP 层是否通;最后检查 PLC 的访问保护设置。这三步走完,九成以上的连接问题都能找到根源。
4.2 数据错位:字节序与位寻址的误解
现象:读回来的 INT 值比 PLC 监控画面上大了 256 倍,或者浮点数读出来是一个 -1.#INF 之类的异常值。
原因多数出在字节序没有转换。S7 协议端的 DINT、REAL 和部分 PLC 固件处理整数的方式与上位机不同。S7-300 的存储器里,INT 是小端序,DINT 是小端序,但 REAL 是大端序。如果你用同一个读取函数把 REAL 当整数解析,数值会完全对不上。
另一个高频误解是位寻址的参数填写。libnodave 的 daveReadBits 函数要求 start 参数是字节偏移,aBit 参数是 0 到 7 的位偏移。很多人会把 start 也当成位号来填,比如想读 M10.3,就把 start 填成 10*8+3=83。这样读出来的根本不是目标点位,而是别的字节的某一位,错得很隐蔽。正确写法是 start=10,aBit=3。
解决这类问题,我有个笨但有效的习惯:在程序里写一个统一的“位变量读取函数”,把字节号和位号作为两个独立的参数传入,内部再封装成 daveReadBits 的调用。这样所有调用点都显式传两个参数,就不可能再把位号混进字节号里。同时对 REAL 类型,封装一个统一的浮点转换函数,内部做字节反转。所有业务代码都走这个封装,就不会出现有的地方转了、有的地方没转的问题。
4.3 内存泄漏与句柄释放
现象:程序运行几小时后内存占用从 30 MB 涨到 300 MB,重连几次后内存涨幅更明显。
原因:libnodave 的连接对象和接口对象都是在堆上分配的,调用 daveNewInterface 和 daveNewConnection 之后,如果没有在流程结束时逐一释放,内存就会越积越多。尤其在连接失败的异常分支里,很多代码只释放了 conn 没有释放 iface,或者只释放 iface 没释放 conn,这两种情况都会泄漏。
另外,daveDisconnectPLC 没有被调用的连接,Socket 句柄不会归还给操作系统。Windows 的两次重连之间会有 TIME_WAIT 状态残留,短时间内大量重试时端口会被占满,连接质量急剧下降。
解决办法:把所有连接逻辑收敛到一个连接管理类里,类的析构函数按顺序执行:daveDisconnectPLC、daveFree(conn)、daveFree(iface)。任何时候都不要在异常分支里跳过释放步骤。如果你是在一个循环里反复重连,建议每次循环创建一个新的接口对象和连接对象,用完立即释放,而不是复用旧的指针。
4.4 线程中调用 libnodave 的隐性崩溃
现象:在 TThread 里调用 daveReadBytes,偶发访问冲突,报错位置不固定,有时候连续跑几个小时才出一次。
原因:libnodave 在 Windows 上依赖 Winsock,而 Winsock 的初始化状态是每个线程独立的。如果子线程在没有调用 WSAStartup 的情况下直接执行 Socket 收发,部分 Windows 版本会返回错误,严重时导致访问冲突。Delphi 的主线程通常在启动时已经由 VCL 完成了 Winsock 初始化,所以只在主线程跑通信时不会出问题;一旦把通信移到工作线程,隐患就暴露了。
解决办法:在工作线程的执行函数开头显式调用 WSAStartup,线程结束时调用 WSACleanup。这里要注意的是,WSAStartup 的版本请求可以填 2.2,但实际返回的版本可能不是 2.2,需要做检查。另外,如果工作线程里用到了 VCL 界面更新,不要直接在线程里操作控件,把回调消息通过 TThread.Synchronize 或 PostMessage 抛回主线程。
提示:如果你的项目里同时启用了多个工作线程,每个线程都要有自己的 Winsock 初始化/清理配对。不同线程之间不要共享同一个连接指针,因为 libnodave 的连接对象不是线程安全的。
4.5 PLC 处于 STOP 模式时读写行为异常
现象:上位机连接正常,但读取返回错误码或者数据一直不变。
原因:当 PLC 处于 STOP 模式时,CPU 不再刷新镜像区,DB 块、M 区的数据停留在停机前的状态。daveReadBytes 在协议层仍然会有响应,但读出来的值在业务语义上是无效的。写操作也一样,写进去的数据不会被执行,但 PLC 不一定会返回错误码。
解决办法:在通信层增加一个 PLC 运行状态的轮询,通过读取系统状态字节来判断 PLC 是否在 RUN。如果非 RUN,界面端显示停机状态,读写请求照常发送但不作为业务数据,避免误导操作人员。这个处理在新设备上电调试阶段特别有用,因为 PLC 很可能处于 STOP 状态,而上位机程序仍然需要启动。
5. 进阶:多 PLC 轮询、线程模型与 DLL 版本的现场教训
多台 PLC 在一条产线上很常见。逐台连接、轮询数据的方案一开始够用,但一旦有一台 PLC 通信出问题,整个上位机界面都会卡住,因为轮询循环里碰上阻塞读,后面的任务全部排队。
我的解决方案是连接池加队列。每台 PLC 建立一条常连接,统一由一个通信线程发送请求。请求对象里包含 PLC 编号、数据区类型、DB 号、起始地址、长度、回传回调函数。通信线程按队列顺序处理请求,收到数据后把结果通过回调交给业务层。这样任何一台 PLC 的响应慢都不会阻塞其他台的数据更新。
在线程选择上,TThread 已经够用,但要注意回调函数不要直接去写全局界面控件。我一般先把数据复制到一块独立缓冲区,设置一个事件标志,界面线程检测到标志后再取数据。这样把通信线程的阻塞时间降到最低,同时保证界面的响应速度。
验证链路是否稳定,有一个很直观的办法:在通信线程里记录每一轮轮询的总耗时,持续记录一小时。如果轮询耗时的标准差超过平均值的 30%,基本可以断定网络里有偶发性抖动或者 PLC 侧的通信负载偏高。这时候需要在读取失败时增加重试和退避机制,而不是紧跟着猛发下一轮请求。
调优方面,常见的一个改动是把同一 DB 块中连续地址的数据合并成一次读取。这个我在前文已经提过,但真正执行起来,还需要和 PLC 侧的程序设计人员沟通数据结构,把要上送给上位机的变量连续摆放。如果变量之间夹着无关数据,就得在 PLC 侧加一段复制逻辑,把相关变量搬运到一个固定的数据块里。
最后说一个我自己经历过的事。当时一台调试电脑和一台现场电脑各放了一份 libnodave 的 DLL,时间久了,两份 DLL 版本不一致。结果对同一块 DB 的解析结果出现了差异,一个能读到正确浮点数,另一个读出来是乱数。排查到后面才发现是 DLL 版本的问题。从那以后,我在所有上位机工程里都把 DLL 的版本信息写进文件属性,程序启动时读取文件版本号并与预期值比对,不一致直接弹窗提醒。这个习惯算是用血泪经验换来的,帮我少花了好几个现场排查日。希望帮到你。
本文还有配套的精品资源,点击获取