前言
二进制数据"乱码"的表现形式五花八门,但症状往往高度一致:上传的图片存进数据库再取出来就打不开了,用十六进制编辑器一看,开头多了三个字节;一个本来能正常解析的压缩包,经过一次"顺手写的转码"后gzdecode报错;接口返回的 JSON 里带了个二进制字段,结果整个响应体变成了空白页。
这些故障的根因可以用一句话概括:二进制数据被当成了文本处理。PHP 的字符串本质上就是字节数组(byte array),它本身没有字符集概念,这就给了二进制数据一个天然的容器;但同时,PHP 又提供了一大批"会悄悄做编码转换"的函数——mb_*系列、htmlspecialchars()、json_encode()、部分字符串函数,还有数据库连接的字符集设置。任何一次不必要的转换,都会破坏字节的完整性,而这种破坏常常是静默的。
本文按"先分类、再防护"的顺序讲:搞清三种性质完全不同的乱码、选对二进制安全的编码容器、在数据库与文件读写里保住字节。文中示例需要 PHP 8.0 及以上,涉及版本差异的地方会单独标明。
一、先把"乱码"分成三类
三类问题的现象相似,解法完全不同,混在一起查会绕远路:
| 类型 | 触发点 | 典型症状 | 是否丢数据 |
|---|---|---|---|
| 编码转换型 | mb_convert_encoding、htmlspecialchars、连接字符集 | 非法字节被替换、字节数变化 | 会,且不可逆 |
| 函数误用型 | 用mb_*处理二进制、用substr截断多字节文本 | 长度对不上、截断处出现半个字符 | 会 |
| 展示型 | 输出到浏览器或终端时的字符集不符 | 明明字节完好,显示成问号或方块 | 不会 |
第三类最容易让人误判:数据本身是好的,只是显示环境不对。判断方法很简单——比对字节数。用strlen()和bin2hex()在"存之前"和"取之后"各打印一次,字节完全一致就说明数据没坏,问题在展示层。
<?php // 判断二进制数据是否在流转中受损:比对字节数与十六进制 declare(strict_types=1); $raw = random_bytes(8); echo 'len = ', strlen($raw), PHP_EOL; echo 'hex = ', bin2hex($raw), PHP_EOL; echo 'sha256 = ', hash('sha256', $raw), PHP_EOL; // 故意做一次"看起来无害"的 UTF-8 转换:哈希与字节数都会变 $mangled = mb_convert_encoding($raw, 'UTF-8', 'UTF-8'); echo 'mangled len = ', strlen($mangled), PHP_EOL; echo 'mangled sha256 = ', hash('sha256', $mangled), PHP_EOL;二、选对容器:hex、base64 与 pack/unpack
二进制数据要跨越"只认文本"的边界(URL、JSON、Cookie、日志、纯文本配置),就必须换一种表示法。三种常用容器各有定位:
| 容器 | 体积膨胀 | 可读性 | 典型用途 |
|---|---|---|---|
bin2hex()/hex2bin() | 2 倍 | 高,便于人工比对 | 短标识、调试输出、哈希比对 |
base64_encode()/base64_decode() | 约 1.33 倍 | 低 | 传输、存文本字段、URL 参数 |
pack()/unpack() | 无膨胀 | 无 | 定长结构、协议报文、文件头 |
base64_decode()一定要开严格模式,否则遇到被污染的内容会静默返回错误的结果:
<?php // 需要 PHP 8.0 及以上 declare(strict_types=1); $raw = random_bytes(32); $b64 = base64_encode($raw); // 用于传输/存储 $hex = bin2hex($raw); // 用于日志/比对 echo base64_decode($b64, true) === $raw ? "base64 往返成功\n" : "base64 往返失败\n"; echo hex2bin($hex) === $raw ? "hex 往返成功\n" : "hex 往返失败\n"; // URL 安全的变体(base64url):替换 +/ 并去掉填充,之后记得补回填充再解码 $urlSafe = rtrim(strtr($b64, '+/', '-_'), '='); $padded = str_pad($urlSafe, (int) (ceil(strlen($urlSafe) / 4) * 4), '=', STR_PAD_RIGHT); echo base64_decode($padded, true) === $raw ? "base64url 往返成功\n" : "base64url 往返失败\n";pack()与unpack()是处理定长二进制结构的正解。下面这个例子打包了一个 16 字节的报文头,格式为:4 字节魔数、1 字节版本、2 字节标志(大端)、1 字节保留位、4 字节负载长度,后面跟 4 字节负载:
<?php // pack_demo.php —— 需要 PHP 8.0 及以上 declare(strict_types=1); $payload = "\x00\xff\x10\x80"; // 故意包含不可打印字节与 0xFF // a4 = 4 字节字符串(不足补 NUL)、C = 无符号字符、n = 大端 16 位、N = 大端 32 位 $packet = pack('a4CnCN', 'HDR1', 2, 1, 0, strlen($payload)) . $payload; echo 'total = ', strlen($packet), PHP_EOL; echo 'hex = ', bin2hex($packet), PHP_EOL; // 解析:每个格式码后跟一个名字,用 / 串起来 $head = substr($packet, 0, 12); $info = unpack('a4magic/Cversion/nflags/Creserved/Nlength', $head); print_r($info); $body = substr($packet, 12); echo 'payload hex = ', bin2hex($body), PHP_EOL; echo 'payload len ok = ', var_export(strlen($body) === $info['length'], true), PHP_EOL;输出是确定的,可以拿来对照:
total = 16 hex = 48445231020001000000000400ff1080 Array ( [magic] => HDR1 [version] => 2 [flags] => 1 [reserved] => 0 [length] => 4 ) payload hex = 00ff1080 payload len ok = true有几个细节值得记住:a与A的区别在于A会去掉尾部的空格和 NUL,处理真正的二进制字段要用a;n/N是大端,v/V是小端,协议里写错字节序是最常见的低级错误,而它造成的现象就是"值大得离谱"或"完全对不上"。unpack()返回的是关联数组,键名由你指定,所以永远不要依赖返回顺序。
三、在数据库与文件读写里保住字节
数据库这一侧,原则是"用二进制类型存二进制,用文本类型存文本"。把二进制塞进TEXT/VARCHAR字段,会撞上两件事:MySQL 会按该列的字符集校验传入的字节序列,非法序列要么报错要么被截断;即使侥幸存进去,一次ALTER TABLE ... CONVERT TO CHARACTER SET迁移就可能把字节改得面目全非。正确做法是用BLOB/VARBINARY,或者干脆存 base64 文本(体积涨 1/3,但可读、可搜索、跨库迁移安全)。
<?php // 需要 PHP 8.0 及以上,pdo_mysql 扩展 declare(strict_types=1); $pdo = new PDO('mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4', 'app', 'secret', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); $pdo->exec('CREATE TABLE IF NOT EXISTS blobs ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, data LONGBLOB NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); $raw = random_bytes(1024); // 写法一:小数据直接绑定字符串 $stmt = $pdo->prepare('INSERT INTO blobs (name, data) VALUES (?, ?)'); $stmt->execute(['bin-' . bin2hex(random_bytes(4)), $raw]); // 写法二:大文件用流绑定,避免把整个文件读进内存 $fp = fopen('php://temp', 'r+b'); fwrite($fp, $raw); rewind($fp); $stmt = $pdo->prepare('INSERT INTO blobs (name, data) VALUES (:n, :d)'); $stmt->bindValue(':n', 'stream'); $stmt->bindParam(':d', $fp, PDO::PARAM_LOB); $stmt->execute(); // 校验:取回来必须与写入时逐字节一致 $got = $pdo->query('SELECT data FROM blobs ORDER BY id DESC LIMIT 1')->fetchColumn(); echo strlen($got) === strlen($raw) ? "长度一致\n" : "长度不一致\n"; echo hash('sha256', $got) === hash('sha256', $raw) ? "内容一致\n" : "内容已被改变\n";查字节长度时用LENGTH()(字节)而不是CHAR_LENGTH()(字符),这也是一个一眼区分"存的是字节还是文本"的小技巧:
SELECT id, name, LENGTH(data) AS bytes FROM blobs ORDER BY id;文件与 HTTP 输出这一侧,重点是别让任何一层替你做转换:
- 打开文件用
fopen($path, 'rb'),Windows 上漏掉b会让换行字节被改写。 - 大文件用
stream_copy_to_stream()直传,不要file_get_contents()全读进内存。 - 输出前确认没有任何输出,
header('Content-Type: application/octet-stream')和Content-Length(字节数,用strlen())必须发在任何内容之前。 - 源码文件不要带 BOM:
\xEF\xBB\xBF会在第一个字节就被输出出去,紧接着header()就会报headers already sent,而这个报错信息和"二进制输出"看起来毫不相关。
<?php // 需要 PHP 8.0 及以上:以附件形式下发二进制内容 declare(strict_types=1); $data = random_bytes(256); $hash = hash('sha256', $data); if (headers_sent($file, $line)) { // 一旦已经有输出,header() 必然失败,这里提前发现自己埋的雷 throw new RuntimeException("已有输出,无法发送响应头,位置: {$file}:{$line}"); } header('Content-Type: application/octet-stream'); header('Content-Length: ' . strlen($data)); // 字节数,不是字符数 header('X-Content-SHA256: ' . $hash); echo $data;常见坑点
- ❌ 顺手对二进制数据调用
mb_convert_encoding($bin, 'UTF-8', 'UTF-8')当作"清洗"
✅ 它是不可逆的:非法字节会被替换成替换字符,字节数随之变化。要校验请用mb_check_encoding()只做判断,不做转换
- ❌ 把二进制塞进
TEXT/VARCHAR字段,或存进utf8mb4的表指望"反正也是字符串"
✅ 用BLOB/VARBINARY存原始字节,或存 base64 文本;连接字符集设成utf8mb4是对的,但别指望它保护二进制列
- ❌ 对二进制调用
htmlspecialchars()做转义
✅ PHP 8.0 及以前遇到非法 UTF-8 会直接返回空字符串(输出凭空消失);PHP 8.1 起默认 flags 带了ENT_SUBSTITUTE,会替换成替换字符,但字节已经丢了。二进制内容要转义时应先 base64
- ❌ 把二进制直接塞进
json_encode(),再用json_decode()取回来
✅ 非法 UTF-8 会让json_encode()返回false(JSON_ERROR_UTF8),整个响应变成空。先base64_encode()或bin2hex(),需要排查错误时配合JSON_THROW_ON_ERROR
- ❌ 用
mb_strlen()/mb_substr()去量二进制数据的长度和切片
✅ 二进制场景一律用strlen()和substr(),它们是字节安全的;mb_*只处理已知编码的文本
- ❌ 用
Content-Length:+ 字符串长度,但把数据当字符数算,或者依赖mb_strlen()
✅Content-Length是字节数,用strlen();下载截断或缺字节多半就是这里算错了
- ❌ 源码文件带 UTF-8 BOM,或者在输出二进制之前有空格、空行、
?>之后的多余字符
✅ 源码不带 BOM,纯 PHP 文件省略结尾的?>,输出前用headers_sent()自检
- ❌ 用
mb_detect_encoding()去猜二进制数据的编码并据此转换
✅ 它只是启发式猜测,对二进制毫无意义;来源编码应当是已知信息(协议约定、数据库列定义),不该猜
总结
| 场景 | 危险做法 | 安全做法 |
|---|---|---|
| 跨文本边界传输 | 直接拼进 JSON/URL | base64_encode()/bin2hex() |
| 解析定长结构 | 手写substr偏移 + 位运算 | pack()/unpack()声明格式 |
| 数据库存储 | TEXT列存原始字节 | BLOB/VARBINARY或 base64 文本 |
| 长度计算 | mb_strlen() | strlen()(字节) |
| 转义与序列化 | htmlspecialchars()/ 裸json_encode() | 先 base64 再交给文本层 |
| 文件读写 | fopen($f, 'r')、整体读入内存 | fopen($f, 'rb')+stream_copy_to_stream() |
| 输出下载 | 中途 echo、忘算Content-Length | 先headers_sent()自检,用strlen()算长度 |
避免二进制乱码的核心只有一条心法:先想清楚这段数据是"字节"还是"文本",然后只在"文本"的世界里做编码转换。二进制数据在 PHP 内部就应该一路保持为原始字符串,只在必须跨界的瞬间才换成 base64 或 hex;进了数据库就用二进制列。守住这条线,绝大多数"取出来就不一样了"的问题根本不会发生。