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

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

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

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

P 使用 integer 类型来表示十进制、十六进制、八进制和二进制。所以无论你放什么数据进去,它始终都是一个整数。

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

复制代码
// 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 中的二进制数字和字符串 部分。

数学中有个叫做"进制"的东西。它定义了如何用不同格式表示数量。人类通常使用十进制(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。

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

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

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

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

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

进位操作

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

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

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

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

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

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

复制代码
// 十六进制(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
// 十进制 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
$str = 'thephp.website';

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

// 输出:
php

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

复制代码
<?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 进行双重检查,看看我们是否走在正确的道路上:

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

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

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

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

复制代码
<?php

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

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

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

复制代码
$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 应该向左移动一步:

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

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

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

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

什么是位掩码

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

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

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

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

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

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

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

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

复制代码
error_reporting(E_WARNING | E_NOTICE);

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

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

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

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

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

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

整数规范化

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

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

复制代码
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 位都将设置为零。

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

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

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

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

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

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

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

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

总结

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

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

相关推荐
Darkershadow几秒前
蓝牙学习之unprovision beacon
python·学习·ble
报错小能手34 分钟前
线程池学习(六)实现工作窃取线程池(WorkStealingThreadPool)
开发语言·学习
星辰烈龙37 分钟前
黑马程序员JavaSE基础加强d5
服务器·网络·php
青莲8431 小时前
Java内存模型(JMM)与JVM内存区域完整详解
android·前端·面试
林栩link1 小时前
【车载Android】「场景引擎」设计思路分享
android
No0d1es1 小时前
2025年12月 GESP CCF编程能力等级认证Python三级真题
开发语言·php
哥只是传说中的小白1 小时前
Nano Banana Pro高并发接入Grsai Api实战!0.09/张无限批量生成(附接入实战+开源工具)
开发语言·数据库·ai作画·开源·aigc·php·api
星火开发设计2 小时前
二叉树详解及C++实现
java·数据结构·c++·学习·二叉树·知识·期末考试
xiaoxiaoxiaolll2 小时前
《Light: Science & Applications》超表面偏振态与偏振度完全独立控制新范式
学习
亚历山大海2 小时前
PHP HTML 实体(HTML Entities)没有被正确解码导致< 和 δ 等字符被转换
开发语言·html·php