简介OPC统一架构OPC UA通信实例源码是一套面向工业自动化开发者与可编程逻辑控制器工程师的实践工程核心解决可编程逻辑控制器与上位机之间标准、安全的数据交换问题。压缩包共285个文件大小约926KB包含111个C语言源码、47个头文件、21个Python脚本以及CMake构建配置文件、文本说明等目录结构清晰便于按模块检索学习。目前已有265人学习下载。源码完整实现了OPC UA服务器与客户端核心逻辑包括信息模型创建、节点属性定义、身份验证与授权、读写与订阅服务同时提供配置文件和示例应用可直接演示基于TCP/IP协议的跨平台通信。开发者通过研读该项目能深入理解OPC统一架构的发布/订阅模式、二进制编码与解码及安全机制掌握工业设备联网集成与数据采集的工程实现方法为搭建可扩展的自动化系统提供了直接参考。1. 一次PLC与PC机通信选型的真实复盘为什么最后落到OPC UA去年给一条装配线做数据采集需要把PLC与PC机通信彻底打通PC机要实时读西门子S7-1200里几十个DB块的数据交给上位机展示和存库。最开始图省事用Modbus TCP变量一多、涉及字符串和浮点数组的时候就很难受——寄存器地址要靠人工登记换一台PLC就要重新对表。后来换了OPC UA方案PLC侧用Put/Get把数据开放出来PC侧用OPC UA客户端直接浏览节点、订阅变化几十个变量几分钟就配完。这份实例源码就是把这条链路做成最小可运行工程服务器端代码、客户端代码、信息模型定义和订阅推送都在里面。适合刚接触OPC UA、想有一套能跑通的PLC与PC机通信框架直接抄作业的人。2. 通信之前先把机制看透选型依据、信息模型与服务集OPC UA 不是单纯把Modbus那套寄存器搬上TCP它是一个带语义的通信框架。这一章先把最影响后续编码的三个点讲清楚为什么选它、节点信息模型长什么样、客户端和服务器之间到底靠哪些服务交互。这些搞明白后面读代码基本无障碍。2.1 为什么不选 Modbus TCP / S7 协议三个方案对比Modbus TCP的优势是简单、到处都有PLC侧几乎不占资源。短板也明显数据以寄存器为单位没有单位、量程、报警上下限这些语义上位机要额外维护一张变量映射表。S7 协议比如西门子S7comm性能好、能直接读写DB块但它是西门子私有协议换三菱、汇川、台达这套代码基本作废。OPC UA 走的是另一条路它定义一个标准信息模型把数据组织成带类型、带属性、带方法的节点。上位机不需要预先知道PLC内部怎么存连上服务器浏览一遍节点树就全部清楚了。下面是三个方案在几个关键维度上的对比维度Modbus TCPS7commOPC UA数据类型描述无只有寄存器地址有限依赖DB块定义完整语义单位/量程/报警厂商中立性中立西门子私有中立安全性无无身份验证加密跨平台好差好实时推送需轮询需轮询订阅机制就实际项目来看如果你只是临时读一两个寄存器Modbus TCP 够用如果系统要长期维护、变量上百个、还牵扯到不同品牌的传感器和数控机床OPC UA 的维护成本反而最低。这个实例源码里涉及的场景正是后者——协议栈本身不挑设备品牌只要对方能暴露OPC UA端点就能接进来。2.2 信息模型节点、引用与属性OPC UA 里一切东西都是节点Node。变量是最常见的节点类型比如温度、电机转速、运行状态。对象节点用来把相关变量组织到一起比如一台 PLC_Station_1 对象节点下面挂 Temperature、MotorSpeed、RunStatus 三个变量节点。节点通过引用Reference连接形成树状结构这和PLC里的DB块层级很类似但比DB块多了语义描述。每个变量节点都带一组标准属性DataType 说明类型ValueRank 说明它是标量还是数组AccessLevel 说明是否可读可写还有 Description、EngineeringUnits 这类语义属性。客户端连接服务器后第一件事通常是浏览节点树这也是为什么上位机不需要手工维护变量表的原因。服务器端创建节点时add_variable 的返回值就是这个节点的句柄之后所有读写、订阅操作都拿着这个句柄做。提示写代码时留意节点 ID 的格式形如 ns2;sTemperature。ns 是命名空间索引由 register_namespace 的返回值决定s 后面是节点字符串标识。客户端访问节点靠的就是这个 ID不是靠名字。2.3 客户端-服务器模式与五类核心服务OPC UA 采用典型的客户端-服务器模式。服务器负责维护信息模型并响应请求客户端负责发起请求。实例源码里服务器模拟PLC的数据源客户端模拟上位机。落到实际项目服务器可以跑在PC上通过S7或Modbus抓PLC数据再暴露OPC UA也可以直接跑在支持OPC UA的PLC或工业网关里两种部署方式服务接口完全一致。交互靠的是服务集实际编码中最常用五类浏览Browse获取节点树的结构和引用关系相当于查看PLC的数据结构读取Read按节点 ID 直接读当前值适合一次性采样写入Write给变量节点赋值常用于下发控制指令订阅Subscribe客户端订阅变量值变化时服务器主动推送这是实时监控的核心调用Call触发服务器上的方法节点适合启停设备这类带逻辑的操作这五类服务与信息模型的对应关系很直接浏览找节点读写操作节点订阅持续盯着节点调用执行节点绑定的逻辑。理解这个对应关系后面看服务器端和客户端的代码就能对上号。2.4 传输层、安全策略与命名空间传输层默认走 opc.tcp 协议默认端口 4840二进制编码支持大消息分片。选 opc.tcp 而不是 HTTP 是出于实时性和吞吐量的考虑工业现场内网环境很少走 WebSocket但 OPC UA 规范里确实支持需要跨防火墙或走互联网时可以选择。编码方式上二进制比 JSON 紧凑适合长时间高频推送。安全有四层配置传输层加密靠安全策略SecurityPolicy身份验证靠Token匿名、用户名密码、X509证书权限靠服务器端ACL审计靠日志。测试阶段通常把安全策略设成 None 省事生产环境至少开 Basic256Sha256 加用户名密码。证书这个东西第一次配的时候确实玄学但理顺之后就是一劳永逸第5章有专门的踩坑记录。命名空间Namespace是新手最容易懵的概念。它其实就是给节点 ID 加一个唯一前缀的隔离域不同厂家或系统的节点放不同命名空间下就不会冲突。服务器初始化时 register_namespace 返回一个整数索引之后所有 add_variable、add_object 都用它。客户端URL里的 ns2 指的就是这个索引两边对不上就会 BadNodeIdUnknown。3. 服务器端落地用Python搭一个能接PLC数据的OPC UA服务器服务器端是整个链路的枢纽。它负责维护信息模型、周期从PLC采集数据、响应客户端读写请求、按订阅推送变化。这一章用 asyncua 库把最小可用的服务器搭出来再把代码改造成接入真实PLC的结构。3.1 环境准备asyncua 库与调试工具实例源码的服务器端用 Python 实现依赖 asyncua 库。选它而不是同步版的 opcua 库因为异步模型在处理大量订阅连接时不阻塞而且 API 更贴近官方命名。安装就一条命令pip install asyncua装完先确认版本能正常导入python -c import asyncua; print(asyncua.__version__)顺手验证一下依赖有没有装齐。开发机建议安装 UaExpert 用来后面验证服务器它只是调试工具不需要随项目分发但排查问题时比任何命令行工具都直观。3.2 创建服务器端点、命名空间与变量节点下面是服务器端的最小主体代码先跑通框架再谈数据源接入import asyncio import struct from asyncua import Server, ua def read_plc_float(db_number: int, offset: int) - float: # 占位函数实际项目里这里换成 snap7 或 modbus 读取 return 25.6 async def main(): server Server() await server.init() # 监听所有网卡端口 4840允许局域网内的客户端连接 server.set_endpoint(opc.tcp://0.0.0.0:4840) server.set_server_name(PlcDataServer) server.set_security_policy([ua.SecurityPolicyType.NoSecurity]) # 注册命名空间返回索引号后续创建节点都要用它 idx await server.register_namespace(http://example.com/plc) # 在 Objects 标准节点下创建一个对象节点代表一台 PLC objects server.nodes.objects plc_obj await objects.add_object(idx, PLC_Station_1) # 创建三个变量节点温度、电机转速、运行状态 temp await plc_obj.add_variable(idx, Temperature, 0.0) speed await plc_obj.add_variable(idx, MotorSpeed, 0) status await plc_obj.add_variable(idx, RunStatus, False) # 允许客户端写入下发控制指令时需要 await temp.set_writable() await speed.set_writable() async with server: print(OPC UA server running on opc.tcp://0.0.0.0:4840) while True: # 周期性采集数据并写入节点 await temp.write_value(read_plc_float(1, 0)) await speed.write_value(read_plc_float(1, 4)) await asyncio.sleep(0.5) if __name__ __main__: asyncio.run(main())关键参数说明set_endpoint 里的 0.0.0.0 表示监听所有网卡改成 127.0.0.1 就只有本机能连生产调试时记得用内网IP或0.0.0.0。register_namespace 的返回值 idx 必须存好后面 add_variable 都传同一个 idx否则客户端访问时节点路径对不上。set_writable() 是给变量打开写权限不调用的话客户端写入会返回 BadNotWritable。这个循环里的 read_plc_float 是占位函数它决定了服务器的数据源。保持每 500ms 写一次节点值这个间隔就是服务器端的采集周期实际按PLC数据变化频率调节。要注意的是write_value 传进去的数据类型必须和 add_variable 初始值类型一致否则会报类型错误。3.3 接入真实PLC以西门子S7-1200为例实例源码里服务器端模拟数据是为了能独立演示接入真实PLC只需要替换数据源函数。西门子PLC最常见的做法是用 python-snap7 直接读DB块安装命令pip install python-snap7。注意S7-1200必须先在TIA Portal里勾选允许从远程伙伴通信Put/Get否则连接直接被拒——这个开关藏得比较深网上搜1200与200smart putget通信能看到一堆类似疑问。import snap7 import struct def read_plc_float(db_number: int, offset: int) - float: client snap7.client.Client() client.connect(192.168.1.10, 0, 1) data client.db_read(db_number, offset, 4) # 读4个字节 client.disconnect() return struct.unpack(f, data)[0] # 西门子是big-endian这段代码里 db_read 的三个参数分别是DB块号、字节偏移、读取长度f 表示按大端序解析单精度浮点这是西门子默认的浮点存储格式。如果是三菱、汇川这类小端序PLC改成 f 就行。每次读都现连现断适合开发验证压测时应该复用连接对象避免频繁握手消耗PLC资源常见做法是把 snap7 客户端做成模块级单例。3.4 Modbus TCP 设备的接入方式如果现场是支持 Modbus TCP 的仪表或网关数据源函数用 pymodbus 实现from pymodbus.client import ModbusTcpClient def read_modbus_float(ip: str, register: int) - float: client ModbusTcpClient(ip, port502) client.connect() rr client.read_holding_registers(register, 2, unit1) # 读2个寄存器 client.close() return struct.unpack(f, struct.pack(HH, rr.registers[0], rr.registers[1]))[0]Modbus 的浮点组合规则比OPC UA繁琐同一个32位浮点有的设备是大端字序、有的是小端字序这属于现场最常见的翻车点建议先用 UaExpert 对照已知值确认解析顺序。read_holding_registers 的 unit 参数是设备地址多台仪表时记得区分。把这类读取函数替换进 3.2 的占位函数服务器逻辑完全不用动。3.5 用 UaExpert 验证服务器服务器跑起来后打开 UaExpert在 Servers 地址栏里填写 opc.tcp://192.168.1.10:4840 并双击连接。连接成功后展开 Objects 树应该能看到 PLC_Station_1 下挂的 Temperature、MotorSpeed、RunStatus 三个节点值按 500ms 周期变化。这一套验证通过说明服务器端信息模型和数据采集都没有问题可以开始写客户端。4. 客户端接入浏览节点、读写变量与订阅数据变化服务器端准备好了这一章写客户端。客户端的代码量比服务器少但踩坑点集中在连接参数、节点路径写法、订阅参数三个地方。这些代码可以直接复制出来改一改套进上位机或者 MES 采集程序里。4.1 连接与会话管理客户端第一步是建立连接。整个生命周期都在 async 函数里连接到会话建立会经过 TCP 握手、安全协商、会话创建三步import asyncio from asyncua import Client async def main(): client Client(urlopc.tcp://192.168.1.10:4840) client.set_security_string(None) # 与服务器端安全策略保持一致 try: await client.connect() print(OPC UA connected, session created) # 业务代码放在这里 finally: await client.disconnect() if __name__ __main__: asyncio.run(main())set_security_string 的值必须和服务器端 set_security_policy 一致服务器设了 NoSecurity客户端就写 None服务器开了 Basic256Sha256客户端要写成 Basic256Sha256,SignAndEncrypt。不一致时最常见的报错是 BadSecurityPolicyRejected。会话默认有超时长时间不活动服务器会断开所以生产代码里通常要加循环重连逻辑断线后自动重新 connect。4.2 浏览节点树不靠变量表靠浏览OPC UA 一个核心体验是客户端连上服务器后先浏览节点树找到自己关心的节点再对节点做操作。这省掉了传统 Modbus 那种人工维护寄存器地址表的工作。root client.nodes.root objects await root.get_child([0:Objects, 2:PLC_Station_1]) children await objects.get_children() for node in children: display_name await node.read_display_name() value await node.read_value() print(f{display_name.Text}: {value}) temp_node await objects.get_child([2:Temperature]) temp await temp_node.read_value() print(fTemperature {temp})get_child 的路径参数里0:Objects 是标准节点2:PLC_Station_1 中的 2 就是服务器端 register_namespace 返回的命名空间索引。如果命名空间索引对不上会报 BadNodeIdUnknown。这里给个排查技巧先用 UaExpert 连上看节点树每个节点旁边会标出 ns 数字拿那个数字替换 2 就行。4.3 读取与写入数据类型必须严格匹配读取变量后拿到的值类型取决于服务器端 add_variable 时的初始值类型。写入时类型不匹配会报 BadTypeMismatch比如服务器端定义的是 Float客户端传个整型进去就可能被拒# 读取 temp_val await temp_node.read_value() # 写入显式指定 VariantType from asyncua import ua await temp_node.write_value(ua.Variant(26.5, ua.VariantType.Float))显式传 ua.Variant 类型能避免很多类型推断上的问题。int/float/bool/string 对应 VariantType 里的 Int16/Int32/Float/Boolean/String数组变量要用带数组维度的 VariantType 写法。写完立刻读取回验是防止写错类型最直接的办法调试时建议打印变量的 data_type 属性确认。4.4 订阅数据变化实时推送的核心写法订阅和轮询的本质区别是服务器在值变化时主动推数据客户端不需要反复请求。这对几百个变量的场景特别重要能省大量CPU和带宽也是OPC UA在实时通信上优于传统PLC轮询方案的关键特性。from asyncua import Client, ua class SubHandler: def datachange_notification(self, node, val, data): # node: 发生变化的节点val: 新值data: 包含时间戳、状态码 print(f{node} {val}, time{data.monitoreditem.Value.SourceTimestamp}) async def subscribe(): client Client(opc.tcp://192.168.1.10:4840) await client.connect() temp_node await client.nodes.root.get_child( [0:Objects, 2:PLC_Station_1, 2:Temperature] ) # 创建订阅period 是发布间隔单位毫秒 sub await client.create_subscription(period200, handlerSubHandler()) # 订阅指定变量返回订阅句柄 handle await sub.subscribe_data_change(temp_node) await asyncio.sleep(60) # 让订阅跑一段时间 await sub.unsubscribe(handle) await sub.delete() await client.disconnect()create_subscription 的两个参数period 决定服务器多久检查一次并推送数据handler 是回调对象。datachange_notification 在值变化时被调用回调里能拿到节点、新值和带时间戳的数据对象。注意 period 设置越小推送越频繁实时性越好但网络压力和服务器CPU占用也越高量级上工业监控最常见的配置是 100ms 到 500ms。死区deadband默认不开启开启后值变化幅度小于该数值时不推送具体调参在第六章展开。4.5 连接池与多变量批量订阅现场往往不是只订阅一个变量。批量订阅的写法是连续调用 subscribe_data_change每个调用返回一个句柄全部句柄存进列表最后统一 unsubscribe。如果变量有几十上百个建议把订阅放到一个常驻进程里用队列把回调数据转发给上层逻辑避免在回调里做耗时操作——回调是同步执行的阻塞会导致数据积压和推送延迟这是新手最容易写崩的地方。5. OPC UA 通信排查指南五个高频故障与修法OPC UA 的报错信息不如 Modbus 直观连不上可能来自七个不同环节。把开发中实际踩过的坑整理成五条每一条都是「现象→原因→解决」的完整链路。调试时建议按顺序排查网络→端口→安全策略→命名空间→订阅参数。5.1 连接被拒绝但服务器明明在跑现象本地用 UaExpert 连服务器一切正常换一台电脑或打开客户端代码就报 Connection refused或 BadConnectionClosed。原因八成是 set_endpoint 用了 127.0.0.1这个地址只监听回环网卡局域网设备根本到达不了。另外 Windows 防火墙默认拦截 4840 端口的外部访问服务器进程虽然活着但外部包根本进不来。解决服务器端改监听 0.0.0.0重启进程后确认netstat -an | findstr 4840显示监听地址是 0.0.0.0:4840防火墙加一条入站规则放行 TCP 4840 端口。在 Linux 部署时还要检查是否被 selinux 或云安全组拦了这两处最隐蔽。5.2 安全策略不一致导致握手失败现象客户端报 BadSecurityPolicyRejected 或者 BadCertificateUntrusted连不上服务器。原因两端安全策略或证书校验不匹配。开发阶段最常见的组合是服务器 NoSecurity 而客户端写成 Basic256Sha256反过来也一样。证书问题时客户端没有导入服务器证书或代码里没做信任配置。解决先统一安全策略服务器 set_security_policy 用 NoSecurity 时客户端 set_security_string 写 None。开证书校验的项目把服务器生成的自签证书复制到客户端信任目录或者调用 set_security_string 时传对应的证书参数。测试环境图省事可以跳过校验但现场部署必须把证书链路补全否则跨设备跑不起来。5.3 订阅建好了变量却一直不更新现象create_subscription 返回正常订阅句柄也拿到了但回调函数从不触发或者要等很久才触发一次。原因三个参数在作怪。第一发布间隔 period 设得太大比如 60000ms服务器每分钟才推一次。第二服务器端开启了死区变量值在死区范围内的小幅波动被过滤掉了。第三回调函数抛异常被静默吞掉订阅线程卡死。解决把 period 调整到 100~500ms确认服务器端没有配置 deadband或者把绝对死区设成 0在回调里加 try/except 并打印堆栈确保任何异常都暴露出来。这三个原因占订阅不更新问题的九成以上。5.4 浮点数读出来是天文数字现象客户端读取的浮点值明显不对比如温度变成 1.4e-33或者和真实值差好几个数量级。有时候整数也对不上。原因字节序不匹配。西门子 PLC 存储 float 是大端序big-endianModbus 设备两种都有而 OPC UA 协议本身是 big-endian。如果数据源函数解析字节序时用错了读到真实数据的字节顺序就会完全颠倒数值自然离谱。解决用 struct.unpack 的 f大端和 f小端各解析一遍和实际值对比即可确认。更彻底的办法是写一个临时脚本对同一个寄存器用两种字节序解析并打印 hex比对哪个符合物理意义。注意三菱 FX5U、汇川 H5U 是小端S7-1200/1500 是大端这个在项目启动前就要确认别等上线了再猜。5.5 UaExpert 能连代码却连不上现象同一个服务器、同一台电脑UaExpert 连接正常opcua/asyncua 客户端代码报 BadNodeIdUnknown 或连接失败。原因命名空间索引不一致。UaExpert 显示 ns2 的节点代码里如果写 ns1 或者直接用默认命名空间访问路径自然对不上。另一个可能是 get_child 路径写成 [0:Objects, 2:PLC_Station_1] 但对象树层级比想象的多一层。解决先用client.nodes.root.get_children()打印出所有一级子节点把树结构完整走一遍再写 get_child。路径里每段都必须带命名空间前缀且要和服务端 register_namespace 返回的 idx 一致。实在对不上时直接用节点的 NodeId形如 ns2;sPLC_Station_1访问绕过路径解析。6. 从能用到好用采样间隔与发布间隔的配合调优订阅链路通了之后会面对一个更实际的问题实时性和负载怎么平衡。这个平衡点由两个参数决定——采样间隔SamplingInterval和发布间隔PublishingInterval。它们经常被混在一起其实职责完全不同。采样间隔是服务器从数据源比如PLC的DB块读取值的时间间隔决定数据看到的频率发布间隔是服务器把变化的数据组装成消息推给客户端的间隔决定数据送到的频率。客户端创建订阅时传入的 period指的就是发布间隔很多人误以为是采样频率。参数位置作用推荐取值采样间隔服务器端数据采集循环决定数据读取频率50~500ms发布间隔客户端 create_subscription决定推送频率100~1000ms死区服务器 monitored item过滤微小变化0~2%按量程实际调参的通用做法先确定数据源的变化快慢。温度这类缓变量采样 500ms、发布 1000ms 就够了伺服电机转速这类快变量采样 50ms、发布 100ms 才有意义。带宽紧张时优先拉大发布间隔而不是采样间隔因为推送才是占用网络流量的动作。死区适合用在振动、张力这类带高频噪声的信号上设 1%~2% 的百分比死区能明显降低推送量代价是丢失细微波动。验证调参结果最直接的方式是在回调里打 SourceTimestamp。相邻两条通知的时间戳差就是实际推送间隔对比它和设置的 period 是否吻合。如果差距大说明服务器响应不过来需要降低采集频率或减少订阅节点数。另外补充一个技巧把订阅句柄、回调异常日志、连接状态三样东西统一打进一个调试面板现场排障时能省一半时间。从那以后我每次搭 OPC UA 链路都先确认采样间隔、发布间隔、死区三个参数写进验收单宁可开发时多调几分钟也不让现场上线了再改。希望帮到你。本文还有配套的精品资源点击获取