Explore "Full-Stack" in depth!

情報系の専門学校で、今は機械学習に的を絞って学習中。プログラミングを趣味でやりつつ、IT系のあらゆる知識と技術を身に付けるべく奮闘中。

IA-32 Architectures Developer's Manual 6章まとめ

目次

概要

IA-32マニュアルを読み始めました。

無料で読めるドキュメントとしては破格の詳しさなので、 システムプログラミングに興味のある人は全員読むべきです。

今回はこのマニュアルのうち6章の内容をベースにしながら、 自身で調べたことをガンガン追記しつつ紹介していこうと思います。

プロシージャ・コールのタイプ

そもそもプロシージャとは、
複数の処理を一つにまとめたもののことを指します。

IA-32アーキテクチャでは特に
call命令やret命令等、
ニーモニックを処理単位とした時に 複数処理が内包されているアセンブリコードをプロシージャと呼称しています。

通常のプログラミングでいえば、
プロシージャ=関数と言い換えて差し支えないでしょう。

IA-32プロセッサにおける プロシージャコール

  • call命令及びret命令
  • enter命令及びleave命令

をサポートしています。

これらプロシージャコールは
スタック(プロシージャコールの文脈ではプロシージャ・スタックとも)を利用して、
呼び出し元の状態を退避しておき、
渡された引数を呼び出し先に渡し、
現在実行されているプロシージャのローカル変数を格納します。

スタック

スタックは、メモリ空間上に連続するデータ構造です。
セグメント内に配置され、
SSレジスタ 内のセグメントセレクタによって識別されます。

実行プログラムにおけるセグメントと
メモリアクセスの文脈で登場するセグメント
別物である場合が多いことを知っておきましょう。

実際にC言語ソースコードコンパイルして、
readelf -lコマンドを使用してみましょう。

f:id:orangebladdy:20190429165535j:plain

確かにGNU_STACKというセグメントが存在しています。

フラットメモリモデルにおいては、
スタックは プログラム用のリニアアドレス空間の任意の場所に配置できます。

1つのスタックはセグメントの最大サイズである 4GBまでのサイズを持てます。

スタック上にアイテム等を配置する場合は PUSH 命令、
スタックから取り出す場合は POP命令を利用します。

スタックにプッシュする際には、

  • ESPレジスタのデクリメント
  • スタックのトップに対象アイテムを書き込む

逆にスタックからポップする際は、

  • スタックのトップからアイテムを読み取る
  • ESPレジスタをインクリメント

という手法を取ります。

スタックのトップは メモリにおける低位アドレス方向を示し、
逆に底に向かうにつれて 高位アドレス に近づくことになります。

マルチタスクの実行環境を持つコンピュータであれば、
各タスクに対して別々のスタックをセットアップ出来るようです。

こちらの記事に詳しい説明がありました。

robot.tamagawa.ac.jp

スタックのセットアップ

多数スタックをセットアップしたとしても、
現在のスタック(ここではカレントスタックと定義しておきます)として設定、
つまりSSレジスタの指すスタックとして設定するには次の手順を踏みます。

  • スタックセグメントの設定
  • mov,pop,lssのいずれかの命令を利用して、スタックセグメントのセグメントレジスタSSレジスタにロード
  • 同じくいずれかの命令を利用して、 スタックポインタをESPレジスタにロード

スタックのアライメント

スタックセグメントのスタックポインタは、
スタックセグメントの幅によってアライメントを揃える必要があります。

具体的には、

  • 16ビット(ワード)境界
  • 32ビット(ダブルワード)境界

のどちらかです。

プロセッサはスタックのアライメントをチェックしないために、
プログラム・タスク・システムプロシージャによって適切に管理する必要があります。

スタックアライメントが不正である時、

  • 処理能力の極端な低下
  • プログラム障害(場合による)

の危険性があります。

プロシージャのリンクに関する情報

プロシージャ間をリンクさせる為に、

  • スタックフレームポインタ
  • リターン命令ポインタ

の2つを利用します。

ソフトウェア上で先述したプロシージャコールと併用することで、
確実にコヒーレンシを損なわずにプロシージャ間リンクが可能です。

スタックは一般に 一連のフレームに分割されます。
各フレームには

  • ローカル変数
  • 別プロシージャに渡すパラメータ
  • プロシージャリンクに関する情報

が格納されています。

EBPレジスタには スタックフレームベースポインタが格納されていますが、
これは スタックフレーム上でプロシージャを参照する為の値であり、
スタック上のローカル変数に容易にアクセスするために用いられます。

プロシージャがコールされた時、
最初の命令を実行する前にEIPレジスタ内のアドレスがスタックにプッシュされます。

