☰
一文读懂计算机编码:字符编码、UTF-8、Base64与数值表示
2026/10/11 5:04:13 网站建设 项目流程

我一直觉得,“编码”这个词在程序员嘴边出现的频率实在太高了:字符串要编码、图片要编码、负数也要编码、甚至电机转了几圈也要用编码器来测量。所谓字符编码,就是给每个字符一个编号;所谓数值表示,就是给每个数字一种二进制写法。两者看起来是两套体系,但在计算机里其实是一回事:都是把“人能理解的信息”转换成一串可存储、可传输的比特,再用约定好的规则还原。区别只是还原出来的东西是文字、数字、图片,还是电机角度。

我这次想把整个链条彻底拆开讲清楚。从最基础的 ASCII 开始,到 Unicode、UTF-8、UTF-16,再到 URL 编码、Base64、Ajax 请求里的字符集问题,然后绕到计算机内部怎么用补码表示负数、浮点数为什么会丢精度、七段数码管和哈夫曼编码又是怎么回事。对前端、后端、嵌入式、算法岗的人都会有用,遇到乱码和编码方向拿不准的时候,至少知道该往哪个方向查。

1. 编码的本质:一切信息先变成数字,再变成比特

1.1 字符到数字:第一层映射

字符编码这件事,本质上就是查表。我们约定一个“词典”,里面给每个字符分配一个唯一的数字编号。比如 ASCII 里,大写字母 A 是 65,小写字母 a 是 97,数字字符 0 是 48。有了这张表,文本“ABC”就可以被写成三个数字:65、66、67。这一步完成的是“字符到数字”的映射。

这张表的大小决定了它能在多大范围内表示字符。ASCII 只用了 7 个比特,也就是 0 到 127,一共 128 个编号。英文大小写、数字、标点、控制字符都塞进去了,但中文汉字不在里面。后来我们有了 GB2312、GBK、GB18030、Shift-JIS 这些本地字符集,每个汉字也都有一个数字编号。比如“中”字在 GBK 里对应的数字是 D6D0,“文”是 CEC4。

从这里能看出一个关键点:字符集(charset)是“字典”,字符编码(encoding)是“把编号写成字节的规则”。两个概念经常混着用,但严格来说不是一回事。同样的“中”字,在 Unicode 这个字符集里编号是 4E2D,但落到磁盘上可以写成 UTF-8 的 E4 B8 AD,也可以写成 UTF-16BE 的 4E 2D。字符集可以很大,编码方案可以有很多种。

1.2 数字到字节:第二层映射

“中”字编号是 4E2D,这是十六进制写法,也就是十进制的 20013。但计算机存储最小单位是字节,一个字节只有 8 比特。如果这个编号超过 255,就必须用多个字节来表示。多个字节怎么排列?先放高位还是先放低位?这就出现了大端(Big Endian)和小端(Little Endian)的问题。

大端存储是“按人类阅读顺序”存:4E 2D 依次放;小端存储是“低位在前”:2D 4E。如果你跨平台读写二进制文件,不处理大小端,读出来的数字会完全不对。我曾经排查过一个跨平台数据文件问题,文件头写明了“UTF-16LE”,但在某些 Linux 工具里被误判成“UTF-16BE”,结果所有中文全部乱码。用xxd一查才发现字节序反了。

再进一步说,数值表示也是同一件事。整数 20013 在 32 位二进制里就是一堆 0 和 1,浮点数 3.14 也有自己的一套二进制形式。文本、数字、图片、音频,在计算机底层全都是比特串,区别只是“解释比特串的规则”不一样。

1.3 编码不是加密,但乱码会让人以为被加密了

很多人第一次接触编码,是在把一份 Windows 上的文档传到 Linux 上,发现所有中文都变成了“锟斤拷”的时候。“锟斤拷”三个字,其实是用 GBK 去解码 UTF-8 字节流被替换成问号后的结果,属于典型的“用错了字典”。

编码转换不会丢失信息,前提是:你能知道原始编码是什么,并且目标编码能表示所有字符。如果你把 GBK 字节流强制转成 UTF-8 之前不先解码,相当于拿着中文电报码去查英文码头字典,出来的东西当然毫无意义。这个过程要严格遵循“先解码、再编码”的顺序,乱码的基本排查逻辑也是从这里展开的。

2. 字符编码体系详解:从 ASCII 到 Unicode 再到 UTF-8 / UTF-16

2.1 ASCII:只有 128 个字符,为什么现在还在用

