前段时间调一个串口通信程序,设备手册里写着"状态寄存器返回 0x3F7,低 8 位是错误码,高 4 位是设备类型"。结果同事直接把这个十六进制数当十进制打印出来,成了 3.977,折腾了大半天才定位到问题是进制转换没做。这种事儿在嵌入式、网络协议、底层开发里真的太常见了。进制转换看着是大学一年级的基础课,但真到了实战里——二进制小数要怎么精确转十进制?负数转十六进制为什么有时候是 FFFFFFFB?八进制和十六进制之间能不能绕开十进制直接换?字符串形式的任意进制互转在 C 语言里怎么写才不容易踩坑?——能一次说清楚的人真不多。
这篇我把整数、小数、正数、负数、任意进制相互转换全部过一遍,从位权原理讲到手算步骤,再给出可编译运行的 C 语言完整实现。适合刚学计算机基础的学生巩固原理,也适合写驱动、做协议解析、处理数据编码的工程师当工具文收藏。
1. 进制到底在说什么:位权机制是唯一核心
很多人学进制转换死记"8421"法则,换一个基数就懵。根源在于没有理解位权。所谓位权,就是数字里每一个位置所代表的实际大小。十进制里我们天天用却不觉得——123 从右往左依次是个位(10^0)、十位(10^1)、百位(10^2),所以 123 = 1×10^2 + 2×10^1 + 3×10^0。这个"10^n"就是十进制每一位的位权。
1.1 位权的排布规律
对任意进制 base,从小数点往左数,整数部分的第 0 位权重是 base^0,第 1 位是 base^1,第 2 位是 base^2,依次类推。从小数点往右数,小数部分的第 1 位权重是 base^(-1),第 2 位是 base^(-2),依次类推。
这里有几个新手常犯的错误。第一,整数位从 0 开始编号,不是从 1 开始。第二,小数位第一位权重是 base^(-1),也就是 1/base,不是 base 分之一再除以 base。第三,位权只取决于位置和进制,跟这位数字本身没关系——数字的值只是位权的倍数。
拿二进制 1101.01 举例,从最左边开始:1 × 2^3 + 1 × 2^2 + 0 × 2^1 + 1 × 2^0 + 0 × 2^(-1) + 1 × 2^(-2)。你先别急着算结果,把这个展开式记住,它就是后面所有转换公式的祖宗。
1.2 用多项式视角统一所有进制
把任意进制数字拆开看,本质上就是一个多项式:
d_n × base^n + d_(n-1) × base^(n-1) + ... + d_1 × base^1 + d_0 × base^0 + d_(-1) × base^(-1) + ...其中 d_i 是第 i 位的数字,base 是基数。为什么说这个视角重要?因为它把"进制转换"从一个需要分别记忆多个口诀的问题,统一成了一个求多项式值的问题。任意进制转十进制,就是直接把这个多项式算出来;十进制转任意进制,就是把这个多项式按照新的进制重新展开;任意进制之间互转,本质上就是反复进行"多项式求值"和"多项式重展开"两个动作。
理解了这一点,你就不会再被"二进制转八进制要三位一组""八进制转十进制要乘以 8 的幂"这类碎片化口诀困住,所有操作都能从原理推导出来。我见过很多工作多年的同事,手算十进制转二进制飞快,但让他解释"为什么除 2 取余要逆序排列"就又说不清了——因为他只背了口诀,没吃透多项式展开。下面我按照最实用的路径,把每一步都拆开讲。
2. 任意进制转十进制:按权展开的通用公式
这一节处理的问题是:给你一个字符串形式的数,比如"2A.3F",进制是十六进制,让你算出它在十进制下是多少。思路只有一个:每一位乘上自己的位权,然后全部加起来。
2.1 整数部分的展开过程
整数部分按权展开,从右往左依次乘 base^0、base^1、base^2……这里用几个实例演示整个计算步骤。
十六进制 0x2A 转十进制:
2A = 2 × 16^1 + A × 16^0 = 2 × 16 + 10 × 1 = 32 + 10 = 42A 在十六进制里代表 10,这个必须记牢。十六进制用 A~F 表示 10~15,字母不分大小写,但很多程序里大写更规范,后文代码也统一输出大写。
八进制 0755 转十进制:
755 = 7 × 8^2 + 5 × 8^1 + 5 × 8^0 = 7 × 64 + 5 × 8 + 5 × 1 = 448 + 40 + 5 = 493这个例子在 Linux 文件权限里非常常见,chmod 755的权限数值就是按八进制语义理解的,改成十进制去看反而别扭。
二进制 10110011 转十进制(从右往左算更不容易出错):
位: 1 0 1 1 0 0 1 1 权: 2^7 2^6 2^5 2^4 2^3 2^2 2^1 2^0 值: 128 0 32 16 0 0 2 1 和 = 128 + 32 + 16 + 2 + 1 = 179实际操作的时候我习惯从右往左逐位累加:1 + 2 + 0 + 0 + 16 + 32 + 0 + 128 = 179。这个方向能避免写错 2 的幂次。
2.2 小数部分的展开过程
小数部分的关键是位权是负指数。二进制小数点后第 1 位代表 1/2,第 2 位代表 1/4,第 3 位代表 1/8,这个数列是 1/2、1/4、1/8、1/16……每一项是前一项的一半,跟直觉完全吻合。
二进制 101.101 转十进制:
整数部分:1×2^2 + 0×2^1 + 1×2^0 = 4 + 0 + 1 = 5 小数部分:1×2^(-1) + 0×2^(-2) + 1×2^(-3) = 0.5 + 0 + 0.125 = 0.625 结果 = 5.625注意,二进制小数能精确表示的值非常有限。0.1、0.2 这样的十进制小数在二进制下是无限循环的,所以浮点数计算会出现 0.1 + 0.2 != 0.3 的现象。这一点在第三节细说。
十六进制 3.A 转十进制:
3.A = 3 × 16^0 + A × 16^(-1) = 3 + 10/16 = 3 + 0.625 = 3.625这里有个受益终身的对应关系值得记住:二进制小数0.1是十进制0.5,十六进制小数0.8是十进制0.5,八进制小数0.4是十进制0.5。每个进制的小数点后一位都等于 1/base,理解了这条,其他对应关系都能秒推。
2.3 常用进制对照速查表
手算和调试时经常要快速查值,下面这个表覆盖了 0~15 在四种常见进制下的表示,建议直接保存在笔记里。
| 十进制 | 二进制 | 八进制 | 十六进制 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 1 | 1 | 1 | 1 |
| 2 | 10 | 2 | 2 |
| 3 | 11 | 3 | 3 |
| 4 | 100 | 4 | 4 |
| 5 | 101 | 5 | 5 |
| 6 | 110 | 6 | 6 |
| 7 | 111 | 7 | 7 |
| 8 | 1000 | 10 | 8 |
| 9 | 1001 | 11 | 9 |
| 10 | 1010 | 12 | A |
| 11 | 1011 | 13 | B |
| 12 | 1100 | 14 | C |
| 13 | 1101 | 15 | D |
| 14 | 1110 | 16 | E |
| 15 | 1111 | 17 | F |
提示:这张表只需要记住"二进制 1000 = 八进制 10,但 1000 看起来像十进制一千"这个反直观点,就能避免以后调试权限位时把 0755 和 755 混为一谈。
3. 十进制转任意进制:整数除基、小数乘基的分治流程
从十进制转出去,整数部分和小数部分的算法完全不同,必须先分开再合并。整数部分是"除法",小数部分是"乘法",方向也相反——这是整篇最容易绕晕的地方。
3.1 整数部分:除基取余,逆序排列
算法口诀六个字:除基取余,逆序。举个完整例子,十进制 42 转二进制:
42 ÷ 2 = 21 余 0 ← 最低位 21 ÷ 2 = 10 余 1 10 ÷ 2 = 5 余 0 5 ÷ 2 = 2 余 1 2 ÷ 2 = 1 余 0 1 ÷ 2 = 0 余 1 ← 最高位从下往上读取余数,得到101010。验证一下:1×2^5 + 0×2^4 + 1×2^3 + 0×2^2 + 1×2^1 + 0×2^0 = 32 + 8 + 2 = 42,正确。
**为什么余数要逆序?**你想,第一次除以 2 的余数,是原数里 2^0 位上的值;第二次的余数是 2^1 位上的值;最后一次的余数是最高位。而我们写数字的时候最高位在最左边,所以必须把先算出来的低位放在后面,最后算出来的高位放在前面。
**如果目标是十六进制,同样操作但除数换成 16:**十进制 493 转十六进制——
493 ÷ 16 = 30 余 13 → D 30 ÷ 16 = 1 余 14 → E 1 ÷ 16 = 0 余 1 → 1逆序得到0x1ED。这个案例可以反向验证:1×256 + 14×16 + 13 = 256 + 224 + 13 = 493,成立。
3.2 小数部分:乘基取整,顺序排列
算法口诀也是六个字:乘基取整,顺序。**注意,这次是正序,不要逆序。**举例,十进制 0.625 转二进制:
0.625 × 2 = 1.25 取整数位 1 ← 第一位 取掉整数后剩 0.25 0.25 × 2 = 0.5 取整数位 0 ← 第二位 0.5 × 2 = 1.0 取整数位 1 ← 第三位正序读取结果0.101。验证:0.5 + 0 + 0.125 = 0.625,完美。
**为什么小数要正序?**第一次乘以 2 得到的整数部分是小数点后第一位的值(权重 2^(-1)),第二次得到的是小数点后第二位(权重 2^(-2)),依次类推。小数部分的位权从左到右递减,所以算出来的顺序正好就是写出来的顺序,不用倒。
3.3 浮点精度会造成哪些假象
十进制小数转二进制时,不是所有小数都能有限位数精确表示。最经典的是 0.1:
0.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 × 2 = 1.2 取 1 0.2 × 2 = 0.4 取 0 …… 从这里开始循环0.1 的二进制展开是无限循环的0.0001100110011001100...,永远写不完。计算机里用 double 存储时只能截取 52 位有效数字,所以浮点数本质上都是近似值。做进制转换工具时,小数部分必须设置一个"最大精度"参数,比如转换 20 位就停下来,否则会出现死循环,或者因为浮点误差算出 0.9999999999 这种尴尬值。
还有一个实际开发中的坑:当小数乘基后得到 0.999999999999 或 1.0000000001 时,取整会得到错误的一位数。严谨的转换程序需要在取整前做一个 epsilon 容差判断——如果 frac 距离 1 的差距小于 1e-10,就把它当作 1 处理,同时向高位进位。这个细节我在第六节的代码里给出了处理方式。
4. 2的幂进制间互转:三位一组和四位一组的快捷规则
如果要把二进制转八进制,最笨的办法是先把二进制转十进制,再把十进制转八进制。但这个流程对 2 的整数次幂进制(二进制、四进制、八进制、十六进制、三十二进制)来说纯属绕路,因为它们之间存在天然的分组对应关系。
4.1 为什么八进制和十六进制这么特殊
八进制的基数是 8 = 2^3,所以1 位八进制数精确对应 3 位二进制数。十六进制的基数是 16 = 2^4,所以1 位十六进制数精确对应 4 位二进制数。这个对应关系不存在任何进位误差,可以直接查表或分组完成。
反过来也成立:二进制转八进制,从小数点开始分别向左、向右每 3 位一组,不够位数用 0 补齐。二进制转十六进制,同样规则但每 4 位一组。
4.2 转换对照表和速算技巧
十六进制和二进制的高位对应关系是最常用的,把下面这行刻进脑子里:
0x0 = 0000 0x4 = 0100 0x8 = 1000 0xC = 1100 0x1 = 0001 0x5 = 0101 0x9 = 1001 0xD = 1101 0x2 = 0010 0x6 = 0110 0xA = 1010 0xE = 1110 0x3 = 0011 0x7 = 0111 0xB = 1011 0xF = 1111做几个完整示例。二进制 110101110.1011 转八进制:
整数部分从右往左每 3 位分组:110 101 110,分别对应6 5 6。小数部分从左往右每 3 位分组,101 100(最后一组补两个 0 凑成 3 位),对应5 4。所以结果是656.54(八进制)。
二进制 110101110.1011 转十六进制:
整数部分从右往左每 4 位分组:0001 1010 1110(最前面补三个 0),对应1 A E。小数部分1011,对应B。结果是0x1AE.B。
注意:对二进制小数分组时,方向是相反的。整数部分是从右往左分组,小数部分是从左往右分组。很多人第一次转小数就不管三七二十一从右往左分,结果全部错位。
4.3 实战场景:颜色值、权限值、协议包头
- HTML/CSS 颜色值:
#FF8800本质是把红色 FF、绿色 88、蓝色 00 三个十六进制字节拼接在一起。想在代码里快速判断颜色变化,先把 FF=255、88=136、00=0 这三个十六进制转十进制,立刻就知道 RGB 值是多少。 - Linux 文件权限:
chmod 754和chmod 0754在权限位上其实有微妙区别,但八进制转二进制时一目了然:7=111(读+写+执行)、5=101(读+执行)、4=100(只读)。 - 网络协议包头解析:以太网协议类型字段
0x0800表示 IPv4,0x86DD表示 IPv6。抓包工具显示的十六进制,你是先字节组再转十进制,还是直接心算?经常做协议解析的人,对 0800、0806、86DD 这几个值必须形成肌肉记忆。
5. 负数参与进制转换:补码机制与符号位处理
负数是最容易让人翻车的环节。核心问题在于:在计算机里"负号"不是一个可以存储的自然概念,数字的符号必须用编码规则表达。于是出现了原码、反码、补码三种表示方式,而现代计算机几乎全部采用补码。
5.1 计算机为什么不用"负号"
假设 8 位二进制,如果按"原码"设计,正数 1 是00000001,负数 -1 就是10000001(最高位当符号位)。这样做的后果是:
00000000和10000000分别表示 +0 和 -0,出现了两个零,浪费编码空间。- 减法无法直接用加法器实现。
1 + (-1)用原码算:00000001 + 10000001 = 10000010,结果是 -2,完全错误。
补码方案解决了这两个问题。负数的补码定义是:该数绝对值取反加一。在补码体系里,减法和加法完全统一,CPU 只需要一套加法电路,这直接决定了计算机硬件可以做得更简单更快。
5.2 从原码到补码的标准流程和手算实例
给定十进制 -5,要求 8 位二进制补码,步骤是:
第一步 写出绝对值 5 的二进制原码: 0000 0101 第二步 按位取反(0变1,1变0): 1111 1010 第三步 加 1: 1111 1011所以-5 的 8 位二进制补码是 11111011。验证一下:把它当无符号数看是 251,而 251 - 256 = -5,说明这个编码表示的确实是 -5。这个"无符号值减 2^n"的验证法在调试时非常有用,后面代码里我就用这个逻辑做补码输出。
**十六进制场景:int32_t 类型的 -5 在内存里是 0xFFFFFFFB。**如果你用%x格式打印一个 int 变量,得到的是:
-5 的 32 位补码 = 11111111 11111111 11111111 11111011 = 0xFFFFFFFB很多初学者看到-5打印成FFFFFFFB以为是程序 bug,实际上这是补码的直接呈现。
5.3 补码形式和带符号形式怎么选择
写进制转换工具时,你要回答一个关键问题:负数转进制,输出-101还是输出补码11111011?
- 面向人类阅读(比如打印日志、教学演示),用带符号形式,先转绝对值再加负号,输出
-101、-0x2A。 - 面向机器存储(比如写文件、协议封包、寄存器值),必须用补码形式,且一定要同时指定位数宽度。8 位、16 位、32 位、64 位下,同一个负数的补码完全不同。
两者混淆是生产事故的重灾区。我见过一个同事把协议里的负数直接按带符号形式转成-3去解析,结果报文解析整体偏移了 4 个字节。所以代码实现里我会同时提供两种输出模式,由调用方决定。
5.4 补码的回推验证
手算补码容易出错,必须学会回推验证。已知补码11111011,想知道它是哪个负数:
第一步 减 1: 1111 1010 第二步 按位取反: 0000 0101 = 5 第三步 加上负号: -5这就完成了补码到十进制负数的还原。跟正向流程对称,正向是"取反加一",反向是"减一取反"。
提示:判断一个补码是正还是负,看最高位。最高位是 0 就是非负数,是 1 就是负数。这个规则在后续写代码判断符号时是核心逻辑。
6. C语言实现任意进制互转:一份可直接编译的参考代码
网络热搜里"进制转换 C 语言"一直是高频词,说明很多人卡在代码实现上。这里给出一份完整、可编译、支持正负整数和小数、支持 2~36 进制的参考实现。整体设计思路是:任意进制 A 的数先转成十进制,再转成目标进制 B,这样做能保证代码通用性和正确性。
6.1 函数拆分与设计思路
我把功能拆成四个独立函数,各司其职:
| 函数名 | 职责 | 关键点 |
|---|---|---|
char_to_val/val_to_char | 数字字符与 0~35 数值互转 | 支持大小写,统一大写输出 |
any_base_to_dec | 字符串形式的任意进制数转十进制 | 处理+/-符号,自动纠错非法字符 |
dec_to_any_base | 十进制整数转任意进制 | 处理负数绝对值,输出带符号形式 |
dec_to_twos_with_bits | 十进制整数按指定位宽输出补码 | 只对 2 的幂进制生效 |
frac_to_any_base | 十进制纯小数转任意进制 | 加入 epsilon 容差,防无限循环 |
设计上有一个取舍要说清楚:整数和小数分开处理。因为 C 语言里long long只能表示整数,浮点数需要单独的字符串拼接逻辑。任何进制的数一旦混入小数位,就先以字符串形式解析成"整数部分 + 小数部分",分别转换后拼接。
6.2 完整代码与逐段说明
#include <stdio.h> #include <string.h> #include <ctype.h> #include <math.h> #define MAX_DIGITS 256 #define EPSILON 1e-12 /* 字符转数值,支持大小写,非法字符返回 -1 */ int char_to_val(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'A' && c <= 'Z') return c - 'A' + 10; if (c >= 'a' && c <= 'z') return c - 'a' + 10; return -1; } /* 数值转字符,只输出大写 */ char val_to_char(int v) { if (v < 10) return '0' + v; return 'A' + (v - 10); } /* 任意进制字符串转十进制整数(支持正负号) */ long long any_base_to_dec(const char* str, int base) { if (!str || base < 2 || base > 36) return 0; int i = 0, neg = 0; if (str[0] == '-') { neg = 1; i = 1; } else if (str[0] == '+') { i = 1; } long long res = 0; for (; str[i]; i++) { int v = char_to_val(str[i]); if (v < 0 || v >= base) break; /* 非法字符截断 */ res = res * base + v; } return neg ? -res : res; } /* 十进制整数转任意进制,输出带符号形式 */ void dec_to_any_base(long long num, int base, char* out) { int neg = 0; unsigned long long v; if (num < 0) { neg = 1; /* 用 (num+1) 再取反,避免 LLONG_MIN 取绝对值时溢出 */ v = (unsigned long long)(-(num + 1)) + 1ULL; } else { v = (unsigned long long)num; } char buf[MAX_DIGITS]; int len = 0; if (v == 0) { buf[len++] = '0'; } while (v > 0) { buf[len++] = val_to_char(v % base); v /= base; } int pos = 0; if (neg) out[pos++] = '-'; while (len > 0) out[pos++] = buf[--len]; out[pos] = '\0'; } /* 十进制整数转指定宽度的补码字符串(仅适用于 2、4、8、16、32 进制) */ /* bits 是二进制位宽,比如 8/16/32/64 */ void dec_to_twos_with_bits(long long num, int base, int bits, char* out) { int unit; if (base == 2) unit = 1; else if (base == 4) unit = 2; else if (base == 8) unit = 3; else if (base == 16) unit = 4; else if (base == 32) unit = 5; else { dec_to_any_base(num, base, out); return; } /* 取低 bits 位。注意:这里把有符号数字按无符号截断到指定位宽 */ unsigned long long mask = (bits >= 64) ? ~0ULL : ((1ULL << bits) - 1ULL); unsigned long long v = (unsigned long long)num & mask; char buf[MAX_DIGITS]; int len = 0; if (v == 0) buf[len++] = '0'; while (v > 0) { buf[len++] = val_to_char(v % base); v /= base; } int width = (bits + unit - 1) / unit; /* 输出的最少字符数 */ int pos = 0; for (int i = len; i < width; i++) out[pos++] = '0'; while (len > 0) out[pos++] = buf[--len]; out[pos] = '\0'; } /* 十进制纯小数转任意进制字符串,precision 是小数位数 */ void frac_to_any_base(double frac, int base, int precision, char* out) { int pos = 0; out[pos++] = '.'; for (int i = 0; i < precision; i++) { frac *= base; int digit = (int)frac; frac -= digit; /* 浮点容差:当 frac 无限接近 1 时按进位处理 */ if (fabs(frac - 1.0) < EPSILON) { digit++; frac = 0.0; } out[pos++] = val_to_char(digit); if (frac < EPSILON && i < precision - 1) break; /* 已经精确到零 */ } out[pos] = '\0'; } int main(void) { /* 测试用例 */ char out[MAX_DIGITS]; printf("=== 整数转换测试 ===\n"); printf("十进制 42 转二进制: %s\n", (dec_to_any_base(42, 2, out), out)); printf("十六进制 0x2A 转十进制: %lld\n", any_base_to_dec("2A", 16)); printf("\n=== 负数转换测试 ===\n"); printf("-5 的 8 位二进制补码: %s\n", (dec_to_twos_with_bits(-5, 2, 8, out), out)); printf("-5 的 32 位十六进制补码: %s\n", (dec_to_twos_with_bits(-5, 16, 32, out), out)); printf("\n=== 小数转换测试 ===\n"); printf("十进制 0.625 转二进制: %s\n", (frac_to_any_base(0.625, 2, 8, out), out)); printf("十进制 0.1 转二进制(12位): %s\n", (frac_to_any_base(0.1, 2, 12, out), out)); printf("\n=== 任意进制互通测试(五进制转七进制) ===\n"); long long dec = any_base_to_dec("1234", 5); /* 五进制 1234 先转十进制 */ dec_to_any_base(dec, 7, out); /* 再转七进制 */ printf("五进制 1234 = 七进制 %s\n", out); return 0; }这份代码编译运行后,输出应该是:
=== 整数转换测试 === 十进制 42 转二进制: 101010 十六进制 0x2A 转十进制: 42 === 负数转换测试 === -5 的 8 位二进制补码: 11111011 -5 的 32 位十六进制补码: FFFFFFFB === 小数转换测试 === 十进制 0.625 转二进制: .101 十进制 0.1 转二进制(12位): .000110011001 === 任意进制互通测试(五进制转七进制) === 五进制 1234 = 七进制 163几个实现上的关键点必须解释:
**关于LLONG_MIN取绝对值的溢出问题。**C 语言里long long的表示范围是非对称的,最小值 -9223372036854775808 取绝对值后是 9223372036854775808,超出了有符号范围。所以代码里用了-(num + 1) + 1这个技巧:先算num + 1(不溢出),取反,再加 1,全程不触发有符号溢出。
关于补码字符串的位宽。dec_to_twos_with_bits只对 2 的幂进制有效,因为只有这些进制才存在"x 位二进制数恰好对应 y 位目标进制数"的整数对应关系。对十进制转十进制补码这种需求本身就不合理,所以代码回退到普通带符号输出。
**关于小数转换的容差。**0.1 在二进制下是无限循环的,浮点数存的是近似值,乘 2 若干次后会得到 0.9999999999999998。如果不做fabs(frac - 1.0) < EPSILON的判断,会多输出一个 0 或少进一位。这个细节是实际写代码时才踩到的坑,教学示例通常不会提。
6.3 一个简化版面试级写法
如果你只是想在面试或笔试题里快速实现一个纯整数版本,不需要处理负数和位宽,可以用这段极简代码:
#include <stdio.h> #include <string.h> char val_to_char(int v) { return v < 10 ? '0' + v : 'A' + v - 10; } void dec_to_base(unsigned int n, int base, char* out) { int i = 0; if (n == 0) { out[i++] = '0'; } while (n > 0) { out[i++] = val_to_char(n % base); n /= base; } out[i] = '\0'; /* 反转字符串 */ for (int j = 0, k = i - 1; j < k; j++, k--) { char t = out[j]; out[j] = out[k]; out[k] = t; } }这个版本只解决"十进制整数转任意进制",但结构清晰、无依赖,适合作为面试手写答案的基础。
7. 实操过程中最常见的错误与排查经验
最后把我这些年帮人排查进制转换问题时遇到的高频错误集中列一下。这些坑看起来小,却能让人排查一整天。
7.1 字母数字混淆与大小写问题
**十六进制里1(数字一)和I(字母 i)、0(数字零)和O(字母 o)在等宽字体下极易混淆。**之前我调试一个序列号解析模块,十六进制字符串里出现了字母l,解析函数用的char_to_val不支持小写字母就返回 -1,整个字符串被截断,数据直接错位。
规避方法很简单:解析前统一toupper,解析函数里拒绝低于 0 或大于等于 base 的非法字符,并且不要静默截断,要报错。生产代码里我一般会返回错误码而不是默默返回 0,不然非法输入会被当成合法的 0 处理,问题更难发现。
7.2 小数转换的浮点误差
0.1 的二进制表示无限循环,double 类型根本无法精确存储。如果做高精度进制转换,比如把0.1转成二进制字符串去显示,你会发现无论换多少精度去乘,最后几位总是诡异的 99999 或 00001。
解决方法有两个方向:一是像第六节代码那样设置 epsilon 容差;二是改用十进制字符串做高精度运算,把小数按位拆成整数运算,完全不经过浮点数。第二种方法的代码会复杂很多,但精度是确定性的。如果你要做一个进制转换在线工具,建议用第二种思路,把用户输入当作字符串处理,用大数运算完成转换,才能保证 0.1 转二进制能得到正确的前 N 位。
7.3 负数取绝对值溢出的隐蔽 bug
很多初学 C 语言的人写负数转正数时会直接写-num或abs(num)。但int和long long的最小值无法安全取绝对值,这在上一节代码注释里已经提到。这个 bug 最阴险的地方在于:只有恰好遇到INT_MIN或LLONG_MIN时才会触发,平时测试全是正数根本发现不了。
另一个相关陷阱是符号位扩展。把一个int8_t类型的 -1 赋给long long,高 56 位会全部补 1。如果不小心做了位运算再取低 8 位,结果看似没问题;但如果你直接转十六进制输出,会得到FFFFFFFFFFFFFFFF而不是FF。所以输出负数的十六进制时,一定要明确指定位宽并手动掩码,这也是代码里dec_to_twos_with_bits存在的意义。
7.4 分组方向错误:整数右起、小数左起
二进制转八进制或十六进制时,整数部分要从小数点往左,从右到左每 3 位或 4 位分组;小数部分要从小数点往右,从左到右分组。这两者方向相反。我见过不止一次,整数分组对了、小数分组却按从右往左分,导致结果差一位。
记忆方法很简单:分组始终从紧贴小数点的位置开始往外走。整数是从小数点往左走,碰到最左边不够就补 0;小数是从小数点往右走,碰到最右边不够也补 0。
7.5 把 printf 的%d和%x混用导致误读
printf("%x", -5)在大部分平台上打印fffffffb,很多人会惊讶"负数怎么变成这么大的数"。原因就是%x把有符号的-5重新解释成了无符号整数。在调试的时候,我建议明确分开:想看到内存里的真实字节,就用%x配合unsigned;想看数值大小,就用%d。两者要表达的东西不同,混着用只会让日志更难读懂。
另外,在嵌入式开发里经常需要从设备寄存器读回一个值,手册写的是"补码形式存储"。如果你直接printf("%d", reg_val),得到的小数值正好是负数的补码语义;如果寄存器是无符号类型,你需要手动判断最高位再转成负数。这里没有银弹,只有搞清楚你面对的数据到底是有符号还是无符号。
总的来说,进制转换这个主题看起来基础,但真要保证正确性,要处理位宽、符号、浮点精度、字符串解析这么多细节。我自己最深刻的体会是:任何时候都不要在代码里硬编码"这个进制只有 2、8、10、16"的假设,把转换逻辑参数化到任意 2~36 进制,配合清晰的函数边界,能省下后面大量在协议解析、数据编码这类场景里的返工时间。以后要是碰到需要转换超大数(超过 64 位的十进制值)的需求,可以在现在的代码基础上把long long换成一串十进制字符做大数除法,那是另一个话题了,但今天梳理的这些核心思想和调试经验在那套实现里也完全适用。