loading...

How to create and debug Ruby gem with C (native) extension

wataash profile image Wataru Ashihara Updated on ・7 min read

This article explains how to create a minimum Ruby gem with native extension written in C and debug it with gdb.

I uploaded complete code to GitHub.

Build CRuby (optional)

This step makes it possible to step into ruby C source. It's not necessary but would be much help for debugging gems.

Here we use rbenv with ruby-build plugin. Let's say rbenv is installed in ~/.rbenv.

# --keep keeps source code in ~/.rbenv/sources/2.6.1/ruby-2.6.1/
rbenv install --keep --verbose 2.6.1
rbenv shell 2.6.1

# check that ruby is debuggable
type ruby           # => ruby is /home/wsh/.rbenv/shims/ruby
rbenv which ruby    # => /home/wsh/.rbenv/versions/2.6.1/bin/ruby
gdb -q ~/.rbenv/versions/2.6.1/bin/ruby
# (gdb) break main
# (gdb) run
# Breakpoint 1, main (argc=1, argv=0x7fffffffdd58) at ./main.c:30
# 30  {
# (gdb) list
# 25  #include <stdlib.h>
# 26  #endif
# 27  
# 28  int
# 29  main(int argc, char **argv)
# 30  {
# 31  #ifdef RUBY_DEBUG_ENV
# 32      ruby_set_debug_option(getenv("RUBY_DEBUG"));
# 33  #endif
# 34  #ifdef HAVE_LOCALE_H

On macOS, use lldb instead of gdb to avoid codesign problem or follow GDB Wiki to code-sign gdb.

Build Ruby without optimization (-O0) would be more debuggable:

git clone https://github.com/ruby/ruby.git
cd ruby/
autoconf -v  # as written in README.md
mkdir build; cd build/
# --disable-install-doc saves build time
../configure --prefix=$HOME/.rbenv/versions/trunk --disable-install-doc --enable-debug-env optflags="-O0"
make V=1 -j4
make install
rbenv shell trunk
ruby --version  # 2.?.?dev
gdb -q ~/.rbenv/versions/trunk/bin/ruby

or:

wget https://cache.ruby-lang.org/pub/ruby/2.6/ruby-2.6.1.tar.gz
tar xvf ruby-2.6.1.tar.gz
cd ruby-2.6.1/
mkdir build/; cd build/
../configure --prefix=$HOME/.rbenv/versions/trunk --disable-install-doc --enable-debug-env optflags="-O0"
make V=1 -j4
make install
rbenv shell 2.6.1-dbg
ruby --version  # 2.6.1
gdb -q ~/.rbenv/versions/2.6.1-dbg/bin/ruby
  • debugflags="-g" isn't needed since debug=flags=-ggdb3 is default.
  • On macOS, do brew install openssl and append CPPFLAGS="-I/usr/local/opt/openssl/include" LDFLAGS="-L/usr/local/opt/openssl/lib" to configure arguments, since built-in /usr/lib/libcrypto.dylib is too old to build the OpenSSL module.
  • V=1 shows build commands.

In the following sections, rbenv shell trunk (~/.rbenv/versions/trunk/) is supposed to be used.

Create and build gem

rbenv shell trunk
cd ~/work/
bundle gem example_ext --coc --ext --mit --test
cd example_ext/
bin/setup  # as described in ./README.md

You'll get the following error:

$ bin/setup

bundle install
+ bundle install
You have one or more invalid gemspecs that need to be fixed.
The gemspec at /home/wsh/work/example_ext/example_ext.gemspec is not valid. Please fix this gemspec.
The validation error was 'metadata['homepage_uri'] has invalid link: "TODO: Put your gem's website or public repo URL here."'

To fix it, edit example_ext.gemspec:

diff --git a/example_ext.gemspec b/example_ext.gemspec
index 4b9d3c1..1446707 100644
--- a/example_ext.gemspec
+++ b/example_ext.gemspec
@@ -9,9 +9,9 @@ Gem::Specification.new do |spec|
   spec.authors       = ["Wataru Ashihara"]
   spec.email         = ["wataash@example.com"]

-  spec.summary       = %q{TODO: Write a short summary, because RubyGems requires one.}
-  spec.description   = %q{TODO: Write a longer description or delete this line.}
-  spec.homepage      = "TODO: Put your gem's website or public repo URL here."
+  spec.summary       = %q{Write a short summary, because RubyGems requires one.}
+  spec.description   = %q{Write a longer description or delete this line.}
+  # spec.homepage      = "TODO: Put your gem's website or public repo URL here."
   spec.license       = "MIT"

   # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
@@ -19,9 +19,9 @@ Gem::Specification.new do |spec|
   if spec.respond_to?(:metadata)
     spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"

-    spec.metadata["homepage_uri"] = spec.homepage
-    spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
-    spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
+    # spec.metadata["homepage_uri"] = spec.homepage
+    # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
+    # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
   else
     raise "RubyGems 2.0 or newer is required to protect against " \
       "public gem pushes."

View on GitHub

Now the building should be passed:

bin/setup
bundle exec rake install  # as described in README.md
bundle exec ruby -e 'require "example_ext"; p ExampleExt::VERSION'
# => "0.1.0"

Let's implement and execute C extension.

diff --git a/ext/example_ext/example_ext.c b/ext/example_ext/example_ext.c
index c89d90a..f47c72e 100644
--- a/ext/example_ext/example_ext.c
+++ b/ext/example_ext/example_ext.c
@@ -2,8 +2,18 @@

 VALUE rb_mExampleExt;

+static VALUE
+example_hello(int argc, VALUE *argv)
+{
+  printf("hello\n");
+
+  return Qnil;
+}
+
 void
 Init_example_ext(void)
 {
   rb_mExampleExt = rb_define_module("ExampleExt");
+
+  rb_define_module_function(rb_mExampleExt, "hello", example_hello, -1);
 }

View on GitHub

bin/setup
bundle exec rake install
bundle exec ruby -e 'require "example_ext"; ExampleExt::hello'
# => hello

Check also The Definitive Guide to Ruby's C API to get started with the C API.

Debug native extension with gdb

Build the native extension without optimization (-O0) and with debug symbols (-ggdb3).

cd ext/example_ext/
vim extconf.rb
ruby extconf.rb # specify -ggdb3 -O0
make V=1        # check -ggdb3 -O0
make clean
cd ../../
bundle exec rake install
diff --git a/ext/example_ext/extconf.rb b/ext/example_ext/extconf.rb
index f657c82..2ca74f1 100644
--- a/ext/example_ext/extconf.rb
+++ b/ext/example_ext/extconf.rb
@@ -1,3 +1,6 @@
 require "mkmf"

+CONFIG["debugflags"] = "-ggdb3"
+CONFIG["optflags"] = "-O0"
+
 create_makefile("example_ext/example_ext")

View on GitHub

$ make V=1
gcc -I. ... -O0 -ggdb3 ... -o example_ext.o -c example_ext.c
...

COINFIG points to RbConfig::MAKEFILE_CONFIG, which stores the build configurations for ruby. So the modification above may not be needed, but make sure that -O0 -ggdb3 is shown in make V=1.

When I create compilation databse (compile_commands.json) using bear or intercept-build, commands below works well.

bundle exec rake clean && bundle exec bear rake build
jq '.' compile_commands.json > compile_commands.json.orig
jq --arg IPWD "-I$PWD" --arg IRUBY \
  "-I$HOME/src/ruby/include" '.[].arguments |= [ .[0], $IPWD, $IRUBY, .[1:][] ]' \
  compile_commands.json.orig > compile_commands.json

Now debug it with gdb.

# (a) recommended:
bundle exec \
  gdb -q -ex 'set breakpoint pending on' -ex 'b example_hello' -ex run --args ruby -e 'require "example_ext"; ExampleExt::hello'
# (b) or:
env RUBYLIB=./lib \
  gdb -q -ex 'set breakpoint pending on' -ex 'b example_hello' -ex run --args ~/.rbenv/versions/trunk/bin/ruby -e 'require "example_ext"; ExampleExt::hello'
# (c) unrecommended:
gdb -q -ex 'set breakpoint pending on' -ex 'b example_hello' -ex run --args ~/.rbenv/versions/trunk/bin/ruby -e 'require "example_ext"; ExampleExt::hello'

TL; DR:

In (a), with bundle exec context, require "example_ext" (indirectly) loads /path/to/example_ext/example_ext.so (example_ext.bundle on macOS) whose debug info refers /path/to/example_ext/ext/example_ext/example_ext.c. Without bundle exec (b), gdb can't load ruby which is a shell script, so specify absolute path to Ruby binary. And RUBY_LIB=./lib is needed; otherwise (c), ~/.rbenv/versions/trunk/.../example_ext.so will be loaded which refers .rbenv/versions/trunk/.../example_ext.c [1]. We can set substitute-path gdb command to let gdb looks up the original sources.

Try commands below for further understanding.

echo $PATH
which -a ruby
bundle exec echo $PATH
bundle exec which -a ruby
file ~/.rbenv/shims/ruby
file ~/.rbenv/versions/trunk/bin/ruby
ruby -e 'p $:'  # or $LOAD_PATH
bundle exec ruby -e 'p $:'
env RUBYLIB=./lib ruby -e 'p $:'

[1] The rake install task executes the build task and gem install /path/to/example_ext/pkg/example_ext-0.1.0.gem command. gem install example_ext-0.1.0.gem extracts the C sources to ~/.rbenv/versions/trunk/lib/ruby/gems/2.7.0/gems/example_ext-0.1.0/ and compiles them, so debug info for the source path points to ~/.rbenv/versions/trunk/lib/ruby/gems/2.7.0/gems/example_ext-0.1.0/ext/example_ext/example_ext.c.
ref:

References

For maintainers of official documentations

I'd be glad to migrate all or part of this and other articles to official documentations. Please make a comment here or contact me to do so.

Posted on by:

Discussion

markdown guide