FromNandの日記

自分的備忘録

【main関数の前の世界】スタートアップ関数を自作してみた!

はじめに

main関数の前にはスタートアップ関数という物があり、スタートアップ関数ではアプリケーションの実行のための準備がされている。
かといって、スタートアップ関数は特別なプログラムではなく、実際にアプリケーションプログラマが自作することも可能である。
しかし、スタートアップ関数では決まった処理をすることが多く、手間がかかるプログラムなので、ライブラリ提供者がライブラリとして配布してくれている。
今回はmain関数のアセンブラを覗くことでargc・argv・envpの扱いについて見ていこうと思う。


参考程度に次の記事も参照してほしい。
fromnand.hatenadiary.jp


システムがスタートアップ関数を呼び出すまでの流れ

さて、まずプログラムがどのように実行開始するのかを見てみよう。
多くの場合はOSがfork系システムコール→exec系システムコールという処理を行うことでプログラムの実行を開始すると思う。
このexec系システムコールの内部でargc・argv・envpがスタックに積まれたあと、_startといったスタートアップ関数に処理が渡される。
これらの一連の処理の中で、レジスタの設定やメモリ空間の割当など、重要な準備が数多く行われている。

スタートアップ関数がmain関数を呼び出すまでの流れ

argv・argc・envpはシステム側によって用意されるが、それをmain関数に引き渡すのはスタートアップ関数の役目だ。
今回は次のようなスタートアップ関数を作成した。
長いプログラムに見えるが、argc・argv・envpについて関連している部分のみを抜粋すると、注目するべき点はそんなに多くはない。

.code32

.extern main
.extern _sctors, _ectors, _sdtors, _edtors

.text

_start:                             # この関数は常にファイルの最初に書かなければならない (正確には.textのオフセット0に置かねばならない)
    xorl %ebp, %ebp                 # ebpをクリアしておく
    call _constructors              # コンストラクタたちを呼び出す処理 (ただ、引数がvoidの関数しか呼べない...)
    popl %eax                       # スタックの頭にはargcが入っている
    movl %esp, %ecx                 # その次にはargv[0], argv[1]...が入っている
    leal 4(%ecx, %eax, 4), %edx     # argv + argc * (4 + 1)番地から環境変数のリストが入っている
    andl $0xfffffff0, %esp          # アライメントを16byteの境界に揃える
    pushl %edx                      # &envp[0]を積む
    pushl %ecx                      # &argv[0]を積む
    pushl %eax                      # argcを積む
    call main                       # mainを呼び出す
    pushl %eax                      # mainからの戻り値をセーブする (eax, ecx, edxは関数呼び出しで書き換えられる可能性があるため)
    call _destructors               # デストラクタたちを呼び出す処理 (コンストラクタ同様に引数がvoidの関数しか呼べないが...)
    popl %eax                       # mainからの戻り値をポップする
    movl %eax, %ebx                 # mainの戻り値をexitの引数に取る (こいつがシステムへの返り値になる。 echo $? で表示できる。)
    movl $1, %eax                   # exitのシステムコール番号
    int $0x80                       # exitの呼び出し (これがないとプログラムが適切に終了しない)
    hlt                             # ここは訪れないはず...

_constructors:
    movl $_sctors, %eax
    movl $_ectors, %ecx
_constructors_loop:
    cmpl %eax, %ecx
    jbe _constructors_end
    pushl %eax
    pushl %ecx
    call *(%eax)
    popl %ecx
    popl %eax
    addl $4, %eax
    jmp _constructors_loop
_constructors_end:
    ret

_destructors:
    movl $_sdtors, %eax
    movl $_edtors, %ecx
_destructors_loop:
    cmpl %eax, %ecx
    jbe _destructors_end
    pushl %eax
    pushl %ecx
    call *(%eax)
    popl %ecx
    popl %eax
    addl $4, %eax
    jmp _destructors_loop
_destructors_end:
    ret


まず、注目するべきなのは次の三行だ。

    popl %eax                       # スタックの頭にはargcが入っている
    movl %esp, %ecx                 # その次にはargv[0], argv[1]...が入っている
    leal 4(%ecx, %eax, 4), %edx     # argv + argc * (4 + 1)番地から環境変数のリストが入っている


