03 字节序与数据类型编码
本篇定位:解决采集项目里最高频、最折磨人的一类问题——"数值读出来了,但是个天文数字 / 负数 / 差一点点"。根因几乎都落在字节序、字序、数据类型编码、位序、量程换算这五件事上。本篇讲透这五件事,并给出一份可直接编译运行的 Java 工具类。报文如何传输见
02-协议栈与工业网络.md,点表与架构见01-工业数据采集全景与架构.md。
本篇目录
- 1. 大端与小端的本质
- 2. 为什么会有两种字节序
- 3. 工业现场的四顺序:ABCD / BADC / CDAB / DCBA
- 4. 为什么 32 位浮点一定有字序问题
- 5. 常见数据类型编码速查表
- 6. 位与字节的映射
- 7. 字符串编码
- 8. BCD 码
- 9. 缩放与量程:RAW → EGU
- 10. Java 实现:完整可运行的字节序工具类
- 11. 常见坑清单
- 小结
- 常见误区
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) |
小结
- 字节序的本质是多字节数据中"高字节在前还是低字节在前"。大端 = 高字节在低地址(与书写顺序一致);小端 = 低字节在低地址(硬件运算更自然)。
- 网络字节序 = 大端是 RFC 1700 定的历史事实,原因是报文可读、便于硬件比较、当时网络设备多用大端 CPU。
- 工业现场的问题不是"大端 vs 小端",而是 ABCD/BADC/CDAB/DCBA 四选一。根源是 Modbus 的 16 位寄存器不规定"两个寄存器怎么拼成 32 位",产生了字序与字节序两个独立自由度。
- 16 位整数没有字序问题,32 位浮点一定有。这解释了"读开关量和 16 位整数都正常,一读浮点就崩"。
- 12.5f = 0x41480000,四种排列分别是
41 48 00 00/48 41 00 00/00 00 41 48/00 00 48 41。按 ABCD 误读 BADC 得到 197632.0(天文数字),误读 CDAB/DCBA 得到 1e-41 量级——这两种现象能反过来帮你定位错在哪种顺序。 - 判定字节序的正确方法是"已知值反推",不要用 0 做测试值(四种排列都是全 0)。类型表关键项:INT16/UINT16 占 1 寄存器,INT32/UINT32/FLOAT32 占 2 个,DOUBLE64 占 4 个;UINT32 在 Java 里必须用
long承接。 - 位序是独立于字节序的第二个维度:bit0 是最低位还是最高位必须单独配置;图纸的 1 基编号(如 D0.1~D0.16)要减 1 才是程序里的 bit0~bit15。
- 字符串三种格式:S7 STRING(2 字节头 + 字符)、C 风格(扫到 0x00)、定长填充(trim)。编码永远显式指定字符集,靠十六进制字节特征判断 GBK 还是 UTF-8。
- BCD 用 4 bit 存一位十进制,优点十进制直观、无浮点累计误差;缺点是浪费空间、易被误当 INT32 读(12345678 会读成 305419896)。
- RAW → EGU 用两点标定公式;西门子标准满量程 27648,32767 表示溢出/断线;其它厂商可能是 32767/32000/4000/6000,必须查手册。写操作前必须做反变换并做范围校验。
- Java 三大坑:byte 有符号(
& 0xFF)、位移的符号扩展(>>vs>>>,且先& 0xFF)、无符号类型缺失(UINT32 用 long)。
常见误区
- 误区 1:字节序只有"大端/小端"两种,选一个就行。 错。工业现场是 ABCD/BADC/CDAB/DCBA 四种,因为 32 位数据跨越两个 16 位寄存器,字序和字节序是两个独立变量。
- 误区 2:西门子是大端,所以一定是 ABCD。 错。S7 内部数据是大端,但经 Modbus 映射暴露出来时取决于写映射功能块的人;同一台 PLC 上不同类型也可能用不同顺序。必须实测。
- 误区 3:16 位整数不用配字节序,或用 0 来测试字节序。 都错。寄存器内部 2 个字节仍有先后,只是只有 2 种可能(等价 ABCD 与 BADC),遇到不一致的设备一样会错;而 0 在四种排列下都是全 0 字节,完全区分不出来,要改用 12.5、1.0 这种字节分布有特征的值。
- 误区 4:
new String(bytes)差不多能用。 错。它用平台默认编码(Windows 上 GBK,Linux 上可能是 UTF-8),同一份数据在不同环境解析结果不同。必须显式指定Charset。 - 误区 5:
(byte) 0xFF就是 255。 错。Java 的 byte 有符号,(byte) 0xFF == -1。所有字节参与运算前必须& 0xFF。 - 误区 6:
b >> 4就是取高 4 位。 错。byte 会先符号扩展成 int,(byte)0x80 >> 4得到 -8。正确写法是(b & 0xFF) >> 4。 - 误区 7:UINT32 用 int 存就够了。 错。
0xFFFFFFFF存进 int 是 -1,累计电量读到 4294967295 时变负数是典型的线上事故。用long。 - 误区 8:浮点值差一点点,调一下系数就行。 错。如果数量级完全不对(1e5 或 1e-41 这种量级),一定是字节序问题;系数问题只会让值差一个固定倍数。
- 误区 9:BCD 和十六进制看起来一样,直接当 INT32 读。 错。
0x12345678作为 BCD 是 12345678,作为 INT32 是 305419896。必须用 BCD 解码。 - 误区 10:模拟量满量程都是 27648。 错。27648 是西门子标准值,其它厂商可能是 32767、32000(老 S7-200)、4000/12000(三菱)、6000(欧姆龙)。必须查模块手册。
- 误区 11:RAW 超过满量程就截断到最大值。 错。超过 27648 通常意味着传感器断线或严重超量程,数据不可信,应标记 BAD 并告警,而不是静默截断成"看起来正常"的最大值。
- 误区 12:字节序配置写在驱动代码里(全局一份)。 错。同一台 PLC 上不同测点、不同数据块、不同厂商模块映射出来的数据可能用不同顺序。字节序必须是测点级配置项。
本文是《工业通信与数据采集》栏目第 3/22 篇 · 上一篇:协议栈与工业网络 | 栏目总览 | 下一篇:TCP 协议深入