在 ST(Structured Text)语言中调用外部库函数时,参数传递方式若被错误理解或配置,会导致变量值意外修改、数据不一致、调试困难甚至系统运行异常。这种问题在 PLC 编程中尤为隐蔽:程序表面逻辑正确,但执行结果随调用次数变化;同一段代码在不同品牌控制器上行为不一;或仅在特定工况下暴露缺陷。根本原因常是开发者将 ByRef(按引用传递)误当作 ByVal(按值传递),或反之——而 ST 标准(IEC 61131-3)本身不显式声明参数传递方式,实际行为完全由目标平台的编译器与外部函数接口定义共同决定。
一、先明确:ST 语言本身没有 ByVal/ByRef 关键字
IEC 61131-3 标准中,ST 语法不提供 byref 或 byval 修饰符。所有函数调用均使用统一语法:
Result := ExternalFunc(Var1, Var2, ConstValue);
参数传递语义不由 ST 代码决定,而由以下三者共同绑定:
- 外部函数的声明原型(通常在
.h头文件或.lib符号表中定义) - PLC 运行时环境对 C/C++ ABI 的映射规则(如 CODESYS、TwinCAT、Siemens SCL 兼容层)
- 用户在 ST 中声明的变量类型与内存布局是否匹配该原型
因此,“混淆”并非语法错误,而是声明—实现不匹配导致的运行时语义错位。
二、核心区别:ByVal 与 ByRef 在内存层面的真实表现
| 特征 | ByVal(按值传递) | ByRef(按引用传递) |
|---|---|---|
| 本质 | 将实参的值副本压入函数栈帧 | 将实参的内存地址传入函数 |
| 函数内修改影响 | 修改形参不影响原始变量 | 修改形参所指内容直接改变原始变量 |
| 典型 C 原型 | int calc(int a, float b) |
void update(int* pCounter, char* buffer) |
| ST 中对应声明 | VAR 变量直接传入(如 myInt) |
ADR(myInt) 或 ADR(myArray[0]) 显式取地址 |
| 风险点 | 无副作用,安全但大结构体效率低 | 高效但易引发未预期写入(如越界、多线程冲突) |
✅ 正确实践原则:外部函数怎么声明,ST 就怎么调用;不猜,不试,只对齐。
三、典型混淆场景与修复步骤(手把手)
场景 1:误将指针函数当值函数调用(ByRef 当作 ByVal)
现象:调用一个用于“填充数组”的外部函数后,全局数组 g_Buffer 每次调用都累加写入,而非重置清零。
错误代码:
// 假设外部函数原型为:void FillBuffer(uint8_t* dst, uint16_t len)
// 错误:直接传数组名(ST 中数组名 = 首元素地址,但此处意图不清)
FillBuffer(g_Buffer, 256); // ❌ 编译通过,但语义错误(若 g_Buffer 是 ARRAY[0..255] OF BYTE)
问题定位:
g_Buffer在 ST 中是数组变量,直接传入等价于ADR(g_Buffer[0])—— 这本身符合 C 的uint8_t*;- 但若该函数设计为“每次从头填充”,而实际运行中
g_Buffer内容持续被覆盖,说明函数内部可能依赖传入地址的可写性与稳定性; - 真正风险在于:若
g_Buffer被声明为CONST、或位于只读区、或被其他任务同时访问,此调用即触发未定义行为。
修复步骤:
- 查证外部函数头文件(如
driver.h):// driver.h void FillBuffer(uint8_t* dst, uint16_t len); // 明确要求 dst 可写且长度匹配 - 检查 ST 中变量声明是否支持取址:
VAR_GLOBAL g_Buffer : ARRAY[0..255] OF BYTE; // ✅ 可取址(非 CONST,非 TEMP) END_VAR - 显式使用
ADR()并添加长度校验注释(强化意图):// ✅ 显式传达“传地址”意图,杜绝歧义 FillBuffer(ADR(g_Buffer[0]), 256); // 注:必须确保 256 ≤ SIZEOF(g_Buffer) → 实际应写为 SIZEOF(g_Buffer)/SIZEOF(BYTE)
场景 2:误将值函数当指针函数调用(ByVal 当作 ByRef)
现象:调用一个“计算 CRC”的纯函数,返回值始终为 0,且输入变量 inputData 值被意外清零。
错误代码:
// 假设外部函数原型为:uint16_t CalcCRC(uint16_t value, uint8_t poly)
// 错误:对简单整数使用 ADR()
crc := CalcCRC(ADR(inputData), 0x1021); // ❌ 传入地址值(如 0x20001234),而非 inputData 的值
问题定位:
ADR(inputData)返回的是inputData变量在内存中的地址(例如16#20001234),作为uint16_t传入,高位被截断 → 实际传入16#1234;- 函数将其当作原始数据计算 CRC,结果无意义;
- 更严重的是:若函数内部有类似
*value ^= 0xFF的操作,则会直接改写inputData所在内存,造成数据破坏。
修复步骤:
- 确认原型为纯值传递(无
*或&):uint16_t CalcCRC(uint16_t value, uint8_t poly); // ✅ 两个参数均为值传递 - ST 中直接传变量名,禁用
ADR():crc := CalcCRC(inputData, 16#1021); // ✅ 传值,安全无副作用 - 添加编译期断言防止误用(CODESYS 支持):
ASSERT NOT IS_POINTER(inputData), 'CalcCRC: inputData must NOT be passed by address';
场景 3:结构体参数的隐式引用陷阱
现象:调用设备初始化函数后,结构体 devCfg 的 timeoutMs 字段变为 0,但函数文档声称“仅读取配置”。
错误代码:
// 假设 C 原型:bool InitDevice(DeviceConfig_t* cfg)
InitDevice(devCfg); // ❌ ST 中结构体变量名不自动转为地址!此调用非法(多数平台报错)
// 实际常见变通写法(错误):
InitDevice(ADR(devCfg)); // ✅ 语法通过,但若函数声明为 const DeviceConfig_t*,则违反只读约定
关键事实:
- ST 中结构体变量名 ≠ 其地址(与 C 不同);
ADR(devCfg)是合法的,但必须与 C 原型中的const DeviceConfig_t*或DeviceConfig_t*严格匹配;- 若 C 函数声明为
const DeviceConfig_t*,而 ST 传ADR(devCfg),则函数内部不可写,否则链接失败或运行时报错; - 若函数实际写了
cfg->timeoutMs = 1000,而声明为const,则属于接口定义污染,必须修正 C 侧。
修复步骤:
-
核对 C 头文件完整声明:
typedef struct { uint16_t timeoutMs; bool autoRetry; } DeviceConfig_t; // 正确定义(若函数只读): bool InitDevice(const DeviceConfig_t* cfg); // 若函数会修改,应为: bool InitDevice(DeviceConfig_t* cfg); -
ST 中声明匹配的变量与调用:
TYPE DeviceConfig_t : STRUCT timeoutMs : UINT; autoRetry : BOOL; END_STRUCT END_TYPE VAR devCfg : DeviceConfig_t; END_VAR // 若 C 声明为 const DeviceConfig_t* → ST 必须传地址,且确保 cfg 不被函数修改: InitDevice(ADR(devCfg)); // ✅ 合法,且语义清晰 // 若 C 声明为 DeviceConfig_t* → 同样传 ADR,但需接受 devCfg 可能被修改 -
防御性编程:调用前后快照关键字段(调试期):
oldTimeout := devCfg.timeoutMs; InitDevice(ADR(devCfg)); IF devCfg.timeoutMs <> oldTimeout THEN // 记录日志:函数违反只读承诺 LogWarning('InitDevice modified timeoutMs'); END_IF
四、跨平台一致性保障:三大厂商实践对照
不同 PLC 平台对 ST 外部调用的 ABI 映射存在细节差异。下表总结关键行为:
| 平台 | ExternalFunc(x) 中 x 为数组时 |
ExternalFunc(ADR(y)) 中 y 为 INT 时 |
是否支持 VAR_IN_OUT 模拟 ByRef |
推荐校验方式 |
|---|---|---|---|---|
| CODESYS (3.5+) | 自动转为 &x[0](等价 ADR(x[0])) |
传入 y 的地址值(4 字节) |
✅ 支持 VAR_IN_OUT 用于 ST 内部函数,对外部函数无效 |
使用 __builtin_types_compatible_p() 宏 + 静态断言 |
| TwinCAT 3 (Beckhoff) | 必须显式 ADR(x[0]),否则编译失败 |
合法,传地址 | ❌ 外部函数不识别 IN_OUT,仅靠 ADR() |
在 TcSmBuild 中启用 “Check C interface compatibility” |
| Siemens TIA Portal (SCL/ST 混合) | 数组名即地址(C 风格),但需声明 ARRAY[...] OF BYTE 且 SIZEOF 匹配 |
合法,但需确保 C 函数接收 int* |
⚠️ IN_OUT 仅作用于 SCL 函数块,对外部 DLL 无效 |
使用 sizeof() 和 TYPEOF() 在 SCL 中做编译期校验 |
✅ 统一建议:永远显式使用
ADR()传地址,永远直接传变量名传值;拒绝任何“默认行为”假设。
五、终极修复工具链:四步自动化验证法
避免人工比对出错,建立可重复的验证流程:
-
头文件解析脚本(Python)
解析driver.h,提取所有外部函数签名,生成 JSON 接口描述:{ "FillBuffer": { "return": "void", "params": [ {"name": "dst", "type": "uint8_t*", "passing": "ByRef"}, {"name": "len", "type": "uint16_t", "passing": "ByVal"} ] } } -
ST 代码静态扫描(正则 + AST)
检查所有外部调用点,匹配ADR(...)出现位置与参数类型:- 若参数声明为
uint8_t*但调用无ADR()→ 报警; - 若参数声明为
uint16_t但调用含ADR()→ 报警。
- 若参数声明为
-
运行时地址监控(PLC 调试模式)
在外部函数入口处插入日志:void FillBuffer(uint8_t* dst, uint16_t len) { LOG("FillBuffer called with dst=0x%08X, len=%u", (unsigned int)dst, len); // ... }对照 ST 中
ADR(g_Buffer[0])计算出的地址(如ADR(g_Buffer[0])=16#20001000),验证是否一致。 -
单元测试用例模板(ST)
为每个外部函数编写隔离测试:PROGRAM Test_FillBuffer VAR testBuf : ARRAY[0..7] OF BYTE := [1,2,3,4,5,6,7,8]; expected : ARRAY[0..7] OF BYTE := [0,0,0,0,0,0,0,0]; END_VAR FillBuffer(ADR(testBuf[0]), 8); // 执行 FOR i := 0 TO 7 DO ASSERT testBuf[i] = expected[i], 'FillBuffer failed at index ' + INT_TO_STRING(i); END_FOR
六、预防性规范(团队落地版)
将修复转化为日常开发纪律:
-
命名约定(强制):
- 所有需传地址的变量后缀
_Ref:g_Buffer_Ref : ARRAY[...] OF BYTE; - 所有值参数变量禁止
_Ref后缀; - 外部函数名前缀
EXT_:EXT_FillBuffer(...)
- 所有需传地址的变量后缀
-
代码审查清单(PR 时必查):
- ✅ 每个外部函数调用旁标注
// ByRef: dst或// ByVal: len; - ✅
ADR()仅出现在参数类型含*或[]的位置; - ✅ 无
ADR()出现在INT、REAL、BOOL类型变量上; - ✅ 所有数组传参使用
ADR(array[0]),禁用ADR(array)(部分平台不支持)。
- ✅ 每个外部函数调用旁标注
-
CI/CD 集成检查:
- 使用
codesys-check或自定义脚本,在构建时扫描.st文件,阻断高危模式:- 匹配
ExternalFunc\([^)]*ADR\([^)]*\)[^)]*\)→ 允许(ByRef); - 匹配
ExternalFunc\([^)]*INT[^)]*ADR\([^)]*\)[^)]*\)→ 拒绝(ByVal 参数用 ADR)。
- 匹配
- 使用
ByRef 与 ByVal 的混淆不是语法漏洞,而是接口契约断裂。修复的核心不是记忆规则,而是建立声明—实现—调用三方对齐的工程习惯。每一次 ADR() 的敲入,都应伴随一次对头文件的确认;每一次值参数的传递,都应默念“此值不可被函数篡改”。自动化验证不是替代思考,而是把思考固化为肌肉记忆。

暂无评论,快来抢沙发吧!