/ «2008-03-05 (Wed) ^ 2008-03-11 (Tue)» ?
   西田 亙の本:GNU 開発ツール -- hello.c から a.out が誕生するまで --

Categories Books | Hard | Hardware | Linux | MCU | Misc | Publish | Radio | Repository | Thoughts | Time | UNIX | Writing | プロフィール


2008-03-09 (Sun)

[Thoughts] プログラマの教養は manual pages に宿る (その5)

今回は、いよいよ GNU C コンパイラの登場です。一般書ではほとんど触れられることのない、インクルード処理の意義を再考してみましょう。

はじまりは hello.c

最初のソースファイルは、意図的に #include <stdio.h> を削除した hello1.c です。

 int main() {
       printf("Hello, world!\n");
       return 123;
 }

main 関数の中に、printf 関数および return 文を記述しただけの小さなプログラムです。通常は return 文にゼロが添えられていますが、ステータスコードの意味を強調するために 123 を指定しています(UNIX の慣習としてプロセスは正常終了の時0、異常終了の時1を返す)。

main 関数の戻り値は、プロセス終了時に exit システムコールを通じて、親プロセスであるシェルに渡されますが、bash では直前に実行したプロセスの終了ステータスを特殊シェル変数 $? で参照できます。

ちなみに、調子に乗って return 文に 456 を指定するとどうなるでしょうか?実際に試してみると分かりますが、結果は 200 が表示されます。この理由は、exit ライブラリ関数の man page (man 3 exit で参照可能。セクション番号を指定せず man exit を実行するとセクション2のシステムコールが表示されるため注意) に書かれています。

 EXIT(3)                    Linux Programmer’s Manual                    EXIT(3)
 
 NAME
        exit - cause normal process termination
 
 SYNOPSIS
        #include <stdlib.h>
 
        void exit(int status);
 
 DESCRIPTION
        The  exit()  function causes normal process termination and the value of
        status & 0377 is returned to the parent (see wait(2)).

最終行に注目して下さい。exit ライブラリ関数は、ステータスコードと 0377 (10進数で255)のAND演算を行った値、すなわち「最下位8ビット」のみを親プロセスに返すと書かれています。10進数の 456 は16進数表現では 0x1C8 になり、下位8ビットを AND 演算で切り出すと 0xC8、よって10進数表記で 200 となる訳です。

隠されたビルトイン関数

それでは、gcc を使った自動ビルドにより、hello1.c から実行可能プログラムを生成してみます(gcc ドライバにより、プリプロセス・コンパイル・アセンブル・リンクが自動実行される: 4つの処理の詳細についてはGNU 開発ツールを参照)。gcc ドライバもまた、デフォルトのファイル出力名は a.out になっているため、-o (Output file name) オプションで hello1 を指定します。

 $ gcc -o hello1 hello1.c ; echo $?
 hello1.c: In function ‘main’:
 hello1.c:2: warning: incompatible implicit declaration of built-in function ‘printf’
 0
 $

終了ステータスがゼロであることから、ビルド作業自体は正常に終了したようですが、途中で警告メッセージが表示されています(警告はエラーとみなされない)。ひとまず、出来上がった実行可能ファイルをチェックしてみましょう。

 $ wc -c hello1
 8973 hello1
 $ ./hello1 ; echo $?
 Hello, world!
 123
 $

hello1 のファイルサイズは8973バイト(環境によってファイルサイズは異なる)であり、前回のアセンブリ言語版 hello と同じくメッセージを表示し、終了ステータスとして123を返しています。警告による実行コードへの影響はなさそうです。

次に、main 関数のコンパイル中にCコンパイラが発した incompatible implicit declaration of built-in function ‘printf’ という警告メッセージですが、ユーザに誤解を招きやすい表現になっており、先頭の incompatible は無視して構いません。

implicit declaration of 'printf' は「ソースファイル中に printf 関数の宣言が存在しない」という意味ですが、警告メッセージ中で printf 関数に添えられている "built-in" は一体何を意味しているのでしょうか?

