☰
进制转换全解析:二进制、十六进制、十进制与八进制互转及位运算实践
2026/10/29 6:23:16 网站建设 项目流程

有位同行把一段日志甩过来,里面一串0x7FFE3A2C,他问这数到底多大。我反问他想干嘛,他说想确认第 12 位有没有被置上。问题一下就清楚了——他根本不需要知道这个数在十进制下是多少,他需要的是把十六进制翻成二进制,然后数位。进制转换被教成一门算术课,实际用起来更像一种翻译:同一个值,换一种写法,方便不同的人和不同的工具去看。二进制是机器和位运算的母语,十六进制是人眼快速扫位的缩写,十进制是脑子做大小比较时的默认语言,八进制则还残留在某些权限表示里。这篇就把二进制、十六进制、十进制、八进制之间的换算从头捋一遍,手算怎么快、代码怎么写、哪些地方最容易错,都会讲到。刚入门的人可以照着把原理吃透,写过几年代码的人也可以拿去对一对,看看自己在补码、前导零、字节序这些老地方有没有栽过。

1. 同一个数,三种看法:进制选择背后的真实取舍

1.1 内存里的值为什么总以十六进制出现在你眼前

调试器里看内存、看寄存器、看协议抓包,默认几乎都是十六进制。原因不玄乎:一个字节是 8 位,用二进制写要 8 个字符,用十进制写要 1 到 3 个字符但边界完全对不齐,用十六进制写刚好 2 个字符,两个十六进制字符就是一字节,四个字符就是两字节,肉眼扫一眼就知道长度。这种"字符数乘以 4 等于位数"的对应关系,是十六进制在工程里长期占据默认位置的根本原因,跟它比十进制"高级"没有任何关系。

再看具体场景。内存地址普遍是十六进制,因为地址空间天然按 2 的幂增长,0x1000、0x8000、0x7FFF_FFFF这类值写成十六进制时,高位在哪一段一目了然。颜色值#FF8800也是十六进制,红绿蓝各占一个字节,FF、88、00三段一眼分得开,换成十进制16746496你根本不知道从哪切。哈希值、UUID、MAC 地址同理,它们的本质都是一串字节,十六进制只是这串字节最省地方又最好切分的写法。

所以看到十六进制不要先想着"换成十进制是多少",先问自己这个值要用来干什么。如果是用来做位判断、拆字节、对协议字段,那就保持十六进制,直接翻成二进制去看。如果是用来跟人汇报大小、做阈值比较,那才需要转十进制。转换的目的决定了要不要转、转到哪一步为止,这一点想不明白,后面全是无用功。

1.2 二进制不是给眼睛看的,是给位运算看的

二进制在实际代码里极少以字符串形式出现,它出现的形态是位运算。一个 32 位的状态字,第 0 位表示开关 A,第 3 位表示开关 B,第 7 位表示错误标志,这种设计在嵌入式、驱动、协议解析里到处都是。判断某一位是否置上,写的是value & (1 << 3),取某一段连续位写的是(value >> 8) & 0xFF,置位写的是value |= (1 << 3),清位写的是value &= ~(1 << 3)。这些操作全部建立在"我知道第几位是什么"这个前提上,而这个前提只有二进制视角能给你。

这里有个常见的认知偏差:很多人以为位运算难,其实难的不是运算本身,是位的编号方向和权重的对应关系。习惯上第 0 位是最低位,权重是 1;第 1 位权重是 2;第 n 位权重是 2 的 n 次方。但有些硬件手册从最高位开始编号,或者把 bit0 画在左边,这时候同一张图翻成代码就容易错位。我自己的做法是,拿到任何一份寄存器手册,先在纸上把每一位对应的权重写成一行1, 2, 4, 8, 16, ...,跟手册的位图对齐,再动手写代码。这一步花两分钟,能省掉后面几个小时的排查。

还有一点值得强调:二进制串本身没有"长度"这个概念,长度是你给的。101到底是 5 还是 13 的低三位,取决于你声明了几位宽。代码里所有涉及位的操作,都必须先明确宽度——8 位、16 位、32 位还是 64 位,宽度不同,同一串 1 和 0 的解读结果完全不同。

