西门子 S7-1200/1500 是长三角产线保有量最大的 PLC,上位机直连它最省事的方式不是 OPC、也不是 Modbus,而是走原生的 S7 通信协议(ISO-on-TCP,102 端口):不用在博图里写任何通信程序、不用额外授权,一个 NuGet 包就能读写 DB 块。但"能连上"和"用得对"之间隔着一堆经典坑:优化块访问、PUT/GET 权限、地址偏移、字符串布局、点式读取的性能。这篇用 S7.NetPlus 把这些坑一次填平。

一、三条路线怎么选:S7 直连 / OPC UA / Modbus TCP

对比项S7 协议直连(S7.NetPlus)OPC UAModbus TCP
PLC 侧配置勾两个选项即可,不写通信程序要配服务器/证书/接口要写 Modbus_Server 程序块、规划保持寄存器
数据模型按 DB 绝对地址/偏移访问,类型自管自描述节点树,结构体原生支持寄存器拼接,全靠点表约定
安全性无加密,靠网络隔离 + PUT/GET 白名单证书加密认证,可跨网无安全机制
适用机型S7-300/400/1200/1500(非优化DB)1500 及新型号原生全系列(占用程序资源)
典型场景单台/少数几台西门子 PLC 的产线级上位机多品牌混线、厂级平台跨品牌仪表/老设备统一轮询

▲ 经验法则:车间内网、PLC 全是西门子、点位集中在几个 DB 块——直接 S7 协议,开发量最小。多品牌、要跨网段或上云——走 OPC UA(见此前 OPC UA 一篇)。

二、报文长什么样:三层封套,理解了排错不慌

抓包时 Wireshark 里看到的 S7 通信是三层套起来的,S7.NetPlus 全部帮你封装,但出问题(连不上、读写报错)时要知道卡在哪层:

① TPKT(ISO-on-TCP,TCP 102)最外层,4 字节,标明版本和整包长度。Telnet 不通 102 端口 = 防火墙/IP/机架号问题,与上层无关。
▼ 包裹
② COTP(ISO 8073)连接建立层,Connect Request 里带机架号 Rack 和槽位号 Slot。S7-1200/1500 通常 Slot=1,S7-300 通常 Rack=0/Slot=2。槽位错表现为 TCP 通但连接被拒。
▼ 包裹
③ S7 Communication真正的业务层:Setup Communication(协商 PDU 长度,常见 240/480 字节)、Read/Write Var、作业读出的 Job/AckData 应答,含返回码(0xFF=OK,0x04=地址错误,0x05=数据类型不匹配)。

▲ 常见错误码对号入座:0x80/0x81 对象不存在(DB 号或偏移越界)、0x84 此类型不支持(优化块没关)、0x85 地址越界。看到 AckData 里的 ErrorClass/ErrorCode,比库抛出的英文异常更直接。

三、PLC 侧两项必改设置(90% 的"连不上"在这)

  1. 关闭"优化的块访问":在博图里右键要读的 DB 块 → 属性 → 特性 → 取消勾选"优化的块访问",然后重新下载。S7 协议走绝对地址(DB1.DBW0 这种),优化块没有固定偏移,开着优化读出来的就是 0x84 报错或全零。建议给上位机单独建通信 DB,全程关闭优化,工艺块保持优化;
  2. 允许 PUT/GET:PLC 属性 → 保护与安全 → 连接机制 → 勾选"允许来自远程伙伴(PLC、HMI、OPM…)的 PUT/GET 通信访问"。不勾,TCP 能连上但读写一律被 PLC 拒绝;
  3. IP 与上位机同网段、关闭或放通 PLC 侧防火墙的 102 端口;S7-1500 还需确认访问级别不是"完全保护(不允许读写)"。

四、地址映射表:DBX/DBB/DBW/DBD 一次记牢

地址写法含义长度/字节序C# 类型
DB1.DBX0.0DB1 第 0 字节第 0 位(Bool)1 bitbool
DB1.DBB1DB1 第 1 字节1 字节byte
DB1.DBW2DB1 从第 2 字节起的字2 字节,大端short / ushort
DB1.DBD4DB1 从第 4 字节起的双字4 字节,大端int / uint / float
DB1.DBD864 位双字长8 字节long / double
DB1.DBB12S7 字符串(如 String[10])头部2字节:最大长度+实际长度,后跟字符[S7String(10)] string
DBW0Int16 产量
偏移 0~1
DBW2Int16 不良数
偏移 2~3
DBD4Real 温度
偏移 4~7
DBB8..String[16]
8=最大 9=实际

