1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

CPythonのバイトコードをgdb上でステップ実行するための下準備

Last updated at Posted at 2017-11-01

この投稿では,PythonのC言語実装であるCPythonにおいて,インタプリタ内部でPythonのソースコードがどのように処理されていくかをgdbで追っかけるための下準備について説明します。ソースコードを頭から読んでいくと日が暮れてしまうため,インタプリタとしての中心的な役割を果たす_PyEval_EvalFrameDefault関数に焦点を当てみたいと思います。実行環境はmacOS Sierra,Pythonは3.6.3,gdbは8.0.1です。

#はじめに
前提として,gdbが起動した状態でPythonの対話モードをプロンプトが表示される状態まで処理を移動させ,そこで入力したコードの挙動を確認していきます。Pythonは処理が実行されるときにバイトコードに変換されます。CPythonでは,バイトコードを構成する各命令コードに処理がそれぞれ割り当てられています。このある命令コードからある処理への割り当てを担当するのが_PyEval_EvalFrameDefault関数です。この関数内に,各命令コードを繰り返し実行するforループが存在し,forループ内のswitch文に各命令コードに対する処理が記されています。

#gdbで_PyEval_EvalFrameDefault関数の先頭に移動する

それでは,Pythonのコードをgdb上で確認できるよう対話モードのプロンプトが表示される箇所まで処理を移動してみます。gdbをシェルから起動した後,_PyEval_EvalFrameDefault関数に直接ブレークポイントを割り当てr(un)コマンドを実行しても,対話モードのプロンプトは表示されません。これは,対話モードに入る前のインタプリタの初期化処理の段階で_PyEval_EvalFrameDefault関数が使用されているためです。これを回避するため,初期化処理完了後しか実行されないPyRun_InteractiveOneObject関数にブレークポイントを設置して,まずはそこまで移動した後,_PyEval_EvalFrameDefault関数にブレークポイントを割り当て,関数の先頭に移動します。(gdbが下記のとおり動作しない場合は,以前投稿した記事参考にしてください。)

$ gdb python.exe
GNU gdb (GDB) 8.0
...
Reading symbols from python.exe...done.
(gdb) b PyRun_InteractiveOneObject  => まずは,PyRun_InteractiveOneObject関数にブレークポイントを設定
Breakpoint 1 at 0x10024f0c5: file Python/pythonrun.c, line 154.
(gdb) r                 => とりあえずr(un)コマンドでプログラムを実行
Thread 3 hit Breakpoint 1, PyRun_InteractiveOneObject (fp=0x7fffc8bc0110, 
    filename='<stdin>', flags=0x7fff5fbff9b8) at Python/pythonrun.c:154
154	    PyObject *m, *d, *v, *w, *oenc = NULL, *mod_name;

(gdb) b _PyEval_EvalFrameDefault   => その後に,PyEval_EvalRrameDefault関数にブレークポイントを設定
Breakpoint 2 at 0x1001dfa49: file Python/ceval.c, line 768.

(gdb) c                => c(ontinue)コマンドで処理が停止する。 => プロンプトが表示される
Continuing.
>>> 

このように,_PyEval_EvalFrameDefaultとPyRun_InteractiveOneObjectにブレークポイントを設置することによって,プロンプトが表示されるポイントとバイトコードの実行が開始される位置に停止できるので非常に便利です。

#バイトコードへの変換

_PyEval_EvalFrameDefault関数の中身について説明する前に,追いかけてみたいPythonのコードがどんなバイトコードに変換されるかを確認する方法を紹介します。Pythonを起動した後,下記のようにdisモジュールを使用して,バイトコードを表示させることができます。(下記は"a = 1"をバイトコードに変換した例)

>>> import dis
>>> dis.dis("a = 1")
  1           0 LOAD_CONST               0 (1)
              2 STORE_NAME               0 (a)
              4 LOAD_CONST               1 (None)
              6 RETURN_VALUE
>>> 

disモジュールは,シェルからも直接実行することもできます。(ファイル"a.py"には,"a = 1"と記述)

$ python3 -m dis a.py
  1           0 LOAD_CONST               0 (1)
              2 STORE_NAME               0 (a)
              4 LOAD_CONST               1 (None)
              6 RETURN_VALUE
$

#_PyEval_EvalFrameDefault関数内のブレークポイントの設置
ステップ実行の対象となる_PyEval_EvalFrameDefault関数について説明します。この関数はPython/ceval.cの768行目に存在しており,1143行目にバイトコードを逐次実行するためのforループがあります。そのforループ内にの1266行目にswitch文の先頭があります。ここ処理対象となる命令コード(opcode変数)を判定して,各命令コードにジャンプします。このようにバイトコードの実行は1266行目にあるswitch文が起点になることから,ここにブレークポイントを設置することでバイトコードを順番にステップ実行することができます。

