最近はC言語を使う方が少ないそうです。わたしもスクリプト言語が楽過ぎてC言語を使う機会が減ったのですが、C言語やポインタを理解しておくと別の言語を使う際やインフラ業務に携わる際でも役に立つ場面があると思いますので、久しぶりにC言語に関する記事を書きます。
C言語を勉強したけれどポインタでつまずいた、という方が対象です。
より深く理解するには末尾でご紹介している「Cプログラミング 専門課程」を読む亊をおすすめします。
ポインタは単なるメモリアドレスを格納する変数
ポインタを難しく考える必要はありません。ポインタは単にメモリアドレスを格納する変数です。
int型変数「i」、int型ポインタ変数「p」を使って確認しましょう。
#include <stdio.h>
int
main(void)
{
int i = 100;
int *p;
p = &i; // iのメモリアドレスをpに格納する
printf("&i : %p\n", &i);
printf("&p : %p\n", &p);
printf("p : %p\n", p);
printf("i : %d\n", i);
printf("*p : %d\n", *p);
}
このプログラムをmem.cという名前で保存したのち、コンパイルします。
$ gcc mem.c
エラーは出ないはずです。a.outというファイル名に出力されているので実行します。
$ ./a.out &i : 0x7ffd9a282b2c &p : 0x7ffd9a282b30 p : 0x7ffd9a282b2c i : 100 *p : 100 $
変数iのメモリアドレスは「&i」で求める事ができます。メモリアドレスは「0x7ffd9a282b2c」です。変数pに格納されているのは i のメモリアドレスですね。オレンジにマーキングしている箇所で分かります。
「&p : 0x7ffd9a282b30」はポインタ変数「p」のポインタです。ポインタ変数といってもただの変数なので、このようにポインタのポインタを求める亊ができます。
ポインタが指し示しているメモリアドレスの内容にアクセスするには「*p」でアクセスできます。
ここまではポインタの使い方のおさらいです。
メモリマップを頭に描く
変数がメモリ上でどのように展開されているのか頭に描く事ができればポインタなんて簡単に理解できます。簡単な例として次のようなコードを書いて実行してみます。
#include <stdio.h>
#include <string.h>
int
main(void)
{
char buf[] = "abc";
char *p = buf;
int i;
printf("--- 配列 ---\n");
for (i = 0; i < strlen(buf); i++)
printf("buf[%d]: %c : %p\n", i, buf[i], &buf[i]); printf("\n");
printf("--- ポインタ ---\n");
for (; *p; p++)
printf("p: %c : %p\n", *p, p);
printf("\n");
printf("pのアドレス: %p\n", &p);
}
コンパイルして実行すると次のように表示されます。
$ ./a.out --- 配列 --- buf[0]: a : 0x7fffaea85f64 buf[1]: b : 0x7fffaea85f65 buf[2]: c : 0x7fffaea85f66 --- ポインタ --- p: a : 0x7fffaea85f64 p: b : 0x7fffaea85f65 p: c : 0x7fffaea85f66 pのアドレス: 0x7fffaea85f58 $
この表示結果をメモリマップにすると以下のようになります。
いかがですか?このメモリマップが理解できればポインタは理解できています。
ポインタ変数「p」はbuf[0]のメモリアドレスが格納されているので、pをインクリメントするとbuf[0]、buf[1]、buf[2]とアクセスできるわけです。
ポインタの理解はメモリの理解です。ポインタが単なるメモリアドレスを格納する変数であり、変数がメモリ上でどのように格納されているのかメモリマップを頭に描く事ができればポインタを理解できます。
値渡しと参照渡し
ポインタのメリットを享受できるのは参照渡しです。
#include <stdio.h>
void
func(int aa, int *bb)
{
aa = 999;
*bb = 999;
}
int
main(void)
{
int a = 100;
int b = 200;
printf("--- a と b の値 ---\n");
printf("a = %d\n", a);
printf("b = %d\n", b);
func(a, &b);
printf("--- a と b の値 ---\n");
printf("a = %d\n", a);
printf("b = %d\n", b);
}
このコードを実行してみましょう。
$ ./a.out --- a と b の値 --- a = 100 b = 200 --- a と b の値 --- a = 100 b = 999 $
なぜ変数「b」だけ値が変わっているのか?それは参照渡しをしているためですね。
参照渡しのメリットは巨大な引数が必要となる場合に良く分かります。
値渡しの場合、引数のサイズだけメモリコピーが発生します。100MBの構造体を引数に渡せば100MBのメモリコピーが発生します。しかし参照渡しの場合はポインタだけ渡せば良いので8バイトで済みます(64bit CPUの場合)。
関数ポインタ
プログラミングとはメモリ上に展開されたコードにジャンプしたりデータをゴニョゴニョといじる作業に過ぎません。
すべて「メモリ」なんですね。
そこが分かると、関数ですらポインタで操作できる亊が分かります。
#include <stdio.h>
#include <stdlib.h>
void
func1(void)
{
printf("*** func1が呼び出されました ***\n");
}
void
func2(void)
{
printf("*** func2が呼び出されました ***\n");
}
void
func3(void)
{
printf("*** func3が呼び出されました ***\n");
}
int
main(void)
{
char input[1024];
int number;
void (*funcarray[4])();
void (*func)(void);
funcarray[0] = NULL;
funcarray[1] = func1;
funcarray[2] = func2;
funcarray[3] = func3;
while (1) {
printf("関数一覧\n");
printf("1: func1\n");
printf("2: func2\n");
printf("3: func3\n");
printf("呼び出す関数を選んでください: ");
scanf("%s", input);
number = atoi(input);
func = funcarray[number];
func();
}
}
関数の呼び出しとは、単にメモリ上に展開されているコードのメモリアドレスにジャンプする亊に過ぎません。ですからポインタを使って関数を呼び出す亊も可能となるわけです。
先ほどのコードをコンパイルして実行してみましょう。
$ ./a.out 関数一覧 1: func1 2: func2 3: func3 呼び出す関数を選んでください: 1 *** func1が呼び出されました *** 関数一覧 1: func1 2: func2 3: func3 呼び出す関数を選んでください: 2 *** func2が呼び出されました *** 関数一覧 1: func1 2: func2 3: func3 呼び出す関数を選んでください: 3 *** func3が呼び出されました *** 関数一覧 1: func1 2: func2 3: func3 呼び出す関数を選んでください: 0 Segmentation fault $
このように関数ポインタを使って関数を呼び出す亊ができます。
「0」を指定した際に「Segmentation fault」が発生しているのはなぜか?それはNULLを参照しているためです。
まとめ
プログラミングとは、データを入力してゴニョゴニョといじり、それを出力する作業です。どんなに複雑なプログラムも基本はそれだけです。
そして、それらはメモリ上で行われます。
その亊を理解してデータやコードがメモリ上でどのように展開されているのか頭に描く亊ができればポインタも理解できるでしょう。
そして、そこまで理解すればメモリ破壊による脆弱性が発生する理由も理解できるようになります。
最後に最高の良書をご紹介します。
「Cプログラミング 専門課程」はわたしが知る限りC言語では最高の良書です。C言語への理解を深めるためには必読といっても過言ではありません。読むだけなく、サンプルコードを打ち込んで実行してみる亊をおすすめします。