简介:这份资源是面向工业自动化开发者与西门子PLC工程师的OPC UA客户端源码包,用于与S7-1200、S7-1500、S7-300、S7-400等系列PLC进行安全的数据交换。源码围绕连接管理、节点读写、数据订阅、错误处理及二进制编解码等核心环节展开,适合已具备一定C#基础、希望深入理解OPC UA通信机制并二次开发上位机或集成自动化系统的技术人员。压缩包共72个文件,约1.06MB,以29个cs源码文件为主体,辅以19个png界面截图、10个resx资源文件、5个dll依赖库及3个csproj工程文件,另含sln解决方案与可执行程序,结构完整便于直接编译调试。目前已有3385人学习下载。通过研读源码,读者可掌握客户端与S7 PLC建立连接、读写变量、订阅实时数据及异常处理的实现思路,并据此扩展为更复杂的工业自动化应用。
1. 西门子OPC UA 客户端(源码):从协议栈到产线数据落地的完整路径
产线数据采不上来,十有八九卡在协议这一层。PLC 侧数据明明在刷新,上位机就是读不到,或者读到了却对不上号——这种场景做工业数据采集的人都不陌生。西门子 OPC UA 客户端(源码)这个方向,解决的就是让上位机、边缘网关或数据平台以标准 OPC UA 协议,稳定读写西门子 S7-1200/1500 及部分 S7-300/400 系列 PLC 的变量。它适合三类人:做产线数据采集的自动化工程师、需要把 PLC 数据接入 MES 或数据库的软件开发者、以及想自研轻量采集网关替代商业组件的团队。源码在手,意味着你可以改超时、改重连策略、改订阅周期,而不是被商业组件的黑匣子行为卡住。
2. 先搞清楚 OPC UA 客户端在西门子体系里到底连什么
2.1 西门子 PLC 侧的服务端角色与端点差异
很多人第一次接触会混淆:OPC UA 客户端连的不是 PLC 本身,而是 PLC 里运行的 OPC UA 服务端。S7-1500 从固件 V2.0 起内置 OPC UA 服务端,S7-1200 从固件 V4.4 起也支持,但两者能力有差异。S7-1500 支持订阅(Subscription)和监控项(MonitoredItem),可以做到变化上报;S7-1200 早期固件只支持读写,不支持订阅,只能轮询。这个差异直接决定你的客户端架构:如果目标设备是 S7-1200 且固件偏低,源码里就必须实现轮询调度器,而不是依赖订阅回调。
端点(Endpoint)是另一个容易翻车的点。PLC 的 OPC UA 服务端默认监听 4840 端口,但西门子允许配置多个端点,安全策略也不同。常见的有 None、Sign、SignAndEncrypt 三种。产线内网调试阶段很多人图省事用 None,但一旦上生产环境,安全策略不匹配会直接导致连接被拒。源码里必须把端点发现(GetEndpoints)和策略协商做进去,不能写死。
# 使用 asyncua 库发现西门子 PLC 的 OPC UA 端点 from asyncua import Client async def discover_endpoints(url): client = Client(url=url) # 不传安全策略,先拿端点列表 endpoints = await client.connect_and_get_server_endpoints() for ep in endpoints: print(f"Endpoint: {ep.EndpointUrl}") print(f" SecurityMode: {ep.SecurityMode}") print(f" SecurityPolicy: {ep.SecurityPolicyUri}") print(f" UserToken: {[t.TokenType for t in ep.UserIdentityTokens]}") await client.disconnect()这段代码的逻辑是先不协商安全策略,直接向服务端请求端点列表,把每个端点支持的 SecurityMode、SecurityPolicyUri 和用户令牌类型打印出来。参数上,url 填opc.tcp://<PLC_IP>:4840,如果 PLC 配了多个端点,这里会全部列出。拿到列表后再决定用哪个端点、配哪种策略。注意connect_and_get_server_endpoints不会建立正式会话,只是拿元数据,所以即使策略不匹配也能执行。
2.2 节点 ID 的三种写法与西门子变量映射
OPC UA 的节点 ID(NodeId)是寻址核心。西门子 PLC 里的变量映射到 OPC UA 地址空间后,节点 ID 通常长这样:ns=3;s="DB1"."Temperature"。其中 ns 是命名空间索引,西门子一般把用户变量放在 ns=3 或 ns=4,具体取决于 PLC 配置。s 表示字符串标识符,后面跟的是符号名。也有用数字标识符的,比如ns=3;i=1001,但西门子默认用符号名,可读性好但解析慢。
源码里处理节点 ID 时,最常见的坑是命名空间索引写死。不同 PLC、不同项目,ns 索引可能不同。正确做法是先读服务端的 NamespaceArray,找到对应命名空间的索引,再拼节点 ID。另一个坑是符号名里的引号和点号,在字符串拼接时容易出错,建议用库提供的 NodeId 构造方法,而不是手拼字符串。
# 动态解析命名空间索引,避免写死 ns=3 async def resolve_node(client, namespace_uri, symbol_name): # 读取服务端命名空间数组 ns_array = await client.get_namespace_array() try: ns_index = ns_array.index(namespace_uri) except ValueError: raise RuntimeError(f"命名空间 {namespace_uri} 不存在") # 用 NodeId 构造,避免手拼字符串 from asyncua import ua node_id = ua.NodeId(symbol_name, ns_index) return client.get_node(node_id)逻辑说明:先拿服务端的命名空间数组,用 URI 反查索引,再用ua.NodeId构造节点。参数上,namespace_uri 一般是http://www.siemens.com/s7-1500或类似,具体看 PLC 配置;symbol_name 是带引号的符号名,比如"DB1"."Temperature"。这样写的好处是换一台 PLC 只要 URI 不变,代码不用改。如果 URI 也变了,那就得改配置,但至少不会因为 ns 索引变化而静默读错变量。
3. 用源码跑通第一个读写会话:连接、读、写、断
3.1 最小连接与读取单个变量的完整代码
先把最小闭环跑通,再谈订阅和批量。下面这段代码用 Python 的 asyncua 库,连接西门子 PLC,读一个变量,写一个变量,然后断开。选 asyncua 是因为它纯 Python、跨平台、源码可读,适合做二次开发。如果你用 C#,OPC Foundation 的官方栈更合适,但源码量大,改起来门槛高。
import asyncio from asyncua import Client async def main(): url = "opc.tcp://192.168.0.10:4840" client = Client(url=url) # 如果 PLC 开了匿名登录,不设用户;否则设用户名密码 # client.set_user("operator") # client.set_password("password") try: await client.connect() print("连接成功") # 读变量 node = client.get_node('ns=3;s="DB1"."Temperature"') value = await node.read_value() print(f"Temperature = {value}") # 写变量 setpoint = client.get_node('ns=3;s="DB1"."Setpoint"') await setpoint.write_value(25.5) print("写入完成") finally: await client.disconnect() asyncio.run(main())逻辑上,connect()会完成端点发现、策略协商、会话建立。get_node()只是构造节点对象,不发起网络请求;read_value()才真正读。write_value()同理。参数上,url 里的 IP 换成你的 PLC 地址,节点 ID 换成实际变量。如果 PLC 开了安全策略,Client构造时要传security_policy和security_mode,还要加载证书。匿名登录只适合调试,生产环境必须配用户。
3.2 批量读取与写入的性能取舍
单点读写延迟在 10 到 50 毫秒量级,取决于网络和 PLC 负载。如果采 100 个变量,逐个读就是 1 到 5 秒,产线节拍根本等不起。OPC UA 提供了 ReadRequest 批量读,一次请求可以带多个节点。asyncua 里用read_values传节点列表。
# 批量读取,一次请求拿多个变量 nodes = [ client.get_node('ns=3;s="DB1"."Temperature"'), client.get_node('ns=3;s="DB1"."Pressure"'), client.get_node('ns=3;s="DB1"."Flow"'), ] values = await client.read_values(nodes) for n, v in zip(nodes, values): print(f"{n} = {v}")批量读的关键参数是单次请求的节点数上限。西门子 PLC 对单次请求的节点数有限制,常见是 100 到 500 个,超了会返回错误。稳妥做法是分片,每片 50 到 100 个。写入同理,用write_values批量写,但要注意写入顺序和 PLC 扫描周期的关系,连续写同一 DB 块的不同变量,可能被 PLC 程序覆盖,建议一次写一个逻辑组。
3.3 会话保持与断线重连的基本策略
产线网络不会永远稳定。客户端必须处理断线重连,否则一次网络抖动就丢数据。asyncua 的 Client 有connect和disconnect,但没有自动重连。源码里要自己包一层重试循环。
async def connect_with_retry(url, max_retries=5, backoff=2): for attempt in range(max_retries): try: client = Client(url=url) await client.connect() return client except Exception as e: wait = backoff ** attempt print(f"连接失败 {e},{wait} 秒后重试") await asyncio.sleep(wait) raise RuntimeError("重试次数用尽")参数上,max_retries 建议 5 到 10,backoff 用 2 的指数退避,避免频繁重连打爆 PLC。重连后要重新建立订阅,因为旧订阅在会话断开时已经失效。如果源码里用了订阅,重连逻辑里必须包含订阅重建,否则会出现“连上了但没数据”的玄学问题。
4. 订阅与监控项:让数据变化主动上报而不是轮询
4.1 创建订阅与监控项的参数怎么设
订阅是 OPC UA 相比 Modbus 轮询的最大优势。客户端向服务端注册一个订阅,服务端按发布周期检查监控项,有变化就推送。核心参数有三个:PublishingInterval(发布周期)、SamplingInterval(采样周期)、QueueSize(队列深度)。PublishingInterval 是服务端检查变化的频率,SamplingInterval 是服务端从 PLC 读值的频率。通常 SamplingInterval 小于等于 PublishingInterval。
# 创建订阅并添加监控项 subscription = await client.create_subscription(period=500, handler=handler) node = client.get_node('ns=3;s="DB1"."Temperature"') handle = await subscription.subscribe_data_change(node)period 是 PublishingInterval,单位毫秒,500 表示每 500 毫秒检查一次。handler 是回调对象,收到通知时触发。subscribe_data_change默认 SamplingInterval 等于 PublishingInterval,QueueSize 默认 1。如果变量变化很快,QueueSize 要调大,否则会丢中间值。但 QueueSize 太大会占 PLC 内存,一般 10 到 100 够用。
4.2 回调处理与数据落库的线程安全
回调是在 asyncio 事件循环里执行的,如果回调里做耗时操作,比如写数据库,会阻塞整个循环,导致后续通知延迟。正确做法是回调里只把数据放进队列,另起消费者协程处理。
import asyncio from collections import deque data_queue = asyncio.Queue(maxsize=10000) class SubHandler: def datachange_notification(self, node, val, data): # 只入队,不做耗时操作 try: data_queue.put_nowait((str(node), val)) except asyncio.QueueFull: print("队列满,丢弃一条") async def consumer(): while True: node_str, val = await data_queue.get() # 这里做落库或转发 print(f"落库: {node_str} = {val}")参数上,队列 maxsize 根据数据量和落库速度调,太小会丢,太大吃内存。消费者协程可以批量落库,比如攒 100 条或每 1 秒写一次,减少数据库压力。注意回调里不要抛异常,抛了会被库吞掉,排查起来很痛苦。
4.3 订阅失效与重建的触发条件
订阅不是一劳永逸的。会话断开、PLC 重启、网络切换都会导致订阅失效。源码里要监听订阅状态,或者定期检查subscription是否还活着。asyncua 的订阅对象有dead属性,但更可靠的做法是设一个心跳监控项,如果超过 N 个周期没收到任何通知,就主动重建订阅。
async def monitor_subscription(subscription, timeout=10): last = asyncio.get_event_loop().time() while True: await asyncio.sleep(1) now = asyncio.get_event_loop().time() if now - last > timeout: print("订阅疑似失效,重建") await subscription.delete() # 重新创建订阅和监控项 return # 收到通知时更新 last,这里简化处理实际源码里,last 要在回调里更新。timeout 设成 PublishingInterval 的 3 到 5 倍。重建订阅时要先删旧的,再建新的,避免服务端资源泄漏。
5. 避坑与排查:西门子 OPC UA 客户端最常见的五个翻车点
5.1 连接被拒但 ping 得通
现象:TCP 能通,4840 端口也开着,但connect()报 BadSecurityPolicyRejected 或 BadCertificateUntrusted。原因:PLC 端配了 SignAndEncrypt,客户端用 None 去连,或者客户端证书没导入 PLC 信任列表。解决:先用端点发现拿到支持的策略,客户端配对应策略和证书。西门子 PLC 的证书管理在 TIA Portal 的 OPC UA 设置里,把客户端证书导入信任列表。
5.2 读到的值全是 0 或不变
现象:连接成功,读值不报错,但值一直是 0 或初始值。原因:节点 ID 写错了命名空间索引,读到了同名的空节点;或者变量在 PLC 里没使能 OPC UA 访问。解决:用 UaExpert 之类的工具先确认节点能读到正确值,再对比源码里的节点 ID。PLC 侧要勾选变量的 OPC UA 可见性。
5.3 订阅收不到通知
现象:订阅创建成功,但回调一直不触发。原因:SamplingInterval 设得比 PLC 扫描周期还短,服务端实际按扫描周期采样,但 PublishingInterval 太长,或者变量变化幅度没超过死区。解决:把 PublishingInterval 调到 100 到 500 毫秒,检查监控项的死区设置,默认死区是 0,但有些库会设默认值。
5.4 批量读超时或返回部分错误
现象:一次读 200 个节点,超时或部分节点返回 BadNodeIdUnknown。原因:单次请求节点数超限,或者其中某个节点 ID 无效导致整个请求失败。解决:分片到 50 个一批,先校验节点 ID 有效性,再批量读。无效节点单独处理,不要让它拖垮整批。
5.5 长时间运行后内存涨
现象:客户端跑几天后内存持续上涨。原因:订阅回调里创建的对象没释放,或者断线重连时旧订阅没删。解决:回调里避免创建大对象,重连逻辑里先delete()旧订阅再建新的。用tracemalloc或objgraph定位泄漏点,常见的是 handler 被全局引用。
6. 进阶:把客户端做成可配置的采集服务
6.1 配置文件驱动的节点映射与采集策略
硬编码节点 ID 的源码只能跑一个项目。要复用,得把节点映射、采集周期、数据类型做成配置。常见做法是 YAML 或 JSON,每个变量一条记录,包含节点 ID、别名、数据类型、是否订阅、死区。
# config.yaml plc: url: "opc.tcp://192.168.0.10:4840" security: "None" variables: - node: 'ns=3;s="DB1"."Temperature"' alias: "temp_01" type: "float" subscribe: true deadband: 0.1 - node: 'ns=3;s="DB1"."Pressure"' alias: "press_01" type: "float" subscribe: false poll_interval: 1000源码启动时读配置,按 subscribe 字段决定走订阅还是轮询。deadband 传给监控项,减少无效通知。poll_interval 用于轮询组。这样换项目只改配置,不动代码。
6.2 数据缓冲与断线续传的简单实现
断线期间数据不能丢。简单做法是本地开一个环形缓冲或 SQLite,断线时写入本地,重连后补传。环形缓冲适合内存够的场景,SQLite 适合要持久化的场景。
import sqlite3 def buffer_write(alias, value, ts): conn = sqlite3.connect("buffer.db") conn.execute("INSERT INTO buffer VALUES (?,?,?)", (alias, value, ts)) conn.commit() conn.close() def flush_buffer(send_func): conn = sqlite3.connect("buffer.db") rows = conn.execute("SELECT * FROM buffer ORDER BY ts").fetchall() for row in rows: send_func(row[0], row[1], row[2]) conn.execute("DELETE FROM buffer WHERE alias=? AND ts=?", (row[0], row[2])) conn.commit() conn.close()参数上,buffer.db 放本地磁盘,flush 在重连后触发。注意并发写要加锁,或者用 WAL 模式。补传时按时间排序,避免乱序。
6.3 用日志和指标验证采集质量
采集服务不能只看“有没有数据”,要看“数据对不对、全不全”。源码里加几个关键指标:连接状态、订阅通知数、读写失败数、队列积压数。用 logging 模块输出到文件,或者暴露 Prometheus 指标。
import logging from prometheus_client import Counter, Gauge notify_count = Counter("opcua_notify_total", "订阅通知总数") fail_count = Counter("opcua_fail_total", "读写失败总数") queue_size = Gauge("opcua_queue_size", "当前队列积压") logging.basicConfig( filename="opcua_client.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" )验证时,先看 notify_count 是否随产线运行增长,再看 fail_count 是否为零或偶发。queue_size 持续上涨说明消费者跟不上,要调批量大小或落库频率。日志里记录每次重连和订阅重建,方便回溯。
我自己的习惯是,任何采集服务上线前,先让它空跑 24 小时,只看日志和指标,不接业务。这 24 小时能暴露 80% 的稳定性问题。希望帮到你。
本文还有配套的精品资源,点击获取