x86_64アーキテクチャ

制御の移譲(=control transfer)

制御の移譲とは、現在実行されているプロシージャー(=procedure)の実行を中断して、別のプロシージャーを実行すること。制御を移譲する手法は、

  • コール(=call)
  • ジャンプ(=jump)
  • ハンドラ(=handler)

の3種類ある。プロセッサが行う処理についてはすでに大方説明してきたので、ここではそれらをまとめる。説明では、32-Bitnのレジスタ名を用いる。64-Bitでも、レジスタ名は変わるが(eg. EAX -> RAX)、同じような処理が行われる。

スタック(=stack)

その前に、コールにおいて重要な枠割りを果たすスタックについて説明しておく。これまでの説明では暗黙知として扱ってきたが、ここでまとめる。スタックは、単方向に成長するデータ領域。通常は、アドレスが小さくなる方へと成長していく。スタックのメモリ上に位置は、スタックセグメントレジスタ(SS)・スタックフレームベースポインタ(BP)・スタックポインタ(SP)で決まる。SSは、スタックが置かれているセグメントを指す。このセグメントは、当然のことながら読み書きともにできなければならない。BPは、スタックないで基準となるアドレスを保持し、主にスタック内にあるデータへのアクセスに使われる。SPは、スタックの先頭アドレスを指す。先頭アドレスとは、スタック内のデータのうち、成長方向の先頭にあるデータのアドレス。スタックへのプッシュは、SPの値をデクリメントし、デクリメント後のSPが指し示しているアドレスにオペランドのデータを格納する。スタックからのポップは、SPが指し示しているアドレスのデータを取り出し、SPの値をインクリメントする。ただし、スタックの成長方向がアドレスが大きくなる方向の場合(僕は例を知らないが)は、インクリメントとデクリメントが逆になる。インクリメントとデクリメントで、どれだけの値を足し引きするかは、コードセグメントのDフラグで決まる。データをスタックにプッシュすることを、データをスタックに積むともいう。

スタックフレーム

enter

(TODO)

leave

(TODO)

コール

コールの種類は、

  • near コール
  • far コール
    • 特権レベルをまたがないコール
      • nonconfortable コール
      • confortable コール
    • 特権レベルをまたぐコール
      • confortable コール
    • コールゲート
      • 特権レベルをまたぐコールゲート
        • nonconfortable コールゲート
        • confortable コールゲート
      • 特権レベルをまたがないコールゲート
        • nonconfortable コールゲート
        • confortable コールゲート
    • タスクゲート

に分かれている。ジャンプについても同様。コールとジャンプの違いは、DPLのチェック(プロテクションで前述)と呼び出しの前後の処理、および、タスクゲートに関しては、リンキングの有無といったところ。

関数の呼出規則

ある環境(OS、言語など)で作られたオブジェクトファイルが、他の環境でも使えるように、関数の呼び出し(コール)などをどのように行うかと言うことについて、アプリケーションバイナリインターフェース(=application binary interface or ABI)という決まりが作られている。ここでは、cdecl呼び出し規則を説明する。この中で、関数を呼び出すときの規則(呼出規則)が決められている。cdecl以外にも呼出規則があり、有名なものを挙げると、

名前 使用場所
stdcall Windows API (DLL)
System V AMD64 ABI 64bit Linux

がある。stdcallでは、パラメタの消去を呼ばれた側で行う点でcdeclと異なる。また、System V AMD64 ABIでは、パラメタを渡す時にレジスタを用いる点が違う。 x86_64では、汎用レジスタの数が増えたのでそうなっているのだろう。呼び出し規則が違うライブラリをリンクすると、正常に作動しないので、ライブラリ使用時には注意が必要。パラメタの受け渡しには、引数リスト(=argument list)と呼ばれる専用のセグメントを使うこともできる。詳細は割愛。

以下の説明では、一般的な場合(スタックスイッチなし)の規則を示す。各段階では、スタックの状態を併記する。そのなかでは、スタックは上に伸びていくものとする。また、indexは相対的な値である。indexはスタックの成長方向に増加しているが、スタックは通常、アドレスが小さくなる方向へと成長する。info列は、ESPやEBPがどこを指し示しているかを可能な限り示す。

まず、呼び出し側では、

  1. 引数を後に指定されているものから順にスタックにプッシュする
  2. 最後にプログラムカウンタ(ie. EIPなど)をリターンアドレスとしてプッシュする
index value info
3 EIP <-ESP
2 param3
1 param2
0 param1
-1 ---

をおこなう。そして、呼び出された側では、

  1. スタックベースポインタ(ie. EBPなど)をスタックにプッシュする
  2. スタックベースポインタにスタックポインタ(ie. ESPなど)を代入する
  3. ESPを変化させて、ローカル変数用の領域を確保する
index value info
6 var2 <-ESP
5 var1
4 EBP(old) <-EBP(new)
3 EIP
2 param3
1 param2
0 param1
-1 ---

をおこなう。ローカル変数用の領域は、アライメント等の関係により、EBPを保存した領域と隣接するとは限らない。リターン時には、呼び出された側では、

  1. 返り値をアキュミュレーションレジスタ(ie. EAXなど)に格納する
  2. ローカル変数等を削除する(eg. ESPにEBPを代入する)
  3. 元のEBPEBPに代入して、EBPを復元する
  4. スタック上のリターンアドレスを用いて、元の処理に戻る
