Cのエラーコード設計

ある関数はbool型を返し、ある関数は-1を返し、ある関数は0を失敗として返す。プロジェクトが育つと、エラーの流儀がモジュールごとにばらばらになりがちです。C言語には例外機構がないため、エラー処理の品質は、戻り値(および出力引数)の設計に大きく左右されます。本記事では、エラーコードの体系の定石と、エラーをどの層で対処するかという伝搬のルール、組み込みならではの運用までを解説します。

なぜ「体系」が必要なのか

エラー処理の最大の敵はばらつきです。関数ごとに成功・失敗の表現が違うと、呼び出し側の書き方が安定せず、チェック漏れや判定ミスが混入します。SEI CERT C Coding Standard(JPCERT/CCが日本語版を公開しているコーディング標準。以下、CERT C コーディングスタンダード)も、レコメンデーションとして「エラー処理には一貫性のある方針を採用する」(ERR00-C)ことを挙げています。体系をプロジェクト開始時に1つ決めておくことが、以後の全コードの品質を底上げします。

定石:0が成功、負がエラーのenum一本

typedef enum {
    ERR_OK        = 0,    /* 成功                 */
    ERR_PARAM     = -1,   /* 引数不正             */
    ERR_TIMEOUT   = -2,   /* 応答なし             */
    ERR_BUSY      = -3,   /* リソース使用中       */
    ERR_CRC       = -4,   /* データ化け           */
    ERR_NOT_READY = -5,   /* 初期化前に呼ばれた   */
} err_t;
err_t sensor_read(uint16_t *out_value);  /* データは出力引数で */
/* 呼び出し側はいつも同じ形になる */
uint16_t value;
err_t err = sensor_read(&value);
if (err != ERR_OK) {
    log_error("sensor_read", (int)err);  /* enumをintへキャスト。保存時はint32_t等に正規化 */
    return err;                          /* 上位へそのまま伝搬 */
}

この体系の利点は、すべての関数で「ERR_OKと比較するだけ」という同じ形が成立することです。正の値を「成功+付加情報」(受信したバイト数など)に割り当てる流儀もあり、負=エラーの原則と両立します。POSIXでも read() のように、成功時は読み取ったバイト数(0はEOFなど)を返し、失敗時は-1を返してerrnoが設定される設計が広く使われています。errnoは、戻り値でエラーを検知した場合に限り参照します。この記事の例はerr_t固定(0または負の値のenum)を前提に説明しており、受信バイト数のように成功時に整数値を返す必要があるAPIは、戻り値をssize_t型などにした別の設計にします。なお、Cの列挙型(enum)の表現サイズ(格納サイズ)は処理系依存(実装定義)になり得ます。ABI(Application Binary Interface)境界をまたぐインターフェースや、不揮発性メモリにエラーログを保存する用途では、err_t をそのまま書き出さず、格納用に int32_t などサイズの決まった別型(err_store_t など)を定義し、変換時に範囲チェックをしたうえで、仕様上のサイズを明記しておくと事故を防げます。

重要なのが、データはエラーコードに混ぜず出力引数で返すことです。CERT C コーディングスタンダードのレコメンデーションにも「帯域外エラーインジケータと正常な戻り値を混同しない」(ERR02-C)があります。これは、同一の戻り値に正常値とエラー値を混在させる設計(インバンドエラー、in-band error)を避けるべきだという趣旨です。「-1はエラー、それ以外は測定値」のような設計は、測定値が-1になり得た瞬間に破綻します。値はエラーコードに混ぜず、出力引数や構造体で返すか、POSIXのread()のように成功時の戻り値を正の値(バイト数など)として使う設計にします。あわせて出力引数の扱いも仕様として決めておきます。出力引数はNULL不可とし、NULLが渡された場合はERR_PARAMを返す、という規約にしておくと呼び出し側の書き方が安定します。

エラー名を取得できるようにする

エラーコードは数値のままログに出すと、現場で「-3とはどのようなエラーか」と混乱を招きます。コードと名前の対応テーブルを1つ用意し、ログには名前を出します。出荷後の機器ではエラーログを不揮発性領域に残す設計と組み合わせると、返品解析の効率が大きく変わります。

const char *err_name(err_t e)
{
    switch (e) {
    case ERR_OK:        return "OK";
    case ERR_PARAM:     return "PARAM";
    case ERR_TIMEOUT:   return "TIMEOUT";
    case ERR_BUSY:      return "BUSY";
    case ERR_CRC:       return "CRC";
    case ERR_NOT_READY: return "NOT_READY";
    default:            return "UNKNOWN";
    }
}

伝搬のルール:検知は下位、対処は上位

エラー処理の設計でもう1つ決めるべきは「どの層が何をするか」です。原則は、下位(ドライバ・ライブラリ層)はエラーを検知して返すだけ、リトライするか、諦めるか、安全停止するかの判断は、文脈を知っている上位(アプリ層)がおこなう、という分担です。下位が勝手にリトライや初期化を始めると、上位から見えない時間遅れや副作用が生まれます。あわせて守るべきは「握りつぶし禁止」です。CERT C コーディングスタンダードのERR33-C(標準ライブラリ関数のエラーを検出し対処する)で述べられる「戻り値の確認」の考え方は、自作APIやHAL(Hardware Abstraction Layer)の戻り値にも同様に適用すべきものです。戻り値を捨てている呼び出しは、静的解析やコンパイラ警告で機械的に洗い出せます。

  • 回復可能なエラー(タイムアウト・BUSY):上位がリトライ方針を決める(「通信におけるI/Oの堅牢化とタイムアウト設計」参照)。
  • 回復不能な状態(あり得ない引数・内部矛盾):エラーコードで返し続けるのではなく、アサートで開発中に即検出します。製品では、ログ保存→安全側出力→必要に応じてリセット(または縮退運転)など、プロダクト方針に従って安全状態へ移行します。

モジュールが増えたら:レンジ分けと境界での変換

規模が大きくなったら、エラーコードにモジュール別のレンジを割り当てると(例:-100番台はフラッシュメモリ、-200番台は通信)、コードを見ただけで発生源の見当がつきます。また、ベンダHALやミドルウェアは独自のエラー型を持っているので、自分のモジュールの境界でアプリ共通のerr_tへ変換し、外へは漏らさないようにします。層をまたいで他のモジュールのエラー型が貫通する設計は、依存関係を硬直させる典型です。

アンチパターン

  • すべてbool型で返す:失敗の理由が消えるため、ログを見ても原因にたどり着けません。
  • 「とりあえず-1」:異なる原因が同じコードに集約されてしまい、エラーコードの意味がなくなります。
  • グローバルなerrno風自作変数:マルチタスク・割り込みと相性が悪く、上書き競合します。戻り値で返すのが基本です(標準のerrno(エラー原因を示すグローバルな診断情報)も、戻り値でエラーを検知した後に原因を特定する用途が本来の使い方です)。

まとめ

Cのエラー処理は「0成功・負エラーのenum+出力引数」「名前テーブルでログに残す」「検知は下位・対処は上位」「握りつぶし禁止」の4点をプロジェクト初日に決めるのが最大のコツです。体系は凝る必要はなく、全員が同じ形で書けることに価値があります。

組み込みソフトの世界 トップへ戻る