wiki:HowTo/CTutorial

Version 3 (modified by 村山 俊之, 14 years ago) ( diff )

--

C 言語チュートリアル

C 言語って何?

UNIX システム発祥の、割と歴史が古いプログラミング言語です。古い言語でありながら、今でも OS や各種アプリケーションの開発に広く使われています。

C 言語の雰囲気になれて頂くために、まずは簡単な例として、1000 以下の素数をすべて求めて表示するプログラムを以下に示します。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_CALC_NUM 1000
#define INTERNAL_BUFFER_SIZE (BUFSIZ * 2)
#define PRIME_BUFFER_UNIT 500

int genNextPrime();
void printByComma(int);

int main()
{
    int n;
    
    do {
        n = genNextPrime();
        printByComma(n);
    } while (n != 0);
    
    return 0;
}

int genNextPrime()
{
    static int n = 2, length = 0, *buf = NULL;
    static size_t buf_size = 0;
    int i;
    if (!buf) {
        buf = calloc(PRIME_BUFFER_UNIT, sizeof(int));
        buf_size = PRIME_BUFFER_UNIT;
    }
    for (; n <= MAX_CALC_NUM; n++) {
        for (i = 0; i < length; i++) {
            if (buf[i] * buf[i] > n) {
                i = length;
                break;
            }
            if (n % buf[i] == 0)
                break;
        }
        if (i == length) {  /* 素数を発見 */
            buf[length++] = n;
            if (length == buf_size) {
                buf_size += PRIME_BUFFER_UNIT;
                buf = realloc(buf, buf_size * sizeof(int));
            }
            return n++;
        }
    }
    /* 素数算出対象の最大値に達した */
    free(buf);
    n = 2, length = 0, buf = NULL, buf_size = 0;
    return 0;
}

void printByComma(int n)
{
    static char line_buffer[INTERNAL_BUFFER_SIZE];
    static int is_first = 1;
    static size_t line_len;
    char buffer[BUFSIZ];
    if (is_first) {
        memset(line_buffer, 0, INTERNAL_BUFFER_SIZE * sizeof(char));
        line_len = 0;
    }
    if (n == 0) {
        puts(line_buffer);
        is_first = 1;
        return;
    }
    sprintf(buffer, is_first ? "%d" : ", %d", n);
    line_len += strlen(buffer);
    strncat(line_buffer, buffer, BUFSIZ);
    is_first = 0;
    if (line_len < BUFSIZ)
        return;
    strncpy(buffer, line_buffer, BUFSIZ - 1);
    buffer[BUFSIZ - 1] = '\0';
    printf("%s", buffer);
    strcpy(line_buffer, line_buffer + BUFSIZ - 1);
    line_len -= BUFSIZ - 1;
}

「関数による」手続き型言語

C 言語は、すべての手続き (これは「命令」と置き換えて読んで頂いても良いでしょう) を関数として書き表す、というスタイルを採用したプログラミング言語です。関数呼び出しが階層的・構造的であるため、構造化言語などと呼ばれたりもします。

上記のサンプルの場合、手続きは以下のように構造化されます。

  • メイン関数 (main)
    • 繰り返し (do ~ while) - 素数を求め終わるまで繰り返す
      • 素数を求める (genNextPrime)
        • 素数バッファのメモリー領域を確保する (calloc)
        • 繰り返し (for) - 判定対象となる値をカウントする
          • 繰り返し (for) - 過去に求めた素数を小さい順にたぐる
            • 評価対象の素数の二乗が判定対象となる値より大きい (buf[i] * buf[i] > n) ならば、それ以上に大きい素数で割りきれることはあり得ない。 - 判定対象の値は素数であるものとして、繰り返しを抜ける。
            • 評価対象の素数で判定対象の値が割りきれる (n % buf[i] == 0) ならば、判定対象の値は素数ではないものとして、繰り返しを抜ける
          • 判定対象の値が素数ならば、
            • 素数バッファにこの値を追加し (buf[length++] = n)、
            • 素数バッファがいっぱいになったならサイズを拡張し (realloc)、
            • この値を返す (return n++)。
        • 判定対象の値が 1000 を超えたら、素数バッファのメモリー領域を解放し (free)、すべての素数を求め終わったことを示す値 0 を返す (return 0)
      • 求めた素数を表示する (printByComma)
        • 初めての呼び出しならば (is_first)、書き溜め用のバッファ領域を初期化する (memset)。
        • 渡された値が素数ではなく (すべての素数を求め終わったことを示す値) 0 ならば、書き溜め用のバッファ領域に残っている文字列をすべて表示し (puts)、関数を抜ける (return)。
        • 素数の値を文字列に変換する (sprintf)。
        • 上で変換された文字列の文字数を求め (strlen)、書き出し用バッファに既に書き出されている文字数との合計を算出する。
        • 書き出し用バッファ上の文字列に、上で変換された文字列を繋げる (strncat)。
        • 書き出し用バッファ上の文字数が一定数を超えた場合、その文字数までを一旦別のバッファにコピーしてから (strncpy) 表示し (printf)、表示した分の文字列を領域から取り除く (strcpy)。

