🚧

Ruby 0.95をUbuntu 24.04(AMD64)で動かしたかった part 2

2025/02/17に公開

前回、Advent Calenderで作成したpart1の続きの記事です。

概要

Ruby 0.95の64bit版のビルドを試み、その過程をまとめて記述したものになります。私自身、C言語に対して深い知識や理解があるわけではないため、学習しながら問題を一つずつ解決して進めています。

前回と同様に、パッチの公開は行いませんが、なるべく再現が可能なように、実際の作業の手順に沿って詳細に記載しています。

背景等 (part 1の省略版)

Rubyに詳しくなりたく、唐突にC言語と格闘したくなったという経緯で、コードに変更を加えつつ64bit版のRuby 0.95をコンパイルするという取り組みに至りました。(詳細はpart1に記載)

私自身に至らぬ点が多く、所々に粗い部分や誤りが含まれている可能性があることや、元のソースコード等の柔軟性を排した形で編集を行っていることをご理解いただけますと幸いです。また、当時のRuby 0.95におけるビルドや実行ファイルの挙動とは互換性がない場合がある点についても、ご了承ください。

前回のまとめ

  • UINTVALUEの定義をunsigned longに変更し、ポインタに関する修正について雑に実施しています。
  • make testtest succeededの出力が確認できました。
  • sample/以下のRubyのサンプルプログラムについては、拡張モジュールが絡むプログラムについては全滅、それ以外も動作するものとしないものが存在しています。

前回の範囲の修正点

前回の修正で、unsigned longULONGとして定義し、それをVALUEに対応する型としていました。これにより、関数の引数や戻り値の受け渡しでポインタの値が欠損しないように変更を加えました。

「バグが発生したから修正した」という訳ではないのですが、UINTunsigned intunsigned longに置き換える方が、様々な処理がうまく動いてくれそうに思えたため、ULONGを新たに定義することをやめて、以下のようにunsinged longUINTとして定義することにしました。

typedef unsigned long UINT;
typedef UINT VALUE;
typedef UINT ID;

また、本記事の最後の方でも触れますが、前回の記事でbignumのテストを通すために変更した箇所を元に戻して、数値の取り扱いをまとめて修正している箇所があります。

コード等の編集について

以降、ソースコード等に加えた編集等に関して記載していきます。前回の記事の続きから実施しています。

拡張モジュール読み込み時のエラーの修正

sample/marshal.rbなどを実行すると、拡張もジュールを読み込むrequire "marshal"でエラーが発生しました。cBignumと呼ばれる名前のシンボルが定義されていないといったエラーとなります。