index value info
3 EIP <-ESP
2 param3
1 param2
0 param1
-1 ---

をおこなう。最後に

  1. スタックに積んだ引数の分だけ、スタックポインタを移動させる
index value info
-1 --- <-ESP

を行なって、関数の呼び出しを終える。EAX、ECX、EDXの値は呼び出された側で書き換えても良いことになっているので、呼び出し側では必要に応じて、これらのレジスタの値を保存しておく。もちろん、スタックスイッチ等が起こる場合はこの限りではないが、この動作が関数呼び出しの基本である。

nearコール(スタックスイッチなし)

f:id:babyron64:20171226213645j:plain nearコールがなされると、プロセッサは以下のような動作をする。

パラメタを渡す場合は、コール前に準備しておく。どのような準備をするかは、呼出規則による。

  1. 現在のEIPの値をスタックに積む
  2. 呼び出し先のアドレスをEIPに格納する
  3. 呼び出し先の処理を始める

呼び出し側の処理では、EBPの保存や、ローカル変数用の領域の確保、パラメタの取得などを呼出規則に従って行う。

リターン(nearリターン)時には、プログラム側でスタックの先頭データが、呼び出し時に保存したEIPであることを保証しなくてはならない。また、戻り値がある場合は、呼出規則に従って設定しておく。復元しなければならないレジスタがある場合も、呼出規則に従って復元する。

  1. スタックの先頭データをポップして、EIPにロードする
  2. もし、RET命令にオペランドが指定されていたら、指定された分(バイト単位)だけESPをインクリメントする
  3. 呼び出し側の処理を再開する

RET命令にオペランドを指定することで、パラメタの受け渡しに使ったスタック領域を自動で削除してくれる。

このように、プロセッサはコールの基本的な機構のみを提供する。パラメタや戻り値の受け渡しやレジスタの復元等の処理は、呼出規則に従ってプログラムで設定する必要がある。このことは、以降で説明するコールについても同じ。また、処理を追ってみるとわかると思うが、どのコールが行われても、プログラム側から見れば大差ない(=transparent)。以降の説明では、呼出規則に依存する処理内容については省略する。

farコール(スタックスイッチなし)

farコールがなされると、プロセッサは以下のような動作をする。farコールにおけるスタックの状態は、nearコール欄に掲載した図に書かれている。また、コールに際しては、プロテクションチェックが行われる段階がいくつもあるが、詳細は、プロテクションの記事を参照。以下の説明においては、プロテクションについての記述は省略。

  1. 現在のCSレジスタの値をスタックに積む
  2. 現在のEIPの値をスタックに積む
  3. 呼び出し先のCSをロード
  4. 呼び出し先のEIPをロード
  5. 呼び出し先の処理を開始

リターン(farリターン)時には、

  1. スタックの先頭データをポップし、EIPにロード
  2. もし、RET命令にオペランドが指定されていたら、指定された分(バイト単位)だけESPをインクリメントする
  3. 呼び出し元の処理を再開する

farコール(スタックスイッチあり)

f:id:babyron64:20171226213651j:plain

特権レベルが変化するfarコールがなされると、プロセッサは以下のような動作をする。特権レベルが変化するfarコールは、コールゲートを使ってのみ可能。

  1. 一時的に、SSとESP、CS、EIPの値を保存する(スタックではなく、一時領域に保存)
  2. 新しいスタック用にSSとESPをロードし、スタックスイッチを行う(以下、スタックは別の領域に変わっている)
  3. 一時保存されていたSSとESPをスタックに積む
  4. コールゲートからパラメタ数を取得し(param count フィールド)、呼び出し元のパラメタをスタックにコピーして積む
  5. 一時保存されていたCPとEIPをスタックに積む
  6. コールゲートから呼び出し先のCSとEIPを取得し、CSとEIPに格納する
  7. 呼び出し先の処理を開始

64-Bitでは、param countフィールドが廃止されたので、パラメタは、スタックにある元のスタックの情報等を基にして、自分でコピーする。

リターン(farリターン)時には、

  1. CSとEIPを復元
  2. もし、RET命令にオペランドが指定されていたら、呼び出し元・呼び出し先の両方で、指定された分(バイト単位)だけESPをインクリメントする
  3. SSとESPを復元(スタックのスイッチバック)
  4. 2番の処理の続き(呼び出し元でのESPのインクリメント)
  5. 呼び出し元の処理の再開

ジャンプ

ジャンプはコールと同じように、離れた場所に実行を移すために使われる。だが、コールの場合とは違い、レジスタ等の保存処理は行われない。ただ、SS、ESP、CS、EIPの値が必要に応じて変更されるのみ。実行を移す際には、必要ならばプロテクションチェックが行われる。

割り込み・例外ハンドラ

f:id:babyron64:20171225172031j:plain 割り込み・例外ハンドラは、コールに似ているが、以下の点で異なる。

  • IDTにゲートがある
  • EFLAGSレジスタも保存される
  • エラーコードがスタックに積まれることがある

詳しくは、割り込み・例外の記事を参照。

sysent / sysexit

f:id:babyron64:20171228004539p:plain (TODO)

syscall / sysret

(TODO)

参考文献