做C开发这些年要说哪段代码写起来平平无奇、但工作中几乎天天用那进制转换肯定要算一个。尤其是十进制转十六进制平时写业务代码很少主动去写可真到了调试崩溃堆栈、分析协议报文、看内存数据、调颜色值的时候又绕不开它。我见过不少同事在这上面翻车要么转出来少了前导零要么负数处理不对要么std::hex把后面一串输出全带着十六进制。这篇内容我想把“十进制转十六进制”这件事彻底讲透从原理到手写实现、从标准库到边界处理适合刚开始学C的也适合工作里偶尔需要写工具函数的人。1. 理解十六进制为什么程序员离不开它1.1 什么是十六进制和十进制有什么本质区别十六进制Hexadecimal是一种基数为16的计数方式也就是说每位可以取0到15这16个值超过9的部分用A、B、C、D、E、F表示。和十进制逢十进一不同十六进制逢十六进一。为什么程序员偏爱它因为计算机内存最小可寻址单位是字节8位而8位二进制刚好可以用2位十六进制表示比如二进制1010 1111按四位一组拆开就是A和F即0xAF。这个映射关系让十六进制成为“二进制的压缩表示”读起来比一长串01直观得多。用一个生活化的类比十进制像我们平时数数从0数到9再往前进一位十六进制则像是设计了一套“每数到16才进一位”的规则于是数字的书写会更紧凑。比如255十进制看起来是三位数十六进制是0xFF只有两位而且一眼能看出它对应二进制1111 1111。1.2 实际开发中哪些场景绕不开十六进制我先说几个我真实遇到的场景崩溃日志的堆栈地址某个进程崩溃后日志里看到的函数地址、内存地址几乎全是十六进制你要逆向定位得能很快把十进制地址和十六进制地址对应起来。调试器和内存窗口我用某IDE调试时内存视图默认就是十六进制每一行展示16个字节如果你不能快速判断某个字节的十六进制值排查指针问题就会很痛苦。颜色值转换做图形界面、前端联调时RGB三个通道0到255的值经常要拼成#A1B2C3这样的十六进制串。协议与寄存器单片机、嵌入式开发中寄存器读写、Modbus报文、I2C地址等大量使用十六进制表示。我之前做某个串口调试工具每条报文都要把十进制转成十六进制才能核对校验位。安全与编码领域哈希摘要、ASCII编码、各类加密文件的hex dump都是十六进制的天下。坦白说很多初学者觉得进制转换是“考试题”但在这些真实场景里它就是一个每天都在用的基础工具。理解了这一点你再回头看“十进制转十六进制”这个题目就不会觉得它只是递归和取模的练习了。2. 手写转换算法从原理到可落地的C代码2.1 除基取余法手动转换的完整过程进制转换最基础的方法是“除基取余法”。拿十进制数255举例把它不断除以16记录每次的余数直到商为0然后把余数从后往前排列255 ÷ 16 15 余 15即F15 ÷ 16 0 余 15即F所以255的十六进制就是0xFF。再来一个例子十进制数4096除以16得到256余0256除以16得到16余016除以16得到1余01除以16得到0余1倒过来是0x1000。这个“除到商为0为止再把余数倒序输出”的过程就是核心原理。为什么是倒序因为第一次求出来的余数是最低位最后求出来的才是最高位。2.2 C实现从无符号整数到十六进制字符串实现也很直接我习惯把转换函数拆成“纯算法”和“格式修饰”两层。下面是核心版本#include string #include algorithm std::string toHexString(unsigned int value) { if (value 0) { return std::string(0); } static const char digits[] 0123456789ABCDEF; std::string result; while (value 0) { unsigned int remainder value % 16; result.push_back(digits[remainder]); value / 16; } std::reverse(result.begin(), result.end()); return result; }这里有几个细节值得注意先把0单独处理否则while循环不会执行返回空字符串。用静态字符数组而不是在循环里做if-else判断代码更简洁性能也更好。使用std::reverse而不是自己写倒序循环少造一次轮子也不容易写错边界。参数类型用unsigned int从语义上明确“非负整数转换”避免负数带来的意外。这样写出的结果是“ABC”这种不带前缀的字符串。如果要输出“0xABC”在外面拼一下前缀即可。关于负数、大小写、补零这些问题后面专门讲。2.3 递归实现与迭代实现的取舍同样思路也可以用递归实现void toHexRecursive(unsigned int value, std::string out) { static const char digits[] 0123456789ABCDEF; if (value 16) { toHexRecursive(value / 16, out); } out.push_back(digits[value % 16]); }递归的好处是天然处理了“倒序”先递归到最高位回来的时候从最高位开始写。递归深度在这里完全没问题int最大值也就8位十六进制。不过迭代版更可控我一直建议主力用迭代版本递归作为理解题目的思维练习。2.4 小数部分转换一个容易忽略的扩展点有些场景比如把0.1转成十六进制小数虽然不常见但面试偶尔会问。规则是小数部分不断乘以16取整数部分作为一位十六进制数字。拿0.1举例0.1 × 16 1.6取1剩下0.60.6 × 16 9.6取9剩下0.6后续会循环所以0.1的十六进制是0x0.1999……所以小数转换可能无限循环必须指定精度。代码可以这样写#include string std::string toHexFraction(double fraction, int precision 10) { static const char digits[] 0123456789ABCDEF; std::string result .; while (precision-- 0 fraction 0.0) { fraction * 16.0; int digit static_castint(fraction); result.push_back(digits[digit]); fraction - static_castdouble(digit); } return result; }这个实现要注意浮点误差比如0.1算到最后可能在某一位差一个数实际工程中实用度不高但理解“乘基取整”的思路对理解IEEE 754浮点表示的底层还是很有帮助的。3. 直接用标准库生产代码里我优先选择的几种写法手写转换适合学习原理但真实工程里我更推荐直接用标准库。不仅代码更短而且经过大量测试不容易出问题。下面三种方案我都实际用过。3.1 iostream 流派std::hex stringstream#include sstream #include string std::string iostreamHex(int value) { std::ostringstream oss; oss std::hex value; return oss.str(); }用std::hex操纵符让输出流以十六进制形式输出整数是最常见也最好记的写法。如果要控制大写配合std::uppercase要补零配合std::setw和std::setfill。#include sstream #include iomanip std::ostringstream oss; oss 0x std::uppercase std::hex std::setw(4) std::setfill(0) value; // 输出形如 0x00AF这里有我踩过的一个坑std::hex是“粘性的”一旦设置了会影响同一个流后续所有整型输出。如果复用同一个stringstream对象做多次输出后面没小心处理会得到一堆莫名其妙的十六进制结果。所以要么每次新建ostringstream要么用完马上用std::dec切回十进制。3.2 C风格输出snprintf 依然是稳妥选择#include cstdio #include string std::string snprintfHex(int value) { char buf[32] {0}; snprintf(buf, sizeof(buf), 0x%X, value); return std::string(buf); }%X输出大写十六进制%x输出小写%08X可以指定至少8位宽度并且前面补0。snprintf函数的第二个参数是缓冲区大小相比sprintf安全很多。虽然它是C函数但在C代码里完全可用而且性能很好很多对性能敏感的程序还在用。要注意如果预期输出的宽度很大缓冲区一定要留足32字节对于int已经足够但对64位整数至少需要18个字符再加符号建议直接给64字节。3.3 现代C方案std::to_chars 与 std::formatC17引入了std::to_chars专门用于高性能的数值转字符串。它不分配内存、不支持本地化、不抛异常速度非常快。用法如下#include charconv #include string #include system_error std::string toCharsHex(int value) { char buf[32]; auto [ptr, ec] std::to_chars(buf, buf sizeof(buf), value, 16); if (ec std::errc()) { return std::string(buf, ptr); } return std::string(conversion error); }C20之后std::format也更方便#include format std::string formatted std::format({:#x}, value); // 0x1f不过std::format需要较新的编译器老项目未必能用。我的建议是新项目且编译器支持C20优先std::format写起来最直观底层库对性能敏感优先std::to_chars普通场景iostream和snprintf都可以看团队代码风格。3.4 几种方案对比帮你快速选型方案最低C标准是否需要手动管理缓冲区性能格式控制能力我的推荐场景手写除基取余C98否中弱学习原理、面试iostreamC98否中强业务代码、日志snprintfC98是高强对性能有要求的转换to_charsC17是极高中高频转换的底层模块formatC20否高极强新项目的首选这里的“性能”是相对而言的。普通业务转换每秒几万次用哪种方案差别都不明显但如果在循环里构造大量字符串比如一次性处理几十万条协议报文to_chars的优势就很明显。我印象中在一个百万条数据转换的场景里to_chars比iostream有至少3倍左右的差距这个差距确实存在。4. 容易翻车的格式与边界问题进制转换核心算法不难真正容易翻车的是格式细节和边界值。我在代码评审里反复看到这些问题单独开一节讲。4.1 前缀、大小写、补零、分隔符先明确几个格式要求是否带0x前缀完全取决于用途。日志和调试里常见带0x协议纯数据一般不带因为解析方只取数字字符。大小写要统一。%X输出大写std::hex默认小写但可以加std::uppercase。颜色值#FF00FF常用大写哈希摘要常用小写这和协议规范有关最好封装成带参数的函数。补零常用于固定宽度场景。比如两个字节的地址希望显示成4位就必须用setw(4)setfill(0)或%04X否则0x000F会被写成0xF这在对齐文本、拼接报文时很容易造成误解。分号或空格分隔比如显示一个byte数组时通常输出“AF 01 3C”这种空格分隔的形式每个字节都两位。这其实不是“一个十进制数转十六进制”而是“每个字节单独转两位”千万别把整个数组当成一个大数一次转。我自己的实践是写一个统一的转换入口#include sstream #include iomanip struct HexFormatOptions { bool prefix true; bool uppercase true; int minWidth 0; char fill 0; }; std::string formatHex(unsigned int value, const HexFormatOptions opt) { std::ostringstream oss; if (opt.prefix) oss 0x; oss std::hex; if (opt.uppercase) oss std::uppercase; if (opt.minWidth 0) oss std::setw(opt.minWidth) std::setfill(opt.fill); oss value; return oss.str(); }这样调用时语义清楚不会到处散落“0x”和uppercase的拼接。在经历过多次评审要求统一格式后我发现一个简单的选项结构体是最省心的。4.2 边界值测试0、最大值、负数建议任何转换函数都跑一遍这组测试0十六进制是0不是空字符串也不是00。int最大值0x7FFFFFFF。int最小值如果按补码处理int的二进制全1对应0xFFFFFFFF但如果简单用负数转换成unsigned int再转换得到的是32位全1的那个值。这里要看你想要什么。负数本身十六进制本身不表达负号所以有两种常见策略。一是加符号比如-255转成“-FF”二是按补码解释直接转成无符号的“FFFFFF01”。多数协议和内存场景用的是第二种。我举一个很实际的例子某个崩溃日志里某地址显示为0xFFFFFFF8同事用int去读它看到是负数-8但真正的地址解析需要把它当作无符号整数处理。这时如果你用带符号处理去转换得到“-8”那就完全对不上内存视图了。所以我的建议是底层内存和协议相关默认按无符号转补码普通业务展示用户期望看到“-FF”这种带负号的版本。4.3 大数场景编译器警告与类型宽度在某些环境下int可能是16位的。虽然标准里int至少16位但实际现在基本是32位。如果处理64位整数比如一个文件偏移量或者时间戳用long long存储转十六进制时要特别注意类型。一个典型错误是long long value 0x1234567890LL; unsigned int truncated static_castunsigned int(value); // 高32位被截断这就是为什么转换函数的参数类型要和调用方数据类型严格匹配。我用的一个经验是64位值统一走uint64_t版本的转换函数32位值走uint32_t版本绝不互相强转。编译器开-Wconversion这类警告可以提前发现很多截断问题。数值溢出场景在进制转换里还有一个隐藏坑计算除以16过程中不会溢出但如果是把十六进制字符串“FF”往回转十进制用char存中间结果可能溢出。不过本主题只讲十进制转十六进制回转的坑就不展开了但思路是相通的。5. 实战经验常见错误、调试技巧与练习建议5.1 常见错误速查表现象可能原因解决方法输出是一串乱码把char类型当字符输出了先强转成unsigned int输出少0没有设置宽度%04X或setw(4)负数变成大正数符号位未处理明确按无符号还是带符号转换明明用了hex后面输出也变十六进制流操纵符粘性用完std::dec复位或重建流前缀重复出现多次拼接统一封装入口函数输出结果带科学计数法用了double存整数使用整型类型5.2 一次印象深刻的排查经历有一次我在做一个串口报文解析工具日志里需要同时打印十进制原始值和十六进制校验值。我复用一个stringstream第一次打印校验值时加了std::hex第二次打印普通十进制数据的时候忘了切回结果半个下午都在看“255ff”这种不伦不类的拼接。直到一个很有经验的同事提醒我才意识到是流的粘性问题。后来我定了一个规矩stringstream只在函数内部即时使用绝不跨语句复用即使复用也立刻std::dec复位。类似的问题其实很多人都会遇到写在这里帮大家避开。还有一个常见误解是栈地址转换。某个进程崩溃日志里地址是0x7ffe2a1c有人想把它转回十进制看大小直接用int接收结果溢出报了负数。我当时提示改用uintptr_t因为地址类型在C里就应该用这个类型表示转换时才不会丢位数。这个知识点在进制转换题目里不会写但工作里非常真实。5.3 建议给自己留组测试用例哪怕是这种“小函数”也值得写一组断言。我长期保留的测试模板大概是这样的#include cassert #include string void testHexFunctions() { assert(toHexString(0) 0); assert(toHexString(1) 1); assert(toHexString(15) F); assert(toHexString(16) 10); assert(toHexString(255) FF); assert(toHexString(4096) 1000); assert(toHexString(0x7FFFFFFFU) 7FFFFFFF); assert(formatHex(255, {true, true, 4}) 0x00FF); }看到0、1、15、16、255、4096这些数了吗它们分别对应了刚好进位的边界、最大值附近、需要补零的场景。以后不管你是重构转换函数还是换用std::to_chars实现跑一遍这个测试就能确认行为没变。最后分享一个经验掌握进制转换的关键不是背代码而是把“除基取余、乘基取整”这八个字理解透。前者是整数场景的核心后者是小数场景的核心。理解了原理你不仅能写十进制转十六进制随手改成转二进制、转八进制都没区别因为它们都是同一个套路。写代码的时候不要想着“题目完成了”而是想着“这个函数放到真实系统里能不能经得住边界值和格式要求的考验”。带着这种心态练习你学到的东西就不仅是一个语法点而是一个真正可复用的工程能力。