sample/marshal.rb:1:in `require': /usr/local/lib/ruby/marshal.so: undefined symbol: cBignum - /usr/local/lib/ruby/marshal.so

marshal.soは、拡張モジュールとしてext/marshal/marshal.cをコンパイルしたものです。marshal.cの中でcBignumは以下のようにして宣言されています。marshal.c以外のファイルにあるcBignumを参照することを意図しているようです。

extern VALUE cBignum, cStruct;

cBignumを定義している箇所を探すと、bignum.cに存在を確認することが可能です。グローバル変数として定義されています。(関連するソースコードを見ると、cBignumにはBignumのクラス定義自体が格納されていそうです。)

VALUE cBignum;

まずは、現状の状態でどこからシンボルの情報を取ってくることができそうか考えることにしました。取り扱うファイルの数を増やしたくないので、make installでインストール済みのファイルを見て、ちょうどよいものがないか探します。

インストールされたファイルの一覧を以下に示します。

  • /usr/local/bin/ruby
  • /usr/local/lib/ruby/
    • etc.so
    • marshal.so
    • socket.so
    • その他requireで読み込み可能と思われる、.rbファイル (base64.rb, parseargs.rbなど)

(ここで、dbm.cがコンパイルされていないことや、tkutil.cが静的にリンクされていることに気が付きますが、後ほど取り扱います)

rubyの実行ファイル自体からシンボルの情報を取得することができると良い気がします。調べていると、-rdynamicオプションを使用すると、実行ファイルのシンボルを共有ライブラリから参照できそうという情報を見つけることができました。

試しにMakefileLIBSのオプション指定に-rdynamicを追加するときちんと動作するようになりました。(うまくいかない場合は、.o.soや古い実行ファイルを削除して、再度ビルドすると良いかもしれません。)

$ ruby sample/marshal.rb 
[25.6, #<struct point: x=10, y=10>, #<struct rectangle: origin=#<struct point: x=10, y=10>, corner=#<struct point: x=20, y=20>>, #<Object:0x632e9a60>, #<Object:0x632e9a60>, "fff"][25.6, #<struct point: x=10, y=10>, #<struct rectangle: origin=#<struct point: x=10, y=10>, corner=#<struct point: x=20, y=20>>, #<Object:0x632e9490>, #<Object:0x632e9490>, "fff"]

Makefileは、./configureが実行されると生成しなおされるため、Makefileの生成に利用されていそうなMakefile.inのLIBSに関する記述に、-rdynamicの指定を追加します。

LDFLAGS = $(CFLAGS) @LDFLAGS@
LIBS = -lm -rdynamic @LIBS@ $(EXTLIBS)
MISSING = @LIBOBJS@ @ALLOCA@

静的リンクに関する補足

拡張モジュールのビルドに関しては、以下のようなREADME中に説明があります。

(必要ならば)ext/Setupに静的にリンクする拡張モジュールを
指定する

ext/Setupに記述したモジュールは静的にリンクされます.

ruby 0.95は、初期状態では以下のような指定となっています。つまり、tkutilのみ静的にリンクされることが指定されているようです。

#option nodynamic

#dbm
#etc
#marshal
#socket
tkutil

試しに、#marshalのコメントを解除してみると、先ほど追加した-rdynamicのオプションを指定せずとも、正常にsample/marshal.rbが動作しました。

ext/dbmをビルドできるようにするためにgdbmを導入

make installでインストールされたファイルを確認したところ、ext/dbmのビルドで得られるはずの共有ライブラリがないことに気が付きました。ログを見ていると、compiling dbmのログ出力の後、特に何も実行されずにmarshalのビルドに移行されていそうだということが分かりました。

処理を追っていると、ext/dbm/extconf.rbを実行し、その結果Makefileなどが生成されず、ビルドがスキップされていそうだということが分かりました。

ext/dbm/extconf.rbは以下のようになっています。have_library("dbm", "dbm_open")の結果を確認するとnilが返ってきており、READNE.EXT内のライブラリが存在するとTRUEを返すという記述から、必要なライブラリが存在していないことがわかりました。

have_library("dbm", "dbm_open")
if have_func("dbm_open")
  create_makefile("dbm")
end

さらに処理を追っていくと、以下のコマンドをsystemメソッドで実行し、コマンドの実行に失敗したため、処理がスキップされていたことがわかります。

gcc -o conftest -I../.. -g -O   conftest.c -ldbm > /dev/null 2>&1

dbmとは、Database Managerの略で、オリジナルの実装の他にも複数の派生版が存在しているようです。キーとデータをペアにして格納して、キーによる高速な検索を行ってデータにアクセスするといった動きをし、比較的シンプルにデータを格納したい場合に用いられるデータベースのようです。

ext/dbm/dbm.cでは、#include <ndbm.h>と指定されています。こちらは、派生版のndbm (New dbm)を指すようです。

Ubuntu 24.04でndbm.hを使えるようにする方法を調べていると、gdbm (GNU dbm)と呼ばれるもののmanを見つけることができました。これによると、gdbmndbmとの互換性があるそうです。また、-lgdbm_compatをコンパイル時に指定すると良さそうな記述を見つけたため、そちらを試してみることにしました。

以下のコマンドで関連するファイルをインストールすることができます。

sudo apt install libgdbm-dev libgdbm-compat-dev

また、ext/dbm/extconf.rbを以下のように書き換えて、-lgdbm_compatのオプションを付与したMakefileの生成ができるようにします。

have_library("gdbm_compat", "dbm_open")
if have_func("dbm_open")
  create_makefile("dbm")
end

これで再度makeを実行すると、dbmに関するビルドの処理が走っているログを確認することができました。

また、sample/dbm.rbを実行すると、以下のように正常に動作する様子を確認することができました。

$ ruby sample/dbm.rb # 保存 (test.dirとtest.pagが作成される)
$ ruby sample/dbm.rb # 読み込んで保存された値を表示
baz
foobar
quux
BZ
FB
QX

tkutilを利用した際のSegmentation Faultの解消

続いて、tkutilを利用した際のエラーを解消していきます。sample/tkhello.rbを実行すると、以下のようになります。0xf7c2dc50aryとして渡されており、ary->lenSegmentation Faultが発生しています。これまで何回か登場したポインタの値が欠損する問題だと考えられます。

Program received signal SIGSEGV, Segmentation fault.
ary_push (ary=ary@entry=0xf7c2dc50, item=140737350130528) at array.c:171
171         astore(ary, ary->len, item);
(gdb) bt
#0  ary_push (ary=ary@entry=0xf7c2dc50, item=140737350130528)
    at array.c:171
#1  0x0000555555564a49 in collect_i (i=<optimized out>, tmp=4156742736)
    at enum.c:122
#2  0x000055555556ad9e in rb_yield_0 (val=140737350130912, 
    self=self@entry=0) at eval.c:1835
#3  0x000055555556ae1c in rb_yield (val=<optimized out>) at eval.c:1870
#4  0x000055555555e913 in ary_each (ary=0x7ffff7c2dc80) at array.c:484
#5  0x000055555556762d in rb_call (class=<optimized out>, 
    recv=recv@entry=140737350130816, mid=2992, argc=argc@entry=0, 
    argv=0x0, scope=scope@entry=1) at eval.c:2238

今回のエラー箇所を呼び出すことになった、大本のコードは、以下のext/tkutil/tkutil.cの以下の箇所です。

static VALUE
tk_yield(obj)
    VALUE obj;
{
    rb_yield_0(obj, obj);
}

static VALUE
tk_s_new(argc, argv, class)
    int argc;
    VALUE *argv;
    VALUE class;
{
    VALUE obj = obj_alloc(class);

    rb_funcall2(obj, rb_intern("initialize"), argc, argv);
    if (iterator_p()) tk_yield(obj);
    return obj;
}

eval.crb_yield_0を見ていくと、以下のような箇所にたどり着きます。このnd_cfncを実行する箇所で、collect_iを呼び出すような形となります。

VALUE
rb_yield_0(val, self)
    VALUE val, self;
{
  // 省略
	else if (nd_type(node) == NODE_CFUNC) {
	    result = (*node->nd_cfnc)(val,node->nd_argc);
	}
  // 省略
}

ここで利用するnodeは、enum.cの以下の箇所で用意していると考えられます。collect_iに渡すtmpは、ary_mew()返されるポインタです。この値が欠損してしまい、Segmentation Faultを引き起こしています。

enum_collectで呼び出している、eval.crb_iterateの内部で、NEW_CFUNC(f,c)を使ってnodeを作成しています。node.hに処理の内容が記載されており、enum_collectcollect_iNEW_CFUNCftmpcに対応しています。

// enum.c
static VALUE
enum_collect(obj)
    VALUE obj;
{
    VALUE tmp;

    tmp = ary_new();
    rb_iterate(rb_each, obj, collect_i, tmp);

    return tmp;
}
// eval.c
VALUE
rb_iterate(it_proc, data1, bl_proc, data2)
    VALUE (*it_proc)(), (*bl_proc)();
    void *data1, *data2;
{
    int   state;
    VALUE retval = Qnil;
    NODE *node = NEW_CFUNC(bl_proc, data2);
    // 省略
}
// node.h
#define NEW_CFUNC(f,c) newnode(NODE_CFUNC,f,c,0)

ポインタが欠損している原因となっているのは、先ほど示したrb_yield_0関数中の、以下の箇所になります。NEW_CFUNCで作成したNODEをどこからか取得し、NODEに格納されている関数のポインタと引数を用いて処理を実行しています。

// eval.c
result = (*node->nd_cfnc)(val,node->nd_argc);

nd_argcはマクロで、それを展開するとnode->u2.argcになります。u2.argcint型(4 byte)なので、ここがポインタの値(8 byte)をキャストして欠損させている箇所となります。

typedef struct RNode {
    // 省略
    union {
        // 省略
    } u1;
    union {
        struct RNode *node;
        ID id;
        int argc;
    } u2;
    union {
        // 省略
    } u3;
} NODE;

u2unionだったため、今回はeval.cnd_argcを使用していた箇所を、以下のように変更しました。他の箇所に影響がないか心配ですが、少なくともmake testは成功するようでした。

// eval.c
result = (*node->nd_cfnc)(val,node->u2.node);

以上のような変更を加えた後、sample/tkhello.rbを実行すると、以下のようなGUIを持つアプリの画面が表示されます。helloのラベルがあるボタンを押すと、標準出力にhelloと出力されます。(WSLgを利用しているため通常のUbuntuとは異なる表示かもしれません)

tkhelloの画面

他のsample/tk*.rbを実行していくと、tktimer.rbは正常に動作し、それ以外は以下のようなエラーが発生しました。

  • 起動したあと画面は表示されるが、数秒以内に画面が閉じる
  • require': No such file to load -- tkscrollboxのエラー出力が表示される

以下は、sample/tktimer.rbの画面です。(0.95で止められているのは、0.05秒(?)間隔で数字の更新が入るためです。)

tktimerの画面

tkutilで作成されたGUIアプリが数秒後に終了するエラーの調査と対処

数秒以内に画面が閉じてしまうsample/tkline.rbについて、gdbで実行中の様子を確かめれていると、以下のようなパイプに関するエラーのメッセージを確認することができました。

Program received signal SIGPIPE, Broken pipe.

また、printデバッグを実施すると、lib/tk.rbの以下の箇所で例外が発生し、それがキャッチされてプログラムが正常終了していることが確認できました。

パイプが壊れたのが先か、例外を吐いたのが先かを調べると、原因をある程度絞ることができそうです。

def mainloop
    begin
      tk_write 'after idle {wm deiconify .}'
      while TRUE
        rf, wf = select(READABLE, WRITABLE)
        for f in rf
          READ_CMD[f].call(f) if READ_CMD[f] #.callの呼び出し時に例外が発生

tkutil.ctk.rb、およびwishについて整理

ここで、今後のデバッグに向けて、tkutil.cに関連する内容を調べることにしました。名称やビルドに必要な外部ライブラリなどから、GUIツールキットのTcl/Tkに関連していると考えられます。

まずは、tkutil.cからです。tkutil.cは、C言語で実装された拡張モジュールのプログラムです。定数WISH_PATHと拡張メソッドのeval_cmdを持つTkUtilモジュールと、Objectクラスを継承したTkKernelクラスが実装されています。

ビルドの結果、tkutil.cは共有ライブラリのtkutil.soとなり、/usr/local/lib/ruby/以下に配置されます。このファイルはRubyのプログラムの中でrequire "tkutil"が実行されると読み込まれます。(この読み込みの処理はeval.cdln.cに実装されています。f_requireの中で共有ライブラリの読み込みと、Rubyのスクリプトファイルの読み込みの処理に分岐されており、dln.cに実装されたdlopenの処理によりtkutil.soは読み込まれます。)

tk.rbは、lib/以下に配置されたRubyのスクリプトのファイルです。lib/以下の.rbのファイルは、make installの時に/usr/local/lib/ruby/以下のコピーされ、Rubyのプログラムの中でrequire "tk"が実行されると読み込まれます。tk.rbtkutilを利用して様々なモジュール、クラスを実装しています。Tcl/Tkに関するライブラリの主な実装になると考えています。

その他、lib/tkcanvas.rblib/tkclass.rblib/tkentry.rblib/tktext.rbは、tk.rbを利用してさらに便利なGUIの構成要素を実装したものです。

TclTool Command Languageの略で、様々な環境で動作するシンプルで柔軟性の高いスクリプト言語です。また、TkTcl向けに実装されたGUIツールキットで、単にTkと呼ばれる場合は、多言語向けのツールキット、C言語向けライブラリ、wishコマンド、Tclインタプリタに新しいコマンドを追加するTkの拡張機能などを指すようです。今回取り扱うwishコマンドは、Tkのコマンドなどが拡張されたTclのインタプリタです。昔からTkを利用するために使われているそうです。(Tclの参考資料, Tkの参考資料)

wishコマンドを実行すると、プロンプトが%に変わり、空のウィンドウが作成されます。プロンプトに対してスクリプトを入力していくと、ウィンドウにボタンなどのウィジェットを配置するなど、GUIを実装することが可能です。

tk.rbTkモジュールの基本的な挙動

Tkモジュールはwishとのやり取りに用いるインターフェースを提供しているように見えます。また、モジュールの定義の最中にも様々な処理が実行されるようになっています。例えば、以下のような箇所です。

module Tk
  include TkUtil
  extend Tk

  $0 =~ /\/(.*$)/

  # パイプを使いつつwishコマンドを呼び出し、その入出力ストリームをPORTに保持する
  PORT = open(format("|%s -n %s", WISH_PATH, $1), "w+");

  # wishに文字列を入力するメソッド
  def tk_write(*args)
    printf PORT, *args;
    PORT.print "\n"
    PORT.flush
  end

# withdrawでwishコマンド起動時に出現するウィンドウを非表示に
# wishでrb_outとkeepaliveを定義、tkerrorをオーバーライドして、エラーが発生した場合に終了するように
  tk_write '\
wm withdraw . 
proc rb_out args {
  puts [format %%s $args]
  flush stdout
}
proc tkerror args { exit }
proc keepalive {} { rb_out alive; after 120000 keepalive}
after 120000 keepalive' # 120000 ms後にkeepaliveを呼び出すように設定

その他、wishから出力された内容の読み込みや、書き込み内容を組み立てる処理、メインループなどが存在しています。

エラーの調査とRubyで実装されたコードの修正(proc/lambdaに関する妥協)

何となく関連情報を把握できたところで、エラーの詳細な調査に入ります。

まずは、先ほども提示したtkerrorをオーバーライドしている箇所を書き換え、エラー発生時にexitで終了させずエラー内容を出力するように変更します。

  tk_write '\
wm withdraw . 
proc rb_out args {
  puts [format %%s $args]
  flush stdout
}
proc tkerror args {
  puts stderr "tkerror: $args"
  flush stderr
}
proc keepalive {} { rb_out alive; after 120000 keepalive}
after 120000 keepalive'

また、書き込みや読み込みの位置にも、その内容を標準出力に出力するような処理を追加します。

  # 省略
  def tk_write(*args)
    printf *args # 追加
    print "\n" # 追加
    printf PORT, *args;
    PORT.print "\n"
    PORT.flush
  end
  # 省略
  def tk_call(*args)
    # 省略
    while PORT.gets
      print "[GETS]", $_, "\n" #追加
      $_.chop!
      if /^=(.*)@@$/
    # 省略

この状態でtkline.rbを実行すると、以下のような出力を確認することができます。after idle {wm deiconify .}で再度ウィンドウが出現します。この状態でウィンドウをクリックしたりすると、tkerror: {invalid command name "150"}の出力を確認することができます。

# 省略
after 120000 keepalive
if [catch {{canvas} {.w00001}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
[GETS]=.w00001@@

if [catch {{pack} {.w00001}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
[GETS]=@@

if [catch {{bind} {.w00001} {<1>} { %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
[GETS]=@@

if [catch {{bind} {.w00001} {<B1-Motion>} { %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
[GETS]=@@

if [catch {{bind} {.w00001} {<ButtonRelease-1>} { %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
[GETS]=@@

after idle {wm deiconify .}
tkerror: {invalid command name "150"}
tkerror: {invalid command name "151"}
tkerror: {invalid command name "152"}
tkerror: {invalid command name "153"}
# 省略

ここまでの情報で、invalid command nameのエラーが発生し、オーバーライドされたtkerrorexitが実行され、パイプを開いたままwishが終了し、その結果パイプを利用して行うはずだった処理で例外が発生して、Rubyの実行が終了してしまったと考えることができます。まず、この方向性で原因を調査することにしました。

invalid command nameが発生しないようにするためには、おそらくwishに対して正しい名前でコマンドを登録する、もしくは呼び出すことが必要になると考えられます。

コマンドの登録に関しては、install_cmdというメソッドを見つけることができ、これが関連すると考えました。また、install_cmdはprivateメソッドになることが意図されているので、これを呼び出すpublicなメソッドを探しました。

  $tk_cmdid = "c00000"
  def install_cmd(cmd)
    return '' if cmd == ''	# uninstall cmd
    id = $tk_cmdid
    $tk_cmdid = $tk_cmdid.next
    $tk_cmdtbl[id] = cmd
    @cmdtbl = [] if not @cmdtbl
    @cmdtbl.push id
    return format('rb_out %s', id)
  end
  def uninstall_cmd(id)
    $tk_cmdtbl[id] = nil
  end
  private :install_cmd, :uninstall_cmd

サンプルコードのtkline.rbを見ていると、proc(ブロックのオブジェクト)を引数としたbindメソッドを呼び出していることが確認できます。これは、処理の登録に相当するように見えました。

$c.bind("1", proc{|e| do_press e.x,e.y})
$c.bind("B1-Motion", proc{|e| do_motion e.x,e.y})
$c.bind("ButtonRelease-1", proc{|e| do_release e.x,e.y})
Tk.mainloop

bindメソッドを深掘りしていくと、install_cmdを利用したinstall_bindメソッドと、それを利用する_bindメソッドにたどり着きました。

  def install_bind(cmd)
    id = install_cmd(proc{|args|
      TkUtil.eval_cmd cmd, Event.new(*args)
    })
    id + " %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y"
  end

  def _bind(path, context, cmd)
    begin
      id = install_bind(cmd)
      tk_call 'bind', path, "<#{context}>", id
    rescue
      $tk_cmdtbl[id] = nil
      fail
    end
  end

次に、wishと直接やり取りしている箇所を探し、登録している値が意図したものになっているか確認することにしました。今まで挙げたコードで、実際にwishとやり取りをしているのは、_bindメソッドのtk_call 'bind', path, "<#{context}>", idの箇所です。tk_callメソッドは以下のような実装になっています。

  def tk_call(*args)
    args = args.collect{|s|
      # 省略
    }
    str = args.join(" ")
    tk_write 'if [catch {%s} var] {puts "!$var"} {puts "=$var@@"};flush stdout', str
    # 省略

tk_write 'if [catch {%s} var] {puts "!$var"} {puts "=$var@@"};flush stdout', strが該当する箇所になります。おそらく、先ほど確認した入出力のログの以下の部分にあたると考えられます。

if [catch {{bind} {.w00001} {<1>} { %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
if [catch {{bind} {.w00001} {<B1-Motion>} { %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y}} var] {puts "!$var"} {puts "=$var@@"};flush stdout
if [catch {{bind} {.w00001} {<ButtonRelease-1>} { %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y}} var] {puts "!$var"} {puts "=$var@@"};flush stdout

install_bind内で確認できる、%# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Yと同じ文字列の並びを確認することができます。install_bindでは、install_cmdで得たidと結合した文字列を返していますが、入出力ログを見るとidにあたりそうな箇所を確認することができませんでした。

次は、idがどんな値となるのか確認することにしました。以下のように、install_bindの中でidを出力してみると、nilといった値を確認することができました。おそらく一意の値を取得したい処理でnilが返ってきてしまっているので、この箇所がエラーの原因である可能性が高いと考えました。

  def install_bind(cmd)
    id = install_cmd(proc{|args|
      TkUtil.eval_cmd cmd, Event.new(*args)
    })
    print id, "\n" # nilと出力される
    id + " %# %b %f %h %k %s %t %w %x %y %A %E %K %N %W %T %X %Y"
  end

この後、install_cmdなど処理の内容を理解してデバッグしようとするなど、右往左往していました。install_cmdをただ単に"hoge"を返すように変更し、その場合でもnilが返ってきたところでエラーの原因に近い挙動を確認することができ、現状のinstall_cmdはメソッドの内容にかかわらずnilを返すことを認識できました。

install_cmdのメソッド呼び出しについて、今までの正常に動作してきたサンプルコードやmake testと比較すると、proc{|args| func args}のようなラムダ式に似たもの(以下、ブロックと呼びます)を直接引数に指定している点が差だと考えられます。そこで試しに以下のようなサンプルコードを実行していると、nilが返る現象を再現することができました。少なくともidnilとして帰ってきているのは、以下のようなメソッドの呼び出しをしているためだとわからいました。

(Ruby 0.95においては、procメソッドとlambdaメソッドは、同じf_lambda関数を呼び出すことになるため、同一の存在であると捉えています)

def hoge(cmd)
  "hoge"
end

def fuga
  ret = hoge(lambda{|args| "ret"})
  return ret
end

print hoge(lambda{|args| "ret"}), "\n" # hogeが出力される
print fuga, "\n" # nilが出力される

この現象への対処のために、eval.cの引数に関する処理や、gotoや、parse.yの処理など、様々な内容を疑いましたが、結局原因はわからないままでした。

VALUEunsigned longにした影響かどうか確かめるため、all-rubyを利用して以下のような手順で動作を確認してみると、現在作業中の環境と同じ出力を確認することができました。

  • all-rubyのDockerコンテナを建てる

  • 何らかの方法で、上記の動作確認に使ったスクリプトのファイルをコンテナ内に用意する

    • 今回は、/scripts/script.rbとして準備しました。
  • ALL_RUBY_BINS='ruby-0.95' all-ruby /script/script.rbを実行する

  • 以下が出力結果です

    # ALL_RUBY_BINS='ruby-0.95' ./all-ruby /scripts/script.rb
    ruby-0.95 nil
    
    • バージョンを指定せずに、全バージョンでの実行結果を確認すると以下のようになります。

      ./all-ruby /scripts/script.rb
      ruby-0.49             /scripts/script.rb:6: syntax error
                            /scripts/script.rb:6: syntax error
                            /scripts/script.rb:6: syntax error
                            /scripts/script.rb:8: syntax error
                        exit 4
      ruby-0.50             /scripts/script.rb:6: syntax error
                            /scripts/script.rb:6: syntax error
                            /scripts/script.rb:6: syntax error
                            /scripts/script.rb:8: syntax error
                        exit 4
      ruby-0.51             nil
      ...
      ruby-1.1b9            nil
      ruby-1.1b9_01         hoge
      ...
      
      ruby-3.4.1            hoge
      

少なくとも私がWSLで用意できそうなLinuxの環境下のRuby 0.95では、正常にこのブロックを引数に指定するコードが動作しないことがわかりました。1995年当時のBSD(?)などの中に開発環境を用意するのは大変に思えたので、RubyのC言語の実装の修正はあきらめて、以下のコードのような回避策をtk.rbやサンプルコードに適用していくことにしました。これにより、nilが返ってくるという現象は回避することが可能です

(かなり確率が低いですが、もしpart3の記事を書く場合は、この箇所のきちんとした修正を取り扱うと思います)

def hoge(cmd)
  "hoge"
end

def fuga_fix
  funcobj = lambda{|args| "ret"} # 一度ブロックのオブジェクトを変数に格納する
  ret = hoge(funcobj) # メソッドの引数にブロックのオブジェクトが格納された変数を指定する
  return ret
end

print fuga_fix, "\n" # hogeと出力される

Rubyのスクリプトファイルの中から、proclambdaで文字列の検索をして修正対象を探し、修正を行っていきます。

修正後、再度sample/tkline.rbを実行すると、invalid command nameのエラーは発生せず、c00000 131 1 ?? ?? ?? 16 3347576 ?? 156 56 ?? 0 ?? ?? .w00001 4 313 171のような文字列をwish側に書き込めていることが確認できます。しかし、サンプルが意図したような、ウィンドウに対する線の描画が発生しません。

sample/tkline.rbが直接用いているlib/tkcanvas.rbLine(TkcLine)の実装を見ていると、create lineといったような文字列がwish側に書き込まれることが正常な状態のようです。しかし、そのような書き込みが発生したような形跡が見られませんでした。

class TkcLine:TkcItem
  def create_self(*args)
    tk_call(@path, 'create', 'line', *args)
  end
end

まずは、bindメソッドで登録したメソッドが正常に呼び出されていないことを疑い、周辺を調査することにしました。

bindの引数に指定したブロックのオブジェクトは、最終的には$tk_cmdtblという変数のハッシュに格納されます。ここからオブジェクトが読みだされて、ブロックが実行されるはずだと考えました。

$tk_cmdtblから値を読み取っているコードは、tk.rb内部に3箇所存在します。そのうち、処理が実行され得るものはtk_tcl2rubyメソッドと、dispatchメソッドの2箇所です。試しにそれぞれのメソッドの先頭にprint "メソッド名\n"のようなコードを差し込んで実行していみるといずれの出力も確認することができませんでした。これらのメソッドを呼び出す箇所も動作していないことが分かりました。

2つのメソッドに関する調査が必要だと考えられますが、tk_tcl2rubyメソッドは、tk_split_listメソッド内で呼び出されています。またtk_split_listメソッドはdispatchメソッドに呼び出されている状態となっています。そのため、先にdispatchの呼び出しに関する問題を解消することが必要となります。

dispatchメソッドが呼び出されるのは、tk_callメソッド内と、file_readableメソッドの引数に指定しているブロックの内部です。後者は以下のように、READ_CMDという変数(定数?)に格納されたハッシュに格納されて、最終的にmainloopメソッド内で呼び出されます。

  def file_readable(port, cmd)
    READABLE.push port
    READ_CMD[port] = cmd
  end

  def mainloop
    begin
      tk_write 'after idle {wm deiconify .}'
      while TRUE
        rf, wf = select(READABLE, WRITABLE)
        for f in rf
          READ_CMD[f].call(f) if READ_CMD[f]
          if f.closed?
            READABLE.delete f
            READ_CMD[f] = nil
          end
        end
    # 省略

READ_CMD[f].call(f)実行されないことに関しては、READ_CMD[f]で代入済みのオブジェクトが見つからずにnilが帰ってきたか、代入したオブジェクトがnilになっていたかの2つが考えられます。

試しに、file_readablecmdの内容を出力してみると、nilになっていることが確認できました。つまり、以下のようなfile_readableのメソッドの呼び出しに使われるブロックがnilとして取り扱われており、ブロックの部分がnilとして解釈されているということがわかりました。この問題を修正する必要がありそうです。

# 前述の修正により、ブロックを変数に格納している状態
file_readable_param_proc = proc {
    exit if not PORT.gets
    Tk.dispatch($_.chop!)
}
file_readable PORT, file_readable_param_proc

次に、上記の問題を再現するRubyのコードを実装して動作を確認することにしました。以下にそのコードを示します。

def test_func
  multi_line_proc = proc {|args|
    print("hello")
    print(" multi line proc\n")
    "ret"
  }
  print multi_line_proc, "\n" # nilが出力される
  print multi_line_proc.call, "\n" # nilに対してcallメソッドを呼び出そうとするので、`test_func': undefined method `call' for nil(Nil)のエラーメッセージが出力
