你有没有好奇过:为什么 int a = 9; 和 float f = 9.0f; 在内存中看起来完全不一样?同一个数字 9,用 %d 打印是 9,用 %f 打印却是 0.000000——反过来,9.0 用 %d 打印却是一个十位数 1091567616?

这不是 bug,这是计算机底层数据表示的精妙设计。前面几讲我们一直在"使用"数据——拷贝、比较、查找——但从未真正"看见"数据。这一讲我们直接钻进内存,看看到底每一个 bit 在干什么。理解了这些,你才算真正理解了 C 语言。

在开始之前,有几个基本概念需要你脑子里有数:计算机只认识 0 和 1,所有数据最终都是一串二进制位。8 个 bit 组成 1 个 byte,这是我们在内存层面上操作的基本单位。因为二进制太长(一个 int 要写 32 位),我们通常用十六进制来缩写——0x1A = 26,每 4 个二进制位对应 1 个十六进制位,非常直观。sizeof(int) 在你的机器上可能是 4(字节),sizeof(double) 是 8(字节)——C 标准只规定了最小值,不规定确切大小。

还有一个贯穿全文的核心认知:当你写 float f = 3.14f; int *p = (int*)&f; 的时候,你其实是在告诉编译器——"请用解读整数的规则来解读这块内存"。底层的二进制位并没有变——变的只是解读方式。这个认知,是你理解后面所有内容的基础。

整数在内存中的存储

原码、反码、补码

对于有符号整数,最高位是符号位:0 表示正数,1 表示负数。剩下的位表示数值。但这个"数值"怎么表示,有三种方案:

  • 原码:符号位 + 数值的二进制。比如 +5 = 00000101,-5 = 10000101。最直观,但有两个硬伤:+0(00000000)和 -0(10000000)不是同一个零,而且加法需要额外电路处理符号位——你不能直接把 00000101 和 10000101 相加得到正确结果。

  • 反码:正数反码 = 原码;负数反码 = 符号位不变,其余位取反。比如 -5 的反码 = 11111010。加法比原码好一些,但仍然有两个零(00000000 和 11111111),而且加法在跨零时需要额外处理(循环进位)。

  • 补码:正数补码 = 原码;负数补码 = 反码 + 1。比如 -5 的补码 = 11111010 + 1 = 11111011。

用一张表看三个编码对同一个字节的解读差异(8 位为例):

二进制位原码解读反码解读补码解读
00000000+0+00
01111111+127+127+127
10000000-0-127-128
11111111-127-0-1

注意最后两行:原码和反码都浪费了一个编码来表示 -0,而补码把它们全部利用起来——10000000 表示 -128,11111111 表示 -1。这就是补码的优雅之处:256 个编码,256 个不同的值,一个不浪费。

你可能会问:为什么计算机选择补码而不是更直观的原码?答案就在硬件设计上。

CPU 的核心计算单元是加法器——它只会做加法。如果使用补码,减法可以通过加法来实现:A - B = A + (-B)的补码。比如 3 - 5 = 3 + (-5的补码) = 00000011 + 11111011 = 11111110,正是 -2 的补码。符号位自动参与运算,不需要额外电路。这种设计的精髓在于:符号位和数值位统一处理,加法和减法统一处理——一套加法器电路搞定所有运算。

我们亲手验证一下 3 + (-5) = -2 的二进制运算过程:

   00000011   ← 3 的补码
 + 11111011   ← -5 的补码(原码 10000101 → 反码 11111010 → 补码 11111011)
 ----------
  111111110   ← 溢出的最高位丢弃
  11111110    ← 结果是 -2 的补码 ✓

看到了吗?加法器把两个补码直接相加,最高位的进位自然丢弃,剩下的就是正确答案的补码——整个过程中符号位没有特殊对待。这就是补码设计的核心价值。

来看一个直观的内存观察实验:

#include <stdio.h>
 
int main()
{
    int a = 10;
    int b = -10;
 
    /* 用 unsigned char* 逐字节查看内存内容 */
    unsigned char *p;
 
    printf("10 的字节表示(小端): ");
    p = (unsigned char *)&a;
    for (int i = 0; i < sizeof(int); i++)
        printf("%02X ", p[i]);  /* 10 = 0x0A,输出: 0A 00 00 00 */
    printf("\n");
 
    printf("-10 的字节表示(小端): ");
    p = (unsigned char *)&b;
    for (int i = 0; i < sizeof(int); i++)
        printf("%02X ", p[i]);  /* 输出: F6 FF FF FF */
    printf("\n");
 
    /* -10 的原码:10000000 00000000 00000000 00001010
       反码:     11111111 11111111 11111111 11110101
       补码:     11111111 11111111 11111111 11110110
       即 0xFFFFFFF6,小端存储为 F6 FF FF FF */
    return 0;
}

注意 -10 的补码是 0xFFFFFFF6——用十六进制看,0xFFFFFFF6 就是"全 1 减去 9 再加 1"的味道。实际上有个更快的口算负数十进制的方法:-10 的补码 = ~10 + 1 = 0xFFFFFFF5 + 1 = 0xFFFFFFF6。任何一个负数的补码,都等于"对它的绝对值取反再加一"。

