const char* const p = "ABC"; と const char q[] = "ABC"; はどちらがよいか、みたいな与太

諸事情あって、ふと前に読んだドキュメントに書いてあった細かいことが気になった。いやいつも細かいけど。

const char* const p = "ABC";

より

const char q[] = "ABC";

のほうがいいのか?的な話。後者の方が良いらしいので、確認するととともに、すぐになんでも書くのはどうなんだと思いつつも無駄に細かく解説。いろいろ間違ってたらゴメンナサイ。C言語入門?

先に結論


共有ライブラリやPIEな実行ファイルを作る場合は、後者の書き方(const char q[] = "xxx")のほうが良さそうですね。PIEじゃない単なる実行ファイルを作るときは、最適化かけるならあんまりかわらないかも。

比較1) コンパイル時の最適化の効きやすさ


最適化といってもいろいろありますが、↓に限って言えば、const char q[] のほうが効きやすいようですね。


gcc version 4.1.1 20060525 (linux/x86) で、最適化なしだと、printf("%zu", strlen(p)); はstrlen関数を呼んでしまうけど、printf("%zu", strlen(q)); は第二引数部分が即値0x3にコンパイルされました。でも、-O2すれば両方0x3になるので、あんまり違わないともいえます。なお、"z" はsize_tとssize_tに対応する修飾文字です。どうでもいいトリビア

比較2) 実行中の時間的なコスト


比較2の結論も先に書くと、「単に実行ファイルを作る場合ならポインタ版と配列版に違いなし。PICかPIEにする場合、間接参照が1段少ない分、配列版のほうが速そうに見えますがどうなんだろ」となります。

static const char* const gsp   = "AAA";
       const char* const gpp   = "BBB";
static const char        gsa[] = "CCC";
       const char        gpa[] = "DDD";

void bar() {
  printf("static pointer = %s\n", gsp);
  printf("public pointer = %s\n", gpp);
  printf("static array   = %s\n", gsa);
  printf("public array   = %s\n", gpa);
}

のようなコードを準備。確認していきます。


(1) PIC

% gcc -fPIC -shared -o shared.so bar.c
% nm shared.so | grep 適当に抜粋 | sort
0000070a r gsa
0000070e R gpa
00001784 d gsp
00001788 D gpp
0000186c a _GLOBAL_OFFSET_TABLE_

PICでコンパイルしてnmしたら上記のようになりました。ポインタ版は.data (か.data.ro.relro)に、配列版は.rodataに置かれたようです。次に、readelfコマンドでセクションの一覧を出しておきます。

% eu-readelf -S shared.so | egrep -e '(got|data|rodata)'
[12] .rodata              PROGBITS     000006e0 0006e0 00008a  1 AMS    0   0  1
[17] .data.rel.ro         PROGBITS     00001784 000784 000010  0 WA     0   0  4
[19] .got                 PROGBITS     00001854 000854 000018  4 WA     0   0  4
[20] .got.plt             PROGBITS     0000186c 00086c 000018  4 WA     0   0  4
[21] .data                PROGBITS     00001884 000884 00000c  0 WA     0   0  4

適当に抜粋しました。最後にsoをobjdump(逆アセ)します。

% objdump -d shared.so | less

のあたりを見ます。ここから無駄に解説が細かいです。

00000616 <bar>:
...
 61d:   e8 a5 ff ff ff          call   5c7 <__i686.get_pc_thunk.bx>

__i686.get_pc_thunk.bx は、gccが用意してくれる関数で、PC(プログラムカウンタ、EIPレジスタの値)をebxにコピーします。姉妹関数で __i686.get_pc_thunk.cx もあります。こちらはPCをecxにコピーします。caller-saved registerであるecxにコピーのほうが若干コードを短く出来るので、状況*1によっては勝手にそちらが使われます。なんでここでPCが必要かというと、グローバル変数にアクセスしたいからです。PIC/PIEの場合、実行時に自分自身(ELFバイナリ)がどの仮想アドレスにmmapされるかはわかりませんので、アドレス決め打ちで変数にアクセスはできません。でも、いま実行している命令(61d:)から変数までのオフセットは*.oをリンクしてsoにした時点で判明します(soファイルのナカミがほぼそのままメモリに貼られるわけなんで)。


