简介:libnodave-0.8.4是一套面向工业现场的开源通信库,主要服务于使用Delphi、VB、VC进行上位机开发的工程师,能够通过工业以太网或MPI与西门子S7-200/300/400系列PLC通信,完成输入输出读写、诊断与块操作等任务。压缩包共334个文件,约3.02MB;其中既有C与Pascal源码(.c/.h/.pas/.dfm),也有HTML说明文档、bat/sh等编译脚本、Delphi/VB/VC/Perl示例工程以及少量exe演示程序,文件覆盖面较完整,适合对照学习库的初始化、数据交换与错误处理流程。目前已有174人浏览学习,适合工业自动化、设备监控和工控上位机方向的初中级开发者,也可供需要理解S7通信协议细节的人员参考。资源中的Pascal源码与Delphi相关单元清晰可读,便于在Lazarus或Delphi环境中直接复用;配合示例程序,可缩短PLC通信功能的验证周期,并为后续扩展自定义协议或驱动提供基础。整体包体紧凑、示例多样,适合直接引入自动化项目作为通信层的参考。
1. libnodave 连 Delphi:还有人用二十年前的开源协议栈吗
做过工控上位机的人应该都对 S7-300 不陌生。想从这种老 PLC 里取数据,官方通信库要花钱买授权,OPC 服务器又是一层黑匣子,很多开发者的最后落点就落在 libnodave 这个开源库上:0.8.4 这个版本号在业内流传很广,本身是 C 语言写的,却能通过动态库被 Delphi 调用,串口、网口都能用。本文把 libnodave 拆开讲清楚:它解决什么问题、靠什么协议跑通、Delphi 里怎么接,以及真正让你翻车的 6 个坑。适合手里有老 S7-300、想用 Delphi 写数据采集或简易 HMI 的工程师;新手可以照步骤跑通最小程序,熟手可以直接跳到连线和字节序那两节。
2. libnodave 的通信机制与选型边界:为什么老版本反而能打
2.1 MPI/PPI 与 S7 通讯的三层关系:先搞懂协议栈再写代码
libnodave 本质上是一个应用层的开源协议栈,它把“读 DB1 的 DBB0 到 DBB31”这样一个请求翻译成符合厂商私有规范的报文,再通过底层物理通道发出去,回包再解析成字节数组交还给你。你不需要自己拼报文,但必须知道你选的物理通道是哪一种,否则后续参数全是空的。
S7-300 的 CPU 上通常自带一个 MPI 口,物理层是 RS-485,采用 MPI 协议,波特率固定为 187500 bps。MPI 是一种令牌总线协议,总线上每个节点有编号,这个编号就叫 MPI 地址,CPU 出厂默认一般是 2。如果你想用串口连接,上位机就需要一个 USB 转 RS-485 的转换器,然后通过 dave.h 里的协议枚举把连接类型指定为 MPI。除了 MPI,同一类串口上还跑着 PPI 协议,常见波特率是 9600 或 19200,多数用在 S7-200 上,300 上相对少见。
另一种更省事的方式是走以太网:给 S7-300 配一个以太网模块,或者用自带 PN 口的 CPU,让 libnodave 通过 TCP 直接和 PLC 建立连接。这时候不需要 USB 转换器,也不关心 MPI 地址,只关心 IP、rack 和 slot。代码层面切换这两种通道的成本很低,libnodave 把底层封装成了连接函数,你换一个协议枚举值和一个地址字符串就行。很多新手栽跟头,就是因为在 MPI 模式下还想着填 IP,或者反过来,在 TCP 模式下还去配串口参数。
2.2 官方 SDK、OPC、libnodave 怎么选:三种方案的代价
在决定用 libnodave 之前,先把三条主流路线摆在一起对比。这里我不点名具体厂商,只说方案本身的差异。
| 方案 | 费用 | 上手难度 | 可维护性 | 适合场景 |
|---|---|---|---|---|
| 官方通信 SDK | 需要购买授权 | 文档全但接口繁重 | 官方持续维护 | 量产设备、多型号混用、有技术支持预算 |
| OPC 服务器 | 中转服务器要钱 | 免编程,但配置繁琐 | 依赖第三方服务 | 上层系统对接、历史数据归档 |
| libnodave | 免费 | 中等,靠经验 | 自己维护 | 原型验证、中小型采集、内部工具 |
我的选型逻辑很简单:如果只有一台老 S7-300,你只做数据采集或简易上位机,而且团队里没人愿意啃官方 SDK 那一堆配置工具,那 libnodave 是最省事的。它不需要安装任何中间服务,一个 DLL 加几个 Pascal 声明就能跑,部署到现场电脑上就一个 exe 和一个 dll。
反过来说,以下几种情况建议别选它:项目要交付给不写代码的现场人员长期运行,出问题你不在现场;产线上除了 S7-300 还有多个不同系列、不同年代的 PLC,你需要一套统一接口;或者 PLC 侧开了较新的加密通讯机制,0.8.4 的报文结构覆盖不到。这些场景下,官方 SDK 的长期收益会大于它的授权成本,OPC 则在系统对接层面更省人力。
2.3 0.8.4 这个版本号的分量:可靠但不完美的落点
libnodave 这么多年下来,网上流传最多的就是 0.8.4 这个 tarball。原因不是它最新,而是它的 API 形态比较稳定,网上能找到的帖子、代码片段大多基于这一代接口。社区后来确实有一些分支在补新机型、补加密握手,但那些分支的维护质量参差不齐,有的只修了一个机型就停了,有的为了支持新协议把老接口都改了,反而不好用。
我的建议是:固定 0.8.4 作为基线,自己维护一小份补丁。不要因为看到了更新版本就随手升级,深度学习一下它的源码成本不高,但升级带来的接口迁移和回归测试成本很高。0.8.4 不完美,它没有加密、没有自动协商、对较新的 S7-1200/1500 覆盖有限,但在 S7-300 这个老平台上,它的读写能力足够扎实,协议字段的处理被无数人验证过,作为开源方案已经很能打了。
3. Delphi 接入 libnodave 的三条路线:选一条能坚持三年维护的路
Delphi 调用 C 库,行业内基本就三条路:把 C 源码编成 DLL 再用外部函数声明调用;把关键 C 代码手工翻译成 Pascal 单元;以及在 C 侧包一层薄薄的“最小 API”,Delphi 只碰几个业务函数。我先给一张对比表,再展开讲每条的细节。
| 路线 | 实现方式 | 部署体积 | 维护成本 | 推荐度 |
|---|---|---|---|---|
| 编译 DLL | MinGW 或 GCC 编出动态库 | 多一个 dll | 低,接口固定 | 最推荐 |
| 翻译 Pascal | 手工移植核心源码 | 无外部文件 | 高,难以同步 | 学习用 |
| C 包装层 | 薄封装隔离版本差异 | 一个自写 dll | 中等,隔离好 | 长期项目推荐 |
3.1 路线一:把 C 源码编译成 DLL,Delphi 用 cdecl 声明外部函数
这是我最常用的路线。先把 libnodave-0.8.4.tar.gz 解包,目录里通常有 src、docs 等子目录,核心源码在 src 下。Windows 下我用 MinGW-w64 而不是 MSVC,因为这套源码是 GNU 风格,MinGW 编起来最顺,不太会遇到 C99 特性报错或者 Makefile 语法不兼容的问题。
# 解包源码包,注意 tar 在 Windows 下可能需要用 Git Bash 或 WSL tar -zxvf libnodave-0.8.4.tar.gz cd libnodave-0.8.4/src # 把核心 .c 文件全量编译成目标文件,-I. 指向头文件目录 gcc -c -O2 -I. dave.c davei.c daveio.c # 链成 DLL,-lws2_32 是 Winsock 库,TCP 连接必须带上 gcc -shared -o libnodave.dll dave.o davei.o daveio.o -lws2_32如果你在链接时报“未定义符号”,不要慌,大概率是某个辅助源文件没编进来。打开 Makefile 或直接看 src 目录下还有哪些 .c,把它们一并加进去重新编译即可。libnodave 的源文件之间依赖不多,缺了哪个函数,报错信息都会明确告诉你是哪个符号,照着补就行。
编译完成后,把 libnodave.dll 放到 Delphi 工程目录。这里有个关键动作:Delphi 工程是 32 位就配 32 位 DLL,是 64 位就配 64 位 DLL,混配会在运行时报“无法定位程序输入点”或者直接进程崩溃。Delphi 2007 之前的版本基本都是 32 位,所以默认编 32 位 DLL 最保险。
DLL 就位后,在 Delphi 单元里写外部函数声明。C 函数的默认调用约定是 cdecl,这个绝对不能写错,写成 stdcall 轻则参数错乱,重则栈不平衡直接崩。
// libnodave_delphi.pas // 注意:所有 C 函数都用 cdecl,指针类型用 PByte/PAnsiChar function daveNewConnection(fd: Integer; ifname: PAnsiChar; protocol: Integer): Pointer; cdecl; function daveConnectPLC(dc: Pointer): Integer; cdecl; function daveDisconnectPLC(dc: Pointer): Integer; cdecl; function daveReadBytes(dc: Pointer; area: Integer; dnum: Integer; start: Integer; len: Integer; buffer: PByte; bufferSize: Integer): Integer; cdecl; function daveWriteBytes(dc: Pointer; area: Integer; dnum: Integer; start: Integer; len: Integer; buffer: PByte; bufferSize: Integer): Integer; cdecl; function daveGetLastError(dc: Pointer): PAnsiChar; cdecl;声明的逻辑很简单:C 的int对应 Delphi 的Integer,C 的char*对应PAnsiChar,C 的void*对应Pointer。buffer参数比较特殊,它是一块内存的起始地址,所以声明成PByte,在调用时传@buf[0]或@buf都行,前提是 buf 是 Byte 数组。为什么 fd 参数传 0?因为走 TCP 时 fd 只是占位,实际连接信息在 ifname 的 IP 字符串里,0.8.4 这一代接口就这么设计。
3.2 路线二:把关键函数翻译成 Pascal 单元,只适合学习
网上确实流传过把 libnodave 核心翻译成 Pascal 单元的版本,优点是 Delphi 项目不依赖外部 DLL,部署时少一个文件,也没有 cdecl 和 stdcall 的调用约定摩擦。但我要泼一盆冷水:0.8.4 的源码量不小,协议解析的细节藏在各种位操作和缓冲区指针移动里,翻译一条读命令很容易,翻译完整会话逻辑很难,而且一旦你基于老版本翻译完,社区后续的补丁你也很难跟进。
拿别人翻译好的单元来学习没有问题,但上产线之前,一定要对照原始 C 源码逐个函数过一遍。我见过一个模拟项目X因为用了翻译版的位移函数,读回来的位状态整体错位,排查了两天才发现是 Pascal 翻译时把移位方向写反了。如果你只是想搞懂 libnodave 的报文结构,翻译版是很好的教材;如果你要交付可长期运行的上位机,老老实实走 DLL 路线。翻译版的价值在于学习,不在于生产。
3.3 路线三:在 C 侧包一层“最小 API”,Delphi 只碰三个函数
如果项目要长期维护,我推荐在路线一的基础上再加一层薄封装。这一层的思路是:把 libnodave 的版本差异、协议细节全部关在 C 侧,Delphi 工程只面对几个业务函数,将来就算换协议栈,Delphi 代码一行都不用改。
// wrap_dave.c // 薄封装:把 libnodave 的会话管理收拢成三个业务函数 #include "dave.h" #include <winsock2.h> __declspec(dllexport) void* PLC_Connect(const char* ip, int rack, int slot) { // TCP 连接固定走 S7 协议,fd 占位为 0 // daveNewConnection 失败时返回 NULL,上层据此提示 void* dc = daveNewConnection(0, (char*)ip, 2); if (!dc) return NULL; // rack/slot 在握手阶段必须和 PLC 组态一致 // 不一致时握手可能不报错,但后续读写会失败 // 这里约定:连接参数由 Delphi 传入,避免 C 侧写死 if (daveConnectPLC(dc) != 0) { return NULL; } return dc; } __declspec(dllexport) int PLC_ReadDB(void* dc, int db, int start, int len, unsigned char* outBuf) { // 统一返回 0 表示成功,-1 表示失败 // len 和 outBuf 的长度由调用方保证,函数内不再做二次校验 return daveReadBytes(dc, 4, db, start, len, outBuf, len); }这个封装的逻辑很直白:PLC_Connect负责建连,PLC_ReadDB内部把 area 固定为 DB 块,Delphi 侧只需要知道“连接对象”和“字节数组”。为什么 rack/slot 要由上层传入?因为不同机型的 CPU 槽位不一样,写死在 C 侧会让封装失去灵活性;传进来之后,就算现场换了 CPU,组态人员改配置文件就行,不用重新编译 DLL。
Delphi 侧对应声明就清爽多了:
function PLC_Connect(ip: PAnsiChar; rack: Integer; slot: Integer): Pointer; cdecl; function PLC_ReadDB(dc: Pointer; db: Integer; start: Integer; len: Integer; outBuf: PByte): Integer; cdecl;三条路线对比下来,我的建议很明确:短期项目走 3.1,长期项目走 3.3,翻译版留在本地当参考书。薄封装多花半天时间,省下来的却是每次排查协议问题的半天,这笔账很划算。
4. 最小可用工程:把 S7-300 的 DB1 数据读进 Delphi 窗体
4.1 连接参数表:MPI 地址、rack、slot 的取值逻辑
不管你走串口还是网口,libnodave 建连时都会涉及几个核心参数。这些参数不是拍脑袋填的,每一项都能在 PLC 的组态软件里查到。我整理了一张常用取值表,照着对即可。
| 参数 | 含义 | S7-300 常见值 | 说明 |
|---|---|---|---|
| MPI 地址 | 总线节点编号 | 2 | CPU 默认,可在组态中修改 |
| rack | 机架号 | 0 | 单机架系统基本是 0 |
| slot | CPU 槽位号 | 2 | 具体看组态里 CPU 占的槽位 |
| 连接类型 | 物理通道 | TCP 或 MPI | 选串口就配波特率,选网口就配 IP |
| 波特率 | 串口/总线速率 | 187500 | MPI 固定值,PPI 用 9600/19200 |
| IP 地址 | 以太网模块地址 | 视组态 | 仅 TCP 方式需要 |
rack 和 slot 最容易填错。常见做法是打开组态软件,看硬件目录里 CPU 挂在哪个机架哪个槽位。S7-300 的 CPU 很多默认在 0 号机架 2 号槽,但如果你用第三方导轨或者扩充机架,这个值可能不在常规位置。有一个值得记住的细节:rack/slot 不一致时,daveConnectPLC未必会报错,但后续第一次读就会返回错误码,因为它握手阶段没有把地址对上。
4.2 从建连到读 DB 块的最小 Delphi 代码
有了声明和参数,最小工程其实只有不到 40 行。这里我给你一套能直接粘贴进 Delphi 的样例,走 TCP 读 DB1 的前 32 个字节。
// 主流程:建连、读 DB、断开 // 实际项目中建议把这段放到后台线程,避免阻塞 UI var dc: Pointer; buf: array[0..31] of Byte; i: Integer; begin // 1. 建连:IP 按组态填写,rack=0,slot=2 dc := daveNewConnection(0, PAnsiChar('192.168.0.1'), 2); if dc = nil then raise Exception.Create('daveNewConnection 返回空,检查 IP 和协议枚举'); // 2. 握手:失败时用 daveGetLastError 看原因 if daveConnectPLC(dc) <> 0 then begin raise Exception.Create(StrPas(daveGetLastError(dc))); end; try // 3. 读 DB1 从 DBB0 开始的 32 个字节 // area=4 即 daveDB,dnum=1 即 DB 块号 FillChar(buf, SizeOf(buf), 0); if daveReadBytes(dc, 4, 1, 0, 32, @buf[0], SizeOf(buf)) = 0 then begin for i := 0 to 31 do Memo1.Lines.Add(Format('DBB%d = $%2.2X', [i, buf[i]])); end else raise Exception.Create(StrPas(daveGetLastError(dc))); finally // 4. 注意顺序:先断开连接,再释放借用资源 daveDisconnectPLC(dc); end; end;这段代码的每一个参数都有明确用途。daveNewConnection的最后一个参数是协议枚举,2 在 0.8.4 的 dave.h 里对应 TCP/S7 方式;如果你走串口 MPI,这里改成 MPI 对应的枚举值,并且第一个参数要填串口描述符而非 0。daveReadBytes的第三个参数是 DB 块号,第四个是起始字节偏移,第五个是读取长度。注意整个库按字节寻址,不要按字寻址,读 DBW 其实就是读两个连续字节。
关于 area 参数,我把 0.8.4 dave.h 里的常用枚举值直接列出来:1 是输入区,2 是输出区,3 是标志区,4 是 DB 块。这些是源码里写死的常量,移植到 Delphi 时建议用常量声明而不是魔法数字,避免以后维护时看懂不了。
// 建议在单元头部声明,与 dave.h 保持一致 const daveInputs = 1; daveOutputs = 2; daveFlags = 3; daveDB = 4;4.3 写入操作与大小端处理:写错一个字节方向盘就翻车
读数据只是第一步,真正让人头疼的是写数据。S7 的 DB 块是多字节数据的存储区,而 S7 协议内部采用大端字节序:高字节在前,低字节在后。你在 Delphi 的 Integer 里看到的值是$12345678,在内存里其实是78 56 34 12,直接传给 PLC 会把高低字节整个颠倒。
// 写一个 Integer 到 DB1.DBW20 // 注意:DBW20 占用 DBB20 和 DBB21 两个字节 var val: Integer; src: array[0..3] of Byte; begin val := 12345; // 手动转成大端:高字节放前 src[0] := Byte((val shr 24) and $FF); src[1] := Byte((val shr 16) and $FF); src[2] := Byte((val shr 8) and $FF); src[3] := Byte(val and $FF); // area=4 表示 DB 块,dnum=1 表示 DB1,start=20 表示 DBW20 if daveWriteBytes(dc, 4, 1, 20, 4, @src[0], 4) = 0 then Memo1.Lines.Add('写入成功') else Memo1.Lines.Add(StrPas(daveGetLastError(dc))); end;写入的逻辑说明其实就一句话:先转换字节序,再调用写接口。但这里有个血泪经验:读回Val后一定要自己验证一遍。常见翻车场景是写了一个Word类型的值,却按 4 字节Integer转了大小端,结果把相邻的 DBB22 和 DBB23 也覆盖掉了。现场调试时这种现象特别隐蔽,因为读写都“成功”,只有设备端行为异常。
位操作也走同样的路数。S7 没有直接按位写的接口,常规做法是先读回整个字节,在 Delphi 里对该字节做位运算,修改目标位后写回。注意这种“读-改-写”不是原子的,如果 PLC 侧同一字节也在被别的程序修改,中间会出现覆盖。对安全要求高的点位,建议把该区段用批量读锁住,或者直接把 PLC 程序改成单写字。
5. libnodave 实战避坑:连不上、读错数、莫名超时的 6 个常见坑
5.1 握手失败:MPI 地址和波特率各调各的
现象:串口方式下,daveConnectPLC返回非零,或者第一次读超时。
原因:MPI 地址写错,或者波特率与 PLC 实际设置不一致。MPI 协议是固定 187500 bps,你如果按 PPI 的 9600 配置,握手报文全都对不上;MPI 地址填错时,PLC 收不到发给自己的令牌,自然不会回包。
解决:先确认 CPU 上的拨码开关或组态软件里的 MPI 地址,并确认转换器型号支持 187500 bps。很多 USB 转 485 线只支持到 115200,这种线接 MPI 必挂。排查时用一个串口助手先看有没有回帧,比反复编译程序快得多。
5.2 读回的整数不对:S7 大端序与 Delphi 小端序
现象:DB 块里存的是 16 位整数,读回来变成一个大得离谱的数,或者数值和实际相差很大。
原因:S7 协议多字节按大端存放,Delphi 的 Integer/Word 按小端解释。你没做字节交换,直接把字节拼成整数,高低字节全反了。
解决:写一个 Swap 函数,读回Word后交换高低字节。32 位整数做两次交换。这个坑在所有 PLC 通讯里都会遇到,不管是不是用 libnodave,先把字节序问题想清楚再写通用封装。
5.3 第一次连接成功,循环里第二十次超时
现象:程序刚启动时读得很顺畅,跑了十几分钟后某一次读操作开始超时,之后连续失败,重启程序又正常。
原因:PLC 侧对单连接有资源限制,或者你的循环里没有做超时控制,某一次网络抖动后连接进入坏状态,之后所有读写都在等一个永远不回来的包。
解决:在读写循环外套一层失败计数,连续失败 3 次就主动断开重连。别指望单次失败的库能自愈,主动重建连接是现场最可靠的做法。重连间隔建议 2 到 5 秒,太短会把 PLC 的通讯口占满。
5.4 回调日志全无输出:函数指针的调用约定
现象:按网上代码注册了日志回调,程序运行时不打印任何输出。
原因:日志回调用的是 C 的函数指针,Delphi 声明回调过程时漏写了 cdecl,默认成了 register 或 stdcall,栈帧不匹配,回调可能根本没被调用,或者调用了但立刻崩溃。
解决:回调过程声明为procedure MyLog(...); cdecl;,并确认参数类型与 C 侧一致。建议先注册一个最简单的回调,只往文件写一行时间戳,确认链路通了再去解析日志字段。
5.5 一运行就崩溃 in daveConnectPLC:DLL 位数与程序不一致
现象:程序启动后调用建连函数直接崩,没有任何异常信息,有时甚至在 IDE 里就崩。
原因:最常见的是 64 位 Windows 上装了 64 位 Delphi,却用了 32 位 libnodave.dll;或者反过来。Delphi 老版本多为 32 位,这是个经典坑。
解决:用 dumpbin 或任务管理器看 DLL 位数,与 exe 保持一致。MinGW 默认编 32 位,用 MinGW-w64 时注意带-m32或者选择 32 位工具链。调试期建议把 DLL 和 exe 放同一目录,避免系统路径加载到旧版本。
5.6 进程停不掉:socket 未及时释放
现象:关闭窗口后进程还在后台,或者第二次启动提示端口被占用。
原因:建连成功后没有调用断开接口,也没关闭底层 socket。libnodave 在 TCP 模式下是直接操作 Winsock 的,Delphi 窗体关闭只结束了你的业务代码,sock 句柄还挂着。
解决:在 finally 块里先daveDisconnectPLC(dc),再关闭对应的 socket 句柄。如果你的封装层里持有 fd,记得一并 close。进程管理器里杀不掉时,用 netstat 找对应端口的 PID,基本能确认是不是这个坑。
6. 验证与进阶:用日志分层把自己从黑匣子里放出来
6.1 三秒定位问题:日志分级输出
libnodave 的老版本提供了日志回调机制,你可以在 Delphi 里注册一个 cdecl 过程,把库内部的收发报文打出来。我一般注册回调后把日志分为两档:正常运行时只输出建连、断连、读写成功与否;排查问题时把日志级别调高,直接看十六进制报文。实践下来,大多数连接和读写问题都能从报文里看出来:请求报文发没发出去,回包是握手错误还是数据错误,一眼就知道问题在物理层还是协议层。
// 日志回调示例:写入 Memo 控件,注意必须 cdecl procedure MyLog(const msg: PAnsiChar); cdecl; begin if Assigned(Form1) then Form1.Memo1.Lines.Add(StrPas(msg)); end; // 注册方式:连接建立前调用即可 daveSetLog(@MyLog);6.2 重连策略:检测什么状态、间隔多久
communication 代码里,我把重连逻辑固定成三句话:失败计数、退避重试、复位计数。继续读失败时计数加一,超过 3 次就执行断开重建;每次重连前等待时间按 2 秒、4 秒、8 秒递增,重连成功后再把间隔重置回 2 秒。这样既不会把 PLC 端口打满,也不会在短暂抖动后误判。实际使用时,把这段逻辑放进后台线程的循环里,窗体上只显示状态变化,不要直接在 UI 线程里同步重连,否则界面会卡死。
6.3 区块批量读:从 N 次往返降成 1 次
如果你要读的 DB1 里的数据是连续的一段,比如从 DBB0 到 DBB200,一次daveReadBytes就能读完,没有必要分 50 次每次读 4 个字节。批量读不仅快,而且能减少 PLC 通讯口的压力。需要注意的地方是:单次读取长度不要超过 PLC 的通讯限制,S7-300 常见的安全值是 200 字节左右,太长会直接报错。读回来以后自己在内存里切字段,比多次往返可靠得多。
我第一次独立接这类项目时,现场反馈偶发读取失败,查了一整天,最后发现是 RS485 转换器的屏蔽层没接地,根本不是代码问题。从此我养成的习惯是:遇到通讯问题,先查物理层,再查参数配置,最后才查协议栈。libnodave 本身是个可靠的开源实现,大部分“库不行”的结论,最后都被追溯到了参数填错或硬件接线。希望帮到你。
本文还有配套的精品资源,点击获取