☰
ST代码拆不拆?八百行整段与二十行小块在PLC工程中的取舍
2026/10/13 9:11:05 网站建设 项目流程

1. 两种风格的真实样貌:八百行整段与二十行小块的现场差异

在工控圈里聊ST(结构化文本),绕不开一个灵魂拷问:一台设备的自动运行程序,有人习惯按工艺顺序从头到尾写成一段八百行的大代码,也有人坚持每个动作拆成二十行左右的功能块,哪种才算“好”?这个问题我前前后后纠结了好几年,改过别人的八百行状态机,也重构过拆碎到找不着完整流程的“积木工程”,最后得出的结论是:两种写法都有存在的理由,但都有致命伤,关键不是“选边站”,而是搞清楚你手里这台设备、这个团队、这种调试方式到底适合哪种组织结构。

先说清楚,我这里说的ST是IEC 61131-3标准里的结构化文本语言,行业里大多数人简称ST。它和C语言、Pascal这类高级语言在语法上很像,但运行环境完全是另一套逻辑——程序不是跑完就结束,而是在PLC的扫描周期里一遍一遍循环执行。这个底层差异决定了“代码拆不拆”在工控领域里比在普通软件项目里更敏感,拆不好就是设备乱动作,不拆又会让维护的人崩溃。这篇文章我会把两种风格的现场表现、背后的运行原理、以及一套能直接落地的拆分判断方法都过一遍,最后聊几个实际调试中最容易踩的坑。

1.1 八百行一段的典型现场:流程很顺,改动很痛

我接手过一个模拟项目X,整套自动运行逻辑写在一个POU里,从启动按钮按下开始,载入、定位、夹紧、加热、冷却、下料,八百多行代码一气呵成。刚开始看这种代码有个非常明显的优势:程序执行顺序就是工艺动作顺序,从第一行读到最后一行,等于把设备流程表看了一遍。当时设备处在调试末期,动作逻辑频繁调整,改这种代码其实效率很高——想改哪个步骤,直接定位到CASE分支或者IF块里,改完下载,盯着动作看一遍,完事。

但设备稳定运行三个月后就出问题了。首先是“能改不敢改”,自动化程序里总有些传感器信号是复用的,同一个输入变量可能在多个状态分支里出现,改一个地方的逻辑,另一个状态分支里跟着变,但你在编辑器里根本看不出来。然后是阅读成本,八百行代码塞在一个POU里,想找一个夹紧气缸的输出变量,得先回忆它到底在第几个状态里,再用滚动条翻好几个屏幕。注释如果写得稀稀拉拉,新来的人接过代码,第一周基本都在猜“这段逻辑为什么这样写”。最致命的是在线监视——ST不像梯形图能一个一个元件看通断,断电状态下想单步跑,只能在特定位置设断点,但一段八百行的程序,断点设少了找不到问题,设多了跟没设一样。

还有版本管理的问题。两个人同时改一个POU,哪怕只是改两个不同的状态分支,合并起来也极其痛苦,因为整段代码是连续文本,Git的diff会把一大片内容标成冲突。这种场景我后来见过太多次了。

1.2 二十行一段的典型现场:每个块都懂,连起来就晕

后来我又参与了一个跨平台系统的改造,前任工程师把程序拆得很细,每个功能块基本都在二十到五十行之间,甚至一个气缸动作都不愿意直接写输出,非要包一层FB_ValveControl。这种代码的好处一眼就能看到:每个功能块的职责单一,打开就能读懂,哪怕不熟悉设备也能猜个大概;而且复用性高,同类动作在多个工位出现时,直接实例化同一个功能块就行。

但真正现场调起来就不是那么回事了。让我印象最深的是,新来的调试人员打开主程序,看到二三十个功能块实例整整齐齐排在调用表里,却完全看不出设备的运行顺序。他得先打开FB_StepLoad看里面输出什么,再跳到FB_StepHeat看里面做什么,还要顺着接口找到底哪几个信号串联起来的。有一次设备报“出料超时”,排查半天,发现出料动作散布在三个功能块里:出料阀控制、出料完成检测、出料互锁保护,各干各的事情,中间靠一堆BOOL变量传递状态。这种拆法导致一个直观问题:程序碎片化,你永远无法从一段主程序里看出完整工艺流程。

更麻烦的是,拆得越细,功能块之间的接口参数就越复杂。我当时数过,某个功能块输入输出接口加起来十几个,调用的时候要填一堆变量名,稍微写错一个信号,设备就动不了。而且功能块内部往往有自己保持状态,多个功能块之间如果变量传递有误,会出现“每个块单独看都对,连起来整个流程就是不顺”的现象。这种问题在梯形图时代不明显,因为梯形图本身的图形化排列会暗示信号流向,但ST文本化之后,调用顺序就成了隐含逻辑,光看代码很难还原出来。

