从 PLC 读取 STRING / WSTRING:UCS-2、GBK 与 Python 实战


从 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)中字符串变量的类型和属性:STRINGWSTRING 或有没有明确选择 Unicode。新型号/多语言工程通常使用 Unicode。
  • 查阅 PLC 文档:S7-300/400 旧系列更多使用 OEM 编码或 ASCII,S7-1200/1500 更多支持 Unicode(UCS-2/UTF-16LE)。

实践检测(字节探测法)

当无法直接查看工程时,可以通过读取字节并尝试解码判断:

  1. 读取 MaxLenActLen
  2. 提取 payload = data[offset+2 : offset+2 + MaxLen * char_bytes](若不清楚 char_bytes,先取 MaxLen*2 作为上限)
  3. 尝试按 utf-16le 解码 payload[:ActLen*2]:若输出为合法文本(没有大量替代字符或异常码点),则很可能为 UCS-2/UTF-16LE。
  4. 若失败,尝试按 gbk 解码 payload[:ActLen*1?](GBK 的字节长度不固定,需小心边界)。

常用 heuristic:若 payload[0] 为 ASCII 可打印字符或序列中每隔一字节有 0x00(常见于 UTF-16LE 小端),那通常是 UTF-16LE;若高位字节不规则,但符合 GBK 两字节中文区间(0x81-0xFE 等),则可能为 GBK。

代码自动检测思路(后面会有实现)

  • 优先尝试 utf-16le(在新 PLC 上更常见且安全性高),失败后回退到 gbk
  • 使用错误替换策略或严格检测(例如检查解码后包含多少不可打印字符或替代字符 ),并据此决定编码。

4. Python 实战:可靠读取与解码函数(含自动检测)

下面给出两个函数:

  1. get_string_ucs2_raw:明确 UTF-16LE
  2. get_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,可传入到上面函数中。注意 startsize 的计算需根据变量在 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. 常见陷阱与调试方法

  1. 偏移错误:变量的 DB 偏移并非总是 0。使用 TIA Portal 看变量在 DB 中的偏移地址,或在 PLC 程序里把字符串变量放在已知位置做测试。
  2. 误把 ActLen 当字节长度:记住 ActLen 是字符数;UCS-2 场景下字节数是 ActLen * 2
  3. 编码混用:工程中不同字符串变量可能用不同编码(历史遗留问题)。对关键字段做编码检测或在 PLC 端统一编码策略。
  4. 截断问题:读取的字节长度需保证覆盖 MaxLen,否则可能截断或丢失数据。
  5. Endian(字节序)问题:S7 系列通常是小端(LE);若看见 0x00 在高位,优先考虑 UTF-16LE。
  6. 空值与填充:PLC 的字符串区可能被 0x00 填充,解码后使用 .rstrip('\x00') 清除尾部空字符。

调试小技巧:

  • 使用 hexdump(或 raw.hex())观察前 32 字节,判断 0x00 分布;
  • 在 PLC 端写已知 ASCII / 中文样本进行对比;
  • 若使用 snap7,可先把 DB 整体导出并在本地做分析。

7. 性能与安全注意事项

  • 性能:字符串解码本身开销小,但若你频繁读取大量 DB(例如每 100ms 读取数百个字符串),应当合理缓存、合并读取请求以减少网络负载。
  • 边界检查:任何从 PLC 读取的偏移与长度都应做边界检查以防止 IndexError
  • 错误记录:在自动检测解码失败时应记录日志(包含原始 bytes 的 hex),便于回溯。
  • 注入风险:PLC 字符串通常不是攻击面,但若上层系统将这些字符串直接用于命令或脚本,应对特殊字符做清洗与验证。


文章作者: 0xdadream
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 0xdadream !
评论
  目录