十六进制、二进制与十进制:程序员真正在用的数字读法
一个颜色是 #FF5733。一个文件权限是 chmod 755。一次权限检查是 flags & 0x04。一个常量是 0b1010、或 0xFF、或 255。每一种看着都像自成一派的方言,其实它们是同一个数字换了身衣服——而让这一切不再是摩擦的那点熟练,比看上去要小得多:字节用十六进制,位用二进制,给人看用十进制。
你一次又一次遇见同一批数字,只是每次换了身衣服。一个颜色是 #FF5733。一个文件权限是 chmod 755。一次权限检查读作 flags & 0x04。一个常量是 0b1010、或 0xFF、或 255。每一种写法看着都像自成一派的小方言,而当你需要在它们之间来回——0xFF 真的是 255 吗?chmod 755 是不是我以为的那个意思?——你就去抓一个转换器,或者掰手指头。让这一切不再是摩擦的那点熟练,比看上去要小得多,而它从一个想法开始。
想法就是:十六进制、二进制、十进制不是三种数字——它们是同一个数字的三种写法。255、0xFF、0b11111111 是一个值的三种记法,正如「十五」、「15」、「XV」是一个值的三种记法。一个数字并不拥有一个进制;一个被写下来的数字才有。所以真正的本事不是转换——工具一键就能完成转换——而是知道你为什么要切:你挑那个能让这个数字的结构显出来的写法。当这个数字其实是一堆字节时(颜色、哈希、地址),用十六进制。当单个位本身就承载含义时(标志位、掩码、硬件),用二进制。当只是要给人读一个计数时,用十进制。
这篇文章先花一分钟讲清进制到底是什么——是分组,不是身份——再讲为什么一位十六进制恰好是四个比特,正是这一点让十六进制成为一扇望向二进制的紧凑窗口。然后是你真正会碰到它们的地方:颜色、chmod、位掩码。再然后是那些只有当你能看见比特时才讲得通的位运算,以及那些当你看不见时会咬你的坑——有符号数、字节序。
唯一的核心:进制是你怎么分组,不是这个数字本身
每一个数字都是对某样东西的计数。一个进制,不过是你在开一个新列之前,把它们分组进去的那个「桶」的大小。在十进制(base 10)里,每一列是 10 的幂:255 = 2×100 + 5×10 + 5×1。在十六进制(base 16)里,每一列是 16 的幂,数字用 0–9、再用 A–F 表示十到十五:0xFF = 15×16 + 15×1 = 255。在二进制(base 2)里,每一列是 2 的幂:0b11111111 = 128+64+32+16+8+4+2+1 = 255。每一次都是同一个 255。值就是那个值;进制只决定它怎么拼写——这也是为什么「把十六进制转成十进制」从来不改变那个数字,只改变你看它的视角。
十六进制:一扇望向字节的紧凑窗口
程序员为什么动不动就用十六进制,归结到一个漂亮的巧合:16 是 2⁴,所以一位十六进制恰好是四个比特——一个半字节(nibble)——而两位十六进制恰好是一个字节(8 位,0–255,0x00–0xFF)。正是这份对齐,是它全部的吸引力。一个用十进制写的字节(77)对它的比特只字不提;同一个字节写成十六进制(0x4D)则干净利落地拆成两个半字节,每个半字节对应你可以直接读出来的四个比特。拿 0x2F 自己算一遍:拆成两个半字节 2 和 F,展开成整字节 0010 1111,再把为 1 的那些列加起来——32 + 8 + 4 + 2 + 1 = 47。所以十六进制其实就是给人看的二进制——字节和比特的全部结构,用四分之一的位数呈现。这就是为什么哈希、颜色、内存地址、字节转储都用十六进制写:它们是一堆字节,而十六进制把字节显出来。
二进制:当单个位本身就承载含义
有时你要的不是那个紧凑视图——你要的就是比特本身,因为每一个都意味着某样东西。那就是二进制,base 2,每一位就是一个比特:开或关、置位或清零。当一个数字压根不是一个数量、而是一组各自独立的开关时,你就降到二进制——一份权限集、一个功能开关整数、一个第 3 位启用某功能、第 6 位表示「出错」的硬件寄存器。在那些场景里,十进制的 13 什么有用信息都没告诉你,而 0b1101 说的是第 0、2、3 位是开的——这才是这个数字真正在搬运的信息。
你真正会碰到它们的地方
三个日常例子把这些概念具体说明:
- 颜色是三个字节的十六进制。
#FF5733就是FF 57 33——红0xFF(255)、绿0x57(87)、蓝0x33(51),每个都是一个 0 到 255 的字节。#RRGGBBAA再加第四个字节表示透明度。一旦你把颜色看成三个字节,#FFFFFF(每个字节都拉满 → 白)和#000000(全零 → 黑)就不再神秘。 - Unix 权限是八进制——第三种进制。
chmod 755既不是十进制也不是十六进制;它是八进制(base 8),每一位恰好是三个比特:r w x。7是111= 读+写+执行;5是101= 读+执行、不可写。所以755就是给「属主/同组/其他」的rwx r-x r-x。八进制之所以在这里活下来,恰恰因为「每位三比特」正好对上那三个权限位。(现代代码把它写成0o755。) - 标志位是你用位运算去测的比特。一组被塞进一个整数的选项,每个选项是 2 的幂、于是各占一个比特:
READ = 1 (0b001)、WRITE = 2 (0b010)、EXEC = 4 (0b100)。你用 OR 把它们合起来(READ | WRITE→0b011),用 AND 测其中一个(flags & WRITE在那一位置位时非零),清掉一个则跟它的反码做 AND。flags & 0x04里的那个0x04,不过是第 2 位披了件十六进制的外衣。
位运算:一旦看得见比特就一目了然
这些运算按位逐个进行,而当你把操作数写成二进制或十六进制的那一刻,它们就从晦涩变得直观:
- AND(
&)——只有两边都是 1 的位才是 1。用来测位或掩码:x & 0xFF保留低字节、把其余清零。 - OR(
|)——任一边是 1 就是 1。用来置位:x | 0x04把第 2 位打开。 - XOR(
^)——两边不同的位才是 1。用来翻转位,也出现在一些简单的校验算法里。 - NOT(
~)——把固定位宽内的每一位都翻过来,所以结果取决于类型有多少位宽。 - 移位(
<<、>>)——把比特整体往左或往右滑。对无符号数、且没发生溢出时,x << 1相当于 ×2、x << 8把一个值整整抬高一个字节;对非负数,x >> 4丢掉低四位。(对有符号数,右移可能做符号扩展,具体行为取决于语言。如果需要可预测地处理位字段,优先使用无符号类型,并遵循具体语言对右移和溢出的规定。)移位正是你打包和拆包字段的方式——(r << 16) | (g << 8) | b就把三个各自独立的字节拼成一个颜色。
试试 0b1100 & 0b1010,答案明明白白是 0b1000;换成十进制的 12 & 10,就成了一道谜题。这就是在做位运算之前先切换写法的全部理由。
容易出错的地方
有两处,这套整齐的图景会失效,两处都值得知道:
- 有符号 vs 无符号,以及二的补码。同一串比特,可以按无符号读,也可以按有符号读。计算机用二的补码(two’s complement)存负数,在固定位宽的表示里,最高位当符号——所以在 8 位二的补码里,字节
0xFF按无符号是255,按有符号是-1。这正是那个经典 32 位溢出背后的机制:一个有符号的 32 位计数器封顶在0x7FFFFFFF,再加一步就会越过它、翻向一个很大的负数——这也是为什么许多用有符号 32 位整数存时间的 Unix 系统会撞上 2038 年问题。(至于语言怎么应对这次溢出,各不相同——有的回绕,有的抛错,有的是未定义行为。) - 字节序(endianness)。当一个数字跨越好几个字节时,这些字节可以按大端(最高位字节在前)或小端(最低位在前)存放。值
0x01020304在大端布局里存成01 02 03 04,在小端布局里存成04 03 02 01——所以一段看着「反了」的字节转储,往往不是损坏,只是小端。
还有两个小便利收个尾。前缀 0x(十六进制)、0b(二进制)、0o(八进制)存在,是为了让一个写下来的数字自报进制——0x10 是 16,0b10 是 2,10 是十。而十六进制或二进制里的前导零只是宽度:0x0F 和 0xF 是同一个值,那个多出来的零只是补齐到一个整字节、方便看。
快速参考
当你在实际代码里遇到一个看着陌生的数字,先把它归位,再动手算:
- 先读前缀。
0x十六进制,0b二进制,0o(或老 C 里前导的0)八进制,没有前缀 → 十进制。六位十六进制前面一个#→ 一个 RGB 颜色。 - 让进制去对上结构。字节(颜色、哈希、地址)→ 十六进制。各自独立的位(标志、掩码、寄存器)→ 二进制。给人看的计数 → 十进制。权限 → 八进制。
- 一位十六进制 = 4 比特,两位 = 一个字节。就这一个事实,就能在脑子里把大多数十六进制转成比特、再转回来。
- 要做位运算,先切到二进制或十六进制,然后用 AND 测/掩码、OR 置位、XOR 翻转、移位挪字段。
- 值看着离谱地不对时,想想有符号和字节序——一个本该很小却巨大的数,往往是把有符号当无符号读了,或者字节是另一种端序。(一条乱掉的 URL 里那些
%XX对,也是十六进制字节——同一套本事就能读它们。)
至于超出心算的一切——一个 64 位掩码、一个不常见的进制、核对一个二的补码值——一个进制转换器会把十六进制、十进制、二进制、八进制并排列出来,让你一眼就看见同一个值在每种记法里的样子。这一切底下,还是那个唯一的核心:它们是同一个数字换了身衣服,而所谓熟练,不过是知道哪身衣服能让结构显出来。