面向对象编程在PLC控制中的应用:以CODESYS和AM600为例
简介这是一份面向工业自动化开发者的 Codesys 编程进阶资料聚焦汇川 AM600 平台上的面向对象编程OOP实现。内容围绕动作Action、方法Method、属性与继承展开讲解如何在函数块或程序中添加动作、定义方法、使用访问修饰符PRIVATE/PUBLIC以及利用继承扩展功能块并配有 PROGRAM MAIN 实例和 ST 语言调用片段适合已掌握基础 IEC 61131-3 语法、希望提升代码模块化与复用能力的中高级工程师。资源为单个 PDF 文件压缩包约 768KB内容紧凑、图文与代码示例并存便于随时查阅。目前已有 8137 人学习是汇川 AM600 用户了解面向对象编程的实用参考资料。通过阅读读者可以掌握动作与方法的添加流程、作用域规则及调用方式理解面向对象设计在 PLC 控制程序中的落地思路从而编写出更清晰、易维护的应用程序。1. 为什么AM600上的CODESYS要先谈面向对象PLC程序越写越乱大多数时候不是逻辑难而是变量倒腾不清楚。油泵、气缸、伺服轴各管各的状态全堆在全局变量表里梯形图里点一处ST里再改一处改到后面不敢动只能靠补丁叠补丁。CODESYS汇川AM600面向对象(动作方法属性)这套方案说的是在AM600这类IEC 61131-3控制器上把设备封装成“类”属性代表设备当前状态方法表示它能执行的某件完整的事动作负责状态里最底层的步骤主流程面对的是一个可复用、可替换的调用界面。这篇笔记直接落到能跑的ST代码解决一个实际问题如何不过度设计、不牺牲实时性地让AM600运动控制代码从“面向变量”变成“面向设备”。2. CODESYS的OOP不是新语法是给FUNCTION_BLOCK挂上方法、动作、属性先给结论在CODESYS V3.5体系里并没有一个突然出现的“类”关键字。你写的每一个FUNCTION_BLOCK经过声明里的方法、动作、属性挂载之后逻辑上就是一个类。AM600的CODESYS工程正是用这种扩展方式实现了通用面向对象落地而不是像C那样另起炉灶。2.1 FUNCTION_BLOCK怎么变成“类”三种成员挂载的差异在AM600的CODESYS工程里新建一个FUNCTION_BLOCK例如命名LX_Axis它就自动具备类的三个入口在块内部空白处点击右键能看到添加方法、添加属性、添加动作、添加接口等选项。方法METHOD是一段独立的ST代码段可以定义输入输出参数可以返回数值调用时像函数块的一个函数动作ACTION同样是一段可执行代码段但它是挂在FB实例上的命名逻辑块没有参数、没有返回值只能操作当前FB实例内部的变量属性PROPERTY是一对读写器对外看起来像一个变量实际每次读取都执行GET段每次写入都执行SET段。三者最关键的区别在于调用约定方法带参数和返回值适合一次性完成一个决策比如“使能成功返回TRUE失败返回FALSE”动作不带参数适合承载一个可重复执行的短流程比如“执行回原点动作”属性最像变量适合把内部状态镜像成对外可读的数值比如当前实际位置或当前速度。这一点必须先立住因为后面很多翻车都是把动作当方法用。方法调用写在赋值语句或条件表达式里动作调用则直接写成“实例名.动作名”。2.2 选方法还是选动作从返回值、参数和调用时序来选很多工程师第一次面对“方法、动作、属性”会问同一个问题有了方法为什么还要动作我一般这样回答如果你需要把结果带回调用点用方法如果这个动作是被设备自身状态机周期性驱动的只是恰好需要从外部触发一下用动作。二者并存的真实原因来自于PLC顺序控制的特点。举个例子。伺服轴回原点这个操作传统做法是在梯形图里置位一个“回零命令”线圈然后等原点信号出现再复位。用面向对象表达时你可以把“回零命令置位”写成一个动作AC_RunHome因为它在整个回零过程中只负责启动后续状态由FB主逻辑去推进也可以写一个方法FN_Home带一个超时参数方法内部等原点信号然后返回是否成功。前者适合放在多轴调度里由顺序控制阶段去触发后者适合单轴手动测试每调用一次阻塞到结果出来。AM600的CODESYS运行环境两者都支持选型的依据不是哪个更高级而是你的调用点想不想拿到结果动作拿不到结果这会让某些联锁逻辑写起来更别扭。再补一个使用场景当一个FB实例有多个动作时动作之间允许共享FB内部变量并且可以互相调用。方法之间共享变量也可以但方法常被设计成无内部停等因为它更像一个封装后的查询或指令。如果某个操作需要多个周期才能完成不要放进方法里等放进动作里配合状态位更符合PLC的习惯。2.3 属性与输入输出变量看不出来的区别初学者最容易忽略的是属性。一个FUNCTION_BLOCK原本就有VAR_INPUT、VAR_OUTPUT为什么还要属性VAR_INPUT是外部每周期赋给FB的输入参数VAR_OUTPUT是FB每周期对外输出的状态。它们都只解决“这个实例当前周期的输入输出”没法表达“外部随时想读一下当前转速”这种请求。属性就是补这个缺口的在GET段里写读取逻辑外部代码写“IF axis.rActualSpeed 100.0 THEN”不需要知道内部是直接读一个变量还是把两个编码器数值换算出来的结果。这样做的好处是后续如果从脉冲轴换成总线轴只要把GET段内部的采集地址改掉调用方代码一行不用动。选型理由再说清楚一点如果你的访问是低频、偶发的用属性如果是每个周期都需要同步读的大批量状态用VAR_OUTPUT或直接把内部变量开放成公共变量性能反而更直接。属性每一GET都是一次函数调用在紧凑周期任务里塞太多属性读取会拉长任务执行时间。AM600主频不低但实时内核上一样要算任务负载。我见过整段逻辑全用属性互读的项目一个周期里属性调用链扯出十几个栈帧最后靠监控任务最长执行时间才发现是属性访问拖慢了中断。属性好用但不能滥用。3. 在汇川AM600上写第一个设备类电机轴的封装与应用面向对象真正落地不是先画类图而是先挑一个足够简单的设备动手。电机轴是最合适的对象有使能、有回零、有速度状态清晰IO点少翻车容易定位。下面这段代码就是最小可复现的轴类。3.1 定义轴类LX_Axis先建立IO结构体再定义内部状态我习惯先把驱动器侧的真实IO整理成一个结构体然后让功能块持有它的引用。这样同一个类代码可以实例化成轴1、轴2、轴3每一轴只更换传入的IO结构体。这个做法比在功能块内部直接引用全局变量要通用得多也方便在调试台上做模拟。TYPE ST_DriveIO : STRUCT bEnableOut : BOOL; // 驱动使能输出点 bReadyIn : BOOL; // 驱动器就绪输入 bAlarmIn : BOOL; // 驱动器报警输入 diPosFeedbackIn : DINT; // 编码器位置反馈经总线映射 diSpeedRefOut : DINT; // 速度给定字 END_STRUCT END_TYPE结构体定义之后轴类功能块声明里使用VAR_IN_OUT引用这个结构体而不是复制一份FUNCTION_BLOCK LX_Axis VAR_IN_OUT stIO : ST_DriveIO; END_VAR VAR bEnabled : BOOL : FALSE; // 当前使能状态 diActualPos : DINT : 0; // 实际位置镜像 diTargetPos : DINT : 0; // 目标位置 bHomeRunning : BOOL : FALSE; // 回零进行中 END_VARVAR_IN_OUT是引用传递不复制数据省内存且能让方法直接改写调用方传入的IO结构体。这一点在AM600上很重要因为一个中型项目可能有十几个轴如果每个轴都复制一大块IO结构体总数据内存会涨得很快任务时间也会被拖长。轴类内部还应该有一个“本体”逻辑段在功能块ST主体里写每个周期执行的默认刷新比如把反馈位置镜像到内部变量把报警状态记录到一个内部位。这相当于面向对象里的“构造函数周期性执行体”虽然它不叫构造函数但承担了实例的周期状态刷新。// FB主体每个任务周期执行一次 diActualPos : stIO.diPosFeedbackIn; bReady : stIO.bReadyIn;注意这段代码写在LX_Axis的功能块主体里不属于任何一个方法或动作。它每周期都执行是类的“默认心跳”。3.2 方法、动作、属性的ST写法轴类的使能逻辑适合写成方法因为它有返回值调用方需要知道使能是否成功。METHOD PUBLIC FN_Enable : BOOL VAR_INPUT bSwitch : BOOL; // TRUE使能 FALSE断开 END_VAR // 复位报警之后才允许使能 IF bSwitch AND NOT stIO.bAlarmIn THEN stIO.bEnableOut : TRUE; ELSE stIO.bEnableOut : FALSE; END_IF // 返回实际状态使能输出且驱动器就绪 FN_Enable : stIO.bEnableOut AND stIO.bReadyIn;参数说明bSwitch由外部触发每次调用时传入只要驱动器报警存在使能输出不允许置位。这是很多真实设备上的安全互锁要求。返回值同时考虑了输出位和驱动器就绪位避免“软件已经置使能但硬件没就绪”的假成功。回零操作适合写成动作。动作没有参数直接操作FB内部变量。在AM600工程里动作是以独立编辑页存在的在LX_Axis功能块下新建动作命名为AC_RunHome然后写以下代码// 动作AC_RunHome // 回零触发使能状态下且不在回零中才允许置位 IF bEnabled AND NOT bHomeRunning THEN stIO.bHomeCmdOut : TRUE; bHomeRunning : TRUE; END_IF动作体内所有变量都来自LX_Axis实例内部因此它天生就是“面向对象的方法”的另一种形态。调用时直接写stAxis1.AC_RunHome();无法带参数也无法把回零是否成功的结果返回给调用者。这种限制在顺序控制里反而是优势状态机在阶段里触发一次回零动作回零是否完成由FB自己的状态位去告诉状态机动作本身不阻塞调用者。属性写当前实际速度。驱动器反馈通常是一个编码器计数值需要换算成工程单位转每分钟PROPERTY PUBLIC rActualSpeed : REAL GET // 返回内部镜像换算后的速度 rActualSpeed : diActualPos / 2048.0 * 60.0; SET // 速度是只读属性外部写入直接丢弃 ;属性GET里用的是diActualPos镜像而不是每次去读stIO.diPosFeedbackIn这是为了减少属性与总线数据不同步的风险。SET段为空意味着外部代码对这个属性赋值不会生效语义上和只读变量一致。属性对外隐藏了换算细节调用方只认rActualSpeed这个“自然名称”。3.3 在结构化文本中实例化并完成主流程调用轴类定义好后在全局变量区声明实例在任务循环里调用VAR_GLOBAL stAxis1 : LX_Axis; stDrive1IO : ST_DriveIO; END_VAR PROGRAM MainTaskImpl VAR xEnableCmd : BOOL : TRUE; // 总使能命令 END_VAR // 每周期绑定IO结构体 stAxis1(stIO : stDrive1IO); stAxis1.FN_Enable(bSwitch : xEnableCmd); // 调方法 // 回零阶段触发动作 IF xHomePhase THEN stAxis1.AC_RunHome(); END_IF // 读属性做监控 IF stAxis1.rActualSpeed 1500.0 THEN xOverSpeed : TRUE; END_IF这里有一个很多新手绕不过去的点stAxis1(stIO : stDrive1IO)这句每周期都会执行它其实是在刷新引用同时也会触发FB主体的逻辑。方法调用、动作调用不改变引用绑定。整个流程下来IO结构体是外部注入的状态量是类内部自有的对外交互全部经过方法、动作、属性三个口子。这正是面向对象希望达到的效果你不需要关心驱动器的IO点地址在哪里只需要调用轴类的口子。4. 让类的属性与真实硬件接通AM600的IO地址映射与任务调度设备类写好了下一步是让stIO真正指向AM600的物理地址。很多项目死在这一步类代码完美但地址映射配错现场使能信号不亮或编码器读出来是负数然后到处改方法越改越乱。4.1 先做IO地址映射还是先做结构体我建议先做结构体AM600的IO映射常见做法是在设备配置里把远程从站的输入输出模块映射到程序变量。直接在全局变量列表里手写硬地址确实能用但可维护性太差。我建议在设备视图里先建好IO映射表把物理点关联到ST_DriveIO的实例成员代码里只看到stDrive1IO不看到%IX和%QX。实际操作是这样在全局变量区定义IO结构体实例后打开设备配置找到对应从站的输入输出映射页将一个物理通道拖到结构体成员上。例如将第一个输出断电器通道映射给stDrive1IO.bEnableOut位置反馈通道映射给stDrive1IO.diPosFeedbackIn。这样做的核心原因是类和IO解耦。如果设备换了一个从站模块地址变了只需要修改设备配置里的映射关系ST代码一行不动。很多老工程师习惯在全局变量里单独定义几十个BOOL再在FB内部用VAR引用它们。那也能跑但每加一个轴就要重复一堆地址变量。结构体映射把一整套驱动IO绑定成一个对象传给轴类时只需要传一个成员代码量直接减掉一半。4.2 用VAR_IN_OUT还是用直接地址访问实时性对比在AM600上VAR_IN_OUT引用传递的参数在方法内部访问时每次都是直接寻址没有数据拷贝实时性足够好。有人会把IO变量直接声明成属性在属性的GET段里写%IX地址访问。这种做法不推荐因为属性GET可能被外部在一个周期内调用多次每次调用都去总线上取最新值表面上“实时”实际上会造成重复读取增加总线周期压力而且容易被中断任务打断导致同一份IO数据在两个时间段读到不同的值。正确的做法是总线数据由系统在每个IEC任务周期开始时刷新一次然后我们在FB主体里把从站反馈镜像到内部变量。后续所有属性GET都读镜像保证单个周期内数据是一致的。用一个简单的代码片段说明镜像过程// FB主体 diActualPos : stIO.diPosFeedbackIn; bReady : stIO.bReadyIn;属性GET再取diActualPos时单个周期内多次读取结果一致。这消除了数据抖动也让属性本身变得非常轻。对于AM600这种中型PLC数据一致性比“绝对实时”更重要因为运动控制里的位置反馈在单个周期内必须是一个稳定的快照否则PID计算会被撕裂信号干扰。4.3 任务周期与执行频率一个实例一个任务的原则CODESYS工程里存在多个任务例如一个1ms的快速任务做位置闭环一个10ms的主任务做顺序控制。面向对象代码最容易在这里翻车同一个方法既被快速任务调用又被主任务调用都试图去改写同一个内部变量最终表现就是位置偶发抖动查不出原因。我建立的项目规范是一个设备实例只归属于一个任务该实例的所有方法、动作调用也只能发生在同一个任务内。跨任务沟通用命令结构体而不是直接调方法。建立一个简单的任务配置说明快速任务FastTask周期1ms优先级0只做位置闭环和电流/速度给定刷新主任务MainTask周期10ms优先级2处理顺序控制、轴调度、回零流程慢任务UiTask周期50ms优先级5处理人机交互和属性读取。轴类实例stAxis1只放在MainTask里调用快速任务需要读取轴的状态时不直接调用stAxis1的属性和方法而是把主任务写给快速任务的镜像数据结构体作为输入。结构体里的位置信号从快速任务自己的总线镜像来主任务只是发布目标位置命令。这种分层在AM600多轴项目里很可靠也能让任务负载更均衡。监控任务最长执行时间时可以看到每个任务的执行时间有没有超过周期预算如果属性读取放错了任务第一刀就切这里。5. AM600上OOP落地最常踩的5个坑现象、根因、解法面向对象在PC上成熟不代表在PLC上也一样顺手。以下五条都是实际调试中被反复踩过的坑每一条都按现象、根因、解决三步记录。5.1 坑一把动作当方法调用动作块为什么不执行现象写好了AC_RunHome动作在主程序里用stAxis1.FN_Enable(bSwitch : TRUE)的方式调用它结果动作不执行或者只执行一次状态完全不对。根因动作是挂在FB实例下的命名代码段不是方法。方法有输入输出和返回值能出现在表达式中动作没有任何参数和返回值它在调用时只是把执行权转移给那段代码在CODESYS里动作调用与普通方法调用的调用约定是不同的。把动作当成带括号的方法使用编译器可能直接报错或者在某些版本中静默忽略掉不在计划任务中的动作。解决动作调用直接写“stAxis1.AC_RunHome();”不带参数也不做返回值接收动作内部需要的结果通过实例变量传递。回零是否完成看stAxis1.bHomeRunning的状态不要在调用动作那行代码里期待返回结果。如果某个行为需要返回值请把它写成方法。5.2 坑二属性GET里直接访问硬件地址位置数值跳变现象轴类里的rActualSpeed属性GET段直接写stIO.diPosFeedbackIn结果人机界面上的速度一直在抖动幅度超过设定值的10%。根因属性可能被界面任务、监控任务频繁触发而每次间接访问从站IO映射变量时总线周期和任务周期不同步偶尔会读到一个正在更新的中间值。这就相当于在多线程环境下读共享变量虽然没有崩溃但数据不一致。解决在FB主逻辑里先把从站反馈镜像到内部变量属性GET只读取镜像。单一周期内所有属性访问读到的是同一个快照。同时把属性读取尽量放在慢任务里不要进入1ms快速任务的任务链。5.3 坑三多任务同时读写共享属性导致运动曲线撕裂现象轴在做匀速运动曲线有小幅锯齿监控任务里看到位置偶尔回退几十个脉冲又立刻恢复。根因主任务在写目标位置属性快速任务在写实际位置镜像监控任务又在读属性三个任务并发访问同一个实例内部变量。AM600的IEC任务调度不是按代码顺序独立运行的两个优先级不同的任务可能在任意时刻切换导致一个任务写入一半时另一个任务读取到了不完整数据。解决一个实例只在一个任务里被调用。快速任务需要的位置信号由主任务通过专用命令结构体发布结构体里的数据一次性拷贝不通过属性传递。属性这个口子只留给慢任务层面的高级语言访问例如人机界面、配方管理。让任务职责保持干净比在代码里加一堆锁要可靠得多。5.4 坑四继承方法之后老实例的IO地址错位现象项目里有A、B两种轴A轴继承自基类B轴也继承自基类结果B轴创建后A轴的方法偶尔会去操作B轴的IO结构体或者相反。根因派生类方法在编译期对基类私有成员做了偏移量寻址当多个派生类实例并存时如果基类方法内部直接引用了由外部注入的VAR_IN_OUT结构体而结构体绑定在实例化时被错误覆盖就会导致跨实例串地址。本质上这是实例引用和IO引用没绑定成一体造成的。解决每个派生类实例都单独持有自己的ST_DriveIO结构体并在实例化之后显式地重新绑定一次不要依赖全局默认绑定。家族类设计时IO结构体务必作为构造时的必传项在程序启动初始化阶段统一绑定所有方法内部一律通过“THIS指针”访问自身成员不跨实例复制引用。AM600上的继承能用但层级控制在两层以内超过两层就放弃继承改用接口组合。5.5 坑五软复位后方法里的临时变量没有按预期清空现象设备运行中做一次控制器软复位恢复运行后回零动作直接跳过或者使能方法一直报成功但实际上驱动器并没有上电。根因方法内部的局部变量在调用结束后并不自动销毁CODESYS会把方法里声明的一些临时变量编译成持久化的内存单元有些变量被标记为保持型软复位之后保留下来导致状态残留。常见的是方法内部有一个“上一次返回值”或“上一次中断标志”复位后停留在上次的值上。解决在方法开头设置显式初始化不使用变量声明时的默认值。瞬时标志位尽量用FB实例内部的非保持变量并在FB主逻辑里周期刷新。如果确实需要在软复位后重新执行一遍初始化流程可以在功能块主体里用一个初始化完成位来保护所有动作入口首周期强制复位内部状态然后再放行。这个做法虽然浪费几个周期但能挡住以后所有软复位类问题。6. 进阶验证用接口把轴类抽象成可替换的运动单元面向对象在AM600上用得最舒服的地方不是把一个轴封装得多漂亮而是把不同品牌、不同驱动类型的轴全部抽象成同一个接口。项目调试时通过接口数组管理四五个轴换轴时不改动主程序逻辑这才是这个方向真正值得投入的理由。接口定义很简单新建一个接口IC_Axis声明两个抽象方法INTERFACE IC_Axis METHOD FN_Enable : BOOL VAR_INPUT bSwitch : BOOL; END_VAR METHOD FN_MoveAbs : BOOL VAR_INPUT diPos : DINT; // 目标绝对位置 END_VAR END_INTERFACE然后让真实轴类实现这个接口例如总线轴类LX_BusAxisFUNCTION_BLOCK LX_BusAxis IMPLEMENTS IC_Axis VAR_IN_OUT stIO : ST_DriveIO; END_VAR METHOD PUBLIC FN_Enable : BOOL VAR_INPUT bSwitch : BOOL; END_VAR // 总线轴使能逻辑与脉冲轴不同但接口对外一致 FN_Enable : TRUE;主程序里把所有轴都声明成接口类型再逐一实例化绑定VAR axes : ARRAY[1..4] OF IC_Axis; axis1 : LX_BusAxis; axis2 : LX_BusAxis; END_VAR axes[1] : axis1; axes[2] : axis2;从这以后主流程只知道“第几轴”不知道它是脉冲轴还是总线轴。换轴的时候只需改实例化绑定那一行顺序控制、回零流程、报警联锁全部不用动。真实验证方法是把其中一根轴临时替换成模拟对象接口方法内部只更新镜像变量不下发硬件整个产线流程照样跑。让一套逻辑适配不同硬件这才是面向对象把AM600项目的维护成本真正压下来的地方。最后说一个我自己的教训面向对象是工具不是信仰。第一年我在这类轴类上套了三级继承类图很漂亮可现场固件一升级一个方法内部的数据类型发生变化整个派生族跟着遭殃。现在我的习惯是方法只写行为属性只做地址映射接口只留两三个关键动作复杂度永远控制在两层以内。少一点聪明设计多一点能一眼看穿的代码机器反而跑得更稳。希望这些思路能帮你在AM600上少踩几个坑。本文还有配套的精品资源点击获取