很多人以为 ASCII 是一个字节对应一个字符,其实它只定义了 7 位,0 到 127。第 8 位最早是用来做奇偶校验的,后来扩展出 Latin-1、Windows-1252 等编码,把 128 到 255 这半段用来放重音字母、货币符号这些字符。但不管怎么扩展,ASCII 的 0 到 127 始终在所有主流编码里保持一致。

这个“兼容性”太重要了。UTF-8 在设计时特意把第一个字节的范围跟 ASCII 完全兼容,0xxxxxxx 这个模式保留了原始 ASCII。所以任何一个纯英文的 UTF-8 文件,跟 ASCII 文件没有区别。这也是 UTF-8 能成为互联网主流格式的直接原因:它不是推翻原有体系,而是把原有体系包进去。

2.2 Unicode 是字符集,UTF-8 和 UTF-16 才是存储方案

Unicode 统一了字符编号,给世界上几乎所有文字分配了一个码点(Code Point)。比如“中”的码点是 U+4E2D,“你”是 U+4F60。但 Unicode 本身不规定怎么把码点变成字节,于是有了 UTF-8、UTF-16、UTF-32 三种主要实现方式。

UTF-8 是变长编码,英文 1 字节,拉丁文、希腊文等 2 字节,中文 3 字节,一些生僻字和 emoji 4 字节。编码规则很直观:1 字节 0xxxxxxx;2 字节 110xxxxx 10xxxxxx;3 字节 1110xxxx 10xxxxxx 10xxxxxx;4 字节 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx。拿“中”来说,码点 0x4E2D 的二进制是 01001110 00101101,填入 3 字节模板后得到 11100100 10111000 10101101,也就是 E4 B8 AD。

UTF-16 则是把码点分成 16 位为一个单元。基本平面里的字符用 1 个单元表示;超出基本平面的字符用一对代理码元(Surrogate Pair)表示。所以 JavaScript 内部用 UTF-16 存字符串,处理 emoji 时经常遇到surrogate问题。UTF-16 还有 BOM(字节序标记):FE FF 表示大端,FF FE 表示小端。UTF-8 的 BOM 是 EF BB BF,虽然 UTF-8 不需要 BOM,但 Windows 记事本老爱加。

2.3 UTF-16 里“高8位或低8位为0”的坑

热搜里有一个很有意思的问题:在 UTF-16 中,是否存在高 8 位或低 8 位为 0 的有效编码?存在,而且很常见。比如英文字母A的码点是 U+0041,UTF-16BE 写成两个字节就是00 41,高 8 位是 0;UTF-16LE 则是41 00,低 8 位是 0。所有码点小于 0x0100 的字符都有这个问题。

这个细节在 C/C++ 里特别致命。如果你用wchar_t*存了一个 UTF-16 或 UTF-32 宽字符串,然后把它强转成char*去按字节处理,中间那些 0 会被当成字符串结束符\0。我见过一个跨平台通信模块,Windows 上通过char*发送宽字符串,发到 Linux 端一收就断了,排查了半天,发现是明明想发 UTF-16 的二进制数据,却用了 C 字符串的发送函数,遇到00就截断。

正确做法是永远不要从字符串类型直接转字节指针去处理二进制数据。C++ 里用std::string存二进制也要小心,它不处理 NUL 字符,但可以存储。要转换成 UTF-8 明文再加结束符,再传输。

2.4 中文乱码的现场:GBK 与 UTF-8 互转的完整流程

我自己的习惯是:拿到一个未知编码的文本文件,先file -bi看 meta 信息,再用xxd看前几十个字节,然后推断编码。下面是一次真实转码流程。

# 查看文件类型和编码猜测 file -bi old.csv # 输出 text/plain; charset=unknown-8bit 则说明类型无法识别 # 用 hexdump 查看前 32 字节 xxd -l 32 old.csv # 如果看到 d6 d0 ce c4,十有八九是 GBK # 因为 "中文" 的 GBK 编码是 d6 d0 ce c4 # 用 iconv 转成 UTF-8 iconv -f GBK -t UTF-8 old.csv -o new.csv

但iconv遇到非法字节会直接报错退出,数据量大的时候很麻烦。我更倾向用 Python 的errors='replace'做兜底。

with open('old.csv', 'rb') as f: raw = f.read() text = raw.decode('gbk', errors='replace') with open('new.csv', 'w', encoding='utf-8') as f: f.write(text)

errors='replace'会把不能解码的字节替换成�,至少能保存有效数据。把控过程时,良好习惯是:边转边统计替换数量,如果替换数量特别多,说明原始编码猜错了。

3. 传输与业务编码:URL、Base64、Ajax 请求里的字符集问题

