Skip to content

第1章 数据编码——计算机的符文系统


元数据卡

项目内容
难度(入门)
前置
关键词比特、字节、进制、ASCII、Unicode、UTF-8、字节序、位运算
C 标准C99+
代码量~60 行

你在哪

"从算法森林的地面往下挖,你钻进了一个巨大的洞窟。这里没有编程语言的魔法——没有垃圾回收、没有自动内存管理、没有类型系统。只有最原始的东西:高电压(1)和低电压(0)。你站在计算机的地心入口。"

上一卷(Vol 2)我们用 Python/Java 搭了一层又一层的抽象——类、接口、泛型、垃圾回收——像是站在编程语言的高楼里俯瞰大地。现在,我们要拆掉地板,钻进地心。

这栋楼的地基,其实只有两种电压:高(~3.3V/5V)低(~0V) 。CPU 里的晶体管靠识别这两种电压工作,把「有电」「没电」翻译成 10。这 1 和 0,就是计算机一切故事的起点。

这一章,我们做一件事:搞懂信息在机器里到底是怎么存的。


你的任务

本章分层

  • 必读:比特与字节的本质关系;进制互转;ASCII / Unicode / UTF-8 三者的区别
  • 选读:字节序(大端 vs 小端)的判断与应用场景;六种基本位运算的 C 实现
  • 深水区:算术右移 vs 逻辑右移的区别与陷阱;UTF-16 代理对与 Java char 的关系 本章不会要求你掌握
  • 硬件级的位操作指令(x86 BTS/BTR 等)
  • 内存屏障与 volatile 对位操作的影响
  • 完整的 Unicode 标准文本规范化算法

学完本章,你应当能做到:

  1. 手算任意十进制 ↔ 二进制/八进制/十六进制互转
  2. 理解 ASCII、Unicode、UTF-8 之间的关系,知道为何三种编码不能混为一谈
  3. 解释大端 vs 小端字节序,判断一个平台用哪种
  4. 用 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   → 最大:255

2⁸ = 256,一个字节可以编码 0-255 共 256 个值。凭这个,就能存下一个英文字母或一个简单的数字。

关键直觉: 计算机从不关心你写的是 "hello" 还是 "你好"。它只看见高高低低的电压。编码是人类和机器之间的契约——我告诉你这些比特代表什么,你告诉机器该怎样解释它们。

💡 C 示例前置说明:本章的 C 代码主要用来演示概念,并非生产级的系统编程。你不需要会写 C 才能理解编码概念——用你熟悉的语言(Python / Java)也能观察类似行为。

在 C 语言中查看任何数据的「比特真身」很简单——拿 unsigned char* 去遍历每个字节,打印出十六进制或二进制值。这个技巧在调试网络协议、文件格式解析时几乎每天都在用。


第2战:进制转换——被忽视的看家本领

问题: 颜色 #FF8040 是个橙红色。FF、80、40 分别代表红、绿、蓝分量。这三种写法为什么出现?怎么读懂它们?

十六进制 FF = 十进制 255。为什么程序员偏爱十六进制?因为它和二进制之间的转换异常整齐——一个十六进制位对应四个二进制位

二进制:    1111    1111
十六进制:    F       F

转换口诀:

二进制十六进制二进制十六进制
0000010008
0001110019
001021010A
001131011B
010041100C
010151101D
011061110E
011171111F

快速心算法——分组即可: 0x3A3 = 0011, A = 101000111010。看,一行就写完了。

十进制转二进制(除2取余法):

c
/* 把十进制转成二进制字符串,验证你的手算结果 */
#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 语言中,用前缀区分进制:

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 映射为英文字母、数字和符号:

c
#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+0041A
  • U+4E2D
  • U+1F389🎉(派对礼炮)

问题来了:U+1F389 超过了一个字节的范围。Unicode 只是字符到码点的映射表,不是存储编码。 UTF-8才是解决存储问题的方案。

UTF-8 的精妙设计:

码点范围UTF-8 编码(字节数)
U+0000 ~ U+007F0xxxxxxx(1 字节,兼容 ASCII)
U+0080 ~ U+07FF110xxxxx 10xxxxxx(2 字节)
U+0800 ~ U+FFFF1110xxxx 10xxxxxx 10xxxxxx(3 字节)
U+10000 ~ U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx(4 字节)

编码规则拆解(以「中」U+4E2D 为例):

  1. U+4E2D 落在 U+0800 ~ U+FFFF,用 3 字节
  2. 4E2D = 0100 1110 0010 1101(16 位)
  3. 填充模板:1110xxxx 10xxxxxx 10xxxxxx
  4. 从右向左填入 → 11100100 10111000 10101101
  5. 十六进制:E4 B8 AD

为什么 UTF-8 这么流行? 因为它向后兼容 ASCII——所有 ASCII 文本也是合法的 UTF-8 文本。一个只含英文的文件用 ASCII 和 UTF-8 编码结果完全一样。

c
#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 首部)用大端——这就是 网络字节序 = 大端的由来。

c
#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 个开关?

答案就是位运算(一次操作检查/设置所有位,比循环快一个数量级):

c
#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(无符号数的行为) 实用技巧:用异或做原地值交换
c
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,不需要临时变量 */

注意:现代编译器对这个技巧已经优化到和临时变量版本一样快,但在一些极度受限(无多余寄存器)的嵌入式场景中仍然有用。

  • 算术右移: 高位补符号位(有符号数的行为)
c
int a = -8;            /* 0xFFFFFFF8 */
int b = a >> 2;        /* 算术右移:高位补1,结果 -2 */
unsigned int c = 0xF8; /* 248 */
unsigned int d = c >> 2; /* 逻辑右移:高位补0,结果 62 */

常见陷阱

用位运算实现一个简易权限掩码系统。这是操作系统中文件权限检查的迷你版本。

c
#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 yy 的值是多少?为什么?

问题 4(位运算实战): 给定 unsigned int x = 0xAABBCCDD,编写代码提取每个字节并逆序输出。不使用循环。


验收标准

技能自检方法
进制互转能在 10 秒内将 0x7F0b101010 转成十进制
ASCII/Unicode/UTF-8能说清「A」在内存里为什么是 0x41,「好」为什么是 3 字节
字节序能解释为什么 htonl() 在网络编程中必不可少
位运算能熟练用 `

常见卡点

  1. 把 Unicode 和 UTF-8 混为一谈。 Unicode 是字符表,UTF-8 是编码方案。就像「人的姓名」和「把姓名写成汉字」的关系。
  2. 算术右移和逻辑右移不区分。 有符号数右移是算术右移,补符号位;无符号数右移是逻辑右移,补 0。
  3. 字节序只影响多字节数据。 单字节(如 char)不受字节序影响。这是初学调试时常见的混淆点。
  4. 移位溢出。 左移位数 ≥ 类型位宽是未定义行为。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 浮点的诡异世界,找到这些误差的根源。

Built with VitePress | Software Systems Atlas