03 字节序与数据类型编码

本篇定位:解决采集项目里最高频、最折磨人的一类问题——"数值读出来了,但是个天文数字 / 负数 / 差一点点"。根因几乎都落在字节序、字序、数据类型编码、位序、量程换算这五件事上。本篇讲透这五件事,并给出一份可直接编译运行的 Java 工具类。报文如何传输见 02-协议栈与工业网络.md,点表与架构见 01-工业数据采集全景与架构.md。

本篇目录


1. 大端与小端的本质

字节序(Endianness):多字节数据在内存或字节流中的字节排列顺序。以 0x12345678 为例(4 个字节高→低为 12、34、56、78),放在地址 0x1000 起:

  大端(Big-Endian,高字节在低地址)
      地址   0x1000  0x1001  0x1002  0x1003
            ┌───────┬───────┬───────┬───────┐
      内容   │  12   │  34   │  56   │  78   │   MSB ──────────▶ LSB
            └───────┴───────┴───────┴───────┘
      从低地址往高地址读 = 12 34 56 78,与书写顺序一致 ✓
  小端(Little-Endian,低字节在低地址)
      地址   0x1000  0x1001  0x1002  0x1003
            ┌───────┬───────┬───────┬───────┐
      内容   │  78   │  56   │  34   │  12   │   LSB ──────────▶ MSB
            └───────┴───────┴───────┴───────┘
      读出来是 78 56 34 12,要反过来看才是 0x12345678

为什么小端在硬件上更自然:CPU 做加法必须从最低位开始(处理进位)。若最低字节在低地址,同一变量按 8/16/32 位访问的起始地址相同、只是长度不同,硬件简单;大端则不同宽度要不同偏移。这是 Intel x86 选小端并保持至今的原因;Motorola 68k、早期 SPARC/PowerPC 与多数网络处理器选了大端。

System.out.println(ByteOrder.nativeOrder());   // 桌面/服务器通常是 LITTLE_ENDIAN
#include <stdio.h>
int main(void) {
    unsigned int x = 0x12345678;
    unsigned char *p = (unsigned char *)&x;
    if (p[0] == 0x78)      printf("Little-Endian\n");
    else if (p[0] == 0x12) printf("Big-Endian\n");
    else                   printf("Middle-Endian / PDP\n");
    return 0;
}

关键提醒:你的 Java 跑在 x86/ARM(小端)上,西门子 S7 内部是大端,Modbus 链路上"每个寄存器内部大端"但"两个字谁在前"没规定。三者不一致是常态,绝不能靠猜。


2. 为什么会有两种字节序

  1970s   Motorola 6800/68000 大端;Intel 8080/8086 小端。两种设计各自有理
  1980s   ARPANET 与早期网络设备大量用 Motorola 68k(大端);IETF 需统一顺序,
          Jon Postel 在 RFC 1700 明确:网络字节序 = 大端,并提供 htonl/htons/ntohl/ntohs
  1990s+  x86 成为主流(小端),但 TCP/IP 已成标准,网络字节序 = 大端已固化;
          今天 ARM / RISC-V 默认小端,网络协议仍统一大端

为什么选大端:① 十六进制 dump 从左到右就是数值书写顺序(08 00 一眼认出 IPv4 EtherType);② 按字节字典序比较与数值大小比较一致(对无符号数),硬件查表快;③ 当时网络处理器多为大端;④ 与字符串"首字符在前"一致。

  对采集工程师的结论:
   1) TCP/IP 头的多字节字段全是大端。DataInputStream.readInt() 按大端读;
      ByteBuffer 默认 BIG_ENDIAN,但显式写 order() 更安全
   2) Modbus:MBAP 头大端、寄存器内部大端,但【两个寄存器拼 32 位时谁是高字】
      没规定 ← 第 3 节问题的根源
   3) 西门子 S7 内部大端;经 Modbus 映射暴露时取决于写映射功能块的人
   4) 三菱/欧姆龙多为小端,但 MC 协议报文本身的头部字段是大端
      —— 头与数据可能不同(**需实测确认**)
   5) 点表里必须显式配置字节序字段

3. 工业现场的四顺序:ABCD / BADC / CDAB / DCBA

3.1 问题从哪来

16 位寄存器只有"两个字节谁在前"一个问题;但 32 位数据占 2 个寄存器,于是出现两个独立自由度:字序(高 16 位在前还是低 16 位在前)与字节序(每个 16 位字内部高字节在前还是低字节在前)。2 × 2 = 4 种组合,即工业现场臭名昭著的"四顺序"。命名约定:把 32 位值 0x12345678 的 4 个字节从高到低标记为 A B C D(A=0x12 最高,D=0x78 最低),四种排列指它们在字节流中的出现顺序;下文 R[n] 是先读到的寄存器。

3.2 四种排列逐个拆解

  ① ABCD —— 大端正序(字序正、字节序正)
     字节流: [ A ][ B ][ C ][ D ] = 12 34 56 78
     R[n]=0x1234(高字)  R[n+1]=0x5678(低字)
     记忆:先高字、字内大端,与网络字节序一致

  ② BADC —— 字节交换 Byte Swap(字序正、字节序反)
     字节流: [ B ][ A ][ D ][ C ] = 34 12 78 56
     R[n]=0x3412   R[n+1]=0x7856
     记忆:高字仍在前面,但每个字内部两个字节反了

  ③ CDAB —— 字交换 Word Swap(字序反、字节序正)
     字节流: [ C ][ D ][ A ][ B ] = 56 78 12 34
     R[n]=0x5678(低字先到)  R[n+1]=0x1234(高字后到)
     记忆:两个字整体调换位置,字内部仍大端

  ④ DCBA —— 完全反转 / 双交换(小端)
     字节流: [ D ][ C ][ B ][ A ] = 78 56 34 12
     R[n]=0x7856   R[n+1]=0x3412
     记忆:小端设备把内存字节原样拆成两个寄存器