3.1 URL 编码:那些 %E4%BD%A0 是什么意思

URL 本身不限制字符,但为了避免歧义,RFC 3986 规定 URL 只能由有限的一部分 ASCII 字符组成。中文、空格、&、=等都需要转义。转义规则是:先把字符按某种编码变成字节,再给每个字节写成%XX的形式。

JavaScript 里encodeURIComponent('你好')返回的是%E4%BD%A0%E5%A5%BD,这正是“你好”的 UTF-8 字节序列。如果一个网站用的是 GBK 编码页面,那么encodeURIComponent的结果会是%C4%E3%BA%C3。这是 URL 编码最容易被忽略的坑:同样的中文,在不同编码页面下生成的 URL 编码完全不同。后端如果固定按 UTF-8 解码,拿到的就是乱码。

后端接收 URL 参数时,应该明确指定字符集。Java 的 Tomcat 可以配URIEncoding="UTF-8",Python 的 Web 框架一般默认 UTF-8。出现中文参数乱码,先查浏览器发出的原始字节,再查服务端解码用的字符集,不要直接改代码。

3.2 Base64:不是加密,是“二进制转文本”

Base64 编码的核心思路是把每 3 个字节(24 比特)拆成 4 组,每组 6 比特,然后映射到 64 个可打印字符:A-Z、a-z、0-9、+、/,不足 3 字节时用=补齐。这样做的好处是,二进制数据可以直接塞进 JSON、XML、URL 等纯文本场景。

JWT 的 Header 和 Payload 就是 Base64URL 编码,去掉了容易产生歧义的+和/,改用-和_。图片转 Base64 放在 HTML 里当 data URI,能减少一次 HTTP 请求,但会增大体积约 33%。邮件系统里的附件传输也常用 Base64。

热搜里提到的“base64编码隐藏”,我必须多说一句:Base64 不是加密。只要拿到字符串,任何人都能解码回原文。有些人把一段命令或脚本 Base64 一下,表面上看是“隐藏”了内容,但安全工具和日志系统都会自动识别 Base64 特征。敏感信息要么不用这种弱转码,要么就真正加密。

3.3 Ajax 请求设置编码格式:前端绕不开的迷局

Ajax 请求里聊编码,主要涉及两个方向:请求发送时怎么编码,响应回来怎么编码。

现代浏览器里fetch和axios发送 JSON 时,默认按 UTF-8 编码,Content-Type: application/json;charset=UTF-8。如果后端接口比较老旧,非要收 GBK 编码的表单,前端就得先把字符串转成 GBK 字节再发送。但浏览器原生 API 没有字符串到 GBK 的编码器,可以用第三方库iconv-lite,或者先用TextEncoder('gbk')试,不行就换iconv-lite。实际项目中,我建议让后端改成兼容 UTF-8,而不是前端凑合。

响应回来的数据也有编码问题。旧的 Java 接口可能返回text/html; charset=GBK的 JSON 字符串,fetch默认按 UTF-8 解码,结果中文全乱。处理方式是先拿arrayBuffer()原始字节,再用TextDecoder('gbk')手动解码。

const res = await fetch('/api/old'); const buf = await res.arrayBuffer(); const text = new TextDecoder('gbk').decode(buf); const data = JSON.parse(text);

这个方法很有用。TextDecoder还支持fatal参数,遇到非法字节直接抛错,方便在调试阶段发现问题。

3.4 “地理编码”和“业务编码”:编码在不同领域的含义

热搜里还有“地理编码”,跟字符编码完全不是一回事。地理编码(Geocoding)是把地名、地址转换成经纬度坐标,反向地理编码是坐标转地址。它们只是借用了“编码”这个词,表示“从一种信息形式映射成另一种形式”。

类似的还有“土地利用编码”这类业务编号。很多业务系统会给行政区划、地块、设备、工单分配一套编码规则,比如“省+市+区+类型+序号”。这类编码的要点是:具有业务意义,能快速定位对象,但一旦规则定错,后期扩展很痛苦。我负责过一个项目,编码最早只留了两位序号,客户数据量一涨就用完了,最后只能重构。

所以遇到“编码”两个字,先想清楚是字符编码、数值编码、压缩编码,还是业务编码。解决问题的路径完全不同。

4. 数值表示与硬件/压缩编码:补码、浮点数、七段数码管、哈夫曼与 LZW

4.1 原码、反码、补码:负数为什么非要绕一圈

如果计算机直接用原码表示负数,会出现两个问题:一是 0 有两种表示法00000000和10000000;二是加减法电路需要分别处理符号位,非常麻烦。补码解决了这两个问题。