実は、GNU C コンパイラ (この場合、GCC という表記は不正確)は、Cライブラリ中の関数の一部を「ビルトイン関数」として内部に実装しているのです。ビルトイン関数に関しては、"Using the GNU Compiler Collection" の第5章 Extensions to the C Language Family 中で、Other built-in functions provided by GCC として紹介されていますが、ほとんどの関数はリストが羅列されているだけであり、内部でどのような実装になっているのかは分かりません(本マニュアルの第5章には GNU C コンパイラの拡張機能が記されており必読)。

そこで、GNU C コンパイラが printf 関数を内部でどのように処理しているのか、コンパイル後のアセンブリソースで確認してみます。gcc ドライバに対して、コンパイル直後(アセンブリソース出力後)に処理を停止させる -S オプションを指定します。

 $ gcc -S hello1.c
 hello1.c: In function ‘main’:
 hello1.c:2: warning: incompatible implicit declaration of built-in function ‘printf’
 $ cat hello1.s
         .file   "hello1.c"
         .section        .rodata
 .LC0:
         .string "Hello, world!"
         .text
 .globl main
         .type   main, @function
 main:
 .LFB2:
         pushq   %rbp
 .LCFI0:
         movq    %rsp, %rbp
 .LCFI1:
         movl    $.LC0, %edi
         call    puts
         movl    $123, %eax
         leave
         ret
 ...

コンパイラが出力したアセンブリソースは、拡張子を .c から .s に変えたファイル名で保存されています。内容を見ますと(後半部分は省略)、先頭の .rodata セクション (読み込みのみ可能, 書き込み不能なセクション)に Hello, world! の ASCII コードが格納され(ソース中の行末コードが削除されている点に注目)、続く .text セクションに main 関数のアセンブリコードが記述されています。

下から4行目の call puts が、printf 関数の呼び出しに相当する部分です。GNU C コンパイラは、printf 関数の引数がフォーマット文字列だけであったことから、より軽く速い実装である puts 関数へ "自己判断で" 書き換えているのです。

この自動置換結果は、アセンブル後に出力されるオブジェクトファイル中のシンボルテーブルをチェックすることでも確認できます。gcc ドライバに、アセンブル直後でビルドを停止させるためには、-c (Compile) オプションを指定します。

 $ gcc -c hello1.c
 hello1.c: In function ‘main’:
 hello1.c:2: warning: incompatible implicit declaration of built-in function ‘printf’
 $ wc -c hello1.o
 1488 hello1.o

アセンブル終了後には、拡張子 .o を持った hello.o オブジェクトファイルが生成されています。binutils ユーティリティのひとつである nm (symbol NaMes) コマンドを使い、内部のシンボルテーブルを表示してみましょう。

 $ nm hello1.o
 0000000000000000 T main
                  U puts

hello1.s からアセンブルされた hello1.o オブジェクトファイル中では、”main" が定義され (T は .text セクションの略)、puts が未定義(U は Undefined の略)の状態で含まれています。この結果は「puts シンボルが参照されているが、その実体は hello1.o 内部中では定義されていない」ことを意味しており、hello1.o は puts を "外部参照" していることが分かります(外部参照先はCライブラリ)。

以上をまとめますと、GNU C コンパイラは printf("Hello, world!\n") の一文を自己判断で puts("Hello, world!") に置き換えたことになります(puts 関数は行末コードを付加する)。使用関数を変更しただけでなく、行末コードまで対応させている点には驚かされますが、このようなビルトイン関数の勝手な振る舞いは、GNU リファレンスマニュアル中に記載されていません。時と場合によっては、この「お節介」が厄介な問題を引き起こす可能性があり、特に自前のライブラリを使用する機会が多い組み込み開発の現場では注意が必要です。

ビルトイン関数の振る舞いが問題となる場合は、GNU C コンパイラに用意されている -fno-builtin オプションを活用しましょう。プログラマが printf と記述すれば、その通りにアセンブリソースへ出力されるようになります。

 $ gcc -fno-builtin -S hello1.c
 $ cat hello1.s
         .file   "hello1.c"
         .section        .rodata
 .LC0:
         .string "Hello, world!\n"
         .text
 .globl main
         .type   main, @function
 main:
 .LFB2:
         pushq   %rbp
 .LCFI0:
         movq    %rsp, %rbp
 .LCFI1:
         movl    $.LC0, %edi
         movl    $0, %eax
         call    printf
         movl    $123, %eax
         leave
         ret
 ...