end

test_func

今回の挙動も、前述の問題と同根の原因であると考えられたため、実装の調査に入る前にall-rubyで動作を確認しました。動作を確認したところ、こちらでも同じ出力やエラーの発生を確認できました。また、意図した通りに動作するバージョンに関しても、ruby-1.1b9_01以降のバージョンになるため、先ほど修正をあきらめた問題と関連があると考えられます。そのため、こちら問題もC言語の実装を修正することを諦めて回避策を探すことにしました。

> ALL_RUBY_BINS='ruby-0.95' ./all-ruby /scripts/script2.rb
ruby-0.95 /scripts/script2.rb:8:in `test_func': undefined method `call' for nil(Nil)
          nil
      exit 1

様々な回避策が考えられると思いますが、今回はパーサーの気持ちになり、ブロックの後ろに別の解釈をする対象が続いていれば何か変わりそうだという直感に基づいて、以下のコードのように多重代入を利用することを試行しました。これで、今回の問題を回避することに成功したため、tk.rbやサンプルコードに対しても多重代入を用いた回避策を適用することにしました。

def test_func_fix
  multi_line_proc, _temp = proc {|args|
    print("hello")
    print(" multi line proc\n")
    "ret"
  }, "_"
  print multi_line_proc, "\n" # #<Proc:0x1d44b5a0>と出力される
  print multi_line_proc.call, "\n" # hello multi line procの出力の後、retと出力される
  print _temp, "\n" # _と出力される