补码的定义是:正数和原码一样,负数的补码等于对应正数取反再加 1。8 位数字里,-1的补码是11111111,-128的补码是10000000。用补码做加法,比如1 + (-1),二进制是00000001 + 11111111 = 00000000,结果天然正确。

所以计算机内部存储有符号整数,绝大多数用的是补码。这也是为什么int的范围负数比正数多 1:-2147483648到2147483647。最高位是符号位,但不仅表示符号,还参与运算。理解了这个,再看 C 语言里-1 >> 1为什么在某些编译器里还是负数,就不会懵了:算术右移会带上符号位扩展。

4.2 浮点数:0.1 + 0.2 为什么等于 0.30000000000000004

浮点数遵循 IEEE 754 标准,用科学计数法的二进制版本表示。一个 32 位 float 由 1 位符号、8 位指数、23 位尾数组成;64 位 double 是 1 位符号、11 位指数、52 位尾数。

问题出在十进制小数转二进制不一定能写尽。0.5 是0.1,0.25 是0.01,但 0.1 换算成二进制是一个无限循环小数:0.0001100110011001100...。计算机只能用有限尾数去近似存它,所以0.1 + 0.2的结果不是精确的 0.3。

实际开发里,不要用浮点数直接比较是否相等,而是用Math.abs(a - b) < 1e-9这种绝对误差或相对误差判断。涉及金额结算,永远用十进制表示,比如整数“分”,或者在数据库里用DECIMAL。

大端和小端同样会影响浮点数的解释。如果把一个 float 的四个字节按不同字节序读出来,数值完全不一样。网络传输标准统一用大端(网络字节序),本机与网络之间转换时用ntohl/htonl这类函数。

4.3 七段数码管与编码电机:硬件里的编码表

嵌入式领域里的“编码”更具体。七段数码管有 a、b、c、d、e、f、g 七个段,加上小数点 dp,一共 8 个 LED。要让数码管显示数字 0,需要让 a、b、c、d、e、f 亮,g 灭。如果按 abcdefg 的顺序对应二进制位,共阴极数码管显示 “0” 的编码是0x3F,显示 “1” 是0x06。

每个型号都要查器件手册确认是共阴极还是共阳极,共阳极的编码正好相反。我一开始没注意,把共阴极的段码表用在共阳极数码管上,结果数字完全亮反。这种编码表本质上还是“字符到数字”的映射,只是输出目标变成了引脚电平。

编码电机,也叫旋转编码器,是另一类东西。它把电机轴的角度或圈数转换成脉冲信号。增量式编码器通过 A、B 两相脉冲的相位关系判断方向;绝对式编码器输出二进制码或格雷码,直接对应绝对位置。格雷码的特点是相邻两个数只有一位变化,可以避免机械位置定位不准时读出乱码。这再次说明,同一个词在不同硬件场景下含义完全不同,看准上下文很重要。

4.4 哈夫曼编码与 LZW 编码:压缩的本质也是编码

哈夫曼编码不是给字符指定固定编号,而是根据字符出现频率分配不同长度的二进制码。出现频率越高的字符,编码越短;频率越低,编码越长,而且是前缀码,任何一个编码都不是另一个编码的前缀,防止解码歧义。

举个例子,一段文本里 A 出现 5 次,C 出现 2 次,B 出现 1 次。构建哈夫曼树之后,A 可以编码成0,C 编码成10,B 编码成11。原来每个字符固定用 8 比特,现在总共可以节省很多位。ZIP、JPEG 里都要用到哈夫曼编码。

LZW 编码的思路完全不同,它用动态构建的字典,把重复出现的字符串替换成字典索引。GIF 图片格式的核心就是 LZW。这个算法不需要预先知道文本统计信息,边读边建字典。有一点要注意:压缩编码是为了减小体积,不是加密,也不具备安全性。

4.5 LDPC 与纠错编码:通信里的“编码”

热搜里出现 LDPC,这属于纠错编码领域。信道传输会引入噪声,可能把 0 变成 1。纠错编码通过在原始数据里加入冗余校验位,让接收端能检测甚至纠正部分错误。

LDPC(低密度奇偶校验码)靠稀疏校验矩阵实现接近理论极限的纠错能力,广泛用在无线通信、光纤传输和 SSD 存储上。常见的奇偶校验就是最简单的检错:一组数据里保证 1 的个数是奇数还是偶数,接收端数一数就知道有没有出错,但不知道错在哪一位。Turbo 码、LDPC、Polar 码则是更复杂的纠错编码。

