Help us understand the problem. What is going on with this article?

Elixir や Erlang の beam ファイルを逆アセンブル、逆コンパイルする方法

More than 3 years have passed since last update.

Elixir プログラムの再帰呼出しが、末尾呼び出し最適化されてるか確認するために、beam ファイルに格納されたマシン語からアセンブリ言語へ逆アセンブルする方法を調べてみた。

おまけとして、beam から Erlang のソースコードへ逆コンパイルする方法も紹介する。

beam ファイル

Elixir や Erlang のプログラムをコンパイルすると、モジュールごとに beam ファイルができる。このファイルには、Erlang VM のマシン語に変換されたプログラムや、オプションでデバッグ情報が入っている。

今回調べてわかったのだが、Erlang の標準ライブラリーに用意された関数を使うと、このマシン語を逆アセンブルできる。また、デバッグ情報付きでコンパイルされている場合は、Erlang コードに逆コンパイルすることも可能だ。

末尾呼び出し最適化されているか確認したい

Elixir や Erlang では、繰り返しの処理は、再帰呼出しで実現する。ただ、これを普通の関数呼び出しと同じように呼び出してしまうと、その度にコールスタックを消費し、いずれはメモリーを使い果たしてしまう。そこで、再帰呼出しが計算の最後のステップになっている時、つまり「末尾再帰」になっている時は、関数呼び出しではなく、戻り先を保存しないジャンプ命令に最適化するようになっている。これを、「末尾呼び出し最適化」と呼ぶ。Erlang VM には、末尾再帰のための命令がいくつか用意されている。

先日、「ReconTrace で Erlang VM のトレース機能に親しむ」という記事を書いたのだが、トレース結果をみて、あれ!?っと思った。末尾再帰で書いたはずの Elixir プログラムのトレースを見ると、再帰呼び出しと同じ回数のリターンが記録されており、普通の再帰呼出しをしているように見えるのだ。

iex(14)> ReconTrace.calls({MyMath, :_, fn(_) -> :return end}, 50, [scope: :local])
9
iex(15)> MyMath.factorial_iter(5)
23:51:32.439220 <0.57.0> MyMath.__info__(:macros)
120
23:51:32.439409 <0.57.0> MyMath.__info__/1 --> []
23:51:32.439564 <0.57.0> MyMath.factorial_iter(5)
23:51:32.439644 <0.57.0> MyMath.factorial_iter1(5, 1)
23:51:32.439737 <0.57.0> MyMath.factorial_iter1(4, 5)
23:51:32.439825 <0.57.0> MyMath.factorial_iter1(3, 20)
23:51:32.439902 <0.57.0> MyMath.factorial_iter1(2, 60)
23:51:32.439987 <0.57.0> MyMath.factorial_iter1(1, 120)
23:51:32.440066 <0.57.0> MyMath.factorial_iter1(0, 120)
23:51:32.440148 <0.57.0> MyMath.factorial_iter1/2 --> 120  # return は1回だけだと思ってた
23:51:32.440221 <0.57.0> MyMath.factorial_iter1/2 --> 120
23:51:32.440297 <0.57.0> MyMath.factorial_iter1/2 --> 120
23:51:32.440374 <0.57.0> MyMath.factorial_iter1/2 --> 120
23:51:32.440446 <0.57.0> MyMath.factorial_iter1/2 --> 120
23:51:32.440517 <0.57.0> MyMath.factorial_iter1/2 --> 120
23:51:32.440590 <0.57.0> MyMath.factorial_iter/1 --> 120

こちらがそのソースコード。

  def factorial_iter(n) when n >= 0, do: factorial_iter1(n, 1)

  defp factorial_iter1(0, acc), do: acc
  defp factorial_iter1(n, acc), do: factorial_iter1(n - 1, acc * n)

これがもし、Erlang プログラムだったら、末尾再帰になっているかをすぐに確認できる。Erlang のコンパイラー erlc に、-S オプションを与えると、beam ファイルの代わりに、アセンブラーファイルを出力してくれるので、それを眺めればいい。

これについては、以下の記事が詳しいのでお薦め。

ところが、Elixir 1.1.1 のコンパイラー elixirc には、そのようなオプションはない。試しに -S オプションにあたる ERL_COMPILER_OPTIONS=\'S'\ を与えてみたところ、コンパイラーがクラッシュしてしまった。そういう使い方は想定されていないらしい。

時間もなかったので、以下のような方法で末尾再帰になっていることを確認し、その記事では、この件には触れないことにした。

  • プログラムを Erlang に移植して、アセンブラーファイルを出力。コードを確認した → OK
  • Elixir プログラム中に、以下のコードを埋め込んで、再帰呼出しの時に、スタックサイズが大きくならないことを確認した → OK
    word_size = :erlang.system_info({:wordsize, :internal})
    stack_size_words = :erlang.process_info(self(), :stack_size) |> elem 1
    stack_size_bytes = stack_size_words * word_size

