C言語と文字列に関するあれこれ

はじめに

ちょっと前にこんなコードが話題になりました(オリジナルから少し変更してあります)。

  char str1[8] = "hoge ";
  char str2[8] = "fuga ";
  char *str3 = strcat(str1,str2);

C言語を知っている人が見れば「あ、これはC言語をよく知らない人が書いたな」ということがわかるかと思います。これ、もちろん駄目なコードなわけですけど、ちゃんとプログラムを習わずに自己流でプログラマになり、コードレビューなどがない会社に就職してしまったりすると、このままになってしまうかもしれません。私も友人に指摘されるまでしばらくこれに似たコードを書いていた経験があり、決して他人事ではありません。

そもそも、C言語における文字列リテラルって、結構いろいろ面倒だったりします。というわけで、この機会に文字列リテラルの扱いについてあれこれ書いてみます。

例1: グローバルな宣言

文字列の宣言には、以下の二通りの方法があります。

char *str = "hoge";
char str2[] = "fuga";

上記二つの宣言の違いについて、あやふやな人は結構いるんじゃないでしょうか。

まずすぐにわかる違いは、両者はsizeofが返す値が違います。

#include <stdio.h>

char *str = "hoge";
char str2[] = "fuga";

int main(){
  printf("sizeof(str)  = %d\n",sizeof(str));
  printf("sizeof(str2) = %d\n",sizeof(str2));
}
$ gcc test1.c
$ ./a.out
sizeof(str)  = 8
sizeof(str2) = 5

また、str2の指す文字列の中身は変更できますが、strの指す中身は変更できません。

#include <stdio.h>

char *str = "hoge";
char str2[] = "fuga";

int main(){
  str2[1] = '!';
  puts(str2);
  str[1] = '!'; // ここで実行時エラーになる
  puts(str);
}
$ gcc test2.c
$ ./a.out
f!ga
zsh: segmentation fault (core dumped)  ./a.out

ちなみに、上記のコードをMac OSXで実行すると、SIGSEGVではなく、なぜかSIGBUSを出します。

$ ./a.out
f!ga
zsh: bus error  ./a.out

さて、なぜstrが書き込み不可能で、str2が書き込み可能かといえば、コンパイルする時に、strの指す先を書き込み不可のメモリに配置し、str2の指す先を書き込み可のメモリに配置するからです。

先程のコードのアセンブリを出してみましょう。

$ gcc -S test2.c
        .file   "test2.c"
        .globl  str
        .section        .rodata
.LC0:
        .string "hoge"
        .data
        .align 8
        .type   str, @object
        .size   str, 8
str:
        .quad   .LC0
        .globl  str2
        .type   str2, @object
        .size   str2, 5
str2:
        .string "fuga"

        .text
        .globl  main
        .type   main, @function
main:
(snip)

これを見ると、strのサイズが8で、str2のサイズが5だとか書いてあるのですが、それより重要なのはセクションです。文字列リテラル”test”は、.section .rodataとあるセクションの下に、”hoge”は.dataセクションの下にあります。.rodataとはRead Only Dataのことで、「ここにあるデータは、読み取り専用メモリにおいてくださいよ」とOSにお願いする文章です。.dataは「読み書き可能なメモリにおいてください」という意味です。

ついでに、.textはプログラムを置くところで、「書き込み不可、実行可能なメモリにおいてください」とOSに依頼します。

さて、strの指す先に書き込みしようとすると実行時エラーが起きるのは、コンパイラが「書き込み不可のメモリにおいてください」と(リンカを通じて)OSに頼んでいるからでした。なので、そこを修正すれば書き込み可能になります。

先程の.section .rodata.dataに書き換えてみましょう。

まず、先程吐いたアセンブリをそのまま実行ファイルにしても、実行時エラーが出ることを確認します。

$ gcc test2.s 
$ ./a.out
f!ga
zsh: segmentation fault (core dumped)  ./a.out

次に、test2.sのセクションを書き換えます。

-	.section	.rodata
+        .data

そしてまたリンクして実行してみましょう。

$ gcc test2.s
$ ./a.out
f!ga
h!ge

無事に実行できました。

例2: ローカルな宣言

先程、文字列リテラルをグローバル変数として宣言しました。 では、ローカル変数として宣言したらどうなるでしょうか。こんな感じです。

void func(){
  char *str = "hoge";
  char str2[] = "fuga";
}

ここで、それぞれの文字列リテラルhogefugaがどこにどうやって配置されるかを即答できる人はそんなにいないんじゃないかと思います(たくさんいたらごめんなさい)。

とりあえずアセンブリを見てみましょう。

$ gcc -S test3.c
        .file   "test3.c"
        .section        .rodata
.LC0:
        .string "hoge"
        .text
        .globl  func
        .type   func, @function
func:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        movq    $.LC0, -8(%rbp)
        movl    $1634170214, -16(%rbp)
        movb    $0, -12(%rbp)
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc

strの指す先のhoge.section .rodataセクションに置かれており、グローバル変数と同じ扱いなのがわかるかと思います。そして、アセンブリにはfugaの文字がありません。

実はfugaは、アセンブリに即値として入っています。ここです。

        movl    $1634170214, -16(%rbp)

fugaのアスキーコードはこうなっています、

f:102
u:117
g:103
a:97

これをまとめましょう。

("a".ord << 24) + ("g".ord << 16) + ("u".ord << 8) + "f".ord
## => 1634170214