-fno-builtin オプション付きでコンパイルされたアセンブリソース中では、私達の意図通り printf 関数が呼び出されており、メッセージの最終尾には記述通り行末コードが添付されています。ソースリストには間違いがないはずなのに、不可解な問題が出現する場合は、-O0 オプションによりコンパイラの最適化を抑止し、-fno-builtin オプションでビルトイン関数の使用を禁止することをお勧めします。

警告無視の危険性

"implicit declaration of function" を始めとする GNU C コンパイラが出力する警告を無視して作業を進めると、どのような事態が待ち受けているのでしょうか。ひとつの例として、hello1.c 中の double quote を single quote に打ち間違えた hello_segfault.c で実験してみます。

 int main() {
       printf('Hello, world!\n');
       return 123;
 }

C言語初心者ではありがちな間違いですが、このプログラムを gcc ドライバでビルドすると、次のような警告メッセージが表示されます。

 $ gcc -o hello_segfault hello_segfault.c ; echo $?
 hello_segfault.c: In function ‘main’:
 hello_segfault.c:2: warning: incompatible implicit declaration of built-in function ‘printf’
 hello_segfault.c:2:9: warning: character constant too long for its type
 hello_segfault.c:2: warning: passing argument 1 of ‘printf’ makes pointer from integer without a cast
 0

最初は、先ほどと同じく「printf 宣言の欠如」であり、2番目は「文字定数が長すぎる」という警告、3番目は「printf 関数への第一引数がポインタではなく、整数値になっている」という警告です。これらの警告を無視して出来上がった、hello_segfault を実行してみましょう。

 $ ./hello_segfault 
 Segmentation fault

"Segmentation fault" (通称 segfault)と呼ばれる致命的エラーにより、プロセスは異常終了しています。

segfault の正体

通常 segmentation fault は、許可されていないメモリ領域へプロセスがアクセスを試みた際に「メモリ保護違反」として発生します。今回の hello_segfault で何が起きたのか、その原因をアセンブリソースで解析してみます。

 $ gcc -S hello_segfault.c
 hello_segfault.c: In function ‘main’:
 hello_segfault.c:2: warning: incompatible implicit declaration of built-in function ‘printf’
 hello_segfault.c:2:9: warning: character constant too long for its type
 hello_segfault.c:2: warning: passing argument 1 of ‘printf’ makes pointer from integer without a cast
 $ cat hello_segfault.s
         .file   "hello_segfault.c"
         .text
 .globl main
         .type   main, @function
 main:
 .LFB2:
         pushq   %rbp
 .LCFI0:
         movq    %rsp, %rbp
 .LCFI1:
         movl    $1818501386, %edi
         movl    $0, %eax
         call    printf
         movl    $123, %eax
         leave
         ret

原因は、printf 関数の第一引数をセットしている "movl $1818501386, %edi" にありそうです(デスティネーションオペランドは環境によって異なる: 例 32ビット環境では movl $1818501386, (%esp))。

1818501386 というマジックナンバーの正体は、16進数表記 0x6C64210A にすると明らかになります。 これらを ASCII コードとして解釈すると、0x6C は小文字のl、0x64 は小文字のd、0x21は!、0x0A は行末コードですから、GNU C コンパイラは Hello, world!\n の最終4文字を整数値に変換したことが分かります。

スタック上、もしくはレジスタにセットされた printf 関数の第一引数は、GNU C ライブラリの printf 関数に引き渡され、同関数は内部で 0x6C64210A 番地以降に格納されている文字列データを参照しようとします。しかし、ここで保護例外が発生し、segfault により強制終了させられたのです。

Linux メモリーマップ

それでは、各プロセスに許可されているメモリ領域はどこに配置されているのでしょうか?Linux の場合、/proc ディレクトリを通して、起動済みプロセスのメモリーマップを参照できますから、カーネル起動後最初に実行される /sbin/init プロセスのマップを参考にしてみましょう。