末尾呼び出し最適化になっているっぽいことは確認できたので、トレースで呼び出しと同じ回数 return するのは、きっとトレース結果をわかりやすくするために、中の人(トレース対象になったプロセス)が頑張ってるんでしょう。という結論に至った。 実際、トレースをかけながら、スタックサイズを観察すると、末尾再帰の時でもスタックサイズが不規則に増減するので、トレースのためになにかの情報をトラッキングしているように思えた。

2015年12月29日訂正: OTP のマニュアルによると、戻り値を表示させるオプション付きでトレースをすると、その間、末尾呼び出し最適化が解除されてしまうそうです。詳しくはこちら

でも、なんだかすっきりしないところに、@hykw さんが、以下の記事を書かれていた。

実際その通りなのか、またどれぐらいでスタックが溢れるのか(どれぐらいまでなら、スタックを気にせず再帰させられるのか)を知るため、実際に再帰処理をしてみて確認してみました。
...
約1時間動かしてみた後のメモリ使用量です。末尾再帰版は、最初から最後まで、ほとんど変動ありませんでした。

やはり、実験だけでなく、コードも見てみたい。これは、Elixir でもアセンブリーコードを見るしかないでしょう! ということで、いろいろと試行錯誤しながら、Erlang/OTP のドキュメントやテストコードを読んだり、Elixir のコンパイラーのコードを読んだりしたところ、最終的に、beam ファイルから、簡単に逆アセンブルできることがわかった。

逆アセンブル

まずはそのコードを紹介。Elixir で書いたが、Erlang の標準ライブラリーしか使ってないので、Erlang への移植は簡単にできる。

beam_spy.ex
defmodule BeamSpy do

  @spec disassemble(module) ::
          {:ok, asm_code :: [tuple], asm_file :: :file.path} | {:error, term}
  def disassemble(module) do
    case read_abstract_code(module) do
      {:ok, path, ac} ->
        case :compile.noenv_forms(ac, [:S]) do
          {:ok, _, {_erl_module, _exports, _attribs, asm_codes, _labels}} ->
            asm_dir  = :file.get_cwd |> elem 1
            asm_name = :filename.basename(path, '.beam') ++ '.S'
            {:ok, asm_codes, :filename.join(asm_dir, asm_name)}
          :error ->
            {:error, :unkown_compile_error}
          {:error, errors, warnings} ->
            {:error, {:compile_error, errors, warnings}}
        end
      {:error, _}=err ->
        err
    end
  end

  @spec decompile_to_erl(module) :: :ok | {:error, term}
  def decompile_to_erl(module) do
    case read_abstract_code(module) do
      {:ok, _path, ac} ->
        :erl_syntax.form_list(ac) |> :erl_prettypr.format |> IO.puts
        :ok
      {:error, _}=err ->
        err
    end
  end

  @spec read_abstract_code(module) :: {:ok, :file.path, [tuple]} | {:error, term}
  defp read_abstract_code(module) do
    case :code.which(module) do
      :non_existing ->
        {:error, {:non_existing, module}}
      path when is_list(path) ->
        case :beam_lib.chunks(path, [:abstract_code]) do
          {:ok, {_, [{:abstract_code, {_, ac}}]}} ->
            {:ok, path, ac}
          {:error, :bean_lib, reason} ->
            {:error, reason}
        end
    end
  end

end

disassemble/1 が逆アセンブル、decompile_to_erl/1 が Erlang コードへの逆コンパイルだ。

では、試してみよう。今回私は Elixir 1.1.1 と Erlang/OTP 18.2.1 を使用した。

iex(1)> c "beam_spy.ex"
[BeamSpy]

例としてこの Elixir プログラムを使用する。

my_math.ex
defmodule MyMath do

  @spec factorial(non_neg_integer) :: non_neg_integer
  def factorial(0),             do: 1
  def factorial(n) when n > 0,  do: n * factorial(n - 1)

  @spec factorial_iter(non_neg_integer) :: non_neg_integer
  def factorial_iter(n) when n >= 0, do: factorial_iter1(n, 1)

  defp factorial_iter1(0, acc), do: acc
  defp factorial_iter1(n, acc), do: factorial_iter1(n - 1, acc * n)

end
iex(2)> c "my_math.ex"
[MyMath]

MyMath モジュールを逆アセンブルする。

