答え
uint32_t
と宣言されたバッファにuint16_t *
でアクセスするのは危険!
uint32_t ReversedHighLow32(uint32_t n) {
uint32_t buffer;
return ReversedHighLow32Helper(n, &buffer, (uint16_t *)&buffer);
}
uint32_t ReversedHighLow32(uint32_t n) {
uint16_t high = n >> 16;
uint16_t low = n & 0xffff;
return (low << 16) | high;
}
解説
これは、コンパイラによる「最適化」に関する問題でした。最適化とは、プログラムをコンパイルする際にニーズに合わせてチューニングすることです。おもに、より高速に動作するようにしたり、メモリーの使用量を減らしたりすることが目的です。
開発している段階では、通常は最適化を実施しません。そのため、リリース後の動作が開発中と変わってしまうことも考えられるのです。
コンパイラは最適化のために、さまざまな方法でコードを変更します。といっても、動作の結果が変わってしまうような変更を加えようとするわけではありません。チューニングを施しながらも、同じ結果が得られるようにしてくれます。
ところが、プログラムの書き方によっては、最適化すると結果が変わってしまう場合があるのです。今回のプログラムでは、次の関数の結果(戻り値)が変わってしまう恐れがありました。
uint32_t ReversedHighLow32Helper(uint32_t n, uint32_t *pAs32, uint16_t *pAs16) {
……
}
この問題の原因は、関数の呼び出し元にあります。
uint32_t buffer;
return ReversedHighLow32Helper(n, &buffer, (uint16_t *)&buffer);
uint32_t
と宣言されたバッファのアドレスを、uint32_t *
とuint16_t *
の2つの型で引き渡している様子が分かるでしょうか。これらは互換性のない型なので、(uint16_t *)
とキャストして型を読み替えています。
エイリアスを用いること自体は、何も問題ありません。しかし、今回のプログラムでは型のパニングによって、uint32_t *
とuint16_t *
の2つのポインタが同じバッファを指していました。実は、コンパイラはこのような状況を想定していません。
最適化を実施するにあたって、コンパイラは「型が違う2つのポインタが同じ領域を指すことはないだろう」と考えていいルールになっています。そのため、このルールを無視して作られたプログラムは、最適化後に思わぬ動作をする恐れがあるのです。
では、ルールを無視すると、具体的に何が起こるのでしょうか?あらためて、問題の関数を確認してみましょう。引数n
には0x11112222
が与えられ、戻り値は0x22221111
になるというのが期待される結果です。
uint32_t ReversedHighLow32Helper(uint32_t n, uint32_t *pAs32, uint16_t *pAs16) {
/* 処理1:1つ目のポインタが指す領域にnの値を書き込む */
*pAs32 = n;
/* 処理2:2つ目のポインタで値を入れ替える */
uint16_t temp = pAs16[0];
pAs16[0] = pAs16[1];
pAs16[1] = temp;
/* 処理3:1つ目のポインタが指す領域から値を読み出す */
return *pAs32;
}
ここでは処理内容を3段階に分けて、コメントを付けました。2つのポインタpAs32
とpAs16
は型が異なりますね。そのため、コンパイラは2つのポインタが別々の領域を指していると想定しています。
すると、1つ目のポインタを使う「処理1」と「処理3」は、2つ目のポインタを使う「処理2」の影響をまったく受けないことになってしまいます。
そこで、「処理1」と「処理3」だけに注目してみましょう。
uint32_t ReversedHighLow32Helper(uint32_t n, uint32_t *pAs32, uint16_t *pAs16) {
/* 処理1:1つ目のポインタが指す領域にnの値を書き込む */
*pAs32 = n;
……
/* 処理3:1つ目のポインタが指す領域から値を読み出す */
return *pAs32;
}
「処理1」では、ポインタでn
の値を書き込んできます。そして、その値を「処理3」で読み出していますね。「処理2」が影響しないなら、ここで読み出される値はn
と同じに違いありません。
そこでコンパイラは、この関数を次のように解釈しても問題ないと判断します。n
と同じに決まっている値をわざわざポインタから読み出すのは無駄なので、その処理を削除して、代わりにn
を返すという最適化を行うのです。
uint32_t ReversedHighLow32Helper(uint32_t n, uint32_t *pAs32, uint16_t *pAs16) {
*pAs32 = n;
uint16_t temp = pAs16[0];
pAs16[0] = pAs16[1];
pAs16[1] = temp;
return n; /* ← 1つ目のポインタからの読み出しを削除! */
}
こうした変更が随所で行われることにより、プログラムは最適に動作するようになります。ただし、そのためには「型が違うポインタは同じ領域を指さない」というルールにしたがうことが前提です。今回のプログラムはルールを破ってしまったため、問題が発生したのでした。
コンパイラの最適化オプションを有効にした状態でテストを実施すれば、最適化に起因する問題は未然に防げます。でも、コンパイラ自体もバージョンアップすることを覚えておきましょう。今は期待どおりに動作しているプログラムでも、新しいコンパイラでコンパイルすると想定外の動作を引き起こすかもしれません。
先々のことまで考えるなら、可能な限りポインタのキャストを使わなくて済む方法を考えるのがおすすめです。今回のプログラムは「32ビットの値の上位16ビット、下位16ビットを入れ替える」というものでした。この操作は、次のようにビット演算で実現できますね。
uint32_t ReversedHighLow32(uint32_t n) {
uint16_t high = n >> 16; /* ← 上位16ビットを取り出す */
uint16_t low = n & 0xffff; /* ← 下位16ビットを取り出す */
return (low << 16) | high; /* ← 上位と下位を反対にしてくっつける */
}
または、もっと単純に割り算や掛け算を使ってもよいでしょう。
uint32_t ReversedHighLow32(uint32_t n) {
uint16_t high = n / 0x10000; /* ← 上位16ビットを取り出す */
uint16_t low = n % 0x10000; /* ← 下位16ビットを取り出す */
return low * 0x10000 + high; /* ← 上位と下位を反対にしてくっつける */
}
修正後のプログラム
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
uint32_t ReversedHighLow32(uint32_t n) {
uint16_t high = n >> 16;
uint16_t low = n & 0xffff;
return (low << 16) | high;
}
int main(void) {
uint32_t x = 0x11112222;
uint32_t y = ReversedHighLow32(x);
printf("x = 0x%08x\n", x);
printf("y = 0x%08x\n", y);
return EXIT_SUCCESS;
}
x = 0x11112222
y = 0x22221111