问题与目标
编码、进制、原码、反码、补码,曾经是我最容易归进“底层知识,以后再说”的一组内容。
平时写变量、循环和函数,确实很少需要手算补码。可一旦遇到乱码、二进制协议、颜色值、权限位、哈希摘要或整数溢出,只知道 Python 语法就不够了。
尤其是文本处理。人看到的是“你好”,文件和网络里保存的却是字节。如果程序不知道这些字节采用什么规则编码,就无法稳定还原字符。
大模型项目又恰好大量处理文本:加载 Markdown、解析网页、读取 JSONL、上传文档、计算 Token、保存模型输出。编码问题不是边缘知识,它经常站在整个数据管道的入口。
核心理解
这几个概念看起来分散,实际都在解释同一件事:
计算机最终保存的是有限位的二进制,字符和整数都需要一套明确规则才能在“人的含义”与“机器的位”之间转换。
对于文本:
字符 --编码--> 字节
字符 <--解码-- 字节
对于整数:
数值 --按某种进制显示--> 数字字符串
有符号整数 --按固定位宽表示--> 二进制位
乱码通常不是字符坏了,而是编码和解码规则不一致;补码也不是另一种数值,而是固定宽度下表示有符号整数的一种规则。
完成标准
完成这一篇后,应当能够:
- 区分字符、Unicode 码点、编码和字节。
- 在
str与bytes之间正确编码和解码。 - 解释常见乱码和
UnicodeDecodeError的来源。 - 使用 Python 表示并转换二、八、十、十六进制整数。
- 读懂基本位运算。
- 在明确位宽后计算负数的原码、反码和补码。
- 说明 Python 任意精度整数与固定宽度整数的区别。
- 判断字符数、字节数和 Token 数为什么不能混用。
核心概念
字符、字符集、编码和字节不是一回事
先把四个容易混在一起的词分开:
- 字符:人理解的文字单位,例如
A、中、🙂。 - 字符集:为字符分配编号的集合,Unicode 是最重要的统一字符集。
- 编码:把字符编号转换成字节序列的具体方式,例如 UTF-8。
- 字节:计算机存储和传输数据的基本单位,1 字节等于 8 位。
Python 3 中:
str表示 Unicode 文本。bytes表示原始字节序列。
text = "你好"
data = text.encode("utf-8")
print(text) # 你好
print(data) # b'\xe4\xbd\xa0\xe5\xa5\xbd'
print(data.decode("utf-8")) # 你好
encode() 从 str 得到 bytes,decode() 从 bytes 恢复 str。两边使用的编码规则必须一致。
UTF-8 是变长编码:ASCII 范围内的字符通常占 1 字节,常见汉字通常占 3 字节,某些字符会占 4 字节。因此字符数量、字节数量和模型 Token 数量是三个不同概念:
text = "AI你好"
print(len(text)) # 字符数:4
print(len(text.encode("utf-8"))) # 字节数:8
Token 数由具体分词器决定,不能用 len(text) 或 UTF-8 字节数直接替代。
ASCII、Unicode、UTF-8 和 GBK 的关系
ASCII 是较早的字符编码方案,主要覆盖英文字母、数字、标点和控制字符。它使用的编号范围较小,无法表示完整中文。
Unicode 的目标是为世界上的字符分配统一码点。例如:
character = "中"
print(ord(character)) # 20013
print(f"U+{ord(character):04X}") # U+4E2D
print(chr(0x4E2D)) # 中
ord() 从字符得到码点整数,chr() 从码点得到字符。码点不是文件里的最终字节,还需要 UTF-8、UTF-16 等编码方式转换。
UTF-8 与 ASCII 兼容,英文 ASCII 字符的字节值保持一致;中文通常使用多个字节。GBK 是另一套常见中文编码。相同字符在两种编码下会形成不同字节:
text = "中文"
utf8_data = text.encode("utf-8")
gbk_data = text.encode("gbk")
print(utf8_data.hex(" "))
print(gbk_data.hex(" "))
只要编码和解码成对使用,都能还原原文:
print(utf8_data.decode("utf-8"))
print(gbk_data.decode("gbk"))
如果把 GBK 字节误当成 UTF-8,通常会直接报错:
try:
gbk_data.decode("utf-8")
except UnicodeDecodeError as error:
print(f"解码失败:{error}")
某些乱码场景不会报错,是因为错误字节碰巧能被另一种编码解释。程序成功得到字符串,不代表内容就正确,因此编码来源必须尽量由数据规范确定。
文本文件中的换行和 BOM
不同平台常见换行形式包括 \n 和 \r\n。Python 以文本模式读取时通常会进行通用换行处理,但比较原始字节、计算哈希或解析严格协议时,换行差异仍然重要。
有些 UTF-8 文本开头带有 BOM。普通 utf-8 解码后可能保留 \ufeff 字符;需要兼容这类文件时,可以使用 utf-8-sig:
from pathlib import Path
content = Path("document.txt").read_text(encoding="utf-8-sig")
这不意味着所有文件都应该固定使用 utf-8-sig。更可靠的做法仍是明确数据来源的编码约定。
进制只是同一个数的不同写法
十进制的 42、二进制的 101010 和十六进制的 2A 表示同一个数值。
Python 中可以直接写不同进制的整数:
binary_value = 0b101010
octal_value = 0o52
decimal_value = 42
hex_value = 0x2A
print(binary_value == octal_value == decimal_value == hex_value) # True
把整数格式化为不同进制字符串:
value = 42
print(bin(value)) # 0b101010
print(oct(value)) # 0o52
print(hex(value)) # 0x2a
print(f"{value:08b}") # 00101010
把某进制字符串转换为整数:
print(int("101010", 2)) # 42
print(int("2a", 16)) # 42
进制改变的是表示方式,不是数本身。十六进制常见,是因为一位十六进制刚好对应四位二进制,较长的位模式可以写得更紧凑。
手工理解转换时,关键是抓住“位权”:
二进制 1011
= 1 × 2³ + 0 × 2² + 1 × 2¹ + 1 × 2⁰
= 8 + 0 + 2 + 1
= 11
十六进制 2A:
2 × 16¹ + A × 16⁰
= 2 × 16 + 10
= 42
二进制与十六进制之间可以每四位一组:
0010 1010
2 A
不足四位时在最左侧补零,不改变数值。
位运算直接处理二进制位
Python 常见位运算符:
| 运算符 | 含义 | 示例 |
|---|---|---|
& | 按位与 | 两位都为 1 才是 1 |
| | 按位或 | 至少一位为 1 就是 1 |
^ | 按位异或 | 两位不同为 1 |
~ | 按位取反 | 0 和 1 互换 |
<< | 左移 | 位向左移动 |
>> | 右移 | 位向右移动 |
a = 0b1100
b = 0b1010
print(f"{a & b:04b}") # 1000
print(f"{a | b:04b}") # 1110
print(f"{a ^ b:04b}") # 0110
print(f"{a << 1:05b}") # 11000
print(f"{a >> 1:04b}") # 0110
位掩码可以使用一个整数保存多个布尔开关:
READ = 0b001
WRITE = 0b010
EXECUTE = 0b100
permission = READ | WRITE
can_read = bool(permission & READ)
can_execute = bool(permission & EXECUTE)
print(can_read) # True
print(can_execute) # False
对 Python 负整数执行 ~、>> 时,要结合 Python 的整数模型理解,不要直接假设它被限制在某个固定位宽中。
原码、反码和补码要先约定位宽
谈负数的二进制表示时,必须先说使用多少位。没有位宽,“最高位是符号位”就没有明确含义。
以 8 位表示 -8:
+8 的二进制:0000 1000
-8 的原码: 1000 1000
-8 的反码: 1111 0111
-8 的补码: 1111 1000
规则可以这样记:
- 正数的原码、反码和补码相同。
- 负数原码的最高位表示负号,其余位表示绝对值。
- 负数反码在原码基础上保持符号位,其余位取反。
- 负数补码等于反码加一。
但比规则更重要的是理解补码为什么存在。
原码会出现正零和负零,而且加减法需要额外处理符号。补码让零只有一种表示,并让固定宽度内的加法电路也能完成减法。
以 8 位计算 10 + (-8):
0000 1010 # 10
+ 1111 1000 # -8 的补码
-----------
1 0000 0010
丢弃超出 8 位的进位,结果是 0000 0010,也就是 2。
Python 整数与固定宽度整数要区分
Python 的 int 可以按需要扩展位数,不是固定的 8 位、32 位或 64 位整数。因此:
print(bin(-8)) # -0b1000
这里打印的是带负号的数值表示,不是某个固定位宽下的补码位模式。
如果要查看 8 位补码,可以按模 2^8 转换:
value = -8
unsigned_value = (1 << 8) + value
print(f"{unsigned_value:08b}") # 11111000
8 位有符号补码范围是 -128 到 127。超出范围时,必须决定是拒绝、截断还是使用更多位,不能默默假设。
可运行实现:同时检查文本编码和 8 位补码
下面写两个小函数,把转换规则固定下来:
def inspect_utf8(text: str) -> None:
encoded = text.encode("utf-8")
hex_bytes = " ".join(f"{byte:02x}" for byte in encoded)
print(f"文本:{text}")
print(f"字符数:{len(text)}")
print(f"字节数:{len(encoded)}")
print(f"UTF-8:{hex_bytes}")
print(f"解码结果:{encoded.decode('utf-8')}")
def to_twos_complement(value: int, bits: int = 8) -> str:
if bits <= 0:
raise ValueError("bits 必须大于 0")
minimum = -(1 << (bits - 1))
maximum = (1 << (bits - 1)) - 1
if not minimum <= value <= maximum:
raise ValueError(f"{value} 超出 {bits} 位有符号整数范围")
encoded_value = value if value >= 0 else (1 << bits) + value
return f"{encoded_value:0{bits}b}"
inspect_utf8("AI你好")
print(f"-8 的 8 位补码:{to_twos_complement(-8)}")
print(f"10 的 8 位补码:{to_twos_complement(10)}")
预期关键输出:
文本:AI你好
字符数:4
字节数:8
UTF-8:41 49 e4 bd a0 e5 a5 bd
解码结果:AI你好
-8 的 8 位补码:11111000
10 的 8 位补码:00001010
这个例子说明,编码和补码不是一张只能死记的表。规则可以由程序验证,边界也能通过异常明确表达。
动手练习
练习一:定位一次编码不匹配
先用 GBK 写入中文,再故意使用 UTF-8 读取,观察异常;最后改成正确编码:
from pathlib import Path
path = Path("gbk-document.txt")
path.write_bytes("模型知识库".encode("gbk"))
try:
path.read_text(encoding="utf-8")
except UnicodeDecodeError as error:
print(f"使用 UTF-8 读取失败:{error}")
content = path.read_text(encoding="gbk")
print(content)
这个练习会创建文件,运行后可以在确认目标路径后自行删除。重点是观察:错误发生在“字节按错误规则解码”的位置。
练习二:实现补码反向转换
已知一个固定位宽补码字符串,将它转换为有符号整数:
def from_twos_complement(bits_text: str) -> int:
if not bits_text or any(bit not in "01" for bit in bits_text):
raise ValueError("必须提供非空二进制字符串")
unsigned_value = int(bits_text, 2)
bits = len(bits_text)
if bits_text[0] == "0":
return unsigned_value
return unsigned_value - (1 << bits)
print(from_twos_complement("00001010")) # 10
print(from_twos_complement("11111000")) # -8
print(from_twos_complement("10000000")) # -128
负数分支减去 2^bits,正好是生成补码时“加上 2^bits”的逆过程。
常见问题与排查
把编码名称当成修复乱码的随机开关
遇到乱码后轮流尝试 UTF-8、GBK、Latin-1,有时碰巧能读,但不能证明文本正确。正确做法是尽量从文件规范、HTTP 响应头、数据提供方或字节特征确认编码来源。
尤其不要为了“不报错”随意使用 errors="ignore"。它会丢弃无法解码的字节,程序虽然继续运行,文档内容可能已经残缺。
混用 str 和 bytes
文本处理通常使用 str,文件传输、哈希和网络协议可能需要 bytes。在边界位置明确编码和解码,比在程序各处来回试转换更可靠。
认为一个汉字固定占两个字节
字符占多少字节取决于编码。即使同为 UTF-8,不同字符占用的字节数也可能不同。涉及文件大小、网络传输和切片时,应直接对编码后的 bytes 测量。
把字符数当成 Token 数
大模型分词器会按自己的词表切分文本。英文单词、中文字符、标点和代码的 Token 比例都不同。上下文预算必须使用目标模型对应的 Tokenizer 估算。
讨论补码时忘记位宽
11111000 在 8 位有符号补码中表示 -8,但放进更宽的无符号或有符号解释中,含义会不同。位模式本身没有完整语义,类型、符号和位宽共同决定解释方式。
以为 Python 的负数直接保存在“无限个 1”里
位运算常用无限符号扩展的模型来解释 Python 负整数行为,但真实存储实现和固定宽度补码需要分开理解。项目中若与 NumPy、C 接口、二进制文件或网络协议交互,必须以目标类型的明确位宽为准。
小结
补完这一块后,我对“数据”有了更完整的认识。
字符串不是天然躺在文件里的文字,整数也不是只用十进制存在。程序必须通过明确规则,在字符、字节、数值和位模式之间转换。
这一阶段的六篇文章到这里形成了一个小闭环:
环境让我稳定运行代码
数据类型和函数让我表达逻辑
模块、文件和异常让我组织流程
面向对象让我管理状态和组件
练习让我暴露理解偏差
底层表示让我看懂数据边界
我还不能因此宣称自己精通 Python,但已经具备继续补工程能力的地基。下一阶段进入 Linux、Shell 和 MySQL 时,我面对的不再只是几段语法,而是一套能够运行、组织、检查和解释代码的方法。
本篇检查清单:
- 能解释
str.encode()与bytes.decode()的方向。 - 能说明 Unicode 码点与 UTF-8 字节不是一回事。
- 能定位“编码规则与解码规则不一致”的问题。
- 能使用
bin()、oct()、hex()和int(text, base)。 - 能读懂与、或、异或、取反和移位。
- 计算补码前会先明确位宽和有符号范围。
- 知道
bin(-8)不是 8 位补码输出。 - 不会把字符数、字节数和 Token 数混为一谈。
许可协议:CC BY-NC 4.0
更新于 2 小时前
觉得文章有帮助?点个赞吧!
0 条评论


