hnwの日記

PHPの中身をgdbで観察できるようなDockerイメージを作りました

CLI版のPHPgdb上で動かしつつ、内部的なデータ構造を覗き見ることができるようなDockerイメージを作ってDocker Hubにアップロードしました。Docker環境さえあればすぐに動かすことができます。


このイメージを動かせばCのコードを書かなくてもPHP内部のデータ構造を確認することができます。PHPの内部構造を詳しく知りたい、というような人は参考にしてみてください。

準備

Macの人はDocker for Macを用意しましょう。他のOS上でも同様にDockerをインストールしてください。また、イメージの圧縮時サイズが200MB程度ありますので、それなりのネットワーク環境で遊ぶことをオススメします。

起動

Docker 1.10以降ではseccompにより一部システムコールが制限されているため、コンテナ内でgdbによるデバッグができません。期待通りにgdbを動かすにはコンテナ起動時にコマンドラインオプション「--cap-add=SYS_PTRACE --security-opt seccomp=unconfined」を付ける必要があります。

$ docker run -v $(pwd):/work -w /work --rm -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined yhnw/php-debug:7.1 /bin/bash

遊び方

とりあえずzvalの中身を覗いてみましょう。


まず作業ディレクトリに移動して、適当なPHPプログラムを作ります。今回は中身を見てみたい変数を列挙してvar_dump()してみましょう。

$ mkdir -p work/php-debug
$ cd work/php-debug
$ vim var_dump.php


今回作成したvar_dump.phpは下記のようなファイルです。

<?php
$a = strtoupper("foo");
$b = [ $a, &$a, "FOO"];
$c = $a;
var_dump($b);
$a = 123;
$b[1000] = 1234.5;
var_dump($b);


このディレクトリでDockerイメージ「yhnw/php-debug:7.1」を実行してgdbを起動します。

$ docker run -v $(pwd):/work -w /work --rm -it --cap-add=SYS_PTRACE --security-opt seccomp=unconfined yhnw/php-debug:7.1 /bin/bash
root@536f406bded6:/work# gdb php
GNU gdb (Debian 7.7.1+dfsg-5) 7.7.1
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from php...done.


まずPHP関数var_dump()にブレークポイントを設置します。PHPの内部関数はCの世界では「zif_」というプレフィックスがつきますので、zif_var_dumpという関数名になります。

(gdb) b zif_var_dump
Breakpoint 1 at 0x8429a4: file /usr/src/php/ext/standard/var.c, line 205.


では先ほどのPHPプログラムを実行してみましょう。

