1. 为什么我劝你别再用梯形图硬啃复杂流程
做了几年PLC项目,我越来越觉得大多数工程师都卡在同一个坎上:程序写到最后,梯形图堆得自己都看不下去了。尤其是欧姆龙NJ/NX系列这种支持ST、支持结构化编程的控制器,如果你还用老一套思维在那里画触点线圈,其实是在拿自己的项目周期和脑细胞去做代价。
我接手过一个分拣线项目,十几个工位协同,每个工位内部还有四五个动作阶段,加上异常复位、暂停续跑、急停恢复。用梯形图硬撸了一个月,截图给朋友看,他回了句:这玩意儿连原作者都维护不了吧。确实,逻辑变量互相缠绕,互锁条件堆成山,改一个工位的时序,另外三个工位跟着出bug。那段时间我每天都在翻自己两周前写的程序,问我当时的自己:这个M100到底是干嘛的?
后来我把整个控制逻辑推倒重来,改用状态机。同样一套动作流程,代码量没减少多少,但结构清晰到我自己都感动。每个工位就是一个独立的状态机,什么时候该干什么、什么条件下允许切换,全部收敛在各自的CASE语句里。调试的时候看当前状态值,配合Sysmac Studio的在线监控,一秒钟定位问题出在哪个环节。这不是什么高深理论,就是嵌入式软件里再普通不过的编程范式,只是PLC圈子里用的人还不够多。
写这篇文章,我想把欧姆龙PLC上用Sysmac Studio实现状态机的完整思路、代码结构、踩坑经历全部摊开来讲。适合正在做复杂流程控制、被梯形图逻辑折磨得够呛、又对ST语言和结构化编程不太熟悉的工程师。内容不涉及高大上的算法,只有能直接抄作业的写法。
2. 状态机到底解决了梯形图的什么问题
2.1 梯形图的本质缺陷是"全图同时扫描"
要理解状态机的价值,得先明白梯形图为什么容易失控。PLC的扫描机制是自顶向下、从左到右,把所有网络全部执行一遍,然后循环。这本身没问题,问题在于:程序跑完一整轮之后,所有输出、中间变量都已经根据这一轮的输入刷新了一遍,下一轮又从头开始执行。如果你的流程控制是"先做A,再做B,再做C",你在梯形图里写出来的实际上是"满足条件就做A、满足条件就做B、满足条件就做C"的并联关系。
打个比方,梯形图就像一个大开间,每个工位、每个动作、每个互锁条件全在一间屋子里,谁都能看到谁,谁都能干扰谁。程序简单的时候无所谓,一旦动作流程超过二十步,各种顺序互锁、并行分支、异常复位搅在一起,这条"线"就看不见了——因为你根本没法确定在某一轮扫描里,到底哪个网络先执行、哪个中间变量先被改写、会不会同一个周期里既满足了步进切换条件又执行了下个动作。
我见过最典型的灾难现场是:设备急停复位后,气缸该伸的没伸,该缩的缩到一半,因为复位逻辑和运行逻辑在同一个扫描周期里互相打架。最后查出来是两个网络用了同一个中间变量,一个置位一个复位,执行顺序刚好反了。
2.2 状态机用"当前状态"把流程变成了一条单行道
状态机的核心就一句话:在任何时刻,程序只执行当前状态对应的那一段逻辑。其他状态的代码,就算条件全部满足,也不会执行。这种"互斥性"从机制上保证了流程不会乱。
具体到欧姆龙PLC的实现方式,就是用ST语言写CASE语句。CASE变量是当前状态值,每个状态一个分支,分支里写这个状态下要做的动作和跳转条件。扫描周期照常一轮一轮跑,但每一轮只进一个分支,逻辑上天然就是串行的。
还拿分拣线举例。一个上料工位的动作流程是:等待启动 -> 夹紧工件 -> 提升到位 -> 横移右行 -> 下降放料 -> 松开退回 -> 回原点。用状态机表示就是7个状态,每个状态里写动作输出和切换条件。任何时刻你在HMI上看到的状态值,就能直接判断设备正在干哪一步,而不是面对几十个中间变量猜"这一步该不该动"。
这就是状态机收敛复杂度的根本逻辑:它把"全局并发"降维成了"局部串行"。你不需要再关心其他状态在干什么,只需要盯住当前状态这一个变量,所有逻辑关系都围绕它展开。
3. Sysmac Studio里准备一个状态机的标准姿势
3.1 用枚举类型给状态起名字,别用数字写死
先做一个很重要的准备工作:在Sysmac Studio里给状态定义成枚举类型。很多人习惯用整数直接代表状态,比如0表示待机,1表示运行,2表示报警。程序里写CASE 1:、CASE 2:,看代码的时候还得翻注释才知道1和2分别是什么。状态一多,十个二十个状态堆在那里,满屏的数字,过俩月自己都看不明白。
Sysmac Studio的ST语言支持枚举类型定义。在全局变量表或者数据结构里定义:
TYPE E_STATE : ( ST_IDLE := 0, // 待机 ST_HOME := 10, // 回原点 ST_CLAMP := 20, // 夹紧 ST_LIFT_UP := 30, // 提升 ST_MOVE_R := 40, // 横移右行 ST_DOWN := 50, // 下降 ST_UNCLAMP := 60, // 松开退回 ST_RETURN := 70, // 返回原点 ST_ALARM := 990 // 报警 ) E_STATE; END_TYPE这里的编号我习惯用10的倍数跳着留增量,方便以后在中间插入新状态不用改其他状态的编号。枚举变量的声明:
progState : E_STATE;有了枚举,你写CASE的时候就是:
CASE progState OF E_STATE.ST_IDLE: ... E_STATE.ST_CLAMP: ... END_CASE;Sysmac Studio会自动把枚举显示成名字而不是数字,在线监控的时候直接看程序状态是ST_CLAMP还是ST_IDLE,不用查对照表。这个习惯越早养成越好,我见过太多人因为省这几分钟定义枚举的时间,后面每次调试都得把数字背一遍。
3.2 程序组织方式:状态机跑在任务里,传感器和执行器单独封装
状态机的魅力在于和外部IO解耦。不要在CASE语句里直接写IN_ClampSensor这种物理输入点的读取,而是把IO状态先映射到内部变量,再把执行器输出封装成动作函数。这样状态机只关心"逻辑上夹紧是否到位",不关心实际物理点在哪个通道。
具体到Sysmac Studio的程序组织结构,我是这么分的:
- 设备抽象层(IO映射):每个程序块里把物理输入点赋值给内部变量,物理输出点由内部变量驱动。这样换IO点的时候只需要改这一个地方。
- 状态机层(逻辑控制):每个工位或者每个控制对象一个状态机FB,内部只使用内部变量,不直接接触物理IO。
- HMI/通信层:把状态值、故障代码推到全局变量,供触摸屏和上位机读取。
分层的意义在于:状态机代码可以原样复制到其他项目里复用。只要底层变量的命名够规范,换一台设备换一套IO,状态逻辑一行都不用改。
3.3 任务周期和状态扫描:别让状态机"跳步"
NJ/NX系列的CPU支持多任务的配置,状态机逻辑建议放在固定周期的周期性任务里。这个周期决定了状态切换的时间精度。一般我用2ms到10ms,具体看设备工艺要求。太快没必要,太慢了动作衔接会卡顿。
Sysmac Studio里新建任务的时候,把程序块添加到任务下,同时设置任务周期。注意:如果任务周期设置得过短,而逻辑运算量比较大,可能造成任务超时报警。状态机这种CASE分支结构运算量不算大,但我见过有人把整个项目的梯形图全部塞进1ms任务里,结果PLC的扫描周期跑不完,直接报任务超时。
还有一个容易被忽略的点:状态切换条件的上升沿处理。在梯形图里习惯用微分指令(DIFU/DIFD),在ST里可以用上一周期状态值做判断实现上升沿效果:
// 上升沿检测:上一次不是某状态,本次是某状态 EdgeDetect := (progState = E_STATE.ST_IDLE) AND (NOT prevStateIsIdle); prevStateIsIdle := (progState = E_STATE.ST_IDLE);这种写法在状态机里很常用,特别是那种"按下按钮之后只触发一次"的场景,能避免因为扫描周期的原因导致同一条件在一个状态里反复触发。
4. 把状态机翻译成ST代码:一个气缸工位的完整示例
4.1 需求说明与状态划分
用一个最典型的单气缸加紧工位来演示完整写法。动作需求:
- 待机状态:气缸缩回,等待启动按钮。
- 夹紧:启动后输出夹紧电磁阀,伸出气缸。
- 等待位置到位:检测到伸出到位传感器后,保持夹紧3秒。
- 松开:3秒后缩回气缸。
- 缩回到位:检测到缩回到位传感器后,回到待机。
要求:任何时候急停信号触发,立即切断所有输出并进入急停状态;急停复位后回到待机。
按这个需求,状态可以这么分:
ST_IDLE:待机。ST_CLAMP:夹紧伸出,等伸出到位信号。ST_HOLD:到位保压计时。ST_RELEASE:松开缩回,等缩回到位信号。ST_E_STOP:急停状态。
4.2 程序块代码逐段拆解
下面这整段就是Sysmac Studio的ST程序块代码。我按功能拆成几块来讲。
先看状态机的核心部分,用一个CASE语句包住全部状态逻辑:
CASE progState OF E_STATE.ST_IDLE: // 待机状态:气缸缩回输出断开,等启动信号 CylinderOut := FALSE; TimerRunning := FALSE; IF StartButton THEN progState := E_STATE.ST_CLAMP; END_IF; E_STATE.ST_CLAMP: // 夹紧:输出电磁阀 CylinderOut := TRUE; IF ClampWorkSensor THEN // 到位后开始计时 HoldTimer(IN := FALSE); // 先复位定时器 progState := E_STATE.ST_HOLD; END_IF; E_STATE.ST_HOLD: // 保持夹紧3秒 CylinderOut := TRUE; HoldTimer(IN := TRUE, PT := T#3S); IF HoldTimer.Q THEN HoldTimer(IN := FALSE); progState := E_STATE.ST_RELEASE; END_IF; E_STATE.ST_RELEASE: // 松开 CylinderOut := FALSE; IF NOT ClampWorkSensor THEN progState := E_STATE.ST_IDLE; END_IF; E_STATE.ST_E_STOP: // 急停:输出全断 CylinderOut := FALSE; IF NOT EStopSignal THEN progState := E_STATE.ST_IDLE; END_IF; END_CASE;这段代码看着简单,但里面有几个细节值得展开讲。
4.3 定时器在状态机里的正确用法
ST_HOLD 状态里的定时器用法是我踩过坑之后总结出来的。一开始我直接在进入ST_HOLD的时候启动定时器,写的是:
HoldTimer(IN := TRUE, PT := T#3S);但问题是:状态机每一轮扫描都会执行这个函数块调用,只要还在ST_HOLD状态,IN始终是TRUE。3秒到了之后,由于程序每个周期还在继续调用,定时器Q会一直保持TRUE,状态切换是能触发,但如果你需要定时器只触发一次,就会出问题。更麻烦的是,如果你从ST_HOLD跳出去之后,另一个地方也用了同一个定时器,定时器的状态没复位,可能直接导致下一次进ST_HOLD时Q还带着上次的残留。
正确做法是:先复位再启动。我上面的代码就是先执行一次HoldTimer(IN := FALSE),把定时器内部的计时状态清零,然后把IN置TRUE重新计时。这个"先复位再置位"的顺序很关键,少一步都会出莫名其妙的问题。
Sysmac Studio的定时器是TON功能块,调用格式是:
// TON实例化之后,用结构体变量调用 HoldTimer(IN := condition, PT := T#3S); // 读取输出 IF HoldTimer.Q THEN ...注意TON的Q输出,在IN断开时是会被清零的,所以如果你在ST_HOLD里直接把CylinderOut设为TRUE,同时IN也一直是TRUE,Q保持,3秒后Q置位,这时切换到ST_RELEASE,IN条件不再满足,Q在下个周期清零。但如果切换不是发生在Q置位的同一个周期,可能出现Q已经置位但没被读取到的情况——所以最好的方式还是"复位再启动"两步走。
4.4 传感器输入要先做映射,别在CASE里直接读
前面提过IO映射的问题,这里演示一下。在程序块的开头,我先做了输入映射:
// IO映射:物理输入点映射到内部变量 StartButton := IO_Start; EStopSignal := IO_EStop; ClampWorkSensor := IO_ClampWork; ClampBackSensor := IO_ClampBack;然后状态机里只用内部变量。好处在调试的时候尤其明显:如果你在Sysmac Studio的在线监控里找不到物理输入点哪里异常,可以直接强制内部变量来模拟信号,不需要真的去短接传感器。
有一点要注意:内部变量和IO变量最好用不同的命名前缀区分。我习惯用IO_开头表示物理点映射,用普通驼峰命名表示内部逻辑变量。这样一眼扫过去就知道哪个是物理点、哪个是逻辑中间量。
4.5 多步动作流程里怎么加并行分支
上面那个例子比较简单,我再扩充一下:如果夹紧之后还要推一个气缸上升,两个动作并行执行,该怎么处理?
状态机的处理方式不是把两个动作放在同一个状态里来回纠结,而是把"并行动作"拆成"一个状态里同时输出多个动作"。假设动作是"夹紧的同时提升",那状态设计就是:
E_STATE.ST_CLAMP_AND_LIFT: CylinderOut := TRUE; LiftOut := TRUE; IF ClampWorkSensor AND LiftUpSensor THEN progState := E_STATE.ST_NEXT; END_IF;这表示在一个状态里同时输出两个执行器,两个都到位才跳下一个状态。如果两个动作的到位时间差异很大,比如夹紧0.5秒到位,提升2秒到位,那就要考虑拆成两个状态分开等,否则夹紧到位之后还得干等着提升,效率低。状态机的好处是这种调整只需要增删状态,不会影响其他逻辑。
5. 状态机的调试心得和那些让你怀疑人生的Bug
5.1 用Sysmac Studio的在线监控当"行车记录仪"
状态机代码写完之后,调试方式和梯形图很不一样。梯形图调试是看哪个网络通了没通、哪个触点亮了没亮;状态机调试只需要盯一个核心变量——progState。Sysmac Studio里可以往监控窗口添加变量,把progState和关键传感器的内部变量放一起。
我最常用的调试手法是:把设备手动打到半自动模式,然后一步步触发动作,看progState跟着哪个状态走、卡在哪个状态不动。卡住的根本原因只有两个可能:要么当前状态的跳转条件不满足,要么跳转目标状态里的动作有问题。前者查传感器和定时器,后者查输出逻辑和IO映射。配合Sysmac Studio的"程序在线编辑"功能,调整几行ST代码然后重新传输,不用整个程序停止,效率极高。
这里要提醒:Sysmac Studio在线编辑之后务必做一些常规的变量一致性检查,尤其是函数块实例名、枚举类型引用这些。有一次我改了一行状态切换条件,编译通过了,结果下载的时候报"程序与全局变量不一致",排查半天发现是枚举类型里加了一个新成员,导致之前用数字初始化的地方出了偏差。这东西编译期不会提醒你,但运行时会给你脸色看。
5.2 状态"卡死"和状态"乱跳"的排查链路
很多刚接触状态机的人会碰到两个经典问题:状态卡住不动,或者状态跳到一个完全意想不到的地方去了。
状态卡住的排查链路,我一般按这个顺序来:
- 先看当前progState停在哪。
- 看这个状态里需要的跳转条件对应的变量,在线监控它们的值。常见情况是传感器没到位,或者定时器Q没置起来。
- 检查传感器信号是不是抖动。有些传感器在机械震动下会闪断闪通,导致状态刚跳过去又跳回来。这时候要么加延时滤波,要么把状态切换条件改成"持续多少毫秒才认"。
状态乱跳的排查链路,稍微复杂一点,常见原因有两个:
一是多个CASE分支同时满足条件。这在CASE语句里不应该发生,因为每个状态只执行自己的分支。但如果你的CASE分支里有个嵌套的IF条件写得过于宽泛,比如IF Input1 OR Input2 THEN,而Input1在状态A里被置位了、在状态B里没被复位,就可能出现"跳到你意想不到的状态"的假象。排查办法是:给每个状态加一个进入时的前置校验条件,不满足就不跳。
二是状态变量类型不匹配。我踩过一次很离谱的坑:我给progState定义的枚举类型,但某个assign语句把一个INT类型的临时变量直接给progState赋值,编译的时候Sysmac Studio居然没拦住(因为某些模式下允许隐式转换),运行的时候progState变成了一个不在枚举范围内的值,CASE语句没有对应分支,整个状态机静默停止输出。从那以后我所有状态切换都严格用枚举赋值,不再用裸数字。
5.3 模拟调试:别等到设备上才第一次跑逻辑
Sysmac Studio自带仿真器Simulator,支持在没有硬件的情况下运行程序、监控变量。强烈建议状态机逻辑写完之后,先用模拟仿真在电脑上把动作流程走一遍。操作方式很简单:把PLC设为"离线"模式,然后在工具栏点"模拟",程序就会被仿真器执行,你可以在监控窗口里强制输入信号,观察状态切换是否正常。
模拟调试最大的价值是:可以在2分钟内测完一整条流程,不需要等气缸真的动。把所有传感器信号按照时序强制置位/复位,看看每个状态切换是否严格按照预期走。如果发现某个状态跳不过去,直接改代码重新跑。等仿真跑通、逻辑确认无误,再上真机调试,你会发现调试时间能压缩到原来的三分之一。
6. 状态机写法的进阶技巧:从"会用"到"用得舒服"
6.1 用状态转换表管理复杂流程
状态一多,比如超过15个状态,光靠看CASE代码已经不太直观了。这时候我习惯先画一张状态转换表,不用画图软件,Excel或者纸上画都行。表格的行是当前状态,列是目标状态,交叉格写的是触发条件。例如:
| 当前状态 | 目标状态 | 触发条件 |
|---|---|---|
| ST_IDLE | ST_CLAMP | 启动按钮=TRUE |
| ST_CLAMP | ST_HOLD | 伸出到位传感器=TRUE |
| ST_HOLD | ST_RELEASE | 定时器Q=TRUE |
| ST_RELEASE | ST_IDLE | 缩回到位传感器=TRUE |
| 任意状态 | ST_E_STOP | 急停信号=TRUE |
| ST_E_STOP | ST_IDLE | 急停信号=FALSE |
这张表拿去对着写CASE代码,效率和准确率都高得多。我一般把这张表做成项目的设计文档,放在程序注释的开头,或者是单独的说明文件里。半年后你或者同事回头维护这段代码的时候,先看转换表再读CASE,理解成本直线下降。
6.2 全局状态字与HMI显示
状态机内部用枚举,但给HMI显示的时候,通常需要输出一个整数值。这个很简单,直接在程序末尾做一个映射:
// HMI显示专用状态值 StateToHMI := progState;HMI那边拿到的就是枚举的数值。配合触摸屏的文本显示功能,可以给每个数值配置一个文本标签,比如0显示"待机"、10显示"回原点"、20显示"夹紧"。这样操作员在触摸屏上看到的就是中文状态名,排查设备卡在哪一步的时候不用打开电脑看程序。
在Sysmac Studio里,也可以把progState直接关联到"变量"标签,HMI数据映射的配置非常灵活。
6.3 超时报警:状态机必备的自保措施
状态机最容易出的事故是:机械卡住导致传感器永远等不到信号,状态卡死在半中间,设备不报警也不动,看起来像死机。解决方法是给每个状态都加一个超时监控——在一个状态停留超过设定时间,强制进入报警状态。
实现方式是在状态机开头统一处理:
// 每个扫描周期累积超时时间 IF progState <> prevProgState THEN StateTimer(IN := FALSE); prevProgState := progState; ELSE StateTimer(IN := TRUE, PT := TimeOutValue); IF StateTimer.Q THEN progState := E_STATE.ST_ALARM; AlarmCode := 100; // 状态超时报警 END_IF; END_IF;不要小看这段代码。有了超时保护,状态机在异常情况下能自动进入报警状态,操作员在HMI上看到报警信息后再去处理。如果没有这层保护,状态卡死可能直接导致动作中断、产品报废,甚至设备损坏。
6.4 子状态机嵌套:大型项目如何避免状态爆炸
如果整个系统的流程非常长,比如有50个状态铺在一个CASE里,可读性依然会崩。我的做法是拆分主状态机和子状态机。主状态机管大阶段(比如:初始化、运行、异常),子状态机管每个大阶段内部的细分步骤。在Sysmac Studio里,我可以定义两个状态枚举类型,两个状态变量,主状态机的CASE里调用子状态机的处理函数。
举例:主状态机的"运行"阶段,对应子状态机里的"夹紧、提升、横移、下降"等步骤。主状态机判断"运行"阶段是否结束,子状态机判断自己内部的步骤怎么走。两层状态机互相独立,每一层都不会超过15个状态,整个项目的可维护性比单层50个状态好得多。
嵌套状态机的原则是:子状态机只负责自己的动作细节,不反向影响主状态机的宏观流程;主状态机只关心阶段切换,不关心子状态机内部每一步在干什么。两者之间通过"阶段完成标志"来交接。这样设计出来的程序,别人接手的时候只要看主状态机就能知道整体流程,真要细看某个阶段再钻到子状态机里。
7. 关于状态机写法,我最想说的几条实战体会
状态机不是银弹,它不会让你的逻辑变少,但会让逻辑变得可维护、可调试、可扩展。我在欧姆龙PLC上用这套写法做了几个项目之后,明显感觉到自己写程序的"安全感"提升了。不再怕改需求,改一个状态不影响其他状态;不再怕现场调试,打开Sysmac Studio看状态值就知道卡在哪;也不再怕交接,给同事看状态转换表比给他看一百行梯形图省力得多。
如果你刚入门,建议找一个简单的工位动作(比如气缸伸缩、传送带启停),用ST写一个10个状态以内的状态机,在仿真器里跑通整个流程。先体会一下"每轮扫描只执行一个逻辑分支"这种思维方式。有了这个基础,再去处理复杂的多工位协同,你会有一种豁然开朗的感觉。
Sysmac Studio的ST语言整体不算难,有C语言或者高级语言基础的人基本可以无障碍上手。真正需要花时间的是思维方式的转变:从"条件满足就执行"变成"当前状态是什么,就只执行这个状态下该做的事"。一旦完成了这个转变,你再看项目里的复杂流程控制,基本都能用状态机这套打法稳稳吃下来。