DEV Community

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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."'
Enter fullscreen mode Exit fullscreen mode

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."
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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);
 }
Enter fullscreen mode Exit fullscreen mode

View on GitHub

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

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
Enter fullscreen mode Exit fullscreen mode
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")
Enter fullscreen mode Exit fullscreen mode

View on GitHub

$ make V=1
gcc -I. ... -O0 -ggdb3 ... -o example_ext.o -c example_ext.c
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 $:'
Enter fullscreen mode Exit fullscreen mode

[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.

Discussion (2)

Collapse
katafrakt profile image
Paweł Świątkowski

You saved my life with instructions how to add trunk ruby to rbenv. Thanks!

Collapse
wataash profile image
Wataru Ashihara Author

Happy to hear that :)

Forem Open with the Forem app