実はシステムからスタートアップ関数に処理を移す際、スタックのトップにはargcが、そしてその直後にargvが,、さらにその直後にはenvpが積まれている。
argvやenvpは「char**」なので、「char*」の配列としてスタックに積まれている配列の先頭要素を指すポインタ変数(4byte)である。
上記の三行では、スタックのトップに積まれているargcを「popl %eax」で受け取ったあと、その直後のargvの先頭アドレスも「movl %esp, %ecx」で受け取っている。
最後の行では環境変数もedxに受け取るようにしてある。


そして、次に注目すべきなのは次の四行である。
この四行では、argv・argc・envpをスタックに積んで「call main」とすることでmain関数にargv・argc・envpを渡している
main関数が呼び出されたので、これでスタートアップ関数の役割は終了となる。(実際の環境では_startのあとに_libc_start_mainなどが呼ばれていたりもするが...)

    pushl %edx                      # &envp[0]を積む
    pushl %ecx                      # &argv[0]を積む
    pushl %eax                      # argcを積む
    call main                       # mainを呼び出す

main関数でargc・argv・envpを受け取って利用してみる

次はmain関数で実際にargc・argv・envpを利用してみよう。
今回は次のようなプログラムを使用した。

#include <stdio.h>

int main(int argc, char *argv[], char *envp[]){
    int i = 0;

    /*  argc の確認 */
    printf("argc = %d\n", argc);

    /* argv の確認 */
    printf("argv = [");
    for (i = 0; i < argc; i++)
    {
        printf("\'%s\' ", argv[i]);
    }
    printf("]\n");

    /* envp の確認 */
    printf("envp = [");
    for (i = 0; envp[i]; i++)
    {
        printf("\'%s\' ", envp[i]);
    }
    printf("]\n");

    return 0;
}


このプログラムに次のようにコマンドラインを与えると、

./test abc def ghi


実行結果はこの様になる。
確かにうまく3つの引数を受け取っているようだ。