char 类型的取值范围

signed char 占用 1 字节(8 位),取值范围是 -128 到 127。你可能觉得不对称——为什么不是 -127 到 127?因为补码的编码方式:00000000 = 0,01111111 = 127,10000000 = -128,11111111 = -1。正数范围 0 到 127(128 个数),负数范围 -1 到 -128(128 个数),合起来正好 256 个值——没有一个编码被浪费。10000000 在原码和反码中会被解释为 -0(白白浪费一个编码),在补码中它代表 -128(完美利用)。unsigned char 则从 0 到 255,每个 bit 都是数值位。

C 标准没有规定 char 默认是有符号还是无符号——由编译器决定。在 x86 的 GCC 和 VS 中,char 默认是 signed char;但在某些嵌入式平台(如 ARM)上,char 可能是 unsigned char。如果你依赖 char 的符号性,请显式写 signed char 或 unsigned char。下面这个实验展示了它们的区别:

#include <stdio.h>
 
int main()
{
    char a = -1;           /* signed char(默认) */
    signed char b = -1;    /* 显式 signed char */
    unsigned char c = -1;  /* unsigned char:-1 的补码是 0xFF */
 
    /* 用 %d 打印时,char 被提升为 int */
    printf("a = %d, b = %d, c = %d\n", a, b, c);
    /* 输出: a = -1, b = -1, c = 255 */
 
    /* 解释:
       a 和 b:signed char 存储 0xFF(-1 的 8 位补码)
               提升为 int 时符号扩展 → 0xFFFFFFFF → 仍为 -1
       c:     unsigned char 存储 0xFF
               提升为 int 时零扩展 → 0x000000FF → 255 */
    return 0;
}

这里涉及了一个容易被忽视的概念——整型提升。当 char 和 short 参与运算或传给 printf 时,它们会被自动提升为 int。signed char 提升时做符号扩展(高位用符号位填充),unsigned char 提升时做零扩展(高位填 0)。这就是为什么同样的 0xFF,signed 打印出来是 -1,unsigned 打印出来是 255。

整型截断:大类型装进小类型的"削足适履"

与整型提升相反的操作是整型截断。当你把一个 int 赋给 char 时,只保留低 8 位,高 24 位被丢弃:

#include <stdio.h>
 
int main()
{
    int i = 0x11223344;   /* 32 位:1 1 2 2 3 3 4 4 */
    char c = i;           /* 截断:只保留低 8 位 0x44 */
    unsigned char uc = i; /* 截断:同样保留 0x44 */
 
    printf("i  = 0x%X\n", i);   /* 0x11223344 */
    printf("c  = %d (0x%X)\n", c, (unsigned char)c);   /* 68 (0x44) */
    printf("uc = %d (0x%X)\n", uc, uc);                /* 68 (0x44) */
 
    /* 截断的规则:丢掉高字节,只留低字节。
       这解释了为什么 char a[1000] 那个经典题里
       -1 - i 截断后会在 128 处"回绕"成 127 */
 
    int j = 0xFFFFFF80;   /* 高字节全 1 */
    char d = j;           /* 截断后是 0x80,作为 signed char 是 -128 */
    printf("d = %d\n", d); /* 输出 -128 */
    return 0;
}

截断的本质是"低字节保留、高字节丢弃"——它和大小端无关,因为无论大端小端,赋值时取的都是"数值的低 8 位"(编译器在寄存器层面完成的,不涉及内存字节序)。

char 溢出实战:strlen 的经典陷阱

看下面这个非常经典的面试题——你能否一眼看出输出结果?

#include <stdio.h>
#include <string.h>
 
int main()
{
    char a[1000];
    int i;
 
    /* a[i] = -1 - i 这条语句是关键:
       当 i = 0:   a[0] = -1    (0xFF)
       当 i = 1:   a[1] = -2    (0xFE)
       ...
       当 i = 127: a[127] = -128 (0x80)
       当 i = 128: a[128] = -129
           但 char 只能存 -128 到 127!
           -129 存不进 char → 截断为 127 (0x7F)
       当 i = 129: a[129] = -130 → 截断为 126 (0x7E)
       ...一直到...
       当 i = 255: a[255] = -256 → 截断为 0 (0x00)
       此时 strlen 在 a[255] 遇到 '\0',停止!*/
 
    for (i = 0; i < 1000; i++)
    {
        a[i] = -1 - i;
    }
 
    printf("strlen(a) = %d\n", (int)strlen(a));
    /* 输出: 255(因为在 a[255] 处首次出现 0x00) */
 
    return 0;
}

这个题目把补码、溢出、char 范围、strlen 行为串在了一起:strlen 在 a[255] 处遇到值为 0 的字节('\0'),停止计数,返回 255。

让我们把"回绕"的过程再捋一遍:-1 - i 对 i = 128 来说是 -129,其 32 位补码是 0xFFFFFF7F,截断到 8 位是 0x7F = 127。所以从 i = 128 开始,值从 -128 回绕到 +127,然后一路递减到 0(i = 255 时 -256 的 8 位截断正好是 0)。这就是"溢出回绕"——补码在有限位数下表现出的循环特性:127 + 1 = -128,-128 - 1 = 127。

