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

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


2008-03-11 (Tue)

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

6回目にして、いよいよ stdio.h の登場です。stdio は STanDard Input/Output の略であり、標準入出力関数のプロトタイプ宣言, 関連するマクロや構造体の定義などを収めています。

printf 関数宣言

stdio.h ヘッダーファイルを利用する前に、man 3 printf で printf ライブラリ関数の説明を見ておきましょう(セクション3を指定しない場合、セクション1の printf コマンドが表示されるので注意)。

 PRINTF(3)                  Linux Programmer’s Manual                  PRINTF(3)
 
 NAME
        printf,   fprintf,   sprintf,  snprintf,  vprintf,  vfprintf,  vsprintf,
        vsnprintf - formatted output conversion
 
 SYNOPSIS
        #include <stdio.h>
 
        int printf(const char *format, ...);
        int fprintf(FILE *stream, const char *format, ...);
        int sprintf(char *str, const char *format, ...);
        int snprintf(char *str, size_t size, const char *format, ...);
 ...
    Return value
        Upon successful return, these functions return the number of  characters
        printed (not including the trailing ’\0’ used to end output to strings).

先頭部分のみを掲載していますが、重要な箇所は "Synopsis (概要)" です。Synopsis の最初には、該当する関数をソースコード中で利用する場合に必要なヘッダーファイルが列挙されています。printf ではstdio.h ひとつですが、次回紹介する open のように複数のヘッダーファイルを要求する関数も数多く存在します。

必須ヘッダーファイルの一覧が終わると、次に関数プロトタイプ宣言(function prototype declaration)がはじまります。各関数が必要とするすべてのパラメータのデータ型と名前(データ型に意味があり、名前は実質的にはコメント)、そして関数の戻り値のデータ型が記されています。printf 関数の場合、最初のパラメータは固定文字列へのポインタであり、二番目以降は可変個パラメータ(variable number of parameters)を意味する "..." が指定されています(先頭パラメータに続くカンマを忘れやすいので注意)。可変個パラメータはC言語の特徴のひとつですが、x86では通常スタック機構を使い実現されています。printf 関数の戻り値は整数値であり、フォーマット文字列の指定に従い出力された文字数が返されます。

関数プロトタイプ宣言の直書き

printf 関数の「プロトタイプ」が分かってしまえば、わざわざ stdio.h ヘッダーファイルをインクルードする必要はありません。次に示す hello2.c で直書きしてみましょう。

 extern int printf(const char* format, ...);
 
 int main() {
       int ret;
 
       ret = printf("Hello, world!\n");
       return ret;
 }

先頭に、extern 修飾子付きで printf 関数のプロトタイプを宣言しています。main 関数内部では、printf 関数の戻り値を ret 変数に格納し、return 文で親プロセスに返します。

 $ gcc -Wall -o hello2 hello2.c ; echo $?
 0
 $ wc -c hello2
 8991 hello2
 $ ./hello2;echo $?
 Hello, world!
 14

hello1.c のコンパイル時と違い、-Wall オプション付きでも警告は表示されませんし、出来上がった hello2 は、"Hello, world!\n" の文字数である14を返しています(行末コードも含まれる)。

このように、ソースファイル中で使用するすべての関数やマクロ定義を自前で用意できれば、ヘッダーファイルは本来必要ないのですが、膨大なライブラリ関数の存在を考えると現実的ではありません。

そこで、stdio.h の登場です。

stdio.h のインクルード

Synopsis で書かれていた通り、printf ライブラリ関数をソースファイル中で利用するためには、#include <stdio.h> を先頭に記述します(hello3.c)。

 #include <stdio.h>    // printf()
 
 int main() {
       int ret;
 
       ret = printf("Hello, world!\n");
       return ret;
 }
 $ gcc -Wall -o hello3 hello3.c
 $ ./hello3 ; echo $?
 Hello, world!
 14

当然のことながら、hello2.c と同じように -Wall オプション付きでも警告無しでビルドを完了し、hello3 は正常に動作しています。

stdio.h ファイルの実体は /usr/include ディレクトリに置かれていますので、内部に記述されている printf 関数のプロトタイプ宣言を探してみます(stdio.h などのシステムヘッダーファイルは、GNU C ライブラリパッケージに由来する)。

 /* Write formatted output to stdout.
 
    This function is a possible cancellation point and therefore not
    marked with __THROW.  */
 extern int printf (__const char *__restrict __format, ...);

