ST语言编程手册:语法图、数据类型与类型转换避坑
简介:《ST语言编程手册》以Word文档形式系统梳理结构化文本(ST)的语法与工程应用,面向工业自动化、机器人控制、数据采集与处理领域的初学者及需要查漏补缺的编程人员,帮助其从语法规则过渡到可实际落地的控制程序编写。压缩包内仅含1个doc文件,约5.63MB,正文按原理、基本元素、数据类型、变量声明、赋值与表达式、控制结构、函数、数组和字符串等章节展开,并配有语法图、标识符规则、数据类型取值范围、用户自定义类型与派生类型等细节,便于按目录检索和对照练习。目前已有235人学习,适合作为日常查阅手册或系统自学材料,读者可从中掌握ST源文件结构、注释与语句组织方式,理解枚举、结构、数组等复杂类型的声明与赋值,并借助运算符、分支循环和函数封装建立较完整的编程思路。
1. ST 语言编程手册不是教程:它是一本按语法图排出来的规则字典
多数人翻 ST 语言编程手册,都是被一个编译错误逼过去的:一段 IF 嵌套写完,下载下去轴不动;或者一个 REAL 赋给 INT,小数点后面的数悄无声息地没了,程序不报错,设备就是不对。这时候再去读手册,才会发现它压根不是按“从入门到精通”组织的,而是按字符组、标识符、字面量、数据类型、变量声明、表达式、控制语句、功能与功能块、源文件结构、调试手段一层层排下来的规则字典。这份 word 文档的价值不在通读,而在于它把“这个写法到底合不合法、这个变量到底什么时候被初始化、这个转换到底丢不丢精度”这三类问题一次说清楚。它适合已经能写几段 ST、但一遇到类型不匹配或者变量莫名被清零就想找依据的人;纯新手拿它当第一本书会卡在语法图那一节,拿它当查错依据刚刚好。
2. 语法图不是装饰:把第一章当查询表用
手册开篇就摆语法图,很多人扫一眼当成学术装饰跳过去,结果后面每一节都在用同一套记号。语法图真正回答的是一个很朴素的问题:这行代码在语言层面合不合法。它不负责告诉你业务对不对,只负责告诉你语法树能不能建起来。把它当成一张查得到的表,比从头到尾读一遍有用得多——尤其是你手上有一段从别人项目里抄来的声明,编译不过又看不出哪里不对的时候。
2.1 四种块结构,决定你能不能在心里跑一遍语法
语法图从左往右读,方块只有四类组合方式,认熟了就能把任何一段声明翻译成“必选什么、可选什么、能重复几次、在哪分叉”。
| 结构 | 图形特征 | 在 ST 里的典型落点 |
|---|---|---|
| 序列 | 方块依次排列,无分支 | 赋值语句:目标 := 表达式 |
| 选项 | 主线旁挂一条可跳过的支路 | 变量声明里的初值、RETAIN 之类的限定符 |
| 迭代 | 带回环箭头 | 标识符的后续字符、数组下标列表 |
| 替代 | 主线分叉成两条或多条 | 数字字面量的十进制 / 2# / 8# / 16# 写法 |
读图时先分清方块类型:圆形或椭圆形是终端,也就是字符组、预留词这类不需要再展开的基本元素;矩形是复合单元,说明它背后还有另一张图。这个区分很实用——当你看到一张图里嵌了一个矩形,就知道“这里还有一层规则”,不用硬猜。
落到代码上,最常被忽略的是“选项”这一类。变量声明里RETAIN、AT定位、:=初值全是可跳过的支路,跳过之后走的是默认路径。默认路径是什么,直接决定你下载之后看到的是 0 还是上次的值。
VAR RETAIN nTotal : DINT := 0; // 带初值,但它在保持区,下载策略另说 rSpeed : REAL := 1.5; END_VAR VAR i : INT; // 无初值,按类型默认值走 bFlag : BOOL; END_VAR这段声明的逻辑不复杂,坑在两条支路的组合:RETAIN决定存储介质,:= 初值决定初始化时写入什么,两者互相独立。带初值的保持变量,在“不初始化下载”的情况下依然是旧值,这不是 bug,是两条规则叠加的结果。参数上唯一要记的是:初值表达式的类型必须能赋给左边声明的类型,写nTotal : DINT := 1.5这种,不同平台的容忍度不一样,别赌。
2.2 标识符与预留词:命名踩坑最多的地方
标识符规则本身很短,短到很多人不看,然后在联调阶段付出代价。
| 规则 | 具体要求 | 反例 |
|---|---|---|
| 首字符 | 字母或下划线 | 4ter |
| 后续字符 | 字母、数字、下划线 | S value(含空格) |
| 连续下划线 | 不允许 | RR__20 |
| 大小写 | 不区分,Anna与AnNa是同一个 | —— |
| 预留词 | 不能拿来命名变量或类型 | ARRAY、IF、CASE |
| 前缀 | 尽量避免以下划线、struct、enum、command 开头 | _MyVar |
ARRAY这个例子最能说明问题:它字符合法、首字符合规、没有连续下划线,作为标识符完全成立,但它是预留词,所以依然不能用。规则表和预留词表要一起看,只看前一张会漏。
// 合法:首字符字母或单下划线,后续用单下划线分隔 VAR nAxisCount : INT; _bEnable : BOOL; // 首字符下划线,后面必须跟字母或数字 rPos_1 : REAL; END_VAR // 编译期就会拦下的写法,记个印象 // 4ter -> 首字符是数字 // RR__20 -> 连续两个下划线 // S value -> 含空格 // ARRAY -> 预留词,不能当变量名下划线开头这条,我一般建议直接禁掉。它在语法上合法,但在装技术包的时候容易出事:技术包里的命令词、参数名、数据类型也可能带这类前缀,两边一撞,报错出现在下载阶段而不是编译阶段,改起来要顺着引用一个个摸。命名规范里写死“首字符用字母、禁止双下划线”,比事后排查便宜得多。
2.3 数字字面量、布尔值与字符串:编译期就会拦下的那批错误
数字这块的错误基本都是硬错误,编译器直接拦,反而是最好处理的。
| 字面量 | 合法写法 | 非法写法与原因 |
|---|---|---|
| 整数 | 0、+1、-1743、60_000 | 123,456(逗号)、36.(小数点) |
| 二进制 | 2#1111 | 写成1111会被当成十进制 |
| 八进制 | 8#17 | 同上 |
| 十六进制 | 16#F | 同上 |
| 浮点 | 0.0、1.3、-0.2、60_000.15 | 中间夹空格 |
| 布尔 | TRUE/FALSE | 拿来和整数直接比大小 |
| 字符串 | 'Axis_1' | 用双引号 |
VAR nDec : INT := 15; nBin : INT := 2#1111; // 同一个值 15 nOct : INT := 8#17; // 15 nHex : INT := 16#F; // 15 rBig : REAL := 60_000.15; // 下划线只做视觉分隔,不参与数值 sName : STRING := 'Axis_1'; // 字符串用单引号 bOn : BOOL := TRUE; END_VAR四种进制写法编译完没有任何区别,差别只在调试器里看变量时的显示形式;下划线是纯视觉分隔,不改变数值,也别指望用它绕开长度限制。真正会被忽略的是符号位:手册里明确写了负号只能出现在有符号类型之前,把-1塞进一个无符号类型,有些平台直接报错,有些平台给你回绕成一个大正数,后者在 watch 表里看就是一个莫名其妙的巨大值——这类现象十有八九先回去查声明,不用怀疑算法。
把这三节当成索引:编译报错先定位到字符组、标识符、字面量,运行时的怪值再去翻数据类型和变量作用域。顺着这个顺序查,比从第一章读起快得多。
3. 数据类型与变量声明:范围、派生和初始化时机
ST 的类型系统不算复杂,但“声明在哪”和“初始化为多少”这两件事,是运行时问题的高发区。手册把基本类型、派生类型、变量声明、初始化时机分成四节,其实是一条线:类型决定取值范围,声明位置决定生命周期,存储域决定掉电之后还在不在,初始化时机决定你下载完看到的是 0 还是旧值。这条线上的任何一环没搞清楚,调试就会变成猜测。
3.1 基本类型与取值范围:SINT 里塞 200 会怎样
| 类型 | 位宽 | 取值范围 | 常见误用 |
|---|---|---|---|
| BOOL | 1 | TRUE / FALSE | 和位串类型混着做位操作 |
| SINT | 8 | -128 ~ 127 | 当计数器用,跑过 127 之后读数跳负 |
| INT | 16 | -32768 ~ 32767 | 存毫秒计时,几十秒就见底 |
| DINT | 32 | 约 ±21 亿 | 存累计脉冲,勉强够 |
| USINT | 8 | 0 ~ 255 | 做减法,结果为负直接回绕 |
| UINT | 16 | 0 ~ 65535 | 同上 |
| UDINT | 32 | 0 ~ 约 42 亿 | 同上 |
| REAL | 32 | 单精度,约 7 位有效数字 | 做累加求和,误差越滚越大 |
| LREAL | 64 | 双精度 | 大量运算时扫描周期变长 |
| CHAR / STRING | —— | 字符 / 字符串 | 用=做内容比较,实际比的是引用 |
SINT做计数器是最典型的省空间操作,跑到 127 加一之后回绕成负数,watch 表里看就是一个负值,不报任何异常,曲线掉到负轴才发现。这类问题的排查成本全在“发现”上,一旦怀疑到类型,看一眼声明就解决了。反过来,如果一开始就把计数类变量统一声明成DINT,代价只是多几个字节,省下的是半夜排查的时间。
浮点这边的问题更隐蔽。REAL只有约 7 位有效数字,做位置累加时,每次加一个很小的增量,加到一定量级之后增量直接被吞掉,位置就停住不动了。这种“程序在跑但值不变”的现象,第一反应往往是逻辑写错,实际是精度耗尽。累加类的量,要么用LREAL,要么改成绝对量计算,别在REAL上做长时间累加。
3.2 ARRAY、STRUCT 与枚举:派生类型的声明骨架
派生类型的语法结构很固定,一张声明块能把三种都覆盖。
TYPE E_State : (IDLE, HOMING, RUNNING, FAULT); // 枚举,底层按整数存 ST_AxisData : STRUCT nState : E_State; rPos : REAL; rVel : REAL; bReady : BOOL; END_STRUCT; A_JointPos : ARRAY[1..6] OF REAL; // 一维数组,下标从 1 起 END_TYPE这段声明里,枚举的底层是按整数存储的,成员顺序就是 0、1、2、3。危险点在于“顺序会变”:后来有人往中间插了一个新状态,序号整体后移,此时如果旧程序里的常量值、HMI 上配的状态号、历史记录里的值还按老顺序理解,设备行为就会错位,而且编译阶段完全看不出来。稳妥做法是给枚举成员显式指定值,或者干脆别把枚举的整数值往外部暴露。
ARRAY的下标上下界都要写死,不要指望运行时越界检查——很多平台在数组越界时不一定报错,可能读到相邻内存,值看起来“有”,但毫无意义。声明成[1..6]之后,循环里就老老实实写FOR i := 1 TO 6,别混用 0 基写法。
STRUCT的赋值要注意整体赋值和成员赋值是两回事。整体赋值在多数平台上是逐成员拷贝,成员多的时候会占扫描周期;成员赋值更可控。如果结构里含数组,整体拷贝的开销还要再乘。
3.3 变量声明的位置决定生命周期
这一段是手册里信息密度最高的部分之一,也是最值得做成表贴在工位上的。
| 声明区 | 作用域 | 存储特性 | 典型用途 |
|---|---|---|---|
| VAR | POU 内 | FC 中为临时,FB 中为静态 | 中间量、状态 |
| VAR_INPUT | POU 内只读 | 调用时传入 | 命令、设定值 |
| VAR_OUTPUT | POU 内可写 | 调用后可读 | 状态、反馈 |
| VAR_IN_OUT | 双向读写 | 传引用 | 大结构、数组 |
| VAR_TEMP | 仅本次调用有效 | 每次调用重新初始化 | 循环变量、临时计算 |
| VAR_STAT | 跨周期保持 | 掉电不保持 | 内部状态机 |
| VAR_GLOBAL | 全局 | 分保持与非保持 | 跨 POU 共享 |
最容易混淆的是VAR在 FC 和 FB 里的语义差异:同一个关键字,在 FC 里调用结束就失效,在 FB 里却跨周期保持。很多人从 FC 迁到 FB 时,把一堆中间变量留在VAR段,以为是临时的,结果这些值跨周期残留,上一轮的计算结果污染下一轮,现象是“偶发算错”,复现全看时序。
VAR_GLOBAL RETAIN nTotalCycles : DINT; // 掉电保持 END_VAR VAR_GLOBAL rCurrentTemp : REAL; // 非保持,重新上电回初值 END_VAR保持与非保持的差别只在存储介质上,和“有没有写初值”是两码事。带初值的保持变量,在“不初始化下载”时依然是旧值;不带初值的非保持变量,上电后是类型默认值。手册里专门用一节讲初始化时机,包括保留全局变量、非保留全局变量、本地变量、静态变量、FB 实例、技术目标系统变量各自的初始化时刻——这些时刻不是同一个点,下载动作、启动类型、是否保持都会影响结果。联调阶段“我明明改了初值”的困惑,九成出在这里。
提示:改完初值下载前,先确认这个变量在不在保持区,以及下载选项里初始化是怎么勾的。这两步花十秒,能省掉半小时的自我怀疑。
4. 表达式到功能块:优先级、类型转换和 FC/FB 选型
语法写对只是第一步,算错和用错封装才是真正拖时间的地方。表达式这块的错误有个共同特征:不报错、结果偏、排查时盯着逻辑看半天,最后发现是少了一对括号或者一次隐式转换。功能与功能块这块的错误则是结构性的:用 FC 的地方用了 FB,或者反过来,程序能跑,但状态残留或者实例冲突。
4.1 运算符优先级:一对括号省三天
| 优先级 | 运算符 | 说明 |
|---|---|---|
| 高 | () | 括号,唯一能强制改写顺序的手段 |
| ↓ | 函数调用 | 先算参数再调用 |
| ↓ | ** | 幂 |
| ↓ | 负号、NOT | 单目运算符 |
| ↓ | */MOD | 乘除取模 |
| ↓ | +- | 加减 |
| ↓ | <><=>= | 关系比较 |
| ↓ | =<> | 相等比较 |
| ↓ | AND | 逻辑与 |
| ↓ | XOR | 逻辑异或 |
| 低 | OR | 逻辑或 |
// 想算 (a + b) * c,少写括号就变成 a + b * c rRes1 := rA + rB * rC; // 先乘后加 rRes2 := (rA + rB) * rC; // 括号改写优先级 // 逻辑表达式里 AND 结合得比 OR 紧 bOK := bA AND bB OR bC; // 等价于 (bA AND bB) OR bC优先级表不用背,但要知道“逻辑与比逻辑或紧”这一条,因为它和自然语言的直觉相反。写条件判断时,只要出现两种以上逻辑运算符,就加括号,读代码的人不用回头查表,改代码的人也不会改错结合顺序。
求值顺序是另一个隐性坑。表达式一般从左往右求值,如果中间调用了带副作用的函数(比如函数内部改了全局量),顺序就影响结果。这种写法本身就该避免,函数尽量做成纯函数:同样的输入给同样的输出,不碰外部状态。真需要副作用,就老老实实拆成几条独立语句,别塞在一个表达式里。
4.2 赋值与类型转换:隐式转换的边界在哪
赋值的左边是变量,右边是表达式,类型不一致时就会发生转换。手册把转换分成隐式和显式两类,隐式的那部分是最需要盯的。
| 转换方向 | 是否安全 | 说明 |
|---|---|---|
| INT → DINT | 安全 | 位宽变大,不丢信息 |
| DINT → INT | 危险 | 超出范围会截断 |
| 整型 → REAL | 基本安全 | 数值很大时有精度损失 |
| REAL → INT | 危险 | 小数部分被丢弃,不是四舍五入 |
| 位串 ↔ 整型 | 看平台 | 字节序处理不一致 |
| 无符号 → 有符号 | 危险 | 高位被解释成符号位 |
VAR nInt : INT; nDint : DINT; rReal : REAL; END_VAR nInt := 300; nDint := nInt; // 隐式,INT -> DINT,安全 rReal := nInt; // 隐式,整型转浮点 nInt := rReal; // 隐式,浮点转整型,小数被丢掉,不报错 nInt := REAL_TO_INT(rReal); // 显式,意图明确,排查时好搜显式转换函数的命名规则一般是“源类型_TO_目标类型”,具体函数名各平台有差异,但套路一致。两种写法编译结果可能完全一样,差别在可维护性:代码里出现REAL_TO_INT,任何人一眼就知道这里有一次有损转换,可以重点关注;写成隐式,这段转换在代码里是隐形的,review 的时候直接滑过去。
REAL转INT是截断不是四舍五入,2.9得到2,-2.9得到-2。需要四舍五入就自己加 0.5 再用平台提供的取整函数,别指望隐式转换帮你做。这类偏差在位置控制里会累积,单次看不出来,跑一天就是可见的偏移。
4.3 控制语句选型:IF、CASE、FOR、WHILE、REPEAT 各自的边界
| 语句 | 什么时候用 | 退出方式 | 注意点 |
|---|---|---|---|
| IF | 分支少、条件不规整 | —— | 嵌套超过三层就考虑拆成 FB |
| CASE | 状态机、离散枚举 | —— | 必须有 ELSE 兜底 |
| FOR | 次数确定的循环 | 计数到界 / EXIT | 循环变量别在循环体里改 |
| WHILE | 次数不定、先判断后执行 | 条件不成立 / EXIT | 条件变量要在循环体内更新 |
| REPEAT | 至少执行一次 | UNTIL 条件成立 / EXIT | 同上,注意死循环 |
| EXIT | 提前跳出循环 | —— | 只跳出一层 |
| RETURN | 提前结束 POU | —— | FB 里提前返回会跳过后面的输出赋值 |
CASE nState OF 0: // IDLE bReady := TRUE; 10: // HOMING IF bHomeDone THEN nState := 20; END_IF; 20: // RUNNING FOR i := 1 TO 6 DO rJoint[i] := rJoint[i] + rDelta[i]; IF rJoint[i] > rLimit THEN nState := 90; // 越限,跳故障 EXIT; // 只跳出 FOR,不会跳出 CASE END_IF; END_FOR; ELSE nState := 90; // 兜底,防止状态值错位后整段逻辑静默不执行 END_CASE;EXIT只跳一层这件事,第一次遇到基本都会判断错。嵌套两层循环时,内层EXIT之后外层继续跑,现象是“明明已经退出了还在动”。要么用标志位加外层判断,要么把内层抽成一个 FC,用RETURN直接结束。
CASE的ELSE不是可选装饰。状态值可能来自 HMI 写入、旧版本下载残留、枚举顺序变更,任何一个来源给出一个不在分支里的整数,没有ELSE就是整段逻辑跳过,设备停在那儿,没有任何报错。这种“静默不执行”比崩溃难查十倍。
4.4 FC 与 FB:什么时候要实例,什么时候只要结果
| 维度 | FC(功能) | FB(功能块) |
|---|---|---|
| 是否有实例 | 无 | 有,每个调用点一个实例 |
| 内部变量 | 调用结束失效 | 静态变量跨周期保持 |
| 输出能否保持 | 不能 | 能 |
| 调用方式 | 直接写名字 | 实例名加参数列表 |
| 典型用途 | 数学计算、单位换算 | 轴控制、状态机、通信 |
FUNCTION FC_Scale : REAL VAR_INPUT rRaw : REAL; rGain : REAL; rOffset : REAL; END_VAR FC_Scale := rRaw * rGain + rOffset; END_FUNCTION FUNCTION_BLOCK FB_Axis VAR_INPUT bEnable : BOOL; END_VAR VAR_OUTPUT bReady : BOOL; END_VAR VAR nState : INT; // 静态,跨周期保持 END_VAR // 状态机逻辑写在这里 END_FUNCTION_BLOCK判断标准很直接:这段逻辑有没有“上一次调用留下的状态”。有,就必须用 FB,因为只有 FB 的实例能跨周期保存变量;没有,用 FC 更轻,不占实例内存,调用开销也小。反过来,把带状态的逻辑写成 FC,靠全局变量存状态,程序能跑,但同一个 FC 被两个地方调用时状态就串了,而且串得毫无规律。
参数传递方向也要留意:VAR_INPUT在块内只读,VAR_OUTPUT由块内写、块外读,VAR_IN_OUT双向。FB 的输出参数在块外访问有时机要求——调用之后才有值,调用之前读到的是上一周期的残留。第一次调用时读到的是什么,取决于实例的初始化规则,不能想当然。
5. 避坑清单:五类翻车现场和它们的排查路径
这些坑的共同点是:编译能过,下载能跑,问题在运行时才暴露,而且现象和原因隔得比较远。每一条我都按“看到什么、为什么、怎么改”写,照着对一遍,能省掉不少猜测。
5.1 五条踩坑记录
第一条:改了初值,下载后还是旧值。现象是声明里写nCount : INT := 5,编译下载,watch 表里显示 0 或者上次运行的值。原因有两个层面:变量在保持区,下载时默认不覆盖;或者下载选项里的初始化方式没选对,平台按“保持”策略处理了。解决办法是先确认声明区是保持还是非保持,再确认下载时的初始化选项,最后才怀疑初值表达式本身。如果这个变量本来就需要一个确定的起点,别依赖初值,在启动逻辑里显式赋值,这样行为是代码写的,不是选项决定的。
第二条:浮点转整型,结果总比预期小 1。现象是rReal等于 2.9,赋给INT之后得到 2,日志里看就是差一。原因是隐式转换走的是截断,直接砍掉小数部分,不是四舍五入。解决办法是明确转换意图:需要四舍五入就加 0.5 再取整,需要向下取整就保持现状但写显式函数,让别人看得懂。关键是别让隐式转换散落在业务代码里,改的时候找不到。
第三条:CASE 少写 ELSE,状态机停住不动。现象是设备停在某个状态,没有任何报警,手动切状态也没反应。原因是当前状态值不在任何分支里——可能是 HMI 写了个越界值,也可能是枚举中间插了成员导致旧值含义变了。没有ELSE,整段CASE直接跳过,程序继续跑,但这个 POU 什么也没做。解决办法是CASE必写ELSE,兜底分支里置故障位,同时把原始状态值记下来,下次再出现就能直接看到是哪个值惹的祸。
第四条:双下划线命名,编译过了,装技术包失败。现象是项目自己编译一切正常,装技术包或者做集成的时候报标识符冲突。原因是命名里有连续下划线,或者以下划线开头,撞上了技术包内部的命令词和类型名。这类冲突暴露得晚,改起来要顺着引用一个个改。解决办法是在命名规范层面直接禁掉,首字符用字母,禁止连续下划线,加一条静态检查比事后排查划算。
第五条:直接访问 I/O 和过程映像混用,读数慢一拍。现象是程序里读某个输入点,值和实际状态对不上,差一个扫描周期。原因是过程映像在循环任务开始时统一刷新,直接地址访问则是即时读,两者混在一个 POU 里,读到的就不是同一个时刻的值。解决办法是先明确这个点用哪种访问方式,同一个信号只走一条路径;对时序敏感的逻辑统一用过程映像,需要即时性的单独隔离出来,并且写清楚为什么。
5.2 排查顺序:从交叉对照表到断点
工具不少,顺序比工具重要。顺序
本文还有配套的精品资源,点击获取