Публикации и ответы на комментарии в блогах JetBrains не выходят на русском языке с 2022 года.
Приносим извинения за неудобства.
Inside Ruby Debuggers: TracePoint, Instruction Sequence, and CRuby API
Hello, Ruby developers!
Debugging is a key part of software development, but most developers use debuggers without knowing how they actually work. The RubyMine team has spent years developing debugging tools for Ruby, and we want to share some of the insights we’ve gained along the way.
In this post, we’ll explore the main technologies behind Ruby debuggers — TracePoint, Instruction Sequence, and Ruby’s C-level debugging APIs.
We’ll begin with TracePoint and see how it lets debuggers pause code at key events. Then we’ll build a minimal debugger to see it in action. Next, we’ll look at Instruction Sequences to understand what Ruby’s bytecode looks like and how it works with TracePoint. Finally, we’ll briefly cover Ruby’s C-level APIs and the extra power they offer.
This blog post is the second in a series based on the Demystifying Debuggers talk by Dmitry Pogrebnoy, RubyMine Team Leader, presented at EuRuKo 2024 and RubyKaigi 2025. If you haven’t read the first post yet, it’s a good idea to start there. Prefer video? You can also watch the original talk here.
Ready? Let’s start!
The core technologies behind any Ruby debugger
Before diving into the debugger internals, it’s essential to understand the two core technologies that make Ruby debugging possible: TracePoint and Instruction Sequence. Regardless of which debugger you use, they all rely on these fundamental features built into Ruby itself. In the following sections, we’ll explore how each of them works and why they’re so important.
TracePoint: Hooking into Code Execution
Let’s begin with TracePoint, a powerful instrumentation technology introduced in Ruby 2.0 back in 2013. It works by intercepting specific runtime events such as method calls, line executions, or exception raises and executing custom code when these events occur. TracePoint works in almost any Ruby context, and it works well with Thread and Fiber. However, it currently has limited support for Ractor.
Let’s take a look at the example and see how TracePoint works.
def say_hello
puts "Hello Ruby developers!"
end
TracePoint.new(:call) do |tp|
puts "Calling method '#{tp.method_id}'"
end.enable
say_hello
# => Calling method 'say_hello'
# => Hello Ruby developers!
In this example, we have a simple say_hello method containing a puts statement, along with a TracePoint that watches events of the call type. Inside the TracePoint block, we print the name of the method being called using method_id. Looking at the output in the comments, we can see that our TracePoint is triggered when entering the say_hello method, and only after that do we see the actual message printed by the method itself.
This example demonstrates how TracePoint lets you intercept normal code execution at specific points where special events occur, allowing you to execute your own custom code. Whenever your debugger stops on a breakpoint, TracePoint is in charge. This technology is valuable for more than just debugging. It is also used in performance monitoring, logging, and other scenarios where gaining runtime insights or influencing program behavior is necessary.
Building the simplest Ruby debugger with TracePoint
With just TracePoint technology, you can build what might be the simplest possible Ruby debugger you’ll ever see.
def say_hello
puts "Hello Ruby developers!"
end
TracePoint.new(:call) do |tp|
puts "Call method '#{tp.method_id}'"
while (input = gets.chomp) != "cont"
puts eval(input)
end
end.enable
say_hello
This is almost the same code as in the TracePoint example, but this time the TracePoint code body is slightly changed.
Let’s examine what’s happening here. The TracePoint block accepts user input via gets.chomp, evaluates it in the current context using the eval method, and prints the result with puts. That’s really all there is to it — a straightforward and effective debugging mechanism in just a few lines of code.
This enables one of the core features of a debugger — the ability to introspect the current program context on each method invocation and modify the state if needed. You can, for example, define a new Ruby constant, create a class on the fly, or change the value of a variable during execution. Simple and powerful, right? Try to run it by yourself!
Clearly, this isn’t a complete debugger — it lacks exception handling and many other essential features. But when we strip away everything else and look at the bare bones, this is the fundamental mechanism that all Ruby debuggers are built upon.
This simple example demonstrates how TracePoint serves as the foundation for Ruby debuggers. Without TracePoint technology, it would be impossible to build a modern Ruby debugger.
Instruction Sequence: Ruby’s bytecode revealed
Another crucial technology for Ruby debuggers is Instruction Sequence.
Instruction Sequence, or iseq for short, represents the compiled bytecode that the Ruby Virtual Machine executes. Think of it as Ruby’s “assembly language” — a low-level representation of your Ruby code after compilation into bytecode. Since it’s closely tied to the Ruby VM internals, the same Ruby code can produce a different iseq in different Ruby versions, not just in terms of instructions but even in their overall structure and relationships between different instruction sequences.
Instruction Sequence provides direct access to the low-level representation of Ruby code. Debuggers can leverage this feature by toggling certain internal flags or even modifying instructions in iseq, effectively altering how the program runs at runtime without changing the original source code.
For example, a debugger might enable trace events on a specific instruction that doesn’t have one by default, causing the Ruby VM to pause when that point is reached. This is how breakpoints in specific language constructions and stepping through chains of calls work. The ability to instrument bytecode directly is essential for building debuggers that operate transparently, without requiring the developer to insert debugging statements or modify their code in any way.
Let’s take a look at how to get an Instruction Sequence in Ruby code.
def say_hello puts "Hello Ruby developers 💎!" end method_object = method(:say_hello) iseq = RubyVM::InstructionSequence.of(method_object) puts iseq.disasm
Let’s examine this code more closely. First, we have our familiar say_hello method containing a puts statement. Then, we create a method object from it using method(:say_hello). Finally, we get the Instruction Sequence for this method and print out its human-readable form using disasm. This lets us peek under the hood and see the actual bytecode instructions that Ruby will execute.
Let’s examine the output and see what it looks like.
== disasm: #<ISeq:say_hello@iseq_example.rb:1 (1,0)-(3,3)> 0000 putself ( 2)[LiCa] 0001 putchilledstring "Hello Ruby developers 💎!" 0003 opt_send_without_block <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE> 0005 leave ( 3)[Re]
The first line shows metadata about our Ruby entity. Specifically, the say_hello method defined in iseq_example.rb with a location range (1,0)-(3,3). Below that are the actual instructions that the Ruby VM will execute. Each line represents a single instruction, presented in a human-readable format. You can easily spot the “Hello Ruby developers 💎!” string argument preserved exactly as it appears in the source code, without any encoding or decoding complexity, even with non-ASCII symbols. Such transparency makes it easier for you to understand what’s happening at the bytecode level.
Instruction Sequence plays a critical role in Ruby debugging by marking key execution points in the bytecode. In bracket notation in the output, you can notice markers like Li for line events, Ca for method calls, and Re for returns. These markers tell the Ruby VM when to emit runtime events. TracePoint relies on these markers to hook into the running program — it listens for these events and steps in when they happen. This tight connection between two technologies is what makes it possible for debuggers to pause execution and inspect the state.
Going deeper: Ruby’s C-level debugging API
So far, we’ve looked at the two core technologies behind Ruby debuggers — TracePoint and Instruction Sequence. These are enough to build a working Ruby debugger. However, if you want to implement advanced features like those offered by RubyMine, such as smart stepping or navigating back and forth through the call stack, TracePoint and Instruction Sequence alone won’t cut it. To support such capabilities, you need to go a level deeper and tap into the low-level debugging APIs provided by Ruby itself.
CRuby exposes a number of internal methods that fill the gaps left by the public Ruby APIs. These methods are defined in C headers such as vm_core.h, vm_callinfo.h, iseq.h, and debug.h, among others. These internal interfaces can unlock powerful capabilities that go beyond what’s possible with the public API, but they come with important trade-offs.
Since they are specific to CRuby, debuggers using them won’t work with other implementations like JRuby or TruffleRuby. Another downside is that these APIs are not public or stable across Ruby versions. Even minor updates can break them, which means any debugger depending on these methods needs constant attention to keep up with Ruby’s changes. Still, it’s worth exploring a few of these internal methods to get a better idea of what this low-level API looks like and what it provides for debugger tools.
Let’s start with rb_tracepoint_new(...):
VALUE rb_tracepoint_new(VALUE target_thread_not_supported_yet, rb_event_flag_t events, void (*func)(VALUE, void *), void *data);
This method works like creating a trace point in Ruby code, but with more flexibility for advanced use. It’s especially helpful for low-level debuggers written as C extensions that need deeper access to the Ruby VM. In the RubyMine debugger, this approach allows more precise control over when and where to enable or disable trace points, which is essential for implementing smart stepping.
Another useful method is rb_debug_inspector_open(...):
VALUE rb_debug_inspector_open(rb_debug_inspector_func_t func, void *data);
This C-level API lets you inspect the call stack without changing the VM state. The func callback receives a rb_debug_inspector_t struct, which provides access to bindings, locations, instruction sequences, and other frame details. In the RubyMine debugger, it’s used to retrieve the list of frames and implement the ability to switch between them back and forth on the call stack when the program is suspended by the debugger. Without this API, frame navigation and custom frame inspection in Ruby would be much more difficult.
The final example is a pair of methods for working with iseq objects. The method rb_iseqw_to_iseq(...) converts an iseq from a Ruby value to a C value, while rb_iseq_original_iseq(...) converts it back from C to Ruby. These let Ruby debuggers switch between Ruby and C-extension code when precise, low-level control is needed. In the RubyMine debugger, they are actively used in the implementation of smart stepping, helping determine which code should be stepped into during debugging.
These low-level APIs offer powerful tools for building advanced debugging features — the kind that aren’t possible with TracePoint and Instruction Sequence alone. But they come with a cost: platform lock-in to CRuby and a high maintenance burden due to their instability across Ruby versions. Despite that, they remain essential for debuggers that need deep integration with the Ruby VM.
Conclusion
In this post, we explored the foundational technologies that power Ruby debuggers — TracePoint and Instruction Sequence. These two components form the basis for how modern Ruby debuggers observe and interact with running Ruby code. TracePoint enables hooks into specific runtime events like method calls and line execution, while Instruction Sequence provides low-level access to the compiled Ruby VM bytecode.
We also took a brief look at how low-level CRuby C APIs exert even more precise control over code execution, offering insight into how debuggers like RubyMine implement advanced features. While we didn’t dive into full debugger implementations here, this foundation lays the groundwork for understanding how these tools operate.
Stay tuned — in a future post, we’ll go further into how modern debuggers are built on top of this foundation.
Happy coding, and may your bugs be few and easily fixable!
The RubyMine team
Subscribe to RubyMine Blog updates
Discover more
Ruby デバッガーの仕組み: TracePoint、命令列、CRuby API
Ruby 開発者の皆さん、こんにちは!
デバッグはソフトウェア開発における重要な工程ですが、ほとんどの開発者は実際のデバッグの仕組みを理解せずにデバッガーを使用しています。RubyMine チームは数年にわたって Ruby 用のデバッグツールを開発しており、その開発の行程で得た知見の一部を共有したいと考えています。
この記事では、Ruby デバッガーの背後にある TracePoint、命令列、および Ruby の C レベルデバッグ API という主な技術について説明します。
まずは TracePoint について取り上げ、それを使用してデバッガーに重要なイベントでコードを停止させる仕組みを見てみましょう。その後、最小限の機能を備えたデバッガーを構築して実際の動作を確認します。次に、Ruby のバイトコード概要とその TracePoint との連携の仕組みを理解するため、命令列について取り上げます。最後に、Ruby の C レベル API とそのメリットについて簡単に説明します。
このブログ記事は、EuRuKo 2024 と RubyKaigi 2025 で RubyMine のチームリーダーである Dmitry Pogrebnoy が講演した「Demystifying Debuggers」に基づく連載の第 2 部です。最初の記事をまだご覧になっていない方は、こちらを先にお読みいただくことをお勧めします。動画をご希望ですか?元の講演は、こちらからご視聴いただけます。
準備はできましたか?では、始めましょう!
Ruby デバッガーの背後にあるコア技術
デバッガーの内部を詳しく見る前に、Ruby デバッガーを実現している TracePoint と命令列という 2 つのコア技術を理解しておく必要があります。使用するデバッガーを問わず、どれも Ruby 自体に組み込まれたこれらの基本機能を使用しています。次のセクションでは、各技術の仕組みとその重要性について説明します。
TracePoint: コード実行へのフック
まずは TracePoint から説明します。これは 2013 年に Ruby 2.0 で導入された強力なインストルメンテーション技術であり、メソッドの呼び出し、行の実行、または例外の発生などの特定の実行時イベントをインターセプトし、これらのイベントが発生した際にカスタムコードを実行することで機能します。TracePoint はほぼすべての Ruby コンテキストで機能し、スレッドとファイバーとうまく連携します。ただし、現時点では Ractor のサポートに制限があります。
例を見ながら TracePoint の機能を確認してみましょう。
def say_hello
puts "Hello Ruby developers!"
end
TracePoint.new(:call) do |tp|
puts "Calling method '#{tp.method_id}'"
end.enable
say_hello
# => Calling method 'say_hello'
# => Hello Ruby developers!
この例には、puts ステートメントを含む単純な say_hello メソッドと、call 型のイベントをウォッチする TracePoint が含まれています。TracePoint の中では、method_id を使用して呼び出し対象のメソッドの名前を出力しています。コメントの出力を見ると、say_hello メソッドに入る際に TracePoint がトリガーされており、それが完了して初めてメソッド自体が実際のメッセージを出力していることが分かります。
この例は、TracePoint を使用して普通のコードの実行を特別なイベントが発生する特定のポイントでインターセプトし、独自のカスタムコードを実行できることを示しています。ブレークポイントでデバッガーが停止するたびに、TracePoint が処理を担います。この技術はデバッグ以外にも役立つため、パフォーマンスの監視、ログの記録、さらには実行時の挙動に関する知見を得たり、プログラムの動作に影響を与えたりする必要があるその他の場面でも使用されます。
TracePoint を使用したごく単純な Ruby デバッガーの構築
TracePoint の技術のみを使用し、可能な限り単純な Ruby デバッガーを構築してみましょう。
def say_hello
puts "Hello Ruby developers!"
end
TracePoint.new(:call) do |tp|
puts "Call method '#{tp.method_id}'"
while (input = gets.chomp) != "cont"
puts eval(input)
end
end.enable
say_hello
これは TracePoint の例とほぼ同じコードですが、今回は <0>TracePoint コードの本文が若干変更されています。
その挙動を詳しく見てみましょう。TracePoint ブロックは gets.chomp 経由でユーザーの入力を受け付けており、その入力を現在のコンテキスト内で eval メソッドを使用して評価し、その結果を puts で出力しています。本当にそれだけのことです。たった数行のコードで単純明快かつ効果的なデバッグの仕組みが構築されています。
これにより、各メソッドの呼び出しで現在のプログラムコンテキストをイントロスペクトし、必要に応じて状態を変更するというデバッガーのコア機能の 1 つが実現されています。たとえば、新しい Ruby 定数の定義、その場でのクラスの作成、実行中の変数の値変更などが可能です。簡単で強力な仕組みだと思いませんか?ぜひ実行してみてください!
ただ、これが完成形のデバッガーでないことは明らかです。例外処理やその他多くの基本機能が欠落しています。しかし、それ以外の要素を無視して骨組みだけを見てみると、これがすべての Ruby デバッガーの基礎となる基本的な仕組みであることがわかります。
この単純な例は、TracePoint が Ruby デバッガーの基礎として機能することを示しています。TracePoint の技術がなければ、モダンな Ruby デバッガーの構築は不可能だったことでしょう。
命令列: Ruby のバイトコードの解明
Ruby デバッガーのもう 1 つの重要な技術は命令列です。
命令列(短縮形は iseq)は、Ruby 仮想マシンが実行するコンパイル済みのバイトコードを表します。これは Ruby の「アセンブリ言語」(バイトコードにコンパイルした後の Ruby コードの低位表現)だと考えることができます。命令列は Ruby VM の内部動作に緊密に結び付いているため、Ruby のバージョンが異なれば、同じ Ruby コードでも命令の観点だけでなく、全体的な構造や異なる命令列間の関係においても異なる iseq が生成される場合があります。
命令列により、Ruby コードの低位表現に直接アクセスすることができます。デバッガーは特定の内部フラグを切り替えたり、 iseq 内の命令を変更したりすることでこの機能を活用し、実行時に元のソースコードを変更することなくプログラムの実行方法を効果的に変更することができます。
たとえば、デバッガーがデフォルトではトレースイベントがない特定の命令でトレースイベントを有効にすると、そのポイントに到達した時点で Ruby VM が一時停止する場合があります。これは、特定の言語構造と呼び出しのステップ実行チェーンにおけるブレークポイントの動作です。開発者がデバッグステートメントを挿入したり、何らかの方法でコードを変更したりすることなく透明的に動作するデバッガーを構築するには、バイトコードを直接インストルメント化する機能が不可欠です。
Ruby コードで命令列を取得する方法を見てみましょう。
def say_hello puts "Hello Ruby developers 💎!" end method_object = method(:say_hello) iseq = RubyVM::InstructionSequence.of(method_object) puts iseq.disasm
このコードをさらに詳しく調べてみましょう。まず、先述の puts ステートメントを含む <0>say_hello メソッドがあります。次に method(:say_hello) を使用し、そのメソッドからメソッドオブジェクトを作成しています。最後に disasm を使用し、そのメソッドの命令列を取得して人間が読み取れる形式で出力しています。これにより、内部動作を調査し、Ruby が実行する実際のバイトコード命令を確認できるようになります。
出力を調査し、どのように表示されるのか見てみましょう。
== disasm: #<ISeq:say_hello@iseq_example.rb:1 (1,0)-(3,3)> 0000 putself ( 2)[LiCa] 0001 putchilledstring "Hello Ruby developers 💎!" 0003 opt_send_without_block <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE> 0005 leave ( 3)[Re]
最初の行に Ruby エンティティに関するメタデータが表示されています。具体的には、(1,0)-(3,3) の位置範囲で iseq_example.rb に say_hello メソッドが定義されているという情報です。その下には、Ruby VM が実行する実際の命令があります。各行は 1 つの命令を表しており、人間が読み取れる形式で表示されています。「Hello Ruby developers 💎!」文字列引数はソースコード内の記述内容が維持されているため、簡単に読み取れます。複雑なエンコーディングやコーディングも、非 ASCII 文字列もありません。このような透明性により、バイトコードレベルでの挙動をより簡単に理解できるようになります。
命令列はバイトコード内の重要な実行ポイントをマークすることで、Ruby のデバッグにおいて重要な役割を果たしています。出力内の角括弧表記では、行イベントの Li やメソッド呼び出しの Ca、そして返却処理の Re といったマーカーが示されています。これらのマーカーは、Ruby VM に実行時イベントを発動させるべきタイミングを指示しています。TracePoint はこれらのマーカーを参照して実行中のプログラムにフックします。これらのイベントをリッスンし、イベントが発生する際に介入するのです。2 つの技術がこのように緊密に結び付いていることで、デバッガーが実行を一時停止し、状態を検査することが可能になっています。
さらに踏み込む: Ruby の C レベルデバッグ API
ここまでで、Ruby デバッガーの背後にある TracePoint と命令列という 2 つのコア技術を説明してきました。実用的な Ruby デバッガーを構築するにはこれらだけでも十分ですが、RubyMine が提供する高度な機能(スマートステップ実行やコールスタック内の前後移動など)を実装する場合は、TracePoint と命令列だけでは不可能です。そのような機能をサポートするには、もう一段階踏み込み、Ruby 自体が提供する低レベルのデバッグ API を活用する必要があります。
CRuby は、一般公開されている Ruby API によって残されたギャップを埋める多数の内部メソッドを公開しています。そのようなメソッドには、vm_core.h、vm_callinfo.h、iseq.h、debug.h などの C ヘッダーに定義されているものがあります。これらの内部インターフェースにより、一般公開の API の対応範囲を超える強力な機能を利用できるようになりますが、重要なトレードオフが伴います。
これらは CRuby に特化しているため、これらを使用するデバッガーは JRuby や TruffleRuby などの他の実装では機能しません。これらの API は、すべての Ruby バージョンで公開版でも安定版でもないという欠点もあります。些細な更新でも機能しなくなる可能性があるため、これらのメソッドを利用しているデバッガーは Ruby の変更に対応できるように常に注意しておく必要があります。しかし、この低位 API がどのようなもので、デバッガーツールに何を提供するのかを把握しておくため、このような内部メソッドをいくつか調査する価値はあります。
rb_tracepoint_new(…) から始めましょう。
VALUE rb_tracepoint_new(VALUE target_thread_not_supported_yet, rb_event_flag_t events, void (*func)(VALUE, void *), void *data);
このメソッドは Ruby コードにトレースポイントを作成するのと同じように機能しますが、より柔軟性が高く、高度な用途に使用できます。Ruby VM により深くアクセスする必要のある C 拡張機能として作成された低レベルのデバッガーでは特に役立ちます。RubyMine デバッガーではこのアプローチを採用することで、トレースポイントを有効/無効化するタイミングと場所をより正確にコントロールできるようにしています。これはスマートステップ実行の実装に不可欠なものです。
rb_debug_inspector_open(…) という便利なメソッドもあります。
VALUE rb_debug_inspector_open(rb_debug_inspector_func_t func, void *data);
この C レベルの API では、VM の状態を変更せずにコールスタックを調査できます。func コールバックは、バインディング、位置、命令列、およびその他のフレームの詳細を提供する rb_debug_inspector_t 構造体を受け取ります。RubyMine デバッガーでは、プログラムがデバッガーによって中断された際にフレームのリストを取得し、コールスタック上でフレームを切り替える機能を実装するために使用されています。この API がない場合、Ruby でのフレーム内の移動操作とカスタムフレームの検査はかなり困難になるでしょう。
最後の例は、iseq オブジェクトと連携するメソッドのペアです。rb_iseqw_to_iseq(…) メソッドは Ruby の値から C の値に iseq を変換するのに対し、rb_iseq_original_iseq(…) メソッドは C から Ruby に変換します。これらのメソッドを使用することで、Ruby デバッガーは正確な低レベルの制御が必要な場合に Ruby コードと C 拡張機能コード間を切り替えています。RubyMine デバッガーでは、これらはスマートステップ実行の実装で活発に使用されており、どのコードがデバッグ中にステップインされるべきかを判断する上で役立っています。
これらの低レベルの API は、TracePoint と命令列だけでは不可能な高度なデバッグ機能の構築に使用できる強力なツールを提供します。ただし、これらにはコストが伴います。プラットフォームが CRuby に固定され、複数の Ruby バージョン間で動作が不安定になるために保守の負担が大きくなるのです。とはいえ、Ruby VM との緊密な統合を必要とするデバッガーでは、これらは依然として不可欠なものです。
まとめ
この記事では、Ruby デバッガーを強力にする TracePoint と命令列と言う基本技術を説明しました。これらの 2 つのコンポーネントは、モダンな Ruby デバッガーが実行中の Ruby コードをどのように観測し、対話するかの基礎を形成するものです。TracePoint はメソッドの呼び出しや行の実行といった特定の実行時イベントへのフックを可能にするのに対し、命令列ではコンパイル済みの Ruby VM バイトコードへの低レベルアクセスを得られます。
また、低レベルの CRuby C API によってコード実行をさらに正確に制御する方法についても簡単に説明し、RubyMine などのデバッガーが高度な機能をどのように実装しているかについての理解を深めました。ここでは完全なデバッガーの実装については説明しませんでしたが、この基礎はこれらのツールの動作を理解するための基盤となります。
今後の記事では、モダンなデバッガーがこの基盤の上にどのように構築されているかをさらに詳しく説明します。
コーディングをお楽しみください。バグが少なく、簡単に修正できることを願っています!
RubyMine チーム一同より