这个方向的“编码”,其实还是在做“信息到比特”的映射,不过多了一个约束条件:在干扰下依然能还原信息。

5. 高频踩坑与排查清单

5.1 网页乱码的正确排查顺序

前端页面出现中文乱码,第一步不是改 HTML,而是先确认真实字节和字符集声明。一个完整排查链是:

  1. 用curl -I看服务端返回的Content-Type有没有charset。
  2. 看 HTML 源码里的<meta charset="...">。
  3. 用file -bi或xxd看文件真实编码。
  4. 检查数据库连接参数,比如 MySQL 的characterEncoding和 JDBC URL。

只有源文件是 UTF-8,页面声明 UTF-8,服务端也返回 UTF-8,数据库连接也是 UTF-8,整条链路才不会乱。中间任何一个环节给出 GBK,就会乱。

我自己就踩过:页面声明 UTF-8,数据库也用的 UTF-8,但服务端框架把 HTTP 响应头写成ISO-8859-1,浏览器按拉丁字符解析,中文全部变成问号。这种问题光改 HTML 没用,必须改响应头。

5.2 文件批量转 UTF-8 的命令与工具

日常处理文本转码,我有几个固定用法。

# 查看编码 file -bi config.ini # 转编码,原始编码 GBK,目标 UTF-8 iconv -f GBK -t UTF-8 config.ini -o config.utf8.ini # 去掉 UTF-8 BOM sed -i '1s/^\xEF\xBB\xBF//' config.utf8.ini

Windows 下,PowerShell 可以用Get-Content配合Set-Content -Encoding UTF8批量转。VS Code 低栏会显示当前文件编码,点一下就能“通过编码重新打开”或“通过编码保存”,后端排查时这个功能非常好用。

Python 里还有一个细节:open()如果指定了encoding='utf-8',默认是不会写 BOM 的。如果 Excel 识别 UTF-8 文件失败,可以加utf-8-sig,它会自动写入 BOM,Windows 软件更容易识别。

5.3 C++、Java、Python 对编码的处理差异

C++ 的std::string只存字节,不关心编码。你用 UTF-8 存中文没问题,但用strlen()得到的是字节数不是字符数。C++11 引入了u8"..."字符串前缀、char16_t、char32_t,但跨平台处理 Unicode 依然要小心。

Java 的String内部是 UTF-16,char是一个 16 位单元,处理 emoji 时要用codePointAt而不是charAt。如果读文件时没有正确指定输入编码,new String(bytes)会按平台默认编码解码,Windows 上通常是 GBK,Linux 是 UTF-8,同样代码换环境就出错。

Python 3 相对友好,str是 Unicode 字符串,bytes是已编码的字节序列。但encode('utf-8')和decode('gbk')一旦用错,照样报UnicodeDecodeError。最常见的一个问题:爬虫拿到的是 GBK 页面,直接response.text时会自动猜测,猜错就乱码,正确做法是response.content拿字节,再指定编码解码。

5.4 “编码规则检查”不只是字符集,还指代码风格

像PEP8 编码风格、HDL Designer 编码规则检查,这里的“编码”指的是代码书写规范,不是字符集。很多开发流水线会集成编码规则检查器,比如 Python 的flake8、Verilog 的 lint 工具,目的是把代码风格、命名、可维护性统一起来。

做个区分:

  • 字符编码:字符 ↔ 数字 ↔ 字节。
  • 数值编码:数字 ↔ 补码/浮点数/二进制。
  • 压缩编码:数据 ↔ 更短的比特流。
  • 业务编码:对象 ↔ 规则化编号。
  • 代码编码风格:人 ↔ 统一书写习惯。

我和同事调试问题的时候,经常发现“编码”这个词被用在完全不同的地方,导致双方一开始都在各说各话。点破这一层之后,沟通效率立刻提升。

5.5 实战总结:三件套定位问题

最后分享一个排查编码问题非常好用的“三件套”思路:先看字节,再看声明,最后看链路。

xxd看字节能告诉你事实,file能给你一个猜测,iconv或 Python 的decode能帮你做转换验证。遇到乱码不要慌,先记录真实字节,再反向推断编码。只要你手上有字节,没有解不出来的文本;麻烦的是那些提前被错误解码、已经替换成?或�的数据,那可真的回不去了。

我个人的体会是,编码问题百分之八十都是“声明与实际不符”:页面声明 UTF-8,文件其实是 GBK;数据库连接用错的字符集;HTTP 响应头漏了 charset。只要坚持“一切以字节为准,重转换、轻猜测”,绝大多数乱码都能在几分钟内定位。希望这篇梳理能让你少走几条弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询