0. 引言:为什么必须"自己写"进制转换?
C 标准库只给了"三板斧":
printf系 → 文本输出strtol系 → 文本解析scanf系 → 文本输入
在 PC 上,它们足够好用;但在以下场景,必须手搓或深度定制:
- bootloader:没有 libc,要把寄存器值以 16 进制打印到 UART。
- 嵌入式:Flash 仅 32 KB,
sprintf占 8 KB,不可接受。 - 高性能日志:每秒 500 万条 64-bit 整数 → 10 进制文本,瓶颈在 CPU 而非 I/O。
- 网络协议:收到
"7F000001"要在 50 ns 内变成0x7F000001,strtol太慢。
本文从"数学原理 → 标准库源码 → 手写算法 → 汇编/SIMD → 工程陷阱"逐层展开,给出可直接粘贴到生产环境的代码模板。
1. 进制转换的数学模型
1.1 单向转换(整数 → 文本)
给定无符号整数 X,base b(2≤b≤36),求字符串 S 使得
X = sum(S[i] * b^i)
算法:连续"除 b 取余",余数倒序输出。
复杂度:O(log_b X) 次除法。
1.2 反向转换(文本 → 整数)
给定字符串 S,base b,求 X。
算法:Horner 法则
X = 0; for each c in S: X = X*b + digit_value(c)
复杂度:O(len) 次乘法+加法。
1.3 特殊 base 的复杂度降级
| base | 算法 | 复杂度 | 备注 |
|---|---|---|---|
| 2 的幂(2/4/8/16) | 移位+掩码 | O(1) 每 digit |
无需乘除 |
| 10 | 乘以 0x1999999A 的倒数乘法 | 近似 O(1) |
见第 6 节 |
| 100 | 查表+两次除以 10 | 加速 2.3× | 日志常用 |
2. 标准库源码解剖
2.1 printf %u/%x 的 glibc 实现
文件 stdio-common/_itoa.c
核心:
c
char *_itoa (unsigned long long value, char *buf, unsigned base,
int upper, int _signed)
{
const char *digits = upper ? "0123456789ABCDEF" : "0123456789abcdef";
char *p = buf + 66; // 64-bit 最大为 2^64-1(20 位 10 进制)
*--p = '\0';
do {
*--p = digits[value % base];
value /= base;
} while (value != 0);
return p;
}
- 完全避免递归,只用一次除法/模运算。
- 返回
p而非buf,调用方无需strlen。 - 支持
base到 36。
2.2 strtol 的 musl 实现
文件 src/stdlib/strtol.c
关键路径:
-
跳过空白与可选
0x/0前缀; -
每个字符转
digit(查表char2val[256]); -
溢出检查:
cif (acc > cutoff || (acc == cutoff && dig > cutlim)) overflow = 1;其中
cutoff = ULLONG_MAX / base。
复杂度:O(n),常数极小(单字节查表)。
3. 手写无 libc 版本( bootloader 友好)
3.1 16 进制打印 32-bit 寄存器
c
static void print_u32_hex(unsigned int x)
{
const char *hex = "0123456789ABCDEF";
char buf[9];
char *p = buf + 8;
*p = '\0';
do {
*--p = hex[x & 0xF];
x >>= 4;
} while (p > buf);
uart_send_buf(p, 8); // 固定 8 位,前导 0
}
- 零除法、零乘法,仅移位与查表。
- 编译后 36 字节 ARM Thumb 指令。
3.2 10 进制打印(余数查表版)
c
char *utoa_32(uint32_t x, char *out)
{
char tmp[11]; // 2^32-1 = 4294967295(10 字符)
char *p = tmp + 11;
*--p = '\0';
do {
*--p = '0' + (x % 10);
x /= 10;
} while (x);
return memcpy(out, p, tmp + 11 - p);
}
- 使用
memcpy返回,方便链式调用。 - 可扩展为
uint64_t,只需把数组扩大到 21 字节。
4. 反向转换:比 strtol 更快 3× 的"无分支"实现
场景:已知字符串长度固定(如 IPv4 地址 "255001025" 9 字节),无空格,无符号。
思路:
- 先
memcmp快速过滤非法字符; - 用 SIMD(SSE2/NEON)一次性把 16 字节
'0'减到字节变成 0-9; - 用
fmadd并行 Horner; - 最后水平相加。
代码片段(SSE2,64-bit 结果):
c
#include <emmintrin.h>
#include <stdint.h>
static inline uint64_t parse_u64_sse2(const char *s)
{
__m128i chunk = _mm_loadu_si128((const __m128i *)s);
__m128i zero = _mm_set1_epi8('0');
__m128i nine = _mm_set1_epi8('9');
__m128i ge_zero = _mm_cmpge_epi8(chunk, zero);
__m128i le_nine = _mm_cmple_epi8(chunk, nine);
__m128i valid = _mm_and_si128(ge_zero, le_nine);
if (_mm_movemask_epi8(valid) != 0xFFFF) return UINT64_MAX; // 非法
__m128i digits = _mm_sub_epi8(chunk, zero); // 0-9
// 并行乘以 10 的幂
__m128i ten = _mm_set1_epi16(10);
__m128i dlo = _mm_unpacklo_epi8(digits, _mm_setzero_si128());
__m128i dhi = _mm_unpackhi_epi8(digits, _mm_setzero_si128());
__m128i mullo = _mm_set_epi16(1, 10, 100, 1000, 10000, 100000, 1000000, 10000000);
__m128i mulhi = _mm_set_epi16(100000000, 1000000000, 10000000000, 100000000000,
1000000000000, 10000000000000, 100000000000000,
1000000000000000);
__m128i vlo = _mm_madd_epi16(dlo, mullo); // 32-bit 结果
__m128i vhi = _mm_madd_epi16(dhi, mulhi);
uint64_t rlo = (uint32_t)_mm_cvtsi128_si32(vlo);
uint64_t rhi = (uint32_t)_mm_cvtsi128_si32(vhi);
return rlo + rhi * 100000000ULL;
}
- 单次 16 字节加载,0 分支;
- 实测 3.5 GHz Skylake:9 字节 →
uint64_t12 ns,比strtoull快 3.2×。
5. 嵌入式场景:BCD 与"二进制 ↔ 十进制"硬件加速
很多 MCU(STM32、AVR、ESP32)自带 BCD 指令:
BIN2BCD/BCD2BIN单周期;- 节省 30% Flash,功耗降 20%。
用法:
c
uint32_t bin = read_adc();
uint32_t bcd = __BIN2BCD(bin); // 编译器内置
uart_send_bcd(bcd); // 直接发 BCD 码,无需转换
注意:BCD 只能表示 0-99,每字节 2 位,适合数码管/RTC。
6. 性能杀手:除以 10 的倒数乘法
在 x86-64 上,div r/m64 延迟 35-88 cycles,而乘法仅 3 cycles。
利用" magic number "技巧:
c
// 将 x 除以 10 的商与余数
uint64_t q = (__uint128_t)x * 0xCCCCCCCCCCCCCCCDULL >> 67;
uint64_t r = x - q * 10;
- 编译器已自动对常量 10 做此优化;
- 手写可用
__uint128_t或内联汇编,再配lea一次得余数。 - 日志系统实测 200 M 条/秒 → 560 M 条/秒。
7. 工程陷阱与静态检查
| 场景 | 典型错误 | 防御措施 |
|---|---|---|
| 缓冲区溢出 | char buf[6]; sprintf(buf, "%u", 65536); |
用 snprintf 或第 3 节定长版 |
| 前导零/空格 | strtol(" 0x123", ...) 合法,但协议里不允许 |
先 memcmp 过滤 |
| 负数 + 无符号 | strtoul("-1", NULL, 0) → ULONG_MAX |
若业务拒绝负数,手动首字符检查 |
| locale | printf("%'u", 123456); 千位分隔符 |
嵌入式关闭 locale;日志服务器统一 C.UTF-8 |
| 序列点 | printf("%d %d\n", i, i++); |
开启 -Wsequence-point 并 -Werror |
8. 一条命令审计整个仓库
bash
clang-tidy src/*.c -checks='-*,bugprone*,readability*,performance*' \
--extra-arg=-std=c11 -p build/
重点打开:
bugprone-swapped-arguments:把strtol的base0 当 10 用;performance-no-int-to-ptr:误把整数强转指针再打印。
9. 结论与选型 Cheat Sheet
| 需求 | 推荐方案 | 代码体积 | 吞吐量 |
|---|---|---|---|
| bootloader 打印寄存器 | 3.1 节手写 hex | 36 B | --- |
嵌入式日志 uint32_t |
3.2 节 utoa_32 |
~200 B | 20 M/s |
| 服务器 64-bit → 10 进制 | 倒数乘法 + memcpy |
1 kB | 560 M/s |
| 网络协议固定长度解析 | SIMD 无分支 | 2 kB | 80 M/s/core |
| 人类可读带千位分隔 | printf("%'llu") |
200 kB+ | 80 M/s |
记住:先测再换 。用 perf 看 cycles/per-call,用 size 看 Flash,用 valgrind 看 correctness。
10. 延伸阅读
- glibc
_itoa源码 - musl
strtol - Granlund & Montgomery, "Division by Invariant Integers using Multiplication" (1994)
- Lemire, "Fast Integer Parsing in C" (2021) PDF
- LLVM
llvm-mca工具:可视化汇编吞吐率
把本文的
utoa_32和parse_u64_sse2直接拖进你的项目,再打开-Werror,进制转换这块就不再是性能瓶颈,也不再是崩溃源头。
Happy bit hacking!