【C 语言】数据在内存中的存储:补码、大小端、整型陷阱与 IEEE 754 全解析

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者。

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。


🔥 本文定位 :从"位模式 + 类型解释"出发,串起整数补码、大小端字节序、char 的有无符号性、无符号回绕以及 IEEE 754 浮点表示,并逐题拆解课程中的典型代码。

💡 学习目标:能手算常见整数与浮点数的位模式;能画出多字节对象在大端、小端机器上的字节排列;能识别整数提升、格式串不匹配、无符号死循环、指针截断和别名规则风险。

📌 平台约定 :除非特别说明,例题沿用课程常见环境:CHAR_BIT == 8、sizeof(int) == 4、有符号整数采用二进制补码、浮点数采用 IEEE 754 binary32/binary64。涉及实现定义或未定义行为的地方会单独标出。


文章目录


一、先建立统一视角:内存只保存比特

变量在源代码里拥有名字和类型,内存本身却只保存一串二进制位。下面两行代码都可能占用 4 个连续字节:

c 复制代码
int   i = 9;
float f = 9.0f;

但它们写入的位模式完全不同。在常见 32 位 int 与 IEEE 754 binary32 环境中:

text 复制代码
int   9     → 0x00000009
float 9.0f  → 0x41100000

分析"数据在内存中如何存储"时,至少要分清四个层次:

  1. 值 :数学意义上的 9、-25、0.5;
  2. 类型 :int、unsigned char、float;
  3. 对象表示:这个类型用哪些比特表示该值;
  4. 字节排列:对象超过一个字节时,各字节落在哪些地址上。

例如 0x11223344 是一个数值写法,44 33 22 11 是它在常见小端机器中从低地址到高地址的字节排列。两件事不能混为一谈。

同理,把 int 的地址强制转换成 float * 并不会把整数数值转换成浮点数;它只是要求程序把原有比特换一套规则解释,而且还可能违反 C 的有效类型与别名规则。

这一讲所有题目的主线都可以压缩成一句话:

先确定位宽与位模式,再根据类型和读取规则解释,最后检查这次读取在 C 语言里是否合法。


二、整数为什么以补码形式保存

2.1 原码、反码和补码

以 8 位宽度表示 -25。先把 25 写成二进制:

text 复制代码
25 = 00011001₂

三种编码分别为:

text 复制代码
原码:10011001
反码:11100110
补码:11100111

计算步骤如下:

  1. 原码最高位为符号位,负数写 1,其余 7 位保存绝对值;
  2. 反码保持符号位不变,其余位按位取反;
  3. 补码等于反码加 1。

对于正数,原码、反码和补码相同。课程以及现代主流平台讨论整数内存表示时,实际关注的是补码位模式。

2.2 补码解决了什么问题

如果把符号和数值完全分开处理,硬件需要为加法、减法以及符号规则准备更多逻辑。补码让固定宽度的减法能够转化为加法。

以 8 位为例,计算 5 + (-3):

text 复制代码
  00000101   // 5
+ 11111101   // -3 的补码
-----------
1 00000010

最高位之外的进位被丢弃,留下 00000010,正好是 2。

这种统一并不意味着可以随意溢出。对 C 程序而言:

  • 无符号整数运算按模 2^N 进行,回绕是规则的一部分;
  • 有符号整数溢出属于未定义行为,不能简单照搬"丢弃最高位"的硬件现象;
  • 位宽不同,同一个数的补码长度也不同,推导前必须先确定类型宽度。

2.3 从补码还原负数

看到 11100111,如果已经知道它是 8 位有符号补码,可以按"按位取反再加 1"求出绝对值:

text 复制代码
11100111  按位取反 → 00011000
00011000  再加 1   → 00011001 = 25

最高位为 1,所以结果为 -25。

注意:同样的 11100111 若按 unsigned char 解释,结果则是 231。位模式没有改变,改变的是解释类型。


三、同一个字节:signed、unsigned 与整型提升

3.1 char、signed char、unsigned char 不是一回事

