組み込みソフト開発101 ~ 処理系

処理系・クロス環境

処理系とは、ソースコードを「処理」するツールや環境のことです。例えばコンパイラですね。コンパイラは、ソースファイル(*.c/*.cpp)を処理してオブジェクトファイル(*.obj/*.o)を出力します。他にも色々あるので表にまとめてみます。

ツール入力ファイル出力ファイル処理
コンパイラソース(*.c/*.cpp)やヘッダ(*.h)オブジェクト(*.obj/*.o)機械語に翻訳。
ライブラリアンオブジェクト(*.obj/*.o)ライブラリ(*.lib/*.a)オブジェクトを連結。
リンカオブジェクトやライブラリ実行ファイル(*.exe/*.elf)最終ターゲットの生成。

例えばマイクロソフトのVisual C++は、C/C++言語の処理系です。この場合、Windowsパソコン上で動作するコンパイラが、Windowsパソコン上で動作するソフトをコンパイルします。ここで、コンパイラが動作する環境をホストと呼び、コンパイルされたソフトが動作する環境をターゲットと呼びます。Visual C++の例では、ホストもターゲットもWindowsパソコン(x86系CPU)です。

処理系

組み込みソフト開発の場合、普通は、ホストとターゲットが異なります。例えば携帯電話なら、携帯電話のCPUはARMで、それを開発するマシンはWindowsパソコンだったりします。この場合、ホストはWindowsパソコン(x86系CPU)で、ターゲットはARMです。このように、ホストとターゲットが異なる開発環境をクロス環境と呼びます。

クロス環境

プリプロセッサ

C/C++言語のソースコードをコンパイルする前に行われる処理を前処理と呼びます。前処理を行うツールがプリプロセッサです。プリプロセッサは、通常はコンパイラと一体化されているので、意識的に実行する必要はありません。 プリプロセッサ向けの命令(ディレクティブ)は、#で始まります。#includeとか#defineとかです。またソースコードのコメントもプリプロセッサの処理対象です(つまりプリプロセッサがコメントを削除する)。

組み込みソフト開発では、#pragmaという命令を見かける頻度が割と高いです。#includeや#defineの挙動はどの処理系でも共通ですが、#pragmaの仕様は処理系ごとに異なります。例えば、warningの抑止、構造体のアライメント制御、変数のメモリマップ指定などに使われるようです。

定数やマクロを定義するときに#defineを使いますが、特にマクロの方はバグの温床になりやすいので注意しましょう。ありがちな誤用例を挙げます。

例1
#define DIV_BY_TEN(x) (x / 10)

int foo()
{
    return DIV_BY_TEN(10 - 10);  // 0を期待。実際は、(10 - 10 / 10)となって9が返る。
                                 // #define DIV_BY_TEN(x) ((x) / 10) とすべき。
}
例2
#define SQUARE(x) if ( (x) > 1 ) { return (x) * (x); }

int bar()
{
    int i = 5;
    SQUARE(i++);          // if ( (i++) > 1 ) { return (i++) * (i++); }で42をreturn。
                          // SQUARE(i); i++; とすべき。
    return i;
}

コンパイラ

コンパイルオプション

コンパイラの基本的な役割は、プログラミング言語で書かれたソースファイルを、ターゲットCPU向けの命令(機械語)に変換(翻訳)することです。ソースファイルが人間が読めるテキストファイルなのに対して、コンパイル後の機械語は人間には読めないバイナリファイルなので(中には機械語を読める人間もいますが…)、コンパイル後のデータのことを単に「バイナリ」と呼ぶ場合があります。また実行イメージと呼ぶこともあります。

自然言語の場合、例えば1つの英文をいろんな和文へ翻訳できますが、コンピュータ言語においても、機械語への翻訳のしかたは何通りも考えられます。翻訳のしかたをチューニングするのがコンパイルオプションです。以下、代表的なチューニング種別について説明します。

最適化

翻訳結果に最も大きな影響を与えるのが最適化方法でしょう。最適化しない場合、コンパイラは、たとえソースファイルに無駄な処理が入っていたり、非効率なロジック分岐があっても、忠実に機械語へ翻訳します。最適化オプションを有効にすると、無駄を省き、多少ソースのフローをねじ曲げてでも効率の良い機械語へ翻訳します(もちろん、最終的なプログラムの動きを変えない範囲で)。最適化には、変換後のプログラムサイズの小ささを優先するアプローチと、プログラムの動作速度の速さを優先するアプローチがあります。

例えば、以下のソースファイルを見てみましょう。global_flagはグローバル変数で、普段は1、whileループを止めたいときに誰かが0に変えると想像して下さい。

int func(int val)
{
    int a = 1;
    while ( a > 0 && global_flag ) {
        if ( val <= 0 ) return -1;
        else if ( val < 10 ) return 1;
        else if ( val < 20 ) return 2;

        sleep(val);
    }
    return -1;
}

まず変数aですが、これは1に初期化されたあと変更されません。よってwhile条件の a > 0 は常に真になります。こういった無駄な論理演算は最適化により削除されます。変数a用のスタック領域も確保されません。

また引数val用の記憶領域はスタックにありますが、参照するたびにスタックから読むのは非効率なので、関数の先頭でスタックから汎用レジスタ(BXとかCXとか)に読み込み、以降はそのレジスタを参照するような機械語に翻訳されると思います。汎用レジスタをキャッシュとして使うわけです(RAMより汎用レジスタの方がアクセスが速い)。

この関数には-1をreturnする処理が2箇所あります。最適化後は、この処理が1箇所にまとめられ、そこへジャンプするようなフローになると思います。

最適化の副作用

最適化では、プログラムの本質的な意味を変えることなく翻訳をチューニングするわけですが、何をもって「本質的」とするかの基準が常にコンパイラとプログラマとで一致するとは限りません。まわりくどい言い方ですが。まぁ、要するに、最適化が副作用を生む場合があるということです。その例を3つ挙げます。

  • デバッガのトレース
  • volatile
  • register

1つ目はデバッガのトレースです。デバッガでソースコードを見ながら1ステップずつ実行することをトレースと呼びます。前述の通り、最適化によりプログラムのフローが変わる可能性があるので、トレース時に戸惑うケースが生じます。例えば上記の例では、-1をreturnする処理が1箇所(関数の最後の方)にまとめられます。valが0の場合、最初のif文でステップ実行すると、関数は終わらずに最終行のreturn文にジャンプするでしょう。なぜなら、実行されているのはソースプログラムではなく、あくまでも翻訳された機械語プログラムの方だからです。

2つ目はC言語のキーワードvolatileに関係する話しです。上記の例では、変数aが不変のため、while文の条件から削除されました。しかし、もし、aのアドレスを誰か(どこか別スレッドの関数)に渡しておき、ポインタ経由でaの値を変えるようにしたい場合はどうでしょう。コンパイラは、aの値が変わる可能性があることに気付いてくれるでしょうか? たぶん無理でしょう。そこで、はっきりと、変わる可能性があることをコンパイラに教えておいた方が無難です。それがvolatileです。

int func(int val)
{
    volatile int a = 1;
    while ( a > 0 && global_flag ) {
      ...

最後はregisterです。これは最適化の副作用というより最適化の促進の話しなのですが、volatileとペアみたいなものなので説明しておきます。上記の例で、引数valを汎用レジスタにキャッシュするという話しをしました。registerは、キャッシュすべきデータの候補をコンパイラに教えるためのキーワードです。ただし、これはコンパイラへのヒントに過ぎないので、register宣言された変数が必ず汎用レジスタにキャッシュされるとは限りません(汎用レジスタの数は限られてますし)。むしろ、register宣言を無視するコンパイラの方が多いと思います。なので、実際に使う場面は無いと思います。

アライメント

アライメントとは、データをメモリに配置するときのアドレスの決め方のことです。

プログラムがRAM上の2バイト以上のデータを読み書きするとき、そのデータが奇数番地に置かれている場合と偶数番地に置かれている場合とで読み書き効率が異なります。2バイトのデータなら偶数番地、4バイトのデータなら4の倍数番地に置くのが最も効率的に読み書きできます。コンパイラやリンカは、それを踏まえてshortやlong型の変数を配置するアドレスを調整します。

単独の変数のアライメントは簡単に決まるのですが、構造体の場合は少し悩みます。以下の例を見て下さい。

typedef struct {
  char a;
  long b;
  char c;
  short d;
} T;

static T data;

変数dataを0x80004000番地に置くとして、dataの各メンバのアライメントパターンを図示してみます。

アライメント

パターンAは読み書き効率を重視したアライメントです。4バイトのdや2バイトのdも効率良く読み書きできます。ただし、メンバの間に未使用の領域があるため、正味8バイトのdataのサイズが実質12バイトに膨らんでしまいます。パターンBはパターンAとは逆で、読み書き効率を犠牲にしてコンパクトさを重視したアライメントです。

コンパイルオプションを使えばアライメントをチューニングできますが、これには発見しにくいバグを生むリスクがあります。よく起きるのが、アライメント方針の食い違いです。例えば、上記の構造体Tをヘッダファイルで定義しておき、複数の開発チームがそのヘッダファイルを共用するとしましょう。ある開発チームがアライメントパターンAを採用し、別の開発チームがパターンBを採用してしまうと、同じ構造体にもかかわらず、メンバ変数に正しくアクセスできません。

こういった齟齬をなるべく回避するため、アライメントオプションに影響されないように構造体定義を工夫するのが一般的です。例を示します。

typedef struct {    // メンバの順序を変える。
  long b;
  short d;
  char a;
  char c;
} T1;

typedef struct {    // ダミーのメンバを追加する。
  char a;
  char reserved1[3];
  long b;
  char c;
  char reserved2[1];
  short d;
} T2;

エンディアン、バイトオーダー

エンディアンとは、2バイト以上のデータをメモリに配置するときの、(データ内部の)順番のことです。図を見た方が分かりやすいでしょう。次の2つのデータを例に説明します。

unsigned long a = 0x01020304;
unsigned short b = 0xff88;

エンディアンには2種類の方法があります。

エンディアン

ビッグエンディアンは、データの上位バイトを先に(若い番地に)配置します。リトルエンディアンは、その逆ですね。それぞれにメリット・デメリットがあったと思いますが忘れてしまいました。

通常、エンディアンはCPUの種類によって決まります。インテルのCPUはリトルエンディアンを採用しています。モトローラのCPUはビッグエンディアンなので、MacがモトローラのCPUを使っていた時代には、WindowsとMacの対立がエンディアンの優劣論争にも飛び火していたように思います。

組み込み機器に採用されることが多いARMプロセッサでは、任意のエンディアンを採用することができます。なので、ARM用のコンパイラには、エンディアンを指定するコンパイルオプションがあります(たぶん)。

Warningレベル

コンパイラのWarningメッセージは(重要度の)レベルで分類されており、どのレベルのWarningを出力するかをコンパイルオプションで指定することができます。開発チームの方針にもよりますが、Warningレベルは最高に設定し(つまり軽微なWarningでも出力させ)、それでもWarningが出ないようにソースコードを書くのが理想でしょう。

日本人の場合、Warningを「ワーニング」と発音する人が大多数でしょう。私はあるときから少数派に転じ、「ウォーニング」と発音するようになりました。どこかで、『じゃぁStar Warsは「スター・ワーズ」かよ?』という記事を読んだのがキッカケで。まぁ、どっちでもいいんですけどね。

デバッグシンボルの生成

コンパイラの本分はソースから機械語への翻訳ですが、それに加えてデバッグシンボルの生成も大事な役割の1つです。デバッグシンボルはデバッガ用の様々な情報のことです。例えば、ソースコードの各行と翻訳後の命令のアドレスとの対応や、ソースの変数とそれが配置されたRAMのアドレスとの対応などを含みます。デバッガを使うときに、ソースコードの特定の行でブレークをかけたり、変数の内容を参照することができるのは、デバッグシンボルがあるおかげなのです。

デバッグシンボルは、オブジェクトファイルの中に含める場合と、専用のファイルに含める場合があります。その辺は、コンパイラやコンパイルオプションに依存します。

ライブラリアン、アーカイバ

コンパイラが生成した複数のオブジェクトファイルを1つのライブラリファイルとして連結するのがライブラリアンの仕事です。アーカイバと呼ぶこともあるようです。単に連結するだけなので、たいしたオプションも無いと思います。

リンカ

オブジェクトファイルやライブラリファイルをまとめて、OSが実行できる形式のファイル(実行ファイル)やROMに焼き込める形式のファイル(実行イメージ)を生成するのがリンカの役割です。リンカには、メモリマップ(スタックやヒープをRAMのどこへ配置するか)に関する情報を入力する必要があります。メモリマップに関しては別項「ハードウェア」を参照して下さい。

C標準ライブラリ(libc)

malloc()やfree()、assert()などは、C標準ライブラリの関数です。標準というくらいなので、どんな環境でも使えると期待できます。この標準ライブラリを提供するのは処理系の役割です。例えばVisual C++を買えば、標準ライブラリも付いてきます。

またプログラムを起動するには、静的変数を初期化したりスタックやヒープをセットアップしてからmain()を呼び出す処理が必要です。こういった処理を行うのがstartupルーチンと呼ばれる関数ですが、これも処理系が提供します。

たぶん標準ライブラリやstartupルーチンを実装するには処理系の内部仕様に関する情報が必要なので、処理系とセットにする方が好都合なのでしょう。

Last modified:2011/04/30 13:27:13
Keyword(s):
References:[組み込みソフト開発101] [組み込みソフト開発101 ~ C言語]
This page is frozen.