
干汇川CodeSys这套东西也快五年了中间用H5U、Easy320做过不少项目说实话很多刚接触汇川中型PLC的朋友最容易踩的坑就是程序从头到尾堆在一两个PRG里梯形图拉几百行现场一改需求整个人直接傻眼。这篇就把我实际项目里怎么拆POU、怎么封装功能块、怎么处理EtherCAT和Modbus通信的细节捋一遍尤其是POU模块化这一块属于是拿真金白银换来的经验看完你直接照着抄就行。1. 先把POU这件事彻底搞清楚1.1 PRG、FB、FC到底什么区别很多入门资料把POU讲得玄乎其实你把它想成厨房里干活就通了。PRGProgram相当于主厨当天的工作清单里面写清楚今天先切菜、再炒菜、最后装盘。在CodeSys里PRG就是被任务循环调用的程序块它负责调度、组织把各个功能块串起来。一个项目里一般不会有很多PRG2到3个是常态多了你自己都理不清谁先谁后。FBFunction Block就像一个有记忆的菜谱模板比如“红烧肉做法”。你调用一次它会记住这锅肉当前炖到什么程度下次调用时它还能接着上次的状态继续。FB最大的特点是能保存内部变量比如一个轴的当前状态、累计运行时间、通信失败次数这些都要靠FB的“记忆”能力。FCFunction则是纯计算工具比如“把华氏度转摄氏度”给个输入就出结果内部不保留任何状态。在PLC里像数据类型转换、公式计算、简单的逻辑判断用FC就够了不需要给它配一堆静态变量。三者的选择其实有个简单标准需要记忆状态的就用FB纯运算没有状态的就用FC负责整体流程调度、跟扫描周期绑定的就是PRG。1.2 模块化解决的是“人祸”问题我刚入行的时候接过一套老设备程序一个PRG里有4000多行梯形图输入输出点全直接映射到全局变量想改一个气缸动作我得满程序找这个输出的引用位置找到之后还要担心会不会影响旁边三个看似无关的步序。那种程序现场设备一抖谁都不敢动。POU模块化的本质不是把代码拆碎而是把功能边界切清楚。比如一个送料设备你可以切成轴控制、气缸序列、报警处理、配方管理、通信管理。每个功能放进独立的FB通过定义好的输入输出接口去交互内部怎么实现是它自己的事。这样改气缸顺序的时候你只需要看气缸序列那一个FB其他模块碰都不用碰。用CodeSys还有一个巨大的优势只要接口不变FB内部随便折腾。一开始用梯形图写的功能块后面改成ST甚至全部推倒重写调用方完全无感。2. 模块化设计思路先画结构再动键盘2.1 一个追标送料项目里的POU划分前阵子做了一套追标送料的设备控制核心是汇川Easy320配了一个GL20-2HC高速计数模块接脉冲传感器做追标基准EtherCAT总线上挂了4台伺服触摸屏走Modbus TCP和PLC通信。这类项目特别适合讲POU划分因为既有运动控制又有高速计数还有通信功能点很典型。我的程序结构分成了这些程序组织单元POU类型职责MainPRGPRG主循环调度各功能块维护总启动/停止逻辑FB_AxisGroupFB管理4个伺服轴的使能、回零、点动、绝对定位FB_CountCaptureFB读取GL20-2HC计数做追标窗口判断FB_VfdControlFB通过Modbus RTU控制若干台变频器FB_AlarmCenterFB统一收集报警信息记录时间联锁停机FB_ParamSetFB配方参数管理从HMI下发参数并写PLC保持区FB_RunStateFB设备运行状态机负责自动模式下的步骤跳转MainPRG里做的事情非常简单就是按顺序调用这些FBfbAlarmCenter(); // 报警先扫有报警直接截停后续动作 fbAxisGroup(); // 轴状态刷新 fbCountCapture(); // 高速计数采集与追标判断 fbVfdControl(); // 变频器通信处理 fbRunState(); // 设备工艺流程 fbParamSet(); // 参数处理放最后避免本周期参数变化影响工艺你会注意到报警处理永远放在最前面。这是现场总结出来的经验设备出异常必须第一时间停下动作逻辑即使后面几个功能块还在执行也不会触发危险动作。2.2 接口设计比功能实现更重要POU划分完成后最关键的其实是每个FB的接口定义。很多人栽就栽在这里功能块写得很爽把一堆全局变量直接往FB里拖看起来是封装了实际上耦合得比不封装还紧。我的原则很简单FB不直接读写其他模块的全局变量所有数据通过VAR_INPUT和VAR_OUTPUT进出。举个实际例子FB_AxisGroup它的输入输出长这样FUNCTION_BLOCK FB_AxisGroup VAR_INPUT bEnable : BOOL; // 总使能 eAxisCmd : INT; // 轴命令0无 1回零 2点动 3定位 rSetPos : ARRAY[0..3] OF LREAL; // 定位目标位置 rJogSpeed : LREAL; // 点动速度 END_VAR VAR_OUTPUT bReady : BOOL; // 轴组就绪 bMoving : BOOL; // 有轴运动中 bHomeDone : ARRAY[0..3] OF BOOL; // 各轴回零完成 aActPos : ARRAY[0..3] OF LREAL; // 各轴实际位置 wErrorCode : WORD; // 错误码 END_VAR这样设计的优势在联机调试的时候体现得特别明显。当触摸屏上的点动按钮不好使需要排查时直接在Watch窗口里看FB_AxisGroup的输入输出一眼就知道是HMI发过来的eAxisCmd没到还是FB内部状态机卡住了。所有数据交互都在同一个FB的面板上不需要拎着放大镜去刺刀见红找变量。3. 亲手写一个顺滑的轴控制功能块3.1 功能块的需求定义轴控制是几乎每个中型PLC项目都绕不开的主题。你可能会说“直接用库里的MC_Power、MC_MoveAbsolute不就行了”但在汇川CodeSys环境下直接用运动控制库封装好的指令确实省事。可是裸用有几个问题每个轴都要写一组MC_Power MC_Home MC_MoveAbsolute MC_Stop程序中全是重复代码而且轴的时序控制乱成一锅粥。所以标准做法是底层用官方库指令上层自己包一层FB_Axis把轴使能、回零、定位、点动全部封装进去。这样到MainPRG里一条指令就完成一个轴的控制清爽无比。FB_Axis的接口FUNCTION_BLOCK FB_Axis VAR_INPUT bPowerOn : BOOL; // 伺服使能 eCmd : INT; // 命令0停车 1回零 2绝对定位 3点动正转 4点动反转 rTargetPos : LREAL; // 定位位置单位mm或pulse rVelocity : LREAL; // 运行速度 rAcc : LREAL; // 加速度 rDec : LREAL; // 减速度 END_VAR VAR_OUTPUT bIsEnable : BOOL; // 已使能 bIsHomed : BOOL; // 已回零 bIsMoving : BOOL; // 运动中 bDone : BOOL; // 本次指令完成 bError : BOOL; // 错误标志 wErrorID : WORD; // 错误码 rActualPos : LREAL; // 实际位置 END_VAR3.2 状态机实现代码FB内部我习惯用一个INT状态变量走状态机。状态定义0空闲1使能中2回零中3定位中4点动中5停止中10错误核心逻辑用CASE实现简洁清晰CASE eState OF 0: // 空闲 bDone : FALSE; bIsMoving : FALSE; IF bPowerOn THEN // 触发伺服上电 MC_Power_Instance.Enable : TRUE; eState : 1; END_IF 1: // 使能中 IF MC_Power_Instance.Status THEN bIsEnable : TRUE; eState : 0; // 使能完成后回空闲等待具体命令 END_IF 2: // 回零中 bIsMoving : TRUE; MC_Home_Instance.Execute : TRUE; IF MC_Home_Instance.Done THEN bIsHomed : TRUE; bDone : TRUE; eState : 0; ELSIF MC_Home_Instance.Error THEN eState : 10; END_IF 3: // 定位中 bIsMoving : TRUE; MC_MoveAbsolute_Instance.Execute : TRUE; MC_MoveAbsolute_Instance.Position : rTargetPos; MC_MoveAbsolute_Instance.Velocity : rVelocity; MC_MoveAbsolute_Instance.Acceleration : rAcc; MC_MoveAbsolute_Instance.Deceleration : rDec; IF MC_MoveAbsolute_Instance.Done THEN bDone : TRUE; eState : 0; ELSIF MC_MoveAbsolute_Instance.Error THEN eState : 10; END_IF 4: // 点动正转 bIsMoving : TRUE; MC_MoveVelocity_Instance.Execute : TRUE; MC_MoveVelocity_Instance.Velocity : rVelocity; // 如果命令取消切换停止 IF eCmd 3 THEN // 停掉速度指令 MC_MoveVelocity_Instance.Execute : FALSE; eState : 5; END_IF 5: // 停止中 MC_Stop_Instance.Execute : TRUE; IF MC_Stop_Instance.Done THEN bIsMoving : FALSE; eState : 0; END_IF 10: // 错误状态 bError : TRUE; wErrorID : MC_Power_Instance.ErrorID; // 只有收到复位命令才退出 IF eCmd 99 THEN bError : FALSE; eState : 0; END_IF END_CASE;这段代码虽然简化过但已经足够体现FB封装的价值。你看到关键点了吗外部调用方根本不用关心轴内部处于什么状态只要给命令、读输出即可。3.3 被忽略的几个关键细节写这个FB的过程中有几个细节特别容易翻车单独拎出来说。第一是超时处理。回零和定位如果伺服卡住官方库的Done可能永远不来程序就死等了。我后来给每个运动阶段都加了看门狗计时器超过设定时间直接跳错误状态把故障抛给HMI弹窗提示至少设备不会卡在中间不动。加计时器的方式很简单用TON指令就行状态机里面每次进入运动状态就复位计时器。第二是指令切换时的边界条件。比如设备正在定位途中操作工突然切到点动模式这时候如果你直接把MC_MoveAbsolute的Execute拉低再触发MC_MoveVelocity官方库会报重叠调用错误。正确做法是先走停止状态等轴完全停稳了再响应新命令。这也是我状态机里面设置“停止中”这一态的原因。第三是位置单位的统一。汇川伺服支持电子齿轮比HMI上操作的显示单位是mm伺服内部实际单位是pulse两者之间换算一旦搞错设备动起来能吓死人。我一般在FB内部做换算输入接口只认mm输出也只报mm所有pulse层面的数据在FB内部消化掉。4. Modbus轮询与多台变频器通信的封装4.1 32台变频器一台一台写不现实热词里面好几个都和“PLC控制32台变频器”“一个西门子PLC与32个变频器Modbus通讯”相关说明这确实是工程里的常见需求。用汇川Easy系列做Modbus RTU主站时RS485总线上挂32台变频器需要注意的点非常多。我最早做这类项目的时候直接在梯形图里堆Modbus_Comm_Load、Modbus_Master一串指令指令的调用位置不统一。后来发现一个致命问题32台变频器如果同时发请求485总线必然冲突你必须自己处理轮询时序否则现场通信乱成粥。正确的做法是写一个FB_VfdModbus把轮询逻辑封装在里面外部只给一个“启动通信”的信号和一组变频器参数表剩下的调度全部内部处理。4.2 寄存器地址映射表先行写通信程序前第一件事不是敲代码而是对照变频器手册把寄存器表整理出来。以汇川MD420变频器为例常用的几个地址功能寄存器地址读写数据类型运行/停止命令0x2000写WORD设定频率0x2001写WORD输出频率0x2010读WORD输出电流0x2011读WORD故障代码0x2020读WORD至于32台设备的地址映射我习惯用数组来维护VAR arrVfdAddr : ARRAY[0..31] OF WORD; // 变频器站号 arrVfdSpeed : ARRAY[0..31] OF INT; // 各变频器设定速度 arrVfdRunCmd : ARRAY[0..31] OF BOOL; // 各变频器运行命令 arrVfdRunning : ARRAY[0..31] OF BOOL; // 各变频器运行状态反馈 arrVfdFault : ARRAY[0..31] OF WORD; // 各变频器故障码 bCommBusy : BOOL; dwSlaveIdx : DWORD; // 当前轮询到哪个从站 END_VAR这样设计的好处是程序里循环处理即可不需要为32台设备写32份重复逻辑。4.3 轮询状态机的实现思路轮询调度的核心是一个状态机不断在“空闲→发送→等待响应→处理结果→下一站”之间循环。关键代码如下CASE eCommState OF 0: // 空闲发起下一轮请求 IF bCommEnable THEN eCommState : 1; dwSlaveIdx : 0; END_IF 1: // 发送当前站请求 IF dwSlaveIdx dwSlaveCount THEN // 准备发送报文读取该站运行状态和输出频率 bCommBusy : TRUE; Modbus_Master_Instance.slave : arrVfdAddr[dwSlaveIdx]; Modbus_Master_Instance.function : 16#03; // 读保持寄存器 Modbus_Master_Instance.startAddr : 16#2010; Modbus_Master_Instance.quantity : 2; Modbus_Master_Instance.Execute : TRUE; eCommState : 2; ELSE // 所有站都扫完一轮重新开始 dwSlaveIdx : 0; eCommState : 1; END_IF 2: // 等待响应 IF Modbus_Master_Instance.Done THEN // 保存数据 arrVfdRunning[dwSlaveIdx] : ...; arrVfdFault[dwSlaveIdx] : ...; Modbus_Master_Instance.Execute : FALSE; dwSlaveIdx : dwSlaveIdx 1; eCommState : 1; ELSIF Modbus_Master_Instance.Error THEN // 读失败记录错误跳过该站 dwVfdErrCnt[dwSlaveIdx] : dwVfdErrCnt[dwSlaveIdx] 1; Modbus_Master_Instance.Execute : FALSE; dwSlaveIdx : dwSlaveIdx 1; eCommState : 1; END_IF END_CASE;轮询时序还有一个关键参数每两个从站请求之间要留一点延时。RS485半双工通信从站响应需要时间主站发完一帧后立即发下一帧大概率会撞车。我在状态机里用TON加了一个50ms的站间隔32个站扫完一轮大约1.6秒对变频器这种慢对象完全够用。4.4 通信故障要自动恢复现场环境恶劣485线干扰导致通信失败是常态。所以FB内部一定要做故障自恢复逻辑每个站的连续失败次数超过N次就标出该站通信异常并输出到HMI但当它恢复正常后计数器清零异常自动取消而不是必须断电重启才能恢复。这个设计在长时间运行的设备上非常实用。否则半夜设备通信闪断一次如果PLC没有自动恢复机制操作工就只能等第二天你来现场处理产线直接停摆。5. 常见问题与排查技巧实录5.1 EtherCAT扫不到伺服怎么办汇川H5U和Easy系列做EtherCAT主站最常用的场景是带IS620N伺服。新手最容易遇到的就是“扫描从站列表里空荡荡一个伺服都找不到”。排查顺序我总结为网线物理链路网口灯亮不亮→ 伺服驱动器状态有没有上电、有没有报ERR→ 从站站号设置通过面板确认伺服站号没有重复→ EtherCAT主站配置扫描前是否选了正确的网卡。这里有一个非常隐蔽的坑汇川CANlink和EtherCAT是同一个物理接口如果伺服驱动器里面通讯协议没切到EtherCAT模式主站永远扫不到。有些型号默认是CANlink或者Modbus模式要手动改参数切换协议类型。5.2 修改汇川PLC的IP地址经常有人问CodeSys里怎么改PLC的IP。其实在InoProShop或者CODESYS开发环境里在线连接PLC后右键点击设备树里的PLC节点选择“更改IP地址”就能直接修改。要注意的是改完IP后PLC需要重新上电生效并且如果你是通过原IP在线连接的改完那一刻通信会断开需要重新使用新IP搜索连接。需要特别提醒的是改IP前先把工程文件存好盘。有一次我改完IP没保存工程重新上电后PLC里面程序直接丢了又从备份恢复折腾了一个多小时。5.3 POU调用顺序导致数据不同步CodeSys里任务扫描顺序对多PRG项目影响很大。比如MainPRG调用FB_AxisGroup获取了当前轴位置接着又用到了这个位置做追标判断但此时FB_CountCapture还没执行计数数据是上一周期的旧数据逻辑上就会出现一拍的延迟。解决方案一是把数据依赖链上的FB按顺序放在同一个任务里先算计数、后算轴位置、再算工艺二是关键数据在通信握手时挂一个“本周期已更新”标志位防止用旧数据。个人建议能用顺序解决的不要搞标志位顺序调度比标志位靠谱得多。5.4 类型转换和比较的坑ST在汇川CodeSys上的类型检查比较严格REAL和LREAL、INT和WORD之间不能直接比较或赋值。我遇到过一个非常典型的错误用DINT类型的轴实际位置去和HMI过来的LREAL目标位置直接比较判等结果因为浮点误差位置永远对不上。解决办法浮点数一律不判等而是判断两个数的差值落在允许偏差范围内。比如IF ABS(rActualPos - rTargetPos) rTolerance THEN bInPosition : TRUE; END_IF这种小问题不踩一次坑很难涨记性但踩过之后你会发现这类问题排查起来比功能逻辑问题恶心多了。6. 写POU的几条实战心得6.1 命名规范统一三个月后还能看懂做模块化设计的第一前提是命名可读。我自己的规范是FB文件名用FB_前缀PRG用Main开头函数用FC_前缀变量用匈牙利前缀区分类型BOOL的用bINT用n或iREAL/LREAL用rWORD/WORD用w数组用arr。例如bIsEnable、nCounter、rActualPos、wErrorID、arrVfdFault。这样任何人打开你的程序光看变量名就知道类型和用途不用反复点开声明列表翻眼睛。6.2 注释是写给别人的更是写给未来的自己一个FB头部我会写清楚功能描述、创建日期、创建人、版本历史、接口说明、注意事项。内部逻辑的关键段落也加注释尤其是那些看似莫名其妙的转换逻辑比如为什么这里要加50ms延时、为什么这个状态要先停车再切换。相信我三个月后你回来看自己的代码如果原来没注释你会怀疑这代码到底是不是自己写的。6.3 版本管理不能靠文件名带日期CodeSys工程和普通软件开发不一样很多人习惯“程序_20250115_V6_final_FINAL”这种命名。说实话这种命名方式至少能让你找到最新版但遇到甲方要求改回上一版逻辑时光靠文件名回溯非常痛苦。我的做法是关键节点用CodeSys自带的工程比较工具或者直接把里程碑版本导出Library保存每完成一个设备调试阶段就导出一次。改动前先导出当前可运行版本改坏了能秒回滚。6.4 从单机功能块到通用库当你在多个项目里反复使用同一个FB_Axis、FB_VfdModbus之后强烈建议把这套FB整理成自己的库文件。汇川CodeSys是支持Library封装的把自己的经验沉淀成库下次做新项目直接拖过来用。这不只是省时间更重要的是把你的经验固化成了资产不会因为换一个人、换一个项目就把之前的坑重新踩一遍。7. 我在实际项目里的几句大实话模块化这件事最忌一步到位。我第一次做POU拆分时拆了二十多个FB接口定义得极其复杂结果项目中期自己都快记不住哪个参数往哪里传。后来逐渐形成“能用、够简、不做过度设计”的原则每个FB解决一个明确问题接口控制在几个到十几个参数之间内部状态机不超过十几个状态。这样既能达到模块化和复用的目的又不至于把自己绕进去。另外别迷信任何人的代码风格。你拿我这套思路去用也要根据自己的设备工艺、团队协作方式去调整。真正有用的模块化是在你反复踩坑、反复重构之后长出来的东西而不是照着教程抄一个结构就能一劳永逸。程序组织单元的核心不是形式上的“拆”而是边界上的“清”——每个块只管自己的事接口干净故障可控。做到这一步你的程序就已经超过了绝大多数同行。