見慣れない定義になっていますが、中身は extern int printf(const char* format, ...) と変わりありません。

ヘッダーファイルを利用することで、プログラマは各関数のプロトタイプを暗記する作業からは解放されたものの、今度は「関数とヘッダーファイルの対応」に悩まされることになります。

インクルード処理の可視化

向学心旺盛な学生さんであれば、コンパイラが #include <stdio.h> を処理する仕組みに興味を持ち、ヘッダーファイルの旅に出る人もいるでしょう。私自身も何度か挑戦したことがありますが、いくばくも解析が進まないうちに "遭難" した苦い思い出があります。実際にヘッダーファイルの追跡を行うと分かるのですが、次から次へと孫引き(#include のネスト)が発生し、頭の中はスパゲッティ状態と化すのです。

私のアドバイスは「GNU C ライブラリのヘッダーファイル追跡は止めた方が良い」というものですが、最初から白旗を掲げるのも癪に障ります。そこで、一見単純そうに見える #include <stdio.h> の真の姿をコマンドラインから暴き出してみましょう。

まずは、Cプリプロセッサを使い、最初のプリプロセス(前処理)で生成される中間ファイル(拡張子 .i)を用意します。Cプリプロセッサは GCC パッケージの一員ですが、GNU C コンパイラ(cc1)とはことなり、PATH に登録済みの /usr/bin ディレクトリに格納されています。

 $ which cpp
 /usr/bin/cpp
 $ cpp hello3.c > hello.i
 $ wc -c hello3.c hello.i
   105 hello3.c
 13996 hello.i
 14101 total

cpp コマンドでCソースファイルを処理すると、その結果は標準出力へ出力されるため、リダイレクションを使い hello.i ファイルに記録しています。プリプロセスを行うと、#include 文やマクロ定義が処理され、105バイトのソースファイルが一気に14Kバイト近くにまで膨らんでいます。

 # 1 "hello3.c"
 # 1 "<built-in>"
 # 1 "<command-line>"
 # 1 "hello3.c"
 # 1 "/usr/include/stdio.h" 1 3 4
 # 28 "/usr/include/stdio.h" 3 4
 # 1 "/usr/include/features.h" 1 3 4
 # 330 "/usr/include/features.h" 3 4
 # 1 "/usr/include/sys/cdefs.h" 1 3 4
 ...省略...
 extern int printf (__const char *__restrict __format, ...);
 ...省略...
 # 2 "hello3.c" 2
 
 int main() {
  int ret;
 
  ret = printf("Hello, world!\n");
  return ret;
 }

内容をチェックすると#で始まる文(Cコンパイラは#以降を無視する)が数多く含まれており、その間にインクルードファイルから取り込まれた関数プロトタイプ宣言, typedef や構造体の定義文が見えます。

#で始まる文(ラインマーク)は、Cプリプロセッサがインクルード処理の進行状況を記録するために出力したものです(詳細については "GNU 開発ツール p.68 ラインマークの意味" を参照)。

ラインマークの解析

参考までに、プリプロセス後のファイルに含まれるラインマーク行すべてを出力してみましょう。

 $ cpp hello3.c | egrep "^# "
 # 1 "hello3.c"
 # 1 "<built-in>"
 # 1 "<command-line>"
 # 1 "hello3.c"
 # 1 "/usr/include/stdio.h" 1 3 4
 # 28 "/usr/include/stdio.h" 3 4
 ...
 # 793 "/usr/include/stdio.h" 3 4
 # 1 "/usr/include/bits/sys_errlist.h" 1 3 4
 # 27 "/usr/include/bits/sys_errlist.h" 3 4
 # 823 "/usr/include/stdio.h" 2 3 4
 # 842 "/usr/include/stdio.h" 3 4
 # 882 "/usr/include/stdio.h" 3 4
 # 912 "/usr/include/stdio.h" 3 4
 # 2 "hello3.c" 2
 $ cpp hello3.c | egrep "^# " | wc -l
 91

cpp コマンドの出力をパイプで egrep コマンドに流し込み、正規表現により先頭文字が '#' ではじまる行を抜き出しています。続いて、その結果をパイプ操作で wc コマンドに入力すれば、総行数91が得られます。