2. 为什么ST代码拆不拆的问题,比普通软件工程里更麻烦

很多人会拿“函数拆分”来类比ST的功能块拆分,说C语言里不也提倡函数别写太长吗?但ST运行在PLC里,很多普通软件的规则到这里就不完全适用了。理解这一点,才能明白为什么“拆不拆”能成为一个争论话题。

2.1 扫描周期和执行顺序是ST代码的隐藏骨架

PLC程序最大的特点是周期扫描。CPU从程序第一行开始,按顺序执行到最后一行,然后刷新输出、读入输入,再从头开始下一轮执行,一轮的时间就是扫描周期,常见几个毫秒到几十毫秒不等。这个机制带来两个后果:第一,所有程序代码在一个周期内按顺序跑完,同一段逻辑里如果你把某个动作的启动放在前面,把对应的停止放在后面,它们在同一周期内就都会执行;第二,程序中任何变量都是全局可见的,没有普通操作系统里的进程隔离。

八百行整段程序在扫描周期里天然“顺”:同一个周期内,前面写的输出条件会立即影响后面的判断,状态切换和执行动作之间的时序关系非常紧密,调试时你只要顺着执行顺序看变量变化,就能把逻辑捋清楚。但拆碎之后,功能块之间的调用顺序必须靠主程序里的排列保证。比如你在主循环里先调用FB_StepA再调用FB_StepB,如果有一天有人把两个实例的调用顺序换了一下,程序语法完全没变,编译也不会报错,但设备动作可能就变了。这种“隐含顺序”问题在普通软件里也存在,但因为在PLC里每个扫描周期都会重复执行,累积下来的影响会非常明显,而且很难通过静态审查发现。

2.2 调试方式与维护习惯决定了代码结构的可接受度

ST的文本化特性让在线调试更依赖人的经验。用梯形图的时候,点亮和熄灭的线条能直观告诉你看门狗逻辑通了没有,但ST程序里只有一个变量表,你需要一边盯着代码一边盯着变量,靠脑内对应关系来判断“当前状态到底在哪一步”。

大段程序状态下,为了定位一个动作为什么不执行,我通常会在程序里临时加“监视变量”,把可疑位置的状态量放到Watch窗口里,一步步刷。但八百行程序里变量多、状态多,经常看漏。拆碎之后,单步调试方便了,每个功能块都可以独立执行、独立观察,但你又面临另一个问题:故障往往发生在多个功能块的交接处,而不是单个功能块内部。比如“加热完成后没有进入冷却”,问题可能出在加热功能块的Done信号没保持住,也可能是主状态机里对应分支的跳转条件写错了。你单独看任何一个功能块都是好的,组合在一起才有问题,这类现场排查需要来回跳转,效率并不高。

还有一个常被忽略的因素:老一代工程师习惯看长串代码,年轻工程师更倾向模块化。两种风格混在同一个维护团队里,如果代码结构走极端,交接成本会非常高。这不是技术问题,是组织问题,但最终都会反映在设备停机时长上。

2.3 团队协作和版本管理也在逼你做出选择

大型设备的程序不是一个人在写。一个项目可能分成机械手控制、输送线控制、视觉检测几个子系统,由不同的人维护。在这种前提下,一整段八百行的POU基本没法并行开发,因为Git合并时整页冲突,只能等一个人改完另一个人再上。拆成小功能块确实解决了这种文件级冲突,但也带来了接口管理负担——大家约定好接口还好说,一旦有人改了功能块的输入参数,所有调用点都要跟着改,遗漏一个就编译报错或逻辑出错。

我记得有个项目里,不同工程师各写各的功能块,命名风格完全不统一。有人写FB_Valve,有人写FB_ValveCtrl,还有人写FB_Value_Control,三个块干的是同一件事。这种“拆碎但没有管理”的情况,比八百行整段更让人崩溃。所以后面我逐渐意识到:拆不拆不是非黑即白的选择题,而是要找到一种既能让工艺顺序看得见,又能让局部逻辑可复用的组织方式。

3. 落地实操:先判断一段代码该不该拆,再决定怎么拆

绕了一圈,回到核心问题:我手里这段ST程序,到底什么时候该拆,什么时候不该拆?这里给你一套我用了很久的判断方法,以及一种兼顾“流程可见”和“模块可复用”的三层代码结构。