类似地,无符号类型的"回绕"也是常见的陷阱。unsigned char i 从 0 到 255,当 i=255 时 i++ 溢出归零——所以 for(i=0; i<=255; i++) 是一个死循环。同样,unsigned int i; for(i=9; i>=0; i--) 也是死循环——因为 unsigned int 永远 >= 0,当 i=0 时 i-- 后变成 4294967295。为了让小白能亲眼看到"回绕"是怎么发生的,我们用一段可安全运行的代码来演示:

#include <stdio.h>
 
int main()
{
    /* 演示一:unsigned char 的回绕
       255 + 1 本应等于 256,但 unsigned char 只有 8 位,
       256 的二进制是 1 00000000,高位的 1 溢出丢失,结果回绕成 0 */
    unsigned char c = 255;
    c = c + 1;
    printf("255 + 1 = %u(回绕成 0,这正是 for(i=0; i<=255; i++) 死循环的原因)\n", c);
 
    /* 演示二:unsigned int 递减到 0 之后的回绕
       0 - 1 本应等于 -1,但 unsigned int 无符号,
       就变成了 2^32-1 = 4294967295 这个巨大正数 */
    unsigned int u = 0;
    printf("0 - 1 = %u(回绕成 4294967295,这正是 for(i=9; i>=0; i--) 死循环的原因)\n", u - 1);
 
    return 0;
}

注意:演示二用的是"计算 u - 1 这个表达式的值"而不真的让循环跑起来——因为真实的 for(i=0; i<=255; i++) 和 for(i=9; i>=0; i--) 会"永远执行"、把程序卡死,根本到不了循环体后面的代码。我们只需要观察回绕之后的数值,就能理解为什么 i <= 255 和 i >= 0 永远为真。

同一个位模式,不同的解读

还有一个有趣的边界实验:char = -128 和 char = 128 在内存中的位模式完全相同(都是 10000000),所以用 %u 打印时输出一模一样:

#include <stdio.h>
 
int main()
{
    char a = -128;
    /* -128 的二进制(8位补码): 10000000
       用 %u 打印时,char 先提升为 int(符号扩展)
       变为 0xFFFFFF80 = 4294967168 */
    printf("char a = -128, %%u: %u\n", a);
 
    char b = 128;
    /* 128 的二进制: 10000000
       但作为 signed char,最高位 1 表示负数
       所以 b 的实际值是 -128
       提升为 int 时符号扩展 → 0xFFFFFF80 */
    printf("char b = 128, %%u: %u\n", b);
 
    /* 你会发现:char a = -128 和 char b = 128 输出完全一样!
       因为在 8 位补码中,-128 和 128 的位模式都是 10000000 */
    return 0;
}

这个实验再次印证核心认知:内存里只有位模式,没有"值"——值是我们解读出来的。10000000 本身既不是 -128 也不是 128,全看你怎么读它。

大小端字节序和字节序判断

有了整数在内存中"如何编码"的知识,下一个问题是:这些字节在内存中"按什么顺序排列"?

对于一个多字节的数据(如 int a = 0x11223344;),它在内存中占用 4 个字节。问题是:这 4 个字节在内存地址中怎么排?

  • 大端模式:高位字节放在低地址。0x11(最高字节)→ 地址 0x100,0x22 → 0x101,0x33 → 0x102,0x44(最低字节)→ 0x103。
  • 小端模式:低位字节放在低地址。0x44(最低字节)→ 地址 0x100,0x33 → 0x101,0x22 → 0x102,0x11(最高字节)→ 0x103。

一个简单的记忆方法:大端是"人的阅读习惯"(高位在前),小端是"计算机的存储习惯"(低位在低地址)。我们日常使用的 x86/x64 架构 CPU(Intel/AMD)是小端模式,而网络协议(TCP/IP)采用大端模式(也叫"网络字节序")。

为什么会有两种模式?根因在于:内存寻址以字节为单位,但处理器的寄存器宽度可能大于一个字节(16 位、32 位、64 位)。当你把一个 32 位的数据放进 4 个连续的 1 字节地址单元时,必然要决定"哪个部分放哪个地址"。不同厂商做了不同选择——这不是对错问题,只是历史路径不同。我们常用的 x86/x64 是小端;而 KEIL C51 单片机、老式 PowerPC 等是大端;很多 ARM、DSP 默认小端,部分 ARM 处理器甚至可以在硬件上配置成端模式。

小端的一个实际好处:低字节在低地址,所以任何多字节值都可以通过"读低地址"来快速得到低位——比如把一个 64 位整数当成 32 位整数读(截断),不需要移位。这也是为什么 C 标准里 int 和 long 混用时偶尔"碰巧能工作"的底层原因。

判断当前机器的大小端有两种经典方法。第一种是指针法:

#include <stdio.h>
 
int check_sys()
{
    int i = 1;
    /* 取 i 的首地址,转为 char* 读取第一个字节
       小端:01 00 00 00 → 首字节是 01 → 返回 1
       大端:00 00 00 01 → 首字节是 00 → 返回 0 */
    return (*(char *)&i);
}
 
