ST语言外部库函数调用参数传递方式(ByRef/ByVal)混淆修复

发布于 2026-03-17 16:34:40 · 浏览 5 次 · 评论 0 条

在 ST(Structured Text)语言中调用外部库函数时,参数传递方式若被错误理解或配置,会导致变量值意外修改、数据不一致、调试困难甚至系统运行异常。这种问题在 PLC 编程中尤为隐蔽:程序表面逻辑正确,但执行结果随调用次数变化;同一段代码在不同品牌控制器上行为不一;或仅在特定工况下暴露缺陷。根本原因常是开发者将 ByRef(按引用传递)误当作 ByVal(按值传递),或反之——而 ST 标准(IEC 61131-3)本身不显式声明参数传递方式,实际行为完全由目标平台的编译器与外部函数接口定义共同决定。


一、先明确:ST 语言本身没有 ByVal/ByRef 关键字

IEC 61131-3 标准中,ST 语法不提供 byrefbyval 修饰符。所有函数调用均使用统一语法:

Result := ExternalFunc(Var1, Var2, ConstValue);

参数传递语义不由 ST 代码决定,而由以下三者共同绑定

  1. 外部函数的声明原型(通常在 .h 头文件或 .lib 符号表中定义)
  2. PLC 运行时环境对 C/C++ ABI 的映射规则(如 CODESYS、TwinCAT、Siemens SCL 兼容层)
  3. 用户在 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、或位于只读区、或被其他任务同时访问,此调用即触发未定义行为。

修复步骤

  1. 查证外部函数头文件(如 driver.h):
    // driver.h
    void FillBuffer(uint8_t* dst, uint16_t len); // 明确要求 dst 可写且长度匹配
  2. 检查 ST 中变量声明是否支持取址
    VAR_GLOBAL
        g_Buffer : ARRAY[0..255] OF BYTE; // ✅ 可取址(非 CONST,非 TEMP)
    END_VAR
  3. 显式使用 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 所在内存,造成数据破坏。

修复步骤

  1. 确认原型为纯值传递(无 *&):
    uint16_t CalcCRC(uint16_t value, uint8_t poly); // ✅ 两个参数均为值传递
  2. ST 中直接传变量名,禁用 ADR()
    crc := CalcCRC(inputData, 16#1021); // ✅ 传值,安全无副作用
  3. 添加编译期断言防止误用(CODESYS 支持):
    ASSERT NOT IS_POINTER(inputData), 'CalcCRC: inputData must NOT be passed by address';

场景 3:结构体参数的隐式引用陷阱

现象:调用设备初始化函数后,结构体 devCfgtimeoutMs 字段变为 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 侧。

修复步骤

  1. 核对 C 头文件完整声明

    typedef struct {
        uint16_t timeoutMs;
        bool     autoRetry;
    } DeviceConfig_t;
    
    // 正确定义(若函数只读):
    bool InitDevice(const DeviceConfig_t* cfg);
    
    // 若函数会修改,应为:
    bool InitDevice(DeviceConfig_t* cfg);
  2. 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 可能被修改
  3. 防御性编程:调用前后快照关键字段(调试期):

    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))yINT 是否支持 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 BYTESIZEOF 匹配 合法,但需确保 C 函数接收 int* ⚠️ IN_OUT 仅作用于 SCL 函数块,对外部 DLL 无效 使用 sizeof()TYPEOF() 在 SCL 中做编译期校验

✅ 统一建议:永远显式使用 ADR() 传地址,永远直接传变量名传值;拒绝任何“默认行为”假设。


五、终极修复工具链:四步自动化验证法

避免人工比对出错,建立可重复的验证流程:

  1. 头文件解析脚本(Python)
    解析 driver.h,提取所有外部函数签名,生成 JSON 接口描述:

    {
      "FillBuffer": {
        "return": "void",
        "params": [
          {"name": "dst", "type": "uint8_t*", "passing": "ByRef"},
          {"name": "len", "type": "uint16_t", "passing": "ByVal"}
        ]
      }
    }
  2. ST 代码静态扫描(正则 + AST)
    检查所有外部调用点,匹配 ADR(...) 出现位置与参数类型:

    • 若参数声明为 uint8_t* 但调用无 ADR() → 报警;
    • 若参数声明为 uint16_t 但调用含 ADR() → 报警。
  3. 运行时地址监控(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),验证是否一致。

  4. 单元测试用例模板(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

六、预防性规范(团队落地版)

将修复转化为日常开发纪律:

  • 命名约定(强制):

    • 所有需传地址的变量后缀 _Refg_Buffer_Ref : ARRAY[...] OF BYTE;
    • 所有值参数变量禁止 _Ref 后缀;
    • 外部函数名前缀 EXT_EXT_FillBuffer(...)
  • 代码审查清单(PR 时必查):

    • ✅ 每个外部函数调用旁标注 // ByRef: dst// ByVal: len
    • ADR() 仅出现在参数类型含 *[] 的位置;
    • ✅ 无 ADR() 出现在 INTREALBOOL 类型变量上;
    • ✅ 所有数组传参使用 ADR(array[0]),禁用 ADR(array)(部分平台不支持)。
  • CI/CD 集成检查

    • 使用 codesys-check 或自定义脚本,在构建时扫描 .st 文件,阻断高危模式:
      • 匹配 ExternalFunc\([^)]*ADR\([^)]*\)[^)]*\) → 允许(ByRef);
      • 匹配 ExternalFunc\([^)]*INT[^)]*ADR\([^)]*\)[^)]*\) → 拒绝(ByVal 参数用 ADR)。

ByRef 与 ByVal 的混淆不是语法漏洞,而是接口契约断裂。修复的核心不是记忆规则,而是建立声明—实现—调用三方对齐的工程习惯。每一次 ADR() 的敲入,都应伴随一次对头文件的确认;每一次值参数的传递,都应默念“此值不可被函数篡改”。自动化验证不是替代思考,而是把思考固化为肌肉记忆。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文