x86 (IA-32) Assembly Language
Intel 486 プロセッサ
[注意]
本ページで扱っているのは 32bit OS上でGNUのgcc コンパイラを使った例です。
2005年ごろまではC コンパイラとして GNU プロジェクトの gcc が利用されることが一般的でしたが、
それ以降は LLVM の clang が使われることが多くなり、現在ではgccコマンドを起動しても
内部はclangであることが多くなっています。
そのため、本ページの例と、手元のシステムのgccの出力が異なる場合がありえます。
参考文献:
はじめて読む486,蒲地輝尚 著,アスキー出版局,1994
Intel CPUの発展:
4004 → 8080 → 8086 → 80186 → 80286 → 80386 → 80486(i486)
→ Pentium → PentiumPro → Pentium II → Pentium III → PentiumIV
→ ...→ Core i7 → Core X → ...
最近の Windows パソコンで使われているintel 製のCPU は
i486 の命令体系をそのまま受け継いでいます。
(64bit化されましたが...)
https://www.intel.co.jp/content/www/jp/ja/products/processors/core.html
i486のレジスタ
図2-12参照
- 一般レジスタ群
- 浮動小数点レジスタ群
- システムレジスタ群
- デバッグレジスタ群
図2-14参照
図2-15参照
i486のアセンブリ言語(gccの_asmにおける表記方法)
レジスタ
%eax, %ebx, %ecx, %edx, %ebp, %esp
アドレスモード
%eax レジスタ指定
$10 定数10
10 アドレス 10番地
8(%ebp) レジスタ相対(ebpの指している位置から8バイト先)
(%ecx) レジスタ間接(ecxの指している位置のデータ)
ラベル
行の先頭で、名前に ':'を付ける。
L0:
L1:
命令
shrl $8, %eax 論理右シフト(この場合は8ビットシフト)
shll $8, %eax 論理左シフト(この場合は8ビットシフト)
asll $8, %eax 算術左シフト(この場合は8ビットシフト)
movl a, b aをbにコピー (コピーの方向はアセンブラによって異なることに注意)
push a a をスタックに push
pop a a にスタックから pop
xorl a, b a xor b → b
andl a, b a and b → b
(マスク操作:
例えば%eaxの下位4ビットを残したいときは
andl $15,%eax
とする)
cmpl a, b aとbを比較する(フラグレジスタが変化する)
jne L フラグレジスタの状態が b != a ならばジャンプする。
je L b == a ならばジャンプする。
jle L b <= a ならばジャンプする。
jl L b < a ならばジャンプする。
jge L b >= a ならばジャンプする。
jg L b > a ならばジャンプする。
call f サブルーチン f を呼び出す
ret サブルーチンを呼び出した場所に戻る
関数の返り値
関数の最後で%eaxに値を入れて ret する。
i486の命令一覧
参考文献のAppendix参照
注意:Appendixの表は、gccが出力する命令とは
オペランドのsourceと target の向きが逆になっていることに
注意して下さい。
文書 | アセンブラの書式 |
gccの出力 | 命令 source, target |
この文書 | 命令 source, target |
参考文献 | 命令 target, source |
i486のアセンブリ言語の例
関数と戻り値
まず、C言語で簡単な関数を作成してみましょう。
ファイル名は sample1.c にします。
sample1.c |
int sample1() {
return 5;
}
|
次に、gccを使ってコンパイルし、 i486 のアセンブリ言語を出力させてみましょう。
gcc に -S オプションをつけるとアセンブリ言語の出力が得られます。
-O や -O2 オプションをつけると最適化のレベルが変わります(数字が大きいほど
最適化のレベルが上)。
sample1.s (gcc -O2 -S sample1.c) |
.file "sample1.c"
.text
.align 2
.align 16
.globl _sample1
.def _sample1; .scl 2; .type 32; .endef
_sample1:
pushl %ebp
movl %esp, %ebp
movl $5, %eax
popl %ebp
ret
|
返り値として5を返す関数ですが、eaxレジスタにその値が入っていることが
わかります。
関数への引数
sample2.c |
int sample2(int x,int y) {
int z;
z = x + y;
return(z);
};
|
sample2.s (gcc -O2 -S sample2.c) |
.file "sample2.c"
.text
.align 2
.align 16
.globl _sample2
.def _sample2; .scl 2; .type 32; .endef
_sample2:
pushl %ebp
movl %esp, %ebp
movl 12(%ebp), %eax
movl 8(%ebp), %edx
popl %ebp
addl %edx, %eax
ret
|
sample2.s (gcc -S sample2.c) |
.file "sample2.c"
.text
.align 2
.globl _sample2
.def _sample2; .scl 2; .type 32; .endef
_sample2:
pushl %ebp
movl %esp, %ebp
subl $4, %esp
movl 12(%ebp), %eax
addl 8(%ebp), %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
leave
ret
|
関数の最初は
pushl %ebp
movl %esp, %ebp
から始まっています。これは、「まず、(espが指す)スタック上に
古いベースポインタを積んで、さらに、そのときのスタックポインタの値を
ベースポインタにコピーしておく」という操作です。
これにより、「ベースポインタがこの関数フレームの始まりを指す」ことになり、
スタックポインタの値を自由に変更できる(スタック上に値をpushしてよい)」
ことになります。
上記のアセンブリ言語に現れる leave という命令は
movl %ebp, %esp
popl %ebp
という2つの命令をまとめて書いたものだと理解して下さい。
すなわち、「ベースポインタ(ebp)の値をスタックポインタ(esp)に
上書きすることでスタックポインタを一気に関数フレームの先頭に戻し、
その後、スタックポインタが指す場所から『古いベースポインタ』を
取り出している」のです。
この後、
ret
命令で、スタック上から「返り番地」を取り出して
「この関数を呼び出した命令の次の命令」から実行を再開すれば、
「eaxレジスタに返り値が入っていて」、さらに
「スタック上には呼び出し側で用意した引数が積まれている状態」
ということになります。
関数を呼び出すときのスタックの使い方を図に示します。
sample3.c の中のmain関数が、sample2.cの中の
int sample2(int x,int y)関数を呼出すときの
スタックの様子です。
わかりやすさを優先して、最適化オプションを指定しない
場合のスタックの使い方を表現しています。
関数名 |
main |
main |
main |
main |
機械語 |
|
pushl $3 |
pushl $2 |
call sample2 |
スタック |
|
|
|
|
関数名 |
sample2 |
sample2 |
sample2 |
機械語 |
pushl %ebp |
movl %esp,%ebp |
subl $4,%esp |
スタック |
|
|
|
関数名 |
sample2 |
sample2 |
sample2 |
sample2 |
main |
機械語 |
引数はebp+8, ebp+12 |
movl %ebp, %esp |
popl %ebp |
ret |
addl $8, %esp |
スタック |
|
|
|
|
|
関数の呼び出し
sample3.c |
int main()
{
int i;
i=sample2(2,3);
printf("%d\n",i);
}
|
sample3.s (gcc -S sample3.c) |
.file "sample3.c"
.def ___main; .scl 2; .type 32; .endef
.text
LC0:
.ascii "%d\12\0"
.align 2
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $24, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -8(%ebp)
movl -8(%ebp), %eax
call __alloca
call ___main
movl $2, (%esp)
movl $3, 4(%esp)
call _sample2
movl %eax, -4(%ebp)
movl $LC0, (%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
call _printf
leave
ret
.def _printf; .scl 2; .type 32; .endef
.def _sample2; .scl 2; .type 32; .endef
|
sample3.s (gcc -O2 -S sample3.c) |
.file "sample3.c"
.def ___main; .scl 2; .type 32; .endef
.text
LC0:
.ascii "%d\12\0"
.align 2
.align 16
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
xorl %eax, %eax
andl $-16, %esp
call __alloca
call ___main
movl $3, 4(%esp)
movl $2, (%esp)
call _sample2
movl %eax, 4(%esp)
movl $LC0, (%esp)
call _printf
movl %ebp, %esp
popl %ebp
ret
.def _printf; .scl 2; .type 32; .endef
.def _sample2; .scl 2; .type 32; .endef
|
「オプティマイズなし」のコードは余分な操作をあちこちで
行っていてひどく効率の悪いものであることがわかります。
それに対して「オプティマイズ・レベル2 (-O2)」の場合は効率の
よいコードが出力されています。
普通(-Oオプションなしの場合)は、関数呼び出しから戻る毎に
スタックポインタに(4や8などを)加算して戻します。
しかし-O2 をつけた場合のコードでは関数呼び出しの後で
スタックポインタを戻していません。
これは高速化のためにスタック用メモリを浪費していることになります。
-O2の場合は、スタックが32バイト伸びた段階で一気に戻すようなコードを
出しています
(sample7.s参照。動作はgccのバージョンによって異なります)。
sample7.c |
int sample6(int x)
{
printf("%08x\n",&x);
}
int main() {
int a,b;
a = 123;
b = 3;
sample6(a<<2+b); /* 以下99行同じ */
...
}
|
sample7.s (gcc -O2 -S sample3.c) |
.file "sample7.c"
.version "01.01"
gcc2_compiled.:
.section .rodata
.LC0:
.string "%08x\n"
.text
.align 4
.globl sample6
.type sample6,@function
sample6:
pushl %ebp
movl %esp,%ebp
leal 8(%ebp),%edx
pushl %edx
pushl $.LC0
call printf
leave
ret
.Lfe1:
.size sample6,.Lfe1-sample6
.align 4
.globl main
.type main,@function
main:
pushl %ebp
movl %esp,%ebp
pushl %ebx
movl $3936,%ebx
pushl %ebx
call sample6
pushl %ebx
call sample6
pushl %ebx
call sample6
pushl %ebx
call sample6
pushl %ebx
call sample6
pushl %ebx
call sample6
pushl %ebx
call sample6
pushl %ebx
call sample6
addl $32,%esp ←一気に32byte分スタックを戻す
... 8呼び出し毎にスタックを戻すことの繰り返し
movl -4(%ebp),%ebx
leave
ret
.Lfe2:
.size main,.Lfe2-main
.ident "GCC: (GNU) egcs-2.91.66 19990314/Linux (egcs-1.1.2 release)"
|
参考文献
再帰関数(階乗を計算する)
fact.c |
int fact(int n) {
int i;
if (n <= 0) i=1;
else i = n * fact(n-1);
return(i);
}
|
fact-O2.s (gcc -S -O2 fact.c) |
.file "fact.c"
.text
.align 2
.align 16
.globl _fact
.def _fact; .scl 2; .type 32; .endef
_fact:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
movl %ebx, -4(%ebp)
movl 8(%ebp), %ebx
movl $1, %eax
testl %ebx, %ebx
jle L3
leal -1(%ebx), %eax
movl %eax, (%esp)
call _fact
imull %ebx, %eax
L3:
movl -4(%ebp), %ebx
movl %ebp, %esp
popl %ebp
ret
|
fact.s (gcc -S fact.c) |
.file "fact.c"
.text
.align 2
.globl _fact
.def _fact; .scl 2; .type 32; .endef
_fact:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
cmpl $0, 8(%ebp)
jg L2
movl $1, -4(%ebp)
jmp L3
L2:
movl 8(%ebp), %eax
decl %eax
movl %eax, (%esp)
call _fact
movl %eax, %edx
movl 8(%ebp), %eax
imull %edx, %eax
movl %eax, -4(%ebp)
L3:
movl -4(%ebp), %eax
leave
ret
|
関数の呼び出し
sample5.c |
int sample5(int a,int b) {
int c;
c=a+b;
printf("%d\n",c);
c=a-b;
printf("%d\n",c);
c=a*b;
printf("%d\n",c);
c=a/b;
printf("%d\n",c);
}
|
sample5.s (gcc -O2 -S sample5.c) |
.file "sample5.c"
.text
LC0:
.ascii "%d\12\0"
.align 2
.align 16
.globl _sample5
.def _sample5; .scl 2; .type 32; .endef
_sample5:
pushl %ebp
movl %esp, %ebp
subl $24, %esp
movl %ebx, -8(%ebp)
movl 8(%ebp), %ebx
movl %esi, -4(%ebp)
movl 12(%ebp), %esi
movl $LC0, (%esp)
leal (%esi,%ebx), %eax
movl %eax, 4(%esp)
call _printf
movl $LC0, (%esp)
movl %ebx, %eax
subl %esi, %eax
movl %eax, 4(%esp)
call _printf
movl $LC0, (%esp)
movl %ebx, %eax
imull %esi, %eax
movl %eax, 4(%esp)
call _printf
movl $LC0, 8(%ebp)
movl %ebx, %eax
movl -8(%ebp), %ebx
cltd
idivl %esi
movl -4(%ebp), %esi
movl %eax, 12(%ebp)
movl %ebp, %esp
popl %ebp
jmp _printf
.def _printf; .scl 2; .type 32; .endef
|
sample5.s (gcc -S sample5.c) |
.file "sample5.c"
.text
LC0:
.ascii "%d\12\0"
.align 2
.globl _sample5
.def _sample5; .scl 2; .type 32; .endef
_sample5:
pushl %ebp
movl %esp, %ebp
subl $24, %esp
movl 12(%ebp), %eax
addl 8(%ebp), %eax
movl %eax, -4(%ebp)
movl $LC0, (%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
call _printf
movl 12(%ebp), %edx
movl 8(%ebp), %eax
subl %edx, %eax
movl %eax, -4(%ebp)
movl $LC0, (%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
call _printf
movl 8(%ebp), %eax
imull 12(%ebp), %eax
movl %eax, -4(%ebp)
movl $LC0, (%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
call _printf
movl 8(%ebp), %edx
movl %edx, %eax
cltd
idivl 12(%ebp)
movl %eax, -4(%ebp)
movl $LC0, (%esp)
movl -4(%ebp), %eax
movl %eax, 4(%esp)
call _printf
leave
ret
.def _printf; .scl 2; .type 32; .endef
|
Cのプログラム中に直接機械語を記述する方法
asm文を使います。
文字列の中で改行したい場合は、その改行を打ち消すために改行文字の
前に \
(バックスラッシュ、日本語環境では円マーク ¥ で表示されることがあります)を入れます。
ただしそのままだと、機械語ファイル(.s)の中で行がつながって
しまうので、'\
改行'の前に明示的に改行コード \n
を入れておきます。
例:
foo.c |
int test(int x, int y)
{
__asm__(" \n\
pushl %edx \n\
movl 8(%ebp),%edx \n\
addl 12(%ebp),%edx \n\
movl %edx,%eax \n\
popl %edx \n\
");
}
main() {
printf("%d\n", test(2,3));
}
|
gcc -O2 -S foo.cで生成したfoo.s |
.file "foo.c"
.text
.align 2
.align 16
.globl _test
.def _test; .scl 2; .type 32; .endef
_test:
pushl %ebp
movl %esp, %ebp
/APP
pushl %edx
movl 8(%ebp),%edx
addl 12(%ebp),%edx
movl %edx,%eax
popl %edx
/NO_APP
popl %ebp
ret
.def ___main; .scl 2; .type 32; .endef
LC0:
.ascii "%d\12\0"
.align 2
.align 16
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
xorl %eax, %eax
andl $-16, %esp
call __alloca
call ___main
movl $3, 4(%esp)
movl $2, (%esp)
call _test
movl %eax, 4(%esp)
movl $LC0, (%esp)
call _printf
movl %ebp, %esp
popl %ebp
ret
.def _printf; .scl 2; .type 32; .endef
|
gcc -S foo.cで生成したfoo.s |
.file "foo.c"
.text
.align 2
.globl _test
.def _test; .scl 2; .type 32; .endef
_test:
pushl %ebp
movl %esp, %ebp
/APP
pushl %edx
movl 8(%ebp),%edx
addl 12(%ebp),%edx
movl %edx,%eax
popl %edx
/NO_APP
popl %ebp
ret
.def ___main; .scl 2; .type 32; .endef
LC0:
.ascii "%d\12\0"
.align 2
.globl _main
.def _main; .scl 2; .type 32; .endef
_main:
pushl %ebp
movl %esp, %ebp
subl $24, %esp
andl $-16, %esp
movl $0, %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
call __alloca
call ___main
movl $2, (%esp)
movl $3, 4(%esp)
call _test
movl $LC0, (%esp)
movl %eax, 4(%esp)
call _printf
leave
ret
.def _printf; .scl 2; .type 32; .endef
|
fooの実行結果 |
nitta@degas 648> gcc -O2 foo.c -o foo
nitta@degas 649> ./foo
5
|
i486マシンの使い方
計算センターに設置されている iMac は Intel 製のCPUを搭載しており、
i486 (x86, IA-32)の命令を直接実行できます。
この授業の演習として i486 のアセンブリ言語で書いたプログラムを
実行させてみることにしましょう。
開発環境としては Windows 上で動作する cygwin を用い、
コンパイラとアセンブラにはgcc を使います。
多くの部分はCでプログラミングをし、部分的にアセンブリ言語で
記述することにしましょう。
[注意]
計算センターのWindows環境は Windows 10 Enterprise 2015 LTSB で 64bit 版OSですが、
その上のcygwin 内の gcc は GNU GCC の32bit版なので i486 (32bit版 x86, IA-32) の命令を生成します。
提出課題
問題1a
if 文のコードを調べなさい。
適切なCの関数を定義し、gccを使ってアセブリ言語のコードを出力し、
それにわかりやすいコメントを書き加えてから 提出しなさい。
GCCのアセンブリ言語の中では、
- /* と */ に囲まれた領域
- # を書いた後その行の終りまで
がコメントとなります。
あまりに簡単な条件式でコンパイラが成り立つか否かを判断できる場合は、
if 文のコードをださないことがあることに注意して下さい。
つまり、以下のようなCのコードを書いても駄目だ、ということです。
(例)
if (1 > 0) { ... } ←コンパイル時に、いつでも真と判明する
(例2)
int a = 500, b=450;
if (a < b) { ... } ← コンパイル時に、いつでも偽と判明する
問題2a
for 文のコードを調べなさい。
適切なCの関数を定義し、gccを使ってアセブリ言語のコードを出力し、
それにわかりやすいコメントを書き加えてから提出して下さい。
問題3a
1から「引数で指定された整数」までの和を返す関数を
機械語で書きなさい。
Cの関数の中にasm文でアセンブリ言語の命令を書くこと。
それにわかりやすいコメントを書き加えてから提出しなさい。
問題4a
引数として与えられた 32bit整数が奇数であれば1を、
奇数でなければ 0 を返す関数 int isodd(int n) の
本体をアセンブリ言語で書きなさい。
ただし、
多少実行速度が遅くても構わないので、__asm__ の中では
機械語命令としては
「movl, testl, jz, jmp だけ」
を使うこと。ラベル、レジスタ名、定数などは自由に使用して構わない。
動作することを確認したら、ソースを提出しなさい。
isodd.c |
int isodd(int n) {
__asm__("
/* ここを次の機械語だけを使って書くのが課題です。
movl, testl, jz, jmp */
");
}
|
oddmain.c |
#include <stdio.h>
int main() {
int n;
printf("整数を入力して王踉擦気 〒 黹瘤罔▲笄♣遘 頏蜴│ヤ樌↑蜩閼筥遘
|
oddmain.cの実行例 |
$ gcc -O2 oddmain.c isodd.c -o oddmain
$ ./oddmain
整数を入力して下さい 5
1 ← isoddの返り値
$ ./oddmain
整数を入力して下さい 4
0 ← isoddの返り値
|
[注意] C言語で関数を作っておいてそれをコンパイルしてアセンブリ言語を出させる
方法では、上記の機械語だけを使ったコードはでないでしょう。
自分で考えて書くことが重要です。