int main()
{
    int ret = check_sys();
    if (ret == 1)
        printf("当前机器是 小端 模式\n");
    else
        printf("当前机器是 大端 模式\n");
    return 0;
}

第二种是联合体法——利用联合体所有成员共享同一块内存的特性(下一讲会详细讲 union):

#include <stdio.h>
 
int check_sys()
{
    union
    {
        int i;    /* int 和 char 共享同一块内存 */
        char c;
    } un;
 
    un.i = 1;     /* 小端:byte0=0x01, byte1=0x00, ... */
    return un.c;  /* 读取第一个字节 */
}
 
int main()
{
    if (check_sys() == 1)
        printf("联合体法判断: 小端\n");
    else
        printf("联合体法判断: 大端\n");
    return 0;
}

大小端 + 指针的组合还能玩出更"阴险"的题目——下面这道经典面试题综合了数组、指针算术和字节序:

#include <stdio.h>
 
int main()
{
    /* X86 环境(小端)下,输出是什么? */
    int a[4] = { 1, 2, 3, 4 };
    int *ptr1 = (int *)(&a + 1);      /* 跳过整个数组 */
    int *ptr2 = (int *)((int)a + 1);  /* 数组首地址 + 1 个字节 */
 
    printf("%x,%x\n", ptr1[-1], *ptr2);
    return 0;
}

一行一行拆开看:

  • &a 的类型是 int (*)[4](指向整个数组的指针),&a + 1 一次跳过 4 × 4 = 16 个字节,指向数组末尾之后的位置。所以 ptr1[-1] 等价于 *(ptr1 - 1),取到的是 a[3],也就是 4。
  • (int)a 把数组首地址当整数用,+1 就是地址加 1 个字节,再转回 int * 后,ptr2 指向 a[0] 起始地址往后 1 字节的位置。小端下 a[0] = 1 的内存是 01 00 00 00,从第 2 个字节开始连续读 4 个字节,得到 00 00 00 02——按小端读回来就是 0x02000000(十进制 33554432)。
  • 所以最终输出:4,2000000。

这个题有两个坑:一是 &a + 1 的步长是"整个数组"而不是"一个元素";二是地址加 1 字节后,读出来的 4 个字节横跨了 a[0] 的尾巴和 a[1] 的开头,小端的字节序让 02 恰好落在最高字节。另外注意题目刻意标注"X86 环境"——因为 (int)a 直接把指针转成 int,在 64 位平台上会截断地址(指针是 8 字节,int 只有 4 字节),这本身就属于不可移植的写法,只是经典面试题常拿它考察字节序理解。

浮点数在内存中的存储

IEEE 754 标准

这是本讲最重要也最复杂的部分。前面讲了整数用补码存储,但浮点数不能这么搞——小数点的位置怎么表示?IEEE 754 标准给出了一个精妙的答案。常见的浮点数家族包括 float、double、long double,各自能表示的范围在 <float.h> 中定义(比如 FLT_MAX、DBL_MIN),需要精确边界值时可以去查它。

任意一个二进制浮点数 V 可以表示为:

V = (-1)^S × M × 2^E

  • S(Sign):符号位。0 表示正数,1 表示负数。
  • M(Mantissa):有效数字,取值范围是 [1, 2)。即 1.xxxxxx 的形式。
  • E(Exponent):指数,决定了小数点的位置。

举例:十进制 5.0,二进制是 101.0 = 1.01 × 2^2。所以 S=0,M=1.01,E=2。

在内存中,float(32 位单精度)的布局是:

S(1 位)E(8 位)M(23 位)

double(64 位双精度)的布局是:

S(1 位)E(11 位)M(52 位)

float 共 1+8+23 = 32 位 = 4 字节,double 共 1+11+52 = 64 位 = 8 字节。float 一定占 4 字节、double 一定占 8 字节(C 标准保证 float ≥ 32 位、double ≥ 64 位,而 IEEE 754 明确规定了 32/64 两种格式)。

存入内存时的特殊规则

M 的规则:因为 M 总是 1.xxxxxx 的形式,那个整数部分的 1 是恒定不变的。IEEE 754 规定:存储时只保存小数点后面的 xxxxxx 部分,读取时再把 1 补回来。这样,23 位的 M 字段实际能表示 24 位精度——相当于白赚了 1 位。

E 的规则:指数 E 在数学上可以是负数(比如 0.5 = 1.0 × 2^(-1),E = -1),但内存中的 E 字段是一个无符号整数。为了让无符号整数能表示负数,IEEE 754 引入了一个"中间值"(bias):对于 float(8 位 E),中间值 = 127;对于 double(11 位 E),中间值 = 1023。存入时的实际值 = 真实指数 + 中间值。比如真实指数 E = 2,存入 float 就是 2 + 127 = 129 = 10000010。

从内存读出时的三种情况

情况 1:E 不全为 0 也不全为 1(常规情况)

这是大多数浮点数的表示方式。读出时:真实指数 = 存储值 - 127(或 1023),有效数字 M 前面加上 1.。规格化浮点数的指数范围:float 从 -126 到 +127(存储值 1~254)。

情况 2:E 全为 0

此时真实指数 = 1 - 127 = -126(不是 0 - 127,这个偏移设计保证了从"非规格化"到"规格化"的平滑过渡)。有效数字 M 前面不再加 1.,而是还原为 0.xxxxxx。这用于表示 ±0(M 也全 0)以及非常接近 0 的小数(subnormal numbers,非规格化数)。为什么要用 1-bias 而不是 0-bias?为了在规格化和非规格化数之间无缝衔接:规格化最小数(E=1, M=0)是 1.0 × 2^-126,非规格化最大数是 0.111... × 2^-126(约等于 1.0 × 2^-126 减一个最小步长)——两者之间没有空洞。

情况 3:E 全为 1

如果 M 全为 0,表示 ±∞(无穷大,比如 1.0/0.0 的结果);如果 M 不全为 0,表示 NaN(Not a Number,比如 0.0/0.0 或 sqrt(-1.0) 的结果)。NaN 有个奇怪的性质:NaN != NaN 永远为真——因为 NaN 的含义是"不是数字",两个"不是数字"的东西当然不相等。判断一个值是否是 NaN,用 x != x 这个技巧(GCC 下会警告,建议用 isnan(x))。

来看一段代码,直观地解构 float 的二进制表示:

#include <stdio.h>
 
/* 打印 float 的二进制位模式 */
void print_float_bits(float f)
{
    unsigned int *p = (unsigned int *)&f;
    unsigned int bits = *p;
 
    printf("符号位 S: %u\n", (bits >> 31) & 1);
    printf("指数位 E: ");
    for (int i = 30; i >= 23; i--)
        printf("%u", (bits >> i) & 1);
    printf(" (%u)\n", (bits >> 23) & 0xFF);
 
    printf("尾数位 M: ");
    for (int i = 22; i >= 0; i--)
        printf("%u", (bits >> i) & 1);
    printf("\n");
}
 
int main()
{
    float f1 = 9.0f;
    printf("9.0 的 IEEE 754 表示:\n");
    print_float_bits(f1);
    /* 输出:
       符号位 S: 0
       指数位 E: 10000010 (130 = 3 + 127)
       尾数位 M: 00100000000000000000000  */
 
    float f2 = 0.5f;
    printf("\n0.5 的 IEEE 754 表示:\n");
    print_float_bits(f2);
    /* 0.5 = 0.1(二进制) = 1.0 × 2^(-1)
       S = 0, E = -1 + 127 = 126 = 01111110, M = 全0 */
    return 0;
}

更多分解练习:5.5、-9.5、0.1

为了彻底掌握"十进制 → IEEE 754 位模式"的转换,我们再手推几个例子:

5.5:二进制 101.1(0.5 = 2^-1,所以小数点后一位是 1)= 1.011 × 2^2。S = 0,E = 2 + 127 = 129 = 10000001,M = 01100000000000000000000。

-9.5:二进制 1001.1 = 1.0011 × 2^3。S = 1,E = 3 + 127 = 130 = 10000010,M = 00110000000000000000000。合起来:1 10000010 00110000000000000000000。

0.1:这个最特别——0.1 的二进制是无限循环小数 0.00011001100110011...(无法精确表示!)。规范化为 1.1001100110011... × 2^-4,尾数只能保留 23 位,多余部分舍入丢弃。所以 0.1f 在内存里的值实际上是 0.100000001490116...——一个和 0.1 很接近但不相等的数。

#include <stdio.h>
 
int main()
{
    /* 打印 0.1f 到底存了什么 */
    float f = 0.1f;
    printf("0.1f 的精确值: %.20f\n", f);
    /* 输出: 0.10000000149011611938  (不是 0.1!) */
 
    /* 0.1 用 double 存储也一样不精确,只是误差更小 */
    double d = 0.1;
    printf("0.1 的精确值:  %.20f\n", d);
    /* 输出: 0.10000000000000000555 */
 
    return 0;
}

这就是浮点数精度问题的根源:0.1 在二进制里是无限循环小数,任何有限位数的存储都会引入舍入误差——就像 1/3 在十进制里写不完一样。

经典互读实验

回到开头的问题:为什么 int n = 9 用 %f 打印是 0.000000?反过来为什么 float f = 9.0f 用 %d 打印是 1091567616?

#include <stdio.h>
 
int main()
{
    int n = 9;
    float *pFloat = (float *)&n;
 
    /* 用整型的规则解读 n */
    printf("n 的整型值:      %d\n", n);
 
    /* 用浮点数的规则解读同一块内存 */
    printf("n 的浮点解读:    %f\n", *pFloat);
    /* 9 的二进制 = 0x00000009
       按 float 解析:S=0, E=0(全0), M=很小 → 接近 0 */
 
    /* 将 9.0 按 float 写入同一块内存 */
    *pFloat = 9.0;
    printf("修改后 n 的整型值:  %d\n", n);
    /* 9.0 的 float 二进制 = 0 10000010 00100000000000000000000
       按 int 解析 = 1091567616 */
 
    printf("修改后 *pFloat 的值: %f\n", *pFloat);
    /* 正常的浮点解读 = 9.000000 */
 
    return 0;
}

解释一下:9 的整型二进制是 00000000 00000000 00000000 00001001。按 IEEE 754 float 解读——S=0,E=00000000(全为 0 → 情况 2),M=00000000000000000001001——结果是 V = (-1)^0 × 0.00000000000000000001001 × 2^(-126),非常接近 0。%f 默认只显示 6 位小数,所以显示 0.000000。

反过来,9.0 = 1.001 × 2^3,按 IEEE 754 编码:S=0,E=3+127=130=10000010,M=00100000000000000000000。合在一起的 32 位二进制数按十进制整数解读就是 1091567616。

核心启示:底层的二进制位没有变,变的是解读规则。当你强行用错误的格式说明符(%d vs %f)去解读某个内存区域的二进制位,你得到的就是按照"另一套规则"翻译出来的结果——这正是 C 语言指针和强制类型转换的威力所在,也是为什么类型安全如此重要。

浮点数的精度问题

理解了 IEEE 754 之后,你就能解释这个现象了:

#include <stdio.h>
 
int main()
{
    /* float 只有 23 位尾数(实际精度 24 位),约 7 位十进制有效数字 */
    float f1 = 123456789.0f;
    printf("float 大数: %.1f\n", f1);
    /* 输出可能是 123456792.0 —— 最后几位已经不准了 */
 
    /* double 有 52 位尾数,约 15-16 位十进制有效数字 */
    double d1 = 123456789.0;
    printf("double 大数: %.1f\n", d1);
    /* 输出: 123456789.0 —— 精度足够 */
 
    /* 浮点数不能精确表示 0.1(二进制是无限循环小数) */
    float sum = 0.0f;
    for (int i = 0; i < 10; i++)
    {
        sum += 0.1f;
    }
    printf("0.1 加 10 次: %.10f\n", sum);
    /* 输出: 1.0000001192...(不是精确的 1.0) */
 
    /* 这就是为什么金融计算不用浮点数! */
    return 0;
}

0.1 这个看似简单的十进制小数,在二进制中是无限循环的——就像 1/3 在十进制中是无限循环的一样。所以 0.1 + 0.2 != 0.3,不是 bug,是浮点数表示的根本性限制。float 只有约 7 位有效数字,double 有约 15-16 位。处理金融数据时,应该用整数(存储"分"而不是"元")或者专门的定点数库。

float 与 double 精度对照表:

类型总位数符号位指数位尾数位有效十进制位数范围(约)
float321823(实 24)6~7 位±3.4×10^38
double6411152(实 53)15~16 位±1.8×10^308

注意:float 和 double 的精度差异不是"小数位数"的差异,而是"二进制有效数字位数"的差异。double 的尾数比 float 多 29 位(52-23),所以能精确表示的十进制位数大约翻倍。但它们都无法精确表示 0.1 这类十进制有限、二进制无限循环的数——只是 double 的误差更小、累积更慢。

浮点数的比较陷阱

因为浮点数有舍入误差,直接比较 f == 0.1 常常是错的:

#include <stdio.h>
#include <math.h>   /* fabs */
 
int main()
{
    float f = 0.1f;
 
    /* 错误做法:直接比较 */
    if (f == 0.1f)
        printf("f == 0.1f\n");   /* 这个可能成立,因为都是同一个舍入结果 */
    else
        printf("f != 0.1f\n");
 
    /* 更隐蔽的错误:和 double 字面量比较 */
    if (f == 0.1)                /* 0.1 是 double 类型! */
        printf("f == 0.1\n");    /* 不成立!float 被提升为 double 后,
                                    位模式不同(float 0.1f 先转成 double
                                    还是 0.100000001490116...) */
    else
        printf("f != 0.1\n");    /* 会打印这行 */
 
    /* 正确做法:比较差值是否在容差范围内 */
    double diff = f - 0.1;
    if (fabs(diff) < 1e-6)
        printf("f 约等于 0.1(误差在容差内)\n");
 
    return 0;
}

注意第一个比较 f == 0.1f 其实"碰巧成立"——因为两边都是 float 0.1f,都是同一个舍入结果。而 f == 0.1 一定不成立:右边的 0.1 是 double(更高精度,舍入误差不同),左边的 float 先转成 double 再比,两个值在 double 精度下并不相等。这是浮点数比较最常见的坑:字面量默认是 double,混合比较时低精度会被提升到高精度,而提升过程不会恢复已经丢失的精度。

浮点数的特殊值速查

情况SEM含义
+0.00全 0全 0正零
-0.01全 0全 0负零(1.0/-0.0 = -inf)
非规格化数任意全 0非全 0接近 0 的极小值
规格化数任意非全0非全1任意常规浮点数
+∞0全 1全 0正无穷(1.0/0.0)
-∞1全 1全 0负无穷(-1.0/0.0)
NaN任意全 1非全 0非数字(0.0/0.0)

有一个冷知识:-0.0 和 +0.0 在内存里位模式不同,但比较时 -0.0 == +0.0 为真。可是 1.0 / -0.0 得到 -inf,1.0 / +0.0 得到 +inf——所以它们"看起来相等,行为却不相同"。

实战:用位运算打印整数的二进制

学了这么多理论,写个工具把它们用起来——打印一个整数的二进制位(这既是补码的验证,也是位运算的复习):

#include <stdio.h>
 
/* 从高到低打印一个整数的 32 个二进制位 */
void print_binary(unsigned int x)
{
    int i;
    for (i = 31; i >= 0; i--)
    {
        printf("%u", (x >> i) & 1);
        if (i % 8 == 0) printf(" ");   /* 每 8 位加一个空格便于阅读 */
    }
    printf("\n");
}
 
int main()
{
    int a = 5;
    int b = -5;
 
    printf("5  = ");  print_binary((unsigned int)a);
    printf("-5 = ");  print_binary((unsigned int)b);
    /* 输出:
       5  = 00000000 00000000 00000000 00000101
       -5 = 11111111 11111111 11111111 11111011
       (-5 的补码 = ~5 + 1 = 11111010 + 1 = 11111011) */
 
    /* 验证:5 + (-5) = 0,用位运算看补码加法的妙处 */
    printf("5 + (-5) = %d\n", a + b);   /* 0 */
    return 0;
}

总结与工程启示

从补码到大小端再到 IEEE 754,你会发现一个贯穿始终的主题:内存中的二进制位不变,变的是解读规则。int、float、char 这些类型,本质上都是同一块二进制数据的"不同眼镜"。戴上哪副眼镜,就看到什么样的数据。C 语言的指针和强制类型转换,就是给你自由切换眼镜的权利——当然,伴随权利而来的,是更多的责任。

在实际工程中,理解大小端对网络编程至关重要——你需要用 htonl()、htons()、ntohl()、ntohs() 在主机字节序和网络字节序之间转换。理解 IEEE 754 的特殊值(NaN、Inf、-0)对科学计算和异常处理至关重要。理解补码的边界行为能帮你避免无符号死循环和类型转换 bug。这些知识不只是应付考试用的——它们是写出正确、安全、高效的 C 代码的基石。

思考题

  1. 为什么补码要"反码 + 1"而不是"符号位不变、其余取反"?从 0 和 -0 的编码重复角度解释。
  2. 在 32 位机器上,int i = 2147483647; i + 1; 的结果是多少?为什么?
  3. 大端和小端存储 0x12345678 时,如果用小端机器上的 (char*)&x[0] 读第一个字节,各读到什么?
  4. 判断:sizeof(float) 一定是 4 吗?sizeof(double) 呢?(提示:查 C 标准对 float/double 的最小要求)
  5. 为什么 0.1f 和 0.1 比较不相等,但 0.5f 和 0.5 比较相等?(提示:0.5 的二进制是有限小数)
  6. 写代码验证:float f = 16777216.0f;(2^24)加 1 后还是原来的值吗?为什么?(提示:24 位尾数的极限)
  7. 尝试手推:float f = -0.75f; 的 IEEE 754 位模式是什么?(0.75 = 0.11 二进制)

思考题参考答案

1. 为什么补码要"反码 + 1"而不是"符号位不变、其余取反"?从 0 和 -0 的编码重复角度解释。

关键在于消除 -0 这个浪费的编码。如果负数只做"反码"(符号位不变、其余取反),那么 0 会出现两个表示:+0 = 00000000、-0 = 11111111——两个不同的位模式却代表同一个值 0,浪费了一个编码,也让相等判断变复杂(比较时还得把 -0 特判成 0)。

"反码 + 1"正是补码的精髓:它把反码里的 11111111(-0)整体 +1,溢出后变成 1 00000000,最高的进位自然丢失,于是 -0 就"让位"给了 -1(11111111 现在表示 -1),而那个空出来的 10000000 则表示为 -128。这样一来 8 位补码的 256 个编码恰好对应 256 个不同的值(-128~127),一个编码都没浪费,同时加法可以简单地"直接相加、自动丢弃进位"。这才是补码被选中的核心原因。

2. 在 32 位机器上,int i = 2147483647; i + 1; 的结果是多少?为什么?

2147483647 是 INT_MAX,即 0x7FFFFFFF。i + 1 应在 0x7FFFFFFF + 1 = 0x80000000,而 0x80000000 作为 32 位有符号 int 解释就是 -2147483648(INT_MIN)——数值从"最大正数"一下跳到了"最小负数",这就是补码的"溢出回绕"。

但要注意:有符号整数溢出在 C 标准里是未定义行为(undefined behavior)。也就是说,虽然绝大多数补码机器上你实际会得到 -2147483648,但标准不允许你依赖这个结果——某些优化(GCC 的 -fstrict-overflow)可能让程序行为变得不可预期。相比之下无符号整数的溢出是"定义良好"的(结果按 2^N 取模回绕)。所以想严谨地讨论"真实结果",说出来的同时务必强调它在标准上是未定义行为。这里把它和思考题里"看内存数值"的角度区分开:看位模式确实是 0x80000000,但把它当成"有符号计算结果"依赖它是不保险的。

3. 大端和小端存储 0x12345678 时,如果用小端机器上的 (char*)&x[0] 读第一个字节,各读到什么?