1.3 八进制和十进制各自还守着哪块地盘

八进制在今天的存在感很弱,但它没死透。类 Unix 系统里的文件权限755、644、600就是八进制,因为权限正好是三组三位:属主、属组、其他人,每组用一位八进制数表示读(4)、写(2)、执行(1)的组合,三位一组恰好不超过 7,用八进制写出来最简洁。755翻译成二进制是111 101 101,一眼就能看出属主有全部权限、其他人只有读和执行。

八进制还藏在一些语言的字面量语法里。C 语言里012是十进制的 10 而不是 12,0x0A才是十六进制。这个坑极深,一个前导零就能让数值差 2。JavaScript 的严格模式下012会直接报语法错,但历史上非严格模式是当八进制处理的。Python 3 里012也是语法错误,必须写0o12。如果你接手过老代码,看到硬编码的数字带前导零,第一反应应该是确认它是不是被当成八进制解析了。

十进制的地盘则是所有跟人打交道的界面:日志里的计数、报表里的金额、配置里的超时时间、监控里的阈值。它的优势是直觉,劣势是在位操作面前完全使不上劲。1024这个数在十进制里看不出什么,写成0x400就能立刻联想到它是 2 的 10 次方,写成二进制100 0000 0000就更直白。所以老手在写常量时经常故意用十六进制表达 2 的幂,比如0x1000而不是4096,0x8000而不是32768,这是在用写法本身传递"这是个位边界"的信息。

2. 位权是唯一的钥匙:拆解所有进制转换的同一套骨架

2.1 除基取余,余数为什么必须倒着读

任意进制转换的通用算法只有一句话:整数部分不断除以目标进制,记录余数,直到商为零,然后把余数从后往前排。拿十进制 202 转二进制走一遍:

202 ÷ 2 = 101 ... 余 0 101 ÷ 2 = 50 ... 余 1 50 ÷ 2 = 25 ... 余 0 25 ÷ 2 = 12 ... 余 1 12 ÷ 2 = 6 ... 余 0 6 ÷ 2 = 3 ... 余 0 3 ÷ 2 = 1 ... 余 1 1 ÷ 2 = 0 ... 余 1

余数依次是0 1 0 1 0 0 1 1,倒过来读得到11001010。验算:128 + 64 + 8 + 2 = 202,对上了。

为什么必须倒着读?因为第一次除出来的余数,是202 = 2 × 101 + 0里的那个 0,它对应的是 2 的 0 次方位,也就是最低位。最后一次除出来的余数来自最高位那一步,对应的是最高位。所以余数的产生顺序是低位到高位,而人读写数字的习惯是高位到低位,倒序只是把顺序调回来,没有任何玄机。理解这一点,就不会出现"忘了倒序"这种低级错误——你只要记住余数产生顺序就是从低位开始的。

同一套流程换个基数就是另一种进制。202 转十六进制:

202 ÷ 16 = 12 ... 余 10(A) 12 ÷ 16 = 0 ... 余 12(C)

倒序得到CA,验算 12 × 16 + 10 = 202。注意余数 10 到 15 要写成 A 到 F,这一步是纯记号的替换,跟数值无关。

这个方法有个前提:被转换的数是非负整数。小数部分要走另一套流程,负数还要先处理符号位,这两块后面单独说。很多人写转换函数时只测了正整数就上线,遇到负数直接返回错误结果,这是很典型的遗漏。

2.2 按权展开,从哪一头算起其实有讲究

反方向,任意进制转十进制,用的是按权展开:每一位数字乘以基数的位次方,全部加起来。0x1A3F展开是:

1 × 16³ + 10 × 16² + 3 × 16¹ + 15 × 16⁰ = 4096 + 2560 + 48 + 15 = 6719

手算时从高位往低位算会更顺,因为可以边算边累加:先拿最高位 1,乘以 16 得 16,加下一位 10 得 26,再乘 16 得 416,加 3 得 419,再乘 16 得 6704,加 15 得 6719。这个方法叫"乘基加位",口算时比逐项求幂快得多,因为它只需要做乘法和加法,不需要记住 16 的几次方是多少。

这个"乘基加位"的思路值得单独记一下,因为它顺手就能写成代码:

def to_decimal(s, base): value = 0 for ch in s: digit = int(ch, 36) # 支持 0-9 和 a-z if digit >= base: raise ValueError(f"非法字符 {ch}") value = value * base + digit return value

这段代码处理to_decimal("1A3F", 16)返回 6719,to_decimal("11001010", 2)返回 202,一套逻辑通吃所有进制。注意int(ch, 36)这个写法,它把单个字符按 36 进制解析,刚好覆盖 0 到 9 和 a 到 z,比手写字符到数值的映射表省事。这种小技巧在写解析器时很实用。

二进制按权展开时还有个提速点:所有位权都是 2 的幂,而且相邻位权是两倍关系。所以从高位往低位扫,每读一位就乘 2 加当前位,11001010的过程是 1 → 3 → 6 → 12 → 25 → 50 → 101 → 202。熟练之后这个心算比查表还快,位数不多的时候完全不需要计算器。

2.3 四位一组的捷径,以及它为什么不适用于十进制

二进制和十六进制之间的转换几乎不需要计算,因为 16 等于 2 的 4 次方,每一位十六进制恰好对应四位二进制,且分组边界永远对齐。0x1A3F直接展开:

1 A 3 F 0001 1010 0011 1111

合并得到0001 1010 0011 1111,去掉前导零就是11010001100111111。反过来,二进制转十六进制从右往左每四位一组,最左边不足四位补零:11010110→1101 0110→D6。

八进制和二进制之间同理,因为 8 等于 2 的 3 次方,每三位一组。11010110从右往左三位一组,最左补零成011 010 110,对应3 2 6,得到0o326,验算 3 × 64 + 2 × 8 + 6 = 214,跟二进制算出来的 214 一致。

这里的关键问题是:为什么这套捷径在二进制和十进制之间行不通?因为 10 不是 2 的幂。10 的任意次方都除不尽 2 的任意次方,权重之间永远对不齐,所以不存在"每 N 位一组直接映射"的分组方式。二进制转十进制只能老老实实按权展开,或者用"乘基加位"逐位累加。这就是为什么很多人能秒转十六进制却卡在十进制上——不是十进制更难,是它没有免费的捷径。

反过来说,十进制转十六进制也没有捷径,必须先转成二进制再分组,或者直接用除 16 取余。实际写代码时,我一般直接调库,只有在需要向别人解释或者排查精度问题时才手算。

3. 写进代码里:位运算、格式化与边界处理

3.1 移位加掩码,把一个大数拆成字节

处理协议、解析二进制文件、操作寄存器,最常见的需求是把一个 32 位整数拆成四个字节。标准写法是移位加掩码:

value = 0x1A2B3C4D b0 = (value >> 0) & 0xFF # 0x4D b1 = (value >> 8) & 0xFF # 0x3C b2 = (value >> 16) & 0xFF # 0x2B b3 = (value >> 24) & 0xFF # 0x1A

& 0xFF这一步不能省。右移之后高位可能还残留着别的内容,掩码把除最低字节之外的位全部清零,确保拿到的就是一个干净的字面值。这个习惯在 C 里更关键,因为整数提升、符号扩展会让结果悄悄变样。

拼回去则用移位加或:

value = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0

用|而不是+,是因为各段之间不重叠,或运算语义更准确,也避免了进位干扰的顾虑。虽然在这个场景下两者结果一样,但写|能明确告诉读代码的人"我在拼接位段"。

Python 里有个特殊情况要注意:Python 的整数是任意精度且带符号的,-1 >> 8得到的是-1而不是某个大正数,因为右移对负数做的是算术移位,保留符号位。所以如果你在做字节拆分时可能遇到负数,& 0xFF就是必须的兜底:

value = -1 b0 = (value >> 0) & 0xFF # 0xFF b1 = (value >> 8) & 0xFF # 0xFF

拿到的是全0xFF,这在按补码理解时是正确的,但如果你期望的是无符号语义,就得先用value & 0xFFFFFFFF把负数规整到 32 位无符号范围内再拆。

3.2 各语言格式化输出的差异与补零陷阱

实际写日志、生成文件名、拼协议字符串,用的最多的是格式化输出,而不是手写转换函数。各语言的写法差别不小,混用的时候很容易出问题。