end

test_func_fix

ここまでの修正で、sample/tkline.rbsample/tkdialog.rbsample/tkfrom.rbが動作するようになりました。sample/tkfrom.rbYou have no mail.の表示の後、何も触らずとも終了してしまいますが、これはバグではなく Tk.afterで2秒後にexitが実行されているためです。

tklineの画面

tkdialogの画面

tkfromの画面

No such file to load -- tkscrollboxの解消

以下のメーリングリストのログにあるtkscrollbox.rbの実装を新規に作成したファイルのlib/tkscrollbox.rbに記載し、sudo make installを実行することで解消することができます。

https://blade.ruby-lang.org/ruby-list/4

これにより、sample/tkbrowse.rbsample/tkbiff.rbを実行した際に、No such file to load -- tkscrollboxのエラーが発生しなくなります。

tkbrowseの画面

tkbiffの画面

srandstack smashing detectedの解消

sample/rcs.rbを実行すると、srandメソッドの実行時に、*** stack smashing detected ***: terminatedが出力され、異常終了します。

今回も、part 1の時と同様に、ローカル変数に関連していると考えられたため、その点から調査することにしました。srandは、random.cf_srand関数に実装されており、以下のようになっています。

static VALUE
f_srand(argc, argv, obj)
    int argc;
    VALUE *argv;
    VALUE obj;
{
    int seed, old;
#ifdef HAVE_RANDOM
    static int saved_seed;
#endif

    if (rb_scan_args(argc, argv, "01", &seed) == 0) {
        // 省略
    }
// 省略
}

