1. 从一次协议调试说起为什么“简单”的进制转换总翻车前阵子帮朋友排查一个蓝牙设备上报数据的Bug抓包抓了一下午最后定位到问题根源居然出在进制转换上。设备端把传感器值按十六进制补码发上来程序里直接用了atoi去解析负数怎么都读不对数值一大还溢出整个状态显示成了一串天文数字。这事挺典型。进制转换作为计算机基础里的基础很多人觉得“不就是短除法嘛”但真到了实战里整数、小数、负数、任意进制互转再加上C语言实现坑一个接一个。尤其在做嵌入式通信、上位机开发、文件解析比如BMP、PNG头、网络协议字段提取时编码数据基本都是十六进制字节流里面既有符号数又有无符号数还有小数被编码成定点数。转换不准整个链路白搭。这篇就把进制转换这件事从头到尾捋一遍整数怎么转、小数怎么转、负数怎么表示、任意进制之间怎么快捷互转外加一份可以直接用的C语言实现。不讲废话全是能直接抄作业的内容。2. 任意进制转换的唯一底层原理按权展开2.1 所谓“权”其实就是位次很多教程一上来就塞公式把人搞懵。其实“权”这个概念用钱来理解最方便人民币有100元、50元、10元、1元这些面额每张人民币的“面额”也就是权决定了它代表多少价值。二进制数里从右往左每一位的权依次是1、2、4、8、16……十进制里从右往左依次是1、10、100、1000……八进制里则是1、8、64……十六进制从右往左是1、16、256……规律就是进制基数R的第n位权是R的n次方从右往左数右边第一位是0次方。所以一个任意进制数转十进制做法就是每一位的数字乘上它的权再加起来。这一步是所有进制转换的地基后面的互转本质上都在反复绕回这个规则。2.2 按权展开换算示例比如八进制数765.24O转十进制整数部分7×8² 6×8¹ 5×8⁰ 448 48 5 501小数部分2×8⁻¹ 4×8⁻² 0.25 0.0625 0.3125合起来就是501.3125再比如二进制1011.1B转十进制整数部分1×2³ 0×2² 1×2¹ 1×2⁰ 8 0 2 1 11小数部分1×2⁻¹ 0.5结果是11.5这个方法对任何进制都有效包括三进制、七进制、三十六进制这类不常见的。只要搞清楚基数R和数字位上的值直接展开累加就完事。注意超过十进制的进制数字位上的值可能用字母表示。比如十六进制里A10、B11、F15三十六进制里Z35。按权展开时记得先把字母换算成对应的数值再参与运算。2.3 核心结论任意进制互转都经由十进制理解了按权展开之后所谓“任意进制之间的相互转换”本质上就是两条路源进制先转十进制用按权展开法。十进制再转目标进制用后面要讲的除基取余法整数和乘基取整法小数。虽然二进制、八进制、十六进制之间因为基数刚好是2的幂次82³162⁴存在快捷的分组转换法可以绕开十进制但底层原理依然逃不开权值计算。所以学透十进制这个中转站其他进制都是它的变体。3. 整数转换全解除基取余法3.1 十进制转任意进制的标准化步骤十进制整数转任意进制算法统一为“除基取余、逆序排列”这个“基”就是目标进制基数R。以十进制数173转二进制为例一步一步写清楚173 ÷ 2 86 ...... 余 1 86 ÷ 2 43 ...... 余 0 43 ÷ 2 21 ...... 余 1 21 ÷ 2 10 ...... 余 1 10 ÷ 2 5 ...... 余 0 5 ÷ 2 2 ...... 余 1 2 ÷ 2 1 ...... 余 0 1 ÷ 2 0 ...... 余 1余数从下往上读10101101这就是173的二进制表示。为什么必须逆序因为第一次除法得到的是最低位即权为2⁰的那一位最后一次除法得到的才是最高位。想知道原理可以想想“先分离个位还是先分离最高位”这个问题——除法除到余数天然是从低位开始的所以读取时必须倒过来。3.2 转八进制与十六进制的实操演示同一个数173转八进制173 ÷ 8 21 ...... 余 5 21 ÷ 8 2 ...... 余 5 2 ÷ 8 0 ...... 余 2余数逆序读取255。验算一下2×8² 5×8¹ 5×8⁰ 128 40 5 173正确。转十六进制173 ÷ 16 10 ...... 余 13即十六进制的D 10 ÷ 16 0 ...... 余 10即十六进制的A结果是AD。验证10×16¹ 13×16⁰ 160 13 173没毛病。从这两个例子能看出目标进制基数越大除法次数越少结果位数越短。这是进制本身的信息密度决定的16进制一位可以表示4个二进制位8进制一位可以表示3个二进制位。3.3 任意进制整数转十进制还记得按权展开吗前面算是铺垫够了。现在反过来任意进制整数转十进制就是把每位数乘上对应权值再累加。三进制数2102转十进制2×3³ 1×3² 0×3¹ 2×3⁰ 54 9 0 2 65从这个例子可以看到“任意进制”不是说说而已只要明白权值规则三进制、四进制、五进制都能处理。只是现实中用得最多的还是2、8、10、16这四种其他进制更多出现在算法题和密码学场景里。3.4 二、八、十六进制之间的快捷互转2、8、16三种进制因为基数都是2的幂存在更快的方法这个方法在处理大量数据时效率优势明显。二进制转八进制从右往左每3位一组不足3位的左边补0然后直接查下表二进制八进制00000011010201131004101511061117二进制转十六进制从右往左每4位一组不足4位的左边补0。二进制十六进制000000001100102001130100401015011060111710008100191010A1011B1100C1101D1110E1111F举例二进制1011011011B转十六进制从右往左4位分组0010 1101 1011查表2 D B结果就是2DBH反向操作同理十六进制一位拆成4位二进制八进制一位拆成3位二进制。转换过程快且不容易出错非常适合手算场景。4. 小数转换全解乘基取整法4.1 与整数完全对称的算法小数部分的转换和整数部分正好是镜像关系。整数部分是除法过程小数部分是乘法过程整数部分结果逆序读小数部分结果正序读。十进制小数0.8125转二进制0.8125 × 2 1.625 取整数部分 1这是第一位小数 0.625 × 2 1.25 取整数部分 1第二位 0.25 × 2 0.5 取整数部分 0第三位 0.5 × 2 1.0 取整数部分 1第四位顺序从第一行开始依次往下读1101。也就是说0.8125转二进制是0.1101B。验证1×2⁻¹ 1×2⁻² 0×2⁻³ 1×2⁻⁴ 0.5 0.25 0 0.0625 0.8125完全正确。4.2 为什么有些小数转出来是无限的这是进制转换里最值得关注的知识点之一。十进制的有限小数不代表在二进制里也有限。比如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.4 × 2 0.8 取 0 0.8 × 2 1.6 取 1看到没有从0.2开始就陷入了0.2 → 0.4 → 0.8 → 1.6 → 1.2的循环这意味着0.1在二进制下是无限循环小数没有精确表示。这就是为什么浮点数运算里0.1 0.2不等于0.3本质就是存储时已经发生精度损失。做进制转换时如果遇到小数部分永远乘不到0的情况通常取到一定位数就截断这个“一定位数”取决于你的精度需求。实际工程里常见做法是保留到小数点后12到16位二进制。4.3 带整数和小数的混合数转换带小数的数处理思路是整数部分和小数部分分开转中间用小数点拼接。比如十进制数173.8125转二进制整数部分173转二进制10101101前面算过小数部分0.8125转二进制1101前面算过拼接10101101.1101B反过来二进制10101101.1101B转十进制就分别按权展开再相加整数部分128 32 8 4 1 173小数部分0.5 0.25 0 0.0625 0.8125整个过程完全对称重点记住“整数除、小数乘结果方向相反”。这个口诀我用了十年一直好用。5. 负数怎么转原码、反码、补码与符号扩展5.1 计算机里没有“负号”这个字符人类写负数很简单前面加个负号就行。但计算机的存储单元里只有0和1没有“-”这个符号位实体。那负数怎么表示答案是“用编码方案”最常见的就是补码。补码的核心思想是找一个正数让这个正数和对应负数的二进制运算结果恰好溢出为0。以单字节8位为例-1的补码是11111111因为00000001 11111111 1000000009位最高位溢出丢掉溢出的那位被CPU丢弃剩下8位全是0所以-1和1相加正好等于0。这就是补码最精妙的地方把减法统一成了加法硬件上只需要设计加法器。5.2 任意进制负数的补码转换流程现在问题来了如果我要把十进制-45转成二进制补码或者直接转成十六进制怎么做标准流程是先算绝对值45的二进制001011018位表示前面补0到目标位数。取反0变11变011010010。再加111010011。所以单字节-45的补码是11010011B即十六进制0xD3。如果目标位数不同处理方式要小心。比如-45用两个字节16位表示绝对值45是0000000000101101取反1111111111010010加11111111111010011结果0xFFD3整数部分45对应十六进制0x2D负数对应0xFFD3。注意看8位补码扩展成16位补码时高位全部补符号位。原来的符号位是1表示负数扩展时高位全部补1这就叫“符号扩展”。5.3 从补码还原十进制反向操作十六进制0xD3还原成十进制先确认符号位D3二进制是11010011最高位是1说明是负数。还原方式也有两条路方法一补码减1再取反11010011 - 1 11010010 取反 00101101 45 加负号 -45方法二先取反再加111010011取反 00101100 1 00101101 45 加负号 -45两条路殊途同归本质上都是“补码的补码是原码”。实际开发中我用方法一居多因为手动心算更顺手。5.4 负数小数的表示方式负小数在计算机里分两种情况定点数小数先处理符号位绝对值部分按普通小数转换最后把符号位单独放一个bit位。比如一个8位定点数最高位是符号位剩下7位表示数值那么-0.8125就转换为1.1101000B这样的格式假设7位小数部分能存下0.8125。浮点数小数IEEE 754标准单精度float是1位符号位8位指数位23位尾数位。这种转换相对复杂工程上通常不手算而是直接用工具或语言内置函数搞定。但在做协议解析时看到0xC03A0000这种原始数据能认出它是一个负的浮点数这是基本功。6. C语言实现一套能直接抄的任意进制转换工具6.1 整体设计思路C语言实现进制转换的坑我在项目里踩了不少。理论上的“任意进制”落到代码里需要处理几个现实问题字符集映射16进制有A-F36进制更是用到全部字母大小写怎么处理合法性校验用户输入12G这种包含非法字符的串不能让它默默算出个错误值。负数解析字符串-FF转十进制和FF转十进制语义完全不同。溢出处理32位int最多表示约21亿转个超大数直接溢出而不报错这是最坑的。这版实现我按“模块化函数”来写每个函数只做一件事方便拿到工程里直接裁剪。6.2 字符映射表#include stdio.h #include string.h #include ctype.h #include stdlib.h /* 数字转字符支持最高36进制 */ char digit_to_char(int val) { if (val 0 val 9) { return 0 val; } else if (val 10 val 35) { return A (val - 10); } return ?; /* 非法值 */ } /* 字符转数字支持最高36进制 */ int char_to_digit(char c) { if (c 0 c 9) { return c - 0; } else if (c A c Z) { return c - A 10; } else if (c a c z) { return c - a 10; } return -1; /* 非法字符 */ }这里有个细节char_to_digit里我做了大小写兼容。因为用户输入0xff和0XFF都很常见如果只认大写使用者体验很糟。6.3 十进制整数转任意进制/* 将十进制整数num转换为base进制字符串结果写入out */ void dec_to_base(long long num, int base, char *out) { char tmp[128]; int idx 0; int negative 0; if (num 0) { strcpy(out, 0); return; } if (num 0) { negative 1; num -num; } while (num 0) { tmp[idx] digit_to_char(num % base); num / base; } int start 0; if (negative) { out[start] -; } /* 逆序输出 */ while (idx 0) { out[start] tmp[--idx]; } out[start] \0; }关键点注num用long long类型这样能支持更大的数值范围。先取余再除和手算的流程完全一致。结果先放临时数组再逆序拷到输出缓冲区。负数处理先转绝对值转换完成后在结果头部加负号。这里的负号是给人看的不是计算机用的补码形式。6.4 任意进制字符串转十进制/* 将base进制字符串str转换为十进制long long */ long long base_to_dec(const char *str, int base) { long long result 0; int negative 0; int idx 0; if (str[0] -) { negative 1; idx 1; } else if (str[0] ) { idx 1; } for (; str[idx] ! \0; idx) { int val char_to_digit(str[idx]); if (val 0 || val base) { fprintf(stderr, 非法字符 %c要求 %d 进制\n, str[idx], base); return 0; } result result * base val; } return negative ? -result : result; }这段代码最值得说的两个地方非法字符校验每读一个字符都检查它是否小于base。比如base是2的时候遇到2就会被当场拦截不会静默算出错误结果。累加方式result result * base val本质就是权值展开的迭代形式。读入1011时先得1然后1×202然后2×215然后5×2111正好是1011B的值。6.5 小数部分的转换实现/* 将十进制小数dec_frac转换为base进制字符串精确到precision位 */ void frac_dec_to_base(double frac, int base, int precision, char *out) { int idx 0; if (frac 0) { frac -frac; } while (idx precision) { frac * base; int digit (int)frac; out[idx] digit_to_char(digit); frac - digit; } out[idx] \0; }这里要特别小心浮点数直接做乘法取整会累积误差。比如0.1转二进制存的是近似值循环几次之后小数尾巴就不干净了。工程上如果对精度要求高建议用定点数或者有理数运算代替double。调用示例void demo_conversion(void) { char buf[128]; /* 十进制173.8125转二进制 */ dec_to_base(173, 2, buf); printf(173 - 2进制整数部分: %s\n, buf); char frac_buf[64]; frac_dec_to_base(0.8125, 2, 12, frac_buf); printf(0.8125 - 2进制小数部分: 0.%s\n, frac_buf); /* 十六进制串转十进制 */ long long val base_to_dec(FF2D, 16); printf(0xFF2D - 十进制: %lld\n, val); /* 负数十进制转十六进制字符串 */ dec_to_base(-255, 16, buf); printf(-255 - 16进制字符串: %s\n, buf); }6.6 负数补码转换的C实现前面的dec_to_base处理负数用的是人可读的“加负号”方式。如果要做成计算机存储的补码形式需要单独实现/* 将十进制整数num转为指定位数的补码base进制的字符串形式 */ /* bits参数指定总位数比如8、16、32 */ void dec_to_complement(long long num, int base, int bits, char *out) { unsigned long long mask (1ULL bits) - 1; unsigned long long encoded; if (num 0) { encoded (unsigned long long)(-num); encoded (~encoded 1) mask; } else { encoded (unsigned long long)num mask; } /* 现在encoded是一个无符号整数再转成对应进制字符串 */ dec_to_base((long long)encoded, base, out); }调用效果char buf[128]; dec_to_complement(-45, 2, 8, buf); printf(-45的8位二进制补码: %s\n, buf); /* 输出11010011 */ dec_to_complement(-45, 16, 8, buf); printf(-45的8位十六进制补码: %s\n, buf); /* 输出D3 */实现要点这里用unsigned long long做位运算而不是有符号数是为了避免右移时符号位扩展带来的问题。mask的作用是把结果截断到指定位数比如8位就只留低8位高位全部清零。6.7 完整调试验证把上面几个函数拼起来跑一组测试用例dec_to_base(173, 8)→ 输出255正确。dec_to_base(173, 2)→ 输出10101101正确。base_to_dec(AD, 16)→ 输出173正确。base_to_dec(10101101, 2)→ 输出173正确。dec_to_base(-255, 16)→ 输出-FF正确。dec_to_complement(-45, 2, 8)→ 输出11010011正确。这组用例覆盖了正数、负数、整数、字符串解析实测结果全部符合预期。拿到工程里用的时候建议把这几个用例保留成单元测试后续改代码不容易改坏。7. 常见问题与排查技巧实录7.1 小数转换为什么算出来是无限循环这是刚学转换的人最容易困惑的点。用十进制小数转二进制如果一直乘2乘不到0说明这个小数在目标进制下是无限循环小数本身无法精确表示。遇到这种情况唯一做法是指定精度提前截断。但要知道截断会引入误差尤其是金融计算里如果拿浮点数做金额累加误差会越滚越大。稳妥做法是避免直接用浮点改用整数表示最小单位比如分或者用高精度库。7.2 C语言里printf格式化输出的坑我见过不少同事在这里踩坑%d用来打印十六进制数结果输出一堆乱码。实际格式符要跟进制配套进制格式符示例输出十进制%d或%ld173八进制%o255十六进制%x或%Xad或AD二进制无内置格式符需要自己实现C语言标准库里没有二进制的printf格式这是很多人没注意到的。需要输出二进制时写个循环逐位判断或者复用上面的dec_to_base函数别傻乎乎自己一位位去拼。再有就是%x输出的是无符号形式的十六进制如果直接传一个负的int进去会因为整型提升和符号扩展打印出ffffffff这种结果。想要预期的截断效果先转成unsigned int再传。7.3 补码转换时“位数”没搞清楚-45转8位二进制补码是11010011转16位补码是1111111111010011。很多人在做协议解析时字段长度明明定义的是16位却按8位的补码去解码结果数值完全不对。排查建议先确认协议里这个字段的位宽再做转换。位宽不同符号位的位置不同补码的解码结果也不同。反过来说0x00D3如果按16位有符号数解析结果仍然是正数211而不是-45因为符号位第15位是0。7.4 溢出问题转出来的值不对代码里如果用了int承接转换结果碰到大数就会溢出。比如十六进制FFFFFFFF转十进制32位int装不下。解决方案使用long long或unsigned long long承接大数转换结果。在做累加时先判断result (LLONG_MAX - val) / base如果成立就报溢出错误。嵌入式开发中如果必须用32位可以考虑把大数拆成多个字节分别解析再做拼接。7.5 排查口诀转换结果对不上时先查这三件事我在项目里排查进制转换问题固定按三步走效率很高查符号这个数据是有符号还是无符号字段定义里写没写signed查位宽字段是1字节、2字节还是4字节读串时按哪个长度切的查字节序协议里数据是大端还是小端多字节数据直接当字符串解析经常在这里栽跟头。遇到数值不对先做这三查八成能找到问题所在。剩下两成再回头看转换算法本身的代码逻辑。8. 一套趁手工具和手算技巧8.1 工程里常用的工具除了自己写代码实际干活时我也会用现成工具快速验证尤其是调试协议数据时Pythonint(FF2D, 16)一行搞定字符串转数字format(173, 08b)快速输出二进制开发调试首选。Windows计算器切换到程序员模式可以在HEX、DEC、OCT、BIN之间实时切换还能直接看补码形式非常实用。在线转换工具应付一次性的零散转换足够但如果数据量大还是写脚本更靠谱别把重要数据放进不熟悉的在线工具里。8.2 手算十六进制转二进制的速查技巧二进制和十六进制之间分组法是最快的。但要是记不住上面那张映射表可以换一个思路记住几个关键锚点。十六进制8对应的二进制是1000这是最高位为1的第一个数正常是7对应0111加1到8对应1000。十六进制A对应1010F对应1111。只要记住10001、20010、40100、81000这几个基准值其他十六进制数比如B11可以拆成821拼出1011。这个方法在调试时比查表快得多因为只需要记住4个基准位权。8.3 二进制负数的手算提示遇到二进制串要判断是不是负数就看最高位。但有一类特殊情况容易被忽略转换结果不足目标位数时正数要在前面补0负数要在前面补1。比如十进制0x7F是八位正数01111111如果高位缺了那个0就会变成11111111那是-1的补码。在做大小端拼接、位扩展时这类问题极易出现多加注意。我在实际项目中常用的三个习惯做进制转换的代码写久了慢慢形成几个固定习惯分享出来供参考。第一个习惯是所有解析函数必须做合法性校验。不管输入来自配置文件、网络报文还是用户键盘永远不要假设它是合法的。字符串里混进一个非法字符宁可立刻报错也不要默默返回一个误导性的值。这个习惯帮我拦下过很多次低级错误。第二个习惯是写完转换代码立刻补测试用例。正数、负数、0、最大值、最小值、带小数、带非法字符每个分支都测一遍。进制转换这种函数最容易被低估改起来“感觉很简单”结果一改就崩。第三个习惯是打印日志时统一标注进制。比如一组协议数据如果日志里直接打印FF看日志的人没法确定它是二进制、十进制还是十六进制。我一般在日志里写成0xFF或0b11111111的格式明明白白标清楚。这个小习惯让联调时候的沟通成本降低不少。进制转换不是那种需要天天背公式的冷知识而是渗透在每一行数据处理代码里的基本功。把底层原理吃透再配上顺手的实现工具遇到再奇葩的进制组合也不会慌。