在實際項目中,經常需要大量依賴 PHP 中的位運算操作。從讀取二進制文件到模擬處理器,這是一項非常有用的知識,而且也非常酷。

PHP 提供了許多工具來支持你處理二進制數據,但需要從一開始就注意:如果你追求極致的底層效率,PHP 並不是最佳選擇。

不過請耐心看下去!本文將展示關於位運算、二進制和十六進制處理的非常有價值的內容,這些在任何語言中都會有用。

原文鏈接 進階學習 PHP 中的二進制和位運算

為什麼 PHP 可能不是最佳選擇 PHP 能優雅地處理比想象中更多的情況。但在需要非常高效地處理二進制數據的場景下,PHP 確實存在侷限性。

説清楚一點:這裏指的不是應用程序多消耗 5 或 10MB 內存的問題,而是精確分配必要的內存量來保存特定數據類型的能力。

根據官方文檔關於整數的説明,PHP 使用 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 底線是:無論你需要存儲 0xff、0xffff、0xffffff 還是其他什麼,它們在 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)。還有 uint32、uint64 以及理論上更高的類型。

有符號整數也可以表示負值,通常使用最後一位來確定數字是正數(最後一位 = 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';

進階學習 PHP 中的二進制和位運算_PHPstr[3]); 進階學習 PHP 中的二進制和位運算_十進制_02str[4]); 進階學習 PHP 中的二進制和位運算_十進制_03str[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 字節 進階學習 PHP 中的二進制和位運算_字符串_04h, 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 和 進階學習 PHP 中的二進制和位運算_PHP_05x ^ $y 異或 一個值,其位在 $x 或 進階學習 PHP 中的二進制和位運算_PHP_06x & $y 與 一個值,其位僅在 $x 和 進階學習 PHP 中的二進制和位運算_十進制_07x 非 翻轉 進階學習 PHP 中的二進制和位運算_字符串_08x << $y 左移 將 $x 的位向左移動 進階學習 PHP 中的二進制和位運算_PHP_09x >> $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 | 進階學習 PHP 中的二進制和位運算_十進制_10x 的第 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 ^ 進階學習 PHP 中的二進制和位運算_PHP_11x & $y)的工作原理 AND 操作符更容易理解。它對每一位執行 AND 操作,因此只有在兩側同時匹配的值才會被檢索。

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

// 1 & 1 = 1 // 1 & 0 = 0 // 0 & 0 = 0

0b00100000 // $x = 0x20 0b00110000 // $y = 0x30 AND ------ // $x & 進階學習 PHP 中的二進制和位運算_PHP_12x)的工作原理 NOT 操作需要單個參數,它只是翻轉傳遞的所有位。它將所有值為 0 的位轉換為 1,將所有值為 1 的位轉換為 0。看下面:

// ~1 = 0 // ~0 = 1

0b00100000 // 進階學習 PHP 中的二進制和位運算_字符串_13x 0b11011111 // 0xDF 如果你在 PHP 中運行此操作並決定使用 sprintf() 調試它,可能會注意到一個更寬的數字。下面的 整數規範化 部分將解釋發生了什麼以及如何修復它。

左移和右移(進階學習 PHP 中的二進制和位運算_十進制_14n 和 $x>> $n)的工作原理 移位操作與將數字乘以或除以 2 的倍數相同。它的作用是使所有位向左或向右移動 $n 步。

這裏採用一個較小的二進制數來演示,這樣更容易理解。以 $x = 0b0010 為例。如果將 $x 向左移動一次,那個位 1 應該向左移動一步:

進階學習 PHP 中的二進制和位運算_PHP_15x = $x << 1; // 0b0100 右移也是一樣。現在 $x = 0b0100,讓我們向右移動兩次:

進階學習 PHP 中的二進制和位運算_十進制_16x = $x >> 2; // 0b0001 實際上,將一個數字向左移動 $n 次等同於將其乘以 2 $n 次,將一個數字向右移動 $n 次等同於將其除以 2 $n 次。

什麼是位掩碼 我們可以用這些操作和其他技術做很多很酷的事情。一個始終要記住的偉大技術是位掩碼。

位掩碼只是你選擇的任意二進制數,經過精心設計以提取非常特定的信息。

例如,採用這樣的思路:當第 8 位未設置(等於 0)時,有符號 8 位整數為正,當設置時為負。那麼問題來了,0x20 是正數還是負數?0x81 呢?

為此,我們可以製作一個非常方便的字節,只設置負位(0b10000000,相當於 0x80)並對 0x20 使用 AND 操作。如果結果等於 0x80(0b10000000,我們的掩碼),那麼它是負數,否則它是正數:

// 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 位整數,我們必須將其與 0xFF(0b11111111)進行 AND 操作,這樣之前的 56 位都將設置為零。

~0x20 & 0xFF -> 0b11011111 注意!永遠不要忘記你的變量中攜帶的內容,否則你可能會遇到意外行為。例如,讓我們看看當我們使用和不使用 8 位掩碼右移上述值時會發生什麼。

~0x20 & 0xFF -> 0b11011111

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

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

(~0x20 >> 2) & 0xFF -> 0b11110111 // 預期? 説清楚一點:從 PHP 的角度來看,這是預期的,因為你顯然在那裏處理 64 位整數。你必須明確你的程序期望什麼。

專業提示:通過 TDD 編碼避免這樣的愚蠢錯誤。

總結 二進制很酷,PHP 也是。最重要的是:這些知識能讓你在令人驚歎的二進制數據世界中探索。

有了這些工具,其他一切只是找到關於二進制文件 / 協議如何行為的適當文檔的問題。畢竟,一切都是二進制序列。

強烈建議查看 PDF 規範,或者圖像元數據的 EXIF。你甚至可以嘗試自己實現 MessagePack 序列化格式,或者 Avro、Protobuf…… 可能性無窮無盡!