この中で、括弧書きで強調表示しているのはすべて関数名です。 main 関数と genNextPrime 関数、 printByComma 関数はサンプルのプログラム中で定義している関数ですので、その関数の中での手続きも合わせて書き出してみました。こうしてみると、処理内容をツリー状に書き表せるということがよく分かると思います。

C 言語はこのように、 main 関数から処理が開始され、関数の中で別の関数を呼び出し、その関数がまた更に別の関数を呼び出す、ということの繰り返しによって、プログラムを成り立たせる。そんなスタイルの言語なのです。

言語として最低限の命令しか用意していない

C 言語が、言語として用意している命令は、ごくわずかです。どんなものがあるかというと、概ね以下の通りです。

  • 宣言子 (int, char などの型指定子、 const, volatile などの型修飾子、 その他、 struct, enum, typedef 等々…)。
  • 処理の流れ制御 (if, switch などの分岐、 for, while などのループ、 ループから抜ける break やループを強制的に回す continue 、関数を抜ける return 等々…)。
  • 演算子 (四則演算の +, -, *, / 、代入 = 、ポインタ演算子 * やアドレス参照 & 、括弧 () 、比較 <, >, == 、等々…)。

それ以外のことは、すべて関数で表現します。例えば、画面に文字を表示するのも、 C 言語に用意された命令ではなく、誰かが用意した関数を呼び出すことで実現するのです。

…といっても、心配することはありません。例えば、上記サンプルプログラムにおいても、画面に文字を表示するのに、 printf や puts といった名前の関数を利用していますが、これらはプログラマーが自分で定義した関数ではなく、 C コンパイラーを作った人々 (ベンダー) が用意している標準ライブラリにおいて用意されている関数です。標準ライブラリにどのような関数が用意されているかは、 ANSI や ISO などによって標準規格として定められており、それらの関数を用いる範囲内においては、ある程度の可搬性 (即ち、 OS や C コンパイラが変わっても、コンパイルが通り、同じような動作をしてくれること) が期待できるのです。

コンパイル言語

C 言語はコンパイル言語です。 C コンパイラが C 言語で書かれたプログラムを実際にコンピュータ上で動作するプログラム (いわゆる「実行ファイル」と呼ばれるもの) に変換するまでの伝統的な手順は概ね以下の通りです。

  1. プリプロセッサにより、番号記号 "#" で始まる行を処理する (ファイル挿入や行削除など)。
  2. コンパイラにより、 C 言語で書かれたプログラムを、アセンブリ言語のプログラムに変換する。
  3. アセンブラにより、アセンブリ言語のプログラムを機械語のバイナリファイルに変換する。
  4. リンカにより、関数や変数などの名前を頼りにバイナリファイルをつなぎ合わせ、実行可能なプログラム (実行ファイル) に変換する。

この動作を知っていることは重要です。プリプロセッサの役割を理解していることは、マクロなどのプリプロセッサ行をどう活用すべきかを考える指針になりますし、コンパイラがアセンブリ言語のプログラムを生成できることを知っていれば、動作効率を最適化するための参考になるでしょうし、コンパイルとリンクが処理の段階としては別個であることを理解していれば、リンクエラーを解決する手立ての見当がつくようになります。

