☰
进制转换全解析:从位权展开到工程级实现与边界处理
2026/10/1 4:04:03 网站建设 项目流程

两年前我做一块温控板的联调时,设备上报的寄存器值是十六进制字符串0x1F4,对应十进制 500,按协议除以 10 就是 50.0 度。那会儿组里几个同事的日常操作是打开在线进制转换网站,复制粘贴。这本身没什么,但到了批量解析日志、写自动化测试脚本的时候,靠网页就撑不住了。进制转换看起来是计算机导论第一节课的内容,真到了工程里,"十进制与任意进制互转"这件事藏着一堆边界:大数溢出怎么办?小数转二进制要不要舍入?字母大小写怎么处理?基数上限取 36 还是 62?这篇文章把我这些年在这块踩过的坑、写过的代码、验证过的思路一次性说清楚。不管你是刚学 C 语言的学生,还是要处理协议字段、颜色值、地址解析的工程师,都值得认真过一遍。

1. 进制到底是什么:位权展开与"逢N进一"的本质

1.1 位权展开式:进制的数学骨架

要理解进制转换,最忌讳死记"除 N 取余"和"乘 N 取整"这两句口诀。先把进制的定义搞清楚,后面所有算法都能推出来。

任何一个 b 进制数,都是这样展开的:

一个 n 位整数,从高位到低位依次是 dₙ₋₁, dₙ₋₂, ..., d₁, d₀,它的数值等于 dₙ₋₁ × bⁿ⁻¹ + dₙ₋₂ × bⁿ⁻² + ... + d₁ × b¹ + d₀ × b⁰

这个式子叫位权展开式。它说明了一个关键事实:数字的"价值"由两部分组成,一部分是数字本身(digit),另一部分是它所在的位置。位置决定了权重,也就是那个 b 的幂。

我习惯用"砝码模型"来理解这一套。b 进制就是一套标准砝码:1、b、b²、b³……十进制用的是 1、10、100、1000 这套砝码,二进制用的是 1、2、4、8、16 这套砝码。任何一个数,就是在某套砝码下找出一种组合方式。十进制 1234,就是 1 个 1000、2 个 100、3 个 10、4 个 1。二进制 1011,就是 1 个 8、0 个 4、1 个 2、1 个 1,加起来正好是 11。

"逢 N 进一"也从这个模型自然得出来。某一位上最多只能放 N-1 个砝码,超过就得往高位进位,因为高一位的砝码恰好等于低一位的 N 倍。这跟十进制"满十进一"是同一个道理,只不过把 10 换成了任意进制 N。

明白位权展开式之后,任意进制转十进制就是傻瓜操作:把每一位乘以对应权重再累加。比如十六进制2F,F 是 15,结果就是 2×16 + 15 = 47。后面要讲的霍纳展开,本质上是这个式子的另一种写法。

1.2 为什么整数转换用"除N取余":从砝码模型说起

十进制转其他进制,最常用的是短除法,也叫除 N 取余法。以十进制 123 转二进制为例:

123 ÷ 2 = 61 余 1 61 ÷ 2 = 30 余 1 30 ÷ 2 = 15 余 0 15 ÷ 2 = 7 余 1 7 ÷ 2 = 3 余 1 3 ÷ 2 = 1 余 1 1 ÷ 2 = 0 余 1

把余数从下往上倒过来读:1111011,这就是 123 的二进制表示。验算一下:64 + 32 + 16 + 8 + 0 + 2 + 1 = 123,完全正确。

为什么这个算法成立?关键在于每次除法都是在做一个拆分:把当前数值拆成"能被 N 整除的商"和"余数"两部分。第一次除以 N 得到的余数,一定是 2⁰ 这一位上的数字,因为这一位上的砝码就是 1,而任何整数除以 N 的余数必然落在 0 到 N-1 之间,正好一位能表示。第二次除法得到的余数,对应 N¹ 位,依此类推。

剥洋葱一样,从最低位一层层剥到最高位,等商变成 0 就结束了。这个过程的反面就是位权展开式的逆运算,所以余数要倒序排列。我刚开始学的时候犯过一个低级错误,把余数正着读出来,结果完全不对,后来想明白了"从低位往高位收集,写出来自然要倒序"这个道理就再也没错过。

2. 整数互转落地:短除法、霍纳展开与C语言实现

2.1 十进制转任意进制:短除法的标准实现

