
上周朋友拉我去处理一个生产看板的需求现场是一台三菱FX5U的PLC每天靠老师傅抄D寄存器的数值做日报想着能不能把温度和压力数据自动送到车间大屏上。我第一反应就是拿Python写个小服务用网线把PLC和电脑接起来定时把数据读出来推到看板。这件事听起来高大上其实核心就一层窗户纸三菱PLC支持MC协议新一点的文档里叫SLMP协议走以太网时所谓“通讯”就是往PLC发一段符合协议规定的报文再把返回值拆开。这篇文章适合自动化工程师、会一点Python又想碰PLC数据采集的朋友内容是我在实际项目里踩过路的完整记录从通信帧结构、PLC参数设置、Python代码到排障经验和优化技巧。看完之后你至少能自己实现一个“定时把三菱PLC的D、M、X/Y数据抓回来存进数据库或CSV”的小工具。1. 先理清整体方案读三菱PLC到底是怎么回事1.1 项目要解决的痛点工厂里大量设备用的还是三菱PLC常见的有FX5U、Q系列、L系列这些设备本身运行稳定但“数据看不到”是一线工程师普遍头疼的事。工艺员想知道实时温度品管想知道每批次的压力曲线生产主管想统计开机率这些数据都在PLC的软元件里比如D100存了当前温度D101存了当前压力。没有上位机的时候只能靠人工定时去触摸屏抄数费时费力还容易记错。把PLC数据读上来之后能干什么往小里说是做个实时看板往大里说是给MES、SCADA提供数据源甚至可以做设备预测性维护。我做过一个比较典型的场景把PLC里的PID参数和运行状态D寄存器定时抓回来配合前端画趋势曲线工艺员在整定PID参数时就不用来回跑现场直接在网页上看响应曲线。还有朋友用FX5U通过CC-Link IE Basic带伺服表面上是在总线上控制伺服但上位机要拿伺服的位置、电流、报警码最终还是要从PLC的软元件映射区去读底层还是同一套软元件读取逻辑。1.2 为什么用Python而不是组态软件或C#很多工厂现在的做法是用组态软件比如组态王、WinCC或者直接用触摸屏。组态软件的优势是开发快拖拖拽拽就出一个画面但问题也很明显一套正版授权不便宜需要跑在Windows工控机上关键逻辑一复杂就受限制而且数据要导出给其他系统用往往比较别扭。C#和VB是工控行业的老牌选择资料多、成熟稳定但开发环境重部署的时候还得装.NET运行时改个小逻辑就要重新编译。Python在这件事上的优点恰好补了短板。第一标准库里的socket就能直接和PLC通信不需要安装任何厂商的组件包这对很多“电脑上不让你乱装软件”的工厂环境特别友好。第二Python处理数据太方便了读回来的数值可以直接用pandas做统计用matplotlib画趋势甚至接Flask做个内网网页。第三脚本改起来快现场发现点位地址不对改一行代码重新跑就行。当然Python也不是没有缺点做高实时性、高频率的运动控制不行但做秒级、百毫秒级的数据采集完全够用。1.3 通信选型MC协议加以太网目前最通用的一条路三菱PLC和上位机通信有几种常见路子。一种是走串口RS232或者RS485老设备常用但波特率有限、距离受限、而且一台上位机想同时接多台PLC很麻烦。另一种是走以太网用三菱的MC协议也叫SLMP协议新老型号基本都支持速度快、部署灵活也是目前主流的选择。MC协议本质上就是一套“应用层报文规范”PLC作为TCP服务端或者UDP服务端监听一个端口上位机作为客户端发起连接、发请求帧、收响应帧。这里有个很关键的点只要你选的是“网口MC协议”这条路不管对面是FX5U、Q系列还是L系列帧结构都是同一套逻辑代码可以复用。唯一的区别是PLC型号不同参数设置入口不同、默认端口号可能不同但协议本身是一致的。所以我把方案定成了“Python标准库socket走TCP使用MC协议的3E帧二进制模式”这样不依赖任何第三方PLC库部署最干净也方便你用Wireshark抓包去核对报文。2. 通信帧结构几乎所有异常都出在这几个字节上2.1 一次读D寄存器的请求报文逐字节拆给你看我用Python读三菱PLC踩过最大的坑就是帧结构。网上能找到的资料很多互相矛盾有的用ASCII模式有的用Qna-3E帧有的用3E帧一个字节对不上PLC就是不理你。所以这部分我建议所有人都耐下心看懂宁可在这里花半小时也不要盲写代码后抓瞎。下面是一次“读取D100开始的10个字软元件”的完整请求帧3E帧二进制模式兼容新版FX5U、Q系列SLMP字段字节数示例值说明帧头2D0 003E帧二进制模式固定头Qna-3E老帧是50 00网络号100一般填0PC号1FF一般填FF255表示请求来源IO编号2FF 03目标模块IO号常见默认03FF小端排列站号100模块站号一般填0请求数据长度20C 00从监视定时器开始到报文末尾的总字节数这里12字节监视定时器210 000x0010单位250ms也就是4秒超时命令201 040x0401小端排列表示批量读取字软元件子命令200 003E帧必须带子命令批量读取时为0起始软元件地址364 00 00D100的地址1003字节小端排列软元件代码1A80xA8表示D寄存器软元件点数20A 00连续读取10个点把这串字节连起来就是D0 00 00 FF FF 03 00 0C 00 10 00 01 04 00 00 64 00 00 A8 0A 00我当时第一次看到这个报文时觉得挺繁琐但把它拆开看就不难理解。它其实就是一个“信封”前面7个字节是固定地址信息告诉PLC这封信从哪来、发给谁接着2个字节写这封信有多长再往后才是正事我要读哪个寄存器、从哪个地址开始、读多少个。2.2 帧头、命令、软元件代码三个最容易出错的地方我见过太多人卡在帧头选择上。三菱PLC的协议文档里有几种帧格式按通信模式分有ASCII模式和二进制模式按应用场景分有Qna-3E帧、3E帧、A兼容1E帧等等。如果你的目标是“用网口读FX5U或Q系列数据”用3E帧二进制模式最省事头固定是D0 00。网上很多老教程用的Qna-3E帧头是50 00而且请求数据区里没有子命令字段两者差出来的两个字节足以让你调试到怀疑人生。所以写代码前先确认你参考的示例到底用的是哪种帧。命令代码也容易混。批量读取字软元件的命令是0x0401但注意三菱报文在二进制模式下是小端排列所以正式拼帧时你得写成01 04。如果看别人贴的报文里写的是“0401”要明白那只是人看的写法socket发出去的字节必须按底层的字节序来。批量读取位软元件时命令是0x1401同理拼帧时写01 14。软元件代码是另一个高频翻车点。我常用的几个代码列在下面单位都是16进制软元件含义MC二进制代码X输入继电器9CY输出继电器9DM内部继电器90L锁存继电器92B链接继电器A0D数据寄存器A8W链接寄存器B4R文件寄存器AFZ变址寄存器CCD寄存器我基本天天用代码是A8这个我闭着眼都记得。如果你是读计数器C的当前值、定时器T的当前值这类特殊软元件代码就不能凭猜了一定要去对应型号PLC的通信手册里查“软元件代码一览表”不同系列会有差异这也是我踩过坑之后养成的习惯凡是拿不准的代码先查手册再拼包。2.3 响应报文的解析套路发完请求帧PLC会回一个响应帧。响应帧的前7个字节和请求帧结构相同也是D0 00 00 FF FF 03 00开头接着2个字节是响应数据长度由结束代码和数据区组成。成功时的结束代码是00 00如果PLC返回别的值就要去手册里查对应的含义了常见的有软元件地址越界、点数超出范围等。读取10个D寄存器10个字成功时响应帧主体就是D0 00 00 FF FF 03 00 16 00 00 00 [20字节数据]其中16 00换算成十进制是22表示后面有22个字节2字节结束代码20字节数据。20字节数据对应10个16位寄存器每个寄存器按小端排列。比如D100的值是123那数据区里对应位置的2个字节就是7B 00。我调试时最喜欢干的一件事就是把请求帧和响应帧都打印成hex串肉眼对着看。这比任何高大上的调试器都好使因为帧一旦对了剩下就是纯数据处理问题。3. 环境搭建与PLC连接配置3.1 Python环境别在最基础的地方翻车很多人看Python采集PLC的教程上来就写socket结果卡在环境配置上一两个小时。Windows下装Python最稳妥的办法是去python.org下载对应版本的安装包安装时一定要勾选“Add Python to PATH”。装完之后打开命令行输入python --version能正常打印版本号就说明成了。如果输入python提示“python was not found; run without arguments to install from the microsoft st”多半是没加PATH要么重启一下命令行要么用py命令试试要么去系统环境变量里手动把Python安装目录加进去。Linux服务器上一般自带python3如果没装Ubuntu系用sudo apt install python3CentOS系用sudo yum install python3。本文的采集脚本只需要标准库socket、struct、time都是内置的所以不装任何第三方包也能跑。后面如果要写CSV、连数据库再按需pip install pandas、pymysql这些随用随装没必要一上来就配一大堆依赖。3.2 PLC的IP、端口和MC协议开关PLC网口默认不是随插随用的。以太网模块或内置以太网口都需要设置IP地址你要先确认PLC和电脑在同一个局域网。现场最简单的做法是拿一根网线直接连PLC和电脑把电脑网卡的IP手动改成和PLC同网段比如PLC是192.168.1.10电脑就设192.168.1.50掩码255.255.255.0然后在命令行ping一下PLC的IP能通再往下走。接下来要确认PLC的以太网端口开了MC协议服务器功能。FX5U的话用GX Works3在“以太网端口设置”里找到SLMP/MC协议相关选项启用服务并记下端口号。Q系列如果用了以太网模块比如QJ71E71要用GX Works2或者模块配置工具设置通信协议为MC协议并指定端口。这里最容易踩的坑是不同型号、不同固件版本的PLC默认端口可能不同有人说是5000有人说是6000还有的场合是4950其实这些都可以在PLC参数里自定义。所以不要拿着别人的代码里的端口号硬套以你的PLC里实际看到为准。还要注意一点GX Works软件如果在PLC上在线监控有些型号会占用通信资源这时候你再开Python去连可能连不上或者响应很慢。调试阶段最好把GX Works的在线监控断开或者两台电脑分开用一台专门跑采集一台维护PLC。3.3 地址规划想清楚读什么再动手写代码代码是最后一步先把点位表整理出来才是正经事。我通常用Excel建一张表列清楚要读的软元件类型、起始地址、点数、数据类型、换算系数、用途备注。比如D100温度16位有符号实际值原始值/10D101压力16位有符号实际值原始值/100D110-D111流量累计值32位无符号M50设备运行状态0停止1运行X10急停按钮状态为什么强调先做地址规划因为批量读取追求的是连续地址如果点位东一个西一个你就只能分成好多次读网络开销大。现场很多PLC程序是多年前别人写的数据分布并没有为上位机优化这时就可以和工艺或电气同事协商把需要采集的数据尽量连续整理到一段空闲的D区上位机读起来会轻松很多。三菱的计数器C当前值、PID自整定过程中的各种参数本质上也都是存在软元件里的读法和D寄存器完全一样只是软元件代码不同。FX5U通过CC-Link IE Basic挂伺服时伺服的状态和控制字也是映射到PLC内部软元件区的你只要知道映射到了哪里照样用这套代码去读。4. 核心代码实现把D寄存器数据读回来4.1 最简版本socket直接读取D100开始的10个字先把最简单的demo贴上照着用就能跑通。这里我用的是3E帧二进制模式命令0x0401读取字软元件软元件代码0xA8对应D寄存器。import socket import struct PLC_IP 192.168.1.10 PLC_PORT 6000 FRAME_HEAD bytes([0xD0, 0x00, 0x00, 0xFF, 0xFF, 0x03, 0x00]) def build_read_word_frame(start, count): monitor struct.pack(H, 0x0010) # 监视定时器4秒 cmd struct.pack(H, 0x0401) # 批量读取字软元件 subcmd struct.pack(H, 0x0000) # 子命令 addr struct.pack(I, start)[:3] # 起始地址3字节小端 code bytes([0xA8]) # 软元件代码D points struct.pack(H, count) # 读取点数 data monitor cmd subcmd addr code points length struct.pack(H, len(data)) return FRAME_HEAD length data def recv_exact(sock, n): buf b while len(buf) n: chunk sock.recv(n - len(buf)) if not chunk: raise ConnectionError(连接被断开) buf chunk return buf def read_d(start, count): frame build_read_word_frame(start, count) with socket.create_connection((PLC_IP, PLC_PORT), timeout3) as s: s.sendall(frame) head recv_exact(s, 9) # 固定头7字节 响应长度2字节 body_len struct.unpack(H, head[7:9])[0] body recv_exact(s, body_len) end_code struct.unpack(H, body[0:2])[0] if end_code ! 0: raise RuntimeError(fPLC返回异常结束代码: 0x{end_code:04X}) raw body[2:2 count * 2] return list(struct.unpack( h * count, raw)) if __name__ __main__: values read_d(100, 10) print(values)这段代码的逻辑不复杂先拼帧发出去再按“先收9个字节解析出响应长度再收完剩余内容”的方式收响应最后按16位有符号整数解析数据区。用recv_exact而不是单纯recv一次是因为TCP是流协议一次recv不一定能收到完整响应帧必须循环读到指定长度为止。很多初学者在socket这一层翻车就是默认“发一次就能收一次收全”这在局域网里大部分时候行得通但遇到网络波动就会偶发丢数据。4.2 封装成可复用的采集客户端项目里不可能只读一次就完事通常要一个“能保持连接、反复调用、异常断线自动重连”的类。把上面的逻辑封一层现场加点位、改功能都方便。class McClient: def __init__(self, ip, port, timeout3): self.ip ip self.port port self.timeout timeout self.sock None def connect(self): self.close() self.sock socket.create_connection((self.ip, self.port), timeoutself.timeout) def close(self): if self.sock: try: self.sock.close() except Exception: pass self.sock None def _transact(self, payload): if not self.sock: self.connect() try: self.sock.sendall(payload) head recv_exact(self.sock, 9) body_len struct.unpack(H, head[7:9])[0] body recv_exact(self.sock, body_len) except Exception: self.close() raise return body def read_words(self, dev_code, start, count): monitor struct.pack(H, 0x0010) cmd struct.pack(H, 0x0401) subcmd struct.pack(H, 0x0000) addr struct.pack(I, start)[:3] points struct.pack(H, count) data monitor cmd subcmd addr bytes([dev_code]) points frame FRAME_HEAD struct.pack(H, len(data)) data body self._transact(frame) end_code struct.unpack(H, body[0:2])[0] if end_code ! 0: raise RuntimeError(fPLC返回异常结束代码: 0x{end_code:04X}) raw body[2:2 count * 2] return list(struct.unpack( h * count, raw))这样调用时只需要几行plc McClient(192.168.1.10, 6000) temp plc.read_words(0xA8, 100, 1)[0] / 10.0 plc.close()只要记住软元件代码和地址每个点位的读取都是同一套逻辑。封装之后脚本主流程可以专注在处理数据上不用每次纠结拼帧。4.3 位软元件和32位浮点数怎么读D寄存器是字软元件读起来简单但现场还需要读M、X、Y这些开关量。批量读取位软元件的命令是0x1401帧结构和读字类似只是命令码不同数据区解析方式也不同。PLC返回的数据区是位打包的第一个字节的最低位对应第一个软元件第二个比特位对应第二个软元件以此类推。比如读M100开始的8个点返回1个字节bit0是M100的值bit1是M101的值。def read_bits(self, dev_code, start, count): monitor struct.pack(H, 0x0010) cmd struct.pack(H, 0x1401) # 批量读取位软元件 subcmd struct.pack(H, 0x0000) addr struct.pack(I, start)[:3] points struct.pack(H, count) data monitor cmd subcmd addr bytes([dev_code]) points frame FRAME_HEAD struct.pack(H, len(data)) data body self._transact(frame) end_code struct.unpack(H, body[0:2])[0] if end_code ! 0: raise RuntimeError(fPLC返回异常结束代码: 0x{end_code:04X}) raw body[2:] return [(raw[i // 8] (i % 8)) 1 for i in range(count)]32位浮点数是另一个常见需求。PLC里一个32位浮点占两个D寄存器比如D110和D111拼成一个浮点。三菱的字节序是低字在前也就是D110是低16位D111是高16位。拼装代码很简单def read_float32(self, start): words self.read_words(0xA8, start, 2) value (words[1] 16) | (words[0] 0xFFFF) return struct.unpack(f, struct.pack(I, value))[0]这里有个血泪教训不要想当然把两个16位整数直接除以65536再相加用位运算和struct打包最稳。同时如果你的PLC数据是用32位整数存储的比如计数器的双字累计值把最后一行的unpack格式从f改成i就行或者直接words[0] | (words[1] 16)无符号版本。5. 定时采集与数据落地从“读一次”到“一直读”5.1 一个简单的轮询采集脚本实际项目里通常不是手动执行一次脚本而是要一个常驻的采集程序每隔一定时间把点位读一遍记录到文件或者数据库。最朴素的框架就是while True加sleep加上异常处理和断线重连。import time import csv from mc_client import McClient def collect_once(plc): row { time: time.strftime(%Y-%m-%d %H:%M:%S), temp: plc.read_words(0xA8, 100, 1)[0] / 10.0, pressure: plc.read_words(0xA8, 101, 1)[0] / 100.0, running: plc.read_bits(0x90, 50, 1)[0], } return row def main(): plc McClient(192.168.1.10, 6000) while True: try: row collect_once(plc) with open(data.csv, a, newline) as f: writer csv.DictWriter(f, fieldnameslist(row.keys())) if f.tell() 0: writer.writeheader() writer.writerow(row) time.sleep(1) except Exception as e: print(采集异常:, e) time.sleep(3) try: plc.connect() except Exception: pass if __name__ __main__: main()这个脚本能跑但有几个工程上的细节要提醒第一采集频率要根据PLC的负载来定别上来就设100ms一次PLC扫描周期可能会受影响一般秒级足够了。第二写文件的异常不能影响主循环比如磁盘满了、CSV被占用要有独立的错误处理。第三生产环境建议把脚本做成Windows服务或者Linux systemd服务保证断电重启后能自己拉起来。5.2 性能优化批量读取远比你想象的重要刚开始做采集的同学特别容易写出这样的代码循环100次每次读1个D寄存器。这在点位少的时候没问题但点位一多问题就来了。每次读取都是一次完整的TCP请求响应网络往返时间在小规模局域网里大概是零点几毫秒到几毫秒看着不多但100个点就是100次往返再加上PLC内部处理时间整体就慢了。批量读取一次可以拿连续地址的数据比如D100到D199一次读取100个字响应帧也就200多字节速度能快一个数量级。我测试过在普通工厂局域网上循环读100个单点和一次性批量读100个字耗时差距大概有5到10倍。所以做点位规划的时候尽量把要采集的数据整理到连续的地址段一次读回来到内存里再按索引取用。三菱协议对批量读取的点数有上限不同型号不太一样常见的是不超过960个字保险起见一次别超过512个点实在需要更多就分段。5.3 数据写CSV、数据库与可视化采集上来的数据落地方式取决于你的用途。只是想导出来做报表CSV最简单Excel直接能开。要长期存储、多人查询那就得进数据库MySQL或者SQLite都行。Python连MySQL用pymysql写起来很直接但注意写入失败不能拖垮采集主循环可以把插入操作放到单独的队列里或者定期批量插入。我常用的做法是采集线程只负责把数据放进一个队列消费者线程负责批量写库这样即使数据库卡了一下采集线程也不会丢数据。数据可视化的选择更多简单的可以直接用matplotlib画曲线适合事后分析。要做实时看板轻量方案是Flask加前端图表库把最近一次读取的数据通过接口暴露出去前端定时拉取刷新。工业环境里如果已经有InfluxDB加Grafana这套时序数据库方案效果会更好Python这边只需要把数据推送进去就行。核心问题始终是“读得稳、存得下”界面反而是最不着急的部分。6. 高频问题和我的排查心得6.1 连不上PLC的排查顺序“连不上”这个问题我被问过无数次排查顺序很重要别一上来就怀疑代码。先ping PLC的IP不通就去查网线、网卡、IP是否同网段。ping通了再确认端口Windows下可以用telnet也可以用Python的socket试着connect一下看端口通不通。端口不通就去PLC侧查MC协议服务有没有启用、端口号是不是写错了。端口通了但代码读不到数据这时候才需要怀疑帧格式把请求帧和响应帧的hex打出来和2.1节的示例逐字节对。我之前遇到过一次很隐蔽的问题PLC参数里开放了多个通信端口但某个端口只允许特定来源IP访问电脑换了IP之后就被拒绝连接查了半天才发现是PLC侧的访问限制设置。6.2 返回结束代码非0与数据不对PLC返回的结束代码非0说明请求被拒绝了。最常见的两类原因一类是地址越界比如你的PLC程序里D区只用到D500你非要去读D1000自然会被拒绝另一类是指定的软元件代码和地址不匹配比如把D寄存器当成了M寄存器来读。处理办法就是把错误代码记下来去对应型号通信手册附录里查结束代码一览表能看到具体原因。要注意的是不同系列PLC的结束代码含义大体一致但不完全一样别拿着FX系列的说明去套Q系列。数据读回来了但明显不对一般也逃不出几个原因。第一是符号问题D寄存器里可能是无符号整数你按有符号解析了负数的表现就会很奇怪。第二是字节序问题三菱走的是小端解析时struct格式串里必须是前缀。第三是32位数据高低字顺序反了两个D寄存器拼32位时记得先读的是低字。还有一种很磨人的情况PLC程序里做了量纲换算比如实际温度是23.5度但PLC里存的是235上位机没除以10直接就显示了。做点位表的时候最好把每个点位的数值类型和换算关系都标注清楚写代码时严格按表来。6.3 现场实施的一些保命建议最后说几条我自己的经验。第一先在办公室搭测试环境PLC模拟器或者一台闲置PLC都行不要在正在生产的设备上第一次跑通就完事万一写命令写错了位置后果不是开玩笑。第二如果只读不写一定要把代码限定在读取命令范围内不要顺手把写命令也封装上去防止有人误调用。第三采集脚本一定要记日志至少把请求帧、响应帧、错误码都记录下来出问题的时候复盘会快很多。第四考虑一下PLC的通信资源有些三菱PLC同时只允许几个客户端连接你一会儿用GX Works连一会儿用Python连新手阶段很容易遇到“刚才还能读怎么突然连不上了”的情况基本是通信连接没释放。第五如果采集脚本打算长年累月跑最好加个看门狗机制Windows下可以用计划任务定时检查进程Linux下用systemd管理简单可靠。我在实际项目里最深的体会是用Python读三菱PLC这件事难点完全不在Python语法而在“你能不能把一个字节一个字节的报文拼对”。一旦把帧结构吃透了后面不管是读D寄存器、M继电器还是扩展成完整的SCADA采集服务都只是锦上添花。希望这篇文章能帮你少走点弯路尽快把第一个点位读出来。