int seed&seedで渡している箇所が怪しそうです。単純にintlongに書き換えるだけで、この問題を解消することができました。ruby sample/rcs.rb sample/rcs.datを実行すると、正常に動作している様子を確認することができます。

その他、サンプル側の修正

sample/freq.rbについては、subsub!に置き換えて、文字列の置き換えができなくなった場合にnilを返すようにして、無限ループにならないように変更しました。

sample/less.rbについては、ZCATLESSに格納されている、実行ファイルのパスを、現在の環境に合わせて書き換えました。

sample/io.rbについては、File.sFile.statに置き換え、sample/trojan.rbに関しては、File.dFile.directory?に、File.fFile.file?に置き換えました。

ここまでの修正で、sample/cbreak.rbを除いた、すべてのサンプルプログラムが動作するようになります。

sample/cbreak.rbはファイル先頭に、# ioctl example works on Sunと記載があるので、特に修正してまで動かそうとしませんでした。

数値の取り扱い周りの修正

前回のpart1の記事で、bignum.cint2bignumeric.cfix_mulruby.hINT2FIXFIX2INTに修正を加え、bignumのテストをパスするように変更を加えました。

しかし、実際にプログラムを書いていると数値の取り扱い周りでバグが見つかりました。そのため、前回の修正を元に戻し、今回の記事では数値の取り扱い周りをできる限り修正することにしました。