(gdb) run var_dump.php
Starting program: /usr/local/bin/php var_dump.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, zif_var_dump (execute_data=0x7ffff2214140, return_value=0x7fffffffb0b0) at /usr/src/php/ext/standard/var.c:205
205		if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {
(gdb) n
209		for (i = 0; i < argc; i++) {


実行するとブレークポイントの設定によりPHPプログラム5行目のvar_dump()で停止します。次に「n」をタイプしてzend_parse_parameters()を実行しています。これによりvar_dump()のパラメータがargsに代入されます。


さて、ではvar_dump()の第一引数$bの中身を見てみましょう。

(gdb) printzv &args[0]
[0x7ffff2214190] (refcount=2) array:     Packed(3)[0x7ffff2258300]: {
      [0] 0 => [0x7ffff225fa08] (refcount=3) string: FOO
      [1] 1 => [0x7ffff225fa28] (refcount=2) reference: [0x7ffff2202120] (refcount=3) string: FOO

      [2] 2 => [0x7ffff225fa48] (refcount=0) string: FOO
}


情報量が多いので全部は説明しませんが、$bは3要素の配列であり、ハッシュテーブルを持たない真の配列(Packed)であることがわかります。また、$b[0]の文字列"FOO"の参照カウントは3であることがわかります。これは$b[0]と、$cと、$aの実体($aおよび$b[1]が共有している)の3つから参照されているためです。$b[1]の参照カウントが2なのは$aと$b[1]が同じ実体を参照しているという意味です。


$b[2]の参照カウントは0ですが、これは文字列リテラルを代入していることが原因です。文字列リテラルインターン化文字列になるため、参照カウントおよびGCの対象外なのです(参考:「PHPのインターン化文字列とは何か - hnwの日記」)。


では、次のブレークポイントまでプログラムを再開してみましょう。

(gdb) c
Continuing.
array(3) {
  [0]=>
  string(3) "FOO"
  [1]=>
  &string(3) "FOO"
  [2]=>
  string(3) "FOO"
}

Breakpoint 1, zif_var_dump (execute_data=0x7ffff2214140, return_value=0x7fffffffb0b0) at /usr/src/php/ext/standard/var.c:205
205		if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {


プログラム5行目のvar_dump()の結果が表示され、8行目のvar_dump()で再び停止しました。改めて$bの中身を見てみましょう。

(gdb) n
209		for (i = 0; i < argc; i++) {
(gdb) printzv &args[0]
[0x7ffff2214190] (refcount=2) array:     Hash(4)[0x7ffff2258300]: {
      [0] 0 => [0x7ffff225fb60] (refcount=2) string: FOO
      [1] 1 => [0x7ffff225fb80] (refcount=2) reference: [0x7ffff2202120] long: 123

      [2] 2 => [0x7ffff225fba0] (refcount=0) string: FOO
      [3] 1000 => [0x7ffff225fbc0] double: 1234.500000
}


$b[1000]に値を代入したことで$bが4要素の連想配列(Hash)に変わったことがわかります。PHPは添字が全部数字であっても中身がスカスカになってしまうような場合は内部構造として連想配列を採用します。各要素のzvalのアドレスも書き換わっていることから、データ構造の変更に伴い要素のメモリコピーが発生していることもわかります。仕組み上当然のことですが、PHP 7において当初配列として利用していたものを途中から連想配列として利用するのは非効率なのです。


また、$a[0]の参照カウントが1減って2になっていることもわかります。これは$aの実体が123に書き換わり、参照先が変わったためです。


ちなみにprintzvというのはPHPに標準添付の.gdbinitで定義されている関数で、zvalの中身を見るのに非常に便利です。もしgdbの標準コマンドで$b[0]の中身を見るとすれば、次のような手順が必要になります。

(gdb) p args[0].u1.v.type
$1 = 7 '\a'
(gdb) p args[0].value.arr.arData[0]->val.u1.v.type
$2 = 6 '\006'
(gdb) p (char *)args[0].value.arr.arData[0]->val->value.str.val
$3 = 0x7ffff22649d8 "FOO"
(gdb) p args[0].value.arr.arData[0]->val->value.str->gc->refcount
$4 = 2


まず$bが配列であること(type=7)を確認し、次に$b[0]が文字列であること(type=6)を確認してその値にアクセスしています。zvalでは共用体を多用しているので仕方ないのですが、ちょっと面倒すぎますよね。

シンボルテーブルを確認する

シンボルテーブルを確認する方法も紹介しておきます。グローバルなシンボルテーブル(つまりグローバル変数)は次のように確認できます。

(gdb) print_ht &executor_globals.symbol_table
Hash(10)[0x1368530]: {
  [0] _GET => [0x7ffff2259100] (refcount=2) array:
  [1] _POST => [0x7ffff2259120] (refcount=2) array:
  [2] _COOKIE => [0x7ffff2259140] (refcount=2) array:
  [3] _FILES => [0x7ffff2259160] (refcount=2) array:
  [4] argv => [0x7ffff2259180] (refcount=2) array:
  [5] argc => [0x7ffff22591a0] long: 1
  [6] _SERVER => [0x7ffff22591c0] (refcount=2) array:
  [7] a => [0x7ffff22591e0] indirect: [0x7ffff2214080] (refcount=2) reference: [0x7ffff2202120] long: 123


  [8] b => [0x7ffff2259200] indirect: [0x7ffff2214090] (refcount=2) array:

  [9] c => [0x7ffff2259220] indirect: [0x7ffff22140a0] (refcount=2) string: FOO

}


そのスコープでのシンボルテーブル(=ローカル変数)は次のようにすれば見えることもあります(PHP 5.2までは常にローカル変数に対応するシンボルテーブルが作られていたのですが、PHP 5.3以降は必要なときだけ作られるようです)。

(gdb) print_ht execute_data.symbol_table

Dockerイメージについて

ちなみに今回作ったDockerイメージはオフィシャルのphpイメージのリポジトリをforkして作りました。Travis CIを利用しており、masterにコミットするとCIでDocker Hubへのデプロイまで行うようにしてありますので、参考にしてみてください。


ちなみに5.6と7.0のイメージもデプロイ済みです。

まとめ

Cもgdbも良くわからなくても、PHPプログラム中のvar_dump()のタイミングで止める方法と「printzv」だけで結構遊べるのではないでしょうか。もう少し深く知りたい場合はgdbのマニュアルおよびPHPソースコードのZend/以下を調べてみてください。