0x12345678 的低字节是 0x78,高字节是 0x12。

  • 小端:低字节放低地址,所以 (char*)&x[0](首地址第一个字节)读到的是低字节 0x78。
  • 大端:高字节放低地址,所以首字节读到的是高字节 0x12。

顺带加深记忆:所谓"小端"就是"低端(低位)在低字节序上先放",所以内存里从低地址到高地址依次是 78 56 34 12;大端则相反,是 12 34 56 78(和数字书写习惯一致)。本讲讲的字节序判断程序,正是靠"读首字节是 1 还是 0"(存 1 时得到首字节 0x01 或 0x00)来区分这两种布局。

4. 判断:sizeof(float) 一定是 4 吗?sizeof(double) 呢?

不一定是,虽然实践中几乎总是。 C 标准对 float/double 的定义是基于"数学特性"而非"固定字节数"——它只要求某种最小表示能力(如 float 至少能表示约 ±1E-37 到 ±1E37 的数值范围、double 表示能力不小于 float 等),并没有强制规定 float 必须按 IEEE 754 单精度、恰好占 4 字节。

不过在绝大多数现代平台上(x86/ARM 等),float 都实现为 IEEE 754 单精度(32 位 = 4 字节)、double 实现为双精度(64 位 = 8 字节),所以实际 sizeof(float) == 4、sizeof(double) == 8 几乎总是成立。流行的解释是:IEEE 754 明确规定了 32/64 两种布局,C 实现通常"遵循它"以与硬件指令集兼容。所以精确说法是:标准不保证,工程实践总是如此;想严谨就写 sizeof(...) 而不是写死 4/8。

5. 为什么 0.1f 和 0.1 比较不相等,但 0.5f 和 0.5 比较相等?

因为 0.1 在二进制里是无限循环小数,无法精确表示:

  • 0.1f(float)按 IEEE 754 存储后得到的近似值约 0.100000001490116...。当它被提升成 double 参与比较时,这个"被 float 舍入过的近似值"并不会恢复精度,只是把同样的一段位扩展成 64 位,值仍是 0.100000001490116...。
  • 右边的 0.1 是 double 字面量,它直接用 52 位尾数近似 0.1,近似值约 0.10000000000000000555...。

两个 double 值在尾数的高位上就已经不同,所以 0.1f == 0.1 为假。

而 0.5 = 1.0 × 2^(-1),二进制是精确的有限小数 0.1,float 和 double 都能无误差地表示它。二者提升后都是完全一致的位模式,所以 0.5f == 0.5 为真。结论一句话:判别标准是"该数在二进制里是否为有限小数"——是,则 float 与 double 比较相等;不是,则低精度近似值在提升后不会与高精度近似值相等。

6. 写代码验证:float f = 16777216.0f;(2^24)加 1 后还是原来的值吗?

还是原来的值(f + 1 == f 成立)。验证代码与原因:

#include <stdio.h>
 
int main()
{
    float f = 16777216.0f;   /* 2^24 */
    float f2 = f + 1.0f;
 
    printf("f  = %.0f\n", f);   /* 16777216 */
    printf("f+1= %.0f\n", f2);  /* 也是 16777216:加 1 无效果 */
 
    if (f == f2)
        printf("f + 1 == f(加 1 后值不变)\n");
    return 0;
}

原因:float 尾数 23 位、加隐含位共 24 位二进制有效数字。它最多能精确表示那些"从第 24 位往后的位都是 0"的整数,也就是值恰好落在 2^23 的整数倍上的数。2^24 本身是 2 的幂,可以精确表示;但 2^24 + 1 需要 25 位有效数字才写得出(第 25 位是 1),超出 float 的 24 位能力,于是它会被舍入回最近的"可表示值"。2^24 与 2^24 + 1 之间的间距正好是 2(因为从 2^24 往上 float 能分辨的最小间隔已经是 2),所以 f + 1 只能被舍回到 2^24 本身。这就是为什么超过 2^24 之后 float 无法表示所有连续整数——这也是"float 只能精确到约 16777216 以内的问题"的由来。

7. 尝试手推:float f = -0.75f; 的 IEEE 754 位模式是什么?

-0.75 绝对值是 0.75,十进制转二进制:0.75 = 0.5 + 0.25 = 2^(-1) + 2^(-2),即二进制的 0.11。规范化成 1.xxxx × 2^E 的形式:把小数点右移 1 位,得 1.1 × 2^(-1)。

  • 符号位 S:负数,S = 1。
  • 指数 E:真实指数是 -1,加上 float 的中间值 127,存 -1 + 127 = 126 = 0b01111110。
  • 尾数 M:1.1 去掉隐含的整数位 1,小数部分为 .1,补足 23 位得 10000000000000000000000。

拼接 S + E + M,-0.75f 的位模式为:

1 01111110 10000000000000000000000

用十六进制看,0.75f 的位模式是 0xBF400000,符号位为 1 会带来最高位 B——这可以作为手推后的自检结果。同样方法可推 5.5f = 0 10000001 01100000000000000000000、-9.5f = 1 10000010 00110000000000000000000,与正文的分解放置结果一致。