进阶学习 PHP 中的二进制和位运算

在实际项目中,经常需要大量依赖 PHP 中的位运算操作。从读取二进制文件到模拟处理器,这是一项非常有用的知识,而且也非常酷。

PHP 提供了许多工具来支持你处理二进制数据,但需要从一开始就注意:如果你追求极致的底层效率,PHP 并不是最佳选择。

不过请耐心看下去!本文将展示关于位运算、二进制和十六进制处理的非常有价值的内容,这些在任何语言中都会有用。

原文链接 进阶学习 PHP 中的二进制和位运算

为什么 PHP 可能不是最佳选择

PHP 能优雅地处理比想象中更多的情况。但在需要非常高效地处理二进制数据的场景下,PHP 确实存在局限性。

说清楚一点:这里指的不是应用程序多消耗 5 或 10MB 内存的问题,而是精确分配必要的内存量来保存特定数据类型的能力。

根据官方文档关于整数的说明,PHP 使用 integer 类型来表示十进制、十六进制、八进制和二进制。所以无论你放什么数据进去,它始终都是一个整数。

你可能听说过 ZVAL,它是表示每个 PHP 变量的 C 结构体。它有一个字段用于表示所有整数,叫做 zend_long。如你所见,zend_long 的类型是 lval,其大小取决于平台:在 64 位平台上,它被表示为 64 位整数,而 32 位平台则表示为 32 位整数。

c 复制代码
// zval 将每个整数存储为 lval
typedef union _zend_value {
  zend_long lval;
  // ...
} zend_value;

// lval 是一个 32 或 64 位整数
#ifdef ZEND_ENABLE_ZVAL_LONG64
 typedef int64_t zend_long;
 // ...
#else
 typedef int32_t zend_long;
 // ...
#endif

底线是:无论你需要存储 0xff0xffff0xffffff 还是其他什么,它们在 PHP 中都将被存储为 32 或 64 位的 long(lval)。

例如,在微控制器模拟项目中。虽然正确处理内存和操作是必须的,但并不真的需要那么高的内存效率,因为主机在数量级上就能弥补这一点。

当然,当涉及到 C 扩展或 FFI 时,一切都会改变,但这里讨论的是纯 PHP。

所以请记住这一点:它能工作,并且可以实现你想要的所有行为,但在大多数情况下,类型不会高效地适配。

二进制和十六进制数据表示快速入门