アセンブリに埋め込まれていた即値が出てきました。

さて、hogeの方は、それを指すポインタchar *strはローカル変数ですが、そこが指すアドレスはグローバル変数なので、関数の外からも参照することができます。

#include <stdio.h>

char* func(){
  char *str = "hoge";
  return str;
}

int main(){
  char *s = func();
  puts(s);
}
$ gcc test4.c
$ ./a.out
hoge

しかし、char []で宣言すると、文字列リテラルの実体がスタックに積まれるため、そのアドレスは関数の外からみると意味のないものになります。

#include <stdio.h>

char* func(){
  char str[] = "fuga";
  return str;
}

int main(){
  char *s = func();
  puts(s);
}

なのでコンパイラもちゃんと怒りますし、無理やり実行しても意味のない文字列が表示されます。

$ gcc test5.c
test5.c: 関数 ‘func’ 内:
test5.c:5:3: 警告: 関数が局所変数のアドレスを返します [-Wreturn-local-addr]
   return str;
   ^

$ ./a.out
???5?

まとめ

というわけで文字列リテラルについて、char *strで宣言した場合とchar str[]で宣言した場合、またそれがグローバル変数とローカル変数にした場合のそれぞれについて、どんなふうに扱われるかつらつら書いてみました。C言語における文字列の扱いは面倒で、多くのセキュリティホールを生み出したため、後から出てきた言語は文字列をイミュータブルにすることで対応することになります。

まぁなんというか、冒頭のコードを公開していたサイトは別にいろいろ問題があるということは十分承知しつつ、それとは別に新人プログラマがちょっとやばいコードを公開して袋叩きにあって、そのプログラマがもうアウトプットする気力を失ってしまったりするのもたまに見かけるので、もう少し優しいインターネッツになるといいですね。

2018年10月20日追記: constについて

コメント欄にて、「文字列リテラルにはconstをつけるべきである」という指摘がありました。これはそのとおりで、以下のコードをgccコンパイルしても怒られませんが、g++でコンパイルすると怒られます。

char *str = "hoge";
$ gcc -c test6.c # => 怒らない
$ g++ -c test6.c
test6.c:1:13: 警告: deprecated conversion from string constant to ‘char*[-Wwrite-strings]
 char *str = "hoge";
             ^

また、const指定すると、char*で宣言した場合、char []で宣言した場合の両方とも、文字列にアクセスしようとするとコンパイラに怒られます。

#include <stdio.h>

const char *str = "hoge";
const char str2[] = "fuga";

int main(){
  str2[1] = '!';
  puts(str2);
  str[1] = '!';
  puts(str);
}
$ gcc test7.c
test7.c: 関数 ‘main’ 内:
test7.c:7:3: エラー: 読み取り専用位置 ‘str2[1]’ への代入です
   str2[1] = '!';
   ^
test7.c:9:3: エラー: 読み取り専用位置 ‘*(str + 1u)’ への代入です
   str[1] = '!';
   ^

また、グローバル変数の文字列リテラルをchar []で受けた場合、データが書き込み可能な.dataセクションに保存されましたが、constをつけると、書き込み不可のセクションに保存されます。

#include <stdio.h>

char str[] = "hoge";        // => .data セクションへ
const char str2[] = "fuga"; // => .section   .rodataへ

int main(){
  puts(str);
  puts(str2);
}

gcc -Sしてみましょう。

        .file   "test8.c"
        .globl  str
        .data
        .type   str, @object
        .size   str, 5
str:
        .string "hoge"
        .globl  str2
        .section        .rodata
        .type   str2, @object
        .size   str2, 5
str2:
        .string "fuga"

hogeは書き込み可能な.dataセクションへ、fugaは書き込み不可の.section .rodataセクションへ配置されました。

const char str2[] = "fuga"; // => .section   .rodataへ

int main(){
  char *tmp = (char*)str2;
  tmp[1] = '!'; //SIGSEGV
}

などとキャストでconstを外しても、文字列リテラルの場所はOSが保護しているために、そこを触ろうとするとSIGSEGVで落ちます。

では、ローカル変数ではどうでしょうか。

#include <stdio.h>

void func(int index){
  char a[1];
  const char str[] = "hoge";
  // str[1] = '!';  // <- これはコンパイラに怒られる
  a[index] = '!';
  puts(str);
}

int main(){
  func(-14);
}

関数funcの中にconst char []で宣言された文字列リテラルがあります。const宣言されているため、この中を触ろうとするとコンパイラに怒られますが、この文字列リテラルのデータはスタックに積まれており、スタックは読み書きできるメモリ領域であるため、間接的に触ることはできます。この例では、近くにある配列のバッファアンダーフローを使って文字列リテラルを書き換えています。

$ gcc test9.c
$ ./a.out
h!ge   # const宣言された文字列リテラルの中身が書き換えられた。

ちなみに、const char*で宣言した場合は、文字列リテラルはスタックではなくグローバル領域の書き込み不可の場所に配置されるため、触ろうとするとSIGSEGVで落ちます(MacだとなぜかSIGBUS)。

というわけでまとめると、グローバルに宣言された文字列リテラルをchar []で受けると読み書き可能なメモリに配置されますが、const宣言をすると書き込み不可の領域に配置されます。しかし、ローカルに宣言した場合はconst char []constをつけても、読み書き可能なメモリに置かれます。

これがC言語の言語仕様なのか、それともコンパイラが気を利かせるのがデフォになってるのかは知りません・・・