/proc ディレクトリには、下記のように整数値をファイル名とするディレクトリ群が存在しますが、これらはその値を PID (Process ID)とするプロセスの情報を収めています。

 $ ls -F /proc
 1/     17/    2341/  2792/  817/       cpuinfo      kcore       swaps
 10/    1798/  2428/  2794/  818/       crypto       key-users   sys/
 11/    18/    2694/  2795/  819/       devices      kmsg        sysrq-trigger
 1126/  19/    2711/  3/     820/       diskstats    loadavg     sysvipc/
 1127/  197/   2721/  4/     821/       dma          locks       timer_list
 1128/  198/   2728/  41/    9/         driver/      meminfo     timer_stats
 1129/  199/   2747/  42/    909/       execdomains  misc        tty/
 12/    2/     2763/  43/    910/       fb           modules     uptime
 13/    200/   2765/  44/    929/       filesystems  mounts@     version
 1311/  201/   2767/  45/    930/       fs/          mtrr        vmstat
 14/    202/   2768/  46/    acpi/      ide/         net/        zoneinfo
 15/    203/   2769/  4620/  asound/    interrupts   partitions
 151/   2080/  2770/  5/     buddyinfo  iomem        scsi/
 154/   2119/  2783/  6/     bus/       ioports      self@
 156/   2324/  2785/  7/     cmdline    irq/         slabinfo
 16/    2331/  2786/  8/     config.gz  kallsyms     stat

/sbin/init のプロセス番号は必ず1となりますので、/proc/1 ディレクトリ配下に同プロセスの情報が記録されています。

 $ sudo ls -l /proc/1
 total 0
 dr-xr-xr-x 2 root root 0 2008-03-09 16:05 attr
 -r-------- 1 root root 0 2008-03-09 16:05 auxv
 --w------- 1 root root 0 2008-03-09 16:05 clear_refs
 -r--r--r-- 1 root root 0 2008-03-09 21:01 cmdline
 -r--r--r-- 1 root root 0 2008-03-09 16:05 cpuset
 lrwxrwxrwx 1 root root 0 2008-03-09 16:05 cwd -> /
 -r-------- 1 root root 0 2008-03-09 16:05 environ
 lrwxrwxrwx 1 root root 0 2008-03-09 12:02 exe -> /sbin/init
 dr-x------ 2 root root 0 2008-03-09 16:05 fd
 dr-x------ 2 root root 0 2008-03-09 16:05 fdinfo
 -r--r--r-- 1 root root 0 2008-03-09 16:05 maps
 -rw------- 1 root root 0 2008-03-09 16:05 mem
 -r--r--r-- 1 root root 0 2008-03-09 16:05 mounts
 -r-------- 1 root root 0 2008-03-09 16:05 mountstats
 -r--r--r-- 1 root root 0 2008-03-09 16:05 numa_maps
 -rw-r--r-- 1 root root 0 2008-03-09 16:05 oom_adj
 -r--r--r-- 1 root root 0 2008-03-09 16:05 oom_score
 lrwxrwxrwx 1 root root 0 2008-03-09 16:05 root -> /
 -r--r--r-- 1 root root 0 2008-03-09 16:05 smaps
 -r--r--r-- 1 root root 0 2008-03-09 21:01 stat
 -r--r--r-- 1 root root 0 2008-03-09 16:05 statm
 -r--r--r-- 1 root root 0 2008-03-09 21:01 status
 dr-xr-xr-x 3 root root 0 2008-03-09 16:05 task
 -r--r--r-- 1 root root 0 2008-03-09 16:05 wchan

