简介:面向工控上位机开发初学者与自动化工程师的C# ModbusTcp通信实战资料,聚焦C#与西门子S7-1200 PLC之间的数据读写与联调问题。压缩包共46个文件,以13个C#源码、4个XML配置、3个压缩包和3个可执行程序为主,并包含解决方案、项目配置、资源文件及说明文本,整体约13.02MB,工程结构较完整,便于按模块查看。已有4316人学习下载,具备一定参考热度。内容围绕ModbusTcp功能码展开,覆盖线圈与保持寄存器的读写、PLC地址映射、Socket直连方式及NModbus库使用思路,也涉及超时、连接失败等异常处理与重试策略,可作为通信Demo、课程设计或现场排错时的对照材料。
1. 一条不用装驱动的捷径:C# 走 ModbusTcp 与 S7-1200 通讯
产线试产那天,上位机要显示十几路温度、压力,还要下发两个配方参数。PLC 那边已经跑起来了,程序是用梯形图写的,之前谁也没在 PC 侧连过它。真正让人头疼的不是写界面,是协议:S7 协议要装库、要做 PDU 协商、要处理各种握手,一套下来光调通就得小半天。后来换成 C# ModbusTcp 直连西门子 PLC,PLC 侧组态一个服务端指令,PC 侧一个 Socket 加一个开源库,半小时就跑通了第一条读数。
这套方案适合谁?适合手上有一台 S7-1200、需要 PC 侧做数据采集或参数下发、又不想引入重协议栈的开发者。它不能替代全部 S7 功能,但在"读几十个寄存器、写几个设定值"这个范围内,性价比极高。下面从协议对齐讲到代码落地,再讲我踩过的几个坑。
2. 协议对齐:功能码、寄存器区与 S7-1200 的地址映射
在写第一行 C# 代码之前,得先把两边的"坐标系"对上。Modbus 和 S7 是两套完全独立的地址语言,错一位,读回来的数就是隔壁变量的值,而且不报错,这种错误最难查。这一章把帧结构、寄存器区、地址偏移和数据类型四件事讲透,后面写代码才不会返工。
2.1 MBAP 报文头:Modbus TCP 和串口 Modbus 的分水岭
很多人从 RTU 转过来,第一反应是找 CRC 校验,结果发现 TCP 版本里根本没有校验字节。原因在于 Modbus TCP 把 RTU 的地址域和校验域全砍了,换成 7 字节的 MBAP 头:事务标识符 2 字节、协议标识符 2 字节、后续长度 2 字节、单元标识符 1 字节。后面才是 PDU,也就是功能码 + 数据。
事务标识符是这里唯一需要你关心的东西。客户端每发一帧就自增一次,服务端回包时原样带回,客户端靠它把响应和请求配对。如果自己用 Socket 手撸协议,这个字段忘了自增或者复用了同一值,就会出现"读 A 寄存器的结果被当成了 B 寄存器的响应",数据看起来正常但实际全错位。用现成库的话,库内部会处理,这也是我建议新手别手写帧的原因。
协议标识符固定为 0,长度字段表示"单元标识符 + PDU"的字节数——注意它不包含前面 6 个字节。单元标识符在 Modbus TCP 里理论上用于区分网关后面的串口从站,直连 S7-1200 时它没有实际路由意义。
2.2 四个寄存器区里,S7-1200 只给你一个
Modbus 标准定义了四个数据区:线圈(0x,可读可写位)、离散输入(1x,只读位)、输入寄存器(3x,只读 16 位)、保持寄存器(4x,可读可写 16 位)。功能码 01/02/03/04/05/06/15/16 分别对应它们的读写。
S7-1200 的标准 Modbus TCP 服务端指令 MB_SERVER,映射的核心是保持寄存器区,通过一个参数指向 PLC 内部的存储区。也就是说,功能码 03(读保持寄存器)、06(写单个寄存器)、16(写多个寄存器)是主力,05 和 15 这类线圈操作不在它的直接映射范围里。现场常见的变通做法是把位状态打包进寄存器:一个 WORD 存 16 个布尔量,客户端读回来自己按位拆,写的时候按位拼。这比硬去找线圈支持省事得多,也更好维护,因为位和寄存器的对应关系在文档里写清楚就行。
还有一点容易被忽略:保持寄存器的数量受你分配给它的 DB 块大小限制。声明了 100 个 WORD,就只能访问 0~99 这段偏移,超出范围服务端要么返回异常码 02(非法数据地址),要么直接不响应。所以设计阶段就要把点位表定下来,别等到联调时才发现不够用。
2.3 那个差 1 的偏移:40001 和 0 基址
这是新人翻车率最高的一处。Modbus 的经典编号体系里,保持寄存器从 40001 开始数,是 1 基址的;而协议报文里传的起始地址是 0 基址。也就是说,文档上写的 40001,报文里要填 0;40011 要填 10。
不同库的处理方式还不统一。有的库 API 接受"经典编号",内部帮你减一;有的库直接传协议地址。我一般做法是:在代码里统一用 0 基址,然后在点位表里额外标注一列"文档编号",两套编号并排写,交接给同事时不会误解。这条规矩看着笨,但能省掉无数次"为什么第一个寄存器永远读不到"的排查。
| 文档常见写法 | 协议报文地址 | NModbus 传参 | 含义 |
|---|---|---|---|
| 40001 | 0 | 0 | 保持寄存器第 1 个 |
| 40010 | 9 | 9 | 保持寄存器第 10 个 |
| 40101 | 100 | 100 | 保持寄存器第 101 个 |
2.4 库选型:三个常见方案的实际差别
C# 侧能用的 Modbus 库不算少,我实际用过三类。
第一类是 NModbus,也是从早期 NModbus4 延续下来的主流选择。它的抽象做得比较完整,串口和 TCP 共用一套 master 接口,同步异步方法都有,源码可读,出问题能跟进去。缺点是 API 风格偏老派,异步支持是后补的,不是原生 async 设计。
第二类是 EasyModbusTCP,上手最快,一个类就够,方法名直白。缺点是它对连接状态的管理比较粗,断线重连要自己包一层,而且长期没人维护的版本在一些新 .NET 运行时上会有兼容问题。
第三类是 FluentModbus,接口设计现代,支持 Span 和真正的异步,性能取数场景下更舒服。生态相对小一些,遇到冷门问题可查的资料少。
我的取舍是:业务系统、要和其它模块共存、需要长期维护的,选 NModbus;只是做个临时工具、验证点位、给现场调试用,EasyModbus 更快。选定之后别中途换,因为不同库对字序、地址基址的默认处理不一样,换库等于把所有点位重验一遍。
3. 用 C# 写一个能跑的客户端:连接、读、写、断线重连
协议层面清楚了,接下来落到代码。这一章给出的是一套可以直接改 IP 就用的骨架,包含连接、读、写、类型解析和一层轮询封装。工程用 .NET 6 以上的控制台或 Worker Service 都行,我用控制台演示,逻辑搬到后台服务里是一样的。
3.1 建工程与引入依赖
# 建一个控制台工程,.NET 6/8 均可 dotnet new console -n PlcBridge cd PlcBridge # 引入 NModbus,它同时支持串口与 TCP dotnet add package NModbus包名就是 NModbus,不要去找 NModbus4,后者是旧分支,新工程没必要。装完之后引用命名空间是NModbus,核心入口是ModbusFactory,由它创建 master 实例。之所以强调这点,是因为网上大量示例还是 NModbus4 的写法,命名空间是Modbus.Device,直接抄会编译不过,这是第一个小坑。
3.2 建立连接与超时参数
using System.Net.Sockets; using NModbus; const string PlcIp = "192.168.0.10"; // PLC 的 IP,按实际网段改 const int PlcPort = 502; // Modbus TCP 标准端口 const byte UnitId = 1; // 单元标识,直连时一般不校验 using var tcp = new TcpClient(); // 连接本身也要有超时,否则网线拔了会卡在默认的 20 秒以上 var connectTask = tcp.ConnectAsync(PlcIp, PlcPort); if (!connectTask.Wait(TimeSpan.FromSeconds(3))) throw new TimeoutException($"连接 {PlcIp}:{PlcPort} 超时"); var factory = new ModbusFactory(); var master = factory.CreateMaster(tcp); // 这三个参数决定断线时你等多久才知道出事了 master.Transport.ReadTimeout = 1000; // 单次读超时,毫秒 master.Transport.WriteTimeout = 1000; // 单次写超时,毫秒 master.Transport.Retries = 0; // 不重试,重试交给上层控制参数逐个说。ReadTimeout和WriteTimeout是同步 API 的等待上限,设太大会让整个采集线程被一次故障拖住,设太小会在网络抖动时误判。1 秒是我在车间网络下的常用值,如果你走的是跨网段或者无线桥接,可以放到 2 秒。
Retries设为 0 是个刻意的选择。库自带的重试是在同一连接上立刻重发,如果对端已经复位了连接,重试只会浪费时间还掩盖故障。我把它关掉,由外层循环决定"这次失败,隔 200 毫秒重建连接再试",语义更清楚。
UnitId这一项,S7-1200 的 MB_SERVER 通常不做严格校验,填 1 就行。但如果中间串了 Modbus 网关或者串口服务器,这个值必须和从站号对上,否则网关不会转发。现场遇到过读所有点位都超时、最后发现是网关要求单元号 17 的情况,血泪经验。
3.3 读保持寄存器并解析成业务值
// 从 0 基址开始读 8 个保持寄存器 ushort[] regs = master.ReadHoldingRegisters(UnitId, 0, 8); // 整型:WORD 直接当 ushort 用,INT 需要按有符号解释 ushort rawTemp = regs[0]; // 无符号 short sTemp = unchecked((short)regs[0]); // 有符号,同一份数据 // 浮点:两个寄存器拼一个 Real,西门子是大端,高字在前 static float RegsToReal(ushort hiWord, ushort loWord) { Span<byte> buf = stackalloc byte[4]; System.Buffers.Binary.BinaryPrimitives .WriteUInt16BigEndian(buf[..2], hiWord); System.Buffers.Binary.BinaryPrimitives .WriteUInt16BigEndian(buf[2..], loWord); return System.Buffers.Binary.BinaryPrimitives.ReadSingleBigEndian(buf); } float pressure = RegsToReal(regs[2], regs[3]);读方法三个参数:单元标识、起始地址(0 基址)、寄存器数量。返回值是ushort[],长度等于你请求的数量。
解析部分要重点讲。regs[0]拿到的是 16 位无符号数,如果 PLC 侧变量声明成 INT(有符号),你需要用unchecked强转成short,否则负温度会显示成六万多。
浮点是另一个陷阱。S7 的 Real 占 4 字节,是 IEEE754 单精度大端;Modbus 保持寄存器是 16 位一个,所以一个 Real 要占两个寄存器。按标准排布,第一个寄存器是高 16 位,第二个是低 16 位,直接按大端拼回去即可。但现场有相当比例的工程,因为 PLC 侧指针偏移、或者第三方网关做了字交换,实际是高字在后。判断方法很简单:读一个已知值(比如 25.5),如果算出来的结果量级离谱,比如 1.5E-38 这种,八成就是字序反了,把两个参数对调再试。别用BitConverter.ToSingle(byte[])直接转,那是按机器字节序解释的,在 x86 上会得到错误结果,必须显式指定大端。
字符串的处理稍微特殊。S7 的 String 类型前两个字节是最大长度和当前实际长度,后面才是字符。跨 Modbus 读的时候,一般约定只传字符部分,按每两个字符一个寄存器打包。我一般会在 PLC 侧先把它转成Array of Char或者干脆用定长字节数组,绕开 S7 String 的头部,这样两边都好处理。
3.4 写入与批量写
// 写单个寄存器:功能码 06 master.WriteSingleRegister(UnitId, 10, (ushort)1500); // 写多个寄存器:功能码 16,一次最多几十个,别超 MB_HOLD_REG 长度 ushort[] recipe = { 120, 300, 45, 800 }; master.WriteMultipleRegisters(UnitId, 20, recipe);WriteSingleRegister适合改一个设定值,比如启停命令、目标温度。WriteMultipleRegisters适合下发配方这类一组参数,好处是原子性更好——一次事务写完,PLC 侧不会看到改了一半的中间态。
一个实操细节:写多个寄存器时,一次请求的寄存器数量不要贪多。一方面 MB_SERVER 对单帧长度有上限,另一方面写太长会挤占采集周期。我一般按 8 到 16 个一组切分,既不会太碎,也不会一帧占用太久的连接。
还有一点,写操作和读操作共用同一个 master 实例时,如果多线程同时调用,内部是可能串包的。NModbus 的 master 不是线程安全的,常见做法是加锁,或者干脆把读和写放到同一个采集线程里串行执行。后者更简单,也更容易定位问题。
3.5 包一层轮询服务
record PlcSnapshot(DateTime Ts, float Temp, float Pressure, ushort Status); // 采集循环:失败就重建连接,不要在同一条坏连接上死磕 async Task LoopAsync(CancellationToken ct) { var factory = new ModbusFactory(); TcpClient? tcp = null; IModbusMaster? master = null; while (!ct.IsCancellationRequested) { try { if (tcp is null || !tcp.Connected) { tcp?.Dispose(); tcp = new TcpClient(); await tcp.ConnectAsync(PlcIp, PlcPort, ct); master = factory.CreateMaster(tcp); master.Transport.ReadTimeout = 1000; master.Transport.WriteTimeout = 1000; master.Transport.Retries = 0; } var regs = await master!.ReadHoldingRegistersAsync(UnitId, 0, 8); var snap = new PlcSnapshot(DateTime.Now, RegsToReal(regs[0], regs[1]), RegsToReal(regs[2], regs[3]), regs[4]); // 交给上层缓存或推送,别在这里做耗时业务 _ = snap; await Task.Delay(200, ct); // 200ms 采集周期,按需要调整 } catch (Exception ex) when (ex is IOException or SocketException or TimeoutException) { // 连接层异常:丢弃连接,下一轮重建 master = null; tcp?.Dispose(); tcp = null; await Task.Delay(500, ct); } } }这段循环的核心思想是"连接是一次性的"。任何一次 IOException、SocketException 或者超时,都直接判定这条连接不可信,销毁重建。比起在坏连接上反复重试,重建的代价其实很小,而且状态干净。
采集周期 200 毫秒是个折中值。太快,PLC 侧的通信任务会占用扫描周期,影响控制逻辑;太慢,趋势曲线会显得锯齿。如果只是做看板,1 秒都够;要做闭环或者报警,200 毫秒以内比较稳妥。
await用的是异步 API,注意 NModbus 的异步方法在某些版本里是同步阻塞实现的包装,不要以为用了 await 就一定不阻塞线程。真正的并发压力测试,还是要看实际 CPU 占用。
4. PLC 侧组态:让 MB_SERVER 在 502 端口上监听
客户端写完了,接下来是另一半:PLC 侧要让服务跑起来。这一步在 TIA Portal 里完成,本质是三件事——准备一块连续的数据区、调用服务端指令、把两者用指针连起来。这三件事里最容易出问题的是数据区,因为"优化的块访问"这个默认选项会让寄存器地址变得不连续。
4.1 建一块非优化的保持寄存器数据块
新建一个全局 DB,命名随意,比如 DB_ModbusRegs。里面按点位表定义变量:温度占 2 个 WORD(一个 Real),压力占 2 个,状态字占 1 个。但是,不能直接定义 float,因为这个块要按绝对地址被外部访问。
关键操作是在 DB 属性里把"优化的块访问"取消勾选。勾选状态下,变量地址由编译器动态分配,没有固定的偏移量,MB_SERVER 拿不到连续的地址空间,读回来会是全 0 或者乱值。取消勾选后,每个变量都会显示偏移量,比如 Temp 在 0.0,Pressure 在 4.0,Status 在 8.0。
然后在块里再放一个Array[0..99] of Word,作为对外暴露的原始寄存器窗口。所有需要跨 Modbus 访问的量,先在程序里换算好,再搬进这个数组。这种做法的好处是:对外接口是一块稳定连续的区,内部变量怎么改都不影响通讯,接口和实现解耦。
| 元素 | 类型 | 偏移 | 对应 Modbus 地址 |
|---|---|---|---|
| Regs[0..99] | Array of Word | 0.0 | 0 ~ 99 |
| 内部 Temp | Real | 100.0 | 不直接暴露 |
| 内部 Status | Word | 104.0 | 不直接暴露 |
4.2 调用 MB_SERVER 与 CONNECT 参数
服务端指令在指令树的"通信"分类下,名称是 MB_SERVER。它需要在主循环块里被调用,通常放在 OB1 的末尾,或者放在一个固定周期的循环中断里。建议用循环中断,周期固定,通讯时序更可控。
指令的引脚里,最重要的三个是 CONNECT、MB_HOLD_REG 和 REQ。
CONNECT 指向一个连接描述结构,类型是TCON_IP_v4,需要单独建在全局 DB 里。这个结构的参数决定了监听行为:
| 参数 | 取值 | 说明 |
|---|---|---|
| InterfaceId | PLC 以太网口硬件标识符 | 在设备组态的硬件属性里能查到 |
| ID | 1 | 连接编号,同一 PLC 内唯一 |
| ConnectionType | 11 | 表示 TCP |
| ActiveEstablished | FALSE | 被动监听,作为服务端 |
| RemoteAddress | 0.0.0.0 | 接受任意客户端 |
| RemotePort | 0 | 被动模式下不限定对端端口 |
| LocalPort | 502 | Modbus TCP 标准端口 |
MB_HOLD_REG 填"DB_ModbusRegs".Regs,也就是那个 Word 数组的首地址。REQ 一般给常 TRUE,让指令每个周期都响应请求;DISCONNECT 给 FALSE 保持连接。
提示:如果 LocalPort 改成非 502 的端口,客户端那一侧也要同步改,否则会一直有连接被拒的现象,而且报文层看不到任何异常码,排查时容易误判成防火墙问题。
4.3 数据类型在块里的落位
数据进数组之前要先做类型转换,这一步决定了客户端解析代码怎么写。
Real 转成两个 Word,可以用DWORD中转:先把 Real 的位模式读出来,再拆高低字。S7-1200 里没有直接的"取位模式"指令,常见做法是用MOVE到 DWORD 再按字取,或者用SCL写一段。SCL 更直观:
// SCL 里把 Real 拆成两个 Word 写进寄存器数组 #dwTemp := DWORD#(#rTemp); // 按位重新解释 "DB_ModbusRegs".Regs[0] := WORD#(#dwTemp SHR 16); // 高字 "DB_ModbusRegs".Regs[1] := WORD#(#dwTemp AND 16#FFFF); // 低字反过来,客户端写下来的两个寄存器,在 PLC 侧要拼回 Real,用SHL和OR组合。这段逻辑务必写在通讯处理块里,别散落在各个工艺程序中间,否则以后加点位找都找不到。
按位打包的布尔量也一样:状态字 Regs[4] 的第 0 位表示运行、第 1 位表示故障、第 2 位表示就绪。PLC 侧用位操作写,C# 侧读回来用(status & 0x01) != 0判断。约定写进接口文档,两边都照着来。
4.4 先用调试工具验一遍再写代码
我强烈建议的顺序是:PLC 程序下载完之后,先别急着跑 C#,用一个现成的 Modbus 调试工具连上去,读一遍寄存器。能看到变化的数值,说明 PLC 侧组态是对的,接下来所有问题都在客户端代码里,排查范围直接砍一半。
反过来,如果调试工具都读不到,那就别浪费时间看 C# 代码了,回去检查 MB_SERVER 是不是真的在跑、CONNECT 的端口是不是 502、DB 块是不是非优化。这个"先验一半"的习惯,能省下大量在两边同时猜的时间。
5. 避坑与排查:连接失败、数据错位、偶发超时的现场记录
这一章是我这几年在联调现场记下来的五条,每条都按现象、原因、解决三段写。这些问题有个共同点:报错信息极少,甚至完全不报错,只能靠逐步缩小范围。
5.1 现象:ping 得通,但 502 端口连不上
原因:MB_SERVER 指令没有被真正执行,或者下载了硬件组态但没下载程序块。ping 走的是网络层,跟服务端指令跑不跑没关系,所以 ping 通完全不能说明通讯就绪。
解决:打开 TIA 在线监控,看 MB_SERVER 的 STATUS 引脚。正常监听时它应该显示无错误。如果 STATUS 有非零值,按帮助文档里的错误码表查。另外确认指令所在的组织块确实在循环执行,放在一个没被调用的 FC 里是不会跑的。
5.2 现象:能连上,读回来全是 0,或者值永远不变
原因:MB_HOLD_REG 指向的数组所在 DB 仍然是"优化的块访问"。这种块没有固定偏移,指令拿到的是个无效地址,返回的数据自然没有意义。
解决:右键 DB 属性,取消"优化的块访问",编译并重新下载。下载时注意要下载整个块,只下载增量有时候不生效。改完之后在 DB 里应该能看到每个变量的偏移量。
5.3 现象:单个点位正常,多个点位一起读就错位
原因:起始地址用了 1 基址,而库用的是 0 基址,或者读的数量超过了 MB_HOLD_REG 的长度,服务端对越界部分返回异常,而客户端把异常响应当成了正常数据。
解决:统一用 0 基址并写进注释,同时在客户端加一道校验——请求的起始地址加数量不能超过数组长度,超了直接抛异常而不是发出去。越界这种情况,宁可本地拦掉,也不要让它在网络上来回猜。
5.4 现象:浮点数读出来是 1.5E-38 这种量级
原因:高低字顺序和预期相反。PLC 侧的 DWORD 拆分方式、指针偏移、或者中间网关做了字节交换,都会导致这个结果。这不是精度问题,是位模式整体错位。
解决:拿一个已知值(比如设定 25.5)做基准,把两个寄存器参数对调再算一次。对上了就固定下来,并在接口文档里写明"高字在前 / 低字在前"。别靠猜,一定要用已知值验证。
5.5 现象:跑几个小时之后 SocketException,程序线程卡死
原因:没有设置读写超时,或者设置了但异常没被捕获。PLC 侧连接被复位(比如重新下载程序、网络抖动)之后,客户端还挂在旧连接上等一个永远不会来的响应。
解决:读写超时都设上,异常统一捕获后销毁连接、重建,重建之间加几百毫秒退避。同时给整个采集循环加一个外部看门狗,比如记录最后一次成功通讯的时间,超过 30 秒没有任何成功记录就记一条告警日志,这样至少能知道是什么时候断的。
6. 稳定性进阶:心跳、批量读与十分钟压测清单
前面几章跑通的是"能读能写",但真正上产线,要的是"连续跑一周不出事"。我一般会在联调的最后一步做三件事,成本不高,但能提前暴露八成问题。
第一件是加心跳。在寄存器区里留一个 WORD 专门做心跳,PLC 侧每个扫描周期自增一次(或者每 100 毫秒翻转一次)。客户端采集时顺带读它,如果连续若干次读到的值完全没变,就判定 PLC 侧程序停了或者通讯链路其实没通——注意,这种情况下 TCP 连接可能是好的,读请求也返回成功,只是数据是陈旧的。这个检测能抓到那种"看着一切正常、其实数据冻结"的隐性故障。
第二件是合并请求。新手常犯的错是一个点位一次读请求,十个点位发十帧。改成一次读连续的一段,再把结果按偏移切开,帧数直接降到一帧。这样做的边界是:MB_HOLD_REG 里的点位要尽量排布紧凑,别把温度放 0 号、压力放 200 号,中间空一大片。设计点位表的时候就把"同一业务模块的变量放一起"作为一条规则,后面省事。
第三件是压测。我会连续跑十分钟以上,观察三件事:CPU 占用有没有缓慢爬升(可能是连接没释放)、失败重连次数有多少(正常应该接近 0)、采集周期有没有越来越长(可能是某个异常分支没走对)。下面这张表是我常用的自检项。
| 检查项 | 合格标准 | 不合格时的常见原因 |
|---|---|---|
| 连续 10 分钟失败次数 | 0 ~ 2 次 | 网络抖动、连接未复用 |
| 进程内存 | 波动后回落,不单调上升 | TcpClient 未 Dispose |
| 采集周期抖动 | 平均值 ±30% 以内 | 同步阻塞调用混在异步链路里 |
| PLC 侧连接资源 | 稳定,不随运行时间增长 | 重连时旧连接未正常关闭 |
最后说个习惯问题。我最早做这类通讯的时候,图省事把所有逻辑写在一个 while 循环里,读、解析、写库、推界面全混在一起,结果一次超时把整个界面卡住,现场以为程序崩了。从那以后我每次做 PLC 通讯,都强制走一遍"连接层 / 解析层 / 业务层"的三层拆分,连接层只管收发和重连,解析层只做字节到值的转换,业务层拿到的是干净的对象。这样出问题时能立刻定位是哪一层,也不会因为一次网络抖动拖垮整个应用。上面那套骨架代码,改改 IP 和点位偏移就能直接用,建议你先在测试机上把心跳和压测跑完,再上现场。希望帮到你。
本文还有配套的精品资源,点击获取