学习C语言的人应该都经历过类似的瞬间:群里有人发了一道题,问“unsigned char a = 200; a += 100; 为什么结果是44?”或者是面试官冷不丁来一句“int 在内存里到底怎么存的?为什么负数要用补码?”。很多人代码写得飞起,天天操作 int、float、指针,但真被问到“数据在内存中的存储”这种基础问题时,反而支支吾吾,只能背出“负数是补码”这句话,却说不清所以然。
这个主题恰恰是C语言绕不过去的一道分水岭。它能解释清楚“变量名、数值、内存二进制”这三层之间的映射关系,也能帮你在排查内存越界、字节序错乱、浮点比较失败这些问题时,不再靠猜。无论你是初学者、正在准备笔试面试的学生,还是做嵌入式、网络协议、音视频编解码的工程师,这篇文章里讲的内容都会直接变成你手里的排查工具。接下来我从整数、字节序、浮点数、类型转换和调试手段五个维度,把这块硬骨头拆开揉碎讲清楚。
1. 为什么说“内存中的存储”是C语言的区分度所在
1.1 这个主题到底在解决什么问题
很多高级语言刻意把内存细节隐藏起来,你声明一个 int a = 10,在语言层面它就“等于10”。但在C语言里,这个说法不够严谨。a 在逻辑上是一个变量名,它的值确实可以认为是10;但在物理上,a 对应某块内存地址,这块内存里放着的是一串二进制位。类型的作用是告诉编译器:“请按这种规则来解释这串二进制位。”
这就是C语言和很多语言最大的不同:它允许并且鼓励你直接面对内存。你写 printf("%d", a) 时,编译器替你做了“按int类型解释内存”的工作;你写 unsigned char *p = (unsigned char *)&a; 时,就是你自己在做这件事。而一旦你亲手做,你会发现很多“理所当然”的结论都会被推翻。比如同一块内存 0x01 0x02 0x03 0x04,换个指针类型去读,读出来的数字可能完全不一样。这不是数据变坏了,而是“解释规则”变了。
所以我常说,如果一个人只会在编译器里写 printf("Hello World"),那他还谈不上懂C;什么时候他能看着一段字节流准确说出这段内存应该被解释成什么,他才算真正入了门。而“数据在内存中的存储”,就是这个入门过程里要补的第一课。
1.2 printf输出是“加工后”的视图,不是内存真相
先做一个非常直观的实验。声明一个 int 变量,给它赋一个让人一眼能看出规律的值,比如 0x12345678(十进制是305419896),然后用 printf 输出,再把它内存里每个字节挨个打印出来。
c复制#include <stdio.h>
int main(void) {
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
printf("x = %d (0x%08X)\n", x, x);
for (int i = 0; i < 4; i++) {
printf("byte[%d] = 0x%02X\n", i, p[i]);
}
return 0;
}
在绝大多数x86电脑上,输出会是:
code复制x = 305419896 (0x12345678)
byte[0] = 0x78
byte[1] = 0x56
byte[2] = 0x34
byte[3] = 0x12
注意,内存里低地址字节存的居然是“低位” 0x78,而不是我们一眼看上去的 0x12。这就是“小端字节序”的直观体现。printf("%08X", x) 是编译器做完了所有解释工作之后,把结果“加工”成你习惯的样子;而 p[i] 是直接扒开内存看原始字节。
这里还引出一个更重要的认知:内存本身没有类型。0x78 0x56 0x34 0x12 这串字节,你用 int* 读是 0x12345678,你用 float* 读可能就是个很小的浮点数,你用 char* 逐个读就是四个字符。所谓“int类型变量”,只是编译器在编译期登记了一个记号,运行时它不会在内存里贴标签说“我是int”。搞清楚这一点,后面理解类型转换、指针强转、联合体,都会通顺很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整数的内存真面目:原码、反码与补码的来龙去脉
2.1 三种编码的数学关系
整数在内存里默认以补码形式存储,这是所有计算机组成原理教材都会强调的点。但很多人只是记住了结论,不知道三种编码之间的推演过程。先讲清楚它们到底是什么。
假设我们用一个8位二进制来表示一个整数,最高位是符号位,0 表示正,1 表示负。
- 原码:符号位不变,其余位表示绝对值。比如
+5是0000 0101,-5是1000 0101。 - 反码:正数的反码等于原码;负数的反码是“符号位不变,其余位全部取反”。所以
-5的反码是1111 1010。 - 补码:正数的补码等于原码;负数的补码是“反码加1”。所以
-5的补码是1111 1011。
用表格列几个例子会更直观:
| 数值 | 原码 | 反码 | 补码 |
|---|---|---|---|
| +127 | 0111 1111 | 0111 1111 | 0111 1111 |
| +1 | 0000 0001 | 0000 0001 | 0000 0001 |
| 0 | 0000 0000 | 0000 0000 | 0000 0000 |
| -1 | 1000 0001 | 1111 1110 | 1111 1111 |
| -127 | 1111 1111 | 1000 0000 | 1000 0001 |
| -128 | 无法表示 | 无法表示 | 1000 0000 |
注意最后一行:8位原码和反码都无法表示 -128,因为 -0 和 +0 占掉了两个编码;但补码里 0 只有 0000 0000 一种表示,于是多出来的 1000 0000 就被定义为 -128。这也是为什么带符号整数的最小值,绝对值总比最大值大1:INT_MIN 是 -2147483648,INT_MAX 是 2147483647。
2.2 补码为什么能让“减法变加法”
补码还有一个极其重要的性质:x - y 等价于 x + (~y + 1),这句话的意思是,CPU根本不需要独立的减法器,只需要一套加法电路就能搞定一切整数运算。
用一个钟表的例子来类比。假设现在时针指向12点,你想让它指向3点。你可以顺时针拨3格,也就是 +3;也可以逆时针拨9格,也就是 -9。在“模12”的体系里,+3 和 -9 是完全等价的操作,因为3 = 12 - 9。二进制也一样:8位整数是一个模256的体系,-1 的补码 1111 1111 本质上就是“加上255”,而255在256的模下等于 -1。
验证一下:计算 1 + (-1)。1 的补码是 0000 0001,-1 的补码是 1111 1111,相加得到 1 0000 0000。因为只有8位,最高位的进位被丢弃,结果是 0000 0000,也就是0。如果最开始就用原码计算 0000 0001 + 1000 0001,得到的是 1000 0010,也就是 -2,结果完全错误。这就是为什么计算机内部非要用补码不可:它能让所有整数按照“模运算”的规则统一处理,加法器直接吃掉减法,符号位和数值位可以一起参与运算,不用单独判断正负,硬件设计大幅简化。
搞清楚这个原理,很多边界问题就迎刃而解了。比如很多人困惑 INT_MIN 取反加一还是它自己:-2147483648 的补码是 0x80000000,取反是 0x7FFFFFFF,加一是 0x80000000,绕回来了。所以在写代码时,千万不要对 INT_MIN 随意求绝对值,它会触发未定义行为。
2.3 无符号数与有符号数的边界:溢出不是bug是规则
有符号整数的溢出在C标准里属于“未定义行为”,但无符号整数的溢出是有明确规则的——它必须按照“模运算”回绕。这段话翻译成人话就是:unsigned char 加到255以后再加1,结果必定是0,这不是bug,是语言规范本来的样子。
c复制unsigned char u = 255;
u = u + 1; // 结果是0,因为取模256
printf("%u\n", u); // 输出 0
有符号数则要小心,编译器在 -O2 优化下可能默认“有符号溢出不会发生”,从而对代码做出激进的假设。比如:
c复制int i = INT_MAX;
if (i + 1 > i) {
printf("overflow not happen?\n");
}
这段代码在优化后可能什么都不会输出,因为编译器认为 i + 1 不可能真的越过 INT_MAX,于是把条件优化掉了。这不是玄学,而是经典的有符号溢出未定义行为引发的编译器优化。所以我的建议很直接:需要回绕语义的变量,一律用无符号类型;用有符号类型做循环计数时,注意边界,不要在循环里把值加到溢出。
还有一个小细节值得记一下:printf 打印 int 的类型提升。你定义一个 char 或 short 类型,传进 printf 时它们都会被提升为 int,所以用 %d 打印没问题;但如果你拿 %x 打印一个负数 -1,因为 -1 被提升为 int 后的位模式是 0xFFFFFFFF,在传参时被看成 unsigned int,打印机就会输出 ffffffff。这不是错误,而是“一个二进制序列在不同类型视角下的不同解释”,把这一点想通了,很多打印异常的困惑都会消失。
3. 大小端:同一块内存,不同CPU读出不同的数
3.1 大小端的本质是“谁先落地”
多字节数据类型(short、int、double)在内存里占用多个连续的字节地址,如果数值是 0x12345678,它的四个字节分别是 0x12、0x34、0x56、0x78。那么问题来了:内存的低地址处,到底存 0x12(高位)还是 0x78(低位)?
这就是字节序的问题。
- 大端模式:高位字节存在低地址。内存长这样:
12 34 56 78,和人读数字的习惯一致。 - 小端模式:低位字节存在低地址。内存长这样:
78 56 34 12,x86架构采用这种模式。
有些人可能觉得“大端才符合直觉,为什么x86要用小端?”一个常见的解释是:CPU在加载一个32位整数时,会一次性把4个字节读入寄存器,小端模式下,低地址处的低字节恰好对应寄存器的低8位,在做加法、进位等运算时,超出的进位自然往高位走,硬件上的对齐处理更简洁;另一个原因是历史兼容,当年8086就是小端,后面为了兼容一直延续到今天。
用一张表格更清楚地展示 0x12345678 在两种模式下的内存布局:
| 内存地址 | 大端 | 小端 |
|---|---|---|
| 低地址(第1字节) | 0x12 | 0x78 |
| 第2字节 | 0x34 | 0x56 |
| 第3字节 | 0x56 | 0x34 |
| 高地址(第4字节) | 0x78 | 0x12 |
这里有一个很容易混淆的点:字节序只影响“多字节数据在内存中的排列顺序”,不影响“变量内位运算”的结果。比如 x & 0xFF 永远取出最低字节,无论大小端结果都一样,因为位运算是在“数值”层面操作,已经脱离了内存布局。
3.2 用union和指针检测本机字节序
判断本机是大端还是小端,网上最常见的一段代码是:
c复制#include <stdio.h>
int main(void) {
union {
int i;
char c[4];
} u;
u.i = 1;
if (u.c[0] == 1) {
printf("little endian\n"); // 小端:低地址存低位1
} else {
printf("big endian\n"); // 大端:高地址存低位1
}
return 0;
}
原理很简单:1 的32位表示是 0x00000001,最低字节是 0x01。如果是小端,这个字节存在低地址 c[0],所以 u.c[0] 等于1;如果是大端,它存在最高地址 c[3],u.c[0] 为0。用联合体(union)是为了让 int 和 char[4] 共用同一块内存,这是联合体最经典也最实用的功能。
不用联合体也可以,直接用指针:
c复制int x = 1;
unsigned char *p = (unsigned char *)&x;
if (*p == 1) {
printf("little endian\n");
} else {
printf("big endian\n");
}
这里一定要用 unsigned char *,不要用 char *。虽然很多情况下 char 就是有符号的 signed char,但标准只规定了 char、signed char、unsigned char 三种类型中 char 的符号性由实现定义。当你逐字节访问原始内存时,应该使用 unsigned char *,避免符号扩展带来的干扰。
我在实际排查问题中发现,不少开发者在自己的机器上写了一个“假设小端”的代码,跑起来一切正常,换了一台arm板子或者网络传输数据时就翻车。所以凡是要跨平台、跨架构处理二进制数据的代码,都必须显式处理字节序,不能依赖“我机器上跑得好好的”这个错觉。
3.3 什么时候必须手动处理字节序:网络、文件、跨平台
最常见的字节序坑出现在网络编程里。TCP/IP协议规定所有多字节字段一律使用“网络字节序”,也就是大端。你在x86这种小端机器上,如果直接把一个 int 写入socket缓冲区,对方收到后按大端解析,高字节和低字节就全反了。所以POSIX提供了四个转换函数:htons(host to network short)、htonl(host to network long)、ntohs、ntohl,分别用于本地字节序和网络字节序之间的转换。
c复制uint16_t port = 8080;
uint16_t net_port = htons(port); // 发送前转网络字节序
文件存储也一样。如果你写一个自定义二进制文件格式,在自己的算法里直接 fwrite(&x, sizeof(x), 1, fp),这个文件在小端机器上生成,拿到大端机器上读,解析出来完全是错的。正规做法是在文件头写一个“魔数”,比如 0xFEEDBEEF,读取时先判断读出来的魔数是否等于预期值,如果字节序反了,则做整体字节交换,或者规定文件内一律使用大端,写入时转换。
还有一点初学者容易忽略:位字段(bit-field)的分配方向和字节序强相关。C标准规定位字段的内存布局由实现决定,你在x86上定义的“从低位开始”的位字段,换到其他平台行为可能不一样。所以写跨平台代码时,不建议为了省一点点内存去用位字段解析协议头,宁愿用 uint8_t 加移位运算,自己控制字节序。虽然代码啰嗦一点,但行为是完全确定的。
4. 浮点数的存储:符号位、阶码和尾数的不可能三角
4.1 IEEE 754单精度内存布局
整数用补码就能解决,但小数不行。想要在有限的位数里表示极大和极小的数,同时还要保留一定的精度,就必须采用“科学计数法”的二进制版本。IEEE 754标准为单精度浮点数(float)规定了32位的分布:
- 第31位(最高位):符号位
S,0为正,1为负。 - 第23到30位(8位):指数位
E,采用偏移量(bias)编码,单精度偏移量是127。 - 第0到22位(23位):尾数位
M,存储规范化小数的小数部分。
实际的数值公式是:
(-1)^S × 1.M × 2^(E - 127)
这里注意,尾数前面的“1.”是隐含的。因为任何规范化浮点数都可以写成 1.xxx × 2^y 的形式,最前面的那个 1 没必要用位去存它,所以23位尾数实际上能表达的精度相当于24位。
双精度 double 是类似的,只是变成1位符号、11位指数、52位尾数,偏移量是1023。
| 类型 | 总位数 | 符号位 | 指数位 | 尾数位 | 指数偏移 |
|---|---|---|---|---|---|
| float | 32 | 1 | 8 | 23 | 127 |
| double | 64 | 1 | 11 | 52 | 1023 |
4.2 用9.75推一遍整个存储过程
只看公式很容易晕,我们拿一个具体数字走一遍完整流程,以 9.75f 为例。
第一步,把十进制数转成二进制。整数部分9是 1001,小数部分0.75是 0.11(因为 0.5 + 0.25 = 0.75),所以 9.75 = 1001.11。
第二步,规范化,让整数部分变成1位。1001.11 要变成 1.00111 × 2^3,也就是小数点左移3位。
第三步,按位段填值。
- 符号位
S:正数,取0。 - 指数位
E:实际指数是3,加上偏移量127,得到130,即二进制1000 0010。 - 尾数位
M:取1.00111的小数部分00111,后面补0补齐23位,得到00111000000000000000000。
于是整个位模式是:
0 10000010 00111000000000000000000
按16进制看,就是 0x411C0000。
验证一下:
c复制#include <stdio.h>
int main(void) {
float f = 9.75f;
unsigned int *p = (unsigned int *)&f;
printf("0x%08X\n", *p); // 输出 0x411C0000
return 0;
}
如果在x86小端机器上按字节打印,内存里应该是 00 00 1C 41,因为低位字节排在前面。看到这个结果时不要慌,把字节顺序调回大端的阅读顺序,就是 41 1C 00 00。
4.3 0.1 + 0.2 != 0.3 的根源与比较方法
0.1 + 0.2 不等于 0.3 是几乎所有人第一次接触浮点数时都见过的“灵异事件”。根源其实不复杂:0.3 不是2的幂的线性组合,它的二进制小数部分是无限循环的,就像十进制中 1/3 是无限循环小数一样。
算一下 0.1 的二进制:乘以2取整数位
- 0.1 × 2 = 0.2,整数位0
- 0.2 × 2 = 0.4,整数位0
- 0.4 × 2 = 0.8,整数位0
- 0.8 × 2 = 0.6,整数位1
- 0.6 × 2 = 0.2,整数位0
- 0.2 × 2 = 0.4,整数位0
- 0.4 × 2 = 0.8,整数位0
- 0.8 × 2 = 0.6,整数位0
- ... 进入循环
所以 0.1 的二进制是 0.0001100110011001100110011... 无限循环。float 只有23位尾数,必然要截断或舍入,于是存进去的 0.1 严格来说已经不精确了。两个不精确的近似数相加,结果自然偏离真实值。
c复制float a = 0.1f;
float b = 0.2f;
float c = a + b;
if (c == 0.3f) {
printf("equal\n");
} else {
printf("not equal: %.10f\n", c); // 输出 0.3000000119 之类的值
}
浮点比较的正确姿势,不要用 ==,而是判断两个数之差是否在一个很小的容差范围内:
c复制#include <math.h>
double a = 0.1 + 0.2;
double b = 0.3;
if (fabs(a - b) < 1e-9) {
printf("approximately equal\n");
}
如果比较的数值本身量级很大,比如几千万,那1e-9的绝对容差可能太严格;这时可以用相对误差。不过对于日常大部分场景,绝对容差已经够用。我的经验是:凡是涉及钱的业务,不要用浮点数,用整数存“分”;凡是涉及密钥、ID、序列号的比较,更不要用浮点,用无符号整数或字符串。浮点数只适合“数值计算”,不适合“精确等值判断”。
5. 类型转换与截断:内存视角下最容易踩的坑
5.1 整型截断:char c = 300到底存了什么
很多初学者以为“把300赋值给char,会得到一个四舍五入或者取模算出来的值”,但更本质的理解是“从int的32位里把低8位截出来”。300 的十六进制是 0x0000012C,取低8位得到 0x2C,十进制是44。所以:
c复制char c = 300; // 结果是44
printf("%d\n", c); // 44
反过来,把 -1 赋值给 unsigned char 也有一套看似反直觉的规则。-1 的补码是 0xFFFFFFFF,截取低8位是 0xFF,也就是255:
c复制unsigned char u = -1;
printf("%u\n", u); // 255
这种截断行为在向较小类型赋值时一定会发生,不会考虑“这个数是否放得下”。所以当我看到有人写 char ch = 200; 然后拿到一个负数时,我第一反应不是“编译器坏了”,而是“你的类型容量不够,高位被切掉了”。写代码时如果确实需要更大的范围,应提前声明更大的类型,或者在赋值前做边界检查,不要指望编译器发出警告。
另外,大端小端对截断行为有影响吗?没有。无论是哪种字节序,“取低8位”都是指数值的最低字节,而不是内存最低地址的字节。这里比较容易混淆,但在逻辑上,类型转换发生在数值层面,与内存布局无关。
5.2 有符号和无符号比较:-1 > 1的“灵异事件”
无符号数和有符号数混合比较时,C语言有一套“隐式转换”规则:int 和 unsigned int 一起参与表达式时,int 会被转换成 unsigned int。也就是说,-1 会先变成 0xFFFFFFFF,也就是4294967295,然后和 1 比较,那么 -1 > 1 自然为真。
c复制int a = -1;
unsigned int b = 1;
if (a > b) {
printf("a is greater than b?!\n"); // 真的会打印
}
这个问题的变体更阴险。sizeof 运算符的返回值类型是 size_t,也就是无符号整数,因此:
c复制int x = 5;
if (sizeof(x) > -1) {
printf("true\n");
} else {
printf("false\n"); // 实际输出false
}
看起来 sizeof(x) 是4,一定大于 -1,但进入比较时 -1 被转换为无符号数变成巨大值,4反而小于它,所以条件为假。这个坑我在实际代码评审里见过不止一次。
再往前推一步,for (int i = n - 1; i >= 0; i--) 这类循环如果 n 是 unsigned int,n - 1 在 n=0 时会变成 UINT_MAX,循环不会按你想象的方式退出。我给出的排错建议是:比较有符号和无符号的表达式时,先统一类型,直接显式强转,不要靠隐式转换;或者让编译器的 -Wsign-compare 警告开着,它会在第一时间提醒你这里有风险。
5.3 隐式转换规则速查
C语言里类型转换的总体原则是“向更大范围、更高精度的方向转”,称为“通常算术转换”。我用一张表把常见情况列出来,方便对照:
| 参与运算的类型 | 转换方向 | 结果类型 |
|---|---|---|
char、short 参与运算 |
先提升为 int |
int |
int 和 unsigned int |
int 转 unsigned int |
unsigned int |
int 或 unsigned int 与 long |
看当前平台上 long 能否装下所有值,能则 long,不能则 unsigned long |
long 或 unsigned long |
任意整数与 float |
整数转 float |
float |
float 与 double |
float 转 double |
double |
这张表是不是看起来有点绕?实战中只需要记住几个高频场景。
第一,小整数类型(char、short)几乎都会先提升为 int 再参与运算。所以 char c = 0x80; 如果 char 有符号,c 的实际值可能是-128,参与运算时被提升为-128再继续。这会导致 c + 1 是-127,而不是129。
第二,int 与 unsigned int 混合时,有符号数会被“洗”成无符号数。前文讲的 -1 > 1 就是这种场景。
第三,浮点数参与比较或赋值时有精度损失。float f = 3.14; 这种写法里 3.14 是 double 类型,赋值给 float 时做了截断,可能引入细微误差;反过来,double d = 0.1f; 也不会让 0.1f 变成更精确的 0.1,只是把已经截断过的位数加长了而已。碰到精度敏感的运算,最好全程用 double,不要 float 和 double 混用。
说到浮点转整型,还有一个常见误区:C语言的浮点数转整数采用“向零截断”,也就是直接丢掉小数部分,不是四舍五入。(int)3.99 结果是3,(int)-3.99 结果是-3。如果你需要四舍五入,要自己用 floor、ceil 或 round 函数处理。
6. 亲手把内存“看”一遍:调试工具与字节级打印技巧
6.1 用printf和指针按字节读内存
理论讲了一堆,最终还是要落到“亲手看内存”这个能力上。调试的第一步,是把任何变量都当成一串字节来观察。最朴素的办法就是前面用过的指针法:
c复制#include <stdio.h>
void print_memory_bytes(const void *ptr, size_t size) {
const unsigned char *p = (const unsigned char *)ptr;
for (size_t i = 0; i < size; i++) {
printf("%02X ", p[i]);
}
printf("\n");
}
为什么用 unsigned char * 而不是 char *?因为取出来的字节如果按有符号解释,0x80 会被当成-128,用 %02X 打印时会得到 FFFFFF80,干扰观察。unsigned char 能保证每个字节都作为0到255之间的值被打印。
这个方法可以用来观察任何类型:int、float、结构体,甚至数组。比如打印一个结构体,就能直观看到编译器为了对齐在成员之间填充的空洞字节:
c复制struct S {
char c; // 1字节
int i; // 4字节
};
struct S s = { 'A', 0x12345678 };
print_memory_bytes(&s, sizeof(s));
在x86-64上输出的可能是 41 CC CC CC 78 56 34 12:第一个字节是 A 的ASCII码,后面3个填充字节通常是不确定的,接着是 int 的4个字节。这个细节对做协议解析、文件格式调试的人特别有用,因为你以为结构体只占5字节,实际大小却是8,直接 fwrite(&s, sizeof(s), 1, fp) 会把填充字节也写进文件,导致对端解析错位。
6.2 VS内存窗口与GDB x命令
如果你在Visual Studio里调试,最直接的方式是打断点后打开“调试 -> 窗口 -> 内存”,在地址栏输入 &变量名,内存窗口会实时显示变量的字节。注意看的时候要结合大小端来理解:在x86上,内存窗口从左到右是“低地址到高地址”,所以 0x12345678 显示为 78 56 34 12,千万别按视觉顺序直接读。
如果你在Linux下用GDB,更高效的是 x 命令:
code复制(gdb) x/4bx &x
0x7fffffffe4ac: 0x78 0x56 0x34 0x12
x 后面的参数格式是“重复次数 + 格式 + 字节大小”。4bx 表示打印4个十六进制字节;4wx 表示打印4个32位字(16进制);12d 表示打印12个十进制数。这个命令对调试协议包、观察数组内存布局非常顺手。
我自己的习惯是:先写一个调试用的辅助函数,把关键变量按字节打印出来,打印时带上变量名和地址;等程序跑稳定了,再把这些调试代码用条件编译关掉。这样既能确认内存里的真实数据,又不影响上线代码。
6.3 一个万能hexdump辅助函数
最后放一个我用了很多年的hexdump函数,可以直接抄进工程里。它按16个字节一行打印,中间加一个空格分隔每4字节,右侧附上ASCII字符,方便扫一眼就看到可打印文本。
c复制#include <ctype.h>
#include <stdio.h>
void hexdump(const void *ptr, size_t len) {
const unsigned char *buf = (const unsigned char *)ptr;
for (size_t i = 0; i < len; i += 16) {
printf("%08lX ", (unsigned long)i);
for (size_t j = 0; j < 16; j++) {
if (i + j < len) {
printf("%02X ", buf[i + j]);
} else {
printf(" ");
}
if (j % 4 == 3) {
printf(" ");
}
}
printf(" ");
for (size_t j = 0; j < 16 && i + j < len; j++) {
unsigned char c = buf[i + j];
putchar(isprint(c) ? c : '.');
}
printf("\n");
}
}
用法非常简单:
c复制int arr[] = { 1, 2, 3, 4 };
hexdump(arr, sizeof(arr));
float f = 3.14f;
hexdump(&f, sizeof(f));
这套“按字节观察内存”的能力,是理解数据在内存中的存储这一主题的终极验证手段。因为不论你背了多少概念,最后真正能帮你定位问题的,还是“这4个字节,为什么会被解释成这个数”的判断能力。我见过太多排错排了一整天的人,绕来绕去,最终发现只是字节序写反了或者类型截断没考虑。希望这篇文章里讲清楚的这些原理和排查路径,能让你以后少踩这些坑。