3.3 各顺序的典型来源(工程经验,需实测确认)

顺序 别名 典型出现场景
ABCD 大端正序 / Big-Endian Modbus 的默认直觉;西门子 S7 内部数据;多数标准 Modbus 仪表、电力仪表
BADC 字节交换 / Byte Swap 部分厂商把 32 位浮点按"寄存器内小端"存放;部分国产仪表、温控表;某些 PLC 的 Modbus Server 映射
CDAB 字交换 / Word Swap 32 位浮点低字在前的设备;部分日系 PLC 与变频器的 Modbus 映射;部分能量表
DCBA 双交换 / 小端 x86/ARM 小端设备把内存直接拆成寄存器;部分国产仪表与变频器

警告:上表是经验归纳,不是规范。同厂商不同型号、同型号不同固件都可能不一样。正确做法:查手册 → 用已知值反推 → 把结论写进点表 byte_order 并留存记录。

3.4 用现象反推顺序

  读到的 32 位浮点是离谱的值?
    ├─ 接近 0 的次正规数(1e-41 量级) → 字节整体错位:试 CDAB 或 DCBA
    ├─ 天文数字(1e5 ~ 1e38)          → 高低字节错位:试 BADC
    ├─ 数量级对但尾数不对 → 量程/小数点问题;完全看不出规律 → 先确认类型
      (可能是 INT32 定点放大了 10/100 倍,或 BCD)
  最稳妥的办法永远是"已知值反推"(见 4.3)

4. 为什么 32 位浮点一定有字序问题

4.1 根源

  FLOAT32 = 4 字节 = 2 个 16 位寄存器
    ┌─────────────┐  ┌─────────────┐
    │ 寄存器 R[n]  │  │ 寄存器 R[n+1]│     这两个,哪个是高 16 位? ← Modbus 规范没说!
    └─────────────┘  └─────────────┘

  Modbus 只规定"每个寄存器 16 位、寄存器内部大端传输",【完全没有规定】两个寄存器
  如何拼成 32 位值 → 各厂商自由发挥,四种都出现了。而 INT16/UINT16 只占 1 个寄存器,
  不存在这个问题 → 这就是"读开关量和 16 位整数都正常,一读浮点就崩"的原因。

4.2 IEEE754 单精度回顾

  ┌─┬─────────┬───────────────────────────┐
  │S│  E (8)  │        M (23)              │   31 30    23 22                      0
  └─┴─────────┴───────────────────────────┘
  数值 = (-1)^S × (1 + M/2^23) × 2^(E-127)(规格化数,E ∈ [1,254])
  有效数字约 7 位十进制;范围约 ±3.4 × 10^38

4.3 以 12.5f 为例:四种排列完整对照

第一步:算出 12.5 的 IEEE754 表示

  12.5 = 1100.1b = 1.1001 × 2^3
  S = 0;E = 3 + 127 = 130 = 0x82 = 1000 0010
  M = 100 1000 0000 0000 0000 0000   (0.1001 后面补 0 到 23 位)
  拼接:0 | 1000 0010 | 100 1000 0000 0000 0000 0000 = 0x41480000
  ★ 12.5f 的大端字节 = 41 48 00 00   (A=0x41, B=0x48, C=0x00, D=0x00)

第二步:四种排列对照表

顺序 字节流 R[n] R[n+1] 若按 ABCD 误解析 现象
ABCD 41 48 00 00 0x4148 0x0000 0x41480000 → 12.5 ✓ 正确
BADC 48 41 00 00 0x4841 0x0000 0x48410000 → 197632.0 天文数字
CDAB 00 00 41 48 0x0000 0x4148 0x00004148 → ≈ 2.3418e-41 接近 0(次正规数)
DCBA 00 00 48 41 0x0000 0x4841 0x00004841 → ≈ 2.592e-41 接近 0(次正规数)

("误解析"列为按 ABCD 规则解释字节流得到的 IEEE754 值,按标准公式计算所得。)

第三步:反推方法(现场最实用)

  1) 让设备显示或写入一个已知值,推荐 12.5 或 1.0
     ★ 不要用 0(四种排列都是全 0,区分不出来)
     ★ 不要用整数(如 100.0 = 0x42C80000,尾数全 0 区分度低)
  2) 读这两个寄存器,记下十六进制值
  3) 对照:R[n]=0x4148 & R[n+1]=0x0000 → ABCD;0x4841/0x0000 → BADC
           0x0000/0x4148 → CDAB;      0x0000/0x4841 → DCBA
  4) 四个都对不上 → 可能不是 FLOAT32:检查是不是 INT32 定点放大了 10/100 倍,
     或者地址偏移差了一个寄存器

DOUBLE64 占 8 字节 = 4 个寄存器,同样有字序问题(理论排列更多,实际仍以这四种模式的扩展为主)。12.5 的 double 是 0x4029000000000000,正序字节 40 29 00 00 00 00 00 00。工业现场用得较少,遇到时按同样方法反推。


5. 常见数据类型编码速查表