在讨论 PHP 如何处理二进制数据之前,需要先了解一些二进制的基础知识。如果你认为自己已经了解所需的一切,请直接跳到 [PHP 中的二进制数字和字符串](#PHP 中的二进制数字和字符串) 部分。

数学中有个叫做"进制"的东西。它定义了如何用不同格式表示数量。人类通常使用十进制(base 10),它允许用数字 0、1、2、3、4、5、6、7、8 和 9 来表示任何数字。

为了使接下来的示例更清晰,本文将数字"20"称为"十进制 20"。

二进制数字(base 2)可以表示任何数字,但只使用两个不同的数字:0 和 1。

十进制 20 在二进制形式中可以表示为 0b00010100。别担心如何转换它,让机器来做这件事 😉

十六进制数字(base 16)可以表示任何数字,为此,它不仅使用十个数字 0、1、2、3、4、5、6、7、8 和 9,还使用从拉丁字母借来的六个额外字符:a、b、c、d、e 和 f。

同样的十进制 20 在十六进制中表示为数字 0x14。再次强调,不要试图在脑海中将其转换为十进制,计算机会处理!

需要理解的重点是,数字可以用不同的进制表示:二进制(base 2)、八进制(base 8)、十进制(base 10,常用的进制)和十六进制(base 16)。

在 PHP 和许多其他语言中,二进制数字像其他数字一样书写,但带有前缀 0b,比如十进制 20 表示为 0b00010100。十六进制数字带有前缀 0x,比如十进制 20 表示为 0x14

众所周知,计算机不存储字面数据。相反,它们用二进制数字表示一切:0 和 1。字符、数字、符号、指令......一切都使用 base 2 表示。字符只是数字序列的约定:例如,字符 'a' 在 ASCII 表中就是数字 97。

尽管一切都以二进制存储,但程序员阅读这些数据最方便的方式是使用十六进制。它们看起来更整洁。看看这个例子:

php 复制代码
// 字符串 "abc"
'abc'

// 二进制形式(呃)
0b01100001 0b01100010 0b01100011

// 十六进制形式(哇哦)
0x61 0x62 0x63

虽然二进制占用大量视觉空间,但十六进制非常整洁地表示二进制数据。这就是为什么我们在进行底层编程时通常坚持使用它们。

进位操作

读者应该已经熟悉进位(Carry)的概念,但需要特别关注它,以便在不同进制中使用。

在十进制中,我们有十个不同的数字来表示从零(0)到九(9)的数字。但每当我们试图表示大于 9 的数字时,我们就会用完数字!因此会发生进位操作:我们在数字前面加上数字一(1),并将右边的数字重置为零(0)。

php 复制代码
// 十进制(base 10)
1 + 1 = 2
2 + 2 = 4
9 + 1 = 10 // <- 进位

二进制会有类似的行为,但仅限于数字 0 和 1。

php 复制代码
// 二进制(base 2)
0 + 0  = 0
0 + 1  = 1
1 + 1  = 10 // <- 进位
1 + 10 = 11

十六进制也是一样,但范围要宽得多。

php 复制代码
// 十六进制(base 16)
1 + 9  = a // 没有进位,a 在范围内
1 + a  = b
1 + f  = 10 // <- 进位
1 + 10 = 11

如你所见,进位操作需要更多位数来表示某个数字。这让你能够理解某些数据类型是如何受限的,以及由于它们存储在计算机中,它们的限制以二进制形式表示。

计算机内存中的数据表示

如我之前提到的,计算机使用二进制格式存储一切。所以实际存储的只有 0 和 1。

可视化它们如何存储的最简单方法,是想象一个单行多列的大表格(列数取决于存储容量),其中每一列都是一个二进制位(bit)。

使用仅 8 位在这样的表格中表示我们的十进制 20,看起来像这样:

位置(地址) 0 1 2 3 4 5 6 7
0 0 0 1 0 1 0 0

无符号 8 位整数 是一个最多只能用 8 个二进制数字表示的数字。所以 0b11111111(十进制 255)是无符号 8 位整数可以存储的最大数字。给它加 1 将需要进位操作,无法用相同数量的数字表示。

有了这个概念,我们可以轻松理解为什么有这么多数字的内存表示,以及它们实际上是什么:uint8 是无符号 8 位整数(十进制 0 到 255),uint16 是无符号 16 位整数(十进制 0 到 65,535)。还有 uint32uint64 以及理论上更高的类型。

有符号整数也可以表示负值,通常使用最后一位来确定数字是正数(最后一位 = 0)还是负数(最后一位 = 1)。如你所想,它们在相同的内存量下能够存储更小的值。有符号 8 位整数的范围是十进制 -128 到十进制 127。

这是十进制 -20 表示为有符号 8 位整数的样子。注意它的第一位(地址 0)被设置(等于 1),这将数字标记为负数。

位置(地址) 0 1 2 3 4 5 6 7
1 0 0 1 0 1 0 0

希望到目前为止一切都说得通。这个入门对于你理解计算机内部如何工作非常重要。只有这样,你才会对 PHP 实际在底层做什么感到舒适,我们必须始终牢记这一点。

算术溢出

选择如何表示数字(8 位、16 位...)将决定它们的最小值和最大值范围。这基本上是因为它们在内存中的存储方式:将 1 加到二进制数字 1 应该导致进位操作,这意味着需要另一个位来作为实际数字的前缀。

由于整数格式定义得非常明确,因此无法依赖超出该限制的进位操作。(实际上是可能的,但有点疯狂)

位置(地址) 0 1 2 3 4 5 6 7
1 1 1 1 1 1 1 0

这里我们非常接近 8 位限制(十进制 255)。如果我们给它加 1,最终会得到十进制 255 和以下二进制表示:

位置(地址) 0 1 2 3 4 5 6 7
1 1 1 1 1 1 1 1

所有位都被设置了!再给它加 1 将需要进位操作,但这无法发生,因为我们没有足够的位:所有 8 位都被设置了!这导致了一种叫做溢出 (overflow)的情况,当你试图超出某个限制时就会发生。当你读取其 8 位结果时,二进制操作 255 + 2 应该得到 1。

位置(地址) 0 1 2 3 4 5 6 7
0 0 0 0 0 0 0 1

这种行为不是随机的,涉及计算来确定新值是什么,这里不相关就不展开了。

PHP 中的二进制数字和字符串

好了,回到 PHP!前面绕了一大圈,但这些基础知识是必要的。

到现在为止,这些概念应该已经串联起来了:二进制数字、它们如何存储、什么是溢出、php 如何表示数字...

十进制 20 在 PHP 整数中表示可能有两种不同的表示形式,具体取决于你的平台。x86 平台以这种方式表示它:

复制代码
位置:  63   ...   7  6  5  4  3  2  1  0
位:    0    ...   0  0  1  0  1  0  0  0

而 x64 平台则是这样:

复制代码
位置:  31   ...   7  6  5  4  3  2  1  0
位:    0    ...   0  0  1  0  1  0  0  0

但对于 PHP 来说,它们是完全相同的数字:整数 20。

这就带来了一个有趣的问题:我们如何处理 binary 字符串?

PHP 字符串允许我们自己定义字节。我们可以创建一个 2 字节的字符串来保存十进制 20,或者一个 8 字节的字符串。具体取决于你的需求。

二进制:整数还是字符串,在 PHP 中该用哪个?

现在来到有趣的部分!让我们动手玩一下 PHP 代码吧!

首先展示如何可视化数据。毕竟需要理解正在处理的内容。

调试整数实际上非常非常简单,可以直接使用 sprintf() 函数。它的格式化功能非常强大,可以帮助快速了解这些值是什么。

下面以 8 位二进制格式和 1 字节十六进制格式表示十进制 20。

php 复制代码
<?php
// 十进制 20
$n = 20;

echo sprintf('%08b', $n) . "\n";
echo sprintf('%02X', $n) . "\n";

// 输出:
00010100
14

格式 %08b 使变量 $n 以二进制表示(b)打印,带 8 位数字(08)。

格式 %02X 以十六进制(X)和 2 位数字(02)表示变量 $n

可视化二进制字符串

虽然 PHP 整数始终是 32 或 64 位长,但字符串的长度取决于其内容。要解码它们的二进制值并可视化发生了什么,我们需要检查并转换每个字节。

幸运的是,PHP 字符串可以像数组一样解引用,每个位置指向一个 1 字节大小的字符。这是一个如何访问字符的快速示例:

php 复制代码
<?php
$str = 'thephp.website';

echo $str[3];
echo $str[4];
echo $str[5];

// 输出:
php

相信每个字符都是 1 字节,我们可以轻松调用 ord() 函数将其转换为 1 字节整数。像这样:

php 复制代码
<?php
$str = 'thephp.website';

$f = ord($str[3]);
$s = ord($str[4]);
$t = ord($str[5]);

echo sprintf(
  '%02X %02X %02X',
  $f,
  $s,
  $t,
);
// 输出:
70 68 70

我们可以通过命令行应用程序 hexdump 进行双重检查,看看我们是否走在正确的道路上:

bash 复制代码
$ echo 'php' | hexdump
// 输出
0000000 70 68 70 ...

其中第一列仅是地址,从第二列开始我们看到表示字符 p、h 和 p 的十六进制值。

此外,在处理二进制字符串时,可以使用 pack()unpack() 函数,这里有一个很棒的例子!

假设需要读取一个 JPEG 文件来获取它的一些数据(比如 EXIF)。可以使用读取二进制模式打开文件句柄。下面是如何操作并立即读取前 2 个字节:

php 复制代码
<?php

$h = fopen('file.jpeg', 'rb');

// 读取 2 字节
$soi = fread($h, 2);

为了将这些值获取到整数数组中,我们可以简单地这样解包它们:

php 复制代码
$ints = unpack('C*', $soi);

var_dump($ints);
// 输出
array(2) {
  [1] => int(-1)
  [2] => int(-40)
}

echo sprintf('%02X', $ints[1]);
echo sprintf('%02X', $ints[2]);
// 输出
FFD8

注意 unpack() 函数中的格式 C 会将字符串 $soi 中的字符解码为无符号 8 位数字。星号修饰符 * 使其解包整个字符串。

位运算操作

PHP 实现了人们可能需要的所有位运算操作。它们以表达式的形式构建,其结果如下所述:

PHP 代码 名称 描述
`$x $y` 包含或
$x ^ $y 异或 一个值,其位在 $x$y 中设置但不会同时设置
$x & $y 一个值,其位仅在 $x$y 中同时设置
~$x 翻转 $x 中的所有位
$x << $y 左移 $x 的位向左移动 $y
$x >> $y 右移 $x 的位向右移动 $y

下面将一一解释它们是如何工作的。

假设 $x = 0x20$y = 0x30。下面的示例将使用二进制表示法使事情更清楚。

包含或(x \| y)的工作原理

包含或操作将产生一个结果,该结果包含两个输入的所有设置位。所以操作 $x | $y 必须返回 0x30。看看下面发生了什么:

复制代码
// 1 | 1 = 1
// 1 | 0 = 1
// 0 | 0 = 0

0b00100000 // $x = 0x20
0b00110000 // $y = 0x30
OR ------- // $x | $y
0b00110000 // 0x30

注意:从右到左,$x 的第 6 位被设置(等于 1),而 $y 的第 5 和第 6 位也被设置。结果合并两者并生成一个第 5 和第 6 位被设置的值:0x30

异或(x \^ y)的工作原理

异或(也称为 Xor)只会捕获仅存在于一侧的位。所以 $x ^ $y 的结果是 0x10。看下面的示例:

复制代码
// 1 ^ 1 = 0
// 1 ^ 0 = 1
// 0 ^ 0 = 0

0b00100000 // $x = 0x20
0b00110000 // $y = 0x30
XOR ------ // $x ^ $y
0b00010000 // 0x10

与(x \& y)的工作原理

AND 操作符更容易理解。它对每一位执行 AND 操作,因此只有在两侧同时匹配的值才会被检索。

$x & $y 的结果是 0x20,原因如下:

复制代码
// 1 & 1 = 1
// 1 & 0 = 0
// 0 & 0 = 0

0b00100000 // $x = 0x20
0b00110000 // $y = 0x30
AND ------ // $x & $y
0b00100000 // 0x20

非(~$x)的工作原理

NOT 操作需要单个参数,它只是翻转传递的所有位。它将所有值为 0 的位转换为 1,将所有值为 1 的位转换为 0。看下面:

复制代码
// ~1 = 0
// ~0 = 1

0b00100000 // $x = 0x20
NOT ------ // ~$x
0b11011111 // 0xDF

如果你在 PHP 中运行此操作并决定使用 sprintf() 调试它,可能会注意到一个更宽的数字。下面的 整数规范化 部分将解释发生了什么以及如何修复它。

左移和右移(x \<\< n 和 x \>\> n)的工作原理

移位操作与将数字乘以或除以 2 的倍数相同。它的作用是使所有位向左或向右移动 $n 步。

这里采用一个较小的二进制数来演示,这样更容易理解。以 $x = 0b0010 为例。如果将 $x 向左移动一次,那个位 1 应该向左移动一步:

php 复制代码
$x = 0b0010;
$x = $x << 1;
// 0b0100

右移也是一样。现在 $x = 0b0100,让我们向右移动两次:

php 复制代码
$x = 0b0100;
$x = $x >> 2;
// 0b0001

实际上,将一个数字向左移动 $n 次等同于将其乘以 2 $n 次,将一个数字向右移动 $n 次等同于将其除以 2 $n 次。

什么是位掩码

我们可以用这些操作和其他技术做很多很酷的事情。一个始终要记住的伟大技术是位掩码

位掩码只是你选择的任意二进制数,经过精心设计以提取非常特定的信息。

例如,采用这样的思路:当第 8 位未设置(等于 0)时,有符号 8 位整数为正,当设置时为负。那么问题来了,0x20 是正数还是负数?0x81 呢?

为此,我们可以制作一个非常方便的字节,只设置负位(0b10000000,相当于 0x80)并对 0x20 使用 AND 操作。如果结果等于 0x800b10000000,我们的掩码),那么它是负数,否则它是正数:

