こんにちは。
プログラミングの勉強のために筑波大の講義「システムプログラミング」をやってみようと思います。
このエントリは完全な個人のメモです。
筑波大の講義ページはこちら 。
お勉強のためにこの本を読んでいるので、内容を覚えるためにSummarizing(サマライジング)を行います。
正直、講義ページがすでにかなり簡潔にまとまっているのでほぼコピペになってしまう気もしています。
第2回の講義内容は以下。
amistad06-a.hatenablog.com
ファイルアクセス
ファイルにアクセスするにはシステムコールかライブラリ関数を使用する。
ライブラリ関数の内部ではシステムコールを使用している。
ライブラリ関数を用いたファイルの入出力
fopen
引数で指定された名前のファイルに繋がるストリームを開く。
読み込み専用、書き込み専用、追記などを指定することができる。
fclose
引数で指定された名前のファイルに繋がるストリームを閉じる。
fopneしたら必ずfcloseする。
fgetc
引数で指定されたストリームから1byte読み込む。
fputc
引数で指定されたストリームに1byte書き込む。
FILE構造体
fopenの戻り値はFILE構造体へのポインタ。
FILE構造体は以下のような情報を持つ。
・ファイルディスクリプタ
・ファイルに対して許可されている操作(読み込み、書き込みなど)
・バッファ
など
以下のプログラムはライブラリ関数を使用したファイルのコピープログラム。
srcというファイルをdstというファイルにコピーする。
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int c;
FILE *src, *dst;
// コピー元のファイルを読み込み専用で開く
src = fopen("src", "r");
if (src == NULL) {
// 失敗した場合はプログラムを終了
perror("src");
exit(1);
}
// コピー先のファイルを書き込み専用で開く
dst = fopen("dst", "w");
if (dst == NULL) {
// 失敗した場合はプログラムを終了
perror("dst");
// すでに開いているストリームを閉じるのを忘れずに
fclose(src);
exit(1);
}
// コピー元のファイルから1byteずつ読み込む
// ファイルの最後に到達するまで繰り返す
while ((c = fgetc(src)) != EOF) {
// 読み込んだbyteをコピー先のストリームに書き込む
fputc(c, dst);
}
// 開いているストリームをすべて閉じてプログラムを終了
fclose(src);
fclose(dst);
return 0;
}
main関数の引数
main関数の引数は、プログラム起動時のコマンドラインの文字列が配列で渡される。
以下はコマンドライン引数を表示するプログラム。
#include <stdio.h>
int main(int argc, char *argv[])
{
int i;
// argcはコマンドライン引数の数
// argvはコマンドライン引数の文字列の配列
for (i = 0; i < argc; i++) {
// コマンドライン引数を0から順に表示
puts(argv[i]);
}
return 0;
}
上記プログラムの実行結果。
$ ./a.out aaa bbb
./a.out
aaa
bbb $
実行ファイル名も引数に含まれている点に注意。
以下、コマンドライン引数でコピー元ファイルとコピー先ファイルを指定するプログラム。
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int c;
FILE *src, *dst;
// コマンドライン引数が3つ以外のときはプログラムを終了
if (argc != 3) {
fprintf(stderr, "Usage: %s from_file to_file\n", argv[0]);
exit(1);
}
// コマンドライン引数で渡されたコピー元ファイルを開く
src = fopen(argv[1], "r");
if (src == NULL) {
// 開くのに失敗したらプログラムを終了
perror(argv[1]);
exit(1);
}
// コマンドライン引数で渡されたコピー先ファイルを開く
dst = fopen(argv[2], "w");
if (dst == NULL) {
// 開くのに失敗したらプログラムを終了
perror(argv[2]);
// すでに開いているストリームを閉じる
fclose(src);
exit(1);
}
// コピー元ファイルから1byte読み込む
// ファイルの最後に到達するまで繰り返す
while ((c = fgetc(src)) != EOF) {
// コピー先ファイルに1byte書き込む
fputc(c, dst);
}
// 開いているストリームを閉じる
fclose(src);
fclose(dst);
return 0;
}
システムコールを用いたファイルの入出力
ファイルディスクリプタ(ファイル記述子)
ストリームの特定に使用される整数の値。
1つのストリームには 1つのユニークな整数値が割り当てられる。
標準入力、標準出力、標準エラー出力は常に固定の値が割り振られている。
0 : 標準入力
1 : 標準出力
2 : 標準エラー出力
以下、システムコールを用いて作成したファイルをコピーするプログラム。
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
char c;
int src, dst;
int count;
// コピー元のファイルを開く
src = open("src", O_RDONLY);
if (src < 0) {
// 開くのに失敗したらプログラムを終了
perror("src");
exit(1);
}
// コピー先のファイルを開く
// 引数はそれぞれ書き込み専用、ファイルが存在しなかったら作成
// ファイルがすでに存在している場合、長さを0にする
// 0666はファイル作成時のパーミッション
// 詳細はman参照
dst = open("dst", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (dst < 0) {
// 開くのに失敗したらプログラムを終了
perror("dst");
// すでに開いているストリームを閉じる
close(src);
exit(1);
}
// コピー元ファイルから1byte読み込む
// 読み込んだbyteが0以下になったらループを終了
while ((count = read(src, &c, 1)) > 0) {
// 読み込んだ1byteをコピー元ファイルに書き込む
if (write(dst, &c, count) < 0) {
// 書き込みに失敗したらプログラムを終了
perror("write");
exit(1);
}
}
// readが失敗した場合、プログラムを終了
if (count < 0) {
perror("read");
exit(1);
}
// 開いているストリームを閉じる
close(src);
close(dst);
return 0;
}
ライブラリとシステムコールの混在
ライブラリ関数とシステムコールを同時に使用することは可能ではあるが、基本的には避けるべきである。
システムコールは基本的に即時ストリームに対して書き込みを行うが、ライブラリ関数は内部にバッファを持っており、バッファがある程度溜まってからまとめて書き込みを行う。
そのため、書き込みの順序がおかしくなる可能性がある。
ファイルのランダムアクセス
大きなデータやファイルを扱うときはシーケンシャルアクセスではなくランダムアクセスをするようにすると効率がいい。
システムコールのread, writeでランダムアクセスをするにはlseekを使用する。
ライブラリ関数のfread, fwriteでランダムアクセスをするにはfseekを使用する。
これらはファイルを読み書きする位置(ファイルオフセット、シークポインタ、ファイルポインタなどといわれる)を操作するもの。
ファイルアクセス(応用)
以下のプログラムは自分でバッファを確保して効率を上げたファイルコピープログラム。
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
FILE *src, *dst;
void *buf;
int rcount, wcount;
// 引数の数が3以外の場合はプログラムを終了
if (argc != 3) {
printf("Usage: %s from_file to_file\n", argv[0]);
exit(1);
}
// コピー元のファイルを読み込み専用で開く
src = fopen(argv[1], "r");
if (src == NULL) {
// 開くのに失敗した場合はプログラムを終了
perror(argv[1]);
exit(1);
}
// コピー先のファイルを書き込み専用で開く
dst = fopen(argv[2], "w");
if (dst == NULL) {
// 開くのに失敗した場合はプログラムを終了
perror(argv[2]);
// すでに開いているストリームを閉じる
fclose(src);
exit(1);
}
// バッファを確保 // BUFSIZはstdio.hで定義されているマクロ // 標準的なバッファサイズ表す
buf = malloc(BUFSIZ);
if (buf == NULL) {
// 確保に失敗したらプログラムを終了
perror("malloc");
// すでに開いているストリームを閉じる
fclose(src);
fclose(dst);
exit(1);
}
// ファイルの終わりにたどり着くまで繰り返し
while (!feof(src)) {
// コピーファイル元からバッファに1×BUFSIZ byte読み込む
// 返り値は実際に読み込んだサイズ
rcount = fread(buf, 1, BUFSIZ, src);
if (ferror(src)) {
// 読み込みに失敗したらプログラムを終了
perror("fread");
// 開いているストリームを閉じる
fclose(src);
fclose(dst);
exit(1);
}
// バッファからコピー先ファイルに1×rcount byte書き込む
wcount = fwrite(buf, 1, rcount, dst);
if (ferror(dst)) {
// 書き込みに失敗したらプログラムを終了
perror("fwrite");
fprintf(stderr, "tried to write %d bytes, "
"but only %d bytes were written.\n",
rcount, wcount);
// 開いているストリームを閉じる
fclose(src);
fclose(dst);
exit(1);
}
}
// 開いているストリームを全て閉じてプログラムを終了
fclose(src);
fclose(dst);
return 0;
}
構造体
構造体を使うと、複数のデータをまとめて扱える。
例えば、住所録を作る場合は名前、住所、電話番号、メールアドレスなどを構造体としてまとめて扱えると便利。
構造体は以下のように記述する。
ここではentryという名前の構造体を定義している。
struct entry {
char name_family[32];
char name_first[32];
char addr[128];
int zip1;
int zip2;
char mail[128];
};
以下のプログラムはutmpを表示するもの。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <utmp.h>
int main(void)
{
FILE *fp;
struct utmp u;
// utmpに繋がるストリームを作成
fp = fopen(_PATH_UTMP, "r");
if (fp == NULL) {
// 失敗した場合、プログラムを終了
perror(_PATH_UTMP);
exit(-1);
}
// ストリームfpから(utmpのサイズ×1)読み込みbyte内部変数uに格納する
// 戻り地は読み込んだデータの個数
while (fread(&u, sizeof(u), 1, fp) == 1) {
// 終了したプロセスの情報はスキップ
if (u.ut_type != DEAD_PROCESS) {
// utmpの情報を表示
time_t t = u.ut_time;
printf("%8.8s|%16.16s|%8.8s|%s", u.ut_name,
u.ut_host, u.ut_line, ctime(&t));
}
}
// 開いているストリームを閉じる
fclose(fp);
return 0;
}
utmpとは
現在ログインしているユーザなどのログイン情報が記録されているファイル。
上記のプログラムを実行するとwhoコマンドど同じような結果が得られる。
データファイルのポータビリティ
構造体のデータをファイルに書き込んで、多くのコンピュータで共有したい場合は注意が必要。
・サイズ
例えば、32bitOSではint型は4byteであることが多いが、16bit環境では2byte、64bit環境では8byteであることも多い。
・バイトオーダ
リトルエンディアンとビッグエンディアンの環境が存在する。
・アライメント
データをメモリ上に格納するときに、プロセッサが処理しやすいように配置される。例えば、double型の4byteデータは4で割り切れる番地に配置されるなど。
ポータビリティが一番高いのは1byte毎にアクセスできるテキストファイル。
構造体の入出力と動的メモリ確保
以下のプログラムは、メモリを動的に確保してutmpを表示するプログラム。
whoコマンドと同じ出力結果が得られる。
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <time.h>
#include <utmp.h>
// 構造体を定義
struct utmplist {
struct utmplist *next;
struct utmp u;
};
int main(void)
{
FILE *fp;
struct utmplist *ulhead = NULL;
struct utmplist *ulprev, *ulp;
// umtpファイルを開く
fp = fopen(_PATH_UTMP, "r");
if (fp == NULL) {
// 開くなかった場合、プログラムを終了
perror(_PATH_UTMP);
exit(-1);
}
// 無限ループ
for (;;) {
// (構造体のサイズ×1)byteのメモリを確保
ulp = calloc(1, sizeof(struct utmplist));
if (ulp == NULL) {
// 確保に失敗っした場合はプログラムを終了
perror("calloc");
// 開いているストリームを閉じる
fclose(fp);
exit(-1);
}
// ストリームから(struct utmp × 1)byte分読み込んで
// 構造体のメンバに格納
if (fread(&ulp->u, sizeof(ulp->u), 1, fp) != 1) {
// 戻り値が1以外の場合は確保した領域を解放して
// ループを抜ける
free(ulp);
break;
}
if (ulhead == NULL) {
// 初回(リストの先頭が未定義)の場合、
// 最初に読み込んだutmpを先頭に設定
ulhead = ulp;
} else {
// 初回以降は読み込んだumtpを1つ前の要素の
// 「次の要素」に設定
ulprev->next = ulp;
}
// 次の要素を読み込む前に、今読み込んだ要素を取っておく
// 次の要素をnextに設定するため
ulprev = ulp;
}
// ストリームを閉じる
fclose(fp);
// ストリームから読み込むために使っていたポインタを
// リストの先頭から読み込むためのポインタとして使いまわす
ulp = ulhead;
// リストの要素がなくなるまで繰り返し
while (ulp) {
// すでに終了したプロセスはスキップ
if (ulp->u.ut_type != DEAD_PROCESS) {
// utmpの中身を出力
time_t t = ulp->u.ut_time;
printf("%8.8s|%16.16s|%8.8s|%s", ulp->u.ut_name,
ulp->u.ut_host, ulp->u.ut_line, ctime(&t));
}
// 出力した要素を一旦退避(メモリを解放するため)
ulprev = ulp;
// 次の要素に読み込みポインタを移す
ulp = ulp->next;
// 使わないメモリ(すでに出力したutmp)を解放
free(ulprev);
}
return 0;
}
上記のプログラムはすべての処理をmain関数の中で行っているので、関数分割したものが以下のプログラム。
実行結果は同じ。
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <time.h>
#include <utmp.h>
// 構造体を定義
struct utmplist {
struct utmplist *next;
struct utmp u;
};
struct utmplist *read_utmp(FILE *fp, struct utmplist *head)
{
struct utmplist *ulprev, *ulp;
// 無限ループ
for (;;) {
// (構造体のサイズ × 1)byteのメモリを確保
ulp = calloc(1, sizeof(struct utmplist));
if (ulp == NULL) {
// 失敗した場合、プログラムを終了
perror("calloc");
// 引数で受け取ったストリームを閉じる
fclose(fp);
exit(-1);
}
// ストリームから(構造体のサイズ×1)byte読み込んで
// 構造体のメンバに格納
if (fread(&ulp->u, sizeof(ulp->u), 1, fp) != 1) {
// 戻り地が1以外だったら確保したメモリを解放して
// ループを抜ける
free(ulp);
break;
}
if (head == NULL) {
// ループ初回ならリストの先頭ポインタに
// 読み込んだutmpのアドレスを設定
head = ulp;
} else {
// 初回以降は前の要素のnextに
// 読み込んだumtpのアドレスを設定
ulprev->next = ulp;
}
// 読み込んだumtpのアドレスを退避
// (次の要素へのリンクを設定するため)
ulprev = ulp;
}
// 全て読み込み終わったらリストの先頭アドレスを返す
return head;
}
void write_utmp(FILE *fp, struct utmplist *head)
{
struct utmplist *ulprev;
struct utmplist *ulp = head;
// リストが最後に辿り着くまで繰り返し
while (ulp) {
// 終了したプロセス以外の情報を表示
if (ulp->u.ut_type != DEAD_PROCESS) {
time_t t = ulp->u.ut_time;
printf("%8.8s|%16.16s|%8.8s|%s", ulp->u.ut_name,
ulp->u.ut_host, ulp->u.ut_line, ctime(&t));
}
// ポインタを次の要素に移し、
// 表示し終わった要素のメモリを解放
ulprev = ulp;
ulp = ulp->next;
free(ulprev);
}
}
int main(void)
{
FILE *fp;
struct utmplist *ulhead = NULL;
// utmpファイルを開く
fp = fopen(_PATH_UTMP, "r");
if (fp == NULL) {
// 開くのに失敗した場合、プログラムを終了
perror(_PATH_UTMP);
exit(-1);
}
// umtpを取得してリストに格納し、リストの先頭アドレスを取得
ulhead = read_utmp(fp, ulhead);
// 開いているストリームを閉じる
fclose(fp);
// ストリームから読み込んで保持していたumtpを出力する
write_utmp(stdout, ulhead);
return 0;
}
※ストリームを引数で渡して、関数の中で閉じちゃったりしてるけどこれはいい設計なんだろうか。
※メモリの確保と解放もできれば同じ関数の中でやりたい気もする。
ファイルのメモリマッピング
ファイルにアクセスする方法として、read、writeを使わずにファイルをアドレス空間にマッピングしてアクセスするメモリマップドファイルまたはメモリマッピングという方法がある。
ファイルをアドレス空間にマップすることでファイルの中身を配列のように扱うことができる。
メモリマッピングを行うためにはmmapシステムコールを使用する。
以下のプログラムは、utmpをメモリマッピングを使用して出力するもの。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <time.h>
#include <utmp.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
int main(void)
{
int fd, num, err;
struct stat fs;
struct utmp *u0, *u;
size_t maplen;
// メモリにマップするファイルを開く
fd = open(_PATH_UTMP, O_RDONLY);
if (fd < 0) {
// 開くのに失敗した場合プログラムを終了する
perror(_PATH_UTMP);
exit(-1);
}
// ファイルの状態を取得
if (fstat(fd, &fs) < 0) {
perror("fstat");
exit(-1);
}
// マップする領域のサイズを計算
// マップするサイズはページサイズの倍数でなければならない
// このfor分でページサイズの倍数になるまでmaplenをインクリメント
for (maplen = fs.st_size;
maplen % sysconf(_SC_PAGE_SIZE) != 0;
maplen++);
// ファイルをメモリにマップする
// 同時に出力用ポインタに先頭アドレスを設定
u0 = u = mmap(NULL, maplen, PROT_READ, MAP_PRIVATE, fd, 0);
if (u == MAP_FAILED) {
// 失敗した場合、プログラムを終了する
perror("mmap");
exit(-1);
}
// メモリにマップされたutmpの数を計算
num = fs.st_size / sizeof(struct utmp);
// utmpを出力し終わるまで繰り返し
while (num--) {
// すでに終了したプロセスをスキップ
if (u->ut_type != DEAD_PROCESS) {
// utmpを出力
time_t t = u->ut_time;
printf("%8.8s|%16.16s|%8.8s|%s", u->ut_name,
u->ut_host, u->ut_line, ctime(&t));
}
// 次のutmpにポインタを移す
u++;
}
// マップした領域を解放する
err = munmap(u0, maplen);
if (err) {
// 解放に失敗した場合、プログラム終了
perror("munmap");
exit(-1);
}
// 開いているストリームを閉じる
close(fd);
return 0;
}
※マップ領域の計算とか、ところどころアクロバティックな書き方をしている気がする。
ポインタ
ポインタの扱いには注意が必要。
以下のプログラムは意図的にバグを含めたもの。
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
FILE *src, *dst;
void *buf;
int rcount, wcount;
if (argc != 3) {
printf("Usage: %s from_file to_file\n", argv[0]);
exit(1);
}
src = fopen(argv[1], "r");
if (src == NULL) {
perror(argv[1]);
exit(1);
}
dst = fopen(argv[2], "w");
if (dst == NULL) {
perror(argv[2]);
fclose(src);
exit(1);
}
// if 0でバッファの確保処理を囲うと、
// コンパイル時にこの部分が無視される
// 結果、内部変数bufが初期化されない
#if 0
buf = malloc(BUFSIZ);
if (buf == NULL) {
perror("malloc");
fclose(src);
fclose(dst);
exit(1);
}
#endif
while (!feof(src)) {
// 初期化されていないbufに対して書き込みを行っている
// 運が良くてエラー、運が悪いとどこかの領域を破壊する
rcount = fread(buf, 1, BUFSIZ, src);
if (ferror(src)) {
perror("fread");
fclose(src);
fclose(dst);
exit(1);
}
wcount = fwrite(buf, 1, rcount, dst);
if (ferror(dst)) {
perror("fwrite");
fprintf(stderr, "tried to write %d bytes, "
"but only %d bytes were written.\n",
rcount, wcount);
fclose(src);
fclose(dst);
exit(1);
}
}
fclose(src);
fclose(dst);
return 0;
}
このプログラムをgccコマンドでコンパイルすると、なんの警告も出ずにコンパイルできてしまう。
-Wallオプションを付けることで以下のような警告が出る。
filecopy-buf-bad.c: In function ‘main’:
filecopy-buf-bad.c:44:26: warning: ‘buf’ may be used uninitialized in this function [-Wmaybe-uninitialized]
rcount = fread(buf, 1, BUFSIZ, src);
^~~~~~~~~~~~~~~~~~~~~~~~~~
この警告は内部変数bufが初期化されないまま使用されているという内容。
バグを避けるためにも、コンパイルするときには基本的に-Wallオプションをつけるようにした方がよい。
データ領域の種類と性質
グローバル変数、ローカル変数、ヒープ領域のそれぞれについて有効範囲と有効期限が異なる。
それぞれの特性を知ったうえでプログラムを組むべき。
・グローバル変数
グローバル変数はプログラムの開始から終了まで有効。
グローバル変数には初期値を持つものを持たない(明示的に初期化していなくても0で初期化される)ものがあり、初期値を持つものはデータセグメント(データセクション)、初期値を持たないものはBSSセグメント(BSSセクション)に置かれる。
これらはそれぞれ、プログラム実行時に必要な分だけ領域が確保される。
データセグメントの初期値はプログラムファイルから読み込まれ、BSSセクションの初期値は0。
これらの領域は自分で解放することができない。
・ローカル変数
ローカル変数は宣言された関数が呼び出されてから、呼び出し元の関数に戻るまで有効。
ローカル変数が宣言された関数が別の関数を呼び出している最中も有効。
例えば、引数でポインタなどを関数渡すと、その関数の中でポインタを使用することができる。
ローカル変数はスタック領域に確保される。
スタック領域の大きさはシステムによって異なり、プログラムの実行中に必要な分だけ大きくなるシステム(現在はこれが主流)もあれば、固定のシステムもある。
どこまで大きく領域を取れるか、伸ばせるかもシステムによって異なる。
・ヒープ領域
ヒープ領域はプログラム実行中に必要な時に確保され、不要になったら解放される領域。
確保と解放は明示的に行われる。
実行時に領域を確保するため、その領域を使用する方法はポインタのみ。
上記全てにおいて共通して言えるのは、ポインタを使用する際にはそのポイントがどの領域を指しているのかを意識し、有効な領域を正しく使用しなければならない。
バッファオーバーフロー
バッファオーバーフローの脆弱性は、悪意を持った攻撃者によってプロセスを乗っ取られる可能性がある。
プロセス乗っ取りの原理を知るためには、プロセス実行時のスタックを理解する必要がある。
以下の図は、実行中プロセスのメモリ空間のイメージ図。
実行中プロセスのメモリ空間イメージ
スタック領域は、プログラムの実行中に必要なだけ領域が拡大していく。上の図でいうと、下に拡大していくイメージ。
以下の図はスタック領域のもう少し詳細なイメージ図。
スタック領域の中のイメージ
関数呼び出しのたびに、スタック領域が積まれていく。
スタック領域の先頭には呼び出し元の関数に戻るためのリターンアドレスが格納されている。
呼び出された関数の中に入列があった場合、配列はスタックが伸びていく方向とは逆方向にデータが積まれる。
この配列の領域を超えた書き込みが可能な時、攻撃者はリターンアドレスを任意のアドレスに設定することができ、リターン後に悪意のあるプログラムを動かすことが可能。
このような攻撃をバッファオーバーフロー攻撃またはスタックスマッシングと呼ぶ。
配列やポインタの扱いには十分気を付ける必要がある。
まとめ
第3回の講義内容は以上です。
正直私のメモ見るよりも講義ページを見るのが良いと思います。
非常に簡潔にまとまっていてわかりやすいです。
なお、この記事の中で使用した図はすべて筑波大学の講義ページから引用しています。
第4回の講義の内容はこちら。
amistad06-a.hatenablog.com
おわり。