☰
ST代码组织粒度:800行一段与20行一段的工程权衡
2026/10/14 1:07:22 网站建设 项目流程

1. 从一段代码说起:为什么“800行 vs 20行”会成为一个问题

第一次看到“800行一段 vs 20行一段”这个对比,我脑子里蹦出来的不是技术问题,而是一个很具体的画面:某次帮朋友看一个嵌入式项目,打开主文件,一个函数从第37行一直写到第900多行,中间没有任何空行分隔,变量命名从a1排到a15,注释只有开头一句“初始化”。我当时的第一反应是——这玩意儿能跑起来已经算奇迹了。

但冷静下来想,这事不能一棍子打死。ST在这个语境下,通常指的是结构化文本(Structured Text),也就是IEC 61131-3标准里定义的那种PLC编程语言。它长得像Pascal,用IF...THEN...END_IF、FOR...DO...END_FOR这类关键字组织逻辑,在工业控制领域用得极广。写ST的人,背景五花八门:有从电气接线图转过来的老师傅,有学自动化出身的工程师,也有半路出家做设备维护的技术员。不同背景的人对“一段代码该写多长”这件事,理解差异大得惊人。

所以这个问题表面上是问行数,实际上问的是:在ST这种强实时、强硬件耦合、调试手段有限的编程环境里,代码的组织粒度到底该怎么定?800行一段的人往往觉得“逻辑都在一块儿,改起来方便”;20行一段的人则认为“拆开了才看得懂、测得了、复用得起来”。两边都有道理,但放到具体场景里,答案会偏向某一侧。

这篇文章不打算给你一个“必须拆成20行”或者“800行也能忍”的绝对结论。我想做的是把这个问题拆开,从ST的语言特性、编译器的实际行为、调试手段的局限、团队协作的成本这几个角度,把“为什么会有这个争论”以及“什么情况下该选哪种”讲清楚。如果你正在写ST代码,或者正准备接手一坨别人留下的ST代码,那接下来的内容应该能帮你少走一些弯路。

2. ST代码组织粒度的核心矛盾:可读性 vs 可维护性

2.1 为什么有人会把一段写到800行

先别急着骂。把一段ST写到800行的人,通常不是懒,而是被现实逼的。我见过几种典型情况:

第一种是硬件映射太直接。比如一个设备有32个气缸,每个气缸有伸出、缩回、到位检测、超时报警四个信号。如果按“一个气缸一个功能块”来拆,主程序里就是32个功能块调用,每个调用传一堆参数。有人觉得这样太啰嗦,干脆把32个气缸的逻辑全塞进一个FOR循环里,用数组索引去访问IO。这样一来,代码确实短了,但循环体内部因为要处理各种边界条件,越写越长,最后变成800行。

第二种是状态机写成了流水账。ST里写状态机很常见,但有些人不用CASE语句,而是用一堆IF...ELSIF串联。每加一个状态就加一个ELSIF,每个分支里再嵌套几个IF处理异常。写着写着,一个状态机就膨胀到几百行。这种代码的逻辑其实是线性的,但因为没有用CASE做结构划分,读起来就像在读一篇没有段落的长文。

第三种是历史遗留的“补丁摞补丁”。设备出厂时的程序可能只有200行,后来客户加了新功能,加一段;现场调试发现某个工况没覆盖,再加一段;换了传感器型号,又改一段。每次改动都是在原有代码后面追加,没人敢动前面的逻辑,因为“一动就出问题”。几年下来,800行算是客气的。

注意:800行本身不是罪,罪在于这800行里混杂了太多不同层次的抽象。如果800行都在做同一件事,比如一个复杂的数学运算,那还能忍;但如果800行里既有IO读写、又有状态判断、还有报警处理,那就是灾难。

2.2 20行一段的拥护者在追求什么

主张拆成20行一段的人,核心诉求其实就三个字:可测试。ST的调试手段比高级语言少得多,没有单元测试框架,没有断点调试(有些平台有,但很简陋),最常见的方法就是“改一个变量,看输出对不对”。如果一段代码只有20行,你可以很快定位到问题在哪个环节;如果一段有800行,你只能靠IF分支里的临时变量去猜。

另一个诉求是可复用。20行一段的代码,通常会被封装成功能块(FB)或者函数(FC)。比如“气缸控制”这个逻辑,如果写成独立的功能块,下一个项目直接调用就行,不用复制粘贴。而800行一段的代码,复用方式基本就是“复制整个文件,改改变量名”,这种复用带来的维护成本极高——改了一个bug,要同步改十个地方。

