C言語の関数の仮引数における配列型宣言の危険性について

#lowhacksでnyaxt, nishioが配列とポインタの違いについて議論していた。その中で関数の仮引数を配列として宣言した場合の挙動はどうなるのか、という話題にたどり着いた。結果僕は非常に危険だと思った。
しかし、まさかそんなことする人はいないだろうと、この記事を書きかけで放置していたのだが、C言語を学習中の友人が危惧していたコードを書く可能性があるミスをしてしまったので書こうと思う。初心者のためにソースは全文、コンパイルチェックをした上で掲載している。

sizeof演算子のおさらい

sizeof演算子は演算対象が配列ならば、配列の要素数×要素のサイズが返ってきて、ポインタならばポインタのサイズ(32bit系では4)が返ってくる。

/* 1 */
#include <stdio.h>
#include <string.h>

int main(void)
{
	char str[] = "Hello, World!";
	char* ptr = "Hello, World!";
	
	printf("%2u %2u %s\n", sizeof(str), strlen(str), str);
	printf("%2u %2u %s\n", sizeof(ptr), strlen(ptr), ptr);
	
	return 0;
}

実行すると

14 13 Hello, World!
 4 13 Hello, World!

まずcharのサイズは1であるのでsizeof(str)はstrの要素数に一致する。ここでsizeof(str)が13ではなく14であることに注意して欲しい。終端のヌル文字も配列の要素であるので、sizeof(str)は文字列の長さ13ではなく、13+1=14となる。

文字列のコピー

そこでピンと来た人はこういうコードを書くかもしれない。

/* 2 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
	char str[] = "Hello, World!";
	
	/* strlen(str)+1とかやってられるか! */
	copied_str = (char*)malloc(sizeof(str));
	strcpy(copied_str, str);
	
	printf("%s\n", copied_str);
	
	return 0;
}

これは問題ない。

本題

じゃあこのコピーを関数化してみよう。ここで本題に戻る。関数の仮引数をどう書くかである。

char* copy(const char* str);
char* copy(const char str[]);

普通は前者で書くが、C言語初心者や玄人でも特にmain関数の仮引数だと後者の書き方をする人も多いだろう。

int main(int argc, char** argv);
int main(int argc, char* argv[]);

さて、ここで先ほどピンと来た人は、この関数の中でもstrが配列的な振る舞いを期待して、この関数をこのように書くかもしれない。

/* 3 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* copy(const char str[])
{
	char* copied_str = (char*)malloc(sizeof(str));
	strcpy(copied_str, str);
	
	return copied_str;
}

int main(void)
{
	char str[] = "Hello, world!";

	printf("%s\n", copy(str));
	
	return 0;
}

実はこの関数は危険である。だが、恐らく意図した通りの動作をするだろう。表面上は。実行してみる。

Hello, world!

しかし問題なく動いた。ではこうしてみよう。

/* 4 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* copy(const char str[])
{
	char* copied_str = (char*)malloc(sizeof(str));
	char* second_str = (char*)malloc(sizeof(char)*(strlen(str)+1));
	
	strcpy(copied_str, str);
	strcpy(second_str, str);
	
	printf("%s\n", copied_str);
	printf("%s\n", second_str);
	
	return copied_str;
}

int main(void)
{
	char str[] = "Hello, world!";
	printf("%s\n", copy(str));
	
	return 0;
}

実行してみる。

Hello, world!
Hello, world!
Hello, world!

やはり問題ないと思うかもしれない。ではこれではどうだろうか?

/* 5 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* copy(const char str[])
{
	char* copied_str = (char*)malloc(sizeof(str));
	char* second_str = (char*)malloc(sizeof(char)*(strlen(str)+1));
	
	strcpy(copied_str, str);
	strcpy(second_str, str);
	
	printf("%s\n", copied_str);
	printf("%s\n", second_str);
	
	return copied_str;
}

int main(void)
{
	char str[] = "Hello, world!!!!!!!!!!!!!!!!!!!!!!!";
	printf("%s\n", copy(str));
	
	return 0;
}

実行してみよう。

Hello, world!!!!Hello, world!!!!!!!!!!!!!!!!!!!!!!!
Hello, world!!!!!!!!!!!!!!!!!!!!!!!
Hello, world!!!!Hello, world!!!!!!!!!!!!!!!!!!!!!!!

見事にぶっ壊れましたね。何がいけないのでしょう?sizeof(str)を調べてみよう。

/* 6 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* copy(const char str[])
{
	printf("%d\n", sizeof(str));
	char* copied_str = (char*)malloc(sizeof(str));
	strcpy(copied_str, str);
	
	return copied_str;
}

int main(void)
{
	char str[] = "Hello, world!";

	printf("%s\n", copy(str));
	
	return 0;
}

実行してみよう。

4
Hello, world!

14が表示されるはずだったのだが実際は4が表示された。何が起きたのだろうか。
関数の仮引数を配列表記にしても実態はポインタなのだ。よってその変数にsizeof演算子を適用してもポインタのサイズ、32bit環境であれば4が返ってくるのだ。何故そうなるかはアセンブラを見ればわかる(ここでは省略する。勉強がてら各自で試してみてほしい。)。
ではなぜ5番のコードはダメだったのに4番のコードは期待したとおりの動作をしたのだろうか?それはcopied_strとsecond_strのアドレスを確認すればわかる。

/* 7 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char* copy(const char str[])
{
	char* copied_str = (char*)malloc(sizeof(str));
	char* second_str = (char*)malloc(sizeof(char)*(strlen(str)+1));
	
	printf("%x/%x\n", copied_str, second_str);
	
	strcpy(copied_str, str);
	strcpy(second_str, str);
	
	printf("%s\n", copied_str);
	printf("%s\n", second_str);
	
	return copied_str;
}

int main(void)
{
	char str[] = "Hello, world!";
	printf("%s\n", copy(str));
	
	return 0;
}

実行してみよう。

8b1980/8b1990
Hello, world!
Hello, world!
Hello, world!

copied_strとsecond_strは16byte離れて確保されている。ということは14byteの"Hello, world!"では後の領域(second_str)に書き込むことはないが、16byteを越える"Hello, world!!!!!!!!!!!!!!!!!!!!!!!"では後の領域を上書きしてしまうことになるのだ。もちろん"正式"に確保されたのは0x8b1980からの4byteであるから4番目のコードも本当はダメだ。

まとめ

関数の仮引数として配列を書いても、実際にはポインタとして扱われる。この点を理解していないとBuffer Overrunを犯してしまう可能性のあるコードを書いてしまう危険性が生まれてしまう。なるべく仮引数はポインタ表記にしたほうがよいだろう。無論分かった上で配列のように宣言するのは問題ない。