类型 字节 寄存器 取值范围 字节序影响 PLC / 现场常见表示
BOOL 1 bit 1 位 / 1 线圈 0 / 1 无(位序有影响) S7 M3.2、DB1.DBX0.1;Modbus 线圈或寄存器某位
BYTE / USINT 1 0.5 0 ~ 255 无 S7 MB0、DB1.DBB0
INT16 (Int) 2 1 -32768 ~ 32767 ★ S7 DB1.DBW2;Modbus 保持寄存器
UINT16 (Word) 2 1 0 ~ 65535 ★ S7 MW0
INT32 (DInt) 4 2 -2147483648 ~ 2147483647 ★★★ S7 DB1.DBD4;累计量
UINT32 (UDInt) 4 2 0 ~ 4294967295 ★★★ ★ Java 侧必须用 long 承接
INT64 (LInt) 8 4 ±9.22 × 10^18 ★★★ S7-1500 支持
FLOAT32 (Real) 4 2 单精度,约 7 位有效数字 ★★★ S7 DB1.DBD0;最常"读崩"的类型
DOUBLE64 (LReal) 8 4 双精度,15~17 位有效数字 ★★★ 高精度计量
STRING n+2(S7) (n+2)/2 见第 7 节 无(逐字节) S7 STRING:2 字节头 + 字符
BCD16 / BCD32 2 / 4 1 / 2 0000~9999 / 00000000~99999999 ★ / ★★★ 电表、水表、累计电量
DATE_TIME (S7) 8 4 见 7.3 ★★★(BCD) S7 DATE_AND_TIME
时间戳 Unix 4 / 8 2 / 4 — ★★★ 新设备常用(4 字节有 2038 问题)
  INT16 -4660 (0xEDCC)   大端: ED CC    小端: CC ED
  INT32 0x12345678       ABCD: 12 34 56 78    BADC: 34 12 78 56
                         CDAB: 56 78 12 34    DCBA: 78 56 34 12
  FLOAT32 12.5           ABCD: 41 48 00 00    BADC: 48 41 00 00
                         CDAB: 00 00 41 48    DCBA: 00 00 48 41
  BCD32 12345678         12 34 56 78(每字节两位十进制)

UINT32 在 Java 里没有原生类型:0xFFFFFFFF 存进 int 是 -1,必须用 long 承接(& 0xFFFFFFFFL)。"电表累计量突然变成 -1"这类线上事故,标准答案就是这个。同理,UINT16 也要用 int 承接(& 0xFFFF),否则 40000 会变成 -25536。


6. 位与字节的映射

工业现场极常见:用一个 16 位寄存器打包 16 个开关量或报警位,节省地址空间。

  寄存器 R = 1010 0011 0000 0101

  LSB first(bit0 = 最低位)—— 工业界与 PLC 的主流约定
    bit:  15 14 13 12 11 10  9  8  7  6  5  4  3  2  1  0
    值:    1  0  1  0  0  0  1  1  0  0  0  0  0  1  0  1
          └──── 高字节 ────┘└──── 低字节 ────┘  bit15=MSB   bit0=LSB

  MSB first(bit0 = 最高位)—— 部分文档与协议使用
    bit:   0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
    值:    1  0  1  0  0  0  1  1  0  0  0  0  0  1  0  1

  ★ 同一个物理位,两种约定编号完全不同:LSB 的 bit15 == MSB 的 bit0

典型报警字:bit0 运行 / bit1 正转 / bit2 故障 / bit3 警告 / bit8 过载 / bit9 过压 / bit11 过热,取值 boolean fault = (status & (1 << 2)) != 0;

  陷阱 1:从 0 还是从 1 开始编号——图纸常写 "D0.1 ~ D0.16"(1 基),程序里是 bit0 ~ bit15
  陷阱 2:MSB first 还是 LSB first——部分老国产设备用 MSB first,点表单独配 bitOrder
  陷阱 3:跨字节的位序——32 位状态字的 bit16~bit31 属于哪个寄存器?若字序是 CDAB
          (低字在前),它们落在【前一个】寄存器,字序与位序叠加,两个都要配对
  陷阱 4:Modbus 读线圈(FC 01)返回"位打包字节"——读 16 个线圈得 2 字节,
          byte0 的 bit0 = 地址最小的线圈,且跨字节连续
  陷阱 5:S7 的 BOOL 是独立位地址——读 `DB1.DBX2.3` 不需要自己做位运算(驱动处理);
          但若读 `DB1.DBW2` 再取位,要遵守 S7 的位序(bit0 = LSB)

7. 字符串编码

字符串是采集里第二容易出问题的类型(第一是浮点)。三种主流格式:

7.1 西门子 S7 STRING(2 字节头 + 字符)

  ┌──────────┬──────────┬─────────────────────────┐
  │ 最大长度   │ 实际长度  │  字符数据(ASCII 字节)  │
  │ 1 byte   │ 1 byte   │      maxLen 字节        │
  └──────────┴──────────┴─────────────────────────┘
  offset 0    offset 1   offset 2 ...

  例:STRING[10] 存 "AB"
    0A 02 41 42 00 00 00 00 00 00 00 00     总占用 = maxLen + 2 = 12 字节
    └最大┘└实际┘└'A'┘└'B'┘└─── 未用部分 ───┘

  ★ 总字节数通常是偶数(S7 的 DB 会自动补齐)
  ★ 未用字节可能是 0x00 也可能是 0x20(空格),取决于写入方
  ★ 解析以头部的"实际长度"为准,不要靠找 0x00 截断

WSTRING(存中文):头部是 2 个字(最大长度、实际长度,共 4 字节),每字符 2 字节 UTF-16。

