GAIO CLUB

2023年05月08日

【第7回】理解しづらいコーディングガイドラインとは? ~注意が必要な演算やライブラリ~

静的解析/コンパイラ技術
判り難いガイドライン紹介の最後に、注意が必要な演算やライブラリに関するお話をします。

整数型の暗黙的変換

暗黙的な型変換で気付きにくいのは、関数の戻り値や引数でしょう。
そして、これらについて、警告を出してくれるCコンパイラは少ないと思います。

たとえば、このような例です。
#include <stdio.h>
unsigned int UI = 0x89ABCDEF;
unsigned short test(unsigned short US)
{
  printf("          Test : 0x%X\n",US);
  printf("Test ---> Main : 0x%X\n",UI);
  return UI;	      /* unsigned int → unsigned short */
}
int main(void)
{
  unsigned int AUI;

  printf("Main ---> Test : 0x%X\n",UI);
  AUI = test(UI);	/* unsigned int → unsigned short */
  printf("          Main : 0x%X\n",AUI);
}
動作結果。
Main	--->  Test	: 0x89ABCDEF
              Test	: 0xCDEF
Test	--->  Main	: 0x89ABCDEF
              Main	: 0xCDEF
関数test()は、unsigned short型の引数を受け、unsigned short型の値を返します。
main()関数が渡した 4バイトの下位2バイトだけがtest()関数に伝わり、test()が返した4バイトの下位2バイトだけが、main()関数に戻ります。上位2バイトは消えてなくなります。

この例のように、両関数がすぐ近くに書かれていれば判りますが、遠く離れたところ、別ファイルに書かれていると気付かないことが多いはずです。

演算順序

演算子には優先順位がありますが、式の評価順が不明確なものもあります。
たとえば、2つの関数の戻り値を加算する式を書いたとき、どちらの関数が先に呼ばれるか、わかりません。

たとえば、以下の例です。
#include <stdio.h>
unsigned int U = 10;
unsigned int SUB1(void)
{
  U += 1;
  printf("SUB1 : %u\n",U);
  return U;
}
unsigned int SUB2(void)
{
  U -= 2;
  printf("SUB2 : %u\n",U);
  return U;
}
int main(void)
{
  unsigned int AU;	

  AU = SUB1() + SUB2();		/* どちらの関数が先に呼ばれるか? */
  printf("%u = SUB1() + SUB2()\n",AU);
  return 0;
}
動作結果。
SUB1 : 11
SUB2 : 9
20 = SUB1() + SUB2()
使用したコンパイラでは、SUB1()関数がSUB2()関数より先に動作しましたが、逆順で動作するコードを生成するコンパイラもあります。
もし、逆順に動作した場合、動作結果はこのようになります。
SUB2 : 8
SUB1 : 9
17 = SUB1() + SUB2()

シフト演算

シフト演算は、最も気を付けなければならない演算子です。というのも、シフト演算はCPUによって動作が異なる例が多いのです。

Cコンパイラは、シフト演算式について、当然ながら、CPUのシフト機械語に翻訳します。その機械語のシフト命令が加減乗除算のようにCPU間で統一していません。通常のシフト演算では問題が発生しにくいのですが、ビット長以上のシフトやマイナス値でシフトすると、CPUによって異なる動作をすることがよくあります。

定数式と変数式で結果が異なることもあります。定数式のシフトはコンパイラがコンパイル時に処理しますが、変数のシフトはCPUの機械語が処理するためです。例えば、1<<32の値は、コンパイラが0にするかもしれませんが、1<<AでAが32の時は、CPUのシフト命令が動作し、結果としてシフトされないことが起きる可能性があります。特に、組み込み用クロスCコンパイラのように、コンパイルする時のCPUと動作する時のCPUが異なる場合、面倒なことが起こるのです。

手元のPCでサンプルソースを動かしてみました。
#include <stdio.h>
  int I = 0x0FF000;
  int P8 = 8;
  int P64 = 64;
  int P65 = 65;
  int M8 = -8;
  int M64 = -64;
  int M65 = -65;
  int main(void)
  {
    printf("0x%08.8X : 0x%08.8X << %d\n",I<<P8,I,P8);
    printf("0x%08.8X : 0x%08.8X << %d\n",I<<P64,I,P64);
    printf("0x%08.8X : 0x%08.8X << %d\n",I<<P65,I,P65);
    printf("0x%08.8X : 0x%08.8X << %d(0x%X)\n",I<<M8,I,M8,M8);
    printf("0x%08.8X : 0x%08.8X << %d(0x%X)\n",I<<M64,I,M64,M64);
    printf("0x%08.8X : 0x%08.8X << %d(0x%X)\n",I<<M65,I,M65,M65);
    return 0;
  }
動作結果。
0x0FF00000 : 0x000FF000 << 8
0x000FF000 : 0x000FF000 << 64
0x001FE000 : 0x000FF000 << 65
0x00000000 : 0x000FF000 << -8(0xFFFFFFF8)
0x000FF000 : 0x000FF000 << -64(0xFFFFFFC0)
0x00000000 : 0x000FF000 << -65(0xFFFFFFBF)
インテルのCPUはこんな感じです。大きな値でシフトしても無駄なので、シフト値の下位数ビットだけを使うCPUは多いです。
ですから、64(0x40)ビットシフトしても変化しませんし、65(0x41)ビットシフトすると1ビットだけ動きます。

CPUのなかには、左シフト命令の機械語しかなく、マイナス値でシフトすると右へシフトするものもあります。
皆さんがお使いのCPUでも試してみて、知っておくと良いかもしれません。

ライブラリ

ガイドラインが使用禁止しているライブラリには、未定義動作を含むものがあります。
たとえば、以下のライブラリです。

・int atoi(const char *);
・long atol(const char *);
・double atof(const char *);

これらのライブラリは文字表記の数を数値に変換するものですが、数値化できない文字が渡された時の動作が未定義であることが理由です。エリアを破壊する恐れのある以下の文字列転送ライブラリも使用禁止です。

・char *strcpy(char *, char *);
・char *strcat(char *, char *);

他にも、使ってはいけないとされているライブラリは多く、特に組み込み系コンパイラがサポートしていない可能性のあるライブラリが目立ちます。<time.h>、<signal.h>、などに含まれるライブラリです。

使用禁止とされているライブラリについては、なぜ危険なのかということを知ることで、自作関数にも同様の問題があることに気付くかもしれません。

マクロ

offsetof

構造体の先頭からメンバ変数までのオフセットを思い込みで判断するのは危険です。

型毎のアラインメント制約を持つCPUは多く、コンパイラはこのアラインメント制約を守るためにメンバ変数間に空きエリアを確保することがあるためです。offsetofマクロを使うことで、コンパイラが変わっても正確なオフセットを求めることができます。

しかし、offsetofマクロに渡すメンバ変数名にビットフィールド名を書いてしまった時の動作は不定なので、このマクロの使用を禁止するガイドラインがあります。

また、そもそも、offsetofマクロで得たオフセット値を使って何をするのか、ということを問題視するガイドラインもあります。

ガイドラインのご紹介は今回で終わりです。
ありがとうございました。

筆者紹介

浅野 昌尚(あさの まさなお)

ガイオ・テクノロジー株式会社

開発2部 QTXグループ

1980年代から30年以上にわたり汎用構造のCコンパイラ開発に従事し、その間に8ビットマイコンからRISC・VLIW・画像処理プロセッサまで、さまざまなCPU向けのクロスCコンパイラを開発。

人気のコラム

最新のコラム