
1. 搜二进制搜出来的两种东西先把进制和二进制文件分开你如果在搜索框里敲二进制三个字返回的结果大概率会分成泾渭分明的两拨。一拨是进制转换——十进制怎么变二进制hex 转十进制怎么算负数为什么是一串 1另一拨是二进制文件——某个平台的安装包、某个可执行文件放哪、怎么指定用它打开。这两拨内容虽然共用二进制这个词但说的完全是两码事硬凑在一起看只会越看越糊。我见过不少刚入门的朋友在查十进制转二进制的时候点进一篇讲部署的文章然后开始怀疑自己是不是搜索方式不对。先把边界划清楚。本文只聊数制这一支二进制、十进制、十六进制之间的相互转换包括整数、小数、负数补码以及落到代码里怎么写。至于二进制包二进制文件某个进程的 socket 文件默认在哪这类话题属于操作系统和软件分发的范畴跟数制没有半毛钱关系这里不掺和。反过来说如果你正在做内核调试、抓包分析、CTF 逆向或者纯粹在啃计算机组成原理的作业那这篇文章里的东西就是每天都要用的基本功。我为什么觉得这个话题值得单独写一篇因为它看起来太简单了简单到很多人觉得自己会然后在两个地方翻车。第一个地方是小数0.1 转二进制永远转不干净写代码的时候一不留神就被浮点误差咬一口。第二个地方是负数脑子里记着最高位是符号位1 表示负结果手算 -128 或者读别人写的掩码时全乱套。这两个坑我自己都踩过而且都是在自以为掌握了之后踩的印象特别深。所以下面不会只给你除 2 取余这种课本答案而是把每一步为什么这么做、哪里容易错、怎么验算讲透。关键词先摆出来方便你对号入座二进制、十进制、进制转换、二进制补码、hex 转十进制、十进制小数转二进制。只要这几个词里有你正在查的往下看基本不会浪费你时间。整套内容从手算讲到代码实现从正数讲到负数和小数中间会穿插一些我在实际项目里的经验比如从一份抓包里读出来的字节流到底该怎么解释、C 语言里哪些函数能干活哪些会坑你。2. 整数转换除2取余和凑权值哪条路更省事整数转换是整个进制体系里最没有争议的部分但它恰恰是最值得先讲清楚两种方法各自适合什么场景的地方。很多人只知道一种——除 2 取余然后就一直用它遇到大的数算得满头大汗。实际上手算场景下另一种方法往往快得多只是没人系统地告诉你什么时候该换。2.1 除2取余法操作步骤和那个最容易搞反的顺序除 2 取余的逻辑一句话能说完不断把十进制数除以 2记下每次的余数直到商为 0然后把余数从下往上读。拿热词里的例子来把 217 转成二进制步骤除数运算商余数1217 ÷ 210812108 ÷ 2540354 ÷ 2270427 ÷ 2131513 ÷ 26166 ÷ 23073 ÷ 21181 ÷ 201从下往上读11011001。验证一下1×128 1×64 0×32 1×16 1×8 0×4 0×2 1×1 128641681 217对得上。这里唯一有技术含量的地方就是读取顺序。余数是先算出来的在低位、后算出来的在高位所以必须倒着读。我教过的人里至少有一半第一次是正着读的写出来是 10011011一比就错。防错的办法很简单每次写完余数就左对齐写最后一次性倒序抄一遍别边算边往右填。还有个更隐蔽的细节——除到最后一步得到的余数一定是 1除非原数是 0因为你最后一步的商必然是 1÷20 余 1。如果你算到最后一位是 0那说明中间某步除错了可以立刻回头查。这个方法的好处是无脑、可机械化、不漏位缺点也明显数字一大除法次数就多。转一个 32 位数要做 32 次除法手算容易累。所以它更适合用在代码实现里而不是人来算。2.2 二进制扩展法凑权值熟练之后快得多另一种思路完全反过来先记住二进制各位的权值然后从大到小看这个数里能塞进哪些位。同样是 217把 8 位以内的权值列出来128 64 32 16 8 4 2 1从 128 开始判断217 ≥ 128取 1剩 217 − 128 8989 ≥ 64取 1剩 89 − 64 2525 32取 025 ≥ 16取 1剩 25 − 16 99 ≥ 8取 1剩 9 − 8 11 4取 01 2取 01 ≥ 1取 1剩0拼起来还是11011001和除 2 取余完全一致但整个过程只需要减法和比较心算压力小很多。这就是网上说的二进制扩展法或者凑权值法。它的价值在于只要你把 2 的幂背熟了至少背到 2¹⁰ 1024做网络和内存相关的工作建议背到 2¹⁶ 65536大部分常见数字都能在几秒内拆出来。2.3 两条路一起用交叉验算的习惯我的习惯是手算用凑权值法出结果然后用除 2 取余的思路做个粗略核对。核对不用全算比如 217 得出的二进制是 110110018 位数最高位是 128217 落在 128 到 255 之间所以最高位必须是 1217 是奇数最低位必须是 1。这两条一验结果就八九不离十了。这里可以总结出几条快速的奇偶和范围校验规则非常实用最低位十进制是奇数二进制末尾就是 1是偶数末尾就是 0。这条几乎可以一眼判断。位数n 位二进制能表示的最大值是 2ⁿ − 1。比如 8 位最大 255所以 217 用 8 位刚好够如果你算出 8 位结果但数字其实超过 255那肯定错了。顶部区间结果最高位是 1说明这个数不小于 2ⁿ⁻¹。217 的 8 位结果最高位是 1符合它 ≥ 128 的事实。这几条规则不需要动笔就能用长期坚持会形成肌肉记忆之后你看到一串二进制基本能立刻估出它的十进制量级。反过来读别人代码里的掩码比如0x1F、0x7F时也能秒懂它在截取哪几位。3. 十进制小数转二进制0.1为什么永远转不干净小数转换是进制问题里最容易让程序员栽跟头的地方也是 AI 味教程最容易一笔带过的地方。很多资料写一句乘 2 取整直到小数部分为 0然后给个能整除的例子就结束了。问题是实际用到的数里能整除的才是少数0.1 这种最常见的数偏偏转不干净。3.1 乘2取整法完整推演一次0.1规则是这样的小数部分不断乘 2取整数部分作为二进制位用剩下的小数部分继续乘直到小数部分为 0 或达到你要的精度。先看个干净的0.6250.625 × 2 1.25 取 1剩 0.25 0.25 × 2 0.5 取 0剩 0.5 0.5 × 2 1.0 取 1剩 0从上往下读0.101。验证一下1/2 0/4 1/8 0.5 0.125 0.625干净利落。现在换成 0.10.1 × 2 0.2 取 0 0.2 × 2 0.4 取 0 0.4 × 2 0.8 取 0 0.8 × 2 1.6 取 1剩 0.6 0.6 × 2 1.2 取 1剩 0.2 0.2 × 2 0.4 取 0 ← 注意0.2 又回来了 0.4 × 2 0.8 取 0 0.8 × 2 1.6 取 1 0.6 × 2 1.2 取 1 ...从第 5 步开始0.2 → 0.4 → 0.8 → 0.6 → 0.2形成了一个长度为 4 的死循环二进制小数部分会无限重复0011。所以 0.1 的二进制是0.1₁₀ 0.00011001100110011...₂ 0011 无限循环结论很关键0.1 在二进制里是无限循环小数永远转不完。这不是精度不够的问题是数学本质。同类的还有 0.2、0.3、0.7 等等反过来 0.5、0.25、0.75、0.125 这些能写成 2 的负幂次之和的数才是干净的。判断一个十进制小数能不能在二进制里精确表示简单办法是看它写成最简分数后分母是不是只含因子 2——分母是 2 的幂就能转干净含其它质因子就不行。3.2 有限精度下截断还是舍入既然转不完实际存储时就必须在某个位置停手。这就引出热词里那个问题十进制小数转二进制有精度限制时需要考虑舍入吗。答案是必须考虑而且截断和舍入的区别在累积误差上会很明显。假设你只保留 8 位小数0.1 的二进制是0.00011001...第 9 位开始是1因为模式是 0001 1001 1001...前 8 位 00011001第 9 位是 1。两种处理截断直接砍掉第 8 位之后的内容得到0.00011001。舍入看第 9 位是 1进一位得到0.00011010。哪一种更接近真实的 0.1算一下十进制值截断结果是 0.09765625舍入结果是 0.1015625。真实值是 0.1两者误差分别是 -0.00234375 和 0.0015625舍入明显更准。这就是为什么浮点硬件和标准的转换库都采用就近舍入到偶数round to nearest, ties to even策略而不是简单截断。但这里有个反直觉的地方虽然舍入单次误差更小在大量运算里误差的方向和累积方式比单次大小更重要。截断的误差方向是固定的总是偏小或偏大会线性累积就近舍入的误差有正有负部分会相互抵消累积误差更接近随机游走的增长。做过批量数值计算的人对这点体会很深——同样是做几百万次累加截断策略跑出来的偏差往往比舍入策略大一个量级。提示如果你在做需要可复现的数值实验别自己随便选舍入模式直接用语言标准库里printf的%.Nf或者专门的十进制库它们的舍入规则经过严格验证。3.3 IEEE 754里0.1的真实样子实际存进 float 或 double 的不是我们手算的那串而是IEEE 754 格式。它以符号 阶码 尾数的形式存尾数默认有个隐含的 1。0.1 的 32 位单精度表示是0x3DCCCCCD对应二进制0 01111011 10011001100110011001101。你会发现尾数部分确实是10011的循环被截到了 23 位。双精度的 0.1 是0x3FB999999999999A。这就是为什么 Python 里0.1 0.2得到的是0.30000000000000004——两个都不精确的数相加误差露出来了。理解这一点对排查为什么钱算错了为什么循环里浮点数比较永远不相等这类问题特别有用。涉及金额、计数、需要精确比较的场景别用浮点要么用整数把元换成以分为单位的整数要么用定点或专门的十进制类型。3.4 一个可以长期用的验算小习惯转完小数之后我习惯做个粗略的量级反推把一个二进制小数按1/2, 1/4, 1/8...估一下有没有落在原数附近。比如0.00011001的主要贡献来自第 4 位和第 5 位也就是 1/16 1/32 0.09375 附近再加上后面量级更小的项约等于 0.0977跟 0.1 差不太多说明没算错位。这种粗估不需要精确但能立刻发现位数错位这种致命错误——小数点点错一位误差就是几倍量的级一看就不对。4. 负数在二进制里长什么样补码不是符号位加个1那么简单负数的表示是几乎所有人第一次学都会误解的地方。流行的说法是最高位是符号位0 表示正1 表示负。这句话在原码里成立但现代计算机不用原码用的是补码。而在补码里最高位确实能看出正负但它同时也是一个有实际权值的位不是单纯的符号标记。这个区别非常关键搞不清就会在算 -1、-128 这类数的时候卡住。4.1 从原码到补码为什么非得改先从最简单的思路开始也就是原码最高位当符号位其余位表示绝对值。8 位下5 00000101-5 10000101。看起来直观但有两个致命问题0 有两种表示000000000和10000000-0硬件判断是不是 0得多做一次比较。加减法没法统一处理要算 5 (-3)你不能直接把两个原码按二进制加法丢进去得先判断符号、比较绝对值、决定用加法还是减法电路复杂。反码是第一次修补负数的表示改成正数按位取反。5 00000101-5 11111010。它解决了减法可以转成加法的一部分问题但 0 还是有两种表示00000000和11111111。补码才是最终方案负数 对应正数按位取反再加 1。-5 就是11111010加 1 得11111011。它的好处是 0 只有一种表示00000000而且加减法完全统一——CPU 不需要区分正负直接做加法就行。4.2 -1为什么是全1用模运算看更清楚很多人第一次看到 -1 的 8 位补码是11111111会觉得莫名其妙。其实用模运算取余的角度看非常自然。8 位系统里能表示的数一共 2⁸ 256 个可以理解成所有运算都对 256 取模就像一个只有 256 个刻度的钟表。在这个模 256 的钟表上-1 就是1 往前退一格等价于1 往后走 255 格所以 -1 和 255 是同一个位置。而 255 的二进制正好是11111111。同理-2 ≡ 254 11111110-5 ≡ 251 11111011-128 ≡ 128 10000000所以负数的二进制有一个超好用的一步算法用 2ⁿ 减去这个负数的绝对值n 是位宽。-5 在 8 位下就是 256 − 5 251 11111011和按位取反加 1 的结果一致但心算更快。这个方法后来我用得比标准流程还多。4.3 补码和十进制互转的手算流程从十进制负数到补码我推荐这个顺序写出绝对值的二进制正数形式。按位取反。加 1。以 -37 为例8 位37 00100101取反得11011010加 1 得11011011。用模运算验算256 − 37 219 11011011一致。从补码到十进制先看最高位最高位是 0直接当正数算正常加权求和。最高位是 1说明是负数。先把它当无符号数算出一个值再用 2ⁿ 减去它。比如11011011无符号值是 219256 − 219 37所以它表示 -37。或者反过来取反加 1 得到绝对值的二进制再取负结果一样。两条路都对选一个练熟就行。我个人偏爱模运算那条因为它一步到位不需要先取反再加 1的两段操作。4.4 边界值 -128 和几个高频误解8 位补码的表示范围是-128 到 127注意不对称负的多一个。为什么因为10000000这个位型没有对应的正数——如果它是 -0那浪费了补码把它定义成 -128正好把两个 0省下来的位置用来多放一个负数。常见的误解有三个误解一以为10000000是 -0。错它表示 -128。误解二以为补码就是符号位 原值。错除了 0几乎所有负数都不满足这个说法。10000101在补码里不是 -5是 -123。误解三以为取负数就是翻转最高位。错那是原码的做法。补码取负是按位取反加 1。这些误解在写位运算代码时特别危险。比如你想用一个掩码取出符号位或者对负数做右移如果脑子里装的是错误的模型结果会莫名其妙。这也是读别人 C 代码里的类型转换、移位操作时为什么必须清楚底层是补码的原因。5. 十六进制当中转站二进制、十六进制、十进制三方换算实际工作里你很少有机会直接和纯二进制打交道。内存地址、字节流、颜色值、网络协议里的标志位绝大多数都以十六进制的形式出现。原因很简单二进制太长太密人眼扫起来累十进制又和二进制没有直观对应关系。十六进制刚好折中一个十六进制位精确对应 4 个二进制位。5.1 为什么工程师偏爱十六进制假设你在看一段抓包数据某个字段的值是十进制的 217。用二进制写是110110018 个字符用十六进制写是D92 个字符。长度的优势是次要的真正关键的是四位一组的整数对齐。因为 2⁴ 16每个十六进制位0-F刚好覆盖二进制的 4 位所以二进制和十六进制之间的转换是纯机械的不需要任何运算。这种对齐是十进制做不到的——你没法把二进制按固定位数切开去对应十进制位。热词里提到的hex 转十进制之所以频繁被搜就是因为在调试时你会同时看到这两种表示工具显示十六进制而日志或业务逻辑里是十进制得来回换。5.2 四位对齐法二进制和hex互相转换的机械操作二进制 → 十六进制从小数点或整数最低位开始每 4 位一组往高位分不足 4 位的高位补 0然后每组查表换成一个十六进制位。以11011001为例1101 1001 D 9结果是0xD9。再大一点的例子1011016 位10 1101 → 前面补两位0010 1101 2 D结果是0x2D。注意补 0 只在最高位组做如果从最低位开始分组时没对齐就先在最高位前面补 0 把它凑够 4 位。十六进制 → 二进制反过来每个十六进制位展开成固定的 4 位二进制直接连起来最后去掉高位的 0如果不需要固定位宽的话。0xD9D 1101 9 1001 → 11011001这张映射表值得背下来用不了几天就熟练十六进制二进制十进制000000100011200102300113401004501015601106701117810008910019A101010B101111C110012D110113E111014F1111155.3 hex转十进制的心算拆解十六进制转十进制标准做法是按位加权求和权值是 16 的幂。拿0xD9举例D × 16¹ 9 × 16⁰ 13 × 16 9 208 9 2170x2D就是2 × 16 13 32 13 45。对于位数更多的比如0x1A3F1 × 16³ A × 16² 3 × 16¹ F × 16⁰ 4096 10×256 48 15 4096 2560 48 15 6719心算时可以只用几个常用幂值16¹1616²25616³409616⁴65536。前三个背熟四位以内的换算基本秒算。这里插一句如果你做的工作偏向读取内存或网络字节流其实很多时候根本不用转成十进制直接看十六进制就行。因为字节流里每个字节就是一个两位十六进制你能一眼看出它的高半字节和低半字节做位运算时非常方便。只有需要跟业务值对照时才把它转成十进制。5.4 一个容易被忽略的坑位宽和补零十六进制的表示里前导 0 会影响位宽判断。0x0D和0xD数值上一样但如果这个值要按字节写入0x0D才是完整的 1 字节0xD半字节。做序列化或者拼字节流的时候用printf(%02X, b)这种带宽度和补零控制的格式别用%X不然一个小于 16 的值会输出单个字符导致字节错位。这种问题在生成十六进制文本文件、写协议报文时特别常见而且因为小值才出错测试时很容易漏。6. 在代码里做转换手写、标准库各自的边界手算搞清楚之后落到代码就是另一套要考虑的东西了。这里我按能不能精确表达能不能处理边界性能够不够几个维度把 C 语言里常用和容易踩坑的路径捋一遍。选 C 举例是因为它最贴近底层理解了 C 的坑换成别的语言也只是语法替换。6.1 手写除2取余一段完整的 C 代码最朴素的实现把十进制整数转成二进制字符串#include stdio.h #include string.h // 把无符号整数转成二进制字符串存入 buf确保 buf 够大 void to_binary(unsigned int n, char *buf, size_t bufsize) { char tmp[33]; // 32 位 结束符 int i 0; if (n 0) { // 单独处理 0否则结果会是空串 strncpy(buf, 0, bufsize); return; } while (n 0 i 32) { tmp[i] 0 (n 1); // 取最低位 n 1; // 右移一位 } // tmp 现在是从低位到高位需要反转 int j 0; while (i 0 (size_t)(j 1) bufsize) { buf[j] tmp[--i]; } buf[j] \0; }这段代码有三个细节值得说。第一n 1和n 1取代了除法和取余。对无符号数来说这两者是等价的但位运算更贴近硬件编译器也更容易优化成单条指令。第二必须单独处理 n 0否则 while 循环一次都不进你会得到一个空字符串而不是 0。第三注意反转因为取位时是从低位开始的写进 tmp 的顺序是倒的所以最后要倒着抄一遍。如果你换成负数情况更复杂。C 里对负数右移是实现定义的算术右移还是逻辑右移由实现决定所以你要先把负数转成对应的无符号位型再处理比如unsigned int u (unsigned int)n;然后用u去取位就能得到正确的补码表示。6.2 printf、sprintf 和 strtol能干什么不能干什么标准库把常见进制转换做得很好但二进制是它做得最差的一块这点很多人不知道。printf(%d, n)/sprintf的%d输出十进制输入时%d也能解析十进制。printf(%x, n)/%X输出十六进制小写 / 大写。配合%02x可以补零到固定宽度前面的坑就用这个解决。printf(%o, n)输出八进制。二进制标准 C在 C23 之前没有%b。想要二进制输出要么自己写上面那段要么用编译器扩展。GCC 和 Clang 在较新的版本里支持%b作为扩展但它不是可移植的标准行为换编译器可能就失效。C23 开始%b正式进入标准但老代码和旧工具链里还是得自己处理所以自己写一个to_binary反而是最稳的。strtol(str, NULL, base)把字符串解析成整数base 参数可以是 2 到 36 任意值。这意味着strtol(11011001, NULL, 2)能直接解析二进制字符串strtol(D9, NULL, 16)解析十六进制strtol(217, NULL, 10)解析十进制。这是我平时最常用的解析手段比%x更灵活因为 base 可以动态传。strtod解析带小数的字符串返回 double。用它处理二进制小数的解析时要注意二进制小数文本没法直接喂进去得自己按1/2ⁿ加权算。一个经典陷阱strtol溢出时返回LONG_MAX或LONG_MIN并设置errno如果你不检查errno会把它当成正常的最大值用悄悄出错。解析来自外部的数字字符串时这个检查必须加。6.3 strstr() 用在二进制内存上为什么不行热词里有人问strstr()能不能拿来在二进制内存里找内容答案是不能而且会出问题。strstr是字符串函数它以 NUL\0字节作为字符串结束标志。二进制内存里几乎必然包含值为 0 的字节strstr遇到它就会认为字符串到此为止后面的内容根本不看。结果就是查找范围被截断该找到的找不到不该匹配的可能匹配错。要在二进制内存里查找一段字节正确做法是用能指定长度的函数或者自己写GNU 扩展的memmemvoid *memmem(const void *haystack, size_t haystacklen, const void *needle, size_t needlelen)它按长度处理不理会 NUL。自己实现一个简单的滑动窗口匹配或者用Boyer-Moore / KMP这种算法追求性能时。C 里可以用std::search加迭代器范围。同理所有名字里带str的函数都假设数据是文本strlen、strcpy、strcmp处理二进制数据一律应该用mem系列memcpy、memcmp、memchr、memset因为它们接受长度参数不依赖 NUL 终止符。这个区分在处理加密数据、压缩数据、网络报文时是底线。6.4 其它语言里的一行写法对照换成别的语言标准库通常更省心Pythonbin(217)得0b11011001format(217, b)去掉前缀int(11011001, 2)解析回来hex(217)得0xd9。JavaScript(217).toString(2)得11011001parseInt(11011001, 2)解析。JavaInteger.toBinaryString(217)Integer.parseInt(11011001, 2)。注意 Java 里Integer.toBinaryString对负数会输出补码的全 32 位这是特性不是 bug。跨语言写代码时要注意一个差异Python 和 JS 的整数是任意精度的Java 和 C 的int是固定宽度。同样一句取反加 1在不同语言里对超出位宽的处理不一样移植代码时这是重灾区。7. 实战里最容易翻车的几个地方知识点讲完了最后说几个我在真实项目里反复见到的翻车点。这些不是理论问题是那种代码跑起来看着对换个输入就错的坑。7.1 溢出和符号从看着对到偶尔错固定位宽的整数在转换中最容易出的问题是溢出。比如你把一个 32 位无符号数当成有符号数读超过 2³¹ 的值会变成负数反过来把一个本该是负数的补码当无符号读会得到一个巨大的正数。这类错误在解析二进制协议字段时特别常见因为协议里的值往往是定宽无符号的而代码里可能用了有符号类型去接。防错的做法很朴素但有效在转换的两端都打印出值和十六进制形式对照着看。比如你期望读到 200结果读到 -56看一眼十六进制是0xC8就知道是 200 被当成了 8 位有符号数200 在 8 位补码里是 -56。有了 hex 这一层判断问题出在哪一步会快很多。7.2 从字节流里读数的顺序问题另一个高频坑是字节序。同一个 32 位整数0x12345678大端序存成12 34 56 78小端序存成78 56 34 12。如果你在解析网络报文通常大端又用了本机的小端假设去读数字会整个反过来变成0x78563412。判断方法很直接看读出来的值是不是一个大得离谱、但字节刚好倒过来的数。养成习惯解析任何多字节整数前先确认协议规定的是哪种字节序别默认。Python 里int.from_bytes(data, big)和little就是这个用途C 里则要用ntohl、htons这类函数。7.3 一份可以拿来自测的清单如果你想把上面这些点都练扎实可以照这个清单自测一遍每个都动手算或写代码验证把 217 分别用除 2 取余和凑权值法转成二进制结果都是11011001。把 0.625 和 0.1 转成二进制说清楚哪个能精确表示、哪个不能。把 -37 转成 8 位补码再用模运算验算一遍。用 C 写一个函数把有符号整数转成二进制字符串正确处理 0 和负数。用strtol解析一个十六进制字符串并检查溢出。写一段代码在包含 NUL 字节的缓冲区里查找子串用memmem而不是strstr。这几个练完你对进制转换的理解就不只停留在会算的层面而是能处理真实数据了。我个人在带新人的时候一般让他们重点练第三个和第六个因为补码和二进制内存查找是最容易在项目里出事的两个地方。前面那些手算技巧练熟之后基本是本能反应反而是这两块需要刻意形成正确的直觉。