7.2 C 风格与定长填充

  C 风格:16 字节缓冲区存 "PUMP-01"
    50 55 4D 50 2D 30 31 00 00 00 00 00 00 00 00 00
    └────── "PUMP-01" ──────┘ └结束符┘└── 填充 0 ──┘
    解析:扫到第一个 0x00;设备写满缓冲区且无 0x00 时会越界 → 必须限制最大长度
  定长填充:无结束符,按约定长度取全部字节后 trim(去尾部 0x00 与空格)
格式 头部开销 长度来源 优点 缺点
S7 STRING 2 字节 头部实际长度 长度明确、可含 0x00 字符 西门子专有
C 风格 0 扫描到 0x00 通用、省空间 设备不写 0x00 会越界
定长填充 0 约定长度 最简单 要 trim,填充可能是 0x00 或 0x20

7.3 S7 的 DATE_AND_TIME(8 字节 BCD)

  字节  0    1    2    3    4    5    6        7
       ┌────┬────┬────┬────┬────┬────┬────────┬────────┐
       │年   │月   │日   │时   │分   │秒   │毫秒高位 │毫秒低位│
       │BCD │BCD │BCD │BCD │BCD │BCD │BCD     │+星期   │
       └────┴────┴────┴────┴────┴────┴────────┴────────┘
  · 年:BCD 两位。90~99 → 1990~1999;00~89 → 2000~2089
  · 字节 6:毫秒百位与十位;字节 7:高 4 位 = 毫秒个位,低 4 位 = 星期(1=周日 … 7=周六)
    (拆分方式需按设备实测确认)
  例:2024-05-18 14:30:25.123,星期六 → 24 05 18 14 30 25 12 37

实际项目更推荐从 PLC 读 Unix 时间戳(INT32/INT64),避免 BCD 与两位年份的歧义。

7.4 字符集现实:ASCII / GBK / UTF-8

  经验分布(需实测确认):
    · 型号、序列号、程序名:绝大多数 ASCII;报警文本与中文设备名:老设备常见 GBK/GB2312
    · 新设备(2015 年后)逐步转 UTF-8,但仍有大量 GBK
    · S7 STRING 存的是字节,编码由写入方决定,中文要用 WSTRING;Modbus 不规定编码
  十六进制判断法:
    ASCII 所有字节 < 0x80 | GBK 中文 两字节且都 ≥ 0x81("中" = D6 D0)
    UTF-8 中文 三字节、首字节 E0~EF("中" = E4 B8 AD)| UTF-16BE 每字符 2 字节
  工程做法:① 点表为字符串测点配置 charset 字段;② 默认 ASCII,乱码时先抓十六进制
           ③ 永远显式指定字符集:new String(bytes, StandardCharsets.UTF_8) ✓
                                 new String(bytes) / s.getBytes()          ✗

8. BCD 码

BCD(Binary-Coded Decimal):用 4 个二进制位表示一个十进制数字(0~9),一个字节存两位十进制数。

  十进制 12345678
    BCD32 编码:12 34 56 78      (每 4 bit 一位:1 2 | 3 4 | 5 6 | 7 8)
    纯二进制  :0x00BC614E
  ★ BCD 的十六进制"看起来"就是十进制数 —— 这既是它的优点,也是它最容易被误当成
    INT32 读取的原因(会读成 305419896,正确值是 12345678)
  转换:BCD → 十进制 value = Σ (digit_i × 10^i);十进制 → BCD 反复取模
        1234 → d0=4, d1=3, d2=2, d3=1 → 0x1234
        非法值:单个 nibble > 9(如 0x1A)应报错而非继续
优点 说明 缺点 说明
十进制直观 数码管直接取 nibble 送显示 空间浪费 1 字节只存 0~99(二进制 0~255)
无浮点误差 累计电量 12345678 永远精确 运算慢 加减要十进制调整,乘除更麻烦
老 MCU 无 FPU 8051 的 DA A 等硬件支持 容易误读 当 INT32 读会得到完全错误的值
行业标准惯性 DL/T645 等标准就规定 BCD
调试友好 抓包看到 12 34 就是 1234

常见变体:① 打包 BCD(每字节两位十进制,最常见);② 非打包 BCD(每字节一位十进制,高 4 位补 0 或 0x30,若是 0x30 则其实是 ASCII 数字字符);③ 带符号/带小数点的 BCD(部分电表用最高 nibble 表示符号 0xC 正 / 0xD 负,或用单独字节表示小数位数,无统一标准,需查手册确认)。


9. 缩放与量程:RAW → EGU

PLC 里存的是 A/D 转换后的原始码值(RAW),不是物理量。要得到工程量(EGU)必须变换。

  两点标定(推荐):
                    (RAW - RAW_MIN) × (ENG_MAX - ENG_MIN)
      EGU = ENG_MIN + ────────────────────────────────────
                              (RAW_MAX - RAW_MIN)
                    (EGU - ENG_MIN) × (RAW_MAX - RAW_MIN)
      RAW = RAW_MIN + ────────────────────────────────────   (写操作前必做)
                              (ENG_MAX - ENG_MIN)
  单点比例:EGU = K × RAW + B ; RAW = (EGU - B) / K

