組み込みソフト開発101 ~ 設計

言い訳

設計について包括的にまとめようなんて思っているわけではありません。思いついたトピックのうち、設計に関するものをここに集めただけなので…。

ソフトウェアレイヤ

大規模なソフトウェアの構造を検討するときは、まず小さな機能ブロックやモジュールに分けてから、ブロック間の関係を考えたりモジュール内部の実装を考えていきます。ブロック間の関係は、利用する側と利用される側に分かれますが、図示するときは、利用する側を上に、利用される側を下に描くのが慣例となっています。下に行くほど機械に近く、上に行くほど人間に近いと考えることができます。つまり、ブロックの高さが抽象度の高さを表しているわけです。同じ高さにあるブロックをグループ化していくと、抽象度の階層が形成されますが、これをソフトウェアレイヤ(layer、層)と呼びます。

かなり大雑把ですが、組み込み機器のソフトウェアレイヤの一例を下図に示します。

ソフトウェアレイヤ

ある2つのレイヤに着目したとき、下のレイヤを下位レイヤ、上のレイヤを上位レイヤと呼びます。レイヤとレイヤの境界にあるのがレイヤのインターフェイスです。下位レイヤは上位レイヤに対してインターフェイスを提供します。インターフェイスは、関数や型、定数など、上位レイヤが下位レイヤを利用するために必要な全ての情報を含みます。アプリケーション向けのインターフェイスのことを特にAPI(Application Programming Interface)と呼びます。

レイヤ内で行う処理の複雑さはレイヤの厚みに例えられます。ほとんど何もしない薄皮のようなレイヤのことをラッパ(wrapper)層と呼びます。ラッパ層は、何かの理由で下位レイヤのAPIを直接使いたくない場合などに設けられ、下位レイヤと上位レイヤとの間でクッションのような役割を果たします。ラッパ層を挟んでおけば、下位レイヤの仕様が変更されたとき、うまくすればラッパ層を修正するだけで上位レイヤを変えずに済むかもしれません。こんなとき、下位レイヤの変更をラッパ層で「吸収する」と表現したりします。

One Fact, One Place

グローバル変数の宣言における、C言語のプリプロセッサを使ったトリックを紹介します。グローバル変数は悪ですが、組み込みソフトの世界では必要悪なので、このトリックにも利用価値があるかもしれません。

めざすのは、宣言を1箇所にまとめることです。何も工夫しない場合、下記のように2箇所に分散してしまうはずです。これだと、もしmylibCounterの型をunsigned longに変えたい場合、2箇所を修正する必要があります。

mylib.h
extern int mylibCounter;   // 1箇所目(外部参照用の宣言)。
mylib.c
int mylibCounter = 0;      // 2箇所目(実体の定義)。
user.c
#include "mylib.h"