php 复制代码
// 0x80 === 0b10000000 (位掩码)
// 0x20 === 0b00100000
// 0x81 === 0b10000001

0x20 & 0x80 === 0x80 // false
0x81 & 0x80 === 0x80 // true

当你处理标志时,这通常是必要的。你甚至可以在 PHP 本身中找到使用示例:错误报告标志。

可以像这样选择将报告哪种错误:

php 复制代码
error_reporting(E_WARNING | E_NOTICE);

那里发生了什么?嗯,只需检查你提供的值:

复制代码
0b00000010 (0x02) E_WARNING
0b00001000 (0x08) E_NOTICE
OR -------
0b00001010 (0x0A)

所以每当 PHP 看到可以报告的 Notice 时,它会检查类似这样的东西:

php 复制代码
// 我们之前设置的错误报告
$e_level = 0x0A;

// 需要抛出一个 notice
if ($e_level & E_NOTICE === E_NOTICE)
 // 标志已设置:抛出 notice

你会到处看到这个!二进制文件、处理器、各种底层东西!

整数规范化

PHP 在处理二进制数字时有一个非常具体的问题:我们的整数是 32 或 64 位宽。这意味着我们经常需要规范化它们才能信任我们的计算。

例如,在 64 位机器上运行以下操作会给我们一个奇怪的(但预期的)结果:

php 复制代码
echo sprintf(
  '0b%08b',
  ~0x20
);

// 预期
0b11011111
// 实际
0b1111111111111111111111111111111111111111111111111111111111011111

那里发生了什么?!嗯,对那个 8 位整数(0x20)的 NOT 操作翻转了所有零位并将它们转换为 1。猜猜过去是零的是什么?没错,我们之前忽略的其他 56 位都在左边!

再次强调,这是因为 PHP 的整数无论你放入什么值都是 32 或 64 位长!

不过,这仍然按你期望的方式工作。例如,操作 ~0x20 & 0b11011111 === 0b11011111 结果为 bool(true)。但始终记住这些左边的位一直存在,否则你的代码中可能会出现奇怪的行为。

要解决此问题,你可以通过应用清除所有这些零的位掩码来规范化整数。例如,要将 ~0x20 规范化为 8 位整数,我们必须将其与 0xFF0b11111111)进行 AND 操作,这样之前的 56 位都将设置为零。

php 复制代码
~0x20 & 0xFF
-> 0b11011111

**注意!**永远不要忘记你的变量中携带的内容,否则你可能会遇到意外行为。例如,让我们看看当我们使用和不使用 8 位掩码右移上述值时会发生什么。

php 复制代码
~0x20 & 0xFF
-> 0b11011111