▲ 注意偏移不能重叠:DBW0 占 0、1,下一个字至少从 2 开始;Real 占 4 字节建议偶数对齐。博图 DB 块的"偏移量"列是最终裁判,C# 结构体字段顺序必须和它完全一致。

大端序是手工解析字节时的头号坑。S7 协议多字节数据是大端(高字节在前),Windows x86 是小端。用库的 Read/ReadStruct 没问题;一旦自己 ReadBytes 再 BitConverter.ToInt16,出来的数值会变成天文数字——要用 BinaryPrimitives.ReadInt16BigEndian。

五、C# 连接与基础读写

NuGet 安装 S7.NetPlus(开源免费,支持 .NET Framework 4.6.1+ 与 .NET 6/8)。最小连接:

using S7.Net;
using S7.Net.Types;

var plc = new Plc(CpuType.S71500, "192.168.1.10", rack: 0, slot: 1);
plc.Open();
if (plc.IsConnected)
{
    short output = (short)plc.Read("DB1.DBW0");     // 读Int16
    float temp   = (float)plc.Read("DB1.DBD4");     // 读Real
    bool  running = (bool)plc.Read("DB1.DBX20.0");  // 读位
    plc.Write("DB1.DBW10", (short)1200);              // 写Int16
}
plc.Close();

生产代码不能这么裸用:Open 可能抛异常、连接随时可能断。包一层带重连和读失败计数的客户端:

public class S7Client : IDisposable
{
    private readonly Plc _plc;
    private readonly object _gate = new();
    private int _failStreak;

    public S7Client(string ip) =>
        _plc = new Plc(CpuType.S71500, ip, 0, 1) { ReadTimeout = 2000, WriteTimeout = 2000 };

    private bool EnsureOpen()
    {
        lock (_gate)
        {
            if (_plc.IsConnected) return true;
            try { _plc.Open(); _failStreak = 0; return true; }
            catch (Exception ex)
            {
                if (++_failStreak % 10 == 1) Log.Warning($"PLC连接失败#{_failStreak}:{ex.Message}");
                return false;        // 不向上抛,采集循环按坏点处理
            }
        }
    }

    public bool TryRead<T>(string addr, out T value)
    {
        value = default;
        if (!EnsureOpen()) return false;
        try
        {
            value = (T)_plc.Read(addr);
            _failStreak = 0;
            return true;
        }
        catch (Exception ex)
        {
            Log.Warning($"读{addr}失败:{ex.Message}");
            try { _plc.Close(); } catch { }   // 强制下次重建连接
            return false;
        }
    }
}

▲ 采集循环对 TryRead 返回 false 的点打"坏质量"标记而不是停循环——和报警、趋势两篇的数据质量约定一致。

六、结构体映射 + 整块读取:一次往返拿全部点

点式 Read 每个地址一个请求包,100 个点就是 100 次网络往返(内网单次约 1~5ms,累计很可观)。正确做法是把同一扫描周期要用的点排进一个通信 DB,一次把整块读上来,再用 ReadClass 映射成结构体:

// C#侧:字段顺序、类型必须与博图非优化DB的偏移列严格一致
public class LineStatus
{
    public short OutputCount;      // DBW0
    public short NgCount;          // DBW2
    public float Temperature;      // DBD4
    public float Pressure;         // DBD8
    [S7String(16)]
    public string ProductCode;     // DBB12:S7 String[16],共占18字节
}

// 一次请求读整块(长度按DB实际大小,PDU不足时库自动分包重组)
var status = plc.ReadClass<LineStatus>(db: 1, startByteAddress: 0);
Log.Information($"产量={status.OutputCount} 温度={status.Temperature:F1} 型号={status.ProductCode}");

// 下发参数同样整块写:填好结构体一次 WriteClass,避免几十个点写一半的中间态
var param = new RecipeParam { TargetTemp = 85.5f, Speed = 1200, ProductCode = "A12-2609" };
plc.WriteClass(param, db: 2, startByteAddress: 0);
读法(120 个点,内网实测量级)网络往返耗时量级
逐点 Read120 次请求/应答200~600ms,周期很难压进 200ms
ReadBytes 整块 + 映射1 次(PDU 内)或少数几次3~15ms
通信 DB 布局建议:一个"状态 DB"只读(PLC 写、上位机读),一个"命令/参数 DB"上位机写。按扫描周期分组,200ms 周期要的点放第一个块,1 秒周期的点放第二个块,避免为了几个慢变量每 200ms 读一大块。改点表 = DB 加字段 + 结构体加字段,两边偏移对齐后重新下载。