语言十六进制二进制补零到固定宽度
Pythonhex(x)/f"{x:x}"bin(x)/f"{x:b}"f"{x:08X}"大写,08x小写
Cprintf("%x", x)无内置,需手写printf("%08X", x)
JavaInteger.toHexString(x)Integer.toBinaryString(x)String.format("%08X", x)
JavaScriptx.toString(16)x.toString(2)x.toString(16).padStart(8, '0')
Gostrconv.FormatInt(x, 16)strconv.FormatInt(x, 2)fmt.Sprintf("%08x", x)

最容易踩的坑是补零。Python 的hex(255)返回'0xff',带前缀不带补零;f"{255:x}"返回'ff',不带前缀也不补零。Java 的Integer.toHexString(255)返回'ff',而toHexString(1)返回'1'而不是'01'。JavaScript 的toString(16)同理。如果下游要求严格的定长格式,比如哈希值必须是 64 个字符、颜色值必须是 6 位,忘记补零就会生成非法数据,而且还不会报错,只在某个值恰好高位为零的时候才暴露。

另一个坑是前缀的处理。0x前缀是给人看的记号,进入字符串比较或写入文件时通常要去掉。有些库的解析函数能同时接受带前缀和不带前缀,有些不能,比如 Python 的int("0xff", 16)可以,int("ff", 16)也可以,但int("0XFF", 0)走的是自动推断基数,行为又不一样。写跨模块的数据交换时,前缀的有无要写进接口约定,不能靠猜。

3.3 大整数与精度:什么时候不能用普通数值类型

自行实现进制转换时,很多人都忽略了一个前提:转换对象可能超出普通数值类型的表示范围。JavaScript 的Number安全整数上限是 2 的 53 次方减 1,也就是 9007199254740991,超过这个范围再加一就不准了。十六进制长串转数值时,如果超过 13 位十六进制数字,用parseInt就已经有精度损失,parseInt("FFFFFFFFFFFFFFFF", 16)得到的结果和真实值差得远,而且不会有任何警告。

处理这类情况的正确做法是全程用字符串或大整数类型。JavaScript 用BigInt,Python 3 的int本身就是任意精度,Java 用BigInteger,Go 用math/big。转换逻辑也得改成字符串层面逐位处理,不能先转数值再格式化。

// 错误:超过安全整数范围 const wrong = parseInt("FFFFFFFFFFFFFFFF", 16); // 正确:全程 BigInt const right = BigInt("0xFFFFFFFFFFFFFFFF"); console.log(right.toString(16)); // ffffffffffffffff

Python 那边虽然int不会溢出,但把大整数转字符串时要注意hex()的行为,它返回的是小写且带前缀,需要自己处理。还有一个隐蔽的问题:int.bit_length()能告诉你这个数最少需要多少位二进制,但在计算字节长度时应该用(bit_length + 7) // 8,因为位数不一定是 8 的倍数,除法之后要向上取整。这个细节在生成固定长度字节串时非常关键,取整方向搞反会导致少一个字节,写进去截断。

4. 四类高频翻车现场:从补码到字节序

4.1 0xFF 到底是 255 还是 -1

这个问题问倒了很多人:0xFF是 255 还是 -1?答案取决于你声明的宽度和是否有符号。8 位无符号,它就是 255;8 位有符号,它就是 -1,因为最高位权重是 -128,其余位加起来是 127,-128 + 127 = -1。同样的 8 个二进制位,两种解读,两种完全不同的结果。

补码规则要记牢:n 位有符号整数的表示范围是 -2^(n-1) 到 2^(n-1) - 1,无符号是 0 到 2^n - 1。8 位有符号是 -128 到 127,8 位无符号是 0 到 255。16 位有符号是 -32768 到 32767。这些边界值在解析二进制数据时经常是判断溢出的依据。

真正的事故往往发生在类型提升上。Java 里byte b = (byte) 0xFF;,b的值是 -1。如果写成if (b == 0xFF),这个条件永远为假,因为0xFF是 int 类型的 255,而b参与比较时会被提升成 int 的 -1。正确写法是if ((b & 0xFF) == 0xFF),用掩码把符号扩展的影响消掉。C 语言里char是否有符号由实现定义,同一段代码在不同编译器下结果可能不一样,涉及字节处理时应该显式用unsigned char或uint8_t。

