# gdb.rb -- a simple wrapper around the GDB console to make inspecting # ruby processes less painful. # # Author: Jamis Buck # # This library is in the public domain. You are free to use it, modify # it, redistribute it, or ignore it, as you wish, with absolutely no # restrictions. # # CAVEATS: # * The current incarnation is not very error tolerant. If you run # into problems, try running it with the $gdb_version variable set # to true. # * Sometimes, the current implementation will kill the process it # attaches to. I suspect it has something to do with garbage # collection kicking in... I leave it as an exercise for the reader. # # See the bottom of this file for a handful of unit tests. require 'rbconfig' class Integer # Monkey patch the Integer class to make it easier to compute # word offsets. "5.ints" gives you the size (in bytes) of 5 # integers (words) on the current platform. def ints self * 1.size end end # The GDB module provides a "raw", low-level, language-independent # interface to GDB (GDB::Interface) as well as a higher-level # interface specifically for Ruby programs (GDB::Ruby). Theoretically, # GDB::Interface could be extended to support other languages # than Ruby. module GDB # This module is used to extend objects (like Strings) that originate # in the remote instance. This lets us reuse them easily in subsequent # calls to the remote instance. module RemoteObject attr_accessor :address end # The basic interface for wrapping GDB. You could concievably use this # to interace with arbitrary remote processes, though it is best used # by extending it to add platform-specific functionality. class Interface # Creates a new GDB::Interface that connects to process-id and # executable. def initialize(executable, pid) @gdb = IO.popen("gdb -q #{executable} #{pid} 2>&1", "r+") read_to_prompt end # Returns the (32-bit) integer at the given address def int_at(address) command("x/1dw #{address}").first.split(/:/).last.to_i end # Returns the string located at the given address def string_at(address) command("x/1s *#{address}").first.match(/"(.*)"/)[1] end # Returns the (8-bit) double precision floating point value at # the given address. def double_at(address) command("x/1fg #{address}").first.split(/:/).last.to_f end # Executes the given command and returns the output as an array # of lines. The command must be in GDB's syntax. If $gdb_verbose # is true, this will print the command to STDOUT as well. def command(cmd) puts "(gdb) #{cmd}" if $gdb_verbose @gdb.puts(cmd.strip) read_to_prompt end # Call a C function. The +function+ parameter must include any # parameters, all of which must be in valid C syntax. +opts+ # should include a :return key, which indicates # what the expected return type of the function is: # # * :int # * :string # * :void # # Currently, the default is :void. The method # returns a value of the expected type. def call(function, opts={}) cast = case opts[:return] when :int then "int" when :string then "char*" when nil, :void then "void" else Kernel.raise "unsupported return type #{opts[:return].inspect}" end result = command("call (#{cast})#{function}") case opts[:return] when :int if md = result.first.match(/= (-?\d+)/) md[1].to_i else Kernel.raise "couldn't match integer result from #{result.inspect}" end when :string then result.first.match(/"(.*)"/)[1] else nil end end # Reads all lines emitted by GDB until a recognized prompt is encountered. # Currently recognized prompts are: # # * "(gdb) " # * " >" # # If your version of GDB uses a different kind of prompt, you should # modify this method to include it. # # This method returns the lines read as an array. If $gdb_verbose is # true, the lines will be echoed to STDOUT. def read_to_prompt lines = [] line = "" while result = IO.select([@gdb]) next if result.empty? c = @gdb.read(1) break if c.nil? line << c break if line == "(gdb) " || line == " >" if line[-1] == ?\n lines << line line = "" end end puts lines.map { |l| "> #{l}" } if $gdb_verbose lines end # A convenience method for executing GDB commands. Any unrecognized # message is packaged up and invoked by GDB. def method_missing(sym, *args) cmd = sym.to_s cmd << " #{args.join(' ')}" unless args.empty? command(cmd) end end # This is an extension of the Interface class that implements Ruby-specific # functionality. class Ruby < Interface # A wrapper for a remote Object instance. class Object attr_reader :gdb attr_reader :address # Given a gdb instance and an address, determine what type of Object # is at the given address, and return it. def self.from_ptr(gdb, address) case when (address & 0x01) == 1 then address >> 1 when address == 0 then false when address == 2 then true when address == 4 then nil when (address & 0xff) == 0x0e then gdb.id2name(address) else case (type = (gdb.int_at(address) & 0x3f)) when 2 then Object.new(gdb, address) when 6 then gdb.double_at(address+2.ints) when 7 then string = gdb.string_at(address+3.ints) string.extend(RemoteObject) string.address = address string when 9 then Array.new(gdb, address) when 11 then Hash.new(gdb, address) else Object.new(gdb, address) end end end # Given a value, return an integer representing the VALUE of the # object on the remote instance. def to_gdb_value(value) return value.address if value.respond_to?(:address) case value when Fixnum then ((value << 1)+1).to_s when false then "0" when true then "2" when nil then "4" when String then gdb.call("rb_str_new2(#{value.inspect})", :return => :int).to_s else Kernel.raise "not supported yet: #{value.class.name}" end end # Creates a new remote Object reference for the given gdb instance # and address. def initialize(gdb, address) @gdb = gdb @address = address.to_i end # Invokes a method on the remote Object reference, returning the # result as another Object. def send(sym, *args) command = "rb_funcall(#{address}, #{gdb.intern(sym.to_s)}, #{args.length}" args = args.map { |arg| to_gdb_value(arg) }.join(", ") command << ", #{args}" unless args.empty? command << ")" gdb.object_from(command) end # A convenience method for #send def method_missing(sym, *args) send(sym, *args) end end # A representation of a remote Array instance. class Array < Object # Query the length of the remote Array instance def length @length ||= gdb.int_at(address+2.ints) end # Return the object at the given index def [](index) gdb.object_from("rb_ary_entry(#{address}, #{index})") end # Iterate over each element of the remote array def each length.times { |idx| yield self[idx] } end # Convert the remote Array into a local (true) Array instance. def to_ary ary = [] each { |item| ary << item } ary end end # Wraps a remote Hash instance. All it really provides, though, is a # way to build a local Hash instance that mirrors it. class Hash < Object # Return a ::Hash instance that has the same keys and values as # the remote hash instance. Default values are not preserved. def to_h keys.to_ary.inject({}) do |hash, key| hash[key] = self[key] hash end end end # Create a new GDB::Ruby instance that attaches to the given # process ID. By default, the same ruby interpreter is used # as is running the GDB::Ruby script, but you can override # that by using the second parameter. def initialize(pid, ruby=nil) ruby ||= File.join(Config::CONFIG["bindir"], Config::CONFIG["RUBY_INSTALL_NAME"] + Config::CONFIG["EXEEXT"]) @interns = {} @names = {} super(ruby, pid) end # Returns the symbol for the given numberic ID, using the remote # environment. def id2name(id) @names[id] ||= call("rb_id2name(#{id >> 8})", :return => :string).to_sym end # Returns the numeric ID on the remote host that represents the # internalized string. def intern(string) @interns[string] ||= call("rb_intern(#{string.inspect})", :return => :int) end # Executes the given command (C API) and returns the result as a wrapped # GDB::Ruby::Object. def object_from(cmd) Object.from_ptr(self, call("#{cmd}", :return => :int)) end # Evaluates the given string in the remote environment, returning the # resulting object. Exceptions are silently ignored. def eval(string) object_from("rb_eval_string_protect(#{string.inspect}, (int*)0)") end # Returns a hash of the local variables that are active in the remote # process, at it's current scope. def local_variables eval("local_variables").to_ary.inject({}) do |hash, name| hash[name] = eval(name) hash end end # Returns an array of the backtrace of the remote process. def backtrace object_from("backtrace(-1)").to_ary end # Raises an exception in the remote process. The +exc+ parameter # must be one of the core exception classes, like Exception, # RuntimeError, and so forth. def raise(exc, message) call "rb_raise((int)rb_e#{exc}, #{message.inspect})" end # Returns a hash of the classes known to the remote instance, # with the number of instances of each of the classes. def object_space eval("h={}; ObjectSpace.each_object { |obj| h[obj.class.name] ||= 0; h[obj.class.name] += 1 }; h").to_h end end end if __FILE__ == $0 require 'test/unit' SENTINEL = "/tmp/gdb-ruby-test.pid" FLAG_FILE = "/tmp/gdb-ruby-test.flag" SCRIPT = < e File.open("#{FLAG_FILE}", "w") { |f| f.write(e.message) } end EOS SCRIPT_NAME = "/tmp/gdb-ruby-test.rb" class GDBRubyTest < Test::Unit::TestCase def setup File.open(SCRIPT_NAME, "w") { |f| f.write(SCRIPT) } start_script @gdb = GDB::Ruby.new(@pid) end def teardown @gdb.quit kill_script File.delete(FLAG_FILE) rescue nil File.delete(SENTINEL) rescue nil File.delete(SCRIPT_NAME) rescue nil end def test_local_variables expect = {"x" => 5, "y" => 6, "z" => 11} assert_equal(expect, @gdb.local_variables) end def test_backtrace expect = ["#{SCRIPT_NAME}:11:in `sleep'", "#{SCRIPT_NAME}:11:in `b'", "#{SCRIPT_NAME}:11:in `b'", "#{SCRIPT_NAME}:5:in `a'", "#{SCRIPT_NAME}:15"] assert_equal expect, @gdb.backtrace end def test_raise assert !File.exist?(FLAG_FILE) @gdb.raise RuntimeError, "going boom" assert File.exist?(FLAG_FILE) assert_equal "going boom", File.read(FLAG_FILE) end def test_object_space space = @gdb.object_space assert_equal 1, space["ZxyVlqrt"] end private def start_script if (@pid = fork) sleep 0.1 while !File.exist?(SENTINEL) return end exec "ruby", SCRIPT_NAME end def kill_script Process.kill("TERM", @pid) end end end