直接看代码。下面这个函数把unsigned long long的十进制整数转换成任意进制(2 到 36)字符串:

#include <stdio.h> #include <string.h> const char DIGITS[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 将十进制整数 value 转换为 base 进制字符串 // out 缓冲区长度至少 66 字节(64 位最大值转二进制是 64 位 + '\0') void dec_to_base(unsigned long long value, int base, char *out) { char tmp[130]; int len = 0; if (base < 2 || base > 36) { out[0] = '\0'; return; } do { tmp[len++] = DIGITS[value % base]; value /= base; } while (value != 0); for (int i = 0; i < len; i++) { out[i] = tmp[len - 1 - i]; } out[len] = '\0'; }

几个细节值得注意。

第一,用do...while而不是while。这样保证value是 0 时也能输出"0",而不是输出空字符串。这是新手最容易漏掉的边界情况。

第二,先存到临时数组再倒序。因为余数是从低位往上生成的,直接写进输出缓冲区会得到反序的结果。当然也可以先算长度再倒着从尾部填充,能省一次拷贝,但可读性差点,我习惯用临时数组。

第三,DIGITS这个映射表是整个函数的核心。value % base得到的是 0 到 base-1 的数字,直接用下标映射到字符,省去了判断分支。比如 base 是 16,余数 11 就映射成'B',正确输出十六进制的 B。

2.2 任意进制转十进制:霍纳展开的妙处

