二进制与十六进制互转及float还原:大小端、移位全解析 前阵子帮人排查一个嵌入式设备日志里面打了一串十六进制字节对方问我怎么把它还原成真实的 float 数值。说实话干这行久了这类问题见得太多但每次被问还是会感慨一句二进制和十六进制平时大家天天在看、天天在写真正能把它们从“一串数字”变成“脑子里的运算规则”的人其实没那么多。尤其当你需要直接对着 hex 数据定位一个温度值、一个加速度计读数或者把二进制文件里某几个字节翻译成人能看懂的坐标时缺少的不是计算器而是底层那几条规则——谁在前面、谁占几位、大小端怎么摆、移位丢掉的位去哪了。这篇我想把二进制和十六进制这条线完整捋一遍从为什么偏偏是这两种进制到手算互转的三个翻车点到用十六进制编辑器实操看图找偏移再到四个字节变 float 的手算过程和移位丢位问题。适合正在学编程的朋友也适合做嵌入式、网络抓包、逆向分析时经常要跟 hex 打交道的同学。我会把关键步骤的“为什么这样做”一并写清楚而不是只丢结论。1. 计算机为什么用二进制人类为什么选十六进制进制选择背后的物理与工程逻辑1.1 二进制的物理基础只有两个稳定状态先看计算机内部。无论 CPU、内存还是总线物理上能可靠识别和存储的信号本质上都是“有/无”“高/低”“通/断”这类二值状态。一块存储单元做成双稳态电路要么锁存为高电平要么锁存为低电平不存在需要精确分辨“1.7 伏”这种连续值的需求——那样又慢又容易受干扰。所以机器内部天然用二进制每一位 bit 只有 0 和 1 两种取值n 位就能表示 2 的 n 次方个不同状态8 个 bit 就是 256 种组合16 个 bit 就是 65536 种。这套规则对硬件设计极其友好晶体管开关、电位移位、存储阵列都可以按照布尔代数直接搭建。但问题也来了二进制写给人看太不友好。一个 32 位的数比如 11000000000000000000000000000000读起来谁能一眼看出它是多少在大小端、对齐、符号扩展这些场景里一长串 0 和 1 极易抄错位。于是需要一种“压缩表示”让一长串 bit 能缩短成较少的字符同时还能保持和二进制之间的快速换算关系。1.2 十六进制为什么是“天选之子”半字节对齐缩短 bit 串的进制有很多比如八进制、十进制、十六进制。为什么最终十六进制成了工程标配核心原因是 16 2 的 4 次方一个十六进制数字恰好等于 4 个二进制位。计算机内部的最小寻址单位是字节8 bit而字节的一半就是 4 bit一个字节正好可以用两个十六进制字符完整表示例如 0xFF 就是 8 个 bit 全为 1。这个“半字节对齐”的价值怎么强调都不过分。你看一个字节的数据用十进制表示可能是 137你得心算 137 到底对应 bit7 置 1 还是 bit0 也置 1但如果看到 0x89你马上知道它就是 1000 1001bit7、bit3 和 bit0 为 1其他位为 0。对做寄存器配置、协议解析、内存调试的人来说这种“位级可视化”是决定性的优势。八进制虽然 8 2 的 3 次方每个八进制数字对应 3 个 bit3 个 bit 并不能整齐切分一个字节一个字节 8 bit 在八进制下会变成怪异的 3 位一截导致 0~255 的表示长度为 3 或 4 个字符且边界错乱工程上如今很少再用了。换个角度说十六进制是“二进制的缩写”而不是“数字的另一种写法”。它最重要的作用不是给人看 255 等于 0xFF而是让人能一眼读懂某个字节内部的位模式。1.3 位的权重十六进制转十进制的真正含义很多人对“进制换算”有误解以为 0xFF 是一个和 255 长得不同、但含义相同的“符号”。这种理解没错但不够深。要真正上手必须接受一个观点进制就是一组位权规则。在十六进制里从右往左每个位置的权重依次是 16 的 0 次方、1 次方、2 次方……所以 0xFF 15×16 15×1 255。在二进制里从右往左权重是 2 的 0 次方、1 次方、2 次方……所以 1111 1111 1286432168421 255。两个数字描述的是同一个数值但编码不同。底层硬件里存储的永远只是 0 和 1十六进制只是我们把一串 bit 按 4 位分组后做的“人话翻译”。一旦想通这一点很多题目就变成机械操作了比如 0x3F 是多少3×16 15 63。再如 0x80 是 1280x100 是 2560x400 是 1024。把这些常见的十六进制幂次记熟调试时心算速度会快很多倍。2. 二进制与十六进制互转看起来只是分组实则三个翻车点都在细节里2.1 基础规则4 位一组的换算操练二进制转十六进制的标准做法是从二进制数的最低位右侧开始每 4 位一组每组转换成对应的十六进制字符如果最左边一组不足 4 位就在前面补 0。补完后顺序不变从左往右读出 hex 即可。这个做法反过来也成立每个十六进制字符展开成 4 位二进制按顺序拼接在一起就是二进制串。举例二进制 10110101011。从低位开始分组先数出 1011、然后 0101剩下最左边 10 不够 4 位补成 0010。从高到低就是 0010 0101 1011转换成十六进制是 0x25B。再举一个反向例子0x4D。4 展开为 0100D 展开为 1101拼起来就是 01001101最高位的 0 可以省略写 1001101。很多初学者会问这个 0 到底能不能省答案是不影响数值但在按字节、按内存布局表达时通常建议保留因为一个字节固定 8 位你写 01001101 才能一眼看出它占满了一个字节而 1001101 只有 7 位会让人误以为少了一位。2.2 翻车点一补位方向弄反这是新手最容易错的地方。假如要把二进制 10101 转成十六进制正确做法是从低位开始分组为 101 和 01高位补 0 后为 0001 0101结果是 0x15。但很多人会直接从最高位开始切切成 101|01结果得到 0xA1多出了整整 160。这个错误很隐蔽因为你单独看 10101 是 210x15 也是 21而 0xA1 是 161差了 8 倍都不止。记住一个口诀从右往左切不足补最左。如果是二进制小数补位则恰恰相反小数部分是从小数点往右数不足 4 位时在最右侧补 0因为小数点右侧的连续位数才是小数精度所在。2.3 翻车点二有符号数的补码表示另一个经常让人懵掉的问题是十六进制 0x80 到底是多少如果按无符号数理解是 128如果按 8 位有符号 char 理解则是 -128。计算机里的有符号整数基本都采用补码two‘s complement编码规则是最高位为符号位0 表示非负1 表示负负数等于“对应正数的按位取反再加 1”。以 -1 为例1 的 8 位二进制是 00000001按位取反得 11111110再加 1 得 11111111即 0xFF。所以常有人说 -1 在内存里就是 0xFF。再如 -128128 的二进制是 10000000取反 01111111 加 1 回到 10000000正好是 0x80。这带来一个很实际的调试经验当你从二进制文件里读出一个字节 0xFE它到底是 254 还是 -2完全取决于你怎么解释它。在 C 语言中用char读是 -2用unsigned char读是 254同一个 bit 模式解释不同结果天差地别。遇到协议数据先看清楚字段定义是无符号还是有符号再去谈换算。2.4 翻车点三大小端字节序互转还有一个隐藏门槛叫字节序。数值 0x12345678 在内存里怎么排大端机器按从高位到低位排成 12 34 56 78小端机器则按从低位到高位排成 78 56 34 12。现代 x86 和大多数 ARM 默认是小端网络协议则几乎都用大端又称网络字节序。当你打开一个二进制文件的 hex dump 时看到的一排排字节是“内存中的物理顺序”而不是“人脑习惯的数值顺序”。假如你在通用报文里看到 78 56 34 12想还原成整数得先知道协议约定是小端然后按 0x12345678 理解。如果你想在 hex 里搜一个已知数值 0x12345678在小端文件里应该搜 78 56 34 12直接搜 12 34 56 78 会一无所获。这个问题我见过无数次固件升级包、传感器数据、日志存储全栽在字节序没对齐上。3. 十六进制编辑器实战用 hxd 看文件、对比内容和定位数据3.1 编辑器布局谁在看偏移、谁在看内容光会手算换算还不够实际工作中大量场景是要打开一个二进制文件对着十六进制编辑器查看里面的内容。这里以 hxd 这个老牌十六进制编辑器为例免费版已经足够日常使用聊聊怎么看、怎么用。打开任意 .bin 或文件后界面通常分三栏最左边是偏移量offset中间是十六进制字节右边是对应的 ASCII 字符。偏移量表示当前行第一个字节相对文件头的字节位置一般用 hex 显示中间每 16 个字节一组排成一行右侧 ASCII 栏是把这 16 字节按可打印字符显示不可见的控制字符显示为点号。不要把 ASCII 栏当摆设。它最大的价值在于快速扫描看到密集的 41 42 43 这种 ASCII 区你马上知道这里有文本看到一堆 00 或者字节杂乱往往是数值数据、压缩数据或加密数据。我曾靠右侧 ASCII 栏在 4MB 的固件 bin 里十几秒定位到一个埋藏的路径字符串然后在 hex 栏直接修改对应字节整个过程不需要任何高级工具。hxd 的免费版好像有文件大小限制具体忘了多少但对学习、看小文件绰绰有余更大文件或者需要脚本批量处理时可以换用命令行工具比如xxdgrep或直接写 Python 脚本。3.2 两步识别文件类型文件头 signature 与偏移搜索打开一个没有后缀、或后缀可疑的文件第一件事就是看文件头部几个字节。很多文件格式都有固定的 magic number魔数文件类型文件头十六进制PNG 图片89 50 4E 47 0D 0A 1A 0AJPEG 图片FF D8 FFZIP / DOCX / JAR50 4B 03 04ELF 可执行文件7F 45 4C 46PDF25 50 44 46旧版 BMP42 4D对照上表你立刻就知道拿着的是什么格式。更进阶的用法是知道某类文件的某个字段固定在某偏移处直接看对应位置的 hex。比如 BMP 文件头第 18~21 字节记录像素宽度第 22~25 字节记录像素高度并且是小端存储。如果你要批量修改图片尺寸直接在 hex 编辑器里跳转到偏移 0x12 修改这几个字节比重新生成文件快得多。这就是为什么说十六进制编辑器是调试利器它把你从“依赖工具、依赖框架”里解放出来让你直接面对文件本身的字节布局。3.3 十六进制内容的比较先对齐偏移再谈差异热搜词里有“十六进制比较内容”我猜大家是想问怎么对比两份二进制内容找到它们哪些字节不同。最直觉的做法是打开两个文件肉眼逐行对比中间的 hex 列。文件小还好文件一大就瞎了。hxd 这类工具自带二进制比较功能但这里我更想讲清楚比较的几个前提第一比较必须从同一偏移开始对齐否则右侧的内容错一位全盘皆错。第二字节序要保持一致——你说“这两个文件内容是否相同”不涉及字节序但你说“某段十六进制转换成整数后是否相同”就一定要先确认协议字节序。第三大文件不要盲目二进制比较先用 SHA-256 之类的哈希确认是否完全相同哈希不同再逐段定位省时间。命令行里做字节级比较可以用cmp -l file1.bin file2.bin它会列出第一个不同字节的偏移量和左右两边的八进制值或者用xxd把两个文件转成可读的 hex dump 后用文本 diff 工具对比。我自己常用 Python 脚本一次性定位所有不同偏移逻辑很简单读文件为 bytes循环 enumerate 逐字节比较收集不同位置的索引。比较有个很常见的实际场景你在两个版本的固件包、两份网络抓包文件里找差异改动点。差异出现在哪个偏移往往就能告诉你改动的是什么字段——改了个别字节说明是参数调整插入或删除了整段字节说明数据结构变了处理方式完全不同。3.4 搜索时先想字节序再想字符串十六进制编辑器的查找功能同时支持十六进制字符串和文本字符串两种模式。如果你要搜索一个 float 在文件中的位置直接输入文本是永远找不到的必须先把 float 转换成 hex 再按字节序排好后搜索。比如 27.0 的单精度表示是 0x41D80000小端存储排成 00 00 D8 41你在编辑器里搜 hex 串00 00 D8 41才能命中。如果协议是网络字节序大端则搜41 D8 00 00。很多人会下意识把“搜索内容的显示形式”与“数据在内存中的真实布局”混淆。在 hex 编辑器里一切搜索都是针对物理字节流的你脑子里的“数值”只是对这段字节流的一种可能解释。4. 从四个字节的 hex 算出一个 floatIEEE 754 手算与常见编程方法4.1 float 在内存里的 1823 布局见过了文件、也学会了对比现在回到开头的那个需求一串 hex 怎么计算成 float。单精度浮点数在内存里占 4 个字节即 32 位。这 32 位按 IEEE 754 标准划分为三部分最高 1 位符号位 S接下来 8 位指数位 E最后 23 位尾数位 M。数值实际等于 (-1) 的 S 次方 × (1.M) × 2 的 (E-127) 次方其中 1.M 表示尾数部分前面隐含一个整数 1127 是单精度指数的偏移量。我们以 27.0 为例手工把十进制转换成十六进制。第一步把 27 转成二进制27 16821 11011₂。第二步规范化成科学计数法形式11011₂ 等于 1.1011₂ × 2 的 4 次方。第三步确定各字段符号位 S 0正数。指数部分是 4 127 131131 的二进制是 10000011。尾数部分是 1011000000000000000000023 位把 1.1011 的小数部分 1011 放在最高位后面补 0。把三段拼起来0 10000011 10110000000000000000000。按 4 位一组分组为 0100 0001 1101 1000 0000 0000 0000 0000十六进制就是 0x41D80000。这个结果意味着只要你在协议里读到四个字节按大端读成 0x41D80000就能还原出 27.0。如果按小端存储物理字节就是 00 00 D8 41。4.2 反向手算从 0x41D80000 推回 27.0逆向过程同样可以手算。拿到 0x41D80000先转成二进制并按 1823 切分0|10000011|10110000000000000000000。于是S 0正数。E 10000011₂ 131去掉偏移量 127 后得 4。尾数 M 10110000000000000000000前面补上隐含的整数 1得到 1.1011₂。再看 1.1011₂整数部分是 1小数部分是 1×(1/2) 0×(1/4) 1×(1/8) 1×(1/16) 0.5 0.125 0.0625 0.6875所以 1.1011₂ 1.6875。乘以 2 的 4 次方16得到 27.0。指数偏移 127 的设计很容易记错我提供一个记忆锚点单精度偏置是 127双精度偏置是 1023。算一次正式案例之后基本不会忘。4.3 编程时正确处理字节序别用强制类型转换走 memcpy 或 struct实际开发中很少有人真的在纸上手算 float。你更可能从串口、网口或文件里读到一个四字节数组然后希望解析成 float。这里有一个经典大坑把 float 强转成 uint32 再互换或用 union 去赋值在不同编译器和不同优化级别下可能触发未定义行为尤其是字节序不一致时更会埋雷。最稳妥的做法是逐字节拷贝代码直白且不依赖平台字节序uint32_t raw 0x41D80000; // 按协议约定先从 4 字节还原成整数 float f; memcpy(f, raw, sizeof(f)); // 字节层面重新解释在 Python 里对应更简单用 struct 模块按小端或大端解包import struct # 假设从文件/串口读到的 4 字节按小端存储 data bytes([0x00, 0x00, 0xD8, 0x41]) value_le struct.unpack(f, data)[0] # 27.0 # 如果是大端存储 data_be bytes([0x41, 0xD8, 0x00, 0x00]) value_be struct.unpack(f, data_be)[0] # 27.0为什么不用直接float.fromhex因为fromhex处理的是十六进制数字字符串例如0x1.8p3不是四个字节的内存拷贝语义完全不同别搞混。4.4 我见过最隐蔽的翻车现场日志里把 float 打印成 hex再想还原实际排查时经常会遇到这种现象设备端把一个 float 变量以字节形式打进日志打印出来的是类似0x41D80000或者00 00 D8 41这样的字符串。看着不直观调试人员习惯性地把日志里这段 hex 粘进在线转换工具以为填进去的就是最终数值。问题在于如果日志把四个字节按小端打印成00 00 D8 41工具却按大端解析得到的是 1.44e-41 这个莫名其妙的极小值方向反了结果天差地别。所以我把这类程序的铁律写在注释里打印字节流方便但解析前必须明确标注字节序并在日志格式里直接带上是小端还是大端避免几个月后自己回来查 Bug 时对着00 00 D8 41发呆。5. 移位与丢位小数点左移时最右侧的 1到底是被吃掉还是被保留5.1 二进制小数点是怎么回事广义来说二进制不一定只有整数也有二进制小数。和十进制类似二进制小数的小数点右移一位等于整体乘以 2左移一位等于整体除以 2。比如 0.01₂ 是 0.25小数点右移一位变成 0.1₂ 等于 0.5右移两位变成 1₂ 等于 1。但在整数上下文中“小数点”其实可以想象成在最低有效位LSB的右侧所有整数位往左排列。所谓“小数点左移”在数学上相当于把整个数乘以 2 的 n 次方而如果存储位置固定最右侧的二进制位就可能被移出可表示范围。热搜词里那句“小数点左移时最右侧的 1 怎么处理”说的就是这种情况一个有限位宽的数在向左移位或向右移位时超出边界的位会怎样。5.2 右移和左移的位丢失机制先说右移。假设一个 8 位整数 00000011十进制 3向右移一位结果是 00000001十进制 1。原来的最低位是 1移位后这个 1 直接“掉出”了 8 位范围无处可去。在大多数编程语言中无符号整数右移后左侧补 0右侧多余的位丢弃3 1 是 1不是 1.5因为整数根本没有地方存小数部分。这个行为不是 Bug而是位宽有限的必然结果舍去的位就是精度损失。有符号数的右移还要注意逻辑右移和算术右移的区别。无符号数的右移永远是逻辑右移补 0有符号数在很多平台x86、ARM执行算术右移左侧补符号位。比如 8 位有符号 -211111110右移 1 位在算术右移下是 11111111即 -1如果误当逻辑右移会得到 01111111即 127那就完全错了。C 语言标准中有符号整数右移的具体行为是实现定义的但几乎你遇到的真实平台都是算术右移。再说左移。整数 00000011 左移一位变成 00000110右侧补 0等于乘 2没问题。但如果最高位已经是 1比如 10000000 左移一位高位的 1 就溢出丢了变成了 00000000。这叫溢出不是循环移位也不是进位保留。很多初学者会把乘法溢出和移位溢出割裂开其实本质一样在固定位宽里一个数乘以 2 后装不进寄存器高位被截断。5.3 “为什么不让丢掉的 1 自动保留”成本和精度的权衡有人会问既然掉出去的是 1计算机就不能保留这个 1 吗答案是可以但需要额外位宽。你想保留掉出去的那些位本质上就是在使用更高精度的表示比如从 8 位换成 16 位整数或者干脆换成浮点数。系统设计永远在成本、速度和精度之间取舍。芯片不会平白无故给你的寄存器多出几个位来存放移掉的尾巴也不会自动把 31 变成 1.5——整数运算就是整数运算想要小数就要显式切换到浮点或定点数。实际工程里经常用“移位取整”来算除法比如x 1相当于x / 2向下取整。但要注意对负数来说整数除法是向下取整还是向零取整不同语言行为不一样。C 语言里-3 / 2在多数编译器结果是 -1向零截断-3 1算术右移结果是 -2向下取整两者差一个舍入方向。如果这段代码是你负责维护的线上逻辑这种差异足以引发隐蔽的边界 Bug。5.4 移位丢位场景里的工程经验四舍五入、定点数、浮点选择既然丢位避免不了工程上怎么做才能少出问题我分享几个最常用的做法第一需要四舍五入时不要直接右移先用加法做偏置。比如无符号数 x 要除以 2 并四舍五入到最近整数可以写成(x 1) 13 进位成 4右移得 2等于四舍五入2 进位成 3右移得 1没有误升。但这个方法在负数上不适用负数的偏置方向相反需要先判断符号。用整型除法语言里直接(x sign) / 2的逻辑要自己小心验证。第二精度要求更高但不想上浮点可用定点数Q 格式。比如一个 16 位定点 Q15 格式用 1 位符号 15 位小数表示 -1 到 1 之间的数。定点数的移位和加减和整数完全一样只是你心里知道小数点固定在某个位置。这在 DSP、音频处理、电机控制里非常常见移位丢掉的低位会被累计误差放大所以设计时通常会多留几位算到最后才舍入。第三真的需要高精度小数直接用浮点但记得浮点也有舍入规则。IEEE 754 的默认舍入是“最近偶数舍入”遇到正好一半时向偶数结果靠拢而不是无脑四舍五入。所以不要假设浮点运算和手算小数永远一致比较浮点是否相等要用误差范围而不是。最后聊一个通用心法任何涉及位宽边界移动的操作先问自己“允许丢失多少精度”。移位丢位不会崩溃但累积误差会。比如把一批 16 位采样值左移截断 8 位再存回短期看只是少了低 8 位而已但经过多轮滤波、平均、缩放误差就会显现为直流偏置或噪声。想在根上避免就得在一开始选择足够宽的中间变量把暂时用不到的精度保存下来到最终输出阶段再一次性舍入。拿我自己来说这些年看二进制数据看得多了最大的体会是要把“解释层”和“存储层”分开。你看 hex dump看到的是存储层物理字节按偏移排列你脑子里在算的数值是解释层按某种字节序、某种数值类型去解读。这两层之间隔着的就是二进制和十六进制互转、IEEE 754 浮点布局、移位舍入这些基本功。很多人觉得这些是理论可真正调试时卡住你的往往就是这些“太基础而没深究”的东西。建议你下次拿到一个 bin 文件先不要急着上分析工具尝试用 xxd 或 hxd 打开从文件头开始用十六进制思维去看每一个字节的含义。多试几次你会发现以前各种玄学问题其实答案都白纸黑字写在 hex 里只是你还没学会怎么读它。