4-20mA / 0-10V 到码值的映射(西门子 S7-300/400/1200/1500 标准模拟量):

  0 ~ 20 mA → 0 ~ 27648   4 ~ 20 mA → 0 ~ 27648(活零已扣除)   0 ~ 10 V → 0 ~ 27648
  ±10 V → -27648 ~ +27648   27649~32511 上溢出区   32767 溢出/断线
  -32512~-27649 下溢出;-32768 下溢极限   (具体模块需查手册确认)
  其它常见满量程(厂商差异大,需实测确认):
      0~32767 部分厂商/通用 16 位   0~32000 老款 S7-200
      0~4000 三菱部分模块          0~12000 三菱高精度模块       0~6000 欧姆龙部分模块

  完整链路(0~300℃ 变送器,4~20mA,接 S7)
      218.6 ℃ → 电流 4 + 218.6/300×16 = 15.66 mA
              → RAW = (15.66-4)/16 × 27648 = 20150 → PLC DB3.DBW10 = 20150
              → EGU = 0 + (20150-0)×(300-0)/(27648-0) = 218.6 ℃
  工程注意点:
   1) 死区与精度配合:EGU 保留 1 位小数时死区设 0.5,否则浮点末位抖动导致每周期上报
   2) 越界判质量码而非静默截断:RAW > 27648 或 = 32767 通常是传感器断线/溢出,
      应标记 BAD 并告警,不要截断成"看起来正常"的最大值
   3) 写操作必须反变换 + 范围校验:下发 250℃ → RAW = 23040,写前检查 0 ≤ RAW ≤ 27648
   4) 非线性传感器不要用线性公式(热电偶/PT100 通常已由变送器线性化);
      小数位由精度决定,FLOAT32 只有约 7 位有效数字,"218.6345678" 是假精度

10. Java 实现:完整可运行的字节序工具类

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

/**
 * 工业采集字节序与数据类型编码工具集。
 * 编译:javac ByteOrderUtil.java     运行:java ByteOrderUtil(执行内置自检)
 * 约定:1) "标准序 / canonical" 指 ABCD,即最高有效字节在最前;
 *       2) toWire() 把标准序转成设备在线上的字节排列,fromWire() 是逆过程;
 *       3) 四种顺序都是对合运算(做两次等于没做),故 toWire 与 fromWire 同实现。
 */
public final class ByteOrderUtil {

    /** 32 位数据的四种字节/字排列 */
    public enum WordOrder { ABCD, BADC, CDAB, DCBA }

    private ByteOrderUtil() { }

    // ============ 1. 字节序变换 ============

    /** 标准序(ABCD)→ 指定排列 */
    public static byte[] toWire(byte[] bigEndian, WordOrder order) {
        if (bigEndian == null || bigEndian.length == 0) {
            return new byte[0];
        }
        byte[] out = new byte[bigEndian.length];
        switch (order) {
            case ABCD:   // 原样
                System.arraycopy(bigEndian, 0, out, 0, bigEndian.length);
                break;
            case BADC:   // 每 2 字节(一个字)内部交换
                for (int i = 0; i + 1 < bigEndian.length; i += 2) {
                    out[i] = bigEndian[i + 1];
                    out[i + 1] = bigEndian[i];
                }
                break;
            case CDAB:   // 每 4 字节组内交换前后两个 16 位字
                for (int i = 0; i + 3 < bigEndian.length; i += 4) {
                    out[i] = bigEndian[i + 2];
                    out[i + 1] = bigEndian[i + 3];
                    out[i + 2] = bigEndian[i];
                    out[i + 3] = bigEndian[i + 1];
                }
                break;
            case DCBA:   // 每 4 字节组内完全反转
                for (int i = 0; i + 3 < bigEndian.length; i += 4) {
                    out[i] = bigEndian[i + 3];
                    out[i + 1] = bigEndian[i + 2];
                    out[i + 2] = bigEndian[i + 1];
                    out[i + 3] = bigEndian[i];
                }
                break;
            default:
                throw new IllegalArgumentException("不支持的字节序: " + order);
        }
        return out;
    }

    /** 线上字节排列 → 标准序(与 toWire 同实现,因为是对合运算) */
    public static byte[] fromWire(byte[] wire, WordOrder order) {
        return toWire(wire, order);
    }

    // ============ 2. 寄存器(16 位)与字节互转 ============

    /** 字节流 → 寄存器数组(每个寄存器大端,即 Modbus 线上的表示) */
    public static int[] bytesToRegisters(byte[] wire) {
        if (wire.length % 2 != 0) {
            throw new IllegalArgumentException("字节数必须为偶数: " + wire.length);
        }
        int[] regs = new int[wire.length / 2];
        for (int i = 0; i < regs.length; i++) {
            regs[i] = ((wire[2 * i] & 0xFF) << 8) | (wire[2 * i + 1] & 0xFF);
        }
        return regs;
    }

    /** 寄存器数组 → 字节流(每个寄存器大端) */
    public static byte[] registersToWireBytes(int[] regs) {
        byte[] b = new byte[regs.length * 2];
        for (int i = 0; i < regs.length; i++) {
            b[2 * i] = (byte) ((regs[i] >> 8) & 0xFF);
            b[2 * i + 1] = (byte) (regs[i] & 0xFF);
        }
        return b;
    }

    /** 取 regIndex 起的 wordCount 个字并转成标准序字节 —— 所有 getter 的公共底层 */
    private static byte[] canonical(int[] regs, int regIndex, int wordCount, WordOrder order) {
        if (regIndex < 0 || regIndex + wordCount > regs.length) {
            throw new IndexOutOfBoundsException("寄存器越界: index=" + regIndex
                    + " count=" + wordCount + " len=" + regs.length);
        }
        byte[] wire = new byte[wordCount * 2];
        for (int i = 0; i < wordCount; i++) {
            int r = regs[regIndex + i] & 0xFFFF;
            wire[2 * i] = (byte) ((r >> 8) & 0xFF);
            wire[2 * i + 1] = (byte) (r & 0xFF);
        }
        return fromWire(wire, order);
    }

    // ============ 3. 读方向:按类型取值 ============

