
1. 先把需求说清楚hex 转换的隐藏分歧点C语言 16 进制整数转字符串这个题目看着像是初学者练习题但凡是在真实代码里写过日志、写过协议封包、写过寄存器调试输出的人都知道它是最容易被写错、也最容易被反复返工的一类小工具函数。原因不在算法难而在于“16 进制整数转字符串”这句话本身信息量太少它至少藏着六七个必须提前拍板的语义选择选错了函数在别人手里就会变成 bug 制造机。我见过不止一次一个看似无害的 hex 打印函数因为少写了一个\0或者把有符号负数直接右移导致整段日志乱码排查方向被带偏了大半天。这篇内容想解决的就是这件事把“把整数按 16 进制写成字符串”这个动作从随手一句sprintf升级成一个语义明确、边界清楚、可以放心复用的工具。适合三类人看——刚学完指针和数组、想搞清楚字符串到底怎么落地的同学在嵌入式或日志系统里天天和十六进制打交道的工程师以及手里有一堆祖传 hex 函数、想统一整理一遍的老手。文中会给三份可以直接抄的完整代码从十行的最小版到带容量检查、支持 64 位与大小写、支持前缀与补零的工程版都会逐段拆开讲。先说清楚它到底在干什么。一个 32 位无符号整数在内存里是 4 个字节在数学上是 0 到 4294967295 之间的一个值而我们要的是一串 ASCII 字符比如把0x00AB变成0、0、a、b、\0五个字节。这里的关键认知是这是数值到文本的转换和内存字节序没有任何关系。这一点必须提前摁死因为相当多的人第一次写这个功能时会绕到memcpy和大小端上去写出一堆在 x86 上正常、换到别的平台上就颠倒的函数。1.1 六个必须先定下来的语义我把实际项目里反复被问到的问题归成六条基本覆盖了 99% 的返工原因。第一条是符号传进去的是int还是uint32_t如果是int且值为 -1你期望输出-1还是ffffffff大多数调试场景要的是后者也就是补码的十六进制表示那就必须先在函数边界上把类型定成无符号。第二条是位宽输入是 8 位、16 位、32 位还是 64 位位宽决定了输出最短长度和缓冲区最小值也决定了移位循环的上界。第三条是是否补零到固定宽度。0x0A这个值输出a、0a、0000000a都是“对的”取决于你的对端要什么。协议里通常要求定长人看的日志通常要求最短。第四条是前缀要不要0x还是要大写的0X还是完全不要。第五条是字母大小写。第六条是失败时的行为缓冲区不够时是截断、返回错误码还是干脆不写。这六条如果不写进函数签名和注释里下一个调用者一定会按他自己脑子里的默认值来用。我的习惯是把它们全部变成参数或枚举标志位哪怕看着有点啰嗦也比三天后回来读自己的代码强。下面这张表是我一般会写进头文件注释里的对照明确到不用再猜。语义维度常见选项调试日志场景协议定长场景输入符号有符号 / 无符号无符号看补码无符号位宽8 / 16 / 32 / 64跟随变量类型由字段定义决定补零补 / 不补不补补满整个字段前缀有 / 无常带0x通常不带字母小写 / 大写小写看协议文档空间不足截断 / 报错 / 不写报错报错1.2 为什么不建议在核心路径上直接 sprintfprintf(%x, v)和snprintf(buf, n, %08x, v)是最省事的写法没有任何争议功能绝对正确可移植性也最好。但把它放进被调用几百万次的位置就会很难受格式化函数的实现要解析格式串、处理宽度、精度、长度修饰符、各种标志位这条路走下来开销远比“取四次低四位、查表、写字符”大得多。做日志库、做高频状态打印、或者在没有完整标准库的裸机环境里通常就会放弃它。提示如果只是调试期临时打印别折腾直接用printf(%08x\n, v)省下来的时间比省下来的 CPU 值钱。真正值得手写的是那些会被调用到热路径、或者需要写进固定缓冲区再发出去的地方。还有一个常被忽略的点printf家族的格式符和参数类型必须严格匹配。uint64_t用%x打在 32 位平台上会只输出低半截size_t用%u打在 64 位平台上大概率报警告。这也是我倾向于自己写一个入口的原因——让类型在函数签名上就固定下来编译器帮你做检查。2. 四条实现路线各自适合什么场景把可选方案摊开来看其实就四类标准库格式化、非标准的itoa那一套、手写移位取位、以及查表加速。这四条不是互相替代的关系而是从“够用”到“高效”的一段光谱选哪条取决于你的调用频率、可移植性要求和代码审查强度。2.1 标准库格式化最稳但最重snprintf(buf, sizeof buf, %016llx, (unsigned long long)v)这一句能覆盖绝大部分需求长度修饰符、补零宽度、大小写全都支持而且行为有标准背书。它最大的优点是不需要测试——标准库已经被测了几十年。缺点除了性能还有两个隐蔽的坑一是返回值是“本该写入的长度”不是实际写入的长度用返回值当下标去续写缓冲会越界二是某些裁剪过的嵌入式 C 库里printf家族默认不带浮点支持有些甚至整个%x分支都实现得很粗糙行为可能不符合预期。我的判断标准很粗暴如果这个函数不是每秒调用上万次就用snprintf如果它在中断里、在裸机上、或者在性能剖析里出现在了热点列表上就换手写。中间地带没有太大价值。2.2 itoa 家族能用但不能依赖itoa不是 C 标准函数它是某些实现提供的扩展不同平台的原型还不一致有的三参数有的四参数有的返回指针有的返回 void。在跨平台项目里依赖它等于给自己埋一颗定时炸弹。更麻烦的是它的很多实现内部用的是静态缓冲区不可重入多线程下会互相覆盖写出来的字符串时对时错这种 bug 极难复现。我在代码审查里看到itoa会直接要求替换理由不是它不好用而是它把“不可移植”和“不可重入”两个属性一起带进了代码库。自己写二十行就能拿到一个完全可控的版本没必要省这点力气。2.3 手写移位取位主力方案核心思路只有两步每次取最低 4 位一个 nibble把它翻译成一个字符然后把值右移 4 位重复直到值为 0。因为取位是从低位到高位的而书写是从高位到低位的所以输出顺序天然是反的需要处理逆序问题。这个方案的代码量在十行到三十行之间开销是可预测的几条指令一个字符没有隐藏分支也不依赖标准库裸机环境里同样能用。逆序有两条处理路径我在实际代码里都用过。第一条是先写临时数组再倒着拷临时数组在栈上长度按最大位数开32 位是 8 字节64 位是 16 字节写完再反向复制到目标缓冲区。这条路径逻辑最直白缺点是破坏性测试不容易看出问题。第二条是从目标缓冲区末尾往前写先定位到结尾写入\0然后指针递减着填字符最后返回首地址。这条路径不需要额外临时空间但对缓冲区容量的计算要求更严算错一位就是越界写。我个人偏好第二条原因是它只碰一次目标缓冲区缓存友好而且强迫我把容量计算写对。但如果团队里新人对指针不熟第一条更好 review。2.4 查表加速只在真的需要时用标准做法里“nibble 到字符”的映射可以用三种方式实现if分支判断是否小于 10、算术变换、或者查一张 16 字节的小表。前两种各有小问题——分支在随机数据上会有预测失败算术变换比如c n 10 ? n 0 : n - 10 a本质上还是分支。查表是纯随机访问16 字节的表一定在 L1 缓存里稳定且快。再往上一层可以把“一个字节”直接映射成两个字符表变成 512 字节一次写 2 个字符循环次数减半。这种优化在有明显性能需求时值得做具体细节在第 6 节展开。但在写第一版的时候我不建议上先把正确性拿到手性能永远排在后面。3. 核心细节位宽、符号与缓冲区算术这一节讲的是最容易出错、也最值得写进团队规范的部分。前面说了算法只有两步但这两步周围全是坑。3.1 缓冲区最小长度的算法这是最容易出人命的地方。很多人凭感觉写char buf[8]去装一个 32 位值的完整十六进制表示然后写入 8 个字符加一个\0总共 9 字节正好越界一格。这个 bug 的可怕之处在于它经常“看起来正常”——多写的那一个字节落在栈上别的变量附近可能什么也没破坏等到换个编译器、换个优化级别才爆。正确的算法是位数 前缀 终止符。一个 N 位无符号整数的十六进制表示最多需要N/4个数字字符前缀0x占 2 个结尾\0占 1 个。所以 32 位需要8 2 1 11字节64 位需要16 2 1 19字节。这个结论可以写成宏让调用方自己算/* 以位为单位给出整数宽度得到足够容纳 0x 数字 \0 的字节数 */ #define HEX_BUF_SIZE(bits) ((bits) / 4 3) char buf32[HEX_BUF_SIZE(32)]; /* 11 */ char buf64[HEX_BUF_SIZE(64)]; /* 19 */注意如果你打算支持“补零到固定宽度”的语义宽度上限不能超过bits/4否则算出来的缓冲区就不够了。这个上限检查必须做在函数内部不能只写在注释里。还有一种更保守的写法用sizeof直接对最大类型算char buf[sizeof(uint64_t) * 2 3];。sizeof(uint64_t)是 8乘 2 得 16加 3 得 19结果一样而且不用记住“除以 4”这件事读起来更不容易算错。我个人在跨平台代码里更偏爱这一种因为它对“uint64_t一定是 8 字节”这个假设做了显式依赖哪怕哪天有人改类型也能跟着变。3.2 有符号整数的补码语义int x -1;直接传给一个内部做右移的函数会发生什么如果函数参数是intx 4在大多数实现上是算术右移高位补 1值仍然是 -1循环如果写成while (v)就永远退不出去直接死循环卡住。这是我在现场遇到过的问题表现是某次打印让整个线程挂住日志最后一行永远停在半截。解决办法是在函数接口上就切断这条路参数用uint32_t或uint64_t调用方负责转换。(uint32_t)(-1)得到的是0xFFFFFFFF之后所有移位都是逻辑右移高位补 0循环一定会终止输出就是ffffffff。这个转换不是取绝对值而是保留补码位模式正好符合调试场景想要的“看看这块内存里到底是什么”。顺带说一句printf(%x, -1)严格按标准是类型不匹配的未定义行为虽然实践中几乎总是打出ffffffff但这不是标准保证的东西。想要可移植的行为就自己转成无符号再传。3.3 nibble 到字符的映射与逆序处理取出的 nibble 值域是 0 到 15。0 到 9 映射到0到910 到 15 映射到a到f或大写。最直观的写法是查表static const char digits[] 0123456789abcdef;然后digits[n 0xF]。这张表连同结尾的\0一共 17 字节永远在缓存里代码也最短。如果用算术变换要注意别写错偏移n 10 ? (char)(0 n) : (char)(a n - 10)。这个写法本身没错但三目运算符在两边的类型不一致时会有整数提升返回int赋给char时需要显式转换否则某些编译器会给出截断警告。表法没有这个问题返回的就是char。逆序处理前面提了两条路。这里补一个细节如果采用“从末尾往前写”的方案必须在写第一个字符之前就把\0放在缓冲区最后一位然后指针向前推进。写完之后真正的字符串首地址是最终指针不是传入的缓冲区首地址。返回值必须返回这个指针否则调用方拿到的是一段包含垃圾前缀的缓冲区。这个错误我至少在三个不同的代码库里见过。3.4 移位与整数提升的两个陷阱第一个陷阱是移位次数不能等于或超过类型宽度。uint32_t v; v 32;是未定义行为。所以用“固定循环 8 次”的写法时第 8 次移位之后v一定是 0安全但如果写成for (i 0; i 32; i 4)这种边界写错的循环就会踩到 UB。用do { ... } while (v)的形式可以完全规避这个问题因为循环次数由值本身决定。第二个陷阱是整数提升。v 0xF如果v是uint8_t或char会先被提升为int再做运算如果v是有符号char且值为负v 0xF的结果虽然还是 0 到 15但中间过程依赖实现。所以在 hex 转换函数里我坚持参数类型是uint32_t或uint64_t中间不做任何窄化避免这类边界情况。4. 完整实现与逐段拆解下面给三份代码。第一份是给你看懂算法用的最小版第二份是准备长期用的工程版第三份是调用示例和验证用例。三份都在同一套语义约定下输入无符号、输出小写、可选前缀与补零。4.1 最小可用版32 位固定 8 位补零#include stdint.h static const char HEX_DIGITS[] 0123456789abcdef; /* 把 v 写成 8 位小写十六进制写入 outout 至少 9 字节 */ void u32_to_hex8(uint32_t v, char *out) { char *p out 8; /* 指向结尾之后一格 */ *p \0; /* 先放终止符 */ for (int i 0; i 8; i) { *--p HEX_DIGITS[v 0xFu]; v 4; } }这段的逻辑值得拆开看。p out 8让指针停在“第 9 个字节”的位置也就是终止符该待的地方写入\0之后*--p先把指针退到第 8 个字节索引 7再写入最低位的字符正好对应十六进制表示的最右一位。循环八次八位数字从右往左填满指针最后停在out此时out就是完整的字符串首地址。因为固定循环 8 次v为 0 时也会输出00000000符合“定长补零”的语义。如果你要的是最短表示把for换成do-while并处理v 0的特例即可具体写法在工程版里。4.2 工程版64 位、可配置、带容量检查工程版的接口设计目标是把所有语义变成显式参数失败时明确返回绝不静默越界。#include stdint.h #include stddef.h enum { HEX_UPPER 1u 0, /* 用大写字母 A-F */ HEX_PREFIX 1u 1, /* 输出 0x / 0X 前缀 */ HEX_PAD 1u 2 /* 按 min_digits 左补 0 */ }; static const char *hex_table(unsigned flags) { return (flags HEX_UPPER) ? 0123456789ABCDEF : 0123456789abcdef; } /* 成功返回写入的字符数不含 \0失败返回 0 并把 dst 置空串 */ size_t hex_u64(char *dst, size_t cap, uint64_t v, int min_digits, unsigned flags) { const char *dig hex_table(flags); char tmp[16]; int n 0; int pad 0; size_t total, k 0; /* 第一步低位到高位取出所有有效数字字符 */ do { tmp[n] dig[(unsigned)(v 0xFu)]; v 4; } while (v ! 0); /* 第二步计算需要补几个 0宽度上限是 16 */ if (flags HEX_PAD) { if (min_digits 16) min_digits 16; if (min_digits n) pad min_digits - n; } /* 第三步容量检查不够就整体放弃不留半截数据 */ total (size_t)n (size_t)pad ((flags HEX_PREFIX) ? 2u : 0u); if (cap 0 || total 1 cap) { if (cap) dst[0] \0; return 0; } /* 第四步按 前缀 - 补零 - 数字倒序 写出 */ if (flags HEX_PREFIX) { dst[k] 0; dst[k] (flags HEX_UPPER) ? X : x; } while (pad-- 0) dst[k] 0; while (n-- 0) dst[k] tmp[n]; dst[k] \0; return k; }拆解几个设计决定。用do-while而不是for是为了正确处理v 0的情况——do-while至少执行一次tmp[0]写入0然后v已经是 0循环终止输出单字符0语义正确。如果写成while (v)v 0时一个字符都不写输出空串这是另一类常见 bug。min_digits只在HEX_PAD打开时生效这样“宽度”和“补零”两个概念不会互相干扰。上限 16 是硬编码的因为uint64_t最多 16 个十六进制数字超过的部分没有任何意义。容量检查放在写任何字符之前失败时把dst[0]置为\0保证调用方拿到的永远是一个合法 C 字符串而不是随机内容——这个约定能省掉大量调试时间。total 1 cap里的1是终止符这个 1 是最容易漏的。我习惯写成这个顺序因为读起来是“总字符数加上终止符超过容量就失败”语义直接对应缓冲区定义。4.3 调用示例与预期输出#include stdio.h #include inttypes.h int main(void) { char buf[HEX_BUF_SIZE(64)]; /* 19 字节够 64 位 0x \0 */ size_t n; n hex_u64(buf, sizeof buf, 0, 0, 0); printf([%zu] %s\n, n, buf); /* [1] 0 */ n hex_u64(buf, sizeof buf, 0x00AB, 4, HEX_PAD); printf([%zu] %s\n, n, buf); /* [4] 00ab */ n hex_u64(buf, sizeof buf, 0xdeadbeefull, 0, HEX_UPPER | HEX_PREFIX); printf([%zu] %s\n, n, buf); /* [10] 0XDEADBEEF */ n hex_u64(buf, sizeof buf, UINT64_MAX, 16, HEX_PAD | HEX_PREFIX); printf([%zu] %s\n, n, buf); /* [18] 0xffffffffffffffff */ n hex_u64(buf, 4, 0x12345678, 0, 0); printf([%zu] %s\n, n, buf); /* [0] 空串容量不足 */ return 0; }对照这几组用例能覆盖绝大多数坑v 0的输出长度、补零与不补零、大小写与前缀的组合、满 64 位的边界、以及容量不足时的失败路径。写单元测试的时候我一般会再加两个0x0配min_digits 16加HEX_PAD应该得到 16 个 0以及缓冲区刚好等于total 1的情况验证不是“多一格才成功”。4.4 编译与自检gcc -stdc11 -Wall -Wextra -Wconversion -O2 -g hex.c -o hex gcc -stdc11 -fsanitizeaddress,undefined -g hex.c -o hex_dbg ./hex_dbg-Wconversion这个选项值得单独提。它会抓出所有隐式窄化转换包括size_t到int、int到char这些在 hex 函数里高频出现的位置。我第一次打开它的时候一个两百行的工具文件报了三十多条警告其中至少五条是真的会出问题。-fsanitizeaddress,undefined则用来抓越界和移位 UB前面那个少写一个\0的问题用 ASan 一跑就现形。5. 常见问题速查与排查实录真实现场的问题往往不表现为“输出不对”而是表现为“程序在某处崩了”“日志偶尔截断”“换个平台就不一样”。下面这张表是我这些年攒下来的对照表按症状查比按原因查快得多。5.1 症状对照表症状最可能的原因处理方式输出比预期多一位、末尾有乱码缓冲区少算终止符用bits/4 3重算容量输出fffffffe而不是fe有符号负数被符号扩展入参改成uint32_t循环不结束、线程卡死有符号负数算术右移恒为 -1同上并改do-while64 位值只打出低 8 位用了%x而非%llx或PRIx64换格式符或inttypes.h每次打印结果都不同返回了栈上局部数组的地址由调用方传缓冲区多线程下字符串互相覆盖函数内部用了静态缓冲区改成无状态、可重入协议对端解析错位该补零没补零打开定长补零标志大小写不符合对端要求字母表选错用标志位切换表5.2 三个踩坑复盘第一个坑少写终止符。某次在写一个把寄存器值追加进日志缓冲的函数我图省事用了“直接定位到末尾往前写”的写法但忘了先把终止符放到末尾。表现是日志行末尾偶尔跟着几个乱七八糟的字符而且时有时无——因为那一段内存恰好被后续调用覆盖了。用 ASan 跑了一遍立刻定位到读越界。修法很简单写字符之前先*p \0但这个顺序很容易在重构时被打乱所以我在函数开头就固定下来绝不在中间插入别的东西。第二个坑把一个int类型的传感器读数直接传进去。那个读数正常情况下是正数但上电瞬间可能读到 -1 表示无效。函数内部是while (v)加算术右移直接卡死看门狗复位现场表现为“设备每隔一段时间重启一次”。定位花了很久因为正常运行时完全复现不了。后来在函数签名上做了硬性约束参数必须是uint32_t调用点必须显式转换编译器就会在类型不匹配时提醒我。第三个坑缓冲区按位数算忽略了前缀。有人用char buf[8]去装 32 位值然后开了HEX_PREFIX写入 2 个前缀字符加 8 个数字再加终止符一共 11 字节整整越界 3 字节。因为栈布局的关系它覆盖的是一个不常读的局部变量测试全过上线两周后才在一次特定的错误路径上触发崩溃。这类问题的根本解不是“小心点”而是让容量计算成为一个宏、一个函数、一处定义所有人引用同一个来源。提示如果你的代码库里 hex 转换函数的缓冲区长度是各写各的先用 grep 把所有调用点列出来统一成一个宏。这一步的收益远大于任何性能优化。6. 性能与进阶什么时候值得折腾前面说过大多数场景用snprintf就够。但如果你的场景是高频日志、协议打包、或者没有完整标准库的环境自己写就有实际收益。这一节讲收益量级、加速手法和一个可移植性清单。6.1 量级对比与判断标准我在手边一台普通 x86_64 机器上做过一组粗略对比转换一个 32 位值到固定 8 位十六进制字符串循环一千万次snprintf大致在几百毫秒量级手写移位查表版在几十毫秒量级两者差一个数量级左右。具体数字随编译器、优化级别和标准库实现变化很大这里只作为量级参考不要当成基准数据引用。真正有意义的判断是这个函数在你程序的性能剖析里出现过吗如果它连前二十都没进优化它就是自我感动。另一个维度是代码体积。snprintf会把整个格式化引擎链进来在几十 KB Flash 的裸机环境里可能是几千字节的代价而手写版本只有几十字节。这种场景下手写的意义不在速度而在空间而且往往是唯一可行的选择。6.2 字节查表与定长输出如果确实要加速最直接的一招是把“nibble 查表”换成“字节查表”准备一张 512 字节的表索引是 0 到 255每项两个字符一次循环处理一个字节循环次数从 8 次降到 4 次。表本身用脚本生成别手敲# 生成 256 项、每项 2 字符的小写十六进制表直接粘进 C 源文件 items [{}.format(format(i, 02x)) for i in range(256)] for i in range(0, 256, 8): print( .join(items[i:i8]))生成出来是一串字符串字面量用拼接成一个大常量即可static const char BYTE2HEX[512] 000102030405060708090a0b0c0d0e0f 101112131415161718191a1b1c1d1e1f /* ... 其余 240 项由脚本生成 ... */ ;使用时从低位开始每次取一个字节const char *p BYTE2HEX[(v 0xFFu) * 2u];然后写p[0]和p[1]两个字符注意逆序时要反过来写。这张表 512 字节对缓存压力不大但换来的是循环次数减半和分支减少在热点路径上有实测收益。再进一步是定长输出专化。如果你的需求固定是“32 位转 8 字符无前缀”那么总长度在编译期就是常量可以去掉所有容量检查和补零判断直接把 8 个字符按固定偏移写进去。这种专化版本最适合放进宏或者static inline函数里让编译器内联展开。我一般会写一个通用的hex_u64做正确性基准再写一个专化版做热路径用测试保证两者输出一致。6.3 可移植性检查清单最后把跨平台时容易翻车的地方列成一份清单发布前逐条过一遍比重读代码快。参数类型是不是固定宽度用uint32_t/uint64_t别用unsigned long它在不同平台上宽度不同。打印时用没用inttypes.h的PRIx32/PRIx64跨平台混用%x和%lx是经典事故。移位次数是否可能达到类型宽度用do-while按值终止别用固定次数加错误的边界。缓冲区长度是不是从宏算出来的别在调用点手写数字。有没有依赖静态缓冲区多线程和递归调用下会出问题。大小写、前缀、补零的默认值是不是写进了头文件注释调用方不该靠猜。顺带提一句成对出现的需求字符串转回整数。strtoul(s, end, 16)是标准做法配errno检查和end是否指向末尾的判断能覆盖绝大多数解析场景。但要注意它会跳过前导空白、接受正负号、溢出时返回极值并置errno如果对端可能发来脏数据这些检查一个都不能省。我自己在解析协议字段时会额外限制长度避免超长输入把解析函数拖慢。我在实际使用中的体会是这类“看着像练习题的底层小函数”真正的价值不在算法而在于你有没有把语义边界、容量算术和失败路径一次性想清楚。写对一次之后省下的是无数次在日志里找乱码的时间。