1 のプリプロセッサは、プログラム中の番号記号 "#" で始まる行 (プリプロセッサ行) を「先に」処理しておくツールです。先ほどのサンプルプログラムで言うと、 #include で始まる行や、 #define で始まる行がそれに該当します。プリプロセッサが #include で始まる行を見つけると、そこに書かれているファイル名のファイルを探してきて、その行の位置に挿入します。また、 #define で始まる行を見つけると、その直後に書かれた名前と同じ単語を、その更に後ろに書かれた内容で置換します (例えば、 "MAX_CALC_NUM" と書かれた箇所が、事前に "1000" に書き換えられます)。

2 のコンパイラは、C で書かれたプログラムを、あくまでアセンブリ言語のプログラムに置き換えるだけです。実は、 C 言語はアセンブリ言語に割と置き換えやすいように設計されています。但し、ただ単に置き換えるよりも、工夫して置き換えた方が、動作効率が良くなったり、実行ファイルを小さくまとめられたりしそうな箇所については、コンパイラが独自に判断して、上手く変換してくれる場合もあります。使用するコンパイラや、コンパイル時に指定するオプションによって、プログラムの実行速度や、実行ファイルのファイルサイズなどが変わる場合があるのは、その為です。

GCC でも、 -S オプションを指定することによって、アセンブリ言語で書かれたプログラムを生成することができます。以下のように実行すると、アセンブリ言語によるプログラムファイル test.s が生成されます。

$ gcc -S test.c

アセンブリ言語とは、機械語と呼ばれる、コンピュータが直接命令として理解できる数値の羅列を、その数値毎に名前をつけて、命令毎に行に起こしたものです。昔は機械語を直接コンピュータに入力していましたが、数値の羅列ではさすがにわかりにくすぎるので、命令毎に書き起こして分かりやすくしたのがアセンブリ言語でした。

しかし、このアプローチはお世辞にも直感的とは言えません。アセンブリ言語には変数の概念が無く、値を書き込むメモリーの位置や、どのレジスタFootNote(CPU などのプロセッサ (演算処理装置) が命令を処理する際、その命令にどの値を用いるかは命令の種類によって決まっていたり、あるいは命令と一緒に指定して決めたりします。その、命令を処理する際に用いる値を格納する場所がレジスタです。これに対し、メインメモリーは、あとでレジスタに取り込んで命令に用いるつもりで用意した値を保存しておく場所であり、メモリー上に記録されている値を直接命令によって処理できるわけではありません。)を用いるかなどは、プログラマーが自分で管理しなければなりません。また、高級言語では 1行の計算式で書き表せる演算を、アセンブリ言語では演算子毎に (演算順序を気にしながら!) 行を分けて書き連ねる必要があり、感覚的には非常に冗長です。

例えば、以下の非常に簡単なプログラム

#include <stdio.h>

int main()
{
    int a[] = { 1, 2, 3, 4 };
    int b = 0;
    
    b = a[0] + a[1] * a[2] - a[3];
    
    printf("a = %d\n", b);
    
    return 0;
}

のうち、以下の行

    b = a[0] + a[1] * a[2] - a[3];

は、アセンブリ言語に変換すると、以下のようになりますFootNote(変換したアセンブリソースの抜粋です。なお、変換に用いた環境は、 Intel CPU を搭載した一般的な Windows XP パソコン上にインストールされた MinGW の GCC 4.5.0 です。)

	movl	28(%esp), %edx
	movl	32(%esp), %ecx
	movl	36(%esp), %eax
	imull	%ecx, %eax
	addl	%eax, %edx
	movl	40(%esp), %eax
	movl	%edx, %ecx
	subl	%eax, %ecx

メモリー上に記録されている値をレジスタにコピーし、掛け算して、足し算して、更にメモリーからレジスタにコピーして、引き算する、といった処理内容です。この例の場合、 imull という 1ステップで掛け算を処理してくれる便利な命令があるのでまだ分かりやすいですが、もしもこの命令が搭載されていないコンピュータだった場合、足し算を複数回繰り返すループとして記述されていたでしょう。

Note: See TracWiki for help on using the wiki.