    /** INT16 有符号 16 位 */
    public static short getInt16(int[] regs, int regIndex, WordOrder order) {
        return ByteBuffer.wrap(canonical(regs, regIndex, 1, order))
                .order(ByteOrder.BIG_ENDIAN).getShort();
    }

    /** UINT16:用 int 承接 */
    public static int getUInt16(int[] regs, int regIndex, WordOrder order) {
        return getInt16(regs, regIndex, order) & 0xFFFF;
    }

    /** INT32 有符号 32 位 */
    public static int getInt32(int[] regs, int regIndex, WordOrder order) {
        return ByteBuffer.wrap(canonical(regs, regIndex, 2, order))
                .order(ByteOrder.BIG_ENDIAN).getInt();
    }

    /** UINT32:★ 必须用 long 承接,存进 int 会变成负数 */
    public static long getUInt32(int[] regs, int regIndex, WordOrder order) {
        return getInt32(regs, regIndex, order) & 0xFFFFFFFFL;
    }

    /** INT64 有符号 64 位 */
    public static long getInt64(int[] regs, int regIndex, WordOrder order) {
        return ByteBuffer.wrap(canonical(regs, regIndex, 4, order))
                .order(ByteOrder.BIG_ENDIAN).getLong();
    }

    /** FLOAT32:IEEE754 单精度 */
    public static float getFloat32(int[] regs, int regIndex, WordOrder order) {
        return Float.intBitsToFloat(getInt32(regs, regIndex, order));
    }

    /** DOUBLE64:IEEE754 双精度 */
    public static double getDouble64(int[] regs, int regIndex, WordOrder order) {
        return Double.longBitsToDouble(getInt64(regs, regIndex, order));
    }

    // ============ 4. 写方向:值 → 应下发的寄存器 ============

