アンティーク・アセンブラ~Antique Assembler

第6回(最終回) 関数の機能 ~ 関数間での連携

前回は、関数[1]が呼ばれた際に、関数内での局所的な情報をどのように管理するかについて説明しました。

今回は、関数呼び出しにおける連携方法について説明しようと思います。

引数と戻り値

引数

関数の呼び出し元は、さまざまな形式で引数を指定します。たとえば、

  • 自身の局所変数の値
  • 自身に指定された引数
  • 他の関数の呼び出し結果
  • 上記の値から導出された値(例: 四則演算/構造体参照等)

一方で(一般的な)言語仕様上から見て、呼び出された側にとっての引数は、関数終了まで領域が保持されている=終了後は必要ない、という点では局所変数と実質的に差異がありません。

そのため、Intel x86アーキテクチャで関数呼び出しを実現する場合、呼び出し元は引数をスタック上に格納します。

リスト1 呼び出し元での引数格納
    movl    $1, 0(%esp)
    movl    $15, 4(%esp)

呼び出し先の関数は、スタック上の引数をEBPレジスタ相対の間接アドレッシングで参照します。

リスト2 呼び出し先での引数参照
    movl    8(%ebp), %eax   # eax には 1 が格納
    movl    12(%ebp), %eax  # eax には 15 が格納

格納時のESPレジスタに対する即値指定と、参照時のEBPレジスタに対するそれが異なるのは、

  • 関数復帰の際の呼び出し元アドレス格納領域(+4)
  • 旧EBP値の格納領域(+4)

上記の分だけスタック上に領域が確保されているためです。

関数が呼び出された際のスタックは以下のような構成となっています。

図1 関数起動時のスタック構成
図1 関数起動時のスタック構成

戻り値

関数引数が複数の値を扱う必要があったのに対して、戻り値は単一の値を扱うだけです。

Intel x86アーキテクチャで関数呼び出しを実現する場合、関数の返却値がEAXレジスタに格納された状態で呼び出し元に復帰します。

リスト3 戻り値格納と復帰
    movl    $1, %eax  # 戻り値は 1
    leave
    ret

再帰呼び出し

