位、字节、字符……这三个词,几乎出现在每一本编程入门书的前几十页,但也是第一批会在项目里坑你的老朋友。你以为自己懂了:位就是二进制的一位,字节就是 8 个位,字符就是像a、中这样的符号。可真到干活的时候,同一个中字一会儿占 2 个字节,一会儿占 3 个字节;文件传到一半变成乱码;数据库存进去再拿出来,完全不是当初那个样子。我在这行干了十几年,接手的乱码问题、存储异常问题,往回追根,基本都是这三个概念没对齐。这篇文章就从干活的角度,把位、字节、字符的底细彻底捋清楚,顺便把字符编码、BOM、大小端这些绕不开的坑一次性讲明白。
1. 先把名词对齐:位、字节、字符到底是谁
1.1 位(bit):只有一个“有/没有”的开关
计算机底层没有那么多玄学,说到底就是一群开关。一个开关两种状态——通电和断电,我们就记成 1 和 0。一个这样的开关,就是一个位,英文叫 bit,中文也叫比特,简写是b。
位是最小的信息单位,它本身存不了什么“有意义”的内容,只能表达两种状态。两个开关组合,就是 4 种状态(00、01、10、11);三个开关组合是 8 种状态;n 个开关组合就是 2 的 n 次方种状态。这就是为什么计算机世界里到处是 2、8、16、32、64、256、1024 这种数字——它们都是 2 的幂。
这里顺带提一个特别容易搞混的点:平时说“32 位系统”“64 位系统”,这里的“位”不是指内存只有 32 个 bit,而是指 CPU 的寄存器宽度、一次能处理的整数位数。一个 64 位系统里,一块内存照样有几十 GB 的容量。所以“位”在不同语境下,含义要跟着上下文走,别看到一个“位”字就以为是二进制的一位。
1.2 字节(byte):机器眼中的最小“一个字”
8 个位排成一队,就是一个字节,英文叫 byte,简写是B。为什么要凑 8 个,不凑 6 个、10 个?这里面有历史原因:早期 ASCII 字符集用了 7 位,但 7 这个数字在计算机里非常别扭,内存寻址、存储规划都喜欢按 2 的幂来,8 正好是 2 的 3 次方,还能留一位做扩展或校验,于是 8 位一组的模式成了事实标准。
一个字节能表示 0 到 255,一共 256 种组合。这个容量在大家眼里好像不大,但它是计算机内存、文件存储、网络传输里真正的最小寻址单位。你可以理解成:内存是一整面墙的抽屉,每个抽屉正好放一个字节(8 个开关),CPU 要读写数据,最少也是按一个抽屉来操作,不可能只打开半个抽屉。
单位换算也是个经典大坑。教科书上写 1KB = 1024B、1MB = 1024KB,这是二进制习惯。而网络带宽和硬盘厂商经常按十进制算,1KB = 1000B。所以 100M 宽带的理论下载速度是 100Mbps ÷ 8 ≈ 12.5MB/s,这里的“Mb/s”是兆位每秒,不是兆字节每秒,大写小写差 8 倍。遇到这种换算问题,先搞清楚说的是 bit 还是 byte,能避免很多看起来像“网速被坑了”的误会。
1.3 字符(character):给人看的符号,跟存多长没有必然关系
字符是语义层面的东西。a是一个字符,中是一个字符,!是一个字符,😂也是一个字符。字符是给人看的,位和字节是给机器用的。问题来了:一个字符到底占几个字节?答案是不一定,完全取决于你用的编码方案。
同一个“中”字:
- 在 GBK 编码里占 2 个字节;
- 在 UTF-8 编码里占 3 个字节;
- 在 UTF-16 编码里占 2 个或者 4 个字节。
你没法只凭“这是个字符”就断定它占几个字节,必须知道它用什么编码。这就像同样一个地名,中文写“北京”,英文写“Beijing”,日文写“北京”,形式不同,但指代同一个地方。字符和字节之间,需要一个翻译规则,这就是编码。
2. 编码是把字符翻译成字节的那张表
2.1 编码的本质是两层东西:字符集 + 编码方案
很多人把“字符集”和“编码”混着说,实际上它们是两个维度。
字符集(character set)负责给字符发号:把每个字符映射成一个数字,这个数字叫码点(code point)。比如英文大写A在 Unicode 里的码点是 U+0041,汉字中的码点是 U+4E2D。码点只是“编号”,不代表怎么存。
编码方案(encoding)负责把码点变成字节序列。同一个码点,在 UTF-8 里是一种字节排法,在 UTF-16 里是另一种,在 GBK 里可能根本没有对应关系。
用一个生活类比:字符集是城市的门牌号系统,每个地址有唯一编号;编码方案是寄件的打包方式——用纸箱还是塑料袋、贴几张面单、怎么塞填充物。地址是同一个,打包方式不一样,运出去的样子就完全不一样。
2.2 ASCII 为什么只用到 127 个位置
聊编码绕不开 ASCII。上世纪 60 年代,美国搞出了一套字符编码,把英文字母、数字、标点和一些控制字符统一编号,一共 128 个字符,用 7 位二进制就可以表示。这套东西叫 ASCII(美国信息交换标准代码)。
为什么是 127 而不是 255?因为它只服务英语。26 个大写字母、26 个小写字母、10 个数字、若干标点和控制符,30 多个控制字符加在一起,128 个位置绰绰有余。控制字符是那些不显示但能“做事”的字符,比如换行(LF)、回车(CR)、退格(BS)、响铃(BEL)。这类字符虽然看不到,但在文件解析、网络协议里很重要,比如\n的 ASCII 码就是十进制的 10。
ASCII 的编号表要熟记几个常用值,干活时候真用得上:
| 字符 | ASCII 十进制 | ASCII 十六进制 |
|---|---|---|
0 | 48 | 0x30 |
A | 65 | 0x41 |
a | 97 | 0x61 |
| 换行 LF | 10 | 0x0A |
| 回车 CR | 13 | 0x0D |
| 空格 Space | 32 | 0x20 |
这几个数记得住,你在排查协议数据、看十六进制日志的时候会轻松非常多。我曾经调试一个二进制协议,对方文档只写了“消息头长度字段是 0x61”,我一看就对上了,这是小写字母a,说明长度是 97,省了好几个小时。
2.3 第 8 位被各国拿走之后,乱码的种子埋下了
ASCII 用 7 位,计算机存储流行 8 位,多出来的第 8 位留给了“扩展”。有人觉得 0-127 之外还有 128 个位置,闲着也是闲着,于是各国开始往这 128 个位置里塞自己的字符。
问题在于,没有人统一塞法。欧洲国家把 128-255 填成带重音的字母,希腊人填成希腊字母,俄罗斯人填成西里尔字母,我们中国人呢?汉字成千上万,128 个位置根本不够塞。更麻烦的是,同一个字节,在不同国家的扩展表里代表完全不同的字符。比如字节 0xE9,在某个西欧编码里是é,在另一个编码里可能是别的字。
这就是乱码产生的根源之一:同一串字节,用不同编码表去解读,得到完全不同的文本。你看到的“乱码”,往往不是你数据坏了,而是你的程序用错了编码表去翻译那串字节。
3. 中文的入场:GB2312、GBK、GB18030 是怎么迭代的
3.1 一个字节不够,那就两个字节:先说 GB2312 的区位码
中文要进计算机,必须解决一个核心矛盾:汉字不是几十几百个,而是几万个。1 个字节最多 256 种组合,连常用汉字都装不下;2 个字节最多 65536 种组合,装常用汉字绰绰有余。于是 1980 年,国家发布了 GB2312 标准。
GB2312 的设计很有特色,它把汉字放在一个 94×94 的矩阵里,每个汉字对应一个区位码:第一字节叫“区”,第二字节叫“位”,范围都是 1 到 94。存储的时候,区号和位号各自加上 0xA0(也就是十进制的 160),变成两个字节。
举个例子,“中”字的区位码是 54-48,区号 54 加 160 等于 214,也就是十六进制 0xD6;位号 48 加 160 等于 208,也就是 0xD0。所以“中”在 GB2312 里的字节就是 D6 D0。这套“偏移 0xA0”的设计保证了一个细节:所有 GB2312 双字节的高位都大于 0xA0,跟 ASCII 的 0x00-0x7F 天然区分开,程序读到字节后能判断这是英文字符还是中文字符的一部分。
GB2312 收录了 6763 个常用汉字和 682 个其他符号。当年这覆盖了绝大多数日常用字,所以迅速铺开。
3.2 GBK 把双字节空间彻底撑开
GB2312 有个问题:只覆盖常用字,很多繁体字、生僻字、人名地名用字没有。于是 1995 年前后,出现了 GBK 编码。GBK 的 K 是“扩展”的意思,它在 GB2312 的基础上大幅扩展,能表示 2 万多个字符,完全兼容 GB2312——这意味着只要是 GB2312 能编的字,GBK 的字节编码完全一样,老文件在新编码体系下不会变样。
GBK 的做法很聪明:它扩宽了第一字节和第二字节的取值区间。第一字节用 0x81-0xFE,第二字节用 0x40-0xFE(跳过单字节值 0x7F),两个字节组合出两万多个码位,既兼容 ASCII 又兼容 GB2312。
要注意的是,GBK 并不是国家标准,而是一个事实上广泛使用的编码规范,后来国家在 GB18030 标准里把 GBK 吸收进去了。在很长一段时间里,Windows 简体中文系统的默认 ANSI 编码就是 GBK,所以很多老一辈的中文软件、旧网站、老数据库里存的都是 GBK 文本。你现在打开某些老的 txt 文件或者老系统的导出文件,看到的中文乱码,很多就是 GBK 和 UTF-8 之间的“翻译事故”。
3.3 GB18030 用四字节兜住整个 Unicode
GBK 虽然能装两万多字符,但还不是所有汉字都收得进去,更别说其他语言文字。于是国家又推出 GB18030,它采用 1、2、4 字节变长编码:
- 单字节:完全兼容 ASCII;
- 双字节:兼容 GBK;
- 四字节:用来表示 GBK 双字节空间之外的 Unicode 码点。
也就是说,从覆盖范围上看,GB18030 可以表示所有 Unicode 字符,只是存储形态是中文体系自己的。某些政务系统、公共平台强制要求支持 GB18030,所以做相关业务的人要认识这个词。实际开发中如果你不确定目标环境用哪种中文编码,统一走 UTF-8 通常是最稳妥的,除非对接的老系统明文要求 GBK 或 GB18030。
3.4 双字节编码的特有问题:解析时不能跳着看
GB2312、GBK 这类双字节编码有个天然坑:一个汉字是两个字节,但你无法从单个字节知道它是不是汉字的一部分,只能线性扫描判断。如果内容里混着半角字符和全角字符,解析时一旦在错误的位置截断,后半段全部错位。
举个例子,如果一个字符串本应是“中文abc”,按 GBK 存成字节D6 D0 61 62 63。如果你从第 1 个字节开始读,能正确解析成“中”“文”“a”“b”“c”;如果你从第 2 个字节开始读,D0 和 61 会被当作一个汉字的两个字节,直接解出一个怪字。这就是为什么二进制协议里处理中文字符串,一定要在协议层写明“字符串长度按字节计”而不是按字符计,否则解析错位你连原因都找不到。
4. Unicode 和 UTF-8:全世界字符的统一门牌号
4.1 Unicode 只编号不存储
各国各搞一套编码,最直接的恶果就是国际协作时鸡同鸭讲。为了解决这个问题,Unicode 标准出现了:它给世界上几乎所有字符都分配一个唯一码点,覆盖各大语言文字、数学符号、emoji、特殊符号,目标是“一个字符一个号,天下大同”。
在 Unicode 体系里,A的码点是 U+0041,中的码点是 U+4E2D,好的码点是 U+597D,emoji😂的码点是 U+1F602。码点是一个整数,范围从 0 到约 110 万,理论上足够覆盖人类已知符号。
但 Unicode 不规定怎么存储。同一个 U+4E2D,你可以把它存成 2 字节的 4E 2D,也可以存成 3 字节的 E4 B8 AD,也可以存成 4 字节的 00 00 4E 2D——具体看你选哪种编码方案。Unicode 只是门牌号,运输方式由 UTF-8、UTF-16、UTF-32 这些方案决定。
4.2 UTF-8 的变长规则,手把手算一遍
UTF-8 是目前最主流的编码方案,几乎是互联网事实标准。它的核心设计是变长 1 到 4 字节,而且刻意做到了完全兼容 ASCII:码点 0 到 127,UTF-8 编码后就是单字节本身。这意味着你任何一个纯英文 UTF-8 文件,同时也是一个合法的纯 ASCII 文件。
UTF-8 的编码规则就几条,按二进制位拆开看:
| 码点范围 | UTF-8 字节模板 |
|---|---|
| 0x00 - 0x7F | 0xxxxxxx(1 字节,兼容 ASCII) |
| 0x80 - 0x7FF | 110xxxxx 10xxxxxx(2 字节) |
| 0x800 - 0xFFFF | 1110xxxx 10xxxxxx 10xxxxxx(3 字节) |
| 0x10000 - 0x10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx(4 字节) |
规则的精髓在于每个多字节序列都用“10”打头的字节当续体,所以程序从一个字节就能判断:如果开头是 0,那就是单字节 ASCII;如果是 110、1110、11110,说明后面还有 1、2、3 个续体字节;如果开头是 10,说明你从中间插进来了,数据可能被截断了。
拿“好”字练一遍,它的码点是 U+597D,转成二进制是0101 1001 0111 1101,一共 16 位。16 位落在三字节区间,按1110xxxx 10xxxxxx 10xxxxxx的模板,把 16 位拆成 4+6+6 三段:0101、100101、111101。填进去:
- 第一字节:
1110 0101= 0xE5 - 第二字节:
10 100101= 0xA5 - 第三字节:
10 111101= 0xBD
所以“好”在 UTF-8 里就是E5 A5 BD三个字节。你可以用hexdump随便看一个存着“好”的文件,一定是这个结果。算过一遍之后,你会突然明白为什么网上常有人说 UTF-8 中文是 3 字节——因为常用汉字的码点基本都在 U+0800 到 U+FFFF 之间,正好落进三字节区间。
4.3 UTF-16、BOM 和大小端
UTF-16 是另一套方案,它默认把码点存成 2 字节,遇到超出 65535 的码点(比如 emoji),用一对 2 字节的代理对来表示,总共 4 字节。JS 内部、很多 Windows 原生 API 用的是 UTF-16。
UTF-16 有个绕不开的问题:多字节数据存到文件或传进网线时,到底是高位在前还是低位在前?如果先写低字节,叫小端(little-endian);先写高字节,叫大端(big-endian)。为了告诉读者自己用的是哪种,人们发明了 BOM(字节序标记)。
常见 BOM 是这样的:
| 编码 | BOM 字节 | 含义 |
|---|---|---|
| UTF-8 | EF BB BF | 通常是多余的,但可用来标记“这是 UTF-8” |
| UTF-16 LE | FF FE | 小端 |
| UTF-16 BE | FE FF | 大端 |
有意思的是,BOM 本身也是为了解决“我的字节序是什么”的问题,但 BOM 也带来了新的麻烦:有些程序不认 BOM,会把 EF BB BF 当成三个普通字符显示成“锘”之类的怪字;有些老接口会把带 BOM 的文件内容拼在字符串前面,导致解析第一个字段失败。所以实践中,纯文本和程序内部基本都用 UTF-8 无 BOM,只有需要给特定软件(比如某些版本的 Excel)识别时才加 BOM。
这里我要明确说结论:新项目、新协议、新数据库,能选 UTF-8 就选 UTF-8。理由很实际:兼容 ASCII,没有大小端问题,没有 BOM 歧义,网页和 JSON 的默认编码就是它,网络传输中也不会因为一个字节序搞出灵异现象。
4.4 特殊字符:为什么 emoji 和组合字符最容易出事
这个部分要专门说一说“特殊字符”。现代文本里真正让程序员头疼的,往往不是汉字,而是以下几类:
- emoji 和生僻字:码点在 U+10000 以上,UTF-8 占 4 字节,JS 的 UTF-16 里还占 2 个逻辑单元。你用函数量长度、截断字符串、统计字数时,非常容易算错。
- 组合字符:比如
é可以是预合成的单码点 U+00E9,也可以是e(U+0065)紧跟一个组合音调符 U+0301。显示出来差不多,但前者长度是 1,后者长度是 2。做字数统计、数据库字段长度校验时,这类字符很容易引发“DB 说长度超了但我明明没写几个字”的诡异错误。 - 零宽字符和方向控制符:零宽空格、零宽连接符、阿拉伯文方向的强制标记等。这类字符肉眼看不见,但真实存在于字符串里,能导致字符串比较失败、搜索引擎关键词不匹配、甚至文件名“看着一样但就是打不开”。
- 全角符号 :全角
A和半角A看起来接近,码点完全不同,直接做字符串比较会返回不相等。
处理这类特殊字符的正确习惯是:不要用“看得见几个字符”去判断文本长度,而要用你所在运行环境的明确规则去计算;涉及存储和传输时,先把字符串编码成字节,再看字节长度;做校验时统一用 code point 或者 grapheme cluster 的标准而不是凭直觉。
5. 实操现场:用命令和代码把编码看个明明白白
5.1 hexdump 看真实字节
排查编码问题,最忌讳的就是用“肉眼”在编辑器里看乱码猜原因。正确做法是先看原始字节。假设你有一个 UTF-8 文件,内容是“中ab”:
$ printf '中ab' > demo.txt $ hexdump -C demo.txt 00000000 e4 b8 ad 61 62 |...ab|e4 b8 ad正好是“中”的 UTF-8 编码,后面61 62是a和b,一共 5 个字节。你不需要认识这些字节,只需要知道“中”在这里是 3 字节。
如果把它转成 GBK 再看:
$ iconv -f UTF-8 -t GBK demo.txt | hexdump -C 00000000 d6 d0 61 62 |..ab|“中”变成d6 d0两个字节,整个文件 4 个字节。这一步操作很有价值:它让你直观看到同一个字符在不同编码下的字节形态差异。不同系统上file命令也能帮你判断文件大致编码,比如:
$ file -i demo.txt demo.txt: text/plain; charset=utf-8但file只是猜测,不一定准,尤其是短文本。真正靠谱的做法永远是看字节、对照表,不要靠猜。
5.2 Python 里 str 与 bytes 的关键区别
Python 3 把字符串和字节分得很清楚,这恰恰是理解编码概念的最佳教材。字符串(str)是字符集合,字节串(bytes)是原始字节集合,两者通过encode和decode互相转换:
s = "中文abc" print(len(s)) # 5,字符数 b = s.encode("utf-8") print(len(b)) # 9,UTF-8 字节数(两个汉字 3*2 + 三个英文) print(b) # b'\xe4\xb8\xad\xe6\x96\x87abc' print(b.decode("utf-8")) # 中文abc如果你用 GBK 去编一个 emoji:
text = "ok😂" text.encode("gbk") # UnicodeEncodeError:GBK 里没有 emoji 的映射这就是特殊字符和旧编码之间的经典冲突。反过来,如果拿错解码表去解一串字节,也可能得到一堆“看起来像中文但其实毫无意义”的怪字:
bad = b"\xe4\xb8\xad\xe6\x96\x87".decode("gbk") print(bad) # 一串奇怪的汉字 restored = bad.encode("gbk") print(restored.decode("utf-8")) # 中文这个“先按错误编码解出怪字,再按错误编码编回去,最后用正确编码解”的过程,就是乱码恢复的常用手法。它能救回来一部分误用错编码读取的文件,但不是万能的——如果中间某步把字符替换成了?或者�,原始信息就永久丢失了。
5.3 乱码排查标准流程
遇到乱码,按下面的顺序走,能省掉大量碰运气的时间:
- 搞清楚原始文本的真实编码来源:文件是用什么工具生成的?HTTP 响应头里
charset写了什么?数据库连接串和表字段的字符集是什么?老系统导出的文件,八成是 GBK;网上爬下来的页面,按 HTML 的 meta 声明判断。 - 先用 hexdump 或 xxd 看一眼原始字节,确认“字节层到底是什么”,这一步别跳。
- 在字节层面做转换,不要对着一堆乱码字符反复另存为、复制粘贴,那样只会让编码错误叠加。
- 每次转换只做一步:从已知的原始编码,一次性转换到目标编码。
- 如果文本里出现
�(U+FFFD),说明已经有不可逆的替换发生了,这时候别继续转换,先回源找原始数据。
6. 经典乱码速查表与多年避坑心得
6.1 几个典型乱码“指纹”
| 你看到的乱码 | 最常见成因 | 处理思路 |
|---|---|---|
锟斤拷锟斤拷 | 无效 UTF-8 字节被替换成 U+FFFD(EF BF BD),又被当成 GBK 解码 | 回源找原始字节,别在这层继续转 |
ä¸-æ–‡、é之类 | UTF-8 字节被按 Latin-1/Windows-1252 解读 | 把文本按原错误编码反向编码回字节,再按 UTF-8 解码 |
涓栫晫这类“中文但不像话” | UTF-8 中文被按 GBK 解读 | 同样手段:先按 GBK 编码还原字节,再按 UTF-8 解码 |
满屏????? | 数据库连接或写入时做了有损替换,源字符不存在于目标字符集 | 源头已经丢信息,只能修改连接参数和字段字符集后重新导数据 |
烫烫烫或屯屯屯 | 调试模式下未初始化内存被填充了固定字节,这些字节被当作 GBK 汉字显示 | 不是编码问题,是访问了未初始化内存 |
文件开头有锘或? | 带 UTF-8 BOM 的文件被按 GBK 读取 | 去掉 BOM,或改用 UTF-8 读取 |
这里最有意思的是锟斤拷。它的字节来源是 EF BF BD 重复出现——也就是一堆被替换成了 U+FFFD 的无效字符——再被 GBK 解读后就变成“锟斤拷锟斤拷”。你只要看到这几个字,基本可以断定数据已经经历过两次编码伤害:一次丢失信息,一次错误翻译。这种乱码几乎不能恢复,最好的办法是找原始数据源。
6.2 让编码少出问题的工作习惯
最后说几个我从多次踩坑里总结出来的习惯,不一定写在哪本教科书里,但非常实用。
第一,新项目默认 UTF-8,没有例外。数据库表字符集、连接字符串、文件读写、HTTP 响应头、HTML meta 标签,全部显式声明 UTF-8,不依赖环境默认值。环境默认值是最不可控的变量,同一个程序在服务器上跑的编码和你本机可能完全不一样。
第二,文本跨系统传递前,先约定编码,再传数据。哪怕是内部系统,也要在接口文档里写清“所有字符串按 UTF-8 编码”。如果对接老系统必须用 GBK,那也要在中间层显式转换,不要指望着双方恰好一样。
第三,读写文件时永远显式指定编码。很多语言的文件 API 都有默认编码,而这个默认值跟操作系统 locale 有关。你以为写的是 UTF-8,实际可能按本地 ANSI 写了,文件换个环境就炸。Python 里读写文件建议这样写:
with open("out.txt", "w", encoding="utf-8", newline="") as f: f.write("中文") with open("out.txt", "r", encoding="utf-8") as f: text = f.read()第四,测试用例里故意放上中文、日文、emoji、组合字符。开发的时候,全是 ASCII 的代码测不出编码问题。我第一次意识到这个,是因为一个“看起来完美”的工具在客户的中文路径上直接崩了,原因就是某层代码用了默认编码去拼路径。从那以后,凡是涉及字符串长度、截断、存储字段的测试,我必放几个特殊字符进去。
第五,做代码审查和运维排查时,看到乱码不要急着“换一个编码打开”。先从字节判断,再决定转换链,永远不要在 GUI 里反复瞎试。一个人如果连“字节和字符不是一回事”都没想清楚,那修乱码就是在赌运气;想清楚了,乱码问题十有八九能定位到具体某一层代码。
我在实际项目里见过太多因为编码问题导致的“事故调查”——从日志乱码一路查到数据库,最后发现只是某个接口在返回时少写了一行charset=utf-8。这种问题不难,但特别浪费时间。希望你看完这篇之后,以后再遇到乱码,第一反应不是“这文件坏了”,而是“这串字节是用哪张表翻译出来的”。想明白这个问题,你离真相就只剩一条命令的距离。