iex(3)> BeamSpy.disassemble MyMath
{:ok,
 [{:function, :__info__, 1, 2,

 ...(中略)...

  {:function, :factorial_iter1, 2, 7,
   [{:label, 6}, {:line, [{:location, 'my_math.ex', 10}]},
    {:func_info, {:atom, MyMath}, {:atom, :factorial_iter1}, 2}, {:label, 7},
    {:test, :is_eq_exact, {:f, 8}, [x: 0, integer: 0]},
    {:move, {:x, 1}, {:x, 0}}, :return, {:label, 8},
    {:line, [{:location, 'my_math.ex', 11}]},
    {:gc_bif, :-, {:f, 0}, 2, [x: 0, integer: 1], {:x, 2}},
    {:line, [{:location, 'my_math.ex', 11}]},
    {:gc_bif, :*, {:f, 0}, 3, [x: 1, x: 0], {:x, 1}}, {:move, {:x, 2}, {:x, 0}},
    {:call_only, 2, {:f, 7}}]},
  {:function, :factorial, 1, 10,
   [{:label, 9}, {:line, [{:location, 'my_math.ex', 4}]},
    {:func_info, {:atom, MyMath}, {:atom, :factorial}, 1}, {:label, 10},
    {:test, :is_eq_exact, {:f, 11}, [x: 0, integer: 0]},
    {:move, {:integer, 1}, {:x, 0}}, :return, {:label, 11},
    {:test, :is_lt, {:f, 9}, [integer: 0, x: 0]}, {:allocate_zero, 1, 1},
    {:line, [{:location, 'my_math.ex', 5}]},
    {:gc_bif, :-, {:f, 0}, 1, [x: 0, integer: 1], {:x, 1}},
    {:move, {:x, 0}, {:y, 0}}, {:move, {:x, 1}, {:x, 0}},
    {:line, [{:location, 'my_math.ex', 5}]}, {:call, 1, {:f, 10}},
    {:line, [{:location, 'my_math.ex', 5}]},
    {:gc_bif, :*, {:f, 0}, 1, [y: 0, x: 0], {:x, 0}}, {:deallocate, 1},
    :return]},

 ...(中略)...

 ],
 '/usr/home/tatsuya/tmp/Elixir.MyMath.S'}
iex(4)>

:factorial_iter1 の方は、{:call_only, 2, {:f, 7}} となっていることから、末尾再帰していることがわかる。プログラムによっては :call_only の代わりに、:call_last:call_ext_only:call_ext_last となっていることもあるが、これらも末尾再帰の一種だ。

一方、:factorial は、{:call, 1, {:f, 10}}:return があることから、末尾再帰になっていないことがわかる。{:gc_bif, :* ...} となっているように、{:call, ...} の戻り値を使って乗算(*)しなけれはならないので、末尾呼び出し最適化は不可能だ。

なお、もしプログラムが長いと、iex 上では途中から表示が省略されてしまう。その時は、最後にアセンブラーファイルのパスが出ているので(上の例では '/usr/home/tatsuya/tmp/Elixir.MyMath.S')、そのファイルを開いてみればいい。

おまけ:Erlang コードへ逆コンパイル

おまけとして、Erlang コードへの逆コンパイルの方法も紹介する。残念ながら、Elixir コードまで戻すことはできない。また、逆コンパイルするためには beam ファイルがデバッグ情報(debug_info)オプション付きでコンパイルされている必要がある。なお、Elixir 1.2 RC のコンパイラーのコードを読んだところ、常にデバッグ情報付きでコンパイルされるようになっていた。

iex(5)> BeamSpy.decompile_to_erl MyMath
-compile(no_auto_import).

-file("my_math.ex", 1).

-module('Elixir.MyMath').

...(中略)...

factorial_iter1(0, acc@1) -> acc@1;
factorial_iter1(n@1, acc@1) ->
    factorial_iter1(n@1 - 1, acc@1 * n@1).

factorial(0) -> 1;
factorial(n@1) when n@1 > 0 -> n@1 * factorial(n@1 - 1).

factorial_iter(n@1) when n@1 >= 0 ->
    factorial_iter1(n@1, 1).
:ok

ちなみに、今回使った逆コンパイル方法は、OTP のマニュアルにもちゃんと書いてある。また、同マニュアルでは、デバッグ情報は付けたいが、逆コンパイルされては困る人のために、デバッグ情報を暗号化する方法も書かれている。

まとめ

  • Erlang/OTP の標準ライブラリーを使うと、beam ファイル内の Erlang VM マシン語を逆アセンブルできる
  • アセンブリーコードを眺めると、末尾呼び出し最適化が行われていることなどが確認できる

参考

tatsuya6502
Erlang/OTPやRustで分散DBを開発してます。プログラミングは中学生の頃から、30年ほど独学でやってきてます。大学はアメリカ東海岸にある美大でした。現在は上海在住
https://blog.rust-jp.rs/tatsuya6502/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした