第2章:信息的表示和处理(Representing and Manipulating Information)
一、导读
1.1 本章学习目标
第2章深入探讨计算机如何表示和处理信息。这是整个课程的基石——如果说不理解第1章只是"视野不够开阔",那么不理解第2章则会导致在实际编程中犯下严重的、难以调试的错误。
本章的核心学习目标包括:
- 理解进制转换:十六进制、二进制、十进制之间的转换及其在编程中的应用
- 掌握整数表示:无符号整数和补码有符号整数的表示范围、运算规则
- 理解整数运算:加法、乘法的溢出行为,以及编译器如何利用这些特性进行优化
- 掌握浮点数表示:IEEE 754 标准的原理、精度限制和特殊值
- 理解类型转换:C 语言中隐式和显式类型转换的底层机制
- 理解字节序:大端序和小端序的区别及其在网络编程中的影响
1.2 为什么这一章至关重要
信息表示是计算机系统最底层的基础。无论是编写嵌入式系统、开发网络协议、进行科学计算还是从事安全研究,对信息表示的深入理解都是必不可少的。许多看似"诡异"的编程 bug 都源于对信息表示的误解:
// 经典 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 个二进制位:
| 十六进制 | 二进制 | 十进制 |
|---|---|---|
| 0 | 0000 | 0 |
| 1 | 0001 | 1 |
| ... | ... | ... |
| 9 | 1001 | 9 |
| A | 1010 | 10 |
| B | 1011 | 11 |
| C | 1100 | 12 |
| D | 1101 | 13 |
| E | 1110 | 14 |
| F | 1111 | 15 |
在 C 语言中,十六进制常量以 0x 或 0X 开头:
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:
| 地址 | 小端序 | 大端序 |
|---|---|---|
| 1000 | 0x67 | 0x01 |
| 1001 | 0x45 | 0x23 |
| 1002 | 0x23 | 0x45 |
| 1003 | 0x01 | 0x67 |
// 检测当前机器的字节序
int check_endian() {
int x = 1;
return *(char *)&x; // 返回 1 表示小端序,返回 0 表示大端序
}字节序在网络编程中非常重要。TCP/IP 协议规定使用大端序(网络字节序),在发送数据前需要使用 htonl()、htons() 等函数进行转换。
字节序问题不仅出现在网络编程中,在以下场景中也需要特别注意:
- 文件格式:某些二进制文件格式(如 BMP 图片)使用特定的字节序,读取时需要正确处理
- 跨平台数据交换:在不同架构的机器之间传递二进制数据时,需要统一字节序
- 安全研究:在逆向工程中,理解字节序对于正确解析二进制数据至关重要
- 内存调试:使用十六进制查看器观察内存时,需要知道当前平台的字节序才能正确解读数据
// 跨平台字节序转换的标准函数
#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"
// 补码取反示例
int x = 5; // 0000...0101
int neg_x = -x; // 1111...1011
// 等价于 ~x + 1
// ~5 = 1111...1010
// ~5 + 1 = 1111...1011 = -52.3.3 有符号与无符号的转换
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 来源。当 int 和 unsigned int 混合运算时,int 会被转换为 unsigned int,负数会变成很大的正数。
2.4 整数运算
2.4.1 无符号加法
w 位无符号加法的数学结果是两个操作数之和,但如果结果超出 w 位能表示的范围,就会发生溢出截断:
UAdd_w(x, y) = (x + y) mod 2^wunsigned char a = 200; // 11001000
unsigned char b = 100; // 01100100
unsigned char c = a + b; // 200 + 100 = 300,截断为 300 mod 256 = 44
// c = 00101100 = 442.4.2 补码加法
补码加法在位级别上与无符号加法完全相同,只是溢出后的解释不同:
TAdd_w(x, y) = U2T(UAdd_w(x, y))即先将 x 和 y 按位相加(与无符号加法相同),然后将结果按补码解释。
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溢出检测:
// 检测补码加法溢出
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++ 标准中最容易被忽视但又最危险的概念之一。当程序触发未定义行为时,编译器可以做任何事情:程序可能崩溃、可能产生错误的结果、可能看似正常工作但在不同编译器或优化级别下表现不同,甚至可能删除整段代码。
// 未定义行为的危险示例
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 位:
// 无符号乘法截断
unsigned char a = 20; // 00010100
unsigned char b = 15; // 00001111
unsigned char c = a * b; // 20 * 15 = 300,截断为 300 mod 256 = 44编译器经常用移位和加法来替代乘法,因为移位操作比乘法快得多:
// x * 14 等价于 x * (16 - 2) = (x << 4) - (x << 1)
int multiply_by_14(int x) {
return (x << 4) - (x << 1);
}2.4.4 整数除法
C 语言中的整数除法是向零取整的:
int a = 7 / 2; // 3(向零取整)
int b = -7 / 2; // -3(向零取整,不是 -4)
int c = 7 / -2; // -3
int d = -7 / -2; // 3取模运算 % 的结果符号与被除数相同:
int a = 7 % 2; // 1
int b = -7 % 2; // -1
int c = 7 % -2; // 12.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 位)
| 位域 | 位数 | 说明 |
|---|---|---|
| 符号位 s | 1 | 最高位 |
| 阶码 exp | 8 | 有偏表示,偏置值 127 |
| 尾数 frac | 23 | 隐含前导 1 |
实际阶码 E = exp - 127(规格化数)
2.5.3 双精度浮点数(64 位)
| 位域 | 位数 | 说明 |
|---|---|---|
| 符号位 s | 1 | 最高位 |
| 阶码 exp | 11 | 有偏表示,偏置值 1023 |
| 尾数 frac | 52 | 隐含前导 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 浮点数精度问题
浮点数不能精确表示所有实数。例如:
// 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:
double sum = 0.1 + 0.2;
printf("%.20f\n", sum); // 0.30000000000000004441
printf("%d\n", sum == 0.3); // 0(不相等)正确的浮点数比较方式:
#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 浮点数加法
浮点数加法不满足结合律:
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 被吞掉了)浮点数加法也不满足分配律:
double a = 1e20, b = 1e20, c = 1.0;
// (a + b) * c != a * c + b * c 在某些情况下2.6.2 浮点数乘法
浮点数乘法满足交换律,但不满足结合律:
// 大数乘小数可能丢失精度
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):参与运算时会引发异常
#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 位运算的应用
位运算是底层编程的基础技能:
// 获取第 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 语言的类型转换遵循"整数提升"和"通常算术转换"规则:
// 整数提升: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 double3.3 扩展与截断
零扩展:无符号数从小类型转换为大类型时,高位补 0。
符号扩展:有符号数从小类型转换为大类型时,高位复制符号位。
// 符号扩展
short x = -5; // 16位: 1111111111111011
int y = x; // 32位: 11111111111111111111111111111011
// 零扩展
unsigned short a = 0xFFFB; // 16位: 1111111111111011
unsigned int b = a; // 32位: 00000000000000001111111111111011截断:大类型转换为小类型时,直接丢弃高位字节。
int x = 0x12345678;
short y = (short)x; // y = 0x56783.4 浮点数与整数的相互转换
// 整数转浮点数:可能丢失精度
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 语言中最危险的陷阱之一。当 unsigned 和 signed 混合运算时,signed 会被隐式转换为 unsigned,负数会变成很大的正数。
// 经典 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 返回的是指针的大小而非数组的大小:
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 难点:理解浮点数的精度与舍入
浮点数的精度问题不仅仅是"不够精确"那么简单。更深层的问题在于:浮点数的误差是相对的,而不是绝对的。对于很大的数,相邻两个可表示的浮点数之间的差距也很大;对于很小的数,差距则很小。
// 相对精度示例
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)
// 灾难性抵消示例
double x = 1.0000001;
double y = 1.0000000;
double diff = x - y; // 0.00000010000000000827...
// 两个接近的数相减,有效数字大幅丢失五、实践应用
5.1 位掩码(Bit Mask)的应用
// 权限控制(类 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 浮点数比较的正确做法
// 相对误差比较
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 利用位运算优化
// 判断是否为 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),可以表示英文字母、数字和基本符号。但对于中文、日文等非拉丁字符,需要更宽的编码。
// 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 整数溢出检测的工程实践
在实际工程中,整数溢出可能导致安全漏洞。以下是一个完整的溢出检测工具集:
#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 浮点数在科学计算中的注意事项
在科学计算中,浮点数的精度问题可能导致灾难性的后果。以下是一些重要的实践原则:
// 原则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 数据序列化与反序列化
在网络通信和文件存储中,数据的字节序和编码格式至关重要:
#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 的格式化输出是理解数据表示的利器:
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 求和、字节序转换等技巧在实际开发中不可或缺。
核心要点:计算机中的信息表示是有限的,理解这些限制是编写正确、高效程序的基础。无论是简单的整数加法还是复杂的科学计算,都需要时刻牢记数据表示的局限性,才能在工程中避免潜在的陷阱。