##FAST_DISPATCHマクロ
switch文が起点になると書きましたが異なるケースが存在します。一般的な繰り返し処理では1つの処理が完了した後にfor文の先頭に戻して次の処理を行いますが,CPythonではインタプリタとしての処理速度を向上させるためswitchに移動しないケースがあります。それを実現するのがFAST_DISPATCHマクロです。FAST_DISPATCHを利用することで「for文の先頭に戻りswitch文で次の命令コードを判定する」処理を飛ばして,次の命令コードの先頭にジャンプすることができます。上記の"a = 1"のような例では,LOAD_CONSTの処理の最後でFAST_DISPATCH()マクロを実行しているため,1266行目を経由せず,STORE_NAME(Python/ceval.c:2211)に直接移動します。
*補足:FAST_DISPATCH()の位置にDISPATCH()がある場合,continue文が実行され,forループの先頭(1143行目)に戻ると想定したのですが,gdbでステップ実行するとFAST_DISPATCH()と同様に1266行目のswitch文を経由せずに次の命令コードに移動します…FAST_DISPATCHとDISPATCHの違いがわからない…。

#まとめ
上記の内容を整理します。バイトコードをgdbでステップ実行するときは以下の手順を踏みます。

  1. まずはPyRun_InteractiveOneObject関数に移動後,_PyEval_EvalFrameDefault関数にブレークポイントを設定する
  2. Python/ceval.cの1266行目(switch文)にブレークポイントを設定する

このことをおさえておけば,Pythonのソースコードの動作をバイトコードの処理の手前から追うことが可能になります。これを利用してバイトコードを追いかけた例としてこちらの投稿を用意しました。

#本編外(メモ置き場)

CPython内部では,命令コードの処理に到達する前にさまざまな前処理が行われています。要点となる箇所をメモします。

[ PyRun_InteractiveLoopFlags関数(Python/pythonrun.c:84) ]
対話モードの本体となる関数。標準入力,対話モードで出力するプロンプト(">>> ")を設定する。111行目にforループがあり,112行目にあるPyRun_InteractiveOneObject関数を繰り返し実行する。

[ PyRun_InteractiveOneObject関数内部(Python/pythonrun.c:212) ]
ここでPyParser_ASTFromFileObject関数をコール。この関数の中ではPyParser_ParseFileObjectとPyAST_FromNodeObjectを利用して,ファイルに記述されたコード(もしくはプロンプトから取得したコード)を構文木およびAST(抽象構文木)に変換している。
  
[ PyRun_InteractiveOneObject関数内部(Python/pythonrun.c:227) ]
PyImport_AddModuleObject関数をコールしている。モジュールの実体を返す。(対話モードの場合,引数に__main__がセットされる。)

[ PyRun_InteractiveOneObject関数内部(Python/pythonrun.c:232) ]
PyModule_GetDict関数をコール。モジュールの実体からディクショナリ(グローバル変数を格納)を取得している。

[ PyRun_InteractiveOneObject関数内部(Python/pythonrun.c:233) ]
run_mod関数をコール。内部でPyAst_CompileObject関数およびPyEval_EvalCode関数をコールして,ASTをバイトコードに変換,そのコードを実行。

[ run_mod関数内部(Python/pythonrun.c:277) ]
PyAst_CompileObject関数をコール。ASTをバイトコードに変換。

[ run_mod関数内部(Python/pythonrun.c:980) ]
PyEval_EvalCode関数をコール。バイトコードを実行。PyEval_EvalCode関数はPyEval_EvalCodeEx関数を経由して,_PyEval_EvalCodeWithName関数を呼び出す。

[ _PyEval_EvalCodeWithName関数内部(Python/ceval.c:3916) ]
PyFrame_New関数をコール。バイトコードを実行するためのフレームを作成。

[ _PyEval_EvalCodeWithName関数内部(Python/ceval.c:3925) ]
PyDict_New関数をコール。キーワードパラメータのためのディクショナリを作成。

[ _PyEval_EvalCodeWithName関数内部(Python/ceval.c:3952) ]
PyTuple_New関数をコール。ポテンシャル引数?のためのタプルを作成。

[ _PyEval_EvalCodeWithName関数内部(Python/ceval.c:4153) ]
PyEval_EvalFrameEx関数をコール。この関数では,現行のスレッドを取得して,そのスレッドに上記で生成したフレームをセットしてバイトコードを実行する。対話モードの場合は,PyEval_EvalFrameDefault関数が実行される。

[ PyEval_EvalFrameDefault関数(Python/ceval.c:757) ]
上記で説明した関数。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?