気が向いたら書くやつ

気が向いたら何か書きます

C言語+GNU Assembler で詰まった話

アセンブリソースのサンプルプログラムが動かず頑張って解析?した記録。

2018/11/1 目次の追加や章立ての変更により、見やすい形に修正。

目次

実行環境

ソース

x86アセンブリ言語の学習として、『組み込みユーザのためのアセンブリ/C言語入門』2章11「C言語とのリンク」のサンプルプログラムを試していました。

www.ohmsha.co.jp

サンプルの内容は、アセンブリ言語で実装した関数を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;
}

加算処理を行う関数intaddGNU 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) は、ある時点でのメモリやレジスタの内容をそのまま記録したものです。 プログラムが異常終了した際のメモリ内容をトレースすることで、エラーの原因を解析することができます。

rabbitfoot141.hatenablog.com

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レジスタの値を変えていい

x86アセンブリ言語での関数コール

EBXレジスタは関数内で使用しないか、使用したあと呼び出し前の値に戻す必要がある 、ということです。

サンプルプログラムの誤りか否か

ここまでわかったところで、 自分のtypoではないか? と再度ソースコードを確認しましたが、書籍中のものと違いはありませんでした。

これ以降に掲載されているアセンブリソースでも、関数内でEBXを変更しcdeclに違反している点が気にかかります。

他に原因となっていそうなのは、実行環境の差です。

残念ながら、現状の知識では、何に誤りがあったのか突き止めることはできませんでした。

まとめ

サンプルプログラムをツールでデバッグし修正するとともに、エラーの原因が呼び出し規約の違反であることを理解できました。

デバッグの練習としては良い経験でしたが、サンプルが誤っていたのか、自分の環境が問題であったのかはわかりませんでした。

なぜダメだったのかわかる日が、いつか来て欲しいと思います。