この中に含まれる maps が、目的のメモリーマップ情報をテキスト形式で出力するファイルです。

 $ cat /proc/1/maps
 00400000-00408000 r-xp 00000000 08:03 1660                               /sbin/init
 00608000-00609000 rw-p 00008000 08:03 1660                               /sbin/init
 00609000-0062a000 rw-p 00609000 00:00 0                                  [heap]
 2b32243ad000-2b32243ca000 r-xp 00000000 08:03 16985                      /lib/ld-2.7.so
 2b32243ca000-2b32243cd000 rw-p 2b32243ca000 00:00 0 
 2b32245c9000-2b32245cb000 rw-p 0001c000 08:03 16985                      /lib/ld-2.7.so
 2b32245cb000-2b3224602000 r-xp 00000000 08:03 1645                       /lib/libsepol.so.1
 2b3224602000-2b3224802000 ---p 00037000 08:03 1645                       /lib/libsepol.so.1
 2b3224802000-2b3224803000 rw-p 00037000 08:03 1645                       /lib/libsepol.so.1
 2b3224803000-2b3224819000 r-xp 00000000 08:03 2017                       /lib/libselinux.so.1
 2b3224819000-2b3224a18000 ---p 00016000 08:03 2017                       /lib/libselinux.so.1
 2b3224a18000-2b3224a1a000 rw-p 00015000 08:03 2017                       /lib/libselinux.so.1
 2b3224a1a000-2b3224a1b000 rw-p 2b3224a1a000 00:00 0 
 2b3224a1b000-2b3224b6f000 r-xp 00000000 08:03 17281                      /lib/libc-2.7.so
 2b3224b6f000-2b3224d6f000 ---p 00154000 08:03 17281                      /lib/libc-2.7.so
 2b3224d6f000-2b3224d72000 r--p 00154000 08:03 17281                      /lib/libc-2.7.so
 2b3224d72000-2b3224d74000 rw-p 00157000 08:03 17281                      /lib/libc-2.7.so
 2b3224d74000-2b3224d79000 rw-p 2b3224d74000 00:00 0 
 2b3224d79000-2b3224d7b000 r-xp 00000000 08:03 17305                      /lib/libdl-2.7.so
 2b3224d7b000-2b3224f7b000 ---p 00002000 08:03 17305                      /lib/libdl-2.7.so
 2b3224f7b000-2b3224f7d000 rw-p 00002000 08:03 17305                      /lib/libdl-2.7.so
 2b3224f7d000-2b3224f7f000 rw-p 2b3224f7d000 00:00 0 
 7fff866e7000-7fff866fd000 rw-p 7fff866e7000 00:00 0                      [stack]
 ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0                  [vdso]

これは、64ビット環境での結果ですが、0x00400000 番地から始まる不連続の領域群が定義されています。なぜ、先頭がゼロ番地から始まらないのか、その理由は後で明らかになります。

アクセスフラグに注目すると、先頭の領域は r-xp ですから書き込み不可となっており、実行コードが格納されています。続く 0x00608000 番地からのフラグは rw-p になっており、実行は不可であるけれども、読み書きが可能なデータ格納領域として機能しています。

参考までに、32ビット環境のメモリーマップも示しておきます。

 $ cat /proc/1/maps 
 08048000-08050000 r-xp 00000000 08:03 114031     /sbin/init
 08050000-08051000 rwxp 00007000 08:03 114031     /sbin/init
 08051000-08072000 rwxp 08051000 00:00 0          [heap]
 b7dee000-b7def000 rwxp b7dee000 00:00 0 
 b7def000-b7df1000 r-xp 00000000 08:03 1189681    /lib/tls/i686/cmov/libdl-2.3.6.so
 b7df1000-b7df3000 rwxp 00001000 08:03 1189681    /lib/tls/i686/cmov/libdl-2.3.6.so
 b7df3000-b7f1a000 r-xp 00000000 08:03 1189678    /lib/tls/i686/cmov/libc-2.3.6.so
 b7f1a000-b7f1f000 r-xp 00127000 08:03 1189678    /lib/tls/i686/cmov/libc-2.3.6.so
 b7f1f000-b7f21000 rwxp 0012c000 08:03 1189678    /lib/tls/i686/cmov/libc-2.3.6.so
 b7f21000-b7f25000 rwxp b7f21000 00:00 0 
 b7f25000-b7f38000 r-xp 00000000 08:03 1172834    /lib/libselinux.so.1
 b7f38000-b7f3a000 rwxp 00012000 08:03 1172834    /lib/libselinux.so.1
 b7f3a000-b7f70000 r-xp 00000000 08:03 1172835    /lib/libsepol.so.1
 b7f70000-b7f71000 rwxp 00035000 08:03 1172835    /lib/libsepol.so.1
 b7f71000-b7f7b000 rwxp b7f71000 00:00 0 
 b7f7f000-b7f81000 rwxp b7f7f000 00:00 0 
 b7f81000-b7f82000 r-xp b7f81000 00:00 0          [vdso]
 b7f82000-b7f97000 r-xp 00000000 08:03 1172738    /lib/ld-2.3.6.so
 b7f97000-b7f99000 rwxp 00014000 08:03 1172738    /lib/ld-2.3.6.so
 bfc42000-bfc58000 rw-p bfc42000 00:00 0          [stack]

こちらは、0x08048000 番地から始まっています。

