byebug(10.0.0)のコードリーディングをしました。

#byebugはオープンクラスでKernelに定義されています。

#byebugはByebug.attachを呼び出します。#started?や#startはC拡張の関数です。

Started関数はIS_STARTEDがtrueかどうかを返します。UNUSEDはunused parametersのWARNINGが出ないためのマクロなので読むときはスルーでOK。

IS_STARTEDはcatchpointsがヌルポインターでなければ(=何かしら設定されていれば)trueを返します。

Start関数はcatchpointsにRubyのハッシュを設定しスレッドテーブルを生成した後、register_tracepoints関数を呼びます。

register_tracepoints関数はTracePointを設定します。byebugはTracePointで:lineや:b_returnなどのイベントをフックしてREPLを起動しています。

tracepointsの配列にTracePointのインスタンスを入れて、最後にrb_tracepoint_enable関数によって有効化します。

start後はContext#step_outを呼び出します(再掲)

引数の3は

  • step_outのCメソッドからのreturn
  • Byebug.attachメソッドからのreturn
  • byebugメソッドからのreturn

の3つのreturnが終わった後、ブレークするために設定している値です。

Context_step_outは以下の通りです。debug_context_t構造体のメンバ変数steps_outが3に設定され、forceがtrueになり、contextのフラグにCTX_FL_STOP_ON_RETが立ちます。その名の通り、returnで止まるフラグです。

Byebug.startで設定したTracePointにより、メソッドのreturnやブロックのreturnに対してreturn_eventの関数がコールバックとして呼ばれます。call_at_xxx系の関数はcall_at関数を呼び出します。call_at関数はline_event関数のところで説明します。

RETURN_EVENT_SETUPやRETURN_EVENT_TEARDOWNのマクロは以下のように定義されており、context->steps_outが1の場合にdc->stepsに1をセットしたり、context->steps_outを減らします。

steps_outは3なので、「step_outのCメソッドからのreturn」「Byebug.attachメソッドからのreturn」「byebugメソッドからのreturn」が終わるとdc->steps_out が0でdc->stepsが1の状態になります。

一方、式の評価であるTracePointの:lineイベントを呼び出すとコールバックとしてline_event関数が呼ばれます。

EVENT_SETUPマクロによって context変数やdc変数がセットされます。

CTX_FL_IGNORE_STEPSが立っていなければdc->stepsが1減算されdc->stepsが0のとき(byebugを呼び出してreturnが3回呼ばれた状態)にcall_at_line_check関数が呼ばれます。call_at_line_checkはさらにcall_at_line関数を呼び出し、call_at_lineはcall_at関数を呼び出します。

call_at関数は以下のように定義されています。call_at_lineの場合、midは:at_lineのシンボルのIDが入ります。

call_atはcall_with_debug_inspectorを呼び出します。rb_ensureは第一引数の関数を第二引数を引数として呼び出し、raiseしたら第三引数の関数を第四引数を引数として呼び出します。

open_debug_inspector関数はrb_debug_inspector_openを呼び出します。rb_debug_inspector_openを呼び出している理由はload_backtraceメソッドによってバックトレースを取得するためです。最終的にopen_debug_inspector_iが呼び出され、rb_funcall2によりContext#at_lineが呼ばれます。

processorはCommandProcessorのインスタンスなのでCommandProcessor#at_lineが呼ばれます。#at_lineは#process_commandsを呼びます。

#replではプロンプトを表示してユーザからの入力を待ち、入力されたコマンドを#run_cmdで実行します。

次に各コマンドについて読んでいきます。

nextは以下のような定義になっています。Context#step_overを呼び出し、CommandProcessor#proceed!を呼び出します。#proceed!はREPLの入出力ループを抜けるためのフラグ制御です。

Context_step_overは以下のように定義されています。nextを引数無しで叩いた場合はcontext->linesに1、context->dest_frameはcontext->calced_stack_size(=バックトレースのスタック数)がセットされます。

context->linesはline_eventで1ずつ減少します。nextを叩いた時のスタック階層と同じか、上の階層のスタックのline_eventの場合のみ減少しています(そうしないとstep_inしちゃう)。line_eventはcontext->linesが0のときにもcall_at_line_checkを呼び出すので結果として次の行でブレークする処理を実現しています。

stepコマンドは以下のような定義になっています。Context#step_intoを呼び出した後、CommandProcessor#proceed!を呼び出します。

step_intoの場合はstepsを設定します。stepsはスタック階層によらずline_eventで1ずつ減少するため、step inを実現できます。

finishコマンドは以下のような定義になっています。引数無しで呼び出した場合、step_outの第一引数は1、forceはfalseになります。

step_outが1のときにreturnイベントがあると、context->stepsに1がセットされるのでreturn直後のline_eventでブレークします。

Cのコードを読む時のあれこれ

Cのコードを読むにあたっては、gdb がかなり便利です。gdbを使ってRubyのデバッグをする方法はこちらの記事がとても詳しいです↓

ホストOS上でやっても良いですがDockerコンテナ上でやると環境が汚れないし色々と便利です。

Dockerfileをこんな感じ↓で書いてコンテナ立ち上げると、デバッグ用Rubyが入った環境であれこれできます。

gdb周りだとGDB dashboardがとても便利だし見栄えが良くなってデバッグ時のテンションが上がるのでオススメ。

 

あと、C読むときにハマったのがマクロのところで、例えばこんな感じな関数

一見して「trace_argって書いてあるけど、どこにも定義されてないじゃないか!」となりそうなんですが、EVENT_SETUPはマクロでビルド時に展開されるため、EVENT_SETUPの変数定義も入ることになります。

また、RubyのC拡張のコードにはRubyのオブジェクトの中にCの構造体をラップして入れる機能があります。

byebugで言うと、steps_outなどのメンバ変数を持つdebug_context_t構造体はContextオブジェクト内にラップされています。以下はContextオブジェクトを新規に生成するcontext_create関数の定義です。debug_context_tを新規に生成してそれをラップしたContextオブジェクトをData_Wrap_Struct関数によって生成しています。

ラップされたオブジェクトからデータを取得するにはData_Get_Struct関数を使います。Contextの各メソッドは内部のdebug_context_t構造体を取得して構造体のメンバ変数を操作しています。