简介:面向C/C++开发者与嵌入式工程师,本资源提供一套实用进制转换代码,专注解决不同位宽数据间的拆分与合并需求,例如将32位数据拆为两个16位或4个8位,或将两个16位、4个8位重新拼回32位,在寄存器读写、传感器数据处理、通信协议拆包组包等场景中都能直接应用。压缩包包含2个文件,即1个cpp源文件与1个头文件,包体仅1KB,代码极简,无冗余依赖,便于快速集成到现有项目。当前已有291人学习浏览,适合C语言/C++初学者理解位掩码与移位操作,也适合嵌入式开发人员参考实现。代码共提供六种转换功能:32位转2个16位、16位转2个8位、2个16位转1个32位、2个8位转1个16位、32位转4个8位、4个8位转1个32位。每个功能均以独立函数呈现,参数与返回类型直观,注释清楚,可直接拷贝使用,帮助读者避免重复编写底层位运算代码。 前几天调一个串口固件,上位机下发一帧十六进制字符串 "FF80",我的程序用sscanf(p, "%x", &val)去解析,Windows 上跑得好好的,交叉编译到 16 位嵌入式环境后,val 永远只有低 16 位,高字节被悄悄丢掉。排查了整整一下午,最后发现根本不是协议的问题,而是我在做进制转换时没有考虑不同位数的数据宽度。进制转换在 C/C++ 里属于那种“看着简单、用起来总出事”的基础功能,尤其当你面对不同位数的数据相互转换时,符号扩展、截断、字节序、字符串长度,每一个都可能是隐藏的雷。
这篇东西适合正在写协议解析、嵌入式驱动、MCU 固件,或者需要处理寄存器位域的开发者。不管你是用 VS Code 搭的编译环境,还是 CMake 构建工程,下面这套逻辑都通用。我会先讲清楚“位数”到底指什么,再给出手写算法、标准库选型、位宽互转的完整思路,最后放一套可以直接抄走的工具封装和边界用例清单。
1. 别小看进制转换:那个让我排查一下午的符号位问题
1.1 两个“位数”根本不是一回事
很多人听到“不同位数的数据相互转换”,第一反应是 8 位、16 位、32 位、64 位。但实际写代码时还要面对另一种“位数”——进制字符串的长度。同样是数值 255,十六进制是 "FF"(2 个字符),二进制是 "11111111"(8 个字符),这只是显示层的变化。真正容易出问题的是前者:同一个 0xFF,放在uint8_t、int8_t、uint16_t、int16_t里,数值语义完全不一样。
先看一个最基础的例子:
uint8_t a = 0xFF; // 255 uint16_t b = a; // 255,无符号数零扩展 char c = 0xFF; // 在大多数平台上是 -1(char 默认 signed) int d = c; // -1,发生了符号扩展同一个字节,零扩展得到 255,符号扩展得到 -1。所以进制转换一旦和位宽互转混在一起,就必须要先明确:你手里拿到的原始数据是“无符号数值”还是“有符号数值的补码位模式”。这也是为什么标题强调“不同位数”,而不是简单说“写个转换函数”。
1.2 位宽错位引发的经典故障
我遇过的另一个高频问题,是十六进制字符串被静默截断。协议里规定字段是 32 位,但解析代码写成了uint16_t temp; sscanf(hex, "%hx", &temp);,结果后面 16 位被丢弃,而且编译器还不报错,因为%hx会把参数当成unsigned short*,类型对得上,逻辑上却是错的。
还有一个更容易忽略的场景:从 bytes 数组里拼一个数。
uint8_t buf[4] = {0x12, 0x34, 0x56, 0x78}; uint32_t v = (buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3];这段看起来没问题,但buf[0]是uint8_t,在位移运算里会被提升为int,假如它是char[]且平台默认signed char,符号位就可能在移位时扩散。所以处理字节序时我从来不用char[],一律用uint8_t[]或unsigned char[]。
这一段我想表达的结论是:在做进制转换之前,先确认数据在内存里的“解释方式”是什么。字符串只是中间载体,真正被转换的是数值语义。
2. 手写转换算法:除基取余、查表、位运算各管一段
2.1 除基取余法:最省心的通用路径
手写进制转换,无非两类需求:数值转字符串,字符串转数值。数值转字符串最朴素的思路是除基取余,对 2、8、10、16 进制都通用:
std::string toString(uint64_t value, int base) { if (base < 2 || base > 36) return {}; const char* digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; char buf[65]; int pos = sizeof(buf) - 1; buf[pos] = '\0'; do { buf[--pos] = digits[value % base]; value /= base; } while (value != 0); return std::string(buf + pos); }为什么从后往前填?因为除基取余得到的第一个余数是最低位,最后一个余数是最高位。先写进buf的尾部,最后再取buf + pos,省掉了 reverse 的麻烦,也避免反复拼接字符串。这个写法的时间复杂度是 O(log_base(n)),64 位整数最长也就是 64 个二进制位,性能完全不用担心。如果只要 C 版本,把std::string换成调用方传入的char buf[]和长度,其余逻辑一模一样。
需要注意负数。如果是int64_t且十进制输出,我通常先判断符号,再把绝对值按无符号处理;如果是转二进制或十六进制,很多嵌入式协议里的习惯是直接输出补码位模式,也就是把int64_t转成uint64_t再调用上面的函数。两种口径没有对错,但接口注释里必须写明。
提示:在
value % base之前,先把负数转成uint64_t,避免对负数取余在不同标准里的实现差异。
2.2 解析十六进制字符串:逐字符查表加溢出哨兵
字符串转数值,最稳的反而是手写查表。标准库函数方便,但边界控制不一定细。下面这个函数处理 0x 前缀、大小写,并且带溢出检查:
bool parseHex(const char* s, uint64_t& out) { if (s == nullptr || *s == '\0') return false; if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s += 2; uint64_t val = 0; for (; *s != '\0'; ++s) { uint64_t d; char c = *s; if (c >= '0' && c <= '9') d = uint64_t(c - '0'); else if (c >= 'a' && c <= 'f') d = uint64_t(c - 'a' + 10); else if (c >= 'A' && c <= 'F') d = uint64_t(c - 'A' + 10); else return false; if (val > (UINT64_MAX - d) / 16) return false; val = val * 16 + d; } out = val; return true; }关键在溢出检查:(UINT64_MAX - d) / 16相当于先判断这次乘法有没有可能溢出,再决定要不要继续。很多初学写法是if (val * 16 + d < val),这个在补码下通常能拦一部分,但碰到val * 16已经溢出的情况,行为就不确定了。查表的c - 'a' + 10可读性更好,而且手动跳过 0x 前缀,正好能补上标准库函数不认前缀的短板。
3. 标准库选型对比:sscanf、stringstream、to_chars 怎么配合
3.1 C 函数:顺手但别忽略细节
sscanf和sprintf是 C 时代的经典,短代码里用起来确实爽。但有三个点必须盯紧:一是格式串里的长度修饰符。%x读的是unsigned int*,在 32/64 位平台上unsigned int是 32 位,读 64 位要用SCNx64或者手动%llx;二是在部分 16 位平台上,printf家族对%llx的支持不完整,有的交叉编译器直接输出 "llx" 三个裸字符;三是sscanf不关心你实际读了多少字符,无法区分 "FF" 和 "0xFF",也容易把多余的尾巴留给下一个字段。
strtol/strtoul系列要可靠一些,因为有endptr可以判断解析终点,还有errno反映 ERANGE 溢出。但它的签名接受const char*,C++ 代码里得先c_str(),遇上空字符串、全空白、带符号的溢出,行为也需要单独列测试用例。
注意:
strtol默认把 0 开头但第二位不是 x 的字符串按八进制解释,比如 "010" 会解析成 8。如果你只想严格按十进制,必须显式指定 base=10,或者自己处理前导零。
3.2 C++ 流与 bitset:方便但有限制
C++ 相对 C 扩充的std::stringstream配合std::hex、std::setbase平时写 demo 很好用,一两个转换无妨。但它的性能一般,而且读不到“解析到哪个位置”这种信息,出错定位要靠异常,很多嵌入式环境又关掉了 RTTI 和异常。std::bitset则只能做二进制输出,模板参数是编译期常量,std::bitset<8>和std::bitset<16>是两个不同类型,没法根据运行时的位宽动态选择。你要是写一个通用转换模块,bitset 基本帮不上忙。
我在实际项目里对流和 bitset 的定位是“调试工具”,不是“正式转换组件”。
3.3 C++17 的 to_chars / from_chars:我现在的主力
C++17 加入的<charconv>头文件提供了std::to_chars和std::from_chars,这两个函数不抛异常、不分配内存、不依赖 locale,速度比 sscanf/stringstream 快一截,而且它能精确告诉你解析停在哪里。这是我现在做进制转换的主力:
char buf[32]; auto res = std::to_chars(buf, buf + sizeof(buf), 0xFFu, 16); if (res.ec == std::errc()) { std::string hex(buf, res.ptr); // "ff" } uint64_t v = 0; auto pres = std::from_chars(str.data(), str.data() + str.size(), v, 16); if (pres.ec == std::errc()) { // 解析成功,v = ... }注意两个限制:from_chars不认 0x 前缀,遇到 "0xFF" 会在 'x' 处停住,需要自己跳前缀;to_chars输出小写字母,没有大小写选项。不过这两个缺点都能用几行代码弥补,换来的是确定性行为和清晰的错误码,总体来说非常划算。
4. 不同位宽互转的本质:符号扩展、截断与溢出检测
4.1 窄转宽:符号扩展和零扩展的取舍
窄类型转宽类型,信息不会丢,但结果有两种语义。无符号数直接赋值就是零扩展;有符号数的赋值在主流平台上是符号扩展,C++ 的隐式转换规则也保证在数值可表示时结果正确。
真正要小心的地方是位模式解释。比如你从一个寄存器里读回0xFFFF8000,希望把低 16 位当成int16_t的补码。直接static_cast<int16_t>(raw & 0xFFFF)在主流平台会得到 -32768,但这是依赖补码表示的实现定义行为。C++20 起有符号整数被标准固定为补码,很多工具链也早就按补码处理,可你的代码要尽量让意图明确:
uint64_t signExtendLowBits(uint64_t value, int bitWidth) { if (bitWidth <= 0 || bitWidth >= 64) return value; uint64_t mask = (1ULL << bitWidth) - 1; uint64_t m = value & mask; uint64_t signBit = 1ULL << (bitWidth - 1); if (m & signBit) m |= ~mask; return m; }这个函数的语义是:把 value 的低 bitWidth 位取出来,如果最高位是 1,把它当负数做符号扩展。它不关心 value 本身的类型,只关心位模式,这样在协议解析和寄存器位域处理里特别好用。
4.2 宽转窄:溢出检测的两种封装思路
宽转窄最怕的是静默截断。你解析一个 32 位字段,想塞进 8 位变量,不检查就直接强转,数据就悄悄变掉了。我通常提供两个接口:一个是按数值语义检查的narrowFrom,一个是按位截断的bitcast,调用方自己决定用哪个。
template <typename T> bool narrowFrom(uint64_t value, T& out) { static_assert(std::is_integral<T>::value, "T must be integral"); if (value > static_cast<uint64_t>(std::numeric_limits<T>::max())) return false; out = static_cast<T>(value); return true; }注意这个版本只处理“非负数值塞进目标类型”的场景。如果要把一个 32 位有符号数转成 8 位有符号数,逻辑就是我上面说的 signExtend 和 narrowFrom 组合:先把位模式扩展成 64 位有符号语义,再用窄化接口判断范围。封装的价值就在于把这一串容易漏掉的检查集中到一处,而不是散落在每个业务函数里。
4.3 字节序:位宽转换里常被漏掉的一环
不同位宽数据互转,还有一个隐形因素是字节序。同样是 32 位整数 0x12345678,大端内存里是 12 34 56 78,小端内存里是 78 56 34 12。如果你的数据来自网络、串口、存储文件,那字节序模块几乎和进制转换绑定在一起,尤其是网络协议普遍用大端,而 x86/ARM 常用小端。
bool isLittleEndian() { uint16_t x = 0x0001; return *reinterpret_cast<uint8_t*>(&x) == 0x01; } uint16_t bswap16(uint16_t v) { return uint16_t((v >> 8) | (v << 8)); } uint32_t bswap32(uint32_t v) { return ((v & 0xFFu) << 24) | ((v & 0xFF00u) << 8) | ((v & 0xFF0000u) >> 8) | ((v & 0xFF000000u) >> 24); }写这个不是为了炫技,而是提醒一个组合场景:“读 4 个字节拼成 uint32_t”和“把这个 uint32_t 显示成十六进制字符串”之间,隔着一个字节序问题。拼数时用大端还是小端,决定了后续进制转换的结果一样还是相反。我的经验是:在数据入口处统一转成本机字节序,之后所有进制转换只面对数值语义,不要中间夹带字节序逻辑。
5. 可直接复用的转换工具类封装
5.1 接口怎么设计才算顺手
设计上我只保留三类功能:数值转字符串、字符串转数值、位宽安全互转。绝不把字节序、进制转换、符号扩展全堆在一个函数里,那样参数会失控。接口尽量简单、返回值直观:字符串转数值用 bool 返回值加出参,数值转字符串直接返回 string。
5.2 完整实现代码
// radix_util.h —— 单一头文件,C++17 #pragma once #include <cstdint> #include <string> #include <limits> #include <type_traits> namespace radix { std::string toString(uint64_t value, int base = 10, bool upper = false) { if (base < 2 || base > 36) return {}; const char* lower = "0123456789abcdefghijklmnopqrstuvwxyz"; const char* upperD = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const char* digits = upper ? upperD : lower; char buf[65]; int pos = sizeof(buf) - 1; buf[pos] = '\0'; do { buf[--pos] = digits[value % base]; value /= base; } while (value != 0); return std::string(buf + pos); } inline std::string toString(int64_t value, int base = 10) { if (value < 0 && base == 10) return "-" + toString(uint64_t(-(value + 1)) + 1, base, false); return toString(uint64_t(value), base, false); } bool fromString(const std::string& s, int base, uint64_t& out) { size_t i = 0; bool neg = false; if (s.empty()) return false; if (s[i] == '+' || s[i] == '-') { neg = (s[i] == '-'); ++i; } if (base == 16 && i + 1 < s.size() && s[i] == '0' && (s[i + 1] == 'x' || s[i + 1] == 'X')) i += 2; if (i >= s.size()) return false; uint64_t val = 0; for (; i < s.size(); ++i) { char c = s[i]; uint64_t d; if (c >= '0' && c <= '9') d = uint64_t(c - '0'); else if (c >= 'a' && c <= 'z') d = uint64_t(c - 'a' + 10); else if (c >= 'A' && c <= 'Z') d = uint64_t(c - 'A' + 10); else return false; if (d >= uint64_t(base)) return false; if (val > (UINT64_MAX - d) / uint64_t(base)) return false; val = val * uint64_t(base) + d; } out = neg ? (0ULL - val) : val; return true; } template <typename T> bool narrowFrom(uint64_t value, T& out) { static_assert(std::is_integral<T>::value, "T must be integral"); if constexpr (std::numeric_limits<T>::is_signed) { if (value > uint64_t(std::numeric_limits<T>::max())) return false; out = static_cast<T>(value); } else { if (value > uint64_t(std::numeric_limits<T>::max())) return false; out = static_cast<T>(value); } return true; } uint64_t signExtendLowBits(uint64_t value, int bitWidth) { if (bitWidth <= 0 || bitWidth >= 64) return value; uint64_t mask = (1ULL << bitWidth) - 1; uint64_t m = value & mask; uint64_t signBit = 1ULL << (bitWidth - 1); if (m & signBit) m |= ~mask; return m; } } // namespace radixtoString(int64_t)里我特意写了uint64_t(-(value + 1)) + 1,而不是直接取绝对值,这是为了避免INT64_MIN在取绝对值时溢出。这个细节踩过的人应该懂。
5.3 三个真实使用场景
第一个场景是协议日志输出。收到一个 32 位大端字段,先 bswap 成本机序,再radix::toString(v, 16, true),直接得到带大写字母的十六进制字符串,和 Wireshark 的显示对齐。第二个场景是寄存器配置。把若干位域拼成一个 uint32_t,需要把一个带符号的 int32_t 写入固定位宽,signExtendLowBits比人肉移位清晰得多。第三个场景是跨平台配置解析。ini 文件里写baud=FF00,fromString统一解析成 uint32_t 再做位宽安全窄化,避免某些设备上 9600 被%u误读。
这段代码我在多个项目里改过几轮,目前这个版本是“少报错、好阅读、易扩展”的平衡点。你要是觉得模板不够,可以继续把narrowFrom拆成narrowSigned/narrowUnsigned,看项目风格。
6. 边界用例与踩坑清单
6.1 一份可以当测试基准的用例表
边界用例看似琐碎,但进制转换恰恰是那种“98% 情况下正常、2% 情况下致命”的功能。你可以在代码审查阶段让人一眼看出 0x 前缀有没有处理,但溢出和符号扩展是看不出来的,必须靠用例钉死。尤其当你把转换工具抽象成公共库之后,调用方可能传任何值进来,不把边界测试当一等公民,迟早会在某个半夜被叫起来处理线上问题。
| 用例 | 输入 | 期望结果 | 关注点 |
|---|---|---|---|
| 十进制转二进制 | toString(42, 2) | "101010" | 基本路径 |
| 十六进制转数值 | fromString("0xFF", 16, v) | v == 255 | 0x 前缀 |
| 八进制前缀坑 | fromString("010", 10, v) | v == 10 | 前导零不按八进制 |
| 溢出检查 | fromString("10000000000000000", 16, v) | 返回 false | 64 位上限 |
| 负数取绝对值 | toString(INT64_MIN, 10) | "-9223372036854775808" | 不溢出 |
| 低 16 位符号扩展 | signExtendLowBits(0xFFFF8000, 16) | 0xFFFFFFFFFFFF8000 | 符号位 |
| 宽到窄溢出 | narrowFrom(0x100, uint8_t) | 返回 false | 静默截断 |
| 大小端字节序 | bswap32(0x12345678) | 0x78563412 | 字节序 |
这 8 组用例我每次迁移平台都要跑一遍。你别嫌麻烦,进制转换的 bug 大多数不是算法错了,而是边界没覆盖。
6.2 我踩过的四个坑
第一个坑:char 数组参与算术。uint8_t是unsigned char的别名,但普通char的符号性由平台决定。所有和位运算、查表、移位相关的数据,我都强制声明成uint8_t或unsigned char。
第二个坑:%x的宽度。32 位平台读 64 位值,sscanf(s, "%x", &v)只写低 32 位,高 32 位是旧值,编译器还经常不警告。用%lx也不保险,跨平台还是用strtoull或from_chars稳。
第三个坑:from_chars停在中间。解析 "0x1A" 时它会在 'x' 处返回,res.ptr指向 'x',如果你没检查res.ptr是否等于输入末尾,就会误以为整个字符串都合法。碰到这种情况先跳前缀,不要偷懒。
第四个坑:负数进十六进制字符串再解析回来,很容易绕晕。我的约定是:协议层一律用无符号数,业务层才解释符号。也就是说,(int32_t)-1显示成FFFFFFFF,解析回来先得到0xFFFFFFFF这个uint64_t,再用narrowFrom压回uint32_t,最后才由业务代码决定它表示 -1。绝不在进制转换函数内部混入符号解释。
这四条是我个人经验里概率最高的坑。你如果也有类似的,欢迎按自己的项目补充成一份清单,比看书管用。
最后再分享一个小技巧:调试进制转换相关的问题时,我会先在代码里故意打印“未转换前的原始内存”——用memcpy把整数逐字节拷贝到uint8_t数组里打出来,再看转换后的字符串。很多时候真正的问题是内存里的字节序和符号位,而不是字符串本身。这个动作只要几行代码,但能把排查范围一下子缩小一半。
本文还有配套的精品资源,点击获取