32ビット環境, 64ビット環境いずれの場合においても、0x6C64210A 番地はメモリーマップに含まれていないため、メモリ保護違反が発生したのです。

メモリ保護違反の実験

メモリ保護違反は大切な概念ですから、いくつかのプログラムを使って実験してみましょう。よくある例として、未設定のポインタ変数を使ったプログラム nullptr.c を用意します。

 #include <stdio.h>
 
 char* ptr;
 
 int main() {
       printf("%s\n", ptr);
       return 0;
 }

今後説明する予定の stdio.h が登場している点はご勘弁頂くとして、このプログラムに文法的な間違いはありません。しかし、ptr 変数は定義されただけで初期値が設定されていないため、その内容はゼロになっています(グローバル変数は明示的に初期化されない場合、.bss もしくは .common セクションに配置され、プロセス起動時にその値はゼロにセットされる)。この結果、printf 関数は文字列データを入手するため、0番地のメモリアクセスを試みますが、該当領域はメモリーマップから外されているため、保護例外が発生するはずです。

 $ gcc -o nullptr nullptr.c
 $ ./nullptr 
 Segmentation fault

予想通り、segfault が発生しています。このように、未設定のポインタ変数はゼロ番地を指していることから Null pointer と呼ばれます。仮想記憶をサポートしているOSでは、Null pointer に基づくバグを "露見させるため"、意図的にゼロ番地付近をメモリーマップから外しているのです。

次に、nullptr.c を少し発展させ、指定した任意のアドレスから16バイトの内容をダンプする プログラムを作成してみましょう。CPU のワードサイズによりプログラム内容が若干変わるため、最初に 32ビット版 peek32.c を提示します。

 #include <stdio.h>
 
 int main(int argc, char** argv) {
       unsigned int add;
       unsigned char* ptr;
       int i;
 
       if (argc != 2)
               return 1;
       if (sscanf(argv[ 1 ], "%x", &add) != 1)
               return 2;
 
       ptr = (unsigned char*) add;
       printf("%08X : ", add);
       fflush(stdout);
       for (i = 0; i < 16; i++)
               printf("%02X ", *ptr++);
       printf("\n");
       return 0;
 }

プログラム内容ですが、ssanf 関数を用い、コマンドライン引数で指定したダンプ開始アドレスを add 変数に格納します(フォーマット文字列中の %x により、16進数として解釈)。次に、得られたアドレスを ptr ポインタ変数にセット後、その値を表示し、最後に16バイト分のバイトデータを出力します。fflush 関数は for 文中で segfault が発生する前に、アドレス値の表示を完了させるために挿入しています。

 $ gcc -Wall -o peek32 peek32.c 
 $ ./peek32 0
 00000000 : Segmentation fault
 $ ./peek32 100000
 00100000 : Segmentation fault
 $ ./peek32 8048000
 08048000 : 7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00 