関数呼び出しの例として、以下のような再帰呼び出しを実装してみましょう(この処理そのものには全く意味はありません⁠⁠。

リスト4 Cによる再帰呼び出しの例
int get_crossing_depth(int lower, int upper){
    if((upper -= 2) <= (lower += 3)){
        return 1;
    }
    else{
        return get_crossing_depth(lower, upper) + 1;
    }
}

....    depth = get_crossing_depth(1, 15);

上記のCプログラムをアセンブラで実現すると、以下のようになります。

リスト5 アセンブラによる再帰呼び出し実装
    .text
    .align  4

    .global get_crossing_depth
get_crossing_depth:
    enter   $8, $0         # 再帰呼び出し引数(4x2)の領域を
                           # スタック上に確保

    subl    $2, 12(%ebp)   # upper -= 2
    addl    $3, 8(%ebp)    # lower += 3

    movl    12(%ebp), %eax
    movl    8(%ebp), %edx
    cmpl    %eax, %edx     # upper と lower の比較
    jl      recursively

    # upper <= lower なので再帰呼び出し無し
    movl    $1, %eax       # 戻り値設定
    leave
    ret

recursively:
    # upper > lower なので再帰呼び出し有り
    movl    8(%ebp), %eax
    movl    %eax, 0(%esp)  # lower 引数格納
    movl    12(%ebp), %eax
    movl    %eax, 4(%esp)  # upper 引数格納

    call    get_crossing_depth

    addl    $1, %eax       # 戻り値設定
                           # 再帰呼び出しの戻り値に +1
    leave
    ret

    .global entry_point
entry_point:
    int3
    addl    $8, %esp       # 引数領域確保
    movl    $1, (%esp)     # lower 引数格納
    movl    $15, 4(%esp)   # upper 引数格納

    call    get_crossing_depth

    .global end_of_program
end_of_program:
    int3

再帰呼び出しを行う get_crossing_depth() では、再帰呼び出しのための引数格納領域として、冒頭のenter命令の時点で4バイト×2=8バイト分をスタック上に確保していますenterの詳細は前回の説明を参照⁠⁠。

また、呼び出し元のentry_point側でも、ESPレジスタを移動させることで呼び出し引数の格納領域を確保しています。

ESP/EBPと、それぞれの値が格納されている領域の相対位置さえ把握してしまえば、やっている処理は単純ですから、とくに難しいことは無いでしょう。

低レイヤから見た関数呼び出し

Application Programming Interface(API)は、当該プログラミング言語において特定の機能を呼び出す際の仕様であり、専らソースコード上での記述方式について定めたものを指します。

その一方で Application Binary Interface(ABI)は、アセンブラレベルでの実際の連携実現方式について定めたものを指します。⁠関数呼び出しの際の引数格納形式」のような"呼出規約(calling convention)もABIを構成する要素となります。

本稿ではこれまで、Intel x86アーキテクチャでの関数呼び出しはスタック経由で引数を受け渡しする、と説明してきました。

しかしその一方で、汎用レジスタを多数保持するSPARCプロセッサのようなCPUアーキテクチャの場合は、一定数以内の引数であればレジスタ経由で引数を受け渡すものとABIで定められています。

以上のように、ABIはCPUアーキテクチャに依存するところが大きいのですが、必ずしもCPUアーキテクチャのみで決定されるわけではありません。

たとえば、先の再帰呼び出し実装の例では、とくに説明せずに、Cプログラムでの引数リスト上の左から右の順で、アドレス低位(ESPに対する加算分の少ない)側から格納しました。

これは、Intel x86アーキテクチャのCコンパイラで用いられる標準的な呼出規約(⁠⁠cdecl⁠形式)において、スタックへの引数格納仕様がそのように定められているためです。

先の再帰呼び出し実装の例からもわかるように、引数の個数を特定するための情報受け渡しが行われない⁠cdecl⁠形式では、引数格納領域の解放は呼び出しの責務となります。

引数の数が固定=呼び出し先で解放可能な関数の呼び出しであっても、領域解放責務は呼び出し元にありますので、結果として引数領域の解放処理が呼び出し元の数だけ散在することになりますが、裏を返せば、引数格納領域の管理が呼び出し元に一任されることになりますから、事前に必要なだけ確保した領域を再利用することもできれば、任意個の引数を渡すことも可能になります。

その一方で、⁠stdcall⁠形式と呼ばれる呼出規約では、引数格納領域の解放は呼び出しの責務とされます。

これは先ほどの "cdecl" の逆で、引数の数が固定である関数の呼び出しの場合は、引数領域の解放処理が呼び出し先に集約されるメリットと引き換えに、引数格納領域の管理自由度や、可変引数の使用が制限されることになります。

これらの呼出規約は、アセンブラソースを生成するプログラミング言語や、最終的な稼働環境のOSにおいて、機能と制約のバランスの落とし所をどこにするか、といった部分を考慮して決定されます。

すべてを自前の関数で構成するのであれば、既存の呼出規約を無視して、⁠MY呼出規約による連携」でも構いませんが、一般的なアセンブラの用途としては、性能/ハード依存要求の高い部分だけをアセンブラで実装し、それ以外はCなどを使用するパターンがほとんどでしょうから、呼出規約を含めたABIに対する配慮が重要になります。

おわりに

全6回(+号外に渡って、アセンブラプログラミングについて説明させていただいたわけですが、如何でしたでしょうか?

ソフトウェアの世界は、日々多機能化/高機能化と共に複雑化が進んでいますが、最終的にはアセンブラレベルでのデータ転送や制御遷移の組み合わせに過ぎません(量子コンピューティング等にパラダイムシフトでもすれば、違ってくるのでしょうが…⁠⁠。

アセンブラレベルからのボトムアップ的な視点を身につけることで、ソフトウェアへの理解はより深まることでしょう。

この連載が、そのような理解へのきっかけになれば幸いです。

おすすめ記事

記事・ニュース一覧