简介:这是一份面向C/C++开发者与嵌入式开发者的进制转换代码资源,基于C/C++实现不同位数数据间的常用转换。无论是软件设计还是底层开发,涉及32位、16位、8位数据拆分与合并时都能直接参考或复用。资源压缩包共2个文件,包含1个cpp源文件和1个头文件,整体大小仅1KB,结构紧凑,可快速集成到项目中。已有291人学习/下载。具体功能涵盖:32位数据转2个16位、16位转2个8位、2个16位合并为32位、2个8位合并为16位、32位转4个8位以及4个8位合并为32位,基本覆盖了常见位宽转换场景。读者可在此基础上理解位运算与掩码的使用方法,并依据实际需求修改或扩充函数接口,提升嵌入式开发中数据处理的编码效率。
1. 进制转换不是除 K 取余:先看清这份“不同位数数据互转”的 C/C++ 实现
进制转换,最难的从来不是“除 2 取余”,而是不同位数的数据相互转换时那套位宽、符号和字节序的处理。我之前在数据采集模块里要把 8 位传感器读数拼成 16 位帧头,再转成十六进制字符串发给上位机,第一版用“除 K 取余 + 查表”写完,换一台机器跑结果全错——后来才明白,内存里的位模式没变,只是你解释它的方式变了。这份 C/C++ 实现的价值不在算法,而是把字符串与整数互转、窄类型升宽、宽类型降位截断、溢出检测这些容易埋雷的路径全走了一遍。适合做嵌入式采集、网络报文解析、协议调试、上位机通信的老铁,新手能从代码里看到“原来这里会踩坑”,熟手能直接拿里面的固定宽度类型和检测函数当基座改。
2. 数据宽度是转换的前提:从 int 的 4 字节到 long long 的 8 字节
不同进制只是数值的表示形式,真正影响转换结果的是数据类型宽度和符号解释。这一章先把“你手里的数是几个字节、有没有符号”这件事钉死。
2.1 C/C++ 整数类型宽度:int 不一定是 4 字节
很多人默认 int 就是 4 字节,这是目前在 PC 上最常见的,但并不是语言标准保证的。C/C++ 标准只规定了相对大小:short <= int <= long <= long long,具体宽度由编译器和平台决定。写跨平台转换代码前,先在本机跑一段探测代码:
#include <cstdio> #include <cstdint> int main() { std::printf("char : %zu bytes\n", sizeof(char)); std::printf("short : %zu bytes\n", sizeof(short)); std::printf("int : %zu bytes\n", sizeof(int)); std::printf("long : %zu bytes\n", sizeof(long)); std::printf("long long: %zu bytes\n", sizeof(long long)); std::printf("int8_t : %zu bytes\n", sizeof(int8_t)); std::printf("int16_t : %zu bytes\n", sizeof(int16_t)); std::printf("int32_t : %zu bytes\n", sizeof(int32_t)); std::printf("int64_t : %zu bytes\n", sizeof(int64_t)); return 0; }这段代码用sizeof打印常见整数类型的字节数。我在 x86-64 Linux 环境跑出来 int 是 4 字节,long 是 8 字节;之前在一台 8 位 MCU 的交叉编译环境里跑,int 直接变成 2 字节,long 还是 4 字节。注意sizeof(char)恒等于 1,但单位是 char 的宽度,不是字节的绝对定义,只是绝大多数平台下一个 char 就是一个字节。
写转换模块时我一般不再用int、long这类原生类型做位宽敏感逻辑,而是直接用<cstdint>里的固定宽度类型,比如uint8_t、int16_t、uint32_t。这样代码在 PC、MCU、嵌入式 Linux 上行为一致,至少不会因为 int 宽度差异导致同一段代码给出不同结果。
2.2 位模式与解释方式:0xFF 到底是 255 还是 -1
进制转换本质上是数值与字符串之间的映射,不是内存数据的物理修改。同样的 0xFF,落在uint8_t里是 255,落在int8_t里是 -1,因为内存里的补码位模式完全一样,只是解释方式不同。这一点在“不同位数的数据相互转换”里特别容易翻车:
#include <cstdio> #include <cstdint> int main() { uint8_t u = 0xFF; // 位模式: 1111 1111 int8_t s = static_cast<int8_t>(0xFF); // 同样的位模式, 解释成 -1 uint16_t e1 = static_cast<uint8_t>(s); // 先还原成无符号 8 位再升位, e1 == 255 uint16_t e2 = static_cast<uint16_t>(s); // 直接升位, 符号扩展成 0xFFFF, e2 == 65535 std::printf("u=%u s=%d e1=%u e2=%u\n", u, s, e1, e2); return 0; }这里static_cast<uint8_t>(s)先把 -1 的位模式还原成无符号 8 位值 255,再升位到uint16_t,结果就是 255;而static_cast<uint16_t>(s)直接把 int8_t 按有符号数做符号扩展,得到 0xFFFF。参数s是有符号 8 位整数,直接强转成更宽类型时会自动补符号位,导致数值语义大变样。
很多初学者以为强转就是“位数变了,数字不变”,实际上有符号数的升位是符号扩展,无符号数才是零扩展。后面第 4 章会专门给一套显式处理方式,这里只需要记住:转换前先问自己,这个变量的位模式是要按有符号解释还是无符号解释。
2.3 端序:同一段字节流在不同机器上读出不同数值
“不同位数数据互转”经常发生在解析网络报文或二进制文件时。假设你在文件里读到两个字节12 34,按大端序拼出来是 0x1234,按小端序拼出来是 0x3412。端序不统一,一切进制输出都是白做。
判断本机端序的常见做法是用联合体:
#include <cstdio> #include <cstdint> bool IsLittleEndian() { union { uint16_t v; uint8_t b[2]; } u; u.v = 0x1234; return u.b[0] == 0x34; } int main() { std::printf("%s\n", IsLittleEndian() ? "little-endian" : "big-endian"); return 0; }联合体让v和b共享同一块内存,写入 0x1234 后,b[0]取到的是最低地址的那个字节。在小端机上最低地址存低位字节 0x34,所以返回 true;在大端机上返回 false。这段逻辑依赖“联合体成员按声明顺序共享起始地址”,在主流编译器上没问题,但严格说在 C++ 标准里读非活跃成员属于未定义行为。生产环境我更推荐直接用编译器内置宏,比如__BYTE_ORDER__。
更重要的是读写协议数据时不要依赖本机序,而是明确按协议规定字节序来拼装。第 5 章会给出ReadLe16/ReadBe16的具体实现,那种显式拼字节的代码才是跨端序的可靠做法。
3. 字符串与整数互转:把解析和格式化做成可靠 API
转换模块里最常用的两个方向是“字符串 → 整数”和“整数 → 字符串”。这一章讲怎么用标准库安全实现,以及怎么封装成方便复用的接口。
3.1 字符串转整数:用 strtol 而不是 atoi
atoi很简洁,但它不提供任何错误机制:字符串非法时它返回 0,溢出时行为未定义,而且不能指定进制。做进制转换工具,我一般直接封装strtol族函数:
#include <cerrno> #include <climits> #include <cstdlib> #include <cstdio> bool SafeStrToLong(const char* s, int base, long* out) { if (s == nullptr || out == nullptr) { return false; } errno = 0; char* end = nullptr; long v = std::strtol(s, &end, base); if (errno == ERANGE) { *out = (v == LONG_MAX) ? LONG_MAX : LONG_MIN; return false; } if (end == s) { return false; // 一个有效数字都没解析出来 } *out = v; return true; }strtol的base参数直接决定进制:传 2 解析二进制,8 解析八进制,16 解析十六进制,传 0 时支持0x前缀自动识别。end指针指向解析结束的位置,end == s说明字符串开头就没有合法数字。errno == ERANGE表示溢出,此时strtol返回LONG_MAX或LONG_MIN,所以先取默认值再返回 false。调用方拿到 false 后应该停止后续转换,而不是继续用out里的值。
这个封装要注意base的范围,标准要求 0 或 2~36,传 1 或负数属于未定义行为。我一般会在入口处加一个if (base != 0 && (base < 2 || base > 36)) return false;的校验,成本极低但能挡住很多从配置表里读进来的奇怪参数。
3.2 整数转字符串:snprintf 与手工拼字符的取舍
整数转十六进制字符串最简单的方案是snprintf,格式控制强,还自带补零能力:
#include <cstdio> #include <cstdint> #include <string> std::string ToHexString(uint64_t value, int width = 0) { char buf[32]; if (width > 0 && width < 16) { std::snprintf(buf, sizeof(buf), "%0*llX", width, static_cast<unsigned long long>(value)); } else { std::snprintf(buf, sizeof(buf), "%llX", static_cast<unsigned long long>(value)); } return std::string(buf); }%0*llX中*位置传入width,表示输出最少width个字符,不足补零;llX表示按unsigned long long输出十六进制大写字母。注意width只保证最小宽度,值本身超过这个位数时不会截断。snprintf返回值是“应写入长度”,如果将来要处理超长结果,可以先调用一次获取长度再分配缓冲区,这里固定 32 字节对 uint64 已经够用。
二进制的格式化输出标准库没有现成格式符,我的习惯是先按位循环把每一位 '0'/'1' 填进std::string,再反转。手工拼字符时两个坑:一是char拼接要用push_back或预分配空间,避免反复+=导致多次重分配;二是注意从最高位开始取还是从最低位开始取,循环写法很容易把结果搞反。
3.3 封装成跨位数 API:模板和重载怎么组织
转换模块要服务“不同位数的数据相互转换”,接口层我一般用模板加重载组织,核心原则是:输入输出宽度分离,函数名能直观看出来它在做什么。
#include <cstdint> #include <type_traits> template <typename To, typename From> bool NarrowCast(From v, To* out) { static_assert(std::is_integral<From>::value && std::is_integral<To>::value, "NarrowCast only supports integer types"); if constexpr (std::is_signed<From>::value || std::is_signed<To>::value) { int64_t tmp = static_cast<int64_t>(v); if (tmp < static_cast<int64_t>(std::numeric_limits<To>::min()) || tmp > static_cast<int64_t>(std::numeric_limits<To>::max())) { return false; } } else { if (v > static_cast<From>(std::numeric_limits<To>::max())) { return false; } } *out = static_cast<To>(v); return true; }这个函数把From类型的值安全降位到To,超范围时返回 false。有符号参与时统一提升到int64_t比较,避免混用时的隐式转换陷阱。这里有个边界:如果From是uint64_t且值超过INT64_MAX,static_cast<int64_t>的结果是实现定义的。所以我在项目里额外加了一条约定:调用方不得把超过 63 位的无符号值传入这个有符号比较分支,必要时先掩码再调用。
模板参数推导也值得说一句:NarrowCast(big, &small)中第一个参数推导出From=int32_t,第二个参数推导出To=int8_t,不需要手写模板参数。如果哪天推导失败,检查out是不是变量地址,以及两个类型是否是整数类型。
4. 不同位数互转:符号扩展、位宽截断与溢出检测
这一章专门处理“位数不一样”的场景:窄类型升宽、宽类型降窄,以及有符号无符号混用。
4.1 窄类型升宽:先明确需要符号扩展还是零扩展
升位方向相对简单,坑主要在“要不要保留符号”。看这段:
#include <cstdio> #include <cstdint> int main() { int8_t raw = static_cast<int8_t>(0x80); // 位模式 1000 0000, 数值 -128 int32_t sign_ext = raw; // 符号扩展: -128 uint32_t zero_ext = static_cast<uint8_t>(raw); // 先转无符号8位, 再零扩展: 128 std::printf("sign_ext=%d zero_ext=%u\n", sign_ext, zero_ext); return 0; }直接拿有符号窄类型赋值给宽类型,C++ 会做符号扩展,0x80变成 -128;如果业务上想保留“位模式对应的无符号字节值”,就得先过一道static_cast<uint8_t>把符号位变成普通数值位,再升位。这个两步转换是处理传感器原始字节最常见的手法:很多采集芯片返回的就是int8_t格式的原始读数,逻辑值其实是 0~255 的物理量。
升位还有一个细节:char本身是否有符号由平台决定。有的编译器上char等价于signed char,有的等价于unsigned char。跨平台解析字节流时,我从来不用char存二进制字节,统一用uint8_t,从根上消掉这个不确定性。
4.2 宽类型降窄:先查范围再截断,别让编译器默默丢位
降位方向最危险的就是“悄悄丢高字节”。很多编译器的-Wconversion警告不会默认打开,赋值时多余的位直接丢掉,连个提示都没有。正确姿势是像 3.3 节那个NarrowCast一样,先比较范围再转:
#include <cstdio> #include <cstdint> #include <limits> int main() { int32_t big = 0x1234; // 4660 int8_t small = 0; if (NarrowCast(big, &small)) { std::printf("small=%d\n", small); } else { std::printf("overflow: %d out of int8_t range\n", big); } return 0; }NarrowCast返回 false 时,small不会被写入任何值,这是我在封装时特别强调的约定:失败时不改变输出,让调用方的错误处理路径更干净。参数big是 32 位源值,small是 8 位目标,函数内部把big与INT8_MIN、INT8_MAX比较后决定是否赋值。4660 显然超过 127,所以走 else 分支。
这里补一个真实遇到过的坑:不要用static_cast<int8_t>(big) == big这种方式判断溢出,因为等于号左边已经被截断,如果截断后的值和原值在低 8 位上恰好相同,判断会误通过。比如big = 0x1234截断成 0x34,两边不相等能发现;但big = 0x1FF截断成 0xFF 即 -1,跟原值 511 不相等。反过来big = 0x00FF截断成 255 然后转 int8_t 变成 -1,也不等。用“先比较再转”的模板逻辑是最省心的。
4.3 有符号无符号混用:比较和转换的双重陷阱
有符号和无符号混用是 C/C++ 里最经典的黑匣子。看这个例子:
#include <cstdio> #include <cstdint> int main() { int32_t a = -1; uint32_t b = 1; if (a < b) { std::printf("a < b\n"); } else { std::printf("a >= b\n"); } return 0; }实际打印的是a >= b。原因是在二元运算里,int32_t会被隐式转换为uint32_t,-1 变成 0xFFFFFFFF,也就是 4294967295,自然不会小于 1。我一开始也把这个当作“玄学”,后来养成习惯:凡是有符号和无符号出现在同一个表达式里,先手动统一类型再比较。
安全做法是显式转换:
if (a < 0 || static_cast<uint32_t>(a) < b) { // 先排除负数, 再看无符号比较 }进制转换时同样要小心:一个uint64_t的 0xFFFFFFFFFFFFFFFF 被强转成int64_t后是 -1,如果你接着把这个值格式化成十六进制字符串,位模式没变还是FFFFFFFFFFFFFFFF,但一旦参与有符号运算,语义全变。所以我在转换 API 里把所有位宽转换和进制格式化都按“位模式处理”设计,符号解释只发生在最外层的业务层。
5. 进制转换避坑指南:五个反复踩过的现场与根源
下面五条都是从实际调试里攒下来的血泪经验,每一条都按现象、原因、解决三个步骤写,遇到类似问题可以直接照做。
5.1 现象:8 位 MCU 上 int 是 16 位,照搬桌面代码结果全错
现象:同一段进制转换代码,PC 上输出正常,目标板上输出长度短一半,帧头解析错位。 原因:目标平台 int 只有 2 字节,unsigned也只有 2 字节,而我用的是int接收 32 位数据,高位直接被截断。代码里没有任何强制类型转换,编译也没警告,属于悄悄丢位。 解决:把所有位宽敏感变量换成<cstdint>固定宽度类型,比如uint32_t frame = (static_cast<uint32_t>(b0) << 16) ...,并打开-Wconversion -Wsign-conversion重新编译,把警告当成错误处理。
5.2 现象:解析“0x1F”失败,但解析“1F”成功
现象:用户输入0x1F转十进制,程序返回 0;输入1F却返回 31。 原因:strtol(s, &end, 16)遇到0x前缀时,标准行为是“base 为 16 时允许带 0x,但解析结束后 end 停在数字部分末尾”,本身不会失败。失败多半发生在我自己写的预处理函数里,把0x前缀截掉时只判断了前两个字符,或者把0x1F整体丢给了 base 为 10 的解析分支。 解决:统一在入口处做规范化。先检查前两个字符是否为0x或0X,去掉后再交给strtol(s + 2, &end, 16);同时保留一个had_prefix标志,后面格式化输出时再补回前缀。注意strtol的end指针在返回后会停留在数字的最后一个有效字符之后,如果你还传了0x前缀,end 也会指向数字末尾,容易让人误以为解析包含前缀。
5.3 现象:负数转十六进制出现一长串 F
现象:把 -1 转成十六进制字符串,输出FFFFFFFFFFFFFFFF,业务只要 2 位宽度,结果 16 位全出来了。 原因:uint64_t变量按位模式保存 -1 的补码是全 1,格式化时没有掩码,也没有限制输出宽度。 解决:明确输出语义。如果是协议字段按字节数输出,先value &= mask,mask 由字节数决定,比如 2 字节用0xFFFF,再调用ToHexString(value, width_in_hex_digits)补零。如果业务要的是“有符号数的十六进制表示”,那就直接以当前机器补码为准,不带掩码,并在接口文档里写清楚是按位模式输出。
5.4 现象:二进制字符串“10000000”被解析成 -128
现象:用strtol("10000000", nullptr, 2)解析 8 位二进制字符串,期望 128,得到 -128。 原因:strtol按long的有符号解释去解析二进制字符串,8 个比特凑成一个字节时,最高位被当成符号位。 解决:对二进制字符串使用无符号手写解析,别依赖strtol的 base=2:
#include <cstdint> bool ParseBinaryToUint(const char* s, uint64_t* out) { if (s == nullptr || out == nullptr) return false; uint64_t v = 0; for (const char* p = s; *p != '\0'; ++p) { if (*p != '0' && *p != '1') return false; v = (v << 1) | static_cast<uint64_t>(*p - '0'); } *out = v; return true; }这个函数从最高位开始逐位左移,始终保持无符号语义。“10000000”会被算成 128,而不是 -128。调试信息里看到strtol的二进制行为不对劲时,先想想它内部是按 long 的有符号范围在判溢出,8 位二进制串恰好落在负数区间里,这不是编译器 bug,是接口定义如此。
5.5 现象:跨平台读文件,同样的字节拼出不同数值
现象:同一份二进制数据文件,在 A 平台解析出 0x0102,在 B 平台解析出 0x0201。 原因:文件里按大端序存储的01 02,在小端平台用“直接强转结构体指针”的方式读取,字节序没转。 解决:别用结构体强转读文件,改用显式拼字节函数:
#include <cstdint> uint16_t ReadLe16(const uint8_t* p) { return static_cast<uint16_t>(p[0]) | (static_cast<uint16_t>(p[1]) << 8); } uint16_t ReadBe16(const uint8_t* p) { return (static_cast<uint16_t>(p[0]) << 8) | static_cast<uint16_t>(p[1]); }ReadLe16把低字节放在低位,ReadBe16把高字节放在低位,两个函数都是平台无关的。解析协议数据时我一般只保留一组字节序读取函数,命名里明确写 Le 或 Be,不搞“自动探测本机序再决定”的伪智能逻辑。二进制协议最大的特点就是字节序写死在规格书里,代码按规格书写就行,不需要聪明,需要确定。
6. 用边界值加随机差量测试钉死转换模块
转换模块的正确性靠肉眼看不出来,我习惯在集成前跑两层测试:一层是边界值矩阵,一层是随机差量对拍。
6.1 边界值测试矩阵
先把最容易被符号扩展坑到的值列成表格,每条都断言预期输出:
| 输入值 | 转十六进制(2 字节大写) | 预期输出 |
|---|---|---|
| 0 | 0000 | 0000 |
| 1 | 0001 | 0001 |
| 127 | 007F | 007F |
| 128 | 0080 | 0080 |
| 255 | 00FF | 00FF |
| 65535 | FFFF | FFFF |
0xFFFF是最典型的混淆点:它既是 -1 的补码位模式,也是 65535 的位模式。测试时我会分别从int16_t(-1)和uint16_t(65535)两个入口调用ToHexString,确认输出都是FFFF,但调用方拿到字符串后要怎么解释,由业务层决定,测试只钉住“位模式 → 字符串”这一段。
6.2 随机化差量测试:和标准库实现做对拍
边界值之后,用随机值把格式化函数和snprintf对拍:
#include <cstdio> #include <cstdint> #include <random> bool DiffTestHex(uint32_t seed) { std::mt19937 rng(seed); std::uniform_int_distribution<uint32_t> dist(0, UINT32_MAX); for (int i = 0; i < 10000; ++i) { uint32_t v = dist(rng); char expect[16]; std::snprintf(expect, sizeof(expect), "%X", v); if (ToHexString(v) != expect) { std::printf("mismatch at %d: v=%08X expect=%s got=%s\n", i, v, expect, ToHexString(v).c_str()); return false; } } return true; }snprintf的%X和 3.2 节封装的ToHexString在width=0时输出应该完全一致。随机种子固定,方便复现失败现场;一旦mismatch打出来,立刻知道是第几个随机值、期望和实际各是什么。这类对拍测试不覆盖业务语义,比如不区分 255 还是 -1,但它能快速暴露格式化层的手误,比如把%llX写成%llx导致小写输出。
从那以后,我每次改完进制转换相关代码,都强制自己先跑一遍这个矩阵再加随机对拍,确认没有翻车才敢合入。简单几步,省掉的是线上调试时一长串不可预期的十六进制输出。希望帮到你。
本文还有配套的精品资源,点击获取