C 语言规定 char、signed char 和 unsigned char 是三种不同类型。char 与其中哪一种具有相同的取值范围,由实现决定。

在常见 8 位字节环境中:

text 复制代码
signed char   常见范围:-128 ~ 127
unsigned char 范围:     0 ~ 255
char          范围:     由实现选择上述一种

因此,下面的 a 不能脱离编译器选项直接断言:

c 复制代码
char a = -1;

但 signed char b = -1; 与 unsigned char c = -1; 的意图更清楚。对 8 位无符号类型,-1 转换后为 255。

3.2 为什么三个变量用 %d 打印

课程例题:

c 复制代码
#include <stdio.h>

int main(void)
{
    char a = -1;
    signed char b = -1;
    unsigned char c = -1;

    printf("a=%d, b=%d, c=%d\n", a, b, c);
    return 0;
}

传给可变参数函数 printf 时,窄整数会先发生默认参数提升。常见平台中三者都提升为 int:

text 复制代码
a:取决于 plain char 的实现选择
b:-1
c:255

若本机 char 默认有符号,典型输出为:

text 复制代码
a=-1, b=-1, c=255

3.3 %u 不是"把负数打印成大正数"的转换器

下面的写法在课程题中经常出现:

c 复制代码
char a = -128;
printf("%u\n", a);

a 通常先提升为 int,而 %u 要求对应实参是 unsigned int。格式说明符与实际参数类型不匹配,标准 C 语义下不能把某个大数当成可靠答案。

如果目的是查看该字节的无符号值,应显式完成转换并匹配格式:

c 复制代码
printf("%u\n", (unsigned int)(unsigned char)a);

若 8 位字节中保存的是 10000000,这里会稳定打印 128。

另一个常见陷阱是:

c 复制代码
char a = 128;

若 char 是有符号且最大值为 127,128 转换到 char 的结果由实现定义。想表达 0~255 范围,应直接使用 unsigned char。


四、大小端:多字节对象怎样排列

4.1 大端与小端的定义

设一个 32 位整数的值为:

c 复制代码
unsigned int value = 0x11223344u;

从高位到低位,它的四个字节是:

text 复制代码
高位                                  低位
0x11        0x22        0x33        0x44

若对象起始地址为 A:

地址 A A+1 A+2 A+3
小端 44 33 22 11
大端 11 22 33 44

定义可以这样记:

  • 小端:最低有效字节放在最低地址;
  • 大端:最高有效字节放在最低地址。

大小端讨论的是多字节对象的字节顺序,不是十六进制数字应该从哪边书写,也不是单个字节内部的位顺序。

4.2 为什么需要字节序

内存按字节编址,而 short、int、long long 等对象通常占多个字节。处理器一次读取一个多字节对象时,体系结构必须规定"哪个字节放低地址"。不同体系结构作出了不同选择,于是形成大端和小端。

x86/x64 通常采用小端。网络协议常把大端称为网络字节序,但不要由此推断所有文件格式、设备协议都使用同一种顺序;协议必须自己写清楚。

4.3 用程序判断当前机器的字节序

最直接的方法是把对象表示当作 unsigned char 序列观察:

c 复制代码
#include <stdio.h>

int is_little_endian(void)
{
    const unsigned int value = 1u;
    const unsigned char *first = (const unsigned char *)&value;
    return *first == 1u;
}

int main(void)
{
    printf("%s\n", is_little_endian() ? "little-endian" : "big-endian");
    return 0;
}

C 允许通过字符类型指针观察对象表示。若最低地址处读到 1,说明数值最低有效字节位于最低地址,即小端。

课程还给出了联合体版本:

c 复制代码
union
{
    int i;
    unsigned char c;
} u;

u.i = 1;
printf("%s\n", u.c == 1 ? "little-endian" : "big-endian");

这个写法在 C 实现中很常见,但用 unsigned char * 明确表达"查看对象表示"更容易说明意图。无论采用哪种检测方法,结果只描述当前运行环境;序列化和网络通信仍应显式转换成协议规定的字节序。


五、课程整型与地址练习完整解析

5.1 字符数组中第一个 \0 在哪里

