
简介这是基于C# WinForm的工业Modbus通讯完整源码面向需要开发上位机与PLC交互应用的工程师。代码同时支持Modbus TCP和串口两种通信方式ModbusClient.cs类已在实际项目永红、西门子PLC中验证可用可单独拷贝复用省去从零编写协议解析的繁琐过程。资源共102个文件、压缩包426KB以72个cs源码文件为核心覆盖ModbusClient、ModbusServer、主窗体及完整示例另含exe可执行程序、png/jpg界面预览、config配置文件等辅助内容开发环境为Visual Studio 2015、.NET 4.0无数据库依赖。已有1555人学习下载适合正在学习C#串口/网络通信或需要快速实现PLC对接的开发者直接参考既可整体编译运行观察效果也可提取核心类嵌入自身项目。1. 从串口到TCPwinform Modbus通讯源码到底要解决什么问题做上位机开发的人早晚会遇到这样一个需求用C# Winfrom窗体程序去读PLC的寄存器、采集仪表的实时数值或者把设备状态写进界面。只要对方是工业设备Modbus通讯基本是绕不开的协议而winform Modbus通讯源码这个方向的检索热度恰恰说明大量从业者卡在了同一个位置上——不是不会写界面而是搞不定通讯层的稳定性和兼容性。这个主题的实质就三件事第一把Modbus协议的RTU和TCP两种模式跑通第二把串口和网口两条通道接到Winform窗体上第三把读写寄存器、轮询、异常重连这些代码写成能直接复用的源码而不是一次性脚本。适合正在做上位机、设备集成、产线数据采集的人以及想找一套能改能用的Winform通讯底子的开发者。2. Modbus通讯的报文基座RTU与TCP帧结构、地址映射与选型判断2.1 两种常用模式Modbus RTU和Modbus TCP到底差在哪Winform里做Modbus通讯首先得选对模式。现场最常见的两种是Modbus RTU和Modbus TCP。RTU走串口也就是RS232或者RS485一个主站挂多个从站靠地址区分设备TCP走以太网直接连PLC的网口或者以太网模块地址是IP加端口。很多人第一次做的时候直接用串口工具去调TCP设备那肯定跑不通因为两者的报文载体完全不一样。RTU报文的特点是紧凑地址码1个字节、功能码1个字节、数据区若干字节、CRC校验2个字节没有帧头帧尾靠时间间隔和字节间隔来区分一帧结束。TCP报文则不一样它在RTU的基础上加了一个MBAP报文头包含事务处理标识符、协议标识符、长度字段和单元标识符共7个字节然后才是功能码和数据区。这就是为什么Modbus TCP的报文比RTU多了几个字节也因为TCP本身有可靠的流传输机制所以不再需要CRC校验。帧结构的差异决定了代码写法完全不同。RTU需要自己处理串口接收缓冲区的分包逻辑——什么时候算一帧完整接收CRC怎么算超时怎么判断TCP只需要用现成的Socket或者TcpClient去收包按长度字段解析即可。如果项目里两种设备都要接源码里最好把这两套通道分开抽象共用同一套寄存器读写接口否则后续维护会非常痛苦。2.2 地址模型线圈、离散输入、保持寄存器、输入寄存器Modbus协议的数据模型分四张表Winform初始化页面和读写逻辑都围着这四张表转。线圈Coil是可读可写的位对应PLC的Q输出点离散输入Discrete Input是只读的位对应PLC的I输入点保持寄存器Holding Register是可读可写的16位字对应PLC的D数据寄存器输入寄存器Input Register是只读的16位字对应模拟量输入通道。RTU的功能码就围绕这四张表展开01读线圈、02读离散输入、03读保持寄存器、04读输入寄存器、05写单个线圈、06写单个寄存器、15写多个线圈、16写多个寄存器。TCP完全沿用这套功能码只是封装层不同。实际项目里90%的读写集中在03和16上面——读保持寄存器、写保持寄存器因为PLC的D寄存器、仪表的工作参数大多映射到保持寄存器。地址映射是Winform上位机开发里最容易出错的点。Modbus协议层的地址是从0开始的比如功能码03读保持寄存器协议地址0对应PLC侧的40001但很多PLC的编程软件里显示的地址是40001起算两者之间存在一个偏移。代码里读地址0实际上对应的是40001。如果从站手册里说“工作频率在40010”那么协议地址应该是9。这个映射关系必须在源码里做成可配置的表不然换一台设备就要改代码。2.3 通讯库选型NModbus还是HslCommunicationWinform项目引用第三方通讯库是常见做法两条主流路线NModbus和HslCommunication。NModbus是老牌开源Modbus协议库轻量、集成简单、遵循协议严格适合只做Modbus通讯的场景HslCommunication是国内的工业通讯库支持Modbus、西门子、三菱、欧姆龙等多种协议封装更贴近工业实践比如提供了字符串地址格式“s2;100”可以直接指定站号。我的经验是如果设备清单里只有Modbus设备用NModbus更干净协议逻辑透明出问题容易排查如果后面可能要接多个品牌的PLC那就直接上HslCommunication省去后面二次集成的成本。选型没有什么绝对的对错看团队对哪个库更熟悉、现场设备的扩展方向。更重要的是无论选哪个库源码里都必须把设备连接、读写操作、异常处理封装成独立类不要和窗体UI逻辑混在一起否则现场调试的时候会改一处崩一片。3. 用NModbus实现RTU从站通讯串口初始化、读保持寄存器与写线圈3.1 最小可跑的RTU通讯源码串口参数和主站创建Winform里做RTU通讯底层还是System.IO.Ports.SerialPort。NModbus只是在串口之上封装了协议解析所以串口的参数设置是否正确直接决定了通讯能否建立。常见的从站配置是9600波特率、8数据位、无校验、1停止位也就是9600 8N1但现场设备可能是19200、偶校验必须匹配从站侧的真实参数。代码里用一个独立的类来管理串口生命周期窗体加载时初始化窗体关闭时释放。下面是一段用NModbus创建RTU主站并读取保持寄存器的最小示例代码。项目里需要先通过NuGet安装NModbus包注意4.x版本的API和旧版有差异旧版直接new ModbusSerialMaster新版改成了通过ModbusFactory创建。using System.IO.Ports; using Modbus.Device; // 串口初始化参数必须和从站完全一致 SerialPort serialPort new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One); serialPort.ReadTimeout 1000; serialPort.WriteTimeout 1000; serialPort.Open(); // NModbus 4.x 标准写法通过工厂创建RTU主站 ModbusFactory factory new ModbusFactory(); IModbusMaster master factory.CreateRtuMaster(serialPort); // 读从站地址1的保持寄存器从协议地址0开始连续读10个 ushort[] values master.ReadHoldingRegisters(1, 0, 10); foreach (ushort v in values) { Console.WriteLine($寄存器值: {v}); }这段代码的核心有两点一是串口打开后才能创建主站因为RtuMaster需要持有串口实例才能收发数据二是ReadHoldingRegisters方法有三个参数第一个是从站地址站号第二个是协议起始地址第三个是读取数量。读出来的ushort[]就是16位无符号整数如果设备返回的是有符号数或者浮点数需要自己转换NModbus不会帮你做类型解析。3.2 写单个线圈和写多个寄存器控制输出与参数下发读数据只是第一步Winform上位机更多时候还需要下发控制指令比如启动设备、切换模式、修改设定值。写操作分两类位操作写线圈字操作写寄存器。写单个线圈用WriteSingleCoil位值用true和false表示写单个寄存器用WriteSingleRegister值用ushort表示。如果一次要写多个连续的寄存器用WriteMultipleRegisters。这里有个容易踩的坑很多从站设备对“写单个寄存器”和“写多个寄存器”的支持并不一致。有的老仪表只实现了06功能码你发16功能码它会报异常有的PLC反过来频繁单个写会被扫描周期拖慢。所以源码里最好把写单个和写多个都封装出来根据设备手册选着用。// 写单个线圈控制从站地址1的线圈输出true表示闭合 bool writeCoilResult master.WriteSingleCoil(1, 0, true); Console.WriteLine($写线圈结果: {writeCoilResult}); // 写单个寄存器把从站地址1的协议地址10设置为1000 master.WriteSingleRegister(1, 10, (ushort)1000); // 批量写寄存器一次写连续5个地址 ushort[] dataToWrite new ushort[] { 100, 200, 300, 400, 500 }; master.WriteMultipleRegisters(1, 20, dataToWrite);批量写寄存器时要注意数据长度的边界。Modbus协议里批量写的最大数据长度受报文限制RTU模式下一次最多写123个寄存器因为数据区最多246字节除以2字节一个寄存器。超过这个数量必须分批写否则从站会返回异常码。另外写操作之后最好加一个短延时再读回验证有些设备写入后需要几十毫秒才能稳定反映到内部存储区。3.3 串口数据接收与分包为什么报文时好时坏RTU通讯有个和TCP截然不同的难点串口是字节流没有明确的帧边界。RS485总线上一帧报文结束后线路处于空闲状态下一帧开始前会有静默间隔。Modbus RTU的标准要求帧间间隔至少是3.5个字符时间也就是在9600波特率下大约4毫秒左右。NModbus内部实现了这套时序逻辑但它依赖串口收到的字节间隔来判断帧是否结束。实际项目里经常遇到一个现象程序连续读取数据偶尔会出现某一次返回异常、CRC校验失败过一会儿又自己恢复了。这大概率不是协议库的问题而是串口缓冲区在系统调度延迟下收到了不完整的数据帧或者RS485转换器没有正确控制收发方向导致的。用USB转RS485的线调试时尤其明显驱动不透明、数据抓包不便帧间隔不稳定。遇到这类问题常规的做法是在源码里加一个串口数据监视层把每次原始收到的字节数组记录到日志文件和标准RTU报文做比对。排查时重点看帧头是否对齐、字节数是否准确、CRC是否匹配。如果确认报文正确但NModbus仍报错检查串口缓冲区大小是否够用SerialPort的ReceivedBytesThreshold是否需要调整还可以考虑把串口ReadTimeout调大一点。这个问题下文专门展开讲。4. 用HslCommunication实现Modbus TCP连接、批量读写与字节序控制4.1 基于TCP的客户端封装连接管理与连接状态工业现场用Modbus TCP的场景越来越多优势很明显走网线不用485布线不用考虑终端电阻通讯距离也更远。HslCommunication对Modbus TCP的封装比NModbus更贴近国内PLC的用法最关键的是它的地址格式直接用字符串表达可以附带站号信息。比如“s2;100”表示从站号2、协议地址100开始操作这在多从站场景下非常直观。TCP通讯在Winform里要注意一个核心问题连接状态是可能随时变化的。PLC断电重启、网线松动、交换机重启都会导致连接断开但Socket表面上还活着直到读写超时才发现。所以源码里必须封装一个连接管理器定时检测连接状态发现异常后自动重连。下面用HslCommunication写一段完整的Modbus TCP客户端示例包含连接建立和保持寄存器读取。using HslCommunication; using HslCommunication.ModBus; // 创建TCP客户端指定PLC的IP和端口 ModbusTcpClient client new ModbusTcpClient(192.168.1.10, 502); // 连接服务器返回操作结果对象 OperateResult connectResult client.ConnectServer(); if (!connectResult.IsSuccess) { Console.WriteLine($连接失败: {connectResult.Message}); return; } // 读取保持寄存器站号1地址100读取10个寄存器 OperateResultshort[] readResult client.ReadInt16(s1;100, 10); if (readResult.IsSuccess) { short[] values readResult.Content; foreach (short v in values) { Console.WriteLine($寄存器值: {v}); } } else { Console.WriteLine($读取失败: {readResult.Message}); }注意ReadInt16的返回值是short[]这是HslCommunication特有的类型转换。Modbus寄存器本质上是16位无符号数但ReadInt16会把寄存器内容按有符号数解析这样读温度、压力这类可能为负值的参数时更自然。如果设备数据是浮点数HslCommunication还提供了ReadFloat方法直接按32位IEEE754标准从两个连续寄存器里解析出float值省去手动拼接字节的麻烦。4.2 读写方法的取舍单点读写还是批量读写Modbus TCP通讯的读写策略直接影响界面响应速度和PLC的负担。有些新手在Winform里循环读数百个地址每个地址单独发一帧报文结果一个界面的刷新周期要好几秒还经常触发从站通讯超时。正确的做法是尽量把连续的地址合并成一次批量读取大幅减少报文交互次数。批量读的逻辑很简单如果界面上需要显示1到20的寄存器那就一次读20个寄存器然后在本地做索引映射而不是发20次单寄存器读命令。HslCommunication的批量读方法大同小异直接改地址和数量即可。但批量读有一个前提要读取的地址区间必须是连续的。如果地址分布很零散比如寄存器的有效数据分布在10、20、30……这种阶梯状布局批量读会把中间的无效数据也读回来网络开销反而更大。这种情况下要权衡间隔近的合并间隔远的拆开。// 批量读取站号1的连续寄存器地址0读到地址49共50个 OperateResultshort[] batchRead client.ReadInt16(s1;0, 50); if (batchRead.IsSuccess) { // 本地索引地址5对应数组下标5 short tempValue batchRead.Content[5]; short pressureValue batchRead.Content[12]; // 更新界面控件 labelTemp.Text tempValue.ToString(F1); labelPressure.Text pressureValue.ToString(F1); }代码里的地址索引要特别小心。batchRead.Content的下标就是相对于起始地址的偏移起始地址是0那么地址5就是Content[5]。如果起始地址是100地址105就是Content[5]计算公式是“目标地址减去起始地址”。这类偏移计算放在一张配置表里最安全不要散落在界面事件代码中。4.3 字节序问题寄存器里的float读出来为什么是天文数字Modbus通讯中最典型的数据坑就是字节序。一个32位浮点数在Modbus协议里占用两个16位寄存器但不同厂商的设备对两个字寄存器的排列方式不一样。有的设备高位字在前有的高位字在后每个字内部的两个字节也存在大小端区别。如果不做处理读上来的float数值完全错乱比如应读25.0却显示成1.7E-40。HslCommunication默认的字节序通常是“低字在前”也就是第一个寄存器存浮点数的低16位第二个寄存器存高16位。正好对应很多国产仪表和部分西门子PLC的存储方式。但三菱PLC、部分台达变频器默认是“高字在前”也就是第一个寄存器存高16位第二个寄存器存低16位。如果设备手册里写了“32位数据高字在前”就必须调整读取方式。// 方法一读取16位寄存器再手动拼float OperateResultbyte[] bytesRead client.Read(s1;100, 4); if (bytesRead.IsSuccess) { byte[] data bytesRead.Content; // 假设高字在前寄存器100是高16位寄存器101是低16位 // 注意Modbus寄存器内字节是高位在前大端 byte[] floatBytes new byte[4]; floatBytes[0] data[0]; floatBytes[1] data[1]; floatBytes[2] data[2]; floatBytes[3] data[3]; float result BitConverter.ToSingle(floatBytes, 0); }上面的代码展示的是“高字在前”的数据拼法如果实际设备是“低字在前”需要把data[0]和data[2]互换。这种转换逻辑建议统一封装到一个静态类里提供ConvertFloatFromRegisters(byte[] data, bool highWordFirst)这样的方法避免在业务代码里到处写BitConverter。字节序没有所谓“标准”答案一切以从站设备手册为准调试时用模拟从站设置固定值把通讯报文和解析结果对比是最有效的验证方式。5. 现场排查与避坑记录CRC、地址偏移、掉线重连和UI卡死5.1 CRC校验失败同样的报文为什么手算是对的但代码报错CRC校验失败是RTU通讯里最常见的一类报错。现象是程序读取数据时返回“CRC校验错误”异常但把串口抓到的报文拿去用第三方工具验证CRC计算又是正确的。这个问题通常不是通讯库的问题而是串口接收到的数据本身不完整或者RS485收发切换太慢导致首尾字节丢失。原因是多方面的。USB转485转换器在Windows下的驱动时序不稳定数据量大的时候偶尔丢字节也有可能是串口缓冲区设置的接收阈值太大导致一帧数据被拆成两次交付NModbus把半帧数据拿去验CRC自然失败。解决办法是先换一个质量好一点的USB转485头确认硬件没问题然后再在串口初始化里把ReceivedBytesThreshold设为1确保每个字节都实时触发接收事件不要等缓冲攒一批才通知上层。// 改善串口接收时序的初始化参数 serialPort.ReceivedBytesThreshold 1; // 收到1个字节就触发DataReceived serialPort.ReadBufferSize 4096; // 调大缓冲区避免溢出丢字节 serialPort.ReadTimeout 500; // 缩短超时尽快反馈异常如果换了硬件、调了参数还是偶发CRC错误就要考虑从站侧的问题了。有些老设备对帧间隔非常敏感主站发送完请求后从站需要几十毫秒才能准备应答。如果上位机没有做串口资源锁保护多个线程同时读写同一个串口可能把请求帧和应答帧混在一起导致CRC计算紊乱。这个问题的根源在代码架构排查方法是在源码的串口读写入口加一个互斥锁SemaphoreSlim一次只允许一个操作占用串口。5.2 地址偏移与数量越界读到的数据永远是错的那一页地址偏移问题比CRC更隐蔽因为它不报错而是静默地返回“看起来正常但其实是错的”的数据。现象是界面显示的温度值一直恒定不变或者数值范围明显不对比如应该显示25度却显示成2500。这种情况九成是地址映射错误还有一成是寄存器数量解析方式不对。Modbus协议地址和PLC编程软件地址的对应需要专门做一张换算表。以西门子S7-1200和200 SMART为例Modbus从站库函数把保持寄存器映射到V区从站地址40001对应VB0/VW040002对应VW2。但不同型号的PLC映射起始位置不一样200 SMART的默认映射地址可以通过库存储区设置调整。写死在代码里的地址在换设备后会产生灾难性后果——可能读了错误的区域还覆盖了PLC的内部逻辑。解决这个问题只有一个可靠方案把地址映射做成Winform界面上的配置文件。界面加载时读取Map文件用“设备点号 寄存器类型 协议地址”的元组来定义每个数据点。这样换设备只需要改配置不需要重新编译源码。顺便说一句界面用DataGridView控件显示这些映射关系时如果你想对配置里的0和1状态列显示为CheckBox而不是纯文本可以把该列的CellTemplate换成DataGridViewCheckBoxCell这在Winform的表格控件里特别好用。5.3 TCP掉线重连Socket还在但数据已经读不到了Modbus TCP的掉线问题比串口更让人头疼。现象是程序运行几个小时后界面上的数值不再刷新但检查TCP连接状态显示还是Connected。这是TCP协议的一个特点连接在没有数据交互时任何一方断开都不会立刻通知对端。PLC重启、网线松动、防火墙超时都可能让连接变成半个僵尸客户端自己并不知道。处理掉线重连不能只靠判断Connected属性要在源码里做一个主动探测机制。常见做法是启动一个后台定时器每隔10秒发送一次Modbus读请求比如读从站状态或者读一个固定寄存器如果连续3次请求都超时就认为连接已失效执行主动断开再重连。这里要小心别把探测读操作和用户界面操作并发执行否则两个线程同时读写同一个Socket会造成报文混乱。// 简单的重连心跳逻辑示例 private async Task CheckAndReconnectAsync(ModbusTcpClient client, CancellationToken token) { while (!token.IsCancellationRequested) { // 发送一个轻量的读请求测试连接状态 OperateResult testRead client.ReadInt16(s1;0, 1); if (!testRead.IsSuccess) { // 连续失败达到3次执行重连 client.ConnectClose(); await Task.Delay(1000); OperateResult reconnect client.ConnectServer(); if (reconnect.IsSuccess) { Console.WriteLine(重连成功); } } await Task.Delay(10000); // 10秒一次心跳 } }重连的间隔设置也有讲究。间隔太短PLC还在重启过程中重连会持续失败并报错刷屏间隔太长恢复通讯的时间太久产线会出现长时间无数据。我一般会做退避策略第一次失败等1秒第二次等2秒最多等30秒成功后重置回10秒的普通心跳间隔。这个逻辑虽然简单但放在无人值守的上位机里能省掉大量半夜去现场重启程序的麻烦。5.4 UI卡死通讯请求把界面线程堵住了Winform界面卡死是几乎所有上位机新手都会遇到的坎。现象是点击“连接设备”按钮后界面没有响应鼠标变成转圈状态过几秒弹出一个“程序未响应”的对话框。原因是把耗时同步网络操作放在了UI线程里比如直接在Button的Click事件里调用ReadHoldingRegisters这个调用要等串口超时或者网络超时才返回Thread期间UI消息循环被阻塞界面自然就冻结了。解决办法有两条路一是用async/await异步编程二是用BackgroundWorker做后台线程两种方式都行。现代Winform开发建议直接上async/await代码更简洁。注意C#的async机制在Winform里依赖SynchronizationContextawait之后的代码会自动回到UI线程更新控件不需要手动Invoke。下面给出改写后的按钮事件代码。private async void btnRead_Click(object sender, EventArgs e) { try { btnRead.Enabled false; // 异步读取放到后台线程执行不阻塞UI OperateResultshort[] readResult await Task.Run(() client.ReadInt16(s1;100, 10)); if (readResult.IsSuccess) { labelValue.Text readResult.Content[0].ToString(); } else { labelError.Text readResult.Message; } } catch (Exception ex) { MessageBox.Show($读取异常: {ex.Message}); } finally { btnRead.Enabled true; } }这段代码的关键在于Task.Run把同步阻塞的Modbus读操作移到线程池await让控件恢复响应finally里重新启用按钮防止重复点击。这里有个细节要注意Task.Run里的代码在后台线程执行但readResult返回后await下面的代码默认在UI线程继续跑所以直接更新label控件是安全的。如果用了BackgroundWorker则要在ProgressChanged或RunWorkerCompleted事件里更新控件原理相通但代码会更啰嗦。5.5 寄存器值为负数或巨大数值类型解析与数据格式不符最后一个高频坑是数据类型不匹配。Modbus寄存器本身没有类型概念就是16位二进制数同样的0xFFFF如果按无符号整数解析是65535按有符号整数解析是-1按两个寄存器拼成浮点数则可能是一亿多。上位机读取后如何解析必须严格按设备手册的数据格式说明来做。现象常见于读取PLC里用32位双字存储的累计量只读了一个寄存器导致数据截断或者设备数据实际是BCD码格式却按二进制整数解析得到类似“0x1234”解析成4660而不是1234。解决办法就是在配置表的数据类型字段里明确标注每个点的解析方式比如“UInt16”“Int16”“Float32高字在前”“BCD16”。在做Winform界面时通常还要配合一个数据点类型下拉框让现场人员可以不改代码直接调整解析规则。6. 验证通讯正确性的三个习惯报文日志、模拟从站与心跳策略源码写完不等于通讯可靠真正的考验在调试阶段。我做的第一件事永远是在源码里加报文日志层。无论用NModbus还是HslCommunication都会在发送请求和接收响应的位置埋一个日志钩子把原始字节以十六进制格式输出到本地文件。没有这一步出了问题就只能靠猜——设备手册、代码逻辑、第三方模拟器各说各话根本定位不到是发送错还是接收错。日志格式很简单时间戳加收发方向加字节数组比如“2025-01-12 10:23:45 TX: 01 03 00 00 00 0A C5 CD”一眼就能和Modbus报文规范做逐字节对照。第二个习惯是准备一套模拟从站环境。Modbus Poll和Modbus Slave这一对工具前者模拟主站发请求后者模拟从站返回数据是抓协议的利器。我在没有真实设备时先用Modbus Slave虚拟一批寄存器数据值固定成容易识别的十六进制数比如0x1234、0xABCD然后用自己写的Winform源码去读取。读出来的结果如果和预期完全一致说明通讯链路和解析逻辑没有大问题如果不一致对照日志和模拟器界面就能快速定位字节序或地址偏移的差异。现场调试时这套方法也适用于先验证PC侧再接入PLC。第三个习惯是给所有读操作设置合理的超时与重试次数。串口通讯用1000毫秒超时是底线TCP稍长一点500到1000毫秒都行。如果第一次读超时不要立刻判定设备故障可能是RS485总线繁忙或PLC扫描周期没完成。重试1到2次后仍失败再报错这样既不会漏掉瞬时故障也不会把偶发超时误报成停机。重试之间加50到200毫秒间隔给串口和网络留出清空缓冲的时间。最后说一个我自己的教训。以前做一套温度采集界面运行时经常出现某个数据点偶尔跳变排查了很久最后发现是日志文件在大量写入的时候拖慢了UI线程导致通讯超时后的重试逻辑和界面刷新逻辑互相争抢资源。后来把日志写入改成后台队列异步落盘问题才彻底消失。从那以后凡是涉及通讯的项目我第一版就会把日志、心跳、异常重试和UI刷新彻底分层绝不在界面事件里直接做耗时操作。这算是一条能少走很多弯路的老经验希望帮到你。本文还有配套的精品资源点击获取