反向转换,任意进制字符串转十进制数值。最容易想到的写法是:遍历每一位,算出权重 bⁱ,然后累加。但这样需要调用幂函数,效率低不说,代码还啰嗦。更优雅的做法是霍纳展开(Horner's method),也叫秦九韶算法:

从最高位开始,不断执行 result = result × base + digit

以十六进制2F为例:

result = 0 第一步:result = 0 × 16 + 2 = 2 第二步:result = 2 × 16 + 15 = 47

两步就得结果,不需要算任何幂。这个算法的本质就是把位权展开式改写成嵌套形式:

d₁×b + d₀ = (d₁)×b + d₀
d₂×b² + d₁×b + d₀ = ((d₂)×b + d₁)×b + d₀

写成代码:

#include <ctype.h> // 把单个字符转换成数字值,0-9 返回 0-9,A-Z/a-z 返回 10-35 // 非法字符返回 -1 int digit_to_value(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; } // 把 base 进制字符串转换为十进制数值 // 成功返回 0,进制非法或含非法字符返回 -1 int any_to_dec(const char *s, int base, unsigned long long *result) { unsigned long long val = 0; int d; if (base < 2 || base > 36) return -1; if (s == NULL || *s == '\0') return -1; for (; *s; s++) { d = digit_to_value(*s); if (d < 0 || d >= base) return -1; // 溢出检查:如果 val * base + d 会超过 unsigned long long 上限就报错 if (val > (18446744073709551615ULL - (unsigned long long)d) / (unsigned long long)base) { return -1; } val = val * base + (unsigned long long)d; } *result = val; return 0; }

霍纳展开在解析数字字面量时是标准做法,编译器扫描源码里的数字也是这么做的。我把结果通过指针参数返回,函数用返回值表示状态,这样调用方就能区分"转换成功"和"输入非法"。这是工程代码和玩具代码的分水岭。

2.3 base大于10时,字母符号怎么处理

进制一旦超过 10,数字就不够用了。十六进制用 A-F 表示 10 到 15,三十六进制一直用到 Z 表示 35。行业惯例是用 0-9 加 A-Z 这 36 个字符,这也是为什么大多数转换工具的基数上限是 36。

有两点容易忽略。一是解析时大小写都要接受,0xff、0xFF、0XfF在语义上完全一样,digit_to_value里对 A-Z 和 a-z 都做了映射,这就是原因。二是输出时建议统一大写,保持可读性,也方便日志对齐。

理论上还可以用 0-9、a-z、A-Z 把基数扩到 62,但这种做法会导致字符集不连续('9' 和 'a' 之间还有几个标点字符),可读性和通用性都差,实际项目中几乎见不到。我自己始终限制在 36,够用了,而且和大多数库函数的约定一致。

3. 小数转换的精度深水区:乘N取整、0.1的循环与舍入策略

3.1 小数部分为什么要乘N取整

整数的转换是除法,小数的转换正好反过来:乘法。以十进制小数 0.625 转二进制为例:

0.625 × 2 = 1.25 取整数部分 1,剩下 0.25 0.25 × 2 = 0.5 取整数部分 0,剩下 0.5 0.5 × 2 = 1.0 取整数部分 1,剩下 0

从上往下读整数部分:101,所以 0.625 的二进制是 0.101。验算:1×0.5 + 0×0.25 + 1×0.125 = 0.625,正确。

为什么是乘法?回到砝码模型。小数位是在"分"1 这个砝码:二进制小数位是 1/2、1/4、1/8,也就是 2⁻¹、2⁻²、2⁻³。乘以 2 这个动作,等于把原来的数按"二等分"的刻度去量,每次量出来的整数部分就是这一位是 0 还是 1,剩下的小数部分继续往下量。通用化就是乘以 N,每次取整数部分作为一位,剩下的继续。

通用的转换函数可以这么写:

// 把十进制小数 frac 转换为 base 进制小数,最多转换 precision 位 // 结果写入 out,out 长度至少 precision + 2 void dec_frac_to_base(double frac, int base, int precision, char *out) { int i; double d = frac; int int_part; if (base < 2 || base > 36 || precision < 1) { out[0] = '\0'; return; } for (i = 0; i < precision && d > 0.0; i++) { d *= base; int_part = (int)d; out[i] = DIGITS[int_part]; d -= int_part; } out[i] = '\0'; }

当 d 变成 0 时说明已经精确转换完,可以提前退出。但注意,这个函数里用的是 double,本身就有精度上限,后面马上说这个问题。

3.2 0.1在二进制里为什么是无限循环小数

试着用上面的办法转换 0.1 到二进制:

0.1 × 2 = 0.2 → 0,剩 0.2 0.2 × 2 = 0.4 → 0,剩 0.4 0.4 × 2 = 0.8 → 0,剩 0.8 0.8 × 2 = 1.6 → 1,剩 0.6 0.6 × 2 = 1.2 → 1,剩 0.2 0.2 × 2 = 0.4 → 0,剩 0.4 ...

会无限循环下去,结果是 0.000110011001100110011...,循环节是 0011。这可不是实现方式的问题,而是数学上注定的。

判断标准很清晰:一个分数在 b 进制下能否用有限位小数表示,要看它约分后的分母,分母的所有质因子是否都是 b 的质因子。10 的质因子是 2 和 5,所以那些分母只含 2 和 5 的分数,比如 1/2、1/4、1/5、3/10,在十进制里都是有限小数。但二进制只有质因子 2,所以 0.1 这个分母含 5 的分数,在二进制里永远写不完。反过来,1/3 在十进制里是 0.333...,在二进制里同样是无限循环,因为分母 3 既不整除 10 也不整除 2。

理解了这一点,就能明白一个反直觉的结论:十进制里看着干干净净的 0.1、0.2、0.3,在计算机内部全是近似值。这不是 C 语言的 bug,是所有二进制浮点数的共同宿命。

3.3 精度限制下必须舍入:IEEE 754的四种舍入模式

热搜里有个问题问得很好:十进制小数转二进制有精度限制时,需要考虑舍入吗?

答案是必须舍入,而且不能随手截断。IEEE 754 标准定义了四种舍入模式,我整理成表格:

舍入模式规则典型效果
向零舍入(截断)直接丢弃多余位结果绝对值偏小,系统性有偏
向最近偶数舍入(ties-to-even)距离相等时取末位为偶数统计上无偏,IEEE 754 默认模式
朝正无穷舍入取更大的可表示值结果偏大
朝负无穷舍入取更小的可表示值结果偏小

直接截断是新手最常见的错误。每次转换都向下取整,误差永远朝着一个方向累积,循环计算几十万次之后误差会大到不可接受。而"向最近偶数舍入"在距离相等时取末位为偶数,比如二进制的 0.1101 要舍入到三位,0.1101 和 0.1110 距离相等,取 0.1100(末位 0),而不是 0.1110。这种策略在统计上误差相互抵消,所以被 IEEE 754 选为默认。

float 和 double 的具体表现也不一样。float 的尾数只有 23 位加 1 个隐含位,共 24 位有效精度,所以 0.1f 实际上是 0.100000001490116119384765625 这个最近可表示值;double 有 52 位加 1 个隐含位,0.1 存进去是 0.1000000000000000055511151231257827021181583404541015625。这就解释了0.1 + 0.2 != 0.3这个经典现象:

double a = 0.1; double b = 0.2; double c = a + b; printf("0.1 + 0.2 = %.20f\n", c); // 输出:0.1 + 0.2 = 0.30000000000000004441

顺带说一句,"会计十进制"这个说法之所以受关注,就是因为金融计算绝不能容忍这种误差。银行对账、发票金额这类场景,要么用专门的高精度十进制类型(Java 的 BigDecimal、Python 的 decimal 模块),要么干脆用整数分来算,而不是直接用二进制浮点数。这不是小题大做,0.01 元在二进制里同样没有精确表示,百万笔交易累加下来,对不上账是迟早的事。

4. 工程级转换函数必须处理的边角情况

4.1 缓冲区长度与整数溢出的两重陷阱

写转换函数,第一道坎是缓冲区。unsigned long long最大值是 18446744073709551615,转成二进制需要 64 位,加上结尾的'\0'至少要 65 个字节。我在代码里注释写"至少 66 字节",多留一个字节做个缓冲。如果你用的是 32 位unsigned int,最大 4294967295,转二进制 32 位,33 字节就够。缓冲区给小了,字符串末尾的'\0'写到越界位置,程序可能当场崩溃,也可能在很久之后才炸,这种 bug 特别难排查。

第二道坎是溢出。霍纳展开每做一次result = result * base + digit,结果都可能超过类型上限。我在any_to_dec里加的判断:

if (val > (18446744073709551615ULL - (unsigned long long)d) / (unsigned long long)base) { return -1; }

原理是把不等式val * base + d > ULLONG_MAX变形成val > (ULLONG_MAX - d) / base,这样在乘法发生之前就能预判是否会溢出。注意ULLONG_MAX定义在<limits.h>头文件里,代码里我直接写了字面量是为了演示,实际项目中请用标准宏。

4.2 输入校验:非法字符、空串、大小写与负号

调用者会传什么进来,你永远猜不到。空字符串、NULL 指针、进制 1、进制 100、字符串里混着@#$、多个小数点、前导零,这些都是真实会发生的事。

我在any_to_dec里做了一层基本防御:进制范围检查、NULL 检查、空串检查、逐字符合法性检查。但这里有个取舍,函数内部只负责识别错误和返回错误码,具体是打印日志、跳过还是终止,交给调用方决定。这也解释了为什么我把函数设计成返回int状态码,而不是直接返回数值——错误处理不该和算法逻辑混在一起。

负号是个有意思的坑。-123转二进制,数学上应该是负号加 1111011,也就是符号数值表示;但如果你在处理的是内存里的整数位模式,那应该是补码表示。这两种表示在不同场景都有道理,但绝对不能混。我的建议是:函数注释里明确写明"本函数处理的是数学意义上的数值,符号单独处理",然后在调用前先提取符号,对绝对值转换,最后再拼回去。如果你要的是内存位模式,那就得按补码规则来,那是另一套逻辑。

大小写上,解析时大小写都接受(如0xff和0xFF),输出统一大写。前导零对数值没有影响,但如果你的应用场景需要固定位宽输出(比如协议字段固定 4 位十六进制),函数外自己补零,不要在函数内部硬编码。

4.3 标准库能帮你做什么:strtol 与 snprintf

其实标准库已经提供了整数进制的转换能力。strtol系列函数(包括strtol、strtoul、以及带 l 的strtoll)可以从任意进制字符串解析出整数,它会自动处理0x前缀、前导空白、正负号,而且支持 2 到 36 进制:

#include <stdlib.h> char *endptr; unsigned long long v = strtoull("0x1F4", &endptr, 16); // v = 500, endptr 指向字符串结束位置

endptr参数尤其有用,它能告诉你解析停在了哪个字符,方便做反序列化或者协议解析。输出方向,C 语言标准库没提供直接的通用进制输出函数(itoa不是标准函数,很多编译器作为扩展提供),但有snprintf配合%x、%o、%d可以做 2、8、10、16 进制:

char buf[32]; snprintf(buf, sizeof(buf), "%llx", 500ULL); // buf = "1f4"

那么自己实现的意义是什么?第一,strtol只支持整数,不支持小数转换;第二,理解算法本身就是价值,面试、笔试、手写代码时刻都有可能考;第三,标准库的行为未必完全符合你的场景,比如你需要输出大写、需要固定位宽、需要处理十进制小数字符串。我的建议是:功能上优先用标准库,但算法要自己吃透,两层能力都不亏。

5. 测试验证与真实项目里的进制转换

5.1 用逆运算做回归测试

写完转换函数,第一件事不是写业务逻辑,而是写一个往返测试:任意进制转十进制,再转回原进制,结果必须完全一致。对整数而言这是无损操作,任何不一致都说明有 bug。

我最常用的写法是随机测试加已知向量两个组合。已知向量就是固定值验证:0 应该输出"0",255 在十六进制下是"FF",65535 是"FFFF",4294967295 是"FFFFFFFF",这些值能快速暴露映射表错误和缓冲区溢出。随机测试长这样:

#include <stdio.h> #include <stdlib.h> int main(void) { char s1[70], s2[70]; unsigned long long v, v2; int bases[] = {2, 8, 10, 16, 36}; int n = sizeof(bases) / sizeof(bases[0]); for (int trial = 0; trial < 100000; trial++) { v = ((unsigned long long)rand() << 32) | rand(); for (int i = 0; i < n; i++) { dec_to_base(v, bases[i], s1); if (any_to_dec(s1, bases[i], &v2) != 0 || v2 != v) { printf("mismatch: %llu -> %s -> %llu (base %d)\n", v, s1, v2, bases[i]); return 1; } } } printf("all tests passed\n"); return 0; }

十万个随机数在五个进制下来回转换,跑完一遍基本能覆盖映射表、边界位、前导零等各种情况。我实际测试时还真抓到过问题,有一次映射表里把'A'的下标写成 9,按十六进制解析0xA抛出了非法字符错误,就是靠随机测试逮住的。

小数的测试要小心,因为小数转换本身有舍入误差,不能要求完全相等,要给定精度后比较误差范围。比如转换 0.625 到二进制,应该严格得到"101";转换 0.1 到二进制,应该得到某个固定位数的近似值,再换算回十进制后和原值误差在可接受范围内。

5.2 实际项目里最常见的几个应用场景

进制转换在工程里无处不在,这里列几个最常见的:

场景进制具体例子
前端颜色值16#FF8800解析成 RGB 三个字节
IPv6 地址16每段 16 位,压缩写法离不开十六进制
MAC 地址1600:1A:2B:3C:4D:5E
Linux 权限位8chmod 755的 7 就是八进制
存储地址与调试16GDB 里看到的0x7fffffffe2c0
Unicode 码点16U+4E2D对应"中"字

协议调试是最典型的场景。我那次温控板联调,寄存器值用十六进制表示,温度要除以 10,精确到一位小数。如果只是看一两个值,用计算器无所谓;但要解析一整夜的日志,几百条十六进制字符串要批量转成温度曲线,就必须写脚本。这时候你手头有没有一个可靠的转换函数,效率差距是十倍以上。

另外,Base64 编码本质上也和进制有关——它把每 3 个字节看成 24 位,再按 6 位一组映射成 64 个字符,可以理解成一个"64 进制"的转换。理解了任意进制的转换原理,再看这些编码方案会通透很多。

5.3 一份关于手写转换函数的个人建议

换成别的语言时,思路完全一样。Python 的int(s, base)和hex()、bin()一行搞定;Java 有Integer.toString(n, base)和Integer.parseInt(s, base);但不管用什么语言,底层都是短除法和霍纳展开,面试官抠细节的时候问的还是这两个算法。

我个人实际的体会是,进制转换是一个"看起来简单、但边界极多"的程序,非常适合用来训练写代码的严谨性。缓冲区尺寸、溢出检测、非法输入、大小写、空串、前后缀,每一个都是真实工程里会遇到的坑。我在给团队做代码评审时,看到新人提交的转换函数,第一眼就看三件事:缓冲区够不够大,循环能不能处理 0 和空串,解析时有没有做字符合法性校验。这三条过了,函数基本就能用。

最后分享一个调试技巧:写转换函数时,把DIGITS映射表里的每一个字符都当成潜在 bug 源,逐个核对'9'后面接的是不是'A','F'后面是不是'G'。映射表错一个字符,整个函数静默出错,随机测试和已知向量测试就是专门用来抓这种错的。别问我为什么知道要专门提醒这一条。

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

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

立即咨询