课程代码的核心是:

c 复制代码
char a[1000];

for (int i = 0; i < 1000; ++i)
{
    a[i] = -1 - i;
}

printf("%zu\n", strlen(a));

在课程采用的 8 位补码环境中,赋入数组的低 8 位依次为:

text 复制代码
i = 0   → 0xFF
i = 1   → 0xFE
...
i = 254 → 0x01
i = 255 → 0x00

因此第一个零字节位于 a[255],典型结果为:

text 复制代码
strlen(a) == 255

完整代码还需要 #include <string.h>,并使用 %zu 打印 size_t。这个结果依赖课程平台对超范围整数到 char 的转换方式;若要编写可移植的业务逻辑,不应把这种转换当作生成字符串终止符的手段。

5.2 unsigned char 为什么会无限循环

c 复制代码
unsigned char i = 0;

for (i = 0; i <= 255; ++i)
{
    puts("hello world");
}

在常见环境中,判断 i <= 255 时,i 提升为 int,它的值永远位于 0~255,所以条件始终为真。当 i 为 255 再执行 ++i,转换回 unsigned char 后回到 0。

若确实想循环 256 次,可以让循环变量拥有更宽的类型:

c 复制代码
for (unsigned int i = 0; i <= 255u; ++i)
{
    puts("hello world");
}

5.3 无符号整数为什么不能用 i >= 0 倒计时

c 复制代码
for (unsigned int i = 9; i >= 0; --i)
{
    printf("%u\n", i);
}

无符号数不可能小于 0,所以 i >= 0 永远成立。i 从 0 再减 1 后会回绕为 UINT_MAX。

常见改法有两种:

c 复制代码
for (int i = 9; i >= 0; --i)
{
    printf("%d\n", i);
}

或保留无符号类型,把递减与判断合并:

c 复制代码
for (unsigned int i = 10; i-- > 0; )
{
    printf("%u\n", i);
}

5.4 地址综合题的课程结果

课程例题:

c 复制代码
int a[4] = {1, 2, 3, 4};
int *ptr1 = (int *)(&a + 1);
int *ptr2 = (int *)((int)a + 1);

printf("%x,%x", ptr1[-1], *ptr2);

在课程假设的 32 位小端、sizeof(int) == 4 环境中:

  • &a 指向整个 int[4] 数组;&a + 1 越过整组 16 字节;
  • 课程写法把它转成 int * 后回退一个元素,典型读到 a[3],即 4;
  • (int)a + 1 把起始地址数值增加 1 字节;
  • 从第二个字节起按一个 int 读取,会看到 00 00 00 02;
  • 小端解释为 0x02000000,用 %x 打印为 2000000。

所以传统题目的典型答案是:

text 复制代码
4,2000000

5.5 为什么不能把这个答案直接搬进工程代码

原程序存在多重风险:

  1. 把指针转换成 int 的结果由实现定义,64 位平台还可能截断地址;
  2. 加 1 后得到的地址通常不满足 int 对齐要求;
  3. 用 int * 解引用拼接出来的四个字节,不是一个合法建立的 int 对象;
  4. 强制转换只压制部分诊断,不会让无效访问变得合法。

如果只是观察数组的字节,应使用字符类型指针:

c 复制代码
const unsigned char *bytes = (const unsigned char *)a;

for (size_t i = 0; i < sizeof a; ++i)
{
    printf("%02x ", (unsigned int)bytes[i]);
}

若要从字节缓冲区恢复一个 int,先确认协议字节序与长度,再用 memcpy 拷入对齐正确的 int 对象;不要直接制造未对齐的 int *。


六、浮点数不是"带小数点的整数"

整数通常按定点位权解释:某一位代表 2^k。浮点数则把有限位数分成符号、阶码与尾数,以近似科学计数法的方式覆盖非常大和非常小的数。

对常见 IEEE 754 二进制浮点数,正规数可写成:

text 复制代码
V = (-1)^S × (1.F) × 2^(E - bias)

其中:

  • S 是符号位;
  • E 是存储后的阶码字段;
  • F 是小数部分,又称 fraction;
  • bias 是偏置值。