FixnumBignumの取り扱いの方針

実際の修正に入る前に、part 1であまり理解せず変更を行った、INT2FIXFIX2INTに関して調べることにしました。

ruby.texiで関連しそうな内容を調べると、Ruby 0.95では、Integerと呼ばれる整数クラスがあり、その数値の大きさによって、サブクラスのFixnumBignumが用いられる旨の記載がされていました。また、Rubyのスクリプトの上においては、FixnumBignumの変換は相互に自動で実施されるようです。

FixnumBignumに関しても、それぞれ説明があり、Fixnumはマシンのlongの長さ-1bitの整数のクラスで、それを超えるとBignum(無限多倍長整数)に拡張されます。また、Fixnumは他のクラスと違いポインタに値が格納されるため、一旦参照を挟まないことが特徴だとされています。

ここまでの情報を踏まえて、FIX2INTおよびINT2FIXの役割を予測すると、C言語中の整数型の値とFixnumを相互に変換するものだと考えられます。また、Ruby 2.4の拡張ライブラリのC言語実装に関するドキュメントにも、そのような記載があったため、この予測はおそらく正しいものであると考えられます。(正確には、FixnumをC言語の整数に変換するために、32bit環境ではFIX2INT、64bit環境ではFIX2LONGの利用を促すような記載がありました)

FixnumBignumの説明を考慮すると、今回の64bit版の実装では、longが64bitのため、Fixnumは63bitで表現できる値をサポートするように実装していくと良いだろうと考えました。

INT2FIXFIX2INTなどのマクロや関連する実装について

ここまでで、前提となる背景や、今後の方針を把握することができたため、関連するマクロを理解していくことにしました。

INT2FIXなどは、ruby.hに定義されています。関連しそうなマクロや関数も含めて以下に示します。

#define FIXNUM_MAX (LONG_MAX>>1)
#define FIXNUM_MIN RSHIFT((long)LONG_MIN,1)

#define FIXNUM_FLAG 0x01
#define INT2FIX(i) (VALUE)(((int)(i))<<1 | FIXNUM_FLAG)
VALUE int2inum();

#if (-1==(((-1)<<1)&FIXNUM_FLAG)>>1) // 0と評価された
# define RSHIFT(x,y) ((x)>>y)
#else
# define RSHIFT(x,y) (((x)<0) ? ~((~(x))>>y) : (x)>>y)
#endif
#define FIX2INT(x) RSHIFT((int)x,1)

#define FIX2UINT(f) ((UINT)(f)>>1)
#define FIXNUM_P(f) (((long)(f))&FIXNUM_FLAG)
#define POSFIXABLE(f) ((f) <= FIXNUM_MAX)
#define NEGFIXABLE(f) ((f) >= FIXNUM_MIN) 
#define FIXABLE(f) (POSFIXABLE(f) && NEGFIXABLE(f))

#define NUM2INT(x) (FIXNUM_P(x)?FIX2INT(x):num2int(x))
VALUE num2fix();
int   num2int();
RSHIFT

まず、各所で使われているRSHIFTから見ていくことにします。RSHIFT#if (-1==(((-1)<<1)&FIXNUM_FLAG)>>1)の評価の結果に応じて、定義が変化します。もし、評価の結果が0でなければ、C言語の>>演算子を用いた単純な右シフト、0の場合は三項分岐を含む複雑な右シフトの操作として定義されます。

// FIXNUM_FLAGは0x01
#if (-1==(((-1)<<1)&FIXNUM_FLAG)>>1) // 0と評価された
# define RSHIFT(x,y) ((x)>>y)
#else
# define RSHIFT(x,y) (((x)<0) ? ~((~(x))>>y) : (x)>>y)
#endif

後者のRSHIFTの定義は、もしxが負の値であれば一度ビットを反転させて右シフトを行い、再度反転させるといった動きになります。おそらく、シフト演算子が算術シフトにならない環境向けのマクロだったと考えられます。

(0以上の数値の場合は通常の論理シフトで、負の値の時はいったん反転させてから論理シフトで左を0埋めしてもらい、再度反転させて1に戻し、結果的に算術シフト相当の操作にしたいといった意図を勝手に感じています。)

今利用している環境ではどちらの分岐になっても算術シフトの右シフトになるなどの理由があり、今回は条件分岐部分を削除しつつ、前者のマクロを採用するように変更しました。

FIXNUM_MAX/FIXNUM_MIN/FIXABLE/POSFIXABLE/NEGFIXABLE

次は、Fixnumとして扱うか否かの閾値を示していそうな部分を見ていきます。

FIXNUN_MAXlongの最大値を示すLONG_MAXを右シフトしたもの。FIXNUM_MINlongの最小値を示すLONG_MINを右シフトしたものです。今回は8バイトのlongなので、それに沿った値となっており、おそらくFixnumの最大値と最小値を示しています。

LONG_MAXLONG_MINは、limit.hがあって定義されていればそれを利用し、そうでなければruby.h中で2147483647などの数値をベースに定義されているものを利用するようになっています。

POSFIXABLEは値がFixnumの最大値以下かどうか、NEGFIXABLEは値がFixnumの最小値以下かどうか確かめています。FIXABLEはそれらの&をとっているので、値がFixnumの範囲に収まっているか否かを確かめることができます。

INT2FIX/FIX2INT/FIXNUM_P

INT2FIXは数値をintにキャストした後、左に1bitシフトして、LSBに1を立てるといったものになっています。FIX2INTはただ単純に右に1bit分シフトしています。

FIXNUM_Pは、LSBに1が立っているかを&で取り出してくるものです。Ruby 2.4の拡張ライブラリのドキュメントやソースコード中の使われ方などからも、値がFixnumかどうかを判定するために使われていると判断することができます。

#define FIXNUM_FLAG 0x01
#define INT2FIX(i) (VALUE)(((int)(i))<<1 | FIXNUM_FLAG)
#define FIX2INT(x) RSHIFT((int)x,1)
#define FIXNUM_P(f) (((long)(f))&FIXNUM_FLAG)
NUN2INT/num2int/num2fix/int2inum

NUM2INTは、値がFixnumかどうか判定して、FixnumであればFIX2INTで数値を整数値を取得し、そうでなければnum2intで整数値を取得しているマクロです。

num2intnum2fixint2inumは、Fixnumかどうか、FloatBignumかどうかを確認しつつ、変換をするメソッドとなっています。

#define NUM2INT(x) (FIXNUM_P(x)?FIX2INT(x):num2int(x))
VALUE int2inum();
VALUE num2fix();
int   num2int();

ここからは、これまでの内容を踏まえた実装の変更に戻ります。

数値・文字列を変換する処理を修正

これまでの修正内容では、例えば以下のようなプログラムが正常に動作しません。

print 2147483648, "\n" # 0と出力
print 2147483647, "\n" # -1と出力
print -2147483646, "\n" # 2と出力
print -2147483647, "\n" # 1と出力

