
简介面向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十六进制是 FF2 个字符二进制是 111111118 个字符这只是显示层的变化。真正容易出问题的是前者同一个 0xFF放在uint8_t、int8_t、uint16_t、int16_t里数值语义完全不一样。先看一个最基础的例子uint8_t a 0xFF; // 255 uint16_t b a; // 255无符号数零扩展 char c 0xFF; // 在大多数平台上是 -1char 默认 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。如果你只想严格按十进制必须显式指定 base10或者自己处理前导零。3.2 C 流与 bitset方便但有限制C 相对 C 扩充的std::stringstream配合std::hex、std::setbase平时写 demo 很好用一两个转换无妨。但它的性能一般而且读不到“解析到哪个位置”这种信息出错定位要靠异常很多嵌入式环境又关掉了 RTTI 和异常。std::bitset则只能做二进制输出模板参数是编译期常量std::bitset8和std::bitset16是两个不同类型没法根据运行时的位宽动态选择。你要是写一个通用转换模块bitset 基本帮不上忙。我在实际项目里对流和 bitset 的定位是“调试工具”不是“正式转换组件”。3.3 C17 的 to_chars / from_chars我现在的主力C17 加入的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_castint16_t(raw 0xFFFF)在主流平台会得到 -32768但这是依赖补码表示的实现定义行为。C20 起有符号整数被标准固定为补码很多工具链也早就按补码处理可你的代码要尽量让意图明确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_integralT::value, T must be integral); if (value static_castuint64_t(std::numeric_limitsT::max())) return false; out static_castT(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_castuint8_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 —— 单一头文件C17 #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_integralT::value, T must be integral); if constexpr (std::numeric_limitsT::is_signed) { if (value uint64_t(std::numeric_limitsT::max())) return false; out static_castT(value); } else { if (value uint64_t(std::numeric_limitsT::max())) return false; out static_castT(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 文件里写baudFF00fromString统一解析成 uint32_t 再做位宽安全窄化避免某些设备上 9600 被%u误读。这段代码我在多个项目里改过几轮目前这个版本是“少报错、好阅读、易扩展”的平衡点。你要是觉得模板不够可以继续把narrowFrom拆成narrowSigned/narrowUnsigned看项目风格。6. 边界用例与踩坑清单6.1 一份可以当测试基准的用例表边界用例看似琐碎但进制转换恰恰是那种“98% 情况下正常、2% 情况下致命”的功能。你可以在代码审查阶段让人一眼看出 0x 前缀有没有处理但溢出和符号扩展是看不出来的必须靠用例钉死。尤其当你把转换工具抽象成公共库之后调用方可能传任何值进来不把边界测试当一等公民迟早会在某个半夜被叫起来处理线上问题。用例输入期望结果关注点十进制转二进制toString(42, 2)101010基本路径十六进制转数值fromString(0xFF, 16, v)v 2550x 前缀八进制前缀坑fromString(010, 10, v)v 10前导零不按八进制溢出检查fromString(10000000000000000, 16, v)返回 false64 位上限负数取绝对值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数组里打出来再看转换后的字符串。很多时候真正的问题是内存里的字节序和符号位而不是字符串本身。这个动作只要几行代码但能把排查范围一下子缩小一半。本文还有配套的精品资源点击获取