C# 里还有个更隐蔽的问题:Convert.ToByte("FF", 16)在值超过 127 时会抛异常,因为它的解析走的是有符号路径。要处理 0 到 255 的任意字节值,得用byte.Parse("FF", NumberStyles.HexNumber)或者Convert.ToByte("FF", 16)配合 unchecked 上下文。这类问题只有真正踩过才会记住,看文档时很容易扫过去。

4.2 前导零消失引发的字符串比较事故

定长格式是很多系统的隐含约定:MAC 地址必须是 17 个字符、MD5 必须是 32 个十六进制字符、颜色值必须是 6 位。转换函数如果不补零,短值就会破坏这个约定。比如 MAC 地址00:1A:2B:3C:4D:5E,去掉补零后会变成0:1A:2B:3C:4D:5E,长度变了,后续按固定位置切分的代码全部错位。

更麻烦的是用字符串比较来判断数值相等。"0A"和"A"数值上相等但字符串不等,如果两个系统一个补零一个不补零,对账时就会出现大量"不一致"的误报。这个问题的根因在于把数值比较交给了字符串操作,修复方向应该是统一格式,而不是在比较函数里加特判。

还有一种情况是解析时的宽松策略造成的不一致。有些解析器允许跳过空白、允许0x前缀可选、允许大小写混用,有些则严格。两个服务之间传十六进制字符串,一边用0xff,一边用FF,看似都能解析,但只要有一边做了简单的startsWith("0x")判断,就会出问题。我在做接口对接时养成的习惯是,凡是涉及十六进制的字段,都在文档里写清楚四件事:有没有前缀、大写还是小写、是否定长、定长时补零在哪一侧。

4.3 同样的字节序列,两种解读

字节序的问题在进制转换里属于另一个维度,但它经常跟进制转换纠缠在一起。一个 32 位整数0x12345678,在大端机器上内存里依次是12 34 56 78,在小端机器上依次是78 56 34 12。你从网络收到四个字节12 34 56 78,如果按大端解析得到0x12345678,按小端解析得到0x78563412,两个值完全不同。

网络协议大多规定使用大端,也就是所谓的网络字节序。本地文件格式则不一定,有些历史格式沿用主机字节序。所以看到一段原始字节,第一件事是确认它来自哪里、遵循什么规范,而不是直接套用本机默认。Python 的struct模块用>表示大端、<表示小端、!表示网络序,写解析代码时把这个前缀写清楚,比事后调试强得多。

字节序问题还有个视觉上的陷阱:在十六进制编辑器里看到的字节顺序,是文件的物理顺序,不是数值的书写顺序。文件开头是12 34,数值可能是0x1234也可能是0x3412,取决于该字段的端序约定。新手经常把编辑器里看到的字节直接当成数值读,然后发现算出来的校验和永远对不上。

4.4 BCD 与浮点:进制转换能覆盖到哪

进制转换不是万能的,有两类数它处理不了,需要单独的方法。

第一类是 BCD 编码。BCD 用 4 个二进制位表示一个十进制数字,0x12在 BCD 里代表十进制 12,而不是十进制的 18。这种编码在一些时钟芯片、数码管驱动、金融系统里还在用,因为跳过二进制转换能避免舍入误差。识别 BCD 的方法是看数据格式是否规定"每字节两个十进制数字",如果规律是每个半字节都在 0 到 9 之间,基本可以确定是 BCD。转换时要拆半字节,然后按十进制拼接,不能直接当二进制数处理。

第二类是浮点数的小数部分。整数的进制转换是精确的,小数则不一定。十进制 0.1 转二进制是一个无限循环小数,任何有限位宽都存不下精确值,所以0.1 + 0.2在浮点世界里不等于 0.3。手算小数转换用乘 2 取整:0.625 × 2 = 1.25 取整 1,0.25 × 2 = 0.5 取整 0,0.5 × 2 = 1.0 取整 1,所以 0.625 的二进制是0.101,这个例子恰好能整除。换成 0.1 就会一直循环下去,必须截断。