これをリターン命令ポインタと言います。
プロシージャのコール元が実行を再開する命令をポイントしておきます。

ret命令時にはスタックポインタが
スタックにプッシュされたRIPを指すようにしなければなりません。

実際に見てみないとわからないと思うので、
実例を見てみましょう。

適当に書いたC言語のコードを-Sオプションをつけてコンパイルしました。

#include<stdio.h>

int add(int a,int b);

int main(void){
  int a = 30;
  int b = 50;
  printf("a:%d,b:%d",a,b);
  printf("add:%d",add(a,b));
  return 0;
}

int add(int a,int b){
  return a+b;
}

アセンブリを見てみましょう。

        .file   "c.c"
        .intel_syntax noprefix
        .text
        .section        .rodata
.LC0:
        .string "a:%d,b:%d"
.LC1:
        .string "add:%d"
        .text
        .globl  main
        .type   main, @function
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16 ;2バイト分スタックポインタを"上げる"
        mov     DWORD PTR -8[rbp], 30 ;int a
        mov     DWORD PTR -4[rbp], 50 ;int b
        mov     edx, DWORD PTR -4[rbp]
        mov     eax, DWORD PTR -8[rbp]
        mov     esi, eax
        lea     rdi, .LC0[rip]
        mov     eax, 0
        call    printf@PLT
        mov     edx, DWORD PTR -4[rbp]
        mov     eax, DWORD PTR -8[rbp]
        mov     esi, edx ;変数aを渡す
        mov     edi, eax ;変数bを渡す
        call    add
        mov     esi, eax
        lea     rdi, .LC1[rip]
        mov     eax, 0
        call    printf@PLT
        mov     eax, 0
        leave
        ret
        .size   main, .-main
        .globl  add
        .type   add, @function
add:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR -4[rbp], edi
        mov     DWORD PTR -8[rbp], esi
        mov     edx, DWORD PTR -4[rbp]
        mov     eax, DWORD PTR -8[rbp]
        add     eax, edx
        pop     rbp
        ret
        .size   add, .-add
        .ident  "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0"
        .section        .note.GNU-stack,"",@progbits

今回はスタックの挙動を深く追っていきたいので、
gdb-pedaを用いて行きます。

まずはAdd関数呼び出し直前です。

f:id:orangebladdy:20190429183108j:plain

この時、コメントアウトに示した様に、
esia=30が、edib=50が格納されています。

それぞれ、
30 = 0x1e,50=0x32となっています。

さて、この時の rip
0x8000683となっています。
codeに書いてある通り、 call addを呼ぶ機械語が格納されたアドレスです。

続いてcall add直後を見てみます。

f:id:orangebladdy:20190429183839j:plain

まずスタックには 0x8000688が積まれています。
これは先程見たとおり main関数のadd呼び出し時のripの値です。

つまりAdd関数呼び出しからmain関数に戻る時、
0x7ffffffedc38 = 0x8000688rspが指していれば良いことになります。

それではAdd関数呼び出しから戻る直前を見てみましょう。

f:id:orangebladdy:20190429184442p:plain

しっかりとrspの値が0x7ffffffedc38を指していますね。

Add関数の呼び出しから戻ります。

f:id:orangebladdy:20190429184748j:plain

呼び出しから戻ってこれました!
これが関数呼び出し時のRIPの挙動です。


CALLとRETによるプロシージャコール

CALL命令には2つあり、

  • nearコール現行コードセグメント内のプロシージャに制御転送
  • farコール異なるコードセグメント内のプロシージャに制御転送

同じくRETURN命令にも nearリターンfarリターンが存在します。

nearコール・リターンについては今挙動を確認したので、
farコール・リターンについて解説します。

farコール・リターン

farコール時にプロセッサが行う動作は以下です。

  • CSレジスタの値をスタックにプッシュ
  • EIPレジスタの値をスタックにプッシュ
  • コールされたプロシージャを格納しているセグメントのセグメントレジスタをCSレジスタにロード
  • コールされたプロシージャのオフセットをEIPレジスタにロード
  • コールされたプロシージャの実行開始

farリターン時には、

  • スタックのトップ(RIP)をEIPレジスタにポップ
  • スタックのトップ値( 戻り先となるコードセグメントのセグメントレジスタ)をCSレジスタにポップ
  • ret命令にオプション引数nがある場合、スタックからパラメータを開放するために指定バイトスタックを上げる

総評

スタックの挙動を勉強しながら、
実際にデバッガで理解を深めました。

ENTER命令、LEAVE命令については省略したので、
後々詳しく勉強したいなあ。