还有一个容易被忽略的点:编译器的优化行为。有些ST编译器对过长的代码段会做保守优化,比如不把变量分配到寄存器,而是频繁读写内存。20行一段的代码,编译器更容易做局部优化,生成的中间代码更紧凑。当然,这个影响因平台而异,但在资源紧张的PLC上,确实值得考虑。

2.3 粒度选择的本质:耦合度与内聚度的平衡

软件工程里有个经典原则:高内聚、低耦合。放到ST里,就是“一段代码只做一件事,这件事的输入输出要清晰”。800行一段的问题不在于行数,而在于这800行里可能包含了多个不同层次的内聚单元,它们之间通过共享变量耦合在一起,导致你改A的时候必须考虑B、C、D会不会受影响。

20行一段的好处是,每个单元的内聚度天然就高——20行能做的事有限,你很难在里面塞进两个不相关的逻辑。但坏处是,如果拆得太碎,单元之间的调用关系会变得复杂,原本一个变量能搞定的事,现在要通过参数传递、全局变量、或者功能块接口来传递,反而增加了耦合的隐式成本。

所以真正的判断标准不是行数,而是:这段代码的修改原因是否单一。如果一段代码会因为“气缸超时时间调整”而修改,也会因为“报警等级重新定义”而修改,那它就该拆。如果一段代码只会因为“气缸控制逻辑本身”而修改,那它即使有200行,也是合理的。

3. ST语言特性对代码组织方式的硬约束

3.1 ST不是C,别用C的思维硬套

很多从C/C++转过来写ST的人,会习惯性地把函数拆得很细,每个函数只做几行。这在C里没问题,因为C的函数调用开销小,编译器还会做内联优化。但在ST里,情况不一样。

首先,ST的功能块调用是有实例数据的。每个功能块实例在内存里占一块空间,用来保存它的内部状态(比如定时器的累计时间、状态机的当前状态)。如果你把一段逻辑拆成10个功能块,每个功能块都有自己的实例数据,这些数据在PLC的扫描周期里会被反复读写。如果PLC的内存紧张,这种拆分就会带来压力。

其次,ST的编译器对函数调用的优化能力有限。有些平台的ST编译器不会做跨功能块的内联优化,这意味着每次调用都有实际的跳转和返回开销。在高速扫描周期(比如1ms)下,过多的功能块调用可能会影响实时性。我实测过一个案例:把一段200行的逻辑拆成8个功能块后,扫描周期从0.8ms涨到了1.2ms。虽然还在可接受范围内,但如果你的系统余量本来就不大,这就是个问题。

提示:拆不拆,先看你的扫描周期余量。如果当前周期已经用掉了70%以上,拆之前最好先测一下调用开销。

3.2 变量作用域与命名空间的现实约束

ST的变量作用域比高级语言粗放得多。全局变量(VAR_GLOBAL)在整个项目里都能访问,局部变量(VAR)只在当前POU(程序组织单元)里有效。如果你把800行拆成20行一段,每段都是一个独立的POU,那原本共享的变量要么变成全局变量(增加耦合),要么通过参数传递(增加接口复杂度)。

我见过一个项目,原本一个800行的主程序里用了30多个局部变量,拆成15个功能块后,为了传递这些变量,接口参数列表长得像火车。更麻烦的是,有些变量在多个功能块里都要用,最后不得不提升为全局变量。结果就是:代码行数少了,但全局变量从5个变成了25个,调试时反而更难追踪“这个变量到底在哪儿被改了”。

所以拆之前,先画一下变量的依赖图。如果一段代码里的变量大部分是内部临时变量,拆起来很轻松;如果大部分是和其他段共享的状态变量,拆的时候就要设计好接口,否则就是拆了个寂寞。

3.3 扫描周期与代码执行顺序的隐性影响

ST程序是按扫描周期执行的,每个周期里,代码从上到下依次执行。如果你把一段逻辑拆成多个功能块,调用顺序就变得至关重要。比如,功能块A负责读取传感器,功能块B负责根据传感器值做判断,功能块C负责输出控制。如果调用顺序变成B→A→C,那B读到的就是上一个周期的传感器值,逻辑就错了。

800行一段的代码,执行顺序是线性的,你一眼就能看出“先读后判再输出”。拆成20行一段后,执行顺序分散在多个调用点,如果项目里还有多个任务(Task)并行,顺序问题就更隐蔽。我踩过一次坑:把一个报警处理逻辑拆成三个功能块,分别放在不同任务里,结果报警响应延迟了一个扫描周期,现场调试时查了半天才发现是任务调度顺序的问题。

所以,拆分的边界最好沿着扫描周期的自然分界来划。比如“输入处理”一段、“逻辑运算”一段、“输出更新”一段,这样每段内部的执行顺序是自洽的,段与段之间的顺序也符合扫描周期的习惯。

