システムプログラム(第2週)

電子・情報工学系
追川 修一
<shui @ cs.tsukuba.ac.jp>

このページは,次の URL にあります.
http://www.coins.tsukuba.ac.jp/~syspro/2005/No2.html
システムプログラムのホームページ(2005年度)
http://www.coins.tsukuba.ac.jp/~syspro/2005/
からもリンクが張ってあります.

今日の内容

理解を深めるために:

文字,文字列のデータ表現

文字コード

コンピュータは2進数しか扱えないので,文字も数として表す必要がある. ある数値がどの文字にあたるかの対応の決まりを文字コードと呼ぶ. 文字コードには唯一絶対というようなものはなく,場合によって使い分けられている. 欧米では必要となる文字数が少ないため,文字コードも標準的なものがあるが,それ以外(特に日本)は大変複雑になってしまっている.

ASCIIコード

UNIXで標準的に使われてきたのがASCII(アスキー)コードである. ASCII は American Standard Code for Information Interchange の略であり,その名のとおりアメリカで使うために作られたものである. 7ビットで表現され,ローマ字,数字,記号,制御コードからなる. 数値との対応は以下の通り:

上位3ビット→
↓下位4ビット
01 23 45 67
0NULDLE SP0@P`p
1SOHDC1 !1AQaq
2STXDC2 "2BRbr
3ETXDC3 #3CScs
4EOTDC4 $4DTdt
5ENQNAC %5EUeu
6ACKSYN &6FVfv
7BELETB '7GWgw
8BSCAN (8HXhx
9HTEM) 9IYiy
ALF/NLSUB *:JZj z
BVTESC +;K[k{
CFFFS, <Ll|
DCRGS -=M]m}
ESORS .>N^n~
FSIUS /?O_oDEL

制御符号の意味

EUC-JP

EUC は Extended UNIX Code の略であるように,EUC-JPはUNIXでは広く使われている日本語文字コードである. 基本的には漢字1文字を2バイトで表すが,3バイトで表される補助漢字もある.

1バイト目:B0
下位4ビット→
↓上位4ビット
0123456789ABCDEF
A0
B0
C0
D0
E0
F0

上記の表の最初にある「亜」という漢字は1バイト目が B0,2バイト目が A1となる. EUC-JPでは,漢字は1バイト目,2バイト目共に8ビット目が立っている(1である). そのため,ある任意の1バイトを見ただけで漢字であるかどうか判別できるという利点がある. しかし,それが漢字の1バイト目なのか2バイト目なのかはわからない.

バックスラッシュ「\」と円マーク「\」

日本語の文字コードでは,ASCIIコードに相当するアルファベット及び記号部分のバックスラッシュ「\」(このバックスラッシュは全角の文字)が円マーク「\」にあてられてしまっている. そのため,文字コード(数値)としては同じ値であるが,目に見える文字としては違うというややこしいことになっている. 例えば以下の簡単なプログラムのように,Cプログラム中に円マーク「\」が出てきたら,バックスラッシュと同じとみなしましょう.

#include <stdio.h>

main()
{
        printf("hello world!\n");
}

さらに詳しい解説

新城先生が書かれた解説を参照してください.
http://www.hlla.is.tsukuba.ac.jp/~yas/classes/ipe/nitiniti2-enshu-1996/1996-11-18/kanji-code.html

C言語における文字と文字列

日本語文字コードの取り扱いは煩雑で難しい. そのためシステムプログラムでは,ASCIIコードだけを取り扱うことにする.

文字はシングルクォーテーション「'」で囲まれており,ダブルクォーテーション「"」で囲まれている. 'A' は文字であり,"A" は文字列である. 見た目の差はわずかかもしれないが,両者は似て非なるものである.

文字

シングルクォーテーション「'」で囲まれた文字は char 型の定数であり,値はASCII コードにおける文字に対応した値になる. 下の (1) と (2) は同じ値を変数 c に代入しており,プログラムの読みやすさ以外,意味的にも何ら変わりはない.

char c;
c = 'A';        /* (1) */
c = 0x41;       /* (2) */

文字は数値であるため,演算や比較の対象になる. 下のプログラムでは,char 型の変数 c を ++ でインクリメント(7行目)したり,<= で文字定数と比較(6行目)している.

     1  #include <stdio.h>
     2
     3  main()
     4  {
     5          char c = 'a';
     6          while (c <= 'z')
     7                  putchar(c++);
     8          putchar('\n');
     9  }

文字列

文字は1つの数値であるため,文字の並びである文字列は配列として表される. 文字列の終端を表すために,文字列の最後には 0 が置かれる. 文字列には終端の 0 が含まれるため,文字列の長さは表示される文字数よりも 1 大きくなる. 下の図は "Hello" という文字列がどのように格納されるか図示したものである.

文字列の格納

配列の0番目の要素から順番に文字が入れられ,最後に文字列の終端を表す 0 が入っている. 終端が \0 と書いてあるのは,値が 0 の文字は '\0' と表記するからである.

以下のプログラムは,文字を配列に格納したものが文字列になることを確かめるものである.

     1  #include <stdio.h>
     2
     3  char s[] = {'H', 'e', 'l', 'l', 'o', 0};
     4
     5  main()
     6  {
     7          int     i = 0;
     8
     9          printf("%s\n", s);
    10
    11          while (s[i]) {
    12                  printf("[%d] = %c\n", i, s[i]);
    13                  i++;
    14          }
    15  }

3行目では,文字型配列 s に順番に Hello と入るように初期化している. 9行目の printf では変換文字に %s を用いて,文字列を出力している. これで文字型配列 s が文字列となっていることがわかる. 11〜14行目では,文字型配列 s の各要素の文字を出力している. 12行目の printf では変換文字に %c を用いて,文字を出力している. これをコンパイル実行すると以下のような結果が得られる.

% ./a.out [←]
Hello
[0] = H
[1] = e
[2] = l
[3] = l
[4] = o
%

3行目は,以下のように書いても同じである.

     3  char s[] = "Hello";

しかしながら,以下のプログラムのように書くと,意味が異なってくる. 3行目では,char ポインタ型の変数 ps に文字列が代入されて初期化されている. 上記の文字型配列 s は,初期値となる文字列と終端文字 '\0' がぴったり入る大きさの配列となる. 配列の中の個々の文字は変更できるが,s 自体は同じメモリ位置を指し変更できない. 一方,char ポインタ型の変数 ps は文字列定数を指すように初期化されたポインタである. 従って,プログラム中でこのポインタを他の場所を指すように変更することができる. 逆に,文字列定数の内容の変更の結果は(言語仕様上は)不定である. Linuxでは通常セグメンテーションフォルトが発生しプログラムは異常終了してしまうが,できてしまうOSもある.

     1  #include <stdio.h>
     2
     3  char *ps = "Hello";
     4
     5  main()
     6  {
     7          int     i = 0;
     8
     9          printf("%s\n", ps);
    10
    11          while (*ps) {
    12                  printf("[%d] = %c\n", i, *ps);
    13                  i++;
    14                  ps++;
    15          }
    16  }

上記プログラム14行目ではポインタをインクリメントすることにより,次の要素を指すようにしている.

上記プログラム11〜15行目の while 文は以下のように書くこともできる. いかにもCらしいプログラムになるが,while 中に繰り返す文を追加する場合に,間違いが入りやすいという欠点もある(このように書くべきではないという意味ではない).

    11          while (*ps)
    12                  printf("[%d] = %c\n", i++, *ps++);

標準入出力

C言語では標準入出力を用いることで,基本的な入出力を行うことができる. 通常,標準入力はキーボードであり,標準出力は端末画面(ウィンドウ)である. Cプログラムを実行したプロセスは,キーボードからの入力を標準入力から受け取ることができ,また標準出力への出力は端末画面(ウィンドウ)に表示される. UNIXのシェルは,標準入出力をリダイレクションやパイプによってファイルや他のプログラムに切り替えることができる. この機能により,ファイルアクセスなしに様々な入出力が可能になっている.

標準入力から,文字,行,書式付の入力を行うライブラリ関数として以下のものがある.

int    getchar(void);
char * gets(char *s);
int    scanf(const char *format, ...);

また標準出力に対し,文字,行,書式付の出力を行うライブラリ関数として以下のものがある.

int    putchar(int c);
int    puts(const char *s);
int    printf(const char *format, ...);

これらのうち gets は,後で述べるバッファオーバーフローの原因となる脆弱性を持つため,使用するべきではない. gets の代わりには,入力バッファの大きさを指定できる fgets を使用すべきである.
scanf も,%s のような書式指定を用いると,バッファオーバーフローに対する脆弱性を持つ.

fgetsに対応する文字,行,書式付きの入出力を行うライブラリ関数には以下のものがある.

int    fgetc(FILE *stream);
char * fgets(char *s, int size, FILE *stream);
int    fscanf(FILE *stream, const char *format, ...);
int    fputc(int c, FILE *stream);
int    fputs(const char *s, FILE *stream);
int    fprintf(FILE *stream, const char *format, ...);

stream に stdin と書くと標準入力になり,stdout と書くと標準出力になる. つまり fgetc, fgets では stdin,fputc, fputs, fprintf では stdout を指定する.

標準入出力には,標準出力である stdout の他に,標準エラー出力と呼ばれるもう一つ出力の口がある. 標準エラー出力は stream に stderr と書くことで指定できる. 標準エラー出力は,エラーメッセージや警告のメッセージなど例外的な処理に関するメッセージを出力するために使用される.

以下は getchar により標準入力から1文字読み込み,putchar により標準出力へ1文字書き出すプログラムである.

     1  #include <stdio.h>
     2
     3  main()
     4  {
     5          int     c;
     6
     7          while ((c = getchar()) != EOF)
     8                  putchar(c);
     9  }

これをコンパイル実行すると以下のような結果が得られる.

% ./a.out [←]
1234567890 [←]
1234567890
abcdefg [←]
abcdefg
[C-D]
%

fgetc, fputc を使用すると以下のプログラムのようになる. 結果は,getchar, putchar を使用した場合と,全く同じになる.

     1  #include <stdio.h>
     2
     3  main()
     4  {
     5          int     c;
     6
     7          while ((c = fgetc(stdin)) != EOF)
     8                  fputc(c, stdout);
     9  }

以下は fgets により標準入力から1行読み込み,puts により標準出力へ1行書き出すプログラムである.

     1  #include <stdio.h>
     2  
     3  #define LINE_LEN        80
     4  
     5  main()
     6  {
     7          char    line_buf[LINE_LEN];
     8  
     9          while (fgets(line_buf, LINE_LEN, stdin) != NULL)
    10                  puts(line_buf);
    11  }

これをコンパイル実行すると以下のような結果が得られる.

% ./a.out [←]
1234567890[←]
1234567890

abcdefg[←]
abcdefg


[C-D]
%

fgets は改行文字もバッファに読み込む. そして puts が文字列を出力した後に改行も出力する仕様のため,改行が2回出力されてしまい,余計な空行が出てしまう. 使用するべきではない gets は行末の改行文字をバッファに読み込まないため,このような問題は生じなかった(しかし1行の文字数がバッファの大きさを越えてしまうと,もっと大きな問題が生じてしまう).

以下のプログラムように puts の代わりに fputs を使用すると,fputs は単にバッファ内の文字列を書き出すだけの仕様のため,上記の問題はなくなる.

     1  #include <stdio.h>
     2  
     3  #define LINE_LEN        5
     4  
     5  main()
     6  {
     7          char    line_buf[LINE_LEN];
     8  
     9          while (fgets(line_buf, LINE_LEN, stdin) != NULL)
    10                  fputs(line_buf, stdout);
    11  }

文字,文字列操作ライブラリ

文字操作

文字操作のライブラリ関数には,以下の大文字又は小文字へ変換する関数と,

int toupper (int c);    /* 大文字へ変換 */
int tolower (int c);    /* 小文字へ変換 */

以下の文字の種類を判別する関数がある.

int isalnum (int c);    /* 英字又は数字? */
int isalpha (int c);    /* アルファベット? */
int isascii (int c);    /* アスキー文字? */
int isblank (int c);    /* 空白文字(スペース又はタブ)? */
int iscntrl (int c);    /* 制御文字? */
int isdigit (int c);    /* 数字? */
int isgraph (int c);    /* 表示可能?(スペースは含まれない) */
int islower (int c);    /* 小文字? */
int isprint (int c);    /* 表示可能?(スペースを含む) */
int ispunct (int c);    /* 表示可能?(スペースと英数字を除く) */
int isspace (int c);    /* 空白文字?(スペース,タブ,改行文字など) */
int isupper (int c);    /* 大文字? */
int isxdigit (int c);   /* 16進数での数字?(0〜9, a〜f, A〜F) */

以下のプログラムは小文字を大文字へ,大文字は小文字へ変換する. o

     1  #include <stdio.h>
     2  #include <ctype.h>
     3
     4  main()
     5  {
     6          int     c;
     7
     8          while ((c = getchar()) != EOF) {
     9                  if (islower(c))
    10                          c = toupper(c);
    11                  else if (isupper(c))
    12                          c = tolower(c);
    13                  putchar(c);
    14          }
    15  }

これをコンパイル実行すると以下のような結果が得られる.

% ./a.out [←]
a[←]
A
b[←]
B
abcdefgABCDEFG[←]
ABCDEFGabcdefg
[C-D]
%

文字列操作

string(3) に文字列操作のためのライブラリ関数一覧がのっている(man 3 stringで表示可能). 比較的良く使われる関数について解説する.

man 3 string を見ると,関数のリストに含まれて

#include <strings.h>
#include <string.h>

とでている. 同じことは,個別の関数のマニュアルページにもでている. これは,これらの関数を使用する時には strings.h 又は string.h をインクルードしなさいという意味である. 指定されたヘッダファイルをインクルードしないと,エラーによりコンパイルできない場合,又は関数が正常に動作しない場合がある.

文字列の長さ

size_t strlen(const char *s);

strlen は文字列の長さを戻り値として返す. 文字列の終端文字 0 は,文字列の長さには含まれない. そのため,strlen("abc") は 3 を返す.

以下のプログラムは, getchar, putchar を用いた小文字を大文字へ,大文字は小文字へ変換するプログラムを,fgets, fputs を用いて行ごとの入出力にすると,文字列に対し,大文字,小文字の変換をするようになる. 文字列に含まれる文字を先頭から見ていくためには,文字列の長さが必要になるため,strlen を使用する.

     1  #include <stdio.h>
     2  #include <ctype.h>
     3  #include <string.h>
     4
     5  #define LINE_LEN        80
     6
     7  main()
     8  {
     9          int     i, len;
    10          char    line_buf[LINE_LEN];
    11          char    *p;
    12
    13          while (fgets(line_buf, LINE_LEN, stdin) != NULL) {
    14                  len = strlen(line_buf);
    15                  p = line_buf;
    16                  for (i = 0; i < len; i++, p++) {
    17                          if (islower(*p))
    18                                  *p = toupper(*p);
    19                          else if (isupper(*p))
    20                                  *p = tolower(*p);
    21                  }
    22                  fputs(line_buf, stdout);
    23          }
    24  }

(実際は,終端文字であるかどうか検査しながら文字の変換をした方がプログラムとしては速い.→練習問題(13)

文字列の比較

int    strcmp(const char *s1, const char *s2);
int    strncmp(const char *s1, const char *s2, size_t n);
int    strcasecmp(const char *s1, const char *s2);
int    strncasecmp(const char *s1, const char *s2, size_t n);

これらは2つの文字列 s1 と s2 を比較し,
条件 戻り値
s1
<
s2 0より小さい数
s1
==
s2 0
s1
>
s2 0より大きい数
という結果を返す. 文字の大小関係はASCIIコードでの大小である.

strncmp, strncasecmp は s1 の先頭 n 文字についてのみ,比較を行う.

strcasecmp, strncasecmp は大文字,小文字を区別せずに(例えば A と a は同じとみなして)比較する.

文字や文字列の検索

char * strchr(const char *s, int c);
char * strrchr(const char *s, int c);
char * index(const char *s, int c);
char * rindex(const char *s, int c);
char * strstr(const char *haystack, const char *needle);

strchr, index は,文字列 s を先頭から探して最初に c の文字が現れたところへのポインタを返す.

strrchr, rindex は,文字列 s を最後尾から探して最初に c の文字が現れたところへのポインタを返す.

strstr は,文字列 haystack を先頭から探して,最初に needle が見つかったところへのポインタを返す.

どれも,見つからなかった場合は NULL が戻り値になる.

以下のプログラムは,ファイルへのパスの構成要素を切り出して出力する.

     1  #include <stdio.h>
     2  #include <string.h>
     3  #include <sys/param.h>
     4
     5  main()
     6  {
     7          int     i;
     8          char    line_buf[MAXPATHLEN];
     9          char    *p, *np;
    10
    11          while (fgets(line_buf, MAXPATHLEN, stdin) != NULL) {
    12                  i = 0;
    13                  p = line_buf;
    14                  while ((np = index(p, '/')) != NULL) {
    15                          *np = '\0';
    16                          printf("%d: %s\n", i++, p);
    17                          p = np + 1;
    18                  }
    19                  printf("%d: %s\n", i, p);
    20          }
    21  }

8行目の MAXPATHLEN は,システムで定義されているパス名の最大長である. MAXPATHLEN は sys/param.h (3行目)で定義されている. これをコンパイル実行すると以下のような結果が得られる.

% ./a.out [←]
dir0/dir1/dir2/dir3/file[←]
0: dir0
1: dir1
2: dir2
3: dir3
4: file

[C-D]
%

文字列のコピー,連結

char * strcpy(char *dest, const char *src);
char * strncpy(char *dest, const char *src, size_t n);

strcpy は,src の文字列を終端文字 0 も含めて dest にコピーする. strncpy は最大 n 文字コピーする. strncpy は,コピーした n 文字に終端文字 0 が含まれるかどうかはチェックしない. しかし,src の文字列が n 文字よりも短かった場合,dest の残りの部分は 0 で埋める.

char * strcat(char *dest, const char *src);
char * strncat(char *dest, const char *src, size_t n);

strcat は,src の文字列を dest の文字列の後に(dest の終端文字のところから)コピーし,最後に終端文字 0 を追加する. strncat は,src の文字列を n 文字だけコピーするところが,strcat とは異なる.

strcat, strcpy は,コピー先の大きさを指定できないため,バッファオーバーフローに対する脆弱性を持つ.

その他の文字列操作関数

char * strdup(const char *s);                        /* 文字列の複製 */
char * strfry(char *string);                         /* 文字列のランダム化 */
char * strsep(char **stringp, const char *delim);    /* トークンの切り出し */
char * strtok(char *s, const char *delim);           /* トークンへの分解 */
size_t strcspn(const char *s, const char *reject);   /* 文字セットに含まれない文字数 */
char * strpbrk(const char *s, const char *accept);   /* 文字セットに含まれる文字の検索 */
size_t strspn(const char *s, const char *accept);    /* 文字セットに含まれる文字数 */
int    strcoll(const char *s1, const char *s2);      /* ロケールに基づく文字列比較 */
size_t strxfrm(char *dest, const char *src, size_t n);  /* ロケールに基づいた文字列変換 */

文字列と数値の変換

getcharやfgetsで数字を入力として受け取っても,それは文字または文字列としての入力である. '1' は文字定数であり,その値は 0x31 であり 1 ではない. "123" という文字列は 0x31, 0x32, 0x33 という文字の並びであり,123 という数値とは異なる. 表示通りの数値の値で計算するためには,そのように変換する必要がある. また 123 という数値はそのままでは表示することができず,表示するためには '1', '2', '3' という文字の並び,又は "123" という文字列に変換する必要がある.

以下は文字列を数値に変換してくれるライブラリ関数である. sscanf は scanf と同じバッファオーバーフローに対する脆弱性を持つので,使用には注意が必要である.

long int          strtol(const char *nptr, char **endptr, int base);
unsigned long int strtoul(const char *nptr, char **endptr, int base);
double            strtod(const char *nptr, char **endptr);
long              atol(const char *nptr);
int               atoi(const char *nptr);
double            atof(const char *nptr);
int               sscanf(const char *str, const char *format, ...);

数値を文字列に変換するライブラリ関数として sprintf, snprintf が使用できる. sprintf は,非常に注意深く使用しなければ,バッファオーバーフローを起こす危険がある. snprintf は書き出す最大文字数を指定できるので,sprintf に代わって常に snprintf を使用すべきである. しかし snprintf も,古い UNIX や Linux では snprintf は sprintf を呼び出しているだけのことがあり,安全ではないことがあるので注意が必要である.

int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const  char  *format, ...);

ファイルアクセス

ファイルアクセスをする方法には,システムコールを用いる方法と,ライブラリ関数を用いる方法がある. どちらにせよ手順は同じで,アクセスしたいファイルを開き (open) ,読み書き (read, write) を行い,最後に閉じる (close).

ライブラリを用いたファイルとの入出力

ライブラリを用いてファイルアクセスを行うために,ファイルを開き,閉じるためには fopen, fclose を用いる. 読み書きのためには,実は標準入出力で述べた関数のうち,先頭に f がついている関数が使用できる. 引数で指定できる FILE *stream を,stdin, stdout の代わりに,fopen の戻り値として得られる値にすればよい. ファイルからの入出力に便利なように fread, fwrite という関数も用意されている(これらも標準入出力で用いることができる).

FILE * fopen(const char *path, const char *mode);
int    fclose(FILE *stream);
size_t fread(void  *ptr,  size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t  nmemb, FILE *stream);

FILE構造体

ファイルを開くために使用する fopen の戻り値の型は FILE * (FILE構造体へのポインタ)である. 1文字入力の fgetc は,読み込み先として引数に FILE * 型の変数を指定し,1文字出力の fputc は引数に文字の他に書き込み先として FILE * 型の変数を指定する.

つまり,ライブラリを用いてファイルアクセスを行うためには,FILE構造体へのポインタ(通称,ファイルポインタ)が,ファイルの代理人のようなものになる.

FILE構造体には,読み書きしているファイル,ファイルに対し許される操作(読み込み,書き込み,又は両方),現在ファイルのどの部分をアクセスしているのか,エラーは起きていないか,などの情報を持つ. ファイルポインタは,このデータ構造体へのポインタになっており,入出力にあたってファイルポインタを指定することで,読み書きを行うことができるようになっている.

ファイルのコピー

fgetc, fputc を用いて標準入力から読み込み,標準出力へ書き出すプログラムは,fopen, fclose を前後に入れることで,簡単にファイルのコピーを行うプログラムになる.

     1  #include <stdio.h>
     2
     3  main()
     4  {
     5          int     c;
     6          FILE    *src, *dst;
     7
     8          src = fopen("src", "r");
     9          if (src == NULL) {
    10                  perror("src");
    11                  exit(1);
    12          }
    13
    14          dst = fopen("dst", "w+");
    15          if (dst == NULL) {
    16                  perror("dst");
    17                  fclose(src);
    18                  exit(1);
    19          }
    20
    21          while ((c = fgetc(src)) != EOF)
    22                  fputc(c, dst);
    23
    24          fclose(src);
    25          fclose(dst);
    26  }

8行目の fopen では,読込元のファイルとして "src" ,オープン時のモードとして "r" を指定している. モードは,オープンされたファイルに対しこの後許される操作,及びオープン時のどこから読み書きが始まるかを決定する. "r" は,ファイルを読み込みのためにオープンすることを意味し,ファイルの先頭から読み込みが始まる.

14行目の fopen では,書き込み先のファイルとして "dst" ,オープン時のモードとして "w+" を指定している. "w+" は,読み書きのためにファイルをオープンすることを意味し,ファイルが存在していなかった場合は新たに作られ,存在していた場合はファイルの長さは 0 にされる. また,ファイルの読み書きはファイルの先頭から始まる.

他にモードとして指定できる文字列には,r+, w, a, a+ (つまり rwa の文字のいずれか,又はそれに + を付けたもの)がある. これらの意味は FOPEN(3) を参照(man fopen).

9〜12行目,15〜19行目は fopen に失敗した時の処理である. どちらも基本的に同じで,perror によりエラーメッセージを出力した後に,exit によりプログラムの実行を終了させている. exit の引数は,プロセスの終了を待っている親プロセスに渡されるが,この詳細は来週に開設する予定.

15〜19行目の処理には,"src" ファイルへのファイルポインタをクローズする fclose が入っている(17行目). エラー処理で exit してしまう場合は,全てのファイルは自動的にクローズされ,領域も開放されてしまうため,この fclose は実際は必要ない. しかし exit しない場合には,同時にオープンできるファイルには制限があり,またメモリリークが起きないようにするためにも,関連するファイルポインタを確実にクローズすることは重要である.

main関数の引数

ファイルのコピーなどのコマンドのプログラムを作る時には,コピーするファイル名をコマンド行の引数として渡せると便利である. このようなプログラム起動時の引数は,main 関数への引数として渡される. 以下のプログラムは,コマンド行の引数を出力する.

     1  #include <stdio.h>
     2
     3  main(int argc, char *argv[])
     4  {
     5          int     i;
     6
     7          for (i = 0; i < argc; i++)
     8                  puts(argv[i]);
     9  }

これをコンパイル実行すると以下のような結果が得られる.

% ./a.out src dst [←]
./a.out
src
dst
%

上記プログラムの3行目の main 関数の引数として argc, argv が指定されており, argc は int 型,argv は char * 型の配列(char 型へのポインタを格納する配列)である. argc は argument count,argv は argument vector の略である. main 関数の引数の名前は argc, argv である必要性はないが,慣例的に argc, argv が使われてきており,この名前で使うことがプログラムのわかりやすさの点でも望ましい.

argc にはコマンド行の文字列の個数が入る(コマンド名+引数). 上記の実行例では,argc の値は 3 になる.

argv は,コマンド行の文字列それぞれへのポインタを格納した配列である. 下図は,上記の実行例における argv の構造を図示したものである. argv[0] はコマンド名の文字列 "./a.out" , argv[1] は最初の引数 "src" , argv[2] は次の引数 "dst" , そして argv[3] 即ち argv[argc] にはNULLポインタ(要するに 0 )が格納される.

main関数の引数

ライブラリ関数を用いてファイルのコピーを行うプログラムを,コピーするファイル名をコマンド行の引数として渡せるようにすると次のようなプログラムになる.

     1  #include <stdio.h>
     2
     3  main(int argc, char *argv[])
     4  {
     5          int     c;
     6          FILE    *src, *dst;
     7
     8          if (argc != 3) {
     9                  printf("Usage: %s from_file to_file\n", argv[0]);
    10                  exit(1);
    11          }
    12
    13          src = fopen(argv[1], "r");
    14          if (src == NULL) {
    15                  perror(argv[1]);
    16                  exit(1);
    17          }
    18
    19          dst = fopen(argv[2], "w+");
    20          if (dst == NULL) {
    21                  perror(argv[2]);
    22                  fclose(src);
    23                  exit(1);
    24          }
    25
    26          while ((c = fgetc(src)) != EOF)
    27                  fputc(c, dst);
    28
    29          fclose(src);
    30          fclose(dst);
    31  }

argc, argv を解釈する場合は,引数の数をチェックすることが大切である. 上記のプログラムでは 8〜11行目で,引数の数が 3 でなければ,そのプログラムの使い方を表示し,プログラムを終了するようにしている.

これをコンパイル実行すると以下のような結果が得られる.

% ./a.out [←]
Usage: ./a.out from_file to_file
% ./a.out src_file dst [←]
src_file: No such file or directory
% ./a.out src dst [←]
%

システムコールを用いたファイルとの入出力

システムコールを用いてファイルアクセスを行うために,ファイルを開き,閉じるためには open, close を用いる. 読み書きのためのシステムコールは read, write を用いる. ライブラリ関数と異なり,read, write 以外には読み書きのためのシステムコールはない.

int     open(const char *pathname, int flags);
int     open(const char *pathname, int flags, mode_t mode);
int     close(int fd);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

ファイルアクセスのためのシステムコールは上記の open, close, read, write である. ライブラリ関数もファイルアクセスを行うために,最終的にはシステムコールを使用する. 従って,fopen は open を呼び,fclose は close を呼ぶ. 入力のための getchar, fgetc, fgets, fread は read を呼び,出力のための putchar, fputc, fputs, fwrite は write を呼ぶ.

ファイルディスクリプタ

fopen は FILE 構造体へのポインタを返し,その後オープンしたファイルへのアクセスのためにはそのポインタを用いる. open はオープンに成功すると 0 以上の整数を返す. その整数は,ファイルディスクリプタ(ファイル記述子)と呼ばれる. システムコールを用いてファイルアクセスを行う場合は,open によりファイルをオープンした後は,戻り値のファイルディスクリプタを指定することにより,そのファイルに対し読み書きを read, write を用いて行うことができる. ファイルアクセスが終了したら,ファイルディスクリプタを引数に close を呼ぶことで,そのファイルをクローズできる.

あるプロセスが一時にオープンできるファイルの数(ファイルディスクリプタの最大値)は制限されている. 昔の UNIX では非常に少なかったが,現在はかなり多くのファイルを一時にオープンすることができる. Linux では /proc/sys/fs/file-max に設定されている値が最大値であるが,通常のプロセスは 1024 に設定されている.

標準入出力のファイルディスクリプタ

ライブラリ関数による入出力も結局はシステムコールを呼び出すことで実現されている. ということは,標準入出力(stdin, stdout, stderr)もシステムコールを通して入出力が処理されている. システムコールを通した入出力は,ファイルディスクリプタにより入出力先が指定されるため,標準入出力がなければおかしいことになる.

標準入出力のためのファイルディスクリプタとして 0, 1, 2 が割り当てられている. 0 が標準入力,1 が標準出力,2 が標準エラー出力に対応する. これら3つのファイルディスクリプタは,プログラムが実行される時に明示的にオープンしなくても,使える状態になっている.

ファイルのコピー

ライブラリ関数を用いてファイルのコピーを行うプログラムを,システムコールを用いるように書き換えると以下のプログラムのようになる.

     1  #include <stdio.h>
     2  #include <fcntl.h>
     3
     4  main()
     5  {
     6          char    c;
     7          int     src, dst;
     8          int     count;
     9
    10          src = open("src", O_RDONLY);
    11          if (src < 0) {
    12                  perror("src");
    13                  exit(1);
    14          }
    15
    16          dst = open("dst", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    17          if (dst < 0) {
    18                  perror("dst");
    19                  close(src);
    20                  exit(1);
    21          }
    22
    23          while ((count = read(src, &c, 1)) > 0) {
    24                  if (write(dst, &c, count) < 0) {
    25                          perror("write");
    26                          exit(1);
    27                  }
    28          }
    29
    30          if (count < 0) {
    31                  perror("read");
    32                  exit(1);
    33          }
    34
    35          close(src);
    36          close(dst);
    37  }

プログラムの流れは,ライブラリ関数を用いた場合と同じである. open への引数は,オープンしたいファイル名と,ファイルをオープンするモードである. O_RDONLY は読み込みのみ,O_WRONLY は書き込みのみである. 読み書きの場合は O_RDWR を指定する. O_RDONLY, O_WRONLY, O_RDWR と一緒に設定できるモードがいくつかある. O_CREAT がセットされていると,ファイルが存在しなかった場合は作成する. O_TRUNC がセットされていると,ファイルが既に存在した場合はファイルの長さは 0 にされる. モードに O_CREAT が含まれる場合は,ファイルが作られた場合に設定するパーミッションが3番目の引数になる. 実際に設定されるパーミッションは,umask のマスクがかかった値になる.

read は読み込める最大バイト数,write は書き込める最大バイト数を指定するが,それぞれ指定されただけの最大バイト数を読み込み又は書き込みできるとは限らない. 実際に読み書きされたバイト数が read, write の戻り値として返される.

read, write で指定する読み書きの最大バイト数は,大きいほうが read, write システムコールの呼び出しとコピーの回数が少なくてすむので,より効率的である. つまり上記のプログラムのように 1 とするのは,非常に効率が悪い. 通常 1024, 4096, 8192 などの値が用いられるが,最も効率の良い値は入出力先のデバイス,デバイスを制御するコントローラ,メモリの量などに依存する.(→練習問題(16)

ライブラリとシステムコールの混在

同一のファイルに対するアクセスをライブラリとシステムコールで混ぜて行うことは,プログラミング上は可能ではあるが,結果がおかしくなる可能性があるので,避けるべきである. ライブラリ関数での入出力は,1文字単位の入出力も効率よく行えるように,入出力データを一時的に蓄えるバッファリングをすることで,システムコールの回数を減らしている. 混ぜて使うと,バッファリングされたデータとの整合性が取れなくなってしまう.

どうしても,どちらかからどちらかへ変換したい時には,以下の関数を使用することができる. fileno, fdopen は,十分なテストをするなど何をしているのか理解するよう注意して使用すべきで,安易に使用すべきではない.

fflush は,上記の目的とは関係なく,バッファリングされたデータをフラッシュしたい時にも使用できる.

int  fileno(FILE *stream);            /* FILE構造体のファイルディスクリプタを返す */
FILE *fdopen(int fildes, char *mode); /* ファイルディスクリプタからFILE構造体を作る */
int  fflush( FILE *stream);           /* バッファリングされたデータをフラッシュ */

バッファオーバーフロー

バッファオーバーフローはプログラムの実行(プロセス)を乗っ取るため,悪意を持った攻撃者によって引き起こされる. バッファオーバーフローを起こすことにより,攻撃者は任意のプログラムを送り込み,それを実行することができてしまい,プロセスは乗っ取られてしまう.

バッファオーバーフローがどうして起こるのか理解するためには,プロセスが実行時に用いられるスタックの構造について理解する必要がある.

下図は,実行中のプロセスのメモリ空間においてどのようにデータが配置されているかを図示したものである. 通常,最も下位アドレスに機械語命令が入ったテキスト領域が置かれる. その上にデータ領域が置かれる. データ領域とは別に,関数呼び出し時の戻りアドレス(リターンアドレス)や関数のローカル変数が格納されるスタック領域がある. データ領域はデータ割り当てが起こるたびに上位アドレスに向かって伸びていく. スタック領域は逆に,関数呼び出しが起こるたびに下位アドレスに向かって伸びていく.

プロセスのアドレス空間

スタック領域をより詳細に図示すると下図のようになる. 関数呼び出しが起こると,リターンアドレスがスタックにプッシュされ,呼び出された関数で使用するローカル変数のための領域が確保される. リターンアドレスとローカル変数のための領域を合わせてスタックフレームと呼ぶ.

バッファオーバーフロー

スタックフレーム中のローカル変数に配列が含まれていると,配列の添字が小さいほうが下位アドレスにあり,大きいほうが上位アドレスにくる. 上の図で buf がポインタとして例えば gets に渡されると,buf[0] から上位アドレスに向かって書き込まれていく. 64文字以上の文字が書き込まれると,buf[63] を超えて書き込まれ,buf として割り当てられた領域を超えて書き込まれてしまうことになる. さらに書き込まれると,リターンアドレスも書き換えられてしまう. スタック領域にうまくプログラムを書き込み,リターンアドレスをそのプログラムの開始アドレスに設定してあげると,関数が戻る時に書き込んだプログラムが実行されてしまう.

このような攻撃方法をスタックスマッシング (stack smashing) と呼ぶ.

練習問題

練習問題(11)

文字列のところにあるプログラムを変更し,文字列定数の内容を変更すると,セグメンテーションフォルトが発生することを確かめよ.

また,cc のオプションに -fwritable-strings を与えると文字列定数の内容を変更が可能になることも確かめよ.

練習問題(12)

標準入出力を用いるライブラリ関数のところにある fgets,puts を用いたプログラムの LINE_LEN を 5 にして,コンパイル実行すると以下のような結果になってしまう. どうしてこのような結果になってしまうのか,その理由を調べよ.

% ./a.out [←]
1234567890[←]
1234
5678
90

abcdefg[←]
abcd
efg

[C-D]
%

練習問題(13)

strlen のところにある文字列に含まれる小文字を大文字へ,大文字は小文字へ変換するプログラムを,strlen を使用せず,終端文字であるかどうかを検査しながら文字の変換をするプログラムに書き換えなさい.

練習問題(14)

文字,文字列の検索のところにあるプログラムは,先頭に / がある場合をうまく扱えず,実行結果は次のようになってしまう. 先頭の / は,パスの構成要素であると認識するように,変更せよ.

% ./a.out [←]
/dir1/dir2/dir3/file[←]
0:
1: dir1
2: dir2
3: dir3
4: file

[C-D]
%

練習問題(15)

練習問題(14)で取り上げたプログラムは,末尾が / で終わる場合もうまく扱えない. 末尾の / は無視するように,変更せよ.

% ./a.out [←]
dir0/dir2/dir3/[←]
0: dir0
1: dir2
2: dir3
3:

[C-D]
%

練習問題(16)

システムコールを用いてファイルコピーを行うプログラムのコピー元のファイルをサイズが大きなものに変更し,バッファサイズをいろいろ変えて実行時間がどのように変化するか実験せよ.

プログラムの実行時間は time コマンドを用いて計測することができる. 以下の実行例では,ユーザ空間での実行に 1.05 秒,カーネルでの実行に 5.87秒,待ち時間も入れて合計で 6.96 秒かかったという結果である. 計測には,同じパラメータで何度か実行,計測し,その平均値をとるなどすると,より信頼性の高い値を得ることができる.

% time ./a.out [←]
1.050u 5.870s 0:06.96 99.4%     0+0k 0+0io 77pf+0w
%

実験後は,コピーした大きなファイルは消去すること!!

練習問題(17)

ライブラリを用いてファイルコピーを行うプログラムで,コピー元のファイルをサイズが大きなものを指定し,fgets, fputs 又は fread, fwrite を用いて入出力を行い,バッファサイズをいろいろ変えて実行時間がどのように変化するか実験せよ.

結果は,システムコールの場合と比較してどのようになっただろうか? 違いについて,考察せよ.

練習問題(18)

以下のように,シェルのように1行入力を受け取り,コマンド名と入力のリダイレクション記号「<」があればその後のファイル名を表示し,そうでなければ入力として console と出力するプログラムを作りなさい. 入力行は,コマンド名だけ,または「コマンド名 < ファイル名」(< の前後にスペース1つ)という形式だけに対応すればよい.

% ./a.out [←]
command[←]
command name: command
input: console
command < file[←]
command name: command
input: file
%

練習問題(19)

練習問題(18)のプログラムを変更し,出力のリダイレクション記号「>」にも対応できるようにせよ. リダイレクション記号の出現順序は入力「<」の後に出力「>」が現れる場合にのみ対応すればよい.

練習問題(20)

練習問題(19)のプログラムを変更し,同じリダイレクション記号が複数出てきたら,エラーメッセージが出力されるようにせよ.

練習問題(21)

練習問題(20)のプログラムを変更し,同じリダイレクション記号が出現順序が変わっても対応できるようにしなさい.

練習問題(22)

練習問題(21)のプログラムを変更し,スペースがコマンド,リダイレクション記号,ファイル名の周りに入っていなくても,または複数個入っていても,同じように処理できるようにしなさい.