mov 0xほげ(%eip) ふが; とか出来れば一番いいんですが(x86_64はできる)、x86はそういうPC相対のアドレス指定はできません。というか、eipを直接的に得ることすら出来ません。なんで、仕方なく一度call命令を発行して、リターンアドレス(callを発行した命令の次の命令のアドレス)をスタック上に自動pushさせて、__i686.get_pc_thunk.bx内でebxレジスタにpopしてます。x86だと、PIC/PIEなコードでグローバル変数を触るだけで、関数呼び出しと同じようなコストがかかるんざますよ!奥様。なお、gcc3では確か、__i686.get_pc_thunk.bxみたいな関数はなくって、安直に次の命令に向かって相対なcallをしてたと思います。call いっこ先; popl %ebx; のように。なんで変化したのかは知りません。コードサイズ? 関数をcallという方式にしておけば、毎回popの分、1バイトづつケチれそうではありますね。さて次。

 622:   81 c3 4a 12 00 00       add    $0x124a,%ebx

PCの値にリンカが決めた適切なオフセットを足して、_GLOBAL_OFFSET_TABLE_ というシンボルのアドレスにします。なお、addする値は、*.o の時にはわからないのでダミーの値になっています。*.o の .rel.text というセクションに、「あとで適切な値をうめてケロ」と書いてあります。*.o を objdump -dr すると、R_386_ほげ みたいな記載になっているところが、TBDな場所です。*.o を so にまとめるときに適切な値を埋めることを、(リンク時の)リロケーションとか呼びます。


このadd命令の場所と、addで足される値の和をgdbを電卓として使って計算してみると、

(gdb) p/x 0x622+0x124a
$1 = 0x186c

で、nmしたときの __GLOBAL_OFFSET_TABLE__ シンボルの値と一致しています。実行時のadd結果は、この0x186cにDSOのロードアドレス(DSOの先頭のアドレス)を足したものになるわけです。これ以降、このebxに格納された _GLOBAL_OFFSET_TABLE_ のアドレスを基準として変数に触ります。このときのebxをPICレジスタとか呼んだりもします。たぶん。以降、面倒なので _G_O_T_ と略記します。GOTだと、.gotセクションの先頭アドレスと紛らわしいので。なんで一致していないのかは良く知りませんが、Sunのマニュアルを見た感じだと、「正および負のオフセットでなるべく多くの範囲を指せる様」にシンボルの位置を後ろにずらしてあるとかないとか。

;; ここから static const char* const gsp = "AAA"; へのアクセス処理

 628:   8b 83 18 ff ff ff       mov    0xffffff18(%ebx),%eax
 62e:   89 44 24 04             mov    %eax,0x4(%esp)

0xffffff18は、gdb電卓様によれば -232 です。_G_O_T_ のアドレス 0x186c からこれを引いてみると0x1784です。このあたりは、先のreadelf -S の結果と比べると、.data.rel.ro セクションです。nmの結果と 0x1784 を見比べると、ジャストでgspのアドレスであることがわかります。2番目のmov命令で、gspの内容(="AAA"のアドレス)をスタックに積んでいます。これがprintf関数に第二引数として渡ります。

 632:   8d 83 aa ee ff ff       lea    0xffffeeaa(%ebx),%eax
 638:   89 04 24                mov    %eax,(%esp)
 63b:   e8 84 fe ff ff          call   4c4 <printf@plt>

最初の2行は、フォーマット文字列を引数として積んでいるだけ、3行目はprintfを呼んでるだけです。PLT経由の関数呼び出しは、すでに前に書いたので省略します。今後、printfとその前の2行は略します。

;;	const char* const gpp = "BBB"; のアクセス

 640:   8b 83 e8 ff ff ff       mov    0xffffffe8(%ebx),%eax
 646:   8b 00                   mov    (%eax),%eax
 648:   89 44 24 04             mov    %eax,0x4(%esp)

0xffffffe8 は -24 で、計算すると丁度 .got セクションの先頭あたりです。1行目で、まずGOT経由*2で変数gppのアドレスを得ています。2行目で、gppの指し先のアドレス("BBB")をスタックに積んでいます。間接参照の段数が多く、まわりくどいですね!


何故わざわざGOTを経由するのかですが、これは、LD_PRELOADなどで変数gppをinterpose可能にするためです(よね)。DSOの外に公開されているシンボルは、interpose可能でなくてはなりません*3。gppなるシンボルをもつyokodori.soを作成して、LD_PRELOADしてやると、0xffffffe8(%ebx) はshared.soの変数gppではなく、yokodori.soの変数gppを指すようになります。dynamic linler(ld.so)がそのように仕向けます。

;; static const char gsa[] = "CCC"; のアクセス

 65a:   8d 83 9e ee ff ff       lea    0xffffee9e(%ebx),%eax
 660:   89 44 24 04             mov    %eax,0x4(%esp)

staticなのでGOTは経由しません。ということで、一見最初のと似ていますが、全然違います。まず、最初の命令がmovでなくlea*4です。そして、0xffffee9e(%ebx)は、.data.rel.ro ではなく .rodata です(計算して readelf -S と見比べてみましょう)。.rodataには、お目当ての CCC が格納されています。確認します:

% nm shared.so | grep 適当に抜粋 | sort
0000070a r gsa
0000186c a _GLOBAL_OFFSET_TABLE_

% gdb
(gdb) p/x 0x186c+0xffffee9e
$1 = 0x70a

% eu-readelf -S shared.so | grep -A 1 rodata
[12] .rodata              PROGBITS     000006e0 0006e0 00008a  1 AMS    0   0  1
[13] .eh_frame            PROGBITS     0000076c 00076c 000004  0 A      0   0  4

確かに、.rodata (というかシンボルgsaのアドレスそのもの)です。

% objdump -sj .rodata --start-address=0x70a shared.so

shared.so:     file format elf32-i386

Contents of section .rodata:
 070a 4343 43004444 44004142 43007374 6174 CCC.DDD.ABC.stat
 071a 6963 20706f69 6e746572 203d2025 730a ic pointer = %s.
 072a 0070 75626c69 6320706f 696e7465 7220 .public pointer
 073a 3d20 25730a00 73746174 69632061 7272 = %s..static arr
 074a 6179 2020203d 2025730a 00707562 6c69 ay   = %s..publi
 075a 6320 61727261 79202020 3d202573 0a00 c array   = %s..

そこにはちゃんと "CCC" が格納されています。一番目のアクセスでは、ポインタgspを経由して "AAA" にたどり着いたのですが、3番目のこれではダイレクトに "CCC" に到達しています。一番速そうです(本当のところは知りませんが)。

;; const char gpa[] = "DDD"; のアクセス

 672:   8b 83 f0 ff ff ff       mov    0xfffffff0(%ebx),%eax
 678:   89 44 24 04             mov    %eax,0x4(%esp)

そろそろ説明を端折ります。non-staticなのでGOTを経由しています。しかし、GOTの指し先は.rodataの"DDD"そのものです。2番目のように、まわりくどくポインタを経由したりはしません。3番目よりはまわりくどいですが、2番目よりは効率が良さそうな気がします。


(2) PIE


次、同じコードにカラのmain()を足して、PIEとしてコンパイルしてみます。gcc -fPIE -pie ですね。できた位置独立な実行ファイルをnmしてみます。すると、変数 g{sp}{ap} の置かれているセクションはおなじっぽいですね。こいつらのアドレスを覚えておきます。

% nm なんちゃら
000007e2 r gsa
000007e6 R gpa
00001858 d gsp
0000185c D gpp
0000193c a _GLOBAL_OFFSET_TABLE_

まえと同じく、eu-readelf -S すると下記。

% eu-readelf -S main_PIE | egrep -e '(got|data|rodata)'
[14] .rodata              PROGBITS     000007b0 0007b0 00008e  0 A      0   0  4
[19] .data.rel.ro         PROGBITS     00001858 000858 00000c  0 WA     0   0  4
[21] .got                 PROGBITS     0000192c 00092c 000010  4 WA     0   0  4
[22] .got.plt             PROGBITS     0000193c 00093c 00001c  4 WA     0   0  4
[23] .data                PROGBITS     00001958 000958 000010  0 WA     0   0  4

同じようにbar()を逆アセンブルしてみていきます。

 66b:   e8 a7 ff ff ff          call   617 <__i686.get_pc_thunk.bx>
 670:   81 c3 cc 12 00 00       add    $0x12cc,%ebx

PICレジスタのセットアップは一緒です。addlする数は、バイナリが異なるのでもちろん微妙に異なるわけですが。

;; static const char* const gsp = "AAA"; のアクセス

 676:   8b 83 1c ff ff ff       mov    0xffffff1c(%ebx),%eax
 67c:   89 44 24 04             mov    %eax,0x4(%esp)

これは、PICでのgspのアクセスとまったく一緒です。.data.rel.roのポインタの値をスタックに積んでます。

;;	const char* const gpp = "BBB"; のアクセス

 68e:   8b 83 20 ff ff ff       mov    0xffffff20(%ebx),%eax
 694:   89 44 24 04             mov    %eax,0x4(%esp)

PIEの1番目、つまりすぐ上のgspの例と同じ、です。PICでは公開された変数であるgppのアクセスはGOTを経由していましたが、PIEではGOTを経由しません。したがって、LD_PRELOADでの乗っ取りはできませんが、コードが若干効率的になります。PIEでは同一実行ファイル内の関数・変数のアクセスではGOTを経由しない決まりになっているようです。

;; static const char gsa[] = "CCC"; のアクセス

 6a6:   8d 83 a6 ee ff ff       lea    0xffffeea6(%ebx),%eax
 6ac:   89 44 24 04             mov    %eax,0x4(%esp)

PICの3番目と同じです。効率的なコードです。

;; const char gpa[] = "DDD"; のアクセス

 6be:   8d 83 aa ee ff ff       lea    0xffffeeaa(%ebx),%eax
 6c4:   89 44 24 04             mov    %eax,0x4(%esp)

すぐ上、あるいはPICの3番目と同じです。効率的なコードです。


(3) ただのexe


gsp, gpp, gsa, gpa のどのアクセスについても、下記のような安直かつ効率の良さそうなコードになります。

 80483e6:       c7 44 24 04 18 85 04    movl   $0x8048518,0x4(%esp)


と、ここまで書いて思ったのですが、-O2 かけると、それぞれがもちっと効率的なコードになりますね。でも書く気力が尽きたのでいいや。位置独立なコードのいい加減な解説文ということで・・・・。

比較3) 起動時の時間的なコスト(再配置回数)


何の話をしていたのか、完全に忘れた感もありますが、話を戻します。ポインタにするか配列にするかで、プロセス起動時(ロード時)のリロケーションのコストが変化します。PICとPIEで微妙に異なるようなので、分けて。下記挙動はすべて、eu-readelf -r binary で調べられます。このへんは(も)理解が浅いのであっさりです。ちゃんと追わないと駄目だ。。。


(1) PIC

  • const char* const x = ""; を使うたびに、R_386_RELATIVE な再配置と R_386_GLOB_DAT な再配置が一つづつ増加します。前者がポインタの指し先の分で、後者がクリリンのぶんというかGOTの分、であってますかね。
  • static const char* const x = ""; だと、R_386_RELATIVE が1つ増えるのみです。
  • const char x[] = ""; だと、R_386_GLOB_DAT が1つ増えるのみです。
  • static const char x[] = ""; だと、ロード時の再配置は起きないようです。

全体的に、配列方式のほうが得ですね。


(2) PIE

  • const char* const x = ""; を使うたびに、R_386_RELATIVE が一つづつ増加します。PICの時よりもマシになっています。
  • static const char* const x = ""; はstaticなしと同じです。R_386_RELATIVE が1つ。
  • const char x[] = ""; だと、ロード時の再配置は起きないようです。
  • static const char x[] = ""; でも、(もちろん)ロード時の再配置は起きないようです。

...というわけで、PIEでも配列の勝ちです。

比較4) 空間コスト


ポインタ方式は.dataセクションのポインタ一個分損なんじゃなかろうか。たぶん(やる気がなくなってきた)。

ベンチマーク


ポインタ型/配列型、それぞれのグローバル変数を数百万個含むDSOを作ってみて、

import List
main = mapM_ (\x -> putStrLn $ "const char* const "++x++"=\""++x++"\";") $ take 10000000 $ perm ['a'..'k']
  where perm [] = [[]]
        perm xs = concat [map (x:) $ perm (delete x xs) | x <- xs]

起動時間をくらべてみよ...うと思ったらgccの4.1.1がICEでコケてコンパイルできなかったのでやめ。

まとめ


というわけで、何がいいたいのかよくわからないエントリですが、わたしもわかりません。


とりあえず、可能ならstaticなり __attribute__((visibility("hidden"))) なりは付与するとして、

char* p = "hello";             //   2点
const char* p = "hello";       //  15点: 最適化がろくに効かないしちょっとなー。
                               //         ...っていうかそのポインタ、ほんとに指し変えるの? 
#define HELLO "hello"
                               //  40点: まぁ、それもアリかもわからんね。-fmerge-all-constants すると吉
const char* const p = "hello"; //  50点: タイプ量が多い割に PIC/PIE だと下のに負ける
const char p[] = "hello";      //  70点: イイ!

ってな感じでどうでしょう?


もちろん100点はRubyをつか(ry

*1:PLTにジャンプしない、とかか?

*2:GOTについては http://d.hatena.ne.jp/yupo5656/20060618/p1 を。変数の場合は、関数と違ってPLTを経由せずに直接参照されます

*3:DSO外に公開されていないシンボル、たとえば一番目のstaticなグローバル変数、はそうでなくてかまいません。だからGOTを経由しませんでした

*4:アドレスを計算してそのまま格納する命令