void user(void)
{
    if ( mylibCounter > 10 ) {
        ....

これに対し、次の例では、宣言が1箇所にまとまっています。プリプロセッサを通せば前の例と同じコードが生成されるはずです。

mylib.h
#ifdef MYLIB_IMPLEMENT
#define EXTERN
#else
#define EXTERN extern
#endif

EXTERN int mylibCounter;   // 1箇所目。
mylib.c
#define MYLIB_IMPLEMENT
#include "mylib.h"
user.c
#include "mylib.h"

void user(void)
{
    if ( mylibCounter > 10 ) {
        ....

再帰呼び出し

再帰呼び出し(recursive call)とは、ある関数が(直接、あるいは間接的に)自関数を呼ぶことです。例として、階乗を計算する関数を、再帰を使って書いてみます。

int factorial(int n) {    // nは自然数とする。
    if ( n == 1 ) return 1;
    return factorial(n - 1) * n;
}

例えばfactorial(3)を呼ぶと、その内部でfactorial(2)が呼ばれ、さらにその内部でfactorial(1)が呼ばれます。factorial(1)は1をreturnし、factorial(2)は2(=1*2)をreturnし、factorial(3)は6(=2*3)をreturnします。

再帰はクールな手法ですが、欠点もあります。例えば、ちょっと間違うと無限に関数コールが続いてしまう危険があります。また、再帰が何段階で止まるか予想がつかない(実行してみないと分からない)のも困ります。なぜなら、再帰呼び出しが深くなるほどスタックの消費量も増えるからです。特に組み込みソフトでは、スタックサイズを静的に見積もって実行前に決めなければならないケースが多いと思います。そんなわけで、組み込みソフトではあまり再帰は使わないかもしれません。しかし、重要なプログラミング技法の1つであることは間違いありません(Joel Spolskyの記事によれば、再帰はプログラマの適正を計る尺度と言えそうです)。

マルチスレッド

プログラム(特に関数)が、マルチスレッド(あるいはマルチタスク)環境下で正しく動作するように作られている場合、そのプログラムはスレッドセーフであると言えます。逆に、特定のスレッド(タスク)からしか呼ばれないことを前提に作られたプログラムはスレッドセーフとは言えません。

スレッドセーフでないプログラムをスレッドセーフに作り変えるのは大変なので、設計段階のうちにスレッドセーフの要否を明確にした方が良いでしょう。とは言え、実際はあとから気付くことも多いし、意思に反してスレッドセーフ対応に漏れが生じることもあります。

スレッドセーフが破綻するケースの代表例が関数への再入です。再入とは、あるスレッド上である関数を実行している最中に、別のスレッド上で同じ関数が呼ばれる現象です。再帰と似てますが、再入と再帰は別物です。スレッドセーフでない関数ライブラリの例を挙げます。

static int handle;

void doSomething(void)
{
    openHandle();

    // handleを使って、何かする。
    ...

    closeHandle();
}

static void openHandle(void)
{
    handle = ...;    // 新しいハンドル値。
}

static void closeHandle(void)
{
    ...
}

この関数ライブラリは、ハンドルをstaticな変数へ保管するようになっています。doSomething()を特定のスレッドのみから呼ぶのであれば、オープンされるハンドルは高々1つだけなので問題はありません。しかし、複数のスレッドからdoSomething()への再入が発生すると、同時に複数のハンドルがオープンされることになります。後からオープンしたハンドルの値によって、先にオープンしたハンドル値が上書きされてしまいます。

このようなケースでは、static変数を使わず、ローカル変数を使えばうまくいきます。

void doSomething(void)
{
    int handle = openHandle();

    // handleを使って、何かする。
    ...

    closeHandle(handle);
}

static int openHandle(void)
{
    return ...;    // 新しいハンドル値。
}

static void closeHandle(int h)
{
    ...
}

もう一つ、スレッドセーフが破綻する例を挙げます。この関数ライブラリには、doSomething()とdoSomethingElse()の2つのAPI関数があります。

static int initialized = 0;

void doSomething(void)
{
    if ( !initialized ) {
        initialized = 1;
        initialize();
    }

    // 何かする。
    ...
}

void doSomethingElse(void)
{
    if ( !initialized ) {
        initialized = 1;
        initialize();
    }

    // 何かする。
    ...
}

static void initialize(void)
{
    ...
}

ここでは、初期化済みかどうかを管理するのにinitializedというstatic変数を使っており、どちらかのAPI関数が初めて呼ばれたときのみ初期化処理が走るようにしています。しかしこれも、マルチスレッド環境下では正しく動きません。

未初期化の状態で、あるスレッドがdoSomething()を呼ぶと、if文に入り、変数initializedに1を代入しようとします。しかし正にそのとき、別のスレッドがdoSomethingElse()を呼ぶかもしれません。まだinitializedは0のままなので、ここでもif文に入ります。結果的に、initialize()が2回呼ばれてしまいます。

このようなケースでは、排他制御を使います。排他制御とは、コードの特定の範囲が排他的に実行されるように制御することです。排他制御の具体的な方法は言語やOSに依存しますがセマフォ、ミューテックス、クリティカルセクションといった名前の機能で提供されます。C言語には標準的な排他制御の仕組みが無いので、OSが提供する機能を使うことになります。ここでは、μITRONのセマフォを使った例を示します。

static int initialized = 0;

void doSomething(void)
{
    wai_sem(1);
    if ( !initialized ) {
        initialized = 1;
        initialize();
    }
    sig_sem(1);

    // 何かする。
    ...
}

void doSomethingElse(void)
{
    wai_sem(1);
    if ( !initialized ) {
        initialized = 1;
        initialize();
    }
    sig_sem(1);

    // 何かする。
    ...
}

static void initialize(void)
{
    ...
}

wai_sem()とsig_sem()で囲まれた範囲が排他区間です。引数にはセマフォIDを指定しますが、これは、排他区間を識別する番号と考えて下さい。IDが同じ排他区間が同時に実行されないよう、OSが調整してくれます。この例では、2つの排他区間に同じセマフォIDを使っているので、あるスレッドがdoSomething()のif文を実行している最中に、もし別のスレッドがdoSomethingElse()を実行したとしても、後のスレッドはwai_sem()を呼んだ時点で待たされます。先のスレッドがsig_sem()を実行すれば待ち状態が解除されますが、そのころには変数initializedが1に変わっているので、if文には入らないはずです。このようにして二重初期化を回避することができます。

Last modified:2011/05/19 19:12:43
Keyword(s):
References:[組み込みソフト開発101] [組み込みソフト開発101 ~ C言語]
This page is frozen.