ご覧の通り、このままでは重複したヘッダーファイルが含まれているため、awk を用いてラインマーク行の3番目のフィールドに位置するヘッダーファイルの絶対パス名のみを抜き出し、この結果に対して sort コマンドでソートをかけます。この時、-u (Unique) オプションを指定しておけば、重複する内容は1行にまとめられます。

 $ cpp hello3.c | awk '/^# / { print $3 }' | sort -u
 "<built-in>"
 "<command-line>"
 "hello3.c"
 "/usr/include/bits/stdio_lim.h"
 "/usr/include/bits/sys_errlist.h"
 "/usr/include/bits/types.h"
 "/usr/include/bits/typesizes.h"
 "/usr/include/bits/wordsize.h"
 "/usr/include/features.h"
 "/usr/include/_G_config.h"
 "/usr/include/gnu/stubs-64.h"
 "/usr/include/gnu/stubs.h"
 "/usr/include/libio.h"
 "/usr/include/stdio.h"
 "/usr/include/sys/cdefs.h"
 "/usr/include/wchar.h"
 "/usr/lib/gcc/x86_64-linux-gnu/4.2.3/include/stdarg.h"
 "/usr/lib/gcc/x86_64-linux-gnu/4.2.3/include/stddef.h"
 $

簡単なワンライナーで、#inlucde <stdio.h> を起点とするインクルードの連鎖を解析することができました。stdio.h は、合計14種類ものヘッダーファイルを孫引きしていることが分かります。これでは、若き挑戦者が遭難するのは当然でしょう。またパス名からは、GNU C ライブラリの開発者に「システムの複雑化を防ごう」という意志はなく、ただ「動けば良い」とする姿勢が垣間見えるように思います。

なお、このリスト中には GCC 専用ディレクトリである /usr/lib/gcc/x86_64-linux-gnu/4.2.3 に収められている stdarg.h, atddef.h が含まれている点に注意してください(次に示す OpenBSD 環境では存在しない)。

OpenBSD のヘッダーファイル環境

Linux 環境は開発ツールはもとより、基本ライブラリ群も GNU に「おんぶにだっこ」状態であるのに対し、*BSD では GNU 開発ツールのみを拝借し、システムの根幹となるライブラリ群はすべて自前で実装しています。

このため、同じ GNU 開発ツール(厳密には、*BSD 版にはかなり手が加えられている)を利用しながら、インクルードディレクトリの内容は GNU 提供のものとは全くことなる内容になっています。

 $ uname -a
 OpenBSD OpenBSD.local.com 4.2 GENERIC#375 i386
 $ gcc --version
 gcc (GCC) 3.3.5 (propolice)
 Copyright (C) 2003 Free Software Foundation, Inc.
 This is free software; see the source for copying conditions.  There is NO
 warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR
 PURPOSE.
 $ cpp hello3.c | awk '/^# / { print $3 }' | sort -ur
 "hello3.c"
 "<command
 "<built-in>"
 "/usr/include/time.h"
 "/usr/include/sys/types.h"
 "/usr/include/sys/time.h"
 "/usr/include/sys/select.h"
 "/usr/include/sys/endian.h"
 "/usr/include/sys/cdefs.h"
 "/usr/include/sys/_types.h"
 "/usr/include/stdio.h"
 "/usr/include/machine/endian.h"
 "/usr/include/machine/cdefs.h"
 "/usr/include/machine/_types.h"

上記は、OpenBSD 4.2 上で同じ手順を実行したものですが、stdio.h が依存しているヘッダーファイルは計10個です。特筆すべき点は、各ファイルの絶対パスにあり、システム間共通のヘッダーファイルは /usr/include, システム依存性のヘッダーファイルは /usr/include/sys, アーキテクチャ依存性のヘッダーファイルは /usr/include/machine (実体は /usr/include/i386 へのシンボリックリンク) ディレクトリと、実に理路整然と分類されていることが分かります。パス名自身が、ヘッダーファイルの階層構造を私達に語りかけ、水先案内人となってくれているのです。

 #include <stdio.h>

教科書では当たり前のように書かれている一文ですが、この行が意味するところは実に深いのです。

続く