3.1 用三个问题快速判断一段代码该不该拆

第一个问题:这段代码里的内容,在设备上会不会重复出现?如果同一个动作逻辑(比如气缸伸出到位检测、伺服回原点、报警延时处理)在两个以上地方出现,那就值得拆出来做成功能块,否则每次复制粘贴都是埋雷。第二个问题:这段代码能不能独立测试?我见过很多“为了拆而拆”的案例,把一个几十行的PID调节逻辑拆成三个功能块,但其实调速逻辑必须依赖传感器信号一起参与,根本没法独立验证。这种逻辑放在一起反而更容易调试。第三个问题:一个屏幕能不能看完?ST编辑器一般一屏能显示三十到五十行,如果一段代码超过一屏就要滚动着看,你又没法同时看到开头和结尾,我建议拆。如果一段代码始终在一屏内,哪怕里面有几百行,只要这部分逻辑是完整的工艺段,可以保留。

顺便说一个比较反直觉的经验:状态机主体反而适合写成“大段”。一个自动运行状态机,状态枚举、跳转条件、每个状态下的动作输出,如果拆成十几个功能块,每个状态一个块,你其实就失去了“一眼看清状态迁移”的好处。我推荐的做法是,状态机作为一个主框架,里面每个状态分支的处理逻辑不超过一屏,超过就往外抽子块。这样主框架保持清晰,子块负责具体执行。

3.2 主状态机加子功能块的三层组织方式

我自己在多个项目中最终沉淀下来的结构是三层:最外层是主状态机,负责工艺顺序和状态切换;中间层是步骤功能块,负责某一个具体工艺步骤内部的小时序;最底层是设备驱动块,直接操作具体阀门、电机、传感器。代码组织上,主状态机内部用CASE语句按枚举状态挨个处理,每个状态分支里几行到十几行,只做“启动子功能块”和“判断子功能块完成信号”这两件事,其余具体动作全部扔给子块。

主状态机的简化轮廓大概是这样的:

FUNCTION_BLOCK FB_AutoCycle VAR eState : E_AutoState := E_AutoState.IDLE; fbStepLoad : FB_StepLoad; fbStepHeat : FB_StepHeat; fbStepCool : FB_StepCool; fbStepUnload : FB_StepUnload; END_VAR CASE eState OF E_AutoState.IDLE: IF bStart AND bSafetyOk THEN eState := E_AutoState.LOADING; END_IF E_AutoState.LOADING: fbStepLoad( bEnable := TRUE, bAtPos := bAtLoadPos, bEndPos := bAtLoadEndPos, bDone => bLoadDone, bError => bLoadError); IF bLoadDone THEN eState := E_AutoState.HEATING; END_IF E_AutoState.HEATING: fbStepHeat( bEnable := TRUE, rTemp := rFurnaceTemp, rSet := rHeatSetpoint, bDone => bHeatDone); IF bHeatDone THEN eState := E_AutoState.COOLING; END_IF // 其余状态类似处理 END_CASE

这种写法的好处非常明显:你打开主状态机,不用看几百行的逻辑细节,只看每个状态分支里的子功能块调用和跳转条件,就能把整台设备的工艺顺序复述出来。遇到具体问题再钻进对应的子功能块,定位到具体动作,不用满程序翻变量。

子功能块内部则保持“小而完整”的风格。比如FB_StepLoad负责“工件进料到夹紧”这个步骤,它内部有自己的小状态变量,甚至有自己的计时器和完成逻辑:

FUNCTION_BLOCK FB_StepLoad VAR eStep : E_StepLoadState := E_StepLoadState.WAIT; tonClamp : TON; END_VAR IF NOT bEnable THEN eStep := E_StepLoadState.WAIT; bDone := FALSE; tonClamp(IN := FALSE); RETURN; END_IF CASE eStep OF E_StepLoadState.WAIT: IF bAtPos THEN eStep := E_StepLoadState.CLAMP; END_IF E_StepLoadState.CLAMP: bOutClamp := TRUE; tonClamp(IN := TRUE, PT := T#500MS); IF tonClamp.Q THEN bDone := TRUE; eStep := E_StepLoadState.WAIT; END_IF END_CASE

注意子功能块里我加了IF NOT bEnable THEN这样的复位入口,这是很多拆碎代码最容易遗忘的部分。主状态机切换后,上一状态对应的子功能块的bEnable变成FALSE,子块内部必须在这个下降沿把自己复位到初始状态,否则等下一次又切换到本状态时,内部状态还停在上次结束的位置,动作就会乱。

三层结构真正解决的问题是:主层保持“八百行的流程可见性”,子层提供“二十行的局部可读性”。主状态机看起来仍像一个大段,但大段的每一小段都足够薄;子功能块足够碎,但碎得有一个统一框架约束,不会散成满天星。

3.3 接口、命名和注释约定要提前定好

拆碎之后最怕接口混乱。我给自己定了几条规则,分享出来可以直接用。

第一,功能块的输入统一叫bEnable开始,后面跟具体的输入信号,比如bAtPos、rTemp、iRecipe;输出统一用bDone、bError、bActive这类通用名。每个功能块至少要有bEnable、bDone、bError这三个接口,没有这三个信号的块基本没法挂到主状态机里统一管理。第二,状态枚举单独定义在一个公共数据类型文件里,不让每个功能块各自搞一套状态命名。第三,功能块内部的状态变量用eStep这类前缀统一表示,外部调用者不要直接访问功能块内部变量,全部通过接口和实例名来访问,这样替换实现的时候不会影响外部逻辑。

还有一个被很多人忽略的细节:主状态机头部写一段“主流程说明”注释块,把工艺动作顺序用简短文字列出来,比如“待机 -> 进料 -> 加热 -> 冷却 -> 出料”,再在后面写上每条跳转条件对应的传感器名称。这个注释块看着没什么技术含量,但后来接手维护的人一定会感谢你。我以前懒得写,后来自己接手别人的程序吃了亏,就再也不敢省这五分钟了。如果一个ST程序像一个迷宫,这个注释块就是迷宫入口的地图,它比任何代码风格都管用。

4. 常见问题与排查技巧实录

不管你是倾向大段还是小段,实际调试中都会遇到一些典型问题。我把这几年现场踩过的坑整理成几个场景,每一个都是真实遇到过并反复排查过的。

4.1 拆完之后状态丢步、输出乱跳,问题通常出在信号保持时间

有一回调试一台自动送料机构,功能块拆得很好,每个步骤都独立封装,但设备跑几个循环就会停一次,看状态机里的变量,总在某个该跳转的位置不跳转,或者跳过去之后马上又跳回来。排查了很久,最后发现是子功能块的Done信号保持时间太短:子块在完成动作的同一个扫描周期里把bDone置为TRUE,又因为内部状态复位逻辑把自己的bDone清掉了,主状态机还没反应过来,信号就已经消失。我用Watch窗口加了边沿捕获才发现,主状态机那个周期执行到跳转判断时,bDone已经是FALSE了。

这种问题在大段程序里几乎不会出现,因为大段代码的变量生命周期贯穿整个周期,但拆碎之后,功能块内部的局部时序和外部主循环的时序各有各的节拍,很容易出现“信号闪断”。处理办法有两个:要么在子功能块里让bDone至少保持一个扫描周期,在主状态机跳转后才被bEnable的下降沿复位;要么在主状态机里用R_TRIG上升沿捕捉子块Done信号,边沿触发比电平触发稳定得多。后来我把这条写进团队规范:状态跳转条件尽量用上升沿或者保持信号,避免直接用电平判断。

4.2 功能块互相调用形成回路,扫描周期直接失控

还有一次遇到设备偶尔“卡死”,一查程序,发现两个功能块互相调用了:FB_CheckSafety内部调用了FB_ValveControl,而FB_ValveControl里又调用了FB_CheckSafety来确认安全信号。这种逻辑在纸面上看起来每次都能执行,但在PLC扫描周期里就会形成递归调用,轻则把扫描周期拖长,重则直接导致看门狗超时停机。原因是有些ST编译器不支持递归,或者允许有限递归但没人会真去用。

排查方法也不难,利用开发环境里的调用树或者交叉引用功能,选中一个功能块看“谁调用了它,它调用了谁”,发现环路马上拆。设计阶段就要立规矩:驱动层功能块不能向上调用逻辑层功能块,逻辑层功能块不能调用主状态机,依赖关系只允许从上往下单向流动。这个规则比任何编码规范都重要。

4.3 在线监视时找不到变量,是新老工程师交接时的重灾区

程序拆碎之后,“全局搜索”成了最高频操作。但你搜到一个变量名,跳进去却发现是一堆功能块实例的输入输出,根本不知道当前谁在写、谁在读。我自己的排查顺序是:先看主状态机当前停在哪个状态;再看这个状态下哪些子功能块的bEnable是TRUE;最后只看这些处于激活状态的块里面的变量变化。通过这个“三层定位法”,基本上能把问题缩小到几十行代码的范围内。

如果实在找不到,就在主状态机里临时加一个调试输出,把当前状态值传给HMI或者诊断报文。我见过太多人,设备一乱就抱着一个变量从头刷到尾,刷了半小时也没头绪,其实看一眼当前状态值,直接跳进去就是了。在ST里,跨功能块的变量传递最容易出错的地方是接口参数顺序,调用时少填一个、多填一个,IDE一般都会报错,但有时候你为了图省事用了位置传参而不是名称传参,编译能过,参数对不上就出逻辑问题。所以调用功能块时我坚持用名称关联写法,像bDone => bLoadDone这种,不要写fbLoad(TRUE, var1, var2, var3)这种纯位置传参。

4.4 在线修改与下载时的版本限制,直接由代码结构决定

工控程序最怕停产时下载新程序。整段八百行的POU,哪怕只改了一个传感器延时时间,在线修改时IDE往往会把整个POU标出来提示需要修改,因为文本级别的比对可能认为整段都变了,严重时要停机下载,生产线上根本接受不了。拆成小功能块之后,改动往往局限在某一个子块或主状态机的某个分支里,在线修改的成功率会高很多。

但拆碎也不等于可以随便在线改,功能块的接口类型和数量一旦变化,在线修改同样可能失败。我遇到过一个项目,赶产量时想把某个功能块的bError输出从BOOL改成带错误码的INT型,结果在线修改直接弹窗提示需要初始化,最后只能等生产间歇全量下载。所以我的建议是:接口设计初期就要想清楚,尽量稳定不变;内部实现的修改可以频繁做,接口结构变化尽量留到计划停机窗口,不要在产线上赌在线修改一定能成功。这不是技术问题,这是风险控制问题。

5. 实际项目中,我最终采用的混合策略

回到标题那个问题,八百行一段和二十行一段,ST到底哪种好?长期实践下来,我现在的答案已经变成了“视情况而定,但默认走混合结构”:状态机主框架允许写到几百行,但每个状态分支最多一屏,具体动作抽到中间层的步骤功能块,步骤功能块内部控制在三五十行,设备驱动逻辑单独封装并统一命名。这个结构既保留了大段顺序执行的流程感,又获得小功能块的可读性和复用性。主状态机里能清楚看到工艺流程,真出现动作不执行,又能在子块里单独测逻辑,不用铺开一整屏八百行去大海捞针。

5.1 按设备类型和工艺特点选择偏重方向

也不是所有设备都适合同一种结构。如果是单机设备,工艺流程简单、动作数量少、逻辑基本不会复用,直接一段一两百行的ST写完完全没问题,硬拆成功能块反而多一堆实例化代码。如果是多工位、多工序的连续生产设备,比如装配线、热处理线、包装线,这类设备工艺顺序复杂且动作重复性高,那就必须上“主状态机加子功能块”的结构,否则后期维护成本高到离谱。还有一类设备是工艺在持续迭代的,比如实验装置、小批量试产线,程序天天改,这时候更适合把每个工艺步骤都做成参数可调的子功能块,改工艺参数比改代码快得多。

我碰过最头疼的设备是那种“工艺复杂但只有一台”的非标机:拆细了吧,模块化优势发挥不出来,因为永远不复制第二台;写大段吧,调试时客户天天提需求,改动又频繁。这种设备我现在会折中一点:主流程用状态机,具体的动作尽量用结构化文本内联写,不抽成功能块,但保持每个状态分支小于一屏。这样既能快速改逻辑,又没把简单的事情复杂化。

5.2 几个坚持至今的代码习惯

最后分享几个和结构无关但很有用的习惯。第一,主状态机文件头部的工艺说明注释不要省,哪怕只是三五行字,也能给后来的人省下一整天摸索时间。第二,所有功能块的错误输出都要挂出去,至少有一个汇总的bError变量传到主状态机或HMI,不然设备停了没人知道是哪个功能块报的错。第三,状态枚举命名要语义化,不要起STATE_1、STATE_2这种名字,起成HEATING、COOLING、LOADING,调试时看变量列表一眼就能明白设备在干什么。第四,无论你多喜欢拆功能块,拆完一定要在主程序里保留一个清晰的调用顺序,不要散落在不同任务里,否则扫描周期的执行顺序会变得不可预期。

代码结构的争论永远不会有标准答案,但有一条底线是确定的:你要能在一个扫描周期结束的时候,清楚说出这个程序刚执行到哪一步、为什么停在那一步、下一步会做什么。如果一段代码写完之后,你连自己都想不清楚这个问题,那就说明这个八百行或二十行的选择都做错了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询