在Cx-Programmer里用ST语言写PLC程序,这事我做了好几年了。刚开始从梯形图转过来时,我也觉得那一片文本没有梯形图直白,可真到了数据处理密集、逻辑分支多的场合,梯形图的线圈和触点堆在一起,改一行要翻好几页,ST的优势一下就出来了。这篇文章不打算讲那种通篇堆砌概念的教材内容,就把我在Cx-Programmer里实际创建ST程序、写逻辑、编译调试的经验拆开讲:哪些语法必须注意、功能块怎么和梯形图配合、编译报错怎么快速定位。无论你是刚接触结构化文本的新手,还是长期用梯形图想拓展技能的老工程师,都可以参考。1. 为什么在Cx-Programmer里我会推荐用ST语言1.1 Cx-Programmer的编程语言生态里,ST排在什么位置Cx-Programmer支持IEC 61131-3标准下的多种编程语言:梯形图、指令表、结构化文本(ST)、顺序功能图(SFC)。实际项目里用得最多的是梯形图,这是事实,因为梯形图直观,现场维护的时候电工师傅也看得懂。但IEC标准里定义ST语言,本来就不是为了取代梯形图,而是为了补上梯形图不擅长的短板。梯形图的本质是图形化表达开关逻辑,触点串联并联、线圈输出,一眼看过去很清楚。但遇到复杂计算、数组处理、循环遍历、协议解析这类任务,图形化的表达方式就显得特别笨重。比如要算一组数据的平均值,梯形图可能得拉好几行比较和运算指令,可读性很差;换成ST,一个FOR循环加上几行赋值语句就写完了,别人看代码时也能很快理解。ST在Cx-Programmer里的定位,就是“高级语言工具”。它允许你用类似Pascal或C语言的结构化写法组织逻辑,支持变量声明、条件分支、循环、功能块调用。它不是万金油,但确实是很锋利的一把刀。我现在遇到稍微复杂的项目,基本上都是梯形图和ST混合着用,什么时候用哪个,后面专门展开说。1.2 ST真正擅长的典型任务结合我自己的项目经验,下面几类场景在Cx-Programmer里用ST写会舒服很多。第一类,批量模拟量处理。设备上有十几路甚至几十路温度、压力、流量信号,每路都要做工程量换算、滤波、超限报警判断。用梯形图的话,每一路的处理逻辑都要复制一遍,改滤波系数得改十几处;用ST的话,把传感器数据放到数组里,一个循环就处理完了,想改参数就改一处。第二类,配方与参数管理。多品种设备的配方参数往往几十上百个,需要按产品号切换,把一组配方数据从数据块搬到工作区。这种批量数据搬移和结构体赋值,梯形图写起来非常痛苦,ST里一个FOR循环或者一条结构体赋值就搞定。第三类,通信协议解析。和变频器、仪表、扫码枪做串口或总线通信时,收到的报文是一串字节,要解析帧头、命令字、数据段、校验和。这种按字节处理的逻辑,梯形图很难下手,ST可以像写高级语言一样把缓冲区拆开处理。第四类,状态机逻辑。设备有一个动作流程,比如上料、定位、夹紧、加工、松开、退回,每一步有进入条件、执行动作、超时保护。ST里的CASE语句,天生就是写状态机的好工具,每个状态一目了然,比梯形图的步进继电器方案清晰得多。1.3 梯形图与ST的配合边界我现在的习惯是,安全逻辑和操作逻辑用梯形图,计算和数据处理用ST。比如急停回路、门锁联锁、手动自动切换,这些必须让现场维护人员一眼能看懂的地方,用梯形图;故障原因判断、模拟量换算、参数匹配这类藏在背后的逻辑,用ST。举个例子。一台设备的手动模式,按钮按下就启动电机,梯形图里就是触点串线圈,清清楚楚;但按下启动按钮之后,设备要判断当前温度是否在允许范围、气压是否足够、前一个工位是否已经让出位置,这三个条件还要经过滤波和迟滞处理,再决定电机能不能转。这部分判断逻辑放梯形图里会很乱,放ST里只需要一个IF条件判断,或者一个功能块的输入输出计算。这样搭配的好处是,现场看梯形图时看到的是“按下启动按钮,满足综合条件后启动电机”,背后的综合条件哪里算出来的,在ST里查;改计算逻辑时,不用去梯形图里翻继电器,直接在ST里改算法,两边都不折腾。2. 从零创建一个可运行的ST程序2.1 新建ST程序单元的最小操作路径在Cx-Programmer里新建ST程序,操作不算复杂,但有几个地方容易踩坑。打开工程树,找到目标PLC节点下的“程序”目录,右键选择插入程序。弹出的对话框里会让你选语言类型,这里选择“结构化文本(ST)”,然后给程序起个名字。程序名不要跟PLC里的全局符号、I/O注释重名,这个我吃过亏,编译的时候一堆人查不出来为什么符号冲突,最后发现是名字撞了。新建完之后,编辑器窗口会变成一个文本编辑区。ST程序和梯形图程序不一样,梯形图是图形化拖拽的,ST就是纯文本输入。代码从变量声明开始写,写完逻辑之后,点编译按钮检查语法。还有一点要注意,Cx-Programmer的不同版本、不同CPU机型,对ST的支持程度有差别。老一点的软件版本或者入门级CPU,可能只在程序类型里允许梯形图,选不了ST。新建之前先确认一下目标机型支持不支持ST,别辛辛苦苦写完了,下载时才发现机型不支持。2.2 变量声明:分清VAR、VAR_INPUT和VAR_OUTPUTST代码的第一部分,是变量声明。在Cx-Programmer的ST程序里,最常用的声明区是VAR和END_VAR之间的内容。这样写:VAR nCount : INT; fValue : REAL; bDone : BOOL; tDuration : TIME; END_VAR这里的nCount、fValue这些变量,是这个ST程序内部的局部变量,只在当前程序里有效。每个变量后面是冒号加数据类型,整行以分号结束。如果你在写功能块,这时候变量的分类更讲究。功能块的输入接口用VAR_INPUT声明,输出接口用VAR_OUTPUT声明,内部中间量用VAR声明。普通程序里一般不强制分输入输出,但功能块必须分清楚,因为梯形图调用功能块时,看的就是这几个接口。还有一个很重要的概念:全局变量。在Cx-Programmer的符号表里定义的全局变量,ST代码里可以直接用,不用再在VAR里声明。比如你在符号表里定义了一个全局BOOL变量叫StartSignal,ST里直接写IF StartSignal THEN就行。但是,如果ST里声明了一个和全局变量同名的局部变量,局部变量会覆盖全局变量,这个优先级容易把人搞晕,建议尽量避免同名。2.3 数组、结构体与数据类型转换ST处理批量数据的核心能力,来自数组和结构体。数组声明的基本格式是:VAR arrTemp : ARRAY[0..15] OF REAL; END_VAR这样声明了一个下标从0到15、共16个元素的REAL数组。使用时直接赋值:arrTemp[0] := 25.5;。在ST循环里遍历数组,比梯形图逐个复制不知道高到哪里去了。结构体在Cx-Programmer里,需要在工程的数据类型管理器里先建好类型,然后在ST里引用。比如定义一个表示三维坐标的结构体类型PositionType,包含X、Y、Z三个REAL字段,然后在ST里声明变量:VAR currentPos : PositionType; END_VAR使用时写currentPos.X := 100.0;。结构体很适合管理一组相关数据,比如一个轴的位置、速度、加速度、跟随误差,打包成一个结构体变量,整个功能块传参都方便。数据类型转换这块,ST里经常踩坑。INT和REAL不能直接随便混用,比如把一个INT变量赋给REAL变量,或者INT除以INT想得到小数,编译时会报类型不匹配。这时候需要显式转换:nInt := REAL_TO_INT(fValue); fValue := INT_TO_REAL(nInt);把REAL转INT的时候,要注意精度问题。不同版本对取整规则处理有细微差别,涉及关键控制量时,先拿几个边界值实测一下,再决定用不用这种方式。3. ST核心语法逐个过3.1 赋值、比较与运算符:别把=和:=搞混ST里赋值操作符是:=,比较操作符是=。这跟C语言完全不一样,新手最容易在这里翻车。写赋值时少写一个冒号,编译直接报错;更隐蔽的是,你可能本来想写比较,结果写了赋值,逻辑上不对还编译能过。正确的用法是这样:fOutput := fInput * 2.0; (* 赋值 *) IF nMode = 1 THEN (* 比较 *) bFlag := TRUE; END_IF;ST的逻辑运算符是AND、OR、NOT、XOR,不是C语言里的&&、||、!。多条件判断时,可以这样写:IF bEnable AND NOT bFault THEN runMotor := TRUE; END_IF;数学函数可以直接调用,比如ABS、SQRT、SIN、COS。计算平方根直接写SQRT(x),绝对值写ABS(x)。这些函数在Cx-Programmer的ST里是内置的,不用额外导入。实数比较有一个经典坑:两个浮点数直接判等,很可能因为精度问题永远不成立。比如计算结果是1.0,但实际存储可能是0.99999999。安全做法是比较差值:IF ABS(fValue - 1.0) < 0.001 THEN bValid := TRUE; END_IF;这种做法在做模拟量阈值判断时非常实用,相当于加了一个死区。3.2 条件分支:IF与CASE怎么选IF语句处理的是“条件是否成立”的分支,适合条件不多、每个条件优先级不同的场景:IF bEnable AND nMode <> 0 THEN nResult := 10; ELSIF bSecond THEN nResult := 20; ELSE nResult := 0; END_IF;注意ELSIF的拼写,不是ELSEIF,这个拼错也是常见报错原因。CASE语句则适合对同一个变量的不同取值做分发,尤其是状态机。写法是这样:CASE nStep OF 0: bIdle := TRUE; 1: bRun := TRUE; 2, 3: bReady := TRUE; ELSE bAlarm := TRUE; END_CASE;CASE后面的表达式只能是整数类型或枚举类型,不能是REAL。分支值可以用逗号并列多个,比如上例里的2, 3。我强烈建议每个CASE都写上ELSE兜底,否则万一跑到没定义的分支,输出状态会保持上一个周期的值,容易出问题。什么时候用IF,什么时候用CASE?我的经验是:条件超过两三个、而且都是针对同一个变量的取值,优先CASE;条件是不同变量的组合判断,用IF。比如“温度过高或压力不足且不在手动模式”这种复合条件,只能IF。3.3 循环结构:FOR与WHILE的正确用法FOR循环适合次数确定的遍历:FOR i := 0 TO 15 DO arrFilter[i] := arrRaw[i] * 0.9 + arrFilter[i] * 0.1; END_FOR;这条代码就是对一个16元素的数组做一阶低通滤波,梯形图写这样的逻辑起码要几十条指令。FOR循环还支持步长。比如把一个数组的每个元素往后移一位,方便做历史数据记录:FOR i := 15 TO 1 BY -1 DO arrData[i] := arrData[i - 1]; END_FOR;这里的BY -1表示每次减1,是IEC标准ST的写法。WHILE循环用于次数未知、靠条件退出的场景:WHILE nPos < 32 DO bBuffer[nPos] := pattern; nPos := nPos + 1; END_WHILE;说一个极其重要的坑:ST里的循环是在PLC扫描周期内执行的,不像电脑上跑程序那样有独立的时间片。如果在Cx-Programmer里写了一个没有退出条件的死循环,整个扫描周期会被卡住,PLC的看门狗超时,CPU直接报警停机。这个后果很严重,现场生产会被打断。所以写WHILE循环,一定要确保退出条件在有限步骤内必然满足,最好再加上安全上限:nGuard := 0; WHILE nPos < 32 AND nGuard < 100 DO bBuffer[nPos] := pattern; nPos := nPos + 1; nGuard := nGuard + 1; END_WHILE;这样即使逻辑有意外,循环也不会无限执行下去。3.4 在ST中调用定时器TON和计数器CTUD定时器在ST里的用法,跟梯形图差别很大。梯形图里TON是一个指令块,用触点驱动;ST里它变成了一个功能块实例,你要先声明实例,再按函数调用的方式调它。声明定时器实例:VAR fbDelay : TON; bDelayDone : BOOL; etDelay : TIME; END_VAR调用方式:fbDelay(In := bInput, Pt := T#2s, Q => bDelayDone, ET => etDelay);这里In是触发条件,Pt是预设时间,T#2s表示2秒。Q是定时器的输出,ET是当前计时值。用=>把功能块的输出接到变量上。引脚名的大小写,不同版本显示可能有细微差别,以你工程里实际显示的为准,常见写法是In、Pt、Q、ET。有一个我踩过的坑:定时器在ST里不能像梯形图那样看到定时器触点,你必须用Q输出的BOOL变量去写后续逻辑。还有,如果In一直为TRUE,定时器计时到之后不会自动复位,它会保持Q为TRUE,直到In变成FALSE。用定时器做报警延时、启动延时,一定要想清楚复位条件。计数器也是同样思路。声明一个CTUD实例,然后调用:fbCount(CU := bUp, CD := bDown, R := bReset, PV := 100, Q => bOver, CV => nCurrent);CU是加计数触发,CD是减计数触发,R是复位,PV是预设值,Q是到达预设值输出,CV是当前值。功能块实例名在整个PLC里要唯一,不同程序段不能共用同一个实例名。举个比较完整的综合例子,一个带超时保护的两步启动流程:CASE nStep OF 0: IF bStart THEN nStep := 1; fbDelay(In := FALSE, Pt := T#5s, Q => bTimeUp, ET => etTime); END_IF; 1: fbDelay(In := TRUE, Pt := T#5s, Q => bTimeUp, ET => etTime); IF bTimeUp THEN nStep := 2; END_IF; 2: bFinish := TRUE; END_CASE;注意在步骤0里面已经把定时器的In置为FALSE,所以当进入步骤1时,定时器是从零开始计时的。如果不在步骤切换时复位定时器,超时输出可能直接就为TRUE,状态机会瞬间跳变,这是ST写状态机最常见的隐形BUG。4. 功能块封装与梯形图协作4.1 用ST写一个功能块:声明接口是第一步功能块是ST语言里复用性最强的组件。在Cx-Programmer里,右键工程树下的“功能块”节点,选择插入功能块,名称比如FB_MotorCtrl,语言选ST。功能块编辑器里,通常分接口声明区、变量声明区和逻辑代码区。接口区的VAR_INPUT是外部输入,VAR_OUTPUT是外部输出,VAR是内部变量。比如写一个电机控制功能块:VAR_INPUT bRunCmd : BOOL; bFault : BOOL; END_VAR VAR_OUTPUT bRunning : BOOL; bError : BOOL; END_VAR VAR fbDelay : TON; bDelayDone : BOOL; END_VAR逻辑部分可以这样写:IF bRunCmd AND NOT bFault THEN bRunning := TRUE; bError := FALSE; ELSE bRunning := FALSE; bError := bFault; END_IF;接口声明为什么重要?因为功能块封装的就是对外契约。别人调用这个功能块时,只看到输入引脚和输出引脚,内部怎么写完全不影响外面的逻辑。这样写出来的代码,可以像搭积木一样复用到多个设备上。4.2 在梯形图里挂接ST功能块ST功能块写好了,不一定全部逻辑都在ST里写。很多时候,你需要在梯形图里调用这个ST功能块,把按钮、传感器信号接进去,把输出接给接触器、指示灯。操作方式不复杂:从功能块列表里把FB_MotorCtrl拖到梯形图的某一条回路上,然后给它起一个实例名,比如Motor1。实例名是这个功能块在项目里的具体身份,同一个功能块类型可以创建多个实例,每个实例的内部变量互相独立。就像同一张图纸,造了多台机器,每台机器都有自己的状态。拖进梯形图之后,功能块会显示成带引脚的一个方块。把输入引脚bRunCmd连接到启动按钮的常开触点,bFault连接到故障信号,输出引脚bRunning连接电机接触器线圈,bError连接故障指示灯。连接时要注意引脚类型,BOOL引脚接BOOL量,如果接错类型,编译会报错。功能块内部如果用了定时器,定时器的实例要声明在功能块的VAR区,而不是放在全局。这样可以保证每个电机实例有自己独立的定时器,互不影响。4.3 内置函数与自建函数的使用Cx-Programmer的ST内置了一批常用函数,可以直接调用:类型转换的INT_TO_REAL、REAL_TO_INT,数学计算的ABS、SQRT、MIN、MAX,还有比较和限幅的LIMIT函数。这些函数返回单一值,不需要声明实例,随用随调,非常方便。比如要把一个模拟量从0到10V的原始值转换成0到100的百分比,可以直接写:fPercent := LIMIT(0, (fVoltage - 0.0) / 10.0 * 100.0, 100);LIMIT函数接收三个参数,下限、原值、上限,返回值会被限制在这两个界限之间。做模拟量处理时防止数值越界,很好用。如果你用的Cx-Programmer版本支持自定义函数,也可以自己写。函数和功能块的区别是,函数没有内部记忆,同样输入永远得到同样输出,适合做纯计算。结构大致是这样:FUNCTION FC_Scale VAR_INPUT fRaw : REAL; fMin : REAL; fMax : REAL; END_VAR VAR END_VAR FC_Scale := (fRaw - fMin) / (fMax - fMin) * 100.0; END_FUNCTION调用的时候直接函数名加名字参数:fPercent := FC_Scale(fRaw := 1.5, fMin := 0.0, fMax := 5.0);自定义函数在版本兼容性上不如功能块稳,如果你只需要封装一段计算逻辑,而且不想折腾函数模板的格式,直接用功能块也行。功能块还允许你保留内部状态,做滤波、累计这类需要记忆的计算反而更灵活。5. 编译错误与调试实战5.1 我工作中常见的四类编译错误ST编译报错,看多了之后会发现就那么几类。我整理成了一张速查表:错误原因典型表现解决办法赋值写成=编译报赋值语法错误赋值一律用:=变量未声明或拼写不一致报未定义符号,代码里找不到对应声明检查变量名、大小写、全局符号是否一致类型不匹配报表达式类型错误,比如INT赋给REAL用INT_TO_REAL或REAL_TO_INT显式转换语句结尾没写分号/括号不匹配报语法错误,定位到某一行附近补分号、核对括号配对CASE分支值类型不符编译直接提示CASE类型错误确保CASE表达式和分支值都是整数类型还遇到过一种情况,报错行号跟实际问题对不上。比如嵌套了好几层IF和CASE,编译器只把错误定位到某个END_IF附近,实际上真正写错的地方在几十行前面。这时候不要死盯着报错行,往上一段一段看,尤其是检查每个赋值语句有没有漏写分号、每个END_IF是不是配对。5.2 在线监视与强制变量的调试思路Cx-Programmer对ST的在线调试,没有高级语言那种断点单步的功能,但基础的在线监视是有的。程序下载后进入监视模式,ST编辑器里能看到每个变量的当前值。不过一次看很多变量不太方便,我一般把关键输出变量添加到监视窗口,或者直接在程序里建立一个调试用的临时变量,把它们收到一个数组里,方便一起观察。更实用的方法是强制变量。找到问题后,给某个输入条件强制成TRUE或FALSE,或者给某个数值强制成特定值,然后观察后续逻辑的输出变化。比如怀疑温度报警阈值判断有问题,就直接把温度变量强制到阈值附近,看报警输出是不是正确翻转。还有一个土办法,我管它叫探针变量。在ST里临时加一个INT变量,每扫描周期加1,下载运行后观察它是否在持续增长。如果探针变量增长正常,说明程序在正常扫描循环,问题出在逻辑本身;如果探针变量不动了,说明某个分支或者循环可能把扫描卡住了。排查ST程序卡死问题,这个办法特别有效。查完记得把探针变量删掉。5.3 扫描周期、任务类型与ST代码的性能原则ST代码是在PLC的周期任务里执行的,一个扫描周期内,代码从第一行执行到最后一行,然后又开始下一轮。这就带来几个需要注意的点。第一,循环次数一定要控制。ST里写个FOR循环遍历一千个元素,如果每个周期都从头到尾跑一遍,扫描周期会被明显拉长,影响整体响应。处理大规模数据时,我习惯分批做。比如128个数据分8批,每周期处理16个,用一个批次计数器记录当前处理到哪一组,这样一个周期里ST只跑一小部分,扫描周期波动小很多。第二,中断任务里的ST要特别精简。Cx-Programmer支持把程序挂到中断任务下,中断程序里的代码执行频率高,不适合做数组循环、大批量计算,更不能调用耗时长的功能块。中断里最好只做输入捕捉、输出置位复位这类短小操作,其他繁重计算放到周期任务里做。第三,ST里不能有动态内存分配、不能有无限等待,所有逻辑必须在一个扫描周期内有明确的进出口。写代码的时候要时刻记住,这个程序每几十毫秒就要从头到尾跑一遍,跟PC上跑一次就结束的控制台程序完全不同。我之前遇到过一次CPU看门狗超时,最后定位到就是WHILE循环里的退出条件因为一个变量被误修改,导致循环退不出来,整个PLC扫描周期被拖死。从那以后,我写ST循环一律加保护上限,宁可在逻辑上多写两行,也不想再让现场冒一次停机的风险。功能块里的状态变量、定时器实例,也都消耗CPU内部资源,数量不是越多越好。项目里如果需要上百个功能块实例,要考虑CPU的内存容量和扫描周期要求,别把资源用满。另外说一个在线修改的习惯。Cx-Programmer支持在线修改程序,但ST代码的在线修改限制比梯形图严格,改动量大时可能会要求重新下载整个程序。重新下载意味着PLC停机,现场如果有生产任务,一定要提前确认停机窗口。我在现场都是先在离线工程里把ST代码改好、编译通过、模拟验证过,再挑设备空闲时段下载。关于ST语言和Cx-Programmer的配合,我最后再分享一个个人习惯:拿到一个新的ST程序段,我会先搭一个能编译的空骨架,包括变量声明和一条最简赋值语句,编译通过后再一点一点往里面填逻辑。每填一小段就编译一次,让错误暴露得越早、越小,后面排查越省力。这种习惯看起来慢,实际效率比憋一个大块头再编译高得多。Cx-Programmer的ST编辑器有语法高亮,但行号提示偶尔会偏差,真出错时宁可手动对照源代码,也别完全依赖错误跳转。ST在Cx-Programmer里,说到底是为了解决梯形图不擅长的那些问题。我的原则很明确:安全回路和操作习惯相关的逻辑留在梯形图里,计算、数组、状态机、协议解析这些交给ST。用熟了之后你会发现,复杂逻辑写起来舒服,维护起来也清楚。希望这些经验能帮你少走点弯路。
在Cx-Programmer里用ST语言写PLC程序,这事我做了好几年了。刚开始从梯形图转过来时,我也觉得那一片文本没有梯形图直白,可真到了数据处理密集、逻辑分支多的场合,梯形图的线圈和触点堆在一起,改一行要翻好几页,ST的优势一下就出来了。这篇文章不打算讲那种通篇堆砌概念的教材内容,就把我在Cx-Programmer里实际创建ST程序、写逻辑、编译调试的经验拆开讲:哪些语法必须注意、功能块怎么和梯形图配合、编译报错怎么快速定位。无论你是刚接触结构化文本的新手,还是长期用梯形图想拓展技能的老工程师,都可以参考。1. 为什么在Cx-Programmer里我会推荐用ST语言1.1 Cx-Programmer的编程语言生态里,ST排在什么位置Cx-Programmer支持IEC 61131-3标准下的多种编程语言:梯形图、指令表、结构化文本(ST)、顺序功能图(SFC)。实际项目里用得最多的是梯形图,这是事实,因为梯形图直观,现场维护的时候电工师傅也看得懂。但IEC标准里定义ST语言,本来就不是为了取代梯形图,而是为了补上梯形图不擅长的短板。梯形图的本质是图形化表达开关逻辑,触点串联并联、线圈输出,一眼看过去很清楚。但遇到复杂计算、数组处理、循环遍历、协议解析这类任务,图形化的表达方式就显得特别笨重。比如要算一组数据的平均值,梯形图可能得拉好几行比较和运算指令,可读性很差;换成ST,一个FOR循环加上几行赋值语句就写完了,别人看代码时也能很快理解。ST在Cx-Programmer里的定位,就是“高级语言工具”。它允许你用类似Pascal或C语言的结构化写法组织逻辑,支持变量声明、条件分支、循环、功能块调用。它不是万金油,但确实是很锋利的一把刀。我现在遇到稍微复杂的项目,基本上都是梯形图和ST混合着用,什么时候用哪个,后面专门展开说。1.2 ST真正擅长的典型任务结合我自己的项目经验,下面几类场景在Cx-Programmer里用ST写会舒服很多。第一类,批量模拟量处理。设备上有十几路甚至几十路温度、压力、流量信号,每路都要做工程量换算、滤波、超限报警判断。用梯形图的话,每一路的处理逻辑都要复制一遍,改滤波系数得改十几处;用ST的话,把传感器数据放到数组里,一个循环就处理完了,想改参数就改一处。第二类,配方与参数管理。多品种设备的配方参数往往几十上百个,需要按产品号切换,把一组配方数据从数据块搬到工作区。这种批量数据搬移和结构体赋值,梯形图写起来非常痛苦,ST里一个FOR循环或者一条结构体赋值就搞定。第三类,通信协议解析。和变频器、仪表、扫码枪做串口或总线通信时,收到的报文是一串字节,要解析帧头、命令字、数据段、校验和。这种按字节处理的逻辑,梯形图很难下手,ST可以像写高级语言一样把缓冲区拆开处理。第四类,状态机逻辑。设备有一个动作流程,比如上料、定位、夹紧、加工、松开、退回,每一步有进入条件、执行动作、超时保护。ST里的CASE语句,天生就是写状态机的好工具,每个状态一目了然,比梯形图的步进继电器方案清晰得多。1.3 梯形图与ST的配合边界我现在的习惯是,安全逻辑和操作逻辑用梯形图,计算和数据处理用ST。比如急停回路、门锁联锁、手动自动切换,这些必须让现场维护人员一眼能看懂的地方,用梯形图;故障原因判断、模拟量换算、参数匹配这类藏在背后的逻辑,用ST。举个例子。一台设备的手动模式,按钮按下就启动电机,梯形图里就是触点串线圈,清清楚楚;但按下启动按钮之后,设备要判断当前温度是否在允许范围、气压是否足够、前一个工位是否已经让出位置,这三个条件还要经过滤波和迟滞处理,再决定电机能不能转。这部分判断逻辑放梯形图里会很乱,放ST里只需要一个IF条件判断,或者一个功能块的输入输出计算。这样搭配的好处是,现场看梯形图时看到的是“按下启动按钮,满足综合条件后启动电机”,背后的综合条件哪里算出来的,在ST里查;改计算逻辑时,不用去梯形图里翻继电器,直接在ST里改算法,两边都不折腾。2. 从零创建一个可运行的ST程序2.1 新建ST程序单元的最小操作路径在Cx-Programmer里新建ST程序,操作不算复杂,但有几个地方容易踩坑。打开工程树,找到目标PLC节点下的“程序”目录,右键选择插入程序。弹出的对话框里会让你选语言类型,这里选择“结构化文本(ST)”,然后给程序起个名字。程序名不要跟PLC里的全局符号、I/O注释重名,这个我吃过亏,编译的时候一堆人查不出来为什么符号冲突,最后发现是名字撞了。新建完之后,编辑器窗口会变成一个文本编辑区。ST程序和梯形图程序不一样,梯形图是图形化拖拽的,ST就是纯文本输入。代码从变量声明开始写,写完逻辑之后,点编译按钮检查语法。还有一点要注意,Cx-Programmer的不同版本、不同CPU机型,对ST的支持程度有差别。老一点的软件版本或者入门级CPU,可能只在程序类型里允许梯形图,选不了ST。新建之前先确认一下目标机型支持不支持ST,别辛辛苦苦写完了,下载时才发现机型不支持。2.2 变量声明:分清VAR、VAR_INPUT和VAR_OUTPUTST代码的第一部分,是变量声明。在Cx-Programmer的ST程序里,最常用的声明区是VAR和END_VAR之间的内容。这样写:VAR nCount : INT; fValue : REAL; bDone : BOOL; tDuration : TIME; END_VAR这里的nCount、fValue这些变量,是这个ST程序内部的局部变量,只在当前程序里有效。每个变量后面是冒号加数据类型,整行以分号结束。如果你在写功能块,这时候变量的分类更讲究。功能块的输入接口用VAR_INPUT声明,输出接口用VAR_OUTPUT声明,内部中间量用VAR声明。普通程序里一般不强制分输入输出,但功能块必须分清楚,因为梯形图调用功能块时,看的就是这几个接口。还有一个很重要的概念:全局变量。在Cx-Programmer的符号表里定义的全局变量,ST代码里可以直接用,不用再在VAR里声明。比如你在符号表里定义了一个全局BOOL变量叫StartSignal,ST里直接写IF StartSignal THEN就行。但是,如果ST里声明了一个和全局变量同名的局部变量,局部变量会覆盖全局变量,这个优先级容易把人搞晕,建议尽量避免同名。2.3 数组、结构体与数据类型转换ST处理批量数据的核心能力,来自数组和结构体。数组声明的基本格式是:VAR arrTemp : ARRAY[0..15] OF REAL; END_VAR这样声明了一个下标从0到15、共16个元素的REAL数组。使用时直接赋值:arrTemp[0] := 25.5;。在ST循环里遍历数组,比梯形图逐个复制不知道高到哪里去了。结构体在Cx-Programmer里,需要在工程的数据类型管理器里先建好类型,然后在ST里引用。比如定义一个表示三维坐标的结构体类型PositionType,包含X、Y、Z三个REAL字段,然后在ST里声明变量:VAR currentPos : PositionType; END_VAR使用时写currentPos.X := 100.0;。结构体很适合管理一组相关数据,比如一个轴的位置、速度、加速度、跟随误差,打包成一个结构体变量,整个功能块传参都方便。数据类型转换这块,ST里经常踩坑。INT和REAL不能直接随便混用,比如把一个INT变量赋给REAL变量,或者INT除以INT想得到小数,编译时会报类型不匹配。这时候需要显式转换:nInt := REAL_TO_INT(fValue); fValue := INT_TO_REAL(nInt);把REAL转INT的时候,要注意精度问题。不同版本对取整规则处理有细微差别,涉及关键控制量时,先拿几个边界值实测一下,再决定用不用这种方式。3. ST核心语法逐个过3.1 赋值、比较与运算符:别把=和:=搞混ST里赋值操作符是:=,比较操作符是=。这跟C语言完全不一样,新手最容易在这里翻车。写赋值时少写一个冒号,编译直接报错;更隐蔽的是,你可能本来想写比较,结果写了赋值,逻辑上不对还编译能过。正确的用法是这样:fOutput := fInput * 2.0; (* 赋值 *) IF nMode = 1 THEN (* 比较 *) bFlag := TRUE; END_IF;ST的逻辑运算符是AND、OR、NOT、XOR,不是C语言里的&&、||、!。多条件判断时,可以这样写:IF bEnable AND NOT bFault THEN runMotor := TRUE; END_IF;数学函数可以直接调用,比如ABS、SQRT、SIN、COS。计算平方根直接写SQRT(x),绝对值写ABS(x)。这些函数在Cx-Programmer的ST里是内置的,不用额外导入。实数比较有一个经典坑:两个浮点数直接判等,很可能因为精度问题永远不成立。比如计算结果是1.0,但实际存储可能是0.99999999。安全做法是比较差值:IF ABS(fValue - 1.0) < 0.001 THEN bValid := TRUE; END_IF;这种做法在做模拟量阈值判断时非常实用,相当于加了一个死区。3.2 条件分支:IF与CASE怎么选IF语句处理的是“条件是否成立”的分支,适合条件不多、每个条件优先级不同的场景:IF bEnable AND nMode <> 0 THEN nResult := 10; ELSIF bSecond THEN nResult := 20; ELSE nResult := 0; END_IF;注意ELSIF的拼写,不是ELSEIF,这个拼错也是常见报错原因。CASE语句则适合对同一个变量的不同取值做分发,尤其是状态机。写法是这样:CASE nStep OF 0: bIdle := TRUE; 1: bRun := TRUE; 2, 3: bReady := TRUE; ELSE bAlarm := TRUE; END_CASE;CASE后面的表达式只能是整数类型或枚举类型,不能是REAL。分支值可以用逗号并列多个,比如上例里的2, 3。我强烈建议每个CASE都写上ELSE兜底,否则万一跑到没定义的分支,输出状态会保持上一个周期的值,容易出问题。什么时候用IF,什么时候用CASE?我的经验是:条件超过两三个、而且都是针对同一个变量的取值,优先CASE;条件是不同变量的组合判断,用IF。比如“温度过高或压力不足且不在手动模式”这种复合条件,只能IF。3.3 循环结构:FOR与WHILE的正确用法FOR循环适合次数确定的遍历:FOR i := 0 TO 15 DO arrFilter[i] := arrRaw[i] * 0.9 + arrFilter[i] * 0.1; END_FOR;这条代码就是对一个16元素的数组做一阶低通滤波,梯形图写这样的逻辑起码要几十条指令。FOR循环还支持步长。比如把一个数组的每个元素往后移一位,方便做历史数据记录:FOR i := 15 TO 1 BY -1 DO arrData[i] := arrData[i - 1]; END_FOR;这里的BY -1表示每次减1,是IEC标准ST的写法。WHILE循环用于次数未知、靠条件退出的场景:WHILE nPos < 32 DO bBuffer[nPos] := pattern; nPos := nPos + 1; END_WHILE;说一个极其重要的坑:ST里的循环是在PLC扫描周期内执行的,不像电脑上跑程序那样有独立的时间片。如果在Cx-Programmer里写了一个没有退出条件的死循环,整个扫描周期会被卡住,PLC的看门狗超时,CPU直接报警停机。这个后果很严重,现场生产会被打断。所以写WHILE循环,一定要确保退出条件在有限步骤内必然满足,最好再加上安全上限:nGuard := 0; WHILE nPos < 32 AND nGuard < 100 DO bBuffer[nPos] := pattern; nPos := nPos + 1; nGuard := nGuard + 1; END_WHILE;这样即使逻辑有意外,循环也不会无限执行下去。3.4 在ST中调用定时器TON和计数器CTUD定时器在ST里的用法,跟梯形图差别很大。梯形图里TON是一个指令块,用触点驱动;ST里它变成了一个功能块实例,你要先声明实例,再按函数调用的方式调它。声明定时器实例:VAR fbDelay : TON; bDelayDone : BOOL; etDelay : TIME; END_VAR调用方式:fbDelay(In := bInput, Pt := T#2s, Q => bDelayDone, ET => etDelay);这里In是触发条件,Pt是预设时间,T#2s表示2秒。Q是定时器的输出,ET是当前计时值。用=>把功能块的输出接到变量上。引脚名的大小写,不同版本显示可能有细微差别,以你工程里实际显示的为准,常见写法是In、Pt、Q、ET。有一个我踩过的坑:定时器在ST里不能像梯形图那样看到定时器触点,你必须用Q输出的BOOL变量去写后续逻辑。还有,如果In一直为TRUE,定时器计时到之后不会自动复位,它会保持Q为TRUE,直到In变成FALSE。用定时器做报警延时、启动延时,一定要想清楚复位条件。计数器也是同样思路。声明一个CTUD实例,然后调用:fbCount(CU := bUp, CD := bDown, R := bReset, PV := 100, Q => bOver, CV => nCurrent);CU是加计数触发,CD是减计数触发,R是复位,PV是预设值,Q是到达预设值输出,CV是当前值。功能块实例名在整个PLC里要唯一,不同程序段不能共用同一个实例名。举个比较完整的综合例子,一个带超时保护的两步启动流程:CASE nStep OF 0: IF bStart THEN nStep := 1; fbDelay(In := FALSE, Pt := T#5s, Q => bTimeUp, ET => etTime); END_IF; 1: fbDelay(In := TRUE, Pt := T#5s, Q => bTimeUp, ET => etTime); IF bTimeUp THEN nStep := 2; END_IF; 2: bFinish := TRUE; END_CASE;注意在步骤0里面已经把定时器的In置为FALSE,所以当进入步骤1时,定时器是从零开始计时的。如果不在步骤切换时复位定时器,超时输出可能直接就为TRUE,状态机会瞬间跳变,这是ST写状态机最常见的隐形BUG。4. 功能块封装与梯形图协作4.1 用ST写一个功能块:声明接口是第一步功能块是ST语言里复用性最强的组件。在Cx-Programmer里,右键工程树下的“功能块”节点,选择插入功能块,名称比如FB_MotorCtrl,语言选ST。功能块编辑器里,通常分接口声明区、变量声明区和逻辑代码区。接口区的VAR_INPUT是外部输入,VAR_OUTPUT是外部输出,VAR是内部变量。比如写一个电机控制功能块:VAR_INPUT bRunCmd : BOOL; bFault : BOOL; END_VAR VAR_OUTPUT bRunning : BOOL; bError : BOOL; END_VAR VAR fbDelay : TON; bDelayDone : BOOL; END_VAR逻辑部分可以这样写:IF bRunCmd AND NOT bFault THEN bRunning := TRUE; bError := FALSE; ELSE bRunning := FALSE; bError := bFault; END_IF;接口声明为什么重要?因为功能块封装的就是对外契约。别人调用这个功能块时,只看到输入引脚和输出引脚,内部怎么写完全不影响外面的逻辑。这样写出来的代码,可以像搭积木一样复用到多个设备上。4.2 在梯形图里挂接ST功能块ST功能块写好了,不一定全部逻辑都在ST里写。很多时候,你需要在梯形图里调用这个ST功能块,把按钮、传感器信号接进去,把输出接给接触器、指示灯。操作方式不复杂:从功能块列表里把FB_MotorCtrl拖到梯形图的某一条回路上,然后给它起一个实例名,比如Motor1。实例名是这个功能块在项目里的具体身份,同一个功能块类型可以创建多个实例,每个实例的内部变量互相独立。就像同一张图纸,造了多台机器,每台机器都有自己的状态。拖进梯形图之后,功能块会显示成带引脚的一个方块。把输入引脚bRunCmd连接到启动按钮的常开触点,bFault连接到故障信号,输出引脚bRunning连接电机接触器线圈,bError连接故障指示灯。连接时要注意引脚类型,BOOL引脚接BOOL量,如果接错类型,编译会报错。功能块内部如果用了定时器,定时器的实例要声明在功能块的VAR区,而不是放在全局。这样可以保证每个电机实例有自己独立的定时器,互不影响。4.3 内置函数与自建函数的使用Cx-Programmer的ST内置了一批常用函数,可以直接调用:类型转换的INT_TO_REAL、REAL_TO_INT,数学计算的ABS、SQRT、MIN、MAX,还有比较和限幅的LIMIT函数。这些函数返回单一值,不需要声明实例,随用随调,非常方便。比如要把一个模拟量从0到10V的原始值转换成0到100的百分比,可以直接写:fPercent := LIMIT(0, (fVoltage - 0.0) / 10.0 * 100.0, 100);LIMIT函数接收三个参数,下限、原值、上限,返回值会被限制在这两个界限之间。做模拟量处理时防止数值越界,很好用。如果你用的Cx-Programmer版本支持自定义函数,也可以自己写。函数和功能块的区别是,函数没有内部记忆,同样输入永远得到同样输出,适合做纯计算。结构大致是这样:FUNCTION FC_Scale VAR_INPUT fRaw : REAL; fMin : REAL; fMax : REAL; END_VAR VAR END_VAR FC_Scale := (fRaw - fMin) / (fMax - fMin) * 100.0; END_FUNCTION调用的时候直接函数名加名字参数:fPercent := FC_Scale(fRaw := 1.5, fMin := 0.0, fMax := 5.0);自定义函数在版本兼容性上不如功能块稳,如果你只需要封装一段计算逻辑,而且不想折腾函数模板的格式,直接用功能块也行。功能块还允许你保留内部状态,做滤波、累计这类需要记忆的计算反而更灵活。5. 编译错误与调试实战5.1 我工作中常见的四类编译错误ST编译报错,看多了之后会发现就那么几类。我整理成了一张速查表:错误原因典型表现解决办法赋值写成=编译报赋值语法错误赋值一律用:=变量未声明或拼写不一致报未定义符号,代码里找不到对应声明检查变量名、大小写、全局符号是否一致类型不匹配报表达式类型错误,比如INT赋给REAL用INT_TO_REAL或REAL_TO_INT显式转换语句结尾没写分号/括号不匹配报语法错误,定位到某一行附近补分号、核对括号配对CASE分支值类型不符编译直接提示CASE类型错误确保CASE表达式和分支值都是整数类型还遇到过一种情况,报错行号跟实际问题对不上。比如嵌套了好几层IF和CASE,编译器只把错误定位到某个END_IF附近,实际上真正写错的地方在几十行前面。这时候不要死盯着报错行,往上一段一段看,尤其是检查每个赋值语句有没有漏写分号、每个END_IF是不是配对。5.2 在线监视与强制变量的调试思路Cx-Programmer对ST的在线调试,没有高级语言那种断点单步的功能,但基础的在线监视是有的。程序下载后进入监视模式,ST编辑器里能看到每个变量的当前值。不过一次看很多变量不太方便,我一般把关键输出变量添加到监视窗口,或者直接在程序里建立一个调试用的临时变量,把它们收到一个数组里,方便一起观察。更实用的方法是强制变量。找到问题后,给某个输入条件强制成TRUE或FALSE,或者给某个数值强制成特定值,然后观察后续逻辑的输出变化。比如怀疑温度报警阈值判断有问题,就直接把温度变量强制到阈值附近,看报警输出是不是正确翻转。还有一个土办法,我管它叫探针变量。在ST里临时加一个INT变量,每扫描周期加1,下载运行后观察它是否在持续增长。如果探针变量增长正常,说明程序在正常扫描循环,问题出在逻辑本身;如果探针变量不动了,说明某个分支或者循环可能把扫描卡住了。排查ST程序卡死问题,这个办法特别有效。查完记得把探针变量删掉。5.3 扫描周期、任务类型与ST代码的性能原则ST代码是在PLC的周期任务里执行的,一个扫描周期内,代码从第一行执行到最后一行,然后又开始下一轮。这就带来几个需要注意的点。第一,循环次数一定要控制。ST里写个FOR循环遍历一千个元素,如果每个周期都从头到尾跑一遍,扫描周期会被明显拉长,影响整体响应。处理大规模数据时,我习惯分批做。比如128个数据分8批,每周期处理16个,用一个批次计数器记录当前处理到哪一组,这样一个周期里ST只跑一小部分,扫描周期波动小很多。第二,中断任务里的ST要特别精简。Cx-Programmer支持把程序挂到中断任务下,中断程序里的代码执行频率高,不适合做数组循环、大批量计算,更不能调用耗时长的功能块。中断里最好只做输入捕捉、输出置位复位这类短小操作,其他繁重计算放到周期任务里做。第三,ST里不能有动态内存分配、不能有无限等待,所有逻辑必须在一个扫描周期内有明确的进出口。写代码的时候要时刻记住,这个程序每几十毫秒就要从头到尾跑一遍,跟PC上跑一次就结束的控制台程序完全不同。我之前遇到过一次CPU看门狗超时,最后定位到就是WHILE循环里的退出条件因为一个变量被误修改,导致循环退不出来,整个PLC扫描周期被拖死。从那以后,我写ST循环一律加保护上限,宁可在逻辑上多写两行,也不想再让现场冒一次停机的风险。功能块里的状态变量、定时器实例,也都消耗CPU内部资源,数量不是越多越好。项目里如果需要上百个功能块实例,要考虑CPU的内存容量和扫描周期要求,别把资源用满。另外说一个在线修改的习惯。Cx-Programmer支持在线修改程序,但ST代码的在线修改限制比梯形图严格,改动量大时可能会要求重新下载整个程序。重新下载意味着PLC停机,现场如果有生产任务,一定要提前确认停机窗口。我在现场都是先在离线工程里把ST代码改好、编译通过、模拟验证过,再挑设备空闲时段下载。关于ST语言和Cx-Programmer的配合,我最后再分享一个个人习惯:拿到一个新的ST程序段,我会先搭一个能编译的空骨架,包括变量声明和一条最简赋值语句,编译通过后再一点一点往里面填逻辑。每填一小段就编译一次,让错误暴露得越早、越小,后面排查越省力。这种习惯看起来慢,实际效率比憋一个大块头再编译高得多。Cx-Programmer的ST编辑器有语法高亮,但行号提示偶尔会偏差,真出错时宁可手动对照源代码,也别完全依赖错误跳转。ST在Cx-Programmer里,说到底是为了解决梯形图不擅长的那些问题。我的原则很明确:安全回路和操作习惯相关的逻辑留在梯形图里,计算、数组、状态机、协议解析这些交给ST。用熟了之后你会发现,复杂逻辑写起来舒服,维护起来也清楚。希望这些经验能帮你少走点弯路。