4. 从800行到20行:一次实际重构的完整记录

4.1 重构前的代码长什么样

我接手过一个设备控制程序,主程序文件叫MAIN.st,总共847行。结构大致是这样的:

  • 第1-50行:变量声明,包括20多个全局变量和30多个局部变量。
  • 第51-200行:初始化逻辑,上电时执行一次,包括气缸复位、参数加载、通信握手。
  • 第201-500行:主循环逻辑,一个巨大的IF...ELSIF链,处理手动模式、自动模式、报警模式、维护模式。
  • 第501-700行:报警处理,每个报警条件一个IF,里面嵌套定时器和输出赋值。
  • 第701-847行:通信数据打包,把各种状态变量拼成一个字节数组发给上位机。

这段代码能跑,但每次改需求都像拆炸弹。有一次客户要求“手动模式下增加一个点动功能”,我在第300行附近加了一个ELSIF分支,结果影响了第600行的报警逻辑——因为报警逻辑里用了同一个状态变量,而我在新分支里改了这个变量的值。

4.2 拆分策略:按“修改原因”划边界

我没有按行数硬拆,而是先列了一张表,把847行里每一段代码的“修改原因”写出来。比如:

代码段主要功能典型修改原因
初始化上电复位、参数加载新增设备参数、修改通信配置
手动模式点动、回零、单步新增操作按钮、调整速度
自动模式循环加工、节拍控制调整工艺参数、增加工位
报警处理超时、超程、急停新增报警类型、调整阈值
通信打包状态上传、命令接收协议变更、新增数据点

这张表一出来,拆分方案就清楚了:初始化、手动、自动、报警、通信,各做一个功能块。每个功能块内部的代码,再按“子功能”拆成更小的函数或方法。比如自动模式里,节拍控制、工位切换、完成判断,各是一个独立函数。

最终拆成了5个功能块,每个功能块内部有3-8个函数,单个函数最长不超过60行。总行数从847行变成了1100多行——因为增加了接口声明和注释。但修改成本大幅下降:改手动模式不会影响自动模式,改报警阈值不会碰通信打包。

4.3 拆分过程中遇到的三个坑

第一个坑:全局变量的诱惑。拆分初期,为了省事,我把原本的局部变量直接提升为全局变量,这样各个功能块都能访问。结果代码是拆开了,但耦合度没降——改一个全局变量的值,还是会影响所有功能块。后来花了两天时间,把大部分全局变量改成了功能块的输入输出参数,只保留了5个真正需要全局共享的状态变量。

第二个坑:功能块实例的初始化顺序。ST里功能块的实例数据在第一次调用时初始化。如果功能块A的初始化依赖功能块B的输出,而B还没被调用,A就会读到默认值。我当时的做法是在主程序开头显式调用一次所有功能块的初始化方法,确保顺序可控。

第三个坑:调试信息的丢失。800行一段的时候,我可以在任意位置插入一个临时变量,用在线监控看它的值。拆成功能块后,临时变量变成了功能块的内部变量,在线监控需要一层层展开功能块才能看到。后来我养成了一个习惯:每个功能块都留一个DEBUG数组,把关键中间变量写进去,在线监控时只看这个数组就行。

实操心得:拆分不是目的,降低修改成本才是。如果拆完之后,你发现改一个功能还是要跳转三四个文件,那说明拆分的边界没找对。

5. 什么情况下800行一段反而更合理

5.1 高速扫描下的性能敏感代码

有些ST代码运行在高速任务里,扫描周期只有几百微秒。这种场景下,功能块调用的开销就不能忽略了。我做过一个测试:在某个PLC平台上,一个空功能块调用大约消耗2-3微秒。如果你有50个功能块调用,就是100-150微秒,占掉了扫描周期的三分之一。这时候,把逻辑合并成一段长代码,减少调用次数,反而是更务实的选择。

当然,合并的前提是这段代码的逻辑确实紧密相关,而不是把不相关的逻辑硬塞在一起。比如一个高速PID控制回路,输入采样、PID运算、输出更新,这三步放在一个功能块里,即使有200行,也是合理的。因为它们的执行频率一致、数据依赖紧密、拆开反而增加开销。

5.2 资源极度受限的嵌入式PLC

有些小型PLC的内存只有几十KB,功能块实例数据会占用宝贵的内存空间。每个功能块实例至少需要几十个字节来保存状态,如果拆出50个功能块,就是几KB的开销。在内存捉襟见肘的情况下,把逻辑合并到少数几个功能块里,可以减少实例数据的数量。

