上周在客户现场调一条老产线,CPU的看门狗一直报“任务周期超时”。我一开始以为是通讯负载的问题,结果把程序翻了个底朝天,最后定位到的问题说出来你可能不信——就是一段ST字符串循环解析,单次最坏要跑四十毫秒。对,数据量算下来每秒二十几帧,一帧解析四十毫秒,CPU的时间被吃得干干净净。把这段逻辑优化到两毫秒之后,所有超时报警全部消失。今天就把这个“2ms vs 40ms”的优化过程完整拆开,讲讲ST(Structured Text,IEC 61131-3标准下的结构化文本)里字符串循环处理为什么这么慢,以及怎么把它压下来。
如果你在PLC项目里解析报文、拼接文本、处理传感器返回的字符串,或者搞过通讯协议解析,这篇文章应该能帮你少走不少弯路。我会从问题现场、慢的原因、改写思路、完整代码对比,到推广方法和PLC环境里特有的坑,一层层讲清楚。
1. 40ms是怎么来的:ST字符串函数的性能账
1.1 一个典型的字符串循环解析现场
很多设备上传的数据是ASCII文本格式,比如扫码枪、读码器、称重仪表,都爱用逗号分隔的字段串。我遇到的项目里,设备每帧数据大概256字节,里面有设备ID、温度、湿度、状态标志等十几个字段。老工程师的第一反应,就是用字符串函数一个个找分隔符、一个个切字段。
我先把当时那种典型写法简化出来,你一眼就能认出自己有没有写过:
// 慢速版:循环切字符串 sBody := sFrame; // 整帧文本数据 iCnt := 0; WHILE (LEN(sBody) > 0) AND (iCnt < 10) DO iPos := FIND(sBody, ','); // 找下一个逗号 IF iPos = 0 THEN tagList[iCnt] := sBody; // 没有逗号了,整段取走 EXIT; ELSE tagList[iCnt] := LEFT(sBody, iPos - 1); // 取当前字段 sBody := MID(sBody, iPos + 1, LEN(sBody) - iPos); // 剩余部分 END_IF iCnt := iCnt + 1; END_WHILE这段代码逻辑一点问题都没有,在PC上写C#或者Python,几十个字段也就是毫秒级的事。问题在于,它跑在PLC里,用的是ST语言,宿主环境完全不是一回事。我实测下来,单帧256字节解析一次大约0.6毫秒,听起来不多,但现场一帧接一帧来处理,64帧批量解析直接飙到40毫秒。
1.2 字符串函数在PLC运行时里到底做了什么
为什么同样一段逻辑,PC上快得没感觉,ST里却慢到爆?因为PLC的运行时环境和PC差别太大了。
PC上的字符串函数背后有大量优化撑着:短字符串优化(SSO,Small String Optimization)让短字符串根本不分配堆内存,缓存友好、分支预测、SIMD指令,一个MID可能就是一两条指令的事。而很多PLC的ST运行时是解释执行,或者经过中间层编译后再解释执行,根本没这些优化。
更关键的是,ST里的FIND、LEFT、MID、拼接操作,每一次调用都不是“读一段内存”这么简单。它们要管理字符串对象、维护长度头、检查边界、分配释放临时内存。
我打个比方:在PC上调用字符串函数,像从传送带上直接拿一杯水;在ST里调用,是先找仓库管理员开库房、领桶、打水、再锁库房。调用一次两次无所谓,可你在循环里成千上万次调用,光“开门锁门”的时间就够喝一壶了。
1.3 真正拖垮性能的三个细节
我后来做了个简单的性能剖析,把慢的原因归纳成三个点:
| 开销来源 | 具体表现 | 严重程度 |
|---|---|---|
| 循环内字符串拼接 | sBody := MID(...)每次都要把剩余部分完整拷贝一遍 | 平方级增长 |
| 临时字符串对象 | LEFT、MID、FIND每次调用都要构造/销毁临时字符串 | 高 |
| 动态内存分配 | 每次创建字符串都可能触发堆分配,跑久了还产生碎片 | 中高 |
第一个是最要命的。每切一个字段,MID就要把剩余的字符串整个拷一遍。假设一帧有10个字段,第一次切完要拷贝剩余9个字段的长度,第二次拷贝剩余8个字段的长度……总拷贝量是O(n²)。20个字段的时候还好,100个字段的时候,性能直接就难看给你看。
第二个也好理解。LEFT返回的是个新字符串对象,FIND返回前也要构造搜索用的临时对象。这些对象用完就销毁,来来回回全是开销。你写代码时只看到一行tagList[iCnt] := LEFT(...),实际背后是一整套对象生命周期管理。
第三个是隐藏炸弹。频繁的堆分配不只慢,还会造成内存碎片。PLC程序经常要求365天×24小时不间断跑,碎片累积到一定程度,系统行为会变得很奇怪——这是后话,第5部分专门讲。
2. 换个视角:把字符串当内存块处理
2.1 标准STRING的内存模型与寻址开销
要优化ST里的字符串循环,第一步是彻底理解STRING到底是什么。
在很多ST实现里,STRING不是一段裸字符数组。它前面有长度字节(有的平台是双字节长度头),尾部有0x00终止符,运行时对字符串的任何操作都要维护这个结构、检查合法性。也就是说,字符串是一个封装好的对象,而不是一块可以随便玩的内存。
这带来一个很实际的问题:标准STRING默认长度只有80个字符。我在现场见过不少工程师把一百多字节的报文直接往STRING里塞,结果后半段被悄悄截断,解析出来全是乱码。用STRING(255)甚至WSTRING能装下,但WSTRING是双字节存储,处理ASCII文本时反而更别扭。
所以我的结论是:在ST里处理大量循环文本,别把字符串当作“文本”来操作,把它当作“内存块”来操作。
2.2 用BYTE数组承载文本,绕开动态内存开销
优化思路其实很直接:报文进来之后,不要先转成STRING,而是放在一个固定大小的ARRAY[0..N] OF BYTE里。扫描、查找、切分全部用数组索引来做,只有最后需要输出字段时,才把对应区间一次性转成STRING。
BYTE数组在ST里就是一块连续内存,索引访问编译出来就是简单的地址计算加读取,没有长度头维护、没有对象构造、没有越界检查(或者说检查成本远低于字符串对象管理)。这个差别,在循环里会被放大到极其可观的程度。
配套的一个重要习惯是:串口或以太网收数据时,直接让接收缓冲区指向BYTE数组,数据在哪就在哪解析,不要先拼成STRING再拆。很多人慢就慢在“先汇总成字符串,再解析字符串”,中间多了一整轮不必要的转换开销。
2.3 块拷贝替代逐字拼接,复杂度从O(n²)降到O(n)
切字段时,最高效的做法不是逐字把字符搬过去,而是用内存块拷贝,一次把一整段连续字节搬到目标数组。用ST语言写的话,就是调用运行时提供的内存拷贝函数,具体名字各平台不一样,有的叫SysMemCopy,有的叫MemMove,有的叫MEMCPY,你查一下自己用的IDE文档就行。
块拷贝一次搬几十个字节,比逐字节循环快一个数量级。而且配合“边界扫描”的思路,整个过程就变成了:
- 从头到尾扫描一遍BYTE数组,记录每个分隔符的位置;
- 根据分隔符位置算出每个字段的起点和长度;
- 对每个字段做一次块拷贝。
扫描一遍是O(n),每个字段只拷一次,总复杂度从O(n²)降到了O(n)。40ms到2ms的差距,本质上就是复杂度等级和对象开销的差距叠加出来的。
3. 一个报文解析案例的完整改写过程
3.1 输入场景与需求
为了让你能直接照搬,我给一个具体的测试场景。假设设备上传的数据格式长这样:
T=23.5,H=61.0,ID=2048,FLAG=OK<CR><LF>每2毫秒来一帧,主程序需要把所有帧的字段解析出来,按ID排序后拼成一个总报文,再转发给HMI显示。我造了64帧报文、每帧约256字节的测试数据,在某个中端PLC上跑,任务周期设置为2ms。
3.2 版本一:FIND+MID循环,40ms
慢版代码就是我第1部分展示的那段,外面再套一层帧循环。我把它写完整一点:
// 慢速版:64帧批量解析 FOR k := 0 TO 63 DO sFrame := frameBuffer[k]; // 取第k帧文本 sBody := sFrame; iCnt := 0; WHILE (LEN(sBody) > 0) AND (iCnt < 10) DO iPos := FIND(sBody, ','); IF iPos = 0 THEN tagList[iCnt] := sBody; EXIT; ELSE tagList[iCnt] := LEFT(sBody, iPos - 1); sBody := MID(sBody, iPos + 1, LEN(sBody) - iPos); END_IF iCnt := iCnt + 1; END_WHILE END_FOR用这个版本跑64帧,我实测最坏情况40毫秒。注意,我说的是“最坏情况”,因为FIND的扫描长度和MID的拷贝长度都跟字符串当前长度有关,字段越多、越长,越接近这个上限。
3.3 版本二:边界扫描+区间块拷贝,2ms
快版的核心数据结构是BYTE数组加两个边界数组。代码如下:
// 快速版:64帧批量解析 // 假设 dataBuf 是收到的原始BYTE数组,dataLen 是有效长度 // fieldStart 记录每个字段的起始下标,fieldLen 记录每个字段的长度 idx := 0; fieldStart[0] := 0; // 第一遍:扫出所有字段的边界 FOR i := 0 TO dataLen - 1 DO IF dataBuf[i] = 16#2C THEN // 逗号对应十六进制2C fieldLen[idx] := i - fieldStart[idx]; idx := idx + 1; fieldStart[idx] := i + 1; END_IF END_FOR fieldLen[idx] := dataLen - fieldStart[idx]; tagCount := idx + 1; // 第二遍:每个字段只做一次块拷贝 FOR j := 0 TO tagCount - 1 DO // 将第j个字段的连续字节拷到临时数组 MEMCPY(ADR(tmpBytes), ADR(dataBuf[fieldStart[j]]), fieldLen[j]); tmpBytes[fieldLen[j]] := 0; // 补一个字符串结尾符 // 将零结尾的字节数组一次性转成ST字符串 tagList[j] := BYTES_TO_STRING(tmpBytes); // 函数名以你用的平台为准 END_FOR这段代码里有两个平台相关的点需要你确认:MEMCPY的实际函数名,以及BYTE数组转STRING的写法。不同IDE之间差异不小,但只要原理一致,性能表现都差不多。
另外注意,上面只展示了一帧的解析逻辑,64帧就是外面再套个循环,每帧的dataBuf指向该帧的起始位置,dataLen是该帧的长度。字段边界数组每帧复用时,记得在帧循环开头把idx清零。
3.4 实测数据与结果解释
我在同一台PLC、同一份输入数据上跑了三个版本,结果如下:
| 实现方式 | 单帧(约128B) | 64帧批量 | 备注 |
|---|---|---|---|
| FIND + MID 循环 | 0.6 ms | 40 ms | 循环内反复构造临时字符串 |
| 字节数组 + 块拷贝 | 0.03 ms | 2 ms | 扫描一遍 + 每个字段一次块拷贝 |
| 字节数组 + 直接消费 | < 0.01 ms | < 1 ms | 不做字符串转换,下游也用BYTE数组 |
这里有个细节值得展开。批量场景里的2ms,其实最后把字段从BYTE数组转换成STRING的那一步占了大约1.5ms。字符串转换是类型系统的固定开销,除非下游逻辑也改成直接消费BYTE数组,否则这部分省不掉。换句话说,2ms已经是“既要快、又要兼容现有STRING接口”的平衡结果。
如果你连这1.5ms也想省,那就得让HMI或者数据库对接时直接接收BYTE数组,或者用指针方式共享内存。我在后面的第4部分会讲清楚什么时候值得这么做。
4. 这套思路可以推广到哪些循环场景
4.1 批量报文拼接和数字转字符串
“把字符串当字节数组操作”不只适用于解析,还适用于生成文本。最常见的坑是拼接:很多工程师用str := str + part这种方式在循环里拼报文,拼10段可能没问题,拼100段就明显卡顿。原因和解析一样,每次拼接都会构造新字符串、拷贝全部已有内容,又是O(n²)。
正确做法是:先算好每段内容放哪里,直接把各段的字节写进BYTE数组的对应位置,最后统一转成STRING。我在实际项目里拼一段几千字节的XML格式报文,老写法要30多毫秒,改成字节数组定位写入后,3毫秒以内搞定。
另一个容易被忽视的重型操作是REAL_TO_STRING这类数值转字符串函数。它要做浮点数格式化、处理小数点和科学计数法逻辑,在ST里是最贵的一类函数之一。如果你在循环里对几十个数值做转换,性能立刻现原形。要么提前算好缓存,要么用定点数格式自己组装字节,能不进循环就别进循环。
4.2 什么时候不该用字节数组硬刚
优化不是无脑套方案,下面这些情况我劝你谨慎:
一是数据量极小、频率极低。比如一分钟才解析一次、一次只有几十字节,没必要为了省0.1毫秒把代码复杂度拉高。可维护性也是成本。
二是涉及WSTRING和Unicode文本。字节数组方案本质是ASCII/单字节友好型,一旦要处理中文、emoji、UTF-8多字节编码,边界计算和编码转换会变得很麻烦,容易翻车。
三是你的平台字符串函数本身没那么差。有些PLC的ST编译器已经做了激进优化,字符串函数性能可能远超你手写的循环。遇到这种情况,自己折腾字节数组反而可能更慢。所以先用第5部分的A/B测试方法,用自己的真实数据跑一遍再决定。
4.3 顺带澄清:这里说的ST不是算法里的“ST表”
不少朋友看到“ST”第一反应是算法竞赛里的ST表(Sparse Table),也就是用来做RMQ区间最值查询的稀疏表。我这次说的ST是Structured Text,结构化文本,IEC 61131-3标准里定义的PLC编程语言。两个完全不是一回事,做项目文档的时候千万别混着用。如果你搜“ST字符串循环优化”搜到一堆稀疏表的内容,不是你看错了,是这两个概念真同名。
顺带一提,字符串优化里确实有用到“表”的思路——比如提前把每帧的字段边界信息存成表,而不是每次都重新扫描。这算是两者唯一的交叉点了。
5. PLC环境里优化不得不防的五个坑
5.1 看门狗和任务周期
40ms的解析逻辑放到2ms的任务里,最直接的后果就是任务周期超时。PLC的任务管理器会认为CPU被卡死,触发看门狗,轻则报警,重则停机。我遇到的那条产线就是这样,报警原因一直指向“周期超时”,但谁都没想到罪魁祸首是几十行字符串处理。
优化到2ms之后,这个逻辑从任务周期里几乎消失了。但这引出一个重要原则:不要在周期任务里做变长的大块字符串处理,哪怕优化了也要控制数据量。如果报文量真的很大,考虑分帧处理,每轮任务只解析几帧,而不是一次全塞进来。
5.2 内存碎片与运行数月后的性能衰减
ST里反复创建和销毁临时字符串,带来的不只是一次性性能损耗,还有内存碎片。现场程序是要长期跑的,一个月、三个月、半年,碎片越积越多,堆分配越来越慢,最后系统可能出现莫名奇妙的卡顿。
这也是为什么我一直建议用固定BYTE数组:数组在数据块里是静态分配的空间,不涉及堆的分配释放,等于从根上把碎片问题解决了。所以这次优化不只让2ms和40ms的区别变得直观,还把未来几个月的隐患提前排掉了。
5.3 在线下载/冷启动对缓冲区初始化的影响
这是一个很容易被忽视的坑。PLC在线更改程序或者冷启动后,所有VAR变量会被重新初始化。如果你的BYTE数组缓冲区是VAR,重启后内容是未知的,而通讯设备可能在你准备好之前就已经发数据过来了。
我遇到过的情况是:重启后半包数据进了缓冲区,程序按完整报文解析,结果字段错位、数据全乱。解决办法有两个:一是解析前校验报文完整性,比如检查长度字段和结尾符;二是在代码里做半包等待,数据不足一帧就先攒着,凑齐了再解析。这个是老生常谈,但用BYTE数组优化完之后特别容易忘。
5.4 峰值与均值:别被第一轮暴力测试骗了
做性能对比的时候,只跑一次就下结论是最容易犯错的事。第一次调用时,缓冲区、缓存、系统状态都是“冷”的,和连续运行后的“热”状态差别很大。
我习惯的做法是:连续跑1000次以上,记录均值、最大值和P99分位值。40ms和2ms的对比,在均值和峰值上都有意义,但如果你只测一次恰好撞上最坏情况,可能会高估优化的必要性;反过来,只测一次恰好是最优情况,又可能低估问题严重性。
另外,测试数据要有代表性,字段数量、字符串长度都要覆盖现场的真实范围。我那次就是把现场几百帧真实报文导出来回放的,比随机造数据可靠得多。
5.5 不同平台的字符串函数实现差异很大
最后提醒一件重要的事:不同PLC平台对ST的支持差别非常大。有的平台ST是编译成机器码执行的,字符串函数内部做了不少优化,性能可能本来就还行;有的平台是解释执行,字符串函数贵得吓人。
我在一个平台上测出来FIND+MID循环要40ms,换一个平台可能只要4ms。所以不要照搬我这里的绝对数值,而要关注方法论:先在自己目标平台上写个小测试,把FIND、MID、拼接、REAL_TO_STRING这些基本操作的实际耗时摸清楚,再决定要不要重写。如果平台本身的字符串函数已经很优秀,你花大力气写字节数组版本,可能只省下1ms,根本不划算。
如果你要做的优化场景明确是“字符串循环处理”,我建议第一步永远是写基准测试,第二步才是动手改。很多时候,问题根本不在于循环逻辑本身,而是一开始就把字符串类型用错了。我在实际项目里的固定动作是:先花半小时把最贵的几个字符串操作测一遍,心里有数之后再决定方案。多数情况下,把字符串函数一个个从循环里拆掉,你会发现整个世界都清净了。