02

信息的表示和处理

我们生活在比特组成的世界

阅读量:2 · 预计 16 分钟读完

十六进制补码浮点数位运算
阅读进度2%

第2章:信息的表示和处理(Representing and Manipulating Information)

一、导读

1.1 本章学习目标

第2章深入探讨计算机如何表示和处理信息。这是整个课程的基石——如果说不理解第1章只是"视野不够开阔",那么不理解第2章则会导致在实际编程中犯下严重的、难以调试的错误。

本章的核心学习目标包括:

  • 理解进制转换:十六进制、二进制、十进制之间的转换及其在编程中的应用
  • 掌握整数表示:无符号整数和补码有符号整数的表示范围、运算规则
  • 理解整数运算:加法、乘法的溢出行为,以及编译器如何利用这些特性进行优化
  • 掌握浮点数表示:IEEE 754 标准的原理、精度限制和特殊值
  • 理解类型转换:C 语言中隐式和显式类型转换的底层机制
  • 理解字节序:大端序和小端序的区别及其在网络编程中的影响

1.2 为什么这一章至关重要

信息表示是计算机系统最底层的基础。无论是编写嵌入式系统、开发网络协议、进行科学计算还是从事安全研究,对信息表示的深入理解都是必不可少的。许多看似"诡异"的编程 bug 都源于对信息表示的误解:

c
// 经典 bug 1:无符号比较陷阱
unsigned int i = 0;
if (i - 1 < 0) {  // 永远为假!
    // 这段代码永远不会执行
}

// 经典 bug 2:浮点数精度问题
double a = 0.1 + 0.2;
if (a == 0.3) {  // 永远为假!
    // 0.1 + 0.2 = 0.30000000000000004
}

// 经典 bug 3:整数溢出
int sum = 0;
for (int i = 1; i <= 100000; i++) {
    sum += i;  // 可能溢出,结果未定义
}

理解本章内容,你就能准确解释这些现象的原因,并避免在关键代码中犯下类似错误。

1.3 核心问题引导

为什么计算机使用补码而不是原码来表示有符号整数?

为什么 INT_MAX + 1 会变成 INT_MIN

浮点数为什么不能精确表示 0.1?

为什么同一个整数在不同机器上的字节顺序可能不同?

类型转换时到底发生了什么?


二、核心概念详解

2.1 十六进制表示

十六进制(Hexadecimal)是程序员与二进制世界沟通的桥梁。在计算机科学中,十六进制之所以被广泛使用,是因为它与二进制之间的转换极其简单直接——每个十六进制数字恰好对应 4 个二进制位。相比十进制,十六进制能更紧凑地表示二进制数据,一个字节(8 位)只需要两个十六进制数字即可表示。在阅读内存转储、调试网络协议、分析文件格式时,十六进制是不可或缺的工具。每个十六进制数字对应 4 个二进制位:

十六进制二进制十进制
000000
100011
.........
910019
A101010
B101111
C110012
D110113
E111014
F111115

在 C 语言中,十六进制常量以 0x0X 开头:

c
int mask = 0xFF;        // 255,二进制 11111111
int address = 0x7FFF0000; // 内存地址
int flags = 0x0A;       // 位标志

十六进制与二进制的快速转换

0x3A5F = 0011 1010 0101 1111
       = 0b0011101001011111

每个十六进制位直接替换为 4 位二进制即可,无需经过十进制中转。

2.2 数据的存储——字节与字

2.2.1 字节序(Endianness)

当一个数据占多个字节时,字节的排列顺序有两种方式:

  • 小端序(Little-Endian):低字节在低地址(x86、ARM 默认)
  • 大端序(Big-Endian):高字节在低地址(网络字节序、某些 RISC 架构)

假设变量 int x = 0x01234567,地址从低到高为 1000-1003:

地址小端序大端序
10000x670x01
10010x450x23
10020x230x45
10030x010x67
c
// 检测当前机器的字节序
int check_endian() {
    int x = 1;
    return *(char *)&x;  // 返回 1 表示小端序,返回 0 表示大端序
}

字节序在网络编程中非常重要。TCP/IP 协议规定使用大端序(网络字节序),在发送数据前需要使用 htonl()htons() 等函数进行转换。

字节序问题不仅出现在网络编程中,在以下场景中也需要特别注意:

  • 文件格式:某些二进制文件格式(如 BMP 图片)使用特定的字节序,读取时需要正确处理
  • 跨平台数据交换:在不同架构的机器之间传递二进制数据时,需要统一字节序
  • 安全研究:在逆向工程中,理解字节序对于正确解析二进制数据至关重要
  • 内存调试:使用十六进制查看器观察内存时,需要知道当前平台的字节序才能正确解读数据
c
// 跨平台字节序转换的标准函数
#include <arpa/inet.h>

uint32_t host_to_network_32(uint32_t val) {
    return htonl(val);  // host to network long (32位)
}

uint16_t host_to_network_16(uint16_t val) {
    return htons(val);  // host to network short (16位)
}

uint32_t network_to_host_32(uint32_t val) {
    return ntohl(val);  // network to host long
}

// 手动实现字节序转换(不依赖平台特定头文件)
uint32_t swap_bytes_32(uint32_t val) {
    return ((val & 0xFF000000) >> 24) |
           ((val & 0x00FF0000) >> 8) |
           ((val & 0x0000FF00) << 8) |
           ((val & 0x000000FF) << 24);
}

2.3 整数表示

2.3.1 无符号整数

w 位无符号整数的表示范围为 [0, 2^w - 1]。

对于 32 位无符号整数(unsigned int):范围是 [0, 4,294,967,295](约 42 亿)。

对于 64 位无符号整数(unsigned long long):范围是 [0, 18,446,744,073,709,551,615](约 1.8 × 10^19)。

无符号整数的编码方式就是标准的二进制编码:

x = b_{w-1} b_{w-2} ... b_1 b_0
B2U(x) = Σ b_i × 2^i  (i 从 0 到 w-1)

