C/C++プログラマのためのDTrace入門

第7回独自プロバイダの定義[2]

前回は、独自に定義したプロバイダのプローブをプログラム中に埋め込むことで、関数呼び出しの境界以外における情報採取の方法を説明しました。

今回は、プローブに指定された値を元に、より詳細な情報を採取するための手法について説明します。

なお、本稿で説明する手法は、必ずしも独自プロバイダを使用する場合に限定したものではありませんので、pidプロバイダを使用する場合にも利用可能です。

可変長領域ダンプの採取

まずは、第2回では固定長で行っていたメモリ領域の内容表示を、可変長領域に対応させる方法について説明します。

採取対象の準備

まずはリスト1に示す独自プロバイダuserioを定義するものと仮定します。

リスト1 プロバイダ定義userio.d)
provider userio
{
    probe readin(void* buf, size_t len);
    probe writeout(void* buf, size_t len);
};

userioプロバイダが提供するプローブは、ユーザプログラムにおける何らかのI/O実施の際に、readinプローブなら実際のI/O実施直後に、writeoutプローブなら実際のI/O実施に先立って使用することで、内容の記録や確認を行うような用途を想定しています。

プロバイダを定義したなら、以下の手順で実行可能バイナリを生成します。

  1. ヘッダフィルの生成
  2. 採取対象ソースファイルread_n_write.cの作成
  3. 採取対象ソースファイルのコンパイルread_n_write.oの生成)
  4. dtrace コマンドによるリンク前処理userio.oの生成)
  5. 採取対象実行可能バイナリのリンクread_n_writeの生成)

作業手順例を図1に示します。

図1 採取対象実行可能バイナリの生成
$ dtrace -s userio.d -h (1)
※read_n_write.cの作成(2)
$ cc -c read_n_write.c (3)
$ dtrace -s userio.d -G read_n_write.o (4)
$ cc -o read_n_write read_n_write.o  userio.o (5)
$

tracemem()の問題点

第2回でデータ領域内容の採取に使用したDスクリプトをリスト2に再掲します。

リスト2 データ領域内容採取Dスクリプト
pid$target:show_args:checksum:entry
{
    this->iobuf = alloca(32);
    copyinto(arg0, 32, this->iobuf);
    tracemem(this->iobuf, 32);
}

tracemem()は、サイズ指定を行う引数が固定値でなければならないため、可変長領域の内容採取に直接使用することはできません。

また、Dスクリプトはループ制御を持たない仕様ですので、可変長領域のサイズに応じてtracemem()を繰り返す、といった記述をすることもできません。

しかし、可変長領域のダンプを行うにあたって、メガバイト単位の採取にまで対応することは、あまり現実的ではありません。記録に必要なディスク容量や、事後参照の利便性などを考えた場合、せいぜい数キロバイトの記録を行うのが現実的な線ではないでしょうか?

数キロバイトまでの対応で割り切ることができるのであれば、tracemem()による固定長のダンプを、可変長領域のサイズに応じて、必要なだけ繰り返し実行する手段を講じればよいことになります。

可変長領域ダンプの実現

さて、それではループ制御を持たないDスクリプトにおいて、⁠可変長領域のサイズに応じて、必要なだけ繰り返し実行」するには、どうすればよいでしょうか?

ここでは、Dスクリプトの以下の性質を利用します。

  • スクリプト中の各節は、記述順序に実行される
  • 述語(=前提条件)記述には、改変を伴う式を記述することができる

繰り返し処理の大枠の構造を、リスト3に示します。

リスト3 擬似的な繰り返し処理
inline uintptr_t width = 128;

/* 節 (1) */
userio$target:$1::
{ self->offset = - width; }

/* 節 (2) */
userio$target:$1::
/(self->offset += width) < arg1/
{ ※ self->offset は 0 }

/* 節 (3) */
userio$target:$1::
/(self->offset += width) < arg1/
{ ※ self->offset は 128 }

/* 節 (4) */
userio$target:$1::
/(self->offset += width) < arg1/
{ ※ self->offset は 256 }
        :
    (以下、同じ記述の繰り返し)
        :

冒頭の"inline int width = 128;"は、Dスクリプト内で定数値をシンボル化するための宣言です。このような定数シンボルを使用することで、事後の保守における改変の利便性が向上します。

第1節で"- width"、即ち-128が設定された"self->offset" 変数は、第2節以降の述語部分での"self->offset += width"記述により、各節では順次0,128,256,....と評価されます。

つまり、必要とされる採取量が高々数キロバイト程度であるとの仮定があるなら、必要とされる上限サイズに達するまで第2節と同じ記述を手動で列挙することで、擬似的な繰り返し処理が実現されるわけです。

この方法による擬似的な繰り返しが、一般的なプログラミング言語における繰り返しと違うのは、"self->offset"変数の値が有効値(この例ではarg1)を越えた後も、残りの節の実行(但し述語成立の判定のみ)が継続される点です。