七、写控制必须握手:永远不要直接写 Q 点

上位机直接写 M 位或 Q 输出去启停设备,丢包、重发、重启都可能造成误动作。规范做法是命令 DB 里做三拍握手,执行权始终在 PLC:

上位机PLC
1 请求写命令码 + 参数 + Req 计数 +1主循环检测 Req≠Ack
2 执行等待校验安全联锁条件 → 执行动作 → Ack=Req + 写结果码
3 确认读到 Ack==Req 与结果码,清命令码命令码被清零后回到空闲
public async Task<bool> SendCommandAsync(ushort code, Dictionary<string,object> p,
                                          CancellationToken ct)
{
    ushort req = (ushort)((ushort)_plc.Read("DB2.DBW30") + 1);  // 读旧Req,+1
    WriteParamsBlock(p);
    _plc.Write("DB2.DBW28", (ushort)code);
    _plc.Write("DB2.DBW30", req);                        // Req最后写,PLC看到即新命令

    var deadline = DateTime.Now.AddSeconds(5);
    while (DateTime.Now < deadline)
    {
        ushort ack = (ushort)_plc.Read("DB2.DBW32");
        if (ack == req)
        {
            short result = (short)_plc.Read("DB2.DBW34");
            _plc.Write("DB2.DBW28", (ushort)0);             // 清命令码
            return result == 0;                           // 0=PLC侧执行成功
        }
        await Task.Delay(100, ct);
    }
    return false;   // 超时:PLC联锁条件不满足或未执行,绝不能重发写Q点式操作
}
用计数器而不用 Bool 做握手。Bool 边沿在断线重连后无法判断"这条命令到底执行过没有";Req/Ack 用累加计数,上位机重启后读出当前值就能对齐,天然幂等。真正的安全联锁(急停、防护门)仍然必须做在 PLC 安全程序里,上位机命令只是"请求"。

八、现场高频坑清单

  1. 读出来全是 0 或 0x84:90% 是优化块访问没关、或改了 DB 没重新下载;
  2. TCP 通但 Open 卡住/被拒:槽位号不对(1200/1500 用 1)、PUT/GET 没勾;
  3. 数值差 256 倍/正负翻转:字节序或偏移错,对照博图偏移列逐字段核对;
  4. 字符串乱码或错位:S7 String 头两字节不能跳过,用 [S7String(长度)] 映射;WString(宽字符)和 String 是两回事,别混用;
  5. 多上位机/HMI 同时连:S7-1200 连接资源有限(个位数),同网段多客户端要走边缘网关收拢成一条连接,别每个程序各连各的;
  6. 轮询周期不要低于 PLC 循环周期:比 OB1 还快只会拿到重复值;200ms 是多数产线的合理起点;
  7. 不要在 UI 线程同步读写:网络抖动会让界面假死,采集放后台线程,值通过线程封送更新界面。

九、验收检查

  1. 博图在线监控与上位机界面对拍:每个字段给已知值,逐一比对数值与字符串,重点验 Int16、Real、String 三种类型;
  2. 拔网线 10 秒:TryRead 全部返回坏点、界面不乱跳;插回后 2 秒内自动恢复,无异常弹窗;
  3. 命令握手测试:在 PLC 联锁条件不满足时发启动命令,验证 5 秒超时返回失败且 PLC 不动作;连续发 20 条命令验证 Req/Ack 不丢不重;
  4. 压测:120 点整块读,200ms 周期连续跑 24 小时,CPU 占用、失败计数、连接数稳定;
  5. 断电演练:上位机与 PLC 分别重启,验证命令计数能重新对齐、无复位后误动作。

S7 直连方案的哲学是让 PLC 干控制,让上位机干数据:非优化通信 DB 做契约、整块读写做效率、计数握手做安全、坏质量标记做鲁棒。这套组合在单品牌西门子产线上,比 OPC 配置轻、比 Modbus 不用写 PLC 程序,是投入产出比最高的接法——等将来多品牌混线或要上云时,再在边缘把同一批数据翻译成 OPC UA,业务层一行都不用改。