実行可能ファイル peek32 を作成し、テストしてみると、0番地・0x10000 番地ではいずれも segfault が発生しています。しかし、32ビット環境における ELF 開始アドレスである 0x08048000 番地を入力すると、何らかのデータが表示されました。このデータの正体は、peek32 実行可能ファイルの先頭をダンプすると明らかになります。

 $ hexdump -C peek32 | head
 00000000  7f 45 4c 46 01 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
 00000010  02 00 03 00 01 00 00 00  80 83 04 08 34 00 00 00  |............4...|
 00000020  a0 0f 00 00 00 00 00 00  34 00 20 00 07 00 28 00  |........4. ...(.|
 00000030  22 00 1f 00 06 00 00 00  34 00 00 00 34 80 04 08  |".......4...4...|
 00000040  34 80 04 08 e0 00 00 00  e0 00 00 00 05 00 00 00  |4...............|
 00000050  04 00 00 00 03 00 00 00  14 01 00 00 14 81 04 08  |................|
 00000060  14 81 04 08 13 00 00 00  13 00 00 00 04 00 00 00  |................|
 00000070  01 00 00 00 01 00 00 00  00 00 00 00 00 80 04 08  |................|
 00000080  00 80 04 08 10 06 00 00  10 06 00 00 05 00 00 00  |................|
 00000090  00 10 00 00 01 00 00 00  10 06 00 00 10 96 04 08  |................|

peek32 が表示した 0x08048000 番地からのデータは、peek32 実行可能ファイルの先頭データ(ELF ヘッダー)と同一であることが分かります。ファイル上に記録されたプログラムは、プロセスへと変化する時点でカーネルがメモリー上にマッピングを行います。この結果、0x08048000 番地からのメモリー内容と、実行可能ファイルの先頭データが一致したのです。

次に、64ビット環境で動作する peek64.c を提示します。

 #include <stdio.h>
 
 int main(int argc, char** argv) {
       unsigned long long add;
       unsigned char* ptr;
       int i;
 
       if (argc != 2)
               return 1;
       if (sscanf(argv[ 1 ], "%llx", &add) != 1)
               return 2;
 
       ptr = (unsigned char*) add;
       printf("%016llX : ", add);
       fflush(stdout);
       for (i = 0; i < 16; i++)
               printf("%02X ", *ptr++);
       printf("\n");
       return 0;
 }

基本骨格は peek32.c と同じですが、add 変数の型を unsigned long long に変更し、sscanf と printf 中で length modifier を 'll' に指定している点がことなります。

 $ gcc -Wall -o peek64 peek64.c
 $ ./peek64 0
 0000000000000000 : Segmentation fault
 $ ./peek64 400000
 0000000000400000 : 7F 45 4C 46 02 01 01 00 00 00 00 00 00 00 00 00 
 $ ./peek64 ffffffffff600000
 FFFFFFFFFF600000 : 41 54 48 85 FF 49 89 F4 55 53 48 89 FB 0F 84 AF 

64ビット分のアドレス表記となると目が回りそうですが、peek64 は正常に動作しています。

Type modifiers & Length modifiers

sscan や printf 関数の第一引数に指定されるデータは「フォーマット文字列」と呼ばれ、二番目以降に指定される引数の処理方法を関数に伝える役目を持っています。フォーマット文字列中では、データの種類を指定するために %s, %d, %x などが用意されていますが、これらは "type modifier" と呼ばれます。一方、頻度は少ないのですが、データ型を指定するために "length modifier" と呼ばれる修飾子が用意されています。代表的な修飾子をまとめておきます。

データ型修飾子
charhh
short inth
longl
long longll, L, q

中でも、64ビット長を扱う場合に ll, L, q は必須です。これらの修飾子が明示されていなければ、データは32ビット長として扱われてしまうため注意してください(詳細については、上記に付した URL を参照)。

-Werror-implicit-function-declaration オプション

さて、ここまでの説明を読んで頂ければ "implicit function declaration" という警告メッセージが持つ重要性の一端をお分かり頂けたのではないかと思います。「本来、この警告はエラーとして扱うべきではないの?」という方もいらっしゃるかと思いますが、誠におっしゃる通り。

このような場合のために、GNU C コンパイラには、特定の警告をエラーとして扱うための仕組みが用意されています(GNU C コンパイラが出力する警告の一覧はこちら)。警告オプション名の前に -Werror- を前置すると、その警告はエラーとして扱われ、コンパイルは中止されるようになります。

hello1.c を使って実験してみましょう。

 $ gcc -fno-builtin -S hello1.c ; echo $?
 0

-fno-builtin オプションを指定すると、このように GNU C コンパイラは printf 関数を与えられた通りに解釈するようになるため、警告メッセージを表示しません。もちろん、コンパイルも正常に終了します。

そこで、GNU C コンパイラを使う場合、普段から -Wall オプションを指定する習慣を身につけておきましょう。-Wall オプションでは30種類あまりの警告がONとなりますが、この中に -Wimplicit-function-declaration も含まれています。

  -Waddress   
  -Warray-bounds (only with -O2)  
  -Wc++0x-compat  
  -Wchar-subscripts  
  -Wimplicit-int  
  -Wimplicit-function-declaration  
  -Wcomment  
  -Wformat   
  -Wmain (only for C/ObjC and unless -ffreestanding)  
  -Wmissing-braces  
  -Wnonnull  
  -Wparentheses  
  -Wpointer-sign  
  -Wreorder   
  -Wreturn-type  
  -Wsequence-point  
  -Wsign-compare (only in C++)  
  -Wstrict-aliasing  
  -Wstrict-overflow=1  
  -Wswitch  
  -Wtrigraphs  
  -Wuninitialized (only with -O1 and above)  
  -Wunknown-pragmas  
  -Wunused-function  
  -Wunused-label     
  -Wunused-value     
  -Wunused-variable  
  -Wvolatile-register-var 

-Wall オプション付きで再度 hello1.c をコンパイルしてみます。

 $ gcc -Wall -fno-builtin -S hello1.c ; echo $?
 hello1.c: In function ‘main’:
 hello1.c:2: warning: implicit declaration of function ‘printf’
 0

警告が表示されていますが、コンパイル自体は正常に終了しています。次に -Werror-implicit-function-declaration オプションを付け加えてみます。

 $ gcc -Werror-implicit-function-declaration -fno-builtin -c hello1.c ; echo $?
 cc1: warnings being treated as errors
 hello1.c: In function ‘main’:
 hello1.c:2: warning: implicit declaration of function ‘printf’
 1

今度は gcc の終了ステータスが1になり、異常終了していることが分かります("cc1: warnings being treated as errors")。この結果、Makefile やビルドスクリプトは異常ステータスを検知し、ビルド作業をその場で中断することが可能になるのです。

-Werror-implicit-function-declaration オプションは、Linux カーネルのビルド時にも指定されています。Linux カーネル 2.6.24 ソースツリーのトップディレクトリ中に含まれる Makefile から、警告メッセージの取り扱いに関連する部分を抜粋してみましょう。

 KBUILD_CFLAGS   := -Wall -Wundef -Wstrict-prototypes -Wno-trigraphs \
                    -fno-strict-aliasing -fno-common \
                    -Werror-implicit-function-declaration

-Wall オプションで有効になる警告に加えて3つの警告メッセージを有効にし、implicit-function-declaration 警告については、エラーとして扱い作業を中止するよう KBUILD_CFLAGS が設定されています。

この処置を「さすが」と見るのか、「まだまだ甘いのぅ、おぬし」と見るのかは、他に比較対象がなければなりません。NetBSD と比較してみましょう。

-Werror の勧め

NetBSD がカーネルビルド時に使用する警告オプションは、sys/conf/Makefile.kern.inc 中で次のように定義されています。

 DEFWARNINGS?=   yes
 .if (${DEFWARNINGS} == "yes")
 . if !defined(NOGCCERROR)
 CWARNFLAGS+=    -Werror
 . endif
 CWARNFLAGS+=    -Wall -Wno-main -Wno-format-zero-length -Wpointer-arith
 CWARNFLAGS+=    -Wmissing-prototypes -Wstrict-prototypes
 CWARNFLAGS+=    -Wswitch -Wshadow
 CWARNFLAGS+=    -Wcast-qual -Wwrite-strings
 # Add -Wno-sign-compare.  -Wsign-compare is included in -Wall as of GCC 3.3,
 # but our sources aren't up for it yet.
 CWARNFLAGS+=    -Wno-sign-compare
 . if ${HAVE_GCC} > 3
 CWARNFLAGS+=    -Wno-pointer-sign -Wno-attributes
 .  if ${MACHINE} == "i386" || ${MACHINE_ARCH} == "sparc64" || ${MACHINE} == "prep"
 CWARNFLAGS+=    -Wextra -Wno-unused-parameter
 .  endif
 . endif
 .endif

CWARNFLAGS 変数に -Wall をはじめとして、いくつかの追加(-W)や、警告解除を指示する(-Wno-)オプション群が埋め込まれていますが、最も重要な点は先頭付近の CWARNFLAGS+= -Werror です。この行は、NOGCCERROR 環境変数が定義されていない場合に実行され、CWARNFLAGS に -Werror オプションが追加されます。

-Werror はその名の通り、「すべての警告をエラーとして扱う」ようにCコンパイラへ指示するオプションです。プログラマにとっては実に厳しいオプションですが、NetBSD ではカーネルだけでなく、ツール・ライブラリなどすべてのビルド作業時に -Werror をデフォルトとすることで、システムの堅牢性を高めようとしています。

ソースツリー内部において、Makefile はどちらかというと飾りのように思われがちですが、実はソースファイルよりも雄弁に開発者の姿勢や教養を物語っているのです。

次回は、いよいよ stdio.h の登場です。

続く