C言語+GNU Assembler で詰まった話
アセンブリソースのサンプルプログラムが動かず頑張って解析?した記録。
2018/11/1 目次の追加や章立ての変更により、見やすい形に修正。
目次
実行環境
- CPU: Intel Core i5-2520M (x86_64)
- OS: Ubuntu18.04 LTS 64bit
- RAM: 8GB
- コンパイラ: gcc 7.3.0
- アセンブラ: GNU assembler 2.30
- デバッガ: GNU gdb 8.1.0
ソース
x86アセンブリ言語の学習として、『組み込みユーザのためのアセンブリ/C言語入門』2章11「C言語とのリンク」のサンプルプログラムを試していました。
サンプルの内容は、アセンブリ言語で実装した関数をC言語のソースファイルとリンクし、実行ファイルを生成するというものです。
C言語側ソースファイル (intadd_main.c) を次に示します。
scanf
で標準入力から読み取った2整数をintadd
で加算し、printf
で出力します。
// intadd_main.c #include <stdio.h> extern void intadd(int a, int b, int *c); int main() { int a, b, c; printf("Enter a,b : "); scanf("%d %d", &a, &b); intadd(a, b, &c); printf("Answer = %d\n", c); return 0; }
加算処理を行う関数intadd
はGNU Assembler (GAS) で記述します。
GASソースファイル (intadd.s) を次に示します。
.file "intadd.s" .text .align 4 .globl intadd .type intadd, @function # void intadd(int a, int b, int *c) intadd: pushl %ebp # save %ebp: it mustn't be changed # between function call/return movl %esp, %ebp movl 16(%ebp), %ebx # get address of c movl 8(%ebp), %edx # %edx = a addl 12(%ebp), %edx # %edx += b movl %edx, (%ebx) # *c = %edx movl %ebp, %esp # retrieve %ebp popl %ebp ret
コンパイルと実行
gcc-multilib
を導入し、-m32
オプションを追加して、64bit環境で32bit実行バイナリを生成・実行しています。
$ gcc -m32 intadd_main.c intadd.s -o out
プログラムを実行してみたところ、Segmentation faultによりエラーが発生しました。
$ gcc -m32 intadd_main.c intadd.s -o out $ ./out Enter a,b : 1 2 Segmentation fault (コアダンプ)
typoを疑いソースファイルを確認してみましたが、誤りはないようでした。
デバッグ・解析
エラーの原因を発見するにはデバッグする必要があります。
IDEのデバッグ機能以外使った経験はありませんでしたが、勉強も兼ねて、コマンドラインツールで解析してみることにしました。
コアダンプを使った解析
コアダンプ (core dump) は、ある時点でのメモリやレジスタの内容をそのまま記録したものです。 プログラムが異常終了した際のメモリ内容をトレースすることで、エラーの原因を解析することができます。
ulimitコマンド(システムリソースの上限を設定する)【UNIX限定】
gcc+gdbによるプログラムのデバッグ 第2回 変数の監視、バックトレース、その他のコマンド
出力
既定の設定ではコアダンプが出力されないため、まずulimit
コマンドで出力サイズの設定を変更します(ここでは出力サイズを無制限に設定)。
$ ulimit -c unlimited
コンパイルの際に-g
オプションを付加し、実行ファイルにデバッグ情報を付加する。
$ gcc -m32 -g intadd_main.c intadd.s -o out
この状態で実行ファイルを走らせると、core
などの名前でコアダンプが出力されます。
$ ./out Enter a,b : 1 2 Segmentation fault (コアダンプ) $ ls core intadd.s intadd_main.c out
デバッグ
※アドレス値はコンパイル時の状況によって変動し、必ずしも一致しません。
GNUデバッガ (GNU gdb) の引数に実行ファイルとそのコアダンプを指定すると、メモリ内容を解析することができます。
$ gdb ./out core GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git Copyright (C) 2018 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> ... Reading symbols from ./out...done. [New LWP 6785] Core was generated by `./out'. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00000000 in ?? ()
Segmentation faultで停止したときのアドレス値は0x00000000
となっています。
どうやら不正なアドレスにアクセスしているようです。
backtrace
で関数の呼び出し元へさかのぼってみると、main
内13行目が呼び出し元となっています。
(gdb) backtrace #0 0x00000000 in ?? () #1 0x5662064b in main () at intadd_main.c:13
list
でソースを確認すると、printf
を実行している箇所です。
(gdb) list 8 9 printf("Enter a,b : "); 10 scanf("%d %d", &a, &b); 11 12 intadd(a, b, &c); 13 printf("Answer = %d\n", c); 14 15 return 0; 16 }
GNU gdbでのデバッグ
静的な情報からはこれ以上原因を追えないと感じ、gdbでステップ実行しながら動作解析を行ってみることにしました。
設定
次の設定を行います。
intadd
にブレークポイントを設置- 停止した時点でのプログラムカウンタ (PC) が示す命令を表示
$ gdb ./out ... (gdb) break intadd Breakpoint 1 at 0x670: file intadd.s, line 8. (gdb) disp/i $pc 1: x/i $pc <error: No registers.>
"No registers."と表示されますが、プログラムカウンタの内容は問題なく表示されます。
デバッグ時の設定は、コマンドを記述したファイルを読み込むほうが楽でしょう。
デバッグ開始
run
で実行します。
scanf
で入力を求められるため、適当な値を入力して続行すると、ブレークポイントを設置したintadd
でプログラムが一時停止します。
Starting program: ... Enter a,b : 1 2 Breakpoint 1, intadd () at intadd.s:8 8 pushl %ebp 1: x/i $pc => 0x56555670 <intadd>: push %ebp
ここからは、エラーの発生箇所までステップ実行で進んでいき、命令を確かめていきます。
(gdb) si 10 movl %esp, %ebp 1: x/i $pc => 0x56555671 <intadd+1>: mov %esp,%ebp (gdb) si 11 movl 16(%ebp), %ebx 1: x/i $pc => 0x56555673 <intadd+3>: mov 0x10(%ebp),%ebx
発見:不正なアドレスへの分岐
ステップ実行でしばらく進めていくと、エラー箇所に到達しました。
直前の命令を確認してみると、printf
に付随した何らかの処理(内部処理?)でエラーが起きているようです。
詳細はわかりませんが、jmp
命令から不正なアドレスに分岐を試みていることが読み取れます。
... (gdb) si 0x56555646 13 printf("Answer = %d\n", c); 1: x/i $pc => 0x56555646 <main+121>: call 0x56555440 <printf@plt> (gdb) si 0x56555440 in printf@plt () 1: x/i $pc => 0x56555440 <printf@plt>: jmp *0xc(%ebx) (gdb) si 0x00000000 in ?? () 1: x/i $pc => 0x0: <error: Cannot access memory at address 0x0>
分岐先のアドレッシングにEBXレジスタ%ebx
の値を利用していることから、EBXの値に原因がありそうです。
jmp *0xc(%ebx)
EBXレジスタの追跡
もう一度最初からプログラムを実行し、EBXの値を確認してみます。
プログラム開始時点(main
の開始時点)でのEBXの値は0x56556fd0
となっています。
Starting program: ... Breakpoint 2, main () at intadd_main.c:6 6 { 1: x/i $pc => 0x565555ea <main+29>: mov %gs:0x14,%eax 2: /x $ebx = 0x56556fd0
続けて見ていくと、変化点が見つかりました。
intadd
の第3引数(変数cのアドレス値)をEBXに代入しており、ここで値が0xffffd038
に変化しています。
(gdb) si 11 movl 16(%ebp), %ebx # set address of c to %edx 1: x/i $pc => 0x56555673 <intadd+3>: mov 0x10(%ebp),%ebx 2: /x $ebx = 0x56556fd0 (gdb) si 12 movl 8(%ebp), %edx # %eax = a 1: x/i $pc => 0x56555676 <intadd+6>: mov 0x8(%ebp),%edx 2: /x $ebx = 0xffffd038
main
に復帰してもEBXの値は戻らず、intadd
の呼び出し前後で値が0x56556fd0
から0xffffd038
へ変化していることになります。
(gdb) si 0x56555440 in printf@plt () 1: x/i $pc => 0x56555440 <printf@plt>: jmp *0xc(%ebx) 2: /x $ebx = 0xffffd038 (gdb) si 0x00000000 in ?? () 1: x/i $pc => 0x0: <error: Cannot access memory at address 0x0> 2: /x $ebx = 0xffffd038
EBXの値をmain
で使用しているのであれば、この変更が原因かもしれません。
修正と確認
使用するレジスタの変更
intadd
の処理は次のようになっていました。
11 movl 16(%ebp), %ebx # get address of c to %ebx 12 movl 8(%ebp), %edx # %edx = a 13 addl 12(%ebp), %edx # %edx += b 14 movl %edx, (%ebx) # *c = %edx
EBXの内容を変更しないよう、使用するレジスタを次のように変更します。
- 第3引数(cのアドレス)の格納先:EBX→EDX
- 計算結果の格納先:EDX→EAX
11 movl 16(%ebp), %edx # set address of c to %edx 12 movl 8(%ebp), %eax # %eax = a 13 addl 12(%ebp), %eax # %eax += b 14 movl %eax, (%edx) # *c = %eax
実行
$ gcc -m32 intadd_main.c intadd.s -o out $ ./out Enter a,b : 1 2 Answer = 3
プログラムが正しく実行されました。 バグを解析し、修正できたようです。
原因:EBXレジスタの書き換え
エラーの原因は、 関数の呼び出し前後でEBXレジスタの値が変わっていた ことでした。
これは、 呼び出し規約 に違反していました。
呼び出し規約
呼び出し規約 (calling conversion) は、関数を呼び出す際の引数や返り値、スタックの扱い方に関する規約です。
オブジェクトコードやバイナリレベルで考えたときの関数呼び出し時の手続きであり、ABI (Application Binary Interface) の一部です。
次の規約が有名で、Linux環境の呼び出し規約はcdeclとなっています。
cdecl
x86ベースのシステム上のC/C++で使われることが多い規約です。
関数の呼び出し前と後で、EAX, ECX, EDXレジスタの内容が違ってもOKです。 それ以外のレジスタを変更する場合は、前の例でEBPを退避・復元したのと同じように、使用するレジスタを退避・復元する必要があります。
まとめると以下のようになります。
・引数はスタックで渡す
・func(a, b, c)なら引数はc, b, aの順にスタックに積まれる
・戻り値はEAXで受け取る
・引数の後始末は呼び出した側でする
・呼び出し前後で、EAX, ECX, EDXレジスタの値を変えていい
EBXレジスタは関数内で使用しないか、使用したあと呼び出し前の値に戻す必要がある 、ということです。
サンプルプログラムの誤りか否か
ここまでわかったところで、 自分のtypoではないか? と再度ソースコードを確認しましたが、書籍中のものと違いはありませんでした。
これ以降に掲載されているアセンブリソースでも、関数内でEBXを変更しcdeclに違反している点が気にかかります。
他に原因となっていそうなのは、実行環境の差です。
- CPUアーキテクチャが異なっている。書籍中はx86 (32bit) アーキテクチャ、自分の実行環境はx86_64 (64bit) アーキテクチャ
- コンパイラ・アセンブラのバージョンによる差。ツールのバージョンによって生成されるアセンブリ命令に差があることは、書籍中でも言及されている
残念ながら、現状の知識では、何に誤りがあったのか突き止めることはできませんでした。
まとめ
サンプルプログラムをツールでデバッグし修正するとともに、エラーの原因が呼び出し規約の違反であることを理解できました。
デバッグの練習としては良い経験でしたが、サンプルが誤っていたのか、自分の環境が問題であったのかはわかりませんでした。
なぜダメだったのかわかる日が、いつか来て欲しいと思います。