発端はuchan_nos氏によるこのツイートでした。
C言語で、本当にメモリの0番地にデータを書きたいときはどうすりゃええの?
— うー@技術書典8 Day1う31 BitNOS (@uchan_nos) 2020年2月12日
それに対する私のリプライ:
uint8_t *p = 1; p--; *p = v;
— hikalium (@hikalium) 2020年2月12日
私はこれで話が終わると思っていたのだが、どうやらそうではなかったらしく、色々な視点からの意見が加わりながら、話は混沌を極めたのでした…。
ということで、ここに私のこのツイートに対しての見解とか、わかったことをまとめておこうと思います。
私のリプライの背景について
uchanさんが求める「0番地にデータを書きたい」という課題設定を、私はこのように解釈しました。
- C言語において、整数0をポインタに変換すると、それはNULLポインタになる
- C言語において、NULLポインタへのアクセスは未定義動作である
- したがって、0番地にデータを書くことはできないのではないだろうか?
- これをすり抜ける方法はあるか?
これを受けて、私はこのように考えました。
- NULLポインタへのアクセスが未定義となるのはコンパイル時の話である
- ではコンパイル時にNULLポインタであるとコンパイラにバレなければ未定義動作にならないのではないだろうか?
- じゃあアドレス1を代入してポインタをつくり、それを引き算して0番地へのポインタをつくれば、コンパイラは見逃してくれるのではないだろうか?
そして、以下のコード片が生まれたわけです。
uint8_t *p = 1; p--; *p = v;
ここで、vは何らかのuint8_t型の書き込みたいデータである(と文脈からわかると期待)。
仕様上の問題
しかしその後、有識者の人々がにわかにざわつき始めた。そして、私の提示したコードはやはり動かないということをherumiさんがありがたくも指摘してくださったわけです。
https://t.co/37AzusUhoc
— herumi (@herumi) 2020年2月14日
Cの規格(6.3.2.3 3)では整数定数0か(void*)0がヌルポインタ(NULL)。NULLへのアクセスは未定義。したがって0へのアクセスは不可能。それ以外の整数からポインタへのキャストは処理系依存。
このコードはNULLへのアクセスと等価とみなしてgccやclangはud2を生成するようです。
では仕様上、単なる0番地への代入コードや、私の書いたコードがなぜうまく動かないのかをみていきましょう。
0をポインタにキャストするとそれはnull pointerになる (6.3.2.3 - 3)
この仕様によれば、整数定数0となるような定数式をポインタにキャストすると、それはnull pointerになる。 そして、null pointerは、いかなるオブジェクトや関数へのポインタとも等しくならないことが保証されている。
null pointerを用いた間接参照は未定義動作になる (6.5.3.2 - 4)
この仕様によれば、*
演算子を用いた間接参照をするときに、その参照が「無効なもの」であるとき、*
演算子の挙動は未定義となる。
ここでいう「無効なもの」には、null pointerも含まれると注釈102に書かれている。
したがって、0番地に1を書き込もうとして *(uint8_t *)0 = 1;
と書いてもこれは未定義動作になる
これは、整数定数0をポインタにキャストしたもの(uint8_t *)0
がnull pointerであり(ここまでは定義された動作)、これへの間接参照が「null pointerを用いた間接参照」になるためである。
この、null pointerへの間接参照を回避するための方法として私が編み出したのがuint8_t *p = 1; p--; *p = v;
であるが、これも以下の仕様によって「処理系定義」の動作になる。
整数からポインタへの変換は処理系定義である (6.3.2.3 - 5)
この仕様によれば、整数からポインタへの変換を行ってもよいが、その結果は処理系定義になるという。
つまり、たとえアドレス1を表現しようとして(uint8_t *)1
と書いても、その内部表現が整数1と等しくなるとは保証されないということである。
したがって、uint8_t *p = 1; p--; *p = v;
と書いても、最初のuint8_t *p = 1;
の段階でpがどこを指しているかは処理系の動作に完全に依存し、1番地を指しているとは限らないため、私の提案した方法は残念ながら処理系定義の結果になる。
しかも、最適化によって、コンパイラは*p=v;
のタイミングでpがnull pointerになることを推測してしまう場合があるらしく、そうするとこれは未定義動作になってしまう。(これがherumiさんにご指摘いただいた部分。)
まじか、最適化の結果未定義動作を踏むこともあるのか、つらいな…。
じゃあどうすればいいのか
これは多くの人がすでに指摘している通りで、かつ私も同意する結果なのですが、
「処理系定義の動作に依存することなくメモリの0番地にC言語から読み書きをすることは不可能である」
が答えです。
これは、そもそもC言語には「メモリの何番地」に該当する概念がなく、ポインタへの整数値の代入も処理系定義であることが原因であるため、どうしようもありません。
なんとかできないの?
いくつかの点に目をつぶれば、まあ結果的に実現することは可能です。
*(uint8_t *)(0) = 1;
とその派生
これは、今まで説明してきた理由によれば未定義動作となるため、鼻から悪魔が出てきてもおかしくありません。早速Compiler Explorerでやってみましょう。
x86-64 gcc 9.2 | x86-64 clang 9.0.0 | |
---|---|---|
*(uint8_t *)(0) = 1; with -O0 |
OK | OK |
*(uint8_t *)(0) = 1; with -O3 |
NG(ud2) | NG(ud2) |
残念ながら悪魔は出てきませんでしたが、最適化を有効にするとやはりud2ですね。しかし、最適化を無効にすれば一応期待通りのコードが得られます。
と、ここで気になるWarningがclangから出力されていることに気付きました。
<source>:3:5: warning: indirection of non-volatile null pointer will be deleted, not trap [-Wnull-dereference] *(uint8_t *)(0) = 1; ^~~~~~~~~~~~~~~ <source>:3:5: note: consider using __builtin_trap() or qualifying pointer with 'volatile' 1 warning generated. Compiler returned: 0
ほう?volatileをつけると何か変わるんですかね。やってみましょう。
x86-64 gcc 9.2 | x86-64 clang 9.0.0 | |
---|---|---|
*(uint8_t * volatile)(0) = 1; with -O0 |
OK | OK |
*(uint8_t * volatile)(0) = 1; with -O3 |
NG(ud2) | NG(ud2) |
あれ、volatiileをつける場所、こっちじゃないの?
x86-64 gcc 9.2 | x86-64 clang 9.0.0 | |
---|---|---|
*(volatile uint8_t *)(0) = 1; with -O0 |
OK | OK |
*(volatile uint8_t *)(0) = 1; with -O3 |
NG(ud2) | OK |
なるほど、こうすれば、clangでは最適化を有効にしていても、volatile 修飾をすることで有効なコードを吐いてくれるようです。gccは規格通りだめみたいですが…。
uint8_t *p = 1; p--; *p = 1;
とその派生
この手法は、null pointer dereference が未定義動作になる挙動を回避した(つもりだった)ものです。(もちろん、この挙動は整数からポインタへの変換という処理系定義の動作に依存しています。)
x86-64 gcc 9.2 | x86-64 clang 9.0.0 | |
---|---|---|
uint8_t *p = 1; p--; *p = 1; with -O0 |
OK | OK |
uint8_t *p = 1; p--; *p = 1; with -O3 |
NG(ud2) | NG(ud2) |
しかし、最適化によって前半の結果pがnull pointerになることが推測されてしまっており、その結果ud2が生成されていますね。ということで、最適化を無効にするためにvolatile をつけてあげましょう。
x86-64 gcc 9.2 | x86-64 clang 9.0.0 | |
---|---|---|
uint8_t * volatile p = 1; p--; *p = 1; with -O0 |
OK | OK |
uint8_t * volatile p = 1; p--; *p = 1; with -O3 |
OK | OK |
volatile uint8_t * p = 1; p--; *p = 1; with -O3 |
NG(ud2) | OK |
おー、確かに動きはしますね。でもポインタ演算が入ってしまいますが…。
volatile修飾をポインタ変数ではなく、その指す値につけてみると、ポインタ演算に関する最適化は両者とも走るようになり、gccでは想定通りud2になったんですが、なんとclangでは最適化された結果である0番地への代入mov byte ptr [0], 1
が生成されました。
clangはvolatileがついてたらnull pointerのdereferenceも許容してくれるってことみたいですね。
追記: -fno-delete-null-pointer-checks
というフラグをコンパイル時につける
-fno-delete-null-pointer-checksだとgcc/clangともに*(char*)0 = 0;で期待する(?)コードがでました。dereferenceしてるのでその先のポインタはNULLじゃないとみなす? 本来int a = p->v; if(!p)...の後ろのチェックを省略するやつ(デフォルトon)なので意味的に逆な感じもするのですが。@hikalium
— herumi (@herumi) 2020年2月15日
clangとgccの両方で期待通り動くようです。gccのドキュメントにおける記載はここにあります。なるほどud2になる挙動は、「絶対に引っかかることがコンパイル時にわかっている(実行時に行われる可能性がある)null pointer checkを、コンパイル時にud2に置き換える」という最適化によるものなんですね。
ところで調べていたら、このコンパイルオプションが追加されたClangへのパッチを見つけました。曰く:
Support for this option is needed for building Linux kernel. This is a very frequently requested feature by kernel developers.
More details : https://lkml.org/lkml/2018/4/4/601
なるほど、やはりLinux Kernel開発者からの熱い要望があったんですね。(lkmlのリンクに飛ぶと、Linusの熱い言葉が見れるのでおすすめです。)入ったのが比較的最近(2018年中頃)というのも面白いですね。
(Thanks @herumi, @kazuho !)
追記: __attribute__((address_space(1)))
をポインタ変数につける(LLVM/clang限定)
id:uenoku さんのコメント曰く:
既出でしたら申し訳ないですが、clangならllvm::NullPointerIsDefinedをtrueにすることを考えれば良いので以下のようなコードがかけます https://godbolt.org/z/x5cXf4
Compiler Explorerでの結果 のうち、f1, f2は前述した-fno-delete-null-pointer-checks
によるもの、f3がこの手法です。
address_space(n)
というattributeは、そのポインタがどのアドレス空間のものかを指定するもので、デフォルトはaddress_space(0)
になっています。そしてこのAddress Spaceの値が、null pointerへの参照が定義された動作かを判定するLLVMの関数bool llvm::NullPointerIsDefined(const Function *F, unsigned AS)
@ lib/IR/Function.cpp に渡されます。この関数の実装を見てみると:
bool Function::nullPointerIsDefined() const { return getFnAttribute("null-pointer-is-valid") .getValueAsString() .equals("true"); } bool llvm::NullPointerIsDefined(const Function *F, unsigned AS) { if (F && F->nullPointerIsDefined()) return true; if (AS != 0) return true; return false; }
AS(address space)が0以外のとき、常にtrueを返すことがわかります。これで、null pointerへの参照が未定義動作ではないよとコンパイラに教えることができるわけです。なるほどー。
(Thanks id:uenoku !)
まとめ
- C言語はやはり難しい
- こんな記事を書いて時間をつぶしている場合ではなかった
- でも仕様書を読むのも楽しいのでおすすめ
参考文献
- (n1570.pdf)