    /** INT32 / UINT32 → 2 个寄存器 */
    public static int[] toRegistersInt32(int value, WordOrder order) {
        byte[] be = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value).array();
        return bytesToRegisters(toWire(be, order));
    }

    /** FLOAT32 → 2 个寄存器 */
    public static int[] toRegistersFloat32(float value, WordOrder order) {
        return toRegistersInt32(Float.floatToIntBits(value), order);
    }

    /** DOUBLE64 → 4 个寄存器 */
    public static int[] toRegistersDouble64(double value, WordOrder order) {
        byte[] be = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
                .putLong(Double.doubleToLongBits(value)).array();
        return bytesToRegisters(toWire(be, order));
    }

    // ============ 5. 位操作 ============

    /** 取 16 位字的某一位(bit 0 = 最低位,LSB first 约定) */
    public static boolean getBit(int word, int bit) {
        if (bit < 0 || bit > 15) {
            throw new IllegalArgumentException("位号必须在 0~15: " + bit);
        }
        return ((word >>> bit) & 1) != 0;
    }

    /** 把 16 位字拆成 16 个 bool,lsbFirst=true 时数组下标 0 对应 bit0 */
    public static boolean[] unpackBits(int word, boolean lsbFirst) {
        boolean[] bits = new boolean[16];
        for (int i = 0; i < 16; i++) {
            bits[i] = ((word >>> (lsbFirst ? i : 15 - i)) & 1) != 0;
        }
        return bits;
    }

    /** 把 16 个 bool 打包成 16 位字 */
    public static int packBits(boolean[] bits, boolean lsbFirst) {
        if (bits.length != 16) {
            throw new IllegalArgumentException("必须是 16 个位");
        }
        int word = 0;
        for (int i = 0; i < 16; i++) {
            if (bits[i]) {
                word |= 1 << (lsbFirst ? i : 15 - i);
            }
        }
        return word & 0xFFFF;
    }

    /** byte → 0~255(★ 0xFF 在 Java 里是 -1,必须 & 0xFF) */
    public static int u8(byte b) {
        return b & 0xFF;
    }

    // ============ 6. BCD ============

    /** BCD 字节 → 数值。例:{0x12, 0x34} → 1234 */
    public static long fromBcd(byte[] bcd) {
        long value = 0;
        for (byte b : bcd) {
            int v = u8(b);
            int high = (v >> 4) & 0x0F;
            int low = v & 0x0F;
            if (high > 9 || low > 9) {
                throw new IllegalArgumentException("非法 BCD 字节: 0x" + Integer.toHexString(v));
            }
            value = value * 100 + high * 10 + low;
        }
        return value;
    }

    /** 数值 → BCD 字节(每字节两位十进制) */
    public static byte[] toBcd(long value, int byteCount) {
        long max = 1;
        for (int i = 0; i < byteCount * 2; i++) {
            max *= 10;
        }
        if (value < 0 || value >= max) {
            throw new IllegalArgumentException("值超出 BCD 可表示范围: " + value);
        }
        byte[] out = new byte[byteCount];
        for (int i = byteCount - 1; i >= 0; i--) {
            out[i] = (byte) ((((int) ((value / 10) % 10)) << 4) | (int) (value % 10));
            value /= 100;
        }
        return out;
    }

    // ============ 7. 字符串与时间 ============

    /** 解析 S7 STRING:raw[0]=最大长度,raw[1]=实际长度,之后是字符 */
    public static String parseS7String(byte[] raw, Charset charset) {
        if (raw.length < 2) {
            return "";
        }
        int actual = u8(raw[1]);
        if (actual > raw.length - 2) {
            actual = raw.length - 2;    // 防御:设备写错长度时不越界
        }
        return new String(raw, 2, actual, charset);
    }

    /** 解析 C 风格字符串:扫到第一个 0x00 为止 */
    public static String parseCString(byte[] raw, Charset charset) {
        int end = 0;
        while (end < raw.length && raw[end] != 0x00) {
            end++;
        }
        return new String(raw, 0, end, charset);
    }

    /** 解析 S7 DATE_AND_TIME(8 字节 BCD) */
    public static String parseS7DateTime(byte[] d) {
        if (d.length < 8) {
            throw new IllegalArgumentException("S7 DATE_AND_TIME 需要 8 字节");
        }
        int yy = bcd2(d[0]);
        int mo = bcd2(d[1]);
        int dd = bcd2(d[2]);
        int hh = bcd2(d[3]);
        int mi = bcd2(d[4]);
        int ss = bcd2(d[5]);
        int msHigh = bcd2(d[6]);                 // 毫秒的百位与十位
        int msLow = (u8(d[7]) >> 4) & 0x0F;      // 毫秒的个位
        int weekday = u8(d[7]) & 0x0F;           // 1=周日 ... 7=周六
        int year = (yy >= 90) ? 1900 + yy : 2000 + yy;
        return String.format("%04d-%02d-%02d %02d:%02d:%02d.%03d (weekday=%d)",
                year, mo, dd, hh, mi, ss, msHigh * 10 + msLow, weekday);
    }

    /** 一个 BCD 字节 → 两位十进制 */
    private static int bcd2(byte b) {
        int v = u8(b);
        return ((v >> 4) & 0x0F) * 10 + (v & 0x0F);
    }

    // ============ 8. 工程量换算 ============

    /** RAW → EGU 两点标定 */
    public static double rawToEng(double raw, double rawMin, double rawMax,
                                  double engMin, double engMax) {
        if (Math.abs(rawMax - rawMin) < 1e-12) {
            throw new IllegalArgumentException("RAW 量程不能为 0");
        }
        return engMin + (raw - rawMin) * (engMax - engMin) / (rawMax - rawMin);
    }

    /** EGU → RAW(写操作前必须调用) */
    public static double engToRaw(double eng, double rawMin, double rawMax,
                                  double engMin, double engMax) {
        if (Math.abs(engMax - engMin) < 1e-12) {
            throw new IllegalArgumentException("EGU 量程不能为 0");
        }
        return rawMin + (eng - engMin) * (rawMax - rawMin) / (engMax - engMin);
    }

    // ============ 9. 调试辅助 ============

    /** 字节数组转十六进制字符串,便于与抓包对照 */
    public static String toHex(byte[] data) {
        StringBuilder sb = new StringBuilder(data.length * 3);
        for (byte b : data) {
            sb.append(String.format("%02X ", b & 0xFF));
        }
        return sb.toString().trim();
    }

    /** 寄存器数组转十六进制字符串 */
    public static String regsToHex(int[] regs) {
        StringBuilder sb = new StringBuilder();
        for (int r : regs) {
            sb.append(String.format("%04X ", r & 0xFFFF));
        }
        return sb.toString().trim();
    }

    // ============ 自检入口 ============

    public static void main(String[] args) {
        System.out.println("[0] 本机字节序 = " + ByteOrder.nativeOrder()
                + "  (与工业设备的字节序无关,必须按点表配置)");

        System.out.println("\n[1] 12.5f 在四种顺序下的寄存器值:");
        for (WordOrder o : WordOrder.values()) {
            int[] regs = toRegistersFloat32(12.5f, o);
            System.out.printf("    %-4s 寄存器=%-12s 线上字节=%-12s 回读=%f%n",
                    o, regsToHex(regs), toHex(registersToWireBytes(regs)), getFloat32(regs, 0, o));
        }

        System.out.println("\n[2] 设备按 X 存、我按 ABCD 读,会得到什么:");
        for (WordOrder o : WordOrder.values()) {
            int[] wire = bytesToRegisters(toWire(
                    new byte[]{(byte) 0x41, (byte) 0x48, 0x00, 0x00}, o));
            System.out.printf("    设备实际 %-4s → 按 ABCD 解析 = %s%n",
                    o, getFloat32(wire, 0, WordOrder.ABCD));
        }

        System.out.println("\n[3] INT32 0x12345678 的四种排列:");
        for (WordOrder o : WordOrder.values()) {
            int[] regs = toRegistersInt32(0x12345678, o);
            System.out.printf("    %-4s 寄存器=%-12s 回读=0x%08X%n",
                    o, regsToHex(regs), getInt32(regs, 0, o));
        }

        System.out.println("\n[4] UINT32 必须用 long 承接:");
        int[] maxUint32 = toRegistersInt32(-1, WordOrder.ABCD);   // 0xFFFFFFFF
        System.out.println("    as int  = " + getInt32(maxUint32, 0, WordOrder.ABCD));
        System.out.println("    as long = " + getUInt32(maxUint32, 0, WordOrder.ABCD));

        System.out.println("\n[5] 位操作(0x0205:bit0、bit2、bit9 置位):");
        int status = 0x0205;
        boolean[] bits = unpackBits(status, true);
        System.out.println("    bit0=" + getBit(status, 0) + " bit1=" + getBit(status, 1)
                + " bit9=" + getBit(status, 9));
        System.out.println("    LSB first 前4位 = " + bits[0] + "," + bits[1]
                + "," + bits[2] + "," + bits[3]
                + ";打包还原 = 0x" + Integer.toHexString(packBits(bits, true)));

        System.out.println("\n[6] BCD / 字符串 / 时间:");
        byte[] bcd = toBcd(12345678L, 4);
        System.out.println("    12345678 → BCD " + toHex(bcd) + " → " + fromBcd(bcd));
        // 0A=最大长度10  07=实际长度7  "PUMP-01"  后面是未用字节
        byte[] s7 = {0x0A, 0x07, 'P', 'U', 'M', 'P', '-', '0', '1', 0, 0, 0};
        System.out.println("    S7 STRING " + toHex(s7) + " → '"
                + parseS7String(s7, StandardCharsets.ISO_8859_1) + "'");
        byte[] cStr = new byte[16];
        System.arraycopy("MOTOR-A".getBytes(StandardCharsets.US_ASCII), 0, cStr, 0, 7);
        System.out.println("    C 风格 → '" + parseCString(cStr, StandardCharsets.US_ASCII) + "'");
        byte[] dt = new byte[]{(byte) 0x24, 0x05, 0x18, 0x14, 0x30, 0x25, 0x12, 0x37};
        System.out.println("    S7 DATE_AND_TIME → " + parseS7DateTime(dt));

        System.out.println("\n[7] 工程量换算(4~20mA→0~27648,量程 0~300℃):");
        System.out.printf("    RAW=20150 → EGU=%.1f ℃;反算 250℃ → RAW=%.0f%n",
                rawToEng(20150, 0, 27648, 0, 300), engToRaw(250, 0, 27648, 0, 300));

        System.out.println("\n[8] Java 有符号 byte 的经典坑:");
        byte b = (byte) 0xFF;
        System.out.println("    (byte)0xFF        = " + b + "      ← 直接打印是 -1");
        System.out.println("    (byte)0xFF & 0xFF = " + u8(b) + "    ← 修正后是 255");
        byte mid = (byte) 0x80;
        System.out.println("    无修正拼接 = 0x" + Integer.toHexString(
                (mid << 16) | (0x34 << 8) | 0x56) + "  ← 期望 00803456,高位被符号位污染");
        System.out.println("    修正后拼接 = 0x" + Integer.toHexString(
                ((mid & 0xFF) << 16) | (0x34 << 8) | 0x56) + "    ← 正确");
    }
}

