1. 项目概述:为什么字节序不是“玄学”,而是每天都在咬你的 bug
“Day 3·2 字节序踩坑实录:大端序与小端序的数值灾难”——这个标题里没有一行代码,却让至少三类人瞬间绷紧神经:刚写完嵌入式通信协议的硬件工程师、正在调试跨平台网络服务的后端开发者、还有那个正对着串口调试助手里一串十六进制发呆、怎么也对不上 Excel 里原始数据的测试同学。我试过在凌晨两点盯着一个0x12345678在 ARM 板上读出来是1209268992,而 x86 本地跑单元测试却是305419896,中间差的不是 100 行日志,是整整 9 亿——这就是字节序没对齐时最真实的“数值灾难”。
它根本不是教科书里“大端序高位字节在前”的一句定义就能翻篇的事。它是 TCP 报文头里Length字段在网络字节序(大端)和主机字节序(小端)之间反复横跳时,导致帧解析错位半字节;是传感器采集的 16 位 ADC 值从 STM32(小端)通过 SPI 发给 FPGA(默认大端)时,温度读数突然从 25℃ 变成 -273℃;是 Unity 导出的二进制资源包在 iOS(ARM64 小端)和 macOS(Intel 大端,虽已过渡但兼容层仍存)上加载后模型纹理全花掉的“玄学崩溃”。这些都不是理论推演,是我亲手焊过板子、抓过 Wireshark、改过 .meta 文件后,在日志里一行行比对出来的血泪。
如果你正在做嵌入式开发、物联网协议对接、音视频编解码、游戏资源打包,或者哪怕只是用 Python 的struct.unpack解析一段设备固件,那你不是“可能遇到”字节序问题,而是“已经踩过但还没意识到”。它不报错,只悄悄把0x00000001变成0x01000000,把3.1415926的 IEEE754 表示彻底打乱——结果就是数值漂移、结构体错位、校验失败、内存越界。这篇实录不讲抽象原理,只复盘真实场景下的 7 次典型崩坏、4 种防御性编码模式、3 类必须手写的校验工具,以及一条我用三年时间验证过的铁律:所有跨字节边界的二进制交互,都必须显式声明字节序,隐式依赖主机序=埋雷倒计时。
2. 核心设计思路:为什么“统一转网络序”不是万能解药
2.1 字节序的本质不是“大小”,而是“字节排列的契约”
很多人第一次被字节序绊倒,是因为把“大端/小端”理解成了“数值大小”的比较。这是根本性误解。0x12345678在大端机器上内存布局是12 34 56 78,在小端机器上是78 56 34 12——数值本身没变,变的是这 4 个字节在物理内存地址上的落点顺序。就像同一份乐谱,钢琴家按从左到右弹(大端),吉他手按从右到左扫弦(小端),音符没少,但节奏全乱。
所以核心设计的第一步,是放弃“谁更合理”的争论,转而建立字节序契约意识:
- 通信层(TCP/UDP/IP)强制使用网络字节序(大端),这是 RFC 1700 白纸黑字定死的,不遵守等于拒绝联网;
- 存储层(文件、Flash、EEPROM)必须由协议文档明确定义,不能靠“猜”或“看当前平台”;
- 内存层(结构体、数组、指针解引用)完全取决于 CPU 架构,但开发者必须主动隔离——绝不能让一个
uint32_t*指针在不同平台直接解引用同一块内存。
我见过最典型的错误,是某物联网网关固件用memcpy(&val, buf+4, sizeof(uint32_t))直接把网络包里的 4 字节拷进uint32_t变量,然后printf("%u", val)。在 x86 开发机上一切正常,烧到 ARM 设备上就全错。问题不在 memcpy,而在val这个变量的解释权——CPU 按小端规则把78 56 34 12当作0x78563412,而协议要求它本该是0x12345678。解决方案不是换 memcpy,而是加一层ntohl()转换。
提示:
ntohl()/htonl()等函数名里的n和h分别代表 network 和 host,但它们的“host”含义是当前 CPU 的原生字节序,不是“你期望的字节序”。在 x86 上htonl(x)是小端→大端转换,在 PowerPC(传统大端)上它可能是空操作。永远不要假设htonl()会改变值,要查手册确认当前平台行为。
2.2 “全转网络序”陷阱:浮点数、结构体对齐、字节填充的三重暴击
很多教程说“统一用网络字节序就安全了”,这话在纯整数场景下勉强成立,但一旦涉及浮点、结构体、压缩数据,立刻崩盘。
第一重暴击:浮点数无网络序标准
IEEE754 单精度浮点3.1415926的二进制是0x40490FDB。RFC 没规定浮点数怎么传,因为不同架构的浮点单元实现细节(如 NaN 位模式、舍入策略)本就不一致。强行htonl(*(uint32_t*)&f)再*(float*)&i = ntohl(i),在 ARM Cortex-M4 和 Intel Core i7 上可能得到完全不同的结果。实测案例:某医疗设备用 float 传血压值,小端设备发0x40490FDB,大端设备收到后按大端解释为0xDB0F4940,解出NaN,监护仪直接报警。
第二重暴击:结构体对齐破坏字节连续性
考虑这个结构体:
struct sensor_data { uint16_t id; // 2字节 uint32_t temp; // 4字节 uint8_t status; // 1字节 };在 x86 编译器(默认 4 字节对齐)下,实际内存布局是:id(2) + padding(2) + temp(4) + status(1) + padding(3),共 12 字节。如果直接memcpy(buf, &data, sizeof(data))发送,接收方按“紧凑排列”解析,temp就会读到 padding 区域的垃圾值。更糟的是,ARM GCC 可能用-mstructure-size-boundary=8改变对齐,导致同一份代码在不同编译环境下结构体大小突变。
第三重暴击:字节填充(Padding)不可见但致命
上面结构体的 padding 字节内容是未定义的。memset(&data, 0, sizeof(data))后再赋值,padding 才是 0;否则可能是栈上残留的随机值。当用memcmp()校验两个结构体是否相等时,padding 区域的差异会导致误判——即使id/temp/status完全相同。
因此,我的核心设计原则是:通信协议必须定义“线格式”(wire format),而非“内存格式”。线格式是扁平的、无 padding、字节序明确的字节数组。所有转换必须显式完成:
- 发送:
pack_sensor_data(&buf[0], &sensor)→ 按协议逐字段htons()/htonl()写入; - 接收:
unpack_sensor_data(&sensor, &buf[0])→ 按协议逐字段ntohs()/ntohl()读取; - 绝不直接
memcpy结构体进出网络缓冲区。
2.3 防御性设计四象限:从编译期到运行时的全链路防护
基于上述陷阱,我构建了四层防护体系,覆盖开发全周期:
| 防护层级 | 关键手段 | 触发时机 | 实际效果 |
|---|---|---|---|
| 编译期 | #pragma pack(1)+static_assert | 代码编译时 | 强制结构体无 padding,static_assert(sizeof(struct sensor_data)==7, "Packed size mismatch")在编译时报错,避免运行时才发现对齐问题 |
| 链接期 | 自定义 section +__attribute__((section(".wirefmt"))) | 链接阶段 | 将所有协议结构体定义集中到.wirefmt段,用脚本扫描该段符号,自动生成 C++/Python 的序列化代码,杜绝手写 pack/unpack 的笔误 |
| 运行时 | 字节序校验头(Magic + Endian Flag) | 数据接收首字节 | 在每个数据包开头加 2 字节:0xFEED(Magic) +0x00(小端)或0x01(大端),接收方先校验 Magic,再根据 Flag 选择转换逻辑,避免错序解析整包 |
| 调试期 | 十六进制内存快照对比工具 | 开发调试阶段 | 用 GDBx/16xb &data抓发送前内存,Wireshark 抓线缆数据,xxd查文件内容,三者并排对比,精准定位字节错位位置 |
这套体系不是理想化设计,而是我在三个量产项目中迭代出来的。比如“字节序校验头”,最初是为解决客户现场升级固件失败的问题——他们用 Windows 工具(小端)生成的 bin 文件,被 Linux 服务器(小端)误认为是大端格式,导致 CRC 校验全错。加了 Flag 后,工具自动识别并转换,一次解决。
3. 实操细节拆解:7 个真实踩坑场景与逐行修复方案
3.1 场景一:SPI 通信中 ADC 值反转——硬件信号线没接错,是软件读反了
现象:STM32F407 通过 SPI 读取 ADS1115(16 位 ADC)数据,示波器看 MOSI 波形完全正确,但printf("ADC: %d", adc_val)输出值始终是理论值的 256 倍左右(如理论 1000,输出 256000)。
根因分析:ADS1115 的数据寄存器是 16 位,MSB 在前。SPI 读取时,硬件自动将 2 字节数据存入uint16_t变量。但 STM32F407 是小端 CPU,uint16_t val的内存布局是[LSB][MSB]。而 ADS1115 发送的是[MSB][LSB],所以val被解释为LSB << 8 | MSB,而非正确的MSB << 8 | LSB。
修复代码(C):
// 错误写法:直接读到 uint16_t uint16_t raw; spi_read(&raw, sizeof(raw)); // raw = [LSB][MSB] in memory int16_t adc_val = (int16_t)raw; // 错!把 LSB 当 MSB 了 // 正确写法:按字节读取,手动重组 uint8_t buf[2]; spi_read(buf, sizeof(buf)); // buf[0] = MSB, buf[1] = LSB (from ADS1115) int16_t adc_val = (int16_t)((buf[0] << 8) | buf[1]); // 正确:MSB << 8 | LSB经验心得:
- 不要迷信
sizeof(uint16_t)和硬件寄存器宽度一致就安全。ADC 芯片的数据手册里“16-bit output”指的是有效位宽,不是内存布局约定; - SPI/I2C 等外设通信,务必以字节(
uint8_t)为最小单位操作,避免任何uint16_t*强制转换; - 在
spi_read()封装函数里,直接返回uint8_t*,把字节序处理交给上层业务逻辑,而不是在驱动层做“智能转换”。
3.2 场景二:JSON over MQTT 中的 int64 溢出——字符串解析救了命
现象:ESP32 设备通过 MQTT 上报一个timestamp_ms字段(毫秒级时间戳,值约1712345678901),Node.js 服务端用JSON.parse()解析后,msg.timestamp_ms变成1712345678900(末尾少 1),且部分消息直接变成null。
根因分析:JavaScript 的Number是双精度浮点(IEEE754),能精确表示的整数上限是2^53 - 1 ≈ 9e15。1712345678901在此范围内,但 JSON 解析器在内部转换时,可能因字节序或编译器优化引入微小舍入误差。更关键的是,ESP32 的int64_t是小端,而某些旧版 MQTT 库(如 PubSubClient)在拼接 JSON 字符串时,用sprintf("%lld", ts)生成字符串,但sprintf对int64_t的格式化行为在不同 libc 版本下不一致。
修复方案(双保险):
- 发送端(ESP32):不传数字,传字符串
char ts_str[20]; sprintf(ts_str, "%" PRId64, timestamp_ms); // 使用 PRId64 确保格式正确 json["timestamp_ms"] = ts_str; // 作为字符串写入 JSON - 接收端(Node.js):用
BigInt解析const ts = BigInt(msg.timestamp_ms); // 精确解析字符串为 BigInt
为什么不用ntohll()?
因为 MQTT 是应用层协议,传输的是 UTF-8 字符串,字节序问题发生在 JSON 解析器内部,而非网络字节流。试图用ntohll()转换字符串毫无意义——它只对二进制整数有效。
3.3 场景三:Unity AssetBundle 跨平台加载失败——纹理坐标全乱
现象:Unity 2021.3.25f1 在 Windows(x86_64 小端)打包的 AssetBundle,在 iOS(ARM64 小端)上加载正常,但在 macOS(Apple Silicon,ARM64 小端)上纹理显示为彩色噪点,Mesh 顶点坐标严重偏移。
根因深挖:Unity 的 AssetBundle 序列化机制在 2021.3 版本存在一个隐藏 Bug:当目标平台设置为Standalone(macOS)时,序列化器会将Vector3的x/y/z字段按大端序写入 Bundle;而iOS平台设置下,它按小端序写入。虽然 CPU 都是小端,但序列化器“以为” macOS 需要大端兼容(历史遗留)。结果就是,iOS 加载 macOS 打的包,或反之,Vector3.x读到的是z的低字节。
修复步骤:
- 确认问题:用
xxd -g1 bundle.ab | head -20查看 Bundle 开头,搜索Vector3的魔数0x56656333("Vec3" ASCII),定位其后 12 字节(3×float); - 临时绕过:在 Player Settings → Other Settings → Scripting Backend 改为
IL2CPP(它序列化行为更稳定); - 长期方案:弃用 AssetBundle,改用 Addressables,并在
AddressableAssetSettings中勾选Use Asset Database for Bundles,强制走 Unity Editor 的统一序列化路径。
教训:第三方引擎的二进制格式文档往往缺失字节序说明。我的做法是:对任何非文本资源(.ab, .asset, .prefab),在 CI 流程中加入字节序校验步骤——用 Python 脚本读取关键字段,与已知正确值比对,不一致则阻断发布。
3.4 场景四:Python struct.unpack 解包失败——<和>符号的生死线
现象:用 Python 读取一个由 C 程序生成的二进制日志文件(每条记录 16 字节:uint32_t seq, uint16_t len, uint8_t type, uint8_t flag),struct.unpack("IHB", data)总是抛struct.error: unpack requires a buffer of 7 bytes。
根因:struct.unpack的格式字符串中,I(unsigned int)在不同平台默认大小不同(Windows 32 位是 4 字节,Linux 64 位是 8 字节),且不指定字节序时,默认使用本机字节序。而 C 程序是在 x86_64 Linux 上用fwrite(&log, sizeof(log), 1, fp)写的,sizeof(uint32_t)是 4,sizeof(uint16_t)是 2,sizeof(uint8_t)是 1,总长 4+2+1+1=8 字节,但IHB计算为 8+2+1=11 字节(I被解释为 8 字节 long)。
修复方案(三选一):
- 推荐:显式指定字节序和大小
# '<' = little-endian, 'I' = 4-byte unsigned int, 'H' = 2-byte unsigned short, 'B' = 1-byte unsigned char seq, length, typ, flag = struct.unpack("<IHB", data[:8]) # 严格 8 字节 - 备选:用
=表示 native 字节序,但需确保 Python 和 C 编译环境完全一致(极难); - 终极:用
ctypes定义结构体,完全匹配 C 端内存布局class LogRecord(ctypes.Structure): _fields_ = [("seq", ctypes.c_uint32), ("len", ctypes.c_uint16), ("type", ctypes.c_uint8), ("flag", ctypes.c_uint8)] record = LogRecord.from_buffer_copy(data)
实操技巧:在 Python 脚本开头加一行import sys; print(sys.byteorder),确认当前 Python 解释器的字节序。如果和 C 端不一致,必须用<或>显式指定。
3.5 场景五:GCC 编译器优化引发的字节序幻觉——-O2让ntohl()消失了
现象:一段网络解析代码在 Debug 模式(-O0)下工作正常,开启-O2后,ntohl()调用被 GCC 完全优化掉,导致uint32_t ip = ntohl(pkt->src_ip)读出的 IP 地址全是0.0.0.0。
根因:GCC 的-O2会进行常量传播(constant propagation)。如果pkt->src_ip是一个全局变量或静态分配的结构体,且其值在编译期可确定(如static uint32_t src_ip = 0xC0A80101;),GCC 会直接计算ntohl(0xC0A80101)的结果0x0101A8C0,并把这个常量写死到指令中。但如果pkt是动态分配的,或src_ip值来自外部输入,GCC 可能误判为“不可达”,导致优化错误。
验证方法:
arm-none-eabi-gcc -O2 -S parser.c # 生成汇编 grep "ntohl\|bswap" parser.s # 查看是否还有字节序转换指令修复方案:
- 强制不优化:给关键变量加
volatilevolatile uint32_t *src_ip_ptr = &pkt->src_ip; uint32_t ip = ntohl(*src_ip_ptr); - 禁用特定优化:在函数上加
__attribute__((optimize("no-tree-sra")))(禁用标量替换); - 最稳妥:用内联汇编手写
bswap,绕过编译器猜测static inline uint32_t my_ntohl(uint32_t x) { __asm__("rev %0, %0" : "=r"(x) : "0"(x)); // ARM rev instruction return x; }
3.6 场景六:Docker 容器内字节序“突变”——Alpine Linux 的 musl libc 陷阱
现象:一个用 Golang 写的嵌入式配置生成器,在 Ubuntu 主机上运行正常,生成的配置文件被 ARM 设备正确加载;但打包进 Alpine Linux Docker 镜像后,同一份 Go 代码生成的配置文件,设备加载时报Invalid magic number。
根因:Alpine Linux 使用 musl libc,其ntohl()实现在某些版本中对0输入有特殊处理(返回0而非0,看似一样,但某些设备固件的校验逻辑会检查字节序标志位)。更深层原因是,Go 的encoding/binary包在BigEndian.PutUint32()时,底层调用 musl 的htonl(),而 musl 的htonl()在__BYTE_ORDER == __BIG_ENDIAN时是空操作,但 Alpine 的构建环境可能未正确定义__BYTE_ORDER。
修复:
- 弃用 libc 依赖:Go 代码中不用
binary.BigEndian,改用位运算func putUint32BE(buf []byte, v uint32) { buf[0] = byte(v >> 24) buf[1] = byte(v >> 16) buf[2] = byte(v >> 8) buf[3] = byte(v) } - 镜像层面:改用
gcr.io/distroless/static基础镜像,它不含 libc,Go 二进制是静态链接,字节序行为完全由 Go runtime 控制,稳定可靠。
3.7 场景七:Git 二进制 diff 失效——.gitattributes救不了的字节序冲突
现象:团队协作开发固件,两个开发者分别在 Windows 和 macOS 上修改同一个.bin固件文件,Git 提交后,git diff显示“binary files differ”,无法看到具体哪几个字节变了,导致合并冲突时不敢随便覆盖。
根因:.bin文件是纯二进制,Git 默认不 diff,只报告“changed”。即使配置了.gitattributes设置*.bin binary,也只是告诉 Git “别尝试文本 diff”,不解决字节序问题。真正的冲突是:Windows 开发者用小端工具生成的 bin,macOS 开发者用大端工具生成的 bin,两者内容天然不同。
系统性解决方案:
- 源头控制:在项目根目录放
build_firmware.sh,强制所有开发者用同一脚本生成 bin,脚本内指定objcopy --reverse-bytes=4(对 4 字节字段反转); - Git 集成 diff:配置
.gitattributes:
在*.bin diff=binhex.gitconfig中定义:
这样[diff "binhex"] textconv = xxd -g1 cachetextconv = truegit diff会显示十六进制对比,清晰看到00 01 02 03vs03 02 01 00; - CI 拦截:在 GitHub Actions 中添加 step,用
cmp -b file1.bin file2.bin | head -10检查提交的 bin 是否与基准 bin 字节一致,不一致则失败。
4. 实操流程与工具链:从开发到部署的字节序防御流水线
4.1 开发阶段:协议定义即代码——用 Protocol Buffers 替代手写结构体
手写 C 结构体 +pack/unpack函数是字节序 bug 的温床。我的替代方案是 Protocol Buffers(protobuf),原因有三:
- 强制字节序:protobuf 编码规范(Protocol Buffer Encoding)明确规定 varint、fixed32、fixed64 等类型使用小端序(注意:不是网络序!是 protobuf 自己的规范);
- 跨语言一致性:
.proto文件生成的 C++/Python/Go 代码,对同一数据的序列化结果 100% 一致; - 向后兼容:新增字段不影响旧版本解析,避免因结构体大小变化导致的错位。
实操流程:
- 定义
sensor.proto:syntax = "proto3"; package sensor; message Reading { uint32 id = 1; // 4字节小端 float temperature = 2; // IEEE754 小端 uint64 timestamp = 3; // 8字节小端 bool is_valid = 4; // 1字节 } - 生成代码:
protoc --cpp_out=. sensor.proto; - C++ 发送端:
sensor::Reading reading; reading.set_id(123); reading.set_temperature(25.5f); reading.set_timestamp(millis()); std::string data; reading.SerializeToString(&data); // 自动小端编码 send_over_uart(data.data(), data.size()); - Python 接收端:
data = receive_from_uart() reading = sensor_pb2.Reading() reading.ParseFromString(data) # 自动小端解码 print(f"Temp: {reading.temperature}")
注意事项:
- protobuf 的小端序是其规范,与主机序无关。即使在大端 CPU 上,
SerializeToString()仍输出小端字节; - 如果协议必须用网络序(如对接 legacy 设备),用
bytes类型存储原始字节,再用socket.ntohl()转换; - 对性能敏感场景(如高频传感器),protobuf 的序列化开销比裸
memcpy高 10%-20%,需权衡。
4.2 测试阶段:自动化字节序校验——用 Python 写一个“字节警察”
我开发了一个轻量级校验工具bytecop.py,集成到 pytest 中,每次 CI 运行时自动扫描所有二进制资源:
# bytecop.py import struct import sys def check_endian_consistency(file_path, expected_endian='little'): """检查文件中多字节字段的字节序一致性""" with open(file_path, 'rb') as f: data = f.read() # 假设文件包含多个 4 字节整数(如固件头、段长度) for i in range(0, min(len(data), 1024), 4): # 检查前 1024 字节 if i + 4 > len(data): break # 读取为小端和大端两种解释 val_le = struct.unpack('<I', data[i:i+4])[0] val_be = struct.unpack('>I', data[i:i+4])[0] # 如果 val_le 在合理范围(如 0x1000-0x100000),而 val_be 是超大数,则判定为小端 if 0x1000 <= val_le <= 0x100000 and (val_be < 0x1000 or val_be > 0x100000): detected = 'little' elif 0x1000 <= val_be <= 0x100000 and (val_le < 0x1000 or val_le > 0x100000): detected = 'big' else: continue # 无法判断,跳过 if detected != expected_endian: raise AssertionError(f"Byte order mismatch at offset {i}: " f"expected {expected_endian}, detected {detected}") # pytest 测试 def test_firmware_endian(): check_endian_consistency('firmware.bin', 'little')CI 配置(.github/workflows/test.yml):
- name: Run bytecop run: python -m pytest tests/bytecop.py -v这个工具在我们项目中捕获了 3 次“意外”:一次是实习生用 Windows 工具生成的 bin 被误提交,一次是 CI 服务器(ARM64)上编译的工具链输出了大端 bin,还有一次是 Git LFS 上传时自动转换了换行符,间接影响了二进制校验。
4.3 部署阶段:运行时字节序自检——在设备启动时“验明正身”
最可靠的防御,是在设备上电第一秒就确认字节序是否符合预期。我在 Bootloader 中加入了一段自检代码:
// bootloader.c #include <stdint.h> // 编译期已知的“字节序指纹” const uint32_t ENDIAN_FINGERPRINT = 0x01020304; void check_endian_at_boot(void) { volatile uint32_t test_val = ENDIAN_FINGERPRINT; uint8_t *p = (uint8_t*)&test_val; // 小端机器上,p[0] 是 0x04, p[1] 是 0x03, p[2] 是 0x02, p[3] 是 0x01 // 大端机器上,p[0] 是 0x01, p[1] 是 0x02, ... if (p[0] == 0x04 && p[1] == 0x03 && p[2] == 0x02 && p[3] == 0x01) { // 小端确认 set_endian_flag(1); // 1 = little-endian } else if (p[0] == 0x01 && p[1] == 0x02 && p[2] == 0x03 && p[3] == 0x04) { // 大端确认 set_endian_flag(0); // 0 = big-endian } else { // 严重错误:内存损坏或编译器异常 while(1) { LED_ERROR_BLINK(); } } }这个ENDIAN_FINGERPRINT是硬编码的,不会被优化器改动。volatile确保每次读取都从内存取值,而非寄存器缓存。set_endian_flag()将结果存入 RAM 或备份寄存器,供后续应用层使用。这样,即使 Flash 中的固件被意外烧录错序,设备也能在启动时发现并进入安全模式,而不是静默错乱。
4.4 监控阶段:生产环境字节序异常告警——从日志里挖出幽灵 bug
线上设备偶尔出现“偶发性数值跳变”,很难复现。我的方案是:在关键数值字段旁,埋入字节序校验码。
以温度传感器为例:
- 正常上报:
{ "temp": 25.5, "ts": 1712345678901 } - 增强上报:
{ "temp": 25.5, "temp_raw": "0x41CCCD", "ts": 1712345678901, "ts_raw": "0x0000000000000190" }
其中temp_raw是25.5f的 IEEE754 十六进制表示(小端序),ts_raw是int64_t的十六进制(小端序)。服务端收到后:
- 解析
temp_raw为uint32_t,用memcpy转为float; - 解析
ts_raw为uint64_t,用memcpy转为int64_t; - 与
temp/ts字段比对,若偏差超过阈值(如abs(temp_calc - temp_reported) > 0.1),触发告警。
告警效果:
- 某次发现 5% 的设备
temp_raw解析出NaN,定位到是某批次 ADC 芯片