涉及金额、计量这类要求精确的场景,不能用浮点做进制转换,应该用整数加分位的方式,或者用十进制类型(如 Python 的decimal)。这个原则跟进制转换本身无关,但很多人在写转换函数时顺手用了 float,后面才发现误差累积。

5. 手算、对照与验证:我日常用的几招

5.1 一张常用对照表和几条心算路径

手算进制转换的速度,很大程度上取决于你对几个常用值的熟悉程度。下面这张表值得背下来,覆盖了日常百分之八十的场景:

十六进制十进制二进制
0x10160001 0000
0x20320010 0000
0x40640100 0000
0x801281000 0000
0x1002561 0000 0000
0x4001024100 0000 0000
0x100040961 0000 0000 0000
0xFFFF655351111 1111 1111 1111
0x7FFFFFFF214748364731 个 1
0xFFFFFFFF429496729532 个 1

有了这张表,很多值可以拆着算。比如0x2C00,拆成0x2000加0xC00,前者是 8192,后者是 12 乘以 256 等于 3072,加起来 11264。比逐位展开快得多,也不容易算错。

二进制这边的心算路径是记住几个锚点:0x80对应最高位,0x40对应次高位,依次往下。判断一个字节里有哪些位被置上,把值减去 128,能减就说明最高位置了,余数继续减 64、32、16,一路往下。这个减法路径比按权展开更符合直觉,也不依赖乘法。

5.2 交叉验证,别相信单一来源

进制转换的错误有个特点:结果看起来都很像,不容易一眼发现。0x1A3F转十进制,算出 6719 和 6729 你未必分得清哪个对。所以我的习惯是永远用两种方式算一遍,两边对上才往下走。

第一种交叉是手算加工具。命令行一行python3 -c "print(0x1A3F)"就能验证,或者printf "%d\n" 0x1A3F也行。工具的价值不是替你算,是替你检查手算有没有错。

第二种交叉是正向反向各走一遍。算出 6719 之后,再把它转回十六进制,看是不是0x1A3F。这个反向验证对小数和负数尤其重要,因为这两类最容易在某一步漏掉符号或进位。

第三种交叉是边界值测试。转换函数写完,至少测这几个值:0、1、最大值、最大值加一(看是否溢出)、负数、负数最小值。比如 8 位无符号的边界是 0 和 255,8 位有符号的边界是 -128 和 127,全部过一遍,比测一堆中间值有用得多。

5.3 写转换代码时的自测清单

最后把我自己写进制转换相关代码时会过一遍的清单放在这里,都是踩过坑之后总结出来的:

  • 输入为空字符串时返回什么?抛异常还是返回 0,要明确,不能靠调用方猜。
  • 输入带0x前缀时怎么处理?是剥离还是报错?
  • 输入大小写混排时是否都能解析?a和A都要能识别。
  • 输入包含非法字符时是否明确报错,而不是静默截断?
  • 负数走的是补码还是原码?宽度是多少?
  • 输出是否需要定长补零?补零在左还是右?
  • 输出是否带前缀?前缀是大写0X还是小写0x?
  • 超出数值类型范围时,是否走大整数路径?
  • 小数部分是否需要支持?支持的话精度怎么定?
  • 字节序在输入输出两端是否一致?

这十条里,前四条是解析健壮性,中间三条是格式约定,后三条是边界和精度。我自己遇到过的最难查的一次问题出在第七条:某个内部工具生成的十六进制带0X大写前缀,而接收方的解析函数只判断了小写0x,结果前缀被当成数据的一部分去解析,报了一个"非法字符 X"的错误。从错误信息完全看不出是前缀的问题,最后是靠打印原始输入才定位到的。

进制转换本身不难,难的是它总是出现在各种边界的交汇处:有符号和无符号的交界、定长和变长的交界、本机字节序和网络字节序的交界、整数和小数的交界。把这些交界处想清楚,比背熟转换公式有价值得多。我现在写这类代码,第一步已经不是算,而是先问清楚这个值从哪里来、要到哪里去、两头对格式的约定是不是一致,这三问答完,代码基本也就写对了。

<全文完>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询