运行结果要点:12.5f 在四种顺序下分别存成 4148 0000 / 4841 0000 / 0000 4148 / 0000 4841;按 ABCD 误读它们则分别得到 197632.0、2.3418E-41、2.592E-41;UINT32 用 int 读是 -1、用 long 读是 4294967295。

注意"设备按 X 存、我也按 X 读"(正确)与"设备按 X 存、我按 ABCD 读"(错误)这两个方向的区别。现场排查时一定要分清——很多人把"应该用哪种顺序"和"当前看到了哪种现象"搞反。


11. 常见坑清单

11.1 Java 的 byte 有符号(0xFF = -1)

这是 Java 工业采集代码的头号 bug 源。

byte b = (byte) 0xFF;          // b = -1,不是 255
System.out.println(b);         // -1
System.out.println(b & 0xFF);  // 255  ✓

// 有符号字节放在【中间】位置时,高位会被符号位填满
byte mid = (byte) 0x80;                                    // 想表示字节 0x80
int wrong = (mid << 16) | (0x34 << 8) | 0x56;              // 0xFF803456  ← 错
int right = ((mid & 0xFF) << 16) | (0x34 << 8) | 0x56;     // 0x00803456  ← 对
// 打印同理:Arrays.toString 显示 [-1, 65],要用 toHex() 才看得到 FF 41

11.2 位移运算的符号扩展

byte b = (byte) 0x80;          // 1000 0000
int a = b >> 4;                // ★ -8(0xFFFFFFF8),不是 8:b 先提升为 0xFFFFFF80 再算术右移
int c = (b & 0xFF) >> 4;       // 8  ✓
int d = b >>> 4;               // 0x0FFFFFF8,仍错(已提升为 32 位,>>> 救不回来)
int e = (b & 0xFF) >>> 4;      // 8  ✓

int x = 0x80000000;            // -2147483648
int f = x >> 1;                // -1073741824(算术右移,补符号位)
int g = x >>> 1;               // 1073741824(无符号右移,补 0)← 取高 16 位时用这个

11.3 其它高频坑

# 坑 表现 修正
1 ByteBuffer 未显式设置 order 换环境后结果变化 .order(ByteOrder.BIG_ENDIAN)
2 UINT32/UINT16 存进 int/short 累计量变负数、40000 变 -25536 UINT32 用 long,UINT16 用 int,并 & 掩码
3 字符串用平台默认编码 Windows(GBK) 与 Linux(UTF-8) 结果不同 永远显式传 Charset
4 float 做累加 累到 1e7 后精度丢失 累计量用 long/BigDecimal
5 用 == 比较 float 计算值与常量不相等 Math.abs(a-b) < eps
6 把 BCD 当 INT32 读 12345678 读成 305419896 点表类型选 BCD
7 地址偏移差 1 个寄存器 32 位值高低字互换 + 串位 核对 0 基/1 基(见 01 篇 7.4)
8 字节序配置写在驱动里 同设备不同类型字节序不同时无法处理 字节序必须跟着测点走
9 Integer.parseInt("FFFFFFFF", 16) 抛 NumberFormatException (int) Long.parseLong(s, 16)
10 写操作前忘记 EGU→RAW 反变换 下发 250 结果写了码值 250(等于 2.7℃) 写通道必调 engToRaw
11 "字节序"与"位序"混为一谈 布尔量取错位 两个维度,各自配置
12 死区设为 0 浮点末位抖动导致每周期上报 死区 ≥ 精度的一半(如保留 1 位小数则设 0.5)

小结


常见误区


本文是《工业通信与数据采集》栏目第 3/22 篇 · 上一篇:协议栈与工业网络 | 栏目总览 | 下一篇:TCP 协议深入