第1章 数据编码——计算机的符文系统
元数据卡
| 项目 | 内容 |
|---|---|
| 难度 | (入门) |
| 前置 | 无 |
| 关键词 | 比特、字节、进制、ASCII、Unicode、UTF-8、字节序、位运算 |
| C 标准 | C99+ |
| 代码量 | ~60 行 |
你在哪
"从算法森林的地面往下挖,你钻进了一个巨大的洞窟。这里没有编程语言的魔法——没有垃圾回收、没有自动内存管理、没有类型系统。只有最原始的东西:高电压(1)和低电压(0)。你站在计算机的地心入口。"
上一卷(Vol 2)我们用 Python/Java 搭了一层又一层的抽象——类、接口、泛型、垃圾回收——像是站在编程语言的高楼里俯瞰大地。现在,我们要拆掉地板,钻进地心。
这栋楼的地基,其实只有两种电压:高(~3.3V/5V) 和 低(~0V) 。CPU 里的晶体管靠识别这两种电压工作,把「有电」「没电」翻译成 1 和 0。这 1 和 0,就是计算机一切故事的起点。
这一章,我们做一件事:搞懂信息在机器里到底是怎么存的。
你的任务
本章分层
- 必读:比特与字节的本质关系;进制互转;ASCII / Unicode / UTF-8 三者的区别
- 选读:字节序(大端 vs 小端)的判断与应用场景;六种基本位运算的 C 实现
- 深水区:算术右移 vs 逻辑右移的区别与陷阱;UTF-16 代理对与 Java char 的关系 本章不会要求你掌握
- 硬件级的位操作指令(x86 BTS/BTR 等)
- 内存屏障与 volatile 对位操作的影响
- 完整的 Unicode 标准文本规范化算法
学完本章,你应当能做到:
- 手算任意十进制 ↔ 二进制/八进制/十六进制互转
- 理解 ASCII、Unicode、UTF-8 之间的关系,知道为何三种编码不能混为一谈
- 解释大端 vs 小端字节序,判断一个平台用哪种
- 用 C 熟练写出六种基本位运算的实用代码
遭遇战 → 获得技能
第1战:比特与字节——最小信息单元
问题: 你在洞壁上发现了一排发光的晶石——有的亮(1)、有的灭(0)。你要把这些闪烁的编码通过地下暗河(总线)传送到引擎室(CPU),让另一端也看到完全一样的闪烁模式。这中间发生了什么?
最底层的回答很简单:比特(bit) 沿着链路传输,设备按照约定好的规则把比特流还原成有意义的信息。
一个比特只能表示两种状态:0 或 1。但八个比特拼在一起,就变成了一个 字节(byte):
1 byte = 8 bits
0 0 0 0 0 0 0 0 → 最小:0
1 1 1 1 1 1 1 1 → 最大:2552⁸ = 256,一个字节可以编码 0-255 共 256 个值。凭这个,就能存下一个英文字母或一个简单的数字。
关键直觉: 计算机从不关心你写的是 "hello" 还是 "你好"。它只看见高高低低的电压。编码是人类和机器之间的契约——我告诉你这些比特代表什么,你告诉机器该怎样解释它们。
💡 C 示例前置说明:本章的 C 代码主要用来演示概念,并非生产级的系统编程。你不需要会写 C 才能理解编码概念——用你熟悉的语言(Python / Java)也能观察类似行为。
在 C 语言中查看任何数据的「比特真身」很简单——拿 unsigned char* 去遍历每个字节,打印出十六进制或二进制值。这个技巧在调试网络协议、文件格式解析时几乎每天都在用。
第2战:进制转换——被忽视的看家本领
问题: 颜色 #FF8040 是个橙红色。FF、80、40 分别代表红、绿、蓝分量。这三种写法为什么出现?怎么读懂它们?
十六进制 FF = 十进制 255。为什么程序员偏爱十六进制?因为它和二进制之间的转换异常整齐——一个十六进制位对应四个二进制位:
二进制: 1111 1111
十六进制: F F转换口诀:
| 二进制 | 十六进制 | 二进制 | 十六进制 |
|---|---|---|---|
| 0000 | 0 | 1000 | 8 |
| 0001 | 1 | 1001 | 9 |
| 0010 | 2 | 1010 | A |
| 0011 | 3 | 1011 | B |
| 0100 | 4 | 1100 | C |
| 0101 | 5 | 1101 | D |
| 0110 | 6 | 1110 | E |
| 0111 | 7 | 1111 | F |
快速心算法——分组即可:
0x3A→3=0011,A=1010→00111010。看,一行就写完了。
十进制转二进制(除2取余法):
/* 把十进制转成二进制字符串,验证你的手算结果 */
#include <stdio.h>
#include <string.h>
void dec_to_bin(unsigned int n, char *out, int len) {
int i = 0;
if (n == 0) { out[0] = '0'; out[1] = '\0'; return; }
while (n > 0 && i < len - 1) {
out[i++] = (n % 2) + '0';
n /= 2;
}
out[i] = '\0';
/* 上面是反的,要反转一次 */
for (int j = 0; j < i / 2; j++) {
char t = out[j];
out[j] = out[i - 1 - j];
out[i - 1 - j] = t;
}
}
int main() {
char buf[33];
dec_to_bin(42, buf, sizeof(buf));
printf("42_dec = %s_bin\n", buf); /* 预期: 42_dec = 101010_bin */
return 0;
}在 C 语言中,用前缀区分进制:
int a = 42; /* 十进制 */
int b = 052; /* 八进制(0 开头)—— 5×8 + 2 = 42 */
int c = 0x2A; /* 十六进制(0x 开头)—— 2×16 + 10 = 42 */
/* C 没有原生二进制字面量(C23 才开始支持 0b 前缀) */
printf("%d, %d, %d\n", a, b, c); /* 42, 42, 42 */🐍 Python 差异: Python 原生支持所有进制的字面量和自带转换函数:
bin(42)→'0b101010',oct(42)→'0o52',hex(42)→'0x2a'。C 需要手写这类函数,但好处是你真正理解了底层机制。
** Java 差异:** Java 也有
Integer.toBinaryString(42)等工具方法。但 Java 的int始终是 32 位有符号(补码),而 C 的int宽度取决于平台和编译器——这在写跨平台代码时要注意。
第3战:字符编码——从 A 到 🎉
问题: 内存里放着一个字节 0x41。它是什么?字母 A,还是数字 65,还是某个指令的操作码?解释权在上下文。
早期计算机用 ASCII(American Standard Code for Information Interchange)把 0-127 映射为英文字母、数字和符号:
#include <stdio.h>
int main() {
/* ASCII 码表里 'A'=65, 'a'=97 */
char c = 65; /* 等价于 char c = 'A' */
printf("%c → %d (0x%x)\n", c, c, c);
printf("%c → %d (0x%x)\n", 'a', 'a', 'a');
return 0;
}
/* 输出:
A → 65 (0x41)
a → 97 (0x61)
*/ASCII 只用 7 位(0-127),最高位留作奇偶校验。这意味着它只能表示 128 个字符——足够写英文,但中文、日文、阿拉伯文一概不支持。
Unicode 登场: Unicode 的目标是把全世界所有字符纳入一张表。每个字符分配一个唯一的 码点(code point),如:
U+0041→AU+4E2D→中U+1F389→🎉(派对礼炮)
问题来了:U+1F389 超过了一个字节的范围。Unicode 只是字符到码点的映射表,不是存储编码。 UTF-8才是解决存储问题的方案。
UTF-8 的精妙设计:
| 码点范围 | UTF-8 编码(字节数) |
|---|---|
| U+0000 ~ U+007F | 0xxxxxxx(1 字节,兼容 ASCII) |
| U+0080 ~ U+07FF | 110xxxxx 10xxxxxx(2 字节) |
| U+0800 ~ U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx(3 字节) |
| U+10000 ~ U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx(4 字节) |
编码规则拆解(以「中」U+4E2D 为例):
U+4E2D落在U+0800 ~ U+FFFF,用 3 字节4E2D=0100 1110 0010 1101(16 位)- 填充模板:
1110xxxx 10xxxxxx 10xxxxxx - 从右向左填入 →
11100100 10111000 10101101 - 十六进制:
E4 B8 AD
为什么 UTF-8 这么流行? 因为它向后兼容 ASCII——所有 ASCII 文本也是合法的 UTF-8 文本。一个只含英文的文件用 ASCII 和 UTF-8 编码结果完全一样。
#include <stdio.h>
#include <string.h>
int main() {
/* 一个中文字在 C 中占多个 char */
char *s = "中";
unsigned char *p = (unsigned char *)s;
printf("「中」的 UTF-8 编码: ");
for (size_t i = 0; i < strlen(s); i++) {
printf("0x%02X ", p[i]);
}
printf("\n");
/* 输出: 「中」的 UTF-8 编码: 0xE4 0xB8 0xAD */
return 0;
}** Java 差异:** Java 的
char是 16 位的 UTF-16 编码单元——不是 UTF-8,也不是 Unicode 码点。对于 BMP(基本多语言平面,U+0000~U+FFFF)内的字符,一个char对一个码点;对于增补平面如🎉 (U+1F389),需要两个char(代理对)。这是 Java 早期设计的历史遗留,现在用codePointAt()等方法处理。
🐍 Python 差异: Python 3 的字符串是 Unicode 码点序列,内部用灵活表示(紧凑型使用 ASCII/Latin-1/UTF-16/UCS-4,对用户透明)。编码/解码通过
s.encode('utf-8')和b.decode('utf-8')显式完成,十分安全。C 没有原生字符串类型,全靠char数组和约定。
系统程序员旁路:字节序是系统编程(网络协议、二进制文件格式)中的核心概念,但如果你暂时只写应用层代码,可以跳过本节,回来再看。
第4战:字节序——端到端的排列哲学
问题: 一个 32 位整数 0x12345678 存进内存。它在内存里是 12 34 56 78 还是 78 56 34 12?
答案是:取决于 CPU 的字节序(endianness)。
- 大端(Big-Endian): 高位字节在低地址。地址从高到低依次是
12 34 56 78。 - 小端(Little-Endian): 低位字节在低地址。地址从高到低依次是
78 56 34 12。
为什么存在两种? 历史。大端像人写数字(先写高位),直观;小端方便 CPU 做算术运算(低地址对应低位,加法从低位开始不需要跨字节跳转)。x86/x86_64 用小端;网络协议(TCP/IP 首部)用大端——这就是 网络字节序 = 大端的由来。
#include <stdio.h>
int main() {
unsigned int x = 0x12345678;
unsigned char *p = (unsigned char *)&x;
printf("int %#010x 在内存中(逐字节,低地址到高地址):\n", x);
for (size_t i = 0; i < sizeof(x); i++) {
printf(" [%zu] = %#04x\n", i, p[i]);
}
if (p[0] == 0x78)
printf("→ 小端 (Little-Endian)\n");
else if (p[0] == 0x12)
printf("→ 大端 (Big-Endian)\n");
return 0;
}
/* 在 x86 上输出:
int 0x12345678 在内存中(逐字节,低地址到高地址):
[0] = 0x78
[1] = 0x56
[2] = 0x34
[3] = 0x12
→ 小端 (Little-Endian)
*/网络编程的雷区: 跨网络传输整型数据时,必须用
htonl()(host-to-network long)把本机字节序转成大端,接收方用ntohl()转回来。跳过这一步,不同架构的机器之间通信会得到完全错误的数据。
系统程序员旁路:位运算是 C 系统编程(设备驱动、操作系统内核)的核心技能。应用层开发者知道基本概念即可,不必深究掩码技巧。
调用,性能开销不小。但在 C 中,位运算符直接对应 CPU 的一条指令,编译后几乎零开销。不需要循环、不需要分支预测,一个时钟周期就能完成对 32 位或 64 位的并行操作。
如果你处理过嵌入式设备的寄存器,一定见过这种模式——32 位寄存器里每个位域代表不同的硬件功能(时钟源选择、中断使能、分频系数等),一条位运算就能精准修改想要的位,而不影响其他位。在操作系统内核中,调度器的就绪位图、中断控制器的掩码寄存器,全是用位运算操作的。
问题: 一个 32 位整数代表 32 个开关的状态。如何只读取第 3 个开关?设置第 7 个开关为 1?翻转第 5 个开关?
答案就是位运算(一次操作检查/设置所有位,比循环快一个数量级):
#include <stdio.h>
#include <stdint.h>
/* 标志位定义 */
#define FLAG_READ (1 << 0) /* 0b0001 */
#define FLAG_WRITE (1 << 1) /* 0b0010 */
#define FLAG_EXEC (1 << 2) /* 0b0100 */
#define FLAG_SYSTEM (1 << 3) /* 0b1000 */
int main() {
uint8_t flags = 0;
/* 设置 WRITE + EXEC 标志 */
flags |= FLAG_WRITE | FLAG_EXEC;
printf("设置后: %#04x\n", flags); /* 0x06 */
/* 检查 EXEC 是否设置 */
if (flags & FLAG_EXEC)
printf("EXEC 已设置\n");
/* 翻转 READ 标志 */
flags ^= FLAG_READ;
printf("翻转后: %#04x\n", flags); /* 0x07 */
/* 清除 WRITE 标志 */
flags &= ~FLAG_WRITE;
printf("清除后: %#04x\n", flags); /* 0x05 */
return 0;
}六种基本位运算:
| 操作符 | 名称 | 例子(32 位简化) | 作用 |
|---|---|---|---|
& | 按位与 | 0b1100 & 0b1010 = 0b1000 | 清零特定位 |
| ` | ` | 按位或 | `0b1100 |
^ | 按位异或 | 0b1100 ^ 0b1010 = 0b0110 | 翻转特定位 |
~ | 按位取反 | ~0b1100 = ...11110011 | 全位翻转 |
<< | 左移 | 0b0001 << 3 = 0b1000 | 高效乘 2ⁿ |
>> | 右移 | 0b1000 >> 2 = 0b0010 | 高效整除 2ⁿ |
** 右移的两个分支:**
- 逻辑右移: 高位补 0(无符号数的行为) 实用技巧:用异或做原地值交换
int a = 5, b = 7;
a ^= b; /* a = 5 ^ 7 = 2 */
b ^= a; /* b = 7 ^ 2 = 5 */
a ^= b; /* a = 2 ^ 5 = 7 */
/* 现在 a=7, b=5,不需要临时变量 */注意:现代编译器对这个技巧已经优化到和临时变量版本一样快,但在一些极度受限(无多余寄存器)的嵌入式场景中仍然有用。
- 算术右移: 高位补符号位(有符号数的行为)
int a = -8; /* 0xFFFFFFF8 */
int b = a >> 2; /* 算术右移:高位补1,结果 -2 */
unsigned int c = 0xF8; /* 248 */
unsigned int d = c >> 2; /* 逻辑右移:高位补0,结果 62 */常见陷阱
用位运算实现一个简易权限掩码系统。这是操作系统中文件权限检查的迷你版本。
#include <stdio.h>
#include <stdint.h>
#define PERM_READ (1 << 0)
#define PERM_WRITE (1 << 1)
#define PERM_EXEC (1 << 2)
void show_perm(uint8_t p) {
printf("权限: %c%c%c\n",
(p & PERM_READ) ? 'r' : '-',
(p & PERM_WRITE) ? 'w' : '-',
(p & PERM_EXEC) ? 'x' : '-');
}
int main() {
uint8_t user = PERM_READ | PERM_WRITE; /* rw- */
uint8_t group = PERM_READ; /* r-- */
uint8_t other = 0; /* --- */
show_perm(user);
show_perm(group);
show_perm(other);
/* 给 others 加上执行权限 */
other |= PERM_EXEC;
/* 从 user 撤销写权限 */
user &= ~PERM_WRITE;
printf("\n修改后:\n");
show_perm(user); /* r-x */
show_perm(other); /* --x */
return 0;
}这就是 Linux chmod 755 在比特层面的工作原理。755 分解为二进制权限位:owner rwx (111=7),group r-x (101=5),others r-x (101=5)。
通关挑战
问题 1(进制转换): IPv4 地址 192.168.1.1 也可以写作一个 32 位整数。它的十进制值是多少?
提示:每段 8 位,
192<<24 | 168<<16 | 1<<8 | 1
问题 2(编码): 写出「好」字(U+597D)的 UTF-8 编码字节序列。手动推演编码过程。
问题 3(字节序): 在 x86 机器上,short x = 0x0102 被写入文件。文件在 big-endian 的 PowerPC 机器上被读入 short y。y 的值是多少?为什么?
问题 4(位运算实战): 给定 unsigned int x = 0xAABBCCDD,编写代码提取每个字节并逆序输出。不使用循环。
验收标准
| 技能 | 自检方法 |
|---|---|
| 进制互转 | 能在 10 秒内将 0x7F、0b101010 转成十进制 |
| ASCII/Unicode/UTF-8 | 能说清「A」在内存里为什么是 0x41,「好」为什么是 3 字节 |
| 字节序 | 能解释为什么 htonl() 在网络编程中必不可少 |
| 位运算 | 能熟练用 ` |
常见卡点
- 把 Unicode 和 UTF-8 混为一谈。 Unicode 是字符表,UTF-8 是编码方案。就像「人的姓名」和「把姓名写成汉字」的关系。
- 算术右移和逻辑右移不区分。 有符号数右移是算术右移,补符号位;无符号数右移是逻辑右移,补 0。
- 字节序只影响多字节数据。 单字节(如
char)不受字节序影响。这是初学调试时常见的混淆点。 - 移位溢出。 左移位数 ≥ 类型位宽是未定义行为。
1 << 32对 32 位int是危险的。
现在不需要理解
- IEEE 754 浮点编码的内部细节(下一章讲)
volatile和内存屏障对位操作的影响(Vol 4 并发)- x86 BTS/BTR 等位操作硬件指令——C 编译器的
|= / &=最终会生成这些指令,但现阶段不需要关心 - UTF-16 的代理对实现细节(Java 开发者未来踩坑时再深究)
旅人笔记
- 一切信息在计算机中最终都是比特流。编码是解释这些比特的契约。
- 十六进制是二进制的人形化缩影——4 位对 1 位,方便到无可替代。
- UTF-8 是人类编码方案设计的教科书案例:向后兼容 + 自同步 + 无字节序歧义。
- 小端 vs 大端没有优劣之分,只是生态的路径依赖。x86 小端,网络大端,在实际工作中必须记住。
- 位运算不是 C 的过时遗产。任何需要操作硬件寄存器、实现位图、做高性能标记的场景,位运算都无可替代。
→ 下一站预告
比特和字节有了,但我们还不能表示负数——不能用 -42 做算术。更糟的是,0.1 + 0.2 在计算机里居然不等于 0.3。下一章我们从整数的补码编码开始,一路钻入 IEEE 754 浮点的诡异世界,找到这些误差的根源。