【PHP】PHPのコードからopcodeを抜き出した話

## 経緯
就職作品としてopcodeを利用したモノを作りたかった為、opcodeについて調べ、値として取り出す方法を模索したものです。
拙い部分もあると思いますが、ヒマつぶし程度に読んでいただけるとありがたいです。

## 環境
XAMPP v3.3.0
PHP Version 8.1.6
Win10

## 参考記事
[PHP による hello world 入門](https://tech.respect-pal.jp/php-helloworld/)
[ZendEngineにえこひいきされる標準関数たち (前篇)](https://qiita.com/tadsan/items/643a7cd0b4d7f27dd3ed)
[【PHP】ローカル環境でオペコード（phpdbg・vld）を表示する](https://qiita.com/7968/items/06b8394fb879978258f6)

## はじめに
ここからは私がopcodeを値として扱いたかった際に試行錯誤した内容がひたすら書かれています。

途中詰まった点なども書いており、読むにはつまらない内容があると思います。
お前の考えとかどうでもいい、結論どうすれば取得できるんだと言う方は[#最終結果](#最終結果)までお進みください。

また、ここではopcodeについてほとんど解説しません。
詳しくは[PHP による hello world 入門](https://tech.respect-pal.jp/php-helloworld/)をご覧ください。

## opcodeの表示
opcodeの取得方法は２種類あります

### phpdbg
phpdbgは標準で搭載されているデバッガです。

``` cmd
phpdbg -e hello.php
promot> print exec
```
``` hello.php
<?php echo "hello world";
```
出力結果
```
$_main:
     ; (lines=3, args=0, vars=0, tmps=0)
     ; I:\xampp\htdocs\実験\vld\hello.php:1-1
L0001 0000 EXT_STMT
L0001 0001 ECHO string("hello world")
L0001 0002 RETURN int(1)
```

### vld
vldはpecl拡張モジュールです、使用するにはダウンロードする必要があります
ダウンロード方法は[公式ホームページ](https://derickrethans.nl/projects.html#vld)を参考にするか、私が参考にした「[【PHP】ローカル環境でオペコード（phpdbg・vld）を表示する](https://qiita.com/7968/items/06b8394fb879978258f6)」を参考にするなどしてください。

``` cmd
php -d vld.active=1 -d vld.execute=0 hello.php
```

出力結果

```
Finding entry points
Branch analysis from position: 0
1 jumps found. (Code = 62) Position 1 = -2
filename:       I:\xampp\htdocs\hello.php
function name:  (null)
number of ops:  2
compiled vars:  none
line      #* E I O op                           fetch          ext  return  operands
-------------------------------------------------------------------------------------
    1     0  E >   ECHO                                                     'hello+world'
          1      > RETURN                                                   1

branch: #  0; line:     1-    1; sop:     0; eop:     1; out0:  -2
path #1: 0,
```

## opcodeの取得
vldはダウンロードが少し面倒なのと、出力結果的に抜き出しずらいと思われたので今回はphpdbgの出力結果から抜き出すことにしました。

まずはこの出力結果を値を取得する必要があったので`exec()`を使ってみました

phpdbgを使う場合は対話モードに対して`print exec`を渡す必要があるため、`echo print exec | phpdbg -e hello.php`という風にパイプ「｜」で繋いだコマンドを実行することで１つのコマンドで値を取得することができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2049329/d02dda62-1d90-9482-f6da-8bc198b2e956.png)

これを```exec()```に渡せば値を受け取れるはず！


``` exec.php
$command = 'echo print exec | phpdbg -e hello.php';
exec($command, $output);

echo "<pre>";
print_r($output);
echo "</pre>";
```

出力結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2049329/79b0f92b-15ad-073c-e44e-6e643eab1817.png)

あれ？
$outputに入っていない。。。


### 失敗原因
[exec()](https://www.php.net/manual/ja/function.exec.php)によると

``` php
exec(string $command, array &$output = null, int &$result_code = null): string|false
```
> 引数 output が存在する場合、指定した配列は、 コマンドからの出力の各行で埋められます。 \n のような後に続く空白は、この配列には含まれません。 

とあるように出力が書き込まれるそうです、明記されていないので推測になりますが、ここで言う出力とは[標準出力（STDOUT）](https://e-words.jp/w/%E6%A8%99%E6%BA%96%E5%87%BA%E5%8A%9B.html)のことだと思います。
それを裏付ける実験として以下のものを行いました。

実験とかどうでもいいという方は[#失敗を踏まえて出力を取得](#失敗を踏まえて出力を取得)までお進みください。

#### 実験

行った実験は表示されているものが標準出力か標準エラー出力かを特定するものです。

```
echo A 1> result.txt
```

echo コマンドの後の`1> result.txt`は出力が標準出力をresult.txtに書き込むというものです。
それを実証するためにエラー出力の場合を見てみます。

```
qwerty 1> result.txt
```
存在しないコマンドqwertyを実行した際は標準出力がなく、result.txtの内容も空にりました。

次にエラーの際に書き込む`2> result.txt`でも試してみます。
```
qwerty 2> result.txt
```

``` result.txt
'qwerty' は、内部コマンドまたは外部コマンド、
操作可能なプログラムまたはバッチ ファイルとして認識されていません。
```
エラーの内容が書き込まれました。
これを本題のphpdbgにも試してみます。

#### phpdbgが標準エラー出力か確認

```
(echo print exec | phpdbg -e hello.php) 1> result.txt
```

``` result.txt
[Welcome to phpdbg, the interactive PHP debugger, v8.1.6]
To get help using phpdbg type "help" and press enter
[Please report bugs to <http://bugs.php.net/report.php>]
[Successful compilation of I:\xampp\htdocs\opcode\hello.php]
prompt> [Context I:\xampp\htdocs\opcode\hello.php (3 ops)]
prompt> 
```
これは`exec()`を実行した際の結果と同じです。

ではエラーの場合を確認してみましょう。
```
(echo print exec | phpdbg -e hello.php) 2> result.txt
```

``` result.txt

$_main:
     ; (lines=3, args=0, vars=0, tmps=0)
     ; I:\xampp\htdocs\opcode\hello.php:1-1
L0001 0000 EXT_STMT
L0001 0001 ECHO string("hello world")
L0001 0002 RETURN int(1)

```

このようにエラーの場合に結果が書き込まれているのでphpdbgで表示されるものは**標準エラー出力**であることが分かると思います。
更に、`exec()`の$outputで取得できるものは**標準出力**だと思われます。

### 失敗を踏まえて出力を取得

ひとつ前の実験結果から`exec()`は標準出力を受け取り、phpdbgの出力結果は標準エラー出力と分かりました。
そこで`exec.php`のコマンドを少し変更します。

``` exec.php
<?php
$command = '(echo print exec | phpdbg -e hello.php 1>nul) 2>&1';
exec($command, $output);

echo "<pre>";
print_r($output);
echo "</pre>";
```

元の$commandは`echo print exec | phpdbg -e hello.php`のみだったが、標準エラー出力が欲しいので `2>&1`で[リダイレクト](https://e-words.jp/w/%E3%83%AA%E3%83%80%E3%82%A4%E3%83%AC%E3%82%AF%E3%83%88.html)させています。
ただこのままだと元の標準出力に付け足されるので( )内で標準出力を`1>nul`先に消しておき、その後空になった標準出力にエラー出力をリダイレクトさせています。

出力結果
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2049329/e0b73be6-e219-1d0a-2ac2-a20cd8335a1d.png)

上手く望みの値を取ることが出来ました。


## 値を解析

### 加工しやすい形にする
まず０～３の値は不要なので消します

``` exec.php
<?php
$command = '(echo print exec | phpdbg -e hello.php 1>nul) 2>&1';
exec($command, $output);

// 不要な値を削除
unset($output[0], $output[1], $output[2], $output[3]);
$output = array_values($output);

echo "<pre>";
print_r($output);
echo "</pre>";
```

次に`L0001 0000 EXT_STMT`は必要ないので出力されないようにコマンドを少し変更します。

``` php
<?php
$command = '(echo print exec | phpdbg hello.php 1>nul) 2>&1';
```
-eオプションがあることで要らない表示が出ていたようです。


### 意味毎に抜き出し
phpdbgではスペース毎に１回の実行内容が表示されているそうです、
なので初めはexplodeで解決しようと思いました。

` $explode = explode(" ", $val); `
ただこれだと` string("hello world") `は判定できないので他の方法を取る必要があります。

#### 正規表現で判定
どうしても細かく判定しなければ取得できなさそうだったので今回は正規表現を使うことにしました。
ここでもう一度現時点で取得できている値を確認してみます。

```
Array
(
    [0] => L0001 0000 ECHO string("hello world")
    [1] => L0001 0001 RETURN int(1)
)
```
まずはファイルの何行目で実行されているかの`L***`を取得します

``` php
foreach ($output as $val) {
    $regex = '/^L\d+\s/';
    preg_match($regex, $val, $matches);
    $file_line = trim($matches[0]);

    var_dump($file_line);
    echo "<br>";
}
```

出力結果
```
string(5) "L0001"
string(5) "L0001"
```

次に、`L0001`の後の数値、実行回数を取得
取得するにあたって既に取得した`L0001`を削除し、新たに先頭から取得するようにします。

``` php
foreach ($output as $val) {
    $regex = '/^L\d+\s/';
    preg_match($regex, $val, $matches);
    $file_line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^\d+\s/';
    preg_match($regex, $val, $matches);
    $line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    var_dump($line);
    echo "<br>";
}
```

出力結果
```
string(4) "0000"
string(4) "0001"
```

次の値を取得する前に`hello.php`を変更して出力結果を見てみます。
#### 他のコードの場合を考慮
``` hello.php
<?php
for ($i = 0; $i < 10; $i++) {
    echo 'A';
}
```

``` cmd
phpdbg hello.php
print exec
```

出力結果
```
$_main:
     ; (lines=7, args=0, vars=1, tmps=3)
     ; I:\xampp\htdocs\opcode\hello.php:1-4       
L0002 0000 ASSIGN CV0($i) int(0)
L0002 0001 JMP 0004
L0003 0002 ECHO string("A")
L0002 0003 PRE_INC CV0($i)
L0002 0004 T3 = IS_SMALLER CV0($i) int(10)        
L0002 0005 JMPNZ T3 0002
L0004 0006 RETURN int(1)
```

0004を見てみると `T3 = IS_SMALLER CV0($i) int(10)`と他とは雰囲気が違うものがあります。
簡単に説明するとIS_SMALLERの結果をT3に格納しているものです、詳しくは下記の蛇足を。

##### 蛇足
IS_SMALLER()はその後にあるCV0($i)がint(10)より小さいかを確認しており、その結果をT3に格納しています。
IS_SMALLER()の返り値は`引数１(CV0) ＜ 引数2(int(10))`なら1、違うなら0になり、0005のJMPNZは第一引数(T3)が0じゃ無ければ第二引数(0002)にジャンプするという物です。


for文の動きを改めて１行づつ確認すると
1. CV0に0を格納
2. 0004にジャンプ
3. CV0が10より小さいか確認
4. JMPNZでT3が0じゃないので0002にジャンプ
5. "A"が出力
6. CV0をインクリメント
7. CV0が10より小さいか確認...

となっています。

蛇足終了。

##### 結果を取得する

同時にopcodeも取得するようにします。

``` php
foreach ($output as $val) {
    $regex = '/^L\d+\s/';
    preg_match($regex, $val, $matches);
    $file_line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^\d+\s/';
    preg_match($regex, $val, $matches);
    $line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^(?:([A-Z]+\d+)\s=\s)?([A-Z_]+)\s?/';
    preg_match($regex, $val, $matches);
    $return = trim ($matches[1]);
    $code   = trim($matches[2]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    var_dump($return, $code);
    echo "<BR>";
}

echo "<pre>";
print_r($output);
echo "</pre>";
```

出力結果
```
string(0) "" string(6) "ASSIGN"
string(0) "" string(3) "JMP"
string(0) "" string(4) "ECHO"
string(0) "" string(7) "PRE_INC"
string(2) "T3" string(10) "IS_SMALLER"
string(0) "" string(5) "JMPNZ"
string(0) "" string(6) "RETURN"

Array
(
    [0] => L0002 0000 ASSIGN CV0($i) int(0)
    [1] => L0002 0001 JMP 0004
    [2] => L0003 0002 ECHO string("hello world")
    [3] => L0002 0003 PRE_INC CV0($i)
    [4] => L0002 0004 T3 = IS_SMALLER CV0($i) int(10)
    [5] => L0002 0005 JMPNZ T3 0002
    [6] => L0012 0006 RETURN int(1)
)
```

最後の引数を取得
``` php
foreach ($output as $val) {
    $regex = '/^L\d+\s/';
    preg_match($regex, $val, $matches);
    $file_line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^\d+\s/';
    preg_match($regex, $val, $matches);
    $line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^(?:([A-Z]+\d+)\s=\s)?([A-Z_]+)\s?/';
    preg_match($regex, $val, $matches);
    $return = trim ($matches[1]);
    $code   = trim($matches[2]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);


    $regex = '/^(string\(.*\)|int\(\d+\)|array\(\.+\)|CV\d+.+\s|[A-Z]+\d+|\d+)?\s?/';
    preg_match($regex, $val, $matches);
    $op1 = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    preg_match($regex, $val, $matches);
    $op2 = trim($matches[0]);

    var_dump($op1, $op2);
    echo "<BR>";
}

echo "<pre>";
print_r($output);
echo "</pre>";
```

出力結果
```
string(7) "CV0($i)" string(6) "int(0)"
string(4) "0004" string(0) ""
string(11) "string("A")" string(0) ""
string(3) "CV0" string(0) ""
string(7) "CV0($i)" string(7) "int(10)"
string(2) "T3" string(4) "0002"
string(6) "int(1)" string(0) ""

Array
(
    [0] => L0002 0000 ASSIGN CV0($i) int(0)
    [1] => L0002 0001 JMP 0004
    [2] => L0003 0002 ECHO string("hello world")
    [3] => L0002 0003 PRE_INC CV0($i)
    [4] => L0002 0004 T3 = IS_SMALLER CV0($i) int(10)
    [5] => L0002 0005 JMPNZ T3 0002
    [6] => L0012 0006 RETURN int(1)
)
```

## 最終結果
ここまでで全ての値を特定できており、問題なく取れているかと思います。
あとはこれを配列にでも格納すればopcodeを値として取得できたと言えると思います。

``` php

<?php
$command = '(echo print exec | phpdbg hello.php 1>nul) 2>&1';
exec($command, $output);

// 不要な値を削除
unset($output[0], $output[1], $output[2], $output[3]);
$output = array_values($output);

$result = [];
foreach ($output as $val) {
    $regex = '/^L\d+\s/';
    preg_match($regex, $val, $matches);
    $file_line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^\d+\s/';
    preg_match($regex, $val, $matches);
    $line = trim($matches[0]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^(?:([A-Z]+\d+)\s=\s)?([A-Z_]+)\s?/';
    preg_match($regex, $val, $matches);
    $return = trim ($matches[1]);
    $code   = trim($matches[2]);

    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    $regex = '/^(string\(.*\)|int\(\d+\)|array\(\.+\)|CV\d+.+\s|[A-Z]+\d+|\d+)?\s?/';
    preg_match($regex, $val, $matches);
    $op1 = trim($matches[0]);
    // 取得した部分を消す
    $val = preg_replace($regex, '', $val);

    preg_match($regex, $val, $matches);
    $op2 = trim($matches[0]);


    $result[] = [
        'file_line' => $file_line,
        'line'      => $line,
        'code'      => $code,
        'op1'       => $op1,
        'op2'       => $op2,
    ];    
}

echo "<pre>";
print_r($result);
echo "</pre>";
```

出力結果
<details>
<summary>出力結果</summary>

```
Array
(
    [0] => Array
        (
            [file_line] => L0002
            [line] => 0000
            [code] => ASSIGN
            [op1] => CV0($i)
            [op2] => int(0)
        )

    [1] => Array
        (
            [file_line] => L0002
            [line] => 0001
            [code] => JMP
            [op1] => 0004
            [op2] => 
        )

    [2] => Array
        (
            [file_line] => L0003
            [line] => 0002
            [code] => ECHO
            [op1] => string("A")
            [op2] => 
        )

    [3] => Array
        (
            [file_line] => L0002
            [line] => 0003
            [code] => PRE_INC
            [op1] => CV0
            [op2] => 
        )

    [4] => Array
        (
            [file_line] => L0002
            [line] => 0004
            [code] => IS_SMALLER
            [op1] => CV0($i)
            [op2] => int(10)
        )

    [5] => Array
        (
            [file_line] => L0002
            [line] => 0005
            [code] => JMPNZ
            [op1] => T3
            [op2] => 0002
        )

    [6] => Array
        (
            [file_line] => L0012
            [line] => 0006
            [code] => RETURN
            [op1] => int(1)
            [op2] => 
        )

)
```

</details>

いかがだったでしょうか、opcodeを値として取得したい人が私以外にいるとはあまり思えませんが、ネタ記事としてでも楽しんでもらえたら幸いです。
また、詰めが甘い部分が多々あるとは思いますが、そこはご容赦ください。