0b11011111 >> 2
-> 0b00110111 // 预期

(~0x20 & 0xFF) >> 2
-> 0b00110111 // 预期

(~0x20 >> 2) & 0xFF
-> 0b11110111 // 预期?

说清楚一点:从 PHP 的角度来看,这是预期的,因为你显然在那里处理 64 位整数。你必须明确你的程序期望什么。

专业提示:通过 TDD 编码避免这样的愚蠢错误。

总结

二进制很酷,PHP 也是。最重要的是:这些知识能让你在令人惊叹的二进制数据世界中探索。

有了这些工具,其他一切只是找到关于二进制文件/协议如何行为的适当文档的问题。毕竟,一切都是二进制序列。

强烈建议查看 PDF 规范,或者图像元数据的 EXIF。你甚至可以尝试自己实现 MessagePack 序列化格式,或者 AvroProtobuf......可能性无穷无尽!

相关推荐
Moment1 小时前
专为 LLM 设计的数据格式 TOON,可节省 60% Token
前端·javascript·后端
青梅主码1 小时前
麦肯锡联合QuantumBlack最新发布《2025年人工智能的现状:智能体、创新和转型》报告:32% 的企业预计会继续裁员
前端·人工智能·后端
William_cl1 小时前
【ASP.NET进阶】Controller 层基础:从 MVC 5 到 Core,继承的奥秘与避坑指南
后端·asp.net·mvc
WX-bisheyuange2 小时前
基于Spring Boot的老年人的景区订票系统
vue.js·spring boot·后端·毕业设计
ArabySide2 小时前
【Spring Boot】基于MyBatis的条件分页
java·spring boot·后端·mybatis
IT_陈寒2 小时前
Vue 3.4 性能优化实战:7个被低估的Composition API技巧让你的应用提速30%
前端·人工智能·后端
我命由我123452 小时前
Java 开发 - 简单消息队列实现、主题消息队列实现
java·开发语言·后端·算法·java-ee·消息队列·intellij-idea
绝无仅有2 小时前
电商大厂技术面试:分布式扩展与系统设计问题解析
后端·面试·架构
Victor3563 小时前
Redis(133)Redis的对象共享机制是什么?
后端