从 PLC 读取 STRING / WSTRING:UCS-2、GBK 与 Python 实战
1. PLC中 STRING / WSTRING 的内存布局
以西门子 S7 系列为例,PLC 中
STRING类型通常在 DB(Data Block)内以固定结构存储:
byte offset: 0 1 2 ...
+--------+--------+-----------------
field | MaxLen | ActLen | Data (payload)
size | 1 byte | 1 byte | MaxLen * (char_size)
- MaxLen(第 0 字节):定义时指定的最大字符数(例如
STRING[20]则为 20)。 - ActLen(第 1 字节):当前字符串占用的字符数(不是字节数!)
- Data(从第 2 字节开始):字符数据。对
STRING来说,旧型号可能用单字节编码(ASCII 或 GBK 的双字节区),新型号与WSTRING会用 2 字节/字符(UCS-2 / UTF-16LE)。
关键:ActLen 保存的是字符个数,而非字节数。因此读取字节区间时需根据每字符的字节数做乘法,例如 UCS-2/UTF-16LE 为 ActLen * 2 字节。
2. UCS-2(UTF-16LE)与 GBK 的本质区别
UCS-2 / UTF-16LE
- 体系:Unicode 的早期实现(UCS-2)与更通用的 UTF-16(两者在基本平面 BMP 区相同)。S7-1500 等设备通常按小端序存储,因此用
utf-16le解码。 - 每字符字节数:固定 2 字节/字符(UCS-2 基本平面)。
- 优点:支持广泛字符集(全球语言),编码稳定一致。
GBK
- 体系:中国国家标准的扩展(在 GB2312 基础上扩展),主要用于中文环境。
- 每字符字节数:1 或 2 字节(英文 ASCII 占 1 字节,中文字符占 2 字节)。
- 优点:在老旧或本地化 Windows/PLC 工程中常见,文本体积相对小。
对比要点
- UCS-2 固定 2 字节,ActLen * 2 即为字节长度;GBK 则是可变的,无法直接用 ActLen 乘以固定系数得到字节数(但 PLC 的
ActLen在 GBK 场景通常表示字符个数,而 payload 区按最大长度分配字节空间)。 - 解码时:UCS-2 用
'utf-16le';GBK 用'gbk'。
3. 如何判断 PLC 存储的是哪种编码(理论与实践)
理论判断
- 查看 PLC 程序或工程文件(TIA Portal)中字符串变量的类型和属性:
STRING、WSTRING或有没有明确选择 Unicode。新型号/多语言工程通常使用 Unicode。 - 查阅 PLC 文档:S7-300/400 旧系列更多使用 OEM 编码或 ASCII,S7-1200/1500 更多支持 Unicode(UCS-2/UTF-16LE)。
实践检测(字节探测法)
当无法直接查看工程时,可以通过读取字节并尝试解码判断:
- 读取
MaxLen与ActLen。 - 提取
payload = data[offset+2 : offset+2 + MaxLen * char_bytes](若不清楚char_bytes,先取MaxLen*2作为上限) - 尝试按
utf-16le解码payload[:ActLen*2]:若输出为合法文本(没有大量替代字符或异常码点),则很可能为 UCS-2/UTF-16LE。 - 若失败,尝试按
gbk解码payload[:ActLen*1?](GBK 的字节长度不固定,需小心边界)。
常用 heuristic:若
payload[0]为 ASCII 可打印字符或序列中每隔一字节有0x00(常见于 UTF-16LE 小端),那通常是 UTF-16LE;若高位字节不规则,但符合 GBK 两字节中文区间(0x81-0xFE 等),则可能为 GBK。
代码自动检测思路(后面会有实现)
- 优先尝试
utf-16le(在新 PLC 上更常见且安全性高),失败后回退到gbk。 - 使用错误替换策略或严格检测(例如检查解码后包含多少不可打印字符或替代字符
�),并据此决定编码。
4. Python 实战:可靠读取与解码函数(含自动检测)
下面给出两个函数:
get_string_ucs2_raw:明确 UTF-16LEget_string_auto_decode:自动检测 UTF-16LE 与 GBK 并返回最可信结果
from typing import Tuple
def get_string_ucs2_raw(data: bytes, byte_offset: int = 0, max_length: int = 254) -> str:
"""
假定 PLC 存储为 UCS-2 / UTF-16LE:
- data: 从 plc.db_read() 得到的 bytes
- byte_offset: 字符串的起始偏移
- max_length: STRING 定义的最大字符数(用于边界检查)
"""
try:
# 读取 MaxLen 与 ActLen(按字节偏移)
max_len = data[byte_offset]
actual_len = data[byte_offset + 1]
if actual_len == 0:
return ""
# 每个字符 2 字节(UCS-2 / UTF-16LE)
start = byte_offset + 2
end = start + actual_len * 2
# 边界保护
end = min(end, len(data))
str_bytes = data[start:end]
return str_bytes.decode('utf-16le', errors='strict').rstrip('\x00')
except Exception as e:
# 若 strict 抛错,可改为 'replace' 或 'ignore',但应该记录日志
print(f"UCS-2 解码失败: {e}")
return ""
def _is_likely_utf16le(b: bytes) -> bool:
"""简单启发式判断:检查在前 N 字节中,是否大量出现偶数位为 0x00(小端UTF-16的常见特点)"""
if len(b) < 4:
return False
# 检查前 8 字节中偶数位是否有较多 0x00
sample = b[:16]
zeros = sum(1 for i in range(1, len(sample), 2) if sample[i] == 0)
return zeros >= max(1, len(sample)//8)
def get_string_auto_decode(data: bytes, byte_offset: int = 0, max_length: int = 254) -> Tuple[str, str]:
"""
自动检测编码并返回 (decoded_str, encoding_used)
返回示例: ("你好", 'utf-16le') 或 ("中文", 'gbk')
"""
try:
max_len = data[byte_offset]
actual_len = data[byte_offset + 1]
if actual_len == 0:
return "", 'unknown'
# 尝试用 utf-16le 解码
start = byte_offset + 2
# 先取一个上限区域(实际长度*2 或者 max_len*2,取边界)
utf16_end = start + actual_len * 2
utf16_end = min(utf16_end, len(data))
candidate = data[start:utf16_end]
# 启发式快速判断
if _is_likely_utf16le(candidate):
try:
s = candidate.decode('utf-16le')
# 可做额外校验:是否包含大量不可打印字符
if '�' not in s:
return s.rstrip('\x00'), 'utf-16le'
except Exception:
pass
# 回退到 GBK 解码:按实际长度取字节
# GBK 是可变字节,因此我们尽量取 max_len*2 作为安全上限
gbk_start = start
gbk_end = min(start + max_length * 2, len(data))
gbk_bytes = data[gbk_start: gbk_end]
try:
s2 = gbk_bytes.decode('gbk', errors='strict')
# 取前 actual_len 个字符(因为 gbk_bytes 可能包含后续空白)
s2 = s2[:actual_len]
return s2.rstrip('\x00'), 'gbk'
except Exception:
# 最后保底:尝试 latin1 显示原始字节(避免崩溃)
return data[start:start+actual_len].decode('latin1', errors='replace'), 'latin1'
except Exception as e:
print(f"读取字符串失败: {e}")
return "", 'error'
使用示例
# 假设 plc.db_read() 返回如下模拟数据
# MaxLen=254, ActLen=3, 内容为 UTF-16LE 编码的 '你好!'
sim = bytes([254, 3]) + '你好!'.encode('utf-16le') + b'\x00'*20
print(get_string_auto_decode(sim, 0))
5. 与常见库的交互(snap7 / python-snap7 / opcua)
- python-snap7 / snap7:常用于直接从 S7 PLC 的 DB 读取 raw bytes。
client.db_read(db_number, start, size)返回bytes,可传入到上面函数中。注意start与size的计算需根据变量在 DB 中的偏移计算。 - opcua / open62541 等:若通过 OPC UA 读取字符串字段,服务器端通常已经做了解码(返回 Python
str),但也可能返回byte[],需按实际情况处理。
示例(snap7):
import snap7
from snap7.util import *
client = snap7.client.Client()
client.connect('192.168.0.10', 0, 1)
raw = client.db_read(1, 0, 260) # 读取 DB1 前 260 字节
s, enc = get_string_auto_decode(raw, byte_offset=0)
print('结果', s, enc)
client.disconnect()
6. 常见陷阱与调试方法
- 偏移错误:变量的 DB 偏移并非总是 0。使用 TIA Portal 看变量在 DB 中的偏移地址,或在 PLC 程序里把字符串变量放在已知位置做测试。
- 误把 ActLen 当字节长度:记住 ActLen 是字符数;UCS-2 场景下字节数是
ActLen * 2。 - 编码混用:工程中不同字符串变量可能用不同编码(历史遗留问题)。对关键字段做编码检测或在 PLC 端统一编码策略。
- 截断问题:读取的字节长度需保证覆盖
MaxLen,否则可能截断或丢失数据。 - Endian(字节序)问题:S7 系列通常是小端(LE);若看见
0x00在高位,优先考虑 UTF-16LE。 - 空值与填充:PLC 的字符串区可能被
0x00填充,解码后使用.rstrip('\x00')清除尾部空字符。
调试小技巧:
- 使用 hexdump(或
raw.hex())观察前 32 字节,判断 0x00 分布; - 在 PLC 端写已知 ASCII / 中文样本进行对比;
- 若使用 snap7,可先把 DB 整体导出并在本地做分析。
7. 性能与安全注意事项
- 性能:字符串解码本身开销小,但若你频繁读取大量 DB(例如每 100ms 读取数百个字符串),应当合理缓存、合并读取请求以减少网络负载。
- 边界检查:任何从 PLC 读取的偏移与长度都应做边界检查以防止
IndexError。 - 错误记录:在自动检测解码失败时应记录日志(包含原始 bytes 的 hex),便于回溯。
- 注入风险:PLC 字符串通常不是攻击面,但若上层系统将这些字符串直接用于命令或脚本,应对特殊字符做清洗与验证。