2.3.2 补码(Two's Complement)

补码是有符号整数的标准表示方式。w 位补码的表示范围为 [-2^(w-1), 2^(w-1) - 1]。

对于 32 位有符号整数(int):范围是 [-2,147,483,648, 2,147,483,647]。

对于 64 位有符号整数(long long):范围是 [-9,223,372,036,854,775,808, 9,223,372,036,854,775,807]。

补码的编码公式:

x = b_{w-1} b_{w-2} ... b_1 b_0
B2T(x) = -b_{w-1} × 2^{w-1} + Σ b_i × 2^i  (i 从 0 到 w-2)

最高位(MSB)是符号位,权重为 -2^(w-1)。

为什么使用补码而不是原码或反码?

加法统一:补码的加法和无符号整数完全相同,CPU 不需要区分有符号和无符号加法

零的表示唯一:原码中 +0 和 -0 有两种表示,补码只有一种

取反简单:对补码取反(求相反数)只需"按位取反再加1"

c
// 补码取反示例
int x = 5;      // 0000...0101
int neg_x = -x;  // 1111...1011
// 等价于 ~x + 1
// ~5 = 1111...1010
// ~5 + 1 = 1111...1011 = -5

2.3.3 有符号与无符号的转换

C 语言中,当有符号数和无符号数进行运算时,有符号数会被隐式转换为无符号数。这个转换的位模式不变,但解释方式变了:

c
// 危险!有符号与无符号比较
int x = -1;
unsigned int y = 1;
if (x < y) {
    // 不会执行!因为 x 被转换为 unsigned int
    // (unsigned int)(-1) = 4294967295 > 1
}
printf("%u\n", (unsigned int)x);  // 输出 4294967295

这是一个非常常见的 bug 来源。当 intunsigned int 混合运算时,int 会被转换为 unsigned int,负数会变成很大的正数。

2.4 整数运算

2.4.1 无符号加法

w 位无符号加法的数学结果是两个操作数之和,但如果结果超出 w 位能表示的范围,就会发生溢出截断

UAdd_w(x, y) = (x + y) mod 2^w
c
unsigned char a = 200;  // 11001000
unsigned char b = 100;  // 01100100
unsigned char c = a + b; // 200 + 100 = 300,截断为 300 mod 256 = 44
// c = 00101100 = 44

2.4.2 补码加法

补码加法在位级别上与无符号加法完全相同,只是溢出后的解释不同:

TAdd_w(x, y) = U2T(UAdd_w(x, y))

即先将 x 和 y 按位相加(与无符号加法相同),然后将结果按补码解释。

c
signed char a = 100;   // 01100100
signed char b = 50;    // 00110010
signed char c = a + b;  // 100 + 50 = 150,超出 signed char 范围 [-128, 127]
// 150 = 10010110,按补码解释为 -106

溢出检测

c
// 检测补码加法溢出
int tadd_ok(int x, int y) {
    int sum = x + y;
    int neg_over = (x < 0) && (y < 0) && (sum >= 0);  // 负+负=正,溢出
    int pos_over = (x > 0) && (y > 0) && (sum < 0);   // 正+正=负,溢出
    return !neg_over && !pos_over;
}

注意:在 C 语言中,有符号整数溢出是未定义行为(Undefined Behavior),编译器可能会利用这一点进行优化。因此,不能依赖溢出后的具体结果。

未定义行为(UB)是 C/C++ 标准中最容易被忽视但又最危险的概念之一。当程序触发未定义行为时,编译器可以做任何事情:程序可能崩溃、可能产生错误的结果、可能看似正常工作但在不同编译器或优化级别下表现不同,甚至可能删除整段代码。

c
// 未定义行为的危险示例
int overflow_example(int x) {
    int y = x + 1;  // 如果 x == INT_MAX,这是未定义行为
    if (y < x) {     // 编译器可能假设溢出不会发生
        return -1;   // 因此这个分支可能被优化掉!
    }
    return y;
}

// 在 -O2 优化下,上述函数可能被优化为:
int overflow_example_optimized(int x) {
    return x + 1;  // 编译器直接删除了溢出检查
}

// 安全的做法:使用无符号整数(溢出是定义良好的)
unsigned int safe_increment(unsigned int x) {
    unsigned int y = x + 1;  // 无符号溢出:y = (x + 1) mod 2^32
    if (y < x) {             // 这个检查是有效的
        // 处理溢出
    }
    return y;
}

2.4.3 整数乘法

整数乘法同样存在溢出问题。对于 w 位乘法,结果截断为低 w 位:

c
// 无符号乘法截断
unsigned char a = 20;   // 00010100
unsigned char b = 15;   // 00001111
unsigned char c = a * b; // 20 * 15 = 300,截断为 300 mod 256 = 44

编译器经常用移位和加法来替代乘法,因为移位操作比乘法快得多:

c
// x * 14 等价于 x * (16 - 2) = (x << 4) - (x << 1)
int multiply_by_14(int x) {
    return (x << 4) - (x << 1);
}

2.4.4 整数除法

C 语言中的整数除法是向零取整的:

c
int a = 7 / 2;    // 3(向零取整)
int b = -7 / 2;   // -3(向零取整,不是 -4)
int c = 7 / -2;   // -3
int d = -7 / -2;  // 3

取模运算 % 的结果符号与被除数相同:

c
int a = 7 % 2;    // 1
int b = -7 % 2;   // -1
int c = 7 % -2;   // 1

2.5 浮点数表示(IEEE 754 标准)

2.5.1 浮点数的编码

IEEE 754 标准定义了浮点数的表示方式。一个浮点数由三部分组成:

V = (-1)^s × M × 2^E
  • s(符号位):1 位,0 表示正数,1 表示负数
  • M(尾数/有效数字):一个二进制小数,范围在 [1.0, 2.0) 或 [0.0, 1.0)
  • E(阶码/指数):一个有偏整数

2.5.2 单精度浮点数(32 位)

位域位数说明
符号位 s1最高位
阶码 exp8有偏表示,偏置值 127
尾数 frac23隐含前导 1

实际阶码 E = exp - 127(规格化数)

2.5.3 双精度浮点数(64 位)

位域位数说明
符号位 s1最高位
阶码 exp11有偏表示,偏置值 1023
尾数 frac52隐含前导 1

2.5.4 三种数值情况

情况一:规格化值(Normalized)

  • exp 不全为 0 也不全为 1(1 ≤ exp ≤ 254 对于单精度)
  • M = 1.f(隐含前导 1)
  • E = exp - Bias

情况二:非规格化值(Denormalized)

  • exp 全为 0
  • M = 0.f(隐含前导 0,没有隐含的 1)
  • E = 1 - Bias(不是 -Bias!这是为了平滑过渡)
  • 用于表示非常接近零的数

情况三:特殊值

  • exp 全为 1,frac 全为 0 → ±∞(正负无穷)
  • exp 全为 1,frac 不全为 0 → NaN(Not a Number)

2.5.5 浮点数精度问题

浮点数不能精确表示所有实数。例如:

c
// 0.1 的二进制表示是无限循环小数
// 0.1 = 0.0001100110011001100110011...(二进制)
// 单精度只能存储前 24 位有效数字

float a = 0.1f;
printf("%.20f\n", a);  // 0.10000000149011611938

double b = 0.1;
printf("%.20f\n", b);  // 0.10000000000000000555

这就是为什么 0.1 + 0.2 != 0.3

c
double sum = 0.1 + 0.2;
printf("%.20f\n", sum);  // 0.30000000000000004441
printf("%d\n", sum == 0.3);  // 0(不相等)

正确的浮点数比较方式

c
#include <math.h>
#define EPSILON 1e-9

int double_equal(double a, double b) {
    return fabs(a - b) < EPSILON;
}

// 使用
if (double_equal(0.1 + 0.2, 0.3)) {
    printf("相等\n");
}

2.5.6 浮点数舍入

IEEE 754 定义了四种舍入模式:

向偶数舍入(Round to Even):默认模式。当结果恰好在两个可表示值中间时,舍入到最低有效位为偶数的那个

向零舍入(Round toward Zero):截断

向下舍入(Round toward -∞):地板函数

向上舍入(Round toward +∞):天花板函数

向偶数舍入的例子:

1.40 → 1  (向下)
1.60 → 2  (向上)
1.50 → 2  (向偶数)
2.50 → 2  (向偶数)
3.50 → 4  (向偶数)

这种舍入方式在统计上无偏,不会系统性地偏大或偏小。

2.6 浮点数运算

2.6.1 浮点数加法

浮点数加法不满足结合律:

c
double a = 1e20, b = -1e20, c = 1.0;
printf("%.1f\n", (a + b) + c);  // 1.0
printf("%.1f\n", a + (b + c));  // 0.0(b+c 中 1.0 被吞掉了)

浮点数加法也不满足分配律:

c
double a = 1e20, b = 1e20, c = 1.0;
// (a + b) * c != a * c + b * c 在某些情况下

2.6.2 浮点数乘法

浮点数乘法满足交换律,但不满足结合律:

c
// 大数乘小数可能丢失精度
double a = 1e300;
double b = 1e-300;
double c = a * b;  // 1.0,正确

// 但连续乘法可能溢出
double d = 1e200 * 1e200;  // inf(溢出)

2.7 IEEE 754 编码详解示例

为了真正理解浮点数的内部表示,让我们手动计算几个例子。

示例一:将 5.0 编码为单精度浮点数

将 5.0 转换为二进制:5 = 101.0(二进制)

规格化:101.0 = 1.01 × 2^2

符号位 s = 0(正数)

阶码 E = 2,有偏阶码 exp = E + 127 = 129 = 10000001(二进制)

尾数 frac = 01000000000000000000000(23位,隐含前导1)

最终编码:0 10000001 01000000000000000000000

十六进制:0x40A00000

示例二:将 -0.75 编码为单精度浮点数

符号位 s = 1(负数)

绝对值 0.75 = 0.11(二进制)= 1.1 × 2^(-1)

阶码 E = -1,有偏阶码 exp = -1 + 127 = 126 = 01111110(二进制)

尾数 frac = 10000000000000000000000(23位)

最终编码:1 01111110 10000000000000000000000

十六进制:0xBF400000

示例三:将 0x40490FDB 解码为浮点数

二进制:0 10000000 10010001111110110111

符号位 s = 0(正数)

阶码 exp = 10000000 = 128,E = 128 - 127 = 1

尾数 M = 1.10010001111110110111(隐含前导1)

值 = 1.10010001111110110111 × 2^1 = 11.0010001111110110111

十进制 ≈ 3.1415927(π 的近似值!)

这就是为什么 float pi = 3.14159265358979 实际上存储的是 3.1415927——单精度浮点数只有约 7 位有效十进制数字。

2.8 非规格化值的意义

非规格化数(denormalized numbers)的存在是为了填补零和最小规格化数之间的"空隙"。如果没有非规格化数,从 0 到最小规格化数之间会有一个突然的跳跃,这会导致"渐进下溢"(gradual underflow)问题。

规格化数的范围:
最小正规格化数(单精度)= 2^(-126) ≈ 1.175 × 10^(-38)
最大正规格化数(单精度)= (2 - 2^(-23)) × 2^127 ≈ 3.402 × 10^38

非规格化数的范围:
最小正非规格化数(单精度)= 2^(-23) × 2^(-126) = 2^(-149) ≈ 1.401 × 10^(-45)
最大非规格化数(单精度)= (1 - 2^(-23)) × 2^(-126) ≈ 1.175 × 10^(-38)

非规格化数的关键特性是:它们的间距是均匀的(都是 2^(-149)),而规格化数的间距随阶码增大而增大。这种设计保证了当计算结果逐渐趋近于零时,精度是逐渐降低的,而不是突然丢失。

2.9 NaN 的类型与应用

NaN(Not a Number)分为两种:

  • 安静 NaN(Quiet NaN, QNaN):参与运算时不引发异常,结果继续传播 NaN
  • 信号 NaN(Signaling NaN, SNaN):参与运算时会引发异常
c
#include <math.h>

// 产生 NaN 的常见方式
double nan1 = 0.0 / 0.0;      // 0/0
double nan2 = sqrt(-1.0);     // 负数开方
double nan3 = 0.0 * INFINITY; // 0 × ∞
double nan4 = INFINITY - INFINITY; // ∞ - ∞

// NaN 的特性
printf("%d\n", nan1 == nan1);    // 0!NaN 不等于自身
printf("%d\n", nan1 != nan1);    // 1!这是检测 NaN 的方法
printf("%d\n", isnan(nan1));     // 1(使用 math.h 的 isnan 函数)

NaN 不等于自身的特性看似违反直觉,但在实际中非常有用:它可以用来标记"无效"或"缺失"的数据值。在科学计算中,如果某个计算步骤产生了 NaN,这个 NaN 会像"病毒"一样传播到后续所有计算中,最终在输出中显现出来,帮助程序员定位问题。


三、重要知识点

3.1 位运算的应用

位运算是底层编程的基础技能:

c
// 获取第 k 位
int get_bit(int x, int k) {
    return (x >> k) & 1;
}

// 设置第 k 位为 1
int set_bit(int x, int k) {
    return x | (1 << k);
}

// 清除第 k 位
int clear_bit(int x, int k) {
    return x & ~(1 << k);
}

// 翻转第 k 位
int toggle_bit(int x, int k) {
    return x ^ (1 << k);
}

// 交换两个变量(不使用临时变量)
void swap(int *a, int *b) {
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

// 计算一个整数中 1 的个数(Brian Kernighan 算法)
int count_bits(int x) {
    int count = 0;
    while (x) {
        x &= (x - 1);  // 清除最低位的 1
        count++;
    }
    return count;
}

3.2 类型转换规则

C 语言的类型转换遵循"整数提升"和"通常算术转换"规则:

c
// 整数提升:char 和 short 在运算前自动提升为 int
char a = 10, b = 20;
int c = a + b;  // a 和 b 先提升为 int,再相加

// 通常算术转换:不同类型运算时,小的向大的转换
int i = -1;
unsigned int u = 1;
// i 被转换为 unsigned int,变成 4294967295

// 转换优先级(从低到高):
// int → unsigned int → long → unsigned long → long long → unsigned long long → float → double → long double

3.3 扩展与截断

零扩展:无符号数从小类型转换为大类型时,高位补 0。

符号扩展:有符号数从小类型转换为大类型时,高位复制符号位。

c
// 符号扩展
short x = -5;      // 16位: 1111111111111011
int y = x;          // 32位: 11111111111111111111111111111011

// 零扩展
unsigned short a = 0xFFFB;  // 16位: 1111111111111011
unsigned int b = a;          // 32位: 00000000000000001111111111111011

截断:大类型转换为小类型时,直接丢弃高位字节。

c
int x = 0x12345678;
short y = (short)x;  // y = 0x5678

3.4 浮点数与整数的相互转换

c
// 整数转浮点数:可能丢失精度
int i = 1234567890;
float f = (float)i;
// f 可能不等于 i,因为 float 只有 24 位有效数字

// 浮点数转整数:截断小数部分
float x = 3.99;
int n = (int)x;  // n = 3(向零取整)

// 超出整数范围的浮点数转换是未定义行为
float huge = 1e20f;
int bad = (int)huge;  // 未定义行为!

四、常见误区与难点

4.1 误区一:"负数的补码就是原码取反加1"

这个说法只对了一半。"取反加1"是求补码的操作方法,但补码的含义是:最高位的权重是 -2^(w-1)。理解这个含义比记住操作方法更重要。

4.2 误区二:"浮点数运算是精确的"

浮点数运算存在固有的精度限制。在金融计算、科学计算等需要精确结果的场景中,应使用定点数或任意精度库(如 GMP),而不是浮点数。

4.3 误区三:"unsigned 和 signed 混合运算没问题"

这是 C 语言中最危险的陷阱之一。当 unsignedsigned 混合运算时,signed 会被隐式转换为 unsigned,负数会变成很大的正数。

c
// 经典 bug
#define MAX_LEN 256
void copy(char *dest, char *src, int len) {
    if (len > MAX_LEN) {  // 如果 len 是负数,会被转为 unsigned,条件为真
        return;
    }
    // ...
}

4.4 误区四:"sizeof 总是返回正确的大小"

sizeof 运算符在编译时计算,它返回的是类型的大小,而不是运行时数据的实际大小。对于数组参数,sizeof 返回的是指针的大小而非数组的大小:

c
void print_size(int arr[]) {
    // 错误!arr 退化为指针,sizeof(arr) 返回 8(指针大小)
    printf("%zu\n", sizeof(arr));  // 输出 8,不是数组大小

    // 正确做法:显式传递数组大小
}

void print_size_correct(int arr[], size_t n) {
    printf("数组有 %zu 个元素\n", n);
}

4.5 难点:理解浮点数的精度与舍入

浮点数的精度问题不仅仅是"不够精确"那么简单。更深层的问题在于:浮点数的误差是相对的,而不是绝对的。对于很大的数,相邻两个可表示的浮点数之间的差距也很大;对于很小的数,差距则很小。

c
// 相对精度示例
float small = 1.0f;
float large = 1e8f;

// 在 small 附近,相邻浮点数的差距约为 2^(-23) ≈ 1.19e-7
// 在 large 附近,相邻浮点数的差距约为 2^(23-23) * 2^3 ≈ 8.0
// 这意味着 large + 1.0f 可能等于 large!

printf("%.1f\n", large + 1.0f);  // 可能输出 100000000.0(1.0 被吞掉了)
printf("%.1f\n", large + 8.0f);  // 可能输出 100000008.0(8.0 刚好能表示)

这种相对精度的特性意味着:在处理不同数量级的数据时,需要选择不同的容差值。绝对容差(如 epsilon = 1e-6)只适用于数据范围已知的情况;对于通用场景,应使用相对容差。

4.4 难点:理解浮点数的精度

理解浮点数精度需要掌握以下概念:

  • 有效数字位数(单精度约 7 位十进制,双精度约 15-16 位十进制)
  • 舍入误差的累积
  • 大数吃小数现象
  • 灾难性抵消(Catastrophic Cancellation)
c
// 灾难性抵消示例
double x = 1.0000001;
double y = 1.0000000;
double diff = x - y;  // 0.00000010000000000827...
// 两个接近的数相减,有效数字大幅丢失

五、实践应用

5.1 位掩码(Bit Mask)的应用

c
// 权限控制(类 Unix 文件权限)
#define READ    0x4  // 100
#define WRITE   0x2  // 010
#define EXECUTE 0x1  // 001

int permissions = READ | WRITE;  // 110 = 6
if (permissions & READ) { /* 有读权限 */ }
permissions &= ~WRITE;       // 移除写权限
permissions |= EXECUTE;      // 添加执行权限

// 颜色处理
typedef struct {
    unsigned int r : 8;
    unsigned int g : 8;
    unsigned int b : 8;
    unsigned int a : 8;
} Color;

// 从 32 位整数中提取颜色分量
unsigned int pixel = 0xAABBGGRR;
unsigned char r = pixel & 0xFF;
unsigned char g = (pixel >> 8) & 0xFF;
unsigned char b = (pixel >> 16) & 0xFF;
unsigned char a = (pixel >> 24) & 0xFF;

5.2 浮点数比较的正确做法

c
// 相对误差比较
int double_equal_relative(double a, double b, double rel_tol) {
    return fabs(a - b) <= rel_tol * fmax(fabs(a), fabs(b));
}

// 使用
if (double_equal_relative(0.1 + 0.2, 0.3, 1e-9)) {
    printf("相等\n");
}

5.3 利用位运算优化

c
// 判断是否为 2 的幂
int is_power_of_2(int x) {
    return x > 0 && (x & (x - 1)) == 0;
}

// 向上对齐到 2 的幂
int align_up(int x, int alignment) {
    // alignment 必须是 2 的幂
    return (x + alignment - 1) & ~(alignment - 1);
}

// 计算绝对值(无分支)
int abs_no_branch(int x) {
    int mask = x >> 31;  // 如果 x < 0,mask = -1(全1);否则 mask = 0
    return (x + mask) ^ mask;
}

5.4 字符串编码的实际应用

理解字符编码对于处理文本数据至关重要。ASCII 编码只使用 7 位(0-127),可以表示英文字母、数字和基本符号。但对于中文、日文等非拉丁字符,需要更宽的编码。

c
// ASCII 编码
char ascii_char = 'A';  // 0x41 = 65

// UTF-8 编码(变长编码)
// 英文字符:1 字节(0xxxxxxx)
// 中文字符:3 字节(1110xxxx 10xxxxxx 10xxxxxx)
// 例如 "中" 的 UTF-8 编码是 0xE4 0xB8 0xAD

// 在 C 语言中处理 UTF-8
const char *utf8_str = "你好";
// 实际存储为 6 个字节:0xE4 0xBD 0xA0 0xE5 0xA5 0xBD

// 宽字符(UTF-32)
#include <wchar.h>
wchar_t wide_char = L'中';  // 4 字节,直接存储 Unicode 码点

UTF-8 是目前互联网上最通用的字符编码。它的优势在于:向后兼容 ASCII(ASCII 字符在 UTF-8 中编码不变),变长编码节省空间(英文 1 字节,中文 3 字节),自同步(可以从任意字节判断字符边界)。

5.5 整数溢出检测的工程实践

在实际工程中,整数溢出可能导致安全漏洞。以下是一个完整的溢出检测工具集:

c
#include <limits.h>
#include <stdint.h>

// 安全加法:检测溢出,返回 0 表示成功,-1 表示溢出
int safe_add_int(int a, int b, int *result) {
    if (b > 0 && a > INT_MAX - b) return -1;  // 正溢出
    if (b < 0 && a < INT_MIN - b) return -1;  // 负溢出
    *result = a + b;
    return 0;
}

// 安全乘法
int safe_mul_int(int a, int b, int *result) {
    if (a > 0 && b > 0 && a > INT_MAX / b) return -1;
    if (a > 0 && b < 0 && b < INT_MIN / a) return -1;
    if (a < 0 && b > 0 && a < INT_MIN / b) return -1;
    if (a < 0 && b < 0 && a < INT_MAX / b) return -1;
    *result = a * b;
    return 0;
}

// 使用 GCC 内置函数(更简洁)
int safe_add_builtin(int a, int b, int *result) {
    return __builtin_add_overflow(a, b, result);
}

int safe_mul_builtin(int a, int b, int *result) {
    return __builtin_mul_overflow(a, b, result);
}

5.6 浮点数在科学计算中的注意事项

在科学计算中,浮点数的精度问题可能导致灾难性的后果。以下是一些重要的实践原则:

c
// 原则1:避免大数吃小数
// 差:先加很小的数再加很大的数
double sum = 1e20;
sum += 1.0;   // 1.0 被吞掉!
sum += 1.0;   // 又被吞掉!
// sum 仍然是 1e20

// 好:先加小数,再加到大数上
double small_sum = 0;
for (int i = 0; i < N; i++) {
    small_sum += small_values[i];  // 先累加小数
}
double total = large_value + small_sum;  // 最后再加大数

// 原则2:使用 Kahan 求和算法减少累积误差
double kahan_sum(double *arr, int n) {
    double sum = 0.0;
    double c = 0.0;  // 补偿值
    for (int i = 0; i < n; i++) {
        double y = arr[i] - c;
        double t = sum + y;
        c = (t - sum) - y;  // 保留丢失的低位
        sum = t;
    }
    return sum;
}

// 原则3:避免灾难性抵消
// 当两个接近的数相减时,有效数字大幅丢失
// 差:直接计算 sqrt(x+1) - sqrt(x)
// 好:有理化后计算 1 / (sqrt(x+1) + sqrt(x))
double safe_diff_sqrt(double x) {
    return 1.0 / (sqrt(x + 1.0) + sqrt(x));
}

5.7 数据序列化与反序列化

在网络通信和文件存储中,数据的字节序和编码格式至关重要:

c
#include <arpa/inet.h>

// 网络字节序转换
uint32_t host_val = 0x12345678;
uint32_t net_val = htonl(host_val);  // 主机字节序 → 网络字节序(大端)
uint32_t back = ntohl(net_val);      // 网络字节序 → 主机字节序

// 手动打包/解包二进制数据
void pack_int32(uint8_t *buf, uint32_t val) {
    buf[0] = (val >> 24) & 0xFF;
    buf[1] = (val >> 16) & 0xFF;
    buf[2] = (val >> 8) & 0xFF;
    buf[3] = val & 0xFF;
}

uint32_t unpack_int32(const uint8_t *buf) {
    return ((uint32_t)buf[0] << 24) |
           ((uint32_t)buf[1] << 16) |
           ((uint32_t)buf[2] << 8) |
           (uint32_t)buf[3];
}

5.8 使用 printf 探索数据表示

printf 的格式化输出是理解数据表示的利器:

c
int x = 0xDEADBEEF;

// 不同进制输出
printf("Hex: 0x%X\n", x);      // Hex: 0xDEADBEEF
printf("Oct: 0%o\n", x);       // 八进制
printf("Dec: %u\n", (unsigned)x); // 无符号十进制
printf("Dec: %d\n", x);         // 有符号十进制(负数)

// 查看浮点数的内部表示
double d = 3.14;
uint64_t bits;
memcpy(&bits, &d, sizeof(bits));
printf("IEEE 754 bits: 0x%016lX\n", bits);
// 输出: 0x40091EB851EB851F
// 分解: 符号=0, 阶码=0x400=1024, E=1024-1023=1
//       尾数=1.91EB851EB851F(十六进制)

// 打印二进制表示
void print_binary(unsigned int val) {
    for (int i = 31; i >= 0; i--) {
        printf("%d", (val >> i) & 1);
        if (i % 4 == 0) printf(" ");
    }
    printf("\n");
}
print_binary(0xDEADBEEF);
// 1101 1110 1010 1101 1011 1111 1110 1111

六、本章小结

第2章深入讲解了计算机中信息的表示和处理方式:

十六进制是程序员与二进制世界沟通的桥梁,熟练掌握进制转换是基本功。

整数表示:无符号整数和补码有符号整数有不同的表示范围和运算规则,混合运算时隐式类型转换是常见 bug 的来源。

整数运算:溢出截断是定义良好的行为(无符号),但有符号溢出是未定义行为。

浮点数表示:IEEE 754 标准通过规格化、非规格化和特殊值三种情况,覆盖了所有可能的浮点数值。

浮点数精度:浮点数运算不满足结合律和分配律,在精度敏感的场景中需要特别小心。

类型转换:理解隐式转换规则对于避免 bug 至关重要。

字符编码:从 ASCII 到 UTF-8,理解编码方式对于处理文本数据至关重要。

工程实践:溢出检测、Kahan 求和、字节序转换等技巧在实际开发中不可或缺。

核心要点:计算机中的信息表示是有限的,理解这些限制是编写正确、高效程序的基础。无论是简单的整数加法还是复杂的科学计算,都需要时刻牢记数据表示的局限性,才能在工程中避免潜在的陷阱。