なお、第1節で "self->offset" の値をわざわざ負の値に初期化しているのは、"self->offset"が0の場合の処理を特別扱いしないことで、第2節以降の記述を完全にコピー&ペーストで済ませることができるためです。

実際に可変長領域をダンプするDスクリプトをリスト4に示します。

リスト 4 可変長データ領域内容採取 D スクリプト
inline uintptr_t width = 128;

userio$target:$1::
{
    printf("addr=0x%p", arg1);
    self->offset = - width;
}
userio$target:$1::
/(self->offset += width) < arg1/
{
    self->buf = alloca(width);
    copyinto(arg0 + self->offset, width, self->buf);
    printf("+0x%p -", self->offset);
    tracemem(self->buf, width);
}
        :
    (以下、同じ記述の繰り返し)
        :

このスクリプトによる実行例を図 2 に示します。

図2 可変長データ領域内容の採取
$ dtrace -s watch_io.d \
         -c './read_n_write ofile 400 300' \
         read_n_write \
  < ./read_n_write
dtrace: script 'watch_io.d' matched 10 probes
CPU     ID                    FUNCTION:NAME
  0  60346                      main:readin addr=0x8062a10
  0  60346                      main:readin +0x0 -
             0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f  0123456789abcdef
         0: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00  .ELF............
        10: 02 00 03 00 01 00 00 00 cc 10 05 08 34 00 00 00  ............4...
        20: 48 38 00 00 00 00 00 00 34 00 20 00 06 00 28 00  H8......4. ...(.
        30: 26 00 25 00 06 00 00 00 34 00 00 00 34 00 05 08  &.%.....4...4...
        40: 00 00 00 00 c0 00 00 00 c0 00 00 00 05 00 00 00  ................
        50: 00 00 00 00 03 00 00 00 f4 00 00 00 00 00 00 00  ................
        60: 00 00 00 00 11 00 00 00 00 00 00 00 04 00 00 00  ................
        70: 00 00 00 00 fd ff ff 6f 08 01 00 00 08 01 05 08  .......o........

  0  60346                      main:readin +0x80 -
             0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f  0123456789abcdef
         0: 00 00 00 00 10 00 00 00 00 00 00 00 04 00 00 00  ................
        10: 00 00 00 00 01 00 00 00 00 00 00 00 00 00 05 08  ................
        20: 00 00 00 00 a9 23 00 00 a9 23 00 00 05 00 00 00  .....#...#......
        30: 00 00 01 00 01 00 00 00 ac 23 00 00 ac 23 06 08  .........#...#..
        40: 00 00 00 00 6c 02 00 00 58 06 00 00 07 00 00 00  ....l...X.......
        50: 00 00 01 00 02 00 00 00 40 24 00 00 40 24 06 08  ........@$..@$..
        60: 00 00 00 00 58 01 00 00 00 00 00 00 07 00 00 00  ....X...........
        70: 00 00 00 00 2f 75 73 72 2f 6c 69 62 2f 6c 64 2e  ..../usr/lib/ld.
....(以下略)

この例では、実行時に指定された400というデータ長を用いて読み込んだデータの内容を、readinプローブを契機に、128バイトごとに区切って出力しています。

なお、リスト4では視認性の点から簡素化していますが、本来copyinto()サブルーチンに指定するサイズ情報は、リスト5のように記述すべきです。

リスト5 サイズ指定超過の防止
copyinto(arg0 + self->offset, 
         (arg1 - self->offset < width
          ? arg1 - self->offset
          : width), 
         self->buf);

この記述により、有効データの残量がwidthよりも小さい場合には、有効なデータの分だけがcopyinto()アクションによるカーネル空間への取り込み対象となります。

copyinto()アクションによるユーザ空間からカーネル空間へのコピーの際に、サイズ指定が過剰である場合、当該プロセスには割り当てられていないメモリ領域に対してもカーネル空間への取り込み=メモリアクセスが実行されてしまうことで、不正領域アクセス=メモリフォールトが発生し、Dスクリプトが期待通りに実行されない可能性があります。

サイズ指定をリスト5のように記述することで、カーネル空間へのコピー対象を、有効なデータが格納されている範囲に限定することができます。

なお、"a ? b : c"形式の"条件式"(conditional expression)⁠※1)は、述語記述による条件分岐を除けば、Dスクリプト内において使用できる唯一の制御構文ですので、憶えておくと何かと便利です。

間接参照先の取得

間接参照先の内容を知りたいケースの最も典型的な例は、main()関数のargv引数から文字列を取得したい、といったようなケースでしょう。

DTrace動作原理に由来する制限

第2回で触れたように、DTraceは、指定されたDスクリプトをカーネル空間で実行しています。

そのため、関数起動時に指定された文字列引数を参照するためにはcopyinstr()サブルーチンを使用する必要がありました。

間接参照先の情報を取得する場合も、この動作原理の影響を受けます。

例として使用するmain()関数のargv引数の場合、引数の値そのものは、⁠"char*" の配列」の先頭アドレスに過ぎません。