shiguren@ubuntu:~/デスクトップ/TEST$ ./arg abc def ghi
argc = 4
argv = ['./arg' 'abc' 'def' 'ghi' ]
envp = ['CLUTTER_IM_MODULE=xim' 'LS_COLORS=rs=0:di=01;34:ln=01;36:mh=00:pi=40 ・・・・・・長過ぎるので省略 ]

main関数を逆アセンブルして、argc・argv・envpの部分を解析する

最後に先程のCプログラムを逆アセンブルしてみる。
至るところにコメントを書いているので、それを参考に解析してもらいたい。(最後の方はすこしずつ雑になっているけれど...)

 # 呼び出し規約はcdecl

000054d <main>:

 # 今、espはmainからのリターンアドレスが格納されているアドレスを指している。
 # esp+4から上のアドレスにはargc・argv・envpが順にpushされている
 # esp+4の値をecxに格納。
 54d:   8d 4c 24 04             lea    0x4(%esp),%ecx

 # アライメントを16byteの境界に合わせている?
 551:   83 e4 f0                and    $0xfffffff0,%esp

 # アライメントを調節して、今のespがリターンアドレスを指しているのかが定かではなくなったので
 # リターンアドレスを今のスタックの先頭にpush(コピーといったほうがいい?)する
 554:   ff 71 fc                pushl  -0x4(%ecx)

 # 新しいスタックフレームを作成する常套処理
 557:   55                      push   %ebp
 558:   89 e5                   mov    %esp,%ebp

 # eax、ecx、edx以外のレジスタを関数内で変更する場合は
 # 関数のはじめにpushしておく
 55a:   56                      push   %esi
 55b:   53                      push   %ebx
 55c:   51                      push   %ecx

 # ローカル変数の領域をまとめて確保
 55d:   83 ec 1c                sub    $0x1c,%esp

 # x86(32bit)におけるPIC(Position Independent Code)を実現するための関数らしい
 560:   e8 eb fe ff ff          call   450 <__x86.get_pc_thunk.bx>
 565:   81 c3 6f 1a 00 00       add    $0x1a6f,%ebx

 # ecxは今、argc・argv・envpの先頭アドレスを指している
 # その値をesiにもコピー
 56b:   89 ce                   mov    %ecx,%esi

 # i = 0 (iのアドレスはebp-0x1c)
 56d:   c7 45 e4 00 00 00 00    movl   $0x0,-0x1c(%ebp)

 # なんかよくわからん
 574:   83 ec 08                sub    $0x8,%esp

 # esiは今、argcの格納されているアドレスを指している
 # push argc
 577:   ff 36                   pushl  (%esi)

 # ebx-0x18f4の部分は「argc = %d\n」という文字列のアドレス
 # そのアドレスをeaxに格納
 579:   8d 83 0c e7 ff ff       lea    -0x18f4(%ebx),%eax

 # 文字列のアドレスをpushして、printfを呼び出す
 57f:   50                      push   %eax
 580:   e8 4b fe ff ff          call   3d0 <printf@plt>

 # cdeclの規約に従い、関数呼び出し後にespの値を調節する
 585:   83 c4 10                add    $0x10,%esp
 588:   83 ec 0c                sub    $0xc,%esp

 # ebx-0x18e9には「argv = [」の文字列のアドレス
 58b:   8d 83 17 e7 ff ff       lea    -0x18e9(%ebx),%eax

 # 文字列のアドレスをpushしたあと、printfの呼び出し
 591:   50                      push   %eax
 592:   e8 39 fe ff ff          call   3d0 <printf@plt>

 # cdeclの規約に従い、espの値を調節する
 597:   83 c4 10                add    $0x10,%esp

 # ebp-0x1c、つまりiに0を代入する
 # これは「for(i = 0; i < argc; i++)」の中の「i = 0」の部分
 59a:   c7 45 e4 00 00 00 00    movl   $0x0,-0x1c(%ebp)

 # for文の内部に侵入する際の常套処理
 # 5cb番地にジャンプする
 5a1:   eb 28                   jmp    5cb <main+0x7e>

 # もし「i < argc」ならば、この部分の処理を実行する
 # ebp-0x1c、つまりiの値をeaxにコピーする
 5a3:   8b 45 e4                mov    -0x1c(%ebp),%eax

 # argvのデータ型は「char**」でサイズは4byte
 # この部分はargvの先頭からのアドレスのオフセットを計算している
 # 意味はedxにeax * 4の値をコピー
 # eaxにはiの値が入っているので、このedxにargvの先頭アドレスを足せば「&argv[i]」の意味になる
 5a6:   8d 14 85 00 00 00 00    lea    0x0(,%eax,4),%edx

 # esiはargc・argv・envpの先頭アドレスを指していて
 # argcのサイズは4byteなので、esi+4はargvを指している
 # よって、このコードはeaxにargvの先頭アドレスをコピー
 5ad:   8b 46 04                mov    0x4(%esi),%eax

 # edxにオフセット、eaxはargvの先頭アドレス
 # つまり、argv[i]のアドレスをeaxに格納
 5b0:   01 d0                   add    %edx,%eax

 # argv[i]の値をeaxにコピー
 5b2:   8b 00                   mov    (%eax),%eax

 # 毎度、よくわからない
 5b4:   83 ec 08                sub    $0x8,%esp

 # argv[i]の値をpush
 5b7:   50                      push   %eax

 # 「"\'%s\' "」という文字列をpushし、printfを呼び出す
 5b8:   8d 83 20 e7 ff ff       lea    -0x18e0(%ebx),%eax
 5be:   50                      push   %eax
 5bf:   e8 0c fe ff ff          call   3d0 <printf@plt>
 5c4:   83 c4 10                add    $0x10,%esp

 # i++
 5c7:   83 45 e4 01             addl   $0x1,-0x1c(%ebp)

 # ebp-0x1c、つまりiの値をeaxにコピー
 5cb:   8b 45 e4                mov    -0x1c(%ebp),%eax

 # esiは今、argcのアドレスを指しているので
 # これは「cmp argc, i」の意味
 5ce:   3b 06                   cmp    (%esi),%eax

 # jlは符号ありジャンプ命令の一つ。
 # この場合、右側の値(eax, i)が左の値((esi), argc)よりも小さい場合にジャンプ
 # とどのつまり、「for(i = 0; i < argc; i++)」の「i < argc」の部分の処理
 5d0:   7c d1                   jl     5a3 <main+0x56>

 # なんだかよくわからない
 5d2:   83 ec 0c                sub    $0xc,%esp

 # 「]\n」という文字列のアドレスをeaxにコピーしてpush
 5d5:   8d 83 26 e7 ff ff       lea    -0x18da(%ebx),%eax
 5db:   50                      push   %eax

 # printfの呼び出し
 5dc:   e8 ff fd ff ff          call   3e0 <puts@plt>
 5e1:   83 c4 10                add    $0x10,%esp
 5e4:   83 ec 0c                sub    $0xc,%esp

 # 「envp = [」という文字列のアドレスをeaxにコピーしてpush
 5e7:   8d 83 28 e7 ff ff       lea    -0x18d8(%ebx),%eax
 5ed:   50                      push   %eax

 # printfの呼び出し
 5ee:   e8 dd fd ff ff          call   3d0 <printf@plt>
 5f3:   83 c4 10                add    $0x10,%esp

 # i = 0
 5f6:   c7 45 e4 00 00 00 00    movl   $0x0,-0x1c(%ebp)

 # for文の内部に侵入する
 5fd:   eb 28                   jmp    627 <main+0xda>

 #
 5ff:   8b 45 e4                mov    -0x1c(%ebp),%eax
 602:   8d 14 85 00 00 00 00    lea    0x0(,%eax,4),%edx
 609:   8b 46 08                mov    0x8(%esi),%eax
 60c:   01 d0                   add    %edx,%eax
 60e:   8b 00                   mov    (%eax),%eax
 610:   83 ec 08                sub    $0x8,%esp
 613:   50                      push   %eax
 614:   8d 83 20 e7 ff ff       lea    -0x18e0(%ebx),%eax
 61a:   50                      push   %eax
 61b:   e8 b0 fd ff ff          call   3d0 <printf@plt>
 620:   83 c4 10                add    $0x10,%esp
 623:   83 45 e4 01             addl   $0x1,-0x1c(%ebp)

 # eaxにiの値をコピー
 627:   8b 45 e4                mov    -0x1c(%ebp),%eax

 # envpの型は「char**」なので、envpの指す型のサイズは「sizeof(char*) = 4」
 # envpのオフセットにはi * sizeof(char*)を使用するので、それをedxにコピー
 62a:   8d 14 85 00 00 00 00    lea    0x0(,%eax,4),%edx

 # envpのアドレスをeaxにコピー
 631:   8b 46 08                mov    0x8(%esi),%eax

 # envp + i * sizeof(char*)
 634:   01 d0                   add    %edx,%eax

 # envp[i]をeaxにコピー
 636:   8b 00                   mov    (%eax),%eax

 # envp[i]が0であるかどうかの判定
 638:   85 c0                   test   %eax,%eax

 # 0以外だった場合は5ff番地にジャンプ
 63a:   75 c3                   jne    5ff <main+0xb2>

 # よくわからない
 63c:   83 ec 0c                sub    $0xc,%esp

 # 「]\n」という文字列のアドレスをpush
 63f:   8d 83 26 e7 ff ff       lea    -0x18da(%ebx),%eax
 645:   50                      push   %eax

 # putsの呼び出し
 646:   e8 95 fd ff ff          call   3e0 <puts@plt>
 64b:   83 c4 10                add    $0x10,%esp

 # x86では関数の戻り値にはeaxを使用するので
 # eaxに0をコピー
 64e:   b8 00 00 00 00          mov    $0x0,%eax

 # 退避したレジスタを復旧する
 653:   8d 65 f4                lea    -0xc(%ebp),%esp
 656:   59                      pop    %ecx
 657:   5b                      pop    %ebx
 658:   5e                      pop    %esi
 659:   5d                      pop    %ebp

 # リターンアドレスが入っているアドレスにespを向ける
 65a:   8d 61 fc                lea    -0x4(%ecx),%esp

 # return
 65d:   c3                      ret   

 # アライメントのための無駄な命令 
 65e:   66 90                   xchg   %ax,%ax


結構時間がかかってしまったが、やりがいのある解析だった。
argvやenvpがポインタ(4byte)として渡されているところがはっきりわかって面白かったと思う。