常见字段布局如下:

格式 总位数 符号 S 阶码 E 尾数字段 F bias
binary32(常见 float) 32 1 8 23 127
binary64(常见 double) 64 1 11 52 1023

这里要保留一个重要边界:C 语言定义 float、double 的语义和最小范围,但并不要求所有实现都必须采用上述 IEEE 754 格式。本文的逐位推导建立在课程与主流桌面环境使用 binary32/binary64 的前提上。

6.1 为什么尾数能"多存一位"

二进制正规数总能规格化为:

text 复制代码
1.xxxxx₂ × 2^e

最高位固定为 1,因此不必真的存进尾数字段。binary32 虽然只有 23 个 F 位,对正规数却能提供 24 位有效精度:1 个隐含的最高位加 23 个显式尾数位。

6.2 为什么阶码要加偏置

真实指数既可能为正,也可能为负。为了把阶码字段作为无符号比特序列存储,binary32 在正规指数上加 127,binary64 加 1023。

例如真实指数为 3:

text 复制代码
E = 3 + 127 = 130 = 10000010₂

读取时再减去偏置即可恢复真实指数。


七、IEEE 754 的存入过程

7.1 把 9.0f 写成 binary32

第一步,把十进制 9 转成二进制:

text 复制代码
9.0 = 1001.0₂

第二步,规格化:

text 复制代码
1001.0₂ = 1.001₂ × 2^3

因此:

text 复制代码
S = 0
真实指数 = 3
E = 3 + 127 = 130 = 10000010₂
F = 00100000000000000000000

拼接得到:

text 复制代码
0 | 10000010 | 00100000000000000000000

对应十六进制位模式:

text 复制代码
0x41100000

若把这 32 位当作无符号整数解释,数值为:

text 复制代码
1091567616

这不表示浮点转换 9.0f → unsigned int 会得到 1091567616。正常的数值转换仍得到 9;1091567616 是同一位模式被整数规则重解释后的结果。

7.2 0.5f 为什么能精确表示

text 复制代码
0.5₁₀ = 0.1₂ = 1.0₂ × 2^-1

所以:

text 复制代码
S = 0
E = -1 + 127 = 126 = 01111110₂
F = 00000000000000000000000

完整位模式为:

text 复制代码
0 | 01111110 | 00000000000000000000000

0.5、0.25、0.125 等以 2 的负整数次幂构成的数可以有限表示。0.1 的二进制小数却会无限循环,所以通常只能保存邻近值,这也是很多浮点误差的来源。


八、IEEE 754 的取出过程与特殊值

读取 binary32 时,不能对所有阶码都机械套用 1.F × 2^(E-127)。要先把阶码分为三类。

8.1 E 既不全为 0,也不全为 1

这是正规数:

text 复制代码
V = (-1)^S × (1.F) × 2^(E-127)

此时恢复隐含的最高位 1。

8.2 E 全为 0

当尾数也全为 0 时,表示 +0 或 -0。符号位仍然保留,因此浮点零存在两种符号。

当尾数不为 0 时,表示次正规数:

text 复制代码
V = (-1)^S × (0.F) × 2^(1-127)

这里不补隐含位 1,真实指数固定为 -126。次正规数让数值可以从最小正规数继续逐渐靠近 0,而不是突然出现巨大空档。

8.3 E 全为 1

  • F == 0:表示正无穷或负无穷;
  • F != 0:表示 NaN,也就是"不是一个数"。

NaN 常来自 0.0 / 0.0、无穷减无穷等无效浮点运算。比较 NaN 时要格外小心:它与包括自身在内的任何值做普通相等比较通常都为假,应使用 <math.h> 中的 isnan。


九、同一串比特为什么会得到两个答案

课程代码通过 float * 指向 int 对象:

c 复制代码
int n = 9;
float *p = (float *)&n;

printf("n = %d\n", n);
printf("*p = %f\n", *p);

*p = 9.0f;
printf("n = %d\n", n);
printf("*p = %f\n", *p);

在常见调试环境中,它试图展示:

text 复制代码
int 9 的位模式 0x00000009
按 binary32 解读约为 1.2611686 × 10^-44
用默认 %f 的六位小数显示为 0.000000

float 9.0f 的位模式 0x41100000
按 32 位整数解读为 1091567616

9.1 为什么整数 9 会变成极小浮点数

32 位整数 9 的常见位模式为:

text 复制代码
00000000 00000000 00000000 00001001

按 binary32 分段:

text 复制代码
S = 0
E = 00000000
F = 00000000000000000001001

阶码全为 0,所以这是次正规数。尾数字段的整数值为 9,binary32 最低尾数位权为 2^-149:

text 复制代码
V = 9 × 2^-149 ≈ 1.2611686 × 10^-44

printf("%f", value) 默认只显示小数点后六位,因此输出看起来是 0.000000。它不是数学上的零,只是显示精度不足。

9.2 为什么原代码不适合作为标准 C 写法

通过 float * 解引用一个实际类型为 int 的对象,会触及 C 的有效类型和严格别名规则;写回还会让优化器基于"int * 与 float * 不别名"的假设产生意外结果。它适合帮助理解位模式,不适合作为可移植程序。

观察位模式时应使用 memcpy:

c 复制代码
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

int main(void)
{
    float value = 9.0f;
    uint32_t bits = 0;

    if (sizeof value != sizeof bits)
    {
        puts("this example requires a 32-bit float");
        return 0;
    }

    memcpy(&bits, &value, sizeof bits);
    printf("0x%08" PRIx32 "\n", bits);
    return 0;
}

在 IEEE 754 binary32 环境中,输出为:

text 复制代码
0x41100000

memcpy 拷贝对象表示,不制造一个指向错误类型的左值;编译器通常也会把这种固定大小拷贝优化成普通寄存器操作。


十、稳定解题流程、高频问答与练习

10.1 五步分析法

第一步:确定平台前提。

先问清楚 CHAR_BIT、sizeof、有符号表示、浮点格式与端序。题目若没有说明,只能给出带前提的典型结果。

第二步:写出完整位模式。

整数按固定位宽写补码;浮点按 S/E/F 字段编码。不要只写"它是负数"或"它是 9.0",而要落到每一位。

第三步:按地址排列字节。

对象超过一个字节时,再根据大端或小端安排字节。单字节对象没有多字节端序问题。

第四步:检查类型与提升。

确认是 char、signed char 还是 unsigned char;传入 printf 后提升成什么;格式说明符是否与实参类型匹配。

第五步:检查语言边界。

关注有符号溢出、无符号回绕、越界、对齐、指针到整数转换、有效类型、严格别名和对象生命周期。

最后把答案归类:

  1. 标准保证的结论;
  2. 实现定义或未指定的结果;
  3. 特定课程平台上的典型现象;
  4. 程序已有未定义行为,不能给出可移植答案。

10.2 高频问答

Q1:补码与小端是一回事吗?

不是。补码描述有符号整数的位模式;小端描述多字节对象各字节的地址顺序。先得到补码,再讨论它的字节怎样排列。

Q2:为什么 unsigned char 特别适合查看内存?

它没有填充位,能够表示一个字节的所有可能位模式,而且 C 允许通过字符类型左值检查任意对象的表示。

Q3:char 一定是 8 位吗?

不一定。C 规定一个字节就是 sizeof(char) 的单位,但每字节的位数由 CHAR_BIT 给出,最少为 8。主流通用平台通常为 8。

Q4:无符号溢出是未定义行为吗?

不是。无符号运算按模 2^N 进行。真正危险的是把它忘记后写出永不终止的条件。有符号整数溢出则属于未定义行为。

Q5:大小端会改变 0x11223344 的数值吗?

不会。在同一类型内正常读取时,处理器会按本机规则还原相同数值。端序差异在逐字节检查、文件、网络和跨平台序列化时显现。

Q6:把 float 强转成 int 能得到其位模式吗?

不能。数值转换 (int)f 会舍弃小数并换算数值。若要复制位模式,使用 memcpy;若实现与语言版本提供明确的位转换工具,也应按其文档使用。

Q7:为什么 0.1 + 0.2 常常不精确等于 0.3?

因为这些十进制小数在二进制中通常是无限循环小数,有限尾数只能保存邻近值。比较浮点结果时应根据问题尺度选择误差容限,而不是机械使用 ==。

Q8:NaN 为什么不等于自己?

NaN 代表无效或未定义的数值结果。IEEE 754 的普通比较把它视为无序,x == x 对 NaN 也为假。检测应使用 isnan(x)。

10.3 动手练习

  1. 在 8 位宽度下分别写出 25、-25、127、-128 的补码。
  2. 画出 uint32_t x = 0xA1B2C3D4u; 在大端与小端机器上的四个地址单元。
  3. 预测 unsigned char c = 255; printf("%d\n", c); 在 INT_MAX >= 255 时的结果,并说明默认参数提升。
  4. 解释 for (size_t i = n - 1; i >= 0; --i) 的问题,并给出至少一种正确写法。
  5. 手算 -5.0f 的 binary32 符号位、阶码与尾数。
  6. 写一个函数,用 memcpy 返回 float 的 uint32_t 位模式,并在编译期或运行期检查二者大小相同。
  7. 分别说明 0x00000000、0x80000000、0x7F800000、0x7FC00000 在 binary32 下的类别。

参考答案要点:

  1. 25 为 00011001,-25 为 11100111,127 为 01111111,-128 为 10000000。
  2. 小端从低地址到高地址为 D4 C3 B2 A1,大端为 A1 B2 C3 D4。
  3. c 提升为 int 255,%d 与提升后的类型匹配,输出 255。
  4. size_t 是无符号类型,i >= 0 恒真;可写 for (size_t i = n; i-- > 0; )。
  5. -5.0 = -1.01₂ × 2^2,所以 S=1、E=129=10000001₂、F=010000...。
  6. 用 uint32_t bits; memcpy(&bits, &value, sizeof bits);,前提是 sizeof value == sizeof bits。
  7. 依次为 +0、-0、+∞、NaN。

总结

数据在内存中并不自带"这是整数""这是浮点数"的标签。补码规定有符号整数如何编码;大小端决定多字节对象如何映射到递增地址;signed、unsigned 与整数提升决定同一个字节进入表达式后得到什么值;IEEE 754 则用符号、阶码和尾数在有限位宽中表示极大、极小以及特殊浮点值。

课程里的 char、无符号循环、地址偏移和 float * 题之所以容易出错,是因为它们同时混合了多个层次。稳定方法不是背下 255、4,2000000 或 1091567616,而是坚持这条链路:

确定位宽 → 写出位模式 → 排列字节 → 按类型解释 → 检查提升与格式 → 验证对象边界和语言规则。

当你能明确区分"标准保证""实现定义""课程平台典型结果"和"未定义行为"时,内存题就不再是靠机器碰运气的谜题,而会变成一套能够逐步验证的推理过程。

相关推荐
灯澜忆梦1 小时前
【面向对象编程C++】| 基础语法
java·c++·算法
一个风轻云淡1 小时前
GCC 和 GDB命令简单解读
java·linux·前端
省钱兄--zs1 小时前
西安24小时自助健身房解决方案实战:系统开发与部署全流程指南
java·spring boot·系统架构·intellij-idea·需求分析
爱跳舞的烤冷面1 小时前
Linux篇——网络编程
网络
资料库012 小时前
Linux 8个常见运维故障排查
java·linux·运维
要开心吖ZSH2 小时前
MySQL 慢 SQL 排查操作手册-个人笔记版
java·笔记·sql·mysql·慢查询
Knight_AL2 小时前
基于 Netty 实现的 WebSocket 服务端
网络·websocket·网络协议
Joy T3 小时前
Spring AI 接入已有 Java 项目的三种架构设计
java·人工智能·springai·ai入门·chatclient·ai service·ai能力接入
杨运交3 小时前
[076][核心模块]构建优雅的Java异常处理框架:从错误码到全局异常处理
java·开发语言