つまり argvの値を直接使用した参照ができないのは勿論、argvの値を元に「"char*" の配列」をカーネル空間に copyinto しても、そこに格納されているのは「"char*"⁠⁠、つまりユーザ空間におけるアドレス値ですから、このままではその先にある文字列を参照することはできません。

間接参照先の取得

main() 関数のargv[0]が参照する文字列の取得を例に、間接参照先の情報を取得するためのD スクリプトをリスト6に示します。

リスト 6 argv[0] 文字列取得スクリプト (watch_argv0.d)

self uintptr_t* buf;

pid$target:$1:main:entry
{
    self->buf = alloca(sizeof(uintptr_t));
    copyinto(arg1, sizeof(uintptr_t), self->buf);
    printf("argv[0]='%s'", copyinstr(*(self->buf)));
}

copyinto()サブルーチンでカーネル空間に取り込んだアドレス情報self->bufに格納)を使用して、再度copyinstr()サブルーチンを実施することで、間接参照先の文字列argv[0] に相当)を取得しています。

Dスクリプト冒頭における"self uintptr_t* buf;" 記述は、self->bufの型を宣言しておくことで、スクリプト内で都度キャスト記述をせずに済ますためのものです。

DTraceは概ね自動的に型を判定してくれますが、必要に応じてリスト6のような適宜型宣言を行うことで、スクリプトの記述を簡素化することができます。

それでは実際に動かしてみましょう。

図3 argv[0] 文字列の採取
$ dtrace -s watch_argv0.d \
         -32 \
         -c '/usr/bin/true' \
         true
dtrace: script 'watch_argv0.d' matched 1 probe
dtrace: pid 911 has exited
CPU     ID                    FUNCTION:NAME
  0  60346                       main:entry argv[0]='/usr/bin/true'

$ 

採取対象に/usr/bin/trueコマンドを用いているのは、DTrace/D スクリプトによる出力と、コマンドの出力が混じって見辛くなるのを防ぐためですので、特に意味はありません。

ポインタ値ビット幅の問題

先ほどの実行例(図3)では、dtraceコマンド実行の際に、特に説明することなく"-32"オプションを使用しました。

実はこのオプションは、⁠ポインタ値のビット幅を32bitとして扱え」ということを指示するものです。

間接参照先の取得を行う場合、ポインタが格納されたユーザ空間の領域をカーネル空間へ複製する際と、複製した領域からのアドレス情報を取り出す際の両方で、ポインタ値のビット幅に依存した処理が必要になります。

Dスクリプトにおけるポインタ値のビット幅は、一般に "sizeof(intptr_t)" といった式を元に算出されますが、32bitアプリケーションを64bitカーネルで稼動させている場合(※2)には、アプリケーション側で想定している "sizeof(intptr_t)" 値と、カーネル側で想定している "sizeof(intptr_t)" 値が異なります。

そして、DTraceは基本的にカーネル側=ポインタ値ビット幅が64bitの立場でDスクリプトを解釈しますから、アプリケーション側=ポインタ値ビット幅を32bitとして動作しているユーザ空間との連携が適切に行えないのです。

そこで、Dスクリプトにおいて想定されているポインタ値ビット幅が32bitであることを"-32"オプションによって指定するわけです。

なお、VMWareやVirtualBox等の仮想化環境を使用する際には、32bitのホストOS上で32bit版の仮想化ソフトを使用しても、CPUが64bit対応の場合には64bitカーネルが稼動する場合がありますので、"isainfo -b"などによりカーネルの動作モードを確認することをお勧めします。

可変長間接参照への応用

main() 関数のargv[] 引数から任意の要素文字列を取得するような、可変長の間接参照に対しては、前ページでの可変長メモリのダンプにおける手法と組み合わせた、リスト7のような D スクリプトで対応することができます。

リスト7 argv[] 引数の任意個表示
self uintptr_t* argv;

pid$target:$1:main:entry
{
    self->index = -1;
}
pid$target:$1:main:entry
/(self->index += 1) < arg0/
{
    self->argv = alloca(sizeof(uintptr_t));
    copyinto(arg1 + (sizeof(uintptr_t) * self->index),
             sizeof(uintptr_t),
             self->argv);
    printf("argv[%d]='%s'", 
           self->index, copyinstr(*(self->argv)));
}
        :
    (以下、同じ記述の繰り返し)
        :

argv引数(=arg1)に対してsizeof(uintptr_t)self->index倍した値を加算することで、C/C++プログラム的に言うところのargv[self->index]に相当するアドレス値を算出しているため、copyinto()サブルーチンを使用する箇所が、少々ごちゃごちゃしてはいますが、これまでの説明を元にすれば十分理解できることと思います。

次回予告

次回は、ユーザプログラムに対してDTraceを適用する際に気になるであろう実行効率に関して、情報採取の実現方式を踏まえて説明したいと思います。

おすすめ記事

記事・ニュース一覧