这种场景下,我的建议是:按数据生命周期分组。生命周期相同的变量放在同一个功能块里,生命周期不同的才拆开。比如,上电初始化用的变量,用完就可以释放;而循环控制用的变量,需要一直保持。把这两类变量放在同一个功能块里,就是浪费内存。

5.3 单人维护且逻辑高度内聚的模块

如果你是一个人维护一个设备,而且这个设备的逻辑非常内聚——比如一个简单的温控器,就是“读温度→比较→输出”三步循环——那写成一段200行的代码完全没问题。因为你知道每一行在干什么,改的时候也不会有人跟你并行修改。这种情况下,强行拆成20行一段,反而增加了你来回跳转的成本。

我个人的经验是:单人项目,代码段长度可以放宽到200-300行;多人协作项目,单个功能块最好控制在100行以内。这不是什么金科玉律,而是一个基于实际协作成本的粗略估计。多人协作时,别人读你的代码需要理解你的思路,100行以内的功能块,读起来不会太累;超过200行,别人就得反复上下滚动,效率明显下降。

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

6.1 拆分后编译报错“变量未定义”怎么办

这是拆分时最常见的问题。原因通常是:原本在同一个POU里的局部变量,拆分后变成了另一个POU的局部变量,但原POU还在引用它。排查步骤:

  1. 在编译器的错误列表里找到报错的变量名。
  2. 确认这个变量原本属于哪个POU。
  3. 如果它被多个POU使用,考虑提升为全局变量,或者通过参数传递。
  4. 如果它只被一个POU使用,检查是不是在拆分时漏掉了变量声明。

注意:有些ST编译器对变量作用域的检查比较宽松,即使变量未定义也可能编译通过,但运行时行为不确定。所以不要只看编译结果,还要做一次完整的在线测试。

6.2 功能块调用后输出不对,怎么快速定位

先查调用顺序。ST是顺序执行的,如果功能块A的输出被功能块B使用,但B在A之前调用,B读到的就是旧值。排查方法:在主程序里给每个功能块调用加一个递增的计数器,在线监控时看计数器的值是否符合预期顺序。

再查实例数据。如果功能块有内部状态(比如定时器),确认它的实例数据是否被正确初始化。有些平台在首次扫描时不会自动初始化功能块实例,需要手动调用一次初始化方法。

最后查参数传递。ST的参数传递有“值传递”和“引用传递”两种,如果接口定义用了VAR_IN_OUT,传进去的变量会被功能块修改。确认这是不是你期望的行为。

6.3 代码行数统计与质量评估的参考表

指标健康范围警告范围危险范围
单个功能块行数50-150行150-300行300行以上
单个函数行数20-60行60-100行100行以上
功能块接口参数数量5-15个15-25个25个以上
全局变量数量5-10个10-20个20个以上
嵌套深度2-3层3-5层5层以上

这张表不是标准,是我从多个项目里总结出来的经验值。超过警告范围不一定有问题,但值得停下来想一想:是不是该拆了?是不是该重新设计接口了?

6.4 独家避坑技巧:用“修改日志”反推拆分边界

如果你不确定一段代码该不该拆,可以做一个简单的实验:在接下来的一周里,记录每次修改这段代码的原因。如果修改原因集中在同一个功能点上,说明这段代码的内聚度够高,不拆也行;如果修改原因分散在多个不相关的功能点上,那这段代码就该拆了。

这个方法我用了很多次,比任何理论分析都管用。因为它是基于实际修改行为的,而不是基于你对代码的“感觉”。感觉会骗人,但修改日志不会。

7. 我个人的实操体会

写了这么多年ST,我现在的习惯是:先写长,再拆短。第一版代码,我会顺着逻辑一口气写下来,不管多少行,先把功能跑通。这时候最重要的是验证逻辑正确性,而不是代码美观。等逻辑稳定了,再回头做一次拆分,按“修改原因”划边界,把长代码拆成短功能块。

拆的时候,我会问自己三个问题:这段代码会因为什么原因修改?修改它的时候,我需要同时看哪些其他代码?如果把它封装成功能块,接口参数能不能控制在10个以内?三个问题都能答上来,拆分的方案就清楚了。

最后再分享一个小技巧:给每个功能块写一句“一句话描述”。比如“这个功能块负责在自动模式下按节拍切换工位”。如果这句话写不出来,或者写出来超过20个字,说明这个功能块的职责不够单一,可能还需要再拆。这个方法看起来简单,但非常有效,能帮你快速发现那些“什么都干一点”的功能块。

至于800行还是20行,我的答案是:看修改成本,不看行数。如果800行的修改成本比20行低,那就800行;如果20行的修改成本更低,那就20行。行数只是表象,修改成本才是本质。

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

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

立即咨询