在工业自动化系统中,使用IEC 61131-3标准的ST(Structured Text)语言编写PLC程序时,结构体(STRUCT)是组织通信数据最常用的方式。当PLC与上位机(如SCADA、MES或HMI)、边缘网关或另一台PLC通过Modbus TCP、S7协议、OPC UA二进制传输或自定义TCP/UDP协议交换数据时,结构体变量常被整体映射为连续字节块进行收发。此时,若ST代码中结构体定义与通信端(尤其是C/C++侧或协议文档)的内存布局不一致,将直接导致字段值错读、符号位异常、整数截断、浮点数解码失败——这类问题隐蔽性强、复现不稳定、调试耗时极长,但根源往往仅在于一个未显式声明的对齐规则。
一、问题本质:ST语言本身不定义结构体内存对齐
IEC 61131-3标准(包括其ST语言规范)完全不规定结构体成员的内存对齐方式。它只定义语法、类型语义和运行时行为,而将内存布局交由具体PLC厂商的编译器实现决定。这意味着:
- 同一份ST代码,在西门子S7-1200(TIA Portal)、倍福TwinCAT、罗克韦尔Logix(Studio 5000)、施耐德Unity Pro、三菱GX Works3上编译后,同一STRUCT的字节偏移可能完全不同;
- 厂商通常默认采用“自然对齐”(natural alignment),即每个成员按自身宽度对齐(BOOL→1字节对齐,INT→2字节对齐,DINT→4字节对齐,REAL→4字节对齐,LREAL→8字节对齐),但是否填充、填充多少、是否允许跨边界紧凑排列,均无统一约束;
- 更关键的是:ST语言语法中没有
#pragma pack、__attribute__((packed))或alignas等控制对齐的关键字,开发者无法在源码中显式声明结构体打包方式。
结果就是:PLC侧STRUCT变量 MyData : MyStruct; 在内存中占据的字节序列,与上位机C语言中 typedef struct { int16_t temp; uint32_t id; float32_t value; } MyStruct; 的实际内存布局,极大概率不一致。
二、典型错位场景还原(以西门子S7-1200 + Modbus TCP为例)
假设需通过Modbus TCP功能块 MB_CLIENT 向上位机发送以下状态数据:
TYPE MyStatus : STRUCT
RunFlag : BOOL; // 期望占1字节
Mode : BYTE; // 期望占1字节
Setpoint : INT; // 期望占2字节(-32768 ~ 32767)
Actual : REAL; // 期望占4字节(IEEE 754单精度)
Counter : DINT; // 期望占4字节(-2147483648 ~ 2147483647)
END_STRUCT
2.1 PLC侧实际内存布局(TIA Portal v18,默认设置)
TIA Portal 编译器对STRUCT采用“按最大成员对齐”策略,并在成员间插入填充字节以满足自然对齐要求:
| 成员 | 类型 | 大小 | 偏移(字节) | 说明 |
|---|---|---|---|---|
| RunFlag | BOOL | 1 | 0 | 起始位置 |
| Mode | BYTE | 1 | 1 | 紧跟其后,无填充 |
| —— | —— | —— | 2–3 | 插入2字节填充(因下一个INT需2字节对齐,当前偏移=2已满足) |
| Setpoint | INT | 2 | 4 | 对齐到地址4(2字节边界) |
| —— | —— | —— | 6–7 | 插入2字节填充(因下一个REAL需4字节对齐,当前偏移=6不满足,跳至8) |
| Actual | REAL | 4 | 8 | 对齐到地址8(4字节边界) |
| —— | —— | —— | 12–15 | 插入4字节填充(因下一个DINT需4字节对齐,当前偏移=12已满足) |
| Counter | DINT | 4 | 16 | 对齐到地址16(4字节边界) |
→ 总大小 = 20 字节
→ 字节序列(十六进制,按地址0→19):
[RunFlag][Mode][xx][xx][Setpoint_L][Setpoint_H][xx][xx][Actual_bytes...][Counter_bytes...]
2.2 上位机C结构体(未加packed)
#pragma pack(push, 1)
typedef struct {
uint8_t RunFlag;
uint8_t Mode;
int16_t Setpoint;
float Actual;
int32_t Counter;
} MyStatus;
#pragma pack(pop)
若开发者忘记#pragma pack(1),使用默认对齐(GCC/MSVC通常为4或8),则C结构体布局为:
| 成员 | 大小 | 偏移 | 填充说明 |
|---|---|---|---|
| RunFlag | 1 | 0 | |
| Mode | 1 | 1 | |
| —— | — | 2–3 | 2字节填充(int16_t需2字节对齐,当前偏移=2已满足) |
| Setpoint | 2 | 4 | |
| —— | — | 6–7 | 2字节填充(float需4字节对齐,当前偏移=6→跳至8) |
| Actual | 4 | 8 | |
| —— | — | 12–15 | 4字节填充(int32_t需4字节对齐,当前偏移=12已满足) |
| Counter | 4 | 16 |
→ 总大小 = 20 字节 → 表面一致
⚠️ 但注意:此C布局与TIA Portal布局仅在“总长度相同”时巧合重合;一旦成员顺序变更、新增字段或类型替换(如将REAL换为LREAL),填充位置立即失配。
更危险的是:某些PLC平台(如部分国产PLC)采用紧凑布局(packed),即不插入任何填充:
| 成员 | 大小 | 偏移 |
|---|---|---|
| RunFlag | 1 | 0 |
| Mode | 1 | 1 |
| Setpoint | 2 | 2 |
| Actual | 4 | 4 |
| Counter | 4 | 8 |
→ 总大小 = 12 字节
此时若上位机仍用20字节接收,Actual会读到Setpoint低字节+Mode+RunFlag拼凑的4字节垃圾值,Counter直接越界——通信数据全盘错位。
三、验证方法:不依赖设备,纯软件级定位
无需连接PLC硬件,即可100%复现并确认错位:
3.1 在PLC中导出结构体原始字节流
在TIA Portal中,创建一个BYTE数组,长度覆盖STRUCT总字节大小,再用MOVE指令将其与STRUCT首地址绑定:
VAR
stData : MyStatus;
abRaw : ARRAY[0..19] OF BYTE; // 长度=20(按TIA实际布局)
pSt : POINTER TO MyStatus;
END_VAR
pSt := ADR(stData);
MOVE(IN:=pSt, OUT:=abRaw, LEN:=SIZEOF(abRaw));
在线监控abRaw数组各元素值,即可看到PLC侧真实字节序列。
3.2 在Python中模拟两种布局解包
import struct
# 假设收到20字节 raw_data = b'\x01\x02\x00\x00\x0a\x00\x00\x00\x40\x48\xf5\xc3\x00\x00\x00\x00\x00\x00\x00\x00'
# 情况1:按TIA Portal实际布局(20字节,含填充)
# 解析:偏移0:BOOL, 1:BYTE, 4:INT, 8:REAL, 16:DINT
vals_tia = struct.unpack('< B B x x h x x f i', raw_data)
# 返回: (1, 2, 10, -3.1415927, 0)
# 情况2:按紧凑布局(12字节)
raw_compact = raw_data[:12] # 取前12字节
vals_packed = struct.unpack('< B B h f i', raw_compact)
# 返回: (1, 2, 256, 2.369e-38, 16777216) ← 明显错误
对比两组输出,数值合理性即暴露布局差异。
四、根治方案:四步强制对齐统一
4.1 步骤1:确定PLC平台实际布局(唯一可信源)
- 查阅厂商手册中“数据类型内存映射”章节(如《TIA Portal Programming Manual》第7.3节);
- 或直接用上述
MOVE+BYTE数组法实测; - 记录每个成员的精确字节偏移(Offset)和大小(Size),形成表格:
| 成员 | 类型 | Size | Offset | 是否填充 |
|---|---|---|---|---|
| RunFlag | BOOL | 1 | 0 | 否 |
| Mode | BYTE | 1 | 1 | 否 |
| Setpoint | INT | 2 | 4 | 是(+2) |
| Actual | REAL | 4 | 8 | 是(+4) |
| Counter | DINT | 4 | 16 | 是(+4) |
→ 结论:该平台STRUCT总大小=20,有效数据仅占12字节,8字节为填充。
4.2 步骤2:上位机侧严格匹配PLC布局
禁用编译器自动对齐,按实测偏移手写结构体(C示例):
#include <stdint.h>
#pragma pack(push, 1)
typedef struct {
uint8_t RunFlag; // offset 0
uint8_t Mode; // offset 1
uint8_t _pad1[2]; // offset 2–3 (filler)
int16_t Setpoint; // offset 4
uint8_t _pad2[2]; // offset 6–7 (filler)
float Actual; // offset 8
uint8_t _pad3[4]; // offset 12–15 (filler)
int32_t Counter; // offset 16
} MyStatus_PLC_TIA;
#pragma pack(pop)
✅ 关键:
_padN成员名明确标识用途,避免误删;#pragma pack(1)确保无额外填充。
4.3 步骤3:ST侧规避STRUCT,改用显式字节数组+位运算(终极可控方案)
当协议要求严苛(如金融级精度、安全回路),或需跨多品牌PLC部署时,彻底放弃STRUCT定义,手动构造字节流:
TYPE MyStatusRaw : STRUCT
RawBytes : ARRAY[0..19] OF BYTE; // 固定20字节
END_STRUCT
FUNCTION_BLOCK SendStatus
VAR
stRaw : MyStatusRaw;
stLogic : MyStatus; // 仅用于逻辑运算,不参与通信
i : INT;
END_VAR
// 1. 填充逻辑值到stLogic(正常编程)
stLogic.RunFlag := ...;
stLogic.Mode := ...;
// 2. 手动映射到RawBytes(按TIA实测布局)
stRaw.RawBytes[0] := BYTE_TO_BYTE(stLogic.RunFlag); // offset 0
stRaw.RawBytes[1] := stLogic.Mode; // offset 1
stRaw.RawBytes[4] := WORD_TO_BYTE(stLogic.Setpoint); // offset 4, low byte
stRaw.RawBytes[5] := WORD_TO_BYTE(SHR(stLogic.Setpoint,8)); // offset 5, high byte
// ... 依此类推填充REAL和DINT(需调用REAL_TO_DWORD等转换函数)
// 3. 将stRaw.RawBytes传给Modbus发送FB
→ 优势:完全脱离编译器布局影响,字节级可控;
→ 成本:开发量增加约30%,但一次写完永久可靠。
4.4 步骤4:建立团队级结构体对齐规范文档
在自动化项目启动阶段,强制产出《通信数据结构对齐约定表》,包含:
- 所有通信STRUCT名称、版本号;
- 每个成员的:名称、ST类型、C类型、PLC平台、实测Offset、实测Size、是否填充;
- 协议传输字节总数;
- 对应上位机结构体完整C代码(含
#pragma pack); - 测试用例(含预设字节流与预期解码值)。
该文档作为API契约,纳入Git仓库,每次变更需双人评审。
五、避坑清单:高频错误动作与正确做法对照
| 错误动作 | 后果 | 正确做法 |
|---|---|---|
| 直接复制ST结构体定义到C头文件,不做对齐处理 | 上位机解析全错 | 必须依据PLC实测Offset手写C结构体,显式添加_pad |
使用#pragma pack(1)但未验证PLC实际布局 |
若PLC本身填充更多,仍错位 | 先测PLC布局,再配C端,不可假设 |
在ST中用UNION混合不同长度类型试图“压缩” |
UNION内各成员共享首地址,但内部对齐仍由编译器决定,不可控 | 改用ARRAY OF BYTE + 位运算 |
| 认为“两个平台都用REAL,就一定4字节” | REAL在IEC 61131中定义为IEEE 754单精度,但部分老旧PLC用自定义格式 | 实测REAL变量对应4字节内容,用已知值(如3.14)验证 |
| 通过Wireshark抓包看到“数据看起来正常”就认为没问题 | 浮点数高位零、整数小值易掩盖错位,需用边界值测试(如-32768、0xFFFFFFFF) | 必须用极值、负数、非规格化浮点数(如0x00000001)触发错位 |
六、延伸:OPC UA二进制编码下的对齐陷阱
OPC UA规范(Part 6)明确定义了结构体序列化规则:
- 成员按声明顺序编码;
- 每个成员独立对齐到自身宽度(例如INT对齐到2字节边界,REAL到4字节);
- 结构体总大小向上取整到最大成员宽度的整数倍;
- 无隐式填充,但对齐空隙必须填0。
这意味着:OPC UA服务器(如PLC UA Server)输出的二进制结构体,其布局与ST语言无关,而由OPC UA栈实现决定。此时:
- 若PLC UA Server按规范实现,则布局固定(如REAL总在4字节边界);
- 但若上位机UA客户端使用动态反射解析(而非预生成结构体),且其SDK对齐处理有bug,仍会错位;
- 解决方案:禁用动态反射,用UA Model Designer导出C结构体代码,确认其
#pragma pack与UA规范一致。
七、总结性结论(可直接嵌入代码注释)
// ⚠️ 重要:本结构体通信布局基于TIA Portal v18实测
// - 总长度20字节,含8字节填充
// - 成员偏移:RunFlag@0, Mode@1, Setpoint@4, Actual@8, Counter@16
// - 上位机C结构体必须使用#pragma pack(1) + 显式_pad成员严格匹配
// - 禁止仅凭ST定义推导内存布局——PLC编译器不保证跨版本兼容
验证通信数据是否错位的最快方法:
在PLC中将结构体第一个成员赋值为16#AA,第二个赋值为16#55,第三个(INT)赋值为16#1234,然后抓取原始字节流,检查AA 55 ?? ?? 34 12是否出现在预期偏移位置。
若34 12未出现在偏移4-5处,即证实对齐失配。

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