この投稿では,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でステップ実行するときは以下の手順を踏みます。
- まずはPyRun_InteractiveOneObject関数に移動後,_PyEval_EvalFrameDefault関数にブレークポイントを設定する
- 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) ]
上記で説明した関数。