前言
unpack()用来从一段二进制字符串里按指定的字节布局解出各个字段,返回一个关联数组。它和pack()是一对:pack()把 PHP 值打包成二进制串,unpack()把它拆回来。凡是涉及「自己定义二进制协议」「读取带文件头的二进制格式」的场景,都绕不开这一对函数。
三个最常见的误解:
- 以为它的格式串和
pack()不是一套。两者完全相同,手册在unpack()的参数说明里直接写着「格式码的解释参见pack()」。学会一套就够。
- 以为不写字段名时数组下标从 0 开始。恰恰相反,PHP 手册明确写着:不给元素命名时,会使用从 1 开始的数字下标。更麻烦的是,每个未命名的元素都从 1 重新编号,混用多种未命名格式码时,后面的值会覆盖前面的值。
- 以为
unpack()能做编码转换。它只管按字节布局拆数据,不做 UTF-8 ↔ GBK 之类的转码;中文文本要用mb_convert_encoding()/iconv()处理。
本文按官方手册讲清unpack()的签名、格式串语法(尤其三种字符串格式码的区别)、字节序的取舍,以及越界解包的问题。
一、签名与格式串语法
unpack(string $format, string $string, int $offset = 0): array | false
$format:格式串,由「格式码 + 可选重复次数 + 可选字段名」组成,多个字段之间用/分隔。
$string:要拆的二进制数据。
$offset:从第几个字节开始解,PHP 7.1.0 起才有这个参数。手册另外注明,自 PHP 7.2.0 起float与double类型同时支持大端和小端。
格式串的构成规则:
格式码 [重复次数] [字段名] / 格式码 [重复次数] [字段名] / ...
<?php // 适用于 PHP 8.0+
$data = "\x04\x00\xa0\x00";
// 每个字段都起名:得到一个以名字为键的关联数组
$named = unpack('cchars/nint', $data);
print_r($named);
// 带重复次数:键名后面会自动补上序号
$repeated = unpack('c2chars/nint', $data);
print_r($repeated);
Array
(
[chars] => 4
[int] => 160
)
Array
(
[chars1] => 4
[chars2] => 0
[int] => 40960
)
注意重复次数放在字段名之前:c2chars表示「两个c,名字前缀是chars」,得到chars1、chars2。
二、格式码速查
下面这张表来自pack()的官方格式码表,unpack()完全适用:
Z | NUL 结尾(ASCIIZ)字符串,做 NUL 填充 |
s/S | 有符号短整型 / 无符号短整型(16 位,机器字节序) |
v/n | 无符号短整型(16 位,小端 / 大端字节序) |
i/I | 有符号整型 / 无符号整型(长度与字节序都依赖机器) |
l/L | 有符号长整型 / 无符号长整型(32 位,机器字节序) |
V/N | 无符号长整型(32 位,小端 / 大端字节序) |
q/Q | 有符号 / 无符号长长整型(64 位,机器字节序) |
P/J | 无符号长长整型(64 位,小端 / 大端字节序) |
三种字符串格式码的区别
这是最容易记混的一组,手册特别说明了它们对尾部字节的处理:
A | 去掉所有尾随的 ASCII 空白(空格、制表符、换行、回车、NUL) |
Z | 去掉尾随的 NUL 字节(专门为 NUL 结尾的 C 字符串设计) |
所以:读定长文本字段用A(自动去填充),读 C 风格字符串用Z,需要拿到原始字节(包括填充)才用a。
三、字节序:为什么协议里只用固定端序的码
unpack('s', ...)、unpack('l', ...)、unpack('f', ...)、unpack('d', ...)、unpack('i', ...)这些码是机器字节序的——同一份数据在不同 CPU 架构上会解出不同的值。做跨机通信的协议时,这几乎一定会出事。
固定字节序的码才有互操作性:
网络协议(TCP 报文头等)一律用大端,所以解析时优先选n/N/J/E/G。
四、实战示例
1. 解析 PNG 的文件签名
PNG 文件固定以 8 个字节开头,十进制是137 80 78 71 13 10 26 10:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
$path = __DIR__ . '/image.png';
$fp = fopen($path, 'rb');
if ($fp === false) {
exit("无法打开文件\n");
}
$header = fread($fp, 8);
fclose($fp);
if (strlen($header) !== 8) {
exit("文件太短,不是有效图片\n");
}
$bytes = unpack('C8', $header);
// 不带字段名时键从 1 开始
var_dump($bytes);
$isPng = $bytes === [1 => 137, 2 => 80, 3 => 78, 4 => 71, 5 => 13, 6 => 10, 7 => 26, 8 => 10];
echo $isPng ? "这是 PNG\n" : "不是 PNG\n";
2. 解析自定义二进制包头
假设协议规定:2 字节魔数、1 字节版本、2 字节大端长度、随后是载荷:
<?php // 适用于 PHP 8.0+
declare(strict_types=1);
function parsePacket(string $raw): array
{
$headLen = 5; // 2 + 1 + 2
if (strlen($raw) < $headLen) {
throw new RuntimeException('数据不足一个包头');
}
// 每个字段都命名,键名清晰且不会互相覆盖
$head = unpack('a2magic/Cversion/nlength', $raw);
if ($head === false) {
throw new RuntimeException('包头解析失败');
}
// a2 读的是原始字节,逐字节比较更稳妥
if ($head['magic'] !== 'PK') {
throw new RuntimeException('魔数不匹配');
}
$payload = substr($raw, $headLen, $head['length']);
if (strlen($payload) !== $head['length']) {
throw new RuntimeException('载荷长度不足');
}
return [
'magic' => $head['magic'],
'version' => $head['version'],
'length' => $head['length'],
'payload' => $payload,
];
}
// 构造一段测试数据:'PK' + 版本 1 + 长度 3(大端 0x0003)+ 'abc'
$raw = 'PK' . "\x01" . "\x00\x03" . 'abc';
var_dump(parsePacket($raw));
3. 用$offset跳过文件头解析浮点数组
<?php // 适用于 PHP 8.0+,$offset 参数需要 PHP 7.1+
declare(strict_types=1);
// 8 字节头 + 3 个单精度浮点(小端)
$binary = str_repeat("\x00", 8)
. pack('g3', 1.5, -2.25, 3.75);
// PHP 7.1 起可以直接用第三个参数,不用先 substr
$values = unpack('g3v', $binary, 8);
var_dump($values);
// array(3) { ["v1"]=> float(1.5) ["v2"]=> float(-2.25) ["v3"]=> float(3.75) }
在 PHP 7.1 之前的版本上,等价写法是unpack('g3v', substr($binary, 8))。
常见坑点
- ❌ 不给字段命名,以为数组下标从 0 开始 —— 手册写明索引从 1 开始。✅ 每个字段都起名字:
unpack('C4flags', $data)得到flags1到flags4。
- ❌ 写成
unpack('c2/n', $data)这种混用未命名字段的形式 —— 每个未命名元素都从 1 重新编号,后面的值会覆盖前面的。手册给出的例子中,unpack("c2/n", "\x32\x42\x00\xa0")的结果只剩两个元素,第一个c的值被n的值覆盖掉了。✅ 每个字段都命名,例如unpack('c2lo/nhi', $data)。
- ❌ 用
s、l、i、f、d这些机器字节序的码去解析网络数据 —— 同一段字节在大端机和小端机上解出不同的值。✅ 协议字段一律用固定字节序的码:n/N/J/G/E(大端)或v/V/P/g/e(小端)。
- ❌ 把解出来的「无符号数」一律当正数用 —— 手册用 Caution 提示,PHP 内部整型是有符号的,解出一个与 PHP 整型同宽的无符号值时,高位为 1 就会得到负数。✅ 32 位值用
N/V在 64 位 PHP 上可以安全表示;64 位无符号数超出PHP_INT_MAX时改走 GMP(gmp_import(),需要 GMP 扩展)。
- ❌ 用
unpack()解文本编码 —— 它按字节布局工作,不做任何字符集转换,直接拿去显示中文会是乱码。✅ 用mb_convert_encoding()/iconv()转码;固定长度的文本字段可以先用A或Z取出再转。
- ❌ 不校验长度就解包 —— 数据不足时结果不可靠(返回
false或伴随告警,具体诊断在不同版本上不完全一致),解析器会静默产出错误数据。✅ 先算清「这个格式串需要多少字节」,用strlen()校验;用$offset时也要保证$offset + 需要字节数 <= strlen($string)。
- ❌ 在需要兼容 PHP 7.1 以下的项目里直接用第三个参数 ——
$offset参数是PHP 7.1.0才加入的。✅ 老版本改用substr($string, $offset)再解包。
- ❌ 把
a、A、Z三者的尾部处理搞混 ——a保留尾随 NUL,A去掉尾随空白(空格、制表、换行、回车、NUL),Z去掉尾随 NUL。✅ 按字段语义选:定长文本用A,NUL 结尾的 C 字符串用Z,需要原始字节才用a。
总结
| 签名 | `unpack(string $format, string $string, int $offset = 0): array \ |
$offset | PHP 7.1.0 起提供;老版本用substr()代替 |
| 格式串 | 与pack()完全一致:格式码[重复次数][字段名],多字段用/分隔 |
| 键名 | 未命名时索引从 1 开始,且多组未命名会互相覆盖,务必逐个命名 |
| 字节序 | s/l/i/f/d依赖机器;协议数据只用n/N/J/E/G(大端)或v/V/P/e/g(小端) |
| 字符串码 | a保留尾部 NUL,A去尾部空白,Z去尾部 NUL |
| 有符号性 | PHP 整型有符号,解出同宽无符号值时可能为负;64 位大数走 GMP |
| 编码 | 不做转码,中文要额外用mb_convert_encoding()处理 |
一句话总结:unpack()的全部风险集中在「字节布局」这四个字上——格式码要和数据源的布局逐字节对齐,字段一律命名,协议数据只用固定字节序的码,解包前先校验长度,做到这几点,它就是一个可靠而精确的二进制解析工具。