2147483648などのリテラルを数値に変換する仕組みを把握していないため、その関数を探すところから始めました。順当に考えると、Rubyのスクリプトを解釈していそうなparse.yに何らかの実装が存在していそうです。

眺めていると start_num:というラベルを発見しました。この付近をさらに読んでいくとdecode_num:というラベルを見つけることができます。その下に記載されているコードは以下のようになっており、整数型の値を返しそうな雰囲気を感じます。

	  decode_num:
      // 省略
	    yylval.val = str2inum(tok(), 10);
	    return INTEGER;

実際に、gdbstr2inumにブレークポイントを貼って先ほどのプログラムを実行してみると、その箇所で実行が停止され、str2inumの引数には、"2147483648"が指定されていることが分かりました。この箇所で、整数のリテラルの解釈を行い、Rubyの内部で扱い数値の構造体を作っていると見て問題なさそうです。

Breakpoint 1, str2inum (str=0x5555555c18f0 "2147483648", base=base@entry=10) at bignum.c:172
172     {

引き続きstr2inumの動きを追っていくと、以下のような処理にたどり着きます。

  int result = strtoul(str, 0, base);
	if (!sign) result = -result;
	if (FIXABLE(result)) return INT2FIX(result);
	return int2big(result);
  }
  len = (len/(sizeof(USHORT)*CHAR_BIT))+1;

stdlib.hで定義されたstrtoul関数を呼び出しており、ここで初めて文字列が数値に変換されています。strtoulの返り値の型はunsigned longのため、ここでunsignedか否かとintlongで型の不一致が発生しています。ただ、missing/strtoul.cの実装を見ると、unsigned longが返ってくるようになっているため、unsignedかどうかは無視してよさそうに思えました。そのため、この箇所はint型の変数をlong型の変数に変更しました。

また、INT2FIXや後続の処理で使われるはずのFIX2INTなどは、以下のように一度int型へキャストした後の操作となっているため、その箇所もlongに変更しておきます。

#define INT2FIX(i) (VALUE)(((int)(i))<<1 | FIXNUM_FLAG)
#define FIX2INT(x) RSHIFT((int)x,1)

いったんここまでの修正で、先ほど提示したRubyのスクリプトを実行してみると、以下のような結果を得ることができました。一番上のパターンのみ失敗しています。

print 2147483648, "\n" # -2147483648と出力
print 2147483647, "\n" # 2147483647と出力
print -2147483646, "\n" # -2147483646と出力
print -2147483647, "\n" # -2147483647と出力

print 2147483648のみのコードでも同じような結果を得られることを確認することができました。様々な原因が考えられますが、先ほどとは逆の方向から見てみようということで、今回はprintメソッドから順番に見ていくことにしました。

printメソッドはio.cf_print関数で実装されており、その中ではRubyの標準出力と関連づく変数を引数に指定しつつio_print関数を呼び出しています。io_printの中では条件分岐の結果次第で呼び出される処理が変わりますが、今回の場合は、rb_funcall(argv[i], id_print_on, 1, out)の処理を呼び出していました。

rb_funcallについては、README.EXTに簡単な説明があり、Rubyのメソッドを呼び出している処理だとわかります。また、周辺には文字列からメソッドのidを得るためには、rb_internを用いるとの記載もあります。

rb_funcall(argv[i], id_print_on, 1, out)id_print_onに関連づいたRubyのメソッドを呼び出していると推測されます。Init_IOid_print_on = rb_intern("print_on")とされていることから、いずれかの箇所で実装されたprint_onメソッドを呼び出していることがわかりました。print_onio.cの中ではf_print_on関数として実装されています。(他にarray.cprint_onもありますが、こちらは無視しました。)

さらに実装を追っていくと、f_print_on関数はio_write関数を呼び出していることがわかり、その中では以下のような実装を確認することができます。

  if (TYPE(str) != T_STRING)
  str = (struct RString*)obj_as_string(str);
  if (str->len == 0) return INT2FIX(0);

  n = fwrite(str->ptr, sizeof(char), str->len, f);

fwrite時点のstr->ptrを見ると、すでに"-2147483648"になっていることを確認することができました。その手前の箇所でobj_as_stringを呼び出しており、この実装を見ると以下のような形で、pt_strに紐づくメソッドを呼び出しているようです。pt_strInit_String関数内でrb_intern("to_s")の結果が格納されています。

VALUE
obj_as_string(obj)
    VALUE obj;
{
    VALUE str;

    if (TYPE(obj) == T_STRING) {
	    return obj;
    }
    str = rb_funcall(obj, pr_str, 0);
    if (TYPE(str) != T_STRING)
    	return krn_to_s(obj);
    return str;
}

おそらく、rb_funcallobj (Fixnum)to_sを実行してくれそうな雰囲気だったため、numeric.crb_define_methodFixnumto_sとして定義されている、fix_to_s関数の実装を見に行きました。fix_to_sfix2str関数を呼び出しており、その中でsprintfで数値を文字列に変換している処理を見つけることができました。以下にその実装を示します。

VALUE
fix2str(x, base)
    VALUE x;
    int base;
{
    char fmt[3], buf[12];

    fmt[0] = '%', fmt[2] = '\0';
    if (base == 10) fmt[1] = 'd';
    else if (base == 16) fmt[1] = 'x';
    else if (base == 8) fmt[1] = 'o';
    else Fail("fixnum cannot treat base %d", base);

    sprintf(buf, fmt, FIX2INT(x));
    return str_new2(buf);
}

fmtにフォーマットを指定する文字列が入り、その結果の文字列がbufに格納されるようです。基数が10の場合、"%d\0"の文字列がフォーマットとして指定されるようになります。%dintの範囲向けのものとなるため、VALUEunsigned int(4 byte)からunsigned long(8 byte)になったことに合わせて"%ld\0"とする必要があります。fmt[4]に変更して、fmt[1]'l'を格納するように変更して、その他の後ろに一つずつずらして格納するように変更を加えました。

また、8byteまでの数値を文字列に変換することを許容するため、bufのサイズをbuf[32]に変更しました。(24で足りそうですが、念のため切りのよい32にしています。)

ここまでの修正で、print 2147483648が正常に動作するようになりました。

FIX2INT/INT2FIXを利用する箇所の修正

先ほどの手順の中で、FIX2INTINT2FIXのキャストについて、intからlongに変更していました。
このFIX2INTの結果を受け取る側や、INT2FIXに値を渡す側についても変更の必要があるため、周辺を見つつ修正を加えることにしました。

#define INT2FIX(i) (VALUE)(((long)(i))<<1 | FIXNUM_FLAG)
#define FIX2INT(x) RSHIFT((long)x,1)

