前阵子帮一个客户收拾一台老设备,程序是别人写的,三菱FX5U,GX Works3环境,结构化文本(ST)大概1700行。设备本身是台十二工位转盘式灌装机,运行两年多没大改过,这次要新增一个配方规格,我打开程序一看,血压直接上来了——里面直接写死的数值一堆一堆的,什么K100、16#64、D300后面跟的索引下标全是裸数字。更要命的是,我把一个关键模式号从100改成120之后,全文搜了一遍,以为改干净了,结果调试的时候才发现:1700行程序里实际写着这个值的地方有十几处,我漏改了8处,其中3处是真真切切在设备上踩了雷。
这篇文章就把这个过程完整复盘一下,重点聊聊三菱ST里"常量vs枚举"到底怎么用、为什么写死的值这么难改干净、以及我后来摸索出来的一套防漏改机制。搞三菱PLC的同行,尤其是用ST写逻辑的,这篇应该能帮你少走不少弯路。
1. 一个"搜干净了"却依然报错的魔幻现场
1.1 这个程序当初是怎么变成"写死值重灾区"的
先看看这1700行ST到底是个什么结构。这台灌装机分成上瓶、灌装、封盖、贴标、下料几个工位,工位之间用凸轮主轴联动,每个工位有一段独立的ST程序块,再加上配方管理、报警汇总、统计计数几个功能块。整套程序大概是这么分布的:
- 主程序MAIN:300行左右,负责流程状态机和工位调用
- 配方加载FB:250行,从触摸屏读配方号,然后把对应参数写进工作寄存器
- 报警汇总FB:200行,收集各工位报警,映射到HMI报警编号
- 各工位动作逻辑:1200行左右,全是气缸、电机、阀岛的动作序列
这种结构其实很典型,问题出在配方加载和报警映射这两块。配方里有个"模式号"参数,全设备共用,原工程师不知道从哪个老项目里拷过来的习惯,直接用数值字面量比较:
IF RecipeNo = 100 THEN // 启动模式A ELSIF RecipeNo = 101 THEN // 启动模式B END_IF;然后报警映射那边也全是字面量:
IF AlarmCode = 200 THEN AlarmDisp := 3001; // HMI报警号 END_IF;你把程序往下翻,会发现这种写法到处都是。为什么?大概率是当时赶工期,先在线上把值试出来,想着"回头再整理",结果一回头就是两年。
1.2 同一个数值在ST程序里能有多少种"长相"
这次踩雷的核心原因,就是搜索替换的时候忽略了同一个值在不同上下文里的不同写法。三菱ST虽然不像C语言那样有#define,但数值字面量的写法非常灵活,尤其是常量在GX Works3里可以有多种存储形式。我这次项目里同一个"100",程序里至少出现了6种写法:
| 写法 | 出现位置 | 我当时搜到了没 |
|---|---|---|
K100 | 比较指令、MOV指令 | 搜到了 |
100 | ST算术表达式x := 100 | 搜到了 |
16#64 | 报警代码比较 | 漏了 |
100.0 | 浮点比较,比如计时器设定 | 漏了 |
100作为数组下标 | Data[100]这种索引写法 | 漏了 |
HMI脚本里的100 | GOT画面宏脚本,不在ST里 | 漏了 |
这就是"写死值"最恶心的地方。你以为你用的是同一个逻辑值,但在程序里它可以是整数、是十六进制、是浮点、是数组索引,甚至跑到触摸屏脚本里成了另一个独立副本。等你意识到要改的时候,光靠眼睛找根本不现实。
1.3 设备端的三个"看上去正常"的假象
为什么漏改了8处当时没爆出来?因为设备并不是立刻全崩,而是以一种"看起来正常但细节不对劲"的方式在运行,这种最坑。
第一假象:设备能跑。模式号漏改的那几处,因为原来的100和新的120之间有部分重叠,某些工位还是能动作,只是动作组合和预期不一样。比如灌装工位应该用新配方的高速参数,结果还在走老配方的低速参数,产量上不去,但你没察觉是模式号的问题。
第二假象:报警不报错。PLC没有报编译错误,因为数值字面量之间做比较根本没有语法问题。三菱的ST编译器不会因为你用了K100就提醒你"这里有个写死的值"。
第三假象:触摸屏显示正常。部分报警编号映射漏改了,但HMI上已有的报警记录还在,你看着报警列表觉得"还挺全",实际上新加的那几个报警点根本没接上。
2. CONSTANT常量与"伪枚举":三菱ST里替代写死值的两条路线
2.1 常量声明:GX Works3的ST语法与适用边界
先说正路。三菱GX Works3的ST语言是支持常量声明的,最常见的写在POU的声明区里,格式长这样:
VAR CONSTANT cModeStart : INT := 100; cModeChange : INT := 101; cAlarmFill : INT := 200; cAlarmCap : INT := 201; cHmiAlarmBase : INT := 3000; END_VAR也可以直接在全局标签里建一个标签,类型选INT,勾上"常量"复选框,这样所有POU都能直接引用,不用到处声明。
常量声明的好处不用多说,你只要改cModeStart这个定义,程序里所有用cModeStart的地方自动跟着变。但这里有个边界你得知道:常量只解决"值改了不用到处改"的问题,不解决"这个值和那个值属于同一类"的问题。换句话说,常量能消灭魔法数字,但没办法把"模式号100"和"报警号200"这两类东西在类型上区分开。
实际用它的时候我还有个体会:命名规范比声明本身重要得多。你光是声明了C100这种常量名,跟写死K100没本质区别,因为你根本不知道C100是干嘛的。我现在的习惯是常量名必须带类型前缀和业务语义前缀,比如cMode,cAlarm,cRecipe这种。
2.2 没有原生enum怎么办:用常量组模拟枚举的两种手法
聊到枚举就得说实话:三菱ST语言本身没有像C语言或高级语言那样原生的ENUM类型。你翻遍GX Works3的帮助文档,找不到TYPE ... ENUM这种定义。所以严格意义上,标题里的"枚举"在ST里玩的是模拟。
我常用的模拟方式有两种。
第一种是"常量分组法"。思路很简单:把同一业务域的一组相关常量放在一起,用统一前缀区分,靠命名把"这是一个枚举集合"这个信息传达出来:
VAR CONSTANT // 灌装模式枚举 eFillMode_Stop : INT := 0; eFillMode_Manual : INT := 1; eFillMode_AutoFast : INT := 2; eFillMode_AutoSlow : INT := 3; // 灌装头状态枚举 eHeadState_Idle : INT := 0; eHeadState_Filling : INT := 1; eHeadState_Sealing : INT := 2; END_VAR注意看前缀:eFillMode_和eHeadState_等于手工实现了两个枚举集合。美中不足的是,三菱不会真的检查你是不是把一个FillMode的值赋给了HeadState变量,因为底层都是INT。也就是说,类型安全性还是得靠人肉保证。
第二种是"结构体打包法",组织性更好:
TYPE stFillMode : STRUCT Stop : INT := 0; Manual : INT := 1; AutoFast : INT := 2; AutoSlow : INT := 3; END_STRUCT END_TYPE VAR FillMode : stFillMode; END_VAR然后程序里就用FillMode.AutoFast这种形式访问。好处是有了命名空间,看起来更像真枚举;坏处是每次引用都写一长串,代码会显得啰嗦,而且结构体成员不是常量,如果你不小心在逻辑里给它赋值,还会被改掉。所以我个人还是更推荐第一种"常量分组法",简单直接,维护成本低。
2.3 常量与枚举到底怎么选:一张判断表
经常有人问我,既然ST没有真枚举,那到底是全用常量,还是硬凑一套枚举出来?我的判断标准看三条:
| 判断维度 | 用常量 | 用枚举模拟 |
|---|---|---|
| 值本身有没有业务含义 | 只有单一数值含义,如最大量程、基础偏移 | 一组同类的状态/模式,如运行模式、气缸位置 |
| 集合是否固定 | 量程、时间、系数这类可能随工艺调整 | 状态机状态、报警类别这类相对固定 |
| 是否经常出现在比较逻辑里 | 偶尔出现 | 频繁在CASE、IF条件里互相比较 |
具体到我这个项目:灌装模式、工位状态、报警类型、配方编号都是典型的枚举场景,用常量组模拟;而主轴速度上限、灌装量补偿系数、延时时间这种就是纯常量,直接VAR CONSTANT声明就行。
这里还有个重要原则:宁可全都用常量组模拟枚举,也不要在逻辑里留一个裸数字。裸数字 = 危险品。哪怕你声明一个cDummy := 0然后在下面写IF x = cDummy,都比直接写IF x = 0安全,因为至少下次搜索cDummy能搜到所有用它的地方。
3. 8处漏改、3处踩雷:一次完整的排查复盘
3.1 第一轮搜索:我只搜了"K100",漏了"16#64"
当时我接到的任务是把模式号100改成120。我自认为抓到了重点,直接在GX Works3的软件自带的查找功能里搜100,然后逐个手工确认,改了5处。当时我还挺得意,因为这5处确实都是模式号比较,改完编译无错误,下载到PLC,设备也能正常运转。
结果第二天客户打电话来说灌装工位速度不对。我远程一看,才发现PLC里同时存在IF RecipeNo = 100(我以为改掉了)和IF RecipeNo = 16#64(这个我一直没注意到)。在ST里,16#64就是十进制的100,三菱的ST编译器完全认这个语法,而且因为你是在ST的表达式里写的,它连转换警告都不会给。两段代码逻辑一模一样,一个是"新值120",一个是"旧值100",设备到底走哪条分支,取决于你先碰哪个条件。
这第一轮漏改的根源,就是我用自己的"想当然"代替了"穷举所有可能的数值表示形式"。十六进制、十进制、二进制前缀写法,在ST里都是合法的整数字面量,你必须承认同一个逻辑值可能有多种物理写法。
3.2 第二轮排查:从报警记录反推漏网点
找到第一处问题之后,我不再信任肉眼搜索了,改用"从现象反推数值"的办法。
具体怎么做的?我把HMI报警历史调出来,找出最近三天所有触发过的报警编号,然后回到ST程序里搜这些编号对应的字面量,一个一个比。这一比不要紧,又揪出来好几处:
- 报警映射FB里有
IF AlarmCode = 200,200没搜,因为它是报警号不是模式号 - 配方加载FB里有
ArrayIndex := 100(把模式号当数组下标用) - 还有一个报警号3001,在HMI脚本里用16#7D1写了,这个居然是同一个意义
这就是第二轮的教训:你排查漏网之鱼的时候,不能只盯着你这次要改的那个值,而得从"这个值在系统里承担什么角色"入手。模式号100,它同时也是配方数组的索引,也是若干报警条件里的一个判断源,还是HMI显示配方编号的输入变量。同一个业务值,在不同层级的程序里以不同形式存在。
3.3 三处踩雷分别在什么环节:模式选择、报警映射、数据下标
8处漏改不是平均分布的,最后真正在设备上出问题的就3处,但每一处的坑法都不一样。
第一处是模式选择分支。就是前面说的16#64那段,典型的表现是设备会随机出现"走了新配方但又混合了旧参数"的情况,因为新旧两个条件都能命中,后命中的覆盖先命中的。这个最隐蔽,因为它是间歇性的,和生产顺序强相关。
第二处是报警映射。报警编号200在报警FB里被写死,导致新模式的报警一直映射不到HMI的报警显示区。设备异常停机时操作工根本看不到对应报警,只能干瞪眼,误以为设备没报错。
第三处是数据下标。配方参数存在D寄存器数组里,索引基准值是100。我把配方结构加了长度之后,这个基准没跟着改,导致新配方读取时索引偏移了8个寄存器。表面上看配方号能读进来,但里面每个参数都错位了,比如灌装时间读成了灌装温度。
这三处分别代表了写死值最常出问题的三类场景:流程分支、映射表、数组寻址。你排查的时候要对这三类格外敏感。
3.4 为什么编译器没有拦住这三处错误
这也是很多ST新手会问的问题:三菱不是会做数据类型检查吗,为什么16#64和K100混用不报错?
答案很简单:因为这两个字面量在语义层面是同一个INT值,编译器只看到"左边是INT,右边是INT,比较合法",它根本不管你俩是从哪个进制写出来的。你要是去搜索"表达式必须含有常量值"这种报错,那是C语言或者部分嵌入式编译器会报的,三菱ST的比较宽松,数值字面量之间的比较只要类型匹配就放行。
所以结论很扎心:你不能指望编译器帮你抓魔法数字。写死值这个问题,从诞生那一刻起就注定要用人肉纪律来兜底。这也是我后来下定决心重构这套逻辑的原因。
4. 不靠"细心"靠机制:从声明规范到交叉引用的防漏改手段
4.1 命名规范:常量名前缀怎么定,定完就得执行
经过这次事件,我把这套程序的常量命名全部重排了。现在我在项目里推的规范是三段式:
c + 业务域 + 语义 = 常量名具体格式:
VAR CONSTANT cMode_Start : INT := 100; // 模式:启动 cMode_Change : INT := 101; // 模式:切换 cAlarm_Fill : INT := 200; // 报警:灌装异常 cAlarm_Cap : INT := 201; // 报警:封盖异常 cIdx_RecipeBase : INT := 100; // 配方数组索引基准 END_VAR业务域我用三种:Mode(流程模式)、Alarm(报警)、Idx(索引/偏移)。命名定下来之后,全项目强制执行,写ST的时候只要看到裸数字就停下来问一句:这个值后面要不要改?要改就声明成常量,当场声明,不要拖。
这里我得承认,规范这东西,最难的不是定规则,而是坚持。好在ST程序不像梯形图那样散,代码块结构集中,代码复查的时候一眼扫过去就能看到有没有裸数字。
4.2 交叉引用不是摆设:改值前必做的三件套
GX Works3的软件里其实自带交叉引用功能,很多人不知道或者不怎么用。我现在每次改一个数值,固定走三件套流程:
- 定义处定位:先找到这个常量/字面量最初的定义位置,搞清楚它到底代表什么业务含义
- 交叉引用全查:右键"交叉引用",把所有引用这个值的位置列出来,一个都不放过
- 写入/读取双向确认:不光看读取的地方,还要看有没有哪个程序往这个变量里写值
交叉引用界面能把所有引用点列成列表,还能看到每个引用是在哪个POU、哪一行。这一步花不了两分钟,但能避免90%的漏改。我这次如果当时老老实实点一下交叉引用,而不是用软件的全局搜索,可能早就看到那8处漏网之鱼了。
4.3 文本编辑器的正则统计:用计数代替目测
对于ST源文件,我还有一个很多人不知道的小技巧:把ST程序导出成文本文件,用VS Code或者Notepad++打开,用正则统计某个数值出现的次数。
比如搜\b(?:100|16#64|1100100b)\b,一条正则就能把一个数值的所有进制形态一把抓出来。搜索之前先用"区分大小写"+"全词匹配"锁定边界,避免把1000或者K1001这种误判进来。
正则搜索的结果列表自带计数,你会清楚看到这个值到底出现了多少次。我当时要是这么干,就会发现"我以为只有5处,实际应该有十几处",而不是等设备出问题才回头查。
4.4 触摸屏与PLC共享变量:写死值的另一个重灾区
前面说到HMI脚本里也有写死值。这是最容易漏掉的一块,因为它是另一个工程文件,GX Works3里搜不到。GOT触摸屏的扩展脚本(比如配方切换、报警提示那些宏)里,经常直接写寄存器编号和数值判断,这些数字在PLC工程里没有任何影子。
我的做法是给触摸屏脚本也建一张"符号映射表",放在脚本文件开头的注释里:
// 寄存器映射: // D100 = 配方号 // D110 = 模式号 // 报警号 3001 对应 PLC 报警汇总的 200脚本里凡是用到这些编号的地方,全部改成注释引用,每次改PLC侧的值,同步更新这张表。虽然麻烦,但好过改漏了之后到现场拿万用表对信号。
5. 重构之后的维护体验与长期习惯
5.1 改完之后:代码检查时长从半天降到半小时
把1700行ST里所有写死的业务值清点完、换成常量组之后,第一感觉是代码变"胖"了——声明区多出来几十行常量定义。但第二感觉是查问题变快了。
以前排查一个报警,得满程序搜数字,翻个半天才能确定报警号是从哪个分支出来的。现在直接在报警域常量组里找cAlarm_xxx,交叉引用点一下,两分钟就能顺着引用链路把源头找出来。实测下来,一次常规的代码检查,从原来的大半天缩短到半小时左右,而且基本不会出现"搜不到"的情况。
5.2 几个值得长期坚持的习惯
这次踩坑之后,我给自己列了五条铁律,分享出来供参考:
- ST程序里禁止出现裸数字业务值。要么是常量,要么是带注释的立即数
- 同一业务域的所有相关常量必须放一起,用统一前缀,禁止散落在各个POU里
- 凡是设计到模式/状态/报警类型的值,按枚举的方式组织,用常量组模拟
- 改任何值之前,先交叉引用,再正则统计,确认数量对得上再动手
- 触摸屏脚本里的数值单独建映射表,和PLC侧常量同步维护
5.3 如果你刚接手一个老项目,先从这里下手
最后再说一个实际经验。如果你刚拿到一个别人的三菱ST项目,想快速判断这程序值不值得重构,先搜几个关键词:看程序里有没有16#开头的字面量、有没有大段的IF x = 数字比较、有没有数组索引直接用数字的。这三样只要中两样,基本可以断定是"写死值重灾区"。
这时候别急着大改。先花半天时间把所有数值字面量列个清单,按"流程模式、报警、索引、参数"四类分组,再决定哪些转常量、哪些转枚举模拟。这个准备工作做完,后续改起来才心里有数。
这台灌装机从那次重构到现在跑了快半年,再没出现过"改一个值带出三个坑"的情况。回过头来看,所谓"常量vs枚举"的争论其实是表象,核心问题是:你愿不愿意在写每一行代码的时候,多想一步"这个值未来会不会变"。
如果会,就该用符号替代它。这一步多想,省下来的可不只是排查时间,还有半夜远程连客户设备的那些糟心时刻。