第25問の答え

答え

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 *)とキャストして型を読み替えています。

こういうキャストって、C言語ではよく見かけますよね。
そうね。でも、「データには型がある」っていう発想を、根本から破壊する手法ともいえるわね。
うむむ。たしかに……
ちなみに、こういう手法は「型のパニング(type punning)」って呼ばれているわ。
「punning」は「語呂合わせ」っていう意味ですね!
あとね、2つのポインタが同じバッファを指しているでしょ?
はい。
そういう状況のことを「エイリアス(alias)」というの。
なるほど、「別名」っていう意味ですね。

エイリアスを用いること自体は、何も問題ありません。しかし、今回のプログラムでは型のパニングによって、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つのポインタpAs32pAs16は型が異なりますね。そのため、コンパイラは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つ目のポインタからの読み出しを削除! */
}

こうした変更が随所で行われることにより、プログラムは最適に動作するようになります。ただし、そのためには「型が違うポインタは同じ領域を指さない」というルールにしたがうことが前提です。今回のプログラムはルールを破ってしまったため、問題が発生したのでした。

ちなみにね、このルールのことを「Strict Aliasing Rule」というわ。直訳すると「厳密なエイリアスのルール」という意味ね。
うー、今回は難しい用語が多いですね。
まぁ、用語は暗記しなくてもいいのだけど。
そうなんですか?
その代わり、もっとシンプルな2つのポイントを押さえておくといいわね。
わくわく。
1つ目は、最適化された状態でプログラムの動作確認をすること。
そっか、それでいいんですね!正直、難しくてどうしようかと思ってましたよ。
ふふふ。結局はテストが大切ってことね。
ですねー。それじゃあ、2つ目は何です?
できる限り、ポインタのキャストを使わないことね。

コンパイラの最適化オプションを有効にした状態でテストを実施すれば、最適化に起因する問題は未然に防げます。でも、コンパイラ自体もバージョンアップすることを覚えておきましょう。今は期待どおりに動作しているプログラムでも、新しいコンパイラでコンパイルすると想定外の動作を引き起こすかもしれません。

先々のことまで考えるなら、可能な限りポインタのキャストを使わなくて済む方法を考えるのがおすすめです。今回のプログラムは「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; /* ← 上位と下位を反対にしてくっつける */
}
同じことをするプログラムでも、いろんな書き方があるんですねぇ。
でしょ。どうせならシンプルに考えたほうが、難しい問題も減らせるんじゃないかしら。
そうですねー!
ここがポイント!
プログラムのテストはコンパイラの最適化を有効にした状態で実施しよう!

修正後のプログラム

main.c
#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