FIX2INTに関連して変更した箇所を以下に箇条書きで示します。

  • bignum.cint2bigの引数をintからlongに変更。また、externをつけてint2bigの関数プロトタイプを宣言している箇所も併せて変更
  • numeric.c
    • flo_div関数のint型の変数f_ylong型の変数に変更
    • Fixnum同士の比較の関数
      • fix_cmp関数のint型の変数a, blong型の変数に変更
      • fix_gt関数のint型の変数a, blong型の変数に変更
      • fix_ge関数のint型の変数a, blong型の変数に変更
      • fix_lt関数のint型の変数a, blong型の変数に変更
      • fix_le関数のint型の変数a, blong型の変数に変更
    • fix_aref関数のint型の変数ilong型の変数に変更 (※iは位置指定のために使われるが、1<<iintにならないように変更)
    • fix_abs関数のint型の変数ilong型に変更
    • fix_next関数のint型の変数ilong型に変更
  • struct.cstruct_alloc関数のint型の変数nlong型に変更
  • variable.crb_class2name関数のint型の変数namelong型に変更

INT2FIXに関連して変更した箇所を以下に箇条書きで示します。

  • array.cary_hash関数のint型の変数hlong型の変数に変更
    • 他のhashメソッドの結果を処理したものを格納するため
  • hash.chash_hash関数のint型の変数hlong型の変数に変更
  • numeric.c
    • fix_plus関数のint型の変数a, b, clong型の変数に変更
    • fix_minus関数のint型の変数a, b, clong型の変数に変更
    • fix_mul関数のint型の変数a, b, clong型の変数に変更
    • fix_div関数のint型の変数ilong型の変数に変更
    • fix_mod関数のint型の変数ilong型の変数に変更
    • fix_pow関数のint型の変数a, blong型の変数に変更
    • flo_mul関数のint型へキャストしている箇所を、long型へのキャストに変更
    • flo_hash関数のint型の変数hlong型の変数に変更
    • flo_to_i関数のint型の変数vallong型の変数に変更
  • string.cstr_hash_method関数のint型の変数keylong型の変数に変更
  • struct.cstruct_hash関数のint型の変数hlong型の変数に変更
  • time.ctime_hash関数のint型の変数hlong型の変数に変更
  • marshal.cr_object関数TYPE_FIXNUMのラベル付近のint型の変数ilong型の変数に変更
  • socket.csock_send関数のint型の変数nlong型の変数に変更

その他の箇所に関しては、インデックスやカウント、ファイル記述子、文字(char)を取り扱っているように見えたり、バグが発生しそうな場合でも正常に動作していたりしたため、置き換える必要がないと判断して置き換えませんでした。

オーバーフローの検出の仕方を変更してfix_mulの挙動を修正

今までの変更でmake testを実行すると、bignumに関するテストで失敗してしまいます。前回、この問題に対して適当な変更を行ってテストが通るようにしましたが、懸念していた通り不適切な方法でした。

(具体的には、FIX2INTunsigned longにキャストしたため、負の値に対する右シフト時に、MSBが0で埋まってしまうというものです)

以下のようなコードで、bignumのテスト失敗の原因を再現することが可能でした。本来、計算結果が正の数となるべきですが、負の値となっています。

print 21 * 2432902008176640000, "\n" # -4249290049419214848 と出力される

数値同士の*は、numeric.cfix_mul関数か、bignum.cbig_mul関数が対応していると考えられます。実際に両方の関数にブレークポイントを貼ってコードを実行するとfix_mulのみが実行されていることがわかります。現時点でのfix_mulの実装の一部を以下に示します。

long a = FIX2INT(x), b = FIX2INT(y);
long c = a * b;
VALUE r = INT2FIX(c);

if (FIX2INT(r) != c) {
r = big_mul(int2big(a), int2big(b));
}
return r;

まず、a * bの結果がオーバーフローし、cに格納されます。おそらくif (FIXNUM(r) != c)でオーバーフローを検出して、Bignumでの計算に切り替えることが意図されているようですが、検出できずにそのままオーバーフローした値Fixnumにしたものを返してしまっているようです。

具体的にbit単位で値を書いていくと、cが11000...00000、rが10001...00001になります。FIX2INT(r)11000...00000で、cと同じ値になっています。仮にc101..のような値であれば、rが01..となり、FIX2INT(r)001...で、オーバーフローを検出することが可能です。

この問題についてはRuby 0.95bのパッチで修正が提供されていました。しかし、私の環境では、これにより問題を解消することはできませんでした。cがオーバーフローすることにより、c / a != b1になると思われますが、実際にはそのようになりませんでした。

調査していくと、整数オーバーフロー時の挙動に関してはC言語の仕様として未定義な部分があり、また最適化も絡む問題のため、別の対策をする必要がありそうということがわかりました。対策を調べていくと、JPCERT/CCが公開しているセキュアなC言語の書き方の資料を見つけることができました。オーバーフローに関する言及があり、対策も一緒に記載がされているため、これをFixnum向けに変更しつつ適用していくことにしました。

https://www.jpcert.or.jp/sc-rules/c-int32-c.html

実際に行った変更を以下に示します。is_overflowと呼ばれる変数を用意して、Fixnumの最大値や最小値を超えそうな場合には、これを1にするようにしています。1になった場合は、Bignumの乗算の処理に移るようになります。

この変更により、bignumのテストが通るようになり、再度make testが通るようになりました。他のfix_*関数などにも、同様の変更を加えた方がよさそうですが、除算と剰余算はオーバーフローというよりも、ゼロ除算に気を使っているようで、似たような変更はfix_plusfix_minusに対してのみ行う形となりました。

int is_overflow = 0;
long a, b, c;
VALUE r;

a = FIX2INT(x);
if (a == 0) { return x; }
b = FIX2INT(y);
c = a * b;

if (a > 0) {
    if (b > 0) { // FIXNUM_MAXをbで割ったものよりaが大きい == a * bをするとFIXNUM_MAXを超える
        is_overflow = a > (FIXNUM_MAX / b);
    } else {
        is_overflow = b < (FIXNUM_MIN / a);
    }
} else {
    if (b > 0) {
        is_overflow = a < (FIXNUM_MIN / b);
    } else {
        is_overflow = (a != 0) && (b < (FIXNUM_MAX / a));
    }
}

r = INT2FIX(c);
if (is_overflow) {
    r = big_mul(int2big(a), int2big(b));
}
return r;

まとめ

今回のpart2の記事中の修正により、以下の2点を達成することができました。

  • 拡張モジュールを正常に取り扱えるようにすること
  • ほとんどのサンプルプログラムを正常に動作させること

また、前回の修正箇所を一部見直して修正を加え、再度make testが通るようになることを確認しました。

しかし、以下の2点から、完全な64bit版Ruby 0.95にすることができたとは言い切れないと考えています。

  • lambda/procに関して、Rubyのスクリプト側の修正という逃げの一手を打った
  • すべての組み込みメソッドを利用してみた訳ではなく、まだ今回の修正に由来したバグが潜んでいる可能性がある

まだできることはありそうですが、今は別のことに力を割いた方がよさそうだったりしたため、一旦ここまでにし、気が向いた時に再度取り組んでみようと考えています。

記事の冒頭にも記載しておりますが、特に何もなければ今回の変更に関するパッチの公開しない予定です。何卒ご了承ください🙇‍♂️

(追記: なぜ公開しないかというとライセンス等に不安な点があるためです。今後その辺りに詳しくなり、明確に大丈夫だと分かった場合は公開します🙏 )

Discussion