ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

进制转换全解析:从位权展开到工程级实现与边界处理

进制转换全解析:从位权展开到工程级实现与边界处理 两年前我做一块温控板的联调时设备上报的寄存器值是十六进制字符串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。明白位权展开式之后任意进制转十进制就是傻瓜操作把每一位乘以对应权重再累加。比如十六进制2FF 是 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ⁱ然后累加。但这样需要调用幂函数效率低不说代码还啰嗦。更优雅的做法是霍纳展开Horners 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-9A-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 位 // 结果写入 outout 长度至少 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 应该输出0255 在十六进制下是FF65535 是FFFF4294967295 是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:5ELinux 权限位8chmod 755的 7 就是八进制存储地址与调试16GDB 里看到的0x7fffffffe2c0Unicode 码点16U4E2D对应中字协议调试是最典型的场景。我那次温控板联调寄存器值用十六进制表示温度要除以 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后面接的是不是AF后面是不是G。映射表错一个字符整个函数静默出错随机测试和